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:
ZhenYi 2026-04-19 20:48:59 +08:00
parent c4d4b2ecf5
commit fb91f5a6c5
123 changed files with 21622 additions and 2 deletions

1
.gitignore vendored
View File

@ -17,4 +17,3 @@ ARCHITECTURE.md
.agents
.agents.md
.next
admin

2
Cargo.lock generated
View File

@ -571,6 +571,8 @@ dependencies = [
"models",
"queue",
"room",
"rust_decimal",
"sea-orm",
"serde",
"serde_json",
"service",

41
admin/.gitignore vendored Normal file
View 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
View 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
View File

@ -0,0 +1 @@
@AGENTS.md

312
admin/PLAN.md Normal file
View 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 CRUDUser/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 管理RedisTTL 可配置)
- [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 / SAMLRust 主应用 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 弹窗 UINext.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
View 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
View 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` — 充值 APIINSERT 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 / SAMLRust 后端)
**现状**: 平台用户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` cookieAdmin 无法直接调用。
解决方案:
1. **共享 Session 存储**Admin 验证后注入 fake session 到 Redis主应用可读取
2. **Admin 专用 API**Rust 新增 `/api/admin/*` 路由,验证 `admin_session` cookie
3. **API Token**Rust API 支持 Bearer TokenAdmin 用服务账号 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

File diff suppressed because it is too large Load Diff

18
admin/eslint.config.mjs Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

46
admin/package.json Normal file
View 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"
}
}

View 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
View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
admin/public/file.svg Normal file
View 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
View 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
View 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
View 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
View 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

View 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>
);
}

View 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 &lt;your-token&gt;</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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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} &lt;{c.authorEmail}&gt;</td>
<td style={{ fontSize: "12px" }}>{format(parseISO(c.createdAt), "yyyy-MM-dd HH:mm")}</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
</div>
</div>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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");
}

View 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);
}

View 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");
}

View 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");
}

View 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 });
}
}

View File

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

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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;
}
}

View 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 });
}
}

View 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);
}

View 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;
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 });
}
}

View 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 });
}
}

View File

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

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View File

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

View File

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

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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>
);
}

View 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
View 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
View 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>
);
}

View 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
View 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");
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View File

@ -0,0 +1,115 @@
/**
* Admin API Token
* admin_api_tokenid, 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
View File

@ -0,0 +1,343 @@
/**
*
*
* 1. +
* 2. OpenID Connect (OIDC)
*
* Session RedisCookie
*/
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
View 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();
}

View 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");
}

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