1 Commits

Author SHA1 Message Date
iven
44256a511c feat: 增强SaaS后端功能与安全性
refactor: 重构数据库连接使用PostgreSQL替代SQLite
feat(auth): 增加JWT验证的audience和issuer检查
feat(crypto): 添加AES-256-GCM字段加密支持
feat(api): 集成utoipa实现OpenAPI文档
fix(admin): 修复配置项表单验证逻辑
style: 统一代码格式与类型定义
docs: 更新技术栈文档说明PostgreSQL
2026-03-31 00:12:53 +08:00
1463 changed files with 44571 additions and 192174 deletions

View File

@@ -1,7 +0,0 @@
# Reduce parallel compilation jobs to prevent compiler OOM.
# The desktop crate + its dependencies (tauri, sqlx, fantoccini, etc.)
# consume significant memory during borrow checking / type inference.
#
# If builds still OOM, try lowering further (e.g. 2 or 1).
[build]
jobs = 2

View File

@@ -1,28 +0,0 @@
// 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
}
});

View File

@@ -1,15 +0,0 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node .claude/hooks/arch-sync-check.js"
}
]
}
]
}
}

View File

@@ -1,52 +0,0 @@
# 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 deleted from 44256a511c

View File

@@ -1,42 +1,93 @@
# ============================================================
# ZCLAW SaaS Backend - Docker Ignore
# ============================================================
# Build artifacts
target/
# Frontend applications (not needed for SaaS backend)
desktop/
admin/
design-system/
# Node.js
node_modules/
# Environment and secrets
.env
.env.*
*.pem
*.key
# IDE and OS
.vscode/
.idea/
.DS_Store
Thumbs.db
.pnpm-store/
bun.lock
pnpm-lock.yaml
package.json
package-lock.json
# Git
.git/
.gitignore
# Logs
*.log
# IDE and editor
.vscode/
.idea/
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
# Docker
docker-compose.yml
.docker/
docker-compose*.yml
Dockerfile
.dockerignore
# Documentation (not needed in image)
# Documentation
docs/
*.md
!README.md
!saas-config.toml
CLAUDE.md
CLAUDE*.md
# Test files
# Environment files (secrets)
.env
.env.*
saas-env.example
# Data files
saas-data/
saas-data.db
saas-data.db-shm
saas-data.db-wal
*.db
*.db-shm
*.db-wal
# Test artifacts
tests/
tests/e2e/
admin-v2/tests/
test-results/
test.rs
*.log
# Claude/development tools
# Temporary files
tmp-screenshot.png
tmp/
temp/
*.tmp
# Claude worktree metadata
.claude/
.planning/
.superpowers/
plans/
pipelines/
scripts/
hands/
skills/
plugins/
config/
extract.js
extract_models.js
extract_privacy.js
start-all.ps1
start.ps1
start.sh
Makefile
PROGRESS.md
CHANGELOG.md
pencil-new.pen

View File

@@ -44,12 +44,3 @@ ZCLAW_EMBEDDING_MODEL=text-embedding-3-small
# === Logging ===
# 可选: debug, info, warn, error
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

View File

@@ -50,7 +50,7 @@ jobs:
- name: Rust Clippy
working-directory: .
run: cargo clippy --workspace --exclude zclaw-saas -- -D warnings
run: cargo clippy --workspace -- -D warnings
- name: Install frontend dependencies
working-directory: desktop
@@ -94,7 +94,7 @@ jobs:
- name: Run Rust tests
working-directory: .
run: cargo test --workspace --exclude zclaw-saas
run: cargo test --workspace
- name: Install frontend dependencies
working-directory: desktop
@@ -138,7 +138,7 @@ jobs:
- name: Rust release build
working-directory: .
run: cargo build --release --workspace --exclude zclaw-saas
run: cargo build --release --workspace
- name: Install frontend dependencies
working-directory: desktop

View File

@@ -45,7 +45,7 @@ jobs:
- name: Run Rust tests
working-directory: .
run: cargo test --workspace --exclude zclaw-saas
run: cargo test --workspace
- name: Install frontend dependencies
working-directory: desktop

4
.gitignore vendored
View File

@@ -12,10 +12,6 @@ build/
.env.local
.env.*.local
# SaaS config (contains database credentials)
saas-config.toml
!saas-config.toml.example
# Logs
logs/
*.log

View File

@@ -1,15 +0,0 @@
{
"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"
}
}
}
}

View File

@@ -1 +0,0 @@
{"reason":"owner process exited","timestamp":1774933144596}

View File

@@ -1 +0,0 @@
1454

View File

@@ -1,151 +0,0 @@
<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>

View File

@@ -1 +0,0 @@
{"reason":"owner process exited","timestamp":1775026601420}

View File

@@ -1 +0,0 @@
1627

View File

@@ -1,68 +0,0 @@
<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>

View File

@@ -1,123 +0,0 @@
<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 / &lt;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>

View File

@@ -1 +0,0 @@
{"reason":"owner process exited","timestamp":1775055441855}

View File

@@ -1 +0,0 @@
1917

View File

@@ -1,166 +0,0 @@
<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>

View File

@@ -1,3 +0,0 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>

View File

@@ -1,3 +0,0 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">正在准备知识库 UI 布局方案...</p>
</div>

View File

@@ -1 +0,0 @@
{"reason":"owner process exited","timestamp":1775043250470}

View File

@@ -1,68 +0,0 @@
<h2>ZCLAW 功能优先级矩阵</h2>
<p class="subtitle">哪些功能能让用户"啊"的一声觉得值?点击选择你认为的杀手级功能(可多选)</p>
<div class="options" data-multiselect>
<div class="option" data-choice="smart-chat" onclick="toggleSelect(this)">
<div class="letter">A</div>
<div class="content">
<h3>智能对话(深度优化)</h3>
<p>多模型无缝切换、流式响应、上下文记忆闭环、Tool Call 可视化。<br><strong>现状:</strong>基础已好,需打磨体验细节(消息虚拟化、搜索、导出)</p>
</div>
</div>
<div class="option" data-choice="hands" onclick="toggleSelect(this)">
<div class="letter">B</div>
<div class="content">
<h3>自主 Hands数字员工</h3>
<p>Browser 自动化、深度研究、数据采集、Twitter 运营——让 AI 真正干活。<br><strong>现状:</strong>9个 Hand 有实现,但需真实场景验证 + 可视化执行流程</p>
</div>
</div>
<div class="option" data-choice="pipeline" onclick="toggleSelect(this)">
<div class="letter">C</div>
<div class="content">
<h3>Pipeline 工作流</h3>
<p>拖拽式自动化编排:多步骤、多模型、并行/条件分支、定时触发。<br><strong>现状:</strong>引擎完成、UI 有基础版,需完善可视化编辑器 + 模板市场</p>
</div>
</div>
<div class="option" data-choice="memory" onclick="toggleSelect(this)">
<div class="letter">D</div>
<div class="content">
<h3>记忆与成长系统</h3>
<p>跨会话记忆、事实提取、偏好学习、知识图谱——AI 越用越懂你。<br><strong>现状:</strong>Growth 系统完成Fact 提取可用,需增强检索质量和可视化</p>
</div>
</div>
<div class="option" data-choice="skills" onclick="toggleSelect(this)">
<div class="letter">E</div>
<div class="content">
<h3>技能市场</h3>
<p>75+ 预置技能 + 社区技能分享 + 一键安装——AI 能力的 App Store。<br><strong>现状:</strong>SKILL.md 体系完成需技能发现UI + 安装/卸载流程</p>
</div>
</div>
<div class="option" data-choice="gateway" onclick="toggleSelect(this)">
<div class="letter">F</div>
<div class="content">
<h3>LLM 网关SaaS 变现核心)</h3>
<p>Key Pool 代理、用量计费、配额管理、组织级 API Key 管理——企业买单的理由。<br><strong>现状:</strong>Relay+Key Pool 完成,缺计费/配额/支付闭环</p>
</div>
</div>
<div class="option" data-choice="multi-agent" onclick="toggleSelect(this)">
<div class="letter">G</div>
<div class="content">
<h3>多 Agent 协作</h3>
<p>Director 编排、A2A 协议、角色分配——多个 AI 角色协同解决复杂问题。<br><strong>现状:</strong>代码完成但 feature-gated未接入桌面端</p>
</div>
</div>
<div class="option" data-choice="admin" onclick="toggleSelect(this)">
<div class="letter">H</div>
<div class="content">
<h3>Admin V2 管理面板</h3>
<p>用户管理、模型配置、用量统计、操作审计——SaaS 运维必备。<br><strong>现状:</strong>10个页面完成需测试 + 告警 + 数据看板</p>
</div>
</div>
</div>

463
65-90p
View File

