@chenkai114/dsh-team-admin 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +85 -2
- package/cordis.patch.yml +6 -0
- package/package.json +56 -4
- package/src/api.mjs +133 -0
- package/src/client.js +1006 -0
- package/src/dismiss.mjs +250 -0
- package/src/error.mjs +16 -0
- package/src/index.js +64 -0
- package/src/store.mjs +175 -0
- package/src/team-read.mjs +390 -0
package/src/client.js
ADDED
|
@@ -0,0 +1,1006 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
// dsh-team-admin client 半区:全局面板入口(sidebar.panellist「团队管理」+ main 中央页面),
|
|
3
|
+
// 同时保留插件管理页(plugins.bundle.config)与设置页(settings.plugin.item)的配置卡片。
|
|
4
|
+
// 经典 script:IIFE 书挡、零 import/export、无构建;按宿主模块表约定以包名注册 factory。
|
|
5
|
+
//
|
|
6
|
+
// 渲染只有一份实现:createPanelController 用原生 DOM 建树(测试/e2e 可直接 mount),
|
|
7
|
+
// React 侧仅用一个宿主 div 承载同一个 controller,不做第二份 UI。
|
|
8
|
+
// 纯逻辑(状态映射 / 时间格式化 / URL 构造 / 响应校验 / 确认文案)挂到
|
|
9
|
+
// globalThis.__DshTeamAdminTest.helpers,仅在测试开关打开时生效,浏览器里不留全局。
|
|
10
|
+
|
|
11
|
+
const PKG_ID = '@chenkai114/dsh-team-admin'
|
|
12
|
+
const SETTINGS_KEY = 'team-admin'
|
|
13
|
+
const API_BASE = '/api/team-admin'
|
|
14
|
+
const STYLE_ID = 'dsh-team-admin-style'
|
|
15
|
+
const DEBOUNCE_MS = 300
|
|
16
|
+
const SECOND_MS = 1000
|
|
17
|
+
const MINUTE_MS = 60 * SECOND_MS
|
|
18
|
+
const HOUR_MS = 60 * MINUTE_MS
|
|
19
|
+
const DAY_MS = 24 * HOUR_MS
|
|
20
|
+
const COPIED_RESET_MS = 1600
|
|
21
|
+
|
|
22
|
+
const PANEL_ID = 'team-admin'
|
|
23
|
+
const PANEL_LABEL = '团队管理'
|
|
24
|
+
const PANEL_ORDER = 20
|
|
25
|
+
|
|
26
|
+
const CARD_TITLE = '智能体团队管理'
|
|
27
|
+
const CARD_SUMMARY = '选择任意会话,查看团队成员并删除角色。'
|
|
28
|
+
const CARD_DESC = '内置 roster 只增不删;这里的删除 = 停会话 + 会话目录进回收站 + 写入墓碑记录(可随时从回收站恢复)。'
|
|
29
|
+
const ROSTER_NOTE = '内置团队面板仍会显示该成员(宿主 roster 只增不删),这里记录的是本插件的墓碑。'
|
|
30
|
+
const DELETE_HINT = '将中断该成员会话,并把会话目录移入回收站(可恢复)。'
|
|
31
|
+
const MODEL_FALLBACK = '跟随宿主默认'
|
|
32
|
+
const NO_SESSION_TITLE = '(未命名会话)'
|
|
33
|
+
const UNKNOWN_TIME = '未知'
|
|
34
|
+
|
|
35
|
+
const MEMBER_PHASES = ['provisioning', 'active', 'failed']
|
|
36
|
+
const MEMBER_STATUSES = ['running', 'inactive', 'provisioning', 'failed']
|
|
37
|
+
const STATUS_META = {
|
|
38
|
+
running: { label: '运行中', tone: 'run' },
|
|
39
|
+
inactive: { label: '未运行', tone: 'idle' },
|
|
40
|
+
provisioning: { label: '创建中', tone: 'warn' },
|
|
41
|
+
failed: { label: '失败', tone: 'bad' },
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const STYLE = `
|
|
45
|
+
.ta-root{--ta-bg:var(--dsw-alias-bg-layer-3,#fff);--ta-bg-sub:var(--dsw-alias-bg-layer-2,#f7f7f8);--ta-bg-muted:var(--dsw-alias-bg-module-platform,#f1f1f3);--ta-border:var(--dsw-alias-border-l2,rgba(0,0,0,.1));--ta-border-strong:var(--dsw-alias-border-l3,rgba(0,0,0,.2));--ta-text:var(--dsw-alias-label-primary,#1c1c1e);--ta-text-sub:var(--dsw-alias-label-secondary,#55555c);--ta-text-dim:var(--dsw-alias-label-tertiary,#8a8a92);--ta-accent:var(--dsw-alias-brand-primary,#3b6ef5);--ta-danger:var(--dsw-alias-state-error-primary,#d33b3b);--ta-ok:var(--dsw-alias-state-success-primary,#2f9e5f);--ta-warn:var(--dsw-alias-state-warn-primary,#c98a15);--ta-hover:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05));--ta-radius:10px;--ta-radius-sm:6px;display:flex;flex-direction:column;gap:12px;min-width:0;padding:14px 16px;border:1px solid var(--ta-border);border-radius:14px;background:var(--ta-bg);color:var(--ta-text);font-size:13px;line-height:1.5}
|
|
46
|
+
.ta-head{display:flex;flex-direction:column;gap:4px;min-width:0}
|
|
47
|
+
.ta-title{margin:0;font-size:15px;font-weight:600;line-height:1.4}
|
|
48
|
+
.ta-desc{margin:0;font-size:12px;color:var(--ta-text-sub);line-height:1.6}
|
|
49
|
+
.ta-body{display:grid;grid-template-columns:minmax(200px,290px) minmax(0,1fr);gap:14px;align-items:start;min-width:0}
|
|
50
|
+
.ta-col{display:flex;flex-direction:column;gap:8px;min-width:0}
|
|
51
|
+
.ta-col--list{padding-right:14px;border-right:1px solid var(--ta-border)}
|
|
52
|
+
.ta-searchbox{display:flex;flex-direction:column;gap:5px;min-width:0}
|
|
53
|
+
.ta-label{font-size:12px;font-weight:600;color:var(--ta-text)}
|
|
54
|
+
.ta-input,.ta-search{box-sizing:border-box;width:100%;min-width:0;min-height:30px;padding:4px 10px;border:1px solid var(--ta-border-strong);border-radius:8px;background:var(--ta-bg-sub);color:var(--ta-text);font:inherit;font-size:12px}
|
|
55
|
+
.ta-input:focus,.ta-search:focus{outline:none;border-color:var(--ta-accent);box-shadow:0 0 0 3px color-mix(in srgb, var(--ta-accent) 18%, transparent)}
|
|
56
|
+
.ta-sessions{display:flex;flex-direction:column;gap:4px;max-height:340px;overflow:auto;padding-right:2px}
|
|
57
|
+
.ta-session{display:flex;flex-direction:column;gap:2px;min-width:0;padding:7px 9px;border:1px solid transparent;border-radius:8px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}
|
|
58
|
+
.ta-session:hover{background:var(--ta-hover)}
|
|
59
|
+
.ta-session:focus-visible{outline:2px solid color-mix(in srgb, var(--ta-accent) 70%, transparent);outline-offset:1px}
|
|
60
|
+
.ta-session--on{border-color:var(--ta-accent);background:color-mix(in srgb, var(--ta-accent) 10%, transparent)}
|
|
61
|
+
.ta-session__title{overflow:hidden;font-size:13px;font-weight:600;text-overflow:ellipsis;white-space:nowrap}
|
|
62
|
+
.ta-session__cwd{overflow:hidden;font-size:11px;color:var(--ta-text-dim);text-overflow:ellipsis;white-space:nowrap}
|
|
63
|
+
.ta-session__meta{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--ta-text-sub)}
|
|
64
|
+
.ta-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
65
|
+
.ta-dot{display:inline-block;flex:none;width:6px;height:6px;border-radius:50%;background:var(--ta-ok)}
|
|
66
|
+
.ta-badge{display:inline-flex;align-items:center;gap:4px;padding:1px 8px;border-radius:999px;background:var(--ta-bg-muted);color:var(--ta-text-sub);font-size:11px;white-space:nowrap}
|
|
67
|
+
.ta-badge--run{background:color-mix(in srgb, var(--ta-ok) 14%, transparent);color:var(--ta-ok)}
|
|
68
|
+
.ta-badge--idle{color:var(--ta-text-dim)}
|
|
69
|
+
.ta-badge--warn{background:color-mix(in srgb, var(--ta-warn) 14%, transparent);color:var(--ta-warn)}
|
|
70
|
+
.ta-badge--bad{background:color-mix(in srgb, var(--ta-danger) 14%, transparent);color:var(--ta-danger)}
|
|
71
|
+
.ta-spacer{flex:1}
|
|
72
|
+
.ta-detail__head{display:flex;align-items:flex-start;gap:10px;min-width:0}
|
|
73
|
+
.ta-detail__title{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}
|
|
74
|
+
.ta-members{display:flex;flex-direction:column;gap:6px}
|
|
75
|
+
.ta-member{display:flex;flex-direction:column;gap:5px;padding:8px 10px;border:1px solid var(--ta-border);border-radius:var(--ta-radius);background:var(--ta-bg)}
|
|
76
|
+
.ta-member--gone{opacity:.55}
|
|
77
|
+
.ta-member__top{display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0}
|
|
78
|
+
.ta-member__name{overflow:hidden;font-weight:600;font-size:13px;text-overflow:ellipsis;white-space:nowrap}
|
|
79
|
+
.ta-member__meta{display:flex;align-items:center;gap:10px;flex-wrap:wrap;min-width:0}
|
|
80
|
+
.ta-desc{margin:0;font-size:12px;color:var(--ta-text-sub);overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-word}
|
|
81
|
+
.ta-hint{margin:0;font-size:11px;color:var(--ta-text-dim);line-height:1.5}
|
|
82
|
+
.ta-hint--bad{color:var(--ta-danger);word-break:break-word}
|
|
83
|
+
.ta-section{display:flex;flex-direction:column;gap:6px;min-width:0}
|
|
84
|
+
.ta-section__head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
85
|
+
.ta-section__title{margin:0;font-size:12px;font-weight:600;color:var(--ta-text)}
|
|
86
|
+
.ta-graves{display:flex;flex-direction:column;gap:6px}
|
|
87
|
+
.ta-grave{display:flex;flex-direction:column;gap:5px;padding:8px 10px;border:1px dashed var(--ta-border);border-radius:var(--ta-radius);background:var(--ta-bg-sub)}
|
|
88
|
+
.ta-grave__head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}
|
|
89
|
+
.ta-trash{display:flex;align-items:center;gap:6px;min-width:0}
|
|
90
|
+
.ta-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:28px;padding:0 11px;border:1px solid var(--ta-border-strong);border-radius:8px;background:var(--ta-bg);color:var(--ta-text);font:inherit;font-size:12px;font-weight:500;cursor:pointer;white-space:nowrap;transition:background .15s,border-color .15s,color .15s}
|
|
91
|
+
.ta-btn:hover:not(:disabled){background:var(--ta-hover)}
|
|
92
|
+
.ta-btn:disabled{cursor:not-allowed;opacity:.55}
|
|
93
|
+
.ta-btn:focus-visible{outline:2px solid color-mix(in srgb, var(--ta-accent) 70%, transparent);outline-offset:1px}
|
|
94
|
+
.ta-btn--sm{min-height:26px;padding:0 9px;font-size:11px}
|
|
95
|
+
.ta-btn--danger{color:var(--ta-danger)}
|
|
96
|
+
.ta-btn--danger:hover:not(:disabled){border-color:var(--ta-danger);background:var(--ta-danger);color:var(--ta-bg)}
|
|
97
|
+
.ta-status{margin:0;padding:10px 0;font-size:12px;color:var(--ta-text-dim)}
|
|
98
|
+
.ta-empty{display:flex;flex-direction:column;align-items:flex-start;gap:6px;padding:10px 0}
|
|
99
|
+
.ta-error{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0;padding:6px 10px;border-left:3px solid var(--ta-danger);border-radius:var(--ta-radius-sm);background:var(--ta-bg-sub);color:var(--ta-danger);font-size:12px;word-break:break-word}
|
|
100
|
+
.ta-notice{margin:0;padding:6px 10px;border-left:3px solid var(--ta-ok);border-radius:var(--ta-radius-sm);background:var(--ta-bg-sub);color:var(--ta-text);font-size:12px;word-break:break-word}
|
|
101
|
+
.ta-confirm{display:flex;flex-direction:column;gap:6px;padding:8px 10px;border:1px solid var(--ta-danger);border-radius:var(--ta-radius);background:color-mix(in srgb, var(--ta-danger) 8%, transparent)}
|
|
102
|
+
.ta-confirm__text{display:flex;flex-direction:column;gap:3px;min-width:0;font-size:12px}
|
|
103
|
+
.ta-confirm__text strong{font-weight:600;color:var(--ta-text)}
|
|
104
|
+
.ta-confirm__actions{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
|
105
|
+
.ta-summary{font-size:12px;color:var(--ta-text-sub);line-height:1.5}
|
|
106
|
+
.ta-host{display:block;min-width:0}
|
|
107
|
+
.ta-main{flex:1;min-height:0;overflow:auto;padding:18px;background:var(--dsw-alias-bg-base)}
|
|
108
|
+
.ta-main__inner{max-width:1080px;margin:0 auto;min-width:0}
|
|
109
|
+
@media (max-width:760px){.ta-body{grid-template-columns:1fr}.ta-col--list{padding-right:0;padding-bottom:10px;border-right:0;border-bottom:1px solid var(--ta-border)}.ta-sessions{max-height:220px}}
|
|
110
|
+
@media (prefers-reduced-motion:reduce){.ta-btn{transition:none}}
|
|
111
|
+
`
|
|
112
|
+
|
|
113
|
+
function isRecord(value) {
|
|
114
|
+
return value !== null && typeof value === 'object' && Array.isArray(value) === false
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function pickString(value, fallback) {
|
|
118
|
+
return typeof value === 'string' ? value : fallback
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function pickNumber(value, fallback) {
|
|
122
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function pad2(value) {
|
|
126
|
+
return String(value).padStart(2, '0')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 状态 → 徽标文案与配色键;未知状态兜底为 muted,不让 UI 掉行
|
|
130
|
+
function statusMeta(status) {
|
|
131
|
+
return STATUS_META[status] || { label: '未知', tone: 'idle' }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 相对时间人话:过去式;未来时间(时钟偏差)按「刚刚」处理
|
|
135
|
+
function formatRelativeTime(timestamp, now) {
|
|
136
|
+
if (typeof timestamp !== 'number' || Number.isFinite(timestamp) === false || timestamp <= 0) return UNKNOWN_TIME
|
|
137
|
+
const base = typeof now === 'number' && Number.isFinite(now) ? now : Date.now()
|
|
138
|
+
const delta = base - timestamp
|
|
139
|
+
if (delta < MINUTE_MS) return '刚刚'
|
|
140
|
+
if (delta < HOUR_MS) return Math.floor(delta / MINUTE_MS) + ' 分钟前'
|
|
141
|
+
if (delta < DAY_MS) return Math.floor(delta / HOUR_MS) + ' 小时前'
|
|
142
|
+
if (delta < 30 * DAY_MS) return Math.floor(delta / DAY_MS) + ' 天前'
|
|
143
|
+
return formatDateTime(timestamp)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 绝对时间:浏览器本地时区;时间戳缺失回退「未知」
|
|
147
|
+
function formatDateTime(timestamp) {
|
|
148
|
+
if (typeof timestamp !== 'number' || Number.isFinite(timestamp) === false || timestamp <= 0) return UNKNOWN_TIME
|
|
149
|
+
const date = new Date(timestamp)
|
|
150
|
+
return date.getFullYear() + '-' + pad2(date.getMonth() + 1) + '-' + pad2(date.getDate())
|
|
151
|
+
+ ' ' + pad2(date.getHours()) + ':' + pad2(date.getMinutes())
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// —— 请求 URL / 请求体 ——
|
|
155
|
+
function sessionsUrl(query) {
|
|
156
|
+
const keyword = typeof query === 'string' ? query.trim() : ''
|
|
157
|
+
return keyword === '' ? API_BASE + '/sessions' : API_BASE + '/sessions?query=' + encodeURIComponent(keyword)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function teamUrl(sessionId) {
|
|
161
|
+
return API_BASE + '/team?sessionId=' + encodeURIComponent(typeof sessionId === 'string' ? sessionId : '')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const DISMISS_URL = API_BASE + '/dismiss'
|
|
165
|
+
|
|
166
|
+
function dismissPayload(sessionId, name) {
|
|
167
|
+
return { sessionId: sessionId, name: name, deleteSession: true }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// —— 响应校验:缺字段回退默认值,形态不对返回可展示的错误 ——
|
|
171
|
+
function normalizeSessionRow(raw) {
|
|
172
|
+
const row = isRecord(raw) ? raw : {}
|
|
173
|
+
const id = pickString(row.id, '')
|
|
174
|
+
return {
|
|
175
|
+
id: id,
|
|
176
|
+
title: pickString(row.title, '') || id || NO_SESSION_TITLE,
|
|
177
|
+
cwd: pickString(row.cwd, ''),
|
|
178
|
+
createdAt: pickNumber(row.createdAt, 0),
|
|
179
|
+
lastActivityAt: pickNumber(row.lastActivityAt, pickNumber(row.createdAt, 0)),
|
|
180
|
+
live: row.live === true,
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function readSessionsPayload(json) {
|
|
185
|
+
if (isRecord(json) === false || Array.isArray(json.sessions) === false) {
|
|
186
|
+
return { ok: false, error: '服务返回的会话列表格式不正确' }
|
|
187
|
+
}
|
|
188
|
+
const sessions = []
|
|
189
|
+
for (const raw of json.sessions) {
|
|
190
|
+
const row = normalizeSessionRow(raw)
|
|
191
|
+
if (row.id !== '') sessions.push(row)
|
|
192
|
+
}
|
|
193
|
+
return { ok: true, sessions: sessions }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function normalizeMemberRow(raw) {
|
|
197
|
+
const row = isRecord(raw) ? raw : {}
|
|
198
|
+
const id = pickString(row.id, '')
|
|
199
|
+
return {
|
|
200
|
+
id: id,
|
|
201
|
+
name: pickString(row.name, '') || id,
|
|
202
|
+
phase: MEMBER_PHASES.indexOf(row.phase) === -1 ? 'active' : row.phase,
|
|
203
|
+
status: MEMBER_STATUSES.indexOf(row.status) === -1 ? 'inactive' : row.status,
|
|
204
|
+
description: pickString(row.description, ''),
|
|
205
|
+
provider: pickString(row.provider, ''),
|
|
206
|
+
context: row.context === 'fork' ? 'fork' : 'fresh',
|
|
207
|
+
model: pickString(row.model, ''),
|
|
208
|
+
error: pickString(row.error, ''),
|
|
209
|
+
dismissed: row.dismissed === true,
|
|
210
|
+
sessionExists: row.sessionExists !== false,
|
|
211
|
+
ownedTasks: pickNumber(row.ownedTasks, 0),
|
|
212
|
+
lastActivityAt: pickNumber(row.lastActivityAt, 0) > 0 ? pickNumber(row.lastActivityAt, 0) : null,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function normalizeDismissedRow(raw) {
|
|
217
|
+
const row = isRecord(raw) ? raw : {}
|
|
218
|
+
const id = pickString(row.id, '')
|
|
219
|
+
return {
|
|
220
|
+
id: id,
|
|
221
|
+
name: pickString(row.name, '') || id,
|
|
222
|
+
dismissedAt: pickNumber(row.dismissedAt, 0),
|
|
223
|
+
sessionDeleted: row.sessionDeleted === true,
|
|
224
|
+
trashPath: pickString(row.trashPath, ''),
|
|
225
|
+
memberStillInRoster: row.memberStillInRoster !== false,
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function readTeamPayload(json) {
|
|
230
|
+
if (isRecord(json) === false || Array.isArray(json.members) === false) {
|
|
231
|
+
return { ok: false, error: '服务返回的团队数据格式不正确' }
|
|
232
|
+
}
|
|
233
|
+
const members = []
|
|
234
|
+
for (const raw of json.members) {
|
|
235
|
+
const row = normalizeMemberRow(raw)
|
|
236
|
+
if (row.id !== '') members.push(row)
|
|
237
|
+
}
|
|
238
|
+
const dismissed = []
|
|
239
|
+
const dismissedSource = Array.isArray(json.dismissed) ? json.dismissed : []
|
|
240
|
+
for (const raw of dismissedSource) {
|
|
241
|
+
const row = normalizeDismissedRow(raw)
|
|
242
|
+
if (row.id !== '') dismissed.push(row)
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
ok: true,
|
|
246
|
+
data: {
|
|
247
|
+
session: isRecord(json.session) ? normalizeSessionRow(json.session) : null,
|
|
248
|
+
members: members,
|
|
249
|
+
dismissed: dismissed,
|
|
250
|
+
},
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function readDismissResult(json, fallback) {
|
|
255
|
+
const source = isRecord(json) ? json : {}
|
|
256
|
+
const base = isRecord(fallback) ? fallback : {}
|
|
257
|
+
return {
|
|
258
|
+
name: pickString(source.name, pickString(base.name, '')),
|
|
259
|
+
id: pickString(source.id, pickString(base.id, '')),
|
|
260
|
+
stopped: source.stopped === true,
|
|
261
|
+
sessionDeleted: source.sessionDeleted === true,
|
|
262
|
+
trashPath: pickString(source.trashPath, ''),
|
|
263
|
+
dismissedAt: pickNumber(source.dismissedAt, 0),
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 选中项跟随列表:仍存在就保留,被过滤掉/未选则落到第一条
|
|
268
|
+
function pickSessionId(sessions, currentId) {
|
|
269
|
+
if (Array.isArray(sessions) === false || sessions.length === 0) return null
|
|
270
|
+
if (typeof currentId === 'string' && currentId !== '' && sessions.some((row) => row.id === currentId)) return currentId
|
|
271
|
+
return sessions[0].id
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// —— 文案 ——
|
|
275
|
+
function confirmDismissText(name) {
|
|
276
|
+
return '确认删除成员「' + String(name === undefined || name === null ? '' : name) + '」?'
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function dismissResultText(result) {
|
|
280
|
+
const row = isRecord(result) ? result : {}
|
|
281
|
+
const name = pickString(row.name, '') || '该成员'
|
|
282
|
+
let text = '已删除成员「' + name + '」'
|
|
283
|
+
text += row.sessionDeleted === true ? ',会话已移入回收站' : ',会话已保留'
|
|
284
|
+
if (pickString(row.trashPath, '') !== '') text += ':' + row.trashPath
|
|
285
|
+
return text
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function apiErrorText(json, status) {
|
|
289
|
+
if (isRecord(json) && typeof json.error === 'string' && json.error.trim() !== '') return json.error.trim()
|
|
290
|
+
return '请求失败(HTTP ' + String(status) + ')'
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function networkErrorText(error) {
|
|
294
|
+
const detail = error !== null && error !== undefined && typeof error.message === 'string' ? error.message : ''
|
|
295
|
+
return detail === ''
|
|
296
|
+
? '无法连接本机服务,请确认 dsh web 仍在运行后重试'
|
|
297
|
+
: '无法连接本机服务,请确认 dsh web 仍在运行后重试(' + detail + ')'
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 网络层:统一 { ok, data | error } 契约,错误一律可展示的中文文案
|
|
301
|
+
async function requestJson(method, url, body) {
|
|
302
|
+
let response = null
|
|
303
|
+
try {
|
|
304
|
+
response = await fetch(url, {
|
|
305
|
+
method: method,
|
|
306
|
+
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
|
|
307
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
308
|
+
})
|
|
309
|
+
} catch (error) {
|
|
310
|
+
return { ok: false, error: networkErrorText(error) }
|
|
311
|
+
}
|
|
312
|
+
let json = null
|
|
313
|
+
try {
|
|
314
|
+
json = await response.json()
|
|
315
|
+
} catch (error) {
|
|
316
|
+
json = null
|
|
317
|
+
}
|
|
318
|
+
if (response.ok !== true) return { ok: false, error: apiErrorText(json, response.status) }
|
|
319
|
+
return { ok: true, data: json }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function ensureStyle(doc) {
|
|
323
|
+
if (doc === null || doc === undefined || typeof doc.getElementById !== 'function') return
|
|
324
|
+
if (doc.getElementById(STYLE_ID) !== null) return
|
|
325
|
+
const style = doc.createElement('style')
|
|
326
|
+
style.id = STYLE_ID
|
|
327
|
+
// 插件自注样式必须自带 data-plugin,否则别的插件材质化时会把本样式算到它名下
|
|
328
|
+
style.setAttribute('data-plugin', PKG_ID)
|
|
329
|
+
style.textContent = STYLE
|
|
330
|
+
if (doc.head !== null && doc.head !== undefined) doc.head.appendChild(style)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function copyText(text) {
|
|
334
|
+
const value = typeof text === 'string' ? text : String(text === undefined || text === null ? '' : text)
|
|
335
|
+
if (value === '') return false
|
|
336
|
+
try {
|
|
337
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard !== undefined && typeof navigator.clipboard.writeText === 'function') {
|
|
338
|
+
await navigator.clipboard.writeText(value)
|
|
339
|
+
return true
|
|
340
|
+
}
|
|
341
|
+
} catch (error) {
|
|
342
|
+
// 剪贴板被浏览器拒绝(非安全上下文/未授权):继续走兜底
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
if (typeof document === 'undefined' || document === null) return false
|
|
346
|
+
const area = document.createElement('textarea')
|
|
347
|
+
area.value = value
|
|
348
|
+
area.setAttribute('readonly', 'readonly')
|
|
349
|
+
area.style.position = 'fixed'
|
|
350
|
+
area.style.top = '-1000px'
|
|
351
|
+
area.style.opacity = '0'
|
|
352
|
+
document.body.appendChild(area)
|
|
353
|
+
area.select()
|
|
354
|
+
const copied = typeof document.execCommand === 'function' ? document.execCommand('copy') : false
|
|
355
|
+
area.remove()
|
|
356
|
+
return copied === true
|
|
357
|
+
} catch (error) {
|
|
358
|
+
return false
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// —— 原生 DOM 小工具(只在浏览器侧调用) ——
|
|
363
|
+
function makeElement(tag, className, text) {
|
|
364
|
+
const node = document.createElement(tag)
|
|
365
|
+
if (className !== undefined && className !== '') node.className = className
|
|
366
|
+
if (text !== undefined && text !== null) node.textContent = String(text)
|
|
367
|
+
return node
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function appendChildren(parent, children) {
|
|
371
|
+
for (const child of children) {
|
|
372
|
+
if (child !== null && child !== undefined) parent.appendChild(child)
|
|
373
|
+
}
|
|
374
|
+
return parent
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// —— 面板控制器:唯一一份 UI 实现 ——
|
|
378
|
+
// React 槽位与测试/e2e 的 mount 都用它;React 只出一层宿主 div。
|
|
379
|
+
function createPanelController() {
|
|
380
|
+
// 样式随首个实例注入:React 槽位与 mount 两条路径都不依赖调用方记得注入
|
|
381
|
+
ensureStyle(typeof document === 'undefined' ? null : document)
|
|
382
|
+
const state = {
|
|
383
|
+
query: '',
|
|
384
|
+
sessions: null,
|
|
385
|
+
sessionsPhase: 'loading',
|
|
386
|
+
sessionsError: null,
|
|
387
|
+
selectedId: null,
|
|
388
|
+
team: null,
|
|
389
|
+
teamPhase: 'idle',
|
|
390
|
+
teamError: null,
|
|
391
|
+
confirm: null,
|
|
392
|
+
notice: null,
|
|
393
|
+
copiedKey: null,
|
|
394
|
+
copyError: null,
|
|
395
|
+
}
|
|
396
|
+
let sessionSeq = 0
|
|
397
|
+
let teamSeq = 0
|
|
398
|
+
let debounceTimer = null
|
|
399
|
+
let copyTimer = null
|
|
400
|
+
let disposed = false
|
|
401
|
+
let started = false
|
|
402
|
+
|
|
403
|
+
const root = makeElement('div', 'ta-root')
|
|
404
|
+
const head = makeElement('header', 'ta-head')
|
|
405
|
+
appendChildren(head, [makeElement('h3', 'ta-title', CARD_TITLE), makeElement('p', 'ta-desc', CARD_DESC)])
|
|
406
|
+
const body = makeElement('div', 'ta-body')
|
|
407
|
+
const listCol = makeElement('div', 'ta-col ta-col--list')
|
|
408
|
+
const searchBox = makeElement('label', 'ta-searchbox')
|
|
409
|
+
const searchInput = makeElement('input', 'ta-search')
|
|
410
|
+
searchInput.type = 'search'
|
|
411
|
+
searchInput.placeholder = '搜索标题 / 目录 / 会话 ID'
|
|
412
|
+
searchInput.setAttribute('aria-label', '搜索会话')
|
|
413
|
+
appendChildren(searchBox, [makeElement('span', 'ta-label', '选择会话'), searchInput])
|
|
414
|
+
const listHost = makeElement('div', 'ta-list-host')
|
|
415
|
+
appendChildren(listCol, [searchBox, listHost])
|
|
416
|
+
const detailCol = makeElement('div', 'ta-col ta-col--detail')
|
|
417
|
+
appendChildren(body, [listCol, detailCol])
|
|
418
|
+
appendChildren(root, [head, body])
|
|
419
|
+
|
|
420
|
+
function render() {
|
|
421
|
+
if (disposed) return
|
|
422
|
+
if (searchInput.value !== state.query) searchInput.value = state.query
|
|
423
|
+
renderList()
|
|
424
|
+
renderDetail()
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function statusText(text) {
|
|
428
|
+
const node = makeElement('p', 'ta-status', text)
|
|
429
|
+
node.setAttribute('role', 'status')
|
|
430
|
+
return node
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function errorBox(text, onRetry) {
|
|
434
|
+
const box = makeElement('div', 'ta-error')
|
|
435
|
+
box.setAttribute('role', 'alert')
|
|
436
|
+
box.appendChild(makeElement('span', '', typeof text === 'string' && text !== '' ? text : '加载失败'))
|
|
437
|
+
if (typeof onRetry === 'function') {
|
|
438
|
+
const retry = makeElement('button', 'ta-btn ta-btn--sm', '重试')
|
|
439
|
+
retry.type = 'button'
|
|
440
|
+
retry.addEventListener('click', onRetry)
|
|
441
|
+
box.appendChild(retry)
|
|
442
|
+
}
|
|
443
|
+
return box
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function hintNode(text) {
|
|
447
|
+
return makeElement('p', 'ta-hint', text)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function renderList() {
|
|
451
|
+
listHost.replaceChildren()
|
|
452
|
+
if (state.sessionsPhase === 'loading' && state.sessions === null) {
|
|
453
|
+
listHost.appendChild(statusText('正在加载会话…'))
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
if (state.sessionsPhase === 'error') {
|
|
457
|
+
listHost.appendChild(errorBox(state.sessionsError, () => { void loadSessions(state.query) }))
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
if (state.sessions === null || state.sessions.length === 0) {
|
|
461
|
+
const keyword = state.query.trim()
|
|
462
|
+
listHost.appendChild(statusText(keyword === '' ? '还没有任何会话。' : '没有匹配「' + keyword + '」的会话。'))
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
const list = makeElement('div', 'ta-sessions')
|
|
466
|
+
for (const row of state.sessions) {
|
|
467
|
+
const active = row.id === state.selectedId
|
|
468
|
+
const button = makeElement('button', 'ta-session' + (active ? ' ta-session--on' : ''))
|
|
469
|
+
button.type = 'button'
|
|
470
|
+
button.setAttribute('aria-pressed', active ? 'true' : 'false')
|
|
471
|
+
const title = makeElement('span', 'ta-session__title', row.title)
|
|
472
|
+
title.title = row.title
|
|
473
|
+
const cwd = makeElement('span', 'ta-session__cwd ta-mono', row.cwd === '' ? '(未知目录)' : row.cwd)
|
|
474
|
+
cwd.title = row.cwd
|
|
475
|
+
const meta = makeElement('span', 'ta-session__meta')
|
|
476
|
+
if (row.live) appendChildren(meta, [makeElement('span', 'ta-dot')])
|
|
477
|
+
meta.appendChild(document.createTextNode((row.live ? '运行中 · ' : '') + '最近活动 ' + formatRelativeTime(row.lastActivityAt, Date.now())))
|
|
478
|
+
appendChildren(button, [title, cwd, meta])
|
|
479
|
+
button.addEventListener('click', () => { selectSession(row.id) })
|
|
480
|
+
list.appendChild(button)
|
|
481
|
+
}
|
|
482
|
+
listHost.appendChild(list)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function renderDetail() {
|
|
486
|
+
detailCol.replaceChildren()
|
|
487
|
+
if (state.selectedId === null) {
|
|
488
|
+
const noSessions = state.sessions !== null && state.sessions.length === 0
|
|
489
|
+
detailCol.appendChild(statusText(noSessions ? '没有可管理的会话。' : '从左侧选择一个会话,查看它的团队成员。'))
|
|
490
|
+
return
|
|
491
|
+
}
|
|
492
|
+
const selectedRow = state.sessions === null
|
|
493
|
+
? null
|
|
494
|
+
: (state.sessions.filter((row) => row.id === state.selectedId)[0] || null)
|
|
495
|
+
const teamSession = state.team !== null ? state.team.session : null
|
|
496
|
+
const headTitle = (teamSession !== null && teamSession.title !== '' ? teamSession.title : '')
|
|
497
|
+
|| (selectedRow !== null ? selectedRow.title : '')
|
|
498
|
+
|| state.selectedId
|
|
499
|
+
const headCwd = teamSession !== null && teamSession.cwd !== ''
|
|
500
|
+
? teamSession.cwd
|
|
501
|
+
: (selectedRow !== null ? selectedRow.cwd : '')
|
|
502
|
+
const header = makeElement('div', 'ta-detail__head')
|
|
503
|
+
const titleBox = makeElement('div', 'ta-detail__title')
|
|
504
|
+
const titleNode = makeElement('div', 'ta-session__title', headTitle)
|
|
505
|
+
titleNode.title = headTitle
|
|
506
|
+
appendChildren(titleBox, [titleNode])
|
|
507
|
+
if (headCwd !== '') {
|
|
508
|
+
const cwdNode = makeElement('div', 'ta-session__cwd ta-mono', headCwd)
|
|
509
|
+
cwdNode.title = headCwd
|
|
510
|
+
titleBox.appendChild(cwdNode)
|
|
511
|
+
}
|
|
512
|
+
const refresh = makeElement('button', 'ta-btn ta-btn--sm', '刷新')
|
|
513
|
+
refresh.type = 'button'
|
|
514
|
+
refresh.disabled = state.teamPhase === 'loading'
|
|
515
|
+
refresh.addEventListener('click', () => { refreshTeam() })
|
|
516
|
+
appendChildren(header, [titleBox, refresh])
|
|
517
|
+
detailCol.appendChild(header)
|
|
518
|
+
|
|
519
|
+
if (state.notice !== null) {
|
|
520
|
+
const notice = makeElement('p', 'ta-notice', state.notice.text)
|
|
521
|
+
notice.setAttribute('role', 'status')
|
|
522
|
+
detailCol.appendChild(notice)
|
|
523
|
+
}
|
|
524
|
+
if (state.teamPhase === 'loading' && state.team === null) {
|
|
525
|
+
detailCol.appendChild(statusText('正在读取团队成员…'))
|
|
526
|
+
return
|
|
527
|
+
}
|
|
528
|
+
if (state.teamPhase === 'error') {
|
|
529
|
+
detailCol.appendChild(errorBox(state.teamError, () => { refreshTeam() }))
|
|
530
|
+
return
|
|
531
|
+
}
|
|
532
|
+
if (state.team === null) return
|
|
533
|
+
if (state.team.members.length === 0 && state.team.dismissed.length === 0) {
|
|
534
|
+
const empty = makeElement('div', 'ta-empty')
|
|
535
|
+
appendChildren(empty, [makeElement('p', '', '该会话没有团队。'), hintNode('先在会话里派一个队友(Agent Teams),再回来刷新。')])
|
|
536
|
+
const reload = makeElement('button', 'ta-btn ta-btn--sm', '刷新')
|
|
537
|
+
reload.type = 'button'
|
|
538
|
+
reload.addEventListener('click', () => { refreshTeam() })
|
|
539
|
+
empty.appendChild(reload)
|
|
540
|
+
detailCol.appendChild(empty)
|
|
541
|
+
return
|
|
542
|
+
}
|
|
543
|
+
detailCol.appendChild(renderMembers(state.team.members))
|
|
544
|
+
detailCol.appendChild(renderDismissed(state.team.dismissed))
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function renderMembers(members) {
|
|
548
|
+
const section = makeElement('section', 'ta-section')
|
|
549
|
+
const sectionHead = makeElement('div', 'ta-section__head')
|
|
550
|
+
appendChildren(sectionHead, [
|
|
551
|
+
makeElement('h4', 'ta-section__title', '团队成员'),
|
|
552
|
+
makeElement('span', 'ta-hint', '共 ' + String(members.length) + ' 人'),
|
|
553
|
+
])
|
|
554
|
+
const list = makeElement('div', 'ta-members')
|
|
555
|
+
for (const member of members) list.appendChild(renderMember(member))
|
|
556
|
+
appendChildren(section, [sectionHead, list])
|
|
557
|
+
return section
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function renderMember(member) {
|
|
561
|
+
const meta = statusMeta(member.status)
|
|
562
|
+
const gone = member.dismissed === true
|
|
563
|
+
const card = makeElement('div', 'ta-member' + (gone ? ' ta-member--gone' : ''))
|
|
564
|
+
const top = makeElement('div', 'ta-member__top')
|
|
565
|
+
const name = makeElement('span', 'ta-member__name', member.name)
|
|
566
|
+
name.title = member.name
|
|
567
|
+
top.appendChild(name)
|
|
568
|
+
const badge = makeElement('span', 'ta-badge ta-badge--' + meta.tone)
|
|
569
|
+
if (meta.tone === 'run') badge.appendChild(makeElement('span', 'ta-dot'))
|
|
570
|
+
badge.appendChild(document.createTextNode(meta.label))
|
|
571
|
+
top.appendChild(badge)
|
|
572
|
+
if (gone) top.appendChild(makeElement('span', 'ta-badge ta-badge--idle', '已删除'))
|
|
573
|
+
if (member.phase === 'failed') top.appendChild(makeElement('span', 'ta-badge ta-badge--bad', '创建失败'))
|
|
574
|
+
top.appendChild(makeElement('span', 'ta-spacer'))
|
|
575
|
+
top.appendChild(makeElement('span', 'ta-hint', '任务 ' + String(member.ownedTasks)))
|
|
576
|
+
if (gone === false) {
|
|
577
|
+
const remove = makeElement('button', 'ta-btn ta-btn--danger ta-btn--sm', '删除')
|
|
578
|
+
remove.type = 'button'
|
|
579
|
+
remove.addEventListener('click', () => { openConfirm(member) })
|
|
580
|
+
top.appendChild(remove)
|
|
581
|
+
}
|
|
582
|
+
card.appendChild(top)
|
|
583
|
+
if (member.description !== '') {
|
|
584
|
+
const desc = makeElement('p', 'ta-desc', member.description)
|
|
585
|
+
desc.title = member.description
|
|
586
|
+
card.appendChild(desc)
|
|
587
|
+
}
|
|
588
|
+
if (member.error !== '') {
|
|
589
|
+
const err = makeElement('p', 'ta-hint ta-hint--bad', '错误:' + member.error)
|
|
590
|
+
err.title = member.error
|
|
591
|
+
card.appendChild(err)
|
|
592
|
+
}
|
|
593
|
+
const metaLine = makeElement('div', 'ta-member__meta')
|
|
594
|
+
const modelText = member.model !== ''
|
|
595
|
+
? member.model
|
|
596
|
+
: (member.provider !== '' ? member.provider + ' 默认模型' : MODEL_FALLBACK)
|
|
597
|
+
metaLine.appendChild(makeElement('span', 'ta-hint', '模型 ' + modelText))
|
|
598
|
+
if (member.provider !== '') metaLine.appendChild(makeElement('span', 'ta-hint', 'provider ' + member.provider))
|
|
599
|
+
metaLine.appendChild(makeElement('span', 'ta-hint', member.context === 'fork' ? 'fork 上下文' : 'fresh 上下文'))
|
|
600
|
+
card.appendChild(metaLine)
|
|
601
|
+
if (state.confirm !== null && state.confirm.id === member.id) card.appendChild(renderConfirm(member))
|
|
602
|
+
return card
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function renderConfirm(member) {
|
|
606
|
+
const busy = state.confirm.busy === true
|
|
607
|
+
const box = makeElement('div', 'ta-confirm')
|
|
608
|
+
box.setAttribute('role', 'group')
|
|
609
|
+
box.setAttribute('aria-label', '删除确认')
|
|
610
|
+
const text = makeElement('div', 'ta-confirm__text')
|
|
611
|
+
appendChildren(text, [makeElement('strong', '', confirmDismissText(member.name)), makeElement('span', 'ta-hint', DELETE_HINT)])
|
|
612
|
+
const actions = makeElement('div', 'ta-confirm__actions')
|
|
613
|
+
const confirm = makeElement('button', 'ta-btn ta-btn--danger ta-btn--sm', busy ? '删除中…' : '确认删除')
|
|
614
|
+
confirm.type = 'button'
|
|
615
|
+
confirm.disabled = busy
|
|
616
|
+
confirm.setAttribute('aria-busy', busy ? 'true' : 'false')
|
|
617
|
+
confirm.addEventListener('click', () => { void confirmDismiss() })
|
|
618
|
+
const cancel = makeElement('button', 'ta-btn ta-btn--sm', '取消')
|
|
619
|
+
cancel.type = 'button'
|
|
620
|
+
cancel.disabled = busy
|
|
621
|
+
cancel.addEventListener('click', () => { state.confirm = null; render() })
|
|
622
|
+
appendChildren(actions, [confirm, cancel])
|
|
623
|
+
appendChildren(box, [text, actions])
|
|
624
|
+
if (state.confirm.error !== null) {
|
|
625
|
+
const err = makeElement('p', 'ta-error', state.confirm.error)
|
|
626
|
+
err.setAttribute('role', 'alert')
|
|
627
|
+
box.appendChild(err)
|
|
628
|
+
}
|
|
629
|
+
return box
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function renderDismissed(dismissed) {
|
|
633
|
+
const section = makeElement('section', 'ta-section')
|
|
634
|
+
const sectionHead = makeElement('div', 'ta-section__head')
|
|
635
|
+
appendChildren(sectionHead, [
|
|
636
|
+
makeElement('h4', 'ta-section__title', '已删除成员'),
|
|
637
|
+
makeElement('span', 'ta-badge', String(dismissed.length)),
|
|
638
|
+
])
|
|
639
|
+
appendChildren(section, [sectionHead, hintNode(ROSTER_NOTE)])
|
|
640
|
+
if (dismissed.length === 0) {
|
|
641
|
+
section.appendChild(hintNode('还没有通过本插件删除的成员。'))
|
|
642
|
+
} else {
|
|
643
|
+
const graves = makeElement('div', 'ta-graves')
|
|
644
|
+
for (const row of dismissed) {
|
|
645
|
+
const grave = makeElement('div', 'ta-grave')
|
|
646
|
+
const graveHead = makeElement('div', 'ta-grave__head')
|
|
647
|
+
appendChildren(graveHead, [
|
|
648
|
+
makeElement('span', 'ta-member__name', row.name),
|
|
649
|
+
makeElement('span', 'ta-hint', '删除于 ' + formatDateTime(row.dismissedAt)),
|
|
650
|
+
makeElement('span', 'ta-badge ' + (row.sessionDeleted ? 'ta-badge--idle' : 'ta-badge--warn'), row.sessionDeleted ? '会话已删除' : '会话仍在'),
|
|
651
|
+
])
|
|
652
|
+
grave.appendChild(graveHead)
|
|
653
|
+
if (row.trashPath !== '') {
|
|
654
|
+
const trash = makeElement('div', 'ta-trash')
|
|
655
|
+
const input = makeElement('input', 'ta-input ta-mono')
|
|
656
|
+
input.readOnly = true
|
|
657
|
+
input.value = row.trashPath
|
|
658
|
+
input.setAttribute('aria-label', '回收站路径')
|
|
659
|
+
input.addEventListener('focus', () => { input.select() })
|
|
660
|
+
const copy = makeElement('button', 'ta-btn ta-btn--sm', state.copiedKey === row.id ? '已复制' : '复制路径')
|
|
661
|
+
copy.type = 'button'
|
|
662
|
+
copy.addEventListener('click', () => { void copyTrash(row) })
|
|
663
|
+
appendChildren(trash, [input, copy])
|
|
664
|
+
grave.appendChild(trash)
|
|
665
|
+
} else {
|
|
666
|
+
grave.appendChild(hintNode('未保留回收站路径(删除时未移除会话)。'))
|
|
667
|
+
}
|
|
668
|
+
graves.appendChild(grave)
|
|
669
|
+
}
|
|
670
|
+
section.appendChild(graves)
|
|
671
|
+
}
|
|
672
|
+
if (state.copyError !== null) {
|
|
673
|
+
const err = makeElement('p', 'ta-error', state.copyError)
|
|
674
|
+
err.setAttribute('role', 'alert')
|
|
675
|
+
section.appendChild(err)
|
|
676
|
+
}
|
|
677
|
+
return section
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function selectSession(id) {
|
|
681
|
+
state.selectedId = id
|
|
682
|
+
state.confirm = null
|
|
683
|
+
state.notice = null
|
|
684
|
+
state.copyError = null
|
|
685
|
+
state.team = null
|
|
686
|
+
state.teamPhase = 'loading'
|
|
687
|
+
state.teamError = null
|
|
688
|
+
render()
|
|
689
|
+
void loadTeam(id)
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function refreshTeam() {
|
|
693
|
+
if (state.selectedId === null) return
|
|
694
|
+
void loadTeam(state.selectedId)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function openConfirm(member) {
|
|
698
|
+
state.confirm = { id: member.id, name: member.name, busy: false, error: null }
|
|
699
|
+
state.notice = null
|
|
700
|
+
render()
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async function loadSessions(keyword) {
|
|
704
|
+
sessionSeq += 1
|
|
705
|
+
const seq = sessionSeq
|
|
706
|
+
state.sessionsPhase = 'loading'
|
|
707
|
+
state.sessionsError = null
|
|
708
|
+
render()
|
|
709
|
+
const outcome = await requestJson('GET', sessionsUrl(keyword))
|
|
710
|
+
if (disposed || seq !== sessionSeq) return
|
|
711
|
+
if (outcome.ok !== true) {
|
|
712
|
+
state.sessionsPhase = 'error'
|
|
713
|
+
state.sessionsError = outcome.error
|
|
714
|
+
render()
|
|
715
|
+
return
|
|
716
|
+
}
|
|
717
|
+
const parsed = readSessionsPayload(outcome.data)
|
|
718
|
+
if (parsed.ok !== true) {
|
|
719
|
+
state.sessionsPhase = 'error'
|
|
720
|
+
state.sessionsError = parsed.error
|
|
721
|
+
render()
|
|
722
|
+
return
|
|
723
|
+
}
|
|
724
|
+
const nextId = pickSessionId(parsed.sessions, state.selectedId)
|
|
725
|
+
state.sessions = parsed.sessions
|
|
726
|
+
state.sessionsPhase = 'ready'
|
|
727
|
+
if (nextId !== state.selectedId) {
|
|
728
|
+
state.selectedId = nextId
|
|
729
|
+
state.confirm = null
|
|
730
|
+
state.notice = null
|
|
731
|
+
state.copyError = null
|
|
732
|
+
state.team = null
|
|
733
|
+
state.teamError = null
|
|
734
|
+
state.teamPhase = nextId === null ? 'idle' : 'loading'
|
|
735
|
+
render()
|
|
736
|
+
if (nextId !== null) void loadTeam(nextId)
|
|
737
|
+
return
|
|
738
|
+
}
|
|
739
|
+
render()
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async function loadTeam(sessionId) {
|
|
743
|
+
teamSeq += 1
|
|
744
|
+
const seq = teamSeq
|
|
745
|
+
state.teamPhase = 'loading'
|
|
746
|
+
state.teamError = null
|
|
747
|
+
render()
|
|
748
|
+
const outcome = await requestJson('GET', teamUrl(sessionId))
|
|
749
|
+
if (disposed || seq !== teamSeq) return
|
|
750
|
+
if (outcome.ok !== true) {
|
|
751
|
+
state.team = null
|
|
752
|
+
state.teamPhase = 'error'
|
|
753
|
+
state.teamError = outcome.error
|
|
754
|
+
render()
|
|
755
|
+
return
|
|
756
|
+
}
|
|
757
|
+
const parsed = readTeamPayload(outcome.data)
|
|
758
|
+
if (parsed.ok !== true) {
|
|
759
|
+
state.team = null
|
|
760
|
+
state.teamPhase = 'error'
|
|
761
|
+
state.teamError = parsed.error
|
|
762
|
+
render()
|
|
763
|
+
return
|
|
764
|
+
}
|
|
765
|
+
state.team = parsed.data
|
|
766
|
+
state.teamPhase = 'ready'
|
|
767
|
+
render()
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
async function confirmDismiss() {
|
|
771
|
+
if (state.confirm === null || state.confirm.busy === true || state.selectedId === null) return
|
|
772
|
+
const target = state.confirm
|
|
773
|
+
state.confirm = { id: target.id, name: target.name, busy: true, error: null }
|
|
774
|
+
render()
|
|
775
|
+
const outcome = await requestJson('POST', DISMISS_URL, dismissPayload(state.selectedId, target.name))
|
|
776
|
+
if (disposed) return
|
|
777
|
+
if (outcome.ok !== true) {
|
|
778
|
+
state.confirm = { id: target.id, name: target.name, busy: false, error: outcome.error }
|
|
779
|
+
render()
|
|
780
|
+
return
|
|
781
|
+
}
|
|
782
|
+
state.confirm = null
|
|
783
|
+
state.notice = { text: dismissResultText(readDismissResult(outcome.data, target)) }
|
|
784
|
+
render()
|
|
785
|
+
await loadTeam(state.selectedId)
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async function copyTrash(row) {
|
|
789
|
+
state.copyError = null
|
|
790
|
+
const copied = await copyText(row.trashPath)
|
|
791
|
+
if (disposed) return
|
|
792
|
+
if (copied !== true) {
|
|
793
|
+
state.copyError = '复制失败,请手动选中路径后复制'
|
|
794
|
+
render()
|
|
795
|
+
return
|
|
796
|
+
}
|
|
797
|
+
state.copiedKey = row.id
|
|
798
|
+
render()
|
|
799
|
+
if (copyTimer !== null) clearTimeout(copyTimer)
|
|
800
|
+
copyTimer = setTimeout(() => {
|
|
801
|
+
copyTimer = null
|
|
802
|
+
if (disposed) return
|
|
803
|
+
if (state.copiedKey === row.id) state.copiedKey = null
|
|
804
|
+
render()
|
|
805
|
+
}, COPIED_RESET_MS)
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
searchInput.addEventListener('input', () => {
|
|
809
|
+
state.query = searchInput.value
|
|
810
|
+
if (debounceTimer !== null) clearTimeout(debounceTimer)
|
|
811
|
+
debounceTimer = setTimeout(() => {
|
|
812
|
+
debounceTimer = null
|
|
813
|
+
void loadSessions(state.query)
|
|
814
|
+
}, DEBOUNCE_MS)
|
|
815
|
+
render()
|
|
816
|
+
})
|
|
817
|
+
|
|
818
|
+
function start() {
|
|
819
|
+
if (started || disposed) return
|
|
820
|
+
started = true
|
|
821
|
+
void loadSessions('')
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function dispose() {
|
|
825
|
+
disposed = true
|
|
826
|
+
if (debounceTimer !== null) clearTimeout(debounceTimer)
|
|
827
|
+
if (copyTimer !== null) clearTimeout(copyTimer)
|
|
828
|
+
debounceTimer = null
|
|
829
|
+
copyTimer = null
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
render()
|
|
833
|
+
return { root: root, start: start, dispose: dispose, render: render }
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// 测试 / e2e 直接挂载:不依赖 ctx.slots,也不依赖 React。
|
|
837
|
+
function mount(container) {
|
|
838
|
+
if (container === null || container === undefined || typeof container.appendChild !== 'function') {
|
|
839
|
+
throw new Error('mount(container) 需要一个可挂载的 DOM 元素')
|
|
840
|
+
}
|
|
841
|
+
ensureStyle(typeof document === 'undefined' ? null : document)
|
|
842
|
+
const controller = createPanelController()
|
|
843
|
+
container.appendChild(controller.root)
|
|
844
|
+
controller.start()
|
|
845
|
+
return {
|
|
846
|
+
root: controller.root,
|
|
847
|
+
refresh: () => { controller.render() },
|
|
848
|
+
unmount() {
|
|
849
|
+
controller.dispose()
|
|
850
|
+
if (controller.root.parentNode === container) container.removeChild(controller.root)
|
|
851
|
+
},
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function factory(require) {
|
|
856
|
+
let React = null
|
|
857
|
+
try {
|
|
858
|
+
React = require('react')
|
|
859
|
+
} catch (error) {
|
|
860
|
+
// 宿主种子表异常时干净禁用,但必须显式报错,禁止静默
|
|
861
|
+
console.error('[team-admin] react 不可用,团队管理卡片已禁用:', error)
|
|
862
|
+
return { inject: [], apply() {} }
|
|
863
|
+
}
|
|
864
|
+
const h = React.createElement
|
|
865
|
+
|
|
866
|
+
// React 只负责一块宿主 div;面板内容由同一个 controller 渲染。
|
|
867
|
+
function TeamAdminPanel() {
|
|
868
|
+
const hostRef = React.useRef(null)
|
|
869
|
+
React.useEffect(() => {
|
|
870
|
+
const container = hostRef.current
|
|
871
|
+
if (container === null || container === undefined || typeof container.appendChild !== 'function') return undefined
|
|
872
|
+
const controller = createPanelController()
|
|
873
|
+
container.appendChild(controller.root)
|
|
874
|
+
controller.start()
|
|
875
|
+
return () => {
|
|
876
|
+
controller.dispose()
|
|
877
|
+
if (controller.root.parentNode === container) container.removeChild(controller.root)
|
|
878
|
+
}
|
|
879
|
+
}, [])
|
|
880
|
+
return h('div', { className: 'ta-host', ref: hostRef })
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// 配置页只给 view(summary | page):summary 一行说明,page 完整面板
|
|
884
|
+
function TeamAdminCard(props) {
|
|
885
|
+
const view = props !== null && props !== undefined && props.view === 'summary' ? 'summary' : 'page'
|
|
886
|
+
return view === 'summary' ? h('div', { className: 'ta-summary' }, CARD_SUMMARY) : h(TeamAdminPanel, null)
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// 侧栏入口图标:sidebar.panellist 渲染契约组件,宿主传 { size, active }
|
|
890
|
+
function TeamAdminIcon(props) {
|
|
891
|
+
const size = props !== null && props !== undefined && typeof props.size === 'number' ? props.size : 16
|
|
892
|
+
return h('svg', {
|
|
893
|
+
viewBox: '0 0 16 16',
|
|
894
|
+
width: size,
|
|
895
|
+
height: size,
|
|
896
|
+
fill: 'none',
|
|
897
|
+
stroke: 'currentColor',
|
|
898
|
+
strokeWidth: 1.3,
|
|
899
|
+
strokeLinecap: 'round',
|
|
900
|
+
strokeLinejoin: 'round',
|
|
901
|
+
'aria-hidden': 'true',
|
|
902
|
+
},
|
|
903
|
+
h('circle', { cx: 5.6, cy: 5.4, r: 2.2 }),
|
|
904
|
+
h('path', { d: 'M1.9 13.2c0-2 1.6-3.3 3.7-3.3s3.7 1.3 3.7 3.3' }),
|
|
905
|
+
h('circle', { cx: 11.3, cy: 6.2, r: 1.8 }),
|
|
906
|
+
h('path', { d: 'M9.7 12.4c.3-1.5 1.3-2.3 2.6-2.3 1.4 0 2.4 1 2.6 2.5' }))
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// 中央面板页:整页壳 + 与配置卡同一份 controller(不复制 UI)
|
|
910
|
+
function TeamAdminPage() {
|
|
911
|
+
return h('div', { className: 'ta-main' }, h('div', { className: 'ta-main__inner' }, h(TeamAdminPanel, null)))
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
return {
|
|
915
|
+
inject: ['slots'],
|
|
916
|
+
apply(ctx) {
|
|
917
|
+
const slots = ctx !== null && ctx !== undefined && ctx.slots !== undefined ? ctx.slots : null
|
|
918
|
+
if (slots === null || typeof slots.inject !== 'function' || typeof slots.register !== 'function') {
|
|
919
|
+
console.warn('[team-admin] 宿主未提供 slots 服务,配置卡片未注册')
|
|
920
|
+
return
|
|
921
|
+
}
|
|
922
|
+
ensureStyle(typeof document === 'undefined' ? null : document)
|
|
923
|
+
const effect = typeof ctx.effect === 'function'
|
|
924
|
+
? (fn, label) => ctx.effect(fn, label)
|
|
925
|
+
: (fn) => {
|
|
926
|
+
const disposer = fn()
|
|
927
|
+
return typeof disposer === 'function' ? disposer : () => {}
|
|
928
|
+
}
|
|
929
|
+
// 全局面板入口:main keyed 条目出中央页面,sidebar.panellist 条目出侧栏入口行;
|
|
930
|
+
// 两者用同一个 id 配对,点击入口由宿主 layout.selectPanel 切换。先注册 main,
|
|
931
|
+
// 侧栏行一旦可点就有页面可切(selectPanel 对未注册 key 会抛错)。
|
|
932
|
+
effect(() => {
|
|
933
|
+
const disposers = []
|
|
934
|
+
disposers.push(slots.inject('main', () => slots.register({
|
|
935
|
+
name: 'main',
|
|
936
|
+
key: PANEL_ID,
|
|
937
|
+
}, TeamAdminPage)))
|
|
938
|
+
disposers.push(slots.inject('sidebar.panellist', () => slots.register({
|
|
939
|
+
name: 'sidebar.panellist',
|
|
940
|
+
id: PANEL_ID,
|
|
941
|
+
order: PANEL_ORDER,
|
|
942
|
+
label: PANEL_LABEL,
|
|
943
|
+
}, TeamAdminIcon)))
|
|
944
|
+
return () => {
|
|
945
|
+
for (const dispose of disposers) {
|
|
946
|
+
if (typeof dispose === 'function') dispose()
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}, 'team-admin: main + sidebar.panellist')
|
|
950
|
+
effect(() => slots.inject('plugins.bundle.config', () => slots.register({
|
|
951
|
+
name: 'plugins.bundle.config',
|
|
952
|
+
key: PKG_ID,
|
|
953
|
+
}, TeamAdminCard)), 'team-admin: plugins.bundle.config')
|
|
954
|
+
// 尽力兼容设置页插件分区:宿主不声明该槽时 inject 挂起、零成本
|
|
955
|
+
effect(() => slots.inject('settings.plugin.item', () => slots.register({
|
|
956
|
+
name: 'settings.plugin.item',
|
|
957
|
+
key: SETTINGS_KEY,
|
|
958
|
+
label: CARD_TITLE,
|
|
959
|
+
}, TeamAdminCard)), 'team-admin: settings.plugin.item')
|
|
960
|
+
},
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
if (typeof globalThis !== 'undefined' && globalThis.__DshTeamAdminTestEnabled === true) {
|
|
965
|
+
globalThis.__DshTeamAdminTest = {
|
|
966
|
+
mount: mount,
|
|
967
|
+
helpers: {
|
|
968
|
+
PKG_ID: PKG_ID,
|
|
969
|
+
SETTINGS_KEY: SETTINGS_KEY,
|
|
970
|
+
PANEL_ID: PANEL_ID,
|
|
971
|
+
PANEL_LABEL: PANEL_LABEL,
|
|
972
|
+
PANEL_ORDER: PANEL_ORDER,
|
|
973
|
+
API_BASE: API_BASE,
|
|
974
|
+
DEBOUNCE_MS: DEBOUNCE_MS,
|
|
975
|
+
CARD_TITLE: CARD_TITLE,
|
|
976
|
+
CARD_SUMMARY: CARD_SUMMARY,
|
|
977
|
+
ROSTER_NOTE: ROSTER_NOTE,
|
|
978
|
+
DELETE_HINT: DELETE_HINT,
|
|
979
|
+
statusMeta: statusMeta,
|
|
980
|
+
formatRelativeTime: formatRelativeTime,
|
|
981
|
+
formatDateTime: formatDateTime,
|
|
982
|
+
sessionsUrl: sessionsUrl,
|
|
983
|
+
teamUrl: teamUrl,
|
|
984
|
+
dismissPayload: dismissPayload,
|
|
985
|
+
readSessionsPayload: readSessionsPayload,
|
|
986
|
+
readTeamPayload: readTeamPayload,
|
|
987
|
+
readDismissResult: readDismissResult,
|
|
988
|
+
pickSessionId: pickSessionId,
|
|
989
|
+
confirmDismissText: confirmDismissText,
|
|
990
|
+
dismissResultText: dismissResultText,
|
|
991
|
+
apiErrorText: apiErrorText,
|
|
992
|
+
networkErrorText: networkErrorText,
|
|
993
|
+
copyText: copyText,
|
|
994
|
+
},
|
|
995
|
+
factory: factory,
|
|
996
|
+
createPanelController: createPanelController,
|
|
997
|
+
ensureStyle: ensureStyle,
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// 宿主模块表:以包名注册,factory 返回 { inject, apply }
|
|
1002
|
+
if (typeof window !== 'undefined' && window !== null && window.__ModuleLoader__ !== undefined && window.__ModuleLoader__ !== null
|
|
1003
|
+
&& typeof window.__ModuleLoader__.load === 'function') {
|
|
1004
|
+
window.__ModuleLoader__.load({ id: PKG_ID, factory: factory })
|
|
1005
|
+
}
|
|
1006
|
+
})()
|