chore: 初始化仓库 — 资产管理系统
This commit is contained in:
commit
a4fe324efd
|
|
@ -0,0 +1,9 @@
|
|||
DATABASE_PATH=./data/assets.db
|
||||
JWT_SECRET=your-secret-key-change-in-production
|
||||
NODE_ENV=development
|
||||
|
||||
# issue-ai API 配置(用于故障历史功能)
|
||||
# NEXT_PUBLIC_ 前缀:构建时内嵌到客户端 JS,云上必须通过 deploy-ai.sh 设置
|
||||
# 本地开发:http://localhost:5176/tickets
|
||||
# 云上生产:https://issue.tlyq.ai/tickets
|
||||
NEXT_PUBLIC_ISSUE_URL=http://localhost:5176/tickets
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
build/
|
||||
.DS_Store
|
||||
*.pem
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.production
|
||||
data/
|
||||
.claude/
|
||||
db-backups/
|
||||
.playwright-mcp/
|
||||
*.tsbuildinfo
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# 变更日志
|
||||
|
||||
## 2026-04-30
|
||||
|
||||
- [新增] 创建 README 文档
|
||||
|
||||
## 2026-04-29
|
||||
|
||||
- [新增] 设备管理列表支持鼠标拖拽自定义列宽(首次拖拽自动快照列宽,最小 60px,支持自动换行)
|
||||
- [优化] 设备状态下拉框改为「腾讯使用/图灵使用/闲置」默认选项,支持自定义新增状态
|
||||
- [优化] 数据库 106 台设备状态从「在用」迁移为「腾讯使用」
|
||||
- [修复] 设备详情页/列表页「腾讯使用」状态徽章颜色显示为灰色的问题
|
||||
- [优化] 列头居中显示,排序/筛选模式切换按钮与排序图标左右对称布局
|
||||
- [修复] 表头 `overflow-hidden` 导致筛选下拉框被裁切的问题
|
||||
- [优化] 筛选弹框宽度改为自适应(`w-fit min-w-48 max-w-80`)
|
||||
- [优化] 排序/筛选图标改为可点击,直接点击图标即可触发排序或筛选
|
||||
- [修复] 列头排序图标使用 `<button>` 嵌套在列名 `<button>` 内导致 HTML 非法嵌套错误,改为 `<span>`
|
||||
|
||||
## 2026-04-28
|
||||
|
||||
- [部署] 生产环境 txjp 服务器更新 `break-all` 序列号换行修复,验证通过
|
||||
- [修复] 资产详情页序列号等长字符串不会自动换行,给 Field 组件值列添加 `break-all` 样式
|
||||
- [调整] 工单接口 `Ticket` 类型移除 `ticket_no` 字段,同步 issue-ai 工单号改造
|
||||
- [修复] 生产环境设备详情页「故障历史」中工单链接和"查看全部工单记录"按钮错误使用 localhost:5176
|
||||
- [修复] `AssetTicketHistory.tsx` 中 `ISSUE_URL` fallback 值从 `localhost:5176/tickets` 改为 `https://issue.tlyq.ai/tickets`
|
||||
- [修复] 未登录时从 issue-ai 工单详情点击业务 IP → assets-ai 登录后错误跳转仪表盘。根因:跨端口 cookie 泄露(同 localhost 的不同端口共享 session cookie),issue-ai JWT payload 用 `id` 字段而 assets-ai 用 `userId`,middleware 放行后 layout.tsx 的 `redirect('/login')` 丢失了 redirect 参数
|
||||
- `middleware.ts`:增加 JWT payload `userId` 检查,无效 cookie 清除后重定向(携带 redirect 参数)
|
||||
- `layout.tsx`:从 `x-original-pathname` header 读取原路径,`redirect('/login')` 时携带 redirect 参数
|
||||
- [修复] 资产详情页「故障历史」显示"会话已过期"。根因:`issue-client.ts` 缺少 API Key 只能转发 cookie,issue-ai 侧 JWT 格式不兼容
|
||||
- 配置本地测试环境 API Key 双向认证(`ISSUE_API_KEY` + `ALLOWED_API_KEYS`)
|
||||
- `issue-client.ts`:fallback 默认值从 `127.0.0.1` 改为 `localhost`
|
||||
- [文档] 更新 `CLAUDE.md`:补充 API Key 双向认证说明和本地 `.env.local` 配置示例
|
||||
- [修复] 同一浏览器同时登录两个系统,任一退出后另一个也被退出。根因:两个系统共用 `session` cookie 名且同 `localhost` 域
|
||||
- cookie 名改为 `session_assets`(assets-ai)和 `session_issue`(issue-ai),互不干扰
|
||||
- 云端部署无需额外操作(域名不同天然隔离,但改名保持一致),用户重新登录即可
|
||||
|
||||
## 2026-04-27
|
||||
|
||||
- [新增] 资产核心 API(CRUD + 分页 + 搜索 + 高级筛选)
|
||||
- [新增] 资产列表页面(`/assets`),支持多选、筛选、排序
|
||||
- [新增] 设备详情页面(`/assets/[id]`),全硬件配置分区卡片展示
|
||||
- [新增] 新增/编辑设备页面
|
||||
- [新增] 批量编辑页面(`/assets/batch-edit`),安全限制仅允许部分字段
|
||||
- [新增] Excel 模板导入(`/assets/import`),按 SN 匹配新增/更新
|
||||
- [新增] 设备导入脚本(`import-servers.ts`)
|
||||
- [新增] 仪表盘统计数据(`/api/stats`):总数/状态/类型/厂商/机房/保修预警
|
||||
- [新增] 调用 issue.tlyq.ai API 获取设备历史工单,资产详情页展示历史工单卡片
|
||||
|
||||
## 2026-04-25
|
||||
|
||||
- [新增] 仪表盘页面(`/dashboard`),资产概览图表
|
||||
|
||||
## 2026-04-24
|
||||
|
||||
- [新增] 项目初始化,基于 Next.js 15.1 + SQLite(standalone 输出模式)
|
||||
- [新增] 认证系统(JWT 自实现 + API Key 双模式)
|
||||
- [新增] 用户管理(`/settings/users`)和角色权限系统(admin/editor/viewer)
|
||||
- [新增] API Key 管理页面(`/settings/api-keys`),支持创建/删除 Key(SHA-256 存储)
|
||||
- [新增] 数据库初始化脚本(init-db.ts),预置角色和默认管理员账号
|
||||
- [新增] Excel 导入模板(`服务器信息-issue.xlsx`)
|
||||
- [新增] Docker 部署配置(两阶段 alpine 构建)
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
# CLAUDE.md — assets.tlyq.ai 资产管理系统
|
||||
|
||||
## 项目概述
|
||||
|
||||
assets-ai 是基于 Next.js + SQLite 的 IT 设备资产管理系统(CMDB),部署在腾讯云(txjp 服务器),域名 `assets.tlyq.ai`。管理 GPU 服务器、存储服务器等 120+ 台设备的完整硬件信息,通过 REST API 对外服务,供 issue.tlyq.ai 等系统调用。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 站点域名 | `assets.tlyq.ai` |
|
||||
| 服务器 | txjp(IP: 43.133.38.210) |
|
||||
| 代码路径 | `/root/docker/assets-ai/` |
|
||||
| 本地端口 | 5177 |
|
||||
| 容器名 | `assets-ai` |
|
||||
| 数据库 | SQLite:`data/assets.db` |
|
||||
| 默认账号 | `admin` / `admin123` |
|
||||
|
||||
### 常用命令
|
||||
|
||||
```bash
|
||||
cd /Users/niuniu/programs/docker/assets-ai
|
||||
npm run dev # 本地开发
|
||||
npm run build # 生产构建
|
||||
npm run db:init # 初始化数据库
|
||||
npm run import # 导入设备数据
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `next.config.ts` | `output: 'standalone'`,better-sqlite3 服务端打包 |
|
||||
| `Dockerfile` | 两阶段构建(alpine builder + alpine runner) |
|
||||
| `docker-compose.yml` | 使用 external webnet,挂载 `.next` 从宿主机 |
|
||||
| `src/middleware.ts` | 全局路由守卫(Cookie JWT / Bearer API Key 双模式) |
|
||||
| `src/lib/db.ts` | SQLite 连接(WAL 模式,外键开启) |
|
||||
| `src/lib/db-schema.ts` | 表初始化 + 默认数据(admin/editor/viewer) |
|
||||
| `src/lib/auth.ts` | JWT 签名/验证、API Key 生成/校验(自实现,未用 jsonwebtoken 包) |
|
||||
| `src/lib/permissions.ts` | 权限检查(按角色 JSON 匹配) |
|
||||
| `src/lib/issue-client.ts` | 调用 issue API 获取设备历史工单 |
|
||||
| `src/lib/excel.ts` | Excel 模板生成 + 导入解析 |
|
||||
| `src/types/index.ts` | User / Asset / ApiKey / PaginatedResult 等类型 |
|
||||
| `src/app/api/assets/` | 资产 CRUD + 批量修改 + 导入/导出 + 高级查询 |
|
||||
|
||||
---
|
||||
|
||||
## 数据库 Schema
|
||||
|
||||
### 表概览
|
||||
|
||||
| 表名 | 说明 |
|
||||
|------|------|
|
||||
| `users` | 用户账号(username/password_hash/role) |
|
||||
| `roles` | 角色定义(name/display_name/permissions JSON) |
|
||||
| `sessions` | 会话(JWT → user_id) |
|
||||
| `api_keys` | API Key(SHA-256 hash,供外部系统调用) |
|
||||
| `assets` | 设备资产主体(68 列硬件字段) |
|
||||
| `audit_logs` | 审计日志 |
|
||||
|
||||
### assets 表核心字段
|
||||
|
||||
**设备标识**:`serial_number`(UNIQUE)、`device_type`(GPU服务器/存储服务器)、`device_purpose`、`room`、`rack_position`、`node_name`、`business_ip`、`hdm_ip`、`manufacturer`、`device_model`、`status`
|
||||
|
||||
**CPU/内存**:`cpu_model`、`cpu_generation`、`cpu_cores`、`cpu_count`、`cpu_threads`、`memory_model`、`memory_frequency`、`memory_unit_capacity`、`memory_count`、`memory_total`
|
||||
|
||||
**GPU/网络**:`gpu_model`、`gpu_power`、`gpu_count`、NIC×3(`nic1/2/3_model/type/speed/count`)
|
||||
|
||||
**存储/电源**:`sys_disk_*`、`data_disk1/2_*`、`raid_model/spec/count`、`psu1/2_model/power/count`
|
||||
|
||||
### 预置角色
|
||||
|
||||
| 角色 | 权限 | 说明 |
|
||||
|------|------|------|
|
||||
| `admin` | `["*"]` | 全部权限 |
|
||||
| `editor` | `["assets:read","assets:write","assets:delete"]` | 可增删改资产,不可管理用户和 Key |
|
||||
| `viewer` | `["assets:read"]` | 只读 |
|
||||
|
||||
---
|
||||
|
||||
## API 路由
|
||||
|
||||
### 认证
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/api/auth/login` | 登录(username + password → JWT cookie,24h 有效) |
|
||||
| POST | `/api/auth/logout` | 登出 |
|
||||
| GET | `/api/auth/me` | 当前用户信息 |
|
||||
|
||||
### 资产
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/assets` | 资产列表(分页/搜索/高级筛选/sort) |
|
||||
| POST | `/api/assets` | 创建设备 |
|
||||
| GET | `/api/assets/[id]` | 设备详情 |
|
||||
| PUT | `/api/assets/[id]` | 更新设备 |
|
||||
| DELETE | `/api/assets/[id]` | 删除设备 |
|
||||
| POST | `/api/assets/batch` | 批量修改(仅允许部分字段,见下方限制) |
|
||||
| POST | `/api/assets/import` | Excel 模板导入(按 SN 匹配新增/更新) |
|
||||
| GET | `/api/assets/export` | 导出 Excel |
|
||||
| GET | `/api/assets/field-values` | 获取字段可选值(筛选下拉用) |
|
||||
|
||||
**GET /api/assets 高级查询**:
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `search` | 全文搜索(SN、节点名、IP、型号、厂商) |
|
||||
| `filter_<field>` | 精确筛选(可多次使用实现 IN 筛选) |
|
||||
| `filters` | JSON 数组:`[{field, op, value}]`,op 支持 contains/equals/starts_with/ends_with/not_empty/empty |
|
||||
| `sortKey` / `sortOrder` | 排序字段 |
|
||||
|
||||
### 统计 / API Key / 用户
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/stats` | 资产概览(总数/按状态/按类型/按厂商/按机房/保修预警) |
|
||||
| GET/POST | `/api/api-keys` | Key 列表 / 创建(仅显示一次) |
|
||||
| DELETE | `/api/api-keys/[id]` | 删除 Key |
|
||||
| GET/POST | `/api/users` | 用户列表/创建 |
|
||||
| GET/PUT/DELETE | `/api/users/[id]` | 单个用户操作 |
|
||||
|
||||
---
|
||||
|
||||
## 认证机制
|
||||
|
||||
- **Web UI**:`middleware.ts` 检查 cookie 中的 JWT token,payload 含 `{ userId, username, role, iat, exp }`
|
||||
- **API Key**:`Bearer ak_<32位十六进制>`,存储时 SHA-256 hash,验证时更新 `last_used_at`,权限由创建时指定的 `permissions` 数组控制
|
||||
|
||||
---
|
||||
|
||||
## 环境配置
|
||||
|
||||
### 本地与云端差异
|
||||
|
||||
| 环境变量 | 本地开发 | 云服务器(txjp) | 说明 |
|
||||
|---------|---------|----------------|------|
|
||||
| `ISSUE_API_URL` | `http://localhost:5176/api` | `http://issue-ai:3000/api` | 调用 issue API 地址 |
|
||||
| `ISSUE_API_KEY` | 本地 issue-ai 生成 | 云上 issue-ai 生成 | **每个环境独立,不可跨环境使用** |
|
||||
| `NEXT_PUBLIC_ISSUE_URL` | `http://localhost:5176/tickets` | `https://issue.tlyq.ai` | 前端跳转链接(构建时内嵌) |
|
||||
| `ALLOWED_API_KEYS` | issue-ai 调用本系统时需要的 Key | 云上 issue-ai 生成的 Key | 仅 issue→assets 方向需要 |
|
||||
| `JWT_SECRET` | `dev-secret-key-local` | `${ASSETS_JWT_SECRET}` | 生产必须强密钥 |
|
||||
| `DATABASE_PATH` | `./data/assets.db` | `/app/data/assets.db` | Docker volume 挂载 |
|
||||
| Cookie 名 | `session_assets` | `session_assets` | 本地两系统用不同名防 localhost 域冲突 |
|
||||
|
||||
### `.env.local` 示例
|
||||
|
||||
```bash
|
||||
DATABASE_PATH=./data/assets.db
|
||||
JWT_SECRET=dev-secret-key-local
|
||||
NODE_ENV=development
|
||||
ISSUE_API_URL=http://localhost:5176/api
|
||||
ISSUE_API_KEY=ak_<32字节十六进制>
|
||||
NEXT_PUBLIC_ISSUE_URL=http://localhost:5176/tickets
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 与 issue.tlyq.ai 的联动
|
||||
|
||||
```
|
||||
assets-ai ──→ GET {ISSUE_API_URL}/tickets/by-asset?ip=xxx (Authorization: Bearer {ISSUE_API_KEY})
|
||||
issue-ai ──→ GET {ASSETS_API_URL}/assets?search=IP (Authorization: Bearer {ASSETS_API_KEY})
|
||||
```
|
||||
|
||||
- **assets → issue**(`src/lib/issue-client.ts`):资产详情页展示历史工单,优先 ISSUE_API_KEY,回退用户 Cookie
|
||||
- **API Key 配置**:在 issue-ai `/settings/api-keys` 创建 Key → 写入 assets-ai 的 `ISSUE_API_KEY`
|
||||
|
||||
详见 [issue-ai CLAUDE.md](../issue-ai/CLAUDE.md#与-assetstlyqai-的联动)。
|
||||
|
||||
---
|
||||
|
||||
## 批量编辑限制
|
||||
|
||||
`POST /api/assets/batch` 仅允许修改以下字段(安全考虑):
|
||||
|
||||
```
|
||||
device_type, device_purpose, room, rack_position, status,
|
||||
manufacturer, device_model, warranty_date
|
||||
```
|
||||
|
||||
IP、序列号、硬件配置等必须通过单台编辑接口逐一修改。
|
||||
|
||||
---
|
||||
|
||||
## Docker 部署
|
||||
|
||||
```
|
||||
txjp 服务器
|
||||
├── assets-ai(容器) ← Next.js standalone,监听 3000
|
||||
├── nginx-ai ← 反向代理 assets.tlyq.ai → assets-ai:3000
|
||||
└── webnet(external) ← 共享网络
|
||||
```
|
||||
|
||||
部署:`bash deploy-ai.sh` → 选择 5。源码打包上传 → 服务器 `npm install` + `npm run build` → `.next` 挂载进容器生效。`--force` 强制重建,`--restart` 仅重启。
|
||||
|
||||
### 生产环境变量
|
||||
|
||||
```bash
|
||||
DATABASE_PATH=/app/data/assets.db
|
||||
JWT_SECRET=<线上密钥>
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_ISSUE_URL=https://issue.tlyq.ai/tickets
|
||||
```
|
||||
|
||||
> `NEXT_PUBLIC_ISSUE_URL` 由 `deploy-ai.sh` 在构建前自动写入服务器 `.env`。
|
||||
|
||||
---
|
||||
|
||||
## 故障排查
|
||||
|
||||
```bash
|
||||
# 容器日志
|
||||
ssh txjp "docker logs assets-ai"
|
||||
|
||||
# 数据库初始化
|
||||
ssh txjp "docker exec assets-ai node -e \"require('./scripts/init-db.js')\""
|
||||
|
||||
# 重建镜像(不常用,.next 挂载已覆盖日常更新)
|
||||
ssh txjp "cd /root/docker/assets-ai && docker compose down && docker compose build --no-cache && docker compose up -d"
|
||||
```
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# builder 阶段:npm 依赖缓存跨构建持久化
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN apk add --no-cache python3 make g++
|
||||
RUN --mount=type=cache,target=/root/.npm,id=assets-npm \
|
||||
npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# runner 阶段:与 builder 保持一致(Alpine + musl),确保 better-sqlite3 等原生模块兼容
|
||||
FROM node:20-alpine AS runner
|
||||
RUN apk add --no-cache python3 make g++
|
||||
WORKDIR /app
|
||||
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/static ./.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
RUN mkdir -p /app/data /app/uploads
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# IT 设备资产管理系统
|
||||
|
||||
基于 Next.js + SQLite 的 IT 设备资产管理系统(CMDB),域名为 `assets.tlyq.ai`,管理 GPU 服务器、存储服务器等 120+ 台设备的硬件信息,通过 REST API 对外提供服务。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 分类 | 技术 |
|
||||
|------|------|
|
||||
| 框架 | Next.js 15 + TypeScript |
|
||||
| UI | React 19 + Tailwind CSS v4 + lucide-react |
|
||||
| 数据库 | SQLite(better-sqlite3,WAL 模式) |
|
||||
| 认证 | JWT(cookie 方式)+ 自定义 session + API Key |
|
||||
|
||||
## 功能
|
||||
|
||||
- 设备 CRUD(创建、编辑、删除、搜索、筛选)
|
||||
- 设备状态管理(腾讯使用 / 已回收(退役) / 备件 / 借用 / 故障)
|
||||
- 设备详情(含硬件信息、历史工单卡片)
|
||||
- 工单历史联动(调用 issue-ai API 获取同 IP 历史工单)
|
||||
- API Key 管理(支持服务间调用认证)
|
||||
- 用户/角色权限管理
|
||||
- Excel 导出设备清单
|
||||
|
||||
## 设备字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| node_name | 节点名称 |
|
||||
| serial_number | 序列号 |
|
||||
| device_type | 设备类型(GPU服务器 / 存储服务器) |
|
||||
| business_ip | 业务 IP |
|
||||
| hdm_ip | HDM 管理 IP |
|
||||
| manufacturer | 厂商 |
|
||||
| device_model | 设备型号 |
|
||||
| status | 设备状态 |
|
||||
| cabinet | 机柜 |
|
||||
| asset_number | 资产编号 |
|
||||
| remark | 备注 |
|
||||
|
||||
## API 路由
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET/POST | `/api/assets` | 设备列表 / 创建设备 |
|
||||
| GET/PUT/DELETE | `/api/assets/[id]` | 单个设备操作 |
|
||||
| GET/POST | `/api/api-keys` | API Key 管理 |
|
||||
| GET/POST | `/api/users` | 用户管理 |
|
||||
| GET | `/api/stats` | 统计概览 |
|
||||
|
||||
……
|
||||
|
||||
## 与 issue.tlyq.ai 联动
|
||||
|
||||
assets-ai 向 issue-ai 提供设备信息,issue-ai 在工单管理中根据 IP 自动关联设备并展示详情卡片。同时 assets-ai 设备详情页调用 issue-ai API 展示同 IP 历史工单。
|
||||
|
||||
```
|
||||
issue-ai ──→ GET /api/assets?search=IP (获取设备信息)
|
||||
assets-ai ──→ GET /api/tickets/by-asset?ip=X (获取历史工单)
|
||||
```
|
||||
|
||||
认证方式:双向 API Key(`Authorization: Bearer ak_xxx`)。
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
services:
|
||||
assets-ai:
|
||||
build: .
|
||||
container_name: assets-ai
|
||||
ports:
|
||||
- "5177:3000"
|
||||
volumes:
|
||||
- assets-data:/app/data
|
||||
- assets-uploads:/app/uploads
|
||||
# .next 目录从主机挂载,主机上 npm run build 后直接生效
|
||||
- ./.next:/app/.next
|
||||
environment:
|
||||
- DATABASE_PATH=/app/data/assets.db
|
||||
- JWT_SECRET=${ASSETS_JWT_SECRET:-change-me-in-production}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||
- NODE_ENV=production
|
||||
- TZ=Asia/Shanghai
|
||||
# issue-ai API 地址(容器内使用 issue-ai 服务名)
|
||||
- ISSUE_API_URL=http://issue-ai:3000/api
|
||||
# issue-ai API Key(用于服务间认证)
|
||||
- ISSUE_API_KEY=${ISSUE_API_KEY:-your-secret-api-key-change-in-production}
|
||||
# 故障历史跳转的工单系统地址(客户端使用)
|
||||
- NEXT_PUBLIC_ISSUE_URL=https://issue.tlyq.ai/tickets
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- webnet
|
||||
|
||||
volumes:
|
||||
assets-data:
|
||||
assets-uploads:
|
||||
|
||||
networks:
|
||||
webnet:
|
||||
external: true
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import type { NextConfig } from 'next'
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
images: { unoptimized: true },
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
serverExternalPackages: ['better-sqlite3'],
|
||||
}
|
||||
export default nextConfig
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"name": "assets-ai",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "NODE_OPTIONS='--max-old-space-size=2048' next dev --port 5177",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"db:init": "tsx scripts/init-db.ts",
|
||||
"import": "tsx scripts/import-servers.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"cookie": "^1.0.2",
|
||||
"lucide-react": "^1.8.0",
|
||||
"next": "^15.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^3.8.1",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/cookie": "^1.0.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"eslint": "^10.2.1",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
const config = { plugins: { '@tailwindcss/postcss': {} } }
|
||||
export default config
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
/**
|
||||
* 导入服务器资产数据
|
||||
* 用法: npx tsx scripts/import-servers.ts [excel文件路径]
|
||||
* 默认路径: templates-docs/服务器信息-issue.xlsx
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
// 动态导入 xlsx
|
||||
const XLSX = require('xlsx')
|
||||
|
||||
const dbPath = process.env.DATABASE_PATH || './data/assets.db'
|
||||
const dbDir = path.dirname(dbPath)
|
||||
if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true })
|
||||
|
||||
const db = new Database(dbPath)
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.pragma('foreign_keys = ON')
|
||||
|
||||
// 初始化数据库
|
||||
function initDb() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'viewer',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
permissions TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL,
|
||||
permissions TEXT NOT NULL DEFAULT '["assets:read"]',
|
||||
last_used_at TEXT,
|
||||
expires_at TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
serial_number TEXT UNIQUE,
|
||||
device_type TEXT NOT NULL,
|
||||
device_purpose TEXT,
|
||||
room TEXT,
|
||||
rack_position TEXT,
|
||||
node_name TEXT,
|
||||
business_ip TEXT,
|
||||
hdm_ip TEXT,
|
||||
manufacturer TEXT,
|
||||
device_model TEXT,
|
||||
status TEXT NOT NULL DEFAULT '在用',
|
||||
warranty_date TEXT,
|
||||
cpu_model TEXT, cpu_generation TEXT, cpu_cores INTEGER,
|
||||
cpu_count INTEGER, cpu_threads INTEGER, cpu_spec TEXT,
|
||||
memory_model TEXT, memory_frequency TEXT, memory_unit_capacity TEXT,
|
||||
memory_count INTEGER, memory_total TEXT,
|
||||
gpu_model TEXT, gpu_power TEXT, gpu_count INTEGER,
|
||||
nic1_model TEXT, nic1_type TEXT, nic1_speed TEXT, nic1_count INTEGER,
|
||||
nic2_model TEXT, nic2_type TEXT, nic2_speed TEXT, nic2_count INTEGER,
|
||||
nic3_model TEXT, nic3_type TEXT, nic3_speed TEXT, nic3_count INTEGER,
|
||||
sys_disk_model TEXT, sys_disk_spec TEXT, sys_disk_capacity TEXT,
|
||||
sys_disk_type TEXT, sys_disk_protocol TEXT, sys_disk_speed TEXT, sys_disk_count INTEGER,
|
||||
data_disk1_model TEXT, data_disk1_spec TEXT, data_disk1_capacity TEXT,
|
||||
data_disk1_type TEXT, data_disk1_protocol TEXT, data_disk1_speed TEXT, data_disk1_count INTEGER,
|
||||
data_disk2_model TEXT, data_disk2_spec TEXT, data_disk2_capacity TEXT,
|
||||
data_disk2_type TEXT, data_disk2_protocol TEXT, data_disk2_speed TEXT, data_disk2_count INTEGER,
|
||||
data_disk_total_space TEXT,
|
||||
raid_model TEXT, raid_spec TEXT, raid_count INTEGER,
|
||||
psu1_model TEXT, psu1_power TEXT, psu1_count INTEGER,
|
||||
psu2_model TEXT, psu2_power TEXT, psu2_count INTEGER,
|
||||
psu_total_power TEXT,
|
||||
board_model TEXT, board_count INTEGER,
|
||||
raw_data TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
api_key_id INTEGER REFERENCES api_keys(id),
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id INTEGER,
|
||||
details TEXT,
|
||||
ip_address TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
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_device_type ON assets(device_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_status ON assets(status);
|
||||
`)
|
||||
|
||||
// 创建默认 admin
|
||||
const bcrypt = require('bcryptjs')
|
||||
const existingAdmin = db.prepare('SELECT id FROM users WHERE username = ?').get('admin')
|
||||
if (!existingAdmin) {
|
||||
const passwordHash = bcrypt.hashSync('admin123', 10)
|
||||
db.prepare('INSERT INTO users (username, password_hash, display_name, role) VALUES (?, ?, ?, ?)')
|
||||
.run('admin', passwordHash, '管理员', 'admin')
|
||||
}
|
||||
|
||||
const defaultRoles = [
|
||||
{ name: 'admin', display_name: '管理员', permissions: '["*"]' },
|
||||
{ name: 'editor', display_name: '编辑者', permissions: '["assets:read","assets:write","assets:delete"]' },
|
||||
{ name: 'viewer', display_name: '查看者', permissions: '["assets:read"]' },
|
||||
]
|
||||
for (const role of defaultRoles) {
|
||||
const existing = db.prepare('SELECT id FROM roles WHERE name = ?').get(role.name)
|
||||
if (!existing) {
|
||||
db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)')
|
||||
.run(role.name, role.display_name, role.permissions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Excel 列名到数据库字段的映射
|
||||
const COL_MAP: Record<string, string> = {
|
||||
'序号': '_skip',
|
||||
'机房': 'room',
|
||||
'机架位置': 'rack_position',
|
||||
'节点名称': 'node_name',
|
||||
'设备型号': 'device_model',
|
||||
'序列号': 'serial_number',
|
||||
'状态': 'status',
|
||||
'HDM地址\n掩码255.255.255.128\n网关29.237.252.126/25': 'hdm_ip',
|
||||
'业务管理IP\n掩码255.255.255.128\n网关29.237.253.126/25': 'business_ip',
|
||||
'维保截止日期': 'warranty_date',
|
||||
'CPU型号': 'cpu_model',
|
||||
'CPU代数': 'cpu_generation',
|
||||
'CPU核心数': 'cpu_cores',
|
||||
'CPU数量': 'cpu_count',
|
||||
'CPU总线程数': 'cpu_threads',
|
||||
'CPU规格': 'cpu_spec',
|
||||
'内存型号': 'memory_model',
|
||||
'最大频率': 'memory_frequency',
|
||||
'单条内存容量': 'memory_unit_capacity',
|
||||
'数量': 'memory_count',
|
||||
'内存总量': 'memory_total',
|
||||
'系统盘型号': 'sys_disk_model',
|
||||
'系统盘规格': 'sys_disk_spec',
|
||||
'介质类型': 'sys_disk_type',
|
||||
'协议': 'sys_disk_protocol',
|
||||
'最大速率': 'sys_disk_speed',
|
||||
'数量.1': 'sys_disk_count',
|
||||
'数据盘型号': 'data_disk1_model',
|
||||
'数据盘规格': 'data_disk1_spec',
|
||||
'介质类型.1': 'data_disk1_type',
|
||||
'协议.1': 'data_disk1_protocol',
|
||||
'最大速率.1': 'data_disk1_speed',
|
||||
'数量.2': 'data_disk1_count',
|
||||
'数据盘总空间': 'data_disk_total_space',
|
||||
'GPU卡型号': 'gpu_model',
|
||||
'功率': 'gpu_power',
|
||||
'数量.3': 'gpu_count',
|
||||
'RAID卡型号': 'raid_model',
|
||||
'芯片型号': 'raid_spec',
|
||||
'数量.4': 'raid_count',
|
||||
'主板型号': 'board_model',
|
||||
'网卡1': 'nic1_model',
|
||||
'数量.5': 'nic1_count',
|
||||
'芯片型号.1': 'nic1_type',
|
||||
'端口类型': 'nic1_speed',
|
||||
'端口数量': '_skip',
|
||||
'端口总数': '_skip',
|
||||
'网卡2': 'nic2_model',
|
||||
'数量.6': 'nic2_count',
|
||||
'芯片型号.2': 'nic2_type',
|
||||
'端口类型.1': 'nic2_speed',
|
||||
'端口数量.1': '_skip',
|
||||
'端口总数.1': '_skip',
|
||||
'网卡3': 'nic3_model',
|
||||
'数量.7': 'nic3_count',
|
||||
'芯片型号.3': 'nic3_type',
|
||||
'端口类型.2': 'nic3_speed',
|
||||
'端口数量.2': '_skip',
|
||||
'端口总数.2': '_skip',
|
||||
'电源型号1': 'psu1_model',
|
||||
'额定功率': 'psu1_power',
|
||||
'数量.8': 'psu1_count',
|
||||
'电源型号2': 'psu2_model',
|
||||
'额定功率.1': 'psu2_power',
|
||||
'数量.9': 'psu2_count',
|
||||
'电源总功率': 'psu_total_power',
|
||||
}
|
||||
|
||||
function cleanValue(val: any): string | number | null {
|
||||
if (val === null || val === undefined || val === '' || (typeof val === 'string' && val.trim() === '')) return null
|
||||
if (typeof val === 'number') return val
|
||||
return String(val).trim()
|
||||
}
|
||||
|
||||
function importSheet(ws: any, deviceType: string, devicePurpose: string) {
|
||||
const data = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' })
|
||||
if (data.length < 2) {
|
||||
console.log(` Sheet has no data rows`)
|
||||
return 0
|
||||
}
|
||||
|
||||
const headers = data[0] as string[]
|
||||
const rows = data.slice(1)
|
||||
|
||||
// Map column index to db field
|
||||
const fieldMap: { idx: number; field: string }[] = []
|
||||
for (let i = 0; i < headers.length; i++) {
|
||||
const header = headers[i]
|
||||
const field = COL_MAP[header]
|
||||
if (field && field !== '_skip') {
|
||||
fieldMap.push({ idx: i, field })
|
||||
}
|
||||
}
|
||||
|
||||
// 按位置兜底:如果关键字段尚未映射(列名匹配失败时),按固定列索引补上
|
||||
const hasField = (f: string) => fieldMap.some(m => m.field === f)
|
||||
if (!hasField('business_ip') && headers.length > 8) {
|
||||
fieldMap.push({ idx: 8, field: 'business_ip' })
|
||||
console.log(' 警告: business_ip 未通过列名匹配,按列索引 8 兜底')
|
||||
}
|
||||
if (!hasField('hdm_ip') && headers.length > 7) {
|
||||
fieldMap.push({ idx: 7, field: 'hdm_ip' })
|
||||
console.log(' 警告: hdm_ip 未通过列名匹配,按列索引 7 兜底')
|
||||
}
|
||||
|
||||
const insertFields = ['device_type', 'device_purpose', ...fieldMap.map(f => f.field)]
|
||||
const placeholders = insertFields.map(() => '?').join(',')
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO assets (${insertFields.join(',')}, raw_data)
|
||||
VALUES (${placeholders}, ?)
|
||||
`)
|
||||
|
||||
let count = 0
|
||||
const importTime = new Date().toISOString()
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
for (const row of rows) {
|
||||
const r = row as any[]
|
||||
if (!r || r.length === 0 || !r[0]) continue
|
||||
|
||||
const values: any[] = [deviceType, devicePurpose]
|
||||
for (const fm of fieldMap) {
|
||||
values.push(cleanValue(r[fm.idx]))
|
||||
}
|
||||
values.push(JSON.stringify({ imported_at: importTime, raw_row: r }))
|
||||
|
||||
stmt.run(...values)
|
||||
count++
|
||||
}
|
||||
})
|
||||
|
||||
transaction()
|
||||
return count
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const excelPath = process.argv[2] || path.join(__dirname, '..', 'templates-docs', '服务器信息-issue.xlsx')
|
||||
|
||||
if (!fs.existsSync(excelPath)) {
|
||||
console.error(`Excel file not found: ${excelPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('Initializing database...')
|
||||
initDb()
|
||||
|
||||
console.log(`Reading Excel: ${excelPath}`)
|
||||
const wb = XLSX.readFile(excelPath)
|
||||
|
||||
console.log('Importing GPU servers...')
|
||||
const gpuSheet = wb.Sheets['GPU服务器']
|
||||
const gpuCount = importSheet(gpuSheet, 'GPU服务器', 'GPU计算')
|
||||
console.log(` Imported ${gpuCount} GPU servers`)
|
||||
|
||||
console.log('Importing storage servers...')
|
||||
const storSheet = wb.Sheets['存储服务器']
|
||||
const storCount = importSheet(storSheet, '存储服务器', '存储')
|
||||
console.log(` Imported ${storCount} storage servers`)
|
||||
|
||||
console.log(`\nTotal: ${gpuCount + storCount} servers imported`)
|
||||
db.close()
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { initDatabase } from '../src/lib/db-schema'
|
||||
|
||||
console.log('初始化数据库...')
|
||||
initDatabase()
|
||||
console.log('数据库初始化完成!')
|
||||
console.log('默认管理员账号: admin / admin123')
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import AssetForm from '@/components/assets/AssetForm'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import type { Asset } from '@/types'
|
||||
|
||||
export default function EditAssetPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const [asset, setAsset] = useState<Asset | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const res = await fetch(`/api/assets/${params.id}`)
|
||||
if (res.ok) { const data = await res.json(); setAsset(data.asset) }
|
||||
else router.push('/assets')
|
||||
}
|
||||
load().finally(() => setLoading(false))
|
||||
}, [params.id, router])
|
||||
|
||||
async function handleSubmit(data: Record<string, unknown>) {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch(`/api/assets/${params.id}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
|
||||
})
|
||||
if (!res.ok) { const d = await res.json(); throw new Error(d.error || '保存失败') }
|
||||
router.push(`/assets/${params.id}`)
|
||||
router.refresh()
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
if (loading) return <div className="py-20 text-center text-slate-500 dark:text-slate-400">加载中...</div>
|
||||
if (!asset) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/assets/${asset.id}`} className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"><ArrowLeft size={20} /></Link>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">编辑资产</h1>
|
||||
</div>
|
||||
<AssetForm asset={asset} onSubmit={handleSubmit} loading={saving} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import AssetDetail from '@/components/assets/AssetDetail'
|
||||
import AssetTicketHistory from '@/components/assets/AssetTicketHistory'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import { ArrowLeft, Edit, Trash2 } from 'lucide-react'
|
||||
import type { Asset } from '@/types'
|
||||
|
||||
export default function AssetDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const [asset, setAsset] = useState<Asset | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showDelete, setShowDelete] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const res = await fetch(`/api/assets/${params.id}`)
|
||||
if (res.ok) { const data = await res.json(); setAsset(data.asset) }
|
||||
else router.push('/assets')
|
||||
}
|
||||
load().finally(() => setLoading(false))
|
||||
}, [params.id, router])
|
||||
|
||||
async function handleDelete() {
|
||||
setDeleting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/assets/${params.id}`, { method: 'DELETE' })
|
||||
if (res.ok) router.push('/assets')
|
||||
} finally { setDeleting(false) }
|
||||
}
|
||||
|
||||
if (loading) return <div className="py-20 text-center text-slate-500 dark:text-slate-400">加载中...</div>
|
||||
if (!asset) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/assets" className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"><ArrowLeft size={20} /></Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{asset.node_name || asset.serial_number || `设备 #${asset.id}`}</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{asset.device_type} - {asset.manufacturer} {asset.device_model}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/assets/${asset.id}/edit`}><Button variant="secondary" size="sm"><Edit size={16} />编辑</Button></Link>
|
||||
<Button variant="danger" size="sm" onClick={() => setShowDelete(true)}><Trash2 size={16} />删除</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AssetDetail asset={asset} />
|
||||
<AssetTicketHistory deviceIp={asset.business_ip} deviceSn={asset.serial_number} />
|
||||
|
||||
<Modal open={showDelete} onClose={() => setShowDelete(false)} title="确认删除">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">确定要删除这条资产记录吗?此操作不可恢复。</p>
|
||||
<div className="flex justify-end gap-3 mt-4">
|
||||
<Button variant="ghost" onClick={() => setShowDelete(false)}>取消</Button>
|
||||
<Button variant="danger" loading={deleting} onClick={handleDelete}>删除</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
'use client'
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, Download } from 'lucide-react'
|
||||
import AssetList from '@/components/assets/AssetList'
|
||||
import AdvancedSearch, { type Filter } from '@/components/assets/AdvancedSearch'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import type { Asset, PaginatedResult } from '@/types'
|
||||
|
||||
export default function AdvancedSearchPage() {
|
||||
const [result, setResult] = useState<PaginatedResult<Asset>>({ data: [], total: 0, page: 1, pageSize: 20, totalPages: 0 })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
const [sortKey, setSortKey] = useState('id')
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
|
||||
const [page, setPage] = useState(1)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [searched, setSearched] = useState(false)
|
||||
const [showExportModal, setShowExportModal] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (filters.length === 0 && !searched) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page), pageSize: '20', sortKey, sortOrder,
|
||||
...(filters.length > 0 && { filters: JSON.stringify(filters) }),
|
||||
})
|
||||
const res = await fetch(`/api/assets?${params}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setResult(data)
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set<number>()
|
||||
for (const id of prev) {
|
||||
if (data.data.some((a: Asset) => a.id === id)) next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
} finally { setLoading(false) }
|
||||
}, [page, sortKey, sortOrder, filters, searched])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
function handleSort(key: string) {
|
||||
if (sortKey === key) setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')
|
||||
else { setSortKey(key); setSortOrder('asc') }
|
||||
}
|
||||
|
||||
function handleToggleSelect(id: number) {
|
||||
setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next })
|
||||
}
|
||||
|
||||
function handleToggleAll() {
|
||||
if (result.data.every(a => selectedIds.has(a.id))) setSelectedIds(new Set())
|
||||
else setSelectedIds(new Set(result.data.map(a => a.id)))
|
||||
}
|
||||
|
||||
function handleSearch(f: Filter[]) {
|
||||
setFilters(f)
|
||||
setPage(1)
|
||||
setSearched(true)
|
||||
}
|
||||
|
||||
function handleExportConfirm() {
|
||||
setShowExportModal(false)
|
||||
const params = new URLSearchParams({ filters: JSON.stringify(filters) })
|
||||
window.open(`/api/assets/export?${params}`, '_blank')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/assets" className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
|
||||
<ArrowLeft size={20} />
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">高级查询</h1>
|
||||
{searched && <span className="text-sm text-slate-500 dark:text-slate-400">共 {result.total} 条</span>}
|
||||
{searched && result.total > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowExportModal(true)} className="ml-auto">
|
||||
<Download size={16} />导出
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AdvancedSearch onSearch={handleSearch} alwaysOpen />
|
||||
|
||||
{loading ? (
|
||||
<div className="py-20 text-center text-slate-500 dark:text-slate-400">加载中...</div>
|
||||
) : searched ? (
|
||||
result.data.length > 0 ? (
|
||||
<AssetList result={result} selectedIds={selectedIds} onToggleSelect={handleToggleSelect} onToggleAll={handleToggleAll} onDelete={() => {}} onSort={handleSort} sortKey={sortKey} sortOrder={sortOrder} onPageChange={setPage} />
|
||||
) : (
|
||||
<div className="py-20 text-center text-slate-500 dark:text-slate-400">未找到匹配的资产记录</div>
|
||||
)
|
||||
) : (
|
||||
<div className="py-20 text-center text-slate-500 dark:text-slate-400">设置查询条件后点击"查询"按钮</div>
|
||||
)}
|
||||
|
||||
<Modal open={showExportModal} onClose={() => setShowExportModal(false)} title="导出资产数据">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
将导出 <span className="font-medium">{result.total}</span> 条匹配的资产记录。
|
||||
</p>
|
||||
{filters.length > 0 && (
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||
<p className="mb-1">查询条件:</p>
|
||||
{filters.map((f, i) => (
|
||||
<p key={i}>• {f.field} {f.op} {f.value}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-4">
|
||||
<Button variant="ghost" onClick={() => setShowExportModal(false)}>取消</Button>
|
||||
<Button onClick={handleExportConfirm}><Download size={14} />确认导出</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
'use client'
|
||||
import { useState, useEffect, Suspense } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import BatchEditForm from '@/components/assets/BatchEditForm'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import type { Asset } from '@/types'
|
||||
|
||||
function BatchEditContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const idsParam = searchParams.get('ids') || ''
|
||||
const ids = idsParam.split(',').map(Number).filter(Boolean)
|
||||
const [assets, setAssets] = useState<Asset[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const results: Asset[] = []
|
||||
for (const id of ids) {
|
||||
const res = await fetch(`/api/assets/${id}`)
|
||||
if (res.ok) { const data = await res.json(); results.push(data.asset) }
|
||||
}
|
||||
setAssets(results)
|
||||
}
|
||||
if (ids.length > 0) load().finally(() => setLoading(false))
|
||||
else setLoading(false)
|
||||
}, [idsParam])
|
||||
|
||||
async function handleSubmit(fields: Record<string, unknown>) {
|
||||
if (ids.length === 0) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch('/api/assets/batch', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids, fields }),
|
||||
})
|
||||
if (!res.ok) { const d = await res.json(); throw new Error(d.error || '批量更新失败') }
|
||||
setSuccess(true)
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
if (ids.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-slate-500 dark:text-slate-400 mb-4">请先在资产列表中选择设备</p>
|
||||
<Link href="/assets"><Button variant="secondary">返回列表</Button></Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) return <div className="py-20 text-center text-slate-500 dark:text-slate-400">加载中...</div>
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<div className="text-green-500 text-lg font-semibold mb-2">批量更新成功</div>
|
||||
<p className="text-slate-500 dark:text-slate-400 mb-4">已更新 {assets.length} 台设备</p>
|
||||
<Link href="/assets"><Button>返回列表</Button></Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/assets" className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"><ArrowLeft size={20} /></Link>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">批量编辑</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-700 p-4">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mb-3">已选择 {assets.length} 台设备:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{assets.map(a => (
|
||||
<Badge key={a.id} color="blue">{a.node_name || a.serial_number || `#${a.id}`}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BatchEditForm onSubmit={handleSubmit} loading={saving} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BatchEditPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="py-20 text-center text-slate-500 dark:text-slate-400">加载中...</div>}>
|
||||
<BatchEditContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
'use client'
|
||||
import Link from 'next/link'
|
||||
import AssetImport from '@/components/assets/AssetImport'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
|
||||
export default function ImportPage() {
|
||||
async function handleImport(file: File) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const res = await fetch('/api/assets/import', { method: 'POST', body: formData })
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || '导入失败')
|
||||
return data as { created: number; updated: number; errors: string[] }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/assets" className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"><ArrowLeft size={20} /></Link>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">导入资产</h1>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-700 p-6">
|
||||
<AssetImport onImport={handleImport} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import AssetForm from '@/components/assets/AssetForm'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
|
||||
export default function NewAssetPage() {
|
||||
const router = useRouter()
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function handleSubmit(data: Record<string, unknown>) {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch('/api/assets', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
|
||||
})
|
||||
if (!res.ok) { const d = await res.json(); throw new Error(d.error || '创建失败') }
|
||||
const result = await res.json()
|
||||
router.push(`/assets/${result.asset.id}`)
|
||||
router.refresh()
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/assets" className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"><ArrowLeft size={20} /></Link>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">新增资产</h1>
|
||||
</div>
|
||||
<AssetForm onSubmit={handleSubmit} loading={saving} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,583 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import Pagination from '@/components/ui/Pagination'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
|
||||
import Button from '@/components/ui/Button'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import type { Asset, PaginatedResult } from '@/types'
|
||||
import {
|
||||
Plus, Upload, Download, Search, ChevronUp, ChevronDown, ChevronsUpDown,
|
||||
Eye, Edit, Trash2, Filter, ArrowUpDown, Check,
|
||||
} from 'lucide-react'
|
||||
|
||||
const statusColor: Record<string, 'blue' | 'green' | 'yellow' | 'red' | 'gray'> = {
|
||||
'腾讯使用': 'green', '图灵使用': 'blue', '闲置': 'yellow', '备用': 'yellow', '维修中': 'red', '已下线': 'gray',
|
||||
}
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'device_type', label: '设备类型', sortable: true },
|
||||
{ key: 'node_name', label: '节点名称', sortable: true },
|
||||
{ key: 'business_ip', label: '业务IP', sortable: true },
|
||||
{ key: 'hdm_ip', label: 'HDM IP', sortable: true },
|
||||
{ key: 'manufacturer', label: '厂商', sortable: true },
|
||||
{ key: 'device_model', label: '设备型号', sortable: true },
|
||||
{ key: 'serial_number', label: '序列号', sortable: true },
|
||||
{ key: 'status', label: '状态', sortable: true },
|
||||
]
|
||||
|
||||
export default function AssetsPage() {
|
||||
const router = useRouter()
|
||||
const [result, setResult] = useState<PaginatedResult<Asset>>({ data: [], total: 0, page: 1, pageSize: 20, totalPages: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [sortKey, setSortKey] = useState('device_type')
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [deleteTarget, setDeleteTarget] = useState<number | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false)
|
||||
|
||||
// 列头筛选相关状态
|
||||
const [tableMode, setTableMode] = useState<'sort' | 'filter'>('sort')
|
||||
const [columnFilterValues, setColumnFilterValues] = useState<Record<string, string[]>>({})
|
||||
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null)
|
||||
const [filterOptions, setFilterOptions] = useState<Record<string, string[]>>({})
|
||||
const [filterLoading, setFilterLoading] = useState<Record<string, boolean>>({})
|
||||
const [filterSearch, setFilterSearch] = useState<Record<string, string>>({})
|
||||
const filterDropRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 列宽拖拽调整
|
||||
const [colWidths, setColWidths] = useState<Record<string, number>>({})
|
||||
const [resizingCol, setResizingCol] = useState<string | null>(null)
|
||||
const resizeRef = useRef<{ col: string; startX: number; startWidth: number } | null>(null)
|
||||
const [hasResized, setHasResized] = useState(false)
|
||||
|
||||
function snapAllColumns() {
|
||||
if (hasResized) return
|
||||
setHasResized(true)
|
||||
const widths: Record<string, number> = {}
|
||||
document.querySelectorAll('th[data-col-key]').forEach(th => {
|
||||
const key = th.getAttribute('data-col-key')
|
||||
if (key) widths[key] = th.getBoundingClientRect().width
|
||||
})
|
||||
setColWidths(widths)
|
||||
}
|
||||
|
||||
function onResizeStart(colKey: string, e: React.MouseEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
snapAllColumns()
|
||||
const th = (e.target as HTMLElement).closest('th')
|
||||
const startWidth = th?.getBoundingClientRect().width || 150
|
||||
resizeRef.current = { col: colKey, startX: e.clientX, startWidth }
|
||||
setResizingCol(colKey)
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 'col-resize'
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizingCol) return
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!resizeRef.current) return
|
||||
const { col, startX, startWidth } = resizeRef.current
|
||||
setColWidths(prev => ({ ...prev, [col]: Math.max(60, startWidth + (e.clientX - startX)) }))
|
||||
}
|
||||
function onMouseUp() {
|
||||
setResizingCol(null)
|
||||
resizeRef.current = null
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
}, [resizingCol])
|
||||
|
||||
// 关闭筛选下拉
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (filterDropRef.current && !filterDropRef.current.contains(e.target as Node)) {
|
||||
setOpenFilterColumn(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [])
|
||||
|
||||
// 加载某列的唯一值
|
||||
const loadColumnOptions = useCallback((field: string) => {
|
||||
if (filterOptions[field]) return
|
||||
setFilterLoading(prev => ({ ...prev, [field]: true }))
|
||||
fetch(`/api/assets/field-values?field=${field}&q=`)
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.values) setFilterOptions(prev => ({ ...prev, [field]: d.values }))
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setFilterLoading(prev => ({ ...prev, [field]: false })))
|
||||
}, [filterOptions])
|
||||
|
||||
// 列头点击:排序或打开筛选
|
||||
function handleColumnClick(key: string, sortable: boolean) {
|
||||
if (tableMode === 'sort') {
|
||||
if (!sortable) return
|
||||
if (sortKey === key) setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')
|
||||
else { setSortKey(key); setSortOrder('asc') }
|
||||
setPage(1)
|
||||
} else {
|
||||
if (openFilterColumn === key) {
|
||||
setOpenFilterColumn(null)
|
||||
} else {
|
||||
setOpenFilterColumn(key)
|
||||
loadColumnOptions(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 切换某列的筛选值
|
||||
function toggleFilterValue(column: string, value: string) {
|
||||
setColumnFilterValues(prev => {
|
||||
const current = prev[column] || []
|
||||
const next = current.includes(value)
|
||||
? current.filter(v => v !== value)
|
||||
: [...current, value]
|
||||
const updated = { ...prev, [column]: next }
|
||||
if (next.length === 0) delete updated[column]
|
||||
return updated
|
||||
})
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
// 清空某列筛选
|
||||
function clearColumnFilter(column: string) {
|
||||
setColumnFilterValues(prev => {
|
||||
const next = { ...prev }
|
||||
delete next[column]
|
||||
return next
|
||||
})
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
// 排序图标(可点击触发排序/筛选)
|
||||
function SortIcon({ colKey, sortable }: { colKey: string; sortable: boolean }) {
|
||||
if (!sortable) return null
|
||||
const icon = tableMode === 'filter'
|
||||
? <Filter size={12} />
|
||||
: sortKey !== colKey
|
||||
? <ChevronsUpDown size={13} />
|
||||
: sortOrder === 'asc'
|
||||
? <ChevronUp size={13} className="text-blue-500" />
|
||||
: <ChevronDown size={13} className="text-blue-500" />
|
||||
return (
|
||||
<span
|
||||
onClick={(e) => { e.stopPropagation(); handleColumnClick(colKey, sortable) }}
|
||||
className={`ml-1 flex-shrink-0 rounded transition-colors cursor-pointer ${tableMode === 'sort' && sortKey === colKey ? 'text-blue-500' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'}`}
|
||||
title={tableMode === 'sort' ? '点击排序' : '点击筛选'}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// 列头是否显示筛选图标(已选值时)
|
||||
function FilterIndicator({ colKey }: { colKey: string }) {
|
||||
const vals = columnFilterValues[colKey]
|
||||
if (!vals || vals.length === 0) return null
|
||||
return (
|
||||
<span className="ml-0.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-blue-600 text-white text-[10px] font-bold">
|
||||
{vals.length}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// 筛选下拉内容
|
||||
function FilterDropdown({ column }: { column: string }) {
|
||||
const options = filterOptions[column] || []
|
||||
const selected = columnFilterValues[column] || []
|
||||
const searchVal = filterSearch[column] || ''
|
||||
const loading = filterLoading[column]
|
||||
const filtered = searchVal
|
||||
? options.filter(o => o.toLowerCase().includes(searchVal.toLowerCase()))
|
||||
: options
|
||||
const hasMore = options.length >= 50
|
||||
|
||||
return (
|
||||
<div className="absolute top-full left-0 z-50 mt-1 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shadow-xl w-fit min-w-48 max-w-80">
|
||||
{/* 搜索框 */}
|
||||
<div className="p-2 border-b border-slate-100 dark:border-slate-700">
|
||||
<input
|
||||
type="text"
|
||||
value={searchVal}
|
||||
onChange={e => setFilterSearch(prev => ({ ...prev, [column]: e.target.value }))}
|
||||
placeholder="搜索..."
|
||||
className="w-full px-2 py-1 rounded border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-xs text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 选项列表 */}
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="px-3 py-4 text-xs text-slate-400 text-center">加载中...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="px-3 py-4 text-xs text-slate-400 text-center">无匹配结果</div>
|
||||
) : (
|
||||
filtered.map(opt => {
|
||||
const checked = selected.includes(opt)
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => toggleFilterValue(column, opt)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors ${
|
||||
checked
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
|
||||
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
|
||||
checked
|
||||
? 'bg-blue-600 border-blue-600'
|
||||
: 'border-slate-300 dark:border-slate-600'
|
||||
}`}>
|
||||
{checked && <Check size={10} className="text-white" />}
|
||||
</span>
|
||||
<span className="truncate">{opt || '(空)'}</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部操作 */}
|
||||
<div className="px-2 py-1.5 border-t border-slate-100 dark:border-slate-700 flex items-center justify-between">
|
||||
<span className="text-[10px] text-slate-400">
|
||||
{selected.length > 0 ? `已选 ${selected.length} 项${hasMore ? '(最多50项)' : ''}` : `${options.length} 个值`}
|
||||
</span>
|
||||
{selected.length > 0 && (
|
||||
<button
|
||||
onClick={() => { clearColumnFilter(column); setOpenFilterColumn(null) }}
|
||||
className="text-[10px] text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 切换模式:清除列筛选
|
||||
function switchMode(mode: 'sort' | 'filter') {
|
||||
setTableMode(mode)
|
||||
setOpenFilterColumn(null)
|
||||
if (mode === 'sort') {
|
||||
// 排序模式不清除筛选状态,保留 columnFilterValues
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAssets = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page), pageSize: String(pageSize), sortKey, sortOrder,
|
||||
})
|
||||
if (search) params.set('search', search)
|
||||
for (const [field, values] of Object.entries(columnFilterValues)) {
|
||||
for (const v of values) {
|
||||
params.append(`filter_${field}`, v)
|
||||
}
|
||||
}
|
||||
const res = await fetch(`/api/assets?${params}`)
|
||||
if (res.status === 401) { router.push('/login'); return }
|
||||
const data = await res.json()
|
||||
setResult(data)
|
||||
} catch { setResult({ data: [], total: 0, page: 1, pageSize: 20, totalPages: 0 }) }
|
||||
finally { setLoading(false) }
|
||||
}, [page, pageSize, sortKey, sortOrder, search, columnFilterValues, router])
|
||||
|
||||
useEffect(() => { fetchAssets() }, [fetchAssets])
|
||||
|
||||
function handleSearch(q: string) {
|
||||
setSearch(q); setPage(1)
|
||||
}
|
||||
|
||||
function handlePageChange(p: number) { setPage(p) }
|
||||
function handlePageSizeChange(s: number) { setPageSize(s); setPage(1) }
|
||||
|
||||
function toggleSelect(id: number) {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
const allIds = result.data.map(a => a.id)
|
||||
if (selectedIds.size === allIds.length) setSelectedIds(new Set())
|
||||
else setSelectedIds(new Set(allIds))
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
setDeleting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/assets/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) { setDeleteTarget(null); fetchAssets() }
|
||||
} finally { setDeleting(false) }
|
||||
}
|
||||
|
||||
function handleExportConfirm() {
|
||||
const params = new URLSearchParams()
|
||||
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)
|
||||
window.open(`/api/assets/export?${params}`, '_blank')
|
||||
}
|
||||
|
||||
const { data, total, totalPages } = result
|
||||
const allSelected = data.length > 0 && data.every(a => selectedIds.has(a.id))
|
||||
|
||||
// 是否有任何列筛选激活
|
||||
const hasActiveColumnFilters = Object.values(columnFilterValues).some(v => v.length > 0)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<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">
|
||||
共 <span className="font-medium">{total}</span> 台设备
|
||||
{selectedIds.size > 0 && <span className="ml-2">,已选中 <span className="font-medium text-blue-600">{selectedIds.size}</span> 台</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedIds.size > 0 && (
|
||||
<Link href={`/assets/batch-edit?ids=${[...selectedIds].join(',')}`}>
|
||||
<Button variant="secondary" size="sm">批量编辑 {selectedIds.size} 台</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/assets/import"><Button variant="secondary" size="sm"><Upload size={14} />导入</Button></Link>
|
||||
<Link href="/assets/template"><Button variant="secondary" size="sm"><Download 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索 + 高级筛选 */}
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch(search)}
|
||||
placeholder="快速搜索..."
|
||||
className="w-full pl-9 pr-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => handleSearch(search)}>搜索</Button>
|
||||
</div>
|
||||
<Link href="/assets/advanced-search">
|
||||
<Button variant="secondary" size="sm">
|
||||
<Search size={14} />高级查询
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 活跃列筛选标签 */}
|
||||
{hasActiveColumnFilters && (
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">列筛选:</span>
|
||||
{Object.entries(columnFilterValues).filter(([, v]) => v.length > 0).map(([col, vals]) => {
|
||||
const colDef = COLUMNS.find(c => c.key === col)
|
||||
return (
|
||||
<span key={col} className="inline-flex items-center gap-1 px-2 py-1 rounded bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 text-xs">
|
||||
<span className="font-medium">{colDef?.label || col}</span>
|
||||
<span>: {vals.join(', ')}</span>
|
||||
<button onClick={() => clearColumnFilter(col)} className="ml-0.5 hover:text-blue-900 dark:hover:text-blue-200">×</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
<button onClick={() => { setColumnFilterValues({}); setPage(1) }}
|
||||
className="text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300">清除全部</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 工具栏:每页行数 */}
|
||||
<div className="flex items-center justify-end mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">每页</span>
|
||||
<div className="flex rounded-lg border border-slate-300 dark:border-slate-600 overflow-hidden">
|
||||
{[20, 50, 100].map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => handlePageSizeChange(s)}
|
||||
className={`px-3 py-1 text-xs font-medium transition-colors ${
|
||||
pageSize === s
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">行</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-200 dark:border-slate-700">
|
||||
<table className="w-full text-sm" style={{ tableLayout: hasResized ? 'fixed' : 'auto' }}>
|
||||
<thead className="bg-slate-50 dark:bg-slate-800">
|
||||
<tr>
|
||||
{/* 模式切换 + 全选 */}
|
||||
<th className="px-4 py-3 w-10 text-center">
|
||||
<input type="checkbox" checked={allSelected} onChange={toggleAll}
|
||||
className="rounded border-slate-300 dark:border-slate-600" />
|
||||
</th>
|
||||
{COLUMNS.map(col => (
|
||||
<th key={col.key} data-col-key={col.key} className="px-4 py-3 text-center relative" style={colWidths[col.key] ? { width: colWidths[col.key] } : undefined}>
|
||||
<div className="relative flex flex-col">
|
||||
<div className="flex items-center justify-center gap-0.5">
|
||||
{/* 左侧:模式切换按钮 */}
|
||||
<button
|
||||
onClick={() => switchMode(tableMode === 'sort' ? 'filter' : 'sort')}
|
||||
className={`p-0.5 rounded transition-colors flex-shrink-0 ${
|
||||
tableMode === 'sort' && col.sortable
|
||||
? 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'
|
||||
: tableMode === 'filter'
|
||||
? 'text-blue-500 bg-blue-50 dark:bg-blue-900/30'
|
||||
: 'text-slate-300'
|
||||
}`}
|
||||
title={tableMode === 'sort' ? '切换到筛选模式' : '切换到排序模式'}
|
||||
>
|
||||
<ArrowUpDown size={12} />
|
||||
</button>
|
||||
{/* 中间:列标题 */}
|
||||
<button
|
||||
className={`font-medium min-w-0 break-all ${
|
||||
col.sortable
|
||||
? 'cursor-pointer hover:text-slate-900 dark:hover:text-slate-100'
|
||||
: 'cursor-default'
|
||||
} ${
|
||||
openFilterColumn === col.key
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: tableMode === 'filter'
|
||||
? 'text-blue-600 dark:text-blue-400 cursor-pointer'
|
||||
: 'text-slate-600 dark:text-slate-300'
|
||||
}`}
|
||||
onClick={() => handleColumnClick(col.key, col.sortable)}
|
||||
>
|
||||
{col.label}
|
||||
</button>
|
||||
{/* 右侧:排序/筛选图标 */}
|
||||
<SortIcon colKey={col.key} sortable={col.sortable} />
|
||||
<FilterIndicator colKey={col.key} />
|
||||
</div>
|
||||
{openFilterColumn === col.key && (
|
||||
<div ref={filterDropRef}>
|
||||
<FilterDropdown column={col.key} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 右侧:拖拽手柄(宽点击区 + 细可见线) */}
|
||||
<div
|
||||
className="absolute top-1/2 right-0 -translate-y-1/2 h-4 w-2 cursor-col-resize z-10 flex items-center justify-center group"
|
||||
onMouseDown={(e) => onResizeStart(col.key, e)}
|
||||
>
|
||||
<div className="w-px h-full bg-slate-300 dark:bg-slate-600 group-hover:bg-blue-400 transition-colors" />
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
<th className="px-4 py-3 text-center font-medium text-slate-600 dark:text-slate-300" style={{ width: 120 }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{loading ? (
|
||||
<tr><td colSpan={COLUMNS.length + 2} className="px-4 py-12 text-center text-slate-500 dark:text-slate-400">加载中...</td></tr>
|
||||
) : data.length === 0 ? (
|
||||
<tr><td colSpan={COLUMNS.length + 2} className="px-4 py-12 text-center text-slate-500 dark:text-slate-400">暂无数据</td></tr>
|
||||
) : data.map(row => (
|
||||
<tr key={row.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||
<td className="px-4 py-3 text-center">
|
||||
<input type="checkbox" checked={selectedIds.has(row.id)} onChange={() => toggleSelect(row.id)}
|
||||
className="rounded border-slate-300 dark:border-slate-600" />
|
||||
</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">
|
||||
<Link href={`/assets/${row.id}`} className="text-blue-600 dark:text-blue-400 hover:underline">{row.node_name || '-'}</Link>
|
||||
</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.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.serial_number || '-'}</td>
|
||||
<td className="px-4 py-3 text-center"><Badge color={statusColor[String(row.status ?? '')] || 'gray'}>{row.status ?? '-'}</Badge></td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<Link href={`/assets/${row.id}`}
|
||||
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} />
|
||||
</Link>
|
||||
<Link href={`/assets/${row.id}/edit`}
|
||||
className="p-1.5 rounded-lg text-slate-500 hover:text-green-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
|
||||
<Edit size={16} />
|
||||
</Link>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={handlePageChange} />
|
||||
</div>
|
||||
|
||||
{/* 删除确认 */}
|
||||
<Modal open={deleteTarget !== null} onClose={() => setDeleteTarget(null)} title="确认删除">
|
||||
<p className="text-slate-700 dark:text-slate-300">确定要删除这台设备吗?此操作不可撤销。</p>
|
||||
<div className="flex justify-end gap-3 mt-4">
|
||||
<Button variant="secondary" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||
<Button variant="danger" loading={deleting} onClick={() => deleteTarget && handleDelete(deleteTarget)}>确认删除</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 导出确认 */}
|
||||
<Modal open={exportModalOpen} onClose={() => setExportModalOpen(false)} title="确认导出">
|
||||
<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">
|
||||
<Button variant="secondary" onClick={() => setExportModalOpen(false)}>取消</Button>
|
||||
<Button onClick={handleExportConfirm}><Download size={14} />确认导出</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import StatsOverview from '@/components/dashboard/StatsOverview'
|
||||
import StatusChart from '@/components/dashboard/StatusChart'
|
||||
import TypeChart from '@/components/dashboard/TypeChart'
|
||||
import RoomChart from '@/components/dashboard/RoomChart'
|
||||
|
||||
interface StatsData {
|
||||
total: number
|
||||
byStatus: Array<{ status: string; count: number }>
|
||||
byDeviceType: Array<{ device_type: string; count: number }>
|
||||
byManufacturer: Array<{ manufacturer: string; count: number }>
|
||||
byRoom: Array<{ room: string; count: number }>
|
||||
warrantySoon: number
|
||||
warrantyExpired: number
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<StatsData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/stats')
|
||||
.then(r => r.json())
|
||||
.then(data => { if (data.total !== undefined) setStats(data) })
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) return <div className="text-center py-12 text-slate-500 dark:text-slate-400">加载中...</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">仪表盘</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1">资产统计概览</p>
|
||||
</div>
|
||||
|
||||
<StatsOverview stats={stats} />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<StatusChart data={stats?.byStatus || []} />
|
||||
<TypeChart data={stats?.byDeviceType || []} />
|
||||
</div>
|
||||
|
||||
<RoomChart data={stats?.byRoom || []} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { cookies, headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import db from '@/lib/db'
|
||||
import { verifyJwt } from '@/lib/auth'
|
||||
import AppShell from '@/components/layout/AppShell'
|
||||
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
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
|
||||
if (!token) redirect(loginUrl)
|
||||
const payload = verifyJwt(token)
|
||||
if (!payload) redirect(loginUrl)
|
||||
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
|
||||
if (!user) redirect(loginUrl)
|
||||
return <AppShell user={user}>{children}</AppShell>
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
'use client'
|
||||
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 { Plus, Trash2, Copy, CheckCircle } from 'lucide-react'
|
||||
|
||||
interface ApiKeyItem {
|
||||
id: number; name: string; permissions: string; last_used_at: string | null;
|
||||
expires_at: string | null; is_active: number; created_at: string
|
||||
}
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
const [keys, setKeys] = useState<ApiKeyItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [newKey, setNewKey] = useState<string | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<ApiKeyItem | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
async function fetchKeys() {
|
||||
const res = await fetch('/api/api-keys')
|
||||
if (res.ok) { const data = await res.json(); setKeys(data.keys) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetchKeys().finally(() => setLoading(false)) }, [])
|
||||
|
||||
async function handleCreate() {
|
||||
if (!name.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch('/api/api-keys', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name.trim() }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setNewKey(data.key)
|
||||
setCreateOpen(false)
|
||||
setName('')
|
||||
fetchKeys()
|
||||
}
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget) return
|
||||
const res = await fetch(`/api/api-keys/${deleteTarget.id}`, { method: 'DELETE' })
|
||||
if (res.ok) { setDeleteTarget(null); fetchKeys() }
|
||||
}
|
||||
|
||||
function copyKey() {
|
||||
if (newKey) {
|
||||
navigator.clipboard.writeText(newKey)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<ApiKeyItem>[] = [
|
||||
{ key: 'name', title: '名称', render: (r) => <span className="text-slate-900 dark:text-white font-medium">{r.name}</span> },
|
||||
{ key: 'permissions', title: '权限', render: (r) => {
|
||||
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>
|
||||
}},
|
||||
{ key: 'is_active', title: '状态', render: (r) => <Badge color={r.is_active ? 'green' : 'red'}>{r.is_active ? '启用' : '禁用'}</Badge> },
|
||||
{ key: 'last_used_at', title: '最后使用', render: (r) => r.last_used_at || '从未使用' },
|
||||
{ key: 'expires_at', title: '过期时间', render: (r) => r.expires_at || '永不过期' },
|
||||
{ key: 'created_at', title: '创建时间' },
|
||||
{ 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>
|
||||
)},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">API Key 管理</h1>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}><Plus size={16} />创建 Key</Button>
|
||||
</div>
|
||||
|
||||
{newKey && (
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<p className="text-sm font-medium text-green-800 dark:text-green-300 mb-2">API Key 已创建(仅显示一次,请妥善保存)</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 p-2 bg-white dark:bg-slate-800 rounded border border-green-200 dark:border-green-700 text-sm font-mono break-all">{newKey}</code>
|
||||
<Button variant="ghost" size="sm" onClick={copyKey}>{copied ? <CheckCircle size={16} className="text-green-500" /> : <Copy size={16} />}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? <div className="py-20 text-center text-slate-500">加载中...</div> : <Table columns={columns} data={keys} rowKey={r => r.id} />}
|
||||
|
||||
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="创建 API Key" footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setCreateOpen(false)}>取消</Button>
|
||||
<Button onClick={handleCreate} loading={saving}>创建</Button>
|
||||
</>
|
||||
}>
|
||||
<Input label="名称" value={name} onChange={e => setName(e.target.value)} placeholder="例如:监控系统" />
|
||||
</Modal>
|
||||
|
||||
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除">
|
||||
<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="danger" onClick={handleDelete}>删除</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import Link from 'next/link'
|
||||
import Card from '@/components/ui/Card'
|
||||
import { Users, Shield, KeyRound } from 'lucide-react'
|
||||
|
||||
const items = [
|
||||
{ href: '/settings/users', label: '用户管理', desc: '管理系统用户和访问权限', icon: Users },
|
||||
{ href: '/settings/roles', label: '角色权限', desc: '配置角色和权限策略', icon: Shield },
|
||||
{ href: '/settings/api-keys', label: 'API Key', desc: '管理 API 访问密钥', icon: KeyRound },
|
||||
]
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">系统设置</h1>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{items.map(item => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Link key={item.href} href={item.href}>
|
||||
<Card className="hover:border-blue-500/50 transition-colors cursor-pointer">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20">
|
||||
<Icon size={24} className="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">{item.label}</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">{item.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
'use client'
|
||||
import Card from '@/components/ui/Card'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
|
||||
const permissionLabels: Record<string, string> = {
|
||||
'*': '所有权限',
|
||||
'assets:read': '资产读取',
|
||||
'assets:write': '资产写入',
|
||||
'assets:delete': '资产删除',
|
||||
'users:read': '用户读取',
|
||||
'users:write': '用户管理',
|
||||
'api-keys:read': 'API Key 读取',
|
||||
'api-keys:write': 'API Key 管理',
|
||||
}
|
||||
|
||||
const defaultRoles = [
|
||||
{ name: 'admin', display_name: '管理员', permissions: ['*'], color: 'blue' as const },
|
||||
{ name: 'editor', display_name: '编辑者', permissions: ['assets:read', 'assets:write', 'assets:delete'], color: 'green' as const },
|
||||
{ name: 'viewer', display_name: '查看者', permissions: ['assets:read'], color: 'gray' as const },
|
||||
]
|
||||
|
||||
export default function RolesPage() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">角色权限</h1>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{defaultRoles.map(role => (
|
||||
<Card key={role.name} title={role.display_name}>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
'use client'
|
||||
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 Select from '@/components/ui/Select'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import { Plus, Edit, Trash2 } from 'lucide-react'
|
||||
|
||||
interface UserItem {
|
||||
id: number; username: string; display_name: string; email: string | null;
|
||||
role: string; is_active: number; created_at: string
|
||||
}
|
||||
|
||||
const roles = [
|
||||
{ value: 'admin', label: '管理员' },
|
||||
{ value: 'editor', label: '编辑者' },
|
||||
{ value: 'viewer', label: '查看者' },
|
||||
]
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<UserItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<UserItem | null>(null)
|
||||
const [form, setForm] = useState({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<UserItem | null>(null)
|
||||
|
||||
async function fetchUsers() {
|
||||
const res = await fetch('/api/users')
|
||||
if (res.ok) { const data = await res.json(); setUsers(data.users) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetchUsers().finally(() => setLoading(false)) }, [])
|
||||
|
||||
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 {
|
||||
if (editing) {
|
||||
const body: Record<string, unknown> = { display_name: form.display_name, email: form.email, role: form.role }
|
||||
if (form.password) body.password = form.password
|
||||
const res = await fetch(`/api/users/${editing.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||
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)
|
||||
fetchUsers()
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget) return
|
||||
const res = await fetch(`/api/users/${deleteTarget.id}`, { method: 'DELETE' })
|
||||
if (res.ok) { setDeleteTarget(null); fetchUsers() }
|
||||
}
|
||||
|
||||
const columns: Column<UserItem>[] = [
|
||||
{ key: 'username', title: '用户名' },
|
||||
{ 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: '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: 'is_active', title: '状态', render: (r) => <Badge color={r.is_active ? 'green' : 'red'}>{r.is_active ? '启用' : '禁用'}</Badge> },
|
||||
{ key: 'created_at', title: '创建时间' },
|
||||
{ key: 'actions', title: '操作', render: (r) => (
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
)},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">用户管理</h1>
|
||||
<Button size="sm" onClick={openCreate}><Plus size={16} />新建用户</Button>
|
||||
</div>
|
||||
|
||||
{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={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setModalOpen(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">
|
||||
{!editing && <Input label="用户名" value={form.username} onChange={e => setForm(p => ({ ...p, username: e.target.value }))} />}
|
||||
<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 }))} />
|
||||
<Input label={editing ? '新密码(留空不修改)' : '密码'} type="password" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
|
||||
<Select label="角色" value={form.role} onChange={e => setForm(p => ({ ...p, role: e.target.value }))} options={roles} />
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除">
|
||||
<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="danger" onClick={handleDelete}>删除</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
'use client'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function LoginForm() {
|
||||
const [username, setUsername] = useState('')
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { Suspense } from 'react'
|
||||
import { LoginForm } from './LoginForm'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><div className="text-slate-400">加载中...</div></div>}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import db from '@/lib/db'
|
||||
import { verifyJwt } from '@/lib/auth'
|
||||
import { checkPermission } from '@/lib/permissions'
|
||||
|
||||
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 }> }) {
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||
if (!checkPermission(session.role, 'api-keys:write')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const existing = db.prepare('SELECT id FROM api_keys WHERE id = ?').get(id)
|
||||
if (!existing) return NextResponse.json({ error: 'API Key 不存在' }, { status: 404 })
|
||||
|
||||
db.prepare('DELETE FROM api_keys WHERE id = ?').run(id)
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import db from '@/lib/db'
|
||||
import { verifyJwt, generateApiKey, hashApiKey } from '@/lib/auth'
|
||||
import { checkPermission } from '@/lib/permissions'
|
||||
|
||||
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() {
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||
if (!checkPermission(session.role, 'api-keys:read')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
|
||||
const keys = db.prepare('SELECT id, name, permissions, last_used_at, expires_at, is_active, created_at FROM api_keys ORDER BY id DESC').all()
|
||||
return NextResponse.json({ keys })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||
if (!checkPermission(session.role, 'api-keys:write')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { name, permissions, expires_at } = body
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: '名称不能为空' }, { status: 400 })
|
||||
}
|
||||
|
||||
const key = generateApiKey()
|
||||
const keyHash = hashApiKey(key)
|
||||
const perms = JSON.stringify(permissions || ['assets:read'])
|
||||
|
||||
const result = db.prepare('INSERT INTO api_keys (name, key_hash, permissions, expires_at, created_by) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(name, keyHash, perms, expires_at || null, session.userId)
|
||||
|
||||
const apiKey = db.prepare('SELECT id, name, permissions, expires_at, is_active, created_at FROM api_keys WHERE id = ?').get(result.lastInsertRowid)
|
||||
|
||||
return NextResponse.json({ key, apiKey }, { status: 201 })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '创建 API Key 失败'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import db from '@/lib/db'
|
||||
import { verifyJwt, verifyApiKey } from '@/lib/auth'
|
||||
import { checkPermission } from '@/lib/permissions'
|
||||
|
||||
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) {
|
||||
const auth = request.headers.get('Authorization') || ''
|
||||
if (!auth.startsWith('Bearer ')) return null
|
||||
return verifyApiKey(auth.slice(7))
|
||||
}
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getSession()
|
||||
const apiKey = getApiKeyAuth(request)
|
||||
if (!session && !apiKey) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
const asset = db.prepare('SELECT * FROM assets WHERE id = ?').get(id)
|
||||
if (!asset) return NextResponse.json({ error: '资产不存在' }, { status: 404 })
|
||||
return NextResponse.json({ asset })
|
||||
}
|
||||
|
||||
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||
if (!checkPermission(session.role, 'assets:write')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const existing = db.prepare('SELECT * FROM assets WHERE id = ?').get(id)
|
||||
if (!existing) return NextResponse.json({ error: '资产不存在' }, { status: 404 })
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const fields = [
|
||||
'serial_number', 'device_type', 'device_purpose', 'room', 'rack_position',
|
||||
'node_name', 'business_ip', 'hdm_ip', 'manufacturer', 'device_model',
|
||||
'status', 'warranty_date',
|
||||
'cpu_model', 'cpu_generation', 'cpu_cores', 'cpu_count', 'cpu_threads', 'cpu_spec',
|
||||
'memory_model', 'memory_frequency', 'memory_unit_capacity', 'memory_count', 'memory_total',
|
||||
'gpu_model', 'gpu_power', 'gpu_count',
|
||||
'nic1_model', 'nic1_type', 'nic1_speed', 'nic1_count',
|
||||
'nic2_model', 'nic2_type', 'nic2_speed', 'nic2_count',
|
||||
'nic3_model', 'nic3_type', 'nic3_speed', 'nic3_count',
|
||||
'sys_disk_model', 'sys_disk_spec', 'sys_disk_capacity', 'sys_disk_type', 'sys_disk_protocol', 'sys_disk_speed', 'sys_disk_count',
|
||||
'data_disk1_model', 'data_disk1_spec', 'data_disk1_capacity', 'data_disk1_type', 'data_disk1_protocol', 'data_disk1_speed', 'data_disk1_count',
|
||||
'data_disk2_model', 'data_disk2_spec', 'data_disk2_capacity', 'data_disk2_type', 'data_disk2_protocol', 'data_disk2_speed', 'data_disk2_count',
|
||||
'data_disk_total_space',
|
||||
'raid_model', 'raid_spec', 'raid_count',
|
||||
'psu1_model', 'psu1_power', 'psu1_count',
|
||||
'psu2_model', 'psu2_power', 'psu2_count',
|
||||
'psu_total_power',
|
||||
'board_model', 'board_count',
|
||||
'raw_data',
|
||||
]
|
||||
|
||||
const updates: string[] = []
|
||||
const values: unknown[] = []
|
||||
for (const f of fields) {
|
||||
if (body[f] !== undefined) {
|
||||
updates.push(`${f} = ?`)
|
||||
values.push(body[f] === '' ? null : body[f])
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return NextResponse.json({ error: '没有要更新的字段' }, { status: 400 })
|
||||
}
|
||||
|
||||
updates.push("updated_at = datetime('now')")
|
||||
values.push(id)
|
||||
|
||||
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', ?, ?)`)
|
||||
.run(session.userId, id, null)
|
||||
|
||||
const asset = db.prepare('SELECT * FROM assets WHERE id = ?').get(id)
|
||||
return NextResponse.json({ asset })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '更新失败'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||
if (!checkPermission(session.role, 'assets:delete')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const existing = db.prepare('SELECT id FROM assets WHERE id = ?').get(id)
|
||||
if (!existing) return NextResponse.json({ error: '资产不存在' }, { status: 404 })
|
||||
|
||||
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)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import db from '@/lib/db'
|
||||
import { verifyJwt } from '@/lib/auth'
|
||||
import { checkPermission } from '@/lib/permissions'
|
||||
|
||||
async function getSession() {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get('session_assets')?.value
|
||||
if (!token) return null
|
||||
return verifyJwt(token)
|
||||
}
|
||||
|
||||
const UPDATABLE_FIELDS = [
|
||||
'device_type', 'device_purpose', 'room', 'rack_position', 'status',
|
||||
'manufacturer', 'device_model', 'warranty_date',
|
||||
]
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||
if (!checkPermission(session.role, 'assets:write')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { ids, fields } = body as { ids: number[]; fields: Record<string, unknown> }
|
||||
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return NextResponse.json({ error: '请选择设备' }, { status: 400 })
|
||||
}
|
||||
if (!fields || typeof fields !== 'object' || Object.keys(fields).length === 0) {
|
||||
return NextResponse.json({ error: '请指定要修改的字段' }, { status: 400 })
|
||||
}
|
||||
|
||||
const updates: string[] = []
|
||||
const values: unknown[] = []
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (!UPDATABLE_FIELDS.includes(key)) continue
|
||||
updates.push(`${key} = ?`)
|
||||
values.push(value === '' ? null : value)
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return NextResponse.json({ error: '没有可更新的有效字段' }, { status: 400 })
|
||||
}
|
||||
|
||||
updates.push("updated_at = datetime('now')")
|
||||
|
||||
const placeholders = ids.map(() => '?').join(', ')
|
||||
const stmt = db.prepare(`UPDATE assets SET ${updates.join(', ')} WHERE id IN (${placeholders})`)
|
||||
const result = stmt.run(...values, ...ids)
|
||||
|
||||
db.prepare(`INSERT INTO audit_logs (user_id, action, entity_type, details, ip_address) VALUES (?, 'batch_update', 'asset', ?, ?)`)
|
||||
.run(session.userId, JSON.stringify({ ids, fields }), null)
|
||||
|
||||
return NextResponse.json({ updated: result.changes })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '批量更新失败'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import db from '@/lib/db'
|
||||
import { verifyJwt } from '@/lib/auth'
|
||||
import { exportAssetsToBuffer } from '@/lib/excel'
|
||||
|
||||
const FILTERABLE_FIELDS = new Set([
|
||||
'serial_number', 'device_type', 'device_purpose', 'room', 'rack_position',
|
||||
'node_name', 'business_ip', 'hdm_ip', 'manufacturer', 'device_model', 'status',
|
||||
'warranty_date',
|
||||
'cpu_model', 'cpu_generation', 'cpu_spec',
|
||||
'memory_model', 'memory_frequency', 'memory_unit_capacity', 'memory_total',
|
||||
'gpu_model', 'gpu_power',
|
||||
'nic1_model', 'nic1_type', 'nic1_speed',
|
||||
'nic2_model', 'nic2_type', 'nic2_speed',
|
||||
'nic3_model', 'nic3_type', 'nic3_speed',
|
||||
'sys_disk_model', 'sys_disk_spec', 'sys_disk_capacity', 'sys_disk_type', 'sys_disk_protocol',
|
||||
'data_disk1_model', 'data_disk1_spec', 'data_disk1_capacity', 'data_disk1_type', 'data_disk1_protocol',
|
||||
'data_disk2_model', 'data_disk2_spec', 'data_disk2_capacity',
|
||||
'data_disk_total_space',
|
||||
'raid_model', 'raid_spec',
|
||||
'psu1_model', 'psu1_power',
|
||||
'psu2_model', 'psu2_power',
|
||||
'psu_total_power',
|
||||
'board_model',
|
||||
])
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const cookieStore = await cookies()
|
||||
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 })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const search = searchParams.get('search') || ''
|
||||
|
||||
const conditions: string[] = []
|
||||
const params: unknown[] = []
|
||||
|
||||
if (search) {
|
||||
conditions.push(`(serial_number LIKE ? OR node_name LIKE ? OR business_ip LIKE ? OR device_model LIKE ? OR manufacturer LIKE ?)`)
|
||||
const s = `%${search}%`
|
||||
params.push(s, s, s, s, s)
|
||||
}
|
||||
|
||||
// Generic filter_field=value params (multi-select: multiple params → IN clause)
|
||||
const filterKeys = [...searchParams.keys()].filter(k => k.startsWith('filter_'))
|
||||
for (const key of filterKeys) {
|
||||
const field = key.replace('filter_', '')
|
||||
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
|
||||
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 assets = db.prepare(`SELECT * FROM assets ${where} ORDER BY id DESC`).all(...params) as Record<string, unknown>[]
|
||||
|
||||
const buffer = exportAssetsToBuffer(assets)
|
||||
return new NextResponse(new Uint8Array(buffer), {
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': `attachment; filename="assets_export_${new Date().toISOString().slice(0, 10)}.xlsx"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import db from '@/lib/db'
|
||||
import { verifyJwt } from '@/lib/auth'
|
||||
|
||||
const ALLOWED_FIELDS = new Set([
|
||||
'device_type', 'device_purpose', 'room', 'rack_position', 'node_name',
|
||||
'business_ip', 'hdm_ip', 'manufacturer', 'device_model', 'serial_number',
|
||||
'status', 'warranty_date',
|
||||
'cpu_model', 'cpu_generation', 'cpu_spec',
|
||||
'memory_model', 'memory_frequency', 'memory_unit_capacity', 'memory_total',
|
||||
'gpu_model', 'gpu_power',
|
||||
'nic1_model', 'nic1_type', 'nic1_speed',
|
||||
'nic2_model', 'nic2_type', 'nic2_speed',
|
||||
'nic3_model', 'nic3_type', 'nic3_speed',
|
||||
'sys_disk_model', 'sys_disk_spec', 'sys_disk_capacity', 'sys_disk_type', 'sys_disk_protocol',
|
||||
'data_disk1_model', 'data_disk1_spec', 'data_disk1_type', 'data_disk1_protocol',
|
||||
'data_disk_total_space',
|
||||
'psu1_model', 'psu1_power', 'psu_total_power',
|
||||
'raid_model', 'board_model',
|
||||
])
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const cookieStore = await cookies()
|
||||
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 })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const field = searchParams.get('field') || ''
|
||||
const q = searchParams.get('q') || ''
|
||||
|
||||
if (!ALLOWED_FIELDS.has(field)) {
|
||||
return NextResponse.json({ values: [] })
|
||||
}
|
||||
|
||||
const where = q ? `WHERE ${field} LIKE ? AND ${field} IS NOT NULL AND ${field} != ''` : `WHERE ${field} IS NOT NULL AND ${field} != ''`
|
||||
const params = q ? [`%${q}%`] : []
|
||||
|
||||
const rows = db.prepare(`SELECT DISTINCT ${field} as val FROM assets ${where} ORDER BY ${field} LIMIT 20`).all(...params) as { val: string }[]
|
||||
return NextResponse.json({ values: rows.map(r => r.val) })
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import db from '@/lib/db'
|
||||
import { verifyJwt } from '@/lib/auth'
|
||||
import { checkPermission } from '@/lib/permissions'
|
||||
import { parseImportBuffer } from '@/lib/excel'
|
||||
|
||||
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) {
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||
if (!checkPermission(session.role, 'assets:write')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
if (!file) return NextResponse.json({ error: '请上传文件' }, { status: 400 })
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
const { rows, errors } = parseImportBuffer(buffer)
|
||||
|
||||
if (errors.length > 0 && rows.length === 0) {
|
||||
return NextResponse.json({ errors }, { status: 400 })
|
||||
}
|
||||
|
||||
const allFields = [
|
||||
'serial_number', 'device_type', 'device_purpose', 'room', 'rack_position',
|
||||
'node_name', 'business_ip', 'hdm_ip', 'manufacturer', 'device_model',
|
||||
'status', 'warranty_date',
|
||||
'cpu_model', 'cpu_generation', 'cpu_cores', 'cpu_count', 'cpu_threads', 'cpu_spec',
|
||||
'memory_model', 'memory_frequency', 'memory_unit_capacity', 'memory_count', 'memory_total',
|
||||
'gpu_model', 'gpu_power', 'gpu_count',
|
||||
'nic1_model', 'nic1_type', 'nic1_speed', 'nic1_count',
|
||||
'nic2_model', 'nic2_type', 'nic2_speed', 'nic2_count',
|
||||
'nic3_model', 'nic3_type', 'nic3_speed', 'nic3_count',
|
||||
'sys_disk_model', 'sys_disk_spec', 'sys_disk_capacity', 'sys_disk_type', 'sys_disk_protocol', 'sys_disk_speed', 'sys_disk_count',
|
||||
'data_disk1_model', 'data_disk1_spec', 'data_disk1_capacity', 'data_disk1_type', 'data_disk1_protocol', 'data_disk1_speed', 'data_disk1_count',
|
||||
'data_disk2_model', 'data_disk2_spec', 'data_disk2_capacity', 'data_disk2_type', 'data_disk2_protocol', 'data_disk2_speed', 'data_disk2_count',
|
||||
'data_disk_total_space',
|
||||
'raid_model', 'raid_spec', 'raid_count',
|
||||
'psu1_model', 'psu1_power', 'psu1_count',
|
||||
'psu2_model', 'psu2_power', 'psu2_count',
|
||||
'psu_total_power',
|
||||
'board_model', 'board_count',
|
||||
]
|
||||
|
||||
let created = 0
|
||||
let updated = 0
|
||||
|
||||
const insertTransaction = db.transaction(() => {
|
||||
for (const row of rows) {
|
||||
const sn = row.serial_number as string | null
|
||||
if (sn) {
|
||||
const existing = db.prepare('SELECT id FROM assets WHERE serial_number = ?').get(sn) as { id: number } | undefined
|
||||
if (existing) {
|
||||
const updates: string[] = []
|
||||
const values: unknown[] = []
|
||||
for (const f of allFields) {
|
||||
if (row[f] !== undefined) {
|
||||
updates.push(`${f} = ?`)
|
||||
values.push(row[f])
|
||||
}
|
||||
}
|
||||
if (updates.length > 0) {
|
||||
updates.push("updated_at = datetime('now')")
|
||||
values.push(existing.id)
|
||||
db.prepare(`UPDATE assets SET ${updates.join(', ')} WHERE id = ?`).run(...values)
|
||||
updated++
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const present = allFields.filter(f => row[f] !== undefined && row[f] !== null)
|
||||
if (present.length === 0) continue
|
||||
const placeholders = present.map(() => '?').join(', ')
|
||||
const values = present.map(f => row[f])
|
||||
db.prepare(`INSERT INTO assets (${present.join(', ')}) VALUES (${placeholders})`).run(...values)
|
||||
created++
|
||||
}
|
||||
})
|
||||
|
||||
insertTransaction()
|
||||
|
||||
db.prepare(`INSERT INTO audit_logs (user_id, action, entity_type, details, ip_address) VALUES (?, 'import', 'asset', ?, ?)`)
|
||||
.run(session.userId, JSON.stringify({ created, updated }), null)
|
||||
|
||||
return NextResponse.json({ created, updated, errors })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '导入失败'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import db from '@/lib/db'
|
||||
import { verifyJwt, verifyApiKey } from '@/lib/auth'
|
||||
import { checkPermission } from '@/lib/permissions'
|
||||
|
||||
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) {
|
||||
const auth = request.headers.get('Authorization') || ''
|
||||
if (!auth.startsWith('Bearer ')) return null
|
||||
return verifyApiKey(auth.slice(7))
|
||||
}
|
||||
|
||||
// 允许高级查询的字段
|
||||
const FILTERABLE_FIELDS = new Set([
|
||||
'serial_number', 'device_type', 'device_purpose', 'room', 'rack_position',
|
||||
'node_name', 'business_ip', 'hdm_ip', 'manufacturer', 'device_model', 'status',
|
||||
'warranty_date',
|
||||
'cpu_model', 'cpu_generation', 'cpu_spec',
|
||||
'memory_model', 'memory_frequency', 'memory_unit_capacity', 'memory_total',
|
||||
'gpu_model', 'gpu_power',
|
||||
'nic1_model', 'nic1_type', 'nic1_speed',
|
||||
'nic2_model', 'nic2_type', 'nic2_speed',
|
||||
'nic3_model', 'nic3_type', 'nic3_speed',
|
||||
'sys_disk_model', 'sys_disk_spec', 'sys_disk_capacity', 'sys_disk_type', 'sys_disk_protocol',
|
||||
'data_disk1_model', 'data_disk1_spec', 'data_disk1_capacity', 'data_disk1_type', 'data_disk1_protocol',
|
||||
'data_disk2_model', 'data_disk2_spec', 'data_disk2_capacity',
|
||||
'data_disk_total_space',
|
||||
'raid_model', 'raid_spec',
|
||||
'psu1_model', 'psu1_power',
|
||||
'psu2_model', 'psu2_power',
|
||||
'psu_total_power',
|
||||
'board_model',
|
||||
])
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await getSession()
|
||||
const apiKey = getApiKeyAuth(request)
|
||||
if (!session && !apiKey) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||
|
||||
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 search = searchParams.get('search') || ''
|
||||
const sortKey = searchParams.get('sortKey') || 'id'
|
||||
const sortOrder = searchParams.get('sortOrder') || 'desc'
|
||||
|
||||
const allowedSortKeys = new Set([
|
||||
'id', 'serial_number', 'device_type', 'device_purpose', 'node_name',
|
||||
'business_ip', 'manufacturer', 'device_model', 'status', 'created_at', 'updated_at'
|
||||
])
|
||||
const safeSortKey = allowedSortKeys.has(sortKey) ? sortKey : 'id'
|
||||
const safeSortOrder = sortOrder === 'asc' ? 'ASC' : 'DESC'
|
||||
|
||||
const conditions: string[] = []
|
||||
const params: unknown[] = []
|
||||
|
||||
if (search) {
|
||||
conditions.push(`(serial_number LIKE ? OR node_name LIKE ? OR business_ip LIKE ? OR device_model LIKE ? OR manufacturer LIKE ?)`)
|
||||
const s = `%${search}%`
|
||||
params.push(s, s, s, s, s)
|
||||
}
|
||||
|
||||
// Generic filter_field=value params (multi-select: multiple params with same key → IN clause)
|
||||
const filterKeys = [...searchParams.keys()].filter(k => k.startsWith('filter_'))
|
||||
for (const key of filterKeys) {
|
||||
const field = key.replace('filter_', '')
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 高级查询 filters: JSON encoded array of {field, op, value}
|
||||
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 invalid filters JSON */ }
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
const offset = (page - 1) * pageSize
|
||||
|
||||
const total = (db.prepare(`SELECT COUNT(*) as count FROM assets ${where}`).get(...params) as { count: number }).count
|
||||
const data = db.prepare(`SELECT * FROM assets ${where} ORDER BY ${safeSortKey} ${safeSortOrder} LIMIT ? OFFSET ?`).all(...params, pageSize, offset)
|
||||
|
||||
return NextResponse.json({
|
||||
data, total, page, pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||
if (!checkPermission(session.role, 'assets:write')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
if (!body.device_type) {
|
||||
return NextResponse.json({ error: '设备类型不能为空' }, { status: 400 })
|
||||
}
|
||||
|
||||
const fields = [
|
||||
'serial_number', 'device_type', 'device_purpose', 'room', 'rack_position',
|
||||
'node_name', 'business_ip', 'hdm_ip', 'manufacturer', 'device_model',
|
||||
'status', 'warranty_date',
|
||||
'cpu_model', 'cpu_generation', 'cpu_cores', 'cpu_count', 'cpu_threads', 'cpu_spec',
|
||||
'memory_model', 'memory_frequency', 'memory_unit_capacity', 'memory_count', 'memory_total',
|
||||
'gpu_model', 'gpu_power', 'gpu_count',
|
||||
'nic1_model', 'nic1_type', 'nic1_speed', 'nic1_count',
|
||||
'nic2_model', 'nic2_type', 'nic2_speed', 'nic2_count',
|
||||
'nic3_model', 'nic3_type', 'nic3_speed', 'nic3_count',
|
||||
'sys_disk_model', 'sys_disk_spec', 'sys_disk_capacity', 'sys_disk_type', 'sys_disk_protocol', 'sys_disk_speed', 'sys_disk_count',
|
||||
'data_disk1_model', 'data_disk1_spec', 'data_disk1_capacity', 'data_disk1_type', 'data_disk1_protocol', 'data_disk1_speed', 'data_disk1_count',
|
||||
'data_disk2_model', 'data_disk2_spec', 'data_disk2_capacity', 'data_disk2_type', 'data_disk2_protocol', 'data_disk2_speed', 'data_disk2_count',
|
||||
'data_disk_total_space',
|
||||
'raid_model', 'raid_spec', 'raid_count',
|
||||
'psu1_model', 'psu1_power', 'psu1_count',
|
||||
'psu2_model', 'psu2_power', 'psu2_count',
|
||||
'psu_total_power',
|
||||
'board_model', 'board_count',
|
||||
'raw_data',
|
||||
]
|
||||
|
||||
const present = fields.filter(f => body[f] !== undefined)
|
||||
const placeholders = present.map(() => '?').join(', ')
|
||||
const values = present.map(f => body[f] === '' ? null : body[f])
|
||||
|
||||
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', ?, ?)`)
|
||||
.run(session.userId, result.lastInsertRowid, null)
|
||||
|
||||
const asset = db.prepare('SELECT * FROM assets WHERE id = ?').get(result.lastInsertRowid)
|
||||
return NextResponse.json({ asset }, { status: 201 })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '创建失败'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { verifyJwt } from '@/lib/auth'
|
||||
import { generateTemplateBuffer } from '@/lib/excel'
|
||||
|
||||
export async function GET() {
|
||||
const cookieStore = await cookies()
|
||||
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 })
|
||||
|
||||
const buffer = generateTemplateBuffer()
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': 'attachment; filename="asset_import_template.xlsx"',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* 代理路由:/api/assets/tickets
|
||||
* 将请求转发至 issue-ai 的 /api/tickets/by-asset API
|
||||
*
|
||||
* GET /api/assets/tickets?deviceIp=xxx&deviceSn=xxx
|
||||
*
|
||||
* 环境变量:
|
||||
* ISSUE_API_URL — issue-ai API 地址(默认 http://localhost:6176/api)
|
||||
* ISSUE_API_KEY — API 密钥
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getTicketsByAsset } from '@/lib/issue-client'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const deviceIp = searchParams.get('deviceIp') || undefined
|
||||
const deviceSn = searchParams.get('deviceSn') || undefined
|
||||
|
||||
if (!deviceIp && !deviceSn) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少必要参数:deviceIp 或 deviceSn' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
// 转发用户 cookie 用于 issue-ai 认证
|
||||
const cookie = request.headers.get('cookie') || undefined
|
||||
const result = await getTicketsByAsset({
|
||||
ip: deviceIp,
|
||||
sn: deviceSn,
|
||||
cookie,
|
||||
})
|
||||
return NextResponse.json(result)
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '获取工单列表失败'
|
||||
const cause = err instanceof Error ? err.cause : undefined
|
||||
console.error('[/api/assets/tickets]', message, cause)
|
||||
return NextResponse.json({ error: message, detail: String(cause) }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import db from '@/lib/db'
|
||||
import { verifyPassword, signJwt } from '@/lib/auth'
|
||||
import type { User } from '@/types'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { username, password } = await request.json()
|
||||
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 })
|
||||
const token = signJwt({ userId: user.id, username: user.username, role: user.role })
|
||||
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 } })
|
||||
} catch { return NextResponse.json({ error: '登录失败' }, { status: 500 }) }
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
export async function POST() {
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.set('session_assets', '', { maxAge: 0, path: '/' })
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import db from '@/lib/db'
|
||||
import { verifyJwt } from '@/lib/auth'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const cookieStore = await cookies()
|
||||
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 })
|
||||
const user = db.prepare('SELECT id, username, display_name, email, role FROM users WHERE id = ? AND is_active = 1').get(payload.userId)
|
||||
if (!user) return NextResponse.json({ error: '用户不存在' }, { status: 401 })
|
||||
return NextResponse.json({ user })
|
||||
} catch { return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 }) }
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import db from '@/lib/db'
|
||||
import { verifyJwt } 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() {
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
|
||||
const total = (db.prepare('SELECT COUNT(*) as c FROM assets').get() as any).c
|
||||
|
||||
const byStatus = db.prepare(
|
||||
'SELECT status, COUNT(*) as count FROM assets GROUP BY status ORDER BY count DESC'
|
||||
).all()
|
||||
|
||||
const byDeviceType = db.prepare(
|
||||
'SELECT device_type, COUNT(*) as count FROM assets GROUP BY device_type ORDER BY count DESC'
|
||||
).all()
|
||||
|
||||
const byManufacturer = db.prepare(
|
||||
"SELECT manufacturer, COUNT(*) as count FROM assets WHERE manufacturer IS NOT NULL AND manufacturer != '' GROUP BY manufacturer ORDER BY count DESC"
|
||||
).all()
|
||||
|
||||
const warrantySoon = (db.prepare(
|
||||
"SELECT COUNT(*) as c FROM assets WHERE warranty_date IS NOT NULL AND warranty_date != '' AND date(warranty_date) <= date('now', '+90 days') AND date(warranty_date) >= date('now')"
|
||||
).get() as any).c
|
||||
|
||||
const warrantyExpired = (db.prepare(
|
||||
"SELECT COUNT(*) as c FROM assets WHERE warranty_date IS NOT NULL AND warranty_date != '' AND date(warranty_date) < date('now')"
|
||||
).get() as any).c
|
||||
|
||||
const byRoom = db.prepare(
|
||||
"SELECT room, COUNT(*) as count FROM assets WHERE room IS NOT NULL AND room != '' GROUP BY room ORDER BY count DESC LIMIT 10"
|
||||
).all()
|
||||
|
||||
return NextResponse.json({
|
||||
total,
|
||||
byStatus,
|
||||
byDeviceType,
|
||||
byManufacturer,
|
||||
byRoom,
|
||||
warrantySoon,
|
||||
warrantyExpired,
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import db from '@/lib/db'
|
||||
import { verifyJwt, hashPassword } from '@/lib/auth'
|
||||
import { checkPermission } from '@/lib/permissions'
|
||||
|
||||
async function getSession() {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get('session_assets')?.value
|
||||
if (!token) return null
|
||||
return verifyJwt(token)
|
||||
}
|
||||
|
||||
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
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 { id } = await params
|
||||
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id)
|
||||
if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const updates: string[] = []
|
||||
const values: unknown[] = []
|
||||
|
||||
if (body.display_name !== undefined) { updates.push('display_name = ?'); values.push(body.display_name) }
|
||||
if (body.email !== undefined) { updates.push('email = ?'); values.push(body.email || null) }
|
||||
if (body.role !== undefined) { updates.push('role = ?'); values.push(body.role) }
|
||||
if (body.is_active !== undefined) { updates.push('is_active = ?'); values.push(body.is_active ? 1 : 0) }
|
||||
if (body.password) { updates.push('password_hash = ?'); values.push(hashPassword(body.password)) }
|
||||
|
||||
if (updates.length === 0) {
|
||||
return NextResponse.json({ error: '没有要更新的字段' }, { status: 400 })
|
||||
}
|
||||
|
||||
updates.push("updated_at = datetime('now')")
|
||||
values.push(id)
|
||||
|
||||
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)
|
||||
return NextResponse.json({ user })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '更新用户失败'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
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 { id } = await params
|
||||
if (String(id) === String(session.userId)) {
|
||||
return NextResponse.json({ error: '不能删除当前登录用户' }, { status: 400 })
|
||||
}
|
||||
|
||||
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id)
|
||||
if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
|
||||
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(id)
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import db from '@/lib/db'
|
||||
import { verifyJwt, hashPassword } from '@/lib/auth'
|
||||
import { checkPermission } from '@/lib/permissions'
|
||||
|
||||
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() {
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||
if (!checkPermission(session.role, 'users:read')) {
|
||||
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()
|
||||
return NextResponse.json({ users })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||
if (!checkPermission(session.role, 'users:write')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { username, password, display_name, email, role } = body
|
||||
if (!username || !password || !display_name) {
|
||||
return NextResponse.json({ error: '用户名、密码和显示名称不能为空' }, { status: 400 })
|
||||
}
|
||||
|
||||
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username)
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: '用户名已存在' }, { status: 409 })
|
||||
}
|
||||
|
||||
const passwordHash = hashPassword(password)
|
||||
const result = db.prepare('INSERT INTO users (username, password_hash, display_name, email, role) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(username, passwordHash, display_name, email || null, role || 'viewer')
|
||||
|
||||
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 })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '创建用户失败'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
@import "tailwindcss";
|
||||
@config "../../tailwind.config.js";
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-slate-50 text-slate-900;
|
||||
}
|
||||
html.dark body {
|
||||
@apply bg-slate-950 text-white;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card-label {
|
||||
font-size: 0.875rem;
|
||||
color: rgba(0, 0, 0, 0.48);
|
||||
margin: 0;
|
||||
}
|
||||
html.dark .card-label {
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
}
|
||||
.card-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-top: 0.25rem;
|
||||
color: #1d1d1f;
|
||||
line-height: 1.2;
|
||||
}
|
||||
html.dark .card-value {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '资产管理系统',
|
||||
description: '设备资产管理系统',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{
|
||||
__html: `(function(){try{var t=localStorage.getItem('theme');if(!t){t=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}if(t==='light'){document.documentElement.classList.remove('dark');document.documentElement.classList.add('light');}else{document.documentElement.classList.add('dark');document.documentElement.classList.remove('light');}}catch(e){}})();`
|
||||
}} />
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import { redirect } from 'next/navigation'
|
||||
export default function Home() { redirect('/dashboard') }
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
'use client'
|
||||
import { useState } from 'react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Autocomplete from '@/components/ui/Autocomplete'
|
||||
import { Plus, X, Search, RotateCcw } from 'lucide-react'
|
||||
|
||||
export interface Filter {
|
||||
field: string
|
||||
op: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface AdvancedSearchProps {
|
||||
onSearch: (filters: Filter[]) => void
|
||||
alwaysOpen?: boolean
|
||||
}
|
||||
|
||||
const FIELD_GROUPS = [
|
||||
{
|
||||
label: '基本信息',
|
||||
fields: [
|
||||
{ value: 'device_type', label: '设备类型' },
|
||||
{ value: 'device_purpose', label: '设备用途' },
|
||||
{ value: 'room', label: '机房' },
|
||||
{ value: 'rack_position', label: '机架位置' },
|
||||
{ value: 'node_name', label: '节点名称' },
|
||||
{ value: 'business_ip', label: '业务IP' },
|
||||
{ value: 'hdm_ip', label: 'HDM地址' },
|
||||
{ value: 'manufacturer', label: '厂商' },
|
||||
{ value: 'device_model', label: '设备型号' },
|
||||
{ value: 'serial_number', label: '序列号' },
|
||||
{ value: 'status', label: '状态' },
|
||||
{ value: 'warranty_date', label: '维保日期' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'CPU',
|
||||
fields: [
|
||||
{ value: 'cpu_model', label: 'CPU型号' },
|
||||
{ value: 'cpu_generation', label: 'CPU代数' },
|
||||
{ value: 'cpu_spec', label: 'CPU规格' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '内存',
|
||||
fields: [
|
||||
{ value: 'memory_model', label: '内存型号' },
|
||||
{ value: 'memory_frequency', label: '内存频率' },
|
||||
{ value: 'memory_unit_capacity', label: '单条容量' },
|
||||
{ value: 'memory_total', label: '内存总量' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'GPU',
|
||||
fields: [
|
||||
{ value: 'gpu_model', label: 'GPU型号' },
|
||||
{ value: 'gpu_power', label: 'GPU功率' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '网卡',
|
||||
fields: [
|
||||
{ value: 'nic1_model', label: '网卡1型号' },
|
||||
{ value: 'nic1_type', label: '网卡1芯片' },
|
||||
{ value: 'nic1_speed', label: '网卡1速率' },
|
||||
{ value: 'nic2_model', label: '网卡2型号' },
|
||||
{ value: 'nic2_type', label: '网卡2芯片' },
|
||||
{ value: 'nic2_speed', label: '网卡2速率' },
|
||||
{ value: 'nic3_model', label: '网卡3型号' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '磁盘',
|
||||
fields: [
|
||||
{ value: 'sys_disk_model', label: '系统盘型号' },
|
||||
{ value: 'sys_disk_spec', label: '系统盘规格' },
|
||||
{ value: 'sys_disk_type', label: '系统盘介质' },
|
||||
{ value: 'sys_disk_protocol', label: '系统盘协议' },
|
||||
{ value: 'data_disk1_model', label: '数据盘1型号' },
|
||||
{ value: 'data_disk1_spec', label: '数据盘1规格' },
|
||||
{ value: 'data_disk1_type', label: '数据盘1介质' },
|
||||
{ value: 'data_disk1_protocol', label: '数据盘1协议' },
|
||||
{ value: 'data_disk_total_space', label: '数据盘总空间' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '电源/其他',
|
||||
fields: [
|
||||
{ value: 'psu1_model', label: '电源1型号' },
|
||||
{ value: 'psu1_power', label: '电源1功率' },
|
||||
{ value: 'psu_total_power', label: '电源总功率' },
|
||||
{ value: 'raid_model', label: 'RAID卡型号' },
|
||||
{ value: 'board_model', label: '主板型号' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const ALL_FIELDS = FIELD_GROUPS.flatMap(g => g.fields)
|
||||
|
||||
const OPERATORS = [
|
||||
{ value: 'contains', label: '包含' },
|
||||
{ value: 'equals', label: '等于' },
|
||||
{ value: 'starts_with', label: '开头是' },
|
||||
{ value: 'ends_with', label: '结尾是' },
|
||||
{ value: 'not_empty', label: '非空' },
|
||||
{ value: 'empty', label: '为空' },
|
||||
]
|
||||
|
||||
const EMPTY_OPS = new Set(['empty', 'not_empty'])
|
||||
|
||||
export default function AdvancedSearch({ onSearch, alwaysOpen }: AdvancedSearchProps) {
|
||||
const [open, setOpen] = useState(!!alwaysOpen)
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
{ field: 'device_type', op: 'equals', value: '' },
|
||||
{ field: 'node_name', op: 'contains', value: '' },
|
||||
])
|
||||
|
||||
function addRow() {
|
||||
setFilters(prev => [...prev, { field: 'node_name', op: 'contains', value: '' }])
|
||||
}
|
||||
|
||||
function removeRow(idx: number) {
|
||||
setFilters(prev => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
function updateRow(idx: number, key: keyof Filter, val: string) {
|
||||
setFilters(prev => prev.map((f, i) => {
|
||||
if (i !== idx) return f
|
||||
if (key === 'field') return { ...f, field: val, value: '' }
|
||||
return { ...f, [key]: val }
|
||||
}))
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
const valid = filters.filter(f => EMPTY_OPS.has(f.op) || f.value.trim())
|
||||
onSearch(valid)
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
setFilters([
|
||||
{ field: 'device_type', op: 'equals', value: '' },
|
||||
{ field: 'node_name', op: 'contains', value: '' },
|
||||
])
|
||||
onSearch([])
|
||||
}
|
||||
|
||||
function getFieldLabel(field: string) {
|
||||
return ALL_FIELDS.find(f => f.value === field)?.label || field
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<Button variant="ghost" size="sm" onClick={() => setOpen(true)}>
|
||||
<Search size={16} />高级查询
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">高级查询</span>
|
||||
<button onClick={() => setOpen(false)} className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filters.map((f, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
{idx > 0 && <span className="text-xs text-slate-400 w-8 text-center">且</span>}
|
||||
{idx === 0 && <span className="w-8" />}
|
||||
|
||||
<select
|
||||
value={f.field}
|
||||
onChange={e => updateRow(idx, 'field', e.target.value)}
|
||||
className="px-2 py-1.5 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 text-sm min-w-[140px]"
|
||||
>
|
||||
{FIELD_GROUPS.map(g => (
|
||||
<optgroup key={g.label} label={g.label}>
|
||||
{g.fields.map(f => <option key={f.value} value={f.value}>{f.label}</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={f.op}
|
||||
onChange={e => updateRow(idx, 'op', e.target.value)}
|
||||
className="px-2 py-1.5 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 text-sm w-[90px]"
|
||||
>
|
||||
{OPERATORS.map(op => <option key={op.value} value={op.value}>{op.label}</option>)}
|
||||
</select>
|
||||
|
||||
{!EMPTY_OPS.has(f.op) && (
|
||||
<Autocomplete
|
||||
field={f.field}
|
||||
value={f.value}
|
||||
onChange={val => updateRow(idx, 'value', val)}
|
||||
placeholder={`输入${getFieldLabel(f.field)}...`}
|
||||
onEnter={handleSearch}
|
||||
/>
|
||||
)}
|
||||
|
||||
{filters.length > 1 && (
|
||||
<button onClick={() => removeRow(idx)} className="p-1 text-slate-400 hover:text-red-500 transition-colors">
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button variant="ghost" size="sm" onClick={addRow}><Plus size={14} />添加条件</Button>
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" onClick={handleReset}><RotateCcw size={14} />重置</Button>
|
||||
<Button size="sm" onClick={handleSearch}><Search size={14} />查询</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
'use client'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import type { Asset } from '@/types'
|
||||
|
||||
interface AssetDetailProps { asset: Asset }
|
||||
|
||||
const statusColor: Record<string, 'green' | 'yellow' | 'red' | 'gray' | 'blue'> = {
|
||||
'腾讯使用': 'green', '图灵使用': 'blue', '闲置': 'yellow', '备用': 'yellow', '维修中': 'red', '已下线': 'gray',
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
if (value == null || value === '') return null
|
||||
return (
|
||||
<div className="flex py-1.5 border-b border-slate-100 dark:border-slate-800">
|
||||
<span className="w-32 text-sm text-slate-500 dark:text-slate-400 shrink-0">{label}</span>
|
||||
<span className="text-sm text-slate-900 dark:text-slate-100 break-all">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
const items = Array.isArray(children) ? children.filter(Boolean) : children
|
||||
return (
|
||||
<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>
|
||||
<div className="space-y-0">{items}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AssetDetail({ asset: a }: AssetDetailProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Section title="基本信息">
|
||||
<Field label="设备类型" value={a.device_type} />
|
||||
<Field label="设备用途" value={a.device_purpose} />
|
||||
<Field label="节点名称" value={a.node_name} />
|
||||
<Field label="序列号" value={a.serial_number} />
|
||||
<Field label="厂商" value={a.manufacturer} />
|
||||
<Field label="设备型号" value={a.device_model} />
|
||||
<div className="flex py-1.5 border-b border-slate-100 dark:border-slate-800">
|
||||
<span className="w-32 text-sm text-slate-500 dark:text-slate-400 shrink-0">状态</span>
|
||||
<Badge color={statusColor[String(a.status ?? '')] || 'gray'}>{a.status ?? '-'}</Badge>
|
||||
</div>
|
||||
<Field label="机房" value={a.room} />
|
||||
<Field label="机架位置" value={a.rack_position} />
|
||||
<Field label="业务IP" value={a.business_ip} />
|
||||
<Field label="HDM管理IP" value={a.hdm_ip ? <a href={`http://${a.hdm_ip}`} target="_blank" rel="noopener noreferrer" className="text-blue-600 dark:text-blue-400 hover:underline">{a.hdm_ip}</a> : null} />
|
||||
<Field label="保修到期日" value={a.warranty_date} />
|
||||
</Section>
|
||||
|
||||
<Section title="CPU">
|
||||
<Field label="型号" value={a.cpu_model} />
|
||||
<Field label="代数" value={a.cpu_generation} />
|
||||
<Field label="核数" value={a.cpu_cores} />
|
||||
<Field label="线程数" value={a.cpu_threads} />
|
||||
<Field label="数量" value={a.cpu_count} />
|
||||
<Field label="规格" value={a.cpu_spec} />
|
||||
</Section>
|
||||
|
||||
<Section title="内存">
|
||||
<Field label="型号" value={a.memory_model} />
|
||||
<Field label="频率" value={a.memory_frequency} />
|
||||
<Field label="单条容量" value={a.memory_unit_capacity} />
|
||||
<Field label="条数" value={a.memory_count} />
|
||||
<Field label="总量" value={a.memory_total} />
|
||||
</Section>
|
||||
|
||||
<Section title="GPU">
|
||||
<Field label="型号" value={a.gpu_model} />
|
||||
<Field label="功耗" value={a.gpu_power} />
|
||||
<Field label="数量" value={a.gpu_count} />
|
||||
</Section>
|
||||
|
||||
<Section title="网卡 1">
|
||||
<Field label="型号" value={a.nic1_model} />
|
||||
<Field label="类型" value={a.nic1_type} />
|
||||
<Field label="速率" value={a.nic1_speed} />
|
||||
<Field label="数量" value={a.nic1_count} />
|
||||
</Section>
|
||||
|
||||
<Section title="网卡 2">
|
||||
<Field label="型号" value={a.nic2_model} />
|
||||
<Field label="类型" value={a.nic2_type} />
|
||||
<Field label="速率" value={a.nic2_speed} />
|
||||
<Field label="数量" value={a.nic2_count} />
|
||||
</Section>
|
||||
|
||||
<Section title="网卡 3">
|
||||
<Field label="型号" value={a.nic3_model} />
|
||||
<Field label="类型" value={a.nic3_type} />
|
||||
<Field label="速率" value={a.nic3_speed} />
|
||||
<Field label="数量" value={a.nic3_count} />
|
||||
</Section>
|
||||
|
||||
<Section title="系统盘">
|
||||
<Field label="型号" value={a.sys_disk_model} />
|
||||
<Field label="规格" value={a.sys_disk_spec} />
|
||||
<Field label="容量" value={a.sys_disk_capacity} />
|
||||
<Field label="类型" value={a.sys_disk_type} />
|
||||
<Field label="协议" value={a.sys_disk_protocol} />
|
||||
<Field label="速率" value={a.sys_disk_speed} />
|
||||
<Field label="数量" value={a.sys_disk_count} />
|
||||
</Section>
|
||||
|
||||
<Section title="数据盘 1">
|
||||
<Field label="型号" value={a.data_disk1_model} />
|
||||
<Field label="规格" value={a.data_disk1_spec} />
|
||||
<Field label="容量" value={a.data_disk1_capacity} />
|
||||
<Field label="类型" value={a.data_disk1_type} />
|
||||
<Field label="协议" value={a.data_disk1_protocol} />
|
||||
<Field label="速率" value={a.data_disk1_speed} />
|
||||
<Field label="数量" value={a.data_disk1_count} />
|
||||
</Section>
|
||||
|
||||
<Section title="数据盘 2">
|
||||
<Field label="型号" value={a.data_disk2_model} />
|
||||
<Field label="规格" value={a.data_disk2_spec} />
|
||||
<Field label="容量" value={a.data_disk2_capacity} />
|
||||
<Field label="类型" value={a.data_disk2_type} />
|
||||
<Field label="协议" value={a.data_disk2_protocol} />
|
||||
<Field label="速率" value={a.data_disk2_speed} />
|
||||
<Field label="数量" value={a.data_disk2_count} />
|
||||
</Section>
|
||||
|
||||
<Section title="RAID / 存储">
|
||||
<Field label="数据盘总空间" value={a.data_disk_total_space} />
|
||||
<Field label="RAID型号" value={a.raid_model} />
|
||||
<Field label="RAID规格" value={a.raid_spec} />
|
||||
<Field label="RAID数量" value={a.raid_count} />
|
||||
</Section>
|
||||
|
||||
<Section title="电源">
|
||||
<Field label="电源1型号" value={a.psu1_model} />
|
||||
<Field label="电源1功率" value={a.psu1_power} />
|
||||
<Field label="电源1数量" value={a.psu1_count} />
|
||||
<Field label="电源2型号" value={a.psu2_model} />
|
||||
<Field label="电源2功率" value={a.psu2_power} />
|
||||
<Field label="电源2数量" value={a.psu2_count} />
|
||||
<Field label="总功率" value={a.psu_total_power} />
|
||||
</Section>
|
||||
|
||||
<Section title="主板">
|
||||
<Field label="型号" value={a.board_model} />
|
||||
<Field label="数量" value={a.board_count} />
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
'use client'
|
||||
import { useState } from 'react'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Select from '@/components/ui/Select'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import Button from '@/components/ui/Button'
|
||||
import type { Asset } from '@/types'
|
||||
|
||||
interface AssetFormProps {
|
||||
asset?: Asset
|
||||
onSubmit: (data: Record<string, unknown>) => Promise<void>
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const deviceTypes = ['服务器', '交换机', '路由器', '防火墙', '存储设备', 'GPU服务器', '其他']
|
||||
|
||||
export default function AssetForm({ asset, onSubmit, loading }: AssetFormProps) {
|
||||
const [form, setForm] = useState<Record<string, unknown>>(() => {
|
||||
if (asset) {
|
||||
const f: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(asset)) {
|
||||
if (k !== 'id' && k !== 'created_at' && k !== 'updated_at') f[k] = v ?? ''
|
||||
}
|
||||
return f
|
||||
}
|
||||
return { device_type: '服务器', status: '腾讯使用' }
|
||||
})
|
||||
|
||||
const [error, setError] = useState('')
|
||||
const [statusOptions, setStatusOptions] = useState<string[]>(['腾讯使用', '图灵使用', '闲置', '备用', '维修中', '已下线'])
|
||||
const [showNewStatus, setShowNewStatus] = useState(false)
|
||||
const [newStatusName, setNewStatusName] = useState('')
|
||||
|
||||
function set(key: string, value: unknown) {
|
||||
setForm(prev => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
function addStatus() {
|
||||
const name = newStatusName.trim()
|
||||
if (!name) return
|
||||
if (!statusOptions.includes(name)) {
|
||||
setStatusOptions(prev => [...prev, name])
|
||||
}
|
||||
set('status', name)
|
||||
setShowNewStatus(false)
|
||||
setNewStatusName('')
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault(); setError('')
|
||||
try { await onSubmit(form) } catch (err) { setError(err instanceof Error ? err.message : '保存失败') }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && <div className="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>}
|
||||
|
||||
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-700 p-5 space-y-4">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">基本信息</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Select label="设备类型" value={String(form.device_type || '')} onChange={e => set('device_type', e.target.value)} options={deviceTypes.map(t => ({ value: t, label: t }))} />
|
||||
<Input label="设备用途" value={String(form.device_purpose || '')} onChange={e => set('device_purpose', e.target.value)} />
|
||||
<Input label="节点名称" value={String(form.node_name || '')} onChange={e => set('node_name', e.target.value)} />
|
||||
<Input label="序列号" value={String(form.serial_number || '')} onChange={e => set('serial_number', e.target.value)} />
|
||||
<Input label="厂商" value={String(form.manufacturer || '')} onChange={e => set('manufacturer', e.target.value)} />
|
||||
<Input label="设备型号" value={String(form.device_model || '')} onChange={e => set('device_model', e.target.value)} />
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">状态</label>
|
||||
<div className="flex gap-1">
|
||||
<div className="flex-1">
|
||||
<Select label="" value={String(form.status || '腾讯使用')} onChange={e => set('status', e.target.value)} options={statusOptions.map(s => ({ value: s, label: s }))} />
|
||||
</div>
|
||||
<button type="button" onClick={() => setShowNewStatus(true)} className="mt-0 px-2 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-500 hover:text-blue-600 dark:hover:text-blue-400 text-sm transition-colors" title="新增状态">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<Modal open={showNewStatus} onClose={() => { setShowNewStatus(false); setNewStatusName('') }} title="新增状态">
|
||||
<Input label="状态名称" value={newStatusName} onChange={e => setNewStatusName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addStatus() } }} autoFocus />
|
||||
<div className="flex justify-end gap-3 mt-4">
|
||||
<button type="button" onClick={() => { setShowNewStatus(false); setNewStatusName('') }} className="px-4 py-2 text-sm text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 transition-colors">取消</button>
|
||||
<button type="button" onClick={addStatus} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">确认</button>
|
||||
</div>
|
||||
</Modal>
|
||||
<Input label="机房" value={String(form.room || '')} onChange={e => set('room', e.target.value)} />
|
||||
<Input label="机架位置" value={String(form.rack_position || '')} onChange={e => set('rack_position', e.target.value)} />
|
||||
<Input label="业务IP" value={String(form.business_ip || '')} onChange={e => set('business_ip', e.target.value)} />
|
||||
<Input label="HDM管理IP" value={String(form.hdm_ip || '')} onChange={e => set('hdm_ip', e.target.value)} />
|
||||
<Input label="保修到期日" type="date" value={String(form.warranty_date || '')} onChange={e => set('warranty_date', e.target.value)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-700 p-5 space-y-4">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">CPU</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Input label="CPU型号" value={String(form.cpu_model || '')} onChange={e => set('cpu_model', e.target.value)} />
|
||||
<Input label="CPU代数" value={String(form.cpu_generation || '')} onChange={e => set('cpu_generation', e.target.value)} />
|
||||
<Input label="CPU核数" type="number" value={String(form.cpu_cores || '')} onChange={e => set('cpu_cores', e.target.value ? Number(e.target.value) : null)} />
|
||||
<Input label="CPU数量" type="number" value={String(form.cpu_count || '')} onChange={e => set('cpu_count', e.target.value ? Number(e.target.value) : null)} />
|
||||
<Input label="CPU线程数" type="number" value={String(form.cpu_threads || '')} onChange={e => set('cpu_threads', e.target.value ? Number(e.target.value) : null)} />
|
||||
<Input label="CPU规格" value={String(form.cpu_spec || '')} onChange={e => set('cpu_spec', e.target.value)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-700 p-5 space-y-4">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">内存</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Input label="内存型号" value={String(form.memory_model || '')} onChange={e => set('memory_model', e.target.value)} />
|
||||
<Input label="内存频率" value={String(form.memory_frequency || '')} onChange={e => set('memory_frequency', e.target.value)} />
|
||||
<Input label="单条容量" value={String(form.memory_unit_capacity || '')} onChange={e => set('memory_unit_capacity', e.target.value)} />
|
||||
<Input label="内存条数" type="number" value={String(form.memory_count || '')} onChange={e => set('memory_count', e.target.value ? Number(e.target.value) : null)} />
|
||||
<Input label="内存总量" value={String(form.memory_total || '')} onChange={e => set('memory_total', e.target.value)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-700 p-5 space-y-4">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">GPU</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Input label="GPU型号" value={String(form.gpu_model || '')} onChange={e => set('gpu_model', e.target.value)} />
|
||||
<Input label="GPU功耗" value={String(form.gpu_power || '')} onChange={e => set('gpu_power', e.target.value)} />
|
||||
<Input label="GPU数量" type="number" value={String(form.gpu_count || '')} onChange={e => set('gpu_count', e.target.value ? Number(e.target.value) : null)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-700 p-5 space-y-4">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">网卡</h3>
|
||||
{[1, 2, 3].map(n => (
|
||||
<div key={n} className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Input label={`网卡${n}型号`} value={String(form[`nic${n}_model`] || '')} onChange={e => set(`nic${n}_model`, e.target.value)} />
|
||||
<Input label={`网卡${n}类型`} value={String(form[`nic${n}_type`] || '')} onChange={e => set(`nic${n}_type`, e.target.value)} />
|
||||
<Input label={`网卡${n}速率`} value={String(form[`nic${n}_speed`] || '')} onChange={e => set(`nic${n}_speed`, e.target.value)} />
|
||||
<Input label={`网卡${n}数量`} type="number" value={String(form[`nic${n}_count`] || '')} onChange={e => set(`nic${n}_count`, e.target.value ? Number(e.target.value) : null)} />
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-700 p-5 space-y-4">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">存储</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Input label="系统盘型号" value={String(form.sys_disk_model || '')} onChange={e => set('sys_disk_model', e.target.value)} />
|
||||
<Input label="系统盘规格" value={String(form.sys_disk_spec || '')} onChange={e => set('sys_disk_spec', e.target.value)} />
|
||||
<Input label="系统盘容量" value={String(form.sys_disk_capacity || '')} onChange={e => set('sys_disk_capacity', e.target.value)} />
|
||||
<Input label="系统盘类型" value={String(form.sys_disk_type || '')} onChange={e => set('sys_disk_type', e.target.value)} />
|
||||
<Input label="系统盘协议" value={String(form.sys_disk_protocol || '')} onChange={e => set('sys_disk_protocol', e.target.value)} />
|
||||
<Input label="系统盘速率" value={String(form.sys_disk_speed || '')} onChange={e => set('sys_disk_speed', e.target.value)} />
|
||||
<Input label="系统盘数量" type="number" value={String(form.sys_disk_count || '')} onChange={e => set('sys_disk_count', e.target.value ? Number(e.target.value) : null)} />
|
||||
</div>
|
||||
{[1, 2].map(n => (
|
||||
<div key={n} className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Input label={`数据盘${n}型号`} value={String(form[`data_disk${n}_model`] || '')} onChange={e => set(`data_disk${n}_model`, e.target.value)} />
|
||||
<Input label={`数据盘${n}规格`} value={String(form[`data_disk${n}_spec`] || '')} onChange={e => set(`data_disk${n}_spec`, e.target.value)} />
|
||||
<Input label={`数据盘${n}容量`} value={String(form[`data_disk${n}_capacity`] || '')} onChange={e => set(`data_disk${n}_capacity`, e.target.value)} />
|
||||
<Input label={`数据盘${n}类型`} value={String(form[`data_disk${n}_type`] || '')} onChange={e => set(`data_disk${n}_type`, e.target.value)} />
|
||||
<Input label={`数据盘${n}协议`} value={String(form[`data_disk${n}_protocol`] || '')} onChange={e => set(`data_disk${n}_protocol`, e.target.value)} />
|
||||
<Input label={`数据盘${n}速率`} value={String(form[`data_disk${n}_speed`] || '')} onChange={e => set(`data_disk${n}_speed`, e.target.value)} />
|
||||
<Input label={`数据盘${n}数量`} type="number" value={String(form[`data_disk${n}_count`] || '')} onChange={e => set(`data_disk${n}_count`, e.target.value ? Number(e.target.value) : null)} />
|
||||
</div>
|
||||
))}
|
||||
<Input label="数据盘总空间" value={String(form.data_disk_total_space || '')} onChange={e => set('data_disk_total_space', e.target.value)} />
|
||||
</section>
|
||||
|
||||
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-700 p-5 space-y-4">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">RAID</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Input label="RAID卡型号" value={String(form.raid_model || '')} onChange={e => set('raid_model', e.target.value)} />
|
||||
<Input label="RAID卡规格" value={String(form.raid_spec || '')} onChange={e => set('raid_spec', e.target.value)} />
|
||||
<Input label="RAID卡数量" type="number" value={String(form.raid_count || '')} onChange={e => set('raid_count', e.target.value ? Number(e.target.value) : null)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-700 p-5 space-y-4">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">电源 / 主板</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Input label="电源1型号" value={String(form.psu1_model || '')} onChange={e => set('psu1_model', e.target.value)} />
|
||||
<Input label="电源1功率" value={String(form.psu1_power || '')} onChange={e => set('psu1_power', e.target.value)} />
|
||||
<Input label="电源1数量" type="number" value={String(form.psu1_count || '')} onChange={e => set('psu1_count', e.target.value ? Number(e.target.value) : null)} />
|
||||
<Input label="电源2型号" value={String(form.psu2_model || '')} onChange={e => set('psu2_model', e.target.value)} />
|
||||
<Input label="电源2功率" value={String(form.psu2_power || '')} onChange={e => set('psu2_power', e.target.value)} />
|
||||
<Input label="电源2数量" type="number" value={String(form.psu2_count || '')} onChange={e => set('psu2_count', e.target.value ? Number(e.target.value) : null)} />
|
||||
<Input label="电源总功率" value={String(form.psu_total_power || '')} onChange={e => set('psu_total_power', e.target.value)} />
|
||||
<Input label="主板型号" value={String(form.board_model || '')} onChange={e => set('board_model', e.target.value)} />
|
||||
<Input label="主板数量" type="number" value={String(form.board_count || '')} onChange={e => set('board_count', e.target.value ? Number(e.target.value) : null)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="submit" loading={loading}>{asset ? '保存修改' : '创建资产'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
'use client'
|
||||
import { useState } from 'react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import FileUpload from '@/components/ui/FileUpload'
|
||||
import { Download, Upload, CheckCircle, AlertCircle } from 'lucide-react'
|
||||
|
||||
interface AssetImportProps {
|
||||
onImport: (file: File) => Promise<{ created: number; updated: number; errors: string[] }>
|
||||
}
|
||||
|
||||
export default function AssetImport({ onImport }: AssetImportProps) {
|
||||
const [step, setStep] = useState<'download' | 'upload' | 'result'>('download')
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState<{ created: number; updated: number; errors: string[] } | null>(null)
|
||||
|
||||
function downloadTemplate() {
|
||||
window.open('/api/assets/template', '_blank')
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!file) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await onImport(file)
|
||||
setResult(res)
|
||||
setStep('result')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (step === 'result' && result) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<CheckCircle size={20} className="text-green-600 dark:text-green-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-800 dark:text-green-300">导入完成</p>
|
||||
<p className="text-sm text-green-600 dark:text-green-400">新增 {result.created} 条,更新 {result.updated} 条</p>
|
||||
</div>
|
||||
</div>
|
||||
{result.errors.length > 0 && (
|
||||
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle size={16} className="text-yellow-600 dark:text-yellow-400" />
|
||||
<span className="text-sm font-medium text-yellow-800 dark:text-yellow-300">部分数据有误</span>
|
||||
</div>
|
||||
<ul className="text-sm text-yellow-700 dark:text-yellow-400 space-y-1">
|
||||
{result.errors.map((e, i) => <li key={i}>{e}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={() => { setStep('download'); setFile(null); setResult(null) }}>继续导入</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{step === 'download' && (
|
||||
<div className="text-center py-8">
|
||||
<Download size={40} className="mx-auto mb-4 text-slate-400" />
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">下载导入模板</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mb-4">下载 Excel 模板,填写资产数据后上传导入</p>
|
||||
<Button onClick={() => { downloadTemplate(); setStep('upload') }}>
|
||||
<Download size={16} />下载模板
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'upload' && (
|
||||
<div className="space-y-4">
|
||||
<FileUpload accept=".xlsx,.xls" onChange={f => setFile(f)} label="选择已填写的 Excel 文件" helperText="支持 .xlsx 和 .xls 格式" />
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="ghost" onClick={() => setStep('download')}>上一步</Button>
|
||||
<Button onClick={handleImport} loading={loading} disabled={!file}>
|
||||
<Upload size={16} />开始导入
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,288 @@
|
|||
'use client'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
import Pagination from '@/components/ui/Pagination'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import { Eye, Edit, Trash2, ChevronUp, ChevronDown, ChevronsUpDown, Filter, X } from 'lucide-react'
|
||||
import type { Asset, PaginatedResult } from '@/types'
|
||||
|
||||
interface AssetListProps {
|
||||
result: PaginatedResult<Asset>
|
||||
selectedIds: Set<number>
|
||||
onToggleSelect: (id: number) => void
|
||||
onToggleAll: () => void
|
||||
onDelete: (id: number) => void
|
||||
onSort: (key: string) => void
|
||||
sortKey: string
|
||||
sortOrder: 'asc' | 'desc'
|
||||
onPageChange: (page: number) => void
|
||||
columnFilters?: Record<string, string[]>
|
||||
onColumnFiltersChange?: (filters: Record<string, string[]>) => void
|
||||
}
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'device_type', label: '设备类型', sortable: true, filterable: true },
|
||||
{ key: 'node_name', label: '节点名称', sortable: true, filterable: true },
|
||||
{ key: 'business_ip', label: '业务IP', sortable: true, filterable: true },
|
||||
{ key: 'manufacturer', label: '厂商', sortable: true, filterable: true },
|
||||
{ key: 'device_model', label: '设备型号', sortable: true, filterable: true },
|
||||
{ key: 'serial_number', label: '序列号', sortable: true, filterable: true },
|
||||
{ key: 'status', label: '状态', sortable: true, filterable: true },
|
||||
]
|
||||
|
||||
const statusColor: Record<string, 'blue' | 'green' | 'yellow' | 'red' | 'gray'> = {
|
||||
'腾讯使用': 'green', '图灵使用': 'blue', '闲置': 'yellow', '备用': 'yellow', '维修中': 'red', '已下线': 'gray',
|
||||
}
|
||||
|
||||
export default function AssetList({
|
||||
result, selectedIds, onToggleSelect, onToggleAll, onDelete,
|
||||
onSort, sortKey, sortOrder, onPageChange,
|
||||
columnFilters = {}, onColumnFiltersChange,
|
||||
}: AssetListProps) {
|
||||
const { data, page, totalPages, total } = result
|
||||
const allSelected = data.length > 0 && data.every(a => selectedIds.has(a.id))
|
||||
|
||||
const [mode, setMode] = useState<'sort' | 'filter'>('sort')
|
||||
const [openCol, setOpenCol] = useState<string | null>(null)
|
||||
const [colValues, setColValues] = useState<Record<string, string[]>>({})
|
||||
const [localFilters, setLocalFilters] = useState<Record<string, string[]>>(columnFilters)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Sync local filters with prop
|
||||
useEffect(() => { setLocalFilters(columnFilters) }, [columnFilters])
|
||||
|
||||
// Click outside to close dropdown
|
||||
useEffect(() => {
|
||||
function handler(e: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setOpenCol(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
async function openDropdown(colKey: string) {
|
||||
setOpenCol(colKey)
|
||||
if (!colValues[colKey]) {
|
||||
try {
|
||||
const res = await fetch(`/api/assets/field-values?field=${colKey}&q=`)
|
||||
if (res.ok) {
|
||||
const d = await res.json()
|
||||
setColValues(prev => ({ ...prev, [colKey]: d.values || [] }))
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function toggleValue(colKey: string, val: string) {
|
||||
const current = localFilters[colKey] || []
|
||||
const next = current.includes(val)
|
||||
? current.filter(v => v !== val)
|
||||
: [...current, val]
|
||||
const updated = { ...localFilters, [colKey]: next }
|
||||
if (next.length === 0) delete updated[colKey]
|
||||
setLocalFilters(updated)
|
||||
onColumnFiltersChange?.(updated)
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
setLocalFilters({})
|
||||
onColumnFiltersChange?.({})
|
||||
}
|
||||
|
||||
function clearCol(colKey: string) {
|
||||
const updated = { ...localFilters }
|
||||
delete updated[colKey]
|
||||
setLocalFilters(updated)
|
||||
onColumnFiltersChange?.(updated)
|
||||
}
|
||||
|
||||
function SortIcon({ colKey }: { colKey: string }) {
|
||||
if (sortKey !== colKey) return <ChevronsUpDown size={12} className="ml-0.5 text-slate-400 opacity-0 group-hover:opacity-100" />
|
||||
return sortOrder === 'asc'
|
||||
? <ChevronUp size={12} className="ml-0.5 text-blue-500" />
|
||||
: <ChevronDown size={12} className="ml-0.5 text-blue-500" />
|
||||
}
|
||||
|
||||
const hasAnyFilter = Object.keys(localFilters).length > 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 工具栏:排序/筛选切换 + 清除筛选 */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1 rounded-lg border border-slate-200 dark:border-slate-700 p-0.5">
|
||||
<button
|
||||
onClick={() => { setMode('sort'); setOpenCol(null) }}
|
||||
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors ${
|
||||
mode === 'sort'
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400'
|
||||
: 'text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
>排序</button>
|
||||
<button
|
||||
onClick={() => setMode('filter')}
|
||||
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors flex items-center gap-1 ${
|
||||
mode === 'filter'
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400'
|
||||
: 'text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
<Filter size={12} />筛选
|
||||
{hasAnyFilter && (
|
||||
<span className="w-4 h-4 rounded-full bg-blue-500 text-white text-[10px] flex items-center justify-center">
|
||||
{Object.keys(localFilters).length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{hasAnyFilter && (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs text-slate-500 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
>
|
||||
<X size={12} />清除筛选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 下拉弹出层(挂载在文档流外,用 ref 控制) */}
|
||||
{openCol && (
|
||||
<div ref={dropdownRef} className="relative z-50">
|
||||
<ColFilterDropdown
|
||||
colKey={openCol}
|
||||
values={colValues[openCol] || []}
|
||||
selected={localFilters[openCol] || []}
|
||||
onToggle={v => toggleValue(openCol, v)}
|
||||
onClear={() => { clearCol(openCol); setOpenCol(null) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-200 dark:border-slate-700">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 dark:bg-slate-800">
|
||||
<tr>
|
||||
<th className="px-4 py-3 w-10">
|
||||
<input type="checkbox" checked={allSelected} onChange={onToggleAll}
|
||||
className="rounded border-slate-300 dark:border-slate-600" />
|
||||
</th>
|
||||
{COLUMNS.map(col => {
|
||||
const filterActive = !!localFilters[col.key]?.length
|
||||
return (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-300 whitespace-nowrap ${
|
||||
col.sortable && mode === 'sort' ? 'cursor-pointer select-none hover:bg-slate-100 dark:hover:bg-slate-700 group' : ''
|
||||
} ${mode === 'filter' && col.filterable ? 'cursor-pointer select-none hover:bg-slate-100 dark:hover:bg-slate-700' : ''}`}
|
||||
onClick={() => {
|
||||
if (mode === 'sort' && col.sortable) {
|
||||
onSort(col.key)
|
||||
} else if (mode === 'filter' && col.filterable) {
|
||||
if (openCol === col.key) { setOpenCol(null) }
|
||||
else { openDropdown(col.key) }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
{col.label}
|
||||
{mode === 'sort' && col.sortable && <SortIcon colKey={col.key} />}
|
||||
{mode === 'filter' && col.filterable && (
|
||||
filterActive
|
||||
? <span className="w-2 h-2 rounded-full bg-blue-500 ml-0.5" title={`已筛选 ${localFilters[col.key].length} 项`} />
|
||||
: <Filter size={12} className="ml-0.5 text-slate-400 opacity-50" />
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-300 w-[120px]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={COLUMNS.length + 2} className="px-4 py-12 text-center text-slate-500 dark:text-slate-400">
|
||||
暂无数据
|
||||
</td>
|
||||
</tr>
|
||||
) : data.map(row => (
|
||||
<tr key={row.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<input type="checkbox" checked={selectedIds.has(row.id)} onChange={() => onToggleSelect(row.id)}
|
||||
className="rounded border-slate-300 dark:border-slate-600" />
|
||||
</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">
|
||||
<Link href={`/assets/${row.id}`} className="text-blue-600 dark:text-blue-400 hover:underline">{row.node_name || '-'}</Link>
|
||||
</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.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.serial_number || '-'}</td>
|
||||
<td className="px-4 py-3"><Badge color={statusColor[String(row.status ?? '')] || 'gray'}>{row.status ?? '-'}</Badge></td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Link href={`/assets/${row.id}`} 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} /></Link>
|
||||
<Link href={`/assets/${row.id}/edit`} className="p-1.5 rounded-lg text-slate-500 hover:text-green-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"><Edit size={16} /></Link>
|
||||
<button onClick={() => onDelete(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>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={onPageChange} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ColFilterDropdownProps {
|
||||
colKey: string
|
||||
values: string[]
|
||||
selected: string[]
|
||||
onToggle: (val: string) => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
function ColFilterDropdown({ values, selected, onToggle, onClear }: ColFilterDropdownProps) {
|
||||
const COL_LABEL: Record<string, string> = {
|
||||
device_type: '设备类型', node_name: '节点名称', business_ip: '业务IP',
|
||||
manufacturer: '厂商', device_model: '设备型号', serial_number: '序列号', status: '状态',
|
||||
room: '机房', rack_position: '机架位置',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute mt-1 bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-700 shadow-lg w-56 max-h-64 overflow-y-auto z-50">
|
||||
<div className="px-3 py-2 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">选择筛选值</span>
|
||||
<button onClick={onClear} className="text-xs text-slate-400 hover:text-red-500">清除</button>
|
||||
</div>
|
||||
{values.length === 0 ? (
|
||||
<div className="px-3 py-4 text-xs text-slate-400 text-center">加载中...</div>
|
||||
) : (
|
||||
values.map(val => (
|
||||
<label
|
||||
key={val}
|
||||
className="flex items-center gap-2 px-3 py-1.5 hover:bg-slate-50 dark:hover:bg-slate-800 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(val)}
|
||||
onChange={() => onToggle(val)}
|
||||
className="rounded border-slate-300 dark:border-slate-600"
|
||||
/>
|
||||
<span className="text-sm text-slate-700 dark:text-slate-300 truncate">{val || '(空)'}</span>
|
||||
{selected.includes(val) && (
|
||||
<span className="ml-auto text-xs text-blue-500">{selected.indexOf(val) + 1}</span>
|
||||
)}
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,341 @@
|
|||
'use client'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import Card from '@/components/ui/Card'
|
||||
import { ExternalLink, AlertCircle, Clock, Activity, Cpu, MemoryStick, HardDrive, Wrench } from 'lucide-react'
|
||||
|
||||
interface Ticket {
|
||||
id: number
|
||||
content: string | null
|
||||
assign_time: string | null
|
||||
close_time: string | null
|
||||
duration_minutes: number | null
|
||||
current_status: string
|
||||
fault_category: string | null
|
||||
fault_subcategory: string | null
|
||||
process_summary: string | null
|
||||
conclusion: string | null
|
||||
parts_replaced: string | null
|
||||
parts_name: string | null
|
||||
}
|
||||
|
||||
interface AssetTicketHistoryProps {
|
||||
deviceIp?: string | null
|
||||
deviceSn?: string | null
|
||||
}
|
||||
|
||||
const statusColorMap: Record<string, 'green' | 'yellow' | 'red' | 'gray' | 'blue'> = {
|
||||
open: 'yellow',
|
||||
pending: 'blue',
|
||||
processing: 'blue',
|
||||
resolved: 'green',
|
||||
closed: 'gray',
|
||||
}
|
||||
|
||||
const statusLabelMap: Record<string, string> = {
|
||||
open: '待处理',
|
||||
pending: '待处理',
|
||||
processing: '处理中',
|
||||
resolved: '已解决',
|
||||
closed: '已关闭',
|
||||
}
|
||||
|
||||
const ISSUE_URL = process.env.NEXT_PUBLIC_ISSUE_URL || 'https://issue.tlyq.ai/tickets'
|
||||
|
||||
function formatDuration(minutes: number | null): string {
|
||||
if (minutes == null) return '-'
|
||||
if (minutes < 60) return `${minutes}分钟`
|
||||
const h = Math.floor(minutes / 60)
|
||||
const m = minutes % 60
|
||||
return m > 0 ? `${h}小时${m}分钟` : `${h}小时`
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string | null): string {
|
||||
if (!dateStr) return '-'
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
// 获取配件图标
|
||||
function getPartIcon(part: string) {
|
||||
const lower = part.toLowerCase()
|
||||
if (lower.includes('gpu') || lower.includes('显卡')) return <Cpu size={14} />
|
||||
if (lower.includes('内存') || lower.includes('ram')) return <MemoryStick size={14} />
|
||||
if (lower.includes('硬盘') || lower.includes('ssd') || lower.includes('磁盘')) return <HardDrive size={14} />
|
||||
return <Wrench size={14} />
|
||||
}
|
||||
|
||||
export default function AssetTicketHistory({ deviceIp, deviceSn }: AssetTicketHistoryProps) {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchTickets = useCallback(async () => {
|
||||
if (!deviceIp && !deviceSn) {
|
||||
setTickets([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (deviceIp) params.set('deviceIp', deviceIp)
|
||||
if (deviceSn) params.set('deviceSn', deviceSn)
|
||||
|
||||
const res = await fetch(`/api/assets/tickets?${params.toString()}`)
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || '获取工单数据失败')
|
||||
}
|
||||
const data = await res.json()
|
||||
setTickets(data.tickets || [])
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [deviceIp, deviceSn])
|
||||
|
||||
useEffect(() => { fetchTickets() }, [fetchTickets])
|
||||
|
||||
// 统计计算
|
||||
const totalCount = tickets.length
|
||||
const durations = tickets
|
||||
.map(t => t.duration_minutes)
|
||||
.filter((d): d is number => d != null)
|
||||
const avgDuration = durations.length > 0
|
||||
? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length)
|
||||
: null
|
||||
|
||||
// 故障类型统计
|
||||
const categoryStats = tickets.reduce((acc, t) => {
|
||||
const cat = t.fault_category || '未知'
|
||||
acc[cat] = (acc[cat] || 0) + 1
|
||||
return acc
|
||||
}, {} as Record<string, number>)
|
||||
|
||||
// 配件更换统计(收集所有 parts_name)
|
||||
const partsList: string[] = []
|
||||
let partsReplacedCount = 0
|
||||
for (const t of tickets) {
|
||||
if (t.parts_replaced && t.parts_replaced !== '否') {
|
||||
partsReplacedCount++
|
||||
// 优先使用 parts_name,其次使用 parts_replaced
|
||||
const partInfo = t.parts_name || t.parts_replaced
|
||||
if (partInfo && partInfo !== '否' && !partsList.includes(partInfo)) {
|
||||
partsList.push(partInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
const hasPartsReplaced = partsReplacedCount > 0
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card title="故障历史">
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<div className="animate-pulse text-sm text-slate-400">加载中...</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card title="故障历史">
|
||||
<div className="flex items-center gap-2 py-6 text-red-600 dark:text-red-400">
|
||||
<AlertCircle size={16} />
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="故障历史">
|
||||
{/* 统计信息 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="p-2 rounded-lg bg-blue-50 dark:bg-blue-500/10">
|
||||
<Activity size={16} className="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">总故障次数</div>
|
||||
<div className="text-lg font-semibold text-slate-900 dark:text-white">{totalCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="p-2 rounded-lg bg-amber-50 dark:bg-amber-500/10">
|
||||
<Clock size={16} className="text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">平均处理时长</div>
|
||||
<div className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{avgDuration != null ? formatDuration(avgDuration) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="p-2 rounded-lg bg-red-50 dark:bg-red-500/10">
|
||||
<Wrench size={16} className="text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">硬件故障</div>
|
||||
<div className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{categoryStats['硬件故障'] || 0}次
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="p-2 rounded-lg bg-green-50 dark:bg-green-500/10">
|
||||
<Cpu size={16} className="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">更换硬件</div>
|
||||
<div className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{partsReplacedCount}次
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 其他故障类型统计(排除硬件故障) */}
|
||||
{Object.entries(categoryStats)
|
||||
.filter(([cat]) => cat !== '硬件故障')
|
||||
.map(([cat, count]) => (
|
||||
<div key={cat} className="flex items-center gap-2.5">
|
||||
<div className="p-2 rounded-lg bg-slate-100 dark:bg-slate-700">
|
||||
<Wrench size={16} className="text-slate-600 dark:text-slate-300" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">{cat}</div>
|
||||
<div className="text-lg font-semibold text-slate-900 dark:text-white">{count}次</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 配件更换汇总 - 显示具体内容 */}
|
||||
{hasPartsReplaced && partsList.length > 0 && (
|
||||
<div className="mb-5 p-3 bg-green-50 dark:bg-green-500/10 rounded-lg">
|
||||
<div className="text-sm font-medium text-green-700 dark:text-green-400 mb-2 flex items-center gap-1.5">
|
||||
<Cpu size={14} /> 更换配件详情
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{partsList.map((part, idx) => (
|
||||
<span key={idx} className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-white dark:bg-slate-800 rounded-lg text-sm font-medium text-green-700 dark:text-green-400">
|
||||
{getPartIcon(part)}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 工单列表 */}
|
||||
{tickets.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-slate-400 dark:text-slate-500">
|
||||
暂无关联工单
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{tickets.map(ticket => (
|
||||
<div
|
||||
key={ticket.id}
|
||||
className="p-4 rounded-lg border border-slate-100 dark:border-slate-800 hover:border-slate-200 dark:hover:border-slate-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3 mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<a
|
||||
href={`${ISSUE_URL}/${ticket.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{ticket.id}
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
<Badge color={statusColorMap[ticket.current_status] || 'gray'}>
|
||||
{statusLabelMap[ticket.current_status] || ticket.current_status}
|
||||
</Badge>
|
||||
{ticket.fault_category && (
|
||||
<Badge variant="default">{ticket.fault_category}</Badge>
|
||||
)}
|
||||
{ticket.fault_subcategory && (
|
||||
<Badge variant="outline">{ticket.fault_subcategory}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300 line-clamp-2">
|
||||
{ticket.content || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 处理概要 */}
|
||||
{ticket.process_summary && (
|
||||
<div className="mt-2 p-2 bg-slate-50 dark:bg-slate-800/50 rounded text-sm">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 mb-1">处理概要</div>
|
||||
<p className="text-slate-700 dark:text-slate-300">{ticket.process_summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 结论 */}
|
||||
{ticket.conclusion && (
|
||||
<div className="mt-2 text-sm text-green-600 dark:text-green-400">
|
||||
结论:{ticket.conclusion}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 配件更换详情 - 显示具体内容 */}
|
||||
{ticket.parts_replaced && ticket.parts_replaced !== '否' && (
|
||||
<div className="mt-2 p-2 bg-amber-50 dark:bg-amber-500/10 rounded text-sm">
|
||||
<div className="flex items-center gap-1.5 text-amber-700 dark:text-amber-400 mb-1">
|
||||
<Cpu size={12} />
|
||||
<span className="font-medium">更换配件:{ticket.parts_replaced}</span>
|
||||
</div>
|
||||
{ticket.parts_name && (
|
||||
<p className="text-amber-600 dark:text-amber-500 text-xs mt-1">
|
||||
{ticket.parts_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 时间信息 */}
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-slate-400 dark:text-slate-500">
|
||||
<span>派单:{formatDateTime(ticket.assign_time)}</span>
|
||||
{ticket.close_time && <span>结单:{formatDateTime(ticket.close_time)}</span>}
|
||||
{ticket.duration_minutes != null && (
|
||||
<span>处理时长:{formatDuration(ticket.duration_minutes)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 跳转到工单系统 */}
|
||||
{(deviceIp || deviceSn) && (
|
||||
<div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
|
||||
<a
|
||||
href={`${ISSUE_URL}?device_ip=${encodeURIComponent(deviceIp || '')}&device_sn=${encodeURIComponent(deviceSn || '')}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
查看全部工单记录
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
'use client'
|
||||
import { useState } from 'react'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Select from '@/components/ui/Select'
|
||||
import Button from '@/components/ui/Button'
|
||||
|
||||
interface BatchEditFormProps {
|
||||
onSubmit: (fields: Record<string, unknown>) => Promise<void>
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const UPDATABLE_FIELDS = [
|
||||
{ key: 'device_type', label: '设备类型', type: 'select', options: ['服务器', '交换机', '路由器', '防火墙', '存储设备', 'GPU服务器', '其他'] },
|
||||
{ key: 'device_purpose', label: '设备用途', type: 'text' },
|
||||
{ key: 'room', label: '机房', type: 'text' },
|
||||
{ key: 'rack_position', label: '机架位置', type: 'text' },
|
||||
{ key: 'status', label: '状态', type: 'select', options: ['腾讯使用', '图灵使用', '闲置', '备用', '维修中', '已下线'] },
|
||||
{ key: 'manufacturer', label: '厂商', type: 'text' },
|
||||
{ key: 'device_model', label: '设备型号', type: 'text' },
|
||||
{ key: 'warranty_date', label: '保修到期日', type: 'date' },
|
||||
]
|
||||
|
||||
export default function BatchEditForm({ onSubmit, loading }: BatchEditFormProps) {
|
||||
const [enabled, setEnabled] = useState<Set<string>>(new Set())
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
|
||||
function toggleField(key: string) {
|
||||
setEnabled(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function setValue(key: string, value: string) {
|
||||
setValues(prev => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const fields: Record<string, unknown> = {}
|
||||
for (const key of enabled) {
|
||||
fields[key] = values[key] ?? ''
|
||||
}
|
||||
await onSubmit(fields)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">勾选需要修改的字段,填入新值后提交。</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{UPDATABLE_FIELDS.map(f => (
|
||||
<div key={f.key} className="flex items-start gap-3">
|
||||
<input type="checkbox" checked={enabled.has(f.key)} onChange={() => toggleField(f.key)} className="mt-2.5 rounded border-slate-300 dark:border-slate-600" />
|
||||
<div className="flex-1">
|
||||
{f.type === 'select' ? (
|
||||
<Select label={f.label} value={values[f.key] || ''} onChange={e => setValue(f.key, e.target.value)} options={f.options!.map(o => ({ value: o, label: o }))} />
|
||||
) : (
|
||||
<Input label={f.label} type={f.type === 'date' ? 'date' : 'text'} value={values[f.key] || ''} onChange={e => setValue(f.key, e.target.value)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" loading={loading} disabled={enabled.size === 0}>应用修改</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
'use client'
|
||||
import { useRef, useState, useEffect } from 'react'
|
||||
import Card from '@/components/ui/Card'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip } from 'recharts'
|
||||
|
||||
export default function RoomChart({ data }: { data: Array<{ room: string; count: number }> }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [size, setSize] = useState({ w: 0, h: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(entries => {
|
||||
const entry = entries[0]
|
||||
if (entry) setSize({ w: entry.contentRect.width, h: entry.contentRect.height })
|
||||
})
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
if (!data || data.length === 0) return null
|
||||
const chartData = data.slice(0, 8)
|
||||
|
||||
return (
|
||||
<Card className="p-5">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-4">机房分布(Top 8)</h3>
|
||||
<div ref={containerRef} className="h-64">
|
||||
{size.w > 0 && size.h > 0 ? (
|
||||
<BarChart data={chartData} width={size.w} height={size.h} margin={{ top: 5, right: 30, left: 10, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="room" tick={{ fontSize: 12, fill: '#94a3b8' }} />
|
||||
<YAxis tick={{ fontSize: 12, fill: '#94a3b8' }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px', color: '#f1f5f9' }}
|
||||
labelStyle={{ color: '#94a3b8' }} formatter={(value: number) => [`${value} 台`, '设备数']} />
|
||||
<Bar dataKey="count" fill="#06b6d4" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
'use client'
|
||||
import Card from '@/components/ui/Card'
|
||||
import { Server, AlertTriangle, ShieldCheck, ShieldAlert, Building2 } from 'lucide-react'
|
||||
|
||||
interface StatsData {
|
||||
total: number
|
||||
byStatus: Array<{ status: string; count: number }>
|
||||
byDeviceType: Array<{ device_type: string; count: number }>
|
||||
byManufacturer: Array<{ manufacturer: string; count: number }>
|
||||
byRoom: Array<{ room: string; count: number }>
|
||||
warrantySoon: number
|
||||
warrantyExpired: number
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon, color }: { title: string; value: number | string; icon: React.ReactNode; color: string }) {
|
||||
const displayValue = typeof value === 'number' && !isNaN(value) ? value : '—'
|
||||
return (
|
||||
<Card>
|
||||
<div style={{ padding: '1.5rem', display: 'flex', alignItems: 'center', justifyContent: 'space-between', minHeight: '100px' }}>
|
||||
<div>
|
||||
<p className="card-label">{title}</p>
|
||||
<p className="card-value">{displayValue}</p>
|
||||
</div>
|
||||
<div
|
||||
className={color}
|
||||
style={{ width: '2.5rem', height: '2.5rem', borderRadius: '0.5rem', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StatsOverview({ stats }: { stats: StatsData | null }) {
|
||||
if (!stats) return null
|
||||
|
||||
const tencent = stats.byStatus.find(s => s.status === '腾讯使用')?.count || 0
|
||||
const turing = stats.byStatus.find(s => s.status === '图灵使用')?.count || 0
|
||||
const idle = stats.byStatus.find(s => s.status === '闲置')?.count || 0
|
||||
const other = stats.byStatus.filter(s => !['腾讯使用', '图灵使用', '闲置'].includes(s.status)).reduce((sum, s) => sum + s.count, 0)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '1rem' }}>
|
||||
<StatCard title="设备总数" value={stats.total} icon={<Server size={20} style={{ color: 'white' }} />} color="bg-blue-600" />
|
||||
<StatCard title="腾讯使用" value={tencent} icon={<ShieldCheck size={20} style={{ color: 'white' }} />} color="bg-emerald-500" />
|
||||
<StatCard title="图灵使用" value={turing} icon={<ShieldCheck size={20} style={{ color: 'white' }} />} color="bg-blue-500" />
|
||||
<StatCard title="闲置" value={idle} icon={<AlertTriangle size={20} style={{ color: 'white' }} />} color="bg-amber-500" />
|
||||
<StatCard title="其他" value={other} icon={<ShieldAlert size={20} style={{ color: 'white' }} />} color="bg-slate-500" />
|
||||
<StatCard title="本月到期保修" value={stats.warrantySoon} icon={<ShieldAlert size={20} style={{ color: 'white' }} />} color="bg-orange-500" />
|
||||
<StatCard title="已过保" value={stats.warrantyExpired} icon={<AlertTriangle size={20} style={{ color: 'white' }} />} color="bg-red-500" />
|
||||
<StatCard title="设备型号数" value={stats.byDeviceType.length} icon={<Server size={20} style={{ color: 'white' }} />} color="bg-purple-500" />
|
||||
<StatCard title="在用机房数" value={stats.byRoom.length} icon={<Building2 size={20} style={{ color: 'white' }} />} color="bg-cyan-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
'use client'
|
||||
import { useRef, useState, useEffect } from 'react'
|
||||
import Card from '@/components/ui/Card'
|
||||
import { PieChart, Pie, Cell, Tooltip, Legend } from 'recharts'
|
||||
|
||||
const COLORS = ['#10b981', '#f59e0b', '#ef4444', '#64748b', '#8b5cf6']
|
||||
|
||||
export default function StatusChart({ data }: { data: Array<{ status: string; count: number }> }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [size, setSize] = useState({ w: 0, h: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(entries => {
|
||||
const entry = entries[0]
|
||||
if (entry) setSize({ w: entry.contentRect.width, h: entry.contentRect.height })
|
||||
})
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
if (!data || data.length === 0) return null
|
||||
const chartData = data.map(d => ({ name: d.status || '未知', value: d.count }))
|
||||
const total = chartData.reduce((sum, d) => sum + d.value, 0)
|
||||
|
||||
return (
|
||||
<Card className="p-5">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-4">设备状态分布</h3>
|
||||
<div ref={containerRef} className="h-64">
|
||||
{size.w > 0 && size.h > 0 ? (
|
||||
<PieChart width={size.w} height={size.h}>
|
||||
<Pie data={chartData} cx="50%" cy="50%" innerRadius={size.h * 0.2} outerRadius={size.h * 0.4} dataKey="value"
|
||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`} labelLine={{ stroke: '#64748b' }}>
|
||||
{chartData.map((_, index) => <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px', color: '#f1f5f9' }}
|
||||
formatter={(value: number) => [`${value} 台 (${((value / total) * 100).toFixed(1)}%)`, '设备数']} />
|
||||
<Legend formatter={(value) => <span style={{ color: '#94a3b8', fontSize: '12px' }}>{value}</span>} />
|
||||
</PieChart>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
'use client'
|
||||
import { useRef, useState, useEffect } from 'react'
|
||||
import Card from '@/components/ui/Card'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip } from 'recharts'
|
||||
|
||||
export default function TypeChart({ data }: { data: Array<{ device_type: string; count: number }> }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [size, setSize] = useState({ w: 0, h: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(entries => {
|
||||
const entry = entries[0]
|
||||
if (entry) setSize({ w: entry.contentRect.width, h: entry.contentRect.height })
|
||||
})
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
if (!data || data.length === 0) return null
|
||||
const chartData = data.slice(0, 8)
|
||||
|
||||
return (
|
||||
<Card className="p-5">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-4">设备类型分布</h3>
|
||||
<div ref={containerRef} className="h-64">
|
||||
{size.w > 0 && size.h > 0 ? (
|
||||
<BarChart data={chartData} layout="vertical" width={size.w} height={size.h} margin={{ top: 5, right: 30, left: 10, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis type="number" tick={{ fontSize: 12, fill: '#94a3b8' }} />
|
||||
<YAxis type="category" dataKey="device_type" tick={{ fontSize: 12, fill: '#94a3b8' }} width={90} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px', color: '#f1f5f9' }}
|
||||
labelStyle={{ color: '#94a3b8' }} formatter={(value: number) => [`${value} 台`, '设备数']} />
|
||||
<Bar dataKey="count" fill="#3b82f6" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
'use client'
|
||||
import { ReactNode } from 'react'
|
||||
import Sidebar from './Sidebar'
|
||||
import TopBar from './TopBar'
|
||||
import { ThemeProvider } from '@/components/providers/ThemeProvider'
|
||||
|
||||
interface AppShellProps { children: ReactNode; user?: { display_name: string; role: string } }
|
||||
|
||||
export default function AppShell({ children, user }: AppShellProps) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Sidebar />
|
||||
<TopBar user={user} />
|
||||
<main className="ml-60 pt-14 min-h-screen bg-slate-50 dark:bg-slate-950">
|
||||
<div className="p-6">{children}</div>
|
||||
</main>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
'use client'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { LayoutDashboard, Server, Search, Settings, Users, Shield, Key } from 'lucide-react'
|
||||
|
||||
const navItems = [
|
||||
{ href: '/dashboard', label: '仪表盘', icon: LayoutDashboard },
|
||||
{ href: '/assets', label: '设备管理', icon: Server },
|
||||
{ href: '/assets/advanced-search', label: '高级查询', icon: Search },
|
||||
]
|
||||
|
||||
const settingsItems = [
|
||||
{ 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()
|
||||
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">
|
||||
<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>
|
||||
<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 + '/')
|
||||
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 className="pt-3 border-t border-slate-200 dark:border-slate-800 mt-3">
|
||||
<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">
|
||||
<Settings size={14} />系统设置
|
||||
</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>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
'use client'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTheme } from '@/components/providers/ThemeProvider'
|
||||
import { Sun, Moon, LogOut, User } from 'lucide-react'
|
||||
|
||||
interface TopBarProps { user?: { display_name: string; role: string } }
|
||||
|
||||
export default function TopBar({ user }: TopBarProps) {
|
||||
const router = useRouter()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
async function handleLogout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
router.push('/login'); router.refresh()
|
||||
}
|
||||
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">
|
||||
<div />
|
||||
<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' ? '切换到亮色模式' : '切换到暗色模式'}>
|
||||
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
{user && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<User size={16} />
|
||||
<span>{user.display_name}</span>
|
||||
</div>
|
||||
<button onClick={handleLogout} className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" title="退出登录">
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
'use client'
|
||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
interface ThemeContextType { theme: Theme; toggleTheme: () => void }
|
||||
const ThemeContext = createContext<ThemeContextType>({ theme: 'dark', toggleTheme: () => {} })
|
||||
|
||||
export function useTheme() { return useContext(ThemeContext) }
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>('dark')
|
||||
|
||||
useEffect(() => {
|
||||
// Read initial theme from localStorage or system preference
|
||||
const stored = localStorage.getItem('theme') as Theme | null
|
||||
const initial = stored || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
setTheme(initial)
|
||||
document.documentElement.classList.toggle('dark', initial === 'dark')
|
||||
document.documentElement.classList.toggle('light', initial === 'light')
|
||||
}, [])
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prev => {
|
||||
const next = prev === 'dark' ? 'light' : 'dark'
|
||||
localStorage.setItem('theme', next)
|
||||
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>
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
'use client'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
interface AutocompleteProps {
|
||||
field: string
|
||||
value: string
|
||||
onChange: (val: string) => void
|
||||
placeholder?: string
|
||||
onEnter?: () => void
|
||||
}
|
||||
|
||||
export default function Autocomplete({ field, value, onChange, placeholder, onEnter }: AutocompleteProps) {
|
||||
const [options, setOptions] = useState<string[]>([])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [highlight, setHighlight] = useState(-1)
|
||||
const timer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const boxRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const fetchOptions = useCallback((q: string) => {
|
||||
setLoading(true)
|
||||
fetch(`/api/assets/field-values?field=${field}&q=${encodeURIComponent(q)}`)
|
||||
.then(r => r.json())
|
||||
.then(d => { if (d.values) setOptions(d.values) })
|
||||
.catch(() => setOptions([]))
|
||||
.finally(() => setLoading(false))
|
||||
}, [field])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
fetchOptions(value)
|
||||
}, [open, field]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function handleInput(q: string) {
|
||||
onChange(q)
|
||||
clearTimeout(timer.current)
|
||||
timer.current = setTimeout(() => {
|
||||
fetchOptions(q)
|
||||
setOpen(true)
|
||||
}, 200)
|
||||
setHighlight(-1)
|
||||
}
|
||||
|
||||
function handleSelect(val: string) {
|
||||
onChange(val)
|
||||
setOpen(false)
|
||||
setOptions([])
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (!open) {
|
||||
if (e.key === 'Enter' && onEnter) { e.preventDefault(); onEnter() }
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setHighlight(h => Math.min(h + 1, options.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setHighlight(h => Math.max(h - 1, 0))
|
||||
} else if (e.key === 'Enter' && highlight >= 0) {
|
||||
e.preventDefault()
|
||||
handleSelect(options[highlight])
|
||||
} else if (e.key === 'Enter' && highlight < 0) {
|
||||
if (onEnter) { e.preventDefault(); onEnter() }
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (boxRef.current && !boxRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={boxRef} className="relative flex-1 min-w-[180px]">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => handleInput(e.target.value)}
|
||||
onFocus={() => { setOpen(true); if (options.length === 0) fetchOptions(value) }}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder || '输入或选择...'}
|
||||
className="w-full px-3 py-1.5 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
||||
/>
|
||||
{open && (
|
||||
<div className="absolute z-50 top-full left-0 right-0 mt-1 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 shadow-lg max-h-48 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="px-3 py-2 text-sm text-slate-400">加载中...</div>
|
||||
) : options.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-slate-400">{value ? '无匹配项' : '暂无数据'}</div>
|
||||
) : (
|
||||
options.map((opt, i) => (
|
||||
<div
|
||||
key={opt}
|
||||
className={`px-3 py-1.5 text-sm cursor-pointer transition-colors ${
|
||||
i === highlight
|
||||
? 'bg-blue-50 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300'
|
||||
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
onMouseDown={() => handleSelect(opt)}
|
||||
onMouseEnter={() => setHighlight(i)}
|
||||
>
|
||||
{opt}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { ReactNode } from 'react'
|
||||
type Variant = 'default' | 'success' | 'warning' | 'danger' | 'info'
|
||||
// 兼容旧 API: blue→info, green→success, gray→default, red→danger, yellow→warning
|
||||
type LegacyColor = 'blue' | 'green' | 'gray' | 'red' | 'yellow'
|
||||
const legacyMap: Record<LegacyColor, Variant> = {
|
||||
blue: 'info', green: 'success', gray: 'default', red: 'danger', yellow: 'warning',
|
||||
}
|
||||
interface BadgeProps { children: ReactNode; variant?: Variant; color?: LegacyColor }
|
||||
const vs: Record<Variant, string> = {
|
||||
default: 'bg-slate-100 dark:bg-slate-800 dark:text-slate-300 text-slate-700',
|
||||
success: 'bg-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-400 text-emerald-700',
|
||||
warning: 'bg-amber-100 dark:bg-amber-500/10 dark:text-amber-400 text-amber-700',
|
||||
danger: 'bg-red-100 dark:bg-red-500/10 dark:text-red-400 text-red-700',
|
||||
info: 'bg-blue-100 dark:bg-blue-500/10 dark:text-blue-400 text-blue-700',
|
||||
}
|
||||
export default function Badge({ children, variant = 'default', color }: BadgeProps) {
|
||||
const v = color ? vs[legacyMap[color]] : vs[variant]
|
||||
return <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${v}`}>{children}</span>
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
'use client'
|
||||
import { ButtonHTMLAttributes, forwardRef } from 'react'
|
||||
type Variant = 'primary' | 'secondary' | 'danger' | 'ghost'
|
||||
type Size = 'sm' | 'md' | 'lg'
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { variant?: Variant; size?: Size; loading?: boolean }
|
||||
const v: Record<Variant, string> = {
|
||||
primary: 'bg-blue-600 hover:bg-blue-700 text-white shadow-sm disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
secondary: 'bg-slate-100 hover:bg-slate-200 text-slate-700 dark:bg-slate-800 dark:hover:bg-slate-700 dark:text-slate-300',
|
||||
danger: 'bg-red-600 hover:bg-red-700 text-white disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
ghost: 'text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800',
|
||||
}
|
||||
const s: Record<Size, string> = { sm: 'px-3 py-1.5 text-xs', md: 'px-4 py-2 text-sm', lg: 'px-6 py-2.5 text-base' }
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(({ variant = 'primary', size = 'md', loading, children, className = '', disabled, ...props }, ref) => (
|
||||
<button ref={ref} disabled={disabled || loading} className={`inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500/50 disabled:opacity-50 disabled:cursor-not-allowed ${v[variant]} ${s[size]} ${className}`} {...props}>
|
||||
{loading && <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>}
|
||||
{children}
|
||||
</button>
|
||||
))
|
||||
Button.displayName = 'Button'
|
||||
export default Button
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { ReactNode } from 'react'
|
||||
interface CardProps { children: ReactNode; title?: string; className?: string }
|
||||
export default function Card({ children, title, className = '' }: CardProps) {
|
||||
return (
|
||||
<div className={`bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm ${className}`}>
|
||||
{title && (
|
||||
<div className="px-6 py-4 border-b border-slate-200 dark:border-slate-800">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">{title}</h3>
|
||||
</div>
|
||||
)}
|
||||
<div className={title ? 'px-6 py-4' : ''}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
'use client'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Upload, File, X } from 'lucide-react'
|
||||
interface FileUploadProps { accept?: string; onChange: (file: File | null) => void; label?: string; helperText?: string }
|
||||
export default function FileUpload({ accept, onChange, label, helperText }: FileUploadProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
function handleFileChange(f: File | null) { setFile(f); onChange(f) }
|
||||
return (
|
||||
<div>
|
||||
{label && <label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">{label}</label>}
|
||||
<div onDragOver={e => { e.preventDefault(); setDragOver(true) }} onDragLeave={() => setDragOver(false)} onDrop={e => { e.preventDefault(); setDragOver(false); handleFileChange(e.dataTransfer.files[0]) }} onClick={() => inputRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors duration-200 ${dragOver ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/10' : 'border-slate-300 dark:border-slate-600 hover:border-blue-400 dark:hover:border-blue-500'}`}>
|
||||
<input ref={inputRef} type="file" accept={accept} className="hidden" onChange={e => handleFileChange(e.target.files?.[0] || null)} />
|
||||
{file ? <div className="flex items-center justify-center gap-2"><File size={18} className="text-blue-500" /><span className="text-sm text-slate-700 dark:text-slate-300">{file.name}</span><button onClick={e => { e.stopPropagation(); handleFileChange(null) }} className="p-1 rounded text-slate-400 hover:text-red-500"><X size={14} /></button></div>
|
||||
: <><Upload size={24} className="mx-auto mb-2 text-slate-400" /><p className="text-sm text-slate-500 dark:text-slate-400">点击或拖拽文件到此处</p></>}
|
||||
</div>
|
||||
{helperText && <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{helperText}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
'use client'
|
||||
import { InputHTMLAttributes, forwardRef } from 'react'
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> { label?: string; error?: string }
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(({ label, error, className = '', id, ...props }, ref) => {
|
||||
const inputId = id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined)
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && <label htmlFor={inputId} className="block text-sm font-medium text-slate-700 dark:text-slate-300">{label}</label>}
|
||||
<input ref={ref} id={inputId} className={`w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm border-slate-300 dark:border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition-colors ${error ? 'border-red-500' : ''} ${className}`} {...props} />
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
Input.displayName = 'Input'
|
||||
export default Input
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
'use client'
|
||||
import { ReactNode, useEffect } from 'react'
|
||||
interface ModalProps { open: boolean; onClose: () => void; title?: string; children: ReactNode; footer?: ReactNode }
|
||||
export default function Modal({ open, onClose, title, children, footer }: ModalProps) {
|
||||
useEffect(() => { document.body.style.overflow = open ? 'hidden' : ''; return () => { document.body.style.overflow = '' } }, [open])
|
||||
if (!open) return null
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative w-full max-w-lg mx-4 max-h-[90vh] overflow-auto bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-xl">
|
||||
{title && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-800">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">{title}</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 text-xl leading-none">×</button>
|
||||
</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>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
'use client'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface PaginationProps {
|
||||
page: number
|
||||
totalPages: number
|
||||
onPageChange: (p: number) => void
|
||||
}
|
||||
|
||||
export default function Pagination({ page, totalPages, onPageChange }: PaginationProps) {
|
||||
const [jumpValue, setJumpValue] = useState('')
|
||||
|
||||
if (totalPages <= 1) return null
|
||||
|
||||
const pages: (number | '...')[] = []
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
if (i === 1 || i === totalPages || (i >= page - 1 && i <= page + 1)) pages.push(i)
|
||||
else if (pages[pages.length - 1] !== '...') pages.push('...')
|
||||
}
|
||||
|
||||
function handleJump() {
|
||||
const targetPage = parseInt(jumpValue)
|
||||
if (isNaN(targetPage) || targetPage < 1 || targetPage > totalPages) {
|
||||
return
|
||||
}
|
||||
onPageChange(targetPage)
|
||||
setJumpValue('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button onClick={() => onPageChange(1)} disabled={page <= 1} title="首页" className="p-1.5 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed"><|</button>
|
||||
<button onClick={() => onPageChange(page - 1)} disabled={page <= 1} title="上一页" className="p-1.5 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed"><</button>
|
||||
{pages.map((p, i) => p === '...'
|
||||
? <span key={`d-${i}`} className="px-1.5 text-slate-400 text-sm">…</span>
|
||||
: <button key={p} onClick={() => onPageChange(p)} className={`min-w-[36px] px-2 py-1.5 rounded-lg text-sm font-medium transition-colors ${p === page ? 'bg-blue-600 text-white' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800'}`}>{p}</button>
|
||||
)}
|
||||
<button onClick={() => onPageChange(page + 1)} disabled={page >= totalPages} title="下一页" className="p-1.5 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed">></button>
|
||||
<button onClick={() => onPageChange(totalPages)} disabled={page >= totalPages} title="末页" className="p-1.5 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed">|></button>
|
||||
|
||||
{/* 跳转输入框 */}
|
||||
<div className="flex items-center gap-1.5 ml-2 border-l border-slate-200 dark:border-slate-700 pl-3">
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">跳转到</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={totalPages}
|
||||
value={jumpValue}
|
||||
onChange={e => setJumpValue(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleJump() }}
|
||||
className="w-14 px-2 py-1.5 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm text-slate-900 dark:text-white text-center focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder={`1-${totalPages}`}
|
||||
/>
|
||||
<button
|
||||
onClick={handleJump}
|
||||
disabled={!jumpValue || parseInt(jumpValue) < 1 || parseInt(jumpValue) > totalPages}
|
||||
className="px-2 py-1.5 rounded-lg text-xs font-medium bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
跳转
|
||||
</button>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">/ 共{totalPages}页</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
'use client'
|
||||
import { SelectHTMLAttributes, forwardRef } from 'react'
|
||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> { label?: string; error?: string; options: { value: string; label: string }[] }
|
||||
const Select = forwardRef<HTMLSelectElement, SelectProps>(({ label, error, options, className = '', id, ...props }, ref) => {
|
||||
const sid = id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined)
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && <label htmlFor={sid} className="block text-sm font-medium text-slate-700 dark:text-slate-300">{label}</label>}
|
||||
<select ref={ref} id={sid} className={`w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm border-slate-300 dark:border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition-colors ${error ? 'border-red-500' : ''} ${className}`} {...props}>
|
||||
{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
Select.displayName = 'Select'
|
||||
export default Select
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
'use client'
|
||||
import { ReactNode } from 'react'
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
|
||||
export interface Column<T> { key: string; title: string; render?: (row: T) => ReactNode; sortable?: boolean; width?: string }
|
||||
interface TableProps<T> { columns: Column<T>[]; data: T[]; rowKey: (row: T) => string | number; sortKey?: string; sortOrder?: 'asc' | 'desc'; onSort?: (key: string) => void }
|
||||
export default function Table<T extends Record<string, unknown>>({ columns, data, rowKey, sortKey, sortOrder, onSort }: TableProps<T>) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 dark:bg-slate-800">
|
||||
<tr>
|
||||
{columns.map(col => (
|
||||
<th key={col.key} style={{ width: col.width }} className={`px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-300 ${col.sortable ? 'cursor-pointer select-none hover:bg-slate-100 dark:hover:bg-slate-700' : ''}`} onClick={() => col.sortable && onSort?.(col.key)}>
|
||||
<span className="inline-flex items-center">{col.title}{col.sortable && (sortKey !== col.key ? <ChevronsUpDown size={14} className="ml-1 text-slate-400" /> : sortOrder === 'asc' ? <ChevronUp size={14} className="ml-1 text-blue-500" /> : <ChevronDown size={14} className="ml-1 text-blue-500" />)}</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{data.map(row => <tr key={rowKey(row)} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">{columns.map(col => <td key={col.key} className="px-4 py-3 text-slate-700 dark:text-slate-300">{col.render ? col.render(row) : String(row[col.key] ?? '')}</td>)}</tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
{data.length === 0 && <div className="py-12 text-center text-slate-500 dark:text-slate-400">暂无数据</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
'use client'
|
||||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
|
||||
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react'
|
||||
type ToastType = 'success' | 'error' | 'info'
|
||||
interface ToastItem { id: number; type: ToastType; message: string }
|
||||
interface ToastContextValue { showToast: (type: ToastType, message: string) => void }
|
||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||
const showToast = useCallback((type: ToastType, message: string) => {
|
||||
const id = Date.now(); setToasts(prev => [...prev, { id, type, message }])
|
||||
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 4000)
|
||||
}, [])
|
||||
const icons: Record<ToastType, ReactNode> = {
|
||||
success: <CheckCircle size={18} className="text-green-500" />,
|
||||
error: <AlertCircle size={18} className="text-red-500" />,
|
||||
info: <Info size={18} className="text-blue-500" />,
|
||||
}
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
<div className="fixed top-4 right-4 z-[60] flex flex-col gap-2">
|
||||
{toasts.map(t => (
|
||||
<div key={t.id} className="flex items-center gap-3 px-4 py-3 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg min-w-[300px] max-w-[500px]">
|
||||
{icons[t.type]}<span className="flex-1 text-sm text-slate-700 dark:text-slate-300">{t.message}</span>
|
||||
<button onClick={() => setToasts(prev => prev.filter(x => x.id !== t.id))} className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"><X size={14} /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext)
|
||||
if (!ctx) throw new Error('useToast must be used within ToastProvider')
|
||||
return ctx
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import crypto from 'crypto'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import db from './db'
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'default-secret-change-me'
|
||||
interface SessionPayload { userId: number; username: string; role: string }
|
||||
|
||||
function base64url(str: string): string { return Buffer.from(str).toString('base64url') }
|
||||
function base64urlDecode(str: string): string { return Buffer.from(str, 'base64url').toString() }
|
||||
|
||||
export function signJwt(payload: SessionPayload, expiresIn: number = 86400): string {
|
||||
const header = { alg: 'HS256', typ: 'JWT' }
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const body = { ...payload, iat: now, exp: now + expiresIn }
|
||||
const segments = [base64url(JSON.stringify(header)), base64url(JSON.stringify(body))]
|
||||
const signingInput = segments.join('.')
|
||||
segments.push(crypto.createHmac('sha256', JWT_SECRET).update(signingInput).digest('base64url'))
|
||||
return segments.join('.')
|
||||
}
|
||||
|
||||
export function verifyJwt(token: string): SessionPayload | null {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
const signingInput = parts.slice(0, 2).join('.')
|
||||
const expectedSig = crypto.createHmac('sha256', JWT_SECRET).update(signingInput).digest('base64url')
|
||||
if (parts[2] !== expectedSig) return null
|
||||
const payload = JSON.parse(base64urlDecode(parts[1]))
|
||||
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null
|
||||
return { userId: payload.userId, username: payload.username, role: payload.role }
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
export function hashPassword(pw: string): string { return bcrypt.hashSync(pw, 10) }
|
||||
export function verifyPassword(pw: string, hash: string): boolean { return bcrypt.compareSync(pw, hash) }
|
||||
export function hashApiKey(key: string): string { return crypto.createHash('sha256').update(key).digest('hex') }
|
||||
export function generateApiKey(): string { return `ak_${crypto.randomBytes(32).toString('hex')}` }
|
||||
export function verifySession(token: string): SessionPayload | null { return verifyJwt(token) }
|
||||
|
||||
export function verifyApiKey(key: string): { id: number; name: string; permissions: string[] } | null {
|
||||
if (!key.startsWith('ak_')) return null
|
||||
const keyHash = hashApiKey(key)
|
||||
const row = db.prepare('SELECT id, name, permissions FROM api_keys WHERE key_hash = ? AND is_active = 1')
|
||||
.get(keyHash) as { id: number; name: string; permissions: string } | undefined
|
||||
if (!row) 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)
|
||||
return { id: row.id, name: row.name, permissions: JSON.parse(row.permissions) }
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import db from './db'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
export function initDatabase() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'viewer',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
permissions TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL,
|
||||
permissions TEXT NOT NULL DEFAULT '["assets:read"]',
|
||||
last_used_at TEXT,
|
||||
expires_at TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
serial_number TEXT UNIQUE,
|
||||
device_type TEXT NOT NULL,
|
||||
device_purpose TEXT,
|
||||
room TEXT,
|
||||
rack_position TEXT,
|
||||
node_name TEXT,
|
||||
business_ip TEXT,
|
||||
hdm_ip TEXT,
|
||||
manufacturer TEXT,
|
||||
device_model TEXT,
|
||||
status TEXT NOT NULL DEFAULT '腾讯使用',
|
||||
warranty_date TEXT,
|
||||
cpu_model TEXT, cpu_generation TEXT, cpu_cores INTEGER,
|
||||
cpu_count INTEGER, cpu_threads INTEGER, cpu_spec TEXT,
|
||||
memory_model TEXT, memory_frequency TEXT, memory_unit_capacity TEXT,
|
||||
memory_count INTEGER, memory_total TEXT,
|
||||
gpu_model TEXT, gpu_power TEXT, gpu_count INTEGER,
|
||||
nic1_model TEXT, nic1_type TEXT, nic1_speed TEXT, nic1_count INTEGER,
|
||||
nic2_model TEXT, nic2_type TEXT, nic2_speed TEXT, nic2_count INTEGER,
|
||||
nic3_model TEXT, nic3_type TEXT, nic3_speed TEXT, nic3_count INTEGER,
|
||||
sys_disk_model TEXT, sys_disk_spec TEXT, sys_disk_capacity TEXT,
|
||||
sys_disk_type TEXT, sys_disk_protocol TEXT, sys_disk_speed TEXT, sys_disk_count INTEGER,
|
||||
data_disk1_model TEXT, data_disk1_spec TEXT, data_disk1_capacity TEXT,
|
||||
data_disk1_type TEXT, data_disk1_protocol TEXT, data_disk1_speed TEXT, data_disk1_count INTEGER,
|
||||
data_disk2_model TEXT, data_disk2_spec TEXT, data_disk2_capacity TEXT,
|
||||
data_disk2_type TEXT, data_disk2_protocol TEXT, data_disk2_speed TEXT, data_disk2_count INTEGER,
|
||||
data_disk_total_space TEXT,
|
||||
raid_model TEXT, raid_spec TEXT, raid_count INTEGER,
|
||||
psu1_model TEXT, psu1_power TEXT, psu1_count INTEGER,
|
||||
psu2_model TEXT, psu2_power TEXT, psu2_count INTEGER,
|
||||
psu_total_power TEXT,
|
||||
board_model TEXT, board_count INTEGER,
|
||||
raw_data TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
api_key_id INTEGER REFERENCES api_keys(id),
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id INTEGER,
|
||||
details TEXT,
|
||||
ip_address TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
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_device_type ON assets(device_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_status ON assets(status);
|
||||
`)
|
||||
|
||||
const existingAdmin = db.prepare('SELECT id FROM users WHERE username = ?').get('admin')
|
||||
if (!existingAdmin) {
|
||||
const passwordHash = bcrypt.hashSync(process.env.ADMIN_PASSWORD || 'admin123', 10)
|
||||
db.prepare('INSERT INTO users (username, password_hash, display_name, role) VALUES (?, ?, ?, ?)')
|
||||
.run('admin', passwordHash, '管理员', 'admin')
|
||||
}
|
||||
const defaultRoles = [
|
||||
{ name: 'admin', display_name: '管理员', permissions: '["*"]' },
|
||||
{ name: 'editor', display_name: '编辑者', permissions: '["assets:read","assets:write","assets:delete"]' },
|
||||
{ name: 'viewer', display_name: '查看者', permissions: '["assets:read"]' },
|
||||
]
|
||||
for (const role of defaultRoles) {
|
||||
const existing = db.prepare('SELECT id FROM roles WHERE name = ?').get(role.name)
|
||||
if (!existing) {
|
||||
db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)')
|
||||
.run(role.name, role.display_name, role.permissions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import Database from 'better-sqlite3'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
const dbPath = process.env.DATABASE_PATH || './data/assets.db'
|
||||
const dbDir = path.dirname(dbPath)
|
||||
if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true })
|
||||
|
||||
const db = new Database(dbPath)
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.pragma('foreign_keys = ON')
|
||||
export default db
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import * as XLSX from 'xlsx'
|
||||
|
||||
const ASSET_FIELDS = [
|
||||
{ key: 'serial_number', label: '序列号' },
|
||||
{ key: 'device_type', label: '设备类型' },
|
||||
{ key: 'device_purpose', label: '设备用途' },
|
||||
{ key: 'room', label: '机房' },
|
||||
{ key: 'rack_position', label: '机架位置' },
|
||||
{ key: 'node_name', label: '节点名称' },
|
||||
{ key: 'business_ip', label: '业务IP' },
|
||||
{ key: 'hdm_ip', label: 'HDM管理IP' },
|
||||
{ key: 'manufacturer', label: '厂商' },
|
||||
{ key: 'device_model', label: '设备型号' },
|
||||
{ key: 'status', label: '状态' },
|
||||
{ key: 'warranty_date', label: '保修到期日' },
|
||||
{ key: 'cpu_model', label: 'CPU型号' },
|
||||
{ key: 'cpu_generation', label: 'CPU代数' },
|
||||
{ key: 'cpu_cores', label: 'CPU核数' },
|
||||
{ key: 'cpu_count', label: 'CPU数量' },
|
||||
{ key: 'cpu_threads', label: 'CPU线程数' },
|
||||
{ key: 'cpu_spec', label: 'CPU规格' },
|
||||
{ key: 'memory_model', label: '内存型号' },
|
||||
{ key: 'memory_frequency', label: '内存频率' },
|
||||
{ key: 'memory_unit_capacity', label: '单条内存容量' },
|
||||
{ key: 'memory_count', label: '内存条数' },
|
||||
{ key: 'memory_total', label: '内存总量' },
|
||||
{ key: 'gpu_model', label: 'GPU型号' },
|
||||
{ key: 'gpu_power', label: 'GPU功耗' },
|
||||
{ key: 'gpu_count', label: 'GPU数量' },
|
||||
{ key: 'nic1_model', label: '网卡1型号' },
|
||||
{ key: 'nic1_type', label: '网卡1类型' },
|
||||
{ key: 'nic1_speed', label: '网卡1速率' },
|
||||
{ key: 'nic1_count', label: '网卡1数量' },
|
||||
{ key: 'nic2_model', label: '网卡2型号' },
|
||||
{ key: 'nic2_type', label: '网卡2类型' },
|
||||
{ key: 'nic2_speed', label: '网卡2速率' },
|
||||
{ key: 'nic2_count', label: '网卡2数量' },
|
||||
{ key: 'nic3_model', label: '网卡3型号' },
|
||||
{ key: 'nic3_type', label: '网卡3类型' },
|
||||
{ key: 'nic3_speed', label: '网卡3速率' },
|
||||
{ key: 'nic3_count', label: '网卡3数量' },
|
||||
{ key: 'sys_disk_model', label: '系统盘型号' },
|
||||
{ key: 'sys_disk_spec', label: '系统盘规格' },
|
||||
{ key: 'sys_disk_capacity', label: '系统盘容量' },
|
||||
{ key: 'sys_disk_type', label: '系统盘类型' },
|
||||
{ key: 'sys_disk_protocol', label: '系统盘协议' },
|
||||
{ key: 'sys_disk_speed', label: '系统盘速率' },
|
||||
{ key: 'sys_disk_count', label: '系统盘数量' },
|
||||
{ key: 'data_disk1_model', label: '数据盘1型号' },
|
||||
{ key: 'data_disk1_spec', label: '数据盘1规格' },
|
||||
{ key: 'data_disk1_capacity', label: '数据盘1容量' },
|
||||
{ key: 'data_disk1_type', label: '数据盘1类型' },
|
||||
{ key: 'data_disk1_protocol', label: '数据盘1协议' },
|
||||
{ key: 'data_disk1_speed', label: '数据盘1速率' },
|
||||
{ key: 'data_disk1_count', label: '数据盘1数量' },
|
||||
{ key: 'data_disk2_model', label: '数据盘2型号' },
|
||||
{ key: 'data_disk2_spec', label: '数据盘2规格' },
|
||||
{ key: 'data_disk2_capacity', label: '数据盘2容量' },
|
||||
{ key: 'data_disk2_type', label: '数据盘2类型' },
|
||||
{ key: 'data_disk2_protocol', label: '数据盘2协议' },
|
||||
{ key: 'data_disk2_speed', label: '数据盘2速率' },
|
||||
{ key: 'data_disk2_count', label: '数据盘2数量' },
|
||||
{ key: 'data_disk_total_space', label: '数据盘总空间' },
|
||||
{ key: 'raid_model', label: 'RAID卡型号' },
|
||||
{ key: 'raid_spec', label: 'RAID卡规格' },
|
||||
{ key: 'raid_count', label: 'RAID卡数量' },
|
||||
{ key: 'psu1_model', label: '电源1型号' },
|
||||
{ key: 'psu1_power', label: '电源1功率' },
|
||||
{ key: 'psu1_count', label: '电源1数量' },
|
||||
{ key: 'psu2_model', label: '电源2型号' },
|
||||
{ key: 'psu2_power', label: '电源2功率' },
|
||||
{ key: 'psu2_count', label: '电源2数量' },
|
||||
{ key: 'psu_total_power', label: '电源总功率' },
|
||||
{ key: 'board_model', label: '主板型号' },
|
||||
{ key: 'board_count', label: '主板数量' },
|
||||
]
|
||||
|
||||
const INTEGER_FIELDS = new Set([
|
||||
'cpu_cores', 'cpu_count', 'cpu_threads', 'memory_count', 'gpu_count',
|
||||
'nic1_count', 'nic2_count', 'nic3_count', 'sys_disk_count',
|
||||
'data_disk1_count', 'data_disk2_count', 'raid_count',
|
||||
'psu1_count', 'psu2_count', 'board_count',
|
||||
])
|
||||
|
||||
export function generateTemplateBuffer(): Buffer {
|
||||
const headers = ASSET_FIELDS.map(f => f.label)
|
||||
const example = ASSET_FIELDS.map(f => {
|
||||
if (f.key === 'device_type') return '服务器'
|
||||
if (f.key === 'status') return '腾讯使用'
|
||||
return ''
|
||||
})
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers, example])
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, '资产导入模板')
|
||||
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as unknown as Buffer
|
||||
}
|
||||
|
||||
interface ImportResult {
|
||||
rows: Record<string, unknown>[]
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export function parseImportBuffer(buffer: Buffer): ImportResult {
|
||||
const wb = XLSX.read(buffer, { type: 'buffer' })
|
||||
const ws = wb.Sheets[wb.SheetNames[0]]
|
||||
const raw = XLSX.utils.sheet_to_json<Record<string, unknown>>(ws, { defval: '' })
|
||||
|
||||
const labelToKey = new Map(ASSET_FIELDS.map(f => [f.label, f.key]))
|
||||
const errors: string[] = []
|
||||
const rows: Record<string, unknown>[] = []
|
||||
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const row = raw[i]
|
||||
const mapped: Record<string, unknown> = {}
|
||||
for (const [label, value] of Object.entries(row)) {
|
||||
const key = labelToKey.get(label.trim())
|
||||
if (!key) continue
|
||||
if (INTEGER_FIELDS.has(key)) {
|
||||
const n = Number(value)
|
||||
mapped[key] = isNaN(n) ? null : Math.round(n)
|
||||
} else {
|
||||
mapped[key] = value === '' ? null : String(value).trim()
|
||||
}
|
||||
}
|
||||
if (!mapped.device_type) {
|
||||
errors.push(`第 ${i + 2} 行:设备类型不能为空`)
|
||||
continue
|
||||
}
|
||||
rows.push(mapped)
|
||||
}
|
||||
return { rows, errors }
|
||||
}
|
||||
|
||||
export function exportAssetsToBuffer(assets: Record<string, unknown>[]): Buffer {
|
||||
const headers = ASSET_FIELDS.map(f => f.label)
|
||||
const data = assets.map(asset =>
|
||||
ASSET_FIELDS.map(f => {
|
||||
const v = asset[f.key]
|
||||
return v == null ? '' : v
|
||||
})
|
||||
)
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers, ...data])
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, '资产列表')
|
||||
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as unknown as Buffer
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* issue-client.ts
|
||||
* 封装对 issue-ai 工单系统的 API 调用
|
||||
*
|
||||
* 环境变量:
|
||||
* ISSUE_API_URL — issue-ai API 地址(默认 http://localhost:5176/api,云上必须设 http://issue-ai:3000/api)
|
||||
* ISSUE_API_KEY — API 密钥(可选,如不设置则需要 Cookie 认证)
|
||||
*/
|
||||
|
||||
const API_BASE = process.env.ISSUE_API_URL || 'http://localhost:5176/api'
|
||||
const API_KEY = process.env.ISSUE_API_KEY || ''
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Ticket {
|
||||
id: number
|
||||
content: string
|
||||
fault_category: string
|
||||
fault_subcategory: string
|
||||
current_status: string
|
||||
assign_time: string
|
||||
close_time: string
|
||||
duration_minutes: number
|
||||
responsibility: string
|
||||
availability: number
|
||||
// 设备关联字段(issue-ai tickets 表实际存储)
|
||||
device_ip?: string | null
|
||||
device_sn?: string | null
|
||||
device_name?: string | null
|
||||
}
|
||||
|
||||
export interface TicketStats {
|
||||
overview: {
|
||||
total: number
|
||||
open: number
|
||||
in_progress: number
|
||||
resolved: number
|
||||
closed: number
|
||||
thisMonth: number
|
||||
avgDuration: number
|
||||
slaRate: number
|
||||
}
|
||||
categories: Array<{ fault_category: string; count: number }>
|
||||
monthlyTrend: Array<{ month: string; tickets: number; avg_duration: number }>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function request<T>(path: string, params?: Record<string, string>, cookie?: string): Promise<T> {
|
||||
const url = new URL(`${API_BASE}${path}`)
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== null && v !== '') {
|
||||
url.searchParams.set(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
// 优先使用 API Key 认证(服务间调用)
|
||||
// 回退到 Cookie 认证(本地开发)
|
||||
if (API_KEY) {
|
||||
headers['Authorization'] = `Bearer ${API_KEY}`
|
||||
} else if (cookie) {
|
||||
headers['Cookie'] = cookie
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
let message = `issue-ai API 请求失败: ${res.status}`
|
||||
try {
|
||||
const body = await res.json()
|
||||
if (body?.error) message = body.error
|
||||
} catch { /* ignore parse error */ }
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 获取某台设备的工单列表
|
||||
*
|
||||
* @param params.ip — 设备业务 IP
|
||||
* @param params.sn — 设备序列号(serial_number)
|
||||
* @param params.cookie — 可选,用于 Cookie 认证(本地开发时使用)
|
||||
*/
|
||||
export async function getTicketsByAsset(params: {
|
||||
ip?: string
|
||||
sn?: string
|
||||
cookie?: string
|
||||
}): Promise<{ tickets: Ticket[]; total: number }> {
|
||||
const { ip, sn, cookie } = params
|
||||
|
||||
if (!ip && !sn) {
|
||||
throw new Error('getTicketsByAsset 必须提供 ip 或 sn 参数')
|
||||
}
|
||||
|
||||
const query: Record<string, string> = {}
|
||||
if (ip) query.ip = ip
|
||||
if (sn) query.sn = sn
|
||||
|
||||
// 调用 issue-ai 专用路由 /api/tickets/by-asset
|
||||
const data = await request<{ tickets: Ticket[]; total: number }>('/tickets/by-asset', query, cookie)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工单统计信息
|
||||
*
|
||||
* 包含:总览(总数/各状态数量/本月新增/平均解决时长/SLA 达成率)、
|
||||
* 故障分类统计、近 13 个月趋势
|
||||
*/
|
||||
export async function getTicketStats(): Promise<TicketStats> {
|
||||
return request<TicketStats>('/stats')
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import db from './db'
|
||||
|
||||
export function checkPermission(role: string, permission: string): boolean {
|
||||
const roleRow = db.prepare('SELECT permissions FROM roles WHERE name = ?').get(role) as { permissions: string } | undefined
|
||||
if (!roleRow) return false
|
||||
const perms: string[] = JSON.parse(roleRow.permissions)
|
||||
if (perms.includes('*')) return true
|
||||
return perms.includes(permission)
|
||||
}
|
||||
|
||||
export function requirePermission(role: string, permission: string): void {
|
||||
if (!checkPermission(role, permission)) {
|
||||
throw new Error(`权限不足:需要 ${permission} 权限`)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
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) {
|
||||
const { pathname } = request.nextUrl
|
||||
if (pathname === '/login' || pathname.startsWith('/api/auth/login')) return NextResponse.next()
|
||||
|
||||
if (pathname.startsWith('/api/')) {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
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).*)'] }
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
export interface User {
|
||||
id: number; username: string; password_hash: string; display_name: string;
|
||||
email: string | null; role: string; is_active: number; created_at: string; updated_at: string
|
||||
}
|
||||
export interface Role {
|
||||
id: number; name: string; display_name: string; permissions: string; created_at: string
|
||||
}
|
||||
export interface Session {
|
||||
id: string; user_id: number; expires_at: string; created_at: string
|
||||
}
|
||||
export interface ApiKey {
|
||||
id: number; name: string; key_hash: string; permissions: string;
|
||||
last_used_at: string | null; expires_at: string | null; is_active: number;
|
||||
created_by: number | null; created_at: string
|
||||
}
|
||||
export interface Asset {
|
||||
id: number; serial_number: string | null; device_type: string; device_purpose: string | null;
|
||||
room: string | null; rack_position: string | null; node_name: string | null;
|
||||
business_ip: string | null; hdm_ip: string | null; manufacturer: string | null;
|
||||
device_model: string | null; status: string; warranty_date: string | null;
|
||||
cpu_model: string | null; cpu_generation: string | null; cpu_cores: number | null;
|
||||
cpu_count: number | null; cpu_threads: number | null; cpu_spec: string | null;
|
||||
memory_model: string | null; memory_frequency: string | null; memory_unit_capacity: string | null;
|
||||
memory_count: number | null; memory_total: string | null;
|
||||
gpu_model: string | null; gpu_power: string | null; gpu_count: number | null;
|
||||
nic1_model: string | null; nic1_type: string | null; nic1_speed: string | null; nic1_count: number | null;
|
||||
nic2_model: string | null; nic2_type: string | null; nic2_speed: string | null; nic2_count: number | null;
|
||||
nic3_model: string | null; nic3_type: string | null; nic3_speed: string | null; nic3_count: number | null;
|
||||
sys_disk_model: string | null; sys_disk_spec: string | null; sys_disk_capacity: string | null;
|
||||
sys_disk_type: string | null; sys_disk_protocol: string | null; sys_disk_speed: string | null;
|
||||
sys_disk_count: number | null;
|
||||
data_disk1_model: string | null; data_disk1_spec: string | null; data_disk1_capacity: string | null;
|
||||
data_disk1_type: string | null; data_disk1_protocol: string | null; data_disk1_speed: string | null;
|
||||
data_disk1_count: number | null;
|
||||
data_disk2_model: string | null; data_disk2_spec: string | null; data_disk2_capacity: string | null;
|
||||
data_disk2_type: string | null; data_disk2_protocol: string | null; data_disk2_speed: string | null;
|
||||
data_disk2_count: number | null; data_disk_total_space: string | null;
|
||||
raid_model: string | null; raid_spec: string | null; raid_count: number | null;
|
||||
psu1_model: string | null; psu1_power: string | null; psu1_count: number | null;
|
||||
psu2_model: string | null; psu2_power: string | null; psu2_count: number | null;
|
||||
psu_total_power: string | null; board_model: string | null; board_count: number | null;
|
||||
raw_data: string | null; created_at: string; updated_at: string
|
||||
}
|
||||
export 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
|
||||
}
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[]; total: number; page: number; pageSize: number; totalPages: number
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: 'class',
|
||||
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
}
|
||||
Binary file not shown.
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true, "skipLibCheck": true, "strict": true, "noEmit": true,
|
||||
"esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler",
|
||||
"resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
Reference in New Issue