@@ -1,463 +0,0 @@
00000000: 2f2f 2120 5a43 4c41 5720 5361 6153 20e6 //! ZCLAW SaaS .
00000010: 9c8d e58a a1e5 85a5 e58f a30d 0a0d 0a75 ...............u
00000020: 7365 2061 7875 6d3a 3a65 7874 7261 6374 se axum::extract
00000030: 3a3a 5374 6174 653b 0d0a 7573 6520 746f ::State;..use to
00000040: 7765 725f 6874 7470 3a3a 7469 6d65 6f75 wer_http::timeou
00000050: 743a 3a54 696d 656f 7574 4c61 7965 723b t::TimeoutLayer;
00000060: 0d0a 7573 6520 7472 6163 696e 673a 3a69 ..use tracing::i
00000070: 6e66 6f3b 0d0a 7573 6520 7a63 6c61 775f nfo;..use zclaw_
00000080: 7361 6173 3a3a 7b63 6f6e 6669 673a 3a53 saas::{config::S
00000090: 6161 5343 6f6e 6669 672c 2064 623a 3a69 aaSConfig, db::i
000000a0: 6e69 745f 6462 2c20 7374 6174 653a 3a41 nit_db, state::A
000000b0: 7070 5374 6174 657d 3b0d 0a75 7365 207a ppState};..use z
000000c0: 636c 6177 5f73 6161 733a 3a77 6f72 6b65 claw_saas::worke
000000d0: 7273 3a3a 576f 726b 6572 4469 7370 6174 rs::WorkerDispat
000000e0: 6368 6572 3b0d 0a75 7365 207a 636c 6177 cher;..use zclaw
000000f0: 5f73 6161 733a 3a77 6f72 6b65 7273 3a3a _saas::workers::
00000100: 6c6f 675f 6f70 6572 6174 696f 6e3a 3a4c log_operation::L
00000110: 6f67 4f70 6572 6174 696f 6e57 6f72 6b65 ogOperationWorke
00000120: 723b 0d0a 7573 6520 7a63 6c61 775f 7361 r;..use zclaw_sa
00000130: 6173 3a3a 776f 726b 6572 733a 3a63 6c65 as::workers::cle
00000140: 616e 7570 5f72 6566 7265 7368 5f74 6f6b anup_refresh_tok
00000150: 656e 733a 3a43 6c65 616e 7570 5265 6672 ens::CleanupRefr
00000160: 6573 6854 6f6b 656e 7357 6f72 6b65 723b eshTokensWorker;
00000170: 0d0a 7573 6520 7a63 6c61 775f 7361 6173 ..use zclaw_saas
00000180: 3a3a 776f 726b 6572 733a 3a63 6c65 616e ::workers::clean
00000190: 7570 5f72 6174 655f 6c69 6d69 743a 3a43 up_rate_limit::C
000001a0: 6c65 616e 7570 5261 7465 4c69 6d69 7457 leanupRateLimitW
000001b0: 6f72 6b65 723b 0d0a 7573 6520 7a63 6c61 orker;..use zcla
000001c0: 775f 7361 6173 3a3a 776f 726b 6572 733a w_saas::workers:
000001d0: 3a72 6563 6f72 645f 7573 6167 653a 3a52 :record_usage::R
000001e0: 6563 6f72 6455 7361 6765 576f 726b 6572 ecordUsageWorker
000001f0: 3b0d 0a75 7365 207a 636c 6177 5f73 6161 ;..use zclaw_saa
00000200: 733a 3a77 6f72 6b65 7273 3a3a 7570 6461 s::workers::upda
00000210: 7465 5f6c 6173 745f 7573 6564 3a3a 5570 te_last_used::Up
00000220: 6461 7465 4c61 7374 5573 6564 576f 726b dateLastUsedWork
00000230: 6572 3b0d 0a0d 0a23 5b74 6f6b 696f 3a3a er;....#[tokio::
00000240: 6d61 696e 5d0d 0a61 7379 6e63 2066 6e20 main]..async fn
00000250: 6d61 696e 2829 202d 3e20 616e 7968 6f77 main() -> anyhow
00000260: 3a3a 5265 7375 6c74 3c28 293e 207b 0d0a ::Result<()> {..
00000270: 2020 2020 7472 6163 696e 675f 7375 6273 tracing_subs
00000280: 6372 6962 6572 3a3a 666d 7428 290d 0a20 criber::fmt()..
00000290: 2020 2020 2020 202e 7769 7468 5f65 6e76 .with_env
000002a0: 5f66 696c 7465 7228 0d0a 2020 2020 2020 _filter(..
000002b0: 2020 2020 2020 7472 6163 696e 675f 7375 tracing_su
000002c0: 6273 6372 6962 6572 3a3a 456e 7646 696c bscriber::EnvFil
000002d0: 7465 723a 3a74 7279 5f66 726f 6d5f 6465 ter::try_from_de
000002e0: 6661 756c 745f 656e 7628 290d 0a20 2020 fault_env()..
000002f0: 2020 2020 2020 2020 2020 2020 202e 756e .un
00000300: 7772 6170 5f6f 725f 656c 7365 287c 5f7c wrap_or_else(|_|
00000310: 2022 7a63 6c61 775f 7361 6173 3d64 6562 "zclaw_saas=deb
00000320: 7567 2c74 6f77 6572 5f68 7474 703d 6465 ug,tower_http=de
00000330: 6275 6722 2e69 6e74 6f28 2929 2c0d 0a20 bug".into()),..
00000340: 2020 2020 2020 2029 0d0a 2020 2020 2020 )..
00000350: 2020 2e69 6e69 7428 293b 0d0a 0d0a 2020 .init();....
00000360: 2020 6c65 7420 636f 6e66 6967 203d 2053 let config = S
00000370: 6161 5343 6f6e 6669 673a 3a6c 6f61 6428 aaSConfig::load(
00000380: 293f 3b0d 0a20 2020 2069 6e66 6f21 2822 )?;.. info!("
00000390: 5361 6153 2063 6f6e 6669 6720 6c6f 6164 SaaS config load
000003a0: 6564 3a20 7b7d 3a7b 7d22 2c20 636f 6e66 ed: {}:{}", conf
000003b0: 6967 2e73 6572 7665 722e 686f 7374 2c20 ig.server.host,
000003c0: 636f 6e66 6967 2e73 6572 7665 722e 706f config.server.po
000003d0: 7274 293b 0d0a 0d0a 2020 2020 6c65 7420 rt);.... let
000003e0: 6462 203d 2069 6e69 745f 6462 2826 636f db = init_db(&co
000003f0: 6e66 6967 2e64 6174 6162 6173 652e 7572 nfig.database.ur
00000400: 6c29 2e61 7761 6974 3f3b 0d0a 2020 2020 l).await?;..
00000410: 696e 666f 2128 2244 6174 6162 6173 6520 info!("Database
00000420: 696e 6974 6961 6c69 7a65 6422 293b 0d0a initialized");..
00000430: 0d0a 2020 2020 2f2f 20e5 889d e5a7 8be5 .. // .......
00000440: 8c96 2057 6f72 6b65 7220 e8b0 83e5 baa6 .. Worker ......
00000450: e599 a820 2b20 e6b3 a8e5 868c e689 80e6 ... + ..........
00000460: 9c89 2057 6f72 6b65 720d 0a20 2020 206c .. Worker.. l
00000470: 6574 206d 7574 2064 6973 7061 7463 6865 et mut dispatche
00000480: 7220 3d20 576f 726b 6572 4469 7370 6174 r = WorkerDispat
00000490: 6368 6572 3a3a 6e65 7728 6462 2e63 6c6f cher::new(db.clo
000004a0: 6e65 2829 293b 0d0a 2020 2020 6469 7370 ne());.. disp
000004b0: 6174 6368 6572 2e72 6567 6973 7465 7228 atcher.register(
000004c0: 4c6f 674f 7065 7261 7469 6f6e 576f 726b LogOperationWork
000004d0: 6572 293b 0d0a 2020 2020 6469 7370 6174 er);.. dispat
000004e0: 6368 6572 2e72 6567 6973 7465 7228 436c cher.register(Cl
000004f0: 6561 6e75 7052 6566 7265 7368 546f 6b65 eanupRefreshToke
00000500: 6e73 576f 726b 6572 293b 0d0a 2020 2020 nsWorker);..
00000510: 6469 7370 6174 6368 6572 2e72 6567 6973 dispatcher.regis
00000520: 7465 7228 436c 6561 6e75 7052 6174 654c ter(CleanupRateL
00000530: 696d 6974 576f 726b 6572 293b 0d0a 2020 imitWorker);..
00000540: 2020 6469 7370 6174 6368 6572 2e72 6567 dispatcher.reg
00000550: 6973 7465 7228 5265 636f 7264 5573 6167 ister(RecordUsag
00000560: 6557 6f72 6b65 7229 3b0d 0a20 2020 2064 eWorker);.. d
00000570: 6973 7061 7463 6865 722e 7265 6769 7374 ispatcher.regist
00000580: 6572 2855 7064 6174 654c 6173 7455 7365 er(UpdateLastUse
00000590: 6457 6f72 6b65 7229 3b0d 0a20 2020 2069 dWorker);.. i
000005a0: 6e66 6f21 2822 576f 726b 6572 2064 6973 nfo!("Worker dis
000005b0: 7061 7463 6865 7220 696e 6974 6961 6c69 patcher initiali
000005c0: 7a65 6420 2835 2077 6f72 6b65 7273 2072 zed (5 workers r
000005d0: 6567 6973 7465 7265 6429 2229 3b0d 0a0d egistered)");...
000005e0: 0a20 2020 206c 6574 2073 7461 7465 203d . let state =
000005f0: 2041 7070 5374 6174 653a 3a6e 6577 2864 AppState::new(d
00000600: 622e 636c 6f6e 6528 292c 2063 6f6e 6669 b.clone(), confi
00000610: 672e 636c 6f6e 6528 292c 2064 6973 7061 g.clone(), dispa
00000620: 7463 6865 7229 3f3b 0d0a 0d0a 2020 2020 tcher)?;....
00000630: 2f2f 20e5 90af e58a a8e5 a3b0 e698 8ee5 // .............
00000640: bc8f 2053 6368 6564 756c 6572 efbc 88e4 .. Scheduler....
00000650: bb8e 2054 4f4d 4c20 e985 8de7 bdae e8af .. TOML ........
00000660: bbe5 8f96 e5ae 9ae6 97b6 e4bb bbe5 8aa1 ................
00000670: efbc 890d 0a20 2020 206c 6574 2073 6368 ..... let sch
00000680: 6564 756c 6572 5f63 6f6e 6669 6720 3d20 eduler_config =
00000690: 2663 6f6e 6669 672e 7363 6865 6475 6c65 &config.schedule
000006a0: 723b 0d0a 2020 2020 7a63 6c61 775f 7361 r;.. zclaw_sa
000006b0: 6173 3a3a 7363 6865 6475 6c65 723a 3a73 as::scheduler::s
000006c0: 7461 7274 5f73 6368 6564 756c 6572 2873 tart_scheduler(s
000006d0: 6368 6564 756c 6572 5f63 6f6e 6669 672c cheduler_config,
000006e0: 2064 622e 636c 6f6e 6528 292c 2073 7461 db.clone(), sta
000006f0: 7465 2e77 6f72 6b65 725f 6469 7370 6174 te.worker_dispat
00000700: 6368 6572 2e63 6c6f 6e65 5f72 6566 2829 cher.clone_ref()
00000710: 293b 0d0a 2020 2020 696e 666f 2128 2253 );.. info!("S
00000720: 6368 6564 756c 6572 2073 7461 7274 6564 cheduler started
00000730: 2077 6974 6820 7b7d 206a 6f62 7322 2c20 with {} jobs",
00000740: 7363 6865 6475 6c65 725f 636f 6e66 6967 scheduler_config
00000750: 2e6a 6f62 732e 6c65 6e28 2929 3b0d 0a0d .jobs.len());...
00000760: 0a20 2020 202f 2f20 e590 afe5 8aa8 e586 . // ........
00000770: 85e7 bdae 2044 4220 e6b8 85e7 9086 e4bb .... DB ........
00000780: bbe5 8aa1 efbc 88e8 aebe e5a4 87e6 b885 ................
00000790: e790 86e7 ad89 e4b8 8de9 809a e8bf 8720 ...............
000007a0: 576f 726b 6572 20e7 9a84 e4bb bbe5 8aa1 Worker .........
000007b0: efbc 890d 0a20 2020 207a 636c 6177 5f73 ..... zclaw_s
000007c0: 6161 733a 3a73 6368 6564 756c 6572 3a3a aas::scheduler::
000007d0: 7374 6172 745f 6462 5f63 6c65 616e 7570 start_db_cleanup
000007e0: 5f74 6173 6b73 2864 622e 636c 6f6e 6528 _tasks(db.clone(
000007f0: 2929 3b0d 0a0d 0a20 2020 202f 2f20 e590 ));.... // ..
00000800: afe5 8aa8 e586 85e5 ad98 e4b8 ade7 9a84 ................
00000810: 2072 6174 6520 6c69 6d69 7420 e69d a1e7 rate limit ....
00000820: 9bae e6b8 85e7 9086 0d0a 2020 2020 6c65 .......... le
00000830: 7420 7261 7465 5f6c 696d 6974 5f73 7461 t rate_limit_sta
00000840: 7465 203d 2073 7461 7465 2e63 6c6f 6e65 te = state.clone
00000850: 2829 3b0d 0a20 2020 2074 6f6b 696f 3a3a ();.. tokio::
00000860: 7370 6177 6e28 6173 796e 6320 6d6f 7665 spawn(async move
00000870: 207b 0d0a 2020 2020 2020 2020 6c65 7420 {.. let
00000880: 6d75 7420 696e 7465 7276 616c 203d 2074 mut interval = t
00000890: 6f6b 696f 3a3a 7469 6d65 3a3a 696e 7465 okio::time::inte
000008a0: 7276 616c 2873 7464 3a3a 7469 6d65 3a3a rval(std::time::
000008b0: 4475 7261 7469 6f6e 3a3a 6672 6f6d 5f73 Duration::from_s
000008c0: 6563 7328 3330 3029 293b 0d0a 2020 2020 ecs(300));..
000008d0: 2020 2020 6c6f 6f70 207b 0d0a 2020 2020 loop {..
000008e0: 2020 2020 2020 2020 696e 7465 7276 616c interval
000008f0: 2e74 6963 6b28 292e 6177 6169 743b 0d0a .tick().await;..
00000900: 2020 2020 2020 2020 2020 2020 7261 7465 rate
00000910: 5f6c 696d 6974 5f73 7461 7465 2e63 6c65 _limit_state.cle
00000920: 616e 7570 5f72 6174 655f 6c69 6d69 745f anup_rate_limit_
00000930: 656e 7472 6965 7328 293b 0d0a 2020 2020 entries();..
00000940: 2020 2020 7d0d 0a20 2020 207d 293b 0d0a }.. });..
00000950: 0d0a 2020 2020 6c65 7420 6170 7020 3d20 .. let app =
00000960: 6275 696c 645f 726f 7574 6572 2873 7461 build_router(sta
00000970: 7465 292e 6177 6169 743b 0d0a 0d0a 2020 te).await;....
00000980: 2020 6c65 7420 6c69 7374 656e 6572 203d let listener =
00000990: 2074 6f6b 696f 3a3a 6e65 743a 3a54 6370 tokio::net::Tcp
000009a0: 4c69 7374 656e 6572 3a3a 6269 6e64 2866 Listener::bind(f
000009b0: 6f72 6d61 7421 2822 7b7d 3a7b 7d22 2c20 ormat!("{}:{}",
000009c0: 636f 6e66 6967 2e73 6572 7665 722e 686f config.server.ho
000009d0: 7374 2c20 636f 6e66 6967 2e73 6572 7665 st, config.serve
000009e0: 722e 706f 7274 2929 0d0a 2020 2020 2020 r.port))..
000009f0: 2020 2e61 7761 6974 3f3b 0d0a 2020 2020 .await?;..
00000a00: 696e 666f 2128 2253 6161 5320 7365 7276 info!("SaaS serv
00000a10: 6572 206c 6973 7465 6e69 6e67 206f 6e20 er listening on
00000a20: 7b7d 3a7b 7d22 2c20 636f 6e66 6967 2e73 {}:{}", config.s
00000a30: 6572 7665 722e 686f 7374 2c20 636f 6e66 erver.host, conf
00000a40: 6967 2e73 6572 7665 722e 706f 7274 293b ig.server.port);
00000a50: 0d0a 0d0a 2020 2020 6178 756d 3a3a 7365 .... axum::se
00000a60: 7276 6528 6c69 7374 656e 6572 2c20 6170 rve(listener, ap
00000a70: 702e 696e 746f 5f6d 616b 655f 7365 7276 p.into_make_serv
00000a80: 6963 655f 7769 7468 5f63 6f6e 6e65 6374 ice_with_connect
00000a90: 5f69 6e66 6f3a 3a3c 7374 643a 3a6e 6574 _info::<std::net
00000aa0: 3a3a 536f 636b 6574 4164 6472 3e28 2929 ::SocketAddr>())
00000ab0: 0d0a 2020 2020 2020 2020 2e77 6974 685f .. .with_
00000ac0: 6772 6163 6566 756c 5f73 6875 7464 6f77 graceful_shutdow
00000ad0: 6e28 7368 7574 646f 776e 5f73 6967 6e61 n(shutdown_signa
00000ae0: 6c28 2929 0d0a 2020 2020 2020 2020 2e61 l()).. .a
00000af0: 7761 6974 3f3b 0d0a 2020 2020 4f6b 2828 wait?;.. Ok((
00000b00: 2929 0d0a 7d0d 0a0d 0a61 7379 6e63 2066 ))..}....async f
00000b10: 6e20 6865 616c 7468 5f68 616e 646c 6572 n health_handler
00000b20: 2853 7461 7465 2873 7461 7465 293a 2053 (State(state): S
00000b30: 7461 7465 3c41 7070 5374 6174 653e 2920 tate<AppState>)
00000b40: 2d3e 2061 7875 6d3a 3a4a 736f 6e3c 7365 -> axum::Json<se
00000b50: 7264 655f 6a73 6f6e 3a3a 5661 6c75 653e rde_json::Value>
00000b60: 207b 0d0a 2020 2020 2f2f 2068 6561 6c74 {.. // healt
00000b70: 6820 e5bf 85e9 a1bb e78b ace7 ab8b e5bf h ..............
00000b80: abe9 809f e8bf 94e5 9b9e efbc 8ce7 94a8 ................
00000b90: 2033 7320 e8b6 85e6 97b6 e981 bfe5 858d 3s ............
00000ba0: e8bf 9ee6 8ea5 e6b1 a0e6 bba1 e697 b6e9 ................
00000bb0: 98bb e5a1 9e0d 0a20 2020 206c 6574 2064 ....... let d
00000bc0: 625f 6865 616c 7468 7920 3d20 746f 6b69 b_healthy = toki
00000bd0: 6f3a 3a74 696d 653a 3a74 696d 656f 7574 o::time::timeout
00000be0: 280d 0a20 2020 2020 2020 2073 7464 3a3a (.. std::
00000bf0: 7469 6d65 3a3a 4475 7261 7469 6f6e 3a3a time::Duration::
00000c00: 6672 6f6d 5f73 6563 7328 3329 2c0d 0a20 from_secs(3),..
00000c10: 2020 2020 2020 2073 716c 783a 3a71 7565 sqlx::que
00000c20: 7279 5f73 6361 6c61 723a 3a3c 5f2c 2069 ry_scalar::<_, i
00000c30: 3332 3e28 2253 454c 4543 5420 3122 292e 32>("SELECT 1").
00000c40: 6665 7463 685f 6f6e 6528 2673 7461 7465 fetch_one(&state
00000c50: 2e64 6229 2c0d 0a20 2020 2029 0d0a 2020 .db),.. )..
00000c60: 2020 2e61 7761 6974 0d0a 2020 2020 2e6d .await.. .m
00000c70: 6170 287c 727c 2072 2e69 735f 6f6b 2829 ap(|r| r.is_ok()
00000c80: 290d 0a20 2020 202e 756e 7772 6170 5f6f ).. .unwrap_o
00000c90: 7228 6661 6c73 6529 3b0d 0a0d 0a20 2020 r(false);....
00000ca0: 206c 6574 2073 7461 7475 7320 3d20 6966 let status = if
00000cb0: 2064 625f 6865 616c 7468 7920 7b20 2268 db_healthy { "h
00000cc0: 6561 6c74 6879 2220 7d20 656c 7365 207b ealthy" } else {
00000cd0: 2022 6465 6772 6164 6564 2220 7d3b 0d0a "degraded" };..
00000ce0: 2020 2020 6c65 7420 5f63 6f64 6520 3d20 let _code =
00000cf0: 6966 2064 625f 6865 616c 7468 7920 7b20 if db_healthy {
00000d00: 3230 3020 7d20 656c 7365 207b 2035 3033 200 } else { 503
00000d10: 207d 3b0d 0a0d 0a20 2020 2061 7875 6d3a };.... axum:
00000d20: 3a4a 736f 6e28 7365 7264 655f 6a73 6f6e :Json(serde_json
00000d30: 3a3a 6a73 6f6e 2128 7b0d 0a20 2020 2020 ::json!({..
00000d40: 2020 2022 7374 6174 7573 223a 2073 7461 "status": sta
00000d50: 7475 732c 0d0a 2020 2020 2020 2020 2264 tus,.. "d
00000d60: 6174 6162 6173 6522 3a20 6462 5f68 6561 atabase": db_hea
00000d70: 6c74 6879 2c0d 0a20 2020 2020 2020 2022 lthy,.. "
00000d80: 7469 6d65 7374 616d 7022 3a20 6368 726f timestamp": chro
00000d90: 6e6f 3a3a 5574 633a 3a6e 6f77 2829 2e74 no::Utc::now().t
00000da0: 6f5f 7266 6333 3333 3928 292c 0d0a 2020 o_rfc3339(),..
00000db0: 2020 2020 2020 2276 6572 7369 6f6e 223a "version":
00000dc0: 2065 6e76 2128 2243 4152 474f 5f50 4b47 env!("CARGO_PKG
00000dd0: 5f56 4552 5349 4f4e 2229 2c0d 0a20 2020 _VERSION"),..
00000de0: 207d 2929 0d0a 7d0d 0a0d 0a61 7379 6e63 }))..}....async
00000df0: 2066 6e20 6275 696c 645f 726f 7574 6572 fn build_router
00000e00: 2873 7461 7465 3a20 4170 7053 7461 7465 (state: AppState
00000e10: 2920 2d3e 2061 7875 6d3a 3a52 6f75 7465 ) -> axum::Route
00000e20: 7220 7b0d 0a20 2020 2075 7365 2061 7875 r {.. use axu
00000e30: 6d3a 3a6d 6964 646c 6577 6172 653b 0d0a m::middleware;..
00000e40: 2020 2020 7573 6520 746f 7765 725f 6874 use tower_ht
00000e50: 7470 3a3a 636f 7273 3a3a 7b41 6e79 2c20 tp::cors::{Any,
00000e60: 436f 7273 4c61 7965 727d 3b0d 0a20 2020 CorsLayer};..
00000e70: 2075 7365 2074 6f77 6572 5f68 7474 703a use tower_http:
00000e80: 3a74 7261 6365 3a3a 5472 6163 654c 6179 :trace::TraceLay
00000e90: 6572 3b0d 0a0d 0a20 2020 2075 7365 2061 er;.... use a
00000ea0: 7875 6d3a 3a68 7474 703a 3a48 6561 6465 xum::http::Heade
00000eb0: 7256 616c 7565 3b0d 0a20 2020 206c 6574 rValue;.. let
00000ec0: 2063 6f72 7320 3d20 7b0d 0a20 2020 2020 cors = {..
00000ed0: 2020 206c 6574 2063 6f6e 6669 6720 3d20 let config =
00000ee0: 7374 6174 652e 636f 6e66 6967 2e72 6561 state.config.rea
00000ef0: 6428 292e 6177 6169 743b 0d0a 2020 2020 d().await;..
00000f00: 2020 2020 6c65 7420 6973 5f64 6576 203d let is_dev =
00000f10: 2073 7464 3a3a 656e 763a 3a76 6172 2822 std::env::var("
00000f20: 5a43 4c41 575f 5341 4153 5f44 4556 2229 ZCLAW_SAAS_DEV")
00000f30: 0d0a 2020 2020 2020 2020 2020 2020 2e6d .. .m
00000f40: 6170 287c 767c 2076 203d 3d20 2274 7275 ap(|v| v == "tru
00000f50: 6522 207c 7c20 7620 3d3d 2022 3122 290d e" || v == "1").
00000f60: 0a20 2020 2020 2020 2020 2020 202e 756e . .un
00000f70: 7772 6170 5f6f 7228 6661 6c73 6529 3b0d wrap_or(false);.
00000f80: 0a20 2020 2020 2020 2069 6620 636f 6e66 . if conf
00000f90: 6967 2e73 6572 7665 722e 636f 7273 5f6f ig.server.cors_o
00000fa0: 7269 6769 6e73 2e69 735f 656d 7074 7928 rigins.is_empty(
00000fb0: 2920 7b0d 0a20 2020 2020 2020 2020 2020 ) {..
00000fc0: 2069 6620 6973 5f64 6576 207b 0d0a 2020 if is_dev {..
00000fd0: 2020 2020 2020 2020 2020 2020 2020 436f Co
00000fe0: 7273 4c61 7965 723a 3a6e 6577 2829 0d0a rsLayer::new()..
00000ff0: 2020 2020 2020 2020 2020 2020 2020 2020
00001000: 2020 2020 2e61 6c6c 6f77 5f6f 7269 6769 .allow_origi
00001010: 6e28 416e 7929 0d0a 2020 2020 2020 2020 n(Any)..
00001020: 2020 2020 2020 2020 2020 2020 2e61 6c6c .all
00001030: 6f77 5f6d 6574 686f 6473 2841 6e79 290d ow_methods(Any).
00001040: 0a20 2020 2020 2020 2020 2020 2020 2020 .
00001050: 2020 2020 202e 616c 6c6f 775f 6865 6164 .allow_head
00001060: 6572 7328 416e 7929 0d0a 2020 2020 2020 ers(Any)..
00001070: 2020 2020 2020 7d20 656c 7365 207b 0d0a } else {..
00001080: 2020 2020 2020 2020 2020 2020 2020 2020
00001090: 7472 6163 696e 673a 3a65 7272 6f72 2128 tracing::error!(
000010a0: 22e7 949f e4ba a7e7 8eaf e5a2 83e5 bf85 "...............
000010b0: e9a1 bbe9 858d e7bd ae20 7365 7276 6572 ......... server
000010c0: 2e63 6f72 735f 6f72 6967 696e 73ef bc8c .cors_origins...
000010d0: e4b8 8de8 83bd e4bd bfe7 94a8 2061 6c6c ............ all
000010e0: 6f77 5f6f 7269 6769 6e28 416e 7929 2229 ow_origin(Any)")
000010f0: 3b0d 0a20 2020 2020 2020 2020 2020 2020 ;..
00001100: 2020 2070 616e 6963 2128 22e7 949f e4ba panic!(".....
00001110: a7e7 8eaf e5a2 83e5 bf85 e9a1 bbe9 858d ................
00001120: e7bd ae20 7365 7276 6572 2e63 6f72 735f ... server.cors_
00001130: 6f72 6967 696e 7320 e799 bde5 908d e58d origins ........
00001140: 95e3 8082 e5bc 80e5 8f91 e78e afe5 a283 ................
00001150: e58f afe8 aebe e7bd ae20 5a43 4c41 575f ......... ZCLAW_
00001160: 5341 4153 5f44 4556 3d74 7275 6520 e7bb SAAS_DEV=true ..
00001170: 95e8 bf87 e380 8222 293b 0d0a 2020 2020 .......");..
00001180: 2020 2020 2020 2020 7d0d 0a20 2020 2020 }..
00001190: 2020 207d 2065 6c73 6520 7b0d 0a20 2020 } else {..
000011a0: 2020 2020 2020 2020 206c 6574 206f 7269 let ori
000011b0: 6769 6e73 3a20 5665 633c 4865 6164 6572 gins: Vec<Header
000011c0: 5661 6c75 653e 203d 2063 6f6e 6669 672e Value> = config.
000011d0: 7365 7276 6572 2e63 6f72 735f 6f72 6967 server.cors_orig
000011e0: 696e 732e 6974 6572 2829 0d0a 2020 2020 ins.iter()..
000011f0: 2020 2020 2020 2020 2020 2020 2e66 696c .fil
00001200: 7465 725f 6d61 7028 7c6f 3a20 2653 7472 ter_map(|o: &Str
00001210: 696e 677c 206f 2e70 6172 7365 3a3a 3c48 ing| o.parse::<H
00001220: 6561 6465 7256 616c 7565 3e28 292e 6f6b eaderValue>().ok
00001230: 2829 290d 0a20 2020 2020 2020 2020 2020 ())..
00001240: 2020 2020 202e 636f 6c6c 6563 7428 293b .collect();
00001250: 0d0a 2020 2020 2020 2020 2020 2020 436f .. Co
00001260: 7273 4c61 7965 723a 3a6e 6577 2829 0d0a rsLayer::new()..
00001270: 2020 2020 2020 2020 2020 2020 2020 2020
00001280: 2e61 6c6c 6f77 5f6f 7269 6769 6e28 6f72 .allow_origin(or
00001290: 6967 696e 7329 0d0a 2020 2020 2020 2020 igins)..
000012a0: 2020 2020 2020 2020 2e61 6c6c 6f77 5f6d .allow_m
000012b0: 6574 686f 6473 285b 0d0a 2020 2020 2020 ethods([..
000012c0: 2020 2020 2020 2020 2020 2020 2020 6178 ax
000012d0: 756d 3a3a 6874 7470 3a3a 4d65 7468 6f64 um::http::Method
000012e0: 3a3a 4745 542c 0d0a 2020 2020 2020 2020 ::GET,..
000012f0: 2020 2020 2020 2020 2020 2020 6178 756d axum
00001300: 3a3a 6874 7470 3a3a 4d65 7468 6f64 3a3a ::http::Method::
00001310: 504f 5354 2c0d 0a20 2020 2020 2020 2020 POST,..
00001320: 2020 2020 2020 2020 2020 2061 7875 6d3a axum:
00001330: 3a68 7474 703a 3a4d 6574 686f 643a 3a50 :http::Method::P
00001340: 5554 2c0d 0a20 2020 2020 2020 2020 2020 UT,..
00001350: 2020 2020 2020 2020 2061 7875 6d3a 3a68 axum::h
00001360: 7474 703a 3a4d 6574 686f 643a 3a50 4154 ttp::Method::PAT
00001370: 4348 2c0d 0a20 2020 2020 2020 2020 2020 CH,..
00001380: 2020 2020 2020 2020 2061 7875 6d3a 3a68 axum::h
00001390: 7474 703a 3a4d 6574 686f 643a 3a44 454c ttp::Method::DEL
000013a0: 4554 452c 0d0a 2020 2020 2020 2020 2020 ETE,..
000013b0: 2020 2020 2020 2020 2020 6178 756d 3a3a axum::
000013c0: 6874 7470 3a3a 4d65 7468 6f64 3a3a 4f50 http::Method::OP
000013d0: 5449 4f4e 532c 0d0a 2020 2020 2020 2020 TIONS,..
000013e0: 2020 2020 2020 2020 5d29 0d0a 2020 2020 ])..
000013f0: 2020 2020 2020 2020 2020 2020 2e61 6c6c .all
00001400: 6f77 5f68 6561 6465 7273 285b 0d0a 2020 ow_headers([..
00001410: 2020 2020 2020 2020 2020 2020 2020 2020
00001420: 2020 6178 756d 3a3a 6874 7470 3a3a 6865 axum::http::he
00001430: 6164 6572 3a3a 4155 5448 4f52 495a 4154 ader::AUTHORIZAT
00001440: 494f 4e2c 0d0a 2020 2020 2020 2020 2020 ION,..
00001450: 2020 2020 2020 2020 2020 6178 756d 3a3a axum::
00001460: 6874 7470 3a3a 6865 6164 6572 3a3a 434f http::header::CO
00001470: 4e54 454e 545f 5459 5045 2c0d 0a20 2020 NTENT_TYPE,..
00001480: 2020 2020 2020 2020 2020 2020 2020 2020
00001490: 2061 7875 6d3a 3a68 7474 703a 3a48 6561 axum::http::Hea
000014a0: 6465 724e 616d 653a 3a66 726f 6d5f 7374 derName::from_st
000014b0: 6174 6963 2822 782d 7265 7175 6573 742d atic("x-request-
000014c0: 6964 2229 2c0d 0a20 2020 2020 2020 2020 id"),..
000014d0: 2020 2020 2020 205d 290d 0a20 2020 2020 ])..
000014e0: 2020 207d 0d0a 2020 2020 7d3b 0d0a 0d0a }.. };....
000014f0: 2020 2020 6c65 7420 7075 626c 6963 5f72 let public_r
00001500: 6f75 7465 7320 3d20 7a63 6c61 775f 7361 outes = zclaw_sa
00001510: 6173 3a3a 6175 7468 3a3a 726f 7574 6573 as::auth::routes
00001520: 2829 0d0a 2020 2020 2020 2020 2e72 6f75 ().. .rou
00001530: 7465 2822 2f61 7069 2f68 6561 6c74 6822 te("/api/health"
00001540: 2c20 6178 756d 3a3a 726f 7574 696e 673a , axum::routing:
00001550: 3a67 6574 2868 6561 6c74 685f 6861 6e64 :get(health_hand
00001560: 6c65 7229 290d 0a20 2020 2020 2020 202e ler)).. .
00001570: 6c61 7965 7228 6d69 6464 6c65 7761 7265 layer(middleware
00001580: 3a3a 6672 6f6d 5f66 6e5f 7769 7468 5f73 ::from_fn_with_s
00001590: 7461 7465 280d 0a20 2020 2020 2020 2020 tate(..
000015a0: 2020 2073 7461 7465 2e63 6c6f 6e65 2829 state.clone()
000015b0: 2c0d 0a20 2020 2020 2020 2020 2020 207a ,.. z
000015c0: 636c 6177 5f73 6161 733a 3a6d 6964 646c claw_saas::middl
000015d0: 6577 6172 653a 3a70 7562 6c69 635f 7261 eware::public_ra
000015e0: 7465 5f6c 696d 6974 5f6d 6964 646c 6577 te_limit_middlew
000015f0: 6172 652c 0d0a 2020 2020 2020 2020 2929 are,.. ))
00001600: 3b0d 0a0d 0a20 2020 206c 6574 2070 726f ;.... let pro
00001610: 7465 6374 6564 5f72 6f75 7465 7320 3d20 tected_routes =
00001620: 7a63 6c61 775f 7361 6173 3a3a 6175 7468 zclaw_saas::auth
00001630: 3a3a 7072 6f74 6563 7465 645f 726f 7574 ::protected_rout
00001640: 6573 2829 0d0a 2020 2020 2020 2020 2e6d es().. .m
00001650: 6572 6765 287a 636c 6177 5f73 6161 733a erge(zclaw_saas:
00001660: 3a61 6363 6f75 6e74 3a3a 726f 7574 6573 :account::routes
00001670: 2829 290d 0a20 2020 2020 2020 202e 6d65 ()).. .me
00001680: 7267 6528 7a63 6c61 775f 7361 6173 3a3a rge(zclaw_saas::
00001690: 6d6f 6465 6c5f 636f 6e66 6967 3a3a 726f model_config::ro
000016a0: 7574 6573 2829 290d 0a20 2020 2020 2020 utes())..
000016b0: 202e 6d65 7267 6528 7a63 6c61 775f 7361 .merge(zclaw_sa
000016c0: 6173 3a3a 7265 6c61 793a 3a72 6f75 7465 as::relay::route
000016d0: 7328 2929 0d0a 2020 2020 2020 2020 2e6d s()).. .m
000016e0: 6572 6765 287a 636c 6177 5f73 6161 733a erge(zclaw_saas:
000016f0: 3a6d 6967 7261 7469 6f6e 3a3a 726f 7574 :migration::rout
00001700: 6573 2829 290d 0a20 2020 2020 2020 202e es()).. .
00001710: 6d65 7267 6528 7a63 6c61 775f 7361 6173 merge(zclaw_saas
00001720: 3a3a 726f 6c65 3a3a 726f 7574 6573 2829 ::role::routes()
00001730: 290d 0a20 2020 2020 2020 202e 6d65 7267 ).. .merg
00001740: 6528 7a63 6c61 775f 7361 6173 3a3a 7072 e(zclaw_saas::pr
00001750: 6f6d 7074 3a3a 726f 7574 6573 2829 290d ompt::routes()).
00001760: 0a20 2020 2020 2020 202e 6d65 7267 6528 . .merge(
00001770: 7a63 6c61 775f 7361 6173 3a3a 6167 656e zclaw_saas::agen
00001780: 745f 7465 6d70 6c61 7465 3a3a 726f 7574 t_template::rout
00001790: 6573 2829 290d 0a20 2020 2020 2020 202e es()).. .
000017a0: 6d65 7267 6528 7a63 6c61 775f 7361 6173 merge(zclaw_saas
000017b0: 3a3a 7465 6c65 6d65 7472 793a 3a72 6f75 ::telemetry::rou
000017c0: 7465 7328 2929 0d0a 2020 2020 2020 2020 tes())..
000017d0: 2e6c 6179 6572 286d 6964 646c 6577 6172 .layer(middlewar
000017e0: 653a 3a66 726f 6d5f 666e 5f77 6974 685f e::from_fn_with_
000017f0: 7374 6174 6528 0d0a 2020 2020 2020 2020 state(..
00001800: 2020 2020 7374 6174 652e 636c 6f6e 6528 state.clone(
00001810: 292c 0d0a 2020 2020 2020 2020 2020 2020 ),..
00001820: 7a63 6c61 775f 7361 6173 3a3a 6d69 6464 zclaw_saas::midd
00001830: 6c65 7761 7265 3a3a 6170 695f 7665 7273 leware::api_vers
00001840: 696f 6e5f 6d69 6464 6c65 7761 7265 2c0d ion_middleware,.
00001850: 0a20 2020 2020 2020 2029 290d 0a20 2020 . ))..
00001860: 2020 2020 202e 6c61 7965 7228 6d69 6464 .layer(midd
00001870: 6c65 7761 7265 3a3a 6672 6f6d 5f66 6e5f leware::from_fn_
00001880: 7769 7468 5f73 7461 7465 280d 0a20 2020 with_state(..
00001890: 2020 2020 2020 2020 2073 7461 7465 2e63 state.c
000018a0: 6c6f 6e65 2829 2c0d 0a20 2020 2020 2020 lone(),..
000018b0: 2020 2020 207a 636c 6177 5f73 6161 733a zclaw_saas:
000018c0: 3a6d 6964 646c 6577 6172 653a 3a72 6571 :middleware::req
000018d0: 7565 7374 5f69 645f 6d69 6464 6c65 7761 uest_id_middlewa
000018e0: 7265 2c0d 0a20 2020 2020 2020 2029 290d re,.. )).
000018f0: 0a20 2020 2020 2020 202e 6c61 7965 7228 . .layer(
00001900: 6d69 6464 6c65 7761 7265 3a3a 6672 6f6d middleware::from
00001910: 5f66 6e5f 7769 7468 5f73 7461 7465 280d _fn_with_state(.
00001920: 0a20 2020 2020 2020 2020 2020 2073 7461 . sta
00001930: 7465 2e63 6c6f 6e65 2829 2c0d 0a20 2020 te.clone(),..
00001940: 2020 2020 2020 2020 207a 636c 6177 5f73 zclaw_s
00001950: 6161 733a 3a6d 6964 646c 6577 6172 653a aas::middleware:
00001960: 3a72 6174 655f 6c69 6d69 745f 6d69 6464 :rate_limit_midd
00001970: 6c65 7761 7265 2c0d 0a20 2020 2020 2020 leware,..
00001980: 2029 290d 0a20 2020 2020 2020 202e 6c61 )).. .la
00001990: 7965 7228 6d69 6464 6c65 7761 7265 3a3a yer(middleware::
000019a0: 6672 6f6d 5f66 6e5f 7769 7468 5f73 7461 from_fn_with_sta
000019b0: 7465 280d 0a20 2020 2020 2020 2020 2020 te(..
000019c0: 2073 7461 7465 2e63 6c6f 6e65 2829 2c0d state.clone(),.
000019d0: 0a20 2020 2020 2020 2020 2020 207a 636c . zcl
000019e0: 6177 5f73 6161 733a 3a61 7574 683a 3a61 aw_saas::auth::a
000019f0: 7574 685f 6d69 6464 6c65 7761 7265 2c0d uth_middleware,.
00001a00: 0a20 2020 2020 2020 2029 293b 0d0a 0d0a . ));....
00001a10: 2020 2020 2f2f 20e9 9d9e e6b5 81e5 bc8f // .........
00001a20: e8b7 afe7 94b1 e5ba 94e7 94a8 e585 a8e5 ................
00001a30: b180 2031 3573 20e8 b685 e697 b6ef bc88 .. 15s .........
00001a40: 7265 6c61 7920 5353 4520 e7ab afe7 82b9 relay SSE ......
00001a50: e99c 80e8 a681 e69b b4e9 95bf e8b6 85e6 ................
00001a60: 97b6 efbc 890d 0a20 2020 206c 6574 206e ....... let n
00001a70: 6f6e 5f73 7472 6561 6d69 6e67 5f72 6f75 on_streaming_rou
00001a80: 7465 7320 3d20 6178 756d 3a3a 526f 7574 tes = axum::Rout
00001a90: 6572 3a3a 6e65 7728 290d 0a20 2020 2020 er::new()..
00001aa0: 2020 202e 6d65 7267 6528 7075 626c 6963 .merge(public
00001ab0: 5f72 6f75 7465 7329 0d0a 2020 2020 2020 _routes)..
00001ac0: 2020 2e6d 6572 6765 2870 726f 7465 6374 .merge(protect
00001ad0: 6564 5f72 6f75 7465 7329 0d0a 2020 2020 ed_routes)..
00001ae0: 2020 2020 2e6c 6179 6572 2854 696d 656f .layer(Timeo
00001af0: 7574 4c61 7965 723a 3a6e 6577 2873 7464 utLayer::new(std
00001b00: 3a3a 7469 6d65 3a3a 4475 7261 7469 6f6e ::time::Duration
00001b10: 3a3a 6672 6f6d 5f73 6563 7328 3135 2929 ::from_secs(15))
00001b20: 293b 0d0a 0d0a 2020 2020 6178 756d 3a3a );.... axum::
00001b30: 526f 7574 6572 3a3a 6e65 7728 290d 0a20 Router::new()..
00001b40: 2020 2020 2020 202e 6d65 7267 6528 6e6f .merge(no
00001b50: 6e5f 7374 7265 616d 696e 675f 726f 7574 n_streaming_rout
00001b60: 6573 290d 0a20 2020 2020 2020 202e 6d65 es).. .me
00001b70: 7267 6528 7a63 6c61 775f 7361 6173 3a3a rge(zclaw_saas::
00001b80: 7265 6c61 793a 3a72 6f75 7465 7328 2929 relay::routes())
00001b90: 0d0a 2020 2020 2020 2020 2e6c 6179 6572 .. .layer
00001ba0: 2854 7261 6365 4c61 7965 723a 3a6e 6577 (TraceLayer::new
00001bb0: 5f66 6f72 5f68 7474 7028 2929 0d0a 2020 _for_http())..
00001bc0: 2020 2020 2020 2e6c 6179 6572 2863 6f72 .layer(cor
00001bd0: 7329 0d0a 2020 2020 2020 2020 2e77 6974 s).. .wit
00001be0: 685f 7374 6174 6528 7374 6174 6529 0d0a h_state(state)..
00001bf0: 7d0d 0a0d 0a2f 2f2f 20e7 9b91 e590 ac20 }..../// ......
00001c00: 4374 726c 2b43 20e4 bfa1 e58f b7ef bc8c Ctrl+C .........
00001c10: e8a7 a6e5 8f91 2067 7261 6365 6675 6c20 ...... graceful
00001c20: 7368 7574 646f 776e 0d0a 6173 796e 6320 shutdown..async
00001c30: 666e 2073 6875 7464 6f77 6e5f 7369 676e fn shutdown_sign
00001c40: 616c 2829 207b 0d0a 2020 2020 746f 6b69 al() {.. toki
00001c50: 6f3a 3a73 6967 6e61 6c3a 3a63 7472 6c5f o::signal::ctrl_
00001c60: 6328 290d 0a20 2020 2020 2020 202e 6177 c().. .aw
00001c70: 6169 740d 0a20 2020 2020 2020 202e 6578 ait.. .ex
00001c80: 7065 6374 2822 4661 696c 6564 2074 6f20 pect("Failed to
00001c90: 696e 7374 616c 6c20 4374 726c 2b43 2068 install Ctrl+C h
00001ca0: 616e 646c 6572 2229 3b0d 0a20 2020 2069 andler");.. i
00001cb0: 6e66 6f21 2822 5265 6365 6976 6564 2073 nfo!("Received s
00001cc0: 6875 7464 6f77 6e20 7369 676e 616c 2c20 hutdown signal,
00001cd0: 6472 6169 6e69 6e67 2063 6f6e 6e65 6374 draining connect
00001ce0: 696f 6e73 2e2e 2e22 293b 0d0a 7d0d 0a ions...");..}..

164
BREAKS.md
View File

@@ -1,164 +0,0 @@
# 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 限流
- **修复**: 测试共享 token6 个测试只 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
```

464
CLAUDE.md
View File

@@ -1,78 +1,52 @@
@wiki/index.md
# ZCLAW 协作与实现规则
> **ZCLAW 是一个独立成熟的 AI Agent 桌面客户端**,专注于提供真实可用的 AI 能力,而不是演示 UI。
> **当前阶段: 发布前管家模式实施。** 稳定化基线已达成管家模式6交付物已完成。
## 1. 项目定位
### 1.1 ZCLAW 是什么
ZCLAW 是面向中文用户的 AI Agent 桌面端,核心能力包括:
- **智能对话** - 多模型支持8 Provider、流式响应、上下文管理
- **自主能力** - 9启用的 Hands另有 Predictor/Lead 已禁用
- **技能系统** - 75 个 SKILL.md 技能定义
- **工作流编排** - Pipeline DSL + 10 行业模板
- **智能对话** - 多模型支持、流式响应、上下文管理
- **自主能力** - 8 个 Hands浏览器、数据采集、研究、预测等
- **技能系统** - 可扩展的 SKILL.md 技能定义
- **工作流编排** - 多步骤自动化任务
- **安全审计** - 完整的操作日志和权限控制
### 1.2 决策原则
**任何改动都要问:这对 ZCLAW 用户今天能产生价值吗?**
**任何改动都要问:这对 ZCLAW 有用吗?对 ZCLAW 有影响吗?**
-修复已知的 P0/P1 缺陷 → 最高优先
-接通"写了没接"的断链 → 高优先
- ✅ 清理死代码和孤立文件 → 应该做
-新增功能/页面/端点 → 稳定化完成前禁止
- ❌ 增加复杂度但无实际价值 → 永远不做
- ❌ 折中方案掩盖根因 → 永远不做
### 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 为准。
-对 ZCLAW 用户有价值的功能 → 优先实现
-提升 ZCLAW 稳定性和可用性 → 必须做
- ❌ 只为兼容其他系统的妥协 → 谨慎评估
-增加复杂度但无实际价值 → 不做
- ✅解决问题要寻找根因,从源头解决问题。不要为了消除问题而选择折中办法,从而导致系统架构、代码安全性、代码质量出现问题
***
## 2. 项目结构
```text
ZCLAW/
├── crates/ # Rust Workspace (10 crates)
├── crates/ # Rust Workspace (核心能力)
│ ├── zclaw-types/ # L1: 基础类型 (AgentId, Message, Error)
│ ├── zclaw-memory/ # L2: 存储层 (SQLite, KV, 会话管理)
│ ├── zclaw-runtime/ # L3: 运行时 (4 Driver, 7 工具, 12 层中间件)
│ ├── zclaw-kernel/ # L4: 核心协调 (182 Tauri 命令)
│ ├── zclaw-skills/ # 技能系统 (75 SKILL.md 解析, 语义路由)
│ ├── zclaw-hands/ # 自主能力 (9 启用, 106 Rust 测试)
│ ├── zclaw-protocols/ # 协议支持 (MCP 完整, A2A feature-gated)
── zclaw-pipeline/ # Pipeline DSL (v1/v2, 10 行业模板)
│ ├── zclaw-growth/ # 记忆增长 (FTS5 + TF-IDF)
│ └── zclaw-saas/ # SaaS 后端 (130 API, Axum + PostgreSQL)
├── admin-v2/ # 管理后台 (Vite + Ant Design Pro, 13 页)
│ ├── zclaw-runtime/ # L3: 运行时 (LLM驱动, 工具, Agent循环)
│ ├── zclaw-kernel/ # L4: 核心协调 (注册, 调度, 事件, 工作流)
│ ├── zclaw-skills/ # 技能系统 (SKILL.md解析, 执行器)
│ ├── zclaw-hands/ # 自主能力 (Hand/Trigger 注册管理)
│ ├── zclaw-channels/ # 通道适配器 (仅 ConsoleChannel 测试适配器)
── zclaw-protocols/ # 协议支持 (MCP, A2A)
├── desktop/ # Tauri 桌面应用
│ ├── src/
│ │ ├── components/ # React UI 组件 (含 SaaS 集成)
│ │ ├── store/ # Zustand 状态管理 (含 saasStore)
│ │ └── lib/ # 客户端通信 / 工具函数 (含 saas-client)
│ │ ├── components/ # React UI 组件
│ │ ├── store/ # Zustand 状态管理
│ │ └── lib/ # 客户端通信 / 工具函数
│ └── src-tauri/ # Tauri Rust 后端 (集成 Kernel)
├── skills/ # SKILL.md 技能定义
├── hands/ # HAND.toml 自主能力配置
├── config/ # TOML 配置文件
├── saas-config.toml # SaaS 后端配置 (PostgreSQL 连接等)
├── docker-compose.yml # PostgreSQL 容器配置
├── docs/ # 架构文档和知识库
└── tests/ # Vitest 回归测试
```
@@ -87,14 +61,12 @@ ZCLAW/
| 层级 | 技术 |
| ---- | --------------------- |
| 前端框架 | React 19 + TypeScript |
| 状态管理 | Zustand 5 |
| 前端框架 | React 18 + TypeScript |
| 状态管理 | Zustand |
| 桌面框架 | Tauri 2.x |
| 样式方案 | Tailwind 4 |
| 样式方案 | Tailwind CSS |
| 配置格式 | TOML |
| 后端核心 | Rust Workspace (10 crates, ~66K 行) |
| SaaS 后端 | Axum + PostgreSQL (zclaw-saas) |
| 管理后台 | Vite + Ant Design Pro (admin-v2/) |
| 后端核心 | Rust Workspace (8 crates) |
### 2.3 Crate 依赖关系
@@ -107,9 +79,7 @@ zclaw-runtime (→ types, memory)
zclaw-kernel (→ types, memory, runtime)
zclaw-saas (→ types, 独立运行于 8080 端口)
desktop/src-tauri (→ kernel, skills, hands, protocols)
desktop/src-tauri (→ kernel, skills, hands, channels, protocols)
```
***
@@ -132,60 +102,13 @@ desktop/src-tauri (→ kernel, skills, hands, protocols)
4. **配置问题** - TOML 解析、环境变量
5. **运行时问题** - 服务启动、端口占用
不在根因未明时盲目堆补丁。这一步在四阶段工作法的"阶段 2: 制定方案"中完成。
不在根因未明时盲目堆补丁。
### 3.3 四阶段工作法(强制,不可跳过任何阶段)
### 3.3 闭环工作法
任何操作 — 无论是修 bug、加功能、重构、还是回答技术问题 — 都必须按以下 4 个阶段执行。不允许跳过、不允许合并阶段。
每次改动形成完整闭环:
#### 阶段 1: 理解背景(先读 wiki
**接到任务后,第一件事是阅读 wiki 获取上下文,而不是直接动手。**
1. 读取 `wiki/index.md` — 理解全局架构,利用**症状导航表**快速定位相关模块
2. 读取对应模块页 — 每个模块页统一 5 节结构:设计决策 → 关键文件+集成契约 → 代码逻辑(不变量) → 活跃问题+陷阱 → 变更记录
3. 如涉及已知问题,检查模块页的"活跃问题"节(全局索引见 `wiki/known-issues.md`
**判断标准**: 你能用一句话说清楚"这个改动涉及哪个模块、走哪条数据链路、影响哪些组件"吗?如果不能,你还没读完。
#### 阶段 2: 制定方案(先想清楚再动手)
基于阶段 1 的理解,制定执行方案:
1. **定位根因** — 确认属于哪一类问题(协议/状态/UI/配置/运行时),不盲目堆补丁
2. **确定影响范围** — 哪些文件需要改?哪些 crate 受影响?有没有上下游依赖?
3. **列出执行步骤** — 按顺序列出要改的文件和验证点
4. **预判风险** — 这个改动可能破坏什么?需要跑哪些测试?
**判断标准**: 你能用 3 句话说清楚"改什么、为什么改、改完怎么验证"吗?如果不能,方案还不成熟。
#### 阶段 3: 执行 + 验证
1. **最小修复** — 只改必要的代码
2. **自动验证**`cargo check` / `cargo test` / `tsc --noEmit` / `vitest run` 必须通过
3. **回归测试** — 跑受影响 crate 的全量测试,确认无回归
#### 阶段 4: Wiki 同步 + 提交(立即,不积压)
**Wiki 同步评估(硬门槛,不可跳过)**
代码改完后、提交前,逐条回答以下问题。任何一条为"是"→ 必须更新对应 wiki 页面:
| 评估问题 | 为"是"时更新 |
|----------|-------------|
| 这个改动修复或引入了 bug | 对应模块页"活跃问题+陷阱"节 + `wiki/known-issues.md` |
| 这个改动改变了某个模块的行为或设计理由? | 对应模块页"设计决策"节 |
| 这个改动增删了文件或改变了目录结构? | 对应模块页"关键文件"表 |
| 这个改动影响了跨模块接口(谁调谁、参数形状、触发时机)? | 涉及双方的"集成契约"表 |
| 这个改动涉及一个必须始终成立的约束? | 对应模块页"代码逻辑"节的 ⚡ 不变量 |
| 这个改动改变了功能链路(前端→后端的完整路径)? | `wiki/feature-map.md` 索引表 |
| 这个改动改变了关键数字(命令数/Store数/测试数等)? | `wiki/index.md` 关键数字表 + `docs/TRUTH.md` |
全部回答完后,无论是否有更新,都追加一条到 `wiki/log.md` + 更新模块页"变更记录"节(保持 5 条)。
**提交推送** — 按 §11 规范提交,**立即 `git push`**。详细文档同步规则见 §8.3。
**铁律:不允许"等一下再提交"或"最后一起推送"。每个独立工作单元完成后立即推送。**
1. 定位问题 → 2. 建立心智模型 → 3. 最小修复 → 4. 自动验证 → 5. 记录沉淀
***
@@ -200,28 +123,18 @@ desktop/src-tauri (→ kernel, skills, hands, protocols)
**禁止**在组件内直接创建 WebSocket 或拼装 HTTP 请求。
### 4.2 分层职责
### 4.2 発能层客户端
```
````
UI 组件 → 只负责展示和交互
Store → 负责状态组织和流程编排
Client → 负责网络通信和协议转换
```
### 4.3 代码自检规则
**每次修改代码前必须检查:**
1. **是否已有相同能力的代码?** — 先搜索再写,避免重复
2. **前端是否有人调用?** — 没有 Rust 调用者的 Tauri 命令,先标注 `@reserved`
3. **错误是否静默吞掉?**`let _ =` 必须替换为 `log::warn!` 或更高级别处理
4. **文档数字是否需要更新?** — 改了数量就要改文档```
Client → 负责网络通信和```
---
### 4.4 代码规范
### 4.3 代码规范
**TypeScript:**
- 避免 `any`,优先 `unknown + 类型守卫`
@@ -268,22 +181,21 @@ Client → 负责网络通信和协议转换
## 6. 自主能力系统 (Hands)
ZCLAW 提供 12 个自主能力包7 已注册 + 3 开发中 + 2 禁用)
ZCLAW 提供 11 个自主能力包:
| Hand | 功能 | 状态 |
|------|------|------|
| Browser | 浏览器自动化 | ✅ 可用 |
| Collector | 数据收集聚合 | ✅ 可用 |
| Researcher | 深度研究 | ✅ 可用 |
| Clip | 视频处理 | ⚠️ 需 FFmpeg |
| Twitter | Twitter 自动化 | ✅ 可用12 个 API v2 真实调用,写操作需 OAuth 1.0a |
| Quiz | 测验生成 | ✅ 可用 |
| _reminder | 系统内部提醒 | ✅ 可用kernel 编程注册,无 HAND.toml |
| Whiteboard | 白板演示 | 🚧 开发中HAND.toml 未合并到主分支) |
| Slideshow | 幻灯片生成 | 🚧 开发中HAND.toml 未合并到主分支) |
| Speech | 语音合成 | 🚧 开发中HAND.toml 未合并到主分支) |
| Predictor | 预测分析 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
| Lead | 销售线索发现 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
| Clip | 视频处理 | ⚠️ 需 FFmpeg |
| Twitter | Twitter 自动化 | ⚠️ 需 API Key |
| Whiteboard | 白板演示 | ✅ 可用(导出功能开发中,标注 demo |
| Slideshow | 幻灯片生成 | ✅ 可用 |
| Speech | 语音合成 | ✅ 可用 |
| Quiz | 测验生成 | ✅ 可用 |
**触发 Hand 时:**
1. 检查依赖是否满足
@@ -304,57 +216,20 @@ ZCLAW 提供 12 个自主能力包7 已注册 + 3 开发中 + 2 禁用):
- 配置读写
- Hand 触发
### 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 验证命令
### 7.2 验证命令
```bash
# TypeScript 类型检查
pnpm tsc --noEmit
# 前端单元测试
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 vitest run
# 启动开发环境
pnpm start:dev
````
### 7.4 人工验证清单
### 7.3 人工验证清单
- [ ] 能否正常连接后端服务
- [ ] 能否发送消息并获得流式响应
@@ -385,57 +260,6 @@ docs/
- **面向未来** - 文档要帮助未来的开发者快速理解
- **中文优先** - 所有面向用户的文档使用中文
### 8.3 完成工作后的收尾流程(强制,不可跳过)
每次完成功能实现、架构变更、问题修复后,**必须立即执行以下收尾**
#### 步骤 AWiki 同步(最高优先,代码提交前)
> **为什么 wiki 排第一**wiki 是新 AI 会话的启动燃料。如果 wiki 与代码不一致,后续所有会话都会基于错误上下文工作,错误会积累放大。
在 §3.3 阶段 4 的评估表基础上,执行具体更新:
| 触发事件 | 更新目标 | 更新内容 |
|----------|---------|---------|
| 修复 bug | 对应模块页"活跃问题+陷阱" | 修复→移除条目;新增→添加条目 |
| 架构/设计变更 | 对应模块页"设计决策" | WHY 变了 + 新的权衡取舍 |
| 文件增删/移动 | 对应模块页"关键文件"表 | 更新文件列表 |
| 跨模块接口变化 | **涉及双方**的"集成契约"表 | 方向/接口/触发时机 |
| 发现新的不变量 | 对应模块页"代码逻辑"节 | ⚡ 标记 + 一句话描述 |
| 功能链路变化 | `wiki/feature-map.md` | 更新索引表对应行 |
| 关键数字变化 | `wiki/index.md` + `docs/TRUTH.md` | 更新数字 + 验证命令 |
| **每次收尾** | `wiki/log.md` + 模块页"变更记录" | 追加日志条目 + 变更记录保持 5 条 |
**wiki 更新原则**
- 只记录代码不能告诉你的东西WHY、跨模块关系、不变量、历史教训
- 模块页控制在 100-200 行,超出则归档到 `wiki/archive/`
- 同一信息只出现在一个页面(单一真相源),其他页面只引用
#### 步骤 B其他文档同步
1. **CLAUDE.md** — 项目结构、技术栈、工作流程、命令变化时
2. **CLAUDE.md §13 架构快照** — 涉及子系统变更时(可执行 `/sync-arch` 技能自动分析)
3. **docs/ARCHITECTURE_BRIEF.md** — 架构决策或关键组件变更时
4. **docs/features/** — 功能状态变化时
5. **docs/knowledge-base/** — 新的排查经验或配置说明
#### 步骤 C提交按逻辑分组
```
代码变更 → 一个或多个逻辑提交
文档变更 → 独立提交(如果和代码分开更清晰)
```
#### 步骤 D推送立即
```
git push
```
**不允许积压。** 每次完成一个独立工作单元后立即推送。不要留到"最后一起推"。
**判断标准:** 如果工作目录有未提交文件,说明收尾流程没完成。
***
## 9. 常见问题排查
@@ -512,210 +336,10 @@ refactor(store): 统一 Store 数据获取方式
***
## 12. 安全注意事项
- 不在代码中硬编码密钥
- 用户输入必须验证
- 敏感操作需要确认
- 保留操作审计日志
- 环境变量 `ZCLAW_SAAS_DEV` 模式放宽安全限制(开发环境设 `ZCLAW_SAAS_DEV=true`
### 认证安全
- **JWT password_version**: 密码修改后自动使所有已签发的 JWT 失效Claims 含 `pwv`,中间件比对 DB
- **账户锁定**: 5 次登录失败后锁定 15 分钟
- **邮箱验证**: RFC 5322 正则 + 254 字符长度限制
- **JWT 密钥**: `#[cfg(debug_assertions)]` 保护 fallbackrelease 模式 `bail` 拒绝启动
- **TOTP 加密密钥**: 生产环境强制独立 `ZCLAW_TOTP_ENCRYPTION_KEY`64 字符 hex不从 JWT 密钥派生
- **TOTP/API Key 加密**: AES-256-GCM + 随机 Nonce
- **密码存储**: Argon2id + OsRng 随机盐
- **Refresh Token 轮换**: 单次使用Logout 时撤销到 DBrotation 校验已撤销的旧 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-23 -->
## 13. 当前架构快照
### 活跃子系统
| 子系统 | 状态 | 最新变更 |
|--------|------|----------|
| 管家模式 (Butler) | ✅ 活跃 | 04-23 跨会话身份(soul.md) + 动态建议(4路并行LLM驱动) + Agent tab 移除 |
| Hermes 管线 | ✅ 活跃 | 04-23 experience_find_relevant Tauri 命令 + ExperienceBrief + OnceLock 单例 |
| Intelligence Heartbeat | ✅ 活跃 | 04-15 统一健康快照 (health_snapshot.rs) + HeartbeatManager 重构 + HealthPanel 前端 |
| 聊天流 (ChatStream) | ✅ 活跃 | 04-23 LLM 动态建议(替换硬编码) + 澄清卡片 UX 优化 |
| 记忆管道 (Memory) | ✅ 活跃 | 04-23 身份信号提取(agent_name/user_name) + ProfileSignals 增强 |
| SaaS 认证 (Auth) | ✅ 稳定 | Token池 RPM/TPM 轮换 + JWT password_version 失效机制 |
| Pipeline DSL | ✅ 稳定 | 04-01 18 个 YAML 模板 + DAG 执行器 |
| Hands 系统 | ✅ 稳定 | 7 注册 (6 HAND.toml + _reminder)Whiteboard/Slideshow/Speech 已删除 |
| 技能系统 (Skills) | ✅ 稳定 | 75 个 SKILL.md + 语义路由 |
| 中间件链 | ✅ 稳定 | 14 层 + 分波并行 (Evolution@78✅, ButlerRouter@80✅, Compaction@100, Memory@150✅, Title@180✅, SkillIndex@200✅, DanglingTool@300, ToolError@350, ToolOutputGuard@360, Guardrail@400, LoopGuard@500, SubagentLimit@550, TrajectoryRecorder@650, TokenCalibration@700) — ✅=parallel_safe |
### 关键架构模式
- **Hermes 管线**: 4模块闭环 — ExperienceStore(FTS5经验存取) + UserProfiler(结构化用户画像) + NlScheduleParser(中文时间→cron) + TrajectoryRecorder+Compressor(轨迹记录压缩)。通过中间件链+intelligence hooks调用
- **管家模式**: 双模式UI (默认简洁/解锁专业) + ButlerRouter 动态行业关键词(4内置+自定义) + <butler-context> XML fencing注入 + 跨会话连续性(痛点回访+经验检索) + 触发信号持久化(VikingStorage) + 冷启动4阶段hook + 跨会话身份(soul.md) + 动态建议(4路并行LLM驱动2续问+1关怀)
- **聊天流**: 3种实现 → GatewayClient(WebSocket) / KernelClient(Tauri Event) / SaaSRelay(SSE) + 5min超时守护。动态建议: prefetch context + generateLLMSuggestions(1追问+1行动+1关怀) 与 memory extraction 解耦。详见 [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权重→检索→注入系统提示 + 身份信号提取(agent_name/user_name)→VikingStorage→soul.md→跨会话名字记忆
- **LLM 驱动**: 4 Rust Driver (Anthropic/OpenAI/Gemini/Local) + 国内兼容 (DeepSeek/Qwen/Moonshot 通过 base_url)
### 最近变更
1. [04-23] 回复效率+建议生成并行化: identity prompt 缓存 + pre-hook 并行(tokio::join!) + middleware 分波并行(parallel_safe, 5层✅) + suggestion context 预取 + 建议与 memory 解耦 + prompt 重写(1追问+1行动+1关怀)
2. [04-23] 动态建议智能化: fetchSuggestionContext 4路并行(用户画像/痛点/经验/技能匹配) + generateLLMSuggestions 混合型 prompt (2续问+1管家关怀) + experience_find_relevant Tauri 命令 + ExperienceBrief
3. [04-23] 跨会话身份: detectAgentNameSuggestion trigger+extract 两步法(10 trigger) + ProfileSignals agent_name/user_name + soul.md 写回 + Agent tab 移除 (~280 行 dead code 清理)
4. [04-22] Wiki 全面重构: 5节模板+集成契约+症状导航+归档压缩,净减 ~1,200 行
4. [04-22] 跨会话记忆断裂修复 + DataMasking 中间件移除 + 搜索功能修复(多引擎+质量过滤+SSE行缓冲)
5. [04-21] Embedding 接通 + 自学习自动化 A线+B线 + Phase 0+1 突破之路 8 项链路修复。验证: 934 tests PASS
6. [04-20] 50 轮功能链路审计 7 项断链修复 (42/50 = 84% 通过率)
7. [04-17] 全系统 E2E 测试 129 链路: 82 PASS / 20 PARTIAL / 1 FAIL / 26 SKIP有效通过率 79.1%
<!-- ARCH-SNAPSHOT-END -->
<!-- ARCH-SNAPSHOT-END -->
<!-- ANTI-PATTERN-START -->
<!-- 此区域由 auto-sync 自动更新,请勿手动编辑。更新时间: 2026-04-23 -->
## 14. AI 协作注意事项
### 反模式警告
- ❌ **不要**建议新增 SaaS API 端点 — 已有 137 个,稳定化约束禁止新增
- ❌ **不要**忽略管家模式 — 已上线且为默认模式,所有聊天经过 ButlerRouter
- ❌ **不要**假设 Tauri 直连 LLM — 实际通过 SaaS Token 池中转SaaS unreachable 时降级到本地 Kernel
- ❌ **不要**建议从零实现已有能力 — 先查 Hand(7注册)/Skill(75个)/Pipeline(18模板) 现有库
- ❌ **不要**在 CLAUDE.md 以外创建项目级配置或规则文件 — 单一入口原则
### 场景化指令
- 当遇到**聊天相关** → 记住有 3 种 ChatStream 实现,先用 `getClient()` 判断当前路由模式
- 当遇到**认证相关** → 记住 Tauri 模式用 OS keyring 存 JWTSaaS 模式用 HttpOnly cookie
- 当遇到**新功能建议** → 先查 [TRUTH.md](docs/TRUTH.md) 确认可用能力清单,避免重复建设
- 当遇到**记忆/上下文相关** → 记住闭环已接通: FTS5+TF-IDF+embedding不是空壳
- 当遇到**管家/Butler** → 管家模式是默认模式ButlerRouter 在中间件链中做关键词分类+system prompt 增强。跨会话身份走 soul.md动态建议走 4 路并行上下文+LLM
<!-- ANTI-PATTERN-END -->
***
## 15. Karpathy 编码原则
> 源自 Andrej Karpathy 对 LLM 编码问题的观察。偏向谨慎而非速度,简单任务可灵活判断。
### 15.1 Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
- State assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them — don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
### 15.2 Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
### 15.3 Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it — don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
### 15.4 Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
---
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.

2815
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ members = [
# ZCLAW Extension Crates
"crates/zclaw-skills",
"crates/zclaw-hands",
"crates/zclaw-channels",
"crates/zclaw-protocols",
"crates/zclaw-pipeline",
"crates/zclaw-growth",
@@ -19,7 +20,7 @@ members = [
]
[workspace.package]
version = "0.9.0-beta.1"
version = "0.1.0"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/zclaw/zclaw"
@@ -29,7 +30,6 @@ rust-version = "1.75"
# Async runtime
tokio = { version = "1", features = ["full"] }
tokio-stream = "0.1"
tokio-util = "0.7"
futures = "0.3"
async-stream = "0.3"
@@ -57,15 +57,12 @@ chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "v5", "serde"] }
# Database
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "postgres", "chrono"] }
libsqlite3-sys = { version = "0.30", features = ["bundled"] }
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "chrono"] }
libsqlite3-sys = { version = "0.27", features = ["bundled"] }
# HTTP client (for LLM drivers)
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 = "2"
@@ -87,7 +84,6 @@ rand = "0.8"
# Crypto
sha2 = "0.10"
aes-gcm = "0.10"
rsa = { version = "0.9", features = ["pem"] }
# Home directory
dirs = "6"
@@ -98,32 +94,19 @@ regex = "1"
# Shell parsing
shlex = "1"
# WASM runtime
wasmtime = { version = "43", default-features = false, features = ["cranelift"] }
wasmtime-wasi = { version = "43" }
# Testing
tempfile = "3"
# SaaS dependencies
axum = { version = "0.7", features = ["macros", "multipart"] }
axum-extra = { version = "0.9", features = ["typed-header", "cookie"] }
axum = { version = "0.7", features = ["macros"] }
axum-extra = { version = "0.9", features = ["typed-header"] }
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"] }
jsonwebtoken = "9"
argon2 = "0.5"
totp-rs = "5"
hex = "0.4"
# Document processing
pdf-extract = "0.7"
calamine = "0.26"
quick-xml = "0.37"
zip = "2"
# TCP socket configuration
socket2 = { version = "0.5", features = ["all"] }
# Internal crates
zclaw-types = { path = "crates/zclaw-types" }
zclaw-memory = { path = "crates/zclaw-memory" }
@@ -131,6 +114,7 @@ zclaw-runtime = { path = "crates/zclaw-runtime" }
zclaw-kernel = { path = "crates/zclaw-kernel" }
zclaw-skills = { path = "crates/zclaw-skills" }
zclaw-hands = { path = "crates/zclaw-hands" }
zclaw-channels = { path = "crates/zclaw-channels" }
zclaw-protocols = { path = "crates/zclaw-protocols" }
zclaw-pipeline = { path = "crates/zclaw-pipeline" }
zclaw-growth = { path = "crates/zclaw-growth" }

View File

@@ -1,106 +1,83 @@
# ============================================================
# ZCLAW SaaS Backend - Multi-stage Docker Build
# ============================================================
# Build: docker build -t zclaw-saas .
# Run: docker run --env-file saas-env.example zclaw-saas
# ============================================================
#
# .dockerignore recommended contents:
# target/
# node_modules/
# desktop/
# admin/
# admin-v2/
# docs/
# .git/
# .claude/
# *.md
# *.pen
# plans/
# dist/
# pencil-new.pen
# ============================================================
# ---- Stage 1: Build ----
FROM rust:1.85-bookworm AS builder
# ---- Stage 1: Builder ----
FROM rust:1.75-bookworm AS builder
WORKDIR /usr/src/zclaw
# Cache dependency builds by copying manifests first
COPY Cargo.toml Cargo.lock ./
# Create dummy lib.rs files so cargo can resolve the workspace
RUN mkdir -p crates/zclaw-types/src && echo "" > crates/zclaw-types/src/lib.rs
RUN mkdir -p crates/zclaw-memory/src && echo "" > crates/zclaw-memory/src/lib.rs
RUN mkdir -p crates/zclaw-runtime/src && echo "" > crates/zclaw-runtime/src/lib.rs
RUN mkdir -p crates/zclaw-kernel/src && echo "" > crates/zclaw-kernel/src/lib.rs
RUN mkdir -p crates/zclaw-skills/src && echo "" > crates/zclaw-skills/src/lib.rs
RUN mkdir -p crates/zclaw-hands/src && echo "" > crates/zclaw-hands/src/lib.rs
RUN mkdir -p crates/zclaw-protocols/src && echo "" > crates/zclaw-protocols/src/lib.rs
RUN mkdir -p crates/zclaw-pipeline/src && echo "" > crates/zclaw-pipeline/src/lib.rs
RUN mkdir -p crates/zclaw-growth/src && echo "" > crates/zclaw-growth/src/lib.rs
RUN mkdir -p crates/zclaw-saas/src && echo "fn main() {}" > crates/zclaw-saas/src/main.rs
RUN mkdir -p desktop/src-tauri/src && echo "" > desktop/src-tauri/src/lib.rs
# Copy all crate Cargo.toml files for dependency resolution
COPY crates/zclaw-types/Cargo.toml crates/zclaw-types/Cargo.toml
COPY crates/zclaw-memory/Cargo.toml crates/zclaw-memory/Cargo.toml
COPY crates/zclaw-runtime/Cargo.toml crates/zclaw-runtime/Cargo.toml
COPY crates/zclaw-kernel/Cargo.toml crates/zclaw-kernel/Cargo.toml
COPY crates/zclaw-skills/Cargo.toml crates/zclaw-skills/Cargo.toml
COPY crates/zclaw-hands/Cargo.toml crates/zclaw-hands/Cargo.toml
COPY crates/zclaw-protocols/Cargo.toml crates/zclaw-protocols/Cargo.toml
COPY crates/zclaw-pipeline/Cargo.toml crates/zclaw-pipeline/Cargo.toml
COPY crates/zclaw-growth/Cargo.toml crates/zclaw-growth/Cargo.toml
COPY crates/zclaw-saas/Cargo.toml crates/zclaw-saas/Cargo.toml
COPY desktop/src-tauri/Cargo.toml desktop/src-tauri/Cargo.toml
# Build dependencies only (cached layer)
RUN cargo build --release -p zclaw-saas 2>/dev/null || true
# Now copy the actual source code
COPY crates/ crates/
COPY desktop/src-tauri/src/ desktop/src-tauri/src/
# Touch source files to invalidate cache after dependency layer
RUN find crates/zclaw-saas/src -type f -exec touch {} +
# Build the final binary
RUN cargo build --release -p zclaw-saas
# ---- Stage 2: Runtime ----
FROM debian:bookworm-slim
# Install runtime dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
curl \
# Install build dependencies for sqlx (postgres) and libsqlite3-sys (bundled)
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy workspace manifests first to leverage Docker layer caching
COPY Cargo.toml Cargo.lock ./
# Create stub source files so cargo can resolve and cache dependencies
# This avoids rebuilding dependencies when only application code changes
RUN mkdir -p crates/zclaw-saas/src \
&& echo 'fn main() {}' > crates/zclaw-saas/src/main.rs \
&& for crate in zclaw-types zclaw-memory zclaw-runtime zclaw-kernel \
zclaw-skills zclaw-hands zclaw-channels zclaw-protocols \
zclaw-pipeline zclaw-growth; do \
mkdir -p crates/$crate/src && echo '' > crates/$crate/src/lib.rs; \
done \
&& mkdir -p desktop/src-tauri/src && echo 'fn main() {}' > desktop/src-tauri/src/main.rs
# Pre-build dependencies (release profile with caching)
RUN cargo build --release --package zclaw-saas 2>/dev/null || true
# Copy actual source code (invalidates stubs, triggers recompile of app code only)
COPY crates/ crates/
COPY desktop/ desktop/
# Touch source files to invalidate the stub timestamps
RUN touch crates/zclaw-saas/src/main.rs \
&& for crate in zclaw-types zclaw-memory zclaw-runtime zclaw-kernel \
zclaw-skills zclaw-hands zclaw-channels zclaw-protocols \
zclaw-pipeline zclaw-growth; do \
touch crates/$crate/src/lib.rs 2>/dev/null || true; \
done \
&& touch desktop/src-tauri/src/main.rs 2>/dev/null || true
# Build the actual binary
RUN cargo build --release --package zclaw-saas
# ---- Stage 2: Runtime ----
FROM debian:bookworm-slim AS runtime
# Install runtime dependencies (ca-certificates for HTTPS, libgcc for Rust runtime)
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
libgcc-s \
&& rm -rf /var/lib/apt/lists/* \
&& update-ca-certificates
# Create non-root user for security
RUN groupadd --gid 1000 zclaw && \
useradd --uid 1000 --gid zclaw --shell /bin/bash --create-home zclaw
RUN groupadd --gid 1000 zclaw \
&& useradd --uid 1000 --gid zclaw --shell /bin/false zclaw
WORKDIR /app
# Copy binary from builder
COPY --from=builder /usr/src/zclaw/target/release/zclaw-saas ./zclaw-saas
COPY --from=builder /app/target/release/zclaw-saas /app/zclaw-saas
# Copy default config (can be overridden by env vars)
COPY saas-config.toml ./saas-config.toml
# Copy configuration file
COPY saas-config.toml /app/saas-config.toml
# Set ownership
# Ensure the non-root user owns the application files
RUN chown -R zclaw:zclaw /app
# Switch to non-root user
USER zclaw
# Expose SaaS backend port
# Expose the SaaS API port
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
# Health check endpoint (matches the saas-config.toml port)
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["/app/zclaw-saas", "--healthcheck"] || exit 1
ENTRYPOINT ["./zclaw-saas"]
ENTRYPOINT ["/app/zclaw-saas"]

View File

@@ -1,7 +1,9 @@
# ZCLAW Makefile
# Cross-platform task runner
.PHONY: help start start-dev start-no-browser desktop desktop-build setup test clean
.PHONY: help start start-dev start-no-browser desktop desktop-build setup test clean \
saas-build saas-run saas-test saas-test-integration saas-clippy saas-migrate \
saas-docker-up saas-docker-down saas-docker-build
help: ## Show this help message
@echo "ZCLAW - AI Agent Desktop Client"
@@ -71,3 +73,32 @@ clean-deep: clean ## Deep clean (including pnpm cache)
@rm -rf desktop/pnpm-lock.yaml
@rm -rf pnpm-lock.yaml
@echo "Deep clean complete. Run 'pnpm install' to reinstall."
# === SaaS Backend ===
saas-build: ## Build zclaw-saas crate
@cargo build -p zclaw-saas
saas-run: ## Start SaaS backend (cargo run)
@cargo run -p zclaw-saas
saas-test: ## Run SaaS unit tests
@cargo test -p zclaw-saas -- --test-threads=1
saas-test-integration: ## Run SaaS integration tests (requires PostgreSQL)
@cargo test -p zclaw-saas -- --ignored --test-threads=1
saas-clippy: ## Run clippy on zclaw-saas
@cargo clippy -p zclaw-saas -- -D warnings
saas-migrate: ## Run database migrations
@cargo run -p zclaw-saas -- --migrate
saas-docker-up: ## Start SaaS services (PostgreSQL + backend)
@docker compose up -d
saas-docker-down: ## Stop SaaS services
@docker compose down
saas-docker-build: ## Build SaaS Docker images
@docker compose build

View File

@@ -1 +0,0 @@
VITE_API_BASE_URL=http://localhost:8080/api/v1

View File

@@ -1 +0,0 @@
VITE_API_BASE_URL=/api/v1

24
admin-v2/.gitignore vendored
View File

@@ -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?

View File

@@ -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...
},
},
])
```

View File

@@ -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,
},
},
])

View File

@@ -1,17 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ZCLAW Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<a href="#main-content" class="skip-to-content">跳转到主要内容</a>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,50 +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",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@ant-design/icons": "^6.1.1",
"@ant-design/pro-components": "^2.8.10",
"@ant-design/pro-layout": "^7.22.7",
"@tanstack/react-query": "^5.95.2",
"antd": "^6.3.4",
"axios": "^1.14.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.2",
"recharts": "^3.8.1",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@playwright/test": "^1.59.1",
"@tailwindcss/vite": "^4.2.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"jsdom": "^29.0.1",
"msw": "^2.12.14",
"tailwindcss": "^4.2.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1",
"vitest": "^4.1.2"
}
}

View File

@@ -1,50 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Admin V2 E2E 测试配置
*
* 断裂探测冒烟测试 — 验证 Admin V2 页面与 SaaS 后端的连通性
*
* 前提条件:
* - SaaS Server 运行在 http://localhost:8080
* - Admin V2 dev server 运行在 http://localhost:5173
* - 数据库有种子数据 (super_admin: testadmin/Admin123456)
*/
export default defineConfig({
testDir: './tests/e2e',
timeout: 60000,
expect: {
timeout: 10000,
},
fullyParallel: false,
retries: 0,
workers: 1,
reporter: [
['list'],
['html', { outputFolder: 'test-results/html-report' }],
],
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 10000,
navigationTimeout: 30000,
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1280, height: 720 },
},
},
],
webServer: {
command: 'pnpm dev --port 5173',
url: 'http://localhost:5173',
reuseExistingServer: true,
timeout: 30000,
},
outputDir: 'test-results/artifacts',
});

5232
admin-v2/pnpm-lock.yaml generated

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

View File

@@ -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

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -1,51 +0,0 @@
import { Button, Result } from 'antd'
import type { FallbackProps } from 'react-error-boundary'
interface ErrorStateProps {
title?: string
message?: string
onRetry?: () => void
}
export function ErrorState({
title = '加载失败',
message,
onRetry,
}: ErrorStateProps) {
return (
<div className="flex items-center justify-center min-h-[200px] p-8">
<Result
status="error"
title={title}
subTitle={message}
extra={
onRetry ? (
<Button type="primary" onClick={onRetry}>
</Button>
) : undefined
}
/>
</div>
)
}
export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<div className="flex items-center justify-center min-h-[200px] p-8">
<Result
status="error"
title="页面出现错误"
subTitle={error.message}
extra={
<div className="flex gap-2">
<Button onClick={resetErrorBoundary}></Button>
<Button type="primary" onClick={() => window.location.reload()}>
</Button>
</div>
}
/>
</div>
)
}

