dsh-plugin 1.3.1 → 1.3.3

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,7 @@ 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
23
  const PROFILE_RE = /^[A-Za-z0-9_-]+$/;
24
24
  const BODY_LIMIT_BYTES = 4 * 1024;
25
25
  const COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
@@ -311,6 +311,27 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
311
311
  }
312
312
  },
313
313
  }),
314
+ webServer.register({
315
+ kind: 'exact',
316
+ path: '/dsh-plugin-hub/clear-log',
317
+ handler: (request, response) => {
318
+ if (!requireTrustedPost(request, response))
319
+ return;
320
+ // 清空所有日志:截断当前日志文件为空
321
+ try {
322
+ const ok = clearLog(profile);
323
+ if (!ok) {
324
+ sendJson(response, 500, { error: 'failed to clear log' });
325
+ return;
326
+ }
327
+ appendLog(profile, { at: Date.now(), level: 'info', category: 'system', event: 'system.clear', message: '日志已由用户清空' });
328
+ sendJson(response, 200, { ok: true });
329
+ }
330
+ catch {
331
+ sendJson(response, 500, { error: 'failed to clear log' });
332
+ }
333
+ },
334
+ }),
314
335
  webServer.register({
315
336
  kind: 'exact',
316
337
  path: '/dsh-plugin-hub/open-log',
@@ -660,6 +681,8 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
660
681
  attempts,
661
682
  timeoutMs: COMMAND_TIMEOUT_MS,
662
683
  env: mutationEnv(settings),
684
+ // 覆盖更新:npm 通道命令显式 @latest(pnpm add 无版本对已存在依赖是幂等的,不加版本不会真更新)
685
+ updateNpm: mode === 'update',
663
686
  });
664
687
  sendJson(response, 200, { ok: true, task: task.id });
665
688
  }
@@ -227,20 +227,26 @@ function pumpQueue() {
227
227
  * Never rejects; failures surface through the result / task state.
228
228
  */
229
229
  function spawnMutation(options) {
230
- const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget, uninstallLoader, globalNpm } = options;
230
+ const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget, uninstallLoader, globalNpm, updateNpm } = options;
231
231
  // 全局 npm 安装:直接 spawn 本机 npm(`npm install -g <pkgs>`),不是 dsh plugin 命令
232
232
  const isGlobalNpm = (globalNpm ?? []).length > 0;
233
233
  const invocation = isGlobalNpm
234
234
  ? { file: 'npm', prefixArgs: [], cwd: process.cwd(), useShell: process.platform === 'win32' }
235
235
  : cliInvocation();
236
+ // 更新已安装的 npm 包:显式指定 @latest。pnpm add <pkg>(无版本)对已存在依赖
237
+ // (尤其精确 spec 如 "1.3.0")是幂等的 —— 输出 "Already up to date" 且不改 package.json,
238
+ // 导致「更新成功」但实际仍停留在旧版本;git 地址与全局安装不受此影响。
239
+ const npmUpdateTarget = updateNpm && !isGlobalNpm && githubRepoOf(target) === null
240
+ ? `${target}@latest`
241
+ : target;
236
242
  const args = isGlobalNpm
237
243
  ? ['install', '-g', ...(globalNpm ?? [])]
238
- : [...invocation.prefixArgs, 'plugin', '--profile', profile, action, target];
244
+ : [...invocation.prefixArgs, 'plugin', '--profile', profile, action, npmUpdateTarget];
239
245
  // 记录实际执行的命令(npm 通道显示包名,git 通道显示 HTTPS 源),
240
246
  // 失败提 Issue 时作为「已尝试的安装方式」随附
241
247
  const executed = isGlobalNpm
242
248
  ? `npm install -g ${(globalNpm ?? []).join(' ')}`
243
- : `dsh plugin --profile ${profile} ${action} ${target}`;
249
+ : `dsh plugin --profile ${profile} ${action} ${npmUpdateTarget}`;
244
250
  if (task && !task.attempts.includes(executed)) {
245
251
  task.attempts.push(executed);
246
252
  }
@@ -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)
@@ -50,6 +50,8 @@ export declare function startPluginMutation(options: {
50
50
  uninstallLoader?: LoaderHandle;
51
51
  /** 入队前已尝试的安装方式(npm registry 反查等):失败提 Issue 时如实展示;实际执行的命令由 spawn 时追加 */
52
52
  attempts?: string[];
53
+ /** 更新已安装的 npm 包(mode=update):命令显式带 @latest,让 pnpm 真正解析最新版本 */
54
+ updateNpm?: boolean;
53
55
  }): InstallTask;
54
56
  /**
55
57
  * 安装后校验已装包的入口文件(package.json 的 main / exports["."].default)是否真实存在。
@@ -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.1",
3
+ "version": "1.3.3",
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', {
@@ -16,21 +16,22 @@ const PROXY_BASE = '/dsh-plugin-hub/catalog'
16
16
  const PLUGINS_URL = (lang: LocaleId) => `${PROXY_BASE}?lang=${lang}`
17
17
  const STATS_URL = `${PROXY_BASE}?stats=1`
18
18
 
19
- async function fetchJson(url: string): Promise<unknown> {
20
- const res = await fetch(url, { cache: 'no-store' })
19
+ async function fetchJson(url: string, signal?: AbortSignal): Promise<unknown> {
20
+ const res = await fetch(url, { cache: 'no-store', signal })
21
21
  if (!res.ok) throw new Error(String(res.status))
22
22
  return res.json()
23
23
  }
24
24
 
25
- /** 目录插件列表(在线 API 已只返回 verified;过滤与排序由上层负责)。 */
26
- export async function fetchCatalog(lang: LocaleId): Promise<HubPlugin[]> {
27
- const data = await fetchJson(PLUGINS_URL(lang))
25
+ /** 目录插件列表(在线 API 已只返回 verified;过滤与排序由上层负责)。
26
+ * signal 可选:宿主重启/代理抖动导致请求挂起时,上层可中止旧请求释放连接。 */
27
+ export async function fetchCatalog(lang: LocaleId, signal?: AbortSignal): Promise<HubPlugin[]> {
28
+ const data = await fetchJson(PLUGINS_URL(lang), signal)
28
29
  return (Array.isArray(data) ? data : []).map((item) => normalize(item as Record<string, unknown>))
29
30
  }
30
31
 
31
32
  /** 收录/精选统计(/api/stats.json);字段不完整返回 null。 */
32
- export async function fetchStats(): Promise<{ total: number; verified: number } | null> {
33
- const s = await fetchJson(STATS_URL) as { total?: number; verified?: number }
33
+ export async function fetchStats(signal?: AbortSignal): Promise<{ total: number; verified: number } | null> {
34
+ const s = await fetchJson(STATS_URL, signal) as { total?: number; verified?: number }
34
35
  if (s && typeof s.total === 'number' && typeof s.verified === 'number') {
35
36
  return { total: s.total, verified: s.verified }
36
37
  }