Compare commits
27 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
43dedb2ffd | |
|
|
6da9b6e4c0 | |
|
|
6c426757e0 | |
|
|
da542e95e7 | |
|
|
b118a16afd | |
|
|
39a88a8220 | |
|
|
5961810a43 | |
|
|
62016cf105 | |
|
|
9986d99ab3 | |
|
|
fc7f3d7255 | |
|
|
2554a61b44 | |
|
|
60e4694d76 | |
|
|
67cba23ce3 | |
|
|
0f56c60313 | |
|
|
ba26ac97f5 | |
|
|
4a97326955 | |
|
|
9364d56e00 | |
|
|
0a4dadc748 | |
|
|
66dbd80587 | |
|
|
3f3dfc1f19 | |
|
|
69694d3fe9 | |
|
|
5d841a56a6 | |
|
|
dbc7600a59 | |
|
|
747fe293d5 | |
|
|
c6a92ed33a | |
|
|
3e7f94b014 | |
|
|
00b9e990f2 |
|
|
@ -0,0 +1,32 @@
|
||||||
|
# 依赖 —— Dockerfile 内用 npm ci 安装
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# 构建产物 —— Dockerfile 内用 npm run build 生成
|
||||||
|
.next
|
||||||
|
|
||||||
|
# 运行时数据 —— volume 挂载
|
||||||
|
data
|
||||||
|
uploads
|
||||||
|
db-backups
|
||||||
|
|
||||||
|
# 开发工具
|
||||||
|
.claude
|
||||||
|
.playwright-mcp
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
Caddyfile
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# 文档
|
||||||
|
*.md
|
||||||
|
docs
|
||||||
|
templates-docs
|
||||||
|
CHANGELOG.md
|
||||||
|
README.md
|
||||||
|
|
||||||
|
# 构建缓存
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
24
.env.example
24
.env.example
|
|
@ -1,9 +1,21 @@
|
||||||
|
# assets-ai 环境变量(本地开发)
|
||||||
DATABASE_PATH=./data/assets.db
|
DATABASE_PATH=./data/assets.db
|
||||||
JWT_SECRET=your-secret-key-change-in-production
|
JWT_SECRET=dev-jwt-secret-local
|
||||||
|
COOKIE_DOMAIN=
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||||
|
|
||||||
# issue-ai API 配置(用于故障历史功能)
|
# LDAP 配置
|
||||||
# NEXT_PUBLIC_ 前缀:构建时内嵌到客户端 JS,云上必须通过 deploy-ai.sh 设置
|
LDAP_URL=ldap://localhost:3890
|
||||||
# 本地开发:http://localhost:5176/tickets
|
LDAP_BASE_DN=dc=tlyq,dc=ai
|
||||||
# 云上生产:https://issue.tlyq.ai/tickets
|
LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
|
||||||
NEXT_PUBLIC_ISSUE_URL=http://localhost:5176/tickets
|
|
||||||
|
# OIDC 配置
|
||||||
|
AUTHELIA_URL=http://127.0.0.1:6180
|
||||||
|
OIDC_CLIENT_ID=assets-oidc
|
||||||
|
OIDC_CLIENT_SECRET=<见 Authelia 配置>
|
||||||
|
OIDC_REDIRECT_URI=http://127.0.0.1:6177/api/auth/callback
|
||||||
|
|
||||||
|
# 跨服务调用
|
||||||
|
ISSUE_API_URL=http://localhost:6176/api
|
||||||
|
NEXT_PUBLIC_ISSUE_URL=http://localhost:6176/tickets
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,5 @@ data/
|
||||||
db-backups/
|
db-backups/
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
.env.local
|
||||||
|
.DS_Store
|
||||||
|
|
|
||||||
92
CHANGELOG.md
92
CHANGELOG.md
|
|
@ -1,5 +1,97 @@
|
||||||
# 变更日志
|
# 变更日志
|
||||||
|
|
||||||
|
## 2026-06-30
|
||||||
|
|
||||||
|
- [新增] SSO 统一认证:集成 Authelia OIDC,支持统一认证登录
|
||||||
|
- [新增] OIDC 登录页面:添加「统一认证登录」按钮,支持 LDAP 回退
|
||||||
|
- [新增] `src/lib/oidc.ts`:OIDC 客户端配置(PKCE + state + nonce)
|
||||||
|
- [新增] `src/app/api/auth/login/oidc/route.ts`:OIDC 登录端点
|
||||||
|
- [新增] `src/app/api/auth/callback/route.ts`:OIDC 回调处理(含用户自动创建)
|
||||||
|
- [新增] `src/app/api/auth/logout/route.ts`:跨域登出(支持 domain 参数)
|
||||||
|
- [修复] NODE_TLS_REJECT_UNAUTHORIZED=0:Authelia 使用自签名证书
|
||||||
|
- [修复] OIDC redirect_uri 回调重定向到 localhost:添加 getBaseUrl() 函数
|
||||||
|
- [修复] token_endpoint_auth_method 配置缺失:添加 client_secret_basic
|
||||||
|
- [优化] docker-compose.yml:统一环境变量管理,移除 env_file
|
||||||
|
- [优化] .env.example:添加 OIDC 配置模板
|
||||||
|
|
||||||
|
## 2026-06-24
|
||||||
|
|
||||||
|
- [修复] SQLite 时区根源修复:重建表修改所有时间列 DEFAULT 为 `datetime('now', '+8 hours')`,涉及 6 个表 8 个列
|
||||||
|
|
||||||
|
## 2026-06-23
|
||||||
|
|
||||||
|
- [新增] 审计日志详情可视化:字段变更用表格对比(旧值/新值),新建/删除用卡片,登录信息用键值对
|
||||||
|
- [新增] 保留天数可编辑:默认隐藏保存按钮,修改值后蓝色高亮显示,保存成功/失败有颜色反馈
|
||||||
|
- [优化] 审计日志列表详情列:显示操作摘要而非"-",支持 message/keys/username 等格式
|
||||||
|
- [优化] 表格列宽统一:时间/用户/操作/对象列防止换行
|
||||||
|
- [修复] 导出文件名使用北京时间(修复凌晨时区偏移)
|
||||||
|
|
||||||
|
## 2026-05-18
|
||||||
|
|
||||||
|
- [新增] 用户详情页 — 点击用户名查看完整信息,支持编辑和键盘导航
|
||||||
|
- [优化] 用户管理 UI 统一:Modal 支持键盘导航(ESC/←→/Enter)、TopBar/Button 样式与 issue-ai 对齐
|
||||||
|
- [调整] 用户列表页去创建时间列,编辑功能移到详情页,仅保留删除按钮
|
||||||
|
- [新增] LDAP 邮箱自动同步:密码登录/SSO 路径均从 LLDAP 同步 `mail` 到本地 `users.email`,增加 `/api/users/sync-emails` 批量同步接口
|
||||||
|
- [新增] 编辑用户弹窗增加启用/禁用状态切换、新密码二次确认
|
||||||
|
- [优化] 角色权限页面 UI 统一:删除改用 Modal 确认、按钮样式对齐
|
||||||
|
- [优化] API Key 页面 UI 统一:增加副标题、状态可点击切换、创建弹窗增加错误提示和默认权限说明、成功提示颜色对齐
|
||||||
|
- [优化] 全站字体大小和颜色统一:列表页/详情页 `text-sm` 一致、dark mode 颜色补全
|
||||||
|
- [修复] 用户管理"最后登录"时间不动态更新:SSO 免登录、本地 JWT 会话验证路径现在也会更新 `last_login_at`
|
||||||
|
|
||||||
|
## 2026-05-15
|
||||||
|
|
||||||
|
- [修复] docker-compose.yml 缺少 `ALLOWED_API_KEYS` 环境变量传入,导致 P2 部署后中间件返回 401,issue-ai 调用 assets API 再次失效
|
||||||
|
- [修复] middleware 移除 `better-sqlite3` 依赖,修复 Edge Runtime 不支持 Node.js 原生模块的编译报错(`The edge runtime does not support Node.js 'fs' module`)
|
||||||
|
- [新增] deploy-ai.sh 部署后自动验证 assets→issue API 连通性,带重试机制(3次/10s间隔),与 issue→assets 方向对称
|
||||||
|
|
||||||
|
## 2026-05-14
|
||||||
|
|
||||||
|
- [新增] 权限细粒度拆分:`assets:write` → `assets:create`(新增设备)、`assets:import`(导入设备)、`assets:update`(编辑设备),支持独立授权
|
||||||
|
- [新增] API 模板下载路由增加 `assets:import` 权限检查(原仅校验登录态)
|
||||||
|
- [优化] 角色权限选择列表同步新增三个权限选项(新增资产/导入资产/编辑资产)
|
||||||
|
- [修复] 资产列表页、详情页按钮无权限控制:导入、模板、新增设备、编辑、删除、批量编辑按钮现在由用户权限驱动显隐
|
||||||
|
- [修复] 新增/导入/编辑资产页面无权限守卫:无权限用户直接访问会被重定向到资产列表
|
||||||
|
- [修复] 资产列表页模板按钮链接指向 404(`/assets/template` → `/api/assets/template`)
|
||||||
|
- [调整] editor/viewer 角色权限允许自定义编辑(`db-schema.ts` 仅强制同步 admin),三个内置角色均不可删除
|
||||||
|
- [修复] issue-ai 调用 assets API 返回 401:云端 `api_keys` 表中注册了 issue-ai 使用的 API Key(统一 Key,此前只存在 issue-ai 的 ALLOWED_API_KEYS 环境变量中,assets-ai 的 api_keys 表缺失该记录)
|
||||||
|
- [优化] 中间件 API Key 验证统一为 ALLOWED_API_KEYS(快速路径)→ api_keys 数据库表(回退)两级验证;无效 key 不再被放行,中间件层直接返回 401(安全加固,与 issue-ai 行为一致)
|
||||||
|
|
||||||
|
## 2026-05-12
|
||||||
|
|
||||||
|
- [部署] 云端 JWT 密钥统一为 oa-shared-jwt-secret-tlyq-2026,三站点密钥一致
|
||||||
|
- [部署] docker-compose 移除 AUTHELIA_URL,添加 LDAP/COOKIE/INTERNAL_API_KEY 环境变量
|
||||||
|
- [部署] LLDAP admin 密码更新为 3Vm!Y!@RCiPs
|
||||||
|
- [部署] nginx 移除 auth_request,恢复纯反向代理;改为运行时 DNS 解析
|
||||||
|
- [新增] `src/lib/db-schema.ts`:users 表新增 last_login_at / last_active_at 列
|
||||||
|
- [新增] `src/lib/auth.ts`:getSession() 更新 last_active_at
|
||||||
|
- [新增] `src/app/api/auth/login/route.ts`:登录时更新 last_login_at 和 last_active_at
|
||||||
|
|
||||||
|
## 2026-05-11
|
||||||
|
|
||||||
|
- [新增] `src/lib/ldap.ts`:`ldapUserExists()` 函数,检查 LLDAP 中用户是否存在(admin bind 搜索,不可达时容错放行)
|
||||||
|
- [新增] `src/lib/jwt.ts`:共享 JWT 签发/验证(`tlyq_session` cookie,HS256,与 OA/issue 共用密钥)
|
||||||
|
- [调整] `src/lib/auth.ts`:`getSession()` 优先 `tlyq_session`,加入 LLDAP 存在性检查,用户被删除后自动清除 cookie 踢出
|
||||||
|
- [调整] `src/app/api/auth/login/route.ts`:LDAP 优先认证 + 本地密码缓存回退 + localadmin 应急用户直连
|
||||||
|
- [调整] `src/app/api/auth/logout/route.ts`:同时清除 `session_assets` 和 `tlyq_session`
|
||||||
|
- [调整] `src/app/api/auth/me/route.ts`:移除 SSO header 路径,改用 `getSession()` 统一获取
|
||||||
|
- [调整] `src/middleware.ts`:优先 `tlyq_session` → 回退 `session_assets`,移除 SSO 代理路径,放行 `/api/internal/`
|
||||||
|
- [调整] `src/app/(app)/layout.tsx`:移除 SSO header 路径,统一从 `session` cookie 读取用户
|
||||||
|
- [新增] `src/app/api/internal/roles/route.ts`:内部 API,返回站点可用角色列表(INTERNAL_API_KEY 鉴权)
|
||||||
|
- [新增] `src/app/api/users/[id]/route.ts`:admin/localadmin 用户禁止删除和修改角色
|
||||||
|
- [调整] `src/app/(app)/settings/users/page.tsx`:admin/localadmin 用户隐藏删除按钮,编辑时角色字段显示为只读
|
||||||
|
- [新增] `src/lib/db-schema.ts`:预置 localadmin 应急用户(admin 角色,纯本地 BCrypt 认证)
|
||||||
|
|
||||||
|
## 2026-05-07
|
||||||
|
|
||||||
|
- [新增] 导出功能区分"导出选中"和"导出全部"两种模式,有选中时工具栏显示两个按钮独立操作
|
||||||
|
- [新增] 角色权限系统完整改造,支持新建角色、编辑角色权限(10 个细粒度权限复选框)
|
||||||
|
- [新增] 用户管理页角色下拉从 API 动态获取,不再硬编码
|
||||||
|
- [优化] 设备详情页空字段区块自动隐藏(如无数据盘 2 则不显示该卡片)
|
||||||
|
- [优化] 导出权限拆分为 `assets:export:selected`(导出选中)和 `assets:export:all`(导出全部),按钮显隐由权限驱动
|
||||||
|
- [优化] 内置角色权限自动同步到最新默认值,自定义角色旧权限自动迁移
|
||||||
|
- [修复] 资产列表 API 和导出 API 的 filter 参数增加字段白名单校验
|
||||||
|
- [修复] 导出文件名日期改用本地时间,避免 UTC+8 时区偏移
|
||||||
|
|
||||||
## 2026-04-30
|
## 2026-04-30
|
||||||
|
|
||||||
- [新增] 创建 README 文档
|
- [新增] 创建 README 文档
|
||||||
|
|
|
||||||
76
CLAUDE.md
76
CLAUDE.md
|
|
@ -13,7 +13,7 @@ assets-ai 是基于 Next.js + SQLite 的 IT 设备资产管理系统(CMDB)
|
||||||
| 站点域名 | `assets.tlyq.ai` |
|
| 站点域名 | `assets.tlyq.ai` |
|
||||||
| 服务器 | txjp(IP: 43.133.38.210) |
|
| 服务器 | txjp(IP: 43.133.38.210) |
|
||||||
| 代码路径 | `/root/docker/assets-ai/` |
|
| 代码路径 | `/root/docker/assets-ai/` |
|
||||||
| 本地端口 | 5177 |
|
| 本地端口 | 6177 |
|
||||||
| 容器名 | `assets-ai` |
|
| 容器名 | `assets-ai` |
|
||||||
| 数据库 | SQLite:`data/assets.db` |
|
| 数据库 | SQLite:`data/assets.db` |
|
||||||
| 默认账号 | `admin` / `admin123` |
|
| 默认账号 | `admin` / `admin123` |
|
||||||
|
|
@ -44,6 +44,7 @@ npm run import # 导入设备数据
|
||||||
| `src/lib/permissions.ts` | 权限检查(按角色 JSON 匹配) |
|
| `src/lib/permissions.ts` | 权限检查(按角色 JSON 匹配) |
|
||||||
| `src/lib/issue-client.ts` | 调用 issue API 获取设备历史工单 |
|
| `src/lib/issue-client.ts` | 调用 issue API 获取设备历史工单 |
|
||||||
| `src/lib/excel.ts` | Excel 模板生成 + 导入解析 |
|
| `src/lib/excel.ts` | Excel 模板生成 + 导入解析 |
|
||||||
|
| `src/lib/audit.ts` | 审计日志(writeAuditLog、diffObjects、getClientIP) |
|
||||||
| `src/types/index.ts` | User / Asset / ApiKey / PaginatedResult 等类型 |
|
| `src/types/index.ts` | User / Asset / ApiKey / PaginatedResult 等类型 |
|
||||||
| `src/app/api/assets/` | 资产 CRUD + 批量修改 + 导入/导出 + 高级查询 |
|
| `src/app/api/assets/` | 资产 CRUD + 批量修改 + 导入/导出 + 高级查询 |
|
||||||
|
|
||||||
|
|
@ -86,11 +87,18 @@ npm run import # 导入设备数据
|
||||||
|
|
||||||
### 认证
|
### 认证
|
||||||
|
|
||||||
|
登录逻辑(v2.1):LDAP 优先 + 本地密码缓存回退 + localadmin 应急用户。
|
||||||
|
登录成功签发两个 cookie:`session_assets`(本地 JWT,24h)+ `tlyq_session`(共享 JWT,7 天,domain=.tlyq.ai)。
|
||||||
|
中间件优先检查 `tlyq_session`,回退 `session_assets`。`getSession()` 每次验证时检查 LLDAP 用户是否存在(已删除则清除 cookie 踢出)。
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| POST | `/api/auth/login` | 登录(username + password → JWT cookie,24h 有效) |
|
| POST | `/api/auth/login` | 登录(LDAP 优先 + 本地回退) |
|
||||||
| POST | `/api/auth/logout` | 登出 |
|
| POST | `/api/auth/logout` | 登出(清除两个 cookie) |
|
||||||
| GET | `/api/auth/me` | 当前用户信息 |
|
| GET | `/api/auth/me` | 当前用户信息 |
|
||||||
|
| GET | `/api/internal/roles` | 内部 API:返回角色列表(x-internal-key 鉴权) |
|
||||||
|
| GET | `/api/internal/users` | 内部 API:返回用户列表(x-internal-key 鉴权) |
|
||||||
|
| POST | `/api/internal/users` | 内部 API:OA 同步用户角色(x-internal-key 鉴权) |
|
||||||
|
|
||||||
### 资产
|
### 资产
|
||||||
|
|
||||||
|
|
@ -129,8 +137,9 @@ npm run import # 导入设备数据
|
||||||
|
|
||||||
## 认证机制
|
## 认证机制
|
||||||
|
|
||||||
- **Web UI**:`middleware.ts` 检查 cookie 中的 JWT token,payload 含 `{ userId, username, role, iat, exp }`
|
- **Web UI(v2.1)**:`middleware.ts` 优先检查 `tlyq_session`(共享 JWT,OA 统一签发)→ 回退 `session_assets`(本地 JWT)。`getSession()` 每次请求时检查 LLDAP 用户是否存在,已删除则清除 cookie 踢出
|
||||||
- **API Key**:`Bearer ak_<32位十六进制>`,存储时 SHA-256 hash,验证时更新 `last_used_at`,权限由创建时指定的 `permissions` 数组控制
|
- **localadmin**:纯本地 BCrypt 认证,不依赖 LLDAP,用于 LLDAP 故障时应急登录(DB 预置,admin 角色)
|
||||||
|
- **API Key(v2.2)**:`middleware.ts` 检查 `ALLOWED_API_KEYS` 环境变量(逗号分隔明文 key),无效 key 在中间件层直接返回 401。注意:middleware 运行在 Edge Runtime,不能使用 `better-sqlite3`,DB 级别的 key 验证由 route handler 中的 `auth.ts verifyApiKey()` 进行(查 `api_keys` 表 SHA-256 hash,支持 `last_used_at` 追踪和 permissions 控制)。外部系统调用本系统 API 时,key 必须注册在 `ALLOWED_API_KEYS` 中
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -140,13 +149,18 @@ npm run import # 导入设备数据
|
||||||
|
|
||||||
| 环境变量 | 本地开发 | 云服务器(txjp) | 说明 |
|
| 环境变量 | 本地开发 | 云服务器(txjp) | 说明 |
|
||||||
|---------|---------|----------------|------|
|
|---------|---------|----------------|------|
|
||||||
| `ISSUE_API_URL` | `http://localhost:5176/api` | `http://issue-ai:3000/api` | 调用 issue API 地址 |
|
| `ISSUE_API_URL` | `http://localhost:6176/api` | `http://issue-ai:3000/api` | 调用 issue API 地址 |
|
||||||
| `ISSUE_API_KEY` | 本地 issue-ai 生成 | 云上 issue-ai 生成 | **每个环境独立,不可跨环境使用** |
|
| `ISSUE_API_KEY` | 本地 issue-ai 生成 | 云上 issue-ai 生成 | **每个环境独立,不可跨环境使用** |
|
||||||
| `NEXT_PUBLIC_ISSUE_URL` | `http://localhost:5176/tickets` | `https://issue.tlyq.ai` | 前端跳转链接(构建时内嵌) |
|
| `NEXT_PUBLIC_ISSUE_URL` | `http://localhost:6176/tickets` | `https://issue.tlyq.ai` | 前端跳转链接(构建时内嵌) |
|
||||||
| `ALLOWED_API_KEYS` | issue-ai 调用本系统时需要的 Key | 云上 issue-ai 生成的 Key | 仅 issue→assets 方向需要 |
|
| `ALLOWED_API_KEYS` | issue-ai 调用本系统时需要的 Key | 云上 issue-ai 生成的 Key | 仅 issue→assets 方向需要 |
|
||||||
| `JWT_SECRET` | `dev-secret-key-local` | `${ASSETS_JWT_SECRET}` | 生产必须强密钥 |
|
| `JWT_SECRET` | `dev-secret-key-local` | `${ASSETS_JWT_SECRET}` | 生产必须强密钥 |
|
||||||
| `DATABASE_PATH` | `./data/assets.db` | `/app/data/assets.db` | Docker volume 挂载 |
|
| `DATABASE_PATH` | `./data/assets.db` | `/app/data/assets.db` | Docker volume 挂载 |
|
||||||
| Cookie 名 | `session_assets` | `session_assets` | 本地两系统用不同名防 localhost 域冲突 |
|
| Cookie 名 | `session_assets` | `session_assets` | 本地两系统用不同名防 localhost 域冲突 |
|
||||||
|
| `AUTHELIA_URL` | `https://sso.tlyq.ai` | 同 | Authelia 地址 |
|
||||||
|
| `OIDC_CLIENT_ID` | `assets-oidc` | 同 | OIDC 客户端 ID |
|
||||||
|
| `OIDC_CLIENT_SECRET` | 本地生成的哈希值 | 服务器生成的哈希值 | OIDC 客户端密钥 |
|
||||||
|
| `OIDC_REDIRECT_URI` | `http://localhost:6177/api/auth/callback` | `https://assets.tlyq.ai/api/auth/callback` | OIDC 回调地址 |
|
||||||
|
| `NODE_TLS_REJECT_UNAUTHORIZED` | 不需要 | `0`(Authelia 使用自签名证书) | TLS 证书验证 |
|
||||||
|
|
||||||
### `.env.local` 示例
|
### `.env.local` 示例
|
||||||
|
|
||||||
|
|
@ -154,9 +168,13 @@ npm run import # 导入设备数据
|
||||||
DATABASE_PATH=./data/assets.db
|
DATABASE_PATH=./data/assets.db
|
||||||
JWT_SECRET=dev-secret-key-local
|
JWT_SECRET=dev-secret-key-local
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
ISSUE_API_URL=http://localhost:5176/api
|
ISSUE_API_URL=http://localhost:6176/api
|
||||||
ISSUE_API_KEY=ak_<32字节十六进制>
|
ISSUE_API_KEY=ak_<32字节十六进制>
|
||||||
NEXT_PUBLIC_ISSUE_URL=http://localhost:5176/tickets
|
NEXT_PUBLIC_ISSUE_URL=http://localhost:6176/tickets
|
||||||
|
AUTHELIA_URL=https://sso.tlyq.ai
|
||||||
|
OIDC_CLIENT_ID=assets-oidc
|
||||||
|
OIDC_CLIENT_SECRET=<本地生成的哈希值>
|
||||||
|
OIDC_REDIRECT_URI=http://localhost:6177/api/auth/callback
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -212,6 +230,46 @@ NEXT_PUBLIC_ISSUE_URL=https://issue.tlyq.ai/tickets
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 开发规范
|
||||||
|
|
||||||
|
- **新增 API**:在 `src/app/api/` 下创建路由 → 顶部调用 `initDatabase()` → `getCurrentUser()` 验证
|
||||||
|
- **新增页面**:在 `src/app/(app)/` 下创建 → 布局由 `(app)/layout.tsx` 提供
|
||||||
|
- **审计日志**:所有写操作 API(POST/PUT/DELETE)必须添加审计日志,使用 `writeAuditLog()` 函数:
|
||||||
|
```typescript
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
|
// 在操作成功后调用
|
||||||
|
writeAuditLog({
|
||||||
|
userId: session.userId,
|
||||||
|
apiKeyId: null, // 或 apiKey?.id
|
||||||
|
action: 'create' | 'update' | 'delete' | 'batch_update' | 'import' | 'export',
|
||||||
|
entityType: 'asset' | 'user' | 'role' | 'api_key' | 'auth',
|
||||||
|
entityId: result.lastInsertRowid as number, // 可选
|
||||||
|
details: { created: { ... } }, // 或 { changes: diffObjects(old, new) } 或 { deleted: { ... } }
|
||||||
|
ipAddress: getClientIP(request)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
- **日期处理(时区规范)**:整个系统统一使用 UTC+8(北京时间)。必须遵守:
|
||||||
|
1. **JavaScript/TypeScript**:禁止使用 `Date.toISOString()` 格式化本地日期。`toISOString()` 返回 UTC 时间,在中国时区(UTC+8)下 `new Date('2026-04-01T00:00:00').toISOString()` 会返回 `"2026-03-31T16:00:00.000Z"`,日期偏移一天。应使用本地时间方法拼接:`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||||
|
2. **SQLite**:所有 `datetime('now')` 必须写成 `datetime('now', '+8 hours')`,包括 UPDATE/SET 语句、以及查询条件中的时间比较。禁止使用不含时区偏移的 `datetime('now')`。
|
||||||
|
3. **INSERT 时间列**:数据库表的 DEFAULT 已统一为 `datetime('now', '+8 hours')`,INSERT 时可省略时间列。如需显式设置,使用 `datetime('now', '+8 hours')`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git Tag 规范
|
||||||
|
|
||||||
|
使用日期版本号 `vYYYY.MM.DD`(如 `v2026.05.18`)。提交后打 tag 再推送:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag v$(date +%Y.%m.%d) && git push origin main && git push origin v$(date +%Y.%m.%d)
|
||||||
|
```
|
||||||
|
|
||||||
|
同一天多次提交只打一个 tag。详见根目录 `CLAUDE.md`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 故障排查
|
## 故障排查
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
14
Dockerfile
14
Dockerfile
|
|
@ -8,15 +8,17 @@ RUN --mount=type=cache,target=/root/.npm,id=assets-npm \
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# runner 阶段:与 builder 保持一致(Alpine + musl),确保 better-sqlite3 等原生模块兼容
|
# runner 阶段:使用 Debian(glibc),与 txjp 宿主机平台一致,避免原生模块 musl/glibc 不兼容
|
||||||
FROM node:20-alpine AS runner
|
FROM node:20-slim AS runner
|
||||||
RUN apk add --no-cache python3 make g++
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/package.json /app/package-lock.json ./
|
COPY --from=builder /app/package.json /app/package-lock.json ./
|
||||||
RUN npm install --omit=dev
|
|
||||||
COPY --from=builder /app/.next/standalone ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
RUN npm install --omit=dev && \
|
||||||
|
npm rebuild better-sqlite3 && \
|
||||||
|
rm -rf node_modules/@img/sharp-linuxmusl-x64 node_modules/@img/sharp-libvips-linuxmusl-x64 \
|
||||||
|
node_modules/@next/swc-linux-x64-musl
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
RUN mkdir -p /app/data /app/uploads
|
RUN apt-get update && apt-get install -y --no-install-recommends sqlite3 && rm -rf /var/lib/apt/lists/* && mkdir -p /app/data /app/uploads
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "server.js"]
|
CMD ["sh", "-c", "HOSTNAME=0.0.0.0 node server.js"]
|
||||||
|
|
|
||||||
56
README.md
56
README.md
|
|
@ -13,39 +13,51 @@
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- 设备 CRUD(创建、编辑、删除、搜索、筛选)
|
- 设备 CRUD(创建、编辑、删除、搜索、列筛选、高级查询)
|
||||||
- 设备状态管理(腾讯使用 / 已回收(退役) / 备件 / 借用 / 故障)
|
- 设备状态管理(腾讯使用 / 图灵使用 / 闲置 / 备用 / 维修中 / 已下线)
|
||||||
- 设备详情(含硬件信息、历史工单卡片)
|
- 设备详情(含完整硬件信息、历史工单卡片)
|
||||||
|
- 批量编辑(多选设备批量修改类型、位置、状态等)
|
||||||
|
- Excel 导入/导出(模板下载、多选导出、全量导出权限控制)
|
||||||
- 工单历史联动(调用 issue-ai API 获取同 IP 历史工单)
|
- 工单历史联动(调用 issue-ai API 获取同 IP 历史工单)
|
||||||
- API Key 管理(支持服务间调用认证)
|
- API Key 管理(支持服务间调用认证,细粒度权限控制)
|
||||||
- 用户/角色权限管理
|
- 用户/角色权限管理(12 个细粒度权限,支持自定义角色)
|
||||||
- Excel 导出设备清单
|
- 审计日志
|
||||||
|
|
||||||
## 设备字段
|
## 设备字段
|
||||||
|
|
||||||
| 字段 | 说明 |
|
| 分类 | 字段 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| node_name | 节点名称 |
|
| 设备标识 | node_name、serial_number、device_type、device_purpose、status |
|
||||||
| serial_number | 序列号 |
|
| 位置信息 | room、rack_position |
|
||||||
| device_type | 设备类型(GPU服务器 / 存储服务器) |
|
| 网络 | business_ip、hdm_ip、NIC×3(型号/类型/速率/数量) |
|
||||||
| business_ip | 业务 IP |
|
| 硬件规格 | manufacturer、device_model、warranty_date |
|
||||||
| hdm_ip | HDM 管理 IP |
|
| CPU/内存 | cpu_model、cpu_generation、cpu_cores、cpu_threads、memory_total 等 |
|
||||||
| manufacturer | 厂商 |
|
| GPU | gpu_model、gpu_power、gpu_count |
|
||||||
| device_model | 设备型号 |
|
| 存储 | sys_disk、data_disk1/2(型号/规格/容量/类型/协议)、raid_model、raid_spec |
|
||||||
| status | 设备状态 |
|
| 电源 | psu1/2_model、psu1/2_power、psu_total_power |
|
||||||
| cabinet | 机柜 |
|
|
||||||
| asset_number | 资产编号 |
|
完整 schema 含 68 列硬件字段,详见 `src/lib/db-schema.ts`。
|
||||||
| remark | 备注 |
|
|
||||||
|
|
||||||
## API 路由
|
## API 路由
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| GET/POST | `/api/assets` | 设备列表 / 创建设备 |
|
| GET/POST | `/api/assets` | 设备列表(分页/搜索/列筛选/排序)/ 创建设备 |
|
||||||
| GET/PUT/DELETE | `/api/assets/[id]` | 单个设备操作 |
|
| GET/PUT/DELETE | `/api/assets/[id]` | 单个设备操作 |
|
||||||
| GET/POST | `/api/api-keys` | API Key 管理 |
|
| POST | `/api/assets/batch` | 批量修改设备 |
|
||||||
| GET/POST | `/api/users` | 用户管理 |
|
| POST | `/api/assets/import` | Excel 导入 |
|
||||||
| GET | `/api/stats` | 统计概览 |
|
| GET | `/api/assets/export` | Excel 导出 |
|
||||||
|
| GET | `/api/assets/field-values` | 获取字段可选值(列筛选下拉) |
|
||||||
|
| GET | `/api/stats` | 统计概览(按状态/类型/厂商/机房) |
|
||||||
|
| GET/POST | `/api/api-keys` | API Key 列表 / 创建(仅显示一次) |
|
||||||
|
| DELETE | `/api/api-keys/[id]` | 删除 Key |
|
||||||
|
| GET/POST | `/api/users` | 用户列表 / 创建 |
|
||||||
|
| GET/PUT/DELETE | `/api/users/[id]` | 单个用户操作 |
|
||||||
|
| GET/POST | `/api/roles` | 角色列表 / 创建 |
|
||||||
|
| PUT/DELETE | `/api/roles/[id]` | 更新角色权限 / 删除角色 |
|
||||||
|
| POST | `/api/auth/login` | 登录 |
|
||||||
|
| POST | `/api/auth/logout` | 登出 |
|
||||||
|
| GET | `/api/auth/me` | 当前用户信息及权限 |
|
||||||
|
|
||||||
……
|
……
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
# assets-ai 生产环境配置(模板)
|
||||||
|
DATABASE_PATH=/app/data/assets.db
|
||||||
|
JWT_SECRET=__JWT_SECRET__
|
||||||
|
COOKIE_DOMAIN=.tlyq.ai
|
||||||
|
NODE_ENV=production
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||||
|
LDAP_URL=ldap://lldap:3890
|
||||||
|
LDAP_BASE_DN=dc=tlyq,dc=ai
|
||||||
|
LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
|
||||||
|
AUTHELIA_URL=https://sso.tlyq.ai
|
||||||
|
OIDC_CLIENT_ID=assets-oidc
|
||||||
|
OIDC_CLIENT_SECRET=__OIDC_CLIENT_SECRET__
|
||||||
|
OIDC_REDIRECT_URI=https://assets.tlyq.ai/api/auth/callback
|
||||||
|
ISSUE_API_URL=http://issue-ai:3000/api
|
||||||
|
NEXT_PUBLIC_ISSUE_URL=https://issue.tlyq.ai/tickets
|
||||||
|
|
@ -3,24 +3,45 @@ services:
|
||||||
build: .
|
build: .
|
||||||
container_name: assets-ai
|
container_name: assets-ai
|
||||||
ports:
|
ports:
|
||||||
- "5177:3000"
|
- "6177:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- assets-data:/app/data
|
- assets-data:/app/data
|
||||||
- assets-uploads:/app/uploads
|
- assets-uploads:/app/uploads
|
||||||
# .next 目录从主机挂载,主机上 npm run build 后直接生效
|
# .next 目录从主机挂载,主机上 npm run build 后直接生效
|
||||||
- ./.next:/app/.next
|
- ./.next:/app/.next
|
||||||
|
# 运行时从 LLDAP 容器动态读取 admin 密码(已迁移至环境变量注入)
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_PATH=/app/data/assets.db
|
- DATABASE_PATH=/app/data/assets.db
|
||||||
- JWT_SECRET=${ASSETS_JWT_SECRET:-change-me-in-production}
|
- JWT_SECRET=${JWT_SECRET:-oa-shared-jwt-secret-tlyq-2026}
|
||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||||
|
- LLDAP_ADMIN_PASSWORD=${LLDAP_ADMIN_PASSWORD}
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
- COOKIE_DOMAIN=.tlyq.ai
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
|
- NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||||
|
- AUTHELIA_URL=${AUTHELIA_URL:-https://sso.tlyq.ai}
|
||||||
|
- LDAP_URL=ldap://lldap:3890
|
||||||
|
- LDAP_BASE_DN=dc=tlyq,dc=ai
|
||||||
|
- LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
|
||||||
# issue-ai API 地址(容器内使用 issue-ai 服务名)
|
# issue-ai API 地址(容器内使用 issue-ai 服务名)
|
||||||
- ISSUE_API_URL=http://issue-ai:3000/api
|
- ISSUE_API_URL=http://issue-ai:3000/api
|
||||||
# issue-ai API Key(用于服务间认证)
|
# issue-ai API Key(用于服务间认证)
|
||||||
- ISSUE_API_KEY=${ISSUE_API_KEY:-your-secret-api-key-change-in-production}
|
- ISSUE_API_KEY=${ISSUE_API_KEY:-your-secret-api-key-change-in-production}
|
||||||
|
# 允许调用本系统的 API Key,逗号分隔(中间件验证用)
|
||||||
|
- ALLOWED_API_KEYS=${ALLOWED_API_KEYS}
|
||||||
# 故障历史跳转的工单系统地址(客户端使用)
|
# 故障历史跳转的工单系统地址(客户端使用)
|
||||||
- NEXT_PUBLIC_ISSUE_URL=https://issue.tlyq.ai/tickets
|
- NEXT_PUBLIC_ISSUE_URL=https://issue.tlyq.ai/tickets
|
||||||
|
# OIDC 配置
|
||||||
|
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-assets-oidc}
|
||||||
|
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
|
||||||
|
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-https://assets.tlyq.ai/api/auth/callback}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- webnet
|
- webnet
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ const nextConfig: NextConfig = {
|
||||||
images: { unoptimized: true },
|
images: { unoptimized: true },
|
||||||
eslint: { ignoreDuringBuilds: true },
|
eslint: { ignoreDuringBuilds: true },
|
||||||
typescript: { ignoreBuildErrors: true },
|
typescript: { ignoreBuildErrors: true },
|
||||||
serverExternalPackages: ['better-sqlite3'],
|
// better-sqlite3: 原生模块,必须 external
|
||||||
|
// ldapts: SSO 自动创建用户依赖 LLDAP 验证,缺少则新用户无法免登录进入系统
|
||||||
|
serverExternalPackages: ['better-sqlite3', 'ldapts'],
|
||||||
}
|
}
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,10 @@
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^11.7.0",
|
"better-sqlite3": "^11.7.0",
|
||||||
"cookie": "^1.0.2",
|
"cookie": "^1.0.2",
|
||||||
|
"ldapts": "^8.1.7",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
|
"openid-client": "^5.7.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
|
|
@ -2830,6 +2832,15 @@
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "4.15.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||||
|
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-buffer": {
|
"node_modules/json-buffer": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||||
|
|
@ -2861,6 +2872,18 @@
|
||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ldapts": {
|
||||||
|
"version": "8.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.1.7.tgz",
|
||||||
|
"integrity": "sha512-TJl6T92eIwMf/OJ0hDfKVa6ISwzo+lqCWCI5Mf//ARlKa3LKQZaSrme/H2rCRBhW0DZCQlrsV+fgoW5YHRNLUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"strict-event-emitter-types": "2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
|
|
@ -3164,6 +3187,18 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lru-cache": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.9.0.tgz",
|
||||||
|
|
@ -3356,6 +3391,24 @@
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-hash": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/oidc-token-hash": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^10.13.0 || >=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/once": {
|
"node_modules/once": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
|
|
@ -3365,6 +3418,21 @@
|
||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openid-client": {
|
||||||
|
"version": "5.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||||
|
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jose": "^4.15.9",
|
||||||
|
"lru-cache": "^6.0.0",
|
||||||
|
"object-hash": "^2.2.0",
|
||||||
|
"oidc-token-hash": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
|
|
@ -3840,6 +3908,12 @@
|
||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strict-event-emitter-types": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/string_decoder": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
|
@ -4126,6 +4200,12 @@
|
||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/yallist": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NODE_OPTIONS='--max-old-space-size=2048' next dev --port 5177",
|
"dev": "NODE_OPTIONS='--max-old-space-size=2048' next dev --port 6177",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
|
@ -14,8 +14,10 @@
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^11.7.0",
|
"better-sqlite3": "^11.7.0",
|
||||||
"cookie": "^1.0.2",
|
"cookie": "^1.0.2",
|
||||||
|
"ldapts": "^8.1.7",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
|
"openid-client": "^5.7.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,15 @@ export default function EditAssetPage() {
|
||||||
const [asset, setAsset] = useState<Asset | null>(null)
|
const [asset, setAsset] = useState<Asset | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [authorized, setAuthorized] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/auth/me').then(r => r.json()).then(d => {
|
||||||
|
const perms: string[] = d.user?.permissions || []
|
||||||
|
if (!perms.includes('*') && !perms.includes('assets:update')) router.replace('/assets')
|
||||||
|
else setAuthorized(true)
|
||||||
|
}).catch(() => router.replace('/assets'))
|
||||||
|
}, [router])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
|
|
@ -35,6 +44,7 @@ export default function EditAssetPage() {
|
||||||
} finally { setSaving(false) }
|
} finally { setSaving(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!authorized) return <div className="py-20 text-center text-slate-500 dark:text-slate-400">验证权限中...</div>
|
||||||
if (loading) return <div className="py-20 text-center text-slate-500 dark:text-slate-400">加载中...</div>
|
if (loading) return <div className="py-20 text-center text-slate-500 dark:text-slate-400">加载中...</div>
|
||||||
if (!asset) return null
|
if (!asset) return null
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export default function AssetDetailPage() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showDelete, setShowDelete] = useState(false)
|
const [showDelete, setShowDelete] = useState(false)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [permissions, setPermissions] = useState<string[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
|
|
@ -26,6 +27,12 @@ export default function AssetDetailPage() {
|
||||||
load().finally(() => setLoading(false))
|
load().finally(() => setLoading(false))
|
||||||
}, [params.id, router])
|
}, [params.id, router])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/auth/me').then(r => r.json()).then(d => {
|
||||||
|
if (d.user?.permissions) setPermissions(d.user.permissions)
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
setDeleting(true)
|
setDeleting(true)
|
||||||
try {
|
try {
|
||||||
|
|
@ -48,8 +55,12 @@ export default function AssetDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link href={`/assets/${asset.id}/edit`}><Button variant="secondary" size="sm"><Edit size={16} />编辑</Button></Link>
|
{(permissions.includes('*') || permissions.includes('assets:update')) && (
|
||||||
<Button variant="danger" size="sm" onClick={() => setShowDelete(true)}><Trash2 size={16} />删除</Button>
|
<Link href={`/assets/${asset.id}/edit`}><Button variant="secondary" size="sm"><Edit size={16} />编辑</Button></Link>
|
||||||
|
)}
|
||||||
|
{(permissions.includes('*') || permissions.includes('assets:delete')) && (
|
||||||
|
<Button variant="danger" size="sm" onClick={() => setShowDelete(true)}><Trash2 size={16} />删除</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,24 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import AssetImport from '@/components/assets/AssetImport'
|
import AssetImport from '@/components/assets/AssetImport'
|
||||||
import { ArrowLeft } from 'lucide-react'
|
import { ArrowLeft } from 'lucide-react'
|
||||||
|
|
||||||
export default function ImportPage() {
|
export default function ImportPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [authorized, setAuthorized] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/auth/me').then(r => r.json()).then(d => {
|
||||||
|
const perms: string[] = d.user?.permissions || []
|
||||||
|
if (!perms.includes('*') && !perms.includes('assets:import')) router.replace('/assets')
|
||||||
|
else setAuthorized(true)
|
||||||
|
}).catch(() => router.replace('/assets'))
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
if (!authorized) return <div className="py-20 text-center text-slate-500 dark:text-slate-400">验证权限中...</div>
|
||||||
|
|
||||||
async function handleImport(file: File) {
|
async function handleImport(file: File) {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
'use client'
|
'use client'
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import AssetForm from '@/components/assets/AssetForm'
|
import AssetForm from '@/components/assets/AssetForm'
|
||||||
|
|
@ -8,6 +8,15 @@ import { ArrowLeft } from 'lucide-react'
|
||||||
export default function NewAssetPage() {
|
export default function NewAssetPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [authorized, setAuthorized] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/auth/me').then(r => r.json()).then(d => {
|
||||||
|
const perms: string[] = d.user?.permissions || []
|
||||||
|
if (!perms.includes('*') && !perms.includes('assets:create')) router.replace('/assets')
|
||||||
|
else setAuthorized(true)
|
||||||
|
}).catch(() => router.replace('/assets'))
|
||||||
|
}, [router])
|
||||||
|
|
||||||
async function handleSubmit(data: Record<string, unknown>) {
|
async function handleSubmit(data: Record<string, unknown>) {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
|
@ -22,6 +31,8 @@ export default function NewAssetPage() {
|
||||||
} finally { setSaving(false) }
|
} finally { setSaving(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!authorized) return <div className="py-20 text-center text-slate-500 dark:text-slate-400">验证权限中...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,10 @@ const statusColor: Record<string, 'blue' | 'green' | 'yellow' | 'red' | 'gray'>
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
{ key: 'device_type', label: '设备类型', sortable: true },
|
|
||||||
{ key: 'node_name', label: '节点名称', sortable: true },
|
{ key: 'node_name', label: '节点名称', sortable: true },
|
||||||
{ key: 'business_ip', label: '业务IP', sortable: true },
|
{ key: 'business_ip', label: '业务IP', sortable: true },
|
||||||
{ key: 'hdm_ip', label: 'HDM IP', sortable: true },
|
{ key: 'hdm_ip', label: 'HDM IP', sortable: true },
|
||||||
|
{ key: 'device_type', label: '设备类型', sortable: true },
|
||||||
{ key: 'manufacturer', label: '厂商', sortable: true },
|
{ key: 'manufacturer', label: '厂商', sortable: true },
|
||||||
{ key: 'device_model', label: '设备型号', sortable: true },
|
{ key: 'device_model', label: '设备型号', sortable: true },
|
||||||
{ key: 'serial_number', label: '序列号', sortable: true },
|
{ key: 'serial_number', label: '序列号', sortable: true },
|
||||||
|
|
@ -36,12 +36,14 @@ export default function AssetsPage() {
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [pageSize, setPageSize] = useState(20)
|
const [pageSize, setPageSize] = useState(20)
|
||||||
const [sortKey, setSortKey] = useState('device_type')
|
const [sortKey, setSortKey] = useState('node_name')
|
||||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||||
const [deleteTarget, setDeleteTarget] = useState<number | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<number | null>(null)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [exportModalOpen, setExportModalOpen] = useState(false)
|
const [exportModalOpen, setExportModalOpen] = useState(false)
|
||||||
|
const [exportMode, setExportMode] = useState<'all' | 'selected'>('all')
|
||||||
|
const [permissions, setPermissions] = useState<string[]>([])
|
||||||
|
|
||||||
// 列头筛选相关状态
|
// 列头筛选相关状态
|
||||||
const [tableMode, setTableMode] = useState<'sort' | 'filter'>('sort')
|
const [tableMode, setTableMode] = useState<'sort' | 'filter'>('sort')
|
||||||
|
|
@ -283,6 +285,12 @@ export default function AssetsPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/auth/me').then(r => r.json()).then(d => {
|
||||||
|
if (d.user?.permissions) setPermissions(d.user.permissions)
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const fetchAssets = useCallback(async () => {
|
const fetchAssets = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
|
|
@ -309,7 +317,7 @@ export default function AssetsPage() {
|
||||||
setSearch(q); setPage(1)
|
setSearch(q); setPage(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePageChange(p: number) { setPage(p) }
|
function handlePageChange(p: number) { setPage(p); setSelectedIds(new Set()) }
|
||||||
function handlePageSizeChange(s: number) { setPageSize(s); setPage(1) }
|
function handlePageSizeChange(s: number) { setPageSize(s); setPage(1) }
|
||||||
|
|
||||||
function toggleSelect(id: number) {
|
function toggleSelect(id: number) {
|
||||||
|
|
@ -337,10 +345,14 @@ export default function AssetsPage() {
|
||||||
|
|
||||||
function handleExportConfirm() {
|
function handleExportConfirm() {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (search) params.set('search', search)
|
if (exportMode === 'selected' && selectedIds.size > 0) {
|
||||||
for (const [field, values] of Object.entries(columnFilterValues)) {
|
params.set('ids', [...selectedIds].join(','))
|
||||||
for (const v of values) {
|
} else {
|
||||||
params.append(`filter_${field}`, v)
|
if (search) params.set('search', search)
|
||||||
|
for (const [field, values] of Object.entries(columnFilterValues)) {
|
||||||
|
for (const v of values) {
|
||||||
|
params.append(`filter_${field}`, v)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setExportModalOpen(false)
|
setExportModalOpen(false)
|
||||||
|
|
@ -353,6 +365,14 @@ export default function AssetsPage() {
|
||||||
// 是否有任何列筛选激活
|
// 是否有任何列筛选激活
|
||||||
const hasActiveColumnFilters = Object.values(columnFilterValues).some(v => v.length > 0)
|
const hasActiveColumnFilters = Object.values(columnFilterValues).some(v => v.length > 0)
|
||||||
|
|
||||||
|
const hasWildcard = permissions.includes('*')
|
||||||
|
const canExportAll = hasWildcard || permissions.includes('assets:export:all')
|
||||||
|
const canExportSelected = hasWildcard || permissions.includes('assets:export:selected')
|
||||||
|
const canCreate = hasWildcard || permissions.includes('assets:create')
|
||||||
|
const canImport = hasWildcard || permissions.includes('assets:import')
|
||||||
|
const canUpdate = hasWildcard || permissions.includes('assets:update')
|
||||||
|
const canDelete = hasWildcard || permissions.includes('assets:delete')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
|
@ -364,15 +384,26 @@ export default function AssetsPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{selectedIds.size > 0 && (
|
{canUpdate && selectedIds.size > 0 && (
|
||||||
<Link href={`/assets/batch-edit?ids=${[...selectedIds].join(',')}`}>
|
<Link href={`/assets/batch-edit?ids=${[...selectedIds].join(',')}`}>
|
||||||
<Button variant="secondary" size="sm">批量编辑 {selectedIds.size} 台</Button>
|
<Button variant="secondary" size="sm">批量编辑 {selectedIds.size} 台</Button>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<Link href="/assets/import"><Button variant="secondary" size="sm"><Upload size={14} />导入</Button></Link>
|
{canImport && (
|
||||||
<Link href="/assets/template"><Button variant="secondary" size="sm"><Download size={14} />模板</Button></Link>
|
<Link href="/assets/import"><Button variant="secondary" size="sm"><Upload size={14} />导入</Button></Link>
|
||||||
<Button variant="secondary" size="sm" onClick={() => setExportModalOpen(true)}><Download size={14} />导出</Button>
|
)}
|
||||||
<Link href="/assets/new"><Button size="sm"><Plus size={14} />新增设备</Button></Link>
|
{canImport && (
|
||||||
|
<a href="/api/assets/template" download><Button variant="secondary" size="sm"><Download size={14} />模板</Button></a>
|
||||||
|
)}
|
||||||
|
{canExportSelected && selectedIds.size > 0 && (
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => { setExportMode('selected'); setExportModalOpen(true) }}><Download size={14} />导出选中({selectedIds.size})</Button>
|
||||||
|
)}
|
||||||
|
{canExportAll && (
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => { setExportMode('all'); setExportModalOpen(true) }}><Download size={14} />{selectedIds.size > 0 ? '导出全部' : '导出'}</Button>
|
||||||
|
)}
|
||||||
|
{canCreate && (
|
||||||
|
<Link href="/assets/new"><Button size="sm"><Plus size={14} />新增设备</Button></Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -519,12 +550,12 @@ export default function AssetsPage() {
|
||||||
<input type="checkbox" checked={selectedIds.has(row.id)} onChange={() => toggleSelect(row.id)}
|
<input type="checkbox" checked={selectedIds.has(row.id)} onChange={() => toggleSelect(row.id)}
|
||||||
className="rounded border-slate-300 dark:border-slate-600" />
|
className="rounded border-slate-300 dark:border-slate-600" />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.device_type}</td>
|
|
||||||
<td className="px-4 py-3 font-medium text-center break-all">
|
<td className="px-4 py-3 font-medium text-center break-all">
|
||||||
<Link href={`/assets/${row.id}`} className="text-blue-600 dark:text-blue-400 hover:underline">{row.node_name || '-'}</Link>
|
<Link href={`/assets/${row.id}`} className="text-blue-600 dark:text-blue-400 hover:underline">{row.node_name || '-'}</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.business_ip || '-'}</td>
|
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.business_ip || '-'}</td>
|
||||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.hdm_ip ? <a href={`http://${row.hdm_ip}`} target="_blank" rel="noopener noreferrer" className="text-blue-600 dark:text-blue-400 hover:underline">{row.hdm_ip}</a> : '-'}</td>
|
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.hdm_ip ? <a href={`http://${row.hdm_ip}`} target="_blank" rel="noopener noreferrer" className="text-blue-600 dark:text-blue-400 hover:underline">{row.hdm_ip}</a> : '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.device_type}</td>
|
||||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.manufacturer || '-'}</td>
|
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.manufacturer || '-'}</td>
|
||||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.device_model || '-'}</td>
|
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.device_model || '-'}</td>
|
||||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.serial_number || '-'}</td>
|
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.serial_number || '-'}</td>
|
||||||
|
|
@ -535,14 +566,18 @@ export default function AssetsPage() {
|
||||||
className="p-1.5 rounded-lg text-slate-500 hover:text-blue-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
|
className="p-1.5 rounded-lg text-slate-500 hover:text-blue-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
|
||||||
<Eye size={16} />
|
<Eye size={16} />
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={`/assets/${row.id}/edit`}
|
{canUpdate && (
|
||||||
className="p-1.5 rounded-lg text-slate-500 hover:text-green-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
|
<Link href={`/assets/${row.id}/edit`}
|
||||||
<Edit size={16} />
|
className="p-1.5 rounded-lg text-slate-500 hover:text-green-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
|
||||||
</Link>
|
<Edit size={16} />
|
||||||
<button onClick={() => setDeleteTarget(row.id)}
|
</Link>
|
||||||
className="p-1.5 rounded-lg text-slate-500 hover:text-red-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
|
)}
|
||||||
<Trash2 size={16} />
|
{canDelete && (
|
||||||
</button>
|
<button onClick={() => setDeleteTarget(row.id)}
|
||||||
|
className="p-1.5 rounded-lg text-slate-500 hover:text-red-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -566,12 +601,20 @@ export default function AssetsPage() {
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* 导出确认 */}
|
{/* 导出确认 */}
|
||||||
<Modal open={exportModalOpen} onClose={() => setExportModalOpen(false)} title="确认导出">
|
<Modal open={exportModalOpen} onClose={() => setExportModalOpen(false)} title={exportMode === 'selected' ? '导出选中设备' : '导出全部设备'}>
|
||||||
<p className="text-slate-700 dark:text-slate-300 mb-3">
|
{exportMode === 'selected' ? (
|
||||||
即将导出 <span className="font-medium">{total}</span> 台设备的资产数据。
|
<p className="text-slate-700 dark:text-slate-300 mb-3">
|
||||||
</p>
|
即将导出已选中的 <span className="font-medium">{selectedIds.size}</span> 台设备的资产数据。
|
||||||
{(Object.keys(columnFilterValues).length > 0 || search) && (
|
</p>
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">当前筛选条件将一并应用,仅导出匹配的数据。</p>
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-slate-700 dark:text-slate-300 mb-3">
|
||||||
|
即将导出 <span className="font-medium">{total}</span> 台设备的资产数据。
|
||||||
|
</p>
|
||||||
|
{(Object.keys(columnFilterValues).length > 0 || search) && (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">当前筛选条件将一并应用,仅导出匹配的数据。</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-end gap-3 mt-4">
|
<div className="flex justify-end gap-3 mt-4">
|
||||||
<Button variant="secondary" onClick={() => setExportModalOpen(false)}>取消</Button>
|
<Button variant="secondary" onClick={() => setExportModalOpen(false)}>取消</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,27 @@
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
import { cookies, headers } from 'next/headers'
|
import { cookies } from 'next/headers'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import db from '@/lib/db'
|
import db from '@/lib/db'
|
||||||
import { verifyJwt } from '@/lib/auth'
|
|
||||||
import AppShell from '@/components/layout/AppShell'
|
import AppShell from '@/components/layout/AppShell'
|
||||||
|
|
||||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
const cookieStore = await cookies()
|
const cookieStore = await cookies()
|
||||||
const headersList = await headers()
|
|
||||||
const originalPath = headersList.get('x-original-pathname') || ''
|
|
||||||
const loginUrl = '/login' + (originalPath ? `?redirect=${encodeURIComponent(originalPath)}` : '')
|
|
||||||
|
|
||||||
const token = cookieStore.get('session_assets')?.value
|
// 从 middleware 设置的 session cookie 获取用户名
|
||||||
if (!token) redirect(loginUrl)
|
const sessionCookie = cookieStore.get('session')?.value
|
||||||
const payload = verifyJwt(token)
|
let username = ''
|
||||||
if (!payload) redirect(loginUrl)
|
if (sessionCookie) {
|
||||||
const user = db.prepare('SELECT display_name, role FROM users WHERE id = ? AND is_active = 1').get(payload.userId) as { display_name: string; role: string } | undefined
|
try { username = JSON.parse(sessionCookie).username || '' } catch { }
|
||||||
if (!user) redirect(loginUrl)
|
}
|
||||||
return <AppShell user={user}>{children}</AppShell>
|
if (!username) redirect('/login')
|
||||||
|
|
||||||
|
// 从数据库获取用户完整信息
|
||||||
|
const user = db.prepare(
|
||||||
|
'SELECT id, username, display_name, role FROM users WHERE username = ? AND is_active = 1'
|
||||||
|
).get(username) as { id: number; username: string; display_name: string; role: string } | undefined
|
||||||
|
|
||||||
|
if (!user) redirect('/login')
|
||||||
|
|
||||||
|
return <AppShell user={{ display_name: user.display_name, role: user.role }}>{children}</AppShell>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export default function ApiKeysPage() {
|
||||||
const [newKey, setNewKey] = useState<string | null>(null)
|
const [newKey, setNewKey] = useState<string | null>(null)
|
||||||
const [deleteTarget, setDeleteTarget] = useState<ApiKeyItem | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<ApiKeyItem | null>(null)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
async function fetchKeys() {
|
async function fetchKeys() {
|
||||||
const res = await fetch('/api/api-keys')
|
const res = await fetch('/api/api-keys')
|
||||||
|
|
@ -31,20 +32,28 @@ export default function ApiKeysPage() {
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
if (!name.trim()) return
|
if (!name.trim()) return
|
||||||
setSaving(true)
|
setSaving(true); setError('')
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/api-keys', {
|
const res = await fetch('/api/api-keys', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name: name.trim() }),
|
body: JSON.stringify({ name: name.trim() }),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
const data = await res.json()
|
||||||
const data = await res.json()
|
if (!res.ok) { setError(data.error || '创建失败'); return }
|
||||||
setNewKey(data.key)
|
setNewKey(data.key)
|
||||||
setCreateOpen(false)
|
setCreateOpen(false)
|
||||||
setName('')
|
setName('')
|
||||||
fetchKeys()
|
fetchKeys()
|
||||||
}
|
} catch { setError('创建失败') }
|
||||||
} finally { setSaving(false) }
|
finally { setSaving(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleActive(k: ApiKeyItem) {
|
||||||
|
await fetch(`/api/api-keys/${k.id}`, {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ is_active: k.is_active ? 0 : 1 }),
|
||||||
|
})
|
||||||
|
fetchKeys()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
|
|
@ -67,19 +76,22 @@ export default function ApiKeysPage() {
|
||||||
const perms: string[] = JSON.parse(r.permissions)
|
const perms: string[] = JSON.parse(r.permissions)
|
||||||
return <div className="flex flex-wrap gap-1">{perms.map(p => <Badge key={p} color="gray">{p}</Badge>)}</div>
|
return <div className="flex flex-wrap gap-1">{perms.map(p => <Badge key={p} color="gray">{p}</Badge>)}</div>
|
||||||
}},
|
}},
|
||||||
{ key: 'is_active', title: '状态', render: (r) => <Badge color={r.is_active ? 'green' : 'red'}>{r.is_active ? '启用' : '禁用'}</Badge> },
|
{ key: 'is_active', title: '状态', render: (r) => <button onClick={() => toggleActive(r)}><Badge color={r.is_active ? 'green' : 'red'}>{r.is_active ? '启用' : '禁用'}</Badge></button> },
|
||||||
{ key: 'last_used_at', title: '最后使用', render: (r) => r.last_used_at || '从未使用' },
|
{ key: 'last_used_at', title: '最后使用', render: (r) => r.last_used_at || '从未使用' },
|
||||||
{ key: 'expires_at', title: '过期时间', render: (r) => r.expires_at || '永不过期' },
|
{ key: 'expires_at', title: '过期时间', render: (r) => r.expires_at || '永不过期' },
|
||||||
{ key: 'created_at', title: '创建时间' },
|
{ key: 'created_at', title: '创建时间' },
|
||||||
{ key: 'actions', title: '操作', render: (r) => (
|
{ key: 'actions', title: '操作', render: (r) => (
|
||||||
<button onClick={() => setDeleteTarget(r)} className="p-1.5 rounded-lg text-slate-500 hover:text-red-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"><Trash2 size={16} /></button>
|
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}><Trash2 size={14} className="text-red-500" /></Button>
|
||||||
)},
|
)},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">API Key 管理</h1>
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">API Key 管理</h1>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">用于第三方系统调用资产系统 API</p>
|
||||||
|
</div>
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}><Plus size={16} />创建 Key</Button>
|
<Button size="sm" onClick={() => setCreateOpen(true)}><Plus size={16} />创建 Key</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -101,15 +113,20 @@ export default function ApiKeysPage() {
|
||||||
<Button onClick={handleCreate} loading={saving}>创建</Button>
|
<Button onClick={handleCreate} loading={saving}>创建</Button>
|
||||||
</>
|
</>
|
||||||
}>
|
}>
|
||||||
<Input label="名称" value={name} onChange={e => setName(e.target.value)} placeholder="例如:监控系统" />
|
<div className="space-y-4">
|
||||||
|
<Input label="名称" value={name} onChange={e => setName(e.target.value)} placeholder="例如:监控系统" />
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">默认权限:<Badge color="gray">assets:read</Badge>(仅读取资产数据)</p>
|
||||||
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除">
|
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除" footer={
|
||||||
<p className="text-sm text-slate-600 dark:text-slate-400">确定要删除 API Key「{deleteTarget?.name}」吗?使用该 Key 的应用将无法访问。</p>
|
<>
|
||||||
<div className="flex justify-end gap-3 mt-4">
|
|
||||||
<Button variant="ghost" onClick={() => setDeleteTarget(null)}>取消</Button>
|
<Button variant="ghost" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||||
<Button variant="danger" onClick={handleDelete}>删除</Button>
|
<Button variant="danger" onClick={handleDelete}>删除</Button>
|
||||||
</div>
|
</>
|
||||||
|
}>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">确定要删除 API Key「{deleteTarget?.name}」吗?使用该 Key 的应用将无法访问。</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,582 @@
|
||||||
|
'use client'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import Table, { Column } from '@/components/ui/Table'
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
import Modal from '@/components/ui/Modal'
|
||||||
|
import Badge from '@/components/ui/Badge'
|
||||||
|
|
||||||
|
interface AuditLog {
|
||||||
|
id: number
|
||||||
|
user_id: number | null
|
||||||
|
api_key_id: number | null
|
||||||
|
action: string
|
||||||
|
entity_type: string
|
||||||
|
entity_id: number | null
|
||||||
|
details: string | null
|
||||||
|
ip_address: string | null
|
||||||
|
created_at: string
|
||||||
|
username: string | null
|
||||||
|
api_key_name: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<string, { label: string; color: 'green' | 'blue' | 'red' | 'gray' }> = {
|
||||||
|
create: { label: '创建', color: 'green' },
|
||||||
|
update: { label: '更新', color: 'blue' },
|
||||||
|
delete: { label: '删除', color: 'red' },
|
||||||
|
batch_update: { label: '批量更新', color: 'blue' },
|
||||||
|
import: { label: '导入', color: 'blue' },
|
||||||
|
export: { label: '导出', color: 'gray' },
|
||||||
|
login: { label: '登录', color: 'gray' },
|
||||||
|
logout: { label: '登出', color: 'gray' },
|
||||||
|
sync_emails: { label: '同步邮箱', color: 'blue' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTITY_LABELS: Record<string, string> = {
|
||||||
|
asset: '资产',
|
||||||
|
user: '用户',
|
||||||
|
role: '角色',
|
||||||
|
api_key: 'API Key',
|
||||||
|
audit_log: '审计日志',
|
||||||
|
auth: '认证',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditLogsPage() {
|
||||||
|
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize] = useState(20)
|
||||||
|
|
||||||
|
// 过滤器
|
||||||
|
const [actionFilter, setActionFilter] = useState('')
|
||||||
|
const [entityFilter, setEntityFilter] = useState('')
|
||||||
|
const [dateFrom, setDateFrom] = useState('')
|
||||||
|
const [dateTo, setDateTo] = useState('')
|
||||||
|
|
||||||
|
// 保留天数设置
|
||||||
|
const [retentionSaved, setRetentionSaved] = useState(180)
|
||||||
|
const [retentionInput, setRetentionInput] = useState('180')
|
||||||
|
const [retentionSaving, setRetentionSaving] = useState(false)
|
||||||
|
const [retentionSavedFeedback, setRetentionSavedFeedback] = useState(false)
|
||||||
|
const [retentionError, setRetentionError] = useState(false)
|
||||||
|
const retentionDirty = retentionInput !== String(retentionSaved)
|
||||||
|
|
||||||
|
// 详情 Modal
|
||||||
|
const [detailLog, setDetailLog] = useState<AuditLog | null>(null)
|
||||||
|
|
||||||
|
async function fetchLogs() {
|
||||||
|
setLoading(true)
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('page', String(page))
|
||||||
|
params.set('pageSize', String(pageSize))
|
||||||
|
if (actionFilter) params.set('action', actionFilter)
|
||||||
|
if (entityFilter) params.set('entity_type', entityFilter)
|
||||||
|
if (dateFrom) params.set('date_from', dateFrom)
|
||||||
|
if (dateTo) params.set('date_to', dateTo)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/audit-logs?${params}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setLogs(data.logs)
|
||||||
|
setTotal(data.total)
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { fetchLogs() }, [page, actionFilter, entityFilter, dateFrom, dateTo])
|
||||||
|
|
||||||
|
async function fetchRetentionDays() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/audit-logs/cleanup')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setRetentionSaved(data.days)
|
||||||
|
setRetentionInput(String(data.days))
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRetentionDays() {
|
||||||
|
const days = parseInt(retentionInput, 10)
|
||||||
|
if (isNaN(days) || days < 30 || days > 365) {
|
||||||
|
setRetentionError(true)
|
||||||
|
setTimeout(() => setRetentionError(false), 2000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRetentionSaving(true)
|
||||||
|
setRetentionError(false)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/audit-logs/cleanup', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ days })
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setRetentionSaved(days)
|
||||||
|
setRetentionSavedFeedback(true)
|
||||||
|
setTimeout(() => setRetentionSavedFeedback(false), 2000)
|
||||||
|
} else {
|
||||||
|
setRetentionError(true)
|
||||||
|
setTimeout(() => setRetentionError(false), 2000)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setRetentionError(true)
|
||||||
|
setTimeout(() => setRetentionError(false), 2000)
|
||||||
|
}
|
||||||
|
setRetentionSaving(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { fetchRetentionDays() }, [])
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
setActionFilter('')
|
||||||
|
setEntityFilter('')
|
||||||
|
setDateFrom('')
|
||||||
|
setDateTo('')
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setQuickDate(days: number) {
|
||||||
|
const today = new Date()
|
||||||
|
const from = new Date(today)
|
||||||
|
from.setDate(from.getDate() - days)
|
||||||
|
const fromStr = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, '0')}-${String(from.getDate()).padStart(2, '0')}`
|
||||||
|
const toStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||||
|
setDateFrom(fromStr)
|
||||||
|
setDateTo(toStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (actionFilter) params.set('action', actionFilter)
|
||||||
|
if (entityFilter) params.set('entity_type', entityFilter)
|
||||||
|
if (dateFrom) params.set('date_from', dateFrom)
|
||||||
|
if (dateTo) params.set('date_to', dateTo)
|
||||||
|
|
||||||
|
const res = await fetch(`/api/audit-logs/export?${params}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const blob = await res.blob()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
const now = new Date()
|
||||||
|
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||||
|
a.download = `audit-logs-${dateStr}.csv`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDetails(log: AuditLog): string {
|
||||||
|
if (!log.details) return '-'
|
||||||
|
try {
|
||||||
|
const details = JSON.parse(log.details)
|
||||||
|
if (details.changes) {
|
||||||
|
const keys = Object.keys(details.changes)
|
||||||
|
return keys.slice(0, 2).join(', ') + (keys.length > 2 ? '...' : '')
|
||||||
|
}
|
||||||
|
if (details.created) return '新建记录'
|
||||||
|
if (details.deleted) return '删除记录'
|
||||||
|
if (details.export) return `导出 ${details.export.count} 条`
|
||||||
|
if (details.batch_update) return `批量更新 ${details.batch_update.count} 条`
|
||||||
|
if (details.import) return `导入 ${details.import.created} 新 / ${details.import.updated} 更新`
|
||||||
|
if (details.message) return details.message
|
||||||
|
if (details.keys) return details.keys.slice(0, 2).join(', ') + (details.keys.length > 2 ? '...' : '')
|
||||||
|
if (details.username) return `用户 ${details.username} 登录`
|
||||||
|
if (details.retention_days !== undefined) return `保留 ${details.retention_days} 天`
|
||||||
|
if (details.cleanup) return `清理 ${details.cleanup.deleted} 条`
|
||||||
|
return '-'
|
||||||
|
} catch { return '-' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字段名汉化映射
|
||||||
|
const FIELD_LABELS: Record<string, string> = {
|
||||||
|
name: '名称', status: '状态', type: '类型', category: '分类',
|
||||||
|
model: '型号', sn: '序列号', location: '位置', department: '部门',
|
||||||
|
ip: 'IP', mac: 'MAC', os: '操作系统', cpu: 'CPU', memory: '内存',
|
||||||
|
disk: '硬盘', purchase_date: '采购日期', warranty_date: '质保到期',
|
||||||
|
description: '描述', notes: '备注', email: '邮箱', role: '角色',
|
||||||
|
username: '用户名', permissions: '权限', count: '数量',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(val: unknown): string {
|
||||||
|
if (val === null || val === undefined) return '-'
|
||||||
|
if (typeof val === 'object') return JSON.stringify(val)
|
||||||
|
return String(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetails(details: Record<string, unknown>) {
|
||||||
|
// 字段变更
|
||||||
|
if (details.changes && typeof details.changes === 'object') {
|
||||||
|
const changes = details.changes as Record<string, { from: unknown; to: unknown }>
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">📋 修改了以下字段:</p>
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-100 dark:bg-slate-700">
|
||||||
|
<th className="px-3 py-2 text-left font-medium">字段</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">旧值</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">新值</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(changes).map(([key, val]) => (
|
||||||
|
<tr key={key} className="border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<td className="px-3 py-2 text-slate-500">{FIELD_LABELS[key] || key}</td>
|
||||||
|
<td className="px-3 py-2 text-slate-400 line-through">{formatValue(val.from)}</td>
|
||||||
|
<td className="px-3 py-2 text-green-600 dark:text-green-400 font-medium">{formatValue(val.to)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新建记录
|
||||||
|
if (details.created && typeof details.created === 'object') {
|
||||||
|
const data = details.created as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-green-600 dark:text-green-400 mb-2">🟢 新建记录:</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{Object.entries(data).map(([key, val]) => (
|
||||||
|
<div key={key} className="flex gap-2 text-sm">
|
||||||
|
<span className="text-slate-500">{FIELD_LABELS[key] || key}:</span>
|
||||||
|
<span>{formatValue(val)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除记录
|
||||||
|
if (details.deleted && typeof details.deleted === 'object') {
|
||||||
|
const data = details.deleted as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-red-600 dark:text-red-400 mb-2">🔴 删除记录:</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{Object.entries(data).map(([key, val]) => (
|
||||||
|
<div key={key} className="flex gap-2 text-sm">
|
||||||
|
<span className="text-slate-500">{FIELD_LABELS[key] || key}:</span>
|
||||||
|
<span>{formatValue(val)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量更新
|
||||||
|
if (details.batch_update && typeof details.batch_update === 'object') {
|
||||||
|
const data = details.batch_update as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2">📦 批量更新 {String(data.count)} 条记录</p>
|
||||||
|
{data.ids ? <p className="text-sm text-slate-500">涉及 ID:{String(data.ids)}</p> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入
|
||||||
|
if (details.import && typeof details.import === 'object') {
|
||||||
|
const data = details.import as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2">📥 导入操作</p>
|
||||||
|
<div className="flex gap-4 text-sm">
|
||||||
|
<span>新增:{String(data.created)} 条</span>
|
||||||
|
<span>更新:{String(data.updated)} 条</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出
|
||||||
|
if (details.export && typeof details.export === 'object') {
|
||||||
|
const data = details.export as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-2">📤 导出 {String(data.count)} 条记录</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录信息
|
||||||
|
if (details.username && typeof details.username === 'string') {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">👤 登录信息:</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="flex gap-2 text-sm">
|
||||||
|
<span className="text-slate-500">用户名:</span>
|
||||||
|
<span>{details.username}</span>
|
||||||
|
</div>
|
||||||
|
{details.role && (
|
||||||
|
<div className="flex gap-2 text-sm">
|
||||||
|
<span className="text-slate-500">角色:</span>
|
||||||
|
<span>{String(details.role)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{details.displayName && (
|
||||||
|
<div className="flex gap-2 text-sm">
|
||||||
|
<span className="text-slate-500">显示名:</span>
|
||||||
|
<span>{String(details.displayName)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留天数设置
|
||||||
|
if (details.retention_days !== undefined) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2">⚙️ 保留天数设置:</p>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">日志保留期限调整为 <span className="font-medium">{String(details.retention_days)}</span> 天</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理操作
|
||||||
|
if (details.cleanup) {
|
||||||
|
const data = details.cleanup as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">🧹 清理操作:</p>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">已清理 {String(data.deleted)} 条过期日志</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:格式化 JSON
|
||||||
|
return (
|
||||||
|
<pre className="mt-2 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg text-xs font-mono overflow-x-auto">
|
||||||
|
{JSON.stringify(details, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Column<AuditLog>[] = [
|
||||||
|
{
|
||||||
|
key: 'created_at',
|
||||||
|
title: '时间',
|
||||||
|
width: '170px',
|
||||||
|
render: (r) => <span className="text-sm whitespace-nowrap">{r.created_at}</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'username',
|
||||||
|
title: '用户',
|
||||||
|
width: '100px',
|
||||||
|
render: (r) => (
|
||||||
|
<div className="flex items-center gap-1 whitespace-nowrap">
|
||||||
|
<span>{r.username || '-'}</span>
|
||||||
|
{r.api_key_id && <Badge color="yellow">[API]</Badge>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'action',
|
||||||
|
title: '操作',
|
||||||
|
width: '70px',
|
||||||
|
render: (r) => {
|
||||||
|
const info = ACTION_LABELS[r.action] || { label: r.action, color: 'gray' as const }
|
||||||
|
return <span className="whitespace-nowrap"><Badge color={info.color}>{info.label}</Badge></span>
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'entity_type',
|
||||||
|
title: '对象',
|
||||||
|
width: '80px',
|
||||||
|
render: (r) => <span className="whitespace-nowrap">{ENTITY_LABELS[r.entity_type] || r.entity_type}</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'details',
|
||||||
|
title: '详情',
|
||||||
|
render: (r) => (
|
||||||
|
<button
|
||||||
|
onClick={() => setDetailLog(r)}
|
||||||
|
className="text-left text-sm text-slate-600 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 truncate max-w-[200px]"
|
||||||
|
>
|
||||||
|
{formatDetails(r)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ip_address',
|
||||||
|
title: 'IP',
|
||||||
|
width: '120px',
|
||||||
|
render: (r) => <span className="text-xs font-mono">{r.ip_address || '-'}</span>
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">审计日志</h1>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">系统操作与事件记录</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="secondary" onClick={handleExport}>导出</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 保留天数设置 */}
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-800/50 rounded-lg text-sm">
|
||||||
|
<span className="text-slate-600 dark:text-slate-400">保留天数:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={30}
|
||||||
|
max={365}
|
||||||
|
value={retentionInput}
|
||||||
|
onChange={(e) => setRetentionInput(e.target.value)}
|
||||||
|
className="w-20 px-2 py-1 text-sm border border-slate-200 dark:border-slate-700 rounded bg-white dark:bg-slate-900"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-400">(30-365 天,默认 180 天)</span>
|
||||||
|
{retentionSavedFeedback ? (
|
||||||
|
<span className="text-xs font-medium text-green-600 dark:text-green-400">✓ 已保存</span>
|
||||||
|
) : retentionError ? (
|
||||||
|
<span className="text-xs font-medium text-red-600 dark:text-red-400">✗ 保存失败</span>
|
||||||
|
) : retentionDirty ? (
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 text-xs font-medium rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"
|
||||||
|
onClick={saveRetentionDays}
|
||||||
|
disabled={retentionSaving}
|
||||||
|
>
|
||||||
|
{retentionSaving ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 过滤器 */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 p-4 bg-slate-50 dark:bg-slate-800/50 rounded-lg">
|
||||||
|
<select
|
||||||
|
value={actionFilter}
|
||||||
|
onChange={(e) => { setActionFilter(e.target.value); setPage(1) }}
|
||||||
|
className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900"
|
||||||
|
>
|
||||||
|
<option value="">全部操作</option>
|
||||||
|
{Object.entries(ACTION_LABELS).map(([key, { label }]) => (
|
||||||
|
<option key={key} value={key}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={entityFilter}
|
||||||
|
onChange={(e) => { setEntityFilter(e.target.value); setPage(1) }}
|
||||||
|
className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900"
|
||||||
|
>
|
||||||
|
<option value="">全部对象</option>
|
||||||
|
{Object.entries(ENTITY_LABELS).map(([key, label]) => (
|
||||||
|
<option key={key} value={key}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={(e) => { setDateFrom(e.target.value); setPage(1) }}
|
||||||
|
className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900"
|
||||||
|
/>
|
||||||
|
<span className="text-slate-400">~</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={(e) => { setDateTo(e.target.value); setPage(1) }}
|
||||||
|
className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setQuickDate(7)}>7天</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setQuickDate(30)}>30天</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setQuickDate(180)}>180天</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button size="sm" variant="ghost" onClick={resetFilters}>重置</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表格 */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-12 text-center text-slate-500 dark:text-slate-400">加载中...</div>
|
||||||
|
) : (
|
||||||
|
<Table columns={columns} data={logs} rowKey={(r) => String(r.id)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 分页 */}
|
||||||
|
{total > pageSize && (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={page === 1}
|
||||||
|
onClick={() => setPage(p => p - 1)}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-slate-500">
|
||||||
|
第 {page} 页 / 共 {Math.ceil(total / pageSize)} 页
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={page >= Math.ceil(total / pageSize)}
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 详情 Modal */}
|
||||||
|
<Modal
|
||||||
|
open={!!detailLog}
|
||||||
|
onClose={() => setDetailLog(null)}
|
||||||
|
title="审计日志详情"
|
||||||
|
>
|
||||||
|
{detailLog && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">时间:</span>
|
||||||
|
<span>{detailLog.created_at}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">用户:</span>
|
||||||
|
<span>{detailLog.username || '-'}</span>
|
||||||
|
{detailLog.api_key_id && <span className="ml-1"><Badge color="yellow">[API]</Badge></span>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">操作:</span>
|
||||||
|
<Badge color={ACTION_LABELS[detailLog.action]?.color || 'gray'}>
|
||||||
|
{ACTION_LABELS[detailLog.action]?.label || detailLog.action}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">对象:</span>
|
||||||
|
<span>{ENTITY_LABELS[detailLog.entity_type] || detailLog.entity_type}</span>
|
||||||
|
{detailLog.entity_id && <span className="ml-1">#{detailLog.entity_id}</span>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">IP:</span>
|
||||||
|
<span className="font-mono">{detailLog.ip_address || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detailLog.details && (
|
||||||
|
<div className="mt-3 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||||
|
{renderDetails(JSON.parse(detailLog.details))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,46 +1,222 @@
|
||||||
'use client'
|
'use client'
|
||||||
import Card from '@/components/ui/Card'
|
import { useState, useEffect } from 'react'
|
||||||
|
import Table, { Column } from '@/components/ui/Table'
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
import Input from '@/components/ui/Input'
|
||||||
|
import Modal from '@/components/ui/Modal'
|
||||||
import Badge from '@/components/ui/Badge'
|
import Badge from '@/components/ui/Badge'
|
||||||
|
import { Plus, Pencil, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
const permissionLabels: Record<string, string> = {
|
interface Role {
|
||||||
'*': '所有权限',
|
id: number
|
||||||
'assets:read': '资产读取',
|
name: string
|
||||||
'assets:write': '资产写入',
|
display_name: string
|
||||||
'assets:delete': '资产删除',
|
permissions: string
|
||||||
'users:read': '用户读取',
|
created_at: string
|
||||||
'users:write': '用户管理',
|
|
||||||
'api-keys:read': 'API Key 读取',
|
|
||||||
'api-keys:write': 'API Key 管理',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultRoles = [
|
const allPermissions = [
|
||||||
{ name: 'admin', display_name: '管理员', permissions: ['*'], color: 'blue' as const },
|
{ key: 'assets:read', label: '查看资产' },
|
||||||
{ name: 'editor', display_name: '编辑者', permissions: ['assets:read', 'assets:write', 'assets:delete'], color: 'green' as const },
|
{ key: 'assets:create', label: '新增资产' },
|
||||||
{ name: 'viewer', display_name: '查看者', permissions: ['assets:read'], color: 'gray' as const },
|
{ key: 'assets:import', label: '导入资产' },
|
||||||
|
{ key: 'assets:update', label: '编辑资产' },
|
||||||
|
{ key: 'assets:delete', label: '删除资产' },
|
||||||
|
{ key: 'assets:export:selected', label: '导出选中资产' },
|
||||||
|
{ key: 'assets:export:all', label: '导出全部资产' },
|
||||||
|
{ key: 'users:read', label: '查看用户' },
|
||||||
|
{ key: 'users:write', label: '管理用户' },
|
||||||
|
{ key: 'roles:read', label: '查看角色' },
|
||||||
|
{ key: 'roles:write', label: '管理角色' },
|
||||||
|
{ key: 'api-keys:read', label: '查看API Key' },
|
||||||
|
{ key: 'api-keys:write', label: '管理API Key' },
|
||||||
|
{ key: 'audit-logs:read', label: '查看审计日志' },
|
||||||
|
{ key: 'audit-logs:export', label: '导出审计日志' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const BUILTIN_ROLES = ['admin', 'editor', 'viewer']
|
||||||
|
|
||||||
|
const roleColor: Record<string, 'blue' | 'green' | 'gray' | 'yellow'> = {
|
||||||
|
admin: 'blue', editor: 'green', viewer: 'gray',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPermissions(permStr: string): string {
|
||||||
|
try {
|
||||||
|
const perms: string[] = JSON.parse(permStr)
|
||||||
|
if (perms.includes('*')) return '全部权限'
|
||||||
|
return perms.map(p => {
|
||||||
|
const f = allPermissions.find(a => a.key === p)
|
||||||
|
return f ? f.label : p
|
||||||
|
}).join(', ') || '无权限'
|
||||||
|
} catch { return '无权限' }
|
||||||
|
}
|
||||||
|
|
||||||
export default function RolesPage() {
|
export default function RolesPage() {
|
||||||
|
const [roles, setRoles] = useState<Role[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editRole, setEditRole] = useState<Role | null>(null)
|
||||||
|
const [form, setForm] = useState({ name: '', display_name: '', permissions: [] as string[] })
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Role | null>(null)
|
||||||
|
|
||||||
|
const fetchRoles = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/roles')
|
||||||
|
const d = await res.json()
|
||||||
|
if (d.roles) setRoles(d.roles)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { fetchRoles() }, [])
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditRole(null)
|
||||||
|
setForm({ name: '', display_name: '', permissions: [] })
|
||||||
|
setError('')
|
||||||
|
setModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (r: Role) => {
|
||||||
|
setEditRole(r)
|
||||||
|
let perms: string[] = []
|
||||||
|
try { perms = JSON.parse(r.permissions) } catch { /* ignore */ }
|
||||||
|
setForm({ name: r.name, display_name: r.display_name, permissions: perms })
|
||||||
|
setError('')
|
||||||
|
setModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePermission = (key: string) => {
|
||||||
|
setForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
permissions: prev.permissions.includes(key)
|
||||||
|
? prev.permissions.filter(p => p !== key)
|
||||||
|
: [...prev.permissions, key],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setError('')
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
if (editRole) {
|
||||||
|
const res = await fetch(`/api/roles/${editRole.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ display_name: form.display_name, permissions: form.permissions }),
|
||||||
|
})
|
||||||
|
if (!res.ok) { const d = await res.json(); setError(d.error || '更新失败'); return }
|
||||||
|
} else {
|
||||||
|
if (!form.name || !form.display_name) { setError('请填写必填项'); return }
|
||||||
|
const res = await fetch('/api/roles', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
})
|
||||||
|
if (!res.ok) { const d = await res.json(); setError(d.error || '创建失败'); return }
|
||||||
|
}
|
||||||
|
setModalOpen(false)
|
||||||
|
fetchRoles()
|
||||||
|
} catch { setError('操作失败') }
|
||||||
|
finally { setSaving(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
const res = await fetch(`/api/roles/${deleteTarget.id}`, { method: 'DELETE' })
|
||||||
|
if (res.ok) { setDeleteTarget(null); fetchRoles() }
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Column<Role>[] = [
|
||||||
|
{ key: 'name', title: '角色名', render: (r) => (
|
||||||
|
<span className="font-medium text-slate-900 dark:text-slate-100">{r.name}</span>
|
||||||
|
)},
|
||||||
|
{ key: 'display_name', title: '显示名称', render: (r) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{r.display_name}</span>
|
||||||
|
{BUILTIN_ROLES.includes(r.name) && (
|
||||||
|
<Badge color={roleColor[r.name] || 'gray'}>内置</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)},
|
||||||
|
{ key: 'permissions', title: '权限', render: (r) => (
|
||||||
|
<span className="text-sm text-slate-500 dark:text-slate-400">{formatPermissions(r.permissions)}</span>
|
||||||
|
)},
|
||||||
|
{ key: 'actions', title: '操作', width: '100px', render: (r) => (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openEdit(r)}><Pencil size={14} /></Button>
|
||||||
|
{!BUILTIN_ROLES.includes(r.name) && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}><Trash2 size={14} className="text-red-500" /></Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)},
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">角色权限</h1>
|
<div className="flex items-center justify-between">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div>
|
||||||
{defaultRoles.map(role => (
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">角色权限</h1>
|
||||||
<Card key={role.name} title={role.display_name}>
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">管理系统角色与权限配置</p>
|
||||||
<div className="space-y-3">
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<Button size="sm" onClick={openCreate}><Plus size={16} />新建角色</Button>
|
||||||
<Badge color={role.color}>{role.name}</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{role.permissions.map(p => (
|
|
||||||
<span key={p} className="px-2 py-0.5 text-xs rounded bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400">
|
|
||||||
{permissionLabels[p] || p}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-12 text-center text-slate-500 dark:text-slate-400">加载中...</div>
|
||||||
|
) : (
|
||||||
|
<Table columns={columns as unknown as Column<Record<string, unknown>>[]} data={roles as unknown as Record<string, unknown>[]} rowKey={(r) => String(r.id)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal open={modalOpen} onClose={() => setModalOpen(false)} title={editRole ? '编辑角色' : '新建角色'}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!editRole && (
|
||||||
|
<Input
|
||||||
|
label="角色名(英文)"
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => setForm(p => ({ ...p, name: e.target.value }))}
|
||||||
|
placeholder="e.g. supervisor"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
label="显示名称"
|
||||||
|
value={form.display_name}
|
||||||
|
onChange={e => setForm(p => ({ ...p, display_name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">权限</label>
|
||||||
|
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
{allPermissions.map(p => (
|
||||||
|
<label key={p.key} className="flex items-center gap-2 cursor-pointer py-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.permissions.includes(p.key) || form.permissions.includes('*')}
|
||||||
|
onChange={() => togglePermission(p.key)}
|
||||||
|
className="rounded border-slate-300 dark:border-slate-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-700 dark:text-slate-300">{p.label}</span>
|
||||||
|
<span className="text-xs text-slate-400">{p.key}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button onClick={handleSave} loading={saving}>{editRole ? '保存' : '创建'}</Button>
|
||||||
|
<Button variant="secondary" onClick={() => setModalOpen(false)}>取消</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除" footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||||
|
<Button variant="danger" onClick={handleDelete}>删除</Button>
|
||||||
|
</>
|
||||||
|
}>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">确定要删除角色「{deleteTarget?.display_name}」吗?此操作不可撤销。</p>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
'use client'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useRouter, useParams } from 'next/navigation'
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
import Modal from '@/components/ui/Modal'
|
||||||
|
import Input from '@/components/ui/Input'
|
||||||
|
import Select from '@/components/ui/Select'
|
||||||
|
import Badge from '@/components/ui/Badge'
|
||||||
|
import { ArrowLeft, Edit } from 'lucide-react'
|
||||||
|
|
||||||
|
interface UserDetail {
|
||||||
|
id: number; username: string; display_name: string; email: string | null
|
||||||
|
role: string; is_active: number; created_at: string; updated_at: string
|
||||||
|
last_login_at: string | null; is_online: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserDetailPage() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const [user, setUser] = useState<UserDetail | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [roleOptions, setRoleOptions] = useState<{ value: string; label: string }[]>([])
|
||||||
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
const [form, setForm] = useState({ display_name: '', email: '', role: '', password: '', password_confirm: '', is_active: 1 })
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function fetchUser() {
|
||||||
|
const res = await fetch(`/api/users/${id}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const d = await res.json()
|
||||||
|
setUser(d.user)
|
||||||
|
setForm({ display_name: d.user.display_name, email: d.user.email || '', role: d.user.role, password: '', password_confirm: '', is_active: d.user.is_active })
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRoles() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/roles')
|
||||||
|
const d = await res.json()
|
||||||
|
if (d.roles) setRoleOptions(d.roles.map((r: { name: string; display_name: string }) => ({ value: r.name, label: r.display_name })))
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { Promise.all([fetchUser(), fetchRoles()]) }, [id])
|
||||||
|
|
||||||
|
function openEdit() { setError(''); setEditOpen(true) }
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true); setError('')
|
||||||
|
try {
|
||||||
|
if (form.password && form.password !== form.password_confirm) { setError('两次输入的密码不一致'); return }
|
||||||
|
const body: Record<string, unknown> = { display_name: form.display_name, email: form.email, is_active: form.is_active }
|
||||||
|
if (form.password) body.password = form.password
|
||||||
|
if (user!.username !== 'admin' && user!.username !== 'localadmin') body.role = form.role
|
||||||
|
const res = await fetch(`/api/users/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||||
|
if (!res.ok) { const d = await res.json(); setError(d.error || '更新失败'); return }
|
||||||
|
setEditOpen(false)
|
||||||
|
fetchUser()
|
||||||
|
} finally { setSaving(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="py-20 text-center text-slate-500">加载中...</div>
|
||||||
|
if (!user) return <div className="py-20 text-center text-slate-500">用户不存在</div>
|
||||||
|
|
||||||
|
const roleLabel = roleOptions.find(r => r.value === user.role)?.label || user.role
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<button onClick={() => router.back()} className="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors">
|
||||||
|
<ArrowLeft size={16} /> 返回用户列表
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">用户详情 — {user.display_name}</h1>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 mt-1">查看和管理用户信息</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={openEdit}><Edit size={16} />编辑用户</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-700 divide-y divide-slate-200 dark:divide-slate-700">
|
||||||
|
{[
|
||||||
|
{ label: '用户名', value: user.username },
|
||||||
|
{ label: '显示名称', value: user.display_name },
|
||||||
|
{ label: '邮箱', value: user.email || '-' },
|
||||||
|
{ label: '角色', value: <Badge color={user.role === 'admin' ? 'blue' : user.role === 'editor' ? 'green' : 'gray'}>{roleLabel}</Badge> },
|
||||||
|
{ label: '状态', value: <Badge color={user.is_active ? 'green' : 'red'}>{user.is_active ? '启用' : '禁用'}</Badge> },
|
||||||
|
{ label: '在线', value: <span className="inline-flex items-center gap-1.5"><span className={`inline-block w-2 h-2 rounded-full ${user.is_online ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'}`} /><span className="text-xs text-slate-500">{user.is_online ? '在线' : '离线'}</span></span> },
|
||||||
|
{ label: '最后登录', value: user.last_login_at ? <span className="text-sm text-slate-500">{user.last_login_at}</span> : <span className="text-sm text-slate-400">从未登录</span> },
|
||||||
|
{ label: '创建时间', value: <span className="text-sm text-slate-500">{user.created_at}</span> },
|
||||||
|
].map(row => (
|
||||||
|
<div key={row.label} className="flex items-center px-6 py-4">
|
||||||
|
<span className="w-24 text-sm font-medium text-slate-600 dark:text-slate-400 shrink-0">{row.label}</span>
|
||||||
|
<span className="text-sm text-slate-900 dark:text-slate-100">{row.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal open={editOpen} onClose={() => setEditOpen(false)} title="编辑用户" footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setEditOpen(false)}>取消</Button>
|
||||||
|
<Button onClick={handleSave} loading={saving}>保存</Button>
|
||||||
|
</>
|
||||||
|
}>
|
||||||
|
{error && <div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">{error}</div>}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input label="显示名称" value={form.display_name} onChange={e => setForm(p => ({ ...p, display_name: e.target.value }))} />
|
||||||
|
<Input label="邮箱" type="email" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} />
|
||||||
|
<Select label="状态" value={String(form.is_active)} onChange={e => setForm(p => ({ ...p, is_active: parseInt(e.target.value) }))} options={[{ value: '1', label: '启用' }, { value: '0', label: '禁用' }]} />
|
||||||
|
<Input label="新密码(留空不修改)" type="password" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
|
||||||
|
{form.password && <Input label="确认新密码" type="password" value={form.password_confirm} onChange={e => setForm(p => ({ ...p, password_confirm: e.target.value }))} />}
|
||||||
|
{user.username === 'admin' || user.username === 'localadmin' ? (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">角色</label>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 py-2">管理员(系统保留)</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Select label="角色" value={form.role} onChange={e => setForm(p => ({ ...p, role: e.target.value }))} options={roleOptions} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -6,27 +6,23 @@ import Input from '@/components/ui/Input'
|
||||||
import Select from '@/components/ui/Select'
|
import Select from '@/components/ui/Select'
|
||||||
import Modal from '@/components/ui/Modal'
|
import Modal from '@/components/ui/Modal'
|
||||||
import Badge from '@/components/ui/Badge'
|
import Badge from '@/components/ui/Badge'
|
||||||
import { Plus, Edit, Trash2 } from 'lucide-react'
|
import { Plus, Trash2 } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
interface UserItem {
|
interface UserItem {
|
||||||
id: number; username: string; display_name: string; email: string | null;
|
id: number; username: string; display_name: string; email: string | null;
|
||||||
role: string; is_active: number; created_at: string
|
role: string; is_active: number; created_at: string; last_login_at: string | null;
|
||||||
|
is_online: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const roles = [
|
|
||||||
{ value: 'admin', label: '管理员' },
|
|
||||||
{ value: 'editor', label: '编辑者' },
|
|
||||||
{ value: 'viewer', label: '查看者' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const [users, setUsers] = useState<UserItem[]>([])
|
const [users, setUsers] = useState<UserItem[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [roleOptions, setRoleOptions] = useState<{ value: string; label: string }[]>([])
|
||||||
const [editing, setEditing] = useState<UserItem | null>(null)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [form, setForm] = useState({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
|
const [createForm, setCreateForm] = useState({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
|
||||||
const [error, setError] = useState('')
|
const [createError, setCreateError] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [deleteTarget, setDeleteTarget] = useState<UserItem | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<UserItem | null>(null)
|
||||||
|
|
||||||
async function fetchUsers() {
|
async function fetchUsers() {
|
||||||
|
|
@ -34,38 +30,39 @@ export default function UsersPage() {
|
||||||
if (res.ok) { const data = await res.json(); setUsers(data.users) }
|
if (res.ok) { const data = await res.json(); setUsers(data.users) }
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { fetchUsers().finally(() => setLoading(false)) }, [])
|
async function fetchRoles() {
|
||||||
|
|
||||||
function openCreate() {
|
|
||||||
setEditing(null)
|
|
||||||
setForm({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
|
|
||||||
setError('')
|
|
||||||
setModalOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(user: UserItem) {
|
|
||||||
setEditing(user)
|
|
||||||
setForm({ username: user.username, password: '', display_name: user.display_name, email: user.email || '', role: user.role })
|
|
||||||
setError('')
|
|
||||||
setModalOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
setSaving(true); setError('')
|
|
||||||
try {
|
try {
|
||||||
if (editing) {
|
const res = await fetch('/api/roles')
|
||||||
const body: Record<string, unknown> = { display_name: form.display_name, email: form.email, role: form.role }
|
const d = await res.json()
|
||||||
if (form.password) body.password = form.password
|
if (d.roles) {
|
||||||
const res = await fetch(`/api/users/${editing.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
setRoleOptions(d.roles.map((r: { name: string; display_name: string }) => ({ value: r.name, label: r.display_name })))
|
||||||
if (!res.ok) { const d = await res.json(); setError(d.error); return }
|
|
||||||
} else {
|
|
||||||
if (!form.username || !form.password) { setError('用户名和密码不能为空'); return }
|
|
||||||
const res = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) })
|
|
||||||
if (!res.ok) { const d = await res.json(); setError(d.error); return }
|
|
||||||
}
|
}
|
||||||
setModalOpen(false)
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([fetchUsers(), fetchRoles()]).finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
setCreating(true); setCreateError('')
|
||||||
|
try {
|
||||||
|
if (!createForm.username || !createForm.password) { setCreateError('用户名和密码不能为空'); return }
|
||||||
|
const res = await fetch('/api/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(createForm)
|
||||||
|
})
|
||||||
|
if (!res.ok) { const d = await res.json(); setCreateError(d.error); return }
|
||||||
|
setCreateOpen(false)
|
||||||
|
setCreateForm({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
|
||||||
fetchUsers()
|
fetchUsers()
|
||||||
} finally { setSaving(false) }
|
} finally { setCreating(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleActive(user: UserItem) {
|
||||||
|
await fetch(`/api/users/${user.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_active: user.is_active ? 0 : 1 }) })
|
||||||
|
fetchUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
|
|
@ -75,16 +72,28 @@ export default function UsersPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: Column<UserItem>[] = [
|
const columns: Column<UserItem>[] = [
|
||||||
{ key: 'username', title: '用户名' },
|
{ key: 'username', title: '用户名', render: (r) => (
|
||||||
|
<Link href={`/settings/users/${r.id}`} className="text-blue-600 dark:text-blue-400 hover:underline font-medium">{r.username}</Link>
|
||||||
|
)},
|
||||||
{ key: 'display_name', title: '显示名称', render: (r) => <span className="text-slate-900 dark:text-white font-medium">{r.display_name}</span> },
|
{ key: 'display_name', title: '显示名称', render: (r) => <span className="text-slate-900 dark:text-white font-medium">{r.display_name}</span> },
|
||||||
{ key: 'email', title: '邮箱', render: (r) => r.email || '-' },
|
{ key: 'email', title: '邮箱', render: (r) => <span className="text-slate-500 dark:text-slate-400">{r.email || '-'}</span> },
|
||||||
{ key: 'role', title: '角色', render: (r) => <Badge color={r.role === 'admin' ? 'blue' : r.role === 'editor' ? 'green' : 'gray'}>{roles.find(ro => ro.value === r.role)?.label || r.role}</Badge> },
|
{ key: 'role', title: '角色', render: (r) => {
|
||||||
{ key: 'is_active', title: '状态', render: (r) => <Badge color={r.is_active ? 'green' : 'red'}>{r.is_active ? '启用' : '禁用'}</Badge> },
|
const option = roleOptions.find(ro => ro.value === r.role)
|
||||||
{ key: 'created_at', title: '创建时间' },
|
return <Badge color={r.role === 'admin' ? 'blue' : r.role === 'editor' ? 'green' : 'gray'}>{option?.label || r.role}</Badge>
|
||||||
|
}},
|
||||||
|
{ key: 'is_active', title: '状态', render: (r) => <button onClick={() => toggleActive(r)}><Badge color={r.is_active ? 'green' : 'red'}>{r.is_active ? '启用' : '禁用'}</Badge></button> },
|
||||||
|
{ key: 'is_online', title: '在线', render: (r) => (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className={`inline-block w-2 h-2 rounded-full ${r.is_online ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'}`} />
|
||||||
|
<span className="text-slate-500 dark:text-slate-400">{r.is_online ? '在线' : '离线'}</span>
|
||||||
|
</span>
|
||||||
|
)},
|
||||||
|
{ key: 'last_login_at', title: '最后登录', render: (r) => r.last_login_at ? <span className="text-slate-500 dark:text-slate-400">{r.last_login_at}</span> : <span className="text-slate-400 dark:text-slate-500">从未登录</span> },
|
||||||
{ key: 'actions', title: '操作', render: (r) => (
|
{ key: 'actions', title: '操作', render: (r) => (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button onClick={() => openEdit(r)} className="p-1.5 rounded-lg text-slate-500 hover:text-blue-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"><Edit size={16} /></button>
|
{r.username !== 'admin' && r.username !== 'localadmin' && (
|
||||||
<button onClick={() => setDeleteTarget(r)} className="p-1.5 rounded-lg text-slate-500 hover:text-red-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"><Trash2 size={16} /></button>
|
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}><Trash2 size={14} className="text-red-500" /></Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)},
|
)},
|
||||||
]
|
]
|
||||||
|
|
@ -92,34 +101,38 @@ export default function UsersPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">用户管理</h1>
|
<div>
|
||||||
<Button size="sm" onClick={openCreate}><Plus size={16} />新建用户</Button>
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">用户管理</h1>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 mt-1">系统用户与账号管理</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={() => { setCreateError(''); setCreateOpen(true) }}><Plus size={16} />新建用户</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? <div className="py-20 text-center text-slate-500">加载中...</div> : <Table columns={columns} data={users} rowKey={r => r.id} />}
|
{loading ? <div className="py-20 text-center text-slate-500">加载中...</div> : <Table columns={columns} data={users} rowKey={r => r.id} />}
|
||||||
|
|
||||||
<Modal open={modalOpen} onClose={() => setModalOpen(false)} title={editing ? '编辑用户' : '新建用户'} footer={
|
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="新建用户" footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" onClick={() => setModalOpen(false)}>取消</Button>
|
<Button variant="ghost" onClick={() => setCreateOpen(false)}>取消</Button>
|
||||||
<Button onClick={handleSave} loading={saving}>保存</Button>
|
<Button onClick={handleCreate} loading={creating}>创建</Button>
|
||||||
</>
|
</>
|
||||||
}>
|
}>
|
||||||
{error && <div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">{error}</div>}
|
{createError && <div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">{createError}</div>}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{!editing && <Input label="用户名" value={form.username} onChange={e => setForm(p => ({ ...p, username: e.target.value }))} />}
|
<Input label="用户名" value={createForm.username} onChange={e => setCreateForm(p => ({ ...p, username: e.target.value }))} />
|
||||||
<Input label="显示名称" value={form.display_name} onChange={e => setForm(p => ({ ...p, display_name: e.target.value }))} />
|
<Input label="显示名称" value={createForm.display_name} onChange={e => setCreateForm(p => ({ ...p, display_name: e.target.value }))} />
|
||||||
<Input label="邮箱" type="email" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} />
|
<Input label="邮箱" type="email" value={createForm.email} onChange={e => setCreateForm(p => ({ ...p, email: e.target.value }))} />
|
||||||
<Input label={editing ? '新密码(留空不修改)' : '密码'} type="password" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
|
<Input label="密码" type="password" value={createForm.password} onChange={e => setCreateForm(p => ({ ...p, password: e.target.value }))} />
|
||||||
<Select label="角色" value={form.role} onChange={e => setForm(p => ({ ...p, role: e.target.value }))} options={roles} />
|
<Select label="角色" value={createForm.role} onChange={e => setCreateForm(p => ({ ...p, role: e.target.value }))} options={roleOptions} />
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除">
|
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除" footer={
|
||||||
<p className="text-sm text-slate-600 dark:text-slate-400">确定要删除用户「{deleteTarget?.display_name}」吗?</p>
|
<>
|
||||||
<div className="flex justify-end gap-3 mt-4">
|
|
||||||
<Button variant="ghost" onClick={() => setDeleteTarget(null)}>取消</Button>
|
<Button variant="ghost" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||||
<Button variant="danger" onClick={handleDelete}>删除</Button>
|
<Button variant="danger" onClick={handleDelete}>删除</Button>
|
||||||
</div>
|
</>
|
||||||
|
}>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">确定要删除用户「{deleteTarget?.display_name}」吗?此操作不可撤销。</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,7 @@
|
||||||
'use client'
|
'use client'
|
||||||
import { useState } from 'react'
|
// 登录表单 — 引用共享组件
|
||||||
|
import LoginPage from '@shared/ui/login-page'
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const [username, setUsername] = useState('')
|
return <LoginPage siteName="资产管理系统" redirectPath="/dashboard" />
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.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 }
|
|
||||||
// 直接从 URL 读取 redirect 参数,避免 Suspense/闭包导致的值捕获问题
|
|
||||||
const params = new URLSearchParams(window.location.search)
|
|
||||||
const redirect = params.get('redirect')
|
|
||||||
const dest = (redirect && redirect.startsWith('/')) ? redirect : '/dashboard'
|
|
||||||
window.location.href = dest
|
|
||||||
} catch { setError('网络错误,请重试') }
|
|
||||||
finally { setLoading(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 px-4">
|
|
||||||
<div className="w-full max-w-sm bg-white dark:bg-slate-900 rounded-lg border border-blue-200/50 dark:border-blue-500/20 shadow-lg p-8">
|
|
||||||
<h1 className="text-2xl font-bold text-center mb-6 text-slate-900 dark:text-white">资产管理系统</h1>
|
|
||||||
{error && <div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">{error}</div>}
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">用户名</label>
|
|
||||||
<input type="text" value={username} onChange={e => setUsername(e.target.value)} placeholder="请输入用户名"
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">密码</label>
|
|
||||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="请输入密码"
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required />
|
|
||||||
</div>
|
|
||||||
<button type="submit" disabled={loading} className="w-full py-2 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium transition-colors duration-200">{loading ? '登录中...' : '登录'}</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,13 @@
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { cookies } from 'next/headers'
|
import { cookies } from 'next/headers'
|
||||||
import db from '@/lib/db'
|
import db from '@/lib/db'
|
||||||
import { verifyJwt } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { checkPermission } from '@/lib/permissions'
|
import { checkPermission } from '@/lib/permissions'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
async function getSession() {
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
const token = cookieStore.get('session_assets')?.value
|
|
||||||
if (!token) return null
|
|
||||||
return verifyJwt(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
||||||
|
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||||
if (!checkPermission(session.role, 'api-keys:write')) {
|
if (!checkPermission(session.role, 'api-keys:write')) {
|
||||||
|
|
@ -19,9 +15,20 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const existing = db.prepare('SELECT id FROM api_keys WHERE id = ?').get(id)
|
const existing = db.prepare('SELECT * FROM api_keys WHERE id = ?').get(id) as Record<string, unknown> | undefined
|
||||||
if (!existing) return NextResponse.json({ error: 'API Key 不存在' }, { status: 404 })
|
if (!existing) return NextResponse.json({ error: 'API Key 不存在' }, { status: 404 })
|
||||||
|
|
||||||
db.prepare('DELETE FROM api_keys WHERE id = ?').run(id)
|
db.prepare('DELETE FROM api_keys WHERE id = ?').run(id)
|
||||||
|
|
||||||
|
writeAuditLog({
|
||||||
|
userId: session.userId,
|
||||||
|
apiKeyId: null,
|
||||||
|
action: 'delete',
|
||||||
|
entityType: 'api_key',
|
||||||
|
entityId: Number(id),
|
||||||
|
details: { deleted: { id: existing.id, name: existing.name } },
|
||||||
|
ipAddress: getClientIP(request)
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,10 @@
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import db from '@/lib/db'
|
import db from '@/lib/db'
|
||||||
import { verifyJwt, generateApiKey, hashApiKey } from '@/lib/auth'
|
import { getSession, generateApiKey, hashApiKey } from '@/lib/auth'
|
||||||
import { checkPermission } from '@/lib/permissions'
|
import { checkPermission } from '@/lib/permissions'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
|
|
||||||
async function getSession() {
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
const token = cookieStore.get('session_assets')?.value
|
|
||||||
if (!token) return null
|
|
||||||
return verifyJwt(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
|
|
@ -45,6 +40,16 @@ export async function POST(request: Request) {
|
||||||
|
|
||||||
const apiKey = db.prepare('SELECT id, name, permissions, expires_at, is_active, created_at FROM api_keys WHERE id = ?').get(result.lastInsertRowid)
|
const apiKey = db.prepare('SELECT id, name, permissions, expires_at, is_active, created_at FROM api_keys WHERE id = ?').get(result.lastInsertRowid)
|
||||||
|
|
||||||
|
writeAuditLog({
|
||||||
|
userId: session.userId,
|
||||||
|
apiKeyId: null,
|
||||||
|
action: 'create',
|
||||||
|
entityType: 'api_key',
|
||||||
|
entityId: result.lastInsertRowid as number,
|
||||||
|
details: { created: { name, permissions: permissions || ['assets:read'] } },
|
||||||
|
ipAddress: getClientIP(request)
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ key, apiKey }, { status: 201 })
|
return NextResponse.json({ key, apiKey }, { status: 201 })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '创建 API Key 失败'
|
const msg = e instanceof Error ? e.message : '创建 API Key 失败'
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,10 @@
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import db from '@/lib/db'
|
import db from '@/lib/db'
|
||||||
import { verifyJwt, verifyApiKey } from '@/lib/auth'
|
import { getSession, verifyApiKey } from '@/lib/auth'
|
||||||
import { checkPermission } from '@/lib/permissions'
|
import { checkPermission } from '@/lib/permissions'
|
||||||
|
import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
|
|
||||||
async function getSession() {
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
const token = cookieStore.get('session_assets')?.value
|
|
||||||
if (!token) return null
|
|
||||||
return verifyJwt(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getApiKeyAuth(request: Request) {
|
function getApiKeyAuth(request: Request) {
|
||||||
const auth = request.headers.get('Authorization') || ''
|
const auth = request.headers.get('Authorization') || ''
|
||||||
|
|
@ -31,7 +26,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
|
||||||
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||||
if (!checkPermission(session.role, 'assets:write')) {
|
if (!checkPermission(session.role, 'assets:update')) {
|
||||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,13 +71,21 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||||
return NextResponse.json({ error: '没有要更新的字段' }, { status: 400 })
|
return NextResponse.json({ error: '没有要更新的字段' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
updates.push("updated_at = datetime('now')")
|
updates.push("updated_at = datetime('now', '+8 hours')")
|
||||||
values.push(id)
|
values.push(id)
|
||||||
|
|
||||||
db.prepare(`UPDATE assets SET ${updates.join(', ')} WHERE id = ?`).run(...values)
|
db.prepare(`UPDATE assets SET ${updates.join(', ')} WHERE id = ?`).run(...values)
|
||||||
|
|
||||||
db.prepare(`INSERT INTO audit_logs (user_id, action, entity_type, entity_id, ip_address) VALUES (?, 'update', 'asset', ?, ?)`)
|
const newAsset = db.prepare('SELECT * FROM assets WHERE id = ?').get(id)
|
||||||
.run(session.userId, id, null)
|
writeAuditLog({
|
||||||
|
userId: session.userId,
|
||||||
|
apiKeyId: null,
|
||||||
|
action: 'update',
|
||||||
|
entityType: 'asset',
|
||||||
|
entityId: parseInt(id),
|
||||||
|
details: { changes: diffObjects(existing as Record<string, unknown>, newAsset as Record<string, unknown>) },
|
||||||
|
ipAddress: getClientIP(request)
|
||||||
|
})
|
||||||
|
|
||||||
const asset = db.prepare('SELECT * FROM assets WHERE id = ?').get(id)
|
const asset = db.prepare('SELECT * FROM assets WHERE id = ?').get(id)
|
||||||
return NextResponse.json({ asset })
|
return NextResponse.json({ asset })
|
||||||
|
|
@ -100,12 +103,20 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const existing = db.prepare('SELECT id FROM assets WHERE id = ?').get(id)
|
const existing = db.prepare('SELECT * FROM assets WHERE id = ?').get(id) as Record<string, unknown> | undefined
|
||||||
if (!existing) return NextResponse.json({ error: '资产不存在' }, { status: 404 })
|
if (!existing) return NextResponse.json({ error: '资产不存在' }, { status: 404 })
|
||||||
|
|
||||||
db.prepare('DELETE FROM assets WHERE id = ?').run(id)
|
db.prepare('DELETE FROM assets WHERE id = ?').run(id)
|
||||||
db.prepare(`INSERT INTO audit_logs (user_id, action, entity_type, entity_id, ip_address) VALUES (?, 'delete', 'asset', ?, ?)`)
|
|
||||||
.run(session.userId, id, null)
|
writeAuditLog({
|
||||||
|
userId: session.userId,
|
||||||
|
apiKeyId: null,
|
||||||
|
action: 'delete',
|
||||||
|
entityType: 'asset',
|
||||||
|
entityId: parseInt(id),
|
||||||
|
details: { deleted: { id: existing.id, serial_number: existing.serial_number, node_name: existing.node_name } },
|
||||||
|
ipAddress: getClientIP(_request)
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,11 @@
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { cookies } from 'next/headers'
|
import { cookies } from 'next/headers'
|
||||||
import db from '@/lib/db'
|
import db from '@/lib/db'
|
||||||
import { verifyJwt } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { checkPermission } from '@/lib/permissions'
|
import { checkPermission } from '@/lib/permissions'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
|
|
||||||
async function getSession() {
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
const token = cookieStore.get('session_assets')?.value
|
|
||||||
if (!token) return null
|
|
||||||
return verifyJwt(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
const UPDATABLE_FIELDS = [
|
const UPDATABLE_FIELDS = [
|
||||||
'device_type', 'device_purpose', 'room', 'rack_position', 'status',
|
'device_type', 'device_purpose', 'room', 'rack_position', 'status',
|
||||||
|
|
@ -19,7 +15,7 @@ const UPDATABLE_FIELDS = [
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||||
if (!checkPermission(session.role, 'assets:write')) {
|
if (!checkPermission(session.role, 'assets:update')) {
|
||||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,14 +42,20 @@ export async function POST(request: Request) {
|
||||||
return NextResponse.json({ error: '没有可更新的有效字段' }, { status: 400 })
|
return NextResponse.json({ error: '没有可更新的有效字段' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
updates.push("updated_at = datetime('now')")
|
updates.push("updated_at = datetime('now', '+8 hours')")
|
||||||
|
|
||||||
const placeholders = ids.map(() => '?').join(', ')
|
const placeholders = ids.map(() => '?').join(', ')
|
||||||
const stmt = db.prepare(`UPDATE assets SET ${updates.join(', ')} WHERE id IN (${placeholders})`)
|
const stmt = db.prepare(`UPDATE assets SET ${updates.join(', ')} WHERE id IN (${placeholders})`)
|
||||||
const result = stmt.run(...values, ...ids)
|
const result = stmt.run(...values, ...ids)
|
||||||
|
|
||||||
db.prepare(`INSERT INTO audit_logs (user_id, action, entity_type, details, ip_address) VALUES (?, 'batch_update', 'asset', ?, ?)`)
|
writeAuditLog({
|
||||||
.run(session.userId, JSON.stringify({ ids, fields }), null)
|
userId: session.userId,
|
||||||
|
apiKeyId: null,
|
||||||
|
action: 'batch_update',
|
||||||
|
entityType: 'asset',
|
||||||
|
details: { batch_update: { ids, fields, count: result.changes } },
|
||||||
|
ipAddress: getClientIP(request)
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ updated: result.changes })
|
return NextResponse.json({ updated: result.changes })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import db from '@/lib/db'
|
import db from '@/lib/db'
|
||||||
import { verifyJwt } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { checkPermission } from '@/lib/permissions'
|
||||||
import { exportAssetsToBuffer } from '@/lib/excel'
|
import { exportAssetsToBuffer } from '@/lib/excel'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
const FILTERABLE_FIELDS = new Set([
|
const FILTERABLE_FIELDS = new Set([
|
||||||
'serial_number', 'device_type', 'device_purpose', 'room', 'rack_position',
|
'serial_number', 'device_type', 'device_purpose', 'room', 'rack_position',
|
||||||
|
|
@ -26,73 +27,106 @@ const FILTERABLE_FIELDS = new Set([
|
||||||
])
|
])
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const cookieStore = await cookies()
|
const payload = await getSession()
|
||||||
const token = cookieStore.get('session_assets')?.value
|
if (!payload) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||||
if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
if (!payload.role) return NextResponse.json({ error: '会话数据异常,请重新登录' }, { status: 401 })
|
||||||
const payload = verifyJwt(token)
|
|
||||||
if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
|
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const search = searchParams.get('search') || ''
|
const search = searchParams.get('search') || ''
|
||||||
|
const idsParam = searchParams.get('ids') || ''
|
||||||
|
const ids = idsParam ? idsParam.split(',').map(Number).filter(n => !isNaN(n)) : []
|
||||||
|
|
||||||
|
// 权限控制
|
||||||
|
if (ids.length > 0) {
|
||||||
|
if (!checkPermission(payload.role, 'assets:export:selected')) {
|
||||||
|
return NextResponse.json({ error: '无导出选中资产权限' }, { status: 403 })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!checkPermission(payload.role, 'assets:export:all')) {
|
||||||
|
if (!checkPermission(payload.role, 'assets:export:selected')) {
|
||||||
|
return NextResponse.json({ error: '无导出权限' }, { status: 403 })
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: '无全量导出权限,请先多选设备后再导出' }, { status: 403 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const conditions: string[] = []
|
const conditions: string[] = []
|
||||||
const params: unknown[] = []
|
const params: unknown[] = []
|
||||||
|
|
||||||
if (search) {
|
// 按选中 ID 导出时,直接使用 ID 列表,忽略搜索/筛选条件
|
||||||
conditions.push(`(serial_number LIKE ? OR node_name LIKE ? OR business_ip LIKE ? OR device_model LIKE ? OR manufacturer LIKE ?)`)
|
if (ids.length > 0) {
|
||||||
const s = `%${search}%`
|
const placeholders = ids.map(() => '?').join(', ')
|
||||||
params.push(s, s, s, s, s)
|
conditions.push(`id IN (${placeholders})`)
|
||||||
}
|
params.push(...ids)
|
||||||
|
} else {
|
||||||
// Generic filter_field=value params (multi-select: multiple params → IN clause)
|
if (search) {
|
||||||
const filterKeys = [...searchParams.keys()].filter(k => k.startsWith('filter_'))
|
conditions.push(`(serial_number LIKE ? OR node_name LIKE ? OR business_ip LIKE ? OR device_model LIKE ? OR manufacturer LIKE ?)`)
|
||||||
for (const key of filterKeys) {
|
const s = `%${search}%`
|
||||||
const field = key.replace('filter_', '')
|
params.push(s, s, s, s, s)
|
||||||
const allValues = searchParams.getAll(key)
|
|
||||||
if (allValues.length === 0) continue
|
|
||||||
if (allValues.length === 1) {
|
|
||||||
conditions.push(`${field} = ?`); params.push(allValues[0])
|
|
||||||
} else {
|
|
||||||
const placeholders = allValues.map(() => '?').join(', ')
|
|
||||||
conditions.push(`${field} IN (${placeholders})`)
|
|
||||||
params.push(...allValues)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Advanced search filters JSON
|
// Generic filter_field=value params (multi-select: multiple params → IN clause)
|
||||||
const filtersRaw = searchParams.get('filters')
|
const filterKeys = [...searchParams.keys()].filter(k => k.startsWith('filter_'))
|
||||||
if (filtersRaw) {
|
for (const key of filterKeys) {
|
||||||
try {
|
const field = key.replace('filter_', '')
|
||||||
const filters = JSON.parse(filtersRaw) as Array<{ field: string; op: string; value: string }>
|
if (!FILTERABLE_FIELDS.has(field)) continue
|
||||||
for (const f of filters) {
|
const allValues = searchParams.getAll(key)
|
||||||
if (!f.field || !FILTERABLE_FIELDS.has(f.field)) continue
|
if (allValues.length === 0) continue
|
||||||
const val = (f.value || '').trim()
|
if (allValues.length === 1) {
|
||||||
switch (f.op) {
|
conditions.push(`${field} = ?`); params.push(allValues[0])
|
||||||
case 'contains':
|
} else {
|
||||||
conditions.push(`${f.field} LIKE ?`); params.push(`%${val}%`); break
|
const placeholders = allValues.map(() => '?').join(', ')
|
||||||
case 'equals':
|
conditions.push(`${field} IN (${placeholders})`)
|
||||||
conditions.push(`${f.field} = ?`); params.push(val); break
|
params.push(...allValues)
|
||||||
case 'starts_with':
|
|
||||||
conditions.push(`${f.field} LIKE ?`); params.push(`${val}%`); break
|
|
||||||
case 'ends_with':
|
|
||||||
conditions.push(`${f.field} LIKE ?`); params.push(`%${val}`); break
|
|
||||||
case 'not_empty':
|
|
||||||
conditions.push(`${f.field} IS NOT NULL AND ${f.field} != ''`); break
|
|
||||||
case 'empty':
|
|
||||||
conditions.push(`(${f.field} IS NULL OR ${f.field} = '')`); break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
}
|
||||||
|
|
||||||
|
// Advanced search filters JSON
|
||||||
|
const filtersRaw = searchParams.get('filters')
|
||||||
|
if (filtersRaw) {
|
||||||
|
try {
|
||||||
|
const filters = JSON.parse(filtersRaw) as Array<{ field: string; op: string; value: string }>
|
||||||
|
for (const f of filters) {
|
||||||
|
if (!f.field || !FILTERABLE_FIELDS.has(f.field)) continue
|
||||||
|
const val = (f.value || '').trim()
|
||||||
|
switch (f.op) {
|
||||||
|
case 'contains':
|
||||||
|
conditions.push(`${f.field} LIKE ?`); params.push(`%${val}%`); break
|
||||||
|
case 'equals':
|
||||||
|
conditions.push(`${f.field} = ?`); params.push(val); break
|
||||||
|
case 'starts_with':
|
||||||
|
conditions.push(`${f.field} LIKE ?`); params.push(`${val}%`); break
|
||||||
|
case 'ends_with':
|
||||||
|
conditions.push(`${f.field} LIKE ?`); params.push(`%${val}`); break
|
||||||
|
case 'not_empty':
|
||||||
|
conditions.push(`${f.field} IS NOT NULL AND ${f.field} != ''`); break
|
||||||
|
case 'empty':
|
||||||
|
conditions.push(`(${f.field} IS NULL OR ${f.field} = '')`); break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||||
const assets = db.prepare(`SELECT * FROM assets ${where} ORDER BY id DESC`).all(...params) as Record<string, unknown>[]
|
const assets = db.prepare(`SELECT * FROM assets ${where} ORDER BY id DESC`).all(...params) as Record<string, unknown>[]
|
||||||
|
|
||||||
|
writeAuditLog({
|
||||||
|
userId: payload.userId,
|
||||||
|
apiKeyId: null,
|
||||||
|
action: 'export',
|
||||||
|
entityType: 'asset',
|
||||||
|
details: { export: { count: assets.length, ids: ids.length > 0 ? ids : undefined } },
|
||||||
|
ipAddress: getClientIP(request)
|
||||||
|
})
|
||||||
|
|
||||||
const buffer = exportAssetsToBuffer(assets)
|
const buffer = exportAssetsToBuffer(assets)
|
||||||
|
const today = new Date()
|
||||||
|
const dateStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`
|
||||||
return new NextResponse(new Uint8Array(buffer), {
|
return new NextResponse(new Uint8Array(buffer), {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
'Content-Disposition': `attachment; filename="assets_export_${new Date().toISOString().slice(0, 10)}.xlsx"`,
|
'Content-Disposition': `attachment; filename="assets_export_${dateStr}.xlsx"`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import db from '@/lib/db'
|
import db from '@/lib/db'
|
||||||
import { verifyJwt } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
|
|
||||||
const ALLOWED_FIELDS = new Set([
|
const ALLOWED_FIELDS = new Set([
|
||||||
'device_type', 'device_purpose', 'room', 'rack_position', 'node_name',
|
'device_type', 'device_purpose', 'room', 'rack_position', 'node_name',
|
||||||
|
|
@ -21,11 +20,8 @@ const ALLOWED_FIELDS = new Set([
|
||||||
])
|
])
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const cookieStore = await cookies()
|
const payload = await getSession()
|
||||||
const token = cookieStore.get('session_assets')?.value
|
if (!payload) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||||
if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
|
||||||
const payload = verifyJwt(token)
|
|
||||||
if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
|
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const field = searchParams.get('field') || ''
|
const field = searchParams.get('field') || ''
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,17 @@
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { cookies } from 'next/headers'
|
import { cookies } from 'next/headers'
|
||||||
import db from '@/lib/db'
|
import db from '@/lib/db'
|
||||||
import { verifyJwt } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { checkPermission } from '@/lib/permissions'
|
import { checkPermission } from '@/lib/permissions'
|
||||||
import { parseImportBuffer } from '@/lib/excel'
|
import { parseImportBuffer } from '@/lib/excel'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
|
|
||||||
async function getSession() {
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
const token = cookieStore.get('session_assets')?.value
|
|
||||||
if (!token) return null
|
|
||||||
return verifyJwt(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||||
if (!checkPermission(session.role, 'assets:write')) {
|
if (!checkPermission(session.role, 'assets:import')) {
|
||||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,7 +66,7 @@ export async function POST(request: Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (updates.length > 0) {
|
if (updates.length > 0) {
|
||||||
updates.push("updated_at = datetime('now')")
|
updates.push("updated_at = datetime('now', '+8 hours')")
|
||||||
values.push(existing.id)
|
values.push(existing.id)
|
||||||
db.prepare(`UPDATE assets SET ${updates.join(', ')} WHERE id = ?`).run(...values)
|
db.prepare(`UPDATE assets SET ${updates.join(', ')} WHERE id = ?`).run(...values)
|
||||||
updated++
|
updated++
|
||||||
|
|
@ -90,8 +86,14 @@ export async function POST(request: Request) {
|
||||||
|
|
||||||
insertTransaction()
|
insertTransaction()
|
||||||
|
|
||||||
db.prepare(`INSERT INTO audit_logs (user_id, action, entity_type, details, ip_address) VALUES (?, 'import', 'asset', ?, ?)`)
|
writeAuditLog({
|
||||||
.run(session.userId, JSON.stringify({ created, updated }), null)
|
userId: session.userId,
|
||||||
|
apiKeyId: null,
|
||||||
|
action: 'import',
|
||||||
|
entityType: 'asset',
|
||||||
|
details: { import: { created, updated } },
|
||||||
|
ipAddress: getClientIP(request)
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ created, updated, errors })
|
return NextResponse.json({ created, updated, errors })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,10 @@
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import db from '@/lib/db'
|
import db from '@/lib/db'
|
||||||
import { verifyJwt, verifyApiKey } from '@/lib/auth'
|
import { getSession, verifyApiKey } from '@/lib/auth'
|
||||||
import { checkPermission } from '@/lib/permissions'
|
import { checkPermission } from '@/lib/permissions'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
function getUserFromCookie() {
|
|
||||||
return null as { userId: number; username: string; role: string } | null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getSession() {
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
const token = cookieStore.get('session_assets')?.value
|
|
||||||
if (!token) return null
|
|
||||||
return verifyJwt(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getApiKeyAuth(request: Request) {
|
function getApiKeyAuth(request: Request) {
|
||||||
const auth = request.headers.get('Authorization') || ''
|
const auth = request.headers.get('Authorization') || ''
|
||||||
|
|
@ -52,14 +43,14 @@ export async function GET(request: Request) {
|
||||||
const page = Math.max(1, parseInt(searchParams.get('page') || '1'))
|
const page = Math.max(1, parseInt(searchParams.get('page') || '1'))
|
||||||
const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get('pageSize') || '20')))
|
const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get('pageSize') || '20')))
|
||||||
const search = searchParams.get('search') || ''
|
const search = searchParams.get('search') || ''
|
||||||
const sortKey = searchParams.get('sortKey') || 'id'
|
const sortKey = searchParams.get('sortKey') || 'node_name'
|
||||||
const sortOrder = searchParams.get('sortOrder') || 'desc'
|
const sortOrder = searchParams.get('sortOrder') || 'asc'
|
||||||
|
|
||||||
const allowedSortKeys = new Set([
|
const allowedSortKeys = new Set([
|
||||||
'id', 'serial_number', 'device_type', 'device_purpose', 'node_name',
|
'id', 'serial_number', 'device_type', 'device_purpose', 'node_name',
|
||||||
'business_ip', 'manufacturer', 'device_model', 'status', 'created_at', 'updated_at'
|
'business_ip', 'hdm_ip', 'manufacturer', 'device_model', 'status', 'created_at', 'updated_at'
|
||||||
])
|
])
|
||||||
const safeSortKey = allowedSortKeys.has(sortKey) ? sortKey : 'id'
|
const safeSortKey = allowedSortKeys.has(sortKey) ? sortKey : 'node_name'
|
||||||
const safeSortOrder = sortOrder === 'asc' ? 'ASC' : 'DESC'
|
const safeSortOrder = sortOrder === 'asc' ? 'ASC' : 'DESC'
|
||||||
|
|
||||||
const conditions: string[] = []
|
const conditions: string[] = []
|
||||||
|
|
@ -75,6 +66,7 @@ export async function GET(request: Request) {
|
||||||
const filterKeys = [...searchParams.keys()].filter(k => k.startsWith('filter_'))
|
const filterKeys = [...searchParams.keys()].filter(k => k.startsWith('filter_'))
|
||||||
for (const key of filterKeys) {
|
for (const key of filterKeys) {
|
||||||
const field = key.replace('filter_', '')
|
const field = key.replace('filter_', '')
|
||||||
|
if (!FILTERABLE_FIELDS.has(field)) continue
|
||||||
const allValues = searchParams.getAll(key)
|
const allValues = searchParams.getAll(key)
|
||||||
if (allValues.length === 0) continue
|
if (allValues.length === 0) continue
|
||||||
if (allValues.length === 1) {
|
if (allValues.length === 1) {
|
||||||
|
|
@ -137,7 +129,7 @@ export async function GET(request: Request) {
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||||
if (!checkPermission(session.role, 'assets:write')) {
|
if (!checkPermission(session.role, 'assets:create')) {
|
||||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,8 +167,15 @@ export async function POST(request: Request) {
|
||||||
|
|
||||||
const result = db.prepare(`INSERT INTO assets (${present.join(', ')}) VALUES (${placeholders})`).run(...values)
|
const result = db.prepare(`INSERT INTO assets (${present.join(', ')}) VALUES (${placeholders})`).run(...values)
|
||||||
|
|
||||||
db.prepare(`INSERT INTO audit_logs (user_id, action, entity_type, entity_id, ip_address) VALUES (?, 'create', 'asset', ?, ?)`)
|
writeAuditLog({
|
||||||
.run(session.userId, result.lastInsertRowid, null)
|
userId: session.userId,
|
||||||
|
apiKeyId: null,
|
||||||
|
action: 'create',
|
||||||
|
entityType: 'asset',
|
||||||
|
entityId: result.lastInsertRowid as number,
|
||||||
|
details: { created: { device_type: body.device_type, node_name: body.node_name, serial_number: body.serial_number } },
|
||||||
|
ipAddress: getClientIP(request)
|
||||||
|
})
|
||||||
|
|
||||||
const asset = db.prepare('SELECT * FROM assets WHERE id = ?').get(result.lastInsertRowid)
|
const asset = db.prepare('SELECT * FROM assets WHERE id = ?').get(result.lastInsertRowid)
|
||||||
return NextResponse.json({ asset }, { status: 201 })
|
return NextResponse.json({ asset }, { status: 201 })
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { cookies } from 'next/headers'
|
import { getSession } from '@/lib/auth'
|
||||||
import { verifyJwt } from '@/lib/auth'
|
import { checkPermission } from '@/lib/permissions'
|
||||||
import { generateTemplateBuffer } from '@/lib/excel'
|
import { generateTemplateBuffer } from '@/lib/excel'
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const cookieStore = await cookies()
|
const payload = await getSession()
|
||||||
const token = cookieStore.get('session_assets')?.value
|
|
||||||
if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
|
||||||
const payload = verifyJwt(token)
|
|
||||||
if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
|
if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
|
||||||
|
if (!checkPermission(payload.role, 'assets:import')) {
|
||||||
|
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
const buffer = generateTemplateBuffer()
|
const buffer = generateTemplateBuffer()
|
||||||
return new NextResponse(buffer, {
|
return new NextResponse(buffer, {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import db from '@/lib/db'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
|
// 获取保留天数配置
|
||||||
|
export async function GET() {
|
||||||
|
initDatabase()
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||||
|
|
||||||
|
const row = db.prepare(
|
||||||
|
"SELECT value FROM settings WHERE key = 'audit_retention_days'"
|
||||||
|
).get() as { value: string } | undefined
|
||||||
|
|
||||||
|
return NextResponse.json({ days: parseInt(row?.value || '180', 10) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新保留天数配置
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
initDatabase()
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(session.userId) as { role: string } | undefined
|
||||||
|
if (!user) return NextResponse.json({ error: '用户不存在' }, { status: 401 })
|
||||||
|
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
const permissions = db.prepare('SELECT permissions FROM roles WHERE name = ?').get(user.role) as { permissions: string } | undefined
|
||||||
|
if (!permissions) return NextResponse.json({ error: '角色不存在' }, { status: 403 })
|
||||||
|
|
||||||
|
const perms = JSON.parse(permissions.permissions)
|
||||||
|
if (!perms.includes('*') && !perms.includes('audit-logs:export')) {
|
||||||
|
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const days = Math.max(30, Math.min(365, parseInt(body.days, 10)))
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
"INSERT OR REPLACE INTO settings (key, value) VALUES ('audit_retention_days', ?)"
|
||||||
|
).run(String(days))
|
||||||
|
|
||||||
|
writeAuditLog({
|
||||||
|
userId: session.userId,
|
||||||
|
action: 'update',
|
||||||
|
entityType: 'audit_log',
|
||||||
|
details: { retention_days: days },
|
||||||
|
ipAddress: getClientIP(request)
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ days })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
initDatabase()
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(session.userId) as { role: string } | undefined
|
||||||
|
if (!user) return NextResponse.json({ error: '用户不存在' }, { status: 401 })
|
||||||
|
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
const permissions = db.prepare('SELECT permissions FROM roles WHERE name = ?').get(user.role) as { permissions: string } | undefined
|
||||||
|
if (!permissions) return NextResponse.json({ error: '角色不存在' }, { status: 403 })
|
||||||
|
|
||||||
|
const perms = JSON.parse(permissions.permissions)
|
||||||
|
if (!perms.includes('*') && !perms.includes('audit-logs:export')) {
|
||||||
|
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 settings 读取保留天数
|
||||||
|
const retentionRow = db.prepare(
|
||||||
|
"SELECT value FROM settings WHERE key = 'audit_retention_days'"
|
||||||
|
).get() as { value: string } | undefined
|
||||||
|
const retentionDays = Math.max(30, Math.min(365, parseInt(retentionRow?.value || '180', 10)))
|
||||||
|
|
||||||
|
const result = db.prepare(
|
||||||
|
`DELETE FROM audit_logs WHERE created_at < datetime('now', '-${retentionDays} days', '+8 hours')`
|
||||||
|
).run()
|
||||||
|
|
||||||
|
writeAuditLog({
|
||||||
|
userId: session.userId,
|
||||||
|
action: 'cleanup',
|
||||||
|
entityType: 'audit_log',
|
||||||
|
details: { cleanup: { deleted: result.changes, retention_days: retentionDays } },
|
||||||
|
ipAddress: getClientIP(request)
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ deleted: result.changes, retention_days: retentionDays })
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import db from '@/lib/db'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
initDatabase()
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(session.userId) as { role: string } | undefined
|
||||||
|
if (!user) return NextResponse.json({ error: '用户不存在' }, { status: 401 })
|
||||||
|
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
const permissions = db.prepare('SELECT permissions FROM roles WHERE name = ?').get(user.role) as { permissions: string } | undefined
|
||||||
|
if (!permissions) return NextResponse.json({ error: '角色不存在' }, { status: 403 })
|
||||||
|
|
||||||
|
const perms = JSON.parse(permissions.permissions)
|
||||||
|
if (!perms.includes('*') && !perms.includes('audit-logs:export')) {
|
||||||
|
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const action = searchParams.get('action')
|
||||||
|
const entityType = searchParams.get('entity_type')
|
||||||
|
const userId = searchParams.get('user_id')
|
||||||
|
const dateFrom = searchParams.get('date_from')
|
||||||
|
const dateTo = searchParams.get('date_to')
|
||||||
|
|
||||||
|
let whereClause = 'WHERE 1=1'
|
||||||
|
const params: unknown[] = []
|
||||||
|
|
||||||
|
if (action) {
|
||||||
|
whereClause += ' AND a.action = ?'
|
||||||
|
params.push(action)
|
||||||
|
}
|
||||||
|
if (entityType) {
|
||||||
|
whereClause += ' AND a.entity_type = ?'
|
||||||
|
params.push(entityType)
|
||||||
|
}
|
||||||
|
if (userId) {
|
||||||
|
whereClause += ' AND a.user_id = ?'
|
||||||
|
params.push(parseInt(userId))
|
||||||
|
}
|
||||||
|
if (dateFrom) {
|
||||||
|
whereClause += ' AND a.created_at >= ?'
|
||||||
|
params.push(dateFrom)
|
||||||
|
}
|
||||||
|
if (dateTo) {
|
||||||
|
whereClause += ' AND a.created_at <= ?'
|
||||||
|
params.push(dateTo + ' 23:59:59')
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = db.prepare(
|
||||||
|
`SELECT a.*, u.username, ak.name as api_key_name
|
||||||
|
FROM audit_logs a
|
||||||
|
LEFT JOIN users u ON a.user_id = u.id
|
||||||
|
LEFT JOIN api_keys ak ON a.api_key_id = ak.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY a.created_at DESC`
|
||||||
|
).all(...params)
|
||||||
|
|
||||||
|
// 生成 CSV
|
||||||
|
const BOM = ''
|
||||||
|
const headers = ['ID', '时间', '用户', 'API Key', '操作', '对象类型', '对象ID', '详情', 'IP地址']
|
||||||
|
const rows = logs.map((log: Record<string, unknown>) => [
|
||||||
|
log.id,
|
||||||
|
log.created_at,
|
||||||
|
log.username || '',
|
||||||
|
log.api_key_name || '',
|
||||||
|
log.action,
|
||||||
|
log.entity_type,
|
||||||
|
log.entity_id || '',
|
||||||
|
log.details ? String(log.details).replace(/"/g, '""') : '',
|
||||||
|
log.ip_address || ''
|
||||||
|
])
|
||||||
|
|
||||||
|
const csvContent = BOM + [
|
||||||
|
headers.join(','),
|
||||||
|
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
// 自审计:记录导出操作
|
||||||
|
writeAuditLog({
|
||||||
|
userId: session.userId,
|
||||||
|
action: 'export',
|
||||||
|
entityType: 'audit_log',
|
||||||
|
details: {
|
||||||
|
export: {
|
||||||
|
filter: { entity_type: entityType, action, user_id: userId, date_from: dateFrom, date_to: dateTo },
|
||||||
|
count: logs.length
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ipAddress: getClientIP(request)
|
||||||
|
})
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||||
|
|
||||||
|
return new NextResponse(csvContent, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/csv; charset=utf-8',
|
||||||
|
'Content-Disposition': `attachment; filename="audit-logs-${dateStr}.csv"`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import db from '@/lib/db'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
initDatabase()
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(session.userId) as { role: string } | undefined
|
||||||
|
if (!user) return NextResponse.json({ error: '用户不存在' }, { status: 401 })
|
||||||
|
|
||||||
|
// admin 角色有所有权限,其他角色需要 audit-logs:read
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
const permissions = db.prepare('SELECT permissions FROM roles WHERE name = ?').get(user.role) as { permissions: string } | undefined
|
||||||
|
if (!permissions) return NextResponse.json({ error: '角色不存在' }, { status: 403 })
|
||||||
|
|
||||||
|
const perms = JSON.parse(permissions.permissions)
|
||||||
|
if (!perms.includes('*') && !perms.includes('audit-logs:read')) {
|
||||||
|
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const page = Math.max(1, parseInt(searchParams.get('page') || '1'))
|
||||||
|
const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get('pageSize') || '20')))
|
||||||
|
const action = searchParams.get('action')
|
||||||
|
const entityType = searchParams.get('entity_type')
|
||||||
|
const userId = searchParams.get('user_id')
|
||||||
|
const dateFrom = searchParams.get('date_from')
|
||||||
|
const dateTo = searchParams.get('date_to')
|
||||||
|
|
||||||
|
let whereClause = 'WHERE 1=1'
|
||||||
|
const params: unknown[] = []
|
||||||
|
|
||||||
|
if (action) {
|
||||||
|
whereClause += ' AND a.action = ?'
|
||||||
|
params.push(action)
|
||||||
|
}
|
||||||
|
if (entityType) {
|
||||||
|
whereClause += ' AND a.entity_type = ?'
|
||||||
|
params.push(entityType)
|
||||||
|
}
|
||||||
|
if (userId) {
|
||||||
|
whereClause += ' AND a.user_id = ?'
|
||||||
|
params.push(parseInt(userId))
|
||||||
|
}
|
||||||
|
if (dateFrom) {
|
||||||
|
whereClause += ' AND a.created_at >= ?'
|
||||||
|
params.push(dateFrom)
|
||||||
|
}
|
||||||
|
if (dateTo) {
|
||||||
|
whereClause += ' AND a.created_at <= ?'
|
||||||
|
params.push(dateTo + ' 23:59:59')
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = db.prepare(
|
||||||
|
`SELECT COUNT(*) as total FROM audit_logs a ${whereClause}`
|
||||||
|
).get(...params) as { total: number }
|
||||||
|
|
||||||
|
const logs = db.prepare(
|
||||||
|
`SELECT a.*, u.username, ak.name as api_key_name
|
||||||
|
FROM audit_logs a
|
||||||
|
LEFT JOIN users u ON a.user_id = u.id
|
||||||
|
LEFT JOIN api_keys ak ON a.api_key_id = ak.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY a.created_at DESC
|
||||||
|
LIMIT ? OFFSET ?`
|
||||||
|
).all(...params, pageSize, (page - 1) * pageSize)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
logs,
|
||||||
|
total: countResult.total,
|
||||||
|
page,
|
||||||
|
pageSize
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
// GET /api/auth/callback — OIDC callback(V2:使用 shared handleOidcCallback 工厂)
|
||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { handleOidcCallback } from '@shared/lib/auth/handle-callback'
|
||||||
|
import db from '@/lib/db'
|
||||||
|
import { writeAuditLog } from '@/lib/audit'
|
||||||
|
|
||||||
|
const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
|
||||||
|
const oidcClientId = process.env.OIDC_CLIENT_ID || 'assets-oidc'
|
||||||
|
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || ''
|
||||||
|
const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://assets.tlyq.ai/api/auth/callback'
|
||||||
|
const jwtSecret = process.env.JWT_SECRET || 'oa-shared-jwt-secret-tlyq-2026'
|
||||||
|
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return handleOidcCallback(request, {
|
||||||
|
oidc: { autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri },
|
||||||
|
jwtSecret,
|
||||||
|
cookieDomain,
|
||||||
|
|
||||||
|
getUser: (username) => {
|
||||||
|
const row = db.prepare(
|
||||||
|
'SELECT id, username, role FROM users WHERE username = ? AND is_active = 1'
|
||||||
|
).get(username) as { id: number; role: string } | undefined
|
||||||
|
return row ? { id: row.id, role: row.role } : null
|
||||||
|
},
|
||||||
|
|
||||||
|
createUser: (username, displayName, email) => {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT OR IGNORE INTO users (username, display_name, email, role, is_active, created_at, updated_at) VALUES (?, ?, ?, 'viewer', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
|
||||||
|
).run(username, displayName, email)
|
||||||
|
const row = db.prepare(
|
||||||
|
'SELECT id, role FROM users WHERE username = ? AND is_active = 1'
|
||||||
|
).get(username) as { id: number; role: string }
|
||||||
|
return { id: row?.id ?? 0, role: row?.role ?? 'viewer' }
|
||||||
|
},
|
||||||
|
|
||||||
|
updateUser: (username, displayName, email) => {
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE users SET display_name = ?, email = ?, updated_at = datetime('now', '+8 hours') WHERE username = ?"
|
||||||
|
).run(displayName, email, username)
|
||||||
|
},
|
||||||
|
|
||||||
|
onAuditLog: (userId, username, req) => {
|
||||||
|
writeAuditLog({
|
||||||
|
userId, username, action: 'login', entityType: 'auth',
|
||||||
|
details: { method: 'oidc' },
|
||||||
|
ipAddress: req.headers.get('x-forwarded-for') || '127.0.0.1',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
// GET /api/auth/login/oidc — OIDC SSO 重定向(V2:使用 shared handleOidcLogin 工厂)
|
||||||
|
import { handleOidcLogin } from '@shared/lib/auth/handle-login'
|
||||||
|
|
||||||
|
const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
|
||||||
|
const oidcClientId = process.env.OIDC_CLIENT_ID || 'assets-oidc'
|
||||||
|
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || ''
|
||||||
|
const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://assets.tlyq.ai/api/auth/callback'
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const switchUser = url.searchParams.get('switch') === '1'
|
||||||
|
return handleOidcLogin({ autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri, switchUser })
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,110 @@
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { cookies } from 'next/headers'
|
import { cookies } from 'next/headers'
|
||||||
import db from '@/lib/db'
|
import db from '@/lib/db'
|
||||||
import { verifyPassword, signJwt } from '@/lib/auth'
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
|
import { verifyPassword, signJwt, hashPassword } from '@/lib/auth'
|
||||||
|
import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt'
|
||||||
|
import { ldapAuth } from '@/lib/ldap'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
|
initDatabase()
|
||||||
const { username, password } = await request.json()
|
const { username, password } = await request.json()
|
||||||
if (!username || !password) return NextResponse.json({ error: '请输入用户名和密码' }, { status: 400 })
|
if (!username || !password) return NextResponse.json({ error: '请输入用户名和密码' }, { status: 400 })
|
||||||
const user = db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username) as User | undefined
|
|
||||||
if (!user || !verifyPassword(password, user.password_hash)) return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
|
let userId: number
|
||||||
const token = signJwt({ userId: user.id, username: user.username, role: user.role })
|
let role: string
|
||||||
|
let displayName: string
|
||||||
|
|
||||||
|
// 1. localadmin:纯本地 BCrypt,不依赖 LLDAP
|
||||||
|
if (username === 'localadmin') {
|
||||||
|
const localUser = db.prepare(
|
||||||
|
'SELECT * FROM users WHERE username = ? AND is_active = 1'
|
||||||
|
).get(username) as User | undefined
|
||||||
|
if (!localUser || !verifyPassword(password, localUser.password_hash)) {
|
||||||
|
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
|
||||||
|
}
|
||||||
|
userId = localUser.id
|
||||||
|
role = localUser.role
|
||||||
|
displayName = localUser.display_name || username
|
||||||
|
} else {
|
||||||
|
// 2. 其他用户:LLDAP 优先
|
||||||
|
const ldapResult = await ldapAuth(username, password)
|
||||||
|
|
||||||
|
if (ldapResult.success) {
|
||||||
|
// LDAP 认证成功 → 更新本地密码缓存 + 自动创建用户
|
||||||
|
displayName = ldapResult.displayName || username
|
||||||
|
const pwHash = hashPassword(password)
|
||||||
|
const existing = db.prepare(
|
||||||
|
'SELECT id, role FROM users WHERE username = ? AND is_active = 1'
|
||||||
|
).get(username) as { id: number; role: string } | undefined
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
db.prepare('UPDATE users SET password_hash = ?, display_name = ?, email = ? WHERE id = ?')
|
||||||
|
.run(pwHash, displayName, ldapResult.email || null, existing.id)
|
||||||
|
userId = existing.id
|
||||||
|
role = existing.role
|
||||||
|
} else {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO users (username, password_hash, display_name, email, role, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, 'viewer', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
|
||||||
|
).run(username, pwHash, displayName, ldapResult.email || null)
|
||||||
|
const created = db.prepare(
|
||||||
|
'SELECT id, role FROM users WHERE username = ?'
|
||||||
|
).get(username) as { id: number; role: string } | undefined
|
||||||
|
if (!created) return NextResponse.json({ error: '用户创建失败' }, { status: 500 })
|
||||||
|
userId = created.id
|
||||||
|
role = created.role
|
||||||
|
}
|
||||||
|
} else if (ldapResult.unreachable) {
|
||||||
|
// LLDAP 不可达 → 回退本地密码缓存
|
||||||
|
const localUser = db.prepare(
|
||||||
|
'SELECT * FROM users WHERE username = ? AND is_active = 1'
|
||||||
|
).get(username) as User | undefined
|
||||||
|
if (!localUser || !verifyPassword(password, localUser.password_hash)) {
|
||||||
|
return NextResponse.json({ error: '认证服务不可用,且本地密码不匹配' }, { status: 401 })
|
||||||
|
}
|
||||||
|
userId = localUser.id
|
||||||
|
role = localUser.role
|
||||||
|
displayName = localUser.display_name || username
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 更新最后登录时间和活跃时间
|
||||||
|
db.prepare("UPDATE users SET last_login_at = datetime('now', '+8 hours'), last_active_at = datetime('now', '+8 hours') WHERE id = ?").run(userId)
|
||||||
|
|
||||||
|
// 4. 签发两个 cookie
|
||||||
|
const localToken = signJwt({ userId, username, role })
|
||||||
|
const sharedToken = signSharedJwt({ username, displayName })
|
||||||
|
const sharedCfg = sharedCookieConfig()
|
||||||
const cookieStore = await cookies()
|
const cookieStore = await cookies()
|
||||||
cookieStore.set('session_assets', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 86400, path: '/' })
|
|
||||||
return NextResponse.json({ user: { id: user.id, username: user.username, display_name: user.display_name, role: user.role } })
|
cookieStore.set('session_assets', localToken, {
|
||||||
} catch { return NextResponse.json({ error: '登录失败' }, { status: 500 }) }
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 86400,
|
||||||
|
path: '/',
|
||||||
|
})
|
||||||
|
cookieStore.set(sharedCfg.name, sharedToken, sharedCfg)
|
||||||
|
|
||||||
|
writeAuditLog({
|
||||||
|
userId: userId,
|
||||||
|
apiKeyId: null,
|
||||||
|
action: 'login',
|
||||||
|
entityType: 'auth',
|
||||||
|
entityId: userId,
|
||||||
|
details: { username, role },
|
||||||
|
ipAddress: getClientIP(request)
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
user: { id: userId, username, display_name: displayName, role },
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: '登录失败' }, { status: 500 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,25 @@
|
||||||
|
// GET + POST /api/auth/logout — 退出登录(清除 cookie + 302 跳转 /login)
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
export async function POST() {
|
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
|
||||||
const cookieStore = await cookies()
|
|
||||||
cookieStore.set('session_assets', '', { maxAge: 0, path: '/' })
|
/** 从 OIDC_REDIRECT_URI 提取 site URL(不可信请求头,见 LESSONS-LEARNED #51) */
|
||||||
return NextResponse.json({ success: true })
|
function getSiteUrl(): string {
|
||||||
|
const redirectUri = process.env.OIDC_REDIRECT_URI || ''
|
||||||
|
try { const u = new URL(redirectUri); return `${u.protocol}//${u.host}` } catch { /* fallthrough */ }
|
||||||
|
return process.env.NEXT_PUBLIC_SITE_URL || 'https://assets.tlyq.ai'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 清除 tlyq_session + session cookie → 302 跳转 /login */
|
||||||
|
function logoutResponse(): NextResponse {
|
||||||
|
const response = NextResponse.redirect(new URL('/login', getSiteUrl()))
|
||||||
|
response.cookies.set('tlyq_session', '', {
|
||||||
|
httpOnly: true, secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0,
|
||||||
|
})
|
||||||
|
response.cookies.set('session', '', { path: '/', maxAge: 0 })
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() { return logoutResponse() }
|
||||||
|
export async function POST() { return logoutResponse() }
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,24 @@
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import db from '@/lib/db'
|
import db from '@/lib/db'
|
||||||
import { verifyJwt } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const cookieStore = await cookies()
|
const session = await getSession()
|
||||||
const token = cookieStore.get('session_assets')?.value
|
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||||
if (!token) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
|
||||||
const payload = verifyJwt(token)
|
const user = db.prepare(
|
||||||
if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
|
'SELECT id, username, display_name, email, role FROM users WHERE id = ? AND is_active = 1'
|
||||||
const user = db.prepare('SELECT id, username, display_name, email, role FROM users WHERE id = ? AND is_active = 1').get(payload.userId)
|
).get(session.userId) as Record<string, unknown> | undefined
|
||||||
if (!user) return NextResponse.json({ error: '用户不存在' }, { status: 401 })
|
if (!user) return NextResponse.json({ error: '用户不存在' }, { status: 401 })
|
||||||
return NextResponse.json({ user })
|
|
||||||
} catch { return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 }) }
|
const roleRow = db.prepare(
|
||||||
|
'SELECT permissions FROM roles WHERE name = ?'
|
||||||
|
).get(user.role) as { permissions: string } | undefined
|
||||||
|
const permissions: string[] = roleRow ? JSON.parse(roleRow.permissions) : []
|
||||||
|
|
||||||
|
return NextResponse.json({ user: { ...user, permissions } })
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
export async function GET() { return NextResponse.json({ status: 'OK' }) }
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import db from '@/lib/db'
|
||||||
|
|
||||||
|
const INTERNAL_KEY = process.env.INTERNAL_API_KEY || 'oa-internal-key-tlyq-2026'
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const key = request.headers.get('x-internal-key')
|
||||||
|
if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
|
const roles = db.prepare('SELECT name, display_name FROM roles ORDER BY name').all() as { name: string; display_name: string }[]
|
||||||
|
return NextResponse.json({ roles })
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
// PUT /api/internal/users/role — 供 OA 修改用户角色
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { execFileSync } from 'child_process'
|
||||||
|
|
||||||
|
const INTERNAL_KEY = process.env.INTERNAL_API_KEY || 'oa-internal-key-tlyq-2026'
|
||||||
|
const DB_PATH = process.env.DATABASE_PATH || '/app/data/assets.db'
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
const key = request.headers.get('x-internal-key')
|
||||||
|
if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { username, role } = await request.json()
|
||||||
|
if (!username || !role) return NextResponse.json({ error: '参数不完整' }, { status: 400 })
|
||||||
|
execFileSync('sqlite3', [DB_PATH], {
|
||||||
|
input: `UPDATE users SET role='${role}', updated_at=datetime('now', '+8 hours') WHERE username='${username}';`,
|
||||||
|
timeout: 3000,
|
||||||
|
})
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: '更新失败' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
// GET /api/internal/users — 供 OA 查询 assets 用户列表
|
||||||
|
// POST /api/internal/users — 供 OA 同步用户角色
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import db from '@/lib/db'
|
||||||
|
|
||||||
|
const INTERNAL_KEY = process.env.INTERNAL_API_KEY || 'oa-internal-key-tlyq-2026'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const key = request.headers.get('x-internal-key')
|
||||||
|
if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
|
const users = db.prepare(
|
||||||
|
'SELECT username, display_name, role FROM users WHERE is_active = 1 ORDER BY username'
|
||||||
|
).all() as { username: string; display_name: string; role: string }[]
|
||||||
|
return NextResponse.json({ users })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const key = request.headers.get('x-internal-key')
|
||||||
|
if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { username, displayName, role } = body
|
||||||
|
if (!username) {
|
||||||
|
return NextResponse.json({ error: 'username 必填' }, { status: 400 })
|
||||||
|
}
|
||||||
|
const VALID_ROLES = ['admin', 'editor', 'viewer']
|
||||||
|
const safeRole = VALID_ROLES.includes(role) ? role : 'viewer'
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO users (username, password_hash, display_name, role, is_active, created_at, updated_at)
|
||||||
|
VALUES (?, '', ?, ?, 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))
|
||||||
|
ON CONFLICT(username) DO UPDATE SET role = ?, display_name = ?,
|
||||||
|
updated_at = datetime('now', '+8 hours')`
|
||||||
|
).run(username, displayName || username, safeRole, safeRole, displayName || username)
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: '同步失败' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import db from '@/lib/db'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { checkPermission } from '@/lib/permissions'
|
||||||
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
|
import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
|
const BUILTIN_ROLES = ['admin', 'editor', 'viewer']
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
initDatabase()
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||||
|
if (!checkPermission(session.role, 'roles:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
const existing = db.prepare('SELECT * FROM roles WHERE id = ?').get(id) as Record<string, unknown> | undefined
|
||||||
|
if (!existing) return NextResponse.json({ error: '角色不存在' }, { status: 404 })
|
||||||
|
|
||||||
|
const fields: string[] = []
|
||||||
|
const values: unknown[] = []
|
||||||
|
|
||||||
|
if (body.display_name) { fields.push('display_name = ?'); values.push(body.display_name) }
|
||||||
|
if (body.permissions) { fields.push('permissions = ?'); values.push(JSON.stringify(body.permissions)) }
|
||||||
|
|
||||||
|
if (fields.length > 0) {
|
||||||
|
values.push(id)
|
||||||
|
db.prepare(`UPDATE roles SET ${fields.join(', ')} WHERE id = ?`).run(...values)
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = db.prepare('SELECT * FROM roles WHERE id = ?').get(id)
|
||||||
|
|
||||||
|
// 记录审计日志
|
||||||
|
const afterRole = role as Record<string, unknown>
|
||||||
|
const changes = diffObjects(existing, afterRole)
|
||||||
|
if (Object.keys(changes).length > 0) {
|
||||||
|
writeAuditLog({
|
||||||
|
userId: session.userId,
|
||||||
|
apiKeyId: null,
|
||||||
|
action: 'update',
|
||||||
|
entityType: 'role',
|
||||||
|
entityId: Number(id),
|
||||||
|
details: { changes },
|
||||||
|
ipAddress: getClientIP(request)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ role })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
initDatabase()
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||||
|
if (!checkPermission(session.role, 'roles:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const existing = db.prepare('SELECT * FROM roles WHERE id = ?').get(id) as Record<string, unknown> | undefined
|
||||||
|
if (!existing) return NextResponse.json({ error: '角色不存在' }, { status: 404 })
|
||||||
|
if (BUILTIN_ROLES.includes(existing.name as string)) {
|
||||||
|
return NextResponse.json({ error: '系统内置角色不能删除' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM roles WHERE id = ?').run(id)
|
||||||
|
|
||||||
|
writeAuditLog({
|
||||||
|
userId: session.userId,
|
||||||
|
apiKeyId: null,
|
||||||
|
action: 'delete',
|
||||||
|
entityType: 'role',
|
||||||
|
entityId: Number(id),
|
||||||
|
details: { deleted: { id: existing.id, name: existing.name, display_name: existing.display_name } },
|
||||||
|
ipAddress: getClientIP(request)
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import db from '@/lib/db'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { checkPermission } from '@/lib/permissions'
|
||||||
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
initDatabase()
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||||
|
|
||||||
|
const roles = db.prepare('SELECT * FROM roles ORDER BY id').all()
|
||||||
|
return NextResponse.json({ roles })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
initDatabase()
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||||
|
if (!checkPermission(session.role, 'roles:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { name, display_name, permissions } = body
|
||||||
|
|
||||||
|
if (!name || !display_name) {
|
||||||
|
return NextResponse.json({ error: '角色名称和显示名称为必填项' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db.prepare('SELECT id FROM roles WHERE name = ?').get(name)
|
||||||
|
if (existing) return NextResponse.json({ error: '角色名已存在' }, { status: 400 })
|
||||||
|
|
||||||
|
const result = db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)').run(
|
||||||
|
name, display_name, JSON.stringify(permissions || [])
|
||||||
|
)
|
||||||
|
const role = db.prepare('SELECT * FROM roles WHERE id = ?').get(result.lastInsertRowid)
|
||||||
|
|
||||||
|
writeAuditLog({
|
||||||
|
userId: session.userId,
|
||||||
|
apiKeyId: null,
|
||||||
|
action: 'create',
|
||||||
|
entityType: 'role',
|
||||||
|
entityId: result.lastInsertRowid as number,
|
||||||
|
details: { created: { name, display_name } },
|
||||||
|
ipAddress: getClientIP(request)
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ role }, { status: 201 })
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,6 @@
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import db from '@/lib/db'
|
import db from '@/lib/db'
|
||||||
import { verifyJwt } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
|
|
||||||
async function getSession() {
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
const token = cookieStore.get('session_assets')?.value
|
|
||||||
if (!token) return null
|
|
||||||
return verifyJwt(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,26 @@
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import db from '@/lib/db'
|
import db from '@/lib/db'
|
||||||
import { verifyJwt, hashPassword } from '@/lib/auth'
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
|
import { getSession, hashPassword } from '@/lib/auth'
|
||||||
import { checkPermission } from '@/lib/permissions'
|
import { checkPermission } from '@/lib/permissions'
|
||||||
|
import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
async function getSession() {
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
const token = cookieStore.get('session_assets')?.value
|
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
if (!token) return null
|
initDatabase()
|
||||||
return verifyJwt(token)
|
const session = await getSession()
|
||||||
|
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const user = db.prepare(`
|
||||||
|
SELECT id, username, display_name, email, role, is_active, created_at, updated_at,
|
||||||
|
last_login_at,
|
||||||
|
CASE WHEN last_active_at IS NOT NULL AND datetime(last_active_at, '+5 minutes') > datetime('now', '+8 hours') THEN 1 ELSE 0 END AS is_online
|
||||||
|
FROM users WHERE id = ?
|
||||||
|
`).get(id) as Record<string, unknown> | undefined
|
||||||
|
if (!user) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
|
||||||
|
return NextResponse.json({ user })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
|
@ -19,11 +31,17 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id)
|
const existing = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as { id: number; username: string } | undefined
|
||||||
if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
|
if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
|
||||||
|
// 禁止修改系统保留用户的角色
|
||||||
|
if (body.role && (existing.username === 'admin' || existing.username === 'localadmin')) {
|
||||||
|
return NextResponse.json({ error: '不能修改系统保留用户的角色' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
const updates: string[] = []
|
const updates: string[] = []
|
||||||
const values: unknown[] = []
|
const values: unknown[] = []
|
||||||
|
|
||||||
|
|
@ -37,11 +55,26 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||||
return NextResponse.json({ error: '没有要更新的字段' }, { status: 400 })
|
return NextResponse.json({ error: '没有要更新的字段' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
updates.push("updated_at = datetime('now')")
|
updates.push("updated_at = datetime('now', '+8 hours')")
|
||||||
values.push(id)
|
values.push(id)
|
||||||
|
|
||||||
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...values)
|
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...values)
|
||||||
const user = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at, updated_at FROM users WHERE id = ?').get(id)
|
|
||||||
|
const newUser = db.prepare('SELECT id, username, display_name, email, role, is_active FROM users WHERE id = ?').get(id) as Record<string, unknown>
|
||||||
|
writeAuditLog({
|
||||||
|
userId: session.userId,
|
||||||
|
apiKeyId: null,
|
||||||
|
action: 'update',
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: parseInt(id),
|
||||||
|
details: { changes: diffObjects(existing as Record<string, unknown>, newUser) },
|
||||||
|
ipAddress: getClientIP(request)
|
||||||
|
})
|
||||||
|
|
||||||
|
const user = db.prepare(`SELECT id, username, display_name, email, role, is_active, created_at, updated_at,
|
||||||
|
last_login_at,
|
||||||
|
CASE WHEN last_active_at IS NOT NULL AND datetime(last_active_at, '+5 minutes') > datetime('now', '+8 hours') THEN 1 ELSE 0 END AS is_online
|
||||||
|
FROM users WHERE id = ?`).get(id)
|
||||||
return NextResponse.json({ user })
|
return NextResponse.json({ user })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '更新用户失败'
|
const msg = e instanceof Error ? e.message : '更新用户失败'
|
||||||
|
|
@ -61,9 +94,23 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{
|
||||||
return NextResponse.json({ error: '不能删除当前登录用户' }, { status: 400 })
|
return NextResponse.json({ error: '不能删除当前登录用户' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id)
|
const existing = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as { id: number; username: string } | undefined
|
||||||
if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
|
if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
|
||||||
|
if (existing.username === 'admin' || existing.username === 'localadmin') {
|
||||||
|
return NextResponse.json({ error: '不能删除系统保留用户' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
db.prepare('DELETE FROM users WHERE id = ?').run(id)
|
db.prepare('DELETE FROM users WHERE id = ?').run(id)
|
||||||
|
|
||||||
|
writeAuditLog({
|
||||||
|
userId: session.userId,
|
||||||
|
apiKeyId: null,
|
||||||
|
action: 'delete',
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: parseInt(id),
|
||||||
|
details: { deleted: { id: existing.id, username: existing.username } },
|
||||||
|
ipAddress: getClientIP(_request)
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import db from '@/lib/db'
|
import db from '@/lib/db'
|
||||||
import { verifyJwt, hashPassword } from '@/lib/auth'
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
|
import { getSession, hashPassword } from '@/lib/auth'
|
||||||
import { checkPermission } from '@/lib/permissions'
|
import { checkPermission } from '@/lib/permissions'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
|
|
||||||
async function getSession() {
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
const token = cookieStore.get('session_assets')?.value
|
|
||||||
if (!token) return null
|
|
||||||
return verifyJwt(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
initDatabase()
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||||
if (!checkPermission(session.role, 'users:read')) {
|
if (!checkPermission(session.role, 'users:read')) {
|
||||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const users = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at, updated_at FROM users ORDER BY id').all()
|
const users = db.prepare(`SELECT id, username, display_name, email, role, is_active, created_at, updated_at,
|
||||||
|
last_login_at,
|
||||||
|
CASE WHEN last_active_at IS NOT NULL AND datetime(last_active_at, '+5 minutes') > datetime('now', '+8 hours') THEN 1 ELSE 0 END AS is_online
|
||||||
|
FROM users ORDER BY id`).all()
|
||||||
return NextResponse.json({ users })
|
return NextResponse.json({ users })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,9 +42,19 @@ export async function POST(request: Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = hashPassword(password)
|
const passwordHash = hashPassword(password)
|
||||||
const result = db.prepare('INSERT INTO users (username, password_hash, display_name, email, role) VALUES (?, ?, ?, ?, ?)')
|
const result = db.prepare("INSERT INTO users (username, password_hash, display_name, email, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))")
|
||||||
.run(username, passwordHash, display_name, email || null, role || 'viewer')
|
.run(username, passwordHash, display_name, email || null, role || 'viewer')
|
||||||
|
|
||||||
|
writeAuditLog({
|
||||||
|
userId: session.userId,
|
||||||
|
apiKeyId: null,
|
||||||
|
action: 'create',
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: result.lastInsertRowid as number,
|
||||||
|
details: { created: { username, display_name, role: role || 'viewer' } },
|
||||||
|
ipAddress: getClientIP(request)
|
||||||
|
})
|
||||||
|
|
||||||
const user = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at FROM users WHERE id = ?').get(result.lastInsertRowid)
|
const user = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at FROM users WHERE id = ?').get(result.lastInsertRowid)
|
||||||
return NextResponse.json({ user }, { status: 201 })
|
return NextResponse.json({ user }, { status: 201 })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import db from '@/lib/db'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { checkPermission } from '@/lib/permissions'
|
||||||
|
import { ldapGetUserInfo } from '@/lib/ldap'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||||
|
if (!checkPermission(session.role, 'users:write')) {
|
||||||
|
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = db.prepare(
|
||||||
|
'SELECT id, username FROM users WHERE email IS NULL OR email = \'\''
|
||||||
|
).all() as { id: number; username: string }[]
|
||||||
|
|
||||||
|
let synced = 0
|
||||||
|
let failed = 0
|
||||||
|
|
||||||
|
for (const u of users) {
|
||||||
|
const info = await ldapGetUserInfo(u.username)
|
||||||
|
if (info?.email) {
|
||||||
|
db.prepare('UPDATE users SET email = ? WHERE id = ?').run(info.email, u.id)
|
||||||
|
synced++
|
||||||
|
} else {
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeAuditLog({
|
||||||
|
userId: session.userId,
|
||||||
|
apiKeyId: null,
|
||||||
|
action: 'sync_emails',
|
||||||
|
entityType: 'user',
|
||||||
|
details: { sync_emails: { synced, failed } },
|
||||||
|
ipAddress: getClientIP(request)
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ synced, failed, total: users.length })
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@config "../../tailwind.config.js";
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
@source "../../shared";
|
||||||
|
@source "../../src";
|
||||||
|
|
||||||
|
@import './tlyq-design-system.css' layer(base);
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
@apply bg-slate-50 text-slate-900;
|
font-family: var(--font-body);
|
||||||
}
|
color: var(--fg);
|
||||||
html.dark body {
|
background-color: var(--bg);
|
||||||
@apply bg-slate-950 text-white;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,362 @@
|
||||||
|
/*
|
||||||
|
* TLYQ 统一设计系统 — OKLCh 色彩空间(2026-07-15 升级)
|
||||||
|
* 从 Tailwind Slate HEX 色板迁移至 OKLCh 色彩空间
|
||||||
|
* 感知均匀、色域更广,保持无障碍对比度(WCAG AA ≥ 4.5:1)
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* - CSS 变量:@import './tlyq-design-system.css'
|
||||||
|
* - Tailwind:颜色类见下方映射说明
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 设计令牌(CSS 变量) ==================== */
|
||||||
|
:root {
|
||||||
|
/* 背景色 */
|
||||||
|
--bg: oklch(0.984 0.003 247.858); /* ≈ #f8fafc (slate-50) */
|
||||||
|
--bg-subtle: oklch(0.968 0.007 247.896); /* ≈ #f1f5f9 (slate-100) */
|
||||||
|
--surface: oklch(1 0 0); /* #ffffff (white) */
|
||||||
|
--surface-raised: oklch(1 0 0); /* #ffffff (white) */
|
||||||
|
--surface-overlay: rgba(255, 255, 255, 0.95);
|
||||||
|
|
||||||
|
/* 文本色 */
|
||||||
|
--fg: oklch(0.129 0.042 264.695); /* ≈ #0f172a (slate-900) */
|
||||||
|
--fg-subtle: oklch(0.372 0.044 257.287); /* ≈ #334155 (slate-700) */
|
||||||
|
--muted: oklch(0.446 0.043 257.281); /* ≈ #475569 (slate-600) */
|
||||||
|
--muted-subtle: oklch(0.662 0.051 257.281); /* ≈ #94a3b8 (slate-400) */
|
||||||
|
|
||||||
|
/* 边框 */
|
||||||
|
--border: oklch(0.928 0.006 264.531); /* ≈ #e2e8f0 (slate-200) */
|
||||||
|
--border-subtle: oklch(0.968 0.007 247.896);/* ≈ #f1f5f9 (slate-100) */
|
||||||
|
|
||||||
|
/* 主色调 — 蓝色系 */
|
||||||
|
--accent: oklch(0.546 0.245 262.881); /* ≈ #2563eb (blue-600) */
|
||||||
|
--accent-hover: oklch(0.479 0.247 262.881); /* ≈ #1d4ed8 (blue-700) */
|
||||||
|
--accent-active: oklch(0.426 0.228 262.881);/* ≈ #1e40af (blue-800) */
|
||||||
|
--accent-subtle: oklch(0.967 0.03 260.592); /* ≈ #eff6ff (blue-50) */
|
||||||
|
--accent-text: oklch(1 0 0); /* #ffffff */
|
||||||
|
|
||||||
|
/* 状态色 */
|
||||||
|
--success: oklch(0.627 0.194 149.214); /* ≈ #059669 (emerald-600) */
|
||||||
|
--success-subtle: oklch(0.982 0.041 152.117);/* ≈ #ecfdf5 (emerald-50) */
|
||||||
|
--warning: oklch(0.681 0.162 75.834); /* ≈ #d97706 (amber-600) */
|
||||||
|
--warning-subtle: oklch(0.986 0.03 92.708); /* ≈ #fffbeb (amber-50) */
|
||||||
|
--danger: oklch(0.577 0.245 27.325); /* ≈ #dc2626 (red-600) */
|
||||||
|
--danger-subtle: oklch(0.96 0.037 26.197); /* ≈ #fef2f2 (red-50) */
|
||||||
|
--info: oklch(0.546 0.245 262.881); /* ≈ #2563eb (blue-600) */
|
||||||
|
--info-subtle: oklch(0.967 0.03 260.592); /* ≈ #eff6ff (blue-50) */
|
||||||
|
|
||||||
|
/* 字体 */
|
||||||
|
--font-display: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, sans-serif;
|
||||||
|
--font-body: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, sans-serif;
|
||||||
|
--font-sans: var(--font-body);
|
||||||
|
--font-mono: ui-monospace, "JetBrains Mono", "IBM Plex Mono", SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
|
||||||
|
/* 字体大小 */
|
||||||
|
--text-xs: 0.75rem; /* 12px */
|
||||||
|
--text-sm: 0.875rem; /* 14px */
|
||||||
|
--text-base: 1rem; /* 16px */
|
||||||
|
--text-lg: 1.125rem; /* 18px */
|
||||||
|
--text-xl: 1.25rem; /* 20px */
|
||||||
|
--text-2xl: 1.5rem; /* 24px */
|
||||||
|
--text-3xl: 1.875rem; /* 30px */
|
||||||
|
|
||||||
|
/* 行高 */
|
||||||
|
--leading-tight: 1.25;
|
||||||
|
--leading-snug: 1.375;
|
||||||
|
--leading-normal: 1.5;
|
||||||
|
--leading-relaxed: 1.625;
|
||||||
|
|
||||||
|
/* 字间距 */
|
||||||
|
--tracking-tighter: -0.05em;
|
||||||
|
--tracking-tight: -0.02em;
|
||||||
|
--tracking-normal: 0;
|
||||||
|
--tracking-wide: 0.02em;
|
||||||
|
--tracking-wider: 0.05em;
|
||||||
|
--tracking-widest: 0.1em;
|
||||||
|
|
||||||
|
/* 间距 */
|
||||||
|
--space-1: 0.25rem; /* 4px */
|
||||||
|
--space-2: 0.5rem; /* 8px */
|
||||||
|
--space-3: 0.75rem; /* 12px */
|
||||||
|
--space-4: 1rem; /* 16px */
|
||||||
|
--space-5: 1.25rem; /* 20px */
|
||||||
|
--space-6: 1.5rem; /* 24px */
|
||||||
|
--space-8: 2rem; /* 32px */
|
||||||
|
--space-10: 2.5rem; /* 40px */
|
||||||
|
--space-12: 3rem; /* 48px */
|
||||||
|
--space-16: 4rem; /* 64px */
|
||||||
|
|
||||||
|
/* 圆角 */
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
--radius-xl: 16px;
|
||||||
|
--radius-2xl: 24px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* 阴影 */
|
||||||
|
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
|
/* 过渡 */
|
||||||
|
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-spring: 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
|
||||||
|
/* 层级系统 */
|
||||||
|
--z-base: 0;
|
||||||
|
--z-raised: 10;
|
||||||
|
--z-dropdown: 100;
|
||||||
|
--z-sticky: 200;
|
||||||
|
--z-overlay: 300;
|
||||||
|
--z-modal: 400;
|
||||||
|
--z-toast: 700;
|
||||||
|
|
||||||
|
/* 布局 */
|
||||||
|
--sidebar-width: 240px;
|
||||||
|
--sidebar-width-collapsed: 64px;
|
||||||
|
--header-height: 56px;
|
||||||
|
--content-max-width: 1440px;
|
||||||
|
--content-padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 深色模式 ==================== */
|
||||||
|
:root.dark {
|
||||||
|
/* 背景色 */
|
||||||
|
--bg: oklch(0.129 0.042 264.695); /* ≈ #020617 (slate-950) */
|
||||||
|
--bg-subtle: oklch(0.208 0.042 264.695); /* ≈ #0f172a (slate-900) */
|
||||||
|
--surface: oklch(0.208 0.042 264.695); /* ≈ #0f172a (slate-900) */
|
||||||
|
--surface-raised: oklch(0.279 0.041 260.031);/* ≈ #1e293b (slate-800) */
|
||||||
|
--surface-overlay: rgba(15, 23, 42, 0.95);
|
||||||
|
|
||||||
|
/* 文本色 */
|
||||||
|
--fg: oklch(0.984 0.003 247.858); /* ≈ #f8fafc (slate-50) */
|
||||||
|
--fg-subtle: oklch(0.837 0.014 253.365); /* ≈ #cbd5e1 (slate-300) */
|
||||||
|
--muted: oklch(0.662 0.051 257.281); /* ≈ #94a3b8 (slate-400) */
|
||||||
|
--muted-subtle: oklch(0.527 0.045 257.281); /* ≈ #64748b (slate-500) */
|
||||||
|
|
||||||
|
/* 边框 */
|
||||||
|
--border: oklch(0.279 0.041 260.031); /* ≈ #1e293b (slate-800) */
|
||||||
|
--border-subtle: oklch(0.208 0.042 264.695);/* ≈ #0f172a (slate-900) */
|
||||||
|
|
||||||
|
/* 主色调 */
|
||||||
|
--accent: oklch(0.623 0.214 259.815); /* ≈ #3b82f6 (blue-500) */
|
||||||
|
--accent-hover: oklch(0.723 0.157 260.543); /* ≈ #60a5fa (blue-400) */
|
||||||
|
--accent-active: oklch(0.829 0.096 260.543);/* ≈ #93c5fd (blue-300) */
|
||||||
|
--accent-subtle: rgba(59, 130, 246, 0.1);
|
||||||
|
--accent-text: oklch(0.208 0.042 264.695); /* ≈ #0f172a (slate-900) */
|
||||||
|
|
||||||
|
/* 状态色 */
|
||||||
|
--success: oklch(0.696 0.17 162.48); /* ≈ #34d399 (emerald-400) */
|
||||||
|
--success-subtle: rgba(52, 211, 153, 0.1);
|
||||||
|
--warning: oklch(0.828 0.12 76.523); /* ≈ #fbbf24 (amber-400) */
|
||||||
|
--warning-subtle: rgba(251, 191, 36, 0.1);
|
||||||
|
--danger: oklch(0.715 0.143 25.059); /* ≈ #f87171 (red-400) */
|
||||||
|
--danger-subtle: rgba(248, 113, 113, 0.1);
|
||||||
|
--info: oklch(0.723 0.157 260.543); /* ≈ #60a5fa (blue-400) */
|
||||||
|
--info-subtle: rgba(96, 165, 250, 0.1);
|
||||||
|
|
||||||
|
/* 阴影(深色下更突出) */
|
||||||
|
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.2);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.2);
|
||||||
|
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Tailwind 暗色模式映射 ==================== */
|
||||||
|
/*
|
||||||
|
* 以下 Tailwind 类名可直接使用:
|
||||||
|
* 页面背景: bg-slate-50 dark:bg-slate-950
|
||||||
|
* 卡片背景: bg-white dark:bg-slate-900
|
||||||
|
* 边框: border-slate-200 dark:border-slate-800
|
||||||
|
* 正文: text-slate-900 dark:text-slate-50
|
||||||
|
* 次要文本: text-slate-700 dark:text-slate-300
|
||||||
|
* 弱文本: text-slate-500 dark:text-slate-400
|
||||||
|
* 主按钮: bg-blue-600 hover:bg-blue-700 text-white
|
||||||
|
* 成功: text-emerald-600 dark:text-emerald-400
|
||||||
|
* 警告: text-amber-600 dark:text-amber-400
|
||||||
|
* 危险: text-red-600 dark:text-red-400
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 全局重置 ==================== */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
color: var(--fg);
|
||||||
|
background-color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 组件规范 ==================== */
|
||||||
|
|
||||||
|
/* 按钮规范
|
||||||
|
* 主按钮: bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-sm
|
||||||
|
* 次按钮: bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 rounded-lg
|
||||||
|
* 幽灵按钮: hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg
|
||||||
|
* 尺寸: sm(px-3 py-1.5 text-xs) / md(px-4 py-2 text-sm) / lg(px-6 py-2.5 text-base)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 输入框规范
|
||||||
|
* 边框: border border-slate-300 dark:border-slate-600
|
||||||
|
* 聚焦: focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||||
|
* 圆角: rounded-lg
|
||||||
|
* 尺寸: h-10(px-3 text-sm)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 卡片规范
|
||||||
|
* 基础: bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6 shadow-sm
|
||||||
|
* 悬停: hover:border-blue-200 dark:hover:border-blue-800
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 表格规范
|
||||||
|
* 头部: bg-slate-50 dark:bg-slate-800 text-slate-500 text-xs font-medium uppercase
|
||||||
|
* 行: border-b border-slate-200 dark:border-slate-700
|
||||||
|
* 悬停: hover:bg-slate-50 dark:hover:bg-slate-800/50
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 徽标规范
|
||||||
|
* 默认: bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300
|
||||||
|
* 成功: bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400
|
||||||
|
* 警告: bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400
|
||||||
|
* 危险: bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400
|
||||||
|
* 信息: bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400
|
||||||
|
* 圆角: rounded-full
|
||||||
|
* 尺寸: px-2.5 py-0.5 text-xs font-medium
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 弹窗规范
|
||||||
|
* 背景: bg-white dark:bg-slate-900
|
||||||
|
* 边框: border border-slate-200 dark:border-slate-800
|
||||||
|
* 圆角: rounded-xl
|
||||||
|
* 阴影: shadow-xl
|
||||||
|
* 标题: text-lg font-semibold
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 侧边栏规范
|
||||||
|
* 宽度: w-60 (240px)
|
||||||
|
* 定位: fixed left-0 top-0 bottom-0
|
||||||
|
* 背景: bg-white dark:bg-slate-900
|
||||||
|
* 边框: border-r border-slate-200 dark:border-slate-800
|
||||||
|
* 品牌区: h-14 flex items-center
|
||||||
|
* 导航项: px-3 py-2.5 rounded-lg text-sm font-medium
|
||||||
|
* 激活态: bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 顶栏规范
|
||||||
|
* 高度: h-14 (56px)
|
||||||
|
* 定位: fixed top-0
|
||||||
|
* 背景: bg-white dark:bg-slate-900
|
||||||
|
* 边框: border-b border-slate-200 dark:border-slate-800
|
||||||
|
* z-index: z-30
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 主内容区规范
|
||||||
|
* 边距: ml-60 pt-14 p-6 (侧边栏+顶栏)
|
||||||
|
* 门户布局: max-w-5xl mx-auto px-6 py-8
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 统计卡片 ==================== */
|
||||||
|
/*
|
||||||
|
* 用于仪表盘/概览页的统计数据展示
|
||||||
|
* 容器: bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6
|
||||||
|
* 悬停: hover:border-blue-200 dark:hover:border-blue-800 hover:shadow-md transition-all
|
||||||
|
* 图标区: w-10 h-10 rounded-lg bg-blue-50 dark:bg-blue-500/10 flex items-center justify-center
|
||||||
|
* 数值: text-2xl font-semibold text-slate-900 dark:text-white font-display
|
||||||
|
* 标签: text-sm text-slate-500 dark:text-slate-400
|
||||||
|
* 变化趋势: text-xs font-medium (positive=text-emerald-600, negative=text-red-600)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 骨架屏加载 ==================== */
|
||||||
|
/*
|
||||||
|
* 用于数据加载时的占位效果
|
||||||
|
* <div className="animate-pulse bg-slate-200 dark:bg-slate-700 rounded-lg [height]" />
|
||||||
|
* 文本行: h-4 w-full / w-3/4
|
||||||
|
* 标题: h-6 w-1/3
|
||||||
|
* 卡片: h-32 w-full rounded-xl
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 无缝统计条 ==================== */
|
||||||
|
/*
|
||||||
|
* 仪表盘顶部关键指标展示,分段之间仅 1px 间隙
|
||||||
|
* 容器: flex gap-px bg-slate-200 dark:bg-slate-800 rounded-xl overflow-hidden
|
||||||
|
* 段: flex-1 bg-white dark:bg-slate-900 px-6 py-4 flex flex-col gap-1
|
||||||
|
* 标签: text-xs font-medium text-slate-500 uppercase tracking-widest
|
||||||
|
* 数值: text-2xl font-semibold font-[family-name:var(--font-display)]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 服务状态卡片 ==================== */
|
||||||
|
/*
|
||||||
|
* 仪表盘中展示被监控服务实时状态
|
||||||
|
* 正常: bg-white dark:bg-slate-900 border rounded-xl p-5
|
||||||
|
* 悬停: hover:shadow-sm hover:-translate-y-px transition-all cursor-pointer
|
||||||
|
* 异常: bg-red-50 dark:bg-red-500/10 border-red-600 dark:border-red-400
|
||||||
|
* 红底红框,突出显示
|
||||||
|
* 状态圆点: w-2.5 h-2.5 rounded-full
|
||||||
|
* ok: bg-emerald-600 dark:bg-emerald-400 shadow-[0_0_8px_var(--success)]
|
||||||
|
* err: bg-red-600 dark:bg-red-400 shadow-[0_0_12px_var(--danger)] animate-pulse
|
||||||
|
* 持续时长: text-lg font-semibold font-mono text-red-600 dark:text-red-400
|
||||||
|
* 展开区: border-t mt-3 pt-3
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 图表容器 ==================== */
|
||||||
|
/*
|
||||||
|
* 用于包裹图表组件
|
||||||
|
* <div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6">
|
||||||
|
* <div className="flex items-center justify-between mb-6">
|
||||||
|
* <h3 className="text-lg font-semibold text-slate-900 dark:text-white">标题</h3>
|
||||||
|
* <div className="flex items-center gap-4 text-sm text-slate-500">图例</div>
|
||||||
|
* </div>
|
||||||
|
* [图表内容]
|
||||||
|
* </div>
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 下拉菜单 ==================== */
|
||||||
|
/*
|
||||||
|
* 用于用户菜单、操作菜单
|
||||||
|
* 容器: absolute right-0 top-full mt-1
|
||||||
|
* bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700
|
||||||
|
* rounded-lg shadow-lg py-1 min-w-[200px] z-[--z-dropdown]
|
||||||
|
* 菜单项: px-4 py-2.5 text-sm flex items-center gap-3
|
||||||
|
* hover:bg-slate-50 dark:hover:bg-slate-700
|
||||||
|
* text-slate-700 dark:text-slate-300
|
||||||
|
* 危险项: text-red-600 dark:text-red-400
|
||||||
|
* 分隔线: border-t border-slate-200 dark:border-slate-700 my-1
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 站点标识色 ==================== */
|
||||||
|
/*
|
||||||
|
* OA: blue-600
|
||||||
|
* assets: blue-600
|
||||||
|
* issue: blue-600
|
||||||
|
* monitor: indigo-600
|
||||||
|
*
|
||||||
|
* 站点卡片标识色(门户首页):
|
||||||
|
* 资产管理: blue-600 tag: CMDB
|
||||||
|
* 工单跟踪: violet-600 tag: ITS
|
||||||
|
* 监控中心: indigo-600 tag: MONITOR
|
||||||
|
* 官网: emerald-600 tag: WWW
|
||||||
|
* 云平台: amber-600 tag: CLOUD
|
||||||
|
* Token: rose-600 tag: TOKEN
|
||||||
|
* 代码仓库: pink-600 tag: GIT
|
||||||
|
*
|
||||||
|
* 2026-07-15:从 HEX 迁移至 OKLCh 色彩空间
|
||||||
|
* 主色 blue-600 OKLCh: oklch(0.546 0.245 262.881)
|
||||||
|
* 主色 indigo-600 OKLCh: oklch(0.511 0.262 276.966)
|
||||||
|
* 对比度验证:白底蓝字 (oklch(0.546 0.245 262.881) on oklch(1 0 0)) → 约 6.5:1(≥ 4.5:1 通过)
|
||||||
|
*/
|
||||||
|
|
@ -19,16 +19,35 @@ function Field({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
const items = Array.isArray(children) ? children.filter(Boolean) : children
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-700 p-5">
|
<div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-700 p-5">
|
||||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white mb-3">{title}</h3>
|
<h3 className="text-base font-semibold text-slate-900 dark:text-white mb-3">{title}</h3>
|
||||||
<div className="space-y-0">{items}</div>
|
<div className="space-y-0">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasAny(a: Asset, keys: (keyof Asset)[]): boolean {
|
||||||
|
return keys.some(k => {
|
||||||
|
const v = a[k]
|
||||||
|
return v != null && v !== ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export default function AssetDetail({ asset: a }: AssetDetailProps) {
|
export default function AssetDetail({ asset: a }: AssetDetailProps) {
|
||||||
|
const cpuKeys = ['cpu_model', 'cpu_generation', 'cpu_cores', 'cpu_threads', 'cpu_count', 'cpu_spec'] as (keyof Asset)[]
|
||||||
|
const memKeys = ['memory_model', 'memory_frequency', 'memory_unit_capacity', 'memory_count', 'memory_total'] as (keyof Asset)[]
|
||||||
|
const gpuKeys = ['gpu_model', 'gpu_power', 'gpu_count'] as (keyof Asset)[]
|
||||||
|
const nic1Keys = ['nic1_model', 'nic1_type', 'nic1_speed', 'nic1_count'] as (keyof Asset)[]
|
||||||
|
const nic2Keys = ['nic2_model', 'nic2_type', 'nic2_speed', 'nic2_count'] as (keyof Asset)[]
|
||||||
|
const nic3Keys = ['nic3_model', 'nic3_type', 'nic3_speed', 'nic3_count'] as (keyof Asset)[]
|
||||||
|
const sysDiskKeys = ['sys_disk_model', 'sys_disk_spec', 'sys_disk_capacity', 'sys_disk_type', 'sys_disk_protocol', 'sys_disk_speed', 'sys_disk_count'] as (keyof Asset)[]
|
||||||
|
const dataDisk1Keys = ['data_disk1_model', 'data_disk1_spec', 'data_disk1_capacity', 'data_disk1_type', 'data_disk1_protocol', 'data_disk1_speed', 'data_disk1_count'] as (keyof Asset)[]
|
||||||
|
const dataDisk2Keys = ['data_disk2_model', 'data_disk2_spec', 'data_disk2_capacity', 'data_disk2_type', 'data_disk2_protocol', 'data_disk2_speed', 'data_disk2_count'] as (keyof Asset)[]
|
||||||
|
const raidKeys = ['data_disk_total_space', 'raid_model', 'raid_spec', 'raid_count'] as (keyof Asset)[]
|
||||||
|
const psuKeys = ['psu1_model', 'psu1_power', 'psu1_count', 'psu2_model', 'psu2_power', 'psu2_count', 'psu_total_power'] as (keyof Asset)[]
|
||||||
|
const boardKeys = ['board_model', 'board_count'] as (keyof Asset)[]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
|
@ -50,101 +69,125 @@ export default function AssetDetail({ asset: a }: AssetDetailProps) {
|
||||||
<Field label="保修到期日" value={a.warranty_date} />
|
<Field label="保修到期日" value={a.warranty_date} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="CPU">
|
{hasAny(a, cpuKeys) && (
|
||||||
<Field label="型号" value={a.cpu_model} />
|
<Section title="CPU">
|
||||||
<Field label="代数" value={a.cpu_generation} />
|
<Field label="型号" value={a.cpu_model} />
|
||||||
<Field label="核数" value={a.cpu_cores} />
|
<Field label="代数" value={a.cpu_generation} />
|
||||||
<Field label="线程数" value={a.cpu_threads} />
|
<Field label="核数" value={a.cpu_cores} />
|
||||||
<Field label="数量" value={a.cpu_count} />
|
<Field label="线程数" value={a.cpu_threads} />
|
||||||
<Field label="规格" value={a.cpu_spec} />
|
<Field label="数量" value={a.cpu_count} />
|
||||||
</Section>
|
<Field label="规格" value={a.cpu_spec} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
<Section title="内存">
|
{hasAny(a, memKeys) && (
|
||||||
<Field label="型号" value={a.memory_model} />
|
<Section title="内存">
|
||||||
<Field label="频率" value={a.memory_frequency} />
|
<Field label="型号" value={a.memory_model} />
|
||||||
<Field label="单条容量" value={a.memory_unit_capacity} />
|
<Field label="频率" value={a.memory_frequency} />
|
||||||
<Field label="条数" value={a.memory_count} />
|
<Field label="单条容量" value={a.memory_unit_capacity} />
|
||||||
<Field label="总量" value={a.memory_total} />
|
<Field label="条数" value={a.memory_count} />
|
||||||
</Section>
|
<Field label="总量" value={a.memory_total} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
<Section title="GPU">
|
{hasAny(a, gpuKeys) && (
|
||||||
<Field label="型号" value={a.gpu_model} />
|
<Section title="GPU">
|
||||||
<Field label="功耗" value={a.gpu_power} />
|
<Field label="型号" value={a.gpu_model} />
|
||||||
<Field label="数量" value={a.gpu_count} />
|
<Field label="功耗" value={a.gpu_power} />
|
||||||
</Section>
|
<Field label="数量" value={a.gpu_count} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
<Section title="网卡 1">
|
{hasAny(a, nic1Keys) && (
|
||||||
<Field label="型号" value={a.nic1_model} />
|
<Section title="网卡 1">
|
||||||
<Field label="类型" value={a.nic1_type} />
|
<Field label="型号" value={a.nic1_model} />
|
||||||
<Field label="速率" value={a.nic1_speed} />
|
<Field label="类型" value={a.nic1_type} />
|
||||||
<Field label="数量" value={a.nic1_count} />
|
<Field label="速率" value={a.nic1_speed} />
|
||||||
</Section>
|
<Field label="数量" value={a.nic1_count} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
<Section title="网卡 2">
|
{hasAny(a, nic2Keys) && (
|
||||||
<Field label="型号" value={a.nic2_model} />
|
<Section title="网卡 2">
|
||||||
<Field label="类型" value={a.nic2_type} />
|
<Field label="型号" value={a.nic2_model} />
|
||||||
<Field label="速率" value={a.nic2_speed} />
|
<Field label="类型" value={a.nic2_type} />
|
||||||
<Field label="数量" value={a.nic2_count} />
|
<Field label="速率" value={a.nic2_speed} />
|
||||||
</Section>
|
<Field label="数量" value={a.nic2_count} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
<Section title="网卡 3">
|
{hasAny(a, nic3Keys) && (
|
||||||
<Field label="型号" value={a.nic3_model} />
|
<Section title="网卡 3">
|
||||||
<Field label="类型" value={a.nic3_type} />
|
<Field label="型号" value={a.nic3_model} />
|
||||||
<Field label="速率" value={a.nic3_speed} />
|
<Field label="类型" value={a.nic3_type} />
|
||||||
<Field label="数量" value={a.nic3_count} />
|
<Field label="速率" value={a.nic3_speed} />
|
||||||
</Section>
|
<Field label="数量" value={a.nic3_count} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
<Section title="系统盘">
|
{hasAny(a, sysDiskKeys) && (
|
||||||
<Field label="型号" value={a.sys_disk_model} />
|
<Section title="系统盘">
|
||||||
<Field label="规格" value={a.sys_disk_spec} />
|
<Field label="型号" value={a.sys_disk_model} />
|
||||||
<Field label="容量" value={a.sys_disk_capacity} />
|
<Field label="规格" value={a.sys_disk_spec} />
|
||||||
<Field label="类型" value={a.sys_disk_type} />
|
<Field label="容量" value={a.sys_disk_capacity} />
|
||||||
<Field label="协议" value={a.sys_disk_protocol} />
|
<Field label="类型" value={a.sys_disk_type} />
|
||||||
<Field label="速率" value={a.sys_disk_speed} />
|
<Field label="协议" value={a.sys_disk_protocol} />
|
||||||
<Field label="数量" value={a.sys_disk_count} />
|
<Field label="速率" value={a.sys_disk_speed} />
|
||||||
</Section>
|
<Field label="数量" value={a.sys_disk_count} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
<Section title="数据盘 1">
|
{hasAny(a, dataDisk1Keys) && (
|
||||||
<Field label="型号" value={a.data_disk1_model} />
|
<Section title="数据盘 1">
|
||||||
<Field label="规格" value={a.data_disk1_spec} />
|
<Field label="型号" value={a.data_disk1_model} />
|
||||||
<Field label="容量" value={a.data_disk1_capacity} />
|
<Field label="规格" value={a.data_disk1_spec} />
|
||||||
<Field label="类型" value={a.data_disk1_type} />
|
<Field label="容量" value={a.data_disk1_capacity} />
|
||||||
<Field label="协议" value={a.data_disk1_protocol} />
|
<Field label="类型" value={a.data_disk1_type} />
|
||||||
<Field label="速率" value={a.data_disk1_speed} />
|
<Field label="协议" value={a.data_disk1_protocol} />
|
||||||
<Field label="数量" value={a.data_disk1_count} />
|
<Field label="速率" value={a.data_disk1_speed} />
|
||||||
</Section>
|
<Field label="数量" value={a.data_disk1_count} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
<Section title="数据盘 2">
|
{hasAny(a, dataDisk2Keys) && (
|
||||||
<Field label="型号" value={a.data_disk2_model} />
|
<Section title="数据盘 2">
|
||||||
<Field label="规格" value={a.data_disk2_spec} />
|
<Field label="型号" value={a.data_disk2_model} />
|
||||||
<Field label="容量" value={a.data_disk2_capacity} />
|
<Field label="规格" value={a.data_disk2_spec} />
|
||||||
<Field label="类型" value={a.data_disk2_type} />
|
<Field label="容量" value={a.data_disk2_capacity} />
|
||||||
<Field label="协议" value={a.data_disk2_protocol} />
|
<Field label="类型" value={a.data_disk2_type} />
|
||||||
<Field label="速率" value={a.data_disk2_speed} />
|
<Field label="协议" value={a.data_disk2_protocol} />
|
||||||
<Field label="数量" value={a.data_disk2_count} />
|
<Field label="速率" value={a.data_disk2_speed} />
|
||||||
</Section>
|
<Field label="数量" value={a.data_disk2_count} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
<Section title="RAID / 存储">
|
{hasAny(a, raidKeys) && (
|
||||||
<Field label="数据盘总空间" value={a.data_disk_total_space} />
|
<Section title="RAID / 存储">
|
||||||
<Field label="RAID型号" value={a.raid_model} />
|
<Field label="数据盘总空间" value={a.data_disk_total_space} />
|
||||||
<Field label="RAID规格" value={a.raid_spec} />
|
<Field label="RAID型号" value={a.raid_model} />
|
||||||
<Field label="RAID数量" value={a.raid_count} />
|
<Field label="RAID规格" value={a.raid_spec} />
|
||||||
</Section>
|
<Field label="RAID数量" value={a.raid_count} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
<Section title="电源">
|
{hasAny(a, psuKeys) && (
|
||||||
<Field label="电源1型号" value={a.psu1_model} />
|
<Section title="电源">
|
||||||
<Field label="电源1功率" value={a.psu1_power} />
|
<Field label="电源1型号" value={a.psu1_model} />
|
||||||
<Field label="电源1数量" value={a.psu1_count} />
|
<Field label="电源1功率" value={a.psu1_power} />
|
||||||
<Field label="电源2型号" value={a.psu2_model} />
|
<Field label="电源1数量" value={a.psu1_count} />
|
||||||
<Field label="电源2功率" value={a.psu2_power} />
|
<Field label="电源2型号" value={a.psu2_model} />
|
||||||
<Field label="电源2数量" value={a.psu2_count} />
|
<Field label="电源2功率" value={a.psu2_power} />
|
||||||
<Field label="总功率" value={a.psu_total_power} />
|
<Field label="电源2数量" value={a.psu2_count} />
|
||||||
</Section>
|
<Field label="总功率" value={a.psu_total_power} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
<Section title="主板">
|
{hasAny(a, boardKeys) && (
|
||||||
<Field label="型号" value={a.board_model} />
|
<Section title="主板">
|
||||||
<Field label="数量" value={a.board_count} />
|
<Field label="型号" value={a.board_model} />
|
||||||
</Section>
|
<Field label="数量" value={a.board_count} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,9 @@ interface AssetListProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
{ key: 'device_type', label: '设备类型', sortable: true, filterable: true },
|
|
||||||
{ key: 'node_name', label: '节点名称', sortable: true, filterable: true },
|
{ key: 'node_name', label: '节点名称', sortable: true, filterable: true },
|
||||||
{ key: 'business_ip', label: '业务IP', sortable: true, filterable: true },
|
{ key: 'business_ip', label: '业务IP', sortable: true, filterable: true },
|
||||||
|
{ key: 'device_type', label: '设备类型', sortable: true, filterable: true },
|
||||||
{ key: 'manufacturer', label: '厂商', sortable: true, filterable: true },
|
{ key: 'manufacturer', label: '厂商', sortable: true, filterable: true },
|
||||||
{ key: 'device_model', label: '设备型号', sortable: true, filterable: true },
|
{ key: 'device_model', label: '设备型号', sortable: true, filterable: true },
|
||||||
{ key: 'serial_number', label: '序列号', sortable: true, filterable: true },
|
{ key: 'serial_number', label: '序列号', sortable: true, filterable: true },
|
||||||
|
|
@ -212,11 +212,11 @@ export default function AssetList({
|
||||||
<input type="checkbox" checked={selectedIds.has(row.id)} onChange={() => onToggleSelect(row.id)}
|
<input type="checkbox" checked={selectedIds.has(row.id)} onChange={() => onToggleSelect(row.id)}
|
||||||
className="rounded border-slate-300 dark:border-slate-600" />
|
className="rounded border-slate-300 dark:border-slate-600" />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{row.device_type}</td>
|
|
||||||
<td className="px-4 py-3 font-medium">
|
<td className="px-4 py-3 font-medium">
|
||||||
<Link href={`/assets/${row.id}`} className="text-blue-600 dark:text-blue-400 hover:underline">{row.node_name || '-'}</Link>
|
<Link href={`/assets/${row.id}`} className="text-blue-600 dark:text-blue-400 hover:underline">{row.node_name || '-'}</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{row.business_ip || '-'}</td>
|
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{row.business_ip || '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{row.device_type}</td>
|
||||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{row.manufacturer || '-'}</td>
|
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{row.manufacturer || '-'}</td>
|
||||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{row.device_model || '-'}</td>
|
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{row.device_model || '-'}</td>
|
||||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{row.serial_number || '-'}</td>
|
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{row.serial_number || '-'}</td>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ interface AppShellProps { children: ReactNode; user?: { display_name: string; ro
|
||||||
export default function AppShell({ children, user }: AppShellProps) {
|
export default function AppShell({ children, user }: AppShellProps) {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<Sidebar />
|
<Sidebar role={user?.role} />
|
||||||
<TopBar user={user} />
|
<TopBar user={user} />
|
||||||
<main className="ml-60 pt-14 min-h-screen bg-slate-50 dark:bg-slate-950">
|
<main className="ml-60 pt-14 min-h-screen bg-slate-50 dark:bg-slate-950">
|
||||||
<div className="p-6">{children}</div>
|
<div className="p-6">{children}</div>
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,77 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
// src/components/layout/Sidebar.tsx — 权限驱动侧边栏(统一标准布局)
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import { LayoutDashboard, Server, Search, Settings, Users, Shield, Key } from 'lucide-react'
|
import { LayoutDashboard, Server, Search, Settings, Users, Shield, Key, FileText } from 'lucide-react'
|
||||||
|
|
||||||
const navItems = [
|
interface NavItem { label: string; href: string; icon: React.ComponentType<{ size?: number }>; perm: string | null }
|
||||||
{ href: '/dashboard', label: '仪表盘', icon: LayoutDashboard },
|
interface NavSection { title?: string; items: NavItem[] }
|
||||||
{ href: '/assets', label: '设备管理', icon: Server },
|
|
||||||
{ href: '/assets/advanced-search', label: '高级查询', icon: Search },
|
|
||||||
]
|
|
||||||
|
|
||||||
const settingsItems = [
|
export default function Sidebar({ role }: { role?: string }) {
|
||||||
{ href: '/settings/users', label: '用户管理', icon: Users },
|
|
||||||
{ href: '/settings/roles', label: '角色权限', icon: Shield },
|
|
||||||
{ href: '/settings/api-keys', label: 'API Key', icon: Key },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function Sidebar() {
|
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
const isActive = (href: string) => pathname === href || (href !== '/' && pathname.startsWith(href))
|
||||||
|
const isAdmin = role === 'admin' || role === 'localadmin'
|
||||||
|
|
||||||
|
const sections: NavSection[] = [
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{ label: '仪表盘', href: '/dashboard', icon: LayoutDashboard, perm: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '资产管理',
|
||||||
|
items: [
|
||||||
|
{ label: '设备管理', href: '/assets', icon: Server, perm: null },
|
||||||
|
{ label: '高级查询', href: '/assets/advanced-search', icon: Search, perm: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '系统设置',
|
||||||
|
items: [
|
||||||
|
{ label: '用户管理', href: '/settings/users', icon: Users, perm: isAdmin ? null : 'hidden' },
|
||||||
|
{ label: '角色权限', href: '/settings/roles', icon: Shield, perm: isAdmin ? null : 'hidden' },
|
||||||
|
{ label: 'API Key', href: '/settings/api-keys', icon: Key, perm: isAdmin ? null : 'hidden' },
|
||||||
|
{ label: '审计日志', href: '/settings/audit-logs', icon: FileText, perm: isAdmin ? null : 'hidden' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="fixed left-0 top-0 bottom-0 w-60 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex flex-col z-40">
|
<aside className="fixed left-0 top-0 bottom-0 w-60 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 z-[200] flex flex-col">
|
||||||
<div className="h-14 flex items-center px-5 border-b border-slate-200 dark:border-slate-800">
|
{/* 品牌区 */}
|
||||||
<span className="text-lg font-semibold text-blue-600 dark:text-blue-400">资产管理系统</span>
|
<div className="h-14 flex items-center px-6 border-b border-slate-200 dark:border-slate-800 shrink-0">
|
||||||
|
<Link href="/dashboard" className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center text-white font-bold text-sm">资</div>
|
||||||
|
<span className="font-semibold text-slate-900 dark:text-white">资产管理系统</span>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 py-3 px-3 space-y-1 overflow-y-auto">
|
|
||||||
{navItems.map((item) => {
|
{/* 导航区 */}
|
||||||
const isActive = pathname === item.href || pathname.startsWith(item.href + '/')
|
<nav className="flex-1 overflow-y-auto p-3 space-y-6">
|
||||||
const Icon = item.icon
|
{sections.map((section, i) => (
|
||||||
return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>)
|
<div key={i}>
|
||||||
})}
|
{section.title && (
|
||||||
<div className="pt-3 border-t border-slate-200 dark:border-slate-800 mt-3">
|
<p className="px-3 mb-1 text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-widest">
|
||||||
<div className="flex items-center gap-3 px-3 py-2 text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
|
{section.title}
|
||||||
<Settings size={14} />系统设置
|
</p>
|
||||||
|
)}
|
||||||
|
{section.items.filter(item => item.perm !== 'hidden').map(item => {
|
||||||
|
const Icon = item.icon
|
||||||
|
return (
|
||||||
|
<Link key={item.href} href={item.href}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors mb-0.5 ${
|
||||||
|
isActive(item.href)
|
||||||
|
? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400'
|
||||||
|
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'
|
||||||
|
}`}>
|
||||||
|
<Icon size={18} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{settingsItems.map((item) => {
|
))}
|
||||||
const isActive = pathname === item.href
|
|
||||||
const Icon = item.icon
|
|
||||||
return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,30 +2,60 @@
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useTheme } from '@/components/providers/ThemeProvider'
|
import { useTheme } from '@/components/providers/ThemeProvider'
|
||||||
import { Sun, Moon, LogOut, User } from 'lucide-react'
|
import { Sun, Moon, LogOut, User } from 'lucide-react'
|
||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark' | 'auto'
|
||||||
|
|
||||||
interface TopBarProps { user?: { display_name: string; role: string } }
|
interface TopBarProps { user?: { display_name: string; role: string } }
|
||||||
|
|
||||||
export default function TopBar({ user }: TopBarProps) {
|
export default function TopBar({ user }: TopBarProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { theme, toggleTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
|
||||||
|
document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await fetch('/api/auth/logout', { method: 'POST' })
|
await fetch('/api/auth/logout', { method: 'POST' })
|
||||||
router.push('/login'); router.refresh()
|
router.push('/login'); router.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const icons: Record<Theme, React.ReactNode> = { light: <Sun size={16} />, dark: <Moon size={16} />, auto: <span style={{fontSize:14}}>◐</span> }
|
||||||
|
const labels: Record<Theme, string> = { light: '浅色', dark: '深色', auto: '自动' }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="fixed top-0 left-60 right-0 h-14 bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between px-6 z-30">
|
<header className="fixed top-0 left-60 right-0 h-14 bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between px-6 z-30">
|
||||||
<div />
|
<div />
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button onClick={toggleTheme} className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" title={theme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'}>
|
<div ref={ref} className="relative">
|
||||||
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
|
<button onClick={() => setOpen(!open)} className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors" title="切换主题">
|
||||||
</button>
|
{icons[theme]}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute top-full right-0 mt-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg z-50 py-1 min-w-[100px]">
|
||||||
|
{(['light', 'dark', 'auto'] as Theme[]).map(t => (
|
||||||
|
<button key={t} onClick={() => { setTheme(t); setOpen(false) }}
|
||||||
|
className={`w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left ${t === theme ? 'bg-slate-100 dark:bg-slate-700' : 'hover:bg-slate-50 dark:hover:bg-slate-700/50'}`}>
|
||||||
|
<span>{icons[t]}</span>
|
||||||
|
<span>{labels[t]}</span>
|
||||||
|
{t === theme && <span className="ml-auto text-blue-600">✓</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{user && (
|
{user && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
|
||||||
<User size={16} />
|
<User size={16} />
|
||||||
<span>{user.display_name}</span>
|
<span>{user.display_name}</span>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleLogout} className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" title="退出登录">
|
<button onClick={handleLogout} className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors" title="退出登录">
|
||||||
<LogOut size={18} />
|
<LogOut size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,38 @@
|
||||||
'use client'
|
'use client'
|
||||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
|
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
|
||||||
|
|
||||||
type Theme = 'light' | 'dark'
|
type Theme = 'light' | 'dark' | 'auto'
|
||||||
interface ThemeContextType { theme: Theme; toggleTheme: () => void }
|
interface ThemeContextType { theme: Theme; setTheme: (t: Theme) => void }
|
||||||
const ThemeContext = createContext<ThemeContextType>({ theme: 'dark', toggleTheme: () => {} })
|
const ThemeContext = createContext<ThemeContextType>({ theme: 'auto', setTheme: () => {} })
|
||||||
|
|
||||||
export function useTheme() { return useContext(ThemeContext) }
|
export function useTheme() { return useContext(ThemeContext) }
|
||||||
|
|
||||||
|
function applyTheme(t: Theme) {
|
||||||
|
const root = document.documentElement
|
||||||
|
root.classList.remove('light', 'dark')
|
||||||
|
if (t === 'auto') {
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
root.classList.add(prefersDark ? 'dark' : 'light')
|
||||||
|
} else {
|
||||||
|
root.classList.add(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
const [theme, setTheme] = useState<Theme>('dark')
|
const [theme, setThemeState] = useState<Theme>('auto')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Read initial theme from localStorage or system preference
|
|
||||||
const stored = localStorage.getItem('theme') as Theme | null
|
const stored = localStorage.getItem('theme') as Theme | null
|
||||||
const initial = stored || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
const initial = stored || 'auto'
|
||||||
setTheme(initial)
|
setThemeState(initial)
|
||||||
document.documentElement.classList.toggle('dark', initial === 'dark')
|
applyTheme(initial)
|
||||||
document.documentElement.classList.toggle('light', initial === 'light')
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const setTheme = (t: Theme) => {
|
||||||
setTheme(prev => {
|
setThemeState(t)
|
||||||
const next = prev === 'dark' ? 'light' : 'dark'
|
localStorage.setItem('theme', t)
|
||||||
localStorage.setItem('theme', next)
|
applyTheme(t)
|
||||||
document.documentElement.classList.toggle('dark', next === 'dark')
|
|
||||||
document.documentElement.classList.toggle('light', next === 'light')
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>
|
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,41 @@
|
||||||
'use client'
|
'use client'
|
||||||
import { ReactNode, useEffect } from 'react'
|
import { ReactNode, useEffect, useRef } from 'react'
|
||||||
interface ModalProps { open: boolean; onClose: () => void; title?: string; children: ReactNode; footer?: ReactNode }
|
interface ModalProps { open: boolean; onClose: () => void; title?: string; children: ReactNode; footer?: ReactNode }
|
||||||
export default function Modal({ open, onClose, title, children, footer }: ModalProps) {
|
export default function Modal({ open, onClose, title, children, footer }: ModalProps) {
|
||||||
useEffect(() => { document.body.style.overflow = open ? 'hidden' : ''; return () => { document.body.style.overflow = '' } }, [open])
|
const footerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.style.overflow = open ? 'hidden' : ''
|
||||||
|
return () => { document.body.style.overflow = '' }
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') { onClose(); return }
|
||||||
|
if (!footerRef.current) return
|
||||||
|
const buttons = footerRef.current.querySelectorAll('button:not([disabled])')
|
||||||
|
if (buttons.length === 0) return
|
||||||
|
const currentIdx = Array.from(buttons).indexOf(document.activeElement as HTMLButtonElement)
|
||||||
|
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault()
|
||||||
|
const next = e.key === 'ArrowRight'
|
||||||
|
? (currentIdx + 1) % buttons.length
|
||||||
|
: (currentIdx - 1 + buttons.length) % buttons.length
|
||||||
|
;(buttons[next] as HTMLButtonElement).focus()
|
||||||
|
} else if (e.key === 'Enter' && currentIdx >= 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
;(document.activeElement as HTMLButtonElement).click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
const btn = footerRef.current?.querySelector('button:not([disabled])')
|
||||||
|
if (btn) (btn as HTMLButtonElement).focus()
|
||||||
|
}, 100)
|
||||||
|
return () => { document.removeEventListener('keydown', handleKeyDown); clearTimeout(t) }
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
|
@ -15,7 +48,9 @@ export default function Modal({ open, onClose, title, children, footer }: ModalP
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="p-6">{children}</div>
|
<div className="p-6">{children}</div>
|
||||||
{footer && <div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-200 dark:border-slate-800">{footer}</div>}
|
{footer && (
|
||||||
|
<div ref={footerRef} className="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-200 dark:border-slate-800">{footer}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
// assets-ai/src/lib/audit.ts — 站点审计包装器(引用共享库)
|
||||||
|
import db from './db'
|
||||||
|
import { writeAuditLog as sharedWriteAuditLog, diffObjects, getClientIP } from '@shared/lib/audit/write-audit-log'
|
||||||
|
import type { AuditLogEntry, AuditStore } from '@shared/lib/audit/write-audit-log'
|
||||||
|
|
||||||
|
// 创建 assets-ai 的 store 适配器
|
||||||
|
const store: AuditStore = {
|
||||||
|
exec: (sql: string) => {
|
||||||
|
try {
|
||||||
|
db.exec(sql)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('审计日志写入失败:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保持原有调用方式:writeAuditLog(opts) —— 内部调用共享库
|
||||||
|
export function writeAuditLog(opts: AuditLogEntry): void {
|
||||||
|
sharedWriteAuditLog(store, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// re-export 共享工具函数
|
||||||
|
export { diffObjects, getClientIP }
|
||||||
|
|
@ -37,6 +37,65 @@ export function hashApiKey(key: string): string { return crypto.createHash('sha2
|
||||||
export function generateApiKey(): string { return `ak_${crypto.randomBytes(32).toString('hex')}` }
|
export function generateApiKey(): string { return `ak_${crypto.randomBytes(32).toString('hex')}` }
|
||||||
export function verifySession(token: string): SessionPayload | null { return verifyJwt(token) }
|
export function verifySession(token: string): SessionPayload | null { return verifyJwt(token) }
|
||||||
|
|
||||||
|
// 统一获取当前会话:优先 tlyq_session(共享 JWT),回退 session_assets(本地 JWT)
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { verifySharedJwt } from '@/lib/jwt'
|
||||||
|
import { ldapUserExists, ldapGetUserInfo, ldapIsAdmin } from '@/lib/ldap'
|
||||||
|
|
||||||
|
export async function getSession(): Promise<SessionPayload | null> {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
|
||||||
|
// 1. tlyq_session(共享 JWT,LDAP 用户)
|
||||||
|
const sharedToken = cookieStore.get('tlyq_session')?.value
|
||||||
|
if (sharedToken) {
|
||||||
|
const sharedPayload = verifySharedJwt(sharedToken)
|
||||||
|
if (sharedPayload) {
|
||||||
|
// Q1: 检查 LLDAP 中用户是否仍存在(已删除则强制退出)
|
||||||
|
if (!(await ldapUserExists(sharedPayload.username))) {
|
||||||
|
cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/' })
|
||||||
|
cookieStore.set('session_assets', '', { maxAge: 0, path: '/' })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const row = db.prepare(
|
||||||
|
'SELECT id, username, role, email FROM users WHERE username = ? AND is_active = 1'
|
||||||
|
).get(sharedPayload.username) as { id: number; username: string; role: string; email: string | null } | undefined
|
||||||
|
if (row) {
|
||||||
|
if (!row.email) {
|
||||||
|
const info = await ldapGetUserInfo(sharedPayload.username)
|
||||||
|
if (info?.email) db.prepare('UPDATE users SET email = ? WHERE id = ?').run(info.email, row.id)
|
||||||
|
}
|
||||||
|
db.prepare("UPDATE users SET last_login_at = datetime('now', '+8 hours'), last_active_at = datetime('now', '+8 hours') WHERE id = ?").run(row.id)
|
||||||
|
return { userId: row.id, username: row.username, role: row.role }
|
||||||
|
}
|
||||||
|
// SSO 免登录:LLDAP 验证通过但本地无记录 → 自动创建(从 LLDAP 判断角色,oa-ai 同步更新)
|
||||||
|
const ldapInfo = await ldapGetUserInfo(sharedPayload.username)
|
||||||
|
const displayName = ldapInfo?.displayName || sharedPayload.displayName
|
||||||
|
const email = ldapInfo?.email ?? null
|
||||||
|
const role = (await ldapIsAdmin(sharedPayload.username)) ? 'admin' : 'viewer'
|
||||||
|
db.prepare(
|
||||||
|
"INSERT OR IGNORE INTO users (username, display_name, email, role, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
|
||||||
|
).run(sharedPayload.username, displayName, email, role)
|
||||||
|
const newRow = db.prepare(
|
||||||
|
'SELECT id, username, role FROM users WHERE username = ? AND is_active = 1'
|
||||||
|
).get(sharedPayload.username) as { id: number; username: string; role: string } | undefined
|
||||||
|
if (newRow) {
|
||||||
|
return { userId: newRow.id, username: newRow.username, role: newRow.role }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. session_assets(本地 JWT,admin 账号或紧急绕过)
|
||||||
|
const token = cookieStore.get('session_assets')?.value
|
||||||
|
if (token) {
|
||||||
|
const payload = verifyJwt(token)
|
||||||
|
if (payload) {
|
||||||
|
db.prepare("UPDATE users SET last_login_at = datetime('now', '+8 hours'), last_active_at = datetime('now', '+8 hours') WHERE id = ?").run(payload.userId)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export function verifyApiKey(key: string): { id: number; name: string; permissions: string[] } | null {
|
export function verifyApiKey(key: string): { id: number; name: string; permissions: string[] } | null {
|
||||||
if (!key.startsWith('ak_')) return null
|
if (!key.startsWith('ak_')) return null
|
||||||
const keyHash = hashApiKey(key)
|
const keyHash = hashApiKey(key)
|
||||||
|
|
@ -44,6 +103,6 @@ export function verifyApiKey(key: string): { id: number; name: string; permissio
|
||||||
.get(keyHash) as { id: number; name: string; permissions: string } | undefined
|
.get(keyHash) as { id: number; name: string; permissions: string } | undefined
|
||||||
if (!row) return null
|
if (!row) return null
|
||||||
if (row.expires_at && new Date(row.expires_at) < new Date()) return null
|
if (row.expires_at && new Date(row.expires_at) < new Date()) return null
|
||||||
db.prepare("UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?").run(row.id)
|
db.prepare("UPDATE api_keys SET last_used_at = datetime('now', '+8 hours') WHERE id = ?").run(row.id)
|
||||||
return { id: row.id, name: row.name, permissions: JSON.parse(row.permissions) }
|
return { id: row.id, name: row.name, permissions: JSON.parse(row.permissions) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,21 +11,21 @@ export function initDatabase() {
|
||||||
email TEXT,
|
email TEXT,
|
||||||
role TEXT NOT NULL DEFAULT 'viewer',
|
role TEXT NOT NULL DEFAULT 'viewer',
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS roles (
|
CREATE TABLE IF NOT EXISTS roles (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
display_name TEXT NOT NULL,
|
display_name TEXT NOT NULL,
|
||||||
permissions TEXT NOT NULL DEFAULT '[]',
|
permissions TEXT NOT NULL DEFAULT '[]',
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
expires_at TEXT NOT NULL,
|
expires_at TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS api_keys (
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|
@ -36,7 +36,7 @@ export function initDatabase() {
|
||||||
expires_at TEXT,
|
expires_at TEXT,
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
created_by INTEGER REFERENCES users(id),
|
created_by INTEGER REFERENCES users(id),
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS assets (
|
CREATE TABLE IF NOT EXISTS assets (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|
@ -73,8 +73,8 @@ export function initDatabase() {
|
||||||
psu_total_power TEXT,
|
psu_total_power TEXT,
|
||||||
board_model TEXT, board_count INTEGER,
|
board_model TEXT, board_count INTEGER,
|
||||||
raw_data TEXT,
|
raw_data TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|
@ -85,7 +85,12 @@ export function initDatabase() {
|
||||||
entity_id INTEGER,
|
entity_id INTEGER,
|
||||||
details TEXT,
|
details TEXT,
|
||||||
ip_address TEXT,
|
ip_address TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_assets_node_name ON assets(node_name);
|
CREATE INDEX IF NOT EXISTS idx_assets_node_name ON assets(node_name);
|
||||||
CREATE INDEX IF NOT EXISTS idx_assets_business_ip ON assets(business_ip);
|
CREATE INDEX IF NOT EXISTS idx_assets_business_ip ON assets(business_ip);
|
||||||
|
|
@ -93,22 +98,53 @@ export function initDatabase() {
|
||||||
CREATE INDEX IF NOT EXISTS idx_assets_status ON assets(status);
|
CREATE INDEX IF NOT EXISTS idx_assets_status ON assets(status);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
// 迁移:添加 last_login_at 和 last_active_at 列
|
||||||
|
try { db.exec('ALTER TABLE users ADD COLUMN last_login_at TEXT') } catch { /* 列已存在 */ }
|
||||||
|
try { db.exec('ALTER TABLE users ADD COLUMN last_active_at TEXT') } catch { /* 列已存在 */ }
|
||||||
|
|
||||||
const existingAdmin = db.prepare('SELECT id FROM users WHERE username = ?').get('admin')
|
const existingAdmin = db.prepare('SELECT id FROM users WHERE username = ?').get('admin')
|
||||||
if (!existingAdmin) {
|
if (!existingAdmin) {
|
||||||
const passwordHash = bcrypt.hashSync(process.env.ADMIN_PASSWORD || 'admin123', 10)
|
const passwordHash = bcrypt.hashSync(process.env.ADMIN_PASSWORD || 'admin123', 10)
|
||||||
db.prepare('INSERT INTO users (username, password_hash, display_name, role) VALUES (?, ?, ?, ?)')
|
db.prepare("INSERT INTO users (username, password_hash, display_name, role, created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))")
|
||||||
.run('admin', passwordHash, '管理员', 'admin')
|
.run('admin', passwordHash, '管理员', 'admin')
|
||||||
}
|
}
|
||||||
|
const existingLocalAdmin = db.prepare('SELECT id FROM users WHERE username = ?').get('localadmin')
|
||||||
|
if (!existingLocalAdmin) {
|
||||||
|
const localPasswordHash = bcrypt.hashSync(process.env.LOCALADMIN_PASSWORD || 'admin123', 10)
|
||||||
|
db.prepare("INSERT INTO users (username, password_hash, display_name, role, created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))")
|
||||||
|
.run('localadmin', localPasswordHash, '本地管理员', 'admin')
|
||||||
|
}
|
||||||
const defaultRoles = [
|
const defaultRoles = [
|
||||||
{ name: 'admin', display_name: '管理员', permissions: '["*"]' },
|
{ name: 'admin', display_name: '管理员', permissions: '["*"]' },
|
||||||
{ name: 'editor', display_name: '编辑者', permissions: '["assets:read","assets:write","assets:delete"]' },
|
{ name: 'editor', display_name: '编辑者', permissions: '["assets:read","assets:create","assets:import","assets:update","assets:delete","assets:export:selected"]' },
|
||||||
{ name: 'viewer', display_name: '查看者', permissions: '["assets:read"]' },
|
{ name: 'viewer', display_name: '查看者', permissions: '["assets:read"]' },
|
||||||
]
|
]
|
||||||
|
const builtinNames = new Set(defaultRoles.map(r => r.name))
|
||||||
for (const role of defaultRoles) {
|
for (const role of defaultRoles) {
|
||||||
const existing = db.prepare('SELECT id FROM roles WHERE name = ?').get(role.name)
|
const existing = db.prepare('SELECT id FROM roles WHERE name = ?').get(role.name)
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)')
|
db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)')
|
||||||
.run(role.name, role.display_name, role.permissions)
|
.run(role.name, role.display_name, role.permissions)
|
||||||
|
} else if (role.name === 'admin') {
|
||||||
|
// 仅 admin 角色始终同步到最新默认权限,editor/viewer 允许用户自定义
|
||||||
|
db.prepare('UPDATE roles SET permissions = ? WHERE name = ?').run(role.permissions, role.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 迁移旧权限(所有角色,包括内置角色)
|
||||||
|
const allRoles = db.prepare('SELECT id, name, permissions FROM roles').all() as { id: number; name: string; permissions: string }[]
|
||||||
|
for (const r of allRoles) {
|
||||||
|
const perms: string[] = JSON.parse(r.permissions)
|
||||||
|
if (perms.includes('assets:export')) {
|
||||||
|
const upgraded = perms.filter(p => p !== 'assets:export')
|
||||||
|
upgraded.push('assets:export:selected')
|
||||||
|
if (perms.includes('*')) upgraded.push('assets:export:all')
|
||||||
|
db.prepare('UPDATE roles SET permissions = ? WHERE id = ?').run(JSON.stringify(upgraded), r.id)
|
||||||
|
}
|
||||||
|
if (perms.includes('assets:write')) {
|
||||||
|
const upgraded = perms.filter(p => p !== 'assets:write')
|
||||||
|
upgraded.push('assets:create', 'assets:import', 'assets:update')
|
||||||
|
db.prepare('UPDATE roles SET permissions = ? WHERE id = ?').run(JSON.stringify(upgraded), r.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@
|
||||||
* 封装对 issue-ai 工单系统的 API 调用
|
* 封装对 issue-ai 工单系统的 API 调用
|
||||||
*
|
*
|
||||||
* 环境变量:
|
* 环境变量:
|
||||||
* ISSUE_API_URL — issue-ai API 地址(默认 http://localhost:5176/api,云上必须设 http://issue-ai:3000/api)
|
* ISSUE_API_URL — issue-ai API 地址(默认 http://localhost:6176/api,云上必须设 http://issue-ai:3000/api)
|
||||||
* ISSUE_API_KEY — API 密钥(可选,如不设置则需要 Cookie 认证)
|
* ISSUE_API_KEY — API 密钥(可选,如不设置则需要 Cookie 认证)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const API_BASE = process.env.ISSUE_API_URL || 'http://localhost:5176/api'
|
const API_BASE = process.env.ISSUE_API_URL || 'http://localhost:6176/api'
|
||||||
const API_KEY = process.env.ISSUE_API_KEY || ''
|
const API_KEY = process.env.ISSUE_API_KEY || ''
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
// assets-ai/src/lib/jwt.ts — 引用共享 JWT,保持原有接口
|
||||||
|
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||||
|
import { signJwtV2 } from '@shared/lib/auth/jwt-v2'
|
||||||
|
import type { AuthConfig } from '@shared/lib/auth/types'
|
||||||
|
|
||||||
|
// 从环境变量读取配置
|
||||||
|
const config: AuthConfig = {
|
||||||
|
jwtSecret: process.env.JWT_SECRET || 'default-secret-change-me',
|
||||||
|
cookieDomain: process.env.COOKIE_DOMAIN || '',
|
||||||
|
autheliaUrl: '', oidcClientId: '', oidcClientSecret: '', oidcRedirectUri: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedSession {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
iat: number
|
||||||
|
exp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保持原有签名:signSharedJwt(payload, expiresIn)
|
||||||
|
export function signSharedJwt(
|
||||||
|
payload: { username: string; displayName: string },
|
||||||
|
expiresIn: number = 7 * 24 * 60 * 60
|
||||||
|
): string {
|
||||||
|
return signJwtV2({ secret: config.jwtSecret, payload, iss: 'assets.tlyq.ai', expiresInSeconds: expiresIn })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保持原有签名:verifySharedJwt(token)
|
||||||
|
export function verifySharedJwt(token: string): SharedSession | null {
|
||||||
|
const payload = verifyJwt(token, config.jwtSecret)
|
||||||
|
if (!payload) return null
|
||||||
|
return {
|
||||||
|
username: payload.username as string,
|
||||||
|
displayName: (payload.displayName || payload.username) as string,
|
||||||
|
iat: payload.iat as number,
|
||||||
|
exp: payload.exp as number,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保持原有签名:sharedCookieConfig(maxAge)
|
||||||
|
export function sharedCookieConfig(maxAge: number = 7 * 24 * 60 * 60) {
|
||||||
|
return {
|
||||||
|
name: 'tlyq_session',
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
domain: config.cookieDomain || undefined,
|
||||||
|
path: '/',
|
||||||
|
maxAge,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { Client, InvalidCredentialsError } from 'ldapts'
|
||||||
|
|
||||||
|
const LDAP_URL = process.env.LDAP_URL || 'ldap://localhost:3890'
|
||||||
|
const LDAP_BASE_DN = process.env.LDAP_BASE_DN || 'dc=tlyq,dc=ai'
|
||||||
|
|
||||||
|
// 从环境变量获取 LLDAP admin 密码(容器内无法执行 docker exec,见 LESSONS-LEARNED #41)
|
||||||
|
function getLdapAdminPassword(): string {
|
||||||
|
return process.env.LLDAP_ADMIN_PASSWORD || 'admin123'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LdapResult {
|
||||||
|
success: boolean
|
||||||
|
unreachable: boolean
|
||||||
|
username?: string
|
||||||
|
displayName?: string
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ldapAuth(
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
): Promise<LdapResult> {
|
||||||
|
const userDn = `uid=${username},ou=people,${LDAP_BASE_DN}`
|
||||||
|
const client = new Client({ url: LDAP_URL, timeout: 5000 })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.bind(userDn, password)
|
||||||
|
try {
|
||||||
|
const { searchEntries } = await client.search(LDAP_BASE_DN, {
|
||||||
|
scope: 'sub',
|
||||||
|
filter: `(uid=${username})`,
|
||||||
|
attributes: ['displayName', 'mail'],
|
||||||
|
timeLimit: 3,
|
||||||
|
})
|
||||||
|
const entry = searchEntries[0] as any
|
||||||
|
const displayName = entry?.displayName || username
|
||||||
|
const email = entry?.mail || null
|
||||||
|
return { success: true, unreachable: false, username, displayName, email }
|
||||||
|
} catch {
|
||||||
|
return { success: true, unreachable: false, username, displayName: username }
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof InvalidCredentialsError) {
|
||||||
|
return { success: false, unreachable: false }
|
||||||
|
}
|
||||||
|
return { success: false, unreachable: true }
|
||||||
|
} finally {
|
||||||
|
await client.unbind()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 LLDAP 获取用户信息(displayName + email),不可达返回 null
|
||||||
|
export async function ldapGetUserInfo(username: string): Promise<{ displayName: string; email: string | null } | null> {
|
||||||
|
const adminDn = process.env.LDAP_ADMIN_DN || 'uid=admin,ou=people,dc=tlyq,dc=ai'
|
||||||
|
const adminPass = getLdapAdminPassword()
|
||||||
|
const client = new Client({ url: LDAP_URL, timeout: 5000 })
|
||||||
|
try {
|
||||||
|
await client.bind(adminDn, adminPass)
|
||||||
|
const { searchEntries } = await client.search(LDAP_BASE_DN, {
|
||||||
|
scope: 'sub', filter: `(uid=${username})`, attributes: ['displayName', 'mail'], timeLimit: 3,
|
||||||
|
})
|
||||||
|
const entry = searchEntries[0] as any
|
||||||
|
return entry ? { displayName: entry.displayName || username, email: entry.mail || null } : null
|
||||||
|
} catch { return null }
|
||||||
|
finally { await client.unbind() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 LLDAP 用户是否为 lldap_admin 组成员
|
||||||
|
export async function ldapIsAdmin(username: string): Promise<boolean> {
|
||||||
|
if (username === 'admin') return true
|
||||||
|
const adminDn = process.env.LDAP_ADMIN_DN || 'uid=admin,ou=people,dc=tlyq,dc=ai'
|
||||||
|
const adminPass = getLdapAdminPassword()
|
||||||
|
const client = new Client({ url: LDAP_URL, timeout: 5000 })
|
||||||
|
try {
|
||||||
|
await client.bind(adminDn, adminPass)
|
||||||
|
const { searchEntries } = await client.search(`ou=groups,${LDAP_BASE_DN}`, {
|
||||||
|
scope: 'sub', filter: `(&(cn=lldap_admin)(member=uid=${username},ou=people,${LDAP_BASE_DN}))`, timeLimit: 3,
|
||||||
|
})
|
||||||
|
return searchEntries.length > 0
|
||||||
|
} catch { return false }
|
||||||
|
finally { try { await client.unbind() } catch { /* */ } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Q1: 检查 LLDAP 中用户是否存在(用 admin bind 搜索,不在/不可达均返回 true 保证容错)
|
||||||
|
export async function ldapUserExists(username: string): Promise<boolean> {
|
||||||
|
const adminDn = process.env.LDAP_ADMIN_DN || 'uid=admin,ou=people,dc=tlyq,dc=ai'
|
||||||
|
const adminPass = getLdapAdminPassword()
|
||||||
|
const client = new Client({ url: LDAP_URL, timeout: 5000 })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.bind(adminDn, adminPass)
|
||||||
|
const { searchEntries } = await client.search(LDAP_BASE_DN, {
|
||||||
|
scope: 'sub', filter: `(uid=${username})`, timeLimit: 3,
|
||||||
|
})
|
||||||
|
return searchEntries.length > 0
|
||||||
|
} catch {
|
||||||
|
return true // LLDAP 不可达 → 不阻断,容错放行
|
||||||
|
} finally {
|
||||||
|
await client.unbind()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { Issuer } from 'openid-client'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
const AUTHELIA_URL = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
|
||||||
|
const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || 'assets-oidc'
|
||||||
|
const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || ''
|
||||||
|
const OIDC_REDIRECT_URI = process.env.OIDC_REDIRECT_URI || 'https://assets.tlyq.ai/api/auth/callback'
|
||||||
|
|
||||||
|
let oidcClient: any = null
|
||||||
|
let lastDiscovery = 0
|
||||||
|
const DISCOVERY_TTL = 3600000 // 1 小时
|
||||||
|
|
||||||
|
export async function getOidcClient() {
|
||||||
|
const now = Date.now()
|
||||||
|
if (oidcClient && (now - lastDiscovery) < DISCOVERY_TTL) {
|
||||||
|
return oidcClient
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const issuer = await Issuer.discover(AUTHELIA_URL)
|
||||||
|
oidcClient = new issuer.Client({
|
||||||
|
client_id: OIDC_CLIENT_ID,
|
||||||
|
client_secret: OIDC_CLIENT_SECRET,
|
||||||
|
redirect_uris: [OIDC_REDIRECT_URI],
|
||||||
|
response_types: ['code'],
|
||||||
|
token_endpoint_auth_method: 'client_secret_basic',
|
||||||
|
})
|
||||||
|
lastDiscovery = now
|
||||||
|
return oidcClient
|
||||||
|
} catch (error) {
|
||||||
|
console.error('OIDC discovery 失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generatePKCE() {
|
||||||
|
const codeVerifier = crypto.randomBytes(32).toString('base64url')
|
||||||
|
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url')
|
||||||
|
return { codeVerifier, codeChallenge }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateState() {
|
||||||
|
return crypto.randomBytes(32).toString('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateNonce() {
|
||||||
|
return crypto.randomBytes(32).toString('base64url')
|
||||||
|
}
|
||||||
|
|
@ -1,47 +1,15 @@
|
||||||
import { NextResponse } from 'next/server'
|
// src/middleware.ts — V2 单 cookie 模型,Edge 验签 + iss 校验
|
||||||
import type { NextRequest } from 'next/server'
|
import { createMiddlewareV2 } from '@shared/lib/auth/middleware-v2'
|
||||||
|
|
||||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
const jwtSecret = process.env.JWT_SECRET || 'oa-shared-jwt-secret-tlyq-2026'
|
||||||
try {
|
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
|
||||||
const parts = token.split('.')
|
|
||||||
if (parts.length !== 3) return null
|
|
||||||
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/')
|
|
||||||
while (payload.length % 4) payload += '='
|
|
||||||
return JSON.parse(atob(payload))
|
|
||||||
} catch { return null }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
export const middleware = createMiddlewareV2({
|
||||||
const { pathname } = request.nextUrl
|
jwtSecret,
|
||||||
if (pathname === '/login' || pathname.startsWith('/api/auth/login')) return NextResponse.next()
|
cookieDomain,
|
||||||
|
allowedIssuers: ['*'],
|
||||||
if (pathname.startsWith('/api/')) {
|
enableApiKey: true,
|
||||||
const authHeader = request.headers.get('authorization')
|
publicPaths: ['/login', '/api/auth', '/api/health', '/api/internal', '/_next', '/favicon.ico'],
|
||||||
if (authHeader?.startsWith('Bearer ak_')) return NextResponse.next()
|
})
|
||||||
const token = request.cookies.get('session_assets')?.value
|
|
||||||
const payload = token ? decodeJwtPayload(token) : null
|
|
||||||
if (!payload?.userId) {
|
|
||||||
return NextResponse.json({ error: '未授权' }, { status: 401 })
|
|
||||||
}
|
|
||||||
return NextResponse.next()
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = request.cookies.get('session_assets')?.value
|
|
||||||
const payload = token ? decodeJwtPayload(token) : null
|
|
||||||
const isValidToken = payload?.userId != null
|
|
||||||
|
|
||||||
if (!isValidToken) {
|
|
||||||
const loginUrl = new URL('/login', request.url)
|
|
||||||
const dest = pathname + (request.nextUrl.search || '')
|
|
||||||
loginUrl.searchParams.set('redirect', dest)
|
|
||||||
const response = NextResponse.redirect(loginUrl)
|
|
||||||
if (token) response.cookies.delete('session_assets')
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = NextResponse.next()
|
|
||||||
response.headers.set('x-original-pathname', pathname + (request.nextUrl.search || ''))
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico|public).*)'] }
|
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico|public).*)'] }
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
module.exports = {
|
|
||||||
darkMode: 'class',
|
|
||||||
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
|
|
||||||
theme: { extend: {} },
|
|
||||||
plugins: [],
|
|
||||||
}
|
|
||||||
|
|
@ -7,7 +7,8 @@
|
||||||
"resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve",
|
"resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [{ "name": "next" }],
|
"plugins": [{ "name": "next" }],
|
||||||
"paths": { "@/*": ["./src/*"] }
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["./src/*"], "@shared/*": ["./shared/*"] }
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue