dsh-plugin 1.3.2 → 1.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,7 +19,62 @@ import { resolveNpmPackage } from '../services/install/npm-resolve.js';
19
19
  import { preflightTarget } from '../services/install/preflight.js';
20
20
  import { isDshPlugin, isEntryLoaded } from '../services/loader.js';
21
21
  import { loadSettings, saveSettings, resetSettings } from '../services/settings.js';
22
- import { appendLog, readLog, logFilePath, defaultLogFilePath, customLogFile } from '../services/log.js';
22
+ import { appendLog, clearLog, readLog, logFilePath, defaultLogFilePath, customLogFile } from '../services/log.js';
23
+ /**
24
+ * 跨平台宿主重启脚本(以 `node -e` 运行,独立于宿主进程)。
25
+ * 取代原先的 `/bin/sh -c`:Windows 无 /bin/sh,且 lsof/nohup 仅 POSIX 存在。
26
+ * 由 `spawn(process.execPath, ['-e', SCRIPT, port], { detached, stdio:'ignore' })` 孵化,
27
+ * 宿主进程被 kill 后仍能完成「停旧 → 等端口释放 → 拉起新 dsh web」。
28
+ */
29
+ const HOST_RESTART_SCRIPT = String.raw `const C = require('node:child_process')
30
+ const F = require('node:fs')
31
+ const P = require('node:path')
32
+ const O = require('node:os')
33
+ const win = process.platform === 'win32'
34
+ const port = Number(process.argv[1])
35
+
36
+ function findPids() {
37
+ if (win) {
38
+ const out = C.spawnSync('netstat', ['-ano', '-p', 'tcp'], { encoding: 'utf8' })
39
+ const found = []
40
+ for (const line of String(out.stdout || '').split(/\r?\n/)) {
41
+ if (!/LISTENING/i.test(line)) continue
42
+ const parts = line.trim().split(/\s+/)
43
+ const addr = parts[1] || ''
44
+ if (!addr.endsWith(':' + port)) continue
45
+ const pid = parts[parts.length - 1]
46
+ if (pid && /\d+/.test(pid)) found.push(pid)
47
+ }
48
+ return found
49
+ }
50
+ const out = C.spawnSync('lsof', ['-ti', 'tcp:' + port, '-sTCP:LISTEN'], { encoding: 'utf8' })
51
+ return String(out.stdout || '').split(/\s+/).filter(Boolean)
52
+ }
53
+
54
+ for (const pid of findPids()) {
55
+ try {
56
+ if (win) C.spawnSync('taskkill', ['/pid', pid, '/t', '/f'])
57
+ else process.kill(Number(pid), 'SIGTERM')
58
+ } catch {}
59
+ }
60
+
61
+ let tries = 0
62
+ ;(function waitForStop() {
63
+ if (findPids().length === 0 || ++tries > 20) return startHost()
64
+ setTimeout(waitForStop, 250)
65
+ })()
66
+
67
+ function startHost() {
68
+ const dir = P.join(O.homedir(), '.dsh', 'logs')
69
+ F.mkdirSync(dir, { recursive: true })
70
+ const fd = F.openSync(P.join(dir, 'dsh-web-' + port + '.log'), 'a')
71
+ const child = C.spawn('dsh', ['web', '--port', String(port)], {
72
+ detached: true,
73
+ shell: win,
74
+ stdio: ['ignore', fd, fd],
75
+ })
76
+ child.unref()
77
+ }`;
23
78
  const PROFILE_RE = /^[A-Za-z0-9_-]+$/;
24
79
  const BODY_LIMIT_BYTES = 4 * 1024;
25
80
  const COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
@@ -311,6 +366,27 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
311
366
  }
312
367
  },
313
368
  }),
