chore: 初始化仓库 — 工单跟踪系统

This commit is contained in:
gitadmin 2026-05-07 10:24:58 +08:00
parent a9bad37849
commit 6a6d0f309d
93 changed files with 14365 additions and 1 deletions

12
.env.example Normal file
View File

@ -0,0 +1,12 @@
DATABASE_PATH=./data/issue.db
JWT_SECRET=your-secret-key-change-in-production
ADMIN_PASSWORD=admin123
ASSETS_API_URL=http://assets-ai:3000/api
ASSETS_API_KEY=your-assets-api-key
# 允许调用 issue-ai API 的 Key逗号分隔支持多个由 issue-ai 管理界面生成
ALLOWED_API_KEYS=your-issue-api-key
# NEXT_PUBLIC_ 前缀:构建时内嵌到客户端 JS云上必须通过 deploy-ai.sh 设置
# 本地开发http://localhost:5177
# 云上生产https://assets.tlyq.ai
NEXT_PUBLIC_ASSETS_URL=http://localhost:5177
NODE_ENV=development

18
.gitignore vendored Normal file
View File

@ -0,0 +1,18 @@
node_modules/
.next/
out/
build/
.DS_Store
*.pem
.env
.env.local
.env.development
.env.production
.claude/
data/
uploads/
reports/
docs/
db-backups/
.playwright-mcp/
*.tsbuildinfo

79
CHANGELOG.md Normal file
View File

@ -0,0 +1,79 @@
# 变更日志
## 2026-05-05
- [修复] 云服务器月报生成失败:重建 Docker 镜像安装 echartsDockerfile 补全 Chromium 系统依赖库libglib2.0、libnss3 等 18 个)
- [修复] 周报多个工单详情表格连在一起:在 2.2/3.2/4.2 节每个工单表格前加入「工单 N」编号标签
- [新增] `next.config.ts` 添加 `outputFileTracingIncludes`,防止 `fs.readFileSync` 加载的依赖在 standalone 模式中丢失
- [调整] `CLAUDE.md` 故障排查章节新增月报/周报生成失败的三类根因及修复方案
## 2026-05-03
- [新增] 周报生成功能数据采集weekly-report.ts、DOCX 构建weekly-report-docx.ts、故障概况/详情表格、固定列宽布局
## 2026-05-02
- [新增] 工单列表重构:拆分为"待办工单"和"已办工单"两个页面,现有工单全部归入已办
- [新增] 手动建单页面(`/tickets/create`),支持 IP 回车自动查询设备信息
- [新增] 工单类型字段(`ticket_type`),支持 OEM诊断/OEM维修与故障大类分离含历史数据迁移
- [新增] admin 专属"全部工单"侧边栏入口,非 admin 自动重定向
- [新增] 工单详情和待办列表显示 Tier 1 SLA 超时时间1% 自然月秒数)
- [新增] ProcessForm 重构:默认 1 个时间线步骤、处理人下拉(腾讯/图灵)、多项必填校验、提交确认弹窗
- [调整] 权限模型admin 可编辑/删除已办工单operator 仅操作待办;删除仅限创建人+adminAPI 层 403 守卫
- [优化] 时间输入框改为文本格式YYYY-MM-DD HH:mm:ss支持复制粘贴失焦实时格式校验
- [优化] 智能返回按钮、历史工单新标签页打开、空时间线隐藏、列表移除操作列
- [调整] 报告生成:月报默认选中上个月,周报默认选上周一至上周日
## 2026-04-30
- [新增] 报告管理页面重构:按钮、月报/周报类型、月份选择器、Toast + 轮询、批量删除(多选/全选/确认弹窗)
- [新增] 月报生成逻辑重写:按 close_time 筛选、SLA 判定(可用性<99%且结论不含"无异常"不计入故障日期精确到秒第四章低可用性黄底红字标记
- [优化] 第一章图表Y 轴动态范围自适应、排除"无故障"工单干扰、支持跨月工单正确计入
- [修复] 报告生成时区修复UTC+8、ECharts 图表数据截断
- [调整] 目录 TOC 风格还原、侧边栏导航改名为"报告管理"、表单输入框宽度优化
- [新增] 创建 README 文档,包含完整的月报设计规则
## 2026-04-29
- [新增] 工单列表支持拖拽自定义列宽、排序/筛选图标可点击、排序图标统一为 `ChevronsUpDown`
- [修复] 筛选弹框过窄(改为自适应宽度)、表头 overflow 裁切下拉框、列头 button 嵌套非法 HTML
- [修复] 编辑工单时配件名称保存无效(数据库缺列 + API 白名单遗漏)
- [数据] 从总表 Excel 导入更换配件名称15 条成功6 条不匹配跳过)
## 2026-04-28
- [调整] 工单号改造id 改为 14 位工单号,删除 ticket_no 列,旧格式自动迁移,生产环境 85 条工单 + 524 条时间线验证通过
- [修复] deploy-ai.sh 打包时排除本地环境文件,防止 `.env.local` 覆盖服务器配置导致跳转链接错误
- [修复] 登录后重定向middleware 携带 redirect 参数login page 登录后读取参数跳转,替代硬编码 `/dashboard`
- [修复] 跨系统认证隔离JWT 增加 payload.id 空值检查防跨系统泄露cookie 名改为 `session_issue` 防 localhost 域冲突
- [修复] assets-ai 调用 issue-ai 会话过期:缺少 `ALLOWED_API_KEYS` 配置,本地环境补齐
- [修复] by-asset API 500 错误、导入页缺少工单号列、用户管理页缺少创建时间列
- [文档] 更新 CLAUDE.md修正 API Key 配置说明,补充本地 .env.local 示例
## 2026-04-27
- [新增] API Key 管理页面(`/settings/api-keys`),支持创建/删除 Key供外部系统调用 API
- [新增] 工单 Excel 导入脚本(`import-tickets.ts`),支持批量导入工单数据
- [新增] 工单时间线导入脚本(`import-steps.ts`),关联工单 ID 导入处理步骤
- [优化] docker-compose.yml 使用 external webnet与 nginx-proxy-ai 共用网络
- [调整] Docker 容器挂载 4 个 volumedata、.next、package.json、tsconfig
## 2026-04-25
- [新增] 工单核心 APICRUD + 分页 + 筛选 + 搜索)
- [新增] 工单列表页面(`/tickets`
- [新增] 新建/编辑/详情页面
- [新增] 月度统计 API`/api/stats/monthly`
- [新增] SLA 达标率统计(`/api/stats/sla`
- [新增] 报告生成与导出Word / PDF / Excel 总表)
- [新增] 调用 assets.tlyq.ai API 获取设备信息,工单详情页展示关联设备卡片
- [调整] PLAN v5SLA 指标改为「服务可用性」计算,故障分类规则(含 SQL 批量更新语句)
- [新增] assets-client.ts支持通过 device_ip 模糊匹配 business_ip / hdm_ip 获取设备信息
## 2026-04-24
- [新增] 项目初始化,基于 Next.js 15.1 + SQLitestandalone 输出模式)
- [新增] 认证系统JWT cookie 方式),登录/登出/当前用户 API
- [新增] 用户管理(`/settings/users`和角色权限系统admin/operator/viewer
- [新增] 数据库初始化脚本init-db.ts预置角色和默认管理员账号
- [新增] Docker 部署配置(两阶段构建 alpine + debian slim

260
CLAUDE.md Normal file
View File

@ -0,0 +1,260 @@
# CLAUDE.md — issue.tlyq.ai 工单跟踪系统
## 项目概述
issue-ai 是基于 Next.js + SQLite 的工单跟踪管理系统部署在腾讯云txjp 服务器),域名 `issue.tlyq.ai`。与 assets.tlyq.ai 资产管理系统联动,获取设备信息、提供故障历史查询。
---
## 快速参考
| 属性 | 值 |
|------|-----|
| 站点域名 | `issue.tlyq.ai` |
| 服务器 | txjpIP: 43.133.38.210 |
| 代码路径 | `/root/docker/issue-ai/` |
| 本地端口 | 5176 |
| 容器名 | `issue-ai` |
| 数据库 | SQLite`data/issue.db` |
| 报告存储 | `reports/` 目录(环境变量 `REPORTS_DIR` 可覆盖) |
| 默认账号 | `admin` / `admin123` |
### 常用命令
```bash
cd /Users/niuniu/programs/docker/issue-ai
npm run dev # 本地开发
npm run build # 生产构建
npm run db:init # 初始化数据库
npm run import # 导入工单
```
---
## 关键文件
| 文件 | 职责 |
|------|------|
| `next.config.ts` | `output: 'standalone'``outputFileTracingIncludes` 防止 fs.readFileSync 依赖丢失 |
| `Dockerfile` | 两阶段构建alpine builder + debian slim runner含 Chromium 系统依赖 |
| `docker-compose.yml` | 使用 external webnet挂载 `.next` 从宿主机 |
| `src/lib/db.ts` | SQLite 连接单例WAL 模式,外键开启) |
| `src/lib/db-schema.ts` | 表初始化 + 默认数据admin/operator/viewer |
| `src/lib/auth.ts` | JWT 解析、session 验证 |
| `src/lib/permissions.ts` | 权限检查admin 全能,按角色 JSON 匹配) |
| `src/lib/sla.ts` | SLA 超时计算Tier 1 = 1% 自然月秒数) |
| `src/lib/assets-client.ts` | 调用 assets API 获取设备信息 |
| `src/lib/report-generator.ts` | 报告数据组装 |
| `src/lib/docx-export.ts` | Word 文档生成 |
| `src/lib/excel.ts` | Excel 解析与导出 |
| `src/lib/pdf.ts` | PDF 生成puppeteer |
| `src/types/ticket.ts` | TicketCreateInput / TicketUpdateInput |
| `src/types/report.ts` | ReportType / ReportData |
---
## 数据库 Schema
### 表概览
| 表名 | 说明 |
|------|------|
| `users` | 用户账号username/password_hash/role |
| `roles` | 角色定义name/display_name/permissions JSON |
| `sessions` | 会话JWT id → user_id |
| `tickets` | 工单主体 |
| `ticket_steps` | 工单时间线(关联 ticket_id |
| `reports` | 报告记录 |
| `audit_logs` | 审计日志 |
### tickets 表核心字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `ticket_no` | TEXT UNIQUE | 工单编号 |
| `device_ip` | TEXT | 设备 IP用于调 assets API 补全信息) |
| `ticket_type` | TEXT | 工单类型OEM诊断/OEM维修 |
| `fault_category` | TEXT | 故障大类(硬件故障/网络故障/误判/其他/空) |
| `fault_subcategory` | TEXT | 故障子分类 |
| `current_status` | TEXT | 状态open/in_progress/resolved/closed |
| `availability` | REAL | 服务可用性0-1 |
| `counted_in_sla` | INTEGER | 是否计入 SLA |
| `assign_time` | TEXT | 指派时间 |
| `close_time` | TEXT | 关闭时间 |
| `duration_minutes` | INTEGER | 处理时长(分钟) |
| `conclusion` | TEXT | 结论 |
| `parts_replaced` | TEXT | 更换配件 |
### 预置角色
| 角色 | 权限 |
|------|------|
| `admin` | `["*"]`(全部权限) |
| `operator` | `["tickets:read","tickets:write","reports:read"]` |
| `viewer` | `["tickets:read","reports:read"]` |
---
## API 路由
### 认证
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/auth/login` | 登录username + password → JWT cookie |
| POST | `/api/auth/logout` | 登出 |
| GET | `/api/auth/me` | 当前用户信息 |
### 工单
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/tickets` | 工单列表(分页/筛选/搜索) |
| POST | `/api/tickets` | 创建工单Cookie 认证) |
| POST | `/api/tickets/external` | 外部系统创建工单API Key 认证,幂等) |
| GET | `/api/tickets/[id]` | 工单详情(自动调 assets API 补全设备名) |
| PUT | `/api/tickets/[id]` | 更新工单(含时间线 steps |
| DELETE | `/api/tickets/[id]` | 删除工单 |
| POST | `/api/tickets/import` | Excel 批量导入 |
### 统计 / 报告 / 用户
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/stats` | 工单概览统计 |
| GET | `/api/stats/monthly` | 月度趋势 |
| GET | `/api/stats/sla` | SLA 达标率 |
| GET/POST | `/api/reports` | 报告列表 / 生成 |
| GET | `/api/reports/[id]` | 报告详情/下载 |
| GET/POST | `/api/users` | 用户列表/创建 |
| GET/PUT/DELETE | `/api/users/[id]` | 单个用户操作 |
| GET/POST | `/api/roles` | 角色列表/创建 |
| GET/PUT/DELETE | `/api/roles/[id]` | 单个角色操作 |
---
## 环境配置
### 本地与云端差异
| 环境变量 | 本地开发 | 云服务器txjp | 说明 |
|---------|---------|----------------|------|
| `ASSETS_API_URL` | `http://localhost:5177/api` | `http://assets-ai:3000/api` | 调用 assets API 地址 |
| `ASSETS_API_KEY` | 本地 assets-ai 生成 | 云上 assets-ai 生成 | **每个环境独立,不可跨环境使用** |
| `NEXT_PUBLIC_ASSETS_URL` | `http://localhost:5177` | `https://assets.tlyq.ai` | 前端跳转链接(构建时内嵌) |
| `ALLOWED_API_KEYS` | 本地 issue-ai 生成的 Key | 云上 issue-ai 生成的 Key | 允许外部系统调本系统的 Key逗号分隔 |
| `JWT_SECRET` | `dev-secret-key-local` | `${ISSUE_JWT_SECRET}` | 本地两系统需一致(同 localhost 域) |
| `DATABASE_PATH` | `./data/issue.db` | `/app/data/issue.db` | Docker volume 挂载 |
| Cookie 名 | `session_issue` | `session_issue` | 本地用不同名防 localhost 域冲突 |
### `.env.local` 示例
```bash
DATABASE_PATH=./data/issue.db
JWT_SECRET=dev-secret-key-local
ADMIN_PASSWORD=admin123
NODE_ENV=development
ASSETS_API_URL=http://localhost:5177/api
ASSETS_API_KEY=ak_<32字节十六进制>
ALLOWED_API_KEYS=ak_<32字节十六进制>
NEXT_PUBLIC_ASSETS_URL=http://localhost:5177
```
---
## 与 assets.tlyq.ai 的联动
```
issue-ai ──→ GET {ASSETS_API_URL}/assets?search=IP&pageSize=50 (Authorization: Bearer {ASSETS_API_KEY})
assets-ai ──→ GET {ISSUE_API_URL}/tickets/by-asset?ip=xxx (Authorization: Bearer {ISSUE_API_KEY})
```
- **issue → assets**`src/lib/assets-client.ts`):根据 device_ip 模糊搜索,匹配 business_ip 或 hdm_ip
- **assets → issue**`src/app/api/tickets/by-asset/route.ts`):查询同 IP 历史工单,支持 Cookie 和 API Key 双认证
### API Key 创建与配置
Key 格式:`ak_<32位十六进制>`,认证头:`Authorization: Bearer <key>`。每个环境独立创建,互不通用。
**issue → assets 方向**:在 assets-ai `/settings/api-keys` 创建 Key → 写入 issue-ai 的 `ASSETS_API_KEY`
**assets → issue 方向**:在 issue-ai `/settings/api-keys` 创建 Key → 写入 assets-ai 的 `ISSUE_API_KEY` + issue-ai 的 `ALLOWED_API_KEYS`
---
## Docker 部署
```
txjp 服务器
├── issue-ai容器 ← Next.js standalone监听 3000
├── nginx-ai ← 反向代理 issue.tlyq.ai → issue-ai:3000
└── webnetexternal ← 共享网络
```
部署:`bash deploy-ai.sh` → 选择 4。源码打包上传 → 服务器 `npm install` + `npm run build``.next` 挂载进容器生效。`--force` 强制重建,`--restart` 仅重启。
**关键**:新增 npm 依赖后必须重建 Docker 镜像(`deploy-ai.sh` 只在宿主机 `npm install`,容器内 `/app/node_modules/` 来自镜像构建时):
```bash
ssh txjp "cd /root/docker/issue-ai && docker compose build --no-cache && docker compose down && docker compose up -d"
```
### 生产环境变量
```bash
DATABASE_PATH=/app/data/issue.db
JWT_SECRET=<线上密钥>
ASSETS_API_URL=http://assets-ai:3000/api
ASSETS_API_KEY=<assets-ai Key>
NODE_ENV=production
NEXT_PUBLIC_ASSETS_URL=https://assets.tlyq.ai
```
---
## 仪表盘统计规则
- **整体服务可用性**`AVG(tickets.availability) × 100`(保留 2 位小数)
- **故障分类**:硬件故障 / 网络故障 / 误判 / 其他content 含 agent+上报) / 空值
- **批量更新**`UPDATE tickets SET fault_category='其他' WHERE (content LIKE '%agent%' OR content LIKE '%上报%') AND (fault_category IS NULL OR fault_category = '');`
---
## 开发规范
- **新增 API**:在 `src/app/api/` 下创建路由 → 顶部调用 `initDatabase()``getCurrentUser()` 验证 → `hasPermission()` 校验
- **新增页面**:在 `src/app/(app)/` 下创建 → 布局由 `(app)/layout.tsx` 提供
- **权限格式**`resource:action`,如 `hasPermission(user, 'tickets:write')`
---
## 故障排查
### 容器启动失败
```bash
ssh txjp "docker logs issue-ai"
```
### 月报/周报生成失败
先查日志:`ssh txjp "docker logs issue-ai 2>&1 | grep -A5 'Report.*failed' | tail -30"`
常见根因:
1. **`ENOENT: no such file or directory, open '/app/node_modules/xxx'`** — 新增了 npm 依赖但未重建镜像。修复:重建镜像(命令见上方 Docker 部署)。
2. **`error while loading shared libraries: libglib-2.0.so.0`** — 缺少 Chromium 系统库。Dockerfile 已安装全套依赖libglib2.0-0、libnss3、libnspr4、libatk1.0-0、libatk-bridge2.0-0、libcups2、libdrm2、libdbus-1-3、libxkbcommon0、libxcomposite1、libxdamage1、libxfixes3、libxrandr2、libgbm1、libasound2、libpango-1.0-0、libcairo2
3. **`fs.readFileSync` 依赖在 standalone 中丢失** — `@vercel/nft` 不追踪动态读取。预防:`next.config.ts` 中 `outputFileTracingIncludes` 已添加 echarts。
### 其他
```bash
# 数据库初始化
ssh txjp "docker exec issue-ai node -e \"require('./scripts/init-db.js')\""
# 确认 assets API 连通性
ssh txjp "docker exec issue-ai sh -c 'wget -q -O- http://assets-ai:3000/api/auth/me'"
# 实时日志
ssh txjp "docker logs -f issue-ai"
```

3
Caddyfile Normal file
View File

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

42
Dockerfile Normal file
View File

@ -0,0 +1,42 @@
# 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=issue-npm \
npm ci
COPY . .
RUN npm run build
# runner 阶段:使用 Debianglibc以支持 better-sqlite3 等 glibc 原生模块
FROM node:20-slim AS runner
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-noto-cjk \
# Chromium 依赖库Puppeteer 生成图表截图需要)—— 每次新增依赖需确认镜像内存在
libglib2.0-0 \
libnss3 \
libnspr4 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libdbus-1-3 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libasound2 \
libpango-1.0-0 \
libcairo2 \
&& rm -rf /var/lib/apt/lists/*
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 /app/reports
EXPOSE 3000
CMD ["node", "server.js"]

238
README.md
View File

@ -1,2 +1,238 @@
# issue-ai
# IT 工单跟踪系统
基于 Next.js + SQLite 的 IT 基础设施工单跟踪管理系统,域名为 `issue.tlyq.ai`,用于记录和管理故障工单,支持与 [assets.tlyq.ai](https://assets.tlyq.ai) 资产管理系统联动。
## 技术栈
| 分类 | 技术 |
|------|------|
| 框架 | Next.js 15 + TypeScript |
| UI | React 19 + Tailwind CSS v4 + lucide-react |
| 数据库 | SQLitebetter-sqlite3WAL 模式) |
| 认证 | JWTcookie 方式)+ 自定义 session |
| 文档导出 | docx + ECharts + Puppeteer图表渲染 |
## 功能
- 工单 CRUD创建、编辑、删除、状态流转
- Excel 批量导入工单
- 仪表盘统计整体可用性、故障分类、SLA 达标率)
- 月报/周报生成DOCX 格式,含封面、目录/图表(月报)、数据表格)
- 与 assets-ai 资产管理系统双向联动
- 用户/角色权限管理
- API Key 管理(支持服务间调用认证)
……
## 月报设计规则
### 概述
月报按自然月生成,统计当月已结单工单,输出为 DOCX 文档。包含封面、目录及四个章节。
### 报告结构
| 章节 | 内容 |
|------|------|
| 封面 | 标题、时间范围、公司名称、生成月份 |
| 目录 | Word 目录域(打开后需更新域以刷新) |
| 第一章 — 总体运营概况 | GPU 服务器 / 存储服务器每日在线节点数折线图 |
| 第二章 — 运营数据总览 | 按日期 + 设备类型分组,自然语言描述故障及恢复情况 |
| 第三章 — 运营故障概览 | 故障工单表格GPU 故障 / 存储故障 / 其他工单三类) |
| 第四章 — 服务可用性说明 | 每台设备服务可用性计算公式及百分比 |
### 数据采集规则
**设备清单**:从 assets-ai 拉取 `filter_status=腾讯使用` 的设备,按 `filter_device_type=GPU服务器` / `存储服务器` 分类,构建 business_ip → device_type 映射。
**工单筛选**:按 `close_time` 范围查询monthly report 统计当月结单),条件:
```sql
close_time >= periodStart AND close_time <= periodEnd + ' 23:59:59'
AND current_status IN ('resolved', 'closed')
AND duration_minutes IS NOT NULL
ORDER BY assign_time
```
**工单分类**:按 device_ip 在设备清单中查找对应 device_typegpu / storage / other
### 第一章规则
- 遍历当月每一天,计算当日在线节点数
- **统计范围**:排除 `fault_category = '无故障'` 的工单(跨月工单如 7/31 故障 8/3 恢复正常计入8/1、8/2 各减 1 台)
- 不在线判断:`assign_time日期 ≤ 当前日期 < close_time日期`
- 当日发生故障次日恢复 → 发生日计入不在线,恢复日不计入
- 当日发生故障当日恢复 → 不计入不在线
- 在线 = 总节点数 - 不在线节点数
- 分别统计 GPU 和存储,生成两张 ECharts 折线图
- **Y 轴动态范围**:根据实际数据波动自动调整 min/max/interval避免总节点数较大时微小变化无法分辨
- 无波动时 Y 轴范围 = total ± 2
- 有波动时根据实际最值加 buffer确保 8~15 个刻度
### 第二章规则
- 仅统计 GPU / 存储工单,排除 `fault_category = '无故障'`
- 按 `device_type + assign_time日期` 分组
- 每条格式:`X月X日发生1次<故障子类>,故障节点为<IP><恢复描述>恢复。`
- 恢复描述:`assign_date` 与 `close_date` 天数差0 → 当日1 → 次日≥2 → N日后
### 第三章规则
**分流逻辑**
| 条件 | 归类 |
|------|------|
| `fault_subcategory = '其他'` | 其他工单 |
| `fault_subcategory ≠ '其他'` 且 device_type 为 gpu | GPU 故障 |
| `fault_subcategory ≠ '其他'` 且 device_type 为 storage | 存储故障 |
| `fault_subcategory ≠ '其他'` 且 device_type 为 other | 其他工单 |
**GPU/存储故障表**7 列):
| 列 | 来源 |
|----|------|
| 工单编号 | `ticket_id` |
| 故障节点 | `device_ip` |
| 故障日期 | `assign_time`(完整时间,精确到秒) |
| 故障问题 | `fault_subcategory` |
| 故障原因 | `parts_name` 有值 → `更换{parts_name}`,否则 → `-` |
| 处理时长(分钟) | `duration_minutes` |
| 是否计入 SLA | 见下方 SLA 规则 |
**其他工单表**7 列):
| 列 | 来源 |
|----|------|
| 工单编号 | `ticket_id` |
| 设备 IP 地址 | `device_ip` |
| 工单日期 | `assign_time`(完整时间,精确到秒) |
| 工单内容 | `content` |
| 工单结论 | `conclusion` |
| 处理时长(分钟) | `duration_minutes` |
| 是否计入 SLA | 见下方 SLA 规则 |
### 第四章规则
- 排除 `fault_category = '无故障'` 的工单
- 按 device_ip 分组求和 `duration_minutes`
- 公式:`可用性 = (monthDays × 24 × 60 - totalDurationMinutes) / (monthDays × 24 × 60) × 100`
- monthDays 为当月实际天数(动态计算)
- 百分比 **< 99%** 该值以黄底红字加粗标记
- 百分比 **≥ 99%** 时,正常样式
### SLA 判定规则
`是否计入SLA` 字段判定逻辑:
```
IF availability IS NULL OR availability >= 0.99 → 否
IF availability < 0.99 AND conclusion 包含 "无异常"
IF availability < 0.99 AND conclusion 不包含 "无异常"
```
### 排版规范
| 元素 | 字体 | 字号 | 行距 | 其他 |
|------|------|------|------|------|
| 封面标题 | SimHei黑体 | 22pt / 26pt | 1.5x | 居中 |
| 章标题Heading 1 | SimSun | 14pt | 1.5x | 加粗 |
| 节标题Heading 2 | SimSun | 12pt | 1.5x | 加粗 |
| 正文 | SimSun | 11pt | 1.5x | 首行缩进 2 字符 |
| 表格表头 | SimSun | 10pt | 1.5x | 蓝底白字加粗 |
| 表格内容 | SimSun | 9pt | 1.15x | 垂直居中 |
| 目录 TOC1/TOC2 | SimSun | 11pt | 1.5x | — |
## 周报设计规则
### 概述
周报按自然周(周一至周日)生成,统计当周活跃工单(含处理中和已结单),输出为 DOCX 文档。包含封面及四个章节,不含目录和图表。
### 报告结构
| 章节 | 内容 |
|------|------|
| 封面 | 标题、报告周期、生成时间、公司名称 |
| 一、总体运营概况 | GPU/存储服务器总数 + 本周故障摘要 + 每日在线节点数表格 |
| 二、GPU 服务器故障 | 故障概况表 + 已恢复工单的故障详情表 |
| 三、存储服务器故障 | 故障概况表 + 已恢复工单的故障详情表 |
| 四、其他工单 | 工单概况表 + 已恢复工单的工单详情表 |
### 数据采集规则
**设备清单**:与月报相同,从 assets-ai 拉取 GPU/存储设备,构建 IP → device_type 映射。
**工单筛选**:按周期内活跃度查询,含处理中工单(区别于月报仅统计已结单):
```sql
-- 周期内结单的工单,或周期内仍在处理中的工单
(close_time >= periodStart AND close_time <= periodEnd + ' 23:59:59')
OR
(assign_time <= periodEnd + ' 23:59:59' AND (current_status NOT IN ('resolved','closed') OR close_time > periodEnd + ' 23:59:59'))
```
**工单分类(三路分流)**
| 条件 | 归类 |
|------|------|
| `ticket_type = 'OEM维修'``fault_category ≠ '无故障'` 且 device_type = gpu | GPU 服务器故障 |
| `ticket_type = 'OEM维修'``fault_category ≠ '无故障'` 且 device_type = storage | 存储服务器故障 |
| `ticket_type = 'OEM诊断'` 或(`ticket_type = 'OEM维修'` 且 `fault_category = '无故障'` | 其他工单 |
### 第一章规则(总体运营概况)
**1.1 本周概览**:展示 GPU/存储服务器总数,本周故障次数及恢复/处理中数量。
**1.2 / 1.3 每日运行状态表**:遍历周期内每一天,计算当日在线节点数。
- 不在线判断:`assign_time日期 ≤ 当前日期 < close_time日期`与月报逻辑一致
- 当日有故障节点时:日期/在线数/故障数三列纵向合并,最后一列逐行列出故障 IP
- 当日无故障时:单行显示,故障 IP 列填 `/`
- 在线 = 总节点数 - 不在线节点数
### 第二/三/四章规则(故障详情)
**故障概况表**5 列):
| 列 | 来源 | 列宽 |
|----|------|------|
| 工单号 | `ticketNo` | 15%2.41cm |
| 设备 IP | `deviceIp` | 15%2.41cm |
| 工单内容 | `content``faultSubcategory` | 40%6.29cm |
| 工单时间 | `assignTime` | 15%2.41cm |
| 目前状态 | 已恢复 / 处理中 | 15%2.41cm |
**故障详情表**4 列,仅已恢复工单展示):
- 基本信息区:工单号、设备 IP、工单内容3 列合并)、派单/结单时间
- 工单流程区:第一列纵向合并为「工单流程」标签,后三列为时间节点、发现人/处理人、处理过程/处理结果
- 若 steps 中无「下发工单」步骤,自动补一行
- 若 steps 中无「结单」步骤,自动补一行
- 工单结论区:标签 + 结论文字3 列合并,居中)
### 表格列宽实现
故障概况表采用 **DXA 绝对值 + 固定布局** 方式确保列宽精确:
```typescript
// 15:15:40:15:15 比例换算 DXAA4 内容宽度 9026 DXA
const colDxa = [1354, 1354, 3610, 1354, 1354]
new Table({
width: { size: 9026, type: WidthType.DXA },
columnWidths: colDxa,
layout: TableLayoutType.FIXED, // 关键:禁止 Word 自动调整列宽
rows: [...],
})
```
每个单元格必须同时设置 `width: { size: colDxa[i], type: WidthType.DXA }`,确保 gridCol、表头 tcW、数据 tcW 三者值完全一致。
### 与月报的关键差异
| 维度 | 月报 | 周报 |
|------|------|------|
| 统计范围 | 自然月,仅已结单 | 自然周(周一~周日),含处理中 |
| 目录 | 有TOC 域) | 无 |
| 图表 | ECharts 折线图(第一章) | 无(纯表格) |
| 服务可用性 | 第四章 SLA 计算 | 无 |
| 故障详情展开 | 无(仅概况表) | 有(完整工单流程时间线) |
| 表格布局 | 百分比自适应 | DXA 固定列宽 + Fixed Layout |

33
docker-compose.yml Normal file
View File

@ -0,0 +1,33 @@
services:
issue-ai:
build: .
container_name: issue-ai
ports:
- "5176:3000"
volumes:
- issue-data:/app/data
- issue-uploads:/app/uploads
- issue-reports:/app/reports
# .next 目录从主机挂载npm run build 后直接生效,无需重建镜像
- ./.next:/app/.next
environment:
- DATABASE_PATH=/app/data/issue.db
- JWT_SECRET=${ISSUE_JWT_SECRET:-change-me-in-production}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
- ASSETS_API_URL=${ASSETS_API_URL:-https://assets.tlyq.ai/api}
- ASSETS_API_KEY=${ASSETS_API_KEY}
- ALLOWED_API_KEYS=${ALLOWED_API_KEYS}
- NODE_ENV=production
- TZ=Asia/Shanghai
restart: unless-stopped
networks:
- webnet
volumes:
issue-data:
issue-uploads:
issue-reports:
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.

14
next.config.ts Normal file
View File

@ -0,0 +1,14 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
images: { unoptimized: true },
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
serverExternalPackages: ['better-sqlite3'],
// 确保 fs.readFileSync 加载的文件也被追踪到 standalone 输出中
// 防止 Docker 镜像中 npm install --omit=dev 漏装时缺失依赖
outputFileTracingIncludes: {
'/**': ['./node_modules/echarts/dist/echarts.min.js'],
},
}
export default nextConfig

5483
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "issue-ai",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "NODE_OPTIONS='--max-old-space-size=2048' next dev --port 5176",
"build": "next build",
"start": "next start",
"lint": "next lint",
"db:init": "tsx scripts/init-db.ts",
"import": "tsx scripts/import-tickets.ts"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.8.1",
"cookie": "^1.0.2",
"docx": "^9.1.1",
"echarts": "^5.5.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^1.8.0",
"next": "^15.1.0",
"puppeteer": "^23.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^2.15.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.12",
"@types/jsonwebtoken": "^9.0.7",
"@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

View File

@ -0,0 +1,69 @@
/**
* Excel "更换配件名称"
* 用法: npx tsx scripts/import-parts-name.ts
*/
import Database from 'better-sqlite3';
import * as XLSX from 'xlsx';
import * as path from 'path';
const EXCEL_PATH = path.resolve(__dirname, '../../reference/图灵机房工单跟踪记录-总表.xlsx');
const DB_PATH = path.resolve(__dirname, '../data/issue.db');
function convertTicketId(raw: string): string {
let id = raw.trim();
// 去掉旧格式 "故障编号" 前缀
if (id.startsWith('故障编号')) {
id = id.replace('故障编号', '');
}
// 去掉连字符
id = id.replace(/-/g, '');
// 右补零至 14 位
id = id.padEnd(14, '0');
return id;
}
function main() {
// 读取 Excel
const wb = XLSX.readFile(EXCEL_PATH);
const sheet = wb.Sheets[wb.SheetNames[0]];
const data = XLSX.utils.sheet_to_json<Record<string, string>>(sheet, { defval: '' });
// 打开数据库
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
const updateStmt = db.prepare('UPDATE tickets SET parts_name = ? WHERE id = ?');
let updated = 0;
let notFound = 0;
const notFoundList: string[] = [];
for (const row of data) {
const rawId = row['故障编号/工单号'] || '';
const partsName = (row['更换配件名称'] || '').trim();
if (!rawId || !partsName) continue;
const ticketId = convertTicketId(rawId);
const result = updateStmt.run(partsName, ticketId);
if (result.changes > 0) {
console.log(`${ticketId} → "${partsName}"`);
updated++;
} else {
console.log(`${ticketId} → 未找到工单 (原始: "${rawId}")`);
notFound++;
notFoundList.push(`${rawId}${ticketId}`);
}
}
console.log(`\n完成: 更新 ${updated} 条, 未找到 ${notFound}`);
if (notFoundList.length > 0) {
console.log('未找到的工单号:');
notFoundList.forEach(s => console.log(` - ${s}`));
}
db.close();
}
main();

324
scripts/import-steps.ts Normal file
View File

