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
+41 -75
View File
@@ -1,5 +1,7 @@
import { useRef, useState } from "react";
import { Upload, Search, Network, FileUp } from "lucide-react";
import { ingestKb, ingestFile, streamIngest, searchKb, graphKb, type IngestEvent, type KbHit, type Triple } from "../lib/api";
import { Button, Input, Textarea, Badge, cn, useToast } from "../ui";
interface IngestLog {
t: string;
@@ -18,6 +20,7 @@ interface Progress {
// 知识库管理:实时入库监控(解析→切块→向量化→写入 + 拆分可视化)+ 检索调试台。
export function KbView() {
const toast = useToast();
const [kb, setKb] = useState("docs");
const [text, setText] = useState("");
const [logs, setLogs] = useState<IngestLog[]>([]);
@@ -28,21 +31,19 @@ export function KbView() {
const [topK, setTopK] = useState(5);
const [hits, setHits] = useState<KbHit[] | null>(null);
const [searching, setSearching] = useState(false);
const [err, setErr] = useState("");
const [graph, setGraph] = useState<Triple[] | null>(null);
const onGraph = async () => {
try {
setGraph(await graphKb(kb));
} catch (e) {
setErr((e as Error).message);
toast.push("error", (e as Error).message);
}
};
const stamp = () => new Date().toLocaleTimeString();
const ingesting = prog?.active ?? false;
// 订阅某入库 job 的进度流。
const follow = (job: string, label: string) => {
setProg({ active: true, stage: "提交", chunks: [] });
streamIngest(
@@ -59,10 +60,8 @@ export function KbView() {
() =>
setProg((p) => {
const ok = p?.stage !== "失败";
setLogs((l) => [
{ t: stamp(), msg: ok ? `${label}${p?.total ?? 0} 块入库完成` : `${label}${p?.error ?? "失败"}`, ok },
...l,
]);
setLogs((l) => [{ t: stamp(), msg: ok ? `${label}${p?.total ?? 0} 块入库完成` : `${label}${p?.error ?? "失败"}`, ok }, ...l]);
toast.push(ok ? "success" : "error", ok ? `${label} 入库完成` : `${label} 入库失败`);
return p ? { ...p, active: false } : null;
}),
() => setProg((p) => (p ? { ...p, active: false, stage: "连接中断" } : null)),
@@ -95,11 +94,10 @@ export function KbView() {
const onSearch = async () => {
if (!q.trim()) return;
setSearching(true);
setErr("");
try {
setHits(await searchKb(kb, q, topK));
} catch (e) {
setErr((e as Error).message);
toast.push("error", (e as Error).message);
setHits(null);
} finally {
setSearching(false);
@@ -112,13 +110,7 @@ export function KbView() {
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b border-line bg-ink-900 px-4 py-2">
<span className="text-sm font-semibold text-slate-300"></span>
<input
className="w-40 rounded-md border border-line bg-ink-800 px-2 py-1 text-sm text-slate-200 focus:border-violet-500/60 focus:outline-none"
value={kb}
onChange={(e) => setKb(e.target.value)}
placeholder="知识库名"
title="知识库(Milvus kb 字段分区)"
/>
<Input className="h-8 w-40" value={kb} onChange={(e) => setKb(e.target.value)} placeholder="知识库名" title="知识库(Milvus kb 字段分区)" />
<span className="text-[11px] text-slate-500"> / / / </span>
</div>
@@ -126,28 +118,22 @@ export function KbView() {
{/* 左:入库 + 实时监控 */}
<section className="flex w-1/2 flex-col border-r border-line p-4">
<h3 className="mb-2 text-xs font-semibold text-slate-400"></h3>
<textarea
className="h-24 w-full resize-none rounded-md border border-line bg-ink-800 p-2 text-sm text-slate-200 focus:border-violet-500/60 focus:outline-none"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={"每行一条知识,或上传文件"}
/>
<Textarea className="h-24 resize-none" value={text} onChange={(e) => setText(e.target.value)} placeholder="每行一条知识,或上传文件" />
<div className="mt-2 flex items-center gap-2">
<button
onClick={onIngest}
disabled={ingesting || !text.trim()}
className="rounded-md bg-emerald-600 px-3 py-1 text-sm text-white hover:bg-emerald-500 disabled:opacity-40"
>
{ingesting ? "入库中…" : "⬆ 入库文本"}
</button>
<Button variant="primary" size="sm" icon={Upload} onClick={onIngest} disabled={ingesting || !text.trim()}>
{ingesting ? "入库中…" : "入库文本"}
</Button>
<span className="text-[11px] text-slate-500"></span>
<Button size="sm" icon={FileUp} onClick={() => fileRef.current?.click()} disabled={ingesting}>
</Button>
<input
ref={fileRef}
type="file"
accept=".txt,.md,.csv,.docx,.xlsx,.pdf"
onChange={(e) => onFile(e.target.files?.[0])}
disabled={ingesting}
className="text-xs text-slate-400 file:mr-2 file:rounded file:border-0 file:bg-ink-700 file:px-2 file:py-1 file:text-xs file:text-slate-300"
className="hidden"
/>
</div>
<span className="mt-1 text-[10px] text-slate-500"> txt/md/csv/docx/xlsx/pdfdocx/xlsx/pdf mcp-py </span>
@@ -155,35 +141,38 @@ export function KbView() {
{/* 实时流水线进度 */}
{prog && (
<div className="mt-3 rounded-lg border border-line bg-ink-850 p-2.5">
<div className="flex flex-wrap items-center gap-1.5 text-xs">
<div className="flex flex-wrap items-center gap-1.5">
{["解析", "切块", "向量化", "写Milvus", "写Bleve", "完成"].map((s) => {
const active = prog.stage.startsWith(s) || (s === "完成" && prog.stage === "完成");
const passed = stageOrder(prog.stage) > stageOrder(s);
return (
<span
key={s}
className={`rounded px-1.5 py-0.5 text-[10px] ${
className={cn(
"rounded px-1.5 py-0.5 text-[10px]",
prog.stage === "失败"
? "bg-ink-800 text-slate-600"
: active
? "bg-violet-600 text-white shadow-glow"
? "bg-brand text-white shadow-glow"
: passed
? "bg-emerald-500/15 text-emerald-400"
: "bg-ink-800 text-slate-600"
}`}
? "bg-success/15 text-success"
: "bg-ink-800 text-slate-600",
)}
>
{s}
</span>
);
})}
</div>
{prog.error && <p className="mt-1 text-[11px] text-rose-400"> {prog.error}</p>}
{prog.error && <p className="mt-1 text-[11px] text-danger"> {prog.error}</p>}
{prog.total ? (
<div className="mt-2">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-ink-700">
<div className="h-full rounded-full bg-gradient-to-r from-violet-500 to-cyan-400 transition-all" style={{ width: `${pct}%` }} />
<div className="h-full rounded-full bg-gradient-to-r from-brand to-accent transition-all" style={{ width: `${pct}%` }} />
</div>
<div className="mt-1 text-[10px] text-slate-500">
{prog.done ?? 0}/{prog.total} {pct}%
</div>
<div className="mt-1 text-[10px] text-slate-500"> {prog.done ?? 0}/{prog.total} {pct}%</div>
</div>
) : null}
{prog.chunks.length > 0 && (
@@ -205,7 +194,7 @@ export function KbView() {
<ul className="flex-1 space-y-1 overflow-auto">
{logs.length === 0 && <li className="text-xs text-slate-600"></li>}
{logs.map((l, i) => (
<li key={i} className={`text-xs ${l.ok ? "text-emerald-400" : "text-rose-400"}`}>
<li key={i} className={cn("text-xs", l.ok ? "text-success" : "text-danger")}>
<span className="text-slate-600">{l.t}</span> {l.ok ? "✓" : "✗"} {l.msg}
</li>
))}
@@ -216,42 +205,21 @@ export function KbView() {
<section className="flex w-1/2 flex-col p-4">
<h3 className="mb-2 text-xs font-semibold text-slate-400"> + rerank</h3>
<div className="flex gap-2">
<input
className="flex-1 rounded-md border border-line bg-ink-800 px-2 py-1 text-sm text-slate-200 focus:border-violet-500/60 focus:outline-none"
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onSearch()}
placeholder="输入查询,语义召回相关片段…"
/>
<input
type="number"
className="w-16 rounded-md border border-line bg-ink-800 px-2 py-1 text-sm text-slate-200 focus:border-violet-500/60 focus:outline-none"
value={topK}
min={1}
max={20}
onChange={(e) => setTopK(Number(e.target.value))}
title="TopK"
/>
<button
onClick={onSearch}
disabled={searching || !q.trim()}
className="rounded-md bg-violet-600 px-3 py-1 text-sm text-white hover:bg-violet-500 disabled:opacity-40"
>
<Input className="flex-1" value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onSearch()} placeholder="输入查询,语义召回相关片段…" />
<Input type="number" className="w-16" value={topK} min={1} max={20} onChange={(e) => setTopK(Number(e.target.value))} title="TopK" />
<Button variant="primary" size="md" icon={Search} onClick={onSearch} disabled={searching || !q.trim()}>
{searching ? "检索中…" : "检索"}
</button>
</Button>
</div>
{err && <p className="mt-2 text-xs text-rose-400"> {err}</p>}
<ul className="mt-3 max-h-[40%] space-y-2 overflow-auto">
{hits === null && <li className="text-xs text-slate-600"></li>}
{hits !== null && hits.length === 0 && (
<li className="text-xs text-slate-600"> RAG </li>
)}
{hits !== null && hits.length === 0 && <li className="text-xs text-slate-600"> RAG </li>}
{hits?.map((h, i) => (
<li key={i} className="rounded-lg border border-line bg-ink-850 p-2">
<div className="mb-1 flex items-center gap-2 text-[10px]">
<span className="rounded bg-sky-500/15 px-1.5 py-0.5 text-sky-400"></span>
<Badge tone="accent"></Badge>
<span className="text-slate-600">#{i + 1}</span>
<span className="ml-auto font-mono text-violet-400">{h.score.toFixed(3)}</span>
<span className="ml-auto font-mono text-brand-400">{h.score.toFixed(3)}</span>
</div>
<div className="text-xs text-slate-300">{h.text}</div>
</li>
@@ -261,20 +229,18 @@ export function KbView() {
{/* 知识图谱(Neo4j / GraphRAG */}
<div className="mt-3 flex items-center justify-between border-t border-line pt-2">
<h3 className="text-xs font-semibold text-slate-400">Neo4j</h3>
<button onClick={onGraph} className="rounded-md border border-line px-2 py-0.5 text-xs text-slate-300 hover:bg-ink-800">
<Button size="sm" icon={Network} onClick={onGraph}>
</button>
</Button>
</div>
<ul className="mt-2 flex-1 space-y-1 overflow-auto">
{graph === null && <li className="text-[11px] text-slate-600"></li>}
{graph !== null && graph.length === 0 && (
<li className="text-[11px] text-slate-600"> chat + </li>
)}
{graph !== null && graph.length === 0 && <li className="text-[11px] text-slate-600"> chat + </li>}
{graph?.map((t, i) => (
<li key={i} className="flex items-center gap-1 text-[11px]">
<span className="rounded bg-amber-500/15 px-1.5 py-0.5 text-amber-300">{t.s}</span>
<Badge tone="warn">{t.s}</Badge>
<span className="text-slate-500">{t.p}</span>
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-emerald-300">{t.o}</span>
<Badge tone="success">{t.o}</Badge>
</li>
))}
</ul>