120 lines
5.3 KiB
TypeScript
120 lines
5.3 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
||
import { Brain, PenLine } from 'lucide-react';
|
||
import type { StreamingDraftSection } from '../../types/ai-writing';
|
||
|
||
interface DraftGeneratingViewProps {
|
||
sections: StreamingDraftSection[];
|
||
}
|
||
|
||
/** 思考过程框:纯只读展示,跟随内容自动滚到底(仅操作 DOM,不触发 setState) */
|
||
function ThinkingBox({ text, done }: { text: string; done: boolean }) {
|
||
const preRef = useRef<HTMLPreElement>(null);
|
||
useEffect(() => {
|
||
if (preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight;
|
||
}, [text]);
|
||
|
||
return (
|
||
<div className="rounded-lg border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden">
|
||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-amber-100/40 dark:bg-amber-900/30">
|
||
<Brain className="size-3 text-amber-500 shrink-0" />
|
||
<span className="text-[11px] font-medium text-amber-600 dark:text-amber-400">思考过程</span>
|
||
</div>
|
||
<pre
|
||
ref={preRef}
|
||
className="text-[10px] font-mono leading-relaxed px-2.5 py-2 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-40 overflow-y-auto italic"
|
||
>
|
||
{text}
|
||
{!done && (
|
||
<span className="inline-block w-0.5 h-3 bg-amber-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
|
||
)}
|
||
</pre>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 「内容生成中」骨架屏:流光扫过的占位行 + 写字笔抖动 + 跳动圆点,提示正文正在右侧实时生成 */
|
||
const SKELETON_WIDTHS = ['92%', '100%', '85%', '96%', '70%'];
|
||
|
||
function GeneratingSkeleton() {
|
||
return (
|
||
<div className="group relative overflow-hidden rounded-xl border bg-card shadow-sm p-4">
|
||
{/* 顶部一道缓慢流过的高光,强化"正在工作"的呼吸感 */}
|
||
<div className="pointer-events-none absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-primary/[0.07] to-transparent animate-[card-sheen_2.8s_ease-in-out_infinite]" />
|
||
|
||
<div className="relative flex items-center gap-2 mb-3">
|
||
<PenLine className="size-3.5 text-primary animate-[pen-write_1.2s_ease-in-out_infinite] origin-bottom-left" />
|
||
<span className="text-xs font-medium text-foreground">内容生成中</span>
|
||
<span className="flex items-end gap-0.5">
|
||
<span className="size-1 rounded-full bg-primary/70 animate-[dot-bounce_1.4s_ease-in-out_infinite]" />
|
||
<span className="size-1 rounded-full bg-primary/70 animate-[dot-bounce_1.4s_ease-in-out_.2s_infinite]" />
|
||
<span className="size-1 rounded-full bg-primary/70 animate-[dot-bounce_1.4s_ease-in-out_.4s_infinite]" />
|
||
</span>
|
||
<span className="text-[11px] text-muted-foreground ml-auto">正文已在右侧实时显示</span>
|
||
</div>
|
||
|
||
<div className="relative space-y-2.5">
|
||
{SKELETON_WIDTHS.map((w, i) => (
|
||
<div
|
||
key={i}
|
||
className="relative h-3 overflow-hidden rounded-full bg-muted/60"
|
||
style={{ width: w }}
|
||
>
|
||
<div
|
||
className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/45 to-transparent dark:via-white/10 animate-[shimmer-sweep_1.5s_ease-in-out_infinite]"
|
||
style={{ animationDelay: `${i * 0.15}s` }}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<style>{`
|
||
@keyframes blink {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0; }
|
||
}
|
||
/* 骨架行流光:高光从左扫到右 */
|
||
@keyframes shimmer-sweep {
|
||
0% { transform: translateX(-100%); }
|
||
100% { transform: translateX(220%); }
|
||
}
|
||
/* 卡片整体微高光呼吸 */
|
||
@keyframes card-sheen {
|
||
0%, 60%, 100% { transform: translateX(-100%); }
|
||
80% { transform: translateX(100%); }
|
||
}
|
||
/* 写字笔:轻微上下 + 摆动,像在书写 */
|
||
@keyframes pen-write {
|
||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||
50% { transform: translateY(-1.5px) rotate(-12deg); }
|
||
}
|
||
/* 跳动圆点 */
|
||
@keyframes dot-bounce {
|
||
0%, 100% { opacity: .35; transform: translateY(0); }
|
||
50% { opacity: 1; transform: translateY(-2px); }
|
||
}
|
||
`}</style>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 作家写作 / 按意见修改阶段,左侧的轻量「生成中」视图。
|
||
*
|
||
* 刻意不再逐章节渲染流式正文(那套 DraftStreamingView 含逐 chunk 的平滑
|
||
* scrollIntoView 等重型操作):正文已在右侧实时显示,左侧只需告知"正在生成",
|
||
* 若模型有思考过程则一并展示。这样左侧每个 chunk 的渲染开销极小,避免更新风暴。
|
||
*/
|
||
export function DraftGeneratingView({ sections }: DraftGeneratingViewProps) {
|
||
// 按章节顺序拼接非空思考内容;是否仍在思考以"存在未完成章节"近似
|
||
const ordered = [...sections].sort((a, b) => a.sectionIndex - b.sectionIndex);
|
||
const thinking = ordered.map((s) => s.thinkingText).filter(Boolean).join('\n\n');
|
||
const thinkingDone = sections.length > 0 && sections.every((s) => s.isDone);
|
||
|
||
return (
|
||
<div className="px-4 pb-4 space-y-3">
|
||
{thinking && <ThinkingBox text={thinking} done={thinkingDone} />}
|
||
<GeneratingSkeleton />
|
||
</div>
|
||
);
|
||
}
|