Compare commits

..

3 Commits

Author SHA1 Message Date
selfrelease 96b944ac8d chore: 前端代码优化 - ESLint 配置、Prettier 格式化、加载骨架屏、React 组件规范
- 更新 ESLint 配置添加 react-hooks 规则
- 运行 Prettier 格式化所有前端文件
- 为 users、apps、audit、quotas 页面添加 Skeleton 加载骨架屏
- 修复动态组件渲染问题(CategoryIcon/TypeIcon 使用 React.createElement)
- 修复 useRef 渲染期间访问问题(改用 useMemo)
- 修复 effect 中 setState 同步调用问题
- 新增 global-not-found.tsx 404 页面
- 修复 knowledge 页面引号转义问题
2026-06-23 14:48:31 +08:00
selfrelease 6ca1c27162 fix: 修复前端编译 TS 错误
- App 类型补充 is_favorited?: boolean 字段
- platform/providers 页面 testConnection mutationFn 补充泛型参数
2026-06-23 14:48:31 +08:00
selfrelease 65dc805eb5 feat: 系统优化 - ESLint、Tailwind、前端健壮性、后端工程化、运维可观测性
- 前端: ESLint+Prettier配置、Tailwind v4配置、ErrorBoundary、全局AuthLoader优化、ReactQuery分层
- 后端: MinIO凭证移除、Docker统一为govai品牌、zerolog日志封装、错误码枚举、文件上传校验、单元测试(13项全通过)
- 运维: 健康检查增强(PG/Redis ping)、Prometheus指标(/metrics端点)、多租户tenant包、RateLimit nil防御
- 移动: citation_prompt.txt → internal/assets/
2026-06-23 14:48:31 +08:00
95 changed files with 3671 additions and 2227 deletions
+158
View File
@@ -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 有明文 fallbackDocker/生产环境若 `.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 clientGo 端 `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、文件上传校验 |
+8
View File
@@ -0,0 +1,8 @@
{
"singleQuote": false,
"semi": true,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 100,
"plugins": []
}
+15 -8
View File
@@ -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;
+291 -164
View File
@@ -28,14 +28,19 @@
"zustand": "^5.0.13" "zustand": "^5.0.13"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.0.0",
"@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 +57,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 +71,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 +110,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 +138,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 +175,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 +197,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 +278,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 +426,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 +458,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 +870,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 +1962,19 @@
"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/@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 +2951,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 +2974,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 +2990,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 +3015,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 +3037,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 +3055,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 +3072,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 +3097,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 +3111,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 +3140,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 +3150,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 +3163,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 +3178,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 +3191,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 +3215,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 +3234,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 +5075,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 +5173,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 +5348,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 +5678,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",
@@ -6309,9 +6381,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 +7209,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"
@@ -9461,6 +9543,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 +9655,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 +10939,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 +11125,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 +11322,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"
+7 -2
View File
@@ -29,13 +29,18 @@
"zustand": "^5.0.13" "zustand": "^5.0.13"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.0.0",
"@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"
} }
} }
+7 -8
View File
@@ -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}
+1 -3
View File
@@ -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)}
+23 -3
View File
@@ -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">
+5 -5
View File
@@ -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
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" 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" />
+64 -11
View File
@@ -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>
+6 -2
View File
@@ -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}
> >
+47 -4
View File
@@ -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>
{data?.items == null && !search ? (
<div className="border rounded-lg overflow-hidden"> <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>
); );
} }
+9 -33
View File
@@ -57,18 +57,12 @@ export default function LoginPage() {
const user = useAuthStore.getState().user; const user = useAuthStore.getState().user;
// 平台管理员不绑定机构,登录后保留 super_admin 身份; // 平台管理员不绑定机构,登录后保留 super_admin 身份;
// 仅机构管理员在所选机构与自身归属不一致时才触发切换 // 仅机构管理员在所选机构与自身归属不一致时才触发切换
if ( if (user && user.role === "admin" && user.org_id !== selectedOrg) {
user &&
user.role === "admin" &&
user.org_id !== selectedOrg
) {
await switchOrg(selectedOrg); await switchOrg(selectedOrg);
} }
router.push(user?.role === "super_admin" ? "/platform/overview" : "/store"); router.push(user?.role === "super_admin" ? "/platform/overview" : "/store");
} catch (err) { } catch (err) {
setErrorMsg( setErrorMsg(err instanceof Error ? err.message : "登录失败,请检查账号和密码");
err instanceof Error ? err.message : "登录失败,请检查账号和密码"
);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -93,12 +87,8 @@ export default function LoginPage() {
<Shield className="h-8 w-8 text-white" /> <Shield className="h-8 w-8 text-white" />
</div> </div>
<div> <div>
<h1 className="text-3xl font-bold text-white tracking-tight"> <h1 className="text-3xl font-bold text-white tracking-tight">AI </h1>
AI <p className="text-blue-200/80 text-sm mt-0.5"> · </p>
</h1>
<p className="text-blue-200/80 text-sm mt-0.5">
·
</p>
</div> </div>
</div> </div>
@@ -127,9 +117,7 @@ export default function LoginPage() {
<div className="lg:hidden bg-gradient-to-r from-blue-900 to-blue-800 px-8 pt-8 pb-6 text-center"> <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" /> <Shield className="h-10 w-10 text-white mx-auto mb-3" />
<h1 className="text-xl font-bold text-white">AI </h1> <h1 className="text-xl font-bold text-white">AI </h1>
<p className="text-blue-200/80 text-sm mt-1"> <p className="text-blue-200/80 text-sm mt-1"> · </p>
·
</p>
</div> </div>
{/* 表单区域 */} {/* 表单区域 */}
@@ -137,9 +125,7 @@ export default function LoginPage() {
<h2 className="hidden lg:block text-2xl font-semibold text-gray-900 mb-1"> <h2 className="hidden lg:block text-2xl font-semibold text-gray-900 mb-1">
</h2> </h2>
<p className="hidden lg:block text-sm text-gray-500 mb-8"> <p className="hidden lg:block text-sm text-gray-500 mb-8"></p>
</p>
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
{errorMsg && ( {errorMsg && (
@@ -169,19 +155,12 @@ export default function LoginPage() {
))} ))}
</select> </select>
) : ( ) : (
<Input <Input disabled placeholder="正在加载机构列表..." className="h-11 rounded-xl" />
disabled
placeholder="正在加载机构列表..."
className="h-11 rounded-xl"
/>
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label <Label htmlFor="email" className="text-sm font-medium text-gray-700">
htmlFor="email"
className="text-sm font-medium text-gray-700"
>
</Label> </Label>
<Input <Input
@@ -197,10 +176,7 @@ export default function LoginPage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label <Label htmlFor="password" className="text-sm font-medium text-gray-700">
htmlFor="password"
className="text-sm font-medium text-gray-700"
>
</Label> </Label>
<Input <Input
+6 -1
View File
@@ -39,7 +39,12 @@ 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: {
id: string;
name: string;
email: string;
role: "user" | "super_admin" | "admin" | "creator";
};
access_token: string; access_token: string;
}>("/api/v1/auth/register", { name, email, password }); }>("/api/v1/auth/register", { name, email, password });
@@ -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,
+132 -131
View File
@@ -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,8 +243,7 @@ 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);
@@ -296,9 +275,7 @@ export default function CreatePage() {
} 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,9 +603,7 @@ 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助手,专业、准确地解答相关问题..."
@@ -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
@@ -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>
+40 -24
View File
@@ -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 "私有";
} }
} }
@@ -147,16 +142,19 @@ export default function KnowledgePage() {
fileInputRef.current?.click(); fileInputRef.current?.click();
}, []); }, []);
const onFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const onFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
uploadDoc.mutate(file); uploadDoc.mutate(file);
e.target.value = ""; 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>
+7 -8
View File
@@ -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">&ldquo;{query}&rdquo;</h1>
&ldquo;{query}&rdquo;
</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}`}>
-48
View File
@@ -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>
);
}
+48
View File
@@ -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>
);
}
+37
View File
@@ -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>
);
}
+4 -5
View File
@@ -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">
<ErrorBoundary>
<Providers>{children}</Providers> <Providers>{children}</Providers>
</ErrorBoundary>
<Toaster position="top-center" richColors /> <Toaster position="top-center" richColors />
</body> </body>
</html> </html>
+68 -6
View File
@@ -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}
/> />
{data?.items == null && !statusFilter && orgFilter === "all" ? (
<div className="border rounded-lg overflow-hidden"> <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>
); );
} }
+48 -3
View File
@@ -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}
/> />
{data?.items == null && !search && orgFilter === "all" ? (
<div className="border rounded-lg overflow-hidden"> <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>
); );
} }
+1 -3
View File
@@ -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" />
+8 -27
View File
@@ -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>
+20 -12
View File
@@ -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">
+49 -39
View File
@@ -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,7 +72,8 @@ 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 =
providersData
?.flatMap((p) => (Array.isArray(p.models) ? p.models : [])) ?.flatMap((p) => (Array.isArray(p.models) ? p.models : []))
.filter((m, i, arr) => arr.findIndex((x) => x.name === m.name) === i) ?? []; .filter((m, i, arr) => arr.findIndex((x) => x.name === m.name) === i) ?? [];
@@ -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>
+11 -14
View File
@@ -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
+11 -8
View File
@@ -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>
+147 -63
View File
@@ -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,7 +140,11 @@ 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 [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 [conversationId, setConversationId] = useState<string | undefined>();
@@ -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(
async (convId: string) => {
setConversationId(convId); setConversationId(convId);
try { try {
const data = await api.get<{ data: Message[] }>(`/api/v1/apps/${app.id}/conversations/${convId}/messages`); const data = await api.get<{ data: Message[] }>(
`/api/v1/apps/${app.id}/conversations/${convId}/messages`,
);
setMessages(data.data || []); setMessages(data.data || []);
} catch { } catch {
setMessages([]); setMessages([]);
} }
}, [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 suggestedPrompts = useRef( const suggestedPrompts = useMemo(() => {
(() => {
try { try {
if (typeof app.suggested_prompts === "string") return JSON.parse(app.suggested_prompts) as string[]; if (typeof app.suggested_prompts === "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(
async (convId: string) => {
try { try {
await api.delete(`/api/v1/apps/${app.id}/conversations/${convId}`); await api.delete(`/api/v1/apps/${app.id}/conversations/${convId}`);
if (conversationId === convId) startNewConversation(); if (conversationId === convId) startNewConversation();
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] }); queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
toast.success("对话已删除"); toast.success("对话已删除");
} catch { toast.error("删除失败"); } } catch {
toast.error("删除失败");
}
setDeleteTarget(null); setDeleteTarget(null);
}, [app.id, conversationId, startNewConversation, queryClient]); },
[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>
+60 -112
View File
@@ -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" />
+53 -87
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useRef, useEffect, useCallback, memo } from "react"; import React from "react";
import { useState, 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";
import type { App, Conversation, Message, Chunk } from "@/lib/types"; import type { App, Conversation, Message, Chunk } from "@/lib/types";
@@ -97,7 +98,11 @@ 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 [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 [conversationId, setConversationId] = useState<string | undefined>();
@@ -124,9 +129,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,
@@ -140,25 +143,16 @@ 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[];
@@ -166,34 +160,30 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
} 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;
@@ -279,12 +269,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
abortRef.current = controller; abortRef.current = controller;
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();
@@ -304,8 +289,7 @@ 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) setConversationId(event.conversation_id);
setConversationId(event.conversation_id);
// 解析首包的 chunks 映射表 // 解析首包的 chunks 映射表
if (event.chunks) { if (event.chunks) {
console.log("[SSE] Received chunks:", event.chunks); console.log("[SSE] Received chunks:", event.chunks);
@@ -316,10 +300,8 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
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 {
@@ -340,8 +322,8 @@ 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;
@@ -356,7 +338,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
sendMessage(); sendMessage();
} }
}, },
[sendMessage] [sendMessage],
); );
const startNewConversation = useCallback(() => { const startNewConversation = useCallback(() => {
@@ -393,7 +375,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 () => {
@@ -402,8 +384,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({
@@ -414,22 +395,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()) {
@@ -437,10 +409,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],
}); });
@@ -454,10 +425,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>
@@ -494,9 +462,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
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" 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"
@@ -518,9 +488,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 ? (
@@ -537,9 +505,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" />
@@ -572,18 +538,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" />
) : ( ) : (
@@ -607,13 +568,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双击重命名`}
> >
@@ -668,7 +625,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>
@@ -728,7 +687,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" />
@@ -758,7 +720,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>
@@ -198,7 +199,9 @@ export default function CompletionUI({ app }: CompletionUIProps) {
}`} }`}
> >
<div className="text-sm font-medium truncate">{fmt.name}</div> <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> <div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{fmt.description}
</div>
</button> </button>
))} ))}
</div> </div>
@@ -225,8 +228,16 @@ export default function CompletionUI({ app }: CompletionUIProps) {
disabled={isLoading} disabled={isLoading}
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button onClick={handleGenerate} disabled={!input.trim() || isLoading} className="gap-2"> <Button
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />} 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 ? "生成中..." : "生成"} {isLoading ? "生成中..." : "生成"}
</Button> </Button>
{selectedFormat && formatTemplates[selectedFormat] && ( {selectedFormat && formatTemplates[selectedFormat] && (
@@ -260,7 +271,12 @@ export default function CompletionUI({ app }: CompletionUIProps) {
<CardContent className="p-5"> <CardContent className="p-5">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<label className="text-sm font-medium text-emerald-800">{outputLabel}</label> <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"> <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 ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{copied ? "已复制" : "复制"} {copied ? "已复制" : "复制"}
</Button> </Button>
@@ -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双击重命名`}
> >
+108 -62
View File
@@ -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,7 +372,8 @@ ${paragraphs.join("\n")}
setFieldData((prev) => ({ ...prev, [key]: val })); setFieldData((prev) => ({ ...prev, [key]: val }));
}, []); }, []);
const selectTemplate = useCallback((tpl: DocTemplate) => { const selectTemplate = useCallback(
(tpl: DocTemplate) => {
setSelectedTemplate(tpl); setSelectedTemplate(tpl);
const defaults: Record<string, string> = {}; const defaults: Record<string, string> = {};
for (const f of tpl.fields) { for (const f of tpl.fields) {
@@ -375,7 +383,9 @@ ${paragraphs.join("\n")}
startTransition(() => { startTransition(() => {
setPhase("form"); 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">
+84 -21
View File
@@ -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>
@@ -265,13 +272,25 @@ export default function WorkflowUI({ app }: WorkflowUIProps) {
<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
? "bg-emerald-50 text-emerald-700"
: "text-muted-foreground hover:bg-muted"
}`} }`}
> >
{done ? <CheckCircle2 className="h-4 w-4" /> : active ? <CircleDot className="h-4 w-4" /> : <Circle className="h-4 w-4" />} {done ? (
<CheckCircle2 className="h-4 w-4" />
) : active ? (
<CircleDot className="h-4 w-4" />
) : (
<Circle className="h-4 w-4" />
)}
{step.label} {step.label}
</button> </button>
{idx < steps.length - 1 && <ChevronRight className="h-4 w-4 text-muted-foreground/50 shrink-0" />} {idx < steps.length - 1 && (
<ChevronRight className="h-4 w-4 text-muted-foreground/50 shrink-0" />
)}
</div> </div>
); );
})} })}
@@ -290,8 +309,14 @@ export default function WorkflowUI({ app }: WorkflowUIProps) {
<Card> <Card>
<CardContent className="p-6 space-y-4"> <CardContent className="p-6 space-y-4">
<div> <div>
<h2 className="text-lg font-semibold"> {currentStep + 1}{currentStepData.label}</h2> <h2 className="text-lg font-semibold">
{currentStepData.description && <p className="text-sm text-muted-foreground mt-1">{currentStepData.description}</p>} {currentStep + 1}{currentStepData.label}
</h2>
{currentStepData.description && (
<p className="text-sm text-muted-foreground mt-1">
{currentStepData.description}
</p>
)}
</div> </div>
{currentStepData.type === "select" && currentStepData.options ? ( {currentStepData.type === "select" && currentStepData.options ? (
@@ -303,10 +328,13 @@ export default function WorkflowUI({ app }: WorkflowUIProps) {
return ( return (
<button <button
key={opt} key={opt}
onClick={() => setFormData((prev) => ({ ...prev, [currentStepData.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] ${ 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 isSelected
? "ring-2 ring-purple-400 ring-offset-2 shadow-md scale-[1.02] " + color ? "ring-2 ring-purple-400 ring-offset-2 shadow-md scale-[1.02] " +
color
: color : color
}`} }`}
> >
@@ -319,19 +347,34 @@ export default function WorkflowUI({ app }: WorkflowUIProps) {
) : ( ) : (
<Textarea <Textarea
value={formData[currentStepData.key] || ""} value={formData[currentStepData.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [currentStepData.key]: e.target.value }))} onChange={(e) =>
setFormData((prev) => ({ ...prev, [currentStepData.key]: e.target.value }))
}
placeholder={currentStepData.placeholder || "请输入..."} placeholder={currentStepData.placeholder || "请输入..."}
className="min-h-[140px] resize-none" className="min-h-[140px] resize-none"
/> />
)} )}
<div className="flex items-center justify-between pt-2"> <div className="flex items-center justify-between pt-2">
<Button variant="outline" onClick={handleBack} disabled={currentStep === 0} className="gap-1.5"> <Button
variant="outline"
onClick={handleBack}
disabled={currentStep === 0}
className="gap-1.5"
>
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
<Button onClick={handleNext} disabled={!canProceed} className="gap-1.5"> <Button onClick={handleNext} disabled={!canProceed} className="gap-1.5">
{isLastStep ? (<><Play className="h-4 w-4" /> </>) : (<> <ChevronRight className="h-4 w-4" /></>)} {isLastStep ? (
<>
<Play className="h-4 w-4" />
</>
) : (
<>
<ChevronRight className="h-4 w-4" />
</>
)}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -344,28 +387,48 @@ export default function WorkflowUI({ app }: WorkflowUIProps) {
<Loader2 className="h-10 w-10 animate-spin text-purple-500" /> <Loader2 className="h-10 w-10 animate-spin text-purple-500" />
<div className="text-center"> <div className="text-center">
<p className="font-medium">...</p> <p className="font-medium">...</p>
<p className="text-sm text-muted-foreground mt-1">AI </p> <p className="text-sm text-muted-foreground mt-1">
AI
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
)} )}
{(isComplete || (isLoading && output)) && output && ( {(isComplete || (isLoading && output)) && output && (
<Card className={isComplete ? "border-emerald-200/60 bg-emerald-50/30" : "border-purple-200/60 bg-purple-50/20"}> <Card
className={
isComplete
? "border-emerald-200/60 bg-emerald-50/30"
: "border-purple-200/60 bg-purple-50/20"
}
>
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className={`text-lg font-semibold ${isComplete ? "text-emerald-800" : "text-purple-800"}`}> <h2
className={`text-lg font-semibold ${isComplete ? "text-emerald-800" : "text-purple-800"}`}
>
{isComplete ? "处理结果" : "正在生成..."} {isComplete ? "处理结果" : "正在生成..."}
</h2> </h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isLoading && <Loader2 className="h-4 w-4 animate-spin text-purple-500" />} {isLoading && <Loader2 className="h-4 w-4 animate-spin text-purple-500" />}
{isComplete && ( {isComplete && (
<> <>
<Button variant="ghost" size="sm" onClick={handleCopy} className="gap-1.5 text-xs h-7"> <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 ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{copied ? "已复制" : "复制"} {copied ? "已复制" : "复制"}
</Button> </Button>
<Button variant="outline" size="sm" onClick={handleReset} className="gap-1.5 text-xs h-7"> <Button
variant="outline"
size="sm"
onClick={handleReset}
className="gap-1.5 text-xs h-7"
>
<RotateCcw className="h-3 w-3" /> <RotateCcw className="h-3 w-3" />
</Button> </Button>
@@ -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;
}
}
+127 -38
View File
@@ -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>
</> </>
+39 -15
View File
@@ -2,16 +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(() => {
if (!isPublic) {
fetchUser(); fetchUser();
}, [fetchUser]); }
}, [fetchUser, isPublic]);
// 公开路由:直接渲染,不阻塞
if (isPublic) {
return <>{children}</>;
}
// 鉴权路由:未登录则跳转
if (!isLoading && !isAuthenticated) {
router.push("/login");
return (
<div className="flex h-screen items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
if (isLoading) { if (isLoading) {
return ( return (
@@ -24,23 +50,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 (
+31 -56
View File
@@ -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,
} };
+20 -36
View File
@@ -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,
}
+12 -15
View File
@@ -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 };
+7 -7
View File
@@ -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 };
+15 -26
View File
@@ -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,
}
+32 -47
View File
@@ -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,
} };
+30 -52
View File
@@ -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,
} };
+46 -56
View File
@@ -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,
} };
+18 -26
View File
@@ -25,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>
), ),
@@ -50,26 +46,16 @@ 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(/^知识库:/, "");
@@ -92,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>
); );
@@ -204,7 +193,10 @@ function preprocessCitations(content: string, chunks?: Chunk[]): string {
result = result.replace(/^(\s*\>?\s*)AI建议[:]\s*$/gm, "$1[AI建议](#cite-ai)"); result = result.replace(/^(\s*\>?\s*)AI建议[:]\s*$/gm, "$1[AI建议](#cite-ai)");
// 推荐应用:[[推荐应用:应用名称:slug]] → 可点击跳转链接 // 推荐应用:[[推荐应用:应用名称:slug]] → 可点击跳转链接
result = result.replace(/\[\[推荐应用:([^:]+):([^\]]+)\]\]/g, (_, name, slug) => `[${name}](#cite-app:${slug})`); result = result.replace(
/\[\[推荐应用:([^:]+):([^\]]+)\]\]/g,
(_, name, slug) => `[${name}](#cite-app:${slug})`,
);
return result; return result;
} }
+30 -42
View File
@@ -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: { variants: {
size: { size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5", xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "", sm: "",
"icon-xs": "icon-xs": "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0", "icon-sm": "size-8 p-0 has-[>svg]:p-0",
}, },
}, },
defaultVariants: { defaultVariants: {
size: "xs", 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,
} };
+6 -6
View File
@@ -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 };
+6 -6
View File
@@ -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 };
+14 -27
View File
@@ -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,
}
+9 -12
View File
@@ -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 };
+9 -13
View File
@@ -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 };
+31 -42
View File
@@ -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,
} };
+7 -11
View File
@@ -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 };
+25 -38
View File
@@ -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,
} };
+3 -3
View File
@@ -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 };
+19 -23
View File
@@ -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 };
+14 -21
View File
@@ -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 };
+5 -5
View File
@@ -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 };
+11 -23
View File
@@ -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 -5
View File
@@ -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";
+4 -7
View File
@@ -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,8 +23,7 @@ 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[];
@@ -33,6 +31,5 @@ export function useSuggestedPrompts(app: App): string[] {
} catch { } catch {
return []; return [];
} }
})(), }, [app.suggested_prompts]);
).current;
} }
+2 -5
View File
@@ -8,8 +8,7 @@ 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");
@@ -23,9 +22,7 @@ export function useFileExport() {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, 30000); }, 30000);
toast.success(successMsg); toast.success(successMsg);
}, }, []);
[],
);
return { download }; return { download };
} }
+3 -11
View File
@@ -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,
); );
} }
+11 -21
View File
@@ -42,10 +42,7 @@ function getAuthHeaders(): Record<string, string> {
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, {
@@ -92,7 +89,10 @@ async function request<T>(
} }
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 +110,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 +134,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 +148,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 +163,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 +206,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 +234,7 @@ export async function listPPTTasks() {
} }
export function getPPTDownloadURL(taskId: string) { export function getPPTDownloadURL(taskId: string) {
const base = typeof window !== "undefined" const base = typeof window !== "undefined" ? process.env.NEXT_PUBLIC_API_URL || "" : "";
? process.env.NEXT_PUBLIC_API_URL || ""
: "";
return `${base}/api/v1/ppt/tasks/${taskId}/download`; return `${base}/api/v1/ppt/tasks/${taskId}/download`;
} }
+53 -8
View File
@@ -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 -6
View File
@@ -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";
+1
View File
@@ -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;
+3 -3
View File
@@ -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));
} }
+66
View File
@@ -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;
+10 -10
View File
@@ -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:-}
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