@ -0,0 +1,324 @@
/**
* - Excel
*
* :
* npx tsx scripts/import-steps.ts <Excel文件路径> [dbPath]
*
* :
* 1. (): Sheet名=, 4-6
* 2. (): Sheet名=YYYYMMDD-XX, 3-13, IP
* 3. 批量Sheet: "等共XX个"Sheet,
*/
import path from 'path'
import fs from 'fs'
import Database from 'better-sqlite3'
const XLSX = require('xlsx')
const excelPath = process.argv[2]?.endsWith('.xlsx') ? process.argv[2] : (process.argv[3]?.endsWith('.xlsx') ? process.argv[3] : null)
const dbPath = process.argv[2] && !process.argv[2].endsWith('.xlsx')
? process.argv[2]
: (process.argv[3] && !process.argv[3].endsWith('.xlsx') ? process.argv[3] : path.join(__dirname, '..', 'data', 'issue.db'))
if (!excelPath) {
console.error('用法: npx tsx scripts/import-steps.ts <Excel文件路径> [dbPath]')
process.exit(1)
}
// ---------------------------------------------------------------------------
// 日期解析
// ---------------------------------------------------------------------------
function parseDate(val: number | string | null | undefined): string | null {
if (val === null || val === undefined) return null
if (typeof val === 'string') {
val = val.trim()
if (!val) return null
// 批量Sheet中的时间是北京时间字符串直接解析为北京时间
// 存储北京时间字符串UI toLocaleString 直接显示正确值
const d = new Date(val)
if (!isNaN(d.getTime())) {
return d.toISOString().replace('T', ' ').slice(0, 19)
}
return null
}
const serial = Number(val)
if (isNaN(serial)) return null
// Excel serial → 北京时间(直接用 XLSX 内置解析,不走 JS Date 时区转换)
// XLSX SSF.parse_date_code 返回 {y,m,d,H,M,S},直接格式化
const dt = XLSX.SSF.parse_date_code(serial)
if (!dt) return null
return `${dt.y}-${String(dt.m).padStart(2, '0')}-${String(dt.d).padStart(2, '0')} ${String(dt.H).padStart(2, '0')}:${String(dt.M).padStart(2, '0')}:${String(dt.S).padStart(2, '0')}`
}
// ---------------------------------------------------------------------------
// 解析批量Sheet (如"20250820042383等共20个")
// 返回每个工单的 { ticket_no, device_ip, steps, conclusion } 数组
// ---------------------------------------------------------------------------
function parseBatchSheet(data: (string | number | null)[][]): Array<{
ticket_no: string
device_ip: string
content: string
assign_time: string | null
close_time: string | null
steps: Array<{ time_node: string | null; handler: string | null; description: string | null }>
conclusion: string
}> {
// 行0: 工单号(20个\n分隔), 设备IP(20个\n分隔)
const ticketNos = (data[0]?.[1] as string || '')
.split('\n')
.map(s => s.trim())
.filter(Boolean)
const ips = (data[0]?.[3] as string || '')
.split('\n')
.map(s => s.trim())
.filter(Boolean)
const count = Math.min(ticketNos.length, ips.length)
const content = String(data[1]?.[1] || '').trim()
const assignTime = parseDate(data[2]?.[1] as number)
const closeTimeRaw = data[2]?.[3] as number
const conclusion = String(data.find(r => r[0] === '工单结论')?.[1] || '').trim()
// 行4-12: 步骤 (多行时间 + 统一描述)
// 行13: 结单时间 (多行时间)
const result: Array<{
ticket_no: string; device_ip: string; content: string
assign_time: string | null; close_time: string | null
steps: Array<{ time_node: string | null; handler: string | null; description: string | null }>
conclusion: string
}> = []
for (let i = 0; i < count; i++) {
const steps: Array<{ time_node: string | null; handler: string | null; description: string | null }> = []
// 遍历行4-12: 每行一个共享描述 + 每台机器不同时间
for (let rowIdx = 4; rowIdx <= 12; rowIdx++) {
const row = data[rowIdx]
if (!row || row.length < 4) continue
const desc = String(row[3] || '').trim()
const handler = String(row[2] || '').trim() || null
if (!desc) continue
// 时间可能是单个序列值,也可能是多行字符串
const timeRaw = row[1]
let timeNode: string | null = null
if (typeof timeRaw === 'number') {
timeNode = parseDate(timeRaw)
} else if (typeof timeRaw === 'string' && timeRaw.includes('\n')) {
// 多行时间取第i个
const times = timeRaw.split('\n').map((t: string) => t.trim()).filter(Boolean)
timeNode = parseDate(times[i] || null)
} else if (typeof timeRaw === 'string') {
timeNode = parseDate(timeRaw)
}
steps.push({ time_node: timeNode, handler, description: desc })
}
// 行13: 结单时间 (多行)
const closeRow = data[13]
if (closeRow && closeRow[1]) {
const closeRaw = closeRow[1]
let closeTime: string | null = null
if (typeof closeRaw === 'string' && closeRaw.includes('\n')) {
const times = closeRaw.split('\n').map((t: string) => t.trim()).filter(Boolean)
closeTime = parseDate(times[i] || null)
} else {
closeTime = parseDate(closeRaw as number)
}
if (closeTime) {
steps.push({ time_node: closeTime, handler: '图灵', description: '结单' })
}
}
result.push({
ticket_no: ticketNos[i],
device_ip: ips[i],
content,
assign_time: assignTime,
close_time: null,
steps,
conclusion
})
}
return result
}
// ---------------------------------------------------------------------------
// 解析标准工单跟踪记录 Sheet
// ticket_no = sheet名IP在行0列3步骤在行4-6
// ---------------------------------------------------------------------------
function parseStandardSheet(data: (string | number | null)[][]): {
ticket_no: string; device_ip: string; steps: Array<{ time_node: string | null; handler: string | null; description: string | null }>
} | null {
// 行4-6: 步骤
const stepRows = data.slice(4, 7)
const steps: Array<{ time_node: string | null; handler: string | null; description: string | null }> = []
for (let i = 0; i < stepRows.length; i++) {
const row = stepRows[i]
if (!row || row.length < 4) continue
const desc = String(row[3] || '').trim()
const handler = String(row[2] || '').trim() || null
if (!desc) continue
steps.push({ time_node: parseDate(row[1] as number), handler, description: desc })
}
if (steps.length === 0) return null
// 行0[3] 是设备IP地址如29.237.253.2
const device_ip = String(data[0]?.[3] || '').trim()
return { ticket_no: '', device_ip, steps }
}
// ---------------------------------------------------------------------------
// 解析故障跟踪记录 Sheet (不同格式)
// ---------------------------------------------------------------------------
function parseFaultSheet(data: (string | number | null)[][]): {
ticket_no: string; device_ip: string; content: string
steps: Array<{ time_node: string | null; handler: string | null; description: string | null }>
conclusion: string
} | null {
const content = String(data[0]?.[3] || '').trim()
// 从内容中提取IP: "gpu-node-8829.237.253.88)存储网卡丢失"
const ipMatch = content.match(/[(](\d+\.\d+\.\d+\.\d+)[)]/)
const device_ip = ipMatch ? ipMatch[1] : ''
// 步骤: 行3到倒数第2行(故障结论)
const conclusion = String(data.find(r => r[0] === '故障结论' || r[0] === '工单结论')?.[1] || '').trim()
const stepRows = data.slice(3).filter(r => r[0] === null && (r[1] !== null && r[1] !== undefined))
const steps: Array<{ time_node: string | null; handler: string | null; description: string | null }> = []
for (const row of stepRows) {
const desc = String(row[3] || '').trim()
const handler = String(row[2] || '').trim() || null
if (!desc) continue
steps.push({ time_node: parseDate(row[1] as number), handler, description: desc })
}
return steps.length > 0 ? { ticket_no: '', device_ip, content, steps, conclusion } : null
}
// ---------------------------------------------------------------------------
// 主逻辑
// ---------------------------------------------------------------------------
function main() {
if (!fs.existsSync(dbPath)) {
console.error(`数据库文件不存在: ${dbPath}`)
process.exit(1)
}
if (!fs.existsSync(excelPath)) {
console.error(`Excel 文件不存在: ${excelPath}`)
process.exit(1)
}
const db = new Database(dbPath)
db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON')
// 确保索引存在
db.exec(`
CREATE INDEX IF NOT EXISTS idx_ticket_steps_ticket_id ON ticket_steps(ticket_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_ticket_steps_unique ON ticket_steps(ticket_id, step_order);
`)
const wb = XLSX.readFile(excelPath)
// 按实际行数据判断格式,不按文件名
const isFaultFormat = false // 行0[0]="故障编号"才为真故障格式目前仅2025年3月
const isBatchFormat = (name: string) => name.includes('等共') || name.includes('共') && /共\d+个/.test(name)
// 先取第一个sheet的首行判断真实格式
const sampleData = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { header: 1, defval: null })
const row0Key = sampleData[0]?.[0]
const realIsFaultFormat = row0Key === '故障编号' || row0Key === '故障内容'
console.log(`真实格式: ${realIsFaultFormat ? '故障跟踪记录' : '工单跟踪记录'} (row0[0]="${row0Key}")`)
console.log(`\n读取: ${path.basename(excelPath)}`)
console.log(`格式: ${isFaultFormat ? '故障跟踪记录' : '工单跟踪记录'}`)
console.log(`Sheet 数: ${wb.SheetNames.length}\n`)
const findTicketByNo = db.prepare('SELECT id FROM tickets WHERE ticket_no = ?')
const findTicketByIP = db.prepare('SELECT id FROM tickets WHERE device_ip = ? LIMIT 1')
const insertStep = db.prepare(`
INSERT OR IGNORE INTO ticket_steps (ticket_id, step_order, time_node, handler, description)
VALUES (?, ?, ?, ?, ?)
`)
let totalSheets = 0
let totalSteps = 0
let skipped = 0
for (const sheetName of wb.SheetNames) {
const ws = wb.Sheets[sheetName]
const data = XLSX.utils.sheet_to_json(ws, { header: 1, defval: null }) as (string | number | null)[][]
if (isBatchFormat(sheetName)) {
// 批量Sheet拆分
const tickets = parseBatchSheet(data)
console.log(`[批量] ${sheetName} -> 拆分为 ${tickets.length} 个工单`)
for (const t of tickets) {
const ticket = findTicketByNo.get(t.ticket_no) as { id: number } | undefined
if (!ticket) {
console.log(` [跳过] ${t.ticket_no} (${t.device_ip}) 不存在于数据库`)
skipped++
continue
}
let n = 0
for (let i = 0; i < t.steps.length; i++) {
const s = t.steps[i]
insertStep.run(ticket.id, i + 1, s.time_node, s.handler, s.description)
n++
}
totalSheets++
totalSteps += n
console.log(`${t.ticket_no}: ${n} 条步骤`)
}
} else if (isFaultFormat) {
// 故障跟踪记录格式 (按IP匹配)
const parsed = parseFaultSheet(data)
if (!parsed) { skipped++; continue }
// 从sheet名提取故障编号作为工单号候选
const faultId = sheetName // 用于查找是否有对应工单
const ticket = findTicketByIP.get(parsed.device_ip) as { id: number } | undefined
if (!ticket) {
console.log(`[跳过] ${sheetName} IP=${parsed.device_ip} 未匹配到数据库工单`)
skipped++
continue
}
let n = 0
for (let i = 0; i < parsed.steps.length; i++) {
const s = parsed.steps[i]
insertStep.run(ticket.id, i + 1, s.time_node, s.handler, s.description)
n++
}
totalSheets++
totalSteps += n
console.log(`${sheetName}(IP:${parsed.device_ip}): ${n} 条步骤`)
} else {
// 标准工单跟踪记录格式 (Sheet名=工单号)
const ticket = findTicketByNo.get(sheetName) as { id: number } | undefined
if (!ticket) {
console.log(`[跳过] ${sheetName} 不存在于数据库`)
skipped++
continue
}
const parsed = parseStandardSheet(data)
if (!parsed) { skipped++; continue }
let n = 0
for (let i = 0; i < parsed.steps.length; i++) {
const s = parsed.steps[i]
insertStep.run(ticket.id, i + 1, s.time_node, s.handler, s.description)
n++
}
totalSheets++
totalSteps += n
console.log(`${sheetName}: ${n} 条步骤`)
}
}
console.log(`\n========================================`)
console.log(`处理工单: ${totalSheets}`)
console.log(`成功导入步骤: ${totalSteps}`)
console.log(`跳过: ${skipped}`)
console.log(`========================================`)
db.close()
}
main()

247
scripts/import-tickets.ts Normal file
View File

@ -0,0 +1,247 @@
/**
*
* 用法: npx tsx scripts/import-tickets.ts [excel文件路径]
* 默认路径: templates-docs/-.xlsx
*/
import Database from 'better-sqlite3'
import path from 'path'
import fs from 'fs'
import bcrypt from 'bcryptjs'
const XLSX = require('xlsx')
const dbPath = process.env.DATABASE_PATH || './data/issue.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 tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_no TEXT NOT NULL UNIQUE,
device_ip TEXT,
device_sn TEXT,
device_name TEXT,
content TEXT,
assign_time TEXT,
close_time TEXT,
duration_minutes INTEGER,
availability REAL,
process_summary TEXT,
conclusion TEXT,
fault_category TEXT,
fault_subcategory TEXT,
parts_replaced TEXT,
current_status TEXT NOT NULL DEFAULT 'open',
counted_in_sla INTEGER NOT NULL DEFAULT 1,
responsibility TEXT,
created_by INTEGER REFERENCES users(id),
updated_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS ticket_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
step_order INTEGER NOT NULL,
time_node TEXT,
handler TEXT,
description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
report_type TEXT NOT NULL,
period_start TEXT,
period_end TEXT,
format TEXT NOT NULL DEFAULT 'pdf',
file_path TEXT,
file_name TEXT,
status TEXT NOT NULL DEFAULT 'pending',
error_message TEXT,
created_by INTEGER REFERENCES users(id),
created_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),
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_tickets_device_ip ON tickets(device_ip);
CREATE INDEX IF NOT EXISTS idx_tickets_assign_time ON tickets(assign_time);
CREATE INDEX IF NOT EXISTS idx_tickets_current_status ON tickets(current_status);
CREATE INDEX IF NOT EXISTS idx_ticket_steps_ticket_id ON ticket_steps(ticket_id);
`)
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: 'operator', display_name: '运维人员', permissions: '["tickets:read","tickets:write","reports:read"]' },
{ name: 'viewer', display_name: '查看者', permissions: '["tickets:read","reports:read"]' },
]
for (const r of defaultRoles) {
const ex = db.prepare('SELECT id FROM roles WHERE name = ?').get(r.name)
if (!ex) db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)')
.run(r.name, r.display_name, r.permissions)
}
}
function excelDateToJS(val: any): string | null {
if (!val) return null
if (val instanceof Date) return val.toISOString().replace('T', ' ').slice(0, 19)
if (typeof val === 'number') {
// Excel serial date: days since 1900-01-01 (with 1900 leap year bug)
const utcDays = val - 25569
const utcSeconds = utcDays * 86400
const d = new Date(utcSeconds * 1000)
return d.toISOString().replace('T', ' ').slice(0, 19)
}
const s = String(val).trim()
return s || null
}
function cleanValue(val: any): any {
if (val === null || val === undefined || val === '' || (typeof val === 'number' && isNaN(val))) return null
if (typeof val === 'string' && val.trim() === '') return null
if (typeof val === 'string' && val.trim() === '/') return null
return val
}
function importTicketSheet(ws: any) {
const data = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' })
if (data.length < 3) {
console.log(' Sheet has insufficient data')
return 0
}
// Headers in row 0, title row in row 0 too, actual data from row 2
const headers = data[0] as string[]
const rows = data.slice(2) // Skip header row + title row
const colIdx: Record<string, number> = {}
for (let i = 0; i < headers.length; i++) {
colIdx[headers[i]] = i
}
const stmt = db.prepare(`
INSERT OR REPLACE INTO tickets (
ticket_no, device_ip, device_sn, device_name, content,
assign_time, close_time, duration_minutes, availability,
process_summary, conclusion, fault_category, fault_subcategory,
parts_replaced, current_status, counted_in_sla
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
let count = 0
const statusMap: Record<string, string> = {
'已恢复': 'resolved',
'处理中': 'in_progress',
'已结单': 'closed',
'待处理': 'open',
'已关闭': 'closed',
}
const transaction = db.transaction(() => {
for (const row of rows) {
const r = row as any[]
if (!r || r.length === 0) continue
const ticketNo = cleanValue(r[colIdx['故障编号/工单号']])
if (!ticketNo) continue
const rawStatus = cleanValue(r[colIdx['目前状态']]) || 'resolved'
const mappedStatus = statusMap[String(rawStatus)] || 'resolved'
const slaVal = cleanValue(r[colIdx['是否计入SLA']])
const countedInSla = slaVal === '是' ? 1 : 0
stmt.run(
ticketNo,
cleanValue(r[colIdx['故障节点']]), // device_ip
cleanValue(r[colIdx['SN']]), // device_sn
null, // device_name (not in Excel)
cleanValue(r[colIdx['故障表现/工单内容']]), // content
excelDateToJS(r[colIdx['派单时间']]), // assign_time
excelDateToJS(r[colIdx['结单时间']]), // close_time
cleanValue(r[colIdx['处理时长\n分钟']]), // duration_minutes
cleanValue(r[colIdx['单次月度可用性']]), // availability
cleanValue(r[colIdx['处理过程']]), // process_summary
cleanValue(r[colIdx['故障结论']]), // conclusion
cleanValue(r[colIdx['故障分类']]), // fault_category
cleanValue(r[colIdx['故障分类二']]), // fault_subcategory
cleanValue(r[colIdx['是否更换配件']]), // parts_replaced
mappedStatus,
countedInSla,
)
count++
}
})
transaction()
return count
}
async function main() {
const excelPath = process.argv[2] || path.join(__dirname, '..', 'templates-docs', '工单跟踪记录-总表.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 tickets from 故障记录汇总...')
const sheet = wb.Sheets['故障记录汇总']
const count = importTicketSheet(sheet)
console.log(` Imported ${count} tickets`)
console.log(`\nTotal: ${count} tickets imported`)
db.close()
}
main().catch(console.error)

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

@ -0,0 +1,3 @@
import { initDatabase } from '../src/lib/db-schema'
initDatabase()
console.log('数据库初始化完成')

View File

