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
package/src/client/index.tsx
CHANGED
|
@@ -1,435 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-plugin client:
|
|
3
|
-
* the
|
|
4
|
-
*
|
|
5
|
-
* install-command copy. Plugin data is fetched live from dsh-plugin.org,
|
|
6
|
-
* so the browser bundle stays small and always in sync with the site.
|
|
7
|
-
*
|
|
8
|
-
* Built by tsdown into the __ModuleLoader__ factory bundle at client/client.js.
|
|
2
|
+
* dsh-plugin client entry: wires the Plugin Hub into the host settings via
|
|
3
|
+
* the module-loader bundle (see tsdown.config.ts). All UI logic lives in
|
|
4
|
+
* PluginHubSection; this file only performs the cordis apply wiring.
|
|
9
5
|
*/
|
|
10
|
-
import { createElement as h
|
|
11
|
-
import
|
|
6
|
+
import { createElement as h } from 'react'
|
|
7
|
+
import type { HubClientContext } from './types.ts'
|
|
12
8
|
import { en, zh } from './locales.ts'
|
|
9
|
+
import { PluginHubSection } from './components/PluginHubSection.tsx'
|
|
10
|
+
import './styles/tokens.module.css'
|
|
13
11
|
|
|
14
12
|
const NS = 'dsh-plugin'
|
|
15
|
-
const SITE_URL = 'https://dsh-plugin.org/'
|
|
16
|
-
|
|
17
|
-
type LocaleId = 'zh' | 'en'
|
|
18
|
-
|
|
19
|
-
/** The subset of the locale service this plugin touches. */
|
|
20
|
-
interface LocaleService {
|
|
21
|
-
register(namespace: string, dicts: { zh: Record<string, string>; en: Record<string, string> }): unknown
|
|
22
|
-
bind(namespace: string): (key: string, params?: Record<string, string | number>) => string
|
|
23
|
-
getSnapshot(): { active: LocaleId }
|
|
24
|
-
subscribe(fn: () => void): () => void
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** The subset of the slots service this plugin touches. */
|
|
28
|
-
interface SlotsService {
|
|
29
|
-
inject(slot: string, register: () => unknown): void
|
|
30
|
-
register(meta: Record<string, unknown>, component: () => unknown): unknown
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** The client cordis context shape this plugin relies on (structural). */
|
|
34
|
-
interface HubClientContext {
|
|
35
|
-
effect(callback: () => unknown, label?: string): void
|
|
36
|
-
slots: SlotsService
|
|
37
|
-
locale: LocaleService
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
interface SectionProps {
|
|
41
|
-
t: (key: string, params?: Record<string, string | number>) => string
|
|
42
|
-
locale: LocaleService
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Slim projection of the site's DirectoryPlugin entries. */
|
|
46
|
-
interface HubPlugin {
|
|
47
|
-
/** 作者小写 key,与 slug 共同构成唯一身份(两级路径 /plugins/{ownerSlug}/{slug});旧数据可能缺省 */
|
|
48
|
-
ownerSlug?: string
|
|
49
|
-
slug: string
|
|
50
|
-
displayName?: string
|
|
51
|
-
category?: string
|
|
52
|
-
topics?: string[]
|
|
53
|
-
features?: string[]
|
|
54
|
-
description?: string
|
|
55
|
-
source?: { repo?: string }
|
|
56
|
-
compatibility?: { status?: string }
|
|
57
|
-
dates?: { repoUpdatedAt?: string; addedAt?: string }
|
|
58
|
-
stats?: { stargazers_count?: number; forks_count?: number }
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const CATEGORY_ORDER = [
|
|
62
|
-
'interface', 'session', 'memory', 'tools', 'agent', 'workflow',
|
|
63
|
-
'integration', 'model', 'dev', 'knowledge', 'fun',
|
|
64
|
-
]
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* 归一化在线 API(dsh-plugin.org/api/plugins.{lang}.json)返回的短 key 结构
|
|
68
|
-
* (s/o/n/c/t/f/d/r/v/u/a/sg/fk),统一为 HubPlugin,保证渲染逻辑只认一种结构。
|
|
69
|
-
*/
|
|
70
|
-
function normalize(raw: Record<string, unknown>): HubPlugin {
|
|
71
|
-
if (typeof raw.s === 'string') {
|
|
72
|
-
return {
|
|
73
|
-
slug: raw.s,
|
|
74
|
-
ownerSlug: typeof raw.o === 'string' ? raw.o : undefined,
|
|
75
|
-
displayName: typeof raw.n === 'string' ? raw.n : undefined,
|
|
76
|
-
category: typeof raw.c === 'string' ? raw.c : undefined,
|
|
77
|
-
topics: Array.isArray(raw.t) ? (raw.t as string[]) : undefined,
|
|
78
|
-
features: Array.isArray(raw.f) ? (raw.f as string[]) : undefined,
|
|
79
|
-
description: typeof raw.d === 'string' ? raw.d : undefined,
|
|
80
|
-
source: typeof raw.r === 'string' ? { repo: raw.r } : undefined,
|
|
81
|
-
compatibility: typeof raw.v === 'string' ? { status: raw.v } : undefined,
|
|
82
|
-
dates: {
|
|
83
|
-
repoUpdatedAt: typeof raw.u === 'string' ? raw.u : undefined,
|
|
84
|
-
addedAt: typeof raw.a === 'string' ? raw.a : undefined,
|
|
85
|
-
},
|
|
86
|
-
stats: {
|
|
87
|
-
stargazers_count: typeof raw.sg === 'number' ? raw.sg : undefined,
|
|
88
|
-
forks_count: typeof raw.fk === 'number' ? raw.fk : undefined,
|
|
89
|
-
},
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
return raw as unknown as HubPlugin
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const CATEGORY_LABELS: Record<string, { zh: string; en: string }> = {
|
|
96
|
-
interface: { zh: '界面与体验', en: 'UI & Experience' },
|
|
97
|
-
session: { zh: '会话与消息', en: 'Sessions & Messages' },
|
|
98
|
-
memory: { zh: '记忆与上下文', en: 'Memory & Context' },
|
|
99
|
-
tools: { zh: '工具与能力', en: 'Tools & Capabilities' },
|
|
100
|
-
agent: { zh: '技能与智能体', en: 'Skills & Agents' },
|
|
101
|
-
workflow: { zh: '工作流与自动化', en: 'Workflow & Automation' },
|
|
102
|
-
integration: { zh: '集成与连接', en: 'Integrations & Connections' },
|
|
103
|
-
model: { zh: '模型与推理', en: 'Models & Reasoning' },
|
|
104
|
-
dev: { zh: '开发与运维', en: 'Development & Operations' },
|
|
105
|
-
knowledge: { zh: '数据与知识', en: 'Data & Knowledge' },
|
|
106
|
-
fun: { zh: '娱乐', en: 'Entertainment' },
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const CATEGORY_SHORT_LABELS: Record<string, { zh: string; en: string }> = {
|
|
110
|
-
interface: { zh: '界面体验', en: 'UI Exp' },
|
|
111
|
-
session: { zh: '会话消息', en: 'Sessions' },
|
|
112
|
-
memory: { zh: '记忆上下文', en: 'Memory' },
|
|
113
|
-
tools: { zh: '工具能力', en: 'Tools' },
|
|
114
|
-
agent: { zh: '技能智能体', en: 'Agents' },
|
|
115
|
-
workflow: { zh: '工作流', en: 'Workflow' },
|
|
116
|
-
integration: { zh: '集成连接', en: 'Integrations' },
|
|
117
|
-
model: { zh: '模型推理', en: 'Models' },
|
|
118
|
-
dev: { zh: '开发运维', en: 'Dev' },
|
|
119
|
-
knowledge: { zh: '数据知识', en: 'Knowledge' },
|
|
120
|
-
fun: { zh: '娱乐', en: 'Fun' },
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const SORTS = ['sortStars', 'sortUpdated', 'sortNewest'] as const
|
|
124
|
-
type SortKey = (typeof SORTS)[number]
|
|
125
|
-
|
|
126
|
-
function fmtStars(count?: number): string {
|
|
127
|
-
if (!count || count <= 0) return '0'
|
|
128
|
-
if (count < 1000) return String(count)
|
|
129
|
-
const k = count / 1000
|
|
130
|
-
return `${k >= 100 ? Math.round(k) : k.toFixed(1).replace(/\.0$/, '')}k`
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function relTime(iso: string | undefined, t: SectionProps['t']): string {
|
|
134
|
-
if (!iso) return ''
|
|
135
|
-
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86400000)
|
|
136
|
-
if (days <= 0) return t('today')
|
|
137
|
-
if (days < 30) return t('daysAgo', { days })
|
|
138
|
-
if (days < 365) return t('monthsAgo', { months: Math.floor(days / 30) })
|
|
139
|
-
return t('yearsAgo', { years: Math.floor(days / 365) })
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
143
|
-
const [lang, setLang] = useState<LocaleId>(locale.getSnapshot().active)
|
|
144
|
-
const [plugins, setPlugins] = useState<HubPlugin[] | null>(null)
|
|
145
|
-
/** 收录/精选统计(官网 /api/stats.json 实时拉取) */
|
|
146
|
-
const [stats, setStats] = useState<{ total: number; verified: number } | null>(null)
|
|
147
|
-
const [failed, setFailed] = useState(false)
|
|
148
|
-
const [reloadKey, setReloadKey] = useState(0)
|
|
149
|
-
const [category, setCategory] = useState('all')
|
|
150
|
-
const [query, setQuery] = useState('')
|
|
151
|
-
const [sort, setSort] = useState<SortKey>('sortStars')
|
|
152
|
-
const [copied, setCopied] = useState<string | null>(null)
|
|
153
|
-
/** 全局复制成功 Toast:{id} 用于重复点击时重新触发入场动画 */
|
|
154
|
-
const [toast, setToast] = useState<{ id: number } | null>(null)
|
|
155
|
-
/** 列表滚动容器:分类/搜索切换后列表内容替换但 scrollTop 保留,会让用户误以为列表没更新,需重置回顶部 */
|
|
156
|
-
const listRef = useRef<HTMLDivElement | null>(null)
|
|
157
|
-
|
|
158
|
-
useEffect(() => {
|
|
159
|
-
listRef.current?.scrollTo({ top: 0 })
|
|
160
|
-
}, [category])
|
|
161
|
-
|
|
162
|
-
useEffect(() => locale.subscribe(() => setLang(locale.getSnapshot().active)), [locale])
|
|
163
|
-
|
|
164
|
-
useEffect(() => {
|
|
165
|
-
let cancelled = false
|
|
166
|
-
setPlugins(null)
|
|
167
|
-
setStats(null)
|
|
168
|
-
setFailed(false)
|
|
169
|
-
const apply = (data: unknown) => {
|
|
170
|
-
if (cancelled) return
|
|
171
|
-
// 在线 API 已只返回 verified;再过滤一次,保证只展示人工验证通过的插件。
|
|
172
|
-
const list = (Array.isArray(data) ? data : []).map((item) => normalize(item as Record<string, unknown>))
|
|
173
|
-
setPlugins(list.filter((p) => p.compatibility?.status === 'verified'))
|
|
174
|
-
}
|
|
175
|
-
const fail = () => {
|
|
176
|
-
if (!cancelled) setFailed(true)
|
|
177
|
-
}
|
|
178
|
-
const fetchData = (url: string): Promise<unknown> =>
|
|
179
|
-
fetch(url).then((res) => {
|
|
180
|
-
if (!res.ok) throw new Error(String(res.status))
|
|
181
|
-
return res.json()
|
|
182
|
-
})
|
|
183
|
-
// 在线拉取最新数据(dsh-plugin.org,走 CDN 缓存),插件不发布也能拿到每日更新。
|
|
184
|
-
const remote = `https://dsh-plugin.org/api/plugins.${lang}.json`
|
|
185
|
-
fetchData(remote).then(apply).catch(fail)
|
|
186
|
-
// 收录/精选统计:实时拉取官网 /api/stats.json。
|
|
187
|
-
const applyStats = (s: { total: number; verified: number }) => {
|
|
188
|
-
if (!cancelled && s && typeof s.total === 'number' && typeof s.verified === 'number') setStats(s)
|
|
189
|
-
}
|
|
190
|
-
fetchData('https://dsh-plugin.org/api/stats.json')
|
|
191
|
-
.then((s) => applyStats(s as { total: number; verified: number }))
|
|
192
|
-
.catch(() => {})
|
|
193
|
-
return () => { cancelled = true }
|
|
194
|
-
}, [lang, reloadKey])
|
|
195
|
-
|
|
196
|
-
const visible = useMemo(() => {
|
|
197
|
-
if (!plugins) return []
|
|
198
|
-
const q = query.trim().toLowerCase()
|
|
199
|
-
const list = plugins.filter((p) => {
|
|
200
|
-
if (category !== 'all' && p.category !== category) return false
|
|
201
|
-
if (!q) return true
|
|
202
|
-
return (
|
|
203
|
-
(p.displayName ?? '').toLowerCase().includes(q) ||
|
|
204
|
-
(p.description ?? '').toLowerCase().includes(q) ||
|
|
205
|
-
(p.topics ?? []).some((topic) => topic.toLowerCase().includes(q))
|
|
206
|
-
)
|
|
207
|
-
})
|
|
208
|
-
return [...list].sort((a, b) => {
|
|
209
|
-
if (sort === 'sortStars') return (b.stats?.stargazers_count ?? 0) - (a.stats?.stargazers_count ?? 0)
|
|
210
|
-
if (sort === 'sortNewest') return (b.dates?.addedAt ?? '').localeCompare(a.dates?.addedAt ?? '')
|
|
211
|
-
return (b.dates?.repoUpdatedAt ?? '').localeCompare(a.dates?.repoUpdatedAt ?? '')
|
|
212
|
-
})
|
|
213
|
-
}, [plugins, category, query, sort])
|
|
214
|
-
|
|
215
|
-
const langKey: LocaleId = lang === 'en' ? 'en' : 'zh'
|
|
216
|
-
// dsh-plugin.org keeps zh pages under the /zh/ prefix; en is the root.
|
|
217
|
-
const langPath = lang === 'zh' ? 'zh/' : ''
|
|
218
|
-
|
|
219
|
-
// 插件界面语言由组件自身 lang 控制(左上角 EN/中文 按钮),宿主的 t() 绑定宿主 locale、
|
|
220
|
-
// 不跟随组件内 lang,因此这里基于本地字典自建翻译函数,保证切换语言后所有文本即时刷新。
|
|
221
|
-
// (settings.section 的导航 label 仍在 apply 里用宿主 t(),跟随宿主语言。)
|
|
222
|
-
const dict: Record<string, string> = langKey === 'en' ? en : zh
|
|
223
|
-
const t = (key: string, params?: Record<string, string | number>): string => {
|
|
224
|
-
const raw = dict[key] ?? key
|
|
225
|
-
return params ? raw.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? '')) : raw
|
|
226
|
-
}
|
|
227
|
-
const catLabel = (map: Record<string, { zh: string; en: string }>, key: string): string =>
|
|
228
|
-
map[key]?.[langKey] ?? key
|
|
229
|
-
|
|
230
|
-
const copyInstall = async (repo: string) => {
|
|
231
|
-
const text = `dsh plugin add ${repo}`
|
|
232
|
-
let ok = false
|
|
233
|
-
try {
|
|
234
|
-
await navigator.clipboard.writeText(text)
|
|
235
|
-
ok = true
|
|
236
|
-
} catch {
|
|
237
|
-
// Clipboard API unavailable (permissions/iframe) — fall back to the
|
|
238
|
-
// legacy hidden-textarea trick, which works in any context.
|
|
239
|
-
const ta = document.createElement('textarea')
|
|
240
|
-
ta.value = text
|
|
241
|
-
ta.style.position = 'fixed'
|
|
242
|
-
ta.style.opacity = '0'
|
|
243
|
-
document.body.appendChild(ta)
|
|
244
|
-
ta.select()
|
|
245
|
-
try {
|
|
246
|
-
ok = document.execCommand('copy')
|
|
247
|
-
} catch {
|
|
248
|
-
ok = false
|
|
249
|
-
}
|
|
250
|
-
document.body.removeChild(ta)
|
|
251
|
-
}
|
|
252
|
-
if (ok) {
|
|
253
|
-
setCopied(repo)
|
|
254
|
-
setToast({ id: Date.now() })
|
|
255
|
-
window.setTimeout(() => setCopied((cur) => (cur === repo ? null : cur)), 1600)
|
|
256
|
-
window.setTimeout(() => setToast(null), 1800)
|
|
257
|
-
} else {
|
|
258
|
-
setCopied(null)
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const total = plugins?.length ?? 0
|
|
263
|
-
const count = visible.length
|
|
264
|
-
|
|
265
|
-
// 统计数字:官网 /api/stats.json 优先;拉取失败时用已加载列表兜底,避免展示 undefined。
|
|
266
|
-
const statsTotal = stats?.total ?? total
|
|
267
|
-
const statsVerified = stats?.verified ?? 0
|
|
268
|
-
|
|
269
|
-
// Per-category plugin counts shown on the category chips.
|
|
270
|
-
const categoryCounts = useMemo(() => {
|
|
271
|
-
const counts: Record<string, number> = { all: total }
|
|
272
|
-
for (const p of plugins ?? []) {
|
|
273
|
-
if (p.category) counts[p.category] = (counts[p.category] ?? 0) + 1
|
|
274
|
-
}
|
|
275
|
-
return counts
|
|
276
|
-
}, [plugins, total])
|
|
277
|
-
|
|
278
|
-
return h('div', { className: styles.root },
|
|
279
|
-
h('div', { className: styles.header },
|
|
280
|
-
h('div', { className: styles.brand },
|
|
281
|
-
h('div', { className: styles.brandText },
|
|
282
|
-
// 市场主标题即本区块的 H1:与官网首页 H1 同文案(DSH-Plugin Hub for DeepSeek
|
|
283
|
-
// Harness),命中品牌词 DSH-Plugin + 产品词 DeepSeek Harness;关键词落在 tagline。
|
|
284
|
-
h('h1', { className: styles.title }, t('title')),
|
|
285
|
-
h('div', { className: styles.tagline }, t('tagline', { total: statsTotal, verified: statsVerified })),
|
|
286
|
-
),
|
|
287
|
-
),
|
|
288
|
-
h('div', { className: styles.controls },
|
|
289
|
-
h('input', {
|
|
290
|
-
className: styles.search,
|
|
291
|
-
type: 'search',
|
|
292
|
-
placeholder: t('search'),
|
|
293
|
-
value: query,
|
|
294
|
-
spellCheck: false,
|
|
295
|
-
onInput: (e: React.FormEvent<HTMLInputElement>) =>
|
|
296
|
-
setQuery((e.target as HTMLInputElement).value),
|
|
297
|
-
}),
|
|
298
|
-
h('select', {
|
|
299
|
-
className: styles.sort,
|
|
300
|
-
value: sort,
|
|
301
|
-
onChange: (e: React.FormEvent<HTMLSelectElement>) =>
|
|
302
|
-
setSort((e.target as HTMLSelectElement).value as SortKey),
|
|
303
|
-
}, SORTS.map((key) => h('option', { key, value: key }, t(key)))),
|
|
304
|
-
h('button', {
|
|
305
|
-
className: styles.langBtn,
|
|
306
|
-
title: lang === 'zh' ? t('toEn') : t('toZh'),
|
|
307
|
-
onClick: () => setLang(lang === 'zh' ? 'en' : 'zh'),
|
|
308
|
-
}, lang === 'zh' ? 'EN' : '中文'),
|
|
309
|
-
h('a', {
|
|
310
|
-
className: styles.openBtn,
|
|
311
|
-
href: `${SITE_URL}${langPath}`,
|
|
312
|
-
target: '_blank',
|
|
313
|
-
rel: 'noopener noreferrer',
|
|
314
|
-
title: t('openHint'),
|
|
315
|
-
}, t('openBtn'), h('span', { className: styles.openArrow }, '\u2197')),
|
|
316
|
-
),
|
|
317
|
-
),
|
|
318
|
-
|
|
319
|
-
h('a', {
|
|
320
|
-
className: styles.adBanner,
|
|
321
|
-
href: `${SITE_URL}${langPath}`,
|
|
322
|
-
target: '_blank',
|
|
323
|
-
rel: 'noopener noreferrer',
|
|
324
|
-
},
|
|
325
|
-
h('span', { className: styles.adBadge }, t('adBadge')),
|
|
326
|
-
h('span', { className: styles.adText }, t('ad', { total: statsTotal, verified: statsVerified })),
|
|
327
|
-
h('span', { className: styles.adArrow }, '\u2197')),
|
|
328
|
-
|
|
329
|
-
h('div', { className: styles.tabs },
|
|
330
|
-
h('button', {
|
|
331
|
-
key: 'all',
|
|
332
|
-
className: category === 'all' ? styles.tabActive : styles.tab,
|
|
333
|
-
onClick: () => setCategory('all'),
|
|
334
|
-
},
|
|
335
|
-
t('all'),
|
|
336
|
-
h('span', { className: category === 'all' ? styles.tabCountActive : styles.tabCount }, total)),
|
|
337
|
-
CATEGORY_ORDER.map((id) => h('button', {
|
|
338
|
-
key: id,
|
|
339
|
-
className: category === id ? styles.tabActive : styles.tab,
|
|
340
|
-
onClick: () => setCategory(id),
|
|
341
|
-
},
|
|
342
|
-
catLabel(CATEGORY_SHORT_LABELS, id),
|
|
343
|
-
h('span', { className: category === id ? styles.tabCountActive : styles.tabCount }, categoryCounts[id] ?? 0))),
|
|
344
|
-
),
|
|
345
|
-
|
|
346
|
-
h('div', { className: styles.body },
|
|
347
|
-
h('div', { ref: listRef, className: styles.list },
|
|
348
|
-
plugins === null && !failed && h('div', { className: styles.state }, t('loading')),
|
|
349
|
-
failed && h('div', { className: styles.state },
|
|
350
|
-
h('div', { className: styles.stateTitle }, t('failed')),
|
|
351
|
-
h('div', { className: styles.stateDesc }, t('failedDesc')),
|
|
352
|
-
h('button', { className: styles.retryBtn, onClick: () => setReloadKey((k) => k + 1) }, t('retry')),
|
|
353
|
-
),
|
|
354
|
-
plugins !== null && !failed && count === 0 && h('div', { className: styles.state },
|
|
355
|
-
h('div', { className: styles.stateTitle }, t('noResult')),
|
|
356
|
-
h('div', { className: styles.stateDesc }, t('noResultDesc')),
|
|
357
|
-
),
|
|
358
|
-
plugins !== null && !failed && visible.map((p) => {
|
|
359
|
-
const repo = p.source?.repo ?? ''
|
|
360
|
-
const isCopied = copied === repo
|
|
361
|
-
return h('div', {
|
|
362
|
-
// 唯一 key:数据重构后同一 slug 可能对应多个作者仓库,仅用 slug 会撞 key,
|
|
363
|
-
// 导致切换分类时 React 复用旧 DOM、列表不刷新。
|
|
364
|
-
key: p.ownerSlug ? `${p.ownerSlug}/${p.slug}` : p.slug,
|
|
365
|
-
className: styles.card,
|
|
366
|
-
},
|
|
367
|
-
h('div', { className: styles.cardMain },
|
|
368
|
-
h('div', { className: styles.cardHead },
|
|
369
|
-
h('div', { className: styles.cardTitle, title: p.description ?? '' }, p.displayName ?? p.slug),
|
|
370
|
-
p.category ? h('span', { className: styles.categoryBadge }, catLabel(CATEGORY_LABELS, p.category)) : null,
|
|
371
|
-
p.compatibility?.status === 'verified'
|
|
372
|
-
? h('span', { className: styles.verified }, t('verified'))
|
|
373
|
-
: null,
|
|
374
|
-
),
|
|
375
|
-
// 英文模式下隐藏仍为中文的描述:站点英文数据缺翻译时 description 会回退成中文,
|
|
376
|
-
// 不显示可保证界面语言一致,不残留中文字符。
|
|
377
|
-
p.description && (langKey === 'zh' || !/[\u4e00-\u9fff]/.test(p.description))
|
|
378
|
-
? h('p', { className: styles.desc }, p.description)
|
|
379
|
-
: null,
|
|
380
|
-
(p.topics?.length ?? 0) > 0
|
|
381
|
-
? h('div', { className: styles.topics },
|
|
382
|
-
p.topics!.slice(0, 3).map((topic) => h('span', { key: topic, className: styles.topic }, topic)),
|
|
383
|
-
)
|
|
384
|
-
: null,
|
|
385
|
-
),
|
|
386
|
-
h('div', { className: styles.cardSide },
|
|
387
|
-
h('div', { className: styles.stats },
|
|
388
|
-
h('span', { className: styles.star }, '\u2605 ', fmtStars(p.stats?.stargazers_count)),
|
|
389
|
-
h('span', { className: styles.fork }, t('fork'), ' ', fmtStars(p.stats?.forks_count)),
|
|
390
|
-
h('span', { className: styles.date }, relTime(p.dates?.repoUpdatedAt, t)),
|
|
391
|
-
),
|
|
392
|
-
repo
|
|
393
|
-
? h('div', { className: styles.actions },
|
|
394
|
-
h('a', {
|
|
395
|
-
className: styles.detailBtn,
|
|
396
|
-
// 两级路径:/plugins/{ownerSlug}/{slug};旧数据缺 ownerSlug 时从 repo 推导
|
|
397
|
-
href: `${SITE_URL}${langPath}plugins/${p.ownerSlug ?? repo.split('/')[0]?.toLowerCase() ?? ''}/${p.slug}`,
|
|
398
|
-
target: '_blank',
|
|
399
|
-
rel: 'noopener noreferrer',
|
|
400
|
-
title: p.slug,
|
|
401
|
-
}, t('detail')),
|
|
402
|
-
h('button', {
|
|
403
|
-
className: isCopied ? styles.installBtnCopied : styles.installBtn,
|
|
404
|
-
// 文字恒定避免按钮宽度变化导致卡片跳动;成功反馈 = 蓝底高亮 + 右下角 Toast
|
|
405
|
-
onClick: () => copyInstall(repo),
|
|
406
|
-
}, t('copy')),
|
|
407
|
-
)
|
|
408
|
-
: null,
|
|
409
|
-
),
|
|
410
|
-
)
|
|
411
|
-
}),
|
|
412
|
-
),
|
|
413
|
-
plugins !== null && !failed && h('div', { className: styles.footer },
|
|
414
|
-
h('span', { className: styles.footText }, count === total ? t('pluginsTotal', { n: total }) : t('filteredCount', { n: count })),
|
|
415
|
-
h('a', {
|
|
416
|
-
className: styles.footLink,
|
|
417
|
-
href: `${SITE_URL}${langPath}`,
|
|
418
|
-
target: '_blank',
|
|
419
|
-
rel: 'noopener noreferrer',
|
|
420
|
-
}, t('browseAll', { n: total })),
|
|
421
|
-
),
|
|
422
|
-
),
|
|
423
|
-
// 复制成功提示条:右下角纯黑色文字条,1800ms 后自动消失
|
|
424
|
-
toast && h('div', { key: toast.id, className: styles.toast }, t('toastCopied')),
|
|
425
|
-
)
|
|
426
|
-
}
|
|
427
13
|
|
|
428
14
|
export const name = NS
|
|
429
15
|
export const inject = ['slots', 'locale']
|
|
430
16
|
|
|
431
17
|
export function apply(ctx: HubClientContext): void {
|
|
432
|
-
ctx.effect(() => ctx.locale.register(NS, { zh, en }),
|
|
18
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), `${NS}: dictionaries`)
|
|
433
19
|
const t = ctx.locale.bind(NS)
|
|
434
20
|
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
435
21
|
name: 'settings.section',
|