Commit 3725c9ad by luoqi

feat(web): 话术 toolbar 选择/触发分离 + 原文流式展开 + 分档 loading 骨架

- RegenBtn:[档位▾][模型▾][重新生成] —— 两下拉只选不触发,重新生成移右为唯一触发口。
- 原文视图:默认展开语义(只记主动折叠)→ 流式时新段(s0/s1…)也展开,不再折叠。
- loading 骨架分档:稳健 4 段固定标题 / 标准 4 段无标题 / 深度 1 段(段数不定)。
- AIStamp 精简(去图标+label,只留相对时间)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent c29f18c7
......@@ -414,15 +414,13 @@ export function PlanDetailApp({
streaming={isStreaming}
model={scriptModel}
tier={scriptTier}
onSelectModel={setScriptModel}
onSelectTier={setScriptTier}
onStop={() => {
abort();
showToast('slate', '已停止', '本次 AI 生成被中断');
}}
onRegen={(m, t) => {
setScriptModel(m);
setScriptTier(t);
void regenerate(plan.id, { model: m, tier: t });
}}
onRegen={() => void regenerate(plan.id, { model: scriptModel, tier: scriptTier })}
/>
{/* AI 时间戳 — 窄屏隐藏;未生成话术时不显示(无 generatedAt) */}
<span className="hidden md:inline-flex">
......@@ -1681,47 +1679,53 @@ const SCRIPT_TIERS: { key: ScriptTier; label: string }[] = [
];
// ──────────────────────────────────────────
// RegenBtn — 流式重新生成拆分按钮
// - 主键:空闲态点击触发 SSE 生成(用当前选中模型);流式态变"停止",可中断
// - 右侧 caret:DropdownMenu 选模型(DeepSeek / Gemini),选中即用该模型重新生成
// RegenBtn — 档位/模型选择 + 重新生成(下拉只选,按钮才触发)
// - 左:档位下拉(稳健/标准/深度)—— 选中只更新选择,不立即生成
// - 中:模型下拉(DeepSeek / Gemini)—— 同上
// - 右:重新生成按钮(唯一触发口,用当前选中档位+模型起 SSE);流式态变"停止"可中断
// ──────────────────────────────────────────
function RegenBtn({
streaming,
model,
tier,
onSelectModel,
onSelectTier,
onStop,
onRegen,
}: {
streaming: boolean;
model: ScriptModel;
tier: ScriptTier;
onSelectModel: (model: ScriptModel) => void;
onSelectTier: (tier: ScriptTier) => void;
onStop: () => void;
onRegen: (model: ScriptModel, tier: ScriptTier) => void;
onRegen: () => void;
}) {
const current = SCRIPT_MODELS.find((m) => m.key === model) ?? SCRIPT_MODELS[0]!;
const currentTier = SCRIPT_TIERS.find((t) => t.key === tier) ?? SCRIPT_TIERS[0]!;
const selectorCls = cn(
'inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10.5px] border-r transition-colors',
streaming
? 'text-slate-300 border-slate-100 cursor-not-allowed'
: 'text-slate-500 border-slate-200 hover:text-indigo-700 hover:bg-indigo-50',
);
return (
<div className="inline-flex flex-none items-stretch rounded overflow-hidden">
{/* 档位选择 caret(最左) */}
<div className="inline-flex flex-none items-stretch rounded border border-slate-200 overflow-hidden">
{/* 档位选择(只选,不触发) */}
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={streaming}>
<button
type="button"
title="选择投入档(稳健 / 标准)"
title="选择投入档(稳健 / 标准 / 深度)— 选中不会立即生成"
disabled={streaming}
className={cn(
'inline-flex items-center gap-0.5 px-1 py-0.5 text-[10.5px] border-r transition-colors',
streaming
? 'text-rose-300 border-rose-100 cursor-not-allowed'
: 'text-slate-400 border-slate-200 hover:text-indigo-700 hover:bg-indigo-50',
)}
className={selectorCls}
>
<span>{currentTier.label}</span>
<ChevronDown className="w-3 h-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[6rem]">
<DropdownMenuRadioGroup value={tier} onValueChange={(v) => onRegen(model, v as ScriptTier)}>
<DropdownMenuRadioGroup value={tier} onValueChange={(v) => onSelectTier(v as ScriptTier)}>
{SCRIPT_TIERS.map((t) => (
<DropdownMenuRadioItem key={t.key} value={t.key} className="text-[12px] font-medium text-slate-800">
{t.label}
......@@ -1730,15 +1734,38 @@ function RegenBtn({
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* 主键 */}
{/* 模型选择(只选,不触发) */}
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={streaming}>
<button
type="button"
title="选择生成模型 — 选中不会立即生成"
disabled={streaming}
className={selectorCls}
>
<span className="hidden sm:inline">{current.label}</span>
<ChevronDown className="w-3 h-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[11rem]">
<DropdownMenuRadioGroup value={model} onValueChange={(v) => onSelectModel(v as ScriptModel)}>
{SCRIPT_MODELS.map((m) => (
<DropdownMenuRadioItem key={m.key} value={m.key} className="text-[12px] font-medium text-slate-800">
{m.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* 重新生成(右侧,唯一触发口) */}
<button
onClick={() => (streaming ? onStop() : onRegen(model, tier))}
onClick={() => (streaming ? onStop() : onRegen())}
title={streaming ? '点击停止本次生成' : `用 ${currentTier.label} · ${current.label} 重新生成话术`}
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 text-[10.5px] transition-colors',
'inline-flex items-center gap-1 px-2 py-0.5 text-[10.5px] font-medium transition-colors',
streaming
? 'text-rose-600 bg-rose-50 hover:bg-rose-100'
: 'text-slate-500 hover:text-indigo-700 hover:bg-indigo-50',
: 'text-indigo-600 hover:text-indigo-700 hover:bg-indigo-50',
)}
>
{streaming ? (
......@@ -1766,41 +1793,6 @@ function RegenBtn({
</>
)}
</button>
{/* 模型选择 caret */}
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={streaming}>
<button
type="button"
title="选择生成模型"
disabled={streaming}
className={cn(
'inline-flex items-center gap-0.5 px-1 py-0.5 text-[10.5px] border-l transition-colors',
streaming
? 'text-rose-300 border-rose-100 cursor-not-allowed'
: 'text-slate-400 border-slate-200 hover:text-indigo-700 hover:bg-indigo-50',
)}
>
<span className="hidden sm:inline">{current.label}</span>
<ChevronDown className="w-3 h-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[11rem]">
<DropdownMenuRadioGroup
value={model}
onValueChange={(v) => onRegen(v as ScriptModel, tier)}
>
{SCRIPT_MODELS.map((m) => (
<DropdownMenuRadioItem
key={m.key}
value={m.key}
className="text-[12px] font-medium text-slate-800"
>
{m.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
......
......@@ -12,16 +12,18 @@ export type ScriptViewMode = 'copilot' | 'cards' | 'markdown';
// 原文 — Markdown 全文(折叠分段)
// ──────────────────────────────────────────
export function ScriptMarkdown({ sections, streaming = false }: { sections: ScriptSection[]; streaming?: boolean }) {
// 原文模式默认全部展开(客服要一眼看全)
const [open, setOpen] = useState<Record<string, boolean>>(() =>
Object.fromEntries(sections.map((s) => [s.id, true])),
);
// 默认全部展开(客服要一眼看全)。只记录被用户**主动折叠**的段(undefined = 展开)。
// 关键:流式时段 id 会变(skeleton 的 opening… → 真内容 s0/s1…),用"默认展开"语义
// 保证新出现的段也展开,不依赖初始 id 集(否则流式中原文是折叠的)。
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
return (
<div className="space-y-2">
{sections.map((sec, idx) => (
{sections.map((sec, idx) => {
const isOpen = !collapsed[sec.id];
return (
<div key={sec.id} className="rounded-md border border-slate-200 bg-white overflow-hidden">
<button
onClick={() => setOpen({ ...open, [sec.id]: !open[sec.id] })}
onClick={() => setCollapsed({ ...collapsed, [sec.id]: !collapsed[sec.id] })}
className="w-full flex items-center justify-between gap-2 px-3 py-2 text-left hover:bg-slate-50"
>
<div className="flex items-center gap-2">
......@@ -37,18 +39,19 @@ export function ScriptMarkdown({ sections, streaming = false }: { sections: Scri
fill="none"
stroke="currentColor"
strokeWidth="2"
className={cn('w-4 h-4 text-slate-400 transition-transform', open[sec.id] && 'rotate-180')}
className={cn('w-4 h-4 text-slate-400 transition-transform', isOpen && 'rotate-180')}
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{open[sec.id] && (
{isOpen && (
<div className="px-3 pb-3 pt-1 border-t border-slate-100">
<MD text={sec.markdown} />
</div>
)}
</div>
))}
);
})}
</div>
);
}
......
......@@ -107,15 +107,6 @@ export function AIStamp({
}) {
return (
<span className="inline-flex items-center gap-1.5 text-[10.5px] text-indigo-600/80 font-medium">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="w-3 h-3">
<path
d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"
strokeLinecap="round"
/>
<circle cx="12" cy="12" r="4" fill="currentColor" fillOpacity="0.15" />
</svg>
<span>{label}</span>
<span className="text-slate-400">·</span>
<span className="text-slate-500 tabular-nums">{relative}</span>
{source === 'template_fallback' && (
<Chip tone="amber" size="xs">
......
......@@ -92,8 +92,8 @@ export function useScriptStream(): UseScriptStream {
if (options?.model) url.searchParams.set('model', options.model);
if (options?.tier) url.searchParams.set('tier', options.tier);
// 占位 sections,避免第一帧到达前 UI 闪
setState({ status: 'streaming', sections: makeEmptySections() });
// 占位 sections(分档骨架),避免第一帧到达前 UI 闪
setState({ status: 'streaming', sections: makeEmptySections(options?.tier) });
try {
// SSE 走原生 fetch(api-client 不处理 stream),自己实现 silent refresh:
......@@ -161,7 +161,7 @@ export function useScriptStream(): UseScriptStream {
if (evt.type === 'start') {
setState({
status: 'streaming',
sections: makeEmptySections(),
sections: makeEmptySections(options?.tier),
modelId: evt.modelId,
invocationId: evt.invocationId,
});
......@@ -252,7 +252,20 @@ function parseSseFrame(frame: string): SseEvent | null {
}
}
function makeEmptySections(): ScriptSection[] {
// loading 占位段(分档:骨架要贴各档的输出形态,别都套稳健的"4段固定标题")
// 稳健 = 4 段固定标题;标准 = 4 段(标题模型自起,占位不假装标题);深度 = 段数不定,只占一段。
function makeEmptySections(tier?: string): ScriptSection[] {
if (tier === 'deep') {
return [{ id: 's0', label: '生成中…', durationHint: '', markdown: '' }];
}
if (tier === 'standard') {
return Array.from({ length: 4 }, (_, i) => ({
id: `s${i}`,
label: '生成中…',
durationHint: '',
markdown: '',
}));
}
return (['opening', 'informMissed', 'reviewAdvice', 'closing'] as const).map((id) => ({
id,
label: LABEL_FALLBACK[id],
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment