dsh-remote-plugin 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,228 @@
1
+ /* Shared read-only context/cost UI. No plugin UI bundles, scripts or settings. */
2
+ ;(function (root) {
3
+ 'use strict'
4
+ const obj = v => v && typeof v === 'object' && !Array.isArray(v) ? v : null
5
+ const num = v => typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null
6
+ const esc = v => String(v ?? '').slice(0, 240).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
7
+ const categories = ['system', 'tools', 'user', 'inject', 'skill', 'assistant', 'tool']
8
+ const labels = {
9
+ zh: { title: '会话洞察', context: '上下文', cost: '费用与额度', close: '关闭', refresh: '刷新', loading: '正在读取…',
10
+ none: '选择一个会话后查看洞察。', missing: '此主机尚未提供 dsh-context 数据。请安装并启用插件后刷新。',
11
+ unavailable: '读取失败,请重试。已有概览仍可查看。', unsupported: '当前插件或 DSH 版本暂不支持此数据格式。',
12
+ costMissing: '此主机尚未提供费用数据。需要启用 dsh-cost-meter,并更新主机端 Remote 插件。',
13
+ occupancy: '上下文占用', modelName: '当前模型', estimated: '组成 Token 为插件估算;占用优先使用 DSH 官方投影。',
14
+ system: '系统提示', tools: '工具定义', user: '用户消息', inject: '注入内容', skill: '技能', assistant: '助手消息', tool: '工具结果',
15
+ composition: '当前组成', events: '最近上下文变化', noEvents: '尚无压缩、裁剪或注入记录。',
16
+ compaction: '压缩', prune: '裁剪', model: '模型变更', mode: '模式变更', counts: '累计变化',
17
+ detailsFailed: '变化记录暂不可用,点击刷新重试。', detailsLoading: '正在读取变化记录…',
18
+ session: '当前会话 API 费用', today: '今日 API 费用', equivalent: '含套餐等值估算', subagents: '子代理 API 费用(单列)',
19
+ balance: '账户余额', plans: '套餐额度 · 已用', source: '数据来源', assembled: '摘要读取于', queried: '供应商查询于',
20
+ estimateNote: '费用由插件账本按用量估算,以供应商账单为准;套餐等值金额不计入 API 费用,子代理费用单独列出。',
21
+ sessionMissing: '插件账本尚无此会话记录', noPlans: '主机未启用可展示的套餐额度。', off: '主机未开启查询',
22
+ error: '供应商查询失败', stale: '缓存数据', unknown: '暂无有效数据', unlimited: '不限量', reset: '重置',
23
+ rolling: '滚动窗口', weekly: '每周', monthly: '每月', date: '主机记账日', details: '明细' },
24
+ en: { title: 'Session insights', context: 'Context', cost: 'Cost & quotas', close: 'Close', refresh: 'Refresh', loading: 'Loading…',
25
+ none: 'Select a session to view insights.', missing: 'This host has no dsh-context data. Enable the plugin, then refresh.',
26
+ unavailable: 'Could not read data. Retry; the current overview is still available.', unsupported: 'This plugin or DSH version uses an unsupported data format.',
27
+ costMissing: 'Enable dsh-cost-meter and update the host Remote plugin to read costs.', occupancy: 'Context occupancy', modelName: 'Current model',
28
+ estimated: 'Composition tokens are plugin estimates; occupancy uses the DSH projection when available.',
29
+ system: 'System prompt', tools: 'Tool definitions', user: 'User messages', inject: 'Injections', skill: 'Skills', assistant: 'Assistant', tool: 'Tool results',
30
+ composition: 'Current composition', events: 'Recent context changes', noEvents: 'No compaction, pruning or injection records yet.',
31
+ compaction: 'Compaction', prune: 'Pruning', model: 'Model change', mode: 'Mode change', counts: 'Total changes',
32
+ detailsFailed: 'Change records are unavailable. Refresh to retry.', detailsLoading: 'Loading change records…',
33
+ session: 'Session API cost', today: 'Today API cost', equivalent: 'Estimate including plan equivalents', subagents: 'Subagent API cost (separate)',
34
+ balance: 'Account balance', plans: 'Plan quotas · used', source: 'Source', assembled: 'Summary read at', queried: 'Provider queried at',
35
+ estimateNote: 'Costs are usage estimates from the plugin ledger. Provider invoices are authoritative. Plan equivalents and subagents are shown separately.',
36
+ sessionMissing: 'No ledger record for this session yet', noPlans: 'No visible plan quota is enabled on this host.', off: 'Query disabled on host',
37
+ error: 'Provider query failed', stale: 'Cached data', unknown: 'No valid data', unlimited: 'Unlimited', reset: 'Resets',
38
+ rolling: 'Rolling window', weekly: 'Weekly', monthly: 'Monthly', date: 'Host ledger date', details: 'Details' },
39
+ }
40
+ let lang = 'zh'
41
+ const t = key => labels[lang][key] || key
42
+ const tokens = v => num(v) === null ? '—' : new Intl.NumberFormat(lang === 'zh' ? 'zh-CN' : 'en-US', { maximumFractionDigits: 0 }).format(v)
43
+ const money = (v, currency = 'USD', digits = 4) => num(v) === null || !/^[A-Z]{3}$/.test(currency) ? '—' : currency + ' ' + v.toFixed(digits)
44
+ const time = v => typeof v === 'number' && v > 0 && v < 8640000000000000 ? new Date(v).toLocaleString(lang === 'zh' ? 'zh-CN' : 'en-US') : '—'
45
+ function contextOf(value) {
46
+ const data = obj(value), current = obj(data?.current)
47
+ if (!data || !current || num(current.total) === null) return null
48
+ if (categories.some(key => num(current[key]) === null)) return null
49
+ return { ...data, current: Object.fromEntries([...categories, 'total'].map(key => [key, current[key]])) }
50
+ }
51
+ function costOf(value) {
52
+ const data = obj(value)
53
+ if (data?.schema !== 1 || data.available !== true || data.source !== 'dsh-cost-meter' || data.currency !== 'USD') return null
54
+ return data
55
+ }
56
+ function metric(label, value, note = '') {
57
+ return `<div class="ins-metric"><span>${esc(label)}</span><strong>${esc(value)}</strong>${note ? `<small>${esc(note)}</small>` : ''}</div>`
58
+ }
59
+ function bar(percent, label) {
60
+ const value = num(percent)
61
+ return value === null ? '' : `<div class="ins-progress" role="progressbar" aria-label="${esc(label)}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.min(value, 100)}"><span style="width:${Math.min(value, 100)}%"></span></div>`
62
+ }
63
+ function contextHtml(session, detail, detailStatus) {
64
+ const values = obj(session?.projections?.values) || {}, head = contextOf(values.contextTimeline)
65
+ // Only combine a detail with the projection revision that requested it.
66
+ const compatible = detail && (!head || head.detailRev === detail.rev)
67
+ const timeline = head || (compatible ? contextOf(detail.head) : null)
68
+ if (timeline?.unsupported) return `<p class="ins-empty">${esc(t('unsupported'))}</p>`
69
+ if (!timeline) return `<p class="ins-empty">${esc(detailStatus === 'loading' ? t('loading') : detailStatus === 'error' ? t('unavailable') : t('missing'))}</p>`
70
+ const pressure = obj(values.contextPressure), occupied = num(pressure?.pressureTokens) ?? timeline.current.total
71
+ const window = num(pressure?.contextWindow) ?? num(timeline.contextWindow), percent = window > 0 ? occupied / window * 100 : null
72
+ let html = `<section class="ins-section"><div class="ins-section-head"><h3>${esc(t('occupancy'))}</h3><span class="ins-badge">dsh-context</span></div>
73
+ <div class="ins-metrics">${metric(t('occupancy'), tokens(occupied), window > 0 ? '/ ' + tokens(window) + ' tokens' : '')}${metric(t('modelName'), timeline.model || '—', timeline.provider || '')}</div>
74
+ ${bar(percent, t('occupancy'))}<p class="ins-note">${esc(t('estimated'))}</p></section>
75
+ <section class="ins-section"><h3>${esc(t('composition'))}</h3><div class="ins-composition">`
76
+ for (const key of categories) {
77
+ const count = timeline.current[key], share = timeline.current.total > 0 ? count / timeline.current.total * 100 : 0
78
+ html += `<div class="ins-category"><span>${esc(t(key))}</span><span class="ins-track"><i style="width:${Math.min(share, 100)}%"></i></span><strong>${tokens(count)}</strong></div>`
79
+ }
80
+ html += '</div></section>'
81
+ const counts = obj(timeline.counts)
82
+ if (counts) html += `<section class="ins-section"><h3>${esc(t('counts'))}</h3><div class="ins-metrics">${['compactions', 'prunes', 'injects'].map((key, i) => metric(t(['compaction', 'prune', 'inject'][i]), tokens(counts[key]))).join('')}</div></section>`
83
+ const events = head?.detailRev !== undefined ? (compatible ? detail.events : []) : timeline.events
84
+ html += `<section class="ins-section"><h3>${esc(t('events'))}</h3>`
85
+ if (head?.detailRev !== undefined && !compatible) html += `<p class="ins-note">${esc(t(detailStatus === 'loading' ? 'detailsLoading' : 'detailsFailed'))}</p>`
86
+ else if (!Array.isArray(events) || !events.length) html += `<p class="ins-note">${esc(t('noEvents'))}</p>`
87
+ else html += '<ol class="ins-events">' + events.filter(row => obj(row) && ['compaction', 'prune', 'inject', 'model', 'mode'].includes(row.kind)).slice(-8).reverse().map(row => `<li><div><strong>${esc(t(row.kind))}</strong><time>${esc(time(row.time))}</time></div><span>${esc(row.name || row.sub || '')}${num(row.tokens) !== null ? ' · ' + tokens(row.tokens) + ' tokens' : ''}</span></li>`).join('') + '</ol>'
88
+ return html + `</section><p class="ins-source">${esc(t('source'))} · dsh-context / DSH</p>`
89
+ }
90
+ function costHtml(raw, phase) {
91
+ const data = costOf(raw)
92
+ if (!data) return `<p class="ins-empty">${esc(t(phase === 'loading' ? 'loading' : phase === 'error' ? 'unavailable' : raw?.available === false ? 'costMissing' : 'unsupported'))}</p>`
93
+ const own = data.session?.found === true ? data.session.own : null
94
+ let html = (phase === 'error' ? `<p role="status" class="ins-note">${esc(t('unavailable'))}</p>` : '') + `<section class="ins-section"><div class="ins-section-head"><h3>${esc(t('cost'))}</h3><span class="ins-badge">dsh-cost-meter</span></div><div class="ins-metrics">
95
+ ${metric(t('session'), money(own?.apiCost), own ? '' : t('sessionMissing'))}${metric(t('today'), money(data.today?.apiCost), data.day ? t('date') + ' · ' + data.day : '')}
96
+ ${own && num(own.cost) !== null && own.cost !== own.apiCost ? metric(t('equivalent'), money(own.cost)) : ''}
97
+ ${num(data.session?.subagentCount) > 0 ? metric(t('subagents'), money(data.session?.subagents?.apiCost)) : ''}</div><p class="ins-note">${esc(t('estimateNote'))}</p></section>`
98
+ if (obj(data.balance)) {
99
+ const balance = data.balance
100
+ html += `<section class="ins-section"><h3>${esc(t('balance'))}</h3><div class="ins-metrics">${metric(t('balance'), balance.status === 'ok' ? money(balance.total, balance.currency, 2) : '—', balance.status === 'ok' ? t('queried') + ' · ' + time(balance.fetchedAt) : t(['off', 'error', 'stale'].includes(balance.status) ? balance.status : 'unknown'))}</div></section>`
101
+ }
102
+ html += `<section class="ins-section"><h3>${esc(t('plans'))}</h3>`
103
+ const plans = Array.isArray(data.plans) ? data.plans.slice(0, 20) : []
104
+ if (!plans.length) html += `<p class="ins-note">${esc(t('noPlans'))}</p>`
105
+ for (const rawPlan of plans) {
106
+ const plan = obj(rawPlan)
107
+ if (!plan) continue
108
+ html += `<div class="ins-plan"><strong>${esc(plan.name)}</strong>`
109
+ if (plan.status !== 'ok') html += `<p class="ins-note">${esc(t(['off', 'error', 'stale'].includes(plan.status) ? plan.status : 'unknown'))}</p>`
110
+ else {
111
+ const rows = Array.isArray(plan.windows) ? plan.windows.slice(0, 8) : []
112
+ if (!rows.length) html += `<p class="ins-note">${esc(t('unknown'))}</p>`
113
+ for (const rawRow of rows) {
114
+ const row = obj(rawRow)
115
+ if (!row) continue
116
+ const percent = num(row.percent)
117
+ html += `<div class="ins-plan-row"><span>${esc(t(row.name))}</span><strong>${row.unlimited === true ? esc(t('unlimited')) : percent !== null && percent <= 100 ? percent.toFixed(1) + '%' : '—'}</strong></div>${row.unlimited === true ? '' : bar(percent, plan.name + ' ' + row.name)}${row.resetsAt ? `<small>${esc(t('reset'))} · ${esc(row.resetsAt)}</small>` : ''}`
118
+ }
119
+ }
120
+ html += `<small>${esc(t('queried'))} · ${esc(time(plan.fetchedAt))}</small></div>`
121
+ }
122
+ return html + `</section><p class="ins-source">${esc(t('source'))} · dsh-cost-meter<br>${esc(t('assembled'))} · ${esc(time(data.generatedAt))}</p>`
123
+ }
124
+
125
+ let dialog, options, tab = 'context', generation = 0, controller, poll, debounce
126
+ let detail = null, detailStatus = '', cost = null, costStatus = '', detailTask = false, detailRevision
127
+ function current() { return options?.getSession?.() }
128
+ function valid(g) { return g === generation && dialog?.open && options.connection.valid() && current()?.sessionId === options.sessionId }
129
+ function render() {
130
+ if (!dialog?.open) return
131
+ if (!valid(generation)) { close(); return }
132
+ dialog.querySelector('.ins-content').innerHTML = tab === 'context' ? contextHtml(current(), detail, detailStatus) : costHtml(cost, costStatus)
133
+ for (const button of dialog.querySelectorAll('[data-ins-tab]')) {
134
+ const active = button.dataset.insTab === tab
135
+ button.setAttribute('aria-selected', String(active)); button.tabIndex = active ? 0 : -1
136
+ }
137
+ dialog.querySelector('.ins-content').setAttribute('aria-labelledby', 'ins-tab-' + tab)
138
+ }
139
+ async function request(path, body, g) {
140
+ if (!valid(g)) throw new Error('stale')
141
+ const connection = options.connection
142
+ const parent = controller.signal, attempt = new AbortController()
143
+ const abort = () => attempt.abort()
144
+ parent.addEventListener('abort', abort, { once: true })
145
+ const deadline = setTimeout(abort, 25000)
146
+ try {
147
+ const response = await fetch(options.url(path), { method: body ? 'POST' : 'GET', headers: { authorization: 'Bearer ' + connection.token,
148
+ 'x-dsh-remote-client': 'web', 'content-type': 'application/json' }, ...(body ? { body: JSON.stringify(body) } : {}), signal: attempt.signal })
149
+ if (!valid(g)) throw new Error('stale')
150
+ if (!response.ok) { const error = new Error('read-failed'); error.status = response.status; throw error }
151
+ const source = await response.text()
152
+ if (!valid(g)) throw new Error('stale')
153
+ if (source.length > 2 * 1024 * 1024) throw new Error('too-large')
154
+ const data = JSON.parse(source)
155
+ if (!obj(data) || data.ok !== true) throw new Error('invalid-response')
156
+ return data
157
+ } finally { clearTimeout(deadline); parent.removeEventListener('abort', abort) }
158
+ }
159
+ async function loadContext(force = false) {
160
+ const g = generation, head = contextOf(current()?.projections?.values?.contextTimeline)
161
+ if (!valid(g) || tab !== 'context' || detailTask || head?.unsupported) return
162
+ if (head && head.detailRev === undefined) { detailStatus = ''; render(); return }
163
+ const revision = head?.detailRev ?? null
164
+ if (!force && detail && detailRevision === revision) return
165
+ detailTask = true; detailStatus = 'loading'; render()
166
+ try {
167
+ const data = await request('/api/dsh-context/detail', { sessionId: options.sessionId }, g)
168
+ if (!valid(g)) return
169
+ const next = obj(data.value), newest = contextOf(current()?.projections?.values?.contextTimeline)
170
+ if (next && num(next.rev) !== null && Array.isArray(next.events) && contextOf(next.head)) {
171
+ if (newest?.detailRev !== undefined && newest.detailRev !== next.rev) { detailStatus = 'changed'; return }
172
+ detail = { rev: next.rev, head: next.head, events: next.events }; detailRevision = newest?.detailRev ?? null; detailStatus = ''
173
+ } else if (next === null) detailStatus = 'missing'
174
+ else detailStatus = 'error'
175
+ } catch (error) { if (valid(g)) detailStatus = error.status === 404 ? 'missing' : 'error' }
176
+ finally {
177
+ if (g === generation) { detailTask = false; render(); if (detailStatus === 'changed') scheduleContext() }
178
+ }
179
+ }
180
+ async function loadCost() {
181
+ const g = generation
182
+ if (!valid(g) || tab !== 'cost' || costStatus === 'loading') return
183
+ costStatus = 'loading'; render()
184
+ try {
185
+ const data = await request('/remote/api/insights/cost?sessionId=' + encodeURIComponent(options.sessionId), null, g)
186
+ if (!valid(g)) return
187
+ if (data.available === false || costOf(data)) { cost = data; costStatus = '' }
188
+ else costStatus = 'error'
189
+ } catch (error) { if (valid(g)) { costStatus = 'error'; if (error.status === 404) { cost = { available: false }; costStatus = '' } } }
190
+ finally { if (valid(g)) render() }
191
+ }
192
+ function scheduleContext() { clearTimeout(debounce); debounce = setTimeout(() => loadContext(), 400) }
193
+ function close() {
194
+ generation++; controller?.abort(); clearInterval(poll); clearTimeout(debounce)
195
+ if (dialog?.open) dialog.close()
196
+ options = null; detail = null; cost = null; detailTask = false; detailStatus = ''; costStatus = ''
197
+ }
198
+ function open(opts) {
199
+ close(); options = opts; tab = opts.tab === 'cost' ? 'cost' : 'context'; lang = root.I18N?.lang === 'en' ? 'en' : 'zh'
200
+ options.sessionId = current()?.sessionId
201
+ if (!options.sessionId) return
202
+ controller = new AbortController(); detailRevision = undefined
203
+ if (!dialog) {
204
+ dialog = document.createElement('dialog'); dialog.className = 'ins-dialog'; document.body.append(dialog)
205
+ dialog.addEventListener('cancel', event => { event.preventDefault(); close() })
206
+ dialog.addEventListener('close', () => { if (options && !dialog.open) close() })
207
+ }
208
+ dialog.innerHTML = `<header class="ins-header"><div><h2 id="ins-title">${esc(t('title'))}</h2><p>${esc(opts.getTitle?.() || options.sessionId)}</p></div><button type="button" data-ins-close aria-label="${esc(t('close'))}">×</button></header>
209
+ <nav class="ins-tabs" role="tablist" aria-label="${esc(t('title'))}"><button type="button" role="tab" id="ins-tab-context" data-ins-tab="context" aria-controls="ins-content">${esc(t('context'))}</button><button type="button" role="tab" id="ins-tab-cost" data-ins-tab="cost" aria-controls="ins-content">${esc(t('cost'))}</button><button type="button" data-ins-refresh>${esc(t('refresh'))}</button></nav><div id="ins-content" class="ins-content" role="tabpanel" tabindex="0"></div>`
210
+ dialog.setAttribute('aria-labelledby', 'ins-title')
211
+ dialog.querySelector('[data-ins-close]').addEventListener('click', close)
212
+ function select(value) { tab = value; render(); tab === 'cost' ? loadCost() : loadContext() }
213
+ dialog.querySelectorAll('[data-ins-tab]').forEach(button => {
214
+ button.addEventListener('click', () => select(button.dataset.insTab))
215
+ button.addEventListener('keydown', event => {
216
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
217
+ event.preventDefault(); select(event.key === 'Home' ? 'context' : event.key === 'End' ? 'cost' : tab === 'context' ? 'cost' : 'context')
218
+ dialog.querySelector('[data-ins-tab="' + tab + '"]').focus()
219
+ })
220
+ })
221
+ dialog.querySelector('[data-ins-refresh]').addEventListener('click', () => tab === 'cost' ? loadCost() : loadContext(true))
222
+ dialog.showModal(); render(); tab === 'cost' ? loadCost() : loadContext()
223
+ poll = setInterval(() => { if (!valid(generation)) close(); else if (tab === 'cost') loadCost(); else { render(); loadContext() } }, 60000)
224
+ }
225
+ const api = { open, close, update() { if (dialog?.open) { render(); if (dialog.open && tab === 'context') scheduleContext() } }, contextOf, costOf, contextHtml, costHtml }
226
+ root.DshInsights = api
227
+ if (typeof module !== 'undefined') module.exports = api
228
+ })(typeof window !== 'undefined' ? window : globalThis)
@@ -1 +1,89 @@
1
- .pc-dialog{width:min(860px,94vw);max-height:88dvh;box-sizing:border-box;border:1px solid #7775;border-radius:18px;background:var(--dsr-panel,#171b24);color:var(--dsr-text,#eee);padding:22px;overflow:auto}.pc-dialog::backdrop{background:#0009}.pc-heading,.pc-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.pc-heading{justify-content:space-between}.pc-dialog h2,.pc-dialog h3{margin:0}.pc-dialog h3{font-size:16px;overflow-wrap:anywhere}.pc-dialog p{line-height:1.6;overflow-wrap:anywhere}.pc-dialog button,.pc-dialog input{font:inherit;border:1px solid #8886;border-radius:9px;padding:9px 12px;background:transparent;color:inherit}.pc-dialog button{cursor:pointer}.pc-dialog button:disabled{opacity:.4;cursor:not-allowed}.pc-dialog button:focus-visible,.pc-dialog input:focus-visible{outline:2px solid #6e9dff;outline-offset:2px}.pc-dialog input{max-width:100%;box-sizing:border-box;min-width:0}.pc-market-tools:not([hidden]){display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}.pc-search{flex:1}.pc-card{border:1px solid #8884;border-radius:12px;padding:16px;margin:12px 0}.pc-card>button,.pc-card>input,.pc-card>a{margin:8px 8px 0 0}.pc-dialog a{color:var(--dsr-accent-strong)}.pc-dialog details{padding:10px 0}.pc-dialog summary{cursor:pointer;overflow-wrap:anywhere}.pc-dialog pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:240px;overflow:auto;font-size:12px}.pc-message{color:var(--dsr-text)}.pc-profile{font-weight:600}.pc-dialog small{opacity:.7}@media(max-width:520px){.pc-dialog{padding:15px}.pc-dialog button{min-height:42px}.pc-actions>*{flex:1}.pc-card{padding:12px}}
1
+ /* Shared plugin manager: theme tokens, readable cards and compact mobile controls. */
2
+ .pc-dialog{width:min(920px,94vw);max-height:88dvh;box-sizing:border-box;border:1px solid var(--dsr-line,#30363d);border-radius:20px;background:var(--dsr-bg,#0d1117);color:var(--dsr-text,#eee);padding:26px;overflow:auto}
3
+ .pc-dialog *,.pc-dialog *::before,.pc-dialog *::after{box-sizing:border-box}
4
+ .pc-dialog::backdrop{background:var(--dsr-overlay,#0009)}
5
+ .pc-dialog [hidden]{display:none!important}
6
+ .pc-dialog h2,.pc-dialog h3,.pc-dialog p{margin:0}
7
+ .pc-dialog h2{font-size:26px;letter-spacing:-.5px;line-height:1.3}
8
+ .pc-dialog h3{font-size:16px;line-height:1.4;overflow-wrap:anywhere}
9
+ .pc-dialog p{line-height:1.6;overflow-wrap:anywhere}
10
+ .pc-dialog button,.pc-dialog input,.pc-dialog select{font:inherit;font-size:13px;color:inherit;border:1px solid var(--dsr-line,#30363d);border-radius:10px;padding:10px 13px;background:var(--dsr-panel,#161b22)}
11
+ .pc-dialog button{cursor:pointer;transition:background .15s,border-color .15s}
12
+ .pc-dialog button:hover{background:var(--dsr-panel-2,#21262d);border-color:var(--dsr-muted,#888)}
13
+ .pc-dialog button:disabled{opacity:.45;cursor:not-allowed}
14
+ .pc-dialog :is(button,input,select):focus-visible{outline:2px solid var(--dsr-accent,#58a6ff);outline-offset:3px}
15
+ .pc-dialog input{min-width:0;max-width:100%}
16
+ .pc-dialog a{color:var(--dsr-accent-strong,#79c0ff)}
17
+ .pc-dialog small{font-size:12px;color:var(--dsr-muted,#a7b0bc)}
18
+ .pc-heading{display:flex;justify-content:space-between;align-items:flex-start;gap:16px}
19
+ .pc-intro{margin-top:7px!important;font-size:13px;color:var(--dsr-muted,#a7b0bc)}
20
+ .pc-context{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:22px 0}
21
+ .pc-profile{font-size:12px;color:var(--dsr-muted,#a7b0bc)}
22
+ .pc-profile::before{content:'';display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--dsr-accent,#58a6ff);margin-right:7px;vertical-align:2px}
23
+ .pc-summary{display:flex;align-items:center;gap:20px}
24
+ .pc-stat{display:inline-flex;align-items:baseline;gap:7px;font-size:12px;color:var(--dsr-muted,#a7b0bc)}
25
+ .pc-stat strong{color:var(--dsr-text,#eee);font-size:20px;font-weight:650;font-variant-numeric:tabular-nums}
26
+ .pc-status,.pc-message:not(:empty),.pc-recovery:not(:empty){border:1px solid var(--dsr-warning-line,#ff9e6466);border-radius:12px;padding:12px 14px;margin:0 0 16px!important;font-size:13px;background:var(--dsr-warning-soft,#ff9e6421);color:var(--dsr-text,#eee)}
27
+ .pc-message:empty,.pc-detail:empty,.pc-recovery:empty{display:none}
28
+ .pc-recovery button{margin-top:10px}
29
+ .pc-controls{border:1px solid var(--dsr-line,#30363d);border-radius:14px;padding:12px;margin-bottom:20px;background:var(--dsr-panel,#161b22)}
30
+ .pc-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px}
31
+ .pc-tabs{display:flex;gap:3px;min-width:0;padding:3px;border-radius:10px;background:var(--dsr-bg-2,#11161e)}
32
+ .pc-tabs button{white-space:nowrap;padding:8px 14px;background:transparent;border-color:transparent;color:var(--dsr-muted,#a7b0bc);border-radius:8px}
33
+ .pc-tabs button.active{background:var(--dsr-accent-soft,#58a6ff1f);color:var(--dsr-accent-strong,#79c0ff)}
34
+ .pc-dialog .pc-refresh{border-color:transparent;background:transparent;color:var(--dsr-muted,#a7b0bc);padding:8px;flex-shrink:0}
35
+ .pc-filters:not([hidden]),.pc-market-tools:not([hidden]){display:flex;gap:10px;margin-top:12px}
36
+ .pc-filters input,.pc-search{flex:1;width:0}
37
+ .pc-filters select{flex-shrink:0;max-width:42%}
38
+ .pc-market-tools{flex-wrap:wrap}
39
+ .pc-market-tools .pc-search{flex-basis:160px}
40
+ .pc-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,310px),1fr));gap:14px}
41
+ .pc-card{min-width:0;border:1px solid var(--dsr-line,#30363d);border-radius:16px;padding:18px;background:var(--dsr-panel,#161b22)}
42
+ .pc-card-head{display:flex;align-items:center;gap:12px;min-height:40px}
43
+ .pc-card-head h3{flex:1;min-width:0}
44
+ .pc-plugin-icon{display:flex;justify-content:center;align-items:center;flex:0 0 40px;height:40px;font-size:17px;font-weight:600;border-radius:12px;background:var(--dsr-accent-soft,#58a6ff1f);color:var(--dsr-accent-strong,#79c0ff)}
45
+ .pc-description{margin-top:14px!important;font-size:13px;color:var(--dsr-muted,#a7b0bc);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
46
+ .pc-package{display:block;margin-top:7px;font-family:ui-monospace,monospace;overflow-wrap:anywhere;font-size:12px!important}
47
+ .pc-meta{display:flex;align-items:center;flex-wrap:wrap;gap:7px;margin:14px 0 16px}
48
+ .pc-badge,.pc-version{font-size:11px;border-radius:6px;padding:4px 7px;line-height:1.2;background:var(--dsr-panel-2,#21262d);color:var(--dsr-muted,#a7b0bc)}
49
+ .pc-badge.is-active{background:var(--dsr-success-soft,#4bcf7e21);color:var(--dsr-success,#4bcf7e)}
50
+ .pc-badge.is-error{background:var(--dsr-error-soft,#ff8f6b24);color:var(--dsr-error,#ff8f6b)}
51
+ .pc-dialog .pc-toggle{position:relative;flex:0 0 42px;width:42px;height:26px;min-height:0;padding:0;border-radius:20px;border:1px solid var(--dsr-line,#30363d);background:var(--dsr-panel-2,#21262d);font-size:0}
52
+ .pc-toggle::after{content:'';position:absolute;top:4px;left:4px;width:16px;height:16px;border-radius:50%;background:var(--dsr-muted,#a7b0bc);transition:transform .15s}
53
+ .pc-dialog .pc-toggle[aria-checked=true]{background:var(--dsr-accent,#58a6ff);border-color:var(--dsr-accent,#58a6ff)}
54
+ .pc-toggle[aria-checked=true]::after{transform:translateX(16px);background:var(--dsr-on-accent,#07111f)}
55
+ .pc-card-actions{display:flex;align-items:center;gap:6px;border-top:1px solid var(--dsr-line,#30363d);padding-top:12px;margin-top:auto}
56
+ .pc-card-actions button{border-color:transparent;background:transparent;font-size:12px;padding:8px 10px}
57
+ .pc-card-actions .pc-detail-button{background:var(--dsr-accent-soft,#58a6ff1f);color:var(--dsr-accent-strong,#79c0ff);margin-right:auto}
58
+ .pc-card-actions .pc-danger-button{color:var(--dsr-muted,#a7b0bc)}
59
+ .pc-card-actions .pc-danger-button:hover{background:var(--dsr-error-soft,#ff8f6b24);color:var(--dsr-error,#ff8f6b);border-color:transparent}
60
+ .pc-list>.pc-card{display:flex;flex-direction:column}
61
+ .pc-detail .pc-card{margin-bottom:18px;border-color:var(--dsr-accent-line,#58a6ff66)}
62
+ .pc-detail .pc-description{display:block}
63
+ .pc-detail .pc-card>p,.pc-detail .pc-card>h4{margin:14px 0 0;font-size:13px}
64
+ .pc-detail .pc-card>button,.pc-detail .pc-card>input,.pc-detail .pc-card>a{margin:16px 8px 0 0}
65
+ .pc-dialog button.pc-primary{background:var(--dsr-accent,#58a6ff);color:var(--dsr-on-accent,#07111f);border-color:transparent}
66
+ .pc-dialog button.pc-danger-confirm{background:var(--dsr-error-soft,#ff8f6b24);color:var(--dsr-error,#ff8f6b);border-color:var(--dsr-error-line,#ff8f6b66)}
67
+ .pc-empty{grid-column:1/-1;padding:30px 16px;text-align:center;border:1px dashed var(--dsr-line,#30363d);border-radius:14px;font-size:13px;color:var(--dsr-muted,#a7b0bc)}
68
+ .pc-list>details,.pc-market-note{grid-column:1/-1}
69
+ .pc-market-note{font-size:12px;color:var(--dsr-muted,#a7b0bc);margin-bottom:4px!important}
70
+ .pc-dialog details{font-size:13px;padding:12px 0}
71
+ .pc-dialog summary{cursor:pointer;overflow-wrap:anywhere;color:var(--dsr-muted,#a7b0bc)}
72
+ .pc-runtime,.pc-history{border-top:1px solid var(--dsr-line,#30363d);margin-top:6px}
73
+ .pc-history{margin-top:16px}
74
+ .pc-dialog details p{margin-top:10px;font-size:12px}
75
+ .pc-jobs>details{margin-top:12px;padding:12px;border-radius:10px;background:var(--dsr-panel,#161b22)}
76
+ .pc-dialog pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:240px;overflow:auto;font-size:12px}
77
+ .pc-page{width:100%;max-height:none;border:0;border-radius:0;background:transparent;padding:12px 0 24px;overflow:visible}
78
+ .plugins-view{padding-top:8px}
79
+ @media(max-width:520px){
80
+ .pc-dialog{padding:18px}.pc-page{padding:6px 0 24px}.pc-dialog h2{font-size:24px}
81
+ .pc-context{align-items:flex-start;flex-direction:column;gap:10px;margin:18px 0}.pc-summary{gap:24px}
82
+ .pc-controls{padding:10px;margin-bottom:16px}.pc-toolbar{gap:4px}.pc-tabs{flex:1}
83
+ .pc-tabs button{flex:1;padding:9px 8px;font-size:12px}
84
+ .pc-filters:not([hidden]),.pc-market-tools:not([hidden]){gap:7px}
85
+ .pc-dialog input,.pc-dialog select{font-size:12px;padding:11px 9px}
86
+ .pc-card{padding:16px}.pc-card-actions button{min-height:40px;padding:9px 10px}
87
+ .pc-dialog .pc-toggle::before{content:'';position:absolute;inset:-9px -3px}
88
+ }
89
+ @media(prefers-reduced-motion:reduce){.pc-dialog button,.pc-toggle::after{transition:none}}
@@ -2,8 +2,8 @@
2
2
  'use strict';
3
3
  window.DshPluginCenter = (() => {
4
4
  let dialog, connection, snapshot, timer, generation = 0, pending = false, tab = 'installed', offset = 0, searchSequence = 0, detailSequence = 0
5
- let renderedInstalled = '', renderedJobs = ''
6
- const labels = { install: '安装', update: '更新', remove: '卸载', enable: '启用', disable: '停用', queued: '等待执行', running: '执行中', complete: '已完成', failed: '失败' }
5
+ let renderedInstalled = '', renderedJobs = '', filter = '', statusFilter = 'all', refreshSequence = 0
6
+ const labels = { install: '安装', update: '更新', remove: '卸载', enable: '启用', disable: '停用', queued: '等待执行', running: '执行中', complete: '已完成', failed: '失败', interrupted: '已中断', active: '运行中', pending: '等待依赖', loading: '加载中', disposed: '未运行', unloading: '卸载中', inactive: '未运行' }
7
7
  function node(tag, text, className) {
8
8
  const el = document.createElement(tag)
9
9
  if (text != null) el.textContent = text
@@ -14,7 +14,7 @@ window.DshPluginCenter = (() => {
14
14
  const el = node('button', text)
15
15
  el.type = 'button'; el.addEventListener('click', () => {
16
16
  const current = generation
17
- Promise.resolve().then(action).catch(error => { if (current === generation) message(error.message) })
17
+ Promise.resolve().then(() => { if (current === generation) return action() }).catch(error => { if (current === generation) message(error.message) })
18
18
  })
19
19
  return el
20
20
  }
@@ -35,37 +35,101 @@ window.DshPluginCenter = (() => {
35
35
  }
36
36
  function card(name, description) {
37
37
  const el = node('article', null, 'pc-card')
38
- el.append(node('h3', name), node('p', description))
38
+ const header = node('div', null, 'pc-card-head')
39
+ const icon = node('span', (name || 'P').replace(/^@/, '').slice(0, 1).toUpperCase(), 'pc-plugin-icon')
40
+ icon.setAttribute('aria-hidden', 'true')
41
+ header.append(icon, node('h3', name)); el.append(header)
42
+ if (description) el.append(node('p', description, 'pc-description'))
39
43
  return el
40
44
  }
41
45
  function renderInstalled() {
42
46
  const list = dialog.querySelector('.pc-list'); list.replaceChildren()
43
- if (!snapshot.items.length) list.append(node('p', '暂无可管理插件'))
44
- for (const item of snapshot.items) {
45
- const el = card(item.name, item.description)
46
- el.append(node('p', (item.version || item.requested || '内置') + ' · ' + (item.enabled ? '配置启用' : '配置停用') + (snapshot.pendingRestart ? ' · 待重启' : '')))
47
+ if (!snapshot) { list.append(node('p', '尚未读取插件状态,请点击刷新。')); return }
48
+ const rows = snapshot.items.filter(item => tab === 'builtin' ? !item.managed : item.managed)
49
+ .filter(item => [item.name, item.displayName, item.description].join(' ').toLowerCase().includes(filter.toLowerCase()))
50
+ .filter(item => statusFilter === 'all' || (statusFilter === 'enabled' ? item.enabled : !item.enabled))
51
+ if (!rows.length) list.append(node('p', filter || statusFilter !== 'all' ? '没有匹配的插件。' : tab === 'builtin' ? '暂无内置插件。' : '还没有安装其他插件,点击“添加插件”查看详情并安装。', 'pc-empty'))
52
+ for (const item of rows) {
53
+ const el = card(item.displayName || item.name, item.description)
54
+ el.append(node('small', item.name, 'pc-package'))
55
+ const meta = node('div', null, 'pc-meta')
56
+ meta.append(node('span', item.version ? 'v' + item.version : item.requested || '内置', 'pc-version'))
57
+ if (!item.bundle || !item.managed || !snapshot.writable) meta.append(node('span', item.enabled ? '配置启用' : '配置停用', 'pc-badge ' + (item.enabled ? 'is-active' : '')))
58
+ const runtime = item.runtime || []
59
+ if (runtime.some(entry => entry.phase === 'failed')) meta.append(node('span', '运行异常', 'pc-badge is-error'))
60
+ else if (runtime.length) meta.append(node('span', `${runtime.filter(entry => entry.phase === 'active').length}/${runtime.length} 组件运行中`, 'pc-badge'))
61
+ el.append(meta)
62
+ const actions = node('div', null, 'pc-card-actions')
63
+ const detail = button('查看详情', () => installedDetails(item)); detail.className = 'pc-detail-button'
64
+ actions.append(detail)
47
65
  if (item.managed && snapshot.writable) {
48
- const actions = node('div', null, 'pc-actions')
49
66
  actions.append(button('查看更新', () => showDetails(item.name)))
50
- if (item.bundle) actions.append(button(item.enabled ? '停用' : '启用', () => mutate(item.enabled ? 'disable' : 'enable', item.name)))
51
- actions.append(button('卸载', () => mutate('remove', item.name)))
52
- for (const action of actions.children) action.disabled = pending || snapshot.busy
53
- el.append(actions)
54
- } else el.append(node('small', '内置、核心或只读插件'))
67
+ if (item.bundle) {
68
+ const toggle = button(item.enabled ? '已启用' : '已停用', () => mutate(item.enabled ? 'disable' : 'enable', item.name))
69
+ toggle.className = 'pc-toggle'; toggle.disabled = pending || snapshot.busy
70
+ toggle.setAttribute('role', 'switch'); toggle.setAttribute('aria-checked', String(item.enabled)); toggle.setAttribute('aria-label', '启用 ' + item.name)
71
+ toggle.setAttribute('title', item.enabled ? '配置已启用' : '配置已停用')
72
+ el.querySelector('.pc-card-head').append(toggle)
73
+ }
74
+ const remove = button('卸载', () => mutate('remove', item.name)); remove.className = 'pc-danger-button'; actions.append(remove)
75
+ for (const action of actions.children) if (action !== detail) action.disabled = pending || snapshot.busy
76
+ } else actions.append(node('small', '主机维护 · 只读'))
77
+ el.append(actions)
55
78
  list.append(el)
56
79
  }
57
- const runtime = node('details')
80
+ const runtime = node('details', null, 'pc-runtime')
58
81
  runtime.append(node('summary', '当前进程实际加载状态 (' + snapshot.runtime.length + ')'))
59
82
  if (!snapshot.runtimeAvailable) runtime.append(node('p', '此 DSH 版本未提供加载状态'))
60
83
  for (const entry of snapshot.runtime) runtime.append(node('p', entry.name + ' · ' + entry.phase + (entry.enabled ? '' : ' · disabled')))
61
84
  list.append(runtime)
62
85
  }
86
+ function installedDetails(item) {
87
+ detailSequence++
88
+ const area = dialog.querySelector('.pc-detail')
89
+ const el = card(item.displayName || item.name, item.description)
90
+ el.append(node('p', item.name + ' · ' + (item.version || item.requested || '内置')), node('p', '配置:' + (item.enabled ? '启用' : '停用')))
91
+ const entries = item.runtime || []
92
+ el.append(node('h4', '组件运行状态'))
93
+ if (!entries.length) el.append(node('p', '未获得该包的组件归属。可在下方查看当前进程全部加载状态。'))
94
+ for (const entry of entries) el.append(node('p', entry.name + ' · ' + (labels[entry.phase] || entry.phase)))
95
+ if (!item.managed) el.append(node('p', '随 DSH 提供的核心插件由主机维护。'))
96
+ el.append(button('返回插件列表', () => area.replaceChildren()))
97
+ area.replaceChildren(el); area.scrollIntoView({ block: 'nearest' })
98
+ }
99
+ function renderTabs() {
100
+ for (const button of dialog.querySelectorAll('[data-pc-tab]')) {
101
+ button.classList.toggle('active', button.dataset.pcTab === tab)
102
+ button.setAttribute('aria-pressed', String(button.dataset.pcTab === tab))
103
+ }
104
+ }
63
105
  async function refresh() {
64
- snapshot = await api('/state')
106
+ const sequence = ++refreshSequence
107
+ let data
108
+ try { data = await api('/state') } catch (error) { if (sequence === refreshSequence) throw error; return }
109
+ if (sequence !== refreshSequence) return
110
+ snapshot = data
111
+ const recovery = dialog.querySelector('.pc-recovery'); recovery.replaceChildren()
112
+ if (snapshot.recovery) {
113
+ recovery.append(node('p', snapshot.recovery.reason))
114
+ const action = button('恢复旧任务', () => {
115
+ const body = { id: snapshot.recovery.id, revision: snapshot.revision, confirmNoRunningProcess: true }
116
+ const review = card('解除旧任务锁', '请先在主机确认没有正在运行的插件安装或卸载进程。此操作保留文件和备份,不执行回滚。')
117
+ review.append(button('已确认,解除锁定', () => executeMutation(body, '/recover')), button('取消', () => dialog.querySelector('.pc-detail').replaceChildren()))
118
+ dialog.querySelector('.pc-detail').replaceChildren(review)
119
+ })
120
+ action.disabled = !snapshot.writable || pending; recovery.append(action)
121
+ }
65
122
  dialog.querySelector('.pc-profile').textContent = snapshot.profile ? '当前环境:' + snapshot.profile : '当前环境无法识别'
66
123
  dialog.querySelector('.pc-status').textContent = snapshot.reason || (snapshot.pendingRestart ? '插件配置已变更,重启 DSH 后生效。' : '安装到当前连接的 DSH 主机。')
67
- const installedKey = JSON.stringify([snapshot.items, snapshot.runtime, snapshot.pendingRestart, snapshot.busy, snapshot.writable, pending])
68
- if (tab === 'installed' && installedKey !== renderedInstalled) { renderInstalled(); renderedInstalled = installedKey }
124
+ dialog.querySelector('.pc-status').hidden = !snapshot.reason && !snapshot.pendingRestart
125
+ const summary = dialog.querySelector('.pc-summary'); summary.replaceChildren()
126
+ for (const [count, title] of [[snapshot.items.filter(item => item.managed).length, '已安装'], [snapshot.items.filter(item => item.managed && item.enabled).length, '配置启用']]) {
127
+ const stat = node('span', null, 'pc-stat'); stat.append(node('strong', String(count)), node('span', title)); summary.append(stat)
128
+ }
129
+ if (snapshot.busy) summary.append(node('span', '任务执行中', 'pc-badge'))
130
+ const installedKey = JSON.stringify([snapshot.items, snapshot.runtime, snapshot.pendingRestart, snapshot.busy, snapshot.writable, pending, tab, filter, statusFilter])
131
+ if (tab !== 'market' && installedKey !== renderedInstalled) { renderInstalled(); renderedInstalled = installedKey }
132
+ renderTabs()
69
133
  const jobsKey = JSON.stringify(snapshot.operations)
70
134
  if (jobsKey === renderedJobs) return
71
135
  renderedJobs = jobsKey
@@ -88,11 +152,14 @@ window.DshPluginCenter = (() => {
88
152
  const list = dialog.querySelector('.pc-list'); list.replaceChildren(node('p', '正在查询 npm 插件目录…'))
89
153
  const data = await api('/market?q=' + encodeURIComponent(q) + '&offset=' + offset)
90
154
  if (tab !== 'market' || sequence !== searchSequence) return
91
- list.replaceChildren(node('p', '来源:npm · 安装前校验 DSH bundle。目录收录不代表安全或兼容性审核。'))
155
+ list.replaceChildren(node('p', '来源:npm · 安装前校验 DSH bundle。目录收录不代表安全或兼容性审核。', 'pc-market-note'))
92
156
  if (!data.items.length) list.append(node('p', '没有找到插件;也可直接输入完整 npm 包名后查看详情。'))
93
157
  for (const item of data.items) {
94
158
  const el = card(item.name, item.description)
95
- el.append(node('small', item.version), button('查看详情', () => showDetails(item.name)))
159
+ const meta = node('div', null, 'pc-meta'); meta.append(node('span', item.version ? 'v' + item.version : '版本未声明', 'pc-version'))
160
+ const actions = node('div', null, 'pc-card-actions')
161
+ const detail = button('查看详情', () => showDetails(item.name)); detail.className = 'pc-detail-button'; actions.append(detail)
162
+ el.append(meta, actions)
96
163
  list.append(el)
97
164
  }
98
165
  if (offset > 0) list.append(button('上一页', () => { offset -= 20; return search() }))
@@ -127,55 +194,97 @@ window.DshPluginCenter = (() => {
127
194
  const area = dialog.querySelector('.pc-detail')
128
195
  const review = card('确认' + labels[action], name + (version ? '@' + version : ''))
129
196
  review.append(node('p', '目标环境:' + snapshot.profile + '。配置修改后需要重启 DSH;不会自动重启。'))
130
- review.append(button('确认' + labels[action], () => executeMutation(body)), button('取消', () => area.replaceChildren()))
197
+ const confirm = button('确认' + labels[action], () => executeMutation(body)); confirm.className = action === 'remove' ? 'pc-danger-confirm' : 'pc-primary'
198
+ review.append(confirm, button('取消', () => area.replaceChildren()))
131
199
  area.replaceChildren(review); area.scrollIntoView({ block: 'nearest' })
132
200
  }
133
- async function executeMutation(body) {
201
+ async function executeMutation(body, endpoint = '/operations') {
134
202
  if (pending) return
135
203
  const current = generation
136
204
  pending = true
137
205
  try {
138
206
  message('正在提交…')
139
- await api('/operations', body)
207
+ await api(endpoint, body)
140
208
  message('已受理,可在操作记录查看结果。')
141
209
  dialog.querySelector('.pc-detail').replaceChildren()
142
210
  } catch (error) {
143
211
  if (current !== generation) return
144
212
  message(error.message + ';请先查看操作记录,避免重复提交。')
145
- const retry = button('重试同一请求', () => executeMutation(body))
213
+ const retry = button('重试同一请求', () => executeMutation(body, endpoint))
146
214
  dialog.querySelector('.pc-detail').replaceChildren(retry)
147
215
  } finally { if (current === generation) { pending = false; await refresh() } }
148
216
  }
149
- async function open(config) {
217
+ function close() {
218
+ generation++; searchSequence++; detailSequence++; clearTimeout(timer)
219
+ if (!dialog) return
220
+ if (dialog.tagName === 'DIALOG') dialog.close()
221
+ else { dialog.open = false; dialog.remove() }
222
+ dialog = null
223
+ }
224
+ async function open(config, target) {
225
+ close()
150
226
  if (!dialog) {
151
- dialog = node('dialog', null, 'pc-dialog')
152
- const heading = node('div', null, 'pc-heading'); heading.append(node('h2', '插件中心'), button('关闭', () => dialog.close()))
153
- const toolbar = node('div', null, 'pc-actions')
154
- toolbar.append(button('已安装', () => { tab = 'installed'; renderedInstalled = ''; dialog.querySelector('.pc-market-tools').hidden = true; return refresh() }), button('发现插件', () => { tab = 'market'; offset = 0; dialog.querySelector('.pc-market-tools').hidden = false; return search() }), button('刷新状态', refresh))
227
+ dialog = node(target ? 'div' : 'dialog', null, 'pc-dialog' + (target ? ' pc-page' : ''))
228
+ const heading = node('div', null, 'pc-heading')
229
+ const title = node('div', null, 'pc-title'); title.append(node('h2', '插件管理'), node('p', '管理当前主机的插件与扩展', 'pc-intro')); heading.append(title)
230
+ if (!target) heading.append(button('关闭', close))
231
+ const controls = node('div', null, 'pc-controls')
232
+ const toolbar = node('div', null, 'pc-tabs')
233
+ for (const [value, title] of [['installed', '已安装'], ['market', '添加插件'], ['builtin', '内置插件']]) {
234
+ const control = button(title, () => {
235
+ tab = value; detailSequence++; renderedInstalled = ''; dialog.querySelector('.pc-detail').replaceChildren()
236
+ renderTabs(); message('')
237
+ dialog.querySelector('.pc-market-tools').hidden = value !== 'market'; dialog.querySelector('.pc-filters').hidden = value === 'market'
238
+ if (value === 'market') { offset = 0; return search() }
239
+ return refresh()
240
+ })
241
+ control.dataset.pcTab = value; toolbar.append(control)
242
+ }
243
+ const refreshButton = button('刷新', () => tab === 'market' ? search() : refresh()); refreshButton.className = 'pc-refresh'
244
+ const toolbarRow = node('div', null, 'pc-toolbar'); toolbarRow.append(toolbar, refreshButton)
245
+ const filters = node('div', null, 'pc-filters')
246
+ const query = node('input'); query.placeholder = '筛选已安装插件'; query.setAttribute('aria-label', '筛选已安装插件')
247
+ query.addEventListener('input', () => { filter = query.value.trim(); renderInstalled() })
248
+ const select = node('select'); select.setAttribute('aria-label', '按配置状态筛选')
249
+ for (const [value, title] of [['all', '全部状态'], ['enabled', '配置启用'], ['disabled', '配置停用']]) { const option = node('option', title); option.value = value; select.append(option) }
250
+ select.addEventListener('change', () => { statusFilter = select.value; renderInstalled() })
251
+ filters.append(query, select)
155
252
  const market = node('form', null, 'pc-market-tools'); market.hidden = true
156
253
  const input = node('input', null, 'pc-search'); input.placeholder = '搜索插件或输入完整 npm 包名'; input.maxLength = 80; input.setAttribute('aria-label', '搜索插件或 npm 包名')
157
254
  market.append(input, button('搜索', () => { offset = 0; return search() }), button('按包名查看', () => showDetails(input.value.trim())))
158
- market.addEventListener('submit', event => { event.preventDefault(); offset = 0; search().catch(error => message(error.message)) })
159
- const jobs = node('details'); jobs.append(node('summary', '操作记录'), node('div', null, 'pc-jobs'))
255
+ market.addEventListener('submit', event => {
256
+ event.preventDefault(); offset = 0
257
+ const current = generation
258
+ search().catch(error => { if (current === generation) message(error.message) })
259
+ })
260
+ const jobs = node('details', null, 'pc-history'); jobs.append(node('summary', '操作记录'), node('div', null, 'pc-jobs'))
160
261
  const alert = node('p', null, 'pc-message'); alert.setAttribute('role', 'status')
161
- dialog.append(heading, node('p', null, 'pc-profile'), node('p', null, 'pc-status'), toolbar, market, alert, node('div', null, 'pc-detail'), node('div', null, 'pc-list'), jobs)
162
- dialog.addEventListener('close', () => { generation++; clearTimeout(timer) })
163
- document.body.append(dialog)
262
+ const context = node('div', null, 'pc-context'); context.append(node('p', null, 'pc-profile'), node('div', null, 'pc-summary'))
263
+ controls.append(toolbarRow, filters, market)
264
+ dialog.append(heading, context, node('p', null, 'pc-status'), controls, alert, node('div', null, 'pc-recovery'), node('div', null, 'pc-detail'), node('div', null, 'pc-list'), jobs)
265
+ const surface = dialog
266
+ dialog.addEventListener('close', () => {
267
+ if (dialog !== surface) return
268
+ generation++; clearTimeout(timer); dialog = null; surface.remove()
269
+ })
270
+ ;(target || document.body).append(dialog)
164
271
  }
165
- generation++; searchSequence++; detailSequence++; connection = config; tab = 'installed'; pending = false; snapshot = null
272
+ generation++; searchSequence++; detailSequence++; connection = config; tab = 'installed'; pending = false; snapshot = null; filter = ''; statusFilter = 'all'
166
273
  const current = generation
167
274
  renderedInstalled = ''; renderedJobs = ''
168
275
  dialog.querySelector('.pc-market-tools').hidden = true
169
276
  dialog.querySelector('.pc-detail').replaceChildren(); dialog.querySelector('.pc-list').replaceChildren(); dialog.querySelector('.pc-jobs').replaceChildren()
170
- dialog.showModal(); message('正在读取插件状态…')
277
+ if (target) dialog.open = true
278
+ else dialog.showModal()
279
+ message('正在读取插件状态…')
171
280
  try { await refresh(); message('') } catch (error) { if (current === generation) message(error.message) }
172
281
  if (current !== generation) return
173
282
  async function poll() {
174
- if (!dialog.open || current !== generation) return
283
+ if (current !== generation || !dialog?.open) return
175
284
  try { await refresh() } catch (error) { if (current === generation) message(error.message) }
176
- if (dialog.open && current === generation) timer = setTimeout(poll, 4000)
285
+ if (current === generation && dialog?.open) timer = setTimeout(poll, 4000)
177
286
  }
178
287
  timer = setTimeout(poll, 4000)
179
288
  }
180
- return { open }
289
+ return { open, mount: (config, target) => open(config, target), close }
181
290
  })()