@ -0,0 +1,293 @@
/**
* ID
* tickets.id 14 ticket_no
*
* npx tsx scripts/migrate-ticket-id.ts
*/
import Database from 'better-sqlite3'
import path from 'path'
const DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, '..', 'data', 'issue.db')
console.log(`数据库路径: ${DB_PATH}`)
const db = new Database(DB_PATH)
// 开启 WAL 模式并强制 checkpoint
db.pragma('journal_mode = WAL')
db.pragma('wal_checkpoint(TRUNCATE)')
console.log('WAL checkpoint 完成')
// ============================================================
// 1. 预检查
// ============================================================
const ticketCount = (db.prepare('SELECT COUNT(*) as c FROM tickets').get() as any).c
const stepCount = (db.prepare('SELECT COUNT(*) as c FROM ticket_steps').get() as any).c
console.log(`当前 tickets: ${ticketCount}, ticket_steps: ${stepCount}`)
if (ticketCount === 0) {
console.log('tickets 表为空,无需迁移,直接退出')
process.exit(0)
}
// 检查是否有 ticket_no 列
const tableInfo = db.prepare('PRAGMA table_info(tickets)').all() as any[]
const hasTicketNo = tableInfo.some((col: any) => col.name === 'ticket_no')
if (!hasTicketNo) {
console.log('tickets 表已无 ticket_no 列,可能已迁移过,退出')
process.exit(0)
}
// 检查 id 列是否已是 INTEGER PRIMARY KEY 无自增
const idCol = tableInfo.find((col: any) => col.name === 'id')
if (idCol && !idCol.pk) {
console.error('id 列不是主键,异常状态,退出')
process.exit(1)
}
// 检查孤立 steps
const orphanSteps = (db.prepare(
'SELECT COUNT(*) as c FROM ticket_steps WHERE ticket_id NOT IN (SELECT id FROM tickets)'
).get() as any).c
if (orphanSteps > 0) {
console.error(`存在 ${orphanSteps} 条孤立 ticket_steps 记录,请先清理`)
process.exit(1)
}
// ============================================================
// 2. 构建 id 映射
// ============================================================
console.log('构建旧 id → 新 id 映射...')
const tickets = db.prepare('SELECT id, ticket_no FROM tickets').all() as any[]
// 验证 ticket_no 有有效数字
for (const t of tickets) {
if (!t.ticket_no || t.ticket_no.replace(/\D/g, '') === '') {
console.error(`工单 id=${t.id} 的 ticket_no 无有效数字: "${t.ticket_no}"`)
process.exit(1)
}
}
// 转换函数清除所有非数字字符取后14位不足则右补0
function toNewId(ticketNo: string): number {
const digits = ticketNo.replace(/\D/g, '')
if (digits.length >= 14) {
return parseInt(digits.slice(-14)) // 取最后14位防止超长
}
return parseInt(digits.padEnd(14, '0'))
}
// 构建映射并检查重复
const idMap = new Map<number, number>()
const reverseMap = new Map<number, number>() // new_id → old_id用于检测重复
for (const t of tickets) {
const newId = toNewId(t.ticket_no)
if (reverseMap.has(newId)) {
const conflictOldId = reverseMap.get(newId)!
console.error(`工单号冲突: ${newId}`)
console.error(` 旧工单 id=${t.id}, ticket_no=${t.ticket_no}`)
console.error(` 旧工单 id=${conflictOldId}, ticket_no=${tickets.find(x => x.id === conflictOldId)?.ticket_no}`)
process.exit(1)
}
reverseMap.set(newId, t.id)
idMap.set(t.id, newId)
}
// 打印转换示例
let sampleCount = 0
for (const t of tickets) {
const newId = idMap.get(t.id)!
if (String(newId) !== t.ticket_no) {
console.log(` 转换: ${t.ticket_no}${newId}`)
sampleCount++
if (sampleCount >= 5) break
}
}
console.log(`映射构建完成,共 ${idMap.size}`)
// ============================================================
// 3. 执行迁移
// ============================================================
console.log('开始迁移(事务模式)...')
try {
db.pragma('foreign_keys = OFF')
const migrate = db.transaction(() => {
// a. 备份旧表
console.log(' 备份旧表...')
db.exec('ALTER TABLE tickets RENAME TO tickets_backup')
db.exec('ALTER TABLE ticket_steps RENAME TO ticket_steps_backup')
// b. 创建新 tickets 表(去掉 ticket_no 列id 无 AUTOINCREMENT
console.log(' 创建新 tickets 表...')
db.exec(`
CREATE TABLE tickets (
id INTEGER PRIMARY KEY,
device_ip TEXT,
device_sn TEXT,
device_name TEXT,
content TEXT,
assign_time TEXT,
close_time TEXT,
duration_minutes INTEGER,
availability REAL,
process_summary TEXT,
conclusion TEXT,
fault_category TEXT,
fault_subcategory TEXT,
parts_replaced TEXT,
current_status TEXT NOT NULL DEFAULT 'open',
counted_in_sla INTEGER NOT NULL DEFAULT 1,
responsibility TEXT,
created_by INTEGER REFERENCES users(id),
updated_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`)
// c. 创建新 ticket_steps 表
console.log(' 创建新 ticket_steps 表...')
db.exec(`
CREATE TABLE ticket_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
step_order INTEGER NOT NULL,
time_node TEXT,
handler TEXT,
description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`)
// d. 迁移 tickets 数据
console.log(' 迁移 tickets 数据...')
const cols = [
'device_ip', 'device_sn', 'device_name', 'content', 'assign_time',
'close_time', 'duration_minutes', 'availability', 'process_summary',
'conclusion', 'fault_category', 'fault_subcategory', 'parts_replaced',
'current_status', 'counted_in_sla', 'responsibility',
'created_by', 'updated_by', 'created_at', 'updated_at'
]
for (const t of tickets) {
const newId = idMap.get(t.id)!
const old = db.prepare(
`SELECT ${cols.join(', ')} FROM tickets_backup WHERE id = ?`
).get(t.id) as any
const values = cols.map(c => old[c])
const placeholders = cols.map(() => '?').join(', ')
db.prepare(
`INSERT INTO tickets (id, ${cols.join(', ')}) VALUES (?, ${placeholders})`
).run(newId, ...values)
}
// e. 迁移 ticket_steps 数据
console.log(' 迁移 ticket_steps 数据...')
const stepsBackup = db.prepare('SELECT * FROM ticket_steps_backup').all() as any[]
for (const s of stepsBackup) {
const newTicketId = idMap.get(s.ticket_id)
if (!newTicketId) {
console.error(` ticket_steps id=${s.id}: ticket_id=${s.ticket_id} 在映射中找不到,跳过`)
continue
}
db.prepare(`
INSERT INTO ticket_steps (id, ticket_id, step_order, time_node, handler, description, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(s.id, newTicketId, s.step_order, s.time_node, s.handler, s.description, s.created_at)
}
// f. 重建索引(使用新名称避免与备份表索引冲突)
console.log(' 重建索引...')
db.exec('CREATE INDEX IF NOT EXISTS idx_ts_ticket_id ON ticket_steps(ticket_id)')
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_ts_unique ON ticket_steps(ticket_id, step_order)')
})
migrate()
// ============================================================
// 4. 验证
// ============================================================
console.log('验证数据完整性...')
const newTicketCount = (db.prepare('SELECT COUNT(*) as c FROM tickets').get() as any).c
const backupTicketCount = (db.prepare('SELECT COUNT(*) as c FROM tickets_backup').get() as any).c
const newStepCount = (db.prepare('SELECT COUNT(*) as c FROM ticket_steps').get() as any).c
const backupStepCount = (db.prepare('SELECT COUNT(*) as c FROM ticket_steps_backup').get() as any).c
console.log(` tickets: ${newTicketCount} (备份: ${backupTicketCount})`)
console.log(` ticket_steps: ${newStepCount} (备份: ${backupStepCount})`)
if (newTicketCount !== backupTicketCount) {
throw new Error(`tickets 计数不一致: ${newTicketCount} vs ${backupTicketCount}`)
}
if (newStepCount !== backupStepCount) {
throw new Error(`ticket_steps 计数不一致: ${newStepCount} vs ${backupStepCount}`)
}
// 外键完整性
const orphan = (db.prepare(
'SELECT COUNT(*) as c FROM ticket_steps WHERE ticket_id NOT IN (SELECT id FROM tickets)'
).get() as any).c
if (orphan > 0) {
throw new Error(`外键断裂: ${orphan}`)
}
console.log(' 外键完整性: OK')
// id 唯一性
const dupCheck = db.prepare(
'SELECT id, COUNT(*) as c FROM tickets GROUP BY id HAVING c > 1'
).all() as any[]
if (dupCheck.length > 0) {
throw new Error(`重复 id: ${JSON.stringify(dupCheck)}`)
}
console.log(' id 唯一性: OK')
db.pragma('foreign_keys = ON')
console.log('')
console.log('✅ 迁移成功!')
console.log('')
console.log('确认无误后手动清理备份表:')
console.log(' DROP TABLE tickets_backup;')
console.log(' DROP TABLE ticket_steps_backup;')
} catch (e) {
console.error('')
console.error('❌ 迁移失败,尝试回滚...')
console.error(e)
// 回滚:检查备份表是否存在,恢复之
try {
const hasBackup = db.prepare(
"SELECT COUNT(*) as c FROM sqlite_master WHERE type='table' AND name='tickets_backup'"
).get() as any
if (hasBackup.c > 0) {
db.exec('DROP TABLE IF EXISTS tickets')
db.exec('ALTER TABLE tickets_backup RENAME TO tickets')
}
const hasStepsBackup = db.prepare(
"SELECT COUNT(*) as c FROM sqlite_master WHERE type='table' AND name='ticket_steps_backup'"
).get() as any
if (hasStepsBackup.c > 0) {
db.exec('DROP TABLE IF EXISTS ticket_steps')
db.exec('ALTER TABLE ticket_steps_backup RENAME TO ticket_steps')
}
db.pragma('foreign_keys = ON')
console.log('已回滚到迁移前状态')
} catch (rollbackErr) {
console.error('自动回滚失败,请从备份文件手动恢复数据库!', rollbackErr)
console.error('运行: cp data/issue.db.backup-* data/issue.db')
}
process.exit(1)
} finally {
db.close()
}

View File

@ -0,0 +1,28 @@
// 数据迁移:将现有所有工单的 current_status 批量更新为 'resolved'(已办工单)
// 运行方式npx tsx scripts/migrate-to-resolved.ts
import { getDb } from '../src/lib/db'
import { initDatabase } from '../src/lib/db-schema'
initDatabase()
const db = getDb()
const before = db.prepare('SELECT COUNT(*) as total FROM tickets').get() as { total: number }
console.log(`当前工单总数: ${before.total}`)
const toUpdate = db.prepare(
"SELECT COUNT(*) as total FROM tickets WHERE current_status != 'resolved'"
).get() as { total: number }
console.log(`需要迁移(非 resolved: ${toUpdate.total}`)
if (toUpdate.total > 0) {
db.prepare("UPDATE tickets SET current_status = 'resolved'").run()
console.log('迁移完成:所有工单已归入已办工单。')
} else {
console.log('无需迁移:所有工单已是 resolved 状态。')
}
const after = db.prepare(
"SELECT current_status, COUNT(*) as cnt FROM tickets GROUP BY current_status"
).all() as { current_status: string; cnt: number }[]
console.log('迁移后状态分布:', after)

View File

@ -0,0 +1,75 @@
'use client'
import { useState, useEffect } from 'react'
import StatsOverview from '@/components/dashboard/StatsOverview'
import TrendChart from '@/components/dashboard/TrendChart'
import CategoryChart from '@/components/dashboard/CategoryChart'
import { Card } from '@/components/ui'
interface StatsData {
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 }>
}
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.overview) 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?.overview || null} />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<TrendChart data={stats?.monthlyTrend || []} />
<CategoryChart data={stats?.categories || []} />
</div>
{/* Recent tickets summary */}
<Card className="p-5">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2"></h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-slate-500 dark:text-slate-400"></span>
<p className="text-lg font-bold text-red-500">{stats?.overview.open || 0}</p>
</div>
<div>
<span className="text-slate-500 dark:text-slate-400"></span>
<p className="text-lg font-bold text-amber-500">{stats?.overview.in_progress || 0}</p>
</div>
<div>
<span className="text-slate-500 dark:text-slate-400"></span>
<p className="text-lg font-bold text-blue-500">{stats?.overview.thisMonth || 0}</p>
</div>
<div>
<span className="text-slate-500 dark:text-slate-400"></span>
<p className={`text-lg font-bold ${(stats?.overview.slaRate || 0) >= 90 ? 'text-emerald-500' : 'text-amber-500'}`}>{stats?.overview.slaRate || 0}%</p>
</div>
</div>
</Card>
</div>
)
}

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

@ -0,0 +1,7 @@
'use client'
import AppShell from '@/components/layout/AppShell'
import { ThemeProvider } from '@/components/providers/ThemeProvider'
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (<ThemeProvider><AppShell>{children}</AppShell></ThemeProvider>)
}

View File

@ -0,0 +1,182 @@
'use client'
import { useState, useEffect } from 'react'
import { Card, Button, Table, Badge, Modal, Input } from '@/components/ui'
import { Plus, Trash2, Copy, Check, Key } from 'lucide-react'
interface ApiKey {
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<ApiKey[]>([])
const [loading, setLoading] = useState(true)
const [createOpen, setCreateOpen] = useState(false)
const [editTarget, setEditTarget] = useState<ApiKey | null>(null)
const [deleteTarget, setDeleteTarget] = useState<ApiKey | null>(null)
const [name, setName] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [newKey, setNewKey] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const fetchKeys = () => {
fetch('/api/api-keys')
.then(r => r.json())
.then(d => { if (d.keys) setKeys(d.keys) })
.catch(() => {})
.finally(() => setLoading(false))
}
useEffect(() => { fetchKeys() }, [])
const handleCreate = async () => {
if (!name.trim()) return
setSaving(true)
setError('')
try {
const res = await fetch('/api/api-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim(), permissions: ['tickets:read'] }),
})
const data = await res.json()
if (!res.ok) { setError(data.error || '创建失败'); return }
setNewKey(data.key)
setCreateOpen(false)
setName('')
fetchKeys()
} catch {
setError('创建失败')
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!deleteTarget) return
const res = await fetch(`/api/api-keys/${deleteTarget.id}`, { method: 'DELETE' })
if (res.ok) {
setDeleteTarget(null)
fetchKeys()
}
}
const handleToggleActive = async (k: ApiKey) => {
await fetch(`/api/api-keys/${k.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: k.name, permissions: JSON.parse(k.permissions), is_active: !k.is_active }),
})
fetchKeys()
}
const copyKey = (key: string) => {
navigator.clipboard.writeText(key)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">API Key </h1>
<p className="text-slate-500 dark:text-slate-400 mt-1"> API</p>
</div>
<Button size="sm" onClick={() => { setCreateOpen(true); setNewKey(null) }}>
<Plus size={16} className="mr-1" /> Key
</Button>
</div>
{newKey && (
<div className="p-4 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-xl">
<p className="text-sm font-medium text-emerald-800 dark:text-emerald-300 mb-2">
API Key
</p>
<div className="flex items-center gap-2">
<code className="flex-1 p-3 bg-white dark:bg-slate-800 rounded-lg border border-emerald-200 dark:border-emerald-700 text-sm font-mono break-all text-slate-800 dark:text-slate-200">
{newKey}
</code>
<Button variant="ghost" size="sm" onClick={() => copyKey(newKey)}>
{copied ? <Check size={16} className="text-emerald-500" /> : <Copy size={16} />}
</Button>
</div>
</div>
)}
{loading ? (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">...</div>
) : keys.length === 0 ? (
<Card className="p-12 text-center">
<Key size={40} className="mx-auto text-slate-300 dark:text-slate-600 mb-3" />
<p className="text-slate-500 dark:text-slate-400"> API Key</p>
<p className="text-sm text-slate-400 dark:text-slate-500 mt-1"> Key</p>
</Card>
) : (
<Table headers={['名称', '权限', '状态', '最后使用', '过期时间', '创建时间', '操作']}>
{keys.map(k => {
const perms: string[] = JSON.parse(k.permissions)
return (
<tr key={k.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<td className="px-4 py-3 font-medium text-slate-900 dark:text-slate-100">{k.name}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{perms.map(p => <Badge key={p} variant="default">{p}</Badge>)}
</div>
</td>
<td className="px-4 py-3">
<button onClick={() => handleToggleActive(k)}>
<Badge variant={k.is_active ? 'success' : 'danger'}>{k.is_active ? '启用' : '禁用'}</Badge>
</button>
</td>
<td className="px-4 py-3 text-slate-500 dark:text-slate-400 text-sm">{k.last_used_at ? new Date(k.last_used_at).toLocaleString('zh-CN') : '从未使用'}</td>
<td className="px-4 py-3 text-slate-500 dark:text-slate-400 text-sm">{k.expires_at ? new Date(k.expires_at).toLocaleString('zh-CN') : '永不过期'}</td>
<td className="px-4 py-3 text-slate-500 dark:text-slate-400 text-sm">{new Date(k.created_at).toLocaleString('zh-CN')}</td>
<td className="px-4 py-3">
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(k)}>
<Trash2 size={14} className="text-red-500" />
</Button>
</td>
</tr>
)
})}
</Table>
)}
<Modal open={createOpen} onClose={() => { setCreateOpen(false); setName(''); setError('') }} title="创建 API Key">
<div className="space-y-4">
<Input
label="名称"
value={name}
onChange={e => setName(e.target.value)}
placeholder="例如:资产管理系统调用"
/>
<p className="text-xs text-slate-500 dark:text-slate-400">
<Badge variant="default">tickets:read</Badge>
</p>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-3">
<Button onClick={handleCreate} loading={saving}></Button>
<Button variant="secondary" onClick={() => { setCreateOpen(false); setError('') }}></Button>
</div>
</div>
</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="secondary" onClick={() => setDeleteTarget(null)}></Button>
<Button variant="danger" onClick={handleDelete}></Button>
</div>
</Modal>
</div>
)
}

View File

@ -0,0 +1,40 @@
import Link from 'next/link'
import { Card } from '@/components/ui'
import { Users, Shield, Settings as SettingsIcon } from 'lucide-react'
const sections = [
{ href: '/settings/users', label: '用户管理', description: '管理系统用户与账号', icon: Users },
{ href: '/settings/roles', label: '角色权限', description: '配置角色与权限', icon: Shield },
]
export default function SettingsPage() {
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>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{sections.map(s => {
const Icon = s.icon
return (
<Link key={s.href} href={s.href}>
<Card className="p-5 hover:border-blue-500/50 transition-colors cursor-pointer">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
<Icon size={20} className="text-blue-500" />
</div>
<div>
<h3 className="font-medium text-slate-900 dark:text-slate-100">{s.label}</h3>
<p className="text-sm text-slate-500 dark:text-slate-400">{s.description}</p>
</div>
</div>
</Card>
</Link>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,152 @@
'use client'
import { useState, useEffect } from 'react'
import { Card, Button, Table, Badge, Modal, Input } from '@/components/ui'
import { Plus, Pencil, Trash2 } from 'lucide-react'
interface Role {
id: number
name: string
display_name: string
permissions: string
created_at: string
}
const allPermissions = [
{ key: 'tickets:read', label: '查看工单' },
{ key: 'tickets:write', label: '编辑工单' },
{ key: 'reports:read', label: '查看报告' },
{ key: 'reports:write', label: '编辑报告' },
{ key: 'users:read', label: '查看用户' },
{ key: 'users:write', label: '编辑用户' },
{ key: 'roles:read', label: '查看角色' },
{ key: 'roles:write', label: '编辑角色' },
]
export default function RolesPage() {
const [roles, setRoles] = useState<Role[]>([])
const [loading, setLoading] = useState(true)
const [modalOpen, setModalOpen] = useState(false)
const [editRole, setEditRole] = useState<Role | null>(null)
const [form, setForm] = useState({ name: '', display_name: '', permissions: [] as string[] })
const [error, setError] = useState('')
const fetchRoles = () => {
fetch('/api/roles').then(r => r.json()).then(d => { if (d.roles) setRoles(d.roles) }).catch(() => {}).finally(() => setLoading(false))
}
useEffect(() => { fetchRoles() }, [])
const openCreate = () => {
setEditRole(null)
setForm({ name: '', display_name: '', permissions: [] })
setError('')
setModalOpen(true)
}
const openEdit = (r: Role) => {
setEditRole(r)
let perms: string[] = []
try { perms = JSON.parse(r.permissions) } catch {}
setForm({ name: r.name, display_name: r.display_name, permissions: perms })
setError('')
setModalOpen(true)
}
const togglePermission = (key: string) => {
setForm(prev => ({
...prev,
permissions: prev.permissions.includes(key) ? prev.permissions.filter(p => p !== key) : [...prev.permissions, key],
}))
}
const handleSave = async () => {
setError('')
try {
if (editRole) {
const res = await fetch(`/api/roles/${editRole.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: form.display_name, permissions: form.permissions }) })
if (!res.ok) { const d = await res.json(); setError(d.error || '更新失败'); return }
} else {
if (!form.name || !form.display_name) { setError('请填写必填项'); return }
const res = await fetch('/api/roles', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) })
if (!res.ok) { const d = await res.json(); setError(d.error || '创建失败'); return }
}
setModalOpen(false)
fetchRoles()
} catch { setError('操作失败') }
}
const handleDelete = async (id: number) => {
if (!confirm('确定删除此角色?')) return
const res = await fetch(`/api/roles/${id}`, { method: 'DELETE' })
if (res.ok) fetchRoles()
else { const d = await res.json(); alert(d.error || '删除失败') }
}
const formatPermissions = (permStr: string) => {
try {
const perms: string[] = JSON.parse(permStr)
if (perms.includes('*')) return '全部权限'
return perms.map(p => { const f = allPermissions.find(a => a.key === p); return f ? f.label : p }).join(', ') || '无权限'
} catch { return '无权限' }
}
const builtinRoles = ['admin', 'operator', 'viewer']
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100"></h1>
<p className="text-slate-500 dark:text-slate-400 mt-1"></p>
</div>
<Button size="sm" onClick={openCreate}><Plus size={16} className="mr-1" /></Button>
</div>
{loading ? (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">...</div>
) : (
<Table headers={['角色名', '显示名称', '权限', '操作']}>
{roles.map(r => (
<tr key={r.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<td className="px-4 py-3 font-medium text-slate-900 dark:text-slate-100">{r.name}</td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{r.display_name}</td>
<td className="px-4 py-3 text-slate-500 dark:text-slate-400 text-sm">{formatPermissions(r.permissions)}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(r)}><Pencil size={14} /></Button>
{!builtinRoles.includes(r.name) && (
<Button variant="ghost" size="sm" onClick={() => handleDelete(r.id)}><Trash2 size={14} className="text-red-500" /></Button>
)}
</div>
</td>
</tr>
))}
</Table>
)}
<Modal open={modalOpen} onClose={() => setModalOpen(false)} title={editRole ? '编辑角色' : '新建角色'}>
<div className="space-y-4">
{!editRole && <Input label="角色名(英文)" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} placeholder="e.g. supervisor" />}
<Input label="显示名称" value={form.display_name} onChange={e => setForm(p => ({ ...p, display_name: e.target.value }))} />
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"></label>
<div className="space-y-2">
{allPermissions.map(p => (
<label key={p.key} className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={form.permissions.includes(p.key) || form.permissions.includes('*')} onChange={() => togglePermission(p.key)} className="rounded border-slate-300" />
<span className="text-sm text-slate-700 dark:text-slate-300">{p.label}</span>
<span className="text-xs text-slate-400">{p.key}</span>
</label>
))}
</div>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-3">
<Button onClick={handleSave}>{editRole ? '保存' : '创建'}</Button>
<Button variant="secondary" onClick={() => setModalOpen(false)}></Button>
</div>
</div>
</Modal>
</div>
)
}

View File

@ -0,0 +1,128 @@
'use client'
import { useState, useEffect } from 'react'
import { Card, Button, Table, Badge, Modal, Input, Select } from '@/components/ui'
import { Plus, Pencil, Trash2 } from 'lucide-react'
interface User {
id: number
username: string
display_name: string
email: string | null
role: string
is_active: number
created_at: string
}
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [modalOpen, setModalOpen] = useState(false)
const [editUser, setEditUser] = useState<User | null>(null)
const [form, setForm] = useState({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
const [error, setError] = useState('')
const fetchUsers = () => {
fetch('/api/users').then(r => r.json()).then(d => { if (d.users) setUsers(d.users) }).catch(() => {}).finally(() => setLoading(false))
}
useEffect(() => { fetchUsers() }, [])
const openCreate = () => {
setEditUser(null)
setForm({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
setError('')
setModalOpen(true)
}
const openEdit = (u: User) => {
setEditUser(u)
setForm({ username: u.username, password: '', display_name: u.display_name, email: u.email || '', role: u.role })
setError('')
setModalOpen(true)
}
const handleSave = async () => {
setError('')
try {
if (editUser) {
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/${editUser.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 || !form.display_name) { 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()
} catch { setError('操作失败') }
}
const handleDelete = async (id: number) => {
if (!confirm('确定删除此用户?')) return
const res = await fetch(`/api/users/${id}`, { method: 'DELETE' })
if (res.ok) fetchUsers()
}
const handleToggleActive = async (u: User) => {
await fetch(`/api/users/${u.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_active: u.is_active ? 0 : 1 }) })
fetchUsers()
}
const roleLabel: Record<string, string> = { admin: '管理员', operator: '运维人员', viewer: '查看者' }
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100"></h1>
<p className="text-slate-500 dark:text-slate-400 mt-1"></p>
</div>
<Button size="sm" onClick={openCreate}><Plus size={16} className="mr-1" /></Button>
</div>
{loading ? (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">...</div>
) : (
<Table headers={['用户名', '显示名称', '邮箱', '角色', '状态', '创建时间', '操作']}>
{users.map(u => (
<tr key={u.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<td className="px-4 py-3 font-medium text-slate-900 dark:text-slate-100">{u.username}</td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{u.display_name}</td>
<td className="px-4 py-3 text-slate-500 dark:text-slate-400">{u.email || '-'}</td>
<td className="px-4 py-3"><Badge variant="info">{roleLabel[u.role] || u.role}</Badge></td>
<td className="px-4 py-3">
<button onClick={() => handleToggleActive(u)}>
<Badge variant={u.is_active ? 'success' : 'danger'}>{u.is_active ? '启用' : '禁用'}</Badge>
</button>
</td>
<td className="px-4 py-3 text-sm text-slate-500 dark:text-slate-400">{u.created_at || '-'}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(u)}><Pencil size={14} /></Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(u.id)}><Trash2 size={14} className="text-red-500" /></Button>
</div>
</td>
</tr>
))}
</Table>
)}
<Modal open={modalOpen} onClose={() => setModalOpen(false)} title={editUser ? '编辑用户' : '新建用户'}>
<div className="space-y-4">
{!editUser && <Input label="用户名" value={form.username} onChange={e => setForm(p => ({ ...p, username: e.target.value }))} required />}
<Input label="显示名称" value={form.display_name} onChange={e => setForm(p => ({ ...p, display_name: e.target.value }))} required />
<Input label={editUser ? '新密码(留空不修改)' : '密码'} type="password" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
<Input label="邮箱" type="email" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} />
<Select label="角色" options={[{ value: 'viewer', label: '查看者' }, { value: 'operator', label: '运维人员' }, { value: 'admin', label: '管理员' }]} value={form.role} onChange={e => setForm(p => ({ ...p, role: e.target.value }))} />
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-3">
<Button onClick={handleSave}>{editUser ? '保存' : '创建'}</Button>
<Button variant="secondary" onClick={() => setModalOpen(false)}></Button>
</div>
</div>
</Modal>
</div>
)
}

View File

@ -0,0 +1,46 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import TicketForm from '@/components/tickets/TicketForm'
export default function EditTicketPage() {
const params = useParams()
const router = useRouter()
const [ticket, setTicket] = useState<any>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const id = params.id
fetch(`/api/tickets/${id}`)
.then(r => r.json())
.then(d => { if (d.ticket) setTicket(d.ticket) })
.catch(() => {})
.finally(() => setLoading(false))
}, [params.id])
useEffect(() => {
fetch('/api/auth/me')
.then(r => r.json())
.then(u => {
const role = u.user?.role
const completed = ['resolved', 'closed']
if (role !== 'admin' && ticket && completed.includes(ticket.current_status)) {
router.replace(`/tickets/${ticket.id}`)
}
})
.catch(() => {})
}, [ticket])
if (loading) return <div className="text-center py-12 text-slate-500 dark:text-slate-400">...</div>
if (!ticket) return <div className="text-center py-12 text-slate-500 dark:text-slate-400"></div>
return (
<div className="space-y-4">
<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"> - {ticket.id}</p>
</div>
<TicketForm initialData={ticket} ticketId={ticket.id} />
</div>
)
}

View File

@ -0,0 +1,121 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import TicketDetail from '@/components/tickets/TicketDetail'
import ProcessForm from '@/components/tickets/ProcessForm'
import { ArrowLeft } from 'lucide-react'
interface HistoryTicket {
id: number
content: string | null
fault_category: string | null
current_status: string
assign_time: string | null
}
export default function TicketDetailPage() {
const params = useParams()
const router = useRouter()
const [data, setData] = useState<any>(null)
const [history, setHistory] = useState<HistoryTicket[]>([])
const [loading, setLoading] = useState(true)
const [showProcessForm, setShowProcessForm] = useState(false)
const [currentUser, setCurrentUser] = useState<{ id: number; display_name: string; role: string } | null>(null)
useEffect(() => {
fetch('/api/auth/me')
.then(r => r.json())
.then(u => { if (u.user) setCurrentUser(u.user) })
.catch(() => {})
}, [])
useEffect(() => {
const id = params.id as string
fetch(`/api/tickets/${id}`)
.then(r => r.json())
.then(d => {
if (d.ticket) {
setData(d)
const ip = d.ticket.device_ip
if (ip) {
fetch(`/api/tickets/by-asset?ip=${encodeURIComponent(ip)}`)
.then(r => r.json())
.then(h => {
const tickets: HistoryTicket[] = (h.tickets || [])
.filter((t: HistoryTicket) => t.id !== d.ticket.id)
.sort((a: HistoryTicket, b: HistoryTicket) => {
const ta = a.assign_time || ''
const tb = b.assign_time || ''
return tb.localeCompare(ta)
})
setHistory(tickets)
})
.catch(() => {})
}
}
})
.catch(() => {})
.finally(() => setLoading(false))
}, [params.id])
if (loading) return <div className="text-center py-12 text-slate-500 dark:text-slate-400">...</div>
if (!data) return <div className="text-center py-12 text-slate-500 dark:text-slate-400"></div>
const ticket = data.ticket
async function handleDelete() {
if (!confirm('确定要删除此工单吗?')) return
const res = await fetch(`/api/tickets/${ticket.id}`, { method: 'DELETE' })
if (res.ok) handleBack()
}
const isPending = (ticket.current_status === 'open' || ticket.current_status === 'in_progress') && !ticket.close_time
const isAdmin = currentUser?.role === 'admin'
const isCreator = currentUser?.id != null && currentUser.id === ticket.created_by
const canDelete = isAdmin || (isPending && isCreator)
const buttonLabel = ticket.current_status === 'in_progress' ? '继续处理' : '开始处理'
function handleBack() {
if (window.history.length > 1) {
router.back()
} else {
router.push(isPending ? '/tickets/pending' : '/tickets/completed')
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button onClick={handleBack} className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
<ArrowLeft size={20} />
</button>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white"></h1>
</div>
</div>
<TicketDetail
ticket={ticket}
steps={data.steps || []}
assetInfo={data.assetInfo || null}
history={history}
showEdit={isAdmin && !isPending}
showProcess={isPending && !showProcessForm}
processLabel={buttonLabel}
onProcess={() => setShowProcessForm(true)}
onDelete={handleDelete}
canDelete={canDelete}
/>
{showProcessForm && (
<ProcessForm
ticket={ticket}
currentUserDisplayName={currentUser?.display_name || ''}
onSuccess={() => {
router.push('/tickets/completed')
router.refresh()
}}
onCancel={() => setShowProcessForm(false)}
/>
)}
</div>
)
}

View File

@ -0,0 +1,37 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import TicketList from '@/components/tickets/TicketList'
export default function AllTicketsPage() {
const router = useRouter()
const [ready, setReady] = useState(false)
const [total, setTotal] = useState(0)
useEffect(() => {
fetch('/api/auth/me')
.then(r => r.json())
.then(u => {
if (u.user?.role !== 'admin') {
router.replace('/tickets/pending')
} else {
setReady(true)
}
})
.catch(() => router.replace('/tickets/pending'))
}, [router])
if (!ready) return <div className="text-center py-12 text-slate-500 dark:text-slate-400">...</div>
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100"></h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
<span className="font-medium">{total}</span>
</p>
</div>
<TicketList onPaginationChange={p => setTotal(p.total)} />
</div>
)
}

View File

@ -0,0 +1,98 @@
'use client'
import { useState, useEffect } from 'react'
import TicketList from '@/components/tickets/TicketList'
export default function CompletedTicketsPage() {
const [stats, setStats] = useState({ total: 0, resolved: 0, closed: 0, newThisMonth: 0 })
const [total, setTotal] = useState(0)
useEffect(() => {
async function fetchStats() {
try {
const params = new URLSearchParams()
params.append('filter_current_status', 'resolved')
params.append('filter_current_status', 'closed')
params.append('pageSize', '1')
const res = await fetch(`/api/tickets?${params}`)
if (res.ok) {
const data = await res.json()
setStats(prev => ({ ...prev, total: data.pagination.total }))
}
} catch { /* silent */ }
}
async function fetchStatusCounts() {
try {
const [resolvedRes, closedRes] = await Promise.all([
fetch('/api/tickets?filter_current_status=resolved&pageSize=1'),
fetch('/api/tickets?filter_current_status=closed&pageSize=1'),
])
const resolvedData = resolvedRes.ok ? await resolvedRes.json() : null
const closedData = closedRes.ok ? await closedRes.json() : null
setStats(prev => ({
...prev,
resolved: resolvedData?.pagination?.total || 0,
closed: closedData?.pagination?.total || 0,
}))
} catch { /* silent */ }
}
async function fetchNewThisMonth() {
try {
const now = new Date()
const startOfMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`
const params = new URLSearchParams()
params.append('filter_current_status', 'resolved')
params.append('filter_current_status', 'closed')
params.append('close_time_start', startOfMonth)
params.append('pageSize', '1')
const res = await fetch(`/api/tickets?${params}`)
if (res.ok) {
const data = await res.json()
setStats(prev => ({ ...prev, newThisMonth: data.pagination.total }))
}
} catch { /* silent */ }
}
fetchStats()
fetchStatusCounts()
fetchNewThisMonth()
}, [])
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100"></h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
<span className="font-medium">{total}</span>
</p>
</div>
<div className="grid grid-cols-4 gap-4">
<div className="rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-4">
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100 mt-1">{stats.total}</p>
</div>
<div className="rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-4">
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-2xl font-bold text-green-600 dark:text-green-400 mt-1">{stats.resolved}</p>
</div>
<div className="rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-4">
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-2xl font-bold text-slate-500 mt-1">{stats.closed}</p>
</div>
<div className="rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-4">
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400 mt-1">{stats.newThisMonth}</p>
</div>
</div>
<TicketList
onPaginationChange={p => setTotal(p.total)}
defaultStatusFilter={['resolved', 'closed']}
showActions={false}
hideDefaultFilterChips={true}
/>
</div>
)
}

View File

@ -0,0 +1,224 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui'
import SelectWithInput from '@/components/ui/SelectWithInput'
const TICKET_TYPES = ['OEM诊断', 'OEM维修']
function beijingNow(): string {
const d = new Date(Date.now() + 8 * 60 * 60 * 1000)
return d.toISOString().slice(0, 10) + ' ' + d.toISOString().slice(11, 19)
}
function beijingTicketNo(): string {
const d = new Date(Date.now() + 8 * 60 * 60 * 1000)
const y = d.getUTCFullYear()
const mo = String(d.getUTCMonth() + 1).padStart(2, '0')
const day = String(d.getUTCDate()).padStart(2, '0')
const h = String(d.getUTCHours()).padStart(2, '0')
const mi = String(d.getUTCMinutes()).padStart(2, '0')
const s = String(d.getUTCSeconds()).padStart(2, '0')
return `${y}${mo}${day}${h}${mi}${s}`
}
export default function CreateTicketPage() {
const router = useRouter()
const [loading, setLoading] = useState(false)
const [lookingUp, setLookingUp] = useState(false)
const [error, setError] = useState('')
const timeRe = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?$/
const [ticketNo, setTicketNo] = useState(beijingTicketNo)
const [deviceIp, setDeviceIp] = useState('')
const [deviceSn, setDeviceSn] = useState('')
const [deviceName, setDeviceName] = useState('')
const [content, setContent] = useState('')
const [assignTime, setAssignTime] = useState(beijingNow())
const [assignTimeError, setAssignTimeError] = useState('')
const [ticketType, setTicketType] = useState('')
const handleIpLookup = async () => {
if (!deviceIp) return
setLookingUp(true)
try {
const res = await fetch(`/api/assets/lookup?ip=${encodeURIComponent(deviceIp)}`)
if (res.ok) {
const data = await res.json()
if (data.asset) {
setDeviceName(data.asset.node_name || '')
setDeviceSn(data.asset.serial_number || '')
}
}
} catch { /* ignore */ }
finally { setLookingUp(false) }
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (!/^\d{14}$/.test(ticketNo.trim())) {
setError('工单号必须为 14 位纯数字')
return
}
if (!deviceIp.trim()) {
setError('业务 IP 不能为空')
return
}
if (!ticketType.trim()) {
setError('工单类型不能为空')
return
}
if (!content.trim()) {
setError('工单内容不能为空')
return
}
if (assignTime && !timeRe.test(assignTime)) {
setError('派单时间格式不正确,请使用 YYYY-MM-DD HH:mm:ss 格式')
return
}
setLoading(true)
try {
const res = await fetch('/api/tickets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ticket_no: ticketNo.trim(),
device_ip: deviceIp.trim(),
device_sn: deviceSn || null,
device_name: deviceName || null,
content: content.trim(),
ticket_type: ticketType || null,
assign_time: assignTime,
}),
})
const data = await res.json()
if (!res.ok) { setError(data.error || '创建失败'); return }
router.push('/tickets/pending')
router.refresh()
} catch {
setError('网络错误')
} finally {
setLoading(false)
}
}
return (
<div className="space-y-4">
<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>
<form onSubmit={handleSubmit} className="space-y-5 max-w-2xl">
{/* 工单号 */}
<div className="space-y-1">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
<span className="text-red-500">*</span>
</label>
<input
className="w-full px-3 py-2 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"
value={ticketNo}
onChange={e => setTicketNo(e.target.value)}
placeholder="14 位工单号(如 20260502000001"
required
/>
</div>
{/* 业务IP + 派单时间 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
IP <span className="text-red-500">*</span>
</label>
<input
className="w-full px-3 py-2 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"
value={deviceIp}
onChange={e => setDeviceIp(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleIpLookup() } }}
placeholder="输入 IP 后按回车查询"
/>
</div>
<div className="space-y-1">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300"></label>
<input
type="text"
className={`w-full px-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 ${assignTimeError ? 'border-red-500 bg-red-50 dark:bg-red-950/20' : 'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-slate-100'}`}
value={assignTime}
onChange={e => { setAssignTime(e.target.value); setAssignTimeError('') }}
onBlur={() => {
if (assignTime && !timeRe.test(assignTime)) {
setAssignTimeError('时间格式不正确,请使用 YYYY-MM-DD HH:mm:ss 格式')
}
}}
placeholder="2026-05-02 18:34:36"
/>
{assignTimeError && <p className="text-xs text-red-500">{assignTimeError}</p>}
</div>
</div>
{/* 自动填充:节点名称 + 设备序列号 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300"></label>
<input
className="w-full px-3 py-2 rounded-lg border bg-slate-50 dark:bg-slate-800/50 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 text-sm focus:outline-none"
value={deviceName}
readOnly
placeholder={lookingUp ? '查询中...' : '输入 IP 后按回车自动填充'}
/>
</div>
<div className="space-y-1">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300"></label>
<input
className="w-full px-3 py-2 rounded-lg border bg-slate-50 dark:bg-slate-800/50 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 text-sm focus:outline-none"
value={deviceSn}
readOnly
placeholder={lookingUp ? '查询中...' : '输入 IP 后按回车自动填充'}
/>
</div>
</div>
{/* 工单类型 */}
<div className="max-w-xs">
<SelectWithInput
label={<> <span className="text-red-500">*</span></>}
value={ticketType}
onChange={setTicketType}
options={TICKET_TYPES}
placeholder="请选择或输入工单类型..."
/>
</div>
{/* 工单内容 */}
<div className="space-y-1">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
<span className="text-red-500">*</span>
</label>
<textarea
className="w-full px-3 py-2 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 min-h-[120px]"
value={content}
onChange={e => setContent(e.target.value)}
placeholder="描述故障现象..."
required
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-3">
<Button type="submit" disabled={loading}>
{loading ? '创建中...' : '创建工单'}
</Button>
<Button type="button" variant="secondary" onClick={() => router.back()}></Button>
</div>
</form>
</div>
)
}

View File

@ -0,0 +1,5 @@
import TicketImport from '@/components/tickets/TicketImport'
export default function ImportTicketsPage() {
return <TicketImport />
}

View File

@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function NewTicketPage() {
redirect('/tickets/create')
}

View File

@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function TicketsPage() {
redirect('/tickets/pending')
}

View File

@ -0,0 +1,101 @@
'use client'
import { useState, useEffect } from 'react'
import TicketList from '@/components/tickets/TicketList'
export default function PendingTicketsPage() {
const [stats, setStats] = useState({ total: 0, open: 0, inProgress: 0, slaTimeout: 0 })
const [total, setTotal] = useState(0)
useEffect(() => {
async function fetchStats() {
try {
const params = new URLSearchParams()
params.append('filter_current_status', 'open')
params.append('filter_current_status', 'in_progress')
params.append('pageSize', '1')
const res = await fetch(`/api/tickets?${params}`)
if (res.ok) {
const data = await res.json()
setStats(prev => ({ ...prev, total: data.pagination.total }))
}
} catch { /* silent */ }
}
async function fetchStatusCounts() {
try {
const [openRes, inProgressRes] = await Promise.all([
fetch('/api/tickets?filter_current_status=open&pageSize=1'),
fetch('/api/tickets?filter_current_status=in_progress&pageSize=1'),
])
const openData = openRes.ok ? await openRes.json() : null
const inProgressData = inProgressRes.ok ? await inProgressRes.json() : null
setStats(prev => ({
...prev,
open: openData?.pagination?.total || 0,
inProgress: inProgressData?.pagination?.total || 0,
}))
} catch { /* silent */ }
}
fetchStats()
fetchStatusCounts()
}, [])
// SLA 超时数 = 已超第一档截止时间的 open/in_progress 工单数
useEffect(() => {
async function fetchSlaTimeout() {
try {
const res = await fetch('/api/tickets?filter_current_status=open&filter_current_status=in_progress&pageSize=200&sortBy=assign_time&sortOrder=asc')
if (res.ok) {
const data = await res.json()
const { getTier1Deadline } = await import('@/lib/sla')
const now = new Date()
const timeoutCount = (data.tickets as any[]).filter((t: any) => {
if (!t.assign_time || t.counted_in_sla === 0) return false
return now > getTier1Deadline(t.assign_time)
}).length
setStats(prev => ({ ...prev, slaTimeout: timeoutCount }))
}
} catch { /* silent */ }
}
fetchSlaTimeout()
}, [stats.total])
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100"></h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
<span className="font-medium">{total}</span>
</p>
</div>
<div className="grid grid-cols-4 gap-4">
<div className="rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-4">
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100 mt-1">{stats.total}</p>
</div>
<div className="rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-4">
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400 mt-1">{stats.open}</p>
</div>
<div className="rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-4">
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-2xl font-bold text-amber-500 mt-1">{stats.inProgress}</p>
</div>
<div className="rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-4">
<p className="text-sm text-slate-500 dark:text-slate-400">SLA </p>
<p className="text-2xl font-bold text-red-500 mt-1">{stats.slaTimeout}</p>
</div>
</div>
<TicketList
onPaginationChange={p => setTotal(p.total)}
defaultStatusFilter={['open', 'in_progress']}
showSlaColumn={true}
showActions={false}
hideDefaultFilterChips={true}
/>
</div>
)
}

View File

@ -0,0 +1,47 @@
'use client'
import { useState } from 'react'
export default function LoginPage() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (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 }
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">IT </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,57 @@
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { getDb } from '@/lib/db'
import { verifyToken } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions'
async function getSession() {
const cookieStore = await cookies()
const token = cookieStore.get('session_issue')?.value
if (!token) return null
return verifyToken(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, 'api-keys:write')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const { id } = await params
const existing = getDb().prepare('SELECT id FROM api_keys WHERE id = ?').get(id)
if (!existing) return NextResponse.json({ error: 'API Key 不存在' }, { status: 404 })
try {
const body = await request.json()
const { name, permissions, expires_at, is_active } = body
getDb().prepare(
'UPDATE api_keys SET name = ?, permissions = ?, expires_at = ?, is_active = ? WHERE id = ?'
).run(
name,
JSON.stringify(permissions || ['tickets:read']),
expires_at || null,
is_active !== undefined ? (is_active ? 1 : 0) : 1,
id
)
return NextResponse.json({ success: true })
} 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, 'api-keys:write')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const { id } = await params
const existing = getDb().prepare('SELECT id FROM api_keys WHERE id = ?').get(id)
if (!existing) return NextResponse.json({ error: 'API Key 不存在' }, { status: 404 })
getDb().prepare('DELETE FROM api_keys WHERE id = ?').run(id)
return NextResponse.json({ success: true })
}

View File

@ -0,0 +1,52 @@
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { getDb } from '@/lib/db'
import { verifyToken, generateApiKey, hashApiKey } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions'
async function getSession() {
const cookieStore = await cookies()
const token = cookieStore.get('session_issue')?.value
if (!token) return null
return verifyToken(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 = getDb().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 || ['tickets:read'])
const result = getDb().prepare(
'INSERT INTO api_keys (name, key_hash, permissions, expires_at, created_by) VALUES (?, ?, ?, ?, ?)'
).run(name, keyHash, perms, expires_at || null, session.id)
return NextResponse.json({ key, id: result.lastInsertRowid }, { status: 201 })
} catch (e) {
const msg = e instanceof Error ? e.message : '创建失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server'
import { getCurrentUser } from '@/lib/auth'
import { getAssetByIp } from '@/lib/assets-client'
export async function GET(request: NextRequest) {
try {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
const ip = request.nextUrl.searchParams.get('ip')
if (!ip) return NextResponse.json({ error: '缺少 ip 参数' }, { status: 400 })
const asset = await getAssetByIp(ip)
return NextResponse.json({ asset })
} catch (e) {
const msg = e instanceof Error ? e.message : '查询失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from 'next/server'
import { login } from '@/lib/auth'
import { initDatabase } from '@/lib/db-schema'
export async function POST(request: NextRequest) {
try {
initDatabase()
const { username, password } = await request.json()
if (!username || !password) return NextResponse.json({ error: '请输入用户名和密码' }, { status: 400 })
const result = await login(username, password)
if (!result) return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
const response = NextResponse.json({ user: result.user })
response.cookies.set('session_issue', result.token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 7 * 24 * 60 * 60, path: '/' })
return response
} catch (e) { console.error('Login error:', e); return NextResponse.json({ error: '登录失败' }, { status: 500 }) }
}

View File

@ -0,0 +1,6 @@
import { NextResponse } from 'next/server'
export async function POST() {
const r = NextResponse.json({ success: true })
r.cookies.set('session_issue', '', { maxAge: 0, path: '/' })
return r
}

View File

@ -0,0 +1,7 @@
import { NextResponse } from 'next/server'
import { getCurrentUser } from '@/lib/auth'
export async function GET() {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
return NextResponse.json({ user })
}

View File

@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'roles:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { id } = await params
const body = await request.json()
const db = getDb()
const existing = db.prepare('SELECT * FROM roles WHERE id = ?').get(id)
if (!existing) return NextResponse.json({ error: '角色不存在' }, { status: 404 })
const fields: string[] = []
const values: unknown[] = []
if (body.display_name) { fields.push('display_name = ?'); values.push(body.display_name) }
if (body.permissions) { fields.push('permissions = ?'); values.push(JSON.stringify(body.permissions)) }
if (fields.length > 0) {
values.push(id)
db.prepare(`UPDATE roles SET ${fields.join(', ')} WHERE id = ?`).run(...values)
}
const role = db.prepare('SELECT * FROM roles WHERE id = ?').get(id)
return NextResponse.json({ role })
} catch (e) {
const msg = e instanceof Error ? e.message : '更新失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'roles:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { id } = await params
const db = getDb()
const existing = db.prepare('SELECT * FROM roles WHERE id = ?').get(id) as any
if (!existing) return NextResponse.json({ error: '角色不存在' }, { status: 404 })
if (['admin', 'operator', 'viewer'].includes(existing.name)) {
return NextResponse.json({ error: '系统内置角色不能删除' }, { status: 400 })
}
db.prepare('DELETE FROM roles WHERE id = ?').run(id)
return NextResponse.json({ success: true })
} catch (e) {
const msg = e instanceof Error ? e.message : '删除失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
export async function GET() {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
const db = getDb()
const roles = db.prepare('SELECT * FROM roles ORDER BY id').all()
return NextResponse.json({ roles })
} catch (e) {
const msg = e instanceof Error ? e.message : '查询失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'roles:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const body = await request.json()
const { name, display_name, permissions } = body
if (!name || !display_name) {
return NextResponse.json({ error: '角色名称和显示名称为必填项' }, { status: 400 })
}
const db = getDb()
const existing = db.prepare('SELECT id FROM roles WHERE name = ?').get(name)
if (existing) return NextResponse.json({ error: '角色名已存在' }, { status: 400 })
const result = db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)').run(name, display_name, JSON.stringify(permissions || []))
const role = db.prepare('SELECT * FROM roles WHERE id = ?').get(result.lastInsertRowid)
return NextResponse.json({ role }, { status: 201 })
} catch (e) {
const msg = e instanceof Error ? e.message : '创建失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
export async function GET(request: NextRequest) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
const { searchParams } = request.nextUrl
const months = Math.min(24, Math.max(1, parseInt(searchParams.get('months') || '12')))
const db = getDb()
const monthlyStats = db.prepare(`
SELECT
strftime('%Y-%m', assign_time) as month,
COUNT(*) as total,
SUM(CASE WHEN current_status IN ('resolved', 'closed') THEN 1 ELSE 0 END) as resolved,
AVG(duration_minutes) as avg_duration,
SUM(CASE WHEN counted_in_sla = 1 AND current_status IN ('resolved', 'closed') THEN 1 ELSE 0 END) as sla_pass,
SUM(CASE WHEN current_status IN ('resolved', 'closed') THEN 1 ELSE 0 END) as sla_total
FROM tickets
WHERE assign_time IS NOT NULL
GROUP BY strftime('%Y-%m', assign_time)
ORDER BY month DESC
LIMIT ?
`).all(months)
return NextResponse.json({ monthlyStats: (monthlyStats as any[]).reverse() })
} catch (e) {
const msg = e instanceof Error ? e.message : '查询失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,61 @@
import { NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
export async function GET() {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
const db = getDb()
const total = (db.prepare('SELECT COUNT(*) as c FROM tickets').get() as any).c
const open = (db.prepare("SELECT COUNT(*) as c FROM tickets WHERE current_status = 'open'").get() as any).c
const inProgress = (db.prepare("SELECT COUNT(*) as c FROM tickets WHERE current_status = 'in_progress'").get() as any).c
const resolved = (db.prepare("SELECT COUNT(*) as c FROM tickets WHERE current_status = 'resolved'").get() as any).c
const closed = (db.prepare("SELECT COUNT(*) as c FROM tickets WHERE current_status = 'closed'").get() as any).c
const thisMonth = new Date().toISOString().slice(0, 7)
const monthTotal = (db.prepare("SELECT COUNT(*) as c FROM tickets WHERE assign_time LIKE ?").get(`${thisMonth}%`) as any).c
const avgDuration = db.prepare("SELECT AVG(duration_minutes) as avg FROM tickets WHERE duration_minutes IS NOT NULL AND duration_minutes != ''").get() as any
const avgAvailability = db.prepare("SELECT AVG(availability) as avg FROM tickets WHERE availability IS NOT NULL AND availability != ''").get() as any
const avgDurationVal = Number(avgDuration?.avg)
const avgAvailabilityVal = Number(avgAvailability?.avg)
const slaRate = isNaN(avgAvailabilityVal) ? 0 : Math.round(avgAvailabilityVal * 10000) / 100
// Category breakdown
const categories = db.prepare(
"SELECT fault_category, COUNT(*) as count FROM tickets WHERE fault_category IS NOT NULL AND fault_category != '' GROUP BY fault_category ORDER BY count DESC"
).all()
// Recent 12 months trend
const monthlyTrend = db.prepare(`
SELECT strftime('%Y-%m', assign_time) as month, COUNT(*) as tickets,
AVG(duration_minutes) as avg_duration
FROM tickets WHERE assign_time IS NOT NULL
GROUP BY strftime('%Y-%m', assign_time)
ORDER BY month DESC LIMIT 13
`).all()
return NextResponse.json({
overview: {
total,
open,
in_progress: inProgress,
resolved,
closed,
thisMonth: monthTotal,
avgDuration: isNaN(avgDurationVal) ? 0 : Math.round(avgDurationVal),
slaRate,
},
categories,
monthlyTrend: (monthlyTrend as any[]).reverse(),
})
} catch (e) {
const msg = e instanceof Error ? e.message : '查询失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
export async function GET(request: NextRequest) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
const { searchParams } = request.nextUrl
const period = searchParams.get('period') || 'all' // 'all' | 'month' | 'quarter'
const db = getDb()
let dateCondition = ''
const now = new Date()
if (period === 'month') {
dateCondition = `AND assign_time >= '${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01'`
} else if (period === 'quarter') {
const qStart = new Date(now.getFullYear(), Math.floor(now.getMonth() / 3) * 3, 1)
dateCondition = `AND assign_time >= '${qStart.getFullYear()}-${String(qStart.getMonth() + 1).padStart(2, '0')}-01'`
}
// Overall SLA rate
const slaData = db.prepare(`
SELECT
COUNT(*) as total_closed,
SUM(CASE WHEN counted_in_sla = 1 THEN 1 ELSE 0 END) as sla_pass
FROM tickets
WHERE current_status IN ('resolved', 'closed') ${dateCondition}
`).get() as any
// SLA by category
const byCategory = db.prepare(`
SELECT
fault_category,
COUNT(*) as total,
SUM(CASE WHEN counted_in_sla = 1 THEN 1 ELSE 0 END) as sla_pass
FROM tickets
WHERE current_status IN ('resolved', 'closed') AND fault_category IS NOT NULL ${dateCondition}
GROUP BY fault_category
ORDER BY total DESC
`).all()
// SLA by month
const byMonth = db.prepare(`
SELECT
strftime('%Y-%m', close_time) as month,
COUNT(*) as total,
SUM(CASE WHEN counted_in_sla = 1 THEN 1 ELSE 0 END) as sla_pass
FROM tickets
WHERE current_status IN ('resolved', 'closed') AND close_time IS NOT NULL ${dateCondition}
GROUP BY strftime('%Y-%m', close_time)
ORDER BY month DESC
LIMIT 12
`).all()
return NextResponse.json({
overall: {
total: slaData.total_closed,
pass: slaData.sla_pass,
rate: slaData.total_closed > 0 ? Math.round((slaData.sla_pass / slaData.total_closed) * 100) : 0,
},
byCategory,
byMonth: (byMonth as any[]).reverse(),
})
} catch (e) {
const msg = e instanceof Error ? e.message : '查询失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,146 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { getAssetByIp } from '@/lib/assets-client'
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
const { id } = await params
const db = getDb()
const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(id)
if (!ticket) return NextResponse.json({ error: '工单不存在' }, { status: 404 })
const steps = db.prepare('SELECT * FROM ticket_steps WHERE ticket_id = ? ORDER BY step_order').all((ticket as any).id)
// Try to get device info from assets API
let assetInfo = null
const t = ticket as any
if (t.device_ip) {
try {
assetInfo = await getAssetByIp(t.device_ip)
} catch {
// Assets API may not be available
}
}
return NextResponse.json({ ticket, steps, assetInfo })
} catch (e) {
const msg = e instanceof Error ? e.message : '查询失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { id } = await params
const body = await request.json()
const db = getDb()
const existing = db.prepare('SELECT * FROM tickets WHERE id = ?').get(id) as Record<string, unknown>
if (!existing) return NextResponse.json({ error: '工单不存在' }, { status: 404 })
// 非管理员不可编辑已办工单
const completedStatuses = ['resolved', 'closed']
if (user.role !== 'admin' && completedStatuses.includes(existing.current_status as string)) {
return NextResponse.json({ error: '已办工单仅管理员可修改' }, { status: 403 })
}
// 自动计算处理时长
if (body.close_time && existing.assign_time) {
const assignMs = new Date(existing.assign_time as string).getTime()
const closeMs = new Date(body.close_time).getTime()
if (!isNaN(assignMs) && !isNaN(closeMs)) {
body.duration_minutes = Math.round((closeMs - assignMs) / 60000)
}
}
// resolved 但未传 close_time 时自动设置为北京时间
if (body.current_status === 'resolved' && !body.close_time && !existing.close_time) {
const d = new Date(Date.now() + 8 * 60 * 60 * 1000)
body.close_time = d.toISOString().slice(0, 19)
}
const fields: string[] = []
const values: unknown[] = []
const updatable = [
'device_ip', 'device_sn', 'device_name', 'content', 'assign_time', 'close_time',
'duration_minutes', 'availability', 'process_summary', 'conclusion',
'ticket_type', 'fault_category', 'fault_subcategory', 'parts_replaced', 'parts_name',
'current_status', 'counted_in_sla', 'responsibility',
]
for (const key of updatable) {
if (key in body) {
fields.push(`${key} = ?`)
values.push(body[key])
}
}
if (fields.length > 0) {
fields.push('updated_by = ?')
values.push(user.id)
fields.push("updated_at = datetime('now')")
values.push(id)
db.prepare(`UPDATE tickets SET ${fields.join(', ')} WHERE id = ?`).run(...values)
}
// Update steps if provided
if (body.steps && Array.isArray(body.steps)) {
db.prepare('DELETE FROM ticket_steps WHERE ticket_id = ?').run(id)
const stmt = db.prepare('INSERT INTO ticket_steps (ticket_id, step_order, time_node, handler, description) VALUES (?, ?, ?, ?, ?)')
for (let i = 0; i < body.steps.length; i++) {
const step = body.steps[i]
stmt.run(id, i + 1, step.time_node || null, step.handler || null, step.description || null)
}
}
const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(id)
return NextResponse.json({ ticket })
} catch (e) {
const msg = e instanceof Error ? e.message : '更新失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { id } = await params
const db = getDb()
const existing = db.prepare('SELECT current_status, created_by FROM tickets WHERE id = ?').get(id) as { current_status: string; created_by: number | null } | undefined
if (!existing) return NextResponse.json({ error: '工单不存在' }, { status: 404 })
// 非管理员不可删除已办工单,非创建人不可删除待办工单
const completedStatuses = ['resolved', 'closed']
if (user.role !== 'admin') {
if (completedStatuses.includes(existing.current_status)) {
return NextResponse.json({ error: '已办工单仅管理员可删除' }, { status: 403 })
}
if (existing.created_by !== user.id) {
return NextResponse.json({ error: '仅创建人和管理员可删除此工单' }, { status: 403 })
}
}
db.prepare('DELETE FROM tickets WHERE id = ?').run(id)
return NextResponse.json({ success: true })
} catch (e) {
const msg = e instanceof Error ? e.message : '删除失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
export async function PUT(request: NextRequest) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const body = await request.json()
const updates: Array<{ id: number; fault_category?: string; current_status?: string; counted_in_sla?: number }> = body.updates || []
if (!Array.isArray(updates) || updates.length === 0) {
return NextResponse.json({ error: '无更新数据' }, { status: 400 })
}
const db = getDb()
const allowedFields = ['fault_category', 'current_status', 'counted_in_sla']
let updated = 0
for (const item of updates) {
if (!item.id) continue
const fields: string[] = []
const values: unknown[] = []
for (const f of allowedFields) {
if (f in item && item[f as keyof typeof item] !== undefined) {
fields.push(`${f} = ?`)
values.push(item[f as keyof typeof item])
}
}
if (fields.length === 0) continue
fields.push("updated_at = datetime('now')")
fields.push('updated_by = ?')
values.push(user.id)
values.push(item.id)
const result = db.prepare(`UPDATE tickets SET ${fields.join(', ')} WHERE id = ?`).run(...values)
updated += result.changes
}
return NextResponse.json({ updated, total: updates.length })
} catch (e) {
const msg = e instanceof Error ? e.message : '批量更新失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser, verifyApiKey } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
function verifyEnvApiKey(key: string): boolean {
const allowed = process.env.ALLOWED_API_KEYS || ''
if (!allowed) return false
return allowed.split(',').map(k => k.trim()).includes(key)
}
export async function GET(request: NextRequest) {
try {
initDatabase()
const authHeader = request.headers.get('authorization')
let authenticated = false
// 优先检查 API Key 认证(服务间调用)
if (authHeader?.startsWith('Bearer ak_')) {
const key = authHeader.slice(7)
// 先用环境变量快速验证middleware 层同款逻辑)
if (verifyEnvApiKey(key)) {
authenticated = true
} else {
// 回退:数据库验证
const keyInfo = verifyApiKey(key)
if (keyInfo) authenticated = true
}
}
// 回退Cookie 认证Web UI 调用)
if (!authenticated) {
const user = await getCurrentUser()
if (user && hasPermission(user, 'tickets:read')) authenticated = true
}
if (!authenticated) {
return NextResponse.json({ error: '未登录或无权限' }, { status: 401 })
}
const { searchParams } = request.nextUrl
const ip = searchParams.get('ip')
const sn = searchParams.get('sn')
if (!ip && !sn) {
return NextResponse.json({ error: 'ip 或 sn 参数至少需要提供一个' }, { status: 400 })
}
const db = getDb()
const conditions: string[] = []
const params: unknown[] = []
if (ip) { conditions.push('device_ip = ?'); params.push(ip) }
if (sn) { conditions.push('device_sn = ?'); params.push(sn) }
const where = `WHERE ${conditions.join(' AND ')}`
const rows = db.prepare(`
SELECT id, content, fault_category, fault_subcategory,
current_status, assign_time, close_time, duration_minutes,
responsibility, availability, process_summary, conclusion, parts_replaced
FROM tickets ${where}
ORDER BY assign_time DESC
`).all(...params) as {
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; process_summary: string | null; conclusion: string | null
parts_replaced: string | null
}[]
return NextResponse.json({ tickets: rows, total: rows.length })
} catch (e) {
const msg = e instanceof Error ? e.message : '查询失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { exportTicketsToExcel } from '@/lib/excel'
export async function GET(request: NextRequest) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
const { searchParams } = request.nextUrl
const idsParam = searchParams.get('ids')
const db = getDb()
let tickets: Array<Record<string, unknown>>
if (idsParam) {
// 导出选中的工单
const ids = idsParam.split(',').map(Number).filter(n => !isNaN(n))
if (ids.length === 0) {
return NextResponse.json({ error: '无效的工单 ID' }, { status: 400 })
}
const placeholders = ids.map(() => '?').join(',')
tickets = db.prepare(`SELECT * FROM tickets WHERE id IN (${placeholders}) ORDER BY created_at DESC`).all(...ids) as Array<Record<string, unknown>>
} else {
// 导出筛选后的工单(保持原有逻辑)
const status = searchParams.get('status') || ''
const category = searchParams.get('category') || ''
const startDate = searchParams.get('startDate') || ''
const endDate = searchParams.get('endDate') || ''
const conditions: string[] = []
const params: unknown[] = []
if (status) { conditions.push('current_status = ?'); params.push(status) }
if (category) { conditions.push('fault_category = ?'); params.push(category) }
if (startDate) { conditions.push('assign_time >= ?'); params.push(startDate) }
if (endDate) { conditions.push('assign_time <= ?'); params.push(endDate) }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
tickets = db.prepare(`SELECT * FROM tickets ${where} ORDER BY created_at DESC`).all(...params) as Array<Record<string, unknown>>
}
const buffer = exportTicketsToExcel(tickets)
return new NextResponse(new Uint8Array(buffer), {
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename="tickets_${new Date().toISOString().slice(0, 10)}.xlsx"`,
},
})
} catch (e) {
const msg = e instanceof Error ? e.message : '导出失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

89
src/app/api/tickets/external/route.ts vendored Normal file
View File

@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { verifyApiKey } from '@/lib/auth'
function verifyEnvApiKey(key: string): boolean {
const allowed = process.env.ALLOWED_API_KEYS || ''
if (!allowed) return false
return allowed.split(',').map(k => k.trim()).includes(key)
}
function validateTicketNo(ticketNo: string): string | null {
if (!/^\d{14}$/.test(ticketNo)) return '工单号必须为 14 位纯数字'
const y = parseInt(ticketNo.slice(0, 4))
const m = parseInt(ticketNo.slice(4, 6))
const d = parseInt(ticketNo.slice(6, 8))
const dt = new Date(y, m - 1, d)
if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) {
return '工单号前 8 位必须为合法日期YYYYMMDD'
}
return null
}
export async function POST(request: NextRequest) {
try {
initDatabase()
const authHeader = request.headers.get('authorization')
let authenticated = false
if (authHeader?.startsWith('Bearer ak_')) {
const key = authHeader.slice(7)
if (verifyEnvApiKey(key)) {
authenticated = true
} else {
const keyInfo = verifyApiKey(key)
if (keyInfo) authenticated = true
}
}
if (!authenticated) {
return NextResponse.json({ error: '未授权:需要有效的 API Key' }, { status: 401 })
}
const body = await request.json()
const ticketNo = String(body.ticket_no || '').trim()
if (!ticketNo) {
return NextResponse.json({ error: '缺少必填字段: ticket_no' }, { status: 400 })
}
const validationError = validateTicketNo(ticketNo)
if (validationError) {
return NextResponse.json({ error: validationError }, { status: 400 })
}
const db = getDb()
const ticketId = parseInt(ticketNo)
const existing = db.prepare('SELECT * FROM tickets WHERE id = ?').get(ticketId) as Record<string, unknown> | undefined
if (existing) {
return NextResponse.json({ ticket: existing, created: false })
}
db.prepare(`
INSERT INTO tickets (id, device_ip, device_sn, device_name, content, assign_time,
ticket_type, fault_category, fault_subcategory, responsibility, current_status, counted_in_sla)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
ticketId,
body.device_ip || null,
body.device_sn || null,
body.device_name || null,
body.content || null,
body.assign_time || new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().slice(0, 19),
body.ticket_type || null,
body.fault_category || null,
body.fault_subcategory || null,
body.responsibility || null,
'open',
body.counted_in_sla ?? 1,
)
const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(ticketId)
return NextResponse.json({ ticket, created: true }, { status: 201 })
} catch (e) {
const msg = e instanceof Error ? e.message : '创建失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
export async function GET(request: NextRequest) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
const { searchParams } = request.nextUrl
const fields = searchParams.getAll('field')
const db = getDb()
const result: Record<string, string[]> = {}
for (const field of fields) {
const allowed = ['device_ip', 'device_name', 'fault_category', 'current_status', 'ticket_no', 'fault_subcategory']
if (!allowed.includes(field)) continue
const rows = db.prepare(`SELECT DISTINCT ${field} FROM tickets WHERE ${field} IS NOT NULL AND ${field} != '' ORDER BY ${field}`).all() as Record<string, string>[]
result[field] = rows.map(r => r[field])
}
return NextResponse.json(result)
} catch (e) {
const msg = e instanceof Error ? e.message : '查询失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,134 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { parseExcelTickets } from '@/lib/excel'
function validateTicketNo(ticketNo: string): string | null {
if (!/^\d{14}$/.test(ticketNo)) {
return '工单号必须为 14 位纯数字'
}
const y = parseInt(ticketNo.slice(0, 4))
const m = parseInt(ticketNo.slice(4, 6))
const d = parseInt(ticketNo.slice(6, 8))
const dt = new Date(y, m - 1, d)
if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) {
return '工单号前 8 位必须为合法日期YYYYMMDD'
}
return null
}
export async function POST(request: NextRequest) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const formData = await request.formData()
const file = formData.get('file') as File | null
const overwrite = formData.get('overwrite') === 'true'
if (!file) return NextResponse.json({ error: '请上传文件' }, { status: 400 })
const buffer = Buffer.from(await file.arrayBuffer())
const parsed = parseExcelTickets(buffer)
if (parsed.length === 0) return NextResponse.json({ error: '文件中没有有效工单数据' }, { status: 400 })
const db = getDb()
// 校验工单号
const validationErrors: string[] = []
const validTickets: typeof parsed = []
for (let i = 0; i < parsed.length; i++) {
const t = parsed[i]
if (!t.ticket_no) {
validationErrors.push(`${i + 2} 行: 缺少工单号`)
continue
}
const err = validateTicketNo(t.ticket_no)
if (err) {
validationErrors.push(`${i + 2} 行: ${err}${t.ticket_no}`)
continue
}
validTickets.push(t)
}
if (validTickets.length === 0) {
return NextResponse.json({
success: false,
error: '所有工单号校验失败',
validationErrors,
}, { status: 400 })
}
// 检查重复
const existingIds = new Set(
(db.prepare('SELECT id FROM tickets').all() as { id: number }[]).map(r => r.id)
)
const conflicts = validTickets.filter(t => existingIds.has(parseInt(t.ticket_no!)))
if (conflicts.length > 0 && !overwrite) {
return NextResponse.json({
success: false,
conflicts: conflicts.map(t => t.ticket_no),
message: `${conflicts.length} 个工单号已存在,是否覆盖?`,
requireOverwrite: true,
validationErrors: validationErrors.length > 0 ? validationErrors : undefined,
}, { status: 409 })
}
// 执行导入
const insertStmt = db.prepare(`
INSERT OR REPLACE INTO tickets (id, device_ip, device_sn, device_name, content, assign_time,
fault_category, fault_subcategory, responsibility, current_status, counted_in_sla, created_by, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
const imported: string[] = []
const errors: string[] = []
const transaction = db.transaction(() => {
for (const t of validTickets) {
try {
const ticketId = parseInt(t.ticket_no!)
insertStmt.run(
ticketId,
t.device_ip || null,
t.device_sn || null,
t.device_name || null,
t.content || null,
t.assign_time || new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().slice(0, 19),
t.fault_category || null,
t.fault_subcategory || null,
t.responsibility || null,
t.current_status || 'open',
t.counted_in_sla ?? 1,
user.id,
user.id,
)
imported.push(t.ticket_no!)
} catch (e) {
errors.push(`${validTickets.indexOf(t) + 2} 行: ${e instanceof Error ? e.message : '导入失败'}`)
}
}
})
transaction()
return NextResponse.json({
success: true,
imported: imported.length,
overwritten: conflicts.length,
errors: errors.length > 0 ? errors : undefined,
validationErrors: validationErrors.length > 0 ? validationErrors : undefined,
})
} catch (e) {
const msg = e instanceof Error ? e.message : '导入失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,164 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
export async function GET(request: NextRequest) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
const { searchParams } = request.nextUrl
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 status = searchParams.get('status') || ''
const category = searchParams.get('category') || ''
const startDate = searchParams.get('startDate') || ''
const endDate = searchParams.get('endDate') || ''
const sortBy = searchParams.get('sortBy') || 'assign_time'
const sortOrder = searchParams.get('sortOrder') === 'asc' ? 'ASC' : 'DESC'
const db = getDb()
const conditions: string[] = []
const params: unknown[] = []
if (search) {
conditions.push('(CAST(id AS TEXT) LIKE ? OR device_ip LIKE ? OR device_name LIKE ? OR content LIKE ?)')
const s = `%${search}%`
params.push(s, s, s, s)
}
if (status) {
conditions.push('current_status = ?')
params.push(status)
}
if (category) {
conditions.push('fault_category = ?')
params.push(category)
}
if (startDate) {
conditions.push('assign_time >= ?')
params.push(startDate)
}
if (endDate) {
conditions.push('assign_time <= ?')
params.push(endDate)
}
// filter_field=value style (multi-select)
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} LIKE ?`); params.push(`%${allValues[0]}%`)
} else {
const placeholders = allValues.map(() => '?').join(', ')
conditions.push(`${field} IN (${placeholders})`)
params.push(...allValues)
}
}
// date range filters (e.g. assign_time_start, assign_time_end)
const rangeKeys = [...searchParams.keys()].filter(k => k.endsWith('_start') || k.endsWith('_end'))
for (const key of rangeKeys) {
const val = searchParams.get(key)
if (!val) continue
if (key.endsWith('_start')) {
const field = key.replace('_start', '')
conditions.push(`${field} >= ?`); params.push(val)
} else if (key.endsWith('_end')) {
const field = key.replace('_end', '')
conditions.push(`${field} <= ?`); params.push(val)
}
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
const allowedSorts = ['id', 'assign_time', 'close_time', 'current_status', 'fault_category', 'created_at']
const sort = allowedSorts.includes(sortBy) ? sortBy : 'created_at'
const countRow = db.prepare(`SELECT COUNT(*) as total FROM tickets ${where}`).get(...params) as { total: number }
const total = countRow.total
const offset = (page - 1) * pageSize
const tickets = db.prepare(`SELECT * FROM tickets ${where} ORDER BY ${sort} ${sortOrder} LIMIT ? OFFSET ?`).all(...params, pageSize, offset)
return NextResponse.json({
tickets,
pagination: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) },
})
} catch (e) {
const msg = e instanceof Error ? e.message : '查询失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const body = await request.json()
const db = getDb()
// 校验工单号
const ticketNo = String(body.ticket_no || '').trim()
if (!/^\d{14}$/.test(ticketNo)) {
return NextResponse.json({ error: '工单号必须为 14 位纯数字' }, { status: 400 })
}
const y = parseInt(ticketNo.slice(0, 4))
const m = parseInt(ticketNo.slice(4, 6))
const d = parseInt(ticketNo.slice(6, 8))
const dt = new Date(y, m - 1, d)
if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) {
return NextResponse.json({ error: '工单号前 8 位必须为合法日期YYYYMMDD' }, { status: 400 })
}
const ticketId = parseInt(ticketNo)
const existing = db.prepare('SELECT id FROM tickets WHERE id = ?').get(ticketId)
if (existing) {
return NextResponse.json({ error: '工单号已存在' }, { status: 409 })
}
const result = db.prepare(`
INSERT INTO tickets (id, device_ip, device_sn, device_name, content, assign_time,
ticket_type, fault_category, fault_subcategory, responsibility, current_status, counted_in_sla, created_by, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
ticketId,
body.device_ip || null,
body.device_sn || null,
body.device_name || null,
body.content || null,
body.assign_time || new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().slice(0, 19),
body.ticket_type || null,
body.fault_category || null,
body.fault_subcategory || null,
body.responsibility || null,
body.current_status || 'open',
body.counted_in_sla ?? 1,
user.id,
user.id,
)
// Insert steps if provided
if (body.steps && Array.isArray(body.steps)) {
const stmt = db.prepare('INSERT INTO ticket_steps (ticket_id, step_order, time_node, handler, description) VALUES (?, ?, ?, ?, ?)')
for (let i = 0; i < body.steps.length; i++) {
const step = body.steps[i]
stmt.run(result.lastInsertRowid, i + 1, step.time_node || null, step.handler || null, step.description || null)
}
}
const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(result.lastInsertRowid)
return NextResponse.json({ ticket }, { status: 201 })
} catch (e) {
const msg = e instanceof Error ? e.message : '创建失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser, hashPassword } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'users:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { id } = await params
const body = await request.json()
const db = getDb()
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id)
if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
const fields: string[] = []
const values: unknown[] = []
if (body.display_name) { fields.push('display_name = ?'); values.push(body.display_name) }
if (body.email !== undefined) { fields.push('email = ?'); values.push(body.email) }
if (body.role) { fields.push('role = ?'); values.push(body.role) }
if (body.is_active !== undefined) { fields.push('is_active = ?'); values.push(body.is_active ? 1 : 0) }
if (body.password) { fields.push('password_hash = ?'); values.push(hashPassword(body.password)) }
if (fields.length > 0) {
fields.push("updated_at = datetime('now')")
values.push(id)
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values)
}
const updated = 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: updated })
} catch (e) {
const msg = e instanceof Error ? e.message : '更新失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'users:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { id } = await params
if (String(id) === String(user.id)) return NextResponse.json({ error: '不能删除自己' }, { status: 400 })
const db = getDb()
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 })
} catch (e) {
const msg = e instanceof Error ? e.message : '删除失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { hashPassword } from '@/lib/auth'
export async function GET() {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'users:read')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const db = getDb()
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 })
} catch (e) {
const msg = e instanceof Error ? e.message : '查询失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'users:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
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 db = getDb()
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username)
if (existing) return NextResponse.json({ error: '用户名已存在' }, { status: 400 })
const hash = hashPassword(password)
const result = db.prepare(
'INSERT INTO users (username, password_hash, display_name, email, role) VALUES (?, ?, ?, ?, ?)'
).run(username, hash, display_name, email || null, role || 'viewer')
const newUser = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at FROM users WHERE id = ?').get(result.lastInsertRowid)
return NextResponse.json({ user: newUser }, { status: 201 })
} catch (e) {
const msg = e instanceof Error ? e.message : '创建失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

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

@ -0,0 +1,34 @@
@import "tailwindcss";
@config "../../tailwind.config.js";
@layer base {
body {
background-color: #f8fafc;
color: #0f172a;
}
html.dark body {
background-color: #020617;
color: #ffffff;
}
}
@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;
}
}

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