View File

@@ -1,25 +0,0 @@
import type { ReactNode } from 'react'
interface PageHeaderProps {
title: string
description?: string
actions?: ReactNode
}
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{title}
</h1>
{description && (
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
{description}
</p>
)}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
)
}

View File

@@ -1,45 +0,0 @@
// ============================================================
// 操作日志状态映射 — Dashboard 与 Logs 共用
// ============================================================
export const actionLabels: Record<string, string> = {
login: '登录',
logout: '登出',
create_account: '创建账号',
update_account: '更新账号',
delete_account: '删除账号',
create_provider: '创建服务商',
update_provider: '更新服务商',
delete_provider: '删除服务商',
create_model: '创建模型',
update_model: '更新模型',
delete_model: '删除模型',
create_token: '创建密钥',
revoke_token: '撤销密钥',
update_config: '更新配置',
create_prompt: '创建提示词',
update_prompt: '更新提示词',
archive_prompt: '归档提示词',
desktop_audit: '桌面端审计',
}
export const actionColors: Record<string, string> = {
login: 'green',
logout: 'default',
create_account: 'blue',
update_account: 'orange',
delete_account: 'red',
create_provider: 'blue',
update_provider: 'orange',
delete_provider: 'red',
create_model: 'blue',
update_model: 'orange',
delete_model: 'red',
create_token: 'blue',
revoke_token: 'red',
update_config: 'orange',
create_prompt: 'blue',
update_prompt: 'orange',
archive_prompt: 'red',
desktop_audit: 'default',
}

