dsh-plugin 0.1.0 → 1.0.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 +52 -27
- package/README.md +52 -27
- package/client/client.js +2148 -229
- package/client/client.js.map +1 -1
- package/lib/http/routes.js +396 -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 +51 -0
- package/lib/services/loader.js +98 -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 +417 -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 +63 -0
- package/lib/types/services/install.d.ts +18 -0
- package/lib/types/services/installed-versions.d.ts +14 -0
- package/lib/types/services/loader.d.ts +43 -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 +76 -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 +230 -0
- package/src/client/components/PluginCard.tsx +104 -0
- package/src/client/components/PluginHubSection.tsx +339 -0
- package/src/client/components/ProgressView.tsx +23 -0
- package/src/client/components/icons.tsx +125 -0
- package/src/client/components/modals.tsx +365 -0
- package/src/client/hooks/useCatalog.ts +192 -0
- package/src/client/hooks/useTaskQueue.ts +405 -0
- package/src/client/index.tsx +8 -422
- package/src/client/lib/catalog.ts +204 -0
- package/src/client/lib/env.ts +16 -0
- package/src/client/lib/failures.ts +153 -0
- package/src/client/lib/format.ts +22 -0
- package/src/client/locales.ts +186 -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 +965 -0
- package/src/client/styles/tokens.module.css +86 -0
- package/src/client/types.ts +80 -0
- package/src/server/http/routes.ts +397 -0
- package/src/server/index.ts +35 -0
- package/src/server/services/install-types.ts +63 -0
- package/src/server/services/install.ts +18 -0
- package/src/server/services/installed-versions.ts +65 -0
- package/src/server/services/loader.ts +110 -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 +450 -0
- package/src/client/Section.module.css +0 -569
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom dropdown (native <select> replacement).
|
|
3
|
+
*
|
|
4
|
+
* macOS/浏览器原生下拉无法跟随宿主主题,这里自绘:触发器按钮 + 绝对定位面板,
|
|
5
|
+
* 点外部/Esc 关闭;选项 hover 加重底色、激活项深色实心高亮,全部走 --dsw-alias-* 主题变量。
|
|
6
|
+
*/
|
|
7
|
+
import { createElement as h, useEffect, useRef, useState } from 'react'
|
|
8
|
+
import type { JSX } from 'react'
|
|
9
|
+
import styles from '../styles/Dropdown.module.css'
|
|
10
|
+
import { ChevronDownIcon } from './icons.tsx'
|
|
11
|
+
|
|
12
|
+
export interface DropdownOption<T extends string> {
|
|
13
|
+
value: T
|
|
14
|
+
label: string
|
|
15
|
+
/** 可选计数徽标(如「已安装 12」) */
|
|
16
|
+
count?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface DropdownProps<T extends string> {
|
|
20
|
+
value: T
|
|
21
|
+
options: DropdownOption<T>[]
|
|
22
|
+
onChange: (value: T) => void
|
|
23
|
+
title?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function Dropdown<T extends string>({ value, options, onChange, title }: DropdownProps<T>): JSX.Element {
|
|
27
|
+
const [open, setOpen] = useState(false)
|
|
28
|
+
const rootRef = useRef<HTMLDivElement | null>(null)
|
|
29
|
+
|
|
30
|
+
// 面板展开时:点面板外部或按 Esc 关闭。
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!open) return
|
|
33
|
+
const onDoc = (e: MouseEvent) => {
|
|
34
|
+
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false)
|
|
35
|
+
}
|
|
36
|
+
const onKey = (e: KeyboardEvent) => {
|
|
37
|
+
if (e.key === 'Escape') setOpen(false)
|
|
38
|
+
}
|
|
39
|
+
document.addEventListener('mousedown', onDoc)
|
|
40
|
+
document.addEventListener('keydown', onKey)
|
|
41
|
+
return () => {
|
|
42
|
+
document.removeEventListener('mousedown', onDoc)
|
|
43
|
+
document.removeEventListener('keydown', onKey)
|
|
44
|
+
}
|
|
45
|
+
}, [open])
|
|
46
|
+
|
|
47
|
+
const current = options.find((o) => o.value === value)
|
|
48
|
+
|
|
49
|
+
return h('div', { className: styles.dropdown, ref: rootRef },
|
|
50
|
+
h('button', {
|
|
51
|
+
className: styles.dropdownBtn,
|
|
52
|
+
type: 'button',
|
|
53
|
+
title,
|
|
54
|
+
'aria-haspopup': 'listbox',
|
|
55
|
+
'aria-expanded': open,
|
|
56
|
+
onClick: () => setOpen((v) => !v),
|
|
57
|
+
},
|
|
58
|
+
h('span', { className: styles.dropdownLabel }, current?.label ?? value),
|
|
59
|
+
h('span', { className: open ? styles.dropdownArrowOpen : styles.dropdownArrow }, h(ChevronDownIcon)),
|
|
60
|
+
),
|
|
61
|
+
open && h('div', { className: styles.dropdownPanel, role: 'listbox' },
|
|
62
|
+
options.map((o) => h('button', {
|
|
63
|
+
key: o.value,
|
|
64
|
+
type: 'button',
|
|
65
|
+
role: 'option',
|
|
66
|
+
'aria-selected': o.value === value,
|
|
67
|
+
className: o.value === value ? styles.dropdownItemActive : styles.dropdownItem,
|
|
68
|
+
onClick: () => {
|
|
69
|
+
onChange(o.value)
|
|
70
|
+
setOpen(false)
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
h('span', { className: styles.dropdownItemLabel }, o.label),
|
|
74
|
+
typeof o.count === 'number'
|
|
75
|
+
? h('span', { className: o.value === value ? styles.dropdownCountActive : styles.dropdownCount }, o.count)
|
|
76
|
+
: null,
|
|
77
|
+
)),
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
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
|
+
/** 记录时间紧凑展示:今年内 MM-DD HH:mm,跨年补年份前缀。 */
|
|
23
|
+
function fmtTime(at: number): string {
|
|
24
|
+
const d = new Date(at)
|
|
25
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
26
|
+
const mmdd = `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
27
|
+
return d.getFullYear() === new Date().getFullYear() ? mmdd : `${d.getFullYear()}-${mmdd}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 圆形状态徽标内的白色图形:成功为对勾、失败为叉。 */
|
|
31
|
+
function BadgeGlyph({ ok }: { ok: boolean }) {
|
|
32
|
+
return h('svg', {
|
|
33
|
+
className: styles.noticeBadgeIcon,
|
|
34
|
+
viewBox: '0 0 16 16',
|
|
35
|
+
width: 12,
|
|
36
|
+
height: 12,
|
|
37
|
+
fill: 'none',
|
|
38
|
+
'aria-hidden': 'true',
|
|
39
|
+
}, ok
|
|
40
|
+
? h('path', {
|
|
41
|
+
d: 'M3 8.5l3.5 3.5 6.5-6.5',
|
|
42
|
+
stroke: '#ffffff',
|
|
43
|
+
strokeWidth: 2,
|
|
44
|
+
strokeLinecap: 'round',
|
|
45
|
+
strokeLinejoin: 'round',
|
|
46
|
+
})
|
|
47
|
+
: h('path', {
|
|
48
|
+
d: 'M4 4l8 8M12 4L4 12',
|
|
49
|
+
stroke: '#ffffff',
|
|
50
|
+
strokeWidth: 2,
|
|
51
|
+
strokeLinecap: 'round',
|
|
52
|
+
}))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function NotificationsModal({ records, tasks, pendingRestarts, t, env, onClose, onCopy, onClear, cancelTask, restarting, onRestart }: {
|
|
56
|
+
records: NotificationRecord[]
|
|
57
|
+
/** 进行中的安装/卸载任务(实时进度,与队列弹窗同一数据源) */
|
|
58
|
+
tasks: QueueTask[]
|
|
59
|
+
/** 待重启插件:装完没重启的常驻提醒 */
|
|
60
|
+
pendingRestarts: PendingRestart[]
|
|
61
|
+
t: Translate
|
|
62
|
+
env: EnvInfo | null
|
|
63
|
+
onClose: () => void
|
|
64
|
+
onCopy: (text: string) => void
|
|
65
|
+
onClear: () => void
|
|
66
|
+
cancelTask: (id: number) => void
|
|
67
|
+
restarting: boolean
|
|
68
|
+
onRestart: () => void
|
|
69
|
+
}) {
|
|
70
|
+
return h('div', {
|
|
71
|
+
className: styles.overlay,
|
|
72
|
+
onClick: (e: MouseEvent<HTMLDivElement>) => {
|
|
73
|
+
if (e.target === e.currentTarget) onClose()
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
h('div', { className: styles.errorModal, role: 'dialog', 'aria-modal': 'true' },
|
|
77
|
+
h('div', { className: styles.modalHead },
|
|
78
|
+
h('div', { className: styles.modalTitle }, t('notifications')),
|
|
79
|
+
h('button', {
|
|
80
|
+
className: styles.modalClose,
|
|
81
|
+
'aria-label': t('errorClose'),
|
|
82
|
+
onClick: onClose,
|
|
83
|
+
}, h(CloseIcon)),
|
|
84
|
+
),
|
|
85
|
+
h('div', { className: styles.modalBody },
|
|
86
|
+
h('div', { className: styles.errorHint }, t('notificationsDesc')),
|
|
87
|
+
// 进行中的安装/卸载任务实时进度也进通知中心:与队列弹窗同一套行结构,
|
|
88
|
+
// 关掉任务弹窗后仍可在这里盯着进度;待重启插件可在此直接触发重启
|
|
89
|
+
(tasks.length > 0 || pendingRestarts.length > 0)
|
|
90
|
+
? h('div', null, [
|
|
91
|
+
pendingRestarts.length > 0
|
|
92
|
+
? h('div', { className: styles.queueSection },
|
|
93
|
+
h('div', { className: styles.queueSectionTitle }, t('sectionPendingRestart')),
|
|
94
|
+
pendingRestarts.map((p) => h('div', { key: `restart-${p.target}`, className: styles.queueRow },
|
|
95
|
+
h('div', { className: styles.queueRowHead },
|
|
96
|
+
h('span', {
|
|
97
|
+
className: `${styles.failKind} ${p.kind === 'uninstall' ? styles.failKindUninstall : styles.failKindInstall}`,
|
|
98
|
+
}, p.kind === 'uninstall' ? t('uninstall') : t('install')),
|
|
99
|
+
h('span', { className: styles.queueRowTarget, title: p.target }, p.target),
|
|
100
|
+
),
|
|
101
|
+
p.desc ? h('div', { className: styles.queueRowDesc, title: p.desc }, p.desc) : null,
|
|
102
|
+
h('div', { className: styles.queueRowBody },
|
|
103
|
+
h('span', { className: styles.pendingRowStatus },
|
|
104
|
+
p.kind === 'uninstall' ? t('restartPendingHintUninstall') : t('restartPendingHint')),
|
|
105
|
+
h('span', { className: styles.pendingRowActions },
|
|
106
|
+
h('button', {
|
|
107
|
+
className: styles.restartLater,
|
|
108
|
+
disabled: restarting,
|
|
109
|
+
onClick: onClose,
|
|
110
|
+
}, t('restartLater')),
|
|
111
|
+
h('button', {
|
|
112
|
+
className: styles.restartNow,
|
|
113
|
+
disabled: restarting,
|
|
114
|
+
onClick: onRestart,
|
|
115
|
+
}, restarting ? t('restarting') : t('restartNow')),
|
|
116
|
+
),
|
|
117
|
+
),
|
|
118
|
+
)),
|
|
119
|
+
)
|
|
120
|
+
: null,
|
|
121
|
+
tasks.length > 0
|
|
122
|
+
? h('div', { className: styles.queueSection },
|
|
123
|
+
h('div', { className: styles.queueSectionTitle }, t('sectionInProgress')),
|
|
124
|
+
tasks.map((q) => h('div', { key: q.id, className: styles.queueRow },
|
|
125
|
+
h('div', { className: styles.queueRowHead },
|
|
126
|
+
h('span', {
|
|
127
|
+
className: `${styles.failKind} ${q.kind === 'install' ? styles.failKindInstall : styles.failKindUninstall}`,
|
|
128
|
+
}, q.kind === 'install' ? t('install') : t('uninstall')),
|
|
129
|
+
h('span', { className: styles.queueRowTarget, title: q.target }, q.target),
|
|
130
|
+
h('button', {
|
|
131
|
+
className: styles.stripCancel,
|
|
132
|
+
disabled: q.status === 'cancelling',
|
|
133
|
+
onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); cancelTask(q.id) },
|
|
134
|
+
}, t('cancelTask')),
|
|
135
|
+
),
|
|
136
|
+
q.desc ? h('div', { className: styles.queueRowDesc, title: q.desc }, q.desc) : null,
|
|
137
|
+
h('div', { className: styles.queueRowBody },
|
|
138
|
+
h('span', { className: styles.queueRowStatus },
|
|
139
|
+
q.status === 'running'
|
|
140
|
+
? (q.kind === 'install' ? t('installing') : t('uninstalling'))
|
|
141
|
+
: q.status === 'cancelling'
|
|
142
|
+
? t('cancelling')
|
|
143
|
+
: (q.kind === 'install' ? t('queuedTitle') : t('queuedUninstallTitle'))),
|
|
144
|
+
h('div', { className: `${styles.progressTrack} ${styles.queueRowTrack}` },
|
|
145
|
+
h('div', { className: styles.progressFill, style: { width: `${q.progress}%` } }),
|
|
146
|
+
),
|
|
147
|
+
h('span', { className: styles.queueRowPct }, `${q.progress}%`),
|
|
148
|
+
),
|
|
149
|
+
)),
|
|
150
|
+
)
|
|
151
|
+
: null,
|
|
152
|
+
])
|
|
153
|
+
: null,
|
|
154
|
+
records.length === 0
|
|
155
|
+
? h('div', { className: styles.failEmpty }, t('notificationsEmpty'))
|
|
156
|
+
: h('div', { className: styles.noticeList },
|
|
157
|
+
records.map((r) => {
|
|
158
|
+
return h('div', { key: r.id, className: styles.noticeRow },
|
|
159
|
+
h('div', {
|
|
160
|
+
className: r.ok ? styles.noticeBadgeOk : styles.noticeBadgeFail,
|
|
161
|
+
}, h(BadgeGlyph, { ok: r.ok })),
|
|
162
|
+
h('div', { className: styles.noticeMain },
|
|
163
|
+
h('div', { className: styles.noticeHead },
|
|
164
|
+
h('span', {
|
|
165
|
+
className: r.ok ? styles.noticeTextOk : styles.noticeTextFail,
|
|
166
|
+
}, r.ok
|
|
167
|
+
? (r.kind === 'install' ? t('installDone') : t('uninstallDone'))
|
|
168
|
+
: (r.kind === 'install' ? t('errorTitleInstall') : t('errorTitleUninstall'))),
|
|
169
|
+
r.repo
|
|
170
|
+
? h('a', {
|
|
171
|
+
className: styles.failRepo,
|
|
172
|
+
// 跳转到官网详情页(含插件收录信息),不在通知弹窗直接跳 GitHub
|
|
173
|
+
href: pluginSiteUrl(r.repo),
|
|
174
|
+
target: '_blank',
|
|
175
|
+
rel: 'noopener noreferrer',
|
|
176
|
+
title: r.repo,
|
|
177
|
+
}, r.repo)
|
|
178
|
+
: null,
|
|
179
|
+
h('span', {
|
|
180
|
+
className: styles.failTime,
|
|
181
|
+
title: new Date(r.at).toLocaleString(),
|
|
182
|
+
}, fmtTime(r.at)),
|
|
183
|
+
!r.ok && h('button', {
|
|
184
|
+
className: styles.failCopy,
|
|
185
|
+
onClick: () => onCopy(r.message),
|
|
186
|
+
}, t('failCopy')),
|
|
187
|
+
),
|
|
188
|
+
// 每条失败记录是一个独立卡片:头部(状态/仓库/时间/复制完整日志)→ 修复或提 Issue 动作。
|
|
189
|
+
// 卡片不展示错误日志预览,完整日志靠「复制完整日志」按钮带走,避免每条卡片被日志撑高
|
|
190
|
+
!r.ok && (() => {
|
|
191
|
+
const kind = classifyFailure(r.message)
|
|
192
|
+
if (kind === 'pluginPrepare' || kind === 'pnpmIgnoredBuild') {
|
|
193
|
+
// [packaging](预检/装后校验拦截:git 分发缺产物,不支持官方默认安装方式)与
|
|
194
|
+
// prepare 构建脚本实际执行失败 / 原生依赖构建被 pnpm 拦截:都是插件打包分发问题 —— 先说明原因,按钮在下方提交
|
|
195
|
+
return h('div', null, [
|
|
196
|
+
h('div', { className: styles.failPrepareHint }, kind === 'pnpmIgnoredBuild'
|
|
197
|
+
? t('failIgnoredBuild')
|
|
198
|
+
: /\[packaging\]/i.test(r.message) ? t('failPackagingHint') : t('failPrepareHint')),
|
|
199
|
+
r.repo ? h('a', {
|
|
200
|
+
className: styles.failBigIssue,
|
|
201
|
+
href: pluginIssueUrl(r.repo, r.message, env),
|
|
202
|
+
target: '_blank',
|
|
203
|
+
rel: 'noopener noreferrer',
|
|
204
|
+
title: t('failIssueHint'),
|
|
205
|
+
}, t('failIssueBig')) : null,
|
|
206
|
+
])
|
|
207
|
+
}
|
|
208
|
+
return r.repo ? h('a', {
|
|
209
|
+
className: styles.failBigIssue,
|
|
210
|
+
href: pluginIssueUrl(r.repo, r.message, env),
|
|
211
|
+
target: '_blank',
|
|
212
|
+
rel: 'noopener noreferrer',
|
|
213
|
+
title: t('failIssueHint'),
|
|
214
|
+
}, t('failIssueBig')) : null
|
|
215
|
+
})(),
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
}),
|
|
219
|
+
),
|
|
220
|
+
h('div', { className: styles.modalActions },
|
|
221
|
+
records.length > 0 ? h('button', {
|
|
222
|
+
className: styles.failClear,
|
|
223
|
+
onClick: onClear,
|
|
224
|
+
}, t('notificationsClear')) : null,
|
|
225
|
+
h('button', { className: styles.restartNow, onClick: onClose }, t('errorClose')),
|
|
226
|
+
),
|
|
227
|
+
),
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
}
|
|
@@ -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
|
+
}
|