dsh-dashcam 1.12.1 → 2.0.0
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.
- package/README.md +47 -10
- package/README.zh-CN.md +47 -14
- package/cordis.patch.yml +5 -0
- package/docs/SPEC.md +1 -1
- package/dsh-dashcam-host/README.md +177 -0
- package/dsh-dashcam-host/README.zh-CN.md +181 -0
- package/dsh-dashcam-host/lib/client.js +1206 -0
- package/dsh-dashcam-host/lib/index.js +971 -0
- package/dsh-dashcam-host/lib/ingress.js +218 -0
- package/dsh-dashcam-host/lib/panel.js +487 -0
- package/dsh-dashcam-host/package.json +29 -0
- package/package.json +27 -3
|
@@ -0,0 +1,1206 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-dashcam-host",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
// dsh-dashcam 面板样式:忠实移植参考稿(dashcam.html)的暗玻璃 + REC 红 +
|
|
8
|
+
// 等宽数字风格,全部类名加 ddc- 前缀、只作用于 [data-ddc-root] 之下,
|
|
9
|
+
// 不外溢进宿主页面;徽章 .ddc-badge 与面板 .ddc-panel 是兄弟节点(都挂
|
|
10
|
+
// document.body,DSH 桌面 dsh-app:// 协议下 iframe 会 404——tan-pet 的教训)。
|
|
11
|
+
const DASHCAM_CSS = `
|
|
12
|
+
.ddc-badge,.ddc-root,.ddc-root *{box-sizing:border-box}
|
|
13
|
+
.ddc-badge,.ddc-root{--ddc-glass:rgba(255,255,255,.08);--ddc-glass-border:rgba(255,255,255,.12);--ddc-line:rgba(255,255,255,.06);--ddc-text:#f2f2f5;--ddc-text-2:rgba(242,242,245,.62);--ddc-text-3:rgba(242,242,245,.38);--ddc-accent:#d9534f;--ddc-accent-rgb:217,83,79;--ddc-accent-soft:#f0b9b6;--ddc-r-card:16px;--ddc-r-sm:8px;--ddc-gap:20px;--ddc-card-pad:16px 20px;--ddc-ease:200ms ease-out;--ddc-shadow:0 2px 8px rgba(0,0,0,.24);--ddc-shadow-hover:0 8px 24px rgba(0,0,0,.32);--ddc-blur:blur(20px) saturate(140%);--ddc-font:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei","Noto Sans CJK SC",sans-serif;--ddc-mono:ui-monospace,"SF Mono","Cascadia Mono",Consolas,"Roboto Mono",monospace}
|
|
14
|
+
.ddc-badge{position:fixed;right:24px;bottom:24px;z-index:2147483000;display:flex;align-items:center;gap:10px;padding:10px 16px;border-radius:999px;border:1px solid var(--ddc-glass-border);background:linear-gradient(180deg,rgba(20,20,28,.92),rgba(10,10,15,.92));backdrop-filter:var(--ddc-blur);-webkit-backdrop-filter:var(--ddc-blur);box-shadow:0 8px 24px rgba(0,0,0,.4);color:var(--ddc-text);font:600 13px/1 var(--ddc-font);cursor:pointer;user-select:none;touch-action:none;letter-spacing:.04em}
|
|
15
|
+
.ddc-badge:hover{border-color:rgba(var(--ddc-accent-rgb),.55);box-shadow:0 10px 28px rgba(0,0,0,.5)}
|
|
16
|
+
.ddc-badge .ddc-dot{width:8px;height:8px;border-radius:50%;background:var(--ddc-accent);box-shadow:0 0 0 4px rgba(var(--ddc-accent-rgb),.2);animation:ddcPulse 2s infinite}
|
|
17
|
+
.ddc-badge.ddc-off .ddc-dot{background:rgba(255,255,255,.25);box-shadow:none;animation:none}
|
|
18
|
+
.ddc-badge .ddc-time{font:600 12px/1 var(--ddc-mono);color:var(--ddc-text-2);letter-spacing:.06em}
|
|
19
|
+
@keyframes ddcPulse{0%,100%{opacity:1}50%{opacity:.4}}
|
|
20
|
+
.ddc-root{position:fixed;right:24px;bottom:24px;z-index:2147483000;width:min(980px,calc(100vw - 48px));height:min(720px,calc(100vh - 96px));display:flex;flex-direction:column;overflow:hidden;border-radius:20px;border:1px solid rgba(255,255,255,.14);background:radial-gradient(1200px 600px at 85% -10%,rgba(var(--ddc-accent-rgb),.1),transparent 60%),linear-gradient(180deg,#0d0d13,#0a0a0f 60%,#14141c);backdrop-filter:var(--ddc-blur);-webkit-backdrop-filter:var(--ddc-blur);box-shadow:0 24px 80px rgba(0,0,0,.6);color:var(--ddc-text);font:400 14px/1.5 var(--ddc-font);animation:ddcPanelIn 240ms cubic-bezier(.2,.9,.3,1)}
|
|
21
|
+
@keyframes ddcPanelIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
|
|
22
|
+
.ddc-root *,.ddc-root *::before,.ddc-root *::after{box-sizing:border-box}
|
|
23
|
+
.ddc-page{flex:1;min-height:0;display:flex;flex-direction:column;gap:var(--ddc-gap);padding:22px 26px}
|
|
24
|
+
.ddc-topbar{display:flex;align-items:center;justify-content:space-between;gap:16px}
|
|
25
|
+
.ddc-topbar h1{margin:0;font:700 22px/1.2 var(--ddc-font);letter-spacing:-.02em}
|
|
26
|
+
.ddc-topbar .ddc-sub{margin:4px 0 0;font:400 13px/1.4 var(--ddc-font);color:var(--ddc-text-2)}
|
|
27
|
+
.ddc-rec{display:flex;align-items:center;gap:9px;padding:8px 14px;border-radius:999px;border:1px solid rgba(var(--ddc-accent-rgb),.3);background:rgba(var(--ddc-accent-rgb),.08);font:600 12px/1 var(--ddc-mono);letter-spacing:.08em}
|
|
28
|
+
.ddc-rec .ddc-dot{width:7px;height:7px;border-radius:50%;background:var(--ddc-accent);animation:ddcPulse 2s infinite}
|
|
29
|
+
.ddc-rec.ddc-off{border-color:var(--ddc-glass-border);background:var(--ddc-glass);color:var(--ddc-text-2)}
|
|
30
|
+
.ddc-rec.ddc-off .ddc-dot{background:rgba(255,255,255,.3);animation:none}
|
|
31
|
+
.ddc-close{width:32px;height:32px;border-radius:10px;border:1px solid transparent;background:transparent;color:var(--ddc-text-2);font:400 20px/1 var(--ddc-font);cursor:pointer;transition:all var(--ddc-ease)}
|
|
32
|
+
.ddc-close:hover{background:var(--ddc-glass);border-color:var(--ddc-glass-border);color:var(--ddc-text)}
|
|
33
|
+
.ddc-tabs{display:flex;gap:6px;padding:5px;border-radius:12px;border:1px solid var(--ddc-line);background:rgba(0,0,0,.25)}
|
|
34
|
+
.ddc-tabs .ddc-tab{flex:0 0 auto;padding:9px 18px;border:0;border-radius:8px;background:transparent;color:var(--ddc-text-2);font:600 13px/1 var(--ddc-font);cursor:pointer;transition:all var(--ddc-ease)}
|
|
35
|
+
.ddc-tabs .ddc-tab:hover{color:var(--ddc-text)}
|
|
36
|
+
.ddc-tabs .ddc-tab.on{background:var(--ddc-glass);border:1px solid var(--ddc-glass-border);color:var(--ddc-text)}
|
|
37
|
+
.ddc-view{display:none;flex:1;min-height:0;flex-direction:column;gap:var(--ddc-gap)}
|
|
38
|
+
.ddc-view.on{display:flex}
|
|
39
|
+
.ddc-card{border:1px solid var(--ddc-line);border-radius:var(--ddc-r-card);background:var(--ddc-glass);box-shadow:var(--ddc-shadow);transition:border-color var(--ddc-ease),box-shadow var(--ddc-ease)}
|
|
40
|
+
.ddc-card:hover{border-color:var(--ddc-glass-border);box-shadow:var(--ddc-shadow-hover)}
|
|
41
|
+
.ddc-card-h{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 20px;border-bottom:1px solid var(--ddc-line)}
|
|
42
|
+
.ddc-card-h h2{margin:0;font:600 13px/1 var(--ddc-font);letter-spacing:.1em;color:var(--ddc-text-2);text-transform:uppercase}
|
|
43
|
+
.ddc-card-h .ddc-meta{font:400 12px/1 var(--ddc-font);color:var(--ddc-text-3)}
|
|
44
|
+
.ddc-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--ddc-gap)}
|
|
45
|
+
.ddc-m{padding:16px 18px}
|
|
46
|
+
.ddc-m .ddc-l{font:400 12px/1 var(--ddc-font);color:var(--ddc-text-3)}
|
|
47
|
+
.ddc-m .ddc-v{margin-top:8px;font:600 30px/1 var(--ddc-mono);font-variant-numeric:tabular-nums;letter-spacing:-.02em}
|
|
48
|
+
.ddc-m .ddc-u{font:600 13px/1 var(--ddc-font);color:var(--ddc-text-3);margin-left:4px}
|
|
49
|
+
.ddc-m.accent .ddc-v{color:var(--ddc-accent-soft)}
|
|
50
|
+
.ddc-split{flex:1;min-height:0;display:grid;grid-template-columns:1.7fr 1fr;gap:var(--ddc-gap)}
|
|
51
|
+
.ddc-stack{display:flex;flex-direction:column;gap:var(--ddc-gap);min-height:0}
|
|
52
|
+
.ddc-body{padding:var(--ddc-card-pad);display:flex;flex-direction:column;gap:10px}
|
|
53
|
+
.ddc-stream{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:8px;padding:2px 4px 2px 2px;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.15) transparent}
|
|
54
|
+
.ddc-stream::-webkit-scrollbar{width:6px}
|
|
55
|
+
.ddc-stream::-webkit-scrollbar-thumb{background:rgba(255,255,255,.15);border-radius:3px}
|
|
56
|
+
.ddc-ev{display:flex;align-items:baseline;gap:12px;padding:10px 12px;border-radius:var(--ddc-r-sm);border:1px solid var(--ddc-line);background:rgba(0,0,0,.2);animation:ddcEvIn 240ms ease-out}
|
|
57
|
+
@keyframes ddcEvIn{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}
|
|
58
|
+
.ddc-ev .ddc-time{font:400 12px/1.4 var(--ddc-mono);color:var(--ddc-text-3);white-space:nowrap;font-variant-numeric:tabular-nums}
|
|
59
|
+
.ddc-ev .ddc-tag{flex:0 0 auto;padding:3px 8px;border-radius:6px;font:600 11px/1 var(--ddc-font);letter-spacing:.06em}
|
|
60
|
+
.ddc-tag.t-think{background:rgba(255,255,255,.06);color:var(--ddc-text-2)}
|
|
61
|
+
.ddc-tag.t-read{background:rgba(120,180,255,.12);color:#a8c8f0}
|
|
62
|
+
.ddc-tag.t-edit{background:rgba(255,200,120,.14);color:#f0d0a0}
|
|
63
|
+
.ddc-tag.t-bash{background:rgba(150,255,200,.1);color:#a0e8c0}
|
|
64
|
+
.ddc-tag.t-net{background:rgba(180,150,255,.12);color:#c8b8f0}
|
|
65
|
+
.ddc-tag.t-tel{background:rgba(255,255,255,.05);color:var(--ddc-text-3)}
|
|
66
|
+
.ddc-tag.t-error{background:rgba(var(--ddc-accent-rgb),.15);color:var(--ddc-accent-soft)}
|
|
67
|
+
.ddc-ev .ddc-title{font:400 13px/1.4 var(--ddc-font);color:var(--ddc-text);word-break:break-all}
|
|
68
|
+
.ddc-ev.error .ddc-title{color:var(--ddc-accent-soft)}
|
|
69
|
+
.ddc-ev .ddc-detail{font:400 12px/1.4 var(--ddc-font);color:var(--ddc-text-3);word-break:break-all}
|
|
70
|
+
.ddc-empty{flex:1;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:8px;padding:28px;color:var(--ddc-text-3);font:400 13px/1.6 var(--ddc-font);text-align:center}
|
|
71
|
+
.ddc-kv{display:grid;grid-template-columns:auto 1fr;gap:7px 16px;font:400 13px/1.5 var(--ddc-font)}
|
|
72
|
+
.ddc-kv .ddc-k{color:var(--ddc-text-3);white-space:nowrap}
|
|
73
|
+
.ddc-kv .ddc-val{color:var(--ddc-text);font-family:var(--ddc-mono);font-size:12px;word-break:break-all;text-align:right}
|
|
74
|
+
.ddc-kv .ddc-val.warn{color:var(--ddc-accent-soft)}
|
|
75
|
+
.ddc-bar{height:100%;border-radius:5px;background:linear-gradient(90deg,rgba(var(--ddc-accent-rgb),.8),var(--ddc-accent));transition:width 500ms ease}
|
|
76
|
+
.ddc-barbg{height:10px;border-radius:5px;background:rgba(255,255,255,.08);overflow:hidden}
|
|
77
|
+
.ddc-step{display:flex;align-items:baseline;gap:12px}
|
|
78
|
+
.ddc-step .ddc-title{font:600 14px/1.4 var(--ddc-font);word-break:break-all}
|
|
79
|
+
.ddc-step.error .ddc-title{color:var(--ddc-accent-soft)}
|
|
80
|
+
.ddc-step .ddc-detail{font:400 12px/1.4 var(--ddc-font);color:var(--ddc-text-3);word-break:break-all}
|
|
81
|
+
.ddc-tl{width:100%;height:120px;display:block;cursor:crosshair;border-radius:var(--ddc-r-sm)}
|
|
82
|
+
.ddc-legend{display:flex;flex-wrap:wrap;gap:6px 16px;padding:2px 4px}
|
|
83
|
+
.ddc-legend span{display:inline-flex;align-items:center;gap:6px;font:400 12px/1 var(--ddc-font);color:var(--ddc-text-2)}
|
|
84
|
+
.ddc-legend i{width:10px;height:10px;border-radius:2px;background:var(--ddc-text-3)}
|
|
85
|
+
.ddc-ctl{display:flex;gap:8px;align-items:center}
|
|
86
|
+
.ddc-btn{padding:8px 14px;border-radius:var(--ddc-r-sm);border:1px solid var(--ddc-glass-border);background:var(--ddc-glass);color:var(--ddc-text);font:600 12px/1 var(--ddc-font);cursor:pointer;transition:all var(--ddc-ease)}
|
|
87
|
+
.ddc-btn:hover{background:rgba(255,255,255,.12)}
|
|
88
|
+
.ddc-btn.primary{background:var(--ddc-accent);border-color:transparent;color:#fff}
|
|
89
|
+
.ddc-btn.primary:hover{background:#e0605c}
|
|
90
|
+
.ddc-spd{padding:6px 10px;border-radius:6px;border:1px solid var(--ddc-line);background:transparent;color:var(--ddc-text-2);font:600 11px/1 var(--ddc-mono);cursor:pointer}
|
|
91
|
+
.ddc-spd.on{border-color:rgba(var(--ddc-accent-rgb),.4);color:var(--ddc-accent-soft);background:rgba(var(--ddc-accent-rgb),.08)}
|
|
92
|
+
.ddc-rpsplit{flex:1;min-height:0;display:grid;grid-template-columns:1.25fr 1fr;gap:var(--ddc-gap)}
|
|
93
|
+
.ddc-evlist{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:6px;padding:2px 4px 2px 2px;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.15) transparent}
|
|
94
|
+
.ddc-evlist::-webkit-scrollbar{width:6px}
|
|
95
|
+
.ddc-evlist::-webkit-scrollbar-thumb{background:rgba(255,255,255,.15);border-radius:3px}
|
|
96
|
+
.ddc-evlist .ddc-ev{cursor:pointer}
|
|
97
|
+
.ddc-evlist .ddc-ev.on{border-color:rgba(var(--ddc-accent-rgb),.5);background:rgba(var(--ddc-accent-rgb),.07)}
|
|
98
|
+
.ddc-diff{margin:0;padding:12px 14px;border-radius:var(--ddc-r-sm);border:1px solid var(--ddc-line);background:rgba(0,0,0,.3);font:400 12px/1.6 var(--ddc-mono);color:var(--ddc-text-2);white-space:pre-wrap;word-break:break-all;max-height:220px;overflow-y:auto;scrollbar-width:thin}
|
|
99
|
+
.ddc-toolbar{display:flex;gap:10px;align-items:center}
|
|
100
|
+
.ddc-search{flex:1;min-width:120px;padding:9px 14px;border-radius:var(--ddc-r-sm);border:1px solid var(--ddc-line);background:rgba(0,0,0,.25);color:var(--ddc-text);font:400 13px/1 var(--ddc-font);outline:none;transition:border-color var(--ddc-ease)}
|
|
101
|
+
.ddc-search:focus{border-color:rgba(var(--ddc-accent-rgb),.4)}
|
|
102
|
+
.ddc-search::placeholder{color:var(--ddc-text-3)}
|
|
103
|
+
.ddc-chips{display:flex;gap:6px}
|
|
104
|
+
.ddc-chip{padding:8px 12px;border-radius:999px;border:1px solid var(--ddc-line);background:transparent;color:var(--ddc-text-2);font:500 12px/1 var(--ddc-font);cursor:pointer;transition:all var(--ddc-ease)}
|
|
105
|
+
.ddc-chip.on{border-color:rgba(var(--ddc-accent-rgb),.45);color:var(--ddc-accent-soft);background:rgba(var(--ddc-accent-rgb),.08)}
|
|
106
|
+
.ddc-hlist{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:8px;padding:2px 4px 2px 2px;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.15) transparent}
|
|
107
|
+
.ddc-hlist::-webkit-scrollbar{width:6px}
|
|
108
|
+
.ddc-hlist::-webkit-scrollbar-thumb{background:rgba(255,255,255,.15);border-radius:3px}
|
|
109
|
+
.ddc-hrow{display:grid;grid-template-columns:90px 1.2fr repeat(4,minmax(60px,auto)) auto;gap:10px;align-items:center;padding:14px 18px;border-radius:var(--ddc-r-sm);border:1px solid var(--ddc-line);background:rgba(0,0,0,.2);cursor:pointer;transition:all var(--ddc-ease)}
|
|
110
|
+
.ddc-hrow:hover{border-color:var(--ddc-glass-border);background:rgba(0,0,0,.3)}
|
|
111
|
+
.ddc-hrow .ddc-hid{font:600 13px/1 var(--ddc-mono);color:var(--ddc-accent-soft)}
|
|
112
|
+
.ddc-hrow .ddc-hstart{font:400 13px/1.3 var(--ddc-font);color:var(--ddc-text)}
|
|
113
|
+
.ddc-hrow .ddc-num{font:400 13px/1 var(--ddc-mono);font-variant-numeric:tabular-nums;color:var(--ddc-text-2)}
|
|
114
|
+
.ddc-hrow .ddc-num.n-err{color:var(--ddc-accent-soft)}
|
|
115
|
+
.ddc-hrow .ddc-hacts{display:flex;gap:6px;justify-content:flex-end}
|
|
116
|
+
.ddc-st{display:inline-flex;align-items:center;gap:6px;font:500 12px/1 var(--ddc-font)}
|
|
117
|
+
.ddc-st::before{content:"";width:6px;height:6px;border-radius:50%}
|
|
118
|
+
.ddc-st.rec{color:var(--ddc-accent-soft)}.ddc-st.rec::before{background:var(--ddc-accent);animation:ddcPulse 2s infinite}
|
|
119
|
+
.ddc-st.done{color:var(--ddc-text-2)}.ddc-st.done::before{background:rgba(255,255,255,.3)}
|
|
120
|
+
.ddc-tip{position:fixed;z-index:2147483600;pointer-events:none;padding:10px 14px;border-radius:10px;border:1px solid var(--ddc-glass-border);background:rgba(12,12,18,.96);box-shadow:0 8px 24px rgba(0,0,0,.5);font:400 12px/1.5 var(--ddc-font);color:var(--ddc-text);max-width:420px;display:none}
|
|
121
|
+
.ddc-tip .ddc-tt{font:600 12px/1.4 var(--ddc-font);margin-bottom:2px}
|
|
122
|
+
.ddc-tip .ddc-td{color:var(--ddc-text-3)}
|
|
123
|
+
.ddc-tip .ddc-tt.error{color:var(--ddc-accent-soft)}
|
|
124
|
+
@media (max-width:1024px){.ddc-metrics{grid-template-columns:repeat(2,1fr)}.ddc-split,.ddc-rpsplit{grid-template-columns:1fr}.ddc-hrow{grid-template-columns:80px 1fr repeat(4,minmax(52px,auto));font-size:12px}}
|
|
125
|
+
@media (max-width:767px){.ddc-root{right:12px;bottom:12px;width:calc(100vw - 24px);height:calc(100vh - 72px)}.ddc-badge{right:12px;bottom:12px}.ddc-page{padding:16px;gap:14px}.ddc-metrics{grid-template-columns:repeat(2,1fr)}.ddc-m .ddc-v{font-size:24px}.ddc-hrow{grid-template-columns:70px 1fr auto auto;grid-auto-flow:row}.ddc-hrow .ddc-hstart{grid-column:2}.ddc-topbar h1{font-size:18px}}
|
|
126
|
+
`;
|
|
127
|
+
|
|
128
|
+
// dsh-dashcam 面板 UI(纯 DOM,不依赖 React——React 只在 client.body.js 挂插槽)。
|
|
129
|
+
// 视觉与交互移植自参考稿 dashcam.html:实时/回放/录像历史三视图、时间轴画布、
|
|
130
|
+
// 事件流、检索过滤。数据形状见 lib/panel.js sanitizeRecord/computeStatus/
|
|
131
|
+
// clusterSessions——面板只消费脱敏后的展示形状,不接触账本原文。
|
|
132
|
+
|
|
133
|
+
const TYPE_TEXT = { think: 'LLM', read: '读取', edit: '编辑', bash: '命令', net: '出网', tel: '遥测', error: '异常' };
|
|
134
|
+
const TYPE_ORDER = ['think', 'read', 'edit', 'bash', 'net', 'tel', 'error'];
|
|
135
|
+
// 画布刻度:类型 → [高度比例, 颜色 CSS 变量名或色值]
|
|
136
|
+
const MARK = {
|
|
137
|
+
edit: [1.0, '#f0d0a0'],
|
|
138
|
+
bash: [0.72, '#a0e8c0'],
|
|
139
|
+
net: [0.6, '#c8b8f0'],
|
|
140
|
+
read: [0.46, '#a8c8f0'],
|
|
141
|
+
think: [0.28, 'rgba(242,242,245,.62)'],
|
|
142
|
+
tel: [0.18, 'rgba(242,242,245,.38)'],
|
|
143
|
+
error: [1.0, '#d9534f'],
|
|
144
|
+
};
|
|
145
|
+
const SPEEDS = [1, 10, 60];
|
|
146
|
+
|
|
147
|
+
function esc(value) {
|
|
148
|
+
return String(value == null ? '' : value)
|
|
149
|
+
.replace(/&/g, '&')
|
|
150
|
+
.replace(/</g, '<')
|
|
151
|
+
.replace(/>/g, '>')
|
|
152
|
+
.replace(/"/g, '"');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function pad(n) {
|
|
156
|
+
return String(n).padStart(2, '0');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function hms(seconds) {
|
|
160
|
+
const s = Math.max(0, Math.floor(seconds));
|
|
161
|
+
const d = Math.floor(s / 86400);
|
|
162
|
+
const h = Math.floor((s % 86400) / 3600);
|
|
163
|
+
const m = Math.floor((s % 3600) / 60);
|
|
164
|
+
const sec = s % 60;
|
|
165
|
+
if (d > 0) return `${d}d ${pad(h)}:${pad(m)}:${pad(sec)}`;
|
|
166
|
+
return `${pad(h)}:${pad(m)}:${pad(sec)}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function shortHms(seconds) {
|
|
170
|
+
const s = Math.max(0, Math.floor(seconds));
|
|
171
|
+
if (s < 3600) return `${Math.floor(s / 60)}:${pad(s % 60)}`;
|
|
172
|
+
return hms(s).replace(/^00:/, '');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function clock(ts) {
|
|
176
|
+
const d = new Date(ts);
|
|
177
|
+
if (Number.isNaN(d.getTime())) return '--:--:--';
|
|
178
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function clockFull(ts) {
|
|
182
|
+
const d = new Date(ts);
|
|
183
|
+
if (Number.isNaN(d.getTime())) return '—';
|
|
184
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${clock(ts)}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function dateShort(ts) {
|
|
188
|
+
const d = new Date(ts);
|
|
189
|
+
if (Number.isNaN(d.getTime())) return '—';
|
|
190
|
+
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function clamp(v, lo, hi) {
|
|
194
|
+
return Math.min(hi, Math.max(lo, v));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function createDashcamPanel(options) {
|
|
198
|
+
const api = options.api || {};
|
|
199
|
+
const root = document.createElement('div');
|
|
200
|
+
root.className = 'ddc-root';
|
|
201
|
+
root.setAttribute('data-ddc-root', '1');
|
|
202
|
+
root.style.display = 'none';
|
|
203
|
+
root.innerHTML = `
|
|
204
|
+
<div class="ddc-page">
|
|
205
|
+
<header class="ddc-topbar">
|
|
206
|
+
<div>
|
|
207
|
+
<h1>Agent Dashcam</h1>
|
|
208
|
+
<p class="ddc-sub">判定链账本 · 实时录制与回放</p>
|
|
209
|
+
</div>
|
|
210
|
+
<div style="display:flex;align-items:center;gap:12px">
|
|
211
|
+
<div class="ddc-rec"><span class="ddc-dot"></span><span class="ddc-lb">REC</span><span class="ddc-time num">--:--:--</span></div>
|
|
212
|
+
<button class="ddc-close" title="关闭(Esc)">×</button>
|
|
213
|
+
</div>
|
|
214
|
+
</header>
|
|
215
|
+
<nav class="ddc-tabs">
|
|
216
|
+
<button class="ddc-tab on" data-ddc-view="live">实时录制</button>
|
|
217
|
+
<button class="ddc-tab" data-ddc-view="replay">回放</button>
|
|
218
|
+
<button class="ddc-tab" data-ddc-view="history">录像历史</button>
|
|
219
|
+
</nav>
|
|
220
|
+
|
|
221
|
+
<section class="ddc-view on" data-ddc-viewcard="live">
|
|
222
|
+
<div class="ddc-metrics">
|
|
223
|
+
<div class="ddc-card ddc-m"><div class="ddc-l">运行时长</div><div class="ddc-v ddc-m-elapsed">--:--:--</div></div>
|
|
224
|
+
<div class="ddc-card ddc-m"><div class="ddc-l">纳管调用</div><div class="ddc-v ddc-m-tools">0</div></div>
|
|
225
|
+
<div class="ddc-card ddc-m"><div class="ddc-l">改动文件</div><div class="ddc-v ddc-m-files">0</div></div>
|
|
226
|
+
<div class="ddc-card ddc-m accent"><div class="ddc-l">异常</div><div class="ddc-v ddc-m-errors">0</div></div>
|
|
227
|
+
</div>
|
|
228
|
+
<div class="ddc-split">
|
|
229
|
+
<section class="ddc-card" style="display:flex;flex-direction:column;min-height:0">
|
|
230
|
+
<div class="ddc-card-h"><h2>实时事件流</h2><span class="ddc-meta ddc-stream-meta">共 0 条</span></div>
|
|
231
|
+
<div class="ddc-body" style="flex:1;min-height:0">
|
|
232
|
+
<div class="ddc-stream"></div>
|
|
233
|
+
</div>
|
|
234
|
+
</section>
|
|
235
|
+
<div class="ddc-stack">
|
|
236
|
+
<section class="ddc-card">
|
|
237
|
+
<div class="ddc-card-h"><h2>当前步骤</h2><span class="ddc-meta ddc-step-age">—</span></div>
|
|
238
|
+
<div class="ddc-body">
|
|
239
|
+
<div class="ddc-step"><span class="ddc-tag t-tel ddc-step-tag">…</span><div><div class="ddc-title ddc-step-title">等待事件</div><div class="ddc-detail ddc-step-detail">—</div></div></div>
|
|
240
|
+
</div>
|
|
241
|
+
</section>
|
|
242
|
+
<section class="ddc-card">
|
|
243
|
+
<div class="ddc-card-h"><h2>判定链状态</h2></div>
|
|
244
|
+
<div class="ddc-body">
|
|
245
|
+
<div style="display:flex;justify-content:space-between;align-items:baseline">
|
|
246
|
+
<span style="font:400 12px/1 var(--ddc-font);color:var(--ddc-text-3)">拦截 / 判定</span>
|
|
247
|
+
<span style="font:600 13px/1 var(--ddc-mono)" class="ddc-bar-val">0 / 0</span>
|
|
248
|
+
</div>
|
|
249
|
+
<div class="ddc-barbg"><div class="ddc-bar" style="width:0%"></div></div>
|
|
250
|
+
<div class="ddc-kv ddc-session-kv"></div>
|
|
251
|
+
</div>
|
|
252
|
+
</section>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
</section>
|
|
256
|
+
|
|
257
|
+
<section class="ddc-view" data-ddc-viewcard="replay">
|
|
258
|
+
<section class="ddc-card" style="display:flex;flex-direction:column;gap:10px;padding:var(--ddc-card-pad)">
|
|
259
|
+
<div style="display:flex;justify-content:space-between;align-items:baseline;gap:12px">
|
|
260
|
+
<div style="font:600 14px/1.3 var(--ddc-font)"><span class="ddc-rp-title">选择录像</span></div>
|
|
261
|
+
<div style="font:400 12px/1 var(--ddc-mono);color:var(--ddc-text-3)" class="ddc-rp-clock">--:--:--</div>
|
|
262
|
+
</div>
|
|
263
|
+
<canvas class="ddc-tl"></canvas>
|
|
264
|
+
<div class="ddc-legend"></div>
|
|
265
|
+
<div class="ddc-ctl">
|
|
266
|
+
<button class="ddc-btn primary ddc-play">播放</button>
|
|
267
|
+
<span style="flex:1"></span>
|
|
268
|
+
<span style="font:400 12px/1 var(--ddc-font);color:var(--ddc-text-3)">速度</span>
|
|
269
|
+
${SPEEDS.map((s) => `<button class="ddc-spd${s === 1 ? ' on' : ''}" data-ddc-spd="${s}">${s}×</button>`).join('')}
|
|
270
|
+
</div>
|
|
271
|
+
</section>
|
|
272
|
+
<div class="ddc-rpsplit">
|
|
273
|
+
<section class="ddc-card" style="display:flex;flex-direction:column;min-height:0">
|
|
274
|
+
<div class="ddc-card-h"><h2>事件</h2><span class="ddc-meta ddc-rp-meta">—</span></div>
|
|
275
|
+
<div class="ddc-body" style="flex:1;min-height:0">
|
|
276
|
+
<div class="ddc-evlist"></div>
|
|
277
|
+
</div>
|
|
278
|
+
</section>
|
|
279
|
+
<section class="ddc-card" style="display:flex;flex-direction:column;min-height:0">
|
|
280
|
+
<div class="ddc-card-h"><h2>事件详情</h2></div>
|
|
281
|
+
<div class="ddc-body ddc-rp-detail" style="flex:1;min-height:0;overflow-y:auto"></div>
|
|
282
|
+
</section>
|
|
283
|
+
</div>
|
|
284
|
+
</section>
|
|
285
|
+
|
|
286
|
+
<section class="ddc-view" data-ddc-viewcard="history">
|
|
287
|
+
<div class="ddc-toolbar">
|
|
288
|
+
<input class="ddc-search" placeholder="搜索 ID / 日期…" />
|
|
289
|
+
<div class="ddc-chips">
|
|
290
|
+
<button class="ddc-chip on" data-ddc-hf="all">全部</button>
|
|
291
|
+
<button class="ddc-chip" data-ddc-hf="recording">录制中</button>
|
|
292
|
+
<button class="ddc-chip" data-ddc-hf="completed">已完成</button>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
<div class="ddc-card" style="flex:1;min-height:0;display:flex;flex-direction:column">
|
|
296
|
+
<div class="ddc-card-h"><h2>录像历史</h2><span class="ddc-meta ddc-hist-meta">—</span></div>
|
|
297
|
+
<div class="ddc-body" style="flex:1;min-height:0">
|
|
298
|
+
<div class="ddc-hlist"></div>
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
</section>
|
|
302
|
+
</div>`;
|
|
303
|
+
|
|
304
|
+
const $ = (sel) => root.querySelector(sel);
|
|
305
|
+
const els = {
|
|
306
|
+
rec: $('.ddc-rec'),
|
|
307
|
+
recTime: $('.ddc-rec .ddc-time'),
|
|
308
|
+
close: $('.ddc-close'),
|
|
309
|
+
tabs: [...root.querySelectorAll('.ddc-tab')],
|
|
310
|
+
mElapsed: $('.ddc-m-elapsed'),
|
|
311
|
+
mTools: $('.ddc-m-tools'),
|
|
312
|
+
mFiles: $('.ddc-m-files'),
|
|
313
|
+
mErrors: $('.ddc-m-errors'),
|
|
314
|
+
stream: $('.ddc-stream'),
|
|
315
|
+
streamMeta: $('.ddc-stream-meta'),
|
|
316
|
+
stepAge: $('.ddc-step-age'),
|
|
317
|
+
stepTag: $('.ddc-step-tag'),
|
|
318
|
+
stepTitle: $('.ddc-step-title'),
|
|
319
|
+
stepDetail: $('.ddc-step-detail'),
|
|
320
|
+
bar: $('.ddc-bar'),
|
|
321
|
+
barVal: $('.ddc-bar-val'),
|
|
322
|
+
sessionKv: $('.ddc-session-kv'),
|
|
323
|
+
rpTitle: $('.ddc-rp-title'),
|
|
324
|
+
rpClock: $('.ddc-rp-clock'),
|
|
325
|
+
tl: $('.ddc-tl'),
|
|
326
|
+
legend: $('.ddc-legend'),
|
|
327
|
+
play: $('.ddc-play'),
|
|
328
|
+
spds: [...root.querySelectorAll('.ddc-spd')],
|
|
329
|
+
evlist: $('.ddc-evlist'),
|
|
330
|
+
rpMeta: $('.ddc-rp-meta'),
|
|
331
|
+
rpDetail: $('.ddc-rp-detail'),
|
|
332
|
+
search: $('.ddc-search'),
|
|
333
|
+
chips: [...root.querySelectorAll('.ddc-chip')],
|
|
334
|
+
hlist: $('.ddc-hlist'),
|
|
335
|
+
histMeta: $('.ddc-hist-meta'),
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const state = {
|
|
339
|
+
view: 'live',
|
|
340
|
+
status: null,
|
|
341
|
+
live: [], // 升序的脱敏记录(实时流,尾部窗口)
|
|
342
|
+
renderedSeq: 0, // 已渲染到流里的最大 seq
|
|
343
|
+
elapsedSec: null,
|
|
344
|
+
hist: { sessions: [], filter: 'all', query: '' },
|
|
345
|
+
rp: { session: null, events: [], dur: 0, t: 0, playing: false, speed: 1, active: -1, startStamp: 0 },
|
|
346
|
+
};
|
|
347
|
+
let rafId = 0;
|
|
348
|
+
let tipEl = null;
|
|
349
|
+
const disposers = [];
|
|
350
|
+
|
|
351
|
+
// ---------- 通用 ----------
|
|
352
|
+
function setView(view) {
|
|
353
|
+
state.view = view;
|
|
354
|
+
els.tabs.forEach((tab) => tab.classList.toggle('on', tab.dataset.ddcView === view));
|
|
355
|
+
root.querySelectorAll('.ddc-view').forEach((card) => {
|
|
356
|
+
card.classList.toggle('on', card.dataset.ddcViewcard === view);
|
|
357
|
+
});
|
|
358
|
+
if (view === 'replay') drawTimeline();
|
|
359
|
+
if (view === 'history') renderHistory();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function evRow(e, timeText, clickable) {
|
|
363
|
+
const row = document.createElement('div');
|
|
364
|
+
const isError = e.type === 'error' || e.denied;
|
|
365
|
+
row.className = 'ddc-ev' + (isError ? ' error' : '');
|
|
366
|
+
if (clickable) {
|
|
367
|
+
row.dataset.ddcSeq = String(e.seq);
|
|
368
|
+
row.addEventListener('click', () => {
|
|
369
|
+
const idx = state.rp.events.findIndex((ev) => ev.seq === e.seq);
|
|
370
|
+
if (idx >= 0) seekTo(state.rp.events[idx].t);
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
const time = document.createElement('span');
|
|
374
|
+
time.className = 'ddc-time';
|
|
375
|
+
time.textContent = timeText;
|
|
376
|
+
const tag = document.createElement('span');
|
|
377
|
+
tag.className = `ddc-tag t-${e.type}`;
|
|
378
|
+
tag.textContent = TYPE_TEXT[e.type] || e.type;
|
|
379
|
+
const main = document.createElement('div');
|
|
380
|
+
main.style.minWidth = '0';
|
|
381
|
+
main.style.flex = '1';
|
|
382
|
+
const title = document.createElement('div');
|
|
383
|
+
title.className = 'ddc-title';
|
|
384
|
+
title.textContent = (e.denied && e.type !== 'error' ? '[拒绝] ' : '') + (e.title || '—');
|
|
385
|
+
const detail = document.createElement('div');
|
|
386
|
+
detail.className = 'ddc-detail';
|
|
387
|
+
detail.textContent = e.detail || '';
|
|
388
|
+
main.appendChild(title);
|
|
389
|
+
if (e.detail) main.appendChild(detail);
|
|
390
|
+
row.appendChild(time);
|
|
391
|
+
row.appendChild(tag);
|
|
392
|
+
row.appendChild(main);
|
|
393
|
+
return row;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function extraBlock(e) {
|
|
397
|
+
const lines = [];
|
|
398
|
+
if (e.ruleId) lines.push(`规则 ${e.ruleId}`);
|
|
399
|
+
lines.push(`决策 ${e.denied ? 'deny' : 'allow'}${e.intercepted ? '(已拦截)' : ''}`);
|
|
400
|
+
if (e.extra && Array.isArray(e.extra.paths) && e.extra.paths.length) {
|
|
401
|
+
lines.push(`路径 ${e.extra.paths.join('\n ')}`);
|
|
402
|
+
}
|
|
403
|
+
if (e.extra && Array.isArray(e.extra.commands) && e.extra.commands.length) {
|
|
404
|
+
lines.push(`命令 ${e.extra.commands.join('\n ')}`);
|
|
405
|
+
}
|
|
406
|
+
if (e.extra && Array.isArray(e.extra.hosts) && e.extra.hosts.length) {
|
|
407
|
+
lines.push(`主机 ${e.extra.hosts.join(', ')}`);
|
|
408
|
+
}
|
|
409
|
+
if (e.extra && typeof e.extra.commandLine === 'string' && e.extra.commandLine) {
|
|
410
|
+
lines.push(`命令行 ${e.extra.commandLine}`);
|
|
411
|
+
}
|
|
412
|
+
return lines.join('\n');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ---------- 实时 ----------
|
|
416
|
+
function updateLive(status, records) {
|
|
417
|
+
if (status) state.status = status;
|
|
418
|
+
const s = state.status;
|
|
419
|
+
if (s) {
|
|
420
|
+
const counts = s.counts || {};
|
|
421
|
+
// 纳管口径:judgments 是映射表内的五类 capability;映射表外(MCP/workflow/
|
|
422
|
+
// subagent 等)走 telemetry/unmapped-tool,单独在判定链状态里展示。
|
|
423
|
+
els.mTools.textContent = String(s.judgments || 0);
|
|
424
|
+
els.mFiles.textContent = String(s.filesTouched || 0);
|
|
425
|
+
els.mErrors.textContent = String(s.errors || 0);
|
|
426
|
+
// 红色只反映真拦截:advisory 的 deny 是旁路命中(deny 账但未拦截),
|
|
427
|
+
// 不进异常卡片,也不进这条比例——否则旁路模式下红色常亮失去警示。
|
|
428
|
+
els.barVal.textContent = `${counts.intercepted || 0} / ${s.judgments || 0}`;
|
|
429
|
+
const ratio = s.judgments ? clamp((counts.intercepted || 0) / s.judgments, 0, 1) : 0;
|
|
430
|
+
els.bar.style.width = `${(ratio * 100).toFixed(1)}%`;
|
|
431
|
+
const kv = [];
|
|
432
|
+
kv.push(['模式', s.mode === 'enforce' ? 'enforce(强制)' : s.mode === 'advisory' ? 'advisory(旁路)' : '—', s.mode === 'enforce']);
|
|
433
|
+
kv.push(['旁路命中', `${counts.bypass || 0} 次(deny 未拦截)`, false]);
|
|
434
|
+
kv.push(['未纳管调用', `${counts.unmapped || 0} 次(telemetry)`, false]);
|
|
435
|
+
kv.push(['插件版本', s.version || '—', false]);
|
|
436
|
+
if (s.session && s.session.id) kv.push(['会话 ID', s.session.id, false]);
|
|
437
|
+
if (s.session && s.session.model) kv.push(['模型', s.session.model, false]);
|
|
438
|
+
kv.push(['账本记录', `#${s.ledgerSeq || 0}${s.windowed ? '(窗口截断)' : ''}`, false]);
|
|
439
|
+
if (s.ledgerPath) kv.push(['账本路径', s.ledgerPath, false]);
|
|
440
|
+
els.sessionKv.innerHTML = kv
|
|
441
|
+
.map(([k, v, warn]) => `<div class="ddc-k">${esc(k)}</div><div class="ddc-val${warn ? ' warn' : ''}">${esc(v)}</div>`)
|
|
442
|
+
.join('');
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (Array.isArray(records) && records.length) {
|
|
446
|
+
state.live = state.live.concat(records).slice(-400);
|
|
447
|
+
}
|
|
448
|
+
const fresh = state.live.filter((e) => e.seq > state.renderedSeq);
|
|
449
|
+
if (fresh.length) {
|
|
450
|
+
const atTop = els.stream.querySelector('.ddc-ev');
|
|
451
|
+
for (const e of fresh) {
|
|
452
|
+
els.stream.insertBefore(evRow(e, clock(e.ts), false), atTop || null);
|
|
453
|
+
}
|
|
454
|
+
state.renderedSeq = fresh[fresh.length - 1].seq;
|
|
455
|
+
// DOM 与 state.live 的 400 条窗口对齐:流是新的在上,超窗口的旧行从尾部
|
|
456
|
+
// 移除——否则面板开一上午行数无限增长,「共 N 条」与屏幕也对不上。
|
|
457
|
+
const rows = els.stream.querySelectorAll('.ddc-ev');
|
|
458
|
+
for (let i = rows.length - 1; i >= 400; i -= 1) rows[i].remove();
|
|
459
|
+
}
|
|
460
|
+
els.streamMeta.textContent = `共 ${state.live.length} 条`;
|
|
461
|
+
if (!state.live.length && !els.stream.querySelector('.ddc-empty')) {
|
|
462
|
+
const empty = document.createElement('div');
|
|
463
|
+
empty.className = 'ddc-empty';
|
|
464
|
+
empty.textContent = '等待账本记录…';
|
|
465
|
+
els.stream.appendChild(empty);
|
|
466
|
+
} else if (state.live.length) {
|
|
467
|
+
const empty = els.stream.querySelector('.ddc-empty');
|
|
468
|
+
if (empty) empty.remove();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const last = state.live.length ? state.live[state.live.length - 1] : s && s.lastEvent ? s.lastEvent : null;
|
|
472
|
+
if (last) {
|
|
473
|
+
const isError = last.type === 'error' || last.denied;
|
|
474
|
+
els.stepTag.className = `ddc-tag t-${last.type}`;
|
|
475
|
+
els.stepTag.textContent = TYPE_TEXT[last.type] || last.type;
|
|
476
|
+
els.stepTitle.textContent = (last.denied && last.type !== 'error' ? '[拒绝] ' : '') + (last.title || '—');
|
|
477
|
+
els.stepTitle.parentElement.parentElement.classList.toggle('error', !!isError);
|
|
478
|
+
els.stepDetail.textContent = last.detail || '—';
|
|
479
|
+
if (last.ts) els.stepAge.dataset.ddcTs = last.ts;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function tickClock(connected) {
|
|
484
|
+
const s = state.status;
|
|
485
|
+
if (s && s.startedAt) {
|
|
486
|
+
const ms = Date.now() - Date.parse(s.startedAt);
|
|
487
|
+
state.elapsedSec = Number.isFinite(ms) ? Math.max(0, Math.floor(ms / 1000)) : null;
|
|
488
|
+
} else {
|
|
489
|
+
state.elapsedSec = null;
|
|
490
|
+
}
|
|
491
|
+
const text = state.elapsedSec == null ? '--:--:--' : hms(state.elapsedSec);
|
|
492
|
+
els.recTime.textContent = text;
|
|
493
|
+
els.rec.classList.toggle('ddc-off', !connected);
|
|
494
|
+
els.mElapsed.textContent = text;
|
|
495
|
+
if (els.stepAge.dataset.ddcTs) {
|
|
496
|
+
const age = Math.max(0, Math.floor((Date.now() - Date.parse(els.stepAge.dataset.ddcTs)) / 1000));
|
|
497
|
+
els.stepAge.textContent = age < 60 ? `${age} 秒前` : `${Math.floor(age / 60)} 分钟前`;
|
|
498
|
+
}
|
|
499
|
+
if (state.rp.playing) drawTimeline();
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ---------- 回放 ----------
|
|
503
|
+
function startReplay(session, records, truncated = false) {
|
|
504
|
+
pause();
|
|
505
|
+
const rp = state.rp;
|
|
506
|
+
rp.session = session || null;
|
|
507
|
+
const list = Array.isArray(records) ? records.filter((e) => e && e.ts) : [];
|
|
508
|
+
const startMs = list.length ? Date.parse(list[0].ts) : 0;
|
|
509
|
+
const endMs = list.length ? Date.parse(list[list.length - 1].ts) : 0;
|
|
510
|
+
rp.events = list.map((e) => ({ ...e, t: Math.max(0, (Date.parse(e.ts) - startMs) / 1000) }));
|
|
511
|
+
rp.dur = session && session.duration ? session.duration : Math.max(1, (endMs - startMs) / 1000);
|
|
512
|
+
rp.t = 0;
|
|
513
|
+
rp.active = -1;
|
|
514
|
+
rp.speed = 1;
|
|
515
|
+
els.spds.forEach((b) => b.classList.toggle('on', b.dataset.ddcSpd === '1'));
|
|
516
|
+
els.rpTitle.textContent = session
|
|
517
|
+
? `${session.id} · ${dateShort(session.started)}(${session.events} 事件 / ${session.errors} 异常)`
|
|
518
|
+
: '最近记录';
|
|
519
|
+
// 服务端 limit 截断时如实标注,避免「看起来完整其实少了」。
|
|
520
|
+
els.rpMeta.textContent = `${rp.events.length} 条${truncated ? ' · 已截断(只载入前 2000 条)' : ''}`;
|
|
521
|
+
els.evlist.innerHTML = '';
|
|
522
|
+
for (const e of rp.events) {
|
|
523
|
+
els.evlist.appendChild(evRow(e, shortHms(e.t), true));
|
|
524
|
+
}
|
|
525
|
+
if (!rp.events.length) {
|
|
526
|
+
const empty = document.createElement('div');
|
|
527
|
+
empty.className = 'ddc-empty';
|
|
528
|
+
empty.textContent = '该录像段没有可用记录';
|
|
529
|
+
els.evlist.appendChild(empty);
|
|
530
|
+
}
|
|
531
|
+
renderDetail(-1);
|
|
532
|
+
drawTimeline();
|
|
533
|
+
setView('replay');
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function renderDetail(idx) {
|
|
537
|
+
const rp = state.rp;
|
|
538
|
+
const e = idx >= 0 ? rp.events[idx] : null;
|
|
539
|
+
if (!e) {
|
|
540
|
+
els.rpDetail.innerHTML = '<div class="ddc-empty">点击时间轴或事件查看详情</div>';
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const block = extraBlock(e);
|
|
544
|
+
els.rpDetail.innerHTML = `
|
|
545
|
+
<div class="ddc-step"><span class="ddc-tag t-${e.type}">${TYPE_TEXT[e.type] || e.type}</span>
|
|
546
|
+
<div><div class="ddc-title">${esc((e.denied && e.type !== 'error' ? '[拒绝] ' : '') + (e.title || '—'))}</div>
|
|
547
|
+
<div class="ddc-detail">${esc(e.detail || '')}</div></div></div>
|
|
548
|
+
<div class="ddc-kv">
|
|
549
|
+
<div class="ddc-k">时间点</div><div class="ddc-val">${esc(clockFull(e.ts))}</div>
|
|
550
|
+
<div class="ddc-k">相对</div><div class="ddc-val">+${shortHms(e.t)}</div>
|
|
551
|
+
<div class="ddc-k">序号</div><div class="ddc-val">#${e.seq}</div>
|
|
552
|
+
</div>
|
|
553
|
+
${block ? `<pre class="ddc-diff">${esc(block)}</pre>` : ''}`;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function seekTo(t) {
|
|
557
|
+
const rp = state.rp;
|
|
558
|
+
rp.t = clamp(t, 0, rp.dur);
|
|
559
|
+
if (rp.playing) rp.startStamp = performance.now() - (rp.t / rp.speed) * 1000;
|
|
560
|
+
syncActive();
|
|
561
|
+
drawTimeline();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function syncActive() {
|
|
565
|
+
const rp = state.rp;
|
|
566
|
+
let idx = -1;
|
|
567
|
+
for (let i = 0; i < rp.events.length; i += 1) {
|
|
568
|
+
if (rp.events[i].t <= rp.t) idx = i;
|
|
569
|
+
else break;
|
|
570
|
+
}
|
|
571
|
+
if (idx !== rp.active) {
|
|
572
|
+
rp.active = idx;
|
|
573
|
+
const rows = els.evlist.querySelectorAll('.ddc-ev[data-ddc-seq]');
|
|
574
|
+
rows.forEach((row) => {
|
|
575
|
+
row.classList.toggle('on', idx >= 0 && row.dataset.ddcSeq === String(rp.events[idx].seq));
|
|
576
|
+
});
|
|
577
|
+
if (idx >= 0) {
|
|
578
|
+
const activeRow = els.evlist.querySelector(`.ddc-ev[data-ddc-seq="${rp.events[idx].seq}"]`);
|
|
579
|
+
if (activeRow) activeRow.scrollIntoView({ block: 'nearest' });
|
|
580
|
+
}
|
|
581
|
+
renderDetail(idx);
|
|
582
|
+
}
|
|
583
|
+
const e = idx >= 0 ? rp.events[idx] : null;
|
|
584
|
+
els.rpClock.textContent = `${hms(rp.t)} / ${hms(rp.dur)}${e ? ` · #${e.seq}` : ''}`;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function play() {
|
|
588
|
+
const rp = state.rp;
|
|
589
|
+
if (!rp.events.length) return;
|
|
590
|
+
if (rp.t >= rp.dur) rp.t = 0;
|
|
591
|
+
rp.playing = true;
|
|
592
|
+
rp.startStamp = performance.now() - (rp.t / rp.speed) * 1000;
|
|
593
|
+
els.play.textContent = '暂停';
|
|
594
|
+
loop();
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function pause() {
|
|
598
|
+
state.rp.playing = false;
|
|
599
|
+
els.play.textContent = '播放';
|
|
600
|
+
if (rafId) {
|
|
601
|
+
cancelAnimationFrame(rafId);
|
|
602
|
+
rafId = 0;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function loop() {
|
|
607
|
+
if (!state.rp.playing) return;
|
|
608
|
+
const rp = state.rp;
|
|
609
|
+
rp.t = Math.min(rp.dur, ((performance.now() - rp.startStamp) / 1000) * rp.speed);
|
|
610
|
+
syncActive();
|
|
611
|
+
drawTimeline();
|
|
612
|
+
if (rp.t >= rp.dur) {
|
|
613
|
+
pause();
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
rafId = requestAnimationFrame(loop);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function drawTimeline() {
|
|
620
|
+
const canvas = els.tl;
|
|
621
|
+
const rp = state.rp;
|
|
622
|
+
const rect = canvas.getBoundingClientRect();
|
|
623
|
+
if (rect.width < 10) return;
|
|
624
|
+
const dpr = window.devicePixelRatio || 1;
|
|
625
|
+
if (canvas.width !== Math.round(rect.width * dpr) || canvas.height !== Math.round(rect.height * dpr)) {
|
|
626
|
+
canvas.width = Math.round(rect.width * dpr);
|
|
627
|
+
canvas.height = Math.round(rect.height * dpr);
|
|
628
|
+
}
|
|
629
|
+
const ctx = canvas.getContext('2d');
|
|
630
|
+
const w = rect.width;
|
|
631
|
+
const h = rect.height;
|
|
632
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
633
|
+
ctx.clearRect(0, 0, w, h);
|
|
634
|
+
|
|
635
|
+
const style = getComputedStyle(root);
|
|
636
|
+
const axis = style.getPropertyValue('--ddc-text-3').trim() || 'rgba(242,242,245,.38)';
|
|
637
|
+
const line = 'rgba(255,255,255,.06)';
|
|
638
|
+
const accent = style.getPropertyValue('--ddc-accent').trim() || '#d9534f';
|
|
639
|
+
const padL = 8;
|
|
640
|
+
const padR = 8;
|
|
641
|
+
const axisY = h - 18;
|
|
642
|
+
const markTop = 8;
|
|
643
|
+
const markH = axisY - markTop - 6;
|
|
644
|
+
|
|
645
|
+
// 轴线
|
|
646
|
+
ctx.strokeStyle = line;
|
|
647
|
+
ctx.lineWidth = 1;
|
|
648
|
+
ctx.beginPath();
|
|
649
|
+
ctx.moveTo(padL, axisY + 0.5);
|
|
650
|
+
ctx.lineTo(w - padR, axisY + 0.5);
|
|
651
|
+
ctx.stroke();
|
|
652
|
+
|
|
653
|
+
// 时间刻度
|
|
654
|
+
const dur = Math.max(1, rp.dur);
|
|
655
|
+
const steps = [1, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200, 14400, 43200, 86400];
|
|
656
|
+
let step = steps[steps.length - 1];
|
|
657
|
+
for (const s of steps) {
|
|
658
|
+
if (dur / s <= 8) {
|
|
659
|
+
step = s;
|
|
660
|
+
break;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
ctx.fillStyle = axis;
|
|
664
|
+
ctx.font = '10px ' + (style.getPropertyValue('--ddc-mono').trim() || 'monospace');
|
|
665
|
+
ctx.textAlign = 'center';
|
|
666
|
+
ctx.textBaseline = 'top';
|
|
667
|
+
for (let t = 0; t <= dur + 0.001; t += step) {
|
|
668
|
+
const x = padL + (t / dur) * (w - padL - padR);
|
|
669
|
+
ctx.strokeStyle = line;
|
|
670
|
+
ctx.beginPath();
|
|
671
|
+
ctx.moveTo(x, markTop);
|
|
672
|
+
ctx.lineTo(x, axisY);
|
|
673
|
+
ctx.stroke();
|
|
674
|
+
ctx.fillText(shortHms(t), x, axisY + 5);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// 事件刻度
|
|
678
|
+
for (const e of rp.events) {
|
|
679
|
+
const mark = MARK[e.type] || MARK.tel;
|
|
680
|
+
const x = padL + (e.t / dur) * (w - padL - padR);
|
|
681
|
+
const height = markH * mark[0];
|
|
682
|
+
ctx.strokeStyle = e.denied && e.type !== 'error' ? accent : mark[1];
|
|
683
|
+
ctx.lineWidth = e.type === 'error' || e.denied ? 2 : 1.5;
|
|
684
|
+
ctx.beginPath();
|
|
685
|
+
ctx.moveTo(x, axisY);
|
|
686
|
+
ctx.lineTo(x, axisY - height);
|
|
687
|
+
ctx.stroke();
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// 播放头
|
|
691
|
+
const px = padL + (clamp(rp.t, 0, dur) / dur) * (w - padL - padR);
|
|
692
|
+
ctx.strokeStyle = accent;
|
|
693
|
+
ctx.lineWidth = 1.5;
|
|
694
|
+
ctx.beginPath();
|
|
695
|
+
ctx.moveTo(px, markTop - 4);
|
|
696
|
+
ctx.lineTo(px, axisY + 2);
|
|
697
|
+
ctx.stroke();
|
|
698
|
+
ctx.fillStyle = accent;
|
|
699
|
+
ctx.beginPath();
|
|
700
|
+
ctx.moveTo(px - 4, markTop - 8);
|
|
701
|
+
ctx.lineTo(px + 4, markTop - 8);
|
|
702
|
+
ctx.lineTo(px, markTop - 2);
|
|
703
|
+
ctx.closePath();
|
|
704
|
+
ctx.fill();
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function eventAtX(offsetX) {
|
|
708
|
+
const rect = els.tl.getBoundingClientRect();
|
|
709
|
+
const rp = state.rp;
|
|
710
|
+
const dur = Math.max(1, rp.dur);
|
|
711
|
+
const padL = 8;
|
|
712
|
+
const padR = 8;
|
|
713
|
+
const t = clamp(((offsetX - padL) / (rect.width - padL - padR)) * dur, 0, dur);
|
|
714
|
+
let best = -1;
|
|
715
|
+
let bestDist = Infinity;
|
|
716
|
+
for (let i = 0; i < rp.events.length; i += 1) {
|
|
717
|
+
const dist = Math.abs(rp.events[i].t - t);
|
|
718
|
+
if (dist < bestDist) {
|
|
719
|
+
bestDist = dist;
|
|
720
|
+
best = i;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
const tolerance = (dur / Math.max(1, rect.width - padL - padR)) * 8;
|
|
724
|
+
return { t, idx: bestDist <= tolerance ? best : -1 };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function ensureTip() {
|
|
728
|
+
if (!tipEl || !tipEl.isConnected) {
|
|
729
|
+
tipEl = document.createElement('div');
|
|
730
|
+
tipEl.className = 'ddc-tip';
|
|
731
|
+
document.body.appendChild(tipEl);
|
|
732
|
+
}
|
|
733
|
+
return tipEl;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ---------- 历史 ----------
|
|
737
|
+
function showHistory(sessions) {
|
|
738
|
+
state.hist.sessions = Array.isArray(sessions) ? sessions : [];
|
|
739
|
+
renderHistory();
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function renderHistory() {
|
|
743
|
+
const { sessions, filter, query } = state.hist;
|
|
744
|
+
const q = query.trim().toLowerCase();
|
|
745
|
+
const rows = sessions.filter((s) => {
|
|
746
|
+
if (filter !== 'all' && s.status !== filter) return false;
|
|
747
|
+
if (!q) return true;
|
|
748
|
+
return (
|
|
749
|
+
s.id.toLowerCase().includes(q) ||
|
|
750
|
+
(s.started || '').toLowerCase().includes(q) ||
|
|
751
|
+
dateShort(s.started).toLowerCase().includes(q)
|
|
752
|
+
);
|
|
753
|
+
});
|
|
754
|
+
els.hlist.innerHTML = '';
|
|
755
|
+
if (!rows.length) {
|
|
756
|
+
const empty = document.createElement('div');
|
|
757
|
+
empty.className = 'ddc-empty';
|
|
758
|
+
empty.textContent = sessions.length ? '没有匹配的录像' : '账本里还没有录像段';
|
|
759
|
+
els.hlist.appendChild(empty);
|
|
760
|
+
}
|
|
761
|
+
for (const s of rows) {
|
|
762
|
+
const row = document.createElement('div');
|
|
763
|
+
row.className = 'ddc-hrow';
|
|
764
|
+
const stText = s.status === 'recording' ? '<span class="ddc-st rec">录制中</span>' : '<span class="ddc-st done">已完成</span>';
|
|
765
|
+
row.innerHTML = `
|
|
766
|
+
<span class="ddc-hid">${esc(s.id)}</span>
|
|
767
|
+
<span class="ddc-hstart">${esc(clockFull(s.started))}<span style="color:var(--ddc-text-3)"> → ${esc(clock(s.ended))}</span></span>
|
|
768
|
+
<span class="ddc-num">${esc(hms(s.duration))}</span>
|
|
769
|
+
<span class="ddc-num">${s.events}</span>
|
|
770
|
+
<span class="ddc-num n-err">${s.errors}</span>
|
|
771
|
+
<span class="ddc-hacts">${stText}</span>`;
|
|
772
|
+
row.addEventListener('click', () => {
|
|
773
|
+
if (typeof api.loadSession === 'function') api.loadSession(s);
|
|
774
|
+
});
|
|
775
|
+
els.hlist.appendChild(row);
|
|
776
|
+
}
|
|
777
|
+
els.histMeta.textContent = `${rows.length} / ${sessions.length} 段`;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ---------- 事件接线 ----------
|
|
781
|
+
els.close.addEventListener('click', () => {
|
|
782
|
+
if (typeof options.onClose === 'function') options.onClose();
|
|
783
|
+
});
|
|
784
|
+
els.tabs.forEach((tab) => tab.addEventListener('click', () => setView(tab.dataset.ddcView)));
|
|
785
|
+
els.play.addEventListener('click', () => (state.rp.playing ? pause() : play()));
|
|
786
|
+
els.spds.forEach((btn) =>
|
|
787
|
+
btn.addEventListener('click', () => {
|
|
788
|
+
state.rp.speed = Number(btn.dataset.ddcSpd) || 1;
|
|
789
|
+
els.spds.forEach((b) => b.classList.toggle('on', b === btn));
|
|
790
|
+
if (state.rp.playing) state.rp.startStamp = performance.now() - (state.rp.t / state.rp.speed) * 1000;
|
|
791
|
+
}),
|
|
792
|
+
);
|
|
793
|
+
els.tl.addEventListener('click', (event) => {
|
|
794
|
+
const { t } = eventAtX(event.offsetX);
|
|
795
|
+
seekTo(t);
|
|
796
|
+
});
|
|
797
|
+
els.tl.addEventListener('mousemove', (event) => {
|
|
798
|
+
const { t, idx } = eventAtX(event.offsetX);
|
|
799
|
+
if (idx < 0) {
|
|
800
|
+
if (tipEl) tipEl.style.display = 'none';
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
const e = state.rp.events[idx];
|
|
804
|
+
const tip = ensureTip();
|
|
805
|
+
tip.innerHTML = `<div class="ddc-tt${e.type === 'error' || e.denied ? ' error' : ''}">${esc(TYPE_TEXT[e.type] || e.type)} · #${e.seq} · +${shortHms(e.t)}</div>
|
|
806
|
+
<div class="ddc-tt">${esc(e.title || '')}</div>
|
|
807
|
+
<div class="ddc-td">${esc(e.detail || '')}</div>`;
|
|
808
|
+
tip.style.display = 'block';
|
|
809
|
+
const pad = 14;
|
|
810
|
+
let x = event.clientX + pad;
|
|
811
|
+
let y = event.clientY + pad;
|
|
812
|
+
const box = tip.getBoundingClientRect();
|
|
813
|
+
if (x + box.width > window.innerWidth - 8) x = event.clientX - box.width - pad;
|
|
814
|
+
if (y + box.height > window.innerHeight - 8) y = event.clientY - box.height - pad;
|
|
815
|
+
tip.style.left = `${x}px`;
|
|
816
|
+
tip.style.top = `${y}px`;
|
|
817
|
+
});
|
|
818
|
+
els.tl.addEventListener('mouseleave', () => {
|
|
819
|
+
if (tipEl) tipEl.style.display = 'none';
|
|
820
|
+
});
|
|
821
|
+
els.search.addEventListener('input', () => {
|
|
822
|
+
state.hist.query = els.search.value || '';
|
|
823
|
+
renderHistory();
|
|
824
|
+
});
|
|
825
|
+
els.chips.forEach((chip) =>
|
|
826
|
+
chip.addEventListener('click', () => {
|
|
827
|
+
state.hist.filter = chip.dataset.ddcHf || 'all';
|
|
828
|
+
els.chips.forEach((c) => c.classList.toggle('on', c === chip));
|
|
829
|
+
renderHistory();
|
|
830
|
+
}),
|
|
831
|
+
);
|
|
832
|
+
els.legend.innerHTML = TYPE_ORDER.map((type) => `<span><i style="background:${MARK[type][1]}"></i>${TYPE_TEXT[type]}</span>`).join('');
|
|
833
|
+
|
|
834
|
+
function onKeydown(event) {
|
|
835
|
+
if (root.style.display === 'none') return;
|
|
836
|
+
if (event.key === 'Escape') {
|
|
837
|
+
if (typeof options.onClose === 'function') options.onClose();
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
if (state.view !== 'replay' || !state.rp.events.length) return;
|
|
841
|
+
const target = event.target;
|
|
842
|
+
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
|
|
843
|
+
if (event.code === 'Space') {
|
|
844
|
+
event.preventDefault();
|
|
845
|
+
if (state.rp.playing) pause();
|
|
846
|
+
else play();
|
|
847
|
+
} else if (event.key === 'ArrowLeft') {
|
|
848
|
+
event.preventDefault();
|
|
849
|
+
const rp = state.rp;
|
|
850
|
+
const idx = clamp(rp.active, 0, rp.events.length - 1);
|
|
851
|
+
const prev = rp.events[clamp(idx - 1, 0, rp.events.length - 1)];
|
|
852
|
+
seekTo(prev ? prev.t : 0);
|
|
853
|
+
} else if (event.key === 'ArrowRight') {
|
|
854
|
+
event.preventDefault();
|
|
855
|
+
const rp = state.rp;
|
|
856
|
+
const idx = clamp(rp.active + 1, 0, rp.events.length - 1);
|
|
857
|
+
const next = rp.events[idx];
|
|
858
|
+
seekTo(next ? next.t : rp.dur);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
document.addEventListener('keydown', onKeydown, true);
|
|
862
|
+
disposers.push(() => document.removeEventListener('keydown', onKeydown, true));
|
|
863
|
+
|
|
864
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
865
|
+
if (state.view === 'replay') drawTimeline();
|
|
866
|
+
});
|
|
867
|
+
resizeObserver.observe(els.tl);
|
|
868
|
+
disposers.push(() => resizeObserver.disconnect());
|
|
869
|
+
|
|
870
|
+
function dispose() {
|
|
871
|
+
pause();
|
|
872
|
+
for (const dispose of disposers) dispose();
|
|
873
|
+
if (tipEl && tipEl.isConnected) tipEl.remove();
|
|
874
|
+
root.remove();
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
return {
|
|
878
|
+
el: root,
|
|
879
|
+
setView,
|
|
880
|
+
updateLive,
|
|
881
|
+
tickClock,
|
|
882
|
+
showHistory,
|
|
883
|
+
startReplay,
|
|
884
|
+
dispose,
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// dsh-dashcam 面板引擎:徽章 + 弹窗面板的生命周期、数据轮询、单例防护。
|
|
889
|
+
// 单例姿势抄 tan-pet(generation 防级联、knownNodeClasses 清扫残留);
|
|
890
|
+
// 只用 right/bottom 定位、直挂 document.body(dsh-app:// 下 iframe 会 404)。
|
|
891
|
+
// 轮询口径:status 常轮询(徽章计时器本地走秒);面板打开且处于实时视图时
|
|
892
|
+
// 增量拉 records(since=lastSeq);历史/回放按需拉。
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
const BADGE_KEY = 'dsh.dashcam.badgeHidden';
|
|
896
|
+
const BADGE_POS_KEY = 'dsh.dashcam.badgePos';
|
|
897
|
+
const DRAG_THRESHOLD_PX = 4;
|
|
898
|
+
const DRAG_EDGE_PAD_PX = 8;
|
|
899
|
+
|
|
900
|
+
function clamp(v, lo, hi) {
|
|
901
|
+
return Math.min(hi, Math.max(lo, v));
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function fetchJSON(url) {
|
|
905
|
+
return fetch(url, { credentials: 'same-origin' }).then((res) => {
|
|
906
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
907
|
+
return res.json();
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function mountStyles() {
|
|
912
|
+
const style = document.createElement('style');
|
|
913
|
+
style.id = 'dsh-dashcam-panel-style';
|
|
914
|
+
style.textContent = DASHCAM_CSS;
|
|
915
|
+
document.head.appendChild(style);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const knownNodeClasses = ['ddc-badge', 'ddc-root', 'ddc-tip'];
|
|
919
|
+
|
|
920
|
+
function mountDashcam() {
|
|
921
|
+
const generation = Symbol('dsh-dashcam-panel');
|
|
922
|
+
window.__DSH_DASHCAM_PANEL__ = generation;
|
|
923
|
+
|
|
924
|
+
// 单例防护:清掉上一个实例可能残留的节点与样式(React StrictMode 双挂载、
|
|
925
|
+
// 热重载都会触发级联 mount)。generation 符号让旧实例的异步回调自杀。
|
|
926
|
+
for (const className of knownNodeClasses) {
|
|
927
|
+
for (const node of document.querySelectorAll(`.${className}`)) node.remove();
|
|
928
|
+
}
|
|
929
|
+
const staleStyle = document.getElementById('dsh-dashcam-panel-style');
|
|
930
|
+
if (staleStyle) staleStyle.remove();
|
|
931
|
+
mountStyles();
|
|
932
|
+
|
|
933
|
+
// ---------- 徽章 ----------
|
|
934
|
+
const badge = document.createElement('div');
|
|
935
|
+
badge.className = 'ddc-badge';
|
|
936
|
+
badge.title = 'Agent Dashcam(Ctrl+Shift+D,拖动可换位置)';
|
|
937
|
+
badge.setAttribute('role', 'button');
|
|
938
|
+
badge.setAttribute('tabindex', '0');
|
|
939
|
+
badge.innerHTML = '<span class="ddc-dot"></span><span class="ddc-lb">REC</span><span class="ddc-time num">--:--:--</span>';
|
|
940
|
+
document.body.appendChild(badge);
|
|
941
|
+
const badgeTime = badge.querySelector('.ddc-time');
|
|
942
|
+
try {
|
|
943
|
+
if (localStorage.getItem(BADGE_KEY) === '1') badge.style.display = 'none';
|
|
944
|
+
// 恢复上次拖拽的位置(right/bottom 坐标,与面板同款姿势)
|
|
945
|
+
const savedPos = JSON.parse(localStorage.getItem(BADGE_POS_KEY) || 'null');
|
|
946
|
+
if (savedPos && Number.isFinite(savedPos.right) && Number.isFinite(savedPos.bottom)) {
|
|
947
|
+
badge.style.right = `${savedPos.right}px`;
|
|
948
|
+
badge.style.bottom = `${savedPos.bottom}px`;
|
|
949
|
+
}
|
|
950
|
+
} catch { /* 隐身模式等场景 localStorage 不可用 */ }
|
|
951
|
+
|
|
952
|
+
// 拖拽 + 点击区分:pointer 按下记录 right/bottom 起点,位移超过阈值算拖拽
|
|
953
|
+
//(更新 right/bottom 并落盘),否则 pointerup 触发 toggle。只用 right/bottom
|
|
954
|
+
// 定位是 tan-pet 的教训(top/left 在宿主布局里会被裁剪/覆盖)。
|
|
955
|
+
let drag = null;
|
|
956
|
+
badge.addEventListener('pointerdown', (event) => {
|
|
957
|
+
if (event.button !== 0) return;
|
|
958
|
+
const rect = badge.getBoundingClientRect();
|
|
959
|
+
drag = {
|
|
960
|
+
pointerId: event.pointerId,
|
|
961
|
+
startX: event.clientX,
|
|
962
|
+
startY: event.clientY,
|
|
963
|
+
startRight: window.innerWidth - rect.right,
|
|
964
|
+
startBottom: window.innerHeight - rect.bottom,
|
|
965
|
+
moved: false,
|
|
966
|
+
};
|
|
967
|
+
try { badge.setPointerCapture(event.pointerId); } catch { /* 老宿主可能不支持 */ }
|
|
968
|
+
});
|
|
969
|
+
badge.addEventListener('pointermove', (event) => {
|
|
970
|
+
if (!drag || event.pointerId !== drag.pointerId) return;
|
|
971
|
+
const dx = event.clientX - drag.startX;
|
|
972
|
+
const dy = event.clientY - drag.startY;
|
|
973
|
+
if (!drag.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
|
|
974
|
+
drag.moved = true;
|
|
975
|
+
const right = clamp(drag.startRight - dx, DRAG_EDGE_PAD_PX, window.innerWidth - badge.offsetWidth - DRAG_EDGE_PAD_PX);
|
|
976
|
+
const bottom = clamp(drag.startBottom - dy, DRAG_EDGE_PAD_PX, window.innerHeight - badge.offsetHeight - DRAG_EDGE_PAD_PX);
|
|
977
|
+
badge.style.right = `${right}px`;
|
|
978
|
+
badge.style.bottom = `${bottom}px`;
|
|
979
|
+
});
|
|
980
|
+
const endDrag = (event) => {
|
|
981
|
+
if (!drag || event.pointerId !== drag.pointerId) return;
|
|
982
|
+
const wasDrag = drag.moved;
|
|
983
|
+
drag = null;
|
|
984
|
+
if (wasDrag) {
|
|
985
|
+
try {
|
|
986
|
+
localStorage.setItem(BADGE_POS_KEY, JSON.stringify({
|
|
987
|
+
right: parseFloat(badge.style.right) || 24,
|
|
988
|
+
bottom: parseFloat(badge.style.bottom) || 24,
|
|
989
|
+
}));
|
|
990
|
+
} catch { /* 同上 */ }
|
|
991
|
+
} else {
|
|
992
|
+
toggle();
|
|
993
|
+
}
|
|
994
|
+
};
|
|
995
|
+
badge.addEventListener('pointerup', endDrag);
|
|
996
|
+
badge.addEventListener('pointercancel', () => { drag = null; });
|
|
997
|
+
badge.addEventListener('keydown', (event) => {
|
|
998
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
999
|
+
event.preventDefault();
|
|
1000
|
+
toggle();
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
// ---------- 面板 ----------
|
|
1005
|
+
let open = false;
|
|
1006
|
+
let lastSeq = 0;
|
|
1007
|
+
let connected = true;
|
|
1008
|
+
let failures = 0;
|
|
1009
|
+
let histCache = { at: 0, sessions: [] };
|
|
1010
|
+
|
|
1011
|
+
const panel = createDashcamPanel({
|
|
1012
|
+
api: {
|
|
1013
|
+
loadSession: async (session) => {
|
|
1014
|
+
try {
|
|
1015
|
+
const data = await fetchJSON(
|
|
1016
|
+
`/api/dsh-dashcam/records?since=${Math.max(0, session.from - 1)}&to=${session.to}&limit=2000`,
|
|
1017
|
+
);
|
|
1018
|
+
if (window.__DSH_DASHCAM_PANEL__ !== generation) return;
|
|
1019
|
+
panel.startReplay(session, data.records || [], data.truncated === true);
|
|
1020
|
+
} catch (error) {
|
|
1021
|
+
panel.startReplay(session, [], false);
|
|
1022
|
+
}
|
|
1023
|
+
},
|
|
1024
|
+
},
|
|
1025
|
+
onClose: () => close(),
|
|
1026
|
+
});
|
|
1027
|
+
document.body.appendChild(panel.el);
|
|
1028
|
+
|
|
1029
|
+
function openPanel() {
|
|
1030
|
+
open = true;
|
|
1031
|
+
panel.el.style.display = '';
|
|
1032
|
+
refreshStatus(true);
|
|
1033
|
+
refreshRecords(true);
|
|
1034
|
+
if (currentView() === 'history') refreshHistory(true);
|
|
1035
|
+
}
|
|
1036
|
+
function close() {
|
|
1037
|
+
open = false;
|
|
1038
|
+
panel.el.style.display = 'none';
|
|
1039
|
+
}
|
|
1040
|
+
function toggle() {
|
|
1041
|
+
if (open) close();
|
|
1042
|
+
else openPanel();
|
|
1043
|
+
}
|
|
1044
|
+
function currentView() {
|
|
1045
|
+
const active = panel.el.querySelector('.ddc-tab.on');
|
|
1046
|
+
return active ? active.dataset.ddcView : 'live';
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
async function refreshStatus(force) {
|
|
1050
|
+
try {
|
|
1051
|
+
const data = await fetchJSON('/api/dsh-dashcam/status');
|
|
1052
|
+
if (window.__DSH_DASHCAM_PANEL__ !== generation) return;
|
|
1053
|
+
if (!data || data.ok !== true) throw new Error('bad payload');
|
|
1054
|
+
failures = 0;
|
|
1055
|
+
if (!connected) {
|
|
1056
|
+
connected = true;
|
|
1057
|
+
panel.tickClock(true);
|
|
1058
|
+
}
|
|
1059
|
+
if (open || force) panel.updateLive(data, null);
|
|
1060
|
+
} catch {
|
|
1061
|
+
if (window.__DSH_DASHCAM_PANEL__ !== generation) return;
|
|
1062
|
+
failures += 1;
|
|
1063
|
+
if (failures >= 2 && connected) {
|
|
1064
|
+
connected = false;
|
|
1065
|
+
badge.classList.add('ddc-off');
|
|
1066
|
+
panel.tickClock(false);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
async function refreshRecords(force) {
|
|
1072
|
+
if ((!open || currentView() !== 'live') && !force) return;
|
|
1073
|
+
try {
|
|
1074
|
+
const data = await fetchJSON(`/api/dsh-dashcam/records?since=${lastSeq}&limit=200`);
|
|
1075
|
+
if (window.__DSH_DASHCAM_PANEL__ !== generation) return;
|
|
1076
|
+
if (!data || data.ok !== true) throw new Error('bad payload');
|
|
1077
|
+
if (Number.isFinite(data.lastSeq) && data.lastSeq > lastSeq) lastSeq = data.lastSeq;
|
|
1078
|
+
if (open) panel.updateLive(null, data.records || []);
|
|
1079
|
+
} catch {
|
|
1080
|
+
// records 拉取失败静默:status 轮询负责连接态展示
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
async function refreshHistory() {
|
|
1085
|
+
try {
|
|
1086
|
+
const [data] = await Promise.all([fetchJSON('/api/dsh-dashcam/history')]);
|
|
1087
|
+
if (window.__DSH_DASHCAM_PANEL__ !== generation) return;
|
|
1088
|
+
if (!data || data.ok !== true) throw new Error('bad payload');
|
|
1089
|
+
histCache = { at: Date.now(), sessions: data.sessions || [] };
|
|
1090
|
+
panel.showHistory(data.sessions || []);
|
|
1091
|
+
} catch {
|
|
1092
|
+
panel.showHistory([]);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// 轮询:status 3s;records 2s;本地秒表 1s。
|
|
1097
|
+
const statusTimer = window.setInterval(() => {
|
|
1098
|
+
if (window.__DSH_DASHCAM_PANEL__ !== generation) return;
|
|
1099
|
+
refreshStatus();
|
|
1100
|
+
}, 3000);
|
|
1101
|
+
const recordsTimer = window.setInterval(() => {
|
|
1102
|
+
if (window.__DSH_DASHCAM_PANEL__ !== generation) return;
|
|
1103
|
+
refreshRecords();
|
|
1104
|
+
}, 2000);
|
|
1105
|
+
const clockTimer = window.setInterval(() => {
|
|
1106
|
+
if (window.__DSH_DASHCAM_PANEL__ !== generation) return;
|
|
1107
|
+
panel.tickClock(connected);
|
|
1108
|
+
if (badgeTime) badgeTime.textContent = panel.el.querySelector('.ddc-rec .ddc-time').textContent;
|
|
1109
|
+
badge.classList.toggle('ddc-off', !connected);
|
|
1110
|
+
}, 1000);
|
|
1111
|
+
|
|
1112
|
+
// 面板内切到历史视图时按需刷新(15s 客户端缓存)
|
|
1113
|
+
const tabObserver = new MutationObserver(() => {
|
|
1114
|
+
if (open && currentView() === 'history' && Date.now() - histCache.at > 15000) {
|
|
1115
|
+
refreshHistory();
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
1118
|
+
tabObserver.observe(panel.el.querySelector('.ddc-tabs'), { attributes: true, subtree: true, attributeFilter: ['class'] });
|
|
1119
|
+
|
|
1120
|
+
refreshStatus(true);
|
|
1121
|
+
|
|
1122
|
+
function dispose() {
|
|
1123
|
+
if (window.__DSH_DASHCAM_PANEL__ === generation) {
|
|
1124
|
+
window.__DSH_DASHCAM_PANEL__ = null;
|
|
1125
|
+
if (window.__DSH_DASHCAM_ENGINE__ === handle) window.__DSH_DASHCAM_ENGINE__ = null;
|
|
1126
|
+
}
|
|
1127
|
+
window.clearInterval(statusTimer);
|
|
1128
|
+
window.clearInterval(recordsTimer);
|
|
1129
|
+
window.clearInterval(clockTimer);
|
|
1130
|
+
tabObserver.disconnect();
|
|
1131
|
+
panel.dispose();
|
|
1132
|
+
badge.remove();
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const handle = { toggle, open: openPanel, close, dispose, refreshHistory };
|
|
1136
|
+
window.__DSH_DASHCAM_ENGINE__ = handle;
|
|
1137
|
+
return handle;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// client.body.js — React 半侧:把面板挂进 DSH Web 的 shell.overlay 插槽,
|
|
1141
|
+
// 注册 Ctrl+Shift+D 快捷键与设置入口。姿势与 dsh-deepseek-tan-pet 相同。
|
|
1142
|
+
// 面板本体(dashcam-engine/dashcam-panel)是纯 DOM,不依赖 React。
|
|
1143
|
+
|
|
1144
|
+
const React = require('react');
|
|
1145
|
+
|
|
1146
|
+
function DashcamMount() {
|
|
1147
|
+
React.useEffect(() => {
|
|
1148
|
+
const engine = mountDashcam();
|
|
1149
|
+
return () => engine.dispose();
|
|
1150
|
+
}, []);
|
|
1151
|
+
return React.createElement('div', { style: { display: 'none' } });
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
exports.name = 'dsh-dashcam-host';
|
|
1155
|
+
exports.inject = ['slots', 'shortcuts'];
|
|
1156
|
+
exports.apply = (ctx) => {
|
|
1157
|
+
ctx.effect(() => {
|
|
1158
|
+
const disposers = [];
|
|
1159
|
+
|
|
1160
|
+
try {
|
|
1161
|
+
const slots = ctx.get('slots');
|
|
1162
|
+
disposers.push(
|
|
1163
|
+
slots.inject('shell.overlay', () =>
|
|
1164
|
+
slots.register({ name: 'shell.overlay', id: 'dsh-dashcam-panel', order: 13 }, DashcamMount),
|
|
1165
|
+
),
|
|
1166
|
+
);
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
console.warn('[dsh-dashcam] shell.overlay 插槽注册失败:', error);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
try {
|
|
1172
|
+
const shortcuts = ctx.get('shortcuts');
|
|
1173
|
+
disposers.push(
|
|
1174
|
+
shortcuts.register({
|
|
1175
|
+
id: 'dsh-dashcam:toggle-panel',
|
|
1176
|
+
regions: ['page'],
|
|
1177
|
+
combo: { primary: true, shift: true, code: 'KeyD' },
|
|
1178
|
+
title: 'Agent Dashcam 面板开关',
|
|
1179
|
+
run: () => {
|
|
1180
|
+
try {
|
|
1181
|
+
const engine = window.__DSH_DASHCAM_ENGINE__;
|
|
1182
|
+
if (engine && typeof engine.toggle === 'function') engine.toggle();
|
|
1183
|
+
} catch (error) {
|
|
1184
|
+
console.warn('[dsh-dashcam] 切换面板失败:', error);
|
|
1185
|
+
}
|
|
1186
|
+
return { handled: true };
|
|
1187
|
+
},
|
|
1188
|
+
}),
|
|
1189
|
+
);
|
|
1190
|
+
} catch (error) {
|
|
1191
|
+
console.warn('[dsh-dashcam] 快捷键注册失败:', error);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
return () => {
|
|
1195
|
+
for (const dispose of disposers) {
|
|
1196
|
+
try {
|
|
1197
|
+
if (typeof dispose === 'function') dispose();
|
|
1198
|
+
} catch { /* 卸载失败不致命 */ }
|
|
1199
|
+
}
|
|
1200
|
+
};
|
|
1201
|
+
}, 'dsh-dashcam: client unmount');
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
return module.exports;
|
|
1205
|
+
}
|
|
1206
|
+
});
|