bothouse-dsh 0.1.44 → 0.1.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1464 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <meta name="color-scheme" content="dark">
7
+ <title>团队工作台 · CoNest</title>
8
+ <link rel="icon" href="data:,">
9
+ <style>
10
+ :root{--bg:#0a0d13;--panel:#111722;--panel2:#0c121b;--line:#243044;--line2:#1a2434;--text:#e7edf7;--muted:#8ea0b8;--gold:#ffc56b;--teal:#5eead4;--red:#ff6b6b;--blue:#7dd3fc;--wood:#8a5a36;--ok:#34d399}
11
+ *{box-sizing:border-box}
12
+ [hidden]{display:none!important}
13
+ html,body{margin:0;min-height:100%}
14
+ body{background:radial-gradient(1200px 620px at 50% -10%,#172235 0,#0a0d13 62%);color:var(--text);font:14px/1.45 system-ui,-apple-system,"PingFang SC","Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}
15
+ button,input,select{font:inherit;color:inherit}
16
+ button{cursor:pointer}
17
+ .app{max-width:1680px;margin:0 auto;min-height:100dvh;padding:12px 16px 10px;display:flex;flex-direction:column;gap:10px}
18
+ .topbar{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:2px}
19
+ .brand{display:flex;align-items:center;gap:12px;min-width:0}
20
+ .mark{width:40px;height:40px;border-radius:12px;background:linear-gradient(145deg,#f6bd68,#b7742e 46%,#58381e);box-shadow:inset 0 0 0 1px #ffd79a55,0 8px 22px #0008;position:relative;flex:0 0 auto}
21
+ .mark:before{content:"";position:absolute;inset:9px;border-radius:7px;border:2px solid #2b1a0d;background:repeating-linear-gradient(90deg,#2b1a0d 0 2px,transparent 2px 6px)}
22
+ .brand h1{font-size:19px;line-height:1.2;margin:0;letter-spacing:.4px}
23
+ .sub{margin:2px 0 0;color:var(--muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
24
+ .topright{display:flex;align-items:center;gap:10px;flex:0 0 auto}
25
+ .clock{font:600 15px/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:1px;color:#cfe0f5}
26
+ .pill{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;border:1px solid var(--line);background:#0b1119;font-size:11px;color:var(--muted);white-space:nowrap}
27
+ .pill:before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 8px currentColor}
28
+ .pill.live{color:var(--ok)}
29
+ .pill.wait{color:#d7a94b}
30
+ .pill.err{color:var(--red)}
31
+ .pill.replay{color:var(--blue)}
32
+ .badge{font-size:11px;padding:4px 9px;border-radius:8px;border:1px solid #7b5a2d;background:#2a1f0f;color:#f5c97b;white-space:nowrap}
33
+ .bar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding:8px 10px;border:1px solid var(--line);border-radius:14px;background:linear-gradient(180deg,#121a27,#0c121b);box-shadow:0 6px 20px #0006}
34
+ .modes{display:flex;padding:3px;background:#0a0f17;border:1px solid var(--line);border-radius:10px;gap:2px}
35
+ .mode{border:0;background:transparent;color:var(--muted);padding:6px 14px;border-radius:8px;font-weight:600;font-size:13px}
36
+ .mode.on{background:#243349;color:#fff;box-shadow:inset 0 0 0 1px #ffffff14}
37
+ .replaybar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;flex:1;min-width:0}
38
+ .field{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted);white-space:nowrap}
39
+ .field input,.field select{background:#0a0f17;border:1px solid var(--line);border-radius:8px;padding:5px 7px;color:var(--text);font-size:12px;color-scheme:dark}
40
+ .field input[type=datetime-local]{width:168px}
41
+ .presets{display:flex;gap:5px}
42
+ .btn{border:1px solid var(--line);background:#16202e;color:#dbe7f6;border-radius:9px;padding:6px 12px;font-size:12px;font-weight:600;white-space:nowrap}
43
+ .btn:hover{border-color:#3b5270;background:#1b2839}
44
+ .btn.primary{background:linear-gradient(180deg,#f3c577,#d89a45);border-color:#e0ad5b;color:#241605}
45
+ .btn.primary:hover{background:linear-gradient(180deg,#ffd48d,#e2a34d)}
46
+ .btn:disabled{opacity:.4;cursor:default}
47
+ .mini{border:1px solid var(--line);background:#0d141e;color:var(--muted);border-radius:7px;padding:4px 8px;font-size:11px}
48
+ .mini:hover{color:var(--text);border-color:#3b5270}
49
+ .progress{flex:1 1 90px;min-width:80px;max-width:240px;accent-color:#e2a34d;height:4px}
50
+ .vclock{font:600 12px ui-monospace,Menlo,monospace;color:var(--gold);min-width:44px;text-align:right}
51
+ .stats{margin-left:auto;font:12px ui-monospace,Menlo,monospace;color:var(--muted);white-space:nowrap}
52
+ main{flex:1;min-height:0;display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:12px}
53
+ .side{border:1px solid var(--line);border-radius:16px;background:linear-gradient(180deg,#101723,#0b1119);padding:12px;display:flex;flex-direction:column;min-height:0}
54
+ .side h2{margin:0 0 8px;font-size:11px;letter-spacing:2px;color:var(--muted);display:flex;justify-content:space-between;align-items:center;text-transform:uppercase}
55
+ .count{font:11px ui-monospace,Menlo,monospace;color:#5f7089;letter-spacing:0}
56
+ .log{list-style:none;margin:0;padding:0;overflow:auto;flex:1;display:flex;flex-direction:column;gap:3px;min-height:60px}
57
+ .log li{padding:6px 8px;border-radius:9px;background:#ffffff05;border:1px solid #ffffff08;animation:logIn .25s ease-out}
58
+ .log .l1{display:flex;align-items:center;gap:6px;font-size:12px;min-width:0}
59
+ .log .t{font:11px ui-monospace,Menlo,monospace;color:#65758c;flex:0 0 auto}
60
+ .log .route{color:#b9c8dc;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
61
+ .log .l2{font-size:11px;color:var(--muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
62
+ .chip{font-size:10px;padding:1px 6px;border-radius:99px;font-weight:700;letter-spacing:.4px;flex:0 0 auto}
63
+ .chip.created{background:#2b3b52;color:#a9c4e6}
64
+ .chip.assigned{background:#4a3412;color:#ffcf85}
65
+ .chip.returned{background:#123c34;color:#7dedd2}
66
+ .chip.rejected{background:#46191c;color:#ff9b9b}
67
+ .chip.paused{background:#26303d;color:#9fb0c4}
68
+ .chip.done{background:#173b28;color:#8ce7ae}
69
+ .chip.at{background:#123246;color:#9fd8ff}
70
+ .bubble .fish{position:relative;display:inline-block;width:12px;height:6px;margin-left:2px;border-radius:50%;background:#7fc9ff;box-shadow:0 0 6px #7fc9ff55}
71
+ .bubble .fish:before{content:"";position:absolute;right:-5px;top:1px;border-left:5px solid #7fc9ff;border-top:2px solid transparent;border-bottom:2px solid transparent}
72
+ .bubble .fish:after{content:"";position:absolute;left:2px;top:2px;width:2px;height:2px;border-radius:50%;background:#0b1622}
73
+ .legend{display:flex;flex-wrap:wrap;gap:10px;margin-top:8px;font-size:11px;color:var(--muted)}
74
+ .lg{display:inline-flex;align-items:center;gap:5px;white-space:nowrap}
75
+ .lg:before{content:"";width:16px;height:2px;border-radius:2px;background:currentColor}
76
+ .lg.assign{color:var(--gold)}.lg.return{color:var(--teal)}.lg.reject{color:var(--red)}.lg.at{color:var(--blue)}.lg.pause{color:#8b98ab}
77
+ .foot{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 4px 2px;color:var(--muted);font-size:12px;min-height:20px}
78
+ .ticker{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
79
+ .foot-note{flex:0 0 auto;opacity:.65}
80
+ .toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);max-width:min(560px,92vw);padding:9px 14px;border-radius:10px;background:#1c2636ee;border:1px solid #38485e;color:#e8eef8;font-size:12px;box-shadow:0 10px 32px #000a;z-index:90}
81
+ .empty{position:absolute;inset:0;display:grid;place-items:center;color:var(--muted);font-size:13px;letter-spacing:1px;pointer-events:none}
82
+ @keyframes logIn{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}}
83
+ .stage{position:relative;height:clamp(600px,calc(100vh - 236px),860px);min-height:600px;border:1px solid #2b3b52;border-radius:20px;overflow:hidden;background:#0a0f17;box-shadow:0 18px 48px #0009,inset 0 0 90px #0007;isolation:isolate}
84
+ .stage:after{content:"";position:absolute;inset:0;pointer-events:none;background:radial-gradient(120% 82% at 50% 38%,transparent 58%,#0000007a);z-index:19}
85
+ .wall{position:absolute;inset:0 0 auto;height:142px;background:linear-gradient(180deg,#1b283b,#141e2d 72%,#101827);border-bottom:2px solid #334764;box-shadow:inset 0 -16px 26px #0007;z-index:2}
86
+ .wall:after{content:"";position:absolute;left:0;right:0;bottom:0;height:9px;background:linear-gradient(180deg,#2c3d53,#1d2a3c);border-top:1px solid #456183}
87
+ .window{position:absolute;top:24px;width:148px;height:78px;border:5px solid #2b3c52;border-radius:5px;overflow:hidden;background:linear-gradient(180deg,#0c2036,#173754 55%,#0a1727);box-shadow:inset 0 0 26px #52a8ff2e,0 2px 0 #0007}
88
+ .window i{position:absolute;top:0;bottom:0;width:2px;background:#2b3c52;z-index:2}
89
+ .window i:nth-child(1){left:33%}.window i:nth-child(2){left:66%}.window i:nth-child(3){display:none}
90
+ .window .city{position:absolute;left:0;right:0;bottom:0;height:44%;background:repeating-linear-gradient(90deg,transparent 0 9px,#0a1625 9px 13px,transparent 13px 22px,#132131 22px 26px,transparent 26px 37px),linear-gradient(180deg,transparent,#08121e)}
91
+ .window .city:after{content:"";position:absolute;inset:0;background:radial-gradient(circle at 12% 30%,#ffd98a 0 1px,transparent 2px),radial-gradient(circle at 44% 52%,#9fd8ff 0 1px,transparent 2px),radial-gradient(circle at 72% 26%,#ffd98a 0 1px,transparent 2px),radial-gradient(circle at 88% 64%,#9fd8ff 0 1px,transparent 2px);opacity:.8}
92
+ .w1{left:56px}.w2{left:222px}
93
+ .whiteboard{position:absolute;top:22px;left:414px;width:226px;height:88px;border:4px solid #3a4c60;border-radius:3px;background:linear-gradient(160deg,#e6edf0,#cfdae0);box-shadow:0 3px 0 #0006;overflow:hidden}
94
+ .whiteboard:before{content:"";position:absolute;right:8px;bottom:6px;width:62px;height:1px;background:#9fb3c4;transform:rotate(-12deg);box-shadow:0 3px 0 #b8c8d4}
95
+ .whiteboard b{position:absolute;height:2px;background:#7f95aa;border-radius:2px}
96
+ .whiteboard b:nth-child(1){left:16px;top:18px;width:92px}
97
+ .whiteboard b:nth-child(2){left:16px;top:32px;width:128px;background:#c05c5c}
98
+ .whiteboard b:nth-child(3){left:16px;top:46px;width:70px}
99
+ .whiteboard b:nth-child(4){left:16px;top:60px;width:110px;background:#4f8f74}
100
+ .wallclock{position:absolute;top:30px;right:116px;width:62px;height:62px;border-radius:50%;background:linear-gradient(160deg,#eef3f8,#c9d5e1);border:5px solid #26364a;box-shadow:0 4px 10px #0007,inset 0 0 0 2px #ffffff88}
101
+ .hand{position:absolute;left:50%;bottom:50%;transform-origin:50% 100%;background:#203045;border-radius:2px}
102
+ .hand.h{width:4px;height:15px;margin-left:-2px}
103
+ .hand.m{width:3px;height:21px;margin-left:-1.5px}
104
+ .hand.s{width:1px;height:22px;margin-left:-.5px;background:#d84b4b}
105
+ .pin{position:absolute;left:50%;top:50%;width:6px;height:6px;margin:-3px 0 0 -3px;border-radius:50%;background:#203045}
106
+ .sign{position:absolute;top:38px;right:16px;padding:6px 9px;border:1px solid #4a5f79;border-radius:4px;background:#1a2638;color:#a8bdd6;font:600 9px/1 ui-monospace,Menlo,monospace;letter-spacing:1.5px;box-shadow:0 2px 0 #0006}
107
+ .floor{position:absolute;inset:142px 0 0;background:radial-gradient(900px 400px at 50% 0,#2b384b 0,#1a2433 56%,#121a27 100%);z-index:0}
108
+ .floor:before{content:"";position:absolute;inset:0;background-image:linear-gradient(#ffffff09 1px,transparent 1px),linear-gradient(90deg,#ffffff09 1px,transparent 1px);background-size:54px 54px;transform:perspective(820px) rotateX(16deg) scale(1.12);transform-origin:50% 0;opacity:.85;-webkit-mask-image:linear-gradient(180deg,#000 0,#000 62%,transparent 100%);mask-image:linear-gradient(180deg,#000 0,#000 62%,transparent 100%)}
109
+ .floor:after{content:"";position:absolute;inset:0;background:radial-gradient(420px 170px at 18% 8%,#ffd9a014,transparent 65%),radial-gradient(460px 190px at 78% 12%,#7fd0ff10,transparent 65%),radial-gradient(700px 300px at 50% 100%,#0008,transparent 70%)}
110
+ .rug{position:absolute;left:50%;top:55%;width:66%;height:50%;transform:translate(-50%,-50%);border-radius:50%;background:radial-gradient(closest-side,#1e3a58 0,#172b42 58%,#12213400 100%);opacity:.6;box-shadow:inset 0 0 70px #0a1421;z-index:1}
111
+ .plant{position:absolute;width:56px;height:74px;z-index:2;filter:drop-shadow(0 10px 12px #0009)}
112
+ .plant:before{content:"";position:absolute;left:50%;bottom:0;width:34px;height:26px;transform:translateX(-50%);background:linear-gradient(180deg,#6b4d32,#3b2a1a);border-radius:3px 3px 7px 7px;box-shadow:inset 0 3px 0 #8d684378}
113
+ .plant:after{content:"";position:absolute;left:50%;bottom:22px;width:18px;height:46px;transform:translateX(-50%) rotate(-6deg);background:#2f8f5e;border-radius:60% 60% 45% 45%;box-shadow:-15px 8px 0 -2px #369e6b,15px 11px 0 -3px #277a50,-9px 23px 0 -5px #3aa873,10px 27px 0 -6px #216b47,0 5px 0 -6px #4fbd86}
114
+ .p1{left:14px;bottom:10px}.p2{right:16px;bottom:8px}.p3{left:10px;top:152px;transform:scale(.82);transform-origin:bottom center;opacity:.8}
115
+ /* Column count follows the live member count (--cols set in renderSeats): 1-5 seats fill one row,
116
+ 6-8 two rows, 9-12 three rows, capped at 4 columns so a desk never gets narrower than ~200px. */
117
+ .seats{position:absolute;left:1.2%;right:1.2%;top:164px;bottom:10px;display:grid;grid-template-columns:repeat(var(--cols,4),minmax(0,1fr));grid-auto-rows:1fr;gap:2px 10px;z-index:3}
118
+ .seat{position:relative;min-height:0;display:flex;align-items:flex-end;justify-content:center}
119
+ /* Room reads from the front: the person sits NEAREST us facing the room (we see their face), the
120
+ desk is behind them, and the monitor beyond the desk shows its back. Chair therefore sits at the
121
+ far side of the person, not in front of them. */
122
+ .chair{position:absolute;left:50%;top:26px;width:74px;height:70px;transform:translateX(-50%);border-radius:50% 50% 40% 40%;background:radial-gradient(circle at 50% 28%,#3d4e65,#222e40 70%);box-shadow:inset 0 -10px 0 #18222f,0 8px 14px #0007;z-index:1}
123
+ .person{position:absolute;left:50%;top:auto;bottom:-8px;width:150px;height:150px;transform:translateX(-50%);z-index:6}
124
+ .body{position:absolute;left:50%;top:70px;width:100px;height:76px;transform:translateX(-50%);border-radius:46% 46% 16% 16%;background:linear-gradient(180deg,var(--shirt,#3b5270),#202d44);box-shadow:inset 0 2px 0 #ffffff1f;z-index:3}
125
+ .aura{position:absolute;left:50%;top:48px;width:132px;height:132px;transform:translate(-50%,-50%);border-radius:50%;background:radial-gradient(circle,#ffc56b33 0,#ffc56b10 46%,transparent 68%);opacity:0;z-index:4}
126
+ .avatar{position:absolute;left:50%;top:0;width:84px;height:84px;transform:translateX(-50%);border-radius:50%;background:#111a26;border:3px solid #d8b27655;box-shadow:0 7px 18px #0009;overflow:visible;z-index:5}
127
+ .avatar img{width:100%;height:100%;border-radius:50%;object-fit:cover;display:block}
128
+ .avatar .init{display:grid;place-items:center;width:100%;height:100%;border-radius:50%;font-weight:800;font-size:30px;color:#fff;text-shadow:0 2px 4px #0008}
129
+ .avatar .dot{position:absolute;right:-1px;bottom:4px;width:14px;height:14px;border-radius:50%;background:var(--ok);border:3px solid #0e1621;box-shadow:0 0 8px #34d39966}
130
+ .avatar .crown{position:absolute;left:50%;top:-13px;transform:translateX(-50%);font-size:15px;line-height:1;color:#ffd37a;text-shadow:0 2px 4px #000,0 0 10px #ffc56b66;filter:drop-shadow(0 2px 3px #0009)}
131
+ .tasktag{position:absolute;left:50%;top:-4px;transform:translate(-50%,-100%);max-width:148px;padding:3px 8px;border-radius:6px;border:1px solid #ffc56b66;background:#1b2230ee;color:#ffe1a9;font-size:11px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-shadow:0 4px 14px #0009;z-index:8}
132
+ .bubble{position:absolute;left:calc(50% + 44px);top:6px;display:flex;align-items:center;gap:3px;padding:5px 7px;border-radius:9px;border:1px solid #33465f;background:#0e1622ee;color:#cfe4ff;font-size:11px;min-height:16px;box-shadow:0 4px 12px #0009;z-index:9;white-space:nowrap}
133
+ .bubble:after{content:"";position:absolute;left:4px;bottom:-4px;width:7px;height:7px;background:#0e1622;border-right:1px solid #33465f;border-bottom:1px solid #33465f;transform:rotate(45deg)}
134
+ .bubble .td{width:4px;height:4px;border-radius:50%;background:#9fd0ff}
135
+ .bubble .fish{width:15px;height:7px;border-radius:50% 50% 50% 50%;background:#7dd3fc;position:relative;opacity:.85}
136
+ .bubble .fish:after{content:"";position:absolute;right:-6px;top:1px;width:0;height:0;border-top:4px solid transparent;border-bottom:4px solid transparent;border-left:6px solid #7dd3fc}
137
+ .desk{position:relative;width:min(92%,252px);height:100px;margin-bottom:2px;z-index:4}
138
+ .desk:before{content:"";position:absolute;left:5%;right:5%;bottom:-16px;height:22px;background:radial-gradient(ellipse at 50% 0,#000b,transparent 70%)}
139
+ .desktop{position:absolute;left:0;right:0;bottom:0;height:58px;border-radius:6px;background:linear-gradient(180deg,#a46c3f,#7a4a29 55%,#5d371f);box-shadow:inset 0 2px 0 #d29a6680,inset 0 -3px 0 #3e2413,0 8px 18px #0008;z-index:2}
140
+ .desktop:before{content:"";position:absolute;left:0;right:0;bottom:-11px;height:13px;border-radius:0 0 6px 6px;background:linear-gradient(180deg,#5b361e,#3e2412);box-shadow:inset 0 2px 0 #0004}
141
+ .keyboard{position:absolute;left:8%;bottom:9px;width:42%;height:15px;background:linear-gradient(180deg,#2d3744,#1b232d);border-radius:3px;transform:skewX(-8deg);box-shadow:0 1px 0 #0006,inset 0 1px 0 #ffffff1a}
142
+ .keyboard:after{content:"";position:absolute;inset:3px 4px;background:repeating-linear-gradient(90deg,#5b6b7e 0 1px,transparent 1px 5px),repeating-linear-gradient(180deg,#5b6b7e 0 1px,transparent 1px 4px);opacity:.45}
143
+ .mug{position:absolute;right:11%;bottom:10px;width:16px;height:18px;border-radius:3px 3px 6px 6px;background:linear-gradient(180deg,#e8eef6,#b9c6d6);box-shadow:inset -3px 0 0 #ffffff66;z-index:3}
144
+ .mug:after{content:"";position:absolute;right:-6px;top:5px;width:7px;height:8px;border:2px solid #c9d5e3;border-left:0;border-radius:0 5px 5px 0}
145
+ .papers{position:absolute;right:24%;bottom:11px;width:26px;height:17px;background:#e9edf2;border-radius:2px;transform:rotate(-7deg);box-shadow:0 1px 2px #0005;opacity:.9;z-index:3}
146
+ .plate{position:absolute;left:50%;bottom:2px;transform:translateX(-50%);max-width:78%;padding:1px 6px;border-radius:3px;background:#2a1b0f;box-shadow:inset 0 1px 0 #ffffff22,0 1px 0 #0006;color:#f6d9a9;font:700 10px/1.5 ui-monospace,Menlo,monospace;letter-spacing:.6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;z-index:4}
147
+ /* The monitor faces the person sitting at the desk, so from the room we see its BACK: a matte
148
+ shell, a vent band and the small badge, never the screen content. */
149
+ .monitor{position:absolute;left:50%;top:-30px;width:84px;height:52px;transform:translateX(-50%);z-index:3}
150
+ .screen{position:absolute;inset:0;border-radius:5px 5px 3px 3px;border:2px solid #141a21;background:linear-gradient(168deg,#3a434f 0%,#262d37 44%,#1a2027 100%);box-shadow:0 8px 15px #0009,inset 0 1px 0 #ffffff26,inset 0 -2px 3px #0006}
151
+ /* Vent band across the lower shell. */
152
+ .screen:before{content:"";position:absolute;left:16%;right:16%;bottom:22%;height:7px;background:repeating-linear-gradient(180deg,#0000004d 0 1px,transparent 1px 3px);opacity:.85}
153
+ /* Badge in the middle of the back panel. */
154
+ .screen:after{content:"";position:absolute;left:50%;top:26%;width:11px;height:11px;margin-left:-5.5px;border-radius:3px;background:radial-gradient(circle at 35% 30%,#8c9aab,#39434f 68%);box-shadow:inset 0 1px 0 #ffffff33}
155
+ .stand{position:absolute;left:50%;top:49px;width:8px;height:12px;transform:translateX(-50%);background:#2a3542}
156
+ .base{position:absolute;left:50%;top:60px;width:34px;height:5px;transform:translateX(-50%);background:#222c38;border-radius:3px}
157
+ .status{position:absolute;right:2%;top:-17px;padding:1px 6px;border-radius:99px;background:#0c121ccc;color:#b8c7dc;font-size:10px;white-space:nowrap;z-index:6}
158
+ .seat.idle .person{animation:bob 4.6s ease-in-out infinite}
159
+ .seat.idle:nth-child(2n) .person{animation-delay:-1.7s}
160
+ .seat.idle:nth-child(3n) .person{animation-delay:-3.1s}
161
+ .seat.idle .bubble{opacity:.9}
162
+ .seat.working .avatar{border-color:#ffc56b;box-shadow:0 0 0 2px #ffc56b44,0 0 22px #ffc56b55,0 7px 18px #0009}
163
+ .seat.working .aura{opacity:1;animation:auraPulse 1.9s ease-in-out infinite}
164
+ .seat.working .desk{filter:drop-shadow(0 0 16px #ffc56b33)}
165
+ /* Working: the light comes from the far side of the panel, so the shell picks up a warm rim and
166
+ the vent band leaks a little of it — the room never sees screen content. */
167
+ /* 干活中:光从屏幕正面溢出,我们只看到背板边缘的暖光和格栅里透出的一点亮度。 */
168
+ .seat.working .screen{box-shadow:0 8px 16px #0000001c,inset 0 1px 0 #fff,inset 0 -3px 6px #0000000f,0 0 20px #ffcf8a3d,0 0 5px #ffcf8a40}
169
+ .seat.working .screen:before{background:repeating-linear-gradient(180deg,#e0a76066 0 1px,transparent 1px 3px);animation:ventGlow 1.6s ease-in-out infinite}
170
+ .seat.working .keyboard:after,.seat.thinking .keyboard:after{animation:keyFlash .5s steps(2) infinite}
171
+ .seat.working .person{animation:workBob 2.2s ease-in-out infinite}
172
+ .seat.thinking .avatar{border-color:#7dd3fc;box-shadow:0 0 0 2px #7dd3fc33,0 0 20px #7dd3fc44}
173
+ .seat.thinking .aura{opacity:.95;background:radial-gradient(circle,#7dd3fc33 0,#7dd3fc0d 48%,transparent 70%);animation:auraPulse 1.4s ease-in-out infinite}
174
+ .seat.thinking .bubble{border-color:#3b6c92}
175
+ .seat.offline{filter:saturate(.15);opacity:.55}
176
+ .seat.offline .person{animation:none}
177
+ .seat.offline .screen{background:linear-gradient(168deg,#eef0f3,#dfe2e8);box-shadow:inset 0 1px 0 #fff,0 6px 12px #00000012}
178
+ .seat.offline .screen:before,.seat.offline .screen:after{opacity:.12}
179
+ .seat.offline .status{color:#7a8798}
180
+ .seat.lead .desk{box-shadow:0 0 0 1px #ffc56b2e}
181
+ .seat.lead .plate{color:#ffe2ab;background:#382312}
182
+ .bubble .td{animation:td 1s ease-in-out infinite}
183
+ .bubble .td:nth-child(2){animation-delay:.16s}
184
+ .bubble .td:nth-child(3){animation-delay:.32s}
185
+ .bubble .zzz{font:700 11px/1 ui-monospace,Menlo,monospace;color:#a9c8ff;animation:zzzFloat 2.6s ease-in-out infinite}
186
+ .bubble .zzz:nth-child(2){animation-delay:.5s;font-size:13px}
187
+ .bubble .fish{animation:fishBob 2.4s ease-in-out infinite}
188
+ @keyframes bob{0%,100%{transform:translateX(-50%) translateY(0)}50%{transform:translateX(-50%) translateY(-3px)}}
189
+ @keyframes workBob{0%,100%{transform:translateX(-50%) translateY(0)}50%{transform:translateX(-50%) translateY(-2px)}}
190
+ @keyframes auraPulse{0%,100%{opacity:.6;transform:translate(-50%,-50%) scale(.94)}50%{opacity:1;transform:translate(-50%,-50%) scale(1.08)}}
191
+ @keyframes td{0%,100%{opacity:.25;transform:translateY(0)}50%{opacity:1;transform:translateY(-2px)}}
192
+ @keyframes zzzFloat{0%,100%{opacity:.35;transform:translateY(1px)}50%{opacity:1;transform:translateY(-3px)}}
193
+ @keyframes fishBob{0%,100%{transform:translateY(0) rotate(-2deg)}50%{transform:translateY(-2px) rotate(2deg)}}
194
+ @keyframes keyFlash{0%,100%{opacity:.22}50%{opacity:1}}
195
+ @keyframes codePulse{0%,100%{opacity:.55;transform:translateY(0)}50%{opacity:1;transform:translateY(2px)}}
196
+ @keyframes ventGlow{0%,100%{opacity:.35}50%{opacity:.85}}
197
+ .seat.flash-gold .desk{animation:goldFlash 1.4s ease-out}
198
+ .seat.flash-good .desk{animation:goodFlash 1.4s ease-out}
199
+ .seat.flash-bad .desk{animation:badFlash 1.4s ease-out}
200
+ .seat.flash-bad{animation:seatShake .45s}
201
+ @keyframes goldFlash{0%,100%{filter:none}35%{filter:drop-shadow(0 0 22px #ffc56b) brightness(1.12)}}
202
+ @keyframes goodFlash{0%,100%{filter:none}35%{filter:drop-shadow(0 0 20px #5eead4) brightness(1.1)}}
203
+ @keyframes badFlash{0%,100%{filter:none}25%,60%{filter:drop-shadow(0 0 20px #ff6b6b) brightness(1.1)}}
204
+ @keyframes seatShake{0%,100%{transform:translateX(0)}20%{transform:translateX(-2px)}40%{transform:translateX(2px)}60%{transform:translateX(-1px)}80%{transform:translateX(1px)}}
205
+ .beams{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:20;overflow:visible}
206
+ .beam{--beam:#ffc56b;animation:beamOut 2.6s ease-out forwards}
207
+ .beam.return{--beam:#5eead4}.beam.reject{--beam:#ff6b6b}.beam.at{--beam:#7dd3fc}.beam.pause{--beam:#9aa8bc}
208
+ .beam path{fill:none;stroke:var(--beam);stroke-linecap:round}
209
+ .beam .glow{stroke-width:11;opacity:.2;filter:blur(3px)}
210
+ .beam .trace{stroke-width:2.4;opacity:.95;filter:drop-shadow(0 0 6px var(--beam))}
211
+ .beam .flow{stroke-width:1.5;stroke-dasharray:8 20;opacity:.85;animation:beamFlow 1s linear infinite}
212
+ .beam .spark{fill:var(--beam);filter:drop-shadow(0 0 5px var(--beam))}
213
+ @keyframes beamFlow{to{stroke-dashoffset:-28}}
214
+ @keyframes beamOut{0%,72%{opacity:1}100%{opacity:0}}
215
+ .pop{position:absolute;left:50%;bottom:105px;transform:translateX(-50%);padding:3px 8px;border-radius:7px;border:1px solid #ffc56b55;background:#0d1622ee;color:#ffe0a8;font-size:11px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:170px;z-index:12;animation:popUp 1.9s ease-out forwards;box-shadow:0 4px 14px #0009}
216
+ .pop.good{color:#8ff0d6;border-color:#5eead455}
217
+ .pop.bad{color:#ffaaaa;border-color:#ff6b6b55}
218
+ .pop.info{color:#cfe4ff;border-color:#7dd3fc44}
219
+ @keyframes popUp{0%{opacity:0;transform:translate(-50%,6px)}12%{opacity:1;transform:translate(-50%,0)}78%{opacity:1;transform:translate(-50%,0)}100%{opacity:0;transform:translate(-50%,-8px)}}
220
+ .toast{animation:toastIn .18s ease-out}
221
+ @keyframes toastIn{from{opacity:0;transform:translate(-50%,6px)}to{opacity:1;transform:translate(-50%,0)}}
222
+ .mode:focus-visible,.btn:focus-visible,.mini:focus-visible,.field input:focus-visible,.field select:focus-visible,.progress:focus-visible{outline:2px solid #7dd3fc;outline-offset:1px}
223
+ @media (prefers-reduced-motion:reduce){*{animation-duration:.001ms!important;animation-iteration-count:1!important}}
224
+ @media (max-width:1180px){main{grid-template-columns:minmax(0,1fr)}.side{display:none}}
225
+ @media (max-width:900px){.replaybar{flex:1 1 100%;order:3}.stats{margin-left:auto}.field input[type=datetime-local]{width:150px}}
226
+ @media (max-width:760px){
227
+ .app{padding:8px 8px 6px;gap:8px;overflow-x:hidden}
228
+ .brand{gap:8px}.mark{width:34px;height:34px;border-radius:10px}.brand h1{font-size:16px}.sub{font-size:11px}
229
+ .topright{gap:6px}.clock{font-size:12px}.pill{font-size:10px;padding:3px 8px}.badge{font-size:10px;padding:3px 7px}
230
+ .bar{padding:7px 8px;gap:8px}.mode{padding:6px 12px;font-size:12px}.stats{font-size:11px}
231
+ .replaybar{gap:6px}.field{flex:1 1 140px;font-size:11px}.field input,.field select{font-size:11px;padding:4px 6px}
232
+ .field input[type=datetime-local]{width:100%}.presets{flex:0 0 auto}.btn{padding:6px 10px}.speed{flex:0 0 auto}
233
+ .progress{flex:1 1 100%;max-width:none;order:6}.vclock{order:5}.mini{padding:4px 7px;font-size:10px}
234
+ .stage{height:auto;min-height:0;padding:104px 0 12px;border-radius:16px}
235
+ .wall{height:96px}.wall:after{height:7px}.window{top:15px;height:60px;width:116px;border-width:3px}.w1{left:16px}.w2{display:none}
236
+ .whiteboard,.sign{display:none}
237
+ .wallclock{top:20px;right:14px;width:48px;height:48px;border-width:4px}.hand.h{height:11px}.hand.m{height:16px}.hand.s{height:17px}
238
+ .floor{inset:96px 0 0}.floor:before{background-size:38px 38px}
239
+ .rug{width:90%;height:60%;top:58%}
240
+ .plant{transform:scale(.7);transform-origin:bottom left}.p1{left:4px;bottom:4px}.p2{right:4px;bottom:2px;transform-origin:bottom right}.p3{display:none}
241
+ .seats{position:relative;left:auto;right:auto;top:auto;bottom:auto;padding:0 8px;grid-template-columns:repeat(2,minmax(0,1fr));grid-auto-rows:200px;gap:4px 2px}
242
+ .seat{min-height:0}
243
+ .chair{width:66px;height:62px;top:4px}
244
+ .person{height:140px;top:4px;width:130px}
245
+ .body{top:60px;width:88px;height:66px}
246
+ .avatar{width:72px;height:72px;border-width:2px}.avatar .init{font-size:26px}
247
+ .avatar .dot{width:12px;height:12px;bottom:2px}
248
+ .tasktag{max-width:120px;padding:2px 6px;font-size:10px;top:-2px}
249
+ .bubble{left:calc(50% + 38px);top:2px;padding:4px 6px;font-size:10px;min-height:14px}
250
+ .desk{width:100%;max-width:214px;height:90px}
251
+ .desktop{height:52px}.desktop:before{height:11px;bottom:-9px}
252
+ .monitor{width:70px;height:44px}.stand{top:41px;height:10px}.base{top:50px;width:30px}
253
+ .screen:after{box-shadow:0 5px 0 #9fe8ff55,0 10px 0 #9fe8ff77,0 15px 0 #9fe8ff33}
254
+ .plate{font-size:9px;bottom:1px;max-width:76%}.status{font-size:9px;top:-14px}
255
+ .keyboard{height:12px}.mug{width:14px;height:16px;bottom:8px}.papers{width:22px;height:15px;bottom:9px}
256
+ .foot{font-size:11px}.foot-note{display:none}
257
+ .toast{bottom:14px;font-size:11px;max-width:94vw}
258
+ .pop{bottom:94px;max-width:140px}
259
+ }
260
+ @media (max-width:420px){
261
+ .sub{display:none}.topright .clock{display:none}
262
+ .seats{grid-auto-rows:190px}
263
+ .desk{max-width:190px}
264
+ .seat .tasktag{max-width:104px}
265
+ }
266
+ @media (min-width:761px) and (max-height:700px){.stage{height:580px;min-height:580px}}
267
+ /* ===== M 路画面层 v2(2026-11-03 返工):浅色高级感 · 细线流光 ===== */
268
+ :root{
269
+ --bg:#f1f2f4;--panel:#ffffff;--panel2:#f4f4f5;--line:#e4e4e7;--line2:#ededf0;
270
+ --text:#111112;--muted:#71717a;--gold:#a15c11;--teal:#0f766e;--red:#dc2626;--blue:#2563eb;
271
+ --wood:#c9a074;--ok:#059669;
272
+ --shadow:0 1px 2px #00000008,0 18px 50px -18px #00000026;
273
+ }
274
+ html,body{background:var(--bg);color:var(--text)}
275
+ .app{gap:14px}
276
+ .topbar{padding:6px 2px}
277
+ .mark{background:linear-gradient(145deg,#f3d9b0,#c99a5e 46%,#8a5a2b);box-shadow:inset 0 0 0 1px #ffffff88,0 6px 16px #0000001a}
278
+ .mark:before{border-color:#5b3a1a;background:repeating-linear-gradient(90deg,#5b3a1a 0 2px,transparent 2px 6px)}
279
+ .brand h1{font-size:20px;letter-spacing:.2px}
280
+ .sub{color:var(--ink3,#85858c)}
281
+ .bar{background:#fff;border:1px solid var(--line);box-shadow:0 1px 2px #00000008;border-radius:16px}
282
+ .modes{background:#f2f2f4;border-color:#e7e7ea}
283
+ .mode{color:#71717a}.mode.on{background:#fff;color:#111;box-shadow:0 1px 2px #00000014,inset 0 0 0 1px #e4e4e7}
284
+ .btn{background:#fff;border-color:var(--line);color:#3f3f46}
285
+ .btn:hover{background:#fafafa;border-color:#d4d4d8}
286
+ .btn.primary{background:linear-gradient(180deg,#2b2b2f,#111112);border-color:#111;color:#fff;box-shadow:0 2px 6px #00000026}
287
+ .btn.primary:hover{background:linear-gradient(180deg,#3a3a40,#1a1a1e)}
288
+ .field input,.field select{background:#fff;border-color:var(--line);color:#111}
289
+ .pill{background:#fff;border-color:var(--line);color:#71717a}
290
+ .pill.galaxy{text-decoration:none;color:#7c3aed}.pill.galaxy:hover{border-color:#a78bfa}
291
+ .pill.live{color:#059669}.pill.replay{color:#2563eb}.pill.wait{color:#b45309}.pill.err{color:#dc2626}
292
+ .badge{background:#fff7ed;border-color:#fed7aa;color:#9a3412}
293
+ .stats{color:#71717a}
294
+ .side{background:#fff;border-color:var(--line);box-shadow:0 1px 2px #00000008}
295
+ .side h2{color:#a1a1aa}.count{color:#a1a1aa}
296
+ .log li{background:#fcfcfd;border:1px solid #eeeef1}
297
+ .log .t{color:#a1a1aa}.log .route{color:#3f3f46}.log .l2{color:#71717a}
298
+ .chip.created{background:#f4f4f5;color:#52525b}
299
+ .chip.assigned{background:#fef3e2;color:#9a5b10}
300
+ .chip.returned{background:#e7f7f4;color:#0f766e}
301
+ .chip.rejected{background:#fdecec;color:#b91c1c}
302
+ .chip.paused{background:#f4f4f5;color:#71717a}
303
+ .chip.done{background:#e9f8ef;color:#047857}
304
+ .chip.at{background:#eaf2fe;color:#1d4ed8}
305
+ .legend{color:#a1a1aa}
306
+ .foot{color:#a1a1aa}
307
+ .toast{background:#111112;border-color:#27272a;color:#fafafa;box-shadow:0 12px 32px #00000033}
308
+
309
+
310
+ /* 房间:白墙、浅地板、很淡的等距线 */
311
+ .stage{background:#fdfcfa;border:1px solid #e6e4e0;border-radius:24px;
312
+ box-shadow:0 1px 2px #00000008,0 24px 60px -24px #0000002e;overflow:hidden}
313
+ .stage:after{background:radial-gradient(120% 86% at 50% 30%,transparent 62%,#0000000a)}
314
+ .wall{background:linear-gradient(180deg,#fefdfb,#f6f4f1 72%,#efedea);border-bottom:1px solid #e6e6ea;box-shadow:inset 0 -18px 28px #00000005}
315
+ .wall:after{background:linear-gradient(180deg,#fbfbfc,#ececef);border-top:1px solid #e2e2e6}
316
+ .window{border-color:#dcdce1;background:linear-gradient(180deg,#eaf4ff,#dcecfd 55%,#f6fbff);box-shadow:inset 0 0 30px #8fbdf32e,0 2px 0 #0000000a}
317
+ .window i{background:#dcdce1}
318
+ .window .city{background:repeating-linear-gradient(90deg,transparent 0 9px,#dbe7f4 9px 13px,transparent 13px 22px,#e8f0fa 22px 26px,transparent 26px 37px),linear-gradient(180deg,transparent,#eef5fc)}
319
+ .whiteboard{border-color:#d9d9de;background:linear-gradient(160deg,#fbfcfe,#f2f5f9);box-shadow:inset 0 0 0 1px #fff,0 2px 0 #0000000d}
320
+ .whiteboard:before{background:#d5dde6}
321
+ .whiteboard b{background:#b8c4d2}
322
+ .whiteboard b:nth-child(2){background:#e0a3a3}
323
+ .whiteboard b:nth-child(4){background:#a7cdb9}
324
+ .wallclock{background:linear-gradient(160deg,#fff,#f3f4f6);border-color:#d9d9de;box-shadow:0 3px 8px #00000012,inset 0 0 0 2px #fff}
325
+ .hand{background:#3f3f46}.hand.s{background:#dc2626}.pin{background:#3f3f46}
326
+ .sign{background:#fff;border-color:#e4e4e7;color:#71717a;box-shadow:0 1px 2px #0000000a}
327
+ .floor{background:radial-gradient(1000px 420px at 50% 0,#ffffff 0,#f7f7f9 56%,#f1f1f4 100%)}
328
+ .floor:before{
329
+ background-image:
330
+ repeating-linear-gradient(116.57deg,#0000000b 0 1px,transparent 1px 64px),
331
+ repeating-linear-gradient(63.43deg,#0000000b 0 1px,transparent 1px 64px);
332
+ transform:none;opacity:.9;
333
+ -webkit-mask-image:radial-gradient(74% 68% at 50% 40%,#000 0,#000 64%,transparent 100%);
334
+ mask-image:radial-gradient(74% 68% at 50% 40%,#000 0,#000 64%,transparent 100%);
335
+ }
336
+ .floor:after{background:radial-gradient(460px 190px at 16% 10%,#ffd9a01f,transparent 66%),radial-gradient(520px 210px at 82% 8%,#9cc7ff1a,transparent 68%),radial-gradient(760px 320px at 50% 106%,#0000000d,transparent 72%)}
337
+ .rug{background:radial-gradient(closest-side,#eef2f8 0,#f2f4f8 58%,#f2f4f800 100%);opacity:.9;box-shadow:inset 0 0 60px #00000008}
338
+ .plant:before{background:linear-gradient(180deg,#e3d3c1,#c9b49c)}
339
+ .plant:after{background:#5fae83;box-shadow:-15px 8px 0 -2px #6cbb90,15px 11px 0 -3px #4c9d75,-9px 23px 0 -5px #75c39a,10px 27px 0 -6px #448a66,0 5px 0 -6px #86d0a9}
340
+
341
+ /* 工位:白卡片感 + 坐姿 */
342
+ .seats{top:150px;bottom:14px;grid-template-columns:repeat(var(--cols,3),minmax(0,1fr));gap:16px 14px;z-index:3}
343
+ .seat:nth-child(3n+2){transform:translateY(12px)}
344
+ .seat:nth-child(3n){transform:translateY(4px)}
345
+ .chair{top:auto;bottom:92px;width:88px;height:80px;z-index:1;
346
+ background:radial-gradient(circle at 50% 28%,#e3e6ec,#cfd4dd 72%);
347
+ box-shadow:inset 0 -10px 0 #c2c8d2,0 8px 16px #00000014}
348
+ .person{top:auto;bottom:58px;width:160px;height:150px;z-index:3}
349
+ .body{top:auto;bottom:0;width:104px;height:82px;border-radius:44% 44% 14% 14%;
350
+ background:linear-gradient(180deg,var(--shirt,#8fa6c4),#6b7f9e 82%);
351
+ box-shadow:inset 0 2px 0 #ffffff66,inset 0 -10px 16px #0000001a}
352
+ .avatar{width:104px;height:104px;border:3px solid #fff;background:#fff;z-index:5;
353
+ box-shadow:0 0 0 1px #00000012,0 10px 24px #00000024}
354
+ .avatar:before{content:"";position:absolute;inset:-16px;border-radius:50%;z-index:-1;
355
+ background:radial-gradient(circle,#ffcf8a2e 0,#ffcf8a0d 52%,transparent 74%)}
356
+ .avatar img{border-radius:50%;filter:saturate(1.05) contrast(1.02)}
357
+ .avatar .dot{width:15px;height:15px;right:0;bottom:2px;border:3px solid #fff}
358
+ .avatar .init{font-size:36px;color:#fff}
359
+
360
+ .desk{width:min(96%,304px);height:112px;margin-bottom:0;z-index:4}
361
+ .desk:before{left:2%;right:2%;bottom:-14px;height:26px;background:radial-gradient(ellipse at 50% 0,#0000001f,transparent 72%)}
362
+ .desktop{height:66px;border-radius:9px 9px 5px 5px;
363
+ background:linear-gradient(180deg,#f0dcc2,#e2c6a4 52%,#d3b08a);
364
+ box-shadow:inset 0 2px 0 #ffffffb3,inset 0 -4px 0 #b8946d,0 10px 22px #0000001c}
365
+ .desktop:before{bottom:-14px;height:16px;background:linear-gradient(180deg,#d8c0a2,#c2a27c);box-shadow:inset 0 2px 0 #ffffff59}
366
+ .desk:after{content:"";position:absolute;right:-9px;bottom:8px;width:10px;height:64px;
367
+ background:linear-gradient(180deg,#dcc3a4,#bb9a74);transform:skewY(-16deg);border-radius:0 3px 3px 0;z-index:1}
368
+ /* 显示器正对坐在工位上的人,所以我们看到的是背面:磨砂背板 + 通风格栅 + 中间小徽标,
369
+ 不是屏幕内容。 */
370
+ .monitor{width:96px;height:58px;top:-2px}
371
+ .screen{border:3px solid #dcdce2;background:linear-gradient(168deg,#f4f5f8 0%,#e7e9ee 42%,#d9dbe2 100%);
372
+ box-shadow:0 8px 16px #0000001c,inset 0 1px 0 #fff,inset 0 -3px 6px #0000000f}
373
+ .screen:before{content:"";position:absolute;left:22%;right:22%;bottom:20%;height:8px;
374
+ background:repeating-linear-gradient(180deg,#0000002e 0 1px,transparent 1px 3px);opacity:.7}
375
+ .screen:after{content:"";position:absolute;left:50%;top:24%;width:12px;height:12px;margin-left:-6px;
376
+ border-radius:3px;background:radial-gradient(circle at 35% 30%,#fff,#b8bcc6 70%);
377
+ box-shadow:inset 0 1px 0 #fff,0 1px 2px #0000001a}
378
+ .stand{top:55px;height:12px;background:#cfcfd6}.base{top:67px;width:38px;background:#c4c4cc}
379
+ .keyboard{background:linear-gradient(180deg,#e8e8ec,#d9d9df);box-shadow:0 1px 0 #00000012,inset 0 1px 0 #fff}
380
+ .keyboard:after{background:repeating-linear-gradient(90deg,#b6b6bf 0 1px,transparent 1px 5px),repeating-linear-gradient(180deg,#b6b6bf 0 1px,transparent 1px 4px);opacity:.5}
381
+ .mug{background:linear-gradient(180deg,#fff,#eceef2);box-shadow:inset -3px 0 0 #ffffff}
382
+ .mug:after{border-color:#dfe1e7}
383
+ .papers{background:#fff;box-shadow:0 1px 3px #0000001a}
384
+ .plate{bottom:3px;font-size:11px;padding:2px 8px;background:#fff;color:#3f3f46;
385
+ box-shadow:inset 0 1px 0 #fff,0 2px 5px #0000001a;border:1px solid #ececf0}
386
+ .status{left:8px;right:auto;top:-24px;background:#fff;color:#52525b;font-size:11px;padding:2px 8px;
387
+ border:1px solid #ececf0;border-radius:99px;box-shadow:0 2px 6px #00000012;z-index:7}
388
+ .tasktag{top:-2px;border:1px solid #fed7aa;background:#fffbeb;color:#92400e;font-size:12px;padding:4px 9px;box-shadow:0 4px 12px #00000014}
389
+ .bubble{background:#fff;border:1px solid #e7e7ea;color:#52525b;box-shadow:0 4px 12px #00000014}
390
+ .bubble:after{background:#fff;border-right:1px solid #e7e7ea;border-bottom:1px solid #e7e7ea}
391
+ .bubble .fish{background:#94a3b8}.bubble .fish:after{border-left-color:#94a3b8}
392
+ .bubble .td{background:#94a3b8}
393
+ .bubble .zzz{color:#94a3b8}
394
+
395
+ /* 台灯:暖光收着 */
396
+ .lamp{position:absolute;right:5%;top:-34px;width:58px;height:104px;z-index:6;pointer-events:none}
397
+ .lamp .arm{position:absolute;left:50%;bottom:0;width:3px;height:56px;margin-left:-1.5px;background:linear-gradient(180deg,#d4d4da,#b9b9c1);border-radius:2px}
398
+ .lamp .shade{position:absolute;left:50%;top:6px;width:46px;height:20px;margin-left:-23px;
399
+ background:linear-gradient(180deg,#3f3f46,#27272a);border-radius:7px 7px 3px 3px;
400
+ box-shadow:inset 0 -2px 0 #ffd9a099,0 3px 6px #0000001f;transform:rotate(-6deg)}
401
+ .lamp .shade:after{content:"";position:absolute;left:50%;bottom:-3px;width:9px;height:4px;margin-left:-4.5px;
402
+ border-radius:50%;background:#fff3d6;box-shadow:0 0 10px #ffcf8a,0 0 22px #ffcf8a66}
403
+ .lamp .cone{position:absolute;left:50%;top:24px;width:78px;height:74px;margin-left:-39px;
404
+ background:linear-gradient(180deg,#ffd9a03d,#ffcf8a12 60%,transparent);
405
+ clip-path:polygon(28% 0,72% 0,100% 100%,0 100%);opacity:.55;transition:opacity .3s}
406
+ .lamp .pool{position:absolute;left:50%;top:96px;width:132px;height:40px;margin-left:-66px;
407
+ background:radial-gradient(ellipse at 50% 40%,#ffcf8a45,#ffcf8a14 58%,transparent 76%);opacity:.8}
408
+ .seat.working .lamp .cone,.seat.working .lamp .pool{opacity:1}
409
+ .seat.offline .lamp .cone,.seat.offline .lamp .pool{opacity:.08}
410
+
411
+ /* 状态:干净的金/蓝圈,不发光 */
412
+ .seat.idle{filter:saturate(.96)}
413
+ .seat.working .avatar{border-color:#fff;box-shadow:0 0 0 3px #f0b45a66,0 10px 24px #00000024}
414
+ .seat.thinking .avatar{border-color:#fff;box-shadow:0 0 0 3px #7fb3e166,0 10px 24px #00000024}
415
+ .seat.working .desk{filter:drop-shadow(0 10px 22px #0000001f) drop-shadow(0 0 14px #f0b45a2e)}
416
+ .seat.offline{filter:grayscale(.7) opacity(.72)}
417
+ .seat.tick .desk{animation:rttick .4s cubic-bezier(.2,.8,.2,1)}
418
+ .seat.hit .desk{animation:rthit .45s cubic-bezier(.36,.07,.19,.97)}
419
+ @keyframes rttick{40%{transform:scale(1.035)}}
420
+ @keyframes rthit{20%{transform:translateX(-5px)}40%{transform:translateX(4px)}60%{transform:translateX(-2px)}80%{transform:translateX(1px)}}
421
+ @keyframes rtflow{to{stroke-dashoffset:-8}}
422
+
423
+ /* 流光:细线 + 沿线小圆点包 */
424
+ .beam{animation:none}
425
+ .beam path{fill:none;stroke-linecap:round}
426
+ .beam .base{stroke:#e6e6ea;stroke-width:1.4}
427
+ .beam .trace{stroke:#8a8a94;stroke-width:1.6;stroke-dasharray:3 5;animation:rtflow .7s linear infinite}
428
+ .beam.at .trace{stroke:#2563eb;stroke-width:1.4;opacity:.7}
429
+ .beam.return .trace{stroke:#0f766e}
430
+ .beam.reject .trace{stroke:#dc2626;stroke-dasharray:2 5}
431
+ .beam .dot{fill:#111112;filter:drop-shadow(0 0 3px #85858c)}
432
+ .beam.return .dot{fill:#fff;stroke:#0f766e;stroke-width:2}
433
+ .beam.reject .dot{fill:#dc2626;stroke:#fff;stroke-width:1.5}
434
+ .beam .halo{fill:none;stroke:#11111218;stroke-width:9}
435
+ /* @ 细光:更淡的细线 + 流动虚线 + 一颗小圆点走过(实时与回放共用同一段动画) */
436
+ .beam.at{animation:atOut 1.5s ease-out forwards}
437
+ .beam.at .base{stroke:#dbeafe;stroke-width:1.2}
438
+ .beam.at .trace{stroke:#2563eb;stroke-width:1.2;stroke-dasharray:2 6;opacity:.9}
439
+ .beam.at .dot{fill:#2563eb;filter:drop-shadow(0 0 4px #2563eb66)}
440
+ @keyframes atOut{0%,72%{opacity:1}100%{opacity:0}}
441
+
442
+ @media (max-width:760px){
443
+ .stage{border-radius:18px}
444
+ .seats{grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 2px}
445
+ .seat:nth-child(3n+2),.seat:nth-child(3n){transform:none}
446
+ .chair{bottom:78px;width:72px;height:66px}
447
+ .person{bottom:50px;width:140px;height:132px}
448
+ .body{width:92px;height:72px}
449
+ .avatar{width:86px;height:86px;border-width:2px}.avatar .init{font-size:28px}
450
+ .desk{width:100%;max-width:220px;height:98px}
451
+ .desktop{height:56px}.desktop:before{height:13px;bottom:-12px}
452
+ .desk:after{width:8px;height:54px;right:-7px}
453
+ .monitor{width:78px;height:48px}.stand{top:45px;height:10px}.base{top:55px;width:32px}
454
+ .lamp{width:44px;height:80px;top:-26px;right:6%}
455
+ .lamp .arm{height:42px}.lamp .shade{width:34px;margin-left:-17px;height:16px}
456
+ .lamp .cone{width:58px;margin-left:-29px;height:56px;top:20px}
457
+ .lamp .pool{width:104px;height:32px;margin-left:-52px;top:74px}
458
+ .status{font-size:9px;top:-16px;left:4px}.tasktag{font-size:10px;padding:2px 6px}
459
+ }
460
+
461
+
462
+ /* 精修层:极淡的暖冷渐变、顶部光泽、卡片层次 */
463
+ .app{background:
464
+ radial-gradient(900px 430px at 16% -10%,#ffe6c4 0,transparent 62%),
465
+ radial-gradient(820px 400px at 92% -6%,#d9e9ff 0,transparent 64%)}
466
+ .stage:before{content:"";position:absolute;left:26px;right:26px;top:0;height:1px;z-index:30;pointer-events:none;
467
+ background:linear-gradient(90deg,transparent,#e6c39a,#a8c6e8 55%,transparent);opacity:.85}
468
+ .stage{box-shadow:0 1px 2px #0000000a,0 30px 70px -30px #00000033}
469
+ .side{box-shadow:0 1px 2px #0000000a,0 20px 44px -26px #0000002e}
470
+ .log li{box-shadow:0 1px 2px #00000008;background:#fff}
471
+ .log li:hover{background:#fcfcfd}
472
+ .log .l1{font-size:12.5px}
473
+ .log .l2{color:#8a8a92}
474
+ .stats,.clock,.vclock{font-variant-numeric:tabular-nums}
475
+ .sub{color:#8a8a92}
476
+ .foot{color:#8a8a92}
477
+ .bar{box-shadow:0 1px 2px #0000000a,0 10px 24px -18px #0000002e}
478
+ .floor{background:
479
+ radial-gradient(680px 300px at 50% 6%,#fffefa 0,#f8f6f2 52%,#efeeec 100%)}
480
+ .floor:after{background:
481
+ radial-gradient(520px 210px at 22% 14%,#ffd9a02b,transparent 62%),
482
+ radial-gradient(560px 230px at 80% 6%,#bcd7ff24,transparent 64%),
483
+ radial-gradient(760px 300px at 50% 104%,#00000012,transparent 70%)}
484
+ .desk{filter:drop-shadow(0 16px 22px #00000018)}
485
+ .seat.working .desk{filter:drop-shadow(0 16px 22px #0000001c) drop-shadow(0 0 16px #e8b2682e)}
486
+ .seat.idle .desk{filter:drop-shadow(0 14px 20px #00000014)}
487
+ .avatar{box-shadow:0 0 0 1px #00000010,0 10px 22px #00000020}
488
+ .seat.working .avatar{box-shadow:0 0 0 3px #efb56777,0 10px 22px #00000020}
489
+ .plate{letter-spacing:.4px}
490
+ </style>
491
+ </head>
492
+ <body>
493
+ <div class="app">
494
+ <header class="topbar">
495
+ <div class="brand"><span class="mark" aria-hidden="true"></span><div><h1>团队工作台</h1><p class="sub" id="subtitle">办公室里每一道光,都是一条真实事件</p></div></div>
496
+ <div class="topright"><a class="pill galaxy" href="/collab/wiki-galaxy">Wiki 星河</a><span class="badge fixture" id="fixtureBadge" hidden>示例数据 · 非真实事件</span><span class="clock" id="clockDigital">--:--:--</span><span class="pill wait" id="conn">连接中</span></div>
497
+ </header>
498
+ <section class="bar">
499
+ <div class="modes" role="tablist" aria-label="模式"><button class="mode on" id="modeLive" role="tab" aria-selected="true">实时</button><button class="mode" id="modeReplay" role="tab" aria-selected="false">回放</button></div>
500
+ <div class="replaybar" id="replayBar" hidden>
501
+ <label class="field">从<input type="datetime-local" id="from"></label>
502
+ <label class="field">到<input type="datetime-local" id="to"></label>
503
+ <span class="presets"><button class="mini" data-range="1h">近 1 小时</button><button class="mini" data-range="tonight">昨晚</button></span>
504
+ <button class="btn primary" id="loadReplay">载入</button>
505
+ <button class="btn" id="playPause" disabled>▶</button>
506
+ <label class="field speed">速度<select id="speed"><option value="1">1×</option><option value="4" selected>4×</option><option value="16">16×</option></select></label>
507
+ <input class="progress" id="progress" type="range" min="0" max="100" value="0" disabled aria-label="回放进度">
508
+ <span class="vclock" id="virtualClock">--:--</span>
509
+ </div>
510
+ <div class="stats" id="stats">在线 0/0 · 进行中 0</div>
511
+ </section>
512
+ <main>
513
+ <section class="stage" id="stage">
514
+ <div class="wall">
515
+ <div class="window w1"><i></i><i></i><i></i><span class="city"></span></div>
516
+ <div class="window w2"><i></i><i></i><i></i><span class="city"></span></div>
517
+ <div class="whiteboard"><b></b><b></b><b></b><b></b></div>
518
+ <div class="wallclock" id="wallClock" aria-hidden="true"><span class="hand h"></span><span class="hand m"></span><span class="hand s"></span><span class="pin"></span></div>
519
+ <div class="sign">TEAM DESK</div>
520
+ </div>
521
+ <div class="floor"></div>
522
+ <div class="rug"></div>
523
+ <div class="plant p1"></div><div class="plant p2"></div><div class="plant p3"></div>
524
+ <div class="seats" id="seats"></div>
525
+ <svg class="beams" id="beams" aria-hidden="true"></svg>
526
+ <div class="empty" id="empty" hidden>还没有成员接入,等 member 事件</div>
527
+ </section>
528
+ <aside class="side">
529
+ <h2>实况 <span class="count" id="eventCount">0</span></h2>
530
+ <ol class="log" id="eventLog"></ol>
531
+ <div class="legend"><span class="lg assign">派活</span><span class="lg return">交回</span><span class="lg reject">打回</span><span class="lg at">@</span><span class="lg pause">暂停</span></div>
532
+ </aside>
533
+ </main>
534
+ <footer class="foot"><div class="ticker" id="ticker">等待事件…</div><div class="foot-note">只画真实事件 · 空闲时安静摸鱼</div></footer>
535
+ </div>
536
+ <div class="toast" id="toast" hidden></div>
537
+ <script type="application/json" id="roster">
538
+ {
539
+ "version": 1,
540
+ "note": "成员 → 头像对照表。project 是 Issue #876 事件契约的键;显示名以 roster.name 为准(state.name 可能只是 agent 的 display_name,如 DeepSeek),name/aliases 也用于解析 who 与 @ 文本。avatar 为空表示用名字首字母圆形徽标。",
541
+ "defaultAvatar": "",
542
+ "members": [
543
+ {
544
+ "project": "gge",
545
+ "name": "G哥",
546
+ "role": "lead",
547
+ "avatar": "dragon",
548
+ "deployed": true,
549
+ "aliases": [
550
+ "G哥",
551
+ "g哥",
552
+ "Codex",
553
+ "codex",
554
+ "龙娘"
555
+ ]
556
+ },
557
+ {
558
+ "project": "cjiang",
559
+ "name": "C酱",
560
+ "role": "sub",
561
+ "avatar": "orange",
562
+ "deployed": true,
563
+ "aliases": [
564
+ "C酱",
565
+ "c酱",
566
+ "Claude",
567
+ "claude",
568
+ "橘"
569
+ ]
570
+ },
571
+ {
572
+ "project": "deep",
573
+ "name": "肥鱼",
574
+ "role": "sub",
575
+ "avatar": "whale",
576
+ "deployed": true,
577
+ "aliases": [
578
+ "肥鱼",
579
+ "大肥鱼",
580
+ "鲸鱼",
581
+ "鲸鱼娘",
582
+ "Deep",
583
+ "deep",
584
+ "DeepSeek",
585
+ "deepseek",
586
+ "深度求索"
587
+ ]
588
+ },
589
+ {
590
+ "project": "gjiang",
591
+ "name": "大豆包",
592
+ "role": "sub",
593
+ "avatar": "rainbow",
594
+ "deployed": true,
595
+ "aliases": [
596
+ "大豆包",
597
+ "豆包",
598
+ "G酱",
599
+ "g酱",
600
+ "Gemini",
601
+ "gemini",
602
+ "彩虹猫"
603
+ ]
604
+ },
605
+ {
606
+ "project": "cursor",
607
+ "name": "Cursor",
608
+ "role": "sub",
609
+ "avatar": "",
610
+ "deployed": true,
611
+ "aliases": [
612
+ "Cursor",
613
+ "cursor"
614
+ ]
615
+ },
616
+ {
617
+ "project": "grok",
618
+ "name": "Grok",
619
+ "role": "sub",
620
+ "avatar": "",
621
+ "deployed": false,
622
+ "aliases": [
623
+ "Grok",
624
+ "grok",
625
+ "渡鸦"
626
+ ]
627
+ }
628
+ ]
629
+ }
630
+ </script>
631
+ <script type="application/json" id="fixtureData">
632
+ [{"type":"members","members":[{"id":"lead","name":"队长","kind":"dsh","online":true,"avatar":"orange"},{"id":"ayan","name":"阿岩","kind":"dsh","online":true,"avatar":"dragon"},{"id":"ayou","name":"阿柚","kind":"bot","online":true,"avatar":"whale"},{"id":"ache","name":"阿澈","kind":"bot","online":true,"avatar":"rainbow"},{"id":"alan","name":"阿岚","kind":"dsh","online":false,"avatar":"lanxi"}]},{"ts":"2026-11-03T09:02:00+08:00","id":"21","kind":"created","from":"lead","to":"ayou","title":"整理发布说明"},{"ts":"2026-11-03T09:03:00+08:00","id":"21","kind":"assigned","from":"lead","to":"ayou","title":"整理发布说明"},{"type":"thinking","ts":"2026-11-03T09:04:00+08:00","room_id":1,"members":["ayou"]},{"type":"message","ts":"2026-11-03T09:10:00+08:00","message":{"id":101,"room_id":1,"author":"ayou","author_kind":"bot","name":"阿柚","text":"@队长 初稿好了,缺一段回滚说明","created_at":1793668200}},{"ts":"2026-11-03T09:12:00+08:00","id":"21","kind":"returned","from":"lead","to":"ayou","title":"整理发布说明"},{"ts":"2026-11-03T09:14:00+08:00","id":"21","kind":"rejected","from":"lead","to":"ayou","title":"整理发布说明","note":"缺回滚步骤"},{"type":"thinking","ts":"2026-11-03T09:15:00+08:00","room_id":1,"members":["ayou"]},{"ts":"2026-11-03T09:38:00+08:00","id":"21","kind":"returned","from":"lead","to":"ayou","title":"整理发布说明"},{"ts":"2026-11-03T09:39:00+08:00","id":"21","kind":"done","from":"lead","to":"ayou","title":"整理发布说明"},{"type":"thinking","ts":"2026-11-03T09:40:00+08:00","room_id":1,"members":[]},{"ts":"2026-11-03T09:50:00+08:00","id":"22","kind":"created","from":"lead","to":"ayan","title":"修 CI 超时"},{"ts":"2026-11-03T09:51:00+08:00","id":"22","kind":"assigned","from":"lead","to":"ayan","title":"修 CI 超时"},{"type":"thinking","ts":"2026-11-03T09:52:00+08:00","room_id":1,"members":["ayan"]},{"type":"message","ts":"2026-11-03T10:02:00+08:00","message":{"id":102,"room_id":1,"author":"ayan","author_kind":"bot","name":"阿岩","text":"@阿澈 这条超时日志帮看下","created_at":1793671320}},{"ts":"2026-11-03T10:10:00+08:00","id":"22","kind":"paused","from":"lead","to":"ayan","title":"修 CI 超时","note":"等上游镜像"},{"type":"thinking","ts":"2026-11-03T10:11:00+08:00","room_id":1,"members":[]},{"ts":"2026-11-03T10:15:00+08:00","id":"23","kind":"assigned","from":"lead","to":"ache","title":"手机竖屏验收"},{"type":"thinking","ts":"2026-11-03T10:16:00+08:00","room_id":1,"members":["ache"]},{"ts":"2026-11-03T10:31:00+08:00","id":"23","kind":"returned","from":"lead","to":"ache","title":"手机竖屏验收"},{"ts":"2026-11-03T10:33:00+08:00","id":"23","kind":"rejected","from":"lead","to":"ache","title":"手机竖屏验收","note":"工位名牌溢出"},{"type":"thinking","ts":"2026-11-03T10:34:00+08:00","room_id":1,"members":["ache"]},{"ts":"2026-11-03T10:52:00+08:00","id":"23","kind":"returned","from":"lead","to":"ache","title":"手机竖屏验收"},{"ts":"2026-11-03T10:53:00+08:00","id":"23","kind":"done","from":"lead","to":"ache","title":"手机竖屏验收"},{"type":"thinking","ts":"2026-11-03T10:54:00+08:00","room_id":1,"members":[]},{"ts":"2026-11-03T10:58:00+08:00","id":"24","kind":"assigned","from":"lead","to":"alan","title":"补回放接口文档"},{"ts":"2026-11-03T11:00:00+08:00","id":"24","kind":"returned","from":"lead","to":"alan","title":"补回放接口文档"},{"ts":"2026-11-03T11:01:00+08:00","id":"24","kind":"done","from":"lead","to":"alan","title":"补回放接口文档"},{"ts":"2026-11-03T11:05:00+08:00","id":"25","kind":"created","from":"lead","to":"","title":"整理周报"},{"ts":"2026-11-03T11:06:00+08:00","id":"25","kind":"assigned","from":"lead","to":"ache","title":"整理周报"},{"type":"thinking","ts":"2026-11-03T11:07:00+08:00","room_id":1,"members":["ache"]},{"ts":"2026-11-03T11:12:00+08:00","id":"25","kind":"returned","from":"lead","to":"ache","title":"整理周报"},{"ts":"2026-11-03T11:13:00+08:00","id":"25","kind":"done","from":"lead","to":"ache","title":"整理周报"},{"type":"thinking","ts":"2026-11-03T11:14:00+08:00","room_id":1,"members":[]}]
633
+ </script>
634
+ <script>
635
+ const $ = id => document.getElementById(id);
636
+ const q = (sel, root = document) => root.querySelector(sel);
637
+ const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
638
+ const SVG_NS = 'http://www.w3.org/2000/svg';
639
+ const svgEl = (tag, attrs = {}) => { const n = document.createElementNS(SVG_NS, tag); for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v); return n; };
640
+ const norm = v => String(v ?? '').trim().toLowerCase().replace(/[\s\u3000]+/g, '');
641
+ const msOf = v => { if (typeof v === 'number') return v < 1e12 ? v * 1000 : v; const n = Date.parse(String(v ?? '')); return Number.isFinite(n) ? n : null; };
642
+ const fmtTime = v => { const n = msOf(v); if (n == null) return '--:--'; const d = new Date(n); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
643
+ const fmtFull = v => { const n = msOf(v); if (n == null) return '--'; const d = new Date(n); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`; };
644
+ const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
645
+ const KIND = { created: '新建', assigned: '派活', returned: '交回', rejected: '打回', paused: '暂停', done: '完成', at: '@' };
646
+ const PALETTE = ['#e0a458', '#6cb6ff', '#59d3a5', '#c78df2', '#f08a8a', '#78d0d8', '#d9b36c'];
647
+ const SHIRTS = ['#3b5270', '#4a3d68', '#2f5d55', '#5a4430', '#3c4f73', '#5a3f52'];
648
+ const hashOf = s => [...String(s ?? '')].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7);
649
+ const colorOf = s => PALETTE[hashOf(s) % PALETTE.length];
650
+ const shirtOf = s => SHIRTS[hashOf(s) % SHIRTS.length];
651
+
652
+ const ROSTER = (() => { try { return JSON.parse($('roster').textContent) ?? { members: [] }; } catch { return { members: [] }; } })();
653
+ const FIXTURE = (() => { try { const v = JSON.parse($('fixtureData').textContent); return Array.isArray(v) ? v : []; } catch { return []; } })();
654
+ // 仅 ?fixture=1 的演示成员;真实模式一律用 /collab/api/state 的成员。
655
+ const DEMO_MEMBERS = [
656
+ { id: "lead", name: "队长", kind: "dsh", avatar: "orange", online: true },
657
+ { id: "ayan", name: "阿岩", kind: "dsh", avatar: "dragon", online: true },
658
+ { id: "ayou", name: "阿柚", kind: "bot", avatar: "whale", online: true },
659
+ { id: "ache", name: "阿澈", kind: "bot", avatar: "rainbow", online: true },
660
+ { id: "alan", name: "阿岚", kind: "dsh", avatar: "lanxi", online: false },
661
+ ];
662
+
663
+ const S = {
664
+ me: null, memberList: [], byId: new Map(), alias: new Map(), rosterFor: new Map(),
665
+ lead: null, active: new Map(), thinkingRooms: new Map(), thinking: new Set(), dispatched: new Set(),
666
+ fixture: false, mode: 'live', eventCount: 0, logLimit: 80,
667
+ replay: { events: [], idx: 0, timer: null, playing: false, speed: 4, loaded: false, auto: false },
668
+ };
669
+
670
+ function matchRoster(m) {
671
+ const id = norm(m.id), name = norm(m.name);
672
+ for (const rm of ROSTER.members ?? []) {
673
+ const cand = [rm.project, rm.name, ...(rm.aliases ?? [])];
674
+ if (cand.some(k => norm(k) === id || norm(k) === name)) return rm;
675
+ }
676
+ return undefined;
677
+ }
678
+
679
+ function indexMembers(list) {
680
+ S.memberList = (Array.isArray(list) ? list : []).filter(m => m && m.kind !== 'human' && m.id);
681
+ S.byId = new Map(); S.alias = new Map(); S.rosterFor = new Map();
682
+ for (const m of S.memberList) {
683
+ S.byId.set(m.id, m);
684
+ const rm = matchRoster(m);
685
+ S.rosterFor.set(m.id, rm);
686
+ for (const k of [m.id, m.name, rm?.project, rm?.name, ...(rm?.aliases ?? [])]) if (k) S.alias.set(norm(k), m);
687
+ }
688
+ for (const id of [...S.active.keys()]) if (!S.byId.has(id)) S.active.delete(id);
689
+ }
690
+
691
+ function resolveRef(ref) {
692
+ const raw = String(ref ?? '').trim();
693
+ if (!raw) return null;
694
+ const direct = S.alias.get(norm(raw));
695
+ if (direct) return direct;
696
+ let s = raw.replace(/([^)]*)|\([^)]*\)/g, '').replace(/工作区\s*\d*/g, '');
697
+ for (const part of s.split(/[;;\/、,,]+/)) {
698
+ let k = norm(part);
699
+ if (!k) continue;
700
+ if (S.alias.has(k)) return S.alias.get(k);
701
+ k = k.replace(/^(?:给|派给|由)/, '');
702
+ if (S.alias.has(k)) return S.alias.get(k);
703
+ const ascii = k.match(/[a-z0-9_-]{2,}/);
704
+ if (ascii && S.alias.has(ascii[0])) return S.alias.get(ascii[0]);
705
+ }
706
+ return null;
707
+ }
708
+
709
+ // roster 是团队称呼的权威来源(state.name 可能只是 agent 的 display_name,如 DeepSeek)。
710
+ const displayName = m => S.rosterFor.get(m?.id)?.name || m?.name || m?.id || '未知';
711
+ function avatarSpec(m) {
712
+ const rm = S.rosterFor.get(m.id);
713
+ const own = String(m.avatar || '').trim();
714
+ const custom = /^https?:\/\//i.test(own) || own.startsWith('upload:');
715
+ // 上传/外链头像优先;内置角色图以 roster 为准,roster 写空就不借用别人的图。
716
+ const v = custom ? own : (rm ? String(rm.avatar || '').trim() : own);
717
+ if (/^https?:\/\//i.test(v)) return { src: v };
718
+ if (v.startsWith('upload:')) return { src: '/collab/avatars/uploads/' + encodeURIComponent(v.slice(7).replace(/\.png$/i, '')) + '.png' };
719
+ if (v) return { src: '/collab/avatars/' + encodeURIComponent(v.replace(/\.png$/i, '')) + '.png' };
720
+ return { init: Array.from(displayName(m))[0] || '?', color: colorOf(m.id) };
721
+ }
722
+
723
+ function findActive(id) {
724
+ for (const [memberId, rec] of S.active) if (rec.id === id) return { memberId, rec };
725
+ return null;
726
+ }
727
+ function setTask(member, rec) {
728
+ if (!member) return;
729
+ for (const [id, cur] of S.active) if (cur.id === rec.id && id !== member.id) S.active.delete(id);
730
+ S.active.set(member.id, rec);
731
+ }
732
+ function clearTask(memberId) { S.active.delete(memberId); }
733
+ function initBadge(m) {
734
+ const n = el('span', 'init', Array.from(displayName(m))[0] || '?');
735
+ n.style.background = `linear-gradient(145deg,${colorOf(m.id)},#182231 70%)`;
736
+ return n;
737
+ }
738
+
739
+ function seatNode(m) {
740
+ const seat = el('div', 'seat');
741
+ seat.dataset.id = m.id;
742
+ seat.style.setProperty('--shirt', shirtOf(m.id));
743
+ const chair = el('div', 'chair');
744
+ const person = el('div', 'person');
745
+ const aura = el('div', 'aura');
746
+ const body = el('div', 'body');
747
+ const avatar = el('div', 'avatar');
748
+ const spec = avatarSpec(m);
749
+ if (spec.src) {
750
+ const img = el('img');
751
+ img.alt = ''; img.decoding = 'async'; img.src = spec.src;
752
+ img.addEventListener('error', () => img.replaceWith(initBadge(m)));
753
+ avatar.append(img);
754
+ } else avatar.append(initBadge(m));
755
+ avatar.append(el('span', 'dot'));
756
+ const crown = el('span', 'crown', '♛'); crown.hidden = true; avatar.append(crown);
757
+ const tasktag = el('div', 'tasktag'); tasktag.hidden = true;
758
+ const bubble = el('div', 'bubble'); bubble.hidden = true;
759
+ person.append(aura, body, avatar, tasktag, bubble);
760
+ const desk = el('div', 'desk');
761
+ const monitor = el('div', 'monitor');
762
+ monitor.append(el('div', 'screen'), el('div', 'stand'), el('div', 'base'));
763
+ const desktop = el('div', 'desktop');
764
+ const plate = el('div', 'plate', displayName(m));
765
+ desktop.append(el('div', 'keyboard'), el('div', 'papers'), el('div', 'mug'), plate);
766
+ const status = el('div', 'status', '摸鱼中');
767
+ const lamp = el('div', 'lamp');
768
+ lamp.append(el('div', 'arm'), el('div', 'shade'), el('div', 'cone'), el('div', 'pool'));
769
+ desk.append(monitor, desktop, status, lamp);
770
+ seat.append(chair, person, desk);
771
+ seat.title = displayName(m);
772
+ return seat;
773
+ }
774
+
775
+ function rosterRank(m) {
776
+ const at = (ROSTER.members ?? []).indexOf(S.rosterFor.get(m.id));
777
+ return at < 0 ? 99 : at;
778
+ }
779
+ function orderedMembers() {
780
+ const list = [...S.memberList];
781
+ list.sort((a, b) => rosterRank(a) - rosterRank(b) || displayName(a).localeCompare(displayName(b), 'zh'));
782
+ const lead = S.lead ? S.byId.get(S.lead) : undefined;
783
+ if (lead) {
784
+ const at = list.indexOf(lead);
785
+ if (at > 0) { list.splice(at, 1); list.unshift(lead); }
786
+ }
787
+ return list;
788
+ }
789
+
790
+ // Column count from the live roster (3-12 seats must all look intentional): up to 4 seats keep one
791
+ // row, 5-8 split into two rows, 9-12 into three; never more than 4 columns, so a desk keeps a
792
+ // readable width. Source: this task's requirement that 3..12 seats all lay out well.
793
+ function seatColumns(n) {
794
+ if (n <= 5) return Math.max(1, n);
795
+ if (n <= 8) return Math.ceil(n / 2);
796
+ return Math.min(4, Math.ceil(n / 3));
797
+ }
798
+
799
+ function renderSeats() {
800
+ const order = orderedMembers();
801
+ const nodes = order.map(seatNode);
802
+ S.seatNodes = new Map(order.map((m, i) => [m.id, nodes[i]]));
803
+ const seats = $('seats');
804
+ seats.style.setProperty('--cols', String(seatColumns(order.length)));
805
+ seats.replaceChildren(...nodes);
806
+ setEmpty();
807
+ paintAll();
808
+ }
809
+
810
+ function paintBubble(node, kind) {
811
+ const bubble = q('.bubble', node);
812
+ if (kind === 'off') { bubble.hidden = true; bubble.dataset.kind = ''; return; }
813
+ bubble.hidden = false;
814
+ if (bubble.dataset.kind === kind) return;
815
+ bubble.dataset.kind = kind;
816
+ bubble.replaceChildren();
817
+ if (kind === 'idle') {
818
+ bubble.append(el('span', 'zzz', 'z'), el('span', 'zzz', 'z'), el('span', 'fish'));
819
+ } else {
820
+ for (let i = 0; i < 3; i++) bubble.append(el('span', 'td'));
821
+ }
822
+ }
823
+
824
+ function paintSeat(m) {
825
+ const node = S.seatNodes?.get(m.id);
826
+ if (!node) return;
827
+ const rec = S.active.get(m.id);
828
+ const thinking = S.thinking.has(m.id);
829
+ const online = m.online !== false;
830
+ const kind = !online ? 'off' : rec ? 'work' : thinking ? 'think' : 'idle';
831
+ node.classList.toggle('offline', !online);
832
+ node.classList.toggle('working', kind === 'work');
833
+ node.classList.toggle('thinking', kind === 'think');
834
+ node.classList.toggle('idle', kind === 'idle');
835
+ node.classList.toggle('lead', S.lead === m.id);
836
+ q('.crown', node).hidden = S.lead !== m.id;
837
+ q('.avatar .dot', node).style.background = !online ? '#64748b' : rec ? '#ffc56b' : thinking ? '#7dd3fc' : '#34d399';
838
+ const tag = q('.tasktag', node);
839
+ if (rec) {
840
+ tag.hidden = false;
841
+ tag.textContent = `#${rec.id || '?'} ${rec.title || ''}`.trim();
842
+ tag.title = `${rec.title || ''}${rec.note ? ' · ' + rec.note : ''}`;
843
+ } else { tag.hidden = true; tag.title = ''; }
844
+ const status = q('.status', node);
845
+ status.textContent = !online ? '离线' : rec ? '干活中' : thinking ? '思考中…' : '摸鱼中';
846
+ paintBubble(node, kind);
847
+ q('.plate', node).textContent = displayName(m);
848
+ node.title = `${displayName(m)} · ${status.textContent}`;
849
+ }
850
+
851
+ function paintAll() {
852
+ for (const m of S.memberList) paintSeat(m);
853
+ updateStats();
854
+ }
855
+
856
+ function updateStats() {
857
+ const online = S.memberList.filter(m => m.online !== false).length;
858
+ $('stats').textContent = `在线 ${online}/${S.memberList.length} · 进行中 ${S.active.size}`;
859
+ }
860
+
861
+ function setEmpty() {
862
+ const empty = $('empty');
863
+ empty.hidden = S.memberList.length > 0;
864
+ if (!S.memberList.length) empty.textContent = S.fixture ? '示例成员载入中…' : '还没有成员接入 DSH';
865
+ }
866
+
867
+ function setTicker(kind, ts, fromName, toName, text, note) {
868
+ const route = [fromName, toName].filter(Boolean).join(' → ');
869
+ // 底部一行只给人看:去掉 dispatcher= / ws_was= 这类原始键值
870
+ const clean = v => String(v ?? '').replace(/[a-z_]+=[^\s·,]+/gi, '').replace(/\s{2,}/g, ' ').trim();
871
+ const body = [clean(text), clean(note)].filter(Boolean).join(' · ');
872
+ $('ticker').textContent = `最新:${fmtTime(ts)} ${KIND[kind] ?? kind} ${route}${body ? ' · ' + body : ''}`;
873
+ }
874
+
875
+ function addLog(kind, ts, fromName, toName, text, note) {
876
+ const list = $('eventLog');
877
+ const li = el('li');
878
+ const l1 = el('div', 'l1');
879
+ l1.append(el('span', 't', fmtTime(ts)), el('span', 'chip ' + kind, KIND[kind] ?? kind));
880
+ l1.append(el('span', 'route', [fromName, toName].filter(Boolean).join(' → ')));
881
+ li.append(l1, el('div', 'l2', [text, note].filter(Boolean).join(' · ') || '—'));
882
+ list.prepend(li);
883
+ while (list.children.length > S.logLimit) list.lastChild.remove();
884
+ S.eventCount += 1;
885
+ $('eventCount').textContent = S.eventCount;
886
+ setTicker(kind, ts, fromName, toName, text, note);
887
+ }
888
+
889
+ function setLead(id) {
890
+ const next = id && S.byId.has(id) ? id : null;
891
+ if (S.lead === next) { paintAll(); return; }
892
+ S.lead = next;
893
+ renderSeats();
894
+ }
895
+ function syncBeamViewBox() {
896
+ const stage = $('stage'), svg = $('beams');
897
+ svg.setAttribute('viewBox', `0 0 ${Math.max(1, stage.clientWidth)} ${Math.max(1, stage.clientHeight)}`);
898
+ }
899
+
900
+ function avatarCenter(id) {
901
+ const node = S.seatNodes?.get(id);
902
+ if (!node) return null;
903
+ const avatar = q('.avatar', node);
904
+ const a = avatar.getBoundingClientRect(), stage = $('stage').getBoundingClientRect();
905
+ return { x: a.left - stage.left + a.width / 2, y: a.top - stage.top + a.height / 2 };
906
+ }
907
+
908
+ function beam(from, to, kind = 'assign') {
909
+ if (!from || !to || from.id === to.id) return;
910
+ const a = avatarCenter(from.id), b = avatarCenter(to.id);
911
+ if (!a || !b) return;
912
+ syncBeamViewBox();
913
+ const dx = b.x - a.x, dy = b.y - a.y, dist = Math.hypot(dx, dy);
914
+ const lift = kind === 'at' ? Math.min(64, 24 + dist * 0.08) : Math.min(110, 32 + dist * 0.12);
915
+ const cx = (a.x + b.x) / 2, cy = Math.min(a.y, b.y) - lift;
916
+ const d = `M ${a.x.toFixed(1)} ${a.y.toFixed(1)} Q ${cx.toFixed(1)} ${cy.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`;
917
+ const g = svgEl('g', { class: `beam ${kind}` });
918
+ g.append(svgEl('path', { d, class: 'base' }));
919
+ g.append(svgEl('path', { d, class: 'trace' }));
920
+ $('beams').append(g);
921
+ // @ 细光:线短暂变成流动虚线,一颗小圆点从作者这边走过(实时与回放共用)。
922
+ if (kind === 'at') {
923
+ const dot = svgEl('circle', { class: 'dot', r: 2.6, cx: a.x, cy: a.y });
924
+ g.append(dot);
925
+ const line = q('.base', g), len = line.getTotalLength();
926
+ // 沿用 887 细线流光 1.5s 的淡出窗口:小圆点 760ms 走完,剩下的时间留给线淡出。
927
+ const dur = 760, t0 = performance.now();
928
+ const step = now => {
929
+ const k = Math.min(1, (now - t0) / dur);
930
+ const e = 1 - Math.pow(1 - k, 3);
931
+ const pt = line.getPointAtLength(len * e);
932
+ dot.setAttribute('cx', pt.x); dot.setAttribute('cy', pt.y);
933
+ if (k < 1) requestAnimationFrame(step);
934
+ };
935
+ requestAnimationFrame(step);
936
+ setTimeout(() => g.remove(), 1500);
937
+ return;
938
+ }
939
+ const dot = svgEl('circle', { class: 'dot', r: kind === 'return' ? 4 : 4.5, cx: a.x, cy: a.y });
940
+ g.append(dot);
941
+ const line = q('.base', g), len = line.getTotalLength();
942
+ const dur = 420, t0 = performance.now();
943
+ const step = now => {
944
+ const k = Math.min(1, (now - t0) / dur);
945
+ const e = k < .5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2;
946
+ const pt = line.getPointAtLength(len * e);
947
+ dot.setAttribute('cx', pt.x); dot.setAttribute('cy', pt.y);
948
+ if (k < 1) { requestAnimationFrame(step); return; }
949
+ const node = S.seatNodes?.get(to.id);
950
+ if (node) {
951
+ node.classList.remove('tick', 'hit');
952
+ void node.offsetWidth;
953
+ node.classList.add(kind === 'reject' ? 'hit' : 'tick');
954
+ setTimeout(() => node.classList.remove('tick', 'hit'), 620);
955
+ }
956
+ setTimeout(() => g.remove(), 280);
957
+ };
958
+ requestAnimationFrame(step);
959
+ }
960
+
961
+ function flashSeat(memberId, cls) {
962
+ const node = S.seatNodes?.get(memberId);
963
+ if (!node) return;
964
+ node.classList.remove(cls);
965
+ void node.offsetWidth;
966
+ node.classList.add(cls);
967
+ setTimeout(() => node.classList.remove(cls), 1500);
968
+ }
969
+
970
+ function deskPop(memberId, text, kind = 'info') {
971
+ const node = S.seatNodes?.get(memberId);
972
+ if (!node || !text) return;
973
+ const pop = el('div', `pop ${kind}`, text);
974
+ node.append(pop);
975
+ setTimeout(() => pop.remove(), 1900);
976
+ }
977
+
978
+ let toastTimer;
979
+ function toast(text) {
980
+ const box = $('toast');
981
+ box.textContent = text;
982
+ box.hidden = false;
983
+ clearTimeout(toastTimer);
984
+ toastTimer = setTimeout(() => { box.hidden = true; }, 3600);
985
+ }
986
+ function recomputeThinking() {
987
+ S.thinking = new Set();
988
+ for (const set of S.thinkingRooms.values()) for (const id of set) S.thinking.add(id);
989
+ }
990
+ function setThinkingRooms(map) {
991
+ S.thinkingRooms = new Map();
992
+ for (const [room, list] of Object.entries(map ?? {})) if (Array.isArray(list) && list.length) S.thinkingRooms.set(String(room), new Set(list));
993
+ recomputeThinking();
994
+ }
995
+ function applyThinking(ev) {
996
+ const room = String(ev.room_id ?? '');
997
+ const set = new Set(Array.isArray(ev.members) ? ev.members : []);
998
+ if (set.size) S.thinkingRooms.set(room, set); else S.thinkingRooms.delete(room);
999
+ recomputeThinking();
1000
+ paintAll();
1001
+ }
1002
+
1003
+ function setMembers(list) {
1004
+ indexMembers(list);
1005
+ if (S.lead && !S.byId.has(S.lead)) S.lead = null;
1006
+ renderSeats();
1007
+ }
1008
+
1009
+ /**
1010
+ * 事件契约(真实 A 路):from 是派活人,to 是干活人。
1011
+ * 回传光只在绘制处反向一次:beam(worker, dispatcher)。这里不再做第二次方向猜测。
1012
+ * active 记录只在 to 缺失时兜底,不覆盖事件里的 to。
1013
+ */
1014
+ function workerAndDispatcher(rec, from, to) {
1015
+ const worker = to || (rec ? S.byId.get(rec.memberId) : null);
1016
+ return { worker, dispatcher: from };
1017
+ }
1018
+
1019
+ function setTask(member, rec) {
1020
+ if (rec.id) for (const [memberId, old] of S.active) if (old.id === rec.id && memberId !== member.id) S.active.delete(memberId);
1021
+ S.active.set(member.id, { ...rec, memberId: member.id });
1022
+ }
1023
+ function clearTask(memberId) { S.active.delete(memberId); }
1024
+ function findActive(taskId) {
1025
+ if (!taskId) return null;
1026
+ for (const [memberId, rec] of S.active) if (rec.id === taskId) return { ...rec, memberId };
1027
+ return null;
1028
+ }
1029
+
1030
+ // <dispatch-model>
1031
+ /** 同一次派活的稳定键:taskId>memberId。 */
1032
+ function dispatchKey(id, toId) { return `${id}>${toId}`; }
1033
+ /** 只有本次派活周期内没画过才放行。 */
1034
+ function dispatchAllowed(id, toId, dispatched) {
1035
+ return !!id && !!toId && !dispatched.has(dispatchKey(id, toId));
1036
+ }
1037
+ /** 结束本次派活:清掉该任务的所有去重键,交回/打回后再派同一个人仍会画光。 */
1038
+ function forgetDispatch(id, dispatched) {
1039
+ if (!id) return;
1040
+ const prefix = id + '>';
1041
+ for (const key of [...dispatched]) if (key.startsWith(prefix)) dispatched.delete(key);
1042
+ }
1043
+ // </dispatch-model>
1044
+
1045
+ function dispatchBeamOnce(id, from, to) {
1046
+ if (!dispatchAllowed(id, to.id, S.dispatched)) return;
1047
+ S.dispatched.add(dispatchKey(id, to.id));
1048
+ beam(from, to, 'assign');
1049
+ }
1050
+
1051
+ function applyTask(ev, animate = true) {
1052
+ const id = String(ev.id ?? '');
1053
+ const kind = String(ev.kind ?? '');
1054
+ const from = resolveRef(ev.from);
1055
+ const to = resolveRef(ev.to);
1056
+ const title = String(ev.title ?? '');
1057
+ const note = String(ev.note ?? '');
1058
+ const ts = ev.ts ?? Date.now();
1059
+ const fromName = from ? displayName(from) : (ev.from || '—');
1060
+ const toName = to ? displayName(to) : (ev.to || '—');
1061
+ const header = id ? `#${id} ${title}`.trim() : title;
1062
+
1063
+ if (kind === 'created' || kind === 'assigned') {
1064
+ if (to) {
1065
+ const prev = S.active.get(to.id);
1066
+ setTask(to, { id, title: title || prev?.title || '', note: note || prev?.note || '', from: from?.id ?? prev?.from ?? null, to: to.id });
1067
+ if (kind === 'created' && from && animate) dispatchBeamOnce(id, from, to);
1068
+ if (kind === 'assigned' && from && animate) dispatchBeamOnce(id, from, to);
1069
+ if (animate) flashSeat(to.id, 'flash-gold');
1070
+ }
1071
+ } else if (kind === 'returned' || kind === 'rejected') {
1072
+ const { worker, dispatcher } = workerAndDispatcher(findActive(id), from, to);
1073
+ if (kind === 'returned') {
1074
+ if (worker && dispatcher && animate) beam(worker, dispatcher, 'return');
1075
+ if (worker) {
1076
+ clearTask(worker.id);
1077
+ if (animate) { flashSeat(worker.id, 'flash-good'); deskPop(worker.id, '✓ 交回', 'good'); }
1078
+ }
1079
+ forgetDispatch(id, S.dispatched);
1080
+ } else {
1081
+ if (worker && dispatcher && animate) beam(dispatcher, worker, 'reject');
1082
+ if (worker && animate) { flashSeat(worker.id, 'flash-bad'); deskPop(worker.id, `✕ 打回${note ? ':' + note : ''}`, 'bad'); }
1083
+ forgetDispatch(id, S.dispatched);
1084
+ }
1085
+ } else if (kind === 'paused' || kind === 'done') {
1086
+ const { worker } = workerAndDispatcher(findActive(id), from, to);
1087
+ if (worker) {
1088
+ clearTask(worker.id);
1089
+ if (animate) {
1090
+ if (kind === 'done') { flashSeat(worker.id, 'flash-good'); deskPop(worker.id, '✓ 完成', 'good'); }
1091
+ else deskPop(worker.id, `暂停${note ? ':' + note : ''}`, 'info');
1092
+ }
1093
+ }
1094
+ forgetDispatch(id, S.dispatched);
1095
+ }
1096
+ addLog(kind, ts, fromName, toName, header, note);
1097
+ paintAll();
1098
+ }
1099
+
1100
+ function applyMessage(ev, animate = true) {
1101
+ const message = ev.message ?? ev;
1102
+ const text = String(message.text ?? '');
1103
+ if (!text || message.author_kind === 'user' || message.author_kind === 'human') return;
1104
+ const author = S.byId.get(message.author) || resolveRef(message.name) || resolveRef(message.author);
1105
+ if (!author) return;
1106
+ const targets = new Set();
1107
+ // #876 P: 回放接口直接给被 @ 的成员 id;实时事件没有这个字段,就按 hub 的同一套规则
1108
+ // (mentions.mjs)从文本认:``` 代码块里的不算,多个名字重叠时最长优先。
1109
+ if (Array.isArray(message.mentions)) {
1110
+ for (const id of message.mentions) {
1111
+ const other = S.byId.get(id);
1112
+ if (other && other.id !== author.id) targets.add(other);
1113
+ }
1114
+ } else {
1115
+ const outside = text.split('```').filter((_, i) => i % 2 === 0).join(' ');
1116
+ const pairs = [];
1117
+ for (const other of S.memberList) {
1118
+ if (other.id === author.id) continue;
1119
+ const rm = S.rosterFor.get(other.id);
1120
+ for (const n of [other.name, other.id, rm?.name, ...(rm?.aliases ?? [])].filter(Boolean)) pairs.push(['@' + n, other]);
1121
+ }
1122
+ pairs.sort((a, b) => b[0].length - a[0].length);
1123
+ // 逐位扫描、命中即跳过整段,和 mentions.mjs 的 matchAll(最长优先) 等价:
1124
+ // 否则 "@阿柚子" 会同时命中 "@阿柚子" 和 "@阿柚" 两个名字。
1125
+ for (let i = 0; i < outside.length;) {
1126
+ const hit = pairs.find(([alias]) => outside.startsWith(alias, i));
1127
+ if (hit === undefined) { i += 1; continue; }
1128
+ targets.add(hit[1]);
1129
+ i += hit[0].length;
1130
+ }
1131
+ }
1132
+ if (!targets.size) return;
1133
+ if (animate) for (const target of targets) beam(author, target, 'at');
1134
+ const ts = ev.ts ?? (message.created_at ? message.created_at * 1000 : Date.now());
1135
+ addLog('at', ts, displayName(author), [...targets].map(displayName).join('、'), text.slice(0, 70), '');
1136
+ }
1137
+
1138
+ function applyEvent(ev, animate = true) {
1139
+ if (!ev || typeof ev !== 'object') return;
1140
+ if (ev.type === 'task') applyTask(ev, animate);
1141
+ else if (ev.type === 'thinking') applyThinking(ev);
1142
+ else if (ev.type === 'message') applyMessage(ev, animate);
1143
+ else if (ev.type === 'members') setMembers(ev.members);
1144
+ else if (ev.type === 'lead') setLead(ev.effective_lead_id ?? ev.lead_id);
1145
+ }
1146
+ S.logLimit = S.logLimit || 80;
1147
+
1148
+ async function api(path) {
1149
+ const res = await fetch(path, { credentials: 'same-origin', cache: 'no-store' });
1150
+ if (!res.ok) {
1151
+ let message = res.statusText;
1152
+ try { const body = await res.json(); message = body.error || message; } catch { /* not JSON */ }
1153
+ throw new Error(`${message} (${res.status})`);
1154
+ }
1155
+ return res.json();
1156
+ }
1157
+
1158
+ function setConn(kind, text) {
1159
+ const pill = $('conn');
1160
+ pill.classList.remove('live', 'wait', 'err', 'replay');
1161
+ if (kind) pill.classList.add(kind);
1162
+ pill.textContent = text;
1163
+ }
1164
+
1165
+ function disconnectLive() {
1166
+ if (S.es) { S.es.close(); S.es = null; }
1167
+ }
1168
+
1169
+ async function loadState() {
1170
+ const state = await api('/collab/api/state');
1171
+ S.me = state.me ?? null;
1172
+ indexMembers(state.members ?? []);
1173
+ const lead = state.effective_lead_id ?? state.lead_id ?? null;
1174
+ S.lead = lead && S.byId.has(lead) ? lead : null;
1175
+ setThinkingRooms(state.thinking ?? {});
1176
+ renderSeats();
1177
+ }
1178
+
1179
+ function connectLive() {
1180
+ disconnectLive();
1181
+ const es = new EventSource('/collab/api/events');
1182
+ S.es = es;
1183
+ es.onopen = () => { if (S.es === es && S.mode === 'live') setConn('live', '实时已连接'); };
1184
+ es.onmessage = (message) => {
1185
+ // 已切回放或已换连接时,忽略在途回调,避免历史场景混入实时事件。
1186
+ if (S.es !== es || S.mode !== 'live') return;
1187
+ try { applyEvent(JSON.parse(message.data), true); } catch (error) { console.warn('desk event', error); }
1188
+ };
1189
+ es.onerror = () => { if (S.es === es && S.mode === 'live') setConn('wait', '重连中…'); };
1190
+ }
1191
+
1192
+ function resetScene(clearLog = true) {
1193
+ stopReplayTimer();
1194
+ S.active.clear();
1195
+ S.thinkingRooms.clear();
1196
+ S.thinking.clear();
1197
+ S.dispatched.clear();
1198
+ $('beams').replaceChildren();
1199
+ for (const node of S.seatNodes?.values() ?? []) for (const pop of node.querySelectorAll('.pop')) pop.remove();
1200
+ if (clearLog) {
1201
+ $('eventLog').replaceChildren();
1202
+ S.eventCount = 0;
1203
+ $('eventCount').textContent = '0';
1204
+ $('ticker').textContent = '等待事件…';
1205
+ }
1206
+ paintAll();
1207
+ }
1208
+
1209
+ function setModeUI(mode) {
1210
+ S.mode = mode;
1211
+ const live = mode === 'live';
1212
+ $('modeLive').classList.toggle('on', live);
1213
+ $('modeReplay').classList.toggle('on', !live);
1214
+ $('modeLive').setAttribute('aria-selected', String(live));
1215
+ $('modeReplay').setAttribute('aria-selected', String(!live));
1216
+ $('replayBar').hidden = live;
1217
+ if (!live) { disconnectLive(); setConn('replay', '回放模式'); }
1218
+ }
1219
+
1220
+ async function goLive() {
1221
+ disconnectLive();
1222
+ stopReplay();
1223
+ S.fixture = false;
1224
+ $('fixtureBadge').hidden = true;
1225
+ resetScene();
1226
+ setModeUI('live');
1227
+ setConn('wait', '连接中');
1228
+ try {
1229
+ await loadState();
1230
+ connectLive();
1231
+ } catch (error) {
1232
+ setConn('err', '连接失败');
1233
+ toast(`加载失败:${error.message}`);
1234
+ }
1235
+ }
1236
+ const R = (S.replay = { events: [], idx: 0, playing: false, speed: 4, timer: null, loaded: false });
1237
+
1238
+ function toLocalInput(date) {
1239
+ const p = n => String(n).padStart(2, '0');
1240
+ return `${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())}T${p(date.getHours())}:${p(date.getMinutes())}`;
1241
+ }
1242
+
1243
+ function setDefaultRange() {
1244
+ const now = new Date();
1245
+ $('to').value = toLocalInput(now);
1246
+ $('from').value = toLocalInput(new Date(now.getTime() - 2 * 3600e3));
1247
+ }
1248
+
1249
+ function setRangePreset(name) {
1250
+ const now = new Date();
1251
+ if (name === '1h') {
1252
+ $('to').value = toLocalInput(now);
1253
+ $('from').value = toLocalInput(new Date(now.getTime() - 3600e3));
1254
+ } else if (name === 'tonight') {
1255
+ const from = new Date(now); from.setDate(from.getDate() - 1); from.setHours(20, 0, 0, 0);
1256
+ const to = new Date(now); to.setHours(8, 0, 0, 0);
1257
+ $('from').value = toLocalInput(from); $('to').value = toLocalInput(to);
1258
+ }
1259
+ }
1260
+
1261
+ function eventMs(ev) {
1262
+ if (ev.type === 'message') {
1263
+ const message = ev.message ?? ev;
1264
+ if (typeof message.created_at === 'number') return message.created_at * 1000;
1265
+ }
1266
+ const raw = ev.ts ?? ev.created_at;
1267
+ if (typeof raw === 'number') return raw > 1e12 ? raw : raw * 1000;
1268
+ const ms = Date.parse(raw);
1269
+ return Number.isFinite(ms) ? ms : NaN;
1270
+ }
1271
+
1272
+ function normalizeReplay(data) {
1273
+ let list = Array.isArray(data) ? data : data?.events ?? data?.items ?? [];
1274
+ if (!Array.isArray(list)) list = [];
1275
+ if (!list.length && (Array.isArray(data?.board) || Array.isArray(data?.messages))) {
1276
+ list = [...(data.board ?? []).map(e => ({ ...e, type: 'task' })), ...(data.messages ?? []).map(m => ({ type: 'message', message: m }))];
1277
+ }
1278
+ return list
1279
+ .map(ev => {
1280
+ if (!ev || typeof ev !== 'object') return null;
1281
+ const out = ev.type === 'task' || ev.kind ? { ...ev, type: 'task' } : ev;
1282
+ const ms = eventMs(out);
1283
+ return Number.isFinite(ms) ? { ...out, _ms: ms } : null;
1284
+ })
1285
+ .filter(Boolean)
1286
+ .sort((a, b) => a._ms - b._ms);
1287
+ }
1288
+
1289
+ function stopReplayTimer() {
1290
+ if (R.timer) clearTimeout(R.timer);
1291
+ R.timer = null;
1292
+ }
1293
+ function pauseReplay() {
1294
+ R.playing = false;
1295
+ stopReplayTimer();
1296
+ const button = $('playPause');
1297
+ if (button) { button.textContent = '▶'; button.setAttribute('aria-label', '播放'); }
1298
+ }
1299
+ function stopReplay() {
1300
+ pauseReplay();
1301
+ R.events = []; R.idx = 0; R.loaded = false;
1302
+ }
1303
+
1304
+ function updateReplayUI() {
1305
+ $('playPause').disabled = !R.loaded || !R.events.length;
1306
+ $('playPause').textContent = R.playing ? '⏸' : '▶';
1307
+ $('playPause').setAttribute('aria-label', R.playing ? '暂停' : '播放');
1308
+ $('progress').disabled = !R.loaded || !R.events.length;
1309
+ $('progress').max = String(Math.max(1, R.events.length));
1310
+ $('progress').value = String(R.idx);
1311
+ if (R.events.length) $('virtualClock').textContent = fmtTime(R.events[R.idx > 0 ? R.idx - 1 : 0]._ms);
1312
+ else $('virtualClock').textContent = '--:--';
1313
+ }
1314
+
1315
+ function setReplayEvents(events) {
1316
+ R.events = events;
1317
+ R.idx = 0;
1318
+ R.loaded = true;
1319
+ R.playing = false;
1320
+ stopReplayTimer();
1321
+ updateReplayUI();
1322
+ if (!events.length) {
1323
+ $('virtualClock').textContent = '--:--';
1324
+ toast('这个时间段没有记录的事件');
1325
+ }
1326
+ }
1327
+
1328
+ function stepReplay() {
1329
+ if (!R.playing) return;
1330
+ const ev = R.events[R.idx];
1331
+ if (!ev) { pauseReplay(); return; }
1332
+ applyEvent(ev, true);
1333
+ R.idx += 1;
1334
+ updateReplayUI();
1335
+ if (R.idx >= R.events.length) {
1336
+ pauseReplay();
1337
+ toast('回放结束');
1338
+ return;
1339
+ }
1340
+ const gap = Math.min(2400, Math.max(350, R.events[R.idx]._ms - ev._ms)) / R.speed;
1341
+ R.timer = setTimeout(stepReplay, gap);
1342
+ }
1343
+
1344
+ function startReplay() {
1345
+ if (!R.events.length) return;
1346
+ if (R.idx >= R.events.length) {
1347
+ resetScene(true);
1348
+ R.idx = 0;
1349
+ }
1350
+ R.playing = true;
1351
+ updateReplayUI();
1352
+ stepReplay();
1353
+ }
1354
+
1355
+ function toggleReplay() {
1356
+ if (R.playing) pauseReplay(); else startReplay();
1357
+ updateReplayUI();
1358
+ }
1359
+
1360
+ function seekReplay(value) {
1361
+ const target = Math.max(0, Math.min(R.events.length, Number(value)));
1362
+ const was = R.playing;
1363
+ pauseReplay();
1364
+ resetScene(true);
1365
+ for (let i = 0; i < target; i++) applyEvent(R.events[i], false);
1366
+ R.idx = target;
1367
+ updateReplayUI();
1368
+ if (was) startReplay();
1369
+ }
1370
+
1371
+ async function loadReplayWindow(auto = false) {
1372
+ setModeUI('replay');
1373
+ pauseReplay();
1374
+ const from = Date.parse($('from').value);
1375
+ const to = Date.parse($('to').value);
1376
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) { toast('请选择有效的时间段'); return; }
1377
+ resetScene(true);
1378
+ setReplayEvents([]);
1379
+ try {
1380
+ const query = `from=${encodeURIComponent(new Date(from).toISOString())}&to=${encodeURIComponent(new Date(to).toISOString())}`;
1381
+ const data = await api('/collab/api/desk/replay?' + query);
1382
+ setReplayEvents(normalizeReplay(data));
1383
+ if (auto && R.events.length) startReplay();
1384
+ } catch (error) {
1385
+ toast(`回放载入失败:${error.message}`);
1386
+ updateReplayUI();
1387
+ }
1388
+ }
1389
+
1390
+ async function loadFixture() {
1391
+ S.fixture = true;
1392
+ $('fixtureBadge').hidden = false;
1393
+ setModeUI('replay');
1394
+ // B 路的 desk 路由只内联 index.html,不服务同目录文件;fixture 必须内联读取。
1395
+ let lines = [];
1396
+ try { lines = JSON.parse($('fixtureData').textContent || '[]'); } catch { lines = []; }
1397
+ const events = [];
1398
+ for (const line of lines) {
1399
+ try { events.push(typeof line === 'string' ? JSON.parse(line) : line); } catch { /* skip bad sample line */ }
1400
+ }
1401
+ const membersEvent = events.find(ev => ev.type === 'members' && Array.isArray(ev.members));
1402
+ indexMembers(membersEvent ? membersEvent.members : DEMO_MEMBERS);
1403
+ S.lead = S.memberList[0]?.id ?? null;
1404
+ renderSeats();
1405
+ setThinkingRooms({});
1406
+ setReplayEvents(normalizeReplay(events.filter(ev => ev.type !== 'members')));
1407
+ const params = new URLSearchParams(location.search);
1408
+ if (params.get('autoplay') === '1' && R.events.length) setTimeout(startReplay, 500);
1409
+ }
1410
+ function tickClock() {
1411
+ const d = new Date();
1412
+ const p = n => String(n).padStart(2, '0');
1413
+ $('clockDigital').textContent = `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
1414
+ const sec = d.getSeconds() + d.getMilliseconds() / 1000;
1415
+ const min = d.getMinutes() + sec / 60;
1416
+ const hour = (d.getHours() % 12) + min / 60;
1417
+ const clock = $('wallClock');
1418
+ const h = q('.hand.h', clock), m = q('.hand.m', clock), sHand = q('.hand.s', clock);
1419
+ if (h) h.style.transform = `rotate(${hour * 30}deg)`;
1420
+ if (m) m.style.transform = `rotate(${min * 6}deg)`;
1421
+ if (sHand) sHand.style.transform = `rotate(${sec * 6}deg)`;
1422
+ }
1423
+
1424
+ function wireUI() {
1425
+ $('modeLive').onclick = () => { goLive(); };
1426
+ $('modeReplay').onclick = () => { pauseReplay(); setModeUI('replay'); };
1427
+ $('loadReplay').onclick = () => { loadReplayWindow(true); };
1428
+ $('playPause').onclick = () => { toggleReplay(); };
1429
+ $('speed').onchange = () => { R.speed = Number($('speed').value) || 4; };
1430
+ // 进度条按事件条数定位;拖动后立刻重建场景,不等计时器。
1431
+ $('progress').oninput = () => { seekReplay($('progress').value); };
1432
+ for (const button of document.querySelectorAll('[data-range]')) button.onclick = () => setRangePreset(button.dataset.range);
1433
+ window.addEventListener('resize', syncBeamViewBox);
1434
+ }
1435
+
1436
+ async function boot() {
1437
+ setDefaultRange();
1438
+ wireUI();
1439
+ tickClock();
1440
+ setInterval(tickClock, 1000);
1441
+ syncBeamViewBox();
1442
+ const params = new URLSearchParams(location.search);
1443
+ if (params.get('fixture') === '1') { await loadFixture(); return; }
1444
+ const from = params.get('from'), to = params.get('to');
1445
+ if (from && to) {
1446
+ const f = Date.parse(from), t = Date.parse(to);
1447
+ if (Number.isFinite(f) && Number.isFinite(t)) {
1448
+ // 时间窗入口直接进回放:先断开实时,再载入历史,避免边连边混。
1449
+ setModeUI('replay');
1450
+ $('from').value = toLocalInput(new Date(f));
1451
+ $('to').value = toLocalInput(new Date(t));
1452
+ try { await loadState(); } catch (error) { setConn('err', '状态加载失败'); }
1453
+ await loadReplayWindow(false);
1454
+ return;
1455
+ }
1456
+ }
1457
+ await goLive();
1458
+ }
1459
+
1460
+ boot();
1461
+
1462
+ </script>
1463
+ </body>
1464
+ </html>