chore: 初始化仓库 — 工单跟踪系统
This commit is contained in:
parent
a9bad37849
commit
6a6d0f309d
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
# 变更日志
|
||||
|
||||
## 2026-05-05
|
||||
|
||||
- [修复] 云服务器月报生成失败:重建 Docker 镜像安装 echarts,Dockerfile 补全 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 仅操作待办;删除仅限创建人+admin;API 层 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 个 volume(data、.next、package.json、tsconfig)
|
||||
|
||||
## 2026-04-25
|
||||
|
||||
- [新增] 工单核心 API(CRUD + 分页 + 筛选 + 搜索)
|
||||
- [新增] 工单列表页面(`/tickets`)
|
||||
- [新增] 新建/编辑/详情页面
|
||||
- [新增] 月度统计 API(`/api/stats/monthly`)
|
||||
- [新增] SLA 达标率统计(`/api/stats/sla`)
|
||||
- [新增] 报告生成与导出(Word / PDF / Excel 总表)
|
||||
- [新增] 调用 assets.tlyq.ai API 获取设备信息,工单详情页展示关联设备卡片
|
||||
- [调整] PLAN v5:SLA 指标改为「服务可用性」计算,故障分类规则(含 SQL 批量更新语句)
|
||||
- [新增] assets-client.ts,支持通过 device_ip 模糊匹配 business_ip / hdm_ip 获取设备信息
|
||||
|
||||
## 2026-04-24
|
||||
|
||||
- [新增] 项目初始化,基于 Next.js 15.1 + SQLite(standalone 输出模式)
|
||||
- [新增] 认证系统(JWT cookie 方式),登录/登出/当前用户 API
|
||||
- [新增] 用户管理(`/settings/users`)和角色权限系统(admin/operator/viewer)
|
||||
- [新增] 数据库初始化脚本(init-db.ts),预置角色和默认管理员账号
|
||||
- [新增] Docker 部署配置(两阶段构建 alpine + debian slim)
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
# CLAUDE.md — issue.tlyq.ai 工单跟踪系统
|
||||
|
||||
## 项目概述
|
||||
|
||||
issue-ai 是基于 Next.js + SQLite 的工单跟踪管理系统,部署在腾讯云(txjp 服务器),域名 `issue.tlyq.ai`。与 assets.tlyq.ai 资产管理系统联动,获取设备信息、提供故障历史查询。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 站点域名 | `issue.tlyq.ai` |
|
||||
| 服务器 | txjp(IP: 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
|
||||
└── webnet(external) ← 共享网络
|
||||
```
|
||||
|
||||
部署:`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"
|
||||
```
|
||||
|
|
@ -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 阶段:使用 Debian(glibc)以支持 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
238
README.md
|
|
@ -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 |
|
||||
| 数据库 | SQLite(better-sqlite3,WAL 模式) |
|
||||
| 认证 | JWT(cookie 方式)+ 自定义 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_type(gpu / 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 比例换算 DXA(A4 内容宽度 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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
|
@ -0,0 +1,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
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
const config = { plugins: { '@tailwindcss/postcss': {} } }
|
||||
export default config
|
||||
|
|
@ -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();
|
||||
|
|
@ -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-88(29.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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import { initDatabase } from '../src/lib/db-schema'
|
||||
initDatabase()
|
||||
console.log('数据库初始化完成')
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import TicketImport from '@/components/tickets/TicketImport'
|
||||
|
||||
export default function ImportTicketsPage() {
|
||||
return <TicketImport />
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function NewTicketPage() {
|
||||
redirect('/tickets/create')
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function TicketsPage() {
|
||||
redirect('/tickets/pending')
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }) }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">表头列:工单号、业务IP、设备序列号、节点名称、工单内容、派单时间、故障大类、故障小类、责任方、当前状态、SLA计数</p>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">×</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">×</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"><|</button>
|
||||
<button onClick={() => onPageChange(page - 1)} disabled={page <= 1} title="上一页" className="p-1.5 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed"><</button>
|
||||
{pages.map((p, i) => p === '...'
|
||||
? <span key={`d-${i}`} className="px-1.5 text-slate-400 text-sm">…</span>
|
||||
: <button key={p} onClick={() => onPageChange(p)} className={`min-w-[36px] px-2 py-1.5 rounded-lg text-sm font-medium transition-colors ${p === page ? 'bg-blue-600 text-white' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800'}`}>{p}</button>
|
||||
)}
|
||||
<button onClick={() => onPageChange(page + 1)} disabled={page >= totalPages} title="下一页" className="p-1.5 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed">></button>
|
||||
<button onClick={() => onPageChange(totalPages)} disabled={page >= totalPages} title="末页" className="p-1.5 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed">|></button>
|
||||
|
||||
{/* 跳转输入框 */}
|
||||
<div className="flex items-center gap-1.5 ml-2 border-l border-slate-200 dark:border-slate-700 pl-3">
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">跳转到</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={totalPages}
|
||||
value={jumpValue}
|
||||
onChange={e => setJumpValue(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleJump() }}
|
||||
className="w-14 px-2 py-1.5 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm text-slate-900 dark:text-white text-center focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder={`1-${totalPages}`}
|
||||
/>
|
||||
<button
|
||||
onClick={handleJump}
|
||||
disabled={!jumpValue || parseInt(jumpValue) < 1 || parseInt(jumpValue) > totalPages}
|
||||
className="px-2 py-1.5 rounded-lg text-xs font-medium bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
跳转
|
||||
</button>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">/ 共{totalPages}页</span>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
|
||||
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">×</button></div></div>)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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' }))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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('权限不足')
|
||||
}
|
||||
|
|
@ -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` }
|
||||
}
|
||||
|
|
@ -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 比例换算 DXA(A4 内容宽度 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:工单内容 | value(3列合并)
|
||||
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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)$).*)'],
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: 'class',
|
||||
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Reference in New Issue