369
+ webServer.register({
370
+ kind: 'exact',
371
+ path: '/dsh-plugin-hub/clear-log',
372
+ handler: (request, response) => {
373
+ if (!requireTrustedPost(request, response))
374
+ return;
375
+ // 清空所有日志:截断当前日志文件为空
376
+ try {
377
+ const ok = clearLog(profile);
378
+ if (!ok) {
379
+ sendJson(response, 500, { error: 'failed to clear log' });
380
+ return;
381
+ }
382
+ appendLog(profile, { at: Date.now(), level: 'info', category: 'system', event: 'system.clear', message: '日志已由用户清空' });
383
+ sendJson(response, 200, { ok: true });
384
+ }
385
+ catch {
386
+ sendJson(response, 500, { error: 'failed to clear log' });
387
+ }
388
+ },
389
+ }),
314
390
  webServer.register({
315
391
  kind: 'exact',
316
392
  path: '/dsh-plugin-hub/open-log',
@@ -783,16 +859,12 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
783
859
  const host = request.headers.host ?? '';
784
860
  const portMatch = host.match(/:(\d+)$/);
785
861
  const port = portMatch ? Number(portMatch[1]) : 7923;
786
- // detached 子 shell 完成「停旧进程 → 等端口释放 → 后台拉起 dsh web」,
787
- // 即使当前宿主(即本插件所在进程)被 kill,重启仍会继续执行
788
- const script = [
789
- `pids=$(lsof -ti tcp:${port} -sTCP:LISTEN 2>/dev/null)`,
790
- '[ -n "$pids" ] && kill -TERM $pids 2>/dev/null || true',
791
- `i=0; while [ -n "$(lsof -ti tcp:${port} -sTCP:LISTEN 2>/dev/null)" ] && [ $i -lt 20 ]; do sleep 0.25; i=$((i + 1)); done`,
792
- 'mkdir -p "$HOME/.dsh/logs"',
793
- `nohup dsh web --port ${port} >>"$HOME/.dsh/logs/dsh-web-${port}.log" 2>&1 &`,
794
- ].join('\n');
795
- spawn('/bin/sh', ['-c', script], { detached: true, stdio: 'ignore' }).unref();
862
+ // detached 子进程完成「停旧进程 → 等端口释放 → 后台拉起 dsh web」,
863
+ // 即使当前宿主(即本插件所在进程)被 kill,重启仍会继续执行。
864
+ // 不用 /bin/sh:Windows 无此路径,且 lsof/nohup 等仅 POSIX 存在,
865
+ // 故孵化 node(process.execPath) 跑跨平台脚本 —— Windows 用
866
+ // netstat + taskkill + shell:true 解析 dsh.cmd,POSIX 用 lsof + SIGTERM。
867
+ spawn(process.execPath, ['-e', HOST_RESTART_SCRIPT, String(port)], { detached: true, stdio: 'ignore' }).unref();
796
868
  sendJson(response, 200, { ok: true, port });
797
869
  },
798
870
  }),
@@ -21,7 +21,8 @@ export const NPM_MIN_VERSION = [11, 6, 0];
21
21
  /** 读本机 npm 版本(major.minor.patch);npm 不在 PATH 或执行失败/超时时返回 null(不妄下结论)。 */