View File

@@ -1,403 +0,0 @@
import { useState, useCallback, useEffect, useMemo } from 'react'
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
import {
DashboardOutlined,
TeamOutlined,
CloudServerOutlined,
BarChartOutlined,
SwapOutlined,
SettingOutlined,
FileTextOutlined,
MessageOutlined,
RobotOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
SunOutlined,
MoonOutlined,
ApiOutlined,
BookOutlined,
CrownOutlined,
SafetyOutlined,
FieldTimeOutlined,
SyncOutlined,
ShopOutlined,
} from '@ant-design/icons'
import { Avatar, Dropdown, Tooltip, Drawer } from 'antd'
import { useAuthStore } from '@/stores/authStore'
import { useThemeStore, setThemeMode } from '@/stores/themeStore'
import type { ReactNode } from 'react'
// ============================================================
// Navigation Configuration
// ============================================================
interface NavItem {
path: string
name: string
icon: ReactNode
permission?: string
group: string
}
const navItems: NavItem[] = [
{ path: '/', name: '仪表盘', icon: <DashboardOutlined />, group: '核心' },
{ path: '/accounts', name: '账号管理', icon: <TeamOutlined />, permission: 'account:admin', group: '资源管理' },
{ path: '/roles', name: '角色与权限', icon: <SafetyOutlined />, permission: 'account:admin', group: '资源管理' },
{ path: '/model-services', name: '模型服务', icon: <CloudServerOutlined />, permission: 'provider:manage', group: '资源管理' },
{ path: '/agent-templates', name: 'Agent 模板', icon: <RobotOutlined />, permission: 'model:read', group: '资源管理' },
{ path: '/api-keys', name: 'API 密钥', icon: <ApiOutlined />, permission: 'provider:manage', group: '资源管理' },
{ path: '/usage', name: '用量统计', icon: <BarChartOutlined />, permission: 'admin:full', group: '运维' },
{ path: '/relay', name: '中转任务', icon: <SwapOutlined />, permission: 'relay:use', group: '运维' },
{ path: '/scheduled-tasks', name: '定时任务', icon: <FieldTimeOutlined />, permission: 'scheduler:read', group: '运维' },
{ path: '/knowledge', name: '知识库', icon: <BookOutlined />, permission: 'knowledge:read', group: '资源管理' },
{ path: '/industries', name: '行业配置', icon: <ShopOutlined />, permission: 'config:read', group: '资源管理' },
{ path: '/billing', name: '计费管理', icon: <CrownOutlined />, permission: 'billing:read', group: '核心' },
{ path: '/logs', name: '操作日志', icon: <FileTextOutlined />, permission: 'admin:full', group: '运维' },
{ path: '/config-sync', name: '同步日志', icon: <SyncOutlined />, permission: 'config:read', group: '运维' },
{ path: '/config', name: '系统配置', icon: <SettingOutlined />, permission: 'config:read', group: '系统' },
{ path: '/prompts', name: '提示词管理', icon: <MessageOutlined />, permission: 'prompt:read', group: '系统' },
]
// ============================================================
// Sidebar Component
// ============================================================
function Sidebar({
collapsed,
onNavigate,
activePath,
}: {
collapsed: boolean
onNavigate: (path: string) => void
activePath: string
}) {
const { hasPermission } = useAuthStore()
const visibleItems = navItems.filter(
(item) => !item.permission || hasPermission(item.permission),
)
const groups = useMemo(() => {
const map = new Map<string, NavItem[]>()
for (const item of visibleItems) {
const list = map.get(item.group) || []
list.push(item)
map.set(item.group, list)
}
return map
}, [visibleItems])
return (
<nav className="flex flex-col h-full" aria-label="主导航">
{/* Logo */}
<div className="flex items-center h-14 px-4 border-b border-neutral-200 dark:border-neutral-800">
<div
className="flex items-center justify-center w-8 h-8 rounded-lg shrink-0"
style={{ background: 'linear-gradient(135deg, #863bff, #47bfff)' }}
>
<span className="text-white font-bold text-sm">Z</span>
</div>
{!collapsed && (
<span className="ml-3 text-lg font-bold text-neutral-900 dark:text-neutral-100 tracking-tight">
ZCLAW
</span>
)}
</div>
{/* Navigation Items */}
<div className="flex-1 overflow-y-auto py-3 px-2">
{Array.from(groups.entries()).map(([groupName, items]) => (
<div key={groupName} className="mb-3">
{!collapsed && (
<div className="px-3 mb-1 text-[11px] font-semibold uppercase tracking-wider text-neutral-400 dark:text-neutral-600">
{groupName}
</div>
)}
{items.map((item) => {
const isActive =
item.path === '/'
? activePath === '/'
: activePath === item.path || activePath.startsWith(item.path + '/')
const btn = (
<button
key={item.path}
onClick={() => onNavigate(item.path)}
className={`
w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium
transition-all duration-150 ease-in-out cursor-pointer border-none bg-transparent
${
isActive
? 'text-brand-purple bg-brand-purple/8 dark:text-brand-purple dark:bg-brand-purple/12'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 hover:bg-neutral-100 dark:hover:bg-neutral-800'
}
${collapsed ? 'justify-center' : ''}
`}
aria-current={isActive ? 'page' : undefined}
>
<span
className={`text-base ${isActive ? 'text-brand-purple' : ''}`}
>
{item.icon}
</span>
{!collapsed && <span>{item.name}</span>}
{isActive && (
<span className="ml-auto w-1.5 h-1.5 rounded-full bg-brand-purple" />
)}
</button>
)
return collapsed ? (
<Tooltip key={item.path} title={item.name} placement="right">
{btn}
</Tooltip>
) : (
<div key={item.path}>{btn}</div>
)
})}
</div>
))}
</div>
{/* Bottom section */}
<div className="border-t border-neutral-200 dark:border-neutral-800 p-3">
{!collapsed && (
<div className="text-[11px] text-neutral-400 dark:text-neutral-600 text-center">
ZCLAW Admin v2
</div>
)}
</div>
</nav>
)
}
// ============================================================
// Mobile Drawer Sidebar
// ============================================================
function MobileDrawer({
open,
onClose,
onNavigate,
activePath,
}: {
open: boolean
onClose: () => void
onNavigate: (path: string) => void
activePath: string
}) {
return (
<Drawer
placement="left"
onClose={onClose}
open={open}
width={280}
styles={{
body: { padding: 0 },
header: { display: 'none' },
}}
>
<Sidebar collapsed={false} onNavigate={onNavigate} activePath={activePath} />
</Drawer>
)
}
// ============================================================
// Breadcrumb
// ============================================================
const breadcrumbMap: Record<string, string> = {
'/': '仪表盘',
'/accounts': '账号管理',
'/roles': '角色与权限',
'/model-services': '模型服务',
'/providers': '模型服务',
'/models': '模型服务',
'/api-keys': 'API 密钥',
'/agent-templates': 'Agent 模板',
'/usage': '用量统计',
'/relay': '中转任务',
'/scheduled-tasks': '定时任务',
'/knowledge': '知识库',
'/billing': '计费管理',
'/config': '系统配置',
'/industries': '行业配置',
'/prompts': '提示词管理',
'/logs': '操作日志',
'/config-sync': '同步日志',
}
// ============================================================
// Main Layout
// ============================================================
export default function AdminLayout() {
const navigate = useNavigate()
const location = useLocation()
const { account, logout } = useAuthStore()
const themeState = useThemeStore()
const [collapsed, setCollapsed] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const [isMobile, setIsMobile] = useState(false)
// Responsive detection
useEffect(() => {
const mq = window.matchMedia('(max-width: 768px)')
setIsMobile(mq.matches)
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
const handleNavigate = useCallback(
(path: string) => {
navigate(path)
setMobileOpen(false)
},
[navigate],
)
const handleLogout = useCallback(() => {
logout()
navigate('/login', { replace: true })
}, [logout, navigate])
const toggleTheme = useCallback(() => {
setThemeMode(themeState.resolved === 'dark' ? 'light' : 'dark')
}, [themeState.resolved])
const currentPage = breadcrumbMap[location.pathname] || '页面'
return (
<div className="flex h-screen overflow-hidden bg-neutral-50 dark:bg-neutral-950">
{/* Desktop Sidebar */}
{!isMobile && (
<aside
className={`
shrink-0 border-r border-neutral-200 dark:border-neutral-800
bg-white dark:bg-neutral-900
transition-all duration-200 ease-in-out
${collapsed ? 'w-12' : 'w-64'}
`}
>
<Sidebar
collapsed={collapsed}
onNavigate={handleNavigate}
activePath={location.pathname}
/>
</aside>
)}
{/* Mobile Drawer */}
{isMobile && (
<MobileDrawer
open={mobileOpen}
onClose={() => setMobileOpen(false)}
onNavigate={handleNavigate}
activePath={location.pathname}
/>
)}
{/* Main Area */}
<div className="flex-1 flex flex-col min-w-0">
{/* Header */}
<header className="h-14 shrink-0 flex items-center justify-between px-4 border-b border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
<div className="flex items-center gap-3">
{/* Mobile menu button */}
{isMobile && (
<button
onClick={() => setMobileOpen(true)}
className="p-2 rounded-lg text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
aria-label="打开菜单"
>
<MenuUnfoldOutlined className="text-lg" />
</button>
)}
{/* Collapse toggle (desktop) */}
{!isMobile && (
<button
onClick={() => setCollapsed(!collapsed)}
className="p-2 rounded-lg text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
>
{collapsed ? (
<MenuUnfoldOutlined className="text-lg" />
) : (
<MenuFoldOutlined className="text-lg" />
)}
</button>
)}
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm">
<span className="text-neutral-400 dark:text-neutral-600">ZCLAW</span>
<span className="text-neutral-300 dark:text-neutral-700">/</span>
<span className="text-neutral-900 dark:text-neutral-100 font-medium">
{currentPage}
</span>
</div>
</div>
{/* Right actions */}
<div className="flex items-center gap-2">
{/* Theme toggle */}
<Tooltip title={themeState.resolved === 'dark' ? '切换亮色' : '切换暗色'}>
<button
onClick={toggleTheme}
className="p-2 rounded-lg text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
aria-label="切换主题"
>
{themeState.resolved === 'dark' ? (
<SunOutlined className="text-lg" />
) : (
<MoonOutlined className="text-lg" />
)}
</button>
</Tooltip>
{/* User avatar */}
<Dropdown
menu={{
items: [
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: handleLogout,
},
],
}}
>
<button className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors">
<Avatar
size={28}
style={{
background: 'linear-gradient(135deg, #863bff, #47bfff)',
fontSize: 12,
fontWeight: 600,
}}
>
{(account?.display_name || account?.username || 'A')[0].toUpperCase()}
</Avatar>
{!isMobile && (
<span className="text-sm text-neutral-700 dark:text-neutral-300 font-medium">
{account?.display_name || account?.username || 'Admin'}
</span>
)}
</button>
</Dropdown>
</div>
</header>
{/* Content */}
<main
id="main-content"
className="flex-1 overflow-y-auto p-6"
>
<Outlet />
</main>
</div>
</div>
)
}

View File

@@ -1,88 +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, theme } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import { router } from './router'
import { ErrorBoundary } from './components/ErrorBoundary'
import { useThemeStore } from './stores/themeStore'
import './styles/globals.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 30_000,
},
},
})
function ThemedApp() {
const resolved = useThemeStore((s) => s.resolved)
return (
<ConfigProvider
locale={zhCN}
theme={{
token: {
colorPrimary: '#863bff',
colorBgContainer: resolved === 'dark' ? '#292524' : '#ffffff',
colorBgElevated: resolved === 'dark' ? '#1c1917' : '#ffffff',
colorBgLayout: resolved === 'dark' ? '#0c0a09' : '#fafaf9',
colorBorder: resolved === 'dark' ? '#44403c' : '#e7e5e4',
colorBorderSecondary: resolved === 'dark' ? '#44403c' : '#f5f5f4',
colorText: resolved === 'dark' ? '#fafaf9' : '#1c1917',
colorTextSecondary: resolved === 'dark' ? '#a8a29e' : '#78716c',
colorTextTertiary: resolved === 'dark' ? '#78716c' : '#a8a29e',
borderRadius: 8,
borderRadiusLG: 12,
fontFamily:
'"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
fontSize: 14,
controlHeight: 36,
},
algorithm: resolved === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm,
components: {
Card: {
borderRadiusLG: 12,
},
Table: {
borderRadiusLG: 12,
headerBg: resolved === 'dark' ? '#1c1917' : '#fafaf9',
headerColor: resolved === 'dark' ? '#a8a29e' : '#78716c',
rowHoverBg: resolved === 'dark' ? 'rgba(134,59,255,0.06)' : 'rgba(134,59,255,0.04)',
},
Button: {
borderRadius: 8,
controlHeight: 36,
},
Input: {
borderRadius: 8,
},
Select: {
borderRadius: 8,
},
Modal: {
borderRadiusLG: 12,
},
Tag: {
borderRadiusSM: 9999,
},
},
}}
>
<AntApp>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AntApp>
</ConfigProvider>
)
}
createRoot(document.getElementById('root')!).render(
<ErrorBoundary>
<ThemedApp />
</ErrorBoundary>,
)

