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
|
@@ -7,11 +7,14 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { useEffect, useMemo, useState } from 'react'
|
|
9
9
|
import type { HubPlugin, LocaleId } from '../types.ts'
|
|
10
|
-
import { normalize } from '../lib/catalog.ts'
|
|
10
|
+
import { HUB_ABOUT_URL, HUB_REPO, HUB_UPDATE_URL, normalize, PLUGIN_VERSION } from '../lib/catalog.ts'
|
|
11
|
+
import type { HubAboutInfo, HubUpdateInfo } from '../lib/catalog.ts'
|
|
11
12
|
import type { SortKey } from '../lib/catalog.ts'
|
|
12
13
|
|
|
13
14
|
const PLUGINS_URL = (lang: LocaleId) => `https://dsh-plugin.org/api/plugins.${lang}.json`
|
|
14
15
|
const STATS_URL = 'https://dsh-plugin.org/api/stats.json'
|
|
16
|
+
/** 插件市场自身仓库:DSH-Plugin Hub 不显示在目录里(自己不进自己的插件列表) */
|
|
17
|
+
const SELF_REPO = 'dshplugin/dsh-plugin-hub'
|
|
15
18
|
|
|
16
19
|
export function useCatalog(lang: LocaleId) {
|
|
17
20
|
/** 目录插件(仅保留人工验证通过的条目) */
|
|
@@ -27,8 +30,13 @@ export function useCatalog(lang: LocaleId) {
|
|
|
27
30
|
const [installedFilter, setInstalledFilter] = useState<'all' | 'installed' | 'notInstalled'>('all')
|
|
28
31
|
/** 当前 profile 已安装插件:npm 包名 -> manifest spec(来自宿主本地路由) */
|
|
29
32
|
const [installed, setInstalled] = useState<Record<string, string>>({})
|
|
30
|
-
/** 安装时记录的目录信号:repo(小写) -> { version, updatedAt }
|
|
31
|
-
|
|
33
|
+
/** 安装时记录的目录信号:repo(小写) -> { version, updatedAt }(来自宿主本地路由);
|
|
34
|
+
* npmPackage 为 npm 优先通道反查命中的包名映射(目录数据未下发时客户端靠它把依赖 key 匹配回仓库) */
|
|
35
|
+
const [versions, setVersions] = useState<Record<string, { version: string; updatedAt: string; npmPackage?: string }>>({})
|
|
36
|
+
/** Hub 自我更新信息:来自 CF Worker 版本控制中心(hub.dsh-plugin.org),与目录数据解耦 */
|
|
37
|
+
const [hubUpdateInfo, setHubUpdateInfo] = useState<HubUpdateInfo | null>(null)
|
|
38
|
+
/** 头部「关注我们」弹窗内容(平台介绍 + 反馈群二维码):来自同款 Worker /about,Markdown 推送非写死 */
|
|
39
|
+
const [hubAboutInfo, setHubAboutInfo] = useState<HubAboutInfo | null>(null)
|
|
32
40
|
|
|
33
41
|
// 拉取目录 + 统计。在线 API 已只返回 verified;再过滤一次,保证只展示人工验证通过的插件。
|
|
34
42
|
useEffect(() => {
|
|
@@ -41,19 +49,29 @@ export function useCatalog(lang: LocaleId) {
|
|
|
41
49
|
if (!res.ok) throw new Error(String(res.status))
|
|
42
50
|
return res.json()
|
|
43
51
|
})
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
// 目录数据直拉:hub 自身版本由独立 Worker 版本控制中心管理,不再依赖主站版本号接口;
|
|
53
|
+
// 数据接口 CDN 缓存 1 小时,发布新数据后最长 1 小时全网生效。
|
|
54
|
+
const load = () =>
|
|
55
|
+
Promise.all([fetchData(PLUGINS_URL(lang)), fetchData(STATS_URL)])
|
|
56
|
+
load()
|
|
57
|
+
.then(([data, s]) => {
|
|
58
|
+
if (cancelled) return
|
|
59
|
+
const list = (Array.isArray(data) ? data : []).map((item) => normalize(item as Record<string, unknown>))
|
|
60
|
+
// 插件市场不显示自己:DSH-Plugin Hub 从目录里排除自身条目,
|
|
61
|
+
// 防止「自己出现在自己的插件列表里、还能自己安装自己」;统计计数同步减 1 保持与列表一致。
|
|
62
|
+
// 主站数据构建侧已同步防止(registry/API 不再含自身),此处为已发布旧数据/CDN 缓存期的兜底。
|
|
63
|
+
const hadSelf = list.some((p) => p.source?.repo === SELF_REPO)
|
|
64
|
+
setPlugins(list.filter((p) => p.compatibility?.status === 'verified' && p.source?.repo !== SELF_REPO))
|
|
65
|
+
const stats = s as { total?: number; verified?: number }
|
|
66
|
+
if (stats && typeof stats.total === 'number' && typeof stats.verified === 'number') {
|
|
67
|
+
setStats(hadSelf
|
|
68
|
+
? { total: stats.total - 1, verified: stats.verified - 1 }
|
|
69
|
+
: { total: stats.total, verified: stats.verified })
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
.catch(() => {
|
|
73
|
+
if (!cancelled) setFailed(true)
|
|
74
|
+
})
|
|
57
75
|
return () => { cancelled = true }
|
|
58
76
|
// lang 必须进依赖:切换界面语言要重新拉对应语言的数据文件,
|
|
59
77
|
// 否则英文模式仍停留在 plugins.zh.json,描述/名称全是中文
|
|
@@ -64,7 +82,7 @@ export function useCatalog(lang: LocaleId) {
|
|
|
64
82
|
try {
|
|
65
83
|
const res = await fetch('/dsh-plugin-hub/installed', { cache: 'no-store' })
|
|
66
84
|
if (!res.ok) return
|
|
67
|
-
const data = await res.json() as { installed?: Record<string, string>; versions?: Record<string, { version: string; updatedAt: string }> }
|
|
85
|
+
const data = await res.json() as { installed?: Record<string, string>; versions?: Record<string, { version: string; updatedAt: string; npmPackage?: string }> }
|
|
68
86
|
setInstalled(data.installed ?? {})
|
|
69
87
|
setVersions(data.versions ?? {})
|
|
70
88
|
} catch {
|
|
@@ -75,7 +93,43 @@ export function useCatalog(lang: LocaleId) {
|
|
|
75
93
|
// 首次进入拉取已安装表(依赖宿主 webServer 服务)
|
|
76
94
|
useEffect(() => { refreshInstalled() }, [])
|
|
77
95
|
|
|
78
|
-
|
|
96
|
+
// Hub 自身版本检查走独立 Worker(版本控制中心):发新版后写一次 Worker KV,
|
|
97
|
+
// 所有已装用户立即看到「可更新」+ Markdown 变更记录,不再依赖主站目录数据管道。
|
|
98
|
+
// 失败静默降级为 null(徽标不出现,不影响目录本身加载)。
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
let cancelled = false
|
|
101
|
+
fetch(HUB_UPDATE_URL, { cache: 'no-store' })
|
|
102
|
+
.then((res): Promise<HubUpdateInfo | null> => (res.ok ? res.json() : Promise.resolve(null)))
|
|
103
|
+
.then((data) => {
|
|
104
|
+
if (cancelled || !data) { if (!cancelled) setHubUpdateInfo(null); return }
|
|
105
|
+
const version = typeof data.version === 'string' && data.version.length > 0 ? data.version : null
|
|
106
|
+
setHubUpdateInfo(version === null
|
|
107
|
+
? null
|
|
108
|
+
: { version, publishedAt: data.publishedAt ?? null, notes: data.notes ?? null })
|
|
109
|
+
})
|
|
110
|
+
.catch(() => { if (!cancelled) setHubUpdateInfo(null) })
|
|
111
|
+
return () => { cancelled = true }
|
|
112
|
+
}, [reloadKey])
|
|
113
|
+
|
|
114
|
+
// 「关注我们」内容走同一套 Worker:作者写好 Markdown(平台介绍 + 反馈群二维码)推送 /admin/about,
|
|
115
|
+
// 客户端下次进入拉 /about 即可看到最新,非写死。失败静默为 null(弹窗展示兜底文案,不阻塞)。
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
let cancelled = false
|
|
118
|
+
fetch(HUB_ABOUT_URL, { cache: 'no-store' })
|
|
119
|
+
.then((res): Promise<HubAboutInfo | null> => (res.ok ? res.json() : Promise.resolve(null)))
|
|
120
|
+
.then((data) => {
|
|
121
|
+
if (cancelled) return
|
|
122
|
+
if (!data || data.content === null || data.content === undefined) { setHubAboutInfo(null); return }
|
|
123
|
+
const content = typeof data.content === 'string' || typeof data.content === 'object' ? data.content : null
|
|
124
|
+
setHubAboutInfo(content === null
|
|
125
|
+
? null
|
|
126
|
+
: { content, updatedAt: data.updatedAt ?? null })
|
|
127
|
+
})
|
|
128
|
+
.catch(() => { if (!cancelled) setHubAboutInfo(null) })
|
|
129
|
+
return () => { cancelled = true }
|
|
130
|
+
}, [reloadKey])
|
|
131
|
+
|
|
132
|
+
/** 插件是否已安装:匹配 installed spec 中的 `github:<owner>/<repo>`,或 npm 通道安装的依赖包名;命中返回 npm 包名。 */
|
|
79
133
|
const installedName = (p: HubPlugin): string | null => {
|
|
80
134
|
const repo = p.source?.repo
|
|
81
135
|
if (!repo) return null
|
|
@@ -83,6 +137,15 @@ export function useCatalog(lang: LocaleId) {
|
|
|
83
137
|
for (const [name, spec] of Object.entries(installed)) {
|
|
84
138
|
if (spec.toLowerCase().includes(needle)) return name
|
|
85
139
|
}
|
|
140
|
+
// npm 通道安装:profile 依赖 key 直接是 npm 包名。包名来源两处——目录数据(npmPackage),
|
|
141
|
+
// 或服务端 npm 优先反查持久化的映射(versions[repo].npmPackage,覆盖组织 scope 与 GitHub
|
|
142
|
+
// 用户名不一致、目录未下发包名的场景);命中任一并依赖 key 真实存在即视为已安装
|
|
143
|
+
const pkg = ((p.source?.npmPackage || versions[repo.toLowerCase()]?.npmPackage) ?? '').toLowerCase()
|
|
144
|
+
if (pkg) {
|
|
145
|
+
for (const name of Object.keys(installed)) {
|
|
146
|
+
if (name.toLowerCase() === pkg) return name
|
|
147
|
+
}
|
|
148
|
+
}
|
|
86
149
|
return null
|
|
87
150
|
}
|
|
88
151
|
|
|
@@ -109,6 +172,18 @@ export function useCatalog(lang: LocaleId) {
|
|
|
109
172
|
return Boolean(current && rec.updatedAt && current > rec.updatedAt)
|
|
110
173
|
}
|
|
111
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Hub 自身是否有可用更新:Hub 就是当前运行的应用(始终已安装,无需 installedName 命中)。
|
|
177
|
+
* 以 Worker 版本控制中心返回的「最新版本」为准,与当前运行的版本号比对——
|
|
178
|
+
* 构建时注入的 PLUGIN_VERSION 是运行 bundle 的真实版本;测试/异常场景缺失时
|
|
179
|
+
* 回退到安装时记录的版本。版本号不等即新版(版本号只在发版时变更,绝无降级场景)。
|
|
180
|
+
*/
|
|
181
|
+
const hubHasUpdate = (() => {
|
|
182
|
+
if (!hubUpdateInfo) return false
|
|
183
|
+
const current = PLUGIN_VERSION || versions[HUB_REPO]?.version || null
|
|
184
|
+
return current !== null && hubUpdateInfo.version !== current
|
|
185
|
+
})()
|
|
186
|
+
|
|
112
187
|
/** 当前分类下的插件(「全部」时为整个目录)。 */
|
|
113
188
|
const categoryPlugins = useMemo(() => {
|
|
114
189
|
if (!plugins) return []
|
|
@@ -174,6 +249,9 @@ export function useCatalog(lang: LocaleId) {
|
|
|
174
249
|
installedName,
|
|
175
250
|
installedVersion,
|
|
176
251
|
hasUpdate,
|
|
252
|
+
hubHasUpdate,
|
|
253
|
+
hubUpdateInfo,
|
|
254
|
+
hubAboutInfo,
|
|
177
255
|
refreshInstalled,
|
|
178
256
|
category,
|
|
179
257
|
setCategory,
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { useEffect, useRef, useState } from 'react'
|
|
9
9
|
import type { HubPlugin, Translate } from '../types.ts'
|
|
10
|
+
import { installCommandOf, installTargetOf } from '../lib/catalog.ts'
|
|
10
11
|
|
|
11
12
|
/** 客户端任务队列项:镜像服务端 active 任务(running 在前、pending 在后)。 */
|
|
12
13
|
export interface QueueTask {
|
|
@@ -25,6 +26,17 @@ export interface QueueTask {
|
|
|
25
26
|
status: 'pending' | 'running' | 'cancelling'
|
|
26
27
|
progress: number
|
|
27
28
|
lines: string[]
|
|
29
|
+
/** 乐观入队条目:点击安装后立即显示「正在安装中」,等 POST 响应回来再换成真实任务 id。
|
|
30
|
+
* 服务端还没登记(preflight 中),轮询合并/消失检测都必须跳过,避免被当作已结束任务收尾。 */
|
|
31
|
+
optimistic?: boolean
|
|
32
|
+
/** 实际执行的安装命令(npm/git 通道随插件数据决定):失败时 issue 预填如实展示 */
|
|
33
|
+
command?: string
|
|
34
|
+
/** 尝试过的安装方式(npm registry 反查 + 实际执行命令,按先后顺序):失败时 issue 预填一并贴给作者 */
|
|
35
|
+
attempts?: string[]
|
|
36
|
+
/** 完成结果是否需要重启才生效(服务端任务终态带出;默认 true 保持老行为:弹窗给重启选项)。
|
|
37
|
+
* 卸载时 loader 已即时移除 → false,结果视图只显示「完成」;true 时弹窗给「稍后重启/立即重启」,
|
|
38
|
+
* 通知中心待重启条目(服务端登记)同步常驻,直到用户点「立即重启」。 */
|
|
39
|
+
needsRestart: boolean
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
/** 客户端待重启项:镜像服务端内存列表 + 本地补齐的展示信息(简介/版本)。 */
|
|
@@ -41,11 +53,12 @@ export interface PendingRestart {
|
|
|
41
53
|
export interface TaskQueueOptions {
|
|
42
54
|
t: Translate
|
|
43
55
|
refreshInstalled: () => void
|
|
44
|
-
/** 任务成功完成:viaModal 表示任务对应当前打开弹窗(弹窗切结果视图),否则走 Toast;repo
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
56
|
+
/** 任务成功完成:viaModal 表示任务对应当前打开弹窗(弹窗切结果视图),否则走 Toast;repo 供成功通知记录;
|
|
57
|
+
* needsRestart 表示该任务完成后是否仍需宿主重启(结果视图据此给「重启 / 仅完成」)。 */
|
|
58
|
+
onInstallDone: (viaModal: boolean, repo: string | null, needsRestart: boolean) => void
|
|
59
|
+
onUninstallDone: (viaModal: boolean, repo: string | null, needsRestart: boolean) => void
|
|
60
|
+
/** 任务失败:完整输出或兜底文案 + 所属插件仓库 + 操作类型(安装/卸载)+ 实际执行的安装命令(issue 预填用,可缺省)+ 尝试过的安装方式(npm 反查/执行命令,可缺省)。 */
|
|
61
|
+
onError: (message: string, repo: string | null, kind: 'install' | 'uninstall', command?: string, attempts?: string[]) => void
|
|
49
62
|
/** 当前打开的安装/卸载弹窗插件:用于在弹窗内匹配进行中的任务。 */
|
|
50
63
|
installPlugin: HubPlugin | null
|
|
51
64
|
uninstallPlugin: HubPlugin | null
|
|
@@ -71,11 +84,15 @@ export function useTaskQueue(opts: TaskQueueOptions) {
|
|
|
71
84
|
resolvePendingRef.current = resolvePending
|
|
72
85
|
/** 当前打开弹窗所对应的任务 id:该任务完成时弹窗切换为结果视图 */
|
|
73
86
|
const modalTaskRef = useRef<number | null>(null)
|
|
87
|
+
/** 已开始收尾但尚未结束的任务 id(服务端 /active 已消失 → 查 /status 中):并发轮询时跳过,避免重复收尾 */
|
|
88
|
+
const settlingRef = useRef<Set<number>>(new Set())
|
|
74
89
|
/** 轮询定时器句柄(队列清空/组件卸载时清理) */
|
|
75
90
|
const pollRef = useRef<number | null>(null)
|
|
76
91
|
/** 请求在途的目标集合(同步防重):双击安装/卸载时第二击直接忽略。
|
|
77
92
|
* 本地队列任务要等 fetch 返回后才入队,仅靠 queueRef 检查拦不住请求窗口内的重复点击。 */
|
|
78
93
|
const submittingRef = useRef<Set<string>>(new Set())
|
|
94
|
+
/** 乐观入队临时 id 计数器:递减产生唯一负数,与服务端自增正数 id 永不冲突。 */
|
|
95
|
+
const tempIdRef = useRef(0)
|
|
79
96
|
/** 请求在途标记:弹窗据此禁用确认按钮,避免等待响应期间被再次点击。 */
|
|
80
97
|
const [submitting, setSubmitting] = useState(false)
|
|
81
98
|
|
|
@@ -156,42 +173,83 @@ export function useTaskQueue(opts: TaskQueueOptions) {
|
|
|
156
173
|
if (q.kind === 'uninstall') {
|
|
157
174
|
// 卸载成功:清掉本地版本记录
|
|
158
175
|
void syncInstalledVersion(q.repo, undefined, undefined)
|
|
159
|
-
onUninstallDone(modalTaskRef.current === q.id, q.repo)
|
|
176
|
+
onUninstallDone(modalTaskRef.current === q.id, q.repo, q.needsRestart)
|
|
160
177
|
} else {
|
|
161
178
|
// 安装成功:记录安装时的目录信号(版本 + 仓库更新时间),供「有更新」比对;
|
|
162
179
|
// 同时缓存展示信息,待重启列表(服务端已登记)合并时直接补齐简介/版本
|
|
163
180
|
void syncInstalledVersion(q.repo, q.version, q.updatedAt)
|
|
164
181
|
pendingInfoRef.current.set(q.target, { desc: q.desc, version: q.version })
|
|
165
|
-
onInstallDone(modalTaskRef.current === q.id, q.repo)
|
|
182
|
+
onInstallDone(modalTaskRef.current === q.id, q.repo, q.needsRestart)
|
|
166
183
|
}
|
|
167
184
|
} else {
|
|
168
185
|
// 失败:完整展示全部输出行(最新在前,逆序为日志阅读顺序),不裁剪
|
|
169
186
|
const detail = lines.length > 0
|
|
170
187
|
? [...lines].reverse().join('\n')
|
|
171
188
|
: q.kind === 'uninstall' ? t('uninstallFail') : t('installFail')
|
|
172
|
-
onError(detail, q.repo, q.kind)
|
|
189
|
+
onError(detail, q.repo, q.kind, q.command, q.attempts)
|
|
173
190
|
}
|
|
174
191
|
maybeStopPoll()
|
|
175
192
|
}
|
|
176
193
|
|
|
194
|
+
/** 卸载成功收尾前的进度过渡:任务已在服务端结束,但保留本地展示,
|
|
195
|
+
* 让进度条在 ~2.4s 内缓缓跑到 100% 再切结果/Toast,给用户一个交互过程,
|
|
196
|
+
* 而不是命令一执行进度条就一闪消失。 */
|
|
197
|
+
const settleDone = (q: QueueTask, lines: string[]) => {
|
|
198
|
+
// 收尾动画期间停止轮询:任务已从服务端 /active 消失,无需再查;
|
|
199
|
+
// 停掉可避免 pollQueue 的 gone 检测把它当「再消失一次」而重复收尾
|
|
200
|
+
stopPoll()
|
|
201
|
+
const from = Math.min(q.progress, 90) // 起点最多 90,给进度条留出跑满的缓冲空间
|
|
202
|
+
const steps = 20
|
|
203
|
+
const stepMs = 120 // 20 步 × 120ms = 2.4s
|
|
204
|
+
let step = 0
|
|
205
|
+
// 动画期间固定显示「卸载中」:任务实已结束,但进度在跑,与「排队中」文案更协调
|
|
206
|
+
applyQueue((prev) => prev.map((x) => (x.id === q.id ? { ...x, status: 'running' as const } : x)))
|
|
207
|
+
const timer = window.setInterval(() => {
|
|
208
|
+
step += 1
|
|
209
|
+
const progress = Math.round(from + (100 - from) * (step / steps))
|
|
210
|
+
applyQueue((prev) => prev.map((x) => (x.id === q.id ? { ...x, progress } : x)))
|
|
211
|
+
if (step >= steps) {
|
|
212
|
+
window.clearInterval(timer)
|
|
213
|
+
finishQueueTask(true, q, lines)
|
|
214
|
+
// 队列若还有其它已在排队/执行的任务,恢复轮询继续盯;已清空则自然停住
|
|
215
|
+
if (queueRef.current.length > 0) pollQueue()
|
|
216
|
+
}
|
|
217
|
+
}, stepMs)
|
|
218
|
+
}
|
|
219
|
+
|
|
177
220
|
/** 任务在 /active 中消失 = 已结束:查 /status 拿终态并收尾(cancelled 静默移除)。 */
|
|
178
221
|
const settleTask = async (q: QueueTask) => {
|
|
179
222
|
let status = 'failed'
|
|
180
223
|
let lines: string[] = []
|
|
224
|
+
let attempts: string[] | undefined
|
|
181
225
|
try {
|
|
182
226
|
const res = await fetch(`/dsh-plugin-hub/status?task=${q.id}`, { cache: 'no-store' })
|
|
183
227
|
if (res.ok) {
|
|
184
|
-
const data = await res.json() as { task?: { status?: string; lines?: string[] } }
|
|
185
|
-
|
|
186
|
-
|
|
228
|
+
const data = await res.json() as { task?: { status?: string; lines?: string[]; attempts?: string[]; needsRestart?: unknown } }
|
|
229
|
+
const task = data.task
|
|
230
|
+
if (task !== undefined) {
|
|
231
|
+
status = task.status ?? 'failed'
|
|
232
|
+
lines = task.lines ?? []
|
|
233
|
+
attempts = Array.isArray(task.attempts) ? task.attempts : undefined
|
|
234
|
+
// 终态带出的「是否需要重启」覆盖本地(服务端 done 时按 loader 即时移除情况设置)
|
|
235
|
+
const needsRestart = typeof task.needsRestart === 'boolean' ? task.needsRestart : q.needsRestart
|
|
236
|
+
if (status === 'done') {
|
|
237
|
+
const settled = { ...q, needsRestart }
|
|
238
|
+
// 卸载成功走进度过渡动画;安装成功直接收尾
|
|
239
|
+
if (q.kind === 'uninstall') settleDone(settled, lines)
|
|
240
|
+
else finishQueueTask(true, settled, lines)
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
if (status === 'failed') {
|
|
244
|
+
// 服务端终态带出的尝试记录覆盖本地:以实际执行为准
|
|
245
|
+
finishQueueTask(false, { ...q, attempts }, lines)
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
}
|
|
187
249
|
}
|
|
188
250
|
} catch { /* 服务端不可达:按失败处理 */ }
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
else {
|
|
192
|
-
applyQueue((prev) => prev.filter((x) => x.id !== q.id)) // cancelled / 未知
|
|
193
|
-
maybeStopPoll()
|
|
194
|
-
}
|
|
251
|
+
applyQueue((prev) => prev.filter((x) => x.id !== q.id)) // cancelled / 未知
|
|
252
|
+
maybeStopPoll()
|
|
195
253
|
}
|
|
196
254
|
|
|
197
255
|
/** 轮询整个队列:合并服务端 /active(running 在前、pending 在后),
|
|
@@ -203,7 +261,7 @@ export function useTaskQueue(opts: TaskQueueOptions) {
|
|
|
203
261
|
const res = await fetch('/dsh-plugin-hub/active', { cache: 'no-store' })
|
|
204
262
|
if (!res.ok) throw new Error(`active ${res.status}`)
|
|
205
263
|
const data = await res.json() as {
|
|
206
|
-
tasks?: { id?: unknown; action?: unknown; target?: unknown; status?: unknown; progress?: unknown; lines?: unknown }[]
|
|
264
|
+
tasks?: { id?: unknown; action?: unknown; target?: unknown; status?: unknown; progress?: unknown; lines?: unknown; attempts?: unknown; needsRestart?: unknown }[]
|
|
207
265
|
pendingRestarts?: unknown
|
|
208
266
|
}
|
|
209
267
|
const active = (data.tasks ?? []).filter((a) => typeof a.id === 'number')
|
|
@@ -213,32 +271,51 @@ export function useTaskQueue(opts: TaskQueueOptions) {
|
|
|
213
271
|
// 待重启列表同步(服务端内存态:宿主重启后自然变空,这里随之清空 UI)
|
|
214
272
|
applyPending(parsePendingRestarts(data.pendingRestarts))
|
|
215
273
|
// 合并:running 在前、pending 在后(与服务端一致),新出现任务补入队列
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
274
|
+
applyQueue((prev) => {
|
|
275
|
+
const next: QueueTask[] = []
|
|
276
|
+
for (const a of active) {
|
|
277
|
+
const id = a.id as number
|
|
278
|
+
const prevTask = prev.find((q) => q.id === id)
|
|
279
|
+
const isRemove = a.action === 'remove'
|
|
280
|
+
next.push({
|
|
281
|
+
id,
|
|
282
|
+
kind: isRemove ? 'uninstall' : 'install',
|
|
283
|
+
target: typeof a.target === 'string' ? a.target.replace(/^github:/, '') : (prevTask?.target ?? ''),
|
|
284
|
+
desc: prevTask?.desc,
|
|
285
|
+
repo: prevTask?.repo ?? (isRemove ? null : (typeof a.target === 'string' ? a.target.replace(/^github:/, '') : null)),
|
|
286
|
+
// 安装时快照的目录信号:轮询合并时保留,任务成功后记录到本地
|
|
287
|
+
version: prevTask?.version,
|
|
288
|
+
updatedAt: prevTask?.updatedAt,
|
|
289
|
+
status: a.status === 'running' ? 'running' : 'pending',
|
|
290
|
+
progress: typeof a.progress === 'number' ? a.progress : prevTask?.progress ?? 0,
|
|
291
|
+
lines: Array.isArray(a.lines) ? (a.lines as string[]) : (prevTask?.lines ?? []),
|
|
292
|
+
attempts: Array.isArray(a.attempts) ? (a.attempts as string[]) : (prevTask?.attempts ?? []),
|
|
293
|
+
needsRestart: typeof a.needsRestart === 'boolean' ? a.needsRestart : (prevTask?.needsRestart ?? true),
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
// 保留乐观条目:服务端尚未登记(preflight 中),无真实 id,不能因 active 缺它就被合并吞掉
|
|
297
|
+
const keepOpt = prev.filter((q) => q.optimistic && !next.some((x) => x.id === q.id))
|
|
298
|
+
if (keepOpt.length > 0) next.push(...keepOpt)
|
|
299
|
+
// 保留「已从服务端消失、但尚未收尾」的任务:收尾(settleTask)负责移除。
|
|
300
|
+
// 若在这里提前摘除,卸载/安装完成的那一瞬队列会先变空,弹窗闪回「确认」态
|
|
301
|
+
// (busy=false 且未 done),表现为「卸载中 → 卸载 → 卸载完成」的中间回退。
|
|
302
|
+
const keepGone = prev.filter((q) =>
|
|
303
|
+
!byId.has(q.id) && !next.some((x) => x.id === q.id) && q.status !== 'cancelling' && !q.optimistic)
|
|
304
|
+
if (keepGone.length > 0) next.push(...keepGone)
|
|
305
|
+
return next
|
|
306
|
+
})
|
|
307
|
+
// 本地有、服务端已消失 → 已结束:逐个查终态收尾
|
|
308
|
+
// (cancelling 是客户端乐观过渡态,交给 cancelTask 的定时移除,不在轮询里提前收敛;
|
|
309
|
+
// optimistic 是点击后尚未登记的占位,服务端 /status 查不到,跳过免误判成失败;
|
|
310
|
+
// settling 是上一轮已开始收尾的任务,并发轮询直接跳过,避免重复收尾)
|
|
311
|
+
const gone = prevQueue.filter((q) =>
|
|
312
|
+
!byId.has(q.id) && q.status !== 'cancelling' && !q.optimistic && !settlingRef.current.has(q.id))
|
|
313
|
+
for (const q of gone) {
|
|
314
|
+
// 防御:同 id 任务可能已被上一轮收尾移除(极端时序下轮询合并出的重复条目),跳过避免重复收尾
|
|
315
|
+
if (!queueRef.current.some((x) => x.id === q.id)) continue
|
|
316
|
+
settlingRef.current.add(q.id)
|
|
317
|
+
try { await settleTask(q) } finally { settlingRef.current.delete(q.id) }
|
|
318
|
+
}
|
|
242
319
|
} catch {
|
|
243
320
|
// 服务端暂不可达:静默等待下一轮
|
|
244
321
|
}
|
|
@@ -254,7 +331,7 @@ export function useTaskQueue(opts: TaskQueueOptions) {
|
|
|
254
331
|
const res = await fetch('/dsh-plugin-hub/active', { cache: 'no-store' })
|
|
255
332
|
if (!res.ok) return
|
|
256
333
|
const data = await res.json() as {
|
|
257
|
-
tasks?: { id?: unknown; action?: unknown; target?: unknown; status?: unknown; progress?: unknown; lines?: unknown }[]
|
|
334
|
+
tasks?: { id?: unknown; action?: unknown; target?: unknown; status?: unknown; progress?: unknown; lines?: unknown; attempts?: unknown; needsRestart?: unknown }[]
|
|
258
335
|
pendingRestarts?: unknown
|
|
259
336
|
}
|
|
260
337
|
if (cancelled) return
|
|
@@ -264,14 +341,21 @@ export function useTaskQueue(opts: TaskQueueOptions) {
|
|
|
264
341
|
.filter((a) => typeof a.id === 'number')
|
|
265
342
|
.map((a) => {
|
|
266
343
|
const isRemove = a.action === 'remove'
|
|
344
|
+
// 服务端任务的 target:github:repo(git 安装)/ npm 包名(npm 安装/卸载)。
|
|
345
|
+
// 恢复展示统一用 displayTarget(owner/repo,服务端固定传):npm 安装时 target 是包名,
|
|
346
|
+
// 直接展示会让用户看到「安装方式」的内部细节,违背无感知原则;卸载保持包名(匹配已装表)。
|
|
347
|
+
const display = typeof (a as { displayTarget?: unknown }).displayTarget === 'string' ? (a as { displayTarget: string }).displayTarget.replace(/^github:/, '') : ''
|
|
348
|
+
const fallback = typeof a.target === 'string' ? a.target.replace(/^github:/, '') : ''
|
|
267
349
|
return {
|
|
268
350
|
id: a.id as number,
|
|
269
351
|
kind: (isRemove ? 'uninstall' : 'install') as 'install' | 'uninstall',
|
|
270
|
-
target:
|
|
271
|
-
repo: isRemove ? null : (
|
|
352
|
+
target: isRemove ? fallback : (display || fallback),
|
|
353
|
+
repo: isRemove ? null : (display || fallback),
|
|
272
354
|
status: a.status === 'running' ? 'running' : 'pending',
|
|
273
355
|
progress: typeof a.progress === 'number' ? a.progress : 0,
|
|
274
356
|
lines: Array.isArray(a.lines) ? (a.lines as string[]) : [],
|
|
357
|
+
attempts: Array.isArray((a as { attempts?: unknown }).attempts) ? ((a as { attempts: string[] }).attempts) : [],
|
|
358
|
+
needsRestart: typeof (a as { needsRestart?: unknown }).needsRestart === 'boolean' ? (a as { needsRestart: boolean }).needsRestart : true,
|
|
275
359
|
}
|
|
276
360
|
})
|
|
277
361
|
if (items.length > 0 || pendingRef.current.length > 0) {
|
|
@@ -289,33 +373,59 @@ export function useTaskQueue(opts: TaskQueueOptions) {
|
|
|
289
373
|
const installNow = async (p: HubPlugin, opts?: { update?: boolean }) => {
|
|
290
374
|
const repo = p.source?.repo ?? ''
|
|
291
375
|
if (!repo) return
|
|
376
|
+
// 安装通道决策(用户无感知):有 npm 包名 → 走 npm;否则 git 直装。
|
|
377
|
+
// 队列条目一律用仓库名展示/防重,只有发给后端的实际安装目标随通道变化。
|
|
378
|
+
const { target } = installTargetOf(p)
|
|
379
|
+
if (!target) return
|
|
292
380
|
// 防重复入队:同一目标已在排队/执行中则忽略
|
|
293
381
|
if (queueRef.current.some((q) => q.kind === 'install' && q.target === repo)) return
|
|
294
382
|
// 请求在途防重:任务要等响应回来才进本地队列,这之前再次点击(双击)直接忽略
|
|
295
383
|
if (submittingRef.current.has(repo)) return
|
|
296
384
|
submittingRef.current.add(repo)
|
|
385
|
+
// 乐观入队:点击安装立即显示「正在安装中」,不等 POST 响应。
|
|
386
|
+
// 服务端 /install 会先跑预检(preflight,可能耗时数秒)再入队,若等响应才入队列,
|
|
387
|
+
// 用户点完安装立刻去通知中心会看不到任务 —— 只有刷新后 restore 才拉回来。
|
|
388
|
+
// 用负临时 id 占位,响应回来换成真实 task id;失败/出错则移除该占位。
|
|
389
|
+
tempIdRef.current -= 1
|
|
390
|
+
const tempId = tempIdRef.current
|
|
391
|
+
modalTaskRef.current = tempId
|
|
392
|
+
applyQueue((prev) => [...prev, {
|
|
393
|
+
id: tempId, kind: 'install', target: repo, repo, desc: p.description,
|
|
394
|
+
version: p.version, updatedAt: p.dates?.repoUpdatedAt,
|
|
395
|
+
status: 'running', progress: 0, lines: [], optimistic: true,
|
|
396
|
+
command: installCommandOf(p, true),
|
|
397
|
+
// 服务端登记后 /active 会带回真实尝试记录(npm 反查 + 执行命令),先占位空列表
|
|
398
|
+
attempts: [], needsRestart: true,
|
|
399
|
+
}])
|
|
297
400
|
setSubmitting(true)
|
|
298
401
|
try {
|
|
299
402
|
const res = await fetch('/dsh-plugin-hub/install', {
|
|
300
403
|
method: 'POST',
|
|
301
404
|
headers: { 'content-type': 'application/json' },
|
|
302
|
-
body: JSON.stringify({ repo, mode: opts?.update ? 'update' : undefined }),
|
|
405
|
+
body: JSON.stringify({ repo: target, display: repo, mode: opts?.update ? 'update' : undefined }),
|
|
303
406
|
})
|
|
304
|
-
const data = await res.json() as { ok?: boolean; task?: number; error?: string }
|
|
407
|
+
const data = await res.json() as { ok?: boolean; task?: number; error?: string; attempts?: string[] }
|
|
305
408
|
if (!data.ok || typeof data.task !== 'number') {
|
|
306
|
-
// 请求层失败(重复安装 409 /
|
|
307
|
-
|
|
409
|
+
// 请求层失败(重复安装 409 / 参数错误等):移除乐观条目 + 完整错误弹窗
|
|
410
|
+
applyQueue((prev) => prev.filter((x) => x.id !== tempId))
|
|
411
|
+
// 同步 400(如预检拦截)服务端会附上已尝试的安装方式,issue 预填一并展示
|
|
412
|
+
onError(data.error ?? `HTTP ${res.status}`, repo, 'install', installCommandOf(p, true), data.attempts)
|
|
308
413
|
return
|
|
309
414
|
}
|
|
310
415
|
const taskId = data.task
|
|
311
416
|
modalTaskRef.current = taskId
|
|
312
|
-
|
|
417
|
+
// 用真实任务 id 替换乐观占位,解除 optimistic 标记(此后归正常收尾/轮询处理)
|
|
418
|
+
applyQueue((prev) => prev.map((x) => (x.id === tempId
|
|
419
|
+
? { ...x, id: taskId, optimistic: undefined }
|
|
420
|
+
: x)))
|
|
313
421
|
// 提前缓存展示信息:安装成功后服务端登记待重启,轮询合并时不用等目录解析
|
|
314
422
|
pendingInfoRef.current.set(repo, { desc: p.description, version: p.version })
|
|
315
423
|
// 摘要条常驻顶部(含实时进度),明细面板保持折叠,想看时再点开
|
|
316
424
|
pollQueue()
|
|
317
425
|
} catch {
|
|
318
|
-
|
|
426
|
+
// 网络异常:移除乐观条目 + 兜底错误弹窗
|
|
427
|
+
applyQueue((prev) => prev.filter((x) => x.id !== tempId))
|
|
428
|
+
onError(t('installFail'), repo, 'install', installCommandOf(p, true))
|
|
319
429
|
} finally {
|
|
320
430
|
submittingRef.current.delete(repo)
|
|
321
431
|
setSubmitting(false)
|
|
@@ -345,7 +455,7 @@ export function useTaskQueue(opts: TaskQueueOptions) {
|
|
|
345
455
|
}
|
|
346
456
|
const taskId = data.task
|
|
347
457
|
modalTaskRef.current = taskId
|
|
348
|
-
applyQueue((prev) => [...prev, { id: taskId, kind: 'uninstall', target: name, desc: p.description, repo, status: 'pending', progress: 0, lines: [] }])
|
|
458
|
+
applyQueue((prev) => [...prev, { id: taskId, kind: 'uninstall', target: name, desc: p.description, repo, status: 'pending', progress: 0, lines: [], needsRestart: true }])
|
|
349
459
|
// 摘要条常驻顶部(含实时进度),明细面板保持折叠,想看时再点开
|
|
350
460
|
pollQueue()
|
|
351
461
|
} catch {
|