@ -0,0 +1,23 @@
export const dynamic = 'force-dynamic'
import type { Metadata } from 'next'
import Script from 'next/script'
import './globals.css'
export const metadata: Metadata = {
title: 'IT 工单跟踪系统',
description: '设备工单跟踪、统计分析与报告生成系统',
}
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<head>
<Script id="theme-init" strategy="beforeInteractive" 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>
)
}

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

@ -0,0 +1,12 @@
export const dynamic = 'force-dynamic'
import { redirect } from 'next/navigation'
import { getCurrentUser } from '@/lib/auth'
import { initDatabase } from '@/lib/db-schema'
export default async function Home() {
initDatabase()
const user = await getCurrentUser()
if (user) redirect('/dashboard')
else redirect('/login')
}

View File

@ -0,0 +1,50 @@
'use client'
import { Card } from '@/components/ui'
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts'
interface CategoryData {
fault_category: string
count: number
}
const COLORS = ['#3b82f6', '#06b6d4', '#f59e0b', '#ef4444', '#8b5cf6', '#10b981', '#f97316', '#ec4899']
export default function CategoryChart({ data }: { data: CategoryData[] }) {
if (!data || data.length === 0) return null
const chartData = data.map(d => ({ name: d.fault_category || '未分类', 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 className="h-80">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={90}
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>
</ResponsiveContainer>
</div>
</Card>
)
}

View File

@ -0,0 +1,101 @@
'use client'
import { Card } from '@/components/ui'
import { Ticket, Clock, CheckCircle, TrendingUp, AlertCircle, BarChart3 } from 'lucide-react'
interface StatsData {
total: number
open: number
in_progress: number
resolved: number
closed: number
thisMonth: number
avgDuration: number
slaRate: number
}
interface StatCardProps {
title: string
value: string | number
icon: React.ReactNode
color: string
}
function StatCard({ title, value, icon, color }: StatCardProps) {
const displayValue = (value !== undefined && value !== null && !(typeof value === 'number' && isNaN(value))) ? value : '—'
return (
<Card>
<div
className="dark-bg-card"
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
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '1rem' }}>
<StatCard
title="总工单数"
value={stats.total}
icon={<Ticket size={20} style={{ color: 'white' }} />}
color="bg-blue-600"
/>
<StatCard
title="本月工单"
value={stats.thisMonth}
icon={<TrendingUp size={20} style={{ color: 'white' }} />}
color="bg-cyan-500"
/>
<StatCard
title="平均处理时长"
value={`${stats.avgDuration} 分钟`}
icon={<Clock size={20} style={{ color: 'white' }} />}
color="bg-amber-500"
/>
<StatCard
title="整体服务可用性"
value={`${stats.slaRate}%`}
icon={<CheckCircle size={20} style={{ color: 'white' }} />}
color={stats.slaRate >= 90 ? 'bg-emerald-500' : stats.slaRate >= 70 ? 'bg-amber-500' : 'bg-red-500'}
/>
<StatCard
title="待处理"
value={stats.open}
icon={<AlertCircle size={20} style={{ color: 'white' }} />}
color="bg-red-500"
/>
<StatCard
title="处理中"
value={stats.in_progress}
icon={<Clock size={20} style={{ color: 'white' }} />}
color="bg-amber-500"
/>
<StatCard
title="已解决"
value={stats.resolved}
icon={<CheckCircle size={20} style={{ color: 'white' }} />}
color="bg-emerald-500"
/>
<StatCard
title="已关闭"
value={stats.closed}
icon={<BarChart3 size={20} style={{ color: 'white' }} />}
color="bg-slate-500"
/>
</div>
)
}

View File

@ -0,0 +1,39 @@
'use client'
import { Card } from '@/components/ui'
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
interface TrendData {
month: string
tickets: number
avg_duration: number
}
export default function TrendChart({ data }: { data: TrendData[] }) {
if (!data || data.length === 0) return null
return (
<Card className="p-5">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-4">13</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<defs>
<linearGradient id="colorTickets" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="month" 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' }}
/>
<Area type="monotone" dataKey="tickets" name="工单数" stroke="#3b82f6" fillOpacity={1} fill="url(#colorTickets)" />
</AreaChart>
</ResponsiveContainer>
</div>
</Card>
)
}

View File

@ -0,0 +1,20 @@
'use client'
import Sidebar from './Sidebar'
import TopBar from './TopBar'
import { ReactNode, useEffect, useState } from 'react'
interface AppShellProps { children: ReactNode }
export default function AppShell({ children }: AppShellProps) {
const [user, setUser] = useState<{ username: string; display_name: string; role: string } | null>(null)
useEffect(() => {
fetch('/api/auth/me').then((r) => r.json()).then((d) => { if (d.user) setUser(d.user) }).catch(() => {})
}, [])
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-950">
<Sidebar />
<TopBar user={user} />
<main className="ml-60 pt-14 min-h-screen"><div className="p-6">{children}</div></main>
</div>
)
}

View File

