feat(admin): add admin panel with billing alerts and model sync
- Add libs/api/admin with admin API endpoints: sync models, workspace credit, billing alert check - Add workspace_alert_config model and alert service - Add Session::no_op() for background tasks without user context - Add admin/ Next.js admin panel (AI models, billing, workspaces, audit) - Start billing alert background task every 30 minutes
This commit is contained in:
parent
c4d4b2ecf5
commit
fb91f5a6c5
1
.gitignore
vendored
1
.gitignore
vendored
@ -17,4 +17,3 @@ ARCHITECTURE.md
|
||||
.agents
|
||||
.agents.md
|
||||
.next
|
||||
admin
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -571,6 +571,8 @@ dependencies = [
|
||||
"models",
|
||||
"queue",
|
||||
"room",
|
||||
"rust_decimal",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"service",
|
||||
|
||||
41
admin/.gitignore
vendored
Normal file
41
admin/.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
5
admin/AGENTS.md
Normal file
5
admin/AGENTS.md
Normal file
@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
1
admin/CLAUDE.md
Normal file
1
admin/CLAUDE.md
Normal file
@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
312
admin/PLAN.md
Normal file
312
admin/PLAN.md
Normal file
@ -0,0 +1,312 @@
|
||||
# Admin 后台管理系统 — 规划文档
|
||||
|
||||
> **最后更新**: 2026-04-19 (v26) — ROADMAP.md Rust依赖表更新与 PLAN.md 一致;剩余仅平台用户SSO/SAML(Rust) + 多租户(架构);41 tests 通过
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
### 定位
|
||||
|
||||
独立部署的 Next.js 16 Admin 后台管理系统,供平台管理员使用。
|
||||
**与主应用完全独立** — 拥有独立的认证系统、RBAC 数据库表、Redis session 命名空间。
|
||||
|
||||
### 技术栈
|
||||
|
||||
- **Framework**: Next.js 16 (App Router, TypeScript)
|
||||
- **UI**: React 19 + Tailwind CSS + 纯 CSS (黑白极简风格 + dark mode)
|
||||
- **数据库**: PostgreSQL (复用主应用数据库,独立 `admin_*` 表)
|
||||
- **缓存**: Redis Cluster (独立 `admin:session:*` 前缀)
|
||||
- **包管理**: bun
|
||||
|
||||
### 架构特点
|
||||
|
||||
- **独立 Admin 用户体系** ≠ 平台用户
|
||||
- **Session-only** 认证(无 JWT)
|
||||
- **RBAC**: 完整 User / Role / Permission 模型
|
||||
- **审计日志**: 所有操作自动记录
|
||||
- **Redis 集群支持**: SCAN 枚举(无 KEYS)
|
||||
|
||||
---
|
||||
|
||||
## 2. 目录结构
|
||||
|
||||
```
|
||||
C:\工作\code\admin\
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ ├── layout.tsx # 根布局
|
||||
│ │ ├── page.tsx # 重定向到 /login
|
||||
│ │ ├── login/page.tsx # 登录页
|
||||
│ │ ├── dashboard/page.tsx # 仪表盘(平台统计)
|
||||
│ │ ├── admin/
|
||||
│ │ │ ├── layout.tsx # Admin 布局(侧边栏 + 权限守卫)
|
||||
│ │ │ ├── users/page.tsx # Admin 用户管理
|
||||
│ │ │ ├── users/[id]/page.tsx # Admin 用户详情
|
||||
│ │ │ ├── roles/page.tsx # 角色管理
|
||||
│ │ │ ├── permissions/page.tsx # 权限管理
|
||||
│ │ │ ├── logs/page.tsx # 审计日志
|
||||
│ │ │ ├── sessions/page.tsx # 在线用户管理
|
||||
│ │ │ ├── api-tokens/page.tsx # API Token 管理
|
||||
│ │ │ ├── workspaces/page.tsx # Workspace 列表
|
||||
│ │ │ ├── workspaces/[id]/page.tsx # Workspace 详情
|
||||
│ │ │ ├── projects/page.tsx # 项目列表
|
||||
│ │ │ ├── projects/[id]/page.tsx # 项目详情
|
||||
│ │ │ ├── rooms/page.tsx # 房间列表
|
||||
│ │ │ ├── rooms/[id]/page.tsx # 房间消息
|
||||
│ │ │ ├── repos/page.tsx # 仓库列表
|
||||
│ │ │ └── ai/page.tsx # AI 模型管理
|
||||
│ │ ├── platform/
|
||||
│ │ │ ├── audit/page.tsx # 平台审计日志页面
|
||||
│ │ │ ├── users/page.tsx # 平台用户(只读)
|
||||
│ │ │ └── sessions/page.tsx # 平台用户会话管理
|
||||
│ │ └── api/
|
||||
│ │ ├── auth/login/route.ts # 登录
|
||||
│ │ ├── auth/logout/route.ts # 登出
|
||||
│ │ ├── auth/me/route.ts # 当前会话
|
||||
│ │ ├── auth/oidc/... # OIDC 登录
|
||||
│ │ ├── users/route.ts # Admin 用户 CRUD
|
||||
│ │ ├── users/[id]/route.ts # Admin 用户详情/编辑
|
||||
│ │ ├── roles/route.ts # 角色 CRUD
|
||||
│ │ ├── permissions/route.ts # 权限 CRUD
|
||||
│ │ ├── logs/route.ts # 审计日志查询
|
||||
│ │ ├── sessions/route.ts # 在线用户管理
|
||||
│ │ ├── platform/stats/route.ts # 平台统计
|
||||
│ │ ├── platform/sessions/route.ts # 平台会话管理
|
||||
│ │ ├── platform/users/route.ts # 平台用户列表
|
||||
│ │ ├── platform/workspaces/route.ts # Workspace 列表
|
||||
│ │ ├── platform/workspaces/[id]/route.ts # Workspace 详情
|
||||
│ │ ├── admin/projects/route.ts # 项目列表
|
||||
│ │ ├── admin/projects/[id]/route.ts # 项目详情
|
||||
│ │ ├── platform/rooms/route.ts # 房间列表
|
||||
│ │ ├── platform/rooms/[id]/messages/route.ts # 房间消息
|
||||
│ │ ├── platform/rooms/[id]/messages/[msgId]/route.ts # 撤回消息
|
||||
│ │ ├── platform/workspaces/[id]/alert-config/route.ts # 告警配置 CRUD
|
||||
│ │ ├── platform/repos/route.ts # 仓库列表
|
||||
│ │ ├── platform/activity-stats/route.ts # DAU/MAU 统计
|
||||
│ │ ├── api-tokens/route.ts # API Token CRUD
|
||||
│ │ ├── api-tokens/[id]/route.ts # API Token 删除
|
||||
│ │ ├── platform/audit-logs/route.ts # 平台审计日志
|
||||
│ │ └── platform/ai/route.ts # AI Provider/Model/定价
|
||||
│ ├── lib/
|
||||
│ │ ├── env.ts # 环境变量(所有配置)
|
||||
│ │ ├── db.ts # PostgreSQL 连接池
|
||||
│ │ ├── redis.ts # Redis 客户端(支持集群)
|
||||
│ │ ├── auth.ts # 认证逻辑(登录/OIDC/Session)
|
||||
│ │ ├── rbac.ts # RBAC CRUD(User/Role/Permission)
|
||||
│ │ ├── log.ts # 审计日志
|
||||
│ │ ├── api-token.ts # API Token 管理
|
||||
│ │ └── rbac-middleware.ts # RBAC 中间件(旧,已合并)
|
||||
│ ├── middleware.ts # Next.js 中间件(路由保护 + Bearer Token + API RBAC)
|
||||
│ └── types/
|
||||
│ └── ioredis.d.ts # ioredis 类型声明
|
||||
├── .env.local # 实际配置
|
||||
├── .env.local.example # 配置示例
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── next.config.ts
|
||||
├── PLAN.md
|
||||
└── ROADMAP.md # 功能演进路线(基于 libs/ 代码库分析)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 功能清单
|
||||
|
||||
### ✅ 已完成
|
||||
|
||||
#### 认证模块
|
||||
|
||||
- [x] 登录页(账号密码)
|
||||
- [x] OIDC 登录(环境变量配置,完整 OIDC/OAuth2 流程 + JWT 验证 + 用户自动注册)
|
||||
- [x] 超级管理员(环境变量配置)
|
||||
- [x] Session 管理(Redis,TTL 可配置)
|
||||
- [x] 登出
|
||||
|
||||
#### 平台概览
|
||||
|
||||
- [x] Dashboard 统计(用户/Workspace/项目/聊天室总数)
|
||||
- [x] 近期注册用户列表
|
||||
- [x] 近期 Workspace 列表
|
||||
- [x] 近期项目列表
|
||||
- [x] Workspace 计划分布统计
|
||||
|
||||
#### Admin 管理
|
||||
|
||||
- [x] Admin 用户 CRUD(分页/搜索)
|
||||
- [x] Admin 用户详情页(角色编辑/密码重置/启用禁用)
|
||||
- [x] 角色 CRUD + 权限分配
|
||||
- [x] 权限 CRUD
|
||||
- [x] 审计日志(分页/筛选/详情弹窗)
|
||||
|
||||
#### 在线用户管理
|
||||
|
||||
- [x] Redis SCAN 枚举会话(`admin:session:*`)
|
||||
- [x] 按用户分组显示
|
||||
- [x] 单会话下线
|
||||
- [x] 用户全部下线
|
||||
- [x] 显示登录时间 / IP / User Agent
|
||||
- [x] 平台用户会话管理(`session:user_uid:*`)
|
||||
- [x] 平台会话下线(单会话/全部下线)
|
||||
|
||||
#### 平台数据(只读)
|
||||
|
||||
- [x] 平台用户列表(分页/搜索)
|
||||
- [x] Workspace 列表(分页/搜索/计划筛选)
|
||||
- [x] Workspace 详情(成员/项目/账单历史)
|
||||
- [x] Workspace 充值(弹窗表单,直写 DB,含事务保护)
|
||||
- [x] Workspace 告警配置(余额不足/月度配额/使用量激增规则配置)
|
||||
- [x] 项目列表(分页/搜索/workspace 筛选/可见性筛选)
|
||||
- [x] 项目详情(成员/账单历史)
|
||||
- [x] 房间列表(分页/搜索/项目筛选/成员数/消息数/最后活跃)
|
||||
- [x] 房间消息详情(消息列表/内容预览/撤回操作)
|
||||
- [x] 仓库列表(分页/搜索/项目筛选/可见性/协作者数/分支数/AI Review 状态)
|
||||
- [x] AI Provider / Model / 版本 / 定价管理(新建/编辑/删除,通过 `POST|PATCH|DELETE /api/admin/ai/providers|models|versions`)
|
||||
|
||||
#### 批量操作
|
||||
|
||||
- [x] 平台用户批量启用/禁用
|
||||
- [x] Workspace 批量调整计划
|
||||
|
||||
#### 集成测试
|
||||
|
||||
- [x] Playwright 测试框架配置(port 3001)
|
||||
- [x] 认证模块测试(登录页表单/重定向/无效凭据/API 认证)
|
||||
- [x] Admin 用户管理 API 测试(CRUD/分页/搜索)
|
||||
- [x] 平台数据 API 测试(统计/用户/Workspace/房间/仓库/活动/审计/会话/项目账单充值)
|
||||
- [x] 中间件权限控制测试(未登录 401/无效 Token 401)
|
||||
|
||||
#### 审计日志
|
||||
|
||||
- [x] 审计日志列表(分页/筛选/详情弹窗)
|
||||
- [x] 审计日志 CSV 导出(携带当前筛选条件)
|
||||
- [x] Admin API Token 管理(创建/删除,Bearer Token 认证)
|
||||
- [x] 平台审计日志(user_activity_log + project_audit_log 合并查询)
|
||||
|
||||
#### RBAC
|
||||
|
||||
- [x] 中间件级别 API 权限控制
|
||||
- [x] 前端布局级路由保护
|
||||
- [x] 16 种默认权限(user:*, role:*, permission:*, log:*, session:*, platform:*)
|
||||
- [x] 超级管理员(`*` 权限)
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据库设计
|
||||
|
||||
### Admin 表(自主管理)
|
||||
|
||||
| 表名 | 说明 |
|
||||
|-------------------------|----------------|
|
||||
| `admin_user` | 管理员用户 |
|
||||
| `admin_role` | 角色 |
|
||||
| `admin_permission` | 权限(code-based) |
|
||||
| `admin_user_role` | 用户-角色关联 |
|
||||
| `admin_role_permission` | 角色-权限关联 |
|
||||
| `admin_audit_log` | 审计日志 |
|
||||
|
||||
### 平台表(只读查询)
|
||||
|
||||
> **Schema 注意事项**:所有主键均为 UUID(`user.uid`、`workspace.id`、`project.id` 等),不能用整数自增 ID。
|
||||
|
||||
| 表名 | 用途 | 关键列说明 |
|
||||
|-----------------------------|-----------------|-----------|
|
||||
| `user` | 平台用户列表 | PK=`uid` |
|
||||
| `user_password` | 用户密码(is_active)| FK=`user` → `user.uid` |
|
||||
| `workspace` | Workspace 列表/详情 | PK=`id`, `slug`, `deleted_at` |
|
||||
| `workspace_membership` | Workspace 成员 | FK=`workspace_id`, `user_id` (UUID) |
|
||||
| `workspace_billing` | Workspace 余额 | FK=`workspace_id` |
|
||||
| `workspace_billing_history` | 账单历史 | FK=`workspace_id`, `user`; `extra` (jsonb) 为描述字段 |
|
||||
| `project_billing_history` | 项目账单历史 | FK=`project`, `user`; `extra` (jsonb) 为描述字段 |
|
||||
| `project` | 项目列表 | PK=`id`, 无 `slug`/`deleted_at` |
|
||||
| `project_members` | 项目成员 | FK=`project_uuid`, `user_uuid` |
|
||||
| `project_billing` | 项目余额 | FK=`project_uuid` |
|
||||
| `project_billing_history` | 项目账单历史 | FK=`project`, `user` |
|
||||
| `room` | 聊天室 | FK=`project` |
|
||||
| `room_member` | 聊天室成员 | FK=`room`, `user` |
|
||||
| `room_message` | 聊天室消息 | FK=`room`, `sender_id` (UUID) |
|
||||
| `repo` | 仓库 | FK=`project` |
|
||||
| `repo_collaborator` | 仓库协作者 | FK=`repo` |
|
||||
| `ai_model_provider` | AI Provider | 列:`website`, `status` |
|
||||
| `ai_model` | AI 模型 | 无 `model_id`/`enabled` |
|
||||
| `ai_model_version` | AI 模型版本 | FK=`model_id` → `ai_model.id` |
|
||||
| `ai_model_pricing` | AI 定价 | FK=`model_version_id` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Redis 设计
|
||||
|
||||
| Key Pattern | 用途 | TTL |
|
||||
|------------------------|---------------|--------|
|
||||
| `admin:session:<uuid>` | Admin Session | 7 days |
|
||||
|
||||
**Session 结构**(兼容 Rust session 格式):
|
||||
|
||||
```json
|
||||
{
|
||||
"v": 1,
|
||||
"state": {
|
||||
"session:user_uid": -1,
|
||||
"session:username": "admin",
|
||||
"session:roles": [
|
||||
"super_admin"
|
||||
],
|
||||
"session:permissions": [
|
||||
"*"
|
||||
],
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**约束**:
|
||||
|
||||
- `admin:session:*` 前缀(与业务 `session:user_uid:*` 隔离)
|
||||
- SCAN 枚举(禁止 KEYS)
|
||||
- Pipeline 批量读取
|
||||
|
||||
---
|
||||
|
||||
## 6. 环境变量
|
||||
|
||||
详见 `.env.local.example`。
|
||||
|
||||
关键变量:
|
||||
|
||||
- `DATABASE_URL` — PostgreSQL 连接
|
||||
- `REDIS_CLUSTER_URLS` — Redis 集群节点(逗号分隔)
|
||||
- `ADMIN_SUPER_USERNAME` / `ADMIN_SUPER_PASSWORD` — 超级管理员
|
||||
- `OIDC_ENABLED` / `OIDC_*` — OIDC 配置
|
||||
|
||||
---
|
||||
|
||||
## 7. 安全模型
|
||||
|
||||
- **Admin 认证 ≠ 平台认证** — 完全隔离
|
||||
- **Redis key 前缀区分** — `admin:session:*` vs `session:user_uid:*`
|
||||
- **超级管理员** — 环境变量配置,拥有 `*` 权限
|
||||
- **RBAC 强制** — 中间件层 401/403,返回头 `x-admin-*`
|
||||
- **审计日志** — 所有写操作自动记录
|
||||
|
||||
---
|
||||
|
||||
## 8. 待完成(未来迭代)
|
||||
|
||||
详细路线图见 `ROADMAP.md`。
|
||||
|
||||
- [ ] 平台用户 SSO / SAML(Rust 主应用 OIDC/SAML 中间件,LDAP 集成)
|
||||
- [ ] 多租户隔离架构(独立部署 Admin + 配置同步)
|
||||
|
||||
> **v25 更新 (2026-04-19)**:Playwright 测试补充:项目账单 GET/POST API 测试;41 tests 通过。
|
||||
|
||||
> **v24 更新 (2026-04-19)**:Admin 项目级账单充值完成:新增 `POST /api/admin/projects/[id]/billing`(Next.js 直连 DB + 事务保护),项目详情页新增充值按钮和弹窗表单;ROADMAP.md P1 项目级账单管理 ✅ 完成。
|
||||
|
||||
> **v23 更新 (2026-04-19)**:Admin AI Model CRUD 完成:`libs/api/admin/ai_models.rs` 新增 Provider/Model/版本/定价的创建/编辑/删除 Admin API;`src/app/admin/ai/page.tsx` 新增 CRUD 弹窗 UI;Next.js API 路由 `src/app/api/admin/ai/providers|models|versions/pricing/[id]/route.ts`;`/api/platform/ai` 路由新增版本查询。
|
||||
|
||||
> **v22 更新 (2026-04-19)**:Admin OIDC SSO 完成:`src/lib/auth.ts` 完整 OIDC/OAuth2 流程(`jose` JWT 验证、JWKS 动态公钥获取、用户自动注册/查找);ROADMAP.md 更新:Admin OIDC SSO ✅,平台用户 SSO/SAML 待完成(Rust 后端),多租户待完成。
|
||||
|
||||
> **v21 更新 (2026-04-19)**:告警触发 Rust 后端完成:`libs/service/workspace/alert.rs`(每 30 分钟检查所有 Workspace)、`libs/api/admin/alerts.rs::POST /api/admin/alerts/check`、`workspace_alert_config` SeaORM 模型、后台任务随 `apps/app/src/main.rs` 启动;Admin 前端新增"立即检查告警"按钮;39 tests 通过。
|
||||
|
||||
> **v20 更新 (2026-04-19)**:新增 Rust Admin API(`libs/api/admin/` 模块):`POST /api/admin/ai/sync`(AI 模型同步)和 `POST /api/admin/workspaces/{slug}/add-credit`(充值);Admin 前端新增"同步 OpenRouter 模型"按钮和 `src/app/api/platform/ai/sync/route.ts`;环境变量 `RUST_BACKEND_URL` + `ADMIN_API_SHARED_KEY`;38 tests 通过。
|
||||
|
||||
> **v19 更新 (2026-04-19)**:TypeScript 类型错误修复(Playwright test `ctx` 类型注解 `Awaited<ReturnType<typeof request.newContext>>`),37 tests 全部通过,TypeScript 零错误。Admin Next.js 前端全部完成。
|
||||
36
admin/README.md
Normal file
36
admin/README.md
Normal file
@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
342
admin/ROADMAP.md
Normal file
342
admin/ROADMAP.md
Normal file
@ -0,0 +1,342 @@
|
||||
# Admin 后台管理系统 — ROADMAP
|
||||
|
||||
> **最后更新**: 2026-04-19 (v26) — ROADMAP.md Rust依赖表更新:平台会话管理 ✅、审计增强 ✅ 与 PLAN.md 一致;剩余仅平台用户SSO/SAML(Rust) + 多租户(架构);41 tests 通过
|
||||
>
|
||||
> 本文档基于 `C:\工作\code\libs` 代码库分析,规划 Admin 系统的演进路线。
|
||||
|
||||
---
|
||||
|
||||
## 1. 技术背景
|
||||
|
||||
### 1.1 双应用架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ PostgreSQL (同一数据库) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│ │
|
||||
┌─────┴──────┐ ┌──────┴──────┐
|
||||
│ Rust 主应用 │ │ Next.js Admin │
|
||||
│ (libs/api) │ │ (自包含) │
|
||||
│ Actix-web │ │ Route API │
|
||||
│ libs/svc │ │ libs/rbac │
|
||||
└────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
### 1.2 libs/ 代码库结构
|
||||
|
||||
| Crate | 职责 | 与 Admin 的关联 |
|
||||
|-------|------|----------------|
|
||||
| `libs/api` | Actix-web HTTP 路由层 | Admin 可通过 `/api/agents/*` 等接口间接调用 |
|
||||
| `libs/service` | 业务逻辑层 | Workspace 计费/AI 模型同步等核心逻辑所在 |
|
||||
| `libs/models` | SeaORM 实体定义 | Admin DB 查询直接引用相同表结构 |
|
||||
| `libs/session` | 会话管理(Redis) | 主应用使用 `session:user_uid:*`,Admin 使用 `admin:session:*` |
|
||||
| `libs/db` | 数据库连接池 | Admin 直接复用 `DATABASE_URL` |
|
||||
| `libs/agent` | AI Agent(聊天/Review/Summary/ReAct) | Admin 可触发 `sync_upstream_models()` |
|
||||
| `libs/queue` | 异步队列(Redis PubSub) | Email 队列等 |
|
||||
| `libs/email` | SMTP 发送 | 审计通知等 |
|
||||
|
||||
### 1.3 关键 API 路由(主应用 Rust)
|
||||
|
||||
**Workspace 计费**(`libs/api/workspace/billing.rs`):
|
||||
- `GET /api/workspaces/{slug}/billing` — 当前余额/配额
|
||||
- `GET /api/workspaces/{slug}/billing/history` — 账单历史
|
||||
- `POST /api/workspaces/{slug}/billing/add-credit` — 充值(需要 workspace 成员权限)
|
||||
|
||||
**AI 模型管理**(`libs/api/agent/`):
|
||||
- `GET/POST /api/agents/providers` — Provider CRUD
|
||||
- `GET/POST /api/agents/models` — Model CRUD
|
||||
- `GET/POST /api/agents/versions` — 版本管理
|
||||
- `POST /api/agents/versions/{id}/pricing` — 定价设置
|
||||
- `POST /api/agents/models/{id}/sync` — 同步上游模型
|
||||
- `POST /api/agents/code-review/{ns}/{repo}` — 触发 Code Review
|
||||
- `POST /api/agents/pr-description/{ns}/{repo}` — 触发 PR Summary
|
||||
|
||||
**Project**(`libs/api/project/`):
|
||||
- `GET/POST /api/projects/{namespace}/{name}` — Project CRUD
|
||||
- `GET /api/projects/{namespace}/{name}/billing` — 项目级计费
|
||||
|
||||
**其他**:
|
||||
- `libs/service/agent/sync.rs` — `sync_upstream_models()` 从 OpenRouter 同步模型
|
||||
- `libs/service/agent/billing.rs` — `record_ai_usage()` 记录 token 消耗并扣费
|
||||
|
||||
---
|
||||
|
||||
## 2. 功能路线图
|
||||
|
||||
### Phase 1: 补全当前功能(短期)
|
||||
|
||||
> 基于现有 libs/api 直接可调用接口,快速实现。
|
||||
|
||||
#### [x] Workspace 计费操作(充值/调整配额)✅
|
||||
**现状**: 已实现(直接写 DB)
|
||||
- `POST /api/platform/workspaces/{id}/add-credit` — 充值 API(INSERT billing_history + UPDATE billing.balance,事务保护)
|
||||
- `/admin/workspaces/[id]/page.tsx` — 充值弹窗(输入金额+备注,充值后自动刷新)
|
||||
- **说明**: 直接写 DB,绕过 Rust service 层(事务内保证原子性)
|
||||
|
||||
#### [x] AI 模型同步触发器 ✅
|
||||
**现状**: 已完成
|
||||
- `libs/api/admin/sync.rs` — `POST /api/admin/ai/sync`(`x-admin-api-key` 认证)
|
||||
- `libs/service/agent/sync.rs::sync_upstream_models` — 已直接调用
|
||||
- `src/app/api/platform/ai/sync/route.ts` — Admin 前端 API 路由(调用 Rust)
|
||||
- `src/app/admin/ai/page.tsx` — "同步 OpenRouter 模型"按钮 + 结果反馈
|
||||
- 环境变量:`RUST_BACKEND_URL` + `ADMIN_API_SHARED_KEY`
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 平台级管控能力(中期)
|
||||
|
||||
#### [x] 仓库(Repository)管理视图 ✅
|
||||
**现状**: 已实现
|
||||
- `/admin/repos/page.tsx` — 仓库列表(分页/搜索/仓库名/项目/可见性/协作者数/分支数/AI Review 状态)
|
||||
- `GET /api/platform/repos` — 仓库列表 API
|
||||
|
||||
#### [x] 房间(Room)管理视图 ✅
|
||||
**现状**: 已实现
|
||||
- `/admin/rooms/page.tsx` — 房间列表(按项目筛选/搜索/成员数/消息数/最后活跃)
|
||||
- `/admin/rooms/[id]/page.tsx` — 房间消息详情(消息列表/撤回操作)
|
||||
- `GET /api/platform/rooms` — 房间列表 API
|
||||
- `GET /api/platform/rooms/[id]/messages` — 房间消息 API
|
||||
- `DELETE /api/platform/rooms/[id]/messages/[msgId]` — 撤回消息(admin session 认证)
|
||||
|
||||
#### [x] 项目(Project)管理视图 ✅
|
||||
**现状**: 已实现
|
||||
- `/admin/projects/page.tsx` — 项目列表(分页/搜索/workspace 筛选/可见性筛选)
|
||||
- `/admin/projects/[id]/page.tsx` — 项目详情(成员/账单历史)
|
||||
- `/api/admin/projects/route.ts` — 项目列表 API
|
||||
- `/api/admin/projects/[id]/route.ts` — 项目详情 API
|
||||
**现状**: Workspace 下有项目,但 Admin 无项目列表
|
||||
|
||||
新增:
|
||||
- `/admin/projects/page.tsx` — 项目列表(分页/搜索/workspace 筛选)
|
||||
- `/admin/projects/[id]/page.tsx` — 项目详情(成员/账单/仓库)
|
||||
|
||||
#### [x] 平台用户会话管理 ✅
|
||||
**现状**: 已实现
|
||||
- `/platform/sessions/page.tsx` — 扫描 `session:user_uid:*` Redis 前缀
|
||||
- 按用户分组显示(用户名/UID/IP/UserAgent/登录时间)
|
||||
- 单会话下线 / 用户全部下线
|
||||
- `/api/platform/sessions/route.ts` — 平台会话 API
|
||||
- RBAC: `platform:read` (GET) / `platform:manage` (DELETE)
|
||||
|
||||
#### [x] 审计日志导出(CSV/Excel) ✅
|
||||
**现状**: 已实现
|
||||
- `GET /api/logs?format=csv` — 导出 CSV(含当前筛选条件,最多 10,000 条)
|
||||
- `/admin/logs/page.tsx` — "导出 CSV" 按钮,自动携带当前筛选参数
|
||||
|
||||
#### [x] 批量操作 ✅
|
||||
**现状**: 已实现
|
||||
- 平台用户:批量启用/禁用(`PATCH /api/platform/users`,更新 `user_password.is_active`)
|
||||
- Workspace:批量调整计划(`PATCH /api/platform/workspaces`,UPDATE `workspace.plan`)
|
||||
- 全选/反选/已选计数 UI
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 安全与合规(中期)
|
||||
|
||||
#### [x] Admin API 令牌 ✅
|
||||
**现状**: 已实现
|
||||
- `src/lib/api-token.ts` — Token 生成(SHA-256 哈希)、验证、创建、删除
|
||||
- `admin_api_token` 表(id, name, token_hash, token_prefix, permissions, created_by, expires_at, last_used_at, is_active)
|
||||
- `POST /api/api-tokens` — 创建 Token(返回明文,仅此一次)
|
||||
- `GET /api/api-tokens` — 列出 Token(不返回 hash)
|
||||
- `DELETE /api/api-tokens/[id]` — 删除 Token
|
||||
- `/admin/api-tokens/page.tsx` — Token 管理页面(创建弹窗含权限选择,到期日)
|
||||
- `middleware.ts` — Bearer Token 认证优先于 Session(`x-admin-auth-type: token` header)
|
||||
- RBAC: Token 管理页面需要 session(不允许 token 访问自己)
|
||||
|
||||
#### [x] 平台级审计日志 ✅
|
||||
**现状**: 已实现
|
||||
- `GET /api/platform/audit-logs` — 查询 `user_activity_log` + `project_audit_log`,按时间合并排序
|
||||
- 支持 `source` 筛选(all/user/project)和 `action` 搜索(ILIKE)
|
||||
- `/platform/audit/page.tsx` — 平台审计页面(来源标签/操作类型/用户UID/IP)
|
||||
|
||||
#### [x] Admin OIDC SSO ✅
|
||||
**现状**: 已完成(Admin 前端 Next.js)
|
||||
- `src/lib/auth.ts` — 完整 OIDC/OAuth2 流程实现
|
||||
- `jose` 库 JWT 验证(JWKS 端点动态获取公钥)
|
||||
- `buildOidcAuthUrl()` — 构造授权 URL(`openid profile email` scopes)
|
||||
- `exchangeOidcCode()` — Token 交换 + JWT 验证 + 用户自动创建/查找
|
||||
- `/api/auth/oidc/authorize` — OIDC 授权重定向
|
||||
- `/api/auth/oidc/callback` — 授权回调处理
|
||||
- 审计日志记录 OIDC 登录
|
||||
- 环境变量:`OIDC_ENABLED` / `OIDC_ISSUER` / `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` / `OIDC_REDIRECT_URI`
|
||||
|
||||
#### [ ] 平台用户 SSO / SAML(Rust 后端)
|
||||
**现状**: 平台用户(Rust 主应用)尚未支持 SSO
|
||||
- 需要在 Rust Actix-web 中实现 OIDC/SAML 中间件
|
||||
- 需要用户自动注册/登录流程
|
||||
- 企业级需求(LDAP / SAML 2.0)
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 平台洞察与自动化(长期)
|
||||
|
||||
#### [x] 平台使用分析仪表盘 ✅
|
||||
**现状**: 已实现
|
||||
- `/dashboard` — 新增聊天室总数统计
|
||||
- `/dashboard` — Workspace 计划分布(饼图替代文字,数量+百分比)
|
||||
- `/dashboard` — 近期项目列表
|
||||
- `GET /api/platform/stats` — 新增 roomCount, planDistribution, recentProjects
|
||||
- `/dashboard` — DAU 趋势图(近30天 SVG 折线图,MAU/总登录/近24h 统计)
|
||||
- `GET /api/platform/activity-stats` — DAU/MAU 统计 API
|
||||
|
||||
#### [x] 告警与自动化规则 ✅
|
||||
**现状**: 已完成
|
||||
- `workspace_alert_config` 模型(`libs/models/workspaces/workspace_alert_config.rs`)
|
||||
- `libs/service/workspace/alert.rs::check_billing_alerts()` — 核心检查逻辑
|
||||
- 后台任务 `start_billing_alert_task()` — 每 30 分钟自动检查所有 Workspace
|
||||
- `POST /api/admin/alerts/check` — Admin API 手动触发检查
|
||||
- `src/app/api/platform/alerts/check/route.ts` — Admin 前端 API 路由
|
||||
- Workspace 详情页「告警配置」Tab — "立即检查告警"按钮 + 结果反馈
|
||||
- 支持三种告警类型:`low_balance`(余额不足)/ `monthly_quota`(配额超标)/ `usage_surge`(用量激增)
|
||||
- 邮件发送给 Workspace owner/admin,检测用户通知偏好设置
|
||||
- `apps/app/src/main.rs` — 后台任务随应用启动
|
||||
|
||||
#### [ ] 多租户隔离架构
|
||||
**现状**: 长期目标
|
||||
- 支持独立部署的 Admin(当前是单实例)
|
||||
- Admin 配置同步(多集群场景)
|
||||
|
||||
---
|
||||
|
||||
## 3. 依赖 Rust 主应用的功能
|
||||
|
||||
以下功能需要 Rust 后端支持或新增接口:
|
||||
|
||||
| 功能 | Rust 依赖 | 优先级 |
|
||||
|------|---------|--------|
|
||||
| Workspace 充值 | `libs/api/admin/billing.rs::admin_workspace_add_credit` ✅ 已完成 | P0 ✅ |
|
||||
| AI 模型同步触发 | `libs/api/admin/sync.rs::admin_sync_models` ✅ 已完成 | P0 ✅ |
|
||||
| 告警触发逻辑 | `libs/service/workspace/alert.rs` + 后台任务 ✅ 已完成 | P0 ✅ |
|
||||
| AI 模型 CRUD | `libs/api/admin/ai_models.rs` ✅ Admin CRUD 新建/编辑/删除(Provider/Model/版本/定价) | P1 ✅ |
|
||||
| 项目级账单管理 | `POST /api/admin/projects/[id]/billing` (Next.js 直连 DB,事务保护) ✅ | P1 ✅ |
|
||||
| 平台用户会话管理 | `/platform/sessions/page.tsx` + `session:user_uid:*` Redis 扫描 ✅ | P2 ✅ |
|
||||
| 审计日志增强 | `user_activity_log` + `project_audit_log` 直接查询 ✅ | P2 ✅ |
|
||||
| 平台用户 SSO/SAML | Rust OIDC/SAML 中间件(企业场景) | P2 |
|
||||
| Admin API Token | 已实现(Next.js 直连 DB) | P3 ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 4. 技术债务与改进
|
||||
|
||||
### 4.1 Admin ↔ 主应用集成模式
|
||||
|
||||
当前问题:Admin 直接查询 DB,绕过 Rust 业务逻辑层。
|
||||
|
||||
两种集成方案:
|
||||
|
||||
**方案 A: Admin 直接写 DB(现状)**
|
||||
- 优点:简单,无跨服务依赖
|
||||
- 缺点:无法复用 service 层逻辑(计费原子性、事务)
|
||||
- 适用:只读操作、非关键数据
|
||||
|
||||
**方案 B: Admin 调用 Rust API(推荐)**
|
||||
- 优点:复用业务逻辑,数据一致性有保障
|
||||
- 缺点:需处理跨应用认证
|
||||
- 适用:计费操作、AI 模型管理等
|
||||
|
||||
**建议**:核心写操作(计费)通过 Rust API,查询类操作可直连 DB。
|
||||
|
||||
### 4.2 跨应用认证问题
|
||||
|
||||
Rust 主应用 API 需要 `session:user_uid` cookie,Admin 无法直接调用。
|
||||
|
||||
解决方案:
|
||||
1. **共享 Session 存储**:Admin 验证后注入 fake session 到 Redis,主应用可读取
|
||||
2. **Admin 专用 API**:Rust 新增 `/api/admin/*` 路由,验证 `admin_session` cookie
|
||||
3. **API Token**:Rust API 支持 Bearer Token,Admin 用服务账号 token 调用
|
||||
|
||||
### 4.3 数据库 Schema 演进
|
||||
|
||||
Admin 表(`admin_*`)与平台表(`workspace`、`user` 等)共存于同一 DB。
|
||||
|
||||
建议:
|
||||
- Admin 表命名统一加前缀 `adm_*`(当前 `admin_*`,兼容)
|
||||
- 避免跨表事务(admin 写操作不影响平台数据,除非明确)
|
||||
|
||||
### 4.4 Redis 命名空间
|
||||
|
||||
| Key Pattern | 用途 |
|
||||
|------------|------|
|
||||
| `session:user_uid:{uuid}` | 平台用户 Session |
|
||||
| `admin:session:{uuid}` | Admin Session |
|
||||
| `user:session:{uuid}` | (别名,同上)|
|
||||
|
||||
注意:平台 session key 实际前缀为 `session:user_uid:`,不是 `user:session:`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 版本计划
|
||||
|
||||
| 版本 | 目标 | 主要交付 |
|
||||
|------|------|---------|
|
||||
| **v0.1** | MVP(已完成) | 登录/RBAC/审计/在线用户/平台用户/Workspace/AI 模型 |
|
||||
| **v0.2** | 补全(已完成) | 用户详情页 / Workspace 计费操作 / AI 同步触发 |
|
||||
| **v0.3** | 平台级管控(已完成) | 项目管理视图 / 批量操作 / 日志导出 |
|
||||
| **v0.4** | 安全合规(已完成) | API Token ✅ / 平台审计日志 ✅ / SSO/SAML 待完成 |
|
||||
| **v0.5** | 管控增强(已完成) | 房间管理 ✅ / 仓库管理 ✅ / 告警配置 ✅ (UI + Rust 触发) / 多租户待完成 |
|
||||
| **v1.0** | 完整版 ✅ Next.js + Rust Admin API 完成 | Playwright 集成测试 ✅ (41 tests) / AI 同步触发器 ✅ / Workspace 充值 ✅ / 告警触发 ✅ / Admin OIDC SSO ✅ / Admin AI Model CRUD ✅ / 项目充值 ✅ / 平台会话管理 ✅ / 审计增强 ✅ / 平台用户SSO/SAML待完成 / 多租户待完成 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 附录:libs/ 关键文件索引
|
||||
|
||||
```
|
||||
C:\工作\code\libs\
|
||||
├── api/
|
||||
│ ├── lib.rs # 路由注册入口
|
||||
│ ├── route.rs # web::ServiceConfig 初始化
|
||||
│ ├── auth/ # 认证(登录/注册/OIDC)
|
||||
│ ├── workspace/ # Workspace API
|
||||
│ │ ├── billing.rs # 余额/充值/账单历史
|
||||
│ │ ├── members.rs # 成员管理
|
||||
│ │ ├── info.rs # Workspace 信息
|
||||
│ │ └── stats.rs # 统计
|
||||
│ ├── project/ # Project API
|
||||
│ │ └── billing.rs # 项目级计费
|
||||
│ ├── agent/ # AI 模型/Provider/定价 CRUD
|
||||
│ │ ├── model.rs # 模型 CRUD
|
||||
│ │ ├── provider.rs # Provider CRUD
|
||||
│ │ ├── model_pricing.rs # 定价管理
|
||||
│ │ ├── code_review.rs # Code Review 触发
|
||||
│ │ └── pr_summary.rs # PR Summary 触发
|
||||
│ ├── room/ # 聊天室 WebSocket API
|
||||
│ ├── git/ # Git 操作 API
|
||||
│ ├── issue/ # Issue API
|
||||
│ ├── pull_request/ # PR API
|
||||
│ ├── user/ # 用户设置 API
|
||||
│ └── search/ # 搜索 API
|
||||
├── service/
|
||||
│ ├── lib.rs # AppService 聚合所有 service
|
||||
│ ├── workspace/
|
||||
│ │ ├── billing.rs # workspace_billing_add_credit()
|
||||
│ │ └── mod.rs
|
||||
│ ├── project/
|
||||
│ │ └── billing.rs # 项目级计费
|
||||
│ ├── agent/
|
||||
│ │ ├── billing.rs # record_ai_usage()
|
||||
│ │ ├── sync.rs # sync_upstream_models()
|
||||
│ │ ├── client.rs # AI 客户端(重试/熔断)
|
||||
│ │ ├── tokent.rs # Token 估算
|
||||
│ │ └── react/ # ReAct 模式
|
||||
│ ├── auth/ # 登录/注册/OIDC 逻辑
|
||||
│ ├── user/ # 用户服务
|
||||
│ └── room/ # 聊天室服务
|
||||
├── models/
|
||||
│ ├── users/ # platform user 实体
|
||||
│ ├── workspaces/ # workspace/workspace_membership/workspace_billing
|
||||
│ ├── projects/ # project/project_members/project_billing
|
||||
│ ├── agents/ # ai_model_provider/model/version/pricing
|
||||
│ └── rooms/ # room/room_message/room_member
|
||||
├── session/
|
||||
│ ├── lib.rs # SessionMiddleware
|
||||
│ ├── storage/ # Redis 存储实现
|
||||
│ └── session.rs # Session 结构
|
||||
├── db/
|
||||
│ ├── database.rs # AppDatabase (sqlx pool)
|
||||
│ └── cache.rs # AppCache (Redis cache)
|
||||
└── queue/
|
||||
├── lib.rs # Redis PubSub 队列
|
||||
└── email.rs # Email worker
|
||||
```
|
||||
1153
admin/bun.lock
Normal file
1153
admin/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
18
admin/eslint.config.mjs
Normal file
18
admin/eslint.config.mjs
Normal file
@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
10
admin/next.config.ts
Normal file
10
admin/next.config.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ["bcrypt"],
|
||||
turbopack: {
|
||||
root: process.cwd(),
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
7276
admin/package-lock.json
generated
Normal file
7276
admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
admin/package.json
Normal file
46
admin/package.json
Normal file
@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"db:migrate": "bun --env-file=.env.local src/lib/db/migrate.ts",
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"argon2": "^0.44.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.3.1",
|
||||
"ioredis": "^5.10.1",
|
||||
"jose": "^5.2.0",
|
||||
"lucide-react": "^0.344.0",
|
||||
"next": "16.2.4",
|
||||
"pg": "^8.11.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"uuid": "^9.0.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/ioredis": "^5.0.0",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.4",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
24
admin/playwright.config.ts
Normal file
24
admin/playwright.config.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
fullyParallel: false,
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 10_000 },
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL: "http://localhost:3001",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||
],
|
||||
webServer: {
|
||||
command: "bun --env-file=.env.local next start -p 3001",
|
||||
url: "http://localhost:3001",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 60_000,
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
},
|
||||
});
|
||||
7
admin/postcss.config.mjs
Normal file
7
admin/postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
admin/public/file.svg
Normal file
1
admin/public/file.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
admin/public/globe.svg
Normal file
1
admin/public/globe.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
admin/public/next.svg
Normal file
1
admin/public/next.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
admin/public/vercel.svg
Normal file
1
admin/public/vercel.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
admin/public/window.svg
Normal file
1
admin/public/window.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
567
admin/src/app/admin/ai/page.tsx
Normal file
567
admin/src/app/admin/ai/page.tsx
Normal file
@ -0,0 +1,567 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Provider {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
website: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
interface Model {
|
||||
id: string;
|
||||
provider_id: string;
|
||||
name: string;
|
||||
modality: string;
|
||||
capability: string;
|
||||
context_length: number;
|
||||
max_output_tokens: number | null;
|
||||
training_cutoff: string | null;
|
||||
is_open_source: boolean;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
interface Version {
|
||||
id: string;
|
||||
model_id: string;
|
||||
version: string;
|
||||
release_date: string | null;
|
||||
change_log: string | null;
|
||||
is_default: boolean;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
interface Pricing {
|
||||
id: number;
|
||||
model_version_id: string;
|
||||
model_name: string;
|
||||
input_price_per_1k_tokens: string;
|
||||
output_price_per_1k_tokens: string;
|
||||
currency: string;
|
||||
effective_from: string;
|
||||
}
|
||||
|
||||
type Tab = "providers" | "models" | "versions" | "pricing";
|
||||
|
||||
interface EditProvider {
|
||||
name: string;
|
||||
display_name: string;
|
||||
website: string;
|
||||
}
|
||||
interface EditModel {
|
||||
provider_id: string;
|
||||
name: string;
|
||||
modality: string;
|
||||
capability: string;
|
||||
context_length: string;
|
||||
max_output_tokens: string;
|
||||
training_cutoff: string;
|
||||
}
|
||||
interface EditVersion {
|
||||
model_id: string;
|
||||
version: string;
|
||||
release_date: string;
|
||||
change_log: string;
|
||||
}
|
||||
interface EditPricing {
|
||||
input_price_per_1k_tokens: string;
|
||||
output_price_per_1k_tokens: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
const MODALITY_OPTIONS = ["text", "chat", "code", "embedding", "image", "audio", "video"];
|
||||
const CAPABILITY_OPTIONS = ["chat", "completion", "embedding", "image-generation", "code-generation"];
|
||||
|
||||
export default function AiPage() {
|
||||
const [providers, setProviders] = useState<Provider[]>([]);
|
||||
const [models, setModels] = useState<Model[]>([]);
|
||||
const [versions, setVersions] = useState<Version[]>([]);
|
||||
const [pricing, setPricing] = useState<Pricing[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState<Tab>("providers");
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncMsg, setSyncMsg] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
// Modals
|
||||
const [showProviderModal, setShowProviderModal] = useState(false);
|
||||
const [editProvider, setEditProvider] = useState<EditProvider>({ name: "", display_name: "", website: "" });
|
||||
const [editingProviderId, setEditingProviderId] = useState<string | null>(null);
|
||||
|
||||
const [showModelModal, setShowModelModal] = useState(false);
|
||||
const [editModel, setEditModel] = useState<EditModel>({ provider_id: "", name: "", modality: "chat", capability: "chat", context_length: "8192", max_output_tokens: "", training_cutoff: "" });
|
||||
const [editingModelId, setEditingModelId] = useState<string | null>(null);
|
||||
|
||||
const [showVersionModal, setShowVersionModal] = useState(false);
|
||||
const [editVersion, setEditVersion] = useState<EditVersion>({ model_id: "", version: "", release_date: "", change_log: "" });
|
||||
const [editingVersionId, setEditingVersionId] = useState<string | null>(null);
|
||||
|
||||
const [editingPricingId, setEditingPricingId] = useState<number | null>(null);
|
||||
const [editPricing, setEditPricing] = useState<EditPricing>({ input_price_per_1k_tokens: "", output_price_per_1k_tokens: "", currency: "USD" });
|
||||
|
||||
function showMsg(type: "success" | "error", text: string) {
|
||||
setMsg({ type, text });
|
||||
setTimeout(() => setMsg(null), 4000);
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/platform/ai");
|
||||
const data = await res.json();
|
||||
setProviders(data.providers || []);
|
||||
setModels(data.models || []);
|
||||
// versions and pricing need to be fetched separately — load from model data
|
||||
setPricing(data.pricing || []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadData(); }, []);
|
||||
|
||||
async function triggerSync() {
|
||||
if (!confirm("确定从 OpenRouter 同步最新模型?")) return;
|
||||
setSyncing(true);
|
||||
setSyncMsg(null);
|
||||
try {
|
||||
const res = await fetch("/api/platform/ai/sync", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (res.ok && data.data) {
|
||||
const d = data.data;
|
||||
setSyncMsg(`同步完成:${d.models_created} 新增 / ${d.models_updated} 更新`);
|
||||
loadData();
|
||||
} else {
|
||||
setSyncMsg(`同步失败: ${data.error}`);
|
||||
}
|
||||
} catch (e) {
|
||||
setSyncMsg(`同步失败: ${e instanceof Error ? e.message : String(e)}`);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Provider CRUD
|
||||
function openNewProvider() {
|
||||
setEditingProviderId(null);
|
||||
setEditProvider({ name: "", display_name: "", website: "" });
|
||||
setShowProviderModal(true);
|
||||
}
|
||||
function openEditProvider(p: Provider) {
|
||||
setEditingProviderId(p.id);
|
||||
setEditProvider({ name: p.name, display_name: p.display_name, website: p.website || "" });
|
||||
setShowProviderModal(true);
|
||||
}
|
||||
async function saveProvider() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = { ...editProvider, website: editProvider.website || null };
|
||||
const res = editingProviderId
|
||||
? await fetch(`/api/admin/ai/providers?id=${editingProviderId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
|
||||
: await fetch("/api/admin/ai/providers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
const data = await res.json();
|
||||
if (!res.ok) { showMsg("error", data.error || "保存失败"); return; }
|
||||
showMsg("success", editingProviderId ? "Provider 已更新" : "Provider 已创建");
|
||||
setShowProviderModal(false);
|
||||
loadData();
|
||||
} catch { showMsg("error", "保存失败"); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
async function deleteProvider(id: string, name: string) {
|
||||
if (!confirm(`删除 Provider "${name}"?`)) return;
|
||||
const res = await fetch(`/api/admin/ai/providers?id=${id}`, { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) { showMsg("error", data.error || "删除失败"); return; }
|
||||
showMsg("success", "已删除");
|
||||
loadData();
|
||||
}
|
||||
|
||||
// Model CRUD
|
||||
function openNewModel() {
|
||||
setEditingModelId(null);
|
||||
setEditModel({ provider_id: providers[0]?.id || "", name: "", modality: "chat", capability: "chat", context_length: "8192", max_output_tokens: "", training_cutoff: "" });
|
||||
setShowModelModal(true);
|
||||
}
|
||||
function openEditModel(m: Model) {
|
||||
setEditingModelId(m.id);
|
||||
setEditModel({ provider_id: m.provider_id, name: m.name, modality: m.modality, capability: m.capability, context_length: String(m.context_length), max_output_tokens: m.max_output_tokens ? String(m.max_output_tokens) : "", training_cutoff: m.training_cutoff ? m.training_cutoff.split("T")[0] : "" });
|
||||
setShowModelModal(true);
|
||||
}
|
||||
async function saveModel() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = {
|
||||
provider_id: editModel.provider_id,
|
||||
name: editModel.name,
|
||||
modality: editModel.modality,
|
||||
capability: editModel.capability,
|
||||
context_length: parseInt(editModel.context_length) || 8192,
|
||||
max_output_tokens: editModel.max_output_tokens ? parseInt(editModel.max_output_tokens) : null,
|
||||
training_cutoff: editModel.training_cutoff ? new Date(editModel.training_cutoff).toISOString() : null,
|
||||
};
|
||||
const res = editingModelId
|
||||
? await fetch(`/api/admin/ai/models?id=${editingModelId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
|
||||
: await fetch("/api/admin/ai/models", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
const data = await res.json();
|
||||
if (!res.ok) { showMsg("error", data.error || "保存失败"); return; }
|
||||
showMsg("success", editingModelId ? "模型已更新" : "模型已创建");
|
||||
setShowModelModal(false);
|
||||
loadData();
|
||||
} catch { showMsg("error", "保存失败"); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
async function deleteModel(id: string, name: string) {
|
||||
if (!confirm(`删除模型 "${name}"?`)) return;
|
||||
const res = await fetch(`/api/admin/ai/models?id=${id}`, { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) { showMsg("error", data.error || "删除失败"); return; }
|
||||
showMsg("success", "已删除");
|
||||
loadData();
|
||||
}
|
||||
|
||||
// Version CRUD
|
||||
function openNewVersion() {
|
||||
setEditingVersionId(null);
|
||||
setEditVersion({ model_id: models[0]?.id || "", version: "", release_date: "", change_log: "" });
|
||||
setShowVersionModal(true);
|
||||
}
|
||||
function openEditVersion(v: Version) {
|
||||
setEditingVersionId(v.id);
|
||||
setEditVersion({ model_id: v.model_id, version: v.version, release_date: v.release_date ? v.release_date.split("T")[0] : "", change_log: v.change_log || "" });
|
||||
setShowVersionModal(true);
|
||||
}
|
||||
async function saveVersion() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = {
|
||||
model_id: editVersion.model_id,
|
||||
version: editVersion.version,
|
||||
release_date: editVersion.release_date ? new Date(editVersion.release_date).toISOString() : null,
|
||||
change_log: editVersion.change_log || null,
|
||||
};
|
||||
const res = editingVersionId
|
||||
? await fetch(`/api/admin/ai/versions?id=${editingVersionId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
|
||||
: await fetch("/api/admin/ai/versions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
const data = await res.json();
|
||||
if (!res.ok) { showMsg("error", data.error || "保存失败"); return; }
|
||||
showMsg("success", editingVersionId ? "版本已更新" : "版本已创建");
|
||||
setShowVersionModal(false);
|
||||
loadData();
|
||||
} catch { showMsg("error", "保存失败"); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
async function deleteVersion(id: string, version: string) {
|
||||
if (!confirm(`删除版本 "${version}"?`)) return;
|
||||
const res = await fetch(`/api/admin/ai/versions?id=${id}`, { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) { showMsg("error", data.error || "删除失败"); return; }
|
||||
showMsg("success", "已删除");
|
||||
loadData();
|
||||
}
|
||||
|
||||
// Pricing inline edit
|
||||
function startEditPricing(p: Pricing) {
|
||||
setEditingPricingId(p.id);
|
||||
setEditPricing({ input_price_per_1k_tokens: p.input_price_per_1k_tokens, output_price_per_1k_tokens: p.output_price_per_1k_tokens, currency: p.currency });
|
||||
}
|
||||
async function savePricing(id: number) {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/ai/pricing/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(editPricing) });
|
||||
const data = await res.json();
|
||||
if (!res.ok) { showMsg("error", data.error || "保存失败"); return; }
|
||||
showMsg("success", "定价已更新");
|
||||
setEditingPricingId(null);
|
||||
loadData();
|
||||
} catch { showMsg("error", "保存失败"); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
if (loading) return <div className="admin-content"><div className="loading">加载中...</div></div>;
|
||||
|
||||
return (
|
||||
<div className="admin-content">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">AI 模型管理</h1>
|
||||
<p className="page-subtitle">Provider / Model / 版本 / 定价</p>
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<button className={`btn btn-sm ${tab === "providers" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("providers")}>Provider ({providers.length})</button>
|
||||
<button className={`btn btn-sm ${tab === "models" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("models")}>模型 ({models.length})</button>
|
||||
<button className={`btn btn-sm ${tab === "versions" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("versions")}>版本</button>
|
||||
<button className={`btn btn-sm ${tab === "pricing" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("pricing")}>定价</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-sm btn-primary" disabled={syncing} onClick={triggerSync}>
|
||||
{syncing ? "同步中..." : "同步 OpenRouter 模型"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{syncMsg && (
|
||||
<div className={`alert ${syncMsg.includes("失败") ? "alert-error" : "alert-success"}`} style={{ marginBottom: "12px" }}>
|
||||
{syncMsg}
|
||||
</div>
|
||||
)}
|
||||
{msg && (
|
||||
<div className={`alert ${msg.type === "error" ? "alert-error" : "alert-success"}`} style={{ marginBottom: "12px" }}>
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
{tab === "providers" && (
|
||||
<>
|
||||
<div style={{ padding: "12px", borderBottom: "1px solid #e5e5e5" }}>
|
||||
<button className="btn btn-sm btn-primary" onClick={openNewProvider}>+ 新建 Provider</button>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>ID</th><th>名称</th><th>显示名</th><th>官网</th><th>状态</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{providers.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td><code style={{ fontSize: "11px" }}>{p.id.slice(0, 8)}</code></td>
|
||||
<td>{p.name}</td>
|
||||
<td>{p.display_name}</td>
|
||||
<td><a href={p.website || "#"} target="_blank" rel="noopener" style={{ fontSize: "12px" }}>{p.website || "-"}</a></td>
|
||||
<td><span className={`badge ${p.status === "active" ? "badge-success" : "badge-neutral"}`}>{p.status}</span></td>
|
||||
<td>
|
||||
<button className="btn btn-sm btn-secondary" style={{ marginRight: "4px" }} onClick={() => openEditProvider(p)}>编辑</button>
|
||||
<button className="btn btn-sm" style={{ color: "#dc2626", border: "none", background: "none", padding: "2px 6px", cursor: "pointer" }} onClick={() => deleteProvider(p.id, p.name)}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{providers.length === 0 && <tr><td colSpan={6} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无 Provider</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "models" && (
|
||||
<>
|
||||
<div style={{ padding: "12px", borderBottom: "1px solid #e5e5e5" }}>
|
||||
<button className="btn btn-sm btn-primary" disabled={!providers.length} onClick={openNewModel}>+ 新建模型</button>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>Provider</th><th>名称</th><th>Modality</th><th>上下文长度</th><th>状态</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{models.map((m) => {
|
||||
const prov = providers.find(p => p.id === m.provider_id);
|
||||
return (
|
||||
<tr key={m.id}>
|
||||
<td>{prov?.display_name || m.provider_id.slice(0, 8)}</td>
|
||||
<td>{m.name}</td>
|
||||
<td><span className="badge badge-neutral">{m.modality}</span></td>
|
||||
<td>{m.context_length ? `${(m.context_length / 1000).toFixed(0)}K` : "-"}</td>
|
||||
<td><span className={`badge ${m.status === "active" ? "badge-success" : "badge-neutral"}`}>{m.status}</span></td>
|
||||
<td>
|
||||
<button className="btn btn-sm btn-secondary" style={{ marginRight: "4px" }} onClick={() => openEditModel(m)}>编辑</button>
|
||||
<button className="btn btn-sm" style={{ color: "#dc2626", border: "none", background: "none", padding: "2px 6px", cursor: "pointer" }} onClick={() => deleteModel(m.id, m.name)}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{models.length === 0 && <tr><td colSpan={6} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无模型</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "versions" && (
|
||||
<>
|
||||
<div style={{ padding: "12px", borderBottom: "1px solid #e5e5e5" }}>
|
||||
<button className="btn btn-sm btn-primary" disabled={!models.length} onClick={openNewVersion}>+ 新建版本</button>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>Model</th><th>版本</th><th>发布日期</th><th>变更日志</th><th>状态</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{versions.map((v) => {
|
||||
const mdl = models.find(m => m.id === v.model_id);
|
||||
return (
|
||||
<tr key={v.id}>
|
||||
<td>{mdl?.name || v.model_id.slice(0, 8)}</td>
|
||||
<td><code>{v.version}</code></td>
|
||||
<td>{v.release_date ? new Date(v.release_date).toLocaleDateString() : "-"}</td>
|
||||
<td style={{ fontSize: "12px", maxWidth: "200px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{v.change_log || "-"}</td>
|
||||
<td><span className={`badge ${v.status === "active" ? "badge-success" : "badge-neutral"}`}>{v.status}</span></td>
|
||||
<td>
|
||||
<button className="btn btn-sm btn-secondary" style={{ marginRight: "4px" }} onClick={() => openEditVersion(v)}>编辑</button>
|
||||
<button className="btn btn-sm" style={{ color: "#dc2626", border: "none", background: "none", padding: "2px 6px", cursor: "pointer" }} onClick={() => deleteVersion(v.id, v.version)}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{versions.length === 0 && <tr><td colSpan={6} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无版本(从 OpenRouter 同步后自动创建)</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "pricing" && (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>模型</th><th>输入 $/1K</th><th>输出 $/1K</th><th>货币</th><th>生效时间</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{pricing.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td style={{ fontSize: "12px", maxWidth: "200px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={p.model_name}>{p.model_name}</td>
|
||||
<td>
|
||||
{editingPricingId === p.id ? (
|
||||
<input className="form-input" style={{ width: "120px" }} value={editPricing.input_price_per_1k_tokens} onChange={e => setEditPricing(p => ({ ...p, input_price_per_1k_tokens: e.target.value }))} />
|
||||
) : (
|
||||
<span>${parseFloat(p.input_price_per_1k_tokens).toFixed(6)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editingPricingId === p.id ? (
|
||||
<input className="form-input" style={{ width: "120px" }} value={editPricing.output_price_per_1k_tokens} onChange={e => setEditPricing(p => ({ ...p, output_price_per_1k_tokens: e.target.value }))} />
|
||||
) : (
|
||||
<span>${parseFloat(p.output_price_per_1k_tokens).toFixed(6)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editingPricingId === p.id ? (
|
||||
<input className="form-input" style={{ width: "80px" }} value={editPricing.currency} onChange={e => setEditPricing(p => ({ ...p, currency: e.target.value }))} />
|
||||
) : (
|
||||
<span>{p.currency}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{new Date(p.effective_from).toLocaleDateString()}</td>
|
||||
<td>
|
||||
{editingPricingId === p.id ? (
|
||||
<>
|
||||
<button className="btn btn-sm btn-primary" disabled={saving} style={{ marginRight: "4px" }} onClick={() => savePricing(p.id)}>{saving ? "..." : "保存"}</button>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => setEditingPricingId(null)}>取消</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => startEditPricing(p)}>编辑</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{pricing.length === 0 && <tr><td colSpan={6} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无定价记录</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Provider Modal */}
|
||||
{showProviderModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowProviderModal(false)}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: "460px" }}>
|
||||
<h2 className="modal-title">{editingProviderId ? "编辑 Provider" : "新建 Provider"}</h2>
|
||||
<div className="form-group">
|
||||
<label className="form-label">名称 (name)</label>
|
||||
<input className="form-input" value={editProvider.name} onChange={e => setEditProvider(p => ({ ...p, name: e.target.value }))} placeholder="例如:openrouter" autoFocus />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">显示名 (display_name)</label>
|
||||
<input className="form-input" value={editProvider.display_name} onChange={e => setEditProvider(p => ({ ...p, display_name: e.target.value }))} placeholder="例如:OpenRouter" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">官网 (website,可选)</label>
|
||||
<input className="form-input" value={editProvider.website} onChange={e => setEditProvider(p => ({ ...p, website: e.target.value }))} placeholder="https://openrouter.ai" />
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowProviderModal(false)}>取消</button>
|
||||
<button className="btn btn-primary" disabled={saving || !editProvider.name || !editProvider.display_name} onClick={saveProvider}>{saving ? "保存中..." : "保存"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Modal */}
|
||||
{showModelModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowModelModal(false)}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: "520px" }}>
|
||||
<h2 className="modal-title">{editingModelId ? "编辑模型" : "新建模型"}</h2>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Provider</label>
|
||||
<select className="form-input" value={editModel.provider_id} onChange={e => setEditModel(m => ({ ...m, provider_id: e.target.value }))}>
|
||||
<option value="">选择 Provider...</option>
|
||||
{providers.map(p => <option key={p.id} value={p.id}>{p.display_name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">模型名称</label>
|
||||
<input className="form-input" value={editModel.name} onChange={e => setEditModel(m => ({ ...m, name: e.target.value }))} placeholder="例如:anthropic/claude-3.5-sonnet" />
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "12px" }}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Modality</label>
|
||||
<select className="form-input" value={editModel.modality} onChange={e => setEditModel(m => ({ ...m, modality: e.target.value }))}>
|
||||
{MODALITY_OPTIONS.map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Capability</label>
|
||||
<select className="form-input" value={editModel.capability} onChange={e => setEditModel(m => ({ ...m, capability: e.target.value }))}>
|
||||
{CAPABILITY_OPTIONS.map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "12px" }}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">上下文长度</label>
|
||||
<input className="form-input" type="number" value={editModel.context_length} onChange={e => setEditModel(m => ({ ...m, context_length: e.target.value }))} placeholder="8192" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">最大输出 tokens</label>
|
||||
<input className="form-input" type="number" value={editModel.max_output_tokens} onChange={e => setEditModel(m => ({ ...m, max_output_tokens: e.target.value }))} placeholder="可选" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowModelModal(false)}>取消</button>
|
||||
<button className="btn btn-primary" disabled={saving || !editModel.provider_id || !editModel.name} onClick={saveModel}>{saving ? "保存中..." : "保存"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Version Modal */}
|
||||
{showVersionModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowVersionModal(false)}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: "460px" }}>
|
||||
<h2 className="modal-title">{editingVersionId ? "编辑版本" : "新建版本"}</h2>
|
||||
<div className="form-group">
|
||||
<label className="form-label">所属模型</label>
|
||||
<select className="form-input" value={editVersion.model_id} onChange={e => setEditVersion(v => ({ ...v, model_id: e.target.value }))}>
|
||||
<option value="">选择模型...</option>
|
||||
{models.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">版本号</label>
|
||||
<input className="form-input" value={editVersion.version} onChange={e => setEditVersion(v => ({ ...v, version: e.target.value }))} placeholder="例如:1.0.0" autoFocus />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">发布日期</label>
|
||||
<input className="form-input" type="date" value={editVersion.release_date} onChange={e => setEditVersion(v => ({ ...v, release_date: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">变更日志</label>
|
||||
<textarea className="form-input" rows={3} value={editVersion.change_log} onChange={e => setEditVersion(v => ({ ...v, change_log: e.target.value }))} placeholder="可选" />
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowVersionModal(false)}>取消</button>
|
||||
<button className="btn btn-primary" disabled={saving || !editVersion.model_id || !editVersion.version} onClick={saveVersion}>{saving ? "保存中..." : "保存"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
233
admin/src/app/admin/api-tokens/page.tsx
Normal file
233
admin/src/app/admin/api-tokens/page.tsx
Normal file
@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface ApiToken {
|
||||
id: number;
|
||||
name: string;
|
||||
token_prefix: string;
|
||||
permissions: string[];
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
expires_at: string | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
const ALL_PERMISSIONS = [
|
||||
{ value: "user:read", label: "用户读取" },
|
||||
{ value: "user:create", label: "用户创建" },
|
||||
{ value: "user:update", label: "用户更新" },
|
||||
{ value: "user:delete", label: "用户删除" },
|
||||
{ value: "role:read", label: "角色读取" },
|
||||
{ value: "role:create", label: "角色创建" },
|
||||
{ value: "role:update", label: "角色更新" },
|
||||
{ value: "role:delete", label: "角色删除" },
|
||||
{ value: "log:read", label: "日志读取" },
|
||||
{ value: "session:manage", label: "会话管理" },
|
||||
{ value: "platform:read", label: "平台读取" },
|
||||
{ value: "platform:manage", label: "平台管理" },
|
||||
{ value: "*", label: "全部权限(超级)" },
|
||||
];
|
||||
|
||||
export default function ApiTokensPage() {
|
||||
const [tokens, setTokens] = useState<ApiToken[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newPerms, setNewPerms] = useState<string[]>([]);
|
||||
const [newExpiry, setNewExpiry] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createdToken, setCreatedToken] = useState("");
|
||||
const [createError, setCreateError] = useState("");
|
||||
|
||||
useEffect(() => { loadTokens(); }, []);
|
||||
|
||||
function loadTokens() {
|
||||
setLoading(true);
|
||||
fetch("/api/api-tokens")
|
||||
.then((r) => r.json())
|
||||
.then((d) => setTokens(d.tokens || []))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newName.trim()) { setCreateError("名称不能为空"); return; }
|
||||
setCreating(true);
|
||||
setCreateError("");
|
||||
try {
|
||||
const body: Record<string, unknown> = { name: newName, permissions: newPerms };
|
||||
if (newExpiry) body.expiresInDays = parseInt(newExpiry, 10);
|
||||
const res = await fetch("/api/api-tokens", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setCreateError(data.error || "创建失败"); return; }
|
||||
setCreatedToken(data.token);
|
||||
loadTokens();
|
||||
setShowCreate(false);
|
||||
setNewName("");
|
||||
setNewPerms([]);
|
||||
setNewExpiry("");
|
||||
} catch { setCreateError("创建失败"); }
|
||||
finally { setCreating(false); }
|
||||
}
|
||||
|
||||
async function handleDelete(t: ApiToken) {
|
||||
if (!confirm(`确定删除 Token「${t.name}」吗?`)) return;
|
||||
await fetch(`/api/api-tokens/${t.id}`, { method: "DELETE" });
|
||||
loadTokens();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-content">
|
||||
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div>
|
||||
<h1 className="page-title">API Token</h1>
|
||||
<p className="page-subtitle">用于自动化工具的 API 访问凭证(Bearer Token 认证)</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => setShowCreate(true)}>+ 新建 Token</button>
|
||||
</div>
|
||||
|
||||
{/* Token 使用说明 */}
|
||||
<div className="card" style={{ marginBottom: "16px", background: "#fafafa" }}>
|
||||
<div style={{ fontSize: "13px", color: "#52525b" }}>
|
||||
<strong>使用方法:</strong>在请求头中添加 <code>Authorization: Bearer <your-token></code>。
|
||||
API Token 可访问除 Token 管理本身之外的所有 API。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{loading ? <div className="loading">加载中...</div> : tokens.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">🔑</div>
|
||||
<p>暂无 API Token,点击上方按钮创建</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>前缀</th>
|
||||
<th>权限</th>
|
||||
<th>有效期</th>
|
||||
<th>最后使用</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokens.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<td style={{ fontWeight: 500 }}>{t.name}</td>
|
||||
<td><code style={{ fontSize: "11px" }}>{t.token_prefix}***</code></td>
|
||||
<td>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px" }}>
|
||||
{t.permissions.includes("*") ? (
|
||||
<span className="badge badge-success">全部权限</span>
|
||||
) : t.permissions.length === 0 ? (
|
||||
<span style={{ color: "#737373", fontSize: "12px" }}>无</span>
|
||||
) : (
|
||||
t.permissions.map((p) => (
|
||||
<span key={p} className="badge badge-neutral" style={{ fontSize: "11px" }}>{p}</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ fontSize: "13px" }}>
|
||||
{t.expires_at
|
||||
? new Date(t.expires_at) < new Date()
|
||||
? <span style={{ color: "#dc2626" }}>已过期</span>
|
||||
: format(new Date(t.expires_at), "yyyy-MM-dd")
|
||||
: "永不过期"}
|
||||
</td>
|
||||
<td style={{ fontSize: "13px" }}>
|
||||
{t.last_used_at
|
||||
? format(new Date(t.last_used_at), "yyyy-MM-dd HH:mm")
|
||||
: <span style={{ color: "#a3a3a3" }}>未使用</span>}
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(t)}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 创建 Token 弹窗 */}
|
||||
{showCreate && (
|
||||
<div className="modal-overlay" onClick={() => { setShowCreate(false); setCreatedToken(""); }}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "520px" }}>
|
||||
<h2 className="modal-title">新建 API Token</h2>
|
||||
|
||||
{createdToken ? (
|
||||
<>
|
||||
<div className="alert alert-success" style={{ marginBottom: "16px" }}>
|
||||
Token 创建成功!请立即复制保存,<strong>关闭后将无法再次查看</strong>。
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">您的 API Token</label>
|
||||
<div style={{
|
||||
background: "#f4f4f5", border: "1px solid #e5e5e5",
|
||||
borderRadius: "6px", padding: "12px",
|
||||
fontFamily: "monospace", fontSize: "13px",
|
||||
wordBreak: "break-all", color: "#171717"
|
||||
}}>
|
||||
{createdToken}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-primary" onClick={() => { setCreatedToken(""); setShowCreate(false); }}>关闭</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{createError && <div className="alert alert-error">{createError}</div>}
|
||||
<div className="form-group">
|
||||
<label className="form-label">Token 名称 *</label>
|
||||
<input type="text" className="form-input" value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)} placeholder="例如:CI/CD 自动化脚本" autoFocus />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">权限范围</label>
|
||||
<div className="checkbox-group" style={{ maxHeight: "200px" }}>
|
||||
{ALL_PERMISSIONS.map((p) => (
|
||||
<label key={p.value} className="checkbox-item">
|
||||
<input type="checkbox"
|
||||
checked={newPerms.includes(p.value)}
|
||||
onChange={(e) => {
|
||||
setNewPerms(e.target.checked
|
||||
? [...newPerms, p.value]
|
||||
: newPerms.filter((x) => x !== p.value));
|
||||
}} />
|
||||
{p.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">有效期(天,留空永不过期)</label>
|
||||
<input type="number" className="form-input" min="1"
|
||||
value={newExpiry} onChange={(e) => setNewExpiry(e.target.value)}
|
||||
placeholder="不填则永不过期" />
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowCreate(false)}>取消</button>
|
||||
<button className="btn btn-primary" disabled={creating} onClick={handleCreate}>
|
||||
{creating ? "创建中..." : "创建"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
admin/src/app/admin/layout.tsx
Normal file
25
admin/src/app/admin/layout.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import Sidebar, { useAdminAuth } from "@/components/admin/Sidebar";
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { user, loading, handleLogout } = useAdminAuth();
|
||||
|
||||
return (
|
||||
<div className="admin-layout">
|
||||
<Sidebar user={user} loading={loading} onLogout={handleLogout} />
|
||||
<main className="admin-main">
|
||||
{!user && !loading && (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%" }}>
|
||||
<div className="loading"><span>加载中...</span></div>
|
||||
</div>
|
||||
)}
|
||||
{(!loading || user) && children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
admin/src/app/admin/logs/page.tsx
Normal file
126
admin/src/app/admin/logs/page.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface AuditLog {
|
||||
id: number; user_id: number; username: string; action: string;
|
||||
resource: string; resource_id: string | null; request_params: Record<string, unknown> | null;
|
||||
ip_address: string | null; result: string; error_message: string | null; created_at: string;
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
create: "创建", read: "查看", update: "更新", delete: "删除",
|
||||
login: "登录", logout: "登出",
|
||||
};
|
||||
|
||||
export default function LogsPage() {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [action, setAction] = useState("");
|
||||
const [resource, setResource] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detailLog, setDetailLog] = useState<AuditLog | null>(null);
|
||||
const pageSize = 20;
|
||||
|
||||
useEffect(() => { loadLogs(); }, [page, action, resource]);
|
||||
async function loadLogs() {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||
if (action) params.set("action", action);
|
||||
if (resource) params.set("resource", resource);
|
||||
const res = await fetch(`/api/logs?${params}`);
|
||||
const data = await res.json();
|
||||
setLogs(data.logs || []);
|
||||
setTotal(data.total || 0);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div className="admin-content">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">审计日志</h1>
|
||||
<p className="page-subtitle">共 {total} 条记录</p>
|
||||
</div>
|
||||
<div className="toolbar">
|
||||
<select className="form-input" style={{ maxWidth: "160px" }} value={action}
|
||||
onChange={(e) => { setAction(e.target.value); setPage(1); }}>
|
||||
<option value="">全部操作</option>
|
||||
{Object.entries(ACTION_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
<input type="text" className="form-input search-input" placeholder="搜索资源..."
|
||||
value={resource} onChange={(e) => { setResource(e.target.value); setPage(1); }} />
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams({ format: "csv" });
|
||||
if (action) params.set("action", action);
|
||||
if (resource) params.set("resource", resource);
|
||||
window.location.href = `/api/logs?${params}`;
|
||||
}}
|
||||
>
|
||||
导出 CSV
|
||||
</button>
|
||||
</div>
|
||||
<div className="card">
|
||||
{loading ? <div className="loading">加载中...</div> : (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>时间</th><th>用户</th><th>操作</th><th>资源</th><th>结果</th><th>IP</th><th>详情</th></tr></thead>
|
||||
<tbody>
|
||||
{logs.map((log) => (
|
||||
<tr key={log.id}>
|
||||
<td>{format(new Date(log.created_at), "yyyy-MM-dd HH:mm:ss")}</td>
|
||||
<td>{log.username}</td>
|
||||
<td><span className="badge badge-neutral">{ACTION_LABELS[log.action] || log.action}</span></td>
|
||||
<td>
|
||||
<span style={{ fontSize: "12px", color: "#737373" }}>{log.resource}</span>
|
||||
{log.resource_id && <span style={{ fontSize: "11px", color: "#a3a3a3", marginLeft: "4px" }}>#{log.resource_id}</span>}
|
||||
</td>
|
||||
<td><span className={`badge ${log.result === "success" ? "badge-success" : "badge-danger"}`}>{log.result === "success" ? "成功" : "失败"}</span></td>
|
||||
<td style={{ fontSize: "12px" }}>{log.ip_address || "-"}</td>
|
||||
<td><button className="btn btn-secondary btn-sm" onClick={() => setDetailLog(log)}>查看</button></td>
|
||||
</tr>
|
||||
))}
|
||||
{logs.length === 0 && <tr><td colSpan={7} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无数据</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="pagination">
|
||||
<span className="pagination-info">第 {page} / {totalPages} 页,共 {total} 条</span>
|
||||
<button className="btn btn-secondary btn-sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||||
<button className="btn btn-secondary btn-sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
{detailLog && (
|
||||
<div className="modal-overlay" onClick={() => setDetailLog(null)}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "600px" }}>
|
||||
<h2 className="modal-title">日志详情</h2>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "auto 1fr", gap: "8px 16px", fontSize: "14px" }}>
|
||||
<span style={{ color: "#737373" }}>ID</span><span>{detailLog.id}</span>
|
||||
<span style={{ color: "#737373" }}>用户</span><span>{detailLog.username}</span>
|
||||
<span style={{ color: "#737373" }}>操作</span><span>{detailLog.action}</span>
|
||||
<span style={{ color: "#737373" }}>资源</span><span>{detailLog.resource}</span>
|
||||
<span style={{ color: "#737373" }}>资源ID</span><span>{detailLog.resource_id || "-"}</span>
|
||||
<span style={{ color: "#737373" }}>结果</span><span>{detailLog.result}</span>
|
||||
<span style={{ color: "#737373" }}>IP</span><span>{detailLog.ip_address || "-"}</span>
|
||||
<span style={{ color: "#737373" }}>时间</span><span>{format(new Date(detailLog.created_at), "yyyy-MM-dd HH:mm:ss")}</span>
|
||||
<span style={{ color: "#737373" }}>请求参数</span>
|
||||
<pre style={{ background: "#f4f4f5", padding: "8px", borderRadius: "4px", fontSize: "12px", overflow: "auto" }}>
|
||||
{detailLog.request_params ? JSON.stringify(detailLog.request_params, null, 2) : "-"}
|
||||
</pre>
|
||||
{detailLog.error_message && (<><span style={{ color: "#737373" }}>错误</span><span style={{ color: "#dc2626" }}>{detailLog.error_message}</span></>)}
|
||||
</div>
|
||||
<div className="modal-footer"><button className="btn btn-secondary" onClick={() => setDetailLog(null)}>关闭</button></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
admin/src/app/admin/permissions/page.tsx
Normal file
93
admin/src/app/admin/permissions/page.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, FormEvent } from "react";
|
||||
|
||||
interface Permission {
|
||||
id: number; name: string; code: string; description: string | null; created_at: string;
|
||||
}
|
||||
|
||||
export default function PermissionsPage() {
|
||||
const [permissions, setPermissions] = useState<Permission[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [form, setForm] = useState({ name: "", code: "", description: "" });
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => { loadPermissions(); }, []);
|
||||
async function loadPermissions() {
|
||||
setLoading(true);
|
||||
const res = await fetch("/api/permissions");
|
||||
const data = await res.json();
|
||||
setPermissions(data.permissions || []);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function handleCreate(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
const res = await fetch("/api/permissions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form) });
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setError(data.error || "创建失败"); return; }
|
||||
setShowCreate(false);
|
||||
setForm({ name: "", code: "", description: "" });
|
||||
loadPermissions();
|
||||
}
|
||||
|
||||
async function handleDelete(perm: Permission) {
|
||||
if (!confirm(`删除权限 "${perm.name}" 吗?`)) return;
|
||||
await fetch(`/api/permissions?id=${perm.id}`, { method: "DELETE" });
|
||||
loadPermissions();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-content">
|
||||
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div><h1 className="page-title">权限管理</h1><p className="page-subtitle">共 {permissions.length} 个权限</p></div>
|
||||
<button className="btn btn-primary" onClick={() => setShowCreate(true)}>+ 新建权限</button>
|
||||
</div>
|
||||
<div className="card">
|
||||
{loading ? <div className="loading">加载中...</div> : (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>ID</th><th>名称</th><th>代码</th><th>描述</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{permissions.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td>{p.id}</td><td>{p.name}</td>
|
||||
<td><code style={{ background: "#f4f4f5", padding: "2px 6px", borderRadius: "4px", fontSize: "12px" }}>{p.code}</code></td>
|
||||
<td>{p.description || "-"}</td>
|
||||
<td><button className="btn btn-danger btn-sm" onClick={() => handleDelete(p)}>删除</button></td>
|
||||
</tr>
|
||||
))}
|
||||
{permissions.length === 0 && <tr><td colSpan={5} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无数据</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showCreate && (
|
||||
<div className="modal-overlay" onClick={() => setShowCreate(false)}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="modal-title">新建权限</h2>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<form onSubmit={handleCreate}>
|
||||
<div className="form-group"><label className="form-label">名称</label>
|
||||
<input type="text" className="form-input" value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })} required /></div>
|
||||
<div className="form-group"><label className="form-label">代码 (唯一)</label>
|
||||
<input type="text" className="form-input" placeholder="如: user:read" value={form.code}
|
||||
onChange={(e) => setForm({ ...form, code: e.target.value.toLowerCase().replace(/[^a-z0-9_:]/g, "") })} required /></div>
|
||||
<div className="form-group"><label className="form-label">描述</label>
|
||||
<input type="text" className="form-input" value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })} /></div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowCreate(false)}>取消</button>
|
||||
<button type="submit" className="btn btn-primary">创建</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
389
admin/src/app/admin/projects/[id]/page.tsx
Normal file
389
admin/src/app/admin/projects/[id]/page.tsx
Normal file
@ -0,0 +1,389 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string | null;
|
||||
is_public: boolean;
|
||||
workspace_id: string | null;
|
||||
workspace_name: string | null;
|
||||
workspace_slug: string | null;
|
||||
workspace_plan: string | null;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
balance: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
interface Member {
|
||||
id?: number;
|
||||
uid: string;
|
||||
username: string;
|
||||
display_name: string | null;
|
||||
avatar_url: string | null;
|
||||
role: string;
|
||||
joined_at: string;
|
||||
}
|
||||
|
||||
interface BillingEntry {
|
||||
reason: string;
|
||||
amount: string;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
owner: "所有者", admin: "管理员", member: "成员",
|
||||
};
|
||||
|
||||
export default function ProjectDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [billing, setBilling] = useState<{ balance: string; currency: string } | null>(null);
|
||||
const [billingHistory, setBillingHistory] = useState<BillingEntry[]>([]);
|
||||
const [tab, setTab] = useState<"members" | "billing">("members");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [showCredit, setShowCredit] = useState(false);
|
||||
const [creditAmount, setCreditAmount] = useState("");
|
||||
const [creditDesc, setCreditDesc] = useState("");
|
||||
const [creditLoading, setCreditLoading] = useState(false);
|
||||
const [creditError, setCreditError] = useState("");
|
||||
|
||||
// Member management
|
||||
const [showAddMember, setShowAddMember] = useState(false);
|
||||
const [addUserId, setAddUserId] = useState("");
|
||||
const [addScope, setAddScope] = useState("member");
|
||||
const [addMemberLoading, setAddMemberLoading] = useState(false);
|
||||
const [addMemberError, setAddMemberError] = useState("");
|
||||
const [searchUsers, setSearchUsers] = useState<{ uid: string; username: string; display_name: string | null }[]>([]);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
fetch(`/api/admin/projects/${id}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.error) { setError(data.error); return; }
|
||||
setProject(data.project);
|
||||
setMembers(data.members || []);
|
||||
setBilling(data.billing || null);
|
||||
setBillingHistory(data.billingHistory || []);
|
||||
})
|
||||
.catch(e => setError(String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
function reloadBilling() {
|
||||
if (!id) return;
|
||||
fetch(`/api/admin/projects/${id}/billing`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.billing) setBilling(data.billing);
|
||||
if (data.history) setBillingHistory(data.history);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
function reload() {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
fetch(`/api/admin/projects/${id}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.error) { setError(data.error); return; }
|
||||
setProject(data.project);
|
||||
setMembers(data.members || []);
|
||||
setBilling(data.billing || null);
|
||||
setBillingHistory(data.billingHistory || []);
|
||||
})
|
||||
.catch(e => setError(String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
async function handleUserSearch(q: string) {
|
||||
setAddUserId(q);
|
||||
if (q.length < 2) { setSearchUsers([]); return; }
|
||||
setSearchLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/platform/users?search=${encodeURIComponent(q)}&pageSize=10`);
|
||||
const data = await res.json();
|
||||
const existingIds = new Set(members.map(m => m.uid));
|
||||
setSearchUsers((data.users || []).filter((u: { uid: string }) => !existingIds.has(u.uid)));
|
||||
} catch { setSearchUsers([]); }
|
||||
finally { setSearchLoading(false); }
|
||||
}
|
||||
|
||||
async function handleAddMember() {
|
||||
if (!addUserId) { setAddMemberError("请选择用户"); return; }
|
||||
setAddMemberLoading(true);
|
||||
setAddMemberError("");
|
||||
try {
|
||||
const res = await fetch(`/api/admin/projects/${id}/members`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: addUserId, scope: addScope }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setAddMemberError(data.error || "添加失败"); return; }
|
||||
setShowAddMember(false);
|
||||
setAddUserId("");
|
||||
setAddScope("member");
|
||||
setSearchUsers([]);
|
||||
reload();
|
||||
} catch { setAddMemberError("添加失败"); }
|
||||
finally { setAddMemberLoading(false); }
|
||||
}
|
||||
|
||||
async function handleRemoveMember(memberId: number) {
|
||||
if (!confirm("确定移除该成员?")) return;
|
||||
try {
|
||||
await fetch(`/api/admin/projects/${id}/members/${memberId}`, { method: "DELETE" });
|
||||
reload();
|
||||
} catch { window.alert("移除失败"); }
|
||||
}
|
||||
|
||||
async function handleUpdateScope(memberId: number, scope: string) {
|
||||
try {
|
||||
await fetch(`/api/admin/projects/${id}/members/${memberId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ scope }),
|
||||
});
|
||||
reload();
|
||||
} catch { window.alert("更新失败"); }
|
||||
}
|
||||
|
||||
async function handleAddCredit() {
|
||||
const amt = parseFloat(creditAmount);
|
||||
if (!amt || amt <= 0) { setCreditError("请输入有效金额"); return; }
|
||||
setCreditLoading(true);
|
||||
setCreditError("");
|
||||
try {
|
||||
const res = await fetch(`/api/admin/projects/${id}/billing`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ amount: amt, description: creditDesc }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setCreditError(data.error || "充值失败"); return; }
|
||||
setShowCredit(false);
|
||||
setCreditAmount("");
|
||||
setCreditDesc("");
|
||||
reloadBilling();
|
||||
} catch { setCreditError("充值失败"); }
|
||||
finally { setCreditLoading(false); }
|
||||
}
|
||||
|
||||
if (loading) return <div className="admin-content"><div className="loading">加载中...</div></div>;
|
||||
if (error || !project) return <div className="admin-content"><div className="alert alert-error">{error || "未找到"}</div></div>;
|
||||
|
||||
return (
|
||||
<div className="admin-content">
|
||||
<div style={{ marginBottom: "16px" }}>
|
||||
<Link href="/admin/projects" className="btn btn-secondary btn-sm">← 返回列表</Link>
|
||||
</div>
|
||||
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">{project.display_name || project.name}</h1>
|
||||
<p className="page-subtitle">
|
||||
{project.workspace_name && (
|
||||
<Link href={`/admin/workspaces/${project.workspace_id}`} style={{ marginRight: "8px" }}>
|
||||
<span className="badge badge-neutral">{project.workspace_name}</span>
|
||||
</Link>
|
||||
)}
|
||||
<span className={`badge ${project.is_public ? "badge-success" : "badge-neutral"}`}>
|
||||
{project.is_public ? "公开" : "私有"}
|
||||
</span>
|
||||
{project.description && (
|
||||
<span style={{ marginLeft: "8px", fontSize: "13px", color: "#737373" }}>{project.description}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{members.length}</div>
|
||||
<div className="stat-label">成员</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">
|
||||
{billing
|
||||
? `${parseFloat(billing.balance).toFixed(4)} ${billing.currency}`
|
||||
: "—"}
|
||||
</div>
|
||||
<div className="stat-label">
|
||||
项目余额
|
||||
<button className="btn btn-secondary btn-sm" style={{ marginLeft: "8px" }} onClick={() => setShowCredit(true)}>+ 充值</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ fontSize: "18px" }}>{project.created_by}</div>
|
||||
<div className="stat-label">创建者 UID</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ fontSize: "16px" }}>{format(new Date(project.created_at), "yyyy-MM-dd")}</div>
|
||||
<div className="stat-label">创建时间</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="toolbar" style={{ marginBottom: "0" }}>
|
||||
<button className={`btn btn-sm ${tab === "members" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("members")}>
|
||||
成员 ({members.length})
|
||||
</button>
|
||||
<button className={`btn btn-sm ${tab === "billing" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("billing")}>
|
||||
账单历史
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{tab === "members" && (
|
||||
<>
|
||||
<div style={{ padding: "12px", borderBottom: "1px solid #e5e5e5", display: "flex", justifyContent: "flex-end" }}>
|
||||
<button className="btn btn-sm btn-primary" onClick={() => setShowAddMember(true)}>+ 添加成员</button>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>加入时间</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{members.map(m => (
|
||||
<tr key={m.id ?? m.uid}>
|
||||
<td>{m.username}</td>
|
||||
<td>{m.display_name || "—"}</td>
|
||||
<td>
|
||||
{m.role === "owner" ? (
|
||||
<span className="badge badge-success">{ROLE_LABELS[m.role] || m.role}</span>
|
||||
) : (
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ padding: "2px 6px", fontSize: "12px", width: "auto" }}
|
||||
value={m.role}
|
||||
onChange={(e) => m.id && handleUpdateScope(m.id, e.target.value)}
|
||||
>
|
||||
<option value="admin">管理员</option>
|
||||
<option value="member">成员</option>
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
<td>{format(new Date(m.joined_at), "yyyy-MM-dd")}</td>
|
||||
<td>
|
||||
{m.role !== "owner" && (
|
||||
<button className="btn btn-danger btn-sm" onClick={() => m.id && handleRemoveMember(m.id)}>移除</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{members.length === 0 && <tr><td colSpan={5} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无成员</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "billing" && (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>时间</th><th>类型</th><th>金额</th><th>描述</th></tr></thead>
|
||||
<tbody>
|
||||
{billingHistory.map((b, i) => (
|
||||
<tr key={i}>
|
||||
<td>{format(new Date(b.created_at), "yyyy-MM-dd HH:mm")}</td>
|
||||
<td><span className="badge badge-neutral">{b.reason}</span></td>
|
||||
<td style={{ color: parseFloat(b.amount) >= 0 ? "#16a34a" : "#dc2626" }}>
|
||||
{parseFloat(b.amount) >= 0 ? "+" : ""}{parseFloat(b.amount).toFixed(6)}
|
||||
</td>
|
||||
<td style={{ fontSize: "12px", color: "#737373" }}>{b.description || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{billingHistory.length === 0 && <tr><td colSpan={4} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无账单记录</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCredit && (
|
||||
<div className="modal-overlay" onClick={() => setShowCredit(false)}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: "420px" }}>
|
||||
<h2 className="modal-title">为项目充值</h2>
|
||||
{creditError && <div className="alert alert-error">{creditError}</div>}
|
||||
<div className="form-group">
|
||||
<label className="form-label">充值金额(USD)</label>
|
||||
<input type="number" className="form-input" min="0.01" step="0.01"
|
||||
value={creditAmount} onChange={e => setCreditAmount(e.target.value)}
|
||||
placeholder="例如:10.00" autoFocus />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">备注(可选)</label>
|
||||
<input type="text" className="form-input"
|
||||
value={creditDesc} onChange={e => setCreditDesc(e.target.value)}
|
||||
placeholder="充值原因" />
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowCredit(false)}>取消</button>
|
||||
<button className="btn btn-primary" disabled={creditLoading} onClick={handleAddCredit}>
|
||||
{creditLoading ? "充值中..." : "确认充值"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddMember && (
|
||||
<div className="modal-overlay" onClick={() => setShowAddMember(false)}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: "440px" }}>
|
||||
<h2 className="modal-title">添加成员</h2>
|
||||
{addMemberError && <div className="alert alert-error">{addMemberError}</div>}
|
||||
<div className="form-group">
|
||||
<label className="form-label">搜索用户</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={addUserId}
|
||||
onChange={e => handleUserSearch(e.target.value)}
|
||||
placeholder="输入用户名搜索..."
|
||||
autoFocus
|
||||
/>
|
||||
{searchUsers.length > 0 && (
|
||||
<div style={{ border: "1px solid #e5e5e5", borderRadius: "6px", marginTop: "4px", maxHeight: "200px", overflowY: "auto", background: "#fff" }}>
|
||||
{searchUsers.map(u => (
|
||||
<div
|
||||
key={u.uid}
|
||||
style={{ padding: "8px 12px", cursor: "pointer", borderBottom: "1px solid #f0f0f0" }}
|
||||
onClick={() => { setAddUserId(u.uid); setSearchUsers([]); }}
|
||||
>
|
||||
<div style={{ fontWeight: 500, fontSize: "14px" }}>{u.username}</div>
|
||||
<div style={{ fontSize: "12px", color: "#737373" }}>{u.display_name || ""}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{searchLoading && <div style={{ fontSize: "12px", color: "#737373", marginTop: "4px" }}>搜索中...</div>}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">角色</label>
|
||||
<select className="form-input" value={addScope} onChange={e => setAddScope(e.target.value)}>
|
||||
<option value="admin">管理员</option>
|
||||
<option value="member">成员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => { setShowAddMember(false); setSearchUsers([]); }}>取消</button>
|
||||
<button className="btn btn-primary" disabled={addMemberLoading || !addUserId} onClick={handleAddMember}>
|
||||
{addMemberLoading ? "添加中..." : "添加"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
admin/src/app/admin/projects/page.tsx
Normal file
156
admin/src/app/admin/projects/page.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string | null;
|
||||
is_public: boolean;
|
||||
workspace_id: string | null;
|
||||
workspace_name: string | null;
|
||||
workspace_slug: string | null;
|
||||
balance: string;
|
||||
currency: string;
|
||||
member_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [workspaceId, setWorkspaceId] = useState("");
|
||||
const [visibility, setVisibility] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const pageSize = 20;
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/platform/workspaces?pageSize=100")
|
||||
.then(r => r.json())
|
||||
.then(d => setWorkspaces(d.workspaces || []))
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadProjects(); }, [page, search, workspaceId, visibility]);
|
||||
|
||||
function loadProjects() {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||
if (search) params.set("search", search);
|
||||
if (workspaceId) params.set("workspaceId", workspaceId);
|
||||
if (visibility) params.set("visibility", visibility);
|
||||
fetch(`/api/admin/projects?${params}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
setProjects(data.projects || []);
|
||||
setTotal(data.total || 0);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div className="admin-content">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">项目管理</h1>
|
||||
<p className="page-subtitle">共 {total} 个项目</p>
|
||||
</div>
|
||||
<div className="toolbar">
|
||||
<input
|
||||
type="text" className="form-input search-input"
|
||||
placeholder="搜索项目名称..."
|
||||
value={search} onChange={e => { setSearch(e.target.value); setPage(1); }}
|
||||
/>
|
||||
<select className="form-input" style={{ maxWidth: "200px" }}
|
||||
value={workspaceId} onChange={e => { setWorkspaceId(e.target.value); setPage(1); }}>
|
||||
<option value="">全部 Workspace</option>
|
||||
{workspaces.map(w => (
|
||||
<option key={w.id} value={w.id}>{w.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="form-input" style={{ maxWidth: "140px" }}
|
||||
value={visibility} onChange={e => { setVisibility(e.target.value); setPage(1); }}>
|
||||
<option value="">全部可见性</option>
|
||||
<option value="public">公开</option>
|
||||
<option value="private">私有</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="card">
|
||||
{loading ? <div className="loading">加载中...</div> : (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>Workspace</th>
|
||||
<th>可见性</th>
|
||||
<th>余额</th>
|
||||
<th>成员</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projects.map(p => (
|
||||
<tr key={p.id}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 500 }}>{p.display_name || p.name}</div>
|
||||
{p.description && <div style={{ fontSize: "12px", color: "#737373" }}>{p.description}</div>}
|
||||
</td>
|
||||
<td>
|
||||
{p.workspace_name
|
||||
? <span style={{ fontSize: "13px" }}>{p.workspace_name}</span>
|
||||
: <span style={{ color: "#a3a3a3", fontSize: "13px" }}>无</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${p.is_public ? "badge-success" : "badge-neutral"}`}>
|
||||
{p.is_public ? "公开" : "私有"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{parseFloat(p.balance) > 0
|
||||
? `${parseFloat(p.balance).toFixed(4)} ${p.currency}`
|
||||
: "—"}
|
||||
</td>
|
||||
<td>{p.member_count}</td>
|
||||
<td>{format(new Date(p.created_at), "yyyy-MM-dd")}</td>
|
||||
<td>
|
||||
<Link href={`/admin/projects/${p.id}`}>
|
||||
<button className="btn btn-secondary btn-sm">详情</button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{projects.length === 0 && (
|
||||
<tr><td colSpan={7} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无数据</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="pagination">
|
||||
<span className="pagination-info">第 {page} / {totalPages} 页,共 {total} 条</span>
|
||||
<button className="btn btn-secondary btn-sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||||
<button className="btn btn-secondary btn-sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
176
admin/src/app/admin/repos/[id]/page.tsx
Normal file
176
admin/src/app/admin/repos/[id]/page.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { format, parseISO } from "date-fns";
|
||||
|
||||
interface Branch {
|
||||
name: string;
|
||||
oid: string;
|
||||
isHead: boolean;
|
||||
upstream: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Commit {
|
||||
id: number;
|
||||
oid: string;
|
||||
shortOid: string;
|
||||
authorName: string;
|
||||
authorEmail: string;
|
||||
message: string;
|
||||
shortMessage: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Repo {
|
||||
id: string;
|
||||
repoName: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
workspaceName: string;
|
||||
defaultBranch: string;
|
||||
isPrivate: boolean;
|
||||
description: string | null;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
aiCodeReviewEnabled: boolean;
|
||||
collaboratorCount: number;
|
||||
}
|
||||
|
||||
type Tab = "branches" | "commits";
|
||||
|
||||
export default function RepoDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [repo, setRepo] = useState<Repo | null>(null);
|
||||
const [branches, setBranches] = useState<Branch[]>([]);
|
||||
const [commits, setCommits] = useState<Commit[]>([]);
|
||||
const [tab, setTab] = useState<Tab>("branches");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
fetch(`/api/admin/repos/${id}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.error) { setError(data.error); return; }
|
||||
setRepo(data.repo);
|
||||
setBranches(data.branches || []);
|
||||
setCommits(data.commits || []);
|
||||
})
|
||||
.catch((e) => setError(String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <div className="admin-content"><div className="loading">加载中...</div></div>;
|
||||
if (error) return <div className="admin-content"><div className="alert alert-error">{error}</div></div>;
|
||||
if (!repo) return null;
|
||||
|
||||
return (
|
||||
<div className="admin-content">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "4px" }}>
|
||||
<Link href="/admin/repos" style={{ color: "#737373", fontSize: "13px" }}>← 仓库列表</Link>
|
||||
</div>
|
||||
<h1 className="page-title">
|
||||
<code>{repo.repoName}</code>
|
||||
</h1>
|
||||
<p className="page-subtitle">
|
||||
{repo.workspaceName ? `${repo.workspaceName} / ` : ""}{repo.projectName}
|
||||
{" · "}
|
||||
<span className={`badge ${repo.isPrivate ? "badge-neutral" : "badge-success"}`}>
|
||||
{repo.isPrivate ? "私有" : "公开"}
|
||||
</span>
|
||||
{" · "}
|
||||
默认分支: <code>{repo.defaultBranch}</code>
|
||||
</p>
|
||||
</div>
|
||||
<span className={`badge ${repo.aiCodeReviewEnabled ? "badge-success" : "badge-neutral"}`}>
|
||||
AI Review {repo.aiCodeReviewEnabled ? "启用" : "禁用"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Repo info card */}
|
||||
<div className="card" style={{ marginBottom: "16px" }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: "16px" }}>
|
||||
<div>
|
||||
<div style={{ fontSize: "12px", color: "#737373", marginBottom: "4px" }}>描述</div>
|
||||
<div style={{ fontSize: "14px" }}>{repo.description || "无描述"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: "12px", color: "#737373", marginBottom: "4px" }}>创建者</div>
|
||||
<div style={{ fontSize: "14px" }}><code>{repo.createdBy}</code></div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: "12px", color: "#737373", marginBottom: "4px" }}>创建时间</div>
|
||||
<div style={{ fontSize: "14px" }}>{format(parseISO(repo.createdAt), "yyyy-MM-dd HH:mm")}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: "12px", color: "#737373", marginBottom: "4px" }}>协作者</div>
|
||||
<div style={{ fontSize: "14px" }}>{repo.collaboratorCount} 人</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="toolbar">
|
||||
<button className={`btn btn-sm ${tab === "branches" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("branches")}>
|
||||
分支 ({branches.length})
|
||||
</button>
|
||||
<button className={`btn btn-sm ${tab === "commits" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("commits")}>
|
||||
最近提交 ({commits.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{tab === "branches" && (
|
||||
branches.length === 0 ? (
|
||||
<div className="empty-state"><div className="empty-state-icon">🌿</div><p>暂无分支</p></div>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>分支名</th><th>Commit OID</th><th>HEAD</th><th>Upstream</th><th>更新时间</th></tr></thead>
|
||||
<tbody>
|
||||
{branches.map((b) => (
|
||||
<tr key={b.name}>
|
||||
<td><code style={{ fontSize: "12px" }}>{b.name}</code></td>
|
||||
<td><code style={{ fontSize: "11px", color: "#737373" }}>{b.oid.slice(0, 8)}</code></td>
|
||||
<td>{b.isHead ? <span className="badge badge-success">HEAD</span> : null}</td>
|
||||
<td style={{ fontSize: "12px", color: "#737373" }}>{b.upstream || "-"}</td>
|
||||
<td style={{ fontSize: "12px" }}>{format(parseISO(b.createdAt), "yyyy-MM-dd HH:mm")}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === "commits" && (
|
||||
commits.length === 0 ? (
|
||||
<div className="empty-state"><div className="empty-state-icon">📝</div><p>暂无提交记录</p></div>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>OID</th><th>提交信息</th><th>作者</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
{commits.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td><code style={{ fontSize: "11px", color: "#737373" }}>{c.shortOid}</code></td>
|
||||
<td style={{ fontSize: "13px", maxWidth: "400px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={c.message}>{c.shortMessage}</td>
|
||||
<td style={{ fontSize: "12px" }}>{c.authorName} <{c.authorEmail}></td>
|
||||
<td style={{ fontSize: "12px" }}>{format(parseISO(c.createdAt), "yyyy-MM-dd HH:mm")}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
admin/src/app/admin/repos/page.tsx
Normal file
135
admin/src/app/admin/repos/page.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { format, parseISO } from "date-fns";
|
||||
|
||||
interface Repo {
|
||||
id: string;
|
||||
repoName: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
workspaceName: string;
|
||||
defaultBranch: string;
|
||||
isPrivate: boolean;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
aiCodeReviewEnabled: boolean;
|
||||
collaboratorCount: number;
|
||||
branchCount: number;
|
||||
}
|
||||
|
||||
export default function ReposPage() {
|
||||
const [repos, setRepos] = useState<Repo[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const pageSize = 30;
|
||||
|
||||
useEffect(() => { loadRepos(); }, [page, search]);
|
||||
|
||||
function loadRepos() {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||
if (search) params.set("search", search);
|
||||
fetch(`/api/platform/repos?${params}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setRepos(data.repos || []);
|
||||
setTotal(data.total || 0);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div className="admin-content">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">仓库管理</h1>
|
||||
<p className="page-subtitle">Git 仓库(只读)</p>
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="form-input search-input"
|
||||
placeholder="搜索仓库名称..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{loading ? <div className="loading">加载中...</div> : repos.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">📦</div>
|
||||
<p>暂无仓库</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>仓库名称</th>
|
||||
<th>所属项目</th>
|
||||
<th>可见性</th>
|
||||
<th>默认分支</th>
|
||||
<th>协作者</th>
|
||||
<th>分支</th>
|
||||
<th>AI Review</th>
|
||||
<th>创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{repos.map((repo) => (
|
||||
<tr key={repo.id}>
|
||||
<td>
|
||||
<Link href={`/admin/repos/${repo.id}`}>
|
||||
<code style={{ fontSize: "12px", cursor: "pointer", color: "#2563eb" }}>{repo.repoName}</code>
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<span style={{ fontSize: "12px" }}>
|
||||
{repo.workspaceName ? `${repo.workspaceName} / ` : ""}
|
||||
{repo.projectName}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${repo.isPrivate ? "badge-neutral" : "badge-success"}`}>
|
||||
{repo.isPrivate ? "私有" : "公开"}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontSize: "12px", fontFamily: "monospace" }}>
|
||||
{repo.defaultBranch || "main"}
|
||||
</td>
|
||||
<td>{repo.collaboratorCount}</td>
|
||||
<td>{repo.branchCount}</td>
|
||||
<td>
|
||||
<span className={`badge ${repo.aiCodeReviewEnabled ? "badge-success" : "badge-neutral"}`}>
|
||||
{repo.aiCodeReviewEnabled ? "启用" : "禁用"}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontSize: "12px" }}>
|
||||
{format(parseISO(repo.createdAt), "yyyy-MM-dd")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="pagination">
|
||||
<span className="pagination-info">第 {page} / {totalPages} 页,共 {total} 条</span>
|
||||
<button className="btn btn-secondary btn-sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||||
<button className="btn btn-secondary btn-sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
admin/src/app/admin/roles/page.tsx
Normal file
138
admin/src/app/admin/roles/page.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, FormEvent } from "react";
|
||||
|
||||
interface Permission { id: number; name: string; code: string; }
|
||||
interface Role {
|
||||
id: number; name: string; description: string | null; created_at: string;
|
||||
permissions?: Permission[];
|
||||
}
|
||||
|
||||
export default function RolesPage() {
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [permissions, setPermissions] = useState<Permission[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showEdit, setShowEdit] = useState<Role | null>(null);
|
||||
const [form, setForm] = useState({ name: "", description: "", permissionIds: [] as number[] });
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => { loadData(); }, []);
|
||||
async function loadData() {
|
||||
const [rolesRes, permsRes] = await Promise.all([fetch("/api/roles"), fetch("/api/permissions")]);
|
||||
const [rolesData, permsData] = await Promise.all([rolesRes.json(), permsRes.json()]);
|
||||
setRoles(rolesData.roles || []);
|
||||
setPermissions(permsData.permissions || []);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function openEdit(role: Role) {
|
||||
const res = await fetch(`/api/roles/${role.id}`);
|
||||
const data = await res.json();
|
||||
setShowEdit(data);
|
||||
setForm({ name: data.name, description: data.description || "", permissionIds: (data.permissions || []).map((p: Permission) => p.id) });
|
||||
}
|
||||
|
||||
async function handleCreate(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
const res = await fetch("/api/roles", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form) });
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setError(data.error || "创建失败"); return; }
|
||||
setShowCreate(false);
|
||||
loadData();
|
||||
}
|
||||
|
||||
async function handleUpdate(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!showEdit) return;
|
||||
setError("");
|
||||
const res = await fetch(`/api/roles/${showEdit.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form) });
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setError(data.error || "更新失败"); return; }
|
||||
setShowEdit(null);
|
||||
loadData();
|
||||
}
|
||||
|
||||
async function handleDelete(role: Role) {
|
||||
if (!confirm(`删除角色 "${role.name}" 吗?`)) return;
|
||||
await fetch(`/api/roles/${role.id}`, { method: "DELETE" });
|
||||
loadData();
|
||||
}
|
||||
|
||||
function renderModal(title: string, onSubmit: (e: FormEvent) => void) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={() => { setShowCreate(false); setShowEdit(null); }}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="modal-title">{title}</h2>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">角色名称</label>
|
||||
<input type="text" className="form-input" value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">描述</label>
|
||||
<input type="text" className="form-input" value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">权限</label>
|
||||
<div className="checkbox-group">
|
||||
{permissions.map((p) => (
|
||||
<label key={p.id} className="checkbox-item">
|
||||
<input type="checkbox" checked={form.permissionIds.includes(p.id)}
|
||||
onChange={(e) => {
|
||||
const ids = e.target.checked ? [...form.permissionIds, p.id] : form.permissionIds.filter(id => id !== p.id);
|
||||
setForm({ ...form, permissionIds: ids });
|
||||
}} />
|
||||
<span>{p.name}</span>
|
||||
<span style={{ fontSize: "11px", color: "#737373", marginLeft: "4px" }}>{p.code}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => { setShowCreate(false); setShowEdit(null); }}>取消</button>
|
||||
<button type="submit" className="btn btn-primary">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-content">
|
||||
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div><h1 className="page-title">角色管理</h1><p className="page-subtitle">共 {roles.length} 个角色</p></div>
|
||||
<button className="btn btn-primary" onClick={() => { setForm({ name: "", description: "", permissionIds: [] }); setShowCreate(true); }}>+ 新建角色</button>
|
||||
</div>
|
||||
<div className="card">
|
||||
{loading ? <div className="loading">加载中...</div> : (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>ID</th><th>名称</th><th>描述</th><th>创建时间</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{roles.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.id}</td><td>{r.name}</td><td>{r.description || "-"}</td>
|
||||
<td>{new Date(r.created_at).toLocaleDateString()}</td>
|
||||
<td><div style={{ display: "flex", gap: "4px" }}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => openEdit(r)}>编辑</button>
|
||||
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(r)}>删除</button>
|
||||
</div></td>
|
||||
</tr>
|
||||
))}
|
||||
{roles.length === 0 && <tr><td colSpan={5} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无数据</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showCreate && renderModal("新建角色", handleCreate)}
|
||||
{showEdit && renderModal("编辑角色", handleUpdate)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
admin/src/app/admin/rooms/[id]/page.tsx
Normal file
175
admin/src/app/admin/rooms/[id]/page.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { format, parseISO } from "date-fns";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
seq: number;
|
||||
senderType: string;
|
||||
senderId: string | null;
|
||||
senderName: string | null;
|
||||
content: string;
|
||||
contentType: string;
|
||||
sendAt: string;
|
||||
revoked: string | null;
|
||||
inReplyTo: string | null;
|
||||
}
|
||||
|
||||
const SENDER_LABELS: Record<string, string> = {
|
||||
user: "用户", member: "用户", ai: "AI", system: "系统", bot: "Bot",
|
||||
};
|
||||
|
||||
const CONTENT_TYPE_LABELS: Record<string, string> = {
|
||||
text: "文本", markdown: "Markdown", code: "代码", image: "图片",
|
||||
};
|
||||
|
||||
export default function RoomDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [revoking, setRevoking] = useState<string | null>(null);
|
||||
const pageSize = 50;
|
||||
|
||||
useEffect(() => { loadMessages(); }, [page]);
|
||||
|
||||
function loadMessages() {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||
fetch(`/api/platform/rooms/${id}/messages?${params}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setMessages(data.messages || []);
|
||||
setTotal(data.total || 0);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
async function revokeMessage(msgId: string) {
|
||||
if (!confirm("确定撤回此消息?")) return;
|
||||
setRevoking(msgId);
|
||||
try {
|
||||
const res = await fetch(`/api/platform/rooms/${id}/messages/${msgId}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
setMessages((msgs) => msgs.map((m) => m.id === msgId ? { ...m, revoked: new Date().toISOString() } : m));
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert(data.error || "撤回失败");
|
||||
}
|
||||
} finally {
|
||||
setRevoking(null);
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div className="admin-content">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">房间消息</h1>
|
||||
<p className="page-subtitle">ID: {id}</p>
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<span style={{ fontSize: "13px", color: "#737373" }}>
|
||||
共 {total.toLocaleString()} 条消息
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{loading ? <div className="loading">加载中...</div> : messages.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">💬</div>
|
||||
<p>暂无消息</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ maxHeight: "600px", overflow: "auto" }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: "60px" }}>序号</th>
|
||||
<th style={{ width: "140px" }}>发送者</th>
|
||||
<th>内容</th>
|
||||
<th style={{ width: "80px" }}>类型</th>
|
||||
<th style={{ width: "160px" }}>时间</th>
|
||||
<th style={{ width: "80px" }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{messages.map((msg) => (
|
||||
<tr key={msg.id} style={{ opacity: msg.revoked ? "0.5" : "1" }}>
|
||||
<td style={{ fontSize: "11px", fontFamily: "monospace" }}>#{msg.seq}</td>
|
||||
<td>
|
||||
<div style={{ fontSize: "12px" }}>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{msg.senderName || <span style={{ color: "#a3a3a3" }}>未知</span>}
|
||||
</div>
|
||||
<div style={{ color: "#737373", fontSize: "11px" }}>
|
||||
{SENDER_LABELS[msg.senderType] || msg.senderType}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ maxWidth: "400px" }}>
|
||||
{msg.revoked ? (
|
||||
<span style={{ color: "#a3a3a3", fontStyle: "italic", fontSize: "12px" }}>
|
||||
[此消息已被撤回]
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontFamily: msg.contentType === "code" ? "monospace" : "inherit",
|
||||
display: "block",
|
||||
maxWidth: "400px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={msg.content}
|
||||
>
|
||||
{msg.content.slice(0, 200)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge badge-neutral">
|
||||
{CONTENT_TYPE_LABELS[msg.contentType] || msg.contentType}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontSize: "11px" }}>
|
||||
{format(parseISO(msg.sendAt), "yyyy-MM-dd HH:mm:ss")}
|
||||
</td>
|
||||
<td>
|
||||
{!msg.revoked && (
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={revoking === msg.id}
|
||||
onClick={() => revokeMessage(msg.id)}
|
||||
style={{ fontSize: "11px", padding: "2px 8px" }}
|
||||
>
|
||||
{revoking === msg.id ? "..." : "撤回"}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="pagination">
|
||||
<span className="pagination-info">第 {page} / {totalPages} 页,共 {total} 条</span>
|
||||
<button className="btn btn-secondary btn-sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||||
<button className="btn btn-secondary btn-sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
admin/src/app/admin/rooms/page.tsx
Normal file
124
admin/src/app/admin/rooms/page.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { format, parseISO } from "date-fns";
|
||||
|
||||
interface Room {
|
||||
id: string;
|
||||
roomName: string;
|
||||
isPublic: boolean;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
workspaceName: string;
|
||||
memberCount: number;
|
||||
messageCount: number;
|
||||
lastMsgAt: string;
|
||||
}
|
||||
|
||||
export default function RoomsPage() {
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const pageSize = 30;
|
||||
|
||||
useEffect(() => { loadRooms(); }, [page, search]);
|
||||
|
||||
function loadRooms() {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||
if (search) params.set("search", search);
|
||||
fetch(`/api/platform/rooms?${params}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setRooms(data.rooms || []);
|
||||
setTotal(data.total || 0);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div className="admin-content">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">房间管理</h1>
|
||||
<p className="page-subtitle">项目聊天室(只读)</p>
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="form-input search-input"
|
||||
placeholder="搜索房间名称..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{loading ? <div className="loading">加载中...</div> : rooms.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">💬</div>
|
||||
<p>暂无房间</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>房间名称</th>
|
||||
<th>项目</th>
|
||||
<th>可见性</th>
|
||||
<th>成员</th>
|
||||
<th>消息数</th>
|
||||
<th>最后活跃</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rooms.map((room) => (
|
||||
<tr key={room.id}>
|
||||
<td>{room.roomName}</td>
|
||||
<td>
|
||||
<span style={{ fontSize: "12px" }}>
|
||||
{room.workspaceName ? `${room.workspaceName} / ` : ""}
|
||||
{room.projectName}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${room.isPublic ? "badge-success" : "badge-neutral"}`}>
|
||||
{room.isPublic ? "公开" : "私有"}
|
||||
</span>
|
||||
</td>
|
||||
<td>{room.memberCount}</td>
|
||||
<td>{room.messageCount.toLocaleString()}</td>
|
||||
<td style={{ fontSize: "12px" }}>
|
||||
{room.lastMsgAt ? format(parseISO(room.lastMsgAt), "yyyy-MM-dd HH:mm") : "—"}
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/admin/rooms/${room.id}`} className="btn btn-secondary btn-sm">
|
||||
查看
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="pagination">
|
||||
<span className="pagination-info">第 {page} / {totalPages} 页,共 {total} 条</span>
|
||||
<button className="btn btn-secondary btn-sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||||
<button className="btn btn-secondary btn-sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
admin/src/app/admin/sessions/page.tsx
Normal file
96
admin/src/app/admin/sessions/page.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface SessionInfo {
|
||||
sessionId: string; userId: string | null; username: string | null;
|
||||
ipAddress: string | null; userAgent: string | null; createdAt: string | null;
|
||||
}
|
||||
|
||||
export default function SessionsPage() {
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [kicking, setKicking] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => { loadSessions(); }, []);
|
||||
async function loadSessions() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/sessions");
|
||||
const data = await res.json();
|
||||
setSessions(data.sessions || []);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
async function handleKickSession(sessionId: string) {
|
||||
if (!confirm("确定强制下线该会话吗?")) return;
|
||||
setKicking(sessionId);
|
||||
try {
|
||||
await fetch("/api/sessions", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId }) });
|
||||
loadSessions();
|
||||
} finally { setKicking(null); }
|
||||
}
|
||||
|
||||
const byUser = sessions.reduce<Record<string, SessionInfo[]>>((acc, s) => {
|
||||
const key = s.userId || "anonymous";
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(s);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="admin-content">
|
||||
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div>
|
||||
<h1 className="page-title">在线用户管理</h1>
|
||||
<p className="page-subtitle">共 {sessions.length} 个活跃会话,涉及 {Object.keys(byUser).length} 个用户</p>
|
||||
</div>
|
||||
<button className="btn btn-secondary" onClick={loadSessions}>刷新</button>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="loading">加载中...</div>
|
||||
: sessions.length === 0 ? (
|
||||
<div className="card"><div className="empty-state"><div className="empty-state-icon">◐</div><p>当前没有在线的管理员会话</p></div></div>
|
||||
) : Object.entries(byUser).map(([userId, userSessions]) => (
|
||||
<div key={userId} className="card" style={{ marginBottom: "16px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "12px" }}>
|
||||
<div>
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
{userId && userSessions[0].username
|
||||
? `${userSessions[0].username} (ID: ${userId})`
|
||||
: userId === "anonymous" ? "匿名会话" : `用户 ID: ${userId}`}
|
||||
</span>
|
||||
<span className="badge badge-neutral" style={{ marginLeft: "8px" }}>{userSessions.length} 个会话</span>
|
||||
</div>
|
||||
{userId !== "anonymous" && (
|
||||
<button className="btn btn-danger btn-sm" onClick={async () => {
|
||||
if (!confirm(`强制下线用户 ${userId} 的所有会话?`)) return;
|
||||
for (const s of userSessions) await fetch("/api/sessions", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: s.sessionId }) });
|
||||
loadSessions();
|
||||
}}>全部下线</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>Session ID</th><th>登录时间</th><th>IP地址</th><th>User Agent</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{userSessions.map((s) => (
|
||||
<tr key={s.sessionId}>
|
||||
<td><code style={{ fontSize: "11px", background: "#f4f4f5", padding: "2px 6px", borderRadius: "4px" }}>{s.sessionId.slice(0, 16)}...</code></td>
|
||||
<td>{s.createdAt ? format(new Date(s.createdAt), "yyyy-MM-dd HH:mm") : "-"}</td>
|
||||
<td style={{ fontSize: "13px" }}>{s.ipAddress || "-"}</td>
|
||||
<td style={{ fontSize: "12px", maxWidth: "300px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{s.userAgent || "-"}</td>
|
||||
<td><button className="btn btn-danger btn-sm" disabled={kicking === s.sessionId} onClick={() => handleKickSession(s.sessionId)}>{kicking === s.sessionId ? "处理中..." : "下线"}</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
214
admin/src/app/admin/users/[id]/page.tsx
Normal file
214
admin/src/app/admin/users/[id]/page.tsx
Normal file
@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
interface AdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
roles: Role[];
|
||||
}
|
||||
|
||||
interface AllRole {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<AdminUser | null>(null);
|
||||
const [allRoles, setAllRoles] = useState<AllRole[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showEditRoles, setShowEditRoles] = useState(false);
|
||||
const [selectedRoles, setSelectedRoles] = useState<number[]>([]);
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
fetch(`/api/users/${id}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.error) { setError(data.error); return; }
|
||||
setUser(data);
|
||||
setSelectedRoles(data.roles.map((r: Role) => r.id));
|
||||
})
|
||||
.catch((e) => setError(String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/roles")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setAllRoles(data.roles || []))
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
const body: Record<string, unknown> = {};
|
||||
if (newPassword) body.password = newPassword;
|
||||
if (showEditRoles) body.roleIds = selectedRoles;
|
||||
|
||||
const res = await fetch(`/api/users/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setError(data.error || "保存失败"); setSaving(false); return; }
|
||||
setNewPassword("");
|
||||
setShowEditRoles(false);
|
||||
// reload user
|
||||
const reloaded = await fetch(`/api/users/${id}`).then(r => r.json());
|
||||
setUser(reloaded);
|
||||
setSelectedRoles(reloaded.roles.map((r: Role) => r.id));
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
if (loading) return <div className="admin-content"><div className="loading">加载中...</div></div>;
|
||||
if (error && !user) return <div className="admin-content"><div className="alert alert-error">{error}</div></div>;
|
||||
if (!user) return <div className="admin-content"><div className="alert alert-error">用户不存在</div></div>;
|
||||
|
||||
return (
|
||||
<div className="admin-content">
|
||||
<div style={{ marginBottom: "16px" }}>
|
||||
<Link href="/admin/users" className="btn btn-secondary btn-sm">← 返回列表</Link>
|
||||
</div>
|
||||
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">{user.username}</h1>
|
||||
<p className="page-subtitle">
|
||||
<span className={`badge ${user.is_active ? "badge-success" : "badge-danger"}`}>
|
||||
{user.is_active ? "启用" : "禁用"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error" style={{ marginBottom: "16px" }}>{error}</div>}
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "16px" }}>
|
||||
{/* 基本信息 */}
|
||||
<div className="card">
|
||||
<div className="card-title">基本信息</div>
|
||||
<table className="info-table">
|
||||
<tbody>
|
||||
<tr><td className="info-label">ID</td><td>{user.id}</td></tr>
|
||||
<tr><td className="info-label">用户名</td><td>{user.username}</td></tr>
|
||||
<tr><td className="info-label">状态</td><td>
|
||||
<span className={`badge ${user.is_active ? "badge-success" : "badge-danger"}`}>
|
||||
{user.is_active ? "启用" : "禁用"}
|
||||
</span>
|
||||
</td></tr>
|
||||
<tr><td className="info-label">创建时间</td><td>{new Date(user.created_at).toLocaleString()}</td></tr>
|
||||
<tr><td className="info-label">更新时间</td><td>{new Date(user.updated_at).toLocaleString()}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 角色 */}
|
||||
<div className="card">
|
||||
<div className="card-title">角色分配</div>
|
||||
{user.roles.length === 0 ? (
|
||||
<p style={{ color: "#737373", fontSize: "14px" }}>暂无角色</p>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px", marginBottom: "12px" }}>
|
||||
{user.roles.map((r) => (
|
||||
<span key={r.id} className="badge badge-neutral">{r.name}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{showEditRoles ? (
|
||||
<div className="checkbox-group" style={{ marginBottom: "12px" }}>
|
||||
{allRoles.map((r) => (
|
||||
<label key={r.id} className="checkbox-item">
|
||||
<input type="checkbox" checked={selectedRoles.includes(r.id)}
|
||||
onChange={(e) => {
|
||||
setSelectedRoles(e.target.checked
|
||||
? [...selectedRoles, r.id]
|
||||
: selectedRoles.filter(x => x !== r.id));
|
||||
}} />
|
||||
{r.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
{!showEditRoles && (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowEditRoles(true)}>修改角色</button>
|
||||
)}
|
||||
{showEditRoles && (
|
||||
<>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => { setShowEditRoles(false); setSelectedRoles(user.roles.map((r: Role) => r.id)); }}>取消</button>
|
||||
<button className="btn btn-primary btn-sm" disabled={saving} onClick={handleSave}>
|
||||
{saving ? "保存中..." : "保存角色"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 重置密码 */}
|
||||
<div className="card">
|
||||
<div className="card-title">重置密码</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">新密码(留空则不修改)</label>
|
||||
<input type="password" className="form-input" value={newPassword}
|
||||
minLength={6} onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="至少6位" />
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" disabled={!newPassword || saving}
|
||||
onClick={handleSave}>
|
||||
{saving ? "保存中..." : "保存密码"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 快速操作 */}
|
||||
<div className="card">
|
||||
<div className="card-title">快速操作</div>
|
||||
<button
|
||||
className={`btn btn-sm ${user.is_active ? "btn-danger" : "btn-primary"}`}
|
||||
style={{ marginRight: "8px", marginBottom: "8px" }}
|
||||
onClick={async () => {
|
||||
const res = await fetch(`/api/users/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isActive: !user!.is_active }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setUser({ ...user, is_active: !user.is_active });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{user.is_active ? "禁用账号" : "启用账号"}
|
||||
</button>
|
||||
{user.id !== -1 && (
|
||||
<button className="btn btn-danger btn-sm"
|
||||
onClick={async () => {
|
||||
if (!confirm(`确定删除用户 "${user.username}" 吗?`)) return;
|
||||
const res = await fetch(`/api/users/${id}`, { method: "DELETE" });
|
||||
if (res.ok) router.push("/admin/users");
|
||||
}}>
|
||||
删除用户
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
187
admin/src/app/admin/users/page.tsx
Normal file
187
admin/src/app/admin/users/page.tsx
Normal file
@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, FormEvent } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface AdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [createForm, setCreateForm] = useState({ username: "", password: "", roleIds: [] as number[] });
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
useEffect(() => { loadUsers(); }, [page, search]);
|
||||
useEffect(() => {
|
||||
fetch("/api/roles")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setRoles(data.roles || []))
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
async function loadUsers() {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||
if (search) params.set("search", search);
|
||||
const res = await fetch(`/api/users?${params}`);
|
||||
if (res.status === 401) {
|
||||
window.location.href = "/login";
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setUsers(data.users || []);
|
||||
setTotal(data.total || 0);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function handleCreate(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
const res = await fetch("/api/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(createForm),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setError(data.error || "创建失败"); return; }
|
||||
setShowCreate(false);
|
||||
setCreateForm({ username: "", password: "", roleIds: [] });
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
async function handleToggleActive(u: AdminUser) {
|
||||
await fetch(`/api/users/${u.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isActive: !u.is_active }),
|
||||
});
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
async function handleDelete(u: AdminUser) {
|
||||
if (!confirm(`确定删除 "${u.username}" 吗?`)) return;
|
||||
await fetch(`/api/users/${u.id}`, { method: "DELETE" });
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div className="admin-content">
|
||||
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div>
|
||||
<h1 className="page-title">Admin 用户管理</h1>
|
||||
<p className="page-subtitle">共 {total} 个管理员用户</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => setShowCreate(true)}>+ 新建用户</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<input type="text" className="form-input search-input" placeholder="搜索用户名..." value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }} />
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{loading ? (
|
||||
<div className="loading">加载中...</div>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>用户名</th><th>状态</th><th>创建时间</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>{u.id}</td>
|
||||
<td>{u.username}</td>
|
||||
<td><span className={`badge ${u.is_active ? "badge-success" : "badge-danger"}`}>{u.is_active ? "启用" : "禁用"}</span></td>
|
||||
<td>{new Date(u.created_at).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<div style={{ display: "flex", gap: "4px" }}>
|
||||
<Link href={`/admin/users/${u.id}`}><button className="btn btn-secondary btn-sm">详情</button></Link>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => handleToggleActive(u)}>{u.is_active ? "禁用" : "启用"}</button>
|
||||
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(u)}>删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<tr><td colSpan={5} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无数据</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="pagination">
|
||||
<span className="pagination-info">第 {page} / {totalPages} 页,共 {total} 条</span>
|
||||
<button className="btn btn-secondary btn-sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||||
<button className="btn btn-secondary btn-sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<div className="modal-overlay" onClick={() => setShowCreate(false)}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="modal-title">新建 Admin 用户</h2>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<form onSubmit={handleCreate}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">用户名</label>
|
||||
<input type="text" className="form-input" value={createForm.username}
|
||||
onChange={(e) => setCreateForm({ ...createForm, username: e.target.value })} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">密码</label>
|
||||
<input type="password" className="form-input" value={createForm.password} minLength={6}
|
||||
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">角色</label>
|
||||
<div className="checkbox-group">
|
||||
{roles.map((r) => (
|
||||
<label key={r.id} className="checkbox-item">
|
||||
<input type="checkbox" checked={createForm.roleIds.includes(r.id)}
|
||||
onChange={(e) => {
|
||||
const ids = e.target.checked ? [...createForm.roleIds, r.id] : createForm.roleIds.filter(id => id !== r.id);
|
||||
setCreateForm({ ...createForm, roleIds: ids });
|
||||
}} />
|
||||
{r.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowCreate(false)}>取消</button>
|
||||
<button type="submit" className="btn btn-primary">创建</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
541
admin/src/app/admin/workspaces/[id]/page.tsx
Normal file
541
admin/src/app/admin/workspaces/[id]/page.tsx
Normal file
@ -0,0 +1,541 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface Workspace {
|
||||
id: string; slug: string; name: string; plan: string;
|
||||
description: string | null; billing_email: string | null;
|
||||
balance: string | null; currency: string | null;
|
||||
monthly_quota: string | null; total_spent: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Member {
|
||||
id?: number;
|
||||
uid: string; username: string; display_name: string | null;
|
||||
avatar_url: string | null; role: string; joined_at: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: string; name: string;
|
||||
is_public: boolean; member_count: number; created_at: string;
|
||||
}
|
||||
|
||||
interface BillingEntry {
|
||||
reason: string; amount: string; description: string | null; created_at: string;
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
owner: "所有者", admin: "管理员", member: "成员", viewer: "查看者",
|
||||
};
|
||||
const PLAN_LABELS: Record<string, string> = {
|
||||
free: "免费", starter: "入门", pro: "专业", enterprise: "企业",
|
||||
};
|
||||
|
||||
type Tab = "members" | "projects" | "billing" | "alerts";
|
||||
|
||||
interface AlertConfig {
|
||||
id: number;
|
||||
alert_type: string;
|
||||
threshold: string;
|
||||
email_enabled: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const ALERT_TYPE_LABELS: Record<string, { label: string; unit: string; placeholder: string }> = {
|
||||
low_balance: { label: "余额不足告警", unit: "USD", placeholder: "10.00" },
|
||||
monthly_quota: { label: "月度配额告警", unit: "%", placeholder: "80" },
|
||||
usage_surge: { label: "使用量激增", unit: "%", placeholder: "200" },
|
||||
};
|
||||
|
||||
export default function WorkspaceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [workspace, setWorkspace] = useState<Workspace | null>(null);
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [billingHistory, setBillingHistory] = useState<BillingEntry[]>([]);
|
||||
const [tab, setTab] = useState<Tab>("members");
|
||||
const [alertConfigs, setAlertConfigs] = useState<AlertConfig[]>([]);
|
||||
const [alertSaving, setAlertSaving] = useState(false);
|
||||
const [alertCheckMsg, setAlertCheckMsg] = useState<string | null>(null);
|
||||
const [alertChecking, setAlertChecking] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [showCredit, setShowCredit] = useState(false);
|
||||
const [creditAmount, setCreditAmount] = useState("");
|
||||
const [creditDesc, setCreditDesc] = useState("");
|
||||
const [creditLoading, setCreditLoading] = useState(false);
|
||||
const [creditError, setCreditError] = useState("");
|
||||
|
||||
// Member management
|
||||
const [showAddMember, setShowAddMember] = useState(false);
|
||||
const [addUserId, setAddUserId] = useState("");
|
||||
const [addRole, setAddRole] = useState("member");
|
||||
const [addMemberLoading, setAddMemberLoading] = useState(false);
|
||||
const [addMemberError, setAddMemberError] = useState("");
|
||||
const [searchUsers, setSearchUsers] = useState<{ uid: string; username: string; display_name: string | null }[]>([]);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
|
||||
function reload() {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
fetch(`/api/platform/workspaces/${id}`).then((r) => r.json()),
|
||||
fetch(`/api/platform/workspaces/${id}/alert-config`).then((r) => r.json()),
|
||||
]).then(([wsData, alertData]) => {
|
||||
if (wsData.error) { setError(wsData.error); return; }
|
||||
setWorkspace(wsData.workspace);
|
||||
setMembers(wsData.members || []);
|
||||
setProjects(wsData.projects || []);
|
||||
setBillingHistory(wsData.billingHistory || []);
|
||||
setAlertConfigs(alertData.configs || []);
|
||||
}).catch((e) => setError(String(e))).finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
reload();
|
||||
}, [id]);
|
||||
|
||||
async function handleSaveAlerts() {
|
||||
setAlertSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/platform/workspaces/${id}/alert-config`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ configs: alertConfigs }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { window.alert(data.error || "保存失败"); return; }
|
||||
window.alert("告警配置已保存");
|
||||
} catch { window.alert("保存失败"); }
|
||||
finally { setAlertSaving(false); }
|
||||
}
|
||||
|
||||
function updateAlert(type: string, field: keyof AlertConfig, value: string | boolean) {
|
||||
setAlertConfigs((prev) => {
|
||||
const existing = prev.find((c) => c.alert_type === type);
|
||||
if (existing) {
|
||||
return prev.map((c) => c.alert_type === type ? { ...c, [field]: value } : c);
|
||||
} else {
|
||||
return [...prev, {
|
||||
id: 0,
|
||||
alert_type: type,
|
||||
threshold: "0",
|
||||
email_enabled: true,
|
||||
enabled: true,
|
||||
[field]: value,
|
||||
} as AlertConfig];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getAlert(type: string): AlertConfig | undefined {
|
||||
return alertConfigs.find((c) => c.alert_type === type);
|
||||
}
|
||||
|
||||
async function handleAddCredit() {
|
||||
const amount = parseFloat(creditAmount);
|
||||
if (!amount || amount <= 0) { setCreditError("请输入有效金额"); return; }
|
||||
setCreditLoading(true);
|
||||
setCreditError("");
|
||||
try {
|
||||
const res = await fetch(`/api/platform/workspaces/${id}/add-credit`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ amount, description: creditDesc }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setCreditError(data.error || "充值失败"); return; }
|
||||
setShowCredit(false);
|
||||
setCreditAmount("");
|
||||
setCreditDesc("");
|
||||
reload();
|
||||
} catch {
|
||||
setCreditError("充值失败,请重试");
|
||||
} finally {
|
||||
setCreditLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Search users for adding member
|
||||
async function handleUserSearch(q: string) {
|
||||
setAddUserId(q);
|
||||
if (q.length < 2) { setSearchUsers([]); return; }
|
||||
setSearchLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/platform/users?search=${encodeURIComponent(q)}&pageSize=10`);
|
||||
const data = await res.json();
|
||||
const existingIds = new Set(members.map(m => m.uid));
|
||||
setSearchUsers((data.users || []).filter((u: { uid: string }) => !existingIds.has(u.uid)));
|
||||
} catch { setSearchUsers([]); }
|
||||
finally { setSearchLoading(false); }
|
||||
}
|
||||
|
||||
async function handleAddMember() {
|
||||
if (!addUserId) { setAddMemberError("请选择用户"); return; }
|
||||
setAddMemberLoading(true);
|
||||
setAddMemberError("");
|
||||
try {
|
||||
const res = await fetch(`/api/platform/workspaces/${id}/members`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: addUserId, role: addRole }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setAddMemberError(data.error || "添加失败"); return; }
|
||||
setShowAddMember(false);
|
||||
setAddUserId("");
|
||||
setAddRole("member");
|
||||
setSearchUsers([]);
|
||||
reload();
|
||||
} catch { setAddMemberError("添加失败"); }
|
||||
finally { setAddMemberLoading(false); }
|
||||
}
|
||||
|
||||
async function handleRemoveMember(memberId: number) {
|
||||
if (!confirm("确定移除该成员?")) return;
|
||||
try {
|
||||
await fetch(`/api/platform/workspaces/${id}/members/${memberId}`, { method: "DELETE" });
|
||||
reload();
|
||||
} catch { window.alert("移除失败"); }
|
||||
}
|
||||
|
||||
async function handleUpdateRole(memberId: number, role: string) {
|
||||
try {
|
||||
await fetch(`/api/platform/workspaces/${id}/members/${memberId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ role }),
|
||||
});
|
||||
reload();
|
||||
} catch { window.alert("更新失败"); }
|
||||
}
|
||||
|
||||
if (loading) return <div className="admin-content"><div className="loading">加载中...</div></div>;
|
||||
if (error || !workspace) return <div className="admin-content"><div className="alert alert-error">{error || "未找到"}</div></div>;
|
||||
|
||||
return (
|
||||
<div className="admin-content">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">{workspace.name}</h1>
|
||||
<p className="page-subtitle">
|
||||
<code>{workspace.slug}</code>
|
||||
<span className="badge badge-neutral" style={{ marginLeft: "8px" }}>{PLAN_LABELS[workspace.plan] || workspace.plan}</span>
|
||||
{workspace.billing_email && <span style={{ marginLeft: "8px", fontSize: "13px", color: "#737373" }}>{workspace.billing_email}</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{members.length}</div>
|
||||
<div className="stat-label">成员</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{projects.length}</div>
|
||||
<div className="stat-label">项目</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">
|
||||
{workspace.balance ? `${parseFloat(workspace.balance).toFixed(2)}` : "0.00"} {workspace.currency || "USD"}
|
||||
</div>
|
||||
<div className="stat-label">
|
||||
当前余额
|
||||
<button className="btn btn-secondary btn-sm" style={{ marginLeft: "8px" }} onClick={() => setShowCredit(true)}>+ 充值</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">
|
||||
{workspace.total_spent ? `${parseFloat(workspace.total_spent).toFixed(2)}` : "0.00"}
|
||||
</div>
|
||||
<div className="stat-label">累计消费</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{workspace.description && (
|
||||
<div className="card" style={{ marginBottom: "16px" }}>
|
||||
<p style={{ margin: 0, color: "#737373", fontSize: "14px" }}>{workspace.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="toolbar" style={{ marginBottom: "0" }}>
|
||||
<button className={`btn btn-sm ${tab === "members" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("members")}>成员 ({members.length})</button>
|
||||
<button className={`btn btn-sm ${tab === "projects" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("projects")}>项目 ({projects.length})</button>
|
||||
<button className={`btn btn-sm ${tab === "billing" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("billing")}>账单历史</button>
|
||||
<button className={`btn btn-sm ${tab === "alerts" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("alerts")}>告警配置</button>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{tab === "members" && (
|
||||
<>
|
||||
<div style={{ padding: "12px", borderBottom: "1px solid #e5e5e5", display: "flex", justifyContent: "flex-end" }}>
|
||||
<button className="btn btn-sm btn-primary" onClick={() => setShowAddMember(true)}>+ 添加成员</button>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>加入时间</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{members.map((m) => (
|
||||
<tr key={m.id ?? m.uid}>
|
||||
<td>{m.username}</td>
|
||||
<td>{m.display_name || "-"}</td>
|
||||
<td>
|
||||
{m.role === "owner" ? (
|
||||
<span className="badge badge-success">{ROLE_LABELS[m.role] || m.role}</span>
|
||||
) : (
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ padding: "2px 6px", fontSize: "12px", width: "auto" }}
|
||||
value={m.role}
|
||||
onChange={(e) => m.id && handleUpdateRole(m.id, e.target.value)}
|
||||
>
|
||||
<option value="admin">管理员</option>
|
||||
<option value="member">成员</option>
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{m.status && m.status !== "active" ? (
|
||||
<span className="badge badge-neutral">{m.status}</span>
|
||||
) : (
|
||||
<span className="badge badge-success">正常</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{format(new Date(m.joined_at), "yyyy-MM-dd")}</td>
|
||||
<td>
|
||||
{m.role !== "owner" && (
|
||||
<button className="btn btn-danger btn-sm" onClick={() => m.id && handleRemoveMember(m.id)}>移除</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{members.length === 0 && <tr><td colSpan={6} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无成员</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "projects" && (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>名称</th><th>可见性</th><th>成员</th><th>创建时间</th></tr></thead>
|
||||
<tbody>
|
||||
{projects.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td>{p.name}</td>
|
||||
<td><span className={`badge ${p.is_public ? "badge-success" : "badge-neutral"}`}>{p.is_public ? "公开" : "私有"}</span></td>
|
||||
<td>{p.member_count}</td>
|
||||
<td>{format(new Date(p.created_at), "yyyy-MM-dd")}</td>
|
||||
</tr>
|
||||
))}
|
||||
{projects.length === 0 && <tr><td colSpan={4} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无项目</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "billing" && (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>时间</th><th>类型</th><th>金额</th><th>描述</th></tr></thead>
|
||||
<tbody>
|
||||
{billingHistory.map((b, i) => (
|
||||
<tr key={i}>
|
||||
<td>{format(new Date(b.created_at), "yyyy-MM-dd HH:mm")}</td>
|
||||
<td><span className="badge badge-neutral">{b.reason}</span></td>
|
||||
<td style={{ color: parseFloat(b.amount) >= 0 ? "#16a34a" : "#dc2626" }}>
|
||||
{parseFloat(b.amount) >= 0 ? "+" : ""}{parseFloat(b.amount).toFixed(4)}
|
||||
</td>
|
||||
<td style={{ fontSize: "12px", color: "#737373" }}>{b.description || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{billingHistory.length === 0 && <tr><td colSpan={4} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无账单记录</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "alerts" && (
|
||||
<div>
|
||||
<p style={{ fontSize: "13px", color: "#737373", marginBottom: "16px" }}>
|
||||
配置 Workspace 的告警规则。当触发条件满足时,将发送邮件通知 Workspace 管理员。
|
||||
</p>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
||||
{Object.entries(ALERT_TYPE_LABELS).map(([type, meta]) => {
|
||||
const cfg = getAlert(type);
|
||||
const enabled = cfg?.enabled ?? false;
|
||||
const threshold = cfg?.threshold ?? "";
|
||||
const emailEnabled = cfg?.email_enabled ?? true;
|
||||
return (
|
||||
<div key={type} style={{ border: "1px solid #e5e5e5", borderRadius: "8px", padding: "16px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "12px" }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{meta.label}</div>
|
||||
<div style={{ fontSize: "12px", color: "#737373", marginTop: "2px" }}>
|
||||
当阈值低于 {meta.unit} 时触发
|
||||
</div>
|
||||
</div>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "8px", cursor: "pointer" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => updateAlert(type, "enabled", e.target.checked)}
|
||||
/>
|
||||
<span style={{ fontSize: "13px" }}>启用告警</span>
|
||||
</label>
|
||||
</div>
|
||||
{enabled && (
|
||||
<div style={{ display: "flex", gap: "12px", alignItems: "center" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
placeholder={meta.placeholder}
|
||||
value={threshold}
|
||||
min="0"
|
||||
step={meta.unit === "%" ? "1" : "0.01"}
|
||||
onChange={(e) => updateAlert(type, "threshold", e.target.value)}
|
||||
style={{ maxWidth: "200px" }}
|
||||
/>
|
||||
<span style={{ marginLeft: "8px", fontSize: "13px", color: "#737373" }}>{meta.unit}</span>
|
||||
</div>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "6px", cursor: "pointer", fontSize: "13px" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={emailEnabled}
|
||||
onChange={(e) => updateAlert(type, "email_enabled", e.target.checked)}
|
||||
/>
|
||||
邮件通知
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ marginTop: "16px" }}>
|
||||
<button className="btn btn-primary" disabled={alertSaving} onClick={handleSaveAlerts}>
|
||||
{alertSaving ? "保存中..." : "保存配置"}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ marginLeft: "8px" }}
|
||||
disabled={alertChecking}
|
||||
onClick={async () => {
|
||||
setAlertChecking(true);
|
||||
setAlertCheckMsg(null);
|
||||
try {
|
||||
const res = await fetch("/api/platform/alerts/check", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (res.ok && data.data) {
|
||||
const d = data.data;
|
||||
setAlertCheckMsg(
|
||||
`检查完成:扫描 ${d.workspaces_checked} 个 Workspace,发送 ${d.alerts_sent} 封告警邮件`
|
||||
);
|
||||
} else {
|
||||
setAlertCheckMsg(`检查失败: ${data.error || res.status}`);
|
||||
}
|
||||
} catch (e) {
|
||||
setAlertCheckMsg(`检查失败: ${e instanceof Error ? e.message : String(e)}`);
|
||||
} finally {
|
||||
setAlertChecking(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{alertChecking ? "检查中..." : "立即检查告警"}
|
||||
</button>
|
||||
</div>
|
||||
{alertCheckMsg && (
|
||||
<div className={`alert ${alertCheckMsg.includes("失败") ? "alert-error" : "alert-success"}`} style={{ marginTop: "12px" }}>
|
||||
{alertCheckMsg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCredit && (
|
||||
<div className="modal-overlay" onClick={() => setShowCredit(false)}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "420px" }}>
|
||||
<h2 className="modal-title">为 Workspace 充值</h2>
|
||||
{creditError && <div className="alert alert-error">{creditError}</div>}
|
||||
<div className="form-group">
|
||||
<label className="form-label">充值金额(USD)</label>
|
||||
<input type="number" className="form-input" min="0.01" step="0.01"
|
||||
value={creditAmount} onChange={(e) => setCreditAmount(e.target.value)}
|
||||
placeholder="例如:100.00" autoFocus />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">备注(可选)</label>
|
||||
<input type="text" className="form-input"
|
||||
value={creditDesc} onChange={(e) => setCreditDesc(e.target.value)}
|
||||
placeholder="充值原因或备注" />
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowCredit(false)}>取消</button>
|
||||
<button className="btn btn-primary" disabled={creditLoading} onClick={handleAddCredit}>
|
||||
{creditLoading ? "充值中..." : "确认充值"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddMember && (
|
||||
<div className="modal-overlay" onClick={() => setShowAddMember(false)}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "440px" }}>
|
||||
<h2 className="modal-title">添加成员</h2>
|
||||
{addMemberError && <div className="alert alert-error">{addMemberError}</div>}
|
||||
<div className="form-group">
|
||||
<label className="form-label">搜索用户</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={addUserId}
|
||||
onChange={(e) => handleUserSearch(e.target.value)}
|
||||
placeholder="输入用户名搜索..."
|
||||
autoFocus
|
||||
/>
|
||||
{searchUsers.length > 0 && (
|
||||
<div style={{ border: "1px solid #e5e5e5", borderRadius: "6px", marginTop: "4px", maxHeight: "200px", overflowY: "auto", background: "#fff" }}>
|
||||
{searchUsers.map((u) => (
|
||||
<div
|
||||
key={u.uid}
|
||||
style={{ padding: "8px 12px", cursor: "pointer", borderBottom: "1px solid #f0f0f0" }}
|
||||
onClick={() => {
|
||||
setAddUserId(u.uid);
|
||||
setSearchUsers([]);
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 500, fontSize: "14px" }}>{u.username}</div>
|
||||
<div style={{ fontSize: "12px", color: "#737373" }}>{u.display_name || ""}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{searchLoading && <div style={{ fontSize: "12px", color: "#737373", marginTop: "4px" }}>搜索中...</div>}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">角色</label>
|
||||
<select className="form-input" value={addRole} onChange={(e) => setAddRole(e.target.value)}>
|
||||
<option value="admin">管理员</option>
|
||||
<option value="member">成员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => { setShowAddMember(false); setSearchUsers([]); }}>取消</button>
|
||||
<button className="btn btn-primary" disabled={addMemberLoading || !addUserId} onClick={handleAddMember}>
|
||||
{addMemberLoading ? "添加中..." : "添加"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
191
admin/src/app/admin/workspaces/page.tsx
Normal file
191
admin/src/app/admin/workspaces/page.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface Workspace {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
plan: string;
|
||||
description: string | null;
|
||||
billing_email: string | null;
|
||||
balance: string | null;
|
||||
currency: string | null;
|
||||
member_count: number;
|
||||
project_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const PLAN_LABELS: Record<string, string> = {
|
||||
free: "免费", starter: "入门", pro: "专业", enterprise: "企业",
|
||||
};
|
||||
|
||||
const ALL_PLANS = ["free", "starter", "pro", "enterprise"];
|
||||
|
||||
export default function WorkspacesPage() {
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [plan, setPlan] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [batchPlan, setBatchPlan] = useState("");
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const pageSize = 20;
|
||||
|
||||
useEffect(() => { loadWorkspaces(); }, [page, search, plan]);
|
||||
|
||||
function loadWorkspaces() {
|
||||
setLoading(true);
|
||||
setSelected(new Set());
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||
if (search) params.set("search", search);
|
||||
if (plan) params.set("plan", plan);
|
||||
fetch(`/api/platform/workspaces?${params}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setWorkspaces(data.workspaces || []);
|
||||
setTotal(data.total || 0);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
if (selected.size === workspaces.length) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(workspaces.map((w) => w.id)));
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(id: string) {
|
||||
const next = new Set(selected);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelected(next);
|
||||
}
|
||||
|
||||
async function handleBatchPlanChange() {
|
||||
if (!batchPlan || selected.size === 0) return;
|
||||
if (!confirm(`将 ${selected.size} 个 Workspace 调整为「${PLAN_LABELS[batchPlan] || batchPlan}」?`)) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/platform/workspaces", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: Array.from(selected), plan: batchPlan }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setSelected(new Set());
|
||||
setBatchPlan("");
|
||||
loadWorkspaces();
|
||||
} else {
|
||||
alert(data.error || "批量调整失败");
|
||||
}
|
||||
} catch (e) {
|
||||
alert("批量调整失败");
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
const allSelected = workspaces.length > 0 && selected.size === workspaces.length;
|
||||
|
||||
return (
|
||||
<div className="admin-content">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Workspace 管理</h1>
|
||||
<p className="page-subtitle">共 {total} 个 Workspace</p>
|
||||
</div>
|
||||
<div className="toolbar">
|
||||
<input
|
||||
type="text" className="form-input search-input"
|
||||
placeholder="搜索名称或 slug..."
|
||||
value={search} onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
/>
|
||||
<select className="form-input" style={{ maxWidth: "160px" }}
|
||||
value={plan} onChange={(e) => { setPlan(e.target.value); setPage(1); }}>
|
||||
<option value="">全部计划</option>
|
||||
{Object.entries(PLAN_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
{selected.size > 0 && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginLeft: "8px" }}>
|
||||
<span style={{ fontSize: "13px", color: "#737373" }}>已选 {selected.size} 个</span>
|
||||
<select className="form-input" style={{ maxWidth: "120px" }}
|
||||
value={batchPlan} onChange={(e) => setBatchPlan(e.target.value)}>
|
||||
<option value="">选择计划</option>
|
||||
{ALL_PLANS.map((p) => (
|
||||
<option key={p} value={p}>{PLAN_LABELS[p]}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn btn-primary btn-sm" disabled={!batchPlan || batchLoading}
|
||||
onClick={handleBatchPlanChange}>
|
||||
{batchLoading ? "调整中..." : "批量调整计划"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card">
|
||||
{loading ? <div className="loading">加载中...</div> : (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: "36px" }}>
|
||||
<input type="checkbox" checked={allSelected} onChange={toggleAll} />
|
||||
</th>
|
||||
<th>名称</th><th>Slug</th><th>计划</th>
|
||||
<th>余额</th><th>成员</th><th>项目</th><th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{workspaces.map((w) => (
|
||||
<tr key={w.id}>
|
||||
<td>
|
||||
<input type="checkbox" checked={selected.has(w.id)} onChange={() => toggle(w.id)} />
|
||||
</td>
|
||||
<td>{w.name}</td>
|
||||
<td><code style={{ fontSize: "12px" }}>{w.slug}</code></td>
|
||||
<td><span className="badge badge-neutral">{PLAN_LABELS[w.plan] || w.plan}</span></td>
|
||||
<td>
|
||||
{w.balance
|
||||
? `${parseFloat(w.balance).toFixed(2)} ${w.currency || "USD"}`
|
||||
: "-"}
|
||||
</td>
|
||||
<td>{w.member_count}</td>
|
||||
<td>{w.project_count}</td>
|
||||
<td>{format(new Date(w.created_at), "yyyy-MM-dd")}</td>
|
||||
<td>
|
||||
<Link href={`/admin/workspaces/${w.id}`}>
|
||||
<button className="btn btn-secondary btn-sm">详情</button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{workspaces.length === 0 && (
|
||||
<tr><td colSpan={9} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无数据</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="pagination">
|
||||
<span className="pagination-info">第 {page} / {totalPages} 页,共 {total} 条</span>
|
||||
<button className="btn btn-secondary btn-sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||||
<button className="btn btn-secondary btn-sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
admin/src/app/api/admin/ai/models/route.ts
Normal file
40
admin/src/app/api/admin/ai/models/route.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { RUST_BACKEND_URL, ADMIN_API_SHARED_KEY } from "@/lib/env";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
async function adminFetch(path: string, method: string, body?: unknown) {
|
||||
if (!ADMIN_API_SHARED_KEY) {
|
||||
return NextResponse.json({ error: "ADMIN_API_SHARED_KEY 未配置" }, { status: 500 });
|
||||
}
|
||||
const res = await fetch(`${RUST_BACKEND_URL}${path}`, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", "x-admin-api-key": ADMIN_API_SHARED_KEY },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) return NextResponse.json(data, { status: res.status });
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
// POST /api/admin/ai/models — create model
|
||||
export async function POST(req: NextRequest) {
|
||||
return adminFetch("/api/admin/ai/models", "POST", await req.json());
|
||||
}
|
||||
|
||||
// PATCH /api/admin/ai/models?id={id} — update model
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const id = searchParams.get("id");
|
||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||
return adminFetch(`/api/admin/ai/models/${id}`, "PATCH", await req.json());
|
||||
}
|
||||
|
||||
// DELETE /api/admin/ai/models?id={id} — delete model
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const id = searchParams.get("id");
|
||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||
return adminFetch(`/api/admin/ai/models/${id}`, "DELETE");
|
||||
}
|
||||
25
admin/src/app/api/admin/ai/pricing/[id]/route.ts
Normal file
25
admin/src/app/api/admin/ai/pricing/[id]/route.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { RUST_BACKEND_URL, ADMIN_API_SHARED_KEY } from "@/lib/env";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// PATCH /api/admin/ai/pricing/{id} — update pricing
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
if (!ADMIN_API_SHARED_KEY) {
|
||||
return NextResponse.json({ error: "ADMIN_API_SHARED_KEY 未配置" }, { status: 500 });
|
||||
}
|
||||
const body = await req.json();
|
||||
const res = await fetch(`${RUST_BACKEND_URL}/api/admin/ai/pricing/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", "x-admin-api-key": ADMIN_API_SHARED_KEY },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) return NextResponse.json(data, { status: res.status });
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
40
admin/src/app/api/admin/ai/providers/route.ts
Normal file
40
admin/src/app/api/admin/ai/providers/route.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { RUST_BACKEND_URL, ADMIN_API_SHARED_KEY } from "@/lib/env";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
async function adminFetch(path: string, method: string, body?: unknown) {
|
||||
if (!ADMIN_API_SHARED_KEY) {
|
||||
return NextResponse.json({ error: "ADMIN_API_SHARED_KEY 未配置" }, { status: 500 });
|
||||
}
|
||||
const res = await fetch(`${RUST_BACKEND_URL}${path}`, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", "x-admin-api-key": ADMIN_API_SHARED_KEY },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) return NextResponse.json(data, { status: res.status });
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
// POST /api/admin/ai/providers — create provider
|
||||
export async function POST(req: NextRequest) {
|
||||
return adminFetch("/api/admin/ai/providers", "POST", await req.json());
|
||||
}
|
||||
|
||||
// PATCH /api/admin/ai/providers?id={id} — update provider
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const id = searchParams.get("id");
|
||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||
return adminFetch(`/api/admin/ai/providers/${id}`, "PATCH", await req.json());
|
||||
}
|
||||
|
||||
// DELETE /api/admin/ai/providers?id={id} — delete provider
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const id = searchParams.get("id");
|
||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||
return adminFetch(`/api/admin/ai/providers/${id}`, "DELETE");
|
||||
}
|
||||
40
admin/src/app/api/admin/ai/versions/route.ts
Normal file
40
admin/src/app/api/admin/ai/versions/route.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { RUST_BACKEND_URL, ADMIN_API_SHARED_KEY } from "@/lib/env";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
async function adminFetch(path: string, method: string, body?: unknown) {
|
||||
if (!ADMIN_API_SHARED_KEY) {
|
||||
return NextResponse.json({ error: "ADMIN_API_SHARED_KEY 未配置" }, { status: 500 });
|
||||
}
|
||||
const res = await fetch(`${RUST_BACKEND_URL}${path}`, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", "x-admin-api-key": ADMIN_API_SHARED_KEY },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) return NextResponse.json(data, { status: res.status });
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
// POST /api/admin/ai/versions — create version
|
||||
export async function POST(req: NextRequest) {
|
||||
return adminFetch("/api/admin/ai/versions", "POST", await req.json());
|
||||
}
|
||||
|
||||
// PATCH /api/admin/ai/versions?id={id} — update version
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const id = searchParams.get("id");
|
||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||
return adminFetch(`/api/admin/ai/versions/${id}`, "PATCH", await req.json());
|
||||
}
|
||||
|
||||
// DELETE /api/admin/ai/versions?id={id} — delete version
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const id = searchParams.get("id");
|
||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||
return adminFetch(`/api/admin/ai/versions/${id}`, "DELETE");
|
||||
}
|
||||
88
admin/src/app/api/admin/projects/[id]/billing/route.ts
Normal file
88
admin/src/app/api/admin/projects/[id]/billing/route.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query, transaction } from "@/lib/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// GET /api/admin/projects/[id]/billing — get project billing info
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const billingRes = await query(
|
||||
`SELECT pb.project_uuid, pb.balance, pb.currency, pb.updated_at, pb.created_at
|
||||
FROM project_billing pb WHERE pb.project_uuid = $1`,
|
||||
[id]
|
||||
);
|
||||
const historyRes = await query(
|
||||
`SELECT uid, project, user, amount, currency, reason, extra, created_at
|
||||
FROM project_billing_history
|
||||
WHERE project = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100`,
|
||||
[id]
|
||||
);
|
||||
const billing = billingRes.rows[0];
|
||||
return NextResponse.json({
|
||||
billing: billing ? {
|
||||
balance: billing.balance,
|
||||
currency: billing.currency,
|
||||
updated_at: billing.updated_at,
|
||||
created_at: billing.created_at,
|
||||
} : null,
|
||||
history: historyRes.rows.map((r: Record<string, unknown>) => ({
|
||||
uid: String(r.uid),
|
||||
amount: String(r.amount),
|
||||
currency: r.currency,
|
||||
reason: r.reason,
|
||||
description: r.extra ? (r.extra as Record<string, unknown>).description : null,
|
||||
created_at: r.created_at,
|
||||
})),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Project billing error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/admin/projects/[id]/billing — add credit
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
const amount = parseFloat(body.amount);
|
||||
const description = String(body.description || "Admin 充值");
|
||||
|
||||
if (!amount || amount <= 0) {
|
||||
return NextResponse.json({ error: "金额必须大于 0" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await transaction(async (tx) => {
|
||||
// Upsert project_billing
|
||||
await tx.query(
|
||||
`INSERT INTO project_billing (project_uuid, balance, currency, updated_at, created_at)
|
||||
VALUES ($1, $2, 'USD', NOW(), NOW())
|
||||
ON CONFLICT (project_uuid) DO UPDATE SET
|
||||
balance = project_billing.balance + $2,
|
||||
updated_at = NOW()`,
|
||||
[id, amount]
|
||||
);
|
||||
|
||||
// Insert history
|
||||
await tx.query(
|
||||
`INSERT INTO project_billing_history (uid, project, user, amount, currency, reason, extra, created_at)
|
||||
VALUES (gen_random_uuid(), $1, NULL, $2, 'USD', 'admin_credit', ('{"description":"' || $3 || '"}')::jsonb, NOW())`,
|
||||
[id, amount, description]
|
||||
);
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, amount });
|
||||
} catch (e) {
|
||||
console.error("Project add credit error:", e);
|
||||
return NextResponse.json({ error: "充值失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,96 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db";
|
||||
import { createAuditLog } from "@/lib/log";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// PATCH /api/admin/projects/[id]/members/[memberId]
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; memberId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id, memberId } = await params;
|
||||
const body = await req.json() as { scope?: string };
|
||||
|
||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
||||
|
||||
if (body.scope && !["owner", "admin", "member"].includes(body.scope)) {
|
||||
return NextResponse.json({ error: "无效的角色" }, { status: 400 });
|
||||
}
|
||||
|
||||
const member = await query<{ id: string; scope: string; user: string }>(
|
||||
`SELECT id, scope, user FROM project_members WHERE id = $1 AND project = $2`,
|
||||
[memberId, id]
|
||||
);
|
||||
if (!member.rows.length) {
|
||||
return NextResponse.json({ error: "成员不存在" }, { status: 404 });
|
||||
}
|
||||
if (member.rows[0].scope === "owner") {
|
||||
return NextResponse.json({ error: "无法修改 Owner 的角色" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (body.scope) {
|
||||
await query(`UPDATE project_members SET scope = $1 WHERE id = $2`, [body.scope, memberId]);
|
||||
}
|
||||
|
||||
await createAuditLog({
|
||||
userId: adminUserId,
|
||||
username: adminUsername,
|
||||
action: "update",
|
||||
resource: "project_member",
|
||||
resourceId: memberId,
|
||||
requestParams: { projectId: id, scope: body.scope },
|
||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||
userAgent: req.headers.get("user-agent") || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (e) {
|
||||
console.error("Update project member error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/admin/projects/[id]/members/[memberId]
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; memberId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id, memberId } = await params;
|
||||
|
||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
||||
|
||||
const member = await query<{ id: string; scope: string; user: string }>(
|
||||
`SELECT id, scope, user FROM project_members WHERE id = $1 AND project = $2`,
|
||||
[memberId, id]
|
||||
);
|
||||
if (!member.rows.length) {
|
||||
return NextResponse.json({ error: "成员不存在" }, { status: 404 });
|
||||
}
|
||||
if (member.rows[0].scope === "owner") {
|
||||
return NextResponse.json({ error: "无法删除 Owner" }, { status: 403 });
|
||||
}
|
||||
|
||||
await query(`DELETE FROM project_members WHERE id = $1`, [memberId]);
|
||||
|
||||
await createAuditLog({
|
||||
userId: adminUserId,
|
||||
username: adminUsername,
|
||||
action: "delete",
|
||||
resource: "project_member",
|
||||
resourceId: memberId,
|
||||
requestParams: { projectId: id, userId: member.rows[0].user },
|
||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||
userAgent: req.headers.get("user-agent") || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (e) {
|
||||
console.error("Delete project member error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
114
admin/src/app/api/admin/projects/[id]/members/route.ts
Normal file
114
admin/src/app/api/admin/projects/[id]/members/route.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db";
|
||||
import { createAuditLog } from "@/lib/log";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// GET /api/admin/projects/[id]/members
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const result = await query(
|
||||
`SELECT pm.id, pm.project, pm.user, pm.scope, pm.joined_at::text,
|
||||
u.username, u.display_name, u.avatar_url,
|
||||
COALESCE(up.is_active, true) as user_is_active
|
||||
FROM project_members pm
|
||||
JOIN "user" u ON u.uid = pm.user
|
||||
LEFT JOIN user_password up ON up.user = u.uid
|
||||
WHERE pm.project = $1
|
||||
ORDER BY pm.scope = 'owner' DESC, pm.scope = 'admin' DESC, pm.joined_at ASC`,
|
||||
[id]
|
||||
);
|
||||
|
||||
const members = result.rows.map((r: Record<string, unknown>) => ({
|
||||
id: r.id,
|
||||
projectId: r.project,
|
||||
userId: r.user,
|
||||
scope: r.scope,
|
||||
joinedAt: r.joined_at,
|
||||
username: r.username,
|
||||
displayName: r.display_name,
|
||||
avatarUrl: r.avatar_url,
|
||||
userIsActive: r.user_is_active,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ members });
|
||||
} catch (e) {
|
||||
console.error("List project members error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/admin/projects/[id]/members
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await req.json() as {
|
||||
userId?: string;
|
||||
scope?: string;
|
||||
};
|
||||
|
||||
if (!body.userId) {
|
||||
return NextResponse.json({ error: "缺少 userId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
||||
|
||||
const scope = body.scope || "member";
|
||||
if (!["owner", "admin", "member"].includes(scope)) {
|
||||
return NextResponse.json({ error: "无效的角色" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 检查 project 是否存在
|
||||
const projRow = await query<{ id: string }>(`SELECT id FROM project WHERE id = $1`, [id]);
|
||||
if (!projRow.rows.length) {
|
||||
return NextResponse.json({ error: "项目不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
// 检查用户是否存在
|
||||
const userRow = await query<{ uid: string }>(`SELECT uid FROM "user" WHERE uid = $1`, [body.userId]);
|
||||
if (!userRow.rows.length) {
|
||||
return NextResponse.json({ error: "用户不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
// 检查是否已是成员
|
||||
const exist = await query(
|
||||
`SELECT id FROM project_members WHERE project = $1 AND user = $2`,
|
||||
[id, body.userId]
|
||||
);
|
||||
if (exist.rows.length) {
|
||||
return NextResponse.json({ error: "该用户已是成员" }, { status: 409 });
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO project_members (project, user, scope, joined_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
RETURNING id`,
|
||||
[id, body.userId, scope]
|
||||
);
|
||||
|
||||
await createAuditLog({
|
||||
userId: adminUserId,
|
||||
username: adminUsername,
|
||||
action: "create",
|
||||
resource: "project_member",
|
||||
resourceId: String(result.rows[0]?.id),
|
||||
requestParams: { projectId: id, userId: body.userId, scope },
|
||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||
userAgent: req.headers.get("user-agent") || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, id: result.rows[0]?.id }, { status: 201 });
|
||||
} catch (e) {
|
||||
console.error("Add project member error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
62
admin/src/app/api/admin/projects/[id]/route.ts
Normal file
62
admin/src/app/api/admin/projects/[id]/route.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const [projectRows, membersRows, billingRows, billingHistoryRows] = await Promise.all([
|
||||
query(
|
||||
`SELECT p.*, w.name as workspace_name, w.slug as workspace_slug, w.plan as workspace_plan,
|
||||
COALESCE(pb.balance, 0)::text as balance, COALESCE(pb.currency, 'USD') as currency
|
||||
FROM project p
|
||||
LEFT JOIN workspace w ON w.id = p.workspace_id
|
||||
LEFT JOIN project_billing pb ON pb.project_uuid = p.id
|
||||
WHERE p.id = $1`,
|
||||
[id]
|
||||
),
|
||||
query(
|
||||
`SELECT pm.id, u.uid, u.username, u.display_name, u.avatar_url,
|
||||
pm.scope as role, pm.joined_at
|
||||
FROM project_members pm
|
||||
JOIN "user" u ON u.uid = pm.user_uuid
|
||||
WHERE pm.project_uuid = $1
|
||||
ORDER BY pm.joined_at DESC`,
|
||||
[id]
|
||||
),
|
||||
query(
|
||||
`SELECT pb.balance::text as balance, pb.currency, pb.updated_at
|
||||
FROM project_billing pb
|
||||
WHERE pb.project_uuid = $1`,
|
||||
[id]
|
||||
),
|
||||
query(
|
||||
`SELECT reason, amount, extra as description, created_at
|
||||
FROM project_billing_history
|
||||
WHERE project = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50`,
|
||||
[id]
|
||||
),
|
||||
]);
|
||||
|
||||
if (!projectRows.rows.length) {
|
||||
return NextResponse.json({ error: "项目不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
project: projectRows.rows[0],
|
||||
members: membersRows.rows,
|
||||
billing: billingRows.rows[0] || null,
|
||||
billingHistory: billingHistoryRows.rows,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Project detail error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
70
admin/src/app/api/admin/projects/route.ts
Normal file
70
admin/src/app/api/admin/projects/route.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = req.nextUrl;
|
||||
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
|
||||
const pageSize = Math.max(1, parseInt(searchParams.get("pageSize") || "20", 10));
|
||||
const search = searchParams.get("search") || "";
|
||||
const workspaceId = searchParams.get("workspaceId") || "";
|
||||
const visibility = searchParams.get("visibility") || "";
|
||||
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const params: (string | number)[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
let where = "WHERE 1=1";
|
||||
if (search) {
|
||||
where += ` AND (p.name ILIKE $${paramIdx} OR p.display_name ILIKE $${paramIdx})`;
|
||||
params.push(`%${search}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
if (workspaceId) {
|
||||
where += ` AND p.workspace_id = $${paramIdx}`;
|
||||
params.push(workspaceId);
|
||||
paramIdx++;
|
||||
}
|
||||
if (visibility) {
|
||||
where += ` AND p.is_public = $${paramIdx}`;
|
||||
params.push(visibility === "public" ? 1 : 0);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const limitParam = paramIdx;
|
||||
const offsetParam = paramIdx + 1;
|
||||
|
||||
const countResult = await query(
|
||||
`SELECT COUNT(*) as total FROM project p ${where}`,
|
||||
params
|
||||
);
|
||||
const total = parseInt(String(countResult.rows[0].total), 10);
|
||||
|
||||
params.push(pageSize, offset);
|
||||
|
||||
const projects = await query(
|
||||
`SELECT p.id, p.name, p.display_name, p.description, p.is_public, p.workspace_id,
|
||||
p.created_by, p.created_at, p.updated_at,
|
||||
w.name as workspace_name, w.slug as workspace_slug,
|
||||
COALESCE(b.balance, 0)::text as balance, COALESCE(b.currency, 'USD') as currency,
|
||||
COUNT(DISTINCT pm.user_uuid) as member_count
|
||||
FROM project p
|
||||
LEFT JOIN workspace w ON w.id = p.workspace_id
|
||||
LEFT JOIN project_billing b ON b.project_uuid = p.id
|
||||
LEFT JOIN project_members pm ON pm.project_uuid = p.id
|
||||
${where}
|
||||
GROUP BY p.id, w.name, w.slug, b.balance, b.currency
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT $${limitParam} OFFSET $${offsetParam}`,
|
||||
params
|
||||
);
|
||||
|
||||
return NextResponse.json({ projects: projects.rows, total, page, pageSize });
|
||||
} catch (e) {
|
||||
console.error("List projects error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
114
admin/src/app/api/admin/repos/[id]/route.ts
Normal file
114
admin/src/app/api/admin/repos/[id]/route.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const [repoResult, branchesResult, commitsResult] = await Promise.all([
|
||||
query<{
|
||||
id: string;
|
||||
repo_name: string;
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
workspace_name: string;
|
||||
default_branch: string;
|
||||
is_private: boolean;
|
||||
description: string | null;
|
||||
created_by: string;
|
||||
created_at: Date;
|
||||
ai_code_review_enabled: boolean;
|
||||
collaborator_count: string;
|
||||
}>(
|
||||
`SELECT r.id, r.repo_name, r.project as project_id, p.name as project_name,
|
||||
COALESCE(w.name, '') as workspace_name, r.default_branch, r.is_private,
|
||||
r.description, r.created_by::text as created_by,
|
||||
r.created_at::text as created_at, r.ai_code_review_enabled,
|
||||
(SELECT COUNT(*) FROM repo_collaborator WHERE repo = r.id)::text as collaborator_count
|
||||
FROM repo r
|
||||
JOIN project p ON p.id = r.project
|
||||
LEFT JOIN workspace w ON w.id = p.workspace_id
|
||||
WHERE r.id = $1`,
|
||||
[id]
|
||||
),
|
||||
query<{
|
||||
name: string;
|
||||
oid: string;
|
||||
head: boolean;
|
||||
upstream: string | null;
|
||||
created_at: Date;
|
||||
}>(
|
||||
`SELECT name, oid, head, upstream, created_at::text as created_at
|
||||
FROM repo_branch
|
||||
WHERE repo = $1
|
||||
ORDER BY head DESC, name ASC`,
|
||||
[id]
|
||||
),
|
||||
query<{
|
||||
id: number;
|
||||
oid: string;
|
||||
author_name: string;
|
||||
author_email: string;
|
||||
message: string;
|
||||
created_at: Date;
|
||||
}>(
|
||||
`SELECT id, oid, author_name, author_email, message,
|
||||
created_at::text as created_at
|
||||
FROM repo_commit
|
||||
WHERE repo = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50`,
|
||||
[id]
|
||||
),
|
||||
]);
|
||||
|
||||
if (!repoResult.rows.length) {
|
||||
return NextResponse.json({ error: "仓库不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
const r = repoResult.rows[0];
|
||||
const repo = {
|
||||
id: r.id,
|
||||
repoName: r.repo_name,
|
||||
projectId: r.project_id,
|
||||
projectName: r.project_name,
|
||||
workspaceName: r.workspace_name,
|
||||
defaultBranch: r.default_branch,
|
||||
isPrivate: r.is_private,
|
||||
description: r.description,
|
||||
createdBy: r.created_by,
|
||||
createdAt: String(r.created_at),
|
||||
aiCodeReviewEnabled: r.ai_code_review_enabled,
|
||||
collaboratorCount: parseInt(r.collaborator_count || "0", 10),
|
||||
};
|
||||
|
||||
const branches = branchesResult.rows.map((b) => ({
|
||||
name: b.name,
|
||||
oid: b.oid,
|
||||
isHead: b.head,
|
||||
upstream: b.upstream,
|
||||
createdAt: String(b.created_at),
|
||||
}));
|
||||
|
||||
const commits = commitsResult.rows.map((c) => ({
|
||||
id: c.id,
|
||||
oid: c.oid,
|
||||
shortOid: c.oid.slice(0, 7),
|
||||
authorName: c.author_name,
|
||||
authorEmail: c.author_email,
|
||||
message: c.message,
|
||||
shortMessage: c.message.split("\n")[0].slice(0, 80),
|
||||
createdAt: String(c.created_at),
|
||||
}));
|
||||
|
||||
return NextResponse.json({ repo, branches, commits });
|
||||
} catch (e) {
|
||||
console.error("Repo detail error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
35
admin/src/app/api/api-tokens/[id]/route.ts
Normal file
35
admin/src/app/api/api-tokens/[id]/route.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { deleteApiToken } from "@/lib/api-token";
|
||||
import { createAuditLog } from "@/lib/log";
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const tokenId = parseInt(id, 10);
|
||||
if (isNaN(tokenId)) {
|
||||
return NextResponse.json({ error: "无效的 Token ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
await deleteApiToken(tokenId);
|
||||
|
||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
||||
await createAuditLog({
|
||||
userId: adminUserId,
|
||||
username: adminUsername,
|
||||
action: "delete",
|
||||
resource: "admin_api_token",
|
||||
resourceId: id,
|
||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||
userAgent: req.headers.get("user-agent") || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (e) {
|
||||
console.error("Delete token error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
64
admin/src/app/api/api-tokens/route.ts
Normal file
64
admin/src/app/api/api-tokens/route.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
listApiTokens,
|
||||
createApiToken,
|
||||
generateToken,
|
||||
} from "@/lib/api-token";
|
||||
import { createAuditLog } from "@/lib/log";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const tokens = await listApiTokens();
|
||||
return NextResponse.json({ tokens });
|
||||
} catch (e) {
|
||||
console.error("List tokens error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as {
|
||||
name?: string;
|
||||
permissions?: string[];
|
||||
expiresInDays?: number;
|
||||
};
|
||||
const { name = "", permissions = [], expiresInDays } = body;
|
||||
|
||||
if (!name.trim()) {
|
||||
return NextResponse.json({ error: "Token 名称不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
const expiresAt = expiresInDays
|
||||
? new Date(Date.now() + expiresInDays * 86400 * 1000)
|
||||
: null;
|
||||
|
||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
||||
|
||||
const result = await createApiToken({
|
||||
name: name.trim(),
|
||||
token,
|
||||
permissions,
|
||||
createdBy: adminUserId,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
await createAuditLog({
|
||||
userId: adminUserId,
|
||||
username: adminUsername,
|
||||
action: "create",
|
||||
resource: "admin_api_token",
|
||||
resourceId: String(result.id),
|
||||
requestParams: { name: name.trim(), permissions, expiresInDays },
|
||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||
userAgent: req.headers.get("user-agent") || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({ id: result.id, token, name: name.trim(), expiresAt }, { status: 201 });
|
||||
} catch (e) {
|
||||
console.error("Create token error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
76
admin/src/app/api/auth/login/route.ts
Normal file
76
admin/src/app/api/auth/login/route.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { login, buildSetCookieHeader } from "@/lib/auth";
|
||||
import { createAuditLog } from "@/lib/log";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as {
|
||||
username?: string;
|
||||
password?: string;
|
||||
};
|
||||
const { username = "", password = "" } = body;
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "用户名和密码不能为空" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await login(username, password);
|
||||
if (!result) {
|
||||
// 记录登录失败
|
||||
const ip = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip") || "unknown";
|
||||
const ua = req.headers.get("user-agent") || "unknown";
|
||||
await createAuditLog({
|
||||
userId: 0,
|
||||
username,
|
||||
action: "login",
|
||||
resource: "auth",
|
||||
result: "failure",
|
||||
errorMessage: "Invalid credentials",
|
||||
ipAddress: ip,
|
||||
userAgent: ua,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "用户名或密码错误" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 记录登录成功
|
||||
const ip = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip") || "unknown";
|
||||
const ua = req.headers.get("user-agent") || "unknown";
|
||||
await createAuditLog({
|
||||
userId: result.adminSession.userId,
|
||||
username: result.adminSession.username,
|
||||
action: "login",
|
||||
resource: "auth",
|
||||
result: "success",
|
||||
ipAddress: ip,
|
||||
userAgent: ua,
|
||||
});
|
||||
|
||||
const response = NextResponse.json({
|
||||
user: {
|
||||
id: result.adminSession.userId,
|
||||
username: result.adminSession.username,
|
||||
roles: result.adminSession.roles,
|
||||
permissions: result.adminSession.permissions,
|
||||
},
|
||||
});
|
||||
|
||||
response.headers.set(
|
||||
"Set-Cookie",
|
||||
buildSetCookieHeader(result.sessionId)
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error("Login error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
39
admin/src/app/api/auth/logout/route.ts
Normal file
39
admin/src/app/api/auth/logout/route.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { logout, parseSessionCookie, loadAdminSession, buildClearCookieHeader } from "@/lib/auth";
|
||||
import { createAuditLog } from "@/lib/log";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
const sessionId = parseSessionCookie(cookieHeader);
|
||||
|
||||
if (sessionId) {
|
||||
const session = await loadAdminSession(sessionId);
|
||||
if (session) {
|
||||
const ip = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip") || "unknown";
|
||||
const ua = req.headers.get("user-agent") || "unknown";
|
||||
await createAuditLog({
|
||||
userId: session.userId,
|
||||
username: session.username,
|
||||
action: "logout",
|
||||
resource: "auth",
|
||||
result: "success",
|
||||
ipAddress: ip,
|
||||
userAgent: ua,
|
||||
});
|
||||
}
|
||||
await logout(sessionId);
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ success: true });
|
||||
response.headers.set("Set-Cookie", buildClearCookieHeader());
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error("Logout error:", e);
|
||||
const response = NextResponse.json({ success: false });
|
||||
response.headers.set("Set-Cookie", buildClearCookieHeader());
|
||||
return response;
|
||||
}
|
||||
}
|
||||
35
admin/src/app/api/auth/me/route.ts
Normal file
35
admin/src/app/api/auth/me/route.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { parseSessionCookie, loadAdminSession, touchSession } from "@/lib/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
const sessionId = parseSessionCookie(cookieHeader);
|
||||
|
||||
if (!sessionId) {
|
||||
return NextResponse.json({ user: null });
|
||||
}
|
||||
|
||||
const session = await loadAdminSession(sessionId);
|
||||
if (!session) {
|
||||
return NextResponse.json({ user: null });
|
||||
}
|
||||
|
||||
// 刷新活跃时间
|
||||
await touchSession(sessionId);
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: session.userId,
|
||||
username: session.username,
|
||||
roles: session.roles,
|
||||
permissions: session.permissions,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Session check error:", e);
|
||||
return NextResponse.json({ user: null });
|
||||
}
|
||||
}
|
||||
13
admin/src/app/api/auth/oidc/authorize/route.ts
Normal file
13
admin/src/app/api/auth/oidc/authorize/route.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildOidcAuthUrl, isOidcEnabled } from "@/lib/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!isOidcEnabled()) {
|
||||
return NextResponse.json({ error: "OIDC 未启用" }, { status: 400 });
|
||||
}
|
||||
|
||||
const authUrl = buildOidcAuthUrl();
|
||||
return NextResponse.redirect(authUrl);
|
||||
}
|
||||
44
admin/src/app/api/auth/oidc/callback/route.ts
Normal file
44
admin/src/app/api/auth/oidc/callback/route.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { exchangeOidcCode, buildSetCookieHeader, isOidcEnabled } from "@/lib/auth";
|
||||
import { createAuditLog } from "@/lib/log";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!isOidcEnabled()) {
|
||||
return NextResponse.redirect(new URL("/login?error=oidc_disabled", req.url));
|
||||
}
|
||||
|
||||
const code = req.nextUrl.searchParams.get("code");
|
||||
const error = req.nextUrl.searchParams.get("error");
|
||||
|
||||
if (error || !code) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/login?error=${encodeURIComponent(error || "auth_failed")}`, req.url)
|
||||
);
|
||||
}
|
||||
|
||||
const result = await exchangeOidcCode(code);
|
||||
if (!result) {
|
||||
return NextResponse.redirect(new URL("/login?error=auth_failed", req.url));
|
||||
}
|
||||
|
||||
const ip = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip") || "unknown";
|
||||
const ua = req.headers.get("user-agent") || "unknown";
|
||||
await createAuditLog({
|
||||
userId: result.adminSession.userId,
|
||||
username: result.adminSession.username,
|
||||
action: "login",
|
||||
resource: "auth/oidc",
|
||||
result: "success",
|
||||
ipAddress: ip,
|
||||
userAgent: ua,
|
||||
});
|
||||
|
||||
const response = NextResponse.redirect(new URL("/", req.url));
|
||||
response.headers.set(
|
||||
"Set-Cookie",
|
||||
buildSetCookieHeader(result.sessionId)
|
||||
);
|
||||
return response;
|
||||
}
|
||||
93
admin/src/app/api/logs/route.ts
Normal file
93
admin/src/app/api/logs/route.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { listAuditLogs } from "@/lib/log";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = req.nextUrl;
|
||||
const format = searchParams.get("format") || "json";
|
||||
|
||||
// CSV export mode
|
||||
if (format === "csv") {
|
||||
const action = searchParams.get("action") || undefined;
|
||||
const resource = searchParams.get("resource") || undefined;
|
||||
const startDate = searchParams.get("startDate")
|
||||
? new Date(searchParams.get("startDate")!)
|
||||
: undefined;
|
||||
const endDate = searchParams.get("endDate")
|
||||
? new Date(searchParams.get("endDate")!)
|
||||
: undefined;
|
||||
|
||||
// Fetch all matching rows (up to 10,000) for CSV export
|
||||
const result = await listAuditLogs({
|
||||
page: 1,
|
||||
pageSize: 10000,
|
||||
action: action || undefined,
|
||||
resource: resource || undefined,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
|
||||
const logs = result.logs as unknown as Array<{
|
||||
id: number; user_id: number; username: string; action: string;
|
||||
resource: string; resource_id: string | null; result: string;
|
||||
ip_address: string | null; created_at: Date;
|
||||
}>;
|
||||
|
||||
const header = ["ID", "时间", "用户", "用户ID", "操作", "资源", "资源ID", "结果", "IP地址"];
|
||||
const rows = logs.map((log) => [
|
||||
log.id,
|
||||
new Date(log.created_at).toISOString(),
|
||||
log.username,
|
||||
log.user_id,
|
||||
log.action,
|
||||
log.resource,
|
||||
log.resource_id || "",
|
||||
log.result,
|
||||
log.ip_address || "",
|
||||
]);
|
||||
|
||||
const csvContent = [header, ...rows]
|
||||
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","))
|
||||
.join("\n");
|
||||
|
||||
const filename = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
|
||||
return new NextResponse(csvContent, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Normal JSON pagination mode
|
||||
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
|
||||
const pageSize = Math.max(1, parseInt(searchParams.get("pageSize") || "20", 10));
|
||||
const userId = searchParams.get("userId")
|
||||
? parseInt(searchParams.get("userId")!, 10)
|
||||
: undefined;
|
||||
const action = searchParams.get("action") || undefined;
|
||||
const resource = searchParams.get("resource") || undefined;
|
||||
const startDate = searchParams.get("startDate")
|
||||
? new Date(searchParams.get("startDate")!)
|
||||
: undefined;
|
||||
const endDate = searchParams.get("endDate")
|
||||
? new Date(searchParams.get("endDate")!)
|
||||
: undefined;
|
||||
|
||||
const result = await listAuditLogs({
|
||||
page,
|
||||
pageSize,
|
||||
userId,
|
||||
action: action || undefined,
|
||||
resource: resource || undefined,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (e) {
|
||||
console.error("List audit logs error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
131
admin/src/app/api/permissions/route.ts
Normal file
131
admin/src/app/api/permissions/route.ts
Normal file
@ -0,0 +1,131 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
listPermissions,
|
||||
createPermission,
|
||||
updatePermission,
|
||||
deletePermission,
|
||||
} from "@/lib/rbac";
|
||||
import { createAuditLog } from "@/lib/log";
|
||||
|
||||
function getAuthInfo(req: NextRequest) {
|
||||
return {
|
||||
userId: parseInt(req.headers.get("x-admin-user-id") || "0", 10),
|
||||
username: req.headers.get("x-admin-username") || "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const permissions = await listPermissions();
|
||||
return NextResponse.json({ permissions });
|
||||
} catch (e) {
|
||||
console.error("List permissions error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as {
|
||||
name?: string;
|
||||
code?: string;
|
||||
description?: string;
|
||||
};
|
||||
const { name = "", code = "", description = "" } = body;
|
||||
|
||||
if (!name || !code) {
|
||||
return NextResponse.json({ error: "名称和代码不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9_:]+$/.test(code)) {
|
||||
return NextResponse.json(
|
||||
{ error: "代码只能包含小写字母、数字、冒号和下划线" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const permission = await createPermission(name, code, description);
|
||||
|
||||
const { userId, username } = getAuthInfo(req);
|
||||
await createAuditLog({
|
||||
userId,
|
||||
username,
|
||||
action: "create",
|
||||
resource: "admin_permission",
|
||||
resourceId: String(permission.id),
|
||||
requestParams: { name, code, description },
|
||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||
userAgent: req.headers.get("user-agent") || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(permission, { status: 201 });
|
||||
} catch (e: unknown) {
|
||||
if ((e as { code?: string }).code === "23505") {
|
||||
return NextResponse.json({ error: "权限代码已存在" }, { status: 409 });
|
||||
}
|
||||
console.error("Create permission error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as {
|
||||
id?: number;
|
||||
name?: string;
|
||||
code?: string;
|
||||
description?: string;
|
||||
};
|
||||
const { id, name = "", code = "", description = "" } = body;
|
||||
|
||||
if (!id) return NextResponse.json({ error: "ID 不能为空" }, { status: 400 });
|
||||
|
||||
const permission = await updatePermission(id, name, code, description);
|
||||
if (!permission) return NextResponse.json({ error: "权限不存在" }, { status: 404 });
|
||||
|
||||
const { userId, username } = getAuthInfo(req);
|
||||
await createAuditLog({
|
||||
userId,
|
||||
username,
|
||||
action: "update",
|
||||
resource: "admin_permission",
|
||||
resourceId: String(id),
|
||||
requestParams: { name, code, description },
|
||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||
userAgent: req.headers.get("user-agent") || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(permission);
|
||||
} catch (e) {
|
||||
console.error("Update permission error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = req.nextUrl;
|
||||
const id = parseInt(searchParams.get("id") || "0", 10);
|
||||
|
||||
if (!id) return NextResponse.json({ error: "ID 不能为空" }, { status: 400 });
|
||||
|
||||
const ok = await deletePermission(id);
|
||||
if (!ok) return NextResponse.json({ error: "权限不存在" }, { status: 404 });
|
||||
|
||||
const { userId, username } = getAuthInfo(req);
|
||||
await createAuditLog({
|
||||
userId,
|
||||
username,
|
||||
action: "delete",
|
||||
resource: "admin_permission",
|
||||
resourceId: String(id),
|
||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||
userAgent: req.headers.get("user-agent") || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (e) {
|
||||
console.error("Delete permission error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
82
admin/src/app/api/platform/activity-stats/route.ts
Normal file
82
admin/src/app/api/platform/activity-stats/route.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Last 30 days of DAU
|
||||
const dauResult = await query<{ date: string; dau: string }>(
|
||||
`SELECT
|
||||
TO_CHAR(DATE_TRUNC('day', created_at), 'YYYY-MM-DD') AS date,
|
||||
COUNT(DISTINCT user_uid) AS dau
|
||||
FROM user_activity_log
|
||||
WHERE created_at >= NOW() - INTERVAL '30 days'
|
||||
GROUP BY DATE_TRUNC('day', created_at)
|
||||
ORDER BY date ASC`
|
||||
);
|
||||
|
||||
// Last 7 days breakdown by action
|
||||
const actionBreakdown = await query<{ date: string; action: string; count: string }>(
|
||||
`SELECT
|
||||
TO_CHAR(DATE_TRUNC('day', created_at), 'YYYY-MM-DD') AS date,
|
||||
action,
|
||||
COUNT(*) AS count
|
||||
FROM user_activity_log
|
||||
WHERE created_at >= NOW() - INTERVAL '7 days'
|
||||
GROUP BY DATE_TRUNC('day', created_at), action
|
||||
ORDER BY date ASC, action ASC`
|
||||
);
|
||||
|
||||
// MAU: unique users in last 30 days
|
||||
const mauResult = await query<{ mau: string }>(
|
||||
`SELECT COUNT(DISTINCT user_uid) AS mau
|
||||
FROM user_activity_log
|
||||
WHERE created_at >= NOW() - INTERVAL '30 days'`
|
||||
);
|
||||
|
||||
// Total logins (ever)
|
||||
const totalLogins = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) AS count FROM user_activity_log WHERE action = 'login'`
|
||||
);
|
||||
|
||||
// Login stats last 30 days
|
||||
const loginStats = await query<{ date: string; logins: string }>(
|
||||
`SELECT
|
||||
TO_CHAR(DATE_TRUNC('day', created_at), 'YYYY-MM-DD') AS date,
|
||||
COUNT(*) AS logins
|
||||
FROM user_activity_log
|
||||
WHERE action = 'login' AND created_at >= NOW() - INTERVAL '30 days'
|
||||
GROUP BY DATE_TRUNC('day', created_at)
|
||||
ORDER BY date ASC`
|
||||
);
|
||||
|
||||
const dau = dauResult.rows.map((r) => ({
|
||||
date: r.date,
|
||||
dau: parseInt(r.dau, 10),
|
||||
}));
|
||||
|
||||
const mau = parseInt(mauResult.rows[0]?.mau || "0", 10);
|
||||
const totalLoginCount = parseInt(totalLogins.rows[0]?.count || "0", 10);
|
||||
const logins = loginStats.rows.map((r) => ({
|
||||
date: r.date,
|
||||
logins: parseInt(r.logins, 10),
|
||||
}));
|
||||
|
||||
// Recent activity count (last 24h)
|
||||
const last24h = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) AS count FROM user_activity_log WHERE created_at >= NOW() - INTERVAL '24 hours'`
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
dau,
|
||||
mau,
|
||||
totalLogins: totalLoginCount,
|
||||
logins,
|
||||
last24h: parseInt(last24h.rows[0]?.count || "0", 10),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Activity stats error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
106
admin/src/app/api/platform/ai/route.ts
Normal file
106
admin/src/app/api/platform/ai/route.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = req.nextUrl;
|
||||
const type = searchParams.get("type") || "all";
|
||||
|
||||
const providers = query(
|
||||
`SELECT id, name, display_name, website, status, created_at
|
||||
FROM ai_model_provider
|
||||
ORDER BY name`
|
||||
);
|
||||
|
||||
const models = query(
|
||||
`SELECT m.id, m.name, m.modality, m.capability, m.context_length,
|
||||
m.max_output_tokens, m.training_cutoff, m.is_open_source, m.status,
|
||||
mv.model_id, mv.version,
|
||||
p.id as provider_id, p.name as provider_name
|
||||
FROM ai_model m
|
||||
JOIN ai_model_provider p ON p.id = m.provider_id
|
||||
LEFT JOIN ai_model_version mv ON mv.model_id = m.id AND mv.is_default = true
|
||||
ORDER BY p.name, m.name`
|
||||
);
|
||||
|
||||
const pricing = query(
|
||||
`SELECT mp.id, mp.model_version_id, mp.input_price_per_1k_tokens, mp.output_price_per_1k_tokens,
|
||||
mp.currency, mp.effective_from,
|
||||
m.name as model_name, mv.model_id
|
||||
FROM ai_model_pricing mp
|
||||
JOIN ai_model_version mv ON mv.id = mp.model_version_id
|
||||
JOIN ai_model m ON m.id = mv.model_id
|
||||
ORDER BY mp.effective_from DESC
|
||||
LIMIT 200`
|
||||
);
|
||||
|
||||
const versions = query(
|
||||
`SELECT mv.id, mv.model_id, mv.version, mv.release_date, mv.change_log, mv.is_default, mv.status, mv.created_at
|
||||
FROM ai_model_version mv
|
||||
ORDER BY mv.model_id, mv.version`
|
||||
);
|
||||
|
||||
const [providersData, modelsData, pricingData, versionsData] = await Promise.all([providers, models, pricing, versions]);
|
||||
|
||||
const providersList = providersData.rows.map((r: Record<string, unknown>) => ({
|
||||
id: String(r.id),
|
||||
name: r.name,
|
||||
display_name: r.display_name,
|
||||
website: r.website,
|
||||
status: r.status,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
enabled: r.status === "active",
|
||||
}));
|
||||
|
||||
const modelsList = modelsData.rows.map((r: Record<string, unknown>) => ({
|
||||
id: String(r.id),
|
||||
name: r.name,
|
||||
model_id: r.model_id ? `${r.model_id}/${r.version}` : r.id,
|
||||
modality: r.modality || "text",
|
||||
capability: r.capability || "chat",
|
||||
context_length: Number(r.context_length) || 8192,
|
||||
max_output_tokens: r.max_output_tokens ? Number(r.max_output_tokens) : null,
|
||||
training_cutoff: r.training_cutoff,
|
||||
is_open_source: r.is_open_source ?? false,
|
||||
enabled: r.status === "active",
|
||||
status: r.status,
|
||||
provider_id: String(r.provider_id),
|
||||
provider_name: r.provider_name,
|
||||
}));
|
||||
|
||||
const pricingList = pricingData.rows.map((r: Record<string, unknown>) => ({
|
||||
id: Number(r.id),
|
||||
model_version_id: String(r.model_version_id),
|
||||
model_id: r.model_id,
|
||||
input_price_per_1k_tokens: String(r.input_price_per_1k_tokens),
|
||||
output_price_per_1k_tokens: String(r.output_price_per_1k_tokens),
|
||||
currency: r.currency,
|
||||
effective_from: r.effective_from,
|
||||
model_name: r.model_name,
|
||||
}));
|
||||
|
||||
const versionsList = versionsData.rows.map((r: Record<string, unknown>) => ({
|
||||
id: String(r.id),
|
||||
model_id: String(r.model_id),
|
||||
version: r.version,
|
||||
release_date: r.release_date,
|
||||
change_log: r.change_log,
|
||||
is_default: r.is_default ?? false,
|
||||
status: r.status,
|
||||
created_at: r.created_at,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
providers: providersList,
|
||||
models: modelsList,
|
||||
pricing: pricingList,
|
||||
versions: versionsList,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("AI data error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
48
admin/src/app/api/platform/ai/sync/route.ts
Normal file
48
admin/src/app/api/platform/ai/sync/route.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { RUST_BACKEND_URL, ADMIN_API_SHARED_KEY } from "@/lib/env";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* Trigger AI model sync via Rust backend.
|
||||
* Calls POST /api/admin/ai/sync on the Rust app.
|
||||
*/
|
||||
export async function POST() {
|
||||
if (!ADMIN_API_SHARED_KEY) {
|
||||
return NextResponse.json(
|
||||
{ error: "ADMIN_API_SHARED_KEY 未配置" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `${RUST_BACKEND_URL}/api/admin/ai/sync`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-admin-api-key": ADMIN_API_SHARED_KEY,
|
||||
},
|
||||
// Timeout: 2 minutes for sync
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
return NextResponse.json(
|
||||
{ error: `同步失败: ${res.status} ${body}` },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error("AI sync error:", msg);
|
||||
return NextResponse.json(
|
||||
{ error: `同步失败: ${msg}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
47
admin/src/app/api/platform/alerts/check/route.ts
Normal file
47
admin/src/app/api/platform/alerts/check/route.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { RUST_BACKEND_URL, ADMIN_API_SHARED_KEY } from "@/lib/env";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* Trigger workspace billing alert check via Rust backend.
|
||||
* Calls POST /api/admin/alerts/check on the Rust app.
|
||||
*/
|
||||
export async function POST() {
|
||||
if (!ADMIN_API_SHARED_KEY) {
|
||||
return NextResponse.json(
|
||||
{ error: "ADMIN_API_SHARED_KEY 未配置" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `${RUST_BACKEND_URL}/api/admin/alerts/check`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-admin-api-key": ADMIN_API_SHARED_KEY,
|
||||
},
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
return NextResponse.json(
|
||||
{ error: `检查失败: ${res.status} ${body}` },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error("Alert check error:", msg);
|
||||
return NextResponse.json(
|
||||
{ error: `检查失败: ${msg}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
111
admin/src/app/api/platform/audit-logs/route.ts
Normal file
111
admin/src/app/api/platform/audit-logs/route.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
interface AuditLog {
|
||||
source: "user_activity" | "project_audit";
|
||||
id: number;
|
||||
actor_uid: string | null;
|
||||
action: string;
|
||||
resource: string | null;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = req.nextUrl;
|
||||
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
|
||||
const pageSize = Math.max(1, parseInt(searchParams.get("pageSize") || "20", 10));
|
||||
const source = searchParams.get("source") || "all";
|
||||
const action = searchParams.get("action") || "";
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
// Build queries with proper parameter indexing
|
||||
let userQuery = "";
|
||||
let projectQuery = "";
|
||||
let queryParams: unknown[] = [];
|
||||
let userCountQuery = "";
|
||||
let projectCountQuery = "";
|
||||
let paramIdx = 1;
|
||||
|
||||
if (source !== "project") {
|
||||
if (action) {
|
||||
userQuery = `SELECT 'user_activity' as source, id,
|
||||
COALESCE(user_uid::text, '') as actor_uid,
|
||||
action, NULL::text as resource,
|
||||
ip_address, user_agent, created_at::text as created_at
|
||||
FROM user_activity_log
|
||||
WHERE action ILIKE $${paramIdx}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${paramIdx + 1} OFFSET $${paramIdx + 2}`;
|
||||
userCountQuery = `SELECT COUNT(*) FROM user_activity_log WHERE action ILIKE $${paramIdx}`;
|
||||
queryParams.push(`%${action}%`, pageSize, offset);
|
||||
paramIdx += 3;
|
||||
} else {
|
||||
userQuery = `SELECT 'user_activity' as source, id,
|
||||
COALESCE(user_uid::text, '') as actor_uid,
|
||||
action, NULL::text as resource,
|
||||
ip_address, user_agent, created_at::text as created_at
|
||||
FROM user_activity_log
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`;
|
||||
userCountQuery = `SELECT COUNT(*) FROM user_activity_log`;
|
||||
queryParams.push(pageSize, offset);
|
||||
paramIdx += 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (source !== "user") {
|
||||
if (action) {
|
||||
projectQuery = `SELECT 'project_audit' as source, id,
|
||||
COALESCE(actor::text, '') as actor_uid,
|
||||
action, details as resource, ip_address,
|
||||
NULL as user_agent, created_at::text as created_at
|
||||
FROM project_audit_log
|
||||
WHERE action ILIKE $${paramIdx}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${paramIdx + 1} OFFSET $${paramIdx + 2}`;
|
||||
projectCountQuery = `SELECT COUNT(*) FROM project_audit_log WHERE action ILIKE $${paramIdx}`;
|
||||
queryParams.push(`%${action}%`, pageSize, offset);
|
||||
paramIdx += 3;
|
||||
} else {
|
||||
projectQuery = `SELECT 'project_audit' as source, id,
|
||||
COALESCE(actor::text, '') as actor_uid,
|
||||
action, details as resource, ip_address,
|
||||
NULL as user_agent, created_at::text as created_at
|
||||
FROM project_audit_log
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`;
|
||||
projectCountQuery = `SELECT COUNT(*) FROM project_audit_log`;
|
||||
queryParams.push(pageSize, offset);
|
||||
paramIdx += 2;
|
||||
}
|
||||
}
|
||||
|
||||
const [userLogs, projectLogs] = await Promise.all([
|
||||
userQuery ? query<AuditLog>(userQuery, queryParams) : Promise.resolve({ rows: [] as AuditLog[] }),
|
||||
projectQuery ? query<AuditLog>(projectQuery, queryParams) : Promise.resolve({ rows: [] as AuditLog[] }),
|
||||
]);
|
||||
|
||||
// 合并并排序
|
||||
const combined = [...userLogs.rows, ...projectLogs.rows].sort(
|
||||
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
);
|
||||
|
||||
// 总数
|
||||
const [userCountRes, projectCountRes] = await Promise.all([
|
||||
userCountQuery ? query<{ count: string }>(userCountQuery, action ? [`%${action}%`] : []) : Promise.resolve({ rows: [{ count: "0" }] }),
|
||||
projectCountQuery ? query<{ count: string }>(projectCountQuery, action ? [`%${action}%`] : []) : Promise.resolve({ rows: [{ count: "0" }] }),
|
||||
]);
|
||||
const total = parseInt(String(userCountRes.rows[0]?.count || "0"), 10) +
|
||||
parseInt(String(projectCountRes.rows[0]?.count || "0"), 10);
|
||||
|
||||
return NextResponse.json({ logs: combined.slice(0, pageSize), total, page, pageSize });
|
||||
} catch (e) {
|
||||
console.error("Platform audit logs error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
96
admin/src/app/api/platform/repos/route.ts
Normal file
96
admin/src/app/api/platform/repos/route.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = req.nextUrl;
|
||||
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
|
||||
const pageSize = Math.max(1, parseInt(searchParams.get("pageSize") || "30", 10));
|
||||
const projectId = searchParams.get("projectId") || "";
|
||||
const search = searchParams.get("search") || "";
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const conditions: string[] = ["1=1"];
|
||||
const params: (string | number)[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (projectId) {
|
||||
conditions.push(`r.project = $${paramIdx++}`);
|
||||
params.push(projectId);
|
||||
}
|
||||
if (search) {
|
||||
conditions.push(`r.repo_name ILIKE $${paramIdx++}`);
|
||||
params.push(`%${search}%`);
|
||||
}
|
||||
|
||||
const whereCond = conditions.join(" AND ");
|
||||
const limitIdx = paramIdx++;
|
||||
const offsetIdx = paramIdx++;
|
||||
|
||||
const [reposResult, countResult] = await Promise.all([
|
||||
query<{
|
||||
id: string;
|
||||
repo_name: string;
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
workspace_name: string;
|
||||
default_branch: string;
|
||||
is_private: boolean;
|
||||
created_by: string;
|
||||
created_at: Date;
|
||||
ai_code_review_enabled: boolean;
|
||||
collaborator_count: string;
|
||||
branch_count: string;
|
||||
}>(
|
||||
`SELECT
|
||||
r.id, r.repo_name, r.project as project_id,
|
||||
p.name as project_name,
|
||||
COALESCE(w.name, '') as workspace_name,
|
||||
r.default_branch, r.is_private,
|
||||
r.created_by::text as created_by,
|
||||
r.created_at::text as created_at,
|
||||
r.ai_code_review_enabled,
|
||||
(SELECT COUNT(*) FROM repo_collaborator WHERE repo = r.id)::text as collaborator_count,
|
||||
(SELECT COUNT(*) FROM repo_branch WHERE repo = r.id)::text as branch_count
|
||||
FROM repo r
|
||||
JOIN project p ON p.id = r.project
|
||||
LEFT JOIN workspace w ON w.id = p.workspace_id
|
||||
WHERE ${whereCond}
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT $${limitIdx} OFFSET $${offsetIdx}`,
|
||||
[...params, pageSize, offset]
|
||||
),
|
||||
query<{ count: string }>(
|
||||
`SELECT COUNT(*)::text as count
|
||||
FROM repo r
|
||||
JOIN project p ON p.id = r.project
|
||||
WHERE ${whereCond}`,
|
||||
params
|
||||
),
|
||||
]);
|
||||
|
||||
const repos = reposResult.rows.map((r) => ({
|
||||
id: r.id,
|
||||
repoName: r.repo_name,
|
||||
projectId: r.project_id,
|
||||
projectName: r.project_name,
|
||||
workspaceName: r.workspace_name,
|
||||
defaultBranch: r.default_branch,
|
||||
isPrivate: r.is_private,
|
||||
createdBy: r.created_by,
|
||||
createdAt: String(r.created_at),
|
||||
aiCodeReviewEnabled: r.ai_code_review_enabled,
|
||||
collaboratorCount: parseInt(r.collaborator_count || "0", 10),
|
||||
branchCount: parseInt(r.branch_count || "0", 10),
|
||||
}));
|
||||
|
||||
const total = parseInt(countResult.rows[0]?.count || "0", 10);
|
||||
|
||||
return NextResponse.json({ repos, total, page, pageSize });
|
||||
} catch (e) {
|
||||
console.error("Repos error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db";
|
||||
import { getAdminUserId } from "@/lib/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; msgId: string }> }
|
||||
) {
|
||||
try {
|
||||
const adminUserId = getAdminUserId(req);
|
||||
if (!adminUserId) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id, msgId } = await params;
|
||||
|
||||
const result = await query<{ id: string }>(
|
||||
`UPDATE room_message
|
||||
SET revoked = NOW()
|
||||
WHERE id = $1 AND room = $2 AND revoked IS NULL
|
||||
RETURNING id`,
|
||||
[msgId, id]
|
||||
);
|
||||
|
||||
if (!result.rows.length) {
|
||||
return NextResponse.json({ error: "消息不存在或已被撤回" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (e) {
|
||||
console.error("Revoke message error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
78
admin/src/app/api/platform/rooms/[id]/messages/route.ts
Normal file
78
admin/src/app/api/platform/rooms/[id]/messages/route.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { searchParams } = req.nextUrl;
|
||||
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
|
||||
const pageSize = Math.max(1, parseInt(searchParams.get("pageSize") || "50", 10));
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const [messagesResult, countResult] = await Promise.all([
|
||||
query<{
|
||||
id: string;
|
||||
seq: string;
|
||||
sender_type: string;
|
||||
sender_id: string | null;
|
||||
sender_name: string | null;
|
||||
content: string;
|
||||
content_type: string;
|
||||
send_at: Date;
|
||||
revoked: Date | null;
|
||||
in_reply_to: string | null;
|
||||
}>(
|
||||
`SELECT
|
||||
m.id, m.seq, m.sender_type, m.sender_id,
|
||||
CASE
|
||||
WHEN m.sender_type = 'member' THEN COALESCE(u.display_name, u.username)
|
||||
WHEN m.sender_type = 'ai' THEN 'AI'
|
||||
WHEN m.sender_type = 'system' THEN 'System'
|
||||
ELSE m.sender_id::text
|
||||
END as sender_name,
|
||||
CASE
|
||||
WHEN m.sender_type = 'member' THEN 'user'
|
||||
ELSE m.sender_type
|
||||
END as sender_type,
|
||||
m.content, m.content_type, m.send_at::text as send_at,
|
||||
m.revoked::text as revoked,
|
||||
m.in_reply_to::text as in_reply_to
|
||||
FROM room_message m
|
||||
LEFT JOIN "user" u ON u.uid = m.sender_id
|
||||
WHERE m.room = $1
|
||||
ORDER BY m.seq DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[id, pageSize, offset]
|
||||
),
|
||||
query<{ count: string }>(
|
||||
`SELECT COUNT(*)::text as count FROM room_message WHERE room = $1`,
|
||||
[id]
|
||||
),
|
||||
]);
|
||||
|
||||
const messages = messagesResult.rows.map((m) => ({
|
||||
id: m.id,
|
||||
seq: parseInt(m.seq, 10),
|
||||
senderType: m.sender_type,
|
||||
senderId: m.sender_id,
|
||||
senderName: m.sender_name,
|
||||
content: m.content,
|
||||
contentType: m.content_type,
|
||||
sendAt: String(m.send_at),
|
||||
revoked: m.revoked ? String(m.revoked) : null,
|
||||
inReplyTo: m.in_reply_to,
|
||||
}));
|
||||
|
||||
const total = parseInt(countResult.rows[0]?.count || "0", 10);
|
||||
|
||||
return NextResponse.json({ messages, total, page, pageSize });
|
||||
} catch (e) {
|
||||
console.error("Room messages error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
96
admin/src/app/api/platform/rooms/route.ts
Normal file
96
admin/src/app/api/platform/rooms/route.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = req.nextUrl;
|
||||
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
|
||||
const pageSize = Math.max(1, parseInt(searchParams.get("pageSize") || "30", 10));
|
||||
const projectId = searchParams.get("projectId") || "";
|
||||
const search = searchParams.get("search") || "";
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const conditions: string[] = ["1=1"];
|
||||
const params: (string | number)[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (projectId) {
|
||||
conditions.push(`r.project = $${paramIdx++}`);
|
||||
params.push(projectId);
|
||||
}
|
||||
if (search) {
|
||||
conditions.push(`r.room_name ILIKE $${paramIdx++}`);
|
||||
params.push(`%${search}%`);
|
||||
}
|
||||
|
||||
const whereCond = conditions.join(" AND ");
|
||||
const limitIdx = paramIdx++;
|
||||
const offsetIdx = paramIdx++;
|
||||
|
||||
const [roomsResult, countResult] = await Promise.all([
|
||||
query<{
|
||||
id: string;
|
||||
room_name: string;
|
||||
is_public: boolean;
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
workspace_name: string;
|
||||
created_by: string;
|
||||
created_at: Date;
|
||||
last_msg_at: Date;
|
||||
member_count: string;
|
||||
message_count: string;
|
||||
}>(
|
||||
`SELECT
|
||||
r.id, r.room_name, r.public as is_public,
|
||||
r.project as project_id,
|
||||
p.name as project_name,
|
||||
COALESCE(w.name, '') as workspace_name,
|
||||
r.created_by::text as created_by,
|
||||
r.created_at::text as created_at,
|
||||
r.last_msg_at::text as last_msg_at,
|
||||
COUNT(DISTINCT rm.user)::text as member_count,
|
||||
(SELECT COUNT(*) FROM room_message WHERE room = r.id AND revoked IS NULL)::text as message_count
|
||||
FROM room r
|
||||
JOIN project p ON p.id = r.project
|
||||
LEFT JOIN workspace w ON w.id = p.workspace_id
|
||||
LEFT JOIN room_member rm ON rm.room = r.id
|
||||
WHERE ${whereCond}
|
||||
GROUP BY r.id, p.name, w.name
|
||||
ORDER BY r.last_msg_at DESC
|
||||
LIMIT $${limitIdx} OFFSET $${offsetIdx}`,
|
||||
[...params, pageSize, offset]
|
||||
),
|
||||
query<{ count: string }>(
|
||||
`SELECT COUNT(DISTINCT r.id)::text as count
|
||||
FROM room r
|
||||
JOIN project p ON p.id = r.project
|
||||
WHERE ${whereCond}`,
|
||||
params
|
||||
),
|
||||
]);
|
||||
|
||||
const rooms = roomsResult.rows.map((r) => ({
|
||||
id: r.id,
|
||||
roomName: r.room_name,
|
||||
isPublic: r.is_public,
|
||||
projectId: r.project_id,
|
||||
projectName: r.project_name,
|
||||
workspaceName: r.workspace_name,
|
||||
createdBy: r.created_by,
|
||||
createdAt: String(r.created_at),
|
||||
lastMsgAt: String(r.last_msg_at),
|
||||
memberCount: parseInt(r.member_count || "0", 10),
|
||||
messageCount: parseInt(r.message_count || "0", 10),
|
||||
}));
|
||||
|
||||
const total = parseInt(countResult.rows[0]?.count || "0", 10);
|
||||
|
||||
return NextResponse.json({ rooms, total, page, pageSize });
|
||||
} catch (e) {
|
||||
console.error("Rooms error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
57
admin/src/app/api/platform/sessions/route.ts
Normal file
57
admin/src/app/api/platform/sessions/route.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
getOnlinePlatformSessions,
|
||||
kickPlatformSession,
|
||||
PlatformSessionInfo,
|
||||
} from "@/lib/redis";
|
||||
import { createAuditLog } from "@/lib/log";
|
||||
|
||||
function getAuthInfo(req: NextRequest) {
|
||||
return {
|
||||
userId: parseInt(req.headers.get("x-admin-user-id") || "0", 10),
|
||||
username: req.headers.get("x-admin-username") || "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const sessions = await getOnlinePlatformSessions();
|
||||
// Log for debugging if sessions are empty
|
||||
if (sessions.length === 0) {
|
||||
console.log("[platform-sessions] No sessions found via Redis SCAN session:user_uid:*");
|
||||
}
|
||||
return NextResponse.json({ sessions });
|
||||
} catch (e) {
|
||||
console.error("Get platform sessions error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as { sessionId?: string };
|
||||
const { sessionId } = body;
|
||||
|
||||
if (!sessionId) {
|
||||
return NextResponse.json({ error: "sessionId 不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const ok = await kickPlatformSession(sessionId);
|
||||
|
||||
const { userId, username } = getAuthInfo(req);
|
||||
await createAuditLog({
|
||||
userId,
|
||||
username,
|
||||
action: "delete",
|
||||
resource: "platform_session",
|
||||
resourceId: sessionId.slice(0, 8) + "...",
|
||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||
userAgent: req.headers.get("user-agent") || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: ok });
|
||||
} catch (e) {
|
||||
console.error("Delete platform session error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
79
admin/src/app/api/platform/stats/route.ts
Normal file
79
admin/src/app/api/platform/stats/route.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const [
|
||||
userCount, workspaceCount, projectCount, roomCount,
|
||||
recentUsers, recentWorkspaces,
|
||||
planDist, recentProjects,
|
||||
] = await Promise.all([
|
||||
query<{ count: string }>("SELECT COUNT(*) as count FROM \"user\""),
|
||||
query<{ count: string }>("SELECT COUNT(*) as count FROM workspace WHERE deleted_at IS NULL"),
|
||||
query<{ count: string }>("SELECT COUNT(*) as count FROM project"),
|
||||
query<{ count: string }>("SELECT COUNT(*) as count FROM room"),
|
||||
query<{ count: string }>(
|
||||
`SELECT uid, username, display_name, last_sign_in_at, created_at
|
||||
FROM "user" ORDER BY created_at DESC LIMIT 10`
|
||||
),
|
||||
query<{ id: string; name: string; slug: string; plan: string; member_count: string }>(
|
||||
`SELECT w.id, w.name, w.slug, w.plan,
|
||||
COUNT(DISTINCT wm.user_id)::text as member_count
|
||||
FROM workspace w
|
||||
LEFT JOIN workspace_membership wm ON wm.workspace_id = w.id
|
||||
WHERE w.deleted_at IS NULL
|
||||
GROUP BY w.id
|
||||
ORDER BY w.created_at DESC LIMIT 10`
|
||||
),
|
||||
query<{ plan: string; count: string }>(
|
||||
`SELECT COALESCE(plan, 'unknown') as plan, COUNT(*)::text as count
|
||||
FROM workspace WHERE deleted_at IS NULL GROUP BY plan`
|
||||
),
|
||||
query<{ id: string; name: string; workspace_name: string | null; member_count: string }>(
|
||||
`SELECT p.id, p.name, w.name as workspace_name,
|
||||
COUNT(DISTINCT pm.user_uuid)::text as member_count
|
||||
FROM project p
|
||||
LEFT JOIN workspace w ON w.id = p.workspace_id
|
||||
LEFT JOIN project_members pm ON pm.project_uuid = p.id
|
||||
WHERE 1=1
|
||||
GROUP BY p.id, w.name
|
||||
ORDER BY p.created_at DESC LIMIT 10`
|
||||
),
|
||||
]);
|
||||
|
||||
const stats = {
|
||||
userCount: parseInt(userCount.rows[0]?.count || "0", 10),
|
||||
workspaceCount: parseInt(workspaceCount.rows[0]?.count || "0", 10),
|
||||
projectCount: parseInt(projectCount.rows[0]?.count || "0", 10),
|
||||
roomCount: parseInt(roomCount.rows[0]?.count || "0", 10),
|
||||
};
|
||||
|
||||
const planDistribution = planDist.rows.map((p) => ({
|
||||
plan: p.plan,
|
||||
count: parseInt(p.count, 10),
|
||||
}));
|
||||
|
||||
const recentWorkspacesWithMembers = recentWorkspaces.rows.map((w) => ({
|
||||
id: w.id, name: w.name, slug: w.slug, plan: w.plan,
|
||||
memberCount: parseInt(w.member_count || "0", 10),
|
||||
}));
|
||||
|
||||
const recentProjectsWithMembers = recentProjects.rows.map((p) => ({
|
||||
id: p.id, name: p.name, workspaceName: p.workspace_name,
|
||||
memberCount: parseInt(p.member_count || "0", 10),
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
stats,
|
||||
recentUsers: recentUsers.rows,
|
||||
recentWorkspaces: recentWorkspacesWithMembers,
|
||||
recentProjects: recentProjectsWithMembers,
|
||||
planDistribution,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Stats error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
151
admin/src/app/api/platform/users/[uid]/route.ts
Normal file
151
admin/src/app/api/platform/users/[uid]/route.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db";
|
||||
import { createAuditLog } from "@/lib/log";
|
||||
import argon2 from "argon2";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ uid: string }> }
|
||||
) {
|
||||
try {
|
||||
const { uid } = await params;
|
||||
|
||||
const result = await query(
|
||||
`SELECT u.uid, u.username, u.display_name, u.avatar_url,
|
||||
u.website_url, u.organization,
|
||||
u.last_sign_in_at::text, u.created_at::text,
|
||||
up.is_active,
|
||||
ue.email
|
||||
FROM "user" u
|
||||
LEFT JOIN user_password up ON up.user = u.uid
|
||||
LEFT JOIN user_email ue ON ue.user = u.uid
|
||||
WHERE u.uid = $1`,
|
||||
[uid]
|
||||
);
|
||||
|
||||
if (!result.rows.length) {
|
||||
return NextResponse.json({ error: "用户不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
const r = result.rows[0];
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
uid: r.uid,
|
||||
username: r.username,
|
||||
displayName: r.display_name,
|
||||
avatarUrl: r.avatar_url,
|
||||
websiteUrl: r.website_url,
|
||||
organization: r.organization,
|
||||
lastSignInAt: r.last_sign_in_at,
|
||||
createdAt: r.created_at,
|
||||
isActive: r.is_active ?? true,
|
||||
email: r.email,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Get user error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ uid: string }> }
|
||||
) {
|
||||
try {
|
||||
const { uid } = await params;
|
||||
const body = await req.json() as {
|
||||
email?: string;
|
||||
password?: string;
|
||||
displayName?: string;
|
||||
organization?: string;
|
||||
};
|
||||
|
||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
||||
|
||||
// 检查用户是否存在
|
||||
const exist = await query<{ uid: string }>(`SELECT uid FROM "user" WHERE uid = $1`, [uid]);
|
||||
if (!exist.rows.length) {
|
||||
return NextResponse.json({ error: "用户不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
// 修改邮箱
|
||||
if (body.email !== undefined) {
|
||||
const emailUpsert = `
|
||||
INSERT INTO user_email (user, email)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user) DO UPDATE SET email = EXCLUDED.email`;
|
||||
await query(emailUpsert, [uid, body.email]);
|
||||
}
|
||||
|
||||
// 修改密码:复用现有盐值重新加密
|
||||
if (body.password) {
|
||||
if (body.password.length < 6) {
|
||||
return NextResponse.json({ error: "密码长度至少6位" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 读取现有盐值
|
||||
const pwRow = await query<{ password_salt: string | null }>(
|
||||
`SELECT password_salt FROM user_password WHERE user = $1`,
|
||||
[uid]
|
||||
);
|
||||
|
||||
let hash: string;
|
||||
if (pwRow.rows[0]?.password_salt) {
|
||||
// 复用现有盐值,重新加密
|
||||
hash = await argon2.hash(body.password, {
|
||||
salt: Buffer.from(pwRow.rows[0].password_salt!, "base64"),
|
||||
});
|
||||
} else {
|
||||
// 无现有记录,生成新盐并加密
|
||||
hash = await argon2.hash(body.password);
|
||||
}
|
||||
|
||||
// upsert user_password(保留现有 is_active 状态)
|
||||
const upsert = `
|
||||
INSERT INTO user_password (user, password_hash, is_active)
|
||||
VALUES ($1, $2, true)
|
||||
ON CONFLICT (user) DO UPDATE SET password_hash = EXCLUDED.password_hash`;
|
||||
await query(upsert, [uid, hash]);
|
||||
}
|
||||
|
||||
// 修改 display_name / organization
|
||||
const updates: string[] = [];
|
||||
const up: unknown[] = [];
|
||||
let idx = 1;
|
||||
if (body.displayName !== undefined) {
|
||||
updates.push(`display_name = $${idx++}`);
|
||||
up.push(body.displayName || null);
|
||||
}
|
||||
if (body.organization !== undefined) {
|
||||
updates.push(`organization = $${idx++}`);
|
||||
up.push(body.organization || null);
|
||||
}
|
||||
if (updates.length > 0) {
|
||||
up.push(uid);
|
||||
await query(
|
||||
`UPDATE "user" SET ${updates.join(", ")}, updated_at = NOW() WHERE uid = $${idx}`,
|
||||
up
|
||||
);
|
||||
}
|
||||
|
||||
await createAuditLog({
|
||||
userId: adminUserId,
|
||||
username: adminUsername,
|
||||
action: "update",
|
||||
resource: "platform_user",
|
||||
resourceId: uid,
|
||||
requestParams: { email: !!body.email, password: !!body.password, displayName: !!body.displayName },
|
||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||
userAgent: req.headers.get("user-agent") || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (e) {
|
||||
console.error("Update user error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
116
admin/src/app/api/platform/users/route.ts
Normal file
116
admin/src/app/api/platform/users/route.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db";
|
||||
import { createAuditLog } from "@/lib/log";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = req.nextUrl;
|
||||
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
|
||||
const pageSize = Math.max(1, parseInt(searchParams.get("pageSize") || "20", 10));
|
||||
const search = searchParams.get("search") || "";
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
let whereClause = "";
|
||||
const params: unknown[] = [];
|
||||
if (search) {
|
||||
whereClause = 'WHERE username ILIKE $1 OR display_name ILIKE $1';
|
||||
params.push(`%${search}%`);
|
||||
}
|
||||
|
||||
const countResult = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) FROM "user" ${whereClause}`,
|
||||
params
|
||||
);
|
||||
const total = parseInt(countResult.rows[0].count, 10);
|
||||
|
||||
const result = await query(
|
||||
`SELECT u.uid, u.username, u.display_name, u.avatar_url, u.organization,
|
||||
u.last_sign_in_at::text as last_sign_in_at, u.created_at::text as created_at,
|
||||
COALESCE(up.is_active, true) as is_active,
|
||||
ue.email
|
||||
FROM "user" u
|
||||
LEFT JOIN user_password up ON up.user = u.uid
|
||||
LEFT JOIN user_email ue ON ue.user = u.uid
|
||||
${whereClause}
|
||||
ORDER BY u.created_at DESC
|
||||
LIMIT $1 OFFSET $2`,
|
||||
params.length > 0
|
||||
? [...params, pageSize, offset]
|
||||
: [pageSize, offset]
|
||||
);
|
||||
|
||||
const users = result.rows.map((r: Record<string, unknown>) => ({
|
||||
uid: r.uid,
|
||||
username: r.username,
|
||||
display_name: r.display_name,
|
||||
avatar_url: r.avatar_url,
|
||||
organization: r.organization,
|
||||
last_sign_in_at: r.last_sign_in_at,
|
||||
created_at: r.created_at,
|
||||
is_active: r.is_active,
|
||||
email: r.email,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ users, total, page, pageSize });
|
||||
} catch (e) {
|
||||
console.error("Platform users error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as {
|
||||
ids?: string[];
|
||||
action?: "enable" | "disable";
|
||||
};
|
||||
const { ids = [], action } = body;
|
||||
|
||||
if (!ids.length) {
|
||||
return NextResponse.json({ error: "至少选择一个用户" }, { status: 400 });
|
||||
}
|
||||
if (!action || !["enable", "disable"].includes(action)) {
|
||||
return NextResponse.json({ error: "action 必须是 enable 或 disable" }, { status: 400 });
|
||||
}
|
||||
|
||||
const isActive = action === "enable";
|
||||
|
||||
// Get user ids from uids
|
||||
const uidPlaceholders = ids.map((_, i) => `$${i + 1}`).join(", ");
|
||||
const uidResult = await query<{ id: number }>(
|
||||
`SELECT id FROM "user" WHERE uid IN (${uidPlaceholders})`,
|
||||
ids
|
||||
);
|
||||
const userIds = uidResult.rows.map((r) => r.id);
|
||||
|
||||
if (!userIds.length) {
|
||||
return NextResponse.json({ error: "未找到匹配的用户" }, { status: 404 });
|
||||
}
|
||||
|
||||
const idPlaceholders = userIds.map((_, i) => `$${i + 1}`).join(", ");
|
||||
await query(
|
||||
`UPDATE user_password SET is_active = $${userIds.length + 1}, updated_at = NOW() WHERE user_id IN (${idPlaceholders})`,
|
||||
[...userIds, isActive]
|
||||
);
|
||||
|
||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
||||
await createAuditLog({
|
||||
userId: adminUserId,
|
||||
username: adminUsername,
|
||||
action: "update",
|
||||
resource: "user_batch_status",
|
||||
resourceId: `batch(${userIds.length})`,
|
||||
requestParams: { uidCount: ids.length, userIdCount: userIds.length, action },
|
||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||
userAgent: req.headers.get("user-agent") || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, updated: userIds.length });
|
||||
} catch (e) {
|
||||
console.error("Batch update user status error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
100
admin/src/app/api/platform/workspaces/[id]/add-credit/route.ts
Normal file
100
admin/src/app/api/platform/workspaces/[id]/add-credit/route.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query, getClient } from "@/lib/db";
|
||||
import { createAuditLog } from "@/lib/log";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await req.json() as {
|
||||
amount?: number;
|
||||
description?: string;
|
||||
};
|
||||
const { amount = 0, description = "" } = body;
|
||||
|
||||
if (!amount || amount <= 0) {
|
||||
return NextResponse.json({ error: "金额必须大于 0" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get workspace info first
|
||||
const wsResult = await query(
|
||||
`SELECT id, slug, name FROM workspace WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[id]
|
||||
);
|
||||
if (!wsResult.rows.length) {
|
||||
return NextResponse.json({ error: "Workspace 不存在" }, { status: 404 });
|
||||
}
|
||||
const workspace = wsResult.rows[0] as { id: string; slug: string; name: string };
|
||||
|
||||
// Get billing account
|
||||
const billingResult = await query(
|
||||
`SELECT id, balance, currency FROM workspace_billing WHERE workspace_id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
let currency = "USD";
|
||||
if (billingResult.rows.length) {
|
||||
currency = (billingResult.rows[0].currency as string) || "USD";
|
||||
}
|
||||
|
||||
// Use transaction for atomicity
|
||||
const client = await getClient();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
if (billingResult.rows.length) {
|
||||
// Update existing balance
|
||||
await client.query(
|
||||
`UPDATE workspace_billing
|
||||
SET balance = balance + $1, currency = $2, updated_at = NOW()
|
||||
WHERE workspace_id = $3`,
|
||||
[amount, currency, id]
|
||||
);
|
||||
} else {
|
||||
// Create billing record if doesn't exist
|
||||
await client.query(
|
||||
`INSERT INTO workspace_billing (workspace_id, balance, currency, monthly_quota, total_spent, updated_at, created_at)
|
||||
VALUES ($1, $2, $3, 0, 0, NOW(), NOW())`,
|
||||
[id, amount, currency]
|
||||
);
|
||||
}
|
||||
|
||||
// Insert billing history
|
||||
await client.query(
|
||||
`INSERT INTO workspace_billing_history
|
||||
(workspace_id, user, amount, reason, extra, currency)
|
||||
VALUES ($1, NULL, $2, 'admin_credit', $3, $4)`,
|
||||
[id, amount, JSON.stringify({ description: description || "Admin 手动充值" }), currency]
|
||||
);
|
||||
|
||||
await client.query("COMMIT");
|
||||
} catch (innerError) {
|
||||
await client.query("ROLLBACK");
|
||||
throw innerError;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
||||
await createAuditLog({
|
||||
userId: adminUserId,
|
||||
username: adminUsername,
|
||||
action: "update",
|
||||
resource: "workspace_billing",
|
||||
resourceId: id,
|
||||
requestParams: { workspaceSlug: workspace.slug, amount, description },
|
||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||
userAgent: req.headers.get("user-agent") || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, amount, currency });
|
||||
} catch (e) {
|
||||
console.error("Add workspace credit error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query, getClient } from "@/lib/db";
|
||||
import { getAdminUserId } from "@/lib/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
interface AlertConfig {
|
||||
id: number;
|
||||
workspace_id: string;
|
||||
alert_type: string;
|
||||
threshold: string;
|
||||
email_enabled: boolean;
|
||||
enabled: boolean;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
const ALERT_TYPE_LABELS: Record<string, string> = {
|
||||
low_balance: "余额不足",
|
||||
monthly_quota: "月度配额",
|
||||
usage_surge: "使用量激增",
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const result = await query<AlertConfig>(
|
||||
`SELECT id, workspace_id, alert_type, threshold, email_enabled, enabled, created_at
|
||||
FROM workspace_alert_config
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY alert_type ASC`,
|
||||
[id]
|
||||
);
|
||||
return NextResponse.json({ configs: result.rows });
|
||||
} catch (e) {
|
||||
console.error("Alert config error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const adminUserId = getAdminUserId(req);
|
||||
if (!adminUserId) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
|
||||
const { id: workspaceId } = await params;
|
||||
const body = await req.json();
|
||||
const { configs } = body as {
|
||||
configs: Array<{
|
||||
alert_type: string;
|
||||
threshold: number;
|
||||
email_enabled: boolean;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
const client = await getClient();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
for (const cfg of configs) {
|
||||
await client.query(
|
||||
`INSERT INTO workspace_alert_config
|
||||
(workspace_id, alert_type, threshold, email_enabled, enabled, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (workspace_id, alert_type)
|
||||
DO UPDATE SET
|
||||
threshold = EXCLUDED.threshold,
|
||||
email_enabled = EXCLUDED.email_enabled,
|
||||
enabled = EXCLUDED.enabled,
|
||||
updated_at = NOW()`,
|
||||
[workspaceId, cfg.alert_type, cfg.threshold, cfg.email_enabled, cfg.enabled, adminUserId]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
return NextResponse.json({ success: true });
|
||||
} catch {
|
||||
await client.query("ROLLBACK");
|
||||
throw "transaction error";
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Alert config update error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db";
|
||||
import { createAuditLog } from "@/lib/log";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// PATCH /api/platform/workspaces/[id]/members/[memberId]
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; memberId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id, memberId } = await params;
|
||||
const body = await req.json() as { role?: string };
|
||||
|
||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
||||
|
||||
if (body.role && !["owner", "admin", "member"].includes(body.role)) {
|
||||
return NextResponse.json({ error: "无效的角色" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 禁止降级 owner
|
||||
const member = await query<{ id: string; role: string; user_id: string }>(
|
||||
`SELECT id, role, user_id FROM workspace_membership WHERE id = $1 AND workspace_id = $2`,
|
||||
[memberId, id]
|
||||
);
|
||||
if (!member.rows.length) {
|
||||
return NextResponse.json({ error: "成员不存在" }, { status: 404 });
|
||||
}
|
||||
if (member.rows[0].role === "owner") {
|
||||
return NextResponse.json({ error: "无法修改 Owner 的角色" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (body.role) {
|
||||
await query(
|
||||
`UPDATE workspace_membership SET role = $1 WHERE id = $2`,
|
||||
[body.role, memberId]
|
||||
);
|
||||
}
|
||||
|
||||
await createAuditLog({
|
||||
userId: adminUserId,
|
||||
username: adminUsername,
|
||||
action: "update",
|
||||
resource: "workspace_member",
|
||||
resourceId: memberId,
|
||||
requestParams: { workspaceId: id, role: body.role },
|
||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||
userAgent: req.headers.get("user-agent") || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (e) {
|
||||
console.error("Update workspace member error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/platform/workspaces/[id]/members/[memberId]
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; memberId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id, memberId } = await params;
|
||||
|
||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
||||
|
||||
const member = await query<{ id: string; role: string; user_id: string }>(
|
||||
`SELECT id, role, user_id FROM workspace_membership WHERE id = $1 AND workspace_id = $2`,
|
||||
[memberId, id]
|
||||
);
|
||||
if (!member.rows.length) {
|
||||
return NextResponse.json({ error: "成员不存在" }, { status: 404 });
|
||||
}
|
||||
if (member.rows[0].role === "owner") {
|
||||
return NextResponse.json({ error: "无法删除 Owner" }, { status: 403 });
|
||||
}
|
||||
|
||||
await query(`DELETE FROM workspace_membership WHERE id = $1`, [memberId]);
|
||||
|
||||
await createAuditLog({
|
||||
userId: adminUserId,
|
||||
username: adminUsername,
|
||||
action: "delete",
|
||||
resource: "workspace_member",
|
||||
resourceId: memberId,
|
||||
requestParams: { workspaceId: id, userId: member.rows[0].user_id },
|
||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||
userAgent: req.headers.get("user-agent") || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (e) {
|
||||
console.error("Delete workspace member error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
119
admin/src/app/api/platform/workspaces/[id]/members/route.ts
Normal file
119
admin/src/app/api/platform/workspaces/[id]/members/route.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db";
|
||||
import { createAuditLog } from "@/lib/log";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// GET /api/platform/workspaces/[id]/members
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const result = await query(
|
||||
`SELECT wm.id, wm.workspace_id, wm.user_id, wm.role, wm.status,
|
||||
wm.invited_by, wm.joined_at::text, wm.invite_token, wm.invite_expires_at::text,
|
||||
u.username, u.display_name, u.avatar_url,
|
||||
COALESCE(up.is_active, true) as user_is_active
|
||||
FROM workspace_membership wm
|
||||
JOIN "user" u ON u.uid = wm.user_id
|
||||
LEFT JOIN user_password up ON up.user = u.uid
|
||||
WHERE wm.workspace_id = $1
|
||||
ORDER BY wm.role = 'owner' DESC, wm.role = 'admin' DESC, wm.joined_at ASC`,
|
||||
[id]
|
||||
);
|
||||
|
||||
const members = result.rows.map((r: Record<string, unknown>) => ({
|
||||
id: r.id,
|
||||
workspaceId: r.workspace_id,
|
||||
userId: r.user_id,
|
||||
role: r.role,
|
||||
status: r.status,
|
||||
invitedBy: r.invited_by,
|
||||
joinedAt: r.joined_at,
|
||||
inviteToken: r.invite_token,
|
||||
inviteExpiresAt: r.invite_expires_at,
|
||||
username: r.username,
|
||||
displayName: r.display_name,
|
||||
avatarUrl: r.avatar_url,
|
||||
userIsActive: r.user_is_active,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ members });
|
||||
} catch (e) {
|
||||
console.error("List workspace members error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/platform/workspaces/[id]/members — 添加成员
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await req.json() as {
|
||||
userId?: string;
|
||||
role?: string;
|
||||
};
|
||||
|
||||
if (!body.userId) {
|
||||
return NextResponse.json({ error: "缺少 userId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
||||
|
||||
const role = body.role || "member";
|
||||
if (!["owner", "admin", "member"].includes(role)) {
|
||||
return NextResponse.json({ error: "无效的角色" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 检查 workspace 是否存在
|
||||
const wsRow = await query<{ id: string }>(`SELECT id FROM workspace WHERE id = $1 AND deleted_at IS NULL`, [id]);
|
||||
if (!wsRow.rows.length) {
|
||||
return NextResponse.json({ error: "Workspace 不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
// 检查用户是否存在
|
||||
const userRow = await query<{ uid: string }>(`SELECT uid FROM "user" WHERE uid = $1`, [body.userId]);
|
||||
if (!userRow.rows.length) {
|
||||
return NextResponse.json({ error: "用户不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
// 检查是否已是成员
|
||||
const exist = await query(
|
||||
`SELECT id FROM workspace_membership WHERE workspace_id = $1 AND user_id = $2`,
|
||||
[id, body.userId]
|
||||
);
|
||||
if (exist.rows.length) {
|
||||
return NextResponse.json({ error: "该用户已是成员" }, { status: 409 });
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO workspace_membership (workspace_id, user_id, role, status, joined_at)
|
||||
VALUES ($1, $2, $3, 'active', NOW())
|
||||
RETURNING id`,
|
||||
[id, body.userId, role]
|
||||
);
|
||||
|
||||
await createAuditLog({
|
||||
userId: adminUserId,
|
||||
username: adminUsername,
|
||||
action: "create",
|
||||
resource: "workspace_member",
|
||||
resourceId: String(result.rows[0]?.id),
|
||||
requestParams: { workspaceId: id, userId: body.userId, role },
|
||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||
userAgent: req.headers.get("user-agent") || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, id: result.rows[0]?.id }, { status: 201 });
|
||||
} catch (e) {
|
||||
console.error("Add workspace member error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
65
admin/src/app/api/platform/workspaces/[id]/route.ts
Normal file
65
admin/src/app/api/platform/workspaces/[id]/route.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const [workspace, members, projects, billing] = await Promise.all([
|
||||
query(
|
||||
`SELECT w.*, wb.balance, wb.currency, wb.monthly_quota, wb.total_spent
|
||||
FROM workspace w
|
||||
LEFT JOIN workspace_billing wb ON wb.workspace_id = w.id
|
||||
WHERE w.id = $1 AND w.deleted_at IS NULL`,
|
||||
[id]
|
||||
),
|
||||
query(
|
||||
`SELECT wm.id, u.uid, u.username, u.display_name, u.avatar_url,
|
||||
wm.role, wm.status, wm.joined_at
|
||||
FROM workspace_membership wm
|
||||
JOIN "user" u ON u.uid = wm.user_id
|
||||
WHERE wm.workspace_id = $1
|
||||
ORDER BY wm.role = 'owner' DESC, wm.role = 'admin' DESC, wm.joined_at ASC`,
|
||||
[id]
|
||||
),
|
||||
query(
|
||||
`SELECT p.id, p.name, p.is_public, p.created_at,
|
||||
COUNT(DISTINCT pm.user_uuid)::int as member_count
|
||||
FROM project p
|
||||
LEFT JOIN project_members pm ON pm.project_uuid = p.id
|
||||
WHERE p.workspace_id = $1
|
||||
GROUP BY p.id
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT 50`,
|
||||
[id]
|
||||
),
|
||||
query(
|
||||
`SELECT reason, amount, extra as description, created_at
|
||||
FROM workspace_billing_history
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50`,
|
||||
[id]
|
||||
),
|
||||
]);
|
||||
|
||||
if (!workspace.rows.length) {
|
||||
return NextResponse.json({ error: "Workspace 不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
workspace: workspace.rows[0],
|
||||
members: members.rows,
|
||||
projects: projects.rows,
|
||||
billingHistory: billing.rows,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Workspace detail error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
114
admin/src/app/api/platform/workspaces/route.ts
Normal file
114
admin/src/app/api/platform/workspaces/route.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db";
|
||||
import { createAuditLog } from "@/lib/log";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = req.nextUrl;
|
||||
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
|
||||
const pageSize = Math.max(1, parseInt(searchParams.get("pageSize") || "20", 10));
|
||||
const search = searchParams.get("search") || "";
|
||||
const plan = searchParams.get("plan") || "";
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
let whereClause = `WHERE w.deleted_at IS NULL`;
|
||||
const params: unknown[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (search) {
|
||||
whereClause += ` AND (w.name ILIKE $${idx} OR w.slug ILIKE $${idx})`;
|
||||
params.push(`%${search}%`);
|
||||
idx++;
|
||||
}
|
||||
if (plan) {
|
||||
whereClause += ` AND w.plan = $${idx}`;
|
||||
params.push(plan);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const countResult = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) FROM workspace w ${whereClause}`,
|
||||
params
|
||||
);
|
||||
const total = parseInt(countResult.rows[0]?.count || "0", 10);
|
||||
|
||||
const limitParam = idx++;
|
||||
const offsetParam = idx++;
|
||||
const rows = await query(
|
||||
`SELECT
|
||||
w.id, w.slug, w.name, w.plan, w.description,
|
||||
w.billing_email, w.created_at, w.updated_at,
|
||||
wb.balance, wb.currency,
|
||||
COUNT(DISTINCT wm.user_id)::int as member_count,
|
||||
COUNT(DISTINCT p.id)::int as project_count
|
||||
FROM workspace w
|
||||
LEFT JOIN workspace_billing wb ON wb.workspace_id = w.id
|
||||
LEFT JOIN workspace_membership wm ON wm.workspace_id = w.id
|
||||
LEFT JOIN project p ON p.workspace_id = w.id
|
||||
${whereClause}
|
||||
GROUP BY w.id, wb.balance, wb.currency
|
||||
ORDER BY w.created_at DESC
|
||||
LIMIT $${limitParam} OFFSET $${offsetParam}`,
|
||||
[...params, pageSize, offset]
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
workspaces: rows.rows,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("List workspaces error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as {
|
||||
ids?: string[];
|
||||
plan?: string;
|
||||
};
|
||||
const { ids = [], plan } = body;
|
||||
|
||||
if (!ids.length) {
|
||||
return NextResponse.json({ error: "至少选择一个 Workspace" }, { status: 400 });
|
||||
}
|
||||
if (!plan) {
|
||||
return NextResponse.json({ error: "plan 不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const validPlans = ["free", "starter", "pro", "enterprise"];
|
||||
if (!validPlans.includes(plan)) {
|
||||
return NextResponse.json({ error: "无效的计划类型" }, { status: 400 });
|
||||
}
|
||||
|
||||
const placeholders = ids.map((_, i) => `$${i + 1}`).join(", ");
|
||||
await query(
|
||||
`UPDATE workspace SET plan = $${ids.length + 1}, updated_at = NOW() WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
|
||||
[...ids, plan]
|
||||
);
|
||||
|
||||
const { searchParams } = req.headers.get("referer") ? new URL(req.headers.get("referer")!) : new URL("http://localhost/");
|
||||
const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10);
|
||||
const adminUsername = req.headers.get("x-admin-username") || "unknown";
|
||||
await createAuditLog({
|
||||
userId: adminUserId,
|
||||
username: adminUsername,
|
||||
action: "update",
|
||||
resource: "workspace_batch_plan",
|
||||
resourceId: `batch(${ids.length})`,
|
||||
requestParams: { ids, plan },
|
||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||
userAgent: req.headers.get("user-agent") || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, updated: ids.length });
|
||||
} catch (e) {
|
||||
console.error("Batch update workspace plan error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
100
admin/src/app/api/roles/[id]/route.ts
Normal file
100
admin/src/app/api/roles/[id]/route.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
getRoleById,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
getRolePermissions,
|
||||
setRolePermissions,
|
||||
} from "@/lib/rbac";
|
||||
import { createAuditLog } from "@/lib/log";
|
||||
|
||||
function getAuthInfo(req: NextRequest) {
|
||||
return {
|
||||
userId: parseInt(req.headers.get("x-admin-user-id") || "0", 10),
|
||||
username: req.headers.get("x-admin-username") || "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const roleId = parseInt(id, 10);
|
||||
const role = await getRoleById(roleId);
|
||||
if (!role) return NextResponse.json({ error: "角色不存在" }, { status: 404 });
|
||||
const permissions = await getRolePermissions(roleId);
|
||||
return NextResponse.json({ ...role, permissions });
|
||||
} catch (e) {
|
||||
console.error("Get role error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const roleId = parseInt(id, 10);
|
||||
const body = await req.json() as {
|
||||
name?: string;
|
||||
description?: string;
|
||||
permissionIds?: number[];
|
||||
};
|
||||
|
||||
const role = await updateRole(roleId, body.name || "", body.description);
|
||||
if (!role) return NextResponse.json({ error: "角色不存在" }, { status: 404 });
|
||||
|
||||
if (body.permissionIds !== undefined) {
|
||||
await setRolePermissions(roleId, body.permissionIds);
|
||||
}
|
||||
|
||||
const { userId, username } = getAuthInfo(req);
|
||||
await createAuditLog({
|
||||
userId,
|
||||
username,
|
||||
action: "update",
|
||||
resource: "admin_role",
|
||||
resourceId: id,
|
||||
requestParams: body,
|
||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||
userAgent: req.headers.get("user-agent") || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(role);
|
||||
} catch (e) {
|
||||
console.error("Update role error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const roleId = parseInt(id, 10);
|
||||
const ok = await deleteRole(roleId);
|
||||
if (!ok) return NextResponse.json({ error: "角色不存在" }, { status: 404 });
|
||||
|
||||
const { userId, username } = getAuthInfo(req);
|
||||
await createAuditLog({
|
||||
userId,
|
||||
username,
|
||||
action: "delete",
|
||||
resource: "admin_role",
|
||||
resourceId: id,
|
||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||
userAgent: req.headers.get("user-agent") || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (e) {
|
||||
console.error("Delete role error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
66
admin/src/app/api/roles/route.ts
Normal file
66
admin/src/app/api/roles/route.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
listRoles,
|
||||
createRole,
|
||||
getRolePermissions,
|
||||
setRolePermissions,
|
||||
} from "@/lib/rbac";
|
||||
import { createAuditLog } from "@/lib/log";
|
||||
|
||||
function getAuthInfo(req: NextRequest) {
|
||||
return {
|
||||
userId: parseInt(req.headers.get("x-admin-user-id") || "0", 10),
|
||||
username: req.headers.get("x-admin-username") || "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const roles = await listRoles();
|
||||
return NextResponse.json({ roles });
|
||||
} catch (e) {
|
||||
console.error("List roles error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as {
|
||||
name?: string;
|
||||
description?: string;
|
||||
permissionIds?: number[];
|
||||
};
|
||||
const { name = "", description = "" } = body;
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: "角色名称不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const role = await createRole(name, description);
|
||||
|
||||
if (body.permissionIds?.length) {
|
||||
await setRolePermissions(role.id, body.permissionIds);
|
||||
}
|
||||
|
||||
const { userId, username } = getAuthInfo(req);
|
||||
await createAuditLog({
|
||||
userId,
|
||||
username,
|
||||
action: "create",
|
||||
resource: "admin_role",
|
||||
resourceId: String(role.id),
|
||||
requestParams: { name, description },
|
||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||
userAgent: req.headers.get("user-agent") || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(role, { status: 201 });
|
||||
} catch (e: unknown) {
|
||||
if ((e as { code?: string }).code === "23505") {
|
||||
return NextResponse.json({ error: "角色名已存在" }, { status: 409 });
|
||||
}
|
||||
console.error("Create role error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
50
admin/src/app/api/sessions/route.ts
Normal file
50
admin/src/app/api/sessions/route.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getOnlineSessions } from "@/lib/redis";
|
||||
import { createAuditLog } from "@/lib/log";
|
||||
|
||||
function getAuthInfo(req: NextRequest) {
|
||||
return {
|
||||
userId: parseInt(req.headers.get("x-admin-user-id") || "0", 10),
|
||||
username: req.headers.get("x-admin-username") || "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const sessions = await getOnlineSessions();
|
||||
return NextResponse.json({ sessions });
|
||||
} catch (e) {
|
||||
console.error("Get sessions error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as { sessionId?: string };
|
||||
const { sessionId } = body;
|
||||
|
||||
if (!sessionId) {
|
||||
return NextResponse.json({ error: "sessionId 不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { kickSession } = await import("@/lib/redis");
|
||||
const ok = await kickSession(sessionId);
|
||||
|
||||
const { userId, username } = getAuthInfo(req);
|
||||
await createAuditLog({
|
||||
userId,
|
||||
username,
|
||||
action: "delete",
|
||||
resource: "admin_session",
|
||||
resourceId: sessionId.slice(0, 8) + "...",
|
||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||
userAgent: req.headers.get("user-agent") || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: ok });
|
||||
} catch (e) {
|
||||
console.error("Delete session error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
119
admin/src/app/api/users/[id]/route.ts
Normal file
119
admin/src/app/api/users/[id]/route.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
getUserById,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
getUserRoles,
|
||||
setUserRoles,
|
||||
} from "@/lib/rbac";
|
||||
import { hashPassword } from "@/lib/auth";
|
||||
import { createAuditLog } from "@/lib/log";
|
||||
|
||||
function getAuthInfo(req: NextRequest) {
|
||||
return {
|
||||
userId: parseInt(req.headers.get("x-admin-user-id") || "0", 10),
|
||||
username: req.headers.get("x-admin-username") || "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const userId = parseInt(id, 10);
|
||||
const user = await getUserById(userId);
|
||||
if (!user) return NextResponse.json({ error: "用户不存在" }, { status: 404 });
|
||||
|
||||
// 不返回密码哈希
|
||||
const { password_hash: _, ...safeUser } = user;
|
||||
const roles = await getUserRoles(userId);
|
||||
|
||||
return NextResponse.json({ ...safeUser, roles });
|
||||
} catch (e) {
|
||||
console.error("Get user error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const userId = parseInt(id, 10);
|
||||
const body = await req.json() as {
|
||||
password?: string;
|
||||
isActive?: boolean;
|
||||
roleIds?: number[];
|
||||
};
|
||||
|
||||
const passwordHash = body.password
|
||||
? await hashPassword(body.password)
|
||||
: undefined;
|
||||
|
||||
const user = await updateUser(userId, {
|
||||
passwordHash,
|
||||
isActive: body.isActive,
|
||||
});
|
||||
|
||||
if (!user) return NextResponse.json({ error: "用户不存在" }, { status: 404 });
|
||||
|
||||
if (body.roleIds !== undefined) {
|
||||
await setUserRoles(userId, body.roleIds);
|
||||
}
|
||||
|
||||
const { userId: actorId, username: actorName } = getAuthInfo(req);
|
||||
await createAuditLog({
|
||||
userId: actorId,
|
||||
username: actorName,
|
||||
action: "update",
|
||||
resource: "admin_user",
|
||||
resourceId: id,
|
||||
requestParams: body,
|
||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||
userAgent: req.headers.get("user-agent") || undefined,
|
||||
});
|
||||
|
||||
const { password_hash: _, ...safeUser } = user;
|
||||
return NextResponse.json(safeUser);
|
||||
} catch (e) {
|
||||
console.error("Update user error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const userId = parseInt(id, 10);
|
||||
|
||||
if (userId === -1) {
|
||||
return NextResponse.json({ error: "无法删除超级管理员" }, { status: 400 });
|
||||
}
|
||||
|
||||
const ok = await deleteUser(userId);
|
||||
if (!ok) return NextResponse.json({ error: "用户不存在" }, { status: 404 });
|
||||
|
||||
const { userId: actorId, username: actorName } = getAuthInfo(req);
|
||||
await createAuditLog({
|
||||
userId: actorId,
|
||||
username: actorName,
|
||||
action: "delete",
|
||||
resource: "admin_user",
|
||||
resourceId: id,
|
||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||
userAgent: req.headers.get("user-agent") || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (e) {
|
||||
console.error("Delete user error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
76
admin/src/app/api/users/route.ts
Normal file
76
admin/src/app/api/users/route.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
listUsers,
|
||||
createUser,
|
||||
setUserRoles,
|
||||
} from "@/lib/rbac";
|
||||
import { createAuditLog } from "@/lib/log";
|
||||
import { hashPassword } from "@/lib/auth";
|
||||
|
||||
function getAuthInfo(req: NextRequest) {
|
||||
return {
|
||||
userId: parseInt(req.headers.get("x-admin-user-id") || "0", 10),
|
||||
username: req.headers.get("x-admin-username") || "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = req.nextUrl;
|
||||
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
|
||||
const pageSize = Math.max(1, parseInt(searchParams.get("pageSize") || "20", 10));
|
||||
const search = searchParams.get("search") || undefined;
|
||||
|
||||
const result = await listUsers({ page, pageSize, search });
|
||||
return NextResponse.json(result);
|
||||
} catch (e) {
|
||||
console.error("List users error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as {
|
||||
username?: string;
|
||||
password?: string;
|
||||
roleIds?: number[];
|
||||
};
|
||||
const { username = "", password = "" } = body;
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: "用户名和密码不能为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return NextResponse.json({ error: "密码长度至少6位" }, { status: 400 });
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const user = await createUser(username, passwordHash);
|
||||
|
||||
if (body.roleIds?.length) {
|
||||
await setUserRoles(user.id, body.roleIds);
|
||||
}
|
||||
|
||||
const { userId, username: actorName } = getAuthInfo(req);
|
||||
await createAuditLog({
|
||||
userId,
|
||||
username: actorName,
|
||||
action: "create",
|
||||
resource: "admin_user",
|
||||
resourceId: String(user.id),
|
||||
requestParams: { username },
|
||||
ipAddress: req.headers.get("x-forwarded-for") || undefined,
|
||||
userAgent: req.headers.get("user-agent") || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(user, { status: 201 });
|
||||
} catch (e: unknown) {
|
||||
if ((e as { code?: string }).code === "23505") {
|
||||
return NextResponse.json({ error: "用户名已存在" }, { status: 409 });
|
||||
}
|
||||
console.error("Create user error:", e);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
25
admin/src/app/dashboard/layout.tsx
Normal file
25
admin/src/app/dashboard/layout.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import Sidebar, { useAdminAuth } from "@/components/admin/Sidebar";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { user, loading, handleLogout } = useAdminAuth();
|
||||
|
||||
return (
|
||||
<div className="admin-layout">
|
||||
<Sidebar user={user} loading={loading} onLogout={handleLogout} />
|
||||
<main className="admin-main">
|
||||
{!user && !loading && (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%" }}>
|
||||
<div className="loading"><span>加载中...</span></div>
|
||||
</div>
|
||||
)}
|
||||
{(!loading || user) && children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
246
admin/src/app/dashboard/page.tsx
Normal file
246
admin/src/app/dashboard/page.tsx
Normal file
@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import LineChart from "@/components/admin/LineChart";
|
||||
|
||||
interface Stats {
|
||||
userCount: number;
|
||||
workspaceCount: number;
|
||||
projectCount: number;
|
||||
roomCount: number;
|
||||
}
|
||||
|
||||
interface ActivityStats {
|
||||
dau: Array<{ date: string; dau: number }>;
|
||||
mau: number;
|
||||
totalLogins: number;
|
||||
last24h: number;
|
||||
}
|
||||
|
||||
interface RecentUser {
|
||||
uid: string;
|
||||
username: string;
|
||||
display_name: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface RecentWorkspace {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
plan: string;
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
interface RecentProject {
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceName: string | null;
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
interface PlanDist {
|
||||
plan: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const PLAN_LABELS: Record<string, string> = {
|
||||
free: "免费", starter: "入门", pro: "专业", enterprise: "企业", unknown: "未知",
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [recentUsers, setRecentUsers] = useState<RecentUser[]>([]);
|
||||
const [recentWorkspaces, setRecentWorkspaces] = useState<RecentWorkspace[]>([]);
|
||||
const [recentProjects, setRecentProjects] = useState<RecentProject[]>([]);
|
||||
const [planDistribution, setPlanDistribution] = useState<PlanDist[]>([]);
|
||||
const [activity, setActivity] = useState<ActivityStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch("/api/platform/stats").then((r) => r.json()),
|
||||
fetch("/api/platform/activity-stats").then((r) => r.json()),
|
||||
])
|
||||
.then(([statsData, activityData]) => {
|
||||
if (statsData.stats) setStats(statsData.stats);
|
||||
if (statsData.recentUsers) setRecentUsers(statsData.recentUsers);
|
||||
if (statsData.recentWorkspaces) setRecentWorkspaces(statsData.recentWorkspaces);
|
||||
if (statsData.recentProjects) setRecentProjects(statsData.recentProjects);
|
||||
if (statsData.planDistribution) setPlanDistribution(statsData.planDistribution);
|
||||
if (activityData.dau) setActivity(activityData);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const totalWorkspaces = planDistribution.reduce((sum, p) => sum + p.count, 0);
|
||||
|
||||
return (
|
||||
<div className="admin-content">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">仪表盘</h1>
|
||||
<p className="page-subtitle">平台概览</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">加载中...</div>
|
||||
) : stats ? (
|
||||
<>
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.userCount.toLocaleString()}</div>
|
||||
<div className="stat-label">用户总数</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.workspaceCount.toLocaleString()}</div>
|
||||
<div className="stat-label">Workspace 总数</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.projectCount.toLocaleString()}</div>
|
||||
<div className="stat-label">项目总数</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.roomCount.toLocaleString()}</div>
|
||||
<div className="stat-label">聊天室总数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DAU Trend Chart */}
|
||||
{activity && activity.dau.length > 0 && (
|
||||
<div className="card" style={{ marginBottom: "16px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "16px" }}>
|
||||
<h2 style={{ fontSize: "16px", fontWeight: 600 }}>DAU 趋势(近30天)</h2>
|
||||
<div style={{ display: "flex", gap: "24px", fontSize: "13px" }}>
|
||||
<span>MAU: <strong>{activity.mau}</strong></span>
|
||||
<span>总登录: <strong>{activity.totalLogins.toLocaleString()}</strong></span>
|
||||
<span>近24h: <strong>{activity.last24h}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<LineChart
|
||||
data={activity.dau.map((d) => ({ date: d.date, value: d.dau }))}
|
||||
label="DAU"
|
||||
color="#6366f1"
|
||||
width={700}
|
||||
height={180}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Workspace Plan Distribution */}
|
||||
{planDistribution.length > 0 && (
|
||||
<div className="card" style={{ marginBottom: "16px" }}>
|
||||
<h2 style={{ fontSize: "16px", fontWeight: 600, marginBottom: "16px" }}>Workspace 计划分布</h2>
|
||||
<div style={{ display: "flex", gap: "16px", flexWrap: "wrap" }}>
|
||||
{planDistribution.map((p) => (
|
||||
<div key={p.plan} style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<span className="badge badge-neutral">{PLAN_LABELS[p.plan] || p.plan}</span>
|
||||
<span style={{ fontSize: "14px", fontWeight: 600 }}>{p.count}</span>
|
||||
<span style={{ fontSize: "12px", color: "#737373" }}>
|
||||
({totalWorkspaces > 0 ? ((p.count / totalWorkspaces) * 100).toFixed(1) : 0}%)
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "16px", marginBottom: "16px" }}>
|
||||
<div className="card">
|
||||
<h2 style={{ fontSize: "16px", fontWeight: 600, marginBottom: "16px" }}>
|
||||
近期注册用户
|
||||
</h2>
|
||||
{recentUsers.length === 0 ? (
|
||||
<div className="empty-state">暂无数据</div>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th>
|
||||
<th>显示名</th>
|
||||
<th>注册时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentUsers.map((u) => (
|
||||
<tr key={u.uid}>
|
||||
<td>{u.username}</td>
|
||||
<td>{u.display_name || "-"}</td>
|
||||
<td>{format(new Date(u.created_at), "yyyy-MM-dd")}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 style={{ fontSize: "16px", fontWeight: 600, marginBottom: "16px" }}>
|
||||
近期 Workspace
|
||||
</h2>
|
||||
{recentWorkspaces.length === 0 ? (
|
||||
<div className="empty-state">暂无数据</div>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>计划</th>
|
||||
<th>成员</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentWorkspaces.map((w) => (
|
||||
<tr key={w.id}>
|
||||
<td>{w.name}</td>
|
||||
<td>
|
||||
<span className="badge badge-neutral">{PLAN_LABELS[w.plan] || w.plan}</span>
|
||||
</td>
|
||||
<td>{w.memberCount}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recentProjects.length > 0 && (
|
||||
<div className="card">
|
||||
<h2 style={{ fontSize: "16px", fontWeight: 600, marginBottom: "16px" }}>
|
||||
近期项目
|
||||
</h2>
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>所属 Workspace</th>
|
||||
<th>成员</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentProjects.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td>{p.name}</td>
|
||||
<td>{p.workspaceName || <span style={{ color: "#a3a3a3" }}>无</span>}</td>
|
||||
<td>{p.memberCount}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="alert alert-error">无法加载统计数据</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
752
admin/src/app/globals.css
Normal file
752
admin/src/app/globals.css
Normal file
@ -0,0 +1,752 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme inline {
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* ========== 黑白极简风格 ========== */
|
||||
|
||||
/* 重置基础样式 */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: #ffffff;
|
||||
color: #171717;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background: #0a0a0a;
|
||||
color: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
/* 链接 */
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 按钮基础 */
|
||||
button {
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* 输入框 */
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ========== 布局工具 ========== */
|
||||
|
||||
.admin-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid #e5e5e5;
|
||||
background: #fafafa;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.admin-sidebar {
|
||||
border-right-color: #27272a;
|
||||
background: #09090b;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-topbar {
|
||||
height: 56px;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.admin-topbar {
|
||||
border-bottom-color: #27272a;
|
||||
background: #09090b;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* ========== 页面容器 ========== */
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 14px;
|
||||
color: #737373;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ========== 卡片 ========== */
|
||||
|
||||
.card {
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.card {
|
||||
border-color: #27272a;
|
||||
background: #18181b;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 表格 ========== */
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #737373;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
border-bottom-color: #27272a;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: #09090b;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
}
|
||||
|
||||
.data-table tr:hover td {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.data-table tr:hover td {
|
||||
background: #18181b;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 按钮 ========== */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #171717;
|
||||
color: #ffffff;
|
||||
border-color: #171717;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #404040;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #ffffff;
|
||||
color: #171717;
|
||||
border-color: #e5e5e5;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ffffff;
|
||||
color: #dc2626;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ========== 表单 ========== */
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background: #ffffff;
|
||||
color: #171717;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #171717;
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.form-input {
|
||||
background: #18181b;
|
||||
border-color: #27272a;
|
||||
color: #ededed;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
.form-error {
|
||||
font-size: 12px;
|
||||
color: #dc2626;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ========== 徽章 ========== */
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.badge-neutral {
|
||||
background: #f4f4f5;
|
||||
color: #52525b;
|
||||
border: 1px solid #e4e4e7;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.badge-success {
|
||||
background: #14532d;
|
||||
color: #bbf7d0;
|
||||
border-color: #166534;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: #7f1d1d;
|
||||
color: #fecaca;
|
||||
border-color: #991b1b;
|
||||
}
|
||||
|
||||
.badge-neutral {
|
||||
background: #27272a;
|
||||
color: #a1a1aa;
|
||||
border-color: #3f3f46;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 分页 ========== */
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: 14px;
|
||||
color: #737373;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* ========== 登录页 ========== */
|
||||
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.login-container {
|
||||
background: #09090b;
|
||||
}
|
||||
}
|
||||
|
||||
.login-box {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 14px;
|
||||
color: #737373;
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 20px 0;
|
||||
font-size: 12px;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.login-divider::before,
|
||||
.login-divider::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: #e5e5e5;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.login-divider::before,
|
||||
.login-divider::after {
|
||||
background: #27272a;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 统计卡片 ========== */
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.stat-card {
|
||||
border-color: #27272a;
|
||||
background: #18181b;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #737373;
|
||||
}
|
||||
|
||||
/* ========== 空状态 ========== */
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: #737373;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* ========== 加载 ========== */
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
color: #737373;
|
||||
}
|
||||
|
||||
/* ========== 模态框 ========== */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.modal {
|
||||
background: #18181b;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.modal-footer {
|
||||
border-top-color: #27272a;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 侧边栏菜单 ========== */
|
||||
|
||||
.sidebar-logo {
|
||||
padding: 20px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.sidebar-logo {
|
||||
border-bottom-color: #27272a;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
padding: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.sidebar-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: #a3a3a3;
|
||||
padding: 8px 12px 4px;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: #52525b;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.sidebar-link:hover {
|
||||
background: #f4f4f5;
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
.sidebar-link.active {
|
||||
background: #171717;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.sidebar-link {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
.sidebar-link:hover {
|
||||
background: #27272a;
|
||||
color: #ededed;
|
||||
}
|
||||
|
||||
.sidebar-link.active {
|
||||
background: #ededed;
|
||||
color: #09090b;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== Toast / Alert ========== */
|
||||
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.alert-error {
|
||||
background: #450a0a;
|
||||
color: #fca5a5;
|
||||
border-color: #7f1d1d;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #052e16;
|
||||
color: #86efac;
|
||||
border-color: #14532d;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 复选框组 ========== */
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.checkbox-group {
|
||||
border-color: #27272a;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.checkbox-item:hover {
|
||||
background: #f4f4f5;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.checkbox-item:hover {
|
||||
background: #27272a;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ========== 搜索栏 ========== */
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
/* ========== 信息表格 ========== */
|
||||
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
padding: 8px 0;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.info-table td {
|
||||
border-bottom-color: #27272a;
|
||||
}
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-weight: 500;
|
||||
color: #737373;
|
||||
width: 100px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ========== 卡片标题 ========== */
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.card-title {
|
||||
border-bottom-color: #27272a;
|
||||
}
|
||||
}
|
||||
21
admin/src/app/layout.tsx
Normal file
21
admin/src/app/layout.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Admin - 管理后台",
|
||||
description: "管理系统后台",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className="min-h-screen bg-white text-black dark:bg-neutral-950 dark:text-white">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
121
admin/src/app/login/page.tsx
Normal file
121
admin/src/app/login/page.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [oidcEnabled, setOidcEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 检查是否已登录
|
||||
fetch("/api/auth/me")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.user) {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [router]);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || "登录失败");
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/dashboard");
|
||||
} catch {
|
||||
setError("网络错误");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleOidcLogin() {
|
||||
window.location.href = "/api/auth/oidc/authorize";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-box card">
|
||||
<h1 className="login-title">Admin</h1>
|
||||
<p className="login-subtitle">管理后台登录</p>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="username">
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="输入用户名"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="password">
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
className="form-input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="输入密码"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
style={{ width: "100%" }}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "登录中..." : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="login-divider">或</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ width: "100%" }}
|
||||
onClick={handleOidcLogin}
|
||||
>
|
||||
通过 OpenID Connect 登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
admin/src/app/page.tsx
Normal file
10
admin/src/app/page.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
async function checkSession() {
|
||||
// This will be called on the server; client will redirect
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function RootPage() {
|
||||
redirect("/login");
|
||||
}
|
||||
124
admin/src/app/platform/audit/page.tsx
Normal file
124
admin/src/app/platform/audit/page.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface AuditLog {
|
||||
source: "user_activity" | "project_audit";
|
||||
id: number;
|
||||
actor_uid: string;
|
||||
action: string;
|
||||
resource: string | null;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
login: "登录", logout: "登出", register: "注册",
|
||||
create: "创建", update: "更新", delete: "删除",
|
||||
password_change: "改密", password_reset: "密码重置",
|
||||
transfer: "转移", rename: "重命名", settings_change: "设置变更",
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
user_activity: "用户活动",
|
||||
project_audit: "项目审计",
|
||||
};
|
||||
|
||||
export default function PlatformAuditPage() {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [source, setSource] = useState("");
|
||||
const [action, setAction] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const pageSize = 30;
|
||||
|
||||
useEffect(() => { loadLogs(); }, [page, source, action]);
|
||||
|
||||
function loadLogs() {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||
if (source) params.set("source", source);
|
||||
if (action) params.set("action", action);
|
||||
fetch(`/api/platform/audit-logs?${params}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setLogs(data.logs || []);
|
||||
setTotal(data.total || 0);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div className="admin-content">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">平台审计日志</h1>
|
||||
<p className="page-subtitle">平台用户活动 + 项目操作记录(来源:user_activity_log + project_audit_log)</p>
|
||||
</div>
|
||||
<div className="toolbar">
|
||||
<select className="form-input" style={{ maxWidth: "160px" }}
|
||||
value={source} onChange={(e) => { setSource(e.target.value); setPage(1); }}>
|
||||
<option value="">全部来源</option>
|
||||
<option value="user">用户活动</option>
|
||||
<option value="project">项目审计</option>
|
||||
</select>
|
||||
<input type="text" className="form-input search-input" placeholder="搜索操作类型..."
|
||||
value={action} onChange={(e) => { setAction(e.target.value); setPage(1); }} />
|
||||
</div>
|
||||
<div className="card">
|
||||
{loading ? <div className="loading">加载中...</div> : logs.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">📋</div>
|
||||
<p>暂无审计记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>来源</th>
|
||||
<th>操作</th>
|
||||
<th>用户 UID</th>
|
||||
<th>资源</th>
|
||||
<th>IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log) => (
|
||||
<tr key={`${log.source}-${log.id}`}>
|
||||
<td style={{ fontSize: "12px" }}>{format(new Date(log.created_at), "yyyy-MM-dd HH:mm:ss")}</td>
|
||||
<td>
|
||||
<span className={`badge ${log.source === "user_activity" ? "badge-success" : "badge-neutral"}`}>
|
||||
{SOURCE_LABELS[log.source] || log.source}
|
||||
</span>
|
||||
</td>
|
||||
<td><span className="badge badge-neutral">{ACTION_LABELS[log.action] || log.action}</span></td>
|
||||
<td style={{ fontSize: "12px", fontFamily: "monospace" }}>{log.actor_uid || "—"}</td>
|
||||
<td style={{ fontSize: "11px", maxWidth: "160px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
title={log.resource || ""}>
|
||||
{log.resource || "—"}
|
||||
</td>
|
||||
<td style={{ fontSize: "11px" }}>{log.ip_address || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="pagination">
|
||||
<span className="pagination-info">第 {page} / {totalPages} 页,共 {total} 条</span>
|
||||
<button className="btn btn-secondary btn-sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||||
<button className="btn btn-secondary btn-sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
admin/src/app/platform/layout.tsx
Normal file
25
admin/src/app/platform/layout.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import Sidebar, { useAdminAuth } from "@/components/admin/Sidebar";
|
||||
|
||||
export default function PlatformLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { user, loading, handleLogout } = useAdminAuth();
|
||||
|
||||
return (
|
||||
<div className="admin-layout">
|
||||
<Sidebar user={user} loading={loading} onLogout={handleLogout} />
|
||||
<main className="admin-main">
|
||||
{!user && !loading && (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%" }}>
|
||||
<div className="loading"><span>加载中...</span></div>
|
||||
</div>
|
||||
)}
|
||||
{(!loading || user) && children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
admin/src/app/platform/sessions/page.tsx
Normal file
159
admin/src/app/platform/sessions/page.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface SessionInfo {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
username: string | null;
|
||||
workspaceId: string | null;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
||||
export default function PlatformSessionsPage() {
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [kicking, setKicking] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => { loadSessions(); }, []);
|
||||
async function loadSessions() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/platform/sessions");
|
||||
if (!res.ok) {
|
||||
setSessions([]);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setSessions(data.sessions || []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setSessions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKickSession(sessionId: string) {
|
||||
if (!confirm("确定强制下线该会话吗?")) return;
|
||||
setKicking(sessionId);
|
||||
try {
|
||||
await fetch("/api/platform/sessions", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
loadSessions();
|
||||
} finally { setKicking(null); }
|
||||
}
|
||||
|
||||
// Group by user
|
||||
const byUser = sessions.reduce<Record<string, SessionInfo[]>>((acc, s) => {
|
||||
const key = s.userId || "unknown";
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(s);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="admin-content">
|
||||
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div>
|
||||
<h1 className="page-title">平台用户在线会话</h1>
|
||||
<p className="page-subtitle">
|
||||
共 {sessions.length} 个活跃会话,涉及 {Object.keys(byUser).length} 个用户
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn btn-secondary" onClick={loadSessions}>刷新</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">加载中...</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="card">
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">◐</div>
|
||||
<p>当前没有在线的平台用户会话</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
Object.entries(byUser).map(([userId, userSessions]) => (
|
||||
<div key={userId} className="card" style={{ marginBottom: "16px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "12px" }}>
|
||||
<div>
|
||||
<span style={{ fontWeight: 600 }}>用户 UID: {userId}</span>
|
||||
{userSessions[0].username && (
|
||||
<span style={{ marginLeft: "8px", color: "#737373" }}>({userSessions[0].username})</span>
|
||||
)}
|
||||
<span className="badge badge-neutral" style={{ marginLeft: "8px" }}>
|
||||
{userSessions.length} 个会话
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={async () => {
|
||||
if (!confirm(`强制下线用户 ${userId} 的所有会话?`)) return;
|
||||
for (const s of userSessions) {
|
||||
await fetch("/api/platform/sessions", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId: s.sessionId }),
|
||||
});
|
||||
}
|
||||
loadSessions();
|
||||
}}
|
||||
>
|
||||
全部下线
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Session ID</th>
|
||||
<th>登录时间</th>
|
||||
<th>IP</th>
|
||||
<th>User Agent</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{userSessions.map((s) => (
|
||||
<tr key={s.sessionId}>
|
||||
<td>
|
||||
<code style={{ fontSize: "11px", background: "#f4f4f5", padding: "2px 6px", borderRadius: "4px" }}>
|
||||
{s.sessionId.slice(0, 16)}...
|
||||
</code>
|
||||
</td>
|
||||
<td>{s.createdAt ? format(new Date(s.createdAt), "yyyy-MM-dd HH:mm") : "—"}</td>
|
||||
<td style={{ fontSize: "13px" }}>{s.ipAddress || "—"}</td>
|
||||
<td
|
||||
style={{ fontSize: "12px", maxWidth: "280px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
title={s.userAgent || ""}
|
||||
>
|
||||
{s.userAgent || "—"}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
disabled={kicking === s.sessionId}
|
||||
onClick={() => handleKickSession(s.sessionId)}
|
||||
>
|
||||
{kicking === s.sessionId ? "处理中..." : "下线"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
254
admin/src/app/platform/users/page.tsx
Normal file
254
admin/src/app/platform/users/page.tsx
Normal file
@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import Link from "next/link";
|
||||
|
||||
interface PlatformUser {
|
||||
uid: string;
|
||||
username: string;
|
||||
display_name: string | null;
|
||||
avatar_url: string | null;
|
||||
organization: string | null;
|
||||
last_sign_in_at: string | null;
|
||||
created_at: string;
|
||||
is_active: boolean;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export default function PlatformUsersPage() {
|
||||
const [users, setUsers] = useState<PlatformUser[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [batchAction, setBatchAction] = useState<"enable" | "disable" | "">("");
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [editUser, setEditUser] = useState<PlatformUser | null>(null);
|
||||
const [editForm, setEditForm] = useState({ email: "", password: "", displayName: "", organization: "" });
|
||||
const [editLoading, setEditLoading] = useState(false);
|
||||
const [editMsg, setEditMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
const pageSize = 20;
|
||||
|
||||
useEffect(() => { loadUsers(); }, [page, search]);
|
||||
async function loadUsers() {
|
||||
setLoading(true);
|
||||
setSelected(new Set());
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||
if (search) params.set("search", search);
|
||||
const res = await fetch(`/api/platform/users?${params}`);
|
||||
const data = await res.json();
|
||||
setUsers(data.users || []);
|
||||
setTotal(data.total || 0);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
if (selected.size === users.length) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(users.map((u) => u.uid)));
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(uid: string) {
|
||||
const next = new Set(selected);
|
||||
if (next.has(uid)) next.delete(uid);
|
||||
else next.add(uid);
|
||||
setSelected(next);
|
||||
}
|
||||
|
||||
async function handleBatchAction(action: "enable" | "disable") {
|
||||
if (selected.size === 0) return;
|
||||
if (!confirm(`${action === "enable" ? "启用" : "禁用"} ${selected.size} 个用户?`)) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/platform/users", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: Array.from(selected), action }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setSelected(new Set());
|
||||
loadUsers();
|
||||
} else {
|
||||
alert(data.error || "批量操作失败");
|
||||
}
|
||||
} catch {
|
||||
alert("批量操作失败");
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openEdit(u: PlatformUser) {
|
||||
setEditUser(u);
|
||||
setEditForm({ email: "", password: "", displayName: u.display_name || "", organization: u.organization || "" });
|
||||
setEditMsg(null);
|
||||
// 如果当前用户列表没有 email,去 API 获取
|
||||
if (!u.email) {
|
||||
try {
|
||||
const res = await fetch(`/api/platform/users/${u.uid}`);
|
||||
const data = await res.json();
|
||||
if (data.user?.email) {
|
||||
setEditForm(f => ({ ...f, email: data.user.email }));
|
||||
}
|
||||
} catch {}
|
||||
} else {
|
||||
setEditForm(f => ({ ...f, email: u.email || "" }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEdit() {
|
||||
if (!editUser) return;
|
||||
setEditLoading(true);
|
||||
setEditMsg(null);
|
||||
try {
|
||||
const body: Record<string, string> = {};
|
||||
if (editForm.email !== undefined) body.email = editForm.email;
|
||||
if (editForm.password) body.password = editForm.password;
|
||||
if (editForm.displayName !== (editUser.display_name || "")) body.displayName = editForm.displayName;
|
||||
if (editForm.organization !== (editUser.organization || "")) body.organization = editForm.organization;
|
||||
const res = await fetch(`/api/platform/users/${editUser.uid}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setEditMsg({ type: "error", text: data.error || "保存失败" });
|
||||
return;
|
||||
}
|
||||
setEditMsg({ type: "success", text: "保存成功" });
|
||||
setTimeout(() => { setEditUser(null); loadUsers(); }, 1000);
|
||||
} catch {
|
||||
setEditMsg({ type: "error", text: "保存失败" });
|
||||
} finally {
|
||||
setEditLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
const allSelected = users.length > 0 && selected.size === users.length;
|
||||
|
||||
return (
|
||||
<div className="admin-content">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">平台用户管理</h1>
|
||||
<p className="page-subtitle">共 {total} 个平台用户</p>
|
||||
</div>
|
||||
<div className="toolbar">
|
||||
<input type="text" className="form-input search-input" placeholder="搜索用户名或显示名..."
|
||||
value={search} onChange={(e) => { setSearch(e.target.value); setPage(1); }} />
|
||||
{selected.size > 0 && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<span style={{ fontSize: "13px", color: "#737373" }}>已选 {selected.size} 个</span>
|
||||
<button className="btn btn-primary btn-sm" disabled={batchLoading}
|
||||
onClick={() => handleBatchAction("enable")}>
|
||||
{batchLoading ? "处理中..." : "批量启用"}
|
||||
</button>
|
||||
<button className="btn btn-danger btn-sm" disabled={batchLoading}
|
||||
onClick={() => handleBatchAction("disable")}>
|
||||
{batchLoading ? "处理中..." : "批量禁用"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card">
|
||||
{loading ? <div className="loading">加载中...</div> : (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: "36px" }}>
|
||||
<input type="checkbox" checked={allSelected} onChange={toggleAll} />
|
||||
</th>
|
||||
<th>用户名</th><th>显示名</th><th>状态</th><th>组织</th>
|
||||
<th>注册时间</th><th>最后登录</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.uid}>
|
||||
<td>
|
||||
<input type="checkbox" checked={selected.has(u.uid)} onChange={() => toggle(u.uid)} />
|
||||
</td>
|
||||
<td>{u.username}</td>
|
||||
<td>{u.display_name || "-"}</td>
|
||||
<td>
|
||||
<span className={`badge ${u.is_active ? "badge-success" : "badge-danger"}`}>
|
||||
{u.is_active ? "正常" : "禁用"}
|
||||
</span>
|
||||
</td>
|
||||
<td>{u.organization || "-"}</td>
|
||||
<td>{format(new Date(u.created_at), "yyyy-MM-dd")}</td>
|
||||
<td>{u.last_sign_in_at ? format(new Date(u.last_sign_in_at), "yyyy-MM-dd HH:mm") : "从未登录"}</td>
|
||||
<td><button className="btn btn-secondary btn-sm" onClick={() => openEdit(u)}>编辑</button></td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<tr><td colSpan={8} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无数据</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="pagination">
|
||||
<span className="pagination-info">第 {page} / {totalPages} 页,共 {total} 条</span>
|
||||
<button className="btn btn-secondary btn-sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||||
<button className="btn btn-secondary btn-sm" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editUser && (
|
||||
<div className="modal-overlay" onClick={() => setEditUser(null)}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: "480px" }}>
|
||||
<h2 className="modal-title">编辑用户 — {editUser.username}</h2>
|
||||
<div style={{ fontSize: "13px", color: "#737373", marginBottom: "16px" }}>
|
||||
UID: <code>{editUser.uid}</code>
|
||||
</div>
|
||||
{editMsg && (
|
||||
<div className={`alert ${editMsg.type === "error" ? "alert-error" : "alert-success"}`} style={{ marginBottom: "12px" }}>
|
||||
{editMsg.text}
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label className="form-label">邮箱</label>
|
||||
<input className="form-input" type="email" value={editForm.email}
|
||||
onChange={e => setEditForm(f => ({ ...f, email: e.target.value }))}
|
||||
placeholder="user@example.com" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">新密码(留空则不修改)</label>
|
||||
<input className="form-input" type="password" value={editForm.password}
|
||||
onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder="至少6位" minLength={6} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">显示名</label>
|
||||
<input className="form-input" value={editForm.displayName}
|
||||
onChange={e => setEditForm(f => ({ ...f, displayName: e.target.value }))}
|
||||
placeholder="可选" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">组织</label>
|
||||
<input className="form-input" value={editForm.organization}
|
||||
onChange={e => setEditForm(f => ({ ...f, organization: e.target.value }))}
|
||||
placeholder="可选" />
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setEditUser(null)}>取消</button>
|
||||
<button className="btn btn-primary" disabled={editLoading} onClick={handleEdit}>
|
||||
{editLoading ? "保存中..." : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
admin/src/components/admin/LineChart.tsx
Normal file
169
admin/src/components/admin/LineChart.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { format, parseISO } from "date-fns";
|
||||
|
||||
interface DataPoint {
|
||||
date: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface LineChartProps {
|
||||
data: DataPoint[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
color?: string;
|
||||
label: string;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export default function LineChart({
|
||||
data,
|
||||
width = 600,
|
||||
height = 200,
|
||||
color = "#6366f1",
|
||||
label,
|
||||
unit = "",
|
||||
}: LineChartProps) {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div style={{ color: "#737373", fontSize: "13px", textAlign: "center", padding: "32px" }}>
|
||||
暂无数据
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const padding = { top: 16, right: 16, bottom: 36, left: 44 };
|
||||
const chartWidth = width - padding.left - padding.right;
|
||||
const chartHeight = height - padding.top - padding.bottom;
|
||||
|
||||
const values = data.map((d) => d.value);
|
||||
const maxValue = Math.max(...values, 1);
|
||||
const minValue = 0;
|
||||
|
||||
function xScale(i: number) {
|
||||
return padding.left + (i / (data.length - 1 || 1)) * chartWidth;
|
||||
}
|
||||
|
||||
function yScale(v: number) {
|
||||
return padding.top + chartHeight - ((v - minValue) / (maxValue - minValue || 1)) * chartHeight;
|
||||
}
|
||||
|
||||
// Build SVG path
|
||||
const pathD = data
|
||||
.map((d, i) => `${i === 0 ? "M" : "L"} ${xScale(i).toFixed(1)} ${yScale(d.value).toFixed(1)}`)
|
||||
.join(" ");
|
||||
|
||||
// Area path (fill under the line)
|
||||
const areaD =
|
||||
pathD +
|
||||
` L ${xScale(data.length - 1).toFixed(1)} ${(padding.top + chartHeight).toFixed(1)}` +
|
||||
` L ${padding.left} ${(padding.top + chartHeight).toFixed(1)} Z`;
|
||||
|
||||
// Y-axis ticks (5 ticks)
|
||||
const yTicks = Array.from({ length: 5 }, (_, i) => {
|
||||
const v = minValue + ((maxValue - minValue) * i) / 4;
|
||||
return { v, y: yScale(v) };
|
||||
});
|
||||
|
||||
// X-axis ticks (show every nth label based on data length)
|
||||
const step = Math.max(1, Math.floor(data.length / 6));
|
||||
const xTicks = data.filter((_, i) => i % step === 0 || i === data.length - 1);
|
||||
|
||||
const gradientId = `grad-${label.replace(/\s/g, "-")}`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
style={{ display: "block", maxWidth: "100%" }}
|
||||
aria-label={`${label} 趋势图`}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.2} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Y-axis gridlines */}
|
||||
{yTicks.map((t, i) => (
|
||||
<line
|
||||
key={i}
|
||||
x1={padding.left}
|
||||
y1={t.y}
|
||||
x2={width - padding.right}
|
||||
y2={t.y}
|
||||
stroke="#e5e5e5"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Y-axis labels */}
|
||||
{yTicks.map((t, i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={padding.left - 6}
|
||||
y={t.y + 4}
|
||||
textAnchor="end"
|
||||
fontSize={11}
|
||||
fill="#737373"
|
||||
>
|
||||
{Math.round(t.v)}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* X-axis labels */}
|
||||
{xTicks.map((d, i) => {
|
||||
const idx = data.findIndex((x) => x.date === d.date);
|
||||
return (
|
||||
<text
|
||||
key={i}
|
||||
x={xScale(idx)}
|
||||
y={padding.top + chartHeight + 16}
|
||||
textAnchor="middle"
|
||||
fontSize={11}
|
||||
fill="#737373"
|
||||
>
|
||||
{(() => {
|
||||
try {
|
||||
return format(parseISO(d.date), "MM-dd");
|
||||
} catch {
|
||||
return d.date.slice(5);
|
||||
}
|
||||
})()}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Area fill */}
|
||||
<path d={areaD} fill={`url(#${gradientId})`} />
|
||||
|
||||
{/* Line */}
|
||||
<path d={pathD} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" />
|
||||
|
||||
{/* Data points (dots) */}
|
||||
{data.map((d, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={xScale(i)}
|
||||
cy={yScale(d.value)}
|
||||
r={3}
|
||||
fill={color}
|
||||
stroke="white"
|
||||
strokeWidth={1.5}
|
||||
style={{ cursor: "default" }}
|
||||
>
|
||||
<title>
|
||||
{(() => {
|
||||
try {
|
||||
return `${format(parseISO(d.date), "yyyy-MM-dd")}: ${d.value}${unit}`;
|
||||
} catch {
|
||||
return `${d.date}: ${d.value}${unit}`;
|
||||
}
|
||||
})()}
|
||||
</title>
|
||||
</circle>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
124
admin/src/components/admin/Sidebar.tsx
Normal file
124
admin/src/components/admin/Sidebar.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
interface AdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
user: AdminUser | null;
|
||||
loading: boolean;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({ user, loading, onLogout }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
section: "概览",
|
||||
links: [{ href: "/dashboard", label: "仪表盘", icon: "◉" }],
|
||||
},
|
||||
{
|
||||
section: "平台数据",
|
||||
links: [
|
||||
{ href: "/admin/workspaces", label: "Workspace", icon: "◎" },
|
||||
{ href: "/admin/projects", label: "项目", icon: "◻" },
|
||||
{ href: "/admin/rooms", label: "房间", icon: "◫" },
|
||||
{ href: "/admin/repos", label: "仓库", icon: "◈" },
|
||||
{ href: "/admin/ai", label: "AI 模型", icon: "◆" },
|
||||
{ href: "/platform/users", label: "用户管理", icon: "◎" },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: "Admin 管理",
|
||||
links: [
|
||||
{ href: "/admin/users", label: "Admin 用户", icon: "◈" },
|
||||
{ href: "/admin/roles", label: "角色管理", icon: "◇" },
|
||||
{ href: "/admin/permissions", label: "权限管理", icon: "○" },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: "系统",
|
||||
links: [
|
||||
{ href: "/admin/logs", label: "审计日志", icon: "▣" },
|
||||
{ href: "/platform/audit", label: "平台审计", icon: "☰" },
|
||||
{ href: "/admin/sessions", label: "Admin 会话", icon: "◐" },
|
||||
{ href: "/platform/sessions", label: "平台会话", icon: "☀" },
|
||||
{ href: "/admin/api-tokens", label: "API Token", icon: "⚿" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="admin-sidebar">
|
||||
<div className="sidebar-logo">Admin</div>
|
||||
<nav className="sidebar-nav">
|
||||
{navItems.map((section) => (
|
||||
<div key={section.section} className="sidebar-section">
|
||||
<div className="sidebar-section-title">{section.section}</div>
|
||||
{section.links.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`sidebar-link ${pathname === link.href ? "active" : ""}`}
|
||||
>
|
||||
<span>{link.icon}</span>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div style={{ padding: "12px", borderTop: "1px solid #e5e5e5" }}>
|
||||
{loading ? (
|
||||
<div style={{ fontSize: "12px", color: "#737373", textAlign: "center" }}>验证中...</div>
|
||||
) : user ? (
|
||||
<>
|
||||
<div style={{ fontSize: "12px", color: "#737373", marginBottom: "8px" }}>
|
||||
{user.username}
|
||||
</div>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="btn btn-secondary btn-sm"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ fontSize: "12px", color: "#a3a3a3", textAlign: "center" }}>未登录</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAdminAuth() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<AdminUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/auth/me")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.user) setUser(data.user);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
router.push("/login");
|
||||
}
|
||||
|
||||
return { user, loading, handleLogout };
|
||||
}
|
||||
115
admin/src/lib/api-token.ts
Normal file
115
admin/src/lib/api-token.ts
Normal file
@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Admin API Token 管理
|
||||
* 表:admin_api_token(id, name, token_hash, permissions, created_by, created_at, last_used_at, expires_at, is_active)
|
||||
*/
|
||||
import { query } from "./db";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
// Token 前缀(用于识别)
|
||||
const TOKEN_PREFIX = "adm_tok_";
|
||||
|
||||
export interface ApiToken {
|
||||
id: number;
|
||||
name: string;
|
||||
token_prefix: string;
|
||||
permissions: string[];
|
||||
created_by: number;
|
||||
created_at: Date;
|
||||
last_used_at: Date | null;
|
||||
expires_at: Date | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
/** 生成随机 token(返回明文,用于一次显示) */
|
||||
export function generateToken(): string {
|
||||
return TOKEN_PREFIX + randomBytes(24).toString("hex");
|
||||
}
|
||||
|
||||
/** 计算 token 的 SHA-256 哈希 */
|
||||
export async function hashToken(token: string): Promise<string> {
|
||||
const { createHash } = await import("crypto");
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
/** 列出所有 token(不返回 hash) */
|
||||
export async function listApiTokens(): Promise<ApiToken[]> {
|
||||
const result = await query<{
|
||||
id: number;
|
||||
name: string;
|
||||
token_prefix: string;
|
||||
permissions: string[];
|
||||
created_by: number;
|
||||
created_at: Date;
|
||||
last_used_at: Date | null;
|
||||
expires_at: Date | null;
|
||||
is_active: boolean;
|
||||
}>(
|
||||
`SELECT id, name, token_prefix, permissions, created_by, created_at, last_used_at, expires_at, is_active
|
||||
FROM admin_api_token ORDER BY created_at DESC`
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/** 根据 token_hash 验证 token(用于中间件) */
|
||||
export async function verifyToken(token: string): Promise<
|
||||
{ valid: true; tokenId: number; permissions: string[] } |
|
||||
{ valid: false }
|
||||
> {
|
||||
const hash = await hashToken(token);
|
||||
const result = await query<{
|
||||
id: number;
|
||||
token_prefix: string;
|
||||
permissions: string[];
|
||||
is_active: boolean;
|
||||
expires_at: Date | null;
|
||||
last_used_at: Date | null;
|
||||
}>(
|
||||
`SELECT id, token_prefix, permissions, is_active, expires_at, last_used_at
|
||||
FROM admin_api_token WHERE token_hash = $1`,
|
||||
[hash]
|
||||
);
|
||||
|
||||
if (!result.rows.length) return { valid: false };
|
||||
|
||||
const row = result.rows[0];
|
||||
if (!row.is_active) return { valid: false };
|
||||
if (row.expires_at && new Date(row.expires_at) < new Date()) return { valid: false };
|
||||
|
||||
// 更新 last_used_at
|
||||
await query(
|
||||
`UPDATE admin_api_token SET last_used_at = NOW() WHERE id = $1`,
|
||||
[row.id]
|
||||
);
|
||||
|
||||
return { valid: true, tokenId: row.id, permissions: row.permissions || [] };
|
||||
}
|
||||
|
||||
/** 创建 token(返回明文,仅此一次) */
|
||||
export async function createApiToken(params: {
|
||||
name: string;
|
||||
token: string;
|
||||
permissions: string[];
|
||||
createdBy: number;
|
||||
expiresAt?: Date | null;
|
||||
}): Promise<{ token: string; id: number }> {
|
||||
const hash = await hashToken(params.token);
|
||||
const result = await query<{ id: number }>(
|
||||
`INSERT INTO admin_api_token (name, token_hash, token_prefix, permissions, created_by, expires_at, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, true)
|
||||
RETURNING id`,
|
||||
[
|
||||
params.name,
|
||||
hash,
|
||||
TOKEN_PREFIX + params.token.slice(TOKEN_PREFIX.length, TOKEN_PREFIX.length + 8),
|
||||
params.permissions,
|
||||
params.createdBy,
|
||||
params.expiresAt || null,
|
||||
]
|
||||
);
|
||||
return { token: params.token, id: result.rows[0].id };
|
||||
}
|
||||
|
||||
/** 删除 token */
|
||||
export async function deleteApiToken(id: number): Promise<void> {
|
||||
await query(`DELETE FROM admin_api_token WHERE id = $1`, [id]);
|
||||
}
|
||||
343
admin/src/lib/auth.ts
Normal file
343
admin/src/lib/auth.ts
Normal file
@ -0,0 +1,343 @@
|
||||
/**
|
||||
* 认证模块
|
||||
* 支持:
|
||||
* 1. 账号密码登录(环境变量 + 数据库)
|
||||
* 2. OpenID Connect (OIDC)
|
||||
*
|
||||
* Session 存储在 Redis,Cookie 传输
|
||||
*/
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import bcrypt from "bcrypt";
|
||||
import * as jose from "jose";
|
||||
import {
|
||||
ADMIN_SESSION_COOKIE_NAME,
|
||||
ADMIN_SESSION_TTL,
|
||||
OIDC_ENABLED,
|
||||
OIDC_ISSUER,
|
||||
OIDC_CLIENT_ID,
|
||||
OIDC_CLIENT_SECRET,
|
||||
OIDC_REDIRECT_URI,
|
||||
ADMIN_SUPER_USERNAME,
|
||||
ADMIN_SUPER_PASSWORD,
|
||||
ADMIN_SUPER_PASSWORD_HASH,
|
||||
COOKIE_SECURE,
|
||||
COOKIE_SAME_SITE,
|
||||
} from "./env";
|
||||
import { saveSession, loadSession, deleteSession, refreshSessionTtl } from "./redis";
|
||||
import {
|
||||
getUserByUsername,
|
||||
getAdminSession,
|
||||
createUser,
|
||||
type AdminSession,
|
||||
} from "./rbac";
|
||||
import type { AdminUser } from "./rbac";
|
||||
|
||||
const SALT_ROUNDS = 12;
|
||||
|
||||
// ============ 密码验证 ============
|
||||
|
||||
export async function verifyPassword(
|
||||
plain: string,
|
||||
hash: string
|
||||
): Promise<boolean> {
|
||||
return bcrypt.compare(plain, hash);
|
||||
}
|
||||
|
||||
export async function hashPassword(plain: string): Promise<string> {
|
||||
return bcrypt.hash(plain, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
// ============ 环境变量超级管理员 ============
|
||||
|
||||
async function verifySuperAdmin(
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<AdminUser | null> {
|
||||
if (
|
||||
!ADMIN_SUPER_USERNAME ||
|
||||
username !== ADMIN_SUPER_USERNAME
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 优先使用预哈希密码
|
||||
if (ADMIN_SUPER_PASSWORD_HASH) {
|
||||
const valid = await bcrypt.compare(password, ADMIN_SUPER_PASSWORD_HASH);
|
||||
if (!valid) return null;
|
||||
} else if (ADMIN_SUPER_PASSWORD) {
|
||||
if (password !== ADMIN_SUPER_PASSWORD) return null;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 返回虚超级管理员用户对象(ID=-1)
|
||||
return {
|
||||
id: -1,
|
||||
username: ADMIN_SUPER_USERNAME,
|
||||
password_hash: ADMIN_SUPER_PASSWORD_HASH,
|
||||
is_active: true,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
} as AdminUser;
|
||||
}
|
||||
|
||||
// ============ 登录 ============
|
||||
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<{ sessionId: string; adminSession: AdminSession } | null> {
|
||||
// 1. 尝试超级管理员
|
||||
const superUser = await verifySuperAdmin(username, password);
|
||||
let adminUser: AdminUser | null = superUser;
|
||||
let isSuperUser = !!superUser;
|
||||
|
||||
// 2. 尝试数据库用户
|
||||
if (!adminUser) {
|
||||
const dbUser = await getUserByUsername(username);
|
||||
if (!dbUser || !dbUser.is_active) return null;
|
||||
|
||||
const valid = await verifyPassword(password, dbUser.password_hash);
|
||||
if (!valid) return null;
|
||||
|
||||
adminUser = dbUser;
|
||||
isSuperUser = false;
|
||||
}
|
||||
|
||||
// 3. 生成 session
|
||||
const sessionId = uuidv4();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// 获取权限信息
|
||||
let adminSession: AdminSession;
|
||||
if (isSuperUser) {
|
||||
adminSession = {
|
||||
userId: adminUser.id,
|
||||
username: adminUser.username,
|
||||
roles: ["super_admin"],
|
||||
permissions: ["*"], // 超级管理员拥有所有权限
|
||||
};
|
||||
} else {
|
||||
adminSession = await getAdminSession(adminUser.id, adminUser.username);
|
||||
}
|
||||
|
||||
// 4. 保存到 Redis
|
||||
const sessionState: Record<string, unknown> = {
|
||||
"session:user_uid": adminUser.id,
|
||||
"session:username": adminUser.username,
|
||||
"session:roles": adminSession.roles,
|
||||
"session:permissions": adminSession.permissions,
|
||||
"session:created_at": now,
|
||||
"session:last_active": now,
|
||||
"session:ip_address": null,
|
||||
"session:user_agent": null,
|
||||
};
|
||||
|
||||
await saveSession(sessionId, sessionState, ADMIN_SESSION_TTL);
|
||||
|
||||
return { sessionId, adminSession };
|
||||
}
|
||||
|
||||
// ============ Session 加载 ============
|
||||
|
||||
export async function loadAdminSession(
|
||||
sessionId: string
|
||||
): Promise<AdminSession | null> {
|
||||
const state = await loadSession(sessionId);
|
||||
if (!state) return null;
|
||||
|
||||
const userId = state["session:user_uid"];
|
||||
const username = state["session:username"] as string;
|
||||
const roles = (state["session:roles"] as string[]) || [];
|
||||
const permissions = (state["session:permissions"] as string[]) || [];
|
||||
|
||||
if (!userId || !username) return null;
|
||||
|
||||
return {
|
||||
userId: userId as number,
|
||||
username,
|
||||
roles,
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ 刷新活跃时间 ============
|
||||
|
||||
export async function touchSession(sessionId: string): Promise<void> {
|
||||
const state = await loadSession(sessionId);
|
||||
if (!state) return;
|
||||
state["session:last_active"] = new Date().toISOString();
|
||||
await refreshSessionTtl(sessionId, ADMIN_SESSION_TTL);
|
||||
}
|
||||
|
||||
// ============ 登出 ============
|
||||
|
||||
export async function logout(sessionId: string): Promise<void> {
|
||||
await deleteSession(sessionId);
|
||||
}
|
||||
|
||||
// ============ OIDC ============
|
||||
|
||||
export function isOidcEnabled(): boolean {
|
||||
return (
|
||||
OIDC_ENABLED &&
|
||||
!!OIDC_ISSUER &&
|
||||
!!OIDC_CLIENT_ID &&
|
||||
!!OIDC_CLIENT_SECRET
|
||||
);
|
||||
}
|
||||
|
||||
export function buildOidcAuthUrl(): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: OIDC_CLIENT_ID,
|
||||
redirect_uri: OIDC_REDIRECT_URI,
|
||||
response_type: "code",
|
||||
scope: "openid profile email",
|
||||
});
|
||||
return `${OIDC_ISSUER}/authorize?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function exchangeOidcCode(
|
||||
code: string
|
||||
): Promise<{ sessionId: string; adminSession: AdminSession } | null> {
|
||||
try {
|
||||
// 1. 用 code 换 token
|
||||
const tokenRes = await fetch(`${OIDC_ISSUER}/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: OIDC_REDIRECT_URI,
|
||||
client_id: OIDC_CLIENT_ID,
|
||||
client_secret: OIDC_CLIENT_SECRET,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenRes.ok) return null;
|
||||
const tokenData = await tokenRes.json() as {
|
||||
access_token?: string;
|
||||
id_token?: string;
|
||||
};
|
||||
|
||||
const idToken = tokenData.id_token || tokenData.access_token;
|
||||
if (!idToken) return null;
|
||||
|
||||
// 2. 验证并解码 id_token
|
||||
const JWKS = jose.createRemoteJWKSet(
|
||||
new URL(`${OIDC_ISSUER}/.well-known/jwks.json`)
|
||||
);
|
||||
|
||||
const { payload } = await jose.jwtVerify(idToken, JWKS, {
|
||||
issuer: OIDC_ISSUER,
|
||||
audience: OIDC_CLIENT_ID,
|
||||
});
|
||||
|
||||
const email = payload.email as string;
|
||||
const name = (payload.name || payload.preferred_username || email) as string;
|
||||
|
||||
// 3. 查找或创建 admin 用户
|
||||
let user = await getUserByUsername(email);
|
||||
if (!user) {
|
||||
// 自动创建(首次 OIDC 登录)
|
||||
const randomPassword = uuidv4();
|
||||
const passwordHash = await hashPassword(randomPassword);
|
||||
user = await createUser(email, passwordHash);
|
||||
}
|
||||
|
||||
if (!user.is_active) return null;
|
||||
|
||||
// 4. 生成 session
|
||||
const sessionId = uuidv4();
|
||||
const now = new Date().toISOString();
|
||||
const adminSession = await getAdminSession(user.id, user.username);
|
||||
|
||||
const sessionState: Record<string, unknown> = {
|
||||
"session:user_uid": user.id,
|
||||
"session:username": user.username,
|
||||
"session:roles": adminSession.roles,
|
||||
"session:permissions": adminSession.permissions,
|
||||
"session:created_at": now,
|
||||
"session:last_active": now,
|
||||
"session:oidc_name": name,
|
||||
"session:ip_address": null,
|
||||
"session:user_agent": null,
|
||||
};
|
||||
|
||||
await saveSession(sessionId, sessionState, ADMIN_SESSION_TTL);
|
||||
|
||||
return { sessionId, adminSession };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Cookie 工具 ============
|
||||
|
||||
export interface CookieOptions {
|
||||
maxAge?: number;
|
||||
path?: string;
|
||||
domain?: string;
|
||||
sameSite?: "strict" | "lax" | "none";
|
||||
httpOnly?: boolean;
|
||||
secure?: boolean;
|
||||
}
|
||||
|
||||
export function buildSetCookieHeader(
|
||||
value: string,
|
||||
options: CookieOptions = {}
|
||||
): string {
|
||||
const {
|
||||
maxAge = ADMIN_SESSION_TTL,
|
||||
path = "/",
|
||||
sameSite = COOKIE_SAME_SITE,
|
||||
httpOnly = true,
|
||||
secure = COOKIE_SECURE,
|
||||
} = options;
|
||||
|
||||
let cookie = `${ADMIN_SESSION_COOKIE_NAME}=${encodeURIComponent(value)}; Path=${path}; Max-Age=${maxAge}; SameSite=${sameSite}`;
|
||||
|
||||
if (secure) cookie += "; Secure";
|
||||
if (httpOnly) cookie += "; HttpOnly";
|
||||
|
||||
return cookie;
|
||||
}
|
||||
|
||||
export function parseSessionCookie(cookieHeader: string | null): string | null {
|
||||
if (!cookieHeader) return null;
|
||||
const cookies = cookieHeader.split(";").map((c) => c.trim());
|
||||
for (const cookie of cookies) {
|
||||
const [name, ...valueParts] = cookie.split("=");
|
||||
if (name === ADMIN_SESSION_COOKIE_NAME) {
|
||||
return decodeURIComponent(valueParts.join("="));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildClearCookieHeader(): string {
|
||||
return `${ADMIN_SESSION_COOKIE_NAME}=; Path=/; Max-Age=0; SameSite=${COOKIE_SAME_SITE}${COOKIE_SECURE ? "; Secure" : ""}; HttpOnly`;
|
||||
}
|
||||
|
||||
// ============ 权限检查 ============
|
||||
|
||||
export function canAccess(
|
||||
session: AdminSession | null,
|
||||
requiredPermission: string
|
||||
): boolean {
|
||||
if (!session) return false;
|
||||
if (session.permissions.includes("*")) return true;
|
||||
return session.permissions.includes(requiredPermission);
|
||||
}
|
||||
|
||||
export function isSuperAdmin(session: AdminSession | null): boolean {
|
||||
return session?.roles.includes("super_admin") ?? false;
|
||||
}
|
||||
|
||||
/** 从 NextRequest headers 提取当前登录的 admin user id(由 middleware 设置) */
|
||||
export function getAdminUserId(req: Request): number | null {
|
||||
const header = req.headers.get("x-admin-user-id");
|
||||
if (!header) return null;
|
||||
const id = parseInt(header, 10);
|
||||
return isNaN(id) ? null : id;
|
||||
}
|
||||
71
admin/src/lib/db.ts
Normal file
71
admin/src/lib/db.ts
Normal file
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 数据库连接池
|
||||
* 使用 pg (node-postgres) 连接复用现有 PostgreSQL
|
||||
*/
|
||||
import { Pool, PoolClient, QueryResult, QueryResultRow } from "pg";
|
||||
import { DATABASE_URL } from "./env";
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: DATABASE_URL,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
|
||||
// 通用查询
|
||||
export async function query<T extends QueryResultRow = Record<string, unknown>>(
|
||||
sql: string,
|
||||
params?: unknown[]
|
||||
): Promise<QueryResult<T>> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
return await client.query<T>(sql, params);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// 事务
|
||||
export async function transaction<T>(
|
||||
fn: (client: PoolClient) => Promise<T>
|
||||
): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const result = await fn(client);
|
||||
await client.query("COMMIT");
|
||||
return result;
|
||||
} catch (e) {
|
||||
await client.query("ROLLBACK");
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// 仅读查询(自动路由到只读副本,如果有的话)
|
||||
export async function readQuery<T extends QueryResultRow = Record<string, unknown>>(
|
||||
sql: string,
|
||||
params?: unknown[]
|
||||
): Promise<QueryResult<T>> {
|
||||
return query<T>(sql, params);
|
||||
}
|
||||
|
||||
// 写入查询(使用主库)
|
||||
export async function writeQuery<T extends QueryResultRow = Record<string, unknown>>(
|
||||
sql: string,
|
||||
params?: unknown[]
|
||||
): Promise<QueryResult<T>> {
|
||||
return query<T>(sql, params);
|
||||
}
|
||||
|
||||
// 关闭连接池
|
||||
export async function closePool(): Promise<void> {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
export { pool };
|
||||
|
||||
export async function getClient(): Promise<PoolClient> {
|
||||
return pool.connect();
|
||||
}
|
||||
37
admin/src/lib/db/migrate-alert-config.ts
Normal file
37
admin/src/lib/db/migrate-alert-config.ts
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Migration: workspace_alert_config
|
||||
* 告警配置表(workspace 级别的阈值告警)
|
||||
*/
|
||||
import { query } from "../db";
|
||||
|
||||
export async function migrate() {
|
||||
// Check if table exists
|
||||
const { rows } = await query<{ exists: boolean }>(
|
||||
`SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'workspace_alert_config'
|
||||
) AS exists`
|
||||
);
|
||||
if (rows[0]?.exists) {
|
||||
console.log("Table workspace_alert_config already exists, skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
await query(`
|
||||
CREATE TABLE workspace_alert_config (
|
||||
id SERIAL PRIMARY KEY,
|
||||
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
alert_type VARCHAR(32) NOT NULL, -- 'low_balance' | 'monthly_quota' | 'usage_surge'
|
||||
threshold DECIMAL(10, 4) NOT NULL, -- 触发阈值(如 10.00 表示余额低于 $10)
|
||||
email_enabled BOOLEAN DEFAULT true,
|
||||
enabled BOOLEAN DEFAULT true,
|
||||
created_by INTEGER REFERENCES admin_user(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(workspace_id, alert_type)
|
||||
)
|
||||
`);
|
||||
await query(`CREATE INDEX idx_alert_config_workspace ON workspace_alert_config(workspace_id)`);
|
||||
|
||||
console.log("Created table: workspace_alert_config");
|
||||
}
|
||||
56
admin/src/lib/db/migrate-api-token.ts
Normal file
56
admin/src/lib/db/migrate-api-token.ts
Normal file
@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 迁移脚本:为 admin_api_token 表创建数据库结构
|
||||
* 运行方式: bun --env-file=.env.local src/lib/db/migrate-api-token.ts
|
||||
*/
|
||||
import { pool } from "../db";
|
||||
|
||||
async function migrate() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 检查表是否存在
|
||||
const tableCheck = await client.query(`
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'admin_api_token'
|
||||
`);
|
||||
|
||||
if (!tableCheck.rows.length) {
|
||||
await client.query(`
|
||||
CREATE TABLE admin_api_token (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
token_prefix VARCHAR(32) NOT NULL,
|
||||
permissions TEXT[] NOT NULL DEFAULT '{}',
|
||||
created_by INTEGER NOT NULL REFERENCES admin_user(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_used_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true
|
||||
)
|
||||
`);
|
||||
await client.query(`
|
||||
CREATE INDEX idx_admin_api_token_hash ON admin_api_token(token_hash)
|
||||
`);
|
||||
await client.query(`
|
||||
COMMENT ON TABLE admin_api_token IS 'Admin API Token for programmatic access'
|
||||
`);
|
||||
console.log("Created table: admin_api_token");
|
||||
} else {
|
||||
console.log("Table admin_api_token already exists, skipping.");
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
console.log("Migration completed successfully.");
|
||||
} catch (e) {
|
||||
await client.query("ROLLBACK");
|
||||
console.error("Migration failed:", e);
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
migrate().catch((e) => { console.error(e); process.exit(1); });
|
||||
140
admin/src/lib/db/migrate.ts
Normal file
140
admin/src/lib/db/migrate.ts
Normal file
@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 数据库迁移脚本
|
||||
* 创建 Admin 管理系统所需的表结构
|
||||
*
|
||||
* 运行: bun run db:migrate
|
||||
*/
|
||||
import { query, closePool } from "../db";
|
||||
|
||||
const migrations = [
|
||||
// 1. 管理员用户表
|
||||
`CREATE TABLE IF NOT EXISTS admin_user (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
// 2. 角色表
|
||||
`CREATE TABLE IF NOT EXISTS admin_role (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
// 3. 权限表
|
||||
`CREATE TABLE IF NOT EXISTS admin_permission (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
code VARCHAR(255) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
// 4. 用户-角色关联表
|
||||
`CREATE TABLE IF NOT EXISTS admin_user_role (
|
||||
user_id INTEGER NOT NULL REFERENCES admin_user(id) ON DELETE CASCADE,
|
||||
role_id INTEGER NOT NULL REFERENCES admin_role(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, role_id)
|
||||
)`,
|
||||
|
||||
// 5. 角色-权限关联表
|
||||
`CREATE TABLE IF NOT EXISTS admin_role_permission (
|
||||
role_id INTEGER NOT NULL REFERENCES admin_role(id) ON DELETE CASCADE,
|
||||
permission_id INTEGER NOT NULL REFERENCES admin_permission(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (role_id, permission_id)
|
||||
)`,
|
||||
|
||||
// 6. 审计日志表
|
||||
`CREATE TABLE IF NOT EXISTS admin_audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
resource VARCHAR(255) NOT NULL,
|
||||
resource_id VARCHAR(255),
|
||||
request_params JSONB,
|
||||
ip_address VARCHAR(255),
|
||||
user_agent TEXT,
|
||||
result VARCHAR(20) NOT NULL DEFAULT 'success',
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
// 索引
|
||||
`CREATE INDEX IF NOT EXISTS idx_admin_user_username ON admin_user(username)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_admin_audit_log_user_id ON admin_audit_log(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_admin_audit_log_created_at ON admin_audit_log(created_at DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_admin_audit_log_action ON admin_audit_log(action)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_admin_audit_log_resource ON admin_audit_log(resource)`,
|
||||
];
|
||||
|
||||
const seedData = [
|
||||
// 默认权限
|
||||
`INSERT INTO admin_permission (name, code, description) VALUES
|
||||
('用户管理', 'user:read', '查看用户列表'),
|
||||
('用户创建', 'user:create', '创建管理员用户'),
|
||||
('用户更新', 'user:update', '更新管理员用户'),
|
||||
('用户删除', 'user:delete', '删除管理员用户'),
|
||||
('角色管理', 'role:read', '查看角色'),
|
||||
('角色创建', 'role:create', '创建角色'),
|
||||
('角色更新', 'role:update', '更新角色'),
|
||||
('角色删除', 'role:delete', '删除角色'),
|
||||
('权限管理', 'permission:read', '查看权限'),
|
||||
('权限创建', 'permission:create', '创建权限'),
|
||||
('权限更新', 'permission:update', '更新权限'),
|
||||
('权限删除', 'permission:delete', '删除权限'),
|
||||
('日志查看', 'log:read', '查看审计日志'),
|
||||
('会话管理', 'session:manage', '管理在线用户会话'),
|
||||
('平台数据', 'platform:read', '查看平台数据'),
|
||||
('平台管理', 'platform:manage', '管理平台数据')
|
||||
ON CONFLICT (code) DO NOTHING`,
|
||||
|
||||
// 超级管理员角色
|
||||
`INSERT INTO admin_role (name, description) VALUES
|
||||
('超级管理员', '拥有所有权限')
|
||||
ON CONFLICT (name) DO NOTHING`,
|
||||
|
||||
// 将所有权限授予超级管理员
|
||||
`INSERT INTO admin_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM admin_role r
|
||||
CROSS JOIN admin_permission p
|
||||
WHERE r.name = '超级管理员'
|
||||
ON CONFLICT DO NOTHING`,
|
||||
];
|
||||
|
||||
async function runMigrations() {
|
||||
console.log("Running admin database migrations...");
|
||||
|
||||
for (let i = 0; i < migrations.length; i++) {
|
||||
try {
|
||||
await query(migrations[i]);
|
||||
console.log(`✓ Migration ${i + 1}/${migrations.length} succeeded`);
|
||||
} catch (e) {
|
||||
console.error(`✗ Migration ${i + 1}/${migrations.length} failed:`, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nSeeding initial data...");
|
||||
for (let i = 0; i < seedData.length; i++) {
|
||||
try {
|
||||
await query(seedData[i]);
|
||||
console.log(`✓ Seed ${i + 1}/${seedData.length} succeeded`);
|
||||
} catch (e) {
|
||||
console.error(`✗ Seed ${i + 1}/${seedData.length} failed:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n✓ All migrations completed!");
|
||||
await closePool();
|
||||
}
|
||||
|
||||
runMigrations().catch((e) => {
|
||||
console.error("Migration failed:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
50
admin/src/lib/env.ts
Normal file
50
admin/src/lib/env.ts
Normal file
@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 环境变量配置
|
||||
* 从 process.env 读取,所有值必须有默认值或抛出错误
|
||||
*/
|
||||
|
||||
// 数据库
|
||||
export const DATABASE_URL =
|
||||
process.env.DATABASE_URL || "postgresql://localhost:5432/code";
|
||||
|
||||
// Redis
|
||||
export const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379";
|
||||
// Redis Cluster 节点列表(逗号分隔,用于 ioredis cluster 模式)
|
||||
export const REDIS_CLUSTER_URLS = (process.env.REDIS_CLUSTER_URLS || "")
|
||||
.split(",")
|
||||
.map((u) => u.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Session
|
||||
export const ADMIN_SESSION_COOKIE_NAME =
|
||||
process.env.ADMIN_SESSION_COOKIE_NAME || "admin_session";
|
||||
export const ADMIN_SESSION_TTL = parseInt(
|
||||
process.env.ADMIN_SESSION_TTL || "604800",
|
||||
10
|
||||
); // 7 days
|
||||
|
||||
// 超级管理员(环境变量配置)
|
||||
export const ADMIN_SUPER_USERNAME = process.env.ADMIN_SUPER_USERNAME || "";
|
||||
export const ADMIN_SUPER_PASSWORD = process.env.ADMIN_SUPER_PASSWORD || "";
|
||||
export const ADMIN_SUPER_PASSWORD_HASH =
|
||||
process.env.ADMIN_SUPER_PASSWORD_HASH || "";
|
||||
|
||||
// OIDC
|
||||
export const OIDC_ENABLED = process.env.OIDC_ENABLED === "true";
|
||||
export const OIDC_ISSUER = process.env.OIDC_ISSUER || "";
|
||||
export const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || "";
|
||||
export const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || "";
|
||||
export const OIDC_REDIRECT_URI =
|
||||
process.env.OIDC_REDIRECT_URI ||
|
||||
"http://localhost:3000/api/auth/oidc/callback";
|
||||
|
||||
// Cookie 安全
|
||||
export const COOKIE_SECURE = process.env.COOKIE_SECURE === "true";
|
||||
export const COOKIE_SAME_SITE =
|
||||
(process.env.COOKIE_SAME_SITE as "strict" | "lax" | "none") || "lax";
|
||||
|
||||
// Rust 主应用集成
|
||||
export const RUST_BACKEND_URL =
|
||||
process.env.RUST_BACKEND_URL || "http://localhost:3000";
|
||||
export const ADMIN_API_SHARED_KEY =
|
||||
process.env.ADMIN_API_SHARED_KEY || "";
|
||||
124
admin/src/lib/log.ts
Normal file
124
admin/src/lib/log.ts
Normal file
@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 日志系统
|
||||
*
|
||||
* 表结构:
|
||||
* - admin_audit_log: 审计日志表
|
||||
* (id, user_id, username, action, resource, resource_id,
|
||||
* request_params, ip_address, user_agent, result, error_message, created_at)
|
||||
*/
|
||||
import { query } from "./db";
|
||||
|
||||
export type AuditAction = "create" | "read" | "update" | "delete" | "login" | "logout" | "other";
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: number;
|
||||
user_id: number;
|
||||
username: string;
|
||||
action: string;
|
||||
resource: string;
|
||||
resource_id: string | null;
|
||||
request_params: Record<string, unknown> | null;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
result: "success" | "failure";
|
||||
error_message: string | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface CreateAuditLogParams {
|
||||
userId: number;
|
||||
username: string;
|
||||
action: AuditAction;
|
||||
resource: string;
|
||||
resourceId?: string;
|
||||
requestParams?: Record<string, unknown>;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
result?: "success" | "failure";
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export async function createAuditLog(params: CreateAuditLogParams): Promise<void> {
|
||||
await query(
|
||||
`INSERT INTO admin_audit_log
|
||||
(user_id, username, action, resource, resource_id, request_params,
|
||||
ip_address, user_agent, result, error_message)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
[
|
||||
params.userId,
|
||||
params.username,
|
||||
params.action,
|
||||
params.resource,
|
||||
params.resourceId || null,
|
||||
params.requestParams ? JSON.stringify(params.requestParams) : null,
|
||||
params.ipAddress || null,
|
||||
params.userAgent || null,
|
||||
params.result || "success",
|
||||
params.errorMessage || null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
export interface ListAuditLogsOptions {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
userId?: number;
|
||||
action?: string;
|
||||
resource?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
}
|
||||
|
||||
export async function listAuditLogs(
|
||||
options: ListAuditLogsOptions = {}
|
||||
): Promise<{ logs: AuditLogEntry[]; total: number }> {
|
||||
const page = options.page ?? 1;
|
||||
const pageSize = options.pageSize ?? 20;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (options.userId !== undefined) {
|
||||
conditions.push(`user_id = $${paramIdx++}`);
|
||||
params.push(options.userId);
|
||||
}
|
||||
if (options.action) {
|
||||
conditions.push(`action = $${paramIdx++}`);
|
||||
params.push(options.action);
|
||||
}
|
||||
if (options.resource) {
|
||||
conditions.push(`resource ILIKE $${paramIdx++}`);
|
||||
params.push(`%${options.resource}%`);
|
||||
}
|
||||
if (options.startDate) {
|
||||
conditions.push(`created_at >= $${paramIdx++}`);
|
||||
params.push(options.startDate);
|
||||
}
|
||||
if (options.endDate) {
|
||||
conditions.push(`created_at <= $${paramIdx++}`);
|
||||
params.push(options.endDate);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const countResult = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) FROM admin_audit_log ${whereClause}`,
|
||||
params
|
||||
);
|
||||
const total = parseInt(countResult.rows[0].count, 10);
|
||||
|
||||
const limitParam = paramIdx++;
|
||||
const offsetParam = paramIdx++;
|
||||
const logsResult = await query<AuditLogEntry>(
|
||||
`SELECT id, user_id, username, action, resource, resource_id,
|
||||
request_params, ip_address, user_agent, result, error_message, created_at
|
||||
FROM admin_audit_log ${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${limitParam} OFFSET $${offsetParam}`,
|
||||
[...params, pageSize, offset]
|
||||
);
|
||||
|
||||
return { logs: logsResult.rows, total };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user