dsh-remote-plugin 0.6.11 → 0.6.13

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/public/admin.js CHANGED
@@ -24,8 +24,77 @@ let gatewayBusy = false
24
24
  let shownToken = token
25
25
  let lastState = null
26
26
  let qrShown = false
27
+ let qrToken = ''
28
+ let qrLabel = ''
29
+ let deviceKeyBusy = false
27
30
  let gatewayPort = 8787
28
31
  let gatewayPortLoaded = false
32
+ let doctorExpanded = store.get('dshAdminDoctorCollapsed') !== '1'
33
+ let doctorChecks = []
34
+
35
+ function onlineClientDevices(st) {
36
+ return (st.devices || []).filter(device => device.online && (device.kind === 'app' || device.kind === 'web'))
37
+ }
38
+
39
+ function firewallCommand(st) {
40
+ const port = Number(st.port || gatewayPort) || 8787
41
+ const ip = (st.lanIPs || []).find(value => /^10\.|^192\.168\.|^172\.(1[6-9]|2\d|3[01])\.|^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(value || ''))
42
+ let cidr = 'LocalSubnet'
43
+ if (/^10\./.test(ip || '')) cidr = '10.0.0.0/8'
44
+ else if (/^192\.168\./.test(ip || '')) cidr = '192.168.0.0/16'
45
+ else if (/^172\./.test(ip || '')) cidr = '172.16.0.0/12'
46
+ else if (/^100\./.test(ip || '')) cidr = '100.64.0.0/10'
47
+ if (st.platform === 'win32') return `New-NetFirewallRule -DisplayName "DSH Remote ${port}" -Direction Inbound -Protocol TCP -LocalPort ${port} -RemoteAddress ${cidr} -Action Allow -Profile Private`
48
+ if (st.platform === 'darwin') return `系统设置 → 网络 → 防火墙;仅允许 Node.js / DSH Remote 接受可信网络入站连接(TCP ${port})`
49
+ return `sudo ufw allow from ${cidr} to any port ${port} proto tcp\n# firewalld: sudo firewall-cmd --zone=home --add-source=${cidr} --permanent && sudo firewall-cmd --zone=home --add-port=${port}/tcp --permanent && sudo firewall-cmd --reload`
50
+ }
51
+
52
+ function buildDoctorChecks(st) {
53
+ const isGateway = st.mode === 'gateway'
54
+ const port = Number(st.port || gatewayPort) || 8787
55
+ const ip = (st.lanIPs || []).find(value => value && value !== '127.0.0.1' && value !== '0.0.0.0')
56
+ const base = ip ? `http://${ip}:${port}` : ''
57
+ const clients = onlineClientDevices(st)
58
+ const events = st.events || {}
59
+ const realtime = !!(events.mux?.connected && events.host?.connected)
60
+ return [
61
+ { id: 'dsh', status: st.upstream?.reachable ? 'pass' : 'fail', title: t('doctor.dsh'), detail: t(st.upstream?.reachable ? 'doctor.dshPass' : 'doctor.dshFail') },
62
+ { id: 'gateway', status: isGateway ? 'pass' : 'fail', title: t('doctor.gateway'), detail: isGateway ? t('doctor.gatewayPass', { host: st.host, port }) : t('doctor.gatewayFail'), action: !isGateway && pluginMode ? 'start' : '' },
63
+ { id: 'network', status: isGateway && ip && st.host !== '127.0.0.1' ? 'pass' : 'fail', title: t('doctor.network'), detail: base ? t('doctor.networkPass', { base }) : t('doctor.networkFail'), action: base ? 'address' : '' },
64
+ { id: 'firewall', status: clients.length ? 'pass' : 'warn', title: t('doctor.firewall'), detail: t(clients.length ? 'doctor.firewallPass' : 'doctor.firewallWarn', { port }), action: clients.length ? '' : 'firewall' },
65
+ { id: 'device', status: clients.length ? 'pass' : 'warn', title: t('doctor.device'), detail: t(clients.length ? 'doctor.devicePass' : 'doctor.deviceWait', { n: clients.length }), action: isGateway && !clients.length ? 'qr' : '' },
66
+ { id: 'realtime', status: realtime ? 'pass' : 'warn', title: t('doctor.realtime'), detail: t(realtime ? 'doctor.realtimePass' : 'doctor.realtimeFail') },
67
+ ]
68
+ }
69
+
70
+ function renderDoctor(st) {
71
+ doctorChecks = buildDoctorChecks(st)
72
+ const passed = doctorChecks.filter(check => check.status === 'pass').length
73
+ const remaining = doctorChecks.length - passed
74
+ const allGood = remaining === 0
75
+ const card = $('doctor-card')
76
+ card.classList.toggle('expanded', doctorExpanded)
77
+ $('doctor-toggle').setAttribute('aria-expanded', String(doctorExpanded))
78
+ $('doctor-progress').textContent = `${passed}/${doctorChecks.length}`
79
+ $('doctor-subtitle').textContent = t(allGood ? 'doctor.ready' : 'doctor.needsWork', { n: remaining })
80
+ $('doctor-summary').textContent = t(allGood ? 'doctor.allGood' : 'doctor.partial')
81
+ $('doctor-summary').classList.toggle('ok', allGood)
82
+ const actionText = { start: 'doctor.start', qr: 'doctor.showQr', address: 'doctor.copyAddress', firewall: 'doctor.copyCommand' }
83
+ $('doctor-steps').innerHTML = doctorChecks.map(check => `<div class="doctor-step ${check.status}">
84
+ <span class="doctor-mark" aria-hidden="true">${check.status === 'pass' ? '✓' : check.status === 'fail' ? '!' : '·'}</span>
85
+ <span class="doctor-copy"><strong>${esc(check.title)}</strong><span>${esc(check.detail)}</span></span>
86
+ ${check.action ? `<button class="mini-btn doctor-action" type="button" data-doctor-action="${check.action}">${esc(t(actionText[check.action]))}</button>` : ''}
87
+ </div>`).join('')
88
+ }
89
+
90
+ async function doctorCopy(value, messageKey) {
91
+ try {
92
+ await navigator.clipboard.writeText(value)
93
+ toast(t(messageKey), 'ok')
94
+ } catch {
95
+ toast(t('toast.copyFailed'), 'err')
96
+ }
97
+ }
29
98
 