View File

@@ -1,341 +0,0 @@
// ============================================================
// 账号管理
// ============================================================
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, Select, Popconfirm, Space, Divider } from 'antd'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { accountService } from '@/services/accounts'
import { industryService } from '@/services/industries'
import { billingService } from '@/services/billing'
import { PageHeader } from '@/components/PageHeader'
import type { AccountPublic } from '@/types'
const roleLabels: Record<string, string> = {
super_admin: '超级管理员',
admin: '管理员',
user: '用户',
}
const roleColors: Record<string, string> = {
super_admin: 'red',
admin: 'blue',
user: 'default',
}
const statusLabels: Record<string, string> = {
active: '正常',
disabled: '已禁用',
suspended: '已封禁',
}
const statusColors: Record<string, string> = {
active: 'green',
disabled: 'default',
suspended: 'red',
}
export default function Accounts() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [searchParams, setSearchParams] = useState<Record<string, string>>({})
const { data, isLoading } = useQuery({
queryKey: ['accounts', searchParams],
queryFn: ({ signal }) => accountService.list(searchParams, signal),
})
// 获取行业列表(用于下拉选择)
const { data: industriesData } = useQuery({
queryKey: ['industries-all'],
queryFn: ({ signal }) => industryService.list({ page: 1, page_size: 100, status: 'active' }, signal),
})
// 获取当前编辑用户的行业授权
const { data: accountIndustries } = useQuery({
queryKey: ['account-industries', editingId],
queryFn: ({ signal }) => industryService.getAccountIndustries(editingId!, signal),
enabled: !!editingId,
})
// 当账户行业数据加载完且正在编辑时,同步到表单
// Guard: only sync when editingId matches the query key
useEffect(() => {
if (accountIndustries && editingId) {
const ids = accountIndustries.map((item) => item.industry_id)
form.setFieldValue('industry_ids', ids)
}
}, [accountIndustries, editingId, form])
// 获取所有活跃计划(用于管理员切换)
const { data: plansData } = useQuery({
queryKey: ['billing-plans'],
queryFn: ({ signal }) => billingService.listPlans(signal),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<AccountPublic> }) =>
accountService.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['accounts'] })
},
onError: (err: Error) => message.error(err.message || '更新失败'),
})
const statusMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: AccountPublic['status'] }) =>
accountService.updateStatus(id, { status }),
onSuccess: () => {
message.success('状态更新成功')
queryClient.invalidateQueries({ queryKey: ['accounts'] })
},
onError: (err: Error) => message.error(err.message || '状态更新失败'),
})
// 设置用户行业授权
const setIndustriesMutation = useMutation({
mutationFn: ({ accountId, industries }: { accountId: string; industries: string[] }) =>
industryService.setAccountIndustries(accountId, {
industries: industries.map((id, idx) => ({
industry_id: id,
is_primary: idx === 0,
})),
}),
onError: (err: Error) => message.error(err.message || '行业授权更新失败'),
})
// 管理员切换用户计划
const switchPlanMutation = useMutation({
mutationFn: ({ accountId, planId }: { accountId: string; planId: string }) =>
billingService.adminSwitchPlan(accountId, planId),
onSuccess: () => message.success('计划切换成功'),
onError: (err: Error) => message.error(err.message || '计划切换失败'),
})
const columns: ProColumns<AccountPublic>[] = [
{ title: '用户名', dataIndex: 'username', width: 120, tooltip: '搜索用户名、邮箱或显示名' },
{ title: '显示名', dataIndex: 'display_name', width: 120, hideInSearch: true },
{ title: '邮箱', dataIndex: 'email', width: 180 },
{
title: '角色',
dataIndex: 'role',
width: 120,
valueType: 'select',
valueEnum: {
super_admin: { text: '超级管理员' },
admin: { text: '管理员' },
user: { text: '用户' },
},
render: (_, record) => <Tag color={roleColors[record.role]}>{roleLabels[record.role] || record.role}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
valueType: 'select',
valueEnum: {
active: { text: '正常', status: 'Success' },
disabled: { text: '已禁用', status: 'Default' },
suspended: { text: '已封禁', status: 'Error' },
},
render: (_, record) => <Tag color={statusColors[record.status]}>{statusLabels[record.status] || record.status}</Tag>,
},
{
title: '2FA',
dataIndex: 'totp_enabled',
width: 80,
hideInSearch: true,
render: (_, record) => record.totp_enabled ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: 'LLM 路由',
dataIndex: 'llm_routing',
width: 120,
hideInSearch: true,
valueType: 'select',
valueEnum: {
relay: { text: 'SaaS 中转', status: 'Success' },
local: { text: '本地直连', status: 'Default' },
},
},
{
title: '最后登录',
dataIndex: 'last_login_at',
width: 180,
hideInSearch: true,
render: (_, record) => record.last_login_at ? new Date(record.last_login_at).toLocaleString('zh-CN') : '-',
},
{
title: '操作',
width: 200,
hideInSearch: true,
render: (_, record) => (
<Space>
<Button
size="small"
onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}
>
</Button>
{record.status === 'active' ? (
<Popconfirm title="确定禁用此账号?" onConfirm={() => statusMutation.mutate({ id: record.id, status: 'disabled' })}>
<Button size="small" danger></Button>
</Popconfirm>
) : (
<Popconfirm title="确定启用此账号?" onConfirm={() => statusMutation.mutate({ id: record.id, status: 'active' })}>
<Button size="small" type="primary"></Button>
</Popconfirm>
)}
</Space>
),
},
]
const handleSave = async () => {
const values = await form.validateFields()
if (!editingId) return
try {
// 更新基础信息
const { industry_ids, plan_id, ...accountData } = values
await updateMutation.mutateAsync({ id: editingId, data: accountData })
// 更新行业授权(如果变更了)
const newIndustryIds: string[] = industry_ids || []
const oldIndustryIds = accountIndustries?.map((i) => i.industry_id) || []
const changed = newIndustryIds.length !== oldIndustryIds.length
|| newIndustryIds.some((id) => !oldIndustryIds.includes(id))
if (changed) {
await setIndustriesMutation.mutateAsync({ accountId: editingId, industries: newIndustryIds })
message.success('行业授权已更新')
queryClient.invalidateQueries({ queryKey: ['account-industries'] })
}
// 切换订阅计划(如果选择了新计划)
if (plan_id) {
await switchPlanMutation.mutateAsync({ accountId: editingId, planId: plan_id })
}
handleClose()
} catch {
// Errors handled by mutation onError callbacks
}
}
const handleClose = () => {
setModalOpen(false)
setEditingId(null)
form.resetFields()
}
const industryOptions = (industriesData?.items || []).map((item) => ({
value: item.id,
label: `${item.icon} ${item.name}`,
}))
const planOptions = (plansData || []).map((plan) => ({
value: plan.id,
label: `${plan.display_name}${(plan.price_cents / 100).toFixed(0)}/月)`,
}))
return (
<div>
<PageHeader title="账号管理" description="管理系统用户账号、角色、权限与行业授权" />
<ProTable<AccountPublic>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={{}}
toolBarRender={() => []}
onSubmit={(values) => {
const filtered: Record<string, string> = {}
for (const [k, v] of Object.entries(values)) {
if (v !== undefined && v !== null && v !== '') {
if (k === 'username') {
filtered.search = String(v)
} else {
filtered[k] = String(v)
}
}
}
setSearchParams(filtered)
}}
onReset={() => setSearchParams({})}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
/>
<Modal
title={<span className="text-base font-semibold"></span>}
open={modalOpen}
onOk={handleSave}
onCancel={handleClose}
confirmLoading={updateMutation.isPending || setIndustriesMutation.isPending || switchPlanMutation.isPending}
width={560}
>
<Form form={form} layout="vertical" className="mt-4">
<Form.Item name="display_name" label="显示名">
<Input />
</Form.Item>
<Form.Item name="email" label="邮箱">
<Input type="email" />
</Form.Item>
<Form.Item name="role" label="角色">
<Select options={[
{ value: 'super_admin', label: '超级管理员' },
{ value: 'admin', label: '管理员' },
{ value: 'user', label: '用户' },
]} />
</Form.Item>
<Form.Item name="llm_routing" label="LLM 路由模式">
<Select options={[
{ value: 'local', label: '本地直连' },
{ value: 'relay', label: 'SaaS 中转 (Token 池)' },
]} />
</Form.Item>
<Divider></Divider>
<Form.Item
name="plan_id"
label="切换计划"
extra="选择新计划后保存将立即切换。留空则不修改当前计划。"
>
<Select
allowClear
placeholder="不修改当前计划"
options={planOptions}
loading={!plansData}
/>
</Form.Item>
<Divider></Divider>
<Form.Item
name="industry_ids"
label="授权行业"
extra="第一个行业将设为主行业。行业决定管家可触达的知识域和技能优先级。"
>
<Select
mode="multiple"
placeholder="选择授权的行业"
options={industryOptions}
loading={!industriesData}
/>
</Form.Item>
</Form>
</Modal>
</div>
)
}

View File

@@ -1,258 +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, MinusCircleOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { agentTemplateService } from '@/services/agent-templates'
import type { AgentTemplate } from '@/types'
const { TextArea } = Input
const sourceLabels: Record<string, string> = { builtin: '内置', custom: '自定义' }
const visibilityLabels: Record<string, string> = { public: '公开', team: '团队', private: '私有' }
const statusLabels: Record<string, string> = { active: '活跃', archived: '已归档' }
const statusColors: Record<string, string> = { active: 'green', archived: 'default' }
export default function AgentTemplates() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const [detailRecord, setDetailRecord] = useState<AgentTemplate | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['agent-templates'],
queryFn: ({ signal }) => agentTemplateService.list(signal),
})
const createMutation = useMutation({
mutationFn: (data: Parameters<typeof agentTemplateService.create>[0]) =>
agentTemplateService.create(data),
onSuccess: () => {
message.success('创建成功')
queryClient.invalidateQueries({ queryKey: ['agent-templates'] })
setModalOpen(false)
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const archiveMutation = useMutation({
mutationFn: (id: string) => agentTemplateService.archive(id),
onSuccess: () => {
message.success('已归档')
queryClient.invalidateQueries({ queryKey: ['agent-templates'] })
},
onError: (err: Error) => message.error(err.message || '归档失败'),
})
const columns: ProColumns<AgentTemplate>[] = [
{ title: '图标', dataIndex: 'emoji', width: 60 },
{ title: '名称', dataIndex: 'name', width: 160 },
{ title: '分类', dataIndex: 'category', width: 100 },
{ title: '模型', dataIndex: 'model', width: 140, render: (_, r) => r.model || '-' },
{
title: '来源',
dataIndex: 'source',
width: 80,
render: (_, r) => <Tag>{sourceLabels[r.source] || r.source}</Tag>,
},
{
title: '可见性',
dataIndex: 'visibility',
width: 80,
render: (_, r) => <Tag color="blue">{visibilityLabels[r.visibility] || r.visibility}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (_, r) => <Tag color={statusColors[r.status]}>{statusLabels[r.status] || r.status}</Tag>,
},
{ title: '版本', dataIndex: 'current_version', width: 70 },
{
title: '操作',
width: 180,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => setDetailRecord(record)}></Button>
{record.status === 'active' && (
<Popconfirm title="确定归档此模板?" onConfirm={() => archiveMutation.mutate(record.id)}>
<Button size="small" danger></Button>
</Popconfirm>
)}
</Space>
),
},
]
const handleCreate = async () => {
const values = await form.validateFields()
createMutation.mutate(values)
}
return (
<div>
<ProTable<AgentTemplate>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true) }}>
</Button>,
]}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
/>
<Modal
title="新建 Agent 模板"
open={modalOpen}
onOk={handleCreate}
onCancel={() => { setModalOpen(false); form.resetFields() }}
confirmLoading={createMutation.isPending}
width={640}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="描述">
<TextArea rows={2} />
</Form.Item>
<Form.Item name="category" label="分类">
<Input placeholder="如 assistant, tool" />
</Form.Item>
<Form.Item name="model" label="默认模型">
<Input placeholder="如 gpt-4o" />
</Form.Item>
<Form.Item name="system_prompt" label="系统提示词">
<TextArea rows={4} />
</Form.Item>
<Form.Item name="temperature" label="Temperature">
<InputNumber min={0} max={2} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="max_tokens" label="最大 Token">
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="visibility" label="可见性">
<Select options={[
{ value: 'public', label: '公开' },
{ value: 'team', label: '团队' },
{ value: 'private', label: '私有' },
]} />
</Form.Item>
<Form.Item name="emoji" label="图标">
<Input placeholder="如 🏥" />
</Form.Item>
<Form.Item name="personality" label="人格预设">
<Select options={[
{ value: 'professional', label: '专业' },
{ value: 'friendly', label: '友好' },
{ value: 'creative', label: '创意' },
{ value: 'concise', label: '简洁' },
]} allowClear placeholder="选择人格预设" />
</Form.Item>
<Form.Item name="soul_content" label="SOUL.md 人格配置">
<TextArea rows={8} />
</Form.Item>
<Form.Item name="welcome_message" label="欢迎语">
<TextArea rows={2} />
</Form.Item>
<Form.Item name="communication_style" label="沟通风格">
<TextArea rows={2} />
</Form.Item>
<Form.Item name="source_id" label="模板标识">
<Input placeholder="如 medical-assistant-v1" />
</Form.Item>
<Form.Item name="scenarios" label="使用场景">
<Select mode="tags" placeholder="输入场景标签后按回车" />
</Form.Item>
<Form.List name="quick_commands">
{(fields, { add, remove }) => (
<>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
{fields.map(({ key, name, ...restField }) => (
<Space key={key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
<Form.Item {...restField} name={[name, 'label']} rules={[{ required: true, message: '请输入标签' }]}>
<Input placeholder="标签" style={{ width: 140 }} />
</Form.Item>
<Form.Item {...restField} name={[name, 'command']} rules={[{ required: true, message: '请输入命令' }]}>
<Input placeholder="命令/提示词" style={{ width: 280 }} />
</Form.Item>
<MinusCircleOutlined onClick={() => remove(name)} />
</Space>
))}
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
</Button>
</>
)}
</Form.List>
</Form>
</Modal>
<Modal
title="模板详情"
open={!!detailRecord}
onCancel={() => setDetailRecord(null)}
footer={null}
width={640}
>
{detailRecord && (
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="图标">{detailRecord.emoji || '-'}</Descriptions.Item>
<Descriptions.Item label="名称">{detailRecord.name}</Descriptions.Item>
<Descriptions.Item label="分类">{detailRecord.category}</Descriptions.Item>
<Descriptions.Item label="模型">{detailRecord.model || '-'}</Descriptions.Item>
<Descriptions.Item label="来源">{sourceLabels[detailRecord.source]}</Descriptions.Item>
<Descriptions.Item label="可见性">{visibilityLabels[detailRecord.visibility]}</Descriptions.Item>
<Descriptions.Item label="状态">{statusLabels[detailRecord.status]}</Descriptions.Item>
<Descriptions.Item label="版本">{detailRecord.version ?? detailRecord.current_version}</Descriptions.Item>
<Descriptions.Item label="描述" span={2}>{detailRecord.description || '-'}</Descriptions.Item>
<Descriptions.Item label="人格预设">{detailRecord.personality || '-'}</Descriptions.Item>
<Descriptions.Item label="沟通风格">{detailRecord.communication_style || '-'}</Descriptions.Item>
<Descriptions.Item label="模板标识" span={2}>{detailRecord.source_id || '-'}</Descriptions.Item>
{detailRecord.welcome_message && (
<Descriptions.Item label="欢迎语" span={2}>{detailRecord.welcome_message}</Descriptions.Item>
)}
{detailRecord.scenarios && detailRecord.scenarios.length > 0 && (
<Descriptions.Item label="使用场景" span={2}>
{detailRecord.scenarios.map((s) => <Tag key={s}>{s}</Tag>)}
</Descriptions.Item>
)}
<Descriptions.Item label="系统提示词" span={2}>
<div style={{ whiteSpace: 'pre-wrap', maxHeight: 200, overflow: 'auto' }}>
{detailRecord.system_prompt || '-'}
</div>
</Descriptions.Item>
{detailRecord.soul_content && (
<Descriptions.Item label="SOUL.md 人格配置" span={2}>
<div style={{ whiteSpace: 'pre-wrap', maxHeight: 200, overflow: 'auto' }}>
{detailRecord.soul_content}
</div>
</Descriptions.Item>
)}
<Descriptions.Item label="工具" span={2}>
{detailRecord.tools?.map((t) => <Tag key={t}>{t}</Tag>) || '-'}
</Descriptions.Item>
<Descriptions.Item label="能力" span={2}>
{detailRecord.capabilities?.map((c) => <Tag key={c} color="blue">{c}</Tag>) || '-'}
</Descriptions.Item>
</Descriptions>
)}
</Modal>
</div>
)
}

View File

@@ -1,169 +0,0 @@
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>
)
}

View File

@@ -1,352 +0,0 @@
// ============================================================
// 计费管理 — 计划/订阅/用量/支付
// ============================================================
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>
)
}

View File

@@ -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>
)
}

View File

@@ -1,111 +0,0 @@
// ============================================================
// 配置同步日志
// ============================================================
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>
)
}

View File

@@ -1,148 +0,0 @@
// ============================================================
// 仪表盘页面
// ============================================================
import { useQuery } from '@tanstack/react-query'
import { Card, Col, Row, Statistic, Table, Tag, Spin } from 'antd'
import {
TeamOutlined,
CloudServerOutlined,
ApiOutlined,
ThunderboltOutlined,
ColumnWidthOutlined,
} from '@ant-design/icons'
import { statsService } from '@/services/stats'
import { logService } from '@/services/logs'
import { PageHeader } from '@/components/PageHeader'
import { ErrorState } from '@/components/ErrorState'
import { actionLabels, actionColors } from '@/constants/status'
import type { OperationLog } from '@/types'
export default function Dashboard() {
const {
data: stats,
isLoading: statsLoading,
error: statsError,
refetch: refetchStats,
} = useQuery({
queryKey: ['dashboard-stats'],
queryFn: ({ signal }) => statsService.dashboard(signal),
})
const { data: logsData, isLoading: logsLoading } = useQuery({
queryKey: ['recent-logs'],
queryFn: ({ signal }) => logService.list({ page: 1, page_size: 10 }, signal),
})
if (statsError) {
return (
<>
<PageHeader title="仪表盘" description="系统概览与最近活动" />
<ErrorState
message={(statsError as Error).message}
onRetry={() => refetchStats()}
/>
</>
)
}
const statCards = [
{ title: '总账号', value: stats?.total_accounts ?? 0, icon: <TeamOutlined />, color: '#863bff' },
{ title: '活跃服务商', value: stats?.active_providers ?? 0, icon: <CloudServerOutlined />, color: '#47bfff' },
{ title: '活跃模型', value: stats?.active_models ?? 0, icon: <ApiOutlined />, color: '#22c55e' },
{ title: '今日请求', value: stats?.tasks_today ?? 0, icon: <ThunderboltOutlined />, color: '#f59e0b' },
{
title: '今日 Token',
value: (stats?.tokens_today_input ?? 0) + (stats?.tokens_today_output ?? 0),
icon: <ColumnWidthOutlined />,
color: '#ef4444',
},
]
const logColumns = [
{
title: '操作类型',
dataIndex: 'action',
key: 'action',
width: 140,
render: (action: string) => (
<Tag color={actionColors[action] || 'default'}>
{actionLabels[action] || action}
</Tag>
),
},
{
title: '目标类型',
dataIndex: 'target_type',
key: 'target_type',
width: 100,
render: (v: string | null) => v || '-',
},
{
title: '时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
},
]
return (
<div>
<PageHeader title="仪表盘" description="系统概览与最近活动" />
{/* Stat Cards */}
<Row gutter={[16, 16]} className="mb-6">
{statsLoading ? (
<Col span={24}>
<div className="flex justify-center py-8">
<Spin size="large" />
</div>
</Col>
) : (
statCards.map((card) => (
<Col xs={24} sm={12} md={8} lg={4} key={card.title}>
<Card
className="hover:shadow-md transition-shadow duration-200"
styles={{ body: { padding: '20px 24px' } }}
>
<Statistic
title={
<span className="text-neutral-500 dark:text-neutral-400 text-xs font-medium uppercase tracking-wide">
{card.title}
</span>
}
value={card.value}
valueStyle={{ fontSize: 28, fontWeight: 600, color: card.color }}
prefix={
<span style={{ color: card.color, marginRight: 4 }}>{card.icon}</span>
}
/>
</Card>
</Col>
))
)}
</Row>
{/* Recent Logs */}
<Card
title={
<span className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
</span>
}
size="small"
styles={{ body: { padding: 0 } }}
>
<Table<OperationLog>
columns={logColumns}
dataSource={logsData?.items ?? []}
loading={logsLoading}
rowKey="id"
pagination={false}
size="small"
/>
</Card>
</div>
)
}

View File

@@ -1,379 +0,0 @@
// ============================================================
// 行业配置管理
// ============================================================
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>
)
}

View File

@@ -1,880 +0,0 @@
// ============================================================
// 知识库管理
// ============================================================
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,
}))
}

View File

