feat(ui): 统计面板指标接入 count-up 动画并完善画布 Agent 聊天 UI (吸收 BoardUI, 尊重 reduced-motion)
This commit is contained in:
@@ -50,7 +50,69 @@ export function extractCanvasAgentQuickActions(text: string): CanvasAgentQuickAc
|
|||||||
|
|
||||||
const WORKING_TEXT = "正在推演...";
|
const WORKING_TEXT = "正在推演...";
|
||||||
|
|
||||||
export function AgentChatMessage({ item, theme, user, isStreaming = false, retrying = false, onRejectTool, onApproveTool, onQuickAction, onRetry }: { item: CanvasAgentChatMessage; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: LocalUser | null; isStreaming?: boolean; retrying?: boolean; onRejectTool?: (id: string) => void; onApproveTool?: (id: string) => void; onQuickAction?: (prompt: string) => void; onRetry?: () => void }) {
|
// Agent 工作指示器:3×3 点阵对角波光。吸收自 boardui agent-thinking 的
|
||||||
|
// wave 形态(不引 UI 资产,仅点阵节奏与降级规范);reduced-motion 下保持静态帧。
|
||||||
|
const WORKING_DOTS = 3;
|
||||||
|
const WORKING_DOTS_TICK_MS = 90;
|
||||||
|
const WORKING_DOTS_MIN_OPACITY = 0.14;
|
||||||
|
const WORKING_DOTS_PHASE_STEP = 1 / 8;
|
||||||
|
const WORKING_DOTS_TRAIL = 0.32;
|
||||||
|
|
||||||
|
function AgentWorkingDots() {
|
||||||
|
const reducedMotion = useReducedMotion();
|
||||||
|
const [phase, setPhase] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (reducedMotion) return;
|
||||||
|
const timer = window.setInterval(() => setPhase((value) => (value + WORKING_DOTS_PHASE_STEP) % 1), WORKING_DOTS_TICK_MS);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [reducedMotion]);
|
||||||
|
return (
|
||||||
|
<span aria-hidden className="grid shrink-0" style={{ gridTemplateColumns: `repeat(${WORKING_DOTS}, 4px)`, gap: 2 }}>
|
||||||
|
{Array.from({ length: WORKING_DOTS * WORKING_DOTS }, (_, index) => {
|
||||||
|
const col = index % WORKING_DOTS;
|
||||||
|
const row = Math.floor(index / WORKING_DOTS);
|
||||||
|
// 对角波相位 0..<1:波前扫过点阵后折返,读作光在格内往返。
|
||||||
|
const scalar = ((col + row) / (2 * (WORKING_DOTS - 1))) * (WORKING_DOTS / (WORKING_DOTS + 1));
|
||||||
|
const behind = (phase - scalar + 1) % 1;
|
||||||
|
const lit = Math.max(0, 1 - behind / WORKING_DOTS_TRAIL) ** 1.5;
|
||||||
|
const opacity = WORKING_DOTS_MIN_OPACITY + (1 - WORKING_DOTS_MIN_OPACITY) * lit;
|
||||||
|
return <span key={index} className="rounded-[1px] bg-current" style={{ width: 4, height: 4, opacity }} />;
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentWorkingElapsed() {
|
||||||
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const started = performance.now();
|
||||||
|
const timer = window.setInterval(() => setElapsed(Math.floor((performance.now() - started) / 1000)), 500);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
return <span className="shrink-0 tabular-nums opacity-55">{elapsed}s</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentChatMessage({
|
||||||
|
item,
|
||||||
|
theme,
|
||||||
|
user,
|
||||||
|
isStreaming = false,
|
||||||
|
retrying = false,
|
||||||
|
onRejectTool,
|
||||||
|
onApproveTool,
|
||||||
|
onQuickAction,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
item: CanvasAgentChatMessage;
|
||||||
|
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||||
|
user: LocalUser | null;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
retrying?: boolean;
|
||||||
|
onRejectTool?: (id: string) => void;
|
||||||
|
onApproveTool?: (id: string) => void;
|
||||||
|
onQuickAction?: (prompt: string) => void;
|
||||||
|
onRetry?: () => void;
|
||||||
|
}) {
|
||||||
const isUser = item.role === "user";
|
const isUser = item.role === "user";
|
||||||
const isSystem = item.role === "system";
|
const isSystem = item.role === "system";
|
||||||
const color = item.role === "error" ? "#dc2626" : item.role === "tool" ? "#2563eb" : theme.node.text;
|
const color = item.role === "error" ? "#dc2626" : item.role === "tool" ? "#2563eb" : theme.node.text;
|
||||||
@@ -78,7 +140,13 @@ export function AgentChatMessage({ item, theme, user, isStreaming = false, retry
|
|||||||
<div className={`flex items-start gap-2.5 ${isUser ? "justify-end" : "justify-start"}`}>
|
<div className={`flex items-start gap-2.5 ${isUser ? "justify-end" : "justify-start"}`}>
|
||||||
{!isUser ? <AgentAvatar theme={theme} /> : null}
|
{!isUser ? <AgentAvatar theme={theme} /> : null}
|
||||||
<div className={`min-w-0 max-w-[86%] text-sm leading-6 ${isUser ? "rounded-md px-3 py-2.5 text-right" : "text-left"}`} style={{ color, ...(isUser ? { background: theme.accent.primarySoft } : {}) }}>
|
<div className={`min-w-0 max-w-[86%] text-sm leading-6 ${isUser ? "rounded-md px-3 py-2.5 text-right" : "text-left"}`} style={{ color, ...(isUser ? { background: theme.accent.primarySoft } : {}) }}>
|
||||||
{item.role === "assistant" ? <AIMessageMarkdown className="text-left" isStreaming={isStreaming}>{item.text}</AIMessageMarkdown> : <div className="whitespace-pre-wrap break-words text-left">{item.text}</div>}
|
{item.role === "assistant" ? (
|
||||||
|
<AIMessageMarkdown className="text-left" isStreaming={isStreaming}>
|
||||||
|
{item.text}
|
||||||
|
</AIMessageMarkdown>
|
||||||
|
) : (
|
||||||
|
<div className="whitespace-pre-wrap break-words text-left">{item.text}</div>
|
||||||
|
)}
|
||||||
{item.role === "error" && onRetry ? (
|
{item.role === "error" && onRetry ? (
|
||||||
<Button size="small" className="mt-2 !h-7" icon={retrying ? <LoaderCircle className="size-3.5 animate-spin" /> : <RotateCcw className="size-3.5" />} disabled={retrying} onClick={onRetry}>
|
<Button size="small" className="mt-2 !h-7" icon={retrying ? <LoaderCircle className="size-3.5 animate-spin" /> : <RotateCcw className="size-3.5" />} disabled={retrying} onClick={onRetry}>
|
||||||
{retrying ? "重试中" : "重试本轮"}
|
{retrying ? "重试中" : "重试本轮"}
|
||||||
@@ -121,9 +189,13 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold leading-5">
|
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold leading-5">
|
||||||
<span>确认工具调用</span>
|
<span>确认工具调用</span>
|
||||||
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[var(--fs-label)] font-medium" style={{ color: "#d97706", background: "rgba(217,119,6,.1)" }}>等待确认</span>
|
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[var(--fs-label)] font-medium" style={{ color: "#d97706", background: "rgba(217,119,6,.1)" }}>
|
||||||
|
等待确认
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm leading-6" style={{ color: theme.node.text }}>
|
||||||
|
{summary}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-sm leading-6" style={{ color: theme.node.text }}>{summary}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{impact?.operationCount ? (
|
{impact?.operationCount ? (
|
||||||
@@ -134,11 +206,27 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
|
|||||||
<ImpactMetric label="删除" value={impact.destructiveCount} attention={impact.destructiveCount > 0} theme={theme} />
|
<ImpactMetric label="删除" value={impact.destructiveCount} attention={impact.destructiveCount > 0} theme={theme} />
|
||||||
<ImpactMetric label="生成" value={impact.generationCount} attention={impact.generationCount > 0} theme={theme} />
|
<ImpactMetric label="生成" value={impact.generationCount} attention={impact.generationCount > 0} theme={theme} />
|
||||||
</div>
|
</div>
|
||||||
{impact.items.length ? <div className="mt-3 space-y-1.5">{impact.items.map((item, index) => <div key={`${item}-${index}`} className="flex gap-2 text-xs leading-5" style={{ color: theme.node.muted }}><span className="mt-2 size-1 shrink-0 rounded-full bg-current" /><span>{item}</span></div>)}</div> : null}
|
{impact.items.length ? (
|
||||||
|
<div className="mt-3 space-y-1.5">
|
||||||
|
{impact.items.map((item, index) => (
|
||||||
|
<div key={`${item}-${index}`} className="flex gap-2 text-xs leading-5" style={{ color: theme.node.muted }}>
|
||||||
|
<span className="mt-2 size-1 shrink-0 rounded-full bg-current" />
|
||||||
|
<span>{item}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{impact.warning ? <div className="mt-3 rounded-md bg-amber-500/[.08] px-2.5 py-2 text-xs leading-5 text-amber-700 dark:text-amber-300">{impact.warning}</div> : null}
|
{impact.warning ? <div className="mt-3 rounded-md bg-amber-500/[.08] px-2.5 py-2 text-xs leading-5 text-amber-700 dark:text-amber-300">{impact.warning}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{detail ? <details className="mt-3 pt-1"><summary className="cursor-pointer text-xs" style={{ color: theme.node.muted }}>技术详情</summary><AgentDetailBlock detail={detail} theme={theme} /></details> : null}
|
{detail ? (
|
||||||
|
<details className="mt-3 pt-1">
|
||||||
|
<summary className="cursor-pointer text-xs" style={{ color: theme.node.muted }}>
|
||||||
|
技术详情
|
||||||
|
</summary>
|
||||||
|
<AgentDetailBlock detail={detail} theme={theme} />
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
{onReject || onApprove ? (
|
{onReject || onApprove ? (
|
||||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||||
<Button danger className="!h-9" icon={<XCircle className="size-4" />} onClick={() => onReject?.()}>
|
<Button danger className="!h-9" icon={<XCircle className="size-4" />} onClick={() => onReject?.()}>
|
||||||
@@ -155,7 +243,16 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ImpactMetric({ label, value, attention = false, theme }: { label: string; value: number; attention?: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
function ImpactMetric({ label, value, attention = false, theme }: { label: string; value: number; attention?: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||||
return <div className="px-1 py-1"><div className="text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>{label}</div><div className="mt-0.5 text-sm font-semibold tabular-nums" style={{ color: attention ? "#d97706" : theme.node.text }}>{value}</div></div>;
|
return (
|
||||||
|
<div className="px-1 py-1">
|
||||||
|
<div className="text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-sm font-semibold tabular-nums" style={{ color: attention ? "#d97706" : theme.node.text }}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function agentImpactFromDetail(detail: unknown) {
|
function agentImpactFromDetail(detail: unknown) {
|
||||||
@@ -187,7 +284,11 @@ export function AgentToolCard({ title, text, detail, theme }: { title: string; t
|
|||||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[var(--fs-label)] font-medium" style={{ color: state.color, background: state.softBg }}>
|
<span className="inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[var(--fs-label)] font-medium" style={{ color: state.color, background: state.softBg }}>
|
||||||
{state.label}
|
{state.label}
|
||||||
</span>
|
</span>
|
||||||
{detail ? <span className="ml-auto text-xs font-normal" style={{ color: theme.node.muted }}>详情</span> : null}
|
{detail ? (
|
||||||
|
<span className="ml-auto text-xs font-normal" style={{ color: theme.node.muted }}>
|
||||||
|
详情
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-sm leading-6" style={{ color: state.isError ? state.color : theme.node.muted }}>
|
<div className="mt-2 text-sm leading-6" style={{ color: state.isError ? state.color : theme.node.muted }}>
|
||||||
{text}
|
{text}
|
||||||
@@ -200,20 +301,12 @@ export function AgentToolCard({ title, text, detail, theme }: { title: string; t
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentWorkingMessage({ theme }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
export function AgentWorkingMessage({ theme, label = WORKING_TEXT }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; label?: string }) {
|
||||||
const [length, setLength] = useState(1);
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = window.setInterval(() => setLength((value) => (value >= WORKING_TEXT.length + 4 ? 1 : value + 1)), 120);
|
|
||||||
return () => window.clearInterval(timer);
|
|
||||||
}, [setLength]);
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-2.5">
|
<div role="status" aria-live="polite" className="flex items-center gap-2.5" style={{ color: theme.node.muted }}>
|
||||||
<AgentAvatar theme={theme} />
|
<AgentWorkingDots />
|
||||||
<div className="min-w-0 max-w-[82%]">
|
<span className="text-sm leading-5">{label}</span>
|
||||||
<div className="font-mono text-sm" style={{ color: theme.node.muted }} aria-label={WORKING_TEXT}>
|
<AgentWorkingElapsed />
|
||||||
<span className="inline-block w-[96px]">{WORKING_TEXT.slice(0, Math.min(length, WORKING_TEXT.length))}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -275,11 +368,7 @@ export function AgentChatComposer({
|
|||||||
|
|
||||||
const applySlashSkill = (skill: Skill) => {
|
const applySlashSkill = (skill: Skill) => {
|
||||||
const token = `@[skill:${skill.skill_id}] `;
|
const token = `@[skill:${skill.skill_id}] `;
|
||||||
const next = slash
|
const next = slash ? `${prompt.slice(0, slash.start)}${token}${prompt.slice(slash.start + slash.query.length)}` : prompt ? `${prompt.replace(/\s+$/u, "")} ${token}` : token;
|
||||||
? `${prompt.slice(0, slash.start)}${token}${prompt.slice(slash.start + slash.query.length)}`
|
|
||||||
: prompt
|
|
||||||
? `${prompt.replace(/\s+$/u, "")} ${token}`
|
|
||||||
: token;
|
|
||||||
setSlash(null);
|
setSlash(null);
|
||||||
setSlashIndex(0);
|
setSlashIndex(0);
|
||||||
onPromptChange(next);
|
onPromptChange(next);
|
||||||
@@ -321,20 +410,35 @@ export function AgentChatComposer({
|
|||||||
return (
|
return (
|
||||||
<div className="px-3 pb-3 pt-2" onWheelCapture={(event) => event.stopPropagation()}>
|
<div className="px-3 pb-3 pt-2" onWheelCapture={(event) => event.stopPropagation()}>
|
||||||
<div
|
<div
|
||||||
className="group/composer rounded-[22px] px-3 pb-2.5 pt-3 transition-[background-color,box-shadow,transform] duration-200 focus-within:-translate-y-px"
|
className="group/composer relative rounded-[22px] px-3 pb-2.5 pt-3 transition-[background-color,box-shadow,transform] duration-200 focus-within:-translate-y-px"
|
||||||
style={{
|
style={{
|
||||||
background: theme.node.fill,
|
background: theme.node.fill,
|
||||||
color: theme.accent.primary,
|
color: theme.accent.primary,
|
||||||
boxShadow: `0 16px 40px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,0.045)`,
|
boxShadow: `0 16px 40px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,0.045)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{sending && !reducedMotion ? (
|
||||||
|
<motion.div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 rounded-[22px]"
|
||||||
|
style={{ boxShadow: `0 0 0 1px ${theme.accent.primary}` }}
|
||||||
|
animate={{ opacity: [0.3, 1, 0.3], boxShadow: [`0 0 0 1px ${theme.accent.primary}66`, `0 0 18px 2px ${theme.accent.primary}55, 0 0 0 1px ${theme.accent.primary}`, `0 0 0 1px ${theme.accent.primary}66`] }}
|
||||||
|
transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{attachments.length ? (
|
{attachments.length ? (
|
||||||
<div className="thin-scrollbar mb-2 flex gap-2 overflow-x-auto pb-1">
|
<div className="thin-scrollbar mb-2 flex gap-2 overflow-x-auto pb-1">
|
||||||
{attachments.map((item) => (
|
{attachments.map((item) => (
|
||||||
<div key={item.id} className="group relative size-14 shrink-0 overflow-hidden rounded-md" title={item.name}>
|
<div key={item.id} className="group relative size-14 shrink-0 overflow-hidden rounded-md" title={item.name}>
|
||||||
<img src={item.url} alt={item.name} className="size-full object-cover" />
|
<img src={item.url} alt={item.name} className="size-full object-cover" />
|
||||||
{onRemoveAttachment ? (
|
{onRemoveAttachment ? (
|
||||||
<button type="button" className="absolute right-1 top-1 grid size-5 place-items-center rounded-full opacity-0 shadow-sm transition group-hover:opacity-100" style={{ background: theme.toolbar.panel, color: theme.node.text }} onClick={() => onRemoveAttachment(item.id)} aria-label="移除图片">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-1 top-1 grid size-5 place-items-center rounded-full opacity-0 shadow-sm transition group-hover:opacity-100"
|
||||||
|
style={{ background: theme.toolbar.panel, color: theme.node.text }}
|
||||||
|
onClick={() => onRemoveAttachment(item.id)}
|
||||||
|
aria-label="移除图片"
|
||||||
|
>
|
||||||
<X className="size-3" />
|
<X className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -387,12 +491,27 @@ export function AgentChatComposer({
|
|||||||
<div className="flex min-w-0 items-center gap-1">
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
{onAddFiles ? (
|
{onAddFiles ? (
|
||||||
<>
|
<>
|
||||||
<input ref={fileInputRef} hidden type="file" accept="image/*" multiple onChange={(event) => {
|
<input
|
||||||
void onAddFiles(event.target.files);
|
ref={fileInputRef}
|
||||||
event.target.value = "";
|
hidden
|
||||||
}} />
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
onChange={(event) => {
|
||||||
|
void onAddFiles(event.target.files);
|
||||||
|
event.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Tooltip title="上传图片">
|
<Tooltip title="上传图片">
|
||||||
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8 !transition-transform hover:!scale-105 active:!scale-95" disabled={sending} style={{ color: theme.node.muted }} icon={<ImagePlus className="size-4" />} onClick={() => fileInputRef.current?.click()} />
|
<Button
|
||||||
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
className="!h-8 !w-8 !min-w-8 !transition-transform hover:!scale-105 active:!scale-95"
|
||||||
|
disabled={sending}
|
||||||
|
style={{ color: theme.node.muted }}
|
||||||
|
icon={<ImagePlus className="size-4" />}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -430,13 +549,33 @@ export function AgentChatComposer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentPanelTabs<T extends string>({ value, items, theme, right, onChange }: { value: T; items: { value: T; label: string; icon?: ReactNode; count?: number }[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; right?: ReactNode; onChange: (value: T) => void }) {
|
export function AgentPanelTabs<T extends string>({
|
||||||
|
value,
|
||||||
|
items,
|
||||||
|
theme,
|
||||||
|
right,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: T;
|
||||||
|
items: { value: T; label: string; icon?: ReactNode; count?: number }[];
|
||||||
|
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||||
|
right?: ReactNode;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="shrink-0 px-3 pb-1">
|
<div className="shrink-0 px-3 pb-1">
|
||||||
<div className="flex min-h-8 items-center justify-between gap-2 rounded-lg px-0.5 py-0.5" style={{ background: "transparent" }}>
|
<div className="flex min-h-8 items-center justify-between gap-2 rounded-lg px-0.5 py-0.5" style={{ background: "transparent" }}>
|
||||||
<nav className="grid min-w-0 flex-1 grid-flow-col auto-cols-fr items-center gap-0.5 text-[var(--fs-label)]" role="tablist" aria-label="Agent 面板">
|
<nav className="grid min-w-0 flex-1 grid-flow-col auto-cols-fr items-center gap-0.5 text-[var(--fs-label)]" role="tablist" aria-label="Agent 面板">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button key={item.value} type="button" role="tab" aria-selected={value === item.value} className={`inline-flex h-7 min-w-0 items-center justify-center gap-1 rounded-md px-1.5 transition-colors ${value === item.value ? "font-medium" : "font-normal"}`} style={{ background: value === item.value ? theme.node.fill : "transparent", color: value === item.value ? theme.node.text : theme.node.muted, boxShadow: value === item.value ? `0 2px 8px ${theme.spatial.shadow}` : "none" }} onClick={() => onChange(item.value)}>
|
<button
|
||||||
|
key={item.value}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={value === item.value}
|
||||||
|
className={`inline-flex h-7 min-w-0 items-center justify-center gap-1 rounded-md px-1.5 transition-colors ${value === item.value ? "font-medium" : "font-normal"}`}
|
||||||
|
style={{ background: value === item.value ? theme.node.fill : "transparent", color: value === item.value ? theme.node.text : theme.node.muted, boxShadow: value === item.value ? `0 2px 8px ${theme.spatial.shadow}` : "none" }}
|
||||||
|
onClick={() => onChange(item.value)}
|
||||||
|
>
|
||||||
<span className="shrink-0">{item.icon}</span>
|
<span className="shrink-0">{item.icon}</span>
|
||||||
<span className="min-w-0 truncate">{item.label}</span>
|
<span className="min-w-0 truncate">{item.label}</span>
|
||||||
{item.count ? <span className="shrink-0 tabular-nums opacity-60">{item.count}</span> : null}
|
{item.count ? <span className="shrink-0 tabular-nums opacity-60">{item.count}</span> : null}
|
||||||
@@ -491,7 +630,8 @@ function toolCardState(title: string, text: string, detail?: unknown) {
|
|||||||
if (objectField(detail, "status") === "noop" || /未生效|无需|没有找到|没有.*可|已存在/.test(raw)) return { label: "未生效", color: "#d97706", softBg: "rgba(217,119,6,.04)", icon: <CircleAlert className="size-4" />, isError: false };
|
if (objectField(detail, "status") === "noop" || /未生效|无需|没有找到|没有.*可|已存在/.test(raw)) return { label: "未生效", color: "#d97706", softBg: "rgba(217,119,6,.04)", icon: <CircleAlert className="size-4" />, isError: false };
|
||||||
if (/拒绝|取消/.test(raw) || lower.includes("rejected")) return { label: "拒绝执行", color: "#dc2626", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true };
|
if (/拒绝|取消/.test(raw) || lower.includes("rejected")) return { label: "拒绝执行", color: "#dc2626", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true };
|
||||||
if (/失败|错误/.test(raw) || lower.includes("failed") || lower.includes("error")) return { label: "执行失败", color: "#dc2626", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true };
|
if (/失败|错误/.test(raw) || lower.includes("failed") || lower.includes("error")) return { label: "执行失败", color: "#dc2626", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true };
|
||||||
if (/完成|成功/.test(raw) || lower.includes("completed") || lower.includes("succeeded")) return { label: tool === "canvas_apply_ops" || /画布操作/.test(title) ? "已批准执行" : "执行完成", color: "#16a34a", softBg: "rgba(22,163,74,.04)", icon: <CheckCircle2 className="size-4" />, isError: false };
|
if (/完成|成功/.test(raw) || lower.includes("completed") || lower.includes("succeeded"))
|
||||||
|
return { label: tool === "canvas_apply_ops" || /画布操作/.test(title) ? "已批准执行" : "执行完成", color: "#16a34a", softBg: "rgba(22,163,74,.04)", icon: <CheckCircle2 className="size-4" />, isError: false };
|
||||||
return { label: "工具调用", color: "#2563eb", softBg: "rgba(37,99,235,.04)", icon: <Wrench className="size-4" />, isError: false };
|
return { label: "工具调用", color: "#2563eb", softBg: "rgba(37,99,235,.04)", icon: <Wrench className="size-4" />, isError: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
48
web/src/hooks/use-count-up.ts
Normal file
48
web/src/hooks/use-count-up.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rolls the displayed number from `from` (defaults to the previous target)
|
||||||
|
* to `target` with an ease-out curve, so headline figures animate when data
|
||||||
|
* refreshes or a chart selection changes.
|
||||||
|
*
|
||||||
|
* Absorbed from boardui hooks/use-count-up.ts; adapted to this project:
|
||||||
|
* - `prefers-reduced-motion` is respected: the value snaps to target at once.
|
||||||
|
* - `from` is exposed so counters can start at 0 on first mount (default
|
||||||
|
* keeps boardui semantics: start at the previous value, no initial jump).
|
||||||
|
* - `round` is applied per-frame so integer counters never show fractions.
|
||||||
|
*/
|
||||||
|
export function useCountUp(target: number, duration = 320, from?: number) {
|
||||||
|
const reduced = useRef(false);
|
||||||
|
if (typeof window !== "undefined" && !reduced.current) {
|
||||||
|
reduced.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [display, setDisplay] = useState(from ?? target);
|
||||||
|
const fromRef = useRef(from ?? target);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (reduced.current) {
|
||||||
|
fromRef.current = target;
|
||||||
|
setDisplay(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const from = fromRef.current;
|
||||||
|
if (from === target) return;
|
||||||
|
const start = performance.now();
|
||||||
|
let raf = 0;
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const t = Math.min(1, (now - start) / duration);
|
||||||
|
const value = Math.round(from + (target - from) * easeOutCubic(t));
|
||||||
|
fromRef.current = value;
|
||||||
|
setDisplay(value);
|
||||||
|
if (t < 1) raf = requestAnimationFrame(tick);
|
||||||
|
else fromRef.current = target;
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, [target, duration]);
|
||||||
|
|
||||||
|
return display;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { App, Button, DatePicker, Drawer, Form, Input, Modal, Select, Tabs, Tag } from "antd";
|
import { App, Button, DatePicker, Drawer, Form, Input, Modal, Select, Tabs, Tag } from "antd";
|
||||||
import { Tooltip } from "@/components/ui/base/tooltip";
|
import { Tooltip } from "@/components/ui/base/tooltip";
|
||||||
|
import { useCountUp } from "@/hooks/use-count-up";
|
||||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
import type { ColumnsType } from "antd/es/table";
|
import type { ColumnsType } from "antd/es/table";
|
||||||
@@ -447,21 +448,21 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
|||||||
<AnalyticsHealthCard
|
<AnalyticsHealthCard
|
||||||
icon={<UsersRound className="size-4" />}
|
icon={<UsersRound className="size-4" />}
|
||||||
label="活跃用户"
|
label="活跃用户"
|
||||||
value={data ? formatNumber(data.kpi.activeUsers) : "--"}
|
value={data ? <AnimatedHealthValue target={data.kpi.activeUsers} format={formatNumber} /> : "--"}
|
||||||
trend={formatCountDelta(currentTrend?.activeUsers, previousTrend?.activeUsers)}
|
trend={formatCountDelta(currentTrend?.activeUsers, previousTrend?.activeUsers)}
|
||||||
detail={data ? `DAU ${formatNumber(data.kpi.dau)} · WAU ${formatNumber(data.kpi.wau)} · MAU ${formatNumber(data.kpi.mau)}` : undefined}
|
detail={data ? `DAU ${formatNumber(data.kpi.dau)} · WAU ${formatNumber(data.kpi.wau)} · MAU ${formatNumber(data.kpi.mau)}` : undefined}
|
||||||
/>
|
/>
|
||||||
<AnalyticsHealthCard
|
<AnalyticsHealthCard
|
||||||
icon={<Workflow className="size-4" />}
|
icon={<Workflow className="size-4" />}
|
||||||
label="生成任务"
|
label="生成任务"
|
||||||
value={data ? formatNumber(data.kpi.generationTasks) : "--"}
|
value={data ? <AnimatedHealthValue target={data.kpi.generationTasks} format={formatNumber} /> : "--"}
|
||||||
trend={formatCountDelta(currentTrend?.tasks, previousTrend?.tasks)}
|
trend={formatCountDelta(currentTrend?.tasks, previousTrend?.tasks)}
|
||||||
detail={data ? `上游请求 ${formatNumber(data.kpi.upstreamRequests)} · 队列 ${formatNumber(data.kpi.currentQueuedTasks)}` : undefined}
|
detail={data ? `上游请求 ${formatNumber(data.kpi.upstreamRequests)} · 队列 ${formatNumber(data.kpi.currentQueuedTasks)}` : undefined}
|
||||||
/>
|
/>
|
||||||
<AnalyticsHealthCard
|
<AnalyticsHealthCard
|
||||||
icon={<Gauge className="size-4" />}
|
icon={<Gauge className="size-4" />}
|
||||||
label="服务质量"
|
label="服务质量"
|
||||||
value={data ? percent(data.kpi.successRate) : "--"}
|
value={data ? <AnimatedHealthValue target={data.kpi.successRate} format={percent} /> : "--"}
|
||||||
trend={formatRateDelta(currentTrend?.requestSuccessRate, previousTrend?.requestSuccessRate)}
|
trend={formatRateDelta(currentTrend?.requestSuccessRate, previousTrend?.requestSuccessRate)}
|
||||||
detail={data ? `P95 ${formatDuration(data.kpi.p95DurationMs)}` : undefined}
|
detail={data ? `P95 ${formatDuration(data.kpi.p95DurationMs)}` : undefined}
|
||||||
tone={data && data.kpi.successRate < 90 ? "warning" : "success"}
|
tone={data && data.kpi.successRate < 90 ? "warning" : "success"}
|
||||||
@@ -692,6 +693,11 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AnimatedHealthValue({ target, format }: { target: number; format: (value: number) => string }) {
|
||||||
|
const display = useCountUp(target, 420, 0);
|
||||||
|
return <>{format(display)}</>;
|
||||||
|
}
|
||||||
|
|
||||||
function AnalyticsHealthCard({ icon, label, value, trend, detail, tone = "neutral" }: { icon: ReactNode; label: string; value: ReactNode; trend?: { value: string; tone?: AdminStatusTone }; detail?: string; tone?: AdminStatusTone }) {
|
function AnalyticsHealthCard({ icon, label, value, trend, detail, tone = "neutral" }: { icon: ReactNode; label: string; value: ReactNode; trend?: { value: string; tone?: AdminStatusTone }; detail?: string; tone?: AdminStatusTone }) {
|
||||||
return (
|
return (
|
||||||
<article className="admin-analytics-health-card" data-tone={tone}>
|
<article className="admin-analytics-health-card" data-tone={tone}>
|
||||||
|
|||||||
Reference in New Issue
Block a user