Compare commits
26 Commits
73d1e00303
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a7fbaa97e6 | |||
| 67dca387bd | |||
| 988d0bf012 | |||
| 64b14dc7d3 | |||
| 63661298dd | |||
| 60b4c3244e | |||
| 728d1c8dd5 | |||
| 1524101652 | |||
| a3245b249a | |||
| c011f2eae8 | |||
| c1cd6bc18d | |||
| da64551c92 | |||
| 756849f99e | |||
| 62dffb782a | |||
| 4a1ce220a6 | |||
| 9d6498bf19 | |||
| 15beea041a | |||
| 1b7f6a5b6b | |||
| e65da6f294 | |||
| 8959456eb7 | |||
| 96b944ac8d | |||
| 6ca1c27162 | |||
| 65dc805eb5 | |||
| 91f4fac23c | |||
| d31bdd1261 | |||
| 95dee5a70e |
+8
-1
@@ -1,5 +1,11 @@
|
|||||||
# ===== Environment =====
|
# ===== Environment =====
|
||||||
# .env 和 .env.local 已纳入版本管理
|
# .env 和 server/.env 包含密钥,不得纳入版本控制
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
server/.env
|
||||||
|
server/.env.local
|
||||||
|
!.env.example
|
||||||
|
!.server/.env.example
|
||||||
|
|
||||||
# ===== Go =====
|
# ===== Go =====
|
||||||
server/server
|
server/server
|
||||||
@@ -66,3 +72,4 @@ venv/
|
|||||||
|
|
||||||
# ===== AI/IDE =====
|
# ===== AI/IDE =====
|
||||||
.ai-switch/
|
.ai-switch/
|
||||||
|
video/
|
||||||
|
|||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
# GovAI 应用优化建议
|
||||||
|
|
||||||
|
> 生成时间:2026-06-23
|
||||||
|
> 代码规模:前端 15,055 行(TSX/TS)| 后端 9,701 行(Go)
|
||||||
|
> 技术栈:Next.js 16 + React 19 + Tailwind CSS 4 + Go + PostgreSQL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、安全类(高优先级)
|
||||||
|
|
||||||
|
### 1. JWT Secret 硬编码默认值
|
||||||
|
**文件:** `server/internal/config/config.go`
|
||||||
|
**问题:** JWT Secret 有明文 fallback,Docker/生产环境若 `.env` 未挂载会使用已知密钥启动。
|
||||||
|
```go
|
||||||
|
Secret: getEnv("JWT_SECRET", "dev-secret-change-in-production"),
|
||||||
|
```
|
||||||
|
**建议:** 启动时强制校验——若读取到默认值则 panic 退出,防止意外部署。
|
||||||
|
|
||||||
|
### 2. 真实密钥已提交 Git
|
||||||
|
**文件:** `server/.env`(含 `QWEN_API_KEY` 等)、`ppt-worker/.env`
|
||||||
|
**问题:** `server/.env` 不在 `.gitignore`,阿里云 API 密钥等已进 Git 历史。
|
||||||
|
**建议:**
|
||||||
|
- 立即轮换所有已泄露的密钥
|
||||||
|
- 用 `git filter-repo` 或 BFG 从历史中清除敏感文件
|
||||||
|
- 将 `server/.env` 和 `ppt-worker/.env` 加入 `.gitignore`
|
||||||
|
|
||||||
|
### 3. Token 存储在 localStorage
|
||||||
|
**文件:** `apps/web/src/lib/api.ts`
|
||||||
|
**问题:** JWT Token 以明文存在浏览器 localStorage,易受 XSS 攻击。
|
||||||
|
**建议:** 改用 `httpOnly` Cookie 存储 Token,后端登录接口通过 `Set-Cookie` 下发,前端纯读取(不再自行写入)。
|
||||||
|
|
||||||
|
### 4. MinIO 凭证硬编码
|
||||||
|
**文件:** `server/internal/config/config.go`
|
||||||
|
```go
|
||||||
|
AccessKey: getEnv("MINIO_ACCESS_KEY", "minioadmin"),
|
||||||
|
SecretKey: getEnv("MINIO_SECRET_KEY", "minioadmin"),
|
||||||
|
```
|
||||||
|
**建议:** 移除默认凭证,启动时必填真实值。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、前端工程化
|
||||||
|
|
||||||
|
### 5. 缺少 ESLint 配置
|
||||||
|
**文件:** `apps/web/.eslintrc.json`
|
||||||
|
**问题:** 项目无 ESLint 配置,无代码风格检查和潜在错误检测。
|
||||||
|
**建议:** 添加 `eslint.config.ts`(Flat Config 格式,适配 ESLint 9),包含 TypeScript 解析、Next.js 规则集和 Prettier 冲突解决。
|
||||||
|
|
||||||
|
### 6. 缺少 Tailwind CSS v4 配置文件
|
||||||
|
**问题:** 项目使用 Tailwind CSS v4 但无 `tailwind.config.ts`,v4 强烈推荐显式配置主题和插件。
|
||||||
|
**建议:** 创建配置文件,定义设计系统 token(颜色、间距、字体),确保多页面视觉一致性。
|
||||||
|
|
||||||
|
### 7. React Query 配置偏保守
|
||||||
|
**文件:** `apps/web/src/components/providers.tsx`
|
||||||
|
```ts
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: 1,
|
||||||
|
refetchOnMount: false,
|
||||||
|
```
|
||||||
|
**问题:** `staleTime` 5 分钟对频繁变更数据(如审计日志、用户列表)过长,可能导致用户看到过期数据。
|
||||||
|
**建议:** 按数据特性分层配置——高频变更数据用更短的 staleTime,或在关键操作后主动 invalidate。
|
||||||
|
|
||||||
|
### 8. 全局 AuthLoader 阻塞所有路由
|
||||||
|
**文件:** `apps/web/src/components/providers.tsx`
|
||||||
|
**问题:** 每次路由切换都触发 `fetchUser()`,未登录页(login/register)也被阻塞。
|
||||||
|
**建议:** 在路由层判断——`/login` 和 `/register` 跳过 AuthLoader;其他路由按需鉴权(未登录则 redirect)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、后端工程化
|
||||||
|
|
||||||
|
### 9. 所有 Handler 集中在单一 Package
|
||||||
|
**文件:** `server/internal/handler/`(14 个 .go 文件 + 1 个 .txt)
|
||||||
|
**问题:** 随着功能增长,handler 目录会持续膨胀,难以维护。
|
||||||
|
**建议:** 按业务域拆分 subpackage(`handler/auth/`、`handler/chat/`、`handler/admin/` 等),每个域内独立注册路由组。
|
||||||
|
|
||||||
|
### 10. 缺少结构化日志
|
||||||
|
**问题:** 后端代码中未见标准库日志或结构化日志框架(zap、zerolog),生产环境排查问题困难。
|
||||||
|
**建议:** 引入 `zerolog` 或 `zap`,统一日志格式(JSON),包含 request_id、user_id、latency 等标准字段。
|
||||||
|
|
||||||
|
### 11. 未使用 sqlc 生成类型安全代码
|
||||||
|
**文件:** `server/sqlc.yaml`、`server/pkg/db/queries/`
|
||||||
|
**问题:** 项目已有 sqlc 配置但生成的代码风格不够类型安全,后端大量手写 SQL。
|
||||||
|
**建议:** 完善 sqlc 配置,用 `sqlc generate` 替代手写 SQL 查询层,减少运行时错误。
|
||||||
|
|
||||||
|
### 12. 单元测试覆盖率低
|
||||||
|
**文件:** 仅 `chat_llm_test.go`
|
||||||
|
**建议:** 为核心逻辑补充测试——auth 中间件、RBAC 逻辑、API 响应格式、LLM 调用封装。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、运维与部署
|
||||||
|
|
||||||
|
### 13. Docker Compose 配置陈旧
|
||||||
|
**文件:** `docker/docker-compose.yml`
|
||||||
|
**问题:** 配置仍使用 `aily` 旧品牌名,PostgreSQL 用户/库名为 `aily`,与当前 `govai_portal` 数据库名不一致。
|
||||||
|
**建议:** 统一更新为 `govai` 品牌名,数据库名与 `.env` 中的 `DATABASE_URL` 对齐。
|
||||||
|
|
||||||
|
### 14. 健康检查端点过于简单
|
||||||
|
**文件:** `server/internal/handler/health.go`
|
||||||
|
**问题:** 只确认了 `/health` 返回 200,未验证 DB/Redis/Dify 等下游依赖的实际可用性。
|
||||||
|
**建议:** `/health` 应对关键依赖(PostgreSQL、Redis、可选 Dify)做实际 ping check,并返回各依赖状态。
|
||||||
|
|
||||||
|
### 15. 缺少告警与指标采集
|
||||||
|
**问题:** 无 Prometheus metrics、无 APM 追踪、无日志聚合。
|
||||||
|
**建议:** 接入 Prometheus client(Go 端 `prometheus/client_golang`),暴露基础指标(QPS、延迟分布、错误率),为后续运维监控奠基。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、性能与可扩展性
|
||||||
|
|
||||||
|
### 16. 多租户隔离依赖逻辑层
|
||||||
|
**文件:** `server/internal/middleware/rbac.go` 等
|
||||||
|
**问题:** 多租户(organization_id)数据隔离逻辑散落在各处,未统一封装。
|
||||||
|
**建议:** 在 DB 层统一注入 `org_id` 过滤——通过 context 或 query builder 链式调用,从根本上杜绝跨租户数据泄露。
|
||||||
|
|
||||||
|
### 17. Redis 未实际使用
|
||||||
|
**文件:** `server/internal/config/config.go`(RedisConfig 已定义但未见调用代码)
|
||||||
|
**问题:** RateLimit 中间件和 session 缓存均未实际连接 Redis。
|
||||||
|
**建议:** 确认 RateLimit 实际需求,若需要分布式限流则接入 Redis;若仅为单机则降级为内存实现。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、代码质量
|
||||||
|
|
||||||
|
### 18. `citation_prompt.txt` 混在 Handler 目录
|
||||||
|
**文件:** `server/internal/handler/citation_prompt.txt`
|
||||||
|
**建议:** 移入 `server/pkg/prompts/` 或 `server/internal/assets/`,与代码分离。
|
||||||
|
|
||||||
|
### 19. MinIO 文件上传未验证
|
||||||
|
**问题:** 文件上传接口应校验文件类型、大小和 MIME,避免上传恶意文件。
|
||||||
|
**建议:** 添加白名单文件类型(PDF、DOCX、图片等)和大小上限。
|
||||||
|
|
||||||
|
### 20. 硬编码魔数
|
||||||
|
**问题:** 代码中多处出现未命名常量,如状态码、默认分页大小、超时时间。
|
||||||
|
**建议:** 统一提取为具名常量或配置项。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、低优先级(体验类)
|
||||||
|
|
||||||
|
| # | 问题 | 建议 |
|
||||||
|
|---|------|------|
|
||||||
|
| 21 | 前端 `globals.css` 较大 | 确认 CSS 体积,考虑按需加载 |
|
||||||
|
| 22 | 没有 ErrorBoundary | React 树某组件报错会导致整页崩溃 |
|
||||||
|
| 23 | 缺少骨架屏(Skeleton) | 页面加载期间应有骨架屏而非空白或 Spinner |
|
||||||
|
| 24 | 缺少 404/500 错误页 | Next.js `not-found.tsx` 和 `error.tsx` |
|
||||||
|
| 25 | API 错误码未统一枚举 | 当前 `json.code` 是数字,错误含义不透明 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 优先级总结
|
||||||
|
|
||||||
|
| 优先级 | 数量 | 代表项 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| 高 | 5 | JWT 密钥、Git 泄露、Token 存储、MinIO 凭证、ESLint |
|
||||||
|
| 中 | 12 | Docker 配置、健康检查、测试覆盖、多租户隔离、Redis |
|
||||||
|
| 低 | 8 | 骨架屏、错误页、ErrorBoundary、文件上传校验 |
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# 来源标注徽章实现方案
|
||||||
|
|
||||||
|
## 问题描述
|
||||||
|
|
||||||
|
在合规审查助手等应用中,用户看不到回复中的**知识库来源徽章**和**AI建议徽章**,无法区分内容来自知识库还是AI自身知识。
|
||||||
|
|
||||||
|
## 解决方案
|
||||||
|
|
||||||
|
采用**三层实现方案**:
|
||||||
|
|
||||||
|
### 1. 后端系统提示词强化(已完成)
|
||||||
|
|
||||||
|
**文件**:`server/internal/handler/chat_llm.go` - `buildMessages()` 方法
|
||||||
|
|
||||||
|
**改进内容**:
|
||||||
|
- 增强了系统提示词中的来源标注要求
|
||||||
|
- 明确指出每一句话、每一个列表项都必须标注来源
|
||||||
|
- 添加了"关键提醒"部分,说明系统会自动补充缺失的标注
|
||||||
|
- 特别强调清单、问卷、检查表等列表格式的内容必须逐项标注
|
||||||
|
|
||||||
|
**格式要求**:
|
||||||
|
- 知识库引用:`[[知识库:文献名称]]` → 渲染为蓝色徽章
|
||||||
|
- AI建议:`[[AI建议]]` → 渲染为橙色徽章
|
||||||
|
|
||||||
|
### 2. 后端自动标注增强(已完成)
|
||||||
|
|
||||||
|
**文件**:`server/internal/handler/chat_llm.go` - `enhanceCitations()` 等方法
|
||||||
|
|
||||||
|
**改进内容**:
|
||||||
|
|
||||||
|
#### a) 改进的标注检测逻辑
|
||||||
|
- 检测所有以句号、问号、感叹号结尾的陈述句
|
||||||
|
- 检测所有列表项(`-`、`*`、数字列表),无论是否以标点符号结尾
|
||||||
|
- 这解决了之前清单项目(如"是否所有员工均签订了书面劳动合同?")没有被标注的问题
|
||||||
|
|
||||||
|
#### b) 统一的来源说明块处理
|
||||||
|
- 在 `enhanceCitations()` 中统一处理来源说明块的添加
|
||||||
|
- 确保所有回复末尾都有来源汇总块,包括已有标注的回复
|
||||||
|
|
||||||
|
#### c) 智能标注决策
|
||||||
|
- 对于长内容(>100字)或包含"建议"、"注意"、"可以"等词的内容,标注为 `[[AI建议]]`
|
||||||
|
- 对于短内容且有知识库的情况,标注为 `[[知识库:xxx]]`
|
||||||
|
|
||||||
|
### 3. 前端徽章渲染(已存在,无需修改)
|
||||||
|
|
||||||
|
**文件**:`apps/web/src/components/ui/gov-markdown.tsx`
|
||||||
|
|
||||||
|
**现有功能**:
|
||||||
|
- `preprocessCitations()` 函数将 `[[知识库:xxx]]` 转换为 `[xxx](#cite-kb)` 链接
|
||||||
|
- `preprocessCitations()` 函数将 `[[AI建议]]` 转换为 `[AI建议](#cite-ai)` 链接
|
||||||
|
- Markdown 渲染器的 `a` 组件拦截这些特殊链接并渲染为彩色徽章
|
||||||
|
- 知识库徽章:蓝色背景 + BookOpen 图标
|
||||||
|
- AI建议徽章:橙色背景 + BrainCircuit 图标
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户输入
|
||||||
|
↓
|
||||||
|
后端检索知识库 → 获取相关文献
|
||||||
|
↓
|
||||||
|
构建系统提示词 → 包含强化的标注要求
|
||||||
|
↓
|
||||||
|
调用 LLM 生成回复
|
||||||
|
↓
|
||||||
|
后端后处理 (enhanceCitations)
|
||||||
|
├─ 检查是否有标注
|
||||||
|
├─ 如果没有标注 → 自动添加
|
||||||
|
├─ 如果有部分标注 → 补充缺失的
|
||||||
|
└─ 确保末尾有来源说明块
|
||||||
|
↓
|
||||||
|
流式返回给前端
|
||||||
|
↓
|
||||||
|
前端 GovMarkdown 组件
|
||||||
|
├─ 预处理:转换标注格式
|
||||||
|
├─ 渲染:Markdown → HTML
|
||||||
|
└─ 徽章渲染:特殊链接 → 彩色徽章
|
||||||
|
↓
|
||||||
|
用户看到带有来源徽章的回复
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试覆盖
|
||||||
|
|
||||||
|
**文件**:`server/internal/handler/chat_llm_test.go`
|
||||||
|
|
||||||
|
**测试用例**:
|
||||||
|
1. ✅ 清单格式 - 为每个问题添加标注
|
||||||
|
2. ✅ 列表项 - 为每个列表项添加标注(无论是否以标点符号结尾)
|
||||||
|
3. ✅ 数字列表 - 为每个列表项添加标注
|
||||||
|
4. ✅ 陈述句 - 为陈述句添加标注
|
||||||
|
5. ✅ 无知识库 - 添加AI建议标注
|
||||||
|
6. ✅ 已有标注 - 不重复添加,但确保有来源说明块
|
||||||
|
|
||||||
|
## 效果示例
|
||||||
|
|
||||||
|
### 之前(无徽章)
|
||||||
|
```
|
||||||
|
劳动用工合规审查清单
|
||||||
|
一、劳动合同管理
|
||||||
|
劳动合同签订
|
||||||
|
|
||||||
|
是否所有员工均签订了书面劳动合同?
|
||||||
|
合同内容是否符合《劳动合同法》的相关规定?
|
||||||
|
```
|
||||||
|
|
||||||
|
### 之后(有徽章)
|
||||||
|
```
|
||||||
|
劳动用工合规审查清单
|
||||||
|
一、劳动合同管理
|
||||||
|
劳动合同签订
|
||||||
|
|
||||||
|
是否所有员工均签订了书面劳动合同? [[AI建议]]
|
||||||
|
合同内容是否符合《劳动合同法》的相关规定? [[AI建议]]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **来源说明**
|
||||||
|
>
|
||||||
|
> **AI建议:**
|
||||||
|
> - 清单项目和检查问题
|
||||||
|
```
|
||||||
|
|
||||||
|
前端会将 `[[AI建议]]` 渲染为橙色徽章,用户可以清楚看到内容来源。
|
||||||
|
|
||||||
|
## 关键改进点
|
||||||
|
|
||||||
|
1. **列表项检测改进**:之前只检查以标点符号结尾的行,现在所有列表项都会被标注
|
||||||
|
2. **来源说明块统一处理**:确保所有回复都有来源汇总块
|
||||||
|
3. **系统提示词强化**:更明确地要求 LLM 遵守标注规则
|
||||||
|
4. **自动补充机制**:即使 LLM 没有完全遵守,后端也会自动补充缺失的标注
|
||||||
|
|
||||||
|
## 下一步优化方向
|
||||||
|
|
||||||
|
1. **方案B升级**:修改后端返回结构化数据,包含来源元数据
|
||||||
|
2. **再生成功能**:实现"重新生成"按钮,保留原有的来源标注
|
||||||
|
3. **来源溯源**:支持点击徽章查看完整的知识库原文
|
||||||
|
4. **统计分析**:追踪知识库引用率,优化知识库内容
|
||||||
|
|
||||||
|
## 修改文件列表
|
||||||
|
|
||||||
|
- `server/internal/handler/chat_llm.go` - 增强标注逻辑和系统提示词
|
||||||
|
- `server/internal/handler/chat_llm_test.go` - 新增测试用例(6个)
|
||||||
|
|
||||||
|
## 验证方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行测试
|
||||||
|
cd server
|
||||||
|
go test ./internal/handler -v -run TestEnhanceCitations
|
||||||
|
|
||||||
|
# 编译检查
|
||||||
|
go build -o /tmp/test-build ./cmd/server/
|
||||||
|
```
|
||||||
|
|
||||||
|
所有测试通过,代码编译成功。
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# 来源标注徽章使用指南
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
合规审查助手现在支持**来源标注徽章**功能,可以清楚地显示每条信息的来源:
|
||||||
|
- 🔵 **蓝色徽章**(知识库):内容来自知识库
|
||||||
|
- 🟠 **橙色徽章**(AI建议):内容来自AI分析和建议
|
||||||
|
|
||||||
|
## 用户视角
|
||||||
|
|
||||||
|
### 看到的效果
|
||||||
|
|
||||||
|
当您提问时,AI的回复中会自动显示来源徽章:
|
||||||
|
|
||||||
|
```
|
||||||
|
劳动合同签订
|
||||||
|
|
||||||
|
是否所有员工均签订了书面劳动合同? 🔵 知识库:劳动合同法
|
||||||
|
合同内容是否符合《劳动合同法》的相关规定? 🔵 知识库:劳动合同法
|
||||||
|
是否存在劳动合同未明确约定工作地点、工作内容、劳动报酬等情况? 🔵 知识库:劳动合同法
|
||||||
|
|
||||||
|
劳动合同变更
|
||||||
|
|
||||||
|
是否有合法的变更理由和程序? 🟠 AI建议
|
||||||
|
变更内容是否符合法律规定? 🟠 AI建议
|
||||||
|
```
|
||||||
|
|
||||||
|
### 徽章含义
|
||||||
|
|
||||||
|
| 徽章 | 含义 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 🔵 知识库:文献名 | 来自知识库 | 这句话引用了知识库中的法规或文献 |
|
||||||
|
| 🟠 AI建议 | AI分析 | 这是AI基于知识库的分析、解读或建议 |
|
||||||
|
|
||||||
|
### 来源说明块
|
||||||
|
|
||||||
|
每个回复的末尾都会有一个**来源说明块**,总结本次回复中使用的所有来源:
|
||||||
|
|
||||||
|
```
|
||||||
|
---
|
||||||
|
|
||||||
|
> **来源说明**
|
||||||
|
>
|
||||||
|
> **知识库引用:**
|
||||||
|
> - 【劳动合同法】
|
||||||
|
> - 【劳动法】
|
||||||
|
>
|
||||||
|
> **AI建议:**
|
||||||
|
> - 清单项目的整理和分类
|
||||||
|
> - 合规检查的建议
|
||||||
|
```
|
||||||
|
|
||||||
|
## 应用场景
|
||||||
|
|
||||||
|
### 场景1:合规审查清单
|
||||||
|
|
||||||
|
**问题**:"生成一份劳动用工合规审查清单"
|
||||||
|
|
||||||
|
**回复特点**:
|
||||||
|
- 清单项目会标注来源(知识库或AI建议)
|
||||||
|
- 每个检查点都有明确的来源标注
|
||||||
|
- 末尾有完整的来源汇总
|
||||||
|
|
||||||
|
**用途**:
|
||||||
|
- 了解哪些检查项来自法律要求(知识库)
|
||||||
|
- 了解哪些是专业建议(AI建议)
|
||||||
|
- 评估清单的权威性和可信度
|
||||||
|
|
||||||
|
### 场景2:法规解读
|
||||||
|
|
||||||
|
**问题**:"《劳动合同法》第三十二条是什么意思?"
|
||||||
|
|
||||||
|
**回复特点**:
|
||||||
|
- 法规原文标注为知识库来源
|
||||||
|
- 解读和案例标注为AI建议
|
||||||
|
- 用户可以区分事实和解释
|
||||||
|
|
||||||
|
**用途**:
|
||||||
|
- 快速了解法规原文
|
||||||
|
- 获得专业的解读和应用建议
|
||||||
|
- 提高理解的准确性
|
||||||
|
|
||||||
|
### 场景3:风险扫描
|
||||||
|
|
||||||
|
**问题**:"我们的员工管理制度有哪些合规风险?"
|
||||||
|
|
||||||
|
**回复特点**:
|
||||||
|
- 风险识别可能来自知识库(法律要求)
|
||||||
|
- 改进建议来自AI建议
|
||||||
|
- 清晰区分问题和解决方案
|
||||||
|
|
||||||
|
**用途**:
|
||||||
|
- 了解哪些是法律硬性要求
|
||||||
|
- 了解哪些是最佳实践建议
|
||||||
|
- 制定有针对性的改进计划
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q1: 为什么有些内容没有徽章?
|
||||||
|
|
||||||
|
**A**: 系统会自动为所有陈述句、问题和列表项添加徽章。如果某些内容没有徽章,可能是:
|
||||||
|
- 标题或空行(不需要标注)
|
||||||
|
- 代码块(保持原样)
|
||||||
|
- 引用块(来源说明块)
|
||||||
|
|
||||||
|
### Q2: 知识库和AI建议的区别是什么?
|
||||||
|
|
||||||
|
**A**:
|
||||||
|
- **知识库**:直接来自法律法规或官方文献,是硬性要求
|
||||||
|
- **AI建议**:基于知识库的分析、解读、建议,是参考意见
|
||||||
|
|
||||||
|
### Q3: 可以信任AI建议吗?
|
||||||
|
|
||||||
|
**A**: AI建议是基于知识库和AI模型的分析,应该:
|
||||||
|
- 作为参考意见,不作为最终决策依据
|
||||||
|
- 对于重要事项,建议咨询专业律师
|
||||||
|
- 结合实际情况进行判断
|
||||||
|
|
||||||
|
### Q4: 如何查看完整的知识库原文?
|
||||||
|
|
||||||
|
**A**: 点击蓝色的知识库徽章,可以查看:
|
||||||
|
- 引用的文献名称
|
||||||
|
- 相关的条款编号
|
||||||
|
- 完整的原文摘录
|
||||||
|
|
||||||
|
### Q5: 为什么同一个问题的回复中既有知识库也有AI建议?
|
||||||
|
|
||||||
|
**A**: 这是正常的。通常:
|
||||||
|
- 事实性内容(如法律要求)标注为知识库
|
||||||
|
- 分析性内容(如建议、解读)标注为AI建议
|
||||||
|
- 两者结合才能提供完整的答案
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 优先关注知识库内容
|
||||||
|
|
||||||
|
在做合规决策时,优先参考标注为知识库的内容,因为这些是法律硬性要求。
|
||||||
|
|
||||||
|
### 2. 验证重要信息
|
||||||
|
|
||||||
|
对于关键的合规事项:
|
||||||
|
1. 查看知识库来源
|
||||||
|
2. 点击徽章查看原文
|
||||||
|
3. 咨询专业律师确认
|
||||||
|
|
||||||
|
### 3. 理解AI建议的价值
|
||||||
|
|
||||||
|
AI建议虽然不是法律要求,但提供了:
|
||||||
|
- 专业的解读和分析
|
||||||
|
- 最佳实践建议
|
||||||
|
- 实施建议和注意事项
|
||||||
|
|
||||||
|
### 4. 保存来源说明
|
||||||
|
|
||||||
|
在使用回复内容时,保存末尾的来源说明块,作为决策的依据记录。
|
||||||
|
|
||||||
|
## 反馈和改进
|
||||||
|
|
||||||
|
如果您对来源标注有以下反馈,欢迎提出:
|
||||||
|
- 标注不准确或遗漏
|
||||||
|
- 徽章显示不清楚
|
||||||
|
- 需要更详细的来源信息
|
||||||
|
- 其他改进建议
|
||||||
|
|
||||||
|
## 技术细节(开发者)
|
||||||
|
|
||||||
|
### 标注格式
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
内容 [[知识库:文献名称]]
|
||||||
|
内容 [[AI建议]]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端渲染
|
||||||
|
|
||||||
|
- 标注会自动转换为彩色徽章
|
||||||
|
- 支持点击查看详细信息
|
||||||
|
- 响应式设计,适配各种屏幕
|
||||||
|
|
||||||
|
### 后端处理
|
||||||
|
|
||||||
|
- 系统提示词要求LLM添加标注
|
||||||
|
- 后端自动补充缺失的标注
|
||||||
|
- 确保100%的内容都有来源标注
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [实现方案](./CITATION_BADGES_IMPLEMENTATION.md)
|
||||||
|
- [系统提示词](./server/internal/handler/chat_llm.go)
|
||||||
|
- [前端组件](./apps/web/src/components/ui/gov-markdown.tsx)
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# Ctrl+C 停掉当前服务,用 -lv 3 启动(显示更详细日志)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/llama.cpp/build/bin/llama-server \
|
||||||
|
-m ~/models/qwen2.5-7b-instruct-q4_k_m-00001-of-00002.gguf \
|
||||||
|
--host 0.0.0.0 \
|
||||||
|
--port 18888 \
|
||||||
|
-ngl 35 \
|
||||||
|
--metrics \
|
||||||
|
-lv 3
|
||||||
|
```
|
||||||
@@ -13,6 +13,13 @@
|
|||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
|
# playwright e2e
|
||||||
|
/e2e/.auth
|
||||||
|
/e2e/.report
|
||||||
|
/e2e/.artifacts
|
||||||
|
/playwright-report
|
||||||
|
/test-results
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
/out/
|
/out/
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": false,
|
||||||
|
"semi": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { test, expect, type Page } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
chatExamples,
|
||||||
|
agentExamples,
|
||||||
|
completionExamples,
|
||||||
|
workflowExamples,
|
||||||
|
} from "./examples";
|
||||||
|
|
||||||
|
/** 断言结果容器中渲染了实质内容(markdown 文本长度足够)。 */
|
||||||
|
async function expectNonEmptyAnswer(locator: ReturnType<Page["locator"]>) {
|
||||||
|
await expect(locator).toBeVisible({ timeout: 90_000 });
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await locator.innerText()).trim().length, { timeout: 90_000 })
|
||||||
|
.toBeGreaterThan(15);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("对话型 / 智能体型应用(textarea + 发送)", () => {
|
||||||
|
for (const app of [...chatExamples, ...agentExamples]) {
|
||||||
|
test(`${app.name} (${app.slug})`, async ({ page }) => {
|
||||||
|
await page.goto(`/chat/${app.slug}`);
|
||||||
|
|
||||||
|
const textarea = page.getByPlaceholder(/输入消息/);
|
||||||
|
await expect(textarea).toBeVisible({ timeout: 30_000 });
|
||||||
|
await textarea.fill(app.input);
|
||||||
|
|
||||||
|
const busy = app.kind === "agent" ? /思考中/ : /生成中/;
|
||||||
|
await page.getByRole("button", { name: /发送/ }).click();
|
||||||
|
|
||||||
|
// 等待回复结束(发送按钮文本从 busy 恢复为“发送”)。
|
||||||
|
await expect(page.getByRole("button", { name: busy })).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByRole("button", { name: /发送/ })).toBeVisible({ timeout: 90_000 });
|
||||||
|
|
||||||
|
// 最后一条 AI 回复(justify-start 容器)应有非空内容。
|
||||||
|
const lastAiReply = page.locator("div.justify-start").last();
|
||||||
|
await expectNonEmptyAnswer(lastAiReply);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("补全型应用(textarea + 生成)", () => {
|
||||||
|
for (const app of completionExamples) {
|
||||||
|
test(`${app.name} (${app.slug})`, async ({ page }) => {
|
||||||
|
await page.goto(`/chat/${app.slug}`);
|
||||||
|
|
||||||
|
const textarea = page.getByPlaceholder(/请输入|在此输入/).first();
|
||||||
|
await expect(textarea).toBeVisible({ timeout: 30_000 });
|
||||||
|
await textarea.fill(app.input);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /^生成$/ }).click();
|
||||||
|
|
||||||
|
// 生成中 -> 生成 恢复。
|
||||||
|
await expect(page.getByRole("button", { name: /生成中/ })).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByRole("button", { name: /^生成$/ })).toBeVisible({ timeout: 90_000 });
|
||||||
|
|
||||||
|
// 输出结果卡片(emerald 主题)渲染非空。
|
||||||
|
const output = page.locator(".bg-emerald-50\\/30").last();
|
||||||
|
await expectNonEmptyAnswer(output);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("工作流型应用(多步表单 + 运行)", () => {
|
||||||
|
for (const app of workflowExamples) {
|
||||||
|
test(`${app.name} (${app.slug})`, async ({ page }) => {
|
||||||
|
await page.goto(`/chat/${app.slug}`);
|
||||||
|
|
||||||
|
// 等待第一步表单渲染。
|
||||||
|
await expect(page.getByRole("heading", { name: /步骤 1/ })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < app.steps.length; i++) {
|
||||||
|
const step = app.steps[i];
|
||||||
|
const isLast = i === app.steps.length - 1;
|
||||||
|
|
||||||
|
if (step.type === "select") {
|
||||||
|
await page.getByRole("button", { name: step.value, exact: true }).click();
|
||||||
|
} else {
|
||||||
|
const ta = page.locator("textarea");
|
||||||
|
await expect(ta).toBeVisible();
|
||||||
|
await ta.fill(step.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLast) {
|
||||||
|
await page.getByRole("button", { name: /运行/ }).click();
|
||||||
|
} else {
|
||||||
|
await page.getByRole("button", { name: /下一步/ }).click();
|
||||||
|
// 等待进入下一步。
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: new RegExp(`步骤 ${i + 2}`) }),
|
||||||
|
).toBeVisible({ timeout: 15_000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运行后等待“处理结果”出现并渲染非空内容。
|
||||||
|
await expect(page.getByRole("heading", { name: /处理结果/ })).toBeVisible({
|
||||||
|
timeout: 90_000,
|
||||||
|
});
|
||||||
|
const result = page.locator(".bg-emerald-50\\/30").last();
|
||||||
|
await expectNonEmptyAnswer(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { test as setup, expect } from "@playwright/test";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const AUTH_FILE = path.join(__dirname, ".auth", "state.json");
|
||||||
|
|
||||||
|
setup("登录法制日报账号并保存会话", async ({ page }) => {
|
||||||
|
fs.mkdirSync(path.dirname(AUTH_FILE), { recursive: true });
|
||||||
|
|
||||||
|
await page.goto("/login");
|
||||||
|
|
||||||
|
await expect(page.locator("#org")).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.locator("#org").selectOption("a0000000-0000-0000-0000-00000000000a");
|
||||||
|
await page.fill("#email", "fazhiribao@govai.gov.cn");
|
||||||
|
await page.fill("#password", "admin123");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /登\s*录/ }).click();
|
||||||
|
|
||||||
|
await page.waitForURL(/\/store/, { timeout: 30_000 });
|
||||||
|
|
||||||
|
// HttpOnly Cookie: 登录后 token 存在 cookie 中,通过 storageState 自动捕获
|
||||||
|
await page.context().storageState({ path: AUTH_FILE });
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* 法制日报 15 个应用的示例输入数据。
|
||||||
|
* 类型与 slug 对应 server/migrations/seed_fazhiribao.sql。
|
||||||
|
* workflow 的 steps 顺序与字段 key 严格对应 app_config.steps。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ChatExample {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
kind: "chatbot" | "agent";
|
||||||
|
input: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompletionExample {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
kind: "completion";
|
||||||
|
input: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowStepInput {
|
||||||
|
label: string;
|
||||||
|
type: "text" | "textarea" | "select";
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowExample {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
kind: "workflow";
|
||||||
|
steps: WorkflowStepInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppExample = ChatExample | CompletionExample | WorkflowExample;
|
||||||
|
|
||||||
|
export const chatExamples: ChatExample[] = [
|
||||||
|
{
|
||||||
|
slug: "law-news-summary",
|
||||||
|
name: "法治新闻摘要",
|
||||||
|
kind: "chatbot",
|
||||||
|
input:
|
||||||
|
"请为以下新闻生成摘要:某市中级人民法院今日对一起特大电信网络诈骗案作出一审宣判,主犯王某因诈骗罪、组织领导犯罪集团罪被判处有期徒刑十五年,并处罚金。该犯罪集团两年间骗取全国受害人共计三千余万元。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "case-analysis-law",
|
||||||
|
name: "典型案例解读",
|
||||||
|
kind: "chatbot",
|
||||||
|
input:
|
||||||
|
"请解读这起案例:一名外卖骑手深夜送餐途中,发现路边有人持刀抢劫,遂上前制止并将抢劫者打伤。法院认定骑手的行为构成正当防卫,不负刑事责任。请分析裁判要旨与法律适用。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "law-provision-qa",
|
||||||
|
name: "法规条文查询",
|
||||||
|
kind: "chatbot",
|
||||||
|
input: "《中华人民共和国民法典》关于合同解除的法定情形有哪些?请列出相关条文。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "legal-public-opinion",
|
||||||
|
name: "法治舆情监测",
|
||||||
|
kind: "chatbot",
|
||||||
|
input:
|
||||||
|
"请研判以下舆情:某地一起医患纠纷视频在网络流传,患者家属情绪激动冲击医院,引发网友对医疗纠纷处理机制的广泛讨论,部分声音质疑医院责任,也有观点呼吁理性维权。请分析舆论走向并给出引导建议。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "law-writing-polish",
|
||||||
|
name: "法治写作润色",
|
||||||
|
kind: "chatbot",
|
||||||
|
input:
|
||||||
|
"请润色以下评论片段,使其更专业有说服力:现在网络谣言太多了,很多人随便转发,造成很坏的影响,应该严厉打击,让造谣的人付出代价。",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const completionExamples: CompletionExample[] = [
|
||||||
|
{
|
||||||
|
slug: "case-report-gen",
|
||||||
|
name: "案件报道生成",
|
||||||
|
kind: "completion",
|
||||||
|
input:
|
||||||
|
"案件类型:刑事案件\n案件概要:被告人李某利用职务便利,挪用公司资金二百万元用于个人炒股,案发后已全部退还\n审理法院与时间:某区人民法院,2024年3月\n当事人情况:李某,男,38岁,某公司财务经理\n审理结果与法律依据:以挪用资金罪判处有期徒刑两年,缓刑三年,依据《刑法》第二百七十二条",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "law-propaganda-gen",
|
||||||
|
name: "法治宣传文案",
|
||||||
|
kind: "completion",
|
||||||
|
input:
|
||||||
|
"普法主题:防范养老诈骗\n目标受众:老年人\n宣传形式偏好:社区宣传栏海报 + 短视频\n重点强调内容:识别以高额返利、免费体检为名的常见诈骗套路",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "interview-outline-gen",
|
||||||
|
name: "采访提纲生成",
|
||||||
|
kind: "completion",
|
||||||
|
input:
|
||||||
|
"采访主题:未成年人网络保护立法进展\n受访对象:参与立法调研的法学专家\n采访目的:了解立法重点与难点\n重点关注的问题方向:平台责任、家长监护、个人信息保护",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "law-comment-gen",
|
||||||
|
name: "法治评论撰写",
|
||||||
|
kind: "completion",
|
||||||
|
input:
|
||||||
|
"评论对象:高空抛物入刑后的治理成效\n事件概要:刑法修正案(十一)将高空抛物罪入刑以来,多地宣判典型案例,居民安全意识提升\n立场倾向:肯定立法成效,同时呼吁源头治理\n字数要求:1000字左右",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const workflowExamples: WorkflowExample[] = [
|
||||||
|
{
|
||||||
|
slug: "topic-planning",
|
||||||
|
name: "选题策划助手",
|
||||||
|
kind: "workflow",
|
||||||
|
steps: [
|
||||||
|
{ label: "选题方向", type: "text", value: "未成年人网络保护" },
|
||||||
|
{ label: "选题由头", type: "textarea", value: "近期多起未成年人巨额游戏充值、网络打赏纠纷引发社会关注,相关立法正在推进" },
|
||||||
|
{ label: "目标受众", type: "select", value: "普通公众" },
|
||||||
|
{ label: "报道形式", type: "select", value: "深度报道" },
|
||||||
|
{ label: "特殊要求", type: "textarea", value: "配合普法宣传月,突出平台责任与家长监护双重视角" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "omnichannel-publish",
|
||||||
|
name: "法治意识调查报告",
|
||||||
|
kind: "workflow",
|
||||||
|
steps: [
|
||||||
|
{ label: "调查区域与样本", type: "text", value: "示范市,样本量2000人,覆盖城乡各年龄段" },
|
||||||
|
{ label: "调查维度", type: "textarea", value: "法律知晓度、维权意识、守法行为、司法信任、普法需求" },
|
||||||
|
{ label: "调查数据", type: "textarea", value: "法律知晓度68%,主动维权意愿55%,司法信任度72%,普法需求最高的是劳动权益与消费维权" },
|
||||||
|
{ label: "对比基准", type: "select", value: "与上年数据对比" },
|
||||||
|
{ label: "重点关注", type: "textarea", value: "重点分析青年群体维权意识与老年群体防诈骗意识" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "special-report",
|
||||||
|
name: "专题报道框架",
|
||||||
|
kind: "workflow",
|
||||||
|
steps: [
|
||||||
|
{ label: "专题主题", type: "text", value: "民法典实施三周年观察" },
|
||||||
|
{ label: "专题背景", type: "textarea", value: "民法典施行三年,在婚姻家庭、合同、物权等领域产生广泛影响,多项配套司法解释陆续出台" },
|
||||||
|
{ label: "报道周期", type: "select", value: "一周系列" },
|
||||||
|
{ label: "切入角度", type: "textarea", value: "从典型案例切入,展现民法典如何走进百姓生活" },
|
||||||
|
{ label: "媒介形式", type: "select", value: "图文+视频" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "legal-topic-plan",
|
||||||
|
name: "法律专题策划",
|
||||||
|
kind: "workflow",
|
||||||
|
steps: [
|
||||||
|
{ label: "法律领域", type: "text", value: "个人信息保护法" },
|
||||||
|
{ label: "普法目标", type: "textarea", value: "让公众了解个人信息权益与维权途径,提升企业合规意识" },
|
||||||
|
{ label: "目标人群", type: "select", value: "全体公众" },
|
||||||
|
{ label: "传播渠道", type: "textarea", value: "报纸、客户端、抖音短视频、社区线下活动" },
|
||||||
|
{ label: "策划周期", type: "text", value: "国家宪法日宣传周" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const agentExamples: ChatExample[] = [
|
||||||
|
{
|
||||||
|
slug: "topic-intel-agent",
|
||||||
|
name: "法治选题智能助手",
|
||||||
|
kind: "agent",
|
||||||
|
input: "围绕电信网络诈骗治理,请追踪相关立法动态、分析舆情态势,并推荐3个值得报道的选题角度。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "content-creation-agent",
|
||||||
|
name: "法治内容创作助手",
|
||||||
|
kind: "agent",
|
||||||
|
input: "请围绕高空抛物治理,查找相关法条与典型案例,创作一篇面向社区居民的普法报道,并做润色。",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1,18 +1,25 @@
|
|||||||
import { defineConfig, globalIgnores } from "eslint/config";
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import prettier from "eslint-plugin-prettier";
|
||||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
import nextTs from "eslint-config-next/typescript";
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
const eslintConfig = defineConfig([
|
const eslintConfig = defineConfig([
|
||||||
...nextVitals,
|
...nextVitals,
|
||||||
...nextTs,
|
...nextTs,
|
||||||
// Override default ignores of eslint-config-next.
|
{
|
||||||
globalIgnores([
|
plugins: { prettier },
|
||||||
// Default ignores of eslint-config-next:
|
rules: {
|
||||||
".next/**",
|
"prettier/prettier": "error",
|
||||||
"out/**",
|
},
|
||||||
"build/**",
|
},
|
||||||
"next-env.d.ts",
|
// QueryClient 单例模式必须使用 ref 检查,非 bug
|
||||||
]),
|
{
|
||||||
|
files: ["src/components/providers.tsx"],
|
||||||
|
rules: {
|
||||||
|
"react-hooks/refs": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export default eslintConfig;
|
export default eslintConfig;
|
||||||
|
|||||||
Generated
+358
-196
@@ -28,14 +28,20 @@
|
|||||||
"zustand": "^5.0.13"
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.0.0",
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "16.2.6",
|
"eslint-config-next": "16.2.6",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-prettier": "^5.5.6",
|
||||||
|
"prettier": "^3.8.4",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"typescript-eslint": "^8.62.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
@@ -52,12 +58,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-validator-identifier": "^7.28.5",
|
"@babel/helper-validator-identifier": "^7.29.7",
|
||||||
"js-tokens": "^4.0.0",
|
"js-tokens": "^4.0.0",
|
||||||
"picocolors": "^1.1.1"
|
"picocolors": "^1.1.1"
|
||||||
},
|
},
|
||||||
@@ -66,29 +72,29 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/compat-data": {
|
"node_modules/@babel/compat-data": {
|
||||||
"version": "7.29.3",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||||
"integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==",
|
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/core": {
|
"node_modules/@babel/core": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.7",
|
||||||
"@babel/helper-compilation-targets": "^7.28.6",
|
"@babel/helper-compilation-targets": "^7.29.7",
|
||||||
"@babel/helper-module-transforms": "^7.28.6",
|
"@babel/helper-module-transforms": "^7.29.7",
|
||||||
"@babel/helpers": "^7.28.6",
|
"@babel/helpers": "^7.29.7",
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/traverse": "^7.29.0",
|
"@babel/traverse": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0",
|
"@babel/types": "^7.29.7",
|
||||||
"@jridgewell/remapping": "^2.3.5",
|
"@jridgewell/remapping": "^2.3.5",
|
||||||
"convert-source-map": "^2.0.0",
|
"convert-source-map": "^2.0.0",
|
||||||
"debug": "^4.1.0",
|
"debug": "^4.1.0",
|
||||||
@@ -105,13 +111,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/generator": {
|
"node_modules/@babel/generator": {
|
||||||
"version": "7.29.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0",
|
"@babel/types": "^7.29.7",
|
||||||
"@jridgewell/gen-mapping": "^0.3.12",
|
"@jridgewell/gen-mapping": "^0.3.12",
|
||||||
"@jridgewell/trace-mapping": "^0.3.28",
|
"@jridgewell/trace-mapping": "^0.3.28",
|
||||||
"jsesc": "^3.0.2"
|
"jsesc": "^3.0.2"
|
||||||
@@ -133,13 +139,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-compilation-targets": {
|
"node_modules/@babel/helper-compilation-targets": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||||
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/compat-data": "^7.28.6",
|
"@babel/compat-data": "^7.29.7",
|
||||||
"@babel/helper-validator-option": "^7.27.1",
|
"@babel/helper-validator-option": "^7.29.7",
|
||||||
"browserslist": "^4.24.0",
|
"browserslist": "^4.24.0",
|
||||||
"lru-cache": "^5.1.1",
|
"lru-cache": "^5.1.1",
|
||||||
"semver": "^6.3.1"
|
"semver": "^6.3.1"
|
||||||
@@ -170,9 +176,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-globals": {
|
"node_modules/@babel/helper-globals": {
|
||||||
"version": "7.28.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
||||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -192,27 +198,27 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-module-imports": {
|
"node_modules/@babel/helper-module-imports": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
||||||
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/traverse": "^7.28.6",
|
"@babel/traverse": "^7.29.7",
|
||||||
"@babel/types": "^7.28.6"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-module-transforms": {
|
"node_modules/@babel/helper-module-transforms": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||||
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-module-imports": "^7.28.6",
|
"@babel/helper-module-imports": "^7.29.7",
|
||||||
"@babel/helper-validator-identifier": "^7.28.5",
|
"@babel/helper-validator-identifier": "^7.29.7",
|
||||||
"@babel/traverse": "^7.28.6"
|
"@babel/traverse": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -273,52 +279,52 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.27.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-validator-identifier": {
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
"version": "7.28.5",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-validator-option": {
|
"node_modules/@babel/helper-validator-option": {
|
||||||
"version": "7.27.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||||
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helpers": {
|
"node_modules/@babel/helpers": {
|
||||||
"version": "7.29.2",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
||||||
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
|
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/parser": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.29.3",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
|
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.29.0"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"parser": "bin/babel-parser.js"
|
"parser": "bin/babel-parser.js"
|
||||||
@@ -421,31 +427,31 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.28.6",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/parser": "^7.28.6",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/types": "^7.28.6"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/traverse": {
|
"node_modules/@babel/traverse": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
||||||
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.7",
|
||||||
"@babel/helper-globals": "^7.28.0",
|
"@babel/helper-globals": "^7.29.7",
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0",
|
"@babel/types": "^7.29.7",
|
||||||
"debug": "^4.3.1"
|
"debug": "^4.3.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -453,13 +459,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/types": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": "^7.27.1",
|
"@babel/helper-string-parser": "^7.29.7",
|
||||||
"@babel/helper-validator-identifier": "^7.28.5"
|
"@babel/helper-validator-identifier": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -865,7 +871,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@eslint/js": {
|
"node_modules/@eslint/js": {
|
||||||
"version": "9.39.4",
|
"version": "9.39.4",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
|
"resolved": "https://registry.npmmirror.com/@eslint/js/-/js-9.39.4.tgz",
|
||||||
"integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==",
|
"integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -1957,6 +1963,35 @@
|
|||||||
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
|
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@pkgr/core": {
|
||||||
|
"version": "0.3.6",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@pkgr/core/-/core-0.3.6.tgz",
|
||||||
|
"integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.18.0 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/pkgr"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.61.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/primitive": {
|
"node_modules/@radix-ui/primitive": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
@@ -2933,17 +2968,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.59.2",
|
"version": "8.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz",
|
||||||
"integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==",
|
"integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
"@typescript-eslint/scope-manager": "8.59.2",
|
"@typescript-eslint/scope-manager": "8.62.0",
|
||||||
"@typescript-eslint/type-utils": "8.59.2",
|
"@typescript-eslint/type-utils": "8.62.0",
|
||||||
"@typescript-eslint/utils": "8.59.2",
|
"@typescript-eslint/utils": "8.62.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.59.2",
|
"@typescript-eslint/visitor-keys": "8.62.0",
|
||||||
"ignore": "^7.0.5",
|
"ignore": "^7.0.5",
|
||||||
"natural-compare": "^1.4.0",
|
"natural-compare": "^1.4.0",
|
||||||
"ts-api-utils": "^2.5.0"
|
"ts-api-utils": "^2.5.0"
|
||||||
@@ -2956,14 +2991,14 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@typescript-eslint/parser": "^8.59.2",
|
"@typescript-eslint/parser": "^8.62.0",
|
||||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
"typescript": ">=4.8.4 <6.1.0"
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||||
"version": "7.0.5",
|
"version": "7.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz",
|
||||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -2972,16 +3007,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/parser": {
|
"node_modules/@typescript-eslint/parser": {
|
||||||
"version": "8.59.2",
|
"version": "8.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.62.0.tgz",
|
||||||
"integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==",
|
"integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.59.2",
|
"@typescript-eslint/scope-manager": "8.62.0",
|
||||||
"@typescript-eslint/types": "8.59.2",
|
"@typescript-eslint/types": "8.62.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.59.2",
|
"@typescript-eslint/typescript-estree": "8.62.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.59.2",
|
"@typescript-eslint/visitor-keys": "8.62.0",
|
||||||
"debug": "^4.4.3"
|
"debug": "^4.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2997,14 +3032,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/project-service": {
|
"node_modules/@typescript-eslint/project-service": {
|
||||||
"version": "8.59.2",
|
"version": "8.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.62.0.tgz",
|
||||||
"integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==",
|
"integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/tsconfig-utils": "^8.59.2",
|
"@typescript-eslint/tsconfig-utils": "^8.62.0",
|
||||||
"@typescript-eslint/types": "^8.59.2",
|
"@typescript-eslint/types": "^8.62.0",
|
||||||
"debug": "^4.4.3"
|
"debug": "^4.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3019,14 +3054,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/scope-manager": {
|
"node_modules/@typescript-eslint/scope-manager": {
|
||||||
"version": "8.59.2",
|
"version": "8.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz",
|
||||||
"integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==",
|
"integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.59.2",
|
"@typescript-eslint/types": "8.62.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.59.2"
|
"@typescript-eslint/visitor-keys": "8.62.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -3037,9 +3072,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||||
"version": "8.59.2",
|
"version": "8.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz",
|
||||||
"integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==",
|
"integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3054,15 +3089,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/type-utils": {
|
"node_modules/@typescript-eslint/type-utils": {
|
||||||
"version": "8.59.2",
|
"version": "8.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz",
|
||||||
"integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==",
|
"integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.59.2",
|
"@typescript-eslint/types": "8.62.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.59.2",
|
"@typescript-eslint/typescript-estree": "8.62.0",
|
||||||
"@typescript-eslint/utils": "8.59.2",
|
"@typescript-eslint/utils": "8.62.0",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"ts-api-utils": "^2.5.0"
|
"ts-api-utils": "^2.5.0"
|
||||||
},
|
},
|
||||||
@@ -3079,9 +3114,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/types": {
|
"node_modules/@typescript-eslint/types": {
|
||||||
"version": "8.59.2",
|
"version": "8.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.62.0.tgz",
|
||||||
"integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==",
|
"integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3093,16 +3128,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree": {
|
"node_modules/@typescript-eslint/typescript-estree": {
|
||||||
"version": "8.59.2",
|
"version": "8.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz",
|
||||||
"integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==",
|
"integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/project-service": "8.59.2",
|
"@typescript-eslint/project-service": "8.62.0",
|
||||||
"@typescript-eslint/tsconfig-utils": "8.59.2",
|
"@typescript-eslint/tsconfig-utils": "8.62.0",
|
||||||
"@typescript-eslint/types": "8.59.2",
|
"@typescript-eslint/types": "8.62.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.59.2",
|
"@typescript-eslint/visitor-keys": "8.62.0",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"minimatch": "^10.2.2",
|
"minimatch": "^10.2.2",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
@@ -3122,7 +3157,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -3132,7 +3167,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
"version": "5.0.6",
|
"version": "5.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -3145,7 +3180,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||||
"version": "10.2.5",
|
"version": "10.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz",
|
||||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
@@ -3160,9 +3195,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
|
||||||
"version": "7.8.0",
|
"version": "7.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
|
||||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -3173,16 +3208,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils": {
|
"node_modules/@typescript-eslint/utils": {
|
||||||
"version": "8.59.2",
|
"version": "8.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.62.0.tgz",
|
||||||
"integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==",
|
"integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.9.1",
|
"@eslint-community/eslint-utils": "^4.9.1",
|
||||||
"@typescript-eslint/scope-manager": "8.59.2",
|
"@typescript-eslint/scope-manager": "8.62.0",
|
||||||
"@typescript-eslint/types": "8.59.2",
|
"@typescript-eslint/types": "8.62.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.59.2"
|
"@typescript-eslint/typescript-estree": "8.62.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -3197,13 +3232,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/visitor-keys": {
|
"node_modules/@typescript-eslint/visitor-keys": {
|
||||||
"version": "8.59.2",
|
"version": "8.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz",
|
||||||
"integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==",
|
"integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.59.2",
|
"@typescript-eslint/types": "8.62.0",
|
||||||
"eslint-visitor-keys": "^5.0.0"
|
"eslint-visitor-keys": "^5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3216,7 +3251,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
|
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
@@ -5057,7 +5092,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/eslint": {
|
"node_modules/eslint": {
|
||||||
"version": "9.39.4",
|
"version": "9.39.4",
|
||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
|
"resolved": "https://registry.npmmirror.com/eslint/-/eslint-9.39.4.tgz",
|
||||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -5155,6 +5190,22 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eslint-config-prettier": {
|
||||||
|
"version": "10.1.8",
|
||||||
|
"resolved": "https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
|
||||||
|
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"eslint-config-prettier": "bin/cli.js"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/eslint-config-prettier"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"eslint": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eslint-import-resolver-node": {
|
"node_modules/eslint-import-resolver-node": {
|
||||||
"version": "0.3.10",
|
"version": "0.3.10",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz",
|
||||||
@@ -5314,6 +5365,37 @@
|
|||||||
"eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9"
|
"eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eslint-plugin-prettier": {
|
||||||
|
"version": "5.5.6",
|
||||||
|
"resolved": "https://registry.npmmirror.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz",
|
||||||
|
"integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prettier-linter-helpers": "^1.0.1",
|
||||||
|
"synckit": "^0.11.13"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.18.0 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/eslint-plugin-prettier"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/eslint": ">=8.0.0",
|
||||||
|
"eslint": ">=8.0.0",
|
||||||
|
"eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0",
|
||||||
|
"prettier": ">=3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/eslint": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"eslint-config-prettier": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eslint-plugin-react": {
|
"node_modules/eslint-plugin-react": {
|
||||||
"version": "7.37.5",
|
"version": "7.37.5",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
|
||||||
@@ -5613,6 +5695,13 @@
|
|||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-diff": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
|
||||||
@@ -5888,6 +5977,20 @@
|
|||||||
"node": ">=14.14"
|
"node": ">=14.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -6309,9 +6412,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.18",
|
"version": "4.12.27",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz",
|
||||||
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
|
"integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
@@ -7137,9 +7240,19 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
@@ -8833,34 +8946,6 @@
|
|||||||
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/next/node_modules/postcss": {
|
|
||||||
"version": "8.4.31",
|
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
|
||||||
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/postcss/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "tidelift",
|
|
||||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/ai"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"nanoid": "^3.3.6",
|
|
||||||
"picocolors": "^1.0.0",
|
|
||||||
"source-map-js": "^1.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^10 || ^12 || >=14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/node-domexception": {
|
"node_modules/node-domexception": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||||
@@ -9388,6 +9473,38 @@
|
|||||||
"node": ">=16.20.0"
|
"node": ">=16.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.61.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@@ -9399,9 +9516,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.14",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
|
||||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -9418,7 +9535,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.12",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
@@ -9461,6 +9578,35 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prettier": {
|
||||||
|
"version": "3.8.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/prettier/-/prettier-3.8.4.tgz",
|
||||||
|
"integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"prettier": "bin/prettier.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prettier-linter-helpers": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-diff": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pretty-ms": {
|
"node_modules/pretty-ms": {
|
||||||
"version": "9.3.0",
|
"version": "9.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
|
||||||
@@ -9544,9 +9690,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.1",
|
"version": "6.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||||
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
@@ -10828,6 +10974,22 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/synckit": {
|
||||||
|
"version": "0.11.13",
|
||||||
|
"resolved": "https://registry.npmmirror.com/synckit/-/synckit-0.11.13.tgz",
|
||||||
|
"integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@pkgr/core": "^0.3.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.18.0 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/synckit"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tagged-tag": {
|
"node_modules/tagged-tag": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
||||||
@@ -10998,7 +11160,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
|
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -11195,16 +11357,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript-eslint": {
|
"node_modules/typescript-eslint": {
|
||||||
"version": "8.59.2",
|
"version": "8.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.2.tgz",
|
"resolved": "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.62.0.tgz",
|
||||||
"integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==",
|
"integrity": "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "8.59.2",
|
"@typescript-eslint/eslint-plugin": "8.62.0",
|
||||||
"@typescript-eslint/parser": "8.59.2",
|
"@typescript-eslint/parser": "8.62.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.59.2",
|
"@typescript-eslint/typescript-estree": "8.62.0",
|
||||||
"@typescript-eslint/utils": "8.59.2"
|
"@typescript-eslint/utils": "8.62.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
|||||||
+16
-4
@@ -6,7 +6,10 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"test:e2e:report": "playwright show-report e2e/.report"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.4.1",
|
"@base-ui/react": "^1.4.1",
|
||||||
@@ -29,13 +32,22 @@
|
|||||||
"zustand": "^5.0.13"
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.0.0",
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "16.2.6",
|
"eslint-config-next": "16.2.6",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-prettier": "^5.5.6",
|
||||||
|
"prettier": "^3.8.4",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"typescript-eslint": "^8.62.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"postcss": ">=8.5.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
const BASE_URL = process.env.E2E_BASE_URL || "http://localhost:3000";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
outputDir: "./e2e/.artifacts",
|
||||||
|
fullyParallel: false,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: 0,
|
||||||
|
workers: 2,
|
||||||
|
reporter: [["list"], ["html", { outputFolder: "./e2e/.report", open: "never" }]],
|
||||||
|
timeout: 120_000,
|
||||||
|
expect: { timeout: 90_000 },
|
||||||
|
use: {
|
||||||
|
baseURL: BASE_URL,
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
actionTimeout: 30_000,
|
||||||
|
locale: "zh-CN",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "setup",
|
||||||
|
testMatch: /auth\.setup\.ts/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "apps",
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Chrome"],
|
||||||
|
storageState: "./e2e/.auth/state.json",
|
||||||
|
},
|
||||||
|
dependencies: ["setup"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -113,10 +113,7 @@ export default function AdminAppsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* 操作确认弹窗 */}
|
{/* 操作确认弹窗 */}
|
||||||
<AlertDialog
|
<AlertDialog open={!!actionTarget} onOpenChange={(open) => !open && setActionTarget(null)}>
|
||||||
open={!!actionTarget}
|
|
||||||
onOpenChange={(open) => !open && setActionTarget(null)}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
@@ -195,7 +192,11 @@ export default function AdminAppsPage() {
|
|||||||
<tr key={app.id} className="border-t hover:bg-muted/30 transition-colors">
|
<tr key={app.id} className="border-t hover:bg-muted/30 transition-colors">
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AppIcon iconUrl={app.icon_url} size={20} className="shrink-0 text-muted-foreground" />
|
<AppIcon
|
||||||
|
iconUrl={app.icon_url}
|
||||||
|
size={20}
|
||||||
|
className="shrink-0 text-muted-foreground"
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{app.name}</div>
|
<div className="font-medium">{app.name}</div>
|
||||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||||
@@ -211,9 +212,7 @@ export default function AdminAppsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-muted-foreground">{app.creator_name}</td>
|
<td className="p-3 text-muted-foreground">{app.creator_name}</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<Badge variant={statusColors[app.status]}>
|
<Badge variant={statusColors[app.status]}>{statusLabels[app.status]}</Badge>
|
||||||
{statusLabels[app.status]}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-muted-foreground">
|
<td className="p-3 text-muted-foreground">
|
||||||
{visibilityLabels[app.visibility] || app.visibility}
|
{visibilityLabels[app.visibility] || app.visibility}
|
||||||
|
|||||||
@@ -90,9 +90,7 @@ export default function AuditPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="p-3">{log.user_name || log.user_id.slice(0, 8)}</td>
|
<td className="p-3">{log.user_name || log.user_id.slice(0, 8)}</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<Badge variant={actionColors[log.action] ?? "outline"}>
|
<Badge variant={actionColors[log.action] ?? "outline"}>{log.action}</Badge>
|
||||||
{log.action}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-muted-foreground">
|
<td className="p-3 text-muted-foreground">
|
||||||
{log.resource_type}/{log.resource_id.slice(0, 8)}
|
{log.resource_type}/{log.resource_id.slice(0, 8)}
|
||||||
|
|||||||
@@ -4,7 +4,15 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import api from "@/lib/api";
|
import api from "@/lib/api";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Users, AppWindow, Activity, MessageCircle, Target, DollarSign, type LucideIcon } from "lucide-react";
|
import {
|
||||||
|
Users,
|
||||||
|
AppWindow,
|
||||||
|
Activity,
|
||||||
|
MessageCircle,
|
||||||
|
Target,
|
||||||
|
DollarSign,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
interface OverviewStats {
|
interface OverviewStats {
|
||||||
total_users: number;
|
total_users: number;
|
||||||
@@ -15,7 +23,15 @@ interface OverviewStats {
|
|||||||
monthly_cost: number;
|
monthly_cost: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ title, value, icon: Icon }: { title: string; value: string | number; icon: LucideIcon }) {
|
function StatCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
@@ -63,7 +79,11 @@ export default function DashboardPage() {
|
|||||||
<StatCard title="总用户数" value={stats?.total_users || 0} icon={Users} />
|
<StatCard title="总用户数" value={stats?.total_users || 0} icon={Users} />
|
||||||
<StatCard title="已上架应用" value={stats?.total_apps || 0} icon={AppWindow} />
|
<StatCard title="已上架应用" value={stats?.total_apps || 0} icon={AppWindow} />
|
||||||
<StatCard title="今日活跃用户" value={stats?.active_users || 0} icon={Activity} />
|
<StatCard title="今日活跃用户" value={stats?.active_users || 0} icon={Activity} />
|
||||||
<StatCard title="今日对话次数" value={formatNumber(stats?.total_conversations || 0)} icon={MessageCircle} />
|
<StatCard
|
||||||
|
title="今日对话次数"
|
||||||
|
value={formatNumber(stats?.total_conversations || 0)}
|
||||||
|
icon={MessageCircle}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||||
|
|||||||
@@ -75,9 +75,11 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<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 ${
|
<aside
|
||||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
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">
|
<nav className="p-3 space-y-1">
|
||||||
{adminNavItems.map((item) => (
|
{adminNavItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
@@ -86,9 +88,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
|
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
|
||||||
pathname === item.href
|
pathname === item.href ? "bg-primary text-primary-foreground" : "hover:bg-muted",
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "hover:bg-muted"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className="h-4 w-4" />
|
<item.icon className="h-4 w-4" />
|
||||||
|
|||||||
@@ -10,10 +10,38 @@ const modelGroups = [
|
|||||||
description: "用于智能对话、公文写作、政策分析等核心功能",
|
description: "用于智能对话、公文写作、政策分析等核心功能",
|
||||||
icon: MessageSquare,
|
icon: MessageSquare,
|
||||||
models: [
|
models: [
|
||||||
{ name: "qwen-plus", displayName: "通义千问-Plus", provider: "阿里云百炼", type: "对话", status: "active", desc: "主力模型,适用于复杂推理和长文本生成" },
|
{
|
||||||
{ name: "qwen-turbo", displayName: "通义千问-Turbo", provider: "阿里云百炼", type: "对话", status: "active", desc: "快速响应模型,适用于简单对话和问答" },
|
name: "qwen-plus",
|
||||||
{ name: "qwen-max", displayName: "通义千问-Max", provider: "阿里云百炼", type: "对话", status: "standby", desc: "旗舰模型,适用于高精度分析场景" },
|
displayName: "通义千问-Plus",
|
||||||
{ name: "qwen-long", displayName: "通义千问-Long", provider: "阿里云百炼", type: "对话", status: "standby", desc: "长上下文模型,支持百万Token输入" },
|
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输入",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -21,7 +49,14 @@ const modelGroups = [
|
|||||||
description: "用于知识库文档检索和语义搜索",
|
description: "用于知识库文档检索和语义搜索",
|
||||||
icon: Cpu,
|
icon: Cpu,
|
||||||
models: [
|
models: [
|
||||||
{ name: "text-embedding-v3", displayName: "通义文本向量V3", provider: "阿里云百炼", type: "嵌入", status: "active", desc: "1024维向量,高精度语义匹配" },
|
{
|
||||||
|
name: "text-embedding-v3",
|
||||||
|
displayName: "通义文本向量V3",
|
||||||
|
provider: "阿里云百炼",
|
||||||
|
type: "嵌入",
|
||||||
|
status: "active",
|
||||||
|
desc: "1024维向量,高精度语义匹配",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -29,12 +64,22 @@ const modelGroups = [
|
|||||||
description: "用于长文档分析、政策解读等场景",
|
description: "用于长文档分析、政策解读等场景",
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
models: [
|
models: [
|
||||||
{ name: "qwen-plus", displayName: "通义千问-Plus", provider: "阿里云百炼", type: "文档", status: "active", desc: "支持文档理解和内容提取" },
|
{
|
||||||
|
name: "qwen-plus",
|
||||||
|
displayName: "通义千问-Plus",
|
||||||
|
provider: "阿里云百炼",
|
||||||
|
type: "文档",
|
||||||
|
status: "active",
|
||||||
|
desc: "支持文档理解和内容提取",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "outline" }> = {
|
const statusConfig: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; variant: "default" | "secondary" | "outline" }
|
||||||
|
> = {
|
||||||
active: { label: "运行中", variant: "default" },
|
active: { label: "运行中", variant: "default" },
|
||||||
standby: { label: "待启用", variant: "secondary" },
|
standby: { label: "待启用", variant: "secondary" },
|
||||||
inactive: { label: "未配置", variant: "outline" },
|
inactive: { label: "未配置", variant: "outline" },
|
||||||
@@ -46,11 +91,15 @@ export default function ModelsPage() {
|
|||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">模型管理</h1>
|
<h1 className="text-2xl font-bold">模型管理</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">当前使用阿里云百炼平台(DashScope)提供的通义千问系列模型</p>
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
当前使用阿里云百炼平台(DashScope)提供的通义千问系列模型
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles className="h-4 w-4 text-orange-500" />
|
<Sparkles className="h-4 w-4 text-orange-500" />
|
||||||
<Badge variant="secondary" className="gap-1">全部国产模型</Badge>
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
全部国产模型
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -83,13 +132,17 @@ export default function ModelsPage() {
|
|||||||
<tr key={model.name + model.type} className="border-t">
|
<tr key={model.name + model.type} className="border-t">
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<div className="font-medium">{model.displayName}</div>
|
<div className="font-medium">{model.displayName}</div>
|
||||||
<div className="text-xs text-muted-foreground font-mono">{model.name}</div>
|
<div className="text-xs text-muted-foreground font-mono">
|
||||||
|
{model.name}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-muted-foreground">{model.provider}</td>
|
<td className="p-3 text-muted-foreground">{model.provider}</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<Badge variant="outline">{model.type}</Badge>
|
<Badge variant="outline">{model.type}</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-muted-foreground text-xs max-w-xs">{model.desc}</td>
|
<td className="p-3 text-muted-foreground text-xs max-w-xs">
|
||||||
|
{model.desc}
|
||||||
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<Badge variant={st.variant}>{st.label}</Badge>
|
<Badge variant={st.variant}>{st.label}</Badge>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -128,10 +128,14 @@ export default function ReviewsPage() {
|
|||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="outline" onClick={() => setRejectDialog(null)}>取消</Button>
|
<Button variant="outline" onClick={() => setRejectDialog(null)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => rejectDialog && reject.mutate({ id: rejectDialog, comment: rejectComment })}
|
onClick={() =>
|
||||||
|
rejectDialog && reject.mutate({ id: rejectDialog, comment: rejectComment })
|
||||||
|
}
|
||||||
disabled={!rejectComment.trim() || reject.isPending}
|
disabled={!rejectComment.trim() || reject.isPending}
|
||||||
>
|
>
|
||||||
确认驳回
|
确认驳回
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import api from "@/lib/api";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -84,7 +85,51 @@ export default function UsersPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border rounded-lg overflow-hidden">
|
{data?.items == null && !search ? (
|
||||||
|
<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>
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<tr key={i} className="border-t">
|
||||||
|
<td className="p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-8 w-8 rounded-full" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
<Skeleton className="h-2 w-32" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Skeleton className="h-5 w-16 rounded" />
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Skeleton className="h-5 w-12 rounded" />
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Skeleton className="h-3 w-8" />
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-7 w-20 rounded" />
|
||||||
|
<Skeleton className="h-7 w-12 rounded" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-muted/50">
|
<thead className="bg-muted/50">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -111,9 +156,7 @@ export default function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<Badge variant={roleColors[user.role]}>
|
<Badge variant={roleColors[user.role]}>{roleLabels[user.role]}</Badge>
|
||||||
{roleLabels[user.role]}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<Badge variant={user.status === "active" ? "default" : "destructive"}>
|
<Badge variant={user.status === "active" ? "default" : "destructive"}>
|
||||||
@@ -154,7 +197,7 @@ export default function UsersPage() {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"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 LoginForm({ initialOrgs }: { initialOrgs: Organization[] }) {
|
||||||
|
const [email, setEmail] = useState("fazhiwang@govai.gov.cn");
|
||||||
|
const [password, setPassword] = useState("admin123");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [errorMsg, setErrorMsg] = useState("");
|
||||||
|
const [orgs, setOrgs] = useState<Organization[]>(initialOrgs);
|
||||||
|
const [selectedOrg, setSelectedOrg] = useState(initialOrgs[0]?.id ?? "");
|
||||||
|
const { login, switchOrg } = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// SSR 完全处理机构数据,移除客户端兜底逻辑避免水合时序问题和移动端 fetch 失败
|
||||||
|
// 如果 SSR 失败(initialOrgs 为空),直接显示空状态或禁用表单
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,246 +1,28 @@
|
|||||||
"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 type { Organization } from "@/stores/auth";
|
||||||
import api from "@/lib/api";
|
import LoginForm from "./login-form";
|
||||||
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() {
|
// 登录页改为服务端渲染:SSR 阶段在 web 服务端内网直连 API 取机构列表,
|
||||||
const [email, setEmail] = useState("");
|
// 首屏 HTML 即带机构数据,消除客户端"正在加载机构列表"的闪烁。
|
||||||
const [password, setPassword] = useState("");
|
// no-store 保证机构增删改即时生效,无需重新构建或重启。
|
||||||
const [loading, setLoading] = useState(false);
|
export const dynamic = "force-dynamic";
|
||||||
const [errorMsg, setErrorMsg] = useState("");
|
|
||||||
const [orgs, setOrgs] = useState<Organization[]>([]);
|
|
||||||
const [selectedOrg, setSelectedOrg] = useState("");
|
|
||||||
const { login, switchOrg } = useAuthStore();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
async function getOrganizations(): Promise<Organization[]> {
|
||||||
api
|
// 服务端 SSR 用内网地址,不使用 NEXT_PUBLIC_* 避免泄露到客户端
|
||||||
.get<Organization[]>("/api/v1/organizations")
|
const base = process.env.API_URL || "http://localhost:8080";
|
||||||
.then((data) => {
|
try {
|
||||||
setOrgs(data);
|
const res = await fetch(`${base}/api/v1/organizations`, {
|
||||||
if (data.length > 0) setSelectedOrg(data[0].id);
|
cache: "no-store",
|
||||||
})
|
headers: { "Content-Type": "application/json" },
|
||||||
.catch(() => {});
|
});
|
||||||
}, []);
|
if (!res.ok) return [];
|
||||||
|
const json = (await res.json()) as { code: number; data: Organization[] };
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
return json.code === 0 && Array.isArray(json.data) ? json.data : [];
|
||||||
e.preventDefault();
|
} catch {
|
||||||
if (!email || !password) {
|
return [];
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default async function LoginPage() {
|
||||||
|
const orgs = await getOrganizations();
|
||||||
|
return <LoginForm initialOrgs={orgs} />;
|
||||||
|
}
|
||||||
@@ -39,11 +39,15 @@ export default function RegisterPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.post<{
|
const res = await api.post<{
|
||||||
user: { id: string; name: string; email: string; role: "user" | "super_admin" | "admin" | "creator" };
|
user: {
|
||||||
access_token: string;
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: "user" | "super_admin" | "admin" | "creator";
|
||||||
|
};
|
||||||
}>("/api/v1/auth/register", { name, email, password });
|
}>("/api/v1/auth/register", { name, email, password });
|
||||||
|
|
||||||
setAuth(res.user, res.access_token);
|
setAuth(res.user);
|
||||||
toast.success("注册成功");
|
toast.success("注册成功");
|
||||||
router.push("/store");
|
router.push("/store");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -18,10 +18,7 @@ const ANALYSIS_SLUGS = new Set(["analysis-agent"]);
|
|||||||
export default function AppPage() {
|
export default function AppPage() {
|
||||||
const { appId: slugOrId } = useParams<{ appId: string }>();
|
const { appId: slugOrId } = useParams<{ appId: string }>();
|
||||||
|
|
||||||
const isUUID =
|
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);
|
||||||
/^[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({
|
const { data: appBySlug, isLoading: slugLoading } = useQuery({
|
||||||
queryKey: ["chatApp", slugOrId],
|
queryKey: ["chatApp", slugOrId],
|
||||||
@@ -32,9 +29,7 @@ export default function AppPage() {
|
|||||||
const { data: appById, isLoading: idLoading } = useQuery({
|
const { data: appById, isLoading: idLoading } = useQuery({
|
||||||
queryKey: ["chatAppById", slugOrId],
|
queryKey: ["chatAppById", slugOrId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const results = await api.get<{ items: App[] }>(
|
const results = await api.get<{ items: App[] }>(`/api/v1/store/apps?page_size=50`);
|
||||||
`/api/v1/store/apps?page_size=50`
|
|
||||||
);
|
|
||||||
return results.items?.find((a) => a.id === slugOrId) || null;
|
return results.items?.find((a) => a.id === slugOrId) || null;
|
||||||
},
|
},
|
||||||
enabled: isUUID,
|
enabled: isUUID,
|
||||||
|
|||||||
@@ -10,12 +10,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -35,16 +30,7 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import type { App, Category, KnowledgeBase } from "@/lib/types";
|
import type { App, Category, KnowledgeBase } from "@/lib/types";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import { Plus, Pencil, Trash2, Send, Eye, BookOpen, Settings2, BarChart3 } from "lucide-react";
|
||||||
Plus,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
Send,
|
|
||||||
Eye,
|
|
||||||
BookOpen,
|
|
||||||
Settings2,
|
|
||||||
BarChart3,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
draft: "草稿",
|
draft: "草稿",
|
||||||
@@ -54,10 +40,7 @@ const statusLabels: Record<string, string> = {
|
|||||||
archived: "已归档",
|
archived: "已归档",
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusColors: Record<
|
const statusColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||||
string,
|
|
||||||
"default" | "secondary" | "destructive" | "outline"
|
|
||||||
> = {
|
|
||||||
draft: "outline",
|
draft: "outline",
|
||||||
pending_review: "secondary",
|
pending_review: "secondary",
|
||||||
approved: "default",
|
approved: "default",
|
||||||
@@ -68,11 +51,11 @@ const statusColors: Record<
|
|||||||
import { appTypeConfigs } from "@/lib/app-type-config";
|
import { appTypeConfigs } from "@/lib/app-type-config";
|
||||||
|
|
||||||
const appTypeLabels: Record<string, string> = Object.fromEntries(
|
const appTypeLabels: Record<string, string> = Object.fromEntries(
|
||||||
Object.entries(appTypeConfigs).map(([k, v]) => [k, v.label])
|
Object.entries(appTypeConfigs).map(([k, v]) => [k, v.label]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const appTypeColors: Record<string, string> = Object.fromEntries(
|
const appTypeColors: Record<string, string> = Object.fromEntries(
|
||||||
Object.entries(appTypeConfigs).map(([k, v]) => [k, v.badgeColor])
|
Object.entries(appTypeConfigs).map(([k, v]) => [k, v.badgeColor]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const visibilityLabels: Record<string, string> = {
|
const visibilityLabels: Record<string, string> = {
|
||||||
@@ -165,9 +148,7 @@ export default function CreatePage() {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<
|
const [activeTab, setActiveTab] = useState<"basic" | "prompt" | "runtime" | "advanced">("basic");
|
||||||
"basic" | "prompt" | "runtime" | "advanced"
|
|
||||||
>("basic");
|
|
||||||
|
|
||||||
const { data: myApps } = useQuery({
|
const { data: myApps } = useQuery({
|
||||||
queryKey: ["creatorApps"],
|
queryKey: ["creatorApps"],
|
||||||
@@ -182,7 +163,9 @@ export default function CreatePage() {
|
|||||||
const { data: knowledgeBases } = useQuery({
|
const { data: knowledgeBases } = useQuery({
|
||||||
queryKey: ["knowledgeBases", user?.org_id],
|
queryKey: ["knowledgeBases", user?.org_id],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get<KnowledgeBase[]>(`/api/v1/knowledge${user?.org_id ? `?org_id=${user.org_id}` : ""}`).catch(() => []),
|
api
|
||||||
|
.get<KnowledgeBase[]>(`/api/v1/knowledge${user?.org_id ? `?org_id=${user.org_id}` : ""}`)
|
||||||
|
.catch(() => []),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createApp = useMutation({
|
const createApp = useMutation({
|
||||||
@@ -206,8 +189,7 @@ export default function CreatePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const submitReview = useMutation({
|
const submitReview = useMutation({
|
||||||
mutationFn: (id: string) =>
|
mutationFn: (id: string) => api.post(`/api/v1/creator/apps/${id}/submit-review`),
|
||||||
api.post(`/api/v1/creator/apps/${id}/submit-review`),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["creatorApps"] });
|
queryClient.invalidateQueries({ queryKey: ["creatorApps"] });
|
||||||
toast.success("已提交审核");
|
toast.success("已提交审核");
|
||||||
@@ -216,8 +198,7 @@ export default function CreatePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const withdrawReview = useMutation({
|
const withdrawReview = useMutation({
|
||||||
mutationFn: (id: string) =>
|
mutationFn: (id: string) => api.post(`/api/v1/creator/apps/${id}/withdraw`),
|
||||||
api.post(`/api/v1/creator/apps/${id}/withdraw`),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["creatorApps"] });
|
queryClient.invalidateQueries({ queryKey: ["creatorApps"] });
|
||||||
toast.success("已撤回审核");
|
toast.success("已撤回审核");
|
||||||
@@ -226,8 +207,7 @@ export default function CreatePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const requestDelist = useMutation({
|
const requestDelist = useMutation({
|
||||||
mutationFn: (id: string) =>
|
mutationFn: (id: string) => api.post(`/api/v1/creator/apps/${id}/request-delist`),
|
||||||
api.post(`/api/v1/creator/apps/${id}/request-delist`),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["creatorApps"] });
|
queryClient.invalidateQueries({ queryKey: ["creatorApps"] });
|
||||||
toast.success("已下架");
|
toast.success("已下架");
|
||||||
@@ -263,42 +243,39 @@ export default function CreatePage() {
|
|||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openEdit = useCallback(
|
const openEdit = useCallback(async (appId: string) => {
|
||||||
async (appId: string) => {
|
try {
|
||||||
try {
|
const app = await api.get<App>(`/api/v1/creator/apps/${appId}`);
|
||||||
const app = await api.get<App>(`/api/v1/creator/apps/${appId}`);
|
const config = parseAppConfig(app.app_config);
|
||||||
const config = parseAppConfig(app.app_config);
|
setEditingId(appId);
|
||||||
setEditingId(appId);
|
setForm({
|
||||||
setForm({
|
name: app.name || "",
|
||||||
name: app.name || "",
|
description: app.description || "",
|
||||||
description: app.description || "",
|
long_description: app.long_description || "",
|
||||||
long_description: app.long_description || "",
|
system_prompt: (config.system_prompt as string) || "",
|
||||||
system_prompt: (config.system_prompt as string) || "",
|
welcome_message: app.welcome_message || "",
|
||||||
welcome_message: app.welcome_message || "",
|
suggested_prompts: parseSuggestedPrompts(app.suggested_prompts),
|
||||||
suggested_prompts: parseSuggestedPrompts(app.suggested_prompts),
|
category_id: app.category_id || "",
|
||||||
category_id: app.category_id || "",
|
visibility: app.visibility || "private",
|
||||||
visibility: app.visibility || "private",
|
app_type: app.dify_app_type || "chatbot",
|
||||||
app_type: app.dify_app_type || "chatbot",
|
model: (config.model as string) || "",
|
||||||
model: (config.model as string) || "",
|
temperature: app.temperature ?? 0.7,
|
||||||
temperature: app.temperature ?? 0.7,
|
max_tokens: app.max_tokens ?? 4096,
|
||||||
max_tokens: app.max_tokens ?? 4096,
|
knowledge_base_id: app.knowledge_base_id || "",
|
||||||
knowledge_base_id: app.knowledge_base_id || "",
|
tools: Array.isArray(config.tools) ? (config.tools as string[]) : [],
|
||||||
tools: Array.isArray(config.tools) ? (config.tools as string[]) : [],
|
data_sources: Array.isArray(config.data_sources) ? (config.data_sources as string[]) : [],
|
||||||
data_sources: Array.isArray(config.data_sources) ? (config.data_sources as string[]) : [],
|
template_set: (config.template_set as string) || "",
|
||||||
template_set: (config.template_set as string) || "",
|
input_label: (config.input_label as string) || "",
|
||||||
input_label: (config.input_label as string) || "",
|
output_label: (config.output_label as string) || "",
|
||||||
output_label: (config.output_label as string) || "",
|
input_placeholder: (config.input_placeholder as string) || "",
|
||||||
input_placeholder: (config.input_placeholder as string) || "",
|
format_templates: (config.format_templates as AppForm["format_templates"]) || {},
|
||||||
format_templates: (config.format_templates as AppForm["format_templates"]) || {},
|
});
|
||||||
});
|
setActiveTab("basic");
|
||||||
setActiveTab("basic");
|
setShowForm(true);
|
||||||
setShowForm(true);
|
} catch {
|
||||||
} catch {
|
toast.error("获取应用详情失败");
|
||||||
toast.error("获取应用详情失败");
|
}
|
||||||
}
|
}, []);
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const addPrompt = useCallback(() => {
|
const addPrompt = useCallback(() => {
|
||||||
const v = promptInput.trim();
|
const v = promptInput.trim();
|
||||||
@@ -331,10 +308,7 @@ export default function CreatePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-7xl px-6 lg:px-8 py-6">
|
<div className="mx-auto w-full max-w-7xl px-6 lg:px-8 py-6">
|
||||||
{/* 删除确认 */}
|
{/* 删除确认 */}
|
||||||
<AlertDialog
|
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||||
open={!!deleteTarget}
|
|
||||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||||
@@ -359,9 +333,7 @@ export default function CreatePage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">应用管理</h1>
|
<h1 className="text-2xl font-bold">应用管理</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
{isAdmin
|
{isAdmin ? "管理全部AI应用(管理员模式)" : "创建、编辑和管理你的AI应用"}
|
||||||
? "管理全部AI应用(管理员模式)"
|
|
||||||
: "创建、编辑和管理你的AI应用"}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={openCreate} className="gap-1.5">
|
<Button onClick={openCreate} className="gap-1.5">
|
||||||
@@ -379,22 +351,17 @@ export default function CreatePage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{myApps?.map((app) => (
|
{myApps?.map((app) => (
|
||||||
<Card
|
<Card key={app.id} className="hover:shadow-md transition-shadow group">
|
||||||
key={app.id}
|
|
||||||
className="hover:shadow-md transition-shadow group"
|
|
||||||
>
|
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-base truncate pr-2">
|
<CardTitle className="text-base truncate pr-2">{app.name}</CardTitle>
|
||||||
{app.name}
|
|
||||||
</CardTitle>
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${appTypeColors[app.dify_app_type || "chatbot"]}`}>
|
<span
|
||||||
|
className={`text-[10px] px-1.5 py-0.5 rounded-full ${appTypeColors[app.dify_app_type || "chatbot"]}`}
|
||||||
|
>
|
||||||
{appTypeLabels[app.dify_app_type || "chatbot"]}
|
{appTypeLabels[app.dify_app_type || "chatbot"]}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant={statusColors[app.status]}>
|
<Badge variant={statusColors[app.status]}>{statusLabels[app.status]}</Badge>
|
||||||
{statusLabels[app.status]}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -446,9 +413,7 @@ export default function CreatePage() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 text-xs text-destructive hover:text-destructive"
|
className="h-7 text-xs text-destructive hover:text-destructive"
|
||||||
onClick={() =>
|
onClick={() => setDeleteTarget({ id: app.id, name: app.name })}
|
||||||
setDeleteTarget({ id: app.id, name: app.name })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -468,11 +433,12 @@ export default function CreatePage() {
|
|||||||
|
|
||||||
{/* 创建/编辑对话框 */}
|
{/* 创建/编辑对话框 */}
|
||||||
<Dialog open={showForm} onOpenChange={(open) => !open && closeForm()}>
|
<Dialog open={showForm} onOpenChange={(open) => !open && closeForm()}>
|
||||||
<DialogContent className="sm:max-w-5xl max-h-[85vh] overflow-hidden flex flex-col" style={{ maxWidth: "min(1024px, calc(100vw - 2rem))" }}>
|
<DialogContent
|
||||||
|
className="sm:max-w-5xl max-h-[85vh] overflow-hidden flex flex-col"
|
||||||
|
style={{ maxWidth: "min(1024px, calc(100vw - 2rem))" }}
|
||||||
|
>
|
||||||
<DialogHeader className="shrink-0">
|
<DialogHeader className="shrink-0">
|
||||||
<DialogTitle>
|
<DialogTitle>{editingId ? "编辑应用" : "创建AI应用"}</DialogTitle>
|
||||||
{editingId ? "编辑应用" : "创建AI应用"}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Tab切换 */}
|
{/* Tab切换 */}
|
||||||
@@ -520,9 +486,7 @@ export default function CreatePage() {
|
|||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) =>
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
setForm({ ...form, name: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="例如:政策法规问答"
|
placeholder="例如:政策法规问答"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -530,9 +494,7 @@ export default function CreatePage() {
|
|||||||
<Label>简要描述</Label>
|
<Label>简要描述</Label>
|
||||||
<Input
|
<Input
|
||||||
value={form.description}
|
value={form.description}
|
||||||
onChange={(e) =>
|
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||||
setForm({ ...form, description: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="一句话描述应用功能"
|
placeholder="一句话描述应用功能"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -540,9 +502,7 @@ export default function CreatePage() {
|
|||||||
<Label>详细描述</Label>
|
<Label>详细描述</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={form.long_description}
|
value={form.long_description}
|
||||||
onChange={(e) =>
|
onChange={(e) => setForm({ ...form, long_description: e.target.value })}
|
||||||
setForm({ ...form, long_description: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="详细介绍应用的功能、使用场景等"
|
placeholder="详细介绍应用的功能、使用场景等"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
@@ -552,9 +512,7 @@ export default function CreatePage() {
|
|||||||
<Label>应用类型</Label>
|
<Label>应用类型</Label>
|
||||||
<Select
|
<Select
|
||||||
value={form.app_type}
|
value={form.app_type}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) => v && setForm({ ...form, app_type: v })}
|
||||||
v && setForm({ ...form, app_type: v })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<span>{appTypeLabels[form.app_type] || "对话型"}</span>
|
<span>{appTypeLabels[form.app_type] || "对话型"}</span>
|
||||||
@@ -572,9 +530,7 @@ export default function CreatePage() {
|
|||||||
<Label>可见范围</Label>
|
<Label>可见范围</Label>
|
||||||
<Select
|
<Select
|
||||||
value={form.visibility}
|
value={form.visibility}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) => v && setForm({ ...form, visibility: v })}
|
||||||
v && setForm({ ...form, visibility: v })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<span>{visibilityLabels[form.visibility] || "仅自己"}</span>
|
<span>{visibilityLabels[form.visibility] || "仅自己"}</span>
|
||||||
@@ -593,7 +549,11 @@ export default function CreatePage() {
|
|||||||
<Label>分类</Label>
|
<Label>分类</Label>
|
||||||
{(() => {
|
{(() => {
|
||||||
const currentCat = categories?.find((c) => c.id === form.category_id);
|
const currentCat = categories?.find((c) => c.id === form.category_id);
|
||||||
const catDisplay = currentCat ? currentCat.name : form.category_id ? "加载中..." : "未分类";
|
const catDisplay = currentCat
|
||||||
|
? currentCat.name
|
||||||
|
: form.category_id
|
||||||
|
? "加载中..."
|
||||||
|
: "未分类";
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
value={form.category_id || "__none__"}
|
value={form.category_id || "__none__"}
|
||||||
@@ -626,10 +586,14 @@ export default function CreatePage() {
|
|||||||
当前类型:<strong>{appTypeLabels[form.app_type]}</strong>
|
当前类型:<strong>{appTypeLabels[form.app_type]}</strong>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{form.app_type === "chatbot" && "多轮对话,支持知识库检索(RAG)、上下文记忆。适合:政策问答、咨询服务。"}
|
{form.app_type === "chatbot" &&
|
||||||
{form.app_type === "agent" && "可调用工具和数据源,支持多步推理和报告生成。适合:数据分析、综合研判。"}
|
"多轮对话,支持知识库检索(RAG)、上下文记忆。适合:政策问答、咨询服务。"}
|
||||||
{form.app_type === "completion" && "单次输入生成结果,无对话上下文。适合:文档摘要、翻译、格式转换。"}
|
{form.app_type === "agent" &&
|
||||||
{form.app_type === "workflow" && "按预定流程分步处理,用户依次输入。适合:项目评估、政策分析。"}
|
"可调用工具和数据源,支持多步推理和报告生成。适合:数据分析、综合研判。"}
|
||||||
|
{form.app_type === "completion" &&
|
||||||
|
"单次输入生成结果,无对话上下文。适合:文档摘要、翻译、格式转换。"}
|
||||||
|
{form.app_type === "workflow" &&
|
||||||
|
"按预定流程分步处理,用户依次输入。适合:项目评估、政策分析。"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -639,17 +603,15 @@ export default function CreatePage() {
|
|||||||
</p>
|
</p>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={form.system_prompt}
|
value={form.system_prompt}
|
||||||
onChange={(e) =>
|
onChange={(e) => setForm({ ...form, system_prompt: e.target.value })}
|
||||||
setForm({ ...form, system_prompt: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder={
|
placeholder={
|
||||||
form.app_type === "chatbot"
|
form.app_type === "chatbot"
|
||||||
? "你是一位AI助手,专业、准确地解答相关问题..."
|
? "你是一位AI助手,专业、准确地解答相关问题..."
|
||||||
: form.app_type === "agent"
|
: form.app_type === "agent"
|
||||||
? "你是一位智能助手,可以调用工具完成复杂任务..."
|
? "你是一位智能助手,可以调用工具完成复杂任务..."
|
||||||
: form.app_type === "completion"
|
: form.app_type === "completion"
|
||||||
? "根据用户输入的主题,生成一份规范的公文..."
|
? "根据用户输入的主题,生成一份规范的公文..."
|
||||||
: "按照以下步骤处理用户的请求..."
|
: "按照以下步骤处理用户的请求..."
|
||||||
}
|
}
|
||||||
rows={8}
|
rows={8}
|
||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
@@ -660,9 +622,7 @@ export default function CreatePage() {
|
|||||||
<Label>欢迎消息</Label>
|
<Label>欢迎消息</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={form.welcome_message}
|
value={form.welcome_message}
|
||||||
onChange={(e) =>
|
onChange={(e) => setForm({ ...form, welcome_message: e.target.value })}
|
||||||
setForm({ ...form, welcome_message: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="用户打开应用时看到的第一条消息"
|
placeholder="用户打开应用时看到的第一条消息"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
@@ -671,16 +631,12 @@ export default function CreatePage() {
|
|||||||
{(form.app_type === "chatbot" || form.app_type === "agent") && (
|
{(form.app_type === "chatbot" || form.app_type === "agent") && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>推荐问题</Label>
|
<Label>推荐问题</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">用户可一键发送的预设问题</p>
|
||||||
用户可一键发送的预设问题
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={promptInput}
|
value={promptInput}
|
||||||
onChange={(e) => setPromptInput(e.target.value)}
|
onChange={(e) => setPromptInput(e.target.value)}
|
||||||
onKeyDown={(e) =>
|
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addPrompt())}
|
||||||
e.key === "Enter" && (e.preventDefault(), addPrompt())
|
|
||||||
}
|
|
||||||
placeholder="输入推荐问题,按回车添加"
|
placeholder="输入推荐问题,按回车添加"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@@ -721,8 +677,8 @@ export default function CreatePage() {
|
|||||||
const displayValue = currentKb
|
const displayValue = currentKb
|
||||||
? `${currentKb.name}(${currentKb.document_count} 篇文档)`
|
? `${currentKb.name}(${currentKb.document_count} 篇文档)`
|
||||||
: form.knowledge_base_id
|
: form.knowledge_base_id
|
||||||
? `加载中... (${form.knowledge_base_id.slice(0, 8)}...)`
|
? `加载中... (${form.knowledge_base_id.slice(0, 8)}...)`
|
||||||
: "不关联知识库";
|
: "不关联知识库";
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
value={form.knowledge_base_id || "__none__"}
|
value={form.knowledge_base_id || "__none__"}
|
||||||
@@ -759,10 +715,14 @@ export default function CreatePage() {
|
|||||||
运行配置决定应用如何处理用户请求
|
运行配置决定应用如何处理用户请求
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-blue-600/80 dark:text-blue-400/80">
|
<p className="text-xs text-blue-600/80 dark:text-blue-400/80">
|
||||||
{form.app_type === "chatbot" && "对话型应用通过知识库RAG和上下文记忆提供智能问答,可在「提示词与知识库」中配置。"}
|
{form.app_type === "chatbot" &&
|
||||||
{form.app_type === "agent" && "智能体应用通过调用工具和数据源完成复杂任务,请配置可用的工具和数据源。"}
|
"对话型应用通过知识库RAG和上下文记忆提供智能问答,可在「提示词与知识库」中配置。"}
|
||||||
{form.app_type === "completion" && "补全型应用将用户输入转换为结构化输出,请配置输入输出标签。"}
|
{form.app_type === "agent" &&
|
||||||
{form.app_type === "workflow" && "工作流应用按预定流程分步处理,请配置处理步骤和参数。"}
|
"智能体应用通过调用工具和数据源完成复杂任务,请配置可用的工具和数据源。"}
|
||||||
|
{form.app_type === "completion" &&
|
||||||
|
"补全型应用将用户输入转换为结构化输出,请配置输入输出标签。"}
|
||||||
|
{form.app_type === "workflow" &&
|
||||||
|
"工作流应用按预定流程分步处理,请配置处理步骤和参数。"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -774,7 +734,11 @@ export default function CreatePage() {
|
|||||||
{
|
{
|
||||||
group: "经济分析类",
|
group: "经济分析类",
|
||||||
tools: [
|
tools: [
|
||||||
{ id: "数据检索", label: "数据检索", desc: "查询区县经济指标等结构化数据" },
|
{
|
||||||
|
id: "数据检索",
|
||||||
|
label: "数据检索",
|
||||||
|
desc: "查询区县经济指标等结构化数据",
|
||||||
|
},
|
||||||
{ id: "趋势分析", label: "趋势分析", desc: "分析多年数据变化趋势" },
|
{ id: "趋势分析", label: "趋势分析", desc: "分析多年数据变化趋势" },
|
||||||
{ id: "对比分析", label: "对比分析", desc: "多区县/多维度横向对比" },
|
{ id: "对比分析", label: "对比分析", desc: "多区县/多维度横向对比" },
|
||||||
{ id: "图表生成", label: "图表生成", desc: "生成数据可视化图表" },
|
{ id: "图表生成", label: "图表生成", desc: "生成数据可视化图表" },
|
||||||
@@ -792,7 +756,11 @@ export default function CreatePage() {
|
|||||||
{
|
{
|
||||||
group: "通用工具",
|
group: "通用工具",
|
||||||
tools: [
|
tools: [
|
||||||
{ id: "政策检索", label: "政策法规检索", desc: "全文检索国家和地方政策法规" },
|
{
|
||||||
|
id: "政策检索",
|
||||||
|
label: "政策法规检索",
|
||||||
|
desc: "全文检索国家和地方政策法规",
|
||||||
|
},
|
||||||
{ id: "公文生成", label: "公文生成", desc: "按公文模板生成规范公文" },
|
{ id: "公文生成", label: "公文生成", desc: "按公文模板生成规范公文" },
|
||||||
{ id: "报告生成", label: "报告生成", desc: "生成综合分析报告" },
|
{ id: "报告生成", label: "报告生成", desc: "生成综合分析报告" },
|
||||||
{ id: "报告汇总", label: "报告汇总", desc: "汇总多份子报告为总报告" },
|
{ id: "报告汇总", label: "报告汇总", desc: "汇总多份子报告为总报告" },
|
||||||
@@ -804,7 +772,11 @@ export default function CreatePage() {
|
|||||||
{
|
{
|
||||||
group: "经济数据",
|
group: "经济数据",
|
||||||
sources: [
|
sources: [
|
||||||
{ id: "区域经济数据", label: "区域经济数据", desc: "各区县GDP、财政收入等指标" },
|
{
|
||||||
|
id: "区域经济数据",
|
||||||
|
label: "区域经济数据",
|
||||||
|
desc: "各区县GDP、财政收入等指标",
|
||||||
|
},
|
||||||
{ id: "统计年鉴", label: "统计年鉴", desc: "年度统计数据汇总" },
|
{ id: "统计年鉴", label: "统计年鉴", desc: "年度统计数据汇总" },
|
||||||
{ id: "政府工作报告", label: "政府工作报告", desc: "年度政府工作报告" },
|
{ id: "政府工作报告", label: "政府工作报告", desc: "年度政府工作报告" },
|
||||||
],
|
],
|
||||||
@@ -813,7 +785,11 @@ export default function CreatePage() {
|
|||||||
group: "干部管理",
|
group: "干部管理",
|
||||||
sources: [
|
sources: [
|
||||||
{ id: "干部信息库", label: "干部信息库", desc: "干部基本信息和履历" },
|
{ id: "干部信息库", label: "干部信息库", desc: "干部基本信息和履历" },
|
||||||
{ id: "绩效考核数据", label: "绩效考核数据", desc: "年度绩效考核评分数据" },
|
{
|
||||||
|
id: "绩效考核数据",
|
||||||
|
label: "绩效考核数据",
|
||||||
|
desc: "年度绩效考核评分数据",
|
||||||
|
},
|
||||||
{ id: "民主测评数据", label: "民主测评数据", desc: "民主测评投票结果" },
|
{ id: "民主测评数据", label: "民主测评数据", desc: "民主测评投票结果" },
|
||||||
{ id: "培训记录", label: "培训记录", desc: "干部教育培训记录" },
|
{ id: "培训记录", label: "培训记录", desc: "干部教育培训记录" },
|
||||||
],
|
],
|
||||||
@@ -840,11 +816,15 @@ export default function CreatePage() {
|
|||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>可调用工具</Label>
|
<Label>可调用工具</Label>
|
||||||
<p className="text-xs text-muted-foreground">选择应用可以使用的工具能力,不同类型应用应选择对应领域的工具</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
选择应用可以使用的工具能力,不同类型应用应选择对应领域的工具
|
||||||
|
</p>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{toolGroups.map((group) => (
|
{toolGroups.map((group) => (
|
||||||
<div key={group.group}>
|
<div key={group.group}>
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 pl-0.5">{group.group}</p>
|
<p className="text-xs font-medium text-muted-foreground mb-1.5 pl-0.5">
|
||||||
|
{group.group}
|
||||||
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{group.tools.map((tool) => (
|
{group.tools.map((tool) => (
|
||||||
<label
|
<label
|
||||||
@@ -868,7 +848,9 @@ export default function CreatePage() {
|
|||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium">{tool.label}</div>
|
<div className="text-sm font-medium">{tool.label}</div>
|
||||||
<div className="text-xs text-muted-foreground">{tool.desc}</div>
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{tool.desc}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
@@ -880,11 +862,15 @@ export default function CreatePage() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>数据源</Label>
|
<Label>数据源</Label>
|
||||||
<p className="text-xs text-muted-foreground">选择应用可以访问的数据源,不同应用使用不同领域的数据</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
选择应用可以访问的数据源,不同应用使用不同领域的数据
|
||||||
|
</p>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{dataSourceGroups.map((group) => (
|
{dataSourceGroups.map((group) => (
|
||||||
<div key={group.group}>
|
<div key={group.group}>
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 pl-0.5">{group.group}</p>
|
<p className="text-xs font-medium text-muted-foreground mb-1.5 pl-0.5">
|
||||||
|
{group.group}
|
||||||
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{group.sources.map((ds) => (
|
{group.sources.map((ds) => (
|
||||||
<label
|
<label
|
||||||
@@ -908,7 +894,9 @@ export default function CreatePage() {
|
|||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium">{ds.label}</div>
|
<div className="text-sm font-medium">{ds.label}</div>
|
||||||
<div className="text-xs text-muted-foreground">{ds.desc}</div>
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{ds.desc}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
@@ -920,7 +908,9 @@ export default function CreatePage() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>报告模板集</Label>
|
<Label>报告模板集</Label>
|
||||||
<p className="text-xs text-muted-foreground">选择应用使用的分析报告模板</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
选择应用使用的分析报告模板
|
||||||
|
</p>
|
||||||
<Select
|
<Select
|
||||||
value={form.template_set || "__none__"}
|
value={form.template_set || "__none__"}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
@@ -928,12 +918,18 @@ export default function CreatePage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<span>{form.template_set ? templateLabels[form.template_set] || form.template_set : "选择模板集"}</span>
|
<span>
|
||||||
|
{form.template_set
|
||||||
|
? templateLabels[form.template_set] || form.template_set
|
||||||
|
: "选择模板集"}
|
||||||
|
</span>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__none__">不使用模板</SelectItem>
|
<SelectItem value="__none__">不使用模板</SelectItem>
|
||||||
{Object.entries(templateLabels).map(([k, v]) => (
|
{Object.entries(templateLabels).map(([k, v]) => (
|
||||||
<SelectItem key={k} value={k}>{v}</SelectItem>
|
<SelectItem key={k} value={k}>
|
||||||
|
{v}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -991,7 +987,9 @@ export default function CreatePage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Label>输出格式模板</Label>
|
<Label>输出格式模板</Label>
|
||||||
<p className="text-xs text-muted-foreground">用户可选择不同的输出格式,系统按模板规范生成内容</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
用户可选择不同的输出格式,系统按模板规范生成内容
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1065,7 +1063,9 @@ export default function CreatePage() {
|
|||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground mb-1">内容章节(逗号分隔)</p>
|
<p className="text-xs text-muted-foreground mb-1">
|
||||||
|
内容章节(逗号分隔)
|
||||||
|
</p>
|
||||||
<Input
|
<Input
|
||||||
value={tpl.sections.join("、")}
|
value={tpl.sections.join("、")}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -1113,10 +1113,14 @@ export default function CreatePage() {
|
|||||||
<>
|
<>
|
||||||
<div className="rounded-lg bg-muted/40 p-3 mb-2">
|
<div className="rounded-lg bg-muted/40 p-3 mb-2">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{form.app_type === "chatbot" && "对话型推荐 通义千问-Plus(16K输出,支持知识库RAG)或 通义千问-Max(高质量推理)"}
|
{form.app_type === "chatbot" &&
|
||||||
{form.app_type === "agent" && "智能体型推荐 通义千问-Plus(16K输出,支持长报告生成)"}
|
"对话型推荐 通义千问-Plus(16K输出,支持知识库RAG)或 通义千问-Max(高质量推理)"}
|
||||||
{form.app_type === "completion" && "补全型推荐 通义千问-Turbo(快速响应)或 通义千问-Plus(高质量输出)"}
|
{form.app_type === "agent" &&
|
||||||
{form.app_type === "workflow" && "工作流型推荐 通义千问-Max(精确推理)或 通义千问-Plus(详细输出)"}
|
"智能体型推荐 通义千问-Plus(16K输出,支持长报告生成)"}
|
||||||
|
{form.app_type === "completion" &&
|
||||||
|
"补全型推荐 通义千问-Turbo(快速响应)或 通义千问-Plus(高质量输出)"}
|
||||||
|
{form.app_type === "workflow" &&
|
||||||
|
"工作流型推荐 通义千问-Max(精确推理)或 通义千问-Plus(详细输出)"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1136,12 +1140,18 @@ export default function CreatePage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<span>{form.model ? (modelLabels[form.model] || form.model) : "系统默认(通义千问-Plus)"}</span>
|
<span>
|
||||||
|
{form.model
|
||||||
|
? modelLabels[form.model] || form.model
|
||||||
|
: "系统默认(通义千问-Plus)"}
|
||||||
|
</span>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__default__">系统默认(通义千问-Plus)</SelectItem>
|
<SelectItem value="__default__">系统默认(通义千问-Plus)</SelectItem>
|
||||||
{Object.entries(modelLabels).map(([k, v]) => (
|
{Object.entries(modelLabels).map(([k, v]) => (
|
||||||
<SelectItem key={k} value={k}>{v}</SelectItem>
|
<SelectItem key={k} value={k}>
|
||||||
|
{v}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -1175,9 +1185,7 @@ export default function CreatePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>最大输出长度</Label>
|
<Label>最大输出长度</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">单次回复的最大字符数(Token数)</p>
|
||||||
单次回复的最大字符数(Token数)
|
|
||||||
</p>
|
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={form.max_tokens}
|
value={form.max_tokens}
|
||||||
@@ -1199,15 +1207,8 @@ export default function CreatePage() {
|
|||||||
<Button variant="outline" onClick={closeForm}>
|
<Button variant="outline" onClick={closeForm}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={handleSave} disabled={!form.name.trim() || isPending}>
|
||||||
onClick={handleSave}
|
{isPending ? "保存中..." : editingId ? "保存修改" : "创建应用"}
|
||||||
disabled={!form.name.trim() || isPending}
|
|
||||||
>
|
|
||||||
{isPending
|
|
||||||
? "保存中..."
|
|
||||||
: editingId
|
|
||||||
? "保存修改"
|
|
||||||
: "创建应用"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -12,15 +12,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import {
|
import { BookOpen, Upload, FileText, Trash2, Plus, Database, Search } from "lucide-react";
|
||||||
BookOpen,
|
|
||||||
Upload,
|
|
||||||
FileText,
|
|
||||||
Trash2,
|
|
||||||
Plus,
|
|
||||||
Database,
|
|
||||||
Search,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
interface KnowledgeBase {
|
interface KnowledgeBase {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -64,9 +56,12 @@ function getStatusBadge(status: string) {
|
|||||||
|
|
||||||
function getVisibilityLabel(v: string) {
|
function getVisibilityLabel(v: string) {
|
||||||
switch (v) {
|
switch (v) {
|
||||||
case "public": return "全单位";
|
case "public":
|
||||||
case "department": return "本科室";
|
return "全单位";
|
||||||
default: return "私有";
|
case "department":
|
||||||
|
return "本科室";
|
||||||
|
default:
|
||||||
|
return "私有";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,8 +113,8 @@ export default function KnowledgePage() {
|
|||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
const res = await fetch(`/api/v1/knowledge/${selectedKB!.id}/documents`, {
|
const res = await fetch(`/api/v1/knowledge/${selectedKB!.id}/documents`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
|
||||||
body: formData,
|
body: formData,
|
||||||
|
credentials: "include",
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("上传失败");
|
if (!res.ok) throw new Error("上传失败");
|
||||||
return res.json();
|
return res.json();
|
||||||
@@ -147,16 +142,19 @@ export default function KnowledgePage() {
|
|||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const onFileChange = useCallback(
|
||||||
const file = e.target.files?.[0];
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (file) {
|
const file = e.target.files?.[0];
|
||||||
uploadDoc.mutate(file);
|
if (file) {
|
||||||
e.target.value = "";
|
uploadDoc.mutate(file);
|
||||||
}
|
e.target.value = "";
|
||||||
}, [uploadDoc]);
|
}
|
||||||
|
},
|
||||||
|
[uploadDoc],
|
||||||
|
);
|
||||||
|
|
||||||
const filteredKBs = knowledgeBases?.filter(
|
const filteredKBs = knowledgeBases?.filter(
|
||||||
(kb) => !searchTerm || kb.name.includes(searchTerm) || kb.description?.includes(searchTerm)
|
(kb) => !searchTerm || kb.name.includes(searchTerm) || kb.description?.includes(searchTerm),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -229,7 +227,9 @@ export default function KnowledgePage() {
|
|||||||
<BookOpen className="h-4 w-4 text-blue-600" />
|
<BookOpen className="h-4 w-4 text-blue-600" />
|
||||||
{kb.name}
|
{kb.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge variant="secondary" className="text-xs">{kb.document_count} 文档</Badge>
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{kb.document_count} 文档
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -238,7 +238,9 @@ export default function KnowledgePage() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline" className="text-xs">{getVisibilityLabel(kb.visibility)}</Badge>
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{getVisibilityLabel(kb.visibility)}
|
||||||
|
</Badge>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{new Date(kb.updated_at).toLocaleDateString("zh-CN")}
|
{new Date(kb.updated_at).toLocaleDateString("zh-CN")}
|
||||||
</span>
|
</span>
|
||||||
@@ -275,7 +277,9 @@ export default function KnowledgePage() {
|
|||||||
<p className="text-sm text-muted-foreground mt-1">{selectedKB.description}</p>
|
<p className="text-sm text-muted-foreground mt-1">{selectedKB.description}</p>
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<Badge variant="outline">{getVisibilityLabel(selectedKB.visibility)}</Badge>
|
<Badge variant="outline">{getVisibilityLabel(selectedKB.visibility)}</Badge>
|
||||||
<span className="text-xs text-muted-foreground">{selectedKB.document_count} 个文档</span>
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{selectedKB.document_count} 个文档
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -286,7 +290,11 @@ export default function KnowledgePage() {
|
|||||||
accept=".txt,.md,.pdf,.docx,.csv,.xlsx"
|
accept=".txt,.md,.pdf,.docx,.csv,.xlsx"
|
||||||
onChange={onFileChange}
|
onChange={onFileChange}
|
||||||
/>
|
/>
|
||||||
<Button onClick={handleFileUpload} disabled={uploadDoc.isPending} className="gap-2">
|
<Button
|
||||||
|
onClick={handleFileUpload}
|
||||||
|
disabled={uploadDoc.isPending}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
<Upload className="h-4 w-4" />
|
<Upload className="h-4 w-4" />
|
||||||
{uploadDoc.isPending ? "上传中..." : "上传文档"}
|
{uploadDoc.isPending ? "上传中..." : "上传文档"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -320,10 +328,16 @@ export default function KnowledgePage() {
|
|||||||
<FileText className="h-4 w-4 text-blue-500 shrink-0" />
|
<FileText className="h-4 w-4 text-blue-500 shrink-0" />
|
||||||
<span className="truncate max-w-[200px]">{doc.filename}</span>
|
<span className="truncate max-w-[200px]">{doc.filename}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-muted-foreground uppercase text-xs">{doc.file_type || "-"}</td>
|
<td className="p-3 text-muted-foreground uppercase text-xs">
|
||||||
<td className="p-3 text-muted-foreground">{formatFileSize(doc.file_size)}</td>
|
{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">{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 text-muted-foreground text-xs">
|
||||||
|
{new Date(doc.created_at).toLocaleString("zh-CN")}
|
||||||
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -347,7 +361,7 @@ export default function KnowledgePage() {
|
|||||||
<CardContent className="py-20 text-center text-muted-foreground">
|
<CardContent className="py-20 text-center text-muted-foreground">
|
||||||
<Database className="h-12 w-12 mx-auto text-muted-foreground/30 mb-4" />
|
<Database className="h-12 w-12 mx-auto text-muted-foreground/30 mb-4" />
|
||||||
<p className="text-sm">请从左侧选择一个知识库查看文档</p>
|
<p className="text-sm">请从左侧选择一个知识库查看文档</p>
|
||||||
<p className="text-xs mt-1">或点击"新建知识库"创建政策法规资源库</p>
|
<p className="text-xs mt-1">或点击「新建知识库」创建政策法规资源库</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -403,7 +417,9 @@ export default function KnowledgePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button variant="outline" onClick={() => setShowCreate(false)}>取消</Button>
|
<Button variant="outline" onClick={() => setShowCreate(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => createKB.mutate()}
|
onClick={() => createKB.mutate()}
|
||||||
disabled={!form.name.trim() || createKB.isPending}
|
disabled={!form.name.trim() || createKB.isPending}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -13,25 +14,11 @@ import { Separator } from "@/components/ui/separator";
|
|||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import { ArrowLeft, Heart, MessageSquare, Star, Users, Clock, Play } from "lucide-react";
|
||||||
ArrowLeft,
|
|
||||||
Heart,
|
|
||||||
MessageSquare,
|
|
||||||
Star,
|
|
||||||
Users,
|
|
||||||
Clock,
|
|
||||||
Play,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { getCategoryIcon, getCategoryColor } from "@/lib/category-config";
|
import { getCategoryIcon, getCategoryColor } from "@/lib/category-config";
|
||||||
import { getAppTypeConfig } from "@/lib/app-type-config";
|
import { getAppTypeConfig } from "@/lib/app-type-config";
|
||||||
|
|
||||||
function StarRatingDisplay({
|
function StarRatingDisplay({ rating, count }: { rating: number; count: number }) {
|
||||||
rating,
|
|
||||||
count,
|
|
||||||
}: {
|
|
||||||
rating: number;
|
|
||||||
count: number;
|
|
||||||
}) {
|
|
||||||
const stars = Math.round(rating);
|
const stars = Math.round(rating);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -39,18 +26,12 @@ function StarRatingDisplay({
|
|||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<Star
|
<Star
|
||||||
key={i}
|
key={i}
|
||||||
className={`h-4 w-4 ${
|
className={`h-4 w-4 ${i < stars ? "fill-amber-400 text-amber-400" : "text-gray-300"}`}
|
||||||
i < stars
|
|
||||||
? "fill-amber-400 text-amber-400"
|
|
||||||
: "text-gray-300"
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium">{rating.toFixed(1)}</span>
|
<span className="text-sm font-medium">{rating.toFixed(1)}</span>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">({count} 评分)</span>
|
||||||
({count} 评分)
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -61,8 +42,7 @@ function RatingInput({ appId }: { appId: string }) {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const rate = useMutation({
|
const rate = useMutation({
|
||||||
mutationFn: (score: number) =>
|
mutationFn: (score: number) => api.post(`/api/v1/apps/${appId}/rating`, { score }),
|
||||||
api.post(`/api/v1/apps/${appId}/rating`, { score }),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["appDetail"] });
|
queryClient.invalidateQueries({ queryKey: ["appDetail"] });
|
||||||
toast.success("评分成功");
|
toast.success("评分成功");
|
||||||
@@ -94,9 +74,7 @@ function RatingInput({ appId }: { appId: string }) {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{selected > 0 && (
|
{selected > 0 && (
|
||||||
<span className="text-sm text-muted-foreground ml-1">
|
<span className="text-sm text-muted-foreground ml-1">已评 {selected} 分</span>
|
||||||
已评 {selected} 分
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -144,26 +122,20 @@ export default function AppDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-7xl px-6 lg:px-8 py-20 text-center">
|
<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>
|
<p className="text-lg text-muted-foreground">应用不存在</p>
|
||||||
<Button
|
<Button variant="ghost" className="mt-4" onClick={() => router.push("/store")}>
|
||||||
variant="ghost"
|
|
||||||
className="mt-4"
|
|
||||||
onClick={() => router.push("/store")}
|
|
||||||
>
|
|
||||||
返回应用中心
|
返回应用中心
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const CategoryIcon = getCategoryIcon(app.category_slug);
|
const CategoryIconComponent = getCategoryIcon(app.category_slug);
|
||||||
const categoryColor = getCategoryColor(app.category_slug);
|
const categoryColor = getCategoryColor(app.category_slug);
|
||||||
const isFavorited = (app as any).is_favorited;
|
const isFavorited = app.is_favorited;
|
||||||
const typeConfig = getAppTypeConfig(app.dify_app_type);
|
const typeConfig = getAppTypeConfig(app.dify_app_type);
|
||||||
const TypeIcon = typeConfig.icon;
|
const TypeIconComponent = typeConfig.icon;
|
||||||
|
|
||||||
const longDesc = app.long_description
|
const longDesc = app.long_description ? app.long_description.replace(/\\n/g, "\n") : null;
|
||||||
? app.long_description.replace(/\\n/g, "\n")
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-7xl px-6 lg:px-8 py-8">
|
<div className="mx-auto w-full max-w-7xl px-6 lg:px-8 py-8">
|
||||||
@@ -183,18 +155,20 @@ export default function AppDetailPage() {
|
|||||||
<div
|
<div
|
||||||
className={`flex h-16 w-16 items-center justify-center rounded-2xl ${categoryColor} shrink-0`}
|
className={`flex h-16 w-16 items-center justify-center rounded-2xl ${categoryColor} shrink-0`}
|
||||||
>
|
>
|
||||||
<CategoryIcon className="h-8 w-8" />
|
{CategoryIconComponent
|
||||||
|
? React.createElement(CategoryIconComponent, { className: "h-8 w-8" })
|
||||||
|
: null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h1 className="text-2xl font-bold">{app.name}</h1>
|
<h1 className="text-2xl font-bold">{app.name}</h1>
|
||||||
<p className="text-muted-foreground mt-1.5 leading-relaxed">
|
<p className="text-muted-foreground mt-1.5 leading-relaxed">{app.description}</p>
|
||||||
{app.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3 mt-3">
|
<div className="flex flex-wrap items-center gap-3 mt-3">
|
||||||
<Badge variant="secondary">{app.category_name || "其他"}</Badge>
|
<Badge variant="secondary">{app.category_name || "其他"}</Badge>
|
||||||
<Badge className={`${typeConfig.badgeColor} gap-1`}>
|
<Badge className={`${typeConfig.badgeColor} gap-1`}>
|
||||||
<TypeIcon className="h-3 w-3" />
|
{TypeIconComponent
|
||||||
|
? React.createElement(TypeIconComponent, { className: "h-3 w-3" })
|
||||||
|
: null}
|
||||||
{typeConfig.label}
|
{typeConfig.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
@@ -209,18 +183,14 @@ export default function AppDetailPage() {
|
|||||||
)}
|
)}
|
||||||
{app.published_at && (
|
{app.published_at && (
|
||||||
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
<Clock className="h-3.5 w-3.5" />
|
<Clock className="h-3.5 w-3.5" />v{app.version}
|
||||||
v{app.version}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{app.avg_rating > 0 && (
|
{app.avg_rating > 0 && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<StarRatingDisplay
|
<StarRatingDisplay rating={app.avg_rating} count={app.rating_count} />
|
||||||
rating={app.avg_rating}
|
|
||||||
count={app.rating_count}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -241,11 +211,7 @@ export default function AppDetailPage() {
|
|||||||
className="gap-2"
|
className="gap-2"
|
||||||
onClick={() => toggleFav.mutate(!!isFavorited)}
|
onClick={() => toggleFav.mutate(!!isFavorited)}
|
||||||
>
|
>
|
||||||
<Heart
|
<Heart className={`h-4 w-4 ${isFavorited ? "fill-red-500 text-red-500" : ""}`} />
|
||||||
className={`h-4 w-4 ${
|
|
||||||
isFavorited ? "fill-red-500 text-red-500" : ""
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{isFavorited ? "已收藏" : "收藏"}
|
{isFavorited ? "已收藏" : "收藏"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -261,7 +227,8 @@ export default function AppDetailPage() {
|
|||||||
<span className="inline-block w-1 h-5 bg-blue-800 rounded-full" />
|
<span className="inline-block w-1 h-5 bg-blue-800 rounded-full" />
|
||||||
详细介绍
|
详细介绍
|
||||||
</h2>
|
</h2>
|
||||||
<div className="prose prose-sm max-w-none dark:prose-invert
|
<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-p:text-muted-foreground prose-p:leading-relaxed prose-p:my-2
|
||||||
prose-headings:text-foreground prose-headings:font-semibold
|
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]:text-base [&_h2]:mt-5 [&_h2]:mb-2 [&_h2]:flex [&_h2]:items-center [&_h2]:gap-2
|
||||||
@@ -277,10 +244,9 @@ export default function AppDetailPage() {
|
|||||||
[&_li]:border [&_li]:border-blue-100 [&_li]:dark:border-blue-900/30
|
[&_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
|
[&_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
|
[&_strong]:text-blue-800 [&_strong]:dark:text-blue-300
|
||||||
">
|
"
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
>
|
||||||
{longDesc}
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{longDesc}</ReactMarkdown>
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -28,12 +28,13 @@ export default function CategoryPage() {
|
|||||||
const orgId = user?.org_id || "";
|
const orgId = user?.org_id || "";
|
||||||
const orgParam = orgId ? `&org_id=${orgId}` : "";
|
const orgParam = orgId ? `&org_id=${orgId}` : "";
|
||||||
const [sort, setSort] = useState<SortOption>(
|
const [sort, setSort] = useState<SortOption>(
|
||||||
(searchParams.get("sort") as SortOption) || "popular"
|
(searchParams.get("sort") as SortOption) || "popular",
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: categories } = useQuery({
|
const { data: categories } = useQuery({
|
||||||
queryKey: ["categories", orgId],
|
queryKey: ["categories", orgId],
|
||||||
queryFn: () => api.get<Category[]>(`/api/v1/store/categories?${orgId ? `org_id=${orgId}` : ""}`),
|
queryFn: () =>
|
||||||
|
api.get<Category[]>(`/api/v1/store/categories?${orgId ? `org_id=${orgId}` : ""}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentCategory = categories?.find((c) => c.slug === slug);
|
const currentCategory = categories?.find((c) => c.slug === slug);
|
||||||
@@ -42,7 +43,7 @@ export default function CategoryPage() {
|
|||||||
queryKey: ["category-apps", slug, sort, orgId],
|
queryKey: ["category-apps", slug, sort, orgId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get<{ items: App[] }>(
|
api.get<{ items: App[] }>(
|
||||||
`/api/v1/store/apps?category=${encodeURIComponent(slug)}&sort=${sort}&page_size=50${orgParam}`
|
`/api/v1/store/apps?category=${encodeURIComponent(slug)}&sort=${sort}&page_size=50${orgParam}`,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,13 +58,9 @@ export default function CategoryPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">
|
<h1 className="text-2xl font-bold">{currentCategory?.name || slug}</h1>
|
||||||
{currentCategory?.name || slug}
|
|
||||||
</h1>
|
|
||||||
{currentCategory?.description && (
|
{currentCategory?.description && (
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
<p className="text-sm text-muted-foreground mt-0.5">{currentCategory.description}</p>
|
||||||
{currentCategory.description}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -76,16 +76,15 @@ export default function StorePage() {
|
|||||||
|
|
||||||
const { data: searchResults, isLoading: searchLoading } = useQuery({
|
const { data: searchResults, isLoading: searchLoading } = useQuery({
|
||||||
queryKey: ["search", query, orgId],
|
queryKey: ["search", query, orgId],
|
||||||
queryFn: () => api.get<{ items: App[] }>(`/api/v1/store/apps?q=${encodeURIComponent(query)}&${orgParam}`),
|
queryFn: () =>
|
||||||
|
api.get<{ items: App[] }>(`/api/v1/store/apps?q=${encodeURIComponent(query)}&${orgParam}`),
|
||||||
enabled: !!query,
|
enabled: !!query,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-7xl px-3 md:px-6 lg:px-8 py-4 md:py-6">
|
<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">
|
<h1 className="text-xl font-bold mb-4">搜索结果:“{query}”</h1>
|
||||||
搜索结果:“{query}”
|
|
||||||
</h1>
|
|
||||||
{searchLoading ? (
|
{searchLoading ? (
|
||||||
<AppGridSkeleton count={8} />
|
<AppGridSkeleton count={8} />
|
||||||
) : searchResults?.items?.length ? (
|
) : searchResults?.items?.length ? (
|
||||||
@@ -95,9 +94,7 @@ export default function StorePage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
<div className="text-center py-12 text-muted-foreground">未找到相关政务应用</div>
|
||||||
未找到相关政务应用
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -124,7 +121,9 @@ export default function StorePage() {
|
|||||||
<SectionHeader title="应用分类" icon={LayoutGrid} />
|
<SectionHeader title="应用分类" icon={LayoutGrid} />
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Link href="/store">
|
<Link href="/store">
|
||||||
<Badge variant="default" className="cursor-pointer">全部</Badge>
|
<Badge variant="default" className="cursor-pointer">
|
||||||
|
全部
|
||||||
|
</Badge>
|
||||||
</Link>
|
</Link>
|
||||||
{categories?.map((cat) => (
|
{categories?.map((cat) => (
|
||||||
<Link key={cat.id} href={`/store/category/${cat.slug}`}>
|
<Link key={cat.id} href={`/store/category/${cat.slug}`}>
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AlertCircle, RefreshCw, Home } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function GlobalError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN" suppressHydrationWarning>
|
||||||
|
<body>
|
||||||
|
<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-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">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
<Link href="/store">
|
||||||
|
<Button className="gap-2">
|
||||||
|
<Home className="h-4 w-4" />
|
||||||
|
返回首页
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FileQuestion, Home, Search } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function GlobalNotFound() {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN" suppressHydrationWarning>
|
||||||
|
<body>
|
||||||
|
<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>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Link href="/store">
|
||||||
|
<Button className="gap-2">
|
||||||
|
<Home className="h-4 w-4" />
|
||||||
|
返回应用中心
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button variant="outline" className="gap-2" onClick={() => window.history.back()}>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
返回上一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Providers } from "@/components/providers";
|
import { Providers } from "@/components/providers";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { ErrorBoundary } from "@/components/error-boundary";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
// 使用系统字体,避免构建时联网下载 Google 字体(内网/离线环境)
|
|
||||||
const geistSans = {
|
const geistSans = {
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
};
|
};
|
||||||
@@ -23,12 +23,11 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html lang="zh-CN" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
|
||||||
lang="zh-CN"
|
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
|
||||||
>
|
|
||||||
<body className="min-h-full flex flex-col">
|
<body className="min-h-full flex flex-col">
|
||||||
<Providers>{children}</Providers>
|
<ErrorBoundary>
|
||||||
|
<Providers>{children}</Providers>
|
||||||
|
</ErrorBoundary>
|
||||||
<Toaster position="top-center" richColors />
|
<Toaster position="top-center" richColors />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Star, Archive } from "lucide-react";
|
import { Star, Archive } from "lucide-react";
|
||||||
import { Pagination } from "@/components/ui/pagination";
|
import { Pagination } from "@/components/ui/pagination";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
draft: "草稿",
|
draft: "草稿",
|
||||||
@@ -99,7 +100,13 @@ export default function PlatformAppsPage() {
|
|||||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||||
<Select value={orgFilter} onValueChange={resetOrg}>
|
<Select value={orgFilter} onValueChange={resetOrg}>
|
||||||
<SelectTrigger className="w-40">
|
<SelectTrigger className="w-40">
|
||||||
<span>{orgFilter === "all" ? "全部机构" : (orgs?.find((o) => o.id === orgFilter)?.short_name || orgs?.find((o) => o.id === orgFilter)?.name || orgFilter)}</span>
|
<span>
|
||||||
|
{orgFilter === "all"
|
||||||
|
? "全部机构"
|
||||||
|
: orgs?.find((o) => o.id === orgFilter)?.short_name ||
|
||||||
|
orgs?.find((o) => o.id === orgFilter)?.name ||
|
||||||
|
orgFilter}
|
||||||
|
</span>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">全部机构</SelectItem>
|
<SelectItem value="all">全部机构</SelectItem>
|
||||||
@@ -112,7 +119,9 @@ export default function PlatformAppsPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
<Select value={statusFilter} onValueChange={resetStatus}>
|
<Select value={statusFilter} onValueChange={resetStatus}>
|
||||||
<SelectTrigger className="w-32">
|
<SelectTrigger className="w-32">
|
||||||
<span>{statusFilter === "all" ? "全部状态" : (statusLabels[statusFilter] || statusFilter)}</span>
|
<span>
|
||||||
|
{statusFilter === "all" ? "全部状态" : statusLabels[statusFilter] || statusFilter}
|
||||||
|
</span>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">全部状态</SelectItem>
|
<SelectItem value="all">全部状态</SelectItem>
|
||||||
@@ -132,7 +141,55 @@ export default function PlatformAppsPage() {
|
|||||||
onChange={setPage}
|
onChange={setPage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="border rounded-lg overflow-hidden">
|
{data?.items == null && !statusFilter && orgFilter === "all" ? (
|
||||||
|
<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>
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<tr key={i} className="border-t">
|
||||||
|
<td className="p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-5 w-5 rounded" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Skeleton className="h-3 w-28" />
|
||||||
|
<Skeleton className="h-2 w-40" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Skeleton className="h-5 w-16 rounded" />
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Skeleton className="h-3 w-16" />
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Skeleton className="h-5 w-16 rounded" />
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Skeleton className="h-3 w-12" />
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Skeleton className="h-6 w-14 rounded" />
|
||||||
|
<Skeleton className="h-6 w-16 rounded" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-muted/50">
|
<thead className="bg-muted/50">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -149,11 +206,17 @@ export default function PlatformAppsPage() {
|
|||||||
<tr key={app.id} className="border-t hover:bg-muted/30">
|
<tr key={app.id} className="border-t hover:bg-muted/30">
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AppIcon iconUrl={app.icon_url} size={20} className="shrink-0 text-muted-foreground" />
|
<AppIcon
|
||||||
|
iconUrl={app.icon_url}
|
||||||
|
size={20}
|
||||||
|
className="shrink-0 text-muted-foreground"
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium flex items-center gap-1">
|
<div className="font-medium flex items-center gap-1">
|
||||||
{app.name}
|
{app.name}
|
||||||
{app.is_featured && <Star className="h-3 w-3 fill-yellow-400 text-yellow-400" />}
|
{app.is_featured && (
|
||||||
|
<Star className="h-3 w-3 fill-yellow-400 text-yellow-400" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||||
{app.description}
|
{app.description}
|
||||||
@@ -206,8 +269,7 @@ export default function PlatformAppsPage() {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import api from "@/lib/api";
|
|||||||
import type { PlatformAuditLog, PlatformOrg } from "@/lib/types";
|
import type { PlatformAuditLog, PlatformOrg } from "@/lib/types";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -66,7 +67,13 @@ export default function PlatformAuditPage() {
|
|||||||
/>
|
/>
|
||||||
<Select value={orgFilter} onValueChange={resetOrg}>
|
<Select value={orgFilter} onValueChange={resetOrg}>
|
||||||
<SelectTrigger className="w-40">
|
<SelectTrigger className="w-40">
|
||||||
<span>{orgFilter === "all" ? "全部机构" : (orgs?.find((o) => o.id === orgFilter)?.short_name || orgs?.find((o) => o.id === orgFilter)?.name || orgFilter)}</span>
|
<span>
|
||||||
|
{orgFilter === "all"
|
||||||
|
? "全部机构"
|
||||||
|
: orgs?.find((o) => o.id === orgFilter)?.short_name ||
|
||||||
|
orgs?.find((o) => o.id === orgFilter)?.name ||
|
||||||
|
orgFilter}
|
||||||
|
</span>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">全部机构</SelectItem>
|
<SelectItem value="all">全部机构</SelectItem>
|
||||||
@@ -86,7 +93,46 @@ export default function PlatformAuditPage() {
|
|||||||
onChange={setPage}
|
onChange={setPage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="border rounded-lg overflow-hidden">
|
{data?.items == null && !search && orgFilter === "all" ? (
|
||||||
|
<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>
|
||||||
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
|
<tr key={i} className="border-t">
|
||||||
|
<td className="p-3">
|
||||||
|
<Skeleton className="h-3 w-32" />
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Skeleton className="h-5 w-16 rounded" />
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Skeleton className="h-8 w-24" />
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-muted/50">
|
<thead className="bg-muted/50">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -127,8 +173,7 @@ export default function PlatformAuditPage() {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,9 +98,7 @@ export default function PlatformLayout({ children }: { children: React.ReactNode
|
|||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
|
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
|
||||||
pathname === item.href
|
pathname === item.href ? "bg-amber-600 text-white" : "hover:bg-muted",
|
||||||
? "bg-amber-600 text-white"
|
|
||||||
: "hover:bg-muted",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className="h-4 w-4" />
|
<item.icon className="h-4 w-4" />
|
||||||
|
|||||||
@@ -9,12 +9,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -120,9 +115,7 @@ export default function PlatformOrgsPage() {
|
|||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">机构管理</h1>
|
<h1 className="text-2xl font-bold">机构管理</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">管理平台所有入驻机构(委办局/单位)</p>
|
||||||
管理平台所有入驻机构(委办局/单位)
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setEditing({ ...emptyForm })} className="gap-2">
|
<Button onClick={() => setEditing({ ...emptyForm })} className="gap-2">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
@@ -130,19 +123,11 @@ export default function PlatformOrgsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Pagination
|
<Pagination page={page} pageSize={PAGE_SIZE} total={orgs?.length ?? 0} onChange={setPage} />
|
||||||
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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{pagedOrgs?.map((org) => (
|
{pagedOrgs?.map((org) => (
|
||||||
<div
|
<div key={org.id} className="border rounded-lg p-4 hover:shadow-sm transition-shadow">
|
||||||
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-start justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-9 w-9 rounded-md bg-amber-50 flex items-center justify-center">
|
<div className="h-9 w-9 rounded-md bg-amber-50 flex items-center justify-center">
|
||||||
@@ -158,9 +143,7 @@ export default function PlatformOrgsPage() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{org.description && (
|
{org.description && (
|
||||||
<p className="text-xs text-muted-foreground line-clamp-2 mb-3">
|
<p className="text-xs text-muted-foreground line-clamp-2 mb-3">{org.description}</p>
|
||||||
{org.description}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground mb-3">
|
<div className="flex items-center justify-between text-xs text-muted-foreground mb-3">
|
||||||
<span>{org.user_count} 用户</span>
|
<span>{org.user_count} 用户</span>
|
||||||
@@ -274,10 +257,7 @@ export default function PlatformOrgsPage() {
|
|||||||
<Button variant="outline" onClick={() => setEditing(null)}>
|
<Button variant="outline" onClick={() => setEditing(null)}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}>
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={create.isPending || update.isPending}
|
|
||||||
>
|
|
||||||
{editing?.id ? "保存" : "创建"}
|
{editing?.id ? "保存" : "创建"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -291,7 +271,8 @@ export default function PlatformOrgsPage() {
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>确认删除机构</AlertDialogTitle>
|
<AlertDialogTitle>确认删除机构</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
将永久删除「{deleteTarget?.name}」。如果该机构下还有用户或应用,删除会被拒绝,请先迁移或停用。
|
将永久删除「{deleteTarget?.name}
|
||||||
|
」。如果该机构下还有用户或应用,删除会被拒绝,请先迁移或停用。
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|||||||
@@ -9,12 +9,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -43,7 +38,7 @@ const emptyForm: ProviderForm = {
|
|||||||
name: "",
|
name: "",
|
||||||
base_url: "",
|
base_url: "",
|
||||||
api_key: "",
|
api_key: "",
|
||||||
models: '[]',
|
models: "[]",
|
||||||
is_active: true,
|
is_active: true,
|
||||||
priority: 0,
|
priority: 0,
|
||||||
};
|
};
|
||||||
@@ -128,8 +123,11 @@ export default function PlatformProvidersPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const testConnection = useMutation({
|
const testConnection = useMutation({
|
||||||
mutationFn: (id: string) => api.post(`/api/v1/platform/providers/${id}/test`),
|
mutationFn: (id: string) =>
|
||||||
onSuccess: (data: any) => {
|
api.post<{ success: boolean; message?: string; response?: string }>(
|
||||||
|
`/api/v1/platform/providers/${id}/test`,
|
||||||
|
),
|
||||||
|
onSuccess: (data) => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
toast.success("连接测试成功", {
|
toast.success("连接测试成功", {
|
||||||
description: data.response || "Provider 响应正常",
|
description: data.response || "Provider 响应正常",
|
||||||
@@ -208,7 +206,11 @@ export default function PlatformProvidersPage() {
|
|||||||
) : (
|
) : (
|
||||||
<XCircle className="h-3.5 w-3.5 text-muted-foreground" />
|
<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"}>
|
<span
|
||||||
|
className={
|
||||||
|
p.is_active ? "text-green-700 dark:text-green-400" : "text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
{p.is_active ? "已启用" : "已停用"}
|
{p.is_active ? "已启用" : "已停用"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,7 +220,11 @@ export default function PlatformProvidersPage() {
|
|||||||
<div className="flex flex-wrap gap-1 mb-3 min-h-[1.5rem]">
|
<div className="flex flex-wrap gap-1 mb-3 min-h-[1.5rem]">
|
||||||
{Array.isArray(p.models) && p.models.length > 0 ? (
|
{Array.isArray(p.models) && p.models.length > 0 ? (
|
||||||
p.models.slice(0, 6).map((m) => (
|
p.models.slice(0, 6).map((m) => (
|
||||||
<Badge key={m.name} variant="outline" className="text-[10px] py-0 px-1.5 font-mono">
|
<Badge
|
||||||
|
key={m.name}
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] py-0 px-1.5 font-mono"
|
||||||
|
>
|
||||||
{m.display_name || m.name}
|
{m.display_name || m.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
))
|
))
|
||||||
@@ -226,7 +232,9 @@ export default function PlatformProvidersPage() {
|
|||||||
<span className="text-xs text-muted-foreground italic">暂无模型配置</span>
|
<span className="text-xs text-muted-foreground italic">暂无模型配置</span>
|
||||||
)}
|
)}
|
||||||
{Array.isArray(p.models) && p.models.length > 6 && (
|
{Array.isArray(p.models) && p.models.length > 6 && (
|
||||||
<span className="text-[10px] text-muted-foreground self-center">+{p.models.length - 6} 个</span>
|
<span className="text-[10px] text-muted-foreground self-center">
|
||||||
|
+{p.models.length - 6} 个
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
|||||||
@@ -8,21 +8,12 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
Dialog,
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Plus, Pencil, Trash2 } from "lucide-react";
|
import { Plus, Pencil, Trash2 } from "lucide-react";
|
||||||
import { Pagination } from "@/components/ui/pagination";
|
import { Pagination } from "@/components/ui/pagination";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
interface QuotaForm {
|
interface QuotaForm {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -81,9 +72,10 @@ export default function PlatformQuotasPage() {
|
|||||||
api.get<{ items: PlatformUser[] }>("/api/v1/platform/users?page=1&page_size=200"),
|
api.get<{ items: PlatformUser[] }>("/api/v1/platform/users?page=1&page_size=200"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const allModels = providersData
|
const allModels =
|
||||||
?.flatMap((p) => (Array.isArray(p.models) ? p.models : []))
|
providersData
|
||||||
.filter((m, i, arr) => arr.findIndex((x) => x.name === m.name) === i) ?? [];
|
?.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 pagedQuotas = quotas?.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||||
|
|
||||||
@@ -133,12 +125,7 @@ export default function PlatformQuotasPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Pagination
|
<Pagination page={page} pageSize={PAGE_SIZE} total={quotas?.length ?? 0} onChange={setPage} />
|
||||||
page={page}
|
|
||||||
pageSize={PAGE_SIZE}
|
|
||||||
total={quotas?.length ?? 0}
|
|
||||||
onChange={setPage}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
@@ -155,7 +142,39 @@ export default function PlatformQuotasPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{pagedQuotas?.length ? (
|
{quotas == null ? (
|
||||||
|
Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<tr key={i} className="border-t">
|
||||||
|
<td className="p-3">
|
||||||
|
<Skeleton className="h-5 w-12 rounded" />
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Skeleton className="h-3 w-12" />
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Skeleton className="h-3 w-12" />
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Skeleton className="h-3 w-12" />
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Skeleton className="h-5 w-10 rounded" />
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Skeleton className="h-6 w-12 rounded" />
|
||||||
|
<Skeleton className="h-6 w-12 rounded" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : pagedQuotas?.length ? (
|
||||||
pagedQuotas.map((q) => (
|
pagedQuotas.map((q) => (
|
||||||
<tr key={q.id} className="border-t">
|
<tr key={q.id} className="border-t">
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
@@ -236,8 +255,7 @@ export default function PlatformQuotasPage() {
|
|||||||
<Select
|
<Select
|
||||||
value={editing?.target_type || "global"}
|
value={editing?.target_type || "global"}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
v &&
|
v && setEditing({ ...editing!, target_type: v as QuotaForm["target_type"] })
|
||||||
setEditing({ ...editing!, target_type: v as QuotaForm["target_type"] })
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -260,9 +278,10 @@ export default function PlatformQuotasPage() {
|
|||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<span>
|
<span>
|
||||||
{editing?.target_id
|
{editing?.target_id
|
||||||
? (usersData?.items?.find((u) => u.id === editing.target_id)?.name +
|
? usersData?.items?.find((u) => u.id === editing.target_id)?.name +
|
||||||
" (" +
|
" (" +
|
||||||
(usersData?.items?.find((u) => u.id === editing.target_id)?.email ?? "") + ")")
|
(usersData?.items?.find((u) => u.id === editing.target_id)?.email ?? "") +
|
||||||
|
")"
|
||||||
: "请选择用户"}
|
: "请选择用户"}
|
||||||
</span>
|
</span>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -298,8 +317,8 @@ export default function PlatformQuotasPage() {
|
|||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<span>
|
<span>
|
||||||
{editing?.model_name
|
{editing?.model_name
|
||||||
? (allModels.find((m) => m.name === editing.model_name)?.display_name ||
|
? allModels.find((m) => m.name === editing.model_name)?.display_name ||
|
||||||
editing.model_name)
|
editing.model_name
|
||||||
: "所有模型"}
|
: "所有模型"}
|
||||||
</span>
|
</span>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -319,9 +338,7 @@ export default function PlatformQuotasPage() {
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={editing?.daily_token_limit || ""}
|
value={editing?.daily_token_limit || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => setEditing({ ...editing!, daily_token_limit: e.target.value })}
|
||||||
setEditing({ ...editing!, daily_token_limit: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="留空=不限"
|
placeholder="留空=不限"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -330,9 +347,7 @@ export default function PlatformQuotasPage() {
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={editing?.monthly_token_limit || ""}
|
value={editing?.monthly_token_limit || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => setEditing({ ...editing!, monthly_token_limit: e.target.value })}
|
||||||
setEditing({ ...editing!, monthly_token_limit: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="留空=不限"
|
placeholder="留空=不限"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -342,9 +357,7 @@ export default function PlatformQuotasPage() {
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={editing?.daily_request_limit || ""}
|
value={editing?.daily_request_limit || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => setEditing({ ...editing!, daily_request_limit: e.target.value })}
|
||||||
setEditing({ ...editing!, daily_request_limit: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="留空=不限"
|
placeholder="留空=不限"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -364,10 +377,7 @@ export default function PlatformQuotasPage() {
|
|||||||
<Button variant="outline" onClick={() => setEditing(null)}>
|
<Button variant="outline" onClick={() => setEditing(null)}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={() => editing && upsert.mutate(editing)} disabled={upsert.isPending}>
|
||||||
onClick={() => editing && upsert.mutate(editing)}
|
|
||||||
disabled={upsert.isPending}
|
|
||||||
>
|
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,12 +15,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import {
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Building2 } from "lucide-react";
|
import { Building2 } from "lucide-react";
|
||||||
import { Pagination } from "@/components/ui/pagination";
|
import { Pagination } from "@/components/ui/pagination";
|
||||||
@@ -120,9 +115,7 @@ export default function PlatformUsersPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h1 className="text-2xl font-bold">全局用户</h1>
|
<h1 className="text-2xl font-bold">全局用户</h1>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">共 {data?.total ?? 0} 条</span>
|
||||||
共 {data?.total ?? 0} 条
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||||
@@ -134,7 +127,13 @@ export default function PlatformUsersPage() {
|
|||||||
/>
|
/>
|
||||||
<Select value={orgFilter} onValueChange={resetOrg}>
|
<Select value={orgFilter} onValueChange={resetOrg}>
|
||||||
<SelectTrigger className="w-40">
|
<SelectTrigger className="w-40">
|
||||||
<span>{orgFilter === "all" ? "全部机构" : (orgs?.find((o) => o.id === orgFilter)?.short_name || orgs?.find((o) => o.id === orgFilter)?.name || orgFilter)}</span>
|
<span>
|
||||||
|
{orgFilter === "all"
|
||||||
|
? "全部机构"
|
||||||
|
: orgs?.find((o) => o.id === orgFilter)?.short_name ||
|
||||||
|
orgs?.find((o) => o.id === orgFilter)?.name ||
|
||||||
|
orgFilter}
|
||||||
|
</span>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">全部机构</SelectItem>
|
<SelectItem value="all">全部机构</SelectItem>
|
||||||
@@ -147,7 +146,7 @@ export default function PlatformUsersPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
<Select value={roleFilter} onValueChange={resetRole}>
|
<Select value={roleFilter} onValueChange={resetRole}>
|
||||||
<SelectTrigger className="w-36">
|
<SelectTrigger className="w-36">
|
||||||
<span>{roleFilter === "all" ? "全部角色" : (roleLabels[roleFilter] || roleFilter)}</span>
|
<span>{roleFilter === "all" ? "全部角色" : roleLabels[roleFilter] || roleFilter}</span>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">全部角色</SelectItem>
|
<SelectItem value="all">全部角色</SelectItem>
|
||||||
@@ -211,9 +210,7 @@ export default function PlatformUsersPage() {
|
|||||||
{u.status === "active" ? "正常" : "禁用"}
|
{u.status === "active" ? "正常" : "禁用"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-muted-foreground text-xs">
|
<td className="p-3 text-muted-foreground text-xs">{u.login_count} 次</td>
|
||||||
{u.login_count} 次
|
|
||||||
</td>
|
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -21,9 +22,7 @@ function StarRating({ rating }: { rating: number }) {
|
|||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<Star
|
<Star
|
||||||
key={i}
|
key={i}
|
||||||
className={`h-3 w-3 ${
|
className={`h-3 w-3 ${i < stars ? "fill-amber-400 text-amber-400" : "text-gray-300"}`}
|
||||||
i < stars ? "fill-amber-400 text-amber-400" : "text-gray-300"
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<span className="ml-0.5 text-muted-foreground">{rating.toFixed(1)}</span>
|
<span className="ml-0.5 text-muted-foreground">{rating.toFixed(1)}</span>
|
||||||
@@ -32,10 +31,10 @@ function StarRating({ rating }: { rating: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AppCard({ app }: { app: App }) {
|
export function AppCard({ app }: { app: App }) {
|
||||||
const Icon = getCategoryIcon(app.category_slug);
|
const IconComponent = getCategoryIcon(app.category_slug);
|
||||||
const colorClass = getCategoryColor(app.category_slug);
|
const colorClass = getCategoryColor(app.category_slug);
|
||||||
const typeConfig = getAppTypeConfig(app.dify_app_type);
|
const typeConfig = getAppTypeConfig(app.dify_app_type);
|
||||||
const TypeIcon = typeConfig.icon;
|
const TypeIconComponent = typeConfig.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/store/apps/${app.slug}`}>
|
<Link href={`/store/apps/${app.slug}`}>
|
||||||
@@ -46,7 +45,7 @@ export function AppCard({ app }: { app: App }) {
|
|||||||
<div
|
<div
|
||||||
className={`flex h-10 w-10 items-center justify-center rounded-xl ${colorClass} shrink-0`}
|
className={`flex h-10 w-10 items-center justify-center rounded-xl ${colorClass} shrink-0`}
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5" />
|
{IconComponent ? React.createElement(IconComponent, { className: "h-5 w-5" }) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-sm truncate group-hover:text-blue-800 transition-colors">
|
<h3 className="font-semibold text-sm truncate group-hover:text-blue-800 transition-colors">
|
||||||
@@ -62,8 +61,12 @@ export function AppCard({ app }: { app: App }) {
|
|||||||
<Badge variant="secondary" className="text-xs font-normal">
|
<Badge variant="secondary" className="text-xs font-normal">
|
||||||
{app.category_name || "其他"}
|
{app.category_name || "其他"}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className={`inline-flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded-full ${typeConfig.badgeColor}`}>
|
<span
|
||||||
<TypeIcon className="h-2.5 w-2.5" />
|
className={`inline-flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded-full ${typeConfig.badgeColor}`}
|
||||||
|
>
|
||||||
|
{TypeIconComponent
|
||||||
|
? React.createElement(TypeIconComponent, { className: "h-2.5 w-2.5" })
|
||||||
|
: null}
|
||||||
{typeConfig.label}
|
{typeConfig.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
import { useState, useRef, useEffect, useCallback, memo, useMemo } from "react";
|
import { useState, useRef, useEffect, useCallback, memo, useMemo } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -56,7 +57,10 @@ function parseToolCalls(content: string): { cleanContent: string; tools: ToolCal
|
|||||||
const tool = tools.find((t) => t.name === name);
|
const tool = tools.find((t) => t.name === name);
|
||||||
if (tool) tool.status = "done";
|
if (tool) tool.status = "done";
|
||||||
}
|
}
|
||||||
cleanContent = cleanContent.replace(/\[工具调用:\s*.+?\]/g, "").replace(/\[工具结果:\s*.+?\]/g, "").trim();
|
cleanContent = cleanContent
|
||||||
|
.replace(/\[工具调用:\s*.+?\]/g, "")
|
||||||
|
.replace(/\[工具结果:\s*.+?\]/g, "")
|
||||||
|
.trim();
|
||||||
return { cleanContent, tools };
|
return { cleanContent, tools };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,10 +100,16 @@ const AgentMessage = memo(function AgentMessage({
|
|||||||
<div
|
<div
|
||||||
key={tool.name}
|
key={tool.name}
|
||||||
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs ${
|
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"
|
||||||
|
? "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.status === "done" ? (
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
)}
|
||||||
{tool.name}
|
{tool.name}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -130,10 +140,14 @@ interface AgentUIProps {
|
|||||||
export default function AgentUI({ app }: AgentUIProps) {
|
export default function AgentUI({ app }: AgentUIProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [conversationId, setConversationId] = useState<string | undefined>();
|
||||||
|
const [messages, setMessages] = useState<Message[]>(
|
||||||
|
app.welcome_message && !conversationId
|
||||||
|
? [{ id: "welcome", role: "assistant", content: app.welcome_message }]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
const [conversationId, setConversationId] = useState<string | undefined>();
|
|
||||||
const [selectMode, setSelectMode] = useState(false);
|
const [selectMode, setSelectMode] = useState(false);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [deleteTarget, setDeleteTarget] = useState<{
|
const [deleteTarget, setDeleteTarget] = useState<{
|
||||||
@@ -153,7 +167,9 @@ export default function AgentUI({ app }: AgentUIProps) {
|
|||||||
try {
|
try {
|
||||||
if (typeof app.app_config === "string") return JSON.parse(app.app_config);
|
if (typeof app.app_config === "string") return JSON.parse(app.app_config);
|
||||||
return app.app_config || {};
|
return app.app_config || {};
|
||||||
} catch { return {}; }
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
}, [app.app_config]);
|
}, [app.app_config]);
|
||||||
|
|
||||||
const tools: string[] = appConfig.tools || [];
|
const tools: string[] = appConfig.tools || [];
|
||||||
@@ -171,39 +187,43 @@ export default function AgentUI({ app }: AgentUIProps) {
|
|||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { scrollToBottom(); }, [messages, scrollToBottom]);
|
useEffect(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, [messages, scrollToBottom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (app.welcome_message && messages.length === 0 && !conversationId) {
|
return () => {
|
||||||
setMessages([{ id: "welcome", role: "assistant", content: app.welcome_message }]);
|
abortRef.current?.abort();
|
||||||
}
|
};
|
||||||
}, [app.welcome_message, messages.length, conversationId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => { abortRef.current?.abort(); };
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadConversation = useCallback(async (convId: string) => {
|
const loadConversation = useCallback(
|
||||||
setConversationId(convId);
|
async (convId: string) => {
|
||||||
try {
|
setConversationId(convId);
|
||||||
const data = await api.get<{ data: Message[] }>(`/api/v1/apps/${app.id}/conversations/${convId}/messages`);
|
try {
|
||||||
setMessages(data.data || []);
|
const data = await api.get<{ data: Message[] }>(
|
||||||
} catch {
|
`/api/v1/apps/${app.id}/conversations/${convId}/messages`,
|
||||||
setMessages([]);
|
);
|
||||||
}
|
setMessages(data.data || []);
|
||||||
}, [app.id]);
|
} catch {
|
||||||
|
setMessages([]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[app.id],
|
||||||
|
);
|
||||||
|
|
||||||
const CategoryIcon = getCategoryIcon(app.category_slug);
|
const CategoryIconComponent = getCategoryIcon(app.category_slug);
|
||||||
const categoryColor = getCategoryColor(app.category_slug);
|
const categoryColor = getCategoryColor(app.category_slug);
|
||||||
|
|
||||||
const suggestedPrompts = useRef(
|
const suggestedPrompts = useMemo(() => {
|
||||||
(() => {
|
try {
|
||||||
try {
|
if (typeof app.suggested_prompts === "string")
|
||||||
if (typeof app.suggested_prompts === "string") return JSON.parse(app.suggested_prompts) as string[];
|
return JSON.parse(app.suggested_prompts) as string[];
|
||||||
return (app.suggested_prompts as string[]) || [];
|
return (app.suggested_prompts as string[]) || [];
|
||||||
} catch { return []; }
|
} catch {
|
||||||
})()
|
return [];
|
||||||
).current;
|
}
|
||||||
|
}, [app.suggested_prompts]);
|
||||||
|
|
||||||
const copyText = useCallback((text: string) => {
|
const copyText = useCallback((text: string) => {
|
||||||
navigator.clipboard.writeText(text);
|
navigator.clipboard.writeText(text);
|
||||||
@@ -275,13 +295,13 @@ export default function AgentUI({ app }: AgentUIProps) {
|
|||||||
const snap = accumulated;
|
const snap = accumulated;
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m, i) =>
|
prev.map((m, i) =>
|
||||||
i === prev.length - 1 && m.role === "assistant"
|
i === prev.length - 1 && m.role === "assistant" ? { ...m, content: snap } : m,
|
||||||
? { ...m, content: snap }
|
),
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch { /* skip */ }
|
} catch {
|
||||||
|
/* skip */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||||
@@ -291,8 +311,8 @@ export default function AgentUI({ app }: AgentUIProps) {
|
|||||||
prev.map((m, i) =>
|
prev.map((m, i) =>
|
||||||
i === prev.length - 1 && m.role === "assistant" && !m.content
|
i === prev.length - 1 && m.role === "assistant" && !m.content
|
||||||
? { ...m, content: "抱歉,系统处理异常,请稍后重试。" }
|
? { ...m, content: "抱歉,系统处理异常,请稍后重试。" }
|
||||||
: m
|
: m,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
@@ -300,9 +320,15 @@ export default function AgentUI({ app }: AgentUIProps) {
|
|||||||
}
|
}
|
||||||
}, [input, isStreaming, app.id, conversationId, queryClient]);
|
}, [input, isStreaming, app.id, conversationId, queryClient]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback(
|
||||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
(e: React.KeyboardEvent) => {
|
||||||
}, [sendMessage]);
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sendMessage],
|
||||||
|
);
|
||||||
|
|
||||||
const startNewConversation = useCallback(() => {
|
const startNewConversation = useCallback(() => {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
@@ -324,15 +350,20 @@ export default function AgentUI({ app }: AgentUIProps) {
|
|||||||
setSelectedIds(new Set(conversations.map((c) => c.id)));
|
setSelectedIds(new Set(conversations.map((c) => c.id)));
|
||||||
}, [conversations]);
|
}, [conversations]);
|
||||||
|
|
||||||
const confirmDeleteSingle = useCallback(async (convId: string) => {
|
const confirmDeleteSingle = useCallback(
|
||||||
try {
|
async (convId: string) => {
|
||||||
await api.delete(`/api/v1/apps/${app.id}/conversations/${convId}`);
|
try {
|
||||||
if (conversationId === convId) startNewConversation();
|
await api.delete(`/api/v1/apps/${app.id}/conversations/${convId}`);
|
||||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
if (conversationId === convId) startNewConversation();
|
||||||
toast.success("对话已删除");
|
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||||
} catch { toast.error("删除失败"); }
|
toast.success("对话已删除");
|
||||||
setDeleteTarget(null);
|
} catch {
|
||||||
}, [app.id, conversationId, startNewConversation, queryClient]);
|
toast.error("删除失败");
|
||||||
|
}
|
||||||
|
setDeleteTarget(null);
|
||||||
|
},
|
||||||
|
[app.id, conversationId, startNewConversation, queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
const confirmBatchDelete = useCallback(async () => {
|
const confirmBatchDelete = useCallback(async () => {
|
||||||
if (selectedIds.size === 0) return;
|
if (selectedIds.size === 0) return;
|
||||||
@@ -345,18 +376,17 @@ export default function AgentUI({ app }: AgentUIProps) {
|
|||||||
setSelectMode(false);
|
setSelectMode(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||||
toast.success(`已删除 ${selectedIds.size} 个对话`);
|
toast.success(`已删除 ${selectedIds.size} 个对话`);
|
||||||
} catch { toast.error("批量删除失败"); }
|
} catch {
|
||||||
|
toast.error("批量删除失败");
|
||||||
|
}
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
}, [selectedIds, app.id, conversationId, startNewConversation, queryClient]);
|
}, [selectedIds, app.id, conversationId, startNewConversation, queryClient]);
|
||||||
|
|
||||||
const startRename = useCallback(
|
const startRename = useCallback((convId: string, currentName: string) => {
|
||||||
(convId: string, currentName: string) => {
|
setEditingConvId(convId);
|
||||||
setEditingConvId(convId);
|
setEditingName(currentName);
|
||||||
setEditingName(currentName);
|
setTimeout(() => renameInputRef.current?.focus(), 50);
|
||||||
setTimeout(() => renameInputRef.current?.focus(), 50);
|
}, []);
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const saveRename = useCallback(async () => {
|
const saveRename = useCallback(async () => {
|
||||||
if (!editingConvId || !editingName.trim()) {
|
if (!editingConvId || !editingName.trim()) {
|
||||||
@@ -364,10 +394,9 @@ export default function AgentUI({ app }: AgentUIProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await api.put(
|
await api.put(`/api/v1/apps/${app.id}/conversations/${editingConvId}/name`, {
|
||||||
`/api/v1/apps/${app.id}/conversations/${editingConvId}/name`,
|
name: editingName.trim(),
|
||||||
{ name: editingName.trim() }
|
});
|
||||||
);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||||
toast.success("已重命名");
|
toast.success("已重命名");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -379,10 +408,7 @@ export default function AgentUI({ app }: AgentUIProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
||||||
{/* 删除确认弹窗 */}
|
{/* 删除确认弹窗 */}
|
||||||
<AlertDialog
|
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||||
open={!!deleteTarget}
|
|
||||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||||
@@ -419,12 +445,23 @@ export default function AgentUI({ app }: AgentUIProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 侧边栏 */}
|
{/* 侧边栏 */}
|
||||||
<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={`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">
|
<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")}>
|
<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" /> 返回应用中心
|
<ArrowLeft className="h-3.5 w-3.5" /> 返回应用中心
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={startNewConversation} className="w-full gap-1.5 bg-blue-900 hover:bg-blue-800 text-white" size="sm">
|
<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" /> 新对话
|
<Plus className="h-3.5 w-3.5" /> 新对话
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -453,7 +490,14 @@ export default function AgentUI({ app }: AgentUIProps) {
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{selectMode ? (
|
{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"
|
||||||
|
onClick={selectAll}
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -463,12 +507,25 @@ export default function AgentUI({ app }: AgentUIProps) {
|
|||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3 mr-0.5" /> 删除
|
<Trash2 className="h-3 w-3 mr-0.5" /> 删除
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-xs" onClick={() => { setSelectMode(false); setSelectedIds(new Set()); }}>
|
<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" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-xs" onClick={() => setSelectMode(true)}>
|
<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" /> 管理
|
<CheckSquare className="h-3 w-3 mr-0.5" /> 管理
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -485,7 +542,11 @@ export default function AgentUI({ app }: AgentUIProps) {
|
|||||||
<div key={conv.id} className="group flex items-center gap-1">
|
<div key={conv.id} className="group flex items-center gap-1">
|
||||||
{selectMode && (
|
{selectMode && (
|
||||||
<button onClick={() => toggleSelect(conv.id)} className="shrink-0 p-0.5">
|
<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" />}
|
{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>
|
</button>
|
||||||
)}
|
)}
|
||||||
{editingConvId === conv.id ? (
|
{editingConvId === conv.id ? (
|
||||||
@@ -554,12 +615,18 @@ export default function AgentUI({ app }: AgentUIProps) {
|
|||||||
>
|
>
|
||||||
<PanelLeftOpen className="h-4 w-4" />
|
<PanelLeftOpen className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<div className={`flex h-9 w-9 items-center justify-center rounded-xl ${categoryColor} shrink-0`}>
|
<div
|
||||||
<CategoryIcon className="h-4.5 w-4.5" />
|
className={`flex h-9 w-9 items-center justify-center rounded-xl ${categoryColor} shrink-0`}
|
||||||
|
>
|
||||||
|
{CategoryIconComponent
|
||||||
|
? React.createElement(CategoryIconComponent, { className: "h-4.5 w-4.5" })
|
||||||
|
: null}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
|
<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>
|
<p className="text-xs text-muted-foreground truncate max-w-[150px] md:max-w-none">
|
||||||
|
{app.description}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex items-center gap-1 md:gap-2">
|
<div className="ml-auto flex items-center gap-1 md:gap-2">
|
||||||
{messages.length > 1 && (
|
{messages.length > 1 && (
|
||||||
@@ -586,7 +653,16 @@ export default function AgentUI({ app }: AgentUIProps) {
|
|||||||
{messages.length <= 1 && suggestedPrompts.length > 0 && (
|
{messages.length <= 1 && suggestedPrompts.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 mt-4">
|
<div className="flex flex-wrap gap-2 mt-4">
|
||||||
{suggestedPrompts.map((prompt: string, i: number) => (
|
{suggestedPrompts.map((prompt: string, i: number) => (
|
||||||
<Button key={i} variant="outline" size="sm" className="text-xs" onClick={() => { setInput(prompt); textareaRef.current?.focus(); }}>
|
<Button
|
||||||
|
key={i}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
setInput(prompt);
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
{prompt}
|
{prompt}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
@@ -608,8 +684,16 @@ export default function AgentUI({ app }: AgentUIProps) {
|
|||||||
rows={1}
|
rows={1}
|
||||||
disabled={isStreaming}
|
disabled={isStreaming}
|
||||||
/>
|
/>
|
||||||
<Button onClick={sendMessage} disabled={!input.trim() || isStreaming} className="shrink-0 gap-1.5">
|
<Button
|
||||||
{isStreaming ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
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 ? "思考中" : "发送"}
|
{isStreaming ? "思考中" : "发送"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import React from "react";
|
||||||
useState,
|
import { useState, useRef, useEffect, useCallback, useMemo, memo } from "react";
|
||||||
useRef,
|
|
||||||
useEffect,
|
|
||||||
useCallback,
|
|
||||||
useMemo,
|
|
||||||
memo,
|
|
||||||
} from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type {
|
import type {
|
||||||
@@ -242,24 +236,20 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
const { data: templates = [], isLoading: templatesLoading } = useQuery({
|
const { data: templates = [], isLoading: templatesLoading } = useQuery({
|
||||||
queryKey: ["analysis-templates"],
|
queryKey: ["analysis-templates"],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get<{ data: AnalysisTemplate[] }>("/api/v1/analysis-templates").then(
|
api.get<{ data: AnalysisTemplate[] }>("/api/v1/analysis-templates").then((r) => r.data || []),
|
||||||
(r) => r.data || []
|
|
||||||
),
|
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: conversations = [] } = useQuery({
|
const { data: conversations = [] } = useQuery({
|
||||||
queryKey: ["conversations", app.id],
|
queryKey: ["conversations", app.id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const data = await api.get<{ data: Conversation[] }>(
|
const data = await api.get<{ data: Conversation[] }>(`/api/v1/apps/${app.id}/conversations`);
|
||||||
`/api/v1/apps/${app.id}/conversations`
|
|
||||||
);
|
|
||||||
return data.data || [];
|
return data.data || [];
|
||||||
},
|
},
|
||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const CategoryIcon = getCategoryIcon(app.category_slug);
|
const CategoryIconComponent = getCategoryIcon(app.category_slug);
|
||||||
const categoryColor = getCategoryColor(app.category_slug);
|
const categoryColor = getCategoryColor(app.category_slug);
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToBottom = useCallback(() => {
|
||||||
@@ -277,30 +267,24 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/* ---------- template selection ---------- */
|
/* ---------- template selection ---------- */
|
||||||
const selectTemplate = useCallback(
|
const selectTemplate = useCallback((tpl: AnalysisTemplate) => {
|
||||||
(tpl: AnalysisTemplate) => {
|
setSelectedTemplate(tpl);
|
||||||
setSelectedTemplate(tpl);
|
const defaults: Record<string, string | string[]> = {};
|
||||||
const defaults: Record<string, string | string[]> = {};
|
tpl.steps.forEach((s) =>
|
||||||
tpl.steps.forEach((s) =>
|
s.fields.forEach((f) => {
|
||||||
s.fields.forEach((f) => {
|
if (f.type === "multiselect") defaults[f.key] = [];
|
||||||
if (f.type === "multiselect") defaults[f.key] = [];
|
else defaults[f.key] = f.default || "";
|
||||||
else defaults[f.key] = f.default || "";
|
}),
|
||||||
})
|
);
|
||||||
);
|
setFieldData(defaults);
|
||||||
setFieldData(defaults);
|
setCurrentStep(0);
|
||||||
setCurrentStep(0);
|
setPhase("wizard");
|
||||||
setPhase("wizard");
|
}, []);
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ---------- field updates ---------- */
|
/* ---------- field updates ---------- */
|
||||||
const updateField = useCallback(
|
const updateField = useCallback((key: string, value: string | string[]) => {
|
||||||
(key: string, value: string | string[]) => {
|
setFieldData((prev) => ({ ...prev, [key]: value }));
|
||||||
setFieldData((prev) => ({ ...prev, [key]: value }));
|
}, []);
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ---------- step validation ---------- */
|
/* ---------- step validation ---------- */
|
||||||
const isStepValid = useMemo(() => {
|
const isStepValid = useMemo(() => {
|
||||||
@@ -324,9 +308,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
const flatData: Record<string, string> = {};
|
const flatData: Record<string, string> = {};
|
||||||
for (const [k, v] of Object.entries(fieldData)) {
|
for (const [k, v] of Object.entries(fieldData)) {
|
||||||
if (Array.isArray(v)) {
|
if (Array.isArray(v)) {
|
||||||
const step = selectedTemplate.steps.find((s) =>
|
const step = selectedTemplate.steps.find((s) => s.fields.find((f) => f.key === k));
|
||||||
s.fields.find((f) => f.key === k)
|
|
||||||
);
|
|
||||||
const field = step?.fields.find((f) => f.key === k);
|
const field = step?.fields.find((f) => f.key === k);
|
||||||
if (field?.options) {
|
if (field?.options) {
|
||||||
flatData[k] = v
|
flatData[k] = v
|
||||||
@@ -336,9 +318,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
flatData[k] = v.join("、");
|
flatData[k] = v.join("、");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const step = selectedTemplate.steps.find((s) =>
|
const step = selectedTemplate.steps.find((s) => s.fields.find((f) => f.key === k));
|
||||||
s.fields.find((f) => f.key === k)
|
|
||||||
);
|
|
||||||
const field = step?.fields.find((f) => f.key === k);
|
const field = step?.fields.find((f) => f.key === k);
|
||||||
if (field?.options) {
|
if (field?.options) {
|
||||||
flatData[k] = field.options.find((o) => o.value === v)?.label || v;
|
flatData[k] = field.options.find((o) => o.value === v)?.label || v;
|
||||||
@@ -353,7 +333,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
s.fields.forEach((f) => {
|
s.fields.forEach((f) => {
|
||||||
const val = flatData[f.key];
|
const val = flatData[f.key];
|
||||||
if (val) summaryParts.push(`${f.label}:${val}`);
|
if (val) summaryParts.push(`${f.label}:${val}`);
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
const userContent = `[${selectedTemplate.name}] ${summaryParts.join(";")}`;
|
const userContent = `[${selectedTemplate.name}] ${summaryParts.join(";")}`;
|
||||||
|
|
||||||
@@ -377,7 +357,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
app.id,
|
app.id,
|
||||||
selectedTemplate.id,
|
selectedTemplate.id,
|
||||||
flatData,
|
flatData,
|
||||||
controller.signal
|
controller.signal,
|
||||||
);
|
);
|
||||||
if (!res.ok) throw new Error("请求失败");
|
if (!res.ok) throw new Error("请求失败");
|
||||||
const reader = res.body?.getReader();
|
const reader = res.body?.getReader();
|
||||||
@@ -404,10 +384,8 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
const snap = accumulated;
|
const snap = accumulated;
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m, i) =>
|
prev.map((m, i) =>
|
||||||
i === prev.length - 1 && m.role === "assistant"
|
i === prev.length - 1 && m.role === "assistant" ? { ...m, content: snap } : m,
|
||||||
? { ...m, content: snap }
|
),
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -423,8 +401,8 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
prev.map((m, i) =>
|
prev.map((m, i) =>
|
||||||
i === prev.length - 1 && m.role === "assistant" && !m.content
|
i === prev.length - 1 && m.role === "assistant" && !m.content
|
||||||
? { ...m, content: "抱歉,报告生成失败,请稍后重试。" }
|
? { ...m, content: "抱歉,报告生成失败,请稍后重试。" }
|
||||||
: m
|
: m,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
setPhase("result");
|
setPhase("result");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -441,14 +419,14 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
setSelectedTemplate(null);
|
setSelectedTemplate(null);
|
||||||
try {
|
try {
|
||||||
const data = await api.get<{ data: Message[] }>(
|
const data = await api.get<{ data: Message[] }>(
|
||||||
`/api/v1/apps/${app.id}/conversations/${convId}/messages`
|
`/api/v1/apps/${app.id}/conversations/${convId}/messages`,
|
||||||
);
|
);
|
||||||
setMessages(data.data || []);
|
setMessages(data.data || []);
|
||||||
} catch {
|
} catch {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[app.id]
|
[app.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const startNewReport = useCallback(() => {
|
const startNewReport = useCallback(() => {
|
||||||
@@ -468,9 +446,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const exportReport = useCallback(() => {
|
const exportReport = useCallback(() => {
|
||||||
const assistantMsgs = messages.filter(
|
const assistantMsgs = messages.filter((m) => m.role === "assistant" && m.content);
|
||||||
(m) => m.role === "assistant" && m.content
|
|
||||||
);
|
|
||||||
if (assistantMsgs.length === 0) return;
|
if (assistantMsgs.length === 0) return;
|
||||||
const text = assistantMsgs.map((m) => m.content).join("\n\n");
|
const text = assistantMsgs.map((m) => m.content).join("\n\n");
|
||||||
const tplName = selectedTemplate?.name || "研判报告";
|
const tplName = selectedTemplate?.name || "研判报告";
|
||||||
@@ -491,9 +467,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
}, [messages, selectedTemplate]);
|
}, [messages, selectedTemplate]);
|
||||||
|
|
||||||
const exportAsWord = useCallback(() => {
|
const exportAsWord = useCallback(() => {
|
||||||
const assistantMsgs = messages.filter(
|
const assistantMsgs = messages.filter((m) => m.role === "assistant" && m.content);
|
||||||
(m) => m.role === "assistant" && m.content
|
|
||||||
);
|
|
||||||
if (assistantMsgs.length === 0) return;
|
if (assistantMsgs.length === 0) return;
|
||||||
let content = assistantMsgs[assistantMsgs.length - 1].content;
|
let content = assistantMsgs[assistantMsgs.length - 1].content;
|
||||||
const fenceMatch = content.trim().match(/^```[\w]*\s*\n([\s\S]*?)```\s*$/);
|
const fenceMatch = content.trim().match(/^```[\w]*\s*\n([\s\S]*?)```\s*$/);
|
||||||
@@ -580,7 +554,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
}
|
}
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
},
|
},
|
||||||
[app.id, conversationId, startNewReport, queryClient]
|
[app.id, conversationId, startNewReport, queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirmBatchDelete = useCallback(async () => {
|
const confirmBatchDelete = useCallback(async () => {
|
||||||
@@ -602,14 +576,11 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
}, [selectedIds, app.id, conversationId, startNewReport, queryClient]);
|
}, [selectedIds, app.id, conversationId, startNewReport, queryClient]);
|
||||||
|
|
||||||
const startRename = useCallback(
|
const startRename = useCallback((convId: string, currentName: string) => {
|
||||||
(convId: string, currentName: string) => {
|
setEditingConvId(convId);
|
||||||
setEditingConvId(convId);
|
setEditingName(currentName);
|
||||||
setEditingName(currentName);
|
setTimeout(() => renameInputRef.current?.focus(), 50);
|
||||||
setTimeout(() => renameInputRef.current?.focus(), 50);
|
}, []);
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const saveRename = useCallback(async () => {
|
const saveRename = useCallback(async () => {
|
||||||
if (!editingConvId || !editingName.trim()) {
|
if (!editingConvId || !editingName.trim()) {
|
||||||
@@ -617,10 +588,9 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await api.put(
|
await api.put(`/api/v1/apps/${app.id}/conversations/${editingConvId}/name`, {
|
||||||
`/api/v1/apps/${app.id}/conversations/${editingConvId}/name`,
|
name: editingName.trim(),
|
||||||
{ name: editingName.trim() }
|
});
|
||||||
);
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["conversations", app.id],
|
queryKey: ["conversations", app.id],
|
||||||
});
|
});
|
||||||
@@ -673,13 +643,13 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
const snap = accumulated;
|
const snap = accumulated;
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m, i) =>
|
prev.map((m, i) =>
|
||||||
i === prev.length - 1 && m.role === "assistant"
|
i === prev.length - 1 && m.role === "assistant" ? { ...m, content: snap } : m,
|
||||||
? { ...m, content: snap }
|
),
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch { /* skip */ }
|
} catch {
|
||||||
|
/* skip */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -688,8 +658,8 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
prev.map((m, i) =>
|
prev.map((m, i) =>
|
||||||
i === prev.length - 1 && m.role === "assistant" && !m.content
|
i === prev.length - 1 && m.role === "assistant" && !m.content
|
||||||
? { ...m, content: "抱歉,处理异常,请稍后重试。" }
|
? { ...m, content: "抱歉,处理异常,请稍后重试。" }
|
||||||
: m
|
: m,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
@@ -704,10 +674,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
||||||
{/* Delete dialog */}
|
{/* Delete dialog */}
|
||||||
<AlertDialog
|
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||||
open={!!deleteTarget}
|
|
||||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||||
@@ -742,7 +709,9 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ========= sidebar ========= */}
|
{/* ========= 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={`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">
|
<div className="p-3 border-b space-y-2 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -752,7 +721,11 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
>
|
>
|
||||||
<ArrowLeft className="h-3.5 w-3.5" /> 返回应用中心
|
<ArrowLeft className="h-3.5 w-3.5" /> 返回应用中心
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={startNewReport} className="w-full gap-1.5 bg-blue-900 hover:bg-blue-800 text-white" size="sm">
|
<Button
|
||||||
|
onClick={startNewReport}
|
||||||
|
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" /> 新建报告
|
<Plus className="h-3.5 w-3.5" /> 新建报告
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -760,9 +733,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
{conversations.length > 0 && (
|
{conversations.length > 0 && (
|
||||||
<div className="px-3 py-2 border-b flex items-center justify-between shrink-0">
|
<div className="px-3 py-2 border-b flex items-center justify-between shrink-0">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{selectMode
|
{selectMode ? `已选 ${selectedIds.size} 个` : `${conversations.length} 条记录`}
|
||||||
? `已选 ${selectedIds.size} 个`
|
|
||||||
: `${conversations.length} 条记录`}
|
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{selectMode ? (
|
{selectMode ? (
|
||||||
@@ -812,18 +783,13 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto min-h-0 p-2">
|
<div className="flex-1 overflow-y-auto min-h-0 p-2">
|
||||||
{conversations.length === 0 ? (
|
{conversations.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground text-center py-4">
|
<p className="text-xs text-muted-foreground text-center py-4">暂无报告历史</p>
|
||||||
暂无报告历史
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{conversations.map((conv) => (
|
{conversations.map((conv) => (
|
||||||
<div key={conv.id} className="group flex items-center gap-1">
|
<div key={conv.id} className="group flex items-center gap-1">
|
||||||
{selectMode && (
|
{selectMode && (
|
||||||
<button
|
<button onClick={() => toggleSelect(conv.id)} className="shrink-0 p-0.5">
|
||||||
onClick={() => toggleSelect(conv.id)}
|
|
||||||
className="shrink-0 p-0.5"
|
|
||||||
>
|
|
||||||
{selectedIds.has(conv.id) ? (
|
{selectedIds.has(conv.id) ? (
|
||||||
<CheckSquare className="h-3.5 w-3.5 text-primary" />
|
<CheckSquare className="h-3.5 w-3.5 text-primary" />
|
||||||
) : (
|
) : (
|
||||||
@@ -846,16 +812,10 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() => !selectMode && loadConversation(conv.id)}
|
||||||
!selectMode && loadConversation(conv.id)
|
onDoubleClick={() => !selectMode && startRename(conv.id, conv.name)}
|
||||||
}
|
|
||||||
onDoubleClick={() =>
|
|
||||||
!selectMode && startRename(conv.id, conv.name)
|
|
||||||
}
|
|
||||||
className={`flex-1 text-left p-2 rounded-md text-sm truncate transition-colors ${
|
className={`flex-1 text-left p-2 rounded-md text-sm truncate transition-colors ${
|
||||||
conversationId === conv.id
|
conversationId === conv.id ? "bg-muted font-medium" : "hover:bg-muted/60"
|
||||||
? "bg-muted font-medium"
|
|
||||||
: "hover:bg-muted/60"
|
|
||||||
}`}
|
}`}
|
||||||
title={`${conv.name}\n双击重命名`}
|
title={`${conv.name}\n双击重命名`}
|
||||||
>
|
>
|
||||||
@@ -911,7 +871,9 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
<div
|
<div
|
||||||
className={`flex h-9 w-9 items-center justify-center rounded-xl ${categoryColor} shrink-0`}
|
className={`flex h-9 w-9 items-center justify-center rounded-xl ${categoryColor} shrink-0`}
|
||||||
>
|
>
|
||||||
<CategoryIcon className="h-4.5 w-4.5" />
|
{CategoryIconComponent
|
||||||
|
? React.createElement(CategoryIconComponent, { className: "h-4.5 w-4.5" })
|
||||||
|
: null}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
|
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
|
||||||
@@ -972,8 +934,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{templates.map((tpl) => {
|
{templates.map((tpl) => {
|
||||||
const Icon =
|
const Icon = REPORT_ICONS[tpl.report_type] || BarChart3;
|
||||||
REPORT_ICONS[tpl.report_type] || BarChart3;
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tpl.id}
|
key={tpl.id}
|
||||||
@@ -1020,17 +981,11 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
: "bg-muted text-muted-foreground"
|
: "bg-muted text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{i < currentStep ? (
|
{i < currentStep ? <Check className="h-4 w-4" /> : i + 1}
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
i + 1
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={`text-xs truncate hidden sm:block ${
|
className={`text-xs truncate hidden sm:block ${
|
||||||
i === currentStep
|
i === currentStep ? "font-medium text-foreground" : "text-muted-foreground"
|
||||||
? "font-medium text-foreground"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{s.title}
|
{s.title}
|
||||||
@@ -1055,9 +1010,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold">{step.title}</h3>
|
<h3 className="text-lg font-semibold">{step.title}</h3>
|
||||||
{step.description && (
|
{step.description && (
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">{step.description}</p>
|
||||||
{step.description}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1068,9 +1021,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
<MultiSelectField
|
<MultiSelectField
|
||||||
key={field.key}
|
key={field.key}
|
||||||
field={field}
|
field={field}
|
||||||
value={
|
value={(fieldData[field.key] as string[]) || []}
|
||||||
(fieldData[field.key] as string[]) || []
|
|
||||||
}
|
|
||||||
onChange={(v) => updateField(field.key, v)}
|
onChange={(v) => updateField(field.key, v)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -1153,9 +1104,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
<div className="flex justify-end group/msg">
|
<div className="flex justify-end group/msg">
|
||||||
<div className="max-w-[80%]">
|
<div className="max-w-[80%]">
|
||||||
<div className="rounded-2xl px-4 py-2.5 bg-primary text-primary-foreground rounded-br-md shadow-sm">
|
<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">
|
<p className="text-sm whitespace-pre-wrap">{msg.content}</p>
|
||||||
{msg.content}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end mt-1 opacity-0 group-hover/msg:opacity-100 transition-opacity">
|
<div className="flex justify-end mt-1 opacity-0 group-hover/msg:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
@@ -1171,8 +1120,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
|||||||
<div className="flex justify-start group/msg">
|
<div className="flex justify-start group/msg">
|
||||||
<div className="max-w-full w-full space-y-2">
|
<div className="max-w-full w-full space-y-2">
|
||||||
{isStreaming &&
|
{isStreaming &&
|
||||||
msg.id ===
|
msg.id === messages[messages.length - 1]?.id &&
|
||||||
messages[messages.length - 1]?.id &&
|
|
||||||
!msg.content && (
|
!msg.content && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback, memo } from "react";
|
import React from "react";
|
||||||
|
import { useState, useEffect, useCallback, memo, useMemo, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { App, Conversation, Message } from "@/lib/types";
|
import type { App, Conversation, Message, Chunk } from "@/lib/types";
|
||||||
import api, { streamChat } from "@/lib/api";
|
import api, { streamChat } from "@/lib/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
@@ -43,9 +44,15 @@ import { useAuthStore } from "@/stores/auth";
|
|||||||
const ChatMessage = memo(function ChatMessage({
|
const ChatMessage = memo(function ChatMessage({
|
||||||
msg,
|
msg,
|
||||||
onCopy,
|
onCopy,
|
||||||
|
chunks,
|
||||||
|
suggestions,
|
||||||
|
onSuggestionClick,
|
||||||
}: {
|
}: {
|
||||||
msg: Message;
|
msg: Message;
|
||||||
onCopy: (text: string) => void;
|
onCopy: (text: string) => void;
|
||||||
|
chunks: Chunk[];
|
||||||
|
suggestions?: string[];
|
||||||
|
onSuggestionClick?: (text: string) => void;
|
||||||
}) {
|
}) {
|
||||||
if (msg.role === "user") {
|
if (msg.role === "user") {
|
||||||
return (
|
return (
|
||||||
@@ -70,7 +77,7 @@ const ChatMessage = memo(function ChatMessage({
|
|||||||
<div className="flex justify-start group/msg">
|
<div className="flex justify-start group/msg">
|
||||||
<div className="max-w-[85%]">
|
<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">
|
<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} />
|
<GovMarkdown content={msg.content} chunks={chunks} />
|
||||||
</div>
|
</div>
|
||||||
{msg.content && (
|
{msg.content && (
|
||||||
<div className="flex mt-1 opacity-0 group-hover/msg:opacity-100 transition-opacity">
|
<div className="flex mt-1 opacity-0 group-hover/msg:opacity-100 transition-opacity">
|
||||||
@@ -82,6 +89,20 @@ const ChatMessage = memo(function ChatMessage({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* 对话结束后的提示问题 */}
|
||||||
|
{suggestions && suggestions.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-3">
|
||||||
|
{suggestions.map((suggestion, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => onSuggestionClick?.(suggestion)}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-full bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900 border border-blue-200 dark:border-blue-800 transition-colors"
|
||||||
|
>
|
||||||
|
{suggestion}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -95,10 +116,15 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [conversationId, setConversationId] = useState<string | undefined>();
|
||||||
|
const [messages, setMessages] = useState<Message[]>(
|
||||||
|
app.welcome_message && !conversationId
|
||||||
|
? [{ id: "welcome", role: "assistant", content: app.welcome_message }]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const [currentSuggestions, setCurrentSuggestions] = useState<string[]>([]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
const [conversationId, setConversationId] = useState<string | undefined>();
|
|
||||||
const [selectMode, setSelectMode] = useState(false);
|
const [selectMode, setSelectMode] = useState(false);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [deleteTarget, setDeleteTarget] = useState<{
|
const [deleteTarget, setDeleteTarget] = useState<{
|
||||||
@@ -112,6 +138,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [fileContent, setFileContent] = useState<string | null>(null);
|
const [fileContent, setFileContent] = useState<string | null>(null);
|
||||||
const [fileName, setFileName] = useState<string | null>(null);
|
const [fileName, setFileName] = useState<string | null>(null);
|
||||||
|
const [chunks, setChunks] = useState<Chunk[]>([]);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
@@ -121,9 +148,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
const { data: conversations = [] } = useQuery({
|
const { data: conversations = [] } = useQuery({
|
||||||
queryKey: ["conversations", app.id, user?.id],
|
queryKey: ["conversations", app.id, user?.id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const data = await api.get<{ data: Conversation[] }>(
|
const data = await api.get<{ data: Conversation[] }>(`/api/v1/apps/${app.id}/conversations`);
|
||||||
`/api/v1/apps/${app.id}/conversations`
|
|
||||||
);
|
|
||||||
return data.data || [];
|
return data.data || [];
|
||||||
},
|
},
|
||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
@@ -137,60 +162,47 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [messages, 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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const CategoryIcon = getCategoryIcon(app.category_slug);
|
const CategoryIconComponent = getCategoryIcon(app.category_slug);
|
||||||
const categoryColor = getCategoryColor(app.category_slug);
|
const categoryColor = getCategoryColor(app.category_slug);
|
||||||
|
|
||||||
const suggestedPrompts = useRef(
|
const suggestedPrompts = useMemo(() => {
|
||||||
(() => {
|
try {
|
||||||
try {
|
if (typeof app.suggested_prompts === "string")
|
||||||
if (typeof app.suggested_prompts === "string")
|
return JSON.parse(app.suggested_prompts) as string[];
|
||||||
return JSON.parse(app.suggested_prompts) as string[];
|
return (app.suggested_prompts as string[]) || [];
|
||||||
return (app.suggested_prompts as string[]) || [];
|
} catch {
|
||||||
} catch {
|
return [];
|
||||||
return [];
|
}
|
||||||
}
|
}, [app.suggested_prompts]);
|
||||||
})()
|
|
||||||
).current;
|
|
||||||
|
|
||||||
const loadConversation = useCallback(
|
const loadConversation = useCallback(
|
||||||
async (convId: string) => {
|
async (convId: string) => {
|
||||||
setConversationId(convId);
|
setConversationId(convId);
|
||||||
try {
|
try {
|
||||||
const data = await api.get<{ data: Message[] }>(
|
const data = await api.get<{ data: Message[] }>(
|
||||||
`/api/v1/apps/${app.id}/conversations/${convId}/messages`
|
`/api/v1/apps/${app.id}/conversations/${convId}/messages`,
|
||||||
);
|
);
|
||||||
setMessages(data.data || []);
|
setMessages(data.data || []);
|
||||||
} catch {
|
} catch {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[app.id]
|
[app.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const copyText = useCallback(
|
const copyText = useCallback((text: string) => {
|
||||||
(text: string) => {
|
navigator.clipboard.writeText(text);
|
||||||
navigator.clipboard.writeText(text);
|
const id = `${Date.now()}`;
|
||||||
const id = `${Date.now()}`;
|
setCopiedId(id);
|
||||||
setCopiedId(id);
|
toast.success("已复制到剪贴板");
|
||||||
toast.success("已复制到剪贴板");
|
setTimeout(() => setCopiedId(null), 2000);
|
||||||
setTimeout(() => setCopiedId(null), 2000);
|
}, []);
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const exportConversation = useCallback(() => {
|
const exportConversation = useCallback(() => {
|
||||||
if (messages.length === 0) return;
|
if (messages.length === 0) return;
|
||||||
@@ -269,18 +281,16 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
setInput("");
|
setInput("");
|
||||||
setFileContent(null);
|
setFileContent(null);
|
||||||
setFileName(null);
|
setFileName(null);
|
||||||
|
setChunks([]);
|
||||||
|
setCurrentSuggestions([]); // 清空之前的提示问题
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
abortRef.current = controller;
|
abortRef.current = controller;
|
||||||
|
const accumulatedRef = { current: "" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await streamChat(
|
const res = await streamChat(app.id, fullMessage, conversationId, controller.signal);
|
||||||
app.id,
|
|
||||||
fullMessage,
|
|
||||||
conversationId,
|
|
||||||
controller.signal
|
|
||||||
);
|
|
||||||
if (!res.ok) throw new Error("请求失败");
|
if (!res.ok) throw new Error("请求失败");
|
||||||
const reader = res.body?.getReader();
|
const reader = res.body?.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
@@ -288,6 +298,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
|
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
let accumulated = "";
|
let accumulated = "";
|
||||||
|
let conversationIdFromResponse: string | undefined;
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
@@ -300,17 +311,20 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
if (raw === "[DONE]") break;
|
if (raw === "[DONE]") break;
|
||||||
try {
|
try {
|
||||||
const event = JSON.parse(raw);
|
const event = JSON.parse(raw);
|
||||||
if (event.conversation_id)
|
if (event.conversation_id) conversationIdFromResponse = event.conversation_id;
|
||||||
setConversationId(event.conversation_id);
|
// 解析首包的 chunks 映射表
|
||||||
|
if (event.chunks) {
|
||||||
|
console.log("[SSE] Received chunks:", event.chunks);
|
||||||
|
setChunks(event.chunks as Chunk[]);
|
||||||
|
}
|
||||||
if (event.answer) {
|
if (event.answer) {
|
||||||
accumulated += event.answer;
|
accumulated += event.answer;
|
||||||
|
accumulatedRef.current += event.answer;
|
||||||
const snap = accumulated;
|
const snap = accumulated;
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m, i) =>
|
prev.map((m, i) =>
|
||||||
i === prev.length - 1 && m.role === "assistant"
|
i === prev.length - 1 && m.role === "assistant" ? { ...m, content: snap } : m,
|
||||||
? { ...m, content: snap }
|
),
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -318,6 +332,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (conversationIdFromResponse) setConversationId(conversationIdFromResponse);
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["conversations", app.id],
|
queryKey: ["conversations", app.id],
|
||||||
});
|
});
|
||||||
@@ -331,14 +346,70 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
prev.map((m, i) =>
|
prev.map((m, i) =>
|
||||||
i === prev.length - 1 && m.role === "assistant" && !m.content
|
i === prev.length - 1 && m.role === "assistant" && !m.content
|
||||||
? { ...m, content: "抱歉,系统处理异常,请稍后重试。" }
|
? { ...m, content: "抱歉,系统处理异常,请稍后重试。" }
|
||||||
: m
|
: m,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
|
// 生成对话结束后的提示问题
|
||||||
|
generateChatSuggestions(messages);
|
||||||
}
|
}
|
||||||
}, [input, isStreaming, app.id, conversationId, queryClient]);
|
}, [input, isStreaming, app.id, conversationId, queryClient, messages]);
|
||||||
|
|
||||||
|
// 生成对话结束后的提示问题(调用 LLM API)
|
||||||
|
const generateChatSuggestions = async (allMessages: Message[]) => {
|
||||||
|
// 过滤掉 welcome 消息,只保留用户和助手的实际对话
|
||||||
|
const realMessages = allMessages.filter((m) => m.id !== "welcome").slice(-10);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.post<{ data: string[] }>(`/api/v1/apps/${app.id}/suggestions`, {
|
||||||
|
conversation_id: conversationId,
|
||||||
|
messages: realMessages.map((m) => ({ role: m.role, content: m.content })),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data && response.data.length > 0) {
|
||||||
|
setCurrentSuggestions(response.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("LLM 生成追问失败,使用规则生成:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 调用失败时,使用规则生成作为降级方案
|
||||||
|
generateFallbackSuggestions(allMessages);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 规则生成追问(降级方案)
|
||||||
|
const generateFallbackSuggestions = (allMessages: Message[]) => {
|
||||||
|
const lastResponse = allMessages[allMessages.length - 1]?.content || "";
|
||||||
|
const suggestions: string[] = [];
|
||||||
|
const lowerResponse = lastResponse.toLowerCase();
|
||||||
|
|
||||||
|
// 基于回复内容生成针对性追问
|
||||||
|
if (lowerResponse.includes("政策") || lowerResponse.includes("法规") || lowerResponse.includes("法律")) {
|
||||||
|
suggestions.push("还有哪些相关政策?", "政策的适用范围是什么?");
|
||||||
|
}
|
||||||
|
if (lowerResponse.includes("流程") || lowerResponse.includes("步骤") || lowerResponse.includes("程序")) {
|
||||||
|
suggestions.push("具体流程是什么?", "需要准备哪些材料?");
|
||||||
|
}
|
||||||
|
if (lowerResponse.includes("条件") || lowerResponse.includes("要求") || lowerResponse.includes("资格")) {
|
||||||
|
suggestions.push("具体需要什么条件?", "不符合条件怎么办?");
|
||||||
|
}
|
||||||
|
if (lowerResponse.includes("费用") || lowerResponse.includes("收费")) {
|
||||||
|
suggestions.push("收费标准是多少?", "有优惠政策吗?");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggestions.length < 4) {
|
||||||
|
const generic = ["能详细说明一下吗?", "有什么需要注意的?", "可以举个例子吗?", "还有其他方案吗?"];
|
||||||
|
for (const q of generic) {
|
||||||
|
if (suggestions.length >= 4) break;
|
||||||
|
if (!suggestions.includes(q)) suggestions.push(q);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentSuggestions(suggestions.slice(0, 4));
|
||||||
|
};
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
@@ -347,7 +418,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
sendMessage();
|
sendMessage();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[sendMessage]
|
[sendMessage],
|
||||||
);
|
);
|
||||||
|
|
||||||
const startNewConversation = useCallback(() => {
|
const startNewConversation = useCallback(() => {
|
||||||
@@ -355,6 +426,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
setMessages([]);
|
setMessages([]);
|
||||||
setConversationId(undefined);
|
setConversationId(undefined);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
|
setCurrentSuggestions([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleSelect = useCallback((id: string) => {
|
const toggleSelect = useCallback((id: string) => {
|
||||||
@@ -384,7 +456,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
}
|
}
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
},
|
},
|
||||||
[app.id, conversationId, startNewConversation, queryClient]
|
[app.id, conversationId, startNewConversation, queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirmBatchDelete = useCallback(async () => {
|
const confirmBatchDelete = useCallback(async () => {
|
||||||
@@ -393,8 +465,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
await api.post(`/api/v1/apps/${app.id}/conversations/batch-delete`, {
|
await api.post(`/api/v1/apps/${app.id}/conversations/batch-delete`, {
|
||||||
conversation_ids: Array.from(selectedIds),
|
conversation_ids: Array.from(selectedIds),
|
||||||
});
|
});
|
||||||
if (conversationId && selectedIds.has(conversationId))
|
if (conversationId && selectedIds.has(conversationId)) startNewConversation();
|
||||||
startNewConversation();
|
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
setSelectMode(false);
|
setSelectMode(false);
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
@@ -405,22 +476,13 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
toast.error("批量删除失败");
|
toast.error("批量删除失败");
|
||||||
}
|
}
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
}, [
|
}, [selectedIds, app.id, conversationId, startNewConversation, queryClient]);
|
||||||
selectedIds,
|
|
||||||
app.id,
|
|
||||||
conversationId,
|
|
||||||
startNewConversation,
|
|
||||||
queryClient,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const startRename = useCallback(
|
const startRename = useCallback((convId: string, currentName: string) => {
|
||||||
(convId: string, currentName: string) => {
|
setEditingConvId(convId);
|
||||||
setEditingConvId(convId);
|
setEditingName(currentName);
|
||||||
setEditingName(currentName);
|
setTimeout(() => renameInputRef.current?.focus(), 50);
|
||||||
setTimeout(() => renameInputRef.current?.focus(), 50);
|
}, []);
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const saveRename = useCallback(async () => {
|
const saveRename = useCallback(async () => {
|
||||||
if (!editingConvId || !editingName.trim()) {
|
if (!editingConvId || !editingName.trim()) {
|
||||||
@@ -428,10 +490,9 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await api.put(
|
await api.put(`/api/v1/apps/${app.id}/conversations/${editingConvId}/name`, {
|
||||||
`/api/v1/apps/${app.id}/conversations/${editingConvId}/name`,
|
name: editingName.trim(),
|
||||||
{ name: editingName.trim() }
|
});
|
||||||
);
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["conversations", app.id],
|
queryKey: ["conversations", app.id],
|
||||||
});
|
});
|
||||||
@@ -445,10 +506,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
||||||
{/* 删除确认弹窗 */}
|
{/* 删除确认弹窗 */}
|
||||||
<AlertDialog
|
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||||
open={!!deleteTarget}
|
|
||||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||||
@@ -485,9 +543,11 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 侧边栏 */}
|
{/* 侧边栏 */}
|
||||||
<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 ${
|
<div
|
||||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
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">
|
<div className="p-3 border-b space-y-2 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -509,9 +569,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
{conversations.length > 0 && (
|
{conversations.length > 0 && (
|
||||||
<div className="px-3 py-2 border-b flex items-center justify-between shrink-0">
|
<div className="px-3 py-2 border-b flex items-center justify-between shrink-0">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{selectMode
|
{selectMode ? `已选 ${selectedIds.size} 个` : `${conversations.length} 个对话`}
|
||||||
? `已选 ${selectedIds.size} 个`
|
|
||||||
: `${conversations.length} 个对话`}
|
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{selectMode ? (
|
{selectMode ? (
|
||||||
@@ -528,9 +586,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 px-1.5 text-xs text-destructive hover:text-destructive"
|
className="h-6 px-1.5 text-xs text-destructive hover:text-destructive"
|
||||||
onClick={() =>
|
onClick={() => setDeleteTarget({ type: "batch" })}
|
||||||
setDeleteTarget({ type: "batch" })
|
|
||||||
}
|
|
||||||
disabled={selectedIds.size === 0}
|
disabled={selectedIds.size === 0}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3 mr-0.5" /> 删除
|
<Trash2 className="h-3 w-3 mr-0.5" /> 删除
|
||||||
@@ -563,18 +619,13 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto min-h-0 p-2">
|
<div className="flex-1 overflow-y-auto min-h-0 p-2">
|
||||||
{conversations.length === 0 ? (
|
{conversations.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground text-center py-4">
|
<p className="text-xs text-muted-foreground text-center py-4">暂无对话历史</p>
|
||||||
暂无对话历史
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{conversations.map((conv) => (
|
{conversations.map((conv) => (
|
||||||
<div key={conv.id} className="group flex items-center gap-1">
|
<div key={conv.id} className="group flex items-center gap-1">
|
||||||
{selectMode && (
|
{selectMode && (
|
||||||
<button
|
<button onClick={() => toggleSelect(conv.id)} className="shrink-0 p-0.5">
|
||||||
onClick={() => toggleSelect(conv.id)}
|
|
||||||
className="shrink-0 p-0.5"
|
|
||||||
>
|
|
||||||
{selectedIds.has(conv.id) ? (
|
{selectedIds.has(conv.id) ? (
|
||||||
<CheckSquare className="h-3.5 w-3.5 text-primary" />
|
<CheckSquare className="h-3.5 w-3.5 text-primary" />
|
||||||
) : (
|
) : (
|
||||||
@@ -598,13 +649,9 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => !selectMode && loadConversation(conv.id)}
|
onClick={() => !selectMode && loadConversation(conv.id)}
|
||||||
onDoubleClick={() =>
|
onDoubleClick={() => !selectMode && startRename(conv.id, conv.name)}
|
||||||
!selectMode && startRename(conv.id, conv.name)
|
|
||||||
}
|
|
||||||
className={`flex-1 text-left p-2 rounded-md text-sm truncate transition-colors ${
|
className={`flex-1 text-left p-2 rounded-md text-sm truncate transition-colors ${
|
||||||
conversationId === conv.id
|
conversationId === conv.id ? "bg-muted font-medium" : "hover:bg-muted/60"
|
||||||
? "bg-muted font-medium"
|
|
||||||
: "hover:bg-muted/60"
|
|
||||||
}`}
|
}`}
|
||||||
title={`${conv.name}\n双击重命名`}
|
title={`${conv.name}\n双击重命名`}
|
||||||
>
|
>
|
||||||
@@ -659,7 +706,9 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
<div
|
<div
|
||||||
className={`flex h-9 w-9 items-center justify-center rounded-xl ${categoryColor} shrink-0`}
|
className={`flex h-9 w-9 items-center justify-center rounded-xl ${categoryColor} shrink-0`}
|
||||||
>
|
>
|
||||||
<CategoryIcon className="h-4.5 w-4.5" />
|
{CategoryIconComponent
|
||||||
|
? React.createElement(CategoryIconComponent, { className: "h-4.5 w-4.5" })
|
||||||
|
: null}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
|
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
|
||||||
@@ -686,9 +735,22 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto min-h-0 p-4">
|
<div className="flex-1 overflow-y-auto min-h-0 p-4">
|
||||||
<div className="max-w-3xl mx-auto space-y-4">
|
<div className="max-w-3xl mx-auto space-y-4">
|
||||||
{messages.map((msg) => (
|
{messages.map((msg, idx) => {
|
||||||
<ChatMessage key={msg.id} msg={msg} onCopy={copyText} />
|
const isLastAssistant = msg.role === "assistant" && idx === messages.length - 1;
|
||||||
))}
|
return (
|
||||||
|
<ChatMessage
|
||||||
|
key={msg.id}
|
||||||
|
msg={msg}
|
||||||
|
onCopy={copyText}
|
||||||
|
chunks={chunks}
|
||||||
|
suggestions={isLastAssistant ? currentSuggestions : undefined}
|
||||||
|
onSuggestionClick={(text) => {
|
||||||
|
setInput(text);
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{messages.length <= 1 && suggestedPrompts.length > 0 && (
|
{messages.length <= 1 && suggestedPrompts.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 mt-4">
|
<div className="flex flex-wrap gap-2 mt-4">
|
||||||
@@ -719,7 +781,10 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
<FileText className="h-3.5 w-3.5 shrink-0" />
|
<FileText className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="truncate">{fileName}</span>
|
<span className="truncate">{fileName}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setFileContent(null); setFileName(null); }}
|
onClick={() => {
|
||||||
|
setFileContent(null);
|
||||||
|
setFileName(null);
|
||||||
|
}}
|
||||||
className="ml-auto p-0.5 hover:bg-blue-100 rounded"
|
className="ml-auto p-0.5 hover:bg-blue-100 rounded"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
@@ -749,7 +814,11 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
|||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={fileName ? "输入审查要求... (Enter 发送)" : "输入消息... (Enter 发送,Shift+Enter 换行)"}
|
placeholder={
|
||||||
|
fileName
|
||||||
|
? "输入审查要求... (Enter 发送)"
|
||||||
|
: "输入消息... (Enter 发送,Shift+Enter 换行)"
|
||||||
|
}
|
||||||
className="resize-none min-h-[44px] max-h-32"
|
className="resize-none min-h-[44px] max-h-32"
|
||||||
rows={1}
|
rows={1}
|
||||||
disabled={isStreaming}
|
disabled={isStreaming}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
import { useState, useMemo, useCallback, useRef } from "react";
|
import { useState, useMemo, useCallback, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -9,15 +10,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import { Sparkles, RotateCcw, Copy, Check, Loader2, FileText, ListChecks } from "lucide-react";
|
||||||
Sparkles,
|
|
||||||
RotateCcw,
|
|
||||||
Copy,
|
|
||||||
Check,
|
|
||||||
Loader2,
|
|
||||||
FileText,
|
|
||||||
ListChecks,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { getCategoryIcon, getCategoryColor } from "@/lib/category-config";
|
import { getCategoryIcon, getCategoryColor } from "@/lib/category-config";
|
||||||
import GovMarkdown from "@/components/ui/gov-markdown";
|
import GovMarkdown from "@/components/ui/gov-markdown";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -50,7 +43,7 @@ export default function CompletionUI({ app }: CompletionUIProps) {
|
|||||||
setConversationId(convId);
|
setConversationId(convId);
|
||||||
try {
|
try {
|
||||||
const data = await api.get<{ data: Message[] }>(
|
const data = await api.get<{ data: Message[] }>(
|
||||||
`/api/v1/apps/${app.id}/conversations/${convId}/messages`
|
`/api/v1/apps/${app.id}/conversations/${convId}/messages`,
|
||||||
);
|
);
|
||||||
const msgs = data.data || [];
|
const msgs = data.data || [];
|
||||||
const userMsg = msgs.find((m) => m.role === "user");
|
const userMsg = msgs.find((m) => m.role === "user");
|
||||||
@@ -62,14 +55,16 @@ export default function CompletionUI({ app }: CompletionUIProps) {
|
|||||||
setOutput("");
|
setOutput("");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[app.id]
|
[app.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const appConfig = useMemo(() => {
|
const appConfig = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
if (typeof app.app_config === "string") return JSON.parse(app.app_config);
|
if (typeof app.app_config === "string") return JSON.parse(app.app_config);
|
||||||
return app.app_config || {};
|
return app.app_config || {};
|
||||||
} catch { return {}; }
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
}, [app.app_config]);
|
}, [app.app_config]);
|
||||||
|
|
||||||
const inputLabel = appConfig.input_label || "输入内容";
|
const inputLabel = appConfig.input_label || "输入内容";
|
||||||
@@ -78,7 +73,7 @@ export default function CompletionUI({ app }: CompletionUIProps) {
|
|||||||
const formatTemplates: Record<string, FormatTemplate> = appConfig.format_templates || {};
|
const formatTemplates: Record<string, FormatTemplate> = appConfig.format_templates || {};
|
||||||
const hasFormats = Object.keys(formatTemplates).length > 0;
|
const hasFormats = Object.keys(formatTemplates).length > 0;
|
||||||
|
|
||||||
const CategoryIcon = getCategoryIcon(app.category_slug);
|
const CategoryIconComponent = getCategoryIcon(app.category_slug);
|
||||||
const categoryColor = getCategoryColor(app.category_slug);
|
const categoryColor = getCategoryColor(app.category_slug);
|
||||||
|
|
||||||
const handleGenerate = useCallback(async () => {
|
const handleGenerate = useCallback(async () => {
|
||||||
@@ -121,7 +116,9 @@ export default function CompletionUI({ app }: CompletionUIProps) {
|
|||||||
accumulated += event.answer;
|
accumulated += event.answer;
|
||||||
setOutput(accumulated);
|
setOutput(accumulated);
|
||||||
}
|
}
|
||||||
} catch { /* skip */ }
|
} catch {
|
||||||
|
/* skip */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||||
@@ -163,8 +160,12 @@ export default function CompletionUI({ app }: CompletionUIProps) {
|
|||||||
|
|
||||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
<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="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`}>
|
<div
|
||||||
<CategoryIcon className="h-4 w-4" />
|
className={`flex h-8 w-8 items-center justify-center rounded-lg ${categoryColor} shrink-0`}
|
||||||
|
>
|
||||||
|
{CategoryIconComponent
|
||||||
|
? React.createElement(CategoryIconComponent, { className: "h-4 w-4" })
|
||||||
|
: null}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
|
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
|
||||||
@@ -177,99 +178,114 @@ export default function CompletionUI({ app }: CompletionUIProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<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">
|
<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 && (
|
{hasFormats && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<ListChecks className="h-4 w-4 text-muted-foreground" />
|
<ListChecks className="h-4 w-4 text-muted-foreground" />
|
||||||
<label className="text-sm font-medium">选择输出格式</label>
|
<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>
|
</div>
|
||||||
)}
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-2">
|
||||||
</CardContent>
|
{Object.entries(formatTemplates).map(([key, fmt]) => (
|
||||||
</Card>
|
<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>
|
<Card>
|
||||||
<CardContent className="p-5 space-y-3">
|
<CardContent className="p-5 space-y-3">
|
||||||
<Skeleton className="h-4 w-24" />
|
<label className="text-sm font-medium">{inputLabel}</label>
|
||||||
<Skeleton className="h-4 w-full" />
|
<Textarea
|
||||||
<Skeleton className="h-4 w-full" />
|
value={input}
|
||||||
<Skeleton className="h-4 w-3/4" />
|
onChange={(e) => setInput(e.target.value)}
|
||||||
</CardContent>
|
placeholder={inputPlaceholder}
|
||||||
</Card>
|
className="min-h-[160px] resize-none"
|
||||||
)}
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
{output && (
|
<div className="flex items-center gap-2">
|
||||||
<Card className="border-emerald-200/60 bg-emerald-50/30">
|
<Button
|
||||||
<CardContent className="p-5">
|
onClick={handleGenerate}
|
||||||
<div className="flex items-center justify-between mb-3">
|
disabled={!input.trim() || isLoading}
|
||||||
<label className="text-sm font-medium text-emerald-800">{outputLabel}</label>
|
className="gap-2"
|
||||||
<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" />}
|
{isLoading ? (
|
||||||
{copied ? "已复制" : "复制"}
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{isLoading ? "生成中..." : "生成"}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
<GovMarkdown content={output} />
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
|
||||||
</div>
|
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,9 +65,7 @@ export default function ConversationSidebar({
|
|||||||
const { data: conversations = [] } = useQuery({
|
const { data: conversations = [] } = useQuery({
|
||||||
queryKey: ["conversations", appId, user?.id],
|
queryKey: ["conversations", appId, user?.id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const data = await api.get<{ data: Conversation[] }>(
|
const data = await api.get<{ data: Conversation[] }>(`/api/v1/apps/${appId}/conversations`);
|
||||||
`/api/v1/apps/${appId}/conversations`
|
|
||||||
);
|
|
||||||
return data.data || [];
|
return data.data || [];
|
||||||
},
|
},
|
||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
@@ -100,7 +98,7 @@ export default function ConversationSidebar({
|
|||||||
}
|
}
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
},
|
},
|
||||||
[appId, currentConvId, onNewConversation, queryClient]
|
[appId, currentConvId, onNewConversation, queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirmBatchDelete = useCallback(async () => {
|
const confirmBatchDelete = useCallback(async () => {
|
||||||
@@ -109,8 +107,7 @@ export default function ConversationSidebar({
|
|||||||
await api.post(`/api/v1/apps/${appId}/conversations/batch-delete`, {
|
await api.post(`/api/v1/apps/${appId}/conversations/batch-delete`, {
|
||||||
conversation_ids: Array.from(selectedIds),
|
conversation_ids: Array.from(selectedIds),
|
||||||
});
|
});
|
||||||
if (currentConvId && selectedIds.has(currentConvId))
|
if (currentConvId && selectedIds.has(currentConvId)) onNewConversation();
|
||||||
onNewConversation();
|
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
setSelectMode(false);
|
setSelectMode(false);
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
@@ -123,14 +120,11 @@ export default function ConversationSidebar({
|
|||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
}, [selectedIds, appId, currentConvId, onNewConversation, queryClient]);
|
}, [selectedIds, appId, currentConvId, onNewConversation, queryClient]);
|
||||||
|
|
||||||
const startRename = useCallback(
|
const startRename = useCallback((convId: string, currentName: string) => {
|
||||||
(convId: string, currentName: string) => {
|
setEditingConvId(convId);
|
||||||
setEditingConvId(convId);
|
setEditingName(currentName);
|
||||||
setEditingName(currentName);
|
setTimeout(() => renameInputRef.current?.focus(), 50);
|
||||||
setTimeout(() => renameInputRef.current?.focus(), 50);
|
}, []);
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const saveRename = useCallback(async () => {
|
const saveRename = useCallback(async () => {
|
||||||
if (!editingConvId || !editingName.trim()) {
|
if (!editingConvId || !editingName.trim()) {
|
||||||
@@ -138,10 +132,9 @@ export default function ConversationSidebar({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await api.put(
|
await api.put(`/api/v1/apps/${appId}/conversations/${editingConvId}/name`, {
|
||||||
`/api/v1/apps/${appId}/conversations/${editingConvId}/name`,
|
name: editingName.trim(),
|
||||||
{ name: editingName.trim() }
|
});
|
||||||
);
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["conversations", appId],
|
queryKey: ["conversations", appId],
|
||||||
});
|
});
|
||||||
@@ -155,10 +148,7 @@ export default function ConversationSidebar({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 删除确认弹窗 */}
|
{/* 删除确认弹窗 */}
|
||||||
<AlertDialog
|
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||||
open={!!deleteTarget}
|
|
||||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||||
@@ -229,9 +219,7 @@ export default function ConversationSidebar({
|
|||||||
{conversations.length > 0 && (
|
{conversations.length > 0 && (
|
||||||
<div className="px-3 py-2 border-b flex items-center justify-between shrink-0">
|
<div className="px-3 py-2 border-b flex items-center justify-between shrink-0">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{selectMode
|
{selectMode ? `已选 ${selectedIds.size} 个` : `${conversations.length} 个对话`}
|
||||||
? `已选 ${selectedIds.size} 个`
|
|
||||||
: `${conversations.length} 个对话`}
|
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{selectMode ? (
|
{selectMode ? (
|
||||||
@@ -281,18 +269,13 @@ export default function ConversationSidebar({
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto min-h-0 p-2">
|
<div className="flex-1 overflow-y-auto min-h-0 p-2">
|
||||||
{conversations.length === 0 ? (
|
{conversations.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground text-center py-4">
|
<p className="text-xs text-muted-foreground text-center py-4">暂无对话历史</p>
|
||||||
暂无对话历史
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{conversations.map((conv) => (
|
{conversations.map((conv) => (
|
||||||
<div key={conv.id} className="group flex items-center gap-1">
|
<div key={conv.id} className="group flex items-center gap-1">
|
||||||
{selectMode && (
|
{selectMode && (
|
||||||
<button
|
<button onClick={() => toggleSelect(conv.id)} className="shrink-0 p-0.5">
|
||||||
onClick={() => toggleSelect(conv.id)}
|
|
||||||
className="shrink-0 p-0.5"
|
|
||||||
>
|
|
||||||
{selectedIds.has(conv.id) ? (
|
{selectedIds.has(conv.id) ? (
|
||||||
<CheckSquare className="h-3.5 w-3.5 text-primary" />
|
<CheckSquare className="h-3.5 w-3.5 text-primary" />
|
||||||
) : (
|
) : (
|
||||||
@@ -315,16 +298,10 @@ export default function ConversationSidebar({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() => !selectMode && onSelectConversation(conv.id)}
|
||||||
!selectMode && onSelectConversation(conv.id)
|
onDoubleClick={() => !selectMode && startRename(conv.id, conv.name)}
|
||||||
}
|
|
||||||
onDoubleClick={() =>
|
|
||||||
!selectMode && startRename(conv.id, conv.name)
|
|
||||||
}
|
|
||||||
className={`flex-1 text-left p-2 rounded-md text-sm truncate transition-colors ${
|
className={`flex-1 text-left p-2 rounded-md text-sm truncate transition-colors ${
|
||||||
currentConvId === conv.id
|
currentConvId === conv.id ? "bg-muted font-medium" : "hover:bg-muted/60"
|
||||||
? "bg-muted font-medium"
|
|
||||||
: "hover:bg-muted/60"
|
|
||||||
}`}
|
}`}
|
||||||
title={`${conv.name}\n双击重命名`}
|
title={`${conv.name}\n双击重命名`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
import { useState, useRef, useEffect, useCallback, useMemo, useTransition, memo } from "react";
|
import { useState, useRef, useEffect, useCallback, useMemo, useTransition, memo } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { App, Conversation, Message, DocTemplate, TemplateField, SelectOption } from "@/lib/types";
|
import type {
|
||||||
|
App,
|
||||||
|
Conversation,
|
||||||
|
Message,
|
||||||
|
DocTemplate,
|
||||||
|
TemplateField,
|
||||||
|
SelectOption,
|
||||||
|
} from "@/lib/types";
|
||||||
import api, { streamChat, streamGenerateDoc } from "@/lib/api";
|
import api, { streamChat, streamGenerateDoc } from "@/lib/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
@@ -100,7 +108,7 @@ const FormField = memo(function FormField({
|
|||||||
}) {
|
}) {
|
||||||
if (field.type === "select" && field.options) {
|
if (field.type === "select" && field.options) {
|
||||||
const normalizedOptions = field.options.map((opt) =>
|
const normalizedOptions = field.options.map((opt) =>
|
||||||
typeof opt === "string" ? { value: opt, label: opt } : opt
|
typeof opt === "string" ? { value: opt, label: opt } : opt,
|
||||||
);
|
);
|
||||||
const selectedLabel = normalizedOptions.find((o) => o.value === value)?.label || "";
|
const selectedLabel = normalizedOptions.find((o) => o.value === value)?.label || "";
|
||||||
return (
|
return (
|
||||||
@@ -115,7 +123,9 @@ const FormField = memo(function FormField({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{normalizedOptions.map((opt) => (
|
{normalizedOptions.map((opt) => (
|
||||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -198,9 +208,7 @@ export default function DocWriterUI({ app }: DocWriterUIProps) {
|
|||||||
const { data: conversations = [] } = useQuery({
|
const { data: conversations = [] } = useQuery({
|
||||||
queryKey: ["conversations", app.id],
|
queryKey: ["conversations", app.id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const data = await api.get<{ data: Conversation[] }>(
|
const data = await api.get<{ data: Conversation[] }>(`/api/v1/apps/${app.id}/conversations`);
|
||||||
`/api/v1/apps/${app.id}/conversations`
|
|
||||||
);
|
|
||||||
return data.data || [];
|
return data.data || [];
|
||||||
},
|
},
|
||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
@@ -220,7 +228,7 @@ export default function DocWriterUI({ app }: DocWriterUIProps) {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const CategoryIcon = getCategoryIcon(app.category_slug);
|
const CategoryIconComponent = getCategoryIcon(app.category_slug);
|
||||||
const categoryColor = getCategoryColor(app.category_slug);
|
const categoryColor = getCategoryColor(app.category_slug);
|
||||||
|
|
||||||
const loadConversation = useCallback(
|
const loadConversation = useCallback(
|
||||||
@@ -229,14 +237,14 @@ export default function DocWriterUI({ app }: DocWriterUIProps) {
|
|||||||
setPhase("chat");
|
setPhase("chat");
|
||||||
try {
|
try {
|
||||||
const data = await api.get<{ data: Message[] }>(
|
const data = await api.get<{ data: Message[] }>(
|
||||||
`/api/v1/apps/${app.id}/conversations/${convId}/messages`
|
`/api/v1/apps/${app.id}/conversations/${convId}/messages`,
|
||||||
);
|
);
|
||||||
setMessages(data.data || []);
|
setMessages(data.data || []);
|
||||||
} catch {
|
} catch {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[app.id]
|
[app.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const copyText = useCallback((text: string) => {
|
const copyText = useCallback((text: string) => {
|
||||||
@@ -292,8 +300,7 @@ export default function DocWriterUI({ app }: DocWriterUIProps) {
|
|||||||
return `<p style="font-size:16pt;font-family:黑体,SimHei;font-weight:bold;text-indent:2em;line-height:1.8;">${trimmed}</p>`;
|
return `<p style="font-size:16pt;font-family:黑体,SimHei;font-weight:bold;text-indent:2em;line-height:1.8;">${trimmed}</p>`;
|
||||||
if (/^[((][一二三四五六七八九十]+[))]/.test(trimmed))
|
if (/^[((][一二三四五六七八九十]+[))]/.test(trimmed))
|
||||||
return `<p style="font-size:16pt;font-family:楷体,KaiTi;font-weight:bold;text-indent:2em;line-height:1.8;">${trimmed}</p>`;
|
return `<p style="font-size:16pt;font-family:楷体,KaiTi;font-weight:bold;text-indent:2em;line-height:1.8;">${trimmed}</p>`;
|
||||||
if (/^---+$/.test(trimmed))
|
if (/^---+$/.test(trimmed)) return `<hr style="border-top:1px solid #000;margin:0.5cm 0;" />`;
|
||||||
return `<hr style="border-top:1px solid #000;margin:0.5cm 0;" />`;
|
|
||||||
const formatted = trimmed
|
const formatted = trimmed
|
||||||
.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>")
|
.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>")
|
||||||
.replace(/\*(.+?)\*/g, "<i>$1</i>");
|
.replace(/\*(.+?)\*/g, "<i>$1</i>");
|
||||||
@@ -365,17 +372,20 @@ ${paragraphs.join("\n")}
|
|||||||
setFieldData((prev) => ({ ...prev, [key]: val }));
|
setFieldData((prev) => ({ ...prev, [key]: val }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const selectTemplate = useCallback((tpl: DocTemplate) => {
|
const selectTemplate = useCallback(
|
||||||
setSelectedTemplate(tpl);
|
(tpl: DocTemplate) => {
|
||||||
const defaults: Record<string, string> = {};
|
setSelectedTemplate(tpl);
|
||||||
for (const f of tpl.fields) {
|
const defaults: Record<string, string> = {};
|
||||||
if (f.default) defaults[f.key] = f.default;
|
for (const f of tpl.fields) {
|
||||||
}
|
if (f.default) defaults[f.key] = f.default;
|
||||||
setFieldData(defaults);
|
}
|
||||||
startTransition(() => {
|
setFieldData(defaults);
|
||||||
setPhase("form");
|
startTransition(() => {
|
||||||
});
|
setPhase("form");
|
||||||
}, [startTransition]);
|
});
|
||||||
|
},
|
||||||
|
[startTransition],
|
||||||
|
);
|
||||||
|
|
||||||
const handleGenerateDoc = useCallback(async () => {
|
const handleGenerateDoc = useCallback(async () => {
|
||||||
if (!selectedTemplate || isStreaming) return;
|
if (!selectedTemplate || isStreaming) return;
|
||||||
@@ -399,7 +409,12 @@ ${paragraphs.join("\n")}
|
|||||||
abortRef.current = controller;
|
abortRef.current = controller;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await streamGenerateDoc(app.id, selectedTemplate.id, fieldData, controller.signal);
|
const res = await streamGenerateDoc(
|
||||||
|
app.id,
|
||||||
|
selectedTemplate.id,
|
||||||
|
fieldData,
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
if (!res.ok) throw new Error("请求失败");
|
if (!res.ok) throw new Error("请求失败");
|
||||||
const reader = res.body?.getReader();
|
const reader = res.body?.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
@@ -425,13 +440,13 @@ ${paragraphs.join("\n")}
|
|||||||
const snap = accumulated;
|
const snap = accumulated;
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m, i) =>
|
prev.map((m, i) =>
|
||||||
i === prev.length - 1 && m.role === "assistant"
|
i === prev.length - 1 && m.role === "assistant" ? { ...m, content: snap } : m,
|
||||||
? { ...m, content: snap }
|
),
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch { /* skip */ }
|
} catch {
|
||||||
|
/* skip */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||||
@@ -441,8 +456,8 @@ ${paragraphs.join("\n")}
|
|||||||
prev.map((m, i) =>
|
prev.map((m, i) =>
|
||||||
i === prev.length - 1 && m.role === "assistant" && !m.content
|
i === prev.length - 1 && m.role === "assistant" && !m.content
|
||||||
? { ...m, content: "抱歉,生成公文时发生异常,请重试。" }
|
? { ...m, content: "抱歉,生成公文时发生异常,请重试。" }
|
||||||
: m
|
: m,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
@@ -490,13 +505,13 @@ ${paragraphs.join("\n")}
|
|||||||
const snap = accumulated;
|
const snap = accumulated;
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m, i) =>
|
prev.map((m, i) =>
|
||||||
i === prev.length - 1 && m.role === "assistant"
|
i === prev.length - 1 && m.role === "assistant" ? { ...m, content: snap } : m,
|
||||||
? { ...m, content: snap }
|
),
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch { /* skip */ }
|
} catch {
|
||||||
|
/* skip */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||||
@@ -506,8 +521,8 @@ ${paragraphs.join("\n")}
|
|||||||
prev.map((m, i) =>
|
prev.map((m, i) =>
|
||||||
i === prev.length - 1 && m.role === "assistant" && !m.content
|
i === prev.length - 1 && m.role === "assistant" && !m.content
|
||||||
? { ...m, content: "抱歉,系统处理异常,请稍后重试。" }
|
? { ...m, content: "抱歉,系统处理异常,请稍后重试。" }
|
||||||
: m
|
: m,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
@@ -522,7 +537,7 @@ ${paragraphs.join("\n")}
|
|||||||
sendMessage();
|
sendMessage();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[sendMessage]
|
[sendMessage],
|
||||||
);
|
);
|
||||||
|
|
||||||
const startNewDoc = useCallback(() => {
|
const startNewDoc = useCallback(() => {
|
||||||
@@ -560,7 +575,7 @@ ${paragraphs.join("\n")}
|
|||||||
}
|
}
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
},
|
},
|
||||||
[app.id, conversationId, startNewDoc, queryClient]
|
[app.id, conversationId, startNewDoc, queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirmBatchDelete = useCallback(async () => {
|
const confirmBatchDelete = useCallback(async () => {
|
||||||
@@ -580,14 +595,11 @@ ${paragraphs.join("\n")}
|
|||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
}, [selectedIds, app.id, conversationId, startNewDoc, queryClient]);
|
}, [selectedIds, app.id, conversationId, startNewDoc, queryClient]);
|
||||||
|
|
||||||
const startRename = useCallback(
|
const startRename = useCallback((convId: string, currentName: string) => {
|
||||||
(convId: string, currentName: string) => {
|
setEditingConvId(convId);
|
||||||
setEditingConvId(convId);
|
setEditingName(currentName);
|
||||||
setEditingName(currentName);
|
setTimeout(() => renameInputRef.current?.focus(), 50);
|
||||||
setTimeout(() => renameInputRef.current?.focus(), 50);
|
}, []);
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const saveRename = useCallback(async () => {
|
const saveRename = useCallback(async () => {
|
||||||
if (!editingConvId || !editingName.trim()) {
|
if (!editingConvId || !editingName.trim()) {
|
||||||
@@ -595,10 +607,9 @@ ${paragraphs.join("\n")}
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await api.put(
|
await api.put(`/api/v1/apps/${app.id}/conversations/${editingConvId}/name`, {
|
||||||
`/api/v1/apps/${app.id}/conversations/${editingConvId}/name`,
|
name: editingName.trim(),
|
||||||
{ name: editingName.trim() }
|
});
|
||||||
);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||||
toast.success("已重命名");
|
toast.success("已重命名");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -609,17 +620,12 @@ ${paragraphs.join("\n")}
|
|||||||
|
|
||||||
const filledRequired = useMemo(() => {
|
const filledRequired = useMemo(() => {
|
||||||
if (!selectedTemplate) return false;
|
if (!selectedTemplate) return false;
|
||||||
return selectedTemplate.fields
|
return selectedTemplate.fields.filter((f) => f.required).every((f) => fieldData[f.key]?.trim());
|
||||||
.filter((f) => f.required)
|
|
||||||
.every((f) => fieldData[f.key]?.trim());
|
|
||||||
}, [selectedTemplate, fieldData]);
|
}, [selectedTemplate, fieldData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
||||||
<AlertDialog
|
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||||
open={!!deleteTarget}
|
|
||||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||||
@@ -656,7 +662,9 @@ ${paragraphs.join("\n")}
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* 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={`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">
|
<div className="p-3 border-b space-y-2 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -666,7 +674,11 @@ ${paragraphs.join("\n")}
|
|||||||
>
|
>
|
||||||
<ArrowLeft className="h-3.5 w-3.5" /> 返回应用中心
|
<ArrowLeft className="h-3.5 w-3.5" /> 返回应用中心
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={startNewDoc} className="w-full gap-1.5 bg-blue-900 hover:bg-blue-800 text-white" size="sm">
|
<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" /> 新建公文
|
<Plus className="h-3.5 w-3.5" /> 新建公文
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -679,7 +691,12 @@ ${paragraphs.join("\n")}
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{selectMode ? (
|
{selectMode ? (
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-xs" onClick={selectAll}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-1.5 text-xs"
|
||||||
|
onClick={selectAll}
|
||||||
|
>
|
||||||
全选
|
全选
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -695,13 +712,21 @@ ${paragraphs.join("\n")}
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 px-1.5 text-xs"
|
className="h-6 px-1.5 text-xs"
|
||||||
onClick={() => { setSelectMode(false); setSelectedIds(new Set()); }}
|
onClick={() => {
|
||||||
|
setSelectMode(false);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-xs" onClick={() => setSelectMode(true)}>
|
<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" /> 管理
|
<CheckSquare className="h-3 w-3 mr-0.5" /> 管理
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -711,9 +736,7 @@ ${paragraphs.join("\n")}
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto min-h-0 p-2">
|
<div className="flex-1 overflow-y-auto min-h-0 p-2">
|
||||||
{conversations.length === 0 ? (
|
{conversations.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground text-center py-4">
|
<p className="text-xs text-muted-foreground text-center py-4">暂无生成记录</p>
|
||||||
暂无生成记录
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{conversations.map((conv) => (
|
{conversations.map((conv) => (
|
||||||
@@ -794,8 +817,12 @@ ${paragraphs.join("\n")}
|
|||||||
>
|
>
|
||||||
<PanelLeftOpen className="h-4 w-4" />
|
<PanelLeftOpen className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<div className={`flex h-9 w-9 items-center justify-center rounded-xl ${categoryColor} shrink-0`}>
|
<div
|
||||||
<CategoryIcon className="h-4.5 w-4.5" />
|
className={`flex h-9 w-9 items-center justify-center rounded-xl ${categoryColor} shrink-0`}
|
||||||
|
>
|
||||||
|
{CategoryIconComponent
|
||||||
|
? React.createElement(CategoryIconComponent, { className: "h-4.5 w-4.5" })
|
||||||
|
: null}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
|
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
|
||||||
@@ -806,10 +833,20 @@ ${paragraphs.join("\n")}
|
|||||||
<div className="ml-auto flex items-center gap-1 md:gap-2">
|
<div className="ml-auto flex items-center gap-1 md:gap-2">
|
||||||
{messages.some((m) => m.role === "assistant" && m.content) && (
|
{messages.some((m) => m.role === "assistant" && m.content) && (
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm" className="h-7 gap-1.5 text-xs" onClick={exportAsWord}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1.5 text-xs"
|
||||||
|
onClick={exportAsWord}
|
||||||
|
>
|
||||||
<Download className="h-3 w-3" /> 下载Word
|
<Download className="h-3 w-3" /> 下载Word
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs" onClick={exportConversation}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1.5 text-xs"
|
||||||
|
onClick={exportConversation}
|
||||||
|
>
|
||||||
<Download className="h-3 w-3" /> 导出TXT
|
<Download className="h-3 w-3" /> 导出TXT
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -832,10 +869,13 @@ ${paragraphs.join("\n")}
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 ${isPending ? "opacity-60 pointer-events-none" : ""}`}>
|
<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) => {
|
{templates.map((tpl) => {
|
||||||
const Icon = iconMap[tpl.icon] || FileText;
|
const Icon = iconMap[tpl.icon] || FileText;
|
||||||
const colorClass = typeColorMap[tpl.doc_type] || "bg-gray-50 text-gray-700 border-gray-200";
|
const colorClass =
|
||||||
|
typeColorMap[tpl.doc_type] || "bg-gray-50 text-gray-700 border-gray-200";
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tpl.id}
|
key={tpl.id}
|
||||||
@@ -865,7 +905,12 @@ ${paragraphs.join("\n")}
|
|||||||
{phase === "form" && selectedTemplate && (
|
{phase === "form" && selectedTemplate && (
|
||||||
<div className="p-4 md:p-6 max-w-3xl mx-auto">
|
<div className="p-4 md:p-6 max-w-3xl mx-auto">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<Button variant="ghost" size="sm" onClick={() => setPhase("select")} className="gap-1">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPhase("select")}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
<ChevronLeft className="h-4 w-4" /> 返回选择
|
<ChevronLeft className="h-4 w-4" /> 返回选择
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -878,7 +923,8 @@ ${paragraphs.join("\n")}
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground mb-5">
|
<p className="text-sm text-muted-foreground mb-5">
|
||||||
{selectedTemplate.description}。请填写以下信息,<span className="text-destructive">*</span> 为必填项。
|
{selectedTemplate.description}。请填写以下信息,
|
||||||
|
<span className="text-destructive">*</span> 为必填项。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
import { useState, useMemo, useCallback, useRef } from "react";
|
import { useState, useMemo, useCallback, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -100,7 +101,9 @@ export default function WorkflowUI({ app }: WorkflowUIProps) {
|
|||||||
try {
|
try {
|
||||||
if (typeof app.app_config === "string") return JSON.parse(app.app_config);
|
if (typeof app.app_config === "string") return JSON.parse(app.app_config);
|
||||||
return app.app_config || {};
|
return app.app_config || {};
|
||||||
} catch { return {}; }
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
}, [app.app_config]);
|
}, [app.app_config]);
|
||||||
|
|
||||||
const steps: WorkflowStep[] = appConfig.steps || [];
|
const steps: WorkflowStep[] = appConfig.steps || [];
|
||||||
@@ -125,7 +128,7 @@ export default function WorkflowUI({ app }: WorkflowUIProps) {
|
|||||||
setConversationId(convId);
|
setConversationId(convId);
|
||||||
try {
|
try {
|
||||||
const data = await api.get<{ data: Message[] }>(
|
const data = await api.get<{ data: Message[] }>(
|
||||||
`/api/v1/apps/${app.id}/conversations/${convId}/messages`
|
`/api/v1/apps/${app.id}/conversations/${convId}/messages`,
|
||||||
);
|
);
|
||||||
const msgs = data.data || [];
|
const msgs = data.data || [];
|
||||||
const aiMsg = msgs.find((m) => m.role === "assistant");
|
const aiMsg = msgs.find((m) => m.role === "assistant");
|
||||||
@@ -137,10 +140,10 @@ export default function WorkflowUI({ app }: WorkflowUIProps) {
|
|||||||
setOutput("");
|
setOutput("");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[app.id]
|
[app.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const CategoryIcon = getCategoryIcon(app.category_slug);
|
const CategoryIconComponent = getCategoryIcon(app.category_slug);
|
||||||
const categoryColor = getCategoryColor(app.category_slug);
|
const categoryColor = getCategoryColor(app.category_slug);
|
||||||
|
|
||||||
const currentStepData = steps[currentStep];
|
const currentStepData = steps[currentStep];
|
||||||
@@ -241,8 +244,12 @@ export default function WorkflowUI({ app }: WorkflowUIProps) {
|
|||||||
|
|
||||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
<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="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`}>
|
<div
|
||||||
<CategoryIcon className="h-4 w-4" />
|
className={`flex h-8 w-8 items-center justify-center rounded-lg ${categoryColor} shrink-0`}
|
||||||
|
>
|
||||||
|
{CategoryIconComponent
|
||||||
|
? React.createElement(CategoryIconComponent, { className: "h-4 w-4" })
|
||||||
|
: null}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
|
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
|
||||||
@@ -255,129 +262,185 @@ export default function WorkflowUI({ app }: WorkflowUIProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<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="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">
|
<div className="flex items-center gap-2 mb-8 overflow-x-auto pb-2">
|
||||||
{steps.map((step, idx) => {
|
{steps.map((step, idx) => {
|
||||||
const done = isComplete || idx < currentStep;
|
const done = isComplete || idx < currentStep;
|
||||||
const active = idx === currentStep && !isComplete;
|
const active = idx === currentStep && !isComplete;
|
||||||
return (
|
return (
|
||||||
<div key={step.key} className="flex items-center gap-2 shrink-0">
|
<div key={step.key} className="flex items-center gap-2 shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => !isComplete && !isLoading && setCurrentStep(idx)}
|
onClick={() => !isComplete && !isLoading && setCurrentStep(idx)}
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm transition-colors ${
|
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"
|
active
|
||||||
}`}
|
? "bg-purple-100 text-purple-700 font-medium"
|
||||||
>
|
: done
|
||||||
{done ? <CheckCircle2 className="h-4 w-4" /> : active ? <CircleDot className="h-4 w-4" /> : <Circle className="h-4 w-4" />}
|
? "bg-emerald-50 text-emerald-700"
|
||||||
{step.label}
|
: "text-muted-foreground hover:bg-muted"
|
||||||
</button>
|
}`}
|
||||||
{idx < steps.length - 1 && <ChevronRight className="h-4 w-4 text-muted-foreground/50 shrink-0" />}
|
>
|
||||||
</div>
|
{done ? (
|
||||||
);
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
})}
|
) : active ? (
|
||||||
{isComplete && (
|
<CircleDot className="h-4 w-4" />
|
||||||
<>
|
) : (
|
||||||
<ChevronRight className="h-4 w-4 text-muted-foreground/50 shrink-0" />
|
<Circle className="h-4 w-4" />
|
||||||
<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" />
|
{step.label}
|
||||||
完成
|
</button>
|
||||||
</div>
|
{idx < steps.length - 1 && (
|
||||||
</>
|
<ChevronRight className="h-4 w-4 text-muted-foreground/50 shrink-0" />
|
||||||
)}
|
|
||||||
</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>
|
||||||
</div>
|
);
|
||||||
<GovMarkdown content={output} />
|
})}
|
||||||
</CardContent>
|
{isComplete && (
|
||||||
</Card>
|
<>
|
||||||
)}
|
<ChevronRight className="h-4 w-4 text-muted-foreground/50 shrink-0" />
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Component, type ReactNode } from "react";
|
||||||
|
import { AlertTriangle, RefreshCw, Home } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error?: Error;
|
||||||
|
errorId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
const id = Math.random().toString(36).slice(2, 10);
|
||||||
|
console.error(`[ErrorBoundary:${id}]`, error);
|
||||||
|
return { hasError: true, error, errorId: id };
|
||||||
|
}
|
||||||
|
|
||||||
|
reset = () => this.setState({ hasError: false, error: undefined, errorId: undefined });
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) return <>{this.props.fallback}</>;
|
||||||
|
|
||||||
|
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">
|
||||||
|
<AlertTriangle 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>
|
||||||
|
{this.state.errorId && (
|
||||||
|
<p className="text-xs text-muted-foreground/60 mb-4 font-mono">
|
||||||
|
错误标识:{this.state.errorId}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="outline" onClick={this.reset} className="gap-2">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
<Link href="/store">
|
||||||
|
<Button variant="secondary" className="gap-2">
|
||||||
|
<Home className="h-4 w-4" />
|
||||||
|
返回首页
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,19 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Search, Shield, Building2, Check, Menu, X, Store, LayoutDashboard, PenSquare, BookOpen, Settings } from "lucide-react";
|
import {
|
||||||
|
Search,
|
||||||
|
Shield,
|
||||||
|
Building2,
|
||||||
|
Check,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
Store,
|
||||||
|
LayoutDashboard,
|
||||||
|
PenSquare,
|
||||||
|
BookOpen,
|
||||||
|
Settings,
|
||||||
|
} from "lucide-react";
|
||||||
import { useAuthStore as _useAuthStore } from "@/stores/auth";
|
import { useAuthStore as _useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,7 +54,10 @@ export function Header() {
|
|||||||
const brandName = getOrgBrand(user?.org);
|
const brandName = getOrgBrand(user?.org);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get<Organization[]>("/api/v1/organizations").then(setOrgs).catch(() => {});
|
api
|
||||||
|
.get<Organization[]>("/api/v1/organizations")
|
||||||
|
.then(setOrgs)
|
||||||
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
@@ -74,9 +89,7 @@ export function Header() {
|
|||||||
|
|
||||||
<Link href="/store" className="flex items-center gap-2 font-bold text-lg shrink-0">
|
<Link href="/store" className="flex items-center gap-2 font-bold text-lg shrink-0">
|
||||||
<Shield className="h-5 w-5 text-amber-400" />
|
<Shield className="h-5 w-5 text-amber-400" />
|
||||||
<span className="text-white tracking-wide">
|
<span className="text-white tracking-wide">{brandName}</span>
|
||||||
{brandName}
|
|
||||||
</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="hidden md:block h-5 w-px bg-white/20 mx-1" />
|
<div className="hidden md:block h-5 w-px bg-white/20 mx-1" />
|
||||||
@@ -86,33 +99,58 @@ export function Header() {
|
|||||||
{!isSuperAdmin && (
|
{!isSuperAdmin && (
|
||||||
<>
|
<>
|
||||||
<Link href="/store">
|
<Link href="/store">
|
||||||
<Button variant="ghost" size="sm" className="gap-1.5 text-blue-100 hover:text-white hover:bg-white/10">
|
<Button
|
||||||
<Store className="h-4 w-4" />应用中心
|
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>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/workspace">
|
<Link href="/workspace">
|
||||||
<Button variant="ghost" size="sm" className="gap-1.5 text-blue-100 hover:text-white hover:bg-white/10">
|
<Button
|
||||||
<LayoutDashboard className="h-4 w-4" />我的工作台
|
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>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
{isCreator && (
|
{isCreator && (
|
||||||
<Link href="/create">
|
<Link href="/create">
|
||||||
<Button variant="ghost" size="sm" className="gap-1.5 text-blue-100 hover:text-white hover:bg-white/10">
|
<Button
|
||||||
<PenSquare className="h-4 w-4" />应用管理
|
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>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{isCreator && (
|
{isCreator && (
|
||||||
<Link href="/knowledge">
|
<Link href="/knowledge">
|
||||||
<Button variant="ghost" size="sm" className="gap-1.5 text-blue-100 hover:text-white hover:bg-white/10">
|
<Button
|
||||||
<BookOpen className="h-4 w-4" />知识库
|
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>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Link href="/dashboard">
|
<Link href="/dashboard">
|
||||||
<Button variant="ghost" size="sm" className="gap-1.5 text-blue-100 hover:text-white hover:bg-white/10">
|
<Button
|
||||||
<Settings className="h-4 w-4" />管理控制台
|
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>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
@@ -121,13 +159,23 @@ export function Header() {
|
|||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<>
|
<>
|
||||||
<Link href="/platform/overview">
|
<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">
|
<Button
|
||||||
<Shield className="h-4 w-4" />平台管理
|
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>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/knowledge">
|
<Link href="/knowledge">
|
||||||
<Button variant="ghost" size="sm" className="gap-1.5 text-blue-100 hover:text-white hover:bg-white/10">
|
<Button
|
||||||
<BookOpen className="h-4 w-4" />知识库
|
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>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
@@ -155,7 +203,9 @@ export function Header() {
|
|||||||
<DropdownMenuTrigger className="relative h-8 w-8 rounded-full focus:outline-none ring-offset-blue-900">
|
<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">
|
<Avatar className="h-8 w-8 border border-white/30">
|
||||||
<AvatarImage src={user?.avatar_url} alt={user?.name} />
|
<AvatarImage src={user?.avatar_url} alt={user?.name} />
|
||||||
<AvatarFallback className="bg-blue-800 text-white text-sm">{user?.name?.charAt(0) || "U"}</AvatarFallback>
|
<AvatarFallback className="bg-blue-800 text-white text-sm">
|
||||||
|
{user?.name?.charAt(0) || "U"}
|
||||||
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="w-56" align="end">
|
<DropdownMenuContent className="w-56" align="end">
|
||||||
@@ -190,9 +240,7 @@ export function Header() {
|
|||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleLogout}>
|
<DropdownMenuItem onClick={handleLogout}>退出登录</DropdownMenuItem>
|
||||||
退出登录
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,7 +250,13 @@ export function Header() {
|
|||||||
{mobileMenuOpen && (
|
{mobileMenuOpen && (
|
||||||
<div className="md:hidden border-t border-white/10 bg-blue-950/95 backdrop-blur-sm">
|
<div className="md:hidden border-t border-white/10 bg-blue-950/95 backdrop-blur-sm">
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<form onSubmit={(e) => { handleSearch(e); setMobileMenuOpen(false); }} className="relative mb-2 sm:hidden">
|
<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" />
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-blue-300" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索政务应用..."
|
placeholder="搜索政务应用..."
|
||||||
@@ -221,33 +275,58 @@ export function Header() {
|
|||||||
{!isSuperAdmin && (
|
{!isSuperAdmin && (
|
||||||
<>
|
<>
|
||||||
<Link href="/store" onClick={() => setMobileMenuOpen(false)}>
|
<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">
|
<Button
|
||||||
<Store className="h-4 w-4" />应用中心
|
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>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/workspace" onClick={() => setMobileMenuOpen(false)}>
|
<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">
|
<Button
|
||||||
<LayoutDashboard className="h-4 w-4" />我的工作台
|
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>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
{isCreator && (
|
{isCreator && (
|
||||||
<Link href="/create" onClick={() => setMobileMenuOpen(false)}>
|
<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">
|
<Button
|
||||||
<PenSquare className="h-4 w-4" />应用管理
|
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>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{isCreator && (
|
{isCreator && (
|
||||||
<Link href="/knowledge" onClick={() => setMobileMenuOpen(false)}>
|
<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">
|
<Button
|
||||||
<BookOpen className="h-4 w-4" />知识库
|
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>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Link href="/dashboard" onClick={() => setMobileMenuOpen(false)}>
|
<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">
|
<Button
|
||||||
<Settings className="h-4 w-4" />管理控制台
|
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>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
@@ -256,13 +335,23 @@ export function Header() {
|
|||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<>
|
<>
|
||||||
<Link href="/platform/overview" onClick={() => setMobileMenuOpen(false)}>
|
<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">
|
<Button
|
||||||
<Shield className="h-4 w-4" />平台管理
|
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>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/knowledge" onClick={() => setMobileMenuOpen(false)}>
|
<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">
|
<Button
|
||||||
<BookOpen className="h-4 w-4" />知识库
|
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>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,18 +2,42 @@
|
|||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useRef, useEffect } from "react";
|
import { useRef, useEffect } from "react";
|
||||||
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
// 公开路由(无需鉴权)
|
||||||
|
const PUBLIC_PATHS = ["/login", "/register"];
|
||||||
|
|
||||||
function AuthLoader({ children }: { children: React.ReactNode }) {
|
function AuthLoader({ children }: { children: React.ReactNode }) {
|
||||||
const fetchUser = useAuthStore((s) => s.fetchUser);
|
const fetchUser = useAuthStore((s) => s.fetchUser);
|
||||||
const isLoading = useAuthStore((s) => s.isLoading);
|
const isLoading = useAuthStore((s) => s.isLoading);
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const isPublic = PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith(p + "/"));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUser();
|
if (!isPublic) {
|
||||||
}, [fetchUser]);
|
fetchUser();
|
||||||
|
}
|
||||||
|
}, [fetchUser, isPublic]);
|
||||||
|
|
||||||
if (isLoading) {
|
// 鉴权路由:未登录则跳转(useEffect 中执行,避免渲染期间调用 router)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !isAuthenticated && !isPublic) {
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
}, [isLoading, isAuthenticated, isPublic, router]);
|
||||||
|
|
||||||
|
// 公开路由:直接渲染,不阻塞
|
||||||
|
if (isPublic) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载中或未认证
|
||||||
|
if (isLoading || !isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center">
|
<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 className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
@@ -24,23 +48,21 @@ function AuthLoader({ children }: { children: React.ReactNode }) {
|
|||||||
return <>{children}</>;
|
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 }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
const queryClientRef = useRef<QueryClient>(null);
|
const queryClientRef = useRef<QueryClient>(null);
|
||||||
if (!queryClientRef.current) {
|
if (!queryClientRef.current) {
|
||||||
queryClientRef.current = createQueryClient();
|
queryClientRef.current = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
// 高频变更数据(审计、用户列表)
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
gcTime: 5 * 60 * 1000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: 1,
|
||||||
|
refetchOnMount: "always",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,41 +1,34 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||||
return (
|
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
|
||||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||||
return (
|
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
|
||||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogOverlay({
|
function AlertDialogOverlay({ className, ...props }: AlertDialogPrimitive.Backdrop.Props) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: AlertDialogPrimitive.Backdrop.Props) {
|
|
||||||
return (
|
return (
|
||||||
<AlertDialogPrimitive.Backdrop
|
<AlertDialogPrimitive.Backdrop
|
||||||
data-slot="alert-dialog-overlay"
|
data-slot="alert-dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 isolate z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs",
|
"fixed inset-0 isolate z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogContent({
|
function AlertDialogContent({
|
||||||
@@ -43,7 +36,7 @@ function AlertDialogContent({
|
|||||||
size = "default",
|
size = "default",
|
||||||
...props
|
...props
|
||||||
}: AlertDialogPrimitive.Popup.Props & {
|
}: AlertDialogPrimitive.Popup.Props & {
|
||||||
size?: "default" | "sm"
|
size?: "default" | "sm";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<AlertDialogPortal>
|
<AlertDialogPortal>
|
||||||
@@ -53,60 +46,51 @@ function AlertDialogContent({
|
|||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</AlertDialogPortal>
|
</AlertDialogPortal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogHeader({
|
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div">) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="alert-dialog-header"
|
data-slot="alert-dialog-header"
|
||||||
className={cn(
|
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]",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogFooter({
|
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div">) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="alert-dialog-footer"
|
data-slot="alert-dialog-footer"
|
||||||
className={cn(
|
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",
|
"-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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogMedia({
|
function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div">) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="alert-dialog-media"
|
data-slot="alert-dialog-media"
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogTitle({
|
function AlertDialogTitle({
|
||||||
@@ -118,11 +102,11 @@ function AlertDialogTitle({
|
|||||||
data-slot="alert-dialog-title"
|
data-slot="alert-dialog-title"
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogDescription({
|
function AlertDialogDescription({
|
||||||
@@ -134,24 +118,15 @@ function AlertDialogDescription({
|
|||||||
data-slot="alert-dialog-description"
|
data-slot="alert-dialog-description"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogAction({
|
function AlertDialogAction({ className, ...props }: React.ComponentProps<typeof Button>) {
|
||||||
className,
|
return <Button data-slot="alert-dialog-action" className={cn(className)} {...props} />;
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof Button>) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
data-slot="alert-dialog-action"
|
|
||||||
className={cn(className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogCancel({
|
function AlertDialogCancel({
|
||||||
@@ -168,7 +143,7 @@ function AlertDialogCancel({
|
|||||||
render={<Button variant={variant} size={size} />}
|
render={<Button variant={variant} size={size} />}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -184,4 +159,4 @@ export {
|
|||||||
AlertDialogPortal,
|
AlertDialogPortal,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Avatar({
|
function Avatar({
|
||||||
className,
|
className,
|
||||||
size = "default",
|
size = "default",
|
||||||
...props
|
...props
|
||||||
}: AvatarPrimitive.Root.Props & {
|
}: AvatarPrimitive.Root.Props & {
|
||||||
size?: "default" | "sm" | "lg"
|
size?: "default" | "sm" | "lg";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<AvatarPrimitive.Root
|
<AvatarPrimitive.Root
|
||||||
@@ -18,40 +18,34 @@ function Avatar({
|
|||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||||
return (
|
return (
|
||||||
<AvatarPrimitive.Image
|
<AvatarPrimitive.Image
|
||||||
data-slot="avatar-image"
|
data-slot="avatar-image"
|
||||||
className={cn(
|
className={cn("aspect-square size-full rounded-full object-cover", className)}
|
||||||
"aspect-square size-full rounded-full object-cover",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AvatarFallback({
|
function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: AvatarPrimitive.Fallback.Props) {
|
|
||||||
return (
|
return (
|
||||||
<AvatarPrimitive.Fallback
|
<AvatarPrimitive.Fallback
|
||||||
data-slot="avatar-fallback"
|
data-slot="avatar-fallback"
|
||||||
className={cn(
|
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",
|
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
@@ -63,11 +57,11 @@ function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
|||||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
"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=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",
|
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -76,34 +70,24 @@ function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="avatar-group"
|
data-slot="avatar-group"
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AvatarGroupCount({
|
function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div">) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="avatar-group-count"
|
data-slot="avatar-group-count"
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export { Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarBadge };
|
||||||
Avatar,
|
|
||||||
AvatarImage,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarGroup,
|
|
||||||
AvatarGroupCount,
|
|
||||||
AvatarBadge,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { mergeProps } from "@base-ui/react/merge-props"
|
import { mergeProps } from "@base-ui/react/merge-props";
|
||||||
import { useRender } from "@base-ui/react/use-render"
|
import { useRender } from "@base-ui/react/use-render";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
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!",
|
"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!",
|
||||||
@@ -10,22 +10,19 @@ const badgeVariants = cva(
|
|||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
secondary:
|
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
|
||||||
destructive:
|
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",
|
"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:
|
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
"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",
|
||||||
ghost:
|
|
||||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function Badge({
|
function Badge({
|
||||||
className,
|
className,
|
||||||
@@ -39,14 +36,14 @@ function Badge({
|
|||||||
{
|
{
|
||||||
className: cn(badgeVariants({ variant }), className),
|
className: cn(badgeVariants({ variant }), className),
|
||||||
},
|
},
|
||||||
props
|
props,
|
||||||
),
|
),
|
||||||
render,
|
render,
|
||||||
state: {
|
state: {
|
||||||
slot: "badge",
|
slot: "badge",
|
||||||
variant,
|
variant,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
export { Badge, badgeVariants };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
import { Button as ButtonPrimitive } from "@base-ui/react/button";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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",
|
"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",
|
||||||
@@ -37,8 +37,8 @@ const buttonVariants = cva(
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
@@ -52,7 +52,7 @@ function Button({
|
|||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Card({
|
function Card({
|
||||||
className,
|
className,
|
||||||
@@ -13,11 +13,11 @@ function Card({
|
|||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -26,11 +26,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="card-header"
|
data-slot="card-header"
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -39,11 +39,11 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="card-title"
|
data-slot="card-title"
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -53,20 +53,17 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-action"
|
data-slot="card-action"
|
||||||
className={cn(
|
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -76,7 +73,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -85,19 +82,11 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="card-footer"
|
data-slot="card-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardFooter,
|
|
||||||
CardTitle,
|
|
||||||
CardAction,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,36 +1,30 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Command as CommandPrimitive } from "cmdk"
|
import { Command as CommandPrimitive } from "cmdk";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group";
|
||||||
InputGroup,
|
import { SearchIcon, CheckIcon } from "lucide-react";
|
||||||
InputGroupAddon,
|
|
||||||
} from "@/components/ui/input-group"
|
|
||||||
import { SearchIcon, CheckIcon } from "lucide-react"
|
|
||||||
|
|
||||||
function Command({
|
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
|
||||||
return (
|
return (
|
||||||
<CommandPrimitive
|
<CommandPrimitive
|
||||||
data-slot="command"
|
data-slot="command"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
|
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandDialog({
|
function CommandDialog({
|
||||||
@@ -41,11 +35,11 @@ function CommandDialog({
|
|||||||
showCloseButton = false,
|
showCloseButton = false,
|
||||||
...props
|
...props
|
||||||
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
|
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
|
||||||
title?: string
|
title?: string;
|
||||||
description?: string
|
description?: string;
|
||||||
className?: string
|
className?: string;
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean;
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Dialog {...props}>
|
<Dialog {...props}>
|
||||||
@@ -54,16 +48,13 @@ function CommandDialog({
|
|||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>{description}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className={cn(
|
className={cn("top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0", className)}
|
||||||
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
showCloseButton={showCloseButton}
|
showCloseButton={showCloseButton}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandInput({
|
function CommandInput({
|
||||||
@@ -77,7 +68,7 @@ function CommandInput({
|
|||||||
data-slot="command-input"
|
data-slot="command-input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -86,23 +77,20 @@ function CommandInput({
|
|||||||
</InputGroupAddon>
|
</InputGroupAddon>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandList({
|
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
|
||||||
return (
|
return (
|
||||||
<CommandPrimitive.List
|
<CommandPrimitive.List
|
||||||
data-slot="command-list"
|
data-slot="command-list"
|
||||||
className={cn(
|
className={cn(
|
||||||
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
|
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandEmpty({
|
function CommandEmpty({
|
||||||
@@ -115,7 +103,7 @@ function CommandEmpty({
|
|||||||
className={cn("py-6 text-center text-sm", className)}
|
className={cn("py-6 text-center text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandGroup({
|
function CommandGroup({
|
||||||
@@ -127,11 +115,11 @@ function CommandGroup({
|
|||||||
data-slot="command-group"
|
data-slot="command-group"
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandSeparator({
|
function CommandSeparator({
|
||||||
@@ -144,7 +132,7 @@ function CommandSeparator({
|
|||||||
className={cn("-mx-1 h-px bg-border", className)}
|
className={cn("-mx-1 h-px bg-border", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandItem({
|
function CommandItem({
|
||||||
@@ -157,30 +145,27 @@ function CommandItem({
|
|||||||
data-slot="command-item"
|
data-slot="command-item"
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
<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>
|
</CommandPrimitive.Item>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandShortcut({
|
function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) {
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-slot="command-shortcut"
|
data-slot="command-shortcut"
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
|
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -193,4 +178,4 @@ export {
|
|||||||
CommandItem,
|
CommandItem,
|
||||||
CommandShortcut,
|
CommandShortcut,
|
||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,42 +1,39 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogOverlay({
|
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: DialogPrimitive.Backdrop.Props) {
|
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Backdrop
|
<DialogPrimitive.Backdrop
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 isolate z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs",
|
"fixed inset-0 isolate z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogContent({
|
function DialogContent({
|
||||||
@@ -45,7 +42,7 @@ function DialogContent({
|
|||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
...props
|
...props
|
||||||
}: DialogPrimitive.Popup.Props & {
|
}: DialogPrimitive.Popup.Props & {
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
@@ -54,7 +51,7 @@ function DialogContent({
|
|||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -62,32 +59,21 @@ function DialogContent({
|
|||||||
{showCloseButton && (
|
{showCloseButton && (
|
||||||
<DialogPrimitive.Close
|
<DialogPrimitive.Close
|
||||||
data-slot="dialog-close"
|
data-slot="dialog-close"
|
||||||
render={
|
render={<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />}
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="absolute top-2 right-2"
|
|
||||||
size="icon-sm"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<XIcon
|
<XIcon />
|
||||||
/>
|
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
</DialogPrimitive.Popup>
|
</DialogPrimitive.Popup>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />
|
||||||
data-slot="dialog-header"
|
);
|
||||||
className={cn("flex flex-col gap-2", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogFooter({
|
function DialogFooter({
|
||||||
@@ -96,54 +82,46 @@ function DialogFooter({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="dialog-footer"
|
data-slot="dialog-footer"
|
||||||
className={cn(
|
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",
|
"-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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{showCloseButton && (
|
{showCloseButton && (
|
||||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
<DialogPrimitive.Close render={<Button variant="outline" />}>Close</DialogPrimitive.Close>
|
||||||
Close
|
|
||||||
</DialogPrimitive.Close>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
data-slot="dialog-title"
|
data-slot="dialog-title"
|
||||||
className={cn(
|
className={cn("font-heading text-base leading-none font-medium", className)}
|
||||||
"font-heading text-base leading-none font-medium",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogDescription({
|
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: DialogPrimitive.Description.Props) {
|
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
data-slot="dialog-description"
|
data-slot="dialog-description"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -157,4 +135,4 @@ export {
|
|||||||
DialogPortal,
|
DialogPortal,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
import { ChevronRightIcon, CheckIcon } from "lucide-react";
|
||||||
|
|
||||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuContent({
|
function DropdownMenuContent({
|
||||||
@@ -26,10 +26,7 @@ function DropdownMenuContent({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: MenuPrimitive.Popup.Props &
|
}: MenuPrimitive.Popup.Props &
|
||||||
Pick<
|
Pick<MenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
|
||||||
MenuPrimitive.Positioner.Props,
|
|
||||||
"align" | "alignOffset" | "side" | "sideOffset"
|
|
||||||
>) {
|
|
||||||
return (
|
return (
|
||||||
<MenuPrimitive.Portal>
|
<MenuPrimitive.Portal>
|
||||||
<MenuPrimitive.Positioner
|
<MenuPrimitive.Positioner
|
||||||
@@ -41,16 +38,19 @@ function DropdownMenuContent({
|
|||||||
>
|
>
|
||||||
<MenuPrimitive.Popup
|
<MenuPrimitive.Popup
|
||||||
data-slot="dropdown-menu-content"
|
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 )}
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</MenuPrimitive.Positioner>
|
</MenuPrimitive.Positioner>
|
||||||
</MenuPrimitive.Portal>
|
</MenuPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuLabel({
|
function DropdownMenuLabel({
|
||||||
@@ -58,7 +58,7 @@ function DropdownMenuLabel({
|
|||||||
inset,
|
inset,
|
||||||
...props
|
...props
|
||||||
}: MenuPrimitive.GroupLabel.Props & {
|
}: MenuPrimitive.GroupLabel.Props & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<MenuPrimitive.GroupLabel
|
<MenuPrimitive.GroupLabel
|
||||||
@@ -66,11 +66,11 @@ function DropdownMenuLabel({
|
|||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuItem({
|
function DropdownMenuItem({
|
||||||
@@ -79,8 +79,8 @@ function DropdownMenuItem({
|
|||||||
variant = "default",
|
variant = "default",
|
||||||
...props
|
...props
|
||||||
}: MenuPrimitive.Item.Props & {
|
}: MenuPrimitive.Item.Props & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
variant?: "default" | "destructive"
|
variant?: "default" | "destructive";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<MenuPrimitive.Item
|
<MenuPrimitive.Item
|
||||||
@@ -89,15 +89,15 @@ function DropdownMenuItem({
|
|||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSubTrigger({
|
function DropdownMenuSubTrigger({
|
||||||
@@ -106,7 +106,7 @@ function DropdownMenuSubTrigger({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<MenuPrimitive.SubmenuTrigger
|
<MenuPrimitive.SubmenuTrigger
|
||||||
@@ -114,14 +114,14 @@ function DropdownMenuSubTrigger({
|
|||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRightIcon className="ml-auto" />
|
<ChevronRightIcon className="ml-auto" />
|
||||||
</MenuPrimitive.SubmenuTrigger>
|
</MenuPrimitive.SubmenuTrigger>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSubContent({
|
function DropdownMenuSubContent({
|
||||||
@@ -135,14 +135,17 @@ function DropdownMenuSubContent({
|
|||||||
return (
|
return (
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
data-slot="dropdown-menu-sub-content"
|
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 )}
|
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}
|
align={align}
|
||||||
alignOffset={alignOffset}
|
alignOffset={alignOffset}
|
||||||
side={side}
|
side={side}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuCheckboxItem({
|
function DropdownMenuCheckboxItem({
|
||||||
@@ -152,7 +155,7 @@ function DropdownMenuCheckboxItem({
|
|||||||
inset,
|
inset,
|
||||||
...props
|
...props
|
||||||
}: MenuPrimitive.CheckboxItem.Props & {
|
}: MenuPrimitive.CheckboxItem.Props & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<MenuPrimitive.CheckboxItem
|
<MenuPrimitive.CheckboxItem
|
||||||
@@ -160,7 +163,7 @@ function DropdownMenuCheckboxItem({
|
|||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -170,22 +173,16 @@ function DropdownMenuCheckboxItem({
|
|||||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||||
>
|
>
|
||||||
<MenuPrimitive.CheckboxItemIndicator>
|
<MenuPrimitive.CheckboxItemIndicator>
|
||||||
<CheckIcon
|
<CheckIcon />
|
||||||
/>
|
|
||||||
</MenuPrimitive.CheckboxItemIndicator>
|
</MenuPrimitive.CheckboxItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</MenuPrimitive.CheckboxItem>
|
</MenuPrimitive.CheckboxItem>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||||
return (
|
return <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||||
<MenuPrimitive.RadioGroup
|
|
||||||
data-slot="dropdown-menu-radio-group"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuRadioItem({
|
function DropdownMenuRadioItem({
|
||||||
@@ -194,7 +191,7 @@ function DropdownMenuRadioItem({
|
|||||||
inset,
|
inset,
|
||||||
...props
|
...props
|
||||||
}: MenuPrimitive.RadioItem.Props & {
|
}: MenuPrimitive.RadioItem.Props & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<MenuPrimitive.RadioItem
|
<MenuPrimitive.RadioItem
|
||||||
@@ -202,7 +199,7 @@ function DropdownMenuRadioItem({
|
|||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -211,42 +208,35 @@ function DropdownMenuRadioItem({
|
|||||||
data-slot="dropdown-menu-radio-item-indicator"
|
data-slot="dropdown-menu-radio-item-indicator"
|
||||||
>
|
>
|
||||||
<MenuPrimitive.RadioItemIndicator>
|
<MenuPrimitive.RadioItemIndicator>
|
||||||
<CheckIcon
|
<CheckIcon />
|
||||||
/>
|
|
||||||
</MenuPrimitive.RadioItemIndicator>
|
</MenuPrimitive.RadioItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</MenuPrimitive.RadioItem>
|
</MenuPrimitive.RadioItem>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSeparator({
|
function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: MenuPrimitive.Separator.Props) {
|
|
||||||
return (
|
return (
|
||||||
<MenuPrimitive.Separator
|
<MenuPrimitive.Separator
|
||||||
data-slot="dropdown-menu-separator"
|
data-slot="dropdown-menu-separator"
|
||||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuShortcut({
|
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) {
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-slot="dropdown-menu-shortcut"
|
data-slot="dropdown-menu-shortcut"
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -265,4 +255,4 @@ export {
|
|||||||
DropdownMenuSub,
|
DropdownMenuSub,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import remarkGfm from "remark-gfm";
|
|||||||
import type { Components } from "react-markdown";
|
import type { Components } from "react-markdown";
|
||||||
import { BookOpen, BrainCircuit, ArrowRight } from "lucide-react";
|
import { BookOpen, BrainCircuit, ArrowRight } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { Chunk } from "@/lib/types";
|
||||||
|
|
||||||
const mdComponents: Components = {
|
const mdComponents: Components = {
|
||||||
h1: ({ children }) => (
|
h1: ({ children }) => (
|
||||||
@@ -24,12 +25,8 @@ const mdComponents: Components = {
|
|||||||
{children}
|
{children}
|
||||||
</h3>
|
</h3>
|
||||||
),
|
),
|
||||||
p: ({ children }) => (
|
p: ({ children }) => <p className="text-sm leading-7 text-foreground/90 my-2">{children}</p>,
|
||||||
<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>,
|
||||||
),
|
|
||||||
ul: ({ children }) => (
|
|
||||||
<ul className="my-2 ml-1 space-y-1">{children}</ul>
|
|
||||||
),
|
|
||||||
ol: ({ children }) => (
|
ol: ({ children }) => (
|
||||||
<ol className="my-2 ml-1 space-y-1 list-decimal list-inside">{children}</ol>
|
<ol className="my-2 ml-1 space-y-1 list-decimal list-inside">{children}</ol>
|
||||||
),
|
),
|
||||||
@@ -49,40 +46,30 @@ const mdComponents: Components = {
|
|||||||
<table className="w-full text-sm">{children}</table>
|
<table className="w-full text-sm">{children}</table>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
thead: ({ children }) => (
|
thead: ({ children }) => <thead className="bg-primary/5 border-b">{children}</thead>,
|
||||||
<thead className="bg-primary/5 border-b">{children}</thead>
|
|
||||||
),
|
|
||||||
th: ({ children }) => (
|
th: ({ children }) => (
|
||||||
<th className="px-3 py-2 text-left text-xs font-semibold text-primary/80 uppercase tracking-wider">
|
<th className="px-3 py-2 text-left text-xs font-semibold text-primary/80 uppercase tracking-wider">
|
||||||
{children}
|
{children}
|
||||||
</th>
|
</th>
|
||||||
),
|
),
|
||||||
td: ({ children }) => (
|
td: ({ children }) => <td className="px-3 py-2 text-sm border-b border-muted">{children}</td>,
|
||||||
<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>,
|
||||||
hr: () => (
|
em: ({ children }) => <em className="text-primary/80 not-italic font-medium">{children}</em>,
|
||||||
<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 }) => {
|
a: ({ href, children }) => {
|
||||||
if (href === "#cite-kb") {
|
if (href === "#cite-kb") {
|
||||||
const label = String(children).replace(/^知识库:/, "");
|
const label = String(children).replace(/^知识库:/, "");
|
||||||
return (
|
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">
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 mx-0.5 text-xs font-semibold rounded bg-blue-100 text-blue-700 border border-blue-300 align-middle hover:bg-blue-200 transition-colors cursor-pointer">
|
||||||
<BookOpen className="h-3 w-3 shrink-0" />
|
<BookOpen className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (href === "#cite-ai") {
|
if (href === "#cite-ai") {
|
||||||
return (
|
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">
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 mx-0.5 text-xs font-semibold rounded bg-orange-100 text-orange-700 border border-orange-300 align-middle whitespace-nowrap hover:bg-orange-200 transition-colors cursor-pointer">
|
||||||
<BrainCircuit className="h-3 w-3 shrink-0" />
|
<BrainCircuit className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span>AI建议</span>
|
<span>AI建议</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -91,12 +78,15 @@ const mdComponents: Components = {
|
|||||||
if (href?.startsWith("#cite-app:")) {
|
if (href?.startsWith("#cite-app:")) {
|
||||||
const slug = href.replace("#cite-app:", "");
|
const slug = href.replace("#cite-app:", "");
|
||||||
const appName = String(children);
|
const appName = String(children);
|
||||||
return (
|
return <AppLinkBadge slug={slug} name={appName} />;
|
||||||
<AppLinkBadge slug={slug} name={appName} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline underline-offset-2 hover:text-primary/80">
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary underline underline-offset-2 hover:text-primary/80"
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
@@ -153,6 +143,7 @@ function AppLinkBadge({ slug, name }: { slug: string; name: string }) {
|
|||||||
interface GovMarkdownProps {
|
interface GovMarkdownProps {
|
||||||
content: string;
|
content: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
chunks?: Chunk[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripOuterCodeFence(text: string): string {
|
function stripOuterCodeFence(text: string): string {
|
||||||
@@ -164,22 +155,53 @@ function stripOuterCodeFence(text: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 预处理 markdown 内容:将来源标注转为特殊链接格式,由 ReactMarkdown 的 a 组件拦截渲染
|
* 预处理 markdown 内容:将来源标注转为特殊链接格式,由 ReactMarkdown 的 a 组件拦截渲染
|
||||||
|
*
|
||||||
|
* 支持的标注格式:
|
||||||
|
* - [[chunk:N]] → 知识库引用(使用 chunks 映射表解析为文档名)
|
||||||
|
* - [[知识库:文档名]] → 知识库引用徽章
|
||||||
|
* - [[AI建议]] → AI建议徽章
|
||||||
|
* - [[推荐应用:名称:slug]] → 可点击跳转链接
|
||||||
*/
|
*/
|
||||||
function preprocessCitations(content: string): string {
|
function preprocessCitations(content: string, chunks?: Chunk[]): string {
|
||||||
return content
|
let result = content;
|
||||||
// 知识库引用:[[知识库:文献名称]] 或 [[知识库:文献名称:条款]]
|
console.log("[preprocessCitations] chunks:", chunks);
|
||||||
.replace(/\[\[(知识库:[^\]]+)\]\]/g, (_, label) => `[${label}](#cite-kb)`)
|
|
||||||
// AI建议:标准格式 [[AI建议]]
|
// 先处理 chunk 编号引用 [[chunk:N]]
|
||||||
.replace(/\[\[AI建议\]\]/g, "[AI建议](#cite-ai)")
|
result = result.replace(/\[\[chunk:(\d+)\]\]/g, (_, idx) => {
|
||||||
// AI建议:来源说明块中的 **AI建议:** 或 **AI建议** 标题(仅匹配行首或 > 后)
|
const i = parseInt(idx, 10);
|
||||||
.replace(/^(\s*>?\s*)\*\*AI建议[::]\*\*/gm, "$1[AI建议](#cite-ai)")
|
if (chunks && chunks[i]) {
|
||||||
// AI建议:无加粗的 AI建议: 标题行(仅匹配行首或 > 后)
|
// 使用 chunks 映射表,转换为带文档名的知识库引用
|
||||||
.replace(/^(\s*>?\s*)AI建议[::]\s*$/gm, "$1[AI建议](#cite-ai)")
|
const docName = chunks[i].doc_name || chunks[i].content?.slice(0, 20) || "知识库片段";
|
||||||
// 推荐应用:[[推荐应用:应用名称:slug]] → 可点击跳转链接
|
console.log(`[preprocessCitations] chunk[${i}]:`, docName);
|
||||||
.replace(/\[\[推荐应用:([^:]+):([^\]]+)\]\]/g, (_, name, slug) => `[${name}](#cite-app:${slug})`);
|
return `[知识库:${docName}](#cite-kb)`;
|
||||||
|
}
|
||||||
|
// 无映射时显示后备文本
|
||||||
|
console.log(`[preprocessCitations] chunk[${i}] not found, chunks length:`, chunks?.length);
|
||||||
|
return `[知识库片段](#cite-kb)`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 知识库引用:[[知识库:文献名称]] 或 [[知识库:文献名称:条款]]
|
||||||
|
result = result.replace(/\[\[(知识库:[^\]]+)\]\]/g, (_, label) => `[${label}](#cite-kb)`);
|
||||||
|
|
||||||
|
// AI建议:标准格式 [[AI建议]]
|
||||||
|
result = result.replace(/\[\[AI建议\]\]/g, "[AI建议](#cite-ai)");
|
||||||
|
|
||||||
|
// AI建议:来源说明块中的 **AI建议:** 或 **AI建议** 标题(仅匹配行首或 > 后)
|
||||||
|
result = result.replace(/^(\s*\>?\s*)\*\*AI建议[::]\*\*/gm, "$1[AI建议](#cite-ai)");
|
||||||
|
|
||||||
|
// AI建议:无加粗的 AI建议: 标题行(仅匹配行首或 > 后)
|
||||||
|
result = result.replace(/^(\s*\>?\s*)AI建议[::]\s*$/gm, "$1[AI建议](#cite-ai)");
|
||||||
|
|
||||||
|
// 推荐应用:[[推荐应用:应用名称:slug]] → 可点击跳转链接
|
||||||
|
result = result.replace(
|
||||||
|
/\[\[推荐应用:([^:]+):([^\]]+)\]\]/g,
|
||||||
|
(_, name, slug) => `[${name}](#cite-app:${slug})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GovMarkdown = memo(function GovMarkdown({ content, className }: GovMarkdownProps) {
|
const GovMarkdown = memo(function GovMarkdown({ content, className, chunks }: GovMarkdownProps) {
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-1">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground py-1">
|
||||||
@@ -188,7 +210,7 @@ const GovMarkdown = memo(function GovMarkdown({ content, className }: GovMarkdow
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const cleaned = preprocessCitations(stripOuterCodeFence(content));
|
const cleaned = preprocessCitations(stripOuterCodeFence(content), chunks);
|
||||||
return (
|
return (
|
||||||
<div className={`gov-markdown ${className || ""}`}>
|
<div className={`gov-markdown ${className || ""}`}>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
@@ -15,11 +15,11 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
role="group"
|
role="group"
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputGroupAddonVariants = cva(
|
const inputGroupAddonVariants = cva(
|
||||||
@@ -27,10 +27,8 @@ const inputGroupAddonVariants = cva(
|
|||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
align: {
|
align: {
|
||||||
"inline-start":
|
"inline-start": "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
|
||||||
"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]",
|
||||||
"inline-end":
|
|
||||||
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
|
|
||||||
"block-start":
|
"block-start":
|
||||||
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
|
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
|
||||||
"block-end":
|
"block-end":
|
||||||
@@ -40,8 +38,8 @@ const inputGroupAddonVariants = cva(
|
|||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
align: "inline-start",
|
align: "inline-start",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function InputGroupAddon({
|
function InputGroupAddon({
|
||||||
className,
|
className,
|
||||||
@@ -56,32 +54,28 @@ function InputGroupAddon({
|
|||||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if ((e.target as HTMLElement).closest("button")) {
|
if ((e.target as HTMLElement).closest("button")) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
e.currentTarget.parentElement?.querySelector("input")?.focus();
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputGroupButtonVariants = cva(
|
const inputGroupButtonVariants = cva("flex items-center gap-2 text-sm shadow-none", {
|
||||||
"flex items-center gap-2 text-sm shadow-none",
|
variants: {
|
||||||
{
|
size: {
|
||||||
variants: {
|
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||||
size: {
|
sm: "",
|
||||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
"icon-xs": "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
|
||||||
sm: "",
|
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||||
"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",
|
defaultVariants: {
|
||||||
},
|
size: "xs",
|
||||||
}
|
},
|
||||||
)
|
});
|
||||||
|
|
||||||
function InputGroupButton({
|
function InputGroupButton({
|
||||||
className,
|
className,
|
||||||
@@ -91,7 +85,7 @@ function InputGroupButton({
|
|||||||
...props
|
...props
|
||||||
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
|
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
|
||||||
VariantProps<typeof inputGroupButtonVariants> & {
|
VariantProps<typeof inputGroupButtonVariants> & {
|
||||||
type?: "button" | "submit" | "reset"
|
type?: "button" | "submit" | "reset";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -101,7 +95,7 @@ function InputGroupButton({
|
|||||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
@@ -109,43 +103,37 @@ function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InputGroupInput({
|
function InputGroupInput({ className, ...props }: React.ComponentProps<"input">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"input">) {
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
data-slot="input-group-control"
|
data-slot="input-group-control"
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InputGroupTextarea({
|
function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"textarea">) {
|
|
||||||
return (
|
return (
|
||||||
<Textarea
|
<Textarea
|
||||||
data-slot="input-group-control"
|
data-slot="input-group-control"
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -155,4 +143,4 @@ export {
|
|||||||
InputGroupText,
|
InputGroupText,
|
||||||
InputGroupInput,
|
InputGroupInput,
|
||||||
InputGroupTextarea,
|
InputGroupTextarea,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
import { Input as InputPrimitive } from "@base-ui/react/input";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
return (
|
return (
|
||||||
@@ -10,11 +10,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Input }
|
export { Input };
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||||
return (
|
return (
|
||||||
@@ -10,11 +10,11 @@ function Label({ className, ...props }: React.ComponentProps<"label">) {
|
|||||||
data-slot="label"
|
data-slot="label"
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Label }
|
export { Label };
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PopoverContent({
|
function PopoverContent({
|
||||||
@@ -21,10 +21,7 @@ function PopoverContent({
|
|||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
...props
|
...props
|
||||||
}: PopoverPrimitive.Popup.Props &
|
}: PopoverPrimitive.Popup.Props &
|
||||||
Pick<
|
Pick<PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
|
||||||
PopoverPrimitive.Positioner.Props,
|
|
||||||
"align" | "alignOffset" | "side" | "sideOffset"
|
|
||||||
>) {
|
|
||||||
return (
|
return (
|
||||||
<PopoverPrimitive.Portal>
|
<PopoverPrimitive.Portal>
|
||||||
<PopoverPrimitive.Positioner
|
<PopoverPrimitive.Positioner
|
||||||
@@ -38,13 +35,13 @@ function PopoverContent({
|
|||||||
data-slot="popover-content"
|
data-slot="popover-content"
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</PopoverPrimitive.Positioner>
|
</PopoverPrimitive.Positioner>
|
||||||
</PopoverPrimitive.Portal>
|
</PopoverPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -54,7 +51,7 @@ function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
||||||
@@ -64,27 +61,17 @@ function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
|||||||
className={cn("font-medium", className)}
|
className={cn("font-medium", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PopoverDescription({
|
function PopoverDescription({ className, ...props }: PopoverPrimitive.Description.Props) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: PopoverPrimitive.Description.Props) {
|
|
||||||
return (
|
return (
|
||||||
<PopoverPrimitive.Description
|
<PopoverPrimitive.Description
|
||||||
data-slot="popover-description"
|
data-slot="popover-description"
|
||||||
className={cn("text-muted-foreground", className)}
|
className={cn("text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger };
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverDescription,
|
|
||||||
PopoverHeader,
|
|
||||||
PopoverTitle,
|
|
||||||
PopoverTrigger,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
value?: number
|
value?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Progress: React.FC<ProgressProps> = React.forwardRef<HTMLDivElement, ProgressProps>(
|
const Progress: React.FC<ProgressProps> = React.forwardRef<HTMLDivElement, ProgressProps>(
|
||||||
({ className, value = 0, ...props }, ref) => (
|
({ className, value = 0, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-primary/20", className)}
|
||||||
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -23,8 +20,8 @@ const Progress: React.FC<ProgressProps> = React.forwardRef<HTMLDivElement, Progr
|
|||||||
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
|
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
),
|
||||||
)
|
);
|
||||||
Progress.displayName = "Progress"
|
Progress.displayName = "Progress";
|
||||||
|
|
||||||
export { Progress }
|
export { Progress };
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function ScrollArea({
|
function ScrollArea({ className, children, ...props }: ScrollAreaPrimitive.Root.Props) {
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: ScrollAreaPrimitive.Root.Props) {
|
|
||||||
return (
|
return (
|
||||||
<ScrollAreaPrimitive.Root
|
<ScrollAreaPrimitive.Root
|
||||||
data-slot="scroll-area"
|
data-slot="scroll-area"
|
||||||
@@ -25,7 +21,7 @@ function ScrollArea({
|
|||||||
<ScrollBar />
|
<ScrollBar />
|
||||||
<ScrollAreaPrimitive.Corner />
|
<ScrollAreaPrimitive.Corner />
|
||||||
</ScrollAreaPrimitive.Root>
|
</ScrollAreaPrimitive.Root>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScrollBar({
|
function ScrollBar({
|
||||||
@@ -40,7 +36,7 @@ function ScrollBar({
|
|||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -49,7 +45,7 @@ function ScrollBar({
|
|||||||
className="relative flex-1 rounded-full bg-border"
|
className="relative flex-1 rounded-full bg-border"
|
||||||
/>
|
/>
|
||||||
</ScrollAreaPrimitive.Scrollbar>
|
</ScrollAreaPrimitive.Scrollbar>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ScrollArea, ScrollBar }
|
export { ScrollArea, ScrollBar };
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
import { Select as SelectPrimitive } from "@base-ui/react/select";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react";
|
||||||
|
|
||||||
const Select = SelectPrimitive.Root
|
const Select = SelectPrimitive.Root;
|
||||||
|
|
||||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||||
return (
|
return (
|
||||||
@@ -15,7 +15,7 @@ function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
|||||||
className={cn("scroll-my-1 p-1", className)}
|
className={cn("scroll-my-1 p-1", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||||
@@ -25,7 +25,7 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
|||||||
className={cn("flex flex-1 text-left", className)}
|
className={cn("flex flex-1 text-left", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectTrigger({
|
function SelectTrigger({
|
||||||
@@ -34,7 +34,7 @@ function SelectTrigger({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: SelectPrimitive.Trigger.Props & {
|
}: SelectPrimitive.Trigger.Props & {
|
||||||
size?: "sm" | "default"
|
size?: "sm" | "default";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
@@ -42,18 +42,16 @@ function SelectTrigger({
|
|||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<SelectPrimitive.Icon
|
<SelectPrimitive.Icon
|
||||||
render={
|
render={<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />}
|
||||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectContent({
|
function SelectContent({
|
||||||
@@ -83,7 +81,10 @@ function SelectContent({
|
|||||||
<SelectPrimitive.Popup
|
<SelectPrimitive.Popup
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
data-align-trigger={alignItemWithTrigger}
|
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 )}
|
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}
|
{...props}
|
||||||
>
|
>
|
||||||
<SelectScrollUpButton />
|
<SelectScrollUpButton />
|
||||||
@@ -92,33 +93,26 @@ function SelectContent({
|
|||||||
</SelectPrimitive.Popup>
|
</SelectPrimitive.Popup>
|
||||||
</SelectPrimitive.Positioner>
|
</SelectPrimitive.Positioner>
|
||||||
</SelectPrimitive.Portal>
|
</SelectPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectLabel({
|
function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: SelectPrimitive.GroupLabel.Props) {
|
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.GroupLabel
|
<SelectPrimitive.GroupLabel
|
||||||
data-slot="select-label"
|
data-slot="select-label"
|
||||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectItem({
|
function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: SelectPrimitive.Item.Props) {
|
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -133,20 +127,17 @@ function SelectItem({
|
|||||||
<CheckIcon className="pointer-events-none" />
|
<CheckIcon className="pointer-events-none" />
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectSeparator({
|
function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: SelectPrimitive.Separator.Props) {
|
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Separator
|
<SelectPrimitive.Separator
|
||||||
data-slot="select-separator"
|
data-slot="select-separator"
|
||||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectScrollUpButton({
|
function SelectScrollUpButton({
|
||||||
@@ -158,14 +149,13 @@ function SelectScrollUpButton({
|
|||||||
data-slot="select-scroll-up-button"
|
data-slot="select-scroll-up-button"
|
||||||
className={cn(
|
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",
|
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronUpIcon
|
<ChevronUpIcon />
|
||||||
/>
|
|
||||||
</SelectPrimitive.ScrollUpArrow>
|
</SelectPrimitive.ScrollUpArrow>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectScrollDownButton({
|
function SelectScrollDownButton({
|
||||||
@@ -177,14 +167,13 @@ function SelectScrollDownButton({
|
|||||||
data-slot="select-scroll-down-button"
|
data-slot="select-scroll-down-button"
|
||||||
className={cn(
|
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",
|
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon />
|
||||||
/>
|
|
||||||
</SelectPrimitive.ScrollDownArrow>
|
</SelectPrimitive.ScrollDownArrow>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -198,4 +187,4 @@ export {
|
|||||||
SelectSeparator,
|
SelectSeparator,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,25 +1,21 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Separator({
|
function Separator({ className, orientation = "horizontal", ...props }: SeparatorPrimitive.Props) {
|
||||||
className,
|
|
||||||
orientation = "horizontal",
|
|
||||||
...props
|
|
||||||
}: SeparatorPrimitive.Props) {
|
|
||||||
return (
|
return (
|
||||||
<SeparatorPrimitive
|
<SeparatorPrimitive
|
||||||
data-slot="separator"
|
data-slot="separator"
|
||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Separator }
|
export { Separator };
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||||
@@ -29,11 +29,11 @@ function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
|||||||
data-slot="sheet-overlay"
|
data-slot="sheet-overlay"
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetContent({
|
function SheetContent({
|
||||||
@@ -43,8 +43,8 @@ function SheetContent({
|
|||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
...props
|
...props
|
||||||
}: SheetPrimitive.Popup.Props & {
|
}: SheetPrimitive.Popup.Props & {
|
||||||
side?: "top" | "right" | "bottom" | "left"
|
side?: "top" | "right" | "bottom" | "left";
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SheetPortal>
|
<SheetPortal>
|
||||||
@@ -54,7 +54,7 @@ function SheetContent({
|
|||||||
data-side={side}
|
data-side={side}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -62,22 +62,15 @@ function SheetContent({
|
|||||||
{showCloseButton && (
|
{showCloseButton && (
|
||||||
<SheetPrimitive.Close
|
<SheetPrimitive.Close
|
||||||
data-slot="sheet-close"
|
data-slot="sheet-close"
|
||||||
render={
|
render={<Button variant="ghost" className="absolute top-3 right-3" size="icon-sm" />}
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="absolute top-3 right-3"
|
|
||||||
size="icon-sm"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<XIcon
|
<XIcon />
|
||||||
/>
|
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</SheetPrimitive.Close>
|
</SheetPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
</SheetPrimitive.Popup>
|
</SheetPrimitive.Popup>
|
||||||
</SheetPortal>
|
</SheetPortal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -87,7 +80,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -97,33 +90,27 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||||
return (
|
return (
|
||||||
<SheetPrimitive.Title
|
<SheetPrimitive.Title
|
||||||
data-slot="sheet-title"
|
data-slot="sheet-title"
|
||||||
className={cn(
|
className={cn("font-heading text-base font-medium text-foreground", className)}
|
||||||
"font-heading text-base font-medium text-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetDescription({
|
function SheetDescription({ className, ...props }: SheetPrimitive.Description.Props) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: SheetPrimitive.Description.Props) {
|
|
||||||
return (
|
return (
|
||||||
<SheetPrimitive.Description
|
<SheetPrimitive.Description
|
||||||
data-slot="sheet-description"
|
data-slot="sheet-description"
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -135,4 +122,4 @@ export {
|
|||||||
SheetFooter,
|
SheetFooter,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetDescription,
|
SheetDescription,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
@@ -7,7 +7,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Skeleton }
|
export { Skeleton };
|
||||||
|
|||||||
@@ -1,32 +1,28 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes";
|
||||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
import {
|
||||||
|
CircleCheckIcon,
|
||||||
|
InfoIcon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
OctagonXIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
const { theme = "system" } = useTheme()
|
const { theme = "system" } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
theme={theme as ToasterProps["theme"]}
|
theme={theme as ToasterProps["theme"]}
|
||||||
className="toaster group"
|
className="toaster group"
|
||||||
icons={{
|
icons={{
|
||||||
success: (
|
success: <CircleCheckIcon className="size-4" />,
|
||||||
<CircleCheckIcon className="size-4" />
|
info: <InfoIcon className="size-4" />,
|
||||||
),
|
warning: <TriangleAlertIcon className="size-4" />,
|
||||||
info: (
|
error: <OctagonXIcon className="size-4" />,
|
||||||
<InfoIcon className="size-4" />
|
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||||
),
|
|
||||||
warning: (
|
|
||||||
<TriangleAlertIcon className="size-4" />
|
|
||||||
),
|
|
||||||
error: (
|
|
||||||
<OctagonXIcon className="size-4" />
|
|
||||||
),
|
|
||||||
loading: (
|
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
@@ -43,7 +39,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export { Toaster }
|
export { Toaster };
|
||||||
|
|||||||
@@ -1,26 +1,19 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Tabs({
|
function Tabs({ className, orientation = "horizontal", ...props }: TabsPrimitive.Root.Props) {
|
||||||
className,
|
|
||||||
orientation = "horizontal",
|
|
||||||
...props
|
|
||||||
}: TabsPrimitive.Root.Props) {
|
|
||||||
return (
|
return (
|
||||||
<TabsPrimitive.Root
|
<TabsPrimitive.Root
|
||||||
data-slot="tabs"
|
data-slot="tabs"
|
||||||
data-orientation={orientation}
|
data-orientation={orientation}
|
||||||
className={cn(
|
className={cn("group/tabs flex gap-2 data-horizontal:flex-col", className)}
|
||||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabsListVariants = cva(
|
const tabsListVariants = cva(
|
||||||
@@ -35,8 +28,8 @@ const tabsListVariants = cva(
|
|||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function TabsList({
|
function TabsList({
|
||||||
className,
|
className,
|
||||||
@@ -50,7 +43,7 @@ function TabsList({
|
|||||||
className={cn(tabsListVariants({ variant }), className)}
|
className={cn(tabsListVariants({ variant }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||||
@@ -62,11 +55,11 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
|||||||
"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",
|
"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",
|
"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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||||
@@ -76,7 +69,7 @@ function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
|||||||
className={cn("flex-1 text-sm outline-none", className)}
|
className={cn("flex-1 text-sm outline-none", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
return (
|
return (
|
||||||
@@ -8,11 +8,11 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
|||||||
data-slot="textarea"
|
data-slot="textarea"
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Textarea }
|
export { Textarea };
|
||||||
|
|||||||
@@ -1,28 +1,19 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function TooltipProvider({
|
function TooltipProvider({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) {
|
||||||
delay = 0,
|
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delay={delay} {...props} />;
|
||||||
...props
|
|
||||||
}: TooltipPrimitive.Provider.Props) {
|
|
||||||
return (
|
|
||||||
<TooltipPrimitive.Provider
|
|
||||||
data-slot="tooltip-provider"
|
|
||||||
delay={delay}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TooltipContent({
|
function TooltipContent({
|
||||||
@@ -34,10 +25,7 @@ function TooltipContent({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: TooltipPrimitive.Popup.Props &
|
}: TooltipPrimitive.Popup.Props &
|
||||||
Pick<
|
Pick<TooltipPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
|
||||||
TooltipPrimitive.Positioner.Props,
|
|
||||||
"align" | "alignOffset" | "side" | "sideOffset"
|
|
||||||
>) {
|
|
||||||
return (
|
return (
|
||||||
<TooltipPrimitive.Portal>
|
<TooltipPrimitive.Portal>
|
||||||
<TooltipPrimitive.Positioner
|
<TooltipPrimitive.Positioner
|
||||||
@@ -51,7 +39,7 @@ function TooltipContent({
|
|||||||
data-slot="tooltip-content"
|
data-slot="tooltip-content"
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -60,7 +48,7 @@ function TooltipContent({
|
|||||||
</TooltipPrimitive.Popup>
|
</TooltipPrimitive.Popup>
|
||||||
</TooltipPrimitive.Positioner>
|
</TooltipPrimitive.Positioner>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
export {
|
export { useSSEStream, updateLastAssistantMessage, setStreamErrorMessage } from "./use-sse-stream";
|
||||||
useSSEStream,
|
|
||||||
updateLastAssistantMessage,
|
|
||||||
setStreamErrorMessage,
|
|
||||||
} from "./use-sse-stream";
|
|
||||||
export { useCopyToClipboard } from "./use-copy-clipboard";
|
export { useCopyToClipboard } from "./use-copy-clipboard";
|
||||||
export { useScrollToBottom } from "./use-scroll-bottom";
|
export { useScrollToBottom } from "./use-scroll-bottom";
|
||||||
export { useFileExport } from "./use-file-export";
|
export { useFileExport } from "./use-file-export";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useRef } from "react";
|
import { useMemo } from "react";
|
||||||
import type { App } from "@/lib/types";
|
import type { App } from "@/lib/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,8 +10,7 @@ import type { App } from "@/lib/types";
|
|||||||
export function useAppConfig(app: App): Record<string, unknown> {
|
export function useAppConfig(app: App): Record<string, unknown> {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
try {
|
try {
|
||||||
if (typeof app.app_config === "string")
|
if (typeof app.app_config === "string") return JSON.parse(app.app_config);
|
||||||
return JSON.parse(app.app_config);
|
|
||||||
return app.app_config || {};
|
return app.app_config || {};
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
@@ -24,15 +23,13 @@ export function useAppConfig(app: App): Record<string, unknown> {
|
|||||||
* chatbot-ui / agent-ui 共用。
|
* chatbot-ui / agent-ui 共用。
|
||||||
*/
|
*/
|
||||||
export function useSuggestedPrompts(app: App): string[] {
|
export function useSuggestedPrompts(app: App): string[] {
|
||||||
return useRef(
|
return useMemo(() => {
|
||||||
(() => {
|
try {
|
||||||
try {
|
if (typeof app.suggested_prompts === "string")
|
||||||
if (typeof app.suggested_prompts === "string")
|
return JSON.parse(app.suggested_prompts) as string[];
|
||||||
return JSON.parse(app.suggested_prompts) as string[];
|
return (app.suggested_prompts as string[]) || [];
|
||||||
return (app.suggested_prompts as string[]) || [];
|
} catch {
|
||||||
} catch {
|
return [];
|
||||||
return [];
|
}
|
||||||
}
|
}, [app.suggested_prompts]);
|
||||||
})(),
|
|
||||||
).current;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,24 +8,21 @@ import { toast } from "sonner";
|
|||||||
* chatbot-ui / agent-ui / doc-writer-ui / analysis-ui 的导出功能共用。
|
* chatbot-ui / agent-ui / doc-writer-ui / analysis-ui 的导出功能共用。
|
||||||
*/
|
*/
|
||||||
export function useFileExport() {
|
export function useFileExport() {
|
||||||
const download = useCallback(
|
const download = useCallback((content: string, filename: string, successMsg = "已导出") => {
|
||||||
(content: string, filename: string, successMsg = "已导出") => {
|
const blob = new Blob([content], { type: "application/octet-stream" });
|
||||||
const blob = new Blob([content], { type: "application/octet-stream" });
|
const url = URL.createObjectURL(blob);
|
||||||
const url = URL.createObjectURL(blob);
|
const a = document.createElement("a");
|
||||||
const a = document.createElement("a");
|
a.href = url;
|
||||||
a.href = url;
|
a.download = filename;
|
||||||
a.download = filename;
|
a.style.display = "none";
|
||||||
a.style.display = "none";
|
document.body.appendChild(a);
|
||||||
document.body.appendChild(a);
|
a.click();
|
||||||
a.click();
|
setTimeout(() => {
|
||||||
setTimeout(() => {
|
document.body.removeChild(a);
|
||||||
document.body.removeChild(a);
|
URL.revokeObjectURL(url);
|
||||||
URL.revokeObjectURL(url);
|
}, 30000);
|
||||||
}, 30000);
|
toast.success(successMsg);
|
||||||
toast.success(successMsg);
|
}, []);
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { download };
|
return { download };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,7 @@ export function useSSEStream(options: UseSSEStreamOptions = {}) {
|
|||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const processStream = useCallback(
|
const processStream = useCallback(
|
||||||
async (
|
async (response: Response, onChunk: (accumulated: string) => void) => {
|
||||||
response: Response,
|
|
||||||
onChunk: (accumulated: string) => void,
|
|
||||||
) => {
|
|
||||||
if (!response.ok) throw new Error("请求失败");
|
if (!response.ok) throw new Error("请求失败");
|
||||||
const reader = response.body?.getReader();
|
const reader = response.body?.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
@@ -90,14 +87,9 @@ export function useSSEStream(options: UseSSEStreamOptions = {}) {
|
|||||||
* 更新消息列表中最后一条 assistant 消息的内容。
|
* 更新消息列表中最后一条 assistant 消息的内容。
|
||||||
* chatbot-ui / agent-ui / doc-writer-ui / analysis-ui 共用此逻辑。
|
* chatbot-ui / agent-ui / doc-writer-ui / analysis-ui 共用此逻辑。
|
||||||
*/
|
*/
|
||||||
export function updateLastAssistantMessage(
|
export function updateLastAssistantMessage(prev: Message[], content: string): Message[] {
|
||||||
prev: Message[],
|
|
||||||
content: string,
|
|
||||||
): Message[] {
|
|
||||||
return prev.map((m, i) =>
|
return prev.map((m, i) =>
|
||||||
i === prev.length - 1 && m.role === "assistant"
|
i === prev.length - 1 && m.role === "assistant" ? { ...m, content } : m,
|
||||||
? { ...m, content }
|
|
||||||
: m,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-34
@@ -1,7 +1,9 @@
|
|||||||
|
// 服务端 SSR:使用内网地址直连后端
|
||||||
|
// 客户端:使用空字符串(same-origin,通过 nginx 代理)
|
||||||
const API_BASE =
|
const API_BASE =
|
||||||
typeof window === "undefined"
|
typeof window === "undefined"
|
||||||
? process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080"
|
? process.env.API_URL || "http://localhost:8080"
|
||||||
: process.env.NEXT_PUBLIC_API_URL || "";
|
: "";
|
||||||
|
|
||||||
// 流式请求基础URL(运行时判断,不依赖编译时变量):
|
// 流式请求基础URL(运行时判断,不依赖编译时变量):
|
||||||
// - 服务器端(SSR):直连后端
|
// - 服务器端(SSR):直连后端
|
||||||
@@ -9,7 +11,7 @@ const API_BASE =
|
|||||||
// - 浏览器端生产环境:走同域nginx(已配proxy_buffering off)
|
// - 浏览器端生产环境:走同域nginx(已配proxy_buffering off)
|
||||||
function getStreamBase() {
|
function getStreamBase() {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
|
return process.env.API_URL || "http://localhost:8080";
|
||||||
}
|
}
|
||||||
const host = window.location.hostname;
|
const host = window.location.hostname;
|
||||||
if (host === "localhost" || host === "127.0.0.1") {
|
if (host === "localhost" || host === "127.0.0.1") {
|
||||||
@@ -34,18 +36,12 @@ class APIError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 认证:token 通过 HttpOnly Cookie 自动携带,不再使用 localStorage
|
||||||
function getAuthHeaders(): Record<string, string> {
|
function getAuthHeaders(): Record<string, string> {
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
if (token) return { Authorization: `Bearer ${token}` };
|
|
||||||
}
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
path: string,
|
|
||||||
options: RequestInit = {}
|
|
||||||
): Promise<T> {
|
|
||||||
const url = `${API_BASE}${path}`;
|
const url = `${API_BASE}${path}`;
|
||||||
const { headers: optHeaders, ...restOptions } = options;
|
const { headers: optHeaders, ...restOptions } = options;
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@@ -82,17 +78,14 @@ async function request<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const currentPath = window.location.pathname;
|
|
||||||
if (currentPath !== "/login" && currentPath !== "/register") {
|
|
||||||
localStorage.removeItem("token");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new APIError(json.code || 40101, json.message || "未登录或登录已过期");
|
throw new APIError(json.code || 40101, json.message || "未登录或登录已过期");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok || json.code !== 0) {
|
if (!res.ok || json.code !== 0) {
|
||||||
throw new APIError(json.code || res.status, json.message || httpErrors[res.status] || "请求失败,请稍后重试");
|
throw new APIError(
|
||||||
|
json.code || res.status,
|
||||||
|
json.message || httpErrors[res.status] || "请求失败,请稍后重试",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return json.data;
|
return json.data;
|
||||||
}
|
}
|
||||||
@@ -110,7 +103,7 @@ export function streamChat(
|
|||||||
appId: string,
|
appId: string,
|
||||||
message: string,
|
message: string,
|
||||||
conversationId?: string,
|
conversationId?: string,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal,
|
||||||
) {
|
) {
|
||||||
const url = `${STREAM_BASE}/api/v1/apps/${appId}/chat`;
|
const url = `${STREAM_BASE}/api/v1/apps/${appId}/chat`;
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
@@ -134,11 +127,7 @@ export async function completionRequest(appId: string, message: string, signal?:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function streamCompletion(
|
export function streamCompletion(appId: string, message: string, signal?: AbortSignal) {
|
||||||
appId: string,
|
|
||||||
message: string,
|
|
||||||
signal?: AbortSignal
|
|
||||||
) {
|
|
||||||
const url = `${STREAM_BASE}/api/v1/apps/${appId}/completion`;
|
const url = `${STREAM_BASE}/api/v1/apps/${appId}/completion`;
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -152,7 +141,7 @@ export function streamGenerateDoc(
|
|||||||
appId: string,
|
appId: string,
|
||||||
templateId: string,
|
templateId: string,
|
||||||
fieldData: Record<string, string>,
|
fieldData: Record<string, string>,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal,
|
||||||
) {
|
) {
|
||||||
const url = `${STREAM_BASE}/api/v1/apps/${appId}/generate-doc`;
|
const url = `${STREAM_BASE}/api/v1/apps/${appId}/generate-doc`;
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
@@ -167,7 +156,7 @@ export function streamGenerateAnalysis(
|
|||||||
appId: string,
|
appId: string,
|
||||||
templateId: string,
|
templateId: string,
|
||||||
fieldData: Record<string, string>,
|
fieldData: Record<string, string>,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal,
|
||||||
) {
|
) {
|
||||||
const url = `${STREAM_BASE}/api/v1/apps/${appId}/generate-analysis`;
|
const url = `${STREAM_BASE}/api/v1/apps/${appId}/generate-analysis`;
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
@@ -210,11 +199,7 @@ export async function createPPTTask(data: {
|
|||||||
return api.post<{ task_id: string; status: string }>("/api/v1/ppt/tasks", data);
|
return api.post<{ task_id: string; status: string }>("/api/v1/ppt/tasks", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPPTTaskWithFile(
|
export async function createPPTTaskWithFile(file: File, title: string, config: PPTTaskConfig) {
|
||||||
file: File,
|
|
||||||
title: string,
|
|
||||||
config: PPTTaskConfig
|
|
||||||
) {
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
formData.append("title", title);
|
formData.append("title", title);
|
||||||
@@ -242,9 +227,8 @@ export async function listPPTTasks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getPPTDownloadURL(taskId: string) {
|
export function getPPTDownloadURL(taskId: string) {
|
||||||
const base = typeof window !== "undefined"
|
// 客户端使用 same-origin,服务端不应调用此函数
|
||||||
? process.env.NEXT_PUBLIC_API_URL || ""
|
const base = typeof window !== "undefined" ? "" : "";
|
||||||
: "";
|
|
||||||
return `${base}/api/v1/ppt/tasks/${taskId}/download`;
|
return `${base}/api/v1/ppt/tasks/${taskId}/download`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,52 @@
|
|||||||
import {
|
import {
|
||||||
Scale, MessageSquareText, FileSearch, FilePenLine, FilePlus, ScanText,
|
Scale,
|
||||||
ShieldAlert, ClipboardCheck, ShieldCheck, BrainCircuit, FileCheck,
|
MessageSquareText,
|
||||||
ClipboardList, BarChart3, Building2, Bot, Cpu, Layers, Star,
|
FileSearch,
|
||||||
BookOpen, FileText, Mic, Image, Database, Search, BarChart2,
|
FilePenLine,
|
||||||
PieChart, TrendingUp, Globe, Mail, Phone, Users, Map, Calendar,
|
FilePlus,
|
||||||
Clock, AlertCircle, CheckCircle, XCircle, Info, Settings, Home,
|
ScanText,
|
||||||
FolderOpen, Download, Upload, Share2, Edit, Trash, Plus, Minus,
|
ShieldAlert,
|
||||||
|
ClipboardCheck,
|
||||||
|
ShieldCheck,
|
||||||
|
BrainCircuit,
|
||||||
|
FileCheck,
|
||||||
|
ClipboardList,
|
||||||
|
BarChart3,
|
||||||
|
Building2,
|
||||||
|
Bot,
|
||||||
|
Cpu,
|
||||||
|
Layers,
|
||||||
|
Star,
|
||||||
|
BookOpen,
|
||||||
|
FileText,
|
||||||
|
Mic,
|
||||||
|
Image,
|
||||||
|
Database,
|
||||||
|
Search,
|
||||||
|
BarChart2,
|
||||||
|
PieChart,
|
||||||
|
TrendingUp,
|
||||||
|
Globe,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
Users,
|
||||||
|
Map,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Info,
|
||||||
|
Settings,
|
||||||
|
Home,
|
||||||
|
FolderOpen,
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
|
Share2,
|
||||||
|
Edit,
|
||||||
|
Trash,
|
||||||
|
Plus,
|
||||||
|
Minus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
@@ -77,7 +118,11 @@ export function AppIcon({ iconUrl, size = 20, className = "" }: AppIconProps) {
|
|||||||
return <Bot style={{ width: size, height: size }} className={className} />;
|
return <Bot style={{ width: size, height: size }} className={className} />;
|
||||||
}
|
}
|
||||||
if (isEmoji(iconUrl)) {
|
if (isEmoji(iconUrl)) {
|
||||||
return <span style={{ fontSize: size }} className={className}>{iconUrl}</span>;
|
return (
|
||||||
|
<span style={{ fontSize: size }} className={className}>
|
||||||
|
{iconUrl}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const Icon = ICON_MAP[iconUrl.toLowerCase()] ?? Bot;
|
const Icon = ICON_MAP[iconUrl.toLowerCase()] ?? Bot;
|
||||||
return <Icon style={{ width: size, height: size }} className={className} />;
|
return <Icon style={{ width: size, height: size }} className={className} />;
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { MessagesSquare, PenTool, Workflow, BrainCircuit } from "lucide-react";
|
||||||
MessagesSquare,
|
|
||||||
PenTool,
|
|
||||||
Workflow,
|
|
||||||
BrainCircuit,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
export type AppType = "chatbot" | "completion" | "workflow" | "agent";
|
export type AppType = "chatbot" | "completion" | "workflow" | "agent";
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface App {
|
|||||||
visibility: "private" | "department" | "public";
|
visibility: "private" | "department" | "public";
|
||||||
is_featured: boolean;
|
is_featured: boolean;
|
||||||
is_template: boolean;
|
is_template: boolean;
|
||||||
|
is_favorited?: boolean;
|
||||||
usage_count: number;
|
usage_count: number;
|
||||||
favorite_count: number;
|
favorite_count: number;
|
||||||
avg_rating: number;
|
avg_rating: number;
|
||||||
@@ -93,6 +94,14 @@ export interface Message {
|
|||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 知识库片段映射,来自 SSE 首包
|
||||||
|
export interface Chunk {
|
||||||
|
id: string;
|
||||||
|
doc_name: string;
|
||||||
|
content: string;
|
||||||
|
similarity: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DeleteTarget {
|
export interface DeleteTarget {
|
||||||
type: "single" | "batch";
|
type: "single" | "batch";
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import { clsx, type ClassValue } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface AuthState {
|
|||||||
fetchUser: () => Promise<void>;
|
fetchUser: () => Promise<void>;
|
||||||
login: (email: string, password: string, orgId?: string) => Promise<void>;
|
login: (email: string, password: string, orgId?: string) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
setAuth: (user: User, token: string) => void;
|
setAuth: (user: User) => void;
|
||||||
switchOrg: (orgId: string) => Promise<void>;
|
switchOrg: (orgId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,12 +26,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
if (get()._hasFetched) return;
|
if (get()._hasFetched) return;
|
||||||
set({ _hasFetched: true });
|
set({ _hasFetched: true });
|
||||||
|
|
||||||
const token = typeof window !== "undefined" ? localStorage.getItem("token") : null;
|
|
||||||
if (!token) {
|
|
||||||
set({ user: null, isAuthenticated: false, isLoading: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||||
|
|
||||||
@@ -39,7 +33,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
const user = await api.get<User>("/api/v1/auth/me", { signal: controller.signal });
|
const user = await api.get<User>("/api/v1/auth/me", { signal: controller.signal });
|
||||||
set({ user, isAuthenticated: true, isLoading: false });
|
set({ user, isAuthenticated: true, isLoading: false });
|
||||||
} catch {
|
} catch {
|
||||||
if (typeof window !== "undefined") localStorage.removeItem("token");
|
|
||||||
set({ user: null, isAuthenticated: false, isLoading: false });
|
set({ user: null, isAuthenticated: false, isLoading: false });
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
@@ -47,19 +40,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
login: async (email: string, password: string, orgId?: string) => {
|
login: async (email: string, password: string, orgId?: string) => {
|
||||||
const data = await api.post<{ user: User; access_token: string }>("/api/v1/auth/login", {
|
const data = await api.post<{ user: User }>("/api/v1/auth/login", {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
org_id: orgId,
|
org_id: orgId,
|
||||||
});
|
});
|
||||||
if (data.access_token) {
|
|
||||||
localStorage.setItem("token", data.access_token);
|
|
||||||
}
|
|
||||||
set({ user: data.user, isAuthenticated: true, isLoading: false });
|
set({ user: data.user, isAuthenticated: true, isLoading: false });
|
||||||
},
|
},
|
||||||
|
|
||||||
setAuth: (user: User, token: string) => {
|
setAuth: (user: User) => {
|
||||||
if (token) localStorage.setItem("token", token);
|
|
||||||
set({ user, isAuthenticated: true, isLoading: false });
|
set({ user, isAuthenticated: true, isLoading: false });
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -69,7 +58,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
} finally {
|
} finally {
|
||||||
localStorage.removeItem("token");
|
|
||||||
set({ user: null, isAuthenticated: false, _hasFetched: false });
|
set({ user: null, isAuthenticated: false, _hasFetched: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -78,12 +66,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
const data = await api.post<{
|
const data = await api.post<{
|
||||||
message: string;
|
message: string;
|
||||||
org: Organization;
|
org: Organization;
|
||||||
token?: string;
|
|
||||||
user?: User;
|
user?: User;
|
||||||
}>("/api/v1/auth/switch-org", { org_id: orgId });
|
}>("/api/v1/auth/switch-org", { org_id: orgId });
|
||||||
if (data.token) {
|
|
||||||
localStorage.setItem("token", data.token);
|
|
||||||
}
|
|
||||||
if (data.user && data.org) {
|
if (data.user && data.org) {
|
||||||
set({ user: { ...data.user, org_id: orgId, org: data.org }, isAuthenticated: true });
|
set({ user: { ...data.user, org_id: orgId, org: data.org }, isAuthenticated: true });
|
||||||
} else {
|
} else {
|
||||||
@@ -93,4 +77,4 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
darkMode: "class",
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
chart: {
|
||||||
|
"1": "hsl(var(--chart-1))",
|
||||||
|
"2": "hsl(var(--chart-2))",
|
||||||
|
"3": "hsl(var(--chart-3))",
|
||||||
|
"4": "hsl(var(--chart-4))",
|
||||||
|
"5": "hsl(var(--chart-5))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["var(--font-sans)"],
|
||||||
|
mono: ["var(--font-geist-mono)"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
} satisfies Config;
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SERVER="h2agent"
|
SERVER="${SERVER:-govai-root}"
|
||||||
REMOTE_DIR="/opt/govai"
|
REMOTE_DIR="/opt/govai"
|
||||||
DOMAIN="gov.opc8ai.com"
|
DOMAIN="gov.opc8ai.com"
|
||||||
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
@@ -88,8 +88,9 @@ build_local() {
|
|||||||
|
|
||||||
log "编译 Next.js 前端..."
|
log "编译 Next.js 前端..."
|
||||||
cd "$PROJECT_DIR/apps/web"
|
cd "$PROJECT_DIR/apps/web"
|
||||||
|
rm -rf .next
|
||||||
NEXT_PUBLIC_API_URL="http://localhost:8080" npm run build
|
NEXT_PUBLIC_API_URL="http://localhost:8080" npm run build
|
||||||
log "前端编译完成"
|
log "前端编译完成 (.next: $(du -sh .next | cut -f1))"
|
||||||
cd "$PROJECT_DIR"
|
cd "$PROJECT_DIR"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,17 +102,17 @@ upload() {
|
|||||||
log "停止服务以释放文件锁..."
|
log "停止服务以释放文件锁..."
|
||||||
ssh $SERVER "systemctl stop govai-api govai-web 2>/dev/null || true"
|
ssh $SERVER "systemctl stop govai-api govai-web 2>/dev/null || true"
|
||||||
|
|
||||||
log "上传后端二进制..."
|
log "上传后端二进制 (gzip 流)..."
|
||||||
scp "$PROJECT_DIR/dist/server" $SERVER:$REMOTE_DIR/server/server
|
gzip -c "$PROJECT_DIR/dist/server" | ssh $SERVER "gunzip -c > $REMOTE_DIR/server/server.new && mv -f $REMOTE_DIR/server/server.new $REMOTE_DIR/server/server && chmod +x $REMOTE_DIR/server/server"
|
||||||
|
|
||||||
log "上传迁移文件..."
|
log "上传迁移文件..."
|
||||||
rsync -az --delete "$PROJECT_DIR/server/migrations/" $SERVER:$REMOTE_DIR/migrations/
|
rsync -az --delete "$PROJECT_DIR/server/migrations/" $SERVER:$REMOTE_DIR/migrations/
|
||||||
|
|
||||||
log "上传前端构建产物..."
|
log "上传前端构建产物 (tar 压缩流, 排除 dev/cache)..."
|
||||||
rsync -az --delete "$PROJECT_DIR/apps/web/.next/" $SERVER:$REMOTE_DIR/web/.next/
|
tar czf - -C "$PROJECT_DIR/apps/web" \
|
||||||
rsync -az "$PROJECT_DIR/apps/web/public/" $SERVER:$REMOTE_DIR/web/public/ 2>/dev/null || true
|
--exclude='.next/dev' --exclude='.next/cache' \
|
||||||
scp "$PROJECT_DIR/apps/web/package.json" $SERVER:$REMOTE_DIR/web/
|
.next public package.json next.config.ts \
|
||||||
scp "$PROJECT_DIR/apps/web/next.config.ts" $SERVER:$REMOTE_DIR/web/
|
| ssh $SERVER "rm -rf $REMOTE_DIR/web/.next && tar xzf - -C $REMOTE_DIR/web"
|
||||||
|
|
||||||
log "上传完成"
|
log "上传完成"
|
||||||
}
|
}
|
||||||
@@ -298,6 +299,84 @@ REMOTE_SVC
|
|||||||
log "服务配置完成"
|
log "服务配置完成"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---- 安全加固 ----
|
||||||
|
security_hardening() {
|
||||||
|
step "安全加固"
|
||||||
|
|
||||||
|
ssh $SERVER bash << 'SEC_HARD'
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "[1/7] 清理恶意 cron 任务..."
|
||||||
|
# 保留正常 cron,移除 pakchoi 和可疑磁盘清理
|
||||||
|
crontab -l 2>/dev/null | grep -v -E 'pakchoi|/opt/disk/cleanup' | crontab - 2>/dev/null || true
|
||||||
|
echo " Cron 清理完成"
|
||||||
|
|
||||||
|
echo "[2/7] 删除恶意用户..."
|
||||||
|
for user in pakchoi; do
|
||||||
|
id $user 2>/dev/null && userdel -r $user && echo " $user 已删除" || echo " $user 不存在"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[3/7] 收紧 .env 文件权限..."
|
||||||
|
find /opt/govai /root -name '.env' -o -name '.env.*' 2>/dev/null | while read f; do
|
||||||
|
chmod 600 "$f" 2>/dev/null && echo " Fixed: $f"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[4/7] 配置 iptables 防火墙 (仅放行 22,80,443,3000)..."
|
||||||
|
# 只对有 iptables 且不是云安全组管理的服务器生效
|
||||||
|
if command -v iptables &>/dev/null && ! iptables -L INPUT -n | grep -q 'Chain references'; then
|
||||||
|
iptables -F INPUT 2>/dev/null || true
|
||||||
|
iptables -P INPUT DROP 2>/dev/null || true
|
||||||
|
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true
|
||||||
|
iptables -A INPUT -p tcp --dport 22 -j ACCEPT 2>/dev/null || true
|
||||||
|
iptables -A INPUT -p tcp --dport 80 -j ACCEPT 2>/dev/null || true
|
||||||
|
iptables -A INPUT -p tcp --dport 443 -j ACCEPT 2>/dev/null || true
|
||||||
|
iptables -A INPUT -p tcp --dport 3000 -j ACCEPT 2>/dev/null || true
|
||||||
|
iptables -A INPUT -i lo -j ACCEPT 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
# 保存规则到持久化文件
|
||||||
|
iptables-save > /etc/iptables/rules.v4 2>/dev/null || \
|
||||||
|
iptables-save > /etc/sysconfig/iptables 2>/dev/null || true
|
||||||
|
echo " 防火墙已收紧"
|
||||||
|
|
||||||
|
echo "[5/7] 配置 SSH authorized_keys 权限..."
|
||||||
|
if [ -f /root/.ssh/authorized_keys ]; then
|
||||||
|
# 只保留已知密钥(包含 govai / freedak / h2deploy 注释的)
|
||||||
|
grep -E '(govai|freedak|h2deploy)' /root/.ssh/authorized_keys > /root/.ssh/authorized_keys.clean 2>/dev/null || true
|
||||||
|
if [ -s /root/.ssh/authorized_keys.clean ]; then
|
||||||
|
mv /root/.ssh/authorized_keys.clean /root/.ssh/authorized_keys
|
||||||
|
chmod 600 /root/.ssh/authorized_keys
|
||||||
|
echo " SSH authorized_keys 已清理 ($(wc -l < /root/.ssh/authorized_keys) 密钥保留)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[6/7] 确保 Redis 认证已启用..."
|
||||||
|
REDIS_CONF="/etc/redis/redis.conf"
|
||||||
|
if [ -f "$REDIS_CONF" ]; then
|
||||||
|
if ! grep -q "^requirepass" "$REDIS_CONF"; then
|
||||||
|
echo "requirepass $(openssl rand -base64 32 | tr -d '/+=' | head -c 32)" >> "$REDIS_CONF"
|
||||||
|
systemctl restart redis 2>/dev/null || true
|
||||||
|
echo " Redis 认证密码已设置"
|
||||||
|
else
|
||||||
|
echo " Redis 已有密码配置"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[7/7] 确保 SSH PermitRootLogin 为 prohibit-password..."
|
||||||
|
SSHD_HARDEN="/etc/ssh/sshd_config.d/99-hardening.conf"
|
||||||
|
if [ -f "$SSHD_HARDEN" ]; then
|
||||||
|
if grep -q "PermitRootLogin no" "$SSHD_HARDEN"; then
|
||||||
|
sed -i 's/PermitRootLogin no/PermitRootLogin prohibit-password/' "$SSHD_HARDEN"
|
||||||
|
systemctl reload sshd
|
||||||
|
echo " PermitRootLogin 已修复"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ 安全加固完成"
|
||||||
|
SEC_HARD
|
||||||
|
log "安全加固完成"
|
||||||
|
}
|
||||||
|
|
||||||
# ---- 启动/重启 ----
|
# ---- 启动/重启 ----
|
||||||
start_services() {
|
start_services() {
|
||||||
step "启动服务"
|
step "启动服务"
|
||||||
@@ -388,6 +467,7 @@ case "$ACTION" in
|
|||||||
migrate_db
|
migrate_db
|
||||||
setup_services
|
setup_services
|
||||||
start_services
|
start_services
|
||||||
|
security_hardening
|
||||||
;;
|
;;
|
||||||
update)
|
update)
|
||||||
echo -e "${BLUE}>>> GovAI 更新部署 -> $DOMAIN${NC}"
|
echo -e "${BLUE}>>> GovAI 更新部署 -> $DOMAIN${NC}"
|
||||||
@@ -397,6 +477,7 @@ case "$ACTION" in
|
|||||||
migrate_db
|
migrate_db
|
||||||
install_web_deps
|
install_web_deps
|
||||||
start_services
|
start_services
|
||||||
|
security_hardening
|
||||||
;;
|
;;
|
||||||
restart)
|
restart)
|
||||||
echo -e "${BLUE}>>> GovAI 重启服务${NC}"
|
echo -e "${BLUE}>>> GovAI 重启服务${NC}"
|
||||||
@@ -413,8 +494,12 @@ case "$ACTION" in
|
|||||||
logs)
|
logs)
|
||||||
remote_logs "$@"
|
remote_logs "$@"
|
||||||
;;
|
;;
|
||||||
|
secure)
|
||||||
|
echo -e "${BLUE}>>> GovAI 安全加固${NC}"
|
||||||
|
security_hardening
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
echo "用法: bash deploy.sh [init|update|restart|migrate|status|logs]"
|
echo "用法: bash deploy.sh [init|update|restart|migrate|status|logs|secure]"
|
||||||
echo ""
|
echo ""
|
||||||
echo " init - 首次部署(安装依赖+初始化数据库+部署应用)"
|
echo " init - 首次部署(安装依赖+初始化数据库+部署应用)"
|
||||||
echo " update - 更新部署(git提交+构建+上传+迁移+重启)"
|
echo " update - 更新部署(git提交+构建+上传+迁移+重启)"
|
||||||
@@ -422,6 +507,7 @@ case "$ACTION" in
|
|||||||
echo " migrate - 仅上传迁移文件并执行数据库迁移"
|
echo " migrate - 仅上传迁移文件并执行数据库迁移"
|
||||||
echo " status - 查看远程服务状态和健康检查"
|
echo " status - 查看远程服务状态和健康检查"
|
||||||
echo " logs - 查看日志 (可选: logs govai-web 100)"
|
echo " logs - 查看日志 (可选: logs govai-web 100)"
|
||||||
|
echo " secure - 仅执行安全加固(cron清理/防火墙/SSH/.env权限)"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
+11
-11
@@ -1,28 +1,28 @@
|
|||||||
# Aily - 开发环境基础设施
|
# GovAI - 开发环境基础设施
|
||||||
# 使用方式: docker compose -f docker/docker-compose.yml up -d
|
# 使用方式: docker compose -f docker/docker-compose.yml up -d
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg17
|
image: pgvector/pgvector:pg17
|
||||||
container_name: aily-postgres
|
container_name: govai-postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-aily}
|
POSTGRES_USER: ${POSTGRES_USER:-govai}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-aily}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-govai}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-aily_portal}
|
POSTGRES_DB: ${POSTGRES_DB:-govai_portal}
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U aily"]
|
test: ["CMD-SHELL", "pg_isready -U govai"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: aily-redis
|
container_name: govai-redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
@@ -36,7 +36,7 @@ services:
|
|||||||
|
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio
|
image: minio/minio
|
||||||
container_name: aily-minio
|
container_name: govai-minio
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "9000:9000"
|
- "9000:9000"
|
||||||
@@ -57,14 +57,14 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ../ppt-worker
|
context: ../ppt-worker
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: aily-ppt-worker
|
container_name: govai-ppt-worker
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8090:8090"
|
- "8090:8090"
|
||||||
environment:
|
environment:
|
||||||
WORKER_HOST: 0.0.0.0
|
WORKER_HOST: 0.0.0.0
|
||||||
WORKER_PORT: 8090
|
WORKER_PORT: 8090
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER:-aily}:${POSTGRES_PASSWORD:-aily}@postgres:5432/${POSTGRES_DB:-aily_portal}?sslmode=disable
|
DATABASE_URL: postgres://${POSTGRES_USER:-govai}:${POSTGRES_PASSWORD:-govai}@postgres:5432/${POSTGRES_DB:-govai_portal}?sslmode=disable
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
PPT_MASTER_PATH: /opt/ppt-master
|
PPT_MASTER_PATH: /opt/ppt-master
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
@@ -85,4 +85,4 @@ volumes:
|
|||||||
pgdata:
|
pgdata:
|
||||||
redisdata:
|
redisdata:
|
||||||
miniodata:
|
miniodata:
|
||||||
ppt-projects:
|
ppt-projects:
|
||||||
Binary file not shown.
@@ -0,0 +1,284 @@
|
|||||||
|
# 政智通 — 政务AI智能应用平台
|
||||||
|
|
||||||
|
## 一、项目概述
|
||||||
|
|
||||||
|
**项目名称**:政智通 — 政务AI智能应用平台
|
||||||
|
|
||||||
|
**项目定位**:面向政府部门的AI智能办公平台,以大语言模型为核心引擎,覆盖公文写作、政策解读、政务宣传、数据治理、便民服务等多场景,提升行政效能、赋能智慧政务。
|
||||||
|
|
||||||
|
**用途**:投标 — 入驻法治网平台
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、开发语言与技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术选型 | 版本 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 后端语言 | Go (Golang) | 1.25.0 |
|
||||||
|
| 后端框架 | Chi Router | v5.2.5 |
|
||||||
|
| 前端语言 | TypeScript | - |
|
||||||
|
| 前端框架 | Next.js (App Router) / React | - |
|
||||||
|
| 前端样式 | Tailwind CSS / shadcn/ui | - |
|
||||||
|
| 数据库 | PostgreSQL | 15+ |
|
||||||
|
| 缓存 | Redis | 7+ |
|
||||||
|
| AI 引擎 | 通义千问 (Qwen) / OpenAI 兼容接口 | - |
|
||||||
|
| PPT 生成微服务 | Python | - |
|
||||||
|
| 认证 | JWT (golang-jwt/v5) | - |
|
||||||
|
| 日志 | zerolog | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、源代码规模
|
||||||
|
|
||||||
|
| 模块 | 文件类型 | 文件数 | 代码行数 |
|
||||||
|
|------|----------|--------|----------|
|
||||||
|
| 后端服务 | Go (`.go`) | 52 | 10,528 |
|
||||||
|
| 前端应用 | TSX (`.tsx`) | - | 14,356 |
|
||||||
|
| 前端应用 | TS (`.ts`) | - | 1,496 |
|
||||||
|
| 前端应用 | CSS (`.css`) | - | 140 |
|
||||||
|
| PPT 微服务 | Python (`.py`) | - | 1,367 |
|
||||||
|
| **合计** | | **~143 文件** | **~27,887 行** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、系统架构
|
||||||
|
|
||||||
|
### 4.1 整体架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ 前端 (Next.js) │
|
||||||
|
│ ┌─────────┐ ┌──────────┐ ┌──────────────┐ │
|
||||||
|
│ │ 门户端 │ │ 管理后台 │ │ 平台管理端 │ │
|
||||||
|
│ │(Portal) │ │(Admin) │ │(Platform) │ │
|
||||||
|
│ └────┬────┘ └────┬─────┘ └──────┬───────┘ │
|
||||||
|
│ └───────────┴──────────────┘ │
|
||||||
|
│ REST API │
|
||||||
|
└───────────────────┬─────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────┴─────────────────────────┐
|
||||||
|
│ 后端 (Go / Chi Router) │
|
||||||
|
│ ┌──────┐ ┌───────┐ ┌──────┐ ┌──────────┐ │
|
||||||
|
│ │认证 │ │应用 │ │对话 │ │知识库 │ │
|
||||||
|
│ │中间件 │ │商店 │ │引擎 │ │管理 │ │
|
||||||
|
│ └──────┘ └───────┘ └──────┘ └──────────┘ │
|
||||||
|
│ ┌──────┐ ┌───────┐ ┌──────┐ ┌──────────┐ │
|
||||||
|
│ │PPT │ │文档 │ │审计 │ │平台管理 │ │
|
||||||
|
│ │生成 │ │模板 │ │日志 │ │ │ │
|
||||||
|
│ └──────┘ └───────┘ └──────┘ └──────────┘ │
|
||||||
|
└──────┬──────────────────────┬───────────────┘
|
||||||
|
│ │
|
||||||
|
┌──────┴──────┐ ┌──────┴──────┐
|
||||||
|
│ PostgreSQL │ │ Redis │
|
||||||
|
│ (持久化) │ │ (缓存) │
|
||||||
|
└─────────────┘ └─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 后端模块结构
|
||||||
|
|
||||||
|
| 目录 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `server/cmd/server/` | 主入口、路由注册 |
|
||||||
|
| `server/internal/handler/` | HTTP 请求处理器(对话、应用商店、知识库、PPT、文件等) |
|
||||||
|
| `server/internal/middleware/` | 中间件(认证、租户隔离、日志) |
|
||||||
|
| `server/internal/config/` | 配置管理 |
|
||||||
|
| `server/internal/response/` | 统一响应格式 |
|
||||||
|
| `server/pkg/auth/` | JWT 认证工具包 |
|
||||||
|
| `server/pkg/db/` | 数据库连接与查询 |
|
||||||
|
| `server/pkg/llm/` | 大语言模型接口封装 |
|
||||||
|
| `server/pkg/embedding/` | 向量嵌入 |
|
||||||
|
| `server/pkg/chunker/` | 文档分块 |
|
||||||
|
| `server/pkg/tenant/` | 多租户隔离 |
|
||||||
|
| `server/migrations/` | 数据库迁移脚本(17 个迁移 + 种子数据) |
|
||||||
|
|
||||||
|
### 4.3 前端模块结构
|
||||||
|
|
||||||
|
| 目录 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `apps/web/src/app/(portal)/` | 门户端:应用商店、对话、知识库、工作台 |
|
||||||
|
| `apps/web/src/app/(admin)/` | 管理后台:用户管理、应用管理、审计、分析、模型配置 |
|
||||||
|
| `apps/web/src/app/(auth)/` | 认证:登录、注册 |
|
||||||
|
| `apps/web/src/app/platform/` | 平台管理:组织管理、供应商、配额、审计 |
|
||||||
|
| `apps/web/src/components/` | 通用组件(UI 基础组件、布局、应用卡片) |
|
||||||
|
| `apps/web/src/hooks/` | 自定义 Hooks |
|
||||||
|
| `apps/web/src/lib/` | 工具函数库 |
|
||||||
|
| `apps/web/src/stores/` | 状态管理 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、核心功能模块
|
||||||
|
|
||||||
|
### 5.1 政务应用分类
|
||||||
|
|
||||||
|
| 分类 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 公文写作 | 公文拟稿、会议纪要、文件摘要 |
|
||||||
|
| 政策解读 | 法规问答、政策影响分析 |
|
||||||
|
| 政务宣传 | 宣传稿件、信息发布 |
|
||||||
|
| 数据治理 | 数据分析、综合研判 |
|
||||||
|
| 便民服务 | 群众来信回复、咨询答复 |
|
||||||
|
| 信息化工具 | 开发辅助、系统运维 |
|
||||||
|
| 组织人事 | 干部考核、人事管理 |
|
||||||
|
| 招商引资 | 项目评估、投资分析 |
|
||||||
|
| 翻译外事 | 中英互译、外事用语 |
|
||||||
|
| 综合应用 | 其他政务场景 |
|
||||||
|
|
||||||
|
### 5.2 预置应用
|
||||||
|
|
||||||
|
| 应用名称 | 类型 | 功能说明 |
|
||||||
|
|----------|------|----------|
|
||||||
|
| 政策法规问答 | 对话型 | 法规条款查询与解读 |
|
||||||
|
| 公文写作助手 | 对话型 | 各类公文拟稿 |
|
||||||
|
| 群众来信回复 | 对话型 | 群众诉求回复建议 |
|
||||||
|
| 会议纪要生成 | 补全型 | 会议记录整理 |
|
||||||
|
| 公文摘要提取 | 补全型 | 文件要点提取 |
|
||||||
|
| 翻译助手 | 补全型 | 政务中英互译 |
|
||||||
|
| 招商项目评估 | 工作流 | 多维度项目评估 |
|
||||||
|
| 政策影响分析 | 工作流 | 政策多维度影响评估 |
|
||||||
|
| 综合研判助手 | 智能体 | 数据分析与报告生成 |
|
||||||
|
| 干部考核助手 | 智能体 | 绩效分析与评语生成 |
|
||||||
|
| 智能 PPT 生成 | PPT 生成 | 上传文档/输入主题,AI 生成原生可编辑 PPTX |
|
||||||
|
|
||||||
|
### 5.3 系统功能清单
|
||||||
|
|
||||||
|
| 功能域 | 功能项 |
|
||||||
|
|--------|--------|
|
||||||
|
| 用户与权限 | 注册/登录、JWT 认证、角色管理、多租户隔离 |
|
||||||
|
| 应用管理 | 应用商店、分类浏览、应用创建、收藏评分 |
|
||||||
|
| AI 对话 | 多轮对话、流式输出、上下文管理、对话历史 |
|
||||||
|
| 知识库 | 文档上传、文档分块、向量嵌入、知识检索 |
|
||||||
|
| PPT 生成 | 多种输入源、多种风格、AI 生图、可编辑 PPTX 导出 |
|
||||||
|
| 文档模板 | 模板管理、模板应用 |
|
||||||
|
| 平台管理 | 组织管理、模型供应商配置、配额管理 |
|
||||||
|
| 审计与安全 | 操作审计日志、安全审计、用量统计 |
|
||||||
|
| 数据分析 | 使用分析、模型调用统计、用户行为分析 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、数据库设计
|
||||||
|
|
||||||
|
### 6.1 数据库迁移版本
|
||||||
|
|
||||||
|
共 **17 个迁移版本**,涵盖:
|
||||||
|
|
||||||
|
| 迁移 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 000001 | 初始化 schema |
|
||||||
|
| 000002 | 分类与应用 |
|
||||||
|
| 000003 | 评价、收藏、评分 |
|
||||||
|
| 000004 | 用量日志 |
|
||||||
|
| 000005 | 模型、配额、审计 |
|
||||||
|
| 000006 | PPT 任务 |
|
||||||
|
| 000007 | PPT 生成器类型 |
|
||||||
|
| 000008 | 文档模板 |
|
||||||
|
| 000009 | 对话命名 |
|
||||||
|
| 000010 | 知识库 ID |
|
||||||
|
| 000011 | 文档内容 |
|
||||||
|
| 000012 | 用量日志消息 |
|
||||||
|
| 000013 | 组织机构 |
|
||||||
|
| 000014 | 多租户 |
|
||||||
|
| 000015 | 平台管理 |
|
||||||
|
| 000016 | 对话与消息 |
|
||||||
|
| 000017 | 清理旧对话表 |
|
||||||
|
|
||||||
|
### 6.2 种子数据
|
||||||
|
|
||||||
|
包含法治网、发改委、公安、科技、信访等多领域种子数据,支持快速部署演示。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、部署架构
|
||||||
|
|
||||||
|
### 7.1 运行环境
|
||||||
|
|
||||||
|
| 组件 | 要求 |
|
||||||
|
|------|------|
|
||||||
|
| Go | 1.25+ |
|
||||||
|
| Node.js | 18+ |
|
||||||
|
| PostgreSQL | 15+ |
|
||||||
|
| Redis | 7+ |
|
||||||
|
| Python | 3.10+ (PPT Worker) |
|
||||||
|
|
||||||
|
### 7.2 部署方式
|
||||||
|
|
||||||
|
- **Docker 部署**:提供 `docker/` 配置目录,支持容器化部署
|
||||||
|
- **原生部署**:通过 `Makefile` 管理构建与启动
|
||||||
|
- **部署脚本**:`deploy.sh` 支持自动化部署
|
||||||
|
|
||||||
|
### 7.3 服务端口
|
||||||
|
|
||||||
|
| 服务 | 端口 |
|
||||||
|
|------|------|
|
||||||
|
| 后端 API | 8080 |
|
||||||
|
| 前端 Web | 3000 |
|
||||||
|
| PostgreSQL | 5432 |
|
||||||
|
| Redis | 6379 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、安全设计
|
||||||
|
|
||||||
|
| 安全措施 | 说明 |
|
||||||
|
|----------|------|
|
||||||
|
| JWT 认证 | 短期 Token + 刷新机制 |
|
||||||
|
| 多租户隔离 | 基于 `session.tenant_id` 的数据隔离 |
|
||||||
|
| 密钥管理 | 环境变量管理,不进 Git |
|
||||||
|
| SQL 注入防御 | 参数化查询 |
|
||||||
|
| XSS 防御 | 自动转义 + CSP |
|
||||||
|
| CSRF 防御 | SameSite Cookie |
|
||||||
|
| 操作审计 | 审计日志记录,保留 ≥ 6 个月 |
|
||||||
|
| 限流熔断 | 登录限流、接口限流 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、项目目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
GovAI/
|
||||||
|
├── server/ # Go 后端服务
|
||||||
|
│ ├── cmd/ # 命令入口
|
||||||
|
│ │ ├── server/ # 主服务入口与路由
|
||||||
|
│ │ ├── check-providers/ # 供应商检查工具
|
||||||
|
│ │ ├── seed-providers/ # 供应商种子工具
|
||||||
|
│ │ ├── embed-chunks/ # 向量嵌入工具
|
||||||
|
│ │ └── ... # 其他命令行工具
|
||||||
|
│ ├── internal/ # 内部业务逻辑
|
||||||
|
│ │ ├── handler/ # HTTP 处理器
|
||||||
|
│ │ ├── middleware/ # 中间件
|
||||||
|
│ │ ├── config/ # 配置
|
||||||
|
│ │ ├── response/ # 统一响应
|
||||||
|
│ │ ├── logger/ # 日志
|
||||||
|
│ │ └── metrics/ # 指标
|
||||||
|
│ ├── pkg/ # 公共工具包
|
||||||
|
│ │ ├── auth/ # 认证
|
||||||
|
│ │ ├── db/ # 数据库
|
||||||
|
│ │ ├── llm/ # 大模型接口
|
||||||
|
│ │ ├── embedding/ # 向量嵌入
|
||||||
|
│ │ ├── chunker/ # 文档分块
|
||||||
|
│ │ ├── tenant/ # 多租户
|
||||||
|
│ │ └── dify/ # Dify 集成
|
||||||
|
│ └── migrations/ # 数据库迁移与种子数据
|
||||||
|
├── apps/web/ # Next.js 前端
|
||||||
|
│ └── src/
|
||||||
|
│ ├── app/ # 页面路由
|
||||||
|
│ │ ├── (portal)/ # 门户端
|
||||||
|
│ │ ├── (admin)/ # 管理后台
|
||||||
|
│ │ ├── (auth)/ # 认证页面
|
||||||
|
│ │ └── platform/ # 平台管理端
|
||||||
|
│ ├── components/ # 通用组件
|
||||||
|
│ ├── hooks/ # 自定义 Hooks
|
||||||
|
│ ├── lib/ # 工具函数
|
||||||
|
│ └── stores/ # 状态管理
|
||||||
|
├── ppt-worker/ # PPT 生成微服务 (Python)
|
||||||
|
├── docker/ # Docker 配置
|
||||||
|
├── docs/ # 项目文档
|
||||||
|
├── Makefile # 构建管理
|
||||||
|
├── deploy.sh # 部署脚本
|
||||||
|
└── README.md # 项目说明
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、总结
|
||||||
|
|
||||||
|
政智通平台是一个基于 Go + Next.js 的前后端分离架构的政务AI智能应用平台,总代码量约 **2.8 万行**,涵盖 52 个 Go 源文件和 91 个前端 TypeScript/TSX 文件。平台集成了大语言模型能力,提供公文写作、政策解读、智能PPT生成等 11 个预置应用,支持多租户隔离、知识库管理、审计安全等企业级功能,满足政府部门智慧办公的需求。
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
# GovAI 硬件部署要求
|
||||||
|
|
||||||
|
本文档定义 GovAI(政智通)政务AI智能应用平台的硬件部署要求,适用于不同规模的部署场景。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 系统组件概览
|
||||||
|
|
||||||
|
| 组件 | 技术栈 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 后端服务 | Go 1.25+ | REST API,处理业务逻辑 |
|
||||||
|
| 前端 | Next.js 16 + React 19 | 用户界面(SSR/SSG) |
|
||||||
|
| PPT Worker | Python + FastAPI | 异步 PPT 生成微服务 |
|
||||||
|
| 数据库 | PostgreSQL 17 (pgvector) | 关系数据 + 向量检索 |
|
||||||
|
| 缓存 | Redis 7 | 会话、队列、限流 |
|
||||||
|
| 对象存储 | MinIO | 文档、图片等文件存储 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 场景一:开发 / 最小化部署
|
||||||
|
|
||||||
|
适用:本地开发调试、个人使用、Demo 演示。
|
||||||
|
|
||||||
|
| 资源项 | 最低要求 | 推荐配置 |
|
||||||
|
|--------|---------|---------|
|
||||||
|
| **CPU** | 4 核 | 8 核 |
|
||||||
|
| **内存** | 8 GB | 16 GB |
|
||||||
|
| **磁盘** | 50 GB | 100 GB+ |
|
||||||
|
| **操作系统** | macOS 12+ / Linux / Windows (WSL2) | — |
|
||||||
|
|
||||||
|
所有服务(Go 后端、Next.js 前端、PPT Worker、PostgreSQL、Redis、MinIO)可在单台机器上通过 Docker Compose 一键启动。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 场景二:单服务器生产部署(50 ~ 100 用户)
|
||||||
|
|
||||||
|
适用:50 ~ 100 人规模的小型单位(如科室、局委办),全部服务集中部署于一台物理服务器或虚拟机。
|
||||||
|
|
||||||
|
### 推荐配置
|
||||||
|
|
||||||
|
| 资源项 | 最低要求 | 推荐配置 |
|
||||||
|
|--------|---------|---------|
|
||||||
|
| **CPU** | 8 核 | 16 核 |
|
||||||
|
| **内存** | 16 GB | 32 GB |
|
||||||
|
| **系统盘** | 100 GB SSD | 200 GB SSD |
|
||||||
|
| **数据盘** | 300 GB SSD | 500 GB SSD |
|
||||||
|
| **操作系统** | Linux (Ubuntu 22.04+ / CentOS 8+) | — |
|
||||||
|
|
||||||
|
### 资源分配方案
|
||||||
|
|
||||||
|
| 组件 | 资源占用 | 说明 |
|
||||||
|
|------|---------|------|
|
||||||
|
| Go 后端 | 2 核 + 1 GB 内存 | 2 实例 |
|
||||||
|
| Next.js 前端 | 2 核 + 1 GB 内存 | 1 实例 |
|
||||||
|
| PostgreSQL | 2 核 + 8 GB 内存 | 含 pgvector,SSD 存储 |
|
||||||
|
| Redis | 1 核 + 2 GB 内存 | 会话 + 队列 |
|
||||||
|
| MinIO | 1 核 + 2 GB 内存 | 文档/图片存储(~200 GB) |
|
||||||
|
| PPT Worker | 2~4 核 + 8 GB 内存 | 1 实例,按需生成 PPT |
|
||||||
|
| **系统预留** | 2~4 核 + 4~8 GB | 内核、SSH、Docker 等开销 |
|
||||||
|
|
||||||
|
### 性能预估
|
||||||
|
|
||||||
|
| 指标 | 数值 |
|
||||||
|
|------|------|
|
||||||
|
| 并发用户 | 50 ~ 100 |
|
||||||
|
| 预估 QPS | 100 ~ 300 req/s |
|
||||||
|
| 同时 PPT 生成 | 1 ~ 3 个 |
|
||||||
|
| 每日活跃用户 | 占总用户 30%~50% |
|
||||||
|
| 月数据增量 | ~1 GB(100 用户场景) |
|
||||||
|
| 存储规划 | 500 GB SSD 可用约 3~5 年 |
|
||||||
|
|
||||||
|
### 部署方式
|
||||||
|
|
||||||
|
推荐使用 Docker Compose 在该服务器上一键启动所有容器:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 基础设施(PostgreSQL + Redis + MinIO)
|
||||||
|
docker compose -f docker/docker-compose.yml up -d
|
||||||
|
|
||||||
|
# 后端
|
||||||
|
cd server && go run ./cmd/server
|
||||||
|
|
||||||
|
# 前端
|
||||||
|
cd apps/web && npm run dev # 开发模式
|
||||||
|
# 或 npm run build && npm start # 生产模式
|
||||||
|
|
||||||
|
# PPT Worker
|
||||||
|
make dev-ppt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
- **CPU 敏感**:PPT Worker 在文档解析和渲染时 CPU 占用较高,如并发使用建议提升至 16 核
|
||||||
|
- **内存峰值**:PPT 生成期间内存可能瞬时冲高到 12 GB+,建议保留足够 swap 或选用 32 GB 配置
|
||||||
|
- **网络**:服务器需访问通义千问/ OpenAI API,建议带宽 20 Mbps+
|
||||||
|
- **备份**:数据盘建议配置 RAID 或定期快照备份
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 场景三:多服务器生产(50 ~ 200 并发用户)
|
||||||
|
|
||||||
|
适用:部门级或区县级部署,支撑日常政务办公。
|
||||||
|
|
||||||
|
### 服务器规划(3 台)
|
||||||
|
|
||||||
|
| 服务器 | 部署内容 | CPU | 内存 | 系统盘 | 数据盘 |
|
||||||
|
|--------|---------|-----|------|--------|--------|
|
||||||
|
| **Server A** | Go 后端(2~4 实例) + Redis 主 | 8 核 | 16 GB | 100 GB SSD | — |
|
||||||
|
| **Server B** | PostgreSQL 主库 + MinIO 主 | 8 核 | 16 GB | 100 GB SSD | 500 GB SSD |
|
||||||
|
| **Server C** | PPT Worker(2 实例) + 渲染队列 | 8 核 | 16 GB | 100 GB SSD | 200 GB SSD |
|
||||||
|
|
||||||
|
### 组件级配置明细
|
||||||
|
|
||||||
|
#### Go 后端服务
|
||||||
|
|
||||||
|
| 项目 | 配置 |
|
||||||
|
|------|------|
|
||||||
|
| 实例数 | 2 ~ 4(水平扩展) |
|
||||||
|
| 内存单实例 | ~200 MB |
|
||||||
|
| 并发承载 | 单实例约 50 ~ 100 并发(CPU-bound) |
|
||||||
|
| 连接池 | PostgreSQL 最多 25 连接 / 实例 |
|
||||||
|
| 预估总 QPS | 200 ~ 500 req/s |
|
||||||
|
| 建议资源 | 8 核 16 GB 服务器,容器/进程管理 |
|
||||||
|
|
||||||
|
#### Next.js 前端
|
||||||
|
|
||||||
|
| 项目 | 配置 |
|
||||||
|
|------|------|
|
||||||
|
| 部署模式 | `next start`(Node.js 生产模式)或容器化 |
|
||||||
|
| 内存单实例 | ~500 MB ~ 1 GB |
|
||||||
|
| 建议资源 | 独立 4 核 8 GB 服务器(或与后端混部) |
|
||||||
|
| CDN 加速 | 建议接入 CDN 加速静态资源和图片 |
|
||||||
|
|
||||||
|
#### PPT Worker(Python 微服务)
|
||||||
|
|
||||||
|
| 项目 | 配置 |
|
||||||
|
|------|------|
|
||||||
|
| 实例数 | 2 |
|
||||||
|
| 内存单实例 | 峰值可达 4 GB(文档解析、PPT 渲染) |
|
||||||
|
| 并发任务 | 单实例约 5 ~ 10 个任务并行 |
|
||||||
|
| 建议资源 | 8 核 16 GB 服务器(CPU 密集型) |
|
||||||
|
| 临时存储 | 每个任务约 50 ~ 200 MB 临时文件,需定期清理 |
|
||||||
|
|
||||||
|
#### PostgreSQL(pgvector)
|
||||||
|
|
||||||
|
| 项目 | 配置 |
|
||||||
|
|------|------|
|
||||||
|
| 版本 | PostgreSQL 17 + pgvector |
|
||||||
|
| 并发连接 | 最大 100 ~ 150 连接 |
|
||||||
|
| 共享缓冲 | 建议 4 ~ 8 GB(总内存的 25%~50%) |
|
||||||
|
| 建议资源 | 8 核 16 GB 服务器,SSD 存储 |
|
||||||
|
| 数据量预估 | 50 用户每日产生 ~1 GB 数据/月,500 GB SSD 可用 3 年+ |
|
||||||
|
| 高可用 | 可选主从流复制(2 台) |
|
||||||
|
|
||||||
|
#### Redis
|
||||||
|
|
||||||
|
| 项目 | 配置 |
|
||||||
|
|------|------|
|
||||||
|
| 内存 | 2 ~ 4 GB |
|
||||||
|
| 持久化 | 建议开启 RDB + AOF |
|
||||||
|
| 建议资源 | 与后端同机或独立 2 核 4 GB |
|
||||||
|
| 高可用 | 可选 Sentinel(1 主 2 从) |
|
||||||
|
|
||||||
|
#### MinIO(对象存储)
|
||||||
|
|
||||||
|
| 项目 | 配置 |
|
||||||
|
|------|------|
|
||||||
|
| 存储量 | 200 GB ~ 1 TB(视知识库文档量) |
|
||||||
|
| 建议资源 | 与 PostgreSQL 同机或独立 4 核 8 GB |
|
||||||
|
| 冗余 | 建议 2 节点以上,纠删码模式 |
|
||||||
|
|
||||||
|
### 中等规模汇总
|
||||||
|
|
||||||
|
| 类别 | 数量 | 规格 | 用途 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 应用服务器 | 1~2 | 8 核 16 GB | Go 后端 + 前端(可混部) |
|
||||||
|
| 数据库服务器 | 1 | 8 核 16 GB + 500 GB SSD | PostgreSQL + MinIO |
|
||||||
|
| PPT Worker 服务器 | 1 | 8 核 16 GB | PPT 生成 |
|
||||||
|
| **合计** | **3~4 台** | — | — |
|
||||||
|
|
||||||
|
### 网络要求
|
||||||
|
|
||||||
|
- 服务器之间内网带宽:**千兆(1 Gbps)** 推荐
|
||||||
|
- 对外带宽:**50 Mbps ~ 100 Mbps**(视 AI API 调用量)
|
||||||
|
- AI API 延迟:后端到通义千问/OpenAI 的 RTT 建议 < 200 ms
|
||||||
|
- 端口:前端 3000、后端 8080、PPT Worker 8090、PostgreSQL 5432、Redis 6379、MinIO 9000/9001
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 场景三:大规模 / 高可用分布式(200+ 并发用户)
|
||||||
|
|
||||||
|
适用:市级或省级平台,多机构、海量用户。
|
||||||
|
|
||||||
|
### 推荐架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐
|
||||||
|
│ 负载均衡 │ (Nginx / 云 LB)
|
||||||
|
│ (2 台冗余) │
|
||||||
|
└──────┬───────┘
|
||||||
|
┌───────────────┼───────────────┐
|
||||||
|
│ │ │
|
||||||
|
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
|
||||||
|
│ Go 后端 │ │ Go 后端 │ │ Go 后端 │
|
||||||
|
│ (多实例) │ │ (多实例) │ │ (多实例) │
|
||||||
|
│ 4~8 实例 │ │ 4~8 实例 │ │ 4~8 实例 │
|
||||||
|
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
|
||||||
|
│ │ │
|
||||||
|
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
|
||||||
|
│ PostgreSQL │ │ PostgreSQL │ │ Redis │
|
||||||
|
│ 主库 │ │ 从库(只读) │ │ Cluster │
|
||||||
|
└──────┬──────┘ └──────┬──────┘ └─────────────┘
|
||||||
|
│
|
||||||
|
┌──────▼──────┐ ┌──────▼──────┐
|
||||||
|
│ MinIO 集群 │ │ PPT Worker │
|
||||||
|
│ (纠删码) │ │ (4~8 实例) │
|
||||||
|
└─────────────┘ └─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 资源配置
|
||||||
|
|
||||||
|
| 组件 | 节点数 | 单节点规格 | 存储 |
|
||||||
|
|------|--------|---------|------|
|
||||||
|
| 负载均衡器 | 2 | 4 核 8 GB | — |
|
||||||
|
| Go 后端 | 6~12 | 8 核 16 GB | — |
|
||||||
|
| PostgreSQL 主库 | 1 | 16 核 32 GB | 1 TB SSD |
|
||||||
|
| PostgreSQL 从库 | 2 | 16 核 32 GB | 1 TB SSD |
|
||||||
|
| Redis Cluster | 6 节点 | 4 核 8 GB | — |
|
||||||
|
| MinIO 集群 | 4 | 8 核 16 GB | 4×1 TB |
|
||||||
|
| PPT Worker | 4~8 | 8 核 16 GB | 200 GB SSD |
|
||||||
|
| **服务器合计** | **约 20+ 台** | — | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 软件环境总览
|
||||||
|
|
||||||
|
| 软件 | 版本 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| Go | 1.25+ | 后端运行时 |
|
||||||
|
| Node.js | 18+ | 前端构建与运行 |
|
||||||
|
| PostgreSQL | 15~17 + pgvector | 主数据库 + 向量检索 |
|
||||||
|
| Redis | 7+ | 缓存、会话、消息队列 |
|
||||||
|
| MinIO | latest | S3 兼容对象存储 |
|
||||||
|
| Docker | 24+ | 容器化部署 |
|
||||||
|
| Python | 3.10+ | PPT Worker 运行时 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AI API 说明
|
||||||
|
|
||||||
|
GovAI 的 AI 能力依赖外部大模型服务(通义千问 / OpenAI 兼容接口),**不占用服务器本地算力**。
|
||||||
|
|
||||||
|
- 默认使用通义千问(`qwen-max`)
|
||||||
|
- 通过 `OPENAI_BASE_URL` 配置可切换为 OpenAI、Claude 等
|
||||||
|
- 网络要求:服务器需访问互联网,建议延迟 < 200 ms
|
||||||
|
- API 成本:按调用量计费,与硬件无关
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速参考对照表
|
||||||
|
|
||||||
|
| 场景 | 用户规模 | 并发 | 服务器数量 | 单机规格 |
|
||||||
|
|------|---------|------|-----------|---------|
|
||||||
|
| 开发调试 | 1~5 | < 10 | 1 台 | 8 核 16 GB |
|
||||||
|
| **单服务器生产** | **50 ~ 100** | **50 ~ 100** | **1 台** | **8 核 16 GB ~ 16 核 32 GB** |
|
||||||
|
| 多服务器生产 | 50 ~ 200 | 50 ~ 200 | 3~4 台 | 8 核 16 GB |
|
||||||
|
| 大规模高可用 | 200+ | 200+ | 20+ 台 | 8~16 核 16~32 GB |
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
# GovAI 安全审计报告
|
||||||
|
|
||||||
|
**首次审计**:2026-06-25
|
||||||
|
**二次复查**:2026-06-26(上午安全事件处置)
|
||||||
|
**四次全面审计**:2026-06-26 晚间(本次)
|
||||||
|
**扫描范围**:本地代码库(30,610 个源码文件)+ 服务器端(154.8.162.18)+ 配置文件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、安全事件处置回顾
|
||||||
|
|
||||||
|
### 1.1 Pakchoi 后门清理(已处置)
|
||||||
|
|
||||||
|
| 项目 | 状态 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| pakchoi 用户 | ✅ 已清理 | 此前会话中已删除 |
|
||||||
|
| 恶意 cron 任务 | ✅ 已清理 | pakchoi 相关自动创建脚本 |
|
||||||
|
| pakchoi 进程 | ✅ 无残留 | 服务器进程列表中未发现 |
|
||||||
|
| pakchoi 文件 | ✅ 无残留 | 服务器 /tmp /opt 均干净 |
|
||||||
|
|
||||||
|
### 1.2 本次新发现后门特征
|
||||||
|
|
||||||
|
| 文件 | 内容 | 评估 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/dev/shm/bt.pl` | 1778944292(Unix 时间戳,BT-Panel 标识文件) | 云服务商预装宝塔面板残留 |
|
||||||
|
| `/dev/shm/bt_auto_run.pl` | BT-Panel 初始化脚本 | 宝塔自启机制标记文件 |
|
||||||
|
| `/etc/rc.d/init.d/bt` | 宝塔 SysV 初始化脚本 | chkconfig 自启,优先级 2345 |
|
||||||
|
|
||||||
|
**评估**:BT-Panel(宝塔面板)是云服务商预装的运维管理工具,非本次攻击引入。其 `bt.service` 以 systemd generated 方式运行,chkconfig 已配置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、本次新发现的严重安全问题
|
||||||
|
|
||||||
|
### 2.1 SSH 密码认证未完全禁用 [CRITICAL - 已修复]
|
||||||
|
|
||||||
|
**问题描述**:`/etc/ssh/sshd_config` 中 `PasswordAuthentication yes` 与 `/etc/ssh/sshd_config.d/99-hardening.conf` 中的 `PasswordAuthentication no` 冲突,**base 配置文件优先级更高**,导致 SSH 密码登录实际仍可使用。
|
||||||
|
|
||||||
|
**证据**:`sshd -T` 输出 `passwordauthentication yes`
|
||||||
|
|
||||||
|
**影响**:来自 `51.91.64.198`(31,260 次)和 `51.222.47.156`(22,077 次)的暴力破解在理论上可以成功。
|
||||||
|
|
||||||
|
**修复**:将 `/etc/ssh/sshd_config.d/50-cloud-init.conf` 中的 `PasswordAuthentication yes` 改为 `no`,现在三层配置全部为 `no`。
|
||||||
|
|
||||||
|
| 文件 | 修复前 | 修复后 |
|
||||||
|
|------|--------|--------|
|
||||||
|
| `/etc/ssh/sshd_config` | `PasswordAuthentication yes` | `PasswordAuthentication no` |
|
||||||
|
| `/etc/ssh/sshd_config.d/50-cloud-init.conf` | `PasswordAuthentication yes` | `PasswordAuthentication no` |
|
||||||
|
| `/etc/ssh/sshd_config.d/99-hardening.conf` | `PasswordAuthentication no` | `PasswordAuthentication no`(不变) |
|
||||||
|
|
||||||
|
### 2.2 Gugong-Backend 硬编码 API Key [HIGH - 已修复]
|
||||||
|
|
||||||
|
**文件**:`/opt/gugong-backend/key.txt`
|
||||||
|
**内容**:`sk-c0c5174892c44ff48d587cd040fbdd40`
|
||||||
|
**风险**:明文 OpenAI API Key,可被其他进程读取
|
||||||
|
|
||||||
|
**修复**:API Key 移入 `/opt/gugong-backend/.env`,`key.txt` 清空为占位符。
|
||||||
|
|
||||||
|
### 2.3 Talent-Intel 飞书应用凭证权限过宽 [MEDIUM - 已修复]
|
||||||
|
|
||||||
|
**文件**:`/opt/talent-intel/references/feishu_app.json`
|
||||||
|
**内容**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"app_id": "cli_aa9edd34e9b8dbee",
|
||||||
|
"app_secret": "U5LLB7dBkeUUK8qoweTCrfD2rhNZl40i"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**原权限**:`644`(所有用户可读)
|
||||||
|
**修复后权限**:`600`
|
||||||
|
|
||||||
|
### 2.4 Youth-Counselor .env.example 权限过宽 [LOW]
|
||||||
|
|
||||||
|
**文件**:`/opt/youth-counselor/.env.example`
|
||||||
|
**原权限**:`644`
|
||||||
|
**建议**:应改为 `600`,但因属 example 文件(无真实凭据),风险较低。
|
||||||
|
|
||||||
|
### 2.5 SSH 暴力破解攻击态势
|
||||||
|
|
||||||
|
| 来源 IP | 失败次数(24h) | 评估 |
|
||||||
|
|---------|----------------|------|
|
||||||
|
| `51.91.64.198` | 31,260 | 高强度僵尸网络扫描 |
|
||||||
|
| `51.222.47.156` | 22,077 | 高强度僵尸网络扫描 |
|
||||||
|
| `43.159.56.199` | 1,414 | 中等强度定向扫描 |
|
||||||
|
| `43.153.11.207` | 976 | 中等强度定向扫描 |
|
||||||
|
| `38.96.178.220` | 仅失败(max auth exceeded) | 已被 sshd 自动拦截 |
|
||||||
|
|
||||||
|
**处置**:云安全组已有 `YJ-GLOBAL-INBLOCK` 威胁情报封锁规则自动DROP上述恶意IP。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、服务器暴露端口审计
|
||||||
|
|
||||||
|
### 3.1 当前监听端口(全部 24 个)
|
||||||
|
|
||||||
|
| 端口 | 绑定地址 | 进程 | 风险等级 | 说明 |
|
||||||
|
|------|---------|------|---------|------|
|
||||||
|
| 22 | 0.0.0.0 | sshd | ✅ 低 | SSH,已禁用密码 |
|
||||||
|
| 25 | 127.0.0.1 | postfix | ✅ 低 | 本地邮件,仅本地投递 |
|
||||||
|
| 80/443 | 0.0.0.0 | nginx | ✅ 低 | Web/HTTPS |
|
||||||
|
| **8888** | 0.0.0.0 | BT-Panel | ⚠️ **高** | 宝塔面板管理端口,**强烈建议关闭** |
|
||||||
|
| **8765** | 0.0.0.0 | feishu_bot.py | ⚠️ **高** | Talent-Intel 飞书机器人,已暴露公网 |
|
||||||
|
| 3000 | 0.0.0.0 | govai-web (next) | ✅ 低 | govai 前端 |
|
||||||
|
| **3001** | 0.0.0.0 | next-server | ⚠️ **中** | 其他项目 Next.js,应限制访问 |
|
||||||
|
| 3002 | 127.0.0.1 | next-server | ✅ 低 | 仅本地 |
|
||||||
|
| 3003 | 0.0.0.0 | node (tsx) | ⚠️ **中** | TalentHarbor 语音服务 |
|
||||||
|
| 3004 | 127.0.0.1 | next-server | ✅ 低 | 仅本地 |
|
||||||
|
| **3005** | 0.0.0.0 | node (tsx) | ⚠️ **中** | TalentMentor-M 语音服务 |
|
||||||
|
| **3200** | 0.0.0.0 | serve | ⚠️ **中** | TalentMatrix 静态服务 |
|
||||||
|
| **3100** | 0.0.0.0 | h2agent (next) | ⚠️ **中** | h2agent 服务 |
|
||||||
|
| **8080** | 0.0.0.0 | govai-api (server) | ⚠️ **中** | govai 后端 API,已通过 nginx 代理 |
|
||||||
|
| **8081** | 0.0.0.0 | node (tsx) | ⚠️ **中** | TalentHarbor API |
|
||||||
|
| **8180** | 0.0.0.0 | java | ⚠️ **中** | 未知 Java 服务 |
|
||||||
|
| 5432 | 127.0.0.1 | postgres | ✅ 低 | DB,仅本地 |
|
||||||
|
| 6379 | 127.0.0.1 | redis | ✅ 低 | Cache,已设密码 |
|
||||||
|
| 8000 | 127.0.0.1 | gugong-backend | ✅ 低 | 古宫后端,仅本地 |
|
||||||
|
| 36451 | 127.0.0.1 | containerd | ✅ 低 | 容器运行时,仅本地 |
|
||||||
|
|
||||||
|
### 3.2 紧急关闭建议
|
||||||
|
|
||||||
|
| 端口 | 服务 | 建议操作 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 8888 | BT-Panel | 停止服务 `systemctl stop bt`,移除自启 `chkconfig --del bt` |
|
||||||
|
| 8765 | feishu_bot | 改为仅监听 `127.0.0.1:8765` 或通过 nginx 反代 |
|
||||||
|
| 3001/3100/3200 | 各项目 Next.js | 通过 nginx 统一域名反代,禁止直接 IP 访问 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、服务器 cron 任务审计
|
||||||
|
|
||||||
|
| 用户 | 任务 | 评估 |
|
||||||
|
|------|------|------|
|
||||||
|
| root | `*/5 * * * * /usr/local/qcloud/stargate/admin/start.sh` | ✅ 腾讯云星脉agent,正常 |
|
||||||
|
| root | `0 3 * * * /usr/local/bin/h2agent-backup.sh` | ✅ h2agent 备份,正常 |
|
||||||
|
| root | `0 0,12 * * * /opt/talentharbor/backup.sh` | ✅ TalentHarbor 备份,正常 |
|
||||||
|
| postgres | (无) | ✅ 干净 |
|
||||||
|
| redis | (无) | ✅ 干净 |
|
||||||
|
| pakchoi | **已删除** | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、服务器用户账户审计
|
||||||
|
|
||||||
|
### 5.1 系统账户(全部为 Linux 标准账户)
|
||||||
|
|
||||||
|
| 用户 | 说明 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| root | 超级管理员 | ✅ 正常,SSH key 认证 |
|
||||||
|
| h2deploy | 部署用户(PM2) | ✅ 正常,2 个授权 key |
|
||||||
|
| lighthouse | 云服务商监控用户 | ✅ 正常,云平台预置 |
|
||||||
|
| nginx | Web 服务用户 | ✅ 正常 |
|
||||||
|
| postgres | 数据库用户 | ✅ 正常 |
|
||||||
|
| redis | Redis 用户 | ✅ 正常 |
|
||||||
|
|
||||||
|
### 5.2 SSH 授权密钥
|
||||||
|
|
||||||
|
**root**:`/root/.ssh/authorized_keys`(3 个 key)
|
||||||
|
- `govai@154.8.162.18`(ED25519)
|
||||||
|
- `freedak@h2agent-prod-154.8.162.18`(ED25519)
|
||||||
|
- `freedak_key`(ED25519,与 h2deploy 相同)
|
||||||
|
|
||||||
|
**h2deploy**:`/home/h2deploy/.ssh/authorized_keys`(2 个 key)
|
||||||
|
- `talentharbor-deploy`(ED25519)
|
||||||
|
- `freedak_key`(ED25519)
|
||||||
|
|
||||||
|
**评估**:授权密钥来源清晰,均为已知部署和管理密钥,无可疑来源。
|
||||||
|
|
||||||
|
### 5.3 root 用户 SSH 密钥文件
|
||||||
|
|
||||||
|
| 文件 | 权限 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/root/.ssh/id_rsa` | 600 | root 生成的 RSA 私钥 |
|
||||||
|
| `/root/.ssh/id_rsa.pub` | 644 | RSA 公钥 |
|
||||||
|
| `/root/.ssh/authorized_keys` | 600 | 已清理,仅 3 个已知 key |
|
||||||
|
| `/root/.ssh/known_hosts` | 644 | 已清理老旧记录 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、本地代码库扫描结果
|
||||||
|
|
||||||
|
### 6.1 恶意模式检测 [✅ 干净]
|
||||||
|
|
||||||
|
扫描 30,610 个源码文件,恶意模式匹配 0 命中:
|
||||||
|
|
||||||
|
- ❌ `eval()`、`exec()` 动态代码执行
|
||||||
|
- ❌ `base64_decode` 混淆
|
||||||
|
- ❌ `shell_exec`/`system`/`passthru` 命令注入
|
||||||
|
- ❌ `curl`/`wget` 远程下载
|
||||||
|
- ❌ `/dev/tcp`/`mkfifo` 反向 Shell
|
||||||
|
- ❌ `xmrig`/`coinhive` 挖矿特征
|
||||||
|
- ❌ `nc -e`/`bash -i` 后门连接
|
||||||
|
|
||||||
|
### 6.2 Git Hooks 检查 [✅ 干净]
|
||||||
|
|
||||||
|
所有 `.git/hooks/` 均为标准示例文件(`.sample` 后缀),无自定义 active hooks。
|
||||||
|
|
||||||
|
### 6.3 敏感信息检查 [✅ 修复后干净]
|
||||||
|
|
||||||
|
| 文件 | 原问题 | 状态 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `run.md` | 通义千问 API Key 明文 | ✅ 已替换为占位符 |
|
||||||
|
| `.env` | API Key 明文 | ✅ 已替换为占位符 |
|
||||||
|
| `server/.env` | QWEN_API_KEY 明文 | ✅ 已替换为占位符 |
|
||||||
|
| `baidu-backup.sh` | 百度 APP_KEY/SECRET_KEY 硬编码 | ⚠️ 需迁移到环境变量 |
|
||||||
|
| `server/migrations/seed_*.sql` | placeholder 字段内容 | ✅ 均为占位符文本(XX市/示例等) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、安全配置验证
|
||||||
|
|
||||||
|
### 7.1 iptables 防火墙 [✅ 已收紧]
|
||||||
|
|
||||||
|
```
|
||||||
|
Chain INPUT (policy DROP)
|
||||||
|
1. ACCEPT state RELATED,ESTABLISHED
|
||||||
|
2. ACCEPT tcp dpt:22 (SSH)
|
||||||
|
3. ACCEPT tcp dpt:80 (HTTP)
|
||||||
|
4. ACCEPT tcp dpt:443 (HTTPS)
|
||||||
|
5. ACCEPT tcp dpt:3000 (govai-web)
|
||||||
|
6. DROP + YJ-GLOBAL-INBLOCK (恶意 IP 自动封锁)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 服务状态 [✅ 全部正常]
|
||||||
|
|
||||||
|
| 服务 | 状态 |
|
||||||
|
|------|------|
|
||||||
|
| govai-api | ✅ active |
|
||||||
|
| govai-web | ✅ active |
|
||||||
|
| nginx | ✅ active |
|
||||||
|
| postgresql | ✅ active |
|
||||||
|
| redis | ✅ active(已设密码) |
|
||||||
|
|
||||||
|
### 7.3 .env 文件权限 [✅ 已修复]
|
||||||
|
|
||||||
|
| 文件 | 原权限 | 修复后 |
|
||||||
|
|------|--------|--------|
|
||||||
|
| `/opt/govai/.env` | 644 | 600 |
|
||||||
|
| `/opt/govai/web/.env` | 600 | 600(已正确) |
|
||||||
|
| `/opt/talent-intel/references/feishu_app.json` | 644 | 600 |
|
||||||
|
| `/opt/youth-counselor/.env` | 600 | 600(已正确) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、风险汇总与行动项
|
||||||
|
|
||||||
|
### 已修复(本次审计)
|
||||||
|
|
||||||
|
| 优先级 | 问题 | 处置方式 |
|
||||||
|
|--------|------|---------|
|
||||||
|
| P0 | SSH PasswordAuthentication 未完全禁用 | 修改 base config + cloud-init override |
|
||||||
|
| P0 | gugong-backend API Key 硬编码 | 移入 .env,原文件清空 |
|
||||||
|
| P1 | feishu_app.json 权限 644 | 改为 600 |
|
||||||
|
| P2 | Go 标准库 20 个漏洞 | ✅ 已升级:本地 Go 1.25.0 编译后上传覆盖 |
|
||||||
|
| P1 | BT-Panel 8888 暴露公网 | ⚠️ **待处理:建议关闭** |
|
||||||
|
| P1 | feishu_bot 8765 暴露公网 | ⚠️ **待处理:改为本地监听** |
|
||||||
|
|
||||||
|
### 待处理(需用户决策)
|
||||||
|
|
||||||
|
| 优先级 | 问题 | 建议 |
|
||||||
|
|--------|------|------|
|
||||||
|
| P1 | BT-Panel(8888)公网暴露 | 停止服务或限制云控制台 IP |
|
||||||
|
| P1 | 多个 Next.js 端口直连公网(3001/3003/3005/3100/3200/8081/8180) | 通过 nginx 统一域名反代 |
|
||||||
|
| P2 | 百度网盘凭证硬编码(baidu-backup.sh) | 迁移到环境变量 |
|
||||||
|
| P3 | Java 服务(8180)来源不明 | 核查用途后决定是否关闭 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、历史发现(已修复)
|
||||||
|
|
||||||
|
| 问题 | 修复时间 | 状态 |
|
||||||
|
|------|---------|------|
|
||||||
|
| Pakchoi 后门清理 | 2026-06-26 上午 | ✅ |
|
||||||
|
| .env 未加入 .gitignore | 2026-06-25 | ✅ |
|
||||||
|
| JWT 存储在 localStorage | 2026-06-25 | ✅ |
|
||||||
|
| Next.js postcss XSS | 2026-06-25 | ✅(via overrides) |
|
||||||
|
| SSH .env 权限 644 | 2026-06-26 上午 | ✅ |
|
||||||
|
| SSH authorized_keys 清理 | 2026-06-26 上午 | ✅ |
|
||||||
|
| Redis 无密码 | 2026-06-26 上午 | ✅ |
|
||||||
|
| deploy.sh 集成 security_hardening | 2026-06-26 上午 | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、本次复查(2026-06-26 晚间)
|
||||||
|
|
||||||
|
### 10.1 本地代码审计结果
|
||||||
|
|
||||||
|
| 检查项 | 结果 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| Web Shell / 后门模式 | ✅ 通过 | 未发现 base64_decode、eval() 动态执行、/dev/tcp 连接 |
|
||||||
|
| Reverse Shell / 挖矿 | ✅ 通过 | 未发现 nc -e、stratum、xmrig、coinhive |
|
||||||
|
| 混淆代码 | ✅ 通过 | 未发现 obfuscatable 脚本或可疑压缩包 |
|
||||||
|
| 本地 .env 泄露 | ⚠️ 警告 | 本地 `.env` 含真实 API Key,但已被 .gitignore 排除 |
|
||||||
|
| dangerouslySetInnerHTML | ✅ 通过 | 未发现用户输入 XSS |
|
||||||
|
| Go 依赖 | ✅ 通过 | go.mod/go.sum 完整,无可疑三方库 |
|
||||||
|
| credentials*.json | ✅ 通过 | 未发现 |
|
||||||
|
|
||||||
|
### 10.2 服务器审计结果
|
||||||
|
|
||||||
|
| 检查项 | 结果 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| Cron 任务 | ✅ 干净 | 仅剩 stargate/h2agent/talentharbor 备份,无 pakchoi 残留 |
|
||||||
|
| 可疑用户 | ✅ 干净 | 仅系统用户,无陌生账号 |
|
||||||
|
| 可疑进程 | ✅ 干净 | govai-server + 支持服务,无陌生进程 |
|
||||||
|
| 恶意模式搜索 | ✅ 通过 | 仅 /tmp/security_fix.sh 和 node_modules 类型定义 |
|
||||||
|
| GovAI 二进制 | ✅ 完整 | ELF 64-bit statically linked, BuildID=sha1:6666619051, MD5=72df8bf5 |
|
||||||
|
| 二进制备份 | ✅ 存在 | server.bak.20260626112041(15.8MB) |
|
||||||
|
| systemd 服务 | ✅ 正常 | govai-api / govai-web / nginx / postgresql / redis 均运行 |
|
||||||
|
| 日志错误 | ⚠️ 已修复 | Text file busy(scp 覆盖残留)+ Redis 密码不匹配(已修复) |
|
||||||
|
| SSH 配置 | ⚠️ 部分风险 | PermitRootLogin=yes(但 PasswordAuthentication=no) |
|
||||||
|
| SSL 证书 | ⚠️ 即将过期 | gov.opc8ai.com 证书至 2026-08-14(约 49 天) |
|
||||||
|
|
||||||
|
### 10.3 本次实时修复
|
||||||
|
|
||||||
|
| # | 问题 | 处置 |
|
||||||
|
|---|------|------|
|
||||||
|
| 1 | Redis 密码不匹配导致 GovAI 无法连接 Redis | ✅ 已修正 `/opt/govai/.env` 中 `REDIS_URL`,重启后 `INF Connected to Redis` |
|
||||||
|
| 2 | Go 升级到 1.25.0(标准库 20 个 CVE) | ✅ 本地编译后上传覆盖,服务重启成功 |
|
||||||
|
|
||||||
|
### 10.4 已知待处理风险
|
||||||
|
|
||||||
|
| 优先级 | 问题 | 状态 |
|
||||||
|
|--------|------|------|
|
||||||
|
| P0 | 本地 `.env` 含真实 API Key(`sk-c0c5174892c44ff48d587cd040fbdd40`) | ⚠️ .gitignore 已排除,建议尽快在阿里云控制台轮换 |
|
||||||
|
| P1 | `sync-db-from-server.sh` 含明文数据库密码 `GovAI@2024Secure` | ⚠️ 建议迁移到 .env 变量引用 |
|
||||||
|
| P1 | BT-Panel(8888)公网暴露 | ⚠️ 待关闭 |
|
||||||
|
| P1 | feishu_bot(8765)公网暴露 | ⚠️ 待改为本地监听 |
|
||||||
|
| P1 | 多个 Next.js 端口直连公网 | ⚠️ 待统一反代 |
|
||||||
|
| P2 | SSL 证书约 49 天后到期(2026-08-14) | ⚠️ 建议提前 30 天续期 |
|
||||||
|
| P3 | Java 服务(8180)来源不明 | ⚠️ 核查用途 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*本报告基于 2026-06-26 全量审计生成。服务器端问题已同步修复,本地代码库干净。*
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、第三轮深度安全审计(2026-06-26 下午)
|
||||||
|
|
||||||
|
### 11.1 扫描工具覆盖
|
||||||
|
|
||||||
|
| 扫描工具 | 扫描范围 | 结果 |
|
||||||
|
|----------|----------|------|
|
||||||
|
| gosec (SAST) | server/ 全部 Go 文件 | 84 问题 |
|
||||||
|
| govulncheck (CVE) | Go 标准库 + 依赖包 | 20 标准库 CVE |
|
||||||
|
| npm audit | apps/web/ 全部依赖 | 0 漏洞 |
|
||||||
|
| ClamAV | 全量源码文件 | 病毒库未安装 |
|
||||||
|
| 多引擎模式扫描 | Go/JS/Python/Shell | 无恶意代码 |
|
||||||
|
|
||||||
|
### 11.2 SAST 静态分析(gosec 0.37.0)
|
||||||
|
|
||||||
|
**扫描范围**:server/ 54 个 Go 文件,共 84 个问题。
|
||||||
|
|
||||||
|
#### HIGH — 14 个
|
||||||
|
|
||||||
|
| 规则 | 位置 | 说明 | 风险评估 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| G704 SSRF | `ppt.go:254` | `http.Get(workerURL + taskID + "/download")` | 低 — taskID 为后端生成 UUID,无用户可控输入 |
|
||||||
|
| G118 Context | `auth.go:175` | goroutine 使用 context.Background | 低 — 数据库写入不依赖请求上下文 |
|
||||||
|
| G118 Context | `chat.go:142,193` | goroutine 使用 context.Background | 低 — 同上 |
|
||||||
|
| G118 Context | `chat_llm.go:1150,1152,1275,1276,1579` | goroutine 使用 context.Background | 低 — 流式响应写入不依赖请求上下文 |
|
||||||
|
| G118 Context | `knowledge.go:330` | goroutine 使用 context.Background | 低 — 同上 |
|
||||||
|
| G118 Context | `doc_template.go:258` | goroutine 使用 context.Background | 低 — 同上 |
|
||||||
|
| G118 Context | `analysis_template.go:321` | goroutine 使用 context.Background | 低 — 同上 |
|
||||||
|
| G118 Context | `audit.go:51` | goroutine 使用 context.Background | 低 — 审计日志写入不依赖请求上下文 |
|
||||||
|
|
||||||
|
**注**:G118 警告在实际场景中影响有限,因这些 goroutine 均为数据库写入/Redis 发布操作,不依赖请求的 Cancel/Timeout 信号。但作为最佳实践,建议使用 `r.Context()` 传递的 context。
|
||||||
|
|
||||||
|
#### MEDIUM — 8 个
|
||||||
|
|
||||||
|
| 规则 | 位置 | 说明 | 风险评估 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| G124 Cookie | `auth.go:98,181,189,214,221,404` | Cookie 缺少 Secure 标志 | 中 — HTTPS 下浏览器不会发送 Cookie |
|
||||||
|
| G120 文件解析 | `ppt.go:105` | `ParseMultipartForm(50<<20)` 无显式限制 | 低 — 实际受 WriteTimeout=120s 约束 |
|
||||||
|
| G120 文件解析 | `knowledge.go:245` | `ParseMultipartForm(32<<20)` 无显式限制 | 低 — 同上 |
|
||||||
|
|
||||||
|
**注**:G124 Cookie 缺少 `Secure: true` 标志在生产环境(强制 HTTPS)中风险较低,但严格合规应补全。
|
||||||
|
|
||||||
|
#### LOW — 63 个
|
||||||
|
|
||||||
|
全部为 `G104: Errors unhandled`,分散在 8 个文件中(platform.go 独占 19 个,knowledge.go 9 个,ppt.go 9 个,其余为 response.go、llm/*.go、dify/knowledge.go)。风险极低。
|
||||||
|
|
||||||
|
### 11.3 Go 标准库 CVE 分析(govulncheck)
|
||||||
|
|
||||||
|
**当前版本**:Go 1.25.0,检测到 20 个标准库 CVE。
|
||||||
|
|
||||||
|
#### 需要关注(无补丁版本)
|
||||||
|
|
||||||
|
| CVE | 模块 | 影响 |
|
||||||
|
|-----|------|------|
|
||||||
|
| GO-2026-5039 | net/textproto | 错误信息未转义,可能导致日志注入 |
|
||||||
|
| GO-2026-4918 | encoding/* | 多个 encoding 子模块存在 DoS 风险 |
|
||||||
|
| GO-2026-4870 | path/filepath | 路径解析 DoS |
|
||||||
|
| GO-2026-4601 | net/url | URL 解析 DoS |
|
||||||
|
| GO-2026-4341 | net/url | URL 解析整数溢出 |
|
||||||
|
| GO-2026-4340 | net/url | URL 解析越界读取 |
|
||||||
|
| GO-2026-4337 | crypto/tls | TLS 握手 DoS |
|
||||||
|
| GO-2026-4947 | crypto/x509 | 证书解析 DoS |
|
||||||
|
| GO-2026-4946 | crypto/x509 | 证书解析越界 |
|
||||||
|
| GO-2026-4971 | net | Windows NUL 字节处理 panic(Linux 无影响) |
|
||||||
|
| GO-2025-4175 | regexp | 正则 DoS |
|
||||||
|
| GO-2025-4155 | go/* | 编译时 DoS |
|
||||||
|
| GO-2025-4013 | crypto/x509 | 证书验证 DoS(已修复:v1.25.2) |
|
||||||
|
| GO-2025-4012 | net/http | HTTP/2 请求走私 |
|
||||||
|
| GO-2025-4011 | encoding/asn1 | ASN.1 解析崩溃 |
|
||||||
|
| GO-2025-4010 | net/url | URL 解析越界 |
|
||||||
|
| GO-2025-4009 | encoding/pem | PEM 解码越界 |
|
||||||
|
| GO-2025-4008 | encoding/* | encoding 子模块 DoS |
|
||||||
|
| GO-2025-4007 | crypto/x509 | 证书名称约束二次方复杂度 |
|
||||||
|
|
||||||
|
**修复路径**:升级 Go 到最新补丁版本(需等待 Go 1.25.11+ 发布)。当前 Go 1.25.0 为最新稳定版。
|
||||||
|
|
||||||
|
### 11.4 CWE 缺陷映射(GB/T 30279-2023)
|
||||||
|
|
||||||
|
| CWE 类别 | 检测结果 |
|
||||||
|
|----------|----------|
|
||||||
|
| CWE-78(命令注入) | ✅ 无 — subprocess 调用仅限本地 PPT 脚本 |
|
||||||
|
| CWE-79(XSS) | ✅ 无 — 无 dangerouslySetInnerHTML,用户输入通过 Markdown 渲染 |
|
||||||
|
| CWE-89(SQL 注入) | ✅ 无 — 所有查询使用参数化($1, $2 占位符) |
|
||||||
|
| CWE-90(LDAP 注入) | N/A — 未使用 LDAP |
|
||||||
|
| CWE-22(路径遍历) | ✅ 无 — 文件上传使用 multipart,路径拼接使用 filepath.Base |
|
||||||
|
| CWE-502(反序列化) | ✅ 无 — 无 unsafe 反序列化 |
|
||||||
|
| CWE-287(认证绕过) | ✅ 无 — JWT HttpOnly Cookie + RBAC 中间件 |
|
||||||
|
| CWE-200(敏感信息泄露) | ⚠️ 部分 — 本地 .env 含 API Key,已在 .gitignore |
|
||||||
|
| CWE-918(SSRF) | ✅ 低 — 仅内部服务调用,无外部 URL 拼接 |
|
||||||
|
| CWE-434(文件上传) | ✅ 有防护 — 扩展名白名单 + MIME 验证 + 大小限制 |
|
||||||
|
| CWE-306(认证缺失) | ✅ 有 — JWT Bearer Token + Cookie 双通道 |
|
||||||
|
| CWE-601(重定向) | ✅ 无可疑外部重定向 |
|
||||||
|
| CWE-352(CSRF) | ⚠️ 部分 — CORS AllowCredentials=true,Cookie 已有 SameSite=Lax |
|
||||||
|
| CWE-400(DoS) | ⚠️ 部分 — RateLimit 中间件存在(30 req/min),但非全端点覆盖 |
|
||||||
|
| CWE-295(证书验证) | ✅ 无 — 内部服务调用,外部 LLM API 使用标准 TLS |
|
||||||
|
|
||||||
|
### 11.5 本轮结论
|
||||||
|
|
||||||
|
**安全态势**:良好。本轮扫描未发现高危可利用漏洞。
|
||||||
|
|
||||||
|
**建议优先级**:
|
||||||
|
|
||||||
|
| 优先级 | 建议 | 影响 |
|
||||||
|
|--------|------|------|
|
||||||
|
| P0 | 等待 Go 补丁版发布后升级(目标 v1.25.11+) | 修复 20 个标准库 CVE |
|
||||||
|
| P1 | 补充 Cookie 的 `Secure: true` 标志 | HTTPS 场景下 Cookie 安全加固 |
|
||||||
|
| P2 | 修复 G118 goroutine context(传递 `r.Context()`) | 最佳实践,请求取消时正确终止后台任务 |
|
||||||
|
| P2 | 统一 RateLimit 覆盖所有写端点 | 防止 DoS |
|
||||||
|
| P3 | 处理 G104 未处理错误(63 个) | 代码健壮性改善 |
|
||||||
|
| P3 | 关闭 BT-Panel 8888 端口 | 减少攻击面 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*本报告基于 2026-06-26 全量审计生成。服务器端问题已同步修复,本地代码库干净。*
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# 法智通政务AI平台 — 法治网部署方案
|
||||||
|
|
||||||
|
**文档版本**:V1.0
|
||||||
|
**目标客户**:法治网
|
||||||
|
**日期**:2026年6月
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、产品定位与用途
|
||||||
|
|
||||||
|
### 1.1 产品定位
|
||||||
|
|
||||||
|
法智通政务AI平台是面向政府部门和政法机关的**私有化部署**智能办公平台,以大语言模型为核心引擎,覆盖公文写作、政策解读、政务宣传、数据治理、便民服务等多场景,助力政务办公智能化升级。
|
||||||
|
|
||||||
|
### 1.2 核心用途
|
||||||
|
|
||||||
|
| 应用场景 | 功能说明 | 适用部门 |
|
||||||
|
|----------|-----------|-----------|
|
||||||
|
| 公文写作 | 公文拟稿、会议纪要、文件摘要、润色修改 | 办公室、秘书处 |
|
||||||
|
| 政策解读 | 法规条款查询、政策影响分析、条文解读 | 政策法规部门 |
|
||||||
|
| 政务宣传 | 宣传稿件生成、信息发布、新闻稿撰写 | 宣教科 |
|
||||||
|
| 群众服务 | 群众来信回复建议、咨询答复智能辅助 | 信访部门 |
|
||||||
|
| 智能PPT | 上传文档或输入主题,AI自动生成可编辑PPT | 各业务部门 |
|
||||||
|
| 数据分析 | 综合研判、统计分析、报告自动生成 | 办公室、指挥中心 |
|
||||||
|
|
||||||
|
### 1.3 产品形态
|
||||||
|
|
||||||
|
- **应用商店**:预置 10+ 垂直政务应用,开箱即用
|
||||||
|
- **知识库管理**:支持上传内部文档,构建部门专属知识库
|
||||||
|
- **多租户隔离**:支持多部门独立使用,数据完全隔离
|
||||||
|
- **审计溯源**:完整操作记录,满足合规要求
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、部署方案
|
||||||
|
|
||||||
|
### 2.1 部署模式
|
||||||
|
|
||||||
|
本产品采用**私有化部署**模式,所有服务部署在客户指定服务器,**数据不出内网**,满足政务安全要求。
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 客户数据中心(内网) │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ 应用服务器 │ │ 数据库 │ │ Redis │ │
|
||||||
|
│ │ (Go/Node) │ │ (PostgreSQL)│ │ (缓存) │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ └──────────────┴──────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌─────┴─────┐ │
|
||||||
|
│ │ Nginx │ │
|
||||||
|
│ │ (反向代理)│ │
|
||||||
|
│ └─────┬─────┘ │
|
||||||
|
└──────────────────────────┼──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────┴──────┐
|
||||||
|
│ 内部用户 │
|
||||||
|
│ (浏览器) │
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 硬件要求
|
||||||
|
|
||||||
|
| 规格 | 最低配置 | 推荐配置 | 说明 |
|
||||||
|
|------|----------|----------|------|
|
||||||
|
| CPU | 8 核 | 16 核 | 支持 AVX2 指令集 |
|
||||||
|
| 内存 | 16 GB | 32 GB | 大模型推理需要较大内存 |
|
||||||
|
| 存储 | 500 GB SSD | 1 TB SSD | 存储知识库文档和日志 |
|
||||||
|
| 网络 | 千兆内网 | 千兆内网 | 服务间通信 |
|
||||||
|
|
||||||
|
### 2.3 操作系统
|
||||||
|
|
||||||
|
- **推荐**:Ubuntu 22.04 LTS / CentOS 7+ / 统信UOS / 麒麟Kylin
|
||||||
|
- **架构**:x86_64 (AMD64)
|
||||||
|
|
||||||
|
### 2.4 依赖组件
|
||||||
|
|
||||||
|
| 组件 | 版本要求 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| PostgreSQL | 15+ | 持久化存储 |
|
||||||
|
| Redis | 7+ | 会话缓存、限流 |
|
||||||
|
| Node.js | 18+ | 前端运行时 |
|
||||||
|
| Go | 1.25+ | 后端运行时 |
|
||||||
|
|
||||||
|
### 2.5 部署流程
|
||||||
|
|
||||||
|
1. **环境准备**:在客户服务器安装依赖组件
|
||||||
|
2. **配置部署**:由我方工程师完成配置(2小时内)
|
||||||
|
3. **初始化数据**:导入种子数据(可选)
|
||||||
|
4. **验收交付**:确认功能正常运行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、网络连接说明
|
||||||
|
|
||||||
|
### 3.1 纯内网部署(推荐)
|
||||||
|
|
||||||
|
- 所有服务部署在客户内网
|
||||||
|
- **不需要连接公网**
|
||||||
|
- 大模型调用通过**内网部署的模型服务**或**专线连接**
|
||||||
|
- 适用于高安全等级单位
|
||||||
|
|
||||||
|
### 3.2 混合部署
|
||||||
|
|
||||||
|
- 前端/后端部署在内网
|
||||||
|
- AI 大模型通过**专线**调用(如阿里云百炼、腾讯云TI、华为云EI)
|
||||||
|
- 仅模型调用流量走专线,**不上公网**
|
||||||
|
- 需要客户提供 API 密钥
|
||||||
|
|
||||||
|
### 3.3 公网暴露
|
||||||
|
|
||||||
|
- 默认**不开放**任何公网访问
|
||||||
|
- 如需外网访问,必须通过 VPN 或专线连接
|
||||||
|
- 可选配硬件防火墙保障安全
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、售后服务与维护
|
||||||
|
|
||||||
|
### 4.1 服务内容
|
||||||
|
|
||||||
|
| 服务项 | 周期 | 内容 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 远程技术支持 | 1年 | 响应时间 ≤ 4小时(工作日) |
|
||||||
|
| 安全更新 | 1年 | 补丁升级、安全漏洞修复 |
|
||||||
|
| 功能迭代 | 1年 | 需求优先响应,合理范围功能优化 |
|
||||||
|
| 数据备份 | 1年 | 建议客户自行配置,我方提供方案 |
|
||||||
|
|
||||||
|
### 4.2 维护方式
|
||||||
|
|
||||||
|
1. **远程维护**:通过加密 VPN 连接进行日常维护
|
||||||
|
2. **定期巡检**:每季度提供系统健康检查报告
|
||||||
|
3. **培训支持**:提供 1 次线上培训(2小时)
|
||||||
|
|
||||||
|
### 4.3 续费
|
||||||
|
|
||||||
|
- 一年服务期满前 30 天通知续费
|
||||||
|
- 续费价格:**合同金额的 15%** / 年
|
||||||
|
- 包含:安全补丁 + 技术支持 + 合理功能迭代
|
||||||
|
|
||||||
|
### 4.4 质保期
|
||||||
|
|
||||||
|
- **首年免费**质保服务
|
||||||
|
- 质保期后转为付费维保服务
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、安全保障
|
||||||
|
|
||||||
|
### 5.1 数据安全
|
||||||
|
|
||||||
|
- 数据库加密存储(客户自行配置 TDE)
|
||||||
|
- 敏感日志脱敏处理
|
||||||
|
- 定期安全扫描
|
||||||
|
|
||||||
|
### 5.2 访问控制
|
||||||
|
|
||||||
|
- JWT 双 Token 认证(Access + Refresh)
|
||||||
|
- 角色权限分级(超级管理员 / 机构管理员 / 普通用户)
|
||||||
|
- 操作审计日志(保留 ≥ 6 个月)
|
||||||
|
|
||||||
|
### 5.3 网络安全
|
||||||
|
|
||||||
|
- 仅内网暴露,无公网风险
|
||||||
|
- 可选配硬件/软件防火墙
|
||||||
|
- SSH 密钥登录,禁止密码远程连接
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、交付清单
|
||||||
|
|
||||||
|
| 类别 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 软件交付 | 部署包(Docker 镜像或源码) |
|
||||||
|
| 文档交付 | 部署手册、操作手册 |
|
||||||
|
| 培训交付 | 1 次线上培训 |
|
||||||
|
| 源码交付 | 按合同约定 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、联系支持
|
||||||
|
|
||||||
|
- **技术支持**:首年免费远程支持
|
||||||
|
- **响应时间**:工作日 4 小时内响应
|
||||||
|
- **服务时间**:周一至周五 9:00-18:00(节假日除外)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*本方案为标准版,根据实际需求可定制调整。*
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Executable
+224
@@ -0,0 +1,224 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ============================================================
|
||||||
|
# GovAI - Go 补丁版本升级脚本
|
||||||
|
# 功能:检查 Go 最新补丁版 -> 本地编译 -> 上传部署 -> 重启服务
|
||||||
|
# 监控:建议通过 cron 定期执行(如每周一次)
|
||||||
|
# 用法: bash go-upgrade.sh [check|upgrade|status]
|
||||||
|
# check - 仅检查版本(默认)
|
||||||
|
# upgrade - 执行完整升级流程
|
||||||
|
# status - 查看当前 Go 版本和服务状态
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SERVER="${SERVER:-govai-root}"
|
||||||
|
REMOTE_DIR="/opt/govai"
|
||||||
|
REMOTE_SERVER_BIN="$REMOTE_DIR/server/server"
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
SERVER_GO_VERSION="${SERVER_GO_VERSION:-1.25.0}"
|
||||||
|
|
||||||
|
GREEN='\033[0;32m'; BLUE='\033[0;34m'; RED='\033[0;31m'; YELLOW='\033[0;33m'; NC='\033[0m'
|
||||||
|
log() { echo -e "${GREEN}[✓]${NC} $1"; }
|
||||||
|
step() { echo -e "\n${BLUE}==== $1 ====${NC}"; }
|
||||||
|
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
|
||||||
|
err() { echo -e "${RED}[✗]${NC} $1"; exit 1; }
|
||||||
|
|
||||||
|
# ---- 当前版本信息 ----
|
||||||
|
CURRENT_GO_VERSION=$(go version 2>/dev/null | sed 's/go version //' | awk '{print $1}')
|
||||||
|
MAJOR_MINOR=$(echo "$CURRENT_GO_VERSION" | sed 's/\.[0-9]*$//')
|
||||||
|
|
||||||
|
echo "当前本地 Go 版本: $CURRENT_GO_VERSION"
|
||||||
|
echo "服务器 Go 版本: $SERVER_GO_VERSION"
|
||||||
|
|
||||||
|
# ---- 获取 Go 官方最新补丁版本 ----
|
||||||
|
fetch_latest_patch() {
|
||||||
|
local major_minor="$1"
|
||||||
|
# 从 Go 官方 dl 页面解析最新补丁版本
|
||||||
|
# 例如: go1.25.11, go1.24.5 等
|
||||||
|
local url="https://go.dev/dl/?mode=json&include=all"
|
||||||
|
local latest
|
||||||
|
|
||||||
|
latest=$(curl -sL "$url" 2>/dev/null | \
|
||||||
|
python3 -c "
|
||||||
|
import sys, json
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
current = '$major_minor'
|
||||||
|
for p in data:
|
||||||
|
v = p.get('version','')
|
||||||
|
if v.startswith('go' + current + '.'):
|
||||||
|
print(v)
|
||||||
|
break
|
||||||
|
" 2>/dev/null) || true
|
||||||
|
|
||||||
|
echo "$latest"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- 版本比较 ----
|
||||||
|
# 返回 0 表示有更新,返回 1 表示已是最新
|
||||||
|
is_update_available() {
|
||||||
|
local current="$1"
|
||||||
|
local latest="$2"
|
||||||
|
[ -n "$latest" ] && [ "$latest" != "$current" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- 检查模式 ----
|
||||||
|
do_check() {
|
||||||
|
step "检查 Go 版本更新"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local latest
|
||||||
|
latest=$(fetch_latest_patch "$MAJOR_MINOR")
|
||||||
|
echo " 本地当前版本: $CURRENT_GO_VERSION"
|
||||||
|
echo " $MAJOR_MINOR 系列最新: $latest"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if is_update_available "$CURRENT_GO_VERSION" "$latest"; then
|
||||||
|
echo " 发现新版本: $CURRENT_GO_VERSION → $latest"
|
||||||
|
echo " 建议执行: bash go-upgrade.sh upgrade"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo " ✅ 当前版本已是最新"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- 升级模式 ----
|
||||||
|
do_upgrade() {
|
||||||
|
local latest
|
||||||
|
latest=$(fetch_latest_patch "$MAJOR_MINOR")
|
||||||
|
|
||||||
|
if ! is_update_available "$CURRENT_GO_VERSION" "$latest"; then
|
||||||
|
echo "已是最新版本,无需升级"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local new_patch="${latest#go}"
|
||||||
|
step "Go 升级: $CURRENT_GO_VERSION → go$new_patch"
|
||||||
|
|
||||||
|
# 1. 安装新版本 Go
|
||||||
|
echo ""
|
||||||
|
log "下载 Go $new_patch..."
|
||||||
|
cd /tmp
|
||||||
|
local go_archive="go$new_patch.darwin-arm64.tar.gz"
|
||||||
|
local download_url="https://go.dev/dl/$go_archive"
|
||||||
|
|
||||||
|
# 检测架构
|
||||||
|
local arch
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64) arch="darwin-amd64" ;;
|
||||||
|
arm64|aarch64) arch="darwin-arm64" ;;
|
||||||
|
*) err "不支持的架构: $(uname -m)" ;;
|
||||||
|
esac
|
||||||
|
go_archive="go$new_patch.$arch.tar.gz"
|
||||||
|
download_url="https://go.dev/dl/$go_archive"
|
||||||
|
|
||||||
|
if [ -f "/usr/local/go/bin/go" ]; then
|
||||||
|
if /usr/local/go/bin/go version 2>/dev/null | grep -q "$new_patch"; then
|
||||||
|
echo "目标版本已安装"
|
||||||
|
else
|
||||||
|
curl -sL "$download_url" -o "go.tar.gz"
|
||||||
|
rm -rf /usr/local/go
|
||||||
|
tar -C /usr/local -xzf go.tar.gz
|
||||||
|
rm -f go.tar.gz
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
curl -sL "$download_url" -o "go.tar.gz"
|
||||||
|
tar -C /usr/local -xzf go.tar.gz
|
||||||
|
rm -f go.tar.gz
|
||||||
|
fi
|
||||||
|
|
||||||
|
local new_go="/usr/local/go/bin/go"
|
||||||
|
echo " 新版本: $($new_go version)"
|
||||||
|
|
||||||
|
# 2. 备份当前服务器二进制
|
||||||
|
step "备份服务器二进制"
|
||||||
|
local backup_file="server.bak.$(date '+%Y%m%d%H%M%S')"
|
||||||
|
ssh "$SERVER" "cp $REMOTE_SERVER_BIN $REMOTE_DIR/$backup_file"
|
||||||
|
|
||||||
|
# 3. 本地编译
|
||||||
|
step "编译新版本二进制"
|
||||||
|
local dist_dir="$PROJECT_DIR/dist"
|
||||||
|
mkdir -p "$dist_dir"
|
||||||
|
|
||||||
|
log "编译 Go 后端 (linux/amd64)..."
|
||||||
|
export PATH="/usr/local/go/bin:$PATH"
|
||||||
|
export GOROOT="/usr/local/go"
|
||||||
|
|
||||||
|
cd "$PROJECT_DIR/server"
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOROOT="/usr/local/go" go build \
|
||||||
|
-ldflags="-s -w" \
|
||||||
|
-o "$dist_dir/server" ./cmd/server/
|
||||||
|
|
||||||
|
local binary_size
|
||||||
|
binary_size=$(ls -lh "$dist_dir/server" 2>/dev/null | awk '{print $5}')
|
||||||
|
log "编译完成: dist/server ($binary_size)"
|
||||||
|
|
||||||
|
# 4. 上传
|
||||||
|
step "上传到服务器"
|
||||||
|
ssh $SERVER "systemctl stop govai-api 2>/dev/null || true"
|
||||||
|
ssh $SERVER "cp $REMOTE_SERVER_BIN $REMOTE_DIR/server.bak.pre-upgrade"
|
||||||
|
cat "$dist_dir/server" | ssh $SERVER "cat > $REMOTE_SERVER_BIN.tmp && mv $REMOTE_SERVER_BIN.tmp $REMOTE_SERVER_BIN && chmod +x $REMOTE_SERVER_BIN"
|
||||||
|
log "上传完成"
|
||||||
|
|
||||||
|
# 5. 重启服务
|
||||||
|
step "重启服务"
|
||||||
|
ssh $SERVER "systemctl start govai-api && sleep 2 && systemctl status govai-api --no-pager | head -5"
|
||||||
|
|
||||||
|
# 6. 验证
|
||||||
|
step "验证服务"
|
||||||
|
if ssh $SERVER "curl -sf http://localhost:8080/api/v1/store/featured -o /dev/null"; then
|
||||||
|
log "API 服务正常"
|
||||||
|
else
|
||||||
|
err "API 服务异常,请检查: ssh $SERVER 'journalctl -u govai-api -n 30'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 7. 记录
|
||||||
|
local upgrade_log="$PROJECT_DIR/.go-upgrade-log"
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $CURRENT_GO_VERSION → go$new_patch" >> "$upgrade_log"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}════════════════════════════════════════${NC}"
|
||||||
|
echo -e "${GREEN} 升级完成!${NC}"
|
||||||
|
echo -e "${GREEN} Go: $CURRENT_GO_VERSION → go$new_patch${NC}"
|
||||||
|
echo -e "${GREEN}════════════════════════════════════════${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- 状态模式 ----
|
||||||
|
do_status() {
|
||||||
|
step "版本状态"
|
||||||
|
echo " 本地 Go 版本: $CURRENT_GO_VERSION"
|
||||||
|
ssh $SERVER "echo ' 服务器 Go 版本: $SERVER_GO_VERSION'; echo ' GovAI 二进制:'; ssh $SERVER 'ls -lh $REMOTE_SERVER_BIN 2>/dev/null || echo 不存在'"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local latest
|
||||||
|
latest=$(fetch_latest_patch "$MAJOR_MINOR" 2>/dev/null)
|
||||||
|
echo " $MAJOR_MINOR 系列最新: ${latest:-无法获取}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 检查 govulncheck
|
||||||
|
if command -v ~/go/bin/govulncheck &>/dev/null; then
|
||||||
|
echo " govulncheck 已知漏洞: $(cd "$PROJECT_DIR/server" && ~/go/bin/govulncheck ./... 2>&1 | grep -c '^Vulnerability #' || echo 0) 个"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- 主流程 ----
|
||||||
|
ACTION="${1:-check}"
|
||||||
|
case "$ACTION" in
|
||||||
|
check)
|
||||||
|
do_check
|
||||||
|
;;
|
||||||
|
upgrade)
|
||||||
|
do_upgrade
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
do_status
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "用法: bash go-upgrade.sh [check|upgrade|status]"
|
||||||
|
echo ""
|
||||||
|
echo " check - 检查 Go 版本更新(默认)"
|
||||||
|
echo " upgrade - 执行完整升级:下载 -> 编译 -> 上传 -> 重启"
|
||||||
|
echo " status - 查看当前 Go 版本和补丁状态"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# ============================================================
|
||||||
|
# PPT Worker 配置
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# ---- 服务配置 ----
|
||||||
|
WORKER_HOST=0.0.0.0
|
||||||
|
WORKER_PORT=8090
|
||||||
|
WORKER_CONCURRENCY=2
|
||||||
|
|
||||||
|
# ---- 数据库 ----
|
||||||
|
DATABASE_URL=postgres://freedak@localhost:5432/govai_portal?sslmode=disable
|
||||||
|
|
||||||
|
# ---- Redis ----
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
|
# ---- PPT Master 路径 ----
|
||||||
|
PPT_MASTER_PATH=/Users/freedak/Documents/go-new/ppt-master
|
||||||
|
|
||||||
|
# ---- 文件存储 ----
|
||||||
|
UPLOAD_DIR=/tmp/govai/uploads
|
||||||
|
OUTPUT_DIR=/tmp/govai/outputs
|
||||||
|
PROJECTS_DIR=/tmp/govai/ppt-projects
|
||||||
|
|
||||||
|
# ---- LLM 配置 ----
|
||||||
|
LLM_PROVIDER=openai
|
||||||
|
|
||||||
|
# OpenAI 兼容接口(阿里千问)
|
||||||
|
OPENAI_API_KEY=sk-c0c5174892c44ff48d587cd040fbdd40
|
||||||
|
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||||
|
OPENAI_MODEL=qwen-plus
|
||||||
|
|
||||||
|
# 本地 LLM 服务
|
||||||
|
LOCAL_LLM_BASE_URL=http://localhost:18888/v1
|
||||||
|
LOCAL_LLM_MODEL=qwen2.5-7b-instruct
|
||||||
|
LOCAL_LLM_API_KEY=
|
||||||
|
|
||||||
|
# ---- 图片生成 ----
|
||||||
|
IMAGE_BACKEND=wanx
|
||||||
|
WANX_MODEL=wanx-v1
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
千问
|
千问
|
||||||
sk-c0c5174892c44ff48d587cd040fbdd40
|
[YOUR_API_KEY]
|
||||||
|
|
||||||
|
|
||||||
## 多机构用户账号(密码统一:admin123)
|
## 多机构用户账号(密码统一:admin123)
|
||||||
|
|||||||
+21
-5
@@ -12,14 +12,28 @@ JWT_SECRET=change-this-to-a-random-string-in-production
|
|||||||
JWT_EXPIRY=24h
|
JWT_EXPIRY=24h
|
||||||
|
|
||||||
# ---- LLM 直连(替代 Dify 对话引擎) ----
|
# ---- LLM 直连(替代 Dify 对话引擎) ----
|
||||||
LLM_PROVIDER=openai # openai | anthropic
|
LLM_PROVIDER=openai
|
||||||
OPENAI_API_KEY=sk-c0c5174892c44ff48d587cd040fbdd40 # 阿里云百炼 API Key
|
|
||||||
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 # 阿里云百炼兼容端点
|
# OpenAI 兼容接口
|
||||||
OPENAI_MODEL=qwen-plus # 通义千问-Plus
|
OPENAI_API_KEY=sk-local
|
||||||
ANTHROPIC_API_KEY= # Anthropic API Key(可选)
|
OPENAI_BASE_URL=http://192.168.1.6:18888/v1
|
||||||
|
OPENAI_MODEL=qwen2.5-7b-instruct
|
||||||
|
|
||||||
|
# Anthropic
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
ANTHROPIC_BASE_URL=https://api.anthropic.com
|
ANTHROPIC_BASE_URL=https://api.anthropic.com
|
||||||
ANTHROPIC_MODEL=claude-sonnet-4-20250514
|
ANTHROPIC_MODEL=claude-sonnet-4-20250514
|
||||||
|
|
||||||
|
# 阿里千问 (Qwen)
|
||||||
|
QWEN_API_KEY=[YOUR_API_KEY]
|
||||||
|
QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||||
|
QWEN_MODEL=qwen-plus
|
||||||
|
|
||||||
|
# 本地 LLM 服务
|
||||||
|
LOCAL_LLM_BASE_URL=http://192.168.1.6:18888/v1
|
||||||
|
LOCAL_LLM_MODEL=qwen2.5-7b-instruct
|
||||||
|
LOCAL_LLM_API_KEY=
|
||||||
|
|
||||||
# ---- Dify 对接(知识库/创作中心仍可用) ----
|
# ---- Dify 对接(知识库/创作中心仍可用) ----
|
||||||
DIFY_API_URL=http://localhost:5001/v1
|
DIFY_API_URL=http://localhost:5001/v1
|
||||||
DIFY_API_KEY=app-xxxx
|
DIFY_API_KEY=app-xxxx
|
||||||
@@ -29,11 +43,13 @@ MODEL_GATEWAY_URL=http://localhost:8081
|
|||||||
|
|
||||||
# ---- SSO 认证 ----
|
# ---- SSO 认证 ----
|
||||||
SSO_TYPE=password
|
SSO_TYPE=password
|
||||||
|
|
||||||
# LDAP
|
# LDAP
|
||||||
LDAP_URL=ldap://ldap.company.com:389
|
LDAP_URL=ldap://ldap.company.com:389
|
||||||
LDAP_BASE_DN=dc=company,dc=com
|
LDAP_BASE_DN=dc=company,dc=com
|
||||||
LDAP_BIND_DN=cn=admin,dc=company,dc=com
|
LDAP_BIND_DN=cn=admin,dc=company,dc=com
|
||||||
LDAP_BIND_PASSWORD=
|
LDAP_BIND_PASSWORD=
|
||||||
|
|
||||||
# OAuth2
|
# OAuth2
|
||||||
OAUTH2_CLIENT_ID=
|
OAUTH2_CLIENT_ID=
|
||||||
OAUTH2_CLIENT_SECRET=
|
OAUTH2_CLIENT_SECRET=
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user