@ -0,0 +1,64 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { LayoutDashboard, FileText, Settings, Users, Shield, Key, Clock, CheckCircle, PlusSquare, Upload, List } from 'lucide-react'
const navItems = [
{ href: '/dashboard', label: '仪表盘', icon: LayoutDashboard },
{ href: '/tickets/pending', label: '待办工单', icon: Clock },
{ href: '/tickets/completed', label: '已办工单', icon: CheckCircle },
{ href: '/tickets/create', label: '手动建单', icon: PlusSquare },
{ href: '/tickets/import', label: '导入工单', icon: Upload },
{ href: '/reports', label: '报告管理', icon: FileText },
]
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()
const [isAdmin, setIsAdmin] = useState(false)
useEffect(() => {
fetch('/api/auth/me')
.then(r => r.json())
.then(u => { if (u.user?.role === 'admin') setIsAdmin(true) })
.catch(() => {})
}, [])
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">IT工单跟踪系统</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>)
})}
{isAdmin && (
<Link
href="/tickets/all"
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${pathname === '/tickets/all' ? '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'}`}
>
<List size={18} />
</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,21 @@
'use client'
import { useTheme } from '@/components/providers/ThemeProvider'
import { Sun, Moon, LogOut, User } from 'lucide-react'
import { useRouter } from 'next/navigation'
interface TopBarProps { user: { username: string; display_name: string; role: string } | null }
export default function TopBar({ user }: TopBarProps) {
const { theme, toggleTheme } = useTheme()
const router = useRouter()
const handleLogout = async () => { 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 text-slate-500 hover:bg-slate-100 dark:text-slate-400 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 text-slate-600 dark:text-slate-300"><User size={16} /><span>{user.display_name}</span></div><button onClick={handleLogout} className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors" title="退出登录"><LogOut size={18} /></button></div>)}
</div>
</header>
)
}

View File

@ -0,0 +1,25 @@
'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(() => {
const stored = localStorage.getItem('theme') as Theme | null
if (stored) { setTheme(stored); document.documentElement.classList.toggle('dark', stored === 'dark') }
else {
const d = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
setTheme(d); document.documentElement.classList.toggle('dark', d === 'dark')
}
}, [])
const toggleTheme = () => {
const n = theme === 'dark' ? 'light' : 'dark'
setTheme(n); localStorage.setItem('theme', n); document.documentElement.classList.toggle('dark', n === 'dark')
}
return <ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>
}

View File

@ -0,0 +1,367 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui'
import SelectWithInput from '@/components/ui/SelectWithInput'
import { Plus, Trash2 } from 'lucide-react'
interface TicketData {
id: number
assign_time: string | null
fault_category: string | null
fault_subcategory: string | null
responsibility: string | null
current_status: string
}
interface TimelineStep {
time_node: string
handler: string
description: string
}
interface ProcessFormProps {
ticket: TicketData
currentUserDisplayName: string
onSuccess: () => void
onCancel: () => void
}
const FAULT_CATEGORIES = [
'硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障', '其他',
]
const TICKET_TYPES = ['OEM诊断', 'OEM维修']
const FAULT_SUBCATEGORIES = [
'GPU故障', 'CPU故障', '内存故障', '硬盘故障', '电源故障', '风扇故障',
'OS崩溃', '驱动故障', '软件崩溃', '配置错误', '网络丢包', '网络中断',
'存储掉盘', 'RAID故障', '无故障', '其他',
]
const HANDLER_OPTIONS = ['腾讯', '图灵']
function beijingNow(): string {
const d = new Date(Date.now() + 8 * 60 * 60 * 1000)
return d.toISOString().slice(0, 10) + ' ' + d.toISOString().slice(11, 19)
}
const TIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?$/
export default function ProcessForm({ ticket, currentUserDisplayName, onSuccess, onCancel }: ProcessFormProps) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [closeTimeError, setCloseTimeError] = useState('')
const [stepTimeErrors, setStepTimeErrors] = useState<Record<number, string>>({})
// 工单类型
const [ticketType, setTicketType] = useState((ticket as any).ticket_type || '')
// 故障信息修正in_progress 时预填已有值)
const [faultCategory, setFaultCategory] = useState(ticket.fault_category || '')
const [faultSubcategory, setFaultSubcategory] = useState(ticket.fault_subcategory || '')
const [responsibility, setResponsibility] = useState(ticket.responsibility || '')
// 结单信息
const [closeTime, setCloseTime] = useState(beijingNow())
const [processSummary, setProcessSummary] = useState('')
const [conclusion, setConclusion] = useState('')
const [partsReplaced, setPartsReplaced] = useState<'是' | '否'>('否')
const [partsName, setPartsName] = useState('')
// 处理时间线(默认至少一个步骤)
const [steps, setSteps] = useState<TimelineStep[]>([
{ time_node: beijingNow(), handler: '图灵', description: '' },
])
function addStep() {
setSteps(prev => [...prev, { time_node: beijingNow(), handler: '', description: '' }])
}
function removeStep(index: number) {
setSteps(prev => prev.filter((_, i) => i !== index))
}
function updateStep(index: number, field: keyof TimelineStep, value: string) {
setSteps(prev => prev.map((s, i) => (i === index ? { ...s, [field]: value } : s)))
}
async function handleSubmit() {
setError('')
// 必填项校验
if (!faultCategory) {
setError('故障大类不能为空')
return
}
if (!faultSubcategory) {
setError('故障小类不能为空')
return
}
if (!closeTime) {
setError('结单时间不能为空')
return
}
if (closeTime && ticket.assign_time && closeTime < ticket.assign_time) {
setError('结单时间不能早于派单时间')
return
}
if (!processSummary.trim()) {
setError('处理结果不能为空')
return
}
if (!conclusion.trim()) {
setError('结论不能为空')
return
}
if (partsReplaced === '是' && !partsName.trim()) {
setError('选择更换配件后,配件名称不能为空')
return
}
if (closeTime && !TIME_RE.test(closeTime)) {
setError('结单时间格式不正确,请使用 YYYY-MM-DD HH:mm:ss 格式')
return
}
const badStep = steps.find(s => s.time_node && !TIME_RE.test(s.time_node))
if (badStep) {
setError(`处理步骤"${badStep.description.slice(0, 20)}"的时间节点格式不正确,请使用 YYYY-MM-DD HH:mm:ss 格式`)
return
}
// 至少一个处理步骤,且描述必填
const emptySteps = steps.filter(s => !s.description.trim())
if (emptySteps.length > 0) {
setError('每个处理步骤的描述不能为空')
return
}
if (!confirm(`确认提交处理?\n\n工单类型${ticketType || '无'}\n故障大类${faultCategory}\n结单时间${closeTime}\n处理结果${processSummary.slice(0, 50)}${processSummary.length > 50 ? '...' : ''}\n处理步骤${steps.length}\n\n提交后将标记为"已解决"。`)) {
return
}
setLoading(true)
try {
const assignTime = ticket.assign_time ? new Date(ticket.assign_time).getTime() : 0
const closeMs = new Date(closeTime).getTime()
const durationMinutes = assignTime ? Math.round((closeMs - assignTime) / 60000) : 0
const body: Record<string, unknown> = {
ticket_type: ticketType || null,
fault_category: faultCategory || null,
fault_subcategory: faultSubcategory || null,
responsibility: responsibility || null,
close_time: closeTime,
process_summary: processSummary || null,
conclusion: conclusion || null,
parts_replaced: partsReplaced,
parts_name: partsReplaced === '是' ? partsName : null,
current_status: 'resolved',
duration_minutes: durationMinutes,
steps: steps.map(s => ({
time_node: s.time_node || null,
handler: s.handler || null,
description: s.description || null,
})),
}
const res = await fetch(`/api/tickets/${ticket.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const data = await res.json()
setError(data.error || '提交失败')
return
}
onSuccess()
} catch {
setError('网络错误')
} finally {
setLoading(false)
}
}
return (
<div className="rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-6 space-y-6">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100"></h2>
{/* 故障信息修正 */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-slate-500 dark:text-slate-400 border-b border-slate-100 dark:border-slate-700 pb-2"></h3>
<div className="grid grid-cols-2 gap-4">
<SelectWithInput
label="工单类型"
value={ticketType}
onChange={setTicketType}
options={TICKET_TYPES}
placeholder="请选择或输入..."
/>
<SelectWithInput
label="责任方"
value={responsibility}
onChange={setResponsibility}
options={['腾讯', '图灵']}
placeholder="选择或输入责任方..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<SelectWithInput
label={<> <span className="text-red-500">*</span></>}
value={faultCategory}
onChange={setFaultCategory}
options={FAULT_CATEGORIES}
placeholder="请选择或输入..."
/>
<SelectWithInput
label={<> <span className="text-red-500">*</span></>}
value={faultSubcategory}
onChange={setFaultSubcategory}
options={FAULT_SUBCATEGORIES}
placeholder="请选择或输入..."
/>
</div>
</div>
{/* 结单信息 */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-slate-500 dark:text-slate-400 border-b border-slate-100 dark:border-slate-700 pb-2"></h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"> <span className="text-red-500">*</span></label>
<input
type="text"
className={`w-full px-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 ${closeTimeError ? 'border-red-500 bg-red-50 dark:bg-red-950/20' : 'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-slate-100'}`}
value={closeTime}
onChange={e => { setCloseTime(e.target.value); setCloseTimeError('') }}
onBlur={() => {
if (closeTime && !TIME_RE.test(closeTime)) {
setCloseTimeError('时间格式不正确,请使用 YYYY-MM-DD HH:mm:ss 格式')
}
}}
placeholder="2026-05-02 18:34:36"
/>
{closeTimeError && <p className="text-xs text-red-500 mt-1">{closeTimeError}</p>}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"> <span className="text-red-500">*</span></label>
<textarea
className="w-full px-3 py-2 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 min-h-[80px]"
value={processSummary}
onChange={e => setProcessSummary(e.target.value)}
placeholder="描述处理结果"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"> <span className="text-red-500">*</span></label>
<textarea
className="w-full px-3 py-2 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 min-h-[80px]"
value={conclusion}
onChange={e => setConclusion(e.target.value)}
placeholder="处理结论..."
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"></label>
<div className="flex gap-3">
<button
type="button"
onClick={() => setPartsReplaced('是')}
className={`px-4 py-1.5 rounded-lg text-sm border transition-colors ${partsReplaced === '是' ? 'bg-blue-600 text-white border-blue-600' : 'border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300'}`}
></button>
<button
type="button"
onClick={() => setPartsReplaced('否')}
className={`px-4 py-1.5 rounded-lg text-sm border transition-colors ${partsReplaced === '否' ? 'bg-blue-600 text-white border-blue-600' : 'border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300'}`}
></button>
</div>
</div>
{partsReplaced === '是' && (
<div className="max-w-xs">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"> <span className="text-red-500">*</span></label>
<input
className="w-full px-3 py-2 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"
value={partsName}
onChange={e => setPartsName(e.target.value)}
placeholder="输入更换的配件名称"
/>
</div>
)}
</div>
{/* 处理时间线 */}
<div className="space-y-4">
<div className="flex items-center justify-between border-b border-slate-100 dark:border-slate-700 pb-2">
<h3 className="text-sm font-medium text-slate-500 dark:text-slate-400">线</h3>
<Button type="button" variant="secondary" size="sm" onClick={addStep}>
<Plus size={14} className="mr-1" />
</Button>
</div>
<p className="text-xs text-slate-400 dark:text-slate-500 -mt-2"></p>
{steps.length === 0 && (
<p className="text-sm text-slate-400 dark:text-slate-500"></p>
)}
{steps.map((step, idx) => (
<div key={idx} className="flex gap-4 p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
<div className="flex flex-col items-center">
<div className="w-8 h-8 rounded-full bg-blue-600 text-white flex items-center justify-center text-xs font-medium">{idx + 1}</div>
</div>
<div className="flex-1 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1"></label>
<input
type="text"
className={`w-full px-2 py-1.5 rounded border text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 ${stepTimeErrors[idx] ? 'border-red-500 bg-red-50 dark:bg-red-950/20' : 'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-slate-100'}`}
value={step.time_node}
onChange={e => { updateStep(idx, 'time_node', e.target.value); setStepTimeErrors(prev => { const next = { ...prev }; delete next[idx]; return next }) }}
onBlur={() => {
if (step.time_node && !TIME_RE.test(step.time_node)) {
setStepTimeErrors(prev => ({ ...prev, [idx]: '时间格式不正确,请使用 YYYY-MM-DD HH:mm:ss 格式' }))
}
}}
placeholder="2026-05-02 18:34:36"
/>
{stepTimeErrors[idx] && <p className="text-xs text-red-500 mt-1">{stepTimeErrors[idx]}</p>}
</div>
<SelectWithInput
label="处理人"
value={step.handler}
onChange={val => updateStep(idx, 'handler', val)}
options={HANDLER_OPTIONS}
placeholder="选择或输入处理人..."
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1"> <span className="text-red-500">*</span></label>
<textarea
className="w-full px-2 py-1.5 rounded 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 min-h-[60px]"
value={step.description}
onChange={e => updateStep(idx, 'description', e.target.value)}
placeholder="描述此处理步骤..."
/>
</div>
{steps.length > 1 && (
<div className="flex justify-end">
<Button type="button" variant="ghost" size="sm" onClick={() => removeStep(idx)}>
<Trash2 size={14} className="text-red-500 mr-1" />
</Button>
</div>
)}
</div>
</div>
))}
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-3 pt-2">
<Button type="button" onClick={handleSubmit} disabled={loading}>
{loading ? '提交中...' : '提交处理'}
</Button>
<Button type="button" variant="secondary" onClick={onCancel}></Button>
</div>
</div>
)
}

View File

@ -0,0 +1,30 @@
'use client'
import { useState, useEffect } from 'react'
import { getSlaStatus } from '@/lib/sla'
interface SlaCountdownProps {
assignTime: string
countedInSla: number
}
export default function SlaCountdown({ assignTime, countedInSla }: SlaCountdownProps) {
const [status, setStatus] = useState(() =>
assignTime ? getSlaStatus(assignTime, countedInSla) : null
)
useEffect(() => {
if (!assignTime) return
const timer = setInterval(() => {
setStatus(getSlaStatus(assignTime, countedInSla))
}, 1000)
return () => clearInterval(timer)
}, [assignTime, countedInSla])
if (!status) return <span className="text-xs text-slate-400">-</span>
return (
<span className="text-xs font-medium" style={{ color: status.color }}>
{status.text}
</span>
)
}

View File

@ -0,0 +1,238 @@
'use client'
import Link from 'next/link'
import { Badge, Button, Card } from '@/components/ui'
import { Pencil, Clock, User, Cpu, Server, ExternalLink, History, Play, Trash2 } from 'lucide-react'
import { getTier1Deadline } from '@/lib/sla'
interface TicketStep {
id: number
step_order: number
time_node: string | null
handler: string | null
description: string | null
}
interface TicketData {
id: number
device_ip: string | null
device_sn: string | null
device_name: string | null
content: string | null
assign_time: string | null
close_time: string | null
duration_minutes: number | null
availability: number | null
process_summary: string | null
conclusion: string | null
fault_category: string | null
fault_subcategory: string | null
parts_replaced: string | null
parts_name: string | null
current_status: string
counted_in_sla: number
responsibility: string | null
created_at: string
updated_at: string
}
interface AssetInfo {
id: number
node_name: string
serial_number: string
device_type: string
business_ip: string | null
hdm_ip: string | null
manufacturer: string
device_model: string
status: string
}
interface HistoryTicket {
id: number
content: string | null
fault_category: string | null
current_status: string
assign_time: string | null
}
const statusMap: Record<string, { label: string; variant: 'default' | 'info' | 'warning' | 'success' | 'danger' }> = {
open: { label: '待处理', variant: 'info' },
in_progress: { label: '处理中', variant: 'warning' },
resolved: { label: '已解决', variant: 'success' },
closed: { label: '已关闭', variant: 'default' },
}
const assetsUrl = process.env.NEXT_PUBLIC_ASSETS_URL || 'https://assets.tlyq.ai'
export default function TicketDetail({
ticket,
steps,
assetInfo,
history,
showEdit = true,
showProcess,
processLabel,
onProcess,
onDelete,
canDelete = true,
}: {
ticket: TicketData
steps: TicketStep[]
assetInfo: AssetInfo | null
history?: HistoryTicket[]
showEdit?: boolean
showProcess?: boolean
processLabel?: string
onProcess?: () => void
onDelete?: () => void
canDelete?: boolean
}) {
const st = statusMap[ticket.current_status] || { label: ticket.current_status, variant: 'default' as const }
const InfoRow = ({ label, value }: { label: string; value: React.ReactNode }) => (
<div className="flex py-2 border-b border-slate-100 dark:border-slate-800 last:border-0">
<span className="w-28 text-sm text-slate-500 dark:text-slate-400 shrink-0">{label}</span>
<span className="text-sm text-slate-900 dark:text-slate-100">{value || '-'}</span>
</div>
)
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">{ticket.id}</h1>
<Badge variant={st.variant}>{st.label}</Badge>
{ticket.counted_in_sla === 0 && <Badge variant="warning">SLA </Badge>}
</div>
{showProcess && (
<div className="flex items-center gap-2">
<Button size="sm" onClick={onProcess}><Play size={14} className="mr-1" />{processLabel || '开始处理'}</Button>
{canDelete && <Button size="sm" variant="ghost" onClick={onDelete}><Trash2 size={14} className="text-red-500" /></Button>}
</div>
)}
{!showProcess && showEdit && <Link href={`/tickets/${ticket.id}/edit`}><Button size="sm"><Pencil size={14} className="mr-1" /></Button></Link>}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Ticket Info */}
<Card className="lg:col-span-2 p-5">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3 flex items-center gap-2"><Cpu size={16} /></h2>
<InfoRow label="业务IP" value={ticket.device_ip ? (
<Link href={`/tickets/pending?device_ip=${encodeURIComponent(ticket.device_ip)}`} className="text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1">{ticket.device_ip}<ExternalLink size={11} /></Link>
) : '-'} />
<InfoRow label="节点名称" value={ticket.device_name ? (
<Link href={`/tickets/pending?device_name=${encodeURIComponent(ticket.device_name)}`} className="text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1">{ticket.device_name}<ExternalLink size={11} /></Link>
) : '-'} />
<InfoRow label="设备序列号" value={ticket.device_sn} />
<InfoRow label="工单内容" value={ticket.content} />
<InfoRow label="工单类型" value={(ticket as any).ticket_type || '-'} />
<InfoRow label="故障大类" value={ticket.fault_category} />
<InfoRow label="故障小类" value={ticket.fault_subcategory} />
<InfoRow label="责任方" value={ticket.responsibility} />
<InfoRow label="派单时间" value={ticket.assign_time ? new Date(ticket.assign_time).toLocaleString('zh-CN') : '-'} />
{ticket.assign_time && ticket.counted_in_sla !== 0 && (
<InfoRow label="超时时间" value={getTier1Deadline(ticket.assign_time).toLocaleString('zh-CN', { hourCycle: 'h23' })} />
)}
<InfoRow label="结单时间" value={ticket.close_time ? new Date(ticket.close_time).toLocaleString('zh-CN') : '-'} />
<InfoRow label="处理时长" value={ticket.duration_minutes != null ? `${ticket.duration_minutes} 分钟` : '-'} />
<InfoRow label="可用性" value={ticket.availability != null ? `${(ticket.availability * 100).toFixed(2)}%` : '-'} />
<InfoRow label="更换配件" value={ticket.parts_replaced} />
<InfoRow label="配件名称" value={ticket.parts_name} />
{ticket.process_summary && <InfoRow label="处理结果" value={ticket.process_summary} />}
{ticket.conclusion && <InfoRow label="结论" value={ticket.conclusion} />}
</Card>
{/* Asset Info */}
<Card className="p-5">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3 flex items-center gap-2"><Server size={16} /></h2>
{assetInfo ? (
<>
<InfoRow label="业务IP" value={
<a
href={`${assetsUrl}/assets/${assetInfo.id}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1"
>
{assetInfo.business_ip || '-'}
<ExternalLink size={11} />
</a>
} />
<InfoRow label="节点名称" value={assetInfo.node_name} />
<InfoRow label="设备类型" value={assetInfo.device_type} />
<InfoRow label="HDM IP" value={assetInfo.hdm_ip ? <a href={`http://${assetInfo.hdm_ip}`} target="_blank" rel="noopener noreferrer" className="text-blue-600 dark:text-blue-400 hover:underline">{assetInfo.hdm_ip}</a> : '-'} />
<InfoRow label="序列号" value={assetInfo.serial_number} />
<InfoRow label="厂商" value={assetInfo.manufacturer} />
<InfoRow label="型号" value={assetInfo.device_model} />
<InfoRow label="状态" value={assetInfo.status} />
</>
) : (
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
)}
</Card>
</div>
{/* Timeline — 仅在有步骤时显示 */}
{steps.length > 0 && (
<Card className="p-5">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-4 flex items-center gap-2"><Clock size={16} />线</h2>
<div className="space-y-4">
{steps.map((step, idx) => (
<div key={step.id} className="flex gap-4">
<div className="flex flex-col items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium ${idx === steps.length - 1 ? 'bg-blue-600 text-white' : 'bg-slate-200 dark:bg-slate-700 text-slate-600 dark:text-slate-300'}`}>
{step.step_order}
</div>
{idx < steps.length - 1 && <div className="w-px flex-1 bg-slate-200 dark:bg-slate-700 my-1" />}
</div>
<div className="flex-1 pb-4">
<div className="flex items-center gap-3 text-sm">
{step.time_node && <span className="text-slate-500 dark:text-slate-400">{new Date(step.time_node).toLocaleString('zh-CN')}</span>}
{step.handler && <span className="flex items-center gap-1 text-slate-600 dark:text-slate-300"><User size={12} />{step.handler}</span>}
</div>
<p className="text-sm text-slate-900 dark:text-slate-100 mt-1">{step.description || '-'}</p>
</div>
</div>
))}
</div>
</Card>
)}
{/* History Tickets */}
{history && history.length > 0 && (
<Card className="p-5">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-4 flex items-center gap-2"><History size={16} /> {history.length} </h2>
<div className="space-y-3">
{history.map((h) => {
const hs = statusMap[h.current_status] || { label: h.current_status, variant: 'default' as const }
return (
<Link
key={h.id}
href={`/tickets/${h.id}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-start gap-3 p-3 rounded-lg border border-slate-100 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-blue-600 dark:text-blue-400">{h.id}</span>
<Badge variant={hs.variant}>{hs.label}</Badge>
{h.fault_category && (
<span className="text-xs text-slate-400 dark:text-slate-500">{h.fault_category}</span>
)}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 line-clamp-1">{h.content || '无工单内容'}</p>
</div>
<div className="text-xs text-slate-400 dark:text-slate-500 shrink-0">
{h.assign_time ? new Date(h.assign_time).toLocaleDateString('zh-CN') : '-'}
</div>
</Link>
)
})}
</div>
</Card>
)}
</div>
)
}

View File

@ -0,0 +1,231 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Input, Select } from '@/components/ui'
import { Search } from 'lucide-react'
import SelectWithInput from '@/components/ui/SelectWithInput'
interface TicketFormProps {
initialData?: Record<string, unknown>
ticketId?: number
}
const defaultFaultCategories = [
'硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障', '其他',
]
const defaultTicketTypes = ['OEM诊断', 'OEM维修']
const defaultFaultSubcategories = [
'GPU故障', 'CPU故障', '内存故障', '硬盘故障', '电源故障', '风扇故障',
'OS崩溃', '驱动故障', '软件崩溃', '配置错误', '网络丢包', '网络中断',
'存储掉盘', 'RAID故障', '无故障', '其他',
]
export default function TicketForm({ initialData, ticketId }: TicketFormProps) {
const router = useRouter()
const isEdit = !!ticketId
const timeRe = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?$/
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [assignTimeError, setAssignTimeError] = useState('')
const [closeTimeError, setCloseTimeError] = useState('')
const [lookingUp, setLookingUp] = useState(false)
const [form, setForm] = useState({
ticket_no: String(initialData?.id || ''),
device_ip: (initialData?.device_ip as string) || '',
device_sn: (initialData?.device_sn as string) || '',
device_name: (initialData?.device_name as string) || '',
content: (initialData?.content as string) || '',
assign_time: (initialData?.assign_time as string) || (() => { const d = new Date(Date.now() + 8 * 60 * 60 * 1000); return d.toISOString().slice(0, 10) + ' ' + d.toISOString().slice(11, 19); })(),
ticket_type: (initialData?.ticket_type as string) || '',
fault_category: (initialData?.fault_category as string) || '',
fault_subcategory: (initialData?.fault_subcategory as string) || '',
responsibility: (initialData?.responsibility as string) || '',
current_status: (initialData?.current_status as string) || 'open',
counted_in_sla: (initialData?.counted_in_sla as number) ?? 1,
close_time: (initialData?.close_time as string) || '',
duration_minutes: (initialData?.duration_minutes as number) || '',
process_summary: (initialData?.process_summary as string) || '',
conclusion: (initialData?.conclusion as string) || '',
parts_replaced: (initialData?.parts_replaced as string) || '',
parts_name: (initialData?.parts_name as string) || '',
})
const handleIpLookup = async () => {
if (!form.device_ip) return
setLookingUp(true)
try {
const res = await fetch(`/api/assets/lookup?ip=${encodeURIComponent(form.device_ip)}`)
if (res.ok) {
const data = await res.json()
if (data.asset) {
setForm(prev => ({
...prev,
device_name: data.asset.node_name || prev.device_name,
device_sn: data.asset.serial_number || prev.device_sn,
}))
}
}
} catch {
// ignore
} finally {
setLookingUp(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (form.assign_time && !timeRe.test(form.assign_time)) {
setError('派单时间格式不正确,请使用 YYYY-MM-DD HH:mm:ss 格式')
return
}
if (form.close_time && !timeRe.test(form.close_time)) {
setError('结单时间格式不正确,请使用 YYYY-MM-DD HH:mm:ss 格式')
return
}
setError('')
setLoading(true)
try {
const url = isEdit ? `/api/tickets/${ticketId}` : '/api/tickets'
const method = isEdit ? 'PUT' : 'POST'
const body: Record<string, unknown> = { ...form }
if (body.duration_minutes === '') delete body.duration_minutes
else body.duration_minutes = Number(body.duration_minutes)
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
const data = await res.json()
if (!res.ok) { setError(data.error || '操作失败'); return }
router.push(isEdit ? `/tickets/${ticketId}` : '/tickets/pending')
router.refresh()
} catch {
setError('网络错误')
} finally {
setLoading(false)
}
}
const update = (key: string, value: string | number) => setForm(prev => ({ ...prev, [key]: value }))
return (
<form onSubmit={handleSubmit} className="space-y-6 max-w-3xl">
{/* 工单号独占一行 */}
<div className="space-y-1">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300"></label>
<input
className="w-full px-3 py-2 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 disabled:opacity-50 disabled:cursor-not-allowed"
value={form.ticket_no}
onChange={(e) => update('ticket_no', e.target.value)}
placeholder="14位工单号如 20260420039303"
disabled={isEdit}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">IP</label>
<div className="flex gap-2">
<input
className="flex-1 px-3 py-2 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"
value={form.device_ip}
onChange={(e) => update('device_ip', e.target.value)}
placeholder="输入设备 IP"
/>
<Button type="button" variant="secondary" size="sm" onClick={handleIpLookup} disabled={lookingUp}>
<Search size={14} />
</Button>
</div>
</div>
<Input label="节点名称" value={form.device_name} onChange={(e) => update('device_name', e.target.value)} placeholder="自动填充或手动输入" />
<Input label="设备序列号" value={form.device_sn} onChange={(e) => update('device_sn', e.target.value)} placeholder="自动填充或手动输入" />
<SelectWithInput
label="工单类型"
value={form.ticket_type}
onChange={val => update('ticket_type', val)}
options={defaultTicketTypes}
placeholder="请选择或输入工单类型..."
/>
<SelectWithInput
label="故障大类"
value={form.fault_category}
onChange={val => update('fault_category', val)}
options={defaultFaultCategories}
placeholder="请选择或输入故障大类..."
/>
<SelectWithInput
label="故障小类"
value={form.fault_subcategory}
onChange={val => update('fault_subcategory', val)}
options={defaultFaultSubcategories}
placeholder="请选择或输入故障小类..."
/>
<Input label="责任方" value={form.responsibility} onChange={(e) => update('responsibility', e.target.value)} />
<Input label="派单时间" type="text" value={form.assign_time} onChange={(e) => { update('assign_time', e.target.value); setAssignTimeError('') }} onBlur={() => { if (form.assign_time && !timeRe.test(form.assign_time)) setAssignTimeError('时间格式不正确,请使用 YYYY-MM-DD HH:mm:ss 格式') }} placeholder="2026-05-02 18:34:36" error={assignTimeError} />
<Select
label="当前状态"
options={[
{ value: 'open', label: '待处理' },
{ value: 'in_progress', label: '处理中' },
{ value: 'resolved', label: '已解决' },
{ value: 'closed', label: '已关闭' },
]}
value={form.current_status}
onChange={(e) => update('current_status', e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300"></label>
<textarea
className="w-full px-3 py-2 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 min-h-[100px]"
value={form.content}
onChange={(e) => update('content', e.target.value)}
placeholder="描述故障现象..."
/>
</div>
{isEdit && (
<div className="grid grid-cols-2 gap-4 border-t border-slate-200 dark:border-slate-700 pt-4">
<Input label="结单时间" type="text" value={form.close_time} onChange={(e) => { update('close_time', e.target.value); setCloseTimeError('') }} onBlur={() => { if (form.close_time && !timeRe.test(form.close_time)) setCloseTimeError('时间格式不正确,请使用 YYYY-MM-DD HH:mm:ss 格式') }} placeholder="2026-05-02 18:34:36" error={closeTimeError} />
<Input label="处理时长(分钟)" type="number" value={String(form.duration_minutes)} onChange={(e) => update('duration_minutes', e.target.value)} />
<div className="col-span-2 space-y-1">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300"></label>
<textarea
className="w-full px-3 py-2 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 min-h-[80px]"
value={form.process_summary}
onChange={(e) => update('process_summary', e.target.value)}
/>
</div>
<div className="col-span-2 space-y-1">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300"></label>
<textarea
className="w-full px-3 py-2 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 min-h-[80px]"
value={form.conclusion}
onChange={(e) => update('conclusion', e.target.value)}
/>
</div>
<Input label="更换配件" value={form.parts_replaced} onChange={(e) => update('parts_replaced', e.target.value)} />
<Input label="配件名称" value={form.parts_name} onChange={(e) => update('parts_name', e.target.value)} />
<Select
label="SLA 计数"
options={[
{ value: '1', label: '是' },
{ value: '0', label: '否' },
]}
value={String(form.counted_in_sla)}
onChange={(e) => update('counted_in_sla', parseInt(e.target.value))}
/>
</div>
)}
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-3">
<Button type="submit" disabled={loading}>{loading ? '保存中...' : isEdit ? '保存修改' : '创建工单'}</Button>
<Button type="button" variant="secondary" onClick={() => router.back()}></Button>
</div>
</form>
)
}

View File

@ -0,0 +1,100 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Card } from '@/components/ui'
import { Upload, FileSpreadsheet, CheckCircle, AlertCircle } from 'lucide-react'
export default function TicketImport() {
const router = useRouter()
const [file, setFile] = useState<File | null>(null)
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<{ imported: number; errors?: string[] } | null>(null)
const [error, setError] = useState('')
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0]
if (f) {
setFile(f)
setResult(null)
setError('')
}
}
const handleImport = async () => {
if (!file) return
setLoading(true)
setError('')
try {
const formData = new FormData()
formData.append('file', file)
const res = await fetch('/api/tickets/import', { method: 'POST', body: formData })
const data = await res.json()
if (!res.ok) { setError(data.error || '导入失败'); return }
setResult({ imported: data.imported, errors: data.errors })
} catch {
setError('网络错误')
} finally {
setLoading(false)
}
}
return (
<div className="max-w-xl 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"> Excel </p>
</div>
<Card className="p-6 space-y-4">
<div className="border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg p-8 text-center">
<input type="file" accept=".xlsx,.xls" onChange={handleFileChange} className="hidden" id="file-input" />
<label htmlFor="file-input" className="cursor-pointer">
<FileSpreadsheet className="mx-auto text-slate-400 mb-3" size={40} />
<p className="text-sm text-slate-600 dark:text-slate-400"> Excel </p>
<p className="text-xs text-slate-400 mt-1"> .xlsx, .xls </p>
</label>
</div>
{file && (
<div className="flex items-center gap-2 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<FileSpreadsheet size={18} className="text-blue-500" />
<span className="text-sm text-slate-700 dark:text-slate-300">{file.name}</span>
<span className="text-xs text-slate-400 ml-auto">{(file.size / 1024).toFixed(1)} KB</span>
</div>
)}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-500/10 rounded-lg text-red-600 dark:text-red-400 text-sm">
<AlertCircle size={16} />{error}
</div>
)}
{result && (
<div className="p-4 bg-emerald-50 dark:bg-emerald-500/10 rounded-lg space-y-2">
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 text-sm font-medium">
<CheckCircle size={16} /> {result.imported}
</div>
{result.errors && result.errors.length > 0 && (
<div className="mt-2 text-xs text-amber-600 dark:text-amber-400 space-y-1">
<p className="font-medium"></p>
{result.errors.map((e, i) => <p key={i}>{e}</p>)}
</div>
)}
</div>
)}
<div className="flex gap-3">
<Button onClick={handleImport} disabled={!file || loading}>
<Upload size={16} className="mr-1" />{loading ? '导入中...' : '开始导入'}
</Button>
<Button variant="secondary" onClick={() => router.push('/tickets/pending')}></Button>
</div>
</Card>
<Card className="p-5">
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Excel </h3>
<p className="text-xs text-slate-500 dark:text-slate-400">IPSLA计数</p>
</Card>
</div>
)
}

View File

@ -0,0 +1,714 @@
'use client'
import { useState, useEffect, useCallback, useRef, Suspense } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button, Badge, Pagination } from '@/components/ui'
import { Search, Download, Eye, Pencil, Trash2, Filter, ArrowUpDown, ChevronsUpDown, ChevronUp, ChevronDown, Check, X, ExternalLink } from 'lucide-react'
import SelectWithInput from '@/components/ui/SelectWithInput'
import SlaCountdown from '@/components/tickets/SlaCountdown'
import { getTier1Deadline } from '@/lib/sla'
interface Ticket {
id: number
device_ip: string | null
device_name: string | null
content: string | null
assign_time: string | null
current_status: string
fault_category: string | null
parts_replaced: string | null
counted_in_sla: number
}
const statusMap: Record<string, { label: string; variant: 'default' | 'info' | 'warning' | 'success' | 'danger' }> = {
open: { label: '待处理', variant: 'info' },
in_progress: { label: '处理中', variant: 'warning' },
resolved: { label: '已解决', variant: 'success' },
closed: { label: '已关闭', variant: 'default' },
}
const STATUS_OPTIONS = ['open', 'in_progress', 'resolved', 'closed']
const FAULT_CATEGORY_OPTIONS = ['硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障', '其他']
const COLUMNS = [
{ key: 'id', label: '工单号', sortable: true, filterable: true, filterType: 'text' as const },
{ key: 'device_ip', label: '业务IP', sortable: true, filterable: true, filterType: 'multi' as const },
{ key: 'device_name', label: '节点名称', sortable: true, filterable: true, filterType: 'multi' as const },
{ key: 'content', label: '工单内容', sortable: true, filterable: true, filterType: 'text' as const },
{ key: 'current_status', label: '状态', sortable: true, filterable: true, filterType: 'multi' as const },
{ key: 'fault_category', label: '故障大类', sortable: true, filterable: true, filterType: 'multi' as const },
{ key: 'assign_time', label: '派单时间', sortable: true, filterable: true, filterType: 'date' as const },
]
type FilterType = 'text' | 'multi' | 'date'
function BatchEditModal({ selected, onClose, onSuccess }: { selected: number[]; onClose: () => void; onSuccess: () => void }) {
const [batchField, setBatchField] = useState<'fault_category' | 'current_status' | 'counted_in_sla'>('fault_category')
const [faultCategory, setFaultCategory] = useState('')
const [currentStatus, setCurrentStatus] = useState('')
const [countedInSla, setCountedInSla] = useState<number | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleSubmit() {
if (!confirm(`确定要批量修改选中的 ${selected.length} 条工单吗?`)) return
setLoading(true)
setError('')
try {
const updates = selected.map(id => {
const body: Record<string, unknown> = { id }
if (batchField === 'fault_category' && faultCategory) body.fault_category = faultCategory
if (batchField === 'current_status' && currentStatus) body.current_status = currentStatus
if (batchField === 'counted_in_sla') body.counted_in_sla = countedInSla
return body
})
const res = await fetch('/api/tickets/batch', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates }),
})
if (!res.ok) {
const d = await res.json()
setError(d.error || '批量修改失败')
return
}
onSuccess()
onClose()
} catch {
setError('网络错误')
} finally {
setLoading(false)
}
}
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 bg-white dark:bg-slate-900 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-700">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100"></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 space-y-4">
<p className="text-sm text-slate-500 dark:text-slate-400"> <strong>{selected.length}</strong> </p>
<div className="space-y-1">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300"></label>
<div className="flex gap-2">
<button onClick={() => setBatchField('fault_category')} className={`px-3 py-1.5 rounded-lg text-sm border ${batchField === 'fault_category' ? 'bg-blue-600 text-white border-blue-600' : 'border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300'}`}></button>
<button onClick={() => setBatchField('current_status')} className={`px-3 py-1.5 rounded-lg text-sm border ${batchField === 'current_status' ? 'bg-blue-600 text-white border-blue-600' : 'border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300'}`}></button>
<button onClick={() => setBatchField('counted_in_sla')} className={`px-3 py-1.5 rounded-lg text-sm border ${batchField === 'counted_in_sla' ? 'bg-blue-600 text-white border-blue-600' : 'border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300'}`}>SLA计数</button>
</div>
</div>
{batchField === 'fault_category' && (
<SelectWithInput label="新故障大类" value={faultCategory} onChange={setFaultCategory} options={FAULT_CATEGORY_OPTIONS} placeholder="请选择或输入..." />
)}
{batchField === 'current_status' && (
<div className="space-y-1">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300"></label>
<div className="flex gap-2">
{STATUS_OPTIONS.map(s => (
<button key={s} onClick={() => setCurrentStatus(s)}
className={`px-3 py-1.5 rounded-lg text-sm border ${currentStatus === s ? 'bg-blue-600 text-white border-blue-600' : 'border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300'}`}>
{statusMap[s].label}
</button>
))}
</div>
</div>
)}
{batchField === 'counted_in_sla' && (
<div className="space-y-1">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">SLA </label>
<div className="flex gap-2">
<button onClick={() => setCountedInSla(1)} className={`px-4 py-1.5 rounded-lg text-sm border ${countedInSla === 1 ? 'bg-blue-600 text-white border-blue-600' : 'border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300'}`}></button>
<button onClick={() => setCountedInSla(0)} className={`px-4 py-1.5 rounded-lg text-sm border ${countedInSla === 0 ? 'bg-blue-600 text-white border-blue-600' : 'border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300'}`}></button>
</div>
</div>
)}
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
<div className="px-6 py-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-3">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading ? '保存中...' : `确认修改 ${selected.length} 条工单`}
</Button>
</div>
</div>
</div>
)
}
interface TicketListInnerProps {
onPaginationChange?: (pagination: { page: number; pageSize: number; total: number; totalPages: number }) => void
defaultStatusFilter?: string[]
showSlaColumn?: boolean
showActions?: boolean
hideDefaultFilterChips?: boolean
}
function TicketListInner({ onPaginationChange, defaultStatusFilter, showSlaColumn, showActions = true, hideDefaultFilterChips }: TicketListInnerProps) {
const router = useRouter()
const searchParams = useSearchParams()
const [tickets, setTickets] = useState<Ticket[]>([])
const [pagination, setPagination] = useState({ page: 1, pageSize: 20, total: 0, totalPages: 0 })
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [showBatchEdit, setShowBatchEdit] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [sortKey, setSortKey] = useState('assign_time')
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
const [tableMode, setTableMode] = useState<'sort' | 'filter'>('sort')
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null)
const [columnFilterValues, setColumnFilterValues] = useState<Record<string, string[]>>({})
const [columnTextFilter, setColumnTextFilter] = useState<Record<string, string>>({})
const [dateFilter, setDateFilter] = useState<Record<string, { start: string; end: string }>>({})
const [fieldOptions, setFieldOptions] = useState<Record<string, string[]>>({})
const [ticketNoFilter, setTicketNoFilter] = useState('')
const filterDropRef = useRef<HTMLDivElement>(null)
// 列宽拖拽调整
const [colWidths, setColWidths] = useState<Record<string, number>>({})
const [resizingCol, setResizingCol] = useState<string | null>(null)
const [hasResized, setHasResized] = useState(false)
const resizeRef = useRef<{ col: string; startX: number; startWidth: number } | null>(null)
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])
// 从 URL 初始化设备筛选
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)
}, [])
useEffect(() => {
const ip = searchParams.get('device_ip')
const name = searchParams.get('device_name')
const sn = searchParams.get('device_sn')
if (ip) {
setColumnFilterValues(prev => ({ ...prev, device_ip: [ip] }))
}
if (name) {
setColumnFilterValues(prev => ({ ...prev, device_name: [name] }))
}
if (sn) {
setColumnFilterValues(prev => ({ ...prev, device_sn: [sn] }))
}
}, [searchParams])
// 应用 defaultStatusFilter prop 到列筛选状态
useEffect(() => {
if (defaultStatusFilter && defaultStatusFilter.length > 0) {
setColumnFilterValues(prev => {
if (prev.current_status && prev.current_status.length > 0) return prev
return { ...prev, current_status: defaultStatusFilter }
})
}
}, [defaultStatusFilter])
const loadFieldOptions = useCallback(async () => {
const fields = ['device_ip', 'device_name', 'fault_category', 'current_status']
const params = new URLSearchParams()
fields.forEach(f => params.append('field', f))
try {
const res = await fetch(`/api/tickets/field-values?${params}`)
if (res.ok) {
const data = await res.json()
setFieldOptions(data)
}
} catch { /* silent */ }
}, [])
useEffect(() => { loadFieldOptions() }, [loadFieldOptions])
const fetchTickets = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize), sortBy: sortKey, sortOrder })
if (search) params.set('search', search)
if (ticketNoFilter) params.set('search', ticketNoFilter)
for (const [field, values] of Object.entries(columnFilterValues)) {
for (const v of values) params.append(`filter_${field}`, v)
}
for (const [field, text] of Object.entries(columnTextFilter)) {
if (text) params.set(`filter_${field}`, text)
}
for (const [field, range] of Object.entries(dateFilter)) {
if (range.start) params.set(`${field}_start`, range.start)
if (range.end) params.set(`${field}_end`, range.end)
}
const res = await fetch(`/api/tickets?${params}`)
const data = await res.json()
if (res.ok) {
setTickets(data.tickets)
setPagination(data.pagination)
onPaginationChange?.(data.pagination)
}
} catch { /* silent */ }
finally { setLoading(false) }
}, [page, pageSize, sortKey, sortOrder, search, columnFilterValues, columnTextFilter, dateFilter, ticketNoFilter, onPaginationChange])
useEffect(() => { fetchTickets() }, [fetchTickets])
function handleColumnClick(key: string, sortable: boolean, filterable: 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 (!filterable) return
setOpenFilterColumn(openFilterColumn === key ? null : 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]
return { ...prev, [column]: next }
})
setPage(1)
}
function clearColumnFilter(column: string) {
setColumnFilterValues(prev => { const n = { ...prev }; delete n[column]; return n })
setColumnTextFilter(prev => { const n = { ...prev }; delete n[column]; return n })
setDateFilter(prev => { const n = { ...prev }; delete n[column]; return n })
setPage(1)
}
function SortIcon({ colKey, colDef }: { colKey: string; colDef: (typeof COLUMNS)[0] }) {
if (tableMode === 'filter' && !colDef.filterable) return null
const icon = tableMode === 'filter'
? <Filter size={12} />
: !colDef.sortable
? null
: sortKey !== colKey
? <ChevronsUpDown size={13} />
: sortOrder === 'asc'
? <ChevronUp size={13} className="text-blue-500" />
: <ChevronDown size={13} className="text-blue-500" />
if (!icon) return null
return (
<span
onClick={(e) => { e.stopPropagation(); handleColumnClick(colKey, colDef.sortable, colDef.filterable) }}
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 multi = columnFilterValues[colKey]
const text = columnTextFilter[colKey]
const date = dateFilter[colKey]
const hasFilter = (multi && multi.length > 0) || (text && text.length > 0) || (date && (date.start || date.end))
if (!hasFilter) return null
const count = (multi?.length || 0) + (text ? 1 : 0) + ((date?.start || date?.end) ? 1 : 0)
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">{count}</span>
}
const hasActiveFilters =
Object.values(columnFilterValues).some(v => v.length > 0) ||
Object.values(columnTextFilter).some(v => v.length > 0) ||
Object.values(dateFilter).some(v => v.start || v.end) ||
ticketNoFilter.length > 0
async function handleDelete(id: number) {
if (!confirm('确定要删除此工单吗?')) return
const res = await fetch(`/api/tickets/${id}`, { method: 'DELETE' })
if (res.ok) fetchTickets()
}
function handleExport() {
if (tickets.length === 0) {
alert('当前列表没有可导出的工单')
return
}
const hasSelection = selectedIds.size > 0
const exportCount = hasSelection ? selectedIds.size : pagination.total
const msg = hasSelection
? `确定要导出选中的 ${exportCount} 条工单吗?`
: `确定要导出当前筛选的 ${exportCount} 条工单吗?`
if (!confirm(msg)) return
if (hasSelection) {
const ids = Array.from(selectedIds).join(',')
window.open(`/api/tickets/export?ids=${ids}`, '_blank')
return
}
const params = new URLSearchParams()
if (search) params.set('search', search)
params.set('sortBy', sortKey)
params.set('sortOrder', sortOrder)
for (const [field, values] of Object.entries(columnFilterValues)) {
for (const v of values) params.append(`filter_${field}`, v)
}
for (const [field, text] of Object.entries(columnTextFilter)) {
if (text) params.set(`filter_${field}`, text)
}
for (const [field, range] of Object.entries(dateFilter)) {
if (range.start) params.set(`${field}_start`, range.start)
if (range.end) params.set(`${field}_end`, range.end)
}
window.open(`/api/tickets/export?${params}`, '_blank')
}
const activeDeviceFilter = (() => {
if (columnFilterValues.device_ip?.length === 1 && columnFilterValues.device_name?.length === 1) {
return { ip: columnFilterValues.device_ip[0], name: columnFilterValues.device_name[0] }
}
if (columnFilterValues.device_ip?.length === 1) return { ip: columnFilterValues.device_ip[0], name: null }
if (columnFilterValues.device_name?.length === 1) return { ip: null, name: columnFilterValues.device_name[0] }
return null
})()
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex-1 flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
<input 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 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="快速搜索..." value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { setPage(1); fetchTickets() } }} />
</div>
<Button variant="secondary" size="sm" onClick={() => { setPage(1); fetchTickets() }}></Button>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={handleExport}><Download size={14} /></Button>
</div>
</div>
{activeDeviceFilter && (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 text-sm">
<ExternalLink size={14} className="text-blue-600 dark:text-blue-400" />
<span className="text-blue-700 dark:text-blue-300">
{activeDeviceFilter.ip && <> <strong>{activeDeviceFilter.ip}</strong></>}
{activeDeviceFilter.name && <>{activeDeviceFilter.name}</>}
{pagination.total}
</span>
<button
onClick={() => {
setColumnFilterValues(prev => {
const n = { ...prev }
delete n.device_ip; delete n.device_name
return n
})
setPage(1)
}}
className="ml-2 text-xs text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1">
<X size={12} />
</button>
</div>
)}
{hasActiveFilters && !activeDeviceFilter && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-slate-500">:</span>
{ticketNoFilter && (
<span 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"></span>: {ticketNoFilter}
<button onClick={() => { setTicketNoFilter(''); setPage(1) }} className="ml-0.5 hover:text-blue-900 dark:hover:text-blue-200"><X size={12} /></button>
</span>
)}
{Object.entries(columnFilterValues).filter(([, v]) => v.length > 0).map(([col, vals]) => {
// 隐藏默认状态筛选标签
if (hideDefaultFilterChips && col === 'current_status' && defaultStatusFilter) {
const isDefaultOnly = vals.length === defaultStatusFilter.length && vals.every(v => defaultStatusFilter.includes(v))
if (isDefaultOnly) return null
}
const colDef = COLUMNS.find(c => c.key === col)
const displayVals = vals.map(v => statusMap[v]?.label || v)
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>: {displayVals.join(', ')}
<button onClick={() => { setColumnFilterValues(prev => { const n = { ...prev }; delete n[col]; return n }); setPage(1) }} className="ml-0.5 hover:text-blue-900 dark:hover:text-blue-200"><X size={12} /></button>
</span>
)
})}
{Object.entries(columnTextFilter).filter(([, v]) => v.length > 0).map(([col, text]) => {
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>: {text}
<button onClick={() => { setColumnTextFilter(prev => { const n = { ...prev }; delete n[col]; return n }); setPage(1) }} className="ml-0.5 hover:text-blue-900 dark:hover:text-blue-200"><X size={12} /></button>
</span>
)
})}
{Object.entries(dateFilter).filter(([, v]) => v.start || v.end).map(([col, range]) => {
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>: {range.start || '*'} ~ {range.end || '*'}
<button onClick={() => { setDateFilter(prev => { const n = { ...prev }; delete n[col]; return n }); setPage(1) }} className="ml-0.5 hover:text-blue-900 dark:hover:text-blue-200"><X size={12} /></button>
</span>
)
})}
<button onClick={() => { setColumnFilterValues({}); setColumnTextFilter({}); setDateFilter({}); setTicketNoFilter(''); 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={() => { setPageSize(s); setPage(1) }}
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">
<input
type="checkbox"
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500 cursor-pointer"
checked={tickets.length > 0 && tickets.every(t => selectedIds.has(t.id))}
onChange={e => {
if (e.target.checked) setSelectedIds(prev => { const n = new Set(prev); tickets.forEach(t => n.add(t.id)); return n })
else setSelectedIds(prev => { const n = new Set(prev); tickets.forEach(t => n.delete(t.id)); return n })
}}
/>
</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-1">
{col.filterable && (
<button
onClick={() => setTableMode(tableMode === 'sort' ? 'filter' : 'sort')}
className="p-0.5 rounded transition-colors text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 cursor-pointer"
title={tableMode === 'sort' ? '切换到筛选' : '切换到排序'}>
<ArrowUpDown size={12} />
</button>
)}
<button
className={`flex items-center justify-center gap-0.5 font-medium cursor-pointer hover:text-slate-900 dark:hover:text-slate-100 ${openFilterColumn === col.key ? 'text-blue-600 dark:text-blue-400' : 'text-slate-600 dark:text-slate-300'}`}
onClick={() => handleColumnClick(col.key, col.sortable, col.filterable)}>
{col.label}
<SortIcon colKey={col.key} colDef={col} />
<FilterIndicator colKey={col.key} />
</button>
</div>
{openFilterColumn === col.key && (
<div ref={filterDropRef}>
<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">
{col.filterType === 'text' ? (
<div className="p-2 border-b border-slate-100 dark:border-slate-700">
<input
autoFocus
type="text"
value={columnTextFilter[col.key] || ''}
onChange={e => setColumnTextFilter(prev => ({ ...prev, [col.key]: e.target.value }))}
onKeyDown={e => { if (e.key === 'Enter') { setPage(1); setOpenFilterColumn(null) } }}
placeholder={`输入${col.label}...`}
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>
) : col.filterType === 'date' ? (
<div className="p-3">
<p className="text-xs text-slate-500 mb-2"></p>
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-slate-400"></label>
<input type="date" autoFocus value={dateFilter[col.key]?.start || ''}
onChange={e => setDateFilter(prev => ({ ...prev, [col.key]: { ...(prev[col.key] || { start: '', end: '' }), start: e.target.value } }))}
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 mt-0.5" />
</div>
<div className="flex-1">
<label className="text-xs text-slate-400"></label>
<input type="date" value={dateFilter[col.key]?.end || ''}
onChange={e => setDateFilter(prev => ({ ...prev, [col.key]: { ...(prev[col.key] || { start: '', end: '' }), end: e.target.value } }))}
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 mt-0.5" />
</div>
</div>
</div>
) : (
<div className="max-h-56 overflow-y-auto">
{(fieldOptions[col.key] || []).length === 0 && <p className="px-3 py-3 text-xs text-slate-400 text-center"></p>}
{(fieldOptions[col.key] || []).map(opt => {
const checked = (columnFilterValues[col.key] || []).includes(opt)
const label = statusMap[opt]?.label || opt
return (
<button key={opt} onClick={() => { toggleFilterValue(col.key, opt); setPage(1) }}
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">{label || '(空)'}</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">
{(columnFilterValues[col.key] || []).length > 0
? `已选 ${(columnFilterValues[col.key] || []).length}`
: `${(fieldOptions[col.key] || []).length} 个值`}
</span>
<button onClick={() => { clearColumnFilter(col.key); setOpenFilterColumn(null) }}
className="text-[10px] text-blue-600 dark:text-blue-400 hover:underline"></button>
</div>
</div>
</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>
))}
{showSlaColumn && <th className="px-4 py-3 text-center font-medium text-slate-600 dark:text-slate-300"></th>}
{showSlaColumn && <th className="px-4 py-3 text-center font-medium text-slate-600 dark:text-slate-300">SLA </th>}
{showActions && <th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-300"></th>}
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-700">
{loading ? (
<tr><td colSpan={COLUMNS.length + (showSlaColumn ? 4 : 2)} className="px-4 py-12 text-center text-slate-500 dark:text-slate-400">...</td></tr>
) : tickets.length === 0 ? (
<tr><td colSpan={COLUMNS.length + (showSlaColumn ? 4 : 2)} className="px-4 py-12 text-center text-slate-500 dark:text-slate-400"></td></tr>
) : tickets.map(t => (
<tr key={t.id} className={`hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors ${selectedIds.has(t.id) ? 'bg-blue-50 dark:bg-blue-900/10' : ''}`}>
<td className="px-4 py-3">
<input
type="checkbox"
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500 cursor-pointer"
checked={selectedIds.has(t.id)}
onChange={e => {
setSelectedIds(prev => {
const n = new Set(prev)
if (e.target.checked) n.add(t.id)
else n.delete(t.id)
return n
})
}}
/>
</td>
<td className="px-4 py-3 font-medium"><Link href={`/tickets/${t.id}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.id}</Link></td>
<td className="px-4 py-3">
{t.device_ip ? (
<Link href={`/tickets?device_ip=${encodeURIComponent(t.device_ip)}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.device_ip}</Link>
) : '-'}
</td>
<td className="px-4 py-3">
{t.device_name ? (
<Link href={`/tickets?device_name=${encodeURIComponent(t.device_name)}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.device_name}</Link>
) : '-'}
</td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 max-w-xs truncate">{t.content || '-'}</td>
<td className="px-4 py-3"><Badge variant={statusMap[t.current_status]?.variant || 'default'}>{statusMap[t.current_status]?.label || t.current_status}</Badge></td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{t.fault_category || '-'}</td>
<td className="px-4 py-3 text-slate-500 dark:text-slate-400 text-sm">{t.assign_time ? new Date(t.assign_time).toLocaleString('zh-CN') : '-'}</td>
{showSlaColumn && (
<td className="px-4 py-3 text-sm text-slate-500 dark:text-slate-400">
{t.assign_time && t.counted_in_sla !== 0
? getTier1Deadline(t.assign_time).toLocaleString('zh-CN', { hourCycle: 'h23' })
: '-'}
</td>
)}
{showSlaColumn && (
<td className="px-4 py-3">
<SlaCountdown assignTime={t.assign_time || ''} countedInSla={t.counted_in_sla} />
</td>
)}
{showActions && (
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<Link href={`/tickets/${t.id}`}><Button variant="ghost" size="sm"><Eye size={14} /></Button></Link>
<Link href={`/tickets/${t.id}/edit`}><Button variant="ghost" size="sm"><Pencil size={14} /></Button></Link>
<Button variant="ghost" size="sm" onClick={() => handleDelete(t.id)}><Trash2 size={14} className="text-red-500" /></Button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={pagination.page} totalPages={pagination.totalPages} onPageChange={p => setPage(p)} />
{showBatchEdit && (
<BatchEditModal
selected={Array.from(selectedIds)}
onClose={() => setShowBatchEdit(false)}
onSuccess={() => { setSelectedIds(new Set()); fetchTickets() }}
/>
)}
</div>
)
}
export default function TicketList({ onPaginationChange, defaultStatusFilter, showSlaColumn, showActions, hideDefaultFilterChips }: TicketListInnerProps) {
return (
<Suspense fallback={<div className="text-center py-12 text-slate-500">...</div>}>
<TicketListInner onPaginationChange={onPaginationChange} defaultStatusFilter={defaultStatusFilter} showSlaColumn={showSlaColumn} showActions={showActions} hideDefaultFilterChips={hideDefaultFilterChips} />
</Suspense>
)
}

View File

@ -0,0 +1,92 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { ChevronDown, Check, Plus } from 'lucide-react'
interface SelectWithInputProps {
label?: React.ReactNode
value: string
onChange: (val: string) => void
options: string[]
placeholder?: string
}
export default function SelectWithInput({ label, value, onChange, options, placeholder = '请选择或输入...' }: SelectWithInputProps) {
const [open, setOpen] = useState(false)
const [inputVal, setInputVal] = useState('')
const [showInput, setShowInput] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [])
function select(val: string) {
onChange(val)
setOpen(false)
setShowInput(false)
setInputVal('')
}
function addCustom() {
const trimmed = inputVal.trim()
if (!trimmed) return
onChange(trimmed)
setOpen(false)
setShowInput(false)
setInputVal('')
}
return (
<div ref={ref} className="space-y-1">
{label && <label className="block text-sm font-medium text-slate-700 dark:text-slate-300">{label}</label>}
<div className="relative">
<button
type="button"
onClick={() => setOpen(o => !o)}
className="w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-left text-sm text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 flex items-center justify-between">
<span className={value ? '' : 'text-slate-400'}>{value || placeholder}</span>
<ChevronDown size={14} className="text-slate-400 flex-shrink-0 ml-2" />
</button>
{open && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shadow-xl">
<div className="max-h-52 overflow-y-auto">
{options.map(opt => (
<button key={opt} onClick={() => select(opt)}
className={`w-full flex items-center justify-between px-3 py-2 text-sm text-left transition-colors ${value === opt ? '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>{opt}</span>
{value === opt && <Check size={12} className="text-blue-600" />}
</button>
))}
</div>
<div className="border-t border-slate-100 dark:border-slate-700 p-2">
{showInput ? (
<div className="flex gap-1">
<input
type="text"
value={inputVal}
onChange={e => setInputVal(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addCustom() } }}
placeholder="输入自定义类目..."
autoFocus
className="flex-1 px-2 py-1.5 rounded border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-sm text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<button onClick={addCustom} className="px-2 py-1.5 rounded bg-blue-600 text-white text-xs hover:bg-blue-700"></button>
<button onClick={() => { setShowInput(false); setInputVal('') }} className="px-2 py-1.5 rounded text-slate-500 text-xs hover:bg-slate-100 dark:hover:bg-slate-700"></button>
</div>
) : (
<button onClick={() => setShowInput(true)}
className="w-full flex items-center gap-1.5 px-2 py-1.5 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors">
<Plus size={12} />
</button>
)}
</div>
</div>
)}
</div>
</div>
)
}

103
src/components/ui/index.tsx Normal file
View File

@ -0,0 +1,103 @@
'use client'
import { ButtonHTMLAttributes, InputHTMLAttributes, SelectHTMLAttributes, ReactNode } from 'react'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; size?: 'sm' | 'md' | 'lg' }
export function Button({ variant = 'primary', size = 'md', className = '', children, ...props }: ButtonProps) {
const base = 'inline-flex items-center justify-center font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500/50 disabled:opacity-50 disabled:cursor-not-allowed'
const v = { primary: 'bg-blue-600 text-white hover:bg-blue-700 shadow-sm', secondary: 'bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700', danger: 'bg-red-600 text-white hover:bg-red-700', ghost: 'text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800' }
const s = { sm: 'px-3 py-1.5 text-sm', md: 'px-4 py-2 text-sm', lg: 'px-6 py-2.5 text-base' }
return <button className={`${base} ${v[variant]} ${s[size]} ${className}`} {...props}>{children}</button>
}
interface InputProps extends InputHTMLAttributes<HTMLInputElement> { label?: string; error?: string }
export function Input({ label, error, className = '', ...props }: InputProps) {
return (<div className="space-y-1">{label && <label className="block text-sm font-medium text-slate-700 dark:text-slate-300">{label}</label>}<input className={`w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition-colors text-sm ${className}`} {...props} />{error && <p className="text-sm text-red-500">{error}</p>}</div>)
}
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> { label?: string; options: Array<{ value: string; label: string }> }
export function Select({ label, options, className = '', ...props }: SelectProps) {
return (<div className="space-y-1">{label && <label className="block text-sm font-medium text-slate-700 dark:text-slate-300">{label}</label>}<select className={`w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition-colors text-sm ${className}`} {...props}>{options.map((o) => (<option key={o.value} value={o.value}>{o.label}</option>))}</select></div>)
}
interface ModalProps { open: boolean; onClose: () => void; title: string; children: ReactNode }
export function Modal({ open, onClose, title, children }: ModalProps) {
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 bg-white dark:bg-slate-900 rounded-xl shadow-xl border border-slate-200 dark:border-slate-800 w-full max-w-lg mx-4 max-h-[90vh] overflow-auto"><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-slate-100">{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></div></div>)
}
interface TableProps { headers: string[]; children: ReactNode }
export function Table({ headers, children }: TableProps) {
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>{headers.map((h) => (<th key={h} className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400">{h}</th>))}</tr></thead><tbody className="divide-y divide-slate-200 dark:divide-slate-700">{children}</tbody></table></div>)
}
import { useState } from 'react'
interface PaginationProps { page: number; totalPages: number; onPageChange: (p: number) => void }
export 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>)
}
interface BadgeProps { variant?: 'default' | 'success' | 'warning' | 'danger' | 'info'; children: ReactNode }
export function Badge({ variant = 'default', children }: BadgeProps) {
const v = { default: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300', success: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400', warning: 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400', danger: 'bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400', info: 'bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' }
return (<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${v[variant]}`}>{children}</span>)
}
interface CardProps { children: ReactNode; className?: string }
export function Card({ children, className = '' }: CardProps) {
return (<div className={`bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-700 shadow-sm ${className}`}>{children}</div>)
}
interface ToastProps { message: string; type?: 'success' | 'error' | 'info'; onClose: () => void }
export function Toast({ message, type = 'info', onClose }: ToastProps) {
const c = { success: 'bg-emerald-50 text-emerald-800 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20', error: 'bg-red-50 text-red-800 border-red-200 dark:bg-red-500/10 dark:text-red-400 dark:border-red-500/20', info: 'bg-blue-50 text-blue-800 border-blue-200 dark:bg-blue-500/10 dark:text-blue-400 dark:border-blue-500/20' }
return (<div className={`fixed bottom-6 right-6 z-50 px-4 py-3 rounded-lg border shadow-lg text-sm font-medium ${c[type]}`}><div className="flex items-center gap-3"><span>{message}</span><button onClick={onClose} className="text-current opacity-60 hover:opacity-100">&times;</button></div></div>)
}

108
src/lib/assets-client.ts Normal file
View File

@ -0,0 +1,108 @@
const ASSETS_API_URL = process.env.ASSETS_API_URL || 'http://localhost:5177/api'
const ASSETS_API_KEY = process.env.ASSETS_API_KEY || ''
export interface Asset {
id: number
node_name: string
serial_number: string
device_type: string
business_ip: string | null
hdm_ip: string | null
manufacturer: string
device_model: string
status: string
}
async function assetsFetch(path: string, options?: RequestInit) {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options?.headers as Record<string, string> || {}),
}
if (ASSETS_API_KEY) headers['Authorization'] = `Bearer ${ASSETS_API_KEY}`
const res = await fetch(`${ASSETS_API_URL}${path}`, { ...options, headers })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Assets API error: ${res.status} ${text}`)
}
return res.json()
}
export async function getAssets(params?: {
page?: number
pageSize?: number
search?: string
device_type?: string
region?: string
status?: string
}) {
const query = new URLSearchParams()
if (params?.page) query.set('page', String(params.page))
if (params?.pageSize) query.set('pageSize', String(params.pageSize))
if (params?.search) query.set('search', params.search)
if (params?.device_type) query.set('filter_device_type', params.device_type)
if (params?.region) query.set('filter_region', params.region)
if (params?.status) query.set('filter_status', params.status)
const qs = query.toString()
return assetsFetch(`/assets${qs ? `?${qs}` : ''}`)
}
export async function getAssetById(id: number): Promise<Asset | null> {
try {
const data = await assetsFetch(`/assets/${id}`)
return data.asset || data
} catch {
return null
}
}
export async function getAssetByIp(ip: string): Promise<Asset | null> {
try {
const data = await assetsFetch(`/assets?search=${encodeURIComponent(ip)}&pageSize=50`)
if (data.data && data.data.length > 0) {
for (const asset of data.data) {
if (asset.business_ip === ip || asset.hdm_ip === ip) return asset
}
}
return null
} catch {
return null
}
}
export async function getAssetHistory(ip: string) {
try {
const data = await assetsFetch(`/assets/history?ip=${encodeURIComponent(ip)}`)
return data.records || []
} catch {
return []
}
}
// 设备类型常量assets-ai 中 device_type 字段取值)
export const DEVICE_TYPE_GPU = 'GPU服务器'
export const DEVICE_TYPE_STORAGE = '存储服务器'
// 获取所有"腾讯使用"状态的设备,支持按设备类型筛选
export async function getActiveDevices(deviceType?: string): Promise<Asset[]> {
const PAGE_SIZE = 200
const MAX_PAGES = 20 // 安全上限20×200=4000台
const allAssets: Asset[] = []
for (let page = 1; page <= MAX_PAGES; page++) {
const query = new URLSearchParams({
page: String(page),
pageSize: String(PAGE_SIZE),
filter_status: '腾讯使用',
})
if (deviceType) query.set('filter_device_type', deviceType)
const data = await assetsFetch(`/assets?${query.toString()}`)
if (!data.data || !Array.isArray(data.data)) break
allAssets.push(...data.data)
// 返回量少于 pageSize 说明已到最后一页
if (data.data.length < PAGE_SIZE) break
}
return allAssets
}

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

@ -0,0 +1,57 @@
import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import { getDb } from './db'
import { cookies } from 'next/headers'
import { createToken, verifyToken, type UserPayload } from './jwt'
export { createToken, verifyToken, type UserPayload }
export async function getCurrentUser(): Promise<UserPayload | null> {
const cookieStore = await cookies()
const token = cookieStore.get('session_issue')?.value
if (!token) return null
return verifyToken(token)
}
export async function login(username: string, password: string) {
const db = getDb()
const user = db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username) as any
if (!user) return null
if (!bcrypt.compareSync(password, user.password_hash)) return null
const payload: UserPayload = { id: user.id, username: user.username, display_name: user.display_name, role: user.role }
return { token: await createToken(payload), user: payload }
}
export function hashPassword(password: string): string {
return bcrypt.hashSync(password, 10)
}
// ---------------------------------------------------------------------------
// API Key
// ---------------------------------------------------------------------------
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 interface ApiKeyInfo {
id: number
name: string
permissions: string[]
}
export function verifyApiKey(key: string): ApiKeyInfo | null {
if (!key.startsWith('ak_')) return null
const db = getDb()
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) }
}

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

@ -0,0 +1,59 @@
import { getDb } from './db'
import bcrypt from 'bcryptjs'
export function initDatabase(): void {
const db = getDb()
const schema = [
"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 tickets (id INTEGER PRIMARY KEY, device_ip TEXT, device_sn TEXT, device_name TEXT, content TEXT, assign_time TEXT, close_time TEXT, duration_minutes INTEGER, availability REAL, process_summary TEXT, conclusion TEXT, fault_category TEXT, fault_subcategory TEXT, parts_replaced TEXT, parts_name TEXT, current_status TEXT NOT NULL DEFAULT 'open', counted_in_sla INTEGER NOT NULL DEFAULT 1, responsibility TEXT, created_by INTEGER REFERENCES users(id), updated_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')));",
"CREATE TABLE IF NOT EXISTS ticket_steps (id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, step_order INTEGER NOT NULL, time_node TEXT, handler TEXT, description TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')));",
"CREATE TABLE IF NOT EXISTS reports (id INTEGER PRIMARY KEY AUTOINCREMENT, report_type TEXT NOT NULL, period_start TEXT, period_end TEXT, format TEXT NOT NULL DEFAULT 'pdf', file_path TEXT, file_name TEXT, status TEXT NOT NULL DEFAULT 'pending', error_message TEXT, created_by INTEGER REFERENCES users(id), created_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), 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 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 '[\"tickets: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')));"
]
for (const sql of schema) db.exec(sql)
// 迁移:添加 parts_name 列
try { db.exec('ALTER TABLE tickets ADD COLUMN parts_name TEXT') } catch { /* 列已存在 */ }
// 迁移:添加 ticket_type 列
try { db.exec('ALTER TABLE tickets ADD COLUMN ticket_type TEXT') } catch { /* 列已存在 */ }
// 迁移:将已有 OEM 分类迁移到 ticket_type
try {
db.prepare("UPDATE tickets SET ticket_type = 'OEM诊断' WHERE fault_category = 'OEM诊断' AND ticket_type IS NULL").run()
db.prepare("UPDATE tickets SET ticket_type = 'OEM维修' WHERE fault_category = 'OEM维修' AND ticket_type IS NULL").run()
db.prepare("UPDATE tickets SET ticket_type = 'OEM诊断' WHERE ticket_type IS NULL AND fault_category = '无故障'").run()
db.prepare("UPDATE tickets SET ticket_type = 'OEM维修' WHERE ticket_type IS NULL AND fault_category IN ('硬件故障', '网络故障', '存储故障', '电源故障')").run()
} catch { /* 迁移失败则保持原样 */ }
// 迁移metadata 列(报告元数据 JSON
try { db.exec('ALTER TABLE reports ADD COLUMN metadata TEXT') } catch { /* 已存在 */ }
// 迁移:修正 reports 表 created_at 为北京时间UTC+8并修正 format 默认值
try {
const info = db.prepare('SELECT sql FROM sqlite_master WHERE type=? AND name=?').get('table', 'reports') as any
if (info && info.sql && !info.sql.includes("+8 hours")) {
db.exec('DROP TABLE IF EXISTS reports')
db.exec("CREATE TABLE reports (id INTEGER PRIMARY KEY AUTOINCREMENT, report_type TEXT NOT NULL, period_start TEXT, period_end TEXT, format TEXT NOT NULL DEFAULT 'docx', file_path TEXT, file_name TEXT, status TEXT NOT NULL DEFAULT 'pending', error_message TEXT, created_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')), metadata TEXT)")
}
} catch { /* 迁移失败则保持原样 */ }
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get('admin')
if (!existing) {
const defaultPassword = process.env.ADMIN_PASSWORD || 'admin123'
const hash = bcrypt.hashSync(defaultPassword, 10)
db.prepare('INSERT INTO users (username, password_hash, display_name, role) VALUES (?, ?, ?, ?)').run('admin', hash, '系统管理员', 'admin')
}
const roles = [
{ name: 'admin', display_name: '管理员', permissions: '["*"]' },
{ name: 'operator', display_name: '运维人员', permissions: '["tickets:read","tickets:write","reports:read"]' },
{ name: 'viewer', display_name: '查看者', permissions: '["tickets:read","reports:read"]' },
]
for (const r of roles) {
const ex = db.prepare('SELECT id FROM roles WHERE name = ?').get(r.name)
if (!ex) db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)').run(r.name, r.display_name, r.permissions)
}
}

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

@ -0,0 +1,21 @@
import Database from 'better-sqlite3'
import path from 'path'
let db: Database.Database | null = null
export function getDb(): Database.Database {
if (!db) {
const dbPath = process.env.DATABASE_PATH || './data/issue.db'
const dir = path.dirname(dbPath)
const fs = require('fs')
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
db = new Database(dbPath)
db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON')
}
return db
}
export function closeDb(): void {
if (db) { db.close(); db = null }
}

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

@ -0,0 +1,77 @@
import * as XLSX from 'xlsx'
import type { TicketCreateInput } from '@/types/ticket'
const TICKET_HEADERS = [
'工单号', '业务IP', '设备序列号', '节点名称', '工单内容',
'派单时间', '结单时间', '处理时长(分钟)', '可用性', '处理结果',
'结论', '故障大类', '故障小类', '更换配件', '当前状态',
'SLA计数', '责任方'
]
export function parseExcelTickets(buffer: Buffer): TicketCreateInput[] {
const workbook = XLSX.read(buffer, { type: 'buffer' })
const sheet = workbook.Sheets[workbook.SheetNames[0]]
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: '' })
return rows.map((row) => {
const r: Record<string, unknown> = {}
for (const [key, value] of Object.entries(row)) {
r[key.trim()] = value
}
return {
ticket_no: String(r['工单号'] || '').trim() || undefined,
device_ip: String(r['业务IP'] || '').trim() || undefined,
device_sn: String(r['设备序列号'] || '').trim() || undefined,
device_name: String(r['节点名称'] || '').trim() || undefined,
content: String(r['工单内容'] || '').trim() || undefined,
assign_time: parseDateField(r['派单时间']),
fault_category: String(r['故障大类'] || '').trim() || undefined,
fault_subcategory: String(r['故障小类'] || '').trim() || undefined,
responsibility: String(r['责任方'] || '').trim() || undefined,
current_status: String(r['当前状态'] || 'open').trim() || 'open',
counted_in_sla: r['SLA计数'] === '否' || r['SLA计数'] === 0 ? 0 : 1,
}
}).filter(t => t.device_ip || t.content)
}
function parseDateField(value: unknown): string | undefined {
if (!value) return undefined
if (typeof value === 'number') {
// Excel date serial number
const date = XLSX.SSF.parse_date_code(value)
if (date) {
return `${date.y}-${String(date.m).padStart(2, '0')}-${String(date.d).padStart(2, '0')} ${String(date.H).padStart(2, '0')}:${String(date.M).padStart(2, '0')}`
}
}
const str = String(value).trim()
return str || undefined
}
export function exportTicketsToExcel(tickets: Array<Record<string, unknown>>): Buffer {
const rows = tickets.map((t) => ({
'工单号': t.id,
'业务IP': t.device_ip || '',
'设备序列号': t.device_sn || '',
'节点名称': t.device_name || '',
'工单内容': t.content || '',
'派单时间': t.assign_time || '',
'结单时间': t.close_time || '',
'处理时长(分钟)': t.duration_minutes ?? '',
'可用性': t.availability ?? '',
'处理结果': t.process_summary || '',
'结论': t.conclusion || '',
'故障大类': t.fault_category || '',
'故障小类': t.fault_subcategory || '',
'更换配件': t.parts_replaced || '',
'当前状态': t.current_status || '',
'SLA计数': t.counted_in_sla ? '是' : '否',
'责任方': t.responsibility || '',
}))
const worksheet = XLSX.utils.json_to_sheet(rows, { header: TICKET_HEADERS })
// Set column widths
worksheet['!cols'] = TICKET_HEADERS.map(() => ({ wch: 18 }))
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(workbook, worksheet, '工单列表')
return Buffer.from(XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }))
}

79
src/lib/jwt.ts Normal file
View File

@ -0,0 +1,79 @@
// Cross-runtime JWT utilities (Edge + Node.js)
// Uses Web Crypto API for HMAC
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production'
export interface UserPayload {
id: number
username: string
display_name: string
role: string
}
// Encode Uint8Array to base64url
function bytesToBase64url(bytes: Uint8Array): string {
let binary = ''
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i])
}
// Use Buffer in Node.js, btoa in Edge
const encoded = typeof Buffer !== 'undefined'
? Buffer.from(binary, 'binary').toString('base64')
: btoa(binary)
return encoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
// Encode string to base64url
function strToBase64url(str: string): string {
const encoded = typeof Buffer !== 'undefined'
? Buffer.from(str, 'utf-8').toString('base64')
: btoa(unescape(encodeURIComponent(str)))
return encoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
// Decode base64url to string
function base64urlToStr(str: string): string {
str = str.replace(/-/g, '+').replace(/_/g, '/')
while (str.length % 4) str += '='
return typeof Buffer !== 'undefined'
? Buffer.from(str, 'base64').toString('utf-8')
: decodeURIComponent(escape(atob(str)))
}
async function hmacSha256(key: string, data: string): Promise<Uint8Array> {
const keyBytes = new TextEncoder().encode(key)
const dataBytes = new TextEncoder().encode(data)
const cryptoKey = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
)
const sig = await crypto.subtle.sign('HMAC', cryptoKey, dataBytes)
return new Uint8Array(sig)
}
export async function createToken(user: UserPayload): Promise<string> {
const header = strToBase64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const now = Math.floor(Date.now() / 1000)
const body = JSON.stringify({ ...user, iat: now, exp: now + 7 * 24 * 60 * 60 })
const payload = strToBase64url(body)
const signingInput = `${header}.${payload}`
const sigBytes = await hmacSha256(JWT_SECRET, signingInput)
const signature = bytesToBase64url(sigBytes)
return `${signingInput}.${signature}`
}
export async function verifyToken(token: string): Promise<UserPayload | null> {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
const signingInput = `${parts[0]}.${parts[1]}`
const sigBytes = await hmacSha256(JWT_SECRET, signingInput)
const expectedSig = bytesToBase64url(sigBytes)
if (parts[2] !== expectedSig) return null
const payload = JSON.parse(base64urlToStr(parts[1]))
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null
if (payload.id == null) return null
return { id: payload.id, username: payload.username, display_name: payload.display_name, role: payload.role }
} catch {
return null
}
}

View File

@ -0,0 +1,139 @@
import puppeteer from 'puppeteer'
import type { DailyOnlineStats } from '@/types/report'
import path from 'path'
import fs from 'fs'
const CHART_WIDTH = 800
const CHART_HEIGHT = 400
// 在 Next.js webpack 环境下 require.resolve 可能失败,用文件系统路径
const ECHARTS_PATH = path.join(process.cwd(), 'node_modules', 'echarts', 'dist', 'echarts.min.js')
export async function generateDailyOnlineChart(
stats: DailyOnlineStats[],
seriesKey: 'gpu' | 'storage'
): Promise<Buffer> {
const label = seriesKey === 'gpu' ? 'GPU' : '存储'
const totalKey = seriesKey === 'gpu' ? 'gpuTotal' : 'storageTotal' as const
const onlineKey = seriesKey === 'gpu' ? 'gpuOnline' : 'storageOnline' as const
const total = stats[0]?.[totalKey] ?? 0
const dates = stats.map(s => s.date.slice(5)) // "MM-DD"
const onlineValues = stats.map(s => s[onlineKey])
const echartsScript = fs.readFileSync(ECHARTS_PATH, 'utf-8')
const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: "SimSun", "Noto Sans CJK SC", sans-serif; }
#chart { width: ${CHART_WIDTH}px; height: ${CHART_HEIGHT}px; }
</style>
</head>
<body>
<div id="chart"></div>
</body>
</html>`
const browser = await puppeteer.launch({
headless: true as any,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
})
try {
const page = await browser.newPage()
await page.setViewport({ width: CHART_WIDTH, height: CHART_HEIGHT, deviceScaleFactor: 1 })
await page.setContent(html, { waitUntil: 'networkidle0' })
// 注入 echarts使用 eval 方式,兼容 Next.js webpack 环境)
await page.evaluate((script: string) => {
eval.call(null, script)
}, echartsScript)
// 渲染图表
await page.evaluate((params: {
dates: string[]; onlineValues: number[]; total: number; label: string;
}) => {
const chartDom = document.getElementById('chart')
if (!chartDom) return
const myChart = (window as any).echarts.init(chartDom)
// 动态 Y 轴范围:根据实际数据波动自动调整,使微小变化也能看清
const minOnline = Math.min(...params.onlineValues)
const maxOnline = Math.max(...params.onlineValues)
let yMin: number, yMax: number, yInterval: number
if (minOnline === maxOnline) {
// 无波动Y 轴范围 total±2
yMin = Math.max(0, params.total - 2)
yMax = params.total + 2
yInterval = 1
} else {
const buffer = Math.max(2, Math.ceil(params.total * 0.05))
yMin = Math.max(0, minOnline - buffer)
yMax = params.total + Math.ceil(buffer / 2)
const yRange = yMax - yMin
// 确保刻度数在 8~15 个
const rawStep = yRange / 10
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)))
const residual = rawStep / magnitude
if (residual <= 1.5) yInterval = magnitude
else if (residual <= 3.5) yInterval = 2 * magnitude
else if (residual <= 7.5) yInterval = 5 * magnitude
else yInterval = 10 * magnitude
if (yInterval < 1) yInterval = 1
}
myChart.setOption({
animation: false,
title: {
text: `${params.label}服务器每日在线节点数(共 ${params.total} 台)`,
left: 'center',
top: 10,
textStyle: { fontFamily: 'SimSun, sans-serif', fontSize: 16, color: '#333' },
},
tooltip: { trigger: 'axis' },
grid: { left: 80, right: 80, top: 60, bottom: 80 },
xAxis: {
type: 'category',
data: params.dates,
boundaryGap: false,
axisLabel: { fontSize: 11, interval: 0, rotate: 45, hideOverlap: false },
},
yAxis: {
type: 'value',
min: yMin,
max: yMax,
name: '在线节点数',
interval: yInterval,
axisLabel: { fontSize: 11 },
},
series: [{
name: '在线节点',
type: 'line',
data: params.onlineValues,
clip: false,
sampling: 'none',
smooth: false,
symbol: 'circle',
symbolSize: 4,
lineStyle: { width: 2, color: '#4472C4' },
itemStyle: { color: '#4472C4' },
areaStyle: { color: 'rgba(68, 114, 196, 0.1)' },
}],
})
}, { dates, onlineValues, total, label })
// 等待渲染
await new Promise(resolve => setTimeout(resolve, 800))
// 截图
const chartElement = await page.$('#chart')
if (!chartElement) throw new Error('图表元素未找到')
const screenshot = await chartElement.screenshot({ type: 'png' })
return Buffer.from(screenshot)
} finally {
await browser.close()
}
}

View File

@ -0,0 +1,311 @@
import {
Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell,
WidthType, AlignmentType, HeadingLevel, PageBreak, ImageRun,
TableOfContents, VerticalAlign,
} from 'docx'
import type {
MonthlyReportData, Chapter3FaultEntry, Chapter3OtherEntry,
} from '@/types/report'
// ---- helpers ----
function createHeaderCell(text: string, width?: number): TableCell {
return new TableCell({
children: [new Paragraph({
children: [new TextRun({ text, bold: true, size: 20, font: 'SimSun', color: 'FFFFFF' })],
alignment: AlignmentType.CENTER,
spacing: { line: 360 },
})],
shading: { fill: '1F4E79' },
width: width ? { size: width, type: WidthType.PERCENTAGE } : undefined,
verticalAlign: VerticalAlign.CENTER,
})
}
function createCell(text: string, align = AlignmentType.CENTER): TableCell {
return new TableCell({
children: [new Paragraph({
children: [new TextRun({ text: text || '', size: 18, font: 'SimSun' })],
alignment: align,
spacing: { line: 276 },
})],
verticalAlign: VerticalAlign.CENTER,
})
}
// 标题段落
function chapterTitle(text: string, heading: HeadingLevel, spaceBefore = 400, spaceAfter = 200): Paragraph {
return new Paragraph({
children: [new TextRun({ text, bold: true, size: 28, font: 'SimSun' })],
heading,
spacing: { before: spaceBefore, after: spaceAfter, line: 360 },
})
}
// 小节标题
function sectionTitle(text: string): Paragraph {
return new Paragraph({
children: [new TextRun({ text, bold: true, size: 24, font: 'SimSun' })],
heading: HeadingLevel.HEADING_2,
spacing: { before: 200, after: 100, line: 360 },
})
}
// 正文段落首行缩进2字符
function bodyPara(text: string): Paragraph {
return new Paragraph({
children: [new TextRun({ text, size: 22, font: 'SimSun' })],
spacing: { after: 80, line: 360 },
indent: { firstLine: 480 },
})
}
// 页面分隔
function pageBreak(): Paragraph {
return new Paragraph({ children: [new PageBreak()] })
}
// ---- 表格构建 ----
function buildFaultTable(entries: Chapter3FaultEntry[]): Table {
const headerTexts = ['工单编号', '故障节点', '故障日期', '故障问题', '故障原因', '处理时长(分钟)', '是否计入SLA']
const headerRow = new TableRow({ children: headerTexts.map(t => createHeaderCell(t)) })
const dataRows = entries.map(e => new TableRow({
children: [
createCell(String(e.ticketId)),
createCell(e.nodeIp),
createCell(e.faultDate),
createCell(e.faultProblem),
createCell(e.faultCause),
createCell(String(e.durationMinutes)),
createCell(e.countedInSla),
],
}))
return new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows: [headerRow, ...dataRows] })
}
function buildOtherTable(entries: Chapter3OtherEntry[]): Table {
const headerTexts = ['工单编号', '设备IP地址', '工单日期', '工单内容', '工单结论', '处理时长(分钟)', '是否计入SLA']
const headerRow = new TableRow({ children: headerTexts.map(t => createHeaderCell(t)) })
const dataRows = entries.map(e => new TableRow({
children: [
createCell(String(e.ticketId)),
createCell(e.deviceIp),
createCell(e.ticketDate),
createCell(e.ticketContent, AlignmentType.LEFT),
createCell(e.ticketConclusion, AlignmentType.LEFT),
createCell(String(e.durationMinutes)),
createCell(e.countedInSla),
],
}))
return new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows: [headerRow, ...dataRows] })
}
// ---- 主入口 ----
export async function buildMonthlyReportDocx(
data: MonthlyReportData,
charts: { gpuPng: Buffer; storagePng: Buffer },
): Promise<Buffer> {
const children: Paragraph[] = []
// ========== 封面页 ==========
// 上方留白
children.push(new Paragraph({ children: [], spacing: { before: 2400 } }))
children.push(new Paragraph({
children: [new TextRun({
text: '图灵引擎&腾讯公司算力服务框架IT基础设施运营月报',
bold: true, size: 44, font: 'SimSun',
})],
alignment: AlignmentType.CENTER,
spacing: { after: 200, line: 360 },
}))
children.push(new Paragraph({
children: [new TextRun({
text: `${data.monthLabel.replace('年', '年').replace('月', '月')}1日-${new Date(data.periodEnd).getDate()}日)`,
bold: true, size: 52, font: 'SimHei',
})],
alignment: AlignmentType.CENTER,
spacing: { after: 800, line: 360 },
}))
// 公司 + 生成月份(页面下方,标题下方留 8 空行)
children.push(new Paragraph({ children: [], spacing: { before: 600, line: 360 } }))
children.push(new Paragraph({ children: [], spacing: { before: 200, line: 360 } }))
children.push(new Paragraph({ children: [], spacing: { before: 200, line: 360 } }))
children.push(new Paragraph({ children: [], spacing: { before: 200, line: 360 } }))
children.push(new Paragraph({ children: [], spacing: { before: 200, line: 360 } }))
children.push(new Paragraph({ children: [], spacing: { before: 200, line: 360 } }))
children.push(new Paragraph({ children: [], spacing: { before: 200, line: 360 } }))
children.push(new Paragraph({ children: [], spacing: { before: 200, line: 360 } }))
children.push(new Paragraph({ children: [], spacing: { before: 200 } }))
children.push(new Paragraph({
children: [new TextRun({ text: '杭州图灵引擎科技有限公司', size: 32, font: 'SimSun' })],
alignment: AlignmentType.CENTER,
spacing: { line: 360 },
}))
children.push(new Paragraph({
children: [new TextRun({ text: `${data.generationLabel}`, size: 32, font: 'SimSun' })],
alignment: AlignmentType.CENTER,
spacing: { line: 360 },
}))
// ========== 目录页 ==========
children.push(pageBreak())
children.push(new Paragraph({
children: [new TextRun({ text: '目录', bold: true, size: 36, font: 'SimSun' })],
alignment: AlignmentType.CENTER,
spacing: { after: 400, line: 360 },
}))
children.push(new TableOfContents('目录', {
hyperlink: true,
headingStyleRange: '1-2',
}) as any)
// ========== 第一章:总体运营概况 ==========
children.push(pageBreak())
children.push(chapterTitle('一、总体运营概况', HeadingLevel.HEADING_1))
// GPU 图表
children.push(new Paragraph({
children: [new ImageRun({
data: charts.gpuPng,
transformation: { width: 550, height: 275 },
type: 'png',
})],
alignment: AlignmentType.CENTER,
spacing: { before: 200, after: 300 },
}))
// 存储图表
children.push(new Paragraph({
children: [new ImageRun({
data: charts.storagePng,
transformation: { width: 550, height: 275 },
type: 'png',
})],
alignment: AlignmentType.CENTER,
spacing: { before: 200, after: 200 },
}))
// ========== 第二章:运营数据总览 ==========
children.push(chapterTitle('二、运营数据总览', HeadingLevel.HEADING_1))
// 按 device_type 分组
const gpuChapter2 = data.chapter2.filter(e => e.device_type === 'gpu')
const storageChapter2 = data.chapter2.filter(e => e.device_type === 'storage')
// 2.1 GPU
children.push(sectionTitle('2.1 GPU服务器运行状态'))
if (gpuChapter2.length === 0) {
children.push(bodyPara('故障记录:无。'))
} else {
for (const entry of gpuChapter2) {
for (const f of entry.faults) {
const dateParts = entry.date.split('-')
const monthDay = `${parseInt(dateParts[1])}${parseInt(dateParts[2])}`
const recoveryText = f.recoveryDays === 0 ? '当日'
: f.recoveryDays === 1 ? '次日'
: `${f.recoveryDays}日后`
children.push(bodyPara(
`${monthDay}发生1次${f.fault_subcategory},故障节点为${f.ip}${recoveryText}恢复。`
))
}
}
}
// 2.2 存储
children.push(sectionTitle('2.2 存储服务器运行状态'))
if (storageChapter2.length === 0) {
children.push(bodyPara('故障记录:无。'))
} else {
for (const entry of storageChapter2) {
for (const f of entry.faults) {
const dateParts = entry.date.split('-')
const monthDay = `${parseInt(dateParts[1])}${parseInt(dateParts[2])}`
const recoveryText = f.recoveryDays === 0 ? '当日'
: f.recoveryDays === 1 ? '次日'
: `${f.recoveryDays}日后`
children.push(bodyPara(
`${monthDay}发生1次${f.fault_subcategory},故障节点为${f.ip}${recoveryText}恢复。`
))
}
}
}
// ========== 第三章:运营故障概览 ==========
children.push(chapterTitle('三、运营故障概览', HeadingLevel.HEADING_1))
const { gpuFaults, storageFaults, otherTickets } = data.chapter3
// 3.1 GPU
children.push(sectionTitle(
gpuFaults.length > 0 ? `3.1 GPU服务器故障${gpuFaults.length}个)` : '3.1 GPU服务器故障'
))
if (gpuFaults.length > 0) children.push(buildFaultTable(gpuFaults))
// 3.2 存储
children.push(sectionTitle(
storageFaults.length > 0 ? `3.2 存储服务器故障(${storageFaults.length}个)` : '3.2 存储服务器故障(无)'
))
if (storageFaults.length > 0) children.push(buildFaultTable(storageFaults))
// 3.3 其他工单
children.push(sectionTitle(
otherTickets.length > 0 ? `3.3 其他工单(${otherTickets.length}个)` : '3.3 其他工单(无)'
))
if (otherTickets.length > 0) children.push(buildOtherTable(otherTickets))
// ========== 第四章:服务可用性说明 ==========
children.push(chapterTitle('四、服务可用性说明', HeadingLevel.HEADING_1))
if (data.chapter4.length === 0) {
children.push(bodyPara('无。'))
} else {
for (const entry of data.chapter4) {
const totalMinutes = entry.monthDays * 24 * 60
const formula = `${entry.ip}服务可用性=(${entry.monthDays}*24*60-${entry.totalDurationMinutes})/(${entry.monthDays}*24*60)*100%=`
const percent = `${entry.availabilityPercent.toFixed(2)}%`
const below99 = entry.availabilityPercent < 99
children.push(new Paragraph({
children: [
new TextRun({ text: formula, size: 22, font: 'SimSun' }),
new TextRun(below99
? { text: percent, size: 22, font: 'SimSun', bold: true, color: 'FF0000', highlight: 'yellow' }
: { text: percent, size: 22, font: 'SimSun' }
),
],
spacing: { after: 80, line: 360 },
indent: { firstLine: 480 },
}))
}
}
// ========== 组装文档 ==========
const doc = new Document({
sections: [{
children,
properties: {
page: {
margin: {
top: 1440, // ~1 inch in twips
bottom: 1440,
left: 1440,
right: 1440,
},
},
},
}],
styles: {
default: {
document: { run: { font: 'SimSun', size: 22 } },
heading1: { run: { font: 'SimSun', size: 28, bold: true }, paragraph: { spacing: { line: 360 } } },
heading2: { run: { font: 'SimSun', size: 24, bold: true }, paragraph: { spacing: { line: 360 } } },
},
},
})
return Packer.toBuffer(doc)
}

276
src/lib/monthly-report.ts Normal file
View File

@ -0,0 +1,276 @@
import { getDb } from './db'
import { getActiveDevices, DEVICE_TYPE_GPU, DEVICE_TYPE_STORAGE } from './assets-client'
import { generateDailyOnlineChart } from './monthly-report-charts'
import { buildMonthlyReportDocx } from './monthly-report-docx'
import type {
ClassifiedTicket, DailyOnlineStats, Chapter2Entry, Chapter2FaultItem,
Chapter3FaultEntry, Chapter3OtherEntry, Chapter4Entry, MonthlyReportData,
} from '@/types/report'
import fs from 'fs'
import path from 'path'
// 报告文件存储目录
const REPORTS_DIR = process.env.REPORTS_DIR || './reports'
function ensureReportsDir() {
if (!fs.existsSync(REPORTS_DIR)) fs.mkdirSync(REPORTS_DIR, { recursive: true })
}
// 获取当月所有日期
function getDateRange(start: string, end: string): string[] {
const dates: string[] = []
const cur = new Date(start)
const last = new Date(end)
while (cur <= last) {
dates.push(cur.toISOString().slice(0, 10))
cur.setDate(cur.getDate() + 1)
}
return dates
}
// 计算两个日期之间的天数差
function daysBetween(dateStr1: string, dateStr2: string): number {
const d1 = new Date(dateStr1.slice(0, 10))
const d2 = new Date(dateStr2.slice(0, 10))
return Math.floor((d2.getTime() - d1.getTime()) / (1000 * 60 * 60 * 24))
}
/** 采集月报所需全部数据 */
export async function collectMonthlyReportData(
periodStart: string, periodEnd: string
): Promise<MonthlyReportData> {
// 1. 从 assets-ai 获取 GPU + 存储设备清单
const [gpuDevices, storageDevices] = await Promise.all([
getActiveDevices(DEVICE_TYPE_GPU),
getActiveDevices(DEVICE_TYPE_STORAGE),
])
// 构建 IP → device_type 映射(用 business_ip 作为主 IP
const ipTypeMap = new Map<string, 'gpu' | 'storage'>()
for (const d of gpuDevices) {
if (d.business_ip) ipTypeMap.set(d.business_ip, 'gpu')
if (d.hdm_ip) ipTypeMap.set(d.hdm_ip, 'gpu')
}
for (const d of storageDevices) {
if (d.business_ip) ipTypeMap.set(d.business_ip, 'storage')
if (d.hdm_ip) ipTypeMap.set(d.hdm_ip, 'storage')
}
// 2. 查询当月已结单工单(按 close_time 范围)
const db = getDb()
const endFull = periodEnd + ' 23:59:59'
const ticketsRaw = db.prepare(`
SELECT * FROM tickets
WHERE close_time >= ? AND close_time <= ?
AND current_status IN ('resolved', 'closed')
AND duration_minutes IS NOT NULL
ORDER BY assign_time
`).all(periodStart, endFull) as any[]
// 3. 分类工单
const tickets: ClassifiedTicket[] = ticketsRaw.map(t => ({
id: t.id,
device_ip: t.device_ip || '',
device_name: t.device_name,
device_type: ipTypeMap.get(t.device_ip) || 'other',
fault_category: t.fault_category,
fault_subcategory: t.fault_subcategory,
parts_replaced: t.parts_replaced,
parts_name: t.parts_name,
content: t.content,
conclusion: t.conclusion,
assign_time: t.assign_time,
close_time: t.close_time,
duration_minutes: t.duration_minutes || 0,
availability: t.availability,
}))
const monthDays = getDateRange(periodStart, periodEnd).length
// 生成月份 & 报告月份
const periodDate = new Date(periodStart)
const genDate = new Date(periodDate.getFullYear(), periodDate.getMonth() + 1, 1)
const monthLabel = `${periodDate.getFullYear()}${periodDate.getMonth() + 1}`
const generationLabel = `${genDate.getFullYear()}${genDate.getMonth() + 1}`
// 4. 第一章:每日在线节点数
const dates = getDateRange(periodStart, periodEnd)
// 排除"无故障"分类的工单agent上报异常等跨月工单上月派发本月恢复正常计入
const monthFaults = tickets.filter(t =>
t.fault_category !== '无故障'
)
const dailyStats: DailyOnlineStats[] = dates.map(date => {
// 当天不在线assign 日期 ≤ date < close 日期(跨月工单也会正确计入)
const gpuOffline = monthFaults.filter(t =>
t.device_type === 'gpu' &&
t.assign_time.slice(0, 10) <= date &&
date < t.close_time.slice(0, 10)
).length
const storageOffline = monthFaults.filter(t =>
t.device_type === 'storage' &&
t.assign_time.slice(0, 10) <= date &&
date < t.close_time.slice(0, 10)
).length
return {
date,
gpuOnline: gpuDevices.length - gpuOffline,
gpuTotal: gpuDevices.length,
storageOnline: storageDevices.length - storageOffline,
storageTotal: storageDevices.length,
}
})
// 5. 第二章:运营数据总览(仅 gpu/storage且排除"无故障"工单)
const gpuStorageTickets = tickets.filter(t => t.device_type !== 'other' && t.fault_category !== '无故障')
const chapter2Map = new Map<string, Chapter2FaultItem[]>()
for (const t of gpuStorageTickets) {
const assignDate = t.assign_time.slice(0, 10)
const key = `${t.device_type}|${assignDate}`
if (!chapter2Map.has(key)) chapter2Map.set(key, [])
chapter2Map.get(key)!.push({
ip: t.device_ip,
fault_subcategory: t.fault_subcategory || '未知故障',
recoveryDays: daysBetween(t.assign_time, t.close_time),
})
}
const chapter2: Chapter2Entry[] = []
for (const [key, faults] of chapter2Map) {
const [device_type, date] = key.split('|')
chapter2.push({ device_type: device_type as 'gpu' | 'storage', date, faults })
}
// 按日期排序
chapter2.sort((a, b) => a.date.localeCompare(b.date))
// 6. 第三章:运营故障概览
// 先按 fault_subcategory === '其他' 分流
const otherTickets = tickets.filter(t => t.fault_subcategory === '其他')
// 其余按 device_type 分
const gpuFaultTickets = tickets.filter(t =>
t.fault_subcategory !== '其他' && t.device_type === 'gpu'
)
const storageFaultTickets = tickets.filter(t =>
t.fault_subcategory !== '其他' && t.device_type === 'storage'
)
// 还有fault_subcategory !== '其他' 但 device_type 也不对(比如未匹配到的)
// 这些也归入 other
const remainingOthers = tickets.filter(t =>
t.fault_subcategory !== '其他' && t.device_type === 'other'
)
function toFaultEntry(t: ClassifiedTicket): Chapter3FaultEntry {
return {
ticketId: t.id,
nodeIp: t.device_ip,
faultDate: t.assign_time,
faultProblem: t.fault_subcategory || '',
faultCause: t.parts_name ? `更换${t.parts_name}` : '-',
durationMinutes: t.duration_minutes,
countedInSla: (t.availability !== null && t.availability < 0.99 && !t.conclusion?.includes('无异常')) ? '是' : '否',
}
}
function toOtherEntry(t: ClassifiedTicket): Chapter3OtherEntry {
return {
ticketId: t.id,
deviceIp: t.device_ip,
ticketDate: t.assign_time,
ticketContent: t.content || '',
ticketConclusion: t.conclusion || '',
durationMinutes: t.duration_minutes,
countedInSla: (t.availability !== null && t.availability < 0.99 && !t.conclusion?.includes('无异常')) ? '是' : '否',
}
}
const gpuFaults = gpuFaultTickets.map(toFaultEntry)
const storageFaults = storageFaultTickets.map(toFaultEntry)
const allOtherTickets = [...otherTickets, ...remainingOthers].map(toOtherEntry)
// 7. 第四章:服务可用性说明(仅已结单工单,按 IP 分组求和,排除"无故障"工单)
const ipDurationMap = new Map<string, number>()
for (const t of tickets) {
if (t.fault_category === '无故障') continue
const dur = ipDurationMap.get(t.device_ip) || 0
ipDurationMap.set(t.device_ip, dur + t.duration_minutes)
}
const chapter4: Chapter4Entry[] = []
for (const [ip, totalDuration] of ipDurationMap) {
const totalMinutes = monthDays * 24 * 60
const availabilityPercent = ((totalMinutes - totalDuration) / totalMinutes) * 100
chapter4.push({
ip,
totalDurationMinutes: totalDuration,
monthDays,
availabilityPercent: Math.round(availabilityPercent * 100) / 100,
})
}
// 按 IP 排序
chapter4.sort((a, b) => a.ip.localeCompare(b.ip))
return {
periodStart,
periodEnd,
monthDays,
monthLabel,
generationLabel,
gpuTotal: gpuDevices.length,
storageTotal: storageDevices.length,
tickets,
dailyStats,
chapter2,
chapter3: { gpuFaults, storageFaults, otherTickets: allOtherTickets },
chapter4,
}
}
/** 异步生成月报fire-and-forget 风格) */
export async function generateMonthlyReport(reportId: number): Promise<void> {
const db = getDb()
const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(reportId) as any
if (!report) throw new Error('报告不存在')
db.prepare("UPDATE reports SET status = 'generating' WHERE id = ?").run(reportId)
try {
const data = await collectMonthlyReportData(report.period_start, report.period_end)
// 并行生成两张图表
const [gpuChartPng, storageChartPng] = await Promise.all([
generateDailyOnlineChart(data.dailyStats, 'gpu'),
generateDailyOnlineChart(data.dailyStats, 'storage'),
])
// 组装 DOCX
const buffer = await buildMonthlyReportDocx(data, { gpuPng: gpuChartPng, storagePng: storageChartPng })
ensureReportsDir()
const fileName = `${data.monthLabel}图灵IT基础设施运营月报.docx`
const filePath = path.join(REPORTS_DIR, fileName)
fs.writeFileSync(filePath, buffer)
const metadata = JSON.stringify({
gpuCount: data.gpuTotal,
storageCount: data.storageTotal,
totalTickets: data.tickets.length,
gpuFaultCount: data.chapter3.gpuFaults.length,
storageFaultCount: data.chapter3.storageFaults.length,
otherTicketCount: data.chapter3.otherTickets.length,
avgAvailability: data.chapter4.length > 0
? Math.round(data.chapter4.reduce((s, e) => s + e.availabilityPercent, 0) / data.chapter4.length * 100) / 100
: null,
})
db.prepare(
"UPDATE reports SET status = 'completed', file_path = ?, file_name = ?, metadata = ? WHERE id = ?"
).run(filePath, fileName, metadata, reportId)
} catch (e) {
const msg = e instanceof Error ? e.message : '生成失败'
console.error(`Report ${reportId} generation failed:`, e)
db.prepare("UPDATE reports SET status = 'failed', error_message = ? WHERE id = ?").run(msg, reportId)
throw e
}
}

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

@ -0,0 +1,33 @@
import { getDb } from './db'
import type { UserPayload } from './auth'
export function hasPermission(user: UserPayload, permission: string): boolean {
if (user.role === 'admin') return true
const db = getDb()
const role = db.prepare('SELECT permissions FROM roles WHERE name = ?').get(user.role) as { permissions: string } | undefined
if (!role) return false
try {
const perms: string[] = JSON.parse(role.permissions)
return perms.includes('*') || perms.includes(permission)
} catch {
return false
}
}
export function checkPermission(role: string, permission: string): boolean {
if (role === 'admin') return true
const db = getDb()
const roleRow = db.prepare('SELECT permissions FROM roles WHERE name = ?').get(role) as { permissions: string } | undefined
if (!roleRow) return false
try {
const perms: string[] = JSON.parse(roleRow.permissions)
return perms.includes('*') || perms.includes(permission)
} catch {
return false
}
}
export function requirePermission(user: UserPayload | null, permission: string): void {
if (!user) throw new Error('未登录')
if (!hasPermission(user, permission)) throw new Error('权限不足')
}

96
src/lib/sla.ts Normal file
View File

@ -0,0 +1,96 @@
// SLA 服务可用性档位定义
// 参考mail_monitor_wechat/README.md 服务可用性档位说明
export interface SlaTier {
name: string
availability: number
penalty: number
ratio: number | null // 当月总秒数占比,第五档 ratio 为 null无上限
}
export const SLA_TIERS: SlaTier[] = [
{ name: '第一档', availability: 0.99, penalty: 0, ratio: 0.01 },
{ name: '第二档', availability: 0.97, penalty: 0.10, ratio: 0.03 },
{ name: '第三档', availability: 0.95, penalty: 0.25, ratio: 0.05 },
{ name: '第四档', availability: 0.90, penalty: 0.50, ratio: 0.10 },
{ name: '第五档', availability: 0, penalty: 1.00, ratio: null },
]
export interface SlaStatus {
level: 'safe' | 'warning' | 'over-tier1' | 'critical' | 'excluded'
color: string
text: string
}
/**
*
*/
export function getMonthSeconds(year: number, month: number): number {
const daysInMonth = new Date(year, month, 0).getDate()
return daysInMonth * 24 * 3600
}
/**
* SLA
* = assign_time + × 1%
*/
export function getTier1Deadline(assignTime: string): Date {
const at = new Date(assignTime)
const monthSec = getMonthSeconds(at.getFullYear(), at.getMonth() + 1)
const tier1Window = monthSec * 0.01 // 1%
return new Date(at.getTime() + tier1Window * 1000)
}
/**
* SLA "严重"
*/
export function getTier2Deadline(assignTime: string): Date {
const at = new Date(assignTime)
const monthSec = getMonthSeconds(at.getFullYear(), at.getMonth() + 1)
const tier2Window = monthSec * 0.03 // 3%
return new Date(at.getTime() + tier2Window * 1000)
}
/**
* assign_time counted_in_sla SLA
*/
export function getSlaStatus(assignTime: string, countedInSla: number): SlaStatus {
if (countedInSla === 0) {
return { level: 'excluded', color: '#94a3b8', text: '不计入SLA' }
}
const now = new Date()
const tier1Deadline = getTier1Deadline(assignTime)
const tier1Window = tier1Deadline.getTime() - new Date(assignTime).getTime()
// 剩余时间(毫秒),正数表示还没超,负数表示已超
const remaining = tier1Deadline.getTime() - now.getTime()
if (remaining > 0) {
const remainingRatio = remaining / tier1Window
if (remainingRatio > 0.5) {
const h = Math.floor(remaining / 3600000)
const m = Math.floor((remaining % 3600000) / 60000)
return { level: 'safe', color: '#22c55e', text: `还剩 ${h}h ${m}m` }
} else {
const h = Math.floor(remaining / 3600000)
const m = Math.floor((remaining % 3600000) / 60000)
return { level: 'warning', color: '#eab308', text: `还剩 ${h}h ${m}m` }
}
}
// 已超第一档
const exceeded = Math.abs(remaining)
const tier2Deadline = getTier2Deadline(assignTime)
if (now < tier2Deadline) {
const h = Math.floor(exceeded / 3600000)
const m = Math.floor((exceeded % 3600000) / 60000)
return { level: 'over-tier1', color: '#f97316', text: `超时 ${h}h ${m}m` }
}
// 已超第二档(严重)
const h = Math.floor(exceeded / 3600000)
const m = Math.floor((exceeded % 3600000) / 60000)
return { level: 'critical', color: '#ef4444', text: `超时 ${h}h ${m}m` }
}

View File

@ -0,0 +1,501 @@
import {
Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell,
WidthType, AlignmentType, HeadingLevel, VerticalAlign,
VerticalMergeType, ShadingType, PageBreak, TableLayoutType,
} from 'docx'
import type { WeeklyReportData, WeeklyTicketEntry, WeeklyDailyStats } from '@/types/report'
const FONT = 'SimSun'
const DARK_BLUE = '1F4E79'
const SUMMARY_COLS = [17, 17, 32, 17, 17] // 1:1:2:1:1百分比和=100
const DETAIL_COLS = [15, 21, 15, 49] // 15:21:15:49百分比和=100
function fmtDate(dateStr: string): string {
const [y, m, d] = dateStr.split('-')
return `${y}/${parseInt(m)}/${parseInt(d)}`
}
// ---- helpers ----
function headerCell(text: string, widthPct?: number): TableCell {
return new TableCell({
children: [new Paragraph({
children: [new TextRun({ text, bold: true, size: 20, font: FONT, color: 'FFFFFF' })],
alignment: AlignmentType.CENTER,
spacing: { line: 360 },
})],
shading: { fill: DARK_BLUE, type: ShadingType.CLEAR },
width: widthPct ? { size: widthPct, type: WidthType.PERCENTAGE } : undefined,
verticalAlign: VerticalAlign.CENTER,
})
}
function dataCell(text: string, align: (typeof AlignmentType)[keyof typeof AlignmentType] = AlignmentType.CENTER, widthPct?: number): TableCell {
return new TableCell({
children: [new Paragraph({
children: [new TextRun({ text, size: 20, font: FONT })],
alignment: align,
spacing: { line: 276 },
})],
width: widthPct ? { size: widthPct, type: WidthType.PERCENTAGE } : undefined,
verticalAlign: VerticalAlign.CENTER,
})
}
function chapterTitle(text: string): Paragraph {
return new Paragraph({
children: [new TextRun({ text, bold: true, size: 28, font: FONT })],
heading: HeadingLevel.HEADING_1,
spacing: { before: 400, after: 200, line: 360 },
})
}
function sectionTitle(text: string): Paragraph {
return new Paragraph({
children: [new TextRun({ text, bold: true, size: 24, font: FONT })],
heading: HeadingLevel.HEADING_2,
spacing: { before: 200, after: 100, line: 360 },
})
}
function bodyPara(text: string): Paragraph {
return new Paragraph({
children: [new TextRun({ text, size: 22, font: FONT })],
spacing: { after: 80, line: 360 },
indent: { firstLine: 480 },
})
}
function ticketLabel(index: number): Paragraph {
return new Paragraph({
children: [new TextRun({ text: `工单 ${index}`, bold: true, size: 22, font: FONT })],
spacing: { before: 200, after: 100, line: 360 },
})
}
function infoLabelCell(text: string, widthPct?: number): TableCell {
return new TableCell({
children: [new Paragraph({
children: [new TextRun({ text, bold: true, size: 20, font: FONT })],
alignment: AlignmentType.CENTER,
spacing: { line: 276 },
})],
shading: { fill: 'D9E2F3', type: ShadingType.CLEAR },
width: widthPct ? { size: widthPct, type: WidthType.PERCENTAGE } : undefined,
verticalAlign: VerticalAlign.CENTER,
})
}
function infoValueCell(text: string, colSpan = 1, widthPct?: number): TableCell {
return new TableCell({
children: [new Paragraph({
children: [new TextRun({ text, size: 20, font: FONT })],
alignment: AlignmentType.LEFT,
spacing: { line: 276 },
})],
columnSpan: colSpan > 1 ? colSpan : undefined,
width: widthPct ? { size: widthPct, type: WidthType.PERCENTAGE } : undefined,
verticalAlign: VerticalAlign.CENTER,
})
}
// ---- 每日运营表格 ----
function buildDailyTable(stats: WeeklyDailyStats[]): Table {
const headerTexts = ['日期', '在线节点数', '故障节点数', '设备IP']
const headerRow = new TableRow({
children: headerTexts.map(t => headerCell(t)),
})
const dataRows: TableRow[] = []
for (const day of stats) {
if (day.offline === 0) {
dataRows.push(new TableRow({
children: [
dataCell(fmtDate(day.date)),
dataCell(String(day.online)),
dataCell('0'),
dataCell('/'),
],
}))
} else {
day.faultIps.forEach((ip, i) => {
const isFirst = i === 0
const mergeV = day.faultIps.length > 1
? (isFirst ? VerticalMergeType.RESTART : VerticalMergeType.CONTINUE)
: undefined
dataRows.push(new TableRow({
children: [
new TableCell({
children: [new Paragraph({
children: [new TextRun({ text: isFirst ? fmtDate(day.date) : '', size: 20, font: FONT })],
alignment: AlignmentType.CENTER,
spacing: { line: 276 },
})],
verticalAlign: VerticalAlign.CENTER,
verticalMerge: mergeV,
}),
new TableCell({
children: [new Paragraph({
children: [new TextRun({ text: isFirst ? String(day.online) : '', size: 20, font: FONT })],
alignment: AlignmentType.CENTER,
spacing: { line: 276 },
})],
verticalAlign: VerticalAlign.CENTER,
verticalMerge: mergeV,
}),
new TableCell({
children: [new Paragraph({
children: [new TextRun({ text: isFirst ? String(day.offline) : '', size: 20, font: FONT })],
alignment: AlignmentType.CENTER,
spacing: { line: 276 },
})],
verticalAlign: VerticalAlign.CENTER,
verticalMerge: mergeV,
}),
dataCell(ip),
],
}))
})
}
}
return new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
rows: [headerRow, ...dataRows],
})
}
// ---- 故障概况表 ----
function buildFaultSummaryTable(tickets: WeeklyTicketEntry[]): Table {
// 15:15:40:15:15 比例换算 DXAA4 内容宽度 9026 DXA
const colDxa = [1354, 1354, 3610, 1354, 1354]
const headerTexts = ['工单号', '设备IP', '工单内容', '工单时间', '目前状态']
const headerRow = new TableRow({
children: headerTexts.map((t, i) => new TableCell({
children: [new Paragraph({
children: [new TextRun({ text: t, bold: true, size: 20, font: FONT, color: 'FFFFFF' })],
alignment: AlignmentType.CENTER,
spacing: { line: 360 },
})],
shading: { fill: DARK_BLUE, type: ShadingType.CLEAR },
width: { size: colDxa[i], type: WidthType.DXA },
verticalAlign: VerticalAlign.CENTER,
})),
})
const dataRows = tickets.map(t => new TableRow({
children: [
new TableCell({ children: [new Paragraph({ children: [new TextRun({ text: t.ticketNo, size: 20, font: FONT })], alignment: AlignmentType.CENTER, spacing: { line: 276 } })], width: { size: colDxa[0], type: WidthType.DXA }, verticalAlign: VerticalAlign.CENTER }),
new TableCell({ children: [new Paragraph({ children: [new TextRun({ text: t.deviceIp, size: 20, font: FONT })], alignment: AlignmentType.CENTER, spacing: { line: 276 } })], width: { size: colDxa[1], type: WidthType.DXA }, verticalAlign: VerticalAlign.CENTER }),
new TableCell({ children: [new Paragraph({ children: [new TextRun({ text: t.content || t.faultSubcategory || '-', size: 20, font: FONT })], alignment: AlignmentType.LEFT, spacing: { line: 276 } })], width: { size: colDxa[2], type: WidthType.DXA }, verticalAlign: VerticalAlign.CENTER }),
new TableCell({ children: [new Paragraph({ children: [new TextRun({ text: t.assignTime, size: 20, font: FONT })], alignment: AlignmentType.CENTER, spacing: { line: 276 } })], width: { size: colDxa[3], type: WidthType.DXA }, verticalAlign: VerticalAlign.CENTER }),
new TableCell({ children: [new Paragraph({ children: [new TextRun({ text: t.isResolved ? '已恢复' : '处理中', size: 20, font: FONT })], alignment: AlignmentType.CENTER, spacing: { line: 276 } })], width: { size: colDxa[4], type: WidthType.DXA }, verticalAlign: VerticalAlign.CENTER }),
],
}))
return new Table({
width: { size: 9026, type: WidthType.DXA },
columnWidths: colDxa,
layout: TableLayoutType.FIXED,
rows: [headerRow, ...dataRows],
})
}
// ---- 故障详情单张工单的完整展开统一为一张4列表格 ----
function buildTicketDetail(ticket: WeeklyTicketEntry): (Paragraph | Table)[] {
const rows: TableRow[] = []
const C = DETAIL_COLS
// colSpan 3 = 后三列宽度之和
const SPAN3 = C[1] + C[2] + C[3]
// 行1工单号 | value | 设备IP地址 | value
rows.push(new TableRow({
children: [
infoLabelCell('工单号', C[0]),
infoValueCell(ticket.ticketNo, 1, C[1]),
infoLabelCell('设备IP', C[2]),
infoValueCell(ticket.deviceIp, 1, C[3]),
],
}))
// 行2工单内容 | value3列合并
rows.push(new TableRow({
children: [
infoLabelCell('工单内容', C[0]),
infoValueCell(ticket.content || ticket.faultSubcategory || '-', 3, SPAN3),
],
}))
// 行3派单时间 | value | 结单时间 | value
rows.push(new TableRow({
children: [
infoLabelCell('派单时间', C[0]),
infoValueCell(ticket.assignTime, 1, C[1]),
infoLabelCell('结单时间', C[2]),
infoValueCell(ticket.closeTime || '—', 1, C[3]),
],
}))
// 构建工单流程行数据
const hasDispatch = ticket.steps.some(s =>
s.handler === '腾讯' && (s.description || '').includes('下发工单')
)
const hasClose = ticket.steps.some(s =>
(s.description || '').includes('结单')
)
const flowRows: { time: string; handler: string; desc: string }[] = []
if (!hasDispatch) {
flowRows.push({ time: ticket.assignTime, handler: '腾讯', desc: '下发工单' })
}
for (const step of ticket.steps) {
flowRows.push({
time: step.time_node || '',
handler: step.handler || '',
desc: step.description || '',
})
}
if (!hasClose && ticket.isResolved) {
flowRows.push({ time: ticket.closeTime || '', handler: '图灵', desc: '结单' })
}
if (flowRows.length > 0) {
// 工单流程表头行(第一列纵向合并起始)
rows.push(new TableRow({
children: [
new TableCell({
children: [new Paragraph({
children: [new TextRun({ text: '工单流程', bold: true, size: 20, font: FONT })],
alignment: AlignmentType.CENTER,
spacing: { line: 276 },
})],
shading: { fill: 'D9E2F3', type: ShadingType.CLEAR },
width: { size: C[0], type: WidthType.PERCENTAGE },
verticalAlign: VerticalAlign.CENTER,
verticalMerge: VerticalMergeType.RESTART,
}),
headerCell('时间节点', C[1]),
headerCell('发现人/处理人', C[2]),
headerCell('处理过程/处理结果', C[3]),
],
}))
// 工单流程数据行(第一列纵向合并延续)
for (const r of flowRows) {
rows.push(new TableRow({
children: [
new TableCell({
children: [new Paragraph({
children: [new TextRun({ text: '', size: 20, font: FONT })],
alignment: AlignmentType.CENTER,
spacing: { line: 276 },
})],
width: { size: C[0], type: WidthType.PERCENTAGE },
verticalAlign: VerticalAlign.CENTER,
verticalMerge: VerticalMergeType.CONTINUE,
}),
dataCell(r.time),
dataCell(r.handler),
dataCell(r.desc, AlignmentType.LEFT),
],
}))
}
}
// 工单结论行3列合并居中显示
const conclusionText = ticket.conclusion || ''
if (conclusionText) {
rows.push(new TableRow({
children: [
infoLabelCell('工单结论', C[0]),
new TableCell({
children: [new Paragraph({
children: [new TextRun({ text: conclusionText, size: 20, font: FONT })],
alignment: AlignmentType.CENTER,
spacing: { line: 276 },
})],
columnSpan: 3,
width: { size: SPAN3, type: WidthType.PERCENTAGE },
verticalAlign: VerticalAlign.CENTER,
}),
],
}))
}
return [new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
rows,
})]
}
// ---- 主入口 ----
export async function buildWeeklyReportDocx(data: WeeklyReportData): Promise<Buffer> {
const children: (Paragraph | Table)[] = []
// ========== 封面 ==========
children.push(new Paragraph({ children: [], spacing: { before: 2800 } }))
children.push(new Paragraph({
children: [new TextRun({
text: '图灵IT基础设施运营周报',
bold: true, size: 36, font: FONT,
})],
alignment: AlignmentType.CENTER,
spacing: { after: 600, line: 360 },
}))
children.push(new Paragraph({
children: [new TextRun({
text: `报告周期:${data.periodStart.replace(/-/g, '/')}${data.periodEnd.replace(/-/g, '/')}`,
size: 28, font: FONT,
})],
alignment: AlignmentType.CENTER,
spacing: { after: 100, line: 360 },
}))
children.push(new Paragraph({
children: [new TextRun({
text: `生成时间:${data.generationDate}`,
size: 28, font: FONT,
})],
alignment: AlignmentType.CENTER,
spacing: { after: 800, line: 360 },
}))
for (let i = 0; i < 6; i++) {
children.push(new Paragraph({ children: [], spacing: { before: 240, line: 360 } }))
}
children.push(new Paragraph({
children: [new TextRun({ text: '杭州图灵引擎科技有限公司', size: 32, font: FONT })],
alignment: AlignmentType.CENTER,
spacing: { line: 360 },
}))
// ========== 一、总体运营概况 ==========
children.push(new Paragraph({ children: [new PageBreak()] }))
children.push(chapterTitle('一、总体运营概况'))
// 1.1 本周概览
children.push(sectionTitle('1.1 本周概览'))
children.push(bodyPara(`GPU服务器总数${data.gpuTotal}节点`))
children.push(bodyPara(`存储服务器总数:${data.storageTotal}节点`))
const summaryParts: string[] = []
if (data.totalFaultCount > 0) {
summaryParts.push(`本周共发生${data.totalFaultCount}次故障GPU服务器${data.gpuFaultTickets.length}次,存储服务器${data.storageFaultTickets.length}次)`)
summaryParts.push(`已恢复${data.resolvedCount}`)
if (data.pendingCount > 0) {
summaryParts.push(`处理中${data.pendingCount}`)
}
} else {
summaryParts.push('本周无故障')
}
children.push(bodyPara(summaryParts.join('') + '。'))
// 1.2 GPU服务器运行状态
children.push(sectionTitle('1.2 GPU服务器运行状态'))
children.push(buildDailyTable(data.gpuDailyStats))
// 1.3 存储服务器运行状态
children.push(sectionTitle('1.3 存储服务器运行状态'))
children.push(buildDailyTable(data.storageDailyStats))
// ========== 二、GPU服务器故障 ==========
children.push(chapterTitle('二、GPU服务器故障'))
children.push(sectionTitle(
data.gpuFaultTickets.length > 0
? `2.1 故障概况(${data.gpuFaultTickets.length}个)`
: '2.1 故障概况(无)'
))
if (data.gpuFaultTickets.length > 0) {
children.push(buildFaultSummaryTable(data.gpuFaultTickets))
} else {
children.push(bodyPara('无。'))
}
const resolvedGpu = data.gpuFaultTickets.filter(t => t.isResolved)
if (resolvedGpu.length > 0) {
children.push(sectionTitle(`2.2 故障详情(${resolvedGpu.length}个)`))
for (let i = 0; i < resolvedGpu.length; i++) {
children.push(ticketLabel(i + 1))
children.push(...buildTicketDetail(resolvedGpu[i]))
}
}
// ========== 三、存储服务器故障 ==========
children.push(chapterTitle('三、存储服务器故障'))
children.push(sectionTitle(
data.storageFaultTickets.length > 0
? `3.1 故障概况(${data.storageFaultTickets.length}个)`
: '3.1 故障概况(无)'
))
if (data.storageFaultTickets.length > 0) {
children.push(buildFaultSummaryTable(data.storageFaultTickets))
} else {
children.push(bodyPara('无。'))
}
const resolvedStorage = data.storageFaultTickets.filter(t => t.isResolved)
if (resolvedStorage.length > 0) {
children.push(sectionTitle(`3.2 故障详情(${resolvedStorage.length}个)`))
for (let i = 0; i < resolvedStorage.length; i++) {
children.push(ticketLabel(i + 1))
children.push(...buildTicketDetail(resolvedStorage[i]))
}
}
// ========== 四、其他工单 ==========
children.push(chapterTitle('四、其他工单'))
children.push(sectionTitle(
data.otherTickets.length > 0
? `4.1 工单概况(${data.otherTickets.length}个)`
: '4.1 工单概况(无)'
))
if (data.otherTickets.length > 0) {
children.push(buildFaultSummaryTable(data.otherTickets))
} else {
children.push(bodyPara('无。'))
}
const resolvedOther = data.otherTickets.filter(t => t.isResolved)
if (resolvedOther.length > 0) {
children.push(sectionTitle(`4.2 工单详情(${resolvedOther.length}个)`))
for (let i = 0; i < resolvedOther.length; i++) {
children.push(ticketLabel(i + 1))
children.push(...buildTicketDetail(resolvedOther[i]))
}
}
// ========== 组装文档 ==========
const doc = new Document({
sections: [{
children,
properties: {
page: {
margin: { top: 1440, bottom: 1440, left: 1440, right: 1440 },
},
},
}],
styles: {
default: {
document: { run: { font: FONT, size: 22 } },
heading1: { run: { font: FONT, size: 28, bold: true }, paragraph: { spacing: { line: 360 }, outlineLevel: 0 } },
heading2: { run: { font: FONT, size: 24, bold: true }, paragraph: { spacing: { line: 360 }, outlineLevel: 1 } },
},
},
})
return Packer.toBuffer(doc)
}

