dsh-plugin 1.0.0 → 1.1.0

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.
@@ -10,7 +10,7 @@ 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
  /** 完成结果视图:绿色对勾 + 标题/描述 + 「稍后重启 / 立即重启」按钮对(部分插件需重启后才会挂载) */
@@ -42,7 +42,7 @@ function ResultView({
42
42
  })),
43
43
  ),
44
44
  h('div', { className: styles.resultTitle }, title),
45
- h('div', { className: styles.resultDesc }, desc),
45
+ desc ? h('div', { className: styles.resultDesc }, desc) : null,
46
46
  h('div', { className: styles.resultRestarting }, restarting ? t('restarting') : t('restartHint')),
47
47
  h('div', { className: styles.modalActions },
48
48
  h('button', { className: styles.restartLater, onClick: onClose, disabled: restarting }, t('restartLater')),
@@ -142,7 +142,7 @@ export function InstallModal(props: InstallModalProps) {
142
142
  role: 'button',
143
143
  tabIndex: 0,
144
144
  },
145
- h('span', { className: styles.modalCmdText }, `dsh plugin add github:${plugin.source?.repo ?? ''}`),
145
+ h('span', { className: styles.modalCmdText }, installCommandOf(plugin)),
146
146
  h('span', { className: styles.modalCmdCopy }, h(CopyIcon), t('copyCmdLabel')),
147
147
  ),
148
148
  task && task.status === 'pending' ? h('div', { className: styles.queuedHint }, t('queuedHint')) : null,