22
22
  export function npmVersionOf(env) {
23
23
  try {
24
- const res = spawnSync('npm', ['-v'], { env, encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] });
24
+ // Windows 下 npm 是 npm.cmd:spawn 直接 exec 会拒绝,须 shell:true 解析(与 task-queue 的 useShell 一致)
25
+ const res = spawnSync('npm', ['-v'], { env, encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32' });
25
26
  if (res.error !== undefined || res.status !== 0)
26
27
  return null;
27
28
  const m = /(\d+)\.(\d+)\.(\d+)/.exec(res.stdout ?? '');
@@ -68,6 +68,18 @@ export function appendLog(profile, entry) {
68
68
  // 日志失败不阻断主流程
69
69
  }
70
70
  }
71
+ /** 清空系统日志:把当前日志文件截断为空。失败返回 false(不阻断调用方)。 */
72
+ export function clearLog(profile) {
73
+ try {
74
+ const file = logFilePath(profile);
75
+ mkdirSync(dirname(file), { recursive: true });
76
+ writeFileSync(file, '');
77
+ return true;
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ }
71
83
  /** 兼容旧格式:早期无 category(level 用 info/ok/fail/warn),归一化到新版。 */
72
84
  function normalize(raw) {
73
85
  if (typeof raw !== 'object' || raw === null)
@@ -45,5 +45,7 @@ export declare function defaultLogFilePath(profile: string): string;
45
45
  export declare function logFilePath(profile: string): string;
46
46
  /** 追加一条系统日志(JSONL 一行一条)。 */
47
47
  export declare function appendLog(profile: string, entry: LogEntry): void;
48
+ /** 清空系统日志:把当前日志文件截断为空。失败返回 false(不阻断调用方)。 */
49
+ export declare function clearLog(profile: string): boolean;
48
50
  /** 读取系统日志(按时间正序,最新在末尾),超上限就地裁剪;支持过滤与分页。 */
49
51
  export declare function readLog(profile: string, opts?: ReadLogOptions): ReadLogResult;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin",
3
- "version": "1.3.2",
3
+ "version": "1.3.4",
4
4
  "description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install 4000+ human-curated community plugins without leaving the app. · DeepSeek Harness 社区插件市场(遵循官方开发规范):4000+ 人工精选社区插件,每日更新。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -15,6 +15,7 @@ import modalStyles from '../styles/Modal.module.css'
15
15
  import type { EnvInfo, HubPlugin, SectionProps, ToastState } from '../types.ts'
16
16
  import { installCommandOf, repoFromInstallTarget } from '../logic/install-command.ts'
17
17
  import { PLUGIN_VERSION } from '../logic/constants.ts'
18
+ import { ensurePluginCss } from '../logic/ensure-plugin-css.ts'
18
19
  import { getEnv, revealInstallFolder } from '../data/host.ts'
19
20
  import { useCatalog } from '../hooks/useCatalog.ts'
20
21
  import { useLanguage } from '../hooks/useLanguage.ts'
@@ -34,7 +35,7 @@ import type { SectionView } from './layout/SectionTabs.tsx'
34
35
  import { InstalledView } from './views/InstalledView.tsx'
35
36
  import { CustomInstallView } from './views/CustomInstallView.tsx'
36
37
  import { SettingsView } from './views/SettingsView.tsx'
37
- import { CloseIcon } from './ui/icons.tsx'
38
+ import { CloseIcon, ConfirmIcon } from './ui/icons.tsx'
38
39
  import type { InstalledItem } from '../logic/installed.ts'
39
40
  import { issueRepoOf, pluginOfItem } from '../logic/installed.ts'
40
41
 
@@ -99,6 +100,10 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
99
100
  return () => { alive = false }
100
101
  }, [])
101
102
 
103
+ // 样式自愈:宿主重启/热更可能移除 <style> 标签而模块缓存未失效(factory 不重跑、CSS 不回来)。
104
+ // 挂载与每次切换视图时比对全局 CSS 清单,把缺失的样式补注入回去 —— 用户无需再手动点击恢复。
105
+ useEffect(() => { ensurePluginCss() }, [view])
106
+
102
107
  const queue = useTaskQueue({
103
108
  t,
104
109
  refreshInstalled: catalog.refreshInstalled,
@@ -509,7 +514,7 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
509
514
  onIgnoreUpdate: (repo: string, version?: string) => setIgnoreTarget({ repo, version }),
510
515
  cancelTask: queue.cancelTask,
511
516
  restarting,
512
- onRestart: () => { void requestRestart() },
517
+ onRestart: () => setShowRestartConfirm(true),
513
518
  // 失败记录仓库反查:历史记录可能存的是裸 npm 包名,反查成 owner/repo 才显示仓库链接
514
519
  // 与提 Issue 按钮;查不到则隐藏(防提到错误仓库)
515
520
  resolveRepo: (repo: string) => issueRepoOf(repo, catalog.installedItems, catalog.plugins),
@@ -531,6 +536,10 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
531
536
  }, h(CloseIcon)),
532
537
  ),
533
538
  h('div', { className: modalStyles.modalBody },
539
+ // 忽略更新确认:品牌蓝问号图标,普通确认
540
+ h('div', { className: modalStyles.confirmIconWrap },
541
+ h(ConfirmIcon, { type: 'question' }),
542
+ ),
534
543
  h('div', { className: modalStyles.failPrepareHint }, t('ignoreUpdateConfirmDetail')),
535
544
  h('div', { className: modalStyles.modalActions },
536
545
  h('button', {
@@ -567,7 +576,8 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
567
576
  onClose: () => setConfirmPlugin(null),
568
577
  onCopy: () => copyCommand(confirmPlugin),
569
578
  onInstall: () => queue.installNow(confirmPlugin, confirmIsUpdate ? { update: true } : undefined),
570
- onRestart: () => { void requestRestart() },
579
+ // 重启:结果视图「立即重启」也会中断进行中的任务 → 先弹重启确认弹窗
580
+ onRestart: () => setShowRestartConfirm(true),
571
581
  }),
572
582
  // 命令行安装确认弹窗:与目录安装同一套确认/进度/结果流程(plugin 传 null 走 customTarget 模式)。
573
583
  // 已安装目标 → update 标记,确认后走覆盖重装(服务端 mode=update 放行),不再 409 报错。
@@ -592,7 +602,7 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
592
602
  setToast({ id: Date.now(), kind: 'copied' })
593
603
  },
594
604
  onInstall: () => void queue.installCustom(confirmCustomTarget, confirmIsUpdate ? { update: true } : undefined),
595
- onRestart: () => { void requestRestart() },
605
+ onRestart: () => setShowRestartConfirm(true),
596
606
  }),
597
607
  // 全局 npm 安装确认弹窗(官方 `npm install -g <pkgs>`):与应用商店同一套确认/进度/结果流程。
