dsh-plugin 1.0.0 → 1.1.1

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.
Files changed (39) hide show
  1. package/README.en.md +12 -7
  2. package/README.md +12 -7
  3. package/client/client.js +725 -223
  4. package/client/client.js.map +1 -1
  5. package/lib/http/routes.js +43 -7
  6. package/lib/services/installed-versions.js +16 -1
  7. package/lib/services/loader.js +12 -5
  8. package/lib/services/npm-resolve.js +67 -0
  9. package/lib/services/task-queue.js +55 -10
  10. package/lib/types/services/install-types.d.ts +8 -0
  11. package/lib/types/services/installed-versions.d.ts +10 -0
  12. package/lib/types/services/loader.d.ts +6 -4
  13. package/lib/types/services/npm-resolve.d.ts +6 -0
  14. package/lib/types/services/task-queue.d.ts +11 -5
  15. package/package.json +1 -1
  16. package/src/client/components/AboutModal.tsx +72 -0
  17. package/src/client/components/CatalogHeader.tsx +30 -5
  18. package/src/client/components/HubUpdateModal.tsx +84 -0
  19. package/src/client/components/NotificationsModal.tsx +84 -58
  20. package/src/client/components/PluginCard.tsx +3 -0
  21. package/src/client/components/PluginHubSection.tsx +73 -14
  22. package/src/client/components/modals.tsx +42 -18
  23. package/src/client/hooks/useCatalog.ts +96 -18
  24. package/src/client/hooks/useTaskQueue.ts +164 -54
  25. package/src/client/lib/catalog.ts +81 -13
  26. package/src/client/lib/failures.ts +14 -2
  27. package/src/client/lib/renderMarkdown.ts +116 -0
  28. package/src/client/locales.ts +47 -4
  29. package/src/client/styles/Header.module.css +83 -3
  30. package/src/client/styles/List.module.css +4 -4
  31. package/src/client/styles/Modal.module.css +320 -5
  32. package/src/client/styles/tokens.module.css +8 -3
  33. package/src/client/types.ts +3 -1
  34. package/src/server/http/routes.ts +42 -7
  35. package/src/server/services/install-types.ts +8 -0
  36. package/src/server/services/installed-versions.ts +20 -1
  37. package/src/server/services/loader.ts +12 -5
  38. package/src/server/services/npm-resolve.ts +67 -0
  39. package/src/server/services/task-queue.ts +57 -10
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Hub 版本信息 / 自我更新说明弹窗:
3
+ * - 有更新(hasUpdate=true):标题「有新版本」,展示新版本号、发布时间与 Worker 下发的
4
+ * Markdown 变更记录(renderMarkdown 渲染,双语言按界面语言取 {zh,en} 对象),
5
+ * 按钮「稍后再说 / 直接更新」,确认后进入安装弹窗执行覆盖重装。
6
+ * - 无更新(hasUpdate=false):标题「当前版本」,展示当前版本信息与更新记录,已是最新。
7
+ * 点击头部版本号(或「可更新」徽标)打开 —— 无论有无更新都能看到更新内容。
8
+ */
9
+ import { createElement as h } from 'react'
10
+ import type { MouseEvent } from 'react'
11
+ import styles from '../styles/Modal.module.css'
12
+ import type { HubUpdateInfo } from '../lib/catalog.ts'
13
+ import type { LocaleId, Translate } from '../types.ts'
14
+ import { renderMarkdown } from '../lib/renderMarkdown.ts'
15
+ import { CloseIcon } from './icons.tsx'
16
+
17
+ export function HubUpdateModal({ info, lang, t, hasUpdate, onProceed, onClose }: {
18
+ info: HubUpdateInfo
19
+ lang: LocaleId
20
+ t: Translate
21
+ /** 是否有可用更新:有则显示「直接更新」,无则显示「已是最新」 */
22
+ hasUpdate: boolean
23
+ /** 点「直接更新」:关闭本弹窗,进入安装弹窗的更新流程 */
24
+ onProceed: () => void
25
+ onClose: () => void
26
+ }) {
27
+ // 变更记录:字符串直接使用;{zh,en} 对象按界面语言取,缺哪种补哪种,全缺则不出记录区
28
+ const notesRaw = typeof info.notes === 'string'
29
+ ? info.notes
30
+ : info.notes && typeof info.notes === 'object'
31
+ ? lang === 'en'
32
+ ? (info.notes.en ?? info.notes.zh ?? '')
33
+ : (info.notes.zh ?? info.notes.en ?? '')
34
+ : ''
35
+ const notesHtml = notesRaw.trim() ? renderMarkdown(notesRaw) : null
36
+ // 发布时间:ISO 字符串转本地可读格式;非法值静默隐藏
37
+ let published: string | null = null
38
+ if (info.publishedAt) {
39
+ const d = new Date(info.publishedAt)
40
+ if (!Number.isNaN(d.getTime())) {
41
+ published = d.toLocaleString(lang === 'en' ? 'en-US' : 'zh-CN')
42
+ }
43
+ }
44
+
45
+ return h('div', {
46
+ className: styles.overlay,
47
+ onClick: (e: MouseEvent<HTMLDivElement>) => {
48
+ if (e.target === e.currentTarget) onClose()
49
+ },
50
+ },
51
+ h('div', { className: `${styles.modal} ${styles.hubUpdateModal}`, role: 'dialog', 'aria-modal': 'true' },
52
+ h('div', { className: styles.modalHead },
53
+ h('div', { className: styles.modalTitle }, t(hasUpdate ? 'hubUpdateTitle' : 'hubCurrentTitle')),
54
+ h('button', {
55
+ className: styles.modalClose,
56
+ 'aria-label': t('confirmCancel'),
57
+ onClick: () => onClose(),
58
+ }, h(CloseIcon)),
59
+ ),
60
+ h('div', { className: styles.modalDesc },
61
+ t(hasUpdate ? 'hubUpdateDesc' : 'hubCurrentDesc', { version: info.version })),
62
+ h('div', { className: styles.hubUpdateMeta },
63
+ h('span', { className: styles.hubUpdateMetaItem }, `${t('version')} ${info.version}`),
64
+ published
65
+ ? h('span', { className: styles.hubUpdateMetaItem }, `${t('hubUpdatePublished')} ${published}`)
66
+ : null,
67
+ ),
68
+ notesHtml
69
+ ? h('div', {
70
+ className: styles.hubUpdateNotes,
71
+ dangerouslySetInnerHTML: { __html: notesHtml },
72
+ })
73
+ : null,
74
+ h('div', { className: styles.modalActions },
75
+ hasUpdate
76
+ ? [
77
+ h('button', { className: styles.restartLater, onClick: onClose }, t('hubUpdateLater')),
78
+ h('button', { className: styles.modalInstall, onClick: onProceed }, t('updateNow')),
79
+ ]
80
+ : h('button', { className: styles.modalInstall, onClick: onClose }, t('hubUpToDate')),
81
+ ),
82
+ ),
83
+ )
84
+ }
@@ -19,12 +19,11 @@ import type { PendingRestart, QueueTask } from '../hooks/useTaskQueue.ts'
19
19
  import { pluginIssueUrl, pluginSiteUrl } from '../lib/catalog.ts'