206
src/lib/weekly-report.ts Normal file
View File

@ -0,0 +1,206 @@
import { getDb } from './db'
import { getActiveDevices, DEVICE_TYPE_GPU, DEVICE_TYPE_STORAGE } from './assets-client'
import { buildWeeklyReportDocx } from './weekly-report-docx'
import type {
WeeklyReportData, WeeklyDailyStats, WeeklyTicketEntry, TicketStepRaw,
} from '@/types/report'
import fs from 'fs'
import path from 'path'
const REPORTS_DIR = process.env.REPORTS_DIR || './reports'
function ensureReportsDir() {
if (!fs.existsSync(REPORTS_DIR)) fs.mkdirSync(REPORTS_DIR, { recursive: true })
}
function getDateRange(start: string, end: string): string[] {
const dates: string[] = []
const cur = new Date(start)
const last = new Date(end)
while (cur <= last) {
dates.push(cur.toISOString().slice(0, 10))
cur.setDate(cur.getDate() + 1)
}
return dates
}
/** 采集周报所需全部数据 */
export async function collectWeeklyReportData(
periodStart: string, periodEnd: string
): Promise<WeeklyReportData> {
// 1. 获取 GPU + 存储设备清单
const [gpuDevices, storageDevices] = await Promise.all([
getActiveDevices(DEVICE_TYPE_GPU),
getActiveDevices(DEVICE_TYPE_STORAGE),
])
// 构建 IP → device_type 映射
const ipTypeMap = new Map<string, 'gpu' | 'storage'>()
for (const d of gpuDevices) {
if (d.business_ip) ipTypeMap.set(d.business_ip, 'gpu')
if (d.hdm_ip) ipTypeMap.set(d.hdm_ip, 'gpu')
}
for (const d of storageDevices) {
if (d.business_ip) ipTypeMap.set(d.business_ip, 'storage')
if (d.hdm_ip) ipTypeMap.set(d.hdm_ip, 'storage')
}
// 2. 查询周期内活跃工单
const db = getDb()
const endFull = periodEnd + ' 23:59:59'
const ticketsRaw = db.prepare(`
SELECT * FROM tickets WHERE (
(close_time >= ? AND close_time <= ?)
OR
(assign_time <= ? AND (current_status NOT IN ('resolved','closed') OR close_time > ?))
)
ORDER BY assign_time
`).all(periodStart, endFull, endFull, endFull) as any[]
// 3. 构建工单条目
const entries: WeeklyTicketEntry[] = ticketsRaw.map(t => {
const isResolved = t.current_status === 'resolved' || t.current_status === 'closed'
let steps: TicketStepRaw[] = []
if (isResolved) {
steps = db.prepare(`
SELECT time_node, handler, description
FROM ticket_steps
WHERE ticket_id = ?
ORDER BY step_order
`).all(t.id) as TicketStepRaw[]
}
return {
id: t.id,
ticketNo: t.ticket_no || String(t.id),
deviceIp: t.device_ip || '',
deviceType: ipTypeMap.get(t.device_ip) || 'gpu',
faultCategory: t.fault_category || null,
faultSubcategory: t.fault_subcategory || null,
content: t.content,
conclusion: t.conclusion,
assignTime: t.assign_time,
closeTime: t.close_time,
partsName: t.parts_name,
ticketType: t.ticket_type || 'OEM维修',
currentStatus: t.current_status,
isResolved,
steps,
}
})
// 4. 按规则分三类
// GPU服务器故障OEM维修 + fault_category != '无故障' + device_type = 'gpu'
const gpuFaultTickets = entries.filter(t =>
t.ticketType === 'OEM维修' && t.faultCategory !== '无故障' && t.deviceType === 'gpu'
)
// 存储服务器故障
const storageFaultTickets = entries.filter(t =>
t.ticketType === 'OEM维修' && t.faultCategory !== '无故障' && t.deviceType === 'storage'
)
// 其他工单OEM诊断 + (OEM维修且fault_category='无故障')
const otherTickets = entries.filter(t =>
t.ticketType === 'OEM诊断' ||
(t.ticketType === 'OEM维修' && t.faultCategory === '无故障')
)
// 5. 每日在线统计GPU + 存储分别统计)
const dates = getDateRange(periodStart, periodEnd)
function computeDailyStats(
deviceType: 'gpu' | 'storage',
totalNodes: number
): WeeklyDailyStats[] {
const faultTickets = [...gpuFaultTickets, ...storageFaultTickets]
.filter(t => t.deviceType === deviceType)
return dates.map(date => {
const offlineTickets = faultTickets.filter(t => {
const assignDate = t.assignTime.slice(0, 10)
return assignDate <= date && (t.closeTime === null || date < t.closeTime.slice(0, 10))
})
return {
date,
online: totalNodes - offlineTickets.length,
total: totalNodes,
offline: offlineTickets.length,
faultIps: offlineTickets.map(t => t.deviceIp),
}
})
}
const gpuDailyStats = computeDailyStats('gpu', gpuDevices.length)
const storageDailyStats = computeDailyStats('storage', storageDevices.length)
// 6. 标签生成
const startDate = new Date(periodStart.replace(/-/g, '/'))
const endDate = new Date(periodEnd.replace(/-/g, '/'))
const weekLabel = `${startDate.getFullYear()}${startDate.getMonth() + 1}${startDate.getDate()}日-${endDate.getMonth() + 1}${endDate.getDate()}`
const genDate = new Date(endDate)
genDate.setDate(genDate.getDate() + 1)
const generationDate = `${genDate.getFullYear()}/${genDate.getMonth() + 1}/${genDate.getDate()}`
// 统计
const totalFaultCount = gpuFaultTickets.length + storageFaultTickets.length
const resolvedCount = [...gpuFaultTickets, ...storageFaultTickets].filter(t => t.isResolved).length
const pendingCount = totalFaultCount - resolvedCount
return {
periodStart,
periodEnd,
weekLabel,
generationDate,
gpuTotal: gpuDevices.length,
storageTotal: storageDevices.length,
gpuDailyStats,
storageDailyStats,
gpuFaultTickets,
storageFaultTickets,
otherTickets,
totalFaultCount,
resolvedCount,
pendingCount,
}
}
/** 异步生成周报 */
export async function generateWeeklyReport(reportId: number): Promise<void> {
const db = getDb()
const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(reportId) as any
if (!report) throw new Error('报告不存在')
db.prepare("UPDATE reports SET status = 'generating' WHERE id = ?").run(reportId)
try {
const data = await collectWeeklyReportData(report.period_start, report.period_end)
const buffer = await buildWeeklyReportDocx(data)
ensureReportsDir()
const fileName = `图灵IT基础设施运营周报${data.weekLabel}.docx`
const filePath = path.join(REPORTS_DIR, fileName)
fs.writeFileSync(filePath, buffer)
const metadata = JSON.stringify({
gpuCount: data.gpuTotal,
storageCount: data.storageTotal,
totalTickets: data.gpuFaultTickets.length + data.storageFaultTickets.length + data.otherTickets.length,
gpuFaultCount: data.gpuFaultTickets.length,
storageFaultCount: data.storageFaultTickets.length,
otherTicketCount: data.otherTickets.length,
})
db.prepare(
"UPDATE reports SET status = 'completed', file_path = ?, file_name = ?, metadata = ? WHERE id = ?"
).run(filePath, fileName, metadata, reportId)
} catch (e) {
const msg = e instanceof Error ? e.message : '生成失败'
console.error(`Weekly report ${reportId} generation failed:`, e)
db.prepare("UPDATE reports SET status = 'failed', error_message = ? WHERE id = ?").run(msg, reportId)
throw e
}
}

