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
@@ -0,0 +1,38 @@
import type { ReactNode } from "react";
import { cn } from "./cn";
type Tone = "neutral" | "brand" | "accent" | "success" | "warn" | "danger";
const tones: Record<Tone, string> = {
neutral: "bg-white/5 text-slate-400",
brand: "bg-brand/15 text-brand-400",
accent: "bg-accent/15 text-accent-400",
success: "bg-success/15 text-success",
warn: "bg-warn/15 text-warn",
danger: "bg-danger/15 text-danger",
};
export function Badge({ tone = "neutral", className, children }: { tone?: Tone; className?: string; children: ReactNode }) {
return (
<span className={cn("inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-medium leading-none", tones[tone], className)}>
{children}
</span>
);
}
// Dot 状态小圆点(可脉冲)。
export function Dot({ tone = "neutral", pulse }: { tone?: Tone | "running"; pulse?: boolean }) {
const color =
tone === "success"
? "bg-success"
: tone === "danger"
? "bg-danger"
: tone === "warn"
? "bg-warn"
: tone === "running" || tone === "accent"
? "bg-accent"
: tone === "brand"
? "bg-brand"
: "bg-slate-600";
return <span className={cn("h-2 w-2 shrink-0 rounded-full", color, pulse && "animate-pulse")} />;
}
@@ -0,0 +1,37 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { type LucideIcon } from "lucide-react";
import { cn } from "./cn";
type Variant = "primary" | "secondary" | "ghost" | "danger";
type Size = "sm" | "md";
const base =
"inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition select-none disabled:cursor-not-allowed disabled:opacity-40 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand/50";
const variants: Record<Variant, string> = {
primary: "bg-brand text-white hover:bg-brand-500 active:bg-brand-600 shadow-glow",
secondary: "border border-line bg-ink-800 text-slate-200 hover:bg-ink-700 hover:border-ink-600",
ghost: "text-slate-400 hover:bg-ink-800 hover:text-slate-200",
danger: "border border-danger/50 text-danger hover:bg-danger/10",
};
const sizes: Record<Size, string> = {
sm: "h-8 px-3 text-xs",
md: "h-9 px-4 text-sm",
};
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
icon?: LucideIcon;
children?: ReactNode;
}
export function Button({ variant = "secondary", size = "md", icon: Icon, className, children, ...rest }: Props) {
return (
<button className={cn(base, variants[variant], sizes[size], className)} {...rest}>
{Icon && <Icon className={size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4"} strokeWidth={2} />}
{children}
</button>
);
}
+36
View File
@@ -0,0 +1,36 @@
import type { ReactNode } from "react";
import { type LucideIcon } from "lucide-react";
import { cn } from "./cn";
// Card 基础卡片容器。
export function Card({ className, children }: { className?: string; children: ReactNode }) {
return <div className={cn("rounded-lg border border-line bg-ink-900 shadow-card", className)}>{children}</div>;
}
// Panel 带表头的分区面板(表头 icon+标题+右侧动作,主体可滚动)。
export function Panel({
title,
icon: Icon,
actions,
className,
bodyClassName,
children,
}: {
title: ReactNode;
icon?: LucideIcon;
actions?: ReactNode;
className?: string;
bodyClassName?: string;
children: ReactNode;
}) {
return (
<section className={cn("flex min-h-0 flex-col rounded-lg border border-line bg-ink-900 shadow-card", className)}>
<div className="flex items-center gap-2 border-b border-line px-4 py-2.5">
{Icon && <Icon className="h-3.5 w-3.5 text-slate-500" strokeWidth={2} />}
<span className="text-[11px] font-medium text-slate-400">{title}</span>
{actions && <div className="ml-auto flex items-center gap-2">{actions}</div>}
</div>
<div className={cn("min-h-0 flex-1 overflow-y-auto p-4", bodyClassName)}>{children}</div>
</section>
);
}
@@ -0,0 +1,51 @@
import type { ReactNode } from "react";
import { X } from "lucide-react";
import { Button } from "./Button";
// Dialog 轻量模态:遮罩 + 居中卡片。open=false 不渲染。
export function Dialog({
open,
onClose,
title,
children,
footer,
}: {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
footer?: ReactNode;
}) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-6" onClick={onClose}>
<div
className="w-full max-w-md rounded-lg border border-line bg-ink-900 shadow-card"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center border-b border-line px-4 py-3">
<h3 className="text-sm font-medium text-slate-100">{title}</h3>
<button onClick={onClose} className="ml-auto text-slate-500 hover:text-slate-300">
<X className="h-4 w-4" />
</button>
</div>
<div className="px-4 py-4 text-sm text-slate-300">{children}</div>
{footer && <div className="flex justify-end gap-2 border-t border-line px-4 py-3">{footer}</div>}
</div>
</div>
);
}
// ConfirmFooter 常用的取消/确认按钮组。
export function ConfirmFooter({ onCancel, onConfirm, confirmLabel = "确认", danger }: { onCancel: () => void; onConfirm: () => void; confirmLabel?: string; danger?: boolean }) {
return (
<>
<Button variant="ghost" size="sm" onClick={onCancel}>
</Button>
<Button variant={danger ? "danger" : "primary"} size="sm" onClick={onConfirm}>
{confirmLabel}
</Button>
</>
);
}
@@ -0,0 +1,34 @@
import type { ReactNode } from "react";
import { type LucideIcon } from "lucide-react";
import { cn } from "./cn";
// Skeleton 占位骨架(加载态)。
export function Skeleton({ className }: { className?: string }) {
return <div className={cn("animate-pulse rounded-md bg-ink-800", className)} />;
}
// EmptyState 空状态:图标 + 标题 + 说明 + 可选动作。
export function EmptyState({
icon: Icon,
title,
desc,
action,
className,
}: {
icon: LucideIcon;
title: string;
desc?: ReactNode;
action?: ReactNode;
className?: string;
}) {
return (
<div className={cn("flex h-full flex-col items-center justify-center gap-3 px-6 py-10 text-center", className)}>
<div className="flex h-12 w-12 items-center justify-center rounded-xl border border-line bg-ink-850 text-slate-500">
<Icon className="h-6 w-6" strokeWidth={1.5} />
</div>
<div className="text-sm font-medium text-slate-300">{title}</div>
{desc && <div className="max-w-sm text-xs leading-relaxed text-slate-500">{desc}</div>}
{action && <div className="mt-1">{action}</div>}
</div>
);
}
@@ -0,0 +1,32 @@
import type { InputHTMLAttributes, TextareaHTMLAttributes, SelectHTMLAttributes, ReactNode } from "react";
import { cn } from "./cn";
const fieldBase =
"w-full rounded-md border border-line bg-ink-950 text-sm text-slate-200 placeholder:text-slate-600 transition focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand/40 disabled:opacity-50";
export function Input({ className, ...rest }: InputHTMLAttributes<HTMLInputElement>) {
return <input className={cn(fieldBase, "h-9 px-3", className)} {...rest} />;
}
export function Textarea({ className, ...rest }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
return <textarea className={cn(fieldBase, "px-3 py-2 leading-relaxed", className)} {...rest} />;
}
export function Select({ className, children, ...rest }: SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select className={cn(fieldBase, "h-9 cursor-pointer px-3", className)} {...rest}>
{children}
</select>
);
}
// Field 包一层标签 + 控件,统一表单纵向节奏。
export function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-[11px] font-medium text-slate-400">{label}</span>
{children}
{hint && <span className="text-[10px] text-slate-600">{hint}</span>}
</label>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { cn } from "./cn";
export interface TabDef<T extends string> {
key: T;
label: string;
count?: number;
}
// Tabs 受控标签条(下划线高亮)。
export function Tabs<T extends string>({
tabs,
value,
onChange,
className,
}: {
tabs: TabDef<T>[];
value: T;
onChange: (v: T) => void;
className?: string;
}) {
return (
<div className={cn("flex items-center gap-1", className)}>
{tabs.map((t) => {
const active = t.key === value;
return (
<button
key={t.key}
onClick={() => onChange(t.key)}
className={cn(
"relative px-3 py-2 text-xs transition",
active ? "font-medium text-brand-400" : "text-slate-500 hover:text-slate-300",
)}
>
{t.label}
{t.count != null && t.count > 0 && (
<span className="ml-1 rounded bg-white/5 px-1 text-[9px] text-slate-400">{t.count}</span>
)}
{active && <span className="absolute inset-x-2 -bottom-px h-0.5 rounded bg-brand" />}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,64 @@
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from "react";
import { CheckCircle2, AlertTriangle, Info, X } from "lucide-react";
import { cn } from "./cn";
type ToastTone = "success" | "error" | "info";
interface Toast {
id: number;
tone: ToastTone;
msg: string;
}
interface ToastCtx {
push: (tone: ToastTone, msg: string) => void;
}
const Ctx = createContext<ToastCtx>({ push: () => {} });
// useToast 在任意组件里弹出全局通知。
export function useToast() {
return useContext(Ctx);
}
const icons = { success: CheckCircle2, error: AlertTriangle, info: Info };
const accent = {
success: "text-success",
error: "text-danger",
info: "text-accent-400",
};
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const seq = useRef(0);
const push = useCallback((tone: ToastTone, msg: string) => {
const id = ++seq.current;
setToasts((t) => [...t, { id, tone, msg }]);
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 4200);
}, []);
const dismiss = (id: number) => setToasts((t) => t.filter((x) => x.id !== id));
return (
<Ctx.Provider value={{ push }}>
{children}
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-80 flex-col gap-2">
{toasts.map((t) => {
const Icon = icons[t.tone];
return (
<div
key={t.id}
className="pointer-events-auto flex items-start gap-2.5 rounded-lg border border-line bg-ink-850 px-3 py-2.5 shadow-card"
>
<Icon className={cn("mt-0.5 h-4 w-4 shrink-0", accent[t.tone])} strokeWidth={2} />
<span className="flex-1 text-xs leading-relaxed text-slate-200">{t.msg}</span>
<button onClick={() => dismiss(t.id)} className="text-slate-600 hover:text-slate-300">
<X className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
</Ctx.Provider>
);
}
+4
View File
@@ -0,0 +1,4 @@
// cn 合并 className —— 过滤掉 falsy,空格连接(零依赖的轻量 clsx)。
export function cn(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
+10
View File
@@ -0,0 +1,10 @@
// UI primitives 桶文件 —— 统一从 "../ui" 引入。
export { cn } from "./cn";
export { Button } from "./Button";
export { Input, Textarea, Select, Field } from "./Input";
export { Card, Panel } from "./Card";
export { Badge, Dot } from "./Badge";
export { Tabs, type TabDef } from "./Tabs";
export { Skeleton, EmptyState } from "./Feedback";
export { Dialog, ConfirmFooter } from "./Dialog";
export { ToastProvider, useToast } from "./Toast";