Compare commits
372 Commits
worktree-s
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d79993691 | ||
|
|
a0d1392371 | ||
|
|
7db9eb29a0 | ||
|
|
1e65b56a0f | ||
|
|
3c01754c40 | ||
|
|
08af78aa83 | ||
|
|
b69dc6115d | ||
|
|
7dea456fda | ||
|
|
f6c5dd21ce | ||
|
|
47250a3b70 | ||
|
|
215c079d29 | ||
|
|
043824c722 | ||
|
|
bd12bdb62b | ||
|
|
28c892fd31 | ||
|
|
9715f542b6 | ||
|
|
5121a3c599 | ||
|
|
ee1c9ef3ea | ||
|
|
76d36f62a6 | ||
|
|
be2a136392 | ||
|
|
76cdfd0c00 | ||
|
|
02a4ba5e75 | ||
|
|
a8a0751005 | ||
|
|
9c59e6e82a | ||
|
|
27b98cae6f | ||
|
|
d0aabf5f2e | ||
|
|
3c42e0d692 | ||
|
|
e0eb7173c5 | ||
|
|
6721a1cc6e | ||
|
|
d2a0c8efc0 | ||
|
|
70229119be | ||
|
|
dd854479eb | ||
|
|
45fd9fee7b | ||
|
|
4c3136890b | ||
|
|
0903a0d652 | ||
|
|
fd3e7fd2cb | ||
|
|
c167ea4ea5 | ||
|
|
c048cb215f | ||
|
|
f32216e1e0 | ||
|
|
d5cb636e86 | ||
|
|
0b512a3d85 | ||
|
|
168dd87af4 | ||
|
|
640df9937f | ||
|
|
f8c5a76ce6 | ||
|
|
3cff31ec03 | ||
|
|
76f6011e0f | ||
|
|
0f9211a7b2 | ||
|
|
60062a8097 | ||
|
|
4800f89467 | ||
|
|
fbc8c9fdde | ||
|
|
c3593d3438 | ||
|
|
b8fb76375c | ||
|
|
b357916d97 | ||
|
|
edf66ab8e6 | ||
|
|
b853978771 | ||
|
|
29fbfbec59 | ||
|
|
5d1050bf6f | ||
|
|
5599cefc41 | ||
|
|
b0a304ca82 | ||
|
|
58aca753aa | ||
|
|
e1af3cca03 | ||
|
|
5fcc4c99c1 | ||
|
|
9e0aa496cd | ||
|
|
2843bd204f | ||
|
|
05374f99b0 | ||
|
|
c88e3ac630 | ||
|
|
dc94a5323a | ||
|
|
69d3feb865 | ||
|
|
3927c92fa8 | ||
|
|
730d50bc63 | ||
|
|
ce10befff1 | ||
|
|
f5c6abf03f | ||
|
|
b3f7328778 | ||
|
|
d50d1ab882 | ||
|
|
d974af3042 | ||
|
|
8a869f6990 | ||
|
|
f7edc59abb | ||
|
|
be01127098 | ||
|
|
33c1bd3866 | ||
|
|
b90306ea4b | ||
|
|
449768bee9 | ||
|
|
d871685e25 | ||
|
|
1171218276 | ||
|
|
33008c06c7 | ||
|
|
5e937d0ce2 | ||
|
|
722d8a3a9e | ||
|
|
db1f8dcbbc | ||
|
|
4e641bd38d | ||
|
|
25a4d4e9d5 | ||
|
|
4dd9ca01fe | ||
|
|
b3f97d6525 | ||
|
|
36a1c87d87 | ||
|
|
9772d6ec94 | ||
|
|
717f2eab4f | ||
|
|
e790cf171a | ||
|
|
4a5389510e | ||
|
|
550e525554 | ||
|
|
1d0e60d028 | ||
|
|
0d815968ca | ||
|
|
b2d5b4075c | ||
|
|
34ef41c96f | ||
|
|
bd48de69ee | ||
|
|
80b7ee8868 | ||
|
|
1e675947d5 | ||
|
|
88cac9557b | ||
|
|
12a018cc74 | ||
|
|
b0e6654944 | ||
|
|
8163289454 | ||
|
|
34043de685 | ||
|
|
99262efca4 | ||
|
|
2e70e1a3f8 | ||
|
|
ffa137eff6 | ||
|
|
c37c7218c2 | ||
|
|
ca2581be90 | ||
|
|
2c8ab47e5c | ||
|
|
26336c3daa | ||
|
|
3b2209b656 | ||
|
|
ba586e5aa7 | ||
|
|
a304544233 | ||
|
|
5ae80d800e | ||
|
|
71cfcf1277 | ||
|
|
b87e4379f6 | ||
|
|
20b856cfb2 | ||
|
|
87537e7c53 | ||
|
|
448b89e682 | ||
|
|
9442471c98 | ||
|
|
f8850ba95a | ||
|
|
bf728c34f3 | ||
|
|
bd6cf8e05f | ||
|
|
0054b32c61 | ||
|
|
a081a97678 | ||
|
|
e6eb97dcaa | ||
|
|
5c6964f52a | ||
|
|
125da57436 | ||
|
|
1965fa5269 | ||
|
|
5f47e62a46 | ||
|
|
4c325de6c3 | ||
|
|
d6ccb18336 | ||
|
|
2f25316e83 | ||
|
|
4b15ead8e7 | ||
|
|
0883bb28ff | ||
|
|
cf9b258c6c | ||
|
|
3f2acb49fb | ||
|
|
f2d6a3b6b7 | ||
|
|
26f50cd746 | ||
|
|
646d8c21af | ||
|
|
e6937e1e5f | ||
|
|
ffaee49d67 | ||
|
|
a4c89ec6f1 | ||
|
|
2247edc362 | ||
|
|
f298a8e1a2 | ||
|
|
5da6c0e4aa | ||
|
|
8af8d733fd | ||
|
|
d5ad07d0a7 | ||
|
|
adcce0d70c | ||
|
|
8eeb616f61 | ||
|
|
de2d3e3a11 | ||
|
|
6e0c1e55a9 | ||
|
|
0b0ab00b9c | ||
|
|
ade534d1ce | ||
|
|
81d1702484 | ||
|
|
a616c73883 | ||
|
|
eab9b5fdcc | ||
|
|
f9303ae0c3 | ||
|
|
ca0e537682 | ||
|
|
ab0e11a719 | ||
|
|
6d2bedcfd7 | ||
|
|
d758a4477f | ||
|
|
803464b492 | ||
|
|
7de486bfca | ||
|
|
a5b887051d | ||
|
|
58703492e1 | ||
|
|
2e5f63be32 | ||
|
|
8e9fc54d92 | ||
|
|
af20487b8d | ||
|
|
80cadd1158 | ||
|
|
e1f3a9719e | ||
|
|
c7ffba196a | ||
|
|
4c8cf06b0d | ||
|
|
8aed363fc8 | ||
|
|
deb206ec0b | ||
|
|
0e1b29da06 | ||
|
|
6d896a5a57 | ||
|
|
2fd6d08899 | ||
|
|
ae55ad6dc4 | ||
|
|
29a1b3db5b | ||
|
|
efc391a165 | ||
|
|
02c69bb3cf | ||
|
|
bbbcd7725b | ||
|
|
6a13fff9ec | ||
|
|
9339b64bae | ||
|
|
e7d5aaebdf | ||
|
|
14c3c963c2 | ||
|
|
c3ab7985d2 | ||
|
|
9871c254be | ||
|
|
15a1849255 | ||
|
|
cb140b5151 | ||
|
|
9c346ed6fb | ||
|
|
7a3334384a | ||
|
|
4e8f2c7692 | ||
|
|
4a23bbeda6 | ||
|
|
7f9799b7e0 | ||
|
|
38e7c7bd9b | ||
|
|
828be3cc9e | ||
|
|
d3da7d4dbb | ||
|
|
26a833d1c8 | ||
|
|
f9e1ce1d6e | ||
|
|
b5993d4f43 | ||
|
|
bcaab50c56 | ||
|
|
e65b49c821 | ||
|
|
90855dc83e | ||
|
|
a458e3f7d8 | ||
|
|
1f792bdfe0 | ||
|
|
66827a55a5 | ||
|
|
4431bef71c | ||
|
|
a3bfdbb01c | ||
|
|
5877e794fa | ||
|
|
0a3ba2fad4 | ||
|
|
9ee89ff67c | ||
|
|
7e56b40972 | ||
|
|
f33de62ee8 | ||
|
|
aef4e01499 | ||
|
|
de36bb0724 | ||
|
|
af0acff2aa | ||
|
|
d6b1f44119 | ||
|
|
745c2fd754 | ||
|
|
3b0ab1a7b7 | ||
|
|
36168d6978 | ||
|
|
b84a503500 | ||
|
|
fb0b8d2af3 | ||
|
|
82842c4258 | ||
|
|
13a40dbbf5 | ||
|
|
f846f3d632 | ||
|
|
ac24d15bab | ||
|
|
26dc500b1b | ||
|
|
37e77d0d5e | ||
|
|
61224efff5 | ||
|
|
6c6fcb76b3 | ||
|
|
1680f931e9 | ||
|
|
1fec8cfbc1 | ||
|
|
8e56df74ec | ||
|
|
88172aa651 | ||
|
|
619bad30cb | ||
|
|
985644dd9a | ||
|
|
59f660b93b | ||
|
|
a644988ca3 | ||
|
|
6d1f2d108a | ||
|
|
05762261be | ||
|
|
442ec0eeef | ||
|
|
e90eb5df60 | ||
|
|
a6902c28f5 | ||
|
|
9f8b0ba375 | ||
|
|
5c48d62f7e | ||
|
|
894c0d7b15 | ||
|
|
eac1d9449e | ||
|
|
be0a78a523 | ||
|
|
9af7b0dd46 | ||
|
|
f4ed1b33e0 | ||
|
|
1399054547 | ||
|
|
769bfdf5d6 | ||
|
|
912f117ea3 | ||
|
|
0be31bbf7e | ||
|
|
b25dfc967a | ||
|
|
b4e5af7a58 | ||
|
|
276ec3ca94 | ||
|
|
8faefd6a61 | ||
|
|
5db2907420 | ||
|
|
5eeabd1f30 | ||
|
|
1c99e5f3a3 | ||
|
|
943afe3b6b | ||
|
|
cc26797faf | ||
|
|
264dc75b2c | ||
|
|
4281ce35b4 | ||
|
|
2ceeeaba3d | ||
|
|
305984c982 | ||
|
|
22b967d2a6 | ||
|
|
edecd4c81f | ||
|
|
0857a1f608 | ||
|
|
1048901665 | ||
|
|
ea00c32c08 | ||
|
|
5b1b747810 | ||
|
|
564c7ca28f | ||
|
|
65b73c547f | ||
|
|
54764a8bbd | ||
|
|
1c697d0b46 | ||
|
|
5a5a4b322d | ||
|
|
d8e2954d73 | ||
|
|
5c74e74f2a | ||
|
|
15d578c5bc | ||
|
|
52bdafa633 | ||
|
|
0a04b260a4 | ||
|
|
da438ad868 | ||
|
|
8898bb399e | ||
|
|
28299807b6 | ||
|
|
d40c4605b2 | ||
|
|
7e4b787d5c | ||
|
|
837abec48a | ||
|
|
11e3d37468 | ||
|
|
8263b236fd | ||
|
|
08268b32b8 | ||
|
|
1bf0d3a73d | ||
|
|
07099e3ef0 | ||
|
|
dce9035584 | ||
|
|
c8dc654fd4 | ||
|
|
b1e3a27043 | ||
|
|
becfda3fbf | ||
|
|
830e9fa301 | ||
|
|
ef60f9a183 | ||
|
|
b66087de0e | ||
|
|
d06ecded34 | ||
|
|
9487cd7f72 | ||
|
|
c6bd4aea27 | ||
|
|
17a2501808 | ||
|
|
cc7ee3189d | ||
|
|
62df7feac1 | ||
|
|
a851a2854f | ||
|
|
59fc7debd6 | ||
|
|
73ff5e8c5e | ||
|
|
e3b93ff96d | ||
|
|
3b1a017761 | ||
|
|
4e3265a853 | ||
|
|
7d4d2b999b | ||
|
|
721451f6a7 | ||
|
|
4b9698034c | ||
|
|
4aa3f884ec | ||
|
|
f23f6c5f91 | ||
|
|
97698f54b2 | ||
|
|
a3bdf11d9a | ||
|
|
9905a8d0d5 | ||
|
|
2ff696289f | ||
|
|
6cae768401 | ||
|
|
3e5d64484e | ||
|
|
ee51d5abcd | ||
|
|
f79560a911 | ||
|
|
d0ae7d2770 | ||
|
|
8e6abc91e1 | ||
|
|
1d9283f335 | ||
|
|
49abd0fe89 | ||
|
|
c9b9c5231b | ||
|
|
9fb9c3204c | ||
|
|
3e57fadfc9 | ||
|
|
eb956d0dce | ||
|
|
6821df5f44 | ||
|
|
9d310e5a3c | ||
|
|
6529b67353 | ||
|
|
a0bbd4ba82 | ||
|
|
c2aff09811 | ||
|
|
e7b2d1c099 | ||
|
|
88aa4b1310 | ||
|
|
ecd7f2e928 | ||
|
|
544358764e | ||
|
|
ba2c6a6105 | ||
|
|
bc8c77e7fe | ||
|
|
834aa12076 | ||
|
|
813b49a986 | ||
|
|
d345e60a6a | ||
|
|
a7d33d0207 | ||
|
|
13c0b18bbc | ||
|
|
5595083b96 | ||
|
|
eed26a1ce4 | ||
|
|
f3f586efef | ||
|
|
6040d98b18 | ||
|
|
ee29b7b752 | ||
|
|
7e90cea117 | ||
|
|
09df242cf8 | ||
|
|
04c366fe8b | ||
|
|
7de294375b | ||
|
|
b7ec317d2c | ||
|
|
a0ca35c9dd | ||
|
|
77374121dd | ||
|
|
8b9d506893 | ||
|
|
5fdf96c3f5 | ||
|
|
9a5fad2b59 |
7
.cargo/config.toml
Normal file
7
.cargo/config.toml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Reduce parallel compilation jobs to prevent compiler OOM.
|
||||||
|
# The desktop crate + its dependencies (tauri, sqlx, fantoccini, etc.)
|
||||||
|
# consume significant memory during borrow checking / type inference.
|
||||||
|
#
|
||||||
|
# If builds still OOM, try lowering further (e.g. 2 or 1).
|
||||||
|
[build]
|
||||||
|
jobs = 2
|
||||||
28
.claude/hooks/arch-sync-check.js
Normal file
28
.claude/hooks/arch-sync-check.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// arch-sync-check.js
|
||||||
|
// PostToolUse hook: detects git commit/push and reminds to sync architecture docs
|
||||||
|
// Reads tool input from stdin, outputs reminder if git operation detected
|
||||||
|
|
||||||
|
const CHUNKS = [];
|
||||||
|
process.stdin.on('data', (c) => CHUNKS.push(c));
|
||||||
|
process.stdin.on('end', () => {
|
||||||
|
try {
|
||||||
|
const input = JSON.parse(Buffer.concat(CHUNKS).toString());
|
||||||
|
const toolName = input.tool_name || '';
|
||||||
|
const toolInput = input.tool_input || {};
|
||||||
|
|
||||||
|
// Only check Bash tool calls
|
||||||
|
if (toolName !== 'Bash') return;
|
||||||
|
|
||||||
|
const cmd = (toolInput.command || '').trim();
|
||||||
|
|
||||||
|
// Detect git commit or git push
|
||||||
|
const isGitCommit = cmd.startsWith('git commit') || cmd.includes('&& git commit');
|
||||||
|
const isGitPush = cmd.startsWith('git push') || cmd.includes('&& git push');
|
||||||
|
|
||||||
|
if (isGitCommit || isGitPush) {
|
||||||
|
console.log('[arch-sync] Architecture docs may need updating. Run /sync-arch or update CLAUDE.md §13 + ARCHITECTURE_BRIEF.md as part of §8.3 completion flow.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently ignore parse errors
|
||||||
|
}
|
||||||
|
});
|
||||||
15
.claude/settings.json
Normal file
15
.claude/settings.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node .claude/hooks/arch-sync-check.js"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
52
.claude/skills/sync-arch
Normal file
52
.claude/skills/sync-arch
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Architecture Sync Skill
|
||||||
|
|
||||||
|
Analyze recent git changes and update the architecture documentation to keep it current.
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
|
||||||
|
- After completing a significant feature or bugfix
|
||||||
|
- As part of the §8.3 completion flow
|
||||||
|
- When you notice the architecture snapshot is stale
|
||||||
|
- User runs `/sync-arch`
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. **Gather context**: Run `git log --oneline -10` and identify commits since the last ARCH-SNAPSHOT update date (check the comment in CLAUDE.md `<!-- ARCH-SNAPSHOT-START -->` section).
|
||||||
|
|
||||||
|
2. **Analyze changes**: For each relevant commit, determine which subsystems were affected:
|
||||||
|
- Butler/管家模式 (butler_router, pain_storage, cold_start, ui_mode)
|
||||||
|
- ChatStream/聊天流 (kernel-chat, gateway-client, saas-relay, streamStore)
|
||||||
|
- LLM Drivers/驱动 (driver/*, config.rs)
|
||||||
|
- Client Routing/客户端路由 (connectionStore)
|
||||||
|
- SaaS Auth/认证 (saas-session, auth handlers, token pool)
|
||||||
|
- Memory Pipeline/记忆管道 (growth, extraction, FTS5)
|
||||||
|
- Pipeline DSL (pipeline/*, executor)
|
||||||
|
- Hands (hands/*, handStore)
|
||||||
|
- Middleware (middleware/*)
|
||||||
|
- Skills (skills/*, skillStore)
|
||||||
|
|
||||||
|
3. **Update CLAUDE.md §13** (between `<!-- ARCH-SNAPSHOT-START -->` and `<!-- ARCH-SNAPSHOT-END -->`):
|
||||||
|
- Update the "活跃子系统" table: change status and latest change for affected subsystems
|
||||||
|
- Update "关键架构模式": modify descriptions if architecture changed
|
||||||
|
- Update "最近变更": add new entries, keep only the most recent 4-5
|
||||||
|
- Update the date in the comment `<!-- 此区域由 auto-sync 自动更新,更新时间: YYYY-MM-DD -->`
|
||||||
|
|
||||||
|
4. **Update CLAUDE.md §14** (between `<!-- ANTI-PATTERN-START -->` and `<!-- ANTI-PATTERN-END -->`):
|
||||||
|
- Add new anti-patterns if new pitfalls were discovered
|
||||||
|
- Add new scenario instructions if new common patterns emerged
|
||||||
|
- Remove items that are no longer relevant
|
||||||
|
|
||||||
|
5. **Update docs/ARCHITECTURE_BRIEF.md**:
|
||||||
|
- Update the affected subsystem sections with new details
|
||||||
|
- Add new components, files, or data flows that were introduced
|
||||||
|
- Update the "最后更新" date at the top
|
||||||
|
|
||||||
|
6. **Commit**: Create a commit with message `docs(sync-arch): update architecture snapshot for <date>`
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Only update content BETWEEN the HTML comment markers — never touch other parts of CLAUDE.md
|
||||||
|
- Keep the snapshot concise — the §13 section should be under 50 lines
|
||||||
|
- Use accurate dates from git log, not approximations
|
||||||
|
- If no significant changes since last update, do nothing (don't create empty commits)
|
||||||
|
- Architecture decisions > code details — focus on WHAT and WHY, not line numbers
|
||||||
Binary file not shown.
Binary file not shown.
1
.claude/worktrees/saas-backend
Submodule
1
.claude/worktrees/saas-backend
Submodule
Submodule .claude/worktrees/saas-backend added at 44256a511c
109
.dockerignore
109
.dockerignore
@@ -1,93 +1,42 @@
|
|||||||
# ============================================================
|
|
||||||
# ZCLAW SaaS Backend - Docker Ignore
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
target/
|
target/
|
||||||
|
|
||||||
# Frontend applications (not needed for SaaS backend)
|
|
||||||
desktop/
|
|
||||||
admin/
|
|
||||||
design-system/
|
|
||||||
|
|
||||||
# Node.js
|
|
||||||
node_modules/
|
node_modules/
|
||||||
.pnpm-store/
|
|
||||||
bun.lock
|
# Environment and secrets
|
||||||
pnpm-lock.yaml
|
.env
|
||||||
package.json
|
.env.*
|
||||||
package-lock.json
|
*.pem
|
||||||
|
*.key
|
||||||
|
|
||||||
|
# IDE and OS
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
# Git
|
# Git
|
||||||
.git/
|
.git/
|
||||||
.gitignore
|
.gitignore
|
||||||
|
|
||||||
# IDE and editor
|
# Logs
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
|
|
||||||
# OS files
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Docker
|
|
||||||
.docker/
|
|
||||||
docker-compose*.yml
|
|
||||||
Dockerfile
|
|
||||||
.dockerignore
|
|
||||||
|
|
||||||
# Documentation
|
|
||||||
docs/
|
|
||||||
*.md
|
|
||||||
!saas-config.toml
|
|
||||||
CLAUDE.md
|
|
||||||
CLAUDE*.md
|
|
||||||
|
|
||||||
# Environment files (secrets)
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
saas-env.example
|
|
||||||
|
|
||||||
# Data files
|
|
||||||
saas-data/
|
|
||||||
saas-data.db
|
|
||||||
saas-data.db-shm
|
|
||||||
saas-data.db-wal
|
|
||||||
*.db
|
|
||||||
*.db-shm
|
|
||||||
*.db-wal
|
|
||||||
|
|
||||||
# Test artifacts
|
|
||||||
tests/
|
|
||||||
test-results/
|
|
||||||
test.rs
|
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Temporary files
|
# Docker
|
||||||
tmp-screenshot.png
|
docker-compose.yml
|
||||||
tmp/
|
Dockerfile
|
||||||
temp/
|
|
||||||
*.tmp
|
|
||||||
|
|
||||||
# Claude worktree metadata
|
# Documentation (not needed in image)
|
||||||
|
docs/
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
tests/
|
||||||
|
tests/e2e/
|
||||||
|
admin-v2/tests/
|
||||||
|
|
||||||
|
# Claude/development tools
|
||||||
.claude/
|
.claude/
|
||||||
|
.planning/
|
||||||
|
.superpowers/
|
||||||
plans/
|
plans/
|
||||||
pipelines/
|
|
||||||
scripts/
|
|
||||||
hands/
|
|
||||||
skills/
|
|
||||||
plugins/
|
|
||||||
config/
|
|
||||||
extract.js
|
|
||||||
extract_models.js
|
|
||||||
extract_privacy.js
|
|
||||||
start-all.ps1
|
|
||||||
start.ps1
|
|
||||||
start.sh
|
|
||||||
Makefile
|
|
||||||
PROGRESS.md
|
|
||||||
CHANGELOG.md
|
|
||||||
pencil-new.pen
|
|
||||||
|
|||||||
@@ -44,3 +44,12 @@ ZCLAW_EMBEDDING_MODEL=text-embedding-3-small
|
|||||||
# === Logging ===
|
# === Logging ===
|
||||||
# 可选: debug, info, warn, error
|
# 可选: debug, info, warn, error
|
||||||
ZCLAW_LOG_LEVEL=info
|
ZCLAW_LOG_LEVEL=info
|
||||||
|
|
||||||
|
# === SaaS Backend ===
|
||||||
|
ZCLAW_SAAS_JWT_SECRET=
|
||||||
|
ZCLAW_TOTP_ENCRYPTION_KEY=
|
||||||
|
ZCLAW_ADMIN_USERNAME=
|
||||||
|
ZCLAW_ADMIN_PASSWORD=
|
||||||
|
DB_PASSWORD=
|
||||||
|
ZCLAW_DATABASE_URL=
|
||||||
|
ZCLAW_SAAS_DEV=false
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,6 +12,10 @@ build/
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
|
# SaaS config (contains database credentials)
|
||||||
|
saas-config.toml
|
||||||
|
!saas-config.toml.example
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs/
|
logs/
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
15
.mcp.json
Normal file
15
.mcp.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"tauri-mcp": {
|
||||||
|
"command": "node",
|
||||||
|
"args": [
|
||||||
|
"C:/Users/szend/AppData/Roaming/npm/node_modules/tauri-plugin-mcp-server/build/index.js"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"TAURI_MCP_CONNECTION_TYPE": "tcp",
|
||||||
|
"TAURI_MCP_TCP_HOST": "127.0.0.1",
|
||||||
|
"TAURI_MCP_TCP_PORT": "4000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
.superpowers/brainstorm/1446-1774933084/.server-stopped
Normal file
1
.superpowers/brainstorm/1446-1774933084/.server-stopped
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"reason":"owner process exited","timestamp":1774933144596}
|
||||||
1
.superpowers/brainstorm/1446-1774933084/.server.pid
Normal file
1
.superpowers/brainstorm/1446-1774933084/.server.pid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
1454
|
||||||
151
.superpowers/brainstorm/1446-1774933084/design-direction.html
Normal file
151
.superpowers/brainstorm/1446-1774933084/design-direction.html
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<h2>Admin 管理后台的设计方向</h2>
|
||||||
|
<p class="subtitle">选择一个整体设计风格方向,后续所有页面都将基于此展开</p>
|
||||||
|
|
||||||
|
<div class="cards">
|
||||||
|
<div class="card" data-choice="modern-minimal" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image">
|
||||||
|
<div style="background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); padding: 24px; min-height: 180px; display: flex; flex-direction: column; gap: 12px;">
|
||||||
|
<div style="display: flex; gap: 12px; align-items: center;">
|
||||||
|
<div style="width: 40px; height: 40px; border-radius: 10px; background: #6366f1;"></div>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight: 700; color: #1e293b; font-size: 14px;">ZCLAW Admin</div>
|
||||||
|
<div style="color: #94a3b8; font-size: 12px;">现代极简</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<div style="flex: 1; height: 8px; border-radius: 4px; background: #6366f1; opacity: 0.2;"></div>
|
||||||
|
<div style="flex: 2; height: 8px; border-radius: 4px; background: #6366f1; opacity: 0.1;"></div>
|
||||||
|
<div style="flex: 1; height: 8px; border-radius: 4px; background: #6366f1; opacity: 0.15;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 8px; margin-top: 4px;">
|
||||||
|
<div style="flex: 1; height: 60px; border-radius: 8px; background: white; border: 1px solid #e2e8f0;"></div>
|
||||||
|
<div style="flex: 1; height: 60px; border-radius: 8px; background: white; border: 1px solid #e2e8f0;"></div>
|
||||||
|
<div style="flex: 1; height: 60px; border-radius: 8px; background: white; border: 1px solid #e2e8f0;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 4px; margin-top: auto;">
|
||||||
|
<div style="width: 20px; height: 20px; border-radius: 4px; background: #6366f1;"></div>
|
||||||
|
<div style="width: 20px; height: 20px; border-radius: 4px; background: #8b5cf6;"></div>
|
||||||
|
<div style="width: 20px; height: 20px; border-radius: 4px; background: #a78bfa;"></div>
|
||||||
|
<div style="width: 20px; height: 20px; border-radius: 4px; background: #c4b5fd;"></div>
|
||||||
|
<div style="width: 20px; height: 20px; border-radius: 4px; background: #e0e7ff;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>A. 现代极简 (Modern Minimal)</h3>
|
||||||
|
<p>大量留白,Indigo/Purple 主色调,圆角卡片,轻量阴影。类似 Linear、Vercel Dashboard 风格。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" data-choice="tech-dark" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image">
|
||||||
|
<div style="background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); padding: 24px; min-height: 180px; display: flex; flex-direction: column; gap: 12px;">
|
||||||
|
<div style="display: flex; gap: 12px; align-items: center;">
|
||||||
|
<div style="width: 40px; height: 40px; border-radius: 10px; background: linear-gradient(135deg, #06b6d4, #3b82f6);"></div>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight: 700; color: #f1f5f9; font-size: 14px;">ZCLAW Admin</div>
|
||||||
|
<div style="color: #64748b; font-size: 12px;">科技暗色</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<div style="flex: 1; height: 8px; border-radius: 4px; background: #06b6d4; opacity: 0.3;"></div>
|
||||||
|
<div style="flex: 2; height: 8px; border-radius: 4px; background: #06b6d4; opacity: 0.15;"></div>
|
||||||
|
<div style="flex: 1; height: 8px; border-radius: 4px; background: #06b6d4; opacity: 0.2;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 8px; margin-top: 4px;">
|
||||||
|
<div style="flex: 1; height: 60px; border-radius: 8px; background: #1e293b; border: 1px solid #334155;"></div>
|
||||||
|
<div style="flex: 1; height: 60px; border-radius: 8px; background: #1e293b; border: 1px solid #334155;"></div>
|
||||||
|
<div style="flex: 1; height: 60px; border-radius: 8px; background: #1e293b; border: 1px solid #334155;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 4px; margin-top: auto;">
|
||||||
|
<div style="width: 20px; height: 20px; border-radius: 4px; background: #06b6d4;"></div>
|
||||||
|
<div style="width: 20px; height: 20px; border-radius: 4px; background: #3b82f6;"></div>
|
||||||
|
<div style="width: 20px; height: 20px; border-radius: 4px; background: #8b5cf6;"></div>
|
||||||
|
<div style="width: 20px; height: 20px; border-radius: 4px; background: #22d3ee;"></div>
|
||||||
|
<div style="width: 20px; height: 20px; border-radius: 4px; background: #1e293b; border: 1px solid #334155;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>B. 科技暗色 (Tech Dark)</h3>
|
||||||
|
<p>深色基底,Cyan/Blue 渐变高亮,发光边框,数据密集感。类似 Grafana、DataDog 风格。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" data-choice="warm-professional" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image">
|
||||||
|
<div style="background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 50%, #f5f5f4 100%); padding: 24px; min-height: 180px; display: flex; flex-direction: column; gap: 12px;">
|
||||||
|
<div style="display: flex; gap: 12px; align-items: center;">
|
||||||
|
<div style="width: 40px; height: 40px; border-radius: 10px; background: linear-gradient(135deg, #f59e0b, #ef4444);"></div>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight: 700; color: #292524; font-size: 14px;">ZCLAW Admin</div>
|
||||||
|
<div style="color: #a8a29e; font-size: 12px;">温暖专业</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<div style="flex: 1; height: 8px; border-radius: 4px; background: #f59e0b; opacity: 0.3;"></div>
|
||||||
|
<div style="flex: 2; height: 8px; border-radius: 4px; background: #f59e0b; opacity: 0.15;"></div>
|
||||||
|
<div style="flex: 1; height: 8px; border-radius: 4px; background: #f59e0b; opacity: 0.2;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 8px; margin-top: 4px;">
|
||||||
|
<div style="flex: 1; height: 60px; border-radius: 8px; background: white; border: 1px solid #e7e5e4; box-shadow: 0 1px 3px rgba(0,0,0,0.05);"></div>
|
||||||
|
<div style="flex: 1; height: 60px; border-radius: 8px; background: white; border: 1px solid #e7e5e4; box-shadow: 0 1px 3px rgba(0,0,0,0.05);"></div>
|
||||||
|
<div style="flex: 1; height: 60px; border-radius: 8px; background: white; border: 1px solid #e7e5e4; box-shadow: 0 1px 3px rgba(0,0,0,0.05);"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 4px; margin-top: auto;">
|
||||||
|
<div style="width: 20px; height: 20px; border-radius: 4px; background: #f59e0b;"></div>
|
||||||
|
<div style="width: 20px; height: 20px; border-radius: 4px; background: #ef4444;"></div>
|
||||||
|
<div style="width: 20px; height: 20px; border-radius: 4px; background: #f97316;"></div>
|
||||||
|
<div style="width: 20px; height: 20px; border-radius: 4px; background: #d97706;"></div>
|
||||||
|
<div style="width: 20px; height: 20px; border-radius: 4px; background: #fef3c7;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>C. 温暖专业 (Warm Professional)</h3>
|
||||||
|
<p>暖白底色,Amber/Orange 主色调,圆润设计,亲切感。类似 Notion、Stripe Dashboard 风格。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" data-choice="brand-zclaw" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image">
|
||||||
|
<div style="background: linear-gradient(135deg, #faf5ff 0%, #ede9fe 50%, #f5f3ff 100%); padding: 24px; min-height: 180px; display: flex; flex-direction: column; gap: 12px;">
|
||||||
|
<div style="display: flex; gap: 12px; align-items: center;">
|
||||||
|
<div style="width: 40px; height: 40px; border-radius: 10px; background: linear-gradient(135deg, #863bff, #47bfff);"></div>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight: 700; color: #1e1b4b; font-size: 14px;">ZCLAW Admin</div>
|
||||||
|
<div style="color: #a78bfa; font-size: 12px;">品牌紫蓝</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<div style="flex: 1; height: 8px; border-radius: 4px; background: #863bff; opacity: 0.3;"></div>
|
||||||
|
<div style="flex: 2; height: 8px; border-radius: 4px; background: #863bff; opacity: 0.15;"></div>
|
||||||
|
<div style="flex: 1; height: 8px; border-radius: 4px; background: #47bfff; opacity: 0.2;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 8px; margin-top: 4px;">
|
||||||
|
<div style="flex: 1; height: 60px; border-radius: 8px; background: white; border: 1px solid #e9d5ff; box-shadow: 0 1px 3px rgba(134,59,255,0.08);"></div>
|
||||||
|
<div style="flex: 1; height: 60px; border-radius: 8px; background: white; border: 1px solid #e9d5ff; box-shadow: 0 1px 3px rgba(134,59,255,0.08);"></div>
|
||||||
|
<div style="flex: 1; height: 60px; border-radius: 8px; background: white; border: 1px solid #e9d5ff; box-shadow: 0 1px 3px rgba(134,59,255,0.08);"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 4px; margin-top: auto;">
|
||||||
|
<div style="width: 20px; height: 20px; border-radius: 4px; background: #863bff;"></div>
|
||||||
|
<div style="width: 20px; height: 20px; border-radius: 4px; background: #47bfff;"></div>
|
||||||
|
<div style="width: 20px; height: 20px; border-radius: 4px; background: #a78bfa;"></div>
|
||||||
|
<div style="width: 20px; height: 20px; border-radius: 4px; background: #67e8f9;"></div>
|
||||||
|
<div style="width: 20px; height: 20px; border-radius: 4px; background: #ede9fe;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>D. 品牌紫蓝 (Brand ZCLAW)</h3>
|
||||||
|
<p>延续 ZCLAW 品牌色(紫色 #863bff + 蓝色 #47bfff),渐变点缀,现代感与品牌一致性。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-top: 24px; padding: 16px; background: rgba(99,102,241,0.05); border-radius: 8px;">
|
||||||
|
<p style="margin: 0; color: #64748b; font-size: 14px;">
|
||||||
|
<strong>提示:</strong>点击卡片选择你偏好的设计方向。这个选择将影响配色方案、组件风格、以及整体视觉语言。
|
||||||
|
后续的暗色模式将基于所选方向的暗色变体。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
1
.superpowers/brainstorm/1619-1775026541/.server-stopped
Normal file
1
.superpowers/brainstorm/1619-1775026541/.server-stopped
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"reason":"owner process exited","timestamp":1775026601420}
|
||||||
1
.superpowers/brainstorm/1619-1775026541/.server.pid
Normal file
1
.superpowers/brainstorm/1619-1775026541/.server.pid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
1627
|
||||||
68
.superpowers/brainstorm/1619-1775026541/priority-matrix.html
Normal file
68
.superpowers/brainstorm/1619-1775026541/priority-matrix.html
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<h2>ZCLAW 功能优先级矩阵</h2>
|
||||||
|
<p class="subtitle">哪些功能能让用户"啊"的一声觉得值?点击选择你认为的杀手级功能(可多选)</p>
|
||||||
|
|
||||||
|
<div class="options" data-multiselect>
|
||||||
|
<div class="option" data-choice="smart-chat" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">A</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>智能对话(深度优化)</h3>
|
||||||
|
<p>多模型无缝切换、流式响应、上下文记忆闭环、Tool Call 可视化。<br><strong>现状:</strong>基础已好,需打磨体验细节(消息虚拟化、搜索、导出)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option" data-choice="hands" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">B</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>自主 Hands(数字员工)</h3>
|
||||||
|
<p>Browser 自动化、深度研究、数据采集、Twitter 运营——让 AI 真正干活。<br><strong>现状:</strong>9个 Hand 有实现,但需真实场景验证 + 可视化执行流程</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option" data-choice="pipeline" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">C</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>Pipeline 工作流</h3>
|
||||||
|
<p>拖拽式自动化编排:多步骤、多模型、并行/条件分支、定时触发。<br><strong>现状:</strong>引擎完成、UI 有基础版,需完善可视化编辑器 + 模板市场</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option" data-choice="memory" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">D</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>记忆与成长系统</h3>
|
||||||
|
<p>跨会话记忆、事实提取、偏好学习、知识图谱——AI 越用越懂你。<br><strong>现状:</strong>Growth 系统完成,Fact 提取可用,需增强检索质量和可视化</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option" data-choice="skills" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">E</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>技能市场</h3>
|
||||||
|
<p>75+ 预置技能 + 社区技能分享 + 一键安装——AI 能力的 App Store。<br><strong>现状:</strong>SKILL.md 体系完成,需技能发现UI + 安装/卸载流程</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option" data-choice="gateway" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">F</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>LLM 网关(SaaS 变现核心)</h3>
|
||||||
|
<p>Key Pool 代理、用量计费、配额管理、组织级 API Key 管理——企业买单的理由。<br><strong>现状:</strong>Relay+Key Pool 完成,缺计费/配额/支付闭环</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option" data-choice="multi-agent" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">G</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>多 Agent 协作</h3>
|
||||||
|
<p>Director 编排、A2A 协议、角色分配——多个 AI 角色协同解决复杂问题。<br><strong>现状:</strong>代码完成但 feature-gated,未接入桌面端</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option" data-choice="admin" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">H</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>Admin V2 管理面板</h3>
|
||||||
|
<p>用户管理、模型配置、用量统计、操作审计——SaaS 运维必备。<br><strong>现状:</strong>10个页面完成,需测试 + 告警 + 数据看板</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
123
.superpowers/brainstorm/1619-1775026541/zclaw-overview.html
Normal file
123
.superpowers/brainstorm/1619-1775026541/zclaw-overview.html
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<h2>ZCLAW 系统现状全景</h2>
|
||||||
|
<p class="subtitle">基于代码库深度扫描,2026-04-01</p>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>技术架构成熟度</h3>
|
||||||
|
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 12px;">
|
||||||
|
<div style="background: #1a2332; border-radius: 8px; padding: 16px; border-left: 4px solid #22c55e;">
|
||||||
|
<div style="font-size: 13px; color: #94a3b8;">核心类型 (zclaw-types)</div>
|
||||||
|
<div style="font-size: 20px; font-weight: 700; color: #22c55e;">95%</div>
|
||||||
|
<div style="font-size: 12px; color: #64748b;">ID/Message/Event/Capability/Error 全套</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: #1a2332; border-radius: 8px; padding: 16px; border-left: 4px solid #22c55e;">
|
||||||
|
<div style="font-size: 13px; color: #94a3b8;">存储层 (zclaw-memory)</div>
|
||||||
|
<div style="font-size: 20px; font-weight: 700; color: #22c55e;">90%</div>
|
||||||
|
<div style="font-size: 12px; color: #64748b;">SQLite + Fact提取 + KV Store</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: #1a2332; border-radius: 8px; padding: 16px; border-left: 4px solid #22c55e;">
|
||||||
|
<div style="font-size: 13px; color: #94a3b8;">运行时 (zclaw-runtime)</div>
|
||||||
|
<div style="font-size: 20px; font-weight: 700; color: #22c55e;">90%</div>
|
||||||
|
<div style="font-size: 12px; color: #64748b;">4驱动 + 11中间件 + Agent Loop</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: #1a2332; border-radius: 8px; padding: 16px; border-left: 4px solid #eab308;">
|
||||||
|
<div style="font-size: 13px; color: #94a3b8;">协调层 (zclaw-kernel)</div>
|
||||||
|
<div style="font-size: 20px; font-weight: 700; color: #eab308;">85%</div>
|
||||||
|
<div style="font-size: 12px; color: #64748b;">注册/调度/事件/Director(feature-gated)</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: #1a2332; border-radius: 8px; padding: 16px; border-left: 4px solid #22c55e;">
|
||||||
|
<div style="font-size: 13px; color: #94a3b8;">SaaS 后端 (zclaw-saas)</div>
|
||||||
|
<div style="font-size: 20px; font-weight: 700; color: #22c55e;">95%</div>
|
||||||
|
<div style="font-size: 12px; color: #64748b;">76+ API / 17表 / Relay代理 / Key Pool</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: #1a2332; border-radius: 8px; padding: 16px; border-left: 4px solid #22c55e;">
|
||||||
|
<div style="font-size: 13px; color: #94a3b8;">桌面端 (Tauri+React)</div>
|
||||||
|
<div style="font-size: 20px; font-weight: 700; color: #22c55e;">85%</div>
|
||||||
|
<div style="font-size: 12px; color: #64748b;">60+组件 / 13 Store / 3连接模式</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: #1a2332; border-radius: 8px; padding: 16px; border-left: 4px solid #22c55e;">
|
||||||
|
<div style="font-size: 13px; color: #94a3b8;">技能系统 (75 SKILL.md)</div>
|
||||||
|
<div style="font-size: 20px; font-weight: 700; color: #22c55e;">80%</div>
|
||||||
|
<div style="font-size: 12px; color: #64748b;">PromptOnly可执行 / Wasm+Native未完成</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: #1a2332; border-radius: 8px; padding: 16px; border-left: 4px solid #22c55e;">
|
||||||
|
<div style="font-size: 13px; color: #94a3b8;">安全体系</div>
|
||||||
|
<div style="font-size: 20px; font-weight: 700; color: #22c55e;">HIGH</div>
|
||||||
|
<div style="font-size: 12px; color: #64748b;">16层防御 / 渗透测试15项修复完成</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>商业基础设施 vs 商业能力</h3>
|
||||||
|
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 12px;">
|
||||||
|
<div style="background: #0c1a0c; border: 1px solid #22c55e33; border-radius: 8px; padding: 16px;">
|
||||||
|
<h4 style="color: #22c55e; margin:0 0 10px 0;">已建成的基础设施</h4>
|
||||||
|
<ul style="margin:0; padding-left: 18px; color: #cbd5e1; font-size: 14px; line-height: 1.8;">
|
||||||
|
<li>LLM Relay 代理 (Key Pool + 429处理 + RPM/TPM)</li>
|
||||||
|
<li>每模型定价元数据 (input/output pricing)</li>
|
||||||
|
<li>用量追踪 (per-account/per-model token)</li>
|
||||||
|
<li>账户路由 (relay vs local 模式)</li>
|
||||||
|
<li>RBAC 权限体系 (3角色 + 细粒度权限)</li>
|
||||||
|
<li>Admin V2 管理面板 (10页面)</li>
|
||||||
|
<li>Docker + Nginx 部署方案</li>
|
||||||
|
<li>Admin V2 前端 (Ant Design Pro)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div style="background: #1a0c0c; border: 1px solid #ef444433; border-radius: 8px; padding: 16px;">
|
||||||
|
<h4 style="color: #ef4444; margin:0 0 10px 0;">缺失的商业能力</h4>
|
||||||
|
<ul style="margin:0; padding-left: 18px; color: #cbd5e1; font-size: 14px; line-height: 1.8;">
|
||||||
|
<li><strong>无订阅/计费系统</strong> — 无Stripe/支付宝/微信支付</li>
|
||||||
|
<li><strong>无配额管理</strong> — quota字段已被移除</li>
|
||||||
|
<li><strong>无计划/层级定义</strong> — 无 free/pro/enterprise</li>
|
||||||
|
<li><strong>无发票/账单</strong> — 无成本计算逻辑</li>
|
||||||
|
<li><strong>无支付集成</strong> — 无任何支付网关代码</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>核心差异化竞争力</h3>
|
||||||
|
<div style="display:grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-top: 12px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #1e293b, #0f172a); border-radius: 8px; padding: 16px; text-align: center;">
|
||||||
|
<div style="font-size: 28px; margin-bottom: 6px;">⚡</div>
|
||||||
|
<div style="font-size: 14px; font-weight: 600; color: #e2e8f0;">Rust 原生性能</div>
|
||||||
|
<div style="font-size: 12px; color: #64748b; margin-top: 4px;">~40MB RAM / <200ms 冷启动<br>vs Electron 400MB+</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: linear-gradient(135deg, #1e293b, #0f172a); border-radius: 8px; padding: 16px; text-align: center;">
|
||||||
|
<div style="font-size: 28px; margin-bottom: 6px;">🤖</div>
|
||||||
|
<div style="font-size: 14px; font-weight: 600; color: #e2e8f0;">9个自主 Hands</div>
|
||||||
|
<div style="font-size: 12px; color: #64748b; margin-top: 4px;">Browser/Researcher/Twitter<br>预置数字员工</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: linear-gradient(135deg, #1e293b, #0f172a); border-radius: 8px; padding: 16px; text-align: center;">
|
||||||
|
<div style="font-size: 28px; margin-bottom: 6px;">🧩</div>
|
||||||
|
<div style="font-size: 14px; font-weight: 600; color: #e2e8f0;">75+ 技能 + Pipeline</div>
|
||||||
|
<div style="font-size: 12px; color: #64748b; margin-top: 4px;">SKILL.md 声明式定义<br>12种 Pipeline Action</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: linear-gradient(135deg, #1e293b, #0f172a); border-radius: 8px; padding: 16px; text-align: center;">
|
||||||
|
<div style="font-size: 28px; margin-bottom: 6px;">🇨🇳</div>
|
||||||
|
<div style="font-size: 14px; font-weight: 600; color: #e2e8f0;">中文市场原生</div>
|
||||||
|
<div style="font-size: 12px; color: #64748b; margin-top: 4px;">GLM/Qwen/Kimi/DeepSeek<br>27+ LLM Provider</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: linear-gradient(135deg, #1e293b, #0f172a); border-radius: 8px; padding: 16px; text-align: center;">
|
||||||
|
<div style="font-size: 28px; margin-bottom: 6px;">☁️</div>
|
||||||
|
<div style="font-size: 14px; font-weight: 600; color: #e2e8f0;">自托管 SaaS 网关</div>
|
||||||
|
<div style="font-size: 12px; color: #64748b; margin-top: 4px;">Key Pool 代理 / 用量追踪<br>组织级 LLM 管理</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: linear-gradient(135deg, #1e293b, #0f172a); border-radius: 8px; padding: 16px; text-align: center;">
|
||||||
|
<div style="font-size: 28px; margin-bottom: 6px;">🔒</div>
|
||||||
|
<div style="font-size: 14px; font-weight: 600; color: #e2e8f0;">16层安全防护</div>
|
||||||
|
<div style="font-size: 12px; color: #64748b; margin-top: 4px;">渗透测试通过<br>企业级安全合规</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-top: 20px; padding: 16px; background: #1e293b; border-radius: 8px;">
|
||||||
|
<h3 style="margin: 0 0 8px 0;">战略定位一句话</h3>
|
||||||
|
<p style="color: #f59e0b; font-size: 16px; margin: 0; font-weight: 600;">
|
||||||
|
ZCLAW = 中文市场的 AI Agent OS,不是另一个 ChatGPT 套壳。
|
||||||
|
</p>
|
||||||
|
<p style="color: #94a3b8; font-size: 13px; margin: 8px 0 0 0;">
|
||||||
|
核心问题:技术基础设施已建成 ~90%,但商业变现路径从 0 → 1 尚未打通。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
1
.superpowers/brainstorm/1909-1775055381/.server-stopped
Normal file
1
.superpowers/brainstorm/1909-1775055381/.server-stopped
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"reason":"owner process exited","timestamp":1775055441855}
|
||||||
1
.superpowers/brainstorm/1909-1775055381/.server.pid
Normal file
1
.superpowers/brainstorm/1909-1775055381/.server.pid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
1917
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<h2>知识库管理 - UI 布局方案</h2>
|
||||||
|
<p class="subtitle">三种页面布局方案,请选择最适合的方案</p>
|
||||||
|
|
||||||
|
<div class="cards">
|
||||||
|
<div class="card" data-choice="layout-a" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image">
|
||||||
|
<div style="background:#f8f9fa;border-radius:8px;padding:16px;font-size:12px;">
|
||||||
|
<div class="mock-nav" style="background:#1a1a2e;color:#fff;padding:8px;margin:-8px -8px 8px;border-radius:4px;">
|
||||||
|
知识库管理
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<div style="width:200px;background:#fff;border:1px solid #e0e0e0;border-radius:4px;padding:8px;">
|
||||||
|
<div style="font-weight:bold;margin-bottom:8px;color:#1890ff;">📁 行业分类</div>
|
||||||
|
<div style="padding:4px 8px;background:#e6f7ff;border-radius:2px;margin-bottom:4px;">🏭 制造业</div>
|
||||||
|
<div style="padding:4px 8px;margin-bottom:4px;">🏥 医疗健康</div>
|
||||||
|
<div style="padding:4px 8px;margin-bottom:4px;">🎓 教育培训</div>
|
||||||
|
<div style="padding:4px 8px;margin-bottom:4px;">👔 企业管理</div>
|
||||||
|
<div style="padding:4px 8px;color:#999;">+ 新增分类</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;background:#fff;border:1px solid #e0e0e0;border-radius:4px;padding:8px;">
|
||||||
|
<div style="display:flex;justify-content:space-between;margin-bottom:8px;">
|
||||||
|
<span style="font-weight:bold;">🏭 制造业 (24条)</span>
|
||||||
|
<div>
|
||||||
|
<span style="background:#1890ff;color:#fff;padding:2px 8px;border-radius:2px;font-size:11px;">+ 新增</span>
|
||||||
|
<span style="background:#f0f0f0;padding:2px 8px;border-radius:2px;font-size:11px;margin-left:4px;">导入</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="border:1px solid #f0f0f0;border-radius:2px;padding:6px;margin-bottom:4px;">
|
||||||
|
<b>注塑成型工艺参数指南</b><br>
|
||||||
|
<span style="font-size:10px;color:#999;">关键词: 注塑, 工艺参数, 温度控制 | 更新于 2小时前</span>
|
||||||
|
</div>
|
||||||
|
<div style="border:1px solid #f0f0f0;border-radius:2px;padding:6px;margin-bottom:4px;">
|
||||||
|
<b>模具设计常见问题集</b><br>
|
||||||
|
<span style="font-size:10px;color:#999;">关键词: 模具, 设计, FAQ | 更新于 1天前</span>
|
||||||
|
</div>
|
||||||
|
<div style="border:1px solid #f0f0f0;border-radius:2px;padding:6px;">
|
||||||
|
<b>QC 质检标准流程</b><br>
|
||||||
|
<span style="font-size:10px;color:#999;">关键词: 质检, QC, 流程 | 更新于 3天前</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>A: 左树右表(经典管理布局)</h3>
|
||||||
|
<p>左侧分类树 + 右侧条目列表。空间利用率高,浏览效率好。适合分类层级清晰的场景。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" data-choice="layout-b" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image">
|
||||||
|
<div style="background:#f8f9fa;border-radius:8px;padding:16px;font-size:12px;">
|
||||||
|
<div class="mock-nav" style="background:#1a1a2e;color:#fff;padding:8px;margin:-8px -8px 8px;border-radius:4px;">
|
||||||
|
知识库管理
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;margin-bottom:8px;">
|
||||||
|
<span style="background:#1890ff;color:#fff;padding:4px 12px;border-radius:12px;font-size:11px;">全部 (68)</span>
|
||||||
|
<span style="background:#f0f0f0;padding:4px 12px;border-radius:12px;font-size:11px;">🏭 制造业 (24)</span>
|
||||||
|
<span style="background:#f0f0f0;padding:4px 12px;border-radius:12px;font-size:11px;">🏥 医疗健康 (18)</span>
|
||||||
|
<span style="background:#f0f0f0;padding:4px 12px;border-radius:12px;font-size:11px;">🎓 教育培训 (15)</span>
|
||||||
|
<span style="background:#f0f0f0;padding:4px 12px;border-radius:12px;font-size:11px;">👔 企业管理 (11)</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
|
||||||
|
<div style="border:1px solid #e0e0e0;border-radius:4px;padding:8px;">
|
||||||
|
<b>注塑成型工艺参数指南</b>
|
||||||
|
<p style="font-size:10px;color:#666;margin:4px 0;">详细描述注塑成型的温度、压力、冷却时间等关键参数...</p>
|
||||||
|
<span style="font-size:10px;color:#1890ff;">🏭 制造业</span>
|
||||||
|
<span style="font-size:10px;color:#999;margin-left:8px;">引用 42 次</span>
|
||||||
|
</div>
|
||||||
|
<div style="border:1px solid #e0e0e0;border-radius:4px;padding:8px;">
|
||||||
|
<b>药品 GMP 合规检查清单</b>
|
||||||
|
<p style="font-size:10px;color:#666;margin:4px 0;">涵盖药品生产质量管理的完整合规要求...</p>
|
||||||
|
<span style="font-size:10px;color:#52c41a;">🏥 医疗健康</span>
|
||||||
|
<span style="font-size:10px;color:#999;margin-left:8px;">引用 38 次</span>
|
||||||
|
</div>
|
||||||
|
<div style="border:1px solid #e0e0e0;border-radius:4px;padding:8px;">
|
||||||
|
<b>模具设计常见问题集</b>
|
||||||
|
<p style="font-size:10px;color:#666;margin:4px 0;">汇总模具设计过程中的常见技术问题和解决方案...</p>
|
||||||
|
<span style="font-size:10px;color:#1890ff;">🏭 制造业</span>
|
||||||
|
<span style="font-size:10px;color:#999;margin-left:8px;">引用 27 次</span>
|
||||||
|
</div>
|
||||||
|
<div style="border:1px solid #e0e0e0;border-radius:4px;padding:8px;">
|
||||||
|
<b>在线课程设计方法论</b>
|
||||||
|
<p style="font-size:10px;color:#666;margin:4px 0;">系统化的在线教育课程设计和评估方法...</p>
|
||||||
|
<span style="font-size:10px;color:#fa8c16;">🎓 教育培训</span>
|
||||||
|
<span style="font-size:10px;color:#999;margin-left:8px;">引用 19 次</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>B: 卡片网格(标签筛选)</h3>
|
||||||
|
<p>顶部标签切换 + 卡片网格展示。视觉友好,快速浏览内容概要。适合知识条目不多且偏内容展示的场景。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" data-choice="layout-c" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image">
|
||||||
|
<div style="background:#f8f9fa;border-radius:8px;padding:16px;font-size:12px;">
|
||||||
|
<div class="mock-nav" style="background:#1a1a2e;color:#fff;padding:8px;margin:-8px -8px 8px;border-radius:4px;">
|
||||||
|
知识库管理
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;margin-bottom:8px;">
|
||||||
|
<div style="background:#1890ff;color:#fff;padding:4px 12px;border-radius:4px;font-size:11px;">📋 知识条目</div>
|
||||||
|
<div style="background:#f0f0f0;padding:4px 12px;border-radius:4px;font-size:11px;">📂 分类管理</div>
|
||||||
|
<div style="background:#f0f0f0;padding:4px 12px;border-radius:4px;font-size:11px;">📊 分析看板</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:8px;display:flex;gap:4px;">
|
||||||
|
<input style="flex:1;padding:4px 8px;border:1px solid #d9d9d9;border-radius:4px;font-size:11px;" placeholder="搜索知识条目...">
|
||||||
|
<select style="padding:4px 8px;border:1px solid #d9d9d9;border-radius:4px;font-size:11px;">
|
||||||
|
<option>全部分类</option><option>制造业</option><option>医疗健康</option>
|
||||||
|
</select>
|
||||||
|
<select style="padding:4px 8px;border:1px solid #d9d9d9;border-radius:4px;font-size:11px;">
|
||||||
|
<option>状态</option><option>活跃</option><option>已归档</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="border-collapse:collapse;width:100%;">
|
||||||
|
<div style="display:flex;background:#fafafa;padding:6px;border:1px solid #f0f0f0;font-size:10px;font-weight:bold;">
|
||||||
|
<span style="width:30px;">☑</span>
|
||||||
|
<span style="flex:2;">标题</span>
|
||||||
|
<span style="flex:1;">分类</span>
|
||||||
|
<span style="flex:1;">关键词</span>
|
||||||
|
<span style="width:60px;">引用</span>
|
||||||
|
<span style="width:60px;">状态</span>
|
||||||
|
<span style="width:80px;">更新时间</span>
|
||||||
|
<span style="width:60px;">操作</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;padding:6px;border:1px solid #f0f0f0;border-top:0;font-size:10px;">
|
||||||
|
<span style="width:30px;">☐</span>
|
||||||
|
<span style="flex:2;font-weight:bold;">注塑成型工艺参数指南</span>
|
||||||
|
<span style="flex:1;color:#1890ff;">🏭 制造业</span>
|
||||||
|
<span style="flex:1;color:#999;">注塑, 工艺</span>
|
||||||
|
<span style="width:60px;">42</span>
|
||||||
|
<span style="width:60px;color:#52c41a;">活跃</span>
|
||||||
|
<span style="width:80px;color:#999;">2h 前</span>
|
||||||
|
<span style="width:60px;color:#1890ff;">编辑</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;padding:6px;border:1px solid #f0f0f0;border-top:0;font-size:10px;">
|
||||||
|
<span style="width:30px;">☐</span>
|
||||||
|
<span style="flex:2;font-weight:bold;">药品 GMP 合规检查清单</span>
|
||||||
|
<span style="flex:1;color:#52c41a;">🏥 医疗</span>
|
||||||
|
<span style="flex:1;color:#999;">GMP, 合规</span>
|
||||||
|
<span style="width:60px;">38</span>
|
||||||
|
<span style="width:60px;color:#52c41a;">活跃</span>
|
||||||
|
<span style="width:80px;color:#999;">1d 前</span>
|
||||||
|
<span style="width:60px;color:#1890ff;">编辑</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;padding:6px;border:1px solid #f0f0f0;border-top:0;font-size:10px;">
|
||||||
|
<span style="width:30px;">☐</span>
|
||||||
|
<span style="flex:2;font-weight:bold;">模具设计常见问题集</span>
|
||||||
|
<span style="flex:1;color:#1890ff;">🏭 制造业</span>
|
||||||
|
<span style="flex:1;color:#999;">模具, FAQ</span>
|
||||||
|
<span style="width:60px;">27</span>
|
||||||
|
<span style="width:60px;color:#52c41a;">活跃</span>
|
||||||
|
<span style="width:80px;color:#999;">3d 前</span>
|
||||||
|
<span style="width:60px;color:#1890ff;">编辑</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>C: 标签页表格(Ant Design 风格)</h3>
|
||||||
|
<p>顶部标签页切换模块 + 标准表格。最符合现有 Admin V2 风格,信息密度高,适合批量操作。与现有页面一致。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
|
||||||
|
<p class="subtitle">Continuing in terminal...</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
|
||||||
|
<p class="subtitle">正在准备知识库 UI 布局方案...</p>
|
||||||
|
</div>
|
||||||
1
.superpowers/brainstorm/229-1775043190/.server-stopped
Normal file
1
.superpowers/brainstorm/229-1775043190/.server-stopped
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"reason":"owner process exited","timestamp":1775043250470}
|
||||||
1
.superpowers/brainstorm/229-1775043190/.server.pid
Normal file
1
.superpowers/brainstorm/229-1775043190/.server.pid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
237
|
||||||
68
.superpowers/brainstorm/229-1775043190/zclaw-overview.html
Normal file
68
.superpowers/brainstorm/229-1775043190/zclaw-overview.html
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<h2>ZCLAW 功能优先级矩阵</h2>
|
||||||
|
<p class="subtitle">哪些功能能让用户"啊"的一声觉得值?点击选择你认为的杀手级功能(可多选)</p>
|
||||||
|
|
||||||
|
<div class="options" data-multiselect>
|
||||||
|
<div class="option" data-choice="smart-chat" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">A</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>智能对话(深度优化)</h3>
|
||||||
|
<p>多模型无缝切换、流式响应、上下文记忆闭环、Tool Call 可视化。<br><strong>现状:</strong>基础已好,需打磨体验细节(消息虚拟化、搜索、导出)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option" data-choice="hands" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">B</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>自主 Hands(数字员工)</h3>
|
||||||
|
<p>Browser 自动化、深度研究、数据采集、Twitter 运营——让 AI 真正干活。<br><strong>现状:</strong>9个 Hand 有实现,但需真实场景验证 + 可视化执行流程</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option" data-choice="pipeline" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">C</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>Pipeline 工作流</h3>
|
||||||
|
<p>拖拽式自动化编排:多步骤、多模型、并行/条件分支、定时触发。<br><strong>现状:</strong>引擎完成、UI 有基础版,需完善可视化编辑器 + 模板市场</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option" data-choice="memory" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">D</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>记忆与成长系统</h3>
|
||||||
|
<p>跨会话记忆、事实提取、偏好学习、知识图谱——AI 越用越懂你。<br><strong>现状:</strong>Growth 系统完成,Fact 提取可用,需增强检索质量和可视化</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option" data-choice="skills" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">E</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>技能市场</h3>
|
||||||
|
<p>75+ 预置技能 + 社区技能分享 + 一键安装——AI 能力的 App Store。<br><strong>现状:</strong>SKILL.md 体系完成,需技能发现UI + 安装/卸载流程</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option" data-choice="gateway" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">F</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>LLM 网关(SaaS 变现核心)</h3>
|
||||||
|
<p>Key Pool 代理、用量计费、配额管理、组织级 API Key 管理——企业买单的理由。<br><strong>现状:</strong>Relay+Key Pool 完成,缺计费/配额/支付闭环</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option" data-choice="multi-agent" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">G</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>多 Agent 协作</h3>
|
||||||
|
<p>Director 编排、A2A 协议、角色分配——多个 AI 角色协同解决复杂问题。<br><strong>现状:</strong>代码完成但 feature-gated,未接入桌面端</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option" data-choice="admin" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">H</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>Admin V2 管理面板</h3>
|
||||||
|
<p>用户管理、模型配置、用量统计、操作审计——SaaS 运维必备。<br><strong>现状:</strong>10个页面完成,需测试 + 告警 + 数据看板</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
463
65-90p
Normal file
463
65-90p
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
00000000: 2f2f 2120 5a43 4c41 5720 5361 6153 20e6 //! ZCLAW SaaS .
|
||||||
|
00000010: 9c8d e58a a1e5 85a5 e58f a30d 0a0d 0a75 ...............u
|
||||||
|
00000020: 7365 2061 7875 6d3a 3a65 7874 7261 6374 se axum::extract
|
||||||
|
00000030: 3a3a 5374 6174 653b 0d0a 7573 6520 746f ::State;..use to
|
||||||
|
00000040: 7765 725f 6874 7470 3a3a 7469 6d65 6f75 wer_http::timeou
|
||||||
|
00000050: 743a 3a54 696d 656f 7574 4c61 7965 723b t::TimeoutLayer;
|
||||||
|
00000060: 0d0a 7573 6520 7472 6163 696e 673a 3a69 ..use tracing::i
|
||||||
|
00000070: 6e66 6f3b 0d0a 7573 6520 7a63 6c61 775f nfo;..use zclaw_
|
||||||
|
00000080: 7361 6173 3a3a 7b63 6f6e 6669 673a 3a53 saas::{config::S
|
||||||
|
00000090: 6161 5343 6f6e 6669 672c 2064 623a 3a69 aaSConfig, db::i
|
||||||
|
000000a0: 6e69 745f 6462 2c20 7374 6174 653a 3a41 nit_db, state::A
|
||||||
|
000000b0: 7070 5374 6174 657d 3b0d 0a75 7365 207a ppState};..use z
|
||||||
|
000000c0: 636c 6177 5f73 6161 733a 3a77 6f72 6b65 claw_saas::worke
|
||||||
|
000000d0: 7273 3a3a 576f 726b 6572 4469 7370 6174 rs::WorkerDispat
|
||||||
|
000000e0: 6368 6572 3b0d 0a75 7365 207a 636c 6177 cher;..use zclaw
|
||||||
|
000000f0: 5f73 6161 733a 3a77 6f72 6b65 7273 3a3a _saas::workers::
|
||||||
|
00000100: 6c6f 675f 6f70 6572 6174 696f 6e3a 3a4c log_operation::L
|
||||||
|
00000110: 6f67 4f70 6572 6174 696f 6e57 6f72 6b65 ogOperationWorke
|
||||||
|
00000120: 723b 0d0a 7573 6520 7a63 6c61 775f 7361 r;..use zclaw_sa
|
||||||
|
00000130: 6173 3a3a 776f 726b 6572 733a 3a63 6c65 as::workers::cle
|
||||||
|
00000140: 616e 7570 5f72 6566 7265 7368 5f74 6f6b anup_refresh_tok
|
||||||
|
00000150: 656e 733a 3a43 6c65 616e 7570 5265 6672 ens::CleanupRefr
|
||||||
|
00000160: 6573 6854 6f6b 656e 7357 6f72 6b65 723b eshTokensWorker;
|
||||||
|
00000170: 0d0a 7573 6520 7a63 6c61 775f 7361 6173 ..use zclaw_saas
|
||||||
|
00000180: 3a3a 776f 726b 6572 733a 3a63 6c65 616e ::workers::clean
|
||||||
|
00000190: 7570 5f72 6174 655f 6c69 6d69 743a 3a43 up_rate_limit::C
|
||||||
|
000001a0: 6c65 616e 7570 5261 7465 4c69 6d69 7457 leanupRateLimitW
|
||||||
|
000001b0: 6f72 6b65 723b 0d0a 7573 6520 7a63 6c61 orker;..use zcla
|
||||||
|
000001c0: 775f 7361 6173 3a3a 776f 726b 6572 733a w_saas::workers:
|
||||||
|
000001d0: 3a72 6563 6f72 645f 7573 6167 653a 3a52 :record_usage::R
|
||||||
|
000001e0: 6563 6f72 6455 7361 6765 576f 726b 6572 ecordUsageWorker
|
||||||
|
000001f0: 3b0d 0a75 7365 207a 636c 6177 5f73 6161 ;..use zclaw_saa
|
||||||
|
00000200: 733a 3a77 6f72 6b65 7273 3a3a 7570 6461 s::workers::upda
|
||||||
|
00000210: 7465 5f6c 6173 745f 7573 6564 3a3a 5570 te_last_used::Up
|
||||||
|
00000220: 6461 7465 4c61 7374 5573 6564 576f 726b dateLastUsedWork
|
||||||
|
00000230: 6572 3b0d 0a0d 0a23 5b74 6f6b 696f 3a3a er;....#[tokio::
|
||||||
|
00000240: 6d61 696e 5d0d 0a61 7379 6e63 2066 6e20 main]..async fn
|
||||||
|
00000250: 6d61 696e 2829 202d 3e20 616e 7968 6f77 main() -> anyhow
|
||||||
|
00000260: 3a3a 5265 7375 6c74 3c28 293e 207b 0d0a ::Result<()> {..
|
||||||
|
00000270: 2020 2020 7472 6163 696e 675f 7375 6273 tracing_subs
|
||||||
|
00000280: 6372 6962 6572 3a3a 666d 7428 290d 0a20 criber::fmt()..
|
||||||
|
00000290: 2020 2020 2020 202e 7769 7468 5f65 6e76 .with_env
|
||||||
|
000002a0: 5f66 696c 7465 7228 0d0a 2020 2020 2020 _filter(..
|
||||||
|
000002b0: 2020 2020 2020 7472 6163 696e 675f 7375 tracing_su
|
||||||
|
000002c0: 6273 6372 6962 6572 3a3a 456e 7646 696c bscriber::EnvFil
|
||||||
|
000002d0: 7465 723a 3a74 7279 5f66 726f 6d5f 6465 ter::try_from_de
|
||||||
|
000002e0: 6661 756c 745f 656e 7628 290d 0a20 2020 fault_env()..
|
||||||
|
000002f0: 2020 2020 2020 2020 2020 2020 202e 756e .un
|
||||||
|
00000300: 7772 6170 5f6f 725f 656c 7365 287c 5f7c wrap_or_else(|_|
|
||||||
|
00000310: 2022 7a63 6c61 775f 7361 6173 3d64 6562 "zclaw_saas=deb
|
||||||
|
00000320: 7567 2c74 6f77 6572 5f68 7474 703d 6465 ug,tower_http=de
|
||||||
|
00000330: 6275 6722 2e69 6e74 6f28 2929 2c0d 0a20 bug".into()),..
|
||||||
|
00000340: 2020 2020 2020 2029 0d0a 2020 2020 2020 )..
|
||||||
|
00000350: 2020 2e69 6e69 7428 293b 0d0a 0d0a 2020 .init();....
|
||||||
|
00000360: 2020 6c65 7420 636f 6e66 6967 203d 2053 let config = S
|
||||||
|
00000370: 6161 5343 6f6e 6669 673a 3a6c 6f61 6428 aaSConfig::load(
|
||||||
|
00000380: 293f 3b0d 0a20 2020 2069 6e66 6f21 2822 )?;.. info!("
|
||||||
|
00000390: 5361 6153 2063 6f6e 6669 6720 6c6f 6164 SaaS config load
|
||||||
|
000003a0: 6564 3a20 7b7d 3a7b 7d22 2c20 636f 6e66 ed: {}:{}", conf
|
||||||
|
000003b0: 6967 2e73 6572 7665 722e 686f 7374 2c20 ig.server.host,
|
||||||
|
000003c0: 636f 6e66 6967 2e73 6572 7665 722e 706f config.server.po
|
||||||
|
000003d0: 7274 293b 0d0a 0d0a 2020 2020 6c65 7420 rt);.... let
|
||||||
|
000003e0: 6462 203d 2069 6e69 745f 6462 2826 636f db = init_db(&co
|
||||||
|
000003f0: 6e66 6967 2e64 6174 6162 6173 652e 7572 nfig.database.ur
|
||||||
|
00000400: 6c29 2e61 7761 6974 3f3b 0d0a 2020 2020 l).await?;..
|
||||||
|
00000410: 696e 666f 2128 2244 6174 6162 6173 6520 info!("Database
|
||||||
|
00000420: 696e 6974 6961 6c69 7a65 6422 293b 0d0a initialized");..
|
||||||
|
00000430: 0d0a 2020 2020 2f2f 20e5 889d e5a7 8be5 .. // .......
|
||||||
|
00000440: 8c96 2057 6f72 6b65 7220 e8b0 83e5 baa6 .. Worker ......
|
||||||
|
00000450: e599 a820 2b20 e6b3 a8e5 868c e689 80e6 ... + ..........
|
||||||
|
00000460: 9c89 2057 6f72 6b65 720d 0a20 2020 206c .. Worker.. l
|
||||||
|
00000470: 6574 206d 7574 2064 6973 7061 7463 6865 et mut dispatche
|
||||||
|
00000480: 7220 3d20 576f 726b 6572 4469 7370 6174 r = WorkerDispat
|
||||||
|
00000490: 6368 6572 3a3a 6e65 7728 6462 2e63 6c6f cher::new(db.clo
|
||||||
|
000004a0: 6e65 2829 293b 0d0a 2020 2020 6469 7370 ne());.. disp
|
||||||
|
000004b0: 6174 6368 6572 2e72 6567 6973 7465 7228 atcher.register(
|
||||||
|
000004c0: 4c6f 674f 7065 7261 7469 6f6e 576f 726b LogOperationWork
|
||||||
|
000004d0: 6572 293b 0d0a 2020 2020 6469 7370 6174 er);.. dispat
|
||||||
|
000004e0: 6368 6572 2e72 6567 6973 7465 7228 436c cher.register(Cl
|
||||||
|
000004f0: 6561 6e75 7052 6566 7265 7368 546f 6b65 eanupRefreshToke
|
||||||
|
00000500: 6e73 576f 726b 6572 293b 0d0a 2020 2020 nsWorker);..
|
||||||
|
00000510: 6469 7370 6174 6368 6572 2e72 6567 6973 dispatcher.regis
|
||||||
|
00000520: 7465 7228 436c 6561 6e75 7052 6174 654c ter(CleanupRateL
|
||||||
|
00000530: 696d 6974 576f 726b 6572 293b 0d0a 2020 imitWorker);..
|
||||||
|
00000540: 2020 6469 7370 6174 6368 6572 2e72 6567 dispatcher.reg
|
||||||
|
00000550: 6973 7465 7228 5265 636f 7264 5573 6167 ister(RecordUsag
|
||||||
|
00000560: 6557 6f72 6b65 7229 3b0d 0a20 2020 2064 eWorker);.. d
|
||||||
|
00000570: 6973 7061 7463 6865 722e 7265 6769 7374 ispatcher.regist
|
||||||
|
00000580: 6572 2855 7064 6174 654c 6173 7455 7365 er(UpdateLastUse
|
||||||
|
00000590: 6457 6f72 6b65 7229 3b0d 0a20 2020 2069 dWorker);.. i
|
||||||
|
000005a0: 6e66 6f21 2822 576f 726b 6572 2064 6973 nfo!("Worker dis
|
||||||
|
000005b0: 7061 7463 6865 7220 696e 6974 6961 6c69 patcher initiali
|
||||||
|
000005c0: 7a65 6420 2835 2077 6f72 6b65 7273 2072 zed (5 workers r
|
||||||
|
000005d0: 6567 6973 7465 7265 6429 2229 3b0d 0a0d egistered)");...
|
||||||
|
000005e0: 0a20 2020 206c 6574 2073 7461 7465 203d . let state =
|
||||||
|
000005f0: 2041 7070 5374 6174 653a 3a6e 6577 2864 AppState::new(d
|
||||||
|
00000600: 622e 636c 6f6e 6528 292c 2063 6f6e 6669 b.clone(), confi
|
||||||
|
00000610: 672e 636c 6f6e 6528 292c 2064 6973 7061 g.clone(), dispa
|
||||||
|
00000620: 7463 6865 7229 3f3b 0d0a 0d0a 2020 2020 tcher)?;....
|
||||||
|
00000630: 2f2f 20e5 90af e58a a8e5 a3b0 e698 8ee5 // .............
|
||||||
|
00000640: bc8f 2053 6368 6564 756c 6572 efbc 88e4 .. Scheduler....
|
||||||
|
00000650: bb8e 2054 4f4d 4c20 e985 8de7 bdae e8af .. TOML ........
|
||||||
|
00000660: bbe5 8f96 e5ae 9ae6 97b6 e4bb bbe5 8aa1 ................
|
||||||
|
00000670: efbc 890d 0a20 2020 206c 6574 2073 6368 ..... let sch
|
||||||
|
00000680: 6564 756c 6572 5f63 6f6e 6669 6720 3d20 eduler_config =
|
||||||
|
00000690: 2663 6f6e 6669 672e 7363 6865 6475 6c65 &config.schedule
|
||||||
|
000006a0: 723b 0d0a 2020 2020 7a63 6c61 775f 7361 r;.. zclaw_sa
|
||||||
|
000006b0: 6173 3a3a 7363 6865 6475 6c65 723a 3a73 as::scheduler::s
|
||||||
|
000006c0: 7461 7274 5f73 6368 6564 756c 6572 2873 tart_scheduler(s
|
||||||
|
000006d0: 6368 6564 756c 6572 5f63 6f6e 6669 672c cheduler_config,
|
||||||
|
000006e0: 2064 622e 636c 6f6e 6528 292c 2073 7461 db.clone(), sta
|
||||||
|
000006f0: 7465 2e77 6f72 6b65 725f 6469 7370 6174 te.worker_dispat
|
||||||
|
00000700: 6368 6572 2e63 6c6f 6e65 5f72 6566 2829 cher.clone_ref()
|
||||||
|
00000710: 293b 0d0a 2020 2020 696e 666f 2128 2253 );.. info!("S
|
||||||
|
00000720: 6368 6564 756c 6572 2073 7461 7274 6564 cheduler started
|
||||||
|
00000730: 2077 6974 6820 7b7d 206a 6f62 7322 2c20 with {} jobs",
|
||||||
|
00000740: 7363 6865 6475 6c65 725f 636f 6e66 6967 scheduler_config
|
||||||
|
00000750: 2e6a 6f62 732e 6c65 6e28 2929 3b0d 0a0d .jobs.len());...
|
||||||
|
00000760: 0a20 2020 202f 2f20 e590 afe5 8aa8 e586 . // ........
|
||||||
|
00000770: 85e7 bdae 2044 4220 e6b8 85e7 9086 e4bb .... DB ........
|
||||||
|
00000780: bbe5 8aa1 efbc 88e8 aebe e5a4 87e6 b885 ................
|
||||||
|
00000790: e790 86e7 ad89 e4b8 8de9 809a e8bf 8720 ...............
|
||||||
|
000007a0: 576f 726b 6572 20e7 9a84 e4bb bbe5 8aa1 Worker .........
|
||||||
|
000007b0: efbc 890d 0a20 2020 207a 636c 6177 5f73 ..... zclaw_s
|
||||||
|
000007c0: 6161 733a 3a73 6368 6564 756c 6572 3a3a aas::scheduler::
|
||||||
|
000007d0: 7374 6172 745f 6462 5f63 6c65 616e 7570 start_db_cleanup
|
||||||
|
000007e0: 5f74 6173 6b73 2864 622e 636c 6f6e 6528 _tasks(db.clone(
|
||||||
|
000007f0: 2929 3b0d 0a0d 0a20 2020 202f 2f20 e590 ));.... // ..
|
||||||
|
00000800: afe5 8aa8 e586 85e5 ad98 e4b8 ade7 9a84 ................
|
||||||
|
00000810: 2072 6174 6520 6c69 6d69 7420 e69d a1e7 rate limit ....
|
||||||
|
00000820: 9bae e6b8 85e7 9086 0d0a 2020 2020 6c65 .......... le
|
||||||
|
00000830: 7420 7261 7465 5f6c 696d 6974 5f73 7461 t rate_limit_sta
|
||||||
|
00000840: 7465 203d 2073 7461 7465 2e63 6c6f 6e65 te = state.clone
|
||||||
|
00000850: 2829 3b0d 0a20 2020 2074 6f6b 696f 3a3a ();.. tokio::
|
||||||
|
00000860: 7370 6177 6e28 6173 796e 6320 6d6f 7665 spawn(async move
|
||||||
|
00000870: 207b 0d0a 2020 2020 2020 2020 6c65 7420 {.. let
|
||||||
|
00000880: 6d75 7420 696e 7465 7276 616c 203d 2074 mut interval = t
|
||||||
|
00000890: 6f6b 696f 3a3a 7469 6d65 3a3a 696e 7465 okio::time::inte
|
||||||
|
000008a0: 7276 616c 2873 7464 3a3a 7469 6d65 3a3a rval(std::time::
|
||||||
|
000008b0: 4475 7261 7469 6f6e 3a3a 6672 6f6d 5f73 Duration::from_s
|
||||||
|
000008c0: 6563 7328 3330 3029 293b 0d0a 2020 2020 ecs(300));..
|
||||||
|
000008d0: 2020 2020 6c6f 6f70 207b 0d0a 2020 2020 loop {..
|
||||||
|
000008e0: 2020 2020 2020 2020 696e 7465 7276 616c interval
|
||||||
|
000008f0: 2e74 6963 6b28 292e 6177 6169 743b 0d0a .tick().await;..
|
||||||
|
00000900: 2020 2020 2020 2020 2020 2020 7261 7465 rate
|
||||||
|
00000910: 5f6c 696d 6974 5f73 7461 7465 2e63 6c65 _limit_state.cle
|
||||||
|
00000920: 616e 7570 5f72 6174 655f 6c69 6d69 745f anup_rate_limit_
|
||||||
|
00000930: 656e 7472 6965 7328 293b 0d0a 2020 2020 entries();..
|
||||||
|
00000940: 2020 2020 7d0d 0a20 2020 207d 293b 0d0a }.. });..
|
||||||
|
00000950: 0d0a 2020 2020 6c65 7420 6170 7020 3d20 .. let app =
|
||||||
|
00000960: 6275 696c 645f 726f 7574 6572 2873 7461 build_router(sta
|
||||||
|
00000970: 7465 292e 6177 6169 743b 0d0a 0d0a 2020 te).await;....
|
||||||
|
00000980: 2020 6c65 7420 6c69 7374 656e 6572 203d let listener =
|
||||||
|
00000990: 2074 6f6b 696f 3a3a 6e65 743a 3a54 6370 tokio::net::Tcp
|
||||||
|
000009a0: 4c69 7374 656e 6572 3a3a 6269 6e64 2866 Listener::bind(f
|
||||||
|
000009b0: 6f72 6d61 7421 2822 7b7d 3a7b 7d22 2c20 ormat!("{}:{}",
|
||||||
|
000009c0: 636f 6e66 6967 2e73 6572 7665 722e 686f config.server.ho
|
||||||
|
000009d0: 7374 2c20 636f 6e66 6967 2e73 6572 7665 st, config.serve
|
||||||
|
000009e0: 722e 706f 7274 2929 0d0a 2020 2020 2020 r.port))..
|
||||||
|
000009f0: 2020 2e61 7761 6974 3f3b 0d0a 2020 2020 .await?;..
|
||||||
|
00000a00: 696e 666f 2128 2253 6161 5320 7365 7276 info!("SaaS serv
|
||||||
|
00000a10: 6572 206c 6973 7465 6e69 6e67 206f 6e20 er listening on
|
||||||
|
00000a20: 7b7d 3a7b 7d22 2c20 636f 6e66 6967 2e73 {}:{}", config.s
|
||||||
|
00000a30: 6572 7665 722e 686f 7374 2c20 636f 6e66 erver.host, conf
|
||||||
|
00000a40: 6967 2e73 6572 7665 722e 706f 7274 293b ig.server.port);
|
||||||
|
00000a50: 0d0a 0d0a 2020 2020 6178 756d 3a3a 7365 .... axum::se
|
||||||
|
00000a60: 7276 6528 6c69 7374 656e 6572 2c20 6170 rve(listener, ap
|
||||||
|
00000a70: 702e 696e 746f 5f6d 616b 655f 7365 7276 p.into_make_serv
|
||||||
|
00000a80: 6963 655f 7769 7468 5f63 6f6e 6e65 6374 ice_with_connect
|
||||||
|
00000a90: 5f69 6e66 6f3a 3a3c 7374 643a 3a6e 6574 _info::<std::net
|
||||||
|
00000aa0: 3a3a 536f 636b 6574 4164 6472 3e28 2929 ::SocketAddr>())
|
||||||
|
00000ab0: 0d0a 2020 2020 2020 2020 2e77 6974 685f .. .with_
|
||||||
|
00000ac0: 6772 6163 6566 756c 5f73 6875 7464 6f77 graceful_shutdow
|
||||||
|
00000ad0: 6e28 7368 7574 646f 776e 5f73 6967 6e61 n(shutdown_signa
|
||||||
|
00000ae0: 6c28 2929 0d0a 2020 2020 2020 2020 2e61 l()).. .a
|
||||||
|
00000af0: 7761 6974 3f3b 0d0a 2020 2020 4f6b 2828 wait?;.. Ok((
|
||||||
|
00000b00: 2929 0d0a 7d0d 0a0d 0a61 7379 6e63 2066 ))..}....async f
|
||||||
|
00000b10: 6e20 6865 616c 7468 5f68 616e 646c 6572 n health_handler
|
||||||
|
00000b20: 2853 7461 7465 2873 7461 7465 293a 2053 (State(state): S
|
||||||
|
00000b30: 7461 7465 3c41 7070 5374 6174 653e 2920 tate<AppState>)
|
||||||
|
00000b40: 2d3e 2061 7875 6d3a 3a4a 736f 6e3c 7365 -> axum::Json<se
|
||||||
|
00000b50: 7264 655f 6a73 6f6e 3a3a 5661 6c75 653e rde_json::Value>
|
||||||
|
00000b60: 207b 0d0a 2020 2020 2f2f 2068 6561 6c74 {.. // healt
|
||||||
|
00000b70: 6820 e5bf 85e9 a1bb e78b ace7 ab8b e5bf h ..............
|
||||||
|
00000b80: abe9 809f e8bf 94e5 9b9e efbc 8ce7 94a8 ................
|
||||||
|
00000b90: 2033 7320 e8b6 85e6 97b6 e981 bfe5 858d 3s ............
|
||||||
|
00000ba0: e8bf 9ee6 8ea5 e6b1 a0e6 bba1 e697 b6e9 ................
|
||||||
|
00000bb0: 98bb e5a1 9e0d 0a20 2020 206c 6574 2064 ....... let d
|
||||||
|
00000bc0: 625f 6865 616c 7468 7920 3d20 746f 6b69 b_healthy = toki
|
||||||
|
00000bd0: 6f3a 3a74 696d 653a 3a74 696d 656f 7574 o::time::timeout
|
||||||
|
00000be0: 280d 0a20 2020 2020 2020 2073 7464 3a3a (.. std::
|
||||||
|
00000bf0: 7469 6d65 3a3a 4475 7261 7469 6f6e 3a3a time::Duration::
|
||||||
|
00000c00: 6672 6f6d 5f73 6563 7328 3329 2c0d 0a20 from_secs(3),..
|
||||||
|
00000c10: 2020 2020 2020 2073 716c 783a 3a71 7565 sqlx::que
|
||||||
|
00000c20: 7279 5f73 6361 6c61 723a 3a3c 5f2c 2069 ry_scalar::<_, i
|
||||||
|
00000c30: 3332 3e28 2253 454c 4543 5420 3122 292e 32>("SELECT 1").
|
||||||
|
00000c40: 6665 7463 685f 6f6e 6528 2673 7461 7465 fetch_one(&state
|
||||||
|
00000c50: 2e64 6229 2c0d 0a20 2020 2029 0d0a 2020 .db),.. )..
|
||||||
|
00000c60: 2020 2e61 7761 6974 0d0a 2020 2020 2e6d .await.. .m
|
||||||
|
00000c70: 6170 287c 727c 2072 2e69 735f 6f6b 2829 ap(|r| r.is_ok()
|
||||||
|
00000c80: 290d 0a20 2020 202e 756e 7772 6170 5f6f ).. .unwrap_o
|
||||||
|
00000c90: 7228 6661 6c73 6529 3b0d 0a0d 0a20 2020 r(false);....
|
||||||
|
00000ca0: 206c 6574 2073 7461 7475 7320 3d20 6966 let status = if
|
||||||
|
00000cb0: 2064 625f 6865 616c 7468 7920 7b20 2268 db_healthy { "h
|
||||||
|
00000cc0: 6561 6c74 6879 2220 7d20 656c 7365 207b ealthy" } else {
|
||||||
|
00000cd0: 2022 6465 6772 6164 6564 2220 7d3b 0d0a "degraded" };..
|
||||||
|
00000ce0: 2020 2020 6c65 7420 5f63 6f64 6520 3d20 let _code =
|
||||||
|
00000cf0: 6966 2064 625f 6865 616c 7468 7920 7b20 if db_healthy {
|
||||||
|
00000d00: 3230 3020 7d20 656c 7365 207b 2035 3033 200 } else { 503
|
||||||
|
00000d10: 207d 3b0d 0a0d 0a20 2020 2061 7875 6d3a };.... axum:
|
||||||
|
00000d20: 3a4a 736f 6e28 7365 7264 655f 6a73 6f6e :Json(serde_json
|
||||||
|
00000d30: 3a3a 6a73 6f6e 2128 7b0d 0a20 2020 2020 ::json!({..
|
||||||
|
00000d40: 2020 2022 7374 6174 7573 223a 2073 7461 "status": sta
|
||||||
|
00000d50: 7475 732c 0d0a 2020 2020 2020 2020 2264 tus,.. "d
|
||||||
|
00000d60: 6174 6162 6173 6522 3a20 6462 5f68 6561 atabase": db_hea
|
||||||
|
00000d70: 6c74 6879 2c0d 0a20 2020 2020 2020 2022 lthy,.. "
|
||||||
|
00000d80: 7469 6d65 7374 616d 7022 3a20 6368 726f timestamp": chro
|
||||||
|
00000d90: 6e6f 3a3a 5574 633a 3a6e 6f77 2829 2e74 no::Utc::now().t
|
||||||
|
00000da0: 6f5f 7266 6333 3333 3928 292c 0d0a 2020 o_rfc3339(),..
|
||||||
|
00000db0: 2020 2020 2020 2276 6572 7369 6f6e 223a "version":
|
||||||
|
00000dc0: 2065 6e76 2128 2243 4152 474f 5f50 4b47 env!("CARGO_PKG
|
||||||
|
00000dd0: 5f56 4552 5349 4f4e 2229 2c0d 0a20 2020 _VERSION"),..
|
||||||
|
00000de0: 207d 2929 0d0a 7d0d 0a0d 0a61 7379 6e63 }))..}....async
|
||||||
|
00000df0: 2066 6e20 6275 696c 645f 726f 7574 6572 fn build_router
|
||||||
|
00000e00: 2873 7461 7465 3a20 4170 7053 7461 7465 (state: AppState
|
||||||
|
00000e10: 2920 2d3e 2061 7875 6d3a 3a52 6f75 7465 ) -> axum::Route
|
||||||
|
00000e20: 7220 7b0d 0a20 2020 2075 7365 2061 7875 r {.. use axu
|
||||||
|
00000e30: 6d3a 3a6d 6964 646c 6577 6172 653b 0d0a m::middleware;..
|
||||||
|
00000e40: 2020 2020 7573 6520 746f 7765 725f 6874 use tower_ht
|
||||||
|
00000e50: 7470 3a3a 636f 7273 3a3a 7b41 6e79 2c20 tp::cors::{Any,
|
||||||
|
00000e60: 436f 7273 4c61 7965 727d 3b0d 0a20 2020 CorsLayer};..
|
||||||
|
00000e70: 2075 7365 2074 6f77 6572 5f68 7474 703a use tower_http:
|
||||||
|
00000e80: 3a74 7261 6365 3a3a 5472 6163 654c 6179 :trace::TraceLay
|
||||||
|
00000e90: 6572 3b0d 0a0d 0a20 2020 2075 7365 2061 er;.... use a
|
||||||
|
00000ea0: 7875 6d3a 3a68 7474 703a 3a48 6561 6465 xum::http::Heade
|
||||||
|
00000eb0: 7256 616c 7565 3b0d 0a20 2020 206c 6574 rValue;.. let
|
||||||
|
00000ec0: 2063 6f72 7320 3d20 7b0d 0a20 2020 2020 cors = {..
|
||||||
|
00000ed0: 2020 206c 6574 2063 6f6e 6669 6720 3d20 let config =
|
||||||
|
00000ee0: 7374 6174 652e 636f 6e66 6967 2e72 6561 state.config.rea
|
||||||
|
00000ef0: 6428 292e 6177 6169 743b 0d0a 2020 2020 d().await;..
|
||||||
|
00000f00: 2020 2020 6c65 7420 6973 5f64 6576 203d let is_dev =
|
||||||
|
00000f10: 2073 7464 3a3a 656e 763a 3a76 6172 2822 std::env::var("
|
||||||
|
00000f20: 5a43 4c41 575f 5341 4153 5f44 4556 2229 ZCLAW_SAAS_DEV")
|
||||||
|
00000f30: 0d0a 2020 2020 2020 2020 2020 2020 2e6d .. .m
|
||||||
|
00000f40: 6170 287c 767c 2076 203d 3d20 2274 7275 ap(|v| v == "tru
|
||||||
|
00000f50: 6522 207c 7c20 7620 3d3d 2022 3122 290d e" || v == "1").
|
||||||
|
00000f60: 0a20 2020 2020 2020 2020 2020 202e 756e . .un
|
||||||
|
00000f70: 7772 6170 5f6f 7228 6661 6c73 6529 3b0d wrap_or(false);.
|
||||||
|
00000f80: 0a20 2020 2020 2020 2069 6620 636f 6e66 . if conf
|
||||||
|
00000f90: 6967 2e73 6572 7665 722e 636f 7273 5f6f ig.server.cors_o
|
||||||
|
00000fa0: 7269 6769 6e73 2e69 735f 656d 7074 7928 rigins.is_empty(
|
||||||
|
00000fb0: 2920 7b0d 0a20 2020 2020 2020 2020 2020 ) {..
|
||||||
|
00000fc0: 2069 6620 6973 5f64 6576 207b 0d0a 2020 if is_dev {..
|
||||||
|
00000fd0: 2020 2020 2020 2020 2020 2020 2020 436f Co
|
||||||
|
00000fe0: 7273 4c61 7965 723a 3a6e 6577 2829 0d0a rsLayer::new()..
|
||||||
|
00000ff0: 2020 2020 2020 2020 2020 2020 2020 2020
|
||||||
|
00001000: 2020 2020 2e61 6c6c 6f77 5f6f 7269 6769 .allow_origi
|
||||||
|
00001010: 6e28 416e 7929 0d0a 2020 2020 2020 2020 n(Any)..
|
||||||
|
00001020: 2020 2020 2020 2020 2020 2020 2e61 6c6c .all
|
||||||
|
00001030: 6f77 5f6d 6574 686f 6473 2841 6e79 290d ow_methods(Any).
|
||||||
|
00001040: 0a20 2020 2020 2020 2020 2020 2020 2020 .
|
||||||
|
00001050: 2020 2020 202e 616c 6c6f 775f 6865 6164 .allow_head
|
||||||
|
00001060: 6572 7328 416e 7929 0d0a 2020 2020 2020 ers(Any)..
|
||||||
|
00001070: 2020 2020 2020 7d20 656c 7365 207b 0d0a } else {..
|
||||||
|
00001080: 2020 2020 2020 2020 2020 2020 2020 2020
|
||||||
|
00001090: 7472 6163 696e 673a 3a65 7272 6f72 2128 tracing::error!(
|
||||||
|
000010a0: 22e7 949f e4ba a7e7 8eaf e5a2 83e5 bf85 "...............
|
||||||
|
000010b0: e9a1 bbe9 858d e7bd ae20 7365 7276 6572 ......... server
|
||||||
|
000010c0: 2e63 6f72 735f 6f72 6967 696e 73ef bc8c .cors_origins...
|
||||||
|
000010d0: e4b8 8de8 83bd e4bd bfe7 94a8 2061 6c6c ............ all
|
||||||
|
000010e0: 6f77 5f6f 7269 6769 6e28 416e 7929 2229 ow_origin(Any)")
|
||||||
|
000010f0: 3b0d 0a20 2020 2020 2020 2020 2020 2020 ;..
|
||||||
|
00001100: 2020 2070 616e 6963 2128 22e7 949f e4ba panic!(".....
|
||||||
|
00001110: a7e7 8eaf e5a2 83e5 bf85 e9a1 bbe9 858d ................
|
||||||
|
00001120: e7bd ae20 7365 7276 6572 2e63 6f72 735f ... server.cors_
|
||||||
|
00001130: 6f72 6967 696e 7320 e799 bde5 908d e58d origins ........
|
||||||
|
00001140: 95e3 8082 e5bc 80e5 8f91 e78e afe5 a283 ................
|
||||||
|
00001150: e58f afe8 aebe e7bd ae20 5a43 4c41 575f ......... ZCLAW_
|
||||||
|
00001160: 5341 4153 5f44 4556 3d74 7275 6520 e7bb SAAS_DEV=true ..
|
||||||
|
00001170: 95e8 bf87 e380 8222 293b 0d0a 2020 2020 .......");..
|
||||||
|
00001180: 2020 2020 2020 2020 7d0d 0a20 2020 2020 }..
|
||||||
|
00001190: 2020 207d 2065 6c73 6520 7b0d 0a20 2020 } else {..
|
||||||
|
000011a0: 2020 2020 2020 2020 206c 6574 206f 7269 let ori
|
||||||
|
000011b0: 6769 6e73 3a20 5665 633c 4865 6164 6572 gins: Vec<Header
|
||||||
|
000011c0: 5661 6c75 653e 203d 2063 6f6e 6669 672e Value> = config.
|
||||||
|
000011d0: 7365 7276 6572 2e63 6f72 735f 6f72 6967 server.cors_orig
|
||||||
|
000011e0: 696e 732e 6974 6572 2829 0d0a 2020 2020 ins.iter()..
|
||||||
|
000011f0: 2020 2020 2020 2020 2020 2020 2e66 696c .fil
|
||||||
|
00001200: 7465 725f 6d61 7028 7c6f 3a20 2653 7472 ter_map(|o: &Str
|
||||||
|
00001210: 696e 677c 206f 2e70 6172 7365 3a3a 3c48 ing| o.parse::<H
|
||||||
|
00001220: 6561 6465 7256 616c 7565 3e28 292e 6f6b eaderValue>().ok
|
||||||
|
00001230: 2829 290d 0a20 2020 2020 2020 2020 2020 ())..
|
||||||
|
00001240: 2020 2020 202e 636f 6c6c 6563 7428 293b .collect();
|
||||||
|
00001250: 0d0a 2020 2020 2020 2020 2020 2020 436f .. Co
|
||||||
|
00001260: 7273 4c61 7965 723a 3a6e 6577 2829 0d0a rsLayer::new()..
|
||||||
|
00001270: 2020 2020 2020 2020 2020 2020 2020 2020
|
||||||
|
00001280: 2e61 6c6c 6f77 5f6f 7269 6769 6e28 6f72 .allow_origin(or
|
||||||
|
00001290: 6967 696e 7329 0d0a 2020 2020 2020 2020 igins)..
|
||||||
|
000012a0: 2020 2020 2020 2020 2e61 6c6c 6f77 5f6d .allow_m
|
||||||
|
000012b0: 6574 686f 6473 285b 0d0a 2020 2020 2020 ethods([..
|
||||||
|
000012c0: 2020 2020 2020 2020 2020 2020 2020 6178 ax
|
||||||
|
000012d0: 756d 3a3a 6874 7470 3a3a 4d65 7468 6f64 um::http::Method
|
||||||
|
000012e0: 3a3a 4745 542c 0d0a 2020 2020 2020 2020 ::GET,..
|
||||||
|
000012f0: 2020 2020 2020 2020 2020 2020 6178 756d axum
|
||||||
|
00001300: 3a3a 6874 7470 3a3a 4d65 7468 6f64 3a3a ::http::Method::
|
||||||
|
00001310: 504f 5354 2c0d 0a20 2020 2020 2020 2020 POST,..
|
||||||
|
00001320: 2020 2020 2020 2020 2020 2061 7875 6d3a axum:
|
||||||
|
00001330: 3a68 7474 703a 3a4d 6574 686f 643a 3a50 :http::Method::P
|
||||||
|
00001340: 5554 2c0d 0a20 2020 2020 2020 2020 2020 UT,..
|
||||||
|
00001350: 2020 2020 2020 2020 2061 7875 6d3a 3a68 axum::h
|
||||||
|
00001360: 7474 703a 3a4d 6574 686f 643a 3a50 4154 ttp::Method::PAT
|
||||||
|
00001370: 4348 2c0d 0a20 2020 2020 2020 2020 2020 CH,..
|
||||||
|
00001380: 2020 2020 2020 2020 2061 7875 6d3a 3a68 axum::h
|
||||||
|
00001390: 7474 703a 3a4d 6574 686f 643a 3a44 454c ttp::Method::DEL
|
||||||
|
000013a0: 4554 452c 0d0a 2020 2020 2020 2020 2020 ETE,..
|
||||||
|
000013b0: 2020 2020 2020 2020 2020 6178 756d 3a3a axum::
|
||||||
|
000013c0: 6874 7470 3a3a 4d65 7468 6f64 3a3a 4f50 http::Method::OP
|
||||||
|
000013d0: 5449 4f4e 532c 0d0a 2020 2020 2020 2020 TIONS,..
|
||||||
|
000013e0: 2020 2020 2020 2020 5d29 0d0a 2020 2020 ])..
|
||||||
|
000013f0: 2020 2020 2020 2020 2020 2020 2e61 6c6c .all
|
||||||
|
00001400: 6f77 5f68 6561 6465 7273 285b 0d0a 2020 ow_headers([..
|
||||||
|
00001410: 2020 2020 2020 2020 2020 2020 2020 2020
|
||||||
|
00001420: 2020 6178 756d 3a3a 6874 7470 3a3a 6865 axum::http::he
|
||||||
|
00001430: 6164 6572 3a3a 4155 5448 4f52 495a 4154 ader::AUTHORIZAT
|
||||||
|
00001440: 494f 4e2c 0d0a 2020 2020 2020 2020 2020 ION,..
|
||||||
|
00001450: 2020 2020 2020 2020 2020 6178 756d 3a3a axum::
|
||||||
|
00001460: 6874 7470 3a3a 6865 6164 6572 3a3a 434f http::header::CO
|
||||||
|
00001470: 4e54 454e 545f 5459 5045 2c0d 0a20 2020 NTENT_TYPE,..
|
||||||
|
00001480: 2020 2020 2020 2020 2020 2020 2020 2020
|
||||||
|
00001490: 2061 7875 6d3a 3a68 7474 703a 3a48 6561 axum::http::Hea
|
||||||
|
000014a0: 6465 724e 616d 653a 3a66 726f 6d5f 7374 derName::from_st
|
||||||
|
000014b0: 6174 6963 2822 782d 7265 7175 6573 742d atic("x-request-
|
||||||
|
000014c0: 6964 2229 2c0d 0a20 2020 2020 2020 2020 id"),..
|
||||||
|
000014d0: 2020 2020 2020 205d 290d 0a20 2020 2020 ])..
|
||||||
|
000014e0: 2020 207d 0d0a 2020 2020 7d3b 0d0a 0d0a }.. };....
|
||||||
|
000014f0: 2020 2020 6c65 7420 7075 626c 6963 5f72 let public_r
|
||||||
|
00001500: 6f75 7465 7320 3d20 7a63 6c61 775f 7361 outes = zclaw_sa
|
||||||
|
00001510: 6173 3a3a 6175 7468 3a3a 726f 7574 6573 as::auth::routes
|
||||||
|
00001520: 2829 0d0a 2020 2020 2020 2020 2e72 6f75 ().. .rou
|
||||||
|
00001530: 7465 2822 2f61 7069 2f68 6561 6c74 6822 te("/api/health"
|
||||||
|
00001540: 2c20 6178 756d 3a3a 726f 7574 696e 673a , axum::routing:
|
||||||
|
00001550: 3a67 6574 2868 6561 6c74 685f 6861 6e64 :get(health_hand
|
||||||
|
00001560: 6c65 7229 290d 0a20 2020 2020 2020 202e ler)).. .
|
||||||
|
00001570: 6c61 7965 7228 6d69 6464 6c65 7761 7265 layer(middleware
|
||||||
|
00001580: 3a3a 6672 6f6d 5f66 6e5f 7769 7468 5f73 ::from_fn_with_s
|
||||||
|
00001590: 7461 7465 280d 0a20 2020 2020 2020 2020 tate(..
|
||||||
|
000015a0: 2020 2073 7461 7465 2e63 6c6f 6e65 2829 state.clone()
|
||||||
|
000015b0: 2c0d 0a20 2020 2020 2020 2020 2020 207a ,.. z
|
||||||
|
000015c0: 636c 6177 5f73 6161 733a 3a6d 6964 646c claw_saas::middl
|
||||||
|
000015d0: 6577 6172 653a 3a70 7562 6c69 635f 7261 eware::public_ra
|
||||||
|
000015e0: 7465 5f6c 696d 6974 5f6d 6964 646c 6577 te_limit_middlew
|
||||||
|
000015f0: 6172 652c 0d0a 2020 2020 2020 2020 2929 are,.. ))
|
||||||
|
00001600: 3b0d 0a0d 0a20 2020 206c 6574 2070 726f ;.... let pro
|
||||||
|
00001610: 7465 6374 6564 5f72 6f75 7465 7320 3d20 tected_routes =
|
||||||
|
00001620: 7a63 6c61 775f 7361 6173 3a3a 6175 7468 zclaw_saas::auth
|
||||||
|
00001630: 3a3a 7072 6f74 6563 7465 645f 726f 7574 ::protected_rout
|
||||||
|
00001640: 6573 2829 0d0a 2020 2020 2020 2020 2e6d es().. .m
|
||||||
|
00001650: 6572 6765 287a 636c 6177 5f73 6161 733a erge(zclaw_saas:
|
||||||
|
00001660: 3a61 6363 6f75 6e74 3a3a 726f 7574 6573 :account::routes
|
||||||
|
00001670: 2829 290d 0a20 2020 2020 2020 202e 6d65 ()).. .me
|
||||||
|
00001680: 7267 6528 7a63 6c61 775f 7361 6173 3a3a rge(zclaw_saas::
|
||||||
|
00001690: 6d6f 6465 6c5f 636f 6e66 6967 3a3a 726f model_config::ro
|
||||||
|
000016a0: 7574 6573 2829 290d 0a20 2020 2020 2020 utes())..
|
||||||
|
000016b0: 202e 6d65 7267 6528 7a63 6c61 775f 7361 .merge(zclaw_sa
|
||||||
|
000016c0: 6173 3a3a 7265 6c61 793a 3a72 6f75 7465 as::relay::route
|
||||||
|
000016d0: 7328 2929 0d0a 2020 2020 2020 2020 2e6d s()).. .m
|
||||||
|
000016e0: 6572 6765 287a 636c 6177 5f73 6161 733a erge(zclaw_saas:
|
||||||
|
000016f0: 3a6d 6967 7261 7469 6f6e 3a3a 726f 7574 :migration::rout
|
||||||
|
00001700: 6573 2829 290d 0a20 2020 2020 2020 202e es()).. .
|
||||||
|
00001710: 6d65 7267 6528 7a63 6c61 775f 7361 6173 merge(zclaw_saas
|
||||||
|
00001720: 3a3a 726f 6c65 3a3a 726f 7574 6573 2829 ::role::routes()
|
||||||
|
00001730: 290d 0a20 2020 2020 2020 202e 6d65 7267 ).. .merg
|
||||||
|
00001740: 6528 7a63 6c61 775f 7361 6173 3a3a 7072 e(zclaw_saas::pr
|
||||||
|
00001750: 6f6d 7074 3a3a 726f 7574 6573 2829 290d ompt::routes()).
|
||||||
|
00001760: 0a20 2020 2020 2020 202e 6d65 7267 6528 . .merge(
|
||||||
|
00001770: 7a63 6c61 775f 7361 6173 3a3a 6167 656e zclaw_saas::agen
|
||||||
|
00001780: 745f 7465 6d70 6c61 7465 3a3a 726f 7574 t_template::rout
|
||||||
|
00001790: 6573 2829 290d 0a20 2020 2020 2020 202e es()).. .
|
||||||
|
000017a0: 6d65 7267 6528 7a63 6c61 775f 7361 6173 merge(zclaw_saas
|
||||||
|
000017b0: 3a3a 7465 6c65 6d65 7472 793a 3a72 6f75 ::telemetry::rou
|
||||||
|
000017c0: 7465 7328 2929 0d0a 2020 2020 2020 2020 tes())..
|
||||||
|
000017d0: 2e6c 6179 6572 286d 6964 646c 6577 6172 .layer(middlewar
|
||||||
|
000017e0: 653a 3a66 726f 6d5f 666e 5f77 6974 685f e::from_fn_with_
|
||||||
|
000017f0: 7374 6174 6528 0d0a 2020 2020 2020 2020 state(..
|
||||||
|
00001800: 2020 2020 7374 6174 652e 636c 6f6e 6528 state.clone(
|
||||||
|
00001810: 292c 0d0a 2020 2020 2020 2020 2020 2020 ),..
|
||||||
|
00001820: 7a63 6c61 775f 7361 6173 3a3a 6d69 6464 zclaw_saas::midd
|
||||||
|
00001830: 6c65 7761 7265 3a3a 6170 695f 7665 7273 leware::api_vers
|
||||||
|
00001840: 696f 6e5f 6d69 6464 6c65 7761 7265 2c0d ion_middleware,.
|
||||||
|
00001850: 0a20 2020 2020 2020 2029 290d 0a20 2020 . ))..
|
||||||
|
00001860: 2020 2020 202e 6c61 7965 7228 6d69 6464 .layer(midd
|
||||||
|
00001870: 6c65 7761 7265 3a3a 6672 6f6d 5f66 6e5f leware::from_fn_
|
||||||
|
00001880: 7769 7468 5f73 7461 7465 280d 0a20 2020 with_state(..
|
||||||
|
00001890: 2020 2020 2020 2020 2073 7461 7465 2e63 state.c
|
||||||
|
000018a0: 6c6f 6e65 2829 2c0d 0a20 2020 2020 2020 lone(),..
|
||||||
|
000018b0: 2020 2020 207a 636c 6177 5f73 6161 733a zclaw_saas:
|
||||||
|
000018c0: 3a6d 6964 646c 6577 6172 653a 3a72 6571 :middleware::req
|
||||||
|
000018d0: 7565 7374 5f69 645f 6d69 6464 6c65 7761 uest_id_middlewa
|
||||||
|
000018e0: 7265 2c0d 0a20 2020 2020 2020 2029 290d re,.. )).
|
||||||
|
000018f0: 0a20 2020 2020 2020 202e 6c61 7965 7228 . .layer(
|
||||||
|
00001900: 6d69 6464 6c65 7761 7265 3a3a 6672 6f6d middleware::from
|
||||||
|
00001910: 5f66 6e5f 7769 7468 5f73 7461 7465 280d _fn_with_state(.
|
||||||
|
00001920: 0a20 2020 2020 2020 2020 2020 2073 7461 . sta
|
||||||
|
00001930: 7465 2e63 6c6f 6e65 2829 2c0d 0a20 2020 te.clone(),..
|
||||||
|
00001940: 2020 2020 2020 2020 207a 636c 6177 5f73 zclaw_s
|
||||||
|
00001950: 6161 733a 3a6d 6964 646c 6577 6172 653a aas::middleware:
|
||||||
|
00001960: 3a72 6174 655f 6c69 6d69 745f 6d69 6464 :rate_limit_midd
|
||||||
|
00001970: 6c65 7761 7265 2c0d 0a20 2020 2020 2020 leware,..
|
||||||
|
00001980: 2029 290d 0a20 2020 2020 2020 202e 6c61 )).. .la
|
||||||
|
00001990: 7965 7228 6d69 6464 6c65 7761 7265 3a3a yer(middleware::
|
||||||
|
000019a0: 6672 6f6d 5f66 6e5f 7769 7468 5f73 7461 from_fn_with_sta
|
||||||
|
000019b0: 7465 280d 0a20 2020 2020 2020 2020 2020 te(..
|
||||||
|
000019c0: 2073 7461 7465 2e63 6c6f 6e65 2829 2c0d state.clone(),.
|
||||||
|
000019d0: 0a20 2020 2020 2020 2020 2020 207a 636c . zcl
|
||||||
|
000019e0: 6177 5f73 6161 733a 3a61 7574 683a 3a61 aw_saas::auth::a
|
||||||
|
000019f0: 7574 685f 6d69 6464 6c65 7761 7265 2c0d uth_middleware,.
|
||||||
|
00001a00: 0a20 2020 2020 2020 2029 293b 0d0a 0d0a . ));....
|
||||||
|
00001a10: 2020 2020 2f2f 20e9 9d9e e6b5 81e5 bc8f // .........
|
||||||
|
00001a20: e8b7 afe7 94b1 e5ba 94e7 94a8 e585 a8e5 ................
|
||||||
|
00001a30: b180 2031 3573 20e8 b685 e697 b6ef bc88 .. 15s .........
|
||||||
|
00001a40: 7265 6c61 7920 5353 4520 e7ab afe7 82b9 relay SSE ......
|
||||||
|
00001a50: e99c 80e8 a681 e69b b4e9 95bf e8b6 85e6 ................
|
||||||
|
00001a60: 97b6 efbc 890d 0a20 2020 206c 6574 206e ....... let n
|
||||||
|
00001a70: 6f6e 5f73 7472 6561 6d69 6e67 5f72 6f75 on_streaming_rou
|
||||||
|
00001a80: 7465 7320 3d20 6178 756d 3a3a 526f 7574 tes = axum::Rout
|
||||||
|
00001a90: 6572 3a3a 6e65 7728 290d 0a20 2020 2020 er::new()..
|
||||||
|
00001aa0: 2020 202e 6d65 7267 6528 7075 626c 6963 .merge(public
|
||||||
|
00001ab0: 5f72 6f75 7465 7329 0d0a 2020 2020 2020 _routes)..
|
||||||
|
00001ac0: 2020 2e6d 6572 6765 2870 726f 7465 6374 .merge(protect
|
||||||
|
00001ad0: 6564 5f72 6f75 7465 7329 0d0a 2020 2020 ed_routes)..
|
||||||
|
00001ae0: 2020 2020 2e6c 6179 6572 2854 696d 656f .layer(Timeo
|
||||||
|
00001af0: 7574 4c61 7965 723a 3a6e 6577 2873 7464 utLayer::new(std
|
||||||
|
00001b00: 3a3a 7469 6d65 3a3a 4475 7261 7469 6f6e ::time::Duration
|
||||||
|
00001b10: 3a3a 6672 6f6d 5f73 6563 7328 3135 2929 ::from_secs(15))
|
||||||
|
00001b20: 293b 0d0a 0d0a 2020 2020 6178 756d 3a3a );.... axum::
|
||||||
|
00001b30: 526f 7574 6572 3a3a 6e65 7728 290d 0a20 Router::new()..
|
||||||
|
00001b40: 2020 2020 2020 202e 6d65 7267 6528 6e6f .merge(no
|
||||||
|
00001b50: 6e5f 7374 7265 616d 696e 675f 726f 7574 n_streaming_rout
|
||||||
|
00001b60: 6573 290d 0a20 2020 2020 2020 202e 6d65 es).. .me
|
||||||
|
00001b70: 7267 6528 7a63 6c61 775f 7361 6173 3a3a rge(zclaw_saas::
|
||||||
|
00001b80: 7265 6c61 793a 3a72 6f75 7465 7328 2929 relay::routes())
|
||||||
|
00001b90: 0d0a 2020 2020 2020 2020 2e6c 6179 6572 .. .layer
|
||||||
|
00001ba0: 2854 7261 6365 4c61 7965 723a 3a6e 6577 (TraceLayer::new
|
||||||
|
00001bb0: 5f66 6f72 5f68 7474 7028 2929 0d0a 2020 _for_http())..
|
||||||
|
00001bc0: 2020 2020 2020 2e6c 6179 6572 2863 6f72 .layer(cor
|
||||||
|
00001bd0: 7329 0d0a 2020 2020 2020 2020 2e77 6974 s).. .wit
|
||||||
|
00001be0: 685f 7374 6174 6528 7374 6174 6529 0d0a h_state(state)..
|
||||||
|
00001bf0: 7d0d 0a0d 0a2f 2f2f 20e7 9b91 e590 ac20 }..../// ......
|
||||||
|
00001c00: 4374 726c 2b43 20e4 bfa1 e58f b7ef bc8c Ctrl+C .........
|
||||||
|
00001c10: e8a7 a6e5 8f91 2067 7261 6365 6675 6c20 ...... graceful
|
||||||
|
00001c20: 7368 7574 646f 776e 0d0a 6173 796e 6320 shutdown..async
|
||||||
|
00001c30: 666e 2073 6875 7464 6f77 6e5f 7369 676e fn shutdown_sign
|
||||||
|
00001c40: 616c 2829 207b 0d0a 2020 2020 746f 6b69 al() {.. toki
|
||||||
|
00001c50: 6f3a 3a73 6967 6e61 6c3a 3a63 7472 6c5f o::signal::ctrl_
|
||||||
|
00001c60: 6328 290d 0a20 2020 2020 2020 202e 6177 c().. .aw
|
||||||
|
00001c70: 6169 740d 0a20 2020 2020 2020 202e 6578 ait.. .ex
|
||||||
|
00001c80: 7065 6374 2822 4661 696c 6564 2074 6f20 pect("Failed to
|
||||||
|
00001c90: 696e 7374 616c 6c20 4374 726c 2b43 2068 install Ctrl+C h
|
||||||
|
00001ca0: 616e 646c 6572 2229 3b0d 0a20 2020 2069 andler");.. i
|
||||||
|
00001cb0: 6e66 6f21 2822 5265 6365 6976 6564 2073 nfo!("Received s
|
||||||
|
00001cc0: 6875 7464 6f77 6e20 7369 676e 616c 2c20 hutdown signal,
|
||||||
|
00001cd0: 6472 6169 6e69 6e67 2063 6f6e 6e65 6374 draining connect
|
||||||
|
00001ce0: 696f 6e73 2e2e 2e22 293b 0d0a 7d0d 0a ions...");..}..
|
||||||
164
BREAKS.md
Normal file
164
BREAKS.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# ZCLAW 断裂探测报告 (BREAKS.md)
|
||||||
|
|
||||||
|
> **生成时间**: 2026-04-10
|
||||||
|
> **更新时间**: 2026-04-10 (P0-01, P1-01, P1-03, P1-02, P1-04, P2-03 已修复)
|
||||||
|
> **测试范围**: Layer 1 断裂探测 — 30 个 Smoke Test
|
||||||
|
> **最终结果**: 21/30 通过 (70%), 0 个 P0 bug, 0 个 P1 bug(所有已知问题已修复)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试执行总结
|
||||||
|
|
||||||
|
| 域 | 测试数 | 通过 | 失败 | Skip | 备注 |
|
||||||
|
|----|--------|------|------|------|------|
|
||||||
|
| SaaS API (S1-S6) | 6 | 5 | 0 | 1 | S3 需 LLM API Key 已 SKIP |
|
||||||
|
| Admin V2 (A1-A6) | 6 | 5 | 1 | 0 | A6 间歇性失败 (AuthGuard 竞态) |
|
||||||
|
| Desktop Chat (D1-D6) | 6 | 3 | 1 | 2 | D1 聊天无响应; D2/D3 非 Tauri 环境 SKIP |
|
||||||
|
| Desktop Feature (F1-F6) | 6 | 6 | 0 | 0 | 全部通过 (探测模式) |
|
||||||
|
| Cross-System (X1-X6) | 6 | 2 | 4 | 0 | 4个因登录限流 429 失败 |
|
||||||
|
| **总计** | **30** | **21** | **6** | **3** | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0 断裂 (立即修复)
|
||||||
|
|
||||||
|
### ~~P0-01: 账户锁定未强制执行~~ [FIXED]
|
||||||
|
|
||||||
|
- **测试**: S2 (s2_account_lockout)
|
||||||
|
- **严重度**: P0 — 安全漏洞
|
||||||
|
- **修复**: 使用 SQL 层 `locked_until > NOW()` 比较替代 broken 的 RFC3339 文本解析 (commit b0e6654)
|
||||||
|
- **验证**: `cargo test -p zclaw-saas --test smoke_saas -- s2` PASS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P1 断裂 (当天修复)
|
||||||
|
|
||||||
|
### ~~P1-01: Refresh Token 注销后仍有效~~ [FIXED]
|
||||||
|
|
||||||
|
- **测试**: S1 (s1_auth_full_lifecycle)
|
||||||
|
- **严重度**: P1 — 安全缺陷
|
||||||
|
- **修复**: logout handler 改为接受 JSON body (optional refresh_token),撤销账户所有 refresh token (commit b0e6654)
|
||||||
|
- **验证**: `cargo test -p zclaw-saas --test smoke_saas -- s1` PASS
|
||||||
|
|
||||||
|
### ~~P1-02: Desktop 浏览器模式聊天无响应~~ [FIXED]
|
||||||
|
|
||||||
|
- **测试**: D1 (Gateway 模式聊天)
|
||||||
|
- **严重度**: P1 — 外部浏览器无法使用聊天
|
||||||
|
- **根因**: Playwright Chromium 非 Tauri 环境,应用走 SaaS relay 路径但测试未预先登录
|
||||||
|
- **修复**: 添加 Playwright fixture 自动检测非 Tauri 模式并注入 SaaS session (commit 34ef41c)
|
||||||
|
- **验证**: `npx playwright test smoke_chat` D1 应正常响应
|
||||||
|
|
||||||
|
### ~~P1-03: Provider 创建 API 必需 display_name~~ [FIXED]
|
||||||
|
|
||||||
|
- **测试**: A2 (Provider CRUD)
|
||||||
|
- **严重度**: P1 — API 兼容性
|
||||||
|
- **修复**: `display_name` 改为 `Option<String>`,缺失时 fallback 到 `name` (commit b0e6654)
|
||||||
|
- **验证**: `cargo test -p zclaw-saas --test smoke_saas -- s3` PASS
|
||||||
|
|
||||||
|
### ~~P1-04: Admin V2 AuthGuard 竞态条件~~ [FIXED]
|
||||||
|
|
||||||
|
- **测试**: A6 (间歇性失败)
|
||||||
|
- **严重度**: P1 — 测试稳定性
|
||||||
|
- **根因**: `loadFromStorage()` 无条件信任 localStorage 设 `isAuthenticated=true`,但 HttpOnly cookie 可能已过期,子组件先渲染后发 401 请求
|
||||||
|
- **修复**: authStore 初始 `isAuthenticated=false`;AuthGuard 三态守卫 (checking/authenticated/unauthenticated),始终先验证 cookie (commit 80b7ee8)
|
||||||
|
- **验证**: `npx playwright test smoke_admin` A6 连续通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P2 发现 (本周修复)
|
||||||
|
|
||||||
|
### P2-01: /me 端点不返回 pwv 字段
|
||||||
|
- JWT claims 含 `pwv`(password_version),但 `GET /me` 不返回 → 前端无法客户端检测密码变更
|
||||||
|
|
||||||
|
### P2-02: 知识搜索即时性不足
|
||||||
|
- 创建知识条目后立即搜索可能找不到(embedding 异步生成中)
|
||||||
|
|
||||||
|
### ~~P2-03: 测试登录限流冲突~~ [FIXED]
|
||||||
|
- **根因**: 6 个 Cross 测试各调一次 `saasLogin()` → 6 次 login/分钟 → 触发 5次/分钟/IP 限流
|
||||||
|
- **修复**: 测试共享 token,6 个测试只 login 一次 (commit bd48de6)
|
||||||
|
- **验证**: `npx playwright test smoke_cross` 不再因 429 失败
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已修复 (本次探测中修复)
|
||||||
|
|
||||||
|
| 修复 | 描述 |
|
||||||
|
|------|------|
|
||||||
|
| P0-02 Desktop CSS | `@import "@tailwindcss/typography"` → `@plugin "@tailwindcss/typography"` (Tailwind v4 语法) |
|
||||||
|
| Admin 凭据 | `testadmin/Admin123456` → `admin/admin123` (来自 .env) |
|
||||||
|
| Dashboard 端点 | `/dashboard/stats` → `/stats/dashboard` |
|
||||||
|
| Provider display_name | 添加缺失的 `display_name` 字段 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已通过测试 (21/30)
|
||||||
|
|
||||||
|
| ID | 测试名称 | 验证内容 |
|
||||||
|
|----|----------|----------|
|
||||||
|
| S1 | 认证闭环 | register→login→/me→refresh→logout |
|
||||||
|
| S2 | 账户锁定 | 5次失败→locked_until设置→DB验证 |
|
||||||
|
| S4 | 权限矩阵 | super_admin 200 + user 403 + 未认证 401 |
|
||||||
|
| S5 | 计费闭环 | dashboard stats + billing usage + plans |
|
||||||
|
| S6 | 知识检索 | category→item→search→DB验证 |
|
||||||
|
| A1 | 登录→Dashboard | 表单登录→统计卡片渲染 |
|
||||||
|
| A2 | Provider CRUD | API 创建+页面可见 |
|
||||||
|
| A3 | Account 管理 | 表格加载、角色列可见 |
|
||||||
|
| A4 | 知识管理 | 分类→条目→页面加载 |
|
||||||
|
| A5 | 角色权限 | 页面加载+API验证 |
|
||||||
|
| D4 | 流取消 | 取消按钮点击+状态验证 |
|
||||||
|
| D5 | 离线队列 | 断网→发消息→恢复→重连 |
|
||||||
|
| D6 | 错误恢复 | 无效模型→错误检测→恢复 |
|
||||||
|
| F1 | Agent 生命周期 | Store 检查+UI 探测 |
|
||||||
|
| F2 | Hands 触发 | 面板加载+Store 检查 |
|
||||||
|
| F3 | Pipeline 执行 | 模板列表加载 |
|
||||||
|
| F4 | 记忆闭环 | Store 检查+面板探测 |
|
||||||
|
| F5 | 管家路由 | ButlerRouter 分类检查 |
|
||||||
|
| F6 | 技能发现 | Store/Tauri 检查 |
|
||||||
|
| X5 | TOTP 流程 | setup 端点调用 |
|
||||||
|
| X6 | 计费查询 | usage + plans 结构验证 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修复优先级路线图
|
||||||
|
|
||||||
|
所有 P0/P1/P2 已知问题已修复。剩余 P2 待观察:
|
||||||
|
|
||||||
|
```
|
||||||
|
P2-01 /me 端点不返回 pwv 字段
|
||||||
|
└── 影响: 前端无法客户端检测密码变更(非阻断)
|
||||||
|
└── 优先级: 低
|
||||||
|
|
||||||
|
P2-02 知识搜索即时性不足
|
||||||
|
└── 影响: 创建知识条目后立即搜索可能找不到(embedding 异步)
|
||||||
|
└── 优先级: 低
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试基础设施状态
|
||||||
|
|
||||||
|
| 项目 | 状态 | 备注 |
|
||||||
|
|------|------|------|
|
||||||
|
| SaaS 集成测试框架 | ✅ 可用 | `crates/zclaw-saas/tests/common/mod.rs` |
|
||||||
|
| Admin V2 Playwright | ✅ 可用 | Chromium 147 + 正确凭据 |
|
||||||
|
| Desktop Playwright | ✅ 可用 | CSS 已修复 |
|
||||||
|
| PostgreSQL 测试 DB | ✅ 运行中 | localhost:5432/zclaw |
|
||||||
|
| SaaS Server | ✅ 运行中 | localhost:8080 |
|
||||||
|
| Admin V2 dev server | ✅ 运行中 | localhost:5173 |
|
||||||
|
| Desktop (Tauri dev) | ✅ 可用 | localhost:1420 |
|
||||||
|
|
||||||
|
## 验证命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SaaS (需 PostgreSQL)
|
||||||
|
cargo test -p zclaw-saas --test smoke_saas -- --test-threads=1
|
||||||
|
|
||||||
|
# Admin V2
|
||||||
|
cd admin-v2 && npx playwright test smoke_admin
|
||||||
|
|
||||||
|
# Desktop
|
||||||
|
cd desktop && npx playwright test smoke_chat smoke_features --config tests/e2e/playwright.config.ts
|
||||||
|
|
||||||
|
# Cross (需先等 1 分钟让限流重置)
|
||||||
|
cd desktop && npx playwright test smoke_cross --config tests/e2e/playwright.config.ts
|
||||||
|
```
|
||||||
326
CLAUDE.md
326
CLAUDE.md
@@ -1,52 +1,78 @@
|
|||||||
|
@wiki/index.md
|
||||||
|
|
||||||
# ZCLAW 协作与实现规则
|
# ZCLAW 协作与实现规则
|
||||||
|
|
||||||
> **ZCLAW 是一个独立成熟的 AI Agent 桌面客户端**,专注于提供真实可用的 AI 能力,而不是演示 UI。
|
> **ZCLAW 是一个独立成熟的 AI Agent 桌面客户端**,专注于提供真实可用的 AI 能力,而不是演示 UI。
|
||||||
|
|
||||||
|
> **当前阶段: 发布前管家模式实施。** 稳定化基线已达成,管家模式6交付物已完成。
|
||||||
|
|
||||||
## 1. 项目定位
|
## 1. 项目定位
|
||||||
|
|
||||||
### 1.1 ZCLAW 是什么
|
### 1.1 ZCLAW 是什么
|
||||||
|
|
||||||
ZCLAW 是面向中文用户的 AI Agent 桌面端,核心能力包括:
|
ZCLAW 是面向中文用户的 AI Agent 桌面端,核心能力包括:
|
||||||
|
|
||||||
- **智能对话** - 多模型支持、流式响应、上下文管理
|
- **智能对话** - 多模型支持(8 Provider)、流式响应、上下文管理
|
||||||
- **自主能力** - 8 个 Hands(浏览器、数据采集、研究、预测等)
|
- **自主能力** - 9 个启用的 Hands(另有 Predictor/Lead 已禁用)
|
||||||
- **技能系统** - 可扩展的 SKILL.md 技能定义
|
- **技能系统** - 75 个 SKILL.md 技能定义
|
||||||
- **工作流编排** - 多步骤自动化任务
|
- **工作流编排** - Pipeline DSL + 10 行业模板
|
||||||
- **安全审计** - 完整的操作日志和权限控制
|
- **安全审计** - 完整的操作日志和权限控制
|
||||||
|
|
||||||
### 1.2 决策原则
|
### 1.2 决策原则
|
||||||
|
|
||||||
**任何改动都要问:这对 ZCLAW 有用吗?对 ZCLAW 有影响吗?**
|
**任何改动都要问:这对 ZCLAW 用户今天能产生价值吗?**
|
||||||
|
|
||||||
- ✅ 对 ZCLAW 用户有价值的功能 → 优先实现
|
- ✅ 修复已知的 P0/P1 缺陷 → 最高优先
|
||||||
- ✅ 提升 ZCLAW 稳定性和可用性 → 必须做
|
- ✅ 接通"写了没接"的断链 → 高优先
|
||||||
- ❌ 只为兼容其他系统的妥协 → 谨慎评估
|
- ✅ 清理死代码和孤立文件 → 应该做
|
||||||
- ❌ 增加复杂度但无实际价值 → 不做
|
- ❌ 新增功能/页面/端点 → 稳定化完成前禁止
|
||||||
- ✅解决问题要寻找根因,从源头解决问题。不要为了消除问题而选择折中办法,从而导致系统架构、代码安全性、代码质量出现问题
|
- ❌ 增加复杂度但无实际价值 → 永远不做
|
||||||
|
- ❌ 折中方案掩盖根因 → 永远不做
|
||||||
|
|
||||||
|
### 1.3 稳定化铁律
|
||||||
|
|
||||||
|
**稳定化基线达成后仍需遵守以下约束:**
|
||||||
|
|
||||||
|
| 禁止行为 | 原因 |
|
||||||
|
|----------|------|
|
||||||
|
| 新增 SaaS API 端点 | 已有 140 个(含 2 个 dev-only),前端未全部接通 |
|
||||||
|
| 新增 SKILL.md 文件 | 已有 75 个,大部分未执行验证 |
|
||||||
|
| 新增 Tauri 命令 | 已有 189 个,70 个无前端调用且无 @reserved |
|
||||||
|
| 新增中间件/Store | 已有 13 层中间件 + 18 个 Store |
|
||||||
|
| 新增 admin 页面 | 已有 15 页 |
|
||||||
|
|
||||||
|
### 1.4 系统真实状态
|
||||||
|
|
||||||
|
参见 [docs/TRUTH.md](docs/TRUTH.md) — 这是唯一的真相源,所有其他文档中的数字如果与此冲突,以 TRUTH.md 为准。
|
||||||
***
|
***
|
||||||
|
|
||||||
## 2. 项目结构
|
## 2. 项目结构
|
||||||
|
|
||||||
```text
|
```text
|
||||||
ZCLAW/
|
ZCLAW/
|
||||||
├── crates/ # Rust Workspace (核心能力)
|
├── crates/ # Rust Workspace (10 crates)
|
||||||
│ ├── zclaw-types/ # L1: 基础类型 (AgentId, Message, Error)
|
│ ├── zclaw-types/ # L1: 基础类型 (AgentId, Message, Error)
|
||||||
│ ├── zclaw-memory/ # L2: 存储层 (SQLite, KV, 会话管理)
|
│ ├── zclaw-memory/ # L2: 存储层 (SQLite, KV, 会话管理)
|
||||||
│ ├── zclaw-runtime/ # L3: 运行时 (LLM驱动, 工具, Agent循环)
|
│ ├── zclaw-runtime/ # L3: 运行时 (4 Driver, 7 工具, 12 层中间件)
|
||||||
│ ├── zclaw-kernel/ # L4: 核心协调 (注册, 调度, 事件, 工作流)
|
│ ├── zclaw-kernel/ # L4: 核心协调 (182 Tauri 命令)
|
||||||
│ ├── zclaw-skills/ # 技能系统 (SKILL.md解析, 执行器)
|
│ ├── zclaw-skills/ # 技能系统 (75 SKILL.md 解析, 语义路由)
|
||||||
│ ├── zclaw-hands/ # 自主能力 (Hand/Trigger 注册管理)
|
│ ├── zclaw-hands/ # 自主能力 (9 启用, 106 Rust 测试)
|
||||||
│ ├── zclaw-channels/ # 通道适配器 (仅 ConsoleChannel 测试适配器)
|
│ ├── zclaw-protocols/ # 协议支持 (MCP 完整, A2A feature-gated)
|
||||||
│ └── zclaw-protocols/ # 协议支持 (MCP, A2A)
|
│ ├── zclaw-pipeline/ # Pipeline DSL (v1/v2, 10 行业模板)
|
||||||
|
│ ├── zclaw-growth/ # 记忆增长 (FTS5 + TF-IDF)
|
||||||
|
│ └── zclaw-saas/ # SaaS 后端 (130 API, Axum + PostgreSQL)
|
||||||
|
├── admin-v2/ # 管理后台 (Vite + Ant Design Pro, 13 页)
|
||||||
├── desktop/ # Tauri 桌面应用
|
├── desktop/ # Tauri 桌面应用
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── components/ # React UI 组件
|
│ │ ├── components/ # React UI 组件 (含 SaaS 集成)
|
||||||
│ │ ├── store/ # Zustand 状态管理
|
│ │ ├── store/ # Zustand 状态管理 (含 saasStore)
|
||||||
│ │ └── lib/ # 客户端通信 / 工具函数
|
│ │ └── lib/ # 客户端通信 / 工具函数 (含 saas-client)
|
||||||
│ └── src-tauri/ # Tauri Rust 后端 (集成 Kernel)
|
│ └── src-tauri/ # Tauri Rust 后端 (集成 Kernel)
|
||||||
├── skills/ # SKILL.md 技能定义
|
├── skills/ # SKILL.md 技能定义
|
||||||
├── hands/ # HAND.toml 自主能力配置
|
├── hands/ # HAND.toml 自主能力配置
|
||||||
├── config/ # TOML 配置文件
|
├── config/ # TOML 配置文件
|
||||||
|
├── saas-config.toml # SaaS 后端配置 (PostgreSQL 连接等)
|
||||||
|
├── docker-compose.yml # PostgreSQL 容器配置
|
||||||
├── docs/ # 架构文档和知识库
|
├── docs/ # 架构文档和知识库
|
||||||
└── tests/ # Vitest 回归测试
|
└── tests/ # Vitest 回归测试
|
||||||
```
|
```
|
||||||
@@ -61,12 +87,14 @@ ZCLAW/
|
|||||||
|
|
||||||
| 层级 | 技术 |
|
| 层级 | 技术 |
|
||||||
| ---- | --------------------- |
|
| ---- | --------------------- |
|
||||||
| 前端框架 | React 18 + TypeScript |
|
| 前端框架 | React 19 + TypeScript |
|
||||||
| 状态管理 | Zustand |
|
| 状态管理 | Zustand 5 |
|
||||||
| 桌面框架 | Tauri 2.x |
|
| 桌面框架 | Tauri 2.x |
|
||||||
| 样式方案 | Tailwind CSS |
|
| 样式方案 | Tailwind 4 |
|
||||||
| 配置格式 | TOML |
|
| 配置格式 | TOML |
|
||||||
| 后端核心 | Rust Workspace (8 crates) |
|
| 后端核心 | Rust Workspace (10 crates, ~66K 行) |
|
||||||
|
| SaaS 后端 | Axum + PostgreSQL (zclaw-saas) |
|
||||||
|
| 管理后台 | Vite + Ant Design Pro (admin-v2/) |
|
||||||
|
|
||||||
### 2.3 Crate 依赖关系
|
### 2.3 Crate 依赖关系
|
||||||
|
|
||||||
@@ -79,7 +107,9 @@ zclaw-runtime (→ types, memory)
|
|||||||
↑
|
↑
|
||||||
zclaw-kernel (→ types, memory, runtime)
|
zclaw-kernel (→ types, memory, runtime)
|
||||||
↑
|
↑
|
||||||
desktop/src-tauri (→ kernel, skills, hands, channels, protocols)
|
zclaw-saas (→ types, 独立运行于 8080 端口)
|
||||||
|
↑
|
||||||
|
desktop/src-tauri (→ kernel, skills, hands, protocols)
|
||||||
```
|
```
|
||||||
|
|
||||||
***
|
***
|
||||||
@@ -104,11 +134,17 @@ desktop/src-tauri (→ kernel, skills, hands, channels, protocols)
|
|||||||
|
|
||||||
不在根因未明时盲目堆补丁。
|
不在根因未明时盲目堆补丁。
|
||||||
|
|
||||||
### 3.3 闭环工作法
|
### 3.3 闭环工作法(强制)
|
||||||
|
|
||||||
每次改动形成完整闭环:
|
每次改动**必须**按顺序完成以下步骤,不允许跳过:
|
||||||
|
|
||||||
1. 定位问题 → 2. 建立心智模型 → 3. 最小修复 → 4. 自动验证 → 5. 记录沉淀
|
1. **定位问题** — 理解根因,不盲目堆补丁
|
||||||
|
2. **最小修复** — 只改必要的代码
|
||||||
|
3. **自动验证** — `tsc --noEmit` / `cargo check` / `vitest run` 必须通过
|
||||||
|
4. **提交推送** — 按 §11 规范提交,**立即 `git push`**,不积压
|
||||||
|
5. **文档同步** — 按 §8.3 检查并更新相关文档,提交并推送
|
||||||
|
|
||||||
|
**铁律:步骤 4 和 5 是任务完成的硬性条件。不允许"等一下再提交"或"最后一起推送"。**
|
||||||
|
|
||||||
***
|
***
|
||||||
|
|
||||||
@@ -123,18 +159,28 @@ desktop/src-tauri (→ kernel, skills, hands, channels, protocols)
|
|||||||
|
|
||||||
**禁止**在组件内直接创建 WebSocket 或拼装 HTTP 请求。
|
**禁止**在组件内直接创建 WebSocket 或拼装 HTTP 请求。
|
||||||
|
|
||||||
### 4.2 発能层客户端
|
### 4.2 分层职责
|
||||||
|
|
||||||
````
|
```
|
||||||
UI 组件 → 只负责展示和交互
|
UI 组件 → 只负责展示和交互
|
||||||
Store → 负责状态组织和流程编排
|
Store → 负责状态组织和流程编排
|
||||||
Client → 负责网络通信和```
|
Client → 负责网络通信和协议转换
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 代码自检规则
|
||||||
|
|
||||||
|
**每次修改代码前必须检查:**
|
||||||
|
|
||||||
|
1. **是否已有相同能力的代码?** — 先搜索再写,避免重复
|
||||||
|
2. **前端是否有人调用?** — 没有 Rust 调用者的 Tauri 命令,先标注 `@reserved`
|
||||||
|
3. **错误是否静默吞掉?** — `let _ =` 必须替换为 `log::warn!` 或更高级别处理
|
||||||
|
4. **文档数字是否需要更新?** — 改了数量就要改文档```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 4.3 代码规范
|
### 4.4 代码规范
|
||||||
|
|
||||||
**TypeScript:**
|
**TypeScript:**
|
||||||
- 避免 `any`,优先 `unknown + 类型守卫`
|
- 避免 `any`,优先 `unknown + 类型守卫`
|
||||||
@@ -181,7 +227,7 @@ Client → 负责网络通信和```
|
|||||||
|
|
||||||
## 6. 自主能力系统 (Hands)
|
## 6. 自主能力系统 (Hands)
|
||||||
|
|
||||||
ZCLAW 提供 11 个自主能力包:
|
ZCLAW 提供 11 个自主能力包(9 启用 + 2 禁用):
|
||||||
|
|
||||||
| Hand | 功能 | 状态 |
|
| Hand | 功能 | 状态 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
@@ -191,10 +237,10 @@ ZCLAW 提供 11 个自主能力包:
|
|||||||
| Predictor | 预测分析 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
| Predictor | 预测分析 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||||
| Lead | 销售线索发现 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
| Lead | 销售线索发现 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||||
| Clip | 视频处理 | ⚠️ 需 FFmpeg |
|
| Clip | 视频处理 | ⚠️ 需 FFmpeg |
|
||||||
| Twitter | Twitter 自动化 | ⚠️ 需 API Key |
|
| Twitter | Twitter 自动化 | ✅ 可用(12 个 API v2 真实调用,写操作需 OAuth 1.0a) |
|
||||||
| Whiteboard | 白板演示 | ✅ 可用(导出功能开发中,标注 demo) |
|
| Whiteboard | 白板演示 | ✅ 可用(导出功能开发中,标注 demo) |
|
||||||
| Slideshow | 幻灯片生成 | ✅ 可用 |
|
| Slideshow | 幻灯片生成 | ✅ 可用 |
|
||||||
| Speech | 语音合成 | ✅ 可用 |
|
| Speech | 语音合成 | ✅ 可用(Browser TTS 前端集成完成) |
|
||||||
| Quiz | 测验生成 | ✅ 可用 |
|
| Quiz | 测验生成 | ✅ 可用 |
|
||||||
|
|
||||||
**触发 Hand 时:**
|
**触发 Hand 时:**
|
||||||
@@ -216,20 +262,57 @@ ZCLAW 提供 11 个自主能力包:
|
|||||||
- 配置读写
|
- 配置读写
|
||||||
- Hand 触发
|
- Hand 触发
|
||||||
|
|
||||||
### 7.2 验证命令
|
### 7.2 前端调试优先使用 WebMCP
|
||||||
|
|
||||||
|
ZCLAW 注册了 WebMCP 结构化调试工具(`desktop/src/lib/webmcp-tools.ts`),AI 代理可直接查询应用状态而无需 DOM 截图。
|
||||||
|
|
||||||
|
**原则:能用 WebMCP 工具完成的调试,优先使用 WebMCP 而非 DevTools MCP(`take_snapshot`/`evaluate_script`),以减少约 67% 的 token 消耗。**
|
||||||
|
|
||||||
|
已注册的 WebMCP 工具:
|
||||||
|
|
||||||
|
| 工具名 | 用途 |
|
||||||
|
|--------|------|
|
||||||
|
| `get_zclaw_state` | 综合状态概览(连接、登录、流式、模型) |
|
||||||
|
| `check_connection` | 连接状态检查 |
|
||||||
|
| `send_message` | 发送聊天消息 |
|
||||||
|
| `cancel_stream` | 取消当前流式响应 |
|
||||||
|
| `get_streaming_state` | 流式响应详细状态 |
|
||||||
|
| `list_conversations` | 列出最近对话 |
|
||||||
|
| `get_current_conversation` | 获取当前对话完整消息 |
|
||||||
|
| `switch_conversation` | 切换到指定对话 |
|
||||||
|
| `get_token_usage` | Token 用量统计 |
|
||||||
|
| `get_offline_queue` | 离线消息队列 |
|
||||||
|
| `get_saas_account` | SaaS 账户和订阅信息 |
|
||||||
|
| `get_available_models` | 可用 LLM 模型列表 |
|
||||||
|
| `get_current_agent` | 当前 Agent 详情 |
|
||||||
|
| `list_agents` | 所有 Agent 列表 |
|
||||||
|
| `get_console_errors` | 应用日志中的错误 |
|
||||||
|
|
||||||
|
**使用前提**:Chrome 146+ 并启用 `chrome://flags/#enable-webmcp-testing`。仅在开发模式注册。
|
||||||
|
|
||||||
|
**何时仍需 DevTools MCP**:UI 布局/样式问题、点击交互、截图对比、网络请求检查。
|
||||||
|
|
||||||
|
### 7.3 验证命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# TypeScript 类型检查
|
# TypeScript 类型检查
|
||||||
pnpm tsc --noEmit
|
pnpm tsc --noEmit
|
||||||
|
|
||||||
# 单元测试
|
# 前端单元测试
|
||||||
pnpm vitest run
|
cd desktop && pnpm vitest run
|
||||||
|
|
||||||
|
# Rust 全量测试(排除 SaaS)
|
||||||
|
cargo test --workspace --exclude zclaw-saas
|
||||||
|
|
||||||
|
# SaaS 集成测试(需要 PostgreSQL)
|
||||||
|
export TEST_DATABASE_URL="postgresql://postgres:123123@localhost:5432/zclaw"
|
||||||
|
cargo test -p zclaw-saas -- --test-threads=1
|
||||||
|
|
||||||
# 启动开发环境
|
# 启动开发环境
|
||||||
pnpm start:dev
|
pnpm start:dev
|
||||||
````
|
````
|
||||||
|
|
||||||
### 7.3 人工验证清单
|
### 7.4 人工验证清单
|
||||||
|
|
||||||
- [ ] 能否正常连接后端服务
|
- [ ] 能否正常连接后端服务
|
||||||
- [ ] 能否发送消息并获得流式响应
|
- [ ] 能否发送消息并获得流式响应
|
||||||
@@ -260,6 +343,44 @@ docs/
|
|||||||
- **面向未来** - 文档要帮助未来的开发者快速理解
|
- **面向未来** - 文档要帮助未来的开发者快速理解
|
||||||
- **中文优先** - 所有面向用户的文档使用中文
|
- **中文优先** - 所有面向用户的文档使用中文
|
||||||
|
|
||||||
|
### 8.3 完成工作后的收尾流程(强制,不可跳过)
|
||||||
|
|
||||||
|
每次完成功能实现、架构变更、问题修复后,**必须立即执行以下收尾**:
|
||||||
|
|
||||||
|
#### 步骤 A:文档同步(代码提交前)
|
||||||
|
|
||||||
|
检查以下文档是否需要更新,有变更则立即修改:
|
||||||
|
|
||||||
|
1. **CLAUDE.md** — 项目结构、技术栈、工作流程、命令变化时
|
||||||
|
2. **CLAUDE.md §13 架构快照** — 涉及子系统变更时,更新 `<!-- ARCH-SNAPSHOT-START/END -->` 标记区域(可执行 `/sync-arch` 技能自动分析)
|
||||||
|
3. **docs/ARCHITECTURE_BRIEF.md** — 架构决策或关键组件变更时
|
||||||
|
4. **docs/features/** — 功能状态变化时
|
||||||
|
5. **docs/knowledge-base/** — 新的排查经验或配置说明
|
||||||
|
6. **wiki/** — 编译后知识库维护(按触发规则更新对应页面):
|
||||||
|
- 修复 bug → 更新 `wiki/known-issues.md`
|
||||||
|
- 架构变更 → 更新 `wiki/architecture.md` + `wiki/data-flows.md`
|
||||||
|
- 文件结构变化 → 更新 `wiki/file-map.md`
|
||||||
|
- 模块状态变化 → 更新 `wiki/module-status.md`
|
||||||
|
- 每次更新 → 在 `wiki/log.md` 追加一条记录
|
||||||
|
6. **docs/TRUTH.md** — 数字(命令数、Store 数、crates 数等)变化时
|
||||||
|
|
||||||
|
#### 步骤 B:提交(按逻辑分组)
|
||||||
|
|
||||||
|
```
|
||||||
|
代码变更 → 一个或多个逻辑提交
|
||||||
|
文档变更 → 独立提交(如果和代码分开更清晰)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 步骤 C:推送(立即)
|
||||||
|
|
||||||
|
```
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
**不允许积压。** 每次完成一个独立工作单元后立即推送。不要留到"最后一起推"。
|
||||||
|
|
||||||
|
**判断标准:** 如果工作目录有未提交文件,说明收尾流程没完成。
|
||||||
|
|
||||||
***
|
***
|
||||||
|
|
||||||
## 9. 常见问题排查
|
## 9. 常见问题排查
|
||||||
@@ -336,10 +457,137 @@ refactor(store): 统一 Store 数据获取方式
|
|||||||
|
|
||||||
***
|
***
|
||||||
|
|
||||||
|
|
||||||
## 12. 安全注意事项
|
## 12. 安全注意事项
|
||||||
|
|
||||||
- 不在代码中硬编码密钥
|
- 不在代码中硬编码密钥
|
||||||
- 用户输入必须验证
|
- 用户输入必须验证
|
||||||
- 敏感操作需要确认
|
- 敏感操作需要确认
|
||||||
- 保留操作审计日志
|
- 保留操作审计日志
|
||||||
|
- 环境变量 `ZCLAW_SAAS_DEV` 模式放宽安全限制(开发环境设 `ZCLAW_SAAS_DEV=true`)
|
||||||
|
|
||||||
|
### 认证安全
|
||||||
|
|
||||||
|
- **JWT password_version**: 密码修改后自动使所有已签发的 JWT 失效(Claims 含 `pwv`,中间件比对 DB)
|
||||||
|
- **账户锁定**: 5 次登录失败后锁定 15 分钟
|
||||||
|
- **邮箱验证**: RFC 5322 正则 + 254 字符长度限制
|
||||||
|
- **JWT 密钥**: `#[cfg(debug_assertions)]` 保护 fallback,release 模式 `bail` 拒绝启动
|
||||||
|
- **TOTP 加密密钥**: 生产环境强制独立 `ZCLAW_TOTP_ENCRYPTION_KEY`(64 字符 hex),不从 JWT 密钥派生
|
||||||
|
- **TOTP/API Key 加密**: AES-256-GCM + 随机 Nonce
|
||||||
|
- **密码存储**: Argon2id + OsRng 随机盐
|
||||||
|
- **Refresh Token 轮换**: 单次使用,Logout 时撤销到 DB,rotation 校验已撤销的旧 token
|
||||||
|
|
||||||
|
### 网络安全
|
||||||
|
|
||||||
|
- **Cookie**: HttpOnly + Secure + SameSite=Strict + 路径作用域
|
||||||
|
- **Cookie Secure**: 开发环境 false,生产 true
|
||||||
|
- **CORS**: 生产强制白名单,缺失拒绝启动
|
||||||
|
- **TLS**: 反向代理(nginx/caddy)提供 HTTPS 终止,Axum 不负责 TLS
|
||||||
|
- **Docker**: SaaS 端口绑定 `127.0.0.1`,仅通过 nginx 反代访问
|
||||||
|
- **XFF**: 仅信任配置的代理 IP
|
||||||
|
|
||||||
|
### 限流
|
||||||
|
|
||||||
|
- `/api/auth/login` — 5次/分钟/IP(防暴力破解)+ 持久化到 PostgreSQL
|
||||||
|
- `/api/auth/register` — 3次/小时/IP(防刷注册)
|
||||||
|
- 公共端点默认 20次/分钟/IP(防滥用)
|
||||||
|
|
||||||
|
### 前端安全
|
||||||
|
|
||||||
|
- **Admin Token**: HttpOnly Cookie 传递,JS 不存储/读取 token
|
||||||
|
- **Tauri CSP**: 移除 `unsafe-inline` script,`connect-src` 限制为 `http://localhost:*` + `https://*`
|
||||||
|
- **Pipeline 日志**: Debug 日志截断 + 仅记录 keys 不记录 values
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
| 变量 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `DB_PASSWORD` | 数据库密码 |
|
||||||
|
| `ZCLAW_DATABASE_URL` | 完整数据库连接 URL(优先级最高) |
|
||||||
|
| `ZCLAW_SAAS_JWT_SECRET` | JWT 签名密钥 (>= 32 字符) |
|
||||||
|
| `ZCLAW_TOTP_ENCRYPTION_KEY` | TOTP/API Key 加密密钥 (64 hex) |
|
||||||
|
| `ZCLAW_ADMIN_USERNAME` | 初始管理员用户名 |
|
||||||
|
| `ZCLAW_ADMIN_PASSWORD` | 初始管理员密码 |
|
||||||
|
| `ZCLAW_SAAS_DEV` | 开发模式标志 (true=开发, false=生产) |
|
||||||
|
|
||||||
|
`saas-config.toml` 支持 `${ENV_VAR}` 模式环境变量插值。
|
||||||
|
|
||||||
|
### 生产环境清单
|
||||||
|
|
||||||
|
- [ ] nginx/caddy 配置反向代理 + HTTPS
|
||||||
|
- [ ] 确保设置 `ZCLAW_SAAS_DEV=false`(或不设置)
|
||||||
|
- [ ] 启用 CORS 白名单(`cors_origins` 配置实际域名)
|
||||||
|
- [ ] Cookie Secure=true + HttpOnly=true + SameSite=Strict
|
||||||
|
- [ ] JWT 签名密钥 >= 32 字符随机字符串
|
||||||
|
- [ ] `ZCLAW_TOTP_ENCRYPTION_KEY` 独立设置
|
||||||
|
- [ ] 数据库密码通过 `${DB_PASSWORD}` 引用
|
||||||
|
|
||||||
|
### 完整审计报告
|
||||||
|
|
||||||
|
参见 `docs/features/SECURITY_PENETRATION_TEST_V1.md`
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
<!-- ARCH-SNAPSHOT-START -->
|
||||||
|
<!-- 此区域由 auto-sync 自动更新,请勿手动编辑。更新时间: 2026-04-15 -->
|
||||||
|
|
||||||
|
## 13. 当前架构快照
|
||||||
|
|
||||||
|
### 活跃子系统
|
||||||
|
|
||||||
|
| 子系统 | 状态 | 最新变更 |
|
||||||
|
|--------|------|----------|
|
||||||
|
| 管家模式 (Butler) | ✅ 活跃 | 04-12 行业配置4行业 + 跨会话连续性 + <butler-context> XML fencing |
|
||||||
|
| Hermes 管线 | ✅ 活跃 | 04-12 触发信号持久化 + 经验行业维度 + 注入格式优化 |
|
||||||
|
| Intelligence Heartbeat | ✅ 活跃 | 04-15 统一健康快照 (health_snapshot.rs) + HeartbeatManager 重构 + HealthPanel 前端 |
|
||||||
|
| 聊天流 (ChatStream) | ✅ 稳定 | 04-02 ChatStore 拆分为 4 Store (stream/conversation/message/chat) |
|
||||||
|
| 记忆管道 (Memory) | ✅ 稳定 | 04-02 闭环修复: 对话→提取→FTS5+TF-IDF→检索→注入 |
|
||||||
|
| SaaS 认证 (Auth) | ✅ 稳定 | Token池 RPM/TPM 轮换 + JWT password_version 失效机制 |
|
||||||
|
| Pipeline DSL | ✅ 稳定 | 04-01 17 个 YAML 模板 + DAG 执行器 |
|
||||||
|
| Hands 系统 | ✅ 稳定 | 9 启用 (Browser/Collector/Researcher/Twitter/Whiteboard/Slideshow/Speech/Quiz/Clip) |
|
||||||
|
| 技能系统 (Skills) | ✅ 稳定 | 75 个 SKILL.md + 语义路由 |
|
||||||
|
| 中间件链 | ✅ 稳定 | 14 层 (ButlerRouter@80, DataMasking@90, Compaction@100, Memory@150, Title@180, SkillIndex@200, DanglingTool@300, ToolError@350, ToolOutputGuard@360, Guardrail@400, LoopGuard@500, SubagentLimit@550, TrajectoryRecorder@650, TokenCalibration@700) |
|
||||||
|
|
||||||
|
### 关键架构模式
|
||||||
|
|
||||||
|
- **Hermes 管线**: 4模块闭环 — ExperienceStore(FTS5经验存取) + UserProfiler(结构化用户画像) + NlScheduleParser(中文时间→cron) + TrajectoryRecorder+Compressor(轨迹记录压缩)。通过中间件链+intelligence hooks调用
|
||||||
|
- **管家模式**: 双模式UI (默认简洁/解锁专业) + ButlerRouter 动态行业关键词(4内置+自定义) + <butler-context> XML fencing注入 + 跨会话连续性(痛点回访+经验检索) + 触发信号持久化(VikingStorage) + 冷启动4阶段hook
|
||||||
|
- **聊天流**: 3种实现 → GatewayClient(WebSocket) / KernelClient(Tauri Event) / SaaSRelay(SSE) + 5min超时守护。详见 [ARCHITECTURE_BRIEF.md](docs/ARCHITECTURE_BRIEF.md)
|
||||||
|
- **客户端路由**: `getClient()` 4分支决策树 → Admin路由 / SaaS Relay(可降级到本地) / Local Kernel / External Gateway
|
||||||
|
- **SaaS 认证**: JWT→OS keyring 存储 + HttpOnly cookie + Token池 RPM/TPM 限流轮换 + SaaS unreachable 自动降级
|
||||||
|
- **记忆闭环**: 对话→extraction_adapter→FTS5全文+TF-IDF权重→检索→注入系统提示
|
||||||
|
- **LLM 驱动**: 4 Rust Driver (Anthropic/OpenAI/Gemini/Local) + 国内兼容 (DeepSeek/Qwen/Moonshot 通过 base_url)
|
||||||
|
|
||||||
|
### 最近变更
|
||||||
|
|
||||||
|
1. [04-15] Heartbeat 统一健康系统: health_snapshot.rs 统一收集器(LLM连接/记忆/会话/系统资源) + heartbeat.rs HeartbeatManager 重构 + HealthPanel.tsx 前端面板 + Tauri 命令 182→183 + intelligence 模块 15→16 文件 + 删除 intelligence-client/ 9 废弃文件
|
||||||
|
2. [04-12] 行业配置+管家主动性 全栈 5 Phase: 行业数据模型+4内置配置+ButlerRouter动态关键词+触发信号+Tauri加载+Admin管理页面+跨会话连续性+XML fencing注入格式
|
||||||
|
2. [04-09] Hermes Intelligence Pipeline 4 Chunk: ExperienceStore+Extractor, UserProfileStore+Profiler, NlScheduleParser, TrajectoryRecorder+Compressor (684 tests, 0 failed)
|
||||||
|
3. [04-09] 管家模式6交付物完成: ButlerRouter + 冷启动 + 简洁模式UI + 桥测试 + 发布文档
|
||||||
|
3. [04-07] @reserved 标注 5 个 butler Tauri 命令 + 痛点持久化 SQLite
|
||||||
|
4. [04-06] 4 个发布前 bug 修复 (身份覆盖/模型配置/agent同步/自动身份)
|
||||||
|
|
||||||
|
<!-- ARCH-SNAPSHOT-END -->
|
||||||
|
|
||||||
|
<!-- ANTI-PATTERN-START -->
|
||||||
|
<!-- 此区域由 auto-sync 自动更新,请勿手动编辑。更新时间: 2026-04-09 -->
|
||||||
|
|
||||||
|
## 14. AI 协作注意事项
|
||||||
|
|
||||||
|
### 反模式警告
|
||||||
|
|
||||||
|
- ❌ **不要**建议新增 SaaS API 端点 — 已有 140 个,稳定化约束禁止新增
|
||||||
|
- ❌ **不要**忽略管家模式 — 已上线且为默认模式,所有聊天经过 ButlerRouter
|
||||||
|
- ❌ **不要**假设 Tauri 直连 LLM — 实际通过 SaaS Token 池中转,SaaS unreachable 时降级到本地 Kernel
|
||||||
|
- ❌ **不要**建议从零实现已有能力 — 先查 Hand(9个)/Skill(75个)/Pipeline(17模板) 现有库
|
||||||
|
- ❌ **不要**在 CLAUDE.md 以外创建项目级配置或规则文件 — 单一入口原则
|
||||||
|
|
||||||
|
### 场景化指令
|
||||||
|
|
||||||
|
- 当遇到**聊天相关** → 记住有 3 种 ChatStream 实现,先用 `getClient()` 判断当前路由模式
|
||||||
|
- 当遇到**认证相关** → 记住 Tauri 模式用 OS keyring 存 JWT,SaaS 模式用 HttpOnly cookie
|
||||||
|
- 当遇到**新功能建议** → 先查 [TRUTH.md](docs/TRUTH.md) 确认可用能力清单,避免重复建设
|
||||||
|
- 当遇到**记忆/上下文相关** → 记住闭环已接通: FTS5+TF-IDF+embedding,不是空壳
|
||||||
|
- 当遇到**管家/Butler** → 管家模式是默认模式,ButlerRouter 在中间件链中做关键词分类+system prompt 增强
|
||||||
|
|
||||||
|
<!-- ANTI-PATTERN-END -->
|
||||||
|
|||||||
2813
Cargo.lock
generated
2813
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
27
Cargo.toml
27
Cargo.toml
@@ -9,7 +9,6 @@ members = [
|
|||||||
# ZCLAW Extension Crates
|
# ZCLAW Extension Crates
|
||||||
"crates/zclaw-skills",
|
"crates/zclaw-skills",
|
||||||
"crates/zclaw-hands",
|
"crates/zclaw-hands",
|
||||||
"crates/zclaw-channels",
|
|
||||||
"crates/zclaw-protocols",
|
"crates/zclaw-protocols",
|
||||||
"crates/zclaw-pipeline",
|
"crates/zclaw-pipeline",
|
||||||
"crates/zclaw-growth",
|
"crates/zclaw-growth",
|
||||||
@@ -20,7 +19,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.9.0-beta.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "Apache-2.0 OR MIT"
|
license = "Apache-2.0 OR MIT"
|
||||||
repository = "https://github.com/zclaw/zclaw"
|
repository = "https://github.com/zclaw/zclaw"
|
||||||
@@ -30,6 +29,7 @@ rust-version = "1.75"
|
|||||||
# Async runtime
|
# Async runtime
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tokio-stream = "0.1"
|
tokio-stream = "0.1"
|
||||||
|
tokio-util = "0.7"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ chrono = { version = "0.4", features = ["serde"] }
|
|||||||
uuid = { version = "1", features = ["v4", "v5", "serde"] }
|
uuid = { version = "1", features = ["v4", "v5", "serde"] }
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "chrono"] }
|
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "postgres", "chrono"] }
|
||||||
libsqlite3-sys = { version = "0.27", features = ["bundled"] }
|
libsqlite3-sys = { version = "0.27", features = ["bundled"] }
|
||||||
|
|
||||||
# HTTP client (for LLM drivers)
|
# HTTP client (for LLM drivers)
|
||||||
@@ -84,6 +84,7 @@ rand = "0.8"
|
|||||||
# Crypto
|
# Crypto
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
aes-gcm = "0.10"
|
aes-gcm = "0.10"
|
||||||
|
rsa = { version = "0.9", features = ["pem"] }
|
||||||
|
|
||||||
# Home directory
|
# Home directory
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
@@ -94,19 +95,32 @@ regex = "1"
|
|||||||
# Shell parsing
|
# Shell parsing
|
||||||
shlex = "1"
|
shlex = "1"
|
||||||
|
|
||||||
|
# WASM runtime
|
||||||
|
wasmtime = { version = "43", default-features = false, features = ["cranelift"] }
|
||||||
|
wasmtime-wasi = { version = "43" }
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
||||||
# SaaS dependencies
|
# SaaS dependencies
|
||||||
axum = { version = "0.7", features = ["macros"] }
|
axum = { version = "0.7", features = ["macros", "multipart"] }
|
||||||
axum-extra = { version = "0.9", features = ["typed-header"] }
|
axum-extra = { version = "0.9", features = ["typed-header", "cookie"] }
|
||||||
tower = { version = "0.4", features = ["util"] }
|
tower = { version = "0.4", features = ["util"] }
|
||||||
tower-http = { version = "0.5", features = ["cors", "trace", "limit"] }
|
tower-http = { version = "0.5", features = ["cors", "trace", "limit", "timeout"] }
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = "9"
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
totp-rs = "5"
|
totp-rs = "5"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
|
||||||
|
# Document processing
|
||||||
|
pdf-extract = "0.7"
|
||||||
|
calamine = "0.26"
|
||||||
|
quick-xml = "0.37"
|
||||||
|
zip = "2"
|
||||||
|
|
||||||
|
# TCP socket configuration
|
||||||
|
socket2 = { version = "0.5", features = ["all"] }
|
||||||
|
|
||||||
# Internal crates
|
# Internal crates
|
||||||
zclaw-types = { path = "crates/zclaw-types" }
|
zclaw-types = { path = "crates/zclaw-types" }
|
||||||
zclaw-memory = { path = "crates/zclaw-memory" }
|
zclaw-memory = { path = "crates/zclaw-memory" }
|
||||||
@@ -114,7 +128,6 @@ zclaw-runtime = { path = "crates/zclaw-runtime" }
|
|||||||
zclaw-kernel = { path = "crates/zclaw-kernel" }
|
zclaw-kernel = { path = "crates/zclaw-kernel" }
|
||||||
zclaw-skills = { path = "crates/zclaw-skills" }
|
zclaw-skills = { path = "crates/zclaw-skills" }
|
||||||
zclaw-hands = { path = "crates/zclaw-hands" }
|
zclaw-hands = { path = "crates/zclaw-hands" }
|
||||||
zclaw-channels = { path = "crates/zclaw-channels" }
|
|
||||||
zclaw-protocols = { path = "crates/zclaw-protocols" }
|
zclaw-protocols = { path = "crates/zclaw-protocols" }
|
||||||
zclaw-pipeline = { path = "crates/zclaw-pipeline" }
|
zclaw-pipeline = { path = "crates/zclaw-pipeline" }
|
||||||
zclaw-growth = { path = "crates/zclaw-growth" }
|
zclaw-growth = { path = "crates/zclaw-growth" }
|
||||||
|
|||||||
127
Dockerfile
127
Dockerfile
@@ -1,83 +1,106 @@
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# ZCLAW SaaS Backend - Multi-stage Docker Build
|
# ZCLAW SaaS Backend - Multi-stage Docker Build
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
# Build: docker build -t zclaw-saas .
|
||||||
|
# Run: docker run --env-file saas-env.example zclaw-saas
|
||||||
|
# ============================================================
|
||||||
|
#
|
||||||
|
# .dockerignore recommended contents:
|
||||||
|
# target/
|
||||||
|
# node_modules/
|
||||||
|
# desktop/
|
||||||
|
# admin/
|
||||||
|
# admin-v2/
|
||||||
|
# docs/
|
||||||
|
# .git/
|
||||||
|
# .claude/
|
||||||
|
# *.md
|
||||||
|
# *.pen
|
||||||
|
# plans/
|
||||||
|
# dist/
|
||||||
|
# pencil-new.pen
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
# ---- Stage 1: Builder ----
|
# ---- Stage 1: Build ----
|
||||||
FROM rust:1.75-bookworm AS builder
|
FROM rust:1.85-bookworm AS builder
|
||||||
|
|
||||||
# Install build dependencies for sqlx (postgres) and libsqlite3-sys (bundled)
|
WORKDIR /usr/src/zclaw
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
pkg-config \
|
|
||||||
libssl-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
# Cache dependency builds by copying manifests first
|
||||||
|
|
||||||
# Copy workspace manifests first to leverage Docker layer caching
|
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
|
||||||
# Create stub source files so cargo can resolve and cache dependencies
|
# Create dummy lib.rs files so cargo can resolve the workspace
|
||||||
# This avoids rebuilding dependencies when only application code changes
|
RUN mkdir -p crates/zclaw-types/src && echo "" > crates/zclaw-types/src/lib.rs
|
||||||
RUN mkdir -p crates/zclaw-saas/src \
|
RUN mkdir -p crates/zclaw-memory/src && echo "" > crates/zclaw-memory/src/lib.rs
|
||||||
&& echo 'fn main() {}' > crates/zclaw-saas/src/main.rs \
|
RUN mkdir -p crates/zclaw-runtime/src && echo "" > crates/zclaw-runtime/src/lib.rs
|
||||||
&& for crate in zclaw-types zclaw-memory zclaw-runtime zclaw-kernel \
|
RUN mkdir -p crates/zclaw-kernel/src && echo "" > crates/zclaw-kernel/src/lib.rs
|
||||||
zclaw-skills zclaw-hands zclaw-channels zclaw-protocols \
|
RUN mkdir -p crates/zclaw-skills/src && echo "" > crates/zclaw-skills/src/lib.rs
|
||||||
zclaw-pipeline zclaw-growth; do \
|
RUN mkdir -p crates/zclaw-hands/src && echo "" > crates/zclaw-hands/src/lib.rs
|
||||||
mkdir -p crates/$crate/src && echo '' > crates/$crate/src/lib.rs; \
|
RUN mkdir -p crates/zclaw-protocols/src && echo "" > crates/zclaw-protocols/src/lib.rs
|
||||||
done \
|
RUN mkdir -p crates/zclaw-pipeline/src && echo "" > crates/zclaw-pipeline/src/lib.rs
|
||||||
&& mkdir -p desktop/src-tauri/src && echo 'fn main() {}' > desktop/src-tauri/src/main.rs
|
RUN mkdir -p crates/zclaw-growth/src && echo "" > crates/zclaw-growth/src/lib.rs
|
||||||
|
RUN mkdir -p crates/zclaw-saas/src && echo "fn main() {}" > crates/zclaw-saas/src/main.rs
|
||||||
|
RUN mkdir -p desktop/src-tauri/src && echo "" > desktop/src-tauri/src/lib.rs
|
||||||
|
|
||||||
# Pre-build dependencies (release profile with caching)
|
# Copy all crate Cargo.toml files for dependency resolution
|
||||||
RUN cargo build --release --package zclaw-saas 2>/dev/null || true
|
COPY crates/zclaw-types/Cargo.toml crates/zclaw-types/Cargo.toml
|
||||||
|
COPY crates/zclaw-memory/Cargo.toml crates/zclaw-memory/Cargo.toml
|
||||||
|
COPY crates/zclaw-runtime/Cargo.toml crates/zclaw-runtime/Cargo.toml
|
||||||
|
COPY crates/zclaw-kernel/Cargo.toml crates/zclaw-kernel/Cargo.toml
|
||||||
|
COPY crates/zclaw-skills/Cargo.toml crates/zclaw-skills/Cargo.toml
|
||||||
|
COPY crates/zclaw-hands/Cargo.toml crates/zclaw-hands/Cargo.toml
|
||||||
|
COPY crates/zclaw-protocols/Cargo.toml crates/zclaw-protocols/Cargo.toml
|
||||||
|
COPY crates/zclaw-pipeline/Cargo.toml crates/zclaw-pipeline/Cargo.toml
|
||||||
|
COPY crates/zclaw-growth/Cargo.toml crates/zclaw-growth/Cargo.toml
|
||||||
|
COPY crates/zclaw-saas/Cargo.toml crates/zclaw-saas/Cargo.toml
|
||||||
|
COPY desktop/src-tauri/Cargo.toml desktop/src-tauri/Cargo.toml
|
||||||
|
|
||||||
# Copy actual source code (invalidates stubs, triggers recompile of app code only)
|
# Build dependencies only (cached layer)
|
||||||
|
RUN cargo build --release -p zclaw-saas 2>/dev/null || true
|
||||||
|
|
||||||
|
# Now copy the actual source code
|
||||||
COPY crates/ crates/
|
COPY crates/ crates/
|
||||||
COPY desktop/ desktop/
|
COPY desktop/src-tauri/src/ desktop/src-tauri/src/
|
||||||
|
|
||||||
# Touch source files to invalidate the stub timestamps
|
# Touch source files to invalidate cache after dependency layer
|
||||||
RUN touch crates/zclaw-saas/src/main.rs \
|
RUN find crates/zclaw-saas/src -type f -exec touch {} +
|
||||||
&& for crate in zclaw-types zclaw-memory zclaw-runtime zclaw-kernel \
|
|
||||||
zclaw-skills zclaw-hands zclaw-channels zclaw-protocols \
|
|
||||||
zclaw-pipeline zclaw-growth; do \
|
|
||||||
touch crates/$crate/src/lib.rs 2>/dev/null || true; \
|
|
||||||
done \
|
|
||||||
&& touch desktop/src-tauri/src/main.rs 2>/dev/null || true
|
|
||||||
|
|
||||||
# Build the actual binary
|
# Build the final binary
|
||||||
RUN cargo build --release --package zclaw-saas
|
RUN cargo build --release -p zclaw-saas
|
||||||
|
|
||||||
# ---- Stage 2: Runtime ----
|
# ---- Stage 2: Runtime ----
|
||||||
FROM debian:bookworm-slim AS runtime
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
# Install runtime dependencies (ca-certificates for HTTPS, libgcc for Rust runtime)
|
# Install runtime dependencies
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && \
|
||||||
ca-certificates \
|
apt-get install -y --no-install-recommends \
|
||||||
libgcc-s \
|
ca-certificates \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
curl \
|
||||||
&& update-ca-certificates
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Create non-root user for security
|
# Create non-root user for security
|
||||||
RUN groupadd --gid 1000 zclaw \
|
RUN groupadd --gid 1000 zclaw && \
|
||||||
&& useradd --uid 1000 --gid zclaw --shell /bin/false zclaw
|
useradd --uid 1000 --gid zclaw --shell /bin/bash --create-home zclaw
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy binary from builder
|
# Copy binary from builder
|
||||||
COPY --from=builder /app/target/release/zclaw-saas /app/zclaw-saas
|
COPY --from=builder /usr/src/zclaw/target/release/zclaw-saas ./zclaw-saas
|
||||||
|
|
||||||
# Copy configuration file
|
# Copy default config (can be overridden by env vars)
|
||||||
COPY saas-config.toml /app/saas-config.toml
|
COPY saas-config.toml ./saas-config.toml
|
||||||
|
|
||||||
# Ensure the non-root user owns the application files
|
# Set ownership
|
||||||
RUN chown -R zclaw:zclaw /app
|
RUN chown -R zclaw:zclaw /app
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
USER zclaw
|
USER zclaw
|
||||||
|
|
||||||
# Expose the SaaS API port
|
# Expose SaaS backend port
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
# Health check endpoint (matches the saas-config.toml port)
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
CMD ["/app/zclaw-saas", "--healthcheck"] || exit 1
|
CMD curl -f http://localhost:8080/health || exit 1
|
||||||
|
|
||||||
ENTRYPOINT ["/app/zclaw-saas"]
|
ENTRYPOINT ["./zclaw-saas"]
|
||||||
|
|||||||
33
Makefile
33
Makefile
@@ -1,9 +1,7 @@
|
|||||||
# ZCLAW Makefile
|
# ZCLAW Makefile
|
||||||
# Cross-platform task runner
|
# Cross-platform task runner
|
||||||
|
|
||||||
.PHONY: help start start-dev start-no-browser desktop desktop-build setup test clean \
|
.PHONY: help start start-dev start-no-browser desktop desktop-build setup test clean
|
||||||
saas-build saas-run saas-test saas-test-integration saas-clippy saas-migrate \
|
|
||||||
saas-docker-up saas-docker-down saas-docker-build
|
|
||||||
|
|
||||||
help: ## Show this help message
|
help: ## Show this help message
|
||||||
@echo "ZCLAW - AI Agent Desktop Client"
|
@echo "ZCLAW - AI Agent Desktop Client"
|
||||||
@@ -73,32 +71,3 @@ clean-deep: clean ## Deep clean (including pnpm cache)
|
|||||||
@rm -rf desktop/pnpm-lock.yaml
|
@rm -rf desktop/pnpm-lock.yaml
|
||||||
@rm -rf pnpm-lock.yaml
|
@rm -rf pnpm-lock.yaml
|
||||||
@echo "Deep clean complete. Run 'pnpm install' to reinstall."
|
@echo "Deep clean complete. Run 'pnpm install' to reinstall."
|
||||||
|
|
||||||
# === SaaS Backend ===
|
|
||||||
|
|
||||||
saas-build: ## Build zclaw-saas crate
|
|
||||||
@cargo build -p zclaw-saas
|
|
||||||
|
|
||||||
saas-run: ## Start SaaS backend (cargo run)
|
|
||||||
@cargo run -p zclaw-saas
|
|
||||||
|
|
||||||
saas-test: ## Run SaaS unit tests
|
|
||||||
@cargo test -p zclaw-saas -- --test-threads=1
|
|
||||||
|
|
||||||
saas-test-integration: ## Run SaaS integration tests (requires PostgreSQL)
|
|
||||||
@cargo test -p zclaw-saas -- --ignored --test-threads=1
|
|
||||||
|
|
||||||
saas-clippy: ## Run clippy on zclaw-saas
|
|
||||||
@cargo clippy -p zclaw-saas -- -D warnings
|
|
||||||
|
|
||||||
saas-migrate: ## Run database migrations
|
|
||||||
@cargo run -p zclaw-saas -- --migrate
|
|
||||||
|
|
||||||
saas-docker-up: ## Start SaaS services (PostgreSQL + backend)
|
|
||||||
@docker compose up -d
|
|
||||||
|
|
||||||
saas-docker-down: ## Stop SaaS services
|
|
||||||
@docker compose down
|
|
||||||
|
|
||||||
saas-docker-build: ## Build SaaS Docker images
|
|
||||||
@docker compose build
|
|
||||||
|
|||||||
1
admin-v2/.env.development
Normal file
1
admin-v2/.env.development
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:8080/api/v1
|
||||||
1
admin-v2/.env.production
Normal file
1
admin-v2/.env.production
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=/api/v1
|
||||||
24
admin-v2/.gitignore
vendored
Normal file
24
admin-v2/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
73
admin-v2/README.md
Normal file
73
admin-v2/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
admin-v2/eslint.config.js
Normal file
23
admin-v2/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
17
admin-v2/index.html
Normal file
17
admin-v2/index.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ZCLAW Admin</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a href="#main-content" class="skip-to-content">跳转到主要内容</a>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
50
admin-v2/package.json
Normal file
50
admin-v2/package.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "admin-v2",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^6.1.1",
|
||||||
|
"@ant-design/pro-components": "^2.8.10",
|
||||||
|
"@ant-design/pro-layout": "^7.22.7",
|
||||||
|
"@tanstack/react-query": "^5.95.2",
|
||||||
|
"antd": "^6.3.4",
|
||||||
|
"axios": "^1.14.0",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.13.2",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/node": "^24.12.0",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"jsdom": "^29.0.1",
|
||||||
|
"msw": "^2.12.14",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.57.0",
|
||||||
|
"vite": "^8.0.1",
|
||||||
|
"vitest": "^4.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
50
admin-v2/playwright.config.ts
Normal file
50
admin-v2/playwright.config.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin V2 E2E 测试配置
|
||||||
|
*
|
||||||
|
* 断裂探测冒烟测试 — 验证 Admin V2 页面与 SaaS 后端的连通性
|
||||||
|
*
|
||||||
|
* 前提条件:
|
||||||
|
* - SaaS Server 运行在 http://localhost:8080
|
||||||
|
* - Admin V2 dev server 运行在 http://localhost:5173
|
||||||
|
* - 数据库有种子数据 (super_admin: testadmin/Admin123456)
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
timeout: 60000,
|
||||||
|
expect: {
|
||||||
|
timeout: 10000,
|
||||||
|
},
|
||||||
|
fullyParallel: false,
|
||||||
|
retries: 0,
|
||||||
|
workers: 1,
|
||||||
|
reporter: [
|
||||||
|
['list'],
|
||||||
|
['html', { outputFolder: 'test-results/html-report' }],
|
||||||
|
],
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
video: 'retain-on-failure',
|
||||||
|
actionTimeout: 10000,
|
||||||
|
navigationTimeout: 30000,
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
viewport: { width: 1280, height: 720 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'pnpm dev --port 5173',
|
||||||
|
url: 'http://localhost:5173',
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 30000,
|
||||||
|
},
|
||||||
|
outputDir: 'test-results/artifacts',
|
||||||
|
});
|
||||||
5232
admin-v2/pnpm-lock.yaml
generated
Normal file
5232
admin-v2/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
admin-v2/public/favicon.svg
Normal file
1
admin-v2/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
admin-v2/public/icons.svg
Normal file
24
admin-v2/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
BIN
admin-v2/src/assets/hero.png
Normal file
BIN
admin-v2/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
admin-v2/src/assets/react.svg
Normal file
1
admin-v2/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
1
admin-v2/src/assets/vite.svg
Normal file
1
admin-v2/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
53
admin-v2/src/components/ErrorBoundary.tsx
Normal file
53
admin-v2/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Component, type ReactNode } from 'react'
|
||||||
|
import { Result, Button } from 'antd'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean
|
||||||
|
error: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props)
|
||||||
|
this.state = { hasError: false, error: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||||
|
console.error('[ErrorBoundary] Unhandled error:', error, info.componentStack)
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReload = () => {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReset = () => {
|
||||||
|
this.setState({ hasError: false, error: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="页面出现错误"
|
||||||
|
subTitle={this.state.error?.message ?? '发生了未知错误,请刷新页面重试'}
|
||||||
|
extra={[
|
||||||
|
<Button key="retry" onClick={this.handleReset}>重试</Button>,
|
||||||
|
<Button key="reload" type="primary" onClick={this.handleReload}>刷新页面</Button>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
51
admin-v2/src/components/ErrorState.tsx
Normal file
51
admin-v2/src/components/ErrorState.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Button, Result } from 'antd'
|
||||||
|
import type { FallbackProps } from 'react-error-boundary'
|
||||||
|
|
||||||
|
interface ErrorStateProps {
|
||||||
|
title?: string
|
||||||
|
message?: string
|
||||||
|
onRetry?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorState({
|
||||||
|
title = '加载失败',
|
||||||
|
message,
|
||||||
|
onRetry,
|
||||||
|
}: ErrorStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[200px] p-8">
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title={title}
|
||||||
|
subTitle={message}
|
||||||
|
extra={
|
||||||
|
onRetry ? (
|
||||||
|
<Button type="primary" onClick={onRetry}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[200px] p-8">
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="页面出现错误"
|
||||||
|
subTitle={error.message}
|
||||||
|
extra={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={resetErrorBoundary}>重试</Button>
|
||||||
|
<Button type="primary" onClick={() => window.location.reload()}>
|
||||||
|
刷新页面
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
admin-v2/src/components/PageHeader.tsx
Normal file
25
admin-v2/src/components/PageHeader.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
actions?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
admin-v2/src/constants/status.ts
Normal file
45
admin-v2/src/constants/status.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 操作日志状态映射 — Dashboard 与 Logs 共用
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export const actionLabels: Record<string, string> = {
|
||||||
|
login: '登录',
|
||||||
|
logout: '登出',
|
||||||
|
create_account: '创建账号',
|
||||||
|
update_account: '更新账号',
|
||||||
|
delete_account: '删除账号',
|
||||||
|
create_provider: '创建服务商',
|
||||||
|
update_provider: '更新服务商',
|
||||||
|
delete_provider: '删除服务商',
|
||||||
|
create_model: '创建模型',
|
||||||
|
update_model: '更新模型',
|
||||||
|
delete_model: '删除模型',
|
||||||
|
create_token: '创建密钥',
|
||||||
|
revoke_token: '撤销密钥',
|
||||||
|
update_config: '更新配置',
|
||||||
|
create_prompt: '创建提示词',
|
||||||
|
update_prompt: '更新提示词',
|
||||||
|
archive_prompt: '归档提示词',
|
||||||
|
desktop_audit: '桌面端审计',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actionColors: Record<string, string> = {
|
||||||
|
login: 'green',
|
||||||
|
logout: 'default',
|
||||||
|
create_account: 'blue',
|
||||||
|
update_account: 'orange',
|
||||||
|
delete_account: 'red',
|
||||||
|
create_provider: 'blue',
|
||||||
|
update_provider: 'orange',
|
||||||
|
delete_provider: 'red',
|
||||||
|
create_model: 'blue',
|
||||||
|
update_model: 'orange',
|
||||||
|
delete_model: 'red',
|
||||||
|
create_token: 'blue',
|
||||||
|
revoke_token: 'red',
|
||||||
|
update_config: 'orange',
|
||||||
|
create_prompt: 'blue',
|
||||||
|
update_prompt: 'orange',
|
||||||
|
archive_prompt: 'red',
|
||||||
|
desktop_audit: 'default',
|
||||||
|
}
|
||||||
403
admin-v2/src/layouts/AdminLayout.tsx
Normal file
403
admin-v2/src/layouts/AdminLayout.tsx
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
import { useState, useCallback, useEffect, useMemo } from 'react'
|
||||||
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
DashboardOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
CloudServerOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
SwapOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
MessageOutlined,
|
||||||
|
RobotOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
MenuFoldOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
SunOutlined,
|
||||||
|
MoonOutlined,
|
||||||
|
ApiOutlined,
|
||||||
|
BookOutlined,
|
||||||
|
CrownOutlined,
|
||||||
|
SafetyOutlined,
|
||||||
|
FieldTimeOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
ShopOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { Avatar, Dropdown, Tooltip, Drawer } from 'antd'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
import { useThemeStore, setThemeMode } from '@/stores/themeStore'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Navigation Configuration
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
path: string
|
||||||
|
name: string
|
||||||
|
icon: ReactNode
|
||||||
|
permission?: string
|
||||||
|
group: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ path: '/', name: '仪表盘', icon: <DashboardOutlined />, group: '核心' },
|
||||||
|
{ path: '/accounts', name: '账号管理', icon: <TeamOutlined />, permission: 'account:admin', group: '资源管理' },
|
||||||
|
{ path: '/roles', name: '角色与权限', icon: <SafetyOutlined />, permission: 'account:admin', group: '资源管理' },
|
||||||
|
{ path: '/model-services', name: '模型服务', icon: <CloudServerOutlined />, permission: 'provider:manage', group: '资源管理' },
|
||||||
|
{ path: '/agent-templates', name: 'Agent 模板', icon: <RobotOutlined />, permission: 'model:read', group: '资源管理' },
|
||||||
|
{ path: '/api-keys', name: 'API 密钥', icon: <ApiOutlined />, permission: 'provider:manage', group: '资源管理' },
|
||||||
|
{ path: '/usage', name: '用量统计', icon: <BarChartOutlined />, permission: 'admin:full', group: '运维' },
|
||||||
|
{ path: '/relay', name: '中转任务', icon: <SwapOutlined />, permission: 'relay:use', group: '运维' },
|
||||||
|
{ path: '/scheduled-tasks', name: '定时任务', icon: <FieldTimeOutlined />, permission: 'scheduler:read', group: '运维' },
|
||||||
|
{ path: '/knowledge', name: '知识库', icon: <BookOutlined />, permission: 'knowledge:read', group: '资源管理' },
|
||||||
|
{ path: '/industries', name: '行业配置', icon: <ShopOutlined />, permission: 'config:read', group: '资源管理' },
|
||||||
|
{ path: '/billing', name: '计费管理', icon: <CrownOutlined />, permission: 'billing:read', group: '核心' },
|
||||||
|
{ path: '/logs', name: '操作日志', icon: <FileTextOutlined />, permission: 'admin:full', group: '运维' },
|
||||||
|
{ path: '/config-sync', name: '同步日志', icon: <SyncOutlined />, permission: 'config:read', group: '运维' },
|
||||||
|
{ path: '/config', name: '系统配置', icon: <SettingOutlined />, permission: 'config:read', group: '系统' },
|
||||||
|
{ path: '/prompts', name: '提示词管理', icon: <MessageOutlined />, permission: 'prompt:read', group: '系统' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Sidebar Component
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function Sidebar({
|
||||||
|
collapsed,
|
||||||
|
onNavigate,
|
||||||
|
activePath,
|
||||||
|
}: {
|
||||||
|
collapsed: boolean
|
||||||
|
onNavigate: (path: string) => void
|
||||||
|
activePath: string
|
||||||
|
}) {
|
||||||
|
const { hasPermission } = useAuthStore()
|
||||||
|
const visibleItems = navItems.filter(
|
||||||
|
(item) => !item.permission || hasPermission(item.permission),
|
||||||
|
)
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const map = new Map<string, NavItem[]>()
|
||||||
|
for (const item of visibleItems) {
|
||||||
|
const list = map.get(item.group) || []
|
||||||
|
list.push(item)
|
||||||
|
map.set(item.group, list)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [visibleItems])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="flex flex-col h-full" aria-label="主导航">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex items-center h-14 px-4 border-b border-neutral-200 dark:border-neutral-800">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center w-8 h-8 rounded-lg shrink-0"
|
||||||
|
style={{ background: 'linear-gradient(135deg, #863bff, #47bfff)' }}
|
||||||
|
>
|
||||||
|
<span className="text-white font-bold text-sm">Z</span>
|
||||||
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<span className="ml-3 text-lg font-bold text-neutral-900 dark:text-neutral-100 tracking-tight">
|
||||||
|
ZCLAW
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Items */}
|
||||||
|
<div className="flex-1 overflow-y-auto py-3 px-2">
|
||||||
|
{Array.from(groups.entries()).map(([groupName, items]) => (
|
||||||
|
<div key={groupName} className="mb-3">
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="px-3 mb-1 text-[11px] font-semibold uppercase tracking-wider text-neutral-400 dark:text-neutral-600">
|
||||||
|
{groupName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{items.map((item) => {
|
||||||
|
const isActive =
|
||||||
|
item.path === '/'
|
||||||
|
? activePath === '/'
|
||||||
|
: activePath.startsWith(item.path)
|
||||||
|
|
||||||
|
const btn = (
|
||||||
|
<button
|
||||||
|
key={item.path}
|
||||||
|
onClick={() => onNavigate(item.path)}
|
||||||
|
className={`
|
||||||
|
w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium
|
||||||
|
transition-all duration-150 ease-in-out cursor-pointer border-none bg-transparent
|
||||||
|
${
|
||||||
|
isActive
|
||||||
|
? 'text-brand-purple bg-brand-purple/8 dark:text-brand-purple dark:bg-brand-purple/12'
|
||||||
|
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
||||||
|
}
|
||||||
|
${collapsed ? 'justify-center' : ''}
|
||||||
|
`}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`text-base ${isActive ? 'text-brand-purple' : ''}`}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
{!collapsed && <span>{item.name}</span>}
|
||||||
|
{isActive && (
|
||||||
|
<span className="ml-auto w-1.5 h-1.5 rounded-full bg-brand-purple" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
return collapsed ? (
|
||||||
|
<Tooltip key={item.path} title={item.name} placement="right">
|
||||||
|
{btn}
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<div key={item.path}>{btn}</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom section */}
|
||||||
|
<div className="border-t border-neutral-200 dark:border-neutral-800 p-3">
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="text-[11px] text-neutral-400 dark:text-neutral-600 text-center">
|
||||||
|
ZCLAW Admin v2
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Mobile Drawer Sidebar
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function MobileDrawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onNavigate,
|
||||||
|
activePath,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onNavigate: (path: string) => void
|
||||||
|
activePath: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
placement="left"
|
||||||
|
onClose={onClose}
|
||||||
|
open={open}
|
||||||
|
width={280}
|
||||||
|
styles={{
|
||||||
|
body: { padding: 0 },
|
||||||
|
header: { display: 'none' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sidebar collapsed={false} onNavigate={onNavigate} activePath={activePath} />
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Breadcrumb
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const breadcrumbMap: Record<string, string> = {
|
||||||
|
'/': '仪表盘',
|
||||||
|
'/accounts': '账号管理',
|
||||||
|
'/roles': '角色与权限',
|
||||||
|
'/model-services': '模型服务',
|
||||||
|
'/providers': '模型服务',
|
||||||
|
'/models': '模型服务',
|
||||||
|
'/api-keys': 'API 密钥',
|
||||||
|
'/agent-templates': 'Agent 模板',
|
||||||
|
'/usage': '用量统计',
|
||||||
|
'/relay': '中转任务',
|
||||||
|
'/scheduled-tasks': '定时任务',
|
||||||
|
'/knowledge': '知识库',
|
||||||
|
'/billing': '计费管理',
|
||||||
|
'/config': '系统配置',
|
||||||
|
'/industries': '行业配置',
|
||||||
|
'/prompts': '提示词管理',
|
||||||
|
'/logs': '操作日志',
|
||||||
|
'/config-sync': '同步日志',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Main Layout
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export default function AdminLayout() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const { account, logout } = useAuthStore()
|
||||||
|
const themeState = useThemeStore()
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
|
|
||||||
|
// Responsive detection
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia('(max-width: 768px)')
|
||||||
|
setIsMobile(mq.matches)
|
||||||
|
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
|
||||||
|
mq.addEventListener('change', handler)
|
||||||
|
return () => mq.removeEventListener('change', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleNavigate = useCallback(
|
||||||
|
(path: string) => {
|
||||||
|
navigate(path)
|
||||||
|
setMobileOpen(false)
|
||||||
|
},
|
||||||
|
[navigate],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleLogout = useCallback(() => {
|
||||||
|
logout()
|
||||||
|
navigate('/login', { replace: true })
|
||||||
|
}, [logout, navigate])
|
||||||
|
|
||||||
|
const toggleTheme = useCallback(() => {
|
||||||
|
setThemeMode(themeState.resolved === 'dark' ? 'light' : 'dark')
|
||||||
|
}, [themeState.resolved])
|
||||||
|
|
||||||
|
const currentPage = breadcrumbMap[location.pathname] || '页面'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden bg-neutral-50 dark:bg-neutral-950">
|
||||||
|
{/* Desktop Sidebar */}
|
||||||
|
{!isMobile && (
|
||||||
|
<aside
|
||||||
|
className={`
|
||||||
|
shrink-0 border-r border-neutral-200 dark:border-neutral-800
|
||||||
|
bg-white dark:bg-neutral-900
|
||||||
|
transition-all duration-200 ease-in-out
|
||||||
|
${collapsed ? 'w-12' : 'w-64'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Sidebar
|
||||||
|
collapsed={collapsed}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
activePath={location.pathname}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile Drawer */}
|
||||||
|
{isMobile && (
|
||||||
|
<MobileDrawer
|
||||||
|
open={mobileOpen}
|
||||||
|
onClose={() => setMobileOpen(false)}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
activePath={location.pathname}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Area */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="h-14 shrink-0 flex items-center justify-between px-4 border-b border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Mobile menu button */}
|
||||||
|
{isMobile && (
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileOpen(true)}
|
||||||
|
className="p-2 rounded-lg text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||||
|
aria-label="打开菜单"
|
||||||
|
>
|
||||||
|
<MenuUnfoldOutlined className="text-lg" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Collapse toggle (desktop) */}
|
||||||
|
{!isMobile && (
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
className="p-2 rounded-lg text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||||
|
aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<MenuUnfoldOutlined className="text-lg" />
|
||||||
|
) : (
|
||||||
|
<MenuFoldOutlined className="text-lg" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-neutral-400 dark:text-neutral-600">ZCLAW</span>
|
||||||
|
<span className="text-neutral-300 dark:text-neutral-700">/</span>
|
||||||
|
<span className="text-neutral-900 dark:text-neutral-100 font-medium">
|
||||||
|
{currentPage}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Theme toggle */}
|
||||||
|
<Tooltip title={themeState.resolved === 'dark' ? '切换亮色' : '切换暗色'}>
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="p-2 rounded-lg text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||||
|
aria-label="切换主题"
|
||||||
|
>
|
||||||
|
{themeState.resolved === 'dark' ? (
|
||||||
|
<SunOutlined className="text-lg" />
|
||||||
|
) : (
|
||||||
|
<MoonOutlined className="text-lg" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* User avatar */}
|
||||||
|
<Dropdown
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'logout',
|
||||||
|
icon: <LogoutOutlined />,
|
||||||
|
label: '退出登录',
|
||||||
|
onClick: handleLogout,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors">
|
||||||
|
<Avatar
|
||||||
|
size={28}
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #863bff, #47bfff)',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(account?.display_name || account?.username || 'A')[0].toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
{!isMobile && (
|
||||||
|
<span className="text-sm text-neutral-700 dark:text-neutral-300 font-medium">
|
||||||
|
{account?.display_name || account?.username || 'Admin'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main
|
||||||
|
id="main-content"
|
||||||
|
className="flex-1 overflow-y-auto p-6"
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
88
admin-v2/src/main.tsx
Normal file
88
admin-v2/src/main.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { RouterProvider } from 'react-router-dom'
|
||||||
|
import { ConfigProvider, App as AntApp, theme } from 'antd'
|
||||||
|
import zhCN from 'antd/locale/zh_CN'
|
||||||
|
import { router } from './router'
|
||||||
|
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||||
|
import { useThemeStore } from './stores/themeStore'
|
||||||
|
import './styles/globals.css'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 30_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function ThemedApp() {
|
||||||
|
const resolved = useThemeStore((s) => s.resolved)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigProvider
|
||||||
|
locale={zhCN}
|
||||||
|
theme={{
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#863bff',
|
||||||
|
colorBgContainer: resolved === 'dark' ? '#292524' : '#ffffff',
|
||||||
|
colorBgElevated: resolved === 'dark' ? '#1c1917' : '#ffffff',
|
||||||
|
colorBgLayout: resolved === 'dark' ? '#0c0a09' : '#fafaf9',
|
||||||
|
colorBorder: resolved === 'dark' ? '#44403c' : '#e7e5e4',
|
||||||
|
colorBorderSecondary: resolved === 'dark' ? '#44403c' : '#f5f5f4',
|
||||||
|
colorText: resolved === 'dark' ? '#fafaf9' : '#1c1917',
|
||||||
|
colorTextSecondary: resolved === 'dark' ? '#a8a29e' : '#78716c',
|
||||||
|
colorTextTertiary: resolved === 'dark' ? '#78716c' : '#a8a29e',
|
||||||
|
borderRadius: 8,
|
||||||
|
borderRadiusLG: 12,
|
||||||
|
fontFamily:
|
||||||
|
'"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
|
fontSize: 14,
|
||||||
|
controlHeight: 36,
|
||||||
|
},
|
||||||
|
algorithm: resolved === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||||
|
components: {
|
||||||
|
Card: {
|
||||||
|
borderRadiusLG: 12,
|
||||||
|
},
|
||||||
|
Table: {
|
||||||
|
borderRadiusLG: 12,
|
||||||
|
headerBg: resolved === 'dark' ? '#1c1917' : '#fafaf9',
|
||||||
|
headerColor: resolved === 'dark' ? '#a8a29e' : '#78716c',
|
||||||
|
rowHoverBg: resolved === 'dark' ? 'rgba(134,59,255,0.06)' : 'rgba(134,59,255,0.04)',
|
||||||
|
},
|
||||||
|
Button: {
|
||||||
|
borderRadius: 8,
|
||||||
|
controlHeight: 36,
|
||||||
|
},
|
||||||
|
Input: {
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
Select: {
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
Modal: {
|
||||||
|
borderRadiusLG: 12,
|
||||||
|
},
|
||||||
|
Tag: {
|
||||||
|
borderRadiusSM: 9999,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AntApp>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AntApp>
|
||||||
|
</ConfigProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ThemedApp />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
)
|
||||||
341
admin-v2/src/pages/Accounts.tsx
Normal file
341
admin-v2/src/pages/Accounts.tsx
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 账号管理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Button, message, Tag, Modal, Form, Input, Select, Popconfirm, Space, Divider } from 'antd'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { accountService } from '@/services/accounts'
|
||||||
|
import { industryService } from '@/services/industries'
|
||||||
|
import { billingService } from '@/services/billing'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import type { AccountPublic } from '@/types'
|
||||||
|
|
||||||
|
const roleLabels: Record<string, string> = {
|
||||||
|
super_admin: '超级管理员',
|
||||||
|
admin: '管理员',
|
||||||
|
user: '用户',
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleColors: Record<string, string> = {
|
||||||
|
super_admin: 'red',
|
||||||
|
admin: 'blue',
|
||||||
|
user: 'default',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
active: '正常',
|
||||||
|
disabled: '已禁用',
|
||||||
|
suspended: '已封禁',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
active: 'green',
|
||||||
|
disabled: 'default',
|
||||||
|
suspended: 'red',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Accounts() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [searchParams, setSearchParams] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['accounts', searchParams],
|
||||||
|
queryFn: ({ signal }) => accountService.list(searchParams, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取行业列表(用于下拉选择)
|
||||||
|
const { data: industriesData } = useQuery({
|
||||||
|
queryKey: ['industries-all'],
|
||||||
|
queryFn: ({ signal }) => industryService.list({ page: 1, page_size: 100, status: 'active' }, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取当前编辑用户的行业授权
|
||||||
|
const { data: accountIndustries } = useQuery({
|
||||||
|
queryKey: ['account-industries', editingId],
|
||||||
|
queryFn: ({ signal }) => industryService.getAccountIndustries(editingId!, signal),
|
||||||
|
enabled: !!editingId,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 当账户行业数据加载完且正在编辑时,同步到表单
|
||||||
|
// Guard: only sync when editingId matches the query key
|
||||||
|
useEffect(() => {
|
||||||
|
if (accountIndustries && editingId) {
|
||||||
|
const ids = accountIndustries.map((item) => item.industry_id)
|
||||||
|
form.setFieldValue('industry_ids', ids)
|
||||||
|
}
|
||||||
|
}, [accountIndustries, editingId, form])
|
||||||
|
|
||||||
|
// 获取所有活跃计划(用于管理员切换)
|
||||||
|
const { data: plansData } = useQuery({
|
||||||
|
queryKey: ['billing-plans'],
|
||||||
|
queryFn: ({ signal }) => billingService.listPlans(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Partial<AccountPublic> }) =>
|
||||||
|
accountService.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['accounts'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusMutation = useMutation({
|
||||||
|
mutationFn: ({ id, status }: { id: string; status: AccountPublic['status'] }) =>
|
||||||
|
accountService.updateStatus(id, { status }),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('状态更新成功')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['accounts'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '状态更新失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 设置用户行业授权
|
||||||
|
const setIndustriesMutation = useMutation({
|
||||||
|
mutationFn: ({ accountId, industries }: { accountId: string; industries: string[] }) =>
|
||||||
|
industryService.setAccountIndustries(accountId, {
|
||||||
|
industries: industries.map((id, idx) => ({
|
||||||
|
industry_id: id,
|
||||||
|
is_primary: idx === 0,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
onError: (err: Error) => message.error(err.message || '行业授权更新失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 管理员切换用户计划
|
||||||
|
const switchPlanMutation = useMutation({
|
||||||
|
mutationFn: ({ accountId, planId }: { accountId: string; planId: string }) =>
|
||||||
|
billingService.adminSwitchPlan(accountId, planId),
|
||||||
|
onSuccess: () => message.success('计划切换成功'),
|
||||||
|
onError: (err: Error) => message.error(err.message || '计划切换失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ProColumns<AccountPublic>[] = [
|
||||||
|
{ title: '用户名', dataIndex: 'username', width: 120, tooltip: '搜索用户名、邮箱或显示名' },
|
||||||
|
{ title: '显示名', dataIndex: 'display_name', width: 120, hideInSearch: true },
|
||||||
|
{ title: '邮箱', dataIndex: 'email', width: 180 },
|
||||||
|
{
|
||||||
|
title: '角色',
|
||||||
|
dataIndex: 'role',
|
||||||
|
width: 120,
|
||||||
|
valueType: 'select',
|
||||||
|
valueEnum: {
|
||||||
|
super_admin: { text: '超级管理员' },
|
||||||
|
admin: { text: '管理员' },
|
||||||
|
user: { text: '用户' },
|
||||||
|
},
|
||||||
|
render: (_, record) => <Tag color={roleColors[record.role]}>{roleLabels[record.role] || record.role}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
valueType: 'select',
|
||||||
|
valueEnum: {
|
||||||
|
active: { text: '正常', status: 'Success' },
|
||||||
|
disabled: { text: '已禁用', status: 'Default' },
|
||||||
|
suspended: { text: '已封禁', status: 'Error' },
|
||||||
|
},
|
||||||
|
render: (_, record) => <Tag color={statusColors[record.status]}>{statusLabels[record.status] || record.status}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '2FA',
|
||||||
|
dataIndex: 'totp_enabled',
|
||||||
|
width: 80,
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, record) => record.totp_enabled ? <Tag color="green">已启用</Tag> : <Tag>未启用</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'LLM 路由',
|
||||||
|
dataIndex: 'llm_routing',
|
||||||
|
width: 120,
|
||||||
|
hideInSearch: true,
|
||||||
|
valueType: 'select',
|
||||||
|
valueEnum: {
|
||||||
|
relay: { text: 'SaaS 中转', status: 'Success' },
|
||||||
|
local: { text: '本地直连', status: 'Default' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最后登录',
|
||||||
|
dataIndex: 'last_login_at',
|
||||||
|
width: 180,
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, record) => record.last_login_at ? new Date(record.last_login_at).toLocaleString('zh-CN') : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 200,
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
{record.status === 'active' ? (
|
||||||
|
<Popconfirm title="确定禁用此账号?" onConfirm={() => statusMutation.mutate({ id: record.id, status: 'disabled' })}>
|
||||||
|
<Button size="small" danger>禁用</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : (
|
||||||
|
<Popconfirm title="确定启用此账号?" onConfirm={() => statusMutation.mutate({ id: record.id, status: 'active' })}>
|
||||||
|
<Button size="small" type="primary">启用</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
if (!editingId) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 更新基础信息
|
||||||
|
const { industry_ids, plan_id, ...accountData } = values
|
||||||
|
await updateMutation.mutateAsync({ id: editingId, data: accountData })
|
||||||
|
|
||||||
|
// 更新行业授权(如果变更了)
|
||||||
|
const newIndustryIds: string[] = industry_ids || []
|
||||||
|
const oldIndustryIds = accountIndustries?.map((i) => i.industry_id) || []
|
||||||
|
const changed = newIndustryIds.length !== oldIndustryIds.length
|
||||||
|
|| newIndustryIds.some((id) => !oldIndustryIds.includes(id))
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
await setIndustriesMutation.mutateAsync({ accountId: editingId, industries: newIndustryIds })
|
||||||
|
message.success('行业授权已更新')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['account-industries'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换订阅计划(如果选择了新计划)
|
||||||
|
if (plan_id) {
|
||||||
|
await switchPlanMutation.mutateAsync({ accountId: editingId, planId: plan_id })
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClose()
|
||||||
|
} catch {
|
||||||
|
// Errors handled by mutation onError callbacks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setModalOpen(false)
|
||||||
|
setEditingId(null)
|
||||||
|
form.resetFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
const industryOptions = (industriesData?.items || []).map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: `${item.icon} ${item.name}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const planOptions = (plansData || []).map((plan) => ({
|
||||||
|
value: plan.id,
|
||||||
|
label: `${plan.display_name} (¥${(plan.price_cents / 100).toFixed(0)}/月)`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="账号管理" description="管理系统用户账号、角色、权限与行业授权" />
|
||||||
|
|
||||||
|
<ProTable<AccountPublic>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={{}}
|
||||||
|
toolBarRender={() => []}
|
||||||
|
onSubmit={(values) => {
|
||||||
|
const filtered: Record<string, string> = {}
|
||||||
|
for (const [k, v] of Object.entries(values)) {
|
||||||
|
if (v !== undefined && v !== null && v !== '') {
|
||||||
|
if (k === 'username') {
|
||||||
|
filtered.search = String(v)
|
||||||
|
} else {
|
||||||
|
filtered[k] = String(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSearchParams(filtered)
|
||||||
|
}}
|
||||||
|
onReset={() => setSearchParams({})}
|
||||||
|
pagination={{
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
pageSize: data?.page_size ?? 20,
|
||||||
|
current: data?.page ?? 1,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={<span className="text-base font-semibold">编辑账号</span>}
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleSave}
|
||||||
|
onCancel={handleClose}
|
||||||
|
confirmLoading={updateMutation.isPending || setIndustriesMutation.isPending || switchPlanMutation.isPending}
|
||||||
|
width={560}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" className="mt-4">
|
||||||
|
<Form.Item name="display_name" label="显示名">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="email" label="邮箱">
|
||||||
|
<Input type="email" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="role" label="角色">
|
||||||
|
<Select options={[
|
||||||
|
{ value: 'super_admin', label: '超级管理员' },
|
||||||
|
{ value: 'admin', label: '管理员' },
|
||||||
|
{ value: 'user', label: '用户' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="llm_routing" label="LLM 路由模式">
|
||||||
|
<Select options={[
|
||||||
|
{ value: 'local', label: '本地直连' },
|
||||||
|
{ value: 'relay', label: 'SaaS 中转 (Token 池)' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Divider>订阅计划</Divider>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="plan_id"
|
||||||
|
label="切换计划"
|
||||||
|
extra="选择新计划后保存将立即切换。留空则不修改当前计划。"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="不修改当前计划"
|
||||||
|
options={planOptions}
|
||||||
|
loading={!plansData}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Divider>行业授权</Divider>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="industry_ids"
|
||||||
|
label="授权行业"
|
||||||
|
extra="第一个行业将设为主行业。行业决定管家可触达的知识域和技能优先级。"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="选择授权的行业"
|
||||||
|
options={industryOptions}
|
||||||
|
loading={!industriesData}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
258
admin-v2/src/pages/AgentTemplates.tsx
Normal file
258
admin-v2/src/pages/AgentTemplates.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
// ============================================================
|
||||||
|
// Agent 模板管理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Button, message, Tag, Modal, Form, Input, Select, InputNumber, Space, Popconfirm, Descriptions } from 'antd'
|
||||||
|
import { PlusOutlined, MinusCircleOutlined } from '@ant-design/icons'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { agentTemplateService } from '@/services/agent-templates'
|
||||||
|
import type { AgentTemplate } from '@/types'
|
||||||
|
|
||||||
|
const { TextArea } = Input
|
||||||
|
|
||||||
|
const sourceLabels: Record<string, string> = { builtin: '内置', custom: '自定义' }
|
||||||
|
const visibilityLabels: Record<string, string> = { public: '公开', team: '团队', private: '私有' }
|
||||||
|
const statusLabels: Record<string, string> = { active: '活跃', archived: '已归档' }
|
||||||
|
const statusColors: Record<string, string> = { active: 'green', archived: 'default' }
|
||||||
|
|
||||||
|
export default function AgentTemplates() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [detailRecord, setDetailRecord] = useState<AgentTemplate | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['agent-templates'],
|
||||||
|
queryFn: ({ signal }) => agentTemplateService.list(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: Parameters<typeof agentTemplateService.create>[0]) =>
|
||||||
|
agentTemplateService.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('创建成功')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agent-templates'] })
|
||||||
|
setModalOpen(false)
|
||||||
|
form.resetFields()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const archiveMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => agentTemplateService.archive(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('已归档')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agent-templates'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '归档失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ProColumns<AgentTemplate>[] = [
|
||||||
|
{ title: '图标', dataIndex: 'emoji', width: 60 },
|
||||||
|
{ title: '名称', dataIndex: 'name', width: 160 },
|
||||||
|
{ title: '分类', dataIndex: 'category', width: 100 },
|
||||||
|
{ title: '模型', dataIndex: 'model', width: 140, render: (_, r) => r.model || '-' },
|
||||||
|
{
|
||||||
|
title: '来源',
|
||||||
|
dataIndex: 'source',
|
||||||
|
width: 80,
|
||||||
|
render: (_, r) => <Tag>{sourceLabels[r.source] || r.source}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '可见性',
|
||||||
|
dataIndex: 'visibility',
|
||||||
|
width: 80,
|
||||||
|
render: (_, r) => <Tag color="blue">{visibilityLabels[r.visibility] || r.visibility}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 80,
|
||||||
|
render: (_, r) => <Tag color={statusColors[r.status]}>{statusLabels[r.status] || r.status}</Tag>,
|
||||||
|
},
|
||||||
|
{ title: '版本', dataIndex: 'current_version', width: 70 },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 180,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => setDetailRecord(record)}>详情</Button>
|
||||||
|
{record.status === 'active' && (
|
||||||
|
<Popconfirm title="确定归档此模板?" onConfirm={() => archiveMutation.mutate(record.id)}>
|
||||||
|
<Button size="small" danger>归档</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
createMutation.mutate(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ProTable<AgentTemplate>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true) }}>
|
||||||
|
新建模板
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
pagination={{
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
pageSize: data?.page_size ?? 20,
|
||||||
|
current: data?.page ?? 1,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="新建 Agent 模板"
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleCreate}
|
||||||
|
onCancel={() => { setModalOpen(false); form.resetFields() }}
|
||||||
|
confirmLoading={createMutation.isPending}
|
||||||
|
width={640}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="描述">
|
||||||
|
<TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="category" label="分类">
|
||||||
|
<Input placeholder="如 assistant, tool" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="model" label="默认模型">
|
||||||
|
<Input placeholder="如 gpt-4o" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="system_prompt" label="系统提示词">
|
||||||
|
<TextArea rows={4} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="temperature" label="Temperature">
|
||||||
|
<InputNumber min={0} max={2} step={0.1} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="max_tokens" label="最大 Token">
|
||||||
|
<InputNumber min={1} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="visibility" label="可见性">
|
||||||
|
<Select options={[
|
||||||
|
{ value: 'public', label: '公开' },
|
||||||
|
{ value: 'team', label: '团队' },
|
||||||
|
{ value: 'private', label: '私有' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="emoji" label="图标">
|
||||||
|
<Input placeholder="如 🏥" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="personality" label="人格预设">
|
||||||
|
<Select options={[
|
||||||
|
{ value: 'professional', label: '专业' },
|
||||||
|
{ value: 'friendly', label: '友好' },
|
||||||
|
{ value: 'creative', label: '创意' },
|
||||||
|
{ value: 'concise', label: '简洁' },
|
||||||
|
]} allowClear placeholder="选择人格预设" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="soul_content" label="SOUL.md 人格配置">
|
||||||
|
<TextArea rows={8} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="welcome_message" label="欢迎语">
|
||||||
|
<TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="communication_style" label="沟通风格">
|
||||||
|
<TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="source_id" label="模板标识">
|
||||||
|
<Input placeholder="如 medical-assistant-v1" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="scenarios" label="使用场景">
|
||||||
|
<Select mode="tags" placeholder="输入场景标签后按回车" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.List name="quick_commands">
|
||||||
|
{(fields, { add, remove }) => (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: 8, fontWeight: 500 }}>快捷命令</div>
|
||||||
|
{fields.map(({ key, name, ...restField }) => (
|
||||||
|
<Space key={key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
|
||||||
|
<Form.Item {...restField} name={[name, 'label']} rules={[{ required: true, message: '请输入标签' }]}>
|
||||||
|
<Input placeholder="标签" style={{ width: 140 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item {...restField} name={[name, 'command']} rules={[{ required: true, message: '请输入命令' }]}>
|
||||||
|
<Input placeholder="命令/提示词" style={{ width: 280 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<MinusCircleOutlined onClick={() => remove(name)} />
|
||||||
|
</Space>
|
||||||
|
))}
|
||||||
|
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||||
|
添加快捷命令
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="模板详情"
|
||||||
|
open={!!detailRecord}
|
||||||
|
onCancel={() => setDetailRecord(null)}
|
||||||
|
footer={null}
|
||||||
|
width={640}
|
||||||
|
>
|
||||||
|
{detailRecord && (
|
||||||
|
<Descriptions column={2} bordered size="small">
|
||||||
|
<Descriptions.Item label="图标">{detailRecord.emoji || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="名称">{detailRecord.name}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="分类">{detailRecord.category}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="模型">{detailRecord.model || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="来源">{sourceLabels[detailRecord.source]}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="可见性">{visibilityLabels[detailRecord.visibility]}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">{statusLabels[detailRecord.status]}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="版本">{detailRecord.version ?? detailRecord.current_version}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="描述" span={2}>{detailRecord.description || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="人格预设">{detailRecord.personality || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="沟通风格">{detailRecord.communication_style || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="模板标识" span={2}>{detailRecord.source_id || '-'}</Descriptions.Item>
|
||||||
|
{detailRecord.welcome_message && (
|
||||||
|
<Descriptions.Item label="欢迎语" span={2}>{detailRecord.welcome_message}</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
{detailRecord.scenarios && detailRecord.scenarios.length > 0 && (
|
||||||
|
<Descriptions.Item label="使用场景" span={2}>
|
||||||
|
{detailRecord.scenarios.map((s) => <Tag key={s}>{s}</Tag>)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
<Descriptions.Item label="系统提示词" span={2}>
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap', maxHeight: 200, overflow: 'auto' }}>
|
||||||
|
{detailRecord.system_prompt || '-'}
|
||||||
|
</div>
|
||||||
|
</Descriptions.Item>
|
||||||
|
{detailRecord.soul_content && (
|
||||||
|
<Descriptions.Item label="SOUL.md 人格配置" span={2}>
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap', maxHeight: 200, overflow: 'auto' }}>
|
||||||
|
{detailRecord.soul_content}
|
||||||
|
</div>
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
<Descriptions.Item label="工具" span={2}>
|
||||||
|
{detailRecord.tools?.map((t) => <Tag key={t}>{t}</Tag>) || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="能力" span={2}>
|
||||||
|
{detailRecord.capabilities?.map((c) => <Tag key={c} color="blue">{c}</Tag>) || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
169
admin-v2/src/pages/ApiKeys.tsx
Normal file
169
admin-v2/src/pages/ApiKeys.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Button, message, Tag, Modal, Form, Input, InputNumber, Select, Space, Popconfirm, Typography } from 'antd'
|
||||||
|
import { PlusOutlined, CopyOutlined } from '@ant-design/icons'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { apiKeyService } from '@/services/api-keys'
|
||||||
|
import type { TokenInfo } from '@/types'
|
||||||
|
|
||||||
|
const { Text, Paragraph } = Typography
|
||||||
|
|
||||||
|
const PERMISSION_OPTIONS = [
|
||||||
|
{ label: 'Relay Chat', value: 'relay:use' },
|
||||||
|
{ label: 'Knowledge Read', value: 'knowledge:read' },
|
||||||
|
{ label: 'Knowledge Write', value: 'knowledge:write' },
|
||||||
|
{ label: 'Agent Read', value: 'agent:read' },
|
||||||
|
{ label: 'Agent Write', value: 'agent:write' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function ApiKeys() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [newToken, setNewToken] = useState<string | null>(null)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(20)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['api-keys', page, pageSize],
|
||||||
|
queryFn: ({ signal }) => apiKeyService.list({ page, page_size: pageSize }, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (values: { name: string; expires_days?: number; permissions: string[] }) =>
|
||||||
|
apiKeyService.create(values),
|
||||||
|
onSuccess: (result: TokenInfo) => {
|
||||||
|
message.success('API 密钥创建成功')
|
||||||
|
if (result.token) {
|
||||||
|
setNewToken(result.token)
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
|
||||||
|
form.resetFields()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const revokeMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => apiKeyService.revoke(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('密钥已吊销')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '吊销失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
createMutation.mutate(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ProColumns<TokenInfo>[] = [
|
||||||
|
{ title: '名称', dataIndex: 'name', width: 180 },
|
||||||
|
{
|
||||||
|
title: '前缀',
|
||||||
|
dataIndex: 'token_prefix',
|
||||||
|
width: 120,
|
||||||
|
render: (val: string) => <Text code>{val}...</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '权限',
|
||||||
|
dataIndex: 'permissions',
|
||||||
|
width: 240,
|
||||||
|
render: (perms: string[]) =>
|
||||||
|
perms?.map((p) => <Tag key={p}>{p}</Tag>) || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最后使用',
|
||||||
|
dataIndex: 'last_used_at',
|
||||||
|
width: 180,
|
||||||
|
render: (val: string) => (val ? new Date(val).toLocaleString() : <Text type="secondary">从未使用</Text>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '过期时间',
|
||||||
|
dataIndex: 'expires_at',
|
||||||
|
width: 180,
|
||||||
|
render: (val: string) =>
|
||||||
|
val ? new Date(val).toLocaleString() : <Text type="secondary">永不过期</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
width: 180,
|
||||||
|
render: (val: string) => new Date(val).toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 100,
|
||||||
|
render: (_: unknown, record: TokenInfo) => (
|
||||||
|
<Popconfirm
|
||||||
|
title="确定吊销此密钥?"
|
||||||
|
description="吊销后使用该密钥的所有请求将被拒绝"
|
||||||
|
onConfirm={() => revokeMutation.mutate(record.id)}
|
||||||
|
>
|
||||||
|
<Button danger size="small">吊销</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<ProTable<TokenInfo>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items || []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: data?.total || 0,
|
||||||
|
onChange: (p, ps) => { setPage(p); setPageSize(ps) },
|
||||||
|
}}
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||||
|
创建密钥
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="创建 API 密钥"
|
||||||
|
open={createOpen}
|
||||||
|
onOk={handleCreate}
|
||||||
|
onCancel={() => { setCreateOpen(false); setNewToken(null); form.resetFields() }}
|
||||||
|
confirmLoading={createMutation.isPending}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
{newToken ? (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Paragraph type="warning">
|
||||||
|
请立即复制密钥,关闭后将无法再次查看。
|
||||||
|
</Paragraph>
|
||||||
|
<Space>
|
||||||
|
<Text code style={{ fontSize: 13 }}>{newToken}</Text>
|
||||||
|
<Button
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
size="small"
|
||||||
|
onClick={() => { navigator.clipboard.writeText(newToken); message.success('已复制') }}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="name" label="密钥名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||||
|
<Input placeholder="例如: 生产环境 API Key" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="expires_days" label="有效期 (天)">
|
||||||
|
<InputNumber min={1} max={3650} placeholder="留空表示永不过期" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="permissions" label="权限" rules={[{ required: true, message: '请选择至少一项权限' }]}>
|
||||||
|
<Select mode="multiple" options={PERMISSION_OPTIONS} placeholder="选择权限" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
352
admin-v2/src/pages/Billing.tsx
Normal file
352
admin-v2/src/pages/Billing.tsx
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 计费管理 — 计划/订阅/用量/支付
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Button, message, Tag, Modal, Card, Row, Col, Statistic, Typography,
|
||||||
|
Progress, Space, Radio, Spin, Empty, Divider,
|
||||||
|
} from 'antd'
|
||||||
|
import {
|
||||||
|
CrownOutlined, CheckCircleOutlined, ThunderboltOutlined,
|
||||||
|
RocketOutlined, TeamOutlined, AlipayCircleOutlined,
|
||||||
|
WechatOutlined, LoadingOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { ErrorState } from '@/components/ErrorState'
|
||||||
|
import { billingService } from '@/services/billing'
|
||||||
|
import type { BillingPlan, SubscriptionInfo, PaymentResult } from '@/services/billing'
|
||||||
|
|
||||||
|
const { Text, Title } = Typography
|
||||||
|
|
||||||
|
// === 计划卡片 ===
|
||||||
|
|
||||||
|
const planIcons: Record<string, React.ReactNode> = {
|
||||||
|
free: <RocketOutlined style={{ fontSize: 24 }} />,
|
||||||
|
pro: <ThunderboltOutlined style={{ fontSize: 24 }} />,
|
||||||
|
team: <TeamOutlined style={{ fontSize: 24 }} />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const planColors: Record<string, string> = {
|
||||||
|
free: '#8c8c8c',
|
||||||
|
pro: '#863bff',
|
||||||
|
team: '#47bfff',
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlanCard({
|
||||||
|
plan,
|
||||||
|
isCurrent,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
plan: BillingPlan
|
||||||
|
isCurrent: boolean
|
||||||
|
onSelect: (plan: BillingPlan) => void
|
||||||
|
}) {
|
||||||
|
const color = planColors[plan.name] || '#666'
|
||||||
|
const limits = plan.limits as Record<string, unknown> | undefined
|
||||||
|
const maxRelay = (limits?.max_relay_requests_monthly as number) ?? '∞'
|
||||||
|
const maxHand = (limits?.max_hand_executions_monthly as number) ?? '∞'
|
||||||
|
const maxPipeline = (limits?.max_pipeline_runs_monthly as number) ?? '∞'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={`relative overflow-hidden transition-all duration-200 hover:shadow-lg ${
|
||||||
|
isCurrent ? 'ring-2 ring-offset-2' : ''
|
||||||
|
}`}
|
||||||
|
style={isCurrent ? { borderColor: color, '--tw-ring-color': color } as React.CSSProperties : {}}
|
||||||
|
>
|
||||||
|
{isCurrent && (
|
||||||
|
<div
|
||||||
|
className="absolute top-0 right-0 px-3 py-1 text-xs font-medium text-white rounded-bl-lg"
|
||||||
|
style={{ background: color }}
|
||||||
|
>
|
||||||
|
当前计划
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<div style={{ color }} className="mb-2">
|
||||||
|
{planIcons[plan.name] || <CrownOutlined style={{ fontSize: 24 }} />}
|
||||||
|
</div>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>{plan.display_name}</Title>
|
||||||
|
{plan.description && (
|
||||||
|
<Text type="secondary" className="text-sm">{plan.description}</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<span className="text-3xl font-bold" style={{ color }}>
|
||||||
|
¥{plan.price_cents === 0 ? '0' : (plan.price_cents / 100).toFixed(0)}
|
||||||
|
</span>
|
||||||
|
<Text type="secondary"> /{plan.interval === 'month' ? '月' : '年'}</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||||
|
<span>中转请求: {maxRelay === Infinity ? '无限' : `${maxRelay} 次/月`}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||||
|
<span>Hand 执行: {maxHand === Infinity ? '无限' : `${maxHand} 次/月`}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||||
|
<span>Pipeline 运行: {maxPipeline === Infinity ? '无限' : `${maxPipeline} 次/月`}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||||
|
<span>知识库: {plan.name === 'free' ? '基础' : '高级'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||||
|
<span>优先级队列: {plan.name === 'team' ? '最高' : plan.name === 'pro' ? '高' : '标准'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
block
|
||||||
|
type={isCurrent ? 'default' : 'primary'}
|
||||||
|
disabled={isCurrent}
|
||||||
|
onClick={() => onSelect(plan)}
|
||||||
|
style={!isCurrent ? { background: color, borderColor: color } : {}}
|
||||||
|
>
|
||||||
|
{isCurrent ? '当前计划' : '升级'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 用量进度条 ===
|
||||||
|
|
||||||
|
function UsageBar({ label, current, max }: { label: string; current: number; max: number | null }) {
|
||||||
|
const pct = max ? Math.min((current / max) * 100, 100) : 0
|
||||||
|
const displayMax = max ? max.toLocaleString() : '∞'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="flex justify-between text-xs text-neutral-500 dark:text-neutral-400 mb-1">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span>{current.toLocaleString()} / {displayMax}</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
percent={pct}
|
||||||
|
showInfo={false}
|
||||||
|
strokeColor={pct >= 90 ? '#ff4d4f' : pct >= 70 ? '#faad14' : '#863bff'}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 主页面 ===
|
||||||
|
|
||||||
|
export default function Billing() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [payModalOpen, setPayModalOpen] = useState(false)
|
||||||
|
const [selectedPlan, setSelectedPlan] = useState<BillingPlan | null>(null)
|
||||||
|
const [payMethod, setPayMethod] = useState<'alipay' | 'wechat'>('alipay')
|
||||||
|
const [payResult, setPayResult] = useState<PaymentResult | null>(null)
|
||||||
|
const [pollingPayment, setPollingPayment] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data: plans = [], isLoading: plansLoading, error: plansError, refetch } = useQuery({
|
||||||
|
queryKey: ['billing-plans'],
|
||||||
|
queryFn: ({ signal }) => billingService.listPlans(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: subInfo, isLoading: subLoading } = useQuery({
|
||||||
|
queryKey: ['billing-subscription'],
|
||||||
|
queryFn: ({ signal }) => billingService.getSubscription(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 支付状态轮询
|
||||||
|
const { data: paymentStatus } = useQuery({
|
||||||
|
queryKey: ['payment-status', pollingPayment],
|
||||||
|
queryFn: ({ signal }) => billingService.getPaymentStatus(pollingPayment!, signal),
|
||||||
|
enabled: !!pollingPayment,
|
||||||
|
refetchInterval: pollingPayment ? 3000 : false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 支付成功后刷新
|
||||||
|
if (paymentStatus?.status === 'succeeded' && pollingPayment) {
|
||||||
|
setPollingPayment(null)
|
||||||
|
setPayModalOpen(false)
|
||||||
|
setPayResult(null)
|
||||||
|
message.success('支付成功!计划已更新')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['billing-subscription'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const createPaymentMutation = useMutation({
|
||||||
|
mutationFn: (data: { plan_id: string; payment_method: 'alipay' | 'wechat' }) =>
|
||||||
|
billingService.createPayment(data),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setPayResult(result)
|
||||||
|
setPollingPayment(result.payment_id)
|
||||||
|
// 打开支付链接
|
||||||
|
window.open(result.pay_url, '_blank', 'width=480,height=640')
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '创建支付失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSelectPlan = (plan: BillingPlan) => {
|
||||||
|
if (plan.price_cents === 0) return
|
||||||
|
setSelectedPlan(plan)
|
||||||
|
setPayResult(null)
|
||||||
|
setPayModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmPay = () => {
|
||||||
|
if (!selectedPlan) return
|
||||||
|
createPaymentMutation.mutate({
|
||||||
|
plan_id: selectedPlan.id,
|
||||||
|
payment_method: payMethod,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plansError) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="计费管理" description="管理订阅计划和用量配额" />
|
||||||
|
<ErrorState message={(plansError as Error).message} onRetry={() => refetch()} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPlanName = subInfo?.plan?.name || 'free'
|
||||||
|
const usage = subInfo?.usage
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="计费管理" description="管理订阅计划和用量配额" />
|
||||||
|
|
||||||
|
{/* 当前计划 + 用量 */}
|
||||||
|
{subInfo && usage && (
|
||||||
|
<Card className="mb-6" title={<span className="text-sm font-semibold">当前用量</span>}>
|
||||||
|
<Row gutter={[24, 16]}>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<UsageBar
|
||||||
|
label="中转请求"
|
||||||
|
current={usage.relay_requests}
|
||||||
|
max={usage.max_relay_requests}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<UsageBar
|
||||||
|
label="Hand 执行"
|
||||||
|
current={usage.hand_executions}
|
||||||
|
max={usage.max_hand_executions}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<UsageBar
|
||||||
|
label="Pipeline 运行"
|
||||||
|
current={usage.pipeline_runs}
|
||||||
|
max={usage.max_pipeline_runs}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{subInfo.subscription && (
|
||||||
|
<div className="mt-4 text-xs text-neutral-400">
|
||||||
|
订阅周期: {new Date(subInfo.subscription.current_period_start).toLocaleDateString()} — {new Date(subInfo.subscription.current_period_end).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 计划选择 */}
|
||||||
|
<Title level={5} className="mb-4">选择计划</Title>
|
||||||
|
|
||||||
|
{plansLoading ? (
|
||||||
|
<div className="flex justify-center py-8"><Spin /></div>
|
||||||
|
) : (
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{plans.map((plan) => (
|
||||||
|
<Col key={plan.id} xs={24} sm={12} lg={8}>
|
||||||
|
<PlanCard
|
||||||
|
plan={plan}
|
||||||
|
isCurrent={plan.name === currentPlanName}
|
||||||
|
onSelect={handleSelectPlan}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 支付弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title={selectedPlan ? `升级到 ${selectedPlan.display_name}` : '支付'}
|
||||||
|
open={payModalOpen}
|
||||||
|
onCancel={() => {
|
||||||
|
setPayModalOpen(false)
|
||||||
|
setPollingPayment(null)
|
||||||
|
setPayResult(null)
|
||||||
|
}}
|
||||||
|
footer={payResult ? null : undefined}
|
||||||
|
onOk={handleConfirmPay}
|
||||||
|
okText={createPaymentMutation.isPending ? '处理中...' : '确认支付'}
|
||||||
|
confirmLoading={createPaymentMutation.isPending}
|
||||||
|
>
|
||||||
|
{payResult ? (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<LoadingOutlined style={{ fontSize: 32, color: '#863bff' }} className="mb-4" />
|
||||||
|
<Title level={5}>等待支付确认...</Title>
|
||||||
|
<Text type="secondary">
|
||||||
|
支付窗口已打开,请在新窗口完成支付。
|
||||||
|
<br />
|
||||||
|
支付金额: ¥{(payResult.amount_cents / 100).toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button onClick={() => { setPollingPayment(null); setPayModalOpen(false); setPayResult(null) }}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{selectedPlan && (
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<div className="text-2xl font-bold" style={{ color: planColors[selectedPlan.name] || '#666' }}>
|
||||||
|
¥{(selectedPlan.price_cents / 100).toFixed(0)}
|
||||||
|
</div>
|
||||||
|
<Text type="secondary">/{selectedPlan.interval === 'month' ? '月' : '年'}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Title level={5} className="text-center mb-4">选择支付方式</Title>
|
||||||
|
|
||||||
|
<Radio.Group
|
||||||
|
value={payMethod}
|
||||||
|
onChange={(e) => setPayMethod(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Space direction="vertical" className="w-full" size={12}>
|
||||||
|
<Radio value="alipay" className="w-full">
|
||||||
|
<div className="flex items-center gap-3 p-3 border rounded-lg w-full hover:border-blue-400 transition-colors">
|
||||||
|
<AlipayCircleOutlined style={{ fontSize: 28, color: '#1677ff' }} />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">支付宝</div>
|
||||||
|
<div className="text-xs text-neutral-400">推荐个人用户</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Radio>
|
||||||
|
<Radio value="wechat" className="w-full">
|
||||||
|
<div className="flex items-center gap-3 p-3 border rounded-lg w-full hover:border-green-400 transition-colors">
|
||||||
|
<WechatOutlined style={{ fontSize: 28, color: '#07c160' }} />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">微信支付</div>
|
||||||
|
<div className="text-xs text-neutral-400">扫码支付</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Radio>
|
||||||
|
</Space>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
110
admin-v2/src/pages/Config.tsx
Normal file
110
admin-v2/src/pages/Config.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 系统配置
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Card, Tabs, message, Tag, Input, Button, Space, Typography } from 'antd'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { configService } from '@/services/config'
|
||||||
|
import type { ConfigItem } from '@/types'
|
||||||
|
|
||||||
|
const { Title } = Typography
|
||||||
|
|
||||||
|
export default function Config() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [category, setCategory] = useState<string>('general')
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [editValue, setEditValue] = useState('')
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['config', category],
|
||||||
|
queryFn: ({ signal }) => configService.list({ category }, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, value }: { id: string; value: string }) =>
|
||||||
|
configService.update(id, { value }),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('配置已更新')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['config', category] })
|
||||||
|
setEditingId(null)
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ProColumns<ConfigItem>[] = [
|
||||||
|
{ title: '配置路径', dataIndex: 'key_path', width: 200, render: (_, r) => <code>{r.key_path}</code> },
|
||||||
|
{
|
||||||
|
title: '当前值',
|
||||||
|
dataIndex: 'current_value',
|
||||||
|
width: 250,
|
||||||
|
render: (_, record) => {
|
||||||
|
if (editingId === record.id) {
|
||||||
|
return (
|
||||||
|
<Space>
|
||||||
|
<Input
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
style={{ width: 180 }}
|
||||||
|
onPressEnter={() => updateMutation.mutate({ id: record.id, value: editValue })}
|
||||||
|
/>
|
||||||
|
<Button size="small" type="primary" onClick={() => updateMutation.mutate({ id: record.id, value: editValue })}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={() => setEditingId(null)}>取消</Button>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
onClick={() => { setEditingId(record.id); setEditValue(record.current_value || '') }}
|
||||||
|
style={{ cursor: 'pointer', color: '#1677ff' }}
|
||||||
|
>
|
||||||
|
{record.current_value || <Tag>未设置</Tag>}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: '默认值', dataIndex: 'default_value', width: 200, render: (_, r) => r.default_value || '-' },
|
||||||
|
{ title: '类型', dataIndex: 'value_type', width: 80, render: (_, r) => <Tag>{r.value_type}</Tag> },
|
||||||
|
{ title: '描述', dataIndex: 'description', width: 200, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '需要重启',
|
||||||
|
dataIndex: 'requires_restart',
|
||||||
|
width: 90,
|
||||||
|
render: (_, r) => r.requires_restart ? <Tag color="orange">是</Tag> : <Tag>否</Tag>,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title level={4} style={{ marginBottom: 24 }}>系统配置</Title>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
activeKey={category}
|
||||||
|
onChange={(key) => { setCategory(key); setEditingId(null) }}
|
||||||
|
items={[
|
||||||
|
{ key: 'general', label: '通用' },
|
||||||
|
{ key: 'auth', label: '认证' },
|
||||||
|
{ key: 'relay', label: '中转' },
|
||||||
|
{ key: 'model', label: '模型' },
|
||||||
|
{ key: 'rate_limit', label: '限流' },
|
||||||
|
{ key: 'log', label: '日志' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProTable<ConfigItem>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={false}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
111
admin-v2/src/pages/ConfigSync.tsx
Normal file
111
admin-v2/src/pages/ConfigSync.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 配置同步日志
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Tag, Typography } from 'antd'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { configSyncService } from '@/services/config-sync'
|
||||||
|
import type { ConfigSyncLog } from '@/types'
|
||||||
|
|
||||||
|
const { Title } = Typography
|
||||||
|
|
||||||
|
const actionLabels: Record<string, string> = {
|
||||||
|
push: '推送',
|
||||||
|
merge: '合并',
|
||||||
|
pull: '拉取',
|
||||||
|
diff: '差异',
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionColors: Record<string, string> = {
|
||||||
|
push: 'blue',
|
||||||
|
merge: 'green',
|
||||||
|
pull: 'cyan',
|
||||||
|
diff: 'orange',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfigSync() {
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['config-sync', page],
|
||||||
|
queryFn: ({ signal }) => configSyncService.list({ page, page_size: 20 }, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ProColumns<ConfigSyncLog>[] = [
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
dataIndex: 'action',
|
||||||
|
width: 100,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Tag color={actionColors[r.action] || 'default'}>
|
||||||
|
{actionLabels[r.action] || r.action}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '客户端指纹',
|
||||||
|
dataIndex: 'client_fingerprint',
|
||||||
|
width: 160,
|
||||||
|
render: (_, r) => <code>{r.client_fingerprint.substring(0, 16)}...</code>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '配置键',
|
||||||
|
dataIndex: 'config_keys',
|
||||||
|
width: 200,
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '客户端值',
|
||||||
|
dataIndex: 'client_values',
|
||||||
|
width: 150,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, r) => r.client_values || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '服务端值',
|
||||||
|
dataIndex: 'saas_values',
|
||||||
|
width: 150,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, r) => r.saas_values || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '解决方式',
|
||||||
|
dataIndex: 'resolution',
|
||||||
|
width: 120,
|
||||||
|
render: (_, r) => r.resolution || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
width: 180,
|
||||||
|
render: (_, r) => new Date(r.created_at).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>配置同步日志</Title>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProTable<ConfigSyncLog>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={false}
|
||||||
|
pagination={{
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
pageSize: 20,
|
||||||
|
current: page,
|
||||||
|
onChange: setPage,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
148
admin-v2/src/pages/Dashboard.tsx
Normal file
148
admin-v2/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 仪表盘页面
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Card, Col, Row, Statistic, Table, Tag, Spin } from 'antd'
|
||||||
|
import {
|
||||||
|
TeamOutlined,
|
||||||
|
CloudServerOutlined,
|
||||||
|
ApiOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
ColumnWidthOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { statsService } from '@/services/stats'
|
||||||
|
import { logService } from '@/services/logs'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { ErrorState } from '@/components/ErrorState'
|
||||||
|
import { actionLabels, actionColors } from '@/constants/status'
|
||||||
|
import type { OperationLog } from '@/types'
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const {
|
||||||
|
data: stats,
|
||||||
|
isLoading: statsLoading,
|
||||||
|
error: statsError,
|
||||||
|
refetch: refetchStats,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['dashboard-stats'],
|
||||||
|
queryFn: ({ signal }) => statsService.dashboard(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: logsData, isLoading: logsLoading } = useQuery({
|
||||||
|
queryKey: ['recent-logs'],
|
||||||
|
queryFn: ({ signal }) => logService.list({ page: 1, page_size: 10 }, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (statsError) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="仪表盘" description="系统概览与最近活动" />
|
||||||
|
<ErrorState
|
||||||
|
message={(statsError as Error).message}
|
||||||
|
onRetry={() => refetchStats()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{ title: '总账号', value: stats?.total_accounts ?? 0, icon: <TeamOutlined />, color: '#863bff' },
|
||||||
|
{ title: '活跃服务商', value: stats?.active_providers ?? 0, icon: <CloudServerOutlined />, color: '#47bfff' },
|
||||||
|
{ title: '活跃模型', value: stats?.active_models ?? 0, icon: <ApiOutlined />, color: '#22c55e' },
|
||||||
|
{ title: '今日请求', value: stats?.tasks_today ?? 0, icon: <ThunderboltOutlined />, color: '#f59e0b' },
|
||||||
|
{
|
||||||
|
title: '今日 Token',
|
||||||
|
value: (stats?.tokens_today_input ?? 0) + (stats?.tokens_today_output ?? 0),
|
||||||
|
icon: <ColumnWidthOutlined />,
|
||||||
|
color: '#ef4444',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const logColumns = [
|
||||||
|
{
|
||||||
|
title: '操作类型',
|
||||||
|
dataIndex: 'action',
|
||||||
|
key: 'action',
|
||||||
|
width: 140,
|
||||||
|
render: (action: string) => (
|
||||||
|
<Tag color={actionColors[action] || 'default'}>
|
||||||
|
{actionLabels[action] || action}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '目标类型',
|
||||||
|
dataIndex: 'target_type',
|
||||||
|
key: 'target_type',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string | null) => v || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
width: 180,
|
||||||
|
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="仪表盘" description="系统概览与最近活动" />
|
||||||
|
|
||||||
|
{/* Stat Cards */}
|
||||||
|
<Row gutter={[16, 16]} className="mb-6">
|
||||||
|
{statsLoading ? (
|
||||||
|
<Col span={24}>
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
) : (
|
||||||
|
statCards.map((card) => (
|
||||||
|
<Col xs={24} sm={12} md={8} lg={4} key={card.title}>
|
||||||
|
<Card
|
||||||
|
className="hover:shadow-md transition-shadow duration-200"
|
||||||
|
styles={{ body: { padding: '20px 24px' } }}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
title={
|
||||||
|
<span className="text-neutral-500 dark:text-neutral-400 text-xs font-medium uppercase tracking-wide">
|
||||||
|
{card.title}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
value={card.value}
|
||||||
|
valueStyle={{ fontSize: 28, fontWeight: 600, color: card.color }}
|
||||||
|
prefix={
|
||||||
|
<span style={{ color: card.color, marginRight: 4 }}>{card.icon}</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Recent Logs */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||||
|
最近操作日志
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
|
<Table<OperationLog>
|
||||||
|
columns={logColumns}
|
||||||
|
dataSource={logsData?.items ?? []}
|
||||||
|
loading={logsLoading}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
379
admin-v2/src/pages/Industries.tsx
Normal file
379
admin-v2/src/pages/Industries.tsx
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 行业配置管理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Button, message, Tag, Modal, Form, Input, Select, Space, Popconfirm,
|
||||||
|
Tabs, Typography, Spin, Empty,
|
||||||
|
} from 'antd'
|
||||||
|
import {
|
||||||
|
PlusOutlined, EditOutlined, CheckCircleOutlined, StopOutlined,
|
||||||
|
ShopOutlined, SettingOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { industryService } from '@/services/industries'
|
||||||
|
import type { IndustryListItem, IndustryFullConfig, UpdateIndustryRequest } from '@/services/industries'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
|
||||||
|
const { TextArea } = Input
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = { active: '启用', inactive: '禁用' }
|
||||||
|
const statusColors: Record<string, string> = { active: 'green', inactive: 'default' }
|
||||||
|
const sourceLabels: Record<string, string> = { builtin: '内置', admin: '自定义', custom: '自定义' }
|
||||||
|
|
||||||
|
// === 行业列表 ===
|
||||||
|
|
||||||
|
function IndustryListPanel() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(20)
|
||||||
|
const [filters, setFilters] = useState<{ status?: string; source?: string }>({})
|
||||||
|
const [editId, setEditId] = useState<string | null>(null)
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['industries', page, pageSize, filters],
|
||||||
|
queryFn: ({ signal }) => industryService.list({ page, page_size: pageSize, ...filters }, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateStatusMutation = useMutation({
|
||||||
|
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||||||
|
industryService.update(id, { status }),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('状态已更新')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['industries'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ProColumns<IndustryListItem>[] = [
|
||||||
|
{
|
||||||
|
title: '图标',
|
||||||
|
dataIndex: 'icon',
|
||||||
|
width: 50,
|
||||||
|
search: false,
|
||||||
|
render: (_, r) => <span className="text-xl">{r.icon}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '行业名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
width: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '描述',
|
||||||
|
dataIndex: 'description',
|
||||||
|
width: 250,
|
||||||
|
search: false,
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '来源',
|
||||||
|
dataIndex: 'source',
|
||||||
|
width: 80,
|
||||||
|
valueType: 'select',
|
||||||
|
valueEnum: {
|
||||||
|
builtin: { text: '内置' },
|
||||||
|
admin: { text: '自定义' },
|
||||||
|
custom: { text: '自定义' },
|
||||||
|
},
|
||||||
|
render: (_, r) => <Tag color={r.source === 'builtin' ? 'blue' : 'purple'}>{sourceLabels[r.source] || r.source}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '关键词数',
|
||||||
|
dataIndex: 'keywords_count',
|
||||||
|
width: 90,
|
||||||
|
search: false,
|
||||||
|
render: (_, r) => <Tag>{r.keywords_count}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 80,
|
||||||
|
valueType: 'select',
|
||||||
|
valueEnum: {
|
||||||
|
active: { text: '启用', status: 'Success' },
|
||||||
|
inactive: { text: '禁用', status: 'Default' },
|
||||||
|
},
|
||||||
|
render: (_, r) => <Tag color={statusColors[r.status]}>{statusLabels[r.status] || r.status}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '更新时间',
|
||||||
|
dataIndex: 'updated_at',
|
||||||
|
width: 160,
|
||||||
|
valueType: 'dateTime',
|
||||||
|
search: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 180,
|
||||||
|
search: false,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => setEditId(r.id)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
{r.status === 'active' ? (
|
||||||
|
<Popconfirm title="确定禁用此行业?" onConfirm={() => updateStatusMutation.mutate({ id: r.id, status: 'inactive' })}>
|
||||||
|
<Button type="link" size="small" danger icon={<StopOutlined />}>禁用</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : (
|
||||||
|
<Popconfirm title="确定启用此行业?" onConfirm={() => updateStatusMutation.mutate({ id: r.id, status: 'active' })}>
|
||||||
|
<Button type="link" size="small" icon={<CheckCircleOutlined />}>启用</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ProTable<IndustryListItem>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items || []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={{
|
||||||
|
onReset: () => { setFilters({}); setPage(1) },
|
||||||
|
onSubmit: (values) => { setFilters(values); setPage(1) },
|
||||||
|
}}
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||||
|
新建行业
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: data?.total || 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => { setPage(p); setPageSize(ps) },
|
||||||
|
}}
|
||||||
|
options={{ density: false, fullScreen: false, reload: () => queryClient.invalidateQueries({ queryKey: ['industries'] }) }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IndustryEditModal
|
||||||
|
open={!!editId}
|
||||||
|
industryId={editId}
|
||||||
|
onClose={() => setEditId(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IndustryCreateModal
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 行业编辑弹窗 ===
|
||||||
|
|
||||||
|
function IndustryEditModal({ open, industryId, onClose }: {
|
||||||
|
open: boolean
|
||||||
|
industryId: string | null
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['industry-full-config', industryId],
|
||||||
|
queryFn: ({ signal }) => industryService.getFullConfig(industryId!, signal),
|
||||||
|
enabled: !!industryId,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data && open && data.id === industryId) {
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: data.name,
|
||||||
|
icon: data.icon,
|
||||||
|
description: data.description,
|
||||||
|
keywords: data.keywords,
|
||||||
|
system_prompt: data.system_prompt,
|
||||||
|
cold_start_template: data.cold_start_template,
|
||||||
|
pain_seed_categories: data.pain_seed_categories,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [data, open, industryId, form])
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (body: UpdateIndustryRequest) =>
|
||||||
|
industryService.update(industryId!, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('行业配置已更新')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['industries'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['industry-full-config'] })
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={<span className="text-base font-semibold">编辑行业配置 — {data?.name || ''}</span>}
|
||||||
|
open={open}
|
||||||
|
onCancel={() => { onClose(); form.resetFields() }}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
confirmLoading={updateMutation.isPending}
|
||||||
|
width={720}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-8"><Spin /></div>
|
||||||
|
) : data ? (
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
className="mt-4"
|
||||||
|
onFinish={(values) => updateMutation.mutate(values)}
|
||||||
|
>
|
||||||
|
<Form.Item name="name" label="行业名称" rules={[{ required: true, message: '请输入行业名称' }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="icon" label="图标">
|
||||||
|
<Input placeholder="行业图标 emoji,如 🏥" className="w-32" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="描述">
|
||||||
|
<TextArea rows={2} placeholder="行业简要描述" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="keywords" label="关键词列表" extra="用于语义路由匹配,回车添加">
|
||||||
|
<Select mode="tags" placeholder="输入关键词后回车添加" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="system_prompt" label="系统提示词" extra="匹配到此行业时注入的 system prompt">
|
||||||
|
<TextArea rows={6} placeholder="行业专属系统提示词模板" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="cold_start_template" label="冷启动模板" extra="首次匹配时的引导消息模板">
|
||||||
|
<TextArea rows={3} placeholder="冷启动引导消息" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="pain_seed_categories" label="痛点种子分类" extra="预置的痛点分类维度">
|
||||||
|
<Select mode="tags" placeholder="输入痛点分类后回车添加" />
|
||||||
|
</Form.Item>
|
||||||
|
<div className="mb-2">
|
||||||
|
<Text type="secondary">
|
||||||
|
来源: <Tag color={data.source === 'builtin' ? 'blue' : 'purple'}>{sourceLabels[data.source]}</Tag>
|
||||||
|
{' '}状态: <Tag color={statusColors[data.status]}>{statusLabels[data.status]}</Tag>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
) : (
|
||||||
|
<Empty description="未找到行业配置" />
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 新建行业弹窗 ===
|
||||||
|
|
||||||
|
function IndustryCreateModal({ open, onClose }: {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: Parameters<typeof industryService.create>[0]) =>
|
||||||
|
industryService.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('行业已创建')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['industries'] })
|
||||||
|
onClose()
|
||||||
|
form.resetFields()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="新建行业"
|
||||||
|
open={open}
|
||||||
|
onCancel={() => { onClose(); form.resetFields() }}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
confirmLoading={createMutation.isPending}
|
||||||
|
width={640}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
className="mt-4"
|
||||||
|
initialValues={{ icon: '🏢' }}
|
||||||
|
onFinish={(values) => {
|
||||||
|
// Auto-generate id from name if not provided
|
||||||
|
if (!values.id && values.name) {
|
||||||
|
// Strip non-ASCII, keep only lowercase alphanumeric + hyphens
|
||||||
|
const generated = values.name.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
if (generated) {
|
||||||
|
values.id = generated
|
||||||
|
} else {
|
||||||
|
// Name has no ASCII chars — require manual ID entry
|
||||||
|
message.warning('中文行业名称无法自动生成标识,请手动填写行业标识')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createMutation.mutate(values)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="name" label="行业名称" rules={[{ required: true, message: '请输入行业名称' }]}>
|
||||||
|
<Input placeholder="如:医疗健康、教育培训" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="id" label="行业标识" extra="唯一标识,留空则从名称自动生成。仅限小写字母、数字、连字符" rules={[
|
||||||
|
{ pattern: /^[a-z0-9-]*$/, message: '仅限小写字母、数字、连字符' },
|
||||||
|
{ max: 63, message: '最长 63 字符' },
|
||||||
|
]}>
|
||||||
|
<Input placeholder="如:healthcare、education" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="icon" label="图标">
|
||||||
|
<Input placeholder="行业图标 emoji" className="w-32" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="描述" rules={[{ required: true, message: '请输入行业描述' }]}>
|
||||||
|
<TextArea rows={2} placeholder="行业简要描述" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="keywords" label="关键词列表" extra="用于语义路由匹配,回车添加">
|
||||||
|
<Select mode="tags" placeholder="输入关键词后回车添加" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="system_prompt" label="系统提示词">
|
||||||
|
<TextArea rows={4} placeholder="行业专属系统提示词" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="cold_start_template" label="冷启动模板" extra="新用户首次对话时使用的引导模板">
|
||||||
|
<TextArea rows={3} placeholder="如:您好!我是您的{行业}管家,可以帮您处理..." />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="pain_seed_categories" label="痛点种子类别" extra="预置的痛点分类,用逗号或回车分隔">
|
||||||
|
<Select mode="tags" placeholder="如:库存管理、客户服务、合规" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 主页面 ===
|
||||||
|
|
||||||
|
export default function Industries() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="行业配置" description="管理行业关键词、系统提示词、痛点种子,驱动管家语义路由" />
|
||||||
|
<Tabs
|
||||||
|
defaultActiveKey="list"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'list',
|
||||||
|
label: '行业列表',
|
||||||
|
icon: <ShopOutlined />,
|
||||||
|
children: <IndustryListPanel />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
880
admin-v2/src/pages/Knowledge.tsx
Normal file
880
admin-v2/src/pages/Knowledge.tsx
Normal file
@@ -0,0 +1,880 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 知识库管理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Button, message, Tag, Modal, Form, Input, Select, Space, Popconfirm,
|
||||||
|
Card, Statistic, Row, Col, Tabs, Tree, Typography, Empty, Spin, InputNumber,
|
||||||
|
Table, Tooltip,
|
||||||
|
} from 'antd'
|
||||||
|
import {
|
||||||
|
PlusOutlined, SearchOutlined, BookOutlined, FolderOutlined,
|
||||||
|
DeleteOutlined, EditOutlined, EyeOutlined, BarChartOutlined,
|
||||||
|
HistoryOutlined, RollbackOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { knowledgeService } from '@/services/knowledge'
|
||||||
|
import type { CategoryResponse, KnowledgeItem, SearchResult } from '@/services/knowledge'
|
||||||
|
import type { StructuredSource } from '@/services/knowledge'
|
||||||
|
import { TableOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
|
const { TextArea } = Input
|
||||||
|
const { Text, Title } = Typography
|
||||||
|
|
||||||
|
// === 分类树 + 条目列表 Tab ===
|
||||||
|
|
||||||
|
function CategoriesPanel() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [editItem, setEditItem] = useState<CategoryResponse | null>(null)
|
||||||
|
const [createForm] = Form.useForm()
|
||||||
|
const [editForm] = Form.useForm()
|
||||||
|
|
||||||
|
const { data: categories = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['knowledge-categories'],
|
||||||
|
queryFn: ({ signal }) => knowledgeService.listCategories(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: Parameters<typeof knowledgeService.createCategory>[0]) =>
|
||||||
|
knowledgeService.createCategory(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('分类已创建')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['knowledge-categories'] })
|
||||||
|
setCreateOpen(false)
|
||||||
|
createForm.resetFields()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => knowledgeService.deleteCategory(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('分类已删除')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['knowledge-categories'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '删除失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, ...data }: { id: string } & Record<string, unknown>) =>
|
||||||
|
knowledgeService.updateCategory(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('分类已更新')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['knowledge-categories'] })
|
||||||
|
setEditItem(null)
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 编辑弹窗打开时同步表单值(Ant Design Form initialValues 仅首次挂载生效)
|
||||||
|
useEffect(() => {
|
||||||
|
if (editItem) {
|
||||||
|
editForm.setFieldsValue({
|
||||||
|
name: editItem.name,
|
||||||
|
description: editItem.description,
|
||||||
|
parent_id: editItem.parent_id,
|
||||||
|
icon: editItem.icon,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [editItem, editForm])
|
||||||
|
|
||||||
|
// 获取当前编辑分类及其所有后代的 ID(防止循环引用)
|
||||||
|
const getDescendantIds = (id: string, cats: CategoryResponse[]): string[] => {
|
||||||
|
const ids: string[] = [id]
|
||||||
|
for (const c of cats) {
|
||||||
|
if (c.parent_id === id) {
|
||||||
|
ids.push(...getDescendantIds(c.id, cats))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
const treeData = useMemo(
|
||||||
|
() => buildTreeData(categories, (id) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除',
|
||||||
|
content: '删除后无法恢复,请确保分类下没有子分类和条目。',
|
||||||
|
okType: 'danger',
|
||||||
|
onOk: () => deleteMutation.mutate(id),
|
||||||
|
})
|
||||||
|
}, (id) => {
|
||||||
|
setEditItem(categories.find((c) => c.id === id) || null)
|
||||||
|
}),
|
||||||
|
[categories, deleteMutation],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<Title level={5} style={{ margin: 0 }}>分类管理</Title>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||||
|
新建分类
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-8"><Spin /></div>
|
||||||
|
) : categories.length === 0 ? (
|
||||||
|
<Empty description="暂无分类,请新建一个" />
|
||||||
|
) : (
|
||||||
|
<Tree
|
||||||
|
treeData={treeData}
|
||||||
|
defaultExpandAll
|
||||||
|
showLine={{ showLeafIcon: false }}
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 新建分类弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title="新建分类"
|
||||||
|
open={createOpen}
|
||||||
|
onCancel={() => { setCreateOpen(false); createForm.resetFields() }}
|
||||||
|
onOk={() => createForm.submit()}
|
||||||
|
confirmLoading={createMutation.isPending}
|
||||||
|
>
|
||||||
|
<Form form={createForm} layout="vertical" onFinish={(v) => createMutation.mutate(v)}>
|
||||||
|
<Form.Item name="name" label="分类名称" rules={[{ required: true, message: '请输入分类名称' }]}>
|
||||||
|
<Input placeholder="例如:产品知识、技术文档" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="描述">
|
||||||
|
<TextArea rows={2} placeholder="可选描述" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="parent_id" label="父分类">
|
||||||
|
<Select placeholder="无(顶级分类)" allowClear>
|
||||||
|
{flattenCategories(categories).map((c) => (
|
||||||
|
<Select.Option key={c.id} value={c.id}>{c.name}</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="icon" label="图标">
|
||||||
|
<Input placeholder="可选,如 📚" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 编辑分类弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title="编辑分类"
|
||||||
|
open={!!editItem}
|
||||||
|
onCancel={() => { setEditItem(null); editForm.resetFields() }}
|
||||||
|
onOk={() => editForm.submit()}
|
||||||
|
confirmLoading={updateMutation.isPending}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={editForm}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={editItem ? { name: editItem.name, description: editItem.description, parent_id: editItem.parent_id, icon: editItem.icon } : undefined}
|
||||||
|
onFinish={(v) => editItem && updateMutation.mutate({ id: editItem.id, ...v })}
|
||||||
|
>
|
||||||
|
<Form.Item name="name" label="分类名称" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="描述">
|
||||||
|
<TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="parent_id" label="父分类">
|
||||||
|
<Select placeholder="无(顶级分类)" allowClear>
|
||||||
|
{editItem && flattenCategories(categories)
|
||||||
|
.filter((c) => !getDescendantIds(editItem.id, categories).includes(c.id))
|
||||||
|
.map((c) => (
|
||||||
|
<Select.Option key={c.id} value={c.id}>{c.name}</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="icon" label="图标">
|
||||||
|
<Input placeholder="如 📚" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 条目列表 ===
|
||||||
|
|
||||||
|
function ItemsPanel() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [detailItem, setDetailItem] = useState<string | null>(null)
|
||||||
|
const [versionModalOpen, setVersionModalOpen] = useState(false)
|
||||||
|
const [rollingBackVersion, setRollingBackVersion] = useState<number | null>(null)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(20)
|
||||||
|
const [filters, setFilters] = useState<{ category_id?: string; status?: string; keyword?: string }>({})
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
const { data: categories = [] } = useQuery({
|
||||||
|
queryKey: ['knowledge-categories'],
|
||||||
|
queryFn: ({ signal }) => knowledgeService.listCategories(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: detailData, isLoading: detailLoading } = useQuery({
|
||||||
|
queryKey: ['knowledge-item-detail', detailItem],
|
||||||
|
queryFn: ({ signal }) => knowledgeService.getItem(detailItem!, signal),
|
||||||
|
enabled: !!detailItem,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: versions } = useQuery({
|
||||||
|
queryKey: ['knowledge-item-versions', detailItem],
|
||||||
|
queryFn: ({ signal }) => knowledgeService.getVersions(detailItem!, signal),
|
||||||
|
enabled: !!detailItem,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['knowledge-items', page, pageSize, filters],
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
knowledgeService.listItems({ page, page_size: pageSize, ...filters }, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: Parameters<typeof knowledgeService.createItem>[0]) =>
|
||||||
|
knowledgeService.createItem(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('条目已创建')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['knowledge-items'] })
|
||||||
|
setCreateOpen(false)
|
||||||
|
form.resetFields()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => knowledgeService.deleteItem(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('已删除')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['knowledge-items'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '删除失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const rollbackMutation = useMutation({
|
||||||
|
mutationFn: ({ itemId, version }: { itemId: string; version: number }) =>
|
||||||
|
knowledgeService.rollbackVersion(itemId, version),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('已回滚')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['knowledge-items'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['knowledge-item-detail'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['knowledge-item-versions'] })
|
||||||
|
setVersionModalOpen(false)
|
||||||
|
setRollingBackVersion(null)
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
message.error(err.message || '回滚失败')
|
||||||
|
setRollingBackVersion(null)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = { active: 'green', draft: 'orange', archived: 'default' }
|
||||||
|
const statusLabels: Record<string, string> = { active: '活跃', draft: '草稿', archived: '已归档' }
|
||||||
|
|
||||||
|
const columns: ProColumns<KnowledgeItem>[] = [
|
||||||
|
{
|
||||||
|
title: '标题',
|
||||||
|
dataIndex: 'keyword',
|
||||||
|
width: 250,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Button type="link" size="small" onClick={() => setDetailItem(r.id)}>
|
||||||
|
{r.title}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 80,
|
||||||
|
valueEnum: Object.fromEntries(
|
||||||
|
Object.entries(statusLabels).map(([k, v]) => [k, { text: v, status: statusColors[k] === 'green' ? 'Success' : statusColors[k] === 'orange' ? 'Warning' : 'Default' }]),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '版本', dataIndex: 'version', width: 60, search: false },
|
||||||
|
{ title: '优先级', dataIndex: 'priority', width: 70, search: false },
|
||||||
|
{
|
||||||
|
title: '标签',
|
||||||
|
dataIndex: 'tags',
|
||||||
|
width: 200,
|
||||||
|
search: false,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Space size={[4, 4]} wrap>
|
||||||
|
{r.tags?.map((t) => <Tag key={t}>{t}</Tag>)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '更新时间', dataIndex: 'updated_at', width: 160, valueType: 'dateTime', search: false },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 150,
|
||||||
|
search: false,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Space>
|
||||||
|
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => setDetailItem(r.id)} />
|
||||||
|
<Tooltip title="版本历史">
|
||||||
|
<Button type="link" size="small" icon={<HistoryOutlined />} onClick={() => { setDetailItem(r.id); setVersionModalOpen(true) }} />
|
||||||
|
</Tooltip>
|
||||||
|
<Popconfirm title="确认删除?" onConfirm={() => deleteMutation.mutate(r.id)}>
|
||||||
|
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ProTable<KnowledgeItem>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items || []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={{
|
||||||
|
onReset: () => { setFilters({}); setPage(1) },
|
||||||
|
onSubmit: (values) => { setFilters(values); setPage(1) },
|
||||||
|
}}
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||||
|
新建条目
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: data?.total || 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => { setPage(p); setPageSize(ps) },
|
||||||
|
}}
|
||||||
|
options={{ density: false, fullScreen: false, reload: () => queryClient.invalidateQueries({ queryKey: ['knowledge-items'] }) }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 创建弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title="新建知识条目"
|
||||||
|
open={createOpen}
|
||||||
|
onCancel={() => { setCreateOpen(false); form.resetFields() }}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
confirmLoading={createMutation.isPending}
|
||||||
|
width={640}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" onFinish={(v) => createMutation.mutate(v)}>
|
||||||
|
<Form.Item name="category_id" label="分类" rules={[{ required: true, message: '请选择分类' }]}>
|
||||||
|
<Select placeholder="选择分类">
|
||||||
|
{flattenCategories(categories).map((c) => (
|
||||||
|
<Select.Option key={c.id} value={c.id}>{c.name}</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }]}>
|
||||||
|
<Input placeholder="知识条目标题" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="content" label="内容" rules={[{ required: true, message: '请输入内容' }]}>
|
||||||
|
<TextArea rows={8} placeholder="支持 Markdown 格式" />
|
||||||
|
</Form.Item>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="keywords" label="关键词">
|
||||||
|
<Select mode="tags" placeholder="输入后回车添加" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="tags" label="标签">
|
||||||
|
<Select mode="tags" placeholder="输入后回车添加" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Form.Item name="priority" label="优先级" initialValue={0}>
|
||||||
|
<InputNumber min={0} max={100} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 详情弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title={detailData?.title || '条目详情'}
|
||||||
|
open={!!detailItem && !versionModalOpen}
|
||||||
|
onCancel={() => setDetailItem(null)}
|
||||||
|
footer={null}
|
||||||
|
width={720}
|
||||||
|
>
|
||||||
|
{detailData && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 flex gap-2">
|
||||||
|
<Tag color={statusColors[detailData.status]}>{statusLabels[detailData.status] || detailData.status}</Tag>
|
||||||
|
<Tag>版本 {detailData.version}</Tag>
|
||||||
|
<Tag>优先级 {detailData.priority}</Tag>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4 whitespace-pre-wrap bg-neutral-50 dark:bg-neutral-900 p-4 rounded-lg max-h-96 overflow-y-auto text-sm">
|
||||||
|
{detailData.content}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{detailData.tags?.map((t) => <Tag key={t} color="blue">{t}</Tag>)}
|
||||||
|
{detailData.keywords?.map((k) => <Tag key={k} color="cyan">{k}</Tag>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 版本历史弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title={`版本历史 - ${detailData?.title || ''}`}
|
||||||
|
open={versionModalOpen}
|
||||||
|
onCancel={() => { setVersionModalOpen(false); setDetailItem(null) }}
|
||||||
|
footer={null}
|
||||||
|
width={720}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
dataSource={versions?.versions || []}
|
||||||
|
rowKey="id"
|
||||||
|
loading={!versions}
|
||||||
|
size="small"
|
||||||
|
pagination={{ pageSize: 10 }}
|
||||||
|
columns={[
|
||||||
|
{ title: '版本', dataIndex: 'version', width: 70 },
|
||||||
|
{ title: '标题', dataIndex: 'title', ellipsis: true },
|
||||||
|
{ title: '摘要', dataIndex: 'change_summary', width: 200, ellipsis: true },
|
||||||
|
{ title: '创建者', dataIndex: 'created_by', width: 100 },
|
||||||
|
{ title: '创建时间', dataIndex: 'created_at', width: 160 },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 80,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Popconfirm
|
||||||
|
title={`确认回滚到版本 ${r.version}?`}
|
||||||
|
description="回滚将创建新版本,当前版本内容会被替换。"
|
||||||
|
onConfirm={() => {
|
||||||
|
setRollingBackVersion(r.version)
|
||||||
|
rollbackMutation.mutate({ itemId: detailItem!, version: r.version })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" icon={<RollbackOutlined />} loading={rollingBackVersion === r.version}>
|
||||||
|
回滚
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 搜索面板 ===
|
||||||
|
|
||||||
|
function SearchPanel() {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([])
|
||||||
|
const [searching, setSearching] = useState(false)
|
||||||
|
const [hasSearched, setHasSearched] = useState(false)
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
if (!query.trim()) return
|
||||||
|
setSearching(true)
|
||||||
|
try {
|
||||||
|
const data = await knowledgeService.search({ query: query.trim(), limit: 10 })
|
||||||
|
setResults(data)
|
||||||
|
setHasSearched(true)
|
||||||
|
} catch {
|
||||||
|
message.error('搜索失败')
|
||||||
|
} finally {
|
||||||
|
setSearching(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title level={5}>语义搜索</Title>
|
||||||
|
<Space.Compact className="w-full mb-4">
|
||||||
|
<Input
|
||||||
|
size="large"
|
||||||
|
placeholder="输入搜索关键词..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
/>
|
||||||
|
<Button size="large" type="primary" loading={searching} onClick={handleSearch}>
|
||||||
|
搜索
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
|
||||||
|
{results.length === 0 && !searching && !hasSearched && (
|
||||||
|
<Empty description="输入关键词搜索知识库" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{results.length === 0 && !searching && hasSearched && (
|
||||||
|
<Empty description="未找到匹配的知识条目" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{results.map((r) => (
|
||||||
|
<Card key={r.chunk_id} size="small" hoverable>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<Text strong>{r.item_title}</Text>
|
||||||
|
<Tag>{r.category_name}</Tag>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-neutral-600 dark:text-neutral-400 line-clamp-3 mb-2">
|
||||||
|
{r.content}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{r.keywords?.slice(0, 5).map((k) => (
|
||||||
|
<Tag key={k} color="cyan" style={{ fontSize: 11 }}>{k}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 分析看板 ===
|
||||||
|
|
||||||
|
function AnalyticsPanel() {
|
||||||
|
const { data: overview, isLoading: overviewLoading } = useQuery({
|
||||||
|
queryKey: ['knowledge-analytics'],
|
||||||
|
queryFn: ({ signal }) => knowledgeService.getOverview(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: trends } = useQuery({
|
||||||
|
queryKey: ['knowledge-trends'],
|
||||||
|
queryFn: ({ signal }) => knowledgeService.getTrends(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: topItems } = useQuery({
|
||||||
|
queryKey: ['knowledge-top-items'],
|
||||||
|
queryFn: ({ signal }) => knowledgeService.getTopItems(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: quality } = useQuery({
|
||||||
|
queryKey: ['knowledge-quality'],
|
||||||
|
queryFn: ({ signal }) => knowledgeService.getQuality(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: gaps } = useQuery({
|
||||||
|
queryKey: ['knowledge-gaps'],
|
||||||
|
queryFn: ({ signal }) => knowledgeService.getGaps(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (overviewLoading) return <div className="flex justify-center py-8"><Spin /></div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title level={5} className="mb-4">知识库概览</Title>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card><Statistic title="总条目数" value={overview?.total_items || 0} /></Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card><Statistic title="活跃条目" value={overview?.active_items || 0} valueStyle={{ color: '#52c41a' }} /></Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card><Statistic title="分类数" value={overview?.total_categories || 0} /></Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card><Statistic title="本周新增" value={overview?.weekly_new_items || 0} valueStyle={{ color: '#1890ff' }} /></Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]} className="mt-4">
|
||||||
|
<Col span={6}>
|
||||||
|
<Card><Statistic title="总引用次数" value={overview?.total_references || 0} /></Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic title="注入率" value={((overview?.injection_rate || 0) * 100).toFixed(1)} suffix="%" />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic title="正面反馈率" value={((overview?.positive_feedback_rate || 0) * 100).toFixed(1)} suffix="%" valueStyle={{ color: '#52c41a' }} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Card><Statistic title="过期条目" value={overview?.stale_items_count || 0} valueStyle={{ color: '#faad14' }} /></Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* 趋势数据表格 */}
|
||||||
|
<Card title="检索趋势(近30天)" className="mt-4" size="small">
|
||||||
|
<Table
|
||||||
|
dataSource={trends?.trends || []}
|
||||||
|
rowKey="date"
|
||||||
|
loading={!trends}
|
||||||
|
size="small"
|
||||||
|
pagination={{ pageSize: 10 }}
|
||||||
|
columns={[
|
||||||
|
{ title: '日期', dataIndex: 'date', width: 120 },
|
||||||
|
{ title: '检索次数', dataIndex: 'count', width: 100 },
|
||||||
|
{ title: '注入次数', dataIndex: 'injected_count', width: 100 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Top Items 表格 */}
|
||||||
|
<Card title="高频引用 Top 20" className="mt-4" size="small">
|
||||||
|
<Table
|
||||||
|
dataSource={topItems?.items || []}
|
||||||
|
rowKey="id"
|
||||||
|
loading={!topItems}
|
||||||
|
size="small"
|
||||||
|
pagination={{ pageSize: 10 }}
|
||||||
|
columns={[
|
||||||
|
{ title: '标题', dataIndex: 'title', ellipsis: true },
|
||||||
|
{ title: '分类', dataIndex: 'category', width: 120 },
|
||||||
|
{ title: '引用次数', dataIndex: 'ref_count', width: 100 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 质量指标 */}
|
||||||
|
{quality?.categories?.length > 0 && (
|
||||||
|
<Card title="分类质量指标" className="mt-4" size="small">
|
||||||
|
<Table
|
||||||
|
dataSource={quality.categories}
|
||||||
|
rowKey="category"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
columns={[
|
||||||
|
{ title: '分类', dataIndex: 'category', width: 150 },
|
||||||
|
{ title: '总条目', dataIndex: 'total', width: 80 },
|
||||||
|
{ title: '活跃', dataIndex: 'active', width: 80 },
|
||||||
|
{ title: '有关键词', dataIndex: 'with_keywords', width: 100 },
|
||||||
|
{ title: '平均优先级', dataIndex: 'avg_priority', width: 100, render: (v: number) => v?.toFixed(1) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 知识缺口 */}
|
||||||
|
{gaps?.gaps?.length > 0 && (
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<WarningOutlined style={{ color: '#faad14' }} />
|
||||||
|
<span>知识缺口检测</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
className="mt-4"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
dataSource={gaps.gaps}
|
||||||
|
rowKey="query"
|
||||||
|
size="small"
|
||||||
|
pagination={{ pageSize: 10 }}
|
||||||
|
columns={[
|
||||||
|
{ title: '查询', dataIndex: 'query', ellipsis: true },
|
||||||
|
{ title: '次数', dataIndex: 'count', width: 80 },
|
||||||
|
{ title: '平均分', dataIndex: 'avg_score', width: 100, render: (v: number) => v?.toFixed(2) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 主页面 ===
|
||||||
|
|
||||||
|
export default function Knowledge() {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<Tabs
|
||||||
|
defaultActiveKey="items"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'items',
|
||||||
|
label: '知识条目',
|
||||||
|
icon: <BookOutlined />,
|
||||||
|
children: <ItemsPanel />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'categories',
|
||||||
|
label: '分类管理',
|
||||||
|
icon: <FolderOutlined />,
|
||||||
|
children: <CategoriesPanel />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'search',
|
||||||
|
label: '搜索',
|
||||||
|
icon: <SearchOutlined />,
|
||||||
|
children: <SearchPanel />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'analytics',
|
||||||
|
label: '分析看板',
|
||||||
|
icon: <BarChartOutlined />,
|
||||||
|
children: <AnalyticsPanel />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'structured',
|
||||||
|
label: '结构化数据',
|
||||||
|
icon: <TableOutlined />,
|
||||||
|
children: <StructuredSourcesPanel />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Structured Data Sources Panel ===
|
||||||
|
|
||||||
|
function StructuredSourcesPanel() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [viewingRows, setViewingRows] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data: sources = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['structured-sources'],
|
||||||
|
queryFn: ({ signal }) => knowledgeService.listStructuredSources(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: rows = [], isLoading: rowsLoading } = useQuery({
|
||||||
|
queryKey: ['structured-rows', viewingRows],
|
||||||
|
queryFn: ({ signal }) => knowledgeService.listStructuredRows(viewingRows!, signal),
|
||||||
|
enabled: !!viewingRows,
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => knowledgeService.deleteStructuredSource(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('数据源已删除')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['structured-sources'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '删除失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ProColumns<StructuredSource>[] = [
|
||||||
|
{ title: '名称', dataIndex: 'name', key: 'name', width: 200 },
|
||||||
|
{ title: '类型', dataIndex: 'source_type', key: 'source_type', width: 120, render: (v: string) => <Tag>{v}</Tag> },
|
||||||
|
{ title: '行数', dataIndex: 'row_count', key: 'row_count', width: 80 },
|
||||||
|
{
|
||||||
|
title: '列',
|
||||||
|
dataIndex: 'columns',
|
||||||
|
key: 'columns',
|
||||||
|
width: 250,
|
||||||
|
render: (cols: string[]) => (
|
||||||
|
<Space size={[4, 4]} wrap>
|
||||||
|
{(cols ?? []).slice(0, 5).map((c) => (
|
||||||
|
<Tag key={c} color="blue">{c}</Tag>
|
||||||
|
))}
|
||||||
|
{(cols ?? []).length > 5 && <Tag>+{(cols as string[]).length - 5}</Tag>}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
width: 160,
|
||||||
|
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
width: 140,
|
||||||
|
render: (_: unknown, record: StructuredSource) => (
|
||||||
|
<Space>
|
||||||
|
<Button type="link" size="small" onClick={() => setViewingRows(record.id)}>
|
||||||
|
查看数据
|
||||||
|
</Button>
|
||||||
|
<Popconfirm title="确认删除此数据源?" onConfirm={() => deleteMutation.mutate(record.id)}>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Dynamically generate row columns from the first row's keys
|
||||||
|
const rowColumns = rows.length > 0
|
||||||
|
? Object.keys(rows[0].row_data).map((key) => ({
|
||||||
|
title: key,
|
||||||
|
dataIndex: ['row_data', key],
|
||||||
|
key,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v: unknown) => String(v ?? ''),
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{viewingRows ? (
|
||||||
|
<Card
|
||||||
|
title="数据行"
|
||||||
|
extra={<Button onClick={() => setViewingRows(null)}>返回列表</Button>}
|
||||||
|
>
|
||||||
|
{rowsLoading ? (
|
||||||
|
<Spin />
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<Empty description="暂无数据" />
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
dataSource={rows}
|
||||||
|
columns={rowColumns}
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
scroll={{ x: true }}
|
||||||
|
pagination={{ pageSize: 20 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<ProTable<StructuredSource>
|
||||||
|
dataSource={sources}
|
||||||
|
columns={columns}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
pagination={{ pageSize: 20 }}
|
||||||
|
toolBarRender={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 辅助函数 ===
|
||||||
|
|
||||||
|
// === 辅助函数 ===
|
||||||
|
|
||||||
|
function flattenCategories(cats: CategoryResponse[]): { id: string; name: string }[] {
|
||||||
|
const result: { id: string; name: string }[] = []
|
||||||
|
for (const c of cats) {
|
||||||
|
result.push({ id: c.id, name: c.name })
|
||||||
|
if (c.children?.length) {
|
||||||
|
result.push(...flattenCategories(c.children))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreeNode {
|
||||||
|
key: string
|
||||||
|
title: React.ReactNode
|
||||||
|
icon?: React.ReactNode
|
||||||
|
children?: TreeNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTreeData(cats: CategoryResponse[], onDelete: (id: string) => void, onEdit: (id: string) => void): TreeNode[] {
|
||||||
|
return cats.map((c) => ({
|
||||||
|
key: c.id,
|
||||||
|
title: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{c.icon || '📁'} {c.name}</span>
|
||||||
|
<Tag>{c.item_count}</Tag>
|
||||||
|
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => onEdit(c.id)} />
|
||||||
|
<Button type="link" size="small" danger onClick={() => onDelete(c.id)}>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
children: c.children?.length ? buildTreeData(c.children, onDelete, onEdit) : undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
160
admin-v2/src/pages/Login.tsx
Normal file
160
admin-v2/src/pages/Login.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 登录页面
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
import { LoginForm, ProFormText } from '@ant-design/pro-components'
|
||||||
|
import { LockOutlined, UserOutlined, SafetyOutlined } from '@ant-design/icons'
|
||||||
|
import { message } from 'antd'
|
||||||
|
import { authService } from '@/services/auth'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
import type { LoginRequest } from '@/types'
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const loginStore = useAuthStore((s) => s.login)
|
||||||
|
const [needTotp, setNeedTotp] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (values: Record<string, string>) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data: LoginRequest = {
|
||||||
|
username: values.username?.trim() || '',
|
||||||
|
password: values.password || '',
|
||||||
|
totp_code: values.totp_code?.trim() || undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await authService.login(data)
|
||||||
|
loginStore(res.account)
|
||||||
|
|
||||||
|
message.success('登录成功')
|
||||||
|
const from = searchParams.get('from') || '/'
|
||||||
|
navigate(from, { replace: true })
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { message?: string; status?: number }
|
||||||
|
const msg = error.message || ''
|
||||||
|
if (msg.includes('TOTP') || msg.includes('totp') || msg.includes('2FA') || msg.includes('验证码') || error.status === 403) {
|
||||||
|
setNeedTotp(true)
|
||||||
|
message.warning(msg || '请输入两步验证码')
|
||||||
|
} else {
|
||||||
|
message.error(msg || '登录失败,请检查用户名和密码')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex">
|
||||||
|
{/* Left Brand Panel — hidden on mobile */}
|
||||||
|
<div className="hidden md:flex flex-1 flex-col items-center justify-center relative overflow-hidden"
|
||||||
|
style={{ background: 'linear-gradient(135deg, #0c0a09 0%, #1c1917 40%, #292524 100%)' }}
|
||||||
|
>
|
||||||
|
{/* Decorative gradient orb */}
|
||||||
|
<div
|
||||||
|
className="absolute w-[400px] h-[400px] rounded-full opacity-20 blur-3xl"
|
||||||
|
style={{ background: 'linear-gradient(135deg, #863bff, #47bfff)', top: '20%', left: '10%' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute w-[300px] h-[300px] rounded-full opacity-10 blur-3xl"
|
||||||
|
style={{ background: 'linear-gradient(135deg, #47bfff, #863bff)', bottom: '10%', right: '15%' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Brand content */}
|
||||||
|
<div className="relative z-10 text-center px-8">
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-6"
|
||||||
|
style={{ background: 'linear-gradient(135deg, #863bff, #47bfff)' }}
|
||||||
|
>
|
||||||
|
<span className="text-white text-2xl font-bold">Z</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-bold text-white mb-3 tracking-tight">ZCLAW</h1>
|
||||||
|
<p className="text-white/50 text-base mb-8">AI Agent 管理平台</p>
|
||||||
|
<div className="w-16 h-px mx-auto mb-8" style={{ background: 'linear-gradient(90deg, transparent, #863bff, #47bfff, transparent)' }} />
|
||||||
|
<p className="text-white/30 text-sm max-w-sm mx-auto leading-relaxed">
|
||||||
|
统一管理 AI 服务商、模型配置、API 密钥、用量监控与系统配置
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Login Form */}
|
||||||
|
<div className="flex-1 md:flex-none md:w-[480px] flex items-center justify-center p-8 bg-white dark:bg-neutral-950">
|
||||||
|
<div className="w-full max-w-[360px]">
|
||||||
|
{/* Mobile logo (visible only on mobile) */}
|
||||||
|
<div className="md:hidden flex items-center gap-3 mb-10">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-xl"
|
||||||
|
style={{ background: 'linear-gradient(135deg, #863bff, #47bfff)' }}
|
||||||
|
>
|
||||||
|
<span className="text-white font-bold">Z</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold text-neutral-900 dark:text-white">ZCLAW</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-semibold text-neutral-900 dark:text-white mb-1">
|
||||||
|
登录
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-8">
|
||||||
|
输入您的账号信息以继续
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<LoginForm
|
||||||
|
onFinish={handleSubmit}
|
||||||
|
submitter={{
|
||||||
|
searchConfig: { submitText: '登录' },
|
||||||
|
submitButtonProps: {
|
||||||
|
loading,
|
||||||
|
block: true,
|
||||||
|
style: {
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 8,
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: 15,
|
||||||
|
background: 'linear-gradient(135deg, #863bff, #47bfff)',
|
||||||
|
border: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProFormText
|
||||||
|
name="username"
|
||||||
|
fieldProps={{
|
||||||
|
size: 'large',
|
||||||
|
prefix: <UserOutlined />,
|
||||||
|
autoComplete: 'username',
|
||||||
|
}}
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
rules={[{ required: true, message: '请输入用户名' }]}
|
||||||
|
/>
|
||||||
|
<ProFormText.Password
|
||||||
|
name="password"
|
||||||
|
fieldProps={{
|
||||||
|
size: 'large',
|
||||||
|
prefix: <LockOutlined />,
|
||||||
|
autoComplete: 'current-password',
|
||||||
|
}}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
rules={[{ required: true, message: '请输入密码' }]}
|
||||||
|
/>
|
||||||
|
{needTotp && (
|
||||||
|
<ProFormText
|
||||||
|
name="totp_code"
|
||||||
|
fieldProps={{
|
||||||
|
size: 'large',
|
||||||
|
prefix: <SafetyOutlined />,
|
||||||
|
maxLength: 6,
|
||||||
|
autoComplete: 'one-time-code',
|
||||||
|
}}
|
||||||
|
placeholder="请输入 6 位验证码"
|
||||||
|
rules={[{ required: true, message: '请输入验证码' }]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</LoginForm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
admin-v2/src/pages/Logs.tsx
Normal file
91
admin-v2/src/pages/Logs.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 操作日志
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Tag, Select, Typography } from 'antd'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { logService } from '@/services/logs'
|
||||||
|
import { actionLabels, actionColors } from '@/constants/status'
|
||||||
|
import type { OperationLog } from '@/types'
|
||||||
|
|
||||||
|
const { Title } = Typography
|
||||||
|
|
||||||
|
const actionOptions = Object.entries(actionLabels).map(([value, label]) => ({ value, label }))
|
||||||
|
|
||||||
|
export default function Logs() {
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [actionFilter, setActionFilter] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['logs', page, actionFilter],
|
||||||
|
queryFn: ({ signal }) => logService.list({ page, page_size: 20, action: actionFilter }, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ProColumns<OperationLog>[] = [
|
||||||
|
{
|
||||||
|
title: '操作类型',
|
||||||
|
dataIndex: 'action',
|
||||||
|
width: 140,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Tag color={actionColors[r.action] || 'default'}>
|
||||||
|
{actionLabels[r.action] || r.action}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '目标类型', dataIndex: 'target_type', width: 100, render: (_, r) => r.target_type || '-' },
|
||||||
|
{ title: '目标 ID', dataIndex: 'target_id', width: 120, render: (_, r) => r.target_id ? <code>{r.target_id.substring(0, 8)}...</code> : '-' },
|
||||||
|
{
|
||||||
|
title: '详情',
|
||||||
|
dataIndex: 'details',
|
||||||
|
width: 250,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, r) => {
|
||||||
|
if (!r.details) return '-'
|
||||||
|
if (typeof r.details === 'string') return r.details
|
||||||
|
return JSON.stringify(r.details)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: 'IP 地址', dataIndex: 'ip_address', width: 130, render: (_, r) => <code>{r.ip_address || '-'}</code> },
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
width: 180,
|
||||||
|
render: (_, r) => new Date(r.created_at).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>操作日志</Title>
|
||||||
|
<Select
|
||||||
|
value={actionFilter}
|
||||||
|
onChange={(v) => { setActionFilter(v === 'all' ? undefined : v); setPage(1) }}
|
||||||
|
placeholder="操作类型筛选"
|
||||||
|
style={{ width: 160 }}
|
||||||
|
allowClear
|
||||||
|
options={[{ value: 'all', label: '全部操作' }, ...actionOptions]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProTable<OperationLog>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={false}
|
||||||
|
pagination={{
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
pageSize: 20,
|
||||||
|
current: page,
|
||||||
|
onChange: setPage,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
431
admin-v2/src/pages/ModelServices.tsx
Normal file
431
admin-v2/src/pages/ModelServices.tsx
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Button, message, Tag, Modal, Form, Input, InputNumber, Switch, Space, Popconfirm, Tabs, Table, Typography } from 'antd'
|
||||||
|
import { PlusOutlined } from '@ant-design/icons'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { providerService } from '@/services/providers'
|
||||||
|
import { modelService } from '@/services/models'
|
||||||
|
import type { Provider, ProviderKey, Model } from '@/types'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 子组件: 模型表格
|
||||||
|
// ============================================================
|
||||||
|
function ProviderModelsTable({ providerId }: { providerId: string }) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['provider-models', providerId],
|
||||||
|
queryFn: ({ signal }) => modelService.list({ provider_id: providerId! }, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: Partial<Omit<Model, 'id'>>) => modelService.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('模型已创建')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['provider-models', providerId] })
|
||||||
|
setModalOpen(false)
|
||||||
|
form.resetFields()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Partial<Omit<Model, 'id'>> }) =>
|
||||||
|
modelService.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('模型已更新')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['provider-models', providerId] })
|
||||||
|
setModalOpen(false)
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => modelService.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('模型已删除')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['provider-models', providerId] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '删除失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
if (editingId) {
|
||||||
|
updateMutation.mutate({ id: editingId, data: values })
|
||||||
|
} else {
|
||||||
|
createMutation.mutate({ ...values, provider_id: providerId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ProColumns<Model>[] = [
|
||||||
|
{ title: '模型 ID', dataIndex: 'model_id', width: 180, render: (_, r) => <Text code>{r.model_id}</Text> },
|
||||||
|
{ title: '别名', dataIndex: 'alias', width: 120 },
|
||||||
|
{ title: '类型', dataIndex: 'is_embedding', width: 80, render: (_, r) => r.is_embedding ? <Tag color="purple">Embedding</Tag> : <Tag>Chat</Tag> },
|
||||||
|
{ title: '上下文窗口', dataIndex: 'context_window', width: 100, render: (_, r) => r.context_window?.toLocaleString() },
|
||||||
|
{ title: '最大输出', dataIndex: 'max_output_tokens', width: 90, render: (_, r) => r.max_output_tokens?.toLocaleString() },
|
||||||
|
{ title: '流式', dataIndex: 'supports_streaming', width: 60, render: (_, r) => r.supports_streaming ? <Tag color="green">是</Tag> : <Tag>否</Tag> },
|
||||||
|
{ title: '视觉', dataIndex: 'supports_vision', width: 60, render: (_, r) => r.supports_vision ? <Tag color="blue">是</Tag> : <Tag>否</Tag> },
|
||||||
|
{ title: '状态', dataIndex: 'enabled', width: 60, render: (_, r) => r.enabled ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag> },
|
||||||
|
{
|
||||||
|
title: '操作', width: 120, render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>编辑</Button>
|
||||||
|
<Popconfirm title="确定删除此模型?" onConfirm={() => deleteMutation.mutate(record.id)}>
|
||||||
|
<Button size="small" danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const models = data?.items ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>
|
||||||
|
添加模型
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Table<Model>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={models}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title={editingId ? '编辑模型' : '添加模型'}
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleSave}
|
||||||
|
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
|
||||||
|
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
width={560}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="model_id" label="模型 ID" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="如 gpt-4o" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="alias" label="别名">
|
||||||
|
<Input placeholder="可选" />
|
||||||
|
</Form.Item>
|
||||||
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<Form.Item name="context_window" label="上下文窗口" style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={0} placeholder="128000" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="max_output_tokens" label="最大输出 Token" style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={0} placeholder="4096" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<Form.Item name="enabled" label="启用" valuePropName="checked" style={{ flex: 1 }}>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="is_embedding" label="Embedding 模型" valuePropName="checked" style={{ flex: 1 }}>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="supports_streaming" label="支持流式" valuePropName="checked" style={{ flex: 1 }}>
|
||||||
|
<Switch defaultChecked />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="supports_vision" label="支持视觉" valuePropName="checked" style={{ flex: 1 }}>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<Form.Item name="pricing_input" label="输入价格 (每百万Token)" style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={0} step={0.01} placeholder="0" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="pricing_output" label="输出价格 (每百万Token)" style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={0} step={0.01} placeholder="0" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 子组件: Key Pool 表格
|
||||||
|
// ============================================================
|
||||||
|
function ProviderKeysTable({ providerId }: { providerId: string }) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [addKeyForm] = Form.useForm()
|
||||||
|
const [addKeyOpen, setAddKeyOpen] = useState(false)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['provider-keys', providerId],
|
||||||
|
queryFn: ({ signal }) => providerService.listKeys(providerId!, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const addKeyMutation = useMutation({
|
||||||
|
mutationFn: (data: { key_label: string; key_value: string; priority?: number; max_rpm?: number; max_tpm?: number }) =>
|
||||||
|
providerService.addKey(providerId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['provider-keys', providerId] })
|
||||||
|
message.success('密钥已添加')
|
||||||
|
setAddKeyOpen(false)
|
||||||
|
addKeyForm.resetFields()
|
||||||
|
},
|
||||||
|
onError: () => message.error('添加失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleKeyMutation = useMutation({
|
||||||
|
mutationFn: ({ keyId, active }: { keyId: string; active: boolean }) =>
|
||||||
|
providerService.toggleKey(providerId, keyId, active),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['provider-keys', providerId] })
|
||||||
|
message.success('状态已切换')
|
||||||
|
},
|
||||||
|
onError: () => message.error('切换失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteKeyMutation = useMutation({
|
||||||
|
mutationFn: (keyId: string) => providerService.deleteKey(providerId, keyId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['provider-keys', providerId] })
|
||||||
|
message.success('密钥已删除')
|
||||||
|
},
|
||||||
|
onError: () => message.error('删除失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const keyColumns: ProColumns<ProviderKey>[] = [
|
||||||
|
{ title: '标签', dataIndex: 'key_label', width: 120 },
|
||||||
|
{ title: '优先级', dataIndex: 'priority', width: 70 },
|
||||||
|
{ title: '请求数', dataIndex: 'total_requests', width: 80 },
|
||||||
|
{ title: 'Token 数', dataIndex: 'total_tokens', width: 90 },
|
||||||
|
{
|
||||||
|
title: '状态', dataIndex: 'is_active', width: 70,
|
||||||
|
render: (_, r) => r.is_active ? <Tag color="green">活跃</Tag> : <Tag color="orange">冷却</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作', width: 120,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Popconfirm
|
||||||
|
title={record.is_active ? '确定禁用此密钥?' : '确定启用此密钥?'}
|
||||||
|
onConfirm={() => toggleKeyMutation.mutate({ keyId: record.id, active: !record.is_active })}
|
||||||
|
>
|
||||||
|
<Button size="small" type={record.is_active ? 'default' : 'primary'}>
|
||||||
|
{record.is_active ? '禁用' : '启用'}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
<Popconfirm title="确定删除此密钥?此操作不可恢复。" onConfirm={() => deleteKeyMutation.mutate(record.id)}>
|
||||||
|
<Button size="small" danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const keys = data ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => { addKeyForm.resetFields(); setAddKeyOpen(true) }}>
|
||||||
|
添加密钥
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Table<ProviderKey>
|
||||||
|
columns={keyColumns}
|
||||||
|
dataSource={keys}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title="添加密钥"
|
||||||
|
open={addKeyOpen}
|
||||||
|
onOk={() => {
|
||||||
|
addKeyForm.validateFields().then((v) => addKeyMutation.mutate(v))
|
||||||
|
}}
|
||||||
|
onCancel={() => setAddKeyOpen(false)}
|
||||||
|
confirmLoading={addKeyMutation.isPending}
|
||||||
|
>
|
||||||
|
<Form form={addKeyForm} layout="vertical">
|
||||||
|
<Form.Item name="key_label" label="标签" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="如: my-openai-key" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="key_value" label="API Key" rules={[{ required: true }]}>
|
||||||
|
<Input.Password placeholder="sk-..." />
|
||||||
|
</Form.Item>
|
||||||
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<Form.Item name="priority" label="优先级" initialValue={0} style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="max_rpm" label="最大 RPM" style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="max_tpm" label="最大 TPM" style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 主页面: 模型服务
|
||||||
|
// ============================================================
|
||||||
|
export default function ModelServices() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['providers'],
|
||||||
|
queryFn: ({ signal }) => providerService.list(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>) =>
|
||||||
|
providerService.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('服务商已创建')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['providers'] })
|
||||||
|
setModalOpen(false)
|
||||||
|
form.resetFields()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>> }) =>
|
||||||
|
providerService.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('服务商已更新')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['providers'] })
|
||||||
|
setModalOpen(false)
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => providerService.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('服务商已删除')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['providers'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '删除失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
if (editingId) {
|
||||||
|
updateMutation.mutate({ id: editingId, data: values })
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ProColumns<Provider>[] = [
|
||||||
|
{ title: '名称', dataIndex: 'display_name', width: 150 },
|
||||||
|
{ title: '标识', dataIndex: 'name', width: 120, render: (_, r) => <Text code>{r.name}</Text> },
|
||||||
|
{ title: 'Base URL', dataIndex: 'base_url', width: 260, ellipsis: true },
|
||||||
|
{ title: '协议', dataIndex: 'api_protocol', width: 90, hideInSearch: true },
|
||||||
|
{ title: 'RPM', dataIndex: 'rate_limit_rpm', width: 80, hideInSearch: true, render: (_, r) => r.rate_limit_rpm ?? '-' },
|
||||||
|
{
|
||||||
|
title: '状态', dataIndex: 'enabled', width: 70, hideInSearch: true,
|
||||||
|
render: (_, r) => r.enabled ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作', width: 140, hideInSearch: true,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>编辑</Button>
|
||||||
|
<Popconfirm title="确定删除此服务商?" onConfirm={() => deleteMutation.mutate(record.id)}>
|
||||||
|
<Button size="small" danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ProTable<Provider>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={{}}
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>
|
||||||
|
新建服务商
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
pagination={{
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
pageSize: data?.page_size ?? 20,
|
||||||
|
current: data?.page ?? 1,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
expandable={{
|
||||||
|
expandedRowRender: (record) => (
|
||||||
|
<Tabs
|
||||||
|
size="small"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'models',
|
||||||
|
label: `模型`,
|
||||||
|
children: <ProviderModelsTable providerId={record.id} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'keys',
|
||||||
|
label: 'Key Pool',
|
||||||
|
children: <ProviderKeysTable providerId={record.id} />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editingId? '编辑服务商' : '新建服务商'}
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleSave}
|
||||||
|
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
|
||||||
|
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
width={560}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="name" label="标识" rules={[{ required: true }]}>
|
||||||
|
<Input disabled={!!editingId} placeholder="如 openai, anthropic" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="display_name" label="显示名称" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="如 OpenAI" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="base_url" label="Base URL" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="https://api.openai.com/v1" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="api_protocol" label="API 协议">
|
||||||
|
<Input placeholder="openai" />
|
||||||
|
</Form.Item>
|
||||||
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<Form.Item name="enabled" label="启用" valuePropName="checked" style={{ flex: 1 }}>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="rate_limit_rpm" label="RPM 限制" style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
228
admin-v2/src/pages/Prompts.tsx
Normal file
228
admin-v2/src/pages/Prompts.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 提示词管理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Button, message, Tag, Modal, Form, Input, Select, Space, Popconfirm, Descriptions, Tabs, Typography } from 'antd'
|
||||||
|
import { PlusOutlined } from '@ant-design/icons'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { promptService } from '@/services/prompts'
|
||||||
|
import type { PromptTemplate, PromptVersion } from '@/types'
|
||||||
|
|
||||||
|
const { TextArea } = Input
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
const sourceLabels: Record<string, string> = { builtin: '内置', custom: '自定义' }
|
||||||
|
const statusLabels: Record<string, string> = { active: '活跃', deprecated: '已废弃', archived: '已归档' }
|
||||||
|
const statusColors: Record<string, string> = { active: 'green', deprecated: 'orange', archived: 'default' }
|
||||||
|
|
||||||
|
export default function Prompts() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [detailName, setDetailName] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['prompts'],
|
||||||
|
queryFn: ({ signal }) => promptService.list(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: detailData } = useQuery({
|
||||||
|
queryKey: ['prompt-detail', detailName],
|
||||||
|
queryFn: ({ signal }) => promptService.get(detailName!, signal),
|
||||||
|
enabled: !!detailName,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: versionsData } = useQuery({
|
||||||
|
queryKey: ['prompt-versions', detailName],
|
||||||
|
queryFn: ({ signal }) => promptService.listVersions(detailName!, signal),
|
||||||
|
enabled: !!detailName,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: Parameters<typeof promptService.create>[0]) => promptService.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('创建成功')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['prompts'] })
|
||||||
|
setCreateOpen(false)
|
||||||
|
form.resetFields()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const archiveMutation = useMutation({
|
||||||
|
mutationFn: (name: string) => promptService.archive(name),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('已归档')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['prompts'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '归档失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const rollbackMutation = useMutation({
|
||||||
|
mutationFn: ({ name, version }: { name: string; version: number }) =>
|
||||||
|
promptService.rollback(name, version),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('回滚成功')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['prompts'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['prompt-detail', detailName] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['prompt-versions', detailName] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '回滚失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ProColumns<PromptTemplate>[] = [
|
||||||
|
{ title: '名称', dataIndex: 'name', width: 200, render: (_, r) => <Text code>{r.name}</Text> },
|
||||||
|
{ title: '分类', dataIndex: 'category', width: 100 },
|
||||||
|
{ title: '描述', dataIndex: 'description', width: 200, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '来源',
|
||||||
|
dataIndex: 'source',
|
||||||
|
width: 80,
|
||||||
|
render: (_, r) => <Tag>{sourceLabels[r.source]}</Tag>,
|
||||||
|
},
|
||||||
|
{ title: '版本', dataIndex: 'current_version', width: 70 },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 90,
|
||||||
|
render: (_, r) => <Tag color={statusColors[r.status]}>{statusLabels[r.status]}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 180,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => setDetailName(record.name)}>详情</Button>
|
||||||
|
{record.status === 'active' && (
|
||||||
|
<Popconfirm title="确定归档此提示词?" onConfirm={() => archiveMutation.mutate(record.name)}>
|
||||||
|
<Button size="small" danger>归档</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
createMutation.mutate(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionColumns: ProColumns<PromptVersion>[] = [
|
||||||
|
{ title: '版本', dataIndex: 'version', width: 60 },
|
||||||
|
{ title: '更新说明', dataIndex: 'changelog', width: 200, ellipsis: true },
|
||||||
|
{ title: '最低版本', dataIndex: 'min_app_version', width: 100, render: (_, r) => r.min_app_version || '-' },
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
width: 180,
|
||||||
|
render: (_, r) => new Date(r.created_at).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 80,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Popconfirm
|
||||||
|
title={`确定回滚到版本 ${record.version}?`}
|
||||||
|
onConfirm={() => detailName && rollbackMutation.mutate({ name: detailName, version: record.version })}
|
||||||
|
>
|
||||||
|
<Button size="small">回滚</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ProTable<PromptTemplate>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setCreateOpen(true) }}>
|
||||||
|
新建提示词
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
pagination={{
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
pageSize: data?.page_size ?? 20,
|
||||||
|
current: data?.page ?? 1,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="新建提示词"
|
||||||
|
open={createOpen}
|
||||||
|
onOk={handleCreate}
|
||||||
|
onCancel={() => { setCreateOpen(false); form.resetFields() }}
|
||||||
|
confirmLoading={createMutation.isPending}
|
||||||
|
width={640}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="唯一标识" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="category" label="分类" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="如 system, tool" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="描述">
|
||||||
|
<TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="system_prompt" label="系统提示词" rules={[{ required: true }]}>
|
||||||
|
<TextArea rows={6} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="user_prompt_template" label="用户提示词模板">
|
||||||
|
<TextArea rows={4} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`提示词详情: ${detailName || ''}`}
|
||||||
|
open={!!detailName}
|
||||||
|
onCancel={() => setDetailName(null)}
|
||||||
|
footer={null}
|
||||||
|
width={800}
|
||||||
|
>
|
||||||
|
<Tabs items={[
|
||||||
|
{
|
||||||
|
key: 'info',
|
||||||
|
label: '基本信息',
|
||||||
|
children: detailData ? (
|
||||||
|
<Descriptions column={2} bordered size="small">
|
||||||
|
<Descriptions.Item label="名称">{detailData.name}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="分类">{detailData.category}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="来源">{sourceLabels[detailData.source]}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">{statusLabels[detailData.status]}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="当前版本">{detailData.current_version}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="描述" span={2}>{detailData.description || '-'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'versions',
|
||||||
|
label: '版本历史',
|
||||||
|
children: (
|
||||||
|
<ProTable<PromptVersion>
|
||||||
|
columns={versionColumns}
|
||||||
|
dataSource={versionsData ?? []}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={false}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
loading={!versionsData}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]} />
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
146
admin-v2/src/pages/Relay.tsx
Normal file
146
admin-v2/src/pages/Relay.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 中转任务
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Tag, Select } from 'antd'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { relayService } from '@/services/relay'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { ErrorState } from '@/components/ErrorState'
|
||||||
|
import type { RelayTask } from '@/types'
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
queued: '排队中',
|
||||||
|
running: '运行中',
|
||||||
|
completed: '已完成',
|
||||||
|
failed: '失败',
|
||||||
|
cancelled: '已取消',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
queued: 'default',
|
||||||
|
running: 'processing',
|
||||||
|
completed: 'green',
|
||||||
|
failed: 'red',
|
||||||
|
cancelled: 'default',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Relay() {
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['relay-tasks', page, statusFilter],
|
||||||
|
queryFn: ({ signal }) => relayService.list({ page, page_size: 20, status: statusFilter }, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="中转任务" description="查看和管理 AI 模型中转请求" />
|
||||||
|
<ErrorState message={(error as Error).message} onRetry={() => refetch()} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ProColumns<RelayTask>[] = [
|
||||||
|
{
|
||||||
|
title: 'ID',
|
||||||
|
dataIndex: 'id',
|
||||||
|
width: 120,
|
||||||
|
render: (_, r) => (
|
||||||
|
<code className="text-xs px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800">
|
||||||
|
{r.id.substring(0, 8)}...
|
||||||
|
</code>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Tag color={statusColors[r.status] || 'default'}>
|
||||||
|
{statusLabels[r.status] || r.status}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '模型', dataIndex: 'model_id', width: 160 },
|
||||||
|
{ title: '优先级', dataIndex: 'priority', width: 70 },
|
||||||
|
{ title: '尝试次数', dataIndex: 'attempt_count', width: 80 },
|
||||||
|
{
|
||||||
|
title: 'Token (入/出)',
|
||||||
|
width: 140,
|
||||||
|
render: (_, r) => (
|
||||||
|
<span className="text-sm">
|
||||||
|
{r.input_tokens.toLocaleString()} / {r.output_tokens.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '错误信息', dataIndex: 'error_message', width: 200, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '排队时间',
|
||||||
|
dataIndex: 'queued_at',
|
||||||
|
width: 180,
|
||||||
|
render: (_, r) => new Date(r.queued_at).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '完成时间',
|
||||||
|
dataIndex: 'completed_at',
|
||||||
|
width: 180,
|
||||||
|
render: (_, r) => (r.completed_at ? new Date(r.completed_at).toLocaleString('zh-CN') : '-'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="中转任务"
|
||||||
|
description="查看和管理 AI 模型中转请求"
|
||||||
|
actions={
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(v) => {
|
||||||
|
setStatusFilter(v === 'all' ? undefined : v)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
placeholder="状态筛选"
|
||||||
|
className="w-36"
|
||||||
|
allowClear
|
||||||
|
options={[
|
||||||
|
{ value: 'all', label: '全部' },
|
||||||
|
{ value: 'queued', label: '排队中' },
|
||||||
|
{ value: 'running', label: '运行中' },
|
||||||
|
{ value: 'completed', label: '已完成' },
|
||||||
|
{ value: 'failed', label: '失败' },
|
||||||
|
{ value: 'cancelled', label: '已取消' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProTable<RelayTask>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={false}
|
||||||
|
pagination={{
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
pageSize: 20,
|
||||||
|
current: page,
|
||||||
|
onChange: setPage,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
509
admin-v2/src/pages/Roles.tsx
Normal file
509
admin-v2/src/pages/Roles.tsx
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 角色与权限模板管理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
message,
|
||||||
|
Tag,
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Popconfirm,
|
||||||
|
Tabs,
|
||||||
|
Tooltip,
|
||||||
|
} from 'antd'
|
||||||
|
import { PlusOutlined, SafetyOutlined, CheckCircleOutlined } from '@ant-design/icons'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { roleService } from '@/services/roles'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import type {
|
||||||
|
Role,
|
||||||
|
PermissionTemplate,
|
||||||
|
CreateRoleRequest,
|
||||||
|
UpdateRoleRequest,
|
||||||
|
CreateTemplateRequest,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 常见权限选项
|
||||||
|
// ============================================================
|
||||||
|
const permissionOptions = [
|
||||||
|
{ value: 'account:admin', label: 'account:admin' },
|
||||||
|
{ value: 'provider:manage', label: 'provider:manage' },
|
||||||
|
{ value: 'model:read', label: 'model:read' },
|
||||||
|
{ value: 'model:write', label: 'model:write' },
|
||||||
|
{ value: 'relay:use', label: 'relay:use' },
|
||||||
|
{ value: 'knowledge:read', label: 'knowledge:read' },
|
||||||
|
{ value: 'knowledge:write', label: 'knowledge:write' },
|
||||||
|
{ value: 'billing:read', label: 'billing:read' },
|
||||||
|
{ value: 'billing:write', label: 'billing:write' },
|
||||||
|
{ value: 'config:read', label: 'config:read' },
|
||||||
|
{ value: 'config:write', label: 'config:write' },
|
||||||
|
{ value: 'prompt:read', label: 'prompt:read' },
|
||||||
|
{ value: 'prompt:write', label: 'prompt:write' },
|
||||||
|
{ value: 'admin:full', label: 'admin:full' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Roles Tab
|
||||||
|
// ============================================================
|
||||||
|
function RolesTab() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['roles'],
|
||||||
|
queryFn: ({ signal }) => roleService.list(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: CreateRoleRequest) => roleService.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('角色已创建')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['roles'] })
|
||||||
|
setModalOpen(false)
|
||||||
|
form.resetFields()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: UpdateRoleRequest }) =>
|
||||||
|
roleService.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('角色已更新')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['roles'] })
|
||||||
|
setModalOpen(false)
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => roleService.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('角色已删除')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['roles'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '删除失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
if (editingId) {
|
||||||
|
updateMutation.mutate({ id: editingId, data: values })
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = async (record: Role) => {
|
||||||
|
setEditingId(record.id)
|
||||||
|
const permissions = await roleService.getPermissions(record.id).catch(() => record.permissions)
|
||||||
|
form.setFieldsValue({ ...record, permissions })
|
||||||
|
setModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingId(null)
|
||||||
|
form.resetFields()
|
||||||
|
setModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setModalOpen(false)
|
||||||
|
setEditingId(null)
|
||||||
|
form.resetFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ProColumns<Role>[] = [
|
||||||
|
{
|
||||||
|
title: '角色名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
width: 160,
|
||||||
|
render: (_, record) => (
|
||||||
|
<span className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
|
{record.name}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '描述',
|
||||||
|
dataIndex: 'description',
|
||||||
|
width: 240,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, record) => record.description || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '权限数',
|
||||||
|
dataIndex: 'permissions',
|
||||||
|
width: 100,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Tooltip title={record.permissions?.join(', ') || '无权限'}>
|
||||||
|
<Tag>{record.permissions?.length ?? 0} 项</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '关联账号',
|
||||||
|
dataIndex: 'account_count',
|
||||||
|
width: 100,
|
||||||
|
render: (_, record) => record.account_count ?? 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
width: 180,
|
||||||
|
render: (_, record) =>
|
||||||
|
record.created_at ? new Date(record.created_at).toLocaleString('zh-CN') : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 160,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => openEdit(record)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定删除此角色?"
|
||||||
|
description="删除后关联的账号将失去此角色权限"
|
||||||
|
onConfirm={() => deleteMutation.mutate(record.id)}
|
||||||
|
>
|
||||||
|
<Button size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ProTable<Role>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||||
|
新建角色
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
pagination={{ showSizeChanger: false }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editingId ? '编辑角色' : '新建角色'}
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleSave}
|
||||||
|
onCancel={closeModal}
|
||||||
|
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
width={560}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" className="mt-4">
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label="角色名称"
|
||||||
|
rules={[{ required: true, message: '请输入角色名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="如 editor, viewer" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="描述">
|
||||||
|
<Input.TextArea rows={2} placeholder="角色用途说明" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="permissions" label="权限">
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="选择权限"
|
||||||
|
options={permissionOptions}
|
||||||
|
maxTagCount={5}
|
||||||
|
allowClear
|
||||||
|
filterOption={(input, option) =>
|
||||||
|
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Permission Templates Tab
|
||||||
|
// ============================================================
|
||||||
|
function TemplatesTab() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [applyOpen, setApplyOpen] = useState(false)
|
||||||
|
const [applyForm] = Form.useForm()
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<PermissionTemplate | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['permission-templates'],
|
||||||
|
queryFn: ({ signal }) => roleService.listTemplates(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: CreateTemplateRequest) => roleService.createTemplate(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('模板已创建')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['permission-templates'] })
|
||||||
|
setModalOpen(false)
|
||||||
|
form.resetFields()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => roleService.deleteTemplate(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('模板已删除')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['permission-templates'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '删除失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const applyMutation = useMutation({
|
||||||
|
mutationFn: ({ templateId, accountIds }: { templateId: string; accountIds: string[] }) =>
|
||||||
|
roleService.applyTemplate(templateId, accountIds),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('模板已应用到所选账号')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['permission-templates'] })
|
||||||
|
setApplyOpen(false)
|
||||||
|
applyForm.resetFields()
|
||||||
|
setSelectedTemplate(null)
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '应用失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const openApply = (record: PermissionTemplate) => {
|
||||||
|
setSelectedTemplate(record)
|
||||||
|
applyForm.resetFields()
|
||||||
|
setApplyOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApply = async () => {
|
||||||
|
const values = await applyForm.validateFields()
|
||||||
|
if (!selectedTemplate) return
|
||||||
|
const accountIds = values.account_ids
|
||||||
|
?.split(',')
|
||||||
|
.map((s: string) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
if (!accountIds?.length) {
|
||||||
|
message.warning('请输入至少一个账号 ID')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applyMutation.mutate({ templateId: selectedTemplate.id, accountIds })
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ProColumns<PermissionTemplate>[] = [
|
||||||
|
{
|
||||||
|
title: '模板名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
width: 180,
|
||||||
|
render: (_, record) => (
|
||||||
|
<span className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
|
{record.name}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '描述',
|
||||||
|
dataIndex: 'description',
|
||||||
|
width: 240,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, record) => record.description || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '权限数',
|
||||||
|
dataIndex: 'permissions',
|
||||||
|
width: 100,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Tooltip title={record.permissions?.join(', ') || '无权限'}>
|
||||||
|
<Tag>{record.permissions?.length ?? 0} 项</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
width: 180,
|
||||||
|
render: (_, record) =>
|
||||||
|
record.created_at ? new Date(record.created_at).toLocaleString('zh-CN') : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 180,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<CheckCircleOutlined />}
|
||||||
|
onClick={() => openApply(record)}
|
||||||
|
>
|
||||||
|
应用
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定删除此模板?"
|
||||||
|
description="删除后已应用的账号不受影响"
|
||||||
|
onConfirm={() => deleteMutation.mutate(record.id)}
|
||||||
|
>
|
||||||
|
<Button size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ProTable<PermissionTemplate>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Button
|
||||||
|
key="add"
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
form.resetFields()
|
||||||
|
setModalOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新建模板
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
pagination={{ showSizeChanger: false }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Create Template Modal */}
|
||||||
|
<Modal
|
||||||
|
title="新建权限模板"
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
createMutation.mutate(values)
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setModalOpen(false)
|
||||||
|
form.resetFields()
|
||||||
|
}}
|
||||||
|
confirmLoading={createMutation.isPending}
|
||||||
|
width={560}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" className="mt-4">
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label="模板名称"
|
||||||
|
rules={[{ required: true, message: '请输入模板名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="如 basic-user, power-user" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="描述">
|
||||||
|
<Input.TextArea rows={2} placeholder="模板用途说明" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="permissions" label="权限">
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="选择权限"
|
||||||
|
options={permissionOptions}
|
||||||
|
maxTagCount={5}
|
||||||
|
allowClear
|
||||||
|
filterOption={(input, option) =>
|
||||||
|
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Apply Template Modal */}
|
||||||
|
<Modal
|
||||||
|
title={`应用模板: ${selectedTemplate?.name ?? ''}`}
|
||||||
|
open={applyOpen}
|
||||||
|
onOk={handleApply}
|
||||||
|
onCancel={() => {
|
||||||
|
setApplyOpen(false)
|
||||||
|
setSelectedTemplate(null)
|
||||||
|
applyForm.resetFields()
|
||||||
|
}}
|
||||||
|
confirmLoading={applyMutation.isPending}
|
||||||
|
width={480}
|
||||||
|
>
|
||||||
|
<Form form={applyForm} layout="vertical" className="mt-4">
|
||||||
|
<div className="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
将模板的 {selectedTemplate?.permissions?.length ?? 0} 项权限应用到指定账号。
|
||||||
|
请输入账号 ID,多个 ID 用逗号分隔。
|
||||||
|
</div>
|
||||||
|
<Form.Item
|
||||||
|
name="account_ids"
|
||||||
|
label="账号 ID"
|
||||||
|
rules={[{ required: true, message: '请输入账号 ID' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={3}
|
||||||
|
placeholder="如: acc_abc123, acc_def456"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Main Page: Roles & Permissions
|
||||||
|
// ============================================================
|
||||||
|
export default function Roles() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="角色与权限"
|
||||||
|
description="管理角色、权限模板,并将权限批量应用到账号"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
defaultActiveKey="roles"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'roles',
|
||||||
|
label: (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<SafetyOutlined />
|
||||||
|
角色
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
children: <RolesTab />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'templates',
|
||||||
|
label: (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<CheckCircleOutlined />
|
||||||
|
权限模板
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
children: <TemplatesTab />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
397
admin-v2/src/pages/ScheduledTasks.tsx
Normal file
397
admin-v2/src/pages/ScheduledTasks.tsx
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 定时任务 — 管理页面
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Button, message, Tag, Modal, Form, Input, Select, Switch, Popconfirm, Space } from 'antd'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { PlusOutlined } from '@ant-design/icons'
|
||||||
|
import { scheduledTaskService } from '@/services/scheduled-tasks'
|
||||||
|
import type { ScheduledTask, CreateScheduledTaskRequest, UpdateScheduledTaskRequest } from '@/services/scheduled-tasks'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { ErrorState } from '@/components/ErrorState'
|
||||||
|
|
||||||
|
const scheduleTypeLabels: Record<string, string> = {
|
||||||
|
cron: 'Cron',
|
||||||
|
interval: '间隔',
|
||||||
|
once: '一次性',
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleTypeColors: Record<string, string> = {
|
||||||
|
cron: 'blue',
|
||||||
|
interval: 'green',
|
||||||
|
once: 'orange',
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetTypeLabels: Record<string, string> = {
|
||||||
|
agent: 'Agent',
|
||||||
|
hand: 'Hand',
|
||||||
|
workflow: 'Workflow',
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetTypeColors: Record<string, string> = {
|
||||||
|
agent: 'purple',
|
||||||
|
hand: 'cyan',
|
||||||
|
workflow: 'geekblue',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null): string {
|
||||||
|
if (!value) return '-'
|
||||||
|
return new Date(value).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number | null): string {
|
||||||
|
if (ms === null) return '-'
|
||||||
|
if (ms < 1000) return `${ms}ms`
|
||||||
|
return `${(ms / 1000).toFixed(2)}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScheduledTasks() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: ['scheduled-tasks'],
|
||||||
|
queryFn: ({ signal }) => scheduledTaskService.list(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: CreateScheduledTaskRequest) => scheduledTaskService.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('任务创建成功')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['scheduled-tasks'] })
|
||||||
|
closeModal()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: UpdateScheduledTaskRequest }) =>
|
||||||
|
scheduledTaskService.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('任务更新成功')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['scheduled-tasks'] })
|
||||||
|
closeModal()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => scheduledTaskService.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('任务已删除')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['scheduled-tasks'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '删除失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
|
||||||
|
scheduledTaskService.update(id, { enabled }),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('状态已更新')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['scheduled-tasks'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '状态更新失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ProColumns<ScheduledTask>[] = [
|
||||||
|
{
|
||||||
|
title: '任务名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '调度规则',
|
||||||
|
dataIndex: 'schedule',
|
||||||
|
width: 140,
|
||||||
|
ellipsis: true,
|
||||||
|
hideInSearch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '调度类型',
|
||||||
|
dataIndex: 'schedule_type',
|
||||||
|
width: 100,
|
||||||
|
valueType: 'select',
|
||||||
|
valueEnum: {
|
||||||
|
cron: { text: 'Cron' },
|
||||||
|
interval: { text: '间隔' },
|
||||||
|
once: { text: '一次性' },
|
||||||
|
},
|
||||||
|
render: (_, record) => (
|
||||||
|
<Tag color={scheduleTypeColors[record.schedule_type]}>
|
||||||
|
{scheduleTypeLabels[record.schedule_type] || record.schedule_type}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '目标',
|
||||||
|
dataIndex: ['target', 'type'],
|
||||||
|
width: 140,
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<Tag color={targetTypeColors[record.target.type]}>
|
||||||
|
{targetTypeLabels[record.target.type] || record.target.type}
|
||||||
|
</Tag>
|
||||||
|
<span className="text-xs text-neutral-500 dark:text-neutral-400">{record.target.id}</span>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '启用',
|
||||||
|
dataIndex: 'enabled',
|
||||||
|
width: 80,
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={record.enabled}
|
||||||
|
onChange={(checked) => toggleMutation.mutate({ id: record.id, enabled: checked })}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '执行次数',
|
||||||
|
dataIndex: 'run_count',
|
||||||
|
width: 90,
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, record) => (
|
||||||
|
<span className="tabular-nums">{record.run_count}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '上次执行',
|
||||||
|
dataIndex: 'last_run',
|
||||||
|
width: 170,
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, record) => formatDateTime(record.last_run),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '下次执行',
|
||||||
|
dataIndex: 'next_run',
|
||||||
|
width: 170,
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, record) => formatDateTime(record.next_run),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '上次耗时',
|
||||||
|
dataIndex: 'last_duration_ms',
|
||||||
|
width: 100,
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, record) => formatDuration(record.last_duration_ms),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '上次错误',
|
||||||
|
dataIndex: 'last_error',
|
||||||
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, record) =>
|
||||||
|
record.last_error ? (
|
||||||
|
<span className="text-red-500 text-xs">{record.last_error}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-neutral-400">-</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 140,
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => openEditModal(record)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定删除此任务?"
|
||||||
|
description="删除后无法恢复"
|
||||||
|
onConfirm={() => deleteMutation.mutate(record.id)}
|
||||||
|
>
|
||||||
|
<Button size="small" danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
setEditingId(null)
|
||||||
|
form.resetFields()
|
||||||
|
form.setFieldsValue({ schedule_type: 'cron', enabled: true })
|
||||||
|
setModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditModal = (record: ScheduledTask) => {
|
||||||
|
setEditingId(record.id)
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: record.name,
|
||||||
|
schedule: record.schedule,
|
||||||
|
schedule_type: record.schedule_type,
|
||||||
|
target_type: record.target.type,
|
||||||
|
target_id: record.target.id,
|
||||||
|
description: record.description ?? '',
|
||||||
|
enabled: record.enabled,
|
||||||
|
})
|
||||||
|
setModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setModalOpen(false)
|
||||||
|
setEditingId(null)
|
||||||
|
form.resetFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
const payload: CreateScheduledTaskRequest | UpdateScheduledTaskRequest = {
|
||||||
|
name: values.name,
|
||||||
|
schedule: values.schedule,
|
||||||
|
schedule_type: values.schedule_type,
|
||||||
|
target: {
|
||||||
|
type: values.target_type,
|
||||||
|
id: values.target_id,
|
||||||
|
},
|
||||||
|
description: values.description || undefined,
|
||||||
|
enabled: values.enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingId) {
|
||||||
|
updateMutation.mutate({ id: editingId, data: payload })
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(payload as CreateScheduledTaskRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="定时任务" description="管理系统定时任务的创建、调度与执行" />
|
||||||
|
<ErrorState message={(error as Error).message} onRetry={() => refetch()} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = Array.isArray(data) ? data : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="定时任务"
|
||||||
|
description="管理系统定时任务的创建、调度与执行"
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={openCreateModal}
|
||||||
|
>
|
||||||
|
新建任务
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProTable<ScheduledTask>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={tasks}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={() => []}
|
||||||
|
pagination={{
|
||||||
|
showSizeChanger: true,
|
||||||
|
defaultPageSize: 20,
|
||||||
|
}}
|
||||||
|
options={{
|
||||||
|
density: false,
|
||||||
|
fullScreen: false,
|
||||||
|
reload: () => refetch(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<span className="text-base font-semibold">
|
||||||
|
{editingId ? '编辑任务' : '新建任务'}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleSave}
|
||||||
|
onCancel={closeModal}
|
||||||
|
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
width={520}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" className="mt-4">
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label="任务名称"
|
||||||
|
rules={[{ required: true, message: '请输入任务名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="例如:每日数据汇总" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="schedule_type"
|
||||||
|
label="调度类型"
|
||||||
|
rules={[{ required: true, message: '请选择调度类型' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: 'cron', label: 'Cron 表达式' },
|
||||||
|
{ value: 'interval', label: '固定间隔' },
|
||||||
|
{ value: 'once', label: '一次性执行' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="schedule"
|
||||||
|
label="调度规则"
|
||||||
|
rules={[{ required: true, message: '请输入调度规则' }]}
|
||||||
|
extra="Cron: 0 8 * * * 间隔: 30m / 1h / 24h 一次性: 2025-12-31T00:00:00Z"
|
||||||
|
>
|
||||||
|
<Input placeholder="0 8 * * *" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="target_type"
|
||||||
|
label="目标类型"
|
||||||
|
rules={[{ required: true, message: '请选择目标类型' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: 'agent', label: 'Agent' },
|
||||||
|
{ value: 'hand', label: 'Hand' },
|
||||||
|
{ value: 'workflow', label: 'Workflow' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="target_id"
|
||||||
|
label="目标 ID"
|
||||||
|
rules={[{ required: true, message: '请输入目标 ID' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="目标唯一标识符" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="description" label="描述">
|
||||||
|
<Input.TextArea rows={3} placeholder="可选的任务描述" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
358
admin-v2/src/pages/Usage.tsx
Normal file
358
admin-v2/src/pages/Usage.tsx
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 用量统计 + 转化漏斗
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Card, Col, Row, Select, Statistic } from 'antd'
|
||||||
|
import { ThunderboltOutlined, ColumnWidthOutlined, UserOutlined, TeamOutlined } from '@ant-design/icons'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import {
|
||||||
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||||
|
FunnelChart, Funnel, LabelList,
|
||||||
|
} from 'recharts'
|
||||||
|
import { telemetryService } from '@/services/telemetry'
|
||||||
|
import { statsService } from '@/services/stats'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { ErrorState } from '@/components/ErrorState'
|
||||||
|
import type { DailyUsageStat, ModelUsageStat } from '@/types'
|
||||||
|
|
||||||
|
// ─── Conversion Funnel Data ───
|
||||||
|
|
||||||
|
interface FunnelStep {
|
||||||
|
name: string
|
||||||
|
value: number
|
||||||
|
fill: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFunnelData(
|
||||||
|
totalAccounts: number,
|
||||||
|
activeAccounts: number,
|
||||||
|
dailyData?: DailyUsageStat[],
|
||||||
|
modelData?: ModelUsageStat[],
|
||||||
|
): FunnelStep[] {
|
||||||
|
const activeDevicesToday = dailyData?.length
|
||||||
|
? dailyData.reduce((s, d) => s + d.unique_devices, 0)
|
||||||
|
: 0
|
||||||
|
const activeModels = modelData?.filter((m) => m.request_count > 0).length ?? 0
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ name: '注册用户', value: totalAccounts, fill: '#8c8c8c' },
|
||||||
|
{ name: '活跃用户', value: activeAccounts, fill: '#863bff' },
|
||||||
|
{ name: '今日使用', value: Math.max(activeDevicesToday, 0), fill: '#47bfff' },
|
||||||
|
{ name: '使用多模型', value: activeModels, fill: '#10b981' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Daily Trend Bar Data ───
|
||||||
|
|
||||||
|
interface DailyTrend {
|
||||||
|
day: string
|
||||||
|
requests: number
|
||||||
|
inputTokens: number
|
||||||
|
outputTokens: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDailyTrend(data?: DailyUsageStat[]): DailyTrend[] {
|
||||||
|
if (!data) return []
|
||||||
|
return data.map((d) => ({
|
||||||
|
day: d.day.slice(5), // MM-DD
|
||||||
|
requests: d.request_count,
|
||||||
|
inputTokens: Math.round(d.input_tokens / 1000), // K tokens
|
||||||
|
outputTokens: Math.round(d.output_tokens / 1000),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Component ───
|
||||||
|
|
||||||
|
export default function Usage() {
|
||||||
|
const [days, setDays] = useState(30)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: dailyData,
|
||||||
|
isLoading: dailyLoading,
|
||||||
|
error: dailyError,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['usage-daily', days],
|
||||||
|
queryFn: ({ signal }) => telemetryService.dailyStats({ days }, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: modelData, isLoading: modelLoading } = useQuery({
|
||||||
|
queryKey: ['usage-model', days],
|
||||||
|
queryFn: ({ signal }) => telemetryService.modelStats({}, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: dashboardStats } = useQuery({
|
||||||
|
queryKey: ['stats-dashboard'],
|
||||||
|
queryFn: ({ signal }) => statsService.dashboard(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (dailyError) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="用量统计" description="查看模型使用情况和 Token 消耗" />
|
||||||
|
<ErrorState message={(dailyError as Error).message} onRetry={() => refetch()} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalRequests = dailyData?.reduce((s, d) => s + d.request_count, 0) ?? 0
|
||||||
|
const totalTokens = dailyData?.reduce((s, d) => s + d.input_tokens + d.output_tokens, 0) ?? 0
|
||||||
|
|
||||||
|
const totalAccounts = dashboardStats?.total_accounts ?? 0
|
||||||
|
const activeAccounts = dashboardStats?.active_accounts ?? 0
|
||||||
|
|
||||||
|
const funnelData = buildFunnelData(totalAccounts, activeAccounts, dailyData, modelData)
|
||||||
|
const trendData = buildDailyTrend(dailyData)
|
||||||
|
|
||||||
|
const dailyColumns: ProColumns<DailyUsageStat>[] = [
|
||||||
|
{ title: '日期', dataIndex: 'day', width: 120 },
|
||||||
|
{
|
||||||
|
title: '请求数',
|
||||||
|
dataIndex: 'request_count',
|
||||||
|
width: 100,
|
||||||
|
render: (_, r) => r.request_count.toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '输入 Token',
|
||||||
|
dataIndex: 'input_tokens',
|
||||||
|
width: 120,
|
||||||
|
render: (_, r) => r.input_tokens.toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '输出 Token',
|
||||||
|
dataIndex: 'output_tokens',
|
||||||
|
width: 120,
|
||||||
|
render: (_, r) => r.output_tokens.toLocaleString(),
|
||||||
|
},
|
||||||
|
{ title: '设备数', dataIndex: 'unique_devices', width: 80 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const modelColumns: ProColumns<ModelUsageStat>[] = [
|
||||||
|
{ title: '模型', dataIndex: 'model_id', width: 200 },
|
||||||
|
{
|
||||||
|
title: '请求数',
|
||||||
|
dataIndex: 'request_count',
|
||||||
|
width: 100,
|
||||||
|
render: (_, r) => r.request_count.toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '输入 Token',
|
||||||
|
dataIndex: 'input_tokens',
|
||||||
|
width: 120,
|
||||||
|
render: (_, r) => r.input_tokens.toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '输出 Token',
|
||||||
|
dataIndex: 'output_tokens',
|
||||||
|
width: 120,
|
||||||
|
render: (_, r) => r.output_tokens.toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '平均延迟',
|
||||||
|
dataIndex: 'avg_latency_ms',
|
||||||
|
width: 100,
|
||||||
|
render: (_, r) => (r.avg_latency_ms ? `${Math.round(r.avg_latency_ms)}ms` : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '成功率',
|
||||||
|
dataIndex: 'success_rate',
|
||||||
|
width: 100,
|
||||||
|
render: (_, r) => `${(r.success_rate * 100).toFixed(1)}%`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="用量统计"
|
||||||
|
description="查看模型使用情况、Token 消耗和用户转化"
|
||||||
|
actions={
|
||||||
|
<Select
|
||||||
|
value={days}
|
||||||
|
onChange={setDays}
|
||||||
|
options={[
|
||||||
|
{ value: 7, label: '最近 7 天' },
|
||||||
|
{ value: 30, label: '最近 30 天' },
|
||||||
|
{ value: 90, label: '最近 90 天' },
|
||||||
|
]}
|
||||||
|
className="w-36"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<Row gutter={[16, 16]} className="mb-6">
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card className="hover:shadow-md transition-shadow duration-200">
|
||||||
|
<Statistic
|
||||||
|
title={
|
||||||
|
<span className="text-neutral-500 dark:text-neutral-400 text-xs font-medium uppercase tracking-wide">
|
||||||
|
总请求数
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
value={totalRequests}
|
||||||
|
prefix={<ThunderboltOutlined style={{ color: '#863bff' }} />}
|
||||||
|
valueStyle={{ fontWeight: 600, color: '#863bff' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card className="hover:shadow-md transition-shadow duration-200">
|
||||||
|
<Statistic
|
||||||
|
title={
|
||||||
|
<span className="text-neutral-500 dark:text-neutral-400 text-xs font-medium uppercase tracking-wide">
|
||||||
|
总 Token 数
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
value={totalTokens}
|
||||||
|
prefix={<ColumnWidthOutlined style={{ color: '#47bfff' }} />}
|
||||||
|
valueStyle={{ fontWeight: 600, color: '#47bfff' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card className="hover:shadow-md transition-shadow duration-200">
|
||||||
|
<Statistic
|
||||||
|
title={
|
||||||
|
<span className="text-neutral-500 dark:text-neutral-400 text-xs font-medium uppercase tracking-wide">
|
||||||
|
注册用户
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
value={totalAccounts}
|
||||||
|
prefix={<UserOutlined style={{ color: '#10b981' }} />}
|
||||||
|
valueStyle={{ fontWeight: 600, color: '#10b981' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card className="hover:shadow-md transition-shadow duration-200">
|
||||||
|
<Statistic
|
||||||
|
title={
|
||||||
|
<span className="text-neutral-500 dark:text-neutral-400 text-xs font-medium uppercase tracking-wide">
|
||||||
|
活跃用户
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
value={activeAccounts}
|
||||||
|
prefix={<TeamOutlined style={{ color: '#f59e0b' }} />}
|
||||||
|
valueStyle={{ fontWeight: 600, color: '#f59e0b' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Conversion Funnel + Daily Trend */}
|
||||||
|
<Row gutter={[16, 16]} className="mb-6">
|
||||||
|
<Col xs={24} lg={10}>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||||
|
用户转化漏斗
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<FunnelChart>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => [value.toLocaleString(), '数量']}
|
||||||
|
/>
|
||||||
|
<Funnel
|
||||||
|
dataKey="value"
|
||||||
|
data={funnelData}
|
||||||
|
isAnimationActive
|
||||||
|
>
|
||||||
|
<LabelList
|
||||||
|
position="right"
|
||||||
|
dataKey="name"
|
||||||
|
fill="#555"
|
||||||
|
stroke="none"
|
||||||
|
fontSize={12}
|
||||||
|
/>
|
||||||
|
</Funnel>
|
||||||
|
</FunnelChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={14}>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||||
|
每日趋势
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<BarChart data={trendData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||||
|
<XAxis dataKey="day" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number, name: string) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
requests: '请求数',
|
||||||
|
inputTokens: '输入 Token(K)',
|
||||||
|
outputTokens: '输出 Token(K)',
|
||||||
|
}
|
||||||
|
return [value.toLocaleString(), labels[name] ?? name]
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="requests" fill="#863bff" radius={[4, 4, 0, 0]} barSize={8} />
|
||||||
|
<Bar dataKey="inputTokens" fill="#47bfff" radius={[4, 4, 0, 0]} barSize={8} />
|
||||||
|
<Bar dataKey="outputTokens" fill="#10b981" radius={[4, 4, 0, 0]} barSize={8} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Daily Stats */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||||
|
每日统计
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
className="mb-6"
|
||||||
|
size="small"
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
|
<ProTable<DailyUsageStat>
|
||||||
|
columns={dailyColumns}
|
||||||
|
dataSource={dailyData ?? []}
|
||||||
|
loading={dailyLoading}
|
||||||
|
rowKey="day"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={false}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Model Stats */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||||
|
按模型统计
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
|
<ProTable<ModelUsageStat>
|
||||||
|
columns={modelColumns}
|
||||||
|
dataSource={modelData ?? []}
|
||||||
|
loading={modelLoading}
|
||||||
|
rowKey="model_id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={false}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
admin-v2/src/router/AuthGuard.tsx
Normal file
71
admin-v2/src/router/AuthGuard.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ZCLAW Admin V2 — Auth Guard with session restore
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// Auth strategy:
|
||||||
|
// 1. On first mount, always validate the HttpOnly cookie via GET /auth/me
|
||||||
|
// 2. If cookie valid -> restore session and render children
|
||||||
|
// 3. If cookie invalid -> clean up and redirect to /login
|
||||||
|
// 4. If already authenticated (from login flow) -> render immediately
|
||||||
|
//
|
||||||
|
// This eliminates the race condition where localStorage had account data
|
||||||
|
// but the HttpOnly cookie was expired, causing children to render and
|
||||||
|
// make failing API calls.
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom'
|
||||||
|
import { Spin } from 'antd'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
import { authService } from '@/services/auth'
|
||||||
|
|
||||||
|
type GuardState = 'checking' | 'authenticated' | 'unauthenticated'
|
||||||
|
|
||||||
|
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||||
|
const login = useAuthStore((s) => s.login)
|
||||||
|
const logout = useAuthStore((s) => s.logout)
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
// Track validation attempt to avoid double-calling (React StrictMode)
|
||||||
|
const validated = useRef(false)
|
||||||
|
const [guardState, setGuardState] = useState<GuardState>(
|
||||||
|
isAuthenticated ? 'authenticated' : 'checking'
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Already authenticated from login flow — skip validation
|
||||||
|
if (isAuthenticated) {
|
||||||
|
setGuardState('authenticated')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent double-validation in React StrictMode
|
||||||
|
if (validated.current) return
|
||||||
|
validated.current = true
|
||||||
|
|
||||||
|
// Validate HttpOnly cookie via /auth/me
|
||||||
|
authService.me()
|
||||||
|
.then((meAccount) => {
|
||||||
|
login(meAccount)
|
||||||
|
setGuardState('authenticated')
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
logout()
|
||||||
|
setGuardState('unauthenticated')
|
||||||
|
})
|
||||||
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
if (guardState === 'checking') {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guardState === 'unauthenticated') {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
42
admin-v2/src/router/index.tsx
Normal file
42
admin-v2/src/router/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 路由定义
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { createBrowserRouter } from 'react-router-dom'
|
||||||
|
import { AuthGuard } from './AuthGuard'
|
||||||
|
import AdminLayout from '@/layouts/AdminLayout'
|
||||||
|
|
||||||
|
export const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
lazy: () => import('@/pages/Login').then((m) => ({ Component: m.default })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
element: (
|
||||||
|
<AuthGuard>
|
||||||
|
<AdminLayout />
|
||||||
|
</AuthGuard>
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
{ index: true, lazy: () => import('@/pages/Dashboard').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'accounts', lazy: () => import('@/pages/Accounts').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'roles', lazy: () => import('@/pages/Roles').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'model-services', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'providers', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'models', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'agent-templates', lazy: () => import('@/pages/AgentTemplates').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'api-keys', lazy: () => import('@/pages/ApiKeys').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'usage', lazy: () => import('@/pages/Usage').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'billing', lazy: () => import('@/pages/Billing').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'relay', lazy: () => import('@/pages/Relay').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'scheduled-tasks', lazy: () => import('@/pages/ScheduledTasks').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'knowledge', lazy: () => import('@/pages/Knowledge').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'config', lazy: () => import('@/pages/Config').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'prompts', lazy: () => import('@/pages/Prompts').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'logs', lazy: () => import('@/pages/Logs').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'config-sync', lazy: () => import('@/pages/ConfigSync').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'industries', lazy: () => import('@/pages/Industries').then((m) => ({ Component: m.default })) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
16
admin-v2/src/services/accounts.ts
Normal file
16
admin-v2/src/services/accounts.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { AccountPublic, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const accountService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<AccountPublic>>('/accounts', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
get: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.get<AccountPublic>(`/accounts/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>, signal?: AbortSignal) =>
|
||||||
|
request.patch<AccountPublic>(`/accounts/${id}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
updateStatus: (id: string, data: { status: AccountPublic['status'] }, signal?: AbortSignal) =>
|
||||||
|
request.patch(`/accounts/${id}/status`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
31
admin-v2/src/services/agent-templates.ts
Normal file
31
admin-v2/src/services/agent-templates.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { AgentTemplate, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const agentTemplateService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<AgentTemplate>>('/agent-templates', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
create: (data: {
|
||||||
|
name: string; description?: string; category?: string; source?: string
|
||||||
|
model?: string; system_prompt?: string; tools?: string[]
|
||||||
|
capabilities?: string[]; temperature?: number; max_tokens?: number
|
||||||
|
visibility?: string; emoji?: string; personality?: string
|
||||||
|
soul_content?: string; welcome_message?: string
|
||||||
|
communication_style?: string; source_id?: string
|
||||||
|
scenarios?: string[]
|
||||||
|
quick_commands?: Array<{ label: string; command: string }>
|
||||||
|
}, signal?: AbortSignal) =>
|
||||||
|
request.post<AgentTemplate>('/agent-templates', data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: {
|
||||||
|
description?: string; model?: string; system_prompt?: string
|
||||||
|
tools?: string[]; capabilities?: string[]; temperature?: number
|
||||||
|
max_tokens?: number; visibility?: string; status?: string
|
||||||
|
}, signal?: AbortSignal) =>
|
||||||
|
request.post<AgentTemplate>(`/agent-templates/${id}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
archive: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.delete<AgentTemplate>(`/agent-templates/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
15
admin-v2/src/services/api-keys.ts
Normal file
15
admin-v2/src/services/api-keys.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { TokenInfo, CreateTokenRequest, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
// 使用 /tokens 路由 (api_tokens 表),前端 UI 字段 {name, expires_days, permissions} 与此后端匹配
|
||||||
|
// 注: /keys 路由 (account_api_keys 表) 需要 {provider_id, key_value},属于不同的 Key 管理系统
|
||||||
|
export const apiKeyService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<TokenInfo>>('/tokens', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
create: (data: CreateTokenRequest, signal?: AbortSignal) =>
|
||||||
|
request.post<TokenInfo>('/tokens', data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
revoke: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.delete(`/tokens/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
10
admin-v2/src/services/auth.ts
Normal file
10
admin-v2/src/services/auth.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { AccountPublic, LoginRequest, LoginResponse } from '@/types'
|
||||||
|
|
||||||
|
export const authService = {
|
||||||
|
login: (data: LoginRequest, signal?: AbortSignal) =>
|
||||||
|
request.post<LoginResponse>('/auth/login', data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
me: (signal?: AbortSignal) =>
|
||||||
|
request.get<AccountPublic>('/auth/me', withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
98
admin-v2/src/services/billing.ts
Normal file
98
admin-v2/src/services/billing.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
|
||||||
|
// === Types ===
|
||||||
|
|
||||||
|
export interface BillingPlan {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
display_name: string
|
||||||
|
description: string | null
|
||||||
|
price_cents: number
|
||||||
|
currency: string
|
||||||
|
interval: string
|
||||||
|
features: Record<string, unknown>
|
||||||
|
limits: Record<string, unknown>
|
||||||
|
is_default: boolean
|
||||||
|
sort_order: number
|
||||||
|
status: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Subscription {
|
||||||
|
id: string
|
||||||
|
account_id: string
|
||||||
|
plan_id: string
|
||||||
|
status: string
|
||||||
|
current_period_start: string
|
||||||
|
current_period_end: string
|
||||||
|
trial_end: string | null
|
||||||
|
canceled_at: string | null
|
||||||
|
cancel_at_period_end: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageQuota {
|
||||||
|
id: string
|
||||||
|
account_id: string
|
||||||
|
period_start: string
|
||||||
|
period_end: string
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
relay_requests: number
|
||||||
|
hand_executions: number
|
||||||
|
pipeline_runs: number
|
||||||
|
max_input_tokens: number | null
|
||||||
|
max_output_tokens: number | null
|
||||||
|
max_relay_requests: number | null
|
||||||
|
max_hand_executions: number | null
|
||||||
|
max_pipeline_runs: number | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionInfo {
|
||||||
|
plan: BillingPlan
|
||||||
|
subscription: Subscription | null
|
||||||
|
usage: UsageQuota
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentResult {
|
||||||
|
payment_id: string
|
||||||
|
trade_no: string
|
||||||
|
pay_url: string
|
||||||
|
amount_cents: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentStatus {
|
||||||
|
id: string
|
||||||
|
method: string
|
||||||
|
amount_cents: number
|
||||||
|
currency: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Service ===
|
||||||
|
|
||||||
|
export const billingService = {
|
||||||
|
listPlans: (signal?: AbortSignal) =>
|
||||||
|
request.get<BillingPlan[]>('/billing/plans', withSignal({}, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
getSubscription: (signal?: AbortSignal) =>
|
||||||
|
request.get<SubscriptionInfo>('/billing/subscription', withSignal({}, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
createPayment: (data: { plan_id: string; payment_method: 'alipay' | 'wechat' }) =>
|
||||||
|
request.post<PaymentResult>('/billing/payments', data).then((r) => r.data),
|
||||||
|
|
||||||
|
getPaymentStatus: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.get<PaymentStatus>(`/billing/payments/${id}`, withSignal({}, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
/** 管理员切换用户订阅计划 (super_admin only) */
|
||||||
|
adminSwitchPlan: (accountId: string, planId: string) =>
|
||||||
|
request.put<{ success: boolean; subscription: Subscription }>(`/admin/accounts/${accountId}/subscription`, { plan_id: planId })
|
||||||
|
.then((r) => r.data),
|
||||||
|
}
|
||||||
7
admin-v2/src/services/config-sync.ts
Normal file
7
admin-v2/src/services/config-sync.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { ConfigSyncLog, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const configSyncService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<ConfigSyncLog>>('/config/sync-logs', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
11
admin-v2/src/services/config.ts
Normal file
11
admin-v2/src/services/config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { ConfigItem, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const configService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<ConfigItem>>('/config/items', withSignal({ params }, signal))
|
||||||
|
.then((r) => r.data.items),
|
||||||
|
|
||||||
|
update: (id: string, data: { value: string | number | boolean }, signal?: AbortSignal) =>
|
||||||
|
request.put<ConfigItem>(`/config/items/${id}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
105
admin-v2/src/services/industries.ts
Normal file
105
admin-v2/src/services/industries.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 行业配置 API 服务层
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { PaginatedResponse } from '@/types'
|
||||||
|
import type { IndustryInfo, AccountIndustryItem } from '@/types'
|
||||||
|
|
||||||
|
/** 行业列表项(列表接口返回) */
|
||||||
|
export interface IndustryListItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
description: string
|
||||||
|
status: string
|
||||||
|
source: string
|
||||||
|
keywords_count: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行业完整配置(含关键词、prompt 等) */
|
||||||
|
export interface IndustryFullConfig {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
description: string
|
||||||
|
status: string
|
||||||
|
source: string
|
||||||
|
keywords: string[]
|
||||||
|
system_prompt: string
|
||||||
|
cold_start_template: string
|
||||||
|
pain_seed_categories: string[]
|
||||||
|
skill_priorities: Array<{ skill_id: string; priority: number }>
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建行业请求 */
|
||||||
|
export interface CreateIndustryRequest {
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
description: string
|
||||||
|
keywords?: string[]
|
||||||
|
system_prompt?: string
|
||||||
|
cold_start_template?: string
|
||||||
|
pain_seed_categories?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新行业请求 */
|
||||||
|
export interface UpdateIndustryRequest {
|
||||||
|
name?: string
|
||||||
|
icon?: string
|
||||||
|
description?: string
|
||||||
|
status?: string
|
||||||
|
keywords?: string[]
|
||||||
|
system_prompt?: string
|
||||||
|
cold_start_template?: string
|
||||||
|
pain_seed_categories?: string[]
|
||||||
|
skill_priorities?: Array<{ skill_id: string; priority: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置用户行业请求 */
|
||||||
|
export interface SetAccountIndustriesRequest {
|
||||||
|
industries: Array<{
|
||||||
|
industry_id: string
|
||||||
|
is_primary: boolean
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const industryService = {
|
||||||
|
/** 行业列表 */
|
||||||
|
list: (params?: { page?: number; page_size?: number; status?: string }, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<IndustryListItem>>('/industries', withSignal({ params }, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
/** 行业详情 */
|
||||||
|
get: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.get<IndustryInfo>(`/industries/${id}`, withSignal({}, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
/** 行业完整配置 */
|
||||||
|
getFullConfig: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.get<IndustryFullConfig>(`/industries/${id}/full-config`, withSignal({}, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
/** 创建行业 */
|
||||||
|
create: (data: CreateIndustryRequest) =>
|
||||||
|
request.post<IndustryInfo>('/industries', data).then((r) => r.data),
|
||||||
|
|
||||||
|
/** 更新行业 */
|
||||||
|
update: (id: string, data: UpdateIndustryRequest) =>
|
||||||
|
request.patch<IndustryInfo>(`/industries/${id}`, data).then((r) => r.data),
|
||||||
|
|
||||||
|
/** 获取用户授权行业 */
|
||||||
|
getAccountIndustries: (accountId: string, signal?: AbortSignal) =>
|
||||||
|
request.get<AccountIndustryItem[]>(`/accounts/${accountId}/industries`, withSignal({}, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
/** 设置用户授权行业 */
|
||||||
|
setAccountIndustries: (accountId: string, data: SetAccountIndustriesRequest) =>
|
||||||
|
request.put<AccountIndustryItem[]>(`/accounts/${accountId}/industries`, data)
|
||||||
|
.then((r) => r.data),
|
||||||
|
}
|
||||||
208
admin-v2/src/services/knowledge.ts
Normal file
208
admin-v2/src/services/knowledge.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
|
||||||
|
// === Types ===
|
||||||
|
|
||||||
|
export interface CategoryResponse {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
parent_id: string | null
|
||||||
|
icon: string | null
|
||||||
|
sort_order: number
|
||||||
|
item_count: number
|
||||||
|
children: CategoryResponse[]
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeItem {
|
||||||
|
id: string
|
||||||
|
category_id: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
keywords: string[]
|
||||||
|
related_questions: string[]
|
||||||
|
priority: number
|
||||||
|
status: string
|
||||||
|
version: number
|
||||||
|
source: string
|
||||||
|
tags: string[]
|
||||||
|
created_by: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
chunk_id: string
|
||||||
|
item_id: string
|
||||||
|
item_title: string
|
||||||
|
category_name: string
|
||||||
|
content: string
|
||||||
|
score: number
|
||||||
|
keywords: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsOverview {
|
||||||
|
total_items: number
|
||||||
|
active_items: number
|
||||||
|
total_categories: number
|
||||||
|
weekly_new_items: number
|
||||||
|
total_references: number
|
||||||
|
avg_reference_per_item: number
|
||||||
|
hit_rate: number
|
||||||
|
injection_rate: number
|
||||||
|
positive_feedback_rate: number
|
||||||
|
stale_items_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListItemsResponse {
|
||||||
|
items: KnowledgeItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Structured Data Sources ===
|
||||||
|
|
||||||
|
export interface StructuredSource {
|
||||||
|
id: string
|
||||||
|
account_id: string
|
||||||
|
name: string
|
||||||
|
source_type: string
|
||||||
|
row_count: number
|
||||||
|
columns: string[]
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StructuredRow {
|
||||||
|
id: string
|
||||||
|
source_id: string
|
||||||
|
row_data: Record<string, unknown>
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StructuredQueryResult {
|
||||||
|
row_id: string
|
||||||
|
source_name: string
|
||||||
|
row_data: Record<string, unknown>
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Service ===
|
||||||
|
|
||||||
|
export const knowledgeService = {
|
||||||
|
// 分类
|
||||||
|
listCategories: (signal?: AbortSignal) =>
|
||||||
|
request.get<CategoryResponse[]>('/knowledge/categories', withSignal({}, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
createCategory: (data: { name: string; description?: string; parent_id?: string; icon?: string }) =>
|
||||||
|
request.post('/knowledge/categories', data).then((r) => r.data),
|
||||||
|
|
||||||
|
deleteCategory: (id: string) =>
|
||||||
|
request.delete(`/knowledge/categories/${id}`).then((r) => r.data),
|
||||||
|
|
||||||
|
updateCategory: (id: string, data: { name?: string; description?: string; parent_id?: string; icon?: string }) =>
|
||||||
|
request.put(`/knowledge/categories/${id}`, data).then((r) => r.data),
|
||||||
|
|
||||||
|
reorderCategories: (items: Array<{ id: string; sort_order: number }>) =>
|
||||||
|
request.patch('/knowledge/categories/reorder', { items }).then((r) => r.data),
|
||||||
|
|
||||||
|
getCategoryItems: (id: string, params?: { page?: number; page_size?: number; status?: string }, signal?: AbortSignal) =>
|
||||||
|
request.get<ListItemsResponse>(`/knowledge/categories/${id}/items`, withSignal({ params }, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
// 条目
|
||||||
|
listItems: (params: { page?: number; page_size?: number; category_id?: string; status?: string; keyword?: string }, signal?: AbortSignal) =>
|
||||||
|
request.get<ListItemsResponse>('/knowledge/items', withSignal({ params }, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
getItem: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.get<KnowledgeItem>(`/knowledge/items/${id}`, withSignal({}, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
createItem: (data: {
|
||||||
|
category_id: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
keywords?: string[]
|
||||||
|
related_questions?: string[]
|
||||||
|
priority?: number
|
||||||
|
tags?: string[]
|
||||||
|
}) => request.post('/knowledge/items', data).then((r) => r.data),
|
||||||
|
|
||||||
|
updateItem: (id: string, data: Record<string, unknown>) =>
|
||||||
|
request.put(`/knowledge/items/${id}`, data).then((r) => r.data),
|
||||||
|
|
||||||
|
deleteItem: (id: string) =>
|
||||||
|
request.delete(`/knowledge/items/${id}`).then((r) => r.data),
|
||||||
|
|
||||||
|
batchCreate: (items: Array<{
|
||||||
|
category_id: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
keywords?: string[]
|
||||||
|
tags?: string[]
|
||||||
|
}>) => request.post('/knowledge/items/batch', items).then((r) => r.data),
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
search: (data: { query: string; category_id?: string; limit?: number }) =>
|
||||||
|
request.post<SearchResult[]>('/knowledge/search', data).then((r) => r.data),
|
||||||
|
|
||||||
|
// 分析
|
||||||
|
getOverview: (signal?: AbortSignal) =>
|
||||||
|
request.get<AnalyticsOverview>('/knowledge/analytics/overview', withSignal({}, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
getTrends: (signal?: AbortSignal) =>
|
||||||
|
request.get('/knowledge/analytics/trends', withSignal({}, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
getTopItems: (signal?: AbortSignal) =>
|
||||||
|
request.get('/knowledge/analytics/top-items', withSignal({}, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
getQuality: (signal?: AbortSignal) =>
|
||||||
|
request.get('/knowledge/analytics/quality', withSignal({}, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
getGaps: (signal?: AbortSignal) =>
|
||||||
|
request.get('/knowledge/analytics/gaps', withSignal({}, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
// 版本
|
||||||
|
getVersions: (itemId: string, signal?: AbortSignal) =>
|
||||||
|
request.get(`/knowledge/items/${itemId}/versions`, withSignal({}, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
rollbackVersion: (itemId: string, version: number) =>
|
||||||
|
request.post(`/knowledge/items/${itemId}/rollback/${version}`).then((r) => r.data),
|
||||||
|
|
||||||
|
// 推荐搜索
|
||||||
|
recommend: (data: { query: string; category_id?: string; limit?: number }) =>
|
||||||
|
request.post<SearchResult[]>('/knowledge/recommend', data).then((r) => r.data),
|
||||||
|
|
||||||
|
// 导入
|
||||||
|
importItems: (data: { category_id: string; files: Array<{ content: string; title?: string; keywords?: string[]; tags?: string[] }> }) =>
|
||||||
|
request.post('/knowledge/items/import', data).then((r) => r.data),
|
||||||
|
|
||||||
|
// === Structured Data Sources ===
|
||||||
|
listStructuredSources: (signal?: AbortSignal) =>
|
||||||
|
request.get<StructuredSource[]>('/structured/sources', withSignal({}, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
getStructuredSource: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.get<StructuredSource>(`/structured/sources/${id}`, withSignal({}, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
deleteStructuredSource: (id: string) =>
|
||||||
|
request.delete(`/structured/sources/${id}`).then((r) => r.data),
|
||||||
|
|
||||||
|
listStructuredRows: (sourceId: string, signal?: AbortSignal) =>
|
||||||
|
request.get<StructuredRow[]>(`/structured/sources/${sourceId}/rows`, withSignal({}, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
queryStructured: (data: { source_id?: string; query?: string; limit?: number }) =>
|
||||||
|
request.post<StructuredQueryResult[]>('/structured/query', data).then((r) => r.data),
|
||||||
|
}
|
||||||
7
admin-v2/src/services/logs.ts
Normal file
7
admin-v2/src/services/logs.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { OperationLog, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const logService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<OperationLog>>('/logs/operations', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
16
admin-v2/src/services/models.ts
Normal file
16
admin-v2/src/services/models.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { Model, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const modelService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<Model>>('/models', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
create: (data: Partial<Omit<Model, 'id'>>, signal?: AbortSignal) =>
|
||||||
|
request.post<Model>('/models', data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: Partial<Omit<Model, 'id'>>, signal?: AbortSignal) =>
|
||||||
|
request.patch<Model>(`/models/${id}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
delete: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.delete(`/models/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
35
admin-v2/src/services/prompts.ts
Normal file
35
admin-v2/src/services/prompts.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { PromptTemplate, PromptVersion, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const promptService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<PromptTemplate>>('/prompts', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
get: (name: string, signal?: AbortSignal) =>
|
||||||
|
request.get<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
create: (data: {
|
||||||
|
name: string; category: string; description?: string; source?: string
|
||||||
|
system_prompt: string; user_prompt_template?: string
|
||||||
|
variables?: unknown[]; min_app_version?: string
|
||||||
|
}, signal?: AbortSignal) =>
|
||||||
|
request.post<PromptTemplate>('/prompts', data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (name: string, data: { description?: string; status?: string }, signal?: AbortSignal) =>
|
||||||
|
request.put<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
archive: (name: string, signal?: AbortSignal) =>
|
||||||
|
request.delete<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
listVersions: (name: string, signal?: AbortSignal) =>
|
||||||
|
request.get<PromptVersion[]>(`/prompts/${encodeURIComponent(name)}/versions`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
createVersion: (name: string, data: {
|
||||||
|
system_prompt: string; user_prompt_template?: string
|
||||||
|
variables?: unknown[]; changelog?: string; min_app_version?: string
|
||||||
|
}, signal?: AbortSignal) =>
|
||||||
|
request.post<PromptVersion>(`/prompts/${encodeURIComponent(name)}/versions`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
rollback: (name: string, version: number, signal?: AbortSignal) =>
|
||||||
|
request.post<PromptTemplate>(`/prompts/${encodeURIComponent(name)}/rollback/${version}`, undefined, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
31
admin-v2/src/services/providers.ts
Normal file
31
admin-v2/src/services/providers.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { Provider, ProviderKey, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const providerService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<Provider>>('/providers', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
create: (data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>, signal?: AbortSignal) =>
|
||||||
|
request.post<Provider>('/providers', data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>, signal?: AbortSignal) =>
|
||||||
|
request.patch<Provider>(`/providers/${id}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
delete: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.delete(`/providers/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
listKeys: (providerId: string, signal?: AbortSignal) =>
|
||||||
|
request.get<ProviderKey[]>(`/providers/${providerId}/keys`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
addKey: (providerId: string, data: {
|
||||||
|
key_label: string; key_value: string; priority?: number
|
||||||
|
max_rpm?: number; max_tpm?: number; quota_reset_interval?: string
|
||||||
|
}, signal?: AbortSignal) =>
|
||||||
|
request.post<{ ok: boolean; key_id: string }>(`/providers/${providerId}/keys`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
toggleKey: (providerId: string, keyId: string, active: boolean, signal?: AbortSignal) =>
|
||||||
|
request.put<{ ok: boolean }>(`/providers/${providerId}/keys/${keyId}/toggle`, { active }, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
deleteKey: (providerId: string, keyId: string, signal?: AbortSignal) =>
|
||||||
|
request.delete<{ ok: boolean }>(`/providers/${providerId}/keys/${keyId}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
10
admin-v2/src/services/relay.ts
Normal file
10
admin-v2/src/services/relay.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { RelayTask, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const relayService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<RelayTask>>('/relay/tasks', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
get: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.get<RelayTask>(`/relay/tasks/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
128
admin-v2/src/services/request.ts
Normal file
128
admin-v2/src/services/request.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ZCLAW Admin V2 — Axios 实例 + 认证拦截器
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// 认证策略: HttpOnly cookie(浏览器自动附加到同域请求)。
|
||||||
|
// 所有 token 均通过 cookie 传递,前端 JS 无法读取。
|
||||||
|
// withCredentials: true 确保浏览器发送 HttpOnly cookie。
|
||||||
|
|
||||||
|
import axios from 'axios'
|
||||||
|
import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||||
|
import type { AxiosRequestConfig } from 'axios'
|
||||||
|
import type { ApiError } from '@/types'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
|
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1'
|
||||||
|
const TIMEOUT_MS = 30_000
|
||||||
|
|
||||||
|
/** API 业务错误 */
|
||||||
|
export class ApiRequestError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
public body: ApiError,
|
||||||
|
) {
|
||||||
|
super(body.message || `Request failed with status ${status}`)
|
||||||
|
this.name = 'ApiRequestError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = axios.create({
|
||||||
|
baseURL: BASE_URL,
|
||||||
|
timeout: TIMEOUT_MS,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
withCredentials: true, // 发送 HttpOnly cookies
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 响应拦截器:401 自动刷新 cookie ──────────────────────
|
||||||
|
|
||||||
|
let isRefreshing = false
|
||||||
|
let pendingRequests: Array<{
|
||||||
|
resolve: (value: unknown) => void
|
||||||
|
reject: (error: unknown) => void
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
function onTokenRefreshed() {
|
||||||
|
pendingRequests.forEach(({ resolve }) => resolve(undefined))
|
||||||
|
pendingRequests = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTokenRefreshFailed(error: unknown) {
|
||||||
|
pendingRequests.forEach(({ reject }) => reject(error))
|
||||||
|
pendingRequests = []
|
||||||
|
}
|
||||||
|
|
||||||
|
request.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError<{ error?: string; message?: string }>) => {
|
||||||
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||||
|
|
||||||
|
// 401 -> 尝试刷新 cookie
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
const store = useAuthStore.getState()
|
||||||
|
if (!store.isAuthenticated) {
|
||||||
|
store.logout()
|
||||||
|
window.location.href = '/login'
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRefreshing) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pendingRequests.push({
|
||||||
|
resolve: () => resolve(request(originalRequest)),
|
||||||
|
reject,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRequest._retry = true
|
||||||
|
isRefreshing = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Refresh endpoint uses HttpOnly cookie (sent automatically via withCredentials)
|
||||||
|
await axios.post(`${BASE_URL}/auth/refresh`, null, {
|
||||||
|
withCredentials: true,
|
||||||
|
})
|
||||||
|
// Cookie is refreshed server-side; browser has the new cookie automatically
|
||||||
|
onTokenRefreshed()
|
||||||
|
return request(originalRequest)
|
||||||
|
} catch (refreshError) {
|
||||||
|
// Refresh failed — reject all pending requests to prevent hangs
|
||||||
|
onTokenRefreshFailed(refreshError)
|
||||||
|
store.logout()
|
||||||
|
window.location.href = '/login'
|
||||||
|
return Promise.reject(refreshError)
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造 ApiRequestError
|
||||||
|
if (error.response) {
|
||||||
|
const body: ApiError = {
|
||||||
|
error: error.response.data?.error || 'unknown',
|
||||||
|
message: error.response.data?.message || `请求失败 (${error.response.status})`,
|
||||||
|
status: error.response.status,
|
||||||
|
}
|
||||||
|
return Promise.reject(new ApiRequestError(error.response.status, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 网络错误统一包装为 ApiRequestError
|
||||||
|
return Promise.reject(
|
||||||
|
new ApiRequestError(0, {
|
||||||
|
error: 'network_error',
|
||||||
|
message: error.message || '网络连接失败,请检查网络后重试',
|
||||||
|
status: 0,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export default request
|
||||||
|
|
||||||
|
/** 将 AbortSignal 注入 Axios config,用于 TanStack Query 的请求取消 */
|
||||||
|
export function withSignal(config: AxiosRequestConfig = {}, signal?: AbortSignal): AxiosRequestConfig {
|
||||||
|
if (signal) {
|
||||||
|
return { ...config, signal }
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
40
admin-v2/src/services/roles.ts
Normal file
40
admin-v2/src/services/roles.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type {
|
||||||
|
Role,
|
||||||
|
PermissionTemplate,
|
||||||
|
CreateRoleRequest,
|
||||||
|
UpdateRoleRequest,
|
||||||
|
CreateTemplateRequest,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
export const roleService = {
|
||||||
|
// ── Roles ─────────────────────────────────────────────────
|
||||||
|
list: (signal?: AbortSignal) =>
|
||||||
|
request.get<Role[]>('/roles', withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
create: (data: CreateRoleRequest, signal?: AbortSignal) =>
|
||||||
|
request.post<Role>('/roles', data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: UpdateRoleRequest, signal?: AbortSignal) =>
|
||||||
|
request.put<Role>(`/roles/${id}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
delete: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.delete(`/roles/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
// ── Role Permissions ──────────────────────────────────────
|
||||||
|
getPermissions: (roleId: string, signal?: AbortSignal) =>
|
||||||
|
request.get<string[]>(`/roles/${roleId}/permissions`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
// ── Permission Templates ──────────────────────────────────
|
||||||
|
listTemplates: (signal?: AbortSignal) =>
|
||||||
|
request.get<PermissionTemplate[]>('/permission-templates', withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
createTemplate: (data: CreateTemplateRequest, signal?: AbortSignal) =>
|
||||||
|
request.post<PermissionTemplate>('/permission-templates', data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
deleteTemplate: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.delete(`/permission-templates/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
applyTemplate: (templateId: string, accountIds: string[], signal?: AbortSignal) =>
|
||||||
|
request.post(`/permission-templates/${templateId}/apply`, { account_ids: accountIds }, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
71
admin-v2/src/services/scheduled-tasks.ts
Normal file
71
admin-v2/src/services/scheduled-tasks.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 定时任务 — Service
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import request, { withSignal } from './request'
|
||||||
|
|
||||||
|
// === Types ===
|
||||||
|
|
||||||
|
export interface TaskTarget {
|
||||||
|
type: string // "agent" | "hand" | "workflow"
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduledTask {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
schedule: string
|
||||||
|
schedule_type: string // "cron" | "interval" | "once"
|
||||||
|
target: TaskTarget
|
||||||
|
enabled: boolean
|
||||||
|
description: string | null
|
||||||
|
last_run: string | null
|
||||||
|
next_run: string | null
|
||||||
|
run_count: number
|
||||||
|
last_result: string | null
|
||||||
|
last_error: string | null
|
||||||
|
last_duration_ms: number | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateScheduledTaskRequest {
|
||||||
|
name: string
|
||||||
|
schedule: string
|
||||||
|
schedule_type?: string
|
||||||
|
target: TaskTarget
|
||||||
|
description?: string
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateScheduledTaskRequest {
|
||||||
|
name?: string
|
||||||
|
schedule?: string
|
||||||
|
schedule_type?: string
|
||||||
|
target?: TaskTarget
|
||||||
|
description?: string
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Service ===
|
||||||
|
|
||||||
|
export const scheduledTaskService = {
|
||||||
|
list: (signal?: AbortSignal) =>
|
||||||
|
request.get<ScheduledTask[]>('/scheduler/tasks', withSignal({}, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
get: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.get<ScheduledTask>(`/scheduler/tasks/${id}`, withSignal({}, signal))
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
create: (data: CreateScheduledTaskRequest) =>
|
||||||
|
request.post<ScheduledTask>('/scheduler/tasks', data)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: UpdateScheduledTaskRequest) =>
|
||||||
|
request.patch<ScheduledTask>(`/scheduler/tasks/${id}`, data)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
delete: (id: string) =>
|
||||||
|
request.delete(`/scheduler/tasks/${id}`)
|
||||||
|
.then((r) => r.data),
|
||||||
|
}
|
||||||
7
admin-v2/src/services/stats.ts
Normal file
7
admin-v2/src/services/stats.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { DashboardStats } from '@/types'
|
||||||
|
|
||||||
|
export const statsService = {
|
||||||
|
dashboard: (signal?: AbortSignal) =>
|
||||||
|
request.get<DashboardStats>('/stats/dashboard', withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
10
admin-v2/src/services/telemetry.ts
Normal file
10
admin-v2/src/services/telemetry.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { ModelUsageStat, DailyUsageStat } from '@/types'
|
||||||
|
|
||||||
|
export const telemetryService = {
|
||||||
|
modelStats: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<ModelUsageStat[]>('/telemetry/stats', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
dailyStats: (params?: { days?: number }, signal?: AbortSignal) =>
|
||||||
|
request.get<DailyUsageStat[]>('/telemetry/daily', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
12
admin-v2/src/services/usage.ts
Normal file
12
admin-v2/src/services/usage.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { UsageRecord, UsageByModel } from '@/types'
|
||||||
|
|
||||||
|
export const usageService = {
|
||||||
|
daily: (params?: { days?: number }, signal?: AbortSignal) =>
|
||||||
|
request.get<{ by_day: UsageRecord[] }>('/usage', withSignal({ params: { ...params, group_by: 'day' } }, signal))
|
||||||
|
.then((r) => r.data.by_day || []),
|
||||||
|
|
||||||
|
byModel: (params?: { days?: number }, signal?: AbortSignal) =>
|
||||||
|
request.get<{ by_model: UsageByModel[] }>('/usage', withSignal({ params: { ...params, group_by: 'model' } }, signal))
|
||||||
|
.then((r) => r.data.by_model || []),
|
||||||
|
}
|
||||||
90
admin-v2/src/stores/authStore.ts
Normal file
90
admin-v2/src/stores/authStore.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ZCLAW Admin V2 — Zustand 认证状态管理
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// 安全策略: JWT token 通过 HttpOnly cookie 传递,前端 JS 无法读取。
|
||||||
|
// account 信息(显示名/角色)存 localStorage 用于页面刷新后恢复 UI。
|
||||||
|
// isAuthenticated 标记用于判断登录状态,不暴露任何 token 到 JS。
|
||||||
|
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import type { AccountPublic } from '@/types'
|
||||||
|
|
||||||
|
/** 权限常量 — 与后端 db.rs seed_roles 保持同步 */
|
||||||
|
const ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||||
|
super_admin: [
|
||||||
|
'admin:full', 'account:admin', 'provider:manage', 'model:manage',
|
||||||
|
'model:read', 'relay:admin', 'relay:use', 'config:write', 'config:read',
|
||||||
|
'prompt:read', 'prompt:write', 'prompt:publish', 'prompt:admin',
|
||||||
|
'scheduler:read', 'knowledge:read', 'knowledge:write',
|
||||||
|
'billing:read', 'billing:write',
|
||||||
|
],
|
||||||
|
admin: [
|
||||||
|
'account:read', 'account:admin', 'provider:manage', 'model:read',
|
||||||
|
'model:manage', 'relay:use', 'relay:admin', 'config:read',
|
||||||
|
'config:write', 'prompt:read', 'prompt:write', 'prompt:publish',
|
||||||
|
'scheduler:read', 'knowledge:read', 'knowledge:write',
|
||||||
|
'billing:read',
|
||||||
|
],
|
||||||
|
user: ['model:read', 'relay:use', 'config:read', 'prompt:read'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCOUNT_KEY = 'zclaw_admin_account'
|
||||||
|
|
||||||
|
/** 从 localStorage 恢复 account 信息(token 通过 HttpOnly cookie 管理) */
|
||||||
|
function loadFromStorage(): { account: AccountPublic | null; isAuthenticated: boolean } {
|
||||||
|
const raw = localStorage.getItem(ACCOUNT_KEY)
|
||||||
|
let account: AccountPublic | null = null
|
||||||
|
if (raw) {
|
||||||
|
try { account = JSON.parse(raw) } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
// IMPORTANT: Do NOT set isAuthenticated = true from localStorage alone.
|
||||||
|
// The HttpOnly cookie must be validated via GET /auth/me before we trust
|
||||||
|
// the session. This prevents the AuthGuard race condition where children
|
||||||
|
// render and make API calls with an expired cookie.
|
||||||
|
return { account, isAuthenticated: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
isAuthenticated: boolean
|
||||||
|
account: AccountPublic | null
|
||||||
|
permissions: string[]
|
||||||
|
|
||||||
|
login: (account: AccountPublic) => void
|
||||||
|
logout: () => void
|
||||||
|
hasPermission: (permission: string) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set, get) => {
|
||||||
|
const stored = loadFromStorage()
|
||||||
|
const perms = stored.account?.role
|
||||||
|
? (ROLE_PERMISSIONS[stored.account.role] ?? [])
|
||||||
|
: []
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAuthenticated: stored.isAuthenticated,
|
||||||
|
account: stored.account,
|
||||||
|
permissions: perms,
|
||||||
|
|
||||||
|
login: (account: AccountPublic) => {
|
||||||
|
// account 保留 localStorage(仅用于 UI 显示,非敏感)
|
||||||
|
localStorage.setItem(ACCOUNT_KEY, JSON.stringify(account))
|
||||||
|
set({
|
||||||
|
isAuthenticated: true,
|
||||||
|
account,
|
||||||
|
permissions: ROLE_PERMISSIONS[account.role] ?? [],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem(ACCOUNT_KEY)
|
||||||
|
set({ isAuthenticated: false, account: null, permissions: [] })
|
||||||
|
// 调用后端 logout 清除 HttpOnly cookies(fire-and-forget)
|
||||||
|
fetch(`${import.meta.env.VITE_API_BASE_URL || '/api/v1'}/auth/logout`, { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||||
|
},
|
||||||
|
|
||||||
|
hasPermission: (permission: string) => {
|
||||||
|
const { permissions } = get()
|
||||||
|
return permissions.includes(permission) || permissions.includes('admin:full')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
56
admin-v2/src/stores/themeStore.ts
Normal file
56
admin-v2/src/stores/themeStore.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
type ThemeMode = 'light' | 'dark' | 'system'
|
||||||
|
|
||||||
|
interface ThemeState {
|
||||||
|
mode: ThemeMode
|
||||||
|
resolved: 'light' | 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSystemTheme(): 'light' | 'dark' {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTheme(mode: ThemeMode): 'light' | 'dark' {
|
||||||
|
return mode === 'system' ? getSystemTheme() : mode
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(resolved: 'light' | 'dark') {
|
||||||
|
const html = document.documentElement
|
||||||
|
html.classList.toggle('dark', resolved === 'dark')
|
||||||
|
html.setAttribute('data-theme', resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialMode(): ThemeMode {
|
||||||
|
const stored = localStorage.getItem('zclaw_admin_theme')
|
||||||
|
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored
|
||||||
|
return 'system'
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialMode = getInitialMode()
|
||||||
|
const initialResolved = resolveTheme(initialMode)
|
||||||
|
applyTheme(initialResolved)
|
||||||
|
|
||||||
|
export const useThemeStore = create<ThemeState>(() => ({
|
||||||
|
mode: initialMode,
|
||||||
|
resolved: initialResolved,
|
||||||
|
}))
|
||||||
|
|
||||||
|
export function setThemeMode(mode: ThemeMode) {
|
||||||
|
const resolved = resolveTheme(mode)
|
||||||
|
localStorage.setItem('zclaw_admin_theme', mode)
|
||||||
|
applyTheme(resolved)
|
||||||
|
useThemeStore.setState({ mode, resolved })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for system theme changes
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||||
|
const { mode } = useThemeStore.getState()
|
||||||
|
if (mode === 'system') {
|
||||||
|
const resolved = getSystemTheme()
|
||||||
|
applyTheme(resolved)
|
||||||
|
useThemeStore.setState({ resolved })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
235
admin-v2/src/styles/globals.css
Normal file
235
admin-v2/src/styles/globals.css
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
ZCLAW Admin Design Tokens
|
||||||
|
DeerFlow-inspired warm neutral palette with brand accents
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* Brand Colors */
|
||||||
|
--color-brand-purple: #863bff;
|
||||||
|
--color-brand-blue: #47bfff;
|
||||||
|
--color-brand-gradient: linear-gradient(135deg, #863bff, #47bfff);
|
||||||
|
|
||||||
|
/* Neutral (warm stone palette) */
|
||||||
|
--color-neutral-50: #fafaf9;
|
||||||
|
--color-neutral-100: #f5f5f4;
|
||||||
|
--color-neutral-200: #e7e5e4;
|
||||||
|
--color-neutral-300: #d6d3d1;
|
||||||
|
--color-neutral-400: #a8a29e;
|
||||||
|
--color-neutral-500: #78716c;
|
||||||
|
--color-neutral-600: #57534e;
|
||||||
|
--color-neutral-700: #44403c;
|
||||||
|
--color-neutral-800: #292524;
|
||||||
|
--color-neutral-900: #1c1917;
|
||||||
|
--color-neutral-950: #0c0a09;
|
||||||
|
|
||||||
|
/* Semantic Colors */
|
||||||
|
--color-success: #22c55e;
|
||||||
|
--color-success-soft: #dcfce7;
|
||||||
|
--color-warning: #f59e0b;
|
||||||
|
--color-warning-soft: #fef3c7;
|
||||||
|
--color-error: #ef4444;
|
||||||
|
--color-error-soft: #fee2e2;
|
||||||
|
--color-info: #3b82f6;
|
||||||
|
--color-info-soft: #dbeafe;
|
||||||
|
|
||||||
|
/* Dark mode neutrals */
|
||||||
|
--color-dark-bg: #0c0a09;
|
||||||
|
--color-dark-surface: #1c1917;
|
||||||
|
--color-dark-card: #292524;
|
||||||
|
--color-dark-border: #44403c;
|
||||||
|
--color-dark-text: #fafaf9;
|
||||||
|
--color-dark-text-secondary: #a8a29e;
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
--spacing-sidebar-expanded: 16rem;
|
||||||
|
--spacing-sidebar-collapsed: 3rem;
|
||||||
|
--spacing-header-height: 3.5rem;
|
||||||
|
|
||||||
|
/* Border Radius */
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
--radius-xl: 16px;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-dropdown: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
--shadow-modal: 0 8px 32px rgba(0, 0, 0, 0.16);
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Base Styles
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background-color: var(--color-neutral-50);
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
transition: background-color var(--transition-normal), color var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode overrides */
|
||||||
|
html.dark body {
|
||||||
|
background-color: var(--color-dark-bg);
|
||||||
|
color: var(--color-dark-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--color-neutral-300);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark ::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--color-dark-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus visible */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-brand-purple);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skip to content (accessibility) */
|
||||||
|
.skip-to-content {
|
||||||
|
position: absolute;
|
||||||
|
top: -100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 9999;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--color-brand-purple);
|
||||||
|
color: white;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: top var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-to-content:focus {
|
||||||
|
top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Ant Design Overrides (Light Mode)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ProTable search area */
|
||||||
|
.ant-pro-table-search {
|
||||||
|
background-color: var(--color-neutral-50) !important;
|
||||||
|
border-bottom: 1px solid var(--color-neutral-200) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card styling */
|
||||||
|
.ant-card {
|
||||||
|
border-radius: var(--radius-lg) !important;
|
||||||
|
border: 1px solid var(--color-neutral-200) !important;
|
||||||
|
box-shadow: var(--shadow-card) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-card:hover {
|
||||||
|
box-shadow: var(--shadow-card-hover) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table styling */
|
||||||
|
.ant-table-wrapper .ant-table-thead > tr > th {
|
||||||
|
background-color: var(--color-neutral-50) !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
color: var(--color-neutral-600) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal styling */
|
||||||
|
.ant-modal .ant-modal-content {
|
||||||
|
border-radius: var(--radius-lg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tag pill style */
|
||||||
|
.ant-tag {
|
||||||
|
border-radius: 9999px !important;
|
||||||
|
padding: 0 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form item */
|
||||||
|
.ant-form-item-label > label {
|
||||||
|
font-weight: 500 !important;
|
||||||
|
color: var(--color-neutral-700) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Dark Mode — Ant Design Overrides
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
html.dark .ant-card {
|
||||||
|
background-color: var(--color-dark-card) !important;
|
||||||
|
border-color: var(--color-dark-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .ant-table-wrapper .ant-table-thead > tr > th {
|
||||||
|
background-color: var(--color-dark-surface) !important;
|
||||||
|
color: var(--color-dark-text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .ant-table-wrapper .ant-table-tbody > tr > td {
|
||||||
|
border-color: var(--color-dark-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .ant-table-wrapper .ant-table-tbody > tr:hover > td {
|
||||||
|
background-color: rgba(134, 59, 255, 0.06) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .ant-modal .ant-modal-content {
|
||||||
|
background-color: var(--color-dark-card) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .ant-modal .ant-modal-header {
|
||||||
|
background-color: var(--color-dark-card) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .ant-drawer .ant-drawer-content {
|
||||||
|
background-color: var(--color-dark-surface) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .ant-form-item-label > label {
|
||||||
|
color: var(--color-dark-text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .ant-select-selector,
|
||||||
|
html.dark .ant-input,
|
||||||
|
html.dark .ant-input-number {
|
||||||
|
background-color: var(--color-dark-card) !important;
|
||||||
|
border-color: var(--color-dark-border) !important;
|
||||||
|
color: var(--color-dark-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .ant-pro-table-search {
|
||||||
|
background-color: var(--color-dark-surface) !important;
|
||||||
|
border-color: var(--color-dark-border) !important;
|
||||||
|
}
|
||||||
365
admin-v2/src/types/index.ts
Normal file
365
admin-v2/src/types/index.ts
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ZCLAW SaaS Admin — 全局类型定义
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 公共账号信息 */
|
||||||
|
export interface AccountPublic {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
display_name: string
|
||||||
|
role: 'super_admin' | 'admin' | 'user'
|
||||||
|
status: 'active' | 'disabled' | 'suspended'
|
||||||
|
totp_enabled: boolean
|
||||||
|
last_login_at: string | null
|
||||||
|
created_at: string
|
||||||
|
llm_routing: 'relay' | 'local'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录请求 */
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
totp_code?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录响应 — tokens 通过 HttpOnly cookie 传递,JS 无法读取 */
|
||||||
|
export interface LoginResponse {
|
||||||
|
account: AccountPublic
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册请求 */
|
||||||
|
export interface RegisterRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
email: string
|
||||||
|
display_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页响应 */
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
items: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行业配置 */
|
||||||
|
export interface IndustryInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
description: string
|
||||||
|
status: string
|
||||||
|
source: string
|
||||||
|
keywords?: string[]
|
||||||
|
system_prompt?: string
|
||||||
|
cold_start_template?: string
|
||||||
|
pain_seed_categories?: string[]
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户-行业关联 */
|
||||||
|
export interface AccountIndustryItem {
|
||||||
|
industry_id: string
|
||||||
|
is_primary: boolean
|
||||||
|
industry_name: string
|
||||||
|
industry_icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务商 (Provider) */
|
||||||
|
export interface Provider {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
display_name: string
|
||||||
|
api_key?: string
|
||||||
|
base_url: string
|
||||||
|
api_protocol: string
|
||||||
|
enabled: boolean
|
||||||
|
rate_limit_rpm: number | null
|
||||||
|
rate_limit_tpm: number | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模型 */
|
||||||
|
export interface Model {
|
||||||
|
id: string
|
||||||
|
provider_id: string
|
||||||
|
model_id: string
|
||||||
|
alias: string
|
||||||
|
context_window: number
|
||||||
|
max_output_tokens: number
|
||||||
|
supports_streaming: boolean
|
||||||
|
supports_vision: boolean
|
||||||
|
enabled: boolean
|
||||||
|
is_embedding: boolean
|
||||||
|
model_type: string
|
||||||
|
pricing_input: number
|
||||||
|
pricing_output: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API 密钥信息 */
|
||||||
|
export interface TokenInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
token_prefix: string
|
||||||
|
permissions: string[]
|
||||||
|
last_used_at?: string
|
||||||
|
expires_at?: string
|
||||||
|
created_at: string
|
||||||
|
token?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建 Token 请求 */
|
||||||
|
export interface CreateTokenRequest {
|
||||||
|
name: string
|
||||||
|
expires_days?: number
|
||||||
|
permissions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 中转任务 */
|
||||||
|
export interface RelayTask {
|
||||||
|
id: string
|
||||||
|
account_id: string
|
||||||
|
provider_id: string
|
||||||
|
model_id: string
|
||||||
|
status: string
|
||||||
|
priority: number
|
||||||
|
attempt_count: number
|
||||||
|
max_attempts: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
error_message: string | null
|
||||||
|
queued_at: string
|
||||||
|
started_at: string | null
|
||||||
|
completed_at: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用量记录 */
|
||||||
|
export interface UsageRecord {
|
||||||
|
day: string
|
||||||
|
count: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按模型用量 */
|
||||||
|
export interface UsageByModel {
|
||||||
|
model_id: string
|
||||||
|
count: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 系统配置项 */
|
||||||
|
export interface ConfigItem {
|
||||||
|
id: string
|
||||||
|
category: string
|
||||||
|
key_path: string
|
||||||
|
value_type: string
|
||||||
|
current_value: string | null
|
||||||
|
default_value: string | null
|
||||||
|
source: string
|
||||||
|
description: string | null
|
||||||
|
requires_restart: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 操作日志 */
|
||||||
|
export interface OperationLog {
|
||||||
|
id: number
|
||||||
|
account_id: string | null
|
||||||
|
action: string
|
||||||
|
target_type: string | null
|
||||||
|
target_id: string | null
|
||||||
|
details: Record<string, unknown> | null
|
||||||
|
ip_address: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仪表盘统计 */
|
||||||
|
export interface DashboardStats {
|
||||||
|
total_accounts: number
|
||||||
|
active_accounts: number
|
||||||
|
tasks_today: number
|
||||||
|
active_providers: number
|
||||||
|
active_models: number
|
||||||
|
tokens_today_input: number
|
||||||
|
tokens_today_output: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API 错误响应 */
|
||||||
|
export interface ApiError {
|
||||||
|
error: string
|
||||||
|
message: string
|
||||||
|
status?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提示词模板 */
|
||||||
|
export interface PromptTemplate {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
category: string
|
||||||
|
description?: string
|
||||||
|
source: 'builtin' | 'custom'
|
||||||
|
current_version: number
|
||||||
|
status: 'active' | 'deprecated' | 'archived'
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提示词版本 */
|
||||||
|
export interface PromptVersion {
|
||||||
|
id: string
|
||||||
|
template_id: string
|
||||||
|
version: number
|
||||||
|
system_prompt: string
|
||||||
|
user_prompt_template?: string
|
||||||
|
variables: PromptVariable[]
|
||||||
|
changelog?: string
|
||||||
|
min_app_version?: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提示词变量定义 */
|
||||||
|
export interface PromptVariable {
|
||||||
|
name: string
|
||||||
|
type: 'string' | 'number' | 'select' | 'boolean'
|
||||||
|
default_value?: string
|
||||||
|
description?: string
|
||||||
|
required?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Agent 模板 */
|
||||||
|
export interface AgentTemplate {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
category: string
|
||||||
|
source: 'builtin' | 'custom'
|
||||||
|
model?: string
|
||||||
|
system_prompt?: string
|
||||||
|
tools: string[]
|
||||||
|
capabilities: string[]
|
||||||
|
temperature?: number
|
||||||
|
max_tokens?: number
|
||||||
|
visibility: 'public' | 'team' | 'private'
|
||||||
|
status: 'active' | 'archived'
|
||||||
|
current_version: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
soul_content?: string
|
||||||
|
scenarios: string[]
|
||||||
|
welcome_message?: string
|
||||||
|
quick_commands: Array<{ label: string; command: string }>
|
||||||
|
personality?: string
|
||||||
|
communication_style?: string
|
||||||
|
emoji?: string
|
||||||
|
version: number
|
||||||
|
source_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Agent 模板可用列表(轻量) */
|
||||||
|
export interface AgentTemplateAvailable {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
category: string
|
||||||
|
emoji?: string
|
||||||
|
description?: string
|
||||||
|
source_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provider Key */
|
||||||
|
export interface ProviderKey {
|
||||||
|
id: string
|
||||||
|
provider_id: string
|
||||||
|
key_label: string
|
||||||
|
priority: number
|
||||||
|
max_rpm?: number
|
||||||
|
max_tpm?: number
|
||||||
|
is_active: boolean
|
||||||
|
last_429_at?: string
|
||||||
|
cooldown_until?: string
|
||||||
|
total_requests: number
|
||||||
|
total_tokens: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按模型聚合的用量统计 */
|
||||||
|
export interface ModelUsageStat {
|
||||||
|
model_id: string
|
||||||
|
request_count: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
avg_latency_ms: number | null
|
||||||
|
success_rate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按天的用量统计 */
|
||||||
|
export interface DailyUsageStat {
|
||||||
|
day: string
|
||||||
|
request_count: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
unique_devices: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 角色 */
|
||||||
|
export interface Role {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
permissions: string[]
|
||||||
|
account_count?: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 权限模板 */
|
||||||
|
export interface PermissionTemplate {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
permissions: string[]
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建角色请求 */
|
||||||
|
export interface CreateRoleRequest {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
permissions?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新角色请求 */
|
||||||
|
export interface UpdateRoleRequest {
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
permissions?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建权限模板请求 */
|
||||||
|
export interface CreateTemplateRequest {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
permissions?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 配置同步日志 */
|
||||||
|
export interface ConfigSyncLog {
|
||||||
|
id: number
|
||||||
|
account_id: string
|
||||||
|
client_fingerprint: string
|
||||||
|
action: string
|
||||||
|
config_keys: string
|
||||||
|
client_values: string | null
|
||||||
|
saas_values: string | null
|
||||||
|
resolution: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
6
admin-v2/test-results/artifacts/.last-run.json
Normal file
6
admin-v2/test-results/artifacts/.last-run.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"failedTests": [
|
||||||
|
"825d61429c68a1b0492e-735d17b3ccbad35e8726"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# Instructions
|
||||||
|
|
||||||
|
- Following Playwright test failed.
|
||||||
|
- Explain why, be concise, respect Playwright best practices.
|
||||||
|
- Provide a snippet of code with the fix, if possible.
|
||||||
|
|
||||||
|
# Test info
|
||||||
|
|
||||||
|
- Name: smoke_admin.spec.ts >> A6: 模型服务页面加载→Provider和Model tab可见
|
||||||
|
- Location: tests\e2e\smoke_admin.spec.ts:179:1
|
||||||
|
|
||||||
|
# Error details
|
||||||
|
|
||||||
|
```
|
||||||
|
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
|
||||||
|
Call log:
|
||||||
|
- waiting for locator('#main-content') to be visible
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
# Page snapshot
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- generic [ref=e1]:
|
||||||
|
- link "跳转到主要内容" [ref=e2] [cursor=pointer]:
|
||||||
|
- /url: "#main-content"
|
||||||
|
- generic [ref=e5]:
|
||||||
|
- generic [ref=e9]:
|
||||||
|
- generic [ref=e11]: Z
|
||||||
|
- heading "ZCLAW" [level=1] [ref=e12]
|
||||||
|
- paragraph [ref=e13]: AI Agent 管理平台
|
||||||
|
- paragraph [ref=e15]: 统一管理 AI 服务商、模型配置、API 密钥、用量监控与系统配置
|
||||||
|
- generic [ref=e17]:
|
||||||
|
- heading "登录" [level=2] [ref=e18]
|
||||||
|
- paragraph [ref=e19]: 输入您的账号信息以继续
|
||||||
|
- generic [ref=e22]:
|
||||||
|
- generic [ref=e28]:
|
||||||
|
- img "user" [ref=e30]:
|
||||||
|
- img [ref=e31]
|
||||||
|
- textbox "请输入用户名" [active] [ref=e33]
|
||||||
|
- generic [ref=e40]:
|
||||||
|
- img "lock" [ref=e42]:
|
||||||
|
- img [ref=e43]
|
||||||
|
- textbox "请输入密码" [ref=e45]
|
||||||
|
- img "eye-invisible" [ref=e47] [cursor=pointer]:
|
||||||
|
- img [ref=e48]
|
||||||
|
- button "登 录" [ref=e51] [cursor=pointer]:
|
||||||
|
- generic [ref=e52]: 登 录
|
||||||
|
```
|
||||||
|
|
||||||
|
# Test source
|
||||||
|
|
||||||
|
```ts
|
||||||
|
1 | /**
|
||||||
|
2 | * Smoke Tests — Admin V2 连通性断裂探测
|
||||||
|
3 | *
|
||||||
|
4 | * 6 个冒烟测试验证 Admin V2 页面与 SaaS 后端的完整连通性。
|
||||||
|
5 | * 所有测试使用真实浏览器 + 真实 SaaS Server。
|
||||||
|
6 | *
|
||||||
|
7 | * 前提条件:
|
||||||
|
8 | * - SaaS Server 运行在 http://localhost:8080
|
||||||
|
9 | * - Admin V2 dev server 运行在 http://localhost:5173
|
||||||
|
10 | * - 种子用户: testadmin / Admin123456 (super_admin)
|
||||||
|
11 | *
|
||||||
|
12 | * 运行: cd admin-v2 && npx playwright test smoke_admin
|
||||||
|
13 | */
|
||||||
|
14 |
|
||||||
|
15 | import { test, expect, type Page } from '@playwright/test';
|
||||||
|
16 |
|
||||||
|
17 | const SaaS_BASE = 'http://localhost:8080/api/v1';
|
||||||
|
18 | const ADMIN_USER = 'admin';
|
||||||
|
19 | const ADMIN_PASS = 'admin123';
|
||||||
|
20 |
|
||||||
|
21 | // Helper: 通过 API 登录获取 HttpOnly cookie + 设置 localStorage
|
||||||
|
22 | async function apiLogin(page: Page) {
|
||||||
|
23 | const res = await page.request.post(`${SaaS_BASE}/auth/login`, {
|
||||||
|
24 | data: { username: ADMIN_USER, password: ADMIN_PASS },
|
||||||
|
25 | });
|
||||||
|
26 | const json = await res.json();
|
||||||
|
27 | // 设置 localStorage 让 Admin V2 AuthGuard 认为已登录
|
||||||
|
28 | await page.goto('/');
|
||||||
|
29 | await page.evaluate((account) => {
|
||||||
|
30 | localStorage.setItem('zclaw_admin_account', JSON.stringify(account));
|
||||||
|
31 | }, json.account);
|
||||||
|
32 | return json;
|
||||||
|
33 | }
|
||||||
|
34 |
|
||||||
|
35 | // Helper: 通过 API 登录 + 导航到指定路径
|
||||||
|
36 | async function loginAndGo(page: Page, path: string) {
|
||||||
|
37 | await apiLogin(page);
|
||||||
|
38 | // 重新导航到目标路径 (localStorage 已设置,React 应识别为已登录)
|
||||||
|
39 | await page.goto(path, { waitUntil: 'networkidle' });
|
||||||
|
40 | // 等待主内容区加载
|
||||||
|
> 41 | await page.waitForSelector('#main-content', { timeout: 15000 });
|
||||||
|
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
|
||||||
|
42 | }
|
||||||
|
43 |
|
||||||
|
44 | // ── A1: 登录→Dashboard ────────────────────────────────────────────
|
||||||
|
45 |
|
||||||
|
46 | test('A1: 登录→Dashboard 5个统计卡片', async ({ page }) => {
|
||||||
|
47 | // 导航到登录页
|
||||||
|
48 | await page.goto('/login');
|
||||||
|
49 | await expect(page.getByPlaceholder('请输入用户名')).toBeVisible({ timeout: 10000 });
|
||||||
|
50 |
|
||||||
|
51 | // 填写表单
|
||||||
|
52 | await page.getByPlaceholder('请输入用户名').fill(ADMIN_USER);
|
||||||
|
53 | await page.getByPlaceholder('请输入密码').fill(ADMIN_PASS);
|
||||||
|
54 |
|
||||||
|
55 | // 提交 (Ant Design 按钮文本有全角空格 "登 录")
|
||||||
|
56 | const loginBtn = page.locator('button').filter({ hasText: /登/ }).first();
|
||||||
|
57 | await loginBtn.click();
|
||||||
|
58 |
|
||||||
|
59 | // 验证跳转到 Dashboard (可能需要等待 API 响应)
|
||||||
|
60 | await expect(page).toHaveURL(/\/(login)?$/, { timeout: 20000 });
|
||||||
|
61 |
|
||||||
|
62 | // 验证 5 个统计卡片
|
||||||
|
63 | await expect(page.getByText('总账号')).toBeVisible({ timeout: 10000 });
|
||||||
|
64 | await expect(page.getByText('活跃服务商')).toBeVisible();
|
||||||
|
65 | await expect(page.getByText('活跃模型')).toBeVisible();
|
||||||
|
66 | await expect(page.getByText('今日请求')).toBeVisible();
|
||||||
|
67 | await expect(page.getByText('今日 Token')).toBeVisible();
|
||||||
|
68 |
|
||||||
|
69 | // 验证统计卡片有数值 (不是 loading 状态)
|
||||||
|
70 | const statCards = page.locator('.ant-statistic-content-value');
|
||||||
|
71 | await expect(statCards.first()).not.toBeEmpty({ timeout: 10000 });
|
||||||
|
72 | });
|
||||||
|
73 |
|
||||||
|
74 | // ── A2: Provider CRUD ──────────────────────────────────────────────
|
||||||
|
75 |
|
||||||
|
76 | test('A2: Provider 创建→列表可见→禁用', async ({ page }) => {
|
||||||
|
77 | // 通过 API 创建 Provider
|
||||||
|
78 | await apiLogin(page);
|
||||||
|
79 | const createRes = await page.request.post(`${SaaS_BASE}/providers`, {
|
||||||
|
80 | data: {
|
||||||
|
81 | name: `smoke_provider_${Date.now()}`,
|
||||||
|
82 | provider_type: 'openai',
|
||||||
|
83 | base_url: 'https://api.smoke.test/v1',
|
||||||
|
84 | enabled: true,
|
||||||
|
85 | display_name: 'Smoke Test Provider',
|
||||||
|
86 | },
|
||||||
|
87 | });
|
||||||
|
88 | if (!createRes.ok()) {
|
||||||
|
89 | const body = await createRes.text();
|
||||||
|
90 | console.log(`A2: Provider create failed: ${createRes.status()} — ${body.slice(0, 300)}`);
|
||||||
|
91 | }
|
||||||
|
92 | expect(createRes.ok()).toBeTruthy();
|
||||||
|
93 |
|
||||||
|
94 | // 导航到 Model Services 页面
|
||||||
|
95 | await page.goto('/model-services');
|
||||||
|
96 | await page.waitForSelector('#main-content', { timeout: 15000 });
|
||||||
|
97 |
|
||||||
|
98 | // 切换到 Provider tab (如果存在 tab 切换)
|
||||||
|
99 | const providerTab = page.getByRole('tab', { name: /服务商|Provider/i });
|
||||||
|
100 | if (await providerTab.isVisible()) {
|
||||||
|
101 | await providerTab.click();
|
||||||
|
102 | }
|
||||||
|
103 |
|
||||||
|
104 | // 验证 Provider 列表非空
|
||||||
|
105 | const tableRows = page.locator('.ant-table-row');
|
||||||
|
106 | await expect(tableRows.first()).toBeVisible({ timeout: 10000 });
|
||||||
|
107 | expect(await tableRows.count()).toBeGreaterThan(0);
|
||||||
|
108 | });
|
||||||
|
109 |
|
||||||
|
110 | // ── A3: Account 管理 ───────────────────────────────────────────────
|
||||||
|
111 |
|
||||||
|
112 | test('A3: Account 列表加载→角色可见', async ({ page }) => {
|
||||||
|
113 | await loginAndGo(page, '/accounts');
|
||||||
|
114 |
|
||||||
|
115 | // 验证表格加载
|
||||||
|
116 | const tableRows = page.locator('.ant-table-row');
|
||||||
|
117 | await expect(tableRows.first()).toBeVisible({ timeout: 10000 });
|
||||||
|
118 |
|
||||||
|
119 | // 至少有 testadmin 自己
|
||||||
|
120 | expect(await tableRows.count()).toBeGreaterThanOrEqual(1);
|
||||||
|
121 |
|
||||||
|
122 | // 验证有角色列
|
||||||
|
123 | const roleText = await page.locator('.ant-table').textContent();
|
||||||
|
124 | expect(roleText).toMatch(/super_admin|admin|user/);
|
||||||
|
125 | });
|
||||||
|
126 |
|
||||||
|
127 | // ── A4: 知识管理 ───────────────────────────────────────────────────
|
||||||
|
128 |
|
||||||
|
129 | test('A4: 知识分类→条目→搜索', async ({ page }) => {
|
||||||
|
130 | // 通过 API 创建分类和条目
|
||||||
|
131 | await apiLogin(page);
|
||||||
|
132 |
|
||||||
|
133 | const catRes = await page.request.post(`${SaaS_BASE}/knowledge/categories`, {
|
||||||
|
134 | data: { name: `smoke_cat_${Date.now()}`, description: 'Smoke test category' },
|
||||||
|
135 | });
|
||||||
|
136 | expect(catRes.ok()).toBeTruthy();
|
||||||
|
137 | const catJson = await catRes.json();
|
||||||
|
138 |
|
||||||
|
139 | const itemRes = await page.request.post(`${SaaS_BASE}/knowledge/items`, {
|
||||||
|
140 | data: {
|
||||||
|
141 | title: 'Smoke Test Knowledge Item',
|
||||||
|
```
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 281 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user