@clawos-dev/clawd 0.2.142-beta.298.934a65b → 0.2.143-beta.300.c2a3792
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/dist/cli.cjs +664 -1367
- package/dist/persona-defaults/persona-app-builder/CLAUDE.md +0 -3
- package/dist/persona-defaults/persona-bug-fixer/CLAUDE.md +0 -2
- package/dist/persona-defaults/persona-dataclaw-builder/.mcp.json +4 -0
- package/dist/persona-defaults/persona-dataclaw-builder/.secrets/dataclaw.env.example +3 -0
- package/dist/persona-defaults/persona-dataclaw-builder/CLAUDE.md +20 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/README.md +96 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/config.env +20 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/contract/bootstrap +22 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/contract/s.yaml.tmpl +59 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/.env.example +15 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/.fcignore +7 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/nest-cli.json +8 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/package.json +29 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/app.module.ts +13 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/auth/gate.ts +70 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/auth/session.ts +52 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/auth/ttc-user-info.ts +23 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/dataclaw/dataclaw.client.ts +83 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/dataclaw/dataclaw.config.ts +18 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/dataclaw/dataclaw.controller.ts +30 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/dataclaw/dataclaw.module.ts +9 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/main.ts +116 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/polyfill.ts +6 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/tsconfig.json +14 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/index.html +12 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/package.json +18 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/App.jsx +9 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/api.js +19 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/auth.js +7 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/components/BarChart.jsx +38 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/components/FilterRow.jsx +79 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/components/ResultCards.jsx +17 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/components/ResultTable.jsx +21 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/components/SchemaFilters.jsx +93 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/enums.js +22 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/main.jsx +6 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/pages/PerformanceQueryPage.jsx +102 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/styles.css +83 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/vite.config.js +30 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/scripts/ensure-toolchain.sh +62 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/scripts/new-extension.sh +66 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/scripts/publish.sh +129 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/scripts/remove-extension.sh +70 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/scripts/verify.sh +38 -0
- package/dist/persona-defaults/persona-developer/CLAUDE.md +0 -2
- package/package.json +2 -5
- package/dist/dispatch/mcp-server.cjs +0 -39756
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import './polyfill';
|
|
2
|
+
import { NestFactory } from '@nestjs/core';
|
|
3
|
+
import { NestExpressApplication } from '@nestjs/platform-express';
|
|
4
|
+
import { AppModule } from './app.module';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import cookieParser from 'cookie-parser';
|
|
7
|
+
import { authGate, loginHandler, callbackHandler } from './auth/gate';
|
|
8
|
+
|
|
9
|
+
// app-builder build 模式(spec 2026-06-01 + 2026-06-02 §5.6.4 子路径 fix):
|
|
10
|
+
// - dev(通过 clawd 启):nest 单进程 mount vite middleware,前端 HMR + /api 都从
|
|
11
|
+
// CLAWD_PREVIEW_PORT 出。所有 URL 走子路径 `/preview/<port>/`,含两层 prefix 处理:
|
|
12
|
+
// 1. vite createServer 传 base=/preview/<port>/,让 vite middleware 识别带前缀的
|
|
13
|
+
// 请求并剥前缀做内部路由 + HTML transform 时给 asset URL 加前缀
|
|
14
|
+
// 2. nest setGlobalPrefix=preview/<port>/api,让 /preview/<port>/api/* 也被 nest 路由匹配
|
|
15
|
+
// 不这样配,daemon preview-proxy 反代过来的请求(含 /preview/<port>/ 前缀)会被 nest 和 vite
|
|
16
|
+
// 都不识别 → 全 404 / 'spa' 兜底返 HTML(API 接不通)
|
|
17
|
+
// - prod / 脱离 clawd:nest 起来时 express.static serve web/dist,base=/ globalPrefix=api
|
|
18
|
+
//
|
|
19
|
+
// CLAWD_PREVIEW_PORT 由 daemon supervisor 启动时注入;FC_SERVER_PORT / PORT 是 FC 部署 / 本地脱离 clawd 调试的回退。
|
|
20
|
+
async function bootstrap() {
|
|
21
|
+
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
|
22
|
+
|
|
23
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
24
|
+
const isClawdDev = !!process.env.CLAWD_PREVIEW_PORT;
|
|
25
|
+
const port =
|
|
26
|
+
Number(process.env.CLAWD_PREVIEW_PORT) ||
|
|
27
|
+
Number(process.env.FC_SERVER_PORT) ||
|
|
28
|
+
Number(process.env.PORT) ||
|
|
29
|
+
3000;
|
|
30
|
+
|
|
31
|
+
// 子路径 prefix:clawd dev 模式下挂 /preview/<port>/,其它(prod / 本地 vite 独跑)走根路径。
|
|
32
|
+
const subPath = isClawdDev ? `preview/${port}` : '';
|
|
33
|
+
const apiPrefix = subPath ? `${subPath}/api` : 'api';
|
|
34
|
+
const viteBase = subPath ? `/${subPath}/` : '/';
|
|
35
|
+
|
|
36
|
+
app.setGlobalPrefix(apiPrefix, { exclude: ['/'] });
|
|
37
|
+
|
|
38
|
+
// TTC 飞书登录门禁(PR-6)。前端 HTML 由 vite middleware(dev) / express.static(prod) serve、
|
|
39
|
+
// 不走 Nest 路由,Nest guard 拦不住,所以门禁是 Express 中间件,必须挂在下方 vite middleware
|
|
40
|
+
// (约:75) / useStaticAssets(约:99) 之前。/api/* 也先经过它(gate 在 vite 守卫和 nest router 之前)。
|
|
41
|
+
// /auth/login、/auth/callback 直接在 express 层注册,不带 globalPrefix;子路径部署带同样前缀。
|
|
42
|
+
// fail-loud:门禁启用却没配 SESSION_SECRET,会用空密钥签 cookie(可被伪造)—— 直接拒启动。
|
|
43
|
+
if (process.env.DATACLAW_AUTH_DISABLED !== '1' && !process.env.SESSION_SECRET) {
|
|
44
|
+
throw new Error('[auth] 缺 SESSION_SECRET:登录门禁启用时必填(否则 session cookie 可被伪造)。本地调试可设 DATACLAW_AUTH_DISABLED=1 跳过门禁。');
|
|
45
|
+
}
|
|
46
|
+
app.use(cookieParser());
|
|
47
|
+
const httpAdapter = app.getHttpAdapter();
|
|
48
|
+
const authBasePath = subPath ? `/${subPath}` : '';
|
|
49
|
+
httpAdapter.get(`${authBasePath}/auth/login`, loginHandler);
|
|
50
|
+
httpAdapter.get(`${authBasePath}/auth/callback`, callbackHandler);
|
|
51
|
+
app.use(authGate);
|
|
52
|
+
|
|
53
|
+
if (isDev && isClawdDev) {
|
|
54
|
+
// dev 模式 + 通过 clawd 启的 → mount vite middleware,root=../../web
|
|
55
|
+
const { createServer: createViteServer } = await import('vite');
|
|
56
|
+
const viteRoot = path.resolve(__dirname, '..', '..', 'web');
|
|
57
|
+
// vite 6+ 默认 server.allowedHosts: [](只放 localhost / .localhost / IP)防 DNS rebinding。
|
|
58
|
+
// frpc tunnel 转发过来的 Host header 是 daemon 当前 subdomain(CLAWD_TUNNEL_HOST 由
|
|
59
|
+
// daemon supervisor 注入),不在白名单 → vite middleware 直接拒(老板 tunnel URL 404 根因)。
|
|
60
|
+
// 显式把它加进 allowedHosts。
|
|
61
|
+
const tunnelHost = process.env.CLAWD_TUNNEL_HOST || '';
|
|
62
|
+
const allowedHosts = ['localhost', '127.0.0.1', ...(tunnelHost ? [tunnelHost] : [])];
|
|
63
|
+
const vite = await createViteServer({
|
|
64
|
+
root: viteRoot,
|
|
65
|
+
// **显式 inline base**:不依赖 web/vite.config.js 解析路径(middleware mode + ESM 配置
|
|
66
|
+
// 文件解析有时不可靠)。vite 据此识别带前缀的请求 + transformIndexHtml 注入带前缀的 asset URL。
|
|
67
|
+
base: viteBase,
|
|
68
|
+
server: {
|
|
69
|
+
middlewareMode: true,
|
|
70
|
+
// HMR WS 复用 nest 的 http.Server —— 跟 HTTP 共享同一个 fd / 同一个端口。
|
|
71
|
+
// 不设这个 vite 会 fallback standalone 用 24678,多 project 并发会撞。
|
|
72
|
+
hmr: { server: app.getHttpServer() },
|
|
73
|
+
allowedHosts,
|
|
74
|
+
},
|
|
75
|
+
// appType: 'spa' 让 vite middleware 自己 serve index.html + fallback 路由(base 下任何
|
|
76
|
+
// 非 vite asset 路径都返 index.html)。
|
|
77
|
+
appType: 'spa',
|
|
78
|
+
});
|
|
79
|
+
// /api/* 守卫:实测 vite 'spa' catch-all 会先吃掉 /api/* 返 HTML,导致 API 拿不到 JSON。
|
|
80
|
+
// Express middleware 栈顺序:vite.middlewares 这一行加进去时 nest controllers 还没挂
|
|
81
|
+
// (nest router init 在 app.listen 时),所以 vite 拦截优先于 nest router → 命中 /api 也走 vite。
|
|
82
|
+
// 守卫:req.url 命中 apiPrefix 时直接 next() 跳过 vite,让 nest router 接管返 JSON。
|
|
83
|
+
const apiPathPrefix = `/${apiPrefix}/`; // 如 '/preview/6173/api/' 或 '/api/'
|
|
84
|
+
app.use((req: any, res: any, next: any) => {
|
|
85
|
+
if (typeof req.url === 'string' && req.url.startsWith(apiPathPrefix)) {
|
|
86
|
+
return next(); // skip vite,让 nest router 处理 /api/*
|
|
87
|
+
}
|
|
88
|
+
return vite.middlewares(req, res, next);
|
|
89
|
+
});
|
|
90
|
+
console.log(`[clawd-dev] vite middleware mounted (root=${viteRoot}, base=${viteBase}, apiPrefix=${apiPrefix}, allowedHosts=${allowedHosts.join(',')})`);
|
|
91
|
+
} else {
|
|
92
|
+
// prod / 脱离 clawd 跑:直接 serve 前端静态资源。
|
|
93
|
+
//
|
|
94
|
+
// 路径解析两条路(覆盖 FC 部署与本地 monorepo 启动两种场景):
|
|
95
|
+
// 1. FC(CODE_DIR=./server 只上传 server/,运行时 __dirname=dist/)
|
|
96
|
+
// publish.sh BUILD_CMD 末尾会跑 `mkdir -p dist/public && cp -R ../web/dist/. dist/public/`
|
|
97
|
+
// 把前端打包产物塞进 dist/public,跟着 server 一起上传到 FC。所以 prod 优先用
|
|
98
|
+
// __dirname/public 这条 FC 内可达的绝对路径。
|
|
99
|
+
// 2. 本地 monorepo `node dist/main.js`(极少用,但保留兼容):__dirname/public 不存在
|
|
100
|
+
// 时 fallback 到 ../../web/dist(monorepo 相对路径)。
|
|
101
|
+
//
|
|
102
|
+
// 历史坑:spec 2026-06-03 前只有路径 2,FC 上 404 整个前端 —— 因为 ../../web/dist 在 FC
|
|
103
|
+
// 容器里压根不存在(CODE_DIR 只上传了 server/)。dev 时靠 vite middleware 走内存编译没暴露。
|
|
104
|
+
const fs = await import('node:fs');
|
|
105
|
+
const fcStaticDir = path.resolve(__dirname, 'public');
|
|
106
|
+
const monorepoStaticDir = path.resolve(__dirname, '..', '..', 'web', 'dist');
|
|
107
|
+
const staticDir = fs.existsSync(fcStaticDir) ? fcStaticDir : monorepoStaticDir;
|
|
108
|
+
app.useStaticAssets(staticDir);
|
|
109
|
+
console.log(`[prod] serving static from ${staticDir}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await app.listen(port, '0.0.0.0');
|
|
113
|
+
console.log(`server listening on :${port} (${isDev ? 'dev' : 'prod'}${isClawdDev ? ' / clawd' : ''})`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
bootstrap();
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Dev 模式 .env loader:`nest start --watch` 不会经过 `node --env-file=.env`,
|
|
2
|
+
// server 拿不到 DATACLAW_* env → loadDataclawConfig 会 fail-loud。
|
|
3
|
+
// 这里在 nest bootstrap 之前显式 require dotenv 把 .env 填进 process.env。
|
|
4
|
+
// prod 路径(node --env-file=.env dist/main.js)Node 自带 loader 已经填了,dotenv 看到
|
|
5
|
+
// 已存在的 env 不会覆盖(默认行为),行为对齐。
|
|
6
|
+
require('dotenv/config');
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"module": "commonjs",
|
|
4
|
+
"target": "ES2021",
|
|
5
|
+
"outDir": "./dist",
|
|
6
|
+
"rootDir": "./src",
|
|
7
|
+
"experimentalDecorators": true,
|
|
8
|
+
"emitDecoratorMetadata": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"sourceMap": false,
|
|
12
|
+
"declaration": false
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>App</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.jsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "guestbook-web",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vite build"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"react": "^18.3.1",
|
|
12
|
+
"react-dom": "^18.3.1"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@vitejs/plugin-react": "^4.4.0",
|
|
16
|
+
"vite": "^6.0.0"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { useAuthGate } from './auth';
|
|
2
|
+
import PerformanceQueryPage from './pages/PerformanceQueryPage';
|
|
3
|
+
|
|
4
|
+
export default function App() {
|
|
5
|
+
const { ready, authed } = useAuthGate();
|
|
6
|
+
if (!ready) return <div>加载中…</div>;
|
|
7
|
+
if (!authed) return <div>请先飞书登录</div>;
|
|
8
|
+
return <PerformanceQueryPage />;
|
|
9
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// 只调 app 自己的 BFF(/api/*),绝不直连 dataclaw —— 密钥/身份都在 server 侧注入,不进浏览器。
|
|
2
|
+
//
|
|
3
|
+
// dev 隧道模式下页面挂在子路径 /preview/<port>/ 下(见 vite.config.js base),
|
|
4
|
+
// 所以 API_BASE 要拼上 import.meta.env.BASE_URL,否则 fetch 会打到错误的绝对路径。
|
|
5
|
+
const API_BASE = (import.meta.env.BASE_URL || '/').replace(/\/+$/, '') + '/api';
|
|
6
|
+
|
|
7
|
+
async function req(path, opts = {}) {
|
|
8
|
+
const res = await fetch(API_BASE + path, {
|
|
9
|
+
headers: { 'Content-Type': 'application/json' },
|
|
10
|
+
...opts,
|
|
11
|
+
});
|
|
12
|
+
const json = await res.json().catch(() => ({}));
|
|
13
|
+
if (!res.ok || json.ok === false) throw new Error(json.error || `请求失败 ${res.status}`);
|
|
14
|
+
return json;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const getSchema = (dataset) => req(`/schema?dataset=${encodeURIComponent(dataset || '')}`);
|
|
18
|
+
export const runQuery = (body) => req('/query', { method: 'POST', body: JSON.stringify(body) });
|
|
19
|
+
export const searchConsultants = (kw) => req(`/consultants?keyword=${encodeURIComponent(kw)}`);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
// 门禁 hook 占位:真实飞书登录态检测在 PR-6。返回 { ready, authed }。
|
|
3
|
+
export function useAuthGate() {
|
|
4
|
+
const [state, setState] = useState({ ready: false, authed: false });
|
|
5
|
+
useEffect(() => { setState({ ready: true, authed: true }); }, []); // 占位:默认已登录
|
|
6
|
+
return state;
|
|
7
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// 纯 SVG 横向柱状图,不引第三方依赖。props { rows, labelField, valueField }。
|
|
2
|
+
export default function BarChart({ rows, labelField, valueField }) {
|
|
3
|
+
if (!rows || rows.length === 0) return null;
|
|
4
|
+
|
|
5
|
+
const data = rows.map((r) => ({
|
|
6
|
+
label: String(r[labelField] ?? ''),
|
|
7
|
+
value: Number(r[valueField]) || 0,
|
|
8
|
+
}));
|
|
9
|
+
const max = Math.max(...data.map((d) => d.value), 0) || 1;
|
|
10
|
+
|
|
11
|
+
const rowH = 24;
|
|
12
|
+
const gap = 8;
|
|
13
|
+
const labelW = 150;
|
|
14
|
+
const barAreaW = 360;
|
|
15
|
+
const valueW = 120;
|
|
16
|
+
const width = labelW + barAreaW + valueW;
|
|
17
|
+
const height = data.length * (rowH + gap) + gap;
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<svg width={width} height={height} role="img" aria-label="柱状图" style={{ maxWidth: '100%' }}>
|
|
21
|
+
{data.map((d, i) => {
|
|
22
|
+
const y = gap + i * (rowH + gap);
|
|
23
|
+
const w = Math.max(0, (d.value / max) * barAreaW);
|
|
24
|
+
return (
|
|
25
|
+
<g key={i}>
|
|
26
|
+
<text x={labelW - 8} y={y + rowH / 2} textAnchor="end" dominantBaseline="middle" fontSize="12" fill="#374151">
|
|
27
|
+
{d.label}
|
|
28
|
+
</text>
|
|
29
|
+
<rect x={labelW} y={y} width={w} height={rowH} rx="3" fill="#2563eb" />
|
|
30
|
+
<text x={labelW + w + 6} y={y + rowH / 2} dominantBaseline="middle" fontSize="12" fill="#1f2328">
|
|
31
|
+
{d.value.toLocaleString('zh-CN')}
|
|
32
|
+
</text>
|
|
33
|
+
</g>
|
|
34
|
+
);
|
|
35
|
+
})}
|
|
36
|
+
</svg>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { ENUM_HINTS } from '../enums';
|
|
2
|
+
|
|
3
|
+
// 操作符中文标签(仅展示用,没命中就回退显示原始 code)。
|
|
4
|
+
const OP_LABELS = {
|
|
5
|
+
eq: '等于', ne: '不等于', contains: '包含',
|
|
6
|
+
gt: '大于', gte: '大于等于', lt: '小于', lte: '小于等于',
|
|
7
|
+
between: '区间', in: '属于', notin: '不属于',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// 单条筛选。受控:props { field(来自 /api/schema 的字段元数据), value:{field,op,value}, onChange, onRemove }。
|
|
11
|
+
// 控件选择规则:
|
|
12
|
+
// - op === 'between' → 两个输入框
|
|
13
|
+
// - op === 'in' / 'notin' → 单输入框,逗号分隔(值为数组)
|
|
14
|
+
// - ENUM_HINTS 命中该字段 → 下拉,选项来自 ENUM_HINTS[sourceField]
|
|
15
|
+
// - 否则按 field.type → string=text / number=number / date=date
|
|
16
|
+
// 操作符下拉选项直接来自 field.operators。
|
|
17
|
+
export default function FilterRow({ field, value, onChange, onRemove }) {
|
|
18
|
+
const type = field.type || 'string';
|
|
19
|
+
const enumValues = ENUM_HINTS[field.sourceField];
|
|
20
|
+
const ops = field.operators && field.operators.length ? field.operators : [value.op].filter(Boolean);
|
|
21
|
+
const inputType = type === 'number' ? 'number' : type === 'date' ? 'date' : 'text';
|
|
22
|
+
const label = (field.aliases && field.aliases[0]) || field.sourceField;
|
|
23
|
+
|
|
24
|
+
const coerce = (raw) => (type === 'number' && raw !== '' ? Number(raw) : raw);
|
|
25
|
+
const set = (patch) => onChange({ ...value, ...patch });
|
|
26
|
+
|
|
27
|
+
// 切操作符时 value 形态可能要换(标量 / 二元区间 / 数组),统一重置避免形态错配。
|
|
28
|
+
const onOpChange = (e) => {
|
|
29
|
+
const op = e.target.value;
|
|
30
|
+
const next = op === 'between' ? ['', ''] : op === 'in' || op === 'notin' ? [] : '';
|
|
31
|
+
set({ op, value: next });
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const renderValueControl = () => {
|
|
35
|
+
if (value.op === 'between') {
|
|
36
|
+
const v = Array.isArray(value.value) ? value.value : ['', ''];
|
|
37
|
+
return (
|
|
38
|
+
<span className="filter-between">
|
|
39
|
+
<input type={inputType} value={v[0] ?? ''} onChange={(e) => set({ value: [coerce(e.target.value), v[1] ?? ''] })} />
|
|
40
|
+
<span className="sep">~</span>
|
|
41
|
+
<input type={inputType} value={v[1] ?? ''} onChange={(e) => set({ value: [v[0] ?? '', coerce(e.target.value)] })} />
|
|
42
|
+
</span>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
if (value.op === 'in' || value.op === 'notin') {
|
|
46
|
+
const text = Array.isArray(value.value) ? value.value.join(',') : value.value ?? '';
|
|
47
|
+
return (
|
|
48
|
+
<input
|
|
49
|
+
className="filter-value"
|
|
50
|
+
placeholder="多个值用逗号分隔"
|
|
51
|
+
value={text}
|
|
52
|
+
onChange={(e) => set({ value: e.target.value.split(',').map((s) => coerce(s.trim())).filter((s) => s !== '') })}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (enumValues) {
|
|
57
|
+
return (
|
|
58
|
+
<select className="filter-value" value={value.value ?? ''} onChange={(e) => set({ value: e.target.value })}>
|
|
59
|
+
<option value="">请选择</option>
|
|
60
|
+
{enumValues.map((opt) => <option key={opt} value={opt}>{opt}</option>)}
|
|
61
|
+
</select>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return (
|
|
65
|
+
<input className="filter-value" type={inputType} value={value.value ?? ''} onChange={(e) => set({ value: coerce(e.target.value) })} />
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className="filter-row">
|
|
71
|
+
<span className="filter-label">{label}</span>
|
|
72
|
+
<select className="filter-op" value={value.op} onChange={onOpChange}>
|
|
73
|
+
{ops.map((op) => <option key={op} value={op}>{OP_LABELS[op] || op}</option>)}
|
|
74
|
+
</select>
|
|
75
|
+
{renderValueControl()}
|
|
76
|
+
<button type="button" className="filter-remove" onClick={onRemove}>删除</button>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// 结果卡片。props { rows, titleField, valueField }。数字按 zh-CN 千分位展示。
|
|
2
|
+
export default function ResultCards({ rows, titleField, valueField }) {
|
|
3
|
+
if (!rows || rows.length === 0) return <div className="empty">无数据</div>;
|
|
4
|
+
return (
|
|
5
|
+
<div className="cards">
|
|
6
|
+
{rows.map((r, i) => {
|
|
7
|
+
const v = r[valueField];
|
|
8
|
+
return (
|
|
9
|
+
<div className="card" key={i}>
|
|
10
|
+
<div className="card-title">{String(r[titleField] ?? '')}</div>
|
|
11
|
+
<div className="card-value">{typeof v === 'number' ? v.toLocaleString('zh-CN') : String(v ?? '')}</div>
|
|
12
|
+
</div>
|
|
13
|
+
);
|
|
14
|
+
})}
|
|
15
|
+
</div>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// 结果表格。列取 rows[0] 的 key,数字按 zh-CN 千分位展示,空数组显示「无数据」。
|
|
2
|
+
export default function ResultTable({ rows }) {
|
|
3
|
+
if (!rows || rows.length === 0) return <div className="empty">无数据</div>;
|
|
4
|
+
const cols = Object.keys(rows[0]);
|
|
5
|
+
return (
|
|
6
|
+
<table className="result-table">
|
|
7
|
+
<thead>
|
|
8
|
+
<tr>{cols.map((c) => <th key={c}>{c}</th>)}</tr>
|
|
9
|
+
</thead>
|
|
10
|
+
<tbody>
|
|
11
|
+
{rows.map((r, i) => (
|
|
12
|
+
<tr key={i}>
|
|
13
|
+
{cols.map((c) => (
|
|
14
|
+
<td key={c}>{typeof r[c] === 'number' ? r[c].toLocaleString('zh-CN') : String(r[c] ?? '')}</td>
|
|
15
|
+
))}
|
|
16
|
+
</tr>
|
|
17
|
+
))}
|
|
18
|
+
</tbody>
|
|
19
|
+
</table>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { getSchema } from '../api';
|
|
3
|
+
import { INTERNAL_FIELDS, ENUM_HINTS, TYPE_DEFAULT_OP } from '../enums';
|
|
4
|
+
import FilterRow from './FilterRow';
|
|
5
|
+
|
|
6
|
+
// 给 schema 没覆盖到的字段(如预置的 Billing日期)按字段名兜底猜类型。
|
|
7
|
+
function guessType(sourceField) {
|
|
8
|
+
if (/日期|时间|date|time/i.test(sourceField)) return 'date';
|
|
9
|
+
return 'string';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const FALLBACK_OPS = {
|
|
13
|
+
string: ['contains', 'eq', 'ne', 'in', 'notin'],
|
|
14
|
+
number: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'between', 'in', 'notin'],
|
|
15
|
+
date: ['eq', 'gt', 'gte', 'lt', 'lte', 'between'],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function initValue(op) {
|
|
19
|
+
if (op === 'between') return ['', ''];
|
|
20
|
+
if (op === 'in' || op === 'notin') return [];
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 受控筛选区。拉 /api/schema 拿字段元数据,过滤掉 INTERNAL_FIELDS,
|
|
25
|
+
// 渲染已选 filters(FilterRow)+ 「+ 添加筛选字段」下拉。
|
|
26
|
+
// props { dataset, filters, onChange }。
|
|
27
|
+
export default function SchemaFilters({ dataset, filters, onChange }) {
|
|
28
|
+
const [fields, setFields] = useState([]);
|
|
29
|
+
const [loading, setLoading] = useState(true);
|
|
30
|
+
const [error, setError] = useState('');
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
let alive = true;
|
|
34
|
+
setLoading(true);
|
|
35
|
+
setError('');
|
|
36
|
+
getSchema(dataset)
|
|
37
|
+
.then((json) => {
|
|
38
|
+
if (!alive) return;
|
|
39
|
+
const data = json.data;
|
|
40
|
+
// 传了 dataset 时 data 可能是单对象;否则是数组。
|
|
41
|
+
const entry = Array.isArray(data) ? data.find((d) => d.dataset === dataset) : data;
|
|
42
|
+
const fs = (entry?.fields || []).filter((f) => !INTERNAL_FIELDS.has(f.sourceField));
|
|
43
|
+
setFields(fs);
|
|
44
|
+
})
|
|
45
|
+
.catch((e) => { if (alive) setError(e.message || '加载 schema 失败'); })
|
|
46
|
+
.finally(() => { if (alive) setLoading(false); });
|
|
47
|
+
return () => { alive = false; };
|
|
48
|
+
}, [dataset]);
|
|
49
|
+
|
|
50
|
+
// 取字段元数据:schema 里找得到用真的,找不到(预置 filter / 未加载)给兜底。
|
|
51
|
+
const metaFor = (sourceField) => {
|
|
52
|
+
const found = fields.find((f) => f.sourceField === sourceField);
|
|
53
|
+
if (found) return found;
|
|
54
|
+
const type = ENUM_HINTS[sourceField] ? 'string' : guessType(sourceField);
|
|
55
|
+
return { sourceField, type, operators: FALLBACK_OPS[type], aliases: [] };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const update = (i, next) => onChange(filters.map((f, idx) => (idx === i ? next : f)));
|
|
59
|
+
const remove = (i) => onChange(filters.filter((_, idx) => idx !== i));
|
|
60
|
+
const add = (e) => {
|
|
61
|
+
const sourceField = e.target.value;
|
|
62
|
+
e.target.value = '';
|
|
63
|
+
if (!sourceField) return;
|
|
64
|
+
const meta = metaFor(sourceField);
|
|
65
|
+
const wanted = TYPE_DEFAULT_OP[meta.type];
|
|
66
|
+
const op = wanted && meta.operators?.includes(wanted) ? wanted : meta.operators?.[0] || 'eq';
|
|
67
|
+
onChange([...filters, { field: sourceField, op, value: initValue(op) }]);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (error) return <div className="error">筛选项加载失败:{error}</div>;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className="schema-filters">
|
|
74
|
+
{filters.map((f, i) => (
|
|
75
|
+
<FilterRow
|
|
76
|
+
key={i}
|
|
77
|
+
field={metaFor(f.field)}
|
|
78
|
+
value={f}
|
|
79
|
+
onChange={(next) => update(i, next)}
|
|
80
|
+
onRemove={() => remove(i)}
|
|
81
|
+
/>
|
|
82
|
+
))}
|
|
83
|
+
<select className="filter-add" value="" onChange={add} disabled={loading}>
|
|
84
|
+
<option value="">{loading ? '加载字段中…' : '+ 添加筛选字段'}</option>
|
|
85
|
+
{fields.map((f) => (
|
|
86
|
+
<option key={f.sourceField} value={f.sourceField}>
|
|
87
|
+
{(f.aliases && f.aliases[0]) || f.sourceField}
|
|
88
|
+
</option>
|
|
89
|
+
))}
|
|
90
|
+
</select>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// 枚举可选值 / 内部字段 —— 逐字对齐后端 query-filters.js。
|
|
2
|
+
//
|
|
3
|
+
// 为什么前端要自带这份常量:
|
|
4
|
+
// - /api/schema 只给 field.type / field.operators / aliases,**不给枚举候选值**,
|
|
5
|
+
// 所以状态类字段的下拉选项只能在前端硬编码(ENUM_HINTS)。
|
|
6
|
+
// - describeDatasets 仍会返回 ID / 各级编号 等内部字段,前端要主动过滤掉不渲染
|
|
7
|
+
// (INTERNAL_FIELDS)。
|
|
8
|
+
// 后端 query-filters.js 改了这里也要同步改。
|
|
9
|
+
|
|
10
|
+
export const ENUM_HINTS = {
|
|
11
|
+
候选人状态: ['推荐', '1面', '2面', '3面', '4面', '终面', 'Offer', '已入职', '淘汰'],
|
|
12
|
+
顾问类型: ['正式', '实习', '外部合作顾问', '兼职', '外包', '虚拟账号'],
|
|
13
|
+
顾问在职状态: ['在职', '离职'],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const INTERNAL_FIELDS = new Set([
|
|
17
|
+
'ID', 'unique_id', '链接', 'SourceID', '发票ID', '客户ID', '职位ID',
|
|
18
|
+
'业绩所属一级团队编号', '业绩所属二级团队编号', '业绩所属三级团队编号', '所属OP名称', '候选人ID',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
// 每种字段类型添加筛选时的默认操作符。
|
|
22
|
+
export const TYPE_DEFAULT_OP = { string: 'contains', number: 'eq', date: 'eq' };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { runQuery } from '../api';
|
|
3
|
+
import SchemaFilters from '../components/SchemaFilters';
|
|
4
|
+
import ResultTable from '../components/ResultTable';
|
|
5
|
+
import BarChart from '../components/BarChart';
|
|
6
|
+
|
|
7
|
+
// 数据集:起手页用 performance。agent 按用户需求改成
|
|
8
|
+
// performance / consultant_progress / metadata_user 之一即可。
|
|
9
|
+
// 数据范围(scope)不在前端选 —— 后端按建页人身份隐式裁剪。
|
|
10
|
+
const DATASET = 'performance';
|
|
11
|
+
|
|
12
|
+
// 聚合字段用真名。注意:`业绩所属二级团队` 是真实字段名,
|
|
13
|
+
// 「顾问所属N级团队」只是它的 alias,查询 groupBy / aggregations 必须用真名。
|
|
14
|
+
const GROUP_FIELD = '业绩所属二级团队';
|
|
15
|
+
const METRIC = '业绩';
|
|
16
|
+
|
|
17
|
+
// 预置一个 Billing 日期区间示例。
|
|
18
|
+
// 提示:PPL 大范围查询很慢,务必带状态/时间区间(如 Billing 日期 between)收敛扫描范围。
|
|
19
|
+
const INITIAL_FILTERS = [
|
|
20
|
+
{ field: 'Billing日期', op: 'between', value: ['2026-01-01', '2026-06-30'] },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// 结果列名由后端决定(聚合后可能不叫原名),这里挑出展示用的标签列 / 数值列,兜底防错配。
|
|
24
|
+
function pickFields(rows, preferredLabel, preferredValue) {
|
|
25
|
+
const cols = Object.keys(rows[0]);
|
|
26
|
+
const labelField = cols.includes(preferredLabel)
|
|
27
|
+
? preferredLabel
|
|
28
|
+
: cols.find((c) => typeof rows[0][c] !== 'number') || cols[0];
|
|
29
|
+
const valueField = cols.includes(preferredValue)
|
|
30
|
+
? preferredValue
|
|
31
|
+
: cols.find((c) => typeof rows[0][c] === 'number' && c !== labelField) || cols[cols.length - 1];
|
|
32
|
+
return { labelField, valueField };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default function PerformanceQueryPage() {
|
|
36
|
+
const [filters, setFilters] = useState(INITIAL_FILTERS);
|
|
37
|
+
const [groupBy, setGroupBy] = useState(GROUP_FIELD);
|
|
38
|
+
const [rows, setRows] = useState([]);
|
|
39
|
+
const [total, setTotal] = useState(0);
|
|
40
|
+
const [loading, setLoading] = useState(false);
|
|
41
|
+
const [error, setError] = useState('');
|
|
42
|
+
const [ran, setRan] = useState(false);
|
|
43
|
+
|
|
44
|
+
const onQuery = async () => {
|
|
45
|
+
setLoading(true);
|
|
46
|
+
setError('');
|
|
47
|
+
try {
|
|
48
|
+
const json = await runQuery({
|
|
49
|
+
dataset: DATASET,
|
|
50
|
+
filters,
|
|
51
|
+
groupBy,
|
|
52
|
+
aggregations: [{ field: METRIC, op: 'sum' }],
|
|
53
|
+
orderBy: { field: METRIC, direction: 'desc' },
|
|
54
|
+
limit: 50,
|
|
55
|
+
});
|
|
56
|
+
setRows(json.data?.rows || []);
|
|
57
|
+
setTotal(json.data?.total || 0);
|
|
58
|
+
setRan(true);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
setError(e.message || '查询失败');
|
|
61
|
+
setRows([]);
|
|
62
|
+
} finally {
|
|
63
|
+
setLoading(false);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const picked = rows.length ? pickFields(rows, groupBy, METRIC) : null;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className="page">
|
|
71
|
+
<h1>业绩查询(示例)</h1>
|
|
72
|
+
<p className="hint">数据集:{DATASET}。数据范围由后端按你的身份自动裁剪。</p>
|
|
73
|
+
|
|
74
|
+
<section className="panel">
|
|
75
|
+
<label className="group-by">
|
|
76
|
+
分组字段
|
|
77
|
+
<input value={groupBy} onChange={(e) => setGroupBy(e.target.value)} />
|
|
78
|
+
</label>
|
|
79
|
+
<SchemaFilters dataset={DATASET} filters={filters} onChange={setFilters} />
|
|
80
|
+
<button type="button" className="btn-primary" onClick={onQuery} disabled={loading}>
|
|
81
|
+
{loading ? '查询中…' : '查询'}
|
|
82
|
+
</button>
|
|
83
|
+
</section>
|
|
84
|
+
|
|
85
|
+
{error && <div className="error">{error}</div>}
|
|
86
|
+
|
|
87
|
+
{ran && !error && (
|
|
88
|
+
<section className="results">
|
|
89
|
+
<div className="results-head">
|
|
90
|
+
共 {total.toLocaleString('zh-CN')} 条,按 {METRIC} 汇总(Top {rows.length})
|
|
91
|
+
</div>
|
|
92
|
+
{picked && rows.length > 0 && (
|
|
93
|
+
<div className="chart-wrap">
|
|
94
|
+
<BarChart rows={rows} labelField={picked.labelField} valueField={picked.valueField} />
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
<ResultTable rows={rows} />
|
|
98
|
+
</section>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|