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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// cn 合并 className —— 过滤掉 falsy,空格连接(零依赖的轻量 clsx)。
|
||||
export function cn(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user