dsh-dpharness 0.3.5

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/lib/client.js ADDED
@@ -0,0 +1,930 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-dpharness",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react = require("react");
8
+ const h = react.createElement;
9
+
10
+ /* ------------------------------------------------------------------ styles */
11
+ const css = [
12
+ ".dph_root{display:flex;flex-direction:column;gap:12px;width:100%;max-width:1080px;margin:0 auto;padding:6px 4px 24px}",
13
+ ".dph_header{display:flex;align-items:center;gap:10px;flex-wrap:wrap}",
14
+ ".dph_title{margin:0;font-size:17px;font-weight:600;color:var(--dsw-alias-label-primary)}",
15
+ ".dph_sub{color:var(--dsw-alias-label-tertiary);font-size:12px}",
16
+ ".dph_spacer{flex:1}",
17
+ ".dph_btn{border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);border-radius:6px;padding:4px 12px;font:inherit;font-size:12px;cursor:pointer;text-decoration:none;white-space:nowrap}",
18
+ ".dph_btn:hover{background:var(--dsw-alias-interactive-bg-hover)}",
19
+ ".dph_bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
20
+ ".dph_input{flex:1;min-width:180px;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:8px;padding:7px 10px;font:inherit;font-size:13px;outline:none}",
21
+ ".dph_input:focus{border-color:var(--dsw-alias-state-business-primary)}",
22
+ ".dph_seg{display:inline-flex;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;overflow:hidden;flex:none}",
23
+ ".dph_segBtn{border:0;background:transparent;color:var(--dsw-alias-label-secondary);padding:6px 12px;font:inherit;font-size:12px;cursor:pointer;white-space:nowrap}",
24
+ ".dph_segBtnOn{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);font-weight:600}",
25
+ ".dph_list{display:flex;flex-direction:column;gap:8px}",
26
+ ".dph_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:10px;padding:12px 14px;display:flex;flex-direction:column;gap:8px}",
27
+ ".dph_cardTop{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}",
28
+ ".dph_name{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}",
29
+ ".dph_owner{font-size:12px;color:var(--dsw-alias-label-tertiary)}",
30
+ ".dph_stars{font-size:12px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}",
31
+ ".dph_tags{display:flex;gap:6px;flex-wrap:wrap}",
32
+ ".dph_tag{border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:1px 8px;font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap}",
33
+ ".dph_tagOk{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}",
34
+ ".dph_tagBad{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}",
35
+ ".dph_desc{font-size:12px;line-height:1.6;color:var(--dsw-alias-label-secondary);margin:0}",
36
+ ".dph_cmdRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
37
+ ".dph_cmdWrap{display:flex;flex-direction:column;gap:4px}",
38
+ ".dph_cmd{flex:1;min-width:200px;box-sizing:border-box;background:var(--dsw-alias-bg-layer-3);border-radius:6px;padding:6px 10px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--dsw-alias-label-primary);overflow-x:auto;white-space:nowrap}",
39
+ ".dph_cmdMissing{font-size:12px;color:var(--dsw-alias-label-tertiary)}",
40
+ ".dph_notice{border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:14px;font-size:13px;color:var(--dsw-alias-label-secondary)}",
41
+ ".dph_noticeBad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}",
42
+ ".dph_footer{font-size:12px;color:var(--dsw-alias-label-tertiary);line-height:1.6}",
43
+ ".dph_tel{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-tertiary);cursor:pointer;user-select:none}",
44
+ ".dph_lb{box-sizing:border-box;display:flex;align-items:center;justify-content:flex-start;gap:8px;width:calc(100% + 8px);height:34px;margin:4px -4px;color:var(--dsw-alias-label-primary);background:transparent;border:none;border-radius:12px;padding:6px 2px 6px 10px;font:inherit;font-size:14px;line-height:22px;text-align:left;cursor:pointer;transition:background .12s}",
45
+ ".dph_lb:hover{background:var(--dsw-alias-interactive-bg-hover)}",
46
+ ".dph_lbLabel{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
47
+ ".dph_lbOff .dph_lb{justify-content:center;width:36px;height:36px;margin:2px 0;border-radius:50%;padding:0;gap:0}",
48
+ ".dph_lbMark{font-size:13px;font-weight:600;color:var(--dsw-alias-state-business-primary)}",
49
+ "div:has(> [data-slot=\"sidebar.footer.action\"]){flex-direction:column}",
50
+ ".dph_ovWrap{position:fixed;right:16px;bottom:16px;z-index:2147483000;display:flex;flex-direction:column;align-items:flex-end;gap:8px}",
51
+ ".dph_pill{display:flex;align-items:center;gap:8px;height:38px;padding:0 14px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);box-shadow:0 6px 20px rgba(0,0,0,.14);cursor:grab;touch-action:none;user-select:none;font:inherit;font-size:13px;font-weight:600}",
52
+ ".dph_pill:hover{background:var(--dsw-alias-interactive-bg-hover)}",
53
+ ".dph_pillMark{color:var(--dsw-alias-state-business-primary)}",
54
+ ".dph_panel{width:400px;max-width:calc(100vw - 32px);max-height:min(72vh,620px);display:flex;flex-direction:column;gap:10px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:14px;box-shadow:0 16px 48px rgba(0,0,0,.22);padding:14px}",
55
+ ".dph_panelHead{display:flex;align-items:center;gap:8px}",
56
+ ".dph_panelTitle{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}",
57
+ ".dph_panelBody{display:flex;flex-direction:column;gap:8px;overflow-y:auto;margin-right:-4px;padding-right:4px}",
58
+ ".dph_panelFoot{display:flex;align-items:center;gap:8px;justify-content:space-between;border-top:1px solid var(--dsw-alias-border-l2);padding-top:8px;cursor:default}",
59
+ ".dph_close{border:0;background:transparent;color:var(--dsw-alias-label-tertiary);font-size:16px;line-height:1;cursor:pointer;padding:2px 6px;border-radius:6px}",
60
+ ".dph_close:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
61
+ ".dph_cardSm{padding:10px 12px;gap:6px}",
62
+ ".dph_instRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
63
+ ".dph_btnPrimary{border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-state-business-primary);font-weight:600}",
64
+ ".dph_btn:disabled{opacity:.55;cursor:not-allowed}",
65
+ ".dph_instMsg{font-size:12px;color:var(--dsw-alias-label-secondary)}",
66
+ ".dph_instOk{color:var(--dsw-alias-state-business-primary)}",
67
+ ".dph_instBad{color:var(--dsw-alias-state-error-primary)}",
68
+ ".dph_instHint{font-size:11px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}",
69
+ ".dph_sell{display:flex;align-items:flex-start;gap:6px;font-size:12px;line-height:1.6}",
70
+ ".dph_sellTag{flex:none;border:1px solid var(--dsw-alias-state-business-primary);color:var(--dsw-alias-state-business-primary);border-radius:4px;padding:0 5px;font-size:11px;line-height:16px}",
71
+ ".dph_sellText{color:var(--dsw-alias-label-primary);font-weight:500}",
72
+ ".dph_raw{color:var(--dsw-alias-label-tertiary)}",
73
+ ".dph_drag{cursor:grab;touch-action:none;user-select:none}",
74
+ ".dph_drag:active{cursor:grabbing}",
75
+ ].join("");
76
+ const tagId = "dsh-dpharness/client.css";
77
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
78
+ const tag = document.createElement("style");
79
+ tag.dataset.plugin = "dsh-dpharness";
80
+ tag.dataset.pluginCss = tagId;
81
+ tag.textContent = css;
82
+ document.head.appendChild(tag);
83
+ }
84
+
85
+ /* ---------------------------------------------------------------- locales */
86
+ const NS = "dpharness";
87
+ /* 目录站点;改这里的同时要改 host 侧 lib/index.js 的 SITE / UPSTREAM / TRACK。 */
88
+ const SITE = "https://dpharness.com";
89
+ const TAKE_OPTIONS = [30, 60, 100];
90
+ const zh = {
91
+ "view.tab": "严选插件",
92
+ "view.title": "dsh 严选插件",
93
+ "view.sub": "只推装得上的 · 数据来源 dpharness.com",
94
+ "search.placeholder": "搜索插件名称 / 描述 / 作者…",
95
+ "sort.default": "综合",
96
+ "sort.stars": "按 Star",
97
+ "action.refresh": "刷新",
98
+ "action.openSite": "打开站点",
99
+ "action.copy": "复制命令",
100
+ "action.copied": "已复制",
101
+ "action.open": "严选插件",
102
+ "action.close": "收起",
103
+ "action.drag": "可拖动调整位置",
104
+ "count.summary": "共 {n} 条结果",
105
+ "state.loading": "正在读取插件目录…",
106
+ "state.empty": "没有匹配的插件。换个关键词试试。",
107
+ "state.failed": "读取失败",
108
+ "cmd.missing": "站点未给出已验证的 npm 包名,请到插件页确认安装方式",
109
+ "badge.stars": "★ {n}",
110
+ "label.sell": "卖点",
111
+ "label.untranslated": "未汉化",
112
+ "label.repoSource": "GitHub 源",
113
+ "hint.repoSource": "该插件未发布到 npm,走 GitHub 源安装(与站点详情页同口径)。",
114
+ "footer.hint": "网页无法自动安装:复制命令到终端执行,装完重启 dsh 才会生效。命令里的 --profile web 不可省略。",
115
+ "telemetry.on": "匿名统计(不含搜索内容)",
116
+ "install.action": "安装",
117
+ "install.confirm": "确认安装?",
118
+ "install.cancel": "取消",
119
+ "install.running": "正在安装…",
120
+ "install.market": "通过 dshmarket 安装中…",
121
+ "install.local": "正在安装(需要重启生效)…",
122
+ "install.okMarket": "已安装,无需重启",
123
+ "install.okLocal": "已安装,重启 dsh 后生效",
124
+ "install.restart": "立即重启",
125
+ "install.failed": "安装失败",
126
+ "install.busy": "已有安装任务在进行",
127
+ "install.hint": "安装会在你的 dsh 里执行 dsh plugin add,来源为下方命令中的包名。",
128
+ };
129
+ const en = {
130
+ "view.tab": "Curated",
131
+ "view.title": "dsh curated plugins",
132
+ "view.sub": "Only installable ones · source dpharness.com",
133
+ "search.placeholder": "Search name / description / owner…",
134
+ "sort.default": "Relevance",
135
+ "sort.stars": "By stars",
136
+ "action.refresh": "Refresh",
137
+ "action.openSite": "Open site",
138
+ "action.copy": "Copy command",
139
+ "action.copied": "Copied",
140
+ "action.open": "Curated",
141
+ "action.close": "Collapse",
142
+ "action.drag": "Drag to reposition",
143
+ "count.summary": "{n} results",
144
+ "state.loading": "Loading the catalog…",
145
+ "state.empty": "No matching plugin. Try another keyword.",
146
+ "state.failed": "Failed to load",
147
+ "cmd.missing": "No verified npm package name on record — check the plugin page for the install method",
148
+ "badge.stars": "★ {n}",
149
+ "label.sell": "Highlight",
150
+ "label.untranslated": "Not translated",
151
+ "label.repoSource": "GitHub source",
152
+ "hint.repoSource": "Not published to npm — installs from the GitHub source (same rule as the site's detail page).",
153
+ "footer.hint": "A web page cannot install plugins: copy the command into a terminal, then restart dsh. The --profile web flag is mandatory.",
154
+ "telemetry.on": "Anonymous stats (no search text)",
155
+ "install.action": "Install",
156
+ "install.confirm": "Confirm install?",
157
+ "install.cancel": "Cancel",
158
+ "install.running": "Installing…",
159
+ "install.market": "Installing via dshmarket…",
160
+ "install.local": "Installing (restart required)…",
161
+ "install.okMarket": "Installed, no restart needed",
162
+ "install.okLocal": "Installed — restart dsh to activate",
163
+ "install.restart": "Restart now",
164
+ "install.failed": "Install failed",
165
+ "install.busy": "Another install is already running",
166
+ "install.hint": "Install runs dsh plugin add inside your dsh, using the package name in the command below.",
167
+ };
168
+
169
+ /* ------------------------------------------------------------- telemetry */
170
+ /* 事件经本地 dsh host 转发到 dpharness.com/api/track/event:
171
+ 站点端点不带 CORS 头,浏览器跨源直发不成立(实测)。
172
+ 隐私口径:不发送搜索关键词原文,只发长度与命中数;可整体关闭。 */
173
+ const TEL_KEY = "dph-hub-telemetry";
174
+ const VID_KEY = "dph-hub-vid";
175
+ let telemetryOn = true;
176
+ try {
177
+ telemetryOn = localStorage.getItem(TEL_KEY) !== "0";
178
+ } catch (error) {
179
+ /* localStorage 不可用时保持默认开 */
180
+ }
181
+
182
+ function visitorId() {
183
+ try {
184
+ const existing = localStorage.getItem(VID_KEY);
185
+ if (existing) return existing;
186
+ const generated = (crypto && crypto.randomUUID) ? crypto.randomUUID() : "dsh-hub-" + String(Date.now());
187
+ localStorage.setItem(VID_KEY, generated);
188
+ return generated;
189
+ } catch (error) {
190
+ return "";
191
+ }
192
+ }
193
+
194
+ function track(event, detail) {
195
+ if (!telemetryOn) return;
196
+ try {
197
+ fetch("/api/dpharness/event", {
198
+ method: "POST",
199
+ headers: { "content-type": "application/json" },
200
+ body: JSON.stringify({ event, detail: detail || {}, visitorId: visitorId() }),
201
+ keepalive: true,
202
+ }).catch(() => {});
203
+ } catch (error) {
204
+ /* 埋点失败静默忽略,绝不影响 UI */
205
+ }
206
+ }
207
+
208
+ /* --------------------------------------------------------------- installer */
209
+ /* 两条路径,安全优先:
210
+ ① 复用已装的 dshmarket 的同源 HTTP 路由(免重启热挂载、可回滚);
211
+ 它只接受自己 curated registry(awesome 目录)里的 url,不在其中会返回 400;
212
+ ② 降级到本插件 host 路由,直接 spawn `dsh plugin add`(改 profile,需重启生效)。
213
+ 已在运行 agent 时 dshmarket 会返回 409 —— 这种情况不降级,避免把插件文件写坏。 */
214
+ const MARKET_INSTALL = "/dsh-market/install";
215
+ const MARKET_STATUS = "/dsh-market/status";
216
+ const LOCAL_INSTALL = "/api/dpharness/install";
217
+ const INSTALL_POLL_MS = 1200;
218
+ const INSTALL_MAX_MS = 5 * 60 * 1000;
219
+
220
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
221
+
222
+ async function requestJson(url, options) {
223
+ try {
224
+ const response = await fetch(url, options);
225
+ let data = null;
226
+ try { data = await response.json(); } catch (error) { data = null; }
227
+ return { status: response.status, data };
228
+ } catch (error) {
229
+ return { status: 0, data: null, error: String((error && error.message) || error) };
230
+ }
231
+ }
232
+
233
+ const postJson = (url, body) => requestJson(url, {
234
+ method: "POST",
235
+ headers: { "content-type": "application/json" },
236
+ body: JSON.stringify(body || {}),
237
+ });
238
+
239
+ const getJson = (url) => requestJson(url, { cache: "no-store" });
240
+
241
+ function messageOf(result, fallback) {
242
+ const data = result && result.data;
243
+ if (data && typeof data.error === "string" && data.error) return data.error.slice(0, 200);
244
+ if (result && result.error) return result.error;
245
+ return fallback + "(HTTP " + (result ? result.status : "?") + ")";
246
+ }
247
+
248
+ async function pollMarketInstall(deadline) {
249
+ while (Date.now() < deadline) {
250
+ await sleep(INSTALL_POLL_MS);
251
+ const status = await getJson(MARKET_STATUS);
252
+ if (status.status !== 200 || !status.data) return { ok: false, message: messageOf(status, "dshmarket status unavailable") };
253
+ if (status.data.active === false && status.data.busy === false) {
254
+ if (status.data.error) return { ok: false, message: String(status.data.error).slice(0, 200) };
255
+ return { ok: true, market: true };
256
+ }
257
+ }
258
+ return { ok: false, message: "timeout" };
259
+ }
260
+
261
+ async function pollLocalInstall(deadline) {
262
+ while (Date.now() < deadline) {
263
+ await sleep(INSTALL_POLL_MS);
264
+ const status = await getJson(LOCAL_INSTALL);
265
+ const job = status.data && status.data.job;
266
+ if (status.status !== 200 || !job) return { ok: false, message: messageOf(status, "install status unavailable") };
267
+ if (job.active === false) {
268
+ if (job.code === 0) return { ok: true, market: false };
269
+ return { ok: false, message: String(job.error || job.tail || ("exit code " + job.code)).slice(0, 200) };
270
+ }
271
+ }
272
+ return { ok: false, message: "timeout" };
273
+ }
274
+
275
+ async function runInstall(item, t) {
276
+ const target = item.pkg;
277
+ if (!target) return;
278
+ const deadline = Date.now() + INSTALL_MAX_MS;
279
+ setHub({ install: { active: true, target, phase: "market", message: t("install.market") } });
280
+ track("plugin_hub", { action: "install" });
281
+
282
+ let done = null;
283
+ if (item.url) {
284
+ const market = await postJson(MARKET_INSTALL, { url: item.url });
285
+ if (market.status === 200) {
286
+ done = await pollMarketInstall(deadline);
287
+ } else if (market.status === 400 || market.status === 404 || market.status === 0) {
288
+ done = null;
289
+ } else {
290
+ setHub({ install: { active: false, target, phase: "failed", message: messageOf(market, t("install.failed")) } });
291
+ return;
292
+ }
293
+ }
294
+ if (done === null) {
295
+ setHub({ install: { active: true, target, phase: "local", message: t("install.local") } });
296
+ const local = await postJson(LOCAL_INSTALL, { pkg: target });
297
+ if (local.status !== 202) {
298
+ setHub({ install: { active: false, target, phase: "failed", message: messageOf(local, t("install.failed")) } });
299
+ return;
300
+ }
301
+ done = await pollLocalInstall(deadline);
302
+ }
303
+ setHub({
304
+ install: {
305
+ active: false,
306
+ target,
307
+ phase: done.ok ? (done.market ? "okMarket" : "okLocal") : "failed",
308
+ message: done.ok ? t(done.market ? "install.okMarket" : "install.okLocal") : done.message,
309
+ },
310
+ });
311
+ if (done.ok) track("plugin_hub", { action: done.market ? "installed_market" : "installed_local" });
312
+ }
313
+
314
+ /** 仅在降级路径(改了 profile)后需要;/api/restart 由 dsh-restart 插件提供(POST)。 */
315
+ async function restartServer() {
316
+ const result = await postJson("/api/restart", {});
317
+ return result.status >= 200 && result.status < 300;
318
+ }
319
+
320
+ /* ------------------------------------------------------------------ store */
321
+ /* 三个入口共享的轻量 store;不依赖 dsh 内部 store,避免猜测未验证的 API。 */
322
+ const hub = { open: false, telemetry: telemetryOn, install: null, listeners: new Set() };
323
+ function setHub(patch) {
324
+ Object.assign(hub, patch);
325
+ hub.listeners.forEach((listener) => listener());
326
+ }
327
+ function useHub() {
328
+ const [, force] = react.useState(0);
329
+ react.useEffect(() => {
330
+ const listener = () => force((n) => n + 1);
331
+ hub.listeners.add(listener);
332
+ return () => { hub.listeners.delete(listener); };
333
+ }, []);
334
+ return hub;
335
+ }
336
+
337
+ /* ------------------------------------------------------------- utilities */
338
+ function format(template, values) {
339
+ return String(template).replace(/\{(\w+)\}/g, (whole, key) => (key in values ? String(values[key]) : whole));
340
+ }
341
+
342
+ /* ------------------------------------------------------------ 浮窗位置(可拖动) */
343
+ /* 为什么要能拖:右下角在 dsh 里不是空位 —— 实测与「检查更新」等既有按钮重叠。
344
+ 拖动后位置记在 localStorage(right/bottom,单位 px),下次进来还在原处。 */
345
+ const POS_KEY = "dph-hub-pos";
346
+ const EDGE = 8;
347
+ const DRAG_THRESHOLD = 5;
348
+ /**
349
+ * 默认位置刻意**不贴右下角**。
350
+ * 实测(1280×860,2026-09-17):dsh 自身的「检查更新」按钮
351
+ * `#dsh-check-updates-btn` 位于 right:16 / bottom:16 / 94×32,
352
+ * 且 z-index 同为 2147483000 —— 与贴角的胶囊**完全重叠**,并在 DOM 中更靠后,
353
+ * 于是胶囊被压住、点不到(playwright 报元素拦截点击)。
354
+ * 故默认抬高到它上方(16 + 32 + 12 = 60),首次出现即不冲突;
355
+ * 用户仍可拖动,位置会记住。
356
+ */
357
+ const DEFAULT_POS = { right: 16, bottom: 60 };
358
+
359
+ function viewportSize() {
360
+ return {
361
+ w: typeof window === "undefined" ? 1440 : window.innerWidth,
362
+ h: typeof window === "undefined" ? 900 : window.innerHeight,
363
+ };
364
+ }
365
+
366
+ /** 夹在视口内(留 EDGE 边距)。纯函数,便于测试。 */
367
+ function clampPos(pos, size, view) {
368
+ const right = Math.max(EDGE, Math.min(Number(pos && pos.right) || EDGE, Math.max(EDGE, view.w - size.w - EDGE)));
369
+ const bottom = Math.max(EDGE, Math.min(Number(pos && pos.bottom) || EDGE, Math.max(EDGE, view.h - size.h - EDGE)));
370
+ return { right: Math.round(right), bottom: Math.round(bottom) };
371
+ }
372
+
373
+ function readStoredPos() {
374
+ try {
375
+ const raw = localStorage.getItem(POS_KEY);
376
+ if (!raw) return DEFAULT_POS;
377
+ const parsed = JSON.parse(raw);
378
+ return clampPos(parsed, { w: 150, h: 38 }, viewportSize());
379
+ } catch (error) {
380
+ return DEFAULT_POS;
381
+ }
382
+ }
383
+
384
+ function storePos(pos) {
385
+ try {
386
+ localStorage.setItem(POS_KEY, JSON.stringify(pos));
387
+ } catch (error) {
388
+ /* 隐私模式下不持久化,本次会话内仍可拖 */
389
+ }
390
+ }
391
+
392
+ /**
393
+ * 拖动逻辑:pointer 事件 + 位移阈值区分「点击」与「拖动」。
394
+ * 返回的函数把 moved 标记交给调用方,用于在 pointerup 后的 click 里跳过打开动作。
395
+ *
396
+ * ⚠️ 监听挂在 `window` 上而不是被拖元素上:元素级监听依赖
397
+ * `setPointerCapture` 成功,而它并非在所有场景都生效(实测面板标题拖不动就是
398
+ * 因为指针移出元素后 pointermove 不再派发给它)。window 监听无条件收到移动事件。
399
+ */
400
+ function makeDragHandlers(getPos, setPos, skipClickRef) {
401
+ return function onPointerDown(event) {
402
+ if (event.button !== undefined && event.button !== 0) return; // 只响应左键
403
+ const node = event.currentTarget;
404
+ const start = getPos();
405
+ const startX = event.clientX;
406
+ const startY = event.clientY;
407
+ const rect = node.getBoundingClientRect();
408
+ const size = { w: rect.width || 150, h: rect.height || 38 };
409
+ let moved = false;
410
+ event.preventDefault();
411
+ try {
412
+ node.setPointerCapture(event.pointerId);
413
+ } catch (error) {
414
+ /* 捕获失败也没关系,下面用 window 监听兜住 */
415
+ }
416
+ const onMove = (moveEvent) => {
417
+ const dx = moveEvent.clientX - startX;
418
+ const dy = moveEvent.clientY - startY;
419
+ if (!moved && Math.abs(dx) + Math.abs(dy) < DRAG_THRESHOLD) return;
420
+ moved = true;
421
+ // right/bottom 与指针位移方向相反
422
+ setPos(clampPos({ right: start.right - dx, bottom: start.bottom - dy }, size, viewportSize()));
423
+ };
424
+ const onUp = () => {
425
+ window.removeEventListener("pointermove", onMove);
426
+ window.removeEventListener("pointerup", onUp);
427
+ window.removeEventListener("pointercancel", onUp);
428
+ try {
429
+ node.releasePointerCapture(event.pointerId);
430
+ } catch (error) {
431
+ /* ignore */
432
+ }
433
+ if (moved) {
434
+ skipClickRef.current = true;
435
+ storePos(getPos());
436
+ }
437
+ };
438
+ window.addEventListener("pointermove", onMove);
439
+ window.addEventListener("pointerup", onUp);
440
+ window.addEventListener("pointercancel", onUp);
441
+ };
442
+ }
443
+
444
+ function copyText(text, done) { const fallback = () => {
445
+ const area = document.createElement("textarea");
446
+ area.value = text;
447
+ area.setAttribute("readonly", "");
448
+ area.style.position = "absolute";
449
+ area.style.left = "-9999px";
450
+ document.body.appendChild(area);
451
+ area.select();
452
+ try {
453
+ document.execCommand("copy");
454
+ } catch (error) {
455
+ /* 剪贴板不可用时静默降级 */
456
+ }
457
+ document.body.removeChild(area);
458
+ done();
459
+ };
460
+ if (navigator.clipboard && navigator.clipboard.writeText) {
461
+ navigator.clipboard.writeText(text).then(done, fallback);
462
+ return;
463
+ }
464
+ fallback();
465
+ }
466
+
467
+ function useCatalog(t) {
468
+ const [keyword, setKeyword] = react.useState("");
469
+ const [debounced, setDebounced] = react.useState("");
470
+ const [take, setTake] = react.useState(TAKE_OPTIONS[0]);
471
+ const [byStars, setByStars] = react.useState(false);
472
+ const [reload, setReload] = react.useState(0);
473
+ const [state, setState] = react.useState({ loading: true, data: null, error: null });
474
+ const [copiedCmd, setCopiedCmd] = react.useState("");
475
+ const abortRef = react.useRef(null);
476
+
477
+ react.useEffect(() => {
478
+ const id = setTimeout(() => setDebounced(keyword.trim()), keyword ? 320 : 0);
479
+ return () => clearTimeout(id);
480
+ }, [keyword]);
481
+
482
+ react.useEffect(() => {
483
+ if (abortRef.current) abortRef.current.abort();
484
+ const controller = new AbortController();
485
+ abortRef.current = controller;
486
+ const timer = setTimeout(() => controller.abort(), 20000);
487
+ setState((previous) => ({ loading: true, data: previous.data, error: null }));
488
+ const params = new URLSearchParams();
489
+ if (debounced) params.set("q", debounced);
490
+ params.set("take", String(take));
491
+ if (byStars) params.set("sort", "stars");
492
+ fetch("/api/dpharness/search?" + params.toString(), { cache: "no-store", signal: controller.signal })
493
+ .then((response) => response.json().then((body) => ({ status: response.status, body })))
494
+ .then((result) => {
495
+ if (controller.signal.aborted) return;
496
+ if (result.status !== 200 || !result.body || result.body.ok !== true) {
497
+ throw new Error((result.body && result.body.error) || ("HTTP " + result.status));
498
+ }
499
+ setState({ loading: false, data: result.body, error: null });
500
+ if (debounced) {
501
+ // 只上报关键词长度与命中数,不上报关键词原文
502
+ track("plugin_hub", { action: "search", length: debounced.length, results: result.body.count });
503
+ }
504
+ })
505
+ .catch((error) => {
506
+ if (controller.signal.aborted) return;
507
+ const message = error && error.name === "AbortError" ? "timeout" : String((error && error.message) || error);
508
+ setState((previous) => ({ loading: false, data: previous.data, error: message }));
509
+ })
510
+ .finally(() => clearTimeout(timer));
511
+ return () => clearTimeout(timer);
512
+ }, [debounced, take, byStars, reload]);
513
+
514
+ const markCopied = react.useCallback((text) => {
515
+ setCopiedCmd(text);
516
+ setTimeout(() => setCopiedCmd((current) => (current === text ? "" : current)), 2000);
517
+ }, []);
518
+
519
+ return {
520
+ keyword, setKeyword, take, setTake, byStars, setByStars,
521
+ refresh: () => setReload((n) => n + 1),
522
+ state, copiedCmd, markCopied,
523
+ plugins: state.data && Array.isArray(state.data.plugins) ? state.data.plugins : [],
524
+ };
525
+ }
526
+
527
+ /* ------------------------------------------------------------ components */
528
+ function makeUi(t) {
529
+ function badge(label, kind) {
530
+ const cls = kind === "ok" ? "dph_tag dph_tagOk" : kind === "bad" ? "dph_tag dph_tagBad" : "dph_tag";
531
+ return h("span", { className: cls, key: label }, label);
532
+ }
533
+
534
+ function tagsOf(item) {
535
+ const tags = [];
536
+ // 中文分类放最前 —— 它是"这东西是干什么的"的第一眼信息
537
+ if (item.cat) tags.push(badge(item.cat, null));
538
+ if (item.tier) tags.push(badge(item.tier, item.tier === "core" ? "ok" : null));
539
+ if (item.compat) tags.push(badge(item.compat, null));
540
+ if (item.verify === "pass") tags.push(badge("verify: pass", "ok"));
541
+ else if (item.verify === "fail") tags.push(badge("verify: fail", "bad"));
542
+ if (item.tested === "passed") tags.push(badge("tested", "ok"));
543
+ if (item.risk && item.risk !== "low") tags.push(badge("risk: " + item.risk, item.risk === "high" ? "bad" : null));
544
+ return tags;
545
+ }
546
+
547
+ /* −−− 一键安装:二次确认 → 执行 → 进度 → 结果(失败/需重启) −−− */
548
+ function InstallRow(props) {
549
+ const item = props.item;
550
+ const state = useHub();
551
+ const [confirming, setConfirming] = react.useState(false);
552
+ const [restarting, setRestarting] = react.useState(false);
553
+ const [restartFailed, setRestartFailed] = react.useState(false);
554
+ if (!item.pkg) return null;
555
+
556
+ const job = state.install;
557
+ const mine = job && job.target === item.pkg;
558
+ const busyElsewhere = !!(job && job.active && !mine);
559
+
560
+ if (mine && job.active) {
561
+ return h("div", { className: "dph_instRow" }, [
562
+ h("span", { className: "dph_instMsg", key: "msg" }, job.message),
563
+ ]);
564
+ }
565
+
566
+ if (mine && (job.phase === "okMarket" || job.phase === "okLocal")) {
567
+ const needsRestart = job.phase === "okLocal";
568
+ return h("div", { className: "dph_instRow" }, [
569
+ h("span", { className: "dph_instMsg dph_instOk", key: "msg" }, job.message),
570
+ needsRestart
571
+ ? h("button", {
572
+ className: "dph_btn dph_btnPrimary",
573
+ key: "restart",
574
+ type: "button",
575
+ disabled: restarting,
576
+ onClick: async () => {
577
+ setRestarting(true);
578
+ const ok = await restartServer();
579
+ setRestarting(false);
580
+ if (!ok) setRestartFailed(true);
581
+ },
582
+ }, t("install.restart"))
583
+ : null,
584
+ needsRestart && restartFailed
585
+ ? h("span", { className: "dph_instMsg dph_instBad", key: "fail" }, "重启接口不可用,请手动重启 dsh")
586
+ : null,
587
+ ]);
588
+ }
589
+
590
+ if (mine && job.phase === "failed") {
591
+ return h("div", { className: "dph_instRow" }, [
592
+ h("span", { className: "dph_instMsg dph_instBad", key: "msg" }, t("install.failed") + ":" + job.message),
593
+ ]);
594
+ }
595
+
596
+ if (confirming) {
597
+ return h("div", { className: "dph_instRow" }, [
598
+ h("span", { className: "dph_instHint", key: "hint" }, t("install.hint")),
599
+ h("button", {
600
+ className: "dph_btn dph_btnPrimary",
601
+ key: "go",
602
+ type: "button",
603
+ onClick: () => {
604
+ setConfirming(false);
605
+ runInstall(item, t);
606
+ },
607
+ }, t("install.confirm")),
608
+ h("button", { className: "dph_btn", key: "cancel", type: "button", onClick: () => setConfirming(false) }, t("install.cancel")),
609
+ ]);
610
+ }
611
+
612
+ return h("div", { className: "dph_instRow" }, [
613
+ h("button", {
614
+ className: "dph_btn dph_btnPrimary",
615
+ key: "install",
616
+ type: "button",
617
+ disabled: busyElsewhere,
618
+ title: busyElsewhere ? t("install.busy") : item.pkg,
619
+ onClick: () => setConfirming(true),
620
+ }, busyElsewhere ? t("install.busy") : t("install.action")),
621
+ h("span", { className: "dph_instHint", key: "pkg" }, item.pkg),
622
+ ]);
623
+ }
624
+
625
+ function renderCard(item, copiedCmd, onCopied, compact) {
626
+ const title = item.nameZh || item.name || item.fullName || "";
627
+ const tags = tagsOf(item);
628
+ // 卖点行:站点 tagline 经严格过滤后的结果;没有就不占位(宁可不显示,也不放英文原文或垃圾串)
629
+ const sellRow = item.sell
630
+ ? h("div", { className: "dph_sell", key: "sell" }, [
631
+ h("span", { className: "dph_sellTag", key: "tag" }, t("label.sell")),
632
+ h("span", { className: "dph_sellText", key: "text" }, item.sell),
633
+ ])
634
+ : null;
635
+ // 描述:汉化正文优先;没有汉化才回落到英文原文,并明确标注
636
+ const descRow = item.descZh
637
+ ? h("p", { className: "dph_desc", key: "desc" }, item.descZh)
638
+ : item.desc
639
+ ? h("p", { className: "dph_desc", key: "desc" }, [
640
+ h("span", { className: "dph_raw", key: "raw" }, item.desc),
641
+ h("span", { className: "dph_tag", key: "unzh" }, t("label.untranslated")),
642
+ ])
643
+ : null;
644
+ const cmdRow = item.cmd
645
+ ? h("div", { className: "dph_cmdWrap", key: "cmd" }, [
646
+ h("div", { className: "dph_cmdRow", key: "row" }, [
647
+ // 未发布到 npm 时标明来源 —— 站点详情页也是这么标注的,两处用词保持一致
648
+ item.src === "repo"
649
+ ? h("span", { className: "dph_tag", key: "src" }, t("label.repoSource"))
650
+ : null,
651
+ h("code", { className: "dph_cmd", key: "code" }, item.cmd),
652
+ h("button", {
653
+ className: "dph_btn",
654
+ key: "copy",
655
+ type: "button",
656
+ onClick: () => copyText(item.cmd, () => {
657
+ onCopied(item.cmd);
658
+ // 与站点 InstallBlock 同口径:value 传整条命令
659
+ track("copy_install", { cmd: item.cmd, pkg: item.pkg || "" });
660
+ }),
661
+ }, copiedCmd === item.cmd ? t("action.copied") : t("action.copy")),
662
+ ]),
663
+ item.src === "repo" ? h("div", { className: "dph_instHint", key: "hint" }, t("hint.repoSource")) : null,
664
+ ])
665
+ : h("div", { className: "dph_cmdMissing", key: "cmd" }, t("cmd.missing"));
666
+
667
+ return h("div", { className: "dph_card" + (compact ? " dph_cardSm" : ""), key: item.fullName || title }, [
668
+ h("div", { className: "dph_cardTop", key: "top" }, [
669
+ h("span", { className: "dph_name", key: "name" }, title),
670
+ item.owner ? h("span", { className: "dph_owner", key: "owner" }, "@" + item.owner) : null,
671
+ h("span", { className: "dph_spacer", key: "gap" }),
672
+ h("span", { className: "dph_stars", key: "stars" }, format(t("badge.stars"), { n: item.stars })),
673
+ item.url
674
+ ? h("a", { className: "dph_btn", key: "link", href: item.url, target: "_blank", rel: "noopener noreferrer" }, "GitHub")
675
+ : null,
676
+ ]),
677
+ tags.length ? h("div", { className: "dph_tags", key: "tags" }, tags) : null,
678
+ sellRow,
679
+ descRow,
680
+ h(InstallRow, { item: item, key: "install" }),
681
+ cmdRow,
682
+ ]);
683
+ }
684
+
685
+ function Notice(props) {
686
+ return h("div", { className: "dph_notice" + (props.kind === "bad" ? " dph_noticeBad" : "") }, props.text);
687
+ }
688
+
689
+ function seg(options, current, onPick) {
690
+ return h("div", { className: "dph_seg" }, options.map((option) =>
691
+ h("button", {
692
+ className: "dph_segBtn" + (current === option.value ? " dph_segBtnOn" : ""),
693
+ key: String(option.value),
694
+ type: "button",
695
+ onClick: () => onPick(option.value),
696
+ }, option.label)));
697
+ }
698
+
699
+ function controls(cat) {
700
+ return h("div", { className: "dph_bar", key: "bar" }, [
701
+ h("input", {
702
+ className: "dph_input",
703
+ key: "input",
704
+ type: "search",
705
+ value: cat.keyword,
706
+ placeholder: t("search.placeholder"),
707
+ onChange: (event) => cat.setKeyword(event.target.value),
708
+ }),
709
+ h("span", { key: "sort" }, seg([
710
+ { value: false, label: t("sort.default") },
711
+ { value: true, label: t("sort.stars") },
712
+ ], cat.byStars, cat.setByStars)),
713
+ h("span", { key: "take" }, seg(TAKE_OPTIONS.map((n) => ({ value: n, label: String(n) })), cat.take, cat.setTake)),
714
+ ]);
715
+ }
716
+
717
+ function telemetryToggle() {
718
+ return h("label", { className: "dph_tel", key: "tel" }, [
719
+ h("input", {
720
+ key: "box",
721
+ type: "checkbox",
722
+ checked: hub.telemetry,
723
+ onChange: (event) => {
724
+ const next = event.target.checked;
725
+ try { localStorage.setItem(TEL_KEY, next ? "1" : "0"); } catch (error) { /* ignore */ }
726
+ setHub({ telemetry: next });
727
+ telemetryOn = next;
728
+ },
729
+ }),
730
+ h("span", { key: "label" }, t("telemetry.on")),
731
+ ]);
732
+ }
733
+
734
+ /* −−− 入口 1:会话视图页签(完整页) −−− */
735
+ function CatalogView() {
736
+ const cat = useCatalog(t);
737
+ const data = cat.state.data;
738
+ return h("div", { className: "dph_root" }, [
739
+ h("div", { className: "dph_header", key: "header" }, [
740
+ h("h2", { className: "dph_title", key: "title" }, t("view.title")),
741
+ h("span", { className: "dph_sub", key: "sub" }, data ? format(t("count.summary"), { n: data.count }) + " · " + t("view.sub") : t("view.sub")),
742
+ h("span", { className: "dph_spacer", key: "gap" }),
743
+ h("a", { className: "dph_btn", key: "site", href: SITE + "/plugins", target: "_blank", rel: "noopener noreferrer" }, t("action.openSite")),
744
+ h("button", { className: "dph_btn", key: "refresh", type: "button", onClick: cat.refresh }, t("action.refresh")),
745
+ ]),
746
+ controls(cat),
747
+ cat.state.error ? h(Notice, { key: "error", kind: "bad", text: t("state.failed") + ":" + cat.state.error }) : null,
748
+ cat.state.loading && cat.plugins.length === 0 ? h(Notice, { key: "loading", text: t("state.loading") }) : null,
749
+ !cat.state.loading && !cat.state.error && cat.plugins.length === 0 ? h(Notice, { key: "empty", text: t("state.empty") }) : null,
750
+ h("div", { className: "dph_list", key: "list" }, cat.plugins.map((item) => renderCard(item, cat.copiedCmd, cat.markCopied, false))),
751
+ h("div", { className: "dph_footer", key: "hint" }, t("footer.hint")),
752
+ h("div", { className: "dph_footer", key: "tel" }, telemetryToggle()),
753
+ ]);
754
+ }
755
+
756
+ /* −−− 入口 2:左下常驻按钮 −−− */
757
+ function FooterEntry(props) {
758
+ const wide = props && props.wide;
759
+ const state = useHub();
760
+ return h("div", { className: wide ? "dph_lbWide" : "dph_lbOff" }, [
761
+ h("button", {
762
+ className: "dph_lb",
763
+ key: "button",
764
+ type: "button",
765
+ title: t("action.open"),
766
+ "aria-label": t("action.open"),
767
+ onClick: () => {
768
+ const next = !state.open;
769
+ setHub({ open: next });
770
+ if (next) track("plugin_hub", { action: "open", scope: "footer" });
771
+ },
772
+ }, [
773
+ h("span", { className: "dph_lbMark", key: "mark" }, "严"),
774
+ wide ? h("span", { className: "dph_lbLabel", key: "label" }, t("action.open")) : null,
775
+ ]),
776
+ ]);
777
+ }
778
+
779
+ /* −−− 入口 3:全站浮窗(shell.overlay,跨路由) −−− */
780
+ function OverlayEntry() {
781
+ const state = useHub();
782
+ const cat = useCatalog(t);
783
+ const [pos, setPos] = react.useState(readStoredPos);
784
+ const posRef = react.useRef(pos);
785
+ posRef.current = pos;
786
+ const skipClickRef = react.useRef(false);
787
+ const panelRef = react.useRef(null);
788
+ const onDrag = makeDragHandlers(() => posRef.current, setPos, skipClickRef);
789
+
790
+ /**
791
+ * 展开时按**面板实际尺寸**重新夹取位置。
792
+ *
793
+ * 为什么必须这么做(2026-09-17 拖动验证实测发现):夹取只按胶囊尺寸(150×38)算时,
794
+ * 锚点可以停在很靠上的位置;而面板最高 72vh(约 620px)且以 bottom 定位,
795
+ * 于是面板顶部会**跑到视口外**,标题与上半部分既看不见也点不到
796
+ * (探针表现为 `elementFromPoint` 返回 null、拖动无效)。
797
+ * 用 ResizeObserver 跟随面板高度变化(内容加载会改高度)重新夹取。
798
+ */
799
+ react.useEffect(() => {
800
+ if (!state.open) return undefined;
801
+ const element = panelRef.current;
802
+ if (element === null) return undefined;
803
+ const apply = () => {
804
+ const rect = element.getBoundingClientRect();
805
+ const next = clampPos(posRef.current, { w: rect.width || 400, h: rect.height || 560 }, viewportSize());
806
+ if (next.right !== posRef.current.right || next.bottom !== posRef.current.bottom) {
807
+ setPos(next);
808
+ storePos(next);
809
+ }
810
+ };
811
+ apply();
812
+ if (typeof ResizeObserver === "undefined") return undefined;
813
+ const observer = new ResizeObserver(apply);
814
+ observer.observe(element);
815
+ return () => observer.disconnect();
816
+ }, [state.open]);
817
+
818
+ // 视口变化时把浮窗夹回可见范围(拖动只保证当时合法)
819
+ react.useEffect(() => {
820
+ const onResize = () => {
821
+ const next = clampPos(posRef.current, { w: 150, h: 38 }, viewportSize());
822
+ if (next.right !== posRef.current.right || next.bottom !== posRef.current.bottom) {
823
+ setPos(next);
824
+ storePos(next);
825
+ }
826
+ };
827
+ window.addEventListener("resize", onResize);
828
+ return () => window.removeEventListener("resize", onResize);
829
+ }, []);
830
+
831
+ const anchor = { right: pos.right + "px", bottom: pos.bottom + "px" };
832
+
833
+ if (!state.open) {
834
+ return h("div", { className: "dph_ovWrap", style: anchor }, [
835
+ h("button", {
836
+ className: "dph_pill",
837
+ key: "pill",
838
+ type: "button",
839
+ title: t("action.open") + " · " + t("action.drag"),
840
+ "aria-label": t("action.open"),
841
+ onPointerDown: onDrag,
842
+ onClick: () => {
843
+ // 拖动结束会跟一个 click,用标记跳过,避免"拖一下就打开"
844
+ if (skipClickRef.current) {
845
+ skipClickRef.current = false;
846
+ return;
847
+ }
848
+ setHub({ open: true });
849
+ track("plugin_hub", { action: "open", scope: "overlay" });
850
+ },
851
+ }, [
852
+ h("span", { className: "dph_pillMark", key: "mark" }, "严"),
853
+ h("span", { key: "label" }, t("action.open")),
854
+ ]),
855
+ ]);
856
+ }
857
+ const data = cat.state.data;
858
+ return h("div", { className: "dph_ovWrap", style: anchor }, [
859
+ h("div", { className: "dph_panel", key: "panel", ref: panelRef }, [
860
+ h("div", { className: "dph_panelHead", key: "head" }, [
861
+ h("span", {
862
+ className: "dph_panelTitle dph_drag",
863
+ key: "title",
864
+ title: t("action.drag"),
865
+ onPointerDown: onDrag,
866
+ }, t("view.title")),
867
+ h("span", { className: "dph_sub", key: "sub" }, data ? format(t("count.summary"), { n: data.count }) : ""),
868
+ h("span", { className: "dph_spacer", key: "gap" }),
869
+ h("a", { className: "dph_btn", key: "site", href: SITE + "/plugins", target: "_blank", rel: "noopener noreferrer", onClick: () => track("plugin_hub", { action: "site_click", scope: "overlay" }) }, t("action.openSite")),
870
+ h("button", { className: "dph_close", key: "close", type: "button", title: t("action.close"), "aria-label": t("action.close"), onClick: () => setHub({ open: false }) }, "×"),
871
+ ]),
872
+ controls(cat),
873
+ cat.state.error ? h(Notice, { key: "error", kind: "bad", text: t("state.failed") + ":" + cat.state.error }) : null,
874
+ cat.state.loading && cat.plugins.length === 0 ? h(Notice, { key: "loading", text: t("state.loading") }) : null,
875
+ !cat.state.loading && !cat.state.error && cat.plugins.length === 0 ? h(Notice, { key: "empty", text: t("state.empty") }) : null,
876
+ h("div", { className: "dph_panelBody", key: "body" }, cat.plugins.map((item) => renderCard(item, cat.copiedCmd, cat.markCopied, true))),
877
+ h("div", { className: "dph_panelFoot", key: "foot" }, [
878
+ telemetryToggle(),
879
+ h("span", { key: "hint", className: "dph_footer" }, t("footer.hint")),
880
+ ]),
881
+ ]),
882
+ ]);
883
+ }
884
+
885
+ return { CatalogView, FooterEntry, OverlayEntry };
886
+ }
887
+
888
+ /* ---------------------------------------------------------------- plugin */
889
+ const inject = ["slots", "locale"];
890
+ function apply(ctx) {
891
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dpharness: dictionaries");
892
+ const t = ctx.locale.bind(NS);
893
+ const ui = makeUi(t);
894
+
895
+ ctx.slots.inject(
896
+ "conversation.view",
897
+ () =>
898
+ ctx.slots.register(
899
+ { name: "conversation.view", id: "dpharness", order: 20, locale: NS, label: () => t("view.tab"), inject: () => ({}) },
900
+ ui.CatalogView,
901
+ ),
902
+ );
903
+
904
+ ctx.slots.inject(
905
+ "sidebar.footer.action",
906
+ () =>
907
+ ctx.slots.register(
908
+ { name: "sidebar.footer.action", id: "dpharness", order: 12, locale: NS, label: () => t("action.open"), inject: () => ({}) },
909
+ ui.FooterEntry,
910
+ ),
911
+ );
912
+
913
+ ctx.slots.inject(
914
+ "shell.overlay",
915
+ () =>
916
+ ctx.slots.register(
917
+ { name: "shell.overlay", id: "dpharness", order: 12, locale: NS, label: () => t("action.open"), inject: () => ({}) },
918
+ ui.OverlayEntry,
919
+ ),
920
+ );
921
+ }
922
+
923
+ exports.NS = NS;
924
+ exports.apply = apply;
925
+ exports.inject = inject;
926
+ /* 供测试使用:位置夹取是纯函数,单独导出便于断言边界 */
927
+ exports.clampPos = clampPos;
928
+ return module.exports;
929
+ },
930
+ });