@@ -270,16 +270,19 @@ export function UninstallModal(props: UninstallModalProps) {
270
270
 
271
271
  /** 预填插件仓库的 GitHub Issue 链接:标题带插件名,正文附完整错误信息,方便用户一键反馈。 */
272
272
  /** 安装/卸载失败弹窗:布局与失败记录一致(类型徽标 + 仓库超链接 + 隐蔽复制按钮),报错完整展示,底部一键提交 Issue。 */
273
- export function ErrorModal({ message, repo, kind, t, env, onCopy, onClose }: {
273
+ export function ErrorModal({ message, repo, kind, command, attempts, t, env, onCopy, onClose }: {
274
274
  message: string
275
275
  repo: string | null
276
276
  kind: 'install' | 'uninstall'
277
+ command?: string
278
+ /** 尝试过的安装方式(npm 反查 + 实际执行命令):issue 预填一并贴给作者,便于反推正确的 npm 包名 */
279
+ attempts?: string[]
277
280
  t: Translate
278
281
  env: EnvInfo | null
279
282
  onCopy: (text: string) => void
280
283
  onClose: () => void
281
284
  }) {
282
- // 失败归类:用官方默认安装方式装不上 = 插件仓库的问题,一律引导提 Issue
285
+ // 失败归类:当前安装通道(npm/git)装不上 = 插件分发/依赖的问题,一律引导提 Issue
283
286
  const failureKind = classifyFailure(message)
284
287
  return h('div', {
285
288
  className: styles.overlay,
@@ -320,14 +323,14 @@ export function ErrorModal({ message, repo, kind, t, env, onCopy, onClose }: {
320
323
  // 插件问题(prepare 构建失败 / 原生依赖构建被拦截 / git 分发缺产物):都是插件打包分发问题 —— 先说明原因,按钮在下方引导提 Issue
321
324
  failureKind === 'pluginPrepare' || failureKind === 'pnpmIgnoredBuild'
322
325
  ? h('div', null, [
323
- // [packaging](预检/装后校验拦截:git 分发缺产物,不支持官方默认安装方式)与
326
+ // [packaging](预检/装后校验拦截:git 分发缺产物)与
324
327
  // prepare 构建脚本实际执行失败 / 原生依赖构建被 pnpm 拦截:都是插件打包分发问题 —— 先说明原因,按钮在下方提交
325
328
  h('div', { className: styles.failPrepareHint }, failureKind === 'pnpmIgnoredBuild'
326
329
  ? t('failIgnoredBuild')
327
330
  : /\[packaging\]/i.test(message) ? t('failPackagingHint') : t('failPrepareHint')),
328
331
  repo ? h('a', {
329
332
  className: styles.failBigIssue,
330
- href: pluginIssueUrl(repo, message, env),
333
+ href: pluginIssueUrl(repo, message, env, command, attempts),
331
334
  target: '_blank',
332
335
  rel: 'noopener noreferrer',
333
336
  title: t('failIssueHint'),
@@ -335,7 +338,7 @@ export function ErrorModal({ message, repo, kind, t, env, onCopy, onClose }: {
335
338
  ])
336
339
  : repo ? h('a', {
337
340
  className: styles.failBigIssue,
338
- href: pluginIssueUrl(repo, message, env),
341
+ href: pluginIssueUrl(repo, message, env, command, attempts),
339
342
  target: '_blank',
340
343
  rel: 'noopener noreferrer',
341
344
  title: t('failIssueHint'),
@@ -27,8 +27,9 @@ export function useCatalog(lang: LocaleId) {
27
27
  const [installedFilter, setInstalledFilter] = useState<'all' | 'installed' | 'notInstalled'>('all')
28
28
  /** 当前 profile 已安装插件:npm 包名 -> manifest spec(来自宿主本地路由) */
29
29
  const [installed, setInstalled] = useState<Record<string, string>>({})
30
- /** 安装时记录的目录信号:repo(小写) -> { version, updatedAt }(来自宿主本地路由) */
31
- const [versions, setVersions] = useState<Record<string, { version: string; updatedAt: string }>>({})
30
+ /** 安装时记录的目录信号:repo(小写) -> { version, updatedAt }(来自宿主本地路由);
31
+ * npmPackage 为 npm 优先通道反查命中的包名映射(目录数据未下发时客户端靠它把依赖 key 匹配回仓库) */
32
+ const [versions, setVersions] = useState<Record<string, { version: string; updatedAt: string; npmPackage?: string }>>({})
32
33
 
33
34
  // 拉取目录 + 统计。在线 API 已只返回 verified;再过滤一次,保证只展示人工验证通过的插件。
34
35
  useEffect(() => {
@@ -64,7 +65,7 @@ export function useCatalog(lang: LocaleId) {
64
65
  try {
65
66
  const res = await fetch('/dsh-plugin-hub/installed', { cache: 'no-store' })
66
67
  if (!res.ok) return
67
- const data = await res.json() as { installed?: Record<string, string>; versions?: Record<string, { version: string; updatedAt: string }> }
68
+ const data = await res.json() as { installed?: Record<string, string>; versions?: Record<string, { version: string; updatedAt: string; npmPackage?: string }> }
68
69
  setInstalled(data.installed ?? {})
69
70
  setVersions(data.versions ?? {})
70
71
  } catch {
@@ -75,7 +76,7 @@ export function useCatalog(lang: LocaleId) {
75
76
  // 首次进入拉取已安装表(依赖宿主 webServer 服务)
76
77
  useEffect(() => { refreshInstalled() }, [])
77
78
 
78
- /** 插件是否已安装:匹配 installed spec 中的 `github:<owner>/<repo>`;命中返回 npm 包名。 */
79
+ /** 插件是否已安装:匹配 installed spec 中的 `github:<owner>/<repo>`,或 npm 通道安装的依赖包名;命中返回 npm 包名。 */
79
80
  const installedName = (p: HubPlugin): string | null => {
80
81
  const repo = p.source?.repo
81
82
  if (!repo) return null
@@ -83,6 +84,15 @@ export function useCatalog(lang: LocaleId) {
83
84
  for (const [name, spec] of Object.entries(installed)) {
84
85
  if (spec.toLowerCase().includes(needle)) return name
85
86
  }
87
+ // npm 通道安装:profile 依赖 key 直接是 npm 包名。包名来源两处——目录数据(npmPackage),
88
+ // 或服务端 npm 优先反查持久化的映射(versions[repo].npmPackage,覆盖组织 scope 与 GitHub
89
+ // 用户名不一致、目录未下发包名的场景);命中任一并依赖 key 真实存在即视为已安装
90
+ const pkg = ((p.source?.npmPackage || versions[repo.toLowerCase()]?.npmPackage) ?? '').toLowerCase()
91
+ if (pkg) {
92
+ for (const name of Object.keys(installed)) {
93
+ if (name.toLowerCase() === pkg) return name
94
+ }
95
+ }
86
96
  return null
87
97
  }
88
98
 
@@ -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,13 @@ 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[]
28
36
  }
29
37
 
30
38
  /** 客户端待重启项:镜像服务端内存列表 + 本地补齐的展示信息(简介/版本)。 */
@@ -44,8 +52,8 @@ export interface TaskQueueOptions {
44
52
  /** 任务成功完成:viaModal 表示任务对应当前打开弹窗(弹窗切结果视图),否则走 Toast;repo 供成功通知记录。 */
45
53
  onInstallDone: (viaModal: boolean, repo: string | null) => void
46
54
  onUninstallDone: (viaModal: boolean, repo: string | null) => void
47
- /** 任务失败:完整输出或兜底文案 + 所属插件仓库 + 操作类型(安装/卸载)。 */
48
- onError: (message: string, repo: string | null, kind: 'install' | 'uninstall') => void
55
+ /** 任务失败:完整输出或兜底文案 + 所属插件仓库 + 操作类型(安装/卸载)+ 实际执行的安装命令(issue 预填用,可缺省)+ 尝试过的安装方式(npm 反查/执行命令,可缺省)。 */
56
+ onError: (message: string, repo: string | null, kind: 'install' | 'uninstall', command?: string, attempts?: string[]) => void
49
57
  /** 当前打开的安装/卸载弹窗插件:用于在弹窗内匹配进行中的任务。 */
50
58
  installPlugin: HubPlugin | null
51
59
  uninstallPlugin: HubPlugin | null
@@ -71,11 +79,15 @@ export function useTaskQueue(opts: TaskQueueOptions) {
71
79
  resolvePendingRef.current = resolvePending
72
80
  /** 当前打开弹窗所对应的任务 id:该任务完成时弹窗切换为结果视图 */
73
81
  const modalTaskRef = useRef<number | null>(null)
82
+ /** 已开始收尾但尚未结束的任务 id(服务端 /active 已消失 → 查 /status 中):并发轮询时跳过,避免重复收尾 */
83
+ const settlingRef = useRef<Set<number>>(new Set())
74
84
  /** 轮询定时器句柄(队列清空/组件卸载时清理) */
75
85
  const pollRef = useRef<number | null>(null)
76
86
  /** 请求在途的目标集合(同步防重):双击安装/卸载时第二击直接忽略。
77
87
  * 本地队列任务要等 fetch 返回后才入队,仅靠 queueRef 检查拦不住请求窗口内的重复点击。 */
78
88
  const submittingRef = useRef<Set<string>>(new Set())
89
+ /** 乐观入队临时 id 计数器:递减产生唯一负数,与服务端自增正数 id 永不冲突。 */
90
+ const tempIdRef = useRef(0)
79
91
  /** 请求在途标记:弹窗据此禁用确认按钮,避免等待响应期间被再次点击。 */
80
92
  const [submitting, setSubmitting] = useState(false)
81
93
 
@@ -169,25 +181,60 @@ export function useTaskQueue(opts: TaskQueueOptions) {
169
181
  const detail = lines.length > 0
170
182
  ? [...lines].reverse().join('\n')
171
183
  : q.kind === 'uninstall' ? t('uninstallFail') : t('installFail')
172
- onError(detail, q.repo, q.kind)
184
+ onError(detail, q.repo, q.kind, q.command, q.attempts)
173
185
  }
174
186
  maybeStopPoll()
175
187
  }
176
188
 
189
+ /** 卸载成功收尾前的进度过渡:任务已在服务端结束,但保留本地展示,
190
+ * 让进度条在 ~2.4s 内缓缓跑到 100% 再切结果/Toast,给用户一个交互过程,
191
+ * 而不是命令一执行进度条就一闪消失。 */
192
+ const settleDone = (q: QueueTask, lines: string[]) => {
193
+ // 收尾动画期间停止轮询:任务已从服务端 /active 消失,无需再查;
194
+ // 停掉可避免 pollQueue 的 gone 检测把它当「再消失一次」而重复收尾
195
+ stopPoll()
196
+ const from = Math.min(q.progress, 90) // 起点最多 90,给进度条留出跑满的缓冲空间
197
+ const steps = 20
198
+ const stepMs = 120 // 20 步 × 120ms = 2.4s
199
+ let step = 0
200
+ // 动画期间固定显示「卸载中」:任务实已结束,但进度在跑,与「排队中」文案更协调
201
+ applyQueue((prev) => prev.map((x) => (x.id === q.id ? { ...x, status: 'running' as const } : x)))
202
+ const timer = window.setInterval(() => {
203
+ step += 1
204
+ const progress = Math.round(from + (100 - from) * (step / steps))
205
+ applyQueue((prev) => prev.map((x) => (x.id === q.id ? { ...x, progress } : x)))
206
+ if (step >= steps) {
207
+ window.clearInterval(timer)
208
+ finishQueueTask(true, q, lines)
209
+ // 队列若还有其它已在排队/执行的任务,恢复轮询继续盯;已清空则自然停住
210
+ if (queueRef.current.length > 0) pollQueue()
211
+ }
212
+ }, stepMs)
213
+ }
214
+
177
215
  /** 任务在 /active 中消失 = 已结束:查 /status 拿终态并收尾(cancelled 静默移除)。 */
178
216
  const settleTask = async (q: QueueTask) => {
179
217
  let status = 'failed'
180
218
  let lines: string[] = []
219
+ let attempts: string[] | undefined
181
220
  try {
182
221
  const res = await fetch(`/dsh-plugin-hub/status?task=${q.id}`, { cache: 'no-store' })
183
222
  if (res.ok) {
184
- const data = await res.json() as { task?: { status?: string; lines?: string[] } }
223
+ const data = await res.json() as { task?: { status?: string; lines?: string[]; attempts?: string[] } }
185
224
  status = data.task?.status ?? 'failed'
186
225
  lines = data.task?.lines ?? []
226
+ attempts = Array.isArray(data.task?.attempts) ? data.task.attempts : undefined
187
227
  }
188
228
  } catch { /* 服务端不可达:按失败处理 */ }
189
- if (status === 'done') finishQueueTask(true, q, lines)
190
- else if (status === 'failed') finishQueueTask(false, q, lines)
229
+ if (status === 'done') {
230
+ // 卸载成功走进度过渡动画;安装成功直接收尾
231
+ if (q.kind === 'uninstall') settleDone(q, lines)
232
+ else finishQueueTask(true, q, lines)
233
+ }
234
+ else if (status === 'failed') {
235
+ // 服务端终态带出的尝试记录覆盖本地:以实际执行为准
236
+ finishQueueTask(false, { ...q, attempts }, lines)
237
+ }
191
238
  else {
192
239
  applyQueue((prev) => prev.filter((x) => x.id !== q.id)) // cancelled / 未知
193
240
  maybeStopPoll()
@@ -203,7 +250,7 @@ export function useTaskQueue(opts: TaskQueueOptions) {
203
250
  const res = await fetch('/dsh-plugin-hub/active', { cache: 'no-store' })
204
251
  if (!res.ok) throw new Error(`active ${res.status}`)
205
252
  const data = await res.json() as {
206
- tasks?: { id?: unknown; action?: unknown; target?: unknown; status?: unknown; progress?: unknown; lines?: unknown }[]
253
+ tasks?: { id?: unknown; action?: unknown; target?: unknown; status?: unknown; progress?: unknown; lines?: unknown; attempts?: unknown }[]
207
254
  pendingRestarts?: unknown
208
255
  }
209
256
  const active = (data.tasks ?? []).filter((a) => typeof a.id === 'number')
@@ -213,32 +260,50 @@ export function useTaskQueue(opts: TaskQueueOptions) {
213
260
  // 待重启列表同步(服务端内存态:宿主重启后自然变空,这里随之清空 UI)
214
261
  applyPending(parsePendingRestarts(data.pendingRestarts))
215
262
  // 合并:running 在前、pending 在后(与服务端一致),新出现任务补入队列
216
- applyQueue((prev) => {
217
- const next: QueueTask[] = []
218
- for (const a of active) {
219
- const id = a.id as number
220
- const prevTask = prev.find((q) => q.id === id)
221
- const isRemove = a.action === 'remove'
222
- next.push({
223
- id,
224
- kind: isRemove ? 'uninstall' : 'install',
225
- target: typeof a.target === 'string' ? a.target.replace(/^github:/, '') : (prevTask?.target ?? ''),
226
- desc: prevTask?.desc,
227
- repo: prevTask?.repo ?? (isRemove ? null : (typeof a.target === 'string' ? a.target.replace(/^github:/, '') : null)),
228
- // 安装时快照的目录信号:轮询合并时保留,任务成功后记录到本地
229
- version: prevTask?.version,
230
- updatedAt: prevTask?.updatedAt,
231
- status: a.status === 'running' ? 'running' : 'pending',
232
- progress: typeof a.progress === 'number' ? a.progress : prevTask?.progress ?? 0,
233
- lines: Array.isArray(a.lines) ? (a.lines as string[]) : (prevTask?.lines ?? []),
234
- })
235
- }
236
- return next
237
- })
238
- // 本地有、服务端已消失 → 已结束:逐个查终态收尾
239
- // (cancelling 是客户端乐观过渡态,交给 cancelTask 的定时移除,不在轮询里提前收敛)
240
- const gone = prevQueue.filter((q) => !byId.has(q.id) && q.status !== 'cancelling')
241
- for (const q of gone) await settleTask(q)
263
+ applyQueue((prev) => {
264
+ const next: QueueTask[] = []
265
+ for (const a of active) {
266
+ const id = a.id as number
267
+ const prevTask = prev.find((q) => q.id === id)
268
+ const isRemove = a.action === 'remove'
269
+ next.push({
270
+ id,
271
+ kind: isRemove ? 'uninstall' : 'install',
272
+ target: typeof a.target === 'string' ? a.target.replace(/^github:/, '') : (prevTask?.target ?? ''),
273
+ desc: prevTask?.desc,
274
+ repo: prevTask?.repo ?? (isRemove ? null : (typeof a.target === 'string' ? a.target.replace(/^github:/, '') : null)),
275
+ // 安装时快照的目录信号:轮询合并时保留,任务成功后记录到本地
276
+ version: prevTask?.version,
277
+ updatedAt: prevTask?.updatedAt,
278
+ status: a.status === 'running' ? 'running' : 'pending',
279
+ progress: typeof a.progress === 'number' ? a.progress : prevTask?.progress ?? 0,
280
+ lines: Array.isArray(a.lines) ? (a.lines as string[]) : (prevTask?.lines ?? []),
281
+ attempts: Array.isArray(a.attempts) ? (a.attempts as string[]) : (prevTask?.attempts ?? []),
282
+ })
283
+ }
284
+ // 保留乐观条目:服务端尚未登记(preflight 中),无真实 id,不能因 active 缺它就被合并吞掉
285
+ const keepOpt = prev.filter((q) => q.optimistic && !next.some((x) => x.id === q.id))
286
+ if (keepOpt.length > 0) next.push(...keepOpt)
287
+ // 保留「已从服务端消失、但尚未收尾」的任务:收尾(settleTask)负责移除。
288
+ // 若在这里提前摘除,卸载/安装完成的那一瞬队列会先变空,弹窗闪回「确认」态
289
+ // (busy=false 且未 done),表现为「卸载中 → 卸载 → 卸载完成」的中间回退。
290
+ const keepGone = prev.filter((q) =>
291
+ !byId.has(q.id) && !next.some((x) => x.id === q.id) && q.status !== 'cancelling' && !q.optimistic)
292
+ if (keepGone.length > 0) next.push(...keepGone)
293
+ return next
294
+ })
295
+ // 本地有、服务端已消失 → 已结束:逐个查终态收尾
296
+ // (cancelling 是客户端乐观过渡态,交给 cancelTask 的定时移除,不在轮询里提前收敛;
297
+ // optimistic 是点击后尚未登记的占位,服务端 /status 查不到,跳过免误判成失败;
298
+ // settling 是上一轮已开始收尾的任务,并发轮询直接跳过,避免重复收尾)
299
+ const gone = prevQueue.filter((q) =>
300
+ !byId.has(q.id) && q.status !== 'cancelling' && !q.optimistic && !settlingRef.current.has(q.id))
301
+ for (const q of gone) {
302
+ // 防御:同 id 任务可能已被上一轮收尾移除(极端时序下轮询合并出的重复条目),跳过避免重复收尾
303
+ if (!queueRef.current.some((x) => x.id === q.id)) continue
304
+ settlingRef.current.add(q.id)
305
+ try { await settleTask(q) } finally { settlingRef.current.delete(q.id) }
306
+ }
242
307
  } catch {
243
308
  // 服务端暂不可达:静默等待下一轮
244
309
  }
@@ -254,7 +319,7 @@ export function useTaskQueue(opts: TaskQueueOptions) {
254
319
  const res = await fetch('/dsh-plugin-hub/active', { cache: 'no-store' })
255
320
  if (!res.ok) return
256
321
  const data = await res.json() as {
257
- tasks?: { id?: unknown; action?: unknown; target?: unknown; status?: unknown; progress?: unknown; lines?: unknown }[]
322
+ tasks?: { id?: unknown; action?: unknown; target?: unknown; status?: unknown; progress?: unknown; lines?: unknown; attempts?: unknown }[]
258
323
  pendingRestarts?: unknown
259
324
  }
260
325
  if (cancelled) return
@@ -264,14 +329,20 @@ export function useTaskQueue(opts: TaskQueueOptions) {
264
329
  .filter((a) => typeof a.id === 'number')
265
330
  .map((a) => {
266
331
  const isRemove = a.action === 'remove'
332
+ // 服务端任务的 target:github:repo(git 安装)/ npm 包名(npm 安装/卸载)。
333
+ // 恢复展示统一用 displayTarget(owner/repo,服务端固定传):npm 安装时 target 是包名,
334
+ // 直接展示会让用户看到「安装方式」的内部细节,违背无感知原则;卸载保持包名(匹配已装表)。
335
+ const display = typeof (a as { displayTarget?: unknown }).displayTarget === 'string' ? (a as { displayTarget: string }).displayTarget.replace(/^github:/, '') : ''
336
+ const fallback = typeof a.target === 'string' ? a.target.replace(/^github:/, '') : ''
267
337
  return {
268
338
  id: a.id as number,
269
339
  kind: (isRemove ? 'uninstall' : 'install') as 'install' | 'uninstall',
270
- target: typeof a.target === 'string' ? a.target.replace(/^github:/, '') : '',
271
- repo: isRemove ? null : (typeof a.target === 'string' ? a.target.replace(/^github:/, '') : null),
340
+ target: isRemove ? fallback : (display || fallback),
341
+ repo: isRemove ? null : (display || fallback),
272
342
  status: a.status === 'running' ? 'running' : 'pending',
273
343
  progress: typeof a.progress === 'number' ? a.progress : 0,
274
344
  lines: Array.isArray(a.lines) ? (a.lines as string[]) : [],
345
+ attempts: Array.isArray((a as { attempts?: unknown }).attempts) ? ((a as { attempts: string[] }).attempts) : [],
275
346
  }
276
347
  })
277
348
  if (items.length > 0 || pendingRef.current.length > 0) {
@@ -289,33 +360,59 @@ export function useTaskQueue(opts: TaskQueueOptions) {
289
360
  const installNow = async (p: HubPlugin, opts?: { update?: boolean }) => {
290
361
  const repo = p.source?.repo ?? ''
291
362
  if (!repo) return
363
+ // 安装通道决策(用户无感知):有 npm 包名 → 走 npm;否则 git 直装。
364
+ // 队列条目一律用仓库名展示/防重,只有发给后端的实际安装目标随通道变化。
365
+ const { target } = installTargetOf(p)
366
+ if (!target) return
292
367
  // 防重复入队:同一目标已在排队/执行中则忽略
293
368
  if (queueRef.current.some((q) => q.kind === 'install' && q.target === repo)) return
294
369
  // 请求在途防重:任务要等响应回来才进本地队列,这之前再次点击(双击)直接忽略
295
370
  if (submittingRef.current.has(repo)) return
296
371
  submittingRef.current.add(repo)
372
+ // 乐观入队:点击安装立即显示「正在安装中」,不等 POST 响应。
373
+ // 服务端 /install 会先跑预检(preflight,可能耗时数秒)再入队,若等响应才入队列,
374
+ // 用户点完安装立刻去通知中心会看不到任务 —— 只有刷新后 restore 才拉回来。
375
+ // 用负临时 id 占位,响应回来换成真实 task id;失败/出错则移除该占位。
376
+ tempIdRef.current -= 1
377
+ const tempId = tempIdRef.current
378
+ modalTaskRef.current = tempId
379
+ applyQueue((prev) => [...prev, {
380
+ id: tempId, kind: 'install', target: repo, repo, desc: p.description,
381
+ version: p.version, updatedAt: p.dates?.repoUpdatedAt,
382
+ status: 'running', progress: 0, lines: [], optimistic: true,
383
+ command: installCommandOf(p, true),
384
+ // 服务端登记后 /active 会带回真实尝试记录(npm 反查 + 执行命令),先占位空列表
385
+ attempts: [],
386
+ }])
297
387
  setSubmitting(true)
298
388
  try {
299
389
  const res = await fetch('/dsh-plugin-hub/install', {
300
390
  method: 'POST',
301
391
  headers: { 'content-type': 'application/json' },
302
- body: JSON.stringify({ repo, mode: opts?.update ? 'update' : undefined }),
392
+ body: JSON.stringify({ repo: target, display: repo, mode: opts?.update ? 'update' : undefined }),
303
393
  })
304
- const data = await res.json() as { ok?: boolean; task?: number; error?: string }
394
+ const data = await res.json() as { ok?: boolean; task?: number; error?: string; attempts?: string[] }
305
395
  if (!data.ok || typeof data.task !== 'number') {
306
- // 请求层失败(重复安装 409 / 参数错误等):完整错误弹窗
307
- onError(data.error ?? `HTTP ${res.status}`, repo, 'install')
396
+ // 请求层失败(重复安装 409 / 参数错误等):移除乐观条目 + 完整错误弹窗
397
+ applyQueue((prev) => prev.filter((x) => x.id !== tempId))
398
+ // 同步 400(如预检拦截)服务端会附上已尝试的安装方式,issue 预填一并展示
399
+ onError(data.error ?? `HTTP ${res.status}`, repo, 'install', installCommandOf(p, true), data.attempts)
308
400
  return
309
401
  }
310
402
  const taskId = data.task
311
403
  modalTaskRef.current = taskId
312
- applyQueue((prev) => [...prev, { id: taskId, kind: 'install', target: repo, repo, desc: p.description, version: p.version, updatedAt: p.dates?.repoUpdatedAt, status: 'pending', progress: 0, lines: [] }])
404
+ // 用真实任务 id 替换乐观占位,解除 optimistic 标记(此后归正常收尾/轮询处理)
405
+ applyQueue((prev) => prev.map((x) => (x.id === tempId
406
+ ? { ...x, id: taskId, optimistic: undefined }
407
+ : x)))
313
408
  // 提前缓存展示信息:安装成功后服务端登记待重启,轮询合并时不用等目录解析
314
409
  pendingInfoRef.current.set(repo, { desc: p.description, version: p.version })
315
410
  // 摘要条常驻顶部(含实时进度),明细面板保持折叠,想看时再点开
316
411
  pollQueue()
317
412
  } catch {
318
- onError(t('installFail'), repo, 'install')
413
+ // 网络异常:移除乐观条目 + 兜底错误弹窗
414
+ applyQueue((prev) => prev.filter((x) => x.id !== tempId))
415
+ onError(t('installFail'), repo, 'install', installCommandOf(p, true))
319
416
  } finally {
320
417
  submittingRef.current.delete(repo)
321
418
  setSubmitting(false)
@@ -46,19 +46,21 @@ export function pluginSiteUrl(repo: string): string {
46
46
 
47
47
  /**
48
48
  * 一键反馈 GitHub Issue 的预填链接:标题带「来自 dsh-plugin.org」标识,正文只带
49
- * 核心信息 —— 原因判定 + 关键错误代码 + 宿主机器环境快照 + 错误核心摘要(完整日志
50
- * 太长,塞进 URL 会被 GitHub 以「request URL too long」拒绝,故只收集重点)。
51
- * 错误弹窗、失败记录共用此逻辑。
49
+ * 核心信息 —— 原因判定 + 关键错误代码 + 尝试过的安装方式 + 宿主机器环境快照 +
50
+ * 错误核心摘要(完整日志太长,塞进 URL 会被 GitHub 以「request URL too long」拒绝,
51
+ * 故只收集重点)。错误弹窗、失败记录共用此逻辑。
52
52
  * env 取不到时为 null,链接照常生成、只是少环境段。
53
+ * attempts(尝试过的安装方式,npm 反查 + 实际执行命令):作者看到我们查过/试过的命令,
54
+ * 组织 scope 与 GitHub 用户名不一致时也能直接指认正确的 npm 包名。
53
55
  */
54
- export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | null): string {
56
+ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | null, command?: string, attempts?: string[]): string {
55
57
  const title = `[dsh-plugin.org | dsh-plugin-hub] Install/Remove failed: ${repo}`
56
58
  // 原因判定:让作者一眼知道这是不是自己插件的问题。
57
- // [packaging](预检/装后校验拦截)单独列:点明「不支持官方默认安装方式」,并写清官方默认方式
58
- // 是什么(git 直装要求仓库提交构建产物或提供 prepare 脚本),作者照此适配即可。
59
+ // [packaging](预检/装后校验拦截)单独列:无论走 npm 还是 git 通道,都是分发物不完整
60
+ // (package.json 声明的入口文件在发布物里缺失),作者照此补齐即可。
59
61
  const kind = classifyFailure(message)
60
62
  const reason = /\[packaging\]/i.test(message)
61
- ? 'plugin does NOT support the official default install method (dsh plugin add github:owner/repo — requires build output committed to the repo or a prepare script); its git distribution lacks the entry file declared in package.json'
63
+ ? 'plugin distribution is incomplete — the entry file declared in package.json is missing from the published package (github tarball or npm package); please commit build output or publish a complete package'
62
64
  : kind === 'pluginPrepare'
63
65
  ? 'plugin prepare/build script failed during install (packaging/distribution issue)'
64
66
  : kind === 'pnpmIgnoredBuild'
@@ -72,17 +74,23 @@ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | nu
72
74
  `- Cause: ${reason}`,
73
75
  ...(code ? [`- Key error: \`${code}\``] : []),
74
76
  '',
75
- // 来源说明:标题(链官网)+ 一句来源(链仓库)+ 实际执行的官方安装命令(含 --profile)与执行结果,链接由常量动态拼接
77
+ // 来源说明:标题(链官网)+ 一句来源(链仓库)+ 实际执行的安装命令(含 --profile)与执行结果,链接由常量动态拼接
76
78
  `## [DSH-Plugin 插件中心](${SITE_URL}) · 安装 Plugin 失败错误信息`,
77
79
  `本错误信息由 [dsh-plugin-hub](${GITHUB_URL}) 插件中心的安装程序自动生成,随本次安装失败一并提交。`,
78
- `- 使用的安装命令(官方默认安装方式):\`dsh plugin${env?.profile ? ` --profile ${env.profile}` : ''} add github:${repo}\``,
80
+ `- 实际执行的安装命令:\`${command ?? `dsh plugin${env?.profile ? ` --profile ${env.profile}` : ''} add github:${repo}`}\``,
79
81
  `- 执行结果:安装失败,未能安装该插件。`,
80
- // [packaging] 场景:向作者说明官方默认安装方式具体是什么,请其按该方式适配(提交构建产物或提供 prepare 脚本)
82
+ // 尝试过的安装方式(npm 反查 + 实际执行命令,按先后顺序):作者据此反推正确的
83
+ // npm 包名 —— 组织 scope 与 GitHub 用户名不一致时仅凭仓库名猜不到,作者看到我们
84
+ // 查过/试过的命令就能直接指认
85
+ ...(attempts && attempts.length > 0
86
+ ? ['', '## Attempted install channels(已尝试的安装方式)', ...attempts.map((a) => `- ${a}`)]
87
+ : []),
88
+ // [packaging] 场景:说明两种官方安装通道(npm 包 / git 直装),当前插件在对应通道下分发不完整
81
89
  ...(/\[packaging\]/i.test(message)
82
90
  ? [
83
91
  '',
84
- '## 官方默认安装方式',
85
- 'DSH 生态的官方默认安装方式是 `dsh plugin add github:owner/repo`(git 直装):插件仓库需提交构建产物,或在 package.json 提供 `prepare` 脚本让 pnpm 安装时自动构建。当前插件两者皆不具备,因此无法按官方方式安装。',
92
+ '## 安装方式说明',
93
+ 'DSH 插件支持两种官方安装通道:`dsh plugin add <npm-package>`(npm 分发,需发布完整构建产物)与 `dsh plugin add github:owner/repo`(git 直装,仓库需提交构建产物或在 package.json 提供 `prepare` 脚本)。当前插件的分发物缺少 package.json 声明的入口文件,请按所用通道补齐后重新发布。',
86
94
  ]
87
95
  : []),
88
96
  '',
@@ -156,7 +164,15 @@ export function normalize(raw: Record<string, unknown>): HubPlugin {
156
164
  topics: Array.isArray(raw.t) ? (raw.t as string[]) : undefined,
157
165
  features: Array.isArray(raw.f) ? (raw.f as string[]) : undefined,
158
166
  description: typeof raw.d === 'string' ? raw.d : undefined,
159
- source: typeof raw.r === 'string' ? { repo: raw.r } : undefined,
167
+ // 投影输出两种形态:老数据 source 是 repo 字符串;新数据是 { repo, npmPackage }
168
+ source: typeof raw.r === 'string'
169
+ ? { repo: raw.r }
170
+ : raw.r !== null && typeof raw.r === 'object'
171
+ ? {
172
+ repo: typeof (raw.r as { repo?: unknown }).repo === 'string' ? (raw.r as { repo: string }).repo : undefined,
173
+ npmPackage: typeof (raw.r as { npmPackage?: unknown }).npmPackage === 'string' ? (raw.r as { npmPackage: string }).npmPackage : undefined,
174
+ }
175
+ : undefined,
160
176
  compatibility: typeof raw.v === 'string' ? { status: raw.v } : undefined,
161
177
  dates: {
162
178
  repoUpdatedAt: typeof raw.u === 'string' ? raw.u : undefined,
@@ -171,6 +187,26 @@ export function normalize(raw: Record<string, unknown>): HubPlugin {
171
187
  return raw as unknown as HubPlugin
172
188
  }
173
189
 
190
+ /**
191
+ * 安装通道决策(用户无感知):目录探测到 npm 包名 → 用 npm 包名安装
192
+ * (走 npm registry tarball,更快、与 GitHub 网络无关);无 npm 包名 → git 直装。
193
+ * 返回值 target 即传给后端 /install 的安装目标(npm 包名 或 owner/repo)。
194
+ */
195
+ export function installTargetOf(p: HubPlugin): { target: string; via: 'npm' | 'github' } {
196
+ const pkg = (p.source?.npmPackage ?? '').trim()
197
+ const repo = (p.source?.repo ?? '').trim()
198
+ if (pkg && repo) return { target: pkg, via: 'npm' }
199
+ return { target: repo, via: 'github' }
200
+ }
201
+
202
+ /** 展示用安装命令(复制/弹窗):npm 通道显示包名,git 通道显示 github: 源。 */
203
+ export function installCommandOf(p: HubPlugin, withProfile = false): string {
204
+ const { target, via } = installTargetOf(p)
205
+ return via === 'npm'
206
+ ? `dsh plugin${withProfile ? ' --profile web' : ''} add ${target}`
207
+ : `dsh plugin${withProfile ? ' --profile web' : ''} add github:${target}`
208
+ }
209
+
174
210
  export const CATEGORY_LABELS: Record<string, { zh: string; en: string }> = {
175
211
  interface: { zh: '界面与体验', en: 'UI & Experience' },
176
212
  session: { zh: '会话与消息', en: 'Sessions & Messages' },
@@ -16,6 +16,10 @@ export interface NotificationRecord {
16
16
  repo: string
17
17
  /** 失败时的完整错误日志;成功记录为空串 */
18
18
  message: string
19
+ /** 实际执行的安装/卸载命令(issue 预填时如实展示);历史记录可能缺失 */
20
+ command?: string
21
+ /** 尝试过的安装方式(npm registry 反查 + 实际执行命令,按先后顺序):失败提 Issue 时贴给作者,便于反推正确的 npm 包名 */
22
+ attempts?: string[]
19
23
  /** 结束时间(epoch ms) */
20
24
  at: number
21
25
  }
@@ -27,7 +31,8 @@ export type FailureKind = 'pnpmIgnoredBuild' | 'pluginPrepare' | 'repo'
27
31
 
28
32
  /**
29
33
  * 失败归类,三态。无论底层机制如何(pnpm 白名单拦截 / 构建脚本被忽略 / prepare 失败),
30
- * 对用户而言结果都一样 —— 用官方默认安装方式装不上,就是插件仓库的问题,一律引导提 Issue:
34
+ * 对用户而言结果都一样 —— 当前安装通道(npm 或 git)装不上,就是插件分发/依赖的问题,
35
+ * 一律引导提 Issue:
31
36
  * - pnpmIgnoredBuild:插件依赖里的原生模块构建脚本被 pnpm 默认拦截(如 node-pty,
32
37
  * `ERR_PNPM_IGNORED_BUILDS`)。只影响带原生模块的插件,其他插件不受影响 —— 差异在
33
38
  * 插件的依赖选择,属插件依赖/打包问题 → 引导去仓库提 Issue(建议改用预编译版本)
@@ -44,7 +49,7 @@ export function classifyFailure(message: string): FailureKind {
44
49
  if (/ERR_PNPM_IGNORED_BUILDS|Ignored build scripts:/i.test(message)) return 'pnpmIgnoredBuild'
45
50
  // 再判 prepare 实际执行失败:只有构建脚本真的跑挂了才是插件问题
46
51
  if (/ERR_PNPM_PREPARE_PACKAGE|ELIFECYCLE|Command failed|prepare-guard/i.test(message)) return 'pluginPrepare'
47
- // 其余失败(含 git prepare 被 pnpm 白名单拦截):官方方式装不上 = 插件仓库的问题,一律提 Issue
52
+ // 其余失败(含 git prepare 被 pnpm 白名单拦截):当前通道装不上 = 插件分发/依赖的问题,一律提 Issue
48
53
  return 'repo'
49
54
  }
50
55
 
@@ -151,3 +156,10 @@ export function clearNotifications(): NotificationRecord[] {
151
156
  save([])
152
157
  return []
153
158
  }
159
+
160
+ /** 按 id 删除单条通知记录,返回更新后的列表(仅移除该条,不影响其余记录)。 */
161
+ export function removeNotification(id: number): NotificationRecord[] {
162
+ const next = loadNotifications().filter((r) => r.id !== id)
163
+ save(next)
164
+ return next
165
+ }
@@ -48,8 +48,8 @@ export const zh = {
48
48
  installNow: '直接安装',
49
49
  installing: '安装中…',
50
50
  installDone: '安装成功',
51
- installResultTitle: '安装完成',
52
- installResultDesc: '插件已安装。',
51
+ installResultTitle: '插件安装成功',
52
+ installResultDesc: '',
53
53
  // 更新语义:已安装插件点「更新」走同一条 add 命令原位覆盖重装,弹窗文案区分安装/更新
54
54
  confirmUpdateTitle: '确认更新',
55
55
  updateNow: '直接更新',
@@ -91,6 +91,7 @@ export const zh = {
91
91
  notificationsDesc: '安装、卸载任务的成败都会记录在这里,即使错过提示也能随时回来查看。',
92
92
  notificationsEmpty: '暂无通知记录',
93
93
  notificationsClear: '清空通知',
94
+ removeNotification: '删除这条通知',
94
95
  failCopy: '复制完整错误信息',
95
96
  failIssueHint: '带着错误日志去作者仓库一键提交 Issue(正文含官网收录链接)',
96
97
  failIssueBig: '一键提交 BUG 到 GitHub Issue 为开源作贡献',
@@ -172,8 +173,8 @@ export const en = {
172
173
  installNow: 'Install',
173
174
  installing: 'Installing…',
174
175
  installDone: 'Installed',
175
- installResultTitle: 'Installed',
176
- installResultDesc: 'The plugin has been installed.',
176
+ installResultTitle: 'Plugin installed successfully',
177
+ installResultDesc: '',
177
178
  // Update semantics: the "update" action re-runs add in place — copy differentiates install vs update
178
179
  confirmUpdateTitle: 'Confirm update',
179
180
  updateNow: 'Update',
@@ -213,6 +214,7 @@ export const en = {
213
214
  notificationsDesc: 'Every install and remove result is logged here — even if you missed the prompt, you can always check back.',
214
215
  notificationsEmpty: 'No notifications yet',
215
216
  notificationsClear: 'Clear notifications',
217
+ removeNotification: 'Remove this notification',
216
218
  failCopy: 'Copy full log',
217
219
  failIssueHint: 'Open a pre-filled issue on the author repo with this error log (includes catalog links)',
218
220
  failIssueBig: 'Report this bug to GitHub — contribute to open source',
@@ -184,7 +184,7 @@
184
184
  user-select: none;
185
185
  }
186
186
 
187
- /* 可更新:琥珀警示色实底按钮(与 star / 有更新徽标同族),点击直接覆盖安装更新 */
187
+ /* 可更新:琥珀警示色实底按钮(与 star / 有更新徽标同族,与安装绿、卸载红区分),点击直接覆盖安装更新 */
188
188
  .installBtnUpdate {
189
189
  color: #fff;
190
190
  background: var(--hub-warn);
@@ -197,17 +197,17 @@
197
197
  background: var(--hub-warn-strong);
198
198
  }
199
199
 
200
- /* 卸载:危险色文字按钮,默认无底,悬浮转鲜红实底白字 */
200
+ /* 卸载:危险色按钮 —— 默认浅红底红字(危险但克制),悬浮转深红实底白字 */
201
201
  .uninstallBtn {
202
202
  color: var(--hub-danger);
203
- background: transparent;
203
+ background: var(--hub-danger-tint);
204
204
  border: none;
205
205
  user-select: none;
206
206
  }
207
207
 
208
208
  .uninstallBtn:hover {
209
209
  color: #ffffff;
210
- background: var(--hub-danger);
210
+ background: var(--hub-danger-hover);
211
211
  }
212
212
 
213
213
  .installBtn {