@@ -1,160 +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 } from 'antd'
import { authService } from '@/services/auth'
import { useAuthStore } from '@/stores/authStore'
import type { LoginRequest } from '@/types'
export default function Login() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const loginStore = useAuthStore((s) => s.login)
const [needTotp, setNeedTotp] = useState(false)
const [loading, setLoading] = useState(false)
const handleSubmit = async (values: Record<string, string>) => {
setLoading(true)
try {
const data: LoginRequest = {
username: values.username?.trim() || '',
password: values.password || '',
totp_code: values.totp_code?.trim() || undefined,
}
const res = await authService.login(data)
loginStore(res.account)
message.success('登录成功')
const from = searchParams.get('from') || '/'
navigate(from, { replace: true })
} catch (err: unknown) {
const error = err as { message?: string; status?: number }
const msg = error.message || ''
if (msg.includes('TOTP') || msg.includes('totp') || msg.includes('2FA') || msg.includes('验证码') || error.status === 403) {
setNeedTotp(true)
message.warning(msg || '请输入两步验证码')
} else {
message.error(msg || '登录失败,请检查用户名和密码')
}
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex">
{/* Left Brand Panel — hidden on mobile */}
<div className="hidden md:flex flex-1 flex-col items-center justify-center relative overflow-hidden"
style={{ background: 'linear-gradient(135deg, #0c0a09 0%, #1c1917 40%, #292524 100%)' }}
>
{/* Decorative gradient orb */}
<div
className="absolute w-[400px] h-[400px] rounded-full opacity-20 blur-3xl"
style={{ background: 'linear-gradient(135deg, #863bff, #47bfff)', top: '20%', left: '10%' }}
/>
<div
className="absolute w-[300px] h-[300px] rounded-full opacity-10 blur-3xl"
style={{ background: 'linear-gradient(135deg, #47bfff, #863bff)', bottom: '10%', right: '15%' }}
/>
{/* Brand content */}
<div className="relative z-10 text-center px-8">
<div
className="inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-6"
style={{ background: 'linear-gradient(135deg, #863bff, #47bfff)' }}
>
<span className="text-white text-2xl font-bold">Z</span>
</div>
<h1 className="text-4xl font-bold text-white mb-3 tracking-tight">ZCLAW</h1>
<p className="text-white/50 text-base mb-8">AI Agent </p>
<div className="w-16 h-px mx-auto mb-8" style={{ background: 'linear-gradient(90deg, transparent, #863bff, #47bfff, transparent)' }} />
<p className="text-white/30 text-sm max-w-sm mx-auto leading-relaxed">
AI API
</p>
</div>
</div>
{/* Right Login Form */}
<div className="flex-1 md:flex-none md:w-[480px] flex items-center justify-center p-8 bg-white dark:bg-neutral-950">
<div className="w-full max-w-[360px]">
{/* Mobile logo (visible only on mobile) */}
<div className="md:hidden flex items-center gap-3 mb-10">
<div
className="flex items-center justify-center w-10 h-10 rounded-xl"
style={{ background: 'linear-gradient(135deg, #863bff, #47bfff)' }}
>
<span className="text-white font-bold">Z</span>
</div>
<span className="text-xl font-bold text-neutral-900 dark:text-white">ZCLAW</span>
</div>
<h2 className="text-2xl font-semibold text-neutral-900 dark:text-white mb-1">
</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-8">
</p>
<LoginForm
onFinish={handleSubmit}
submitter={{
searchConfig: { submitText: '登录' },
submitButtonProps: {
loading,
block: true,
style: {
height: 44,
borderRadius: 8,
fontWeight: 500,
fontSize: 15,
background: 'linear-gradient(135deg, #863bff, #47bfff)',
border: 'none',
},
},
}}
>
<ProFormText
name="username"
fieldProps={{
size: 'large',
prefix: <UserOutlined />,
autoComplete: 'username',
}}
placeholder="请输入用户名"
rules={[{ required: true, message: '请输入用户名' }]}
/>
<ProFormText.Password
name="password"
fieldProps={{
size: 'large',
prefix: <LockOutlined />,
autoComplete: 'current-password',
}}
placeholder="请输入密码"
rules={[{ required: true, message: '请输入密码' }]}
/>
{needTotp && (
<ProFormText
name="totp_code"
fieldProps={{
size: 'large',
prefix: <SafetyOutlined />,
maxLength: 6,
autoComplete: 'one-time-code',
}}
placeholder="请输入 6 位验证码"
rules={[{ required: true, message: '请输入验证码' }]}
/>
)}
</LoginForm>
</div>
</div>
</div>
)
}

View File

@@ -1,91 +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 { actionLabels, actionColors } from '@/constants/status'
import type { OperationLog } from '@/types'
const { Title } = Typography
const actionOptions = Object.entries(actionLabels).map(([value, label]) => ({ value, label }))
export default function Logs() {
const [page, setPage] = useState(1)
const [actionFilter, setActionFilter] = useState<string | undefined>(undefined)
const { data, isLoading } = useQuery({
queryKey: ['logs', page, actionFilter],
queryFn: ({ signal }) => logService.list({ page, page_size: 20, action: actionFilter }, signal),
})
const columns: ProColumns<OperationLog>[] = [
{
title: '操作类型',
dataIndex: 'action',
width: 140,
render: (_, r) => (
<Tag color={actionColors[r.action] || 'default'}>
{actionLabels[r.action] || r.action}
</Tag>
),
},
{ title: '目标类型', dataIndex: 'target_type', width: 100, render: (_, r) => r.target_type || '-' },
{ title: '目标 ID', dataIndex: 'target_id', width: 120, render: (_, r) => r.target_id ? <code>{r.target_id.substring(0, 8)}...</code> : '-' },
{
title: '详情',
dataIndex: 'details',
width: 250,
ellipsis: true,
render: (_, r) => {
if (!r.details) return '-'
if (typeof r.details === 'string') return r.details
return JSON.stringify(r.details)
},
},
{ title: 'IP 地址', dataIndex: 'ip_address', width: 130, render: (_, r) => <code>{r.ip_address || '-'}</code> },
{
title: '时间',
dataIndex: 'created_at',
width: 180,
render: (_, r) => new Date(r.created_at).toLocaleString('zh-CN'),
},
]
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<Title level={4} style={{ margin: 0 }}></Title>
<Select
value={actionFilter}
onChange={(v) => { setActionFilter(v === 'all' ? undefined : v); setPage(1) }}
placeholder="操作类型筛选"
style={{ width: 160 }}
allowClear
options={[{ value: 'all', label: '全部操作' }, ...actionOptions]}
/>
</div>
<ProTable<OperationLog>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={false}
pagination={{
total: data?.total ?? 0,
pageSize: 20,
current: page,
onChange: setPage,
showSizeChanger: false,
}}
/>
</div>
)
}

View File

@@ -1,431 +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, Tabs, Table, Typography } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { providerService } from '@/services/providers'
import { modelService } from '@/services/models'
import type { Provider, ProviderKey, Model } from '@/types'
const { Text } = Typography
// ============================================================
// 子组件: 模型表格
// ============================================================
function ProviderModelsTable({ providerId }: { providerId: string }) {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['provider-models', providerId],
queryFn: ({ signal }) => modelService.list({ provider_id: providerId! }, signal),
})
const createMutation = useMutation({
mutationFn: (data: Partial<Omit<Model, 'id'>>) => modelService.create(data),
onSuccess: () => {
message.success('模型已创建')
queryClient.invalidateQueries({ queryKey: ['provider-models', providerId] })
setModalOpen(false)
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Omit<Model, 'id'>> }) =>
modelService.update(id, data),
onSuccess: () => {
message.success('模型已更新')
queryClient.invalidateQueries({ queryKey: ['provider-models', providerId] })
setModalOpen(false)
},
onError: (err: Error) => message.error(err.message || '更新失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => modelService.delete(id),
onSuccess: () => {
message.success('模型已删除')
queryClient.invalidateQueries({ queryKey: ['provider-models', providerId] })
},
onError: (err: Error) => message.error(err.message || '删除失败'),
})
const handleSave = async () => {
const values = await form.validateFields()
if (editingId) {
updateMutation.mutate({ id: editingId, data: values })
} else {
createMutation.mutate({ ...values, provider_id: providerId })
}
}
const columns: ProColumns<Model>[] = [
{ title: '模型 ID', dataIndex: 'model_id', width: 180, render: (_, r) => <Text code>{r.model_id}</Text> },
{ title: '别名', dataIndex: 'alias', width: 120 },
{ title: '类型', dataIndex: 'is_embedding', width: 80, render: (_, r) => r.is_embedding ? <Tag color="purple">Embedding</Tag> : <Tag>Chat</Tag> },
{ title: '上下文窗口', dataIndex: 'context_window', width: 100, render: (_, r) => r.context_window?.toLocaleString() },
{ title: '最大输出', dataIndex: 'max_output_tokens', width: 90, render: (_, r) => r.max_output_tokens?.toLocaleString() },
{ title: '流式', dataIndex: 'supports_streaming', width: 60, render: (_, r) => r.supports_streaming ? <Tag color="green"></Tag> : <Tag></Tag> },
{ title: '视觉', dataIndex: 'supports_vision', width: 60, render: (_, r) => r.supports_vision ? <Tag color="blue"></Tag> : <Tag></Tag> },
{ title: '状态', dataIndex: 'enabled', width: 60, render: (_, r) => r.enabled ? <Tag color="green"></Tag> : <Tag></Tag> },
{
title: '操作', width: 120, render: (_, record) => (
<Space>
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}></Button>
<Popconfirm title="确定删除此模型?" onConfirm={() => deleteMutation.mutate(record.id)}>
<Button size="small" danger></Button>
</Popconfirm>
</Space>
),
},
]
const models = data?.items ?? []
return (
<div>
<div style={{ marginBottom: 8 }}>
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>
</Button>
</div>
<Table<Model>
columns={columns}
dataSource={models}
loading={isLoading}
rowKey="id"
size="small"
pagination={false}
/>
<Modal
title={editingId ? '编辑模型' : '添加模型'}
open={modalOpen}
onOk={handleSave}
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
confirmLoading={createMutation.isPending || updateMutation.isPending}
width={560}
>
<Form form={form} layout="vertical">
<Form.Item name="model_id" label="模型 ID" rules={[{ required: true }]}>
<Input placeholder="如 gpt-4o" />
</Form.Item>
<Form.Item name="alias" label="别名">
<Input placeholder="可选" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="context_window" label="上下文窗口" style={{ flex: 1 }}>
<InputNumber min={0} placeholder="128000" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="max_output_tokens" label="最大输出 Token" style={{ flex: 1 }}>
<InputNumber min={0} placeholder="4096" style={{ width: '100%' }} />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="enabled" label="启用" valuePropName="checked" style={{ flex: 1 }}>
<Switch />
</Form.Item>
<Form.Item name="is_embedding" label="Embedding 模型" valuePropName="checked" style={{ flex: 1 }}>
<Switch />
</Form.Item>
<Form.Item name="supports_streaming" label="支持流式" valuePropName="checked" style={{ flex: 1 }}>
<Switch defaultChecked />
</Form.Item>
<Form.Item name="supports_vision" label="支持视觉" valuePropName="checked" style={{ flex: 1 }}>
<Switch />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="pricing_input" label="输入价格 (每百万Token)" style={{ flex: 1 }}>
<InputNumber min={0} step={0.01} placeholder="0" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="pricing_output" label="输出价格 (每百万Token)" style={{ flex: 1 }}>
<InputNumber min={0} step={0.01} placeholder="0" style={{ width: '100%' }} />
</Form.Item>
</div>
</Form>
</Modal>
</div>
)
}
// ============================================================
// 子组件: Key Pool 表格
// ============================================================
function ProviderKeysTable({ providerId }: { providerId: string }) {
const queryClient = useQueryClient()
const [addKeyForm] = Form.useForm()
const [addKeyOpen, setAddKeyOpen] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['provider-keys', providerId],
queryFn: ({ signal }) => providerService.listKeys(providerId!, signal),
})
const addKeyMutation = useMutation({
mutationFn: (data: { key_label: string; key_value: string; priority?: number; max_rpm?: number; max_tpm?: number }) =>
providerService.addKey(providerId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['provider-keys', providerId] })
message.success('密钥已添加')
setAddKeyOpen(false)
addKeyForm.resetFields()
},
onError: () => message.error('添加失败'),
})
const toggleKeyMutation = useMutation({
mutationFn: ({ keyId, active }: { keyId: string; active: boolean }) =>
providerService.toggleKey(providerId, keyId, active),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['provider-keys', providerId] })
message.success('状态已切换')
},
onError: () => message.error('切换失败'),
})
const deleteKeyMutation = useMutation({
mutationFn: (keyId: string) => providerService.deleteKey(providerId, keyId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['provider-keys', providerId] })
message.success('密钥已删除')
},
onError: () => message.error('删除失败'),
})
const keyColumns: ProColumns<ProviderKey>[] = [
{ title: '标签', dataIndex: 'key_label', width: 120 },
{ title: '优先级', dataIndex: 'priority', width: 70 },
{ title: '请求数', dataIndex: 'total_requests', width: 80 },
{ title: 'Token 数', dataIndex: 'total_tokens', width: 90 },
{
title: '状态', dataIndex: 'is_active', width: 70,
render: (_, r) => r.is_active ? <Tag color="green"></Tag> : <Tag color="orange"></Tag>,
},
{
title: '操作', width: 120,
render: (_, record) => (
<Space>
<Popconfirm
title={record.is_active ? '确定禁用此密钥?' : '确定启用此密钥?'}
onConfirm={() => toggleKeyMutation.mutate({ keyId: record.id, active: !record.is_active })}
>
<Button size="small" type={record.is_active ? 'default' : 'primary'}>
{record.is_active ? '禁用' : '启用'}
</Button>
</Popconfirm>
<Popconfirm title="确定删除此密钥?此操作不可恢复。" onConfirm={() => deleteKeyMutation.mutate(record.id)}>
<Button size="small" danger></Button>
</Popconfirm>
</Space>
),
},
]
const keys = data ?? []
return (
<div>
<div style={{ marginBottom: 8 }}>
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => { addKeyForm.resetFields(); setAddKeyOpen(true) }}>
</Button>
</div>
<Table<ProviderKey>
columns={keyColumns}
dataSource={keys}
loading={isLoading}
rowKey="id"
size="small"
pagination={false}
/>
<Modal
title="添加密钥"
open={addKeyOpen}
onOk={() => {
addKeyForm.validateFields().then((v) => addKeyMutation.mutate(v))
}}
onCancel={() => setAddKeyOpen(false)}
confirmLoading={addKeyMutation.isPending}
>
<Form form={addKeyForm} layout="vertical">
<Form.Item name="key_label" label="标签" rules={[{ required: true }]}>
<Input placeholder="如: my-openai-key" />
</Form.Item>
<Form.Item name="key_value" label="API Key" rules={[{ required: true }]}>
<Input.Password placeholder="sk-..." />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="priority" label="优先级" initialValue={0} style={{ flex: 1 }}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="max_rpm" label="最大 RPM" style={{ flex: 1 }}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="max_tpm" label="最大 TPM" style={{ flex: 1 }}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</div>
</Form>
</Modal>
</div>
)
}
// ============================================================
// 主页面: 模型服务
// ============================================================
export default function ModelServices() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['providers'],
queryFn: ({ signal }) => providerService.list(signal),
})
const createMutation = useMutation({
mutationFn: (data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>) =>
providerService.create(data),
onSuccess: () => {
message.success('服务商已创建')
queryClient.invalidateQueries({ queryKey: ['providers'] })
setModalOpen(false)
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>> }) =>
providerService.update(id, data),
onSuccess: () => {
message.success('服务商已更新')
queryClient.invalidateQueries({ queryKey: ['providers'] })
setModalOpen(false)
},
onError: (err: Error) => message.error(err.message || '更新失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => providerService.delete(id),
onSuccess: () => {
message.success('服务商已删除')
queryClient.invalidateQueries({ queryKey: ['providers'] })
},
onError: (err: Error) => message.error(err.message || '删除失败'),
})
const handleSave = async () => {
const values = await form.validateFields()
if (editingId) {
updateMutation.mutate({ id: editingId, data: values })
} else {
createMutation.mutate(values)
}
}
const columns: ProColumns<Provider>[] = [
{ title: '名称', dataIndex: 'display_name', width: 150 },
{ title: '标识', dataIndex: 'name', width: 120, render: (_, r) => <Text code>{r.name}</Text> },
{ title: 'Base URL', dataIndex: 'base_url', width: 260, ellipsis: true },
{ title: '协议', dataIndex: 'api_protocol', width: 90, hideInSearch: true },
{ title: 'RPM', dataIndex: 'rate_limit_rpm', width: 80, hideInSearch: true, render: (_, r) => r.rate_limit_rpm ?? '-' },
{
title: '状态', dataIndex: 'enabled', width: 70, hideInSearch: true,
render: (_, r) => r.enabled ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: '操作', width: 140, hideInSearch: true,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}></Button>
<Popconfirm title="确定删除此服务商?" onConfirm={() => deleteMutation.mutate(record.id)}>
<Button size="small" danger></Button>
</Popconfirm>
</Space>
),
},
]
return (
<div>
<ProTable<Provider>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={{}}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>
</Button>,
]}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
expandable={{
expandedRowRender: (record) => (
<Tabs
size="small"
style={{ marginTop: 8 }}
items={[
{
key: 'models',
label: `模型`,
children: <ProviderModelsTable providerId={record.id} />,
},
{
key: 'keys',
label: 'Key Pool',
children: <ProviderKeysTable providerId={record.id} />,
},
]}
/>
),
}}
/>
<Modal
title={editingId? '编辑服务商' : '新建服务商'}
open={modalOpen}
onOk={handleSave}
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
confirmLoading={createMutation.isPending || updateMutation.isPending}
width={560}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="标识" rules={[{ required: true }]}>
<Input disabled={!!editingId} placeholder="如 openai, anthropic" />
</Form.Item>
<Form.Item name="display_name" label="显示名称" rules={[{ required: true }]}>
<Input placeholder="如 OpenAI" />
</Form.Item>
<Form.Item name="base_url" label="Base URL" rules={[{ required: true }]}>
<Input placeholder="https://api.openai.com/v1" />
</Form.Item>
<Form.Item name="api_protocol" label="API 协议">
<Input placeholder="openai" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="enabled" label="启用" valuePropName="checked" style={{ flex: 1 }}>
<Switch />
</Form.Item>
<Form.Item name="rate_limit_rpm" label="RPM 限制" style={{ flex: 1 }}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</div>
</Form>
</Modal>
</div>
)
}

View File

@@ -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>
)
}

View File

@@ -1,146 +0,0 @@
// ============================================================
// 中转任务
// ============================================================
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Tag, Select } from 'antd'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { relayService } from '@/services/relay'
import { PageHeader } from '@/components/PageHeader'
import { ErrorState } from '@/components/ErrorState'
import type { RelayTask } from '@/types'
const statusLabels: Record<string, string> = {
queued: '排队中',
running: '运行中',
completed: '已完成',
failed: '失败',
cancelled: '已取消',
}
const statusColors: Record<string, string> = {
queued: 'default',
running: 'processing',
completed: 'green',
failed: 'red',
cancelled: 'default',
}
export default function Relay() {
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined)
const [page, setPage] = useState(1)
const {
data,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ['relay-tasks', page, statusFilter],
queryFn: ({ signal }) => relayService.list({ page, page_size: 20, status: statusFilter }, signal),
})
if (error) {
return (
<>
<PageHeader title="中转任务" description="查看和管理 AI 模型中转请求" />
<ErrorState message={(error as Error).message} onRetry={() => refetch()} />
</>
)
}
const columns: ProColumns<RelayTask>[] = [
{
title: 'ID',
dataIndex: 'id',
width: 120,
render: (_, r) => (
<code className="text-xs px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800">
{r.id.substring(0, 8)}...
</code>
),
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (_, r) => (
<Tag color={statusColors[r.status] || 'default'}>
{statusLabels[r.status] || r.status}
</Tag>
),
},
{ title: '模型', dataIndex: 'model_id', width: 160 },
{ title: '优先级', dataIndex: 'priority', width: 70 },
{ title: '尝试次数', dataIndex: 'attempt_count', width: 80 },
{
title: 'Token (入/出)',
width: 140,
render: (_, r) => (
<span className="text-sm">
{r.input_tokens.toLocaleString()} / {r.output_tokens.toLocaleString()}
</span>
),
},
{ title: '错误信息', dataIndex: 'error_message', width: 200, ellipsis: true },
{
title: '排队时间',
dataIndex: 'queued_at',
width: 180,
render: (_, r) => new Date(r.queued_at).toLocaleString('zh-CN'),
},
{
title: '完成时间',
dataIndex: 'completed_at',
width: 180,
render: (_, r) => (r.completed_at ? new Date(r.completed_at).toLocaleString('zh-CN') : '-'),
},
]
return (
<div>
<PageHeader
title="中转任务"
description="查看和管理 AI 模型中转请求"
actions={
<Select
value={statusFilter}
onChange={(v) => {
setStatusFilter(v === 'all' ? undefined : v)
setPage(1)
}}
placeholder="状态筛选"
className="w-36"
allowClear
options={[
{ value: 'all', label: '全部' },
{ value: 'queued', label: '排队中' },
{ value: 'running', label: '运行中' },
{ value: 'completed', label: '已完成' },
{ value: 'failed', label: '失败' },
{ value: 'cancelled', label: '已取消' },
]}
/>
}
/>
<ProTable<RelayTask>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={false}
pagination={{
total: data?.total ?? 0,
pageSize: 20,
current: page,
onChange: setPage,
showSizeChanger: false,
}}
/>
</div>
)
}

View File

@@ -1,509 +0,0 @@
// ============================================================
// 角色与权限模板管理
// ============================================================
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>
)
}

View File

@@ -1,397 +0,0 @@
// ============================================================
// 定时任务 — 管理页面
// ============================================================
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>
)
}

View File

