@clawos-dev/clawd 0.2.143-beta.301.814d293 → 0.2.143-beta.303.ab28945
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 +1364 -617
- package/dist/dispatch/mcp-server.cjs +39756 -0
- package/dist/persona-defaults/persona-app-builder/CLAUDE.md +3 -0
- package/dist/persona-defaults/persona-bug-fixer/CLAUDE.md +2 -0
- package/dist/persona-defaults/persona-dataclaw-builder/CLAUDE.md +29 -9
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/README.md +10 -18
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/config.env +2 -5
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/contract/s.yaml.tmpl +1 -6
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/ext.conf +3 -2
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/.env.example +5 -14
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/package.json +1 -3
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/app.module.ts +2 -2
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/dataclaw/dataclaw.client.ts +18 -8
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/dataclaw/dataclaw.config.ts +9 -12
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/dataclaw/dataclaw.controller.ts +12 -8
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/main.ts +3 -16
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/App.jsx +1 -4
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/api.js +4 -2
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/components/ResultTable.jsx +4 -2
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/enums.js +3 -18
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/pages/PerformanceQueryPage.jsx +35 -57
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/hooks/pre-deploy.sh +16 -20
- package/dist/persona-defaults/persona-developer/CLAUDE.md +2 -0
- package/package.json +5 -2
- package/dist/persona-defaults/persona-dataclaw-builder/.secrets/dataclaw.env.example +0 -3
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/auth/gate.ts +0 -70
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/auth/session.ts +0 -52
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/auth/ttc-user-info.ts +0 -23
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/auth.js +0 -7
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/components/FilterRow.jsx +0 -79
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/components/SchemaFilters.jsx +0 -93
|
@@ -1,82 +1,51 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
|
-
import {
|
|
3
|
-
import SchemaFilters from '../components/SchemaFilters';
|
|
2
|
+
import { queryPerformance } from '../api';
|
|
4
3
|
import ResultTable from '../components/ResultTable';
|
|
5
|
-
import BarChart from '../components/BarChart';
|
|
6
4
|
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
const
|
|
5
|
+
// 业绩查询起手页:对接 dataclaw 公网 /api/performance(必填 startDate/endDate)。
|
|
6
|
+
// agent 按建页人需求可改成 queryPpl / searchConsultants,或加收窄条件、换展示形态。
|
|
7
|
+
// 数据为只读全量(公网 apikey 即全量),无身份裁剪。
|
|
8
|
+
const DATE_FIELD_LABELS = { billing: 'Billing 业绩', invoice: '开票业绩', payment: '回款业绩' };
|
|
11
9
|
|
|
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
|
-
}
|
|
10
|
+
const fmt = (n) => (Number(n) || 0).toLocaleString('zh-CN');
|
|
34
11
|
|
|
35
12
|
export default function PerformanceQueryPage() {
|
|
36
|
-
const [
|
|
37
|
-
const [
|
|
38
|
-
const [
|
|
39
|
-
const [total, setTotal] = useState(0);
|
|
13
|
+
const [startDate, setStartDate] = useState('2026-05-01');
|
|
14
|
+
const [endDate, setEndDate] = useState('2026-05-31');
|
|
15
|
+
const [result, setResult] = useState(null);
|
|
40
16
|
const [loading, setLoading] = useState(false);
|
|
41
17
|
const [error, setError] = useState('');
|
|
42
|
-
const [ran, setRan] = useState(false);
|
|
43
18
|
|
|
44
19
|
const onQuery = async () => {
|
|
20
|
+
if (!startDate || !endDate) { setError('请选择起止日期'); return; }
|
|
45
21
|
setLoading(true);
|
|
46
22
|
setError('');
|
|
47
23
|
try {
|
|
48
|
-
|
|
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);
|
|
24
|
+
setResult(await queryPerformance({ startDate, endDate }));
|
|
59
25
|
} catch (e) {
|
|
60
26
|
setError(e.message || '查询失败');
|
|
61
|
-
|
|
27
|
+
setResult(null);
|
|
62
28
|
} finally {
|
|
63
29
|
setLoading(false);
|
|
64
30
|
}
|
|
65
31
|
};
|
|
66
32
|
|
|
67
|
-
const
|
|
33
|
+
const byDateField = result?.byDateField || null;
|
|
68
34
|
|
|
69
35
|
return (
|
|
70
36
|
<div className="page">
|
|
71
37
|
<h1>业绩查询(示例)</h1>
|
|
72
|
-
<p className="hint"
|
|
38
|
+
<p className="hint">数据来自 dataclaw 业绩库,按日期区间查询(只读)。</p>
|
|
73
39
|
|
|
74
40
|
<section className="panel">
|
|
75
41
|
<label className="group-by">
|
|
76
|
-
|
|
77
|
-
<input value={
|
|
42
|
+
起始日期
|
|
43
|
+
<input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
|
|
44
|
+
</label>
|
|
45
|
+
<label className="group-by">
|
|
46
|
+
结束日期
|
|
47
|
+
<input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
|
|
78
48
|
</label>
|
|
79
|
-
<SchemaFilters dataset={DATASET} filters={filters} onChange={setFilters} />
|
|
80
49
|
<button type="button" className="btn-primary" onClick={onQuery} disabled={loading}>
|
|
81
50
|
{loading ? '查询中…' : '查询'}
|
|
82
51
|
</button>
|
|
@@ -84,17 +53,26 @@ export default function PerformanceQueryPage() {
|
|
|
84
53
|
|
|
85
54
|
{error && <div className="error">{error}</div>}
|
|
86
55
|
|
|
87
|
-
{
|
|
56
|
+
{result && !error && (
|
|
88
57
|
<section className="results">
|
|
89
58
|
<div className="results-head">
|
|
90
|
-
|
|
59
|
+
{result.queryRange || `${startDate} ~ ${endDate}`} 共 {fmt(result.total)} 条
|
|
91
60
|
</div>
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
61
|
+
|
|
62
|
+
{byDateField && (
|
|
63
|
+
<div className="cards">
|
|
64
|
+
{Object.entries(byDateField).map(([k, v]) => (
|
|
65
|
+
<div className="card" key={k}>
|
|
66
|
+
<div className="card-title">{DATE_FIELD_LABELS[k] || k}({fmt(v?.total)} 条)</div>
|
|
67
|
+
<div className="card-value">{fmt(v?.amount)}</div>
|
|
68
|
+
</div>
|
|
69
|
+
))}
|
|
95
70
|
</div>
|
|
96
71
|
)}
|
|
97
|
-
|
|
72
|
+
|
|
73
|
+
<div className="chart-wrap" style={{ overflowX: 'auto' }}>
|
|
74
|
+
<ResultTable rows={result.items || []} />
|
|
75
|
+
</div>
|
|
98
76
|
</section>
|
|
99
77
|
)}
|
|
100
78
|
</div>
|
|
@@ -1,25 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# dataclaw
|
|
3
|
-
# 由 deploy-kit/publish.sh
|
|
2
|
+
# dataclaw 发布前置:从生成 app 的 server/.env 读出建页人填的 apikey,export 供 s.yaml 占位符替换。
|
|
3
|
+
# 由 deploy-kit/publish.sh 在渲染 s.yaml 前 source(共享 EXT_DIR/APP_NAME/PERSONA_KIT 等作用域)。
|
|
4
4
|
|
|
5
|
-
# dataclaw
|
|
6
|
-
#
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
# 建页人提供的 dataclaw apikey 写在生成 app 的 server/.env(DATACLAW_API_KEY)。
|
|
6
|
+
# .env 被 server/.fcignore 排除、不进部署包,所以这里读出来、经 s.yaml 注入 FC 环境变量。
|
|
7
|
+
ENV_FILE="$EXT_DIR/server/.env"
|
|
8
|
+
if [ -f "$ENV_FILE" ]; then
|
|
9
|
+
# 只取 DATACLAW_API_KEY 一行、剥掉可能的引号;不 source 整个 .env(避免污染其它变量)。
|
|
10
|
+
# || true:publish.sh 跑在 set -euo pipefail 下,grep 无命中(有 .env 但缺 key 行)会让管道返回非 0
|
|
11
|
+
# → 不加会静默死在这行、走不到下面的 fail-loud 友好报错。统一收敛到下方空值检查。
|
|
12
|
+
DATACLAW_API_KEY="$(grep -E '^DATACLAW_API_KEY=' "$ENV_FILE" | head -1 | cut -d= -f2- | sed -e 's/^["'\'']//' -e 's/["'\'']$//' || true)"
|
|
13
|
+
fi
|
|
14
|
+
if [ -z "${DATACLAW_API_KEY:-}" ]; then
|
|
15
|
+
echo "❌ 缺 DATACLAW_API_KEY:请在 $ENV_FILE 填入建页人的 dataclaw apikey(只填冒号后的 secret 段)。" >&2
|
|
9
16
|
exit 1
|
|
10
|
-
}
|
|
11
|
-
set -a; . "$PERSONA_ROOT/.secrets/dataclaw.env"; set +a
|
|
12
|
-
|
|
13
|
-
# SESSION_SECRET:每个 app 一份、跨重部署稳定(否则重发后所有人 session 失效)。
|
|
14
|
-
# 首次发布生成并持久化到 ext.conf;之后 source ext.conf 已带上。
|
|
15
|
-
if [ -z "${SESSION_SECRET:-}" ]; then
|
|
16
|
-
SESSION_SECRET="$(openssl rand -hex 32)"
|
|
17
|
-
echo "SESSION_SECRET=$SESSION_SECRET" >> "$EXT_DIR/ext.conf"
|
|
18
17
|
fi
|
|
19
|
-
export
|
|
20
|
-
|
|
21
|
-
# APP_BASE_URL:飞书登录回调用。fc3 自定义域名由 APP_NAME+ACC+REGION 推导,用 https(cookie secure 要求)。
|
|
22
|
-
export APP_BASE_URL="https://${APP_NAME}.fcv3.${ACC}.${REGION}.fc.devsapp.net"
|
|
18
|
+
export DATACLAW_API_KEY
|
|
23
19
|
|
|
24
|
-
#
|
|
25
|
-
export
|
|
20
|
+
# service url:config.env / ext.conf 已 source 进来;兜底公网地址。
|
|
21
|
+
export DATACLAW_SERVICE_URL="${DATACLAW_SERVICE_URL:-https://120.48.21.36}"
|
|
@@ -115,3 +115,5 @@ done
|
|
|
115
115
|
⚠️ **`.mcp.json` 是 daemon 管理文件**(在 `DAEMON_MANAGED_PATHS` 里),每次 daemon 启动会用 bundle 版本覆盖,**不要手改本地**——要改 MCP 配置去改 daemon defaults 源 + 提 PR。
|
|
116
116
|
|
|
117
117
|
**要加别的 MCP server**(项目专属 / 个人用):在**项目目录**(不是 persona 目录)下加 `.mcp.json`,跟 persona 这份不冲突。或者扩 daemon defaults 给所有用户。
|
|
118
|
+
|
|
119
|
+
> dispatch 教学(`@persona/<id>` 委派 / `personaDispatchComplete` 回报)由 daemon 通过 `--append-system-prompt` 给所有 cc spawn 统一注入,不在这份 CLAUDE.md 重复。源见 `daemon/src/dispatch/system-prompt.ts`。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clawos-dev/clawd",
|
|
3
|
-
"version": "0.2.143-beta.
|
|
3
|
+
"version": "0.2.143-beta.303.ab28945",
|
|
4
4
|
"description": "Standalone clawd daemon — Claude Code (and future Codex) session server over WebSocket",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@clawos/clawd-protocol": "workspace:*",
|
|
30
|
+
"@clawos/log-client": "workspace:*",
|
|
30
31
|
"@types/diff": "^7.0.2",
|
|
31
32
|
"@types/node": "^22.0.0",
|
|
32
33
|
"@types/uuid": "^10.0.0",
|
|
@@ -48,8 +49,10 @@
|
|
|
48
49
|
},
|
|
49
50
|
"dependencies": {
|
|
50
51
|
"@lydell/node-pty": "1.2.0-beta.12",
|
|
52
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
51
53
|
"@xterm/addon-serialize": "^0.14.0",
|
|
52
54
|
"@xterm/headless": "^6.0.0",
|
|
53
|
-
"jszip": "^3.10.1"
|
|
55
|
+
"jszip": "^3.10.1",
|
|
56
|
+
"undici": "^6"
|
|
54
57
|
}
|
|
55
58
|
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
// TTC 飞书登录门禁(无密钥经纪):Express 中间件 + /auth/login、/auth/callback 处理器。
|
|
2
|
-
// 必须挂在 vite middleware(dev) / express.static(prod) 之前——前端 HTML 不走 Nest 路由,
|
|
3
|
-
// Nest guard 拦不住,所以门禁只能是 Express 层中间件(main.ts 接线在 vite/static 之前)。
|
|
4
|
-
// 门禁只决定"能不能打开页";数据范围仍由 dataclaw client 固定用建页人 union_id(PR-4 已做),
|
|
5
|
-
// 不用登录者身份查数。
|
|
6
|
-
import { fetchTtcUser } from './ttc-user-info'
|
|
7
|
-
import { makeSession, readSession, makeState, verifyState } from './session'
|
|
8
|
-
|
|
9
|
-
// req/res/next 用 any:express 不是本 server 包的直接依赖(pnpm 下从 server 根 require.resolve
|
|
10
|
-
// 'express' 失败,main.ts:67 同样用 any),从 'express' import 类型会编译失败。
|
|
11
|
-
type Req = any
|
|
12
|
-
type Res = any
|
|
13
|
-
type Next = (err?: unknown) => void
|
|
14
|
-
|
|
15
|
-
const SESSION_COOKIE = 'dc_session'
|
|
16
|
-
const STATE_COOKIE = 'dc_oauth_state'
|
|
17
|
-
|
|
18
|
-
// 子路径部署(clawd dev preview/<port>/,见 main.ts subPath 逻辑)下 redirect 要带同样前缀;
|
|
19
|
-
// prod base=/ 时无前缀,返回 '/auth/login'。
|
|
20
|
-
function loginPath(req: Req): string {
|
|
21
|
-
const url = String(req.originalUrl || req.url || '')
|
|
22
|
-
const m = url.match(/^\/(preview\/\d+)\//)
|
|
23
|
-
return m ? `/${m[1]}/auth/login` : '/auth/login'
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// 门禁中间件:放行 /auth/*;DATACLAW_AUTH_DISABLED=1 全放行;否则无有效 session → 302 /auth/login。
|
|
27
|
-
export function authGate(req: Req, res: Res, next: Next) {
|
|
28
|
-
if (process.env.DATACLAW_AUTH_DISABLED === '1') return next()
|
|
29
|
-
const p = String(req.path || req.url || '')
|
|
30
|
-
if (p.includes('/auth/login') || p.includes('/auth/callback')) return next()
|
|
31
|
-
const sess = readSession(req.cookies?.[SESSION_COOKIE])
|
|
32
|
-
if (sess) return next()
|
|
33
|
-
return res.redirect(302, loginPath(req))
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function loginHandler(req: Req, res: Res) {
|
|
37
|
-
const authBase = process.env.TTC_AUTH_BASE || 'https://app.ttcadvisory.com'
|
|
38
|
-
const appBase = (process.env.APP_BASE_URL || '').replace(/\/+$/, '')
|
|
39
|
-
const state = makeState()
|
|
40
|
-
res.cookie(STATE_COOKIE, state, { httpOnly: true, sameSite: 'lax', secure: true, maxAge: 5 * 60 * 1000 })
|
|
41
|
-
const url = new URL('/auth/authorize', authBase)
|
|
42
|
-
url.searchParams.set('callback_url', `${appBase}/auth/callback`)
|
|
43
|
-
url.searchParams.set('state', state)
|
|
44
|
-
url.searchParams.set('auto', '1')
|
|
45
|
-
res.redirect(302, url.toString())
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export async function callbackHandler(req: Req, res: Res) {
|
|
49
|
-
const token = String(req.query?.token || '')
|
|
50
|
-
const state = String(req.query?.state || '')
|
|
51
|
-
const cookieState = req.cookies?.[STATE_COOKIE]
|
|
52
|
-
// CSRF:回调 state 必须等于 cookie 里的、且签名有效未过期
|
|
53
|
-
if (!token || !state || state !== cookieState || !verifyState(state)) {
|
|
54
|
-
return res.status(401).send('登录校验失败,请重试')
|
|
55
|
-
}
|
|
56
|
-
try {
|
|
57
|
-
const apiBase = process.env.TTC_API_BASE || 'https://api.ttcadvisory.com'
|
|
58
|
-
const user = await fetchTtcUser(apiBase, token) // 只验身份;TTC token 验完即丢,不存不用于查数
|
|
59
|
-
res.clearCookie(STATE_COOKIE)
|
|
60
|
-
res.cookie(SESSION_COOKIE, makeSession(user.uniqueId, user.name), {
|
|
61
|
-
httpOnly: true,
|
|
62
|
-
sameSite: 'lax',
|
|
63
|
-
secure: true,
|
|
64
|
-
maxAge: 12 * 3600 * 1000,
|
|
65
|
-
})
|
|
66
|
-
return res.redirect(302, '/')
|
|
67
|
-
} catch {
|
|
68
|
-
return res.status(401).send('飞书身份校验失败')
|
|
69
|
-
}
|
|
70
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
// HMAC 签名 session + oauth state cookie,用 SESSION_SECRET 签发/校验。
|
|
2
|
-
// FC 多实例无共享内存,所以 state 也走签名 cookie,不能用进程内 Map。
|
|
3
|
-
import { createHmac, timingSafeEqual, randomBytes } from 'node:crypto'
|
|
4
|
-
|
|
5
|
-
const SECRET = () => process.env.SESSION_SECRET || ''
|
|
6
|
-
|
|
7
|
-
function sign(payload: string): string {
|
|
8
|
-
const mac = createHmac('sha256', SECRET()).update(payload).digest('base64url')
|
|
9
|
-
return `${Buffer.from(payload).toString('base64url')}.${mac}`
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function verify(token: string): string | null {
|
|
13
|
-
const [b64, mac] = (token || '').split('.')
|
|
14
|
-
if (!b64 || !mac) return null
|
|
15
|
-
const payload = Buffer.from(b64, 'base64url').toString()
|
|
16
|
-
const expect = createHmac('sha256', SECRET()).update(payload).digest('base64url')
|
|
17
|
-
const a = Buffer.from(mac)
|
|
18
|
-
const b = Buffer.from(expect)
|
|
19
|
-
if (a.length !== b.length || !timingSafeEqual(a, b)) return null
|
|
20
|
-
return payload
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// session:存 {uid,name,exp}(仅展示/审计,不用于查数——数据范围恒用建页人 union_id)。
|
|
24
|
-
export function makeSession(uid: string, name: string, ttlMs = 12 * 3600 * 1000): string {
|
|
25
|
-
return sign(JSON.stringify({ uid, name, exp: Date.now() + ttlMs }))
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function readSession(token?: string): { uid: string; name: string } | null {
|
|
29
|
-
const p = token && verify(token)
|
|
30
|
-
if (!p) return null
|
|
31
|
-
try {
|
|
32
|
-
const o = JSON.parse(p)
|
|
33
|
-
if (typeof o.exp === 'number' && o.exp > Date.now()) return { uid: o.uid, name: o.name }
|
|
34
|
-
} catch {}
|
|
35
|
-
return null
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function makeState(): string {
|
|
39
|
-
const nonce = randomBytes(16).toString('base64url')
|
|
40
|
-
return sign(JSON.stringify({ nonce, exp: Date.now() + 5 * 60 * 1000 }))
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function verifyState(token?: string): boolean {
|
|
44
|
-
const p = token && verify(token)
|
|
45
|
-
if (!p) return false
|
|
46
|
-
try {
|
|
47
|
-
const o = JSON.parse(p)
|
|
48
|
-
return typeof o.exp === 'number' && o.exp > Date.now()
|
|
49
|
-
} catch {
|
|
50
|
-
return false
|
|
51
|
-
}
|
|
52
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
// 用 TTC JWT 调 user_info 验身份(无密钥经纪:app 不持飞书/TTC 密钥,只用回调 token 验身份)。
|
|
2
|
-
// 精简自 daemon feishu-auth/ttc-client.ts,独立实现、不 import daemon 代码。
|
|
3
|
-
// 接口响应结构:GET /api/user_service/v1/login/user → { code:0, data:{ unique_id, name, union_id, avatar_url } }
|
|
4
|
-
export interface TtcUser {
|
|
5
|
-
uniqueId: string
|
|
6
|
-
name: string
|
|
7
|
-
unionId?: string
|
|
8
|
-
avatarUrl?: string
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export async function fetchTtcUser(apiBase: string, token: string, fetchImpl: typeof fetch = fetch): Promise<TtcUser> {
|
|
12
|
-
const res = await fetchImpl(`${apiBase}/api/user_service/v1/login/user`, { headers: { Authorization: `Bearer ${token}` } })
|
|
13
|
-
if (!res.ok) throw new Error(`TTC user_info HTTP ${res.status}`)
|
|
14
|
-
const body = (await res.json()) as { code?: number; data?: { unique_id?: string; name?: string; union_id?: string; avatar_url?: string } }
|
|
15
|
-
if (body.code !== 0 || !body.data?.unique_id || !body.data?.name) throw new Error('TTC user_info invalid')
|
|
16
|
-
const d = body.data
|
|
17
|
-
return {
|
|
18
|
-
uniqueId: d.unique_id,
|
|
19
|
-
name: d.name,
|
|
20
|
-
...(d.union_id ? { unionId: d.union_id } : {}),
|
|
21
|
-
...(d.avatar_url ? { avatarUrl: d.avatar_url } : {}),
|
|
22
|
-
}
|
|
23
|
-
}
|
|
@@ -1,7 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,79 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,93 +0,0 @@
|
|
|
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
|
-
}
|