dsh-plugin 1.3.2 → 1.3.4
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/client/client.js +627 -319
- package/client/client.js.map +1 -1
- package/lib/http/routes.js +83 -11
- package/lib/services/install/npm-check.js +2 -1
- package/lib/services/log.js +12 -0
- package/lib/types/services/log.d.ts +2 -0
- package/package.json +1 -1
- package/src/client/components/PluginHubSection.tsx +16 -6
- package/src/client/components/modals/ConfirmDialog.tsx +73 -0
- package/src/client/components/modals/LogsModal.tsx +49 -4
- package/src/client/components/modals/NotificationsModal.tsx +29 -4
- package/src/client/components/modals/modals.tsx +13 -1
- package/src/client/components/ui/icons.tsx +48 -0
- package/src/client/components/views/LogsView.tsx +1 -1
- package/src/client/components/views/SettingsView.tsx +5 -1
- package/src/client/data/catalog.ts +8 -7
- package/src/client/hooks/useCatalog.ts +37 -5
- package/src/client/locales.ts +22 -2
- package/src/client/logic/ensure-plugin-css.ts +42 -0
- package/src/client/styles/LogsView.module.css +29 -8
- package/src/client/styles/Modal.module.css +77 -0
- package/src/server/http/routes.ts +82 -11
- package/src/server/services/install/npm-check.ts +2 -1
- package/src/server/services/log.ts +12 -0
package/lib/http/routes.js
CHANGED
|
@@ -19,7 +19,62 @@ import { resolveNpmPackage } from '../services/install/npm-resolve.js';
|
|
|
19
19
|
import { preflightTarget } from '../services/install/preflight.js';
|
|
20
20
|
import { isDshPlugin, isEntryLoaded } from '../services/loader.js';
|
|
21
21
|
import { loadSettings, saveSettings, resetSettings } from '../services/settings.js';
|
|
22
|
-
import { appendLog, readLog, logFilePath, defaultLogFilePath, customLogFile } from '../services/log.js';
|
|
22
|
+
import { appendLog, clearLog, readLog, logFilePath, defaultLogFilePath, customLogFile } from '../services/log.js';
|
|
23
|
+
/**
|
|
24
|
+
* 跨平台宿主重启脚本(以 `node -e` 运行,独立于宿主进程)。
|
|
25
|
+
* 取代原先的 `/bin/sh -c`:Windows 无 /bin/sh,且 lsof/nohup 仅 POSIX 存在。
|
|
26
|
+
* 由 `spawn(process.execPath, ['-e', SCRIPT, port], { detached, stdio:'ignore' })` 孵化,
|
|
27
|
+
* 宿主进程被 kill 后仍能完成「停旧 → 等端口释放 → 拉起新 dsh web」。
|
|
28
|
+
*/
|
|
29
|
+
const HOST_RESTART_SCRIPT = String.raw `const C = require('node:child_process')
|
|
30
|
+
const F = require('node:fs')
|
|
31
|
+
const P = require('node:path')
|
|
32
|
+
const O = require('node:os')
|
|
33
|
+
const win = process.platform === 'win32'
|
|
34
|
+
const port = Number(process.argv[1])
|
|
35
|
+
|
|
36
|
+
function findPids() {
|
|
37
|
+
if (win) {
|
|
38
|
+
const out = C.spawnSync('netstat', ['-ano', '-p', 'tcp'], { encoding: 'utf8' })
|
|
39
|
+
const found = []
|
|
40
|
+
for (const line of String(out.stdout || '').split(/\r?\n/)) {
|
|
41
|
+
if (!/LISTENING/i.test(line)) continue
|
|
42
|
+
const parts = line.trim().split(/\s+/)
|
|
43
|
+
const addr = parts[1] || ''
|
|
44
|
+
if (!addr.endsWith(':' + port)) continue
|
|
45
|
+
const pid = parts[parts.length - 1]
|
|
46
|
+
if (pid && /\d+/.test(pid)) found.push(pid)
|
|
47
|
+
}
|
|
48
|
+
return found
|
|
49
|
+
}
|
|
50
|
+
const out = C.spawnSync('lsof', ['-ti', 'tcp:' + port, '-sTCP:LISTEN'], { encoding: 'utf8' })
|
|
51
|
+
return String(out.stdout || '').split(/\s+/).filter(Boolean)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const pid of findPids()) {
|
|
55
|
+
try {
|
|
56
|
+
if (win) C.spawnSync('taskkill', ['/pid', pid, '/t', '/f'])
|
|
57
|
+
else process.kill(Number(pid), 'SIGTERM')
|
|
58
|
+
} catch {}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let tries = 0
|
|
62
|
+
;(function waitForStop() {
|
|
63
|
+
if (findPids().length === 0 || ++tries > 20) return startHost()
|
|
64
|
+
setTimeout(waitForStop, 250)
|
|
65
|
+
})()
|
|
66
|
+
|
|
67
|
+
function startHost() {
|
|
68
|
+
const dir = P.join(O.homedir(), '.dsh', 'logs')
|
|
69
|
+
F.mkdirSync(dir, { recursive: true })
|
|
70
|
+
const fd = F.openSync(P.join(dir, 'dsh-web-' + port + '.log'), 'a')
|
|
71
|
+
const child = C.spawn('dsh', ['web', '--port', String(port)], {
|
|
72
|
+
detached: true,
|
|
73
|
+
shell: win,
|
|
74
|
+
stdio: ['ignore', fd, fd],
|
|
75
|
+
})
|
|
76
|
+
child.unref()
|
|
77
|
+
}`;
|
|
23
78
|
const PROFILE_RE = /^[A-Za-z0-9_-]+$/;
|
|
24
79
|
const BODY_LIMIT_BYTES = 4 * 1024;
|
|
25
80
|
const COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
|
|
@@ -311,6 +366,27 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
311
366
|
}
|
|
312
367
|
},
|
|
313
368
|
}),
|
|
369
|
+
webServer.register({
|
|
370
|
+
kind: 'exact',
|
|
371
|
+
path: '/dsh-plugin-hub/clear-log',
|
|
372
|
+
handler: (request, response) => {
|
|
373
|
+
if (!requireTrustedPost(request, response))
|
|
374
|
+
return;
|
|
375
|
+
// 清空所有日志:截断当前日志文件为空
|
|
376
|
+
try {
|
|
377
|
+
const ok = clearLog(profile);
|
|
378
|
+
if (!ok) {
|
|
379
|
+
sendJson(response, 500, { error: 'failed to clear log' });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
appendLog(profile, { at: Date.now(), level: 'info', category: 'system', event: 'system.clear', message: '日志已由用户清空' });
|
|
383
|
+
sendJson(response, 200, { ok: true });
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
sendJson(response, 500, { error: 'failed to clear log' });
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
}),
|
|
314
390
|
webServer.register({
|
|
315
391
|
kind: 'exact',
|
|
316
392
|
path: '/dsh-plugin-hub/open-log',
|
|
@@ -783,16 +859,12 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
783
859
|
const host = request.headers.host ?? '';
|
|
784
860
|
const portMatch = host.match(/:(\d+)$/);
|
|
785
861
|
const port = portMatch ? Number(portMatch[1]) : 7923;
|
|
786
|
-
// detached
|
|
787
|
-
// 即使当前宿主(即本插件所在进程)被 kill
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
'mkdir -p "$HOME/.dsh/logs"',
|
|
793
|
-
`nohup dsh web --port ${port} >>"$HOME/.dsh/logs/dsh-web-${port}.log" 2>&1 &`,
|
|
794
|
-
].join('\n');
|
|
795
|
-
spawn('/bin/sh', ['-c', script], { detached: true, stdio: 'ignore' }).unref();
|
|
862
|
+
// detached 子进程完成「停旧进程 → 等端口释放 → 后台拉起 dsh web」,
|
|
863
|
+
// 即使当前宿主(即本插件所在进程)被 kill,重启仍会继续执行。
|
|
864
|
+
// 不用 /bin/sh:Windows 无此路径,且 lsof/nohup 等仅 POSIX 存在,
|
|
865
|
+
// 故孵化 node(process.execPath) 跑跨平台脚本 —— Windows 用
|
|
866
|
+
// netstat + taskkill + shell:true 解析 dsh.cmd,POSIX 用 lsof + SIGTERM。
|
|
867
|
+
spawn(process.execPath, ['-e', HOST_RESTART_SCRIPT, String(port)], { detached: true, stdio: 'ignore' }).unref();
|
|
796
868
|
sendJson(response, 200, { ok: true, port });
|
|
797
869
|
},
|
|
798
870
|
}),
|
|
@@ -21,7 +21,8 @@ export const NPM_MIN_VERSION = [11, 6, 0];
|
|
|
21
21
|
/** 读本机 npm 版本(major.minor.patch);npm 不在 PATH 或执行失败/超时时返回 null(不妄下结论)。 */
|
|
22
22
|
export function npmVersionOf(env) {
|
|
23
23
|
try {
|
|
24
|
-
|
|
24
|
+
// Windows 下 npm 是 npm.cmd:spawn 直接 exec 会拒绝,须 shell:true 解析(与 task-queue 的 useShell 一致)
|
|
25
|
+
const res = spawnSync('npm', ['-v'], { env, encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32' });
|
|
25
26
|
if (res.error !== undefined || res.status !== 0)
|
|
26
27
|
return null;
|
|
27
28
|
const m = /(\d+)\.(\d+)\.(\d+)/.exec(res.stdout ?? '');
|
package/lib/services/log.js
CHANGED
|
@@ -68,6 +68,18 @@ export function appendLog(profile, entry) {
|
|
|
68
68
|
// 日志失败不阻断主流程
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
|
+
/** 清空系统日志:把当前日志文件截断为空。失败返回 false(不阻断调用方)。 */
|
|
72
|
+
export function clearLog(profile) {
|
|
73
|
+
try {
|
|
74
|
+
const file = logFilePath(profile);
|
|
75
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
76
|
+
writeFileSync(file, '');
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
71
83
|
/** 兼容旧格式:早期无 category(level 用 info/ok/fail/warn),归一化到新版。 */
|
|
72
84
|
function normalize(raw) {
|
|
73
85
|
if (typeof raw !== 'object' || raw === null)
|
|
@@ -45,5 +45,7 @@ export declare function defaultLogFilePath(profile: string): string;
|
|
|
45
45
|
export declare function logFilePath(profile: string): string;
|
|
46
46
|
/** 追加一条系统日志(JSONL 一行一条)。 */
|
|
47
47
|
export declare function appendLog(profile: string, entry: LogEntry): void;
|
|
48
|
+
/** 清空系统日志:把当前日志文件截断为空。失败返回 false(不阻断调用方)。 */
|
|
49
|
+
export declare function clearLog(profile: string): boolean;
|
|
48
50
|
/** 读取系统日志(按时间正序,最新在末尾),超上限就地裁剪;支持过滤与分页。 */
|
|
49
51
|
export declare function readLog(profile: string, opts?: ReadLogOptions): ReadLogResult;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.4",
|
|
4
4
|
"description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install 4000+ human-curated community plugins without leaving the app. · DeepSeek Harness 社区插件市场(遵循官方开发规范):4000+ 人工精选社区插件,每日更新。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -15,6 +15,7 @@ import modalStyles from '../styles/Modal.module.css'
|
|
|
15
15
|
import type { EnvInfo, HubPlugin, SectionProps, ToastState } from '../types.ts'
|
|
16
16
|
import { installCommandOf, repoFromInstallTarget } from '../logic/install-command.ts'
|
|
17
17
|
import { PLUGIN_VERSION } from '../logic/constants.ts'
|
|
18
|
+
import { ensurePluginCss } from '../logic/ensure-plugin-css.ts'
|
|
18
19
|
import { getEnv, revealInstallFolder } from '../data/host.ts'
|
|
19
20
|
import { useCatalog } from '../hooks/useCatalog.ts'
|
|
20
21
|
import { useLanguage } from '../hooks/useLanguage.ts'
|
|
@@ -34,7 +35,7 @@ import type { SectionView } from './layout/SectionTabs.tsx'
|
|
|
34
35
|
import { InstalledView } from './views/InstalledView.tsx'
|
|
35
36
|
import { CustomInstallView } from './views/CustomInstallView.tsx'
|
|
36
37
|
import { SettingsView } from './views/SettingsView.tsx'
|
|
37
|
-
import { CloseIcon } from './ui/icons.tsx'
|
|
38
|
+
import { CloseIcon, ConfirmIcon } from './ui/icons.tsx'
|
|
38
39
|
import type { InstalledItem } from '../logic/installed.ts'
|
|
39
40
|
import { issueRepoOf, pluginOfItem } from '../logic/installed.ts'
|
|
40
41
|
|
|
@@ -99,6 +100,10 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
99
100
|
return () => { alive = false }
|
|
100
101
|
}, [])
|
|
101
102
|
|
|
103
|
+
// 样式自愈:宿主重启/热更可能移除 <style> 标签而模块缓存未失效(factory 不重跑、CSS 不回来)。
|
|
104
|
+
// 挂载与每次切换视图时比对全局 CSS 清单,把缺失的样式补注入回去 —— 用户无需再手动点击恢复。
|
|
105
|
+
useEffect(() => { ensurePluginCss() }, [view])
|
|
106
|
+
|
|
102
107
|
const queue = useTaskQueue({
|
|
103
108
|
t,
|
|
104
109
|
refreshInstalled: catalog.refreshInstalled,
|
|
@@ -509,7 +514,7 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
509
514
|
onIgnoreUpdate: (repo: string, version?: string) => setIgnoreTarget({ repo, version }),
|
|
510
515
|
cancelTask: queue.cancelTask,
|
|
511
516
|
restarting,
|
|
512
|
-
onRestart: () =>
|
|
517
|
+
onRestart: () => setShowRestartConfirm(true),
|
|
513
518
|
// 失败记录仓库反查:历史记录可能存的是裸 npm 包名,反查成 owner/repo 才显示仓库链接
|
|
514
519
|
// 与提 Issue 按钮;查不到则隐藏(防提到错误仓库)
|
|
515
520
|
resolveRepo: (repo: string) => issueRepoOf(repo, catalog.installedItems, catalog.plugins),
|
|
@@ -531,6 +536,10 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
531
536
|
}, h(CloseIcon)),
|
|
532
537
|
),
|
|
533
538
|
h('div', { className: modalStyles.modalBody },
|
|
539
|
+
// 忽略更新确认:品牌蓝问号图标,普通确认
|
|
540
|
+
h('div', { className: modalStyles.confirmIconWrap },
|
|
541
|
+
h(ConfirmIcon, { type: 'question' }),
|
|
542
|
+
),
|
|
534
543
|
h('div', { className: modalStyles.failPrepareHint }, t('ignoreUpdateConfirmDetail')),
|
|
535
544
|
h('div', { className: modalStyles.modalActions },
|
|
536
545
|
h('button', {
|
|
@@ -567,7 +576,8 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
567
576
|
onClose: () => setConfirmPlugin(null),
|
|
568
577
|
onCopy: () => copyCommand(confirmPlugin),
|
|
569
578
|
onInstall: () => queue.installNow(confirmPlugin, confirmIsUpdate ? { update: true } : undefined),
|
|
570
|
-
|
|
579
|
+
// 重启:结果视图「立即重启」也会中断进行中的任务 → 先弹重启确认弹窗
|
|
580
|
+
onRestart: () => setShowRestartConfirm(true),
|
|
571
581
|
}),
|
|
572
582
|
// 命令行安装确认弹窗:与目录安装同一套确认/进度/结果流程(plugin 传 null 走 customTarget 模式)。
|
|
573
583
|
// 已安装目标 → update 标记,确认后走覆盖重装(服务端 mode=update 放行),不再 409 报错。
|
|
@@ -592,7 +602,7 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
592
602
|
setToast({ id: Date.now(), kind: 'copied' })
|
|
593
603
|
},
|
|
594
604
|
onInstall: () => void queue.installCustom(confirmCustomTarget, confirmIsUpdate ? { update: true } : undefined),
|
|
595
|
-
onRestart: () =>
|
|
605
|
+
onRestart: () => setShowRestartConfirm(true),
|
|
596
606
|
}),
|
|
597
607
|
// 全局 npm 安装确认弹窗(官方 `npm install -g <pkgs>`):与应用商店同一套确认/进度/结果流程。
|
|
598
608
|
// 装的是系统级 CLI 工具(如 @deepseek-ai/dsh),不进任何 profile、无需宿主重启;
|
|
@@ -618,7 +628,7 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
618
628
|
setToast({ id: Date.now(), kind: 'copied' })
|
|
619
629
|
},
|
|
620
630
|
onInstall: () => void queue.installGlobalNpm(confirmGlobalNpm.pkgs),
|
|
621
|
-
onRestart: () =>
|
|
631
|
+
onRestart: () => setShowRestartConfirm(true),
|
|
622
632
|
}),
|
|
623
633
|
// 卸载确认弹窗:确认/进度/结果视图逻辑收敛在 modals.tsx。
|
|
624
634
|
// 已安装视图的自定义安装(目录外)没有完整目录数据,按已安装项伪插件适配后走同一弹窗
|
|
@@ -645,7 +655,7 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
645
655
|
if (uninstallItem) void queue.uninstallItem(uninstallItem)
|
|
646
656
|
else if (uninstallPlugin) void queue.uninstallNow(uninstallPlugin)
|
|
647
657
|
},
|
|
648
|
-
onRestart: () =>
|
|
658
|
+
onRestart: () => setShowRestartConfirm(true),
|
|
649
659
|
}),
|
|
650
660
|
// 已安装详情弹窗:目录元数据 + 宿主运行时信息的完整展示,操作(更新/卸载/复制路径)由此发起
|
|
651
661
|
detailItem && h(InstalledDetailModal, {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH Plugin Hub — the community plugin marketplace for DeepSeek Harness.
|
|
3
|
+
* Website: https://dsh-plugin.org
|
|
4
|
+
* GitHub: https://github.com/dshplugin/dsh-plugin-hub
|
|
5
|
+
*
|
|
6
|
+
* Generic confirm dialog — the single entry point for every "are you sure?"
|
|
7
|
+
* question in the app. Rule: any question must be a modal dialog, never an
|
|
8
|
+
* in-button double-click.
|
|
9
|
+
*
|
|
10
|
+
* The icon in the middle tells the type at a glance: a blue question mark
|
|
11
|
+
* for routine confirmations (install / update / restart / ignore), a red
|
|
12
|
+
* action glyph for destructive ones (clear / delete / uninstall / reset).
|
|
13
|
+
*/
|
|
14
|
+
import { createElement as h } from 'react'
|
|
15
|
+
import type { MouseEvent } from 'react'
|
|
16
|
+
import styles from '../../styles/Modal.module.css'
|
|
17
|
+
import type { Translate } from '../../types.ts'
|
|
18
|
+
import { CloseIcon, ConfirmIcon, type ConfirmIconType } from '../ui/icons.tsx'
|
|
19
|
+
|
|
20
|
+
/** 通用确认弹窗:标题 + 类型图标 + 描述 + 取消/确认按钮。
|
|
21
|
+
* type='question' → 蓝问号 + 品牌蓝确认按钮;危险类型 → 红图形 + 红色确认按钮。 */
|
|
22
|
+
export function ConfirmDialog({ type, title, desc, confirmLabel, cancelLabel, busy, busyLabel, t, onConfirm, onCancel }: {
|
|
23
|
+
type: ConfirmIconType
|
|
24
|
+
title: string
|
|
25
|
+
desc?: string
|
|
26
|
+
confirmLabel: string
|
|
27
|
+
cancelLabel?: string
|
|
28
|
+
/** 确认动作在途:禁用按钮防止二次点击 */
|
|
29
|
+
busy?: boolean
|
|
30
|
+
/** 在途时确认按钮文案(如「清空中…」);缺省沿用 confirmLabel */
|
|
31
|
+
busyLabel?: string
|
|
32
|
+
t: Translate
|
|
33
|
+
onConfirm: () => void
|
|
34
|
+
onCancel: () => void
|
|
35
|
+
}) {
|
|
36
|
+
const danger = type !== 'question'
|
|
37
|
+
return h('div', {
|
|
38
|
+
className: styles.overlay,
|
|
39
|
+
onClick: (e: MouseEvent<HTMLDivElement>) => { if (e.target === e.currentTarget) onCancel() },
|
|
40
|
+
},
|
|
41
|
+
h('div', { className: styles.modal, role: 'dialog', 'aria-modal': 'true' },
|
|
42
|
+
h('div', { className: styles.modalHead },
|
|
43
|
+
h('div', { className: styles.modalTitle }, title),
|
|
44
|
+
h('button', {
|
|
45
|
+
className: styles.modalClose,
|
|
46
|
+
type: 'button',
|
|
47
|
+
'aria-label': t('errorClose'),
|
|
48
|
+
onClick: onCancel,
|
|
49
|
+
}, h(CloseIcon)),
|
|
50
|
+
),
|
|
51
|
+
h('div', { className: styles.modalBody },
|
|
52
|
+
h('div', { className: styles.confirmIconWrap },
|
|
53
|
+
h(ConfirmIcon, { type }),
|
|
54
|
+
),
|
|
55
|
+
desc ? h('div', { className: styles.modalDesc }, desc) : null,
|
|
56
|
+
h('div', { className: styles.modalActions },
|
|
57
|
+
h('button', {
|
|
58
|
+
className: styles.restartLater,
|
|
59
|
+
type: 'button',
|
|
60
|
+
disabled: busy,
|
|
61
|
+
onClick: onCancel,
|
|
62
|
+
}, cancelLabel ?? t('confirmCancel')),
|
|
63
|
+
h('button', {
|
|
64
|
+
className: danger ? styles.dangerConfirm : styles.confirmPrimary,
|
|
65
|
+
type: 'button',
|
|
66
|
+
disabled: busy,
|
|
67
|
+
onClick: onConfirm,
|
|
68
|
+
}, busy && busyLabel ? busyLabel : confirmLabel),
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -25,6 +25,7 @@ import view from '../../styles/LogsView.module.css'
|
|
|
25
25
|
import type { Translate } from '../../types.ts'
|
|
26
26
|
import { openLogFile } from '../../data/host.ts'
|
|
27
27
|
import { CloseIcon } from '../ui/icons.tsx'
|
|
28
|
+
import { ConfirmDialog } from './ConfirmDialog.tsx'
|
|
28
29
|
|
|
29
30
|
/** 与服务端 /logs 返回的 LogEntry 对齐的客户端投影。 */
|
|
30
31
|
export interface LogEntryView {
|
|
@@ -120,6 +121,9 @@ export function LogsModal({ t, onClose }: {
|
|
|
120
121
|
const [error, setError] = useState(false)
|
|
121
122
|
const [opening, setOpening] = useState(false)
|
|
122
123
|
const [openFailed, setOpenFailed] = useState(false)
|
|
124
|
+
const [showClearConfirm, setShowClearConfirm] = useState(false)
|
|
125
|
+
const [clearing, setClearing] = useState(false)
|
|
126
|
+
const [clearFailed, setClearFailed] = useState(false)
|
|
123
127
|
const offsetRef = useRef(0)
|
|
124
128
|
|
|
125
129
|
/** 拉一页:过滤参数取当前 state,偏移量由调用方给出。 */
|
|
@@ -195,6 +199,24 @@ export function LogsModal({ t, onClose }: {
|
|
|
195
199
|
if (!ok) setOpenFailed(true)
|
|
196
200
|
}
|
|
197
201
|
|
|
202
|
+
/** 清空所有日志:确认弹窗(独立弹出框,垃圾桶图标)确认后执行。 */
|
|
203
|
+
const doClear = async () => {
|
|
204
|
+
setClearing(true)
|
|
205
|
+
try {
|
|
206
|
+
const res = await fetch('/dsh-plugin-hub/clear-log', { method: 'POST', cache: 'no-store' })
|
|
207
|
+
if (!res.ok) {
|
|
208
|
+
setClearFailed(true)
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
await reload()
|
|
212
|
+
} catch {
|
|
213
|
+
setClearFailed(true)
|
|
214
|
+
} finally {
|
|
215
|
+
setClearing(false)
|
|
216
|
+
setShowClearConfirm(false)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
198
220
|
/** 导出:拼纯文本 → Blob → 触发浏览器下载 .log 文件 */
|
|
199
221
|
const exportFile = () => {
|
|
200
222
|
const text = entries.map((e) => `[${fmtLogTime(e.at)}] [${e.level}] [${e.category}] [${e.event}] ${e.message}`).join('\n')
|
|
@@ -219,6 +241,15 @@ export function LogsModal({ t, onClose }: {
|
|
|
219
241
|
h('div', { className: `${styles.errorModal} ${styles.logModal}`, role: 'dialog', 'aria-modal': 'true' },
|
|
220
242
|
h('div', { className: styles.modalHead },
|
|
221
243
|
h('div', { className: styles.modalTitle }, t('logModalTitle')),
|
|
244
|
+
h('div', { className: view.headActions },
|
|
245
|
+
h('button', {
|
|
246
|
+
type: 'button',
|
|
247
|
+
className: view.clearBtn,
|
|
248
|
+
disabled: clearing,
|
|
249
|
+
title: t('logClearAllTip'),
|
|
250
|
+
onClick: () => setShowClearConfirm(true),
|
|
251
|
+
}, t('logClearAll')),
|
|
252
|
+
),
|
|
222
253
|
h('button', {
|
|
223
254
|
className: styles.modalClose,
|
|
224
255
|
'aria-label': t('errorClose'),
|
|
@@ -277,9 +308,11 @@ export function LogsModal({ t, onClose }: {
|
|
|
277
308
|
h('div', { className: view.foot },
|
|
278
309
|
h('span', { className: view.footPath, title: path },
|
|
279
310
|
`${t('logPathLabel')}: ${path === '' ? '…' : path}`),
|
|
280
|
-
|
|
281
|
-
? h('span', { className: view.footFail }, t('
|
|
282
|
-
:
|
|
311
|
+
clearFailed
|
|
312
|
+
? h('span', { className: view.footFail }, t('logClearFailed'))
|
|
313
|
+
: openFailed
|
|
314
|
+
? h('span', { className: view.footFail }, t('logOpenFileFail'))
|
|
315
|
+
: h('span', { className: view.footCount }, t('logCount', { n: total })),
|
|
283
316
|
h('div', { className: view.footActions },
|
|
284
317
|
h('button', {
|
|
285
318
|
type: 'button',
|
|
@@ -290,12 +323,24 @@ export function LogsModal({ t, onClose }: {
|
|
|
290
323
|
}, t('logOpenFile')),
|
|
291
324
|
h('button', {
|
|
292
325
|
type: 'button',
|
|
293
|
-
className:
|
|
326
|
+
className: view.btn,
|
|
294
327
|
disabled: entries.length === 0,
|
|
295
328
|
onClick: exportFile,
|
|
296
329
|
}, t('logsExport')),
|
|
297
330
|
),
|
|
298
331
|
),
|
|
299
332
|
),
|
|
333
|
+
// 清空日志确认弹窗:独立弹出框(垃圾桶图标,危险操作),确认后才真正清空
|
|
334
|
+
showClearConfirm && h(ConfirmDialog, {
|
|
335
|
+
type: 'trash',
|
|
336
|
+
title: t('logClearConfirmTitle'),
|
|
337
|
+
desc: t('logClearConfirmDesc'),
|
|
338
|
+
confirmLabel: t('logClearAll'),
|
|
339
|
+
busy: clearing,
|
|
340
|
+
busyLabel: t('logClearing'),
|
|
341
|
+
t,
|
|
342
|
+
onConfirm: () => void doClear(),
|
|
343
|
+
onCancel: () => setShowClearConfirm(false),
|
|
344
|
+
}),
|
|
300
345
|
)
|
|
301
346
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* failures keep their copy / fix / file-an-issue actions. Opened from the
|
|
14
14
|
* header entry button (after the Settings tab).
|
|
15
15
|
*/
|
|
16
|
-
import { createElement as h } from 'react'
|
|
16
|
+
import { createElement as h, useState } from 'react'
|
|
17
17
|
import type { MouseEvent } from 'react'
|
|
18
18
|
import styles from '../../styles/Modal.module.css'
|
|
19
19
|
import type { EnvInfo, Translate } from '../../types.ts'
|
|
@@ -22,6 +22,7 @@ import { classifyFailure, npmTooLowVersion } from '../../logic/failures.ts'
|
|
|
22
22
|
import type { PendingRestart, QueueTask } from '../../hooks/useTaskQueue.ts'
|
|
23
23
|
import { pluginIssueUrl, pluginSiteUrl } from '../../logic/urls.ts'
|
|
24
24
|
import { CloseIcon } from '../ui/icons.tsx'
|
|
25
|
+
import { ConfirmDialog } from './ConfirmDialog.tsx'
|
|
25
26
|
|
|
26
27
|
/** 记录时间完整展示:YYYY-MM-DD HH:mm:ss(每条通知都带精确到秒的时间戳)。 */
|
|
27
28
|
function fmtTime(at: number): string {
|
|
@@ -79,6 +80,10 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
|
|
|
79
80
|
* 查不到返回 null,按钮与仓库链接一并隐藏(防提到错误仓库)。不传则按记录原样展示。 */
|
|
80
81
|
resolveRepo?: (repo: string) => string | null
|
|
81
82
|
}) {
|
|
83
|
+
// 危险操作确认:清空全部 / 删除单条都必须先弹独立确认框(垃圾桶图标),禁止按钮内直接生效
|
|
84
|
+
const [confirmClear, setConfirmClear] = useState(false)
|
|
85
|
+
const [confirmRemoveId, setConfirmRemoveId] = useState<number | null>(null)
|
|
86
|
+
|
|
82
87
|
return h('div', {
|
|
83
88
|
className: styles.overlay,
|
|
84
89
|
onClick: (e: MouseEvent<HTMLDivElement>) => {
|
|
@@ -234,12 +239,12 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
|
|
|
234
239
|
onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onIgnoreUpdate(r.repo, r.version) },
|
|
235
240
|
}, t('ignoreUpdateRun'))
|
|
236
241
|
: null,
|
|
237
|
-
//
|
|
242
|
+
// 每条通知右侧的删除按钮:先弹确认框,确认后移除这一条
|
|
238
243
|
h('button', {
|
|
239
244
|
className: styles.noticeRemove,
|
|
240
245
|
'aria-label': t('removeNotification'),
|
|
241
246
|
title: t('removeNotification'),
|
|
242
|
-
onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation();
|
|
247
|
+
onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); setConfirmRemoveId(r.id) },
|
|
243
248
|
}, h(CloseIcon)),
|
|
244
249
|
),
|
|
245
250
|
// 每条失败记录是一个独立卡片:头部(状态/仓库/时间/复制完整日志)→ 修复或提 Issue 动作。
|
|
@@ -294,11 +299,31 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
|
|
|
294
299
|
h('div', { className: styles.modalActions },
|
|
295
300
|
records.length > 0 ? h('button', {
|
|
296
301
|
className: styles.failClear,
|
|
297
|
-
onClick:
|
|
302
|
+
onClick: () => setConfirmClear(true),
|
|
298
303
|
}, t('notificationsClear')) : null,
|
|
299
304
|
h('button', { className: styles.restartNow, onClick: onClose }, t('errorClose')),
|
|
300
305
|
),
|
|
301
306
|
),
|
|
302
307
|
),
|
|
308
|
+
// 清空全部通知确认弹窗:独立弹出框(垃圾桶图标,危险操作),确认后才真正清空
|
|
309
|
+
confirmClear && h(ConfirmDialog, {
|
|
310
|
+
type: 'trash',
|
|
311
|
+
title: t('notificationsClearConfirmTitle'),
|
|
312
|
+
desc: t('notificationsClearConfirmDesc'),
|
|
313
|
+
confirmLabel: t('notificationsClear'),
|
|
314
|
+
t,
|
|
315
|
+
onConfirm: () => { setConfirmClear(false); onClear() },
|
|
316
|
+
onCancel: () => setConfirmClear(false),
|
|
317
|
+
}),
|
|
318
|
+
// 删除单条通知确认弹窗:独立弹出框(垃圾桶图标),确认后才移除
|
|
319
|
+
confirmRemoveId !== null && h(ConfirmDialog, {
|
|
320
|
+
type: 'trash',
|
|
321
|
+
title: t('removeNotificationConfirmTitle'),
|
|
322
|
+
desc: t('removeNotificationConfirmDesc'),
|
|
323
|
+
confirmLabel: t('removeNotification'),
|
|
324
|
+
t,
|
|
325
|
+
onConfirm: () => { onRemove(confirmRemoveId); setConfirmRemoveId(null) },
|
|
326
|
+
onCancel: () => setConfirmRemoveId(null),
|
|
327
|
+
}),
|
|
303
328
|
)
|
|
304
329
|
}
|
|
@@ -12,7 +12,7 @@ import { createElement as h } from 'react'
|
|
|
12
12
|
import type { MouseEvent } from 'react'
|
|
13
13
|
import styles from '../../styles/Modal.module.css'
|
|
14
14
|
import type { EnvInfo, HubPlugin, TaskState, ToastState, Translate } from '../../types.ts'
|
|
15
|
-
import { CloseIcon, CopyIcon, LinkIcon } from '../ui/icons.tsx'
|
|
15
|
+
import { CloseIcon, ConfirmIcon, CopyIcon, LinkIcon } from '../ui/icons.tsx'
|
|
16
16
|
import { ProgressView } from './ProgressView.tsx'
|
|
17
17
|
import { installCommandOf } from '../../logic/install-command.ts'
|
|
18
18
|
import { pluginDetailUrl, pluginIssueUrl, pluginSiteUrl } from '../../logic/urls.ts'
|
|
@@ -144,6 +144,10 @@ export function InstallModal(props: InstallModalProps) {
|
|
|
144
144
|
})
|
|
145
145
|
// 确认/进行中:来源行 + 安装命令 + 实时进度 + 操作按钮
|
|
146
146
|
: h('div', { className: styles.modalBody },
|
|
147
|
+
// 安装/更新确认态:品牌蓝问号图标,一眼识别这是确认询问
|
|
148
|
+
!busy ? h('div', { className: styles.confirmIconWrap },
|
|
149
|
+
h(ConfirmIcon, { type: 'question' }),
|
|
150
|
+
) : null,
|
|
147
151
|
h('div', { className: styles.trustHint }, t('confirmDesc')),
|
|
148
152
|
h('div', { className: styles.modalRow },
|
|
149
153
|
h('span', { className: styles.modalLabel }, t('confirmPlugin')),
|
|
@@ -267,6 +271,10 @@ export function UninstallModal(props: UninstallModalProps) {
|
|
|
267
271
|
})
|
|
268
272
|
// 确认/进行中:来源行 + 实时进度 + 操作按钮
|
|
269
273
|
: h('div', { className: styles.modalBody },
|
|
274
|
+
// 卸载确认态:红色垃圾桶图标,删除类危险操作
|
|
275
|
+
!busy ? h('div', { className: styles.confirmIconWrap },
|
|
276
|
+
h(ConfirmIcon, { type: 'trash' }),
|
|
277
|
+
) : null,
|
|
270
278
|
h('div', { className: styles.modalDesc }, t('uninstallDesc')),
|
|
271
279
|
h('div', { className: styles.modalRow },
|
|
272
280
|
h('span', { className: styles.modalLabel }, t('confirmPlugin')),
|
|
@@ -449,6 +457,10 @@ export function RestartConfirmModal({ t, restarting, onClose, onRestartNow }: {
|
|
|
449
457
|
}, h(CloseIcon)),
|
|
450
458
|
),
|
|
451
459
|
h('div', { className: styles.modalBody },
|
|
460
|
+
// 重启确认:红色警告三角 —— 重启会中断正在进行的安装/卸载任务,风险提示
|
|
461
|
+
h('div', { className: styles.confirmIconWrap },
|
|
462
|
+
h(ConfirmIcon, { type: 'warning' }),
|
|
463
|
+
),
|
|
452
464
|
h('div', { className: styles.failPrepareHint }, t('restartHint')),
|
|
453
465
|
h('div', { className: styles.modalActions },
|
|
454
466
|
h('button', {
|
|
@@ -340,6 +340,54 @@ export function ResetIcon() {
|
|
|
340
340
|
}))
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
+
/** 确认弹窗图标类型:普通确认 = 问号;危险操作 = 垃圾桶 / 警告三角 / 还原箭头 */
|
|
344
|
+
export type ConfirmIconType = 'question' | 'trash' | 'warning' | 'reset'
|
|
345
|
+
|
|
346
|
+
/** 确认弹窗图标:圆形徽标 + 白色图形 —— 普通确认(安装/更新/重启/忽略)蓝底白问号;
|
|
347
|
+
* 危险操作(清空/删除/卸载/重置)红底白动作图形。居中放在弹窗标题下方,一眼区分确认类型。 */
|
|
348
|
+
export function ConfirmIcon({ type }: { type: ConfirmIconType }) {
|
|
349
|
+
const danger = type !== 'question'
|
|
350
|
+
const glyphs = type === 'trash'
|
|
351
|
+
? [
|
|
352
|
+
h('path', {
|
|
353
|
+
d: 'M4.5 6.5h15M9.5 6.5V4.8a1.3 1.3 0 0 1 1.3-1.3h2.4a1.3 1.3 0 0 1 1.3 1.3v1.7',
|
|
354
|
+
stroke: '#ffffff', strokeWidth: 1.9, strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
355
|
+
}),
|
|
356
|
+
h('path', {
|
|
357
|
+
d: 'M6.2 6.5l.7 12.2a1.5 1.5 0 0 0 1.5 1.3h7.2a1.5 1.5 0 0 0 1.5-1.3l.7-12.2',
|
|
358
|
+
stroke: '#ffffff', strokeWidth: 1.9, strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
359
|
+
}),
|
|
360
|
+
h('path', { d: 'M10 10.2v6M14 10.2v6', stroke: '#ffffff', strokeWidth: 1.9, strokeLinecap: 'round' }),
|
|
361
|
+
]
|
|
362
|
+
: type === 'warning'
|
|
363
|
+
? [
|
|
364
|
+
h('path', {
|
|
365
|
+
d: 'M12 4.2l8.5 14.3a1 1 0 0 1-.87 1.5H4.37a1 1 0 0 1-.87-1.5L12 4.2z',
|
|
366
|
+
stroke: '#ffffff', strokeWidth: 1.9, strokeLinejoin: 'round',
|
|
367
|
+
}),
|
|
368
|
+
h('path', { d: 'M12 9.6v4.5', stroke: '#ffffff', strokeWidth: 1.9, strokeLinecap: 'round' }),
|
|
369
|
+
h('circle', { cx: 12, cy: 16.7, r: 1.05, fill: '#ffffff' }),
|
|
370
|
+
]
|
|
371
|
+
: type === 'reset'
|
|
372
|
+
? [
|
|
373
|
+
h('path', { d: 'M4.5 7.8A8.4 8.4 0 1 1 3.6 14.4', stroke: '#ffffff', strokeWidth: 1.9, strokeLinecap: 'round' }),
|
|
374
|
+
h('path', { d: 'M3.9 3.9v3.9h3.9', stroke: '#ffffff', strokeWidth: 1.9, strokeLinecap: 'round', strokeLinejoin: 'round' }),
|
|
375
|
+
]
|
|
376
|
+
: [
|
|
377
|
+
h('path', {
|
|
378
|
+
d: 'M9.5 9.2a2.6 2.6 0 1 1 3.7 2.38c-.84.38-1.2.95-1.2 1.72',
|
|
379
|
+
stroke: '#ffffff', strokeWidth: 1.9, strokeLinecap: 'round',
|
|
380
|
+
}),
|
|
381
|
+
h('circle', { cx: 12, cy: 16.4, r: 1.1, fill: '#ffffff' }),
|
|
382
|
+
]
|
|
383
|
+
return h('div', {
|
|
384
|
+
className: danger ? `${modalStyles.confirmIcon} ${modalStyles.confirmIconDanger}` : modalStyles.confirmIcon,
|
|
385
|
+
'aria-hidden': 'true',
|
|
386
|
+
},
|
|
387
|
+
h('svg', { viewBox: '0 0 24 24', width: 22, height: 22, fill: 'none' }, ...glyphs),
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
|
|
343
391
|
/** 系统日志图标:文档纸 + 三行文字(stroke 继承 currentColor),设置左侧导航「系统日志」用。 */
|
|
344
392
|
export function LogsIcon() {
|
|
345
393
|
return h('svg', {
|
|
@@ -95,7 +95,7 @@ export function LogsView({ t, logPath, onLogPathSaved }: {
|
|
|
95
95
|
h('div', { className: styles.actions },
|
|
96
96
|
h('button', {
|
|
97
97
|
type: 'button',
|
|
98
|
-
className:
|
|
98
|
+
className: styles.btn,
|
|
99
99
|
onClick: () => setOpen(true),
|
|
100
100
|
}, t('logViewerOpen')),
|
|
101
101
|
h('button', { type: 'button', className: styles.btn, disabled: entries.length === 0, onClick: exportFile }, t('logsExport')),
|
|
@@ -24,7 +24,7 @@ import { Toggle } from '../ui/Toggle.tsx'
|
|
|
24
24
|
import { DiagnosticsView } from './DiagnosticsView.tsx'
|
|
25
25
|
import { LogsView } from './LogsView.tsx'
|
|
26
26
|
import {
|
|
27
|
-
CloseIcon, DiagnosticsIcon, LogsIcon, ResetIcon, SecurityIcon, UpdatesIcon,
|
|
27
|
+
CloseIcon, ConfirmIcon, DiagnosticsIcon, LogsIcon, ResetIcon, SecurityIcon, UpdatesIcon,
|
|
28
28
|
} from '../ui/icons.tsx'
|
|
29
29
|
import type { EnvInfo, Translate } from '../../types.ts'
|
|
30
30
|
import type { HubSettings } from '../../hooks/useSettings.ts'
|
|
@@ -253,6 +253,10 @@ export function SettingsView({ t, settings, update, reset, env, onCopy }: {
|
|
|
253
253
|
}, h(CloseIcon)),
|
|
254
254
|
),
|
|
255
255
|
h('div', { className: modalStyles.modalBody },
|
|
256
|
+
// 恢复默认 = 破坏性操作:红色警告三角图标,醒目提示风险
|
|
257
|
+
h('div', { className: modalStyles.confirmIconWrap },
|
|
258
|
+
h(ConfirmIcon, { type: 'warning' }),
|
|
259
|
+
),
|
|
256
260
|
h('div', { className: modalStyles.failPrepareHint }, t('settingsResetConfirmDetail')),
|
|
257
261
|
h('div', { className: modalStyles.modalActions },
|
|
258
262
|
h('button', {
|