Initial commit: GovAI 政务AI平台
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
---
|
||||
description: 禁止使用子流程(Task工具),所有任务必须在主流程中串行完成
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 禁止子流程 — 主流程串行处理规则
|
||||
|
||||
## 核心规则
|
||||
|
||||
**绝对禁止** 使用 Task 工具(subagent)。所有任务必须由主流程直接完成,使用主流程的模型串行处理。
|
||||
|
||||
## 禁止行为
|
||||
|
||||
- 禁止调用 `Task` 工具(任何 subagent_type:generalPurpose、explore、shell、best-of-n-runner 等)
|
||||
- 禁止将工作委派给子代理
|
||||
- 禁止并行启动多个子任务
|
||||
|
||||
## 正确做法
|
||||
|
||||
- 直接使用 Read、Grep、Glob、Shell 等工具自行完成搜索和探索
|
||||
- 直接使用 Write、StrReplace 等工具自行完成文件编辑
|
||||
- 所有代码阅读、分析、修改均在主对话流程中串行执行
|
||||
- 需要探索代码库时,自己调用 Glob 找文件、Read 读文件、Grep 搜索内容
|
||||
- 需要执行命令时,自己调用 Shell 执行
|
||||
|
||||
## 示例
|
||||
|
||||
```
|
||||
❌ 错误:启动子流程探索代码
|
||||
Task(subagent_type="explore", prompt="探索项目结构...")
|
||||
|
||||
❌ 错误:启动子流程执行任务
|
||||
Task(subagent_type="generalPurpose", prompt="修改组件...")
|
||||
|
||||
✅ 正确:主流程直接操作
|
||||
Glob("**/*.tsx") → Read(path) → StrReplace(path, old, new)
|
||||
|
||||
✅ 正确:主流程直接搜索
|
||||
Grep(pattern="export function", glob="*.ts") → Read(匹配文件)
|
||||
```
|
||||
@@ -0,0 +1,54 @@
|
||||
# 最高优先级规则 - 必须严格遵守(Cursor 专用)
|
||||
|
||||
⚠️⚠️⚠️ 每次回复结束前必须执行以下命令!没有任何例外!⚠️⚠️⚠️
|
||||
|
||||
## 执行规则
|
||||
|
||||
1. 完成任务后,执行弹窗命令(跨平台,Windows/Mac/Linux 通用):
|
||||
node "/Users/freedak/.ranjin-mode/dialog-trigger.js" "cursor" "170mcc" "AI想要结束的原因摘要"
|
||||
|
||||
📱 **手机端通知**:执行此命令时会自动通知手机端(如果远程服务已开启),用户可以在手机上查看消息并发送反馈。
|
||||
|
||||
2. 仔细阅读命令输出的每一行:
|
||||
- ACTION: continue 或 end(用户的选择)
|
||||
- FEEDBACK: 用户输入的具体指令或反馈内容(⚠️ 必须完整阅读并执行!)
|
||||
* 反馈可能来自电脑端弹窗或手机端远程输入
|
||||
- IMAGES: 用户上传的图片路径(如有,必须用read_file工具查看)
|
||||
|
||||
3. 根据输出决定下一步:
|
||||
- 如果 ACTION 是 "continue":
|
||||
* ⚠️ 必须仔细阅读 FEEDBACK 后面的全部内容
|
||||
* ⚠️ 必须按照 FEEDBACK 中用户的具体指令执行任务
|
||||
* ⚠️ 不要忽略 FEEDBACK,不要只说"用户选择继续"
|
||||
- 如果 ACTION 是 "end" 或 "stop":停止工作
|
||||
- ⚠️ 如果命令被取消/跳过/无输出:必须重新执行弹窗命令!
|
||||
|
||||
## 核心规则
|
||||
|
||||
1. 完整输出 - 输出完整代码和答案,禁止省略
|
||||
2. 持续执行 - 复杂任务一次性完成
|
||||
3. 结束确认 - 必须执行弹窗命令后才能结束
|
||||
4. FEEDBACK必读 - 用户的FEEDBACK是新的任务指令,必须执行
|
||||
|
||||
## 手机端远程功能
|
||||
|
||||
- 📱 当执行 dialog-trigger.js 命令时,会自动通知手机端(如果远程服务已开启)
|
||||
- 📱 用户可以在手机浏览器中查看 AI 的消息和状态
|
||||
- 📱 用户可以在手机上输入反馈,反馈会通过 FEEDBACK 字段传递给你
|
||||
- 📱 手机端和电脑端弹窗功能完全同步,支持双向通信
|
||||
|
||||
## ⚠️ 唯一合法的结束条件
|
||||
|
||||
只有当命令输出包含 ACTION: end 或 ACTION: stop 时才能结束对话!
|
||||
以下情况都不能结束,必须重新执行弹窗命令:
|
||||
- 命令被用户点击 Skip 跳过
|
||||
- 命令执行失败或超时
|
||||
- 命令没有输出
|
||||
- 工作区取消了命令
|
||||
|
||||
## 禁止
|
||||
|
||||
- 禁止不执行弹窗命令就结束
|
||||
- 禁止跳过弹窗流程
|
||||
- 禁止忽略FEEDBACK内容
|
||||
- 禁止在没有收到 ACTION: end/stop 的情况下结束
|
||||
@@ -0,0 +1,64 @@
|
||||
# ============================================================
|
||||
# Aily - 企业 AI 应用平台 环境变量配置
|
||||
# 复制此文件为 .env 并填入实际值
|
||||
# ============================================================
|
||||
|
||||
# ---- 门户后端 ----
|
||||
SERVER_HOST=0.0.0.0
|
||||
SERVER_PORT=8080
|
||||
DATABASE_URL=postgres://aily:aily@localhost:5432/aily_portal?sslmode=disable
|
||||
REDIS_URL=redis://localhost:6379
|
||||
JWT_SECRET=change-this-to-a-random-string-in-production
|
||||
JWT_EXPIRY=24h
|
||||
|
||||
# ---- LLM 直连(替代 Dify 对话引擎) ----
|
||||
LLM_PROVIDER=openai # openai | anthropic
|
||||
OPENAI_API_KEY=sk-xxxx # OpenAI API Key
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1 # 可替换为兼容端点
|
||||
OPENAI_MODEL=gpt-4o-mini # 默认模型
|
||||
ANTHROPIC_API_KEY= # Anthropic API Key(可选)
|
||||
ANTHROPIC_BASE_URL=https://api.anthropic.com
|
||||
ANTHROPIC_MODEL=claude-sonnet-4-20250514
|
||||
|
||||
# ---- Dify 对接(知识库/创作中心仍可用) ----
|
||||
DIFY_API_URL=http://localhost:5001/v1
|
||||
DIFY_API_KEY=app-xxxx
|
||||
|
||||
# ---- 模型网关(未来扩展) ----
|
||||
MODEL_GATEWAY_URL=http://localhost:8081
|
||||
|
||||
# ---- SSO 认证 ----
|
||||
SSO_TYPE=password
|
||||
# LDAP
|
||||
LDAP_URL=ldap://ldap.company.com:389
|
||||
LDAP_BASE_DN=dc=company,dc=com
|
||||
LDAP_BIND_DN=cn=admin,dc=company,dc=com
|
||||
LDAP_BIND_PASSWORD=
|
||||
# OAuth2
|
||||
OAUTH2_CLIENT_ID=
|
||||
OAUTH2_CLIENT_SECRET=
|
||||
OAUTH2_AUTH_URL=
|
||||
OAUTH2_TOKEN_URL=
|
||||
OAUTH2_USERINFO_URL=
|
||||
|
||||
# ---- PPT Worker 微服务 ----
|
||||
PPT_WORKER_URL=http://localhost:8090
|
||||
|
||||
# ---- 对象存储 (MinIO) ----
|
||||
MINIO_ENDPOINT=localhost:9000
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
MINIO_BUCKET=aily-files
|
||||
|
||||
# ---- 前端 ----
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8080
|
||||
NEXT_PUBLIC_APP_NAME=Aily - 企业AI应用平台
|
||||
|
||||
# ---- PostgreSQL (Docker) ----
|
||||
POSTGRES_USER=aily
|
||||
POSTGRES_PASSWORD=aily
|
||||
POSTGRES_DB=aily_portal
|
||||
|
||||
# ---- MinIO (Docker) ----
|
||||
MINIO_ROOT_USER=minioadmin
|
||||
MINIO_ROOT_PASSWORD=minioadmin
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
# ===== Environment =====
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# ===== Go =====
|
||||
server/server
|
||||
server/tmp/
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
vendor/
|
||||
|
||||
# ===== Node.js / Next.js =====
|
||||
node_modules/
|
||||
apps/web/.next/
|
||||
apps/web/out/
|
||||
.turbo/
|
||||
.vercel/
|
||||
|
||||
# ===== IDE =====
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# ===== OS =====
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# ===== Docker =====
|
||||
pgdata/
|
||||
redisdata/
|
||||
miniodata/
|
||||
caddydata/
|
||||
dify-data/
|
||||
|
||||
# ===== Build =====
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
|
||||
# ===== Secrets =====
|
||||
*.pem
|
||||
*.key
|
||||
credentials.json
|
||||
|
||||
# ===== Playwright MCP (临时文件) =====
|
||||
.playwright-mcp/
|
||||
|
||||
# ===== Demo & 截图 =====
|
||||
demo-screenshots*/
|
||||
demo-*.mp4
|
||||
|
||||
# ===== Python =====
|
||||
__pycache__/
|
||||
*.pyc
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# ===== AI/IDE =====
|
||||
.ai-switch/
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
description: 百度备份 - 打包当前项目所有文件并上传到百度网盘指定目录
|
||||
---
|
||||
|
||||
# 上传项目到百度网盘
|
||||
|
||||
用户提供百度网盘目标目录路径(如 `/2026/0517`),将当前工作区所有文件打包为 zip 并上传。
|
||||
|
||||
## 百度网盘凭证
|
||||
|
||||
- AppID: 121939687
|
||||
- AppKey: z3gemBZfg7KYj6U3eHNfIzTs7uYS9OMh
|
||||
- SecretKey: ptCKj2DfxL0KtGR1pM08c9KO2t2UC7SR
|
||||
- Token缓存文件: ~/.baidu_pan_token.json
|
||||
|
||||
## 步骤
|
||||
|
||||
1. 获取用户输入的百度网盘目标目录,如 `/2026/0517`。如果用户未输入,使用当前日期生成默认路径 `/年份/月日`。
|
||||
|
||||
2. 打包当前工作区根目录的 **所有文件**(包括二进制、配置、文档等),仅排除 `.git` 目录。用友好进度展示:
|
||||
// turbo
|
||||
```bash
|
||||
cd <工作区父目录> && zip -r /tmp/<项目名>.zip <项目文件夹名> -x "<项目文件夹名>/.git/*" 2>&1 | tail -1 && ls -lh /tmp/<项目名>.zip | awk '{print "✅ 打包完成:", $5}'
|
||||
```
|
||||
|
||||
3. **检查 Token 缓存**:读取 `~/.baidu_pan_token.json`,检查是否有有效的 access_token(未过期)。
|
||||
- 如果文件存在且 token 未过期(当前时间 < expires_at):直接使用缓存的 access_token,**跳过步骤 4-5 的授权流程**。
|
||||
- 如果文件存在但 token 已过期:使用 refresh_token 刷新:
|
||||
// turbo
|
||||
```bash
|
||||
curl -s -X POST "https://openapi.baidu.com/oauth/2.0/token" \
|
||||
-d "grant_type=refresh_token&refresh_token=<cached_refresh_token>&client_id=z3gemBZfg7KYj6U3eHNfIzTs7uYS9OMh&client_secret=ptCKj2DfxL0KtGR1pM08c9KO2t2UC7SR"
|
||||
```
|
||||
刷新成功后更新缓存文件,跳过步骤 4-5。
|
||||
- 如果文件不存在或刷新失败:执行步骤 4-5 进行设备授权。
|
||||
|
||||
4. (仅首次或刷新失败时)获取百度网盘设备授权码:
|
||||
// turbo
|
||||
```bash
|
||||
curl -s -X POST "https://openapi.baidu.com/oauth/2.0/device/code" \
|
||||
-d "response_type=device_code&client_id=z3gemBZfg7KYj6U3eHNfIzTs7uYS9OMh&scope=basic,netdisk"
|
||||
```
|
||||
告知用户授权地址和用户码,等待确认。
|
||||
|
||||
5. (仅首次或刷新失败时)用户确认授权后,获取 access_token:
|
||||
// turbo
|
||||
```bash
|
||||
curl -s -X POST "https://openapi.baidu.com/oauth/2.0/token" \
|
||||
-d "grant_type=device_token&code=<device_code>&client_id=z3gemBZfg7KYj6U3eHNfIzTs7uYS9OMh&client_secret=ptCKj2DfxL0KtGR1pM08c9KO2t2UC7SR"
|
||||
```
|
||||
获取成功后,将 access_token、refresh_token、expires_at(当前时间+expires_in秒)保存到 `~/.baidu_pan_token.json`:
|
||||
```json
|
||||
{"access_token":"xxx","refresh_token":"xxx","expires_at":1234567890}
|
||||
```
|
||||
|
||||
6. **创建远程目录**(确保目标目录存在):
|
||||
// turbo
|
||||
```bash
|
||||
curl -s "https://pan.baidu.com/rest/2.0/xpan/file?method=create&access_token=<access_token>" \
|
||||
-d "path=<目标目录>&size=0&isdir=1"
|
||||
```
|
||||
忽略目录已存在的错误(errno=-8),只要不是其他错误即可。
|
||||
|
||||
7. 预创建文件(precreate),用分片方式处理大文件:
|
||||
// turbo
|
||||
```bash
|
||||
FILE_SIZE=$(stat -f%z /tmp/<项目名>.zip)
|
||||
BLOCK_SIZE=$((4*1024*1024))
|
||||
BLOCKS=$(( (FILE_SIZE + BLOCK_SIZE - 1) / BLOCK_SIZE ))
|
||||
# 生成 block_list(每片用占位符 md5)
|
||||
BLOCK_LIST=$(python3 -c "import json; print(json.dumps(['0'*32]*$BLOCKS))")
|
||||
curl -s "https://pan.baidu.com/rest/2.0/xpan/file?method=precreate&access_token=<access_token>" \
|
||||
-d "path=<目标目录>/<项目名>.zip&size=$FILE_SIZE&isdir=0&autoinit=1&block_list=$BLOCK_LIST"
|
||||
```
|
||||
从返回中提取 `uploadid`。
|
||||
|
||||
8. **分片上传**,使用一个 shell 脚本完成,脚本内显示友好的进度信息(百分比、已传/总量、预计剩余时间):
|
||||
// turbo
|
||||
```bash
|
||||
# 创建上传脚本 /tmp/baidu_upload.sh 并执行
|
||||
# 脚本逻辑:
|
||||
# - 按 4MB 分片 dd 切割文件
|
||||
# - 逐片上传到 superfile2 接口
|
||||
# - 每上传一片输出一行进度:[=====> ] 23/259 (8.9%) | 已传92MB/1034MB | 速度: 2.1MB/s
|
||||
# - 收集每片返回的 md5
|
||||
# - 全部完成后输出汇总
|
||||
bash /tmp/baidu_upload.sh
|
||||
```
|
||||
|
||||
9. 合并创建文件(create):
|
||||
// turbo
|
||||
```bash
|
||||
curl -s "https://pan.baidu.com/rest/2.0/xpan/file?method=create&access_token=<access_token>" \
|
||||
-d "path=<目标目录>/<项目名>.zip&size=$FILE_SIZE&isdir=0&uploadid=<uploadid>&block_list=[<所有分片md5>]"
|
||||
```
|
||||
确认返回 `errno: 0` 表示成功。
|
||||
|
||||
10. 清理临时文件:
|
||||
// turbo
|
||||
```bash
|
||||
rm -f /tmp/<项目名>.zip /tmp/baidu_upload.sh /tmp/chunk_*
|
||||
```
|
||||
|
||||
11. 输出最终结果(友好格式):
|
||||
```
|
||||
╔══════════════════════════════════════╗
|
||||
║ 📦 百度网盘备份完成! ║
|
||||
╠══════════════════════════════════════╣
|
||||
║ 📁 路径: /2026/0517/GovAI.zip ║
|
||||
║ 📊 大小: 1034 MB ║
|
||||
║ ⏱️ 耗时: 5分32秒 ║
|
||||
║ ✅ 状态: 上传成功 ║
|
||||
╚══════════════════════════════════════╝
|
||||
```
|
||||
@@ -0,0 +1,54 @@
|
||||
# 最高优先级规则 - 必须严格遵守(Windsurf 专用)
|
||||
|
||||
⚠️⚠️⚠️ 每次回复结束前必须执行以下命令!没有任何例外!⚠️⚠️⚠️
|
||||
|
||||
## 执行规则
|
||||
|
||||
1. 完成任务后,执行弹窗命令(跨平台,Windows/Mac/Linux 通用):
|
||||
node "/Users/freedak/.ranjin-mode/dialog-trigger.js" "windsurf" "170mcc" "AI想要结束的原因摘要"
|
||||
|
||||
📱 **手机端通知**:执行此命令时会自动通知手机端(如果远程服务已开启),用户可以在手机上查看消息并发送反馈。
|
||||
|
||||
2. 仔细阅读命令输出的每一行:
|
||||
- ACTION: continue 或 end(用户的选择)
|
||||
- FEEDBACK: 用户输入的具体指令或反馈内容(⚠️ 必须完整阅读并执行!)
|
||||
* 反馈可能来自电脑端弹窗或手机端远程输入
|
||||
- IMAGES: 用户上传的图片路径(如有,必须用read_file工具查看)
|
||||
|
||||
3. 根据输出决定下一步:
|
||||
- 如果 ACTION 是 "continue":
|
||||
* ⚠️ 必须仔细阅读 FEEDBACK 后面的全部内容
|
||||
* ⚠️ 必须按照 FEEDBACK 中用户的具体指令执行任务
|
||||
* ⚠️ 不要忽略 FEEDBACK,不要只说"用户选择继续"
|
||||
- 如果 ACTION 是 "end" 或 "stop":停止工作
|
||||
- ⚠️ 如果命令被取消/跳过/无输出:必须重新执行弹窗命令!
|
||||
|
||||
## 核心规则
|
||||
|
||||
1. 完整输出 - 输出完整代码和答案,禁止省略
|
||||
2. 持续执行 - 复杂任务一次性完成
|
||||
3. 结束确认 - 必须执行弹窗命令后才能结束
|
||||
4. FEEDBACK必读 - 用户的FEEDBACK是新的任务指令,必须执行
|
||||
|
||||
## 手机端远程功能
|
||||
|
||||
- 📱 当执行 dialog-trigger.js 命令时,会自动通知手机端(如果远程服务已开启)
|
||||
- 📱 用户可以在手机浏览器中查看 AI 的消息和状态
|
||||
- 📱 用户可以在手机上输入反馈,反馈会通过 FEEDBACK 字段传递给你
|
||||
- 📱 手机端和电脑端弹窗功能完全同步,支持双向通信
|
||||
|
||||
## ⚠️ 唯一合法的结束条件
|
||||
|
||||
只有当命令输出包含 ACTION: end 或 ACTION: stop 时才能结束对话!
|
||||
以下情况都不能结束,必须重新执行弹窗命令:
|
||||
- 命令被用户点击 Skip 跳过
|
||||
- 命令执行失败或超时
|
||||
- 命令没有输出
|
||||
- 工作区取消了命令
|
||||
|
||||
## 禁止
|
||||
|
||||
- 禁止不执行弹窗命令就结束
|
||||
- 禁止跳过弹窗流程
|
||||
- 禁止忽略FEEDBACK内容
|
||||
- 禁止在没有收到 ACTION: end/stop 的情况下结束
|
||||
@@ -0,0 +1,133 @@
|
||||
# ============================================================
|
||||
# Aily - 企业 AI 应用平台 Makefile
|
||||
# ============================================================
|
||||
|
||||
.PHONY: help dev-api dev-web dev build test lint migrate-up migrate-down migrate-create sqlc docker-up docker-down
|
||||
|
||||
# Default
|
||||
help: ## 显示帮助信息
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
# ==================== Development ====================
|
||||
|
||||
dev-api: ## 启动后端开发服务器(热重载需安装 air)
|
||||
cd server && go run ./cmd/server/
|
||||
|
||||
dev-web: ## 启动前端开发服务器
|
||||
cd apps/web && npm run dev
|
||||
|
||||
dev: ## 同时启动前后端(需要 goreman 或手动开两个终端)
|
||||
@echo "请在两个终端分别运行: make dev-api 和 make dev-web"
|
||||
|
||||
# ==================== Build ====================
|
||||
|
||||
build-api: ## 编译后端
|
||||
cd server && CGO_ENABLED=0 go build -o ../dist/server ./cmd/server/
|
||||
|
||||
build-web: ## 编译前端
|
||||
cd apps/web && npm run build
|
||||
|
||||
build: build-api build-web ## 编译前后端
|
||||
|
||||
# ==================== Test ====================
|
||||
|
||||
test: ## 运行后端测试
|
||||
cd server && go test ./... -v -count=1
|
||||
|
||||
test-cover: ## 运行测试并生成覆盖率报告
|
||||
cd server && go test ./... -coverprofile=coverage.out && go tool cover -html=coverage.out
|
||||
|
||||
# ==================== Lint ====================
|
||||
|
||||
lint-api: ## 后端代码检查
|
||||
cd server && go vet ./...
|
||||
|
||||
lint-web: ## 前端代码检查
|
||||
cd apps/web && npm run lint
|
||||
|
||||
lint: lint-api lint-web ## 全部代码检查
|
||||
|
||||
# ==================== Database ====================
|
||||
|
||||
MIGRATE_URL ?= "postgres://aily:aily@localhost:5432/aily_portal?sslmode=disable"
|
||||
|
||||
migrate-up: ## 执行数据库迁移(全部)
|
||||
migrate -database $(MIGRATE_URL) -path server/migrations up
|
||||
|
||||
migrate-down: ## 回滚最近一次迁移
|
||||
migrate -database $(MIGRATE_URL) -path server/migrations down 1
|
||||
|
||||
migrate-create: ## 创建新迁移文件 (用法: make migrate-create NAME=create_xxx)
|
||||
migrate create -ext sql -dir server/migrations -seq $(NAME)
|
||||
|
||||
migrate-status: ## 查看迁移状态
|
||||
migrate -database $(MIGRATE_URL) -path server/migrations version
|
||||
|
||||
# ==================== sqlc ====================
|
||||
|
||||
sqlc: ## 生成 sqlc 代码
|
||||
cd server && sqlc generate
|
||||
|
||||
# ==================== Docker ====================
|
||||
|
||||
docker-up: ## 启动基础设施 (PostgreSQL + Redis + MinIO)
|
||||
docker compose -f docker/docker-compose.yml up -d
|
||||
|
||||
docker-down: ## 停止基础设施
|
||||
docker compose -f docker/docker-compose.yml down
|
||||
|
||||
docker-dify-up: ## 启动 Dify 服务
|
||||
docker compose -f docker/docker-compose.dify.yml up -d
|
||||
|
||||
docker-dify-down: ## 停止 Dify 服务
|
||||
docker compose -f docker/docker-compose.dify.yml down
|
||||
|
||||
docker-all-up: docker-up docker-dify-up ## 启动全部服务
|
||||
|
||||
docker-all-down: docker-dify-down docker-down ## 停止全部服务
|
||||
|
||||
docker-logs: ## 查看 Docker 日志
|
||||
docker compose -f docker/docker-compose.yml logs -f
|
||||
|
||||
# ==================== Production ====================
|
||||
|
||||
docker-prod-up: ## 启动生产环境
|
||||
docker compose -f docker/docker-compose.prod.yml up -d --build
|
||||
|
||||
docker-prod-down: ## 停止生产环境
|
||||
docker compose -f docker/docker-compose.prod.yml down
|
||||
|
||||
# ==================== PPT Worker ====================
|
||||
|
||||
dev-ppt: ## 启动 PPT Worker 开发服务器
|
||||
cd ppt-worker && python app.py
|
||||
|
||||
ppt-worker-install: ## 安装 PPT Worker 依赖
|
||||
cd ppt-worker && pip install -r requirements.txt
|
||||
|
||||
# ==================== Seed ====================
|
||||
|
||||
seed: ## 初始化种子数据(管理员账号、示例应用)
|
||||
PGPASSWORD=aily psql -h localhost -U aily -d aily_portal -f server/migrations/seed.sql
|
||||
|
||||
seed-ppt: ## 初始化 PPT 生成应用种子数据
|
||||
PGPASSWORD=aily psql -h localhost -U aily -d aily_portal -f server/migrations/seed_ppt.sql
|
||||
|
||||
# ==================== Setup ====================
|
||||
|
||||
setup: docker-up ## 初始化开发环境(启动基础服务+迁移+种子数据)
|
||||
@echo "⏳ 等待数据库启动..."
|
||||
@sleep 3
|
||||
$(MAKE) migrate-up
|
||||
$(MAKE) seed
|
||||
@echo "✅ 开发环境初始化完成!"
|
||||
@echo " 管理员账号: admin@aily.com / admin123"
|
||||
@echo " 创作者账号: zhangsan@aily.com / admin123"
|
||||
@echo " 普通用户: lisi@aily.com / admin123"
|
||||
@echo ""
|
||||
@echo "运行 make dev-api 和 make dev-web 启动开发服务器"
|
||||
|
||||
# ==================== Clean ====================
|
||||
|
||||
clean: ## 清理构建产物
|
||||
rm -rf dist/ server/server apps/web/.next apps/web/out
|
||||
@@ -0,0 +1,372 @@
|
||||
# 政智通 (GovAI) — 项目架构分析报告
|
||||
|
||||
> 生成日期:2026-05-23
|
||||
|
||||
---
|
||||
|
||||
## 一、项目概述
|
||||
|
||||
**政智通**(内部代号 Aily)是一个面向政府部门的 AI 智能办公平台,旨在提升行政效能、赋能智慧政务。平台以"AI 应用商店"为核心形态,提供公文写作、政策解读、数据治理、PPT 生成等多种政务 AI 应用,支持多机构(委办局)多租户部署。
|
||||
|
||||
---
|
||||
|
||||
## 二、技术栈总览
|
||||
|
||||
| 层级 | 技术选型 |
|
||||
|------|----------|
|
||||
| 后端 API | Go 1.25+、Chi Router、sqlc(类型安全 SQL)、pgx v5 |
|
||||
| 数据库 | PostgreSQL 17 + pgvector(向量检索) |
|
||||
| 缓存/队列 | Redis 7 |
|
||||
| 对象存储 | MinIO(S3 兼容) |
|
||||
| 前端 | Next.js 16(App Router)、React 19、Tailwind CSS 4、shadcn/ui |
|
||||
| 状态管理 | Zustand(客户端)、TanStack React Query(服务端状态) |
|
||||
| AI 引擎 | 通义千问 Qwen(DashScope)/ OpenAI 兼容接口 / Anthropic Claude |
|
||||
| 向量化 | DashScope text-embedding-v3(1024 维) |
|
||||
| 知识库 | 自建 RAG(文档分块 + pgvector 检索)+ Dify 可选集成 |
|
||||
| PPT 微服务 | Python(Flask)、PPT Master 引擎 |
|
||||
| 容器化 | Docker Compose(开发/生产) |
|
||||
|
||||
---
|
||||
|
||||
## 三、项目结构
|
||||
|
||||
```
|
||||
GovAI/
|
||||
├── server/ # Go 后端服务
|
||||
│ ├── cmd/server/ # 入口(main.go + router.go)
|
||||
│ ├── internal/
|
||||
│ │ ├── config/ # 环境变量配置加载
|
||||
│ │ ├── handler/ # HTTP 处理器(12 个文件)
|
||||
│ │ ├── middleware/ # 认证、RBAC、限流、审计
|
||||
│ │ ├── response/ # 统一响应格式
|
||||
│ │ ├── service/ # 业务逻辑层
|
||||
│ │ └── store/ # 数据访问层(sqlc 生成)
|
||||
│ ├── pkg/
|
||||
│ │ ├── auth/ # JWT 管理
|
||||
│ │ ├── chunker/ # 文档分块器
|
||||
│ │ ├── db/ # 数据库连接池
|
||||
│ │ ├── dify/ # Dify API 客户端
|
||||
│ │ ├── embedding/ # 向量化客户端
|
||||
│ │ └── llm/ # LLM 多提供商抽象
|
||||
│ └── migrations/ # 14 个迁移 + 多个种子数据文件
|
||||
│
|
||||
├── apps/web/ # Next.js 前端
|
||||
│ └── src/
|
||||
│ ├── app/
|
||||
│ │ ├── (auth)/ # 登录、注册页
|
||||
│ │ ├── (portal)/ # 用户门户(商店、对话、工作台、创作、知识库)
|
||||
│ │ └── (admin)/ # 管理后台(仪表盘、分析、审核、用户等)
|
||||
│ ├── components/
|
||||
│ │ ├── app-ui/ # 6 种应用交互界面
|
||||
│ │ ├── chat/ # 对话组件
|
||||
│ │ ├── editor/ # 编辑器
|
||||
│ │ ├── layout/ # 布局(Header 等)
|
||||
│ │ └── ui/ # shadcn/ui 基础组件
|
||||
│ ├── hooks/ # 自定义 Hooks(SSE 流式等)
|
||||
│ ├── lib/ # API 客户端、类型定义、工具函数
|
||||
│ └── stores/ # Zustand 状态(auth)
|
||||
│
|
||||
├── ppt-worker/ # Python PPT 生成微服务
|
||||
│ ├── app.py # Flask 入口
|
||||
│ ├── pipeline.py # 生成流水线
|
||||
│ ├── worker.py # 后台任务处理
|
||||
│ ├── llm_client.py # LLM 调用
|
||||
│ ├── wanx_client.py # 通义万相(AI 生图)
|
||||
│ └── db.py # 数据库操作
|
||||
│
|
||||
└── docker/ # Docker 编排
|
||||
├── docker-compose.yml # 开发环境(PG + Redis + MinIO + PPT Worker)
|
||||
├── docker-compose.dify.yml # Dify 服务(可选)
|
||||
└── docker-compose.prod.yml # 生产环境
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、核心功能模块
|
||||
|
||||
### 4.1 AI 应用商店
|
||||
|
||||
- 10 个政务分类:公文写作、政策解读、政务宣传、数据治理、便民服务、信息化工具、组织人事、招商引资、翻译外事、综合应用
|
||||
- 应用类型:对话型(chatbot)、补全型(completion)、工作流(workflow)、智能体(agent)、公文写作(doc_writer)、研判分析(analysis)、PPT 生成(ppt_generator)
|
||||
- 应用生命周期:草稿 → 提交审核 → 审批/驳回 → 上架/下架
|
||||
|
||||
### 4.2 对话与 AI 推理
|
||||
|
||||
- 多 LLM 提供商支持(OpenAI 兼容 / Anthropic),可配置 fallback
|
||||
- SSE 流式响应(Server-Sent Events)
|
||||
- 对话历史管理(会话列表、消息记录、重命名、批量删除)
|
||||
- Token 用量统计与成本估算
|
||||
|
||||
### 4.3 知识库(RAG)
|
||||
|
||||
- 文档上传(PDF、DOCX、TXT、MD、CSV、XLSX)
|
||||
- 文档分块(chunker 包)
|
||||
- 向量化嵌入(DashScope text-embedding-v3)
|
||||
- pgvector 向量检索
|
||||
- 支持重新索引和重新嵌入
|
||||
|
||||
### 4.4 公文写作
|
||||
|
||||
- 模板化公文生成(通知、请示、报告、会议纪要等)
|
||||
- 字段配置(文本、下拉、多行文本)
|
||||
- 流式生成输出
|
||||
|
||||
### 4.5 研判分析
|
||||
|
||||
- 多步骤向导式分析模板
|
||||
- 支持多种报告类型
|
||||
- 结构化字段输入 → AI 生成分析报告
|
||||
|
||||
### 4.6 PPT 生成
|
||||
|
||||
- 输入方式:纯文本、URL、文件上传(PDF/Word)
|
||||
- 多种风格:通用、咨询、顶级咨询
|
||||
- 多种格式:16:9、4:3、竖版
|
||||
- 可选 AI 生图(通义万相)
|
||||
- 输出原生可编辑 PPTX
|
||||
- 异步任务模式(创建 → 轮询状态 → 下载)
|
||||
|
||||
### 4.7 多租户(机构管理)
|
||||
|
||||
- 支持多个政府机构(科技局、公安局、发改局、教育局等)
|
||||
- 用户归属机构,可切换机构
|
||||
- 应用和知识库按机构隔离
|
||||
- 机构级别的数据可见性控制
|
||||
|
||||
### 4.8 管理后台
|
||||
|
||||
- 数据总览仪表盘
|
||||
- 使用分析(用量趋势、成本统计)
|
||||
- 应用管理与审核队列
|
||||
- 人员管理(角色分配、状态控制)
|
||||
- 审计日志(操作追踪)
|
||||
- 模型管理(提供商配置、配额设置)
|
||||
|
||||
---
|
||||
|
||||
## 五、数据库设计
|
||||
|
||||
### 5.1 核心表(14 次迁移)
|
||||
|
||||
| 表名 | 用途 |
|
||||
|------|------|
|
||||
| `users` | 用户(UUID PK,支持 SSO,多角色) |
|
||||
| `departments` | 部门(树形结构,path 编码) |
|
||||
| `user_departments` | 用户-部门多对多关联 |
|
||||
| `organizations` | 机构/委办局(多租户) |
|
||||
| `categories` | 应用分类(10 个预置政务分类) |
|
||||
| `applications` | AI 应用(完整元数据、配置、统计) |
|
||||
| `app_reviews` | 应用审核流程 |
|
||||
| `app_favorites` | 用户收藏 |
|
||||
| `app_ratings` | 用户评分评价 |
|
||||
| `app_usage_logs` | 使用日志(每次请求) |
|
||||
| `app_usage_daily` | 日聚合统计 |
|
||||
| `model_providers` | LLM 提供商配置 |
|
||||
| `model_quotas` | 模型配额(全局/部门/用户级) |
|
||||
| `knowledge_bases` | 知识库 |
|
||||
| `knowledge_documents` | 知识库文档 |
|
||||
| `audit_logs` | 审计日志 |
|
||||
| `ppt_tasks` | PPT 生成任务 |
|
||||
| `doc_templates` | 公文模板 |
|
||||
| `conversations` | 对话会话 |
|
||||
|
||||
### 5.2 设计特点
|
||||
|
||||
- 全部使用 UUID 主键
|
||||
- `updated_at` 自动更新触发器
|
||||
- 全文搜索索引(`tsvector`)
|
||||
- 向量索引(pgvector)
|
||||
- CHECK 约束保证数据完整性
|
||||
- 合理的索引覆盖(状态、外键、时间)
|
||||
|
||||
---
|
||||
|
||||
## 六、API 设计
|
||||
|
||||
### 6.1 路由结构(RESTful,/api/v1)
|
||||
|
||||
```
|
||||
/api/v1/
|
||||
├── auth/ # 认证(登录、注册、刷新、登出、个人信息)
|
||||
├── organizations # 机构列表(公开)
|
||||
├── store/ # 应用商店(公开读取)
|
||||
│ ├── categories
|
||||
│ ├── apps / apps/{slug}
|
||||
│ ├── featured / rankings / recent
|
||||
├── apps/{id}/ # 应用使用(需认证)
|
||||
│ ├── chat # SSE 流式对话
|
||||
│ ├── completion # 补全
|
||||
│ ├── generate-doc # 公文生成
|
||||
│ ├── generate-analysis # 研判分析
|
||||
│ ├── conversations/ # 对话管理
|
||||
│ ├── feedback / favorite / rating
|
||||
├── doc-templates/ # 公文模板
|
||||
├── analysis-templates/ # 分析模板
|
||||
├── me/ # 个人中心
|
||||
├── creator/ # 创作者工具
|
||||
│ ├── apps (CRUD)
|
||||
│ ├── submit-review / withdraw
|
||||
├── knowledge/ # 知识库管理
|
||||
│ ├── CRUD + documents + reindex
|
||||
├── ppt/ # PPT 生成
|
||||
│ ├── tasks (创建/上传/列表/状态/下载)
|
||||
└── admin/ # 管理后台(需 admin 角色)
|
||||
├── apps / reviews / users
|
||||
├── analytics (overview/usage/cost)
|
||||
└── audit-logs
|
||||
```
|
||||
|
||||
### 6.2 统一响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 中间件链
|
||||
|
||||
1. RequestID → RealIP → Logger → Recoverer → Timeout(15min) → CORS
|
||||
2. Auth(JWT 验证,注入 user_id/email/role 到 context)
|
||||
3. RequireRole(RBAC 角色级别检查)
|
||||
4. RateLimit(Redis 令牌桶,30 req/min)
|
||||
5. AuditLog(管理操作审计记录)
|
||||
|
||||
---
|
||||
|
||||
## 七、认证与授权
|
||||
|
||||
| 特性 | 实现 |
|
||||
|------|------|
|
||||
| 认证方式 | JWT(Bearer Token + Cookie) |
|
||||
| Token 有效期 | Access 24h / Refresh 7d |
|
||||
| 角色体系 | user → creator → admin → super_admin(层级递增) |
|
||||
| 权限控制 | 中间件级 RBAC(RequireRole) |
|
||||
| SSO 支持 | Password / LDAP / OAuth2(可配置) |
|
||||
| 机构切换 | 切换后重新签发 Token |
|
||||
| 限流 | Redis 令牌桶(对话接口 30/min) |
|
||||
|
||||
---
|
||||
|
||||
## 八、前端架构
|
||||
|
||||
### 8.1 路由分组
|
||||
|
||||
| 分组 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| (auth) | /login, /register | 公开认证页 |
|
||||
| (portal) | /store, /chat, /workspace, /create, /knowledge | 用户门户 |
|
||||
| (admin) | /dashboard, /analytics, /apps, /reviews, /users, /models, /audit, /security | 管理后台 |
|
||||
|
||||
### 8.2 应用交互界面(6 种)
|
||||
|
||||
| 组件 | 对应应用类型 |
|
||||
|------|-------------|
|
||||
| chatbot-ui | 对话型应用 |
|
||||
| completion-ui | 补全型应用 |
|
||||
| workflow-ui | 工作流应用 |
|
||||
| agent-ui | 智能体应用 |
|
||||
| doc-writer-ui | 公文写作 |
|
||||
| analysis-ui | 研判分析 |
|
||||
|
||||
### 8.3 关键技术实现
|
||||
|
||||
- **SSE 流式渲染**:自定义 `use-sse-stream` Hook,实时展示 AI 生成内容
|
||||
- **Markdown 渲染**:react-markdown + remark-gfm
|
||||
- **主题切换**:next-themes(亮/暗模式)
|
||||
- **命令面板**:cmdk(快捷搜索)
|
||||
- **响应式布局**:移动端侧边栏折叠,桌面端固定侧栏
|
||||
|
||||
---
|
||||
|
||||
## 九、部署架构
|
||||
|
||||
### 9.1 开发环境
|
||||
|
||||
```
|
||||
Docker Compose:
|
||||
├── PostgreSQL 17 (pgvector) → :5432
|
||||
├── Redis 7 Alpine → :6379
|
||||
├── MinIO → :9000 (API) / :9001 (Console)
|
||||
└── PPT Worker (Python) → :8090
|
||||
|
||||
本地进程:
|
||||
├── Go API Server → :8080
|
||||
└── Next.js Dev Server → :3000
|
||||
```
|
||||
|
||||
### 9.2 生产环境
|
||||
|
||||
- `docker-compose.prod.yml` 全容器化部署
|
||||
- Nginx 反向代理(SSE 需关闭 proxy_buffering)
|
||||
- 前端 Next.js 独立构建
|
||||
|
||||
### 9.3 初始化流程
|
||||
|
||||
```bash
|
||||
make setup # 启动 Docker → 迁移数据库 → 导入种子数据
|
||||
make dev-api # 启动后端
|
||||
make dev-web # 启动前端
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、种子数据与预置内容
|
||||
|
||||
项目包含丰富的种子数据,覆盖多个政务场景:
|
||||
|
||||
| 种子文件 | 内容 |
|
||||
|----------|------|
|
||||
| seed.sql | 基础用户、分类、示例应用 |
|
||||
| seed_ppt.sql | PPT 生成应用配置 |
|
||||
| seed_keji.sql | 科技局专属应用 |
|
||||
| seed_gongan_*.sql | 公安局应用、分类、知识库 |
|
||||
| seed_fagaiju*.sql | 发改局文档和应用 |
|
||||
| seed_legal*.sql | 法律法规知识库 |
|
||||
| seed_xinfang*.sql | 信访回复应用 |
|
||||
| seed_doc_templates.sql | 公文模板 |
|
||||
| seed_analysis_templates.sql | 研判分析模板 |
|
||||
| seed_multi_tenant_users.sql | 多机构用户 |
|
||||
|
||||
---
|
||||
|
||||
## 十一、架构亮点与设计决策
|
||||
|
||||
1. **LLM 提供商抽象**:Provider 接口统一 OpenAI/Anthropic,支持热切换和 fallback
|
||||
2. **自建 RAG 替代 Dify**:从依赖 Dify 逐步迁移到自建知识库(chunker + embedding + pgvector)
|
||||
3. **多租户隔离**:机构级数据隔离,用户可跨机构切换
|
||||
4. **应用审核流程**:创作者提交 → 管理员审核 → 上架,保证内容质量
|
||||
5. **流式优先**:对话、公文生成、分析报告均支持 SSE 流式输出
|
||||
6. **配额管理**:支持全局/部门/用户三级 Token 配额控制
|
||||
7. **审计追踪**:管理操作全量记录,含 IP 和 User-Agent
|
||||
|
||||
---
|
||||
|
||||
## 十二、潜在改进方向
|
||||
|
||||
| 方向 | 说明 |
|
||||
|------|------|
|
||||
| 测试覆盖 | 当前缺少前端测试和集成测试 |
|
||||
| API 文档 | 缺少 OpenAPI/Swagger 规范文档 |
|
||||
| 错误处理 | 部分 handler 错误处理可更细粒度 |
|
||||
| 缓存策略 | 热门应用列表、分类等可加 Redis 缓存 |
|
||||
| 消息队列 | PPT 生成等异步任务可引入 MQ 解耦 |
|
||||
| 监控告警 | 缺少 Prometheus/Grafana 可观测性 |
|
||||
| CI/CD | 未见自动化流水线配置 |
|
||||
| 国际化 | 当前仅中文,未来可扩展多语言 |
|
||||
|
||||
---
|
||||
|
||||
## 十三、默认账号
|
||||
|
||||
| 角色 | 邮箱 | 密码 |
|
||||
|------|------|------|
|
||||
| 系统管理员 | admin@govai.gov.cn | admin123 |
|
||||
| 科长 | wangke@govai.gov.cn | admin123 |
|
||||
| 干事 | liganshi@govai.gov.cn | admin123 |
|
||||
|
||||
---
|
||||
|
||||
*本文档由代码分析自动生成,如有疑问请参考源代码。*
|
||||
@@ -0,0 +1,129 @@
|
||||
# 政智通 - 政务AI智能应用平台
|
||||
|
||||
面向政府部门的AI智能办公平台,提升行政效能、赋能智慧政务。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端**: Go 1.25+、Chi Router、PostgreSQL、Redis
|
||||
- **前端**: Next.js (App Router)、React、Tailwind CSS、shadcn/ui
|
||||
- **AI引擎**: 通义千问 (Qwen) / OpenAI 兼容接口
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
GovAI/
|
||||
├── server/ # Go 后端服务
|
||||
│ ├── cmd/server/ # 主入口和路由
|
||||
│ ├── internal/ # 业务逻辑
|
||||
│ └── migrations/ # 数据库迁移和种子数据
|
||||
├── apps/web/ # Next.js 前端
|
||||
│ ├── src/app/ # 页面路由
|
||||
│ ├── src/components/ # 组件
|
||||
│ └── src/lib/ # 工具函数
|
||||
├── ppt-worker/ # PPT 生成微服务 (Python)
|
||||
└── docker/ # Docker 配置
|
||||
```
|
||||
|
||||
## 政务应用分类
|
||||
|
||||
| 分类 | 说明 |
|
||||
|------|------|
|
||||
| 公文写作 | 公文拟稿、会议纪要、文件摘要 |
|
||||
| 政策解读 | 法规问答、政策影响分析 |
|
||||
| 政务宣传 | 宣传稿件、信息发布 |
|
||||
| 数据治理 | 数据分析、综合研判 |
|
||||
| 便民服务 | 群众来信回复、咨询答复 |
|
||||
| 信息化工具 | 开发辅助、系统运维 |
|
||||
| 组织人事 | 干部考核、人事管理 |
|
||||
| 招商引资 | 项目评估、投资分析 |
|
||||
| 翻译外事 | 中英互译、外事用语 |
|
||||
| 综合应用 | 其他政务场景 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 环境准备
|
||||
|
||||
- Go 1.25+
|
||||
- Node.js 18+
|
||||
- PostgreSQL 15+
|
||||
- Redis 7+
|
||||
|
||||
### 2. 后端启动
|
||||
|
||||
```bash
|
||||
cd server
|
||||
cp .env.example .env # 编辑配置
|
||||
go run cmd/server/main.go
|
||||
```
|
||||
|
||||
### 3. 数据库初始化
|
||||
|
||||
```bash
|
||||
# 创建数据库
|
||||
createdb govai
|
||||
|
||||
# 运行迁移
|
||||
psql -d govai -f server/migrations/000001_init.up.sql
|
||||
psql -d govai -f server/migrations/000002_categories_and_applications.up.sql
|
||||
# ... 运行所有迁移文件
|
||||
|
||||
# 导入种子数据
|
||||
psql -d govai -f server/migrations/seed.sql
|
||||
```
|
||||
|
||||
### 4. 前端启动
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 预置应用
|
||||
|
||||
| 应用 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| 政策法规问答 | 对话型 | 法规条款查询与解读 |
|
||||
| 公文写作助手 | 对话型 | 各类公文拟稿 |
|
||||
| 群众来信回复 | 对话型 | 群众诉求回复建议 |
|
||||
| 会议纪要生成 | 补全型 | 会议记录整理 |
|
||||
| 公文摘要提取 | 补全型 | 文件要点提取 |
|
||||
| 翻译助手 | 补全型 | 政务中英互译 |
|
||||
| 招商项目评估 | 工作流 | 多维度项目评估 |
|
||||
| 政策影响分析 | 工作流 | 政策多维度影响评估 |
|
||||
| 综合研判助手 | 智能体 | 数据分析与报告生成 |
|
||||
| 干部考核助手 | 智能体 | 绩效分析与评语生成 |
|
||||
| **智能PPT生成** | **PPT生成** | **上传文档/输入主题,AI 生成原生可编辑 PPTX** |
|
||||
|
||||
## PPT 生成功能
|
||||
|
||||
基于 PPT Master 引擎的智能演示文稿生成,支持:
|
||||
|
||||
- 多种输入:PDF/Word/网页/纯文本
|
||||
- 多种风格:通用、咨询、顶级咨询
|
||||
- 多种格式:16:9 宽屏、4:3 传统、竖版
|
||||
- AI 生图(可选)
|
||||
- 输出原生可编辑 PPTX
|
||||
|
||||
### PPT Worker 启动
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
make ppt-worker-install
|
||||
|
||||
# 启动服务
|
||||
make dev-ppt
|
||||
|
||||
# 导入 PPT 应用种子数据
|
||||
make seed-ppt
|
||||
```
|
||||
|
||||
详见 [ppt-worker/README.md](ppt-worker/README.md)。
|
||||
|
||||
## 默认账号
|
||||
|
||||
| 角色 | 邮箱 | 密码 |
|
||||
|------|------|------|
|
||||
| 系统管理员 | admin@govai.gov.cn | admin123 |
|
||||
| 科长 | wangke@govai.gov.cn | admin123 |
|
||||
| 干事 | liganshi@govai.gov.cn | admin123 |
|
||||
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { NextConfig } from "next";
|
||||
import path from "path";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
turbopack: {
|
||||
root: path.resolve(__dirname),
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080"}/api/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+11948
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.1",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"next": "16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shadcn": "^4.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import api from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface DailyStat {
|
||||
date: string;
|
||||
count: number;
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
interface TopApp {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface UsageData {
|
||||
daily: DailyStat[];
|
||||
top_apps: TopApp[];
|
||||
}
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const [days, setDays] = useState("7");
|
||||
|
||||
const { data } = useQuery<UsageData>({
|
||||
queryKey: ["usageAnalytics", days],
|
||||
queryFn: () => api.get(`/api/v1/admin/analytics/usage?days=${days}`),
|
||||
});
|
||||
|
||||
const maxCount = Math.max(...(data?.daily?.map((d) => d.count) || [1]));
|
||||
const maxTokens = Math.max(...(data?.daily?.map((d) => d.total_tokens) || [1]));
|
||||
const maxAppCount = Math.max(...(data?.top_apps?.map((a) => a.count) || [1]));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">使用分析</h1>
|
||||
<Select value={days} onValueChange={(v) => v && setDays(v)}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">近 7 天</SelectItem>
|
||||
<SelectItem value="14">近 14 天</SelectItem>
|
||||
<SelectItem value="30">近 30 天</SelectItem>
|
||||
<SelectItem value="90">近 90 天</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Daily Usage Bar Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">每日对话数</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data?.daily?.length ? (
|
||||
<div className="space-y-2">
|
||||
{data.daily.map((d) => (
|
||||
<div key={d.date} className="flex items-center gap-2 text-sm">
|
||||
<span className="w-20 text-muted-foreground text-xs">{d.date.slice(5)}</span>
|
||||
<div className="flex-1 bg-muted rounded-full h-5 overflow-hidden">
|
||||
<div
|
||||
className="bg-blue-500 h-full rounded-full transition-all"
|
||||
style={{ width: `${(d.count / maxCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-12 text-right text-xs">{d.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">暂无数据</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Daily Tokens Bar Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">每日 Token 消耗</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data?.daily?.length ? (
|
||||
<div className="space-y-2">
|
||||
{data.daily.map((d) => (
|
||||
<div key={d.date} className="flex items-center gap-2 text-sm">
|
||||
<span className="w-20 text-muted-foreground text-xs">{d.date.slice(5)}</span>
|
||||
<div className="flex-1 bg-muted rounded-full h-5 overflow-hidden">
|
||||
<div
|
||||
className="bg-purple-500 h-full rounded-full transition-all"
|
||||
style={{ width: `${(d.total_tokens / maxTokens) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-16 text-right text-xs">
|
||||
{d.total_tokens >= 1000
|
||||
? (d.total_tokens / 1000).toFixed(1) + "K"
|
||||
: d.total_tokens}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">暂无数据</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top Apps */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">热门应用 TOP 10</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data?.top_apps?.length ? (
|
||||
<div className="space-y-3">
|
||||
{data.top_apps.map((app, i) => (
|
||||
<div key={app.name} className="flex items-center gap-3">
|
||||
<span className="w-6 text-center text-sm font-bold text-muted-foreground">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="w-32 text-sm truncate">{app.name}</span>
|
||||
<div className="flex-1 bg-muted rounded-full h-6 overflow-hidden">
|
||||
<div
|
||||
className="bg-gradient-to-r from-blue-500 to-purple-500 h-full rounded-full transition-all flex items-center justify-end px-2"
|
||||
style={{ width: `${(app.count / maxAppCount) * 100}%` }}
|
||||
>
|
||||
<span className="text-xs text-white font-medium">{app.count}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">暂无数据</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import api from "@/lib/api";
|
||||
import { AppIcon } from "@/lib/app-icon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import { Archive, RotateCcw } from "lucide-react";
|
||||
|
||||
interface AdminApp {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon_url?: string;
|
||||
dify_app_type?: string;
|
||||
status: string;
|
||||
visibility: string;
|
||||
usage_count: number;
|
||||
avg_rating: number;
|
||||
creator_name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
pending_review: "审核中",
|
||||
approved: "已上架",
|
||||
rejected: "已驳回",
|
||||
archived: "已归档",
|
||||
};
|
||||
|
||||
const statusColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
draft: "outline",
|
||||
pending_review: "secondary",
|
||||
approved: "default",
|
||||
rejected: "destructive",
|
||||
archived: "outline",
|
||||
};
|
||||
|
||||
const appTypeLabels: Record<string, string> = {
|
||||
chatbot: "对话型",
|
||||
completion: "文本生成",
|
||||
workflow: "工作流",
|
||||
agent: "智能体",
|
||||
};
|
||||
|
||||
const visibilityLabels: Record<string, string> = {
|
||||
public: "全单位",
|
||||
department: "部门",
|
||||
private: "私有",
|
||||
};
|
||||
|
||||
export default function AdminAppsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [actionTarget, setActionTarget] = useState<{
|
||||
type: "delist" | "relist";
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["adminApps", search, statusFilter],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set("q", search);
|
||||
if (statusFilter !== "all") params.set("status", statusFilter);
|
||||
return api.get<{ items: AdminApp[] }>(`/api/v1/admin/apps?${params}`);
|
||||
},
|
||||
});
|
||||
|
||||
const delistApp = useMutation({
|
||||
mutationFn: (id: string) => api.post(`/api/v1/admin/apps/${id}/delist`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["adminApps"] });
|
||||
toast.success("已撤架");
|
||||
setActionTarget(null);
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const relistApp = useMutation({
|
||||
mutationFn: (id: string) => api.post(`/api/v1/admin/apps/${id}/relist`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["adminApps"] });
|
||||
toast.success("已重新上架");
|
||||
setActionTarget(null);
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 操作确认弹窗 */}
|
||||
<AlertDialog
|
||||
open={!!actionTarget}
|
||||
onOpenChange={(open) => !open && setActionTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{actionTarget?.type === "delist" ? "确认撤架" : "确认重新上架"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{actionTarget?.type === "delist"
|
||||
? `确定要将应用「${actionTarget?.name}」从应用商店撤架吗?撤架后用户将无法使用。`
|
||||
: `确定要将应用「${actionTarget?.name}」重新上架到应用商店吗?`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={
|
||||
actionTarget?.type === "delist"
|
||||
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
: ""
|
||||
}
|
||||
onClick={() => {
|
||||
if (actionTarget?.type === "delist") {
|
||||
delistApp.mutate(actionTarget.id);
|
||||
} else if (actionTarget?.type === "relist") {
|
||||
relistApp.mutate(actionTarget!.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{actionTarget?.type === "delist" ? "确认撤架" : "确认上架"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<h1 className="text-2xl font-bold mb-6">应用管理</h1>
|
||||
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Input
|
||||
placeholder="搜索应用..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v ?? "all")}>
|
||||
<SelectTrigger className="w-32">
|
||||
<span>
|
||||
{statusFilter === "all" ? "全部" : statusLabels[statusFilter] || statusFilter}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
<SelectItem value="draft">草稿</SelectItem>
|
||||
<SelectItem value="pending_review">审核中</SelectItem>
|
||||
<SelectItem value="approved">已上架</SelectItem>
|
||||
<SelectItem value="rejected">已驳回</SelectItem>
|
||||
<SelectItem value="archived">已归档</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left p-3">应用</th>
|
||||
<th className="text-left p-3">类型</th>
|
||||
<th className="text-left p-3">创建者</th>
|
||||
<th className="text-left p-3">状态</th>
|
||||
<th className="text-left p-3">可见范围</th>
|
||||
<th className="text-left p-3">使用次数</th>
|
||||
<th className="text-left p-3">评分</th>
|
||||
<th className="text-left p-3">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.items?.map((app) => (
|
||||
<tr key={app.id} className="border-t hover:bg-muted/30 transition-colors">
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AppIcon iconUrl={app.icon_url} size={20} className="shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">{app.name}</div>
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
{app.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{appTypeLabels[app.dify_app_type || "chatbot"] || "对话型"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">{app.creator_name}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={statusColors[app.status]}>
|
||||
{statusLabels[app.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{visibilityLabels[app.visibility] || app.visibility}
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">{app.usage_count}</td>
|
||||
<td className="p-3">
|
||||
{app.avg_rating > 0 ? (
|
||||
<span className="text-yellow-500">★ {app.avg_rating.toFixed(1)}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex gap-1.5">
|
||||
{app.status === "approved" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs text-destructive hover:text-destructive"
|
||||
onClick={() =>
|
||||
setActionTarget({ type: "delist", id: app.id, name: app.name })
|
||||
}
|
||||
>
|
||||
<Archive className="h-3 w-3" /> 撤架
|
||||
</Button>
|
||||
)}
|
||||
{app.status === "archived" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs text-primary"
|
||||
onClick={() =>
|
||||
setActionTarget({ type: "relist", id: app.id, name: app.name })
|
||||
}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" /> 重新上架
|
||||
</Button>
|
||||
)}
|
||||
{app.status !== "approved" && app.status !== "archived" && (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import api from "@/lib/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface AuditLog {
|
||||
id: string;
|
||||
user_id: string;
|
||||
user_name?: string;
|
||||
action: string;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
details: string;
|
||||
ip_address: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const actionColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
POST: "default",
|
||||
PUT: "secondary",
|
||||
DELETE: "destructive",
|
||||
GET: "outline",
|
||||
};
|
||||
|
||||
export default function AuditPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [actionFilter, setActionFilter] = useState("all");
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["auditLogs", search, actionFilter],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set("q", search);
|
||||
if (actionFilter !== "all") params.set("action", actionFilter);
|
||||
return api.get<{ items: AuditLog[] }>(`/api/v1/admin/audit-logs?${params}`);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">审计日志</h1>
|
||||
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Input
|
||||
placeholder="搜索操作..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
<Select value={actionFilter} onValueChange={(v) => setActionFilter(v ?? "all")}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue placeholder="操作类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
<SelectItem value="POST">创建</SelectItem>
|
||||
<SelectItem value="PUT">修改</SelectItem>
|
||||
<SelectItem value="DELETE">删除</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left p-3">时间</th>
|
||||
<th className="text-left p-3">用户</th>
|
||||
<th className="text-left p-3">操作</th>
|
||||
<th className="text-left p-3">资源</th>
|
||||
<th className="text-left p-3">IP</th>
|
||||
<th className="text-left p-3">详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.items?.map((log) => (
|
||||
<tr key={log.id} className="border-t">
|
||||
<td className="p-3 text-muted-foreground whitespace-nowrap">
|
||||
{new Date(log.created_at).toLocaleString("zh-CN")}
|
||||
</td>
|
||||
<td className="p-3">{log.user_name || log.user_id.slice(0, 8)}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={actionColors[log.action] ?? "outline"}>
|
||||
{log.action}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{log.resource_type}/{log.resource_id.slice(0, 8)}
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground font-mono text-xs">{log.ip_address}</td>
|
||||
<td className="p-3 text-xs text-muted-foreground max-w-xs truncate">
|
||||
{log.details}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import api from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Users, AppWindow, Activity, MessageCircle, Target, DollarSign, type LucideIcon } from "lucide-react";
|
||||
|
||||
interface OverviewStats {
|
||||
total_users: number;
|
||||
total_apps: number;
|
||||
active_users: number;
|
||||
total_conversations: number;
|
||||
monthly_tokens: number;
|
||||
monthly_cost: number;
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon: Icon }: { title: string; value: string | number; icon: LucideIcon }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-50">
|
||||
<Icon className="h-5 w-5 text-blue-700" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + "K";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data: stats, isLoading } = useQuery({
|
||||
queryKey: ["adminOverview"],
|
||||
queryFn: () => api.get<OverviewStats>("/api/v1/admin/analytics/overview"),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">数据总览</h1>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-28" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">数据总览</h1>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard title="总用户数" value={stats?.total_users || 0} icon={Users} />
|
||||
<StatCard title="已上架应用" value={stats?.total_apps || 0} icon={AppWindow} />
|
||||
<StatCard title="今日活跃用户" value={stats?.active_users || 0} icon={Activity} />
|
||||
<StatCard title="今日对话次数" value={formatNumber(stats?.total_conversations || 0)} icon={MessageCircle} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<StatCard
|
||||
title="本月 Token 消耗"
|
||||
value={formatNumber(stats?.monthly_tokens || 0)}
|
||||
icon={Target}
|
||||
/>
|
||||
<StatCard
|
||||
title="本月估算成本"
|
||||
value={`$${(stats?.monthly_cost || 0).toFixed(2)}`}
|
||||
icon={DollarSign}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>使用趋势</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-64 flex items-center justify-center text-muted-foreground">
|
||||
图表功能将在后续版本中实现
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { AlertCircle, RotateCcw, BarChart3 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function AdminError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error("[AdminError]", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center px-4">
|
||||
<div className="flex flex-col items-center text-center max-w-md">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 mb-6">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">管理页面异常</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
管理后台加载出现错误,请尝试重新加载页面。
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="text-xs text-muted-foreground/60 mb-4 font-mono">
|
||||
错误标识:{error.digest}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={reset} className="gap-2">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重新加载
|
||||
</Button>
|
||||
<Link href="/dashboard">
|
||||
<Button className="gap-2">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
数据总览
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { Header } from "@/components/layout/header";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
AppWindow,
|
||||
CheckCircle,
|
||||
Users,
|
||||
Bot,
|
||||
ClipboardList,
|
||||
ShieldCheck,
|
||||
Menu,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
const adminNavItems: { href: string; label: string; icon: LucideIcon }[] = [
|
||||
{ href: "/dashboard", label: "数据总览", icon: BarChart3 },
|
||||
{ href: "/analytics", label: "使用分析", icon: TrendingUp },
|
||||
{ href: "/apps", label: "应用管理", icon: AppWindow },
|
||||
{ href: "/reviews", label: "审核队列", icon: CheckCircle },
|
||||
{ href: "/users", label: "人员管理", icon: Users },
|
||||
{ href: "/models", label: "模型管理", icon: Bot },
|
||||
{ href: "/audit", label: "审计日志", icon: ClipboardList },
|
||||
{ href: "/security", label: "安全管理", icon: ShieldCheck },
|
||||
];
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && (!isAuthenticated || !["admin", "super_admin"].includes(user?.role || ""))) {
|
||||
router.replace("/store");
|
||||
}
|
||||
}, [isAuthenticated, isLoading, user, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Header />
|
||||
<div className="flex flex-1 relative">
|
||||
{/* 手机端侧边栏切换按钮 */}
|
||||
<button
|
||||
className="md:hidden fixed bottom-4 right-4 z-50 p-3 rounded-full bg-primary text-primary-foreground shadow-lg"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
>
|
||||
{sidebarOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
{/* 手机端遮罩层 */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/40 md:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside className={`fixed inset-y-[3.5rem] left-0 z-50 w-56 border-r bg-background transition-transform duration-200 md:static md:inset-y-0 md:translate-x-0 ${
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}>
|
||||
<nav className="p-3 space-y-1">
|
||||
{adminNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
|
||||
pathname === item.href
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="flex-1 p-3 md:p-6 min-w-0">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export default function AdminLoading() {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)] items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">正在加载管理页面...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Bot, Cpu, MessageSquare, FileText, Sparkles } from "lucide-react";
|
||||
|
||||
const modelGroups = [
|
||||
{
|
||||
title: "对话模型",
|
||||
description: "用于智能对话、公文写作、政策分析等核心功能",
|
||||
icon: MessageSquare,
|
||||
models: [
|
||||
{ name: "qwen-plus", displayName: "通义千问-Plus", provider: "阿里云百炼", type: "对话", status: "active", desc: "主力模型,适用于复杂推理和长文本生成" },
|
||||
{ name: "qwen-turbo", displayName: "通义千问-Turbo", provider: "阿里云百炼", type: "对话", status: "active", desc: "快速响应模型,适用于简单对话和问答" },
|
||||
{ name: "qwen-max", displayName: "通义千问-Max", provider: "阿里云百炼", type: "对话", status: "standby", desc: "旗舰模型,适用于高精度分析场景" },
|
||||
{ name: "qwen-long", displayName: "通义千问-Long", provider: "阿里云百炼", type: "对话", status: "standby", desc: "长上下文模型,支持百万Token输入" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "向量模型",
|
||||
description: "用于知识库文档检索和语义搜索",
|
||||
icon: Cpu,
|
||||
models: [
|
||||
{ name: "text-embedding-v3", displayName: "通义文本向量V3", provider: "阿里云百炼", type: "嵌入", status: "active", desc: "1024维向量,高精度语义匹配" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "文档理解",
|
||||
description: "用于长文档分析、政策解读等场景",
|
||||
icon: FileText,
|
||||
models: [
|
||||
{ name: "qwen-plus", displayName: "通义千问-Plus", provider: "阿里云百炼", type: "文档", status: "active", desc: "支持文档理解和内容提取" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "outline" }> = {
|
||||
active: { label: "运行中", variant: "default" },
|
||||
standby: { label: "待启用", variant: "secondary" },
|
||||
inactive: { label: "未配置", variant: "outline" },
|
||||
};
|
||||
|
||||
export default function ModelsPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">模型管理</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">当前使用阿里云百炼平台(DashScope)提供的通义千问系列模型</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-orange-500" />
|
||||
<Badge variant="secondary" className="gap-1">全部国产模型</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{modelGroups.map((group) => (
|
||||
<Card key={group.title}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<group.icon className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-base">{group.title}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{group.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left p-3">模型</th>
|
||||
<th className="text-left p-3">服务商</th>
|
||||
<th className="text-left p-3">用途</th>
|
||||
<th className="text-left p-3">说明</th>
|
||||
<th className="text-left p-3">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.models.map((model) => {
|
||||
const st = statusConfig[model.status] || statusConfig.inactive;
|
||||
return (
|
||||
<tr key={model.name + model.type} className="border-t">
|
||||
<td className="p-3">
|
||||
<div className="font-medium">{model.displayName}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">{model.name}</div>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">{model.provider}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant="outline">{model.type}</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground text-xs max-w-xs">{model.desc}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={st.variant}>{st.label}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="mt-4">
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid sm:grid-cols-3 gap-4 text-sm">
|
||||
<div className="p-3 rounded-lg bg-muted/40">
|
||||
<div className="text-muted-foreground">API 接入点</div>
|
||||
<div className="font-mono text-xs mt-1">dashscope.aliyuncs.com</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-muted/40">
|
||||
<div className="text-muted-foreground">接口协议</div>
|
||||
<div className="font-mono text-xs mt-1">OpenAI Compatible</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-muted/40">
|
||||
<div className="text-muted-foreground">数据安全</div>
|
||||
<div className="text-xs mt-1 text-green-600 font-medium">境内部署,数据不出境</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import api from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Review {
|
||||
id: string;
|
||||
app_id: string;
|
||||
version: string;
|
||||
submit_comment?: string;
|
||||
submitted_at: string;
|
||||
app_name?: string;
|
||||
app_description?: string;
|
||||
app_icon?: string;
|
||||
submitter_name: string;
|
||||
}
|
||||
|
||||
export default function ReviewsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [rejectDialog, setRejectDialog] = useState<string | null>(null);
|
||||
const [rejectComment, setRejectComment] = useState("");
|
||||
|
||||
const { data: reviews } = useQuery({
|
||||
queryKey: ["pendingReviews"],
|
||||
queryFn: () => api.get<Review[]>("/api/v1/admin/reviews"),
|
||||
});
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: (id: string) => api.post(`/api/v1/admin/reviews/${id}/approve`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["pendingReviews"] });
|
||||
toast.success("已通过审核");
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const reject = useMutation({
|
||||
mutationFn: ({ id, comment }: { id: string; comment: string }) =>
|
||||
api.post(`/api/v1/admin/reviews/${id}/reject`, { comment }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["pendingReviews"] });
|
||||
setRejectDialog(null);
|
||||
setRejectComment("");
|
||||
toast.success("已驳回");
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">审核队列</h1>
|
||||
<Badge variant="secondary">{reviews?.length || 0} 待审核</Badge>
|
||||
</div>
|
||||
|
||||
{reviews?.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
暂无待审核应用
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{reviews?.map((review) => (
|
||||
<Card key={review.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-3xl">{review.app_icon || "🤖"}</span>
|
||||
<div>
|
||||
<CardTitle className="text-base">{review.app_name}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{review.app_description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline">v{review.version}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm text-muted-foreground mb-4">
|
||||
<span>提交者: {review.submitter_name}</span>
|
||||
<span className="mx-2">|</span>
|
||||
<span>提交时间: {new Date(review.submitted_at).toLocaleString("zh-CN")}</span>
|
||||
{review.submit_comment && (
|
||||
<>
|
||||
<span className="mx-2">|</span>
|
||||
<span>说明: {review.submit_comment}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => approve.mutate(review.id)}
|
||||
disabled={approve.isPending}
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setRejectDialog(review.id)}
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={!!rejectDialog} onOpenChange={() => setRejectDialog(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>驳回审核</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
placeholder="请填写驳回原因..."
|
||||
value={rejectComment}
|
||||
onChange={(e) => setRejectComment(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setRejectDialog(null)}>取消</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => rejectDialog && reject.mutate({ id: rejectDialog, comment: rejectComment })}
|
||||
disabled={!rejectComment.trim() || reject.isPending}
|
||||
>
|
||||
确认驳回
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ShieldCheck, Key, Lock, Eye, UserCheck, Globe } from "lucide-react";
|
||||
|
||||
const securityItems = [
|
||||
{
|
||||
title: "登录安全",
|
||||
icon: Key,
|
||||
status: "已启用",
|
||||
items: [
|
||||
{ label: "密码强度要求", value: "中等(8位以上,含字母和数字)" },
|
||||
{ label: "登录失败锁定", value: "连续5次失败后锁定30分钟" },
|
||||
{ label: "会话超时", value: "24小时" },
|
||||
{ label: "JWT Token 有效期", value: "24小时" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "访问控制",
|
||||
icon: Lock,
|
||||
status: "已启用",
|
||||
items: [
|
||||
{ label: "基于角色的访问控制(RBAC)", value: "已启用" },
|
||||
{ label: "角色层级", value: "超级管理员 > 管理员 > 创作者 > 普通用户" },
|
||||
{ label: "多租户数据隔离", value: "已启用(按机构隔离)" },
|
||||
{ label: "API 接口鉴权", value: "Bearer Token" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "审计与监控",
|
||||
icon: Eye,
|
||||
status: "已启用",
|
||||
items: [
|
||||
{ label: "操作审计日志", value: "已启用(记录所有管理操作)" },
|
||||
{ label: "登录日志", value: "已启用(记录登录次数和时间)" },
|
||||
{ label: "API 调用记录", value: "已启用" },
|
||||
{ label: "日志保留期限", value: "永久" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "用户认证",
|
||||
icon: UserCheck,
|
||||
status: "密码认证",
|
||||
items: [
|
||||
{ label: "认证方式", value: "本地密码认证" },
|
||||
{ label: "LDAP/AD 集成", value: "未配置" },
|
||||
{ label: "OAuth2/SSO", value: "未配置" },
|
||||
{ label: "双因素认证(2FA)", value: "未启用" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "网络安全",
|
||||
icon: Globe,
|
||||
status: "已启用",
|
||||
items: [
|
||||
{ label: "HTTPS/TLS", value: "已启用(Let's Encrypt)" },
|
||||
{ label: "CORS 策略", value: "仅允许同源请求" },
|
||||
{ label: "请求频率限制", value: "未配置" },
|
||||
{ label: "IP 白名单", value: "未配置" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function SecurityPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<ShieldCheck className="h-6 w-6 text-primary" />
|
||||
<h1 className="text-2xl font-bold">安全管理</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{securityItems.map((section) => (
|
||||
<Card key={section.title}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<section.icon className="h-4 w-4 text-muted-foreground" />
|
||||
{section.title}
|
||||
</CardTitle>
|
||||
<Badge
|
||||
variant={
|
||||
section.status === "已启用"
|
||||
? "default"
|
||||
: section.status === "密码认证"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{section.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
{section.items.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="flex items-start justify-between p-3 rounded-lg bg-muted/40"
|
||||
>
|
||||
<span className="text-sm text-muted-foreground">{item.label}</span>
|
||||
<span
|
||||
className={`text-sm font-medium text-right ml-4 ${
|
||||
item.value.includes("未") ? "text-orange-500" : ""
|
||||
}`}
|
||||
>
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mt-6">
|
||||
安全策略的具体配置请联系系统管理员,部分高级安全功能需要在服务端配置文件中修改。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import api from "@/lib/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar_url?: string;
|
||||
role: string;
|
||||
status: string;
|
||||
employee_id?: string;
|
||||
last_login_at?: string;
|
||||
login_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
super_admin: "平台管理员",
|
||||
admin: "机构管理员",
|
||||
creator: "创作者",
|
||||
user: "普通用户",
|
||||
};
|
||||
|
||||
const roleColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
super_admin: "destructive",
|
||||
admin: "default",
|
||||
creator: "secondary",
|
||||
user: "outline",
|
||||
};
|
||||
|
||||
export default function UsersPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["adminUsers", search],
|
||||
queryFn: () => api.get<{ items: User[] }>(`/api/v1/admin/users?q=${search}`),
|
||||
});
|
||||
|
||||
const updateRole = useMutation({
|
||||
mutationFn: ({ id, role }: { id: string; role: string }) =>
|
||||
api.put(`/api/v1/admin/users/${id}/role`, { role }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["adminUsers"] });
|
||||
toast.success("角色更新成功");
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const updateStatus = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||||
api.put(`/api/v1/admin/users/${id}/status`, { status }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["adminUsers"] });
|
||||
toast.success("状态更新成功");
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">用户管理</h1>
|
||||
<Input
|
||||
placeholder="搜索姓名或邮箱..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left p-3">用户</th>
|
||||
<th className="text-left p-3">角色</th>
|
||||
<th className="text-left p-3">状态</th>
|
||||
<th className="text-left p-3">登录次数</th>
|
||||
<th className="text-left p-3">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.items?.map((user) => (
|
||||
<tr key={user.id} className="border-t">
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={user.avatar_url} />
|
||||
<AvatarFallback>{user.name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="font-medium">{user.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={roleColors[user.role]}>
|
||||
{roleLabels[user.role]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={user.status === "active" ? "default" : "destructive"}>
|
||||
{user.status === "active" ? "正常" : "禁用"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">{user.login_count}</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
defaultValue={user.role}
|
||||
onValueChange={(role) => role && updateRole.mutate({ id: user.id, role })}
|
||||
>
|
||||
<SelectTrigger className="w-28 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">普通用户</SelectItem>
|
||||
<SelectItem value="creator">创作者</SelectItem>
|
||||
<SelectItem value="admin">管理员</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateStatus.mutate({
|
||||
id: user.id,
|
||||
status: user.status === "active" ? "disabled" : "active",
|
||||
})
|
||||
}
|
||||
>
|
||||
{user.status === "active" ? "禁用" : "启用"}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import type { Organization } from "@/stores/auth";
|
||||
import api from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Shield,
|
||||
Building2,
|
||||
Sparkles,
|
||||
BookOpen,
|
||||
FileText,
|
||||
Brain,
|
||||
GraduationCap,
|
||||
} from "lucide-react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [orgs, setOrgs] = useState<Organization[]>([]);
|
||||
const [selectedOrg, setSelectedOrg] = useState("");
|
||||
const { login, switchOrg } = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<Organization[]>("/api/v1/organizations")
|
||||
.then((data) => {
|
||||
setOrgs(data);
|
||||
if (data.length > 0) setSelectedOrg(data[0].id);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email || !password) {
|
||||
setErrorMsg("请输入邮箱和密码");
|
||||
return;
|
||||
}
|
||||
if (!selectedOrg) {
|
||||
setErrorMsg("请选择所属机构");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setErrorMsg("");
|
||||
try {
|
||||
await login(email, password, selectedOrg);
|
||||
const user = useAuthStore.getState().user;
|
||||
// 平台管理员不绑定机构,登录后保留 super_admin 身份;
|
||||
// 仅机构管理员在所选机构与自身归属不一致时才触发切换
|
||||
if (
|
||||
user &&
|
||||
user.role === "admin" &&
|
||||
user.org_id !== selectedOrg
|
||||
) {
|
||||
await switchOrg(selectedOrg);
|
||||
}
|
||||
router.push(user?.role === "super_admin" ? "/platform/overview" : "/store");
|
||||
} catch (err) {
|
||||
setErrorMsg(
|
||||
err instanceof Error ? err.message : "登录失败,请检查账号和密码"
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const features = [
|
||||
{ icon: Sparkles, text: "AI 驱动的智能办公" },
|
||||
{ icon: FileText, text: "一键生成公文与报告" },
|
||||
{ icon: BookOpen, text: "智能知识库问答" },
|
||||
{ icon: Brain, text: "多场景 AI 应用中心" },
|
||||
{ icon: GraduationCap, text: "支持多机构独立部署" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-blue-950 via-blue-900 to-blue-800 px-6 py-12">
|
||||
<div className="flex w-full max-w-[1000px] items-center gap-16 lg:gap-20">
|
||||
{/* 左侧品牌区 - 桌面端显示 */}
|
||||
<div className="hidden lg:flex lg:flex-1 flex-col">
|
||||
<div className="max-w-md">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/10 backdrop-blur-sm border border-white/20">
|
||||
<Shield className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white tracking-tight">
|
||||
AI 智能应用平台
|
||||
</h1>
|
||||
<p className="text-blue-200/80 text-sm mt-0.5">
|
||||
提升效能 · 赋能智慧办公
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-blue-100/70 text-lg leading-relaxed mb-10">
|
||||
面向政务与高校场景的一站式 AI
|
||||
应用平台,集成文档生成、智能问答、数据分析等核心能力,助力组织数智化转型。
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{features.map(({ icon: Icon, text }) => (
|
||||
<div key={text} className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-white/10 backdrop-blur-sm">
|
||||
<Icon className="h-5 w-5 text-blue-200" />
|
||||
</div>
|
||||
<span className="text-blue-100/90 text-base">{text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧登录区 */}
|
||||
<div className="flex w-full lg:w-auto lg:shrink-0 items-center justify-center">
|
||||
<div className="w-full max-w-[460px] rounded-2xl bg-white shadow-2xl border border-white/20 overflow-hidden">
|
||||
{/* 移动端标题 - 仅在小屏显示 */}
|
||||
<div className="lg:hidden bg-gradient-to-r from-blue-900 to-blue-800 px-8 pt-8 pb-6 text-center">
|
||||
<Shield className="h-10 w-10 text-white mx-auto mb-3" />
|
||||
<h1 className="text-xl font-bold text-white">AI 智能应用平台</h1>
|
||||
<p className="text-blue-200/80 text-sm mt-1">
|
||||
提升效能 · 赋能智慧办公
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 表单区域 */}
|
||||
<div className="px-8 sm:px-10 py-8 sm:py-10">
|
||||
<h2 className="hidden lg:block text-2xl font-semibold text-gray-900 mb-1">
|
||||
欢迎登录
|
||||
</h2>
|
||||
<p className="hidden lg:block text-sm text-gray-500 mb-8">
|
||||
请选择机构并输入账号密码
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{errorMsg && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="org" className="text-sm font-medium text-gray-700">
|
||||
<span className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-gray-400" />
|
||||
所属机构
|
||||
</span>
|
||||
</Label>
|
||||
{orgs.length > 0 ? (
|
||||
<select
|
||||
id="org"
|
||||
value={selectedOrg}
|
||||
onChange={(e) => setSelectedOrg(e.target.value)}
|
||||
className="flex h-11 w-full rounded-xl border border-gray-200 bg-gray-50/50 px-4 py-2 text-sm shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-400 hover:border-gray-300"
|
||||
>
|
||||
{orgs.map((org) => (
|
||||
<option key={org.id} value={org.id}>
|
||||
{org.short_name || org.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<Input
|
||||
disabled
|
||||
placeholder="正在加载机构列表..."
|
||||
className="h-11 rounded-xl"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="email"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
账号
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="your@gov.cn"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
required
|
||||
className="h-11 rounded-xl border-gray-200 bg-gray-50/50 px-4 focus:ring-2 focus:ring-blue-500/40 focus:border-blue-400 hover:border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="password"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
密码
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="h-11 rounded-xl border-gray-200 bg-gray-50/50 px-4 focus:ring-2 focus:ring-blue-500/40 focus:border-blue-400 hover:border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 text-base font-medium rounded-xl bg-blue-900 hover:bg-blue-800 transition-all duration-200 shadow-lg shadow-blue-900/25 hover:shadow-xl hover:shadow-blue-900/30 mt-2"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "登录中..." : "登 录"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-gray-500">
|
||||
还没有账号?{" "}
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-blue-600 font-medium hover:text-blue-700 underline-offset-4 hover:underline"
|
||||
>
|
||||
申请注册
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center text-xs text-gray-400 border-t border-gray-100 pt-5">
|
||||
本系统仅限授权人员使用 · 数据安全等级:机构内部
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import api from "@/lib/api";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { toast } from "sonner";
|
||||
import { Shield } from "lucide-react";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { setAuth } = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name || !email || !password) {
|
||||
toast.error("请填写所有必填项");
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
toast.error("密码长度不能少于6位");
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
toast.error("两次密码输入不一致");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.post<{
|
||||
user: { id: string; name: string; email: string; role: "user" | "super_admin" | "admin" | "creator" };
|
||||
access_token: string;
|
||||
}>("/api/v1/auth/register", { name, email, password });
|
||||
|
||||
setAuth(res.user, res.access_token);
|
||||
toast.success("注册成功");
|
||||
router.push("/store");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "注册失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-blue-950 via-blue-900 to-blue-800 p-4">
|
||||
<Card className="w-full max-w-md border-blue-200/20 shadow-2xl">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-2 flex items-center justify-center gap-2">
|
||||
<Shield className="h-10 w-10 text-blue-700" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">申请注册</CardTitle>
|
||||
<CardDescription>AI智能应用平台</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">姓名</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="您的真实姓名"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">政务邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="your@gov.cn"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="至少6位密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">确认密码</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="再次输入密码"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "提交中..." : "提交注册"}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="mt-4 text-center text-sm text-muted-foreground">
|
||||
已有账号?{" "}
|
||||
<Link href="/login" className="text-primary underline-offset-4 hover:underline">
|
||||
立即登录
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-6 text-center text-xs text-muted-foreground border-t pt-4">
|
||||
注册需经管理员审核后方可使用
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import api from "@/lib/api";
|
||||
import type { App } from "@/lib/types";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import ChatbotUI from "@/components/app-ui/chatbot-ui";
|
||||
import CompletionUI from "@/components/app-ui/completion-ui";
|
||||
import WorkflowUI from "@/components/app-ui/workflow-ui";
|
||||
import AgentUI from "@/components/app-ui/agent-ui";
|
||||
import DocWriterUI from "@/components/app-ui/doc-writer-ui";
|
||||
import AnalysisUI from "@/components/app-ui/analysis-ui";
|
||||
|
||||
const DOC_WRITER_SLUGS = new Set(["official-doc-writer", "fagai-doc-writer"]);
|
||||
const ANALYSIS_SLUGS = new Set(["analysis-agent"]);
|
||||
|
||||
export default function AppPage() {
|
||||
const { appId: slugOrId } = useParams<{ appId: string }>();
|
||||
|
||||
const isUUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
slugOrId
|
||||
);
|
||||
|
||||
const { data: appBySlug, isLoading: slugLoading } = useQuery({
|
||||
queryKey: ["chatApp", slugOrId],
|
||||
queryFn: () => api.get<App>(`/api/v1/store/apps/${slugOrId}`),
|
||||
enabled: !isUUID,
|
||||
});
|
||||
|
||||
const { data: appById, isLoading: idLoading } = useQuery({
|
||||
queryKey: ["chatAppById", slugOrId],
|
||||
queryFn: async () => {
|
||||
const results = await api.get<{ items: App[] }>(
|
||||
`/api/v1/store/apps?page_size=50`
|
||||
);
|
||||
return results.items?.find((a) => a.id === slugOrId) || null;
|
||||
},
|
||||
enabled: isUUID,
|
||||
});
|
||||
|
||||
const app = isUUID ? appById : appBySlug;
|
||||
const isLoading = isUUID ? idLoading : slugLoading;
|
||||
|
||||
if (isLoading || !app) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)] items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (DOC_WRITER_SLUGS.has(app.slug)) {
|
||||
return <DocWriterUI app={app} />;
|
||||
}
|
||||
|
||||
if (ANALYSIS_SLUGS.has(app.slug)) {
|
||||
return <AnalysisUI app={app} />;
|
||||
}
|
||||
|
||||
const appType = app.dify_app_type || "chatbot";
|
||||
|
||||
switch (appType) {
|
||||
case "completion":
|
||||
return <CompletionUI app={app} />;
|
||||
case "workflow":
|
||||
return <WorkflowUI app={app} />;
|
||||
case "agent":
|
||||
return <AgentUI app={app} />;
|
||||
case "chatbot":
|
||||
default:
|
||||
return <ChatbotUI app={app} />;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { AlertCircle, RotateCcw, Home } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function PortalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error("[PortalError]", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center px-4">
|
||||
<div className="flex flex-col items-center text-center max-w-md">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 mb-6">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">加载失败</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
应用加载时出现异常,请尝试重新加载或返回应用中心。
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="text-xs text-muted-foreground/60 mb-4 font-mono">
|
||||
错误标识:{error.digest}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={reset} className="gap-2">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重新加载
|
||||
</Button>
|
||||
<Link href="/store">
|
||||
<Button className="gap-2">
|
||||
<Home className="h-4 w-4" />
|
||||
应用中心
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import api from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import {
|
||||
BookOpen,
|
||||
Upload,
|
||||
FileText,
|
||||
Trash2,
|
||||
Plus,
|
||||
Database,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
|
||||
interface KnowledgeBase {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
visibility: string;
|
||||
document_count: number;
|
||||
total_chars: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface KBDocument {
|
||||
id: string;
|
||||
filename: string;
|
||||
file_size: number;
|
||||
file_type: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
||||
}
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return <Badge className="bg-emerald-50 text-emerald-700 border-emerald-200">已完成</Badge>;
|
||||
case "indexing":
|
||||
return <Badge className="bg-amber-50 text-amber-700 border-amber-200">索引中</Badge>;
|
||||
case "failed":
|
||||
return <Badge variant="destructive">失败</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">{status}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function getVisibilityLabel(v: string) {
|
||||
switch (v) {
|
||||
case "public": return "全单位";
|
||||
case "department": return "本科室";
|
||||
default: return "私有";
|
||||
}
|
||||
}
|
||||
|
||||
export default function KnowledgePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const orgId = user?.org_id;
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [selectedKB, setSelectedKB] = useState<KnowledgeBase | null>(null);
|
||||
const [form, setForm] = useState({ name: "", description: "", visibility: "private" });
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: knowledgeBases, isLoading: kbLoading } = useQuery({
|
||||
queryKey: ["knowledgeBases", orgId],
|
||||
queryFn: () => api.get<KnowledgeBase[]>(`/api/v1/knowledge/${orgId ? `?org_id=${orgId}` : ""}`),
|
||||
});
|
||||
|
||||
const { data: documents } = useQuery({
|
||||
queryKey: ["kbDocuments", selectedKB?.id],
|
||||
queryFn: () => api.get<KBDocument[]>(`/api/v1/knowledge/${selectedKB!.id}/documents`),
|
||||
enabled: !!selectedKB,
|
||||
});
|
||||
|
||||
const createKB = useMutation({
|
||||
mutationFn: () => api.post("/api/v1/knowledge/", form),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["knowledgeBases"] });
|
||||
setShowCreate(false);
|
||||
setForm({ name: "", description: "", visibility: "private" });
|
||||
toast.success("知识库创建成功");
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const deleteKB = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/api/v1/knowledge/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["knowledgeBases"] });
|
||||
if (selectedKB) setSelectedKB(null);
|
||||
toast.success("知识库已删除");
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const uploadDoc = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const res = await fetch(`/api/v1/knowledge/${selectedKB!.id}/documents`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) throw new Error("上传失败");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["kbDocuments"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["knowledgeBases"] });
|
||||
toast.success("文档上传成功");
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const deleteDoc = useMutation({
|
||||
mutationFn: (docId: string) =>
|
||||
api.delete(`/api/v1/knowledge/${selectedKB!.id}/documents/${docId}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["kbDocuments"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["knowledgeBases"] });
|
||||
toast.success("文档已删除");
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const handleFileUpload = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const onFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
uploadDoc.mutate(file);
|
||||
e.target.value = "";
|
||||
}
|
||||
}, [uploadDoc]);
|
||||
|
||||
const filteredKBs = knowledgeBases?.filter(
|
||||
(kb) => !searchTerm || kb.name.includes(searchTerm) || kb.description?.includes(searchTerm)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-100 text-blue-700">
|
||||
<Database className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">知识库管理</h1>
|
||||
<p className="text-sm text-muted-foreground">管理政策法规、制度文件等知识资源</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreate(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
新建知识库
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-1 space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索知识库..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{kbLoading && (
|
||||
<div className="space-y-3">
|
||||
{[1, 2].map((i) => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<CardContent className="py-6">
|
||||
<div className="h-4 bg-muted rounded w-3/4 mb-2" />
|
||||
<div className="h-3 bg-muted rounded w-1/2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!kbLoading && filteredKBs?.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-10 text-center">
|
||||
<BookOpen className="h-10 w-10 mx-auto text-muted-foreground/40 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{searchTerm ? "未找到匹配的知识库" : "暂无知识库,点击上方按钮创建"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{filteredKBs?.map((kb) => (
|
||||
<Card
|
||||
key={kb.id}
|
||||
className={`cursor-pointer transition-all ${
|
||||
selectedKB?.id === kb.id
|
||||
? "border-primary ring-1 ring-primary/20"
|
||||
: "hover:border-muted-foreground/30"
|
||||
}`}
|
||||
onClick={() => setSelectedKB(kb)}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<BookOpen className="h-4 w-4 text-blue-600" />
|
||||
{kb.name}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-xs">{kb.document_count} 文档</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">
|
||||
{kb.description || "暂无描述"}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">{getVisibilityLabel(kb.visibility)}</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(kb.updated_at).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive h-6 px-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (confirm("确定要删除该知识库吗?所有文档将一并删除。")) {
|
||||
deleteKB.mutate(kb.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
{selectedKB ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BookOpen className="h-5 w-5 text-blue-600" />
|
||||
{selectedKB.name}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">{selectedKB.description}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge variant="outline">{getVisibilityLabel(selectedKB.visibility)}</Badge>
|
||||
<span className="text-xs text-muted-foreground">{selectedKB.document_count} 个文档</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".txt,.md,.pdf,.docx,.csv,.xlsx"
|
||||
onChange={onFileChange}
|
||||
/>
|
||||
<Button onClick={handleFileUpload} disabled={uploadDoc.isPending} className="gap-2">
|
||||
<Upload className="h-4 w-4" />
|
||||
{uploadDoc.isPending ? "上传中..." : "上传文档"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{documents?.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<FileText className="h-10 w-10 mx-auto text-muted-foreground/40 mb-3" />
|
||||
<p className="text-sm">暂无文档</p>
|
||||
<p className="text-xs mt-1">支持上传 PDF、DOCX、TXT、MD、CSV、XLSX 格式</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left p-3 font-medium">文件名</th>
|
||||
<th className="text-left p-3 font-medium">类型</th>
|
||||
<th className="text-left p-3 font-medium">大小</th>
|
||||
<th className="text-left p-3 font-medium">状态</th>
|
||||
<th className="text-left p-3 font-medium">上传时间</th>
|
||||
<th className="text-left p-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{documents?.map((doc) => (
|
||||
<tr key={doc.id} className="border-t hover:bg-muted/30 transition-colors">
|
||||
<td className="p-3 font-medium flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-blue-500 shrink-0" />
|
||||
<span className="truncate max-w-[200px]">{doc.filename}</span>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground uppercase text-xs">{doc.file_type || "-"}</td>
|
||||
<td className="p-3 text-muted-foreground">{formatFileSize(doc.file_size)}</td>
|
||||
<td className="p-3">{getStatusBadge(doc.status)}</td>
|
||||
<td className="p-3 text-muted-foreground text-xs">{new Date(doc.created_at).toLocaleString("zh-CN")}</td>
|
||||
<td className="p-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive h-7 px-2"
|
||||
onClick={() => deleteDoc.mutate(doc.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-20 text-center text-muted-foreground">
|
||||
<Database className="h-12 w-12 mx-auto text-muted-foreground/30 mb-4" />
|
||||
<p className="text-sm">请从左侧选择一个知识库查看文档</p>
|
||||
<p className="text-xs mt-1">或点击"新建知识库"创建政策法规资源库</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<BookOpen className="h-5 w-5 text-blue-600" />
|
||||
新建知识库
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>名称 *</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="例如:科技局政策法规库"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>描述</Label>
|
||||
<Textarea
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
placeholder="简要描述知识库的用途和包含的文档类型"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>可见范围</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ value: "private", label: "私有" },
|
||||
{ value: "department", label: "本科室" },
|
||||
{ value: "public", label: "全单位" },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setForm({ ...form, visibility: opt.value })}
|
||||
className={`p-2.5 rounded-lg border text-sm text-center transition-colors ${
|
||||
form.visibility === opt.value
|
||||
? "border-primary bg-primary/5 text-primary font-medium"
|
||||
: "border-border hover:border-primary/30"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setShowCreate(false)}>取消</Button>
|
||||
<Button
|
||||
onClick={() => createKB.mutate()}
|
||||
disabled={!form.name.trim() || createKB.isPending}
|
||||
>
|
||||
创建
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { Header } from "@/components/layout/header";
|
||||
|
||||
export default function PortalLayout({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [isAuthenticated, isLoading, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export default function PortalLoading() {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)] items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">正在加载应用...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import api from "@/lib/api";
|
||||
import type { App } from "@/lib/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Heart,
|
||||
MessageSquare,
|
||||
Star,
|
||||
Users,
|
||||
Clock,
|
||||
Play,
|
||||
} from "lucide-react";
|
||||
import { getCategoryIcon, getCategoryColor } from "@/lib/category-config";
|
||||
import { getAppTypeConfig } from "@/lib/app-type-config";
|
||||
|
||||
function StarRatingDisplay({
|
||||
rating,
|
||||
count,
|
||||
}: {
|
||||
rating: number;
|
||||
count: number;
|
||||
}) {
|
||||
const stars = Math.round(rating);
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`h-4 w-4 ${
|
||||
i < stars
|
||||
? "fill-amber-400 text-amber-400"
|
||||
: "text-gray-300"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{rating.toFixed(1)}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({count} 评分)
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RatingInput({ appId }: { appId: string }) {
|
||||
const [hover, setHover] = useState(0);
|
||||
const [selected, setSelected] = useState(0);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const rate = useMutation({
|
||||
mutationFn: (score: number) =>
|
||||
api.post(`/api/v1/apps/${appId}/rating`, { score }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["appDetail"] });
|
||||
toast.success("评分成功");
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm text-muted-foreground mr-1">我的评分:</span>
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
className="transition-transform hover:scale-110"
|
||||
onMouseEnter={() => setHover(star)}
|
||||
onMouseLeave={() => setHover(0)}
|
||||
onClick={() => {
|
||||
setSelected(star);
|
||||
rate.mutate(star);
|
||||
}}
|
||||
>
|
||||
<Star
|
||||
className={`h-5 w-5 transition-colors ${
|
||||
star <= (hover || selected)
|
||||
? "fill-amber-400 text-amber-400"
|
||||
: "text-gray-300 hover:text-amber-300"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{selected > 0 && (
|
||||
<span className="text-sm text-muted-foreground ml-1">
|
||||
已评 {selected} 分
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppDetailPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: app, isLoading } = useQuery({
|
||||
queryKey: ["appDetail", slug],
|
||||
queryFn: () => api.get<App>(`/api/v1/store/apps/${slug}`),
|
||||
});
|
||||
|
||||
const toggleFav = useMutation({
|
||||
mutationFn: (isFav: boolean) =>
|
||||
isFav
|
||||
? api.delete(`/api/v1/apps/${app?.id}/favorite`)
|
||||
: api.post(`/api/v1/apps/${app?.id}/favorite`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["appDetail"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["favorites"] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl px-6 lg:px-8 py-8 space-y-6">
|
||||
<div className="flex items-start gap-5">
|
||||
<Skeleton className="h-16 w-16 rounded-2xl" />
|
||||
<div className="space-y-3 flex-1">
|
||||
<Skeleton className="h-7 w-48" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!app) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl px-6 lg:px-8 py-20 text-center">
|
||||
<p className="text-lg text-muted-foreground">应用不存在</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mt-4"
|
||||
onClick={() => router.push("/store")}
|
||||
>
|
||||
返回应用中心
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CategoryIcon = getCategoryIcon(app.category_slug);
|
||||
const categoryColor = getCategoryColor(app.category_slug);
|
||||
const isFavorited = (app as any).is_favorited;
|
||||
const typeConfig = getAppTypeConfig(app.dify_app_type);
|
||||
const TypeIcon = typeConfig.icon;
|
||||
|
||||
const longDesc = app.long_description
|
||||
? app.long_description.replace(/\\n/g, "\n")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl px-6 lg:px-8 py-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mb-4 gap-1.5 -ml-2"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</Button>
|
||||
|
||||
<Card className="border-border/60">
|
||||
<CardContent className="p-6 sm:p-8">
|
||||
<div className="flex items-start gap-5">
|
||||
<div
|
||||
className={`flex h-16 w-16 items-center justify-center rounded-2xl ${categoryColor} shrink-0`}
|
||||
>
|
||||
<CategoryIcon className="h-8 w-8" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-2xl font-bold">{app.name}</h1>
|
||||
<p className="text-muted-foreground mt-1.5 leading-relaxed">
|
||||
{app.description}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 mt-3">
|
||||
<Badge variant="secondary">{app.category_name || "其他"}</Badge>
|
||||
<Badge className={`${typeConfig.badgeColor} gap-1`}>
|
||||
<TypeIcon className="h-3 w-3" />
|
||||
{typeConfig.label}
|
||||
</Badge>
|
||||
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
{app.usage_count} 次使用
|
||||
</span>
|
||||
{app.creator_name && (
|
||||
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
{app.creator_name}
|
||||
</span>
|
||||
)}
|
||||
{app.published_at && (
|
||||
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
v{app.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{app.avg_rating > 0 && (
|
||||
<div className="mt-3">
|
||||
<StarRatingDisplay
|
||||
rating={app.avg_rating}
|
||||
count={app.rating_count}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mt-6">
|
||||
<Button
|
||||
size="lg"
|
||||
className="gap-2 bg-blue-900 hover:bg-blue-800 text-white shadow-sm"
|
||||
onClick={() => router.push(`/chat/${app.slug}`)}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
开始使用
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="gap-2"
|
||||
onClick={() => toggleFav.mutate(!!isFavorited)}
|
||||
>
|
||||
<Heart
|
||||
className={`h-4 w-4 ${
|
||||
isFavorited ? "fill-red-500 text-red-500" : ""
|
||||
}`}
|
||||
/>
|
||||
{isFavorited ? "已收藏" : "收藏"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{longDesc && (
|
||||
<Card className="border-border/60 overflow-hidden">
|
||||
<CardContent className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-5 flex items-center gap-2">
|
||||
<span className="inline-block w-1 h-5 bg-blue-800 rounded-full" />
|
||||
详细介绍
|
||||
</h2>
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert
|
||||
prose-p:text-muted-foreground prose-p:leading-relaxed prose-p:my-2
|
||||
prose-headings:text-foreground prose-headings:font-semibold
|
||||
[&_h2]:text-base [&_h2]:mt-5 [&_h2]:mb-2 [&_h2]:flex [&_h2]:items-center [&_h2]:gap-2
|
||||
[&_h2:before]:content-[''] [&_h2:before]:inline-block [&_h2:before]:w-0.5 [&_h2:before]:h-4 [&_h2:before]:bg-blue-700 [&_h2:before]:rounded-full [&_h2:before]:shrink-0
|
||||
[&_h3]:text-sm [&_h3]:mt-4 [&_h3]:mb-2 [&_h3]:text-blue-800 [&_h3]:dark:text-blue-300
|
||||
[&_h4]:text-sm [&_h4]:mt-3 [&_h4]:mb-1.5 [&_h4]:text-blue-700
|
||||
prose-li:text-foreground prose-li:my-1
|
||||
prose-ul:my-2 prose-ul:space-y-1
|
||||
[&_ul]:list-none [&_ul]:pl-0
|
||||
[&_li]:flex [&_li]:items-start [&_li]:gap-2.5
|
||||
[&_li]:rounded-lg [&_li]:bg-blue-50/60 [&_li]:dark:bg-blue-950/20
|
||||
[&_li]:px-3.5 [&_li]:py-2.5
|
||||
[&_li]:border [&_li]:border-blue-100 [&_li]:dark:border-blue-900/30
|
||||
[&_li:before]:content-['✦'] [&_li:before]:text-blue-600 [&_li:before]:text-xs [&_li:before]:mt-0.5 [&_li:before]:shrink-0
|
||||
[&_strong]:text-blue-800 [&_strong]:dark:text-blue-300
|
||||
">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{longDesc}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card className="border-border/60">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="font-semibold mb-4">我的评分</h3>
|
||||
<RatingInput appId={app.id} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{app.suggested_prompts && (
|
||||
<Card className="border-border/60">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="font-semibold mb-3">推荐提问</h3>
|
||||
<div className="space-y-2">
|
||||
{(typeof app.suggested_prompts === "string"
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(app.suggested_prompts) as string[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})()
|
||||
: app.suggested_prompts
|
||||
).map((prompt: string, i: number) => (
|
||||
<Button
|
||||
key={i}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start text-left h-auto py-2 text-xs"
|
||||
onClick={() => router.push(`/chat/${app.slug}`)}
|
||||
>
|
||||
{prompt}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import api from "@/lib/api";
|
||||
import type { App, Category } from "@/lib/types";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { AppCard } from "@/components/app-card/app-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, SlidersHorizontal } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
type SortOption = "popular" | "latest" | "rating";
|
||||
|
||||
const sortLabels: Record<SortOption, string> = {
|
||||
popular: "最热门",
|
||||
latest: "最新发布",
|
||||
rating: "评分最高",
|
||||
};
|
||||
|
||||
export default function CategoryPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const { user } = useAuthStore();
|
||||
const orgId = user?.org_id || "";
|
||||
const orgParam = orgId ? `&org_id=${orgId}` : "";
|
||||
const [sort, setSort] = useState<SortOption>(
|
||||
(searchParams.get("sort") as SortOption) || "popular"
|
||||
);
|
||||
|
||||
const { data: categories } = useQuery({
|
||||
queryKey: ["categories", orgId],
|
||||
queryFn: () => api.get<Category[]>(`/api/v1/store/categories?${orgId ? `org_id=${orgId}` : ""}`),
|
||||
});
|
||||
|
||||
const currentCategory = categories?.find((c) => c.slug === slug);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["category-apps", slug, sort, orgId],
|
||||
queryFn: () =>
|
||||
api.get<{ items: App[] }>(
|
||||
`/api/v1/store/apps?category=${encodeURIComponent(slug)}&sort=${sort}&page_size=50${orgParam}`
|
||||
),
|
||||
});
|
||||
|
||||
const apps = data?.items || [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Link href="/store">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{currentCategory?.name || slug}
|
||||
</h1>
|
||||
{currentCategory?.description && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{currentCategory.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 mb-6">
|
||||
<Link href="/store">
|
||||
<Badge variant="outline" className="cursor-pointer hover:bg-muted">
|
||||
全部分类
|
||||
</Badge>
|
||||
</Link>
|
||||
{categories?.map((cat) => (
|
||||
<Link key={cat.id} href={`/store/category/${cat.slug}`}>
|
||||
<Badge
|
||||
variant={cat.slug === slug ? "default" : "secondary"}
|
||||
className="cursor-pointer hover:bg-secondary/80"
|
||||
>
|
||||
{cat.name}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isLoading ? "加载中..." : `共 ${apps.length} 个应用`}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<SlidersHorizontal className="h-4 w-4 text-muted-foreground mr-1" />
|
||||
{(Object.keys(sortLabels) as SortOption[]).map((key) => (
|
||||
<Button
|
||||
key={key}
|
||||
variant={sort === key ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="text-xs h-7"
|
||||
onClick={() => setSort(key)}
|
||||
>
|
||||
{sortLabels[key]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-36 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : apps.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{apps.map((app) => (
|
||||
<AppCard key={app.id} app={app} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20 text-muted-foreground">
|
||||
<p className="text-lg mb-2">该分类暂无应用</p>
|
||||
<p className="text-sm">敬请期待更多精彩应用上线</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import api from "@/lib/api";
|
||||
import type { App, Category } from "@/lib/types";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { AppCard } from "@/components/app-card/app-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Sparkles, LayoutGrid } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
function SectionHeader({
|
||||
title,
|
||||
icon: Icon,
|
||||
href,
|
||||
}: {
|
||||
title: string;
|
||||
icon?: LucideIcon;
|
||||
href?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||
<span className="inline-block w-1 h-5 bg-blue-800 rounded-full mr-1" />
|
||||
{Icon && <Icon className="h-5 w-5 text-blue-700" />}
|
||||
{title}
|
||||
</h2>
|
||||
{href && (
|
||||
<Link
|
||||
href={href}
|
||||
className="text-sm text-blue-700 hover:text-blue-900 font-medium transition-colors"
|
||||
>
|
||||
查看全部 →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppGridSkeleton({ count = 4 }: { count?: number }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-36 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StorePage() {
|
||||
const searchParams = useSearchParams();
|
||||
const query = searchParams.get("q") || "";
|
||||
const { user } = useAuthStore();
|
||||
const orgId = user?.org_id || "";
|
||||
const orgParam = orgId ? `org_id=${orgId}` : "";
|
||||
|
||||
const { data: categories } = useQuery({
|
||||
queryKey: ["categories", orgId],
|
||||
queryFn: () => api.get<Category[]>(`/api/v1/store/categories?${orgParam}`),
|
||||
});
|
||||
|
||||
const { data: featured, isLoading: featuredLoading } = useQuery({
|
||||
queryKey: ["featured", orgId],
|
||||
queryFn: () => api.get<App[]>(`/api/v1/store/featured?${orgParam}`),
|
||||
enabled: !query,
|
||||
});
|
||||
|
||||
const { data: topApps, isLoading: topLoading } = useQuery({
|
||||
queryKey: ["topApps", orgId],
|
||||
queryFn: () => api.get<App[]>(`/api/v1/store/rankings?${orgParam}`),
|
||||
enabled: !query,
|
||||
});
|
||||
|
||||
const { data: searchResults, isLoading: searchLoading } = useQuery({
|
||||
queryKey: ["search", query, orgId],
|
||||
queryFn: () => api.get<{ items: App[] }>(`/api/v1/store/apps?q=${encodeURIComponent(query)}&${orgParam}`),
|
||||
enabled: !!query,
|
||||
});
|
||||
|
||||
if (query) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl px-3 md:px-6 lg:px-8 py-4 md:py-6">
|
||||
<h1 className="text-xl font-bold mb-4">
|
||||
搜索结果:“{query}”
|
||||
</h1>
|
||||
{searchLoading ? (
|
||||
<AppGridSkeleton count={8} />
|
||||
) : searchResults?.items?.length ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{searchResults.items.map((app) => (
|
||||
<AppCard key={app.id} app={app} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
未找到相关政务应用
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl px-3 md:px-6 lg:px-8 py-4 md:py-8 space-y-6 md:space-y-10">
|
||||
{/* Featured */}
|
||||
<section>
|
||||
<SectionHeader title="推荐应用" icon={Sparkles} />
|
||||
{featuredLoading ? (
|
||||
<AppGridSkeleton />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{featured?.map((app) => (
|
||||
<AppCard key={app.id} app={app} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Categories */}
|
||||
<section>
|
||||
<SectionHeader title="应用分类" icon={LayoutGrid} />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/store">
|
||||
<Badge variant="default" className="cursor-pointer">全部</Badge>
|
||||
</Link>
|
||||
{categories?.map((cat) => (
|
||||
<Link key={cat.id} href={`/store/category/${cat.slug}`}>
|
||||
<Badge variant="secondary" className="cursor-pointer hover:bg-secondary/80">
|
||||
{cat.name}
|
||||
{cat.app_count != null && cat.app_count > 0 && (
|
||||
<span className="ml-1 text-muted-foreground">{cat.app_count}</span>
|
||||
)}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* All Apps */}
|
||||
<section>
|
||||
<SectionHeader title="全部应用" icon={LayoutGrid} />
|
||||
{topLoading ? (
|
||||
<AppGridSkeleton />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{topApps?.map((app) => (
|
||||
<AppCard key={app.id} app={app} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import api from "@/lib/api";
|
||||
import type { App } from "@/lib/types";
|
||||
import { AppCard } from "@/components/app-card/app-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Clock, Star, LayoutDashboard } from "lucide-react";
|
||||
|
||||
export default function WorkspacePage() {
|
||||
const { data: recentApps, isLoading: recentLoading } = useQuery({
|
||||
queryKey: ["recentApps"],
|
||||
queryFn: () => api.get<App[]>("/api/v1/store/recent"),
|
||||
});
|
||||
|
||||
const { data: favorites, isLoading: favsLoading } = useQuery({
|
||||
queryKey: ["favorites"],
|
||||
queryFn: () => api.get<App[]>("/api/v1/me/favorites"),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl px-6 lg:px-8 py-6 space-y-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<LayoutDashboard className="h-6 w-6 text-blue-800" />
|
||||
<h1 className="text-2xl font-bold text-foreground">我的工作台</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-6">快速访问常用应用,提升办公效率</p>
|
||||
|
||||
{/* Recent */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-blue-600" /> 最近使用
|
||||
</h2>
|
||||
{recentLoading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-36 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : recentApps?.length ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{recentApps.map((app) => (
|
||||
<AppCard key={app.id} app={app} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
还没有使用过任何应用,前往应用中心查看
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Favorites */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-amber-500" /> 我的收藏
|
||||
</h2>
|
||||
{favsLoading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-36 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : favorites?.length ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{favorites.map((app) => (
|
||||
<AppCard key={app.id} app={app} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
还没有收藏任何应用
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { AlertCircle, RotateCcw, Home } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error("[GlobalError]", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center px-4">
|
||||
<div className="flex flex-col items-center text-center max-w-md">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 mb-6">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">页面出现异常</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
很抱歉,系统遇到了意外错误。请尝试刷新页面,如问题持续存在请联系管理员。
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="text-xs text-muted-foreground/60 mb-4 font-mono">
|
||||
错误标识:{error.digest}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={reset} className="gap-2">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重试
|
||||
</Button>
|
||||
<a href="/store">
|
||||
<Button className="gap-2">
|
||||
<Home className="h-4 w-4" />
|
||||
返回首页
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,130 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-heading: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(0.985 0.002 250);
|
||||
--foreground: oklch(0.145 0.015 250);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0.015 250);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0.015 250);
|
||||
--primary: oklch(0.30 0.10 250);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.96 0.01 250);
|
||||
--secondary-foreground: oklch(0.25 0.06 250);
|
||||
--muted: oklch(0.96 0.008 250);
|
||||
--muted-foreground: oklch(0.50 0.02 250);
|
||||
--accent: oklch(0.96 0.01 250);
|
||||
--accent-foreground: oklch(0.25 0.06 250);
|
||||
--destructive: oklch(0.55 0.22 25);
|
||||
--border: oklch(0.90 0.01 250);
|
||||
--input: oklch(0.90 0.01 250);
|
||||
--ring: oklch(0.45 0.12 250);
|
||||
--chart-1: oklch(0.45 0.15 250);
|
||||
--chart-2: oklch(0.55 0.20 25);
|
||||
--chart-3: oklch(0.60 0.10 150);
|
||||
--chart-4: oklch(0.65 0.12 300);
|
||||
--chart-5: oklch(0.55 0.15 50);
|
||||
--radius: 0.5rem;
|
||||
--sidebar: oklch(0.97 0.005 250);
|
||||
--sidebar-foreground: oklch(0.145 0.015 250);
|
||||
--sidebar-primary: oklch(0.35 0.12 250);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.94 0.01 250);
|
||||
--sidebar-accent-foreground: oklch(0.25 0.06 250);
|
||||
--sidebar-border: oklch(0.90 0.01 250);
|
||||
--sidebar-ring: oklch(0.45 0.12 250);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0.015 250);
|
||||
--foreground: oklch(0.985 0.002 250);
|
||||
--card: oklch(0.20 0.02 250);
|
||||
--card-foreground: oklch(0.985 0.002 250);
|
||||
--popover: oklch(0.20 0.02 250);
|
||||
--popover-foreground: oklch(0.985 0.002 250);
|
||||
--primary: oklch(0.60 0.15 250);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.27 0.02 250);
|
||||
--secondary-foreground: oklch(0.985 0.002 250);
|
||||
--muted: oklch(0.27 0.02 250);
|
||||
--muted-foreground: oklch(0.70 0.02 250);
|
||||
--accent: oklch(0.27 0.02 250);
|
||||
--accent-foreground: oklch(0.985 0.002 250);
|
||||
--destructive: oklch(0.70 0.19 22);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.55 0.12 250);
|
||||
--chart-1: oklch(0.60 0.15 250);
|
||||
--chart-2: oklch(0.65 0.20 25);
|
||||
--chart-3: oklch(0.65 0.10 150);
|
||||
--chart-4: oklch(0.70 0.12 300);
|
||||
--chart-5: oklch(0.60 0.15 50);
|
||||
--sidebar: oklch(0.20 0.02 250);
|
||||
--sidebar-foreground: oklch(0.985 0.002 250);
|
||||
--sidebar-primary: oklch(0.55 0.18 250);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.27 0.02 250);
|
||||
--sidebar-accent-foreground: oklch(0.985 0.002 250);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.55 0.12 250);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Providers } from "@/components/providers";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AI智能应用平台",
|
||||
description: "AI智能办公平台,提升效能,赋能智慧办公",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="zh-CN"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<Providers>{children}</Providers>
|
||||
<Toaster position="top-center" richColors />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export default function GlobalLoading() {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { FileQuestion, Home } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-4">
|
||||
<div className="flex flex-col items-center text-center max-w-md">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-6">
|
||||
<FileQuestion className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">页面未找到</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
您访问的页面不存在或已被移除,请检查链接是否正确。
|
||||
</p>
|
||||
<Link href="/store">
|
||||
<Button className="gap-2">
|
||||
<Home className="h-4 w-4" />
|
||||
返回应用中心
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/store");
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import api from "@/lib/api";
|
||||
import { AppIcon } from "@/lib/app-icon";
|
||||
import type { PlatformApp, PlatformOrg } from "@/lib/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import { Star, Archive } from "lucide-react";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
pending_review: "审核中",
|
||||
approved: "已上架",
|
||||
rejected: "已驳回",
|
||||
archived: "已归档",
|
||||
};
|
||||
|
||||
const statusColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
draft: "outline",
|
||||
pending_review: "secondary",
|
||||
approved: "default",
|
||||
rejected: "destructive",
|
||||
archived: "outline",
|
||||
};
|
||||
|
||||
export default function PlatformAppsPage() {
|
||||
const qc = useQueryClient();
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [orgFilter, setOrgFilter] = useState("all");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const { data: orgs } = useQuery({
|
||||
queryKey: ["platformOrgs"],
|
||||
queryFn: () => api.get<PlatformOrg[]>("/api/v1/platform/orgs"),
|
||||
});
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["platformApps", statusFilter, orgFilter, page],
|
||||
queryFn: () => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(page));
|
||||
if (statusFilter !== "all") p.set("status", statusFilter);
|
||||
if (orgFilter !== "all") p.set("org_id", orgFilter);
|
||||
return api.get<{
|
||||
items: PlatformApp[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}>(`/api/v1/platform/apps?${p}`);
|
||||
},
|
||||
});
|
||||
|
||||
const resetStatus = (v: string | null) => {
|
||||
setStatusFilter(v ?? "all");
|
||||
setPage(1);
|
||||
};
|
||||
const resetOrg = (v: string | null) => {
|
||||
setOrgFilter(v ?? "all");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const setFeatured = useMutation({
|
||||
mutationFn: ({ id, is_featured }: { id: string; is_featured: boolean }) =>
|
||||
api.put(`/api/v1/platform/apps/${id}/featured`, { is_featured }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["platformApps"] });
|
||||
toast.success("已更新");
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const forceDelist = useMutation({
|
||||
mutationFn: (id: string) => api.post(`/api/v1/platform/apps/${id}/force-delist`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["platformApps"] });
|
||||
toast.success("已强制下架");
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">全局应用</h1>
|
||||
<span className="text-xs text-muted-foreground">共 {data?.total ?? 0} 条</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
<Select value={orgFilter} onValueChange={resetOrg}>
|
||||
<SelectTrigger className="w-40">
|
||||
<span>{orgFilter === "all" ? "全部机构" : (orgs?.find((o) => o.id === orgFilter)?.short_name || orgs?.find((o) => o.id === orgFilter)?.name || orgFilter)}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部机构</SelectItem>
|
||||
{orgs?.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>
|
||||
{o.short_name || o.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={resetStatus}>
|
||||
<SelectTrigger className="w-32">
|
||||
<span>{statusFilter === "all" ? "全部状态" : (statusLabels[statusFilter] || statusFilter)}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="approved">已上架</SelectItem>
|
||||
<SelectItem value="pending_review">审核中</SelectItem>
|
||||
<SelectItem value="draft">草稿</SelectItem>
|
||||
<SelectItem value="rejected">已驳回</SelectItem>
|
||||
<SelectItem value="archived">已归档</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
page={data?.page ?? 1}
|
||||
pageSize={data?.page_size ?? 20}
|
||||
total={data?.total ?? 0}
|
||||
onChange={setPage}
|
||||
/>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left p-3">应用</th>
|
||||
<th className="text-left p-3">所属机构</th>
|
||||
<th className="text-left p-3">创建者</th>
|
||||
<th className="text-left p-3">状态</th>
|
||||
<th className="text-left p-3">使用次数</th>
|
||||
<th className="text-left p-3">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.items?.map((app) => (
|
||||
<tr key={app.id} className="border-t hover:bg-muted/30">
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AppIcon iconUrl={app.icon_url} size={20} className="shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium flex items-center gap-1">
|
||||
{app.name}
|
||||
{app.is_featured && <Star className="h-3 w-3 fill-yellow-400 text-yellow-400" />}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
{app.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{app.org_name ? (
|
||||
<Badge variant="outline">{app.org_short || app.org_name}</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground text-xs">{app.creator_name}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={statusColors[app.status]}>{statusLabels[app.status]}</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">{app.usage_count}</td>
|
||||
<td className="p-3">
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs"
|
||||
onClick={() =>
|
||||
setFeatured.mutate({ id: app.id, is_featured: !app.is_featured })
|
||||
}
|
||||
>
|
||||
<Star
|
||||
className={`h-3 w-3 ${
|
||||
app.is_featured ? "fill-yellow-400 text-yellow-400" : ""
|
||||
}`}
|
||||
/>
|
||||
{app.is_featured ? "取消精选" : "精选"}
|
||||
</Button>
|
||||
{app.status === "approved" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs text-destructive hover:text-destructive"
|
||||
onClick={() => forceDelist.mutate(app.id)}
|
||||
>
|
||||
<Archive className="h-3 w-3" /> 强制下架
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import api from "@/lib/api";
|
||||
import type { PlatformAuditLog, PlatformOrg } from "@/lib/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
|
||||
export default function PlatformAuditPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [orgFilter, setOrgFilter] = useState("all");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const { data: orgs } = useQuery({
|
||||
queryKey: ["platformOrgs"],
|
||||
queryFn: () => api.get<PlatformOrg[]>("/api/v1/platform/orgs"),
|
||||
});
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["platformAuditLogs", search, orgFilter, page],
|
||||
queryFn: () => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(page));
|
||||
if (search) p.set("action", search);
|
||||
if (orgFilter !== "all") p.set("org_id", orgFilter);
|
||||
return api.get<{
|
||||
items: PlatformAuditLog[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}>(`/api/v1/platform/audit-logs?${p}`);
|
||||
},
|
||||
});
|
||||
|
||||
const resetSearch = (v: string) => {
|
||||
setSearch(v);
|
||||
setPage(1);
|
||||
};
|
||||
const resetOrg = (v: string | null) => {
|
||||
setOrgFilter(v ?? "all");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">全局审计日志</h1>
|
||||
<span className="text-xs text-muted-foreground">共 {data?.total ?? 0} 条</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
<Input
|
||||
placeholder="搜索操作(如 POST 或 path 关键字)..."
|
||||
value={search}
|
||||
onChange={(e) => resetSearch(e.target.value)}
|
||||
className="w-72"
|
||||
/>
|
||||
<Select value={orgFilter} onValueChange={resetOrg}>
|
||||
<SelectTrigger className="w-40">
|
||||
<span>{orgFilter === "all" ? "全部机构" : (orgs?.find((o) => o.id === orgFilter)?.short_name || orgs?.find((o) => o.id === orgFilter)?.name || orgFilter)}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部机构</SelectItem>
|
||||
{orgs?.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>
|
||||
{o.short_name || o.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
page={data?.page ?? 1}
|
||||
pageSize={data?.page_size ?? 20}
|
||||
total={data?.total ?? 0}
|
||||
onChange={setPage}
|
||||
/>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left p-3">时间</th>
|
||||
<th className="text-left p-3">机构</th>
|
||||
<th className="text-left p-3">用户</th>
|
||||
<th className="text-left p-3">操作</th>
|
||||
<th className="text-left p-3">资源</th>
|
||||
<th className="text-left p-3">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.items?.map((log) => (
|
||||
<tr key={log.id} className="border-t">
|
||||
<td className="p-3 text-muted-foreground whitespace-nowrap text-xs">
|
||||
{new Date(log.created_at).toLocaleString("zh-CN")}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{log.org_name ? (
|
||||
<Badge variant="outline">{log.org_short || log.org_name}</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="text-xs">
|
||||
<div>{log.user_name}</div>
|
||||
<div className="text-muted-foreground">{log.user_email}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs">{log.action}</td>
|
||||
<td className="p-3 text-muted-foreground text-xs">
|
||||
{log.resource_type}
|
||||
{log.resource_id ? `/${log.resource_id.slice(0, 8)}` : ""}
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground font-mono text-xs">{log.ip_address}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { Header } from "@/components/layout/header";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Building2,
|
||||
UsersRound,
|
||||
Boxes,
|
||||
ScrollText,
|
||||
Cpu,
|
||||
Gauge,
|
||||
ShieldAlert,
|
||||
Menu,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
const platformNavItems: { href: string; label: string; icon: LucideIcon }[] = [
|
||||
{ href: "/platform/overview", label: "平台总览", icon: LayoutDashboard },
|
||||
{ href: "/platform/orgs", label: "机构管理", icon: Building2 },
|
||||
{ href: "/platform/users", label: "全局用户", icon: UsersRound },
|
||||
{ href: "/platform/apps", label: "全局应用", icon: Boxes },
|
||||
{ href: "/platform/audit", label: "全局审计", icon: ScrollText },
|
||||
{ href: "/platform/providers", label: "模型提供商", icon: Cpu },
|
||||
{ href: "/platform/quotas", label: "配额管理", icon: Gauge },
|
||||
];
|
||||
|
||||
export default function PlatformLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && (!isAuthenticated || user?.role !== "super_admin")) {
|
||||
router.replace("/store");
|
||||
}
|
||||
}, [isAuthenticated, isLoading, user, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated || user?.role !== "super_admin") return null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Header />
|
||||
<div className="flex flex-1 relative">
|
||||
{/* 平台管理区身份标识条 */}
|
||||
<div className="hidden md:block absolute top-0 left-56 right-0 h-1 bg-gradient-to-r from-amber-500 via-orange-500 to-amber-500 z-10" />
|
||||
|
||||
{/* 手机端侧边栏切换按钮 */}
|
||||
<button
|
||||
className="md:hidden fixed bottom-4 right-4 z-50 p-3 rounded-full bg-amber-600 text-white shadow-lg"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
>
|
||||
{sidebarOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/40 md:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={`fixed inset-y-[3.5rem] left-0 z-50 w-56 border-r bg-background transition-transform duration-200 md:static md:inset-y-0 md:translate-x-0 ${
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="px-3 py-3 border-b bg-amber-50/50 dark:bg-amber-950/20">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-amber-900 dark:text-amber-200">
|
||||
<ShieldAlert className="h-4 w-4" />
|
||||
平台管理控制台
|
||||
</div>
|
||||
<p className="text-[11px] text-amber-700/70 dark:text-amber-300/60 mt-0.5">
|
||||
跨机构最高权限
|
||||
</p>
|
||||
</div>
|
||||
<nav className="p-3 space-y-1">
|
||||
{platformNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
|
||||
pathname === item.href
|
||||
? "bg-amber-600 text-white"
|
||||
: "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="flex-1 p-3 md:p-6 min-w-0">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import api from "@/lib/api";
|
||||
import type { PlatformOrg } from "@/lib/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Pencil, Power, Trash2, Building2, LogIn } from "lucide-react";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
interface OrgForm {
|
||||
id?: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
short_name: string;
|
||||
description: string;
|
||||
logo_url: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
const emptyForm: OrgForm = {
|
||||
name: "",
|
||||
slug: "",
|
||||
short_name: "",
|
||||
description: "",
|
||||
logo_url: "",
|
||||
sort_order: 0,
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
export default function PlatformOrgsPage() {
|
||||
const qc = useQueryClient();
|
||||
const { switchOrg } = useAuthStore();
|
||||
const [editing, setEditing] = useState<OrgForm | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<PlatformOrg | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const { data: orgs } = useQuery({
|
||||
queryKey: ["platformOrgs"],
|
||||
queryFn: () => api.get<PlatformOrg[]>("/api/v1/platform/orgs"),
|
||||
});
|
||||
|
||||
const pagedOrgs = orgs?.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (form: OrgForm) => api.post("/api/v1/platform/orgs", form),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["platformOrgs"] });
|
||||
toast.success("机构已创建");
|
||||
setEditing(null);
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: (form: OrgForm) => api.put(`/api/v1/platform/orgs/${form.id}`, form),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["platformOrgs"] });
|
||||
toast.success("已更新");
|
||||
setEditing(null);
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: ({ id, is_active }: { id: string; is_active: boolean }) =>
|
||||
api.put(`/api/v1/platform/orgs/${id}`, { is_active }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["platformOrgs"] });
|
||||
toast.success("已更新状态");
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/api/v1/platform/orgs/${id}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["platformOrgs"] });
|
||||
toast.success("已删除");
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!editing) return;
|
||||
if (!editing.name.trim() || !editing.slug.trim()) {
|
||||
toast.error("名称和标识不能为空");
|
||||
return;
|
||||
}
|
||||
if (editing.id) update.mutate(editing);
|
||||
else create.mutate(editing);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">机构管理</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
管理平台所有入驻机构(委办局/单位)
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setEditing({ ...emptyForm })} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
新增机构
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={orgs?.length ?? 0}
|
||||
onChange={setPage}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{pagedOrgs?.map((org) => (
|
||||
<div
|
||||
key={org.id}
|
||||
className="border rounded-lg p-4 hover:shadow-sm transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-9 w-9 rounded-md bg-amber-50 flex items-center justify-center">
|
||||
<Building2 className="h-5 w-5 text-amber-700" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium leading-tight">{org.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{org.short_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={org.is_active ? "default" : "outline"}>
|
||||
{org.is_active ? "启用" : "停用"}
|
||||
</Badge>
|
||||
</div>
|
||||
{org.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mb-3">
|
||||
{org.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground mb-3">
|
||||
<span>{org.user_count} 用户</span>
|
||||
<span>{org.app_count} 应用</span>
|
||||
<span className="font-mono text-[10px]">{org.slug}</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 gap-1 text-xs text-blue-600 hover:text-blue-700"
|
||||
onClick={async () => {
|
||||
await switchOrg(org.id);
|
||||
window.location.href = "/dashboard";
|
||||
}}
|
||||
>
|
||||
<LogIn className="h-3 w-3" /> 进入后台
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 gap-1 text-xs"
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
short_name: org.short_name,
|
||||
description: org.description,
|
||||
logo_url: org.logo_url,
|
||||
sort_order: org.sort_order,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Pencil className="h-3 w-3" /> 编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 gap-1 text-xs"
|
||||
onClick={() => toggle.mutate({ id: org.id, is_active: !org.is_active })}
|
||||
>
|
||||
<Power className="h-3 w-3" />
|
||||
{org.is_active ? "停用" : "启用"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 gap-1 text-xs text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(org)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" /> 删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 新建/编辑对话框 */}
|
||||
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing?.id ? "编辑机构" : "新增机构"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>机构名称 *</Label>
|
||||
<Input
|
||||
value={editing?.name || ""}
|
||||
onChange={(e) => setEditing({ ...editing!, name: e.target.value })}
|
||||
placeholder="例:科学技术局"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>简称</Label>
|
||||
<Input
|
||||
value={editing?.short_name || ""}
|
||||
onChange={(e) => setEditing({ ...editing!, short_name: e.target.value })}
|
||||
placeholder="例:科技局"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>标识 (slug) *</Label>
|
||||
<Input
|
||||
value={editing?.slug || ""}
|
||||
onChange={(e) => setEditing({ ...editing!, slug: e.target.value })}
|
||||
disabled={!!editing?.id}
|
||||
placeholder="例:keji(创建后不可修改)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>描述</Label>
|
||||
<Textarea
|
||||
value={editing?.description || ""}
|
||||
onChange={(e) => setEditing({ ...editing!, description: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="机构职能描述"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>排序</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editing?.sort_order ?? 0}
|
||||
onChange={(e) =>
|
||||
setEditing({ ...editing!, sort_order: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={create.isPending || update.isPending}
|
||||
>
|
||||
{editing?.id ? "保存" : "创建"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认 */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(o) => !o && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除机构</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
将永久删除「{deleteTarget?.name}」。如果该机构下还有用户或应用,删除会被拒绝,请先迁移或停用。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => deleteTarget && remove.mutate(deleteTarget.id)}
|
||||
>
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import api from "@/lib/api";
|
||||
import type { PlatformOverview, OrgRanking } from "@/lib/types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Building2,
|
||||
Users,
|
||||
Boxes,
|
||||
Activity,
|
||||
Coins,
|
||||
DollarSign,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
primary,
|
||||
secondary,
|
||||
icon: Icon,
|
||||
accent,
|
||||
}: {
|
||||
title: string;
|
||||
primary: string | number;
|
||||
secondary?: string;
|
||||
icon: LucideIcon;
|
||||
accent: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
|
||||
<div className={`flex h-9 w-9 items-center justify-center rounded-lg ${accent}`}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{primary}</div>
|
||||
{secondary && <p className="text-xs text-muted-foreground mt-1">{secondary}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlatformOverviewPage() {
|
||||
const { data: stats, isLoading } = useQuery({
|
||||
queryKey: ["platformOverview"],
|
||||
queryFn: () => api.get<PlatformOverview>("/api/v1/platform/overview"),
|
||||
});
|
||||
|
||||
const { data: ranking } = useQuery({
|
||||
queryKey: ["platformOrgRanking"],
|
||||
queryFn: () => api.get<OrgRanking[]>("/api/v1/platform/org-ranking"),
|
||||
});
|
||||
|
||||
const maxConv = Math.max(1, ...(ranking?.map((r) => r.conversations) || [1]));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">平台总览</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">所有机构的全局聚合数据</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-28" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
title="入驻机构"
|
||||
primary={stats?.total_orgs ?? 0}
|
||||
secondary={`其中 ${stats?.active_orgs ?? 0} 个活跃`}
|
||||
icon={Building2}
|
||||
accent="bg-amber-50 text-amber-700"
|
||||
/>
|
||||
<StatCard
|
||||
title="平台用户"
|
||||
primary={formatNumber(stats?.total_users ?? 0)}
|
||||
secondary={`${formatNumber(stats?.active_users ?? 0)} 活跃`}
|
||||
icon={Users}
|
||||
accent="bg-blue-50 text-blue-700"
|
||||
/>
|
||||
<StatCard
|
||||
title="平台应用"
|
||||
primary={formatNumber(stats?.total_apps ?? 0)}
|
||||
secondary={`${stats?.approved_apps ?? 0} 已上架`}
|
||||
icon={Boxes}
|
||||
accent="bg-purple-50 text-purple-700"
|
||||
/>
|
||||
<StatCard
|
||||
title="今日登录"
|
||||
primary={formatNumber(stats?.today_logins ?? 0)}
|
||||
secondary={`今日对话 ${formatNumber(stats?.today_convs ?? 0)} 次`}
|
||||
icon={Activity}
|
||||
accent="bg-green-50 text-green-700"
|
||||
/>
|
||||
<StatCard
|
||||
title="本月 Token"
|
||||
primary={formatNumber(stats?.monthly_tokens ?? 0)}
|
||||
icon={Coins}
|
||||
accent="bg-rose-50 text-rose-700"
|
||||
/>
|
||||
<StatCard
|
||||
title="本月成本"
|
||||
primary={`$${(stats?.monthly_cost ?? 0).toFixed(2)}`}
|
||||
icon={DollarSign}
|
||||
accent="bg-orange-50 text-orange-700"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">机构活跃度排行(本月)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{ranking?.length ? (
|
||||
<div className="space-y-3">
|
||||
{ranking.map((r, i) => (
|
||||
<div key={r.id} className="flex items-center gap-3">
|
||||
<span className="w-6 text-center text-sm font-bold text-muted-foreground">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="w-32 text-sm truncate font-medium">
|
||||
{r.short_name || r.name}
|
||||
</span>
|
||||
<div className="flex-1 bg-muted rounded-full h-6 overflow-hidden">
|
||||
<div
|
||||
className="bg-gradient-to-r from-amber-500 to-orange-500 h-full rounded-full transition-all flex items-center justify-end px-2"
|
||||
style={{ width: `${(r.conversations / maxConv) * 100}%` }}
|
||||
>
|
||||
<span className="text-xs text-white font-medium">{r.conversations}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="w-20 text-xs text-muted-foreground text-right">
|
||||
{r.users}用户/{r.apps}应用
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">暂无数据</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import api from "@/lib/api";
|
||||
import type { ModelProvider } from "@/lib/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Pencil, Power, Trash2, Cpu, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
|
||||
interface ProviderForm {
|
||||
id?: string;
|
||||
name: string;
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
models: string; // JSON string
|
||||
is_active: boolean;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
const emptyForm: ProviderForm = {
|
||||
name: "",
|
||||
base_url: "",
|
||||
api_key: "",
|
||||
models: '[]',
|
||||
is_active: true,
|
||||
priority: 0,
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 8;
|
||||
|
||||
export default function PlatformProvidersPage() {
|
||||
const qc = useQueryClient();
|
||||
const [editing, setEditing] = useState<ProviderForm | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ModelProvider | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const { data: providers } = useQuery({
|
||||
queryKey: ["platformProviders"],
|
||||
queryFn: () => api.get<ModelProvider[]>("/api/v1/platform/providers"),
|
||||
});
|
||||
|
||||
const pagedProviders = providers?.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (form: ProviderForm) => {
|
||||
let modelsJSON: unknown;
|
||||
try {
|
||||
modelsJSON = JSON.parse(form.models || "[]");
|
||||
} catch {
|
||||
throw new Error("models 不是合法 JSON");
|
||||
}
|
||||
return api.post("/api/v1/platform/providers", {
|
||||
...form,
|
||||
models: modelsJSON,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["platformProviders"] });
|
||||
toast.success("已创建");
|
||||
setEditing(null);
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: (form: ProviderForm) => {
|
||||
let modelsJSON: unknown;
|
||||
try {
|
||||
modelsJSON = JSON.parse(form.models || "[]");
|
||||
} catch {
|
||||
throw new Error("models 不是合法 JSON");
|
||||
}
|
||||
const payload: Record<string, unknown> = {
|
||||
name: form.name,
|
||||
base_url: form.base_url,
|
||||
models: modelsJSON,
|
||||
is_active: form.is_active,
|
||||
priority: form.priority,
|
||||
};
|
||||
if (form.api_key) payload.api_key = form.api_key;
|
||||
return api.put(`/api/v1/platform/providers/${form.id}`, payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["platformProviders"] });
|
||||
toast.success("已更新");
|
||||
setEditing(null);
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: ({ id, is_active }: { id: string; is_active: boolean }) =>
|
||||
api.put(`/api/v1/platform/providers/${id}`, { is_active }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["platformProviders"] }),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/api/v1/platform/providers/${id}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["platformProviders"] });
|
||||
toast.success("已删除");
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!editing) return;
|
||||
if (!editing.name || !editing.base_url) {
|
||||
toast.error("名称和 URL 不能为空");
|
||||
return;
|
||||
}
|
||||
if (!editing.id && !editing.api_key) {
|
||||
toast.error("新增时必须填写 API Key");
|
||||
return;
|
||||
}
|
||||
if (editing.id) update.mutate(editing);
|
||||
else create.mutate(editing);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">模型提供商</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
管理 LLM 提供商接入配置(OpenAI 兼容协议)
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setEditing({ ...emptyForm })} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
新增提供商
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={providers?.length ?? 0}
|
||||
onChange={setPage}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{pagedProviders?.map((p) => (
|
||||
<div key={p.id} className="border rounded-lg p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-9 w-9 rounded-md bg-purple-50 flex items-center justify-center">
|
||||
<Cpu className="h-5 w-5 text-purple-700" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{p.name}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">{p.base_url}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
{p.is_active ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className={p.is_active ? "text-green-700 dark:text-green-400" : "text-muted-foreground"}>
|
||||
{p.is_active ? "已启用" : "已停用"}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">优先级 {p.priority}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mb-3 min-h-[1.5rem]">
|
||||
{Array.isArray(p.models) && p.models.length > 0 ? (
|
||||
p.models.slice(0, 6).map((m) => (
|
||||
<Badge key={m.name} variant="outline" className="text-[10px] py-0 px-1.5 font-mono">
|
||||
{m.display_name || m.name}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground italic">暂无模型配置</span>
|
||||
)}
|
||||
{Array.isArray(p.models) && p.models.length > 6 && (
|
||||
<span className="text-[10px] text-muted-foreground self-center">+{p.models.length - 6} 个</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 gap-1 text-xs"
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
base_url: p.base_url,
|
||||
api_key: "",
|
||||
models: JSON.stringify(p.models, null, 2),
|
||||
is_active: p.is_active,
|
||||
priority: p.priority,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Pencil className="h-3 w-3" /> 编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 gap-1 text-xs"
|
||||
onClick={() => toggle.mutate({ id: p.id, is_active: !p.is_active })}
|
||||
>
|
||||
<Power className="h-3 w-3" />
|
||||
{p.is_active ? "停用" : "启用"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 gap-1 text-xs text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(p)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" /> 删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{pagedProviders?.length === 0 && providers?.length === 0 && (
|
||||
<div className="md:col-span-2 text-center py-12 text-muted-foreground text-sm border border-dashed rounded-lg">
|
||||
暂无提供商,点击右上角新增
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing?.id ? "编辑提供商" : "新增提供商"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>名称 *</Label>
|
||||
<Input
|
||||
value={editing?.name || ""}
|
||||
onChange={(e) => setEditing({ ...editing!, name: e.target.value })}
|
||||
placeholder="阿里云百炼"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Base URL *</Label>
|
||||
<Input
|
||||
value={editing?.base_url || ""}
|
||||
onChange={(e) => setEditing({ ...editing!, base_url: e.target.value })}
|
||||
placeholder="https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>API Key {editing?.id ? "(留空则不修改)" : "*"}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={editing?.api_key || ""}
|
||||
onChange={(e) => setEditing({ ...editing!, api_key: e.target.value })}
|
||||
placeholder={editing?.id ? "保留为空表示不更新密钥" : "sk-xxxx"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>支持模型 (JSON)</Label>
|
||||
<Textarea
|
||||
value={editing?.models || "[]"}
|
||||
onChange={(e) => setEditing({ ...editing!, models: e.target.value })}
|
||||
rows={5}
|
||||
className="font-mono text-xs"
|
||||
placeholder='[{"name":"qwen-plus","display_name":"通义千问-Plus"}]'
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>优先级</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editing?.priority ?? 0}
|
||||
onChange={(e) =>
|
||||
setEditing({ ...editing!, priority: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_active"
|
||||
checked={editing?.is_active ?? true}
|
||||
onChange={(e) => setEditing({ ...editing!, is_active: e.target.checked })}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label htmlFor="is_active" className="cursor-pointer">
|
||||
启用
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}>
|
||||
{editing?.id ? "保存" : "创建"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(o) => !o && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
将永久删除提供商「{deleteTarget?.name}」,依赖此提供商的功能将不可用。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => deleteTarget && remove.mutate(deleteTarget.id)}
|
||||
>
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import api from "@/lib/api";
|
||||
import type { ModelQuota, ModelProvider, PlatformUser } from "@/lib/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Pencil, Trash2 } from "lucide-react";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
|
||||
interface QuotaForm {
|
||||
id?: string;
|
||||
target_type: "global" | "department" | "user";
|
||||
target_id: string;
|
||||
model_name: string;
|
||||
daily_token_limit: string;
|
||||
monthly_token_limit: string;
|
||||
daily_request_limit: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
const emptyForm: QuotaForm = {
|
||||
target_type: "global",
|
||||
target_id: "",
|
||||
model_name: "",
|
||||
daily_token_limit: "",
|
||||
monthly_token_limit: "",
|
||||
daily_request_limit: "",
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
const targetTypeLabel: Record<string, string> = {
|
||||
global: "全局",
|
||||
department: "部门",
|
||||
user: "用户",
|
||||
};
|
||||
|
||||
function formatNum(n?: number | null): string {
|
||||
if (n == null) return "—";
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function PlatformQuotasPage() {
|
||||
const qc = useQueryClient();
|
||||
const [editing, setEditing] = useState<QuotaForm | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const { data: quotas } = useQuery({
|
||||
queryKey: ["platformQuotas"],
|
||||
queryFn: () => api.get<ModelQuota[]>("/api/v1/platform/quotas"),
|
||||
});
|
||||
|
||||
const { data: providersData } = useQuery({
|
||||
queryKey: ["platformProviders"],
|
||||
queryFn: () => api.get<ModelProvider[]>("/api/v1/platform/providers"),
|
||||
});
|
||||
|
||||
const { data: usersData } = useQuery({
|
||||
queryKey: ["platformUsersAll"],
|
||||
queryFn: () =>
|
||||
api.get<{ items: PlatformUser[] }>("/api/v1/platform/users?page=1&page_size=200"),
|
||||
});
|
||||
|
||||
const allModels = providersData
|
||||
?.flatMap((p) => (Array.isArray(p.models) ? p.models : []))
|
||||
.filter((m, i, arr) => arr.findIndex((x) => x.name === m.name) === i) ?? [];
|
||||
|
||||
const pagedQuotas = quotas?.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
const upsert = useMutation({
|
||||
mutationFn: (form: QuotaForm) => {
|
||||
const payload = {
|
||||
id: form.id,
|
||||
target_type: form.target_type,
|
||||
target_id: form.target_type === "global" ? null : form.target_id || null,
|
||||
model_name: form.model_name || null,
|
||||
daily_token_limit: form.daily_token_limit ? parseInt(form.daily_token_limit) : null,
|
||||
monthly_token_limit: form.monthly_token_limit ? parseInt(form.monthly_token_limit) : null,
|
||||
daily_request_limit: form.daily_request_limit ? parseInt(form.daily_request_limit) : null,
|
||||
is_active: form.is_active,
|
||||
};
|
||||
return api.post("/api/v1/platform/quotas", payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["platformQuotas"] });
|
||||
toast.success("已保存");
|
||||
setEditing(null);
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/api/v1/platform/quotas/${id}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["platformQuotas"] });
|
||||
toast.success("已删除");
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">配额管理</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
为平台、部门、用户分别设置 Token 和请求频率限制
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setEditing({ ...emptyForm })} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
新增配额
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={quotas?.length ?? 0}
|
||||
onChange={setPage}
|
||||
/>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left p-3">作用范围</th>
|
||||
<th className="text-left p-3">目标</th>
|
||||
<th className="text-left p-3">模型</th>
|
||||
<th className="text-left p-3">日 Token 上限</th>
|
||||
<th className="text-left p-3">月 Token 上限</th>
|
||||
<th className="text-left p-3">日请求上限</th>
|
||||
<th className="text-left p-3">状态</th>
|
||||
<th className="text-left p-3">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pagedQuotas?.length ? (
|
||||
pagedQuotas.map((q) => (
|
||||
<tr key={q.id} className="border-t">
|
||||
<td className="p-3">
|
||||
<Badge variant="outline">{targetTypeLabel[q.target_type]}</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-xs">
|
||||
{q.target_type === "global" ? (
|
||||
<span className="text-muted-foreground italic">全平台</span>
|
||||
) : (
|
||||
q.target_name || q.target_id?.slice(0, 8)
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3 text-xs font-mono">
|
||||
{q.model_name || <span className="text-muted-foreground">所有模型</span>}
|
||||
</td>
|
||||
<td className="p-3 text-xs">{formatNum(q.daily_token_limit)}</td>
|
||||
<td className="p-3 text-xs">{formatNum(q.monthly_token_limit)}</td>
|
||||
<td className="p-3 text-xs">{formatNum(q.daily_request_limit)}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={q.is_active ? "default" : "outline"}>
|
||||
{q.is_active ? "启用" : "停用"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 gap-1 text-xs"
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
id: q.id,
|
||||
target_type: q.target_type,
|
||||
target_id: q.target_id || "",
|
||||
model_name: q.model_name || "",
|
||||
daily_token_limit: q.daily_token_limit?.toString() || "",
|
||||
monthly_token_limit: q.monthly_token_limit?.toString() || "",
|
||||
daily_request_limit: q.daily_request_limit?.toString() || "",
|
||||
is_active: q.is_active,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Pencil className="h-3 w-3" /> 编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 gap-1 text-xs text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
if (confirm("确认删除此配额?")) remove.mutate(q.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" /> 删除
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={8} className="p-8 text-center text-muted-foreground text-sm">
|
||||
暂无配额规则
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing?.id ? "编辑配额" : "新增配额"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>作用范围</Label>
|
||||
<Select
|
||||
value={editing?.target_type || "global"}
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setEditing({ ...editing!, target_type: v as QuotaForm["target_type"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<span>{targetTypeLabel[editing?.target_type || "global"]}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">全局</SelectItem>
|
||||
<SelectItem value="department">部门</SelectItem>
|
||||
<SelectItem value="user">用户</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{editing?.target_type === "user" && (
|
||||
<div>
|
||||
<Label>目标用户</Label>
|
||||
<Select
|
||||
value={editing?.target_id || ""}
|
||||
onValueChange={(v) => v && setEditing({ ...editing!, target_id: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<span>
|
||||
{editing?.target_id
|
||||
? (usersData?.items?.find((u) => u.id === editing.target_id)?.name +
|
||||
" (" +
|
||||
(usersData?.items?.find((u) => u.id === editing.target_id)?.email ?? "") + ")")
|
||||
: "请选择用户"}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{usersData?.items?.map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
<span className="text-muted-foreground text-xs ml-1">({u.email})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{editing?.target_type === "department" && (
|
||||
<div>
|
||||
<Label>部门 ID</Label>
|
||||
<Input
|
||||
value={editing?.target_id || ""}
|
||||
onChange={(e) => setEditing({ ...editing!, target_id: e.target.value })}
|
||||
placeholder="部门 UUID"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label>限制模型(空 = 所有模型)</Label>
|
||||
<Select
|
||||
value={editing?.model_name || "__all__"}
|
||||
onValueChange={(v) =>
|
||||
v !== null && setEditing({ ...editing!, model_name: v === "__all__" ? "" : v })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<span>
|
||||
{editing?.model_name
|
||||
? (allModels.find((m) => m.name === editing.model_name)?.display_name ||
|
||||
editing.model_name)
|
||||
: "所有模型"}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">所有模型</SelectItem>
|
||||
{allModels.map((m) => (
|
||||
<SelectItem key={m.name} value={m.name}>
|
||||
{m.display_name || m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>日 Token 上限</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editing?.daily_token_limit || ""}
|
||||
onChange={(e) =>
|
||||
setEditing({ ...editing!, daily_token_limit: e.target.value })
|
||||
}
|
||||
placeholder="留空=不限"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>月 Token 上限</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editing?.monthly_token_limit || ""}
|
||||
onChange={(e) =>
|
||||
setEditing({ ...editing!, monthly_token_limit: e.target.value })
|
||||
}
|
||||
placeholder="留空=不限"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>日请求次数上限</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editing?.daily_request_limit || ""}
|
||||
onChange={(e) =>
|
||||
setEditing({ ...editing!, daily_request_limit: e.target.value })
|
||||
}
|
||||
placeholder="留空=不限"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="quota_active"
|
||||
checked={editing?.is_active ?? true}
|
||||
onChange={(e) => setEditing({ ...editing!, is_active: e.target.checked })}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label htmlFor="quota_active" className="cursor-pointer">
|
||||
启用
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => editing && upsert.mutate(editing)}
|
||||
disabled={upsert.isPending}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import api from "@/lib/api";
|
||||
import type { PlatformUser, PlatformOrg } from "@/lib/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import { Building2 } from "lucide-react";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
super_admin: "平台管理员",
|
||||
admin: "机构管理员",
|
||||
creator: "创作者",
|
||||
user: "普通用户",
|
||||
};
|
||||
|
||||
const roleColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
super_admin: "destructive",
|
||||
admin: "default",
|
||||
creator: "secondary",
|
||||
user: "outline",
|
||||
};
|
||||
|
||||
export default function PlatformUsersPage() {
|
||||
const qc = useQueryClient();
|
||||
const [search, setSearch] = useState("");
|
||||
const [orgFilter, setOrgFilter] = useState("all");
|
||||
const [roleFilter, setRoleFilter] = useState("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [migrateTarget, setMigrateTarget] = useState<PlatformUser | null>(null);
|
||||
const [newOrgID, setNewOrgID] = useState("");
|
||||
|
||||
const { data: orgs } = useQuery({
|
||||
queryKey: ["platformOrgs"],
|
||||
queryFn: () => api.get<PlatformOrg[]>("/api/v1/platform/orgs"),
|
||||
});
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["platformUsers", search, orgFilter, roleFilter, page],
|
||||
queryFn: () => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(page));
|
||||
if (search) p.set("q", search);
|
||||
if (orgFilter !== "all") p.set("org_id", orgFilter);
|
||||
if (roleFilter !== "all") p.set("role", roleFilter);
|
||||
return api.get<{
|
||||
items: PlatformUser[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}>(`/api/v1/platform/users?${p}`);
|
||||
},
|
||||
});
|
||||
|
||||
// 切换过滤条件时重置到第一页
|
||||
const resetSearch = (v: string) => {
|
||||
setSearch(v);
|
||||
setPage(1);
|
||||
};
|
||||
const resetOrg = (v: string | null) => {
|
||||
setOrgFilter(v ?? "all");
|
||||
setPage(1);
|
||||
};
|
||||
const resetRole = (v: string | null) => {
|
||||
setRoleFilter(v ?? "all");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const updateRole = useMutation({
|
||||
mutationFn: ({ id, role }: { id: string; role: string }) =>
|
||||
api.put(`/api/v1/platform/users/${id}/role`, { role }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["platformUsers"] });
|
||||
toast.success("角色已更新");
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const updateStatus = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||||
api.put(`/api/v1/platform/users/${id}/status`, { status }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["platformUsers"] });
|
||||
toast.success("状态已更新");
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const assignOrg = useMutation({
|
||||
mutationFn: ({ id, org_id }: { id: string; org_id: string }) =>
|
||||
api.put(`/api/v1/platform/users/${id}/org`, { org_id }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["platformUsers"] });
|
||||
toast.success("已迁移到目标机构");
|
||||
setMigrateTarget(null);
|
||||
setNewOrgID("");
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold">全局用户</h1>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
共 {data?.total ?? 0} 条
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
<Input
|
||||
placeholder="搜索姓名或邮箱..."
|
||||
value={search}
|
||||
onChange={(e) => resetSearch(e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
<Select value={orgFilter} onValueChange={resetOrg}>
|
||||
<SelectTrigger className="w-40">
|
||||
<span>{orgFilter === "all" ? "全部机构" : (orgs?.find((o) => o.id === orgFilter)?.short_name || orgs?.find((o) => o.id === orgFilter)?.name || orgFilter)}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部机构</SelectItem>
|
||||
{orgs?.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>
|
||||
{o.short_name || o.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={roleFilter} onValueChange={resetRole}>
|
||||
<SelectTrigger className="w-36">
|
||||
<span>{roleFilter === "all" ? "全部角色" : (roleLabels[roleFilter] || roleFilter)}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部角色</SelectItem>
|
||||
<SelectItem value="super_admin">平台管理员</SelectItem>
|
||||
<SelectItem value="admin">机构管理员</SelectItem>
|
||||
<SelectItem value="creator">创作者</SelectItem>
|
||||
<SelectItem value="user">普通用户</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
page={data?.page ?? 1}
|
||||
pageSize={data?.page_size ?? 20}
|
||||
total={data?.total ?? 0}
|
||||
onChange={setPage}
|
||||
/>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left p-3">用户</th>
|
||||
<th className="text-left p-3">所属机构</th>
|
||||
<th className="text-left p-3">角色</th>
|
||||
<th className="text-left p-3">状态</th>
|
||||
<th className="text-left p-3">登录</th>
|
||||
<th className="text-left p-3">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.items?.map((u) => (
|
||||
<tr key={u.id} className="border-t">
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={u.avatar_url} />
|
||||
<AvatarFallback>{u.name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="font-medium">{u.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{u.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{u.org_name ? (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Building2 className="h-3 w-3" />
|
||||
{u.org_short || u.org_name}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground italic">—(平台级)</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={roleColors[u.role]}>{roleLabels[u.role]}</Badge>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={u.status === "active" ? "default" : "destructive"}>
|
||||
{u.status === "active" ? "正常" : "禁用"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground text-xs">
|
||||
{u.login_count} 次
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Select
|
||||
defaultValue={u.role}
|
||||
onValueChange={(role) => role && updateRole.mutate({ id: u.id, role })}
|
||||
>
|
||||
<SelectTrigger className="w-28 h-7 text-xs">
|
||||
<span>{roleLabels[u.role] || u.role}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">普通用户</SelectItem>
|
||||
<SelectItem value="creator">创作者</SelectItem>
|
||||
<SelectItem value="admin">机构管理员</SelectItem>
|
||||
<SelectItem value="super_admin">平台管理员</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => {
|
||||
setMigrateTarget(u);
|
||||
setNewOrgID(u.org_id || "");
|
||||
}}
|
||||
>
|
||||
迁移
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() =>
|
||||
updateStatus.mutate({
|
||||
id: u.id,
|
||||
status: u.status === "active" ? "disabled" : "active",
|
||||
})
|
||||
}
|
||||
>
|
||||
{u.status === "active" ? "禁用" : "启用"}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 迁移机构对话框 */}
|
||||
<Dialog open={!!migrateTarget} onOpenChange={(o) => !o && setMigrateTarget(null)}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>迁移用户机构</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
将用户「{migrateTarget?.name}」迁移到目标机构
|
||||
</p>
|
||||
<Select value={newOrgID} onValueChange={(v) => setNewOrgID(v ?? "")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择目标机构" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{orgs?.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>
|
||||
{o.name}({o.short_name})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setMigrateTarget(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!newOrgID || newOrgID === migrateTarget?.org_id}
|
||||
onClick={() =>
|
||||
migrateTarget && assignOrg.mutate({ id: migrateTarget.id, org_id: newOrgID })
|
||||
}
|
||||
>
|
||||
确认迁移
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { App } from "@/lib/types";
|
||||
import { Star } from "lucide-react";
|
||||
import { getCategoryIcon, getCategoryColor } from "@/lib/category-config";
|
||||
import { getAppTypeConfig } from "@/lib/app-type-config";
|
||||
|
||||
function formatCount(n: number): string {
|
||||
if (n >= 10000) return (n / 10000).toFixed(1) + "w";
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + "k";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function StarRating({ rating }: { rating: number }) {
|
||||
const stars = Math.round(rating);
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`h-3 w-3 ${
|
||||
i < stars ? "fill-amber-400 text-amber-400" : "text-gray-300"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
<span className="ml-0.5 text-muted-foreground">{rating.toFixed(1)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppCard({ app }: { app: App }) {
|
||||
const Icon = getCategoryIcon(app.category_slug);
|
||||
const colorClass = getCategoryColor(app.category_slug);
|
||||
const typeConfig = getAppTypeConfig(app.dify_app_type);
|
||||
const TypeIcon = typeConfig.icon;
|
||||
|
||||
return (
|
||||
<Link href={`/store/apps/${app.slug}`}>
|
||||
<Card className="group hover:shadow-lg transition-all duration-200 hover:-translate-y-0.5 cursor-pointer h-full border-border/60 hover:border-blue-300 relative overflow-hidden">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-0.5 bg-transparent group-hover:bg-blue-700 transition-colors" />
|
||||
<CardContent className="p-5 flex flex-col gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-xl ${colorClass} shrink-0`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-sm truncate group-hover:text-blue-800 transition-colors">
|
||||
{app.name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mt-1 leading-relaxed">
|
||||
{app.description || "暂无描述"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge variant="secondary" className="text-xs font-normal">
|
||||
{app.category_name || "其他"}
|
||||
</Badge>
|
||||
<span className={`inline-flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded-full ${typeConfig.badgeColor}`}>
|
||||
<TypeIcon className="h-2.5 w-2.5" />
|
||||
{typeConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatCount(app.usage_count)} 次使用
|
||||
</span>
|
||||
</div>
|
||||
{app.avg_rating > 0 && <StarRating rating={app.avg_rating} />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, memo, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { App, Conversation, Message, ToolCall } from "@/lib/types";
|
||||
import api, { streamChat } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Send,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Bot,
|
||||
Wrench,
|
||||
CheckCircle2,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
CheckSquare,
|
||||
Square,
|
||||
X,
|
||||
Copy,
|
||||
Download,
|
||||
Pencil,
|
||||
PanelLeftOpen,
|
||||
} from "lucide-react";
|
||||
import { getCategoryIcon, getCategoryColor } from "@/lib/category-config";
|
||||
import GovMarkdown from "@/components/ui/gov-markdown";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function parseToolCalls(content: string): { cleanContent: string; tools: ToolCall[] } {
|
||||
const tools: ToolCall[] = [];
|
||||
let cleanContent = content;
|
||||
const toolCallRegex = /\[工具调用:\s*(.+?)\]/g;
|
||||
const toolResultRegex = /\[工具结果:\s*(.+?)\]/g;
|
||||
let match;
|
||||
while ((match = toolCallRegex.exec(content)) !== null) {
|
||||
const name = match[1].trim();
|
||||
if (!tools.find((t) => t.name === name)) tools.push({ name, status: "running" });
|
||||
}
|
||||
while ((match = toolResultRegex.exec(content)) !== null) {
|
||||
const name = match[1].trim();
|
||||
const tool = tools.find((t) => t.name === name);
|
||||
if (tool) tool.status = "done";
|
||||
}
|
||||
cleanContent = cleanContent.replace(/\[工具调用:\s*.+?\]/g, "").replace(/\[工具结果:\s*.+?\]/g, "").trim();
|
||||
return { cleanContent, tools };
|
||||
}
|
||||
|
||||
const AgentMessage = memo(function AgentMessage({
|
||||
msg,
|
||||
onCopy,
|
||||
}: {
|
||||
msg: Message;
|
||||
onCopy: (text: string) => void;
|
||||
}) {
|
||||
if (msg.role === "user") {
|
||||
return (
|
||||
<div className="flex justify-end group/msg">
|
||||
<div className="max-w-[80%]">
|
||||
<div className="rounded-2xl px-4 py-2.5 bg-primary text-primary-foreground rounded-br-md shadow-sm">
|
||||
<p className="text-sm whitespace-pre-wrap">{msg.content}</p>
|
||||
</div>
|
||||
<div className="flex justify-end mt-1 opacity-0 group-hover/msg:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => onCopy(msg.content)}
|
||||
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground px-1.5 py-0.5 rounded"
|
||||
>
|
||||
<Copy className="h-3 w-3" /> 复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { cleanContent, tools: parsedTools } = parseToolCalls(msg.content);
|
||||
return (
|
||||
<div className="flex justify-start group/msg">
|
||||
<div className="max-w-[85%] space-y-2">
|
||||
{parsedTools.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{parsedTools.map((tool) => (
|
||||
<div
|
||||
key={tool.name}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs ${
|
||||
tool.status === "done" ? "bg-emerald-50 text-emerald-700" : "bg-amber-50 text-amber-700"
|
||||
}`}
|
||||
>
|
||||
{tool.status === "done" ? <CheckCircle2 className="h-3 w-3" /> : <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{tool.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-2xl px-5 py-3 bg-white dark:bg-card border border-border/50 rounded-bl-md shadow-sm">
|
||||
<GovMarkdown content={cleanContent} />
|
||||
</div>
|
||||
{cleanContent && (
|
||||
<div className="flex mt-1 opacity-0 group-hover/msg:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => onCopy(cleanContent)}
|
||||
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground px-1.5 py-0.5 rounded"
|
||||
>
|
||||
<Copy className="h-3 w-3" /> 复制
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface AgentUIProps {
|
||||
app: App;
|
||||
}
|
||||
|
||||
export default function AgentUI({ app }: AgentUIProps) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [conversationId, setConversationId] = useState<string | undefined>();
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [deleteTarget, setDeleteTarget] = useState<{
|
||||
type: "single" | "batch";
|
||||
id?: string;
|
||||
name?: string;
|
||||
} | null>(null);
|
||||
const [editingConvId, setEditingConvId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const appConfig = useMemo(() => {
|
||||
try {
|
||||
if (typeof app.app_config === "string") return JSON.parse(app.app_config);
|
||||
return app.app_config || {};
|
||||
} catch { return {}; }
|
||||
}, [app.app_config]);
|
||||
|
||||
const tools: string[] = appConfig.tools || [];
|
||||
|
||||
const { data: conversations = [] } = useQuery({
|
||||
queryKey: ["conversations", app.id],
|
||||
queryFn: async () => {
|
||||
const data = await api.get<{ data: Conversation[] }>(`/api/v1/apps/${app.id}/conversations`);
|
||||
return data.data || [];
|
||||
},
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => { scrollToBottom(); }, [messages, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (app.welcome_message && messages.length === 0 && !conversationId) {
|
||||
setMessages([{ id: "welcome", role: "assistant", content: app.welcome_message }]);
|
||||
}
|
||||
}, [app.welcome_message, messages.length, conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { abortRef.current?.abort(); };
|
||||
}, []);
|
||||
|
||||
const loadConversation = useCallback(async (convId: string) => {
|
||||
setConversationId(convId);
|
||||
try {
|
||||
const data = await api.get<{ data: Message[] }>(`/api/v1/apps/${app.id}/conversations/${convId}/messages`);
|
||||
setMessages(data.data || []);
|
||||
} catch {
|
||||
setMessages([]);
|
||||
}
|
||||
}, [app.id]);
|
||||
|
||||
const CategoryIcon = getCategoryIcon(app.category_slug);
|
||||
const categoryColor = getCategoryColor(app.category_slug);
|
||||
|
||||
const suggestedPrompts = useRef(
|
||||
(() => {
|
||||
try {
|
||||
if (typeof app.suggested_prompts === "string") return JSON.parse(app.suggested_prompts) as string[];
|
||||
return (app.suggested_prompts as string[]) || [];
|
||||
} catch { return []; }
|
||||
})()
|
||||
).current;
|
||||
|
||||
const copyText = useCallback((text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success("已复制到剪贴板");
|
||||
}, []);
|
||||
|
||||
const exportConversation = useCallback(() => {
|
||||
if (messages.length === 0) return;
|
||||
const lines = messages
|
||||
.filter((m) => m.id !== "welcome")
|
||||
.map((m) => {
|
||||
const role = m.role === "user" ? "【用户】" : "【AI助手】";
|
||||
return `${role}\n${m.content}`;
|
||||
});
|
||||
const text = `${app.name} - 对话记录\n导出时间:${new Date().toLocaleString("zh-CN")}\n${"=".repeat(40)}\n\n${lines.join("\n\n" + "-".repeat(40) + "\n\n")}`;
|
||||
const filename = `${app.name}-对话记录-${new Date().toISOString().slice(0, 10)}.txt`;
|
||||
const blob = new Blob([text], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 30000);
|
||||
toast.success("对话已导出");
|
||||
}, [messages, app.name]);
|
||||
|
||||
const sendMessage = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text || isStreaming) return;
|
||||
const userMsg: Message = { id: `u-${Date.now()}`, role: "user", content: text };
|
||||
const assistantMsg: Message = { id: `a-${Date.now()}`, role: "assistant", content: "" };
|
||||
|
||||
setMessages((prev) => [...prev, userMsg, assistantMsg]);
|
||||
setInput("");
|
||||
setIsStreaming(true);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const res = await streamChat(app.id, text, conversationId, controller.signal);
|
||||
if (!res.ok) throw new Error("请求失败");
|
||||
const reader = res.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
if (!reader) throw new Error("无法获取响应流");
|
||||
|
||||
let buffer = "";
|
||||
let accumulated = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const raw = line.slice(6);
|
||||
if (raw === "[DONE]") break;
|
||||
try {
|
||||
const event = JSON.parse(raw);
|
||||
if (event.conversation_id) setConversationId(event.conversation_id);
|
||||
if (event.answer) {
|
||||
accumulated += event.answer;
|
||||
const snap = accumulated;
|
||||
setMessages((prev) =>
|
||||
prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant"
|
||||
? { ...m, content: snap }
|
||||
: m
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
setMessages((prev) =>
|
||||
prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant" && !m.content
|
||||
? { ...m, content: "抱歉,系统处理异常,请稍后重试。" }
|
||||
: m
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setIsStreaming(false);
|
||||
}
|
||||
}, [input, isStreaming, app.id, conversationId, queryClient]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
||||
}, [sendMessage]);
|
||||
|
||||
const startNewConversation = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
setMessages([]);
|
||||
setConversationId(undefined);
|
||||
setIsStreaming(false);
|
||||
}, []);
|
||||
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelectedIds(new Set(conversations.map((c) => c.id)));
|
||||
}, [conversations]);
|
||||
|
||||
const confirmDeleteSingle = useCallback(async (convId: string) => {
|
||||
try {
|
||||
await api.delete(`/api/v1/apps/${app.id}/conversations/${convId}`);
|
||||
if (conversationId === convId) startNewConversation();
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
toast.success("对话已删除");
|
||||
} catch { toast.error("删除失败"); }
|
||||
setDeleteTarget(null);
|
||||
}, [app.id, conversationId, startNewConversation, queryClient]);
|
||||
|
||||
const confirmBatchDelete = useCallback(async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
try {
|
||||
await api.post(`/api/v1/apps/${app.id}/conversations/batch-delete`, {
|
||||
conversation_ids: Array.from(selectedIds),
|
||||
});
|
||||
if (conversationId && selectedIds.has(conversationId)) startNewConversation();
|
||||
setSelectedIds(new Set());
|
||||
setSelectMode(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
toast.success(`已删除 ${selectedIds.size} 个对话`);
|
||||
} catch { toast.error("批量删除失败"); }
|
||||
setDeleteTarget(null);
|
||||
}, [selectedIds, app.id, conversationId, startNewConversation, queryClient]);
|
||||
|
||||
const startRename = useCallback(
|
||||
(convId: string, currentName: string) => {
|
||||
setEditingConvId(convId);
|
||||
setEditingName(currentName);
|
||||
setTimeout(() => renameInputRef.current?.focus(), 50);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const saveRename = useCallback(async () => {
|
||||
if (!editingConvId || !editingName.trim()) {
|
||||
setEditingConvId(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.put(
|
||||
`/api/v1/apps/${app.id}/conversations/${editingConvId}/name`,
|
||||
{ name: editingName.trim() }
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
toast.success("已重命名");
|
||||
} catch {
|
||||
toast.error("重命名失败");
|
||||
}
|
||||
setEditingConvId(null);
|
||||
}, [editingConvId, editingName, app.id, queryClient]);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
||||
{/* 删除确认弹窗 */}
|
||||
<AlertDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteTarget?.type === "single"
|
||||
? `确定要删除对话"${deleteTarget.name || "新对话"}"吗?删除后无法恢复。`
|
||||
: `确定要删除选中的 ${selectedIds.size} 个对话吗?删除后无法恢复。`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => {
|
||||
if (deleteTarget?.type === "single" && deleteTarget.id) {
|
||||
confirmDeleteSingle(deleteTarget.id);
|
||||
} else {
|
||||
confirmBatchDelete();
|
||||
}
|
||||
}}
|
||||
>
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 手机端侧边栏遮罩层 */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/40 md:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 侧边栏 */}
|
||||
<div className={`fixed inset-y-[3.5rem] left-0 z-50 w-64 border-r bg-background flex flex-col shrink-0 overflow-hidden transition-transform duration-200 md:static md:inset-y-0 md:translate-x-0 ${sidebarOpen ? "translate-x-0" : "-translate-x-full"}`}>
|
||||
<div className="p-3 border-b space-y-2 shrink-0">
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start gap-1.5 text-muted-foreground" onClick={() => router.push("/store")}>
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> 返回应用中心
|
||||
</Button>
|
||||
<Button onClick={startNewConversation} className="w-full gap-1.5 bg-blue-900 hover:bg-blue-800 text-white" size="sm">
|
||||
<Plus className="h-3.5 w-3.5" /> 新对话
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tools.length > 0 && (
|
||||
<div className="p-3 border-b shrink-0">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2 flex items-center gap-1">
|
||||
<Wrench className="h-3 w-3" /> 可用工具
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tools.map((tool) => (
|
||||
<Badge key={tool} variant="outline" className="text-xs font-normal gap-1">
|
||||
<Sparkles className="h-2.5 w-2.5" />
|
||||
{tool}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conversations.length > 0 && (
|
||||
<div className="px-3 py-2 border-b flex items-center justify-between shrink-0">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectMode ? `已选 ${selectedIds.size} 个` : `${conversations.length} 个对话`}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{selectMode ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-xs" onClick={selectAll}>全选</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteTarget({ type: "batch" })}
|
||||
disabled={selectedIds.size === 0}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-0.5" /> 删除
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-xs" onClick={() => { setSelectMode(false); setSelectedIds(new Set()); }}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-xs" onClick={() => setSelectMode(true)}>
|
||||
<CheckSquare className="h-3 w-3 mr-0.5" /> 管理
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 p-2">
|
||||
{conversations.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">暂无对话历史</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{conversations.map((conv) => (
|
||||
<div key={conv.id} className="group flex items-center gap-1">
|
||||
{selectMode && (
|
||||
<button onClick={() => toggleSelect(conv.id)} className="shrink-0 p-0.5">
|
||||
{selectedIds.has(conv.id) ? <CheckSquare className="h-3.5 w-3.5 text-primary" /> : <Square className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
</button>
|
||||
)}
|
||||
{editingConvId === conv.id ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onBlur={saveRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveRename();
|
||||
if (e.key === "Escape") setEditingConvId(null);
|
||||
}}
|
||||
className="flex-1 px-2 py-1 text-sm border rounded-md bg-background focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
maxLength={50}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => !selectMode && loadConversation(conv.id)}
|
||||
onDoubleClick={() => !selectMode && startRename(conv.id, conv.name)}
|
||||
className={`flex-1 text-left p-2 rounded-md text-sm truncate transition-colors ${
|
||||
conversationId === conv.id ? "bg-muted font-medium" : "hover:bg-muted/60"
|
||||
}`}
|
||||
title={`${conv.name}\n双击重命名`}
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5 inline mr-1.5 opacity-50" />
|
||||
{conv.name || "新对话"}
|
||||
</button>
|
||||
)}
|
||||
{!selectMode && editingConvId !== conv.id && (
|
||||
<div className="shrink-0 flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startRename(conv.id, conv.name);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-muted"
|
||||
title="重命名"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget({ type: "single", id: conv.id, name: conv.name });
|
||||
}}
|
||||
className="p-1 rounded hover:bg-destructive/10 hover:text-destructive"
|
||||
title="删除对话"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主对话区域 */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<div className="border-b px-3 md:px-5 py-3 flex items-center gap-2 md:gap-3 shrink-0">
|
||||
<button
|
||||
className="md:hidden p-1.5 rounded-md text-muted-foreground hover:bg-muted"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</button>
|
||||
<div className={`flex h-9 w-9 items-center justify-center rounded-xl ${categoryColor} shrink-0`}>
|
||||
<CategoryIcon className="h-4.5 w-4.5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
|
||||
<p className="text-xs text-muted-foreground truncate max-w-[150px] md:max-w-none">{app.description}</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1 md:gap-2">
|
||||
{messages.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
onClick={exportConversation}
|
||||
>
|
||||
<Download className="h-3 w-3" /> 导出
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 text-xs text-blue-800 bg-blue-100 px-2 py-1 rounded-full font-medium">
|
||||
<Bot className="h-3 w-3" /> 智能体
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 p-4">
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
{messages.map((msg) => (
|
||||
<AgentMessage key={msg.id} msg={msg} onCopy={copyText} />
|
||||
))}
|
||||
{messages.length <= 1 && suggestedPrompts.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
{suggestedPrompts.map((prompt: string, i: number) => (
|
||||
<Button key={i} variant="outline" size="sm" className="text-xs" onClick={() => { setInput(prompt); textareaRef.current?.focus(); }}>
|
||||
{prompt}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t p-2 md:p-4 shrink-0">
|
||||
<div className="max-w-3xl mx-auto flex gap-2">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="输入消息... (Enter 发送,Shift+Enter 换行)"
|
||||
className="resize-none min-h-[44px] max-h-32"
|
||||
rows={1}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
<Button onClick={sendMessage} disabled={!input.trim() || isStreaming} className="shrink-0 gap-1.5">
|
||||
{isStreaming ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
{isStreaming ? "思考中" : "发送"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,775 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, memo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { App, Conversation, Message } from "@/lib/types";
|
||||
import api, { streamChat } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Send,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Trash2,
|
||||
CheckSquare,
|
||||
Square,
|
||||
X,
|
||||
Copy,
|
||||
Check,
|
||||
Download,
|
||||
Pencil,
|
||||
PanelLeftOpen,
|
||||
Paperclip,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { getCategoryIcon, getCategoryColor } from "@/lib/category-config";
|
||||
import GovMarkdown from "@/components/ui/gov-markdown";
|
||||
import { toast } from "sonner";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const ChatMessage = memo(function ChatMessage({
|
||||
msg,
|
||||
onCopy,
|
||||
}: {
|
||||
msg: Message;
|
||||
onCopy: (text: string) => void;
|
||||
}) {
|
||||
if (msg.role === "user") {
|
||||
return (
|
||||
<div className="flex justify-end group/msg">
|
||||
<div className="max-w-[80%]">
|
||||
<div className="rounded-2xl px-4 py-2.5 bg-primary text-primary-foreground rounded-br-md shadow-sm">
|
||||
<p className="text-sm whitespace-pre-wrap">{msg.content}</p>
|
||||
</div>
|
||||
<div className="flex justify-end mt-1 opacity-0 group-hover/msg:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => onCopy(msg.content)}
|
||||
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground px-1.5 py-0.5 rounded"
|
||||
>
|
||||
<Copy className="h-3 w-3" /> 复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex justify-start group/msg">
|
||||
<div className="max-w-[85%]">
|
||||
<div className="rounded-2xl px-5 py-3 bg-white dark:bg-card border border-border/50 rounded-bl-md shadow-sm">
|
||||
<GovMarkdown content={msg.content} />
|
||||
</div>
|
||||
{msg.content && (
|
||||
<div className="flex mt-1 opacity-0 group-hover/msg:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => onCopy(msg.content)}
|
||||
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground px-1.5 py-0.5 rounded"
|
||||
>
|
||||
<Copy className="h-3 w-3" /> 复制
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface ChatbotUIProps {
|
||||
app: App;
|
||||
}
|
||||
|
||||
export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuthStore();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [conversationId, setConversationId] = useState<string | undefined>();
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [deleteTarget, setDeleteTarget] = useState<{
|
||||
type: "single" | "batch";
|
||||
id?: string;
|
||||
name?: string;
|
||||
} | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [editingConvId, setEditingConvId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [fileContent, setFileContent] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: conversations = [] } = useQuery({
|
||||
queryKey: ["conversations", app.id, user?.id],
|
||||
queryFn: async () => {
|
||||
const data = await api.get<{ data: Conversation[] }>(
|
||||
`/api/v1/apps/${app.id}/conversations`
|
||||
);
|
||||
return data.data || [];
|
||||
},
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (app.welcome_message && messages.length === 0 && !conversationId) {
|
||||
setMessages([
|
||||
{ id: "welcome", role: "assistant", content: app.welcome_message },
|
||||
]);
|
||||
}
|
||||
}, [app.welcome_message, messages.length, conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const CategoryIcon = getCategoryIcon(app.category_slug);
|
||||
const categoryColor = getCategoryColor(app.category_slug);
|
||||
|
||||
const suggestedPrompts = useRef(
|
||||
(() => {
|
||||
try {
|
||||
if (typeof app.suggested_prompts === "string")
|
||||
return JSON.parse(app.suggested_prompts) as string[];
|
||||
return (app.suggested_prompts as string[]) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})()
|
||||
).current;
|
||||
|
||||
const loadConversation = useCallback(
|
||||
async (convId: string) => {
|
||||
setConversationId(convId);
|
||||
try {
|
||||
const data = await api.get<{ data: Message[] }>(
|
||||
`/api/v1/apps/${app.id}/conversations/${convId}/messages`
|
||||
);
|
||||
setMessages(data.data || []);
|
||||
} catch {
|
||||
setMessages([]);
|
||||
}
|
||||
},
|
||||
[app.id]
|
||||
);
|
||||
|
||||
const copyText = useCallback(
|
||||
(text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
const id = `${Date.now()}`;
|
||||
setCopiedId(id);
|
||||
toast.success("已复制到剪贴板");
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const exportConversation = useCallback(() => {
|
||||
if (messages.length === 0) return;
|
||||
const lines = messages
|
||||
.filter((m) => m.id !== "welcome")
|
||||
.map((m) => {
|
||||
const role = m.role === "user" ? "【用户】" : "【AI助手】";
|
||||
return `${role}\n${m.content}`;
|
||||
});
|
||||
const text = `${app.name} - 对话记录\n导出时间:${new Date().toLocaleString("zh-CN")}\n${"=".repeat(40)}\n\n${lines.join("\n\n" + "-".repeat(40) + "\n\n")}`;
|
||||
const filename = `${app.name}-对话记录-${new Date().toISOString().slice(0, 10)}.txt`;
|
||||
const blob = new Blob([text], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 30000);
|
||||
toast.success("对话已导出");
|
||||
}, [messages, app.name]);
|
||||
|
||||
const handleFileUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
// 限制文件大小 2MB
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error("文件过大,请上传2MB以内的文件");
|
||||
return;
|
||||
}
|
||||
const name = file.name;
|
||||
try {
|
||||
const text = await file.text();
|
||||
if (!text.trim()) {
|
||||
toast.error("无法提取文件内容,请确认文件格式");
|
||||
return;
|
||||
}
|
||||
setFileContent(text);
|
||||
setFileName(name);
|
||||
toast.success(`已加载文件: ${name}`);
|
||||
} catch {
|
||||
toast.error("文件读取失败");
|
||||
}
|
||||
// 重置input避免无法重复选同文件
|
||||
e.target.value = "";
|
||||
}, []);
|
||||
|
||||
const sendMessage = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text && !fileContent) return;
|
||||
if (isStreaming) return;
|
||||
|
||||
// 拼接文件内容
|
||||
let fullMessage = text;
|
||||
if (fileContent) {
|
||||
const filePrefix = `【上传文件:${fileName}】\n\n${fileContent}\n\n---\n\n`;
|
||||
fullMessage = filePrefix + (text || "请审查以上文件内容");
|
||||
}
|
||||
|
||||
const userMsg: Message = {
|
||||
id: `u-${Date.now()}`,
|
||||
role: "user",
|
||||
content: fullMessage,
|
||||
};
|
||||
const assistantMsg: Message = {
|
||||
id: `a-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMsg, assistantMsg]);
|
||||
setInput("");
|
||||
setFileContent(null);
|
||||
setFileName(null);
|
||||
setIsStreaming(true);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const res = await streamChat(
|
||||
app.id,
|
||||
fullMessage,
|
||||
conversationId,
|
||||
controller.signal
|
||||
);
|
||||
if (!res.ok) throw new Error("请求失败");
|
||||
const reader = res.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
if (!reader) throw new Error("无法获取响应流");
|
||||
|
||||
let buffer = "";
|
||||
let accumulated = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const raw = line.slice(6);
|
||||
if (raw === "[DONE]") break;
|
||||
try {
|
||||
const event = JSON.parse(raw);
|
||||
if (event.conversation_id)
|
||||
setConversationId(event.conversation_id);
|
||||
if (event.answer) {
|
||||
accumulated += event.answer;
|
||||
const snap = accumulated;
|
||||
setMessages((prev) =>
|
||||
prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant"
|
||||
? { ...m, content: snap }
|
||||
: m
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["conversations", app.id],
|
||||
});
|
||||
// 延迟刷新以获取LLM生成的对话名称
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
setMessages((prev) =>
|
||||
prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant" && !m.content
|
||||
? { ...m, content: "抱歉,系统处理异常,请稍后重试。" }
|
||||
: m
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setIsStreaming(false);
|
||||
}
|
||||
}, [input, isStreaming, app.id, conversationId, queryClient]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
},
|
||||
[sendMessage]
|
||||
);
|
||||
|
||||
const startNewConversation = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
setMessages([]);
|
||||
setConversationId(undefined);
|
||||
setIsStreaming(false);
|
||||
}, []);
|
||||
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelectedIds(new Set(conversations.map((c) => c.id)));
|
||||
}, [conversations]);
|
||||
|
||||
const confirmDeleteSingle = useCallback(
|
||||
async (convId: string) => {
|
||||
try {
|
||||
await api.delete(`/api/v1/apps/${app.id}/conversations/${convId}`);
|
||||
if (conversationId === convId) startNewConversation();
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["conversations", app.id],
|
||||
});
|
||||
toast.success("对话已删除");
|
||||
} catch {
|
||||
toast.error("删除失败");
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
[app.id, conversationId, startNewConversation, queryClient]
|
||||
);
|
||||
|
||||
const confirmBatchDelete = useCallback(async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
try {
|
||||
await api.post(`/api/v1/apps/${app.id}/conversations/batch-delete`, {
|
||||
conversation_ids: Array.from(selectedIds),
|
||||
});
|
||||
if (conversationId && selectedIds.has(conversationId))
|
||||
startNewConversation();
|
||||
setSelectedIds(new Set());
|
||||
setSelectMode(false);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["conversations", app.id],
|
||||
});
|
||||
toast.success(`已删除 ${selectedIds.size} 个对话`);
|
||||
} catch {
|
||||
toast.error("批量删除失败");
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
}, [
|
||||
selectedIds,
|
||||
app.id,
|
||||
conversationId,
|
||||
startNewConversation,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
const startRename = useCallback(
|
||||
(convId: string, currentName: string) => {
|
||||
setEditingConvId(convId);
|
||||
setEditingName(currentName);
|
||||
setTimeout(() => renameInputRef.current?.focus(), 50);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const saveRename = useCallback(async () => {
|
||||
if (!editingConvId || !editingName.trim()) {
|
||||
setEditingConvId(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.put(
|
||||
`/api/v1/apps/${app.id}/conversations/${editingConvId}/name`,
|
||||
{ name: editingName.trim() }
|
||||
);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["conversations", app.id],
|
||||
});
|
||||
toast.success("已重命名");
|
||||
} catch {
|
||||
toast.error("重命名失败");
|
||||
}
|
||||
setEditingConvId(null);
|
||||
}, [editingConvId, editingName, app.id, queryClient]);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
||||
{/* 删除确认弹窗 */}
|
||||
<AlertDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteTarget?.type === "single"
|
||||
? `确定要删除对话"${deleteTarget.name || "新对话"}"吗?删除后无法恢复。`
|
||||
: `确定要删除选中的 ${selectedIds.size} 个对话吗?删除后无法恢复。`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => {
|
||||
if (deleteTarget?.type === "single" && deleteTarget.id) {
|
||||
confirmDeleteSingle(deleteTarget.id);
|
||||
} else {
|
||||
confirmBatchDelete();
|
||||
}
|
||||
}}
|
||||
>
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 手机端侧边栏遮罩层 */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/40 md:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 侧边栏 */}
|
||||
<div className={`fixed inset-y-[3.5rem] left-0 z-50 w-64 border-r bg-background flex flex-col shrink-0 overflow-hidden transition-transform duration-200 md:static md:inset-y-0 md:translate-x-0 ${
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}>
|
||||
<div className="p-3 border-b space-y-2 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-1.5 text-muted-foreground"
|
||||
onClick={() => router.push("/store")}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> 返回应用中心
|
||||
</Button>
|
||||
<Button
|
||||
onClick={startNewConversation}
|
||||
className="w-full gap-1.5 bg-blue-900 hover:bg-blue-800 text-white"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> 新对话
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{conversations.length > 0 && (
|
||||
<div className="px-3 py-2 border-b flex items-center justify-between shrink-0">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectMode
|
||||
? `已选 ${selectedIds.size} 个`
|
||||
: `${conversations.length} 个对话`}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{selectMode ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs"
|
||||
onClick={selectAll}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs text-destructive hover:text-destructive"
|
||||
onClick={() =>
|
||||
setDeleteTarget({ type: "batch" })
|
||||
}
|
||||
disabled={selectedIds.size === 0}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-0.5" /> 删除
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs"
|
||||
onClick={() => {
|
||||
setSelectMode(false);
|
||||
setSelectedIds(new Set());
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs"
|
||||
onClick={() => setSelectMode(true)}
|
||||
>
|
||||
<CheckSquare className="h-3 w-3 mr-0.5" /> 管理
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 p-2">
|
||||
{conversations.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">
|
||||
暂无对话历史
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{conversations.map((conv) => (
|
||||
<div key={conv.id} className="group flex items-center gap-1">
|
||||
{selectMode && (
|
||||
<button
|
||||
onClick={() => toggleSelect(conv.id)}
|
||||
className="shrink-0 p-0.5"
|
||||
>
|
||||
{selectedIds.has(conv.id) ? (
|
||||
<CheckSquare className="h-3.5 w-3.5 text-primary" />
|
||||
) : (
|
||||
<Square className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{editingConvId === conv.id ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onBlur={saveRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveRename();
|
||||
if (e.key === "Escape") setEditingConvId(null);
|
||||
}}
|
||||
className="flex-1 px-2 py-1 text-sm border rounded-md bg-background focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
maxLength={50}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => !selectMode && loadConversation(conv.id)}
|
||||
onDoubleClick={() =>
|
||||
!selectMode && startRename(conv.id, conv.name)
|
||||
}
|
||||
className={`flex-1 text-left p-2 rounded-md text-sm truncate transition-colors ${
|
||||
conversationId === conv.id
|
||||
? "bg-muted font-medium"
|
||||
: "hover:bg-muted/60"
|
||||
}`}
|
||||
title={`${conv.name}\n双击重命名`}
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5 inline mr-1.5 opacity-50" />
|
||||
{conv.name || "新对话"}
|
||||
</button>
|
||||
)}
|
||||
{!selectMode && editingConvId !== conv.id && (
|
||||
<div className="shrink-0 flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startRename(conv.id, conv.name);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-muted"
|
||||
title="重命名"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget({
|
||||
type: "single",
|
||||
id: conv.id,
|
||||
name: conv.name,
|
||||
});
|
||||
}}
|
||||
className="p-1 rounded hover:bg-destructive/10 hover:text-destructive"
|
||||
title="删除对话"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主对话区域 */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<div className="border-b px-3 md:px-5 py-3 flex items-center gap-2 md:gap-3 shrink-0">
|
||||
<button
|
||||
className="md:hidden p-1.5 rounded-md text-muted-foreground hover:bg-muted"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</button>
|
||||
<div
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-xl ${categoryColor} shrink-0`}
|
||||
>
|
||||
<CategoryIcon className="h-4.5 w-4.5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
|
||||
<p className="text-xs text-muted-foreground truncate max-w-[150px] md:max-w-none">
|
||||
{app.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1 md:gap-2">
|
||||
{messages.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
onClick={exportConversation}
|
||||
>
|
||||
<Download className="h-3 w-3" /> 导出
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 text-xs text-blue-800 bg-blue-100 px-2 py-1 rounded-full font-medium">
|
||||
<MessageSquare className="h-3 w-3" /> 对话型
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 p-4">
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
{messages.map((msg) => (
|
||||
<ChatMessage key={msg.id} msg={msg} onCopy={copyText} />
|
||||
))}
|
||||
|
||||
{messages.length <= 1 && suggestedPrompts.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
{suggestedPrompts.map((prompt: string, i: number) => (
|
||||
<Button
|
||||
key={i}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
setInput(prompt);
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
{prompt}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t p-2 md:p-4 shrink-0">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{fileName && (
|
||||
<div className="flex items-center gap-2 mb-2 px-2 py-1.5 bg-blue-50 border border-blue-200 rounded-lg text-xs text-blue-700">
|
||||
<FileText className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{fileName}</span>
|
||||
<button
|
||||
onClick={() => { setFileContent(null); setFileName(null); }}
|
||||
className="ml-auto p-0.5 hover:bg-blue-100 rounded"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".txt,.md,.csv,.json,.log,.xml,.html,.htm"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 h-[44px] w-[44px]"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isStreaming}
|
||||
title="上传文件"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={fileName ? "输入审查要求... (Enter 发送)" : "输入消息... (Enter 发送,Shift+Enter 换行)"}
|
||||
className="resize-none min-h-[44px] max-h-32"
|
||||
rows={1}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
<Button
|
||||
onClick={sendMessage}
|
||||
disabled={(!input.trim() && !fileContent) || isStreaming}
|
||||
className="shrink-0 gap-1.5"
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
{isStreaming ? "生成中" : "发送"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { App, Message, FormatTemplate } from "@/lib/types";
|
||||
import api, { streamCompletion } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Sparkles,
|
||||
RotateCcw,
|
||||
Copy,
|
||||
Check,
|
||||
Loader2,
|
||||
FileText,
|
||||
ListChecks,
|
||||
} from "lucide-react";
|
||||
import { getCategoryIcon, getCategoryColor } from "@/lib/category-config";
|
||||
import GovMarkdown from "@/components/ui/gov-markdown";
|
||||
import { toast } from "sonner";
|
||||
import ConversationSidebar from "./conversation-sidebar";
|
||||
|
||||
interface CompletionUIProps {
|
||||
app: App;
|
||||
}
|
||||
|
||||
export default function CompletionUI({ app }: CompletionUIProps) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [input, setInput] = useState("");
|
||||
const [output, setOutput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [selectedFormat, setSelectedFormat] = useState<string>("");
|
||||
const [conversationId, setConversationId] = useState<string | undefined>();
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const handleNewConversation = useCallback(() => {
|
||||
setInput("");
|
||||
setOutput("");
|
||||
setSelectedFormat("");
|
||||
setConversationId(undefined);
|
||||
}, []);
|
||||
|
||||
const handleSelectConversation = useCallback(
|
||||
async (convId: string) => {
|
||||
setConversationId(convId);
|
||||
try {
|
||||
const data = await api.get<{ data: Message[] }>(
|
||||
`/api/v1/apps/${app.id}/conversations/${convId}/messages`
|
||||
);
|
||||
const msgs = data.data || [];
|
||||
const userMsg = msgs.find((m) => m.role === "user");
|
||||
const aiMsg = msgs.find((m) => m.role === "assistant");
|
||||
setInput(userMsg?.content || "");
|
||||
setOutput(aiMsg?.content || "");
|
||||
} catch {
|
||||
setInput("");
|
||||
setOutput("");
|
||||
}
|
||||
},
|
||||
[app.id]
|
||||
);
|
||||
|
||||
const appConfig = useMemo(() => {
|
||||
try {
|
||||
if (typeof app.app_config === "string") return JSON.parse(app.app_config);
|
||||
return app.app_config || {};
|
||||
} catch { return {}; }
|
||||
}, [app.app_config]);
|
||||
|
||||
const inputLabel = appConfig.input_label || "输入内容";
|
||||
const inputPlaceholder = appConfig.input_placeholder || "在此输入...";
|
||||
const outputLabel = appConfig.output_label || "生成结果";
|
||||
const formatTemplates: Record<string, FormatTemplate> = appConfig.format_templates || {};
|
||||
const hasFormats = Object.keys(formatTemplates).length > 0;
|
||||
|
||||
const CategoryIcon = getCategoryIcon(app.category_slug);
|
||||
const categoryColor = getCategoryColor(app.category_slug);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!input.trim() || isLoading) return;
|
||||
setIsLoading(true);
|
||||
setOutput("");
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
let finalInput = input.trim();
|
||||
if (selectedFormat && formatTemplates[selectedFormat]) {
|
||||
const fmt = formatTemplates[selectedFormat];
|
||||
finalInput = `【输出格式要求】请按照「${fmt.name}」格式生成,包含以下章节:${fmt.sections.join("、")}。\n\n${finalInput}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await streamCompletion(app.id, finalInput, controller.signal);
|
||||
if (!res.ok) throw new Error("请求失败");
|
||||
const reader = res.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
if (!reader) throw new Error("无法获取响应流");
|
||||
|
||||
let buffer = "";
|
||||
let accumulated = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const raw = line.slice(6);
|
||||
if (raw === "[DONE]") break;
|
||||
try {
|
||||
const event = JSON.parse(raw);
|
||||
if (event.conversation_id) setConversationId(event.conversation_id);
|
||||
if (event.answer) {
|
||||
accumulated += event.answer;
|
||||
setOutput(accumulated);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
// 延迟刷新以获取LLM生成的对话名称
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
toast.error("生成失败,请重试");
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [input, isLoading, app.id, selectedFormat, formatTemplates, queryClient]);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
await navigator.clipboard.writeText(output);
|
||||
setCopied(true);
|
||||
toast.success("已复制到剪贴板");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [output]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
setInput("");
|
||||
setOutput("");
|
||||
setSelectedFormat("");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
||||
<ConversationSidebar
|
||||
appId={app.id}
|
||||
currentConvId={conversationId}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
onNewConversation={handleNewConversation}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<div className="border-b px-3 md:px-5 py-3 flex items-center gap-2 md:gap-3 shrink-0">
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${categoryColor} shrink-0`}>
|
||||
<CategoryIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
|
||||
<p className="text-xs text-muted-foreground truncate">{app.description}</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1.5 text-xs text-blue-800 bg-blue-100 px-2 py-1 rounded-full font-medium">
|
||||
<FileText className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">补全型</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="mx-auto w-full max-w-7xl px-3 md:px-6 lg:px-8 py-4 md:py-6 space-y-4 md:space-y-6">
|
||||
{/* 格式选择区域 */}
|
||||
{hasFormats && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<ListChecks className="h-4 w-4 text-muted-foreground" />
|
||||
<label className="text-sm font-medium">选择输出格式</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-2">
|
||||
{Object.entries(formatTemplates).map(([key, fmt]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setSelectedFormat(selectedFormat === key ? "" : key)}
|
||||
className={`text-left p-3 rounded-lg border transition-all ${
|
||||
selectedFormat === key
|
||||
? "border-emerald-500 bg-emerald-50 ring-1 ring-emerald-500"
|
||||
: "border-border hover:border-emerald-300 hover:bg-emerald-50/50"
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium truncate">{fmt.name}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{fmt.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedFormat && formatTemplates[selectedFormat] && (
|
||||
<div className="mt-3 p-2.5 rounded-md bg-emerald-50 border border-emerald-200/60">
|
||||
<p className="text-xs text-emerald-700">
|
||||
<span className="font-medium">包含章节:</span>
|
||||
{formatTemplates[selectedFormat].sections.join(" → ")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-5 space-y-3">
|
||||
<label className="text-sm font-medium">{inputLabel}</label>
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={inputPlaceholder}
|
||||
className="min-h-[160px] resize-none"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handleGenerate} disabled={!input.trim() || isLoading} className="gap-2">
|
||||
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
||||
{isLoading ? "生成中..." : "生成"}
|
||||
</Button>
|
||||
{selectedFormat && formatTemplates[selectedFormat] && (
|
||||
<span className="text-xs text-emerald-600 bg-emerald-50 px-2 py-1 rounded">
|
||||
格式:{formatTemplates[selectedFormat].name}
|
||||
</span>
|
||||
)}
|
||||
{(output || input) && (
|
||||
<Button variant="outline" onClick={handleReset} className="gap-2">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重置
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isLoading && !output && (
|
||||
<Card>
|
||||
<CardContent className="p-5 space-y-3">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{output && (
|
||||
<Card className="border-emerald-200/60 bg-emerald-50/30">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="text-sm font-medium text-emerald-800">{outputLabel}</label>
|
||||
<Button variant="ghost" size="sm" onClick={handleCopy} className="gap-1.5 text-xs h-7">
|
||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
{copied ? "已复制" : "复制"}
|
||||
</Button>
|
||||
</div>
|
||||
<GovMarkdown content={output} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Conversation } from "@/lib/types";
|
||||
import api from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
MessageSquare,
|
||||
Trash2,
|
||||
CheckSquare,
|
||||
Square,
|
||||
X,
|
||||
Pencil,
|
||||
PanelLeftOpen,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
interface ConversationSidebarProps {
|
||||
appId: string;
|
||||
currentConvId?: string;
|
||||
onSelectConversation: (convId: string) => void;
|
||||
onNewConversation: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可复用的对话历史侧边栏组件
|
||||
* 支持对话列表、单删、批量删、重命名
|
||||
*/
|
||||
export default function ConversationSidebar({
|
||||
appId,
|
||||
currentConvId,
|
||||
onSelectConversation,
|
||||
onNewConversation,
|
||||
}: ConversationSidebarProps) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuthStore();
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [deleteTarget, setDeleteTarget] = useState<{
|
||||
type: "single" | "batch";
|
||||
id?: string;
|
||||
name?: string;
|
||||
} | null>(null);
|
||||
const [editingConvId, setEditingConvId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: conversations = [] } = useQuery({
|
||||
queryKey: ["conversations", appId, user?.id],
|
||||
queryFn: async () => {
|
||||
const data = await api.get<{ data: Conversation[] }>(
|
||||
`/api/v1/apps/${appId}/conversations`
|
||||
);
|
||||
return data.data || [];
|
||||
},
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelectedIds(new Set(conversations.map((c) => c.id)));
|
||||
}, [conversations]);
|
||||
|
||||
const confirmDeleteSingle = useCallback(
|
||||
async (convId: string) => {
|
||||
try {
|
||||
await api.delete(`/api/v1/apps/${appId}/conversations/${convId}`);
|
||||
if (currentConvId === convId) onNewConversation();
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["conversations", appId],
|
||||
});
|
||||
toast.success("对话已删除");
|
||||
} catch {
|
||||
toast.error("删除失败");
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
[appId, currentConvId, onNewConversation, queryClient]
|
||||
);
|
||||
|
||||
const confirmBatchDelete = useCallback(async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
try {
|
||||
await api.post(`/api/v1/apps/${appId}/conversations/batch-delete`, {
|
||||
conversation_ids: Array.from(selectedIds),
|
||||
});
|
||||
if (currentConvId && selectedIds.has(currentConvId))
|
||||
onNewConversation();
|
||||
setSelectedIds(new Set());
|
||||
setSelectMode(false);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["conversations", appId],
|
||||
});
|
||||
toast.success(`已删除 ${selectedIds.size} 个对话`);
|
||||
} catch {
|
||||
toast.error("批量删除失败");
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
}, [selectedIds, appId, currentConvId, onNewConversation, queryClient]);
|
||||
|
||||
const startRename = useCallback(
|
||||
(convId: string, currentName: string) => {
|
||||
setEditingConvId(convId);
|
||||
setEditingName(currentName);
|
||||
setTimeout(() => renameInputRef.current?.focus(), 50);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const saveRename = useCallback(async () => {
|
||||
if (!editingConvId || !editingName.trim()) {
|
||||
setEditingConvId(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.put(
|
||||
`/api/v1/apps/${appId}/conversations/${editingConvId}/name`,
|
||||
{ name: editingName.trim() }
|
||||
);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["conversations", appId],
|
||||
});
|
||||
toast.success("已重命名");
|
||||
} catch {
|
||||
toast.error("重命名失败");
|
||||
}
|
||||
setEditingConvId(null);
|
||||
}, [editingConvId, editingName, appId, queryClient]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 删除确认弹窗 */}
|
||||
<AlertDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteTarget?.type === "single"
|
||||
? `确定要删除对话"${deleteTarget.name || "新对话"}"吗?删除后无法恢复。`
|
||||
: `确定要删除选中的 ${selectedIds.size} 个对话吗?删除后无法恢复。`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => {
|
||||
if (deleteTarget?.type === "single" && deleteTarget.id) {
|
||||
confirmDeleteSingle(deleteTarget.id);
|
||||
} else {
|
||||
confirmBatchDelete();
|
||||
}
|
||||
}}
|
||||
>
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 手机端打开侧边栏按钮 */}
|
||||
<button
|
||||
className="md:hidden fixed top-[4.5rem] left-2 z-30 p-1.5 rounded-md bg-background border shadow-sm text-muted-foreground hover:bg-muted"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* 手机端遮罩 */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/40 md:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 侧边栏 */}
|
||||
<div
|
||||
className={`fixed inset-y-[3.5rem] left-0 z-50 w-64 border-r bg-background flex flex-col shrink-0 overflow-hidden transition-transform duration-200 md:static md:inset-y-0 md:translate-x-0 ${
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="p-3 border-b space-y-2 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-1.5 text-muted-foreground"
|
||||
onClick={() => router.push("/store")}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> 返回应用中心
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNewConversation}
|
||||
className="w-full gap-1.5 bg-blue-900 hover:bg-blue-800 text-white"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> 新对话
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{conversations.length > 0 && (
|
||||
<div className="px-3 py-2 border-b flex items-center justify-between shrink-0">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectMode
|
||||
? `已选 ${selectedIds.size} 个`
|
||||
: `${conversations.length} 个对话`}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{selectMode ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs"
|
||||
onClick={selectAll}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteTarget({ type: "batch" })}
|
||||
disabled={selectedIds.size === 0}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-0.5" /> 删除
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs"
|
||||
onClick={() => {
|
||||
setSelectMode(false);
|
||||
setSelectedIds(new Set());
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs"
|
||||
onClick={() => setSelectMode(true)}
|
||||
>
|
||||
<CheckSquare className="h-3 w-3 mr-0.5" /> 管理
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 p-2">
|
||||
{conversations.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">
|
||||
暂无对话历史
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{conversations.map((conv) => (
|
||||
<div key={conv.id} className="group flex items-center gap-1">
|
||||
{selectMode && (
|
||||
<button
|
||||
onClick={() => toggleSelect(conv.id)}
|
||||
className="shrink-0 p-0.5"
|
||||
>
|
||||
{selectedIds.has(conv.id) ? (
|
||||
<CheckSquare className="h-3.5 w-3.5 text-primary" />
|
||||
) : (
|
||||
<Square className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{editingConvId === conv.id ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onBlur={saveRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveRename();
|
||||
if (e.key === "Escape") setEditingConvId(null);
|
||||
}}
|
||||
className="flex-1 px-2 py-1 text-sm border rounded-md bg-background focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
maxLength={50}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() =>
|
||||
!selectMode && onSelectConversation(conv.id)
|
||||
}
|
||||
onDoubleClick={() =>
|
||||
!selectMode && startRename(conv.id, conv.name)
|
||||
}
|
||||
className={`flex-1 text-left p-2 rounded-md text-sm truncate transition-colors ${
|
||||
currentConvId === conv.id
|
||||
? "bg-muted font-medium"
|
||||
: "hover:bg-muted/60"
|
||||
}`}
|
||||
title={`${conv.name}\n双击重命名`}
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5 inline mr-1.5 opacity-50" />
|
||||
{conv.name || "新对话"}
|
||||
</button>
|
||||
)}
|
||||
{!selectMode && editingConvId !== conv.id && (
|
||||
<div className="shrink-0 flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startRename(conv.id, conv.name);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-muted"
|
||||
title="重命名"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget({
|
||||
type: "single",
|
||||
id: conv.id,
|
||||
name: conv.name,
|
||||
});
|
||||
}}
|
||||
className="p-1 rounded hover:bg-destructive/10 hover:text-destructive"
|
||||
title="删除对话"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,991 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, useMemo, useTransition, memo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { App, Conversation, Message, DocTemplate, TemplateField, SelectOption } from "@/lib/types";
|
||||
import api, { streamChat, streamGenerateDoc } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Send,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Trash2,
|
||||
CheckSquare,
|
||||
Square,
|
||||
X,
|
||||
Copy,
|
||||
Download,
|
||||
FileText,
|
||||
ChevronLeft,
|
||||
Sparkles,
|
||||
HelpCircle,
|
||||
BarChart3,
|
||||
CheckCircle,
|
||||
Mail,
|
||||
AlertCircle,
|
||||
Lightbulb,
|
||||
Gavel,
|
||||
BookOpen,
|
||||
Megaphone,
|
||||
Rocket,
|
||||
Award,
|
||||
Pencil,
|
||||
PanelLeftOpen,
|
||||
} from "lucide-react";
|
||||
import { getCategoryIcon, getCategoryColor } from "@/lib/category-config";
|
||||
import GovMarkdown from "@/components/ui/gov-markdown";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const iconMap: Record<string, React.ElementType> = {
|
||||
FileText,
|
||||
HelpCircle,
|
||||
BarChart3,
|
||||
CheckCircle,
|
||||
Mail,
|
||||
AlertCircle,
|
||||
Lightbulb,
|
||||
Gavel,
|
||||
BookOpen,
|
||||
Megaphone,
|
||||
Rocket,
|
||||
Award,
|
||||
};
|
||||
|
||||
const typeColorMap: Record<string, string> = {
|
||||
notice: "bg-blue-50 text-blue-700 border-blue-200",
|
||||
request: "bg-purple-50 text-purple-700 border-purple-200",
|
||||
report: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||
reply: "bg-teal-50 text-teal-700 border-teal-200",
|
||||
letter: "bg-orange-50 text-orange-700 border-orange-200",
|
||||
circular: "bg-red-50 text-red-700 border-red-200",
|
||||
opinion: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
decision: "bg-slate-100 text-slate-700 border-slate-300",
|
||||
meeting_minutes: "bg-cyan-50 text-cyan-700 border-cyan-200",
|
||||
announcement: "bg-pink-50 text-pink-700 border-pink-200",
|
||||
project_notice: "bg-indigo-50 text-indigo-700 border-indigo-200",
|
||||
tech_award: "bg-yellow-50 text-yellow-700 border-yellow-200",
|
||||
};
|
||||
|
||||
const FormField = memo(function FormField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
field: TemplateField;
|
||||
value: string;
|
||||
onChange: (key: string, val: string) => void;
|
||||
}) {
|
||||
if (field.type === "select" && field.options) {
|
||||
const normalizedOptions = field.options.map((opt) =>
|
||||
typeof opt === "string" ? { value: opt, label: opt } : opt
|
||||
);
|
||||
const selectedLabel = normalizedOptions.find((o) => o.value === value)?.label || "";
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</Label>
|
||||
<Select value={value || ""} onValueChange={(val) => onChange(field.key, val ?? "")}>
|
||||
<SelectTrigger className="w-full">
|
||||
<span className="truncate">{selectedLabel || `请选择${field.label}`}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{normalizedOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (field.type === "textarea") {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
rows={3}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface DocWriterUIProps {
|
||||
app: App;
|
||||
}
|
||||
|
||||
export default function DocWriterUI({ app }: DocWriterUIProps) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const [phase, setPhase] = useState<"select" | "form" | "chat">("select");
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<DocTemplate | null>(null);
|
||||
const [fieldData, setFieldData] = useState<Record<string, string>>({});
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [conversationId, setConversationId] = useState<string | undefined>();
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [deleteTarget, setDeleteTarget] = useState<{
|
||||
type: "single" | "batch";
|
||||
id?: string;
|
||||
name?: string;
|
||||
} | null>(null);
|
||||
const [editingConvId, setEditingConvId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: templates = [] } = useQuery({
|
||||
queryKey: ["doc-templates"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<{ data: DocTemplate[] }>("/api/v1/doc-templates");
|
||||
return res.data || [];
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const { data: conversations = [] } = useQuery({
|
||||
queryKey: ["conversations", app.id],
|
||||
queryFn: async () => {
|
||||
const data = await api.get<{ data: Conversation[] }>(
|
||||
`/api/v1/apps/${app.id}/conversations`
|
||||
);
|
||||
return data.data || [];
|
||||
},
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const CategoryIcon = getCategoryIcon(app.category_slug);
|
||||
const categoryColor = getCategoryColor(app.category_slug);
|
||||
|
||||
const loadConversation = useCallback(
|
||||
async (convId: string) => {
|
||||
setConversationId(convId);
|
||||
setPhase("chat");
|
||||
try {
|
||||
const data = await api.get<{ data: Message[] }>(
|
||||
`/api/v1/apps/${app.id}/conversations/${convId}/messages`
|
||||
);
|
||||
setMessages(data.data || []);
|
||||
} catch {
|
||||
setMessages([]);
|
||||
}
|
||||
},
|
||||
[app.id]
|
||||
);
|
||||
|
||||
const copyText = useCallback((text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success("已复制到剪贴板");
|
||||
}, []);
|
||||
|
||||
const exportConversation = useCallback(() => {
|
||||
if (messages.length === 0) return;
|
||||
const lines = messages
|
||||
.filter((m) => m.id !== "welcome")
|
||||
.map((m) => {
|
||||
const role = m.role === "user" ? "【用户】" : "【AI公文助手】";
|
||||
return `${role}\n${m.content}`;
|
||||
});
|
||||
const text = `${app.name} - 公文生成记录\n导出时间:${new Date().toLocaleString("zh-CN")}\n${"=".repeat(40)}\n\n${lines.join("\n\n" + "-".repeat(40) + "\n\n")}`;
|
||||
const filename = `公文-${selectedTemplate?.name || "文档"}-${new Date().toISOString().slice(0, 10)}.txt`;
|
||||
const blob = new Blob([text], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 30000);
|
||||
toast.success("公文已导出");
|
||||
}, [messages, app.name, selectedTemplate]);
|
||||
|
||||
const exportAsWord = useCallback(() => {
|
||||
const assistantMsg = messages.filter((m) => m.role === "assistant" && m.content);
|
||||
if (assistantMsg.length === 0) return;
|
||||
let lastDoc = assistantMsg[assistantMsg.length - 1].content;
|
||||
|
||||
const fenceMatch = lastDoc.trim().match(/^```[\w]*\s*\n([\s\S]*?)```\s*$/);
|
||||
if (fenceMatch) lastDoc = fenceMatch[1].trim();
|
||||
|
||||
const paragraphs = lastDoc.split("\n").map((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return "<p> </p>";
|
||||
if (trimmed.startsWith("# "))
|
||||
return `<p style="text-align:center;font-size:22pt;font-family:方正小标宋体,SimSun;font-weight:bold;line-height:2;">${trimmed.slice(2)}</p>`;
|
||||
if (trimmed.startsWith("## "))
|
||||
return `<p style="text-align:center;font-size:18pt;font-family:方正小标宋体,SimSun;font-weight:bold;line-height:2;">${trimmed.slice(3)}</p>`;
|
||||
if (trimmed.startsWith("### "))
|
||||
return `<p style="font-size:16pt;font-family:黑体,SimHei;font-weight:bold;line-height:1.8;">${trimmed.slice(4)}</p>`;
|
||||
if (trimmed.startsWith("**") && trimmed.endsWith("**"))
|
||||
return `<p style="text-align:center;font-size:22pt;font-family:方正小标宋体,SimSun;font-weight:bold;line-height:2;">${trimmed.slice(2, -2)}</p>`;
|
||||
if (/^[一二三四五六七八九十]+[、..]/.test(trimmed))
|
||||
return `<p style="font-size:16pt;font-family:黑体,SimHei;font-weight:bold;text-indent:2em;line-height:1.8;">${trimmed}</p>`;
|
||||
if (/^[((][一二三四五六七八九十]+[))]/.test(trimmed))
|
||||
return `<p style="font-size:16pt;font-family:楷体,KaiTi;font-weight:bold;text-indent:2em;line-height:1.8;">${trimmed}</p>`;
|
||||
if (/^---+$/.test(trimmed))
|
||||
return `<hr style="border-top:1px solid #000;margin:0.5cm 0;" />`;
|
||||
const formatted = trimmed
|
||||
.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>")
|
||||
.replace(/\*(.+?)\*/g, "<i>$1</i>");
|
||||
return `<p style="font-size:16pt;font-family:仿宋,FangSong;text-indent:2em;line-height:1.8;">${formatted}</p>`;
|
||||
});
|
||||
|
||||
const html = `
|
||||
<html xmlns:o="urn:schemas-microsoft-com:office:office"
|
||||
xmlns:w="urn:schemas-microsoft-com:office:word"
|
||||
xmlns="http://www.w3.org/TR/REC-html40">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 3.7cm 2.6cm 3.5cm 2.8cm;
|
||||
}
|
||||
body {
|
||||
font-family: 仿宋, FangSong, SimSun, serif;
|
||||
font-size: 16pt;
|
||||
line-height: 1.8;
|
||||
color: #000;
|
||||
}
|
||||
p { margin: 0; padding: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${paragraphs.join("\n")}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
let docTitle = fieldData.title || "";
|
||||
if (!docTitle) {
|
||||
const aiMsg = messages.find((m) => m.role === "assistant" && m.content);
|
||||
if (aiMsg?.content) {
|
||||
const boldMatch = aiMsg.content.match(/\*\*(.{4,60}?)\*\*/);
|
||||
if (boldMatch) docTitle = boldMatch[1].trim();
|
||||
}
|
||||
}
|
||||
if (!docTitle) {
|
||||
const userMsg = messages.find((m) => m.role === "user");
|
||||
if (userMsg?.content) {
|
||||
const match = userMsg.content.match(/\]\s*(.{4,})/);
|
||||
docTitle = match ? match[1].trim() : userMsg.content.replace(/^\[.*?\]\s*/, "").trim();
|
||||
}
|
||||
}
|
||||
if (!docTitle) docTitle = selectedTemplate?.name || "公文";
|
||||
const filename = `${docTitle}-${new Date().toISOString().slice(0, 10)}.doc`;
|
||||
|
||||
console.log("[exportAsWord] filename:", filename);
|
||||
|
||||
const blob = new Blob(["\ufeff" + html], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
console.log("[exportAsWord] a.download:", a.download, "href:", url);
|
||||
a.click();
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 30000);
|
||||
toast.success("公文已导出为Word格式");
|
||||
}, [messages, selectedTemplate, fieldData]);
|
||||
|
||||
const updateField = useCallback((key: string, val: string) => {
|
||||
setFieldData((prev) => ({ ...prev, [key]: val }));
|
||||
}, []);
|
||||
|
||||
const selectTemplate = useCallback((tpl: DocTemplate) => {
|
||||
setSelectedTemplate(tpl);
|
||||
const defaults: Record<string, string> = {};
|
||||
for (const f of tpl.fields) {
|
||||
if (f.default) defaults[f.key] = f.default;
|
||||
}
|
||||
setFieldData(defaults);
|
||||
startTransition(() => {
|
||||
setPhase("form");
|
||||
});
|
||||
}, [startTransition]);
|
||||
|
||||
const handleGenerateDoc = useCallback(async () => {
|
||||
if (!selectedTemplate || isStreaming) return;
|
||||
|
||||
for (const f of selectedTemplate.fields) {
|
||||
if (f.required && !fieldData[f.key]?.trim()) {
|
||||
toast.error(`请填写必填项:${f.label}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const summary = `[${selectedTemplate.name}] ${fieldData.title || fieldData.content || "公文生成"}`;
|
||||
const userMsg: Message = { id: `u-${Date.now()}`, role: "user", content: summary };
|
||||
const assistantMsg: Message = { id: `a-${Date.now()}`, role: "assistant", content: "" };
|
||||
|
||||
setMessages([userMsg, assistantMsg]);
|
||||
setPhase("chat");
|
||||
setIsStreaming(true);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const res = await streamGenerateDoc(app.id, selectedTemplate.id, fieldData, controller.signal);
|
||||
if (!res.ok) throw new Error("请求失败");
|
||||
const reader = res.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
if (!reader) throw new Error("无法获取响应流");
|
||||
|
||||
let buffer = "";
|
||||
let accumulated = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const raw = line.slice(6);
|
||||
if (raw === "[DONE]") break;
|
||||
try {
|
||||
const event = JSON.parse(raw);
|
||||
if (event.conversation_id) setConversationId(event.conversation_id);
|
||||
if (event.answer) {
|
||||
accumulated += event.answer;
|
||||
const snap = accumulated;
|
||||
setMessages((prev) =>
|
||||
prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant"
|
||||
? { ...m, content: snap }
|
||||
: m
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
setMessages((prev) =>
|
||||
prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant" && !m.content
|
||||
? { ...m, content: "抱歉,生成公文时发生异常,请重试。" }
|
||||
: m
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setIsStreaming(false);
|
||||
}
|
||||
}, [selectedTemplate, fieldData, isStreaming, app.id, queryClient]);
|
||||
|
||||
const sendMessage = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text || isStreaming) return;
|
||||
const userMsg: Message = { id: `u-${Date.now()}`, role: "user", content: text };
|
||||
const assistantMsg: Message = { id: `a-${Date.now()}`, role: "assistant", content: "" };
|
||||
|
||||
setMessages((prev) => [...prev, userMsg, assistantMsg]);
|
||||
setInput("");
|
||||
setIsStreaming(true);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const res = await streamChat(app.id, text, conversationId, controller.signal);
|
||||
if (!res.ok) throw new Error("请求失败");
|
||||
const reader = res.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
if (!reader) throw new Error("无法获取响应流");
|
||||
|
||||
let buffer = "";
|
||||
let accumulated = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const raw = line.slice(6);
|
||||
if (raw === "[DONE]") break;
|
||||
try {
|
||||
const event = JSON.parse(raw);
|
||||
if (event.conversation_id) setConversationId(event.conversation_id);
|
||||
if (event.answer) {
|
||||
accumulated += event.answer;
|
||||
const snap = accumulated;
|
||||
setMessages((prev) =>
|
||||
prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant"
|
||||
? { ...m, content: snap }
|
||||
: m
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
setMessages((prev) =>
|
||||
prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant" && !m.content
|
||||
? { ...m, content: "抱歉,系统处理异常,请稍后重试。" }
|
||||
: m
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setIsStreaming(false);
|
||||
}
|
||||
}, [input, isStreaming, app.id, conversationId, queryClient]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
},
|
||||
[sendMessage]
|
||||
);
|
||||
|
||||
const startNewDoc = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
setMessages([]);
|
||||
setConversationId(undefined);
|
||||
setIsStreaming(false);
|
||||
setSelectedTemplate(null);
|
||||
setFieldData({});
|
||||
setPhase("select");
|
||||
}, []);
|
||||
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelectedIds(new Set(conversations.map((c) => c.id)));
|
||||
}, [conversations]);
|
||||
|
||||
const confirmDeleteSingle = useCallback(
|
||||
async (convId: string) => {
|
||||
try {
|
||||
await api.delete(`/api/v1/apps/${app.id}/conversations/${convId}`);
|
||||
if (conversationId === convId) startNewDoc();
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
toast.success("记录已删除");
|
||||
} catch {
|
||||
toast.error("删除失败");
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
[app.id, conversationId, startNewDoc, queryClient]
|
||||
);
|
||||
|
||||
const confirmBatchDelete = useCallback(async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
try {
|
||||
await api.post(`/api/v1/apps/${app.id}/conversations/batch-delete`, {
|
||||
conversation_ids: Array.from(selectedIds),
|
||||
});
|
||||
if (conversationId && selectedIds.has(conversationId)) startNewDoc();
|
||||
setSelectedIds(new Set());
|
||||
setSelectMode(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
toast.success(`已删除 ${selectedIds.size} 条记录`);
|
||||
} catch {
|
||||
toast.error("批量删除失败");
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
}, [selectedIds, app.id, conversationId, startNewDoc, queryClient]);
|
||||
|
||||
const startRename = useCallback(
|
||||
(convId: string, currentName: string) => {
|
||||
setEditingConvId(convId);
|
||||
setEditingName(currentName);
|
||||
setTimeout(() => renameInputRef.current?.focus(), 50);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const saveRename = useCallback(async () => {
|
||||
if (!editingConvId || !editingName.trim()) {
|
||||
setEditingConvId(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.put(
|
||||
`/api/v1/apps/${app.id}/conversations/${editingConvId}/name`,
|
||||
{ name: editingName.trim() }
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
toast.success("已重命名");
|
||||
} catch {
|
||||
toast.error("重命名失败");
|
||||
}
|
||||
setEditingConvId(null);
|
||||
}, [editingConvId, editingName, app.id, queryClient]);
|
||||
|
||||
const filledRequired = useMemo(() => {
|
||||
if (!selectedTemplate) return false;
|
||||
return selectedTemplate.fields
|
||||
.filter((f) => f.required)
|
||||
.every((f) => fieldData[f.key]?.trim());
|
||||
}, [selectedTemplate, fieldData]);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
||||
<AlertDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteTarget?.type === "single"
|
||||
? `确定要删除"${deleteTarget.name || "记录"}"吗?`
|
||||
: `确定要删除选中的 ${selectedIds.size} 条记录吗?`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => {
|
||||
if (deleteTarget?.type === "single" && deleteTarget.id) {
|
||||
confirmDeleteSingle(deleteTarget.id);
|
||||
} else {
|
||||
confirmBatchDelete();
|
||||
}
|
||||
}}
|
||||
>
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 手机端侧边栏遮罩层 */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/40 md:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className={`fixed inset-y-[3.5rem] left-0 z-50 w-64 border-r bg-background flex flex-col shrink-0 overflow-hidden transition-transform duration-200 md:static md:inset-y-0 md:translate-x-0 ${sidebarOpen ? "translate-x-0" : "-translate-x-full"}`}>
|
||||
<div className="p-3 border-b space-y-2 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-1.5 text-muted-foreground"
|
||||
onClick={() => router.push("/store")}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> 返回应用中心
|
||||
</Button>
|
||||
<Button onClick={startNewDoc} className="w-full gap-1.5 bg-blue-900 hover:bg-blue-800 text-white" size="sm">
|
||||
<Plus className="h-3.5 w-3.5" /> 新建公文
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{conversations.length > 0 && (
|
||||
<div className="px-3 py-2 border-b flex items-center justify-between shrink-0">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectMode ? `已选 ${selectedIds.size} 个` : `${conversations.length} 条记录`}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{selectMode ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-xs" onClick={selectAll}>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteTarget({ type: "batch" })}
|
||||
disabled={selectedIds.size === 0}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-0.5" /> 删除
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs"
|
||||
onClick={() => { setSelectMode(false); setSelectedIds(new Set()); }}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-xs" onClick={() => setSelectMode(true)}>
|
||||
<CheckSquare className="h-3 w-3 mr-0.5" /> 管理
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 p-2">
|
||||
{conversations.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">
|
||||
暂无生成记录
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{conversations.map((conv) => (
|
||||
<div key={conv.id} className="group flex items-center gap-1">
|
||||
{selectMode && (
|
||||
<button onClick={() => toggleSelect(conv.id)} className="shrink-0 p-0.5">
|
||||
{selectedIds.has(conv.id) ? (
|
||||
<CheckSquare className="h-3.5 w-3.5 text-primary" />
|
||||
) : (
|
||||
<Square className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{editingConvId === conv.id ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onBlur={saveRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveRename();
|
||||
if (e.key === "Escape") setEditingConvId(null);
|
||||
}}
|
||||
className="flex-1 px-2 py-1 text-sm border rounded-md bg-background focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
maxLength={50}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => !selectMode && loadConversation(conv.id)}
|
||||
onDoubleClick={() => !selectMode && startRename(conv.id, conv.name)}
|
||||
className={`flex-1 text-left p-2 rounded-md text-sm truncate transition-colors ${
|
||||
conversationId === conv.id ? "bg-muted font-medium" : "hover:bg-muted/60"
|
||||
}`}
|
||||
title={`${conv.name}\n双击重命名`}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 inline mr-1.5 opacity-50" />
|
||||
{conv.name || "公文记录"}
|
||||
</button>
|
||||
)}
|
||||
{!selectMode && editingConvId !== conv.id && (
|
||||
<div className="shrink-0 flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startRename(conv.id, conv.name);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-muted"
|
||||
title="重命名"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget({ type: "single", id: conv.id, name: conv.name });
|
||||
}}
|
||||
className="p-1 rounded hover:bg-destructive/10 hover:text-destructive"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Area */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="border-b px-3 md:px-5 py-3 flex items-center gap-2 md:gap-3 shrink-0">
|
||||
<button
|
||||
className="md:hidden p-1.5 rounded-md text-muted-foreground hover:bg-muted"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</button>
|
||||
<div className={`flex h-9 w-9 items-center justify-center rounded-xl ${categoryColor} shrink-0`}>
|
||||
<CategoryIcon className="h-4.5 w-4.5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
|
||||
<p className="text-xs text-muted-foreground truncate max-w-[120px] md:max-w-none">
|
||||
{selectedTemplate ? `正在编写:${selectedTemplate.name}` : "选择公文类型开始"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1 md:gap-2">
|
||||
{messages.some((m) => m.role === "assistant" && m.content) && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="h-7 gap-1.5 text-xs" onClick={exportAsWord}>
|
||||
<Download className="h-3 w-3" /> 下载Word
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs" onClick={exportConversation}>
|
||||
<Download className="h-3 w-3" /> 导出TXT
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 text-xs text-blue-800 bg-blue-100 px-2 py-1 rounded-full font-medium">
|
||||
<FileText className="h-3 w-3" /> 公文写作
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{/* Phase 1: Template Selection */}
|
||||
{phase === "select" && (
|
||||
<div className="p-4 md:p-6 max-w-4xl mx-auto">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-lg font-semibold mb-1">选择公文类型</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
依据《党政机关公文格式》(GB/T 9704) 规范,选择需要撰写的公文种类
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 ${isPending ? "opacity-60 pointer-events-none" : ""}`}>
|
||||
{templates.map((tpl) => {
|
||||
const Icon = iconMap[tpl.icon] || FileText;
|
||||
const colorClass = typeColorMap[tpl.doc_type] || "bg-gray-50 text-gray-700 border-gray-200";
|
||||
return (
|
||||
<button
|
||||
key={tpl.id}
|
||||
onClick={() => selectTemplate(tpl)}
|
||||
className={`flex flex-col items-center gap-2 p-4 rounded-xl border-2 hover:shadow-md transition-all text-center ${colorClass} hover:scale-[1.02]`}
|
||||
>
|
||||
<Icon className="h-7 w-7" />
|
||||
<span className="font-medium text-sm">{tpl.name}</span>
|
||||
<span className="text-[11px] opacity-70 line-clamp-2 leading-tight">
|
||||
{tpl.description}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-muted/50 rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>提示:</strong>
|
||||
您也可以直接在下方输入框描述需求,AI 会根据描述为您推荐合适的公文类型并辅助生成。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phase 2: Form */}
|
||||
{phase === "form" && selectedTemplate && (
|
||||
<div className="p-4 md:p-6 max-w-3xl mx-auto">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => setPhase("select")} className="gap-1">
|
||||
<ChevronLeft className="h-4 w-4" /> 返回选择
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const Icon = iconMap[selectedTemplate.icon] || FileText;
|
||||
return <Icon className="h-5 w-5 text-primary" />;
|
||||
})()}
|
||||
<h2 className="font-semibold">{selectedTemplate.name}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-5">
|
||||
{selectedTemplate.description}。请填写以下信息,<span className="text-destructive">*</span> 为必填项。
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{selectedTemplate.fields.map((field) => (
|
||||
<FormField
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={fieldData[field.key] || ""}
|
||||
onChange={updateField}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t">
|
||||
<Button variant="outline" onClick={() => setPhase("select")}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleGenerateDoc}
|
||||
disabled={!filledRequired || isStreaming}
|
||||
className="gap-2"
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
{isStreaming ? "生成中..." : "生成公文"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phase 3: Chat / Result */}
|
||||
{phase === "chat" && (
|
||||
<div className="p-4">
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"} group/msg`}
|
||||
>
|
||||
<div className={msg.role === "user" ? "max-w-[80%]" : "max-w-[85%]"}>
|
||||
{msg.role === "user" ? (
|
||||
<div className="rounded-2xl px-4 py-2.5 bg-primary text-primary-foreground rounded-br-md shadow-sm">
|
||||
<p className="text-sm whitespace-pre-wrap">{msg.content}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl px-5 py-3 bg-white dark:bg-card border border-border/50 rounded-bl-md shadow-sm">
|
||||
<GovMarkdown content={msg.content} />
|
||||
</div>
|
||||
)}
|
||||
{msg.content && (
|
||||
<div
|
||||
className={`flex ${msg.role === "user" ? "justify-end" : ""} mt-1 opacity-0 group-hover/msg:opacity-100 transition-opacity`}
|
||||
>
|
||||
<button
|
||||
onClick={() => copyText(msg.content)}
|
||||
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground px-1.5 py-0.5 rounded"
|
||||
>
|
||||
<Copy className="h-3 w-3" /> 复制
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input Area (visible in chat phase or select phase for free-form) */}
|
||||
{(phase === "chat" || phase === "select") && (
|
||||
<div className="border-t p-2 md:p-4 shrink-0">
|
||||
<div className="max-w-3xl mx-auto flex gap-2">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
phase === "select"
|
||||
? "或直接描述您的公文需求..."
|
||||
: "对生成的公文提出修改意见... (Enter 发送)"
|
||||
}
|
||||
className="resize-none min-h-[44px] max-h-32"
|
||||
rows={1}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
<Button
|
||||
onClick={sendMessage}
|
||||
disabled={!input.trim() || isStreaming}
|
||||
className="shrink-0 gap-1.5"
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
{isStreaming ? "生成中" : "发送"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { App, Message, WorkflowStep } from "@/lib/types";
|
||||
import api, { streamChat } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Copy,
|
||||
Check,
|
||||
Loader2,
|
||||
GitBranch,
|
||||
CircleDot,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
FileText,
|
||||
ClipboardList,
|
||||
Shield,
|
||||
ScrollText,
|
||||
BookOpen,
|
||||
BarChart3,
|
||||
Calendar,
|
||||
MapPin,
|
||||
Users,
|
||||
Search,
|
||||
AlertCircle,
|
||||
Megaphone,
|
||||
Gavel,
|
||||
Briefcase,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { getCategoryIcon, getCategoryColor } from "@/lib/category-config";
|
||||
import GovMarkdown from "@/components/ui/gov-markdown";
|
||||
import { toast } from "sonner";
|
||||
import ConversationSidebar from "./conversation-sidebar";
|
||||
|
||||
/** 选项关键词到图标的映射 */
|
||||
const optionIconMap: [RegExp, LucideIcon][] = [
|
||||
[/接处警|出警|报警/, ClipboardList],
|
||||
[/案件|受理/, Gavel],
|
||||
[/巡逻|巡查/, Shield],
|
||||
[/专项|行动|整治/, Megaphone],
|
||||
[/治安|形势|分析/, BarChart3],
|
||||
[/排班|值班|考勤/, Calendar],
|
||||
[/人员|名单|干部/, Users],
|
||||
[/地点|区域|辖区/, MapPin],
|
||||
[/调查|摸排|排查/, Search],
|
||||
[/预警|预案|应急/, AlertCircle],
|
||||
[/汇报|总结|报告/, FileText],
|
||||
[/法律|法规|条文/, BookOpen],
|
||||
[/项目|投资|招商/, Briefcase],
|
||||
[/文书|笔录|记录/, ScrollText],
|
||||
];
|
||||
|
||||
/** 选项颜色方案轮转列表 */
|
||||
const optionColorPalette = [
|
||||
"bg-blue-50 text-blue-700 border-blue-200 hover:shadow-blue-100",
|
||||
"bg-purple-50 text-purple-700 border-purple-200 hover:shadow-purple-100",
|
||||
"bg-emerald-50 text-emerald-700 border-emerald-200 hover:shadow-emerald-100",
|
||||
"bg-teal-50 text-teal-700 border-teal-200 hover:shadow-teal-100",
|
||||
"bg-orange-50 text-orange-700 border-orange-200 hover:shadow-orange-100",
|
||||
"bg-red-50 text-red-700 border-red-200 hover:shadow-red-100",
|
||||
"bg-amber-50 text-amber-700 border-amber-200 hover:shadow-amber-100",
|
||||
"bg-cyan-50 text-cyan-700 border-cyan-200 hover:shadow-cyan-100",
|
||||
"bg-pink-50 text-pink-700 border-pink-200 hover:shadow-pink-100",
|
||||
"bg-indigo-50 text-indigo-700 border-indigo-200 hover:shadow-indigo-100",
|
||||
"bg-slate-100 text-slate-700 border-slate-300 hover:shadow-slate-100",
|
||||
"bg-yellow-50 text-yellow-700 border-yellow-200 hover:shadow-yellow-100",
|
||||
];
|
||||
|
||||
/** 根据选项名称匹配图标 */
|
||||
function getOptionIcon(name: string): LucideIcon {
|
||||
for (const [re, icon] of optionIconMap) {
|
||||
if (re.test(name)) return icon;
|
||||
}
|
||||
return FileText;
|
||||
}
|
||||
|
||||
/** 根据索引获取颜色 */
|
||||
function getOptionColor(index: number): string {
|
||||
return optionColorPalette[index % optionColorPalette.length];
|
||||
}
|
||||
|
||||
interface WorkflowUIProps {
|
||||
app: App;
|
||||
}
|
||||
|
||||
export default function WorkflowUI({ app }: WorkflowUIProps) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const appConfig = useMemo(() => {
|
||||
try {
|
||||
if (typeof app.app_config === "string") return JSON.parse(app.app_config);
|
||||
return app.app_config || {};
|
||||
} catch { return {}; }
|
||||
}, [app.app_config]);
|
||||
|
||||
const steps: WorkflowStep[] = appConfig.steps || [];
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||
const [output, setOutput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
const [conversationId, setConversationId] = useState<string | undefined>();
|
||||
|
||||
const handleNewConversation = useCallback(() => {
|
||||
setCurrentStep(0);
|
||||
setFormData({});
|
||||
setOutput("");
|
||||
setIsComplete(false);
|
||||
setConversationId(undefined);
|
||||
}, []);
|
||||
|
||||
const handleSelectConversation = useCallback(
|
||||
async (convId: string) => {
|
||||
setConversationId(convId);
|
||||
try {
|
||||
const data = await api.get<{ data: Message[] }>(
|
||||
`/api/v1/apps/${app.id}/conversations/${convId}/messages`
|
||||
);
|
||||
const msgs = data.data || [];
|
||||
const aiMsg = msgs.find((m) => m.role === "assistant");
|
||||
if (aiMsg?.content) {
|
||||
setOutput(aiMsg.content);
|
||||
setIsComplete(true);
|
||||
}
|
||||
} catch {
|
||||
setOutput("");
|
||||
}
|
||||
},
|
||||
[app.id]
|
||||
);
|
||||
|
||||
const CategoryIcon = getCategoryIcon(app.category_slug);
|
||||
const categoryColor = getCategoryColor(app.category_slug);
|
||||
|
||||
const currentStepData = steps[currentStep];
|
||||
const isLastStep = currentStep === steps.length - 1;
|
||||
const canProceed = formData[currentStepData?.key]?.trim();
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const handleRun = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setOutput("");
|
||||
setIsComplete(false);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const message = steps.map((s) => `【${s.label}】\n${formData[s.key] || "未填写"}`).join("\n\n");
|
||||
try {
|
||||
const res = await streamChat(app.id, message, undefined, controller.signal);
|
||||
if (!res.ok) throw new Error("请求失败");
|
||||
const reader = res.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
if (!reader) throw new Error("无法获取响应流");
|
||||
|
||||
let buffer = "";
|
||||
let accumulated = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const raw = line.slice(6);
|
||||
if (raw === "[DONE]") break;
|
||||
try {
|
||||
const event = JSON.parse(raw);
|
||||
if (event.conversation_id) setConversationId(event.conversation_id);
|
||||
if (event.answer) {
|
||||
accumulated += event.answer;
|
||||
setOutput(accumulated);
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
}
|
||||
setIsComplete(true);
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
// 延迟刷新以获取LLM生成的对话名称
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
toast.error("处理失败,请重试");
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [steps, formData, app.id]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (isLastStep) {
|
||||
handleRun();
|
||||
} else {
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
}
|
||||
}, [isLastStep, handleRun]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setCurrentStep((prev) => Math.max(0, prev - 1));
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
await navigator.clipboard.writeText(output);
|
||||
setCopied(true);
|
||||
toast.success("已复制到剪贴板");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [output]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setCurrentStep(0);
|
||||
setFormData({});
|
||||
setOutput("");
|
||||
setIsComplete(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
||||
<ConversationSidebar
|
||||
appId={app.id}
|
||||
currentConvId={conversationId}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
onNewConversation={handleNewConversation}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<div className="border-b px-3 md:px-5 py-3 flex items-center gap-2 md:gap-3 shrink-0">
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${categoryColor} shrink-0`}>
|
||||
<CategoryIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
|
||||
<p className="text-xs text-muted-foreground truncate">{app.description}</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1.5 text-xs text-blue-800 bg-blue-100 px-2 py-1 rounded-full font-medium">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">工作流</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="mx-auto w-full max-w-4xl px-3 md:px-6 lg:px-8 py-4 md:py-6">
|
||||
<div className="flex items-center gap-2 mb-8 overflow-x-auto pb-2">
|
||||
{steps.map((step, idx) => {
|
||||
const done = isComplete || idx < currentStep;
|
||||
const active = idx === currentStep && !isComplete;
|
||||
return (
|
||||
<div key={step.key} className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => !isComplete && !isLoading && setCurrentStep(idx)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm transition-colors ${
|
||||
active ? "bg-purple-100 text-purple-700 font-medium" : done ? "bg-emerald-50 text-emerald-700" : "text-muted-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{done ? <CheckCircle2 className="h-4 w-4" /> : active ? <CircleDot className="h-4 w-4" /> : <Circle className="h-4 w-4" />}
|
||||
{step.label}
|
||||
</button>
|
||||
{idx < steps.length - 1 && <ChevronRight className="h-4 w-4 text-muted-foreground/50 shrink-0" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{isComplete && (
|
||||
<>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/50 shrink-0" />
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-emerald-100 text-emerald-700 font-medium text-sm shrink-0">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
完成
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isComplete && !isLoading && currentStepData && (
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">步骤 {currentStep + 1}:{currentStepData.label}</h2>
|
||||
{currentStepData.description && <p className="text-sm text-muted-foreground mt-1">{currentStepData.description}</p>}
|
||||
</div>
|
||||
|
||||
{currentStepData.type === "select" && currentStepData.options ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{currentStepData.options.map((opt, idx) => {
|
||||
const Icon = getOptionIcon(opt);
|
||||
const color = getOptionColor(idx);
|
||||
const isSelected = formData[currentStepData.key] === opt;
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => setFormData((prev) => ({ ...prev, [currentStepData.key]: opt }))}
|
||||
className={`flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all text-center hover:shadow-md hover:scale-[1.02] ${
|
||||
isSelected
|
||||
? "ring-2 ring-purple-400 ring-offset-2 shadow-md scale-[1.02] " + color
|
||||
: color
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-7 w-7" />
|
||||
<span className="font-medium text-sm">{opt}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
value={formData[currentStepData.key] || ""}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, [currentStepData.key]: e.target.value }))}
|
||||
placeholder={currentStepData.placeholder || "请输入..."}
|
||||
className="min-h-[140px] resize-none"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button variant="outline" onClick={handleBack} disabled={currentStep === 0} className="gap-1.5">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
上一步
|
||||
</Button>
|
||||
<Button onClick={handleNext} disabled={!canProceed} className="gap-1.5">
|
||||
{isLastStep ? (<><Play className="h-4 w-4" /> 运行</>) : (<>下一步 <ChevronRight className="h-4 w-4" /></>)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading && !output && (
|
||||
<Card className="border-purple-200/60">
|
||||
<CardContent className="p-8 flex flex-col items-center gap-4">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-purple-500" />
|
||||
<div className="text-center">
|
||||
<p className="font-medium">正在处理中...</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">AI 正在根据您提供的信息生成结果</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{(isComplete || (isLoading && output)) && output && (
|
||||
<Card className={isComplete ? "border-emerald-200/60 bg-emerald-50/30" : "border-purple-200/60 bg-purple-50/20"}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className={`text-lg font-semibold ${isComplete ? "text-emerald-800" : "text-purple-800"}`}>
|
||||
{isComplete ? "处理结果" : "正在生成..."}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading && <Loader2 className="h-4 w-4 animate-spin text-purple-500" />}
|
||||
{isComplete && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={handleCopy} className="gap-1.5 text-xs h-7">
|
||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
{copied ? "已复制" : "复制"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleReset} className="gap-1.5 text-xs h-7">
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
重新开始
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<GovMarkdown content={output} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import type { Organization } from "@/stores/auth";
|
||||
import api from "@/lib/api";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Search, Shield, Building2, Check, Menu, X, Store, LayoutDashboard, PenSquare, BookOpen, Settings } from "lucide-react";
|
||||
import { useAuthStore as _useAuthStore } from "@/stores/auth";
|
||||
|
||||
/**
|
||||
* 根据机构简称动态生成平台品牌名
|
||||
* 规则:取 short_name 首字 + "政通",如科技局→科政通,律所→律政通
|
||||
*/
|
||||
function getOrgBrand(org?: Organization): string {
|
||||
if (!org?.short_name) return "智政通";
|
||||
return org.short_name[0] + "政通";
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const { user, logout, switchOrg } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState("");
|
||||
const [orgs, setOrgs] = useState<Organization[]>([]);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const brandName = getOrgBrand(user?.org);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Organization[]>("/api/v1/organizations").then(setOrgs).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (search.trim()) {
|
||||
router.push(`/store?q=${encodeURIComponent(search.trim())}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
const isSuperAdmin = user?.role === "super_admin";
|
||||
const isAdmin = user?.role === "admin";
|
||||
const isCreator = user?.role === "creator" || user?.role === "admin";
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-blue-900/20 bg-gradient-to-r from-blue-950 via-blue-900 to-blue-950 shadow-md">
|
||||
<div className="mx-auto w-full max-w-7xl px-3 md:px-6 lg:px-8 flex h-14 items-center gap-2 md:gap-4">
|
||||
{/* 手机端汉堡菜单按钮 */}
|
||||
<button
|
||||
className="md:hidden p-1.5 rounded-md text-blue-100 hover:text-white hover:bg-white/10"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
<Link href="/store" className="flex items-center gap-2 font-bold text-lg shrink-0">
|
||||
<Shield className="h-5 w-5 text-amber-400" />
|
||||
<span className="text-white tracking-wide">
|
||||
{brandName}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:block h-5 w-px bg-white/20 mx-1" />
|
||||
|
||||
{/* 桌面端导航 */}
|
||||
<nav className="hidden md:flex items-center gap-0.5 text-sm">
|
||||
{!isSuperAdmin && (
|
||||
<>
|
||||
<Link href="/store">
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 text-blue-100 hover:text-white hover:bg-white/10">
|
||||
<Store className="h-4 w-4" />应用中心
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/workspace">
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 text-blue-100 hover:text-white hover:bg-white/10">
|
||||
<LayoutDashboard className="h-4 w-4" />我的工作台
|
||||
</Button>
|
||||
</Link>
|
||||
{isCreator && (
|
||||
<Link href="/create">
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 text-blue-100 hover:text-white hover:bg-white/10">
|
||||
<PenSquare className="h-4 w-4" />应用管理
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{isCreator && (
|
||||
<Link href="/knowledge">
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 text-blue-100 hover:text-white hover:bg-white/10">
|
||||
<BookOpen className="h-4 w-4" />知识库
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link href="/dashboard">
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 text-blue-100 hover:text-white hover:bg-white/10">
|
||||
<Settings className="h-4 w-4" />管理控制台
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isSuperAdmin && (
|
||||
<>
|
||||
<Link href="/platform/overview">
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 text-amber-200 hover:text-white hover:bg-amber-500/20 border border-amber-500/30">
|
||||
<Shield className="h-4 w-4" />平台管理
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/knowledge">
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 text-blue-100 hover:text-white hover:bg-white/10">
|
||||
<BookOpen className="h-4 w-4" />知识库
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<form onSubmit={handleSearch} className="flex-1 max-w-md mx-auto relative hidden sm:block">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-blue-300" />
|
||||
<Input
|
||||
placeholder="搜索政务应用..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-9 pl-9 bg-white/10 border-white/20 text-white placeholder:text-blue-300 focus-visible:ring-blue-400"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="flex items-center gap-2 md:gap-3 ml-auto">
|
||||
{user?.org && (
|
||||
<div className="hidden sm:flex items-center gap-1.5 text-xs text-blue-200 bg-white/10 px-2.5 py-1 rounded-full border border-white/10">
|
||||
<Building2 className="h-3 w-3" />
|
||||
<span className="font-medium">{user.org.short_name || user.org.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="relative h-8 w-8 rounded-full focus:outline-none ring-offset-blue-900">
|
||||
<Avatar className="h-8 w-8 border border-white/30">
|
||||
<AvatarImage src={user?.avatar_url} alt={user?.name} />
|
||||
<AvatarFallback className="bg-blue-800 text-white text-sm">{user?.name?.charAt(0) || "U"}</AvatarFallback>
|
||||
</Avatar>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end">
|
||||
<div className="flex items-center gap-2 p-2">
|
||||
<div className="flex flex-col">
|
||||
<p className="text-sm font-medium">{user?.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Building2 className="mr-2 h-4 w-4" />
|
||||
切换机构
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{orgs.map((org) => (
|
||||
<DropdownMenuItem
|
||||
key={org.id}
|
||||
onClick={async () => {
|
||||
await switchOrg(org.id);
|
||||
queryClient.invalidateQueries();
|
||||
const switchedRole = _useAuthStore.getState().user?.role;
|
||||
window.location.href = switchedRole === "admin" ? "/dashboard" : "/store";
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
{org.short_name || org.name}
|
||||
{user?.org_id === org.id && <Check className="h-4 w-4 text-blue-600" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
退出登录
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 手机端下拉菜单 */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden border-t border-white/10 bg-blue-950/95 backdrop-blur-sm">
|
||||
<div className="px-3 py-2">
|
||||
<form onSubmit={(e) => { handleSearch(e); setMobileMenuOpen(false); }} className="relative mb-2 sm:hidden">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-blue-300" />
|
||||
<Input
|
||||
placeholder="搜索政务应用..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-9 pl-9 bg-white/10 border-white/20 text-white placeholder:text-blue-300 focus-visible:ring-blue-400"
|
||||
/>
|
||||
</form>
|
||||
{user?.org && (
|
||||
<div className="sm:hidden flex items-center gap-1.5 text-xs text-blue-200 bg-white/10 px-2.5 py-1.5 rounded-lg border border-white/10 mb-2">
|
||||
<Building2 className="h-3 w-3" />
|
||||
<span className="font-medium">{user.org.short_name || user.org.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{!isSuperAdmin && (
|
||||
<>
|
||||
<Link href="/store" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start gap-2 text-blue-100 hover:text-white hover:bg-white/10">
|
||||
<Store className="h-4 w-4" />应用中心
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/workspace" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start gap-2 text-blue-100 hover:text-white hover:bg-white/10">
|
||||
<LayoutDashboard className="h-4 w-4" />我的工作台
|
||||
</Button>
|
||||
</Link>
|
||||
{isCreator && (
|
||||
<Link href="/create" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start gap-2 text-blue-100 hover:text-white hover:bg-white/10">
|
||||
<PenSquare className="h-4 w-4" />应用管理
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{isCreator && (
|
||||
<Link href="/knowledge" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start gap-2 text-blue-100 hover:text-white hover:bg-white/10">
|
||||
<BookOpen className="h-4 w-4" />知识库
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link href="/dashboard" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start gap-2 text-blue-100 hover:text-white hover:bg-white/10">
|
||||
<Settings className="h-4 w-4" />管理控制台
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isSuperAdmin && (
|
||||
<>
|
||||
<Link href="/platform/overview" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start gap-2 text-amber-200 hover:text-white hover:bg-amber-500/20">
|
||||
<Shield className="h-4 w-4" />平台管理
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/knowledge" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start gap-2 text-blue-100 hover:text-white hover:bg-white/10">
|
||||
<BookOpen className="h-4 w-4" />知识库
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
function AuthLoader({ children }: { children: React.ReactNode }) {
|
||||
const fetchUser = useAuthStore((s) => s.fetchUser);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser();
|
||||
}, [fetchUser]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const queryClientRef = useRef<QueryClient>(null);
|
||||
if (!queryClientRef.current) {
|
||||
queryClientRef.current = createQueryClient();
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClientRef.current}>
|
||||
<TooltipProvider>
|
||||
<AuthLoader>{children}</AuthLoader>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Backdrop
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Popup.Props & {
|
||||
size?: "default" | "sm"
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Popup
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Close.Props &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Close
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
render={<Button variant={variant} size={size} />}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AvatarPrimitive.Root.Props & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.Fallback.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
} from "@/components/ui/input-group"
|
||||
import { SearchIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = false,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
|
||||
className
|
||||
)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
{children}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
||||
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className={cn("py-6 text-center text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||
</CommandPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 outline-none sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Components } from "react-markdown";
|
||||
import { BookOpen, BrainCircuit, ArrowRight } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const mdComponents: Components = {
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-lg font-bold text-primary border-b-2 border-primary/20 pb-2 mb-3 mt-4 first:mt-0">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-base font-bold text-primary/90 border-l-3 border-primary pl-3 mb-2 mt-4">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-sm font-bold text-foreground mb-1.5 mt-3 flex items-center gap-1.5">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-primary shrink-0" />
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="text-sm leading-7 text-foreground/90 my-2">{children}</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="my-2 ml-1 space-y-1">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="my-2 ml-1 space-y-1 list-decimal list-inside">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="text-sm leading-6 text-foreground/90 flex items-start gap-1.5">
|
||||
<span className="inline-block w-1 h-1 rounded-full bg-primary/60 mt-2.5 shrink-0" />
|
||||
<span className="flex-1">{children}</span>
|
||||
</li>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="my-3 border-l-3 border-amber-400 bg-amber-50/60 dark:bg-amber-950/20 px-4 py-2 rounded-r-lg text-sm">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="my-3 overflow-x-auto rounded-lg border">
|
||||
<table className="w-full text-sm">{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead className="bg-primary/5 border-b">{children}</thead>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-primary/80 uppercase tracking-wider">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-3 py-2 text-sm border-b border-muted">{children}</td>
|
||||
),
|
||||
hr: () => (
|
||||
<hr className="my-4 border-t-2 border-dashed border-primary/10" />
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold text-foreground">{children}</strong>
|
||||
),
|
||||
em: ({ children }) => (
|
||||
<em className="text-primary/80 not-italic font-medium">{children}</em>
|
||||
),
|
||||
a: ({ href, children }) => {
|
||||
if (href === "#cite-kb") {
|
||||
const label = String(children).replace(/^知识库:/, "");
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 mx-0.5 text-xs font-medium rounded-md bg-blue-50 text-blue-700 border border-blue-200 align-middle">
|
||||
<BookOpen className="h-3 w-3 shrink-0" />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (href === "#cite-ai") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 mx-0.5 text-xs font-medium rounded-md bg-amber-50 text-amber-700 border border-amber-200 align-middle whitespace-nowrap">
|
||||
<BrainCircuit className="h-3 w-3 shrink-0" />
|
||||
<span>AI建议</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// 推荐应用:渲染为可点击的应用跳转卡片
|
||||
if (href?.startsWith("#cite-app:")) {
|
||||
const slug = href.replace("#cite-app:", "");
|
||||
const appName = String(children);
|
||||
return (
|
||||
<AppLinkBadge slug={slug} name={appName} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline underline-offset-2 hover:text-primary/80">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
code: ({ className, children }) => {
|
||||
const lang = className?.replace("language-", "") || "";
|
||||
const isBlock = className?.includes("language-");
|
||||
if (isBlock && (lang === "markdown" || lang === "md")) {
|
||||
const text = String(children).replace(/\n$/, "");
|
||||
return (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
if (isBlock) {
|
||||
return (
|
||||
<div className="my-3 rounded-lg overflow-hidden border bg-slate-50 dark:bg-slate-900">
|
||||
<div className="px-3 py-1.5 bg-slate-100 dark:bg-slate-800 text-xs text-muted-foreground border-b">
|
||||
{lang || "代码"}
|
||||
</div>
|
||||
<pre className="p-3 overflow-x-auto text-xs leading-5">
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="text-xs font-mono bg-primary/5 text-primary px-1.5 py-0.5 rounded border border-primary/10">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => <>{children}</>,
|
||||
};
|
||||
|
||||
/**
|
||||
* 推荐应用跳转徽章组件:点击后跳转到同机构内的其他应用
|
||||
*/
|
||||
function AppLinkBadge({ slug, name }: { slug: string; name: string }) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/chat/${slug}`)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 mx-0.5 text-xs font-medium rounded-lg bg-emerald-50 text-emerald-700 border border-emerald-200 align-middle cursor-pointer hover:bg-emerald-100 hover:border-emerald-300 transition-colors whitespace-nowrap"
|
||||
>
|
||||
<ArrowRight className="h-3 w-3 shrink-0" />
|
||||
<span>{name}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface GovMarkdownProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function stripOuterCodeFence(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
const match = trimmed.match(/^```[\w]*\s*\n([\s\S]*?)```\s*$/);
|
||||
if (match) return match[1].trim();
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预处理 markdown 内容:将来源标注转为特殊链接格式,由 ReactMarkdown 的 a 组件拦截渲染
|
||||
*/
|
||||
function preprocessCitations(content: string): string {
|
||||
return content
|
||||
// 知识库引用:[[知识库:文献名称]] 或 [[知识库:文献名称:条款]]
|
||||
.replace(/\[\[(知识库:[^\]]+)\]\]/g, (_, label) => `[${label}](#cite-kb)`)
|
||||
// AI建议:标准格式 [[AI建议]]
|
||||
.replace(/\[\[AI建议\]\]/g, "[AI建议](#cite-ai)")
|
||||
// AI建议:来源说明块中的 **AI建议:** 或 **AI建议** 标题(仅匹配行首或 > 后)
|
||||
.replace(/^(\s*>?\s*)\*\*AI建议[::]\*\*/gm, "$1[AI建议](#cite-ai)")
|
||||
// AI建议:无加粗的 AI建议: 标题行(仅匹配行首或 > 后)
|
||||
.replace(/^(\s*>?\s*)AI建议[::]\s*$/gm, "$1[AI建议](#cite-ai)")
|
||||
// 推荐应用:[[推荐应用:应用名称:slug]] → 可点击跳转链接
|
||||
.replace(/\[\[推荐应用:([^:]+):([^\]]+)\]\]/g, (_, name, slug) => `[${name}](#cite-app:${slug})`);
|
||||
}
|
||||
|
||||
const GovMarkdown = memo(function GovMarkdown({ content, className }: GovMarkdownProps) {
|
||||
if (!content) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-1">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-primary/60 animate-pulse" />
|
||||
正在检索知识库并生成回复...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const cleaned = preprocessCitations(stripOuterCodeFence(content));
|
||||
return (
|
||||
<div className={`gov-markdown ${className || ""}`}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
||||
{cleaned}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default GovMarkdown;
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
|
||||
"inline-end":
|
||||
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
|
||||
"block-end":
|
||||
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"flex items-center gap-2 text-sm shadow-none",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
|
||||
VariantProps<typeof inputGroupButtonVariants> & {
|
||||
type?: "button" | "submit" | "reset"
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
interface PaginationProps {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
onChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function Pagination({ page, pageSize, total, onChange }: PaginationProps) {
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const start = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||
const end = Math.min(total, page * pageSize);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mt-4 text-sm">
|
||||
<div className="text-muted-foreground">
|
||||
共 {total} 条,第 {start}-{end} 条
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onChange(page - 1)}
|
||||
>
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground px-2">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onChange(page + 1)}
|
||||
>
|
||||
下一页
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: PopoverPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PopoverPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
className={cn(
|
||||
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Positioner>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Title
|
||||
data-slot="popover-title"
|
||||
className={cn("font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: PopoverPrimitive.Description.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Description
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value?: number
|
||||
}
|
||||
|
||||
const Progress: React.FC<ProgressProps> = React.forwardRef<HTMLDivElement, ProgressProps>(
|
||||
({ className, value = 0, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
Progress.displayName = "Progress"
|
||||
|
||||
export { Progress }
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Root.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Scrollbar.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Backdrop
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: SheetPrimitive.Popup.Props & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Popup
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: SheetPrimitive.Description.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
),
|
||||
info: (
|
||||
<InfoIcon className="size-4" />
|
||||
),
|
||||
warning: (
|
||||
<TriangleAlertIcon className="size-4" />
|
||||
),
|
||||
error: (
|
||||
<OctagonXIcon className="size-4" />
|
||||
),
|
||||
loading: (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
),
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: TabsPrimitive.Root.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Panel
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
useSSEStream,
|
||||
updateLastAssistantMessage,
|
||||
setStreamErrorMessage,
|
||||
} from "./use-sse-stream";
|
||||
export { useCopyToClipboard } from "./use-copy-clipboard";
|
||||
export { useScrollToBottom } from "./use-scroll-bottom";
|
||||
export { useFileExport } from "./use-file-export";
|
||||
export { useAppConfig, useSuggestedPrompts } from "./use-app-config";
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef } from "react";
|
||||
import type { App } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* 解析 app.app_config(可能是 JSON 字符串或对象)。
|
||||
* completion-ui / workflow-ui / agent-ui 共用。
|
||||
*/
|
||||
export function useAppConfig(app: App): Record<string, unknown> {
|
||||
return useMemo(() => {
|
||||
try {
|
||||
if (typeof app.app_config === "string")
|
||||
return JSON.parse(app.app_config);
|
||||
return app.app_config || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, [app.app_config]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 app.suggested_prompts(可能是 JSON 字符串或数组)。
|
||||
* chatbot-ui / agent-ui 共用。
|
||||
*/
|
||||
export function useSuggestedPrompts(app: App): string[] {
|
||||
return useRef(
|
||||
(() => {
|
||||
try {
|
||||
if (typeof app.suggested_prompts === "string")
|
||||
return JSON.parse(app.suggested_prompts) as string[];
|
||||
return (app.suggested_prompts as string[]) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
).current;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
/**
|
||||
* 复制文本到剪贴板,显示 toast 提示。
|
||||
* chatbot-ui / agent-ui / completion-ui / doc-writer-ui / analysis-ui 共用。
|
||||
*/
|
||||
export function useCopyToClipboard(resetMs = 2000) {
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
const copy = useCallback(
|
||||
(text: string, id?: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success("已复制到剪贴板");
|
||||
if (id !== undefined) {
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId(null), resetMs);
|
||||
}
|
||||
},
|
||||
[resetMs],
|
||||
);
|
||||
|
||||
return { copy, copiedId };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
/**
|
||||
* 触发浏览器文件下载。
|
||||
* chatbot-ui / agent-ui / doc-writer-ui / analysis-ui 的导出功能共用。
|
||||
*/
|
||||
export function useFileExport() {
|
||||
const download = useCallback(
|
||||
(content: string, filename: string, successMsg = "已导出") => {
|
||||
const blob = new Blob([content], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 30000);
|
||||
toast.success(successMsg);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { download };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user