@@ -1,358 +0,0 @@
// ============================================================
// 用量统计 + 转化漏斗
// ============================================================
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Card, Col, Row, Select, Statistic } from 'antd'
import { ThunderboltOutlined, ColumnWidthOutlined, UserOutlined, TeamOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
FunnelChart, Funnel, LabelList,
} from 'recharts'
import { telemetryService } from '@/services/telemetry'
import { statsService } from '@/services/stats'
import { PageHeader } from '@/components/PageHeader'
import { ErrorState } from '@/components/ErrorState'
import type { DailyUsageStat, ModelUsageStat } from '@/types'
// ─── Conversion Funnel Data ───
interface FunnelStep {
name: string
value: number
fill: string
}
function buildFunnelData(
totalAccounts: number,
activeAccounts: number,
dailyData?: DailyUsageStat[],
modelData?: ModelUsageStat[],
): FunnelStep[] {
const activeDevicesToday = dailyData?.length
? dailyData.reduce((s, d) => s + d.unique_devices, 0)
: 0
const activeModels = modelData?.filter((m) => m.request_count > 0).length ?? 0
return [
{ name: '注册用户', value: totalAccounts, fill: '#8c8c8c' },
{ name: '活跃用户', value: activeAccounts, fill: '#863bff' },
{ name: '今日使用', value: Math.max(activeDevicesToday, 0), fill: '#47bfff' },
{ name: '使用多模型', value: activeModels, fill: '#10b981' },
]
}
// ─── Daily Trend Bar Data ───
interface DailyTrend {
day: string
requests: number
inputTokens: number
outputTokens: number
}
function buildDailyTrend(data?: DailyUsageStat[]): DailyTrend[] {
if (!data) return []
return data.map((d) => ({
day: d.day.slice(5), // MM-DD
requests: d.request_count,
inputTokens: Math.round(d.input_tokens / 1000), // K tokens
outputTokens: Math.round(d.output_tokens / 1000),
}))
}
// ─── Main Component ───
export default function Usage() {
const [days, setDays] = useState(30)
const {
data: dailyData,
isLoading: dailyLoading,
error: dailyError,
refetch,
} = useQuery({
queryKey: ['usage-daily', days],
queryFn: ({ signal }) => telemetryService.dailyStats({ days }, signal),
})
const { data: modelData, isLoading: modelLoading } = useQuery({
queryKey: ['usage-model', days],
queryFn: ({ signal }) => telemetryService.modelStats({}, signal),
})
const { data: dashboardStats } = useQuery({
queryKey: ['stats-dashboard'],
queryFn: ({ signal }) => statsService.dashboard(signal),
})
if (dailyError) {
return (
<>
<PageHeader title="用量统计" description="查看模型使用情况和 Token 消耗" />
<ErrorState message={(dailyError as Error).message} onRetry={() => refetch()} />
</>
)
}
const totalRequests = dailyData?.reduce((s, d) => s + d.request_count, 0) ?? 0
const totalTokens = dailyData?.reduce((s, d) => s + d.input_tokens + d.output_tokens, 0) ?? 0
const totalAccounts = dashboardStats?.total_accounts ?? 0
const activeAccounts = dashboardStats?.active_accounts ?? 0
const funnelData = buildFunnelData(totalAccounts, activeAccounts, dailyData, modelData)
const trendData = buildDailyTrend(dailyData)
const dailyColumns: ProColumns<DailyUsageStat>[] = [
{ title: '日期', dataIndex: 'day', width: 120 },
{
title: '请求数',
dataIndex: 'request_count',
width: 100,
render: (_, r) => r.request_count.toLocaleString(),
},
{
title: '输入 Token',
dataIndex: 'input_tokens',
width: 120,
render: (_, r) => r.input_tokens.toLocaleString(),
},
{
title: '输出 Token',
dataIndex: 'output_tokens',
width: 120,
render: (_, r) => r.output_tokens.toLocaleString(),
},
{ title: '设备数', dataIndex: 'unique_devices', width: 80 },
]
const modelColumns: ProColumns<ModelUsageStat>[] = [
{ title: '模型', dataIndex: 'model_id', width: 200 },
{
title: '请求数',
dataIndex: 'request_count',
width: 100,
render: (_, r) => r.request_count.toLocaleString(),
},
{
title: '输入 Token',
dataIndex: 'input_tokens',
width: 120,
render: (_, r) => r.input_tokens.toLocaleString(),
},
{
title: '输出 Token',
dataIndex: 'output_tokens',
width: 120,
render: (_, r) => r.output_tokens.toLocaleString(),
},
{
title: '平均延迟',
dataIndex: 'avg_latency_ms',
width: 100,
render: (_, r) => (r.avg_latency_ms ? `${Math.round(r.avg_latency_ms)}ms` : '-'),
},
{
title: '成功率',
dataIndex: 'success_rate',
width: 100,
render: (_, r) => `${(r.success_rate * 100).toFixed(1)}%`,
},
]
return (
<div>
<PageHeader
title="用量统计"
description="查看模型使用情况、Token 消耗和用户转化"
actions={
<Select
value={days}
onChange={setDays}
options={[
{ value: 7, label: '最近 7 天' },
{ value: 30, label: '最近 30 天' },
{ value: 90, label: '最近 90 天' },
]}
className="w-36"
/>
}
/>
{/* Summary Cards */}
<Row gutter={[16, 16]} className="mb-6">
<Col xs={24} sm={12} md={6}>
<Card className="hover:shadow-md transition-shadow duration-200">
<Statistic
title={
<span className="text-neutral-500 dark:text-neutral-400 text-xs font-medium uppercase tracking-wide">
</span>
}
value={totalRequests}
prefix={<ThunderboltOutlined style={{ color: '#863bff' }} />}
valueStyle={{ fontWeight: 600, color: '#863bff' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card className="hover:shadow-md transition-shadow duration-200">
<Statistic
title={
<span className="text-neutral-500 dark:text-neutral-400 text-xs font-medium uppercase tracking-wide">
Token
</span>
}
value={totalTokens}
prefix={<ColumnWidthOutlined style={{ color: '#47bfff' }} />}
valueStyle={{ fontWeight: 600, color: '#47bfff' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card className="hover:shadow-md transition-shadow duration-200">
<Statistic
title={
<span className="text-neutral-500 dark:text-neutral-400 text-xs font-medium uppercase tracking-wide">
</span>
}
value={totalAccounts}
prefix={<UserOutlined style={{ color: '#10b981' }} />}
valueStyle={{ fontWeight: 600, color: '#10b981' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card className="hover:shadow-md transition-shadow duration-200">
<Statistic
title={
<span className="text-neutral-500 dark:text-neutral-400 text-xs font-medium uppercase tracking-wide">
</span>
}
value={activeAccounts}
prefix={<TeamOutlined style={{ color: '#f59e0b' }} />}
valueStyle={{ fontWeight: 600, color: '#f59e0b' }}
/>
</Card>
</Col>
</Row>
{/* Conversion Funnel + Daily Trend */}
<Row gutter={[16, 16]} className="mb-6">
<Col xs={24} lg={10}>
<Card
title={
<span className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
</span>
}
size="small"
>
<ResponsiveContainer width="100%" height={260}>
<FunnelChart>
<Tooltip
formatter={(value: number) => [value.toLocaleString(), '数量']}
/>
<Funnel
dataKey="value"
data={funnelData}
isAnimationActive
>
<LabelList
position="right"
dataKey="name"
fill="#555"
stroke="none"
fontSize={12}
/>
</Funnel>
</FunnelChart>
</ResponsiveContainer>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card
title={
<span className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
</span>
}
size="small"
>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={trendData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="day" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip
formatter={(value: number, name: string) => {
const labels: Record<string, string> = {
requests: '请求数',
inputTokens: '输入 Token(K)',
outputTokens: '输出 Token(K)',
}
return [value.toLocaleString(), labels[name] ?? name]
}}
/>
<Bar dataKey="requests" fill="#863bff" radius={[4, 4, 0, 0]} barSize={8} />
<Bar dataKey="inputTokens" fill="#47bfff" radius={[4, 4, 0, 0]} barSize={8} />
<Bar dataKey="outputTokens" fill="#10b981" radius={[4, 4, 0, 0]} barSize={8} />
</BarChart>
</ResponsiveContainer>
</Card>
</Col>
</Row>
{/* Daily Stats */}
<Card
title={
<span className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
</span>
}
className="mb-6"
size="small"
styles={{ body: { padding: 0 } }}
>
<ProTable<DailyUsageStat>
columns={dailyColumns}
dataSource={dailyData ?? []}
loading={dailyLoading}
rowKey="day"
search={false}
toolBarRender={false}
pagination={false}
size="small"
/>
</Card>
{/* Model Stats */}
<Card
title={
<span className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
</span>
}
size="small"
styles={{ body: { padding: 0 } }}
>
<ProTable<ModelUsageStat>
columns={modelColumns}
dataSource={modelData ?? []}
loading={modelLoading}
rowKey="model_id"
search={false}
toolBarRender={false}
pagination={false}
size="small"
/>
</Card>
</div>
)
}

View File

@@ -1,71 +0,0 @@
// ============================================================
// ZCLAW Admin V2 — Auth Guard with session restore
// ============================================================
//
// Auth strategy:
// 1. On first mount, always validate the HttpOnly cookie via GET /auth/me
// 2. If cookie valid -> restore session and render children
// 3. If cookie invalid -> clean up and redirect to /login
// 4. If already authenticated (from login flow) -> render immediately
//
// This eliminates the race condition where localStorage had account data
// but the HttpOnly cookie was expired, causing children to render and
// make failing API calls.
import { useEffect, useRef, useState } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { Spin } from 'antd'
import { useAuthStore } from '@/stores/authStore'
import { authService } from '@/services/auth'
type GuardState = 'checking' | 'authenticated' | 'unauthenticated'
export function AuthGuard({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const login = useAuthStore((s) => s.login)
const logout = useAuthStore((s) => s.logout)
const location = useLocation()
// Track validation attempt to avoid double-calling (React StrictMode)
const validated = useRef(false)
const [guardState, setGuardState] = useState<GuardState>(
isAuthenticated ? 'authenticated' : 'checking'
)
useEffect(() => {
// Already authenticated from login flow — skip validation
if (isAuthenticated) {
setGuardState('authenticated')
return
}
// Prevent double-validation in React StrictMode
if (validated.current) return
validated.current = true
// Validate HttpOnly cookie via /auth/me
authService.me()
.then((meAccount) => {
login(meAccount)
setGuardState('authenticated')
})
.catch(() => {
logout()
setGuardState('unauthenticated')
})
}, []) // eslint-disable-line react-hooks/exhaustive-deps
if (guardState === 'checking') {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
)
}
if (guardState === 'unauthenticated') {
return <Navigate to="/login" state={{ from: location }} replace />
}
return <>{children}</>
}

View File

@@ -1,42 +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: 'roles', lazy: () => import('@/pages/Roles').then((m) => ({ Component: m.default })) },
{ path: 'model-services', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
{ path: 'providers', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
{ path: 'models', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
{ path: 'agent-templates', lazy: () => import('@/pages/AgentTemplates').then((m) => ({ Component: m.default })) },
{ path: 'api-keys', lazy: () => import('@/pages/ApiKeys').then((m) => ({ Component: m.default })) },
{ path: 'usage', lazy: () => import('@/pages/Usage').then((m) => ({ Component: m.default })) },
{ path: 'billing', lazy: () => import('@/pages/Billing').then((m) => ({ Component: m.default })) },
{ path: 'relay', lazy: () => import('@/pages/Relay').then((m) => ({ Component: m.default })) },
{ path: 'scheduled-tasks', lazy: () => import('@/pages/ScheduledTasks').then((m) => ({ Component: m.default })) },
{ path: 'knowledge', lazy: () => import('@/pages/Knowledge').then((m) => ({ Component: m.default })) },
{ path: 'config', lazy: () => import('@/pages/Config').then((m) => ({ Component: m.default })) },
{ path: 'prompts', lazy: () => import('@/pages/Prompts').then((m) => ({ Component: m.default })) },
{ path: 'logs', lazy: () => import('@/pages/Logs').then((m) => ({ Component: m.default })) },
{ path: 'config-sync', lazy: () => import('@/pages/ConfigSync').then((m) => ({ Component: m.default })) },
{ path: 'industries', lazy: () => import('@/pages/Industries').then((m) => ({ Component: m.default })) },
],
},
])

View File

@@ -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),
}

View File

@@ -1,31 +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),
create: (data: {
name: string; description?: string; category?: string; source?: string
model?: string; system_prompt?: string; tools?: string[]
capabilities?: string[]; temperature?: number; max_tokens?: number
visibility?: string; emoji?: string; personality?: string
soul_content?: string; welcome_message?: string
communication_style?: string; source_id?: string
scenarios?: string[]
quick_commands?: Array<{ label: string; command: string }>
}, signal?: AbortSignal) =>
request.post<AgentTemplate>('/agent-templates', data, withSignal({}, signal)).then((r) => r.data),
update: (id: string, data: {
description?: string; model?: string; system_prompt?: string
tools?: string[]; capabilities?: string[]; temperature?: number
max_tokens?: number; visibility?: string; status?: string
}, signal?: AbortSignal) =>
request.post<AgentTemplate>(`/agent-templates/${id}`, data, withSignal({}, signal)).then((r) => r.data),
archive: (id: string, signal?: AbortSignal) =>
request.delete<AgentTemplate>(`/agent-templates/${id}`, withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -1,15 +0,0 @@
import request, { withSignal } from './request'
import type { TokenInfo, CreateTokenRequest, PaginatedResponse } from '@/types'
// 使用 /tokens 路由 (api_tokens 表),前端 UI 字段 {name, expires_days, permissions} 与此后端匹配
// 注: /keys 路由 (account_api_keys 表) 需要 {provider_id, key_value},属于不同的 Key 管理系统
export const apiKeyService = {
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
request.get<PaginatedResponse<TokenInfo>>('/tokens', withSignal({ params }, signal)).then((r) => r.data),
create: (data: CreateTokenRequest, signal?: AbortSignal) =>
request.post<TokenInfo>('/tokens', data, withSignal({}, signal)).then((r) => r.data),
revoke: (id: string, signal?: AbortSignal) =>
request.delete(`/tokens/${id}`, withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -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),
}

View File

@@ -1,98 +0,0 @@
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),
}

View File

@@ -1,7 +0,0 @@
import request, { withSignal } from './request'
import type { ConfigSyncLog, PaginatedResponse } from '@/types'
export const configSyncService = {
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
request.get<PaginatedResponse<ConfigSyncLog>>('/config/sync-logs', withSignal({ params }, signal)).then((r) => r.data),
}

View File

@@ -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.put<ConfigItem>(`/config/items/${id}`, data, withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -1,105 +0,0 @@
// ============================================================
// 行业配置 API 服务层
// ============================================================
import request, { withSignal } from './request'
import type { PaginatedResponse } from '@/types'
import type { IndustryInfo, AccountIndustryItem } from '@/types'
/** 行业列表项(列表接口返回) */
export interface IndustryListItem {
id: string
name: string
icon: string
description: string
status: string
source: string
keywords_count: number
created_at: string
updated_at: string
}
/** 行业完整配置含关键词、prompt 等) */
export interface IndustryFullConfig {
id: string
name: string
icon: string
description: string
status: string
source: string
keywords: string[]
system_prompt: string
cold_start_template: string
pain_seed_categories: string[]
skill_priorities: Array<{ skill_id: string; priority: number }>
created_at: string
updated_at: string
}
/** 创建行业请求 */
export interface CreateIndustryRequest {
id?: string
name: string
icon: string
description: string
keywords?: string[]
system_prompt?: string
cold_start_template?: string
pain_seed_categories?: string[]
}
/** 更新行业请求 */
export interface UpdateIndustryRequest {
name?: string
icon?: string
description?: string
status?: string
keywords?: string[]
system_prompt?: string
cold_start_template?: string
pain_seed_categories?: string[]
skill_priorities?: Array<{ skill_id: string; priority: number }>
}
/** 设置用户行业请求 */
export interface SetAccountIndustriesRequest {
industries: Array<{
industry_id: string
is_primary: boolean
}>
}
export const industryService = {
/** 行业列表 */
list: (params?: { page?: number; page_size?: number; status?: string }, signal?: AbortSignal) =>
request.get<PaginatedResponse<IndustryListItem>>('/industries', withSignal({ params }, signal))
.then((r) => r.data),
/** 行业详情 */
get: (id: string, signal?: AbortSignal) =>
request.get<IndustryInfo>(`/industries/${id}`, withSignal({}, signal))
.then((r) => r.data),
/** 行业完整配置 */
getFullConfig: (id: string, signal?: AbortSignal) =>
request.get<IndustryFullConfig>(`/industries/${id}/full-config`, withSignal({}, signal))
.then((r) => r.data),
/** 创建行业 */
create: (data: CreateIndustryRequest) =>
request.post<IndustryInfo>('/industries', data).then((r) => r.data),
/** 更新行业 */
update: (id: string, data: UpdateIndustryRequest) =>
request.patch<IndustryInfo>(`/industries/${id}`, data).then((r) => r.data),
/** 获取用户授权行业 */
getAccountIndustries: (accountId: string, signal?: AbortSignal) =>
request.get<AccountIndustryItem[]>(`/accounts/${accountId}/industries`, withSignal({}, signal))
.then((r) => r.data),
/** 设置用户授权行业 */
setAccountIndustries: (accountId: string, data: SetAccountIndustriesRequest) =>
request.put<AccountIndustryItem[]>(`/accounts/${accountId}/industries`, data)
.then((r) => r.data),
}

View File

@@ -1,208 +0,0 @@
import request, { withSignal } from './request'
// === Types ===
export interface CategoryResponse {
id: string
name: string
description: string | null
parent_id: string | null
icon: string | null
sort_order: number
item_count: number
children: CategoryResponse[]
created_at: string
updated_at: string
}
export interface KnowledgeItem {
id: string
category_id: string
title: string
content: string
keywords: string[]
related_questions: string[]
priority: number
status: string
version: number
source: string
tags: string[]
created_by: string
created_at: string
updated_at: string
}
export interface SearchResult {
chunk_id: string
item_id: string
item_title: string
category_name: string
content: string
score: number
keywords: string[]
}
export interface AnalyticsOverview {
total_items: number
active_items: number
total_categories: number
weekly_new_items: number
total_references: number
avg_reference_per_item: number
hit_rate: number
injection_rate: number
positive_feedback_rate: number
stale_items_count: number
}
export interface ListItemsResponse {
items: KnowledgeItem[]
total: number
page: number
page_size: number
}
// === Structured Data Sources ===
export interface StructuredSource {
id: string
account_id: string
name: string
source_type: string
row_count: number
columns: string[]
created_at: string
updated_at: string
}
export interface StructuredRow {
id: string
source_id: string
row_data: Record<string, unknown>
created_at: string
}
export interface StructuredQueryResult {
row_id: string
source_name: string
row_data: Record<string, unknown>
score: number
}
// === Service ===
export const knowledgeService = {
// 分类
listCategories: (signal?: AbortSignal) =>
request.get<CategoryResponse[]>('/knowledge/categories', withSignal({}, signal))
.then((r) => r.data),
createCategory: (data: { name: string; description?: string; parent_id?: string; icon?: string }) =>
request.post('/knowledge/categories', data).then((r) => r.data),
deleteCategory: (id: string) =>
request.delete(`/knowledge/categories/${id}`).then((r) => r.data),
updateCategory: (id: string, data: { name?: string; description?: string; parent_id?: string; icon?: string }) =>
request.put(`/knowledge/categories/${id}`, data).then((r) => r.data),
reorderCategories: (items: Array<{ id: string; sort_order: number }>) =>
request.patch('/knowledge/categories/reorder', { items }).then((r) => r.data),
getCategoryItems: (id: string, params?: { page?: number; page_size?: number; status?: string }, signal?: AbortSignal) =>
request.get<ListItemsResponse>(`/knowledge/categories/${id}/items`, withSignal({ params }, signal))
.then((r) => r.data),
// 条目
listItems: (params: { page?: number; page_size?: number; category_id?: string; status?: string; keyword?: string }, signal?: AbortSignal) =>
request.get<ListItemsResponse>('/knowledge/items', withSignal({ params }, signal))
.then((r) => r.data),
getItem: (id: string, signal?: AbortSignal) =>
request.get<KnowledgeItem>(`/knowledge/items/${id}`, withSignal({}, signal))
.then((r) => r.data),
createItem: (data: {
category_id: string
title: string
content: string
keywords?: string[]
related_questions?: string[]
priority?: number
tags?: string[]
}) => request.post('/knowledge/items', data).then((r) => r.data),
updateItem: (id: string, data: Record<string, unknown>) =>
request.put(`/knowledge/items/${id}`, data).then((r) => r.data),
deleteItem: (id: string) =>
request.delete(`/knowledge/items/${id}`).then((r) => r.data),
batchCreate: (items: Array<{
category_id: string
title: string
content: string
keywords?: string[]
tags?: string[]
}>) => request.post('/knowledge/items/batch', items).then((r) => r.data),
// 搜索
search: (data: { query: string; category_id?: string; limit?: number }) =>
request.post<SearchResult[]>('/knowledge/search', data).then((r) => r.data),
// 分析
getOverview: (signal?: AbortSignal) =>
request.get<AnalyticsOverview>('/knowledge/analytics/overview', withSignal({}, signal))
.then((r) => r.data),
getTrends: (signal?: AbortSignal) =>
request.get('/knowledge/analytics/trends', withSignal({}, signal))
.then((r) => r.data),
getTopItems: (signal?: AbortSignal) =>
request.get('/knowledge/analytics/top-items', withSignal({}, signal))
.then((r) => r.data),
getQuality: (signal?: AbortSignal) =>
request.get('/knowledge/analytics/quality', withSignal({}, signal))
.then((r) => r.data),
getGaps: (signal?: AbortSignal) =>
request.get('/knowledge/analytics/gaps', withSignal({}, signal))
.then((r) => r.data),
// 版本
getVersions: (itemId: string, signal?: AbortSignal) =>
request.get(`/knowledge/items/${itemId}/versions`, withSignal({}, signal))
.then((r) => r.data),
rollbackVersion: (itemId: string, version: number) =>
request.post(`/knowledge/items/${itemId}/rollback/${version}`).then((r) => r.data),
// 推荐搜索
recommend: (data: { query: string; category_id?: string; limit?: number }) =>
request.post<SearchResult[]>('/knowledge/recommend', data).then((r) => r.data),
// 导入
importItems: (data: { category_id: string; files: Array<{ content: string; title?: string; keywords?: string[]; tags?: string[] }> }) =>
request.post('/knowledge/items/import', data).then((r) => r.data),
// === Structured Data Sources ===
listStructuredSources: (signal?: AbortSignal) =>
request.get<StructuredSource[]>('/structured/sources', withSignal({}, signal))
.then((r) => r.data),
getStructuredSource: (id: string, signal?: AbortSignal) =>
request.get<StructuredSource>(`/structured/sources/${id}`, withSignal({}, signal))
.then((r) => r.data),
deleteStructuredSource: (id: string) =>
request.delete(`/structured/sources/${id}`).then((r) => r.data),
listStructuredRows: (sourceId: string, signal?: AbortSignal) =>
request.get<StructuredRow[]>(`/structured/sources/${sourceId}/rows`, withSignal({}, signal))
.then((r) => r.data),
queryStructured: (data: { source_id?: string; query?: string; limit?: number }) =>
request.post<StructuredQueryResult[]>('/structured/query', data).then((r) => r.data),
}

View File

@@ -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),
}

View File

@@ -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),
}

View File

@@ -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),
}

View File

@@ -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),
}

View File

@@ -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),
}

View File

@@ -1,128 +0,0 @@
// ============================================================
// ZCLAW Admin V2 — Axios 实例 + 认证拦截器
// ============================================================
//
// 认证策略: HttpOnly cookie浏览器自动附加到同域请求
// 所有 token 均通过 cookie 传递,前端 JS 无法读取。
// withCredentials: true 确保浏览器发送 HttpOnly cookie。
import axios from 'axios'
import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
import type { AxiosRequestConfig } from 'axios'
import type { ApiError } from '@/types'
import { useAuthStore } from '@/stores/authStore'
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1'
const TIMEOUT_MS = 30_000
/** API 业务错误 */
export class ApiRequestError extends Error {
constructor(
public status: number,
public body: ApiError,
) {
super(body.message || `Request failed with status ${status}`)
this.name = 'ApiRequestError'
}
}
const request = axios.create({
baseURL: BASE_URL,
timeout: TIMEOUT_MS,
headers: { 'Content-Type': 'application/json' },
withCredentials: true, // 发送 HttpOnly cookies
})
// ── 响应拦截器401 自动刷新 cookie ──────────────────────
let isRefreshing = false
let pendingRequests: Array<{
resolve: (value: unknown) => void
reject: (error: unknown) => void
}> = []
function onTokenRefreshed() {
pendingRequests.forEach(({ resolve }) => resolve(undefined))
pendingRequests = []
}
function onTokenRefreshFailed(error: unknown) {
pendingRequests.forEach(({ reject }) => reject(error))
pendingRequests = []
}
request.interceptors.response.use(
(response) => response,
async (error: AxiosError<{ error?: string; message?: string }>) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
// 401 -> 尝试刷新 cookie
if (error.response?.status === 401 && !originalRequest._retry) {
const store = useAuthStore.getState()
if (!store.isAuthenticated) {
store.logout()
window.location.href = '/login'
return Promise.reject(error)
}
if (isRefreshing) {
return new Promise((resolve, reject) => {
pendingRequests.push({
resolve: () => resolve(request(originalRequest)),
reject,
})
})
}
originalRequest._retry = true
isRefreshing = true
try {
// Refresh endpoint uses HttpOnly cookie (sent automatically via withCredentials)
await axios.post(`${BASE_URL}/auth/refresh`, null, {
withCredentials: true,
})
// Cookie is refreshed server-side; browser has the new cookie automatically
onTokenRefreshed()
return request(originalRequest)
} catch (refreshError) {
// Refresh failed — reject all pending requests to prevent hangs
onTokenRefreshFailed(refreshError)
store.logout()
window.location.href = '/login'
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
}
// 构造 ApiRequestError
if (error.response) {
const body: ApiError = {
error: error.response.data?.error || 'unknown',
message: error.response.data?.message || `请求失败 (${error.response.status})`,
status: error.response.status,
}
return Promise.reject(new ApiRequestError(error.response.status, body))
}
// 网络错误统一包装为 ApiRequestError
return Promise.reject(
new ApiRequestError(0, {
error: 'network_error',
message: error.message || '网络连接失败,请检查网络后重试',
status: 0,
})
)
},
)
export default request
/** 将 AbortSignal 注入 Axios config用于 TanStack Query 的请求取消 */
export function withSignal(config: AxiosRequestConfig = {}, signal?: AbortSignal): AxiosRequestConfig {
if (signal) {
return { ...config, signal }
}
return config
}

View File

@@ -1,40 +0,0 @@
import request, { withSignal } from './request'
import type {
Role,
PermissionTemplate,
CreateRoleRequest,
UpdateRoleRequest,
CreateTemplateRequest,
} from '@/types'
export const roleService = {
// ── Roles ─────────────────────────────────────────────────
list: (signal?: AbortSignal) =>
request.get<Role[]>('/roles', withSignal({}, signal)).then((r) => r.data),
create: (data: CreateRoleRequest, signal?: AbortSignal) =>
request.post<Role>('/roles', data, withSignal({}, signal)).then((r) => r.data),
update: (id: string, data: UpdateRoleRequest, signal?: AbortSignal) =>
request.put<Role>(`/roles/${id}`, data, withSignal({}, signal)).then((r) => r.data),
delete: (id: string, signal?: AbortSignal) =>
request.delete(`/roles/${id}`, withSignal({}, signal)).then((r) => r.data),
// ── Role Permissions ──────────────────────────────────────
getPermissions: (roleId: string, signal?: AbortSignal) =>
request.get<string[]>(`/roles/${roleId}/permissions`, withSignal({}, signal)).then((r) => r.data),
// ── Permission Templates ──────────────────────────────────
listTemplates: (signal?: AbortSignal) =>
request.get<PermissionTemplate[]>('/permission-templates', withSignal({}, signal)).then((r) => r.data),
createTemplate: (data: CreateTemplateRequest, signal?: AbortSignal) =>
request.post<PermissionTemplate>('/permission-templates', data, withSignal({}, signal)).then((r) => r.data),
deleteTemplate: (id: string, signal?: AbortSignal) =>
request.delete(`/permission-templates/${id}`, withSignal({}, signal)).then((r) => r.data),
applyTemplate: (templateId: string, accountIds: string[], signal?: AbortSignal) =>
request.post(`/permission-templates/${templateId}/apply`, { account_ids: accountIds }, withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -1,71 +0,0 @@
// ============================================================
// 定时任务 — Service
// ============================================================
import request, { withSignal } from './request'
// === Types ===
export interface TaskTarget {
type: string // "agent" | "hand" | "workflow"
id: string
}
export interface ScheduledTask {
id: string
name: string
schedule: string
schedule_type: string // "cron" | "interval" | "once"
target: TaskTarget
enabled: boolean
description: string | null
last_run: string | null
next_run: string | null
run_count: number
last_result: string | null
last_error: string | null
last_duration_ms: number | null
created_at: string
}
export interface CreateScheduledTaskRequest {
name: string
schedule: string
schedule_type?: string
target: TaskTarget
description?: string
enabled?: boolean
}
export interface UpdateScheduledTaskRequest {
name?: string
schedule?: string
schedule_type?: string
target?: TaskTarget
description?: string
enabled?: boolean
}
// === Service ===
export const scheduledTaskService = {
list: (signal?: AbortSignal) =>
request.get<ScheduledTask[]>('/scheduler/tasks', withSignal({}, signal))
.then((r) => r.data),
get: (id: string, signal?: AbortSignal) =>
request.get<ScheduledTask>(`/scheduler/tasks/${id}`, withSignal({}, signal))
.then((r) => r.data),
create: (data: CreateScheduledTaskRequest) =>
request.post<ScheduledTask>('/scheduler/tasks', data)
.then((r) => r.data),
update: (id: string, data: UpdateScheduledTaskRequest) =>
request.patch<ScheduledTask>(`/scheduler/tasks/${id}`, data)
.then((r) => r.data),
delete: (id: string) =>
request.delete(`/scheduler/tasks/${id}`)
.then((r) => r.data),
}

View File

@@ -1,7 +0,0 @@
import request, { withSignal } from './request'
import type { DashboardStats } from '@/types'
export const statsService = {
dashboard: (signal?: AbortSignal) =>
request.get<DashboardStats>('/admin/dashboard', withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -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),
}

View File

@@ -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 || []),
}

