dsh-plugin 1.3.2 → 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',
@@ -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.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
  }
@@ -9,7 +9,7 @@
9
9
  * installed-plugin table, and the filter/search/sort/install-status view
10
10
  * state, exposing the derived visible list and per-category counts.
11
11
  */
12
- import { useEffect, useMemo, useState } from 'react'
12
+ import { useEffect, useMemo, useRef, useState } from 'react'
13
13
  import type { HubPlugin, LocaleId } from '../types.ts'
14
14
  import { HUB_REPO, PLUGIN_VERSION } from '../logic/constants.ts'
15
15
  import type { HubAboutInfo, HubUpdateInfo } from '../types.ts'
@@ -45,6 +45,14 @@ export function useCatalog(lang: LocaleId) {
45
45
  const [sort, setSort] = useState<SortKey>('sortStars')
46
46
  /** 当前排序方向:点同一个排序按钮切换 正序/倒序;点新排序用该排序的默认方向(与已安装视图一致) */
47
47
  const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
48
+ /** 目录加载「超时自动强制刷新」重试计数:宿主重启/代理未就绪时请求可能长时间挂起,
49
+ * 界面停在「正在加载插件数据…」假死 —— 超时自动重拉,最多自动重试 2 次后转失败态;
50
+ * 用户手动刷新(reload)时清零重新计数。 */
51
+ const loadRetriesRef = useRef(0)
52
+ /** 目录加载超时阈值:超过即判定挂起,强制刷新一次(服务端代理 curl 上限 20s,客户端 12s 先兜底) */
53
+ const LOAD_TIMEOUT_MS = 12000
54
+ /** 挂起后最多自动重试次数:避免代理持续不通时无限循环刷新(每次重试仍 12s,2 次后转失败界面可手动重试) */
55
+ const MAX_LOAD_RETRIES = 2
48
56
 
49
57
  /** 点击排序按钮:同一按钮切换正/倒序,新按钮用默认方向 */
50
58
  function toggleSort(key: SortKey) {
@@ -83,15 +91,21 @@ export function useCatalog(lang: LocaleId) {
83
91
  // 拉取目录 + 统计。在线 API 已只返回 verified;再过滤一次,保证只展示人工验证通过的插件。
84
92
  useEffect(() => {
85
93
  let cancelled = false
94
+ // 本次加载是否已收尾(成功或失败):超时定时器只对「还在加载中」生效
95
+ let settled = false
96
+ // 中止控制器:请求挂起时(宿主重启/代理抖动)超时自动重试会换新请求,
97
+ // 旧请求在此中止释放连接,避免挂起堆积
98
+ const controller = new AbortController()
86
99
  setPlugins(null)
87
100
  setHubPlugin(null)
88
101
  setStats(null)
89
102
  setFailed(false)
90
103
  // 目录数据直拉:hub 自身版本由独立 Worker 版本控制中心管理,不再依赖主站版本号接口。
91
- const load = () => Promise.all([fetchCatalog(lang), fetchStats()])
104
+ const load = () => Promise.all([fetchCatalog(lang, controller.signal), fetchStats(controller.signal)])
92
105
  load()
93
106
  .then(([list, stats]) => {
94
107
  if (cancelled) return
108
+ settled = true
95
109
  // 插件市场不显示自己:DSH Plugin Hub 从目录里排除自身条目,
96
110
  // 防止「自己出现在自己的插件列表里、还能自己安装自己」;统计计数同步减 1 保持与列表一致。
97
111
  // 目录数据已排除自身,此处再兜底过滤一次,防旧快照仍含自身条目。
@@ -106,9 +120,27 @@ export function useCatalog(lang: LocaleId) {
106
120
  }
107
121
  })
108
122
  .catch(() => {
109
- if (!cancelled) setFailed(true)
123
+ if (cancelled) return
124
+ settled = true
125
+ setFailed(true)
110
126
  })
111
- return () => { cancelled = true }
127
+ // 加载超时兜底:宿主刚重启/代理未就绪时目录请求可能长时间挂起,界面停在「正在加载插件数据…」
128
+ // 像假死。超过阈值未收尾 → 强制刷新列表(重拉一次,最多 MAX_LOAD_RETRIES 次自动重试),
129
+ // 重试仍失败 → 转失败界面(显示「重试」按钮,手动刷新时计数清零重新开始)。
130
+ const timer = window.setTimeout(() => {
131
+ if (cancelled || settled) return
132
+ if (loadRetriesRef.current < MAX_LOAD_RETRIES) {
133
+ loadRetriesRef.current += 1
134
+ setReloadKey((k) => k + 1)
135
+ } else {
136
+ setFailed(true)
137
+ }
138
+ }, LOAD_TIMEOUT_MS)
139
+ return () => {
140
+ cancelled = true
141
+ window.clearTimeout(timer)
142
+ controller.abort()
143
+ }
112
144
  // lang 必须进依赖:切换界面语言要重新拉对应语言的数据文件,
113
145
  // 否则英文模式仍停留在 plugins.zh.json,描述/名称全是中文
114
146
  }, [reloadKey, lang])
@@ -218,7 +250,7 @@ export function useCatalog(lang: LocaleId) {
218
250
  hubPlugin,
219
251
  stats,
220
252
  failed,
221
- reload: () => setReloadKey((k) => k + 1),
253
+ reload: () => { loadRetriesRef.current = 0; setReloadKey((k) => k + 1) },
222
254
  installed,
223
255
  installedItems,
224
256
  installedName,