30
99
  const STATS_API = pluginMode ? API + '/stats' : '/stats'
31
100
  let statsTimer = null
@@ -170,6 +239,53 @@ function fmtTime(ts) {
170
239
  return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
171
240
  }
172
241
 
242
+ function esc(value) {
243
+ return String(value ?? '').replace(/[<>&"]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]))
244
+ }
245
+
246
+ function renderDeviceKeys(st, isGateway) {
247
+ const config = st.deviceKeys || { supported: false, enabled: false, entries: [] }
248
+ const supported = isGateway && config.supported !== false
249
+ const enabled = supported && config.enabled === true
250
+ const toggleWrap = $('device-key-toggle')
251
+ toggleWrap.classList.toggle('hidden', !isGateway && !pluginMode)
252
+ const toggle = $('device-key-enabled')
253
+ toggle.checked = enabled
254
+ toggle.disabled = !supported || deviceKeyBusy
255
+ $('btn-device-key-add').classList.toggle('hidden', !enabled)
256
+ $('btn-device-key-add').disabled = deviceKeyBusy
257
+ $('shared-token-row').classList.toggle('hidden', enabled)
258
+ $('device-key-panel').classList.toggle('hidden', !enabled)
259
+ if (!enabled) {
260
+ $('device-key-rows').innerHTML = ''
261
+ $('device-key-empty').classList.add('hidden')
262
+ return
263
+ }
264
+ const entries = Array.isArray(config.entries) ? config.entries : []
265
+ $('device-key-empty').classList.toggle('hidden', entries.length > 0)
266
+ $('device-key-rows').innerHTML = entries.map(entry => {
267
+ const note = entry.note || t('deviceKeys.neverUsed')
268
+ const ip = entry.lastIp || t('deviceKeys.neverUsed')
269
+ return `<div class="device-key-grid" data-device-key-id="${esc(entry.id)}">
270
+ <div class="device-key-note" data-label="${esc(t('deviceKeys.note'))}"><span>${esc(note)}</span><button class="mini-btn" type="button" data-device-key-note="${esc(entry.id)}">${esc(t('deviceKeys.edit'))}</button></div>
271
+ <div class="device-key-ip" data-label="${esc(t('deviceKeys.ip'))}">${esc(ip)}</div>
272
+ <code data-label="Token">${esc(entry.token)}</code>
273
+ <div class="device-key-actions">
274
+ <button class="mini-btn" type="button" data-device-key-qr="${esc(entry.id)}">${esc(t('qrCode'))}</button>
275
+ <button class="mini-btn" type="button" data-device-key-rotate="${esc(entry.id)}">${esc(t('rotateToken'))}</button>
276
+ <button class="mini-btn" type="button" data-device-key-copy="${esc(entry.id)}">${esc(t('copyToken'))}</button>
277
+ <button class="mini-btn danger" type="button" data-device-key-revoke="${esc(entry.id)}">${esc(t('deviceKeys.revoke'))}</button>
278
+ </div>
279
+ </div>`
280
+ }).join('')
281
+ const byId = id => entries.find(entry => entry.id === id)
282
+ document.querySelectorAll('[data-device-key-note]').forEach(button => button.addEventListener('click', () => editDeviceKeyNote(byId(button.dataset.deviceKeyNote))))
283
+ document.querySelectorAll('[data-device-key-qr]').forEach(button => button.addEventListener('click', () => showDeviceKeyQr(byId(button.dataset.deviceKeyQr))))
284
+ document.querySelectorAll('[data-device-key-rotate]').forEach(button => button.addEventListener('click', () => rotateDeviceKey(byId(button.dataset.deviceKeyRotate))))
285
+ document.querySelectorAll('[data-device-key-copy]').forEach(button => button.addEventListener('click', () => copyDeviceKey(byId(button.dataset.deviceKeyCopy))))
286
+ document.querySelectorAll('[data-device-key-revoke]').forEach(button => button.addEventListener('click', () => revokeDeviceKey(byId(button.dataset.deviceKeyRevoke))))
287
+ }
288
+
173
289
  async function loadState() {
174
290
  if (!token && !pluginMode) return
175
291
  try {
@@ -205,7 +321,9 @@ function render(st) {
205
321
  // 二维码与轮换只在网关模式下可用(二维码里有完整令牌, 不能在没有网关时生成)
206
322
  $('btn-qr').classList.toggle('hidden', isGateway !== true || !shownToken)
207
323
  $('btn-rotate').classList.toggle('hidden', isGateway !== true || !shownToken || !!st.tokenFromEnv)
324
+ renderDeviceKeys(st, isGateway)
208
325
  renderQr(st)
326
+ renderDoctor(st)
209
327
  // 网关开关: 仅插件内嵌页提供, 网关运行/停止两种状态
210
328
  gatewayRunning = isGateway
211
329
  $('btn-gateway').classList.toggle('hidden', !pluginMode)
@@ -305,30 +423,31 @@ function render(st) {
305
423
  btn.addEventListener('click', () => setNote(btn.dataset.noteIp, btn.dataset.note)))
306
424
  }
307
425
 
308
- function pairTarget(st) {
426
+ function pairTarget(st, accessToken) {
309
427
  const ip = (st.lanIPs || []).find(x => x && x !== '127.0.0.1' && x !== '0.0.0.0') || (st.lanIPs || [])[0]
310
428
  const host = ip || (st.host && st.host !== '0.0.0.0' ? st.host : location.hostname)
311
429
  const port = st.port || 8787
312
430
  const base = `http://${host}:${port}`
313
431
  return {
314
- url: `dshremote://pair?token=${encodeURIComponent(shownToken)}&server=${encodeURIComponent(base)}`,
432
+ url: `dshremote://pair?token=${encodeURIComponent(accessToken)}&server=${encodeURIComponent(base)}`,
315
433
  base
316
434
  }
317
435
  }
318
436
 
319
437
  function renderQr(st) {
320
438
  const box = $('pair-box')
321
- if (!qrShown || !shownToken || st.mode !== 'gateway') {
439
+ const accessToken = qrToken || shownToken
440
+ if (!qrShown || !accessToken || st.mode !== 'gateway') {
322
441
  box.classList.add('hidden')
323
442
  return
324
443
  }
325
444
  try {
326
- const pt = pairTarget(st)
445
+ const pt = pairTarget(st, accessToken)
327
446
  const qr = window.qrcode(0, 'M')
328
447
  qr.addData(pt.url)
329
448
  qr.make()
330
449
  $('pair-qr').innerHTML = qr.createSvgTag({ cellSize: 4, margin: 2, scalable: true })
331
- $('pair-hint').textContent = t('pair.hint', { base: pt.base })
450
+ $('pair-hint').textContent = `${qrLabel ? qrLabel + ' · ' : ''}${t('pair.hint', { base: pt.base })}`
332
451
  box.classList.remove('hidden')
333
452
  } catch (e) {
334
453
  $('pair-qr').textContent = t('pair.failed')
@@ -367,6 +486,110 @@ async function kick(ip) {
367
486
  }
368
487
  }
369
488
 
489
+ async function deviceKeyMutation(action, payload = {}) {
490
+ deviceKeyBusy = true
491
+ const toggle = $('device-key-enabled')
492
+ if (toggle) toggle.disabled = true
493
+ try {
494
+ const res = await fetch(`${API}/device-keys/${action}`, {
495
+ method: 'POST',
496
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' },
497
+ body: JSON.stringify(payload),
498
+ })
499
+ const out = await res.json().catch(() => ({}))
500
+ if (!res.ok || !out.ok) throw new Error(out.detail || out.error || `HTTP ${res.status}`)
501
+ return out
502
+ } catch (error) {
503
+ toast(t('deviceKeys.failed', { msg: error?.message || error }), 'err')
504
+ return null
505
+ } finally {
506
+ deviceKeyBusy = false
507
+ if (toggle) toggle.disabled = false
508
+ }
509
+ }
510
+
511
+ async function setDeviceKeyMode(enabled) {
512
+ const toggle = $('device-key-enabled')
513
+ const confirmKey = enabled ? 'deviceKeys.enableConfirm' : 'deviceKeys.disableConfirm'
514
+ if (!confirm(t(confirmKey))) {
515
+ toggle.checked = !enabled
516
+ return
517
+ }
518
+ const out = await deviceKeyMutation('mode', { enabled })
519
+ if (!out) {
520
+ toggle.checked = !enabled
521
+ return
522
+ }
523
+ qrShown = false
524
+ qrToken = ''
525
+ qrLabel = ''
526
+ toast(t(enabled ? 'deviceKeys.enabled' : 'deviceKeys.disabled'), 'ok')
527
+ await loadState()
528
+ }
529
+
530
+ async function createDeviceKey() {
531
+ const note = prompt(t('deviceKeys.notePrompt'), '')
532
+ if (note === null) return
533
+ const out = await deviceKeyMutation('create', { note })
534
+ if (!out) return
535
+ toast(t('deviceKeys.created'), 'ok')
536
+ await loadState()
537
+ if (out.entry) showDeviceKeyQr(out.entry)
538
+ }
539
+
540
+ async function editDeviceKeyNote(entry) {
541
+ if (!entry) return
542
+ const note = prompt(t('deviceKeys.notePrompt'), entry.note || '')
543
+ if (note === null) return
544
+ const out = await deviceKeyMutation('note', { id: entry.id, note })
545
+ if (!out) return
546
+ toast(t('deviceKeys.saved'), 'ok')
547
+ await loadState()
548
+ }
549
+
550
+ function showDeviceKeyQr(entry) {
551
+ if (!entry?.token) return
552
+ qrToken = entry.token
553
+ qrLabel = entry.note || ''
554
+ qrShown = true
555
+ renderQr(lastState || { mode: '', token: '' })
556
+ $('pair-box').scrollIntoView?.({ behavior: 'smooth', block: 'nearest' })
557
+ }
558
+
559
+ async function copyDeviceKey(entry) {
560
+ if (!entry?.token) return
561
+ try {
562
+ await navigator.clipboard.writeText(entry.token)
563
+ toast(t('toast.tokenCopied'), 'ok')
564
+ } catch {
565
+ toast(t('toast.copyFailed'), 'err')
566
+ }
567
+ }
568
+
569
+ async function rotateDeviceKey(entry) {
570
+ if (!entry || !confirm(t('deviceKeys.rotateConfirm'))) return
571
+ const out = await deviceKeyMutation('rotate', { id: entry.id })
572
+ if (!out) return
573
+ qrShown = false
574
+ qrToken = ''
575
+ qrLabel = ''
576
+ toast(t('deviceKeys.rotated'), 'ok')
577
+ await loadState()
578
+ }
579
+
580
+ async function revokeDeviceKey(entry) {
581
+ if (!entry || !confirm(t('deviceKeys.revokeConfirm'))) return
582
+ const out = await deviceKeyMutation('revoke', { id: entry.id })
583
+ if (!out) return
584
+ if (qrToken === entry.token) {
585
+ qrShown = false
586
+ qrToken = ''
587
+ qrLabel = ''
588
+ }
589
+ toast(t('deviceKeys.revoked'), 'ok')
590
+ await loadState()
591
+ }
592
+
370
593
  function enter() {
371
594
  const val = $('token-input').value.trim()
372
595
  if (!val) return
@@ -402,6 +625,8 @@ function logout() {
402
625
  $('btn-login').addEventListener('click', enter)
403
626
  $('token-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') enter() })
404
627
  $('btn-logout').addEventListener('click', logout)
628
+ $('device-key-enabled').addEventListener('change', (event) => setDeviceKeyMode(event.target.checked))
629
+ $('btn-device-key-add').addEventListener('click', createDeviceKey)
405
630
  // 插件内嵌: 收起面板按钮 → postMessage 给父窗口(同源)关闭右侧抽屉
406
631
  $('btn-close-drawer').addEventListener('click', () => {
407
632
  window.parent.postMessage({ source: 'dsh-remote-admin', type: 'close' }, location.origin)
@@ -412,6 +637,38 @@ $('admin-hero-action').addEventListener('click', () => {
412
637
  if (action === 'copy') return $('btn-copy').click()
413
638
  $('device-rows').closest('.table-wrap')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
414
639
  })
640
+ $('doctor-toggle').addEventListener('click', () => {
641
+ doctorExpanded = !doctorExpanded
642
+ store.set('dshAdminDoctorCollapsed', doctorExpanded ? '0' : '1')
643
+ if (lastState) renderDoctor(lastState)
644
+ })
645
+ $('doctor-refresh').addEventListener('click', () => {
646
+ $('doctor-subtitle').textContent = t('doctor.checking')
647
+ loadState()
648
+ })
649
+ $('doctor-copy-report').addEventListener('click', () => {
650
+ const report = doctorChecks.map(check => `[${check.status.toUpperCase()}] ${check.title}: ${check.detail}`).join('\n')
651
+ doctorCopy(`DSH Remote Doctor\n${report}`, 'doctor.reportCopied')
652
+ })
653
+ $('doctor-steps').addEventListener('click', (event) => {
654
+ const button = event.target.closest('[data-doctor-action]')
655
+ if (!button || !lastState) return
656
+ const action = button.dataset.doctorAction
657
+ if (action === 'start') return $('btn-gateway').click()
658
+ if (action === 'address') return doctorCopy(pairTarget(lastState, shownToken || token).base, 'doctor.addressCopied')
659
+ if (action === 'firewall') return doctorCopy(firewallCommand(lastState), 'doctor.commandCopied')
660
+ if (action === 'qr') {
661
+ const firstKey = lastState.deviceKeys?.enabled && lastState.deviceKeys.entries?.[0]
662
+ if (firstKey) showDeviceKeyQr(firstKey)
663
+ else {
664
+ qrToken = shownToken
665
+ qrLabel = ''
666
+ qrShown = true
667
+ renderQr(lastState)
668
+ $('pair-box').scrollIntoView?.({ behavior: 'smooth', block: 'nearest' })
669
+ }
670
+ }
671
+ })
415
672
  $('btn-copy').addEventListener('click', async () => {
416
673
  try {
417
674
  await navigator.clipboard.writeText(shownToken || token)
@@ -422,6 +679,8 @@ $('btn-copy').addEventListener('click', async () => {
422
679
  })
423
680
 
424
681
  $('btn-qr').addEventListener('click', () => {
682
+ qrToken = shownToken
683
+ qrLabel = ''
425
684
  qrShown = !qrShown
426
685
  renderQr(lastState || { mode: '', token: shownToken })
427
686
  })
@@ -15,6 +15,41 @@
15
15
  "minVersion": "0.6.9",
16
16
  "maxVersion": "",
17
17
  "publishedAt": "2026-08-22T12:00:00+08:00"
18
+ },
19
+ {
20
+ "id": "2026-08-23-feedback-polls",
21
+ "title": "反馈系统更新与投票公告上线",
22
+ "content": "大家好,DSH Remote 的反馈系统已完成修复,并新增了投票类公告功能。后续我们会通过投票收集大家对新功能、优化方向和更新优先级的意见,让版本规划更贴近实际使用需求。\n\n欢迎各位用户和开发者提交更多反馈,包括功能建议、异常问题、兼容性情况以及使用体验等。\n\n目前应用内的“直接反馈”功能仍处于测试阶段,稳定性还在持续改进。如果是重要问题或需要持续跟进的 Bug,建议优先通过 GitHub Issues 或 B 站相关渠道反馈,这两种方式目前比直接反馈更加稳定。\n\n提交日志或截图前,请注意隐藏 Token、服务器地址等敏感信息。感谢大家的支持与参与!",
23
+ "minVersion": "",
24
+ "maxVersion": "",
25
+ "publishedAt": "2026-08-23T19:04:26+08:00"
26
+ },
27
+ {
28
+ "id": "2026-08-24-update-rhythm-progress",
29
+ "title": "关于后续更新节奏及近期进展",
30
+ "content": "感谢大家一直以来对 dsh-Remote 的关注和反馈。\n\n后续版本将采用更加稳定、清晰的更新节奏:每个正式版本发布前,会先推出 RC 版本进行测试和验证,确认整体运行稳定后再发布正式版本。常规功能会集中到版本迭代中发布;影响正常使用的紧急问题,则会根据实际情况优先修复。\n\n下一个版本目前正在开发和测试中,主要更新内容包括:\n\n- 网关控制台新增可选的独立设备密钥功能,支持按设备管理备注、最近 IP 和访问令牌,并提供二维码配对、令牌轮换、复制及设备退出等操作。\n- 改进手机端的思考内容显示与推理强度选择,并进一步完善工作区、会话和文件管理体验。\n- 优化会话列表,避免子代理产生的内部碎片会话混入普通会话;同时增加 App 与网关版本不一致时的更新提醒。\n- 完善协议能力协商、首次连接引导和运行环境检测,并继续加强 DSH 远程启动、重启及异常恢复过程中的进度与错误提示。\n\n另外,反馈网络已经成功建立并投入使用。大家在使用过程中遇到的问题、改进建议以及功能需求,都可以通过反馈渠道提交,我们会持续整理和跟进。当前直接反馈功能仍在继续测试和完善,如果遇到提交不稳定的情况,也可以优先通过 GitHub Issues 或哔哩哔哩相关渠道联系我们。\n\n与此同时,我们也正在推进 ICP 备案相关工作,为未来建设个人博客网站及后续内容发布提前做好准备。备案完成后,计划逐步完善网站内容,为项目公告、开发记录、使用文档以及其他个人内容提供更加稳定的发布渠道。\n\n感谢大家的支持,也欢迎继续提出意见和建议。dsh-Remote 会继续保持稳定迭代,逐步完善功能与使用体验。",
31
+ "minVersion": "",
32
+ "maxVersion": "",
33
+ "publishedAt": "2026-08-24T13:01:41+08:00"
34
+ },
35
+ {
36
+ "id": "2026-08-24-workbench-retention-poll",
37
+ "title": "投票:工作台功能是否需要保留?",
38
+ "content": "“工作台”可以绑定一个项目总目录,自动将其中的子文件夹整理为 DSH 工作区,并按项目集中显示会话、创建新会话和管理文件。\n\n随着工作区筛选、会话分组和文件管理功能逐渐完善,工作台与现有功能出现了一定重叠。我们希望了解大家的实际使用情况,以决定后续继续优化、合并还是移除。\n\n投票结果仅作为版本规划参考,不会立即改变现有功能。",
39
+ "minVersion": "0.6.11",
40
+ "maxVersion": "",
41
+ "publishedAt": "2026-08-24T13:11:45+08:00",
42
+ "poll": {
43
+ "id": "workbench-retention-2026-08",
44
+ "question": "后续应该如何处理工作台功能?",
45
+ "options": [
46
+ { "id": "keep-improve", "label": "保留并继续优化" },
47
+ { "id": "keep-current", "label": "保留现有功能即可" },
48
+ { "id": "merge-workspaces", "label": "合并到工作区功能" },
49
+ { "id": "remove-gradually", "label": "可以逐步移除" },
50
+ { "id": "not-used", "label": "尚未使用,暂时无法判断" }
51
+ ]
52
+ }
18
53
  }
19
54
  ]
20
55
  }