feat(desktop): 办公室加走位编排(进场 / 敲键盘 / 递简报)
专家被派发 → 从门口走进来 → 在工位敲键盘 → 干完起身走到协调者桌前 递简报(手里真拿一页) → 回工位。协调者的白板仍是思考/综合流。 为什么做这个:重看参考项目(ai-office-react),它的 Spine 动画只有 idle 和 walking 两个——"好玩"来自角色在房间里走动,不是画质。上一版把人钉死 在工位上只让手动,这才是不好玩的根因。 实现:全 CSS 关键帧,无 rAF / 无定时器 / 无状态机。 - 直播(streaming):工位随事件到达而挂载,延迟一律 0,动画即真实进度。 - 复盘:按事件时间戳算出各自的出场/交付时刻,整段压进 PLAY_MS 当一出戏放; 敲击次数由"工作时长 ÷ 敲击周期"换算,所以快的人敲几下、慢的人敲一路。 - 收工时刻强制不早于"进门 + 敲一下",否则压缩比大时人还没进门就来递简报。 - 角色层与桌子层分开画,人才能走到别人桌前而不被自己那张桌子焊死。 - 补了腿:原来是"永远被桌子挡住的半身像",一走出工位就成了悬空上半身。 - reduced-motion 直接就位不演。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, string | number>;
|
||||
|
||||
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 (
|
||||
<g className={working ? "sdx-bob" : "sdx-breathe"} style={{ animationDelay: d }}>
|
||||
{/* 手臂:working 时上下敲键盘 */}
|
||||
<g className={working ? "sdx-arm sdx-type" : "sdx-arm"} style={{ animationDelay: d }}>
|
||||
<g className="sdx-breathe" style={{ animationDelay: `${delay}s` }}>
|
||||
{/* 腿:坐着时整段被桌子挡住,只有走位(入场/递简报)时才看得见。
|
||||
没有腿的话人一离开工位就变成悬在影子上方的半身像。 */}
|
||||
<rect x={-12} y={-50} width={11} height={50} rx={5.5} fill={PANTS} stroke={OUTLINE} strokeWidth={2.2} />
|
||||
<rect x={1} y={-50} width={11} height={50} rx={5.5} fill={PANTS} stroke={OUTLINE} strokeWidth={2.2} />
|
||||
|
||||
{/* 手臂:敲键盘 */}
|
||||
<g className="sdx-arm" style={armStyle}>
|
||||
<rect x={-30} y={-90} width={11} height={36} rx={5.5} fill={shirt} stroke={OUTLINE} strokeWidth={2.4} />
|
||||
</g>
|
||||
<g className={working ? "sdx-arm sdx-type" : "sdx-arm"} style={{ animationDelay: `${delay + 0.18}s` }}>
|
||||
<g className="sdx-arm" style={armStyle ? { ...armStyle, animationDelay: `${(typing?.delay ?? 0) + 0.17}s` } : undefined}>
|
||||
<rect x={19} y={-90} width={11} height={36} rx={5.5} fill={shirt} stroke={OUTLINE} strokeWidth={2.4} />
|
||||
</g>
|
||||
|
||||
@@ -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 (
|
||||
<g className="sdx-paper" style={{ animationDuration: `${dur}ms`, animationDelay: `${delay}s` }}>
|
||||
<rect x={22} y={-72} width={17} height={22} rx={2} fill="#fff" stroke={OUTLINE} strokeWidth={2} transform="rotate(-8 30 -61)" />
|
||||
<path d="M 26 -66 L 35 -67 M 26 -61 L 35 -62 M 26 -56 L 32 -57" stroke={OUTLINE} strokeWidth={1.4} strokeLinecap="round" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
// 工位(桌子层):白桌 + 显示器辉光(状态色)+ 桌面铭牌。桌子挡住下半身,人只露上半身。
|
||||
function Deskette({ name, sub, tone, glow, w = 200 }: { name: string; sub: string; tone: string; glow?: boolean; w?: number }) {
|
||||
const hw = w / 2;
|
||||
return (
|
||||
<g>
|
||||
{/* 显示器(背对我们,屏幕辉光溢出到桌面) */}
|
||||
<g transform={`translate(${hw - 34}, 0)`}>
|
||||
<ellipse cx={0} cy={-46} rx={26} ry={9} fill={tone} opacity={running ? 0.35 : 0.18} className={running ? "sdx-glow" : undefined} />
|
||||
<ellipse cx={0} cy={-46} rx={26} ry={9} fill={tone} opacity={glow ? 0.35 : 0.18} className={glow ? "sdx-glow" : undefined} />
|
||||
<rect x={-3} y={-58} width={6} height={12} fill="#c9c2b8" stroke={OUTLINE} strokeWidth={2} />
|
||||
<rect x={-21} y={-84} width={42} height={28} rx={4} fill="#eceae6" stroke={OUTLINE} strokeWidth={2.4} />
|
||||
<rect x={-17} y={-80} width={34} height={20} rx={2} fill={tone} opacity={running ? 0.8 : 0.45} />
|
||||
<rect x={-17} y={-80} width={34} height={20} rx={2} fill={tone} opacity={glow ? 0.8 : 0.45} />
|
||||
</g>
|
||||
{/* 桌面 + 桌沿 */}
|
||||
<rect x={-hw} y={-46} width={w} height={9} rx={4} fill={DESK_TOP} stroke={OUTLINE} strokeWidth={2.6} />
|
||||
@@ -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 (
|
||||
<g transform={`translate(${x}, ${y}) scale(${scale})`}>
|
||||
<ellipse cx={0} cy={2} rx={46} ry={9} fill="#000" opacity={0.09} />
|
||||
// 一位专家的完整档期:出场时刻、敲多久、何时去递简报。
|
||||
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 = (
|
||||
<>
|
||||
<Chibi
|
||||
shirt={SHIRTS[index % SHIRTS.length]}
|
||||
hair={HAIRS[index % HAIRS.length]}
|
||||
style={index % 3}
|
||||
working={running}
|
||||
happy={seat.status === "done"}
|
||||
typing={typing}
|
||||
happy={done}
|
||||
sad={seat.status === "error"}
|
||||
delay={index * 0.23}
|
||||
/>
|
||||
{bubble && <Bubble status={seat.status} delay={index * 0.23} />}
|
||||
<Deskette name={seat.name} sub={sub} tone={tone} running={running} />
|
||||
{done && !reduced && <Paper dur={HANDOFF_MS} delay={cue.handoffAt} />}
|
||||
{cue.bubble && <Bubble status={seat.status} delay={cue.bubbleAt} />}
|
||||
</>
|
||||
);
|
||||
|
||||
// reduced-motion:直接就位,不演。
|
||||
if (reduced) {
|
||||
return (
|
||||
<g transform={`translate(${x}, ${y}) scale(${s})`}>
|
||||
<ellipse cx={0} cy={2} rx={46} ry={9} fill="#000" opacity={0.09} />
|
||||
{body}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<g transform={`translate(${x}, ${y}) scale(${s})`}>
|
||||
<g className="sdx-enter" style={enterVars}>
|
||||
<g className="sdx-walk" style={{ "--wcount": Math.round(ENTER_MS / 300), animationDelay: `${cue.enterAt}s` } as Vars}>
|
||||
{done ? (
|
||||
<g className="sdx-handoff" style={handoffVars}>
|
||||
<g className="sdx-walk" style={{ "--wcount": Math.round(HANDOFF_MS / 300), animationDelay: `${cue.handoffAt}s` } as Vars}>
|
||||
<ellipse cx={0} cy={2} rx={46} ry={9} fill="#000" opacity={0.09} />
|
||||
{body}
|
||||
</g>
|
||||
</g>
|
||||
) : (
|
||||
<>
|
||||
<ellipse cx={0} cy={2} rx={46} ry={9} fill="#000" opacity={0.09} />
|
||||
{body}
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -231,19 +345,25 @@ function Room() {
|
||||
<rect x={0} y={FLOOR_Y - 5} width={VB_W} height={5} fill="#cabfae" />
|
||||
{/* 地毯 */}
|
||||
<ellipse cx={360} cy={402} rx={318} ry={48} fill="#e9dfd0" stroke="#cfc2ae" strokeWidth={2} />
|
||||
{/* 窗 */}
|
||||
{/* 门:专家从这里进场 */}
|
||||
<g>
|
||||
<rect x={38} y={30} width={112} height={92} rx={5} fill="#cfe9f5" stroke={OUTLINE} strokeWidth={2.6} />
|
||||
<path d="M 94 30 L 94 122 M 38 76 L 150 76" stroke={OUTLINE} strokeWidth={2.2} />
|
||||
<path d="M 48 116 L 88 38 L 104 38 L 64 116 Z" fill="#fff" opacity={0.35} />
|
||||
<rect x={44} y={86} width={80} height={114} rx={4} fill="#e4d9c6" stroke={OUTLINE} strokeWidth={2.8} />
|
||||
<rect x={52} y={94} width={64} height={98} rx={2} fill="#efe6d6" stroke={OUTLINE} strokeWidth={2} />
|
||||
<circle cx={110} cy={146} r={3.6} fill={OUTLINE} />
|
||||
</g>
|
||||
{/* 挂钟 */}
|
||||
<g transform="translate(660, 54)">
|
||||
<g transform="translate(534, 58)">
|
||||
<circle r={22} fill="#fff" stroke={OUTLINE} strokeWidth={2.6} />
|
||||
<path d="M 0 0 L 0 -13 M 0 0 L 9 5" stroke={OUTLINE} strokeWidth={2.4} strokeLinecap="round" />
|
||||
</g>
|
||||
{/* 窗 */}
|
||||
<g>
|
||||
<rect x={578} y={34} width={116} height={92} rx={5} fill="#cfe9f5" stroke={OUTLINE} strokeWidth={2.6} />
|
||||
<path d="M 636 34 L 636 126 M 578 80 L 694 80" stroke={OUTLINE} strokeWidth={2.2} />
|
||||
<path d="M 588 120 L 626 42 L 642 42 L 604 120 Z" fill="#fff" opacity={0.35} />
|
||||
</g>
|
||||
{/* 绿植 */}
|
||||
<g transform="translate(668, 186)">
|
||||
<g transform="translate(636, 186)">
|
||||
<path d="M 0 -6 C -22 -14 -26 -44 -4 -52 C -12 -30 -4 -18 0 -6 Z" fill="#4a9c6d" stroke={OUTLINE} strokeWidth={2.2} strokeLinejoin="round" />
|
||||
<path d="M 0 -6 C 22 -18 24 -50 2 -58 C 12 -32 4 -18 0 -6 Z" fill="#5cb07d" stroke={OUTLINE} strokeWidth={2.2} strokeLinejoin="round" />
|
||||
<path d="M -13 -6 L 13 -6 L 9 16 L -9 16 Z" fill="#e6e0d6" stroke={OUTLINE} strokeWidth={2.4} strokeLinejoin="round" />
|
||||
@@ -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 <div className="p-6 text-center text-xs text-slate-600">暂无协调轨迹。</div>;
|
||||
}
|
||||
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 <div className="p-6 text-center text-xs text-slate-600">暂无协调轨迹。</div>;
|
||||
}
|
||||
|
||||
const leadTone = streaming ? RUN : lead?.status === "error" ? ERR : OK;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-auto">
|
||||
<style>{`
|
||||
@keyframes sdx-breathe{0%,100%{transform:translateY(0)}50%{transform:translateY(-1.5px)}}
|
||||
@keyframes sdx-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}}
|
||||
@keyframes sdx-type{0%,100%{transform:rotate(-7deg)}50%{transform:rotate(9deg)}}
|
||||
@keyframes sdx-type{0%,100%{transform:rotate(0deg)}50%{transform:rotate(11deg)}}
|
||||
@keyframes sdx-walk{0%,100%{transform:rotate(0deg) translateY(0)}25%{transform:rotate(-3.5deg) translateY(-2.5px)}75%{transform:rotate(3.5deg) translateY(-2.5px)}}
|
||||
@keyframes sdx-enter{
|
||||
0%{transform:translate(var(--ex),var(--ey)) scale(var(--es));opacity:0}
|
||||
12%{opacity:1}
|
||||
100%{transform:translate(0,0) scale(1);opacity:1}
|
||||
}
|
||||
@keyframes sdx-handoff{
|
||||
0%{transform:translate(0,0) scale(1)}
|
||||
38%{transform:translate(var(--hx),var(--hy)) scale(var(--hs))}
|
||||
62%{transform:translate(var(--hx),var(--hy)) scale(var(--hs))}
|
||||
100%{transform:translate(0,0) scale(1)}
|
||||
}
|
||||
@keyframes sdx-paper{0%,8%{opacity:0}14%{opacity:1}44%{opacity:1}52%,100%{opacity:0}}
|
||||
@keyframes sdx-dot{0%,80%,100%{transform:translateY(0);opacity:.45}40%{transform:translateY(-3.5px);opacity:1}}
|
||||
@keyframes sdx-glow{0%,100%{opacity:.2}50%{opacity:.5}}
|
||||
@keyframes sdx-pop{from{transform:translateY(4px) scale(.85);opacity:0}to{transform:translateY(0) scale(1);opacity:1}}
|
||||
@keyframes sdx-flow{to{stroke-dashoffset:-18}}
|
||||
@keyframes sdx-blink{50%{opacity:0}}
|
||||
.sdx-breathe{animation:sdx-breathe 3.4s ease-in-out infinite}
|
||||
.sdx-bob{animation:sdx-bob 1.6s ease-in-out infinite}
|
||||
.sdx-arm{transform-box:fill-box;transform-origin:50% 8%}
|
||||
.sdx-type{animation:sdx-type .34s ease-in-out infinite}
|
||||
.sdx-enter{transform-box:fill-box;transform-origin:50% 100%;animation:sdx-enter ${ENTER_MS}ms ease-out both}
|
||||
.sdx-handoff{transform-box:fill-box;transform-origin:50% 100%;animation:sdx-handoff ${HANDOFF_MS}ms ease-in-out both}
|
||||
.sdx-walk{transform-box:fill-box;transform-origin:50% 100%;animation:sdx-walk 300ms ease-in-out var(--wcount) both}
|
||||
.sdx-paper{animation-name:sdx-paper;animation-timing-function:linear;animation-fill-mode:both}
|
||||
.sdx-dot{transform-box:fill-box;transform-origin:50% 50%;animation:sdx-dot 1.1s ease-in-out infinite}
|
||||
.sdx-glow{animation:sdx-glow 1.8s ease-in-out infinite}
|
||||
.sdx-pop{transform-box:fill-box;transform-origin:50% 100%;animation:sdx-pop .32s cubic-bezier(.34,1.56,.64,1) both}
|
||||
.sdx-flow{stroke-dasharray:5 5;animation:sdx-flow 1.1s linear infinite}
|
||||
.sdx-caret{animation:sdx-blink 1.05s step-end infinite}
|
||||
@media(prefers-reduced-motion:reduce){
|
||||
.sdx-breathe,.sdx-bob,.sdx-type,.sdx-dot,.sdx-glow,.sdx-pop,.sdx-flow,.sdx-caret{animation:none}
|
||||
.sdx-breathe,.sdx-arm,.sdx-enter,.sdx-handoff,.sdx-walk,.sdx-paper,.sdx-dot,.sdx-glow,.sdx-pop,.sdx-flow,.sdx-caret{animation:none}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
@@ -314,9 +473,9 @@ export function OfficeView({ events, output, phase }: { events: ExecEvent[]; out
|
||||
|
||||
{/* 白板:协调者的思考/综合过程 */}
|
||||
<g>
|
||||
<rect x={196} y={18} width={330} height={94} rx={6} fill="#fff" stroke={OUTLINE} strokeWidth={2.8} />
|
||||
<rect x={196} y={104} width={330} height={8} rx={3} fill="#e2dbcf" stroke={OUTLINE} strokeWidth={2.2} />
|
||||
<foreignObject x={208} y={26} width={306} height={74}>
|
||||
<rect x={176} y={18} width={320} height={94} rx={6} fill="#fff" stroke={OUTLINE} strokeWidth={2.8} />
|
||||
<rect x={176} y={104} width={320} height={8} rx={3} fill="#e2dbcf" stroke={OUTLINE} strokeWidth={2.2} />
|
||||
<foreignObject x={188} y={26} width={296} height={74}>
|
||||
<div style={{ fontFamily: "system-ui, sans-serif", color: "#4a4238", lineHeight: 1.35 }}>
|
||||
<div style={{ fontSize: 12, color: leadTone, fontWeight: 600 }}>
|
||||
{streaming ? "综合中…" : lead?.status === "error" ? "协调失败" : lead?.status === "done" ? "已综合" : "协调中"}
|
||||
@@ -339,13 +498,28 @@ export function OfficeView({ events, output, phase }: { events: ExecEvent[]; out
|
||||
</foreignObject>
|
||||
</g>
|
||||
|
||||
{/* 协调者 */}
|
||||
<g transform={`translate(${LEAD_X}, ${LEAD_Y}) scale(${LEAD_SCALE})`}>
|
||||
<ellipse cx={0} cy={2} rx={46} ry={9} fill="#000" opacity={0.09} />
|
||||
<Chibi
|
||||
shirt={RUN}
|
||||
hair="#3f3229"
|
||||
style={2}
|
||||
typing={streaming ? { count: Infinity, delay: 0 } : undefined}
|
||||
happy={lead?.status === "done"}
|
||||
sad={lead?.status === "error"}
|
||||
delay={0}
|
||||
/>
|
||||
<Deskette name={lead?.label ?? "协调者"} sub="协调者" tone={leadTone} glow={streaming} w={230} />
|
||||
</g>
|
||||
|
||||
{/* 派发连线:只画正在工作的工位 */}
|
||||
{placed
|
||||
.filter((p) => p.seat.status === "running")
|
||||
.map((p) => (
|
||||
{cues
|
||||
.filter((c) => c.seat.status === "running")
|
||||
.map((c) => (
|
||||
<path
|
||||
key={`w-${p.seat.node}`}
|
||||
d={`M 360 214 Q ${(360 + p.x) / 2} ${(214 + p.y) / 2 - 24} ${p.x} ${p.y - 100 * p.scale}`}
|
||||
key={`w-${c.seat.node}`}
|
||||
d={`M ${LEAD_X} 214 Q ${(LEAD_X + c.x) / 2} ${(214 + c.y) / 2 - 24} ${c.x} ${c.y - 100 * c.scale}`}
|
||||
fill="none"
|
||||
stroke={RUN}
|
||||
strokeWidth={1.6}
|
||||
@@ -354,15 +528,19 @@ export function OfficeView({ events, output, phase }: { events: ExecEvent[]; out
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 协调者:白板前,比前排小 → 纵深 */}
|
||||
<g transform={`translate(360, 232) scale(0.62)`}>
|
||||
<ellipse cx={0} cy={2} rx={46} ry={9} fill="#000" opacity={0.09} />
|
||||
<Chibi shirt={RUN} hair="#3f3229" style={2} working={streaming} happy={lead?.status === "done"} sad={lead?.status === "error"} delay={0} />
|
||||
<Deskette name={lead?.label ?? "协调者"} sub="协调者" tone={leadTone} running={streaming} w={230} />
|
||||
</g>
|
||||
|
||||
{placed.map((p) => (
|
||||
<Seat key={p.seat.node} seat={p.seat} x={p.x} y={p.y} scale={p.scale} index={p.index} bubble={p.bubble} />
|
||||
{/* 角色层在前、桌子层在后:角色才能走到别人桌前而不被自己那张桌子焊死 */}
|
||||
{cues.map((c) => (
|
||||
<Actor key={c.seat.node} cue={c} reduced={reduced} />
|
||||
))}
|
||||
{cues.map((c) => (
|
||||
<g key={`d-${c.seat.node}`} transform={`translate(${c.x}, ${c.y}) scale(${c.scale})`}>
|
||||
<Deskette
|
||||
name={c.seat.name}
|
||||
sub={c.seat.status === "running" ? "工作中…" : c.seat.status === "error" ? "失败" : `已回报 ${fmtMs(c.seat.ms)}`}
|
||||
tone={toneOf(c.seat)}
|
||||
glow={c.seat.status === "running"}
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user