nx-sk 0.3.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/CHANGELOG.md +133 -0
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/assets/nx-sk/SKILL.md +102 -0
- package/assets/nx-sk/references/00-design.md +116 -0
- package/assets/nx-sk/references/10-sections-entries.md +165 -0
- package/assets/nx-sk/references/20-secrets.md +170 -0
- package/assets/nx-sk/references/30-export-backup.md +80 -0
- package/assets/nx-sk/references/40-extend.md +75 -0
- package/bin/nx-sk.mjs +11 -0
- package/package.json +56 -0
- package/src/core/crypto.js +73 -0
- package/src/core/errors.js +52 -0
- package/src/core/fields.js +345 -0
- package/src/core/fsx.js +103 -0
- package/src/core/ids.js +19 -0
- package/src/core/open.js +32 -0
- package/src/core/paths.js +141 -0
- package/src/core/render.js +144 -0
- package/src/core/store.js +317 -0
- package/src/core/vault.js +109 -0
- package/src/index.js +14 -0
- package/src/modules/entries/index.js +152 -0
- package/src/modules/entries/kvTable.jsx +158 -0
- package/src/modules/entries/service.js +539 -0
- package/src/modules/entries/view.jsx +240 -0
- package/src/modules/export/index.js +39 -0
- package/src/modules/export/service.js +192 -0
- package/src/modules/export/view.jsx +155 -0
- package/src/modules/sections/index.js +104 -0
- package/src/modules/sections/service.js +337 -0
- package/src/modules/sections/view.jsx +311 -0
- package/src/modules/settings/index.js +34 -0
- package/src/modules/settings/service.js +78 -0
- package/src/modules/settings/view.jsx +193 -0
- package/src/modules/skill/index.js +51 -0
- package/src/modules/skill/service.js +200 -0
- package/src/modules/system/index.js +42 -0
- package/src/modules/system/service.js +155 -0
- package/src/runtime/api.js +101 -0
- package/src/runtime/cli.js +266 -0
- package/src/runtime/registry.js +65 -0
- package/src/runtime/server.js +80 -0
- package/src/runtime/spec.js +158 -0
- package/src/web/frontend/App.jsx +108 -0
- package/src/web/frontend/api/client.js +26 -0
- package/src/web/frontend/components/fieldEditor.jsx +85 -0
- package/src/web/frontend/components/ui.jsx +143 -0
- package/src/web/frontend/index.html +12 -0
- package/src/web/frontend/main.jsx +13 -0
- package/src/web/frontend/registry.js +20 -0
- package/src/web/frontend/store.jsx +87 -0
- package/src/web/frontend/styles.css +301 -0
- package/src/web/public/assets/index-BWGLG7Qa.js +40 -0
- package/src/web/public/assets/index-CwF5hM2v.css +1 -0
- package/src/web/public/assets/view-B6gPh1NQ.js +1 -0
- package/src/web/public/assets/view-CfXXYwW2.js +1 -0
- package/src/web/public/assets/view-CxzKrdA3.js +7 -0
- package/src/web/public/assets/view-DjO1C54o.js +7 -0
- package/src/web/public/index.html +13 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
2
|
+
|
|
3
|
+
export async function api(path, opts = {}) {
|
|
4
|
+
const ctrl = new AbortController();
|
|
5
|
+
const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs || DEFAULT_TIMEOUT_MS);
|
|
6
|
+
try {
|
|
7
|
+
const res = await fetch(path, {
|
|
8
|
+
method: opts.method || 'GET',
|
|
9
|
+
headers: opts.body ? { 'content-type': 'application/json' } : undefined,
|
|
10
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
11
|
+
signal: ctrl.signal,
|
|
12
|
+
});
|
|
13
|
+
const json = await res.json().catch(() => { throw new Error(`HTTP ${res.status}`); });
|
|
14
|
+
if (!json.ok) {
|
|
15
|
+
const err = new Error(json.error || '请求失败');
|
|
16
|
+
err.code = json.code; // 调用方可据此区分冲突与参数错误
|
|
17
|
+
throw err;
|
|
18
|
+
}
|
|
19
|
+
return json.data;
|
|
20
|
+
} catch (e) {
|
|
21
|
+
if (e && e.name === 'AbortError') throw new Error('请求超时(本地服务无响应)');
|
|
22
|
+
throw e;
|
|
23
|
+
} finally {
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// 字段编辑器:按字段字典的 type 决定控件。
|
|
2
|
+
// 字典来自后端(section.fields),所以**面板能填的字段与 CLI 能填的字段天然一致**。
|
|
3
|
+
export function FieldControl({ def, value, onChange, disabled }) {
|
|
4
|
+
const type = def.type || 'text';
|
|
5
|
+
const v = value === null || value === undefined ? '' : value;
|
|
6
|
+
|
|
7
|
+
if (type === 'bool') {
|
|
8
|
+
const checked = v === true || v === 'true' || v === '是';
|
|
9
|
+
return (
|
|
10
|
+
<label className="inline">
|
|
11
|
+
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
|
12
|
+
<span className="muted">{checked ? '是' : '否'}</span>
|
|
13
|
+
</label>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (type === 'select') {
|
|
18
|
+
const options = def.options || [];
|
|
19
|
+
const isCustom = v !== '' && !options.includes(v);
|
|
20
|
+
return (
|
|
21
|
+
<select value={isCustom ? '__custom' : String(v)} onChange={(e) => onChange(e.target.value === '__custom' ? '' : e.target.value)}>
|
|
22
|
+
<option value="">(未填写)</option>
|
|
23
|
+
{options.map((o) => <option key={o} value={o}>{o}</option>)}
|
|
24
|
+
{isCustom ? <option value="__custom">{String(v)}(当前值,不在候选里)</option> : null}
|
|
25
|
+
</select>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (type === 'textarea') {
|
|
30
|
+
return <textarea value={String(v)} onChange={(e) => onChange(e.target.value)} rows={3} />;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (type === 'tags') {
|
|
34
|
+
const text = Array.isArray(v) ? v.join('、') : String(v);
|
|
35
|
+
return (
|
|
36
|
+
<input
|
|
37
|
+
value={text}
|
|
38
|
+
placeholder={def.maxItems ? `多个用「、」分隔,最多 ${def.maxItems} 个` : '多个用「、」分隔'}
|
|
39
|
+
onChange={(e) => onChange(e.target.value.split(/[,,、;;]+/).map((x) => x.trim()).filter(Boolean))}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const inputType = type === 'number' ? 'number' : (type === 'date' ? 'date' : (type === 'month' ? 'month' : 'text'));
|
|
45
|
+
if (type === 'secret') {
|
|
46
|
+
return (
|
|
47
|
+
<input
|
|
48
|
+
type="text"
|
|
49
|
+
value={String(v)}
|
|
50
|
+
placeholder="密文落盘;原样保留占位则视为不修改"
|
|
51
|
+
disabled={disabled}
|
|
52
|
+
onChange={(e) => onChange(e.target.value)}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return <input type={inputType} value={String(v)} onChange={(e) => onChange(e.target.value)} />;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 一整组字段(同一 group)的矩阵。 */
|
|
60
|
+
export function FieldGroup({ title, fields, draft, onPatch }) {
|
|
61
|
+
return (
|
|
62
|
+
<>
|
|
63
|
+
<div className="group-title">{title}</div>
|
|
64
|
+
<div className="pad">
|
|
65
|
+
<div className="grid">
|
|
66
|
+
{fields.map((def) => (
|
|
67
|
+
<div key={def.key} className={`field ${def.type === 'textarea' ? 'wide' : ''}`}>
|
|
68
|
+
<div className="flabel">
|
|
69
|
+
<span>{def.label}</span>
|
|
70
|
+
{def.sensitive ? <span className="tag bad">密文</span> : null}
|
|
71
|
+
{/* 填写策略来自后端字段字典;不计入完整度,所以标签用中性色而不是红色——
|
|
72
|
+
用户是**主动**跳过它,不是出错。 */}
|
|
73
|
+
{def.fill && def.fill !== 'normal' ? <span className="tag">{def.fillLabel || def.fill}</span> : null}
|
|
74
|
+
<span className="fkey">{def.key}</span>
|
|
75
|
+
</div>
|
|
76
|
+
<div className="fctrl">
|
|
77
|
+
<FieldControl def={def} value={draft[def.key]} onChange={(v) => onPatch(def.key, v)} />
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
))}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Component, Fragment, useEffect, useRef } from 'react';
|
|
2
|
+
import { useStore } from '../store.jsx';
|
|
3
|
+
|
|
4
|
+
export function Copyable({ text, children, className = '', title }) {
|
|
5
|
+
const { toast } = useStore();
|
|
6
|
+
const onClick = async () => {
|
|
7
|
+
const value = String(text ?? '');
|
|
8
|
+
try {
|
|
9
|
+
await navigator.clipboard.writeText(value);
|
|
10
|
+
toast('已复制');
|
|
11
|
+
} catch {
|
|
12
|
+
toast('复制失败:浏览器拒绝了剪贴板访问', 'bad');
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
return (
|
|
16
|
+
<span
|
|
17
|
+
className={`copyable ${className}`}
|
|
18
|
+
title={title || `点击复制:${text}`}
|
|
19
|
+
onClick={onClick}
|
|
20
|
+
role="button"
|
|
21
|
+
tabIndex={0}
|
|
22
|
+
onKeyDown={(e) => { if (e.key === 'Enter') onClick(); }}
|
|
23
|
+
>
|
|
24
|
+
{children ?? text}
|
|
25
|
+
</span>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function Tag({ children, kind = '' }) {
|
|
30
|
+
return <span className={`tag ${kind}`}>{children}</span>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 懒加载视图的兜底:没有它,一个视图崩掉就是**整页白屏**,用户连切 tab 自救都不行。 */
|
|
34
|
+
export class ErrorBoundary extends Component {
|
|
35
|
+
constructor(props) {
|
|
36
|
+
super(props);
|
|
37
|
+
this.state = { err: null };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
static getDerivedStateFromError(err) {
|
|
41
|
+
return { err };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
render() {
|
|
45
|
+
if (this.state.err) {
|
|
46
|
+
return (
|
|
47
|
+
<div className="pad stack">
|
|
48
|
+
<div><strong>这个视图崩了</strong></div>
|
|
49
|
+
<div className="muted">{String(this.state.err?.message || this.state.err)}</div>
|
|
50
|
+
<div className="hint">切到别的 tab 仍然可用;修好之后本页会自愈。</div>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return this.props.children;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function Toasts() {
|
|
59
|
+
const { toasts } = useStore();
|
|
60
|
+
if (!toasts.length) return null;
|
|
61
|
+
return (
|
|
62
|
+
<div className="toasts">
|
|
63
|
+
{toasts.map((t) => <div key={t.id} className={`toast ${t.kind === 'ok' ? '' : 'bad'}`}>{t.message}</div>)}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function Modal({ title, children, onClose, footer }) {
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const onKey = (e) => { if (e.key === 'Escape') onClose?.(); };
|
|
71
|
+
window.addEventListener('keydown', onKey);
|
|
72
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
73
|
+
}, [onClose]);
|
|
74
|
+
return (
|
|
75
|
+
<div className="mask" onClick={(e) => { if (e.target === e.currentTarget) onClose?.(); }}>
|
|
76
|
+
<div className="dialog">
|
|
77
|
+
<div className="dialog-title">
|
|
78
|
+
<span className="grow nowrap">{title}</span>
|
|
79
|
+
<button className="btn small ghost" onClick={onClose}>关闭</button>
|
|
80
|
+
</div>
|
|
81
|
+
<div className="dialog-body">{children}</div>
|
|
82
|
+
{footer ? <div className="dialog-foot">{footer}</div> : null}
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 确认弹窗。输入框用 ref 读,不用 document.querySelector(页面上出现第二个同名 class 就会读错)。 */
|
|
89
|
+
export function DialogHost() {
|
|
90
|
+
const { dialogState, closeDialog } = useStore();
|
|
91
|
+
const inputRef = useRef(null);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (dialogState?.input && inputRef.current) inputRef.current.focus();
|
|
94
|
+
}, [dialogState]);
|
|
95
|
+
if (!dialogState) return null;
|
|
96
|
+
const { message, detail, danger, input, confirmText = '确定', cancelText = '取消', hideCancel } = dialogState;
|
|
97
|
+
return (
|
|
98
|
+
<div className="mask">
|
|
99
|
+
<div className="dialog">
|
|
100
|
+
<div className="dialog-title">{danger ? '危险操作' : '确认'}</div>
|
|
101
|
+
<div className="dialog-body stack">
|
|
102
|
+
<div>{message}</div>
|
|
103
|
+
{detail ? <pre className="block">{detail}</pre> : null}
|
|
104
|
+
{input ? (
|
|
105
|
+
<input ref={inputRef} defaultValue={input.defaultValue || ''} placeholder={input.placeholder || ''} />
|
|
106
|
+
) : null}
|
|
107
|
+
</div>
|
|
108
|
+
<div className="dialog-foot">
|
|
109
|
+
{hideCancel ? null : <button className="btn ghost" onClick={() => closeDialog(null)}>{cancelText}</button>}
|
|
110
|
+
<button
|
|
111
|
+
className={`btn ${danger ? 'danger' : ''}`}
|
|
112
|
+
onClick={() => closeDialog(input ? (inputRef.current?.value ?? '') : true)}
|
|
113
|
+
>
|
|
114
|
+
{confirmText}
|
|
115
|
+
</button>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 「这个页面每个按钮都有同构 CLI 命令」——提示**由数据派生**,不手写(手写必然与实际命令漂移)。 */
|
|
123
|
+
export function CliHints({ module }) {
|
|
124
|
+
const { boot } = useStore();
|
|
125
|
+
const cmds = (boot?.commands || []).filter((c) => c.module === module);
|
|
126
|
+
if (!cmds.length) return null;
|
|
127
|
+
return (
|
|
128
|
+
<div className="cli-hint">
|
|
129
|
+
<span className="cli-hint-label">这个页面上的每个操作都有一条同构的 CLI 命令:</span>
|
|
130
|
+
{cmds.map((c, i) => (
|
|
131
|
+
<Fragment key={c.id}>
|
|
132
|
+
{i > 0 ? <span className="cli-hint-sep"> · </span> : null}
|
|
133
|
+
<Copyable className="cli-cmd" text={c.command} title={`点击复制:${c.usage}`}>{c.command}</Copyable>
|
|
134
|
+
</Fragment>
|
|
135
|
+
))}
|
|
136
|
+
<span className="cli-hint-tail">。加 <code className="cli-cmd">--json</code> 得机器可读输出。</span>
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function Empty({ children }) {
|
|
142
|
+
return <div className="row muted">{children}</div>;
|
|
143
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>nx-sk · 个人资源管理器</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="./main.jsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { StrictMode } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import App from './App.jsx';
|
|
4
|
+
import { StoreProvider } from './store.jsx';
|
|
5
|
+
import './styles.css';
|
|
6
|
+
|
|
7
|
+
createRoot(document.getElementById('root')).render(
|
|
8
|
+
<StrictMode>
|
|
9
|
+
<StoreProvider>
|
|
10
|
+
<App />
|
|
11
|
+
</StoreProvider>
|
|
12
|
+
</StrictMode>,
|
|
13
|
+
);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { lazy } from 'react';
|
|
2
|
+
|
|
3
|
+
// 新增面板 = 写组件 + 登记一行。tab 顺序由 order 决定。
|
|
4
|
+
//
|
|
5
|
+
// `tab: false` 的条目**不出现在导航里**,但仍然必须登记——它由「栏目」动态挂载
|
|
6
|
+
// (每个栏目一个 tab,共用同一个视图)。一致性测试对的是「模块声明了 view ↔ 这里登记了」,
|
|
7
|
+
// 而不是「有没有 tab」。
|
|
8
|
+
//
|
|
9
|
+
// 注意:必须用**字面量** lazy(() => import('...')):变量拼路径会让 Vite 静态分析不了,失去代码分割。
|
|
10
|
+
export const VIEWS = [
|
|
11
|
+
{ id: 'sections', title: '栏目', order: 500, component: lazy(() => import('../../modules/sections/view.jsx')) },
|
|
12
|
+
{ id: 'entries', title: '条目', order: 600, tab: false, component: lazy(() => import('../../modules/entries/view.jsx')) },
|
|
13
|
+
{ id: 'export', title: '导出', order: 700, component: lazy(() => import('../../modules/export/view.jsx')) },
|
|
14
|
+
{ id: 'settings', title: '设置', order: 900, component: lazy(() => import('../../modules/settings/view.jsx')) },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export const SECTION_VIEW = VIEWS.find((v) => v.id === 'entries').component;
|
|
18
|
+
|
|
19
|
+
/** 模块 id → 视图 id 的映射,供一致性测试核对「模块声明的 view」都登记过。 */
|
|
20
|
+
export const VIEW_MODULE_IDS = VIEWS.map((v) => v.id);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { api } from './api/client.js';
|
|
3
|
+
|
|
4
|
+
export const LS_KEY = 'nx-sk-ui';
|
|
5
|
+
|
|
6
|
+
// 只持久化**选择**,不持久化**数据**——数据永远从 /api 拉,避免陈旧缓存。
|
|
7
|
+
export const DEFAULT_UI = {
|
|
8
|
+
view: 'job', // 当前 tab
|
|
9
|
+
entry: '', // 各栏目选中的条目
|
|
10
|
+
q: '', // 搜索词
|
|
11
|
+
mask: false, // 打码显示密文字段(默认原文;投屏时勾上)
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function loadUi() {
|
|
15
|
+
try {
|
|
16
|
+
return { ...DEFAULT_UI, ...JSON.parse(localStorage.getItem(LS_KEY) || '{}') };
|
|
17
|
+
} catch {
|
|
18
|
+
return { ...DEFAULT_UI };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const Ctx = createContext(null);
|
|
23
|
+
|
|
24
|
+
export function StoreProvider({ children }) {
|
|
25
|
+
const [boot, setBoot] = useState(null);
|
|
26
|
+
const [bootError, setBootError] = useState(null);
|
|
27
|
+
const [ui, setUi] = useState(loadUi);
|
|
28
|
+
const [toasts, setToasts] = useState([]);
|
|
29
|
+
const [dialogState, setDialogState] = useState(null);
|
|
30
|
+
|
|
31
|
+
const patchUi = useCallback((patch) => {
|
|
32
|
+
setUi((prev) => {
|
|
33
|
+
const next = { ...prev, ...patch };
|
|
34
|
+
try { localStorage.setItem(LS_KEY, JSON.stringify(next)); } catch { /* 隐私模式 */ }
|
|
35
|
+
return next;
|
|
36
|
+
});
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
const reload = useCallback(async () => {
|
|
40
|
+
try {
|
|
41
|
+
const data = await api('/api/bootstrap');
|
|
42
|
+
setBoot(data);
|
|
43
|
+
setBootError(null);
|
|
44
|
+
return data;
|
|
45
|
+
} catch (e) {
|
|
46
|
+
setBootError(e.message || String(e));
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
useEffect(() => { reload(); }, [reload]);
|
|
52
|
+
|
|
53
|
+
const toast = useCallback((message, kind = 'ok') => {
|
|
54
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
55
|
+
setToasts((t) => [...t, { id, message, kind }]);
|
|
56
|
+
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 2600);
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
// Promise 风格:调用处读起来是同步的 —— `const ok = await dialog({...}); if (!ok) return;`
|
|
60
|
+
const dialog = useCallback((opts) => new Promise((resolve) => {
|
|
61
|
+
setDialogState({ ...opts, resolve });
|
|
62
|
+
}), []);
|
|
63
|
+
|
|
64
|
+
const closeDialog = useCallback((value) => {
|
|
65
|
+
setDialogState((cur) => { cur?.resolve?.(value); return null; });
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
const guard = useCallback((fn) => async () => {
|
|
69
|
+
try {
|
|
70
|
+
await fn();
|
|
71
|
+
} catch (e) {
|
|
72
|
+
toast(e?.message || String(e), 'bad');
|
|
73
|
+
}
|
|
74
|
+
}, [toast]);
|
|
75
|
+
|
|
76
|
+
const value = useMemo(() => ({
|
|
77
|
+
boot, bootError, ui, patchUi, reload, toast, dialog, closeDialog, guard, toasts, dialogState,
|
|
78
|
+
}), [boot, bootError, ui, patchUi, reload, toast, dialog, closeDialog, guard, toasts, dialogState]);
|
|
79
|
+
|
|
80
|
+
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function useStore() {
|
|
84
|
+
const v = useContext(Ctx);
|
|
85
|
+
if (!v) throw new Error('useStore 必须在 StoreProvider 内使用');
|
|
86
|
+
return v;
|
|
87
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/* nx-sk 面板样式
|
|
2
|
+
*
|
|
3
|
+
* 三条核心不变量:
|
|
4
|
+
* 1. 单色 + 无 emoji;唯一允许的彩色是语义红 #b3261e
|
|
5
|
+
* 2. 状态用标签,不用颜色块
|
|
6
|
+
* 3. 立体感**只给**可被按/可被填的元素(按钮 / 选中态 / 输入框);容器一律不投影
|
|
7
|
+
*
|
|
8
|
+
* 密度:核心显示行 28px · 按钮 32px · 字号 12px(两级密度,差 4px)
|
|
9
|
+
* 分隔:全站只用 1px 浅底线,不用 margin / 投影把容器撑成岛屿
|
|
10
|
+
*/
|
|
11
|
+
:root {
|
|
12
|
+
--ink: #14161a;
|
|
13
|
+
--paper: #fff;
|
|
14
|
+
--soft: #f5f6f7;
|
|
15
|
+
--soft-2: #ebedf0;
|
|
16
|
+
--mid: #8b9096;
|
|
17
|
+
--danger: #b3261e;
|
|
18
|
+
--border: 1px solid var(--soft-2);
|
|
19
|
+
|
|
20
|
+
--row-h: 28px;
|
|
21
|
+
--btn-h: 32px;
|
|
22
|
+
--fs-core: 12px;
|
|
23
|
+
|
|
24
|
+
--radius-btn: 8px;
|
|
25
|
+
|
|
26
|
+
--shadow-relief: -1px -1px 2px rgba(255, 255, 255, .85), 2px 2px 6px rgba(20, 22, 26, .07);
|
|
27
|
+
--shadow-relief-press: inset 1px 1px 2px rgba(20, 22, 26, .10), inset -1px -1px 2px rgba(255, 255, 255, .7);
|
|
28
|
+
--shadow-relief-inset: inset 1px 1px 2px rgba(20, 22, 26, .06), inset -1px -1px 2px rgba(255, 255, 255, .9);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
* { box-sizing: border-box; }
|
|
32
|
+
|
|
33
|
+
html, body, #root { height: 100%; }
|
|
34
|
+
|
|
35
|
+
body {
|
|
36
|
+
margin: 0;
|
|
37
|
+
background: var(--paper);
|
|
38
|
+
color: var(--ink);
|
|
39
|
+
font-family: system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
|
|
40
|
+
font-size: var(--fs-core);
|
|
41
|
+
line-height: 1.5;
|
|
42
|
+
-webkit-font-smoothing: antialiased;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.mono, code, .path, .cli-cmd { font-family: ui-monospace, Consolas, "Courier New", monospace; }
|
|
46
|
+
.muted { color: var(--mid); }
|
|
47
|
+
.bad { color: var(--danger); }
|
|
48
|
+
.nowrap { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
49
|
+
.pad { padding: 10px 14px; }
|
|
50
|
+
.rowgap { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
51
|
+
.grow { flex: 1; min-width: 0; }
|
|
52
|
+
.right { margin-left: auto; }
|
|
53
|
+
|
|
54
|
+
/* ── 骨架 ───────────────────────────────────────────── */
|
|
55
|
+
.app { display: flex; flex-direction: column; height: 100%; min-height: 0; }
|
|
56
|
+
|
|
57
|
+
header.top {
|
|
58
|
+
display: flex;
|
|
59
|
+
flex-wrap: wrap;
|
|
60
|
+
align-items: center;
|
|
61
|
+
gap: 10px;
|
|
62
|
+
padding: 6px 14px;
|
|
63
|
+
border-bottom: var(--border);
|
|
64
|
+
flex-shrink: 0;
|
|
65
|
+
}
|
|
66
|
+
header.top .brand { font-weight: 700; letter-spacing: .3px; }
|
|
67
|
+
header.top .meta { color: var(--mid); font-size: 11px; }
|
|
68
|
+
|
|
69
|
+
nav.tabs {
|
|
70
|
+
display: flex;
|
|
71
|
+
flex-wrap: wrap;
|
|
72
|
+
gap: 6px;
|
|
73
|
+
padding: 6px 14px;
|
|
74
|
+
border-bottom: var(--border);
|
|
75
|
+
flex-shrink: 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
main.body { flex: 1; min-height: 0; overflow: auto; }
|
|
79
|
+
|
|
80
|
+
/* ── tab:单层立体阴影,别叠 --shadow,叠两层就变岛屿 ── */
|
|
81
|
+
.tab {
|
|
82
|
+
font: inherit;
|
|
83
|
+
font-size: var(--fs-core);
|
|
84
|
+
height: var(--btn-h);
|
|
85
|
+
padding: 0 12px;
|
|
86
|
+
border: 0;
|
|
87
|
+
border-radius: var(--radius-btn);
|
|
88
|
+
background: var(--soft);
|
|
89
|
+
color: var(--ink);
|
|
90
|
+
cursor: pointer;
|
|
91
|
+
display: inline-flex;
|
|
92
|
+
align-items: center;
|
|
93
|
+
gap: 6px;
|
|
94
|
+
}
|
|
95
|
+
.tab .count { color: var(--mid); font-size: 11px; }
|
|
96
|
+
.tab.active { background: var(--paper); box-shadow: var(--shadow-relief); font-weight: 600; }
|
|
97
|
+
|
|
98
|
+
/* ── 按钮:凸起 → 按下内嵌 + 下沉 1px ── */
|
|
99
|
+
.btn {
|
|
100
|
+
font: inherit;
|
|
101
|
+
font-size: var(--fs-core);
|
|
102
|
+
cursor: pointer;
|
|
103
|
+
height: var(--btn-h);
|
|
104
|
+
padding: 0 12px;
|
|
105
|
+
border: 0;
|
|
106
|
+
border-radius: var(--radius-btn);
|
|
107
|
+
background: var(--ink);
|
|
108
|
+
color: var(--paper);
|
|
109
|
+
box-shadow: var(--shadow-relief);
|
|
110
|
+
display: inline-flex;
|
|
111
|
+
align-items: center;
|
|
112
|
+
gap: 4px;
|
|
113
|
+
transition: box-shadow .12s, transform .08s;
|
|
114
|
+
}
|
|
115
|
+
.btn:hover:not(:disabled) { box-shadow: var(--shadow-relief), 0 0 0 3px rgba(20, 22, 26, .05); }
|
|
116
|
+
.btn:active:not(:disabled) { box-shadow: var(--shadow-relief-press); transform: translateY(1px); }
|
|
117
|
+
.btn:disabled { background: var(--soft); color: var(--mid); box-shadow: var(--shadow-relief-inset); cursor: not-allowed; }
|
|
118
|
+
.btn.ghost { background: var(--soft); color: var(--ink); }
|
|
119
|
+
.btn.danger { background: var(--danger); color: #fff; }
|
|
120
|
+
.btn.small { height: 22px; padding: 0 8px; font-size: 11px; border-radius: 6px; }
|
|
121
|
+
|
|
122
|
+
input, select, textarea {
|
|
123
|
+
font: inherit;
|
|
124
|
+
font-size: var(--fs-core);
|
|
125
|
+
color: var(--ink);
|
|
126
|
+
background: var(--paper);
|
|
127
|
+
border: var(--border);
|
|
128
|
+
border-radius: 6px;
|
|
129
|
+
padding: 0 8px;
|
|
130
|
+
}
|
|
131
|
+
input, select { height: var(--row-h); }
|
|
132
|
+
textarea { padding: 5px 8px; resize: vertical; min-height: 56px; line-height: 1.5; }
|
|
133
|
+
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--soft-2); box-shadow: var(--shadow-relief-inset); }
|
|
134
|
+
label.inline { display: inline-flex; align-items: center; gap: 6px; }
|
|
135
|
+
|
|
136
|
+
/* ── 卡片:不投影、不 margin、不用大 padding;靠 1px 底线分隔 ── */
|
|
137
|
+
.card { border-bottom: var(--border); }
|
|
138
|
+
.card:last-child { border-bottom: 0; }
|
|
139
|
+
.card > .colhead {
|
|
140
|
+
display: flex;
|
|
141
|
+
align-items: center;
|
|
142
|
+
gap: 8px;
|
|
143
|
+
height: var(--row-h);
|
|
144
|
+
padding: 0 14px;
|
|
145
|
+
border-bottom: var(--border);
|
|
146
|
+
font-size: 13px;
|
|
147
|
+
font-weight: 600;
|
|
148
|
+
}
|
|
149
|
+
.card > .colbody { padding: 0; }
|
|
150
|
+
|
|
151
|
+
/* ── 行:28px 锁死高度,竖直 padding = 0 ── */
|
|
152
|
+
.row {
|
|
153
|
+
display: flex;
|
|
154
|
+
align-items: center;
|
|
155
|
+
gap: 8px;
|
|
156
|
+
min-height: var(--row-h);
|
|
157
|
+
padding: 0 14px;
|
|
158
|
+
border-bottom: var(--border);
|
|
159
|
+
font-size: var(--fs-core);
|
|
160
|
+
transition: background .12s;
|
|
161
|
+
}
|
|
162
|
+
.row:hover { background: var(--soft); }
|
|
163
|
+
.row:last-child { border-bottom: 0; }
|
|
164
|
+
.row .name { font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
165
|
+
.row .desc { color: var(--mid); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
166
|
+
.row .acts { margin-left: auto; display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
|
167
|
+
.row.selectable { cursor: pointer; }
|
|
168
|
+
.row.selected { background: var(--soft); box-shadow: inset 2px 0 0 var(--ink); }
|
|
169
|
+
|
|
170
|
+
/* ── 标签:比行更紧凑 ── */
|
|
171
|
+
.tag {
|
|
172
|
+
display: inline-flex;
|
|
173
|
+
align-items: center;
|
|
174
|
+
height: 22px;
|
|
175
|
+
padding: 0 7px;
|
|
176
|
+
border-radius: 999px;
|
|
177
|
+
background: var(--soft);
|
|
178
|
+
color: var(--mid);
|
|
179
|
+
font-size: 11px;
|
|
180
|
+
white-space: nowrap;
|
|
181
|
+
}
|
|
182
|
+
.tag.strong { background: var(--ink); color: var(--paper); }
|
|
183
|
+
.tag.bad { background: #fbeae9; color: var(--danger); }
|
|
184
|
+
|
|
185
|
+
/* ── 点击即复制 ── */
|
|
186
|
+
.copyable { cursor: copy; border-radius: 4px; padding: 0 3px; }
|
|
187
|
+
.copyable:hover { background: var(--soft); }
|
|
188
|
+
|
|
189
|
+
/* ── 表单字段矩阵 ── */
|
|
190
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 0 14px; }
|
|
191
|
+
.field { border-bottom: var(--border); padding: 5px 0; min-width: 0; }
|
|
192
|
+
.field > .flabel { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--mid); }
|
|
193
|
+
.field > .flabel .fkey { font-family: ui-monospace, Consolas, monospace; font-size: 10px; color: var(--soft-2); }
|
|
194
|
+
.field > .fctrl { margin-top: 3px; }
|
|
195
|
+
.field input, .field select, .field textarea { width: 100%; }
|
|
196
|
+
.field.wide { grid-column: 1 / -1; }
|
|
197
|
+
.group-title {
|
|
198
|
+
display: flex;
|
|
199
|
+
align-items: center;
|
|
200
|
+
gap: 8px;
|
|
201
|
+
height: var(--row-h);
|
|
202
|
+
padding: 0 14px;
|
|
203
|
+
border-bottom: var(--border);
|
|
204
|
+
background: var(--soft);
|
|
205
|
+
font-size: 11px;
|
|
206
|
+
color: var(--mid);
|
|
207
|
+
letter-spacing: .5px;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/* ── CLI 提示:文案通顺,命令之间用间隔点分开 ── */
|
|
211
|
+
.cli-hint {
|
|
212
|
+
display: flex;
|
|
213
|
+
flex-wrap: wrap;
|
|
214
|
+
align-items: center;
|
|
215
|
+
gap: 2px;
|
|
216
|
+
padding: 6px 14px;
|
|
217
|
+
color: var(--mid);
|
|
218
|
+
font-size: 11px;
|
|
219
|
+
border-top: var(--border);
|
|
220
|
+
}
|
|
221
|
+
.cli-hint-label { margin-right: 4px; }
|
|
222
|
+
.cli-hint-sep { color: var(--soft-2); }
|
|
223
|
+
.cli-cmd { color: var(--ink); }
|
|
224
|
+
|
|
225
|
+
/* ── toast ── */
|
|
226
|
+
.toasts { position: fixed; right: 14px; bottom: 14px; display: flex; flex-direction: column; gap: 6px; z-index: 40; }
|
|
227
|
+
.toast {
|
|
228
|
+
background: var(--ink);
|
|
229
|
+
color: var(--paper);
|
|
230
|
+
padding: 7px 12px;
|
|
231
|
+
border-radius: 8px;
|
|
232
|
+
font-size: var(--fs-core);
|
|
233
|
+
box-shadow: var(--shadow-relief);
|
|
234
|
+
max-width: 420px;
|
|
235
|
+
}
|
|
236
|
+
.toast.bad { background: var(--danger); }
|
|
237
|
+
|
|
238
|
+
/* ── 弹窗:页内实现,不用 alert / confirm / prompt ── */
|
|
239
|
+
.mask {
|
|
240
|
+
position: fixed;
|
|
241
|
+
inset: 0;
|
|
242
|
+
background: rgba(20, 22, 26, .28);
|
|
243
|
+
display: flex;
|
|
244
|
+
align-items: center;
|
|
245
|
+
justify-content: center;
|
|
246
|
+
z-index: 50;
|
|
247
|
+
padding: 20px;
|
|
248
|
+
}
|
|
249
|
+
.dialog {
|
|
250
|
+
background: var(--paper);
|
|
251
|
+
border-radius: 8px;
|
|
252
|
+
min-width: 320px;
|
|
253
|
+
max-width: min(880px, 92vw);
|
|
254
|
+
max-height: 86vh;
|
|
255
|
+
display: flex;
|
|
256
|
+
flex-direction: column;
|
|
257
|
+
overflow: hidden;
|
|
258
|
+
box-shadow: 0 10px 40px rgba(20, 22, 26, .18);
|
|
259
|
+
}
|
|
260
|
+
.dialog-title {
|
|
261
|
+
display: flex;
|
|
262
|
+
align-items: center;
|
|
263
|
+
gap: 8px;
|
|
264
|
+
height: var(--row-h);
|
|
265
|
+
padding: 0 14px;
|
|
266
|
+
border-bottom: var(--border);
|
|
267
|
+
font-size: 13px;
|
|
268
|
+
font-weight: 600;
|
|
269
|
+
flex-shrink: 0;
|
|
270
|
+
}
|
|
271
|
+
.dialog-body { padding: 10px 14px; overflow: auto; min-height: 0; }
|
|
272
|
+
.dialog-foot {
|
|
273
|
+
display: flex;
|
|
274
|
+
gap: 8px;
|
|
275
|
+
justify-content: flex-end;
|
|
276
|
+
padding: 8px 14px;
|
|
277
|
+
border-top: var(--border);
|
|
278
|
+
flex-shrink: 0;
|
|
279
|
+
}
|
|
280
|
+
pre.block {
|
|
281
|
+
margin: 0;
|
|
282
|
+
padding: 10px 14px;
|
|
283
|
+
background: var(--soft);
|
|
284
|
+
border-radius: 8px;
|
|
285
|
+
font-family: ui-monospace, Consolas, monospace;
|
|
286
|
+
font-size: 11px;
|
|
287
|
+
line-height: 1.55;
|
|
288
|
+
white-space: pre-wrap;
|
|
289
|
+
word-break: break-word;
|
|
290
|
+
max-height: 60vh;
|
|
291
|
+
overflow: auto;
|
|
292
|
+
}
|
|
293
|
+
.hint { color: var(--mid); font-size: 11px; }
|
|
294
|
+
.stack { display: flex; flex-direction: column; gap: 10px; }
|
|
295
|
+
|
|
296
|
+
/* ── 布局:flex + wrap,不写死宽度;min-width:0 是关键,否则长内容撑破容器 ── */
|
|
297
|
+
.toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
298
|
+
.cols { display: flex; gap: 14px; align-items: flex-start; padding: 0 14px 14px; }
|
|
299
|
+
.col { flex: 1; min-width: 0; }
|
|
300
|
+
.desc { color: var(--mid); }
|
|
301
|
+
.cols > .col > .card { border: var(--border); border-radius: 4px; }
|