From 02608bb6a43036855b8036ed97dd0f090762d1b4 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Wed, 15 Jul 2026 16:53:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20=E5=8A=9E=E5=85=AC=E5=AE=A4?= =?UTF-8?q?=E5=8A=A0=E8=B5=B0=E4=BD=8D=E7=BC=96=E6=8E=92(=E8=BF=9B?= =?UTF-8?q?=E5=9C=BA=20/=20=E6=95=B2=E9=94=AE=E7=9B=98=20/=20=E9=80=92?= =?UTF-8?q?=E7=AE=80=E6=8A=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 专家被派发 → 从门口走进来 → 在工位敲键盘 → 干完起身走到协调者桌前 递简报(手里真拿一页) → 回工位。协调者的白板仍是思考/综合流。 为什么做这个:重看参考项目(ai-office-react),它的 Spine 动画只有 idle 和 walking 两个——"好玩"来自角色在房间里走动,不是画质。上一版把人钉死 在工位上只让手动,这才是不好玩的根因。 实现:全 CSS 关键帧,无 rAF / 无定时器 / 无状态机。 - 直播(streaming):工位随事件到达而挂载,延迟一律 0,动画即真实进度。 - 复盘:按事件时间戳算出各自的出场/交付时刻,整段压进 PLAY_MS 当一出戏放; 敲击次数由"工作时长 ÷ 敲击周期"换算,所以快的人敲几下、慢的人敲一路。 - 收工时刻强制不早于"进门 + 敲一下",否则压缩比大时人还没进门就来递简报。 - 角色层与桌子层分开画,人才能走到别人桌前而不被自己那张桌子焊死。 - 补了腿:原来是"永远被桌子挡住的半身像",一走出工位就成了悬空上半身。 - reduced-motion 直接就位不演。 Co-Authored-By: Claude Opus 4.8 --- .../frontend/src/components/OfficeView.tsx | 316 ++++++++++++++---- 1 file changed, 247 insertions(+), 69 deletions(-) diff --git a/sundynix-desktop/frontend/src/components/OfficeView.tsx b/sundynix-desktop/frontend/src/components/OfficeView.tsx index ca2693b..b7249a2 100644 --- a/sundynix-desktop/frontend/src/components/OfficeView.tsx +++ b/sundynix-desktop/frontend/src/components/OfficeView.tsx @@ -1,11 +1,16 @@ -import { useMemo } from "react"; +import { useMemo, type CSSProperties } from "react"; import { deriveTeam, teamNow, type RunPhase, type TeamSeat } from "../lib/run"; import type { ExecEvent } from "../lib/api"; -// 2D 卡通办公室:把多智能体协调演成「协调者在白板前统筹 + 专家在工位干活」。 +// 2D 卡通办公室:把多智能体协调演成一出办公室短剧。 +// 专家被派发 → 从门口走进来 → 在工位敲键盘 → 干完起身走到协调者桌前递简报 → 回工位。 // 手绘贴纸风(粗描边 + 平涂 + 大头身),纯 SVG 手写——不引 PixiJS/Spine、不下任何素材, -// 因此没有授权问题,整个场景几 KB。动效=状态编码(敲键盘/思考流),并守 reduced-motion。 -// 数据与「卡片看板」同源(deriveTeam),换皮不换数据。 +// 因此没有授权问题,整个场景几 KB。数据与「卡片看板」同源(deriveTeam),换皮不换数据。 +// +// 走位全部是 CSS 关键帧(无 rAF / 无定时器 / 无状态机): +// 直播时(streaming) 工位随事件到达而挂载,延迟一律 0,动画即时反映真实进度; +// 复盘时按事件时间戳算出各自的出场/交付时刻,压进 PLAY_MS 内当一出戏放。 +// 动效=状态编码,不是装饰;并守 prefers-reduced-motion。 const VB_W = 720; const VB_H = 460; @@ -13,6 +18,7 @@ const FLOOR_Y = 200; const OUTLINE = "#2f2a26"; const SKIN = "#f7d9bd"; +const PANTS = "#4a5568"; const WALL = "#f3ede4"; const FLOOR = "#ddd0be"; const DESK_TOP = "#ffffff"; @@ -26,6 +32,26 @@ const ERR = "#fb7185"; const SHIRTS = ["#7c5cf6", "#38bdf8", "#34d399", "#fbbf24", "#fb7185", "#a78bfa", "#22d3ee", "#f472b6"]; const HAIRS = ["#3f3229", "#6b4a2f", "#c8913f", "#2e3f52", "#8c4a4a", "#4a3a6b", "#5c5148", "#7a4a2a"]; +// 门口:出场点。角色从这里走向自己的工位。 +const DOOR_X = 84; +const DOOR_Y = 198; +const DOOR_SCALE = 0.5; + +// 协调者机位;递简报时专家走到它桌子两侧。 +const LEAD_X = 360; +const LEAD_Y = 232; +const LEAD_SCALE = 0.62; +const HANDOFF_DX = 86; +const HANDOFF_Y = 250; +const HANDOFF_SCALE = 0.66; + +const ENTER_MS = 950; +const HANDOFF_MS = 1900; +const TYPE_MS = 340; // 单次敲击周期,用来把「工作时长」换算成敲击次数 +const PLAY_MS = 6000; // 复盘时整段戏的目标时长上限(真实跨度更短就按真实节奏放) + +type Vars = CSSProperties & Record; + function toneOf(s: TeamSeat): string { return s.status === "error" ? ERR : s.status === "running" ? RUN : OK; } @@ -77,11 +103,12 @@ function Hair({ color, style }: { color: string; style: number }) { ); } +// typing: undefined=不敲;{count,delay}=敲 count 次(count=Infinity 即一直敲) function Chibi({ shirt, hair, style, - working, + typing, happy, sad, delay, @@ -89,19 +116,26 @@ function Chibi({ shirt: string; hair: string; style: number; - working?: boolean; + typing?: { count: number; delay: number }; happy?: boolean; sad?: boolean; delay: number; }) { - const d = `${delay}s`; + const armStyle: Vars | undefined = typing + ? { animationName: "sdx-type", animationDuration: `${TYPE_MS}ms`, animationTimingFunction: "ease-in-out", animationIterationCount: typing.count === Infinity ? "infinite" : typing.count, animationDelay: `${typing.delay}s` } + : undefined; return ( - - {/* 手臂:working 时上下敲键盘 */} - + + {/* 腿:坐着时整段被桌子挡住,只有走位(入场/递简报)时才看得见。 + 没有腿的话人一离开工位就变成悬在影子上方的半身像。 */} + + + + {/* 手臂:敲键盘 */} + - + @@ -167,17 +201,27 @@ function Bubble({ status, delay }: { status: TeamSeat["status"]; delay: number } ); } -// 工位:白桌 + 显示器辉光(状态色)+ 桌面铭牌。桌子挡住下半身,人只露上半身。 -function Deskette({ name, sub, tone, running, w = 200 }: { name: string; sub: string; tone: string; running?: boolean; w?: number }) { +// 手里那页简报:只在递交那一程可见,到协调者桌前就「放下」了。 +function Paper({ dur, delay }: { dur: number; delay: number }) { + return ( + + + + + ); +} + +// 工位(桌子层):白桌 + 显示器辉光(状态色)+ 桌面铭牌。桌子挡住下半身,人只露上半身。 +function Deskette({ name, sub, tone, glow, w = 200 }: { name: string; sub: string; tone: string; glow?: boolean; w?: number }) { const hw = w / 2; return ( {/* 显示器(背对我们,屏幕辉光溢出到桌面) */} - + - + {/* 桌面 + 桌沿 */} @@ -200,24 +244,94 @@ function Deskette({ name, sub, tone, running, w = 200 }: { name: string; sub: st ); } -function Seat({ seat, x, y, scale, index, bubble }: { seat: TeamSeat; x: number; y: number; scale: number; index: number; bubble: boolean }) { - const tone = toneOf(seat); - const running = seat.status === "running"; - const sub = running ? "工作中…" : seat.status === "error" ? "失败" : `已回报 ${fmtMs(seat.ms)}`; - return ( - - +// 一位专家的完整档期:出场时刻、敲多久、何时去递简报。 +interface Cue { + seat: TeamSeat; + x: number; + y: number; + scale: number; + index: number; + bubble: boolean; + enterAt: number; // 秒 + typeAt: number; + typeCount: number; + handoffAt: number; + bubbleAt: number; +} + +// 角色层:走位全在这里。桌子不跟着走,所以单独画在上层。 +function Actor({ cue, reduced }: { cue: Cue; reduced: boolean }) { + const { seat, x, y, scale: s, index } = cue; + const done = seat.status === "done"; + + // 入场:从门口走到工位(局部坐标 = 场景增量 ÷ 本工位缩放)。 + const enterVars: Vars = { + "--ex": `${(DOOR_X - x) / s}px`, + "--ey": `${(DOOR_Y - y) / s}px`, + "--es": DOOR_SCALE / s, + animationDelay: `${cue.enterAt}s`, + }; + // 递简报:走到协调者桌子一侧(按序号左右分流,免得挤成一堆),停一拍,再走回来。 + const side = index % 2 === 0 ? -1 : 1; + const handoffVars: Vars = { + "--hx": `${(LEAD_X + side * HANDOFF_DX - x) / s}px`, + "--hy": `${(HANDOFF_Y - y) / s}px`, + "--hs": HANDOFF_SCALE / s, + animationDelay: `${cue.handoffAt}s`, + }; + + const typing = + seat.status === "running" + ? { count: Infinity, delay: cue.typeAt } + : cue.typeCount > 0 + ? { count: cue.typeCount, delay: cue.typeAt } + : undefined; + + const body = ( + <> - {bubble && } - + {done && !reduced && } + {cue.bubble && } + + ); + + // reduced-motion:直接就位,不演。 + if (reduced) { + return ( + + + {body} + + ); + } + + return ( + + + + {done ? ( + + + + {body} + + + ) : ( + <> + + {body} + + )} + + ); } @@ -231,19 +345,25 @@ function Room() { {/* 地毯 */} - {/* 窗 */} + {/* 门:专家从这里进场 */} - - - + + + {/* 挂钟 */} - + + {/* 窗 */} + + + + + {/* 绿植 */} - + @@ -253,59 +373,98 @@ function Room() { } export function OfficeView({ events, output, phase }: { events: ExecEvent[]; output?: string; phase?: RunPhase }) { - const team = useMemo(() => deriveTeam(events, teamNow(events, phase === "streaming")), [events, phase]); + const streaming = phase === "streaming"; + const team = useMemo(() => deriveTeam(events, teamNow(events, streaming)), [events, streaming]); const { lead, seats, t0, t1 } = team; const span = Math.max(t1 - t0, 1); - const streaming = phase === "streaming"; const thinking = truncate((output ?? "").slice(-110).replace(/\s+/g, " "), 110); - - if (!lead && seats.length === 0) { - return
暂无协调轨迹。
; - } + const reduced = useMemo( + () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches, + [], + ); // 排位:≤5 人一排(前排),否则拆两排,后排小、前排大 —— 用缩放造纵深。 // 两排时后排不出头顶气泡:气泡很高,会顶到协调者的桌子;状态仍由显示器辉光和铭牌圆点表达。 const rows: TeamSeat[][] = seats.length <= 5 ? [seats] : [seats.slice(0, Math.ceil(seats.length / 2)), seats.slice(Math.ceil(seats.length / 2))]; const rowCfg = rows.length === 1 ? [{ y: 430, s: 1 }] : [{ y: 322, s: 0.6 }, { y: 438, s: 0.82 }]; - const placed = rows.flatMap((row, r) => { + // 档期:直播时事件到哪演到哪(延迟一律 0);复盘时按时间戳排,整段压进 PLAY_MS。 + const rate = streaming ? 0 : Math.min(1, PLAY_MS / span); + const cues: Cue[] = rows.flatMap((row, r) => { const pitch = VB_W / row.length; const cfg = rowCfg[r]; const scale = Math.min(cfg.s * Math.min(pitch / 230, 1), 0.9); - return row.map((seat, i) => ({ - seat, - x: pitch * (i + 0.5), - y: cfg.y, - scale, - index: seats.indexOf(seat), - bubble: rows.length === 1 || r === 1, - })); + return row.map((seat, i) => { + const enterMs = streaming ? 0 : (seat.startTS - t0) * rate; + // 收工时刻至少要在「走到工位 + 敲一下」之后,否则压缩比大时会人还没进门就来递简报。 + const doneMs = streaming ? 0 : Math.max((seat.endTS ?? t1) - t0, 0) * rate; + const workEndMs = Math.max(doneMs, enterMs + ENTER_MS + 400); + const typeAtMs = enterMs + ENTER_MS; + return { + seat, + x: pitch * (i + 0.5), + y: cfg.y, + scale, + index: seats.indexOf(seat), + bubble: rows.length === 1 || r === 1, + enterAt: enterMs / 1000, + typeAt: typeAtMs / 1000, + typeCount: streaming ? 0 : Math.max(1, Math.round((workEndMs - typeAtMs) / TYPE_MS)), + handoffAt: workEndMs / 1000, + bubbleAt: + (streaming + ? 0 + : seat.status === "done" + ? workEndMs + HANDOFF_MS + : seat.status === "error" + ? workEndMs + : typeAtMs) / 1000, + }; + }); }); + if (!lead && seats.length === 0) { + return
暂无协调轨迹。
; + } + const leadTone = streaming ? RUN : lead?.status === "error" ? ERR : OK; return (
@@ -314,9 +473,9 @@ export function OfficeView({ events, output, phase }: { events: ExecEvent[]; out {/* 白板:协调者的思考/综合过程 */} - - - + + +
{streaming ? "综合中…" : lead?.status === "error" ? "协调失败" : lead?.status === "done" ? "已综合" : "协调中"} @@ -339,13 +498,28 @@ export function OfficeView({ events, output, phase }: { events: ExecEvent[]; out + {/* 协调者 */} + + + + + + {/* 派发连线:只画正在工作的工位 */} - {placed - .filter((p) => p.seat.status === "running") - .map((p) => ( + {cues + .filter((c) => c.seat.status === "running") + .map((c) => ( ))} - {/* 协调者:白板前,比前排小 → 纵深 */} - - - - - - - {placed.map((p) => ( - + {/* 角色层在前、桌子层在后:角色才能走到别人桌前而不被自己那张桌子焊死 */} + {cues.map((c) => ( + + ))} + {cues.map((c) => ( + + + ))}