20
20
  import { CloseIcon } from './icons.tsx'
21
21
 
22
- /** 记录时间紧凑展示:今年内 MM-DD HH:mm,跨年补年份前缀。 */
22
+ /** 记录时间完整展示:YYYY-MM-DD HH:mm:ss(每条通知都带精确到秒的时间戳)。 */
23
23
  function fmtTime(at: number): string {
24
24
  const d = new Date(at)
25
25
  const pad = (n: number) => String(n).padStart(2, '0')
26
- const mmdd = `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
27
- return d.getFullYear() === new Date().getFullYear() ? mmdd : `${d.getFullYear()}-${mmdd}`
26
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
28
27
  }
29
28
 
30
29
  /** 圆形状态徽标内的白色图形:成功为对勾、失败为叉。 */
@@ -52,7 +51,7 @@ function BadgeGlyph({ ok }: { ok: boolean }) {
52
51
  }))
53
52
  }
54
53
 
55
- export function NotificationsModal({ records, tasks, pendingRestarts, t, env, onClose, onCopy, onClear, cancelTask, restarting, onRestart }: {
54
+ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, onClose, onCopy, onClear, onRemove, cancelTask, restarting, onRestart }: {
56
55
  records: NotificationRecord[]
57
56
  /** 进行中的安装/卸载任务(实时进度,与队列弹窗同一数据源) */
58
57
  tasks: QueueTask[]
@@ -63,6 +62,8 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
63
62
  onClose: () => void
64
63
  onCopy: (text: string) => void
65
64
  onClear: () => void
65
+ /** 删除单条通知记录 */
66
+ onRemove: (id: number) => void
66
67
  cancelTask: (id: number) => void
67
68
  restarting: boolean
68
69
  onRestart: () => void
@@ -155,65 +156,90 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
155
156
  ? h('div', { className: styles.failEmpty }, t('notificationsEmpty'))
156
157
  : h('div', { className: styles.noticeList },
157
158
  records.map((r) => {
158
- return h('div', { key: r.id, className: styles.noticeRow },
159
- h('div', {
160
- className: r.ok ? styles.noticeBadgeOk : styles.noticeBadgeFail,
161
- }, h(BadgeGlyph, { ok: r.ok })),
162
- h('div', { className: styles.noticeMain },
163
- h('div', { className: styles.noticeHead },
164
- h('span', {
165
- className: r.ok ? styles.noticeTextOk : styles.noticeTextFail,
166
- }, r.ok
167
- ? (r.kind === 'install' ? t('installDone') : t('uninstallDone'))
168
- : (r.kind === 'install' ? t('errorTitleInstall') : t('errorTitleUninstall'))),
169
- r.repo
170
- ? h('a', {
171
- className: styles.failRepo,
172
- // 跳转到官网详情页(含插件收录信息),不在通知弹窗直接跳 GitHub
173
- href: pluginSiteUrl(r.repo),
159
+ return h('div', {
160
+ key: r.id,
161
+ className: r.ok ? `${styles.noticeRow} ${styles.noticeRowOk}` : styles.noticeRow,
162
+ },
163
+ h('div', { className: styles.noticeRowMain },
164
+ h('div', {
165
+ className: r.ok ? styles.noticeBadgeOk : styles.noticeBadgeFail,
166
+ }, h(BadgeGlyph, { ok: r.ok })),
167
+ h('div', { className: styles.noticeMain },
168
+ h('div', { className: styles.noticeHead },
169
+ h('span', {
170
+ className: r.ok ? styles.noticeTextOk : styles.noticeTextFail,
171
+ }, r.ok
172
+ ? (r.kind === 'install' ? t('installDone') : t('uninstallDone'))
173
+ : (r.kind === 'install' ? t('errorTitleInstall') : t('errorTitleUninstall'))),
174
+ r.repo
175
+ ? h('a', {
176
+ className: styles.failRepo,
177
+ // 跳转到官网详情页(含插件收录信息),不在通知弹窗直接跳 GitHub
178
+ href: pluginSiteUrl(r.repo),
179
+ target: '_blank',
180
+ rel: 'noopener noreferrer',
181
+ title: r.repo,
182
+ }, r.repo)
183
+ : null,
184
+ !r.ok && h('button', {
185
+ className: styles.failCopy,
186
+ onClick: () => onCopy(r.message),
187
+ }, t('failCopy')),
188
+ // 成功通知:时间戳并入本行右侧,与图标/文字垂直居中 ——
189
+ // 不再单独占底部一行,卡片只有一行内容,图标文字整体居中不顶头
190
+ r.ok
191
+ ? h('span', {
192
+ className: styles.noticeTime,
193
+ title: new Date(r.at).toLocaleString(),
194
+ }, fmtTime(r.at))
195
+ : null,
196
+ // 每条通知右侧的删除按钮:单独移除这一条
197
+ h('button', {
198
+ className: styles.noticeRemove,
199
+ 'aria-label': t('removeNotification'),
200
+ title: t('removeNotification'),
201
+ onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onRemove(r.id) },
202
+ }, h(CloseIcon)),
203
+ ),
204
+ // 每条失败记录是一个独立卡片:头部(状态/仓库/时间/复制完整日志)→ 修复或提 Issue 动作。
205
+ // 卡片不展示错误日志预览,完整日志靠「复制完整日志」按钮带走,避免每条卡片被日志撑高
206
+ !r.ok && (() => {
207
+ const kind = classifyFailure(r.message)
208
+ if (kind === 'pluginPrepare' || kind === 'pnpmIgnoredBuild') {
209
+ // [packaging](预检/装后校验拦截:git 分发缺产物)与
210
+ // prepare 构建脚本实际执行失败 / 原生依赖构建被 pnpm 拦截:都是插件打包分发问题 —— 先说明原因,按钮在下方提交
211
+ return h('div', null, [
212
+ h('div', { className: styles.failPrepareHint }, kind === 'pnpmIgnoredBuild'
213
+ ? t('failIgnoredBuild')
214
+ : /\[packaging\]/i.test(r.message) ? t('failPackagingHint') : t('failPrepareHint')),
215
+ r.repo ? h('a', {
216
+ className: styles.failBigIssue,
217
+ href: pluginIssueUrl(r.repo, r.message, env, r.command, r.attempts),
218
+ target: '_blank',
219
+ rel: 'noopener noreferrer',
220
+ title: t('failIssueHint'),
221
+ }, t('failIssueBig')) : null,
222
+ ])
223
+ }
224
+ return r.repo ? h('a', {
225
+ className: styles.failBigIssue,
226
+ href: pluginIssueUrl(r.repo, r.message, env, r.command, r.attempts),
174
227
  target: '_blank',
175
228
  rel: 'noopener noreferrer',
176
- title: r.repo,
177
- }, r.repo)
178
- : null,
229
+ title: t('failIssueHint'),
230
+ }, t('failIssueBig')) : null
231
+ })(),
232
+ ),
233
+ ),
234
+ // 失败通知:时间戳保留独立底部行(右下角);成功通知已并入头部行右侧
235
+ !r.ok
236
+ ? h('div', { className: styles.noticeFoot },
179
237
  h('span', {
180
- className: styles.failTime,
238
+ className: styles.noticeTime,
181
239
  title: new Date(r.at).toLocaleString(),
182
240
  }, fmtTime(r.at)),
183
- !r.ok && h('button', {
184
- className: styles.failCopy,
185
- onClick: () => onCopy(r.message),
186
- }, t('failCopy')),
187
- ),
188
- // 每条失败记录是一个独立卡片:头部(状态/仓库/时间/复制完整日志)→ 修复或提 Issue 动作。
189
- // 卡片不展示错误日志预览,完整日志靠「复制完整日志」按钮带走,避免每条卡片被日志撑高
190
- !r.ok && (() => {
191
- const kind = classifyFailure(r.message)
192
- if (kind === 'pluginPrepare' || kind === 'pnpmIgnoredBuild') {
193
- // [packaging](预检/装后校验拦截:git 分发缺产物,不支持官方默认安装方式)与
194
- // prepare 构建脚本实际执行失败 / 原生依赖构建被 pnpm 拦截:都是插件打包分发问题 —— 先说明原因,按钮在下方提交
195
- return h('div', null, [
196
- h('div', { className: styles.failPrepareHint }, kind === 'pnpmIgnoredBuild'
197
- ? t('failIgnoredBuild')
198
- : /\[packaging\]/i.test(r.message) ? t('failPackagingHint') : t('failPrepareHint')),
199
- r.repo ? h('a', {
200
- className: styles.failBigIssue,
201
- href: pluginIssueUrl(r.repo, r.message, env),
202
- target: '_blank',
203
- rel: 'noopener noreferrer',
204
- title: t('failIssueHint'),
205
- }, t('failIssueBig')) : null,
206
- ])
207
- }
208
- return r.repo ? h('a', {
209
- className: styles.failBigIssue,
210
- href: pluginIssueUrl(r.repo, r.message, env),
211
- target: '_blank',
212
- rel: 'noopener noreferrer',
213
- title: t('failIssueHint'),
214
- }, t('failIssueBig')) : null
215
- })(),
216
- ),
241
+ )
242
+ : null,
217
243
  )
218
244
  }),
219
245
  ),
@@ -84,6 +84,9 @@ export function PluginCard({ plugin: p, copied, installedName, installedVersion,
84
84
  disabled: true,
85
85
  title: t('installed'),
86
86
  }, t('installed')))
87
+ // 未安装:按钮恒为「安装」,点击先弹信任确认;AI 识别为「可能需要命令行辅助」
88
+ // 的插件(webInstallable=false)不拦截,允许尝试一键安装,弹窗内会给出提示与
89
+ // 复制命令通道,安装失败时引导去 dsh 终端手动执行
87
90
  : h('button', {
88
91
  className: isCopied ? styles.installBtnCopied : styles.installBtn,
89
92
  // 文字恒定避免按钮宽度变化导致卡片跳动;点击先弹信任确认,
@@ -8,13 +8,15 @@ import { createElement as h, useEffect, useState } from 'react'
8
8
  import styles from '../styles/Header.module.css'
9
9
  import { en, zh } from '../locales.ts'
10
10
  import type { EnvInfo, HubPlugin, LocaleId, SectionProps, ToastState } from '../types.ts'
11
- import { langPathOf } from '../lib/catalog.ts'
11
+ import { langPathOf, installCommandOf, HUB_REPO, PLUGIN_VERSION } from '../lib/catalog.ts'
12
12
  import { getEnv } from '../lib/env.ts'
13
13
  import { useCatalog } from '../hooks/useCatalog.ts'
14
14
  import { useTaskQueue } from '../hooks/useTaskQueue.ts'
15
15
  import { ErrorModal, InstallModal, UninstallModal, Toast } from './modals.tsx'
16
+ import { AboutModal } from './AboutModal.tsx'
17
+ import { HubUpdateModal } from './HubUpdateModal.tsx'
16
18
  import { NotificationsModal } from './NotificationsModal.tsx'
17
- import { addFailure, addSuccess, clearNotifications, loadNotifications } from '../lib/failures.ts'
19
+ import { addFailure, addSuccess, clearNotifications, loadNotifications, removeNotification } from '../lib/failures.ts'
18
20
  import type { NotificationRecord } from '../lib/failures.ts'
19
21
  import { CatalogHeader } from './CatalogHeader.tsx'
20
22
  import { CategoryTabs } from './CategoryTabs.tsx'
@@ -48,15 +50,23 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
48
50
  const [confirmPlugin, setConfirmPlugin] = useState<HubPlugin | null>(null)
49
51
  /** 弹窗动作是否为「更新」:已安装插件点「更新」→ 走同一条 add 命令原位覆盖重装,文案区分安装/更新 */
50
52
  const [confirmIsUpdate, setConfirmIsUpdate] = useState(false)
53
+ /** Hub 自我更新说明弹窗:点「可更新」徽标先展示版本 + Markdown 变更记录,确认后再进安装弹窗 */
54
+ const [showHubUpdate, setShowHubUpdate] = useState(false)
55
+ /** 「关注我们」弹窗:GitHub 图标后按钮点击打开,展示平台介绍 + 用户反馈群二维码(Worker /about 推送) */
56
+ const [showAbout, setShowAbout] = useState(false)
51
57
  /** 卸载确认弹窗:记录待卸载的插件 */
52
58
  const [uninstallPlugin, setUninstallPlugin] = useState<HubPlugin | null>(null)
53
59
  /** 安装/卸载完成后的结果视图:停留弹窗内,点「完成」关闭 */
54
60
  const [installDone, setInstallDone] = useState(false)
55
61
  const [uninstallDone, setUninstallDone] = useState(false)
62
+ /** 结果视图是否给「立即重启」:服务端任务终态带出(卸载时 loader 已即时移除 → false 只给「完成」;
63
+ * true 时通知中心待重启条目同步常驻,直到用户点「立即重启」真正重启后才消失) */
64
+ const [installNeedsRestart, setInstallNeedsRestart] = useState(true)
65
+ const [uninstallNeedsRestart, setUninstallNeedsRestart] = useState(true)
56
66
  /** 结果视图「立即重启」:请求宿主重启后进入等待,服务回来后整页刷新 */
57
67
  const [restarting, setRestarting] = useState(false)
58
- /** 操作失败完整信息 + 所属插件仓库 + 失败类型(决定弹窗标题「安装失败/卸载失败」) */
59
- const [errorMsg, setErrorMsg] = useState<{ message: string; repo: string | null; kind: 'install' | 'uninstall' } | null>(null)
68
+ /** 操作失败完整信息 + 所属插件仓库 + 失败类型(决定弹窗标题「安装失败/卸载失败」)+ 实际执行的安装命令 + 尝试过的安装方式(issue 预填用) */
69
+ const [errorMsg, setErrorMsg] = useState<{ message: string; repo: string | null; kind: 'install' | 'uninstall'; command?: string; attempts?: string[] } | null>(null)
60
70
  /** 安装/卸载任务通知记录:localStorage 持久化,成败即落盘,即使错过弹窗也能回来查看 */
61
71
  const [notifications, setNotifications] = useState<NotificationRecord[]>(() => loadNotifications())
62
72
  const [showNotifications, setShowNotifications] = useState(false)
@@ -71,21 +81,23 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
71
81
  const queue = useTaskQueue({
72
82
  t,
73
83
  refreshInstalled: catalog.refreshInstalled,
74
- onInstallDone: (viaModal, repo) => {
84
+ onInstallDone: (viaModal, repo, needsRestart) => {
85
+ setInstallNeedsRestart(needsRestart)
75
86
  if (viaModal) setInstallDone(true)
76
87
  else setToast({ id: Date.now(), kind: 'done' })
77
88
  // 安装成功也写入通知记录:通知中心里成功与失败都能看到
78
89
  setNotifications(addSuccess({ kind: 'install', repo: repo ?? '' }))
79
90
  },
80
- onUninstallDone: (viaModal, repo) => {
91
+ onUninstallDone: (viaModal, repo, needsRestart) => {
92
+ setUninstallNeedsRestart(needsRestart)
81
93
  if (viaModal) setUninstallDone(true)
82
94
  else setToast({ id: Date.now(), kind: 'removed' })
83
95
  setNotifications(addSuccess({ kind: 'uninstall', repo: repo ?? '' }))
84
96
  },
85
- onError: (message, repo, kind) => {
86
- setErrorMsg({ message, repo, kind })
97
+ onError: (message, repo, kind, command, attempts) => {
98
+ setErrorMsg({ message, repo, kind, command, attempts })
87
99
  // 失败自动写入通知记录:任务在后台结束时没人盯着也能留痕
88
- setNotifications(addFailure({ kind, repo: repo ?? '', message }))
100
+ setNotifications(addFailure({ kind, repo: repo ?? '', message, command, attempts }))
89
101
  },
90
102
  installPlugin: confirmPlugin,
91
103
  uninstallPlugin,
@@ -97,20 +109,22 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
97
109
  },
98
110
  })
99
111
 
100
- // 信任/卸载/通知记录弹窗打开时按 Esc 关闭;任务进入后台队列后可随时关闭(任务继续)
112
+ // 信任/卸载/更新说明/通知记录弹窗打开时按 Esc 关闭;任务进入后台队列后可随时关闭(任务继续)
101
113
  useEffect(() => {
102
- if (!confirmPlugin && !uninstallPlugin && !showNotifications) return
114
+ if (!confirmPlugin && !uninstallPlugin && !showNotifications && !showHubUpdate && !showAbout) return
103
115
  const onKey = (e: KeyboardEvent) => {
104
116
  if (e.key === 'Escape') {
105
117
  setConfirmPlugin(null)
106
118
  setUninstallPlugin(null)
107
119
  setUninstallDone(false)
120
+ setShowHubUpdate(false)
121
+ setShowAbout(false)
108
122
  setShowNotifications(false)
109
123
  }
110
124
  }
111
125
  window.addEventListener('keydown', onKey)
112
126
  return () => window.removeEventListener('keydown', onKey)
113
- }, [confirmPlugin, uninstallPlugin, showNotifications])
127
+ }, [confirmPlugin, uninstallPlugin, showNotifications, showHubUpdate, showAbout])
114
128
 
115
129
  // Toast 统一自动消失
116
130
  useEffect(() => {
@@ -144,10 +158,10 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
144
158
  }
145
159
  }
146
160
 
147
- /** 弹窗动作一:复制安装命令到剪贴板,引导去终端粘贴执行。 */
161
+ /** 弹窗动作一:复制安装命令到剪贴板,引导去终端粘贴执行(npm 通道显示包名命令)。 */
148
162
  const copyCommand = async (p: HubPlugin) => {
149
163
  const repo = p.source?.repo ?? ''
150
- const ok = await doCopy(`dsh plugin add github:${repo}`)
164
+ const ok = await doCopy(installCommandOf(p))
151
165
  if (ok) {
152
166
  setCopied(repo)
153
167
  setToast({ id: Date.now(), kind: 'copied' })
@@ -204,6 +218,10 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
204
218
 
205
219
  const count = catalog.visible.length
206
220
 
221
+ // Hub 自身条目(目录数据里的 dshplugin/dsh-plugin-hub):头部「可更新」徽标的更新目标。
222
+ // 目录可能尚未加载(此时 hubEntry 为 null,徽标不渲染,加载完成后自然出现)。
223
+ const hubEntry = catalog.plugins?.find((p) => p.source?.repo === HUB_REPO) ?? null
224
+
207
225
  return h('div', { className: styles.root },
208
226
  h(CatalogHeader, {
209
227
  t,
@@ -211,6 +229,12 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
211
229
  statsTotal,
212
230
  statsVerified,
213
231
  onToggleLang: () => setManualLang(lang === 'en' ? 'zh' : 'en'),
232
+ // Hub 自身版本信息入口:版本号常驻可点(无更新也看得到当前版本更新内容),
233
+ // 有可用更新时版本号后紧跟红色「可更新」徽标(同一入口),点击都打开同一个更新说明弹窗。
234
+ // 有更新弹窗显示新版本 + Markdown 变更记录,点「直接更新」进安装弹窗覆盖重装。
235
+ hubUpdate: catalog.hubHasUpdate,
236
+ onVersionClick: () => setShowHubUpdate(true),
237
+ onAboutClick: () => setShowAbout(true),
214
238
  }),
215
239
  h(CategoryTabs, {
216
240
  category: catalog.category,
@@ -270,6 +294,34 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
270
294
  setUninstallPlugin(p)
271
295
  },
272
296
  }),
297
+ // Hub 版本信息弹窗:版本号/徽标点击打开 —— 有更新显示新版本 + 变更记录 + 「直接更新」,
298
+ // 无更新显示当前版本 + 记录 + 「已是最新」。Worker 拉取失败时兜底展示当前版本号。
299
+ showHubUpdate && h(HubUpdateModal, {
300
+ info: catalog.hubUpdateInfo ?? { version: PLUGIN_VERSION },
301
+ lang,
302
+ t,
303
+ hasUpdate: catalog.hubHasUpdate,
304
+ onClose: () => setShowHubUpdate(false),
305
+ onProceed: () => {
306
+ setShowHubUpdate(false)
307
+ // 与列表内更新同一流程:覆盖重装 dsh-plugin + 结果视图「立即重启」
308
+ if (hubEntry) {
309
+ setInstallDone(false)
310
+ setConfirmIsUpdate(true)
311
+ queue.clearModalTask()
312
+ setConfirmPlugin(hubEntry)
313
+ }
314
+ },
315
+ }),
316
+ // 「关注我们」弹窗:GitHub 图标后按钮点击打开 —— 平台介绍 + 用户反馈群二维码,
317
+ // 内容由 dsh-update Worker 的 /about 接口以 Markdown 形式推送,作者随时可改,非写死。
318
+ // Worker 未推送(info 为 null)时弹窗仍能打开并展示兜底文案,不影响其他功能。
319
+ showAbout && h(AboutModal, {
320
+ info: catalog.hubAboutInfo ?? null,
321
+ lang,
322
+ t,
323
+ onClose: () => setShowAbout(false),
324
+ }),
273
325
  // 安装确认弹窗:锁定/进度/结果视图逻辑收敛在 modals.tsx;更新与安装同入口,仅标记 update
274
326
  confirmPlugin && h(InstallModal, {
275
327
  plugin: confirmPlugin,
@@ -279,7 +331,10 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
279
331
  langPath,
280
332
  restarting,
281
333
  update: confirmIsUpdate,
334
+ // 仅命令行插件(webInstallable=false):弹窗只展示命令供复制,不提供一键安装
335
+ cliOnly: confirmPlugin.install?.webInstallable === false,
282
336
  submitting: queue.submitting,
337
+ needsRestart: installNeedsRestart,
283
338
  onClose: () => setConfirmPlugin(null),
284
339
  onCopy: () => copyCommand(confirmPlugin),
285
340
  onInstall: () => queue.installNow(confirmPlugin, confirmIsUpdate ? { update: true } : undefined),
@@ -294,6 +349,7 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
294
349
  langPath,
295
350
  restarting,
296
351
  submitting: queue.submitting,
352
+ needsRestart: uninstallNeedsRestart,
297
353
  onClose: () => {
298
354
  setUninstallDone(false)
299
355
  setUninstallPlugin(null)
@@ -309,6 +365,8 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
309
365
  message: errorMsg.message,
310
366
  repo: errorMsg.repo,
311
367
  kind: errorMsg.kind,
368
+ command: errorMsg.command,
369
+ attempts: errorMsg.attempts,
312
370
  t,
313
371
  env,
314
372
  onCopy: (text: string) => {
@@ -331,6 +389,7 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
331
389
  setToast({ id: Date.now(), kind: 'errCopied' })
332
390
  },
333
391
  onClear: () => setNotifications(clearNotifications()),
392
+ onRemove: (id: number) => setNotifications(removeNotification(id)),
334
393
  cancelTask: queue.cancelTask,
335
394
  restarting,
336
395
  onRestart: () => { void requestRestart() },
@@ -10,17 +10,21 @@ import styles from '../styles/Modal.module.css'
10
10
  import type { EnvInfo, HubPlugin, TaskState, ToastState, Translate } from '../types.ts'
11
11
  import { CloseIcon, CopyIcon, LinkIcon } from './icons.tsx'
12
12
  import { ProgressView } from './ProgressView.tsx'
13
- import { pluginDetailUrl, pluginIssueUrl, pluginSiteUrl } from '../lib/catalog.ts'
13
+ import { installCommandOf, pluginDetailUrl, pluginIssueUrl, pluginSiteUrl } from '../lib/catalog.ts'
14
14
  import { classifyFailure } from '../lib/failures.ts'
15
15
 
16
- /** 完成结果视图:绿色对勾 + 标题/描述 + 「稍后重启 / 立即重启」按钮对(部分插件需重启后才会挂载) */
16
+ /** 完成结果视图:绿色对勾 + 标题/描述;
17
+ * needsRestart=true(插件需重启才生效)→ 「稍后重启 / 立即重启」按钮对,点稍后重启后
18
+ * 通知中心待重启条目常驻(服务端登记,内存态),直到用户点「立即重启」真正重启后才消失;
19
+ * needsRestart=false(卸载已即时生效)→ 仅「完成」关闭。 */
17
20
  function ResultView({
18
- title, desc, t, restarting, onRestart, onClose,
21
+ title, desc, t, restarting, needsRestart, onRestart, onClose,
19
22
  }: {
20
23
  title: string
21
24
  desc: string
22
25
  t: Translate
23
26
  restarting: boolean
27
+ needsRestart: boolean
24
28
  onRestart: () => void
25
29
  onClose: () => void
26
30
  }) {
@@ -42,13 +46,18 @@ function ResultView({
42
46
  })),
43
47
  ),
44
48
  h('div', { className: styles.resultTitle }, title),
45
- h('div', { className: styles.resultDesc }, desc),
46
- h('div', { className: styles.resultRestarting }, restarting ? t('restarting') : t('restartHint')),
47
- h('div', { className: styles.modalActions },
48
- h('button', { className: styles.restartLater, onClick: onClose, disabled: restarting }, t('restartLater')),
49
- h('button', { className: styles.restartNow, onClick: onRestart, disabled: restarting },
50
- restarting ? t('restarting') : t('restartNow')),
51
- ),
49
+ desc ? h('div', { className: styles.resultDesc }, desc) : null,
50
+ needsRestart
51
+ ? h('div', null, [
52
+ h('div', { className: styles.resultRestarting }, restarting ? t('restarting') : t('restartHint')),
53
+ h('div', { className: styles.modalActions },
54
+ h('button', { className: styles.restartLater, onClick: onClose, disabled: restarting }, t('restartLater')),
55
+ h('button', { className: styles.restartNow, onClick: onRestart, disabled: restarting },
56
+ restarting ? t('restarting') : t('restartNow')),
57
+ ),
58
+ ])
59
+ : h('div', { className: styles.modalActions },
60
+ h('button', { className: styles.restartNow, onClick: onClose }, t('done'))),
52
61
  )
53
62
  }
54
63
 
@@ -61,8 +70,12 @@ export interface InstallModalProps {
61
70
  restarting: boolean
62
71
  /** 已安装插件的覆盖更新(走同一条 add 命令原位重装,文案区分安装/更新) */
63
72
  update: boolean
73
+ /** 仅命令行插件(webInstallable=false):不提供一键安装,只展示命令供复制到 dsh 终端 */
74
+ cliOnly: boolean
64
75
  /** 安装请求在途(fetch 等待响应):此时任务尚未入队,需禁用确认按钮防止二次点击 */
65
76
  submitting: boolean
77
+ /** 完成结果是否需重启才生效:true → 「稍后重启 / 立即重启」;false → 仅「完成」 */
78
+ needsRestart: boolean
66
79
  onClose: () => void
67
80
  onCopy: () => void
68
81
  onInstall: () => void
@@ -74,7 +87,7 @@ export interface InstallModalProps {
74
87
  * 只在本任务执行中展示实时进度;完成后切换为结果视图,与卸载一致。
75
88
  */
76
89
  export function InstallModal(props: InstallModalProps) {
77
- const { plugin, done, task, t, langPath, restarting, submitting, update, onClose, onCopy, onInstall, onRestart } = props
90
+ const { plugin, done, task, t, langPath, restarting, submitting, update, cliOnly, needsRestart, onClose, onCopy, onInstall, onRestart } = props
78
91
  const busy = submitting || (task !== null && (task.status === 'pending' || task.status === 'running'))
79
92
  // 进行中标题带上插件名(中文「XX 插件安装中」;英文状态词在前更自然),并用状态色区分
80
93
  const name = plugin.displayName ?? plugin.slug
@@ -112,6 +125,7 @@ export function InstallModal(props: InstallModalProps) {
112
125
  desc: update ? t('updateResultDesc') : t('installResultDesc'),
113
126
  t,
114
127
  restarting,
128
+ needsRestart,
115
129
  onRestart,
116
130
  onClose,
117
131
  })
@@ -142,11 +156,15 @@ export function InstallModal(props: InstallModalProps) {
142
156
  role: 'button',
143
157
  tabIndex: 0,
144
158
  },
145
- h('span', { className: styles.modalCmdText }, `dsh plugin add github:${plugin.source?.repo ?? ''}`),
159
+ h('span', { className: styles.modalCmdText }, installCommandOf(plugin)),
146
160
  h('span', { className: styles.modalCmdCopy }, h(CopyIcon), t('copyCmdLabel')),
147
161
  ),
162
+ // AI 识别可能需要命令行辅助的插件(webInstallable=false):提示但不拦截,仍允许尝试一键安装
163
+ cliOnly ? h('div', { className: styles.cliOnlyHint }, t('cliOnlyHint')) : null,
148
164
  task && task.status === 'pending' ? h('div', { className: styles.queuedHint }, t('queuedHint')) : null,
149
165
  task ? h(ProgressView, { task }) : null,
166
+ // 安装失败:引导用户复制上方命令到 dsh 终端手动安装(如 node-pty 等原生依赖构建脚本需人工放行)
167
+ task && task.status === 'failed' ? h('div', { className: styles.failedCopyHint }, t('failedCopyHint')) : null,
150
168
  h('div', { className: styles.modalActions },
151
169
  h('button', {
152
170
  className: styles.modalCopy,
@@ -177,6 +195,8 @@ export interface UninstallModalProps {
177
195
  restarting: boolean
178
196
  /** 卸载请求在途(fetch 等待响应):此时任务尚未入队,需禁用确认按钮防止二次点击 */
179
197
  submitting: boolean
198
+ /** 完成结果是否需重启才生效:true → 「稍后重启 / 立即重启」;false(loader 已即时移除)→ 仅「完成」 */
199
+ needsRestart: boolean
180
200
  onClose: () => void
181
201
  onCancel: () => void
182
202
  onCopyCommand: () => void
@@ -186,7 +206,7 @@ export interface UninstallModalProps {
186
206
 
187
207
  /** 卸载确认弹窗:确认/进行中(后台队列,可关闭);完成后切换为结果视图(成功即生效,仅「完成」关闭)。 */
188
208
  export function UninstallModal(props: UninstallModalProps) {
189
- const { plugin, done, task, t, langPath, restarting, submitting, onClose, onCancel, onCopyCommand, onConfirm, onRestart } = props
209
+ const { plugin, done, task, t, langPath, restarting, submitting, needsRestart, onClose, onCancel, onCopyCommand, onConfirm, onRestart } = props
190
210
  const busy = submitting || (task !== null && (task.status === 'pending' || task.status === 'running'))
191
211
  // 进行中标题带上插件名,与安装弹窗一致,并用状态色区分
192
212
  const name = plugin.displayName ?? plugin.slug
@@ -221,6 +241,7 @@ export function UninstallModal(props: UninstallModalProps) {
221
241
  desc: t('uninstallResultDesc'),
222
242
  t,
223
243
  restarting,
244
+ needsRestart,
224
245
  onRestart,
225
246
  onClose,
226
247
  })
@@ -270,16 +291,19 @@ export function UninstallModal(props: UninstallModalProps) {
270
291
 
271
292
  /** 预填插件仓库的 GitHub Issue 链接:标题带插件名,正文附完整错误信息,方便用户一键反馈。 */
272
293
  /** 安装/卸载失败弹窗:布局与失败记录一致(类型徽标 + 仓库超链接 + 隐蔽复制按钮),报错完整展示,底部一键提交 Issue。 */
273
- export function ErrorModal({ message, repo, kind, t, env, onCopy, onClose }: {
294
+ export function ErrorModal({ message, repo, kind, command, attempts, t, env, onCopy, onClose }: {
274
295
  message: string
275
296
  repo: string | null
276
297
  kind: 'install' | 'uninstall'
298
+ command?: string
299
+ /** 尝试过的安装方式(npm 反查 + 实际执行命令):issue 预填一并贴给作者,便于反推正确的 npm 包名 */
300
+ attempts?: string[]
277
301
  t: Translate
278
302
  env: EnvInfo | null
279
303
  onCopy: (text: string) => void
280
304
  onClose: () => void
281
305
  }) {
282
- // 失败归类:用官方默认安装方式装不上 = 插件仓库的问题,一律引导提 Issue
306
+ // 失败归类:当前安装通道(npm/git)装不上 = 插件分发/依赖的问题,一律引导提 Issue
283
307
  const failureKind = classifyFailure(message)
284
308
  return h('div', {
285
309
  className: styles.overlay,
@@ -320,14 +344,14 @@ export function ErrorModal({ message, repo, kind, t, env, onCopy, onClose }: {
320
344
  // 插件问题(prepare 构建失败 / 原生依赖构建被拦截 / git 分发缺产物):都是插件打包分发问题 —— 先说明原因,按钮在下方引导提 Issue
321
345
  failureKind === 'pluginPrepare' || failureKind === 'pnpmIgnoredBuild'
322
346
  ? h('div', null, [
323
- // [packaging](预检/装后校验拦截:git 分发缺产物,不支持官方默认安装方式)与
347
+ // [packaging](预检/装后校验拦截:git 分发缺产物)与
324
348
  // prepare 构建脚本实际执行失败 / 原生依赖构建被 pnpm 拦截:都是插件打包分发问题 —— 先说明原因,按钮在下方提交
325
349
  h('div', { className: styles.failPrepareHint }, failureKind === 'pnpmIgnoredBuild'
326
350
  ? t('failIgnoredBuild')
327
351
  : /\[packaging\]/i.test(message) ? t('failPackagingHint') : t('failPrepareHint')),
328
352
  repo ? h('a', {
329
353
  className: styles.failBigIssue,
330
- href: pluginIssueUrl(repo, message, env),
354
+ href: pluginIssueUrl(repo, message, env, command, attempts),
331
355
  target: '_blank',
332
356
  rel: 'noopener noreferrer',
333
357
  title: t('failIssueHint'),
@@ -335,7 +359,7 @@ export function ErrorModal({ message, repo, kind, t, env, onCopy, onClose }: {
335
359
  ])
336
360
  : repo ? h('a', {
337
361
  className: styles.failBigIssue,
338
- href: pluginIssueUrl(repo, message, env),
362
+ href: pluginIssueUrl(repo, message, env, command, attempts),
339
363
  target: '_blank',
340
364
  rel: 'noopener noreferrer',
341
365
  title: t('failIssueHint'),