dsh-plugin 0.1.1 → 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.
- package/README.en.md +56 -28
- package/README.md +56 -28
- package/client/client.js +2282 -230
- package/client/client.js.map +1 -1
- package/lib/http/routes.js +432 -0
- package/lib/index.js +17 -8
- package/lib/services/install-types.js +11 -0
- package/lib/services/install.js +4 -0
- package/lib/services/installed-versions.js +66 -0
- package/lib/services/loader.js +98 -0
- package/lib/services/npm-resolve.js +67 -0
- package/lib/services/pending-restart.js +34 -0
- package/lib/services/preflight.js +94 -0
- package/lib/services/profile.js +88 -0
- package/lib/services/progress.js +50 -0
- package/lib/services/task-queue.js +457 -0
- package/lib/types/http/routes.d.ts +23 -0
- package/lib/types/index.d.ts +27 -0
- package/lib/types/services/install-types.d.ts +69 -0
- package/lib/types/services/install.d.ts +18 -0
- package/lib/types/services/installed-versions.d.ts +24 -0
- package/lib/types/services/loader.d.ts +43 -0
- package/lib/types/services/npm-resolve.d.ts +6 -0
- package/lib/types/services/pending-restart.d.ts +13 -0
- package/lib/types/services/preflight.d.ts +6 -0
- package/lib/types/services/profile.d.ts +24 -0
- package/lib/types/services/progress.d.ts +17 -0
- package/lib/types/services/task-queue.d.ts +82 -0
- package/package.json +27 -6
- package/src/client/components/CatalogControls.tsx +104 -0
- package/src/client/components/CatalogHeader.tsx +78 -0
- package/src/client/components/CatalogList.tsx +75 -0
- package/src/client/components/CategoryTabs.tsx +35 -0
- package/src/client/components/Dropdown.tsx +80 -0
- package/src/client/components/NotificationsModal.tsx +243 -0
- package/src/client/components/PluginCard.tsx +104 -0
- package/src/client/components/PluginHubSection.tsx +342 -0
- package/src/client/components/ProgressView.tsx +23 -0
- package/src/client/components/icons.tsx +125 -0
- package/src/client/components/modals.tsx +368 -0
- package/src/client/hooks/useCatalog.ts +202 -0
- package/src/client/hooks/useTaskQueue.ts +502 -0
- package/src/client/index.tsx +8 -422
- package/src/client/lib/catalog.ts +240 -0
- package/src/client/lib/env.ts +16 -0
- package/src/client/lib/failures.ts +165 -0
- package/src/client/lib/format.ts +22 -0
- package/src/client/locales.ts +188 -18
- package/src/client/styles/Dropdown.module.css +123 -0
- package/src/client/styles/Header.module.css +432 -0
- package/src/client/styles/List.module.css +324 -0
- package/src/client/styles/Modal.module.css +1006 -0
- package/src/client/styles/tokens.module.css +91 -0
- package/src/client/types.ts +80 -0
- package/src/server/http/routes.ts +432 -0
- package/src/server/index.ts +35 -0
- package/src/server/services/install-types.ts +69 -0
- package/src/server/services/install.ts +18 -0
- package/src/server/services/installed-versions.ts +84 -0
- package/src/server/services/loader.ts +110 -0
- package/src/server/services/npm-resolve.ts +67 -0
- package/src/server/services/pending-restart.ts +36 -0
- package/src/server/services/preflight.ts +97 -0
- package/src/server/services/profile.ts +89 -0
- package/src/server/services/progress.ts +48 -0
- package/src/server/services/task-queue.ts +492 -0
- package/src/client/Section.module.css +0 -589
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification-center dialog: a persistent log of every settled install /
|
|
3
|
+
* remove task — successes and failures alike.
|
|
4
|
+
*
|
|
5
|
+
* Records are written to localStorage at settle time (see lib/failures.ts),
|
|
6
|
+
* so a result is never lost even when the dialog was dismissed or the user
|
|
7
|
+
* was away. Each entry carries a circular status badge (green check for
|
|
8
|
+
* success, red cross for failure) with white glyph and message text;
|
|
9
|
+
* failures keep their copy / fix / file-an-issue actions. Opened from the
|
|
10
|
+
* header entry button.
|
|
11
|
+
*/
|
|
12
|
+
import { createElement as h } from 'react'
|
|
13
|
+
import type { MouseEvent } from 'react'
|
|
14
|
+
import styles from '../styles/Modal.module.css'
|
|
15
|
+
import type { EnvInfo, Translate } from '../types.ts'
|
|
16
|
+
import type { NotificationRecord } from '../lib/failures.ts'
|
|
17
|
+
import { classifyFailure } from '../lib/failures.ts'
|
|
18
|
+
import type { PendingRestart, QueueTask } from '../hooks/useTaskQueue.ts'
|
|
19
|
+
import { pluginIssueUrl, pluginSiteUrl } from '../lib/catalog.ts'
|
|
20
|
+
import { CloseIcon } from './icons.tsx'
|
|
21
|
+
|
|
22
|
+
/** 记录时间完整展示:YYYY-MM-DD HH:mm:ss(每条通知都带精确到秒的时间戳)。 */
|
|
23
|
+
function fmtTime(at: number): string {
|
|
24
|
+
const d = new Date(at)
|
|
25
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
26
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 圆形状态徽标内的白色图形:成功为对勾、失败为叉。 */
|
|
30
|
+
function BadgeGlyph({ ok }: { ok: boolean }) {
|
|
31
|
+
return h('svg', {
|
|
32
|
+
className: styles.noticeBadgeIcon,
|
|
33
|
+
viewBox: '0 0 16 16',
|
|
34
|
+
width: 12,
|
|
35
|
+
height: 12,
|
|
36
|
+
fill: 'none',
|
|
37
|
+
'aria-hidden': 'true',
|
|
38
|
+
}, ok
|
|
39
|
+
? h('path', {
|
|
40
|
+
d: 'M3 8.5l3.5 3.5 6.5-6.5',
|
|
41
|
+
stroke: '#ffffff',
|
|
42
|
+
strokeWidth: 2,
|
|
43
|
+
strokeLinecap: 'round',
|
|
44
|
+
strokeLinejoin: 'round',
|
|
45
|
+
})
|
|
46
|
+
: h('path', {
|
|
47
|
+
d: 'M4 4l8 8M12 4L4 12',
|
|
48
|
+
stroke: '#ffffff',
|
|
49
|
+
strokeWidth: 2,
|
|
50
|
+
strokeLinecap: 'round',
|
|
51
|
+
}))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function NotificationsModal({ records, tasks, pendingRestarts, t, env, onClose, onCopy, onClear, onRemove, cancelTask, restarting, onRestart }: {
|
|
55
|
+
records: NotificationRecord[]
|
|
56
|
+
/** 进行中的安装/卸载任务(实时进度,与队列弹窗同一数据源) */
|
|
57
|
+
tasks: QueueTask[]
|
|
58
|
+
/** 待重启插件:装完没重启的常驻提醒 */
|
|
59
|
+
pendingRestarts: PendingRestart[]
|
|
60
|
+
t: Translate
|
|
61
|
+
env: EnvInfo | null
|
|
62
|
+
onClose: () => void
|
|
63
|
+
onCopy: (text: string) => void
|
|
64
|
+
onClear: () => void
|
|
65
|
+
/** 删除单条通知记录 */
|
|
66
|
+
onRemove: (id: number) => void
|
|
67
|
+
cancelTask: (id: number) => void
|
|
68
|
+
restarting: boolean
|
|
69
|
+
onRestart: () => void
|
|
70
|
+
}) {
|
|
71
|
+
return h('div', {
|
|
72
|
+
className: styles.overlay,
|
|
73
|
+
onClick: (e: MouseEvent<HTMLDivElement>) => {
|
|
74
|
+
if (e.target === e.currentTarget) onClose()
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
h('div', { className: styles.errorModal, role: 'dialog', 'aria-modal': 'true' },
|
|
78
|
+
h('div', { className: styles.modalHead },
|
|
79
|
+
h('div', { className: styles.modalTitle }, t('notifications')),
|
|
80
|
+
h('button', {
|
|
81
|
+
className: styles.modalClose,
|
|
82
|
+
'aria-label': t('errorClose'),
|
|
83
|
+
onClick: onClose,
|
|
84
|
+
}, h(CloseIcon)),
|
|
85
|
+
),
|
|
86
|
+
h('div', { className: styles.modalBody },
|
|
87
|
+
h('div', { className: styles.errorHint }, t('notificationsDesc')),
|
|
88
|
+
// 进行中的安装/卸载任务实时进度也进通知中心:与队列弹窗同一套行结构,
|
|
89
|
+
// 关掉任务弹窗后仍可在这里盯着进度;待重启插件可在此直接触发重启
|
|
90
|
+
(tasks.length > 0 || pendingRestarts.length > 0)
|
|
91
|
+
? h('div', null, [
|
|
92
|
+
pendingRestarts.length > 0
|
|
93
|
+
? h('div', { className: styles.queueSection },
|
|
94
|
+
h('div', { className: styles.queueSectionTitle }, t('sectionPendingRestart')),
|
|
95
|
+
pendingRestarts.map((p) => h('div', { key: `restart-${p.target}`, className: styles.queueRow },
|
|
96
|
+
h('div', { className: styles.queueRowHead },
|
|
97
|
+
h('span', {
|
|
98
|
+
className: `${styles.failKind} ${p.kind === 'uninstall' ? styles.failKindUninstall : styles.failKindInstall}`,
|
|
99
|
+
}, p.kind === 'uninstall' ? t('uninstall') : t('install')),
|
|
100
|
+
h('span', { className: styles.queueRowTarget, title: p.target }, p.target),
|
|
101
|
+
),
|
|
102
|
+
p.desc ? h('div', { className: styles.queueRowDesc, title: p.desc }, p.desc) : null,
|
|
103
|
+
h('div', { className: styles.queueRowBody },
|
|
104
|
+
h('span', { className: styles.pendingRowStatus },
|
|
105
|
+
p.kind === 'uninstall' ? t('restartPendingHintUninstall') : t('restartPendingHint')),
|
|
106
|
+
h('span', { className: styles.pendingRowActions },
|
|
107
|
+
h('button', {
|
|
108
|
+
className: styles.restartLater,
|
|
109
|
+
disabled: restarting,
|
|
110
|
+
onClick: onClose,
|
|
111
|
+
}, t('restartLater')),
|
|
112
|
+
h('button', {
|
|
113
|
+
className: styles.restartNow,
|
|
114
|
+
disabled: restarting,
|
|
115
|
+
onClick: onRestart,
|
|
116
|
+
}, restarting ? t('restarting') : t('restartNow')),
|
|
117
|
+
),
|
|
118
|
+
),
|
|
119
|
+
)),
|
|
120
|
+
)
|
|
121
|
+
: null,
|
|
122
|
+
tasks.length > 0
|
|
123
|
+
? h('div', { className: styles.queueSection },
|
|
124
|
+
h('div', { className: styles.queueSectionTitle }, t('sectionInProgress')),
|
|
125
|
+
tasks.map((q) => h('div', { key: q.id, className: styles.queueRow },
|
|
126
|
+
h('div', { className: styles.queueRowHead },
|
|
127
|
+
h('span', {
|
|
128
|
+
className: `${styles.failKind} ${q.kind === 'install' ? styles.failKindInstall : styles.failKindUninstall}`,
|
|
129
|
+
}, q.kind === 'install' ? t('install') : t('uninstall')),
|
|
130
|
+
h('span', { className: styles.queueRowTarget, title: q.target }, q.target),
|
|
131
|
+
h('button', {
|
|
132
|
+
className: styles.stripCancel,
|
|
133
|
+
disabled: q.status === 'cancelling',
|
|
134
|
+
onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); cancelTask(q.id) },
|
|
135
|
+
}, t('cancelTask')),
|
|
136
|
+
),
|
|
137
|
+
q.desc ? h('div', { className: styles.queueRowDesc, title: q.desc }, q.desc) : null,
|
|
138
|
+
h('div', { className: styles.queueRowBody },
|
|
139
|
+
h('span', { className: styles.queueRowStatus },
|
|
140
|
+
q.status === 'running'
|
|
141
|
+
? (q.kind === 'install' ? t('installing') : t('uninstalling'))
|
|
142
|
+
: q.status === 'cancelling'
|
|
143
|
+
? t('cancelling')
|
|
144
|
+
: (q.kind === 'install' ? t('queuedTitle') : t('queuedUninstallTitle'))),
|
|
145
|
+
h('div', { className: `${styles.progressTrack} ${styles.queueRowTrack}` },
|
|
146
|
+
h('div', { className: styles.progressFill, style: { width: `${q.progress}%` } }),
|
|
147
|
+
),
|
|
148
|
+
h('span', { className: styles.queueRowPct }, `${q.progress}%`),
|
|
149
|
+
),
|
|
150
|
+
)),
|
|
151
|
+
)
|
|
152
|
+
: null,
|
|
153
|
+
])
|
|
154
|
+
: null,
|
|
155
|
+
records.length === 0
|
|
156
|
+
? h('div', { className: styles.failEmpty }, t('notificationsEmpty'))
|
|
157
|
+
: h('div', { className: styles.noticeList },
|
|
158
|
+
records.map((r) => {
|
|
159
|
+
return h('div', { key: r.id, className: styles.noticeRow },
|
|
160
|
+
h('div', { className: styles.noticeRowMain },
|
|
161
|
+
h('div', {
|
|
162
|
+
className: r.ok ? styles.noticeBadgeOk : styles.noticeBadgeFail,
|
|
163
|
+
}, h(BadgeGlyph, { ok: r.ok })),
|
|
164
|
+
h('div', { className: styles.noticeMain },
|
|
165
|
+
h('div', { className: styles.noticeHead },
|
|
166
|
+
h('span', {
|
|
167
|
+
className: r.ok ? styles.noticeTextOk : styles.noticeTextFail,
|
|
168
|
+
}, r.ok
|
|
169
|
+
? (r.kind === 'install' ? t('installDone') : t('uninstallDone'))
|
|
170
|
+
: (r.kind === 'install' ? t('errorTitleInstall') : t('errorTitleUninstall'))),
|
|
171
|
+
r.repo
|
|
172
|
+
? h('a', {
|
|
173
|
+
className: styles.failRepo,
|
|
174
|
+
// 跳转到官网详情页(含插件收录信息),不在通知弹窗直接跳 GitHub
|
|
175
|
+
href: pluginSiteUrl(r.repo),
|
|
176
|
+
target: '_blank',
|
|
177
|
+
rel: 'noopener noreferrer',
|
|
178
|
+
title: r.repo,
|
|
179
|
+
}, r.repo)
|
|
180
|
+
: null,
|
|
181
|
+
!r.ok && h('button', {
|
|
182
|
+
className: styles.failCopy,
|
|
183
|
+
onClick: () => onCopy(r.message),
|
|
184
|
+
}, t('failCopy')),
|
|
185
|
+
// 每条通知右侧的删除按钮:单独移除这一条
|
|
186
|
+
h('button', {
|
|
187
|
+
className: styles.noticeRemove,
|
|
188
|
+
'aria-label': t('removeNotification'),
|
|
189
|
+
title: t('removeNotification'),
|
|
190
|
+
onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onRemove(r.id) },
|
|
191
|
+
}, h(CloseIcon)),
|
|
192
|
+
),
|
|
193
|
+
// 每条失败记录是一个独立卡片:头部(状态/仓库/时间/复制完整日志)→ 修复或提 Issue 动作。
|
|
194
|
+
// 卡片不展示错误日志预览,完整日志靠「复制完整日志」按钮带走,避免每条卡片被日志撑高
|
|
195
|
+
!r.ok && (() => {
|
|
196
|
+
const kind = classifyFailure(r.message)
|
|
197
|
+
if (kind === 'pluginPrepare' || kind === 'pnpmIgnoredBuild') {
|
|
198
|
+
// [packaging](预检/装后校验拦截:git 分发缺产物)与
|
|
199
|
+
// prepare 构建脚本实际执行失败 / 原生依赖构建被 pnpm 拦截:都是插件打包分发问题 —— 先说明原因,按钮在下方提交
|
|
200
|
+
return h('div', null, [
|
|
201
|
+
h('div', { className: styles.failPrepareHint }, kind === 'pnpmIgnoredBuild'
|
|
202
|
+
? t('failIgnoredBuild')
|
|
203
|
+
: /\[packaging\]/i.test(r.message) ? t('failPackagingHint') : t('failPrepareHint')),
|
|
204
|
+
r.repo ? h('a', {
|
|
205
|
+
className: styles.failBigIssue,
|
|
206
|
+
href: pluginIssueUrl(r.repo, r.message, env, r.command, r.attempts),
|
|
207
|
+
target: '_blank',
|
|
208
|
+
rel: 'noopener noreferrer',
|
|
209
|
+
title: t('failIssueHint'),
|
|
210
|
+
}, t('failIssueBig')) : null,
|
|
211
|
+
])
|
|
212
|
+
}
|
|
213
|
+
return r.repo ? h('a', {
|
|
214
|
+
className: styles.failBigIssue,
|
|
215
|
+
href: pluginIssueUrl(r.repo, r.message, env, r.command, r.attempts),
|
|
216
|
+
target: '_blank',
|
|
217
|
+
rel: 'noopener noreferrer',
|
|
218
|
+
title: t('failIssueHint'),
|
|
219
|
+
}, t('failIssueBig')) : null
|
|
220
|
+
})(),
|
|
221
|
+
),
|
|
222
|
+
),
|
|
223
|
+
// 每条通知右下角:完整年月日时分秒时间戳(独立一行,不参与徽标垂直居中)
|
|
224
|
+
h('div', { className: styles.noticeFoot },
|
|
225
|
+
h('span', {
|
|
226
|
+
className: styles.noticeTime,
|
|
227
|
+
title: new Date(r.at).toLocaleString(),
|
|
228
|
+
}, fmtTime(r.at)),
|
|
229
|
+
),
|
|
230
|
+
)
|
|
231
|
+
}),
|
|
232
|
+
),
|
|
233
|
+
h('div', { className: styles.modalActions },
|
|
234
|
+
records.length > 0 ? h('button', {
|
|
235
|
+
className: styles.failClear,
|
|
236
|
+
onClick: onClear,
|
|
237
|
+
}, t('notificationsClear')) : null,
|
|
238
|
+
h('button', { className: styles.restartNow, onClick: onClose }, t('errorClose')),
|
|
239
|
+
),
|
|
240
|
+
),
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One plugin card in the catalog list: name/category/verified badge,
|
|
3
|
+
* description + topics, star/fork/date stats, and the detail/install/
|
|
4
|
+
* uninstall actions.
|
|
5
|
+
*/
|
|
6
|
+
import { createElement as h } from 'react'
|
|
7
|
+
import styles from '../styles/List.module.css'
|
|
8
|
+
import type { HubPlugin, LocaleId, Translate } from '../types.ts'
|
|
9
|
+
import { CATEGORY_LABELS, categoryLabel, pluginDetailUrl } from '../lib/catalog.ts'
|
|
10
|
+
import { fmtStars, relTime } from '../lib/format.ts'
|
|
11
|
+
|
|
12
|
+
export function PluginCard({ plugin: p, copied, installedName, installedVersion, hasUpdate, t, langKey, langPath, onInstall, onUninstall }: {
|
|
13
|
+
plugin: HubPlugin
|
|
14
|
+
copied: string | null
|
|
15
|
+
installedName: (p: HubPlugin) => string | null
|
|
16
|
+
/** 已安装版本号(安装时从目录信号记录;无 release 的仓库为空,无版本号可展示) */
|
|
17
|
+
installedVersion: (p: HubPlugin) => string | null
|
|
18
|
+
/** 已安装且目录有更新(有版本比版本、无版本比仓库更新时间) */
|
|
19
|
+
hasUpdate: (p: HubPlugin) => boolean
|
|
20
|
+
t: Translate
|
|
21
|
+
langKey: LocaleId
|
|
22
|
+
langPath: string
|
|
23
|
+
/** 第二个参数标记「更新」(已安装目标的覆盖安装):按钮/弹窗据此走更新语义 */
|
|
24
|
+
onInstall: (p: HubPlugin, opts?: { update?: boolean }) => void
|
|
25
|
+
onUninstall: (p: HubPlugin) => void
|
|
26
|
+
}) {
|
|
27
|
+
const repo = p.source?.repo ?? ''
|
|
28
|
+
const isCopied = copied === repo
|
|
29
|
+
const pkg = installedName(p)
|
|
30
|
+
const isInstalled = pkg !== null
|
|
31
|
+
const update = hasUpdate(p)
|
|
32
|
+
// 版本号展示:已安装 → 当前已装版本(最能反映用户状态);未安装 → 目录最新版本
|
|
33
|
+
const versionShown = isInstalled ? installedVersion(p) : p.version
|
|
34
|
+
|
|
35
|
+
return h('div', { className: styles.card },
|
|
36
|
+
h('div', { className: styles.cardMain },
|
|
37
|
+
h('div', { className: styles.cardHead },
|
|
38
|
+
h('div', { className: styles.cardTitle, title: p.description ?? '' }, p.displayName ?? p.slug),
|
|
39
|
+
// 版本号紧跟插件名称:有版本才显示(无 release 的仓库不展示版本号)
|
|
40
|
+
versionShown ? h('span', { className: styles.versionBadge, title: t('version') }, versionShown) : null,
|
|
41
|
+
// 已安装且有更新:醒目提示(有 release 比版本号、无 release 比仓库最近更新时间)
|
|
42
|
+
update ? h('span', { className: styles.updateBadge, title: t('updateAvailableHint') }, t('updateAvailable')) : null,
|
|
43
|
+
p.category ? h('span', { className: styles.categoryBadge }, categoryLabel(CATEGORY_LABELS, p.category, langKey)) : null,
|
|
44
|
+
p.compatibility?.status === 'verified'
|
|
45
|
+
? h('span', { className: styles.verified }, t('verified'))
|
|
46
|
+
: null,
|
|
47
|
+
),
|
|
48
|
+
// 描述始终显示:站点英文数据缺翻译时 description 回退成中文也照常展示,
|
|
49
|
+
// 描述是了解插件的第一入口,比隐藏更实用(不再为求语言一致而砍内容)。
|
|
50
|
+
p.description ? h('p', { className: styles.desc }, p.description) : null,
|
|
51
|
+
(p.topics?.length ?? 0) > 0
|
|
52
|
+
? h('div', { className: styles.topics },
|
|
53
|
+
p.topics!.slice(0, 3).map((topic) => h('span', { key: topic, className: styles.topic }, topic)),
|
|
54
|
+
)
|
|
55
|
+
: null,
|
|
56
|
+
),
|
|
57
|
+
h('div', { className: styles.cardSide },
|
|
58
|
+
h('div', { className: styles.stats },
|
|
59
|
+
h('span', { className: styles.star }, '\u2605 ', fmtStars(p.stats?.stargazers_count)),
|
|
60
|
+
h('span', { className: styles.fork }, t('fork'), ' ', fmtStars(p.stats?.forks_count)),
|
|
61
|
+
h('span', { className: styles.date }, relTime(p.dates?.repoUpdatedAt, t)),
|
|
62
|
+
),
|
|
63
|
+
repo
|
|
64
|
+
? h('div', { className: styles.actions },
|
|
65
|
+
// 查看详情:未安装/已安装都始终显示,跳转官网详情页
|
|
66
|
+
h('a', {
|
|
67
|
+
className: styles.detailBtn,
|
|
68
|
+
// 两级路径:/plugins/{ownerSlug}/{slug};旧数据缺 ownerSlug 时从 repo 推导
|
|
69
|
+
href: pluginDetailUrl(p, langPath),
|
|
70
|
+
target: '_blank',
|
|
71
|
+
rel: 'noopener noreferrer',
|
|
72
|
+
title: p.slug,
|
|
73
|
+
}, t('detail')),
|
|
74
|
+
isInstalled
|
|
75
|
+
// 已安装且有更新:按钮变「可更新」,点击直接覆盖安装(安装完成自动记录新版本)
|
|
76
|
+
? (update
|
|
77
|
+
? h('button', {
|
|
78
|
+
className: styles.installBtnUpdate,
|
|
79
|
+
title: t('updateAvailableHint'),
|
|
80
|
+
onClick: () => onInstall(p, { update: true }),
|
|
81
|
+
}, t('update'))
|
|
82
|
+
: h('button', {
|
|
83
|
+
className: styles.installBtnInstalled,
|
|
84
|
+
disabled: true,
|
|
85
|
+
title: t('installed'),
|
|
86
|
+
}, t('installed')))
|
|
87
|
+
: h('button', {
|
|
88
|
+
className: isCopied ? styles.installBtnCopied : styles.installBtn,
|
|
89
|
+
// 文字恒定避免按钮宽度变化导致卡片跳动;点击先弹信任确认,
|
|
90
|
+
// 弹窗内可选择复制命令或直接安装
|
|
91
|
+
onClick: () => onInstall(p),
|
|
92
|
+
}, t('install')),
|
|
93
|
+
isInstalled
|
|
94
|
+
// 已安装:卸载按钮(危险色),保留明确状态语义
|
|
95
|
+
? h('button', {
|
|
96
|
+
className: styles.uninstallBtn,
|
|
97
|
+
onClick: () => onUninstall(p),
|
|
98
|
+
}, t('uninstall'))
|
|
99
|
+
: null,
|
|
100
|
+
)
|
|
101
|
+
: null,
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
}
|