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.
- package/README.en.md +12 -7
- package/README.md +12 -7
- package/client/client.js +725 -223
- package/client/client.js.map +1 -1
- package/lib/http/routes.js +43 -7
- package/lib/services/installed-versions.js +16 -1
- package/lib/services/loader.js +12 -5
- package/lib/services/npm-resolve.js +67 -0
- package/lib/services/task-queue.js +55 -10
- package/lib/types/services/install-types.d.ts +8 -0
- package/lib/types/services/installed-versions.d.ts +10 -0
- package/lib/types/services/loader.d.ts +6 -4
- package/lib/types/services/npm-resolve.d.ts +6 -0
- package/lib/types/services/task-queue.d.ts +11 -5
- package/package.json +1 -1
- package/src/client/components/AboutModal.tsx +72 -0
- package/src/client/components/CatalogHeader.tsx +30 -5
- package/src/client/components/HubUpdateModal.tsx +84 -0
- package/src/client/components/NotificationsModal.tsx +84 -58
- package/src/client/components/PluginCard.tsx +3 -0
- package/src/client/components/PluginHubSection.tsx +73 -14
- package/src/client/components/modals.tsx +42 -18
- package/src/client/hooks/useCatalog.ts +96 -18
- package/src/client/hooks/useTaskQueue.ts +164 -54
- package/src/client/lib/catalog.ts +81 -13
- package/src/client/lib/failures.ts +14 -2
- package/src/client/lib/renderMarkdown.ts +116 -0
- package/src/client/locales.ts +47 -4
- package/src/client/styles/Header.module.css +83 -3
- package/src/client/styles/List.module.css +4 -4
- package/src/client/styles/Modal.module.css +320 -5
- package/src/client/styles/tokens.module.css +8 -3
- package/src/client/types.ts +3 -1
- package/src/server/http/routes.ts +42 -7
- package/src/server/services/install-types.ts +8 -0
- package/src/server/services/installed-versions.ts +20 -1
- package/src/server/services/loader.ts +12 -5
- package/src/server/services/npm-resolve.ts +67 -0
- 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
|
-
/**
|
|
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
|
-
|
|
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', {
|
|
159
|
-
|
|
160
|
-
className: r.ok ? styles.
|
|
161
|
-
},
|
|
162
|
-
h('div', { className: styles.
|
|
163
|
-
h('div', {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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:
|
|
177
|
-
},
|
|
178
|
-
|
|
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.
|
|
238
|
+
className: styles.noticeTime,
|
|
181
239
|
title: new Date(r.at).toLocaleString(),
|
|
182
240
|
}, fmtTime(r.at)),
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 },
|
|
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
|
-
//
|
|
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'),
|