598
608
  // 装的是系统级 CLI 工具(如 @deepseek-ai/dsh),不进任何 profile、无需宿主重启;
@@ -618,7 +628,7 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
618
628
  setToast({ id: Date.now(), kind: 'copied' })
619
629
  },
620
630
  onInstall: () => void queue.installGlobalNpm(confirmGlobalNpm.pkgs),
621
- onRestart: () => { void requestRestart() },
631
+ onRestart: () => setShowRestartConfirm(true),
622
632
  }),
623
633
  // 卸载确认弹窗:确认/进度/结果视图逻辑收敛在 modals.tsx。
624
634
  // 已安装视图的自定义安装(目录外)没有完整目录数据,按已安装项伪插件适配后走同一弹窗
@@ -645,7 +655,7 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
645
655
  if (uninstallItem) void queue.uninstallItem(uninstallItem)
646
656
  else if (uninstallPlugin) void queue.uninstallNow(uninstallPlugin)
647
657
  },
648
- onRestart: () => { void requestRestart() },
658
+ onRestart: () => setShowRestartConfirm(true),
649
659
  }),
650
660
  // 已安装详情弹窗:目录元数据 + 宿主运行时信息的完整展示,操作(更新/卸载/复制路径)由此发起
651
661
  detailItem && h(InstalledDetailModal, {
@@ -0,0 +1,73 @@
1
+ /**
2
+ * DSH Plugin Hub — the community plugin marketplace for DeepSeek Harness.
3
+ * Website: https://dsh-plugin.org
4
+ * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
+ *
6
+ * Generic confirm dialog — the single entry point for every "are you sure?"
7
+ * question in the app. Rule: any question must be a modal dialog, never an
8
+ * in-button double-click.
9
+ *
10
+ * The icon in the middle tells the type at a glance: a blue question mark
11
+ * for routine confirmations (install / update / restart / ignore), a red
12
+ * action glyph for destructive ones (clear / delete / uninstall / reset).
13
+ */
14
+ import { createElement as h } from 'react'
15
+ import type { MouseEvent } from 'react'
16
+ import styles from '../../styles/Modal.module.css'
17
+ import type { Translate } from '../../types.ts'
18
+ import { CloseIcon, ConfirmIcon, type ConfirmIconType } from '../ui/icons.tsx'
19
+
20
+ /** 通用确认弹窗:标题 + 类型图标 + 描述 + 取消/确认按钮。
21
+ * type='question' → 蓝问号 + 品牌蓝确认按钮;危险类型 → 红图形 + 红色确认按钮。 */
22
+ export function ConfirmDialog({ type, title, desc, confirmLabel, cancelLabel, busy, busyLabel, t, onConfirm, onCancel }: {
23
+ type: ConfirmIconType
24
+ title: string
25
+ desc?: string
26
+ confirmLabel: string
27
+ cancelLabel?: string
28
+ /** 确认动作在途:禁用按钮防止二次点击 */
29
+ busy?: boolean
30
+ /** 在途时确认按钮文案(如「清空中…」);缺省沿用 confirmLabel */
31
+ busyLabel?: string
32
+ t: Translate
33
+ onConfirm: () => void
34
+ onCancel: () => void
35
+ }) {
36
+ const danger = type !== 'question'
37
+ return h('div', {
38
+ className: styles.overlay,
39
+ onClick: (e: MouseEvent<HTMLDivElement>) => { if (e.target === e.currentTarget) onCancel() },
40
+ },
41
+ h('div', { className: styles.modal, role: 'dialog', 'aria-modal': 'true' },
42
+ h('div', { className: styles.modalHead },
43
+ h('div', { className: styles.modalTitle }, title),
44
+ h('button', {
45
+ className: styles.modalClose,
46
+ type: 'button',
47
+ 'aria-label': t('errorClose'),
48
+ onClick: onCancel,
49
+ }, h(CloseIcon)),
50
+ ),
51
+ h('div', { className: styles.modalBody },
52
+ h('div', { className: styles.confirmIconWrap },
53
+ h(ConfirmIcon, { type }),
54
+ ),
55
+ desc ? h('div', { className: styles.modalDesc }, desc) : null,
56
+ h('div', { className: styles.modalActions },
57
+ h('button', {
58
+ className: styles.restartLater,
59
+ type: 'button',
60
+ disabled: busy,
61
+ onClick: onCancel,
62
+ }, cancelLabel ?? t('confirmCancel')),
63
+ h('button', {
64
+ className: danger ? styles.dangerConfirm : styles.confirmPrimary,
65
+ type: 'button',
66
+ disabled: busy,
67
+ onClick: onConfirm,
68
+ }, busy && busyLabel ? busyLabel : confirmLabel),
69
+ ),
70
+ ),
71
+ ),
72
+ )
73
+ }
@@ -25,6 +25,7 @@ import view from '../../styles/LogsView.module.css'
25
25
  import type { Translate } from '../../types.ts'
26
26
  import { openLogFile } from '../../data/host.ts'
27
27
  import { CloseIcon } from '../ui/icons.tsx'
28
+ import { ConfirmDialog } from './ConfirmDialog.tsx'
28
29
 
29
30
  /** 与服务端 /logs 返回的 LogEntry 对齐的客户端投影。 */
30
31
  export interface LogEntryView {
@@ -120,6 +121,9 @@ export function LogsModal({ t, onClose }: {
120
121
  const [error, setError] = useState(false)
121
122
  const [opening, setOpening] = useState(false)
122
123
  const [openFailed, setOpenFailed] = useState(false)
124
+ const [showClearConfirm, setShowClearConfirm] = useState(false)
125
+ const [clearing, setClearing] = useState(false)
126
+ const [clearFailed, setClearFailed] = useState(false)
123
127
  const offsetRef = useRef(0)
124
128
 
125
129
  /** 拉一页:过滤参数取当前 state,偏移量由调用方给出。 */
@@ -195,6 +199,24 @@ export function LogsModal({ t, onClose }: {
195
199
  if (!ok) setOpenFailed(true)
196
200
  }
197
201
 
202
+ /** 清空所有日志:确认弹窗(独立弹出框,垃圾桶图标)确认后执行。 */
203
+ const doClear = async () => {
204
+ setClearing(true)
205
+ try {
206
+ const res = await fetch('/dsh-plugin-hub/clear-log', { method: 'POST', cache: 'no-store' })
207
+ if (!res.ok) {
208
+ setClearFailed(true)
209
+ return
210
+ }
211
+ await reload()
212
+ } catch {
213
+ setClearFailed(true)
214
+ } finally {
215
+ setClearing(false)
216
+ setShowClearConfirm(false)
217
+ }
218
+ }
219
+
198
220
  /** 导出:拼纯文本 → Blob → 触发浏览器下载 .log 文件 */
199
221
  const exportFile = () => {
200
222
  const text = entries.map((e) => `[${fmtLogTime(e.at)}] [${e.level}] [${e.category}] [${e.event}] ${e.message}`).join('\n')
@@ -219,6 +241,15 @@ export function LogsModal({ t, onClose }: {
219
241
  h('div', { className: `${styles.errorModal} ${styles.logModal}`, role: 'dialog', 'aria-modal': 'true' },
220
242
  h('div', { className: styles.modalHead },
221
243
  h('div', { className: styles.modalTitle }, t('logModalTitle')),
244
+ h('div', { className: view.headActions },
245
+ h('button', {
246
+ type: 'button',
247
+ className: view.clearBtn,
248
+ disabled: clearing,
249
+ title: t('logClearAllTip'),
250
+ onClick: () => setShowClearConfirm(true),
251
+ }, t('logClearAll')),
252
+ ),
222
253
  h('button', {
223
254
  className: styles.modalClose,
224
255
  'aria-label': t('errorClose'),
@@ -277,9 +308,11 @@ export function LogsModal({ t, onClose }: {
277
308
  h('div', { className: view.foot },
278
309
  h('span', { className: view.footPath, title: path },
279
310
  `${t('logPathLabel')}: ${path === '' ? '…' : path}`),
280
- openFailed
281
- ? h('span', { className: view.footFail }, t('logOpenFileFail'))
282
- : h('span', { className: view.footCount }, t('logCount', { n: total })),
311
+ clearFailed
312
+ ? h('span', { className: view.footFail }, t('logClearFailed'))
313
+ : openFailed
314
+ ? h('span', { className: view.footFail }, t('logOpenFileFail'))
315
+ : h('span', { className: view.footCount }, t('logCount', { n: total })),
283
316
  h('div', { className: view.footActions },
284
317
  h('button', {
285
318
  type: 'button',
@@ -290,12 +323,24 @@ export function LogsModal({ t, onClose }: {
290
323
  }, t('logOpenFile')),
291
324
  h('button', {
292
325
  type: 'button',
293
- className: `${view.btn} ${view.btnPrimary}`,
326
+ className: view.btn,
294
327
  disabled: entries.length === 0,
295
328
  onClick: exportFile,
296
329
  }, t('logsExport')),
297
330
  ),
298
331
  ),
299
332
  ),
333
+ // 清空日志确认弹窗:独立弹出框(垃圾桶图标,危险操作),确认后才真正清空
334
+ showClearConfirm && h(ConfirmDialog, {
335
+ type: 'trash',
336
+ title: t('logClearConfirmTitle'),
337
+ desc: t('logClearConfirmDesc'),
338
+ confirmLabel: t('logClearAll'),
339
+ busy: clearing,
340
+ busyLabel: t('logClearing'),
341
+ t,
342
+ onConfirm: () => void doClear(),
343
+ onCancel: () => setShowClearConfirm(false),
344
+ }),
300
345
  )
301
346
  }
@@ -13,7 +13,7 @@
13
13
  * failures keep their copy / fix / file-an-issue actions. Opened from the
14
14
  * header entry button (after the Settings tab).
15
15
  */
16
- import { createElement as h } from 'react'
16
+ import { createElement as h, useState } from 'react'
17
17
  import type { MouseEvent } from 'react'
18
18
  import styles from '../../styles/Modal.module.css'
19
19
  import type { EnvInfo, Translate } from '../../types.ts'
@@ -22,6 +22,7 @@ import { classifyFailure, npmTooLowVersion } from '../../logic/failures.ts'
22
22
  import type { PendingRestart, QueueTask } from '../../hooks/useTaskQueue.ts'
23
23
  import { pluginIssueUrl, pluginSiteUrl } from '../../logic/urls.ts'
24
24
  import { CloseIcon } from '../ui/icons.tsx'
25
+ import { ConfirmDialog } from './ConfirmDialog.tsx'
25
26
 
26
27
  /** 记录时间完整展示:YYYY-MM-DD HH:mm:ss(每条通知都带精确到秒的时间戳)。 */
27
28
  function fmtTime(at: number): string {
@@ -79,6 +80,10 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
79
80
  * 查不到返回 null,按钮与仓库链接一并隐藏(防提到错误仓库)。不传则按记录原样展示。 */
80
81
  resolveRepo?: (repo: string) => string | null
81
82
  }) {
83
+ // 危险操作确认:清空全部 / 删除单条都必须先弹独立确认框(垃圾桶图标),禁止按钮内直接生效
84
+ const [confirmClear, setConfirmClear] = useState(false)
85
+ const [confirmRemoveId, setConfirmRemoveId] = useState<number | null>(null)
86
+
82
87
  return h('div', {
83
88
  className: styles.overlay,
84
89
  onClick: (e: MouseEvent<HTMLDivElement>) => {
@@ -234,12 +239,12 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
234
239
  onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onIgnoreUpdate(r.repo, r.version) },
235
240
  }, t('ignoreUpdateRun'))
236
241
  : null,
237
- // 每条通知右侧的删除按钮:单独移除这一条
242
+ // 每条通知右侧的删除按钮:先弹确认框,确认后移除这一条
238
243
  h('button', {
239
244
  className: styles.noticeRemove,
240
245
  'aria-label': t('removeNotification'),
241
246
  title: t('removeNotification'),
242
- onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onRemove(r.id) },
247
+ onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); setConfirmRemoveId(r.id) },
243
248
  }, h(CloseIcon)),
244
249
  ),
245
250
  // 每条失败记录是一个独立卡片:头部(状态/仓库/时间/复制完整日志)→ 修复或提 Issue 动作。
@@ -294,11 +299,31 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
294
299
  h('div', { className: styles.modalActions },
295
300
  records.length > 0 ? h('button', {
296
301
  className: styles.failClear,
297
- onClick: onClear,
302
+ onClick: () => setConfirmClear(true),
298
303
  }, t('notificationsClear')) : null,
299
304
  h('button', { className: styles.restartNow, onClick: onClose }, t('errorClose')),
300
305
  ),
301
306
  ),
302
307
  ),
308
+ // 清空全部通知确认弹窗:独立弹出框(垃圾桶图标,危险操作),确认后才真正清空
309
+ confirmClear && h(ConfirmDialog, {
310
+ type: 'trash',
311
+ title: t('notificationsClearConfirmTitle'),
312
+ desc: t('notificationsClearConfirmDesc'),
313
+ confirmLabel: t('notificationsClear'),
314
+ t,
315
+ onConfirm: () => { setConfirmClear(false); onClear() },
316
+ onCancel: () => setConfirmClear(false),
317
+ }),
318
+ // 删除单条通知确认弹窗:独立弹出框(垃圾桶图标),确认后才移除
319
+ confirmRemoveId !== null && h(ConfirmDialog, {
320
+ type: 'trash',
321
+ title: t('removeNotificationConfirmTitle'),
322
+ desc: t('removeNotificationConfirmDesc'),
323
+ confirmLabel: t('removeNotification'),
324
+ t,
325
+ onConfirm: () => { onRemove(confirmRemoveId); setConfirmRemoveId(null) },
326
+ onCancel: () => setConfirmRemoveId(null),
327
+ }),
303
328
  )
304
329
  }
@@ -12,7 +12,7 @@ import { createElement as h } from 'react'
12
12
  import type { MouseEvent } from 'react'
13
13
  import styles from '../../styles/Modal.module.css'
14
14
  import type { EnvInfo, HubPlugin, TaskState, ToastState, Translate } from '../../types.ts'
15
- import { CloseIcon, CopyIcon, LinkIcon } from '../ui/icons.tsx'
15
+ import { CloseIcon, ConfirmIcon, CopyIcon, LinkIcon } from '../ui/icons.tsx'
16
16
  import { ProgressView } from './ProgressView.tsx'
17
17
  import { installCommandOf } from '../../logic/install-command.ts'
18
18
  import { pluginDetailUrl, pluginIssueUrl, pluginSiteUrl } from '../../logic/urls.ts'
@@ -144,6 +144,10 @@ export function InstallModal(props: InstallModalProps) {
144
144
  })
145
145
  // 确认/进行中:来源行 + 安装命令 + 实时进度 + 操作按钮
146
146
  : h('div', { className: styles.modalBody },
147
+ // 安装/更新确认态:品牌蓝问号图标,一眼识别这是确认询问
148
+ !busy ? h('div', { className: styles.confirmIconWrap },
149
+ h(ConfirmIcon, { type: 'question' }),
150
+ ) : null,
147
151
  h('div', { className: styles.trustHint }, t('confirmDesc')),
148
152
  h('div', { className: styles.modalRow },
149
153
  h('span', { className: styles.modalLabel }, t('confirmPlugin')),
@@ -267,6 +271,10 @@ export function UninstallModal(props: UninstallModalProps) {
267
271
  })
268
272
  // 确认/进行中:来源行 + 实时进度 + 操作按钮
269
273
  : h('div', { className: styles.modalBody },
274
+ // 卸载确认态:红色垃圾桶图标,删除类危险操作
275
+ !busy ? h('div', { className: styles.confirmIconWrap },
276
+ h(ConfirmIcon, { type: 'trash' }),
277
+ ) : null,
270
278
  h('div', { className: styles.modalDesc }, t('uninstallDesc')),
271
279
  h('div', { className: styles.modalRow },
272
280
  h('span', { className: styles.modalLabel }, t('confirmPlugin')),
@@ -449,6 +457,10 @@ export function RestartConfirmModal({ t, restarting, onClose, onRestartNow }: {
449
457
  }, h(CloseIcon)),
450
458
  ),
451
459
  h('div', { className: styles.modalBody },
460
+ // 重启确认:红色警告三角 —— 重启会中断正在进行的安装/卸载任务,风险提示
461
+ h('div', { className: styles.confirmIconWrap },
462
+ h(ConfirmIcon, { type: 'warning' }),
463
+ ),
452
464
  h('div', { className: styles.failPrepareHint }, t('restartHint')),
453
465
  h('div', { className: styles.modalActions },
454
466
  h('button', {
@@ -340,6 +340,54 @@ export function ResetIcon() {
340
340
  }))
341
341
  }
342
342
 
343
+ /** 确认弹窗图标类型:普通确认 = 问号;危险操作 = 垃圾桶 / 警告三角 / 还原箭头 */
344
+ export type ConfirmIconType = 'question' | 'trash' | 'warning' | 'reset'
345
+
346
+ /** 确认弹窗图标:圆形徽标 + 白色图形 —— 普通确认(安装/更新/重启/忽略)蓝底白问号;
347
+ * 危险操作(清空/删除/卸载/重置)红底白动作图形。居中放在弹窗标题下方,一眼区分确认类型。 */
348
+ export function ConfirmIcon({ type }: { type: ConfirmIconType }) {
349
+ const danger = type !== 'question'
350
+ const glyphs = type === 'trash'
351
+ ? [
352
+ h('path', {
353
+ d: 'M4.5 6.5h15M9.5 6.5V4.8a1.3 1.3 0 0 1 1.3-1.3h2.4a1.3 1.3 0 0 1 1.3 1.3v1.7',
354
+ stroke: '#ffffff', strokeWidth: 1.9, strokeLinecap: 'round', strokeLinejoin: 'round',
355
+ }),
356
+ h('path', {
357
+ d: 'M6.2 6.5l.7 12.2a1.5 1.5 0 0 0 1.5 1.3h7.2a1.5 1.5 0 0 0 1.5-1.3l.7-12.2',
358
+ stroke: '#ffffff', strokeWidth: 1.9, strokeLinecap: 'round', strokeLinejoin: 'round',
359
+ }),
360
+ h('path', { d: 'M10 10.2v6M14 10.2v6', stroke: '#ffffff', strokeWidth: 1.9, strokeLinecap: 'round' }),
361
+ ]
362
+ : type === 'warning'
363
+ ? [
364
+ h('path', {
365
+ d: 'M12 4.2l8.5 14.3a1 1 0 0 1-.87 1.5H4.37a1 1 0 0 1-.87-1.5L12 4.2z',
366
+ stroke: '#ffffff', strokeWidth: 1.9, strokeLinejoin: 'round',
367
+ }),
368
+ h('path', { d: 'M12 9.6v4.5', stroke: '#ffffff', strokeWidth: 1.9, strokeLinecap: 'round' }),
369
+ h('circle', { cx: 12, cy: 16.7, r: 1.05, fill: '#ffffff' }),
370
+ ]
371
+ : type === 'reset'
372
+ ? [
373
+ h('path', { d: 'M4.5 7.8A8.4 8.4 0 1 1 3.6 14.4', stroke: '#ffffff', strokeWidth: 1.9, strokeLinecap: 'round' }),
374
+ h('path', { d: 'M3.9 3.9v3.9h3.9', stroke: '#ffffff', strokeWidth: 1.9, strokeLinecap: 'round', strokeLinejoin: 'round' }),
375
+ ]
376
+ : [
377
+ h('path', {
378
+ d: 'M9.5 9.2a2.6 2.6 0 1 1 3.7 2.38c-.84.38-1.2.95-1.2 1.72',
379
+ stroke: '#ffffff', strokeWidth: 1.9, strokeLinecap: 'round',
380
+ }),
381
+ h('circle', { cx: 12, cy: 16.4, r: 1.1, fill: '#ffffff' }),
382
+ ]
383
+ return h('div', {
384
+ className: danger ? `${modalStyles.confirmIcon} ${modalStyles.confirmIconDanger}` : modalStyles.confirmIcon,
385
+ 'aria-hidden': 'true',
386
+ },
387
+ h('svg', { viewBox: '0 0 24 24', width: 22, height: 22, fill: 'none' }, ...glyphs),
388
+ )
389
+ }
390
+
343
391
  /** 系统日志图标:文档纸 + 三行文字(stroke 继承 currentColor),设置左侧导航「系统日志」用。 */
344
392
  export function LogsIcon() {
345
393
  return h('svg', {
@@ -95,7 +95,7 @@ export function LogsView({ t, logPath, onLogPathSaved }: {
95
95
  h('div', { className: styles.actions },
96
96
  h('button', {
97
97
  type: 'button',
98
- className: `${styles.btn} ${styles.btnPrimary}`,
98
+ className: styles.btn,
99
99
  onClick: () => setOpen(true),
100
100
  }, t('logViewerOpen')),
101
101
  h('button', { type: 'button', className: styles.btn, disabled: entries.length === 0, onClick: exportFile }, t('logsExport')),
@@ -24,7 +24,7 @@ import { Toggle } from '../ui/Toggle.tsx'
24
24
  import { DiagnosticsView } from './DiagnosticsView.tsx'
25
25
  import { LogsView } from './LogsView.tsx'
26
26
  import {
27
- CloseIcon, DiagnosticsIcon, LogsIcon, ResetIcon, SecurityIcon, UpdatesIcon,
27
+ CloseIcon, ConfirmIcon, DiagnosticsIcon, LogsIcon, ResetIcon, SecurityIcon, UpdatesIcon,
28
28
  } from '../ui/icons.tsx'
29
29
  import type { EnvInfo, Translate } from '../../types.ts'
30
30
  import type { HubSettings } from '../../hooks/useSettings.ts'
@@ -253,6 +253,10 @@ export function SettingsView({ t, settings, update, reset, env, onCopy }: {
253
253
  }, h(CloseIcon)),
254
254
  ),
255
255
  h('div', { className: modalStyles.modalBody },
256
+ // 恢复默认 = 破坏性操作:红色警告三角图标,醒目提示风险
257
+ h('div', { className: modalStyles.confirmIconWrap },
258
+ h(ConfirmIcon, { type: 'warning' }),
259
+ ),
256
260
  h('div', { className: modalStyles.failPrepareHint }, t('settingsResetConfirmDetail')),
257
261
  h('div', { className: modalStyles.modalActions },
258
262
  h('button', {