View File

@@ -1,90 +0,0 @@
// ============================================================
// ZCLAW Admin V2 — Zustand 认证状态管理
// ============================================================
//
// 安全策略: JWT token 通过 HttpOnly cookie 传递,前端 JS 无法读取。
// account 信息(显示名/角色)存 localStorage 用于页面刷新后恢复 UI。
// isAuthenticated 标记用于判断登录状态,不暴露任何 token 到 JS。
import { create } from 'zustand'
import type { AccountPublic } from '@/types'
/** 权限常量 — 与后端 db.rs seed_roles 保持同步 */
const ROLE_PERMISSIONS: Record<string, string[]> = {
super_admin: [
'admin:full', 'account:admin', 'provider:manage', 'model:manage',
'model:read', 'relay:admin', 'relay:use', 'config:write', 'config:read',
'prompt:read', 'prompt:write', 'prompt:publish', 'prompt:admin',
'scheduler:read', 'knowledge:read', 'knowledge:write',
'billing:read', 'billing:write',
],
admin: [
'account:read', 'account:admin', 'provider:manage', 'model:read',
'model:manage', 'relay:use', 'relay:admin', 'config:read',
'config:write', 'prompt:read', 'prompt:write', 'prompt:publish',
'scheduler:read', 'knowledge:read', 'knowledge:write',
'billing:read',
],
user: ['model:read', 'relay:use', 'config:read', 'prompt:read'],
}
const ACCOUNT_KEY = 'zclaw_admin_account'
/** 从 localStorage 恢复 account 信息token 通过 HttpOnly cookie 管理) */
function loadFromStorage(): { account: AccountPublic | null; isAuthenticated: boolean } {
const raw = localStorage.getItem(ACCOUNT_KEY)
let account: AccountPublic | null = null
if (raw) {
try { account = JSON.parse(raw) } catch { /* ignore */ }
}
// IMPORTANT: Do NOT set isAuthenticated = true from localStorage alone.
// The HttpOnly cookie must be validated via GET /auth/me before we trust
// the session. This prevents the AuthGuard race condition where children
// render and make API calls with an expired cookie.
return { account, isAuthenticated: false }
}
interface AuthState {
isAuthenticated: boolean
account: AccountPublic | null
permissions: string[]
login: (account: AccountPublic) => void
logout: () => void
hasPermission: (permission: string) => boolean
}
export const useAuthStore = create<AuthState>((set, get) => {
const stored = loadFromStorage()
const perms = stored.account?.role
? (ROLE_PERMISSIONS[stored.account.role] ?? [])
: []
return {
isAuthenticated: stored.isAuthenticated,
account: stored.account,
permissions: perms,
login: (account: AccountPublic) => {
// account 保留 localStorage仅用于 UI 显示,非敏感)
localStorage.setItem(ACCOUNT_KEY, JSON.stringify(account))
set({
isAuthenticated: true,
account,
permissions: ROLE_PERMISSIONS[account.role] ?? [],
})
},
logout: () => {
localStorage.removeItem(ACCOUNT_KEY)
set({ isAuthenticated: false, account: null, permissions: [] })
// 调用后端 logout 清除 HttpOnly cookiesfire-and-forget
fetch(`${import.meta.env.VITE_API_BASE_URL || '/api/v1'}/auth/logout`, { method: 'POST', credentials: 'include' }).catch(() => {})
},
hasPermission: (permission: string) => {
const { permissions } = get()
return permissions.includes(permission) || permissions.includes('admin:full')
},
}
})

View File

@@ -1,56 +0,0 @@
import { create } from 'zustand'
type ThemeMode = 'light' | 'dark' | 'system'
interface ThemeState {
mode: ThemeMode
resolved: 'light' | 'dark'
}
function getSystemTheme(): 'light' | 'dark' {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function resolveTheme(mode: ThemeMode): 'light' | 'dark' {
return mode === 'system' ? getSystemTheme() : mode
}
function applyTheme(resolved: 'light' | 'dark') {
const html = document.documentElement
html.classList.toggle('dark', resolved === 'dark')
html.setAttribute('data-theme', resolved)
}
function getInitialMode(): ThemeMode {
const stored = localStorage.getItem('zclaw_admin_theme')
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored
return 'system'
}
const initialMode = getInitialMode()
const initialResolved = resolveTheme(initialMode)
applyTheme(initialResolved)
export const useThemeStore = create<ThemeState>(() => ({
mode: initialMode,
resolved: initialResolved,
}))
export function setThemeMode(mode: ThemeMode) {
const resolved = resolveTheme(mode)
localStorage.setItem('zclaw_admin_theme', mode)
applyTheme(resolved)
useThemeStore.setState({ mode, resolved })
}
// Listen for system theme changes
if (typeof window !== 'undefined') {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const { mode } = useThemeStore.getState()
if (mode === 'system') {
const resolved = getSystemTheme()
applyTheme(resolved)
useThemeStore.setState({ resolved })
}
})
}

View File

@@ -1,235 +0,0 @@
@import "tailwindcss";
/* ============================================================
ZCLAW Admin Design Tokens
DeerFlow-inspired warm neutral palette with brand accents
============================================================ */
@theme {
/* Brand Colors */
--color-brand-purple: #863bff;
--color-brand-blue: #47bfff;
--color-brand-gradient: linear-gradient(135deg, #863bff, #47bfff);
/* Neutral (warm stone palette) */
--color-neutral-50: #fafaf9;
--color-neutral-100: #f5f5f4;
--color-neutral-200: #e7e5e4;
--color-neutral-300: #d6d3d1;
--color-neutral-400: #a8a29e;
--color-neutral-500: #78716c;
--color-neutral-600: #57534e;
--color-neutral-700: #44403c;
--color-neutral-800: #292524;
--color-neutral-900: #1c1917;
--color-neutral-950: #0c0a09;
/* Semantic Colors */
--color-success: #22c55e;
--color-success-soft: #dcfce7;
--color-warning: #f59e0b;
--color-warning-soft: #fef3c7;
--color-error: #ef4444;
--color-error-soft: #fee2e2;
--color-info: #3b82f6;
--color-info-soft: #dbeafe;
/* Dark mode neutrals */
--color-dark-bg: #0c0a09;
--color-dark-surface: #1c1917;
--color-dark-card: #292524;
--color-dark-border: #44403c;
--color-dark-text: #fafaf9;
--color-dark-text-secondary: #a8a29e;
/* Spacing */
--spacing-sidebar-expanded: 16rem;
--spacing-sidebar-collapsed: 3rem;
--spacing-header-height: 3.5rem;
/* Border Radius */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
/* Shadows */
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
--shadow-dropdown: 0 4px 16px rgba(0, 0, 0, 0.12);
--shadow-modal: 0 8px 32px rgba(0, 0, 0, 0.16);
/* Typography */
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
/* Transitions */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* ============================================================
Base Styles
============================================================ */
html {
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
background-color: var(--color-neutral-50);
color: var(--color-neutral-900);
transition: background-color var(--transition-normal), color var(--transition-normal);
}
/* Dark mode overrides */
html.dark body {
background-color: var(--color-dark-bg);
color: var(--color-dark-text);
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--color-neutral-300);
border-radius: 3px;
}
html.dark ::-webkit-scrollbar-thumb {
background-color: var(--color-dark-border);
}
/* Focus visible */
:focus-visible {
outline: 2px solid var(--color-brand-purple);
outline-offset: 2px;
border-radius: 4px;
}
/* Skip to content (accessibility) */
.skip-to-content {
position: absolute;
top: -100%;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
padding: 8px 16px;
background: var(--color-brand-purple);
color: white;
border-radius: var(--radius-md);
font-size: 14px;
text-decoration: none;
transition: top var(--transition-fast);
}
.skip-to-content:focus {
top: 8px;
}
/* ============================================================
Ant Design Overrides (Light Mode)
============================================================ */
/* ProTable search area */
.ant-pro-table-search {
background-color: var(--color-neutral-50) !important;
border-bottom: 1px solid var(--color-neutral-200) !important;
}
/* Card styling */
.ant-card {
border-radius: var(--radius-lg) !important;
border: 1px solid var(--color-neutral-200) !important;
box-shadow: var(--shadow-card) !important;
}
.ant-card:hover {
box-shadow: var(--shadow-card-hover) !important;
}
/* Table styling */
.ant-table-wrapper .ant-table-thead > tr > th {
background-color: var(--color-neutral-50) !important;
font-weight: 600 !important;
color: var(--color-neutral-600) !important;
}
/* Modal styling */
.ant-modal .ant-modal-content {
border-radius: var(--radius-lg) !important;
}
/* Tag pill style */
.ant-tag {
border-radius: 9999px !important;
padding: 0 8px !important;
}
/* Form item */
.ant-form-item-label > label {
font-weight: 500 !important;
color: var(--color-neutral-700) !important;
}
/* ============================================================
Dark Mode — Ant Design Overrides
============================================================ */
html.dark .ant-card {
background-color: var(--color-dark-card) !important;
border-color: var(--color-dark-border) !important;
}
html.dark .ant-table-wrapper .ant-table-thead > tr > th {
background-color: var(--color-dark-surface) !important;
color: var(--color-dark-text-secondary) !important;
}
html.dark .ant-table-wrapper .ant-table-tbody > tr > td {
border-color: var(--color-dark-border) !important;
}
html.dark .ant-table-wrapper .ant-table-tbody > tr:hover > td {
background-color: rgba(134, 59, 255, 0.06) !important;
}
html.dark .ant-modal .ant-modal-content {
background-color: var(--color-dark-card) !important;
}
html.dark .ant-modal .ant-modal-header {
background-color: var(--color-dark-card) !important;
}
html.dark .ant-drawer .ant-drawer-content {
background-color: var(--color-dark-surface) !important;
}
html.dark .ant-form-item-label > label {
color: var(--color-dark-text-secondary) !important;
}
html.dark .ant-select-selector,
html.dark .ant-input,
html.dark .ant-input-number {
background-color: var(--color-dark-card) !important;
border-color: var(--color-dark-border) !important;
color: var(--color-dark-text) !important;
}
html.dark .ant-pro-table-search {
background-color: var(--color-dark-surface) !important;
border-color: var(--color-dark-border) !important;
}

View File

@@ -1,365 +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
llm_routing: 'relay' | 'local'
}
/** 登录请求 */
export interface LoginRequest {
username: string
password: string
totp_code?: string
}
/** 登录响应 — tokens 通过 HttpOnly cookie 传递JS 无法读取 */
export interface LoginResponse {
account: AccountPublic
}
/** 注册请求 */
export interface RegisterRequest {
username: string
password: string
email: string
display_name?: string
}
/** 分页响应 */
export interface PaginatedResponse<T> {
items: T[]
total: number
page: number
page_size: number
}
/** 行业配置 */
export interface IndustryInfo {
id: string
name: string
icon: string
description: string
status: string
source: string
keywords?: string[]
system_prompt?: string
cold_start_template?: string
pain_seed_categories?: string[]
created_at: string
updated_at: string
}
/** 用户-行业关联 */
export interface AccountIndustryItem {
industry_id: string
is_primary: boolean
industry_name: string
industry_icon: string
}
/** 服务商 (Provider) */
export interface Provider {
id: string
name: string
display_name: string
api_key?: string
base_url: string
api_protocol: string
enabled: boolean
rate_limit_rpm: number | null
rate_limit_tpm: number | null
created_at: string
updated_at: string
}
/** 模型 */
export interface Model {
id: string
provider_id: string
model_id: string
alias: string
context_window: number
max_output_tokens: number
supports_streaming: boolean
supports_vision: boolean
enabled: boolean
is_embedding: boolean
model_type: string
pricing_input: number
pricing_output: number
}
/** API 密钥信息 */
export interface TokenInfo {
id: string
name: string
token_prefix: string
permissions: string[]
last_used_at?: string
expires_at?: string
created_at: string
token?: string
}
/** 创建 Token 请求 */
export interface CreateTokenRequest {
name: string
expires_days?: number
permissions: string[]
}
/** 中转任务 */
export interface RelayTask {
id: string
account_id: string
provider_id: string
model_id: string
status: string
priority: number
attempt_count: number
max_attempts: number
input_tokens: number
output_tokens: number
error_message: string | null
queued_at: string
started_at: string | null
completed_at: string | null
created_at: string
}
/** 用量记录 */
export interface UsageRecord {
day: string
count: number
input_tokens: number
output_tokens: number
}
/** 按模型用量 */
export interface UsageByModel {
model_id: string
count: number
input_tokens: number
output_tokens: number
}
/** 系统配置项 */
export interface ConfigItem {
id: string
category: string
key_path: string
value_type: string
current_value: string | null
default_value: string | null
source: string
description: string | null
requires_restart: boolean
created_at: string
updated_at: string
}
/** 操作日志 */
export interface OperationLog {
id: number
account_id: string | null
action: string
target_type: string | null
target_id: string | null
details: Record<string, unknown> | null
ip_address: string | null
created_at: string
}
/** 仪表盘统计 */
export interface DashboardStats {
total_accounts: number
active_accounts: number
tasks_today: number
active_providers: number
active_models: number
tokens_today_input: number
tokens_today_output: number
}
/** API 错误响应 */
export interface ApiError {
error: string
message: string
status?: number
}
/** 提示词模板 */
export interface PromptTemplate {
id: string
name: string
category: string
description?: string
source: 'builtin' | 'custom'
current_version: number
status: 'active' | 'deprecated' | 'archived'
created_at: string
updated_at: string
}
/** 提示词版本 */
export interface PromptVersion {
id: string
template_id: string
version: number
system_prompt: string
user_prompt_template?: string
variables: PromptVariable[]
changelog?: string
min_app_version?: string
created_at: string
}
/** 提示词变量定义 */
export interface PromptVariable {
name: string
type: 'string' | 'number' | 'select' | 'boolean'
default_value?: string
description?: string
required?: boolean
}
/** Agent 模板 */
export interface AgentTemplate {
id: string
name: string
description?: string
category: string
source: 'builtin' | 'custom'
model?: string
system_prompt?: string
tools: string[]
capabilities: string[]
temperature?: number
max_tokens?: number
visibility: 'public' | 'team' | 'private'
status: 'active' | 'archived'
current_version: number
created_at: string
updated_at: string
soul_content?: string
scenarios: string[]
welcome_message?: string
quick_commands: Array<{ label: string; command: string }>
personality?: string
communication_style?: string
emoji?: string
version: number
source_id?: string
}
/** Agent 模板可用列表(轻量) */
export interface AgentTemplateAvailable {
id: string
name: string
category: string
emoji?: string
description?: string
source_id?: string
}
/** Provider Key */
export interface ProviderKey {
id: string
provider_id: string
key_label: string
priority: number
max_rpm?: number
max_tpm?: number
is_active: boolean
last_429_at?: string
cooldown_until?: string
total_requests: number
total_tokens: number
created_at: string
updated_at: string
}
/** 按模型聚合的用量统计 */
export interface ModelUsageStat {
model_id: string
request_count: number
input_tokens: number
output_tokens: number
avg_latency_ms: number | null
success_rate: number
}
/** 按天的用量统计 */
export interface DailyUsageStat {
day: string
request_count: number
input_tokens: number
output_tokens: number
unique_devices: number
}
/** 角色 */
export interface Role {
id: string
name: string
description: string
permissions: string[]
account_count?: number
created_at: string
updated_at: string
}
/** 权限模板 */
export interface PermissionTemplate {
id: string
name: string
description: string
permissions: string[]
created_at: string
updated_at: string
}
/** 创建角色请求 */
export interface CreateRoleRequest {
name: string
description?: string
permissions?: string[]
}
/** 更新角色请求 */
export interface UpdateRoleRequest {
name?: string
description?: string
permissions?: string[]
}
/** 创建权限模板请求 */
export interface CreateTemplateRequest {
name: string
description?: string
permissions?: string[]
}
/** 配置同步日志 */
export interface ConfigSyncLog {
id: number
account_id: string
client_fingerprint: string
action: string
config_keys: string
client_values: string | null
saas_values: string | null
resolution: string | null
created_at: string
}

View File

@@ -1,6 +0,0 @@
{
"status": "failed",
"failedTests": [
"825d61429c68a1b0492e-735d17b3ccbad35e8726"
]
}

View File

@@ -1,196 +0,0 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: smoke_admin.spec.ts >> A6: 模型服务页面加载→Provider和Model tab可见
- Location: tests\e2e\smoke_admin.spec.ts:179:1
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('#main-content') to be visible
```
# Page snapshot
```yaml
- generic [ref=e1]:
- link "跳转到主要内容" [ref=e2] [cursor=pointer]:
- /url: "#main-content"
- generic [ref=e5]:
- generic [ref=e9]:
- generic [ref=e11]: Z
- heading "ZCLAW" [level=1] [ref=e12]
- paragraph [ref=e13]: AI Agent 管理平台
- paragraph [ref=e15]: 统一管理 AI 服务商、模型配置、API 密钥、用量监控与系统配置
- generic [ref=e17]:
- heading "登录" [level=2] [ref=e18]
- paragraph [ref=e19]: 输入您的账号信息以继续
- generic [ref=e22]:
- generic [ref=e28]:
- img "user" [ref=e30]:
- img [ref=e31]
- textbox "请输入用户名" [active] [ref=e33]
- generic [ref=e40]:
- img "lock" [ref=e42]:
- img [ref=e43]
- textbox "请输入密码" [ref=e45]
- img "eye-invisible" [ref=e47] [cursor=pointer]:
- img [ref=e48]
- button "登 录" [ref=e51] [cursor=pointer]:
- generic [ref=e52]: 登 录
```
# Test source
```ts
1 | /**
2 | * Smoke Tests — Admin V2 连通性断裂探测
3 | *
4 | * 6 个冒烟测试验证 Admin V2 页面与 SaaS 后端的完整连通性。
5 | * 所有测试使用真实浏览器 + 真实 SaaS Server。
6 | *
7 | * 前提条件:
8 | * - SaaS Server 运行在 http://localhost:8080
9 | * - Admin V2 dev server 运行在 http://localhost:5173
10 | * - 种子用户: testadmin / Admin123456 (super_admin)
11 | *
12 | * 运行: cd admin-v2 && npx playwright test smoke_admin
13 | */
14 |
15 | import { test, expect, type Page } from '@playwright/test';
16 |
17 | const SaaS_BASE = 'http://localhost:8080/api/v1';
18 | const ADMIN_USER = 'admin';
19 | const ADMIN_PASS = 'admin123';
20 |
21 | // Helper: 通过 API 登录获取 HttpOnly cookie + 设置 localStorage
22 | async function apiLogin(page: Page) {
23 | const res = await page.request.post(`${SaaS_BASE}/auth/login`, {
24 | data: { username: ADMIN_USER, password: ADMIN_PASS },
25 | });
26 | const json = await res.json();
27 | // 设置 localStorage 让 Admin V2 AuthGuard 认为已登录
28 | await page.goto('/');
29 | await page.evaluate((account) => {
30 | localStorage.setItem('zclaw_admin_account', JSON.stringify(account));
31 | }, json.account);
32 | return json;
33 | }
34 |
35 | // Helper: 通过 API 登录 + 导航到指定路径
36 | async function loginAndGo(page: Page, path: string) {
37 | await apiLogin(page);
38 | // 重新导航到目标路径 (localStorage 已设置React 应识别为已登录)
39 | await page.goto(path, { waitUntil: 'networkidle' });
40 | // 等待主内容区加载
> 41 | await page.waitForSelector('#main-content', { timeout: 15000 });
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
42 | }
43 |
44 | // ── A1: 登录→Dashboard ────────────────────────────────────────────
45 |
46 | test('A1: 登录→Dashboard 5个统计卡片', async ({ page }) => {
47 | // 导航到登录页
48 | await page.goto('/login');
49 | await expect(page.getByPlaceholder('请输入用户名')).toBeVisible({ timeout: 10000 });
50 |
51 | // 填写表单
52 | await page.getByPlaceholder('请输入用户名').fill(ADMIN_USER);
53 | await page.getByPlaceholder('请输入密码').fill(ADMIN_PASS);
54 |
55 | // 提交 (Ant Design 按钮文本有全角空格 "登 录")
56 | const loginBtn = page.locator('button').filter({ hasText: /登/ }).first();
57 | await loginBtn.click();
58 |
59 | // 验证跳转到 Dashboard (可能需要等待 API 响应)
60 | await expect(page).toHaveURL(/\/(login)?$/, { timeout: 20000 });
61 |
62 | // 验证 5 个统计卡片
63 | await expect(page.getByText('总账号')).toBeVisible({ timeout: 10000 });
64 | await expect(page.getByText('活跃服务商')).toBeVisible();
65 | await expect(page.getByText('活跃模型')).toBeVisible();
66 | await expect(page.getByText('今日请求')).toBeVisible();
67 | await expect(page.getByText('今日 Token')).toBeVisible();
68 |
69 | // 验证统计卡片有数值 (不是 loading 状态)
70 | const statCards = page.locator('.ant-statistic-content-value');
71 | await expect(statCards.first()).not.toBeEmpty({ timeout: 10000 });
72 | });
73 |
74 | // ── A2: Provider CRUD ──────────────────────────────────────────────
75 |
76 | test('A2: Provider 创建→列表可见→禁用', async ({ page }) => {
77 | // 通过 API 创建 Provider
78 | await apiLogin(page);
79 | const createRes = await page.request.post(`${SaaS_BASE}/providers`, {
80 | data: {
81 | name: `smoke_provider_${Date.now()}`,
82 | provider_type: 'openai',
83 | base_url: 'https://api.smoke.test/v1',
84 | enabled: true,
85 | display_name: 'Smoke Test Provider',
86 | },
87 | });
88 | if (!createRes.ok()) {
89 | const body = await createRes.text();
90 | console.log(`A2: Provider create failed: ${createRes.status()}${body.slice(0, 300)}`);
91 | }
92 | expect(createRes.ok()).toBeTruthy();
93 |
94 | // 导航到 Model Services 页面
95 | await page.goto('/model-services');
96 | await page.waitForSelector('#main-content', { timeout: 15000 });
97 |
98 | // 切换到 Provider tab (如果存在 tab 切换)
99 | const providerTab = page.getByRole('tab', { name: /服务商|Provider/i });
100 | if (await providerTab.isVisible()) {
101 | await providerTab.click();
102 | }
103 |
104 | // 验证 Provider 列表非空
105 | const tableRows = page.locator('.ant-table-row');
106 | await expect(tableRows.first()).toBeVisible({ timeout: 10000 });
107 | expect(await tableRows.count()).toBeGreaterThan(0);
108 | });
109 |
110 | // ── A3: Account 管理 ───────────────────────────────────────────────
111 |
112 | test('A3: Account 列表加载→角色可见', async ({ page }) => {
113 | await loginAndGo(page, '/accounts');
114 |
115 | // 验证表格加载
116 | const tableRows = page.locator('.ant-table-row');
117 | await expect(tableRows.first()).toBeVisible({ timeout: 10000 });
118 |
119 | // 至少有 testadmin 自己
120 | expect(await tableRows.count()).toBeGreaterThanOrEqual(1);
121 |
122 | // 验证有角色列
123 | const roleText = await page.locator('.ant-table').textContent();
124 | expect(roleText).toMatch(/super_admin|admin|user/);
125 | });
126 |
127 | // ── A4: 知识管理 ───────────────────────────────────────────────────
128 |
129 | test('A4: 知识分类→条目→搜索', async ({ page }) => {
130 | // 通过 API 创建分类和条目
131 | await apiLogin(page);
132 |
133 | const catRes = await page.request.post(`${SaaS_BASE}/knowledge/categories`, {
134 | data: { name: `smoke_cat_${Date.now()}`, description: 'Smoke test category' },
135 | });
136 | expect(catRes.ok()).toBeTruthy();
137 | const catJson = await catRes.json();
138 |
139 | const itemRes = await page.request.post(`${SaaS_BASE}/knowledge/items`, {
140 | data: {
141 | title: 'Smoke Test Knowledge Item',
```

Some files were not shown because too many files have changed in this diff Show More