Compare commits
365 Commits
9905a8d0d5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb727fdcc7 | ||
|
|
a9ea9d8691 | ||
|
|
f97e6fdbb6 | ||
|
|
7d03e6a90c | ||
|
|
415abf9e66 | ||
|
|
8d218e9ab9 | ||
|
|
e2d44ecf52 | ||
|
|
8ec6ca5990 | ||
|
|
7e8eb64c4a | ||
|
|
e88c51fd85 | ||
|
|
e10549a1b9 | ||
|
|
f3fb5340b5 | ||
|
|
35a11504d7 | ||
|
|
450569dc88 | ||
|
|
3a24455401 | ||
|
|
4e4eefdde1 | ||
|
|
0522f2bf95 | ||
|
|
04f70c797d | ||
|
|
a685e97b17 | ||
|
|
2037809196 | ||
|
|
eaa99a20db | ||
|
|
a38e91935f | ||
|
|
5687dc20e0 | ||
|
|
21c3222ad5 | ||
|
|
5381e316f0 | ||
|
|
96294d5b87 | ||
|
|
e3b6003be2 | ||
|
|
f9f5472d99 | ||
|
|
cb9e48f11d | ||
|
|
14fa7e150a | ||
|
|
f9290ea683 | ||
|
|
0754ea19c2 | ||
|
|
2cae822775 | ||
|
|
93df380ca8 | ||
|
|
90340725a4 | ||
|
|
b2758d34e9 | ||
|
|
a504a40395 | ||
|
|
1309101a94 | ||
|
|
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 |
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
|
||||||
Submodule .claude/worktrees/saas-backend updated: 4d8d560d1f...44256a511c
42
.dockerignore
Normal file
42
.dockerignore
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Build artifacts
|
||||||
|
target/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Environment and secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
|
||||||
|
# IDE and OS
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.yml
|
||||||
|
Dockerfile
|
||||||
|
|
||||||
|
# Documentation (not needed in image)
|
||||||
|
docs/
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
tests/
|
||||||
|
tests/e2e/
|
||||||
|
admin-v2/tests/
|
||||||
|
|
||||||
|
# Claude/development tools
|
||||||
|
.claude/
|
||||||
|
.planning/
|
||||||
|
.superpowers/
|
||||||
|
plans/
|
||||||
@@ -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
|
||||||
|
|||||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Rust Clippy
|
- name: Rust Clippy
|
||||||
working-directory: .
|
working-directory: .
|
||||||
run: cargo clippy --workspace -- -D warnings
|
run: cargo clippy --workspace --exclude zclaw-saas -- -D warnings
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: desktop
|
working-directory: desktop
|
||||||
@@ -94,7 +94,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Rust tests
|
- name: Run Rust tests
|
||||||
working-directory: .
|
working-directory: .
|
||||||
run: cargo test --workspace
|
run: cargo test --workspace --exclude zclaw-saas
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: desktop
|
working-directory: desktop
|
||||||
@@ -138,7 +138,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Rust release build
|
- name: Rust release build
|
||||||
working-directory: .
|
working-directory: .
|
||||||
run: cargo build --release --workspace
|
run: cargo build --release --workspace --exclude zclaw-saas
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: desktop
|
working-directory: desktop
|
||||||
|
|||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Rust tests
|
- name: Run Rust tests
|
||||||
working-directory: .
|
working-directory: .
|
||||||
run: cargo test --workspace
|
run: cargo test --workspace --exclude zclaw-saas
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: desktop
|
working-directory: desktop
|
||||||
|
|||||||
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>
|
||||||
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
|
||||||
|
```
|
||||||
402
CLAUDE.md
402
CLAUDE.md
@@ -1,44 +1,67 @@
|
|||||||
|
@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-protocols/ # 协议支持 (MCP, A2A)
|
│ ├── zclaw-protocols/ # 协议支持 (MCP 完整, A2A feature-gated)
|
||||||
│ └── zclaw-saas/ # SaaS 后端 (账号, 模型配置, 中转, 配置同步)
|
│ ├── zclaw-pipeline/ # Pipeline DSL (v1/v2, 10 行业模板)
|
||||||
├── admin/ # Next.js 管理后台
|
│ ├── 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 组件 (含 SaaS 集成)
|
│ │ ├── components/ # React UI 组件 (含 SaaS 集成)
|
||||||
@@ -64,14 +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 (9 crates) |
|
| 后端核心 | Rust Workspace (10 crates, ~66K 行) |
|
||||||
| SaaS 后端 | Axum + PostgreSQL (zclaw-saas) |
|
| SaaS 后端 | Axum + PostgreSQL (zclaw-saas) |
|
||||||
| 管理后台 | Next.js (admin/) |
|
| 管理后台 | Vite + Ant Design Pro (admin-v2/) |
|
||||||
|
|
||||||
### 2.3 Crate 依赖关系
|
### 2.3 Crate 依赖关系
|
||||||
|
|
||||||
@@ -111,11 +134,17 @@ desktop/src-tauri (→ kernel, skills, hands, 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 是任务完成的硬性条件。不允许"等一下再提交"或"最后一起推送"。**
|
||||||
|
|
||||||
***
|
***
|
||||||
|
|
||||||
@@ -130,18 +159,28 @@ desktop/src-tauri (→ kernel, skills, hands, 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 + 类型守卫`
|
||||||
@@ -188,21 +227,22 @@ Client → 负责网络通信和```
|
|||||||
|
|
||||||
## 6. 自主能力系统 (Hands)
|
## 6. 自主能力系统 (Hands)
|
||||||
|
|
||||||
ZCLAW 提供 11 个自主能力包:
|
ZCLAW 提供 12 个自主能力包(7 已注册 + 3 开发中 + 2 禁用):
|
||||||
|
|
||||||
| Hand | 功能 | 状态 |
|
| Hand | 功能 | 状态 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| Browser | 浏览器自动化 | ✅ 可用 |
|
| Browser | 浏览器自动化 | ✅ 可用 |
|
||||||
| Collector | 数据收集聚合 | ✅ 可用 |
|
| Collector | 数据收集聚合 | ✅ 可用 |
|
||||||
| Researcher | 深度研究 | ✅ 可用 |
|
| Researcher | 深度研究 | ✅ 可用 |
|
||||||
| Predictor | 预测分析 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
|
||||||
| Lead | 销售线索发现 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
|
||||||
| Clip | 视频处理 | ⚠️ 需 FFmpeg |
|
| Clip | 视频处理 | ⚠️ 需 FFmpeg |
|
||||||
| Twitter | Twitter 自动化 | ✅ 可用(12 个 API v2 真实调用,写操作需 OAuth 1.0a) |
|
| Twitter | Twitter 自动化 | ✅ 可用(12 个 API v2 真实调用,写操作需 OAuth 1.0a) |
|
||||||
| Whiteboard | 白板演示 | ✅ 可用(导出功能开发中,标注 demo) |
|
|
||||||
| Slideshow | 幻灯片生成 | ✅ 可用 |
|
|
||||||
| Speech | 语音合成 | ✅ 可用(Browser TTS 前端集成完成) |
|
|
||||||
| Quiz | 测验生成 | ✅ 可用 |
|
| Quiz | 测验生成 | ✅ 可用 |
|
||||||
|
| _reminder | 系统内部提醒 | ✅ 可用(kernel 编程注册,无 HAND.toml) |
|
||||||
|
| Whiteboard | 白板演示 | 🚧 开发中(HAND.toml 未合并到主分支) |
|
||||||
|
| Slideshow | 幻灯片生成 | 🚧 开发中(HAND.toml 未合并到主分支) |
|
||||||
|
| Speech | 语音合成 | 🚧 开发中(HAND.toml 未合并到主分支) |
|
||||||
|
| Predictor | 预测分析 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||||
|
| Lead | 销售线索发现 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||||
|
|
||||||
**触发 Hand 时:**
|
**触发 Hand 时:**
|
||||||
1. 检查依赖是否满足
|
1. 检查依赖是否满足
|
||||||
@@ -223,20 +263,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 人工验证清单
|
||||||
|
|
||||||
- [ ] 能否正常连接后端服务
|
- [ ] 能否正常连接后端服务
|
||||||
- [ ] 能否发送消息并获得流式响应
|
- [ ] 能否发送消息并获得流式响应
|
||||||
@@ -267,17 +344,43 @@ docs/
|
|||||||
- **面向未来** - 文档要帮助未来的开发者快速理解
|
- **面向未来** - 文档要帮助未来的开发者快速理解
|
||||||
- **中文优先** - 所有面向用户的文档使用中文
|
- **中文优先** - 所有面向用户的文档使用中文
|
||||||
|
|
||||||
### 8.3 完成工作后的文档同步(强制)
|
### 8.3 完成工作后的收尾流程(强制,不可跳过)
|
||||||
|
|
||||||
每次完成功能实现、架构变更、问题修复后,**必须**同步更新以下文档:
|
每次完成功能实现、架构变更、问题修复后,**必须立即执行以下收尾**:
|
||||||
|
|
||||||
1. **CLAUDE.md** — 如果涉及项目结构、技术栈、工作流程、命令的变化
|
#### 步骤 A:文档同步(代码提交前)
|
||||||
2. **docs/features/** — 如果涉及新功能、功能变更、功能状态更新
|
|
||||||
3. **docs/knowledge-base/** — 如果涉及新知识、故障排查经验、配置说明
|
|
||||||
4. **saas-config.toml 注释** — 如果涉及 SaaS 配置项变更
|
|
||||||
5. **CHANGELOG** — 如果涉及对外可见的行为变化
|
|
||||||
|
|
||||||
**执行时机:** 代码编译通过且验证成功后,在标记任务完成之前,立即执行文档更新。文档更新是任务完成的必要条件,不是可选步骤。
|
检查以下文档是否需要更新,有变更则立即修改:
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
**不允许积压。** 每次完成一个独立工作单元后立即推送。不要留到"最后一起推"。
|
||||||
|
|
||||||
|
**判断标准:** 如果工作目录有未提交文件,说明收尾流程没完成。
|
||||||
|
|
||||||
***
|
***
|
||||||
|
|
||||||
@@ -355,84 +458,137 @@ refactor(store): 统一 Store 数据获取方式
|
|||||||
|
|
||||||
***
|
***
|
||||||
|
|
||||||
|
|
||||||
## 12. 安全注意事项
|
## 12. 安全注意事项
|
||||||
|
|
||||||
</section>
|
- 不在代码中硬编码密钥
|
||||||
|
|
||||||
< + + 寜### 安全注意事项
|
|
||||||
|`
|
|
||||||
|--- 不在代码中硬编码密钥`
|
|
||||||
| - 敄 操作需要确认
|
|
||||||
` - 不在代码中硬编码密V Token/ API |
|
|
||||||
| - 保留操作审计日志
|
|
||||||
` - 用户输入必须验证` ` - 敄 就环境变量 `ZCLAW_SAAS_DEV` 模式放宽安全限制(开发环境设 `ZCLAW_SAAS_DEV=true`), + ` - **生产环境 TLS 终止**:
|
|
||||||
nginx/caddy 反代向提供 HTTPS**
|
|
||||||
|
|
|
||||||
| - Cookie `Secure` 标记在生产环境设为 true,开发环境设为 false(仅 臉 TOTP 加密密钥 `ZCLAW_TOTP_ENCRYPTION_KEY` 必须设置(64 字符 hex)
|
|
||||||
密钥) |
|
|
||||||
| - **Cookie SameSite=Strict** 鰲止 CSRF)` |
|
|
||||||
| - Refresh Token 轮换: 退出时,DB 撤销为关联, 旧 token` |
|
|
||||||
| + **Rotation 校验已使用 token 是否已撤销` |
|
|
||||||
| + **Logout 时撤销 refresh token` |
|
|
||||||
| - **TLS**: 生产环境必须使用反向代理 (nginx/caddy) 提供 HTTPS, |
|
|
||||||
| - Cookie Secure 标记: 开发环境 false, 生产 true` |
|
|
||||||
|
|
||||||
| + + | **配置说明** |
|
|
||||||
| - saas-config.toml 支持 `${ENV_VAR}` 稡式环境变量插值,如 `${DB_PASSWORD}` |
|
|
||||||
| - `ZCLAW_DATABASE_URL` 茉境变量覆盖 |
|
|
||||||
优先级最高) |
|
|
||||||
| - **Auth**: /api/auth/login` - 5次/分钟/IP (防暴力破解) |
|
|
||||||
| - `/api/auth/register` - 3次/小时/IP (防刷注册) |
|
|
||||||
| - 公共端点默认 20次/分钟/IP (防滥用) |
|
|
||||||
| - JWT 寰钥: `#[cfg(debug_assertions)]` 保护 fallback,release 枋 | ` bail` 拒绝启动` | - TOTP 加密密钥: AES-256-GCM 加密, 支持 SHA-256 崾生 JWT 密钥派生` |
|
|
||||||
- Logout 撤销: refresh token 到 DB 栘 UPDATE` |
|
|
||||||
| - Cookie: Secure 标志: 开发环境 false, 生产 true
|
|
||||||
|
|
|
||||||
| + + `SameSite=Strict` + 跨站 CSRF + SSL ( CORS) |
|
|
||||||
| + | TLS 终止:: nginx/caddy 反向代理提供 HTTPS`, 或 |
|
|
||||||
生产环境日志写入 WAF - | | **TLS 终止说明**: | 反向代理实现 HTTPS, | Axum 服务不负责 TLS 配置、 |
|
|
||||||
|
|
||||||
`saas-config.toml.example` 更新安全说明 |
|
|
||||||
| | 密钥管理 | 甤境变量引用 (`${DB_PASSWORD}` 等) |
|
|
||||||
数据库密码) | | TOML 解析支持 `${VAR}` 稡式环境变量插值, | | 通过 `ZCLAW_DATABASE_URL` 猯变量完整覆盖 (优先级最高) |
|
|
||||||
|
|
||||||
| - JWT fallback key | `#[cfg(debug_assertions)]` 保护 fallback,release 拒绝启动` | - TOTP/API Key 加密: `AES-256-GCM`, 支持 SHA-256 派生 JWT 密钥派生` | - Logout 时撤销 refresh token 到 DB (`used_at IS NULL` 切 `revoked`) + rotation 校验已撤销的旧 token` | - Cookie Secure: 开发环境 false, 生产 true | `SameSite=Strict` + 跨站 CSRF + SSR CORS 白名单 + `X-Request头 + 请求日志 | |
|
|
||||||
|
|
||||||
| - **TLS**: 生产环境必须使用反向代理 (nginx/caddy) 提供 HTTPS, | - **生产环境日志写入 WAF - | |
|
|
||||||
| - **配置说明**: `saas-config.toml` 支持 `${ENV_VAR}` 稡式环境变量插值, | 文件模板已示例已更新 |
|
|
||||||
| - `ZCLAW_SAAS_JWT_SECRET` | JWT 签名密钥 (至少 32 字符随机字符串) | | | TOTP 加密密钥 `ZCLAW_TOTP_ENCRYPTION_KEY` | TOTP 加密密钥 (hex 编码, 64 字符) | |
|
|
||||||
| | SAAS 配置环境变量 | `ZCLAW_SAAS_DEV` 开发环境 |
|
|
||||||
| `ZCLAW_SAAS_DEV=true` 放宽安全限制 (开发环境: | | 公共端点请求限流 |
|
|
||||||
| - 公共端点限流 & login/register) | refresh/logout | 默认 | `ZCLAW_SAAS_DEV` 不设置) |
|
|
||||||
| | **Cookie**: HttpOnly + Secure + SameSite=Strict + 路径="/api" + "/api/v1/auth" + `Secure` 仅在生产环境为 true |
|
|
||||||
|
|
||||||
| | **TLS**: 反向代理** 提供 HTTPS 终止** | 反向代理(如 nginx/caddy)配置上游 → [SSL 终止 (`proxy downgrade`) |
|
|
||||||
| **Cookie**: Secure 标记仅在开发环境 (`ZCLAW_SAAS_DEV=true`) 设为 false(不强制 HTTPS),生产环境设为 true |
|
|
||||||
|
|
||||||
| - **环境变量模板**: | | 瘾境命令 |
|
|
||||||
| - `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=生产) |
|
|
||||||
| - **生产环境清单单** |
|
|
||||||
| | nginx/caddy 配置反向代理 + HTTPS |
|
|
||||||
| | 确保设置 `ZCLAW_SAAS_DEV=false`(或不设置) |
|
|
||||||
| | 启用 CORS 白名单 | | | `cors_origins` 匇向实际域名 |
|
|
||||||
| | Cookie Secure=true + HttpOnly=true + SameSite=Strict |
|
|
||||||
| - JWT 寋名密钥 >= 32 字符随机字符串 |
|
|
||||||
| - 数据库密码通过 `${DB_PASSWORD}` 引用 | |
|
|
||||||
|
|
||||||
| **部署命令** (参考) |
|
|
||||||
| | 设置环境变量: `export DB_PASSWORD=your_password` |
|
|
||||||
| | `export ZCLAW_SAAS_JWT_SECRET=$(openssl rand -hex 32)` |
|
|
||||||
| | `cp saas-config.toml.example saas-config.toml` |
|
|
||||||
| | 编辑 saas-config.toml 填入实际数据库 URL |
|
|
||||||
| | `cargo build --release -p zclaw-saas` |
|
|
||||||
| | 启动服务: `./zclaw-saas` |- 不在代码中硬编码密钥
|
|
||||||
- 用户输入必须验证
|
- 用户输入必须验证
|
||||||
- 敏感操作需要确认
|
- 敏感操作需要确认
|
||||||
- 保留操作审计日志
|
- 保留操作审计日志
|
||||||
|
- 环境变量 `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-17 E2E 验证: 存储+FTS5+TF-IDF+注入闭环,去重+跨会话注入已修复 |
|
||||||
|
| SaaS 认证 (Auth) | ✅ 稳定 | Token池 RPM/TPM 轮换 + JWT password_version 失效机制 |
|
||||||
|
| Pipeline DSL | ✅ 稳定 | 04-01 17 个 YAML 模板 + DAG 执行器 |
|
||||||
|
| Hands 系统 | ✅ 稳定 | 7 注册 (6 HAND.toml + _reminder),Whiteboard/Slideshow/Speech 开发中 |
|
||||||
|
| 技能系统 (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权重→检索→注入系统提示(E2E 04-17 验证通过,去重+跨会话注入已修复)
|
||||||
|
- **LLM 驱动**: 4 Rust Driver (Anthropic/OpenAI/Gemini/Local) + 国内兼容 (DeepSeek/Qwen/Moonshot 通过 base_url)
|
||||||
|
|
||||||
|
### 最近变更
|
||||||
|
|
||||||
|
1. [04-17] 全系统 E2E 测试 129 链路: 82 PASS / 20 PARTIAL / 1 FAIL / 26 SKIP,有效通过率 79.1%。7 项 Bug 修复 (Dashboard 404/记忆去重/记忆注入/invoice_id/Prompt版本/agent隔离/行业字段)
|
||||||
|
2. [04-16] 3 项 P0 修复 + 5 项 E2E Bug 修复 + Agent 面板刷新 + TRUTH.md 数字校准
|
||||||
|
3. [04-15] Heartbeat 统一健康系统: health_snapshot.rs 统一收集器(LLM连接/记忆/会话/系统资源) + heartbeat.rs HeartbeatManager 重构 + HealthPanel.tsx 前端面板 + Tauri 命令 182→183 + intelligence 模块 15→16 文件 + 删除 intelligence-client/ 9 废弃文件
|
||||||
|
4. [04-12] 行业配置+管家主动性 全栈 5 Phase: 行业数据模型+4内置配置+ButlerRouter动态关键词+触发信号+Tauri加载+Admin管理页面+跨会话连续性+XML fencing注入格式
|
||||||
|
5. [04-09] Hermes Intelligence Pipeline 4 Chunk: ExperienceStore+Extractor, UserProfileStore+Profiler, NlScheduleParser, TrajectoryRecorder+Compressor (684 tests, 0 failed)
|
||||||
|
6. [04-09] 管家模式6交付物完成: ButlerRouter + 冷启动 + 简洁模式UI + 桥测试 + 发布文档
|
||||||
|
|
||||||
|
<!-- 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 -->
|
||||||
|
|||||||
1731
Cargo.lock
generated
1731
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
16
Cargo.toml
@@ -19,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"
|
||||||
@@ -57,12 +57,15 @@ 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", "sqlite", "postgres"] }
|
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)
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
|
||||||
|
|
||||||
|
# Synchronous HTTP (for WASM host functions in blocking threads)
|
||||||
|
ureq = { version = "3", features = ["rustls"] }
|
||||||
|
|
||||||
# URL parsing
|
# URL parsing
|
||||||
url = "2"
|
url = "2"
|
||||||
|
|
||||||
@@ -84,6 +87,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"
|
||||||
@@ -102,7 +106,7 @@ wasmtime-wasi = { version = "43" }
|
|||||||
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", "cookie"] }
|
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", "timeout"] }
|
tower-http = { version = "0.5", features = ["cors", "trace", "limit", "timeout"] }
|
||||||
@@ -111,6 +115,12 @@ 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
|
# TCP socket configuration
|
||||||
socket2 = { version = "0.5", features = ["all"] }
|
socket2 = { version = "0.5", features = ["all"] }
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
VITE_API_BASE_URL=http://localhost:8080/api/v1
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
VITE_API_BASE_URL=/api/v1
|
|
||||||
24
admin-temp-dir/.gitignore
vendored
24
admin-temp-dir/.gitignore
vendored
@@ -1,24 +0,0 @@
|
|||||||
# 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?
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# 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...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<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>admin-v2</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "admin-v2",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc -b && vite build",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@ant-design/charts": "^2.6.7",
|
|
||||||
"@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",
|
|
||||||
"zustand": "^5.0.12"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/js": "^9.39.4",
|
|
||||||
"@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",
|
|
||||||
"typescript": "~5.9.3",
|
|
||||||
"typescript-eslint": "^8.57.0",
|
|
||||||
"vite": "^8.0.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
5008
admin-temp-dir/pnpm-lock.yaml
generated
5008
admin-temp-dir/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,24 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 4.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,53 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// AdminLayout — ProLayout 管理后台布局
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
|
||||||
import ProLayout from '@ant-design/pro-layout'
|
|
||||||
import {
|
|
||||||
DashboardOutlined,
|
|
||||||
TeamOutlined,
|
|
||||||
CloudServerOutlined,
|
|
||||||
ApiOutlined,
|
|
||||||
KeyOutlined,
|
|
||||||
BarChartOutlined,
|
|
||||||
SwapOutlined,
|
|
||||||
SettingOutlined,
|
|
||||||
FileTextOutlined,
|
|
||||||
MessageOutlined,
|
|
||||||
RobotOutlined,
|
|
||||||
LogoutOutlined,
|
|
||||||
} from '@ant-design/icons'
|
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
|
||||||
import { Avatar, Dropdown, message } from 'antd'
|
|
||||||
import type { MenuDataItem } from '@ant-design/pro-layout'
|
|
||||||
|
|
||||||
const menuConfig: MenuDataItem[] = [
|
|
||||||
{ path: '/', name: '仪表盘', icon: <DashboardOutlined /> },
|
|
||||||
{ path: '/accounts', name: '账号管理', icon: <TeamOutlined />, permission: 'account:admin' },
|
|
||||||
{ path: '/providers', name: '服务商', icon: <CloudServerOutlined />, permission: 'provider:manage' },
|
|
||||||
{ path: '/models', name: '模型管理', icon: <ApiOutlined />, permission: 'model:read' },
|
|
||||||
{ path: '/agent-templates', name: 'Agent 模板', icon: <RobotOutlined />, permission: 'model:read' },
|
|
||||||
{ path: '/api-keys', name: 'API 密钥', icon: <KeyOutlined />, permission: 'admin:full' },
|
|
||||||
{ path: '/usage', name: '用量统计', icon: <BarChartOutlined />, permission: 'admin:full' },
|
|
||||||
{ path: '/relay', name: '中转任务', icon: <SwapOutlined />, permission: 'relay:use' },
|
|
||||||
{ path: '/config', name: '系统配置', icon: <SettingOutlined />, permission: 'config:read' },
|
|
||||||
{ path: '/prompts', name: '提示词管理', icon: <MessageOutlined />, permission: 'prompt:read' },
|
|
||||||
{ path: '/logs', name: '操作日志', icon: <FileTextOutlined />, permission: 'admin:full' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function filterMenuByPermission(
|
|
||||||
items: MenuDataItem[],
|
|
||||||
hasPermission: (p: string) => boolean,
|
|
||||||
): MenuDataItem[] {
|
|
||||||
return items
|
|
||||||
.filter((item) => !item.permission || hasPermission(item.permission as string))
|
|
||||||
.map(({ permission, ...rest }) => ({
|
|
||||||
...rest,
|
|
||||||
children: rest.children ? filterMenuByPermission(rest.children, hasPermission) : undefined,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminLayout() {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const location = useLocation()
|
|
||||||
const { account, hasPermission, logout } = useAuthStore()
|
|
||||||
|
|
||||||
const menuData = filterMenuByPermission(menuConfig, hasPermission)
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
logout()
|
|
||||||
message.success('已退出登录')
|
|
||||||
navigate('/login', { replace: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ProLayout
|
|
||||||
title="ZCLAW"
|
|
||||||
logo={<div style={{ width: 28, height: 28, background: '#1677ff', borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontWeight: 700, fontSize: 14 }}>Z</div>}
|
|
||||||
layout="mix"
|
|
||||||
fixSiderbar
|
|
||||||
fixedHeader
|
|
||||||
location={{ pathname: location.pathname }}
|
|
||||||
menuDataRender={() => menuData}
|
|
||||||
menuItemRender={(item, dom) => (
|
|
||||||
<div onClick={() => item.path && navigate(item.path)}>{dom}</div>
|
|
||||||
)}
|
|
||||||
avatarProps={{
|
|
||||||
src: undefined,
|
|
||||||
title: account?.display_name || account?.username || 'Admin',
|
|
||||||
size: 'small',
|
|
||||||
render: (_, dom) => (
|
|
||||||
<Dropdown
|
|
||||||
menu={{
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
key: 'logout',
|
|
||||||
icon: <LogoutOutlined />,
|
|
||||||
label: '退出登录',
|
|
||||||
onClick: handleLogout,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{dom}
|
|
||||||
</Dropdown>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
suppressSiderWhenMenuEmpty
|
|
||||||
contentStyle={{ padding: 24 }}
|
|
||||||
>
|
|
||||||
<Outlet />
|
|
||||||
</ProLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { createRoot } from 'react-dom/client'
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
||||||
import { RouterProvider } from 'react-router-dom'
|
|
||||||
import { ConfigProvider, App as AntApp } from 'antd'
|
|
||||||
import zhCN from 'antd/locale/zh_CN'
|
|
||||||
import { router } from './router'
|
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
retry: 1,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
staleTime: 30_000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
|
||||||
<ErrorBoundary>
|
|
||||||
<ConfigProvider locale={zhCN}>
|
|
||||||
<AntApp>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<RouterProvider router={router} />
|
|
||||||
</QueryClientProvider>
|
|
||||||
</AntApp>
|
|
||||||
</ConfigProvider>
|
|
||||||
</ErrorBoundary>,
|
|
||||||
)
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// 账号管理
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { Button, message, Tag, Modal, Form, Input, Select, Popconfirm, Space } from 'antd'
|
|
||||||
import { PlusOutlined } from '@ant-design/icons'
|
|
||||||
import type { ProColumns } from '@ant-design/pro-components'
|
|
||||||
import { ProTable } from '@ant-design/pro-components'
|
|
||||||
import { accountService } from '@/services/accounts'
|
|
||||||
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 { data, isLoading } = useQuery({
|
|
||||||
queryKey: ['accounts'],
|
|
||||||
queryFn: ({ signal }) => accountService.list(signal),
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
|
||||||
mutationFn: ({ id, data }: { id: string; data: Partial<AccountPublic> }) =>
|
|
||||||
accountService.update(id, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
message.success('更新成功')
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['accounts'] })
|
|
||||||
setModalOpen(false)
|
|
||||||
},
|
|
||||||
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 columns: ProColumns<AccountPublic>[] = [
|
|
||||||
{ title: '用户名', dataIndex: 'username', width: 120 },
|
|
||||||
{ title: '显示名', dataIndex: 'display_name', width: 120 },
|
|
||||||
{ title: '邮箱', dataIndex: 'email', width: 180 },
|
|
||||||
{
|
|
||||||
title: '角色',
|
|
||||||
dataIndex: 'role',
|
|
||||||
width: 120,
|
|
||||||
render: (_, record) => <Tag color={roleColors[record.role]}>{roleLabels[record.role] || record.role}</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'status',
|
|
||||||
width: 100,
|
|
||||||
render: (_, record) => <Tag color={statusColors[record.status]}>{statusLabels[record.status] || record.status}</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '2FA',
|
|
||||||
dataIndex: 'totp_enabled',
|
|
||||||
width: 80,
|
|
||||||
render: (_, record) => record.totp_enabled ? <Tag color="green">已启用</Tag> : <Tag>未启用</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '最后登录',
|
|
||||||
dataIndex: 'last_login_at',
|
|
||||||
width: 180,
|
|
||||||
render: (_, record) => record.last_login_at ? new Date(record.last_login_at).toLocaleString('zh-CN') : '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
width: 200,
|
|
||||||
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) {
|
|
||||||
updateMutation.mutate({ id: editingId, data: values })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ProTable<AccountPublic>
|
|
||||||
columns={columns}
|
|
||||||
dataSource={data?.items ?? []}
|
|
||||||
loading={isLoading}
|
|
||||||
rowKey="id"
|
|
||||||
search={false}
|
|
||||||
toolBarRender={() => []}
|
|
||||||
pagination={{
|
|
||||||
total: data?.total ?? 0,
|
|
||||||
pageSize: data?.page_size ?? 20,
|
|
||||||
current: data?.page ?? 1,
|
|
||||||
showSizeChanger: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title="编辑账号"
|
|
||||||
open={modalOpen}
|
|
||||||
onOk={handleSave}
|
|
||||||
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
|
|
||||||
confirmLoading={updateMutation.isPending}
|
|
||||||
>
|
|
||||||
<Form form={form} layout="vertical">
|
|
||||||
<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>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// 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 } 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: '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>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title="模板详情"
|
|
||||||
open={!!detailRecord}
|
|
||||||
onCancel={() => setDetailRecord(null)}
|
|
||||||
footer={null}
|
|
||||||
width={640}
|
|
||||||
>
|
|
||||||
{detailRecord && (
|
|
||||||
<Descriptions column={2} bordered size="small">
|
|
||||||
<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="描述" span={2}>{detailRecord.description || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="系统提示词" span={2}>
|
|
||||||
<div style={{ whiteSpace: 'pre-wrap', maxHeight: 200, overflow: 'auto' }}>
|
|
||||||
{detailRecord.system_prompt || '-'}
|
|
||||||
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// API 密钥管理
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { Button, message, Tag, Modal, Form, Input, InputNumber, Select, Popconfirm, Space, Typography } from 'antd'
|
|
||||||
import { PlusOutlined, CopyOutlined } from '@ant-design/icons'
|
|
||||||
import type { ProColumns } from '@ant-design/pro-components'
|
|
||||||
import { ProTable } from '@ant-design/pro-components'
|
|
||||||
import { apiKeyService } from '@/services/api-keys'
|
|
||||||
import type { TokenInfo } from '@/types'
|
|
||||||
|
|
||||||
const { Text } = Typography
|
|
||||||
|
|
||||||
export default function ApiKeys() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const [form] = Form.useForm()
|
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
|
||||||
const [newToken, setNewToken] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey: ['api-keys'],
|
|
||||||
queryFn: ({ signal }) => apiKeyService.list(signal),
|
|
||||||
})
|
|
||||||
|
|
||||||
const createMutation = useMutation({
|
|
||||||
mutationFn: (data: { name: string; expires_days?: number; permissions: string[] }) =>
|
|
||||||
apiKeyService.create(data),
|
|
||||||
onSuccess: (result: TokenInfo) => {
|
|
||||||
message.success('创建成功')
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
|
|
||||||
if (result.token) {
|
|
||||||
setNewToken(result.token)
|
|
||||||
}
|
|
||||||
setModalOpen(false)
|
|
||||||
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 columns: ProColumns<TokenInfo>[] = [
|
|
||||||
{ title: '名称', dataIndex: 'name', width: 160 },
|
|
||||||
{ title: '前缀', dataIndex: 'token_prefix', width: 120, render: (_, r) => <Text code>{r.token_prefix}...</Text> },
|
|
||||||
{
|
|
||||||
title: '权限',
|
|
||||||
dataIndex: 'permissions',
|
|
||||||
width: 200,
|
|
||||||
render: (_, r) => r.permissions?.map((p) => <Tag key={p}>{p}</Tag>),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '过期时间',
|
|
||||||
dataIndex: 'expires_at',
|
|
||||||
width: 180,
|
|
||||||
render: (_, r) => r.expires_at ? new Date(r.expires_at).toLocaleString('zh-CN') : '永不过期',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '最后使用',
|
|
||||||
dataIndex: 'last_used_at',
|
|
||||||
width: 180,
|
|
||||||
render: (_, r) => r.last_used_at ? new Date(r.last_used_at).toLocaleString('zh-CN') : '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '创建时间',
|
|
||||||
dataIndex: 'created_at',
|
|
||||||
width: 180,
|
|
||||||
render: (_, r) => new Date(r.created_at).toLocaleString('zh-CN'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
width: 100,
|
|
||||||
render: (_, record) => (
|
|
||||||
<Popconfirm title="确定撤销此密钥?撤销后无法恢复。" onConfirm={() => revokeMutation.mutate(record.id)}>
|
|
||||||
<Button size="small" danger>撤销</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
|
||||||
const values = await form.validateFields()
|
|
||||||
createMutation.mutate(values)
|
|
||||||
}
|
|
||||||
|
|
||||||
const copyToken = () => {
|
|
||||||
if (newToken) {
|
|
||||||
navigator.clipboard.writeText(newToken)
|
|
||||||
message.success('已复制到剪贴板')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ProTable<TokenInfo>
|
|
||||||
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="创建 API 密钥"
|
|
||||||
open={modalOpen}
|
|
||||||
onOk={handleCreate}
|
|
||||||
onCancel={() => { setModalOpen(false); form.resetFields() }}
|
|
||||||
confirmLoading={createMutation.isPending}
|
|
||||||
>
|
|
||||||
<Form form={form} layout="vertical">
|
|
||||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
|
||||||
<Input placeholder="给密钥起个名字" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="expires_days" label="有效期 (天)">
|
|
||||||
<InputNumber min={1} placeholder="留空则永不过期" style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="permissions" label="权限" rules={[{ required: true }]}>
|
|
||||||
<Select mode="multiple" placeholder="选择权限" options={[
|
|
||||||
{ value: 'relay:use', label: '中转使用' },
|
|
||||||
{ value: 'model:read', label: '模型读取' },
|
|
||||||
{ value: 'config:read', label: '配置读取' },
|
|
||||||
]} />
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title="密钥创建成功"
|
|
||||||
open={!!newToken}
|
|
||||||
onOk={() => setNewToken(null)}
|
|
||||||
onCancel={() => setNewToken(null)}
|
|
||||||
>
|
|
||||||
<p>请立即保存此密钥,关闭后将无法再次查看:</p>
|
|
||||||
<Input.TextArea
|
|
||||||
value={newToken || ''}
|
|
||||||
rows={3}
|
|
||||||
readOnly
|
|
||||||
addonAfter={<CopyOutlined onClick={copyToken} style={{ cursor: 'pointer' }} />}
|
|
||||||
/>
|
|
||||||
<Button type="primary" icon={<CopyOutlined />} onClick={copyToken} style={{ marginTop: 8 }}>
|
|
||||||
复制密钥
|
|
||||||
</Button>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// 系统配置
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// 仪表盘页面
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { Card, Col, Row, Statistic, Table, Tag, Typography, Spin, Alert } from 'antd'
|
|
||||||
import {
|
|
||||||
TeamOutlined,
|
|
||||||
CloudServerOutlined,
|
|
||||||
ApiOutlined,
|
|
||||||
ThunderboltOutlined,
|
|
||||||
ColumnWidthOutlined,
|
|
||||||
} from '@ant-design/icons'
|
|
||||||
import { statsService } from '@/services/stats'
|
|
||||||
import { logService } from '@/services/logs'
|
|
||||||
import type { OperationLog } from '@/types'
|
|
||||||
|
|
||||||
const { Title } = Typography
|
|
||||||
|
|
||||||
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: '桌面端审计',
|
|
||||||
}
|
|
||||||
|
|
||||||
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',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
|
||||||
const { data: stats, isLoading: statsLoading, error: statsError } = 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 <Alert type="error" message="加载仪表盘数据失败" description={(statsError as Error).message} showIcon />
|
|
||||||
}
|
|
||||||
|
|
||||||
const statCards = [
|
|
||||||
{ title: '总账号', value: stats?.total_accounts ?? 0, icon: <TeamOutlined />, color: '#1677ff' },
|
|
||||||
{ title: '活跃服务商', value: stats?.active_providers ?? 0, icon: <CloudServerOutlined />, color: '#52c41a' },
|
|
||||||
{ title: '活跃模型', value: stats?.active_models ?? 0, icon: <ApiOutlined />, color: '#722ed1' },
|
|
||||||
{ title: '今日请求', value: stats?.tasks_today ?? 0, icon: <ThunderboltOutlined />, color: '#fa8c16' },
|
|
||||||
{ title: '今日 Token', value: ((stats?.tokens_today_input ?? 0) + (stats?.tokens_today_output ?? 0)), icon: <ColumnWidthOutlined />, color: '#eb2f96' },
|
|
||||||
]
|
|
||||||
|
|
||||||
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>
|
|
||||||
<Title level={4} style={{ marginBottom: 24 }}>仪表盘</Title>
|
|
||||||
|
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
|
||||||
{statsLoading ? (
|
|
||||||
<Col span={24}><Spin /></Col>
|
|
||||||
) : (
|
|
||||||
statCards.map((card) => (
|
|
||||||
<Col xs={24} sm={12} md={8} lg={4} key={card.title}>
|
|
||||||
<Card>
|
|
||||||
<Statistic
|
|
||||||
title={card.title}
|
|
||||||
value={card.value}
|
|
||||||
prefix={<span style={{ color: card.color }}>{card.icon}</span>}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Card title="最近操作日志" size="small">
|
|
||||||
<Table<OperationLog>
|
|
||||||
columns={logColumns}
|
|
||||||
dataSource={logsData?.items ?? []}
|
|
||||||
loading={logsLoading}
|
|
||||||
rowKey="id"
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// 登录页面
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
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, Divider, Typography } from 'antd'
|
|
||||||
import { authService } from '@/services/auth'
|
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
|
||||||
import type { LoginRequest } from '@/types'
|
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
|
||||||
|
|
||||||
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.token, res.refresh_token, 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 style={{ minHeight: '100vh', display: 'flex' }}>
|
|
||||||
{/* 左侧品牌区 */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: '1 1 0',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
background: 'linear-gradient(135deg, #001529 0%, #003a70 50%, #001529 100%)',
|
|
||||||
position: 'relative',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Title level={1} style={{ color: '#fff', marginBottom: 8, letterSpacing: 4 }}>
|
|
||||||
ZCLAW
|
|
||||||
</Title>
|
|
||||||
<Text style={{ color: 'rgba(255,255,255,0.65)', fontSize: 16 }}>AI Agent 管理平台</Text>
|
|
||||||
<Divider style={{ borderColor: 'rgba(22,119,255,0.3)', width: 100, minWidth: 100 }} />
|
|
||||||
<Text style={{ color: 'rgba(255,255,255,0.45)', fontSize: 13, maxWidth: 320, textAlign: 'center' }}>
|
|
||||||
统一管理 AI 服务商、模型配置、API 密钥、用量监控与系统配置
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 右侧登录表单 */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: '0 0 480px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: 48,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ width: '100%', maxWidth: 360 }}>
|
|
||||||
<Title level={3} style={{ marginBottom: 4 }}>登录</Title>
|
|
||||||
<Text type="secondary" style={{ display: 'block', marginBottom: 32 }}>
|
|
||||||
输入您的账号信息以继续
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<LoginForm
|
|
||||||
onFinish={handleSubmit}
|
|
||||||
submitter={{
|
|
||||||
searchConfig: { submitText: '登录' },
|
|
||||||
submitButtonProps: { loading, block: true },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// 操作日志
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
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 type { OperationLog } from '@/types'
|
|
||||||
|
|
||||||
const { Title } = Typography
|
|
||||||
|
|
||||||
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: '桌面端审计',
|
|
||||||
}
|
|
||||||
|
|
||||||
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',
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// 模型管理
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { Button, message, Tag, Modal, Form, Input, InputNumber, Switch, Select, Space, Popconfirm } from 'antd'
|
|
||||||
import { PlusOutlined } from '@ant-design/icons'
|
|
||||||
import type { ProColumns } from '@ant-design/pro-components'
|
|
||||||
import { ProTable } from '@ant-design/pro-components'
|
|
||||||
import { modelService } from '@/services/models'
|
|
||||||
import { providerService } from '@/services/providers'
|
|
||||||
import type { Model } from '@/types'
|
|
||||||
|
|
||||||
export default function Models() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const [form] = Form.useForm()
|
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey: ['models'],
|
|
||||||
queryFn: ({ signal }) => modelService.list(signal),
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: providersData } = useQuery({
|
|
||||||
queryKey: ['providers-for-select'],
|
|
||||||
queryFn: ({ signal }) => providerService.list(signal),
|
|
||||||
})
|
|
||||||
|
|
||||||
const createMutation = useMutation({
|
|
||||||
mutationFn: (data: Partial<Omit<Model, 'id'>>) => modelService.create(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
message.success('创建成功')
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['models'] })
|
|
||||||
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: ['models'] })
|
|
||||||
setModalOpen(false)
|
|
||||||
},
|
|
||||||
onError: (err: Error) => message.error(err.message || '更新失败'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: (id: string) => modelService.delete(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
message.success('删除成功')
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['models'] })
|
|
||||||
},
|
|
||||||
onError: (err: Error) => message.error(err.message || '删除失败'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const columns: ProColumns<Model>[] = [
|
|
||||||
{ title: '模型 ID', dataIndex: 'model_id', width: 180, render: (_, r) => <code>{r.model_id}</code> },
|
|
||||||
{ title: '别名', dataIndex: 'alias', width: 140 },
|
|
||||||
{
|
|
||||||
title: '服务商',
|
|
||||||
dataIndex: 'provider_id',
|
|
||||||
width: 140,
|
|
||||||
render: (_, r) => {
|
|
||||||
const provider = providersData?.items?.find((p) => p.id === r.provider_id)
|
|
||||||
return provider?.display_name || r.provider_id.substring(0, 8)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ title: '上下文窗口', dataIndex: 'context_window', width: 110, render: (_, r) => r.context_window?.toLocaleString() },
|
|
||||||
{ title: '最大输出', dataIndex: 'max_output_tokens', width: 100, render: (_, r) => r.max_output_tokens?.toLocaleString() },
|
|
||||||
{
|
|
||||||
title: '流式',
|
|
||||||
dataIndex: 'supports_streaming',
|
|
||||||
width: 70,
|
|
||||||
render: (_, r) => r.supports_streaming ? <Tag color="green">是</Tag> : <Tag>否</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '视觉',
|
|
||||||
dataIndex: 'supports_vision',
|
|
||||||
width: 70,
|
|
||||||
render: (_, r) => r.supports_vision ? <Tag color="blue">是</Tag> : <Tag>否</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'enabled',
|
|
||||||
width: 70,
|
|
||||||
render: (_, r) => r.enabled ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
width: 160,
|
|
||||||
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 handleSave = async () => {
|
|
||||||
const values = await form.validateFields()
|
|
||||||
if (editingId) {
|
|
||||||
updateMutation.mutate({ id: editingId, data: values })
|
|
||||||
} else {
|
|
||||||
createMutation.mutate(values)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ProTable<Model>
|
|
||||||
columns={columns}
|
|
||||||
dataSource={data?.items ?? []}
|
|
||||||
loading={isLoading}
|
|
||||||
rowKey="id"
|
|
||||||
search={false}
|
|
||||||
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,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title={editingId ? '编辑模型' : '新建模型'}
|
|
||||||
open={modalOpen}
|
|
||||||
onOk={handleSave}
|
|
||||||
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
|
|
||||||
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
|
||||||
width={600}
|
|
||||||
>
|
|
||||||
<Form form={form} layout="vertical">
|
|
||||||
<Form.Item name="provider_id" label="服务商" rules={[{ required: true }]}>
|
|
||||||
<Select
|
|
||||||
options={(providersData?.items ?? []).map((p) => ({ value: p.id, label: p.display_name }))}
|
|
||||||
placeholder="选择服务商"
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="model_id" label="模型 ID" rules={[{ required: true }]}>
|
|
||||||
<Input placeholder="如 gpt-4o" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="alias" label="别名">
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="context_window" label="上下文窗口">
|
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="max_output_tokens" label="最大输出 Token">
|
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="supports_streaming" label="支持流式" valuePropName="checked">
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="supports_vision" label="支持视觉" valuePropName="checked">
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="pricing_input" label="输入价格 (每百万 Token)">
|
|
||||||
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="pricing_output" label="输出价格 (每百万 Token)">
|
|
||||||
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// 提示词管理
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// 服务商管理
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { Button, message, Tag, Modal, Form, Input, InputNumber, Switch, Space, Popconfirm, 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 type { Provider, ProviderKey } from '@/types'
|
|
||||||
|
|
||||||
const { Text } = Typography
|
|
||||||
|
|
||||||
export default function Providers() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const [form] = Form.useForm()
|
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
|
||||||
const [keyModalProviderId, setKeyModalProviderId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey: ['providers'],
|
|
||||||
queryFn: ({ signal }) => providerService.list(signal),
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: keysData, isLoading: keysLoading } = useQuery({
|
|
||||||
queryKey: ['provider-keys', keyModalProviderId],
|
|
||||||
queryFn: ({ signal }) => providerService.listKeys(keyModalProviderId!, signal),
|
|
||||||
enabled: !!keyModalProviderId,
|
|
||||||
})
|
|
||||||
|
|
||||||
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 columns: ProColumns<Provider>[] = [
|
|
||||||
{ title: '名称', dataIndex: 'display_name', width: 140 },
|
|
||||||
{ title: '标识', dataIndex: 'name', width: 120, render: (_, r) => <Text code>{r.name}</Text> },
|
|
||||||
{ title: '协议', dataIndex: 'api_protocol', width: 100 },
|
|
||||||
{ title: 'RPM 限制', dataIndex: 'rate_limit_rpm', width: 100, render: (_, r) => r.rate_limit_rpm ?? '-' },
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'enabled',
|
|
||||||
width: 80,
|
|
||||||
render: (_, r) => r.enabled ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
width: 260,
|
|
||||||
render: (_, record) => (
|
|
||||||
<Space>
|
|
||||||
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
<Button size="small" onClick={() => setKeyModalProviderId(record.id)}>
|
|
||||||
Key Pool
|
|
||||||
</Button>
|
|
||||||
<Popconfirm title="确定删除此服务商?" onConfirm={() => deleteMutation.mutate(record.id)}>
|
|
||||||
<Button size="small" danger>删除</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const keyColumns: ProColumns<ProviderKey>[] = [
|
|
||||||
{ title: '标签', dataIndex: 'key_label', width: 120 },
|
|
||||||
{ title: '优先级', dataIndex: 'priority', width: 80 },
|
|
||||||
{ title: '请求数', dataIndex: 'total_requests', width: 80 },
|
|
||||||
{ title: 'Token 数', dataIndex: 'total_tokens', width: 100 },
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'is_active',
|
|
||||||
width: 80,
|
|
||||||
render: (_, r) => r.is_active ? <Tag color="green">活跃</Tag> : <Tag>冷却</Tag>,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
const values = await form.validateFields()
|
|
||||||
if (editingId) {
|
|
||||||
updateMutation.mutate({ id: editingId, data: values })
|
|
||||||
} else {
|
|
||||||
createMutation.mutate(values)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ProTable<Provider>
|
|
||||||
columns={columns}
|
|
||||||
dataSource={data?.items ?? []}
|
|
||||||
loading={isLoading}
|
|
||||||
rowKey="id"
|
|
||||||
search={false}
|
|
||||||
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,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title={editingId ? '编辑服务商' : '新建服务商'}
|
|
||||||
open={modalOpen}
|
|
||||||
onOk={handleSave}
|
|
||||||
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
|
|
||||||
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
|
||||||
>
|
|
||||||
<Form form={form} layout="vertical">
|
|
||||||
<Form.Item name="name" label="标识" rules={[{ required: true }]}>
|
|
||||||
<Input disabled={!!editingId} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="display_name" label="显示名称" rules={[{ required: true }]}>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="base_url" label="Base URL" rules={[{ required: true }]}>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="api_protocol" label="API 协议">
|
|
||||||
<Input placeholder="openai" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="rate_limit_rpm" label="RPM 限制">
|
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title="Key Pool"
|
|
||||||
open={!!keyModalProviderId}
|
|
||||||
onCancel={() => setKeyModalProviderId(null)}
|
|
||||||
footer={null}
|
|
||||||
width={700}
|
|
||||||
>
|
|
||||||
<ProTable<ProviderKey>
|
|
||||||
columns={keyColumns}
|
|
||||||
dataSource={keysData ?? []}
|
|
||||||
loading={keysLoading}
|
|
||||||
rowKey="id"
|
|
||||||
search={false}
|
|
||||||
toolBarRender={false}
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// 中转任务
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
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 { relayService } from '@/services/relay'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import type { RelayTask } from '@/types'
|
|
||||||
|
|
||||||
const { Title } = Typography
|
|
||||||
|
|
||||||
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 } = useQuery({
|
|
||||||
queryKey: ['relay-tasks', page, statusFilter],
|
|
||||||
queryFn: ({ signal }) => relayService.list({ page, page_size: 20, status: statusFilter }, signal),
|
|
||||||
})
|
|
||||||
|
|
||||||
const columns: ProColumns<RelayTask>[] = [
|
|
||||||
{ title: 'ID', dataIndex: 'id', width: 120, render: (_, r) => <code>{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) => `${r.input_tokens.toLocaleString()} / ${r.output_tokens.toLocaleString()}`,
|
|
||||||
},
|
|
||||||
{ 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>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
|
||||||
<Title level={4} style={{ margin: 0 }}>中转任务</Title>
|
|
||||||
<Select
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(v) => { setStatusFilter(v === 'all' ? undefined : v); setPage(1) }}
|
|
||||||
placeholder="状态筛选"
|
|
||||||
style={{ width: 140 }}
|
|
||||||
allowClear
|
|
||||||
options={[
|
|
||||||
{ value: 'all', label: '全部' },
|
|
||||||
{ value: 'queued', label: '排队中' },
|
|
||||||
{ value: 'running', label: '运行中' },
|
|
||||||
{ value: 'completed', label: '已完成' },
|
|
||||||
{ value: 'failed', label: '失败' },
|
|
||||||
{ value: 'cancelled', label: '已取消' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// 用量统计
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { Card, Row, Col, Select, Spin, Alert, Statistic, Typography } from 'antd'
|
|
||||||
import { ColumnWidthOutlined, ThunderboltOutlined } from '@ant-design/icons'
|
|
||||||
import type { ProColumns } from '@ant-design/pro-components'
|
|
||||||
import { ProTable } from '@ant-design/pro-components'
|
|
||||||
import { usageService } from '@/services/usage'
|
|
||||||
import { telemetryService } from '@/services/telemetry'
|
|
||||||
import type { DailyUsageStat, ModelUsageStat } from '@/types'
|
|
||||||
|
|
||||||
const { Title } = Typography
|
|
||||||
|
|
||||||
export default function Usage() {
|
|
||||||
const [days, setDays] = useState(30)
|
|
||||||
|
|
||||||
const { data: dailyData, isLoading: dailyLoading, error: dailyError } = 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),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (dailyError) {
|
|
||||||
return <Alert type="error" message="加载用量数据失败" description={(dailyError as Error).message} showIcon />
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
|
||||||
<Title level={4} style={{ margin: 0 }}>用量统计</Title>
|
|
||||||
<Select
|
|
||||||
value={days}
|
|
||||||
onChange={setDays}
|
|
||||||
options={[
|
|
||||||
{ value: 7, label: '最近 7 天' },
|
|
||||||
{ value: 30, label: '最近 30 天' },
|
|
||||||
{ value: 90, label: '最近 90 天' },
|
|
||||||
]}
|
|
||||||
style={{ width: 140 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
|
||||||
<Col span={12}>
|
|
||||||
<Card>
|
|
||||||
<Statistic title="总请求数" value={totalRequests} prefix={<ThunderboltOutlined />} />
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
|
||||||
<Card>
|
|
||||||
<Statistic title="总 Token 数" value={totalTokens} prefix={<ColumnWidthOutlined />} />
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Card title="每日统计" style={{ marginBottom: 24 }} size="small">
|
|
||||||
<ProTable<DailyUsageStat>
|
|
||||||
columns={dailyColumns}
|
|
||||||
dataSource={dailyData ?? []}
|
|
||||||
loading={dailyLoading}
|
|
||||||
rowKey="day"
|
|
||||||
search={false}
|
|
||||||
toolBarRender={false}
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="按模型统计" size="small">
|
|
||||||
<ProTable<ModelUsageStat>
|
|
||||||
columns={modelColumns}
|
|
||||||
dataSource={modelData ?? []}
|
|
||||||
loading={modelLoading}
|
|
||||||
rowKey="model_id"
|
|
||||||
search={false}
|
|
||||||
toolBarRender={false}
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// 路由守卫 — 未登录重定向到 /login
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { Navigate, useLocation } from 'react-router-dom'
|
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
|
||||||
|
|
||||||
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
|
||||||
const token = useAuthStore((s) => s.token)
|
|
||||||
const location = useLocation()
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return <Navigate to="/login" state={{ from: location }} replace />
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// 路由定义
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
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: 'providers', lazy: () => import('@/pages/Providers').then((m) => ({ Component: m.default })) },
|
|
||||||
{ path: 'models', lazy: () => import('@/pages/Models').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: 'relay', lazy: () => import('@/pages/Relay').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 })) },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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),
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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),
|
|
||||||
|
|
||||||
get: (id: string, signal?: AbortSignal) =>
|
|
||||||
request.get<AgentTemplate>(`/agent-templates/${id}`, withSignal({}, 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
|
|
||||||
}, 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),
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import request, { withSignal } from './request'
|
|
||||||
import type { TokenInfo, CreateTokenRequest, PaginatedResponse } from '@/types'
|
|
||||||
|
|
||||||
export const apiKeyService = {
|
|
||||||
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
|
||||||
request.get<PaginatedResponse<TokenInfo>>('/keys', withSignal({ params }, signal)).then((r) => r.data),
|
|
||||||
|
|
||||||
create: (data: CreateTokenRequest, signal?: AbortSignal) =>
|
|
||||||
request.post<TokenInfo>('/keys', data, withSignal({}, signal)).then((r) => r.data),
|
|
||||||
|
|
||||||
revoke: (id: string, signal?: AbortSignal) =>
|
|
||||||
request.delete(`/keys/${id}`, withSignal({}, signal)).then((r) => r.data),
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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),
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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.patch<ConfigItem>(`/config/items/${id}`, data, withSignal({}, signal)).then((r) => r.data),
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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),
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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),
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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),
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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),
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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),
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// ZCLAW Admin V2 — Axios 实例 + JWT 拦截器
|
|
||||||
// ============================================================
|
|
||||||
//
|
|
||||||
// 认证策略: 主路径使用 HttpOnly cookie(浏览器自动附加),
|
|
||||||
// Authorization header 作为 fallback 保留用于 API 客户端。
|
|
||||||
|
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── 请求拦截器:附加 Authorization header fallback ──────────
|
|
||||||
|
|
||||||
request.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
|
||||||
const token = useAuthStore.getState().token
|
|
||||||
if (token) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── 响应拦截器:401 自动刷新 ──────────────────────────────
|
|
||||||
|
|
||||||
let isRefreshing = false
|
|
||||||
let pendingRequests: Array<(token: string) => void> = []
|
|
||||||
|
|
||||||
function onTokenRefreshed(newToken: string) {
|
|
||||||
pendingRequests.forEach((cb) => cb(newToken))
|
|
||||||
pendingRequests = []
|
|
||||||
}
|
|
||||||
|
|
||||||
request.interceptors.response.use(
|
|
||||||
(response) => response,
|
|
||||||
async (error: AxiosError<{ error?: string; message?: string }>) => {
|
|
||||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
|
||||||
|
|
||||||
// 401 → 尝试刷新 Token
|
|
||||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
|
||||||
const store = useAuthStore.getState()
|
|
||||||
if (!store.refreshToken) {
|
|
||||||
store.logout()
|
|
||||||
window.location.href = '/login'
|
|
||||||
return Promise.reject(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRefreshing) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
pendingRequests.push((newToken: string) => {
|
|
||||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
|
||||||
resolve(request(originalRequest))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
originalRequest._retry = true
|
|
||||||
isRefreshing = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await axios.post(`${BASE_URL}/auth/refresh`, null, {
|
|
||||||
headers: { Authorization: `Bearer ${store.refreshToken}` },
|
|
||||||
withCredentials: true, // 发送 refresh cookie
|
|
||||||
})
|
|
||||||
const newToken = res.data.token as string
|
|
||||||
const newRefreshToken = res.data.refresh_token as string
|
|
||||||
// 更新内存中的 token(实际认证通过 HttpOnly cookie,浏览器已自动更新)
|
|
||||||
store.setToken(newToken)
|
|
||||||
if (newRefreshToken) {
|
|
||||||
store.setRefreshToken(newRefreshToken)
|
|
||||||
}
|
|
||||||
onTokenRefreshed(newToken)
|
|
||||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
|
||||||
return request(originalRequest)
|
|
||||||
} catch {
|
|
||||||
store.logout()
|
|
||||||
window.location.href = '/login'
|
|
||||||
return Promise.reject(error)
|
|
||||||
} 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))
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject(error)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export default request
|
|
||||||
|
|
||||||
/** 将 AbortSignal 注入 Axios config,用于 TanStack Query 的请求取消 */
|
|
||||||
export function withSignal(config: AxiosRequestConfig = {}, signal?: AbortSignal): AxiosRequestConfig {
|
|
||||||
if (signal) {
|
|
||||||
return { ...config, signal }
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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),
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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),
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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 || []),
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// ZCLAW Admin V2 — Zustand 认证状态管理
|
|
||||||
// ============================================================
|
|
||||||
//
|
|
||||||
// 安全策略: JWT token 通过 HttpOnly cookie 传递,前端 JS 无法读取。
|
|
||||||
// account 信息(显示名/角色)仍存 localStorage 用于页面刷新后恢复 UI。
|
|
||||||
// 内存中的 token/refreshToken 仅用于 Authorization header fallback(API 客户端兼容)。
|
|
||||||
|
|
||||||
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',
|
|
||||||
'relay:admin', 'config:write', 'prompt:read', 'prompt:write',
|
|
||||||
'prompt:publish', 'prompt:admin',
|
|
||||||
],
|
|
||||||
admin: [
|
|
||||||
'account:read', 'account:admin', 'provider:manage', 'model:read',
|
|
||||||
'model:manage', 'relay:use', 'config:read',
|
|
||||||
'config:write', 'prompt:read', 'prompt:write', 'prompt:publish',
|
|
||||||
],
|
|
||||||
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 } {
|
|
||||||
const raw = localStorage.getItem(ACCOUNT_KEY)
|
|
||||||
let account: AccountPublic | null = null
|
|
||||||
if (raw) {
|
|
||||||
try { account = JSON.parse(raw) } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
return { account }
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthState {
|
|
||||||
token: string | null
|
|
||||||
refreshToken: string | null
|
|
||||||
account: AccountPublic | null
|
|
||||||
permissions: string[]
|
|
||||||
|
|
||||||
setToken: (token: string) => void
|
|
||||||
setRefreshToken: (refreshToken: string) => void
|
|
||||||
login: (token: string, refreshToken: string, 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 {
|
|
||||||
token: null,
|
|
||||||
refreshToken: null,
|
|
||||||
account: stored.account,
|
|
||||||
permissions: perms,
|
|
||||||
|
|
||||||
setToken: (token: string) => {
|
|
||||||
set({ token })
|
|
||||||
},
|
|
||||||
|
|
||||||
setRefreshToken: (refreshToken: string) => {
|
|
||||||
set({ refreshToken })
|
|
||||||
},
|
|
||||||
|
|
||||||
login: (token: string, refreshToken: string, account: AccountPublic) => {
|
|
||||||
// account 保留 localStorage(仅用于 UI 显示,非敏感)
|
|
||||||
localStorage.setItem(ACCOUNT_KEY, JSON.stringify(account))
|
|
||||||
// token 仅存内存(实际认证通过 HttpOnly cookie)
|
|
||||||
set({
|
|
||||||
token,
|
|
||||||
refreshToken,
|
|
||||||
account,
|
|
||||||
permissions: ROLE_PERMISSIONS[account.role] ?? [],
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
logout: () => {
|
|
||||||
localStorage.removeItem(ACCOUNT_KEY)
|
|
||||||
set({ token: null, refreshToken: null, account: null, permissions: [] })
|
|
||||||
// 调用后端 logout 清除 HttpOnly cookies(fire-and-forget)
|
|
||||||
fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
|
|
||||||
},
|
|
||||||
|
|
||||||
hasPermission: (permission: string) => {
|
|
||||||
const { permissions } = get()
|
|
||||||
return permissions.includes(permission) || permissions.includes('admin:full')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 登录请求 */
|
|
||||||
export interface LoginRequest {
|
|
||||||
username: string
|
|
||||||
password: string
|
|
||||||
totp_code?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 登录响应 */
|
|
||||||
export interface LoginResponse {
|
|
||||||
token: string
|
|
||||||
refresh_token: string
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 服务商 (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
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Provider Key */
|
|
||||||
export interface ProviderKey {
|
|
||||||
id: string
|
|
||||||
provider_id: string
|
|
||||||
key_label: string
|
|
||||||
priority: number
|
|
||||||
max_rpm?: number
|
|
||||||
max_tpm?: number
|
|
||||||
quota_reset_interval?: string
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
||||||
"target": "ES2023",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"types": ["vite/client"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
|
|
||||||
/* Path alias */
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["src/*"]
|
|
||||||
},
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedSideEffectImports": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"files": [],
|
|
||||||
"references": [
|
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
|
||||||
"target": "ES2023",
|
|
||||||
"lib": ["ES2023"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"types": ["node"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedSideEffectImports": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react'
|
|
||||||
import path from 'path'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': path.resolve(__dirname, 'src'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
port: 5173,
|
|
||||||
proxy: {
|
|
||||||
// SSE relay 端点需要长超时(流式响应可持续数分钟)
|
|
||||||
'/api/v1/relay/chat/completions': {
|
|
||||||
target: 'http://localhost:8080',
|
|
||||||
changeOrigin: true,
|
|
||||||
timeout: 600_000,
|
|
||||||
proxyTimeout: 600_000,
|
|
||||||
},
|
|
||||||
'/api': {
|
|
||||||
target: 'http://localhost:8080',
|
|
||||||
changeOrigin: true,
|
|
||||||
timeout: 30_000,
|
|
||||||
proxyTimeout: 30_000,
|
|
||||||
configure: (proxy) => {
|
|
||||||
proxy.on('proxyReq', (proxyReq) => {
|
|
||||||
proxyReq.setTimeout(30_000)
|
|
||||||
})
|
|
||||||
proxy.on('proxyRes', (proxyRes) => {
|
|
||||||
proxyRes.setTimeout(30_000)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -21,10 +21,12 @@
|
|||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^7.13.2",
|
"react-router-dom": "^7.13.2",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.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',
|
||||||
|
});
|
||||||
336
admin-v2/pnpm-lock.yaml
generated
336
admin-v2/pnpm-lock.yaml
generated
@@ -35,13 +35,19 @@ importers:
|
|||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^7.13.2
|
specifier: ^7.13.2
|
||||||
version: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
recharts:
|
||||||
|
specifier: ^3.8.1
|
||||||
|
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@18.3.1)(react@19.2.4)(redux@5.0.1)
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^5.0.12
|
specifier: ^5.0.12
|
||||||
version: 5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
|
version: 5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^9.39.4
|
specifier: ^9.39.4
|
||||||
version: 9.39.4
|
version: 9.39.4
|
||||||
|
'@playwright/test':
|
||||||
|
specifier: ^1.59.1
|
||||||
|
version: 1.59.1
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.2.2
|
specifier: ^4.2.2
|
||||||
version: 4.2.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(terser@5.46.1))
|
version: 4.2.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(terser@5.46.1))
|
||||||
@@ -549,6 +555,11 @@ packages:
|
|||||||
'@oxc-project/types@0.122.0':
|
'@oxc-project/types@0.122.0':
|
||||||
resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==}
|
resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==}
|
||||||
|
|
||||||
|
'@playwright/test@1.59.1':
|
||||||
|
resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@rc-component/async-validator@5.1.0':
|
'@rc-component/async-validator@5.1.0':
|
||||||
resolution: {integrity: sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==}
|
resolution: {integrity: sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==}
|
||||||
engines: {node: '>=14.x'}
|
engines: {node: '>=14.x'}
|
||||||
@@ -832,6 +843,17 @@ packages:
|
|||||||
react: '>=16.9.0'
|
react: '>=16.9.0'
|
||||||
react-dom: '>=16.9.0'
|
react-dom: '>=16.9.0'
|
||||||
|
|
||||||
|
'@reduxjs/toolkit@2.11.2':
|
||||||
|
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
|
||||||
|
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
react-redux:
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.0.0-rc.12':
|
'@rolldown/binding-android-arm64@1.0.0-rc.12':
|
||||||
resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==}
|
resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -936,6 +958,9 @@ packages:
|
|||||||
'@standard-schema/spec@1.1.0':
|
'@standard-schema/spec@1.1.0':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
|
'@standard-schema/utils@0.3.0':
|
||||||
|
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||||
|
|
||||||
'@tailwindcss/node@4.2.2':
|
'@tailwindcss/node@4.2.2':
|
||||||
resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==}
|
resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==}
|
||||||
|
|
||||||
@@ -1076,6 +1101,33 @@ packages:
|
|||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||||
|
|
||||||
|
'@types/d3-array@3.2.2':
|
||||||
|
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||||
|
|
||||||
|
'@types/d3-color@3.1.3':
|
||||||
|
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||||
|
|
||||||
|
'@types/d3-ease@3.0.2':
|
||||||
|
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
||||||
|
|
||||||
|
'@types/d3-interpolate@3.0.4':
|
||||||
|
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||||
|
|
||||||
|
'@types/d3-path@3.1.1':
|
||||||
|
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
|
||||||
|
|
||||||
|
'@types/d3-scale@4.0.9':
|
||||||
|
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
||||||
|
|
||||||
|
'@types/d3-shape@3.1.8':
|
||||||
|
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
|
||||||
|
|
||||||
|
'@types/d3-time@3.0.4':
|
||||||
|
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
|
||||||
|
|
||||||
|
'@types/d3-timer@3.0.2':
|
||||||
|
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||||
|
|
||||||
'@types/deep-eql@4.0.2':
|
'@types/deep-eql@4.0.2':
|
||||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||||
|
|
||||||
@@ -1099,6 +1151,9 @@ packages:
|
|||||||
'@types/statuses@2.0.6':
|
'@types/statuses@2.0.6':
|
||||||
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
|
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
|
||||||
|
|
||||||
|
'@types/use-sync-external-store@0.0.6':
|
||||||
|
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.57.2':
|
'@typescript-eslint/eslint-plugin@8.57.2':
|
||||||
resolution: {integrity: sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==}
|
resolution: {integrity: sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -1367,6 +1422,50 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
d3-array@3.2.4:
|
||||||
|
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-color@3.1.0:
|
||||||
|
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-ease@3.0.1:
|
||||||
|
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-format@3.1.2:
|
||||||
|
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-interpolate@3.0.1:
|
||||||
|
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-path@3.1.0:
|
||||||
|
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-scale@4.0.2:
|
||||||
|
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-shape@3.2.0:
|
||||||
|
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-time-format@4.1.0:
|
||||||
|
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-time@3.1.0:
|
||||||
|
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-timer@3.0.1:
|
||||||
|
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
data-urls@7.0.0:
|
data-urls@7.0.0:
|
||||||
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
|
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
|
||||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||||
@@ -1383,6 +1482,9 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decimal.js-light@2.5.1:
|
||||||
|
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||||
|
|
||||||
decimal.js@10.6.0:
|
decimal.js@10.6.0:
|
||||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||||
|
|
||||||
@@ -1444,6 +1546,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
es-toolkit@1.45.1:
|
||||||
|
resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==}
|
||||||
|
|
||||||
escalade@3.2.0:
|
escalade@3.2.0:
|
||||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1512,6 +1617,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
eventemitter3@5.0.4:
|
||||||
|
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||||
|
|
||||||
expect-type@1.3.0:
|
expect-type@1.3.0:
|
||||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -1562,6 +1670,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -1646,6 +1759,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
|
immer@10.2.0:
|
||||||
|
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
|
||||||
|
|
||||||
|
immer@11.1.4:
|
||||||
|
resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1658,6 +1777,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
|
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
internmap@2.0.3:
|
||||||
|
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
is-extglob@2.1.1:
|
is-extglob@2.1.1:
|
||||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1944,6 +2067,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
playwright-core@1.59.1:
|
||||||
|
resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
playwright@1.59.1:
|
||||||
|
resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
postcss@8.5.8:
|
postcss@8.5.8:
|
||||||
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
@@ -2012,6 +2145,18 @@ packages:
|
|||||||
react-lifecycles-compat@3.0.4:
|
react-lifecycles-compat@3.0.4:
|
||||||
resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==}
|
resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==}
|
||||||
|
|
||||||
|
react-redux@9.2.0:
|
||||||
|
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': ^18.2.25 || ^19
|
||||||
|
react: ^18.0 || ^19
|
||||||
|
redux: ^5.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
redux:
|
||||||
|
optional: true
|
||||||
|
|
||||||
react-router-dom@7.13.2:
|
react-router-dom@7.13.2:
|
||||||
resolution: {integrity: sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==}
|
resolution: {integrity: sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -2038,10 +2183,26 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '*'
|
react: '*'
|
||||||
|
|
||||||
|
recharts@3.8.1:
|
||||||
|
resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
redent@3.0.0:
|
redent@3.0.0:
|
||||||
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
redux-thunk@3.1.0:
|
||||||
|
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
|
||||||
|
peerDependencies:
|
||||||
|
redux: ^5.0.0
|
||||||
|
|
||||||
|
redux@5.0.1:
|
||||||
|
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
|
||||||
|
|
||||||
require-directory@2.1.1:
|
require-directory@2.1.1:
|
||||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -2050,6 +2211,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
reselect@5.1.1:
|
||||||
|
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||||
|
|
||||||
resize-observer-polyfill@1.5.1:
|
resize-observer-polyfill@1.5.1:
|
||||||
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
|
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
|
||||||
|
|
||||||
@@ -2187,6 +2351,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
|
resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
|
||||||
engines: {node: '>=12.22'}
|
engines: {node: '>=12.22'}
|
||||||
|
|
||||||
|
tiny-invariant@1.3.3:
|
||||||
|
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||||
|
|
||||||
tinybench@2.9.0:
|
tinybench@2.9.0:
|
||||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
@@ -2273,6 +2440,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
victory-vendor@37.3.6:
|
||||||
|
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||||
|
|
||||||
vite@8.0.3:
|
vite@8.0.3:
|
||||||
resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==}
|
resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -3064,6 +3234,10 @@ snapshots:
|
|||||||
|
|
||||||
'@oxc-project/types@0.122.0': {}
|
'@oxc-project/types@0.122.0': {}
|
||||||
|
|
||||||
|
'@playwright/test@1.59.1':
|
||||||
|
dependencies:
|
||||||
|
playwright: 1.59.1
|
||||||
|
|
||||||
'@rc-component/async-validator@5.1.0':
|
'@rc-component/async-validator@5.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.29.2
|
'@babel/runtime': 7.29.2
|
||||||
@@ -3410,6 +3584,18 @@ snapshots:
|
|||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
|
||||||
|
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
'@standard-schema/spec': 1.1.0
|
||||||
|
'@standard-schema/utils': 0.3.0
|
||||||
|
immer: 11.1.4
|
||||||
|
redux: 5.0.1
|
||||||
|
redux-thunk: 3.1.0(redux@5.0.1)
|
||||||
|
reselect: 5.1.1
|
||||||
|
optionalDependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1)
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.0.0-rc.12':
|
'@rolldown/binding-android-arm64@1.0.0-rc.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -3466,6 +3652,8 @@ snapshots:
|
|||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
|
'@standard-schema/utils@0.3.0': {}
|
||||||
|
|
||||||
'@tailwindcss/node@4.2.2':
|
'@tailwindcss/node@4.2.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/remapping': 2.3.5
|
'@jridgewell/remapping': 2.3.5
|
||||||
@@ -3587,6 +3775,30 @@ snapshots:
|
|||||||
'@types/deep-eql': 4.0.2
|
'@types/deep-eql': 4.0.2
|
||||||
assertion-error: 2.0.1
|
assertion-error: 2.0.1
|
||||||
|
|
||||||
|
'@types/d3-array@3.2.2': {}
|
||||||
|
|
||||||
|
'@types/d3-color@3.1.3': {}
|
||||||
|
|
||||||
|
'@types/d3-ease@3.0.2': {}
|
||||||
|
|
||||||
|
'@types/d3-interpolate@3.0.4':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-color': 3.1.3
|
||||||
|
|
||||||
|
'@types/d3-path@3.1.1': {}
|
||||||
|
|
||||||
|
'@types/d3-scale@4.0.9':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-time': 3.0.4
|
||||||
|
|
||||||
|
'@types/d3-shape@3.1.8':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-path': 3.1.1
|
||||||
|
|
||||||
|
'@types/d3-time@3.0.4': {}
|
||||||
|
|
||||||
|
'@types/d3-timer@3.0.2': {}
|
||||||
|
|
||||||
'@types/deep-eql@4.0.2': {}
|
'@types/deep-eql@4.0.2': {}
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
@@ -3607,6 +3819,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/statuses@2.0.6': {}
|
'@types/statuses@2.0.6': {}
|
||||||
|
|
||||||
|
'@types/use-sync-external-store@0.0.6': {}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
'@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
@@ -3947,6 +4161,44 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
d3-array@3.2.4:
|
||||||
|
dependencies:
|
||||||
|
internmap: 2.0.3
|
||||||
|
|
||||||
|
d3-color@3.1.0: {}
|
||||||
|
|
||||||
|
d3-ease@3.0.1: {}
|
||||||
|
|
||||||
|
d3-format@3.1.2: {}
|
||||||
|
|
||||||
|
d3-interpolate@3.0.1:
|
||||||
|
dependencies:
|
||||||
|
d3-color: 3.1.0
|
||||||
|
|
||||||
|
d3-path@3.1.0: {}
|
||||||
|
|
||||||
|
d3-scale@4.0.2:
|
||||||
|
dependencies:
|
||||||
|
d3-array: 3.2.4
|
||||||
|
d3-format: 3.1.2
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-time: 3.1.0
|
||||||
|
d3-time-format: 4.1.0
|
||||||
|
|
||||||
|
d3-shape@3.2.0:
|
||||||
|
dependencies:
|
||||||
|
d3-path: 3.1.0
|
||||||
|
|
||||||
|
d3-time-format@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
d3-time: 3.1.0
|
||||||
|
|
||||||
|
d3-time@3.1.0:
|
||||||
|
dependencies:
|
||||||
|
d3-array: 3.2.4
|
||||||
|
|
||||||
|
d3-timer@3.0.1: {}
|
||||||
|
|
||||||
data-urls@7.0.0:
|
data-urls@7.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
whatwg-mimetype: 5.0.0
|
whatwg-mimetype: 5.0.0
|
||||||
@@ -3960,6 +4212,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
decimal.js-light@2.5.1: {}
|
||||||
|
|
||||||
decimal.js@10.6.0: {}
|
decimal.js@10.6.0: {}
|
||||||
|
|
||||||
deep-is@0.1.4: {}
|
deep-is@0.1.4: {}
|
||||||
@@ -4008,6 +4262,8 @@ snapshots:
|
|||||||
has-tostringtag: 1.0.2
|
has-tostringtag: 1.0.2
|
||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
|
|
||||||
|
es-toolkit@1.45.1: {}
|
||||||
|
|
||||||
escalade@3.2.0: {}
|
escalade@3.2.0: {}
|
||||||
|
|
||||||
escape-string-regexp@4.0.0: {}
|
escape-string-regexp@4.0.0: {}
|
||||||
@@ -4101,6 +4357,8 @@ snapshots:
|
|||||||
|
|
||||||
esutils@2.0.3: {}
|
esutils@2.0.3: {}
|
||||||
|
|
||||||
|
eventemitter3@5.0.4: {}
|
||||||
|
|
||||||
expect-type@1.3.0: {}
|
expect-type@1.3.0: {}
|
||||||
|
|
||||||
fast-deep-equal@3.1.3: {}
|
fast-deep-equal@3.1.3: {}
|
||||||
@@ -4139,6 +4397,9 @@ snapshots:
|
|||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
mime-types: 2.1.35
|
mime-types: 2.1.35
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4210,6 +4471,10 @@ snapshots:
|
|||||||
|
|
||||||
ignore@7.0.5: {}
|
ignore@7.0.5: {}
|
||||||
|
|
||||||
|
immer@10.2.0: {}
|
||||||
|
|
||||||
|
immer@11.1.4: {}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
parent-module: 1.0.1
|
parent-module: 1.0.1
|
||||||
@@ -4219,6 +4484,8 @@ snapshots:
|
|||||||
|
|
||||||
indent-string@4.0.0: {}
|
indent-string@4.0.0: {}
|
||||||
|
|
||||||
|
internmap@2.0.3: {}
|
||||||
|
|
||||||
is-extglob@2.1.1: {}
|
is-extglob@2.1.1: {}
|
||||||
|
|
||||||
is-fullwidth-code-point@3.0.0: {}
|
is-fullwidth-code-point@3.0.0: {}
|
||||||
@@ -4467,6 +4734,14 @@ snapshots:
|
|||||||
|
|
||||||
picomatch@4.0.4: {}
|
picomatch@4.0.4: {}
|
||||||
|
|
||||||
|
playwright-core@1.59.1: {}
|
||||||
|
|
||||||
|
playwright@1.59.1:
|
||||||
|
dependencies:
|
||||||
|
playwright-core: 1.59.1
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.2
|
||||||
|
|
||||||
postcss@8.5.8:
|
postcss@8.5.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.11
|
nanoid: 3.3.11
|
||||||
@@ -4545,6 +4820,15 @@ snapshots:
|
|||||||
|
|
||||||
react-lifecycles-compat@3.0.4: {}
|
react-lifecycles-compat@3.0.4: {}
|
||||||
|
|
||||||
|
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1):
|
||||||
|
dependencies:
|
||||||
|
'@types/use-sync-external-store': 0.0.6
|
||||||
|
react: 19.2.4
|
||||||
|
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.14
|
||||||
|
redux: 5.0.1
|
||||||
|
|
||||||
react-router-dom@7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
react-router-dom@7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
@@ -4566,15 +4850,43 @@ snapshots:
|
|||||||
lodash: 4.17.23
|
lodash: 4.17.23
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
|
|
||||||
|
recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@18.3.1)(react@19.2.4)(redux@5.0.1):
|
||||||
|
dependencies:
|
||||||
|
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)
|
||||||
|
clsx: 2.1.1
|
||||||
|
decimal.js-light: 2.5.1
|
||||||
|
es-toolkit: 1.45.1
|
||||||
|
eventemitter3: 5.0.4
|
||||||
|
immer: 10.2.0
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
react-is: 18.3.1
|
||||||
|
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1)
|
||||||
|
reselect: 5.1.1
|
||||||
|
tiny-invariant: 1.3.3
|
||||||
|
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||||
|
victory-vendor: 37.3.6
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- redux
|
||||||
|
|
||||||
redent@3.0.0:
|
redent@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
indent-string: 4.0.0
|
indent-string: 4.0.0
|
||||||
strip-indent: 3.0.0
|
strip-indent: 3.0.0
|
||||||
|
|
||||||
|
redux-thunk@3.1.0(redux@5.0.1):
|
||||||
|
dependencies:
|
||||||
|
redux: 5.0.1
|
||||||
|
|
||||||
|
redux@5.0.1: {}
|
||||||
|
|
||||||
require-directory@2.1.1: {}
|
require-directory@2.1.1: {}
|
||||||
|
|
||||||
require-from-string@2.0.2: {}
|
require-from-string@2.0.2: {}
|
||||||
|
|
||||||
|
reselect@5.1.1: {}
|
||||||
|
|
||||||
resize-observer-polyfill@1.5.1: {}
|
resize-observer-polyfill@1.5.1: {}
|
||||||
|
|
||||||
resolve-from@4.0.0: {}
|
resolve-from@4.0.0: {}
|
||||||
@@ -4702,6 +5014,8 @@ snapshots:
|
|||||||
|
|
||||||
throttle-debounce@5.0.2: {}
|
throttle-debounce@5.0.2: {}
|
||||||
|
|
||||||
|
tiny-invariant@1.3.3: {}
|
||||||
|
|
||||||
tinybench@2.9.0: {}
|
tinybench@2.9.0: {}
|
||||||
|
|
||||||
tinycolor2@1.6.0: {}
|
tinycolor2@1.6.0: {}
|
||||||
@@ -4776,6 +5090,23 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
|
|
||||||
|
victory-vendor@37.3.6:
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-array': 3.2.2
|
||||||
|
'@types/d3-ease': 3.0.2
|
||||||
|
'@types/d3-interpolate': 3.0.4
|
||||||
|
'@types/d3-scale': 4.0.9
|
||||||
|
'@types/d3-shape': 3.1.8
|
||||||
|
'@types/d3-time': 3.0.4
|
||||||
|
'@types/d3-timer': 3.0.2
|
||||||
|
d3-array: 3.2.4
|
||||||
|
d3-ease: 3.0.1
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-scale: 4.0.2
|
||||||
|
d3-shape: 3.2.0
|
||||||
|
d3-time: 3.1.0
|
||||||
|
d3-timer: 3.0.1
|
||||||
|
|
||||||
vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(terser@5.46.1):
|
vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(terser@5.46.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
@@ -4893,8 +5224,9 @@ snapshots:
|
|||||||
|
|
||||||
zod@4.3.6: {}
|
zod@4.3.6: {}
|
||||||
|
|
||||||
zustand@5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
|
zustand@5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
|
immer: 11.1.4
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import { Tag } from 'antd'
|
|
||||||
|
|
||||||
interface StatusTagProps {
|
|
||||||
status: string
|
|
||||||
labels: Record<string, string>
|
|
||||||
colors: Record<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatusTag({ status, labels, colors }: StatusTagProps) {
|
|
||||||
return (
|
|
||||||
<Tag color={colors[status] || 'default'}>
|
|
||||||
{labels[status] || status}
|
|
||||||
</Tag>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -16,6 +16,12 @@ import {
|
|||||||
SunOutlined,
|
SunOutlined,
|
||||||
MoonOutlined,
|
MoonOutlined,
|
||||||
ApiOutlined,
|
ApiOutlined,
|
||||||
|
BookOutlined,
|
||||||
|
CrownOutlined,
|
||||||
|
SafetyOutlined,
|
||||||
|
FieldTimeOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
ShopOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { Avatar, Dropdown, Tooltip, Drawer } from 'antd'
|
import { Avatar, Dropdown, Tooltip, Drawer } from 'antd'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
@@ -37,12 +43,18 @@ interface NavItem {
|
|||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ path: '/', name: '仪表盘', icon: <DashboardOutlined />, group: '核心' },
|
{ path: '/', name: '仪表盘', icon: <DashboardOutlined />, group: '核心' },
|
||||||
{ path: '/accounts', name: '账号管理', icon: <TeamOutlined />, permission: 'account:admin', 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: '/model-services', name: '模型服务', icon: <CloudServerOutlined />, permission: 'provider:manage', group: '资源管理' },
|
||||||
{ path: '/agent-templates', name: 'Agent 模板', icon: <RobotOutlined />, permission: 'model:read', group: '资源管理' },
|
{ path: '/agent-templates', name: 'Agent 模板', icon: <RobotOutlined />, permission: 'model:read', group: '资源管理' },
|
||||||
{ path: '/api-keys', name: 'API 密钥', icon: <ApiOutlined />, permission: 'provider:manage', group: '资源管理' },
|
{ path: '/api-keys', name: 'API 密钥', icon: <ApiOutlined />, permission: 'provider:manage', group: '资源管理' },
|
||||||
{ path: '/usage', name: '用量统计', icon: <BarChartOutlined />, permission: 'admin:full', group: '运维' },
|
{ path: '/usage', name: '用量统计', icon: <BarChartOutlined />, permission: 'admin:full', group: '运维' },
|
||||||
{ path: '/relay', name: '中转任务', icon: <SwapOutlined />, permission: 'relay:use', 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: '/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: '/config', name: '系统配置', icon: <SettingOutlined />, permission: 'config:read', group: '系统' },
|
||||||
{ path: '/prompts', name: '提示词管理', icon: <MessageOutlined />, permission: 'prompt:read', group: '系统' },
|
{ path: '/prompts', name: '提示词管理', icon: <MessageOutlined />, permission: 'prompt:read', group: '系统' },
|
||||||
]
|
]
|
||||||
@@ -197,6 +209,7 @@ function MobileDrawer({
|
|||||||
const breadcrumbMap: Record<string, string> = {
|
const breadcrumbMap: Record<string, string> = {
|
||||||
'/': '仪表盘',
|
'/': '仪表盘',
|
||||||
'/accounts': '账号管理',
|
'/accounts': '账号管理',
|
||||||
|
'/roles': '角色与权限',
|
||||||
'/model-services': '模型服务',
|
'/model-services': '模型服务',
|
||||||
'/providers': '模型服务',
|
'/providers': '模型服务',
|
||||||
'/models': '模型服务',
|
'/models': '模型服务',
|
||||||
@@ -204,9 +217,14 @@ const breadcrumbMap: Record<string, string> = {
|
|||||||
'/agent-templates': 'Agent 模板',
|
'/agent-templates': 'Agent 模板',
|
||||||
'/usage': '用量统计',
|
'/usage': '用量统计',
|
||||||
'/relay': '中转任务',
|
'/relay': '中转任务',
|
||||||
|
'/scheduled-tasks': '定时任务',
|
||||||
|
'/knowledge': '知识库',
|
||||||
|
'/billing': '计费管理',
|
||||||
'/config': '系统配置',
|
'/config': '系统配置',
|
||||||
|
'/industries': '行业配置',
|
||||||
'/prompts': '提示词管理',
|
'/prompts': '提示词管理',
|
||||||
'/logs': '操作日志',
|
'/logs': '操作日志',
|
||||||
|
'/config-sync': '同步日志',
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
// 账号管理
|
// 账号管理
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Button, message, Tag, Modal, Form, Input, Select, Popconfirm, Space } from 'antd'
|
import { Button, message, Tag, Modal, Form, Input, Select, Popconfirm, Space, Divider } from 'antd'
|
||||||
import type { ProColumns } from '@ant-design/pro-components'
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
import { ProTable } from '@ant-design/pro-components'
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
import { accountService } from '@/services/accounts'
|
import { accountService } from '@/services/accounts'
|
||||||
|
import { industryService } from '@/services/industries'
|
||||||
|
import { billingService } from '@/services/billing'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
import type { AccountPublic } from '@/types'
|
import type { AccountPublic } from '@/types'
|
||||||
|
|
||||||
@@ -40,19 +42,46 @@ export default function Accounts() {
|
|||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [searchParams, setSearchParams] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['accounts'],
|
queryKey: ['accounts', searchParams],
|
||||||
queryFn: ({ signal }) => accountService.list(signal),
|
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({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: string; data: Partial<AccountPublic> }) =>
|
mutationFn: ({ id, data }: { id: string; data: Partial<AccountPublic> }) =>
|
||||||
accountService.update(id, data),
|
accountService.update(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
message.success('更新成功')
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['accounts'] })
|
queryClient.invalidateQueries({ queryKey: ['accounts'] })
|
||||||
setModalOpen(false)
|
|
||||||
},
|
},
|
||||||
onError: (err: Error) => message.error(err.message || '更新失败'),
|
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||||||
})
|
})
|
||||||
@@ -67,22 +96,52 @@ export default function Accounts() {
|
|||||||
onError: (err: Error) => message.error(err.message || '状态更新失败'),
|
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>[] = [
|
const columns: ProColumns<AccountPublic>[] = [
|
||||||
{ title: '用户名', dataIndex: 'username', width: 120 },
|
{ title: '用户名', dataIndex: 'username', width: 120, tooltip: '搜索用户名、邮箱或显示名' },
|
||||||
{ title: '显示名', dataIndex: 'display_name', width: 120, hideInSearch: true },
|
{ title: '显示名', dataIndex: 'display_name', width: 120, hideInSearch: true },
|
||||||
{ title: '邮箱', dataIndex: 'email', width: 180 },
|
{ title: '邮箱', dataIndex: 'email', width: 180 },
|
||||||
{
|
{
|
||||||
title: '角色',
|
title: '角色',
|
||||||
dataIndex: 'role',
|
dataIndex: 'role',
|
||||||
width: 120,
|
width: 120,
|
||||||
hideInSearch: true,
|
valueType: 'select',
|
||||||
|
valueEnum: {
|
||||||
|
super_admin: { text: '超级管理员' },
|
||||||
|
admin: { text: '管理员' },
|
||||||
|
user: { text: '用户' },
|
||||||
|
},
|
||||||
render: (_, record) => <Tag color={roleColors[record.role]}>{roleLabels[record.role] || record.role}</Tag>,
|
render: (_, record) => <Tag color={roleColors[record.role]}>{roleLabels[record.role] || record.role}</Tag>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 100,
|
width: 100,
|
||||||
hideInSearch: true,
|
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>,
|
render: (_, record) => <Tag color={statusColors[record.status]}>{statusLabels[record.status] || record.status}</Tag>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -138,14 +197,55 @@ export default function Accounts() {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const values = await form.validateFields()
|
const values = await form.validateFields()
|
||||||
if (editingId) {
|
if (!editingId) return
|
||||||
updateMutation.mutate({ id: editingId, data: values })
|
|
||||||
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader title="账号管理" description="管理系统用户账号、角色与权限" />
|
<PageHeader title="账号管理" description="管理系统用户账号、角色、权限与行业授权" />
|
||||||
|
|
||||||
<ProTable<AccountPublic>
|
<ProTable<AccountPublic>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@@ -154,6 +254,20 @@ export default function Accounts() {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
search={{}}
|
search={{}}
|
||||||
toolBarRender={() => []}
|
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={{
|
pagination={{
|
||||||
total: data?.total ?? 0,
|
total: data?.total ?? 0,
|
||||||
pageSize: data?.page_size ?? 20,
|
pageSize: data?.page_size ?? 20,
|
||||||
@@ -166,8 +280,9 @@ export default function Accounts() {
|
|||||||
title={<span className="text-base font-semibold">编辑账号</span>}
|
title={<span className="text-base font-semibold">编辑账号</span>}
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
onOk={handleSave}
|
onOk={handleSave}
|
||||||
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
|
onCancel={handleClose}
|
||||||
confirmLoading={updateMutation.isPending}
|
confirmLoading={updateMutation.isPending || setIndustriesMutation.isPending || switchPlanMutation.isPending}
|
||||||
|
width={560}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" className="mt-4">
|
<Form form={form} layout="vertical" className="mt-4">
|
||||||
<Form.Item name="display_name" label="显示名">
|
<Form.Item name="display_name" label="显示名">
|
||||||
@@ -189,6 +304,36 @@ export default function Accounts() {
|
|||||||
{ value: 'relay', label: 'SaaS 中转 (Token 池)' },
|
{ value: 'relay', label: 'SaaS 中转 (Token 池)' },
|
||||||
]} />
|
]} />
|
||||||
</Form.Item>
|
</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>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const res = await authService.login(data)
|
const res = await authService.login(data)
|
||||||
loginStore(res.token, res.refresh_token, res.account)
|
loginStore(res.account)
|
||||||
|
|
||||||
message.success('登录成功')
|
message.success('登录成功')
|
||||||
const from = searchParams.get('from') || '/'
|
const from = searchParams.get('from') || '/'
|
||||||
|
|||||||
@@ -8,32 +8,11 @@ import { Tag, Select, Typography } from 'antd'
|
|||||||
import type { ProColumns } from '@ant-design/pro-components'
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
import { ProTable } from '@ant-design/pro-components'
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
import { logService } from '@/services/logs'
|
import { logService } from '@/services/logs'
|
||||||
|
import { actionLabels, actionColors } from '@/constants/status'
|
||||||
import type { OperationLog } from '@/types'
|
import type { OperationLog } from '@/types'
|
||||||
|
|
||||||
const { Title } = Typography
|
const { Title } = Typography
|
||||||
|
|
||||||
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: '桌面端审计',
|
|
||||||
}
|
|
||||||
|
|
||||||
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',
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionOptions = Object.entries(actionLabels).map(([value, label]) => ({ value, label }))
|
const actionOptions = Object.entries(actionLabels).map(([value, label]) => ({ value, label }))
|
||||||
|
|
||||||
export default function Logs() {
|
export default function Logs() {
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ function ProviderModelsTable({ providerId }: { providerId: string }) {
|
|||||||
const columns: ProColumns<Model>[] = [
|
const columns: ProColumns<Model>[] = [
|
||||||
{ title: '模型 ID', dataIndex: 'model_id', width: 180, render: (_, r) => <Text code>{r.model_id}</Text> },
|
{ title: '模型 ID', dataIndex: 'model_id', width: 180, render: (_, r) => <Text code>{r.model_id}</Text> },
|
||||||
{ title: '别名', dataIndex: 'alias', width: 120 },
|
{ 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: '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: '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_streaming', width: 60, render: (_, r) => r.supports_streaming ? <Tag color="green">是</Tag> : <Tag>否</Tag> },
|
||||||
@@ -128,6 +129,9 @@ function ProviderModelsTable({ providerId }: { providerId: string }) {
|
|||||||
<Form.Item name="enabled" label="启用" valuePropName="checked" style={{ flex: 1 }}>
|
<Form.Item name="enabled" label="启用" valuePropName="checked" style={{ flex: 1 }}>
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</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 }}>
|
<Form.Item name="supports_streaming" label="支持流式" valuePropName="checked" style={{ flex: 1 }}>
|
||||||
<Switch defaultChecked />
|
<Switch defaultChecked />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,18 +1,71 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// 用量统计
|
// 用量统计 + 转化漏斗
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Card, Col, Row, Select, Statistic } from 'antd'
|
import { Card, Col, Row, Select, Statistic } from 'antd'
|
||||||
import { ThunderboltOutlined, ColumnWidthOutlined } from '@ant-design/icons'
|
import { ThunderboltOutlined, ColumnWidthOutlined, UserOutlined, TeamOutlined } from '@ant-design/icons'
|
||||||
import type { ProColumns } from '@ant-design/pro-components'
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
import { ProTable } 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 { telemetryService } from '@/services/telemetry'
|
||||||
|
import { statsService } from '@/services/stats'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
import { ErrorState } from '@/components/ErrorState'
|
import { ErrorState } from '@/components/ErrorState'
|
||||||
import type { DailyUsageStat, ModelUsageStat } from '@/types'
|
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() {
|
export default function Usage() {
|
||||||
const [days, setDays] = useState(30)
|
const [days, setDays] = useState(30)
|
||||||
|
|
||||||
@@ -31,6 +84,11 @@ export default function Usage() {
|
|||||||
queryFn: ({ signal }) => telemetryService.modelStats({}, signal),
|
queryFn: ({ signal }) => telemetryService.modelStats({}, signal),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: dashboardStats } = useQuery({
|
||||||
|
queryKey: ['stats-dashboard'],
|
||||||
|
queryFn: ({ signal }) => statsService.dashboard(signal),
|
||||||
|
})
|
||||||
|
|
||||||
if (dailyError) {
|
if (dailyError) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -43,6 +101,12 @@ export default function Usage() {
|
|||||||
const totalRequests = dailyData?.reduce((s, d) => s + d.request_count, 0) ?? 0
|
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 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>[] = [
|
const dailyColumns: ProColumns<DailyUsageStat>[] = [
|
||||||
{ title: '日期', dataIndex: 'day', width: 120 },
|
{ title: '日期', dataIndex: 'day', width: 120 },
|
||||||
{
|
{
|
||||||
@@ -104,7 +168,7 @@ export default function Usage() {
|
|||||||
<div>
|
<div>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="用量统计"
|
title="用量统计"
|
||||||
description="查看模型使用情况和 Token 消耗"
|
description="查看模型使用情况、Token 消耗和用户转化"
|
||||||
actions={
|
actions={
|
||||||
<Select
|
<Select
|
||||||
value={days}
|
value={days}
|
||||||
@@ -121,7 +185,7 @@ export default function Usage() {
|
|||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
<Row gutter={[16, 16]} className="mb-6">
|
<Row gutter={[16, 16]} className="mb-6">
|
||||||
<Col xs={24} sm={12}>
|
<Col xs={24} sm={12} md={6}>
|
||||||
<Card className="hover:shadow-md transition-shadow duration-200">
|
<Card className="hover:shadow-md transition-shadow duration-200">
|
||||||
<Statistic
|
<Statistic
|
||||||
title={
|
title={
|
||||||
@@ -135,7 +199,7 @@ export default function Usage() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12}>
|
<Col xs={24} sm={12} md={6}>
|
||||||
<Card className="hover:shadow-md transition-shadow duration-200">
|
<Card className="hover:shadow-md transition-shadow duration-200">
|
||||||
<Statistic
|
<Statistic
|
||||||
title={
|
title={
|
||||||
@@ -149,6 +213,100 @@ export default function Usage() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</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>
|
</Row>
|
||||||
|
|
||||||
{/* Daily Stats */}
|
{/* Daily Stats */}
|
||||||
|
|||||||
@@ -3,10 +3,14 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
//
|
//
|
||||||
// Auth strategy:
|
// Auth strategy:
|
||||||
// 1. If Zustand has token (normal flow after login) → authenticated
|
// 1. On first mount, always validate the HttpOnly cookie via GET /auth/me
|
||||||
// 2. If no token but account in localStorage → call GET /auth/me
|
// 2. If cookie valid -> restore session and render children
|
||||||
// to validate HttpOnly cookie and restore session
|
// 3. If cookie invalid -> clean up and redirect to /login
|
||||||
// 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 { useEffect, useRef, useState } from 'react'
|
||||||
import { Navigate, useLocation } from 'react-router-dom'
|
import { Navigate, useLocation } from 'react-router-dom'
|
||||||
@@ -14,41 +18,44 @@ import { Spin } from 'antd'
|
|||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { authService } from '@/services/auth'
|
import { authService } from '@/services/auth'
|
||||||
|
|
||||||
|
type GuardState = 'checking' | 'authenticated' | 'unauthenticated'
|
||||||
|
|
||||||
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||||
const token = useAuthStore((s) => s.token)
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||||
const account = useAuthStore((s) => s.account)
|
|
||||||
const login = useAuthStore((s) => s.login)
|
const login = useAuthStore((s) => s.login)
|
||||||
const logout = useAuthStore((s) => s.logout)
|
const logout = useAuthStore((s) => s.logout)
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
// Track restore attempt to avoid double-calling
|
// Track validation attempt to avoid double-calling (React StrictMode)
|
||||||
const restoreAttempted = useRef(false)
|
const validated = useRef(false)
|
||||||
const [restoring, setRestoring] = useState(false)
|
const [guardState, setGuardState] = useState<GuardState>(
|
||||||
|
isAuthenticated ? 'authenticated' : 'checking'
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (restoreAttempted.current) return
|
// Already authenticated from login flow — skip validation
|
||||||
restoreAttempted.current = true
|
if (isAuthenticated) {
|
||||||
|
setGuardState('authenticated')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// If no in-memory token but account exists in localStorage,
|
// Prevent double-validation in React StrictMode
|
||||||
// try to validate the HttpOnly cookie via /auth/me
|
if (validated.current) return
|
||||||
if (!token && account) {
|
validated.current = true
|
||||||
setRestoring(true)
|
|
||||||
|
// Validate HttpOnly cookie via /auth/me
|
||||||
authService.me()
|
authService.me()
|
||||||
.then((meAccount) => {
|
.then((meAccount) => {
|
||||||
// Cookie is valid — restore session
|
login(meAccount)
|
||||||
// Use sentinel token since real auth is via HttpOnly cookie
|
setGuardState('authenticated')
|
||||||
login('cookie-session', '', meAccount)
|
|
||||||
setRestoring(false)
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Cookie expired or invalid — clean up stale data
|
|
||||||
logout()
|
logout()
|
||||||
setRestoring(false)
|
setGuardState('unauthenticated')
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
if (restoring) {
|
if (guardState === 'checking') {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||||
<Spin size="large" />
|
<Spin size="large" />
|
||||||
@@ -56,7 +63,7 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!token) {
|
if (guardState === 'unauthenticated') {
|
||||||
return <Navigate to="/login" state={{ from: location }} replace />
|
return <Navigate to="/login" state={{ from: location }} replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,16 +21,22 @@ export const router = createBrowserRouter([
|
|||||||
children: [
|
children: [
|
||||||
{ index: true, lazy: () => import('@/pages/Dashboard').then((m) => ({ Component: m.default })) },
|
{ index: true, lazy: () => import('@/pages/Dashboard').then((m) => ({ Component: m.default })) },
|
||||||
{ path: 'accounts', lazy: () => import('@/pages/Accounts').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: 'model-services', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
|
||||||
{ path: 'providers', 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: 'models', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
|
||||||
{ path: 'agent-templates', lazy: () => import('@/pages/AgentTemplates').then((m) => ({ Component: m.default })) },
|
{ path: 'agent-templates', lazy: () => import('@/pages/AgentTemplates').then((m) => ({ Component: m.default })) },
|
||||||
{ path: 'api-keys', lazy: () => import('@/pages/ModelServices').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: '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: '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: 'config', lazy: () => import('@/pages/Config').then((m) => ({ Component: m.default })) },
|
||||||
{ path: 'prompts', lazy: () => import('@/pages/Prompts').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: '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 })) },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -5,11 +5,7 @@ export const agentTemplateService = {
|
|||||||
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
request.get<PaginatedResponse<AgentTemplate>>('/agent-templates', withSignal({ params }, signal)).then((r) => r.data),
|
request.get<PaginatedResponse<AgentTemplate>>('/agent-templates', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
|
||||||
get: (id: string, signal?: AbortSignal) =>
|
|
||||||
request.get<AgentTemplate>(`/agent-templates/${id}`, withSignal({}, signal)).then((r) => r.data),
|
|
||||||
|
|
||||||
getFull: (id: string, signal?: AbortSignal) =>
|
|
||||||
request.get<AgentTemplate>(`/agent-templates/${id}/full`, withSignal({}, signal)).then((r) => r.data),
|
|
||||||
|
|
||||||
create: (data: {
|
create: (data: {
|
||||||
name: string; description?: string; category?: string; source?: string
|
name: string; description?: string; category?: string; source?: string
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import request, { withSignal } from './request'
|
import request, { withSignal } from './request'
|
||||||
import type { TokenInfo, CreateTokenRequest, PaginatedResponse } from '@/types'
|
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 = {
|
export const apiKeyService = {
|
||||||
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
request.get<PaginatedResponse<TokenInfo>>('/keys', withSignal({ params }, signal)).then((r) => r.data),
|
request.get<PaginatedResponse<TokenInfo>>('/tokens', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
|
||||||
create: (data: CreateTokenRequest, signal?: AbortSignal) =>
|
create: (data: CreateTokenRequest, signal?: AbortSignal) =>
|
||||||
request.post<TokenInfo>('/keys', data, withSignal({}, signal)).then((r) => r.data),
|
request.post<TokenInfo>('/tokens', data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
revoke: (id: string, signal?: AbortSignal) =>
|
revoke: (id: string, signal?: AbortSignal) =>
|
||||||
request.delete(`/keys/${id}`, withSignal({}, signal)).then((r) => r.data),
|
request.delete(`/tokens/${id}`, 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),
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user