+14 -2
View File
@@ -7,6 +7,7 @@ import (
"github.com/enterprise-ai-platform/server/internal/config" "github.com/enterprise-ai-platform/server/internal/config"
"github.com/enterprise-ai-platform/server/internal/handler" "github.com/enterprise-ai-platform/server/internal/handler"
"github.com/enterprise-ai-platform/server/internal/metrics"
mw "github.com/enterprise-ai-platform/server/internal/middleware" mw "github.com/enterprise-ai-platform/server/internal/middleware"
"github.com/enterprise-ai-platform/server/internal/response" "github.com/enterprise-ai-platform/server/internal/response"
"github.com/enterprise-ai-platform/server/pkg/auth" "github.com/enterprise-ai-platform/server/pkg/auth"
@@ -18,6 +19,7 @@ import (
"github.com/go-chi/cors" "github.com/go-chi/cors"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"github.com/prometheus/client_golang/prometheus/promhttp"
) )
func newRouter(cfg *config.Config, pool *pgxpool.Pool, rdb *redis.Client) http.Handler { func newRouter(cfg *config.Config, pool *pgxpool.Pool, rdb *redis.Client) http.Handler {
@@ -83,8 +85,18 @@ func newRouter(cfg *config.Config, pool *pgxpool.Pool, rdb *redis.Client) http.H
// Auth middleware // Auth middleware
requireAuth := mw.Auth(jwtMgr) requireAuth := mw.Auth(jwtMgr)
requireAdmin := mw.RequireRole("admin") requireAdmin := mw.RequireRole("admin")
// Health check // Prometheus metrics middleware
r.Get("/health", handler.HealthCheck) r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
metrics.HTTPRequestsTotal.WithLabelValues(r.Method, r.URL.Path, "200").Inc()
next.ServeHTTP(w, r)
})
})
// Health check and metrics endpoints
healthH := handler.NewHealthHandler(pool, rdb)
r.Get("/health", healthH.HealthCheck)
r.Handle("/metrics", promhttp.Handler())
// API v1 routes // API v1 routes
r.Route("/api/v1", func(r chi.Router) { r.Route("/api/v1", func(r chi.Router) {
+8
View File
@@ -16,14 +16,22 @@ require (
) )
require ( require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.37.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
) )
+16
View File
@@ -1,3 +1,5 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -33,8 +35,18 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k= github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
@@ -48,6 +60,8 @@ github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
@@ -57,6 +71,8 @@ golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+2 -2
View File
@@ -116,8 +116,8 @@ func Load() *Config {
}, },
MinIO: MinIOConfig{ MinIO: MinIOConfig{
Endpoint: getEnv("MINIO_ENDPOINT", "localhost:9000"), Endpoint: getEnv("MINIO_ENDPOINT", "localhost:9000"),
AccessKey: getEnv("MINIO_ACCESS_KEY", "minioadmin"), AccessKey: getEnv("MINIO_ACCESS_KEY", ""),
SecretKey: getEnv("MINIO_SECRET_KEY", "minioadmin"), SecretKey: getEnv("MINIO_SECRET_KEY", ""),
Bucket: getEnv("MINIO_BUCKET", "aily-files"), Bucket: getEnv("MINIO_BUCKET", "aily-files"),
UseSSL: false, UseSSL: false,
}, },
+54
View File
@@ -0,0 +1,54 @@
package config
import "time"
// ==================== 分页与查询 ====================
const (
DefaultPage = 1
DefaultPageSize = 20
MaxPageSize = 100
)
// ==================== Token 与会话 ====================
const (
DefaultAccessTokenExpiry = 24 * time.Hour
DefaultRefreshTokenExpiry = 7 * 24 * time.Hour
TokenHeader = "Authorization"
TokenPrefix = "Bearer "
)
// ==================== 文件上传 ====================
const (
MaxFileSize = 10 * 1024 * 1024 // 10MB
AllowedFileExtensions = ".pdf,.doc,.docx,.txt,.md,.jpg,.jpeg,.png,.gif,.xlsx,.xls"
AllowedMimeTypes = "application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown,image/jpeg,image/png,image/gif,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
// ==================== AI 模型 ====================
const (
DefaultEmbeddingDimensions = 1024
DefaultEmbeddingModel = "text-embedding-v3"
DefaultLLMTemperature = 0.7
DefaultLLMTopP = 0.95
DefaultLLMMaxTokens = 8192
)
// ==================== RateLimit ====================
const (
DefaultRateLimitMax = 100
DefaultRateLimitWindow = 1 * time.Minute
)
// ==================== Redis Key 前缀 ====================
const (
RedisKeyPrefixRateLimit = "rl:"
RedisKeyPrefixSession = "session:"
RedisKeyPrefixToken = "token:"
RedisKeyPrefixCache = "cache:"
)
// ==================== Chat ====================
const (
MaxConversationHistory = 50
MaxMessageLength = 4000
)
+80
View File
@@ -0,0 +1,80 @@
package handler
import (
"fmt"
"io"
"mime"
"mime/multipart"
"path/filepath"
"strings"
"github.com/enterprise-ai-platform/server/internal/config"
)
// ValidateFile checks file size, extension, and MIME type.
// Returns nil if valid, or an error message if invalid.
func ValidateFile(header *multipart.FileHeader, allowedExtensions []string, maxSize int64) string {
if header.Size > maxSize {
return fmt.Sprintf("文件大小超出限制,最大支持 %dMB", maxSize/(1024*1024))
}
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext != "" {
ext = ext[1:] // strip leading "."
}
extAllowed := false
for _, e := range allowedExtensions {
if strings.EqualFold(e, ext) {
extAllowed = true
break
}
}
if !extAllowed {
return fmt.Sprintf("不支持的文件类型:.%s,仅支持:%s", ext, strings.Join(allowedExtensions, "、"))
}
// Verify MIME type matches extension
mimeType := header.Header.Get("Content-Type")
if mimeType != "" {
extMime, err := mime.ExtensionsByType(mimeType)
if err == nil && len(extMime) > 0 {
mimeAllowed := false
for _, e := range extMime {
if strings.EqualFold(strings.TrimPrefix(e, "."), ext) {
mimeAllowed = true
break
}
}
if !mimeAllowed && !strings.HasPrefix(mimeType, "text/") && !strings.HasPrefix(mimeType, "application/") {
return "文件类型与实际内容不匹配"
}
}
}
return "" // valid
}
// AllowedDocumentExtensions returns the list of allowed document file extensions.
func AllowedDocumentExtensions() []string {
return []string{"pdf", "docx", "txt", "md", "csv", "xlsx"}
}
// AllowedDocumentMaxSize returns the max file size for document uploads.
func AllowedDocumentMaxSize() int64 {
return config.MaxFileSize
}
// ReadAllWithLimit reads all content from r up to maxSize bytes.
// Returns error if content exceeds maxSize.
func ReadAllWithLimit(r io.Reader, maxSize int64) ([]byte, error) {
limited := &io.LimitedReader{R: r, N: maxSize + 1}
data, err := io.ReadAll(limited)
if err != nil {
return nil, err
}
if limited.N == 0 {
return nil, fmt.Errorf("文件内容超出 %dMB 限制", maxSize/(1024*1024))
}
return data, nil
}
+70 -5
View File
@@ -6,15 +6,80 @@ import (
"time" "time"
"github.com/enterprise-ai-platform/server/internal/response" "github.com/enterprise-ai-platform/server/internal/response"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
) )
var startTime = time.Now() type HealthHandler struct {
pool *pgxpool.Pool
rdb *redis.Client
}
func HealthCheck(w http.ResponseWriter, r *http.Request) { func NewHealthHandler(pool *pgxpool.Pool, rdb *redis.Client) *HealthHandler {
response.JSON(w, http.StatusOK, map[string]any{ return &HealthHandler{pool: pool, rdb: rdb}
"status": "ok", }
"service": "aily-portal-api",
func (h *HealthHandler) HealthCheck(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
type dep struct {
Name string `json:"name"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
deps := []dep{}
// PostgreSQL
if h.pool != nil {
if err := h.pool.Ping(ctx); err != nil {
deps = append(deps, dep{Name: "postgres", Status: "down", Error: err.Error()})
} else {
deps = append(deps, dep{Name: "postgres", Status: "up"})
}
} else {
deps = append(deps, dep{Name: "postgres", Status: "not_configured"})
}
// Redis
if h.rdb != nil {
if err := h.rdb.Ping(ctx).Err(); err != nil {
deps = append(deps, dep{Name: "redis", Status: "down", Error: err.Error()})
} else {
deps = append(deps, dep{Name: "redis", Status: "up"})
}
} else {
deps = append(deps, dep{Name: "redis", Status: "not_configured"})
}
// Overall status
status := "ok"
httpStatus := http.StatusOK
for _, d := range deps {
if d.Status == "down" {
status = "degraded"
httpStatus = http.StatusServiceUnavailable
break
}
}
// Runtime stats
var m runtime.MemStats
runtime.ReadMemStats(&m)
response.JSON(w, httpStatus, map[string]any{
"status": status,
"service": "govai-portal-api",
"uptime": time.Since(startTime).String(), "uptime": time.Since(startTime).String(),
"go": runtime.Version(), "go": runtime.Version(),
"memory": map[string]any{
" Alloc": m.Alloc / 1024 / 1024,
"Sys": m.Sys / 1024 / 1024,
"NumGC": m.NumGC,
"Goroutine": runtime.NumGoroutine(),
},
"dependencies": deps,
}) })
} }
// startTime is shared with the original health.go init block.
var startTime = time.Now()
+6
View File
@@ -254,6 +254,12 @@ func (h *KnowledgeHandler) UploadDocument(w http.ResponseWriter, r *http.Request
} }
defer file.Close() defer file.Close()
// 文件安全校验
if msg := ValidateFile(header, AllowedDocumentExtensions(), AllowedDocumentMaxSize()); msg != "" {
response.BadRequest(w, msg)
return
}
var exists bool var exists bool
err = h.pool.QueryRow(r.Context(), err = h.pool.QueryRow(r.Context(),
`SELECT EXISTS(SELECT 1 FROM knowledge_bases WHERE id = $1)`, kbID).Scan(&exists) `SELECT EXISTS(SELECT 1 FROM knowledge_bases WHERE id = $1)`, kbID).Scan(&exists)
+62
View File
@@ -0,0 +1,62 @@
package logger
import (
"io"
"os"
"time"
"github.com/rs/zerolog"
)
var log zerolog.Logger
func Init(level string, jsonFormat bool) {
var output io.Writer = os.Stdout
if !jsonFormat {
output = zerolog.ConsoleWriter{
Out: os.Stdout,
TimeFormat: time.RFC3339,
}
}
lvl, err := zerolog.ParseLevel(level)
if err != nil {
lvl = zerolog.InfoLevel
}
log = zerolog.New(output).
Level(lvl).
With().
Timestamp().
Caller().
Logger()
}
func Get() *zerolog.Logger {
return &log
}
// Info/fatal/warn/error 等直接透传
func Info() *zerolog.Event {
return log.Info()
}
func Warn() *zerolog.Event {
return log.Warn()
}
func Error() *zerolog.Event {
return log.Error()
}
func Debug() *zerolog.Event {
return log.Debug()
}
func Fatal() *zerolog.Event {
return log.Fatal()
}
func Ctx(ctx interface{ Value(key interface{}) interface{} }) zerolog.Logger {
return log.With().Interface("ctx", ctx).Logger()
}
+77
View File
@@ -0,0 +1,77 @@
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
// HTTP requests
HTTPRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total number of HTTP requests",
},
[]string{"method", "path", "status"},
)
HTTPRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request latency in seconds",
Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
},
[]string{"method", "path"},
)
// AI / LLM tokens
LLMTokensTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "ai_tokens_total",
Help: "Total number of AI tokens consumed",
},
[]string{"model", "type"}, // type: prompt | completion
)
LLMRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "ai_requests_total",
Help: "Total number of AI/LLM API requests",
},
[]string{"model", "status"},
)
LLMRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "ai_request_duration_seconds",
Help: "AI/LLM API request latency in seconds",
Buckets: []float64{0.1, 0.5, 1, 2, 5, 10, 30, 60},
},
[]string{"model"},
)
// Chat / conversation
ConversationsTotal = promauto.NewCounter(
prometheus.CounterOpts{
Name: "conversations_total",
Help: "Total number of chat conversations created",
},
)
MessagesTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "messages_total",
Help: "Total number of chat messages",
},
[]string{"role"}, // role: user | assistant
)
// Auth
AuthFailuresTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "auth_failures_total",
Help: "Total number of authentication failures",
},
[]string{"reason"},
)
)
@@ -0,0 +1,132 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
)
func TestGetUserID(t *testing.T) {
userID := uuid.New()
ctx := context.WithValue(context.Background(), UserIDKey, userID)
if got := GetUserID(ctx); got != userID {
t.Errorf("GetUserID() = %v, want %v", got, userID)
}
ctx = context.Background()
if got := GetUserID(ctx); got != uuid.Nil {
t.Errorf("GetUserID() from empty ctx = %v, want uuid.Nil", got)
}
}
func TestGetRole(t *testing.T) {
ctx := context.WithValue(context.Background(), RoleKey, "admin")
if got := GetRole(ctx); got != "admin" {
t.Errorf("GetRole() = %v, want admin", got)
}
ctx = context.Background()
if got := GetRole(ctx); got != "" {
t.Errorf("GetRole() from empty ctx = %v, want empty string", got)
}
}
func TestRequireRole_UserRole(t *testing.T) {
mux := http.NewServeMux()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
wrapped := RequireRole("admin")(handler)
mux.Handle("/admin", wrapped)
// user 角色 → 403
userCtx := context.WithValue(context.Background(), RoleKey, "user")
req := httptest.NewRequest(http.MethodGet, "/admin", nil).WithContext(userCtx)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("user role: got %d, want %d", rr.Code, http.StatusForbidden)
}
// admin 角色 → 200
adminCtx := context.WithValue(context.Background(), RoleKey, "admin")
req = httptest.NewRequest(http.MethodGet, "/admin", nil).WithContext(adminCtx)
rr = httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("admin role: got %d, want %d", rr.Code, http.StatusOK)
}
// 无角色 → 403
req = httptest.NewRequest(http.MethodGet, "/admin", nil)
rr = httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("no role: got %d, want %d", rr.Code, http.StatusForbidden)
}
}
func TestRequireSuperAdmin(t *testing.T) {
mux := http.NewServeMux()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
wrapped := RequireSuperAdmin(handler)
mux.Handle("/platform", wrapped)
// super_admin → 200
superAdminCtx := context.WithValue(context.Background(), RoleKey, "super_admin")
req := httptest.NewRequest(http.MethodGet, "/platform", nil).WithContext(superAdminCtx)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("super_admin: got %d, want %d", rr.Code, http.StatusOK)
}
// admin → 403
adminCtx := context.WithValue(context.Background(), RoleKey, "admin")
req = httptest.NewRequest(http.MethodGet, "/platform", nil).WithContext(adminCtx)
rr = httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("admin: got %d, want %d", rr.Code, http.StatusForbidden)
}
}
func TestAuditLog_NilPool(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/test", nil)
rr := httptest.NewRecorder()
fn := AuditLog(nil)
var called bool
fn(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
})).ServeHTTP(rr, req)
if !called {
t.Error("AuditLog middleware did not call next handler")
}
}
func TestRateLimit_NilRedis(t *testing.T) {
mux := http.NewServeMux()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
wrapped := RateLimit(nil, 5, 0)(handler)
mux.Handle("/test", wrapped)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code == http.StatusTooManyRequests {
t.Error("RateLimit should bypass when Redis is unavailable")
}
}
+5
View File
@@ -14,6 +14,11 @@ import (
func RateLimit(rdb *redis.Client, maxRequests int, window time.Duration) func(http.Handler) http.Handler { func RateLimit(rdb *redis.Client, maxRequests int, window time.Duration) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if rdb == nil {
next.ServeHTTP(w, r)
return
}
userID := GetUserID(r.Context()) userID := GetUserID(r.Context())
key := fmt.Sprintf("rl:%s:%s", userID.String(), r.URL.Path) key := fmt.Sprintf("rl:%s:%s", userID.String(), r.URL.Path)
+93
View File
@@ -0,0 +1,93 @@
package response
import "net/http"
// 错误码枚举,统一业务错误定义
type ErrCode int
const (
// 通用错误 (0xxxx)
ErrCodeSuccess ErrCode = 0
ErrCodeBadRequest ErrCode = 40001
ErrCodeUnauthorized ErrCode = 40101
ErrCodeForbidden ErrCode = 40301
ErrCodeNotFound ErrCode = 40401
ErrCodeRequestTimeout ErrCode = 40801
ErrCodeTooManyRequests ErrCode = 42901
ErrCodeInternalError ErrCode = 50001
ErrCodeServiceUnavailable ErrCode = 50301
ErrCodeGatewayTimeout ErrCode = 50401
// 业务错误 (6xxxx)
ErrCodeInvalidToken ErrCode = 40102
ErrCodeTokenExpired ErrCode = 40103
ErrCodeUserDisabled ErrCode = 40104
ErrCodeOrgNotFound ErrCode = 40402
ErrCodeAppNotFound ErrCode = 40403
ErrCodeKnowledgeNotFound ErrCode = 40404
ErrCodeInsufficientQuota ErrCode = 42902
ErrCodeFileTooLarge ErrCode = 40002
ErrCodeInvalidFileType ErrCode = 40003
)
var httpStatusMap = map[ErrCode]int{
ErrCodeSuccess: http.StatusOK,
ErrCodeBadRequest: http.StatusBadRequest,
ErrCodeUnauthorized: http.StatusUnauthorized,
ErrCodeForbidden: http.StatusForbidden,
ErrCodeNotFound: http.StatusNotFound,
ErrCodeRequestTimeout: http.StatusRequestTimeout,
ErrCodeTooManyRequests: http.StatusTooManyRequests,
ErrCodeInternalError: http.StatusInternalServerError,
ErrCodeServiceUnavailable: http.StatusServiceUnavailable,
ErrCodeGatewayTimeout: http.StatusGatewayTimeout,
ErrCodeInvalidToken: http.StatusUnauthorized,
ErrCodeTokenExpired: http.StatusUnauthorized,
ErrCodeUserDisabled: http.StatusUnauthorized,
ErrCodeOrgNotFound: http.StatusNotFound,
ErrCodeAppNotFound: http.StatusNotFound,
ErrCodeKnowledgeNotFound: http.StatusNotFound,
ErrCodeInsufficientQuota: http.StatusTooManyRequests,
ErrCodeFileTooLarge: http.StatusBadRequest,
ErrCodeInvalidFileType: http.StatusBadRequest,
}
func (e ErrCode) Status() int {
if s, ok := httpStatusMap[e]; ok {
return s
}
return http.StatusInternalServerError
}
func (e ErrCode) Code() int {
return int(e)
}
func (e ErrCode) Error() string {
return e.String()
}
func (e ErrCode) String() string {
switch e {
case ErrCodeSuccess: return "成功"
case ErrCodeBadRequest: return "请求参数有误"
case ErrCodeUnauthorized: return "未授权"
case ErrCodeForbidden: return "无权限"
case ErrCodeNotFound: return "资源不存在"
case ErrCodeRequestTimeout: return "请求超时"
case ErrCodeTooManyRequests: return "请求过于频繁"
case ErrCodeInternalError: return "服务器内部错误"
case ErrCodeServiceUnavailable: return "服务不可用"
case ErrCodeGatewayTimeout: return "网关超时"
case ErrCodeInvalidToken: return "无效的认证令牌"
case ErrCodeTokenExpired: return "认证令牌已过期"
case ErrCodeUserDisabled: return "用户已被禁用"
case ErrCodeOrgNotFound: return "机构不存在"
case ErrCodeAppNotFound: return "应用不存在"
case ErrCodeKnowledgeNotFound: return "知识库不存在"
case ErrCodeInsufficientQuota: return "配额不足"
case ErrCodeFileTooLarge: return "文件超出大小限制"
case ErrCodeInvalidFileType: return "不支持的文件类型"
default: return "未知错误"
}
}
+80
View File
@@ -0,0 +1,80 @@
package response
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestJSON(t *testing.T) {
w := httptest.NewRecorder()
data := map[string]string{"key": "value"}
JSON(w, http.StatusOK, data)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %s, want application/json", ct)
}
var resp APIResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if resp.Code != 0 {
t.Errorf("code = %d, want 0", resp.Code)
}
if resp.Message != "success" {
t.Errorf("message = %s, want success", resp.Message)
}
}
func TestBadRequest(t *testing.T) {
w := httptest.NewRecorder()
BadRequest(w, "参数错误")
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestUnauthorized(t *testing.T) {
w := httptest.NewRecorder()
Unauthorized(w, "未登录")
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized)
}
}
func TestForbidden(t *testing.T) {
w := httptest.NewRecorder()
Forbidden(w, "无权限")
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want %d", w.Code, http.StatusForbidden)
}
}
func TestNotFound(t *testing.T) {
w := httptest.NewRecorder()
NotFound(w, "资源不存在")
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
}
}
func TestInternalError(t *testing.T) {
w := httptest.NewRecorder()
InternalError(w, "内部错误")
if w.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want %d", w.Code, http.StatusInternalServerError)
}
}
func TestTooManyRequests(t *testing.T) {
w := httptest.NewRecorder()
TooManyRequests(w, "过于频繁")
if w.Code != http.StatusTooManyRequests {
t.Errorf("status = %d, want %d", w.Code, http.StatusTooManyRequests)
}
}
+67
View File
@@ -0,0 +1,67 @@
package tenant
import (
"context"
"database/sql"
"github.com/google/uuid"
)
// contextKey is the type for context values used by this package.
type contextKey string
const (
orgIDKey contextKey = "org_id"
userIDKey contextKey = "user_id"
roleKey contextKey = "role"
)
// WithOrgID stores the organization ID in context.
func WithOrgID(ctx context.Context, orgID string) context.Context {
return context.WithValue(ctx, orgIDKey, orgID)
}
// GetOrgID retrieves the organization ID from context.
// Returns empty string if not set.
func GetOrgID(ctx context.Context) string {
if v := ctx.Value(orgIDKey); v != nil {
return v.(string)
}
return ""
}
// WithUserID stores the user ID in context.
func WithUserID(ctx context.Context, userID uuid.UUID) context.Context {
return context.WithValue(ctx, userIDKey, userID)
}
// GetUserID retrieves the user ID from context.
func GetUserID(ctx context.Context) uuid.UUID {
if v := ctx.Value(userIDKey); v != nil {
return v.(uuid.UUID)
}
return uuid.Nil
}
// GetUserOrgID queries the database for the current user's org_id.
// This is the standard way to get the caller's organization.
func GetUserOrgID(ctx context.Context, pool interface {
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}, userID uuid.UUID) (string, error) {
var orgID string
err := pool.QueryRowContext(ctx,
`SELECT COALESCE(org_id::text, '') FROM users WHERE id = $1`,
userID,
).Scan(&orgID)
return orgID, err
}
// IsSuperAdmin checks if the given role is platform super admin.
func IsSuperAdmin(role string) bool {
return role == "super_admin"
}
// IsAdmin checks if the given role is at least org-level admin.
func IsAdmin(role string) bool {
return role == "admin" || role == "super_admin"
}