58
src/middleware.ts Normal file
View File

@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server'
import { verifyToken } from '@/lib/jwt'
function verifyApiKey(key: string): boolean {
// API Key 以环境变量形式存储,支持多个 Key逗号分隔
const allowedKeys = process.env.ALLOWED_API_KEYS || ''
if (!allowedKeys) return false
const keys = allowedKeys.split(',').map(k => k.trim())
return keys.includes(key)
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
if (pathname.startsWith('/login') || pathname === '/') return NextResponse.next()
if (pathname === '/api/auth/login') return NextResponse.next()
const authHeader = request.headers.get('authorization')
// API Key 认证Bearer ak_xxx 格式
if (authHeader?.startsWith('Bearer ak_')) {
const key = authHeader.slice(7)
if (verifyApiKey(key)) return NextResponse.next()
// 环境变量中未匹配API 路由仍放行route handler 可查询数据库二次验证)
if (pathname.startsWith('/api/')) return NextResponse.next()
}
// Cookie 认证
const token = request.cookies.get('session_issue')?.value
// 构建带 redirect 参数的登录 URL
function buildLoginRedirect() {
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_issue')
return response
}
if (pathname.startsWith('/api/')) {
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
const valid = await verifyToken(token)
if (!valid) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
return NextResponse.next()
}
if (!token) return buildLoginRedirect()
const valid = await verifyToken(token)
if (!valid) return buildLoginRedirect()
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|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
}

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

