dscs-server 0.1.3 → 0.1.4

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/web/admin.html CHANGED
@@ -30,6 +30,15 @@
30
30
  }
31
31
  * { box-sizing: border-box; }
32
32
  html, body { min-height: 100%; }
33
+ html { scrollbar-width: thin; scrollbar-color: #3a4350 transparent; scrollbar-gutter: stable; }
34
+ @supports not (scrollbar-gutter: stable) { html { overflow-y: scroll; } }
35
+ *::-webkit-scrollbar { width: 8px; height: 8px; }
36
+ *::-webkit-scrollbar-track { background: transparent; }
37
+ *::-webkit-scrollbar-thumb { background: #3a4350; border: 2px solid transparent; border-radius: 999px; background-clip: content-box; }
38
+ *::-webkit-scrollbar-thumb:hover { background: #4a5566; border: 2px solid transparent; background-clip: content-box; }
39
+ *::-webkit-scrollbar-corner { background: transparent; }
40
+ *::-webkit-scrollbar-button { display: none; width: 0; height: 0; }
41
+ .table-wrap { scrollbar-gutter: stable; overflow-x: auto; overflow-y: hidden; }
33
42
  body {
34
43
  margin: 0;
35
44
  background:
@@ -64,7 +73,12 @@
64
73
  .btn.danger { background: transparent; border-color: rgba(239,119,119,.38); color: #ffabab; }
65
74
  .btn.danger:hover { background: var(--bad-soft); border-color: rgba(239,119,119,.58); }
66
75
  .btn.small { min-height: 27px; padding: 3px 9px; font-size: 11.5px; }
76
+ .btn.tiny { min-height: 22px; padding: 1px 7px; font-size: 10.5px; border-radius: 6px; }
77
+ .row-actions { display: flex; gap: 4px; }
67
78
  .btn.wide { width: 100%; }
79
+ .btn svg { flex: 0 0 auto; }
80
+ .spinner { animation: spin .9s linear infinite; }
81
+ @keyframes spin { to { transform: rotate(360deg); } }
68
82
 
69
83
  .input, input, select {
70
84
  width: 100%; min-height: 36px;
@@ -82,36 +96,12 @@
82
96
  input::placeholder { color: var(--faint); }
83
97
 
84
98
  /* login */
85
- .login-page {
86
- min-height: 100vh;
87
- display: grid;
88
- place-items: center;
89
- padding: 28px 20px;
90
- }
91
- .login-inner {
92
- width: min(420px, 100%);
93
- display: flex; flex-direction: column; align-items: center;
94
- animation: card-in .38s cubic-bezier(.16,1,.3,1) both;
95
- }
96
- .brand-mark {
97
- width: 44px; height: 44px; border-radius: 13px;
98
- display: grid; place-items: center;
99
- background: linear-gradient(145deg, #24314a, #17202f);
100
- border: 1px solid #33415a;
101
- box-shadow: 0 12px 30px rgba(0,0,0,.28);
102
- }
99
+ .login-page { position: relative; z-index: 1; min-height: 100vh; display: grid; place-items: center; padding: 28px 20px; }
100
+ .login-inner { width: min(420px, 100%); display: flex; flex-direction: column; align-items: center; animation: card-in .38s cubic-bezier(.16,1,.3,1) both; }
101
+ .brand-mark { width: 44px; height: 44px; border-radius: 13px; display: grid; place-items: center; background: linear-gradient(145deg, #24314a, #17202f); border: 1px solid #33415a; box-shadow: 0 12px 30px rgba(0,0,0,.28); }
103
102
  .brand-mark img { width: 26px; height: 26px; }
104
- .auth-card {
105
- width: 100%; margin-top: 18px; padding: 28px;
106
- border: 1px solid var(--line); border-radius: 18px;
107
- background: linear-gradient(180deg, rgba(32,37,46,.92), rgba(26,30,37,.96));
108
- box-shadow: var(--shadow);
109
- }
110
- .eyebrow {
111
- display: inline-block;
112
- color: var(--accent-2); font-size: 10.5px; font-weight: 700;
113
- letter-spacing: .14em; text-transform: uppercase;
114
- }
103
+ .auth-card { width: 100%; margin-top: 18px; padding: 28px; border: 1px solid var(--line); border-radius: 18px; background: linear-gradient(180deg, rgba(32,37,46,.92), rgba(26,30,37,.96)); box-shadow: var(--shadow); }
104
+ .eyebrow { display: inline-block; color: var(--accent-2); font-size: 10.5px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; }
115
105
  .auth-card h1 { margin: 8px 0 4px; font-size: 24px; line-height: 1.2; letter-spacing: -.01em; }
116
106
  .auth-card .muted { margin: 0; }
117
107
  .auth-card .field { margin-top: 22px; }
@@ -120,96 +110,115 @@
120
110
  .field > span, .field-label { color: var(--muted); font-size: 12px; font-weight: 500; }
121
111
  .input-wrap { position: relative; }
122
112
  .input-wrap input { padding-right: 58px; }
123
- .input-wrap .reveal {
124
- position: absolute; right: 5px; top: 50%; transform: translateY(-50%);
125
- border: 0; background: transparent; color: var(--muted);
126
- font-size: 11.5px; padding: 5px 7px; border-radius: 6px;
127
- }
113
+ .input-wrap .reveal { position: absolute; right: 5px; top: 50%; transform: translateY(-50%); border: 0; background: transparent; color: var(--muted); font-size: 11.5px; padding: 5px 7px; border-radius: 6px; }
128
114
  .input-wrap .reveal:hover { color: var(--text); background: rgba(255,255,255,.05); }
129
115
  .error { min-height: 20px; margin-top: 7px; color: #ffaaaa; font-size: 12px; }
130
116
  .login-card .error { margin-top: 9px; }
131
117
 
118
+ /* 背景:斜向胶囊条带,低频漂移 + 首屏入场 */
119
+ .admin-bg { position: fixed; inset: 0; z-index: 0; overflow: hidden; pointer-events: none; background: radial-gradient(60vw 40vw at 20% 0%, rgba(111,141,255,.08), transparent 70%); }
120
+ .admin-bg-plane { position: absolute; inset: -55% -30%; transform: rotate(37deg); }
121
+ .admin-bg-rows { position: absolute; inset: 0; }
122
+ .admin-bg-row { position: absolute; left: -280px; right: -280px; height: var(--row, 30px); background-repeat: repeat; opacity: .7; animation: admin-drift-right var(--dur, 8s) linear infinite, admin-enter 2.8s cubic-bezier(.2,.8,.2,1) both; }
123
+ .admin-bg-row.odd { animation-name: admin-drift-left, admin-enter; }
124
+ .admin-bg-shade { position: absolute; inset: 0; pointer-events: none; background-image: radial-gradient(125% 94% at 0% 0%, rgba(130,160,255,.10), transparent 62%), radial-gradient(135% 135% at 50% 50%, transparent 20%, rgba(0,0,0,.55) 100%); }
125
+ @keyframes admin-enter { from { translate: var(--enter-from, 0px) 0; } to { translate: var(--enter-to, 0px) 0; } }
126
+ @keyframes admin-drift-right { from { transform: translateX(0); } to { transform: translateX(var(--tile, 58px)); } }
127
+ @keyframes admin-drift-left { from { transform: translateX(0); } to { transform: translateX(calc(var(--tile, 58px) * -1)); } }
128
+ @media (prefers-reduced-motion: reduce) { .admin-bg-row { animation: none; opacity: .45; } }
129
+
132
130
  /* app shell */
133
- .app { max-width: 1180px; margin: 0 auto; padding: 24px 24px 52px; }
134
- .topbar {
135
- display: flex; align-items: center; justify-content: space-between; gap: 16px;
136
- padding: 8px 0 22px;
137
- animation: fade-in .24s ease both;
138
- }
139
- .brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
140
- .brand img { width: 26px; height: 26px; }
131
+ .app { position: relative; z-index: 1; max-width: 1240px; margin: 0 auto; padding: 22px 24px 60px; }
132
+ .topbar { display: flex; align-items: center; gap: 12px; padding: 8px 0 18px; animation: fade-in .24s ease both; }
133
+ .brand { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 0 0 auto; }
134
+ .brand img { width: 30px; height: 30px; border-radius: 9px; }
141
135
  .brand h1 { margin: 0; font-size: 16px; font-weight: 650; letter-spacing: -.01em; white-space: nowrap; }
142
- .brand .brand-sub { color: var(--faint); font-size: 11.5px; margin-left: 2px; }
143
- .top-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
144
- .version-pill {
145
- display: inline-flex; align-items: center; gap: 6px;
146
- color: var(--faint); font-size: 11.5px;
147
- padding: 4px 9px; border: 1px solid var(--line); border-radius: 999px;
148
- }
136
+ .brand .brand-sub { color: var(--faint); font-size: 11.5px; margin-left: 2px; white-space: nowrap; }
137
+ .topbar .spacer { flex: 1 1 auto; }
138
+ .version-pill { display: inline-flex; align-items: center; gap: 6px; color: var(--faint); font-size: 11.5px; padding: 4px 9px; border: 1px solid var(--line); border-radius: 999px; white-space: nowrap; }
149
139
  .version-pill i { width: 6px; height: 6px; border-radius: 50%; background: var(--good); box-shadow: 0 0 0 3px var(--good-soft); }
150
- .hero {
151
- display: flex; align-items: flex-end; justify-content: space-between; gap: 16px;
152
- margin: 4px 0 18px;
153
- animation: rise-in .32s cubic-bezier(.16,1,.3,1) both;
154
- }
155
- .hero h2 { margin: 5px 0 4px; font-size: 22px; line-height: 1.2; letter-spacing: -.015em; }
140
+ .topbar .badge { white-space: nowrap; }
141
+ .top-actions { display: flex; align-items: center; gap: 8px; }
142
+
143
+ .hero { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin: 6px 0 18px; }
144
+ .hero h2 { margin: 5px 0 4px; font-size: 26px; line-height: 1.2; letter-spacing: -.02em; }
156
145
  .hero .muted { margin: 0; }
157
- .health { display: inline-flex; align-items: center; gap: 7px; color: var(--good); font-size: 12px; white-space: nowrap; }
158
- .health i { width: 7px; height: 7px; border-radius: 50%; background: var(--good); box-shadow: 0 0 0 4px var(--good-soft); }
159
-
160
- .metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 18px; }
161
- .metric {
162
- padding: 15px 16px; border: 1px solid var(--line); border-radius: var(--radius);
163
- background: linear-gradient(180deg, rgba(30,35,44,.9), rgba(24,28,35,.94));
164
- animation: rise-in .34s cubic-bezier(.16,1,.3,1) both;
165
- }
166
- .metric:nth-child(2) { animation-delay: .04s; }
167
- .metric:nth-child(3) { animation-delay: .08s; }
168
- .metric:nth-child(4) { animation-delay: .12s; }
169
- .metric .k { color: var(--faint); font-size: 11.5px; }
170
- .metric .v { margin-top: 4px; font-size: 23px; line-height: 1.15; font-weight: 650; font-variant-numeric: tabular-nums; }
146
+ .health { display: inline-flex; align-items: center; gap: 7px; padding: 6px 11px; border: 1px solid rgba(94,211,146,.28); border-radius: 999px; background: var(--good-soft); color: var(--good); font-size: 12px; white-space: nowrap; }
147
+ .health i { width: 7px; height: 7px; border-radius: 50%; background: var(--good); }
148
+ .health.bad { border-color: rgba(239,119,119,.35); background: var(--bad-soft); color: #ffabab; }
149
+ .health.bad i { background: var(--bad); }
150
+
151
+ .metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; margin-bottom: 16px; }
152
+ .metric { padding: 14px 15px; border: 1px solid var(--line); border-radius: var(--radius); background: linear-gradient(180deg, rgba(30,35,44,.9), rgba(24,28,35,.94)); animation: rise-in .34s cubic-bezier(.16,1,.3,1) both; }
153
+ .metric .k { display: flex; align-items: center; gap: 7px; color: var(--faint); font-size: 11.5px; white-space: nowrap; }
154
+ .metric .k svg { color: var(--muted); }
155
+ .metric .v { margin-top: 6px; font-size: 24px; line-height: 1.1; font-weight: 650; font-variant-numeric: tabular-nums; }
171
156
  .metric .hint { margin-top: 3px; color: var(--faint); font-size: 11px; }
157
+ .metric .bar { height: 4px; margin-top: 8px; border-radius: 2px; background: rgba(255,255,255,.07); overflow: hidden; }
158
+ .metric .bar i { display: block; height: 100%; background: var(--accent); }
172
159
 
173
- .layout {
174
- display: grid;
175
- grid-template-columns: minmax(0, 1.48fr) minmax(320px, .82fr);
176
- grid-template-areas: "sessions storage" "devices devices";
177
- gap: 16px;
178
- align-items: stretch;
179
- }
160
+ .activity-panel { margin-bottom: 12px; }
161
+ .activity-range { position: relative; }
162
+ .range-popover { position: absolute; right: 0; top: calc(100% + 8px); z-index: 30; width: 280px; padding: 14px; border: 1px solid var(--line-strong); border-radius: 12px; background: var(--panel); box-shadow: 0 18px 50px rgba(0,0,0,.45); }
163
+ .range-presets { display: flex; gap: 6px; margin-bottom: 12px; }
164
+ .range-presets button { border: 1px solid var(--line); background: transparent; color: var(--muted); border-radius: 7px; min-height: 26px; padding: 0 9px; font-size: 11px; cursor: pointer; }
165
+ .range-presets button:hover { color: var(--text); border-color: var(--line-strong); }
166
+ .range-fields { display: grid; gap: 9px; }
167
+ .range-fields label { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: var(--muted); font-size: 11.5px; }
168
+ .range-fields input { width: 160px; min-height: 32px; }
169
+ .range-actions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 13px; }
170
+ .activity-legend { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 11px; }
171
+ .activity-device { display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border: 1px solid var(--line); border-radius: 999px; background: transparent; color: var(--muted); font-size: 11px; cursor: pointer; white-space: nowrap; }
172
+ .activity-device:hover { color: var(--text); border-color: var(--line-strong); }
173
+ .activity-device.on { color: var(--text); border-color: rgba(91,136,255,.55); background: var(--accent-soft); }
174
+ .activity-device i { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
175
+ .activity-grid-wrap { overflow-x: auto; padding: 2px 0 4px; }
176
+ .activity-grid { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 15px); grid-auto-columns: 15px; gap: 4px; width: max-content; }
177
+ .heat-cell { width: 15px; height: 15px; border-radius: 3px; background: rgba(255,255,255,.045); cursor: pointer; }
178
+ .heat-cell:hover { outline: 1px solid rgba(255,255,255,.65); }
179
+ .heat-blank { background: transparent; }
180
+ .heat-level-1 { background: rgba(94,211,146,.22); }
181
+ .heat-level-2 { background: rgba(94,211,146,.4); }
182
+ .heat-level-3 { background: rgba(94,211,146,.65); }
183
+ .heat-level-4 { background: rgba(94,211,146,.92); }
184
+ .activity-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 9px; color: var(--faint); font-size: 11px; flex-wrap: wrap; }
185
+ .heat-legend { display: inline-flex; align-items: center; gap: 4px; }
186
+ .heat-legend .heat-cell { width: 10px; height: 10px; }
187
+ .layout { display: grid; grid-template-columns: minmax(0, 360px) minmax(0, 1fr); grid-template-areas: "sessions sessions" "storage devices"; gap: 12px; align-items: stretch; }
180
188
  .sessions-panel { grid-area: sessions; }
189
+ .audit-panel { margin-top: 12px; }
190
+ .audit-more { display: flex; justify-content: center; padding-top: 10px; }
191
+ .audit-detail { max-width: 360px; overflow: hidden; text-overflow: ellipsis; color: var(--muted); }
192
+ .audit-target { color: var(--muted); font-size: 11px; }
193
+ .audit-action { font-weight: 600; }
194
+ .audit-action.good { color: #9ce8bc; }
195
+ .audit-action.bad { color: #ffb0b0; }
196
+ .audit-action.warn { color: var(--warn); }
181
197
  .storage-panel { grid-area: storage; display: flex; flex-direction: column; }
182
- .storage-panel .panel-body { display: flex; flex-direction: column; flex: 1 1 auto; }
183
- .storage-panel .quota-form { margin-top: auto; padding-top: 14px; }
184
198
  .devices-panel { grid-area: devices; }
185
- .panel {
186
- border: 1px solid var(--line); border-radius: var(--radius);
187
- background: linear-gradient(180deg, rgba(28,33,41,.92), rgba(23,27,34,.96));
188
- overflow: hidden;
189
- animation: rise-in .36s cubic-bezier(.16,1,.3,1) both;
190
- }
191
- .panel-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; padding: 16px 16px 0; }
192
- .panel-head h2 { margin: 0; font-size: 14px; font-weight: 650; }
193
- .panel-head p { margin: 3px 0 0; color: var(--faint); font-size: 11.5px; }
194
- .panel-body { padding: 14px 16px 16px; }
195
- .badge {
196
- display: inline-flex; align-items: center; gap: 5px;
197
- min-height: 22px; padding: 1px 8px;
198
- border: 1px solid var(--line); border-radius: 999px;
199
- color: var(--muted); background: rgba(255,255,255,.025);
200
- font-size: 11px; white-space: nowrap;
201
- }
199
+ .panel { border: 1px solid var(--line); border-radius: var(--radius); background: linear-gradient(180deg, rgba(28,33,41,.92), rgba(23,27,34,.96)); overflow: hidden; animation: rise-in .36s cubic-bezier(.16,1,.3,1) both; }
200
+ .panel-head { display: flex; align-items: center; gap: 10px; padding: 14px 16px 10px; }
201
+ .panel-icon { width: 28px; height: 28px; border-radius: 8px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid var(--line); background: var(--bg-soft); color: var(--muted); }
202
+ .panel-title { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
203
+ .panel-title b { margin: 0; font-size: 14px; font-weight: 650; }
204
+ .panel-title span { margin: 0; color: var(--faint); font-size: 11.5px; }
205
+ .panel-head .spacer { flex: 1 1 auto; }
206
+ .panel-body { padding: 4px 16px 16px; }
207
+ .badge { display: inline-flex; align-items: center; gap: 5px; min-height: 22px; padding: 1px 8px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); background: rgba(255,255,255,.025); font-size: 11px; white-space: nowrap; }
202
208
  .badge.good { color: #9ce8bc; border-color: rgba(94,211,146,.28); background: var(--good-soft); }
203
209
  .badge.bad { color: #ffb0b0; border-color: rgba(239,119,119,.3); background: var(--bad-soft); }
204
210
  .badge.accent { color: #b9ccff; border-color: rgba(91,136,255,.32); background: var(--accent-soft); }
205
211
 
206
- /* storage */
207
- .storage-line { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 8px; }
208
- .storage-line b { font-size: 17px; font-weight: 650; font-variant-numeric: tabular-nums; }
212
+ .storage-line { display: flex; align-items: baseline; gap: 7px; margin-bottom: 8px; }
213
+ .storage-line b { font-size: 24px; font-weight: 650; font-variant-numeric: tabular-nums; }
209
214
  .storage-line span { color: var(--faint); font-size: 11.5px; }
210
- .bar { height: 7px; border-radius: 999px; background: rgba(255,255,255,.07); overflow: hidden; }
215
+ .bar { height: 6px; border-radius: 3px; background: rgba(255,255,255,.07); overflow: hidden; }
211
216
  .bar i { display: block; height: 100%; width: 0; border-radius: inherit; background: linear-gradient(90deg, var(--accent), var(--accent-2)); transition: width .35s cubic-bezier(.16,1,.3,1); }
212
- .quota-form { display: flex; align-items: center; gap: 7px; margin-top: 13px; }
217
+ .presets { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 12px; }
218
+ .preset { height: 26px; padding: 0 9px; border-radius: 7px; border: 1px solid var(--line); background: transparent; color: var(--muted); font-size: 11.5px; }
219
+ .preset:hover { color: var(--text); border-color: var(--line-strong); }
220
+ .preset.on { border-color: rgba(91,136,255,.5); background: var(--accent-soft); color: var(--accent-2); }
221
+ .quota-form { display: flex; align-items: center; gap: 7px; margin-top: 12px; }
213
222
  .quota-form input { width: 84px; min-height: 32px; }
214
223
  .quota-form .unit { color: var(--faint); font-size: 11.5px; }
215
224
  .quota-form .btn { margin-left: auto; }
@@ -217,129 +226,172 @@
217
226
  .inline-result.good { color: var(--good); }
218
227
  .inline-result.bad { color: #ffaaaa; }
219
228
 
220
- /* devices */
221
- .device-form { display: flex; gap: 7px; }
229
+ .device-form { display: flex; gap: 7px; max-width: 520px; }
222
230
  .device-form input { min-width: 0; }
223
231
  .device-form .btn { flex: 0 0 auto; }
224
- .device-list { margin-top: 12px; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px; }
225
- .device-form { max-width: 520px; }
226
- .device-row {
227
- display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 8px 10px;
228
- padding: 10px 11px; border: 1px solid var(--line); border-radius: 10px;
229
- background: rgba(255,255,255,.018);
230
- }
231
- .device-main { min-width: 0; }
232
- .device-label { display: flex; align-items: center; gap: 7px; font-size: 12.5px; font-weight: 600; }
233
- .device-id { display: flex; align-items: center; gap: 5px; margin-top: 2px; color: var(--faint); font-size: 10.5px; }
234
- .device-id code { color: var(--muted); overflow: hidden; text-overflow: ellipsis; }
235
- .icon-btn {
236
- border: 0; background: transparent; color: var(--faint);
237
- border-radius: 6px; padding: 2px 5px; font-size: 10.5px;
238
- }
232
+ .device-list { margin-top: 10px; display: flex; flex-direction: column; }
233
+ .device-row { display: flex; align-items: center; gap: 12px; padding: 11px 4px; border-top: 1px solid var(--line); }
234
+ .device-row:first-child { border-top: 0; }
235
+ .device-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1 1 auto; }
236
+ .device-label { display: flex; align-items: center; gap: 7px; font-size: 12.5px; font-weight: 600; white-space: nowrap; }
237
+ .device-label .device-color { width: 7px; height: 7px; border-radius: 2px; background: var(--faint); flex: 0 0 auto; }
238
+ .device-label .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); flex: 0 0 auto; }
239
+ .device-label .dot.good { background: var(--good); box-shadow: 0 0 0 3px var(--good-soft); }
240
+ .device-label .dot.warn { background: var(--warn); box-shadow: 0 0 0 3px rgba(240,182,74,.12); }
241
+ .device-id { display: flex; align-items: center; gap: 4px; color: var(--faint); font-size: 11px; }
242
+ .device-id code { color: var(--muted); }
243
+ .device-meta { display: flex; gap: 12px; color: var(--faint); font-size: 11px; flex-wrap: wrap; }
244
+ .device-actions { display: flex; gap: 6px; flex: 0 0 auto; }
245
+ .inline-edit { display: flex; align-items: center; gap: 6px; }
246
+ .inline-edit input { height: 28px; width: 160px; min-height: 28px; padding: 0 8px; font-size: 12.5px; border-color: var(--accent); }
247
+ .icon-btn { border: 0; background: transparent; color: var(--faint); border-radius: 6px; padding: 2px 5px; font-size: 10.5px; display: inline-flex; align-items: center; gap: 3px; }
239
248
  .icon-btn:hover { color: var(--text); background: rgba(255,255,255,.06); }
240
- .device-meta { color: var(--faint); font-size: 10.5px; line-height: 1.5; margin-top: 3px; display: flex; flex-direction: column; gap: 1px; }
241
- .device-actions { display: flex; align-items: flex-start; }
249
+ .icon-btn.danger:hover { color: #ffb0b0; background: var(--bad-soft); }
242
250
 
243
- /* sessions */
244
- .session-tools { display: flex; align-items: center; gap: 7px; margin-bottom: 11px; }
245
- .session-tools input { min-width: 0; flex: 1 1 auto; max-width: 300px; min-height: 32px; }
251
+ .session-tools { display: flex; align-items: center; gap: 7px; margin-bottom: 11px; flex-wrap: wrap; }
252
+ .session-tools input { min-width: 0; flex: 1 1 220px; max-width: 360px; min-height: 32px; }
246
253
  .session-tools select { width: auto; min-width: 112px; min-height: 32px; }
254
+ .session-tools .spacer { flex: 1 1 auto; }
255
+ .switch { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 12px; cursor: pointer; user-select: none; white-space: nowrap; }
256
+ .switch input { display: none; }
257
+ .switch i { width: 30px; height: 17px; border-radius: 999px; background: #303845; position: relative; transition: background .15s; }
258
+ .switch i::after { content: ""; position: absolute; top: 2px; left: 2px; width: 13px; height: 13px; border-radius: 50%; background: #9aa5b5; transition: transform .15s, background .15s; }
259
+ .switch input:checked + i { background: rgba(91,136,255,.6); }
260
+ .switch input:checked + i::after { transform: translateX(13px); background: #fff; }
261
+
247
262
  .table-wrap { overflow: auto; border: 1px solid var(--line); border-radius: 10px; }
248
263
  table { width: 100%; border-collapse: collapse; font-size: 11.5px; }
249
264
  th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid rgba(255,255,255,.055); white-space: nowrap; }
250
265
  th { color: var(--faint); font-size: 10.5px; font-weight: 600; letter-spacing: .02em; background: rgba(255,255,255,.025); position: sticky; top: 0; }
251
266
  tr:last-child td { border-bottom: 0; }
252
267
  tbody tr:hover td { background: rgba(255,255,255,.025); }
253
-
254
- /* session groups */
268
+ td.session-id { max-width: 320px; overflow: hidden; text-overflow: ellipsis; }
269
+ td.session-id code { color: #b8c7de; }
270
+ .row-copy { border: 0; background: transparent; color: var(--faint); padding: 2px 4px; border-radius: 5px; font-size: 10px; display: inline-flex; align-items: center; }
271
+ .row-copy:hover { color: var(--text); background: rgba(255,255,255,.07); }
255
272
  .session-group td { background: rgba(255,255,255,.03); padding: 7px 10px; }
256
273
  .session-group-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
274
+ .chev { display: grid; place-items: center; width: 20px; height: 20px; border: 0; border-radius: 5px; background: transparent; color: var(--muted); transition: transform .15s; padding: 0; }
275
+ .chev.closed { transform: rotate(-90deg); }
257
276
  .workspace-alias { width: 150px; min-height: 26px; font-size: 11px; }
258
277
  .session-group-key { display: flex; align-items: center; gap: 6px; color: var(--faint); font-size: 10.5px; }
259
278
  .session-group-key code { color: var(--muted); font-size: 10.5px; }
260
- .session-group-meta { margin-left: auto; color: var(--faint); font-size: 10.5px; }
261
- td.session-id { max-width: 250px; overflow: hidden; text-overflow: ellipsis; }
262
- td.session-id code { color: #b8c7de; }
263
- .row-copy { border: 0; background: transparent; color: var(--faint); padding: 2px 4px; border-radius: 5px; font-size: 10px; }
264
- .row-copy:hover { color: var(--text); background: rgba(255,255,255,.07); }
265
- .empty { padding: 24px 14px; text-align: center; color: var(--faint); }
279
+ .session-group-meta { margin-left: auto; color: var(--faint); font-size: 10.5px; white-space: nowrap; }
280
+ .empty { padding: 30px 16px; text-align: center; color: var(--faint); }
281
+ .empty .icon { color: var(--muted); margin-bottom: 6px; }
266
282
  .empty b { display: block; margin-bottom: 3px; color: var(--muted); font-size: 12.5px; }
267
283
  .empty .btn { margin-top: 10px; }
268
- .empty-filter { color: var(--faint); font-size: 11.5px; padding: 16px; text-align: center; }
269
284
 
270
285
  .muted { color: var(--muted); }
271
286
  .faint { color: var(--faint); }
272
287
  .mono { font-variant-ligatures: none; }
273
288
 
274
- /* toast + modal */
275
- .toast {
276
- position: fixed; left: 50%; bottom: 24px; z-index: 80;
277
- max-width: min(520px, calc(100vw - 32px));
278
- padding: 9px 14px; border: 1px solid var(--line-strong); border-radius: 10px;
279
- background: rgba(30,35,44,.97); color: var(--text);
280
- box-shadow: 0 16px 40px rgba(0,0,0,.4);
281
- font-size: 12px; opacity: 0; pointer-events: none;
282
- transform: translate(-50%, 8px);
283
- transition: opacity .18s, transform .18s;
284
- }
289
+ .toast { position: fixed; left: 50%; bottom: 24px; z-index: 80; max-width: min(520px, calc(100vw - 32px)); padding: 9px 14px; border: 1px solid var(--line-strong); border-radius: 10px; background: rgba(30,35,44,.97); color: var(--text); box-shadow: 0 16px 40px rgba(0,0,0,.4); font-size: 12px; opacity: 0; pointer-events: none; transform: translate(-50%, 8px); transition: opacity .18s, transform .18s; }
285
290
  .toast.on { opacity: 1; transform: translate(-50%, 0); }
286
291
  .toast.bad { border-color: rgba(239,119,119,.4); color: #ffb0b0; }
287
- .modal {
288
- position: fixed; inset: 0; z-index: 70;
289
- display: grid; place-items: center; padding: 24px;
290
- background: rgba(6,8,11,.66); backdrop-filter: blur(6px);
291
- animation: fade-in .18s ease both;
292
- }
292
+ .modal { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; padding: 24px; background: rgba(6,8,11,.66); backdrop-filter: blur(6px); animation: fade-in .18s ease both; }
293
293
  body.modal-open { overflow: hidden; }
294
- .modal-card {
295
- width: min(520px, 100%);
296
- padding: 24px; border: 1px solid var(--line-strong); border-radius: 17px;
297
- background: var(--panel); box-shadow: 0 28px 80px rgba(0,0,0,.55);
298
- animation: card-in .28s cubic-bezier(.16,1,.3,1) both;
299
- }
294
+ .modal-card { width: min(520px, 100%); padding: 24px; border: 1px solid var(--line-strong); border-radius: 17px; background: var(--panel); box-shadow: 0 28px 80px rgba(0,0,0,.55); animation: card-in .28s cubic-bezier(.16,1,.3,1) both; }
300
295
  .modal-card h2 { margin: 0 0 4px; font-size: 18px; }
301
296
  .modal-card p { margin: 0; color: var(--muted); font-size: 12.5px; }
302
- .token-box {
303
- margin: 16px 0 6px; padding: 12px;
304
- border: 1px solid var(--line); border-radius: 10px;
305
- background: var(--bg);
306
- color: #c7dbff; font: 12px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace;
307
- word-break: break-all;
308
- }
297
+ .modal-card .field { margin-top: 12px; }
298
+ .modal-card .field input { min-height: 34px; }
299
+ .device-color-btn { border: 0; background: transparent; padding: 0; display: inline-flex; cursor: pointer; border-radius: 8px; }
300
+ .device-color-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
301
+ .device-avatar { width: 24px; height: 24px; border-radius: 8px; display: grid; place-items: center; font-size: 12px; font-weight: 700; line-height: 1; box-shadow: inset 0 0 0 1px rgba(255,255,255,.16); }
302
+ .device-status { display: inline-flex; align-items: center; gap: 5px; color: var(--faint); font-size: 11px; font-weight: 400; }
303
+ .device-status .dot { width: 6px; height: 6px; box-shadow: none; }
304
+ .device-status .dot.idle { background: transparent; border: 1px solid var(--faint); }
305
+ .device-status.online { color: var(--good); }
306
+ .color-picker-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
307
+ .color-picker-row input[type="color"] { width: 62px; height: 42px; padding: 3px; border: 1px solid var(--line-strong); border-radius: 9px; background: var(--bg-soft); cursor: pointer; }
308
+ .color-picker-row input[type="color"]::-webkit-color-swatch-wrapper { padding: 0; }
309
+ .color-picker-row input[type="color"]::-webkit-color-swatch { border: 0; border-radius: 6px; }
310
+ .color-hex { width: 112px; min-height: 34px; padding: 6px 9px; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--bg-soft); color: var(--text); outline: none; font: 12px ui-monospace, Consolas, monospace; }
311
+ .color-hex:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
312
+ .color-hint { margin-top: 9px; color: var(--faint); font-size: 11px; }
313
+ .color-presets { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
314
+ .color-preset { width: 28px; height: 28px; padding: 0; border: 2px solid transparent; border-radius: 8px; cursor: pointer; box-shadow: inset 0 0 0 1px rgba(255,255,255,.18); }
315
+ .color-preset.on { border-color: #fff; }
316
+ .color-live { display: flex; align-items: center; gap: 8px; margin-top: 14px; padding: 9px 11px; border: 1px solid var(--line); border-radius: 9px; background: rgba(0,0,0,.12); color: var(--muted); font-size: 11px; flex-wrap: wrap; }
317
+ .color-live .swatch { width: 12px; height: 12px; border-radius: 3px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.25); }
318
+ .color-live .online { width: 7px; height: 7px; border-radius: 50%; background: var(--good); }
319
+ .history-card { width: min(760px, 100%); max-height: calc(100vh - 48px); display: flex; flex-direction: column; }
320
+ .history-summary { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0; }
321
+ .history-chip { display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: 11px; white-space: nowrap; }
322
+ .history-chip i { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
323
+ .history-tabs { display: flex; gap: 6px; margin: 12px 0 8px; }
324
+ .history-tab { border: 1px solid var(--line); background: transparent; color: var(--muted); border-radius: 7px; min-height: 26px; padding: 0 9px; font-size: 11px; cursor: pointer; }
325
+ .history-tab:hover { color: var(--text); border-color: var(--line-strong); }
326
+ .history-tab.on { color: var(--text); border-color: rgba(91,136,255,.5); background: var(--accent-soft); }
327
+ .history-pane { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
328
+ .history-list { flex: 1 1 auto; min-height: 80px; overflow: auto; border: 1px solid var(--line); border-radius: 10px; background: rgba(0,0,0,.12); }
329
+ .history-gantt { flex: 1 1 auto; overflow: auto; border: 1px solid var(--line); border-radius: 10px; background: rgba(0,0,0,.12); padding: 12px; display: flex; flex-direction: column; gap: 10px; }
330
+ .history-gantt-row { display: grid; grid-template-columns: 110px minmax(0,1fr); gap: 10px; align-items: center; }
331
+ .history-gantt-label { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
332
+ .history-gantt-label i { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
333
+ .history-gantt-track { height: 12px; border-radius: 6px; background: rgba(255,255,255,.04); position: relative; }
334
+ .history-gantt-bar { position: absolute; top: 2px; height: 8px; min-width: 3px; border-radius: 4px; }
335
+ .history-heatmap { flex: 1 1 auto; display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 11px); grid-auto-columns: 11px; gap: 3px; width: max-content; overflow: auto; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: rgba(0,0,0,.12); }
336
+ .history-heatmap .heat-cell { width: 11px; height: 11px; cursor: default; }
337
+ .history-item { display: grid; grid-template-columns: 82px 16px minmax(0, 1fr); gap: 9px; padding: 9px 11px; border-top: 1px solid rgba(255,255,255,.055); }
338
+ .history-item:first-child { border-top: 0; }
339
+ .history-time { color: var(--faint); font-size: 11px; padding-top: 1px; white-space: nowrap; }
340
+ .history-rail { position: relative; display: flex; justify-content: center; }
341
+ .history-rail::before { content: ""; position: absolute; top: -9px; bottom: -9px; width: 1px; background: var(--line); }
342
+ .history-item:first-child .history-rail::before { top: 6px; }
343
+ .history-item:last-child .history-rail::before { bottom: calc(100% - 7px); }
344
+ .history-dot { width: 8px; height: 8px; margin-top: 5px; border-radius: 50%; background: var(--dot-color, var(--muted)); position: relative; z-index: 1; box-shadow: 0 0 0 3px rgba(15,17,21,.9); }
345
+ .history-item.admin .history-dot { --dot-color: var(--warn); }
346
+ .history-item.gone .history-dot { --dot-color: var(--bad); }
347
+ .history-main { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
348
+ .history-title { color: var(--text); font-size: 12.5px; font-weight: 600; }
349
+ .history-detail { color: var(--faint); font-size: 11px; }
350
+ .history-item.admin .history-title { color: var(--warn); }
351
+ .history-item.gone .history-title { color: #ffb0b0; }
352
+ .history-empty { padding: 30px 16px; text-align: center; color: var(--faint); }
353
+ .token-box { margin: 16px 0 6px; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg); color: #c7dbff; font: 12px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace; word-break: break-all; }
309
354
  .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
310
355
 
356
+ /* 单行元素统一禁止换行,避免窄列被压成竖排。 */
357
+ .topbar, .brand, .brand h1, .brand .brand-sub, .version-pill, .badge, .btn, .health,
358
+ .eyebrow, .hero h2, .metric .k, .metric .v, .metric .hint,
359
+ .panel-title b, .panel-head > .badge, .device-label, .device-id, .device-meta,
360
+ .storage-line, .preset, .quota-form, .session-tools, th, td { white-space: nowrap; }
361
+
311
362
  @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
312
363
  @keyframes card-in { from { opacity: 0; transform: translateY(-10px) scale(.98); } to { opacity: 1; transform: none; } }
364
+ @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } }
365
+ @keyframes card-out { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateY(-8px) scale(.98); } }
366
+ .modal.closing { animation: fade-out .16s ease both; }
367
+ .modal.closing .modal-card { animation: card-out .16s ease both; }
313
368
  @keyframes rise-in { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: none; } }
314
- @media (prefers-reduced-motion: reduce) {
315
- *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
316
- }
369
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } }
317
370
 
318
- @media (max-width: 980px) {
319
- .layout { grid-template-columns: 1fr; grid-template-areas: "sessions" "storage" "devices"; }
320
- }
371
+ @media (max-width: 980px) { .layout { grid-template-columns: 1fr; grid-template-areas: "sessions" "storage" "devices"; } }
321
372
  @media (max-width: 760px) {
322
373
  body { font-size: 13px; }
323
- .app { padding: 16px 14px 36px; }
374
+ .app { padding: 16px 14px 40px; }
324
375
  .topbar { align-items: flex-start; flex-direction: column; gap: 10px; padding-bottom: 16px; }
325
376
  .top-actions { width: 100%; justify-content: space-between; }
326
377
  .hero { align-items: flex-start; flex-direction: column; gap: 8px; }
327
- .metrics { grid-template-columns: repeat(2, minmax(0,1fr)); gap: 9px; }
328
- .metric { padding: 12px; }
329
- .metric .v { font-size: 20px; }
330
- .session-tools { flex-wrap: wrap; }
378
+ .hero h2 { font-size: 22px; }
331
379
  .session-tools input { max-width: none; }
332
380
  .session-tools select { flex: 1 1 auto; }
381
+ .device-row { flex-wrap: wrap; }
382
+ .device-actions { width: 100%; justify-content: flex-end; }
333
383
  .auth-card { padding: 22px; }
334
384
  }
335
- @media (max-width: 420px) {
336
- .metrics { grid-template-columns: 1fr; }
337
- .brand h1 { font-size: 14px; }
338
- .brand-sub { display: none; }
339
- }
340
385
  </style>
341
386
  </head>
342
387
  <body>
388
+ <div class="admin-bg" aria-hidden="true">
389
+ <div class="admin-bg-plane">
390
+ <div class="admin-bg-rows" id="adminBgRows"></div>
391
+ </div>
392
+ <div class="admin-bg-shade"></div>
393
+ </div>
394
+
343
395
  <div id="loginView" class="login-page">
344
396
  <div class="login-inner">
345
397
  <div class="brand-mark"><img src="/logo.svg" alt=""></div>
@@ -368,9 +420,12 @@
368
420
  <h1>dsh-session-cloudsync</h1>
369
421
  <span class="brand-sub">管理台</span>
370
422
  </div>
423
+ <span class="version-pill"><i></i><span id="version">连接中…</span></span>
424
+ <span class="spacer"></span>
425
+ <span id="updatedBadge" class="badge">更新于刚刚</span>
371
426
  <div class="top-actions">
372
- <span class="version-pill"><i></i><span id="version">连接中…</span></span>
373
- <button id="refresh" class="btn secondary" type="button">刷新</button>
427
+ <button id="refresh" class="btn secondary" type="button"></button>
428
+ <button id="changePassword" class="btn ghost" type="button">密码</button>
374
429
  <button id="logout" class="btn ghost" type="button">退出</button>
375
430
  </div>
376
431
  </header>
@@ -382,33 +437,75 @@
382
437
  <h2>同步概览</h2>
383
438
  <p class="muted">自部署节点、设备与会话视图;设备可签发、改名、吊销。</p>
384
439
  </div>
385
- <div class="health"><i></i><span id="healthText">服务正常</span></div>
440
+ <div id="health" class="health"><i></i><span id="healthText">服务正常</span></div>
386
441
  </section>
387
442
 
388
443
  <section id="metrics" class="metrics" aria-label="概览指标"></section>
389
444
 
445
+ <section class="panel activity-panel">
446
+ <div class="panel-head">
447
+ <span class="panel-icon" id="activityIcon"></span>
448
+ <div class="panel-title">
449
+ <b>工作强度</b>
450
+ <span>按天统计新增事件;只反映同步活动,不代表内容。</span>
451
+ </div>
452
+ <span class="spacer"></span>
453
+ <div class="activity-range">
454
+ <button id="activityRangeButton" class="btn small secondary" type="button">最近 90 天</button>
455
+ <div id="activityRangePopover" class="range-popover" hidden>
456
+ <div class="range-presets" id="activityPresets">
457
+ <button type="button" data-days="30">30 天</button>
458
+ <button type="button" data-days="90">90 天</button>
459
+ <button type="button" data-days="365">365 天</button>
460
+ </div>
461
+ <div class="range-fields">
462
+ <label>开始日期<input id="activityFrom" type="date"></label>
463
+ <label>结束日期<input id="activityTo" type="date"></label>
464
+ </div>
465
+ <div class="range-actions">
466
+ <button id="activityCancel" class="btn small ghost" type="button">取消</button>
467
+ <button id="activityApply" class="btn small" type="button">应用</button>
468
+ </div>
469
+ </div>
470
+ </div>
471
+ </div>
472
+ <div class="panel-body">
473
+ <div id="activityDevices" class="activity-legend"></div>
474
+ <div class="activity-grid-wrap"><div id="activityGrid" class="activity-grid"></div></div>
475
+ <div class="activity-foot">
476
+ <span id="activityNote" class="faint">正在读取活动…</span>
477
+ <span class="heat-legend"><span>少</span><i class="heat-cell heat-level-1"></i><i class="heat-cell heat-level-2"></i><i class="heat-cell heat-level-3"></i><i class="heat-cell heat-level-4"></i><span>多</span></span>
478
+ </div>
479
+ </div>
480
+ </section>
481
+
390
482
  <div class="layout">
391
483
  <section class="panel sessions-panel">
392
484
  <div class="panel-head">
393
- <div>
394
- <h2>会话</h2>
395
- <p>按服务端更新时间倒序;只展示元数据。</p>
485
+ <span class="panel-icon" id="sessionsIcon"></span>
486
+ <div class="panel-title">
487
+ <b>会话</b>
488
+ <span>按服务端更新时间倒序;只显示同步时间、大小等摘要,内容端到端加密。</span>
396
489
  </div>
490
+ <span class="spacer"></span>
397
491
  <span id="sessionBadge" class="badge">0 个</span>
398
492
  </div>
399
493
  <div class="panel-body">
400
494
  <div class="session-tools">
401
- <input id="sessionSearch" type="search" placeholder="搜索 session id…" autocomplete="off">
495
+ <input id="sessionSearch" type="search" placeholder="搜索 session id 或工作区…" autocomplete="off">
402
496
  <select id="sessionFilter" aria-label="筛选会话">
403
497
  <option value="all">全部状态</option>
404
498
  <option value="active">正常</option>
405
499
  <option value="deleted">已删除</option>
406
500
  </select>
501
+ <button id="sessionDayChip" class="badge accent" type="button" hidden></button>
502
+ <span class="spacer"></span>
503
+ <label class="switch"><input id="autoRefresh" type="checkbox" checked><i></i>自动刷新 10s</label>
407
504
  </div>
408
- <div class="table-wrap">
505
+ <div class="table-wrap" id="sessionTableWrap">
409
506
  <table>
410
507
  <thead>
411
- <tr><th>会话 id</th><th>格式</th><th>批次</th><th>最后序号</th><th>更新时间</th><th>状态</th></tr>
508
+ <tr><th>会话</th><th>格式</th><th>批次</th><th>最后序号</th><th>大小</th><th>更新时间</th><th>状态</th><th>操作</th></tr>
412
509
  </thead>
413
510
  <tbody id="sessions"></tbody>
414
511
  </table>
@@ -419,19 +516,22 @@
419
516
 
420
517
  <section class="panel storage-panel">
421
518
  <div class="panel-head">
422
- <div>
423
- <h2>存储</h2>
424
- <p>按唯一密文字节计算。</p>
519
+ <span class="panel-icon" id="storageIcon"></span>
520
+ <div class="panel-title">
521
+ <b>存储</b>
522
+ <span>只统计服务端实际占用的加密数据。</span>
425
523
  </div>
524
+ <span class="spacer"></span>
426
525
  <span id="storageBadge" class="badge">0%</span>
427
526
  </div>
428
527
  <div class="panel-body">
429
- <div class="storage-line">
430
- <b id="storageUsed">0 B</b>
431
- <span id="storageTotal">/ 0 GiB</span>
432
- </div>
433
- <div class="bar" role="progressbar" aria-label="存储配额使用率" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
434
- <i id="storageBar"></i>
528
+ <div class="storage-line"><b id="storageUsed">0 B</b><span id="storageTotal">/ 0 GiB</span></div>
529
+ <div class="bar" role="progressbar" aria-label="存储配额使用率" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><i id="storageBar"></i></div>
530
+ <div class="presets" id="quotaPresets">
531
+ <button class="preset" type="button" data-gb="1">1 GiB</button>
532
+ <button class="preset" type="button" data-gb="5">5 GiB</button>
533
+ <button class="preset" type="button" data-gb="10">10 GiB</button>
534
+ <button class="preset" type="button" data-gb="50">50 GiB</button>
435
535
  </div>
436
536
  <form id="quotaForm" class="quota-form">
437
537
  <input id="quotaGb" type="number" min="1" step="1" inputmode="numeric" aria-label="配额 GiB">
@@ -444,10 +544,12 @@
444
544
 
445
545
  <section class="panel devices-panel">
446
546
  <div class="panel-head">
447
- <div>
448
- <h2>设备</h2>
449
- <p>每台设备一个 token;token 只显示一次。</p>
547
+ <span class="panel-icon" id="devicesIcon"></span>
548
+ <div class="panel-title">
549
+ <b>设备</b>
550
+ <span>每台设备一把接入密钥,只在创建时显示一次;点击头像可自定义颜色。</span>
450
551
  </div>
552
+ <span class="spacer"></span>
451
553
  <span id="deviceBadge" class="badge">0 台</span>
452
554
  </div>
453
555
  <div class="panel-body">
@@ -460,6 +562,40 @@
460
562
  </div>
461
563
  </section>
462
564
  </div>
565
+
566
+ <section class="panel audit-panel">
567
+ <div class="panel-head">
568
+ <span class="panel-icon" id="auditIcon"></span>
569
+ <div class="panel-title">
570
+ <b>审计</b>
571
+ <span>管理动作流水;不记录会话内容、设备密钥或管理密码。</span>
572
+ </div>
573
+ <span class="spacer"></span>
574
+ <span id="auditBadge" class="badge">0 条</span>
575
+ </div>
576
+ <div class="panel-body">
577
+ <div class="session-tools">
578
+ <select id="auditFilter" aria-label="筛选审计动作">
579
+ <option value="">全部动作</option>
580
+ </select>
581
+ <input id="auditSearch" type="search" placeholder="搜索操作者或目标…" autocomplete="off" aria-label="搜索审计">
582
+ <span class="spacer"></span>
583
+ <button id="auditRefresh" class="btn small secondary" type="button">刷新</button>
584
+ </div>
585
+ <div class="table-wrap">
586
+ <table>
587
+ <thead>
588
+ <tr><th>时间</th><th>动作</th><th>操作者</th><th>目标</th><th>详情</th></tr>
589
+ </thead>
590
+ <tbody id="auditRows"></tbody>
591
+ </table>
592
+ </div>
593
+ <div id="auditEmpty" class="empty" hidden></div>
594
+ <div id="auditMoreWrap" class="audit-more" hidden>
595
+ <button id="auditMore" class="btn small ghost" type="button">加载更多</button>
596
+ </div>
597
+ </div>
598
+ </section>
463
599
  </main>
464
600
  </div>
465
601
 
@@ -467,20 +603,175 @@
467
603
 
468
604
  <div id="tokenDialog" class="modal" hidden>
469
605
  <div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="tokenTitle">
470
- <h2 id="tokenTitle">设备 token</h2>
606
+ <h2 id="tokenTitle">设备接入密钥</h2>
471
607
  <p>只显示这一次。关闭后无法再次查看,只能重新签发。</p>
472
608
  <div id="tokenValue" class="token-box" tabindex="0"></div>
473
609
  <div class="modal-actions">
474
- <button id="copyToken" class="btn secondary" type="button">复制 token</button>
610
+ <button id="copyToken" class="btn secondary" type="button">复制密钥</button>
475
611
  <button id="closeToken" class="btn" type="button">我已保存</button>
476
612
  </div>
477
613
  </div>
478
614
  </div>
479
615
 
616
+ <div id="revokeDialog" class="modal" hidden>
617
+ <div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="revokeTitle">
618
+ <h2 id="revokeTitle">吊销设备</h2>
619
+ <p id="revokeText"></p>
620
+ <div class="modal-actions">
621
+ <button id="cancelRevoke" class="btn ghost" type="button">取消</button>
622
+ <button id="confirmRevoke" class="btn danger" type="button">确认吊销</button>
623
+ </div>
624
+ </div>
625
+ </div>
626
+
627
+ <div id="passwordDialog" class="modal" hidden>
628
+ <div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="passwordTitle">
629
+ <h2 id="passwordTitle">修改管理密码</h2>
630
+ <p>修改后其它管理会话会退出登录,当前会话保持在线。</p>
631
+ <label class="field">
632
+ <span>当前密码</span>
633
+ <input id="currentPassword" type="password" autocomplete="current-password">
634
+ </label>
635
+ <label class="field">
636
+ <span>新密码</span>
637
+ <input id="newPassword" type="password" autocomplete="new-password">
638
+ </label>
639
+ <label class="field">
640
+ <span>确认新密码</span>
641
+ <input id="confirmPassword" type="password" autocomplete="new-password">
642
+ </label>
643
+ <div id="passwordError" class="inline-result bad" role="alert"></div>
644
+ <div class="modal-actions">
645
+ <button id="cancelPassword" class="btn ghost" type="button">取消</button>
646
+ <button id="savePassword" class="btn" type="button">保存</button>
647
+ </div>
648
+ </div>
649
+ </div>
650
+
651
+ <div id="colorDialog" class="modal" hidden>
652
+ <div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="colorTitle">
653
+ <h2 id="colorTitle">设备颜色</h2>
654
+ <p id="colorDeviceName"></p>
655
+ <div class="color-picker-row">
656
+ <input id="deviceColorPicker" type="color" value="#5b88ff" aria-label="自定义设备颜色">
657
+ <input id="deviceColorHex" class="color-hex" value="#5b88ff" spellcheck="false" aria-label="颜色十六进制值">
658
+ </div>
659
+ <div id="deviceColorPresets" class="color-presets"></div>
660
+ <div class="color-hint">支持系统调色板、预设色,也可以直接输入 #rrggbb。</div>
661
+ <div class="color-live">
662
+ <span class="device-avatar" id="colorPreviewAvatar">L</span>
663
+ <span id="colorPreviewName"></span>
664
+ <span class="faint">颜色预览</span>
665
+ </div>
666
+ <div id="colorError" class="inline-result bad" role="alert"></div>
667
+ <div class="modal-actions">
668
+ <button id="cancelColor" class="btn ghost" type="button">取消</button>
669
+ <button id="saveColor" class="btn" type="button">保存颜色</button>
670
+ </div>
671
+ </div>
672
+ </div>
673
+
674
+ <div id="historyDialog" class="modal" hidden>
675
+ <div class="modal-card history-card" role="dialog" aria-modal="true" aria-labelledby="historyTitle">
676
+ <h2 id="historyTitle">会话历史</h2>
677
+ <p id="historySession"></p>
678
+ <div id="historySummary" class="history-summary"></div>
679
+ <div class="history-tabs">
680
+ <button class="history-tab on" type="button" data-pane="timeline">时间线</button>
681
+ <button class="history-tab" type="button" data-pane="devices">设备</button>
682
+ <button class="history-tab" type="button" data-pane="heatmap">热力图</button>
683
+ </div>
684
+ <div id="historyTimelinePane" class="history-pane">
685
+ <div id="historyList" class="history-list"></div>
686
+ </div>
687
+ <div id="historyDevicesPane" class="history-pane" hidden>
688
+ <div id="historyGantt" class="history-gantt"></div>
689
+ </div>
690
+ <div id="historyHeatmapPane" class="history-pane" hidden>
691
+ <div id="historyHeatmap" class="history-heatmap"></div>
692
+ <div class="faint" style="margin-top:8px;font-size:11px">最近 90 天 · 按新增事件</div>
693
+ </div>
694
+ <div id="historyError" class="inline-result bad" role="alert"></div>
695
+ <div class="modal-actions">
696
+ <button id="closeHistory" class="btn secondary" type="button">关闭</button>
697
+ </div>
698
+ </div>
699
+ </div>
700
+
701
+ <div id="sessionDialog" class="modal" hidden>
702
+ <div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="sessionActionTitle">
703
+ <h2 id="sessionActionTitle">会话操作</h2>
704
+ <p id="sessionActionText"></p>
705
+ <label class="field" id="sessionResetField" hidden>
706
+ <span>指定重建设备</span>
707
+ <select id="sessionResetDevice"></select>
708
+ </label>
709
+ <div id="sessionActionError" class="inline-result bad" role="alert"></div>
710
+ <div class="modal-actions">
711
+ <button id="cancelSessionAction" class="btn ghost" type="button">取消</button>
712
+ <button id="confirmSessionAction" class="btn danger" type="button">确认</button>
713
+ </div>
714
+ </div>
715
+ </div>
716
+
480
717
  <script>
481
718
  const $ = (id) => document.getElementById(id)
482
- const state = { overview: null, devices: [], sessions: [] }
719
+ const GiB = 1024 ** 3
720
+ const state = {
721
+ overview: null,
722
+ devices: [],
723
+ sessions: [],
724
+ autoRefresh: true,
725
+ editingDeviceId: null,
726
+ revokeTarget: null,
727
+ sessionAction: null,
728
+ colorTarget: null,
729
+ collapsed: new Set(['__unmapped__']),
730
+ audit: [],
731
+ auditBefore: null,
732
+ auditHasMore: false,
733
+ activityStart: null,
734
+ activityEnd: null,
735
+ activityPresetDays: 90,
736
+ activityDeviceId: null,
737
+ activityDays: [],
738
+ sessionDayFilter: null,
739
+ activityDaySessionIds: null,
740
+ }
483
741
  let toastTimer = null
742
+ let updatedAt = 0
743
+ let pollTimer = null
744
+
745
+ const ICONS = {
746
+ devices: ['M2.5 3.5h8v6h-8z', 'M11.5 7h2v5.5h-2z', 'M4.5 12h4'],
747
+ sessions: ['M2 3.5h12v7H7l-3 2.5v-2.5H2z'],
748
+ storage: ['M3 3.5h10v3H3z', 'M3 6.5h10v3H3z', 'M3 9.5h10v3H3z'],
749
+ refresh: ['M13 8a5 5 0 1 1-1.5-3.5', 'M13 3.5V6h-2.5'],
750
+ copy: ['M6 5V3.5A1.5 1.5 0 0 1 7.5 2h5A1.5 1.5 0 0 1 14 3.5v5A1.5 1.5 0 0 1 12.5 10H11', 'M2 6.5A1.5 1.5 0 0 1 3.5 5h5A1.5 1.5 0 0 1 10 6.5v5A1.5 1.5 0 0 1 8.5 13h-5A1.5 1.5 0 0 1 2 11.5z'],
751
+ pencil: ['M11 2.5l2.5 2.5-8 8H3v-2.5z'],
752
+ chevron: ['M4 6l4 4 4-4'],
753
+ audit: ['M3 2.5h10v11H3z', 'M5.5 5.5h5', 'M5.5 8h5', 'M5.5 10.5h3'],
754
+ activity: ['M2.5 13.5h11', 'M3.5 11.5l3-4 2 2 3-5 2 2.5'],
755
+ }
756
+
757
+ function icon(name, size = 16) {
758
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
759
+ svg.setAttribute('viewBox', '0 0 16 16')
760
+ svg.setAttribute('width', String(size))
761
+ svg.setAttribute('height', String(size))
762
+ svg.setAttribute('fill', 'none')
763
+ svg.setAttribute('aria-hidden', 'true')
764
+ for (const d of ICONS[name] || []) {
765
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
766
+ path.setAttribute('d', d)
767
+ path.setAttribute('stroke', 'currentColor')
768
+ path.setAttribute('stroke-width', '1.5')
769
+ path.setAttribute('stroke-linecap', 'round')
770
+ path.setAttribute('stroke-linejoin', 'round')
771
+ svg.append(path)
772
+ }
773
+ return svg
774
+ }
484
775
 
485
776
  function el(tag, className, text) {
486
777
  const node = document.createElement(tag)
@@ -493,14 +784,44 @@ function formatBytes(value) {
493
784
  const bytes = Number(value) || 0
494
785
  if (bytes < 1024) return bytes + ' B'
495
786
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
496
- if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB'
497
- return (bytes / 1024 / 1024 / 1024).toFixed(2) + ' GiB'
787
+ if (bytes < GiB) return (bytes / 1024 / 1024).toFixed(1) + ' MB'
788
+ return (bytes / GiB).toFixed(2) + ' GiB'
498
789
  }
499
790
 
500
791
  function time(value) {
501
792
  return value === null || value === undefined ? '—' : new Date(value).toLocaleString()
502
793
  }
503
794
 
795
+ function formatAgo(value) {
796
+ if (value === null || value === undefined) return '从未'
797
+ const diff = Date.now() - value
798
+ if (diff < 0) return '刚刚'
799
+ if (diff < 60 * 1000) return '刚刚'
800
+ if (diff < 3600 * 1000) return Math.floor(diff / 60000) + ' 分钟前'
801
+ if (diff < 86400 * 1000) return Math.floor(diff / 3600000) + ' 小时前'
802
+ return Math.floor(diff / 86400000) + ' 天前'
803
+ }
804
+
805
+ function agoCell(value) {
806
+ const cell = el('td', 'muted', formatAgo(value))
807
+ cell.title = time(value)
808
+ return cell
809
+ }
810
+
811
+ const MODAL_EXIT_MS = 160
812
+
813
+ function closeModal(id) {
814
+ const node = $(id)
815
+ if (node === null || node.hidden || node.classList.contains('closing')) return
816
+ node.classList.add('closing')
817
+ setTimeout(() => {
818
+ node.hidden = true
819
+ node.classList.remove('closing')
820
+ const open = [...document.querySelectorAll('.modal')].some((modal) => !modal.hidden)
821
+ if (!open) document.body.classList.remove('modal-open')
822
+ }, MODAL_EXIT_MS)
823
+ }
824
+
504
825
  function showToast(text, bad) {
505
826
  const node = $('toast')
506
827
  node.textContent = text
@@ -538,15 +859,36 @@ async function api(path, options) {
538
859
  throw new Error('unauthorized')
539
860
  }
540
861
  const payload = await response.json().catch(() => ({}))
541
- if (!response.ok) throw new Error((payload && payload.error && payload.error.message) || '请求失败')
862
+ if (!response.ok) {
863
+ const error = new Error((payload && payload.error && payload.error.message) || '请求失败')
864
+ error.status = response.status
865
+ throw error
866
+ }
542
867
  return payload.value
543
868
  }
544
869
 
870
+ function isUninitialized(error) {
871
+ return error !== null && typeof error === 'object'
872
+ && error.status === 409
873
+ && /初始化/u.test(String(error.message || ''))
874
+ }
875
+
545
876
  function showLogin() {
546
877
  $('loginView').hidden = false
547
878
  $('appView').hidden = true
548
879
  $('loginError').textContent = ''
880
+ if (!$('passwordDialog').hidden) closePassword()
881
+ if (!$('sessionDialog').hidden) closeSessionAction()
882
+ if (!$('historyDialog').hidden) closeHistory()
883
+ if (!$('colorDialog').hidden) closeColorDialog()
549
884
  setTimeout(() => $('password').focus(), 30)
885
+ // 服务端数据被清空(开发 --clean 或换库)时,直接回初始化向导。
886
+ fetch('/setup/state', { credentials: 'same-origin' })
887
+ .then((response) => response.json())
888
+ .then((payload) => {
889
+ if (payload && payload.value && payload.value.initialized === false) location.replace('/setup')
890
+ })
891
+ .catch(() => {})
550
892
  }
551
893
 
552
894
  function showApp() {
@@ -557,14 +899,22 @@ function showApp() {
557
899
  function renderMetrics(overview) {
558
900
  const percent = overview.maxBytes > 0 ? Math.min(100, Math.round(overview.usedBytes / overview.maxBytes * 1000) / 10) : 0
559
901
  const items = [
560
- { key: '设备', value: overview.devices, hint: '已签发 token' },
561
- { key: '会话', value: overview.sessions, hint: overview.deletedSessions > 0 ? overview.deletedSessions + ' 个墓碑' : '没有墓碑' },
562
- { key: '已用存储', value: formatBytes(overview.usedBytes), hint: percent + '% · 配额 ' + formatBytes(overview.maxBytes) },
563
- { key: '同步版本', value: overview.revision, hint: '服务端 revision' },
902
+ { icon: 'devices', key: '设备', value: overview.devices, hint: '已签发接入密钥' },
903
+ { icon: 'sessions', key: '会话', value: overview.sessions, hint: overview.deletedSessions > 0 ? overview.deletedSessions + ' 个已删除' : '没有删除记录' },
904
+ { icon: 'storage', key: '已用存储', value: formatBytes(overview.usedBytes), hint: percent + '% · 配额 ' + formatBytes(overview.maxBytes), bar: percent },
564
905
  ]
565
906
  $('metrics').replaceChildren(...items.map((item) => {
566
907
  const box = el('div', 'metric')
567
- box.append(el('div', 'k', item.key), el('div', 'v', String(item.value)), el('div', 'hint', item.hint))
908
+ const head = el('div', 'k')
909
+ head.append(icon(item.icon, 13), el('span', null, item.key))
910
+ box.append(head, el('div', 'v', String(item.value)), el('div', 'hint', item.hint))
911
+ if (item.bar !== undefined) {
912
+ const bar = el('div', 'bar')
913
+ const fill = el('i', null)
914
+ fill.style.width = item.bar + '%'
915
+ bar.append(fill)
916
+ box.append(bar)
917
+ }
568
918
  return box
569
919
  }))
570
920
  $('version').textContent = 'v' + overview.version + ' · protocol ' + overview.protocol
@@ -572,9 +922,222 @@ function renderMetrics(overview) {
572
922
  $('storageUsed').textContent = formatBytes(overview.usedBytes)
573
923
  $('storageTotal').textContent = '/ ' + formatBytes(overview.maxBytes)
574
924
  $('storageBar').style.width = Math.min(100, percent) + '%'
575
- const bar = $('storageBar').parentElement
576
- bar.setAttribute('aria-valuenow', String(percent))
577
- $('quotaGb').value = String(Math.max(1, Math.round(overview.maxBytes / 1024 / 1024 / 1024)))
925
+ $('storageBar').parentElement.setAttribute('aria-valuenow', String(percent))
926
+ const quotaGb = Math.max(1, Math.round(overview.maxBytes / GiB))
927
+ $('quotaGb').value = String(quotaGb)
928
+ for (const button of $('quotaPresets').querySelectorAll('.preset')) {
929
+ const gb = Number(button.dataset.gb)
930
+ button.classList.toggle('on', gb === quotaGb)
931
+ }
932
+ }
933
+
934
+ function dateKey(date) {
935
+ const year = date.getFullYear()
936
+ const month = String(date.getMonth() + 1).padStart(2, '0')
937
+ const day = String(date.getDate()).padStart(2, '0')
938
+ return year + '-' + month + '-' + day
939
+ }
940
+
941
+ function parseDateInput(value) {
942
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/u.exec(String(value || ''))
943
+ if (match === null) return null
944
+ const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
945
+ return Number.isNaN(date.getTime()) ? null : date
946
+ }
947
+
948
+ function defaultActivityRange(days) {
949
+ const end = new Date()
950
+ end.setHours(0, 0, 0, 0)
951
+ const start = new Date(end)
952
+ start.setDate(start.getDate() - (days - 1))
953
+ return { start, end }
954
+ }
955
+
956
+ function activityThresholds(days) {
957
+ const counts = days.map((day) => day.events).filter((events) => events > 0).sort((left, right) => left - right)
958
+ if (counts.length === 0) return []
959
+ const pick = (quantile) => counts[Math.min(counts.length - 1, Math.floor(quantile * (counts.length - 1)))]
960
+ return [...new Set([pick(0.25), pick(0.5), pick(0.75)])]
961
+ }
962
+
963
+ function activityLevel(events, thresholds, maxEvents) {
964
+ if (events <= 0) return 0
965
+ if (maxEvents !== undefined && events >= maxEvents) return 4
966
+ let level = 1
967
+ for (const threshold of thresholds || []) {
968
+ if (events > threshold) level += 1
969
+ }
970
+ return Math.min(4, level)
971
+ }
972
+
973
+ function setActivityRange(start, end, presetDays) {
974
+ state.activityStart = start
975
+ state.activityEnd = end
976
+ state.activityPresetDays = presetDays
977
+ $('activityRangeButton').textContent = presetDays === null
978
+ ? dateKey(start) + ' → ' + dateKey(end)
979
+ : '最近 ' + presetDays + ' 天'
980
+ }
981
+
982
+ function openActivityRange() {
983
+ $('activityFrom').value = dateKey(state.activityStart)
984
+ $('activityTo').value = dateKey(state.activityEnd)
985
+ $('activityRangePopover').hidden = false
986
+ }
987
+
988
+ function closeActivityRange() {
989
+ $('activityRangePopover').hidden = true
990
+ }
991
+
992
+ function applyActivityRange() {
993
+ const start = parseDateInput($('activityFrom').value)
994
+ const end = parseDateInput($('activityTo').value)
995
+ if (start === null || end === null) { showToast('请选择有效的开始和结束日期', true); return }
996
+ if (start > end) { showToast('开始日期不能晚于结束日期', true); return }
997
+ setActivityRange(start, end, null)
998
+ closeActivityRange()
999
+ void loadActivity()
1000
+ }
1001
+
1002
+ function renderActivityDevices() {
1003
+ const wrap = $('activityDevices')
1004
+ const options = [{ id: null, label: '全部设备', color: '#94a3b8' }]
1005
+ for (const device of state.devices) {
1006
+ options.push({ id: device.deviceId, label: device.label || device.deviceId, color: device.color || historyDeviceColor(device.deviceId) })
1007
+ }
1008
+ wrap.replaceChildren(...options.map((option) => {
1009
+ const button = el('button', 'activity-device' + (state.activityDeviceId === option.id ? ' on' : ''))
1010
+ button.type = 'button'
1011
+ const dot = el('i')
1012
+ dot.style.background = option.color
1013
+ button.append(dot, el('span', null, option.label))
1014
+ button.onclick = () => {
1015
+ state.activityDeviceId = state.activityDeviceId === option.id ? null : option.id
1016
+ void loadActivity()
1017
+ }
1018
+ return button
1019
+ }))
1020
+ }
1021
+
1022
+ function renderActivity() {
1023
+ renderActivityDevices()
1024
+ const grid = $('activityGrid')
1025
+ if (state.activityStart === null || state.activityEnd === null) {
1026
+ grid.replaceChildren()
1027
+ return
1028
+ }
1029
+ const byDay = new Map(state.activityDays.map((day) => [day.day, day]))
1030
+ const thresholds = activityThresholds(state.activityDays)
1031
+ const maxEvents = state.activityDays.reduce((max, day) => Math.max(max, day.events), 0)
1032
+ const cells = []
1033
+ const startWeekday = (state.activityStart.getDay() + 6) % 7
1034
+ for (let index = 0; index < startWeekday; index += 1) cells.push(el('div', 'heat-cell heat-blank'))
1035
+ let totalEvents = 0
1036
+ let activeDays = 0
1037
+ for (let cursor = new Date(state.activityStart); cursor <= state.activityEnd; cursor.setDate(cursor.getDate() + 1)) {
1038
+ const key = dateKey(cursor)
1039
+ const data = byDay.get(key)
1040
+ const events = data === undefined ? 0 : data.events
1041
+ totalEvents += events
1042
+ if (events > 0) activeDays += 1
1043
+ const cell = el('div', 'heat-cell heat-level-' + activityLevel(events, thresholds, maxEvents))
1044
+ cell.setAttribute('aria-label', key + ':' + events + ' 事件')
1045
+ cell.title = data === undefined
1046
+ ? key + ' · 没有同步活动'
1047
+ : key + ' · ' + data.events + ' 事件 · ' + formatBytes(data.bytes) + ' · ' + data.appends + ' 次 · ' + data.sessions + ' 会话 · ' + data.devices + ' 设备'
1048
+ cell.dataset.day = key
1049
+ cell.onclick = () => { void toggleSessionDayFilter(key) }
1050
+ cells.push(cell)
1051
+ }
1052
+ grid.replaceChildren(...cells)
1053
+ const scope = state.activityDeviceId === null ? '全部设备' : historyDeviceLabel(state.activityDeviceId)
1054
+ $('activityNote').textContent = activeDays === 0
1055
+ ? scope + ' · 这个范围还没有同步活动'
1056
+ : scope + ' · ' + totalEvents + ' 事件 · ' + activeDays + ' 个活跃日 · 颜色按当前范围分档'
1057
+ const legend = document.querySelector('.heat-legend')
1058
+ if (legend !== null) {
1059
+ legend.title = thresholds.length === 0
1060
+ ? '没有活动时不分档'
1061
+ : '事件分档阈值:' + thresholds.join(' / ')
1062
+ }
1063
+ }
1064
+
1065
+ async function loadActivity() {
1066
+ if (state.activityStart === null || state.activityEnd === null) return
1067
+ const params = new URLSearchParams({
1068
+ from: String(state.activityStart.getTime()),
1069
+ to: String(state.activityEnd.getTime() + 24 * 60 * 60 * 1000),
1070
+ tzOffset: String(-new Date().getTimezoneOffset()),
1071
+ })
1072
+ if (state.activityDeviceId !== null) params.set('deviceId', state.activityDeviceId)
1073
+ try {
1074
+ const data = await api('/admin/activity?' + params.toString())
1075
+ state.activityDays = data.days
1076
+ renderActivity()
1077
+ } catch (error) {
1078
+ if (error.message !== 'unauthorized') {
1079
+ $('activityNote').textContent = '活动读取失败:' + error.message
1080
+ }
1081
+ }
1082
+ }
1083
+
1084
+ function updateSessionDayChip() {
1085
+ const chip = $('sessionDayChip')
1086
+ if (state.sessionDayFilter === null) {
1087
+ chip.hidden = true
1088
+ chip.textContent = ''
1089
+ return
1090
+ }
1091
+ chip.hidden = false
1092
+ chip.textContent = '活动日 ' + state.sessionDayFilter + ' ×'
1093
+ chip.title = '清除日期筛选'
1094
+ }
1095
+
1096
+ async function toggleSessionDayFilter(day) {
1097
+ if (state.sessionDayFilter === day) {
1098
+ state.sessionDayFilter = null
1099
+ state.activityDaySessionIds = null
1100
+ updateSessionDayChip()
1101
+ renderSessions()
1102
+ return
1103
+ }
1104
+ try {
1105
+ const params = new URLSearchParams({
1106
+ day,
1107
+ tzOffset: String(-new Date().getTimezoneOffset()),
1108
+ })
1109
+ const data = await api('/admin/activity/sessions?' + params.toString())
1110
+ state.sessionDayFilter = day
1111
+ state.activityDaySessionIds = new Set(data.sessions.map((session) => session.sessionId))
1112
+ updateSessionDayChip()
1113
+ renderSessions()
1114
+ } catch (error) {
1115
+ if (error.message !== 'unauthorized') showToast(error.message, true)
1116
+ }
1117
+ }
1118
+
1119
+ function deviceOnline(device) {
1120
+ return device.lastSeenAt !== null && device.lastSeenAt !== undefined
1121
+ && Date.now() - device.lastSeenAt < 5 * 60 * 1000
1122
+ }
1123
+
1124
+ function deviceStatus(device) {
1125
+ if (device.lastSeenAt === null || device.lastSeenAt === undefined) return { label: '从未在线', idle: true }
1126
+ if (deviceOnline(device)) return { label: '在线', idle: false }
1127
+ return { label: '离线', idle: true }
1128
+ }
1129
+
1130
+ function deviceInitial(device) {
1131
+ const label = String(device.label || device.deviceId || '?').trim()
1132
+ return label === '' ? '?' : label.slice(0, 1).toUpperCase()
1133
+ }
1134
+
1135
+ function readableOn(hex) {
1136
+ const color = /^#[0-9a-f]{6}$/iu.test(String(hex)) ? String(hex).slice(1) : '5b88ff'
1137
+ const r = parseInt(color.slice(0, 2), 16)
1138
+ const g = parseInt(color.slice(2, 4), 16)
1139
+ const b = parseInt(color.slice(4, 6), 16)
1140
+ return (r * 299 + g * 587 + b * 114) / 1000 > 150 ? '#10131a' : '#ffffff'
578
1141
  }
579
1142
 
580
1143
  function renderDevices(devices) {
@@ -582,7 +1145,9 @@ function renderDevices(devices) {
582
1145
  const list = $('devices')
583
1146
  if (devices.length === 0) {
584
1147
  const empty = el('div', 'empty')
585
- empty.append(el('b', null, '还没有设备'), el('span', null, '签发一个 token,把 DSH 插件接到这台服务器。'))
1148
+ const mark = el('div', 'icon')
1149
+ mark.append(icon('devices', 22))
1150
+ empty.append(mark, el('b', null, '还没有设备'), el('span', null, '签发一把接入密钥,把 DSH 插件接到这台服务器。'))
586
1151
  list.replaceChildren(empty)
587
1152
  return
588
1153
  }
@@ -590,72 +1155,611 @@ function renderDevices(devices) {
590
1155
  const row = el('div', 'device-row')
591
1156
  const main = el('div', 'device-main')
592
1157
  const label = el('div', 'device-label')
593
- label.append(el('span', null, device.label || 'device'), el('span', 'badge good', '有效'))
1158
+ const avatarColor = device.color || historyDeviceColor(device.deviceId)
1159
+ const colorButton = el('button', 'device-color-btn')
1160
+ colorButton.type = 'button'
1161
+ colorButton.title = '修改设备颜色'
1162
+ colorButton.setAttribute('aria-label', '修改设备颜色')
1163
+ const avatar = el('span', 'device-avatar', deviceInitial(device))
1164
+ avatar.style.background = avatarColor
1165
+ avatar.style.color = readableOn(avatarColor)
1166
+ colorButton.append(avatar)
1167
+ colorButton.onclick = () => openColorDialog(device)
1168
+ const status = deviceStatus(device)
1169
+ const statusChip = el('span', 'device-status' + (status.idle ? '' : ' online'))
1170
+ statusChip.append(el('i', 'dot' + (status.idle ? ' idle' : ' good')), el('span', null, status.label))
1171
+ statusChip.title = device.lastSeenAt === null || device.lastSeenAt === undefined
1172
+ ? '从未在线'
1173
+ : '最后在线:' + time(device.lastSeenAt)
1174
+ label.append(colorButton)
1175
+ if (state.editingDeviceId === device.deviceId) {
1176
+ const form = el('div', 'inline-edit')
1177
+ const input = el('input')
1178
+ input.value = device.label || 'device'
1179
+ input.maxLength = 64
1180
+ input.setAttribute('aria-label', '设备名称')
1181
+ const save = el('button', 'btn small', '保存')
1182
+ save.type = 'button'
1183
+ save.onclick = () => saveDeviceLabel(device, input.value)
1184
+ const cancel = el('button', 'btn small ghost', '取消')
1185
+ cancel.type = 'button'
1186
+ cancel.onclick = () => { state.editingDeviceId = null; renderDevices(state.devices) }
1187
+ form.append(input, save, cancel)
1188
+ label.append(form, statusChip)
1189
+ input.addEventListener('keydown', (event) => {
1190
+ if (event.key === 'Enter') save.click()
1191
+ if (event.key === 'Escape') cancel.click()
1192
+ })
1193
+ setTimeout(() => input.focus(), 0)
1194
+ } else {
1195
+ label.append(el('b', null, device.label || 'device'), statusChip)
1196
+ }
594
1197
  const idLine = el('div', 'device-id')
595
- idLine.append(el('code', null, device.deviceId))
596
- const copy = el('button', 'row-copy', '复制')
1198
+ const copy = el('button', 'row-copy')
597
1199
  copy.type = 'button'
1200
+ copy.title = '复制设备 ID'
1201
+ copy.append(icon('copy', 11))
598
1202
  copy.onclick = () => copyText(device.deviceId)
599
- idLine.append(copy)
1203
+ idLine.append(el('code', null, device.deviceId), copy)
600
1204
  const meta = el('div', 'device-meta')
601
- meta.append(
602
- el('span', null, '创建 ' + time(device.createdAt)),
603
- el('span', null, '最后在线 ' + (device.lastSeenAt === null || device.lastSeenAt === undefined ? '从未' : time(device.lastSeenAt))),
604
- )
1205
+ const created = el('span', null, '创建 ' + formatAgo(device.createdAt))
1206
+ created.title = time(device.createdAt)
1207
+ const seen = el('span', null, '最后在线 ' + formatAgo(device.lastSeenAt))
1208
+ seen.title = time(device.lastSeenAt)
1209
+ meta.append(created, seen)
605
1210
  main.append(label, idLine, meta)
606
1211
  const actions = el('div', 'device-actions')
607
- const rename = el('button', 'btn small', '改名')
1212
+ const rename = el('button', 'icon-btn')
608
1213
  rename.type = 'button'
609
- rename.onclick = async () => {
610
- const next = prompt('新设备名(1–64 个字符)', device.label || 'device')
611
- if (next === null) return
612
- const label = next.trim()
613
- if (label === '') {
614
- showToast('设备名不能为空', true)
615
- return
616
- }
617
- rename.disabled = true
618
- rename.textContent = '改名中…'
619
- try {
620
- await api('/admin/devices/' + encodeURIComponent(device.deviceId), {
621
- method: 'PATCH',
622
- body: JSON.stringify({ label }),
623
- })
624
- showToast('设备已改名')
625
- await refresh(true)
626
- } catch (error) {
627
- showToast(error.message, true)
628
- rename.disabled = false
629
- rename.textContent = '改名'
630
- }
631
- }
632
- const revoke = el('button', 'btn small danger', '吊销')
1214
+ rename.append(icon('pencil', 12), el('span', null, '改名'))
1215
+ rename.onclick = () => { state.editingDeviceId = device.deviceId; renderDevices(state.devices) }
1216
+ const revoke = el('button', 'icon-btn danger')
633
1217
  revoke.type = 'button'
634
- revoke.onclick = async () => {
635
- if (!confirm('吊销后该设备 token 立即失效,确定?')) return
636
- revoke.disabled = true
637
- revoke.textContent = '吊销中…'
638
- try {
639
- await api('/admin/devices/' + encodeURIComponent(device.deviceId), { method: 'DELETE' })
640
- showToast('设备已吊销')
641
- await refresh(true)
642
- } catch (error) {
643
- showToast(error.message, true)
644
- revoke.disabled = false
645
- revoke.textContent = '吊销'
646
- }
647
- }
1218
+ revoke.append(el('span', null, '吊销'))
1219
+ revoke.onclick = () => openRevoke(device)
648
1220
  actions.append(rename, revoke)
649
1221
  row.append(main, actions)
650
1222
  return row
651
1223
  }))
652
1224
  }
653
1225
 
1226
+ async function saveDeviceLabel(device, raw) {
1227
+ const label = String(raw || '').trim()
1228
+ if (label === '') { showToast('设备名不能为空', true); return }
1229
+ try {
1230
+ await api('/admin/devices/' + encodeURIComponent(device.deviceId), {
1231
+ method: 'PATCH',
1232
+ body: JSON.stringify({ label }),
1233
+ })
1234
+ state.editingDeviceId = null
1235
+ showToast('设备已改名')
1236
+ await refresh(true)
1237
+ } catch (error) {
1238
+ showToast(error.message, true)
1239
+ }
1240
+ }
1241
+
1242
+ function openRevoke(device) {
1243
+ state.revokeTarget = device.deviceId
1244
+ $('revokeText').textContent = '吊销「' + (device.label || 'device') + '」后,它的接入密钥立即失效;该设备将无法再推送或拉取。'
1245
+ $('revokeDialog').classList.remove('closing')
1246
+ $('revokeDialog').hidden = false
1247
+ document.body.classList.add('modal-open')
1248
+ setTimeout(() => $('confirmRevoke').focus(), 30)
1249
+ }
1250
+
1251
+ function closeRevoke() {
1252
+ state.revokeTarget = null
1253
+ closeModal('revokeDialog')
1254
+ }
1255
+
1256
+ function openPassword() {
1257
+ $('currentPassword').value = ''
1258
+ $('newPassword').value = ''
1259
+ $('confirmPassword').value = ''
1260
+ $('passwordError').textContent = ''
1261
+ $('passwordDialog').classList.remove('closing')
1262
+ $('passwordDialog').hidden = false
1263
+ document.body.classList.add('modal-open')
1264
+ setTimeout(() => $('currentPassword').focus(), 30)
1265
+ }
1266
+
1267
+ function closePassword() {
1268
+ closeModal('passwordDialog')
1269
+ }
1270
+
1271
+ const SESSION_ACTIONS = {
1272
+ archive: {
1273
+ title: '存档会话',
1274
+ text: '停止同步,但保留服务端保存的完整数据;之后可以恢复。其他设备上的本地副本不会被删除。',
1275
+ confirm: '存档',
1276
+ danger: false,
1277
+ },
1278
+ restore: {
1279
+ title: '恢复会话',
1280
+ text: '让这个会话重新参与同步。其他设备上的副本可能与服务端版本不一致,请确认。',
1281
+ confirm: '恢复',
1282
+ danger: false,
1283
+ },
1284
+ remove: {
1285
+ title: '移除并释放空间',
1286
+ text: '停止同步并永久删除服务端保存的数据,以释放空间;不可恢复。其他设备上的本地副本仍会保留。',
1287
+ confirm: '移除并释放',
1288
+ danger: true,
1289
+ },
1290
+ reset: {
1291
+ title: '重置远端会话',
1292
+ text: '删除服务端保存的这个会话及删除标记,允许指定设备把本地完整副本重新上传。会中断正在进行的同步;请先让目标设备同步。',
1293
+ confirm: '重置',
1294
+ danger: true,
1295
+ },
1296
+ 'clear-reset': {
1297
+ title: '取消重置',
1298
+ text: '清除等待重建状态;之后任意设备都可以重新上传这个会话。',
1299
+ confirm: '取消重置',
1300
+ danger: false,
1301
+ },
1302
+ }
1303
+
1304
+ function openSessionAction(session, action) {
1305
+ const info = SESSION_ACTIONS[action]
1306
+ if (info === undefined) return
1307
+ state.sessionAction = { session, action }
1308
+ $('sessionActionTitle').textContent = info.title
1309
+ $('sessionActionText').textContent = info.text
1310
+ $('sessionActionError').textContent = ''
1311
+ const field = $('sessionResetField')
1312
+ const select = $('sessionResetDevice')
1313
+ const confirm = $('confirmSessionAction')
1314
+ confirm.className = 'btn' + (info.danger ? ' danger' : '')
1315
+ confirm.textContent = info.confirm
1316
+ confirm.disabled = false
1317
+ if (action === 'reset') {
1318
+ field.hidden = false
1319
+ select.replaceChildren()
1320
+ for (const device of state.devices) {
1321
+ const option = el('option', null, device.label || device.deviceId)
1322
+ option.value = device.deviceId
1323
+ select.append(option)
1324
+ }
1325
+ if (state.devices.length === 0) {
1326
+ const option = el('option', null, '没有可用设备')
1327
+ option.value = ''
1328
+ select.append(option)
1329
+ $('sessionActionError').textContent = '没有可指定的设备;请先创建设备接入密钥'
1330
+ confirm.disabled = true
1331
+ }
1332
+ } else {
1333
+ field.hidden = true
1334
+ }
1335
+ $('sessionDialog').classList.remove('closing')
1336
+ $('sessionDialog').hidden = false
1337
+ document.body.classList.add('modal-open')
1338
+ setTimeout(() => confirm.focus(), 30)
1339
+ }
1340
+
1341
+ function closeSessionAction() {
1342
+ state.sessionAction = null
1343
+ closeModal('sessionDialog')
1344
+ }
1345
+
1346
+ function normalizeColor(value) {
1347
+ const color = String(value || '').trim().toLowerCase()
1348
+ return /^#[0-9a-f]{6}$/u.test(color) ? color : null
1349
+ }
1350
+
1351
+ function applyColorDraft(value) {
1352
+ const color = normalizeColor(value)
1353
+ if (color === null) return
1354
+ $('deviceColorPicker').value = color
1355
+ $('deviceColorHex').value = color
1356
+ $('colorPreviewAvatar').style.background = color
1357
+ $('colorPreviewAvatar').style.color = readableOn(color)
1358
+ for (const button of $('deviceColorPresets').querySelectorAll('.color-preset')) {
1359
+ button.classList.toggle('on', button.dataset.color === color)
1360
+ }
1361
+ }
1362
+
1363
+ function openColorDialog(device) {
1364
+ state.colorTarget = device
1365
+ $('colorDeviceName').textContent = device.label || device.deviceId
1366
+ $('colorPreviewName').textContent = device.label || device.deviceId
1367
+ $('colorPreviewAvatar').textContent = deviceInitial(device)
1368
+ $('colorError').textContent = ''
1369
+ $('deviceColorPresets').replaceChildren(...DEVICE_COLOR_PRESETS.map((color) => {
1370
+ const button = el('button', 'color-preset')
1371
+ button.type = 'button'
1372
+ button.dataset.color = color
1373
+ button.style.background = color
1374
+ button.title = color
1375
+ button.onclick = () => applyColorDraft(color)
1376
+ return button
1377
+ }))
1378
+ applyColorDraft(device.color || historyDeviceColor(device.deviceId))
1379
+ $('colorDialog').classList.remove('closing')
1380
+ $('colorDialog').hidden = false
1381
+ document.body.classList.add('modal-open')
1382
+ setTimeout(() => $('deviceColorPicker').focus(), 30)
1383
+ }
1384
+
1385
+ function closeColorDialog() {
1386
+ state.colorTarget = null
1387
+ closeModal('colorDialog')
1388
+ }
1389
+
1390
+ async function saveDeviceColor() {
1391
+ const target = state.colorTarget
1392
+ if (target === null) return
1393
+ const color = normalizeColor($('deviceColorHex').value)
1394
+ if (color === null) {
1395
+ $('colorError').textContent = '请输入 #rrggbb 格式的颜色'
1396
+ return
1397
+ }
1398
+ const button = $('saveColor')
1399
+ button.disabled = true
1400
+ button.textContent = '保存中…'
1401
+ try {
1402
+ await api('/admin/devices/' + encodeURIComponent(target.deviceId), {
1403
+ method: 'PATCH',
1404
+ body: JSON.stringify({ color }),
1405
+ })
1406
+ closeColorDialog()
1407
+ showToast('设备颜色已更新')
1408
+ await refresh(true)
1409
+ } catch (error) {
1410
+ $('colorError').textContent = error.message
1411
+ } finally {
1412
+ button.disabled = false
1413
+ button.textContent = '保存颜色'
1414
+ }
1415
+ }
1416
+
1417
+ function switchHistoryPane(name) {
1418
+ for (const tab of document.querySelectorAll('.history-tab')) {
1419
+ tab.classList.toggle('on', tab.dataset.pane === name)
1420
+ }
1421
+ for (const pane of ['timeline', 'devices', 'heatmap']) {
1422
+ const node = $('history' + pane[0].toUpperCase() + pane.slice(1) + 'Pane')
1423
+ if (node !== null) node.hidden = pane !== name
1424
+ }
1425
+ }
1426
+
1427
+ function renderHistoryGantt(events) {
1428
+ const wrap = $('historyGantt')
1429
+ const byDevice = new Map()
1430
+ for (const event of events) {
1431
+ if ((event.kind !== 'create' && event.kind !== 'append') || !event.deviceId) continue
1432
+ const entry = byDevice.get(event.deviceId) || {
1433
+ deviceId: event.deviceId,
1434
+ label: event.deviceLabel || historyDeviceLabel(event.deviceId),
1435
+ color: event.deviceColor || historyDeviceColor(event.deviceId),
1436
+ firstAt: event.at,
1437
+ lastAt: event.at,
1438
+ events: 0,
1439
+ bytes: 0,
1440
+ }
1441
+ entry.firstAt = Math.min(entry.firstAt, event.at)
1442
+ entry.lastAt = Math.max(entry.lastAt, event.at)
1443
+ entry.events += event.events
1444
+ entry.bytes += event.bytes
1445
+ byDevice.set(event.deviceId, entry)
1446
+ }
1447
+ if (byDevice.size === 0) {
1448
+ wrap.replaceChildren(el('div', 'history-empty', '还没有设备活动。'))
1449
+ return
1450
+ }
1451
+ const entries = [...byDevice.values()]
1452
+ const minAt = Math.min(...entries.map((entry) => entry.firstAt))
1453
+ const maxAt = Math.max(...entries.map((entry) => entry.lastAt))
1454
+ const span = Math.max(1, maxAt - minAt)
1455
+ wrap.replaceChildren(...entries.map((entry) => {
1456
+ const row = el('div', 'history-gantt-row')
1457
+ const label = el('div', 'history-gantt-label')
1458
+ const dot = el('i')
1459
+ dot.style.background = entry.color
1460
+ label.append(dot, el('span', null, entry.label))
1461
+ const track = el('div', 'history-gantt-track')
1462
+ const bar = el('div', 'history-gantt-bar')
1463
+ bar.style.background = entry.color
1464
+ bar.style.left = ((entry.firstAt - minAt) / span * 100) + '%'
1465
+ bar.style.width = Math.max(2, (entry.lastAt - entry.firstAt) / span * 100) + '%'
1466
+ bar.title = entry.label + ' · ' + formatAgo(entry.firstAt) + ' → ' + formatAgo(entry.lastAt) + ' · ' + entry.events + ' 事件'
1467
+ track.append(bar)
1468
+ row.append(label, track)
1469
+ return row
1470
+ }))
1471
+ }
1472
+
1473
+ async function renderHistoryHeatmap(session) {
1474
+ const grid = $('historyHeatmap')
1475
+ grid.replaceChildren(el('div', 'history-empty', '正在读取…'))
1476
+ const end = new Date()
1477
+ end.setHours(0, 0, 0, 0)
1478
+ const start = new Date(end)
1479
+ start.setDate(start.getDate() - 89)
1480
+ const params = new URLSearchParams({
1481
+ from: String(start.getTime()),
1482
+ to: String(end.getTime() + 24 * 60 * 60 * 1000),
1483
+ tzOffset: String(-new Date().getTimezoneOffset()),
1484
+ sessionId: session.sessionId,
1485
+ })
1486
+ try {
1487
+ const data = await api('/admin/activity?' + params.toString())
1488
+ const byDay = new Map(data.days.map((day) => [day.day, day]))
1489
+ const thresholds = activityThresholds(data.days)
1490
+ const maxEvents = data.days.reduce((max, day) => Math.max(max, day.events), 0)
1491
+ const cells = []
1492
+ const startWeekday = (start.getDay() + 6) % 7
1493
+ for (let index = 0; index < startWeekday; index += 1) cells.push(el('div', 'heat-cell heat-blank'))
1494
+ for (let cursor = new Date(start); cursor <= end; cursor.setDate(cursor.getDate() + 1)) {
1495
+ const key = dateKey(cursor)
1496
+ const day = byDay.get(key)
1497
+ const events = day === undefined ? 0 : day.events
1498
+ const cell = el('div', 'heat-cell heat-level-' + activityLevel(events, thresholds, maxEvents))
1499
+ cell.title = key + ' · ' + events + ' 事件' + (day === undefined ? '' : ' · ' + formatBytes(day.bytes))
1500
+ cells.push(cell)
1501
+ }
1502
+ grid.replaceChildren(...cells)
1503
+ } catch (error) {
1504
+ if (error.message !== 'unauthorized') grid.replaceChildren(el('div', 'history-empty', '热力图读取失败:' + error.message))
1505
+ }
1506
+ }
1507
+
1508
+ function openHistory(session) {
1509
+ $('historySession').textContent = shortDisplayId(session.sessionId) + (session.workspaceKey ? ' · 工作区 ' + session.workspaceKey.slice(0, 12) + '…' : '')
1510
+ $('historySummary').replaceChildren()
1511
+ $('historyList').replaceChildren(el('div', 'history-empty', '正在读取历史…'))
1512
+ $('historyError').textContent = ''
1513
+ switchHistoryPane('timeline')
1514
+ $('historyDialog').classList.remove('closing')
1515
+ $('historyDialog').hidden = false
1516
+ document.body.classList.add('modal-open')
1517
+ void loadHistory(session)
1518
+ }
1519
+
1520
+ function closeHistory() {
1521
+ closeModal('historyDialog')
1522
+ }
1523
+
1524
+ function shortDisplayId(id) {
1525
+ return id.length > 32 ? id.slice(0, 30) + '…' : id
1526
+ }
1527
+
1528
+ function historyEventLabel(kind) {
1529
+ const labels = {
1530
+ create: '创建并上传',
1531
+ append: '更新',
1532
+ 'device-delete': '设备删除',
1533
+ archive: '存档',
1534
+ remove: '移除',
1535
+ reset: '重置',
1536
+ restore: '恢复',
1537
+ }
1538
+ return labels[kind] || kind
1539
+ }
1540
+
1541
+ const DEVICE_COLOR_PRESETS = ['#5b88ff', '#5ed392', '#f0b64a', '#c084fc', '#38bdf8', '#f472b6', '#a3e635', '#fb923c', '#94a3b8', '#f87171']
1542
+ const HISTORY_DEVICE_COLORS = DEVICE_COLOR_PRESETS
1543
+
1544
+ function historyDeviceColor(id) {
1545
+ let hash = 0
1546
+ for (const char of String(id || '')) hash = (hash * 31 + char.charCodeAt(0)) | 0
1547
+ return HISTORY_DEVICE_COLORS[Math.abs(hash) % HISTORY_DEVICE_COLORS.length]
1548
+ }
1549
+
1550
+ function historyDeviceLabel(id) {
1551
+ if (!id) return '管理台'
1552
+ const device = state.devices.find((item) => item.deviceId === id)
1553
+ return device ? device.label || device.deviceId : id
1554
+ }
1555
+
1556
+ function historyEventDetail(event) {
1557
+ const parts = []
1558
+ if (event.kind === 'create' || event.kind === 'append') {
1559
+ if (event.events > 0) parts.push('+' + event.events + ' 事件')
1560
+ if (event.bytes > 0) parts.push(formatBytes(event.bytes))
1561
+ if (event.batchCount > 0) parts.push(event.batchCount + ' 批')
1562
+ if (event.seqFrom !== null && event.seqTo !== null) parts.push('seq ' + event.seqFrom + '–' + event.seqTo)
1563
+ if (event.detail && event.detail.tailReplaced === true) parts.push('替换尾部')
1564
+ } else if (event.kind === 'reset') {
1565
+ const deviceId = event.detail && typeof event.detail === 'object' ? event.detail.deviceId : null
1566
+ if (deviceId) parts.push('指定 ' + historyDeviceLabel(deviceId))
1567
+ if (event.bytes > 0) parts.push('释放 ' + formatBytes(event.bytes))
1568
+ } else if (event.kind === 'remove') {
1569
+ if (event.bytes > 0) parts.push('释放 ' + formatBytes(event.bytes))
1570
+ }
1571
+ if (event.detail && event.detail.backfilled === true) parts.push('回填')
1572
+ return parts.join(' · ')
1573
+ }
1574
+
1575
+ function historyRunDetail(run) {
1576
+ const parts = []
1577
+ if (run.events > 0) parts.push('+' + run.events + ' 事件')
1578
+ if (run.bytes > 0) parts.push(formatBytes(run.bytes))
1579
+ if (run.batches > 0) parts.push(run.batches + ' 批')
1580
+ if (run.seqFrom !== null && run.seqTo !== null) parts.push('seq ' + run.seqFrom + '–' + run.seqTo)
1581
+ return parts.join(' · ')
1582
+ }
1583
+
1584
+ async function loadHistory(session) {
1585
+ try {
1586
+ const data = await api('/admin/sessions/' + encodeURIComponent(session.sessionId) + '/history?limit=200')
1587
+ const summary = data.summary
1588
+ const chips = [
1589
+ '总事件 ' + summary.events,
1590
+ '总批次 ' + summary.batches,
1591
+ '总大小 ' + formatBytes(summary.bytes),
1592
+ '设备 ' + summary.devices,
1593
+ summary.firstAt !== null ? '首次 ' + formatAgo(summary.firstAt) : '还没有事件',
1594
+ summary.lastAt !== null ? '最后 ' + formatAgo(summary.lastAt) : '',
1595
+ ].filter((chip) => chip !== '')
1596
+ const deviceLegend = new Map()
1597
+ for (const event of data.events) {
1598
+ if (event.deviceId && !deviceLegend.has(event.deviceId)) {
1599
+ deviceLegend.set(event.deviceId, {
1600
+ label: event.deviceLabel || historyDeviceLabel(event.deviceId),
1601
+ color: event.deviceColor || historyDeviceColor(event.deviceId),
1602
+ })
1603
+ }
1604
+ }
1605
+ function deviceLegendColor(deviceId) {
1606
+ return deviceLegend.get(deviceId)?.color || historyDeviceColor(deviceId)
1607
+ }
1608
+ $('historySummary').replaceChildren(
1609
+ ...chips.map((chip) => el('span', 'history-chip', chip)),
1610
+ ...[...deviceLegend.entries()].map(([deviceId, entry]) => {
1611
+ const chip = el('span', 'history-chip')
1612
+ const dot = el('i')
1613
+ dot.style.background = deviceLegendColor(deviceId)
1614
+ chip.append(dot, el('span', null, entry.label))
1615
+ return chip
1616
+ }),
1617
+ )
1618
+ const list = $('historyList')
1619
+ if (data.events.length === 0) {
1620
+ list.replaceChildren(el('div', 'history-empty', '还没有这个会话的历史记录。'))
1621
+ renderHistoryGantt([])
1622
+ void renderHistoryHeatmap(session)
1623
+ return
1624
+ }
1625
+ const ordered = [...data.events].reverse()
1626
+ const rows = []
1627
+ let run = null
1628
+ for (const event of ordered) {
1629
+ if (event.kind === 'create') {
1630
+ if (run !== null) { rows.push(run); run = null }
1631
+ rows.push({ kind: 'event', event })
1632
+ continue
1633
+ }
1634
+ if (event.kind === 'append') {
1635
+ if (run !== null && run.deviceId === event.deviceId) {
1636
+ run.events += event.events
1637
+ run.bytes += event.bytes
1638
+ run.batches += event.batchCount
1639
+ run.lastAt = event.at
1640
+ if (event.seqFrom !== null) run.seqFrom = run.seqFrom === null ? event.seqFrom : Math.min(run.seqFrom, event.seqFrom)
1641
+ if (event.seqTo !== null) run.seqTo = run.seqTo === null ? event.seqTo : Math.max(run.seqTo, event.seqTo)
1642
+ run.last = event
1643
+ continue
1644
+ }
1645
+ if (run !== null) rows.push(run)
1646
+ run = {
1647
+ kind: 'run', deviceId: event.deviceId, first: event, last: event,
1648
+ events: event.events, bytes: event.bytes, batches: event.batchCount,
1649
+ seqFrom: event.seqFrom, seqTo: event.seqTo, firstAt: event.at, lastAt: event.at,
1650
+ }
1651
+ continue
1652
+ }
1653
+ if (run !== null) { rows.push(run); run = null }
1654
+ rows.push({ kind: 'event', event })
1655
+ }
1656
+ if (run !== null) rows.push(run)
1657
+ list.replaceChildren(...rows.map((row) => {
1658
+ if (row.kind === 'run') {
1659
+ const timeCell = el('div', 'history-time', formatAgo(row.firstAt))
1660
+ timeCell.title = time(row.firstAt) + ' → ' + time(row.lastAt)
1661
+ const main = el('div', 'history-main')
1662
+ main.append(
1663
+ el('div', 'history-title', historyEventLabel(row.first.kind) + ' · ' + historyDeviceLabel(row.deviceId)),
1664
+ el('div', 'history-detail', historyRunDetail(row)),
1665
+ )
1666
+ const rail = el('div', 'history-rail')
1667
+ const dot = el('i', 'history-dot')
1668
+ dot.style.setProperty('--dot-color', row.first.deviceColor || historyDeviceColor(row.deviceId))
1669
+ rail.append(dot)
1670
+ const item = el('div', 'history-item')
1671
+ item.append(timeCell, rail, main)
1672
+ return item
1673
+ }
1674
+ const event = row.event
1675
+ const timeCell = el('div', 'history-time', formatAgo(event.at))
1676
+ timeCell.title = time(event.at)
1677
+ const main = el('div', 'history-main')
1678
+ const title = historyEventLabel(event.kind) + (event.kind === 'reset' || event.kind === 'archive' || event.kind === 'remove' || event.kind === 'restore' ? ' · 管理台' : ' · ' + historyDeviceLabel(event.deviceId))
1679
+ main.append(el('div', 'history-title', title))
1680
+ const detail = historyEventDetail(event)
1681
+ if (detail !== '') main.append(el('div', 'history-detail', detail))
1682
+ const className = event.kind === 'remove' || event.kind === 'reset' || event.kind === 'device-delete' ? 'history-item gone' : event.kind === 'archive' || event.kind === 'restore' ? 'history-item admin' : 'history-item'
1683
+ const rail = el('div', 'history-rail')
1684
+ const dot = el('i', 'history-dot')
1685
+ if (event.kind === 'create' || event.kind === 'append') dot.style.setProperty('--dot-color', event.deviceColor || historyDeviceColor(event.deviceId))
1686
+ rail.append(dot)
1687
+ const item = el('div', className)
1688
+ item.append(timeCell, rail, main)
1689
+ return item
1690
+ }))
1691
+ renderHistoryGantt(data.events)
1692
+ void renderHistoryHeatmap(session)
1693
+ } catch (error) {
1694
+ $('historyError').textContent = error.message
1695
+ }
1696
+ }
1697
+
1698
+ async function confirmSessionAction() {
1699
+ const pending = state.sessionAction
1700
+ if (pending === null) return
1701
+ const { session, action } = pending
1702
+ const button = $('confirmSessionAction')
1703
+ $('sessionActionError').textContent = ''
1704
+ button.disabled = true
1705
+ const label = button.textContent
1706
+ button.textContent = '处理中…'
1707
+ try {
1708
+ const body = { createdAt: session.createdAt }
1709
+ if (action === 'reset') {
1710
+ const deviceId = $('sessionResetDevice').value
1711
+ if (deviceId === '') throw new Error('请选择一台设备')
1712
+ body.deviceId = deviceId
1713
+ }
1714
+ await api('/admin/sessions/' + encodeURIComponent(session.sessionId) + '/' + action, {
1715
+ method: 'POST',
1716
+ body: JSON.stringify(body),
1717
+ })
1718
+ closeSessionAction()
1719
+ showToast('会话操作完成')
1720
+ await refresh(true)
1721
+ await loadAudit(true)
1722
+ } catch (error) {
1723
+ $('sessionActionError').textContent = error.message
1724
+ } finally {
1725
+ button.disabled = false
1726
+ button.textContent = label
1727
+ }
1728
+ }
1729
+
1730
+ async function savePassword() {
1731
+ const current = $('currentPassword').value
1732
+ const next = $('newPassword').value
1733
+ const confirm = $('confirmPassword').value
1734
+ $('passwordError').textContent = ''
1735
+ if (current === '') { $('passwordError').textContent = '请输入当前密码'; return }
1736
+ if (next !== confirm) { $('passwordError').textContent = '两次输入的新密码不一致'; return }
1737
+ if (next === '') { $('passwordError').textContent = '请输入新密码'; return }
1738
+ const button = $('savePassword')
1739
+ button.disabled = true
1740
+ button.textContent = '保存中…'
1741
+ try {
1742
+ await api('/admin/password', {
1743
+ method: 'POST',
1744
+ body: JSON.stringify({ currentPassword: current, newPassword: next }),
1745
+ })
1746
+ closePassword()
1747
+ showToast('管理密码已修改')
1748
+ await loadAudit(true)
1749
+ } catch (error) {
1750
+ $('passwordError').textContent = error.message
1751
+ } finally {
1752
+ button.disabled = false
1753
+ button.textContent = '保存'
1754
+ }
1755
+ }
1756
+
654
1757
  function filteredSessions() {
655
1758
  const query = $('sessionSearch').value.trim().toLowerCase()
656
1759
  const mode = $('sessionFilter').value
657
1760
  return state.sessions.filter((session) => {
658
- if (query && !session.sessionId.toLowerCase().includes(query)) return false
1761
+ if (state.activityDaySessionIds !== null && !state.activityDaySessionIds.has(session.sessionId)) return false
1762
+ if (query && !session.sessionId.toLowerCase().includes(query) && !(session.workspaceKey || '').toLowerCase().includes(query)) return false
659
1763
  if (mode === 'active' && session.deletedAt !== null) return false
660
1764
  if (mode === 'deleted' && session.deletedAt === null) return false
661
1765
  return true
@@ -664,42 +1768,125 @@ function filteredSessions() {
664
1768
 
665
1769
  function sessionRow(session) {
666
1770
  const row = el('tr')
1771
+ const resetting = session.resetExpiresAt !== null && session.resetExpiresAt !== undefined && session.resetExpiresAt > Date.now()
667
1772
  const idCell = el('td', 'session-id')
668
1773
  idCell.append(el('code', null, session.sessionId))
669
- const copy = el('button', 'row-copy', '复制')
1774
+ const copy = el('button', 'row-copy')
670
1775
  copy.type = 'button'
1776
+ copy.title = '复制 session id'
1777
+ copy.append(icon('copy', 11))
671
1778
  copy.onclick = () => copyText(session.sessionId)
672
1779
  idCell.append(copy)
673
- row.append(idCell, el('td', null, 'v' + session.formatVersion), el('td', null, String(session.batchCount)), el('td', null, String(session.lastSeq)), el('td', null, time(session.updatedAt)))
1780
+ row.append(
1781
+ idCell,
1782
+ el('td', null, resetting ? '—' : 'v' + session.formatVersion),
1783
+ el('td', null, resetting ? '—' : String(session.batchCount)),
1784
+ el('td', null, resetting ? '—' : String(session.lastSeq)),
1785
+ el('td', null, resetting ? '—' : formatBytes(session.storedBytes)),
1786
+ agoCell(session.updatedAt),
1787
+ )
674
1788
  const status = el('td')
675
- status.append(el('span', 'badge' + (session.deletedAt === null ? ' good' : ' bad'), session.deletedAt === null ? '正常' : '已删除'))
676
- row.append(status)
1789
+ const purged = session.purgedAt !== null && session.purgedAt !== undefined
1790
+ const deleted = session.deletedAt !== null && session.deletedAt !== undefined
1791
+ status.append(el('span', 'badge' + (resetting ? ' accent' : deleted ? ' bad' : ' good'), resetting ? '重置中' : purged ? '已移除' : deleted ? (session.deletedBy === 'admin:archive' ? '已存档' : '已删除') : '正常'))
1792
+ const actions = el('td')
1793
+ const actionBox = el('div', 'row-actions')
1794
+ if (resetting) {
1795
+ actionBox.append(sessionActionButton(session, '取消重置', 'clear-reset'))
1796
+ } else {
1797
+ if (!deleted) actionBox.append(sessionActionButton(session, '存档', 'archive'))
1798
+ if (deleted && !purged) actionBox.append(sessionActionButton(session, '恢复', 'restore'))
1799
+ if (!purged) actionBox.append(sessionActionButton(session, '移除', 'remove', true))
1800
+ actionBox.append(sessionActionButton(session, '重置', 'reset'))
1801
+ }
1802
+ actionBox.append(sessionHistoryButton(session))
1803
+ actions.append(actionBox)
1804
+ row.append(status, actions)
677
1805
  return row
678
1806
  }
679
1807
 
1808
+ /**
1809
+ * 生成会话操作按钮;具体行为由 openSessionAction 处理。
1810
+ * @param {object} session - 会话行。
1811
+ * @param {string} label - 按钮文字。
1812
+ * @param {'archive'|'restore'|'remove'|'reset'|'clear-reset'} action - 动作。
1813
+ * @param {boolean} danger - 是否危险色。
1814
+ */
1815
+ function sessionHistoryButton(session) {
1816
+ const button = el('button', 'btn tiny secondary')
1817
+ button.type = 'button'
1818
+ button.textContent = '详情'
1819
+ button.onclick = () => openHistory(session)
1820
+ return button
1821
+ }
1822
+
1823
+ function sessionActionButton(session, label, action, danger) {
1824
+ const button = el('button', 'btn tiny' + (danger ? ' danger' : ' secondary'))
1825
+ button.type = 'button'
1826
+ button.textContent = label
1827
+ button.onclick = () => openSessionAction(session, action)
1828
+ return button
1829
+ }
1830
+
1831
+ function workspaceAlias(key) {
1832
+ if (!key) return ''
1833
+ try { return localStorage.getItem(WORKSPACE_ALIAS_PREFIX + key) || '' } catch { return '' }
1834
+ }
1835
+
1836
+ function saveWorkspaceAlias(key, value) {
1837
+ if (!key) return
1838
+ try {
1839
+ const trimmed = String(value || '').trim()
1840
+ if (trimmed === '') localStorage.removeItem(WORKSPACE_ALIAS_PREFIX + key)
1841
+ else localStorage.setItem(WORKSPACE_ALIAS_PREFIX + key, trimmed)
1842
+ } catch {
1843
+ // 浏览器禁用 localStorage 时别名不持久化,不影响服务端数据。
1844
+ }
1845
+ }
1846
+ const WORKSPACE_ALIAS_PREFIX = 'dsh-sync-workspace-alias:'
1847
+
680
1848
  function workspaceGroupRow(group) {
681
1849
  const row = el('tr', 'session-group')
682
1850
  const cell = el('td')
683
- cell.colSpan = 6
1851
+ cell.colSpan = 8
684
1852
  const head = el('div', 'session-group-head')
1853
+ const collapseKey = group.key || '__unmapped__'
1854
+ const collapsed = state.collapsed.has(collapseKey)
1855
+ const chev = el('button', 'chev' + (collapsed ? ' closed' : ''))
1856
+ chev.type = 'button'
1857
+ chev.title = collapsed ? '展开' : '折叠'
1858
+ chev.append(icon('chevron', 12))
1859
+ chev.onclick = (event) => {
1860
+ event.stopPropagation()
1861
+ if (collapsed) state.collapsed.delete(collapseKey)
1862
+ else state.collapsed.add(collapseKey)
1863
+ renderSessions()
1864
+ }
1865
+ head.append(chev)
685
1866
  if (group.key) {
686
1867
  const input = el('input', 'workspace-alias')
687
1868
  input.value = workspaceAlias(group.key)
688
- input.placeholder = '工作区本机别名(仅浏览器)'
1869
+ input.placeholder = '工作区别名(可选)'
689
1870
  input.setAttribute('aria-label', '工作区别名')
1871
+ input.onclick = (event) => event.stopPropagation()
690
1872
  input.onchange = () => saveWorkspaceAlias(group.key, input.value)
691
1873
  head.append(input)
1874
+ } else {
1875
+ head.append(el('b', null, '未归属'))
692
1876
  }
693
1877
  const keyLine = el('div', 'session-group-key')
694
- keyLine.append(el('code', null, group.key ? group.key.slice(0, 16) + '…' : '未归属(子代理/旧 manifest)'))
1878
+ keyLine.append(el('code', null, group.key ? group.key.slice(0, 16) + '…' : '子代理 / 较早版本'))
695
1879
  if (group.key) {
696
- const copy = el('button', 'row-copy', '复制')
1880
+ const copy = el('button', 'row-copy')
697
1881
  copy.type = 'button'
698
- copy.onclick = () => copyText(group.key)
1882
+ copy.title = '复制工作区标识'
1883
+ copy.append(icon('copy', 11))
1884
+ copy.onclick = (event) => { event.stopPropagation(); copyText(group.key) }
699
1885
  keyLine.append(copy)
700
1886
  }
701
1887
  head.append(keyLine)
702
- head.append(el('div', 'session-group-meta', group.sessions.length + ' 个会话 · 最近 ' + time(group.sessions[0].updatedAt)))
1888
+ const totalBytes = group.sessions.reduce((sum, session) => sum + (session.storedBytes || 0), 0)
1889
+ head.append(el('div', 'session-group-meta', group.sessions.length + ' 个 · ' + formatBytes(totalBytes)))
703
1890
  cell.append(head)
704
1891
  row.append(cell)
705
1892
  return row
@@ -710,7 +1897,6 @@ function renderSessions() {
710
1897
  const sessions = filteredSessions()
711
1898
  $('sessionBadge').textContent = (sessions.length === all.length ? all.length : sessions.length + ' / ' + all.length) + ' 个'
712
1899
  const body = $('sessions')
713
- // 会话按 manifest 里的 opaque workspaceKey 分组;子代理没有 key,归到“未归属”。
714
1900
  const groups = new Map()
715
1901
  for (const session of sessions) {
716
1902
  const key = session.workspaceKey || null
@@ -725,18 +1911,21 @@ function renderSessions() {
725
1911
  })
726
1912
  const rows = []
727
1913
  for (const group of ordered) {
1914
+ const collapseKey = group.key || '__unmapped__'
728
1915
  rows.push(workspaceGroupRow(group))
1916
+ if (state.collapsed.has(collapseKey)) continue
729
1917
  for (const session of group.sessions) rows.push(sessionRow(session))
730
1918
  }
731
1919
  body.replaceChildren(...rows)
732
1920
  const empty = $('sessionsEmpty')
733
- const table = body.closest('.table-wrap')
1921
+ const table = $('sessionTableWrap')
734
1922
  if (sessions.length === 0) {
735
1923
  table.hidden = true
736
1924
  empty.hidden = false
737
1925
  empty.replaceChildren()
738
1926
  if (all.length === 0) {
739
- empty.append(el('b', null, '还没有同步过会话'), el('span', null, '接入 DSH 插件后,会话会出现在这里。'))
1927
+ const mark = el('div', 'icon'); mark.append(icon('sessions', 22))
1928
+ empty.append(mark, el('b', null, '还没有同步过会话'), el('span', null, '接入 DSH 插件后,会话会出现在这里。'))
740
1929
  const button = el('button', 'btn secondary small', '签发第一台设备')
741
1930
  button.type = 'button'
742
1931
  button.onclick = () => { $('deviceLabel').focus(); $('deviceLabel').scrollIntoView({ behavior: 'smooth', block: 'center' }) }
@@ -754,29 +1943,140 @@ function renderSessions() {
754
1943
  }
755
1944
  }
756
1945
 
757
- const WORKSPACE_ALIAS_PREFIX = 'dsh-sync-workspace-alias:'
1946
+ const AUDIT_ACTIONS = {
1947
+ 'login': '登录',
1948
+ 'login_failed': '登录失败',
1949
+ 'logout': '退出登录',
1950
+ 'device.create': '签发设备',
1951
+ 'device.rename': '设备改名',
1952
+ 'device.revoke': '吊销设备',
1953
+ 'quota.change': '修改配额',
1954
+ 'password.change': '修改密码',
1955
+ 'password.change_failed': '修改密码失败',
1956
+ 'password.reset_cli': 'CLI 重置密码',
1957
+ 'setup.password': '初始化密码',
1958
+ 'setup.quota': '初始化配额',
1959
+ 'setup.device': '初始化设备',
1960
+ 'setup.complete': '完成初始化',
1961
+ 'session.delete': '删除会话',
1962
+ 'session.archive': '存档会话',
1963
+ 'session.remove': '移除会话',
1964
+ 'session.reset': '重置会话',
1965
+ 'session.restore': '恢复会话',
1966
+ 'session.reset_cleared': '取消重置',
1967
+ }
758
1968
 
759
- function workspaceAlias(key) {
760
- if (!key) return ''
761
- try { return localStorage.getItem(WORKSPACE_ALIAS_PREFIX + key) || '' } catch { return '' }
1969
+ function auditActionLabel(action) {
1970
+ return AUDIT_ACTIONS[action] || action
762
1971
  }
763
1972
 
764
- function saveWorkspaceAlias(key, value) {
765
- if (!key) return
1973
+ function auditActionTone(action) {
1974
+ if (action === 'device.revoke' || action === 'session.delete' || action === 'session.remove' || action === 'session.reset' || action === 'login_failed' || action === 'password.change_failed') return 'bad'
1975
+ if (action === 'device.create' || action === 'login' || action === 'setup.complete') return 'good'
1976
+ if (action === 'device.rename' || action === 'quota.change' || action === 'password.change' || action === 'password.reset_cli' || action === 'session.archive' || action === 'session.restore' || action === 'session.reset_cleared') return 'warn'
1977
+ return ''
1978
+ }
1979
+
1980
+ function auditTargetText(target) {
1981
+ if (target === null || target === '') return '—'
1982
+ return target
1983
+ }
1984
+
1985
+ function auditDetailText(action, detail) {
1986
+ if (detail === null || detail === undefined) return '—'
1987
+ if (typeof detail !== 'object') return String(detail)
1988
+ if ('reason' in detail) return String(detail.reason)
1989
+ if ('from' in detail && 'to' in detail) {
1990
+ if (action === 'quota.change') return formatBytes(detail.from) + ' → ' + formatBytes(detail.to)
1991
+ return String(detail.from) + ' → ' + String(detail.to)
1992
+ }
1993
+ if ('maxBytes' in detail) return formatBytes(detail.maxBytes)
1994
+ if ('label' in detail) return '标签 ' + String(detail.label)
1995
+ if ('createdAt' in detail) return 'createdAt ' + time(detail.createdAt)
1996
+ return Object.entries(detail).map(([key, value]) => {
1997
+ const text = value !== null && typeof value === 'object' ? JSON.stringify(value) : String(value)
1998
+ return key + ': ' + text
1999
+ }).join(' · ')
2000
+ }
2001
+
2002
+ function renderAudit() {
2003
+ const query = $('auditSearch').value.trim().toLowerCase()
2004
+ const rows = state.audit.filter((event) => {
2005
+ if (query === '') return true
2006
+ return (event.actorLabel || '').toLowerCase().includes(query)
2007
+ || (event.target || '').toLowerCase().includes(query)
2008
+ || (event.action || '').toLowerCase().includes(query)
2009
+ })
2010
+ const body = $('auditRows')
2011
+ body.replaceChildren(...rows.map((event) => {
2012
+ const row = el('tr')
2013
+ const timeCell = el('td', 'muted', formatAgo(event.at))
2014
+ timeCell.title = time(event.at)
2015
+ const actionCell = el('td')
2016
+ actionCell.append(el('span', 'audit-action ' + auditActionTone(event.action), auditActionLabel(event.action)))
2017
+ const actorCell = el('td')
2018
+ actorCell.append(el('span', null, event.actorLabel || event.actorId))
2019
+ if (event.actorKind === 'device') {
2020
+ const id = el('span', 'audit-target', ' · ' + event.actorId)
2021
+ id.title = event.actorId
2022
+ actorCell.append(id)
2023
+ }
2024
+ const targetCell = el('td')
2025
+ const target = auditTargetText(event.target)
2026
+ targetCell.append(el('code', 'audit-target', target))
2027
+ targetCell.title = target
2028
+ const detailCell = el('td', 'audit-detail', auditDetailText(event.action, event.detail))
2029
+ const meta = [event.ip, event.userAgent].filter((value) => value !== null && value !== undefined && value !== '')
2030
+ if (meta.length > 0) detailCell.title = meta.join(' · ')
2031
+ row.title = event.actorId + (meta.length > 0 ? ' · ' + meta.join(' · ') : '')
2032
+ row.append(timeCell, actionCell, actorCell, targetCell, detailCell)
2033
+ return row
2034
+ }))
2035
+ $('auditBadge').textContent = rows.length + ' 条'
2036
+ const table = body.closest('.table-wrap')
2037
+ table.hidden = rows.length === 0
2038
+ const empty = $('auditEmpty')
2039
+ empty.hidden = rows.length > 0
2040
+ empty.replaceChildren()
2041
+ if (rows.length === 0) {
2042
+ const mark = el('div', 'icon'); mark.append(icon('audit', 22))
2043
+ empty.append(mark, el('b', null, '还没有审计记录'), el('span', null, '登录、签发设备、修改配额等动作会出现在这里。'))
2044
+ }
2045
+ $('auditMoreWrap').hidden = !state.auditHasMore
2046
+ }
2047
+
2048
+ async function loadAudit(reset) {
2049
+ const button = $('auditRefresh')
2050
+ button.disabled = true
766
2051
  try {
767
- const trimmed = String(value || '').trim()
768
- if (trimmed === '') localStorage.removeItem(WORKSPACE_ALIAS_PREFIX + key)
769
- else localStorage.setItem(WORKSPACE_ALIAS_PREFIX + key, trimmed)
770
- } catch {
771
- // 浏览器禁用 localStorage 时别名只是不持久化,不影响服务端数据。
2052
+ const action = $('auditFilter').value
2053
+ const params = new URLSearchParams({ limit: '100' })
2054
+ if (action !== '') params.set('action', action)
2055
+ if (!reset && state.auditBefore !== null) params.set('before', String(state.auditBefore))
2056
+ const data = await api('/admin/audit?' + params.toString())
2057
+ state.audit = reset ? data.events : state.audit.concat(data.events)
2058
+ state.auditBefore = data.nextBefore
2059
+ state.auditHasMore = data.nextBefore !== null
2060
+ renderAudit()
2061
+ } catch (error) {
2062
+ if (isUninitialized(error)) {
2063
+ location.replace('/setup')
2064
+ return
2065
+ }
2066
+ if (error.message !== 'unauthorized') showToast(error.message, true)
2067
+ } finally {
2068
+ button.disabled = false
772
2069
  }
773
2070
  }
774
2071
 
775
2072
  async function refresh(silent) {
776
2073
  const button = $('refresh')
2074
+ const svg = button.querySelector('svg')
777
2075
  if (!silent) {
778
2076
  button.disabled = true
779
- button.textContent = '刷新中…'
2077
+ if (svg) svg.classList.add('spinner')
2078
+ button.textContent = ''
2079
+ button.append(svg || icon('refresh', 14), el('span', null, '刷新中…'))
780
2080
  }
781
2081
  try {
782
2082
  const [overview, devices, sessions] = await Promise.all([
@@ -787,25 +2087,53 @@ async function refresh(silent) {
787
2087
  state.overview = overview
788
2088
  state.devices = devices
789
2089
  state.sessions = sessions
2090
+ updatedAt = Date.now()
790
2091
  showApp()
791
2092
  renderMetrics(overview)
792
- renderDevices(devices)
793
- // 正在编辑工作区别名时不重绘会话表,避免 10 秒轮询抢走焦点。
2093
+ if (state.editingDeviceId === null) renderDevices(devices)
794
2094
  if (!$('sessions').contains(document.activeElement)) renderSessions()
795
2095
  $('healthText').textContent = '服务正常'
2096
+ $('health').classList.remove('bad')
2097
+ $('updatedBadge').textContent = '更新于 ' + formatAgo(updatedAt)
2098
+ void loadActivity()
796
2099
  } catch (error) {
2100
+ if (isUninitialized(error)) {
2101
+ location.replace('/setup')
2102
+ return
2103
+ }
797
2104
  if (error.message !== 'unauthorized') {
798
2105
  $('healthText').textContent = '连接异常'
2106
+ $('health').classList.add('bad')
799
2107
  showToast(error.message, true)
800
2108
  }
801
2109
  } finally {
802
2110
  if (!silent) {
803
2111
  button.disabled = false
804
- button.textContent = '刷新'
2112
+ button.textContent = ''
2113
+ button.append(icon('refresh', 14), el('span', null, '刷新'))
805
2114
  }
806
2115
  }
807
2116
  }
808
2117
 
2118
+ function startPolling() {
2119
+ if (pollTimer !== null) clearInterval(pollTimer)
2120
+ pollTimer = setInterval(() => {
2121
+ if (state.autoRefresh && !$('appView').hidden) refresh(true)
2122
+ }, 10000)
2123
+ }
2124
+
2125
+ $('refresh').append(icon('refresh', 14), el('span', null, '刷新'))
2126
+ $('sessionsIcon').append(icon('sessions', 15))
2127
+ $('storageIcon').append(icon('storage', 15))
2128
+ $('devicesIcon').append(icon('devices', 15))
2129
+ $('activityIcon').append(icon('activity', 15))
2130
+ $('auditIcon').append(icon('audit', 15))
2131
+ for (const [action, label] of Object.entries(AUDIT_ACTIONS)) {
2132
+ const option = el('option', null, label)
2133
+ option.value = action
2134
+ $('auditFilter').append(option)
2135
+ }
2136
+
809
2137
  $('loginButton').onclick = async () => {
810
2138
  const button = $('loginButton')
811
2139
  $('loginError').textContent = ''
@@ -824,11 +2152,17 @@ $('loginButton').onclick = async () => {
824
2152
  })
825
2153
  if (!response.ok) {
826
2154
  const payload = await response.json().catch(() => ({}))
827
- $('loginError').textContent = (payload && payload.error && payload.error.message) || '登录失败'
2155
+ const message = (payload && payload.error && payload.error.message) || '登录失败'
2156
+ if (response.status === 409 && /初始化/u.test(message)) {
2157
+ location.replace('/setup')
2158
+ return
2159
+ }
2160
+ $('loginError').textContent = message
828
2161
  return
829
2162
  }
830
2163
  $('password').value = ''
831
2164
  await refresh(false)
2165
+ await loadAudit(true)
832
2166
  } catch (error) {
833
2167
  $('loginError').textContent = error.message || '登录失败'
834
2168
  } finally {
@@ -848,7 +2182,14 @@ $('logout').onclick = async () => {
848
2182
  await fetch('/admin/logout', { method: 'POST' }).catch(() => {})
849
2183
  showLogin()
850
2184
  }
851
- $('refresh').onclick = () => refresh(false)
2185
+ $('refresh').onclick = () => { refresh(false); loadAudit(true) }
2186
+ $('autoRefresh').addEventListener('change', (event) => { state.autoRefresh = event.target.checked })
2187
+ $('quotaPresets').addEventListener('click', (event) => {
2188
+ const button = event.target.closest('.preset')
2189
+ if (!button) return
2190
+ $('quotaGb').value = button.dataset.gb
2191
+ for (const item of $('quotaPresets').querySelectorAll('.preset')) item.classList.toggle('on', item === button)
2192
+ })
852
2193
  $('quotaForm').addEventListener('submit', async (event) => {
853
2194
  event.preventDefault()
854
2195
  const button = $('saveQuota')
@@ -863,7 +2204,7 @@ $('quotaForm').addEventListener('submit', async (event) => {
863
2204
  button.disabled = true
864
2205
  button.textContent = '保存中…'
865
2206
  try {
866
- await api('/admin/quota', { method: 'POST', body: JSON.stringify({ maxBytes: Math.round(gib * 1024 * 1024 * 1024) }) })
2207
+ await api('/admin/quota', { method: 'POST', body: JSON.stringify({ maxBytes: Math.round(gib * GiB) }) })
867
2208
  $('quotaResult').textContent = '已保存'
868
2209
  $('quotaResult').className = 'inline-result good'
869
2210
  await refresh(true)
@@ -895,26 +2236,221 @@ $('deviceForm').addEventListener('submit', async (event) => {
895
2236
  })
896
2237
  function showToken(created) {
897
2238
  $('tokenValue').textContent = created.token
2239
+ $('tokenDialog').classList.remove('closing')
898
2240
  $('tokenDialog').hidden = false
899
2241
  document.body.classList.add('modal-open')
900
2242
  $('copyToken').onclick = () => copyText(created.token)
901
2243
  setTimeout(() => $('copyToken').focus(), 30)
902
2244
  }
903
2245
  function closeToken() {
904
- $('tokenDialog').hidden = true
905
- document.body.classList.remove('modal-open')
906
- $('tokenValue').textContent = ''
2246
+ closeModal('tokenDialog')
2247
+ setTimeout(() => { $('tokenValue').textContent = '' }, MODAL_EXIT_MS)
907
2248
  }
908
2249
  $('closeToken').onclick = closeToken
909
2250
  $('tokenDialog').addEventListener('click', (event) => { if (event.target === $('tokenDialog')) closeToken() })
2251
+ $('cancelRevoke').onclick = closeRevoke
2252
+ $('revokeDialog').addEventListener('click', (event) => { if (event.target === $('revokeDialog')) closeRevoke() })
2253
+ $('changePassword').onclick = openPassword
2254
+ $('cancelPassword').onclick = closePassword
2255
+ $('passwordDialog').addEventListener('click', (event) => { if (event.target === $('passwordDialog')) closePassword() })
2256
+ $('savePassword').onclick = savePassword
2257
+ $('cancelSessionAction').onclick = closeSessionAction
2258
+ $('sessionDialog').addEventListener('click', (event) => { if (event.target === $('sessionDialog')) closeSessionAction() })
2259
+ $('confirmSessionAction').onclick = confirmSessionAction
2260
+ $('closeHistory').onclick = closeHistory
2261
+ for (const tab of document.querySelectorAll('.history-tab')) {
2262
+ tab.addEventListener('click', () => switchHistoryPane(tab.dataset.pane))
2263
+ }
2264
+ $('historyDialog').addEventListener('click', (event) => { if (event.target === $('historyDialog')) closeHistory() })
2265
+ $('cancelColor').onclick = closeColorDialog
2266
+ $('colorDialog').addEventListener('click', (event) => { if (event.target === $('colorDialog')) closeColorDialog() })
2267
+ $('saveColor').onclick = saveDeviceColor
2268
+ $('deviceColorPicker').addEventListener('input', () => applyColorDraft($('deviceColorPicker').value))
2269
+ $('deviceColorHex').addEventListener('change', () => {
2270
+ const color = normalizeColor($('deviceColorHex').value)
2271
+ if (color === null) {
2272
+ $('colorError').textContent = '请输入 #rrggbb 格式的颜色'
2273
+ return
2274
+ }
2275
+ $('colorError').textContent = ''
2276
+ applyColorDraft(color)
2277
+ })
2278
+ $('deviceColorHex').addEventListener('keydown', (event) => { if (event.key === 'Enter') saveDeviceColor() })
2279
+ for (const id of ['currentPassword', 'newPassword', 'confirmPassword']) {
2280
+ $(id).addEventListener('keydown', (event) => { if (event.key === 'Enter') savePassword() })
2281
+ }
2282
+ $('confirmRevoke').onclick = async () => {
2283
+ const deviceId = state.revokeTarget
2284
+ if (deviceId === null) return
2285
+ const button = $('confirmRevoke')
2286
+ button.disabled = true
2287
+ button.textContent = '吊销中…'
2288
+ try {
2289
+ await api('/admin/devices/' + encodeURIComponent(deviceId), { method: 'DELETE' })
2290
+ closeRevoke()
2291
+ showToast('设备已吊销')
2292
+ await refresh(true)
2293
+ } catch (error) {
2294
+ showToast(error.message, true)
2295
+ } finally {
2296
+ button.disabled = false
2297
+ button.textContent = '确认吊销'
2298
+ }
2299
+ }
910
2300
  document.addEventListener('keydown', (event) => {
911
- if (event.key === 'Escape' && !$('tokenDialog').hidden) closeToken()
2301
+ if (event.key !== 'Escape') return
2302
+ if (!$('tokenDialog').hidden) closeToken()
2303
+ if (!$('revokeDialog').hidden) closeRevoke()
2304
+ if (!$('passwordDialog').hidden) closePassword()
2305
+ if (!$('sessionDialog').hidden) closeSessionAction()
2306
+ if (!$('historyDialog').hidden) closeHistory()
2307
+ if (!$('colorDialog').hidden) closeColorDialog()
2308
+ if (!$('activityRangePopover').hidden) closeActivityRange()
912
2309
  })
913
2310
  $('sessionSearch').addEventListener('input', renderSessions)
914
2311
  $('sessionFilter').addEventListener('change', renderSessions)
2312
+ $('sessionDayChip').addEventListener('click', () => {
2313
+ state.sessionDayFilter = null
2314
+ state.activityDaySessionIds = null
2315
+ updateSessionDayChip()
2316
+ renderSessions()
2317
+ })
2318
+ $('auditSearch').addEventListener('input', renderAudit)
2319
+ $('auditFilter').addEventListener('change', () => loadAudit(true))
2320
+ $('auditRefresh').addEventListener('click', () => loadAudit(true))
2321
+ $('auditMore').addEventListener('click', () => loadAudit(false))
2322
+ $('activityRangeButton').addEventListener('click', (event) => {
2323
+ event.stopPropagation()
2324
+ if ($('activityRangePopover').hidden) openActivityRange()
2325
+ else closeActivityRange()
2326
+ })
2327
+ $('activityCancel').addEventListener('click', closeActivityRange)
2328
+ $('activityApply').addEventListener('click', applyActivityRange)
2329
+ $('activityPresets').addEventListener('click', (event) => {
2330
+ const button = event.target.closest('[data-days]')
2331
+ if (!button) return
2332
+ const days = Number(button.dataset.days)
2333
+ const range = defaultActivityRange(days)
2334
+ setActivityRange(range.start, range.end, days)
2335
+ closeActivityRange()
2336
+ void loadActivity()
2337
+ })
2338
+ for (const id of ['activityFrom', 'activityTo']) {
2339
+ $(id).addEventListener('keydown', (event) => { if (event.key === 'Enter') applyActivityRange() })
2340
+ }
2341
+ document.addEventListener('click', (event) => {
2342
+ if ($('activityRangePopover').hidden) return
2343
+ const wrap = document.querySelector('.activity-range')
2344
+ if (wrap !== null && !wrap.contains(event.target)) closeActivityRange()
2345
+ })
915
2346
 
2347
+ const initialDays = window.matchMedia('(max-width: 900px)').matches ? 90 : 365
2348
+ const initialActivity = defaultActivityRange(initialDays)
2349
+ setActivityRange(initialActivity.start, initialActivity.end, initialDays)
916
2350
  refresh(false)
917
- setInterval(() => { if (!$('appView').hidden) refresh(true) }, 10000)
2351
+ loadAudit(true)
2352
+ startPolling()
2353
+
2354
+ // 背景:同尺寸胶囊条带;偶数行向右、奇数行向左,入场结束后只保留低速漂移。
2355
+ // 参数来自 site/bg-tune.html,控制台里降低透明度与速度,避免干扰阅读。
2356
+ ;(function initAdminBackground() {
2357
+ var CONFIG = {
2358
+ length: 48, height: 10, radius: 5, gap: 9, row: 28,
2359
+ alpha: 0.26, tint: '76, 98, 150', duration: 2.2, variance: 2.8,
2360
+ rotation: 37, jitter: true,
2361
+ }
2362
+ var rowsBox = document.getElementById('adminBgRows')
2363
+ if (rowsBox === null) return
2364
+
2365
+ var seed = 20261113
2366
+ function random() {
2367
+ seed = (seed + 0x6D2B79F5) | 0
2368
+ var value = Math.imul(seed ^ (seed >>> 15), 1 | seed)
2369
+ value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value
2370
+ return ((value ^ (value >>> 14)) >>> 0) / 4294967296
2371
+ }
2372
+
2373
+ var lo = CONFIG.duration
2374
+ var hi = Math.max(CONFIG.duration + CONFIG.variance, CONFIG.duration * 2)
2375
+ var usedDurations = {}
2376
+ var durations = []
2377
+
2378
+ function takeDuration(index) {
2379
+ var previous = index >= 2 ? durations[index - 2] : undefined
2380
+ for (var attempt = 0; attempt < 80; attempt += 1) {
2381
+ var candidate = lo + random() * (hi - lo)
2382
+ var key = candidate.toFixed(3)
2383
+ if (usedDurations[key] === true) continue
2384
+ if (previous !== undefined) {
2385
+ var ratio = candidate / previous
2386
+ if (ratio >= 0.7 && ratio <= 1.3) continue
2387
+ }
2388
+ usedDurations[key] = true
2389
+ return candidate
2390
+ }
2391
+ var fallback = previous !== undefined && previous * 1.3 <= hi ? previous * 1.31 : lo
2392
+ usedDurations[fallback.toFixed(3)] = true
2393
+ return fallback
2394
+ }
2395
+
2396
+ var stageW = window.innerWidth
2397
+ var stageH = window.innerHeight
2398
+ var planeWidth = stageW * 1.6
2399
+ var planeHeight = stageH * 2.1
2400
+ var lineLeft = -280
2401
+ var lineRight = planeWidth + 280
2402
+ var angle = CONFIG.rotation * Math.PI / 180
2403
+ var cos = Math.cos(angle)
2404
+ var sin = Math.sin(angle)
2405
+ var margin = 300
2406
+ var height = planeHeight
2407
+ var count = Math.ceil(height / CONFIG.row) + 2
2408
+ var fragment = document.createDocumentFragment()
2409
+
2410
+ for (var index = 0; index < count; index += 1) {
2411
+ var rowGap = CONFIG.jitter
2412
+ ? Math.max(2, Math.round(CONFIG.gap * (0.7 + random() * 0.6)))
2413
+ : CONFIG.gap
2414
+ var rowTile = CONFIG.length + rowGap
2415
+ var odd = index % 2 === 1
2416
+ var top = (CONFIG.row - CONFIG.height) / 2
2417
+ var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + rowTile + '" height="' + CONFIG.row + '">'
2418
+ + '<rect x="0" y="' + top + '" width="' + CONFIG.length + '" height="' + CONFIG.height
2419
+ + '" rx="' + Math.min(CONFIG.radius, CONFIG.height / 2) + '" fill="rgba(' + CONFIG.tint + ',' + CONFIG.alpha + ')"/></svg>'
2420
+ var duration = takeDuration(index)
2421
+ durations[index] = duration
2422
+ var row = document.createElement('div')
2423
+ row.className = 'admin-bg-row' + (odd ? ' odd' : '')
2424
+ row.style.top = (index * CONFIG.row - CONFIG.row) + 'px'
2425
+ row.style.setProperty('--row', CONFIG.row + 'px')
2426
+ row.style.backgroundImage = 'url("data:image/svg+xml,' + encodeURIComponent(svg) + '")'
2427
+ row.style.backgroundPositionX = (-random() * rowTile).toFixed(1) + 'px'
2428
+ row.style.setProperty('--dur', duration.toFixed(2) + 's')
2429
+ row.style.animationDelay = (-random() * duration).toFixed(2) + 's, 0s'
2430
+ row.style.setProperty('--tile', rowTile + 'px')
2431
+
2432
+ var yRel = (index * CONFIG.row - CONFIG.row + CONFIG.row / 2) - planeHeight / 2
2433
+ var halfW = stageW / 2 + CONFIG.length
2434
+ var halfH = stageH / 2 + CONFIG.height
2435
+ var xHi = Math.min((halfW + yRel * sin) / cos, (halfH - yRel * cos) / sin)
2436
+ var xLo = Math.max((-halfW + yRel * sin) / cos, (-halfH - yRel * cos) / sin)
2437
+ var visHi = xHi + planeWidth / 2
2438
+ var visLo = xLo + planeWidth / 2
2439
+ var enterFrom
2440
+ var enterTo
2441
+ if (odd) {
2442
+ enterFrom = visHi + margin - lineLeft
2443
+ enterTo = visLo - margin - lineLeft
2444
+ } else {
2445
+ enterFrom = visLo - margin - lineRight
2446
+ enterTo = visHi + margin - lineRight
2447
+ }
2448
+ row.style.setProperty('--enter-from', enterFrom.toFixed(1) + 'px')
2449
+ row.style.setProperty('--enter-to', enterTo.toFixed(1) + 'px')
2450
+ fragment.appendChild(row)
2451
+ }
2452
+ rowsBox.replaceChildren(fragment)
2453
+ })()
918
2454
  </script>
919
2455
  </body>
920
2456
  </html>