chore: 初始化仓库 — 资产管理系统

This commit is contained in:
gitadmin 2026-05-07 10:25:02 +08:00
commit a4fe324efd
86 changed files with 10011 additions and 0 deletions

9
.env.example Normal file
View File

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

15
.gitignore vendored Normal file
View File

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

61
CHANGELOG.md Normal file
View File

@ -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 cookieissue-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 只能转发 cookieissue-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
- [新增] 资产核心 APICRUD + 分页 + 搜索 + 高级筛选)
- [新增] 资产列表页面(`/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 + SQLitestandalone 输出模式)
- [新增] 认证系统JWT 自实现 + API Key 双模式)
- [新增] 用户管理(`/settings/users`和角色权限系统admin/editor/viewer
- [新增] API Key 管理页面(`/settings/api-keys`),支持创建/删除 KeySHA-256 存储)
- [新增] 数据库初始化脚本init-db.ts预置角色和默认管理员账号
- [新增] Excel 导入模板(`服务器信息-issue.xlsx`
- [新增] Docker 部署配置(两阶段 alpine 构建)

226
CLAUDE.md Normal file
View File

@ -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` |
| 服务器 | txjpIP: 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 KeySHA-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 cookie24h 有效) |
| 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 tokenpayload 含 `{ 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
└── webnetexternal ← 共享网络
```
部署:`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"
```

3
Caddyfile Normal file
View File

@ -0,0 +1,3 @@
assets.tlyq.ai {
reverse_proxy assets-ai:3000
}

22
Dockerfile Normal file
View File

@ -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"]

61
README.md Normal file
View File

@ -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 |
| 数据库 | SQLitebetter-sqlite3WAL 模式) |
| 认证 | JWTcookie 方式)+ 自定义 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`)。

34
docker-compose.yml Normal file
View File

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

6
next-env.d.ts vendored Normal file
View File

@ -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.

9
next.config.ts Normal file
View File

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

4143
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View File

@ -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"
}
}

2
postcss.config.mjs Normal file
View File

@ -0,0 +1,2 @@
const config = { plugins: { '@tailwindcss/postcss': {} } }
export default config

303
scripts/import-servers.ts Normal file
View File

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

6
scripts/init-db.ts Normal file
View File

@ -0,0 +1,6 @@
import { initDatabase } from '../src/lib/db-schema'
console.log('初始化数据库...')
initDatabase()
console.log('数据库初始化完成!')
console.log('默认管理员账号: admin / admin123')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

22
src/app/(app)/layout.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"`,
},
})
}

View File

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

View File

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

187
src/app/api/assets/route.ts Normal file
View File

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

View File

@ -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"',
},
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

32
src/app/globals.css Normal file
View File

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

20
src/app/layout.tsx Normal file
View File

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

2
src/app/page.tsx Normal file
View File

@ -0,0 +1,2 @@
import { redirect } from 'next/navigation'
export default function Home() { redirect('/dashboard') }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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">&times;</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>
)
}

View File

@ -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">&lt;|</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">&lt;</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">&gt;</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">|&gt;</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>
)
}

View File

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

View File

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

View File

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

49
src/lib/auth.ts Normal file
View File

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

114
src/lib/db-schema.ts Normal file
View File

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

12
src/lib/db.ts Normal file
View File

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

146
src/lib/excel.ts Normal file
View File

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

131
src/lib/issue-client.ts Normal file
View File

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

15
src/lib/permissions.ts Normal file
View File

@ -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} 权限`)
}
}

47
src/middleware.ts Normal file
View File

@ -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).*)'] }

51
src/types/index.ts Normal file
View File

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

7
tailwind.config.js Normal file
View File

@ -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.

14
tsconfig.json Normal file
View File

@ -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"]
}