@ -0,0 +1,78 @@
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 Ticket {
id: number
device_ip: string | null
device_sn: string | null
device_name: string | null
content: string | null
assign_time: string | null
close_time: string | null
duration_minutes: number | null
availability: number | null
process_summary: string | null
conclusion: string | null
fault_category: string | null
fault_subcategory: string | null
parts_replaced: string | null
current_status: string
counted_in_sla: number
responsibility: string | null
created_by: number | null
updated_by: number | null
created_at: string
updated_at: string
}
export interface TicketStep {
id: number
ticket_id: number
step_order: number
time_node: string | null
handler: string | null
description: string | null
created_at: string
}
export interface Report {
id: number
report_type: string
period_start: string | null
period_end: string | null
format: string
file_path: string | null
file_name: string | null
status: string
error_message: string | null
created_by: number | null
created_at: string
}
export interface AuditLog {
id: number
user_id: number | null
action: string
entity_type: string
entity_id: number | null
details: string | null
ip_address: string | null
created_at: string
}

177
src/types/report.ts Normal file
View File

@ -0,0 +1,177 @@
// 报告基础类型
export type ReportType = 'weekly' | 'monthly'
export type ReportFormat = 'pdf' | 'docx'
export type ReportStatus = 'pending' | 'generating' | 'completed' | 'failed'
export interface ReportCreateInput {
report_type: ReportType
period_start: string
period_end: string
format?: ReportFormat
}
// 设备资产(来自 assets-ai 的返回)
export interface AssetDevice {
id: number
node_name: string
serial_number: string
device_type: string
business_ip: string | null
hdm_ip: string | null
manufacturer: string
device_model: string
status: string
}
// 已分类工单(本地 tickets 表 + assets 设备类型分类)
export interface ClassifiedTicket {
id: number
device_ip: string
device_name: string | null
device_type: 'gpu' | 'storage' | 'other'
fault_category: string | null
fault_subcategory: string | null
parts_replaced: string | null
parts_name: string | null
content: string | null
conclusion: string | null
assign_time: string
close_time: string
duration_minutes: number
availability: number | null
}
// 第一章:每日在线节点统计
export interface DailyOnlineStats {
date: string // "2025-08-01"
gpuOnline: number
gpuTotal: number
storageOnline: number
storageTotal: number
}
// 第二章:按日+设备类型分组的故障描述
export interface Chapter2FaultItem {
ip: string
fault_subcategory: string
recoveryDays: number // 0=当日, 1=次日, ≥2=N日后
}
export interface Chapter2Entry {
device_type: 'gpu' | 'storage'
date: string // 故障日期 "YYYY-MM-DD"
faults: Chapter2FaultItem[]
}
// 第三章GPU/存储故障表行7列
export interface Chapter3FaultEntry {
ticketId: number
nodeIp: string
faultDate: string
faultProblem: string // fault_subcategory
faultCause: string // "更换" + parts_replaced 或 "-"
durationMinutes: number
countedInSla: '是' | '否'
}
// 第三章其他工单表行7列表头不同
export interface Chapter3OtherEntry {
ticketId: number
deviceIp: string
ticketDate: string
ticketContent: string // content
ticketConclusion: string // conclusion
durationMinutes: number
countedInSla: '是' | '否'
}
// 第四章每IP服务可用性
export interface Chapter4Entry {
ip: string
totalDurationMinutes: number
monthDays: number
availabilityPercent: number // e.g. 97.28
}
// 月报顶层聚合数据
export interface MonthlyReportData {
periodStart: string
periodEnd: string
monthDays: number
monthLabel: string // "2025年8月"
generationLabel: string // "2025年9月"
gpuTotal: number
storageTotal: number
tickets: ClassifiedTicket[]
dailyStats: DailyOnlineStats[]
chapter2: Chapter2Entry[] // 仅含 gpu/storage 工单
chapter3: {
gpuFaults: Chapter3FaultEntry[]
storageFaults: Chapter3FaultEntry[]
otherTickets: Chapter3OtherEntry[]
}
chapter4: Chapter4Entry[]
}
// 详情页预览数据
export interface MonthlyReportPreview {
gpuCount: number
storageCount: number
totalTickets: number
resolvedTickets: number
gpuFaultCount: number
storageFaultCount: number
otherTicketCount: number
avgAvailability: number | null
}
// ===== 周报类型 =====
export interface WeeklyDailyStats {
date: string // "2025-12-01"
online: number
total: number
offline: number
faultIps: string[] // 当天故障设备 IP 列表
}
export interface TicketStepRaw {
time_node: string
handler: string
description: string
}
export interface WeeklyTicketEntry {
id: number
ticketNo: string
deviceIp: string
deviceType: 'gpu' | 'storage'
faultCategory: string | null
faultSubcategory: string | null
content: string | null
conclusion: string | null
assignTime: string
closeTime: string | null
partsName: string | null
ticketType: string // 'OEM诊断' | 'OEM维修'
currentStatus: string // 'resolved' | 'closed' | 'in_progress' | 'open'
isResolved: boolean
steps: TicketStepRaw[]
}
export interface WeeklyReportData {
periodStart: string
periodEnd: string
weekLabel: string // "12月1日-12月7日"
generationDate: string // "2025/12/8"
gpuTotal: number
storageTotal: number
gpuDailyStats: WeeklyDailyStats[]
storageDailyStats: WeeklyDailyStats[]
gpuFaultTickets: WeeklyTicketEntry[]
storageFaultTickets: WeeklyTicketEntry[]
otherTickets: WeeklyTicketEntry[]
totalFaultCount: number
resolvedCount: number
pendingCount: number
}

24
src/types/ticket.ts Normal file
View File

@ -0,0 +1,24 @@
export type TicketStatus = 'open' | 'in_progress' | 'resolved' | 'closed'
export interface TicketCreateInput {
ticket_no?: string
device_ip?: string
device_sn?: string
device_name?: string
content?: string
assign_time?: string
fault_category?: string
fault_subcategory?: string
responsibility?: string
current_status?: string
counted_in_sla?: number
}
export interface TicketUpdateInput extends Partial<TicketCreateInput> {
close_time?: string
duration_minutes?: number
availability?: number
process_summary?: string
conclusion?: string
parts_replaced?: string
}

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: [],
}

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"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"]
}