nx-kv 0.1.1
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 +48 -0
- package/LICENSE +21 -0
- package/README.md +133 -0
- package/assets/nx-kv/SKILL.md +99 -0
- package/assets/nx-kv/references/agent-workflow.md +178 -0
- package/assets/nx-kv/references/todo-commands.md +193 -0
- package/bin/cli.mjs +10 -0
- package/package.json +57 -0
- package/src/core/config.js +94 -0
- package/src/core/errors.js +81 -0
- package/src/core/frontmatter.js +65 -0
- package/src/core/fstree.js +74 -0
- package/src/core/kvapi.js +201 -0
- package/src/core/open.js +26 -0
- package/src/core/paths.js +46 -0
- package/src/core/prompt.js +60 -0
- package/src/index.js +9 -0
- package/src/modules/auth/index.js +98 -0
- package/src/modules/auth/view.jsx +78 -0
- package/src/modules/bundled/index.js +51 -0
- package/src/modules/bundled/service.js +80 -0
- package/src/modules/group/index.js +199 -0
- package/src/modules/group/view.jsx +159 -0
- package/src/modules/system/index.js +153 -0
- package/src/modules/todo/index.js +280 -0
- package/src/modules/todo/service.js +524 -0
- package/src/modules/todo/view.jsx +280 -0
- package/src/runtime/api.js +108 -0
- package/src/runtime/cli.js +299 -0
- package/src/runtime/registry.js +56 -0
- package/src/runtime/server.js +74 -0
- package/src/runtime/spec.js +147 -0
- package/src/web/frontend/App.jsx +74 -0
- package/src/web/frontend/api/client.js +39 -0
- package/src/web/frontend/components/CliHints.jsx +25 -0
- package/src/web/frontend/components/ui.jsx +197 -0
- package/src/web/frontend/index.html +13 -0
- package/src/web/frontend/main.jsx +17 -0
- package/src/web/frontend/registry.js +12 -0
- package/src/web/frontend/store.jsx +74 -0
- package/src/web/frontend/style.css +368 -0
- package/src/web/public/assets/CliHints-3H3KOoDZ.js +1 -0
- package/src/web/public/assets/index-BnWex75m.css +1 -0
- package/src/web/public/assets/index-jhTtYCb9.js +10 -0
- package/src/web/public/assets/view-CrL-DSJu.js +1 -0
- package/src/web/public/assets/view-Du7Wv_-h.js +1 -0
- package/src/web/public/assets/view-S0N5g6v1.js +2 -0
- package/src/web/public/index.html +13 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// 模块注册表:所有功能域的 action 在此汇合。
|
|
2
|
+
//
|
|
3
|
+
// CLI 命令表、HTTP 路由表、help 文本三者全部由此派生 —— 这是
|
|
4
|
+
// 「Web 上每个操作都有等价 CLI 命令」得以成为**结构性保证**而非口头约定的原因:
|
|
5
|
+
// 一条 action 同时声明 cli 与 http,二者写在同一处,不可能分叉。
|
|
6
|
+
//
|
|
7
|
+
// 新增功能域:
|
|
8
|
+
// 1. 写 src/modules/<name>/(index.js 声明 actions,service.js 写业务,view.jsx 写面板)
|
|
9
|
+
// 2. 在下面 import 并加进 MODULES;再到 web/frontend/registry.js 登记视图
|
|
10
|
+
// tests/unit/registry.test.mjs 会断言两侧对齐,漏登记直接测试失败。
|
|
11
|
+
import { cliPathsOf } from './spec.js';
|
|
12
|
+
import system from '../modules/system/index.js';
|
|
13
|
+
import auth from '../modules/auth/index.js';
|
|
14
|
+
import group from '../modules/group/index.js';
|
|
15
|
+
import todo from '../modules/todo/index.js';
|
|
16
|
+
import bundled from '../modules/bundled/index.js';
|
|
17
|
+
|
|
18
|
+
export const MODULES = [system, auth, group, todo, bundled].sort(
|
|
19
|
+
(a, b) => (a.order ?? 100) - (b.order ?? 100)
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
export const ACTIONS = MODULES.flatMap((m) =>
|
|
23
|
+
(m.actions || []).map((a) => ({ ...a, module: m.id }))
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
// 装载期自检:把「两个模块声明了同名命令」这类问题暴露在启动瞬间,
|
|
27
|
+
// 而不是等某个用户敲到那条命令时才发现。
|
|
28
|
+
//
|
|
29
|
+
// 方向性约定(刻意的不对称):
|
|
30
|
+
// - 每条 action 都必须有 cli —— 保证「Web 上能做的,CLI 都能做」
|
|
31
|
+
// - http 允许为 null(纯 CLI 命令),但**不允许**声明 http 却没有 cli
|
|
32
|
+
const seenIds = new Set();
|
|
33
|
+
const seenCli = new Set();
|
|
34
|
+
const seenHttp = new Set();
|
|
35
|
+
for (const a of ACTIONS) {
|
|
36
|
+
if (seenIds.has(a.id)) throw new Error('action id 重复: ' + a.id);
|
|
37
|
+
seenIds.add(a.id);
|
|
38
|
+
|
|
39
|
+
const paths = cliPathsOf(a);
|
|
40
|
+
if (!paths.length) throw new Error(`action 没有声明 CLI 命令(Web 操作必须有 CLI 等价): ${a.id}`);
|
|
41
|
+
for (const path of paths) {
|
|
42
|
+
const key = path.join(' ');
|
|
43
|
+
if (seenCli.has(key)) throw new Error(`CLI 命令重复: ${key}(action ${a.id})`);
|
|
44
|
+
seenCli.add(key);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (a.http) {
|
|
48
|
+
const httpKey = a.http[0] + ' ' + a.http[1];
|
|
49
|
+
if (seenHttp.has(httpKey)) throw new Error(`HTTP 路由重复: ${httpKey}(action ${a.id})`);
|
|
50
|
+
seenHttp.add(httpKey);
|
|
51
|
+
} else if (a.http !== null) {
|
|
52
|
+
throw new Error(`action 的 http 必须是路由数组或显式 null: ${a.id}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!a.run) throw new Error(`action 缺少 run: ${a.id}`);
|
|
56
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Web 服务器:node:http 零依赖。
|
|
2
|
+
// 职责只有两件事:发静态面板文件 + 把 /api/* 交给路由层(见 api.js)。
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import { extname, resolve, sep } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import fsp from 'node:fs/promises';
|
|
7
|
+
import { handleApi, sendJson } from './api.js';
|
|
8
|
+
import { DEFAULT_PORT } from '../core/paths.js';
|
|
9
|
+
|
|
10
|
+
// vite 的 outDir:src/web/public(构建产物,gitignore)
|
|
11
|
+
const PUBLIC_DIR = resolve(fileURLToPath(new URL('../web/public/', import.meta.url)));
|
|
12
|
+
|
|
13
|
+
const MIME = {
|
|
14
|
+
'.html': 'text/html; charset=utf-8',
|
|
15
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
16
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
17
|
+
'.css': 'text/css; charset=utf-8',
|
|
18
|
+
'.json': 'application/json; charset=utf-8',
|
|
19
|
+
'.svg': 'image/svg+xml',
|
|
20
|
+
'.png': 'image/png',
|
|
21
|
+
'.jpg': 'image/jpeg',
|
|
22
|
+
'.webp': 'image/webp',
|
|
23
|
+
'.ico': 'image/x-icon',
|
|
24
|
+
'.woff2': 'font/woff2',
|
|
25
|
+
'.map': 'application/json; charset=utf-8',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
async function serveStatic(pathname, res) {
|
|
29
|
+
const rel = pathname === '/' ? 'index.html' : decodeURIComponent(pathname).replace(/^\/+/, '');
|
|
30
|
+
const file = resolve(PUBLIC_DIR, rel);
|
|
31
|
+
|
|
32
|
+
// 越界校验:解析成绝对路径后必须仍落在 PUBLIC_DIR 内。
|
|
33
|
+
// 比对解析结果,而不是剥离 '..' 字面量——后者挡不住编码变形。
|
|
34
|
+
if (file !== PUBLIC_DIR && !file.startsWith(PUBLIC_DIR + sep)) {
|
|
35
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
|
|
36
|
+
res.end('Forbidden');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const buf = await fsp.readFile(file);
|
|
42
|
+
res.writeHead(200, {
|
|
43
|
+
'content-type': MIME[extname(file).toLowerCase()] || 'application/octet-stream',
|
|
44
|
+
'cache-control': 'no-store',
|
|
45
|
+
});
|
|
46
|
+
res.end(buf);
|
|
47
|
+
} catch {
|
|
48
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
49
|
+
res.end('Not Found');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function startServer({ port = DEFAULT_PORT, host = '127.0.0.1' } = {}) {
|
|
54
|
+
const server = http.createServer(async (req, res) => {
|
|
55
|
+
try {
|
|
56
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
57
|
+
if (url.pathname.startsWith('/api/')) {
|
|
58
|
+
await handleApi(req, res, url);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
await serveStatic(url.pathname, res);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
sendJson(res, 500, {
|
|
64
|
+
ok: false,
|
|
65
|
+
error: String((err && err.message) || err),
|
|
66
|
+
code: 'INTERNAL',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
return new Promise((resolve_, reject) => {
|
|
71
|
+
server.once('error', reject);
|
|
72
|
+
server.listen(port, host, () => resolve_(server));
|
|
73
|
+
});
|
|
74
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// action 规格的解析、校验与文本生成。
|
|
2
|
+
//
|
|
3
|
+
// 一处声明,两端生效:CLI 的 `--flag` 与 HTTP 的 query/body 都走同一套强转与校验。
|
|
4
|
+
// 这正是历史上 `parseInt(flags.depth, 10) || 3` 散落在各处的根源——两端各写一遍解析,
|
|
5
|
+
// 迟早分叉。现在 depth 只在 action 声明里写一次 `{ type: 'number', default: 3 }`。
|
|
6
|
+
import { badInput } from '../core/errors.js';
|
|
7
|
+
|
|
8
|
+
const TYPES = new Set(['string', 'number', 'boolean', 'array']);
|
|
9
|
+
|
|
10
|
+
// 取 action 的 CLI 路径列表。支持两种写法:
|
|
11
|
+
// 单路径 cli: ['repo', 'list']
|
|
12
|
+
// 多路径 cli: [['bundled','list'], ['skill','install','--list']] ← 别名
|
|
13
|
+
export function cliPathsOf(action) {
|
|
14
|
+
const cli = action.cli;
|
|
15
|
+
if (!cli || !cli.length) return [];
|
|
16
|
+
return Array.isArray(cli[0]) ? cli : [cli];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 位置参数规格:字符串表示必填,对象可声明 optional / rest(收集剩余全部)
|
|
20
|
+
export function argSpecsOf(action) {
|
|
21
|
+
return (action.args || []).map((a) =>
|
|
22
|
+
typeof a === 'string'
|
|
23
|
+
? { name: a, required: true }
|
|
24
|
+
: { ...a, required: a.required !== false }
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function flagSpecsOf(action) {
|
|
29
|
+
return Object.entries(action.flags || {}).map(([name, spec]) => ({ name, ...spec }));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 该 action 里哪些 flag 是布尔型——CLI 解析据此决定要不要吃掉下一个 token 当值。
|
|
33
|
+
// 少了这份信息,`skill install --force demo` 会把 force 解析成字符串 'demo'。
|
|
34
|
+
export function booleanFlagNames(action) {
|
|
35
|
+
return new Set(flagSpecsOf(action).filter((f) => (f.type || 'string') === 'boolean').map((f) => f.name));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function coerceOne(name, spec, raw) {
|
|
39
|
+
const type = spec.type || 'string';
|
|
40
|
+
if (!TYPES.has(type)) throw new Error(`action 声明有误:flag "${name}" 的 type 非法 (${type})`);
|
|
41
|
+
if (raw === undefined || raw === null) return undefined;
|
|
42
|
+
|
|
43
|
+
// 需要值的 flag 写在末尾时(`--depth` 后面没跟东西),解析器只能记成 true。
|
|
44
|
+
// 放任下去:Number(true) === 1 会静默变成「深度 1」这种另一个合法值,
|
|
45
|
+
// String(true) 则会让路径变成 'true'。宁可报错,也不要把笔误变成别的语义。
|
|
46
|
+
if (raw === true && type !== 'boolean') {
|
|
47
|
+
throw badInput(`参数 --${name} 需要提供值`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (type === 'boolean') {
|
|
51
|
+
if (typeof raw === 'boolean') return raw;
|
|
52
|
+
const s = String(raw).toLowerCase();
|
|
53
|
+
return !(s === 'false' || s === '0' || s === 'no' || s === '');
|
|
54
|
+
}
|
|
55
|
+
if (type === 'number') {
|
|
56
|
+
const n = Number(raw);
|
|
57
|
+
if (!Number.isFinite(n)) throw badInput(`参数 ${name} 必须是数字,收到: ${raw}`);
|
|
58
|
+
return n;
|
|
59
|
+
}
|
|
60
|
+
if (type === 'array') {
|
|
61
|
+
if (Array.isArray(raw)) return raw.map((x) => String(x).trim()).filter(Boolean);
|
|
62
|
+
return String(raw).split(',').map((x) => x.trim()).filter(Boolean);
|
|
63
|
+
}
|
|
64
|
+
return String(raw);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 参数缺失/非法时的统一报错。
|
|
68
|
+
//
|
|
69
|
+
// 「用法:」前缀是**对外契约**:assets/repo-hub/references/agent-workflow.md 明确
|
|
70
|
+
// 教 agent 用错误文本里的「用法:」判定为参数错误,并据此决定「停下来问人」。
|
|
71
|
+
// 所以这里不能只写「缺少参数 X」,必须带上完整用法串。
|
|
72
|
+
function inputError(action, detail) {
|
|
73
|
+
return badInput(`用法: ${usageOf(action)} —— ${detail}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 按声明校验并强转输入。未声明的键原样透传(HTTP body 可能带界面用的额外字段)。
|
|
77
|
+
export function applySpec(action, raw) {
|
|
78
|
+
const out = { ...raw };
|
|
79
|
+
|
|
80
|
+
for (const spec of flagSpecsOf(action)) {
|
|
81
|
+
const v = coerceOne(spec.name, spec, raw[spec.name]);
|
|
82
|
+
if (v === undefined) {
|
|
83
|
+
if (spec.default !== undefined) out[spec.name] = spec.default;
|
|
84
|
+
else if (spec.required) throw inputError(action, `缺少参数 --${spec.name}`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (spec.enum && !spec.enum.includes(v)) {
|
|
88
|
+
throw inputError(action, `参数 --${spec.name} 只能是 ${spec.enum.join(' | ')},收到: ${v}`);
|
|
89
|
+
}
|
|
90
|
+
out[spec.name] = v;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const spec of argSpecsOf(action)) {
|
|
94
|
+
const v = out[spec.name];
|
|
95
|
+
const empty = v === undefined || v === '' || (Array.isArray(v) && !v.length);
|
|
96
|
+
if (empty && spec.required) throw inputError(action, `缺少参数 <${spec.name}>`);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- HTTP 路由模式编译:'/api/repos/:id' → 正则 + 键名 ----
|
|
102
|
+
|
|
103
|
+
const ROUTE_CACHE = new WeakMap();
|
|
104
|
+
|
|
105
|
+
export function compileRoute(action) {
|
|
106
|
+
const cached = ROUTE_CACHE.get(action);
|
|
107
|
+
if (cached) return cached;
|
|
108
|
+
const [method, pattern] = action.http;
|
|
109
|
+
const keys = [];
|
|
110
|
+
const body = pattern
|
|
111
|
+
.split('/')
|
|
112
|
+
.map((seg) => {
|
|
113
|
+
if (!seg.startsWith(':')) return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
114
|
+
keys.push(seg.slice(1));
|
|
115
|
+
return '([^/]+)';
|
|
116
|
+
})
|
|
117
|
+
.join('/');
|
|
118
|
+
const compiled = { method, keys, regex: new RegExp('^' + body + '$') };
|
|
119
|
+
ROUTE_CACHE.set(action, compiled);
|
|
120
|
+
return compiled;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---- 用法串生成:从声明反推,help 因此不可能与实际命令脱节 ----
|
|
124
|
+
|
|
125
|
+
export function usageOf(action) {
|
|
126
|
+
const [path] = cliPathsOf(action);
|
|
127
|
+
const parts = ['nx-kv', ...path];
|
|
128
|
+
|
|
129
|
+
for (const a of argSpecsOf(action)) {
|
|
130
|
+
const label = a.rest ? `${a.name}...` : a.name;
|
|
131
|
+
parts.push(a.required ? `<${label}>` : `[${label}]`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const f of flagSpecsOf(action)) {
|
|
135
|
+
let token;
|
|
136
|
+
if (f.type === 'boolean') {
|
|
137
|
+
token = `--${f.name}`;
|
|
138
|
+
} else {
|
|
139
|
+
// 值的占位符优先用 enum 展开(--side <ours|theirs>),其次显式 hint,
|
|
140
|
+
// 最后才退回 flag 名——`--http <http>` 这种读起来没有信息量。
|
|
141
|
+
const hint = f.enum ? f.enum.join('|') : f.hint || f.name;
|
|
142
|
+
token = `--${f.name} <${hint}>`;
|
|
143
|
+
}
|
|
144
|
+
parts.push(f.required ? token : `[${token}]`);
|
|
145
|
+
}
|
|
146
|
+
return parts.join(' ');
|
|
147
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// 应用壳:顶栏 + tab 导航(注册表驱动)+ hash 路由。
|
|
2
|
+
// 路由即持久化:当前视图写进 location.hash 与 store,刷新/分享链接都停在原页面。
|
|
3
|
+
import { Suspense, useEffect } from 'react';
|
|
4
|
+
import { useStore } from './store.jsx';
|
|
5
|
+
import { ErrorBoundary } from './components/ui.jsx';
|
|
6
|
+
import { VIEWS } from './registry.js';
|
|
7
|
+
|
|
8
|
+
function viewFromHash() {
|
|
9
|
+
const h = (location.hash || '').replace(/^#\/?/, '');
|
|
10
|
+
return VIEWS.some((v) => v.id === h) ? h : '';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function App() {
|
|
14
|
+
const { boot, ui, patchUi } = useStore();
|
|
15
|
+
|
|
16
|
+
// 初始进入:hash 优先,否则用持久化的上次视图
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const fromHash = viewFromHash();
|
|
19
|
+
if (fromHash && fromHash !== ui.view) patchUi({ view: fromHash });
|
|
20
|
+
else if (!location.hash) location.hash = '#/' + ui.view;
|
|
21
|
+
// 只在挂载时对齐一次:后续 hash 变化由下面的 hashchange 监听接管
|
|
22
|
+
}, []);
|
|
23
|
+
|
|
24
|
+
// hash 变化(浏览器前进后退)→ 同步 store
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const onHash = () => {
|
|
27
|
+
const v = viewFromHash();
|
|
28
|
+
if (v) patchUi({ view: v });
|
|
29
|
+
};
|
|
30
|
+
window.addEventListener('hashchange', onHash);
|
|
31
|
+
return () => window.removeEventListener('hashchange', onHash);
|
|
32
|
+
}, [patchUi]);
|
|
33
|
+
|
|
34
|
+
const current = VIEWS.find((v) => v.id === ui.view) || VIEWS[0];
|
|
35
|
+
|
|
36
|
+
const switchTo = (id) => {
|
|
37
|
+
patchUi({ view: id });
|
|
38
|
+
location.hash = '#/' + id;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<>
|
|
43
|
+
<header>
|
|
44
|
+
<div className="brand">nx-kv<span className="sub">nx-kv</span></div>
|
|
45
|
+
<nav>
|
|
46
|
+
{VIEWS.map((v) => (
|
|
47
|
+
<button
|
|
48
|
+
key={v.id}
|
|
49
|
+
className={'tab' + (v.id === current.id ? ' active' : '')}
|
|
50
|
+
onClick={() => switchTo(v.id)}
|
|
51
|
+
>
|
|
52
|
+
{v.title}
|
|
53
|
+
</button>
|
|
54
|
+
))}
|
|
55
|
+
</nav>
|
|
56
|
+
<div className="meta">{boot?.storePath || ''}</div>
|
|
57
|
+
</header>
|
|
58
|
+
|
|
59
|
+
<main>
|
|
60
|
+
{/* 边界放在 Suspense 外层:懒加载 chunk 拉取失败也由它接管,
|
|
61
|
+
否则一个视图崩掉就是整页白屏 */}
|
|
62
|
+
<ErrorBoundary key={current.id}>
|
|
63
|
+
<Suspense fallback={<div className="muted" style={{ padding: 24 }}>加载中…</div>}>
|
|
64
|
+
<section className="panel active"><current.component /></section>
|
|
65
|
+
</Suspense>
|
|
66
|
+
</ErrorBoundary>
|
|
67
|
+
</main>
|
|
68
|
+
|
|
69
|
+
<footer>
|
|
70
|
+
面板上每个按钮都有同构 CLI 命令(见各页底部提示)· <code>nx-kv help</code> · agent 可加 <code>--json</code> 获取机器可读输出
|
|
71
|
+
</footer>
|
|
72
|
+
</>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// API 客户端:全部面板操作走 /api,与 CLI 共享同一份 action 声明。
|
|
2
|
+
//
|
|
3
|
+
// 响应包约定 { ok: true, data } / { ok: false, error, code }。
|
|
4
|
+
// 失败时抛 Error 并带上 .code —— 调用方可据此区分「业务冲突」与「参数错误」,
|
|
5
|
+
// 而不是像改造前那样只能对错误文案做字符串匹配。
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
7
|
+
|
|
8
|
+
export async function api(path, opts = {}) {
|
|
9
|
+
const ctrl = new AbortController();
|
|
10
|
+
const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs || DEFAULT_TIMEOUT_MS);
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const res = await fetch(path, {
|
|
14
|
+
method: opts.method || 'GET',
|
|
15
|
+
headers: opts.body ? { 'content-type': 'application/json' } : undefined,
|
|
16
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
17
|
+
signal: ctrl.signal,
|
|
18
|
+
});
|
|
19
|
+
let json;
|
|
20
|
+
try {
|
|
21
|
+
json = await res.json();
|
|
22
|
+
} catch {
|
|
23
|
+
throw new Error(`HTTP ${res.status}`);
|
|
24
|
+
}
|
|
25
|
+
if (!json.ok) {
|
|
26
|
+
const err = new Error(json.error || '请求失败');
|
|
27
|
+
err.code = json.code;
|
|
28
|
+
err.details = json.details;
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
return json.data;
|
|
32
|
+
} catch (e) {
|
|
33
|
+
// AbortError 的原生文案是「The operation was aborted」,对用户没有意义
|
|
34
|
+
if (e && e.name === 'AbortError') throw new Error('请求超时(本地服务无响应)');
|
|
35
|
+
throw e;
|
|
36
|
+
} finally {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// CLI 等价提示:列出当前模块的全部同构命令。
|
|
2
|
+
//
|
|
3
|
+
// 为什么由数据派生而不是手写:这行提示是用户核对「面板上这个按钮到底有没有 CLI 等价」
|
|
4
|
+
// 的唯一依据。改造前它是三个视图各自维护的字符串(reposView / skillsView / githubView
|
|
5
|
+
// 各写一行),命令一改就悄悄过期——提示说「有」,实际没有,比没有提示更糟。
|
|
6
|
+
// 现在它来自 bootstrap 下发的命令表,与 CLI 实际注册的命令同源。
|
|
7
|
+
import { useStore } from '../store.jsx';
|
|
8
|
+
import { Copyable } from './ui.jsx';
|
|
9
|
+
|
|
10
|
+
export function CliHints({ module: moduleId }) {
|
|
11
|
+
const { boot } = useStore();
|
|
12
|
+
const cmds = (boot?.commands || []).filter((c) => c.module === moduleId);
|
|
13
|
+
if (!cmds.length) return null;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div className="cli-hint">
|
|
17
|
+
<span className="cli-hint-label">CLI 等价(同构命令,加 --json 得机器可读输出):</span>
|
|
18
|
+
{cmds.map((c) => (
|
|
19
|
+
<Copyable key={c.id} className="cli-cmd" text={c.command} title={`点击复制:${c.usage}`}>
|
|
20
|
+
{c.command}
|
|
21
|
+
</Copyable>
|
|
22
|
+
))}
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// 通用 UI 件:toast、对话框、弹窗、错误边界、diff 渲染。
|
|
2
|
+
// 约定延续自 vanilla 版:无 emoji、不用浏览器原生弹窗(alert/confirm/prompt 一律页内实现)。
|
|
3
|
+
import { Component, createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
// ---- toast:页内轻提示(自动消失) ----
|
|
6
|
+
|
|
7
|
+
const ToastCtx = createContext(null);
|
|
8
|
+
|
|
9
|
+
export function ToastProvider({ children }) {
|
|
10
|
+
const [msgs, setMsgs] = useState([]);
|
|
11
|
+
const idRef = useRef(0);
|
|
12
|
+
|
|
13
|
+
const toast = useCallback((msg) => {
|
|
14
|
+
const id = ++idRef.current;
|
|
15
|
+
setMsgs((m) => [...m, { id, msg }]);
|
|
16
|
+
setTimeout(() => setMsgs((m) => m.filter((x) => x.id !== id)), 2800);
|
|
17
|
+
}, []);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<ToastCtx.Provider value={toast}>
|
|
21
|
+
{children}
|
|
22
|
+
<div className="toast-stack">
|
|
23
|
+
{msgs.map((m) => <div key={m.id} className="toast show">{m.msg}</div>)}
|
|
24
|
+
</div>
|
|
25
|
+
</ToastCtx.Provider>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useToast() {
|
|
30
|
+
return useContext(ToastCtx) || ((m) => console.log(m));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 操作守卫:包一层,失败自动 toast 错误信息(对应 vanilla 版的 guard())
|
|
34
|
+
export function useGuard() {
|
|
35
|
+
const toast = useToast();
|
|
36
|
+
return useCallback(async (fn) => {
|
|
37
|
+
try { return await fn(); } catch (e) { toast(String((e && e.message) || e)); }
|
|
38
|
+
}, [toast]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---- 点击即复制:所有路径/长标识的展示标准 ----
|
|
42
|
+
// 点一下复制全文;hover 显示「点击复制」提示;复制成功 toast 确认。
|
|
43
|
+
export function Copyable({ text, className = '', title, children }) {
|
|
44
|
+
const toast = useToast();
|
|
45
|
+
const copy = async () => {
|
|
46
|
+
const v = String(text ?? '');
|
|
47
|
+
try {
|
|
48
|
+
await navigator.clipboard.writeText(v);
|
|
49
|
+
toast('已复制: ' + (v.length > 60 ? v.slice(0, 57) + '...' : v));
|
|
50
|
+
} catch {
|
|
51
|
+
// 剪贴板 API 不可用(非安全上下文等):退回 execCommand
|
|
52
|
+
try {
|
|
53
|
+
const ta = document.createElement('textarea');
|
|
54
|
+
ta.value = v;
|
|
55
|
+
ta.style.cssText = 'position:fixed;opacity:0';
|
|
56
|
+
document.body.appendChild(ta);
|
|
57
|
+
ta.select();
|
|
58
|
+
document.execCommand('copy');
|
|
59
|
+
ta.remove();
|
|
60
|
+
toast('已复制: ' + (v.length > 60 ? v.slice(0, 57) + '...' : v));
|
|
61
|
+
} catch {
|
|
62
|
+
toast('复制失败(剪贴板不可用)');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
return (
|
|
67
|
+
<span
|
|
68
|
+
className={'copyable' + (className ? ' ' + className : '')}
|
|
69
|
+
title={title || '点击复制'}
|
|
70
|
+
onClick={copy}
|
|
71
|
+
>{children !== undefined ? children : text}</span>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---- 对话框:确认 / 输入(Promise 风格,对应 vanilla 版 dialog()) ----
|
|
76
|
+
|
|
77
|
+
export function useDialog() {
|
|
78
|
+
const [state, setState] = useState(null); // {resolve, ...opts}
|
|
79
|
+
// 用 ref 读输入框,而不是 document.querySelector('.dlg-input')——
|
|
80
|
+
// 后者绕开 React 直接摸 DOM,页面上有第二个同名类名时就会读错。
|
|
81
|
+
const inputRef = useRef(null);
|
|
82
|
+
|
|
83
|
+
const close = useCallback((val) => {
|
|
84
|
+
setState((s) => { if (s && s.resolve) s.resolve(val); return null; });
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
const dialog = useCallback((opts = {}) => new Promise((resolve) => {
|
|
88
|
+
setState({ ...opts, resolve });
|
|
89
|
+
}), []);
|
|
90
|
+
|
|
91
|
+
const node = state ? (
|
|
92
|
+
<div className="dlg" onMouseDown={(e) => { if (e.target === e.currentTarget) close(null); }}>
|
|
93
|
+
<div className="dlg-box">
|
|
94
|
+
{state.title ? <div className="dlg-title">{state.title}</div> : null}
|
|
95
|
+
{state.message ? <div className="dlg-msg">{state.message}</div> : null}
|
|
96
|
+
{state.input ? (
|
|
97
|
+
<input
|
|
98
|
+
ref={inputRef}
|
|
99
|
+
className="dlg-input"
|
|
100
|
+
autoFocus
|
|
101
|
+
spellCheck="false"
|
|
102
|
+
placeholder={state.placeholder || ''}
|
|
103
|
+
defaultValue={state.value || ''}
|
|
104
|
+
onKeyDown={(e) => {
|
|
105
|
+
if (e.key === 'Enter') close(e.currentTarget.value.trim());
|
|
106
|
+
if (e.key === 'Escape') close(null);
|
|
107
|
+
}}
|
|
108
|
+
/>
|
|
109
|
+
) : null}
|
|
110
|
+
<div className="dlg-acts">
|
|
111
|
+
<button className="btn ghost" onClick={() => close(null)}>取消</button>
|
|
112
|
+
<button
|
|
113
|
+
className={'btn' + (state.danger ? ' danger' : '')}
|
|
114
|
+
onClick={() => close(state.input ? (inputRef.current?.value.trim() ?? null) : true)}
|
|
115
|
+
>
|
|
116
|
+
{state.okText || '确定'}
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
) : null;
|
|
122
|
+
|
|
123
|
+
return { dialog, node };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---- 错误边界:把崩溃限制在单个视图内 ----
|
|
127
|
+
// 视图都是 lazy() 加载的,没有这层兜底时,任何一个视图抛错(或 chunk 加载失败)
|
|
128
|
+
// 都会让整个面板白屏,用户连切到别的 tab 自救都做不到。
|
|
129
|
+
|
|
130
|
+
export class ErrorBoundary extends Component {
|
|
131
|
+
constructor(props) {
|
|
132
|
+
super(props);
|
|
133
|
+
this.state = { error: null };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
static getDerivedStateFromError(error) {
|
|
137
|
+
return { error };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
componentDidCatch(error, info) {
|
|
141
|
+
console.error('[nx-kv] 视图渲染失败:', error, info);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
render() {
|
|
145
|
+
if (!this.state.error) return this.props.children;
|
|
146
|
+
return (
|
|
147
|
+
<div className="card" style={{ padding: 16 }}>
|
|
148
|
+
<div className="colhead"><h3>这个面板出错了</h3></div>
|
|
149
|
+
<div className="dlg-msg">{String(this.state.error?.message || this.state.error)}</div>
|
|
150
|
+
<div className="muted" style={{ marginBottom: 10 }}>
|
|
151
|
+
其他面板不受影响,可以切到别的 tab 继续操作;控制台有完整堆栈。
|
|
152
|
+
</div>
|
|
153
|
+
<button className="btn" onClick={() => this.setState({ error: null })}>重试</button>
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---- 弹窗:大块内容(diff / 冲突详情 / 命令输出) ----
|
|
160
|
+
|
|
161
|
+
export function Modal({ title, onClose, children }) {
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
|
|
164
|
+
document.addEventListener('keydown', onKey);
|
|
165
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
166
|
+
}, [onClose]);
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<div className="modal" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
|
170
|
+
<div className="modal-box">
|
|
171
|
+
<div className="modal-title">
|
|
172
|
+
<span>{title}</span>
|
|
173
|
+
<button className="btn small ghost" onClick={onClose}>关闭</button>
|
|
174
|
+
</div>
|
|
175
|
+
<div className="modal-body">{children}</div>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---- diff 文本渲染(+/- 行着色) ----
|
|
182
|
+
|
|
183
|
+
export function DiffPre({ text }) {
|
|
184
|
+
const lines = String(text ?? '').split('\n');
|
|
185
|
+
return (
|
|
186
|
+
<pre>
|
|
187
|
+
{lines.map((line, i) => (
|
|
188
|
+
<span key={i} className={
|
|
189
|
+
line.startsWith('+') && !line.startsWith('+++') ? 'd-add'
|
|
190
|
+
: line.startsWith('-') && !line.startsWith('---') ? 'd-del' : ''
|
|
191
|
+
}>
|
|
192
|
+
{line}{i < lines.length - 1 ? '\n' : ''}
|
|
193
|
+
</span>
|
|
194
|
+
))}
|
|
195
|
+
</pre>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
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-kv · nx-kv</title>
|
|
7
|
+
<link rel="stylesheet" href="/style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script type="module" src="/main.jsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// 面板入口:Provider 组装(store → toast → dialog → App)。
|
|
2
|
+
import { StrictMode } from 'react';
|
|
3
|
+
import { createRoot } from 'react-dom/client';
|
|
4
|
+
import App from './App.jsx';
|
|
5
|
+
import { StoreProvider } from './store.jsx';
|
|
6
|
+
import { ToastProvider } from './components/ui.jsx';
|
|
7
|
+
import './style.css';
|
|
8
|
+
|
|
9
|
+
createRoot(document.getElementById('root')).render(
|
|
10
|
+
<StrictMode>
|
|
11
|
+
<StoreProvider>
|
|
12
|
+
<ToastProvider>
|
|
13
|
+
<App />
|
|
14
|
+
</ToastProvider>
|
|
15
|
+
</StoreProvider>
|
|
16
|
+
</StrictMode>
|
|
17
|
+
);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// 视图注册表:新增面板 = 在模块里写 view.jsx + 在这里登记一行。
|
|
2
|
+
// App 的 tab 导航、hash 路由、懒加载全部由这张表驱动,改面板不用动壳。
|
|
3
|
+
//
|
|
4
|
+
// 与后端 src/runtime/registry.js 的模块表一一对应;tests/unit/registry.test.mjs
|
|
5
|
+
// 会断言两侧对齐——漏登记(或登记了不存在的视图)都会直接测试失败。
|
|
6
|
+
import { lazy } from 'react';
|
|
7
|
+
|
|
8
|
+
export const VIEWS = [
|
|
9
|
+
{ id: 'todo', title: '清单', component: lazy(() => import('../../modules/todo/view.jsx')) },
|
|
10
|
+
{ id: 'group', title: '工作空间', component: lazy(() => import('../../modules/group/view.jsx')) },
|
|
11
|
+
{ id: 'auth', title: '登录', component: lazy(() => import('../../modules/auth/view.jsx')) },
|
|
12
|
+
];
|