feat(desktop): 工业化升级 A —— 设计系统地基(primitives + lucide + 语义令牌)

把"手搓内联 class + Unicode 字符图标"换成统一组件与真实图标,为后续工业化打底。

- 依赖:装 lucide-react(描线图标,按需 tree-shake)
- 令牌:tailwind.config 加语义色 brand/accent/success/warn/danger + 圆角档位;
  强调色字面量(violet/cyan/emerald…)收敛到令牌,便于整体换肤
- primitives(src/ui,零重依赖自建):Button/Input/Textarea/Select/Field/Card/Panel/
  Badge/Dot/Tabs/Skeleton/EmptyState/Dialog/Toast(+useToast)/cn,桶文件统一引入
- 迁移:TopBar/LeftNav/BottomDrawer + Home/Report/Runs/Kb/Placeholder/ExecTrace/
  MemoryPanel/StudioView 全部换 primitives + lucide 图标;导航/能力卡/按钮告别
  ▤◆▣▦ 等 Unicode 字符;错误改用全局 Toast;空状态用 EmptyState
- App 包 ToastProvider

验证:tsc + vite build 通过;浏览器(Preview)走查工作台/报告页——真实图标、统一卡片/
按钮/输入;跑报告端到端正常(执行轨迹 lucide 状态图标点亮、章节耗时/字数/检索片段、
完成弹 Toast + 下载 Word)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-12 16:39:42 +08:00
parent 190c191ce4
commit 72bd43965f
25 changed files with 715 additions and 348 deletions
@@ -1,19 +1,21 @@
import { useRef, useState } from "react";
import { Play, Download, FileText } from "lucide-react";
import { generateReport, streamTokens, streamExec, reportDownloadUrl, type Identity, type ExecEvent } from "../lib/api";
import { ExecTrace } from "../components/ExecTrace";
import { Button, Input, Field, Panel, Dot, EmptyState, useToast } from "../ui";
type Phase = "idle" | "running" | "done" | "error";
// 报告生成:输入主题(+可选知识库) → 触发后端专用编排
// (规划大纲 → 各章并行检索+撰写 → 渲染 Word),实时看进度与正文,完成后下载 .docx。
export function ReportView({ identity }: { identity: Identity }) {
const toast = useToast();
const [topic, setTopic] = useState("");
const [kb, setKb] = useState("");
const [phase, setPhase] = useState<Phase>("idle");
const [out, setOut] = useState("");
const [exec, setExec] = useState<ExecEvent[]>([]);
const [taskId, setTaskId] = useState("");
const [err, setErr] = useState("");
const closeRef = useRef<(() => void) | null>(null);
const execCloseRef = useRef<(() => void) | null>(null);
@@ -26,7 +28,6 @@ export function ReportView({ identity }: { identity: Identity }) {
setPhase("running");
setOut("");
setExec([]);
setErr("");
setTaskId("");
try {
const id = await generateReport(identity, topic.trim(), kb.trim() || undefined);
@@ -40,20 +41,25 @@ export function ReportView({ identity }: { identity: Identity }) {
closeRef.current = streamTokens(
id,
(tok) => setOut((o) => o + tok),
() => setPhase("done"),
() => {
setErr("连接中断");
setPhase("done");
toast.push("success", "报告已生成,可下载 Word");
},
() => {
setPhase("error");
toast.push("error", "报告流连接中断");
},
);
} catch (e) {
setErr((e as Error).message);
setPhase("error");
toast.push("error", (e as Error).message);
}
};
const tracePhase = running ? "streaming" : phase === "done" ? "done" : phase === "error" ? "error" : "idle";
return (
<div className="flex h-full min-h-0 flex-col gap-4 overflow-y-auto p-6">
<div className="flex h-full min-h-0 flex-col gap-4 overflow-hidden p-6">
<header>
<h1 className="text-lg font-semibold text-slate-100"></h1>
<p className="mt-1 text-xs text-slate-500">
@@ -61,75 +67,53 @@ export function ReportView({ identity }: { identity: Identity }) {
</p>
</header>
{/* 输入区 */}
<div className="grid grid-cols-[1fr_220px] gap-3 rounded-xl border border-line bg-ink-900 p-4 shadow-card">
<div className="flex flex-col gap-2">
<label className="text-[11px] font-medium text-slate-400"></label>
<input
<div className="grid grid-cols-[1fr_220px_auto_auto] items-end gap-3 rounded-lg border border-line bg-ink-900 p-4 shadow-card">
<Field label="报告主题">
<Input
value={topic}
onChange={(e) => setTopic(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onGenerate()}
placeholder="如:2026 年国产大模型产业现状与趋势分析"
className="rounded-lg border border-line bg-ink-950 px-3 py-2 text-sm text-slate-200 outline-none placeholder:text-slate-600 focus:border-violet-500"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-[11px] font-medium text-slate-400"></label>
<input
value={kb}
onChange={(e) => setKb(e.target.value)}
placeholder="如 docs,留空则不挂检索"
className="rounded-lg border border-line bg-ink-950 px-3 py-2 text-sm text-slate-200 outline-none placeholder:text-slate-600 focus:border-violet-500"
/>
</div>
<div className="col-span-2 flex items-center gap-3">
<button
onClick={onGenerate}
disabled={running || !topic.trim()}
className="rounded-lg bg-gradient-to-r from-violet-500 to-cyan-500 px-4 py-2 text-sm font-medium text-white shadow-glow transition disabled:cursor-not-allowed disabled:opacity-40"
</Field>
<Field label="知识库(可选)">
<Input value={kb} onChange={(e) => setKb(e.target.value)} placeholder="如 docs,留空则不挂检索" />
</Field>
<Button variant="primary" icon={Play} onClick={onGenerate} disabled={running || !topic.trim()}>
{running ? "生成中…" : "生成报告"}
</Button>
{phase === "done" && taskId ? (
<a
href={reportDownloadUrl(taskId)}
className="inline-flex h-9 items-center gap-1.5 rounded-md border border-brand/60 px-4 text-sm font-medium text-brand-400 transition hover:bg-brand/10"
>
{running ? "生成中…" : "生成报告"}
</button>
{phase === "done" && taskId && (
<a
href={reportDownloadUrl(taskId)}
className="rounded-lg border border-violet-500/60 px-4 py-2 text-sm font-medium text-violet-300 transition hover:bg-violet-500/10"
>
Word
</a>
)}
<span className="text-xs text-slate-500">
{running && "正在编排,实时进度见下方…"}
{phase === "done" && "已完成"}
{phase === "error" && <span className="text-rose-400">{err}</span>}
</span>
</div>
<Download className="h-4 w-4" />
Word
</a>
) : (
<span className="h-9" />
)}
</div>
{/* 执行轨迹 + 报告正文 */}
<div className="grid min-h-0 flex-1 grid-cols-[340px_1fr] gap-4">
<section className="flex min-h-0 flex-col rounded-xl border border-line bg-ink-900 shadow-card">
<div className="border-b border-line px-4 py-2.5 text-[11px] font-medium text-slate-400"></div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<ExecTrace events={exec} phase={running ? "streaming" : phase === "done" ? "done" : phase === "error" ? "error" : "idle"} />
</div>
</section>
<Panel title="执行轨迹" icon={FileText}>
<ExecTrace events={exec} phase={tracePhase} />
</Panel>
<section className="flex min-h-0 flex-col rounded-xl border border-line bg-ink-900 shadow-card">
<div className="flex items-center gap-2 border-b border-line px-4 py-2.5 text-[11px] text-slate-500">
<span className={`h-2 w-2 rounded-full ${running ? "animate-pulse bg-cyan-400" : phase === "done" ? "bg-emerald-400" : "bg-slate-600"}`} />
· {taskId || "未开始"}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{out ? (
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-slate-300">{out}</pre>
) : (
<div className="flex h-full items-center justify-center text-sm text-slate-600">
</div>
)}
</div>
</section>
<Panel
title={
<span className="flex items-center gap-2">
<Dot tone={running ? "running" : phase === "done" ? "success" : "neutral"} pulse={running} />
· {taskId || "未开始"}
</span>
}
>
{out ? (
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-slate-300">{out}</pre>
) : (
<EmptyState icon={FileText} title="尚未生成报告" desc="输入主题并点击「生成报告」,这里将实时显示规划与撰写过程。" />
)}
</Panel>
</div>
</div>
);