Compare commits

...

54 Commits
v0.1.0 ... main

Author SHA1 Message Date
gitadmin 46b220d6ca feat: #21 Phase 5 — Tailwind v4 语法迁移 (globals.css + 删除 tailwind.config.js) 2026-07-15 16:05:51 +08:00
gitadmin 7346031fc4 feat: #21 Phase 1-4 — CSS导入+OKLCh+登录页统一+Sidebar品牌/权限+docker安全 2026-07-15 16:03:34 +08:00
gitadmin fc8dd6f0c8 fix: #26 logout 修复 + auth/ldap 认证对齐 2026-07-15 16:00:23 +08:00
gitadmin e20189d5f8 docs: 更新 CLAUDE.md/CHANGELOG — 记录7/13统一结单推送+wechat独立页+useDirtyTracker 2026-07-13 17:20:42 +08:00
gitadmin 459e19cbb8 refactor: wechat页per-webhook独立保存 + monitor页用useDirtyTracker统一section保存
- wechat页: 每推送组底部独立保存按钮(仅修改后显示),移除全局保存按钮
- monitor页: 用 useDirtyTracker 替代 originalConfig+hasSectionChanged 模式
2026-07-13 16:41:07 +08:00
gitadmin 991b7b85c3 fix: webhook测试masked URL导致93000 invalid url
- settings-manager: getMaskedConfig 用 .map() 替代直接 mutate wh.url
- test-wechat API: 检测 mask URL(含••••••••)时从DB查真实URL再发送
2026-07-13 16:30:51 +08:00
gitadmin ada95e4e7f fix: 补全4处审计日志缺失 + audit_logs.username列迁移
- api-keys/[id] PUT: 添加 diffObjects 变更审计
- reports/[id] DELETE: 添加删除报告快照审计
- auth/logout POST: 从 session cookie 提取 username 记录登出审计
- reports/download POST: 添加 export 审计
- db-schema: 添加 audit_logs.username/api_key_id 列迁移(修复审计日志静默失败)
2026-07-13 16:10:20 +08:00
gitadmin b5e9cfbcd2 fix: 添加 audit_logs.username 列迁移,修复审计日志静默写入失败
writeAuditLog 共享库写入 username 列,但 issue-ai 数据库未添加此列,
导致所有审计日志 INSERT 失败(7月2日至今无新记录)。
服务器已执行 ALTER TABLE 修复,代码加迁移防未来部署遗漏。
2026-07-13 16:03:57 +08:00
gitadmin 629924c342 feat: 统一结单推送 + close_time 口径 + 微信推送独立页
- 结单推送从 worker 轮询上移到 API 层(PUT [id]/batch 状态跳变触发)
- 新增 notifyTicketResolved(结单)与 notifyTicketCreated(建单)并列构成完整通知层
- 移除 worker.checkRecoveryUpdates,副作用迁移至 resolution-side-effects
- 月度统计改为 close_time 归组,修复月报第四章跨月重复计算
- 微信推送独立为 /settings/wechat 页面,前端与邮件监控完全解耦
- 方案: docs/superpowers/specs/2026-07-13-unified-resolution-notification-design.md
- 计划: docs/superpowers/plans/2026-07-13-unified-resolution-notification-plan.md
2026-07-13 15:46:41 +08:00
gitadmin 7ea37afc75 feat: 删除故障分类冗余选项 + 报告口径统一 + SSRF 非规范IP加固
- 故障大类删除'其他'、故障小类删除'无故障'(TicketForm/ProcessForm/TicketList)
- 周报按'真实故障+设备类型'分章节(去 ticketType 判断,新增 isRealFault 排除 无故障/误判/空)
- 月报口径与周报对齐,统一排除'误判'
- 监控设置 SSRF 加固:拒绝整数/十六进制/八进制/短式等非规范数字型 IP,防绕过内网黑名单
2026-07-13 09:56:03 +08:00
gitadmin f7bc9fd5fb fix: SSRF 加固增强 — 拒绝内网/回环/链路本地 IPv4 邮箱服务器地址
响应 push 安全审查:settings VALIDATORS 的 isValidHost 增加内网 IPv4 字面量
检测,拒绝 0/8、10/8、127/8(回环)、169.254/16(链路本地/云元数据)、
172.16/12、192.168/16、100.64/10(CGNAT),防止 imap/pop3/smtp 服务器地址
被指向内网主机(SSRF 纵深防御)。

未做 DNS 解析 + rebinding 防御:host 需 monitor:write 权限修改、且为 POP3/TLS
协议连接(非 HTTP),对 admin-only 邮箱配置属过度设计。IPv6 字面量含 ':'
已被字符集正则天然拒绝。
2026-07-13 09:17:01 +08:00
gitadmin 28c37f1069 feat: 邮件监控 4 项改进 + POP3 地址可配置 + SSRF 加固
改进内容:
1. POP3 备用服务器/端口从硬编码改为可配置(types + settings-manager +
   fetchPOP3Emails 参数化 host/port + 两处调用点传配置值 + 设置页字段),
   默认 pophz.qiye.163.com:995 保持向后兼容
2. 扫描历史固定保留最近 50 条(saveScanHistory 按 id 排序自动裁剪)
3. 补齐审计缺口:test-mail、scan-history DELETE、test-wechat 失败/异常分支
4. 运行状态"错误"从历史累计(10000+)改为反映最近一次 tick 状态

安全加固:
- settings VALIDATORS 增加 isValidHost 主机名格式校验(imap/pop3/smtp 三处),
  拒绝 URL/端口路径/空格等载荷,收窄 SSRF 面(纵深防御)
- 审计 entityType 命名统一(monitor_mail / monitor_wechat)

经 6 Task + 独立审查(零 CRITICAL/HIGH/MEDIUM),2 处逻辑经内存 sqlite 单测验证。
自动安全审查:SSRF 已加固,scan_history DELETE IDOR 为误报(无用户输入)。
2026-07-11 19:10:41 +08:00
gitadmin 5152d7d4f2 feat: 全路径建单微信推送 + IMAP BODY 空 POP3 fallback
问题背景:
1. 网易企业邮箱 IMAP 对部分邮件返回空 BODY,worker 与手动扫描均无法获取正文
2. 6 处建单入口中仅 worker 有推送(且因取不到邮件而瘫痪),导入工单后无微信通知

改动:
- 新增 pop3-fetcher.ts:共享 POP3 获取模块(Message-ID 精确匹配,
  含 CRLF 防护、TLS 证书校验、50MB 上限、dot-stuffing、超时等加固)
- 新增 ticket-notifier.ts:共享推送模块,fire-and-forget 不阻塞建单,
  动态读 webhook、字段截断、assign_time 护栏、限频去重、5s 超时兜底
- mail-monitor.ts:worker fetchUnread 加 POP3 fallback,恢复自动建单
- worker.ts:检查 createTicketFromEmail 返回值,防与 notifier 双推
- 6 处建单入口接入推送:手动建单/外部API/手动扫描/手动扫描导入(单条)、
  Excel 批量(事务后汇总单条)、worker(保持内联推送)
- scan-emails 内联 POP3 重构为引用共享模块

经三轮多独立审查员审查(设计+实现+集成)+ LOW 修复复审,全部零阻塞。
2026-07-11 17:31:56 +08:00
gitadmin c2715d78d3 fix: 使用 body: '' 获取邮件内容
- 使用 body: '' 替代 rfc822: true,尝试获取完整邮件内容
- 添加多种获取方式的回退逻辑
2026-07-10 13:52:31 +08:00
gitadmin f70675412f fix: 使用 rfc822 获取邮件内容
- 使用 rfc822: true 替代 body: '.',兼容性更好
- rfc822 返回原始邮件内容,适用于所有 IMAP 服务器
2026-07-10 13:42:30 +08:00
gitadmin 5430f51885 fix: 修复 IMAP 邮件内容获取问题
- 使用 body: '.' 获取邮件完整内容,替代 source(某些 IMAP 服务器不支持)
- 添加详细的 fetch 结果日志,记录返回的字段名
- 改进邮件获取失败时的日志输出
2026-07-10 13:33:02 +08:00
gitadmin c11e9fbc64 feat: 邮件扫描导入功能增强
- 新增 scan-emails/import API 端点,支持手动导入跳过的邮件为工单
- 前端添加'导入跳过的邮件为工单'按钮,扫描完成后可选择导入
- 添加扫描详情日志,记录无内容邮件的跳过原因
- 修复 scan_history 表 details_json 为空的问题
2026-07-10 13:22:55 +08:00
gitadmin 75ca749db7 fix: 根本修复 getClientIP tree-shaking 问题
- audit.ts: 直接定义 getClientIP 函数,而非从 shared 库 re-export
- 原因: Next.js standalone 构建的 tree-shaking 会丢弃 re-export 的函数
- 影响: 修复所有使用 getClientIP 的 API 路由(scan-emails、scan-status、auth/login)
2026-07-10 13:09:47 +08:00
gitadmin 73b442717e fix: 修复 getClientIP 导入导致的树摇问题
- scan-emails/route.ts: 直接定义 getClientIP 函数,避免从 @/lib/audit 导入
- scan-status/route.ts: 同上
- 原因: Next.js tree-shaking 在打包时未将 getClientIP 包含在模块导出中
- scan-history API: 新增扫描历史查询接口
- db-schema: 新增 scan_history 表
2026-07-10 12:59:21 +08:00
gitadmin b6f0393450 feat: 邮件扫描功能增强与 Bug 修复
主要更新:
- 新增邮箱工单检测模块,支持扫描邮箱并自动导入工单
- 新增扫描取消机制,支持用户中途取消扫描任务
- 新增扫描历史持久化,保存到 scan_history 表
- 修复 IMAP 连接管理问题,连接失败时正确重置 client
- 修复邮件搜索范围,从今天改为最近 7 天

Bug 修复:
- B-1: 修复 getClientIP 传入 null 问题,正确获取客户端 IP
- B-2: 修复 toISOString() 违反规范,改用 formatBeijingTime
- B-3: 修复 refreshStatus/refreshErrors 缺少 .catch() 处理
- E-1: 添加 detailsTruncated 字段,超过 500 条时显示截断警告
- E-2: 添加 5 分钟整体超时控制,避免扫描长时间挂起
- E-3: 优化 refreshErrors 路径依赖,使用安全的属性合并
- E-4: scanning 状态从 scanState.status 派生,保持状态一致
- 修复超时状态被覆盖为 completed 的 Bug

新增文件:
- src/lib/monitor/scan-state.ts: 共享扫描状态管理
- src/app/api/monitor/scan-emails/route.ts: 邮件扫描 API
- src/app/api/monitor/scan-status/route.ts: 扫描状态查询 API
- src/app/api/monitor/scan-history/route.ts: 扫描历史查询 API
2026-07-10 11:49:16 +08:00
gitadmin d64c950894 fix: middleware publicPaths /api/internal + Dockerfile sqlite3 2026-07-08 10:35:59 +08:00
gitadmin c179e47c6b fix: logout redirect uses OIDC_REDIRECT_URI instead of request headers (open redirect fix) 2026-07-07 17:24:49 +08:00
gitadmin c50c06f095 fix: logout 302 redirect instead of JSON, use x-forwarded-host 2026-07-07 17:18:19 +08:00
gitadmin 248ccaeb66 chore: .env.example 统一为本地开发配置(JWT_SECRET/AUTHELIA_URL/COOKIE_DOMAIN) 2026-07-03 14:01:38 +08:00
gitadmin 36aee38160 feat: SSO切换账号按钮 + 生产配置模板 2026-07-03 12:12:11 +08:00
gitadmin 6bba264927 fix: 移除 middleware adminPaths,解决非 admin 角色 Forbidden 问题
- 移除 middleware 中的 adminPaths: ['/settings'] 配置
- settings 页面权限由各 API 端点自行检查,middleware 仅负责认证
- 修复 viewer/operator 等非 admin 角色访问 /settings/* 返回 403 的问题
- 侧边栏 hasAnyAdminPerm 增加 audit-logs 和 monitor 权限检查
2026-07-02 18:02:12 +08:00
gitadmin f507adfaa1 feat: 权限细化 tickets:edit/tickets:delete + 多 Webhook 推送组
- tickets:write 拆分为 tickets:edit(处理工单)和 tickets:delete(删除工单)
- 内置 operator 角色默认同时拥有两个权限
- 迁移脚本自动将已有角色的 tickets:write 替换为两个新权限
- 微信推送支持多个 webhook 推送组,每个可独立启用/禁用
- 每个推送组包含独立的运行参数(检查间隔、静默期)
- 旧格式 wechat.webhook_url 自动迁移为新数组格式
- worker 推送逻辑适配多 webhook(遍历所有启用的推送组)
2026-07-02 17:39:36 +08:00
gitadmin 38129bdb67 docs: 更新 CHANGELOG — 企业微信消息格式对齐 + 时区修复 2026-07-02 17:12:10 +08:00
gitadmin 02918fa907 feat: 企业微信推送消息格式全面对齐 zabbix-01 脚本
- P1-P5: 结单消息按 fault_type 分支,补齐机架位置/故障类型/故障时间/月度统计
- P6: OEM 维修档位标题补全「基于月度内累计故障时长计算」
- P7: OEM 诊断字段顺序调整(到期时间在故障信息之前)
- P8: 每小时提醒显示「X小时Y分」
- P9/P10: 静默期积压消息格式对齐(动态剩余时间、机架位置、30min 区分)
- 修复: isInSilentPeriod/isTimeToFlush 时区问题(UTC+8 偏移)
- 修复: 先推送后更新 DB,防止推送失败通知丢失
- 修复: faultDetail 截断 500 字符防止超限
- 修复: tierPenaltyMap 提取为类级常量
2026-07-02 16:45:43 +08:00
gitadmin d39d2e65b3 refactor: P1 共享库迁移
- audit.ts: 引用 shared/lib/audit(包装器保持原接口)
- jwt-shared.ts: 引用 shared/lib/auth/jwt(包装器保持 signSharedJwt/verifySharedJwt)
- wechat-pusher.ts: pushText 改用 shared WeChatPusher,保留 formatAvailabilityMessage
- middleware.ts: 使用 shared/lib/auth/middleware 工厂
- tsconfig: 添加 @shared/* 路径
- 添加 shared symlink

独立审查通过
2026-07-01 18:42:49 +08:00
gitadmin 091ef426db feat: SSO 统一认证登录
- 集成 Authelia OIDC,支持统一认证登录
- 添加 OIDC 登录页面和回调处理
- 更新 docker-compose.yml 统一环境变量管理
- 更新 CHANGELOG.md 和 CLAUDE.md
2026-06-30 17:09:24 +08:00
gitadmin 565f891e1e fix: logout 端点添加 domain 参数清除跨域 cookie
- logout 端点清除 cookie 时添加 domain 参数
- 修复 .tlyq.ai 域的 tlyq_session cookie 未正确清除的问题
2026-06-29 20:05:19 +08:00
gitadmin d8c65d5121 feat: SSO 统一认证 + ThemeProvider 三模式 + Card 边框统一
- 安装 openid-client v5,新增 OIDC 登录/callback 端点
- ThemeProvider 支持 light/dark/auto 三种模式
- TopBar 主题切换改为下拉菜单
- Card 暗色边框统一为 slate-800
- middleware 登录页已登录自动跳转 + /api/auth/login 放行
- 登录页添加 SSO 按钮 + LDAP 回退
2026-06-29 18:39:46 +08:00
gitadmin 24581166bc fix(monitor): OEM维修超时提醒消息格式优化
- 超时提醒格式:第X档已超时,达到第Y档扣XX%
- 提前1小时提醒格式:距离第X档(扣XX%)还剩1小时
- 第四档超时提示:已触发全额扣费(100%)
- 第四档还剩1小时提示:距离全额扣费(100%)还剩1小时
- 静默期积压消息:动态计算剩余时间并显示扣费比例
- 消息内容与空行分隔
2026-06-25 11:49:10 +08:00
gitadmin 3fd09dec25 fix(monitor): OEM维修第五档提醒消息区分扣费比例
- 第四档: 图标 + '距离第四档(扣50%)超时还剩1小时'
- 第五档:🚨 图标 + '距离第五档(扣100%)超时还剩1小时'
- 第五档额外提示:'即将进入全额扣费,请立即处理'
2026-06-25 11:12:45 +08:00
gitadmin b7b2b34a1f feat: 审计日志 + 邮件监控 + 修复删除工单 bug 2026-06-24 15:43:18 +08:00
gitadmin 1c1555e417 feat: 添加审计日志功能
- 新增 audit.ts 核心函数(writeAuditLog、diffObjects、getClientIP)
- 新增审计日志 API(分页查询、CSV 导出)
- 新增审计日志前端页面
- 更新 Sidebar 添加审计日志菜单项
- 添加审计权限配置
- 在所有写操作 API 中集成审计日志
- 更新 CLAUDE.md 添加审计日志开发规范
2026-06-23 10:59:24 +08:00
gitadmin 5f0c312e9c feat: 用户管理 UI 全面统一 + LDAP 邮箱同步 + 角色/Key 页面对齐
- 新增用户详情页,支持编辑(状态切换/密码确认)、键盘导航
- Modal/Button 拆分为独立文件,样式与 assets-ai 对齐
- LDAP 邮箱三路径同步:密码登录、SSO 免登录、批量同步接口
- 角色权限页:Badge 按角色分色、增加保存 loading、checkbox dark 样式
- API Key 页:成功提示颜色对齐、取消按钮 ghost 统一
- 全站字体/颜色一致:text-sm、dark mode 补全
- 删除确认统一改用 Modal 替换原生 confirm
2026-05-18 16:57:30 +08:00
gitadmin 703ed1a8d4 docs: 添加 Git Tag 日期版本号规范 2026-05-18 14:58:38 +08:00
gitadmin b40802efbf fix: SSO 免登录和本地 JWT 路径现在也会更新 last_login_at
此前仅密码登录路径更新 last_login_at,导致通过 SSO 共享 JWT
或本地 JWT 访问系统的用户始终显示"从未登录"。
2026-05-18 14:49:16 +08:00
gitadmin 726c8bee27 docs: 记录 middleware Edge Runtime 修复与连通性检查重试优化 2026-05-15 15:21:45 +08:00
gitadmin 651971e0fe fix: middleware 移除 better-sqlite3 依赖,修复 Edge Runtime 报错
middleware 运行在 Edge Runtime,不支持 Node.js 原生模块。
API Key 验证回归 ALLOWED_API_KEYS 环境变量检查,DB 验证由 route handler 负责。
2026-05-15 09:26:20 +08:00
gitadmin dce8b0b6cc docs: 记录 API Key 修复与中间件安全加固(v2026.05.14)
- [修复] IP 查询失败:assets-ai api_keys 表注册缺失的 key
- [优化] 中间件 API Key 验证统一为 ALLOWED_API_KEYS → DB 两级
- [新增] deploy-ai.sh 部署后自动验证 issue→assets 连通性
2026-05-15 09:13:46 +08:00
gitadmin 2697aaaa75 fix: 内置角色可编辑不可删除,修复种子迁移覆盖用户权限
- admin/operator/viewer 均为内置不可删除,但可编辑
- 移除 initDatabase() 中的权限合并迁移逻辑,避免每次启动覆盖用户自定义权限
- admin 在 API 层禁止删除
2026-05-14 17:29:55 +08:00
gitadmin 2d74f0a05b feat: 前端按钮基于 permissions 显隐 — 导出/新建报告/下载/删除
三处修改:
- TicketList 导出按钮基于 tickets:export 权限显隐
- 报告列表页新建/批量下载/批量删除按钮及各状态行内操作基于 reports:create/reports:download 显隐
- 报告详情页生成文档/下载报告/重新生成按钮基于 permissions 显隐
2026-05-14 17:05:14 +08:00
gitadmin 152241e666 feat: 前端侧边栏和全部工单页面改为基于permissions数组控制显隐
- Sidebar: isAdmin boolean → permissions string[],所有导航项按 perm 属性过滤
- 侧边栏 navItems 新增 perm 字段,canSee() 检查 tickets:read/tickets:create/tickets:import/reports:read
- "全部工单"仅 permissions.includes('*') 可见,"系统设置"区由 hasAnyAdminPerm() 控制
- 设置子项按各自 perm 过滤(users:read/roles:read/api-keys:read)
- /tickets/all 页面权限检查同步改为 permissions.includes('*')
2026-05-14 17:01:22 +08:00
gitadmin 48f8084b9b feat: 报告API三层权限检查 — read/download/create 全覆盖
- GET /api/reports — 新增 reports:read 权限检查
- POST /api/reports — 修复为 reports:create(原错误使用 reports:read)
- DELETE /api/reports — 新增 reports:create 权限检查
- GET /api/reports/[id] — 新增 reports:read 权限检查
- DELETE /api/reports/[id] — 新增 reports:create 权限检查
- POST /api/reports/[id]/generate — 从 reports:write 改为 reports:create
- GET /api/reports/[id]/download — 新增 reports:download 权限检查
- POST /api/reports/download — 新增 reports:download 权限检查

5 个文件,共 8 个改动点
2026-05-14 16:57:29 +08:00
gitadmin 200fd7d3a1 feat: 工单API细粒度权限 — create/import/export 独立检查
- POST /api/tickets 手动建单改用 tickets:create
- POST /api/tickets/import 导入改用 tickets:import
- GET /api/tickets/export 新增 tickets:export 权限检查(此前仅有登录检查,是一个安全漏洞)
- PUT/DELETE /api/tickets/[id] 和 PUT /api/tickets/batch 保持 tickets:write 不变
2026-05-14 16:54:34 +08:00
gitadmin a52241f4db feat: 角色管理UI扩展权限项至14个,种子数据自动迁移operator/viewer权限 2026-05-14 16:51:11 +08:00
gitadmin 1ae84294bb feat: UserPayload 增加 permissions 字段,getCurrentUser 补全权限列表
- UserPayload 新增 permissions: string[] 字段
- getCurrentUser() 三个返回路径(共享JWT查DB/SSO自动创建/本地JWT)
  均通过 getUserPermissions() 从 roles 表实时查询补全权限
- 新增 getUserPermissions(role) 工具函数,admin 返回 ['*'],
  其他角色从 roles 表解析 permissions JSON
- login() 函数和 login API 路由同步补全 permissions
- verifyToken() 返回对象补全 permissions 空值防止类型错误
2026-05-14 16:47:35 +08:00
gitadmin 4b6bee1868 feat: SSO 集成 — 共享 JWT + LDAP 认证 + 跨站点用户管理 API
- 新增 src/lib/jwt-shared.ts:共享 JWT 签发/验证(与 OA 共用密钥)
- 新增 src/lib/ldap.ts:LDAP 认证与用户存在性检查
- 新增 src/app/api/internal/roles/route.ts:内部 API 供 OA 查询角色
- 重构 auth.ts:SSO 共享 JWT 验证
- 重构 middleware.ts:SSO 优先 + 本地认证回退
- 更新 docker-compose.yml:挂载 docker.sock 用于运行时 LLDAP 密码获取
- 更新 next.config.ts:serverExternalPackages 添加 ldapts
- 更新 Dockerfile:生产依赖安装优化
2026-05-14 16:37:56 +08:00
gitadmin 01a717e8b2 feat: SSO双路径认证 + 端口修正 5176→6176
- 中间件支持 X-Remote-User (SSO) + JWT 双路径
- /api/auth/me 签发 JWT,支持 SSO header 回退
- 退出登录同时清除 SSO cookie
- 侧边栏系统设置仅 admin 可见
- package.json 端口 5176→6176
- 跨站点引用端口全部修正
2026-05-09 17:14:53 +08:00
gitadmin f578198cf9 feat: 添加 .dockerignore 减小 Docker 构建上下文
排除 node_modules、.next、data 等目录,解决 Build Cache 膨胀问题。
2026-05-08 12:55:44 +08:00
gitadmin f692546281 feat: 周报预览运营数据增加无故障天数
参照月报逻辑,在 buildWeeklyMetadata 中计算无故障天数,
排除"其他"子分类工单,按故障影响日期范围统计。
2026-05-08 09:28:39 +08:00
130 changed files with 11830 additions and 405 deletions

34
.dockerignore Normal file
View File

@ -0,0 +1,34 @@
# 依赖 —— Dockerfile 内用 npm ci 安装
node_modules
# 构建产物 —— Dockerfile 内用 npm run build 生成
.next
# 运行时数据 —— volume 挂载
data
uploads
reports
db-backups
# 开发工具
.claude
.playwright-mcp
.superpowers
.git
.gitignore
Dockerfile
docker-compose.yml
Caddyfile
.env
.env.*
.DS_Store
# 文档
*.md
*.png
docs
CHANGELOG.md
README.md
# 构建缓存
tsconfig.tsbuildinfo

View File

@ -1,12 +1,21 @@
# issue-ai 环境变量(本地开发)
DATABASE_PATH=./data/issue.db DATABASE_PATH=./data/issue.db
JWT_SECRET=your-secret-key-change-in-production JWT_SECRET=dev-jwt-secret-local
ADMIN_PASSWORD=admin123 COOKIE_DOMAIN=
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 NODE_ENV=development
NODE_TLS_REJECT_UNAUTHORIZED=0
# LDAP 配置
LDAP_URL=ldap://localhost:3890
LDAP_BASE_DN=dc=tlyq,dc=ai
LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
# OIDC 配置
AUTHELIA_URL=http://127.0.0.1:6180
OIDC_CLIENT_ID=issue-oidc
OIDC_CLIENT_SECRET=<见 Authelia 配置>
OIDC_REDIRECT_URI=http://127.0.0.1:6176/api/auth/callback
# 跨服务调用
ASSETS_API_URL=http://localhost:6177/api
NEXT_PUBLIC_ASSETS_URL=http://localhost:6177

4
.gitignore vendored
View File

@ -11,8 +11,10 @@ build/
.claude/ .claude/
data/ data/
uploads/ uploads/
reports/ /reports/
docs/ docs/
db-backups/ db-backups/
.playwright-mcp/ .playwright-mcp/
*.tsbuildinfo *.tsbuildinfo
.env.local
.DS_Store

View File

@ -0,0 +1,192 @@
<h2>报告预览页</h2>
<p class="subtitle">从列表点击报告名称或"预览"按钮进入。右上角按钮根据状态动态切换。</p>
<div class="mockup">
<div class="mockup-header">预览页 — 状态:数据已就绪(待生成文档)</div>
<div class="mockup-body">
<!-- Header -->
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 0;border-bottom:1px solid #e2e8f0;margin-bottom:20px">
<div style="display:flex;align-items:center;gap:16px">
<span style="color:#94a3b8;font-size:18px;cursor:pointer"></span>
<div>
<h2 style="font-size:22px;font-weight:700;color:#0f172a;margin:0">月报</h2>
<span style="font-size:13px;color:#64748b">2026-04-01 ~ 2026-04-30</span>
</div>
<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:500;background:#fef3c7;color:#92400e">
<span style="width:6px;height:6px;border-radius:50%;background:#f59e0b"></span>
数据已就绪
</span>
</div>
<button class="mock-button" style="background:#059669;color:#fff">生成报告文档</button>
</div>
<!-- KPI Row -->
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-bottom:20px">
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
<div style="font-size:12px;color:#64748b;margin-bottom:4px">工单总数</div>
<div style="font-size:28px;font-weight:700;color:#0f172a">12</div>
</div>
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
<div style="font-size:12px;color:#64748b;margin-bottom:4px">已解决</div>
<div style="font-size:28px;font-weight:700;color:#059669">10</div>
<div style="font-size:11px;color:#94a3b8">83.3%</div>
</div>
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
<div style="font-size:12px;color:#64748b;margin-bottom:4px">整体可用性</div>
<div style="font-size:28px;font-weight:700;color:#f59e0b">98.52%</div>
<div style="font-size:11px;color:#94a3b8">低于99%</div>
</div>
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
<div style="font-size:12px;color:#64748b;margin-bottom:4px">平均处理时长</div>
<div style="font-size:28px;font-weight:700;color:#0f172a">240</div>
<div style="font-size:11px;color:#94a3b8">分钟</div>
</div>
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
<div style="font-size:12px;color:#64748b;margin-bottom:4px">进行中</div>
<div style="font-size:28px;font-weight:700;color:#0369a1">2</div>
</div>
</div>
<!-- Ch1 -->
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden;margin-bottom:12px">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
<div style="display:flex;align-items:center;gap:10px">
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#3b82f6;color:#fff;border-radius:6px;font-size:12px;font-weight:700">1</span>
<span style="font-size:15px;font-weight:600;color:#1e293b">设备概况</span>
</div>
</div>
<div style="display:flex;gap:32px;padding:16px 20px">
<div style="display:flex;align-items:center;gap:12px">
<div style="width:44px;height:44px;border-radius:10px;background:#dbeafe;display:flex;align-items:center;justify-content:center;font-size:20px">🖥</div>
<div><span style="font-size:24px;font-weight:700;color:#0f172a">8</span><span style="font-size:13px;color:#64748b;margin-left:4px">台 GPU 服务器</span></div>
</div>
<div style="display:flex;align-items:center;gap:12px">
<div style="width:44px;height:44px;border-radius:10px;background:#fef3c7;display:flex;align-items:center;justify-content:center;font-size:20px">🗄</div>
<div><span style="font-size:24px;font-weight:700;color:#0f172a">3</span><span style="font-size:13px;color:#64748b;margin-left:4px">台 存储服务器</span></div>
</div>
</div>
</div>
<!-- Ch2 -->
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden;margin-bottom:12px">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
<div style="display:flex;align-items:center;gap:10px">
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#8b5cf6;color:#fff;border-radius:6px;font-size:12px;font-weight:700">2</span>
<span style="font-size:15px;font-weight:600;color:#1e293b">运营数据</span>
</div>
</div>
<div style="display:flex;gap:24px;padding:16px 20px">
<div style="font-size:13px;color:#64748b">故障工单 <span style="font-weight:700;color:#d97706;font-size:16px;margin-left:4px">8</span></div>
<div style="font-size:13px;color:#64748b">涉及设备 <span style="font-weight:700;color:#0f172a;font-size:16px;margin-left:4px">5</span></div>
<div style="font-size:13px;color:#64748b">无故障天数 <span style="font-weight:700;color:#059669;font-size:16px;margin-left:4px">24</span></div>
</div>
</div>
<!-- Ch3 -->
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden;margin-bottom:12px">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
<div style="display:flex;align-items:center;gap:10px">
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#f59e0b;color:#fff;border-radius:6px;font-size:12px;font-weight:700">3</span>
<span style="font-size:15px;font-weight:600;color:#1e293b">故障分类</span>
</div>
</div>
<div style="display:flex;gap:16px;padding:16px 20px">
<div style="flex:1;padding:12px;border-radius:8px;background:#fff7ed;text-align:center">
<div style="font-size:20px;font-weight:700;color:#f59e0b">5</div>
<div style="font-size:12px;color:#64748b">GPU 故障</div>
<div style="width:100%;height:4px;background:#fef3c7;border-radius:2px;margin-top:6px"><div style="width:62.5%;height:100%;background:#f59e0b;border-radius:2px"></div></div>
<div style="font-size:11px;color:#94a3b8;margin-top:2px">62.5%</div>
</div>
<div style="flex:1;padding:12px;border-radius:8px;background:#fff7ed;text-align:center">
<div style="font-size:20px;font-weight:700;color:#f59e0b">1</div>
<div style="font-size:12px;color:#64748b">存储故障</div>
<div style="width:100%;height:4px;background:#fef3c7;border-radius:2px;margin-top:6px"><div style="width:12.5%;height:100%;background:#f59e0b;border-radius:2px"></div></div>
<div style="font-size:11px;color:#94a3b8;margin-top:2px">12.5%</div>
</div>
<div style="flex:1;padding:12px;border-radius:8px;background:#eff6ff;text-align:center">
<div style="font-size:20px;font-weight:700;color:#3b82f6">2</div>
<div style="font-size:12px;color:#64748b">其他工单</div>
<div style="width:100%;height:4px;background:#dbeafe;border-radius:2px;margin-top:6px"><div style="width:25%;height:100%;background:#3b82f6;border-radius:2px"></div></div>
<div style="font-size:11px;color:#94a3b8;margin-top:2px">25.0%</div>
</div>
</div>
</div>
<!-- Ch4 -->
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
<div style="display:flex;align-items:center;gap:10px">
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#10b981;color:#fff;border-radius:6px;font-size:12px;font-weight:700">4</span>
<span style="font-size:15px;font-weight:600;color:#1e293b">服务可用性</span>
</div>
</div>
<div style="padding:16px 20px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
<span style="font-size:13px;color:#64748b">整体可用性:</span>
<span style="font-size:22px;font-weight:700;color:#f59e0b">98.52%</span>
</div>
<table style="width:100%;font-size:13px;border-collapse:collapse">
<thead>
<tr style="border-bottom:2px solid #e2e8f0;text-align:left">
<th style="padding:8px 12px;color:#64748b;font-weight:500">IP 地址</th>
<th style="padding:8px 12px;color:#64748b;font-weight:500">设备类型</th>
<th style="padding:8px 12px;color:#64748b;font-weight:500">故障时长</th>
<th style="padding:8px 12px;color:#64748b;font-weight:500">可用性</th>
<th style="padding:8px 12px;color:#64748b;font-weight:500">状态</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:8px 12px;font-weight:500;color:#0f172a">10.0.0.35</td>
<td style="padding:8px 12px;color:#64748b">GPU</td>
<td style="padding:8px 12px;color:#dc2626;font-weight:500">2,160 分钟</td>
<td style="padding:8px 12px;font-weight:700;color:#dc2626">95.12%</td>
<td style="padding:8px 12px"><span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:#fef2f2;color:#dc2626">进行中</span></td>
</tr>
<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:8px 12px;font-weight:500;color:#0f172a">10.0.0.42</td>
<td style="padding:8px 12px;color:#64748b">GPU</td>
<td style="padding:8px 12px;color:#0f172a">1,440 分钟</td>
<td style="padding:8px 12px;font-weight:700;color:#f59e0b">96.78%</td>
<td style="padding:8px 12px"><span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:#dcfce7;color:#15803d">已恢复</span></td>
</tr>
<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:8px 12px;font-weight:500;color:#0f172a">10.0.0.18</td>
<td style="padding:8px 12px;color:#64748b">存储</td>
<td style="padding:8px 12px;color:#0f172a">720 分钟</td>
<td style="padding:8px 12px;font-weight:700;color:#f59e0b">98.41%</td>
<td style="padding:8px 12px"><span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:#dcfce7;color:#15803d">已恢复</span></td>
</tr>
</tbody>
</table>
<div style="font-size:11px;color:#94a3b8;margin-top:8px">共 3 个 IP 可用性低于 100%,其余设备保持 100% 在线</div>
</div>
</div>
</div>
</div>
<!-- 不同状态下的按钮变化 -->
<div class="section">
<h3>右上角按钮 — 按状态切换</h3>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-top:12px">
<div style="background:#fef3c7;border:1px solid #fde68a;border-radius:8px;padding:12px;text-align:center">
<div style="font-size:11px;color:#a16207;margin-bottom:6px">数据已就绪</div>
<span style="display:inline-block;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;background:#059669;color:#fff">生成报告文档</span>
</div>
<div style="background:#dbeafe;border:1px solid #bfdbfe;border-radius:8px;padding:12px;text-align:center">
<div style="font-size:11px;color:#1e40af;margin-bottom:6px">文档生成中</div>
<span style="display:inline-block;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;background:#94a3b8;color:#fff">生成中...</span>
</div>
<div style="background:#dcfce7;border:1px solid #bbf7d0;border-radius:8px;padding:12px;text-align:center">
<div style="font-size:11px;color:#15803d;margin-bottom:6px">已完成</div>
<span style="display:inline-block;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;background:#2563eb;color:#fff">⬇ 下载报告</span>
</div>
<div style="background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:12px;text-align:center">
<div style="font-size:11px;color:#b91c1c;margin-bottom:6px">生成失败</div>
<span style="display:inline-block;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;background:#f59e0b;color:#fff">重新生成</span>
</div>
</div>

View File

@ -0,0 +1 @@
{"type":"server-started","port":50153,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:50153","screen_dir":"/Users/niuniu/programs/docker/issue-ai/.superpowers/brainstorm/65974-1778137360/content","state_dir":"/Users/niuniu/programs/docker/issue-ai/.superpowers/brainstorm/65974-1778137360/state"}

View File

@ -0,0 +1,53 @@
<h2>报告预览页 — 设计方向</h2>
<p class="subtitle">当前页面只展示 4 个数字卡片 + 3 个故障分类卡片。以下三个方向兼顾数据丰富度与视觉提升,各有侧重。</p>
<div class="options" data-multiselect>
<div class="option" data-choice="a" onclick="toggleSelect(this)">
<div class="letter">A</div>
<div class="content">
<h3>仪表盘风格</h3>
<p>将预览页做成迷你仪表盘:顶部 KPI 卡片带环比/同比趋势箭头,中间用横向条状图展示故障分类,底部展示服务可用性分布。数据量大但不杂乱,适合快速扫读。</p>
</div>
</div>
<div class="option" data-choice="b" onclick="toggleSelect(this)">
<div class="letter">B</div>
<div class="content">
<h3>摘要 + 详情分区</h3>
<p>顶部为摘要区(设备规模 + 核心指标如可用性/SLA/解决率),下方可展开的折叠面板展示故障明细(按分类、按设备、按日期)。信息层级清晰,用户按需深入。</p>
</div>
</div>
<div class="option" data-choice="c" onclick="toggleSelect(this)">
<div class="letter">C</div>
<div class="content">
<h3>单页报告预览</h3>
<p>接近真实报告的排版风格:带章节标题(一、设备概况 二、运营数据 三、故障分类 四、服务可用性),每个章节内嵌简表或数据卡片。预览即缩略版报告,视觉专业正式。</p>
</div>
</div>
</div>
<div class="section">
<h3>共同增强的数据指标</h3>
<p>无论选哪个方向,以下字段都会从 metadata 中新增暴露:</p>
</div>
<div class="pros-cons">
<div class="pros">
<h4>新增指标</h4>
<ul>
<li>解决率(已解决 / 故障总数)</li>
<li>SLA 达标率</li>
<li>平均处理时长</li>
<li>故障分类分布(带具体数量)</li>
<li>进行中工单数</li>
<li>月报独有:设备级最低可用性 IP</li>
</ul>
</div>
<div class="cons">
<h4>改动范围</h4>
<ul>
<li>前后端都需改metadata 字段扩展 + 预览页 UI 重写</li>
<li>旧报告不含新字段,需兼容降级展示</li>
<li>周报/月报数据结构不同,需分别处理</li>
</ul>
</div>
</div>

View File

@ -0,0 +1,200 @@
<h2>报告预览页 — 最终方案</h2>
<p class="subtitle">5 KPI + 4 章节全部展开。第 4 章列出所有可用性 &lt; 100% 的 IP。</p>
<div class="mockup">
<div class="mockup-header">完整页面布局 — 月报示例2026年4月</div>
<div class="mockup-body">
<!-- Header -->
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 0;border-bottom:1px solid #e2e8f0;margin-bottom:20px">
<div style="display:flex;align-items:center;gap:16px">
<span style="color:#94a3b8;font-size:18px"></span>
<div>
<h2 style="font-size:22px;font-weight:700;color:#0f172a;margin:0">月报</h2>
<span style="font-size:13px;color:#64748b">2026-04-01 ~ 2026-04-30</span>
</div>
<span style="background:#dcfce7;color:#15803d;padding:2px 10px;border-radius:12px;font-size:12px;font-weight:600">已完成</span>
</div>
<button class="mock-button">⬇ 下载报告</button>
</div>
<!-- KPI Row -->
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-bottom:20px">
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
<div style="font-size:12px;color:#64748b;margin-bottom:4px">工单总数</div>
<div style="font-size:28px;font-weight:700;color:#0f172a">12</div>
</div>
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
<div style="font-size:12px;color:#64748b;margin-bottom:4px">已解决</div>
<div style="font-size:28px;font-weight:700;color:#059669">10</div>
<div style="font-size:11px;color:#94a3b8">83.3%</div>
</div>
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
<div style="font-size:12px;color:#64748b;margin-bottom:4px">整体可用性</div>
<div style="font-size:28px;font-weight:700;color:#f59e0b">98.52%</div>
<div style="font-size:11px;color:#94a3b8">低于99%</div>
</div>
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
<div style="font-size:12px;color:#64748b;margin-bottom:4px">平均处理时长</div>
<div style="font-size:28px;font-weight:700;color:#0f172a">240</div>
<div style="font-size:11px;color:#94a3b8">分钟</div>
</div>
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
<div style="font-size:12px;color:#64748b;margin-bottom:4px">进行中</div>
<div style="font-size:28px;font-weight:700;color:#0369a1">2</div>
</div>
</div>
<!-- Chapter Sections — all expanded -->
<div style="display:flex;flex-direction:column;gap:12px">
<!-- Ch1: 设备概况 -->
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
<div style="display:flex;align-items:center;gap:10px">
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#3b82f6;color:#fff;border-radius:6px;font-size:12px;font-weight:700">1</span>
<span style="font-size:15px;font-weight:600;color:#1e293b">设备概况</span>
</div>
<span style="font-size:20px;color:#94a3b8"></span>
</div>
<div style="display:flex;gap:32px;padding:16px 20px">
<div style="display:flex;align-items:center;gap:12px">
<div style="width:44px;height:44px;border-radius:10px;background:#dbeafe;display:flex;align-items:center;justify-content:center;font-size:20px">🖥</div>
<div><span style="font-size:24px;font-weight:700;color:#0f172a">8</span><span style="font-size:13px;color:#64748b;margin-left:4px">台 GPU 服务器</span></div>
</div>
<div style="display:flex;align-items:center;gap:12px">
<div style="width:44px;height:44px;border-radius:10px;background:#fef3c7;display:flex;align-items:center;justify-content:center;font-size:20px">🗄</div>
<div><span style="font-size:24px;font-weight:700;color:#0f172a">3</span><span style="font-size:13px;color:#64748b;margin-left:4px">台 存储服务器</span></div>
</div>
</div>
</div>
<!-- Ch2: 运营数据 -->
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
<div style="display:flex;align-items:center;gap:10px">
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#8b5cf6;color:#fff;border-radius:6px;font-size:12px;font-weight:700">2</span>
<span style="font-size:15px;font-weight:600;color:#1e293b">运营数据</span>
</div>
<span style="font-size:20px;color:#94a3b8"></span>
</div>
<div style="display:flex;gap:24px;padding:16px 20px">
<div style="font-size:13px;color:#64748b">故障工单 <span style="font-weight:700;color:#d97706;font-size:16px;margin-left:4px">8</span></div>
<div style="font-size:13px;color:#64748b">涉及设备 <span style="font-weight:700;color:#0f172a;font-size:16px;margin-left:4px">5</span></div>
<div style="font-size:13px;color:#64748b">无故障天数 <span style="font-weight:700;color:#059669;font-size:16px;margin-left:4px">24</span></div>
</div>
</div>
<!-- Ch3: 故障分类 -->
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
<div style="display:flex;align-items:center;gap:10px">
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#f59e0b;color:#fff;border-radius:6px;font-size:12px;font-weight:700">3</span>
<span style="font-size:15px;font-weight:600;color:#1e293b">故障分类</span>
</div>
<span style="font-size:20px;color:#94a3b8"></span>
</div>
<div style="display:flex;gap:16px;padding:16px 20px">
<div style="flex:1;padding:12px;border-radius:8px;background:#fff7ed;text-align:center">
<div style="font-size:20px;font-weight:700;color:#f59e0b">5</div>
<div style="font-size:12px;color:#64748b">GPU 故障</div>
<div style="width:100%;height:4px;background:#fef3c7;border-radius:2px;margin-top:6px"><div style="width:62.5%;height:100%;background:#f59e0b;border-radius:2px"></div></div>
<div style="font-size:11px;color:#94a3b8;margin-top:2px">62.5%</div>
</div>
<div style="flex:1;padding:12px;border-radius:8px;background:#fff7ed;text-align:center">
<div style="font-size:20px;font-weight:700;color:#f59e0b">1</div>
<div style="font-size:12px;color:#64748b">存储故障</div>
<div style="width:100%;height:4px;background:#fef3c7;border-radius:2px;margin-top:6px"><div style="width:12.5%;height:100%;background:#f59e0b;border-radius:2px"></div></div>
<div style="font-size:11px;color:#94a3b8;margin-top:2px">12.5%</div>
</div>
<div style="flex:1;padding:12px;border-radius:8px;background:#eff6ff;text-align:center">
<div style="font-size:20px;font-weight:700;color:#3b82f6">2</div>
<div style="font-size:12px;color:#64748b">其他工单</div>
<div style="width:100%;height:4px;background:#dbeafe;border-radius:2px;margin-top:6px"><div style="width:25%;height:100%;background:#3b82f6;border-radius:2px"></div></div>
<div style="font-size:11px;color:#94a3b8;margin-top:2px">25.0%</div>
</div>
</div>
</div>
<!-- Ch4: 服务可用性 — 列出所有可用性 <100% 的 IP -->
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
<div style="display:flex;align-items:center;gap:10px">
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#10b981;color:#fff;border-radius:6px;font-size:12px;font-weight:700">4</span>
<span style="font-size:15px;font-weight:600;color:#1e293b">服务可用性</span>
</div>
<span style="font-size:20px;color:#94a3b8"></span>
</div>
<div style="padding:16px 20px">
<!-- Overall availability banner -->
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
<span style="font-size:13px;color:#64748b">整体可用性:</span>
<span style="font-size:22px;font-weight:700;color:#f59e0b">98.52%</span>
</div>
<!-- IP table -->
<table style="width:100%;font-size:13px;border-collapse:collapse">
<thead>
<tr style="border-bottom:2px solid #e2e8f0;text-align:left">
<th style="padding:8px 12px;color:#64748b;font-weight:500">IP 地址</th>
<th style="padding:8px 12px;color:#64748b;font-weight:500">设备类型</th>
<th style="padding:8px 12px;color:#64748b;font-weight:500">故障时长</th>
<th style="padding:8px 12px;color:#64748b;font-weight:500">可用性</th>
<th style="padding:8px 12px;color:#64748b;font-weight:500">状态</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:8px 12px;font-weight:500;color:#0f172a">10.0.0.35</td>
<td style="padding:8px 12px;color:#64748b">GPU</td>
<td style="padding:8px 12px;color:#dc2626;font-weight:500">2,160 分钟</td>
<td style="padding:8px 12px;font-weight:700;color:#dc2626">95.12%</td>
<td style="padding:8px 12px"><span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:#fef2f2;color:#dc2626">进行中</span></td>
</tr>
<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:8px 12px;font-weight:500;color:#0f172a">10.0.0.42</td>
<td style="padding:8px 12px;color:#64748b">GPU</td>
<td style="padding:8px 12px;color:#0f172a">1,440 分钟</td>
<td style="padding:8px 12px;font-weight:700;color:#f59e0b">96.78%</td>
<td style="padding:8px 12px"><span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:#dcfce7;color:#15803d">已恢复</span></td>
</tr>
<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:8px 12px;font-weight:500;color:#0f172a">10.0.0.18</td>
<td style="padding:8px 12px;color:#64748b">存储</td>
<td style="padding:8px 12px;color:#0f172a">720 分钟</td>
<td style="padding:8px 12px;font-weight:700;color:#f59e0b">98.41%</td>
<td style="padding:8px 12px"><span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:#dcfce7;color:#15803d">已恢复</span></td>
</tr>
</tbody>
</table>
<div style="font-size:11px;color:#94a3b8;margin-top:8px">共 3 个 IP 可用性低于 100%,其余设备保持 100% 在线</div>
</div>
</div>
</div>
</div>
</div>
<div class="section">
<h3>方案确认清单</h3>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
<div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;padding:14px">
<h4 style="margin:0 0 6px;font-size:14px;color:#15803d">✓ 已定</h4>
<ul style="margin:0;padding-left:18px;font-size:13px;color:#166534;line-height:1.8">
<li>5 KPI 卡片(工单总数 / 已解决 / 整体可用性 / 平均处理时长 / 进行中)</li>
<li>4 章节全部默认展开</li>
<li>Ch4 列出所有可用性 &lt;100% 的 IP + 明细表</li>
<li>故障分类带占比进度条</li>
<li>周报/月报自适应章节内容</li>
</ul>
</div>
<div style="background:#fff7ed;border:1px solid #fed7aa;border-radius:8px;padding:14px">
<h4 style="margin:0 0 6px;font-size:14px;color:#c2410c">? 待确认</h4>
<ul style="margin:0;padding-left:18px;font-size:13px;color:#7c2d12;line-height:1.8">
<li>Ch3 展开后是否需要按 fault_subcategory 二级明细?</li>
<li>Ch2 展开后是否需要按日故障统计简表?</li>
<li>后端 metadata 需扩展 5-6 个新字段</li>
</ul>
</div>
</div>

View File

@ -0,0 +1,172 @@
<h2>报告预览页 — 融合方案全景</h2>
<p class="subtitle">B摘要+详情分区)+ C报告章节结构。周报/月报共用同一布局,章节内容自适应。</p>
<div class="mockup">
<div class="mockup-header">完整页面布局 — 月报示例2026年4月</div>
<div class="mockup-body">
<!-- Header -->
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 0;border-bottom:1px solid #e2e8f0;margin-bottom:20px">
<div style="display:flex;align-items:center;gap:16px">
<span style="color:#94a3b8;font-size:18px"></span>
<div>
<h2 style="font-size:22px;font-weight:700;color:#0f172a;margin:0">月报</h2>
<span style="font-size:13px;color:#64748b">2026-04-01 ~ 2026-04-30</span>
</div>
<span style="background:#dcfce7;color:#15803d;padding:2px 10px;border-radius:12px;font-size:12px;font-weight:600">已完成</span>
</div>
<button class="mock-button">⬇ 下载报告</button>
</div>
<!-- KPI Row -->
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:12px;margin-bottom:20px">
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
<div style="font-size:12px;color:#64748b;margin-bottom:4px">工单总数</div>
<div style="font-size:28px;font-weight:700;color:#0f172a">12</div>
</div>
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
<div style="font-size:12px;color:#64748b;margin-bottom:4px">已解决</div>
<div style="font-size:28px;font-weight:700;color:#059669">10</div>
<div style="font-size:11px;color:#94a3b8">83.3%</div>
</div>
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
<div style="font-size:12px;color:#64748b;margin-bottom:4px">可用性均值</div>
<div style="font-size:28px;font-weight:700;color:#f59e0b">98.52%</div>
</div>
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
<div style="font-size:12px;color:#64748b;margin-bottom:4px">SLA 达标率</div>
<div style="font-size:28px;font-weight:700;color:#059669">91.7%</div>
</div>
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
<div style="font-size:12px;color:#64748b;margin-bottom:4px">平均处理时长</div>
<div style="font-size:28px;font-weight:700;color:#0f172a">240</div>
<div style="font-size:11px;color:#94a3b8">分钟</div>
</div>
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
<div style="font-size:12px;color:#64748b;margin-bottom:4px">进行中</div>
<div style="font-size:28px;font-weight:700;color:#0369a1">2</div>
</div>
</div>
<!-- Chapter Sections -->
<div style="display:flex;flex-direction:column;gap:12px">
<!-- Ch1: 设备概况 -->
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
<div style="display:flex;align-items:center;gap:10px">
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#3b82f6;color:#fff;border-radius:6px;font-size:12px;font-weight:700">1</span>
<span style="font-size:15px;font-weight:600;color:#1e293b">设备概况</span>
</div>
<span style="font-size:20px;color:#94a3b8"></span>
</div>
<div style="display:flex;gap:32px;padding:16px 20px">
<div style="display:flex;align-items:center;gap:12px">
<div style="width:44px;height:44px;border-radius:10px;background:#dbeafe;display:flex;align-items:center;justify-content:center;font-size:20px">🖥</div>
<div><span style="font-size:24px;font-weight:700;color:#0f172a">8</span><span style="font-size:13px;color:#64748b;margin-left:4px">台 GPU 服务器</span></div>
</div>
<div style="display:flex;align-items:center;gap:12px">
<div style="width:44px;height:44px;border-radius:10px;background:#fef3c7;display:flex;align-items:center;justify-content:center;font-size:20px">🗄</div>
<div><span style="font-size:24px;font-weight:700;color:#0f172a">3</span><span style="font-size:13px;color:#64748b;margin-left:4px">台 存储服务器</span></div>
</div>
</div>
</div>
<!-- Ch2: 运营数据 -->
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
<div style="display:flex;align-items:center;gap:10px">
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#8b5cf6;color:#fff;border-radius:6px;font-size:12px;font-weight:700">2</span>
<span style="font-size:15px;font-weight:600;color:#1e293b">运营数据</span>
</div>
<span style="font-size:20px;color:#94a3b8"></span>
</div>
<div style="display:flex;gap:24px;padding:16px 20px">
<div style="font-size:13px;color:#64748b">故障工单 <span style="font-weight:700;color:#d97706;font-size:16px;margin-left:4px">8</span></div>
<div style="font-size:13px;color:#64748b">涉及设备 <span style="font-weight:700;color:#0f172a;font-size:16px;margin-left:4px">5</span></div>
<div style="font-size:13px;color:#64748b">无故障天数 <span style="font-weight:700;color:#059669;font-size:16px;margin-left:4px">24</span></div>
</div>
</div>
<!-- Ch3: 故障分类 -->
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
<div style="display:flex;align-items:center;gap:10px">
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#f59e0b;color:#fff;border-radius:6px;font-size:12px;font-weight:700">3</span>
<span style="font-size:15px;font-weight:600;color:#1e293b">故障分类</span>
</div>
<span style="font-size:20px;color:#94a3b8"></span>
</div>
<div style="display:flex;gap:16px;padding:16px 20px">
<div style="flex:1;padding:12px;border-radius:8px;background:#fff7ed;text-align:center">
<div style="font-size:20px;font-weight:700;color:#f59e0b">5</div>
<div style="font-size:12px;color:#64748b">GPU 故障</div>
<div style="width:100%;height:4px;background:#fef3c7;border-radius:2px;margin-top:6px"><div style="width:62.5%;height:100%;background:#f59e0b;border-radius:2px"></div></div>
<div style="font-size:11px;color:#94a3b8;margin-top:2px">62.5%</div>
</div>
<div style="flex:1;padding:12px;border-radius:8px;background:#fff7ed;text-align:center">
<div style="font-size:20px;font-weight:700;color:#f59e0b">1</div>
<div style="font-size:12px;color:#64748b">存储故障</div>
<div style="width:100%;height:4px;background:#fef3c7;border-radius:2px;margin-top:6px"><div style="width:12.5%;height:100%;background:#f59e0b;border-radius:2px"></div></div>
<div style="font-size:11px;color:#94a3b8;margin-top:2px">12.5%</div>
</div>
<div style="flex:1;padding:12px;border-radius:8px;background:#eff6ff;text-align:center">
<div style="font-size:20px;font-weight:700;color:#3b82f6">2</div>
<div style="font-size:12px;color:#64748b">其他工单</div>
<div style="width:100%;height:4px;background:#dbeafe;border-radius:2px;margin-top:6px"><div style="width:25%;height:100%;background:#3b82f6;border-radius:2px"></div></div>
<div style="font-size:11px;color:#94a3b8;margin-top:2px">25.0%</div>
</div>
</div>
</div>
<!-- Ch4: 服务可用性 -->
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
<div style="display:flex;align-items:center;gap:10px">
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#10b981;color:#fff;border-radius:6px;font-size:12px;font-weight:700">4</span>
<span style="font-size:15px;font-weight:600;color:#1e293b">服务可用性</span>
</div>
<span style="font-size:20px;color:#94a3b8"></span>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px">
<div>
<span style="font-size:28px;font-weight:700;color:#f59e0b">98.52%</span>
<span style="font-size:13px;color:#64748b;margin-left:6px">整体可用性</span>
</div>
<div style="font-size:12px;color:#64748b">
<span>最低可用 IP</span>
<span style="font-weight:600;color:#dc2626">10.0.0.35</span>
<span style="color:#dc2626;font-weight:600;margin-left:4px">(95.12%)</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Key features -->
<div class="section">
<h3>设计要点</h3>
</div>
<div class="pros-cons">
<div class="pros">
<h4>交互特性</h4>
<ul>
<li>章节卡片默认全部展开,可折叠</li>
<li>折叠态显示摘要数据,不丢失关键信息</li>
<li>有数据的章节正常展示无数据的章节如无GPU故障保留卡片但数量为 0</li>
<li>周报/月报章节内容自适应(周报无"按IP可用性"明细,改为按日统计)</li>
</ul>
</div>
<div class="cons">
<h4>待确认</h4>
<ul>
<li>KPI 行 5-6 个指标是否太多?是否需要精简到 4 个?</li>
<li>章节是否默认全部展开,还是默认折叠只显示摘要?</li>
<li>故障分类是否需要按 fault_subcategory 二级展开?</li>
</ul>
</div>
</div>

View File

@ -0,0 +1,192 @@
<h2>报告预览页</h2>
<p class="subtitle">从列表点击报告名称或"预览"按钮进入。右上角按钮根据状态动态切换。</p>
<div class="mockup">
<div class="mockup-header">预览页 — 状态:数据已就绪(待生成文档)</div>
<div class="mockup-body">
<!-- Header -->
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 0;border-bottom:1px solid #e2e8f0;margin-bottom:20px">
<div style="display:flex;align-items:center;gap:16px">
<span style="color:#94a3b8;font-size:18px;cursor:pointer"></span>
<div>
<h2 style="font-size:22px;font-weight:700;color:#0f172a;margin:0">月报</h2>
<span style="font-size:13px;color:#64748b">2026-04-01 ~ 2026-04-30</span>
</div>
<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:500;background:#fef3c7;color:#92400e">
<span style="width:6px;height:6px;border-radius:50%;background:#f59e0b"></span>
数据已就绪
</span>
</div>
<button class="mock-button" style="background:#059669;color:#fff">生成报告文档</button>
</div>
<!-- KPI Row -->
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-bottom:20px">
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
<div style="font-size:12px;color:#64748b;margin-bottom:4px">工单总数</div>
<div style="font-size:28px;font-weight:700;color:#0f172a">12</div>
</div>
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
<div style="font-size:12px;color:#64748b;margin-bottom:4px">已解决</div>
<div style="font-size:28px;font-weight:700;color:#059669">10</div>
<div style="font-size:11px;color:#94a3b8">83.3%</div>
</div>
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
<div style="font-size:12px;color:#64748b;margin-bottom:4px">整体可用性</div>
<div style="font-size:28px;font-weight:700;color:#f59e0b">98.52%</div>
<div style="font-size:11px;color:#94a3b8">低于99%</div>
</div>
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
<div style="font-size:12px;color:#64748b;margin-bottom:4px">平均处理时长</div>
<div style="font-size:28px;font-weight:700;color:#0f172a">240</div>
<div style="font-size:11px;color:#94a3b8">分钟</div>
</div>
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
<div style="font-size:12px;color:#64748b;margin-bottom:4px">进行中</div>
<div style="font-size:28px;font-weight:700;color:#0369a1">2</div>
</div>
</div>
<!-- Ch1 -->
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden;margin-bottom:12px">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
<div style="display:flex;align-items:center;gap:10px">
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#3b82f6;color:#fff;border-radius:6px;font-size:12px;font-weight:700">1</span>
<span style="font-size:15px;font-weight:600;color:#1e293b">设备概况</span>
</div>
</div>
<div style="display:flex;gap:32px;padding:16px 20px">
<div style="display:flex;align-items:center;gap:12px">
<div style="width:44px;height:44px;border-radius:10px;background:#dbeafe;display:flex;align-items:center;justify-content:center;font-size:20px">🖥</div>
<div><span style="font-size:24px;font-weight:700;color:#0f172a">8</span><span style="font-size:13px;color:#64748b;margin-left:4px">台 GPU 服务器</span></div>
</div>
<div style="display:flex;align-items:center;gap:12px">
<div style="width:44px;height:44px;border-radius:10px;background:#fef3c7;display:flex;align-items:center;justify-content:center;font-size:20px">🗄</div>
<div><span style="font-size:24px;font-weight:700;color:#0f172a">3</span><span style="font-size:13px;color:#64748b;margin-left:4px">台 存储服务器</span></div>
</div>
</div>
</div>
<!-- Ch2 -->
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden;margin-bottom:12px">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
<div style="display:flex;align-items:center;gap:10px">
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#8b5cf6;color:#fff;border-radius:6px;font-size:12px;font-weight:700">2</span>
<span style="font-size:15px;font-weight:600;color:#1e293b">运营数据</span>
</div>
</div>
<div style="display:flex;gap:24px;padding:16px 20px">
<div style="font-size:13px;color:#64748b">故障工单 <span style="font-weight:700;color:#d97706;font-size:16px;margin-left:4px">8</span></div>
<div style="font-size:13px;color:#64748b">涉及设备 <span style="font-weight:700;color:#0f172a;font-size:16px;margin-left:4px">5</span></div>
<div style="font-size:13px;color:#64748b">无故障天数 <span style="font-weight:700;color:#059669;font-size:16px;margin-left:4px">24</span></div>
</div>
</div>
<!-- Ch3 -->
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden;margin-bottom:12px">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
<div style="display:flex;align-items:center;gap:10px">
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#f59e0b;color:#fff;border-radius:6px;font-size:12px;font-weight:700">3</span>
<span style="font-size:15px;font-weight:600;color:#1e293b">故障分类</span>
</div>
</div>
<div style="display:flex;gap:16px;padding:16px 20px">
<div style="flex:1;padding:12px;border-radius:8px;background:#fff7ed;text-align:center">
<div style="font-size:20px;font-weight:700;color:#f59e0b">5</div>
<div style="font-size:12px;color:#64748b">GPU 故障</div>
<div style="width:100%;height:4px;background:#fef3c7;border-radius:2px;margin-top:6px"><div style="width:62.5%;height:100%;background:#f59e0b;border-radius:2px"></div></div>
<div style="font-size:11px;color:#94a3b8;margin-top:2px">62.5%</div>
</div>
<div style="flex:1;padding:12px;border-radius:8px;background:#fff7ed;text-align:center">
<div style="font-size:20px;font-weight:700;color:#f59e0b">1</div>
<div style="font-size:12px;color:#64748b">存储故障</div>
<div style="width:100%;height:4px;background:#fef3c7;border-radius:2px;margin-top:6px"><div style="width:12.5%;height:100%;background:#f59e0b;border-radius:2px"></div></div>
<div style="font-size:11px;color:#94a3b8;margin-top:2px">12.5%</div>
</div>
<div style="flex:1;padding:12px;border-radius:8px;background:#eff6ff;text-align:center">
<div style="font-size:20px;font-weight:700;color:#3b82f6">2</div>
<div style="font-size:12px;color:#64748b">其他工单</div>
<div style="width:100%;height:4px;background:#dbeafe;border-radius:2px;margin-top:6px"><div style="width:25%;height:100%;background:#3b82f6;border-radius:2px"></div></div>
<div style="font-size:11px;color:#94a3b8;margin-top:2px">25.0%</div>
</div>
</div>
</div>
<!-- Ch4 -->
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
<div style="display:flex;align-items:center;gap:10px">
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#10b981;color:#fff;border-radius:6px;font-size:12px;font-weight:700">4</span>
<span style="font-size:15px;font-weight:600;color:#1e293b">服务可用性</span>
</div>
</div>
<div style="padding:16px 20px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
<span style="font-size:13px;color:#64748b">整体可用性:</span>
<span style="font-size:22px;font-weight:700;color:#f59e0b">98.52%</span>
</div>
<table style="width:100%;font-size:13px;border-collapse:collapse">
<thead>
<tr style="border-bottom:2px solid #e2e8f0;text-align:left">
<th style="padding:8px 12px;color:#64748b;font-weight:500">IP 地址</th>
<th style="padding:8px 12px;color:#64748b;font-weight:500">设备类型</th>
<th style="padding:8px 12px;color:#64748b;font-weight:500">故障时长</th>
<th style="padding:8px 12px;color:#64748b;font-weight:500">可用性</th>
<th style="padding:8px 12px;color:#64748b;font-weight:500">状态</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:8px 12px;font-weight:500;color:#0f172a">10.0.0.35</td>
<td style="padding:8px 12px;color:#64748b">GPU</td>
<td style="padding:8px 12px;color:#dc2626;font-weight:500">2,160 分钟</td>
<td style="padding:8px 12px;font-weight:700;color:#dc2626">95.12%</td>
<td style="padding:8px 12px"><span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:#fef2f2;color:#dc2626">进行中</span></td>
</tr>
<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:8px 12px;font-weight:500;color:#0f172a">10.0.0.42</td>
<td style="padding:8px 12px;color:#64748b">GPU</td>
<td style="padding:8px 12px;color:#0f172a">1,440 分钟</td>
<td style="padding:8px 12px;font-weight:700;color:#f59e0b">96.78%</td>
<td style="padding:8px 12px"><span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:#dcfce7;color:#15803d">已恢复</span></td>
</tr>
<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:8px 12px;font-weight:500;color:#0f172a">10.0.0.18</td>
<td style="padding:8px 12px;color:#64748b">存储</td>
<td style="padding:8px 12px;color:#0f172a">720 分钟</td>
<td style="padding:8px 12px;font-weight:700;color:#f59e0b">98.41%</td>
<td style="padding:8px 12px"><span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:#dcfce7;color:#15803d">已恢复</span></td>
</tr>
</tbody>
</table>
<div style="font-size:11px;color:#94a3b8;margin-top:8px">共 3 个 IP 可用性低于 100%,其余设备保持 100% 在线</div>
</div>
</div>
</div>
</div>
<!-- 不同状态下的按钮变化 -->
<div class="section">
<h3>右上角按钮 — 按状态切换</h3>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-top:12px">
<div style="background:#fef3c7;border:1px solid #fde68a;border-radius:8px;padding:12px;text-align:center">
<div style="font-size:11px;color:#a16207;margin-bottom:6px">数据已就绪</div>
<span style="display:inline-block;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;background:#059669;color:#fff">生成报告文档</span>
</div>
<div style="background:#dbeafe;border:1px solid #bfdbfe;border-radius:8px;padding:12px;text-align:center">
<div style="font-size:11px;color:#1e40af;margin-bottom:6px">文档生成中</div>
<span style="display:inline-block;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;background:#94a3b8;color:#fff">生成中...</span>
</div>
<div style="background:#dcfce7;border:1px solid #bbf7d0;border-radius:8px;padding:12px;text-align:center">
<div style="font-size:11px;color:#15803d;margin-bottom:6px">已完成</div>
<span style="display:inline-block;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;background:#2563eb;color:#fff">⬇ 下载报告</span>
</div>
<div style="background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:12px;text-align:center">
<div style="font-size:11px;color:#b91c1c;margin-bottom:6px">生成失败</div>
<span style="display:inline-block;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;background:#f59e0b;color:#fff">重新生成</span>
</div>
</div>

View File

@ -0,0 +1,155 @@
<h2>报告管理列表</h2>
<p class="subtitle">类型列用颜色区分月报/周报。批量操作按钮仅在选中后出现。</p>
<div class="mockup">
<div class="mockup-header">报告管理页面</div>
<div class="mockup-body">
<!-- Header -->
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 0;border-bottom:1px solid #e2e8f0;margin-bottom:16px">
<div>
<h2 style="font-size:20px;font-weight:700;color:#0f172a;margin:0">报告管理</h2>
<span style="font-size:12px;color:#94a3b8">数据预览与文档生成</span>
</div>
<button class="mock-button">+ 新建报告</button>
</div>
<!-- Table -->
<table style="width:100%;font-size:13px;border-collapse:collapse">
<thead>
<tr style="border-bottom:2px solid #e2e8f0;text-align:left">
<th style="padding:10px 12px;color:#64748b;font-weight:500;width:40px">
<input type="checkbox" style="width:16px;height:16px;border-radius:4px;border:1px solid #cbd5e1">
</th>
<th style="padding:10px 12px;color:#64748b;font-weight:500">报告名称</th>
<th style="padding:10px 12px;color:#64748b;font-weight:500">类型</th>
<th style="padding:10px 12px;color:#64748b;font-weight:500">时间段</th>
<th style="padding:10px 12px;color:#64748b;font-weight:500">状态</th>
<th style="padding:10px 12px;color:#64748b;font-weight:500">创建时间</th>
<th style="padding:10px 12px;color:#64748b;font-weight:500">操作</th>
</tr>
</thead>
<tbody>
<!-- Row 1: preview -->
<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:10px 12px"><input type="checkbox" style="width:16px;height:16px;border-radius:4px;border:1px solid #cbd5e1"></td>
<td style="padding:10px 12px;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
<a href="#" style="color:#2563eb;font-weight:500;text-decoration:none;cursor:pointer" onclick="event.preventDefault()">2026年4月图灵IT基础设施运营月报</a>
</td>
<td style="padding:10px 12px">
<span style="display:inline-block;padding:3px 10px;border-radius:8px;font-size:11px;font-weight:600;background:#dbeafe;color:#1e40af">月报</span>
</td>
<td style="padding:10px 12px;color:#64748b">2026-04-01 ~ 2026-04-30</td>
<td style="padding:10px 12px">
<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:500;background:#fef3c7;color:#92400e">
<span style="width:6px;height:6px;border-radius:50%;background:#f59e0b"></span>
数据已就绪
</span>
</td>
<td style="padding:10px 12px;color:#94a3b8;font-size:12px">2026-05-07 14:30</td>
<td style="padding:10px 12px">
<div style="display:flex;gap:4px">
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#eff6ff;color:#2563eb">预览</span>
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#f0fdf4;color:#059669">生成文档</span>
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;color:#dc2626">🗑</span>
</div>
</td>
</tr>
<!-- Row 2: generating -->
<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:10px 12px"><input type="checkbox" style="width:16px;height:16px;border-radius:4px;border:1px solid #cbd5e1"></td>
<td style="padding:10px 12px;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
<a href="#" style="color:#2563eb;font-weight:500;text-decoration:none;cursor:pointer" onclick="event.preventDefault()">图灵IT基础设施运营周报4月28日-5月4日</a>
</td>
<td style="padding:10px 12px">
<span style="display:inline-block;padding:3px 10px;border-radius:8px;font-size:11px;font-weight:600;background:#ede9fe;color:#6b21a8">周报</span>
</td>
<td style="padding:10px 12px;color:#64748b">2026-04-28 ~ 2026-05-04</td>
<td style="padding:10px 12px">
<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:500;background:#dbeafe;color:#1e40af">
<span style="display:inline-block;width:10px;height:10px;border:2px solid #3b82f6;border-top-color:transparent;border-radius:50%"></span>
文档生成中
</span>
</td>
<td style="padding:10px 12px;color:#94a3b8;font-size:12px">2026-05-07 14:35</td>
<td style="padding:10px 12px">
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#eff6ff;color:#2563eb">预览</span>
</td>
</tr>
<!-- Row 3: completed -->
<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:10px 12px"><input type="checkbox" style="width:16px;height:16px;border-radius:4px;border:1px solid #cbd5e1"></td>
<td style="padding:10px 12px;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
<a href="#" style="color:#2563eb;font-weight:500;text-decoration:none;cursor:pointer" onclick="event.preventDefault()">2026年3月图灵IT基础设施运营月报</a>
</td>
<td style="padding:10px 12px">
<span style="display:inline-block;padding:3px 10px;border-radius:8px;font-size:11px;font-weight:600;background:#dbeafe;color:#1e40af">月报</span>
</td>
<td style="padding:10px 12px;color:#64748b">2026-03-01 ~ 2026-03-31</td>
<td style="padding:10px 12px">
<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:500;background:#dcfce7;color:#15803d">
<span style="width:6px;height:6px;border-radius:50%;background:#22c55e"></span>
已完成
</span>
</td>
<td style="padding:10px 12px;color:#94a3b8;font-size:12px">2026-05-06 10:15</td>
<td style="padding:10px 12px">
<div style="display:flex;gap:4px">
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#eff6ff;color:#2563eb">预览</span>
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#f0fdf4;color:#059669">⬇ 下载</span>
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;color:#dc2626">🗑</span>
</div>
</td>
</tr>
<!-- Row 4: failed -->
<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:10px 12px"><input type="checkbox" style="width:16px;height:16px;border-radius:4px;border:1px solid #cbd5e1"></td>
<td style="padding:10px 12px;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
<a href="#" style="color:#2563eb;font-weight:500;text-decoration:none;cursor:pointer" onclick="event.preventDefault()">图灵IT基础设施运营周报4月21日-4月27日</a>
</td>
<td style="padding:10px 12px">
<span style="display:inline-block;padding:3px 10px;border-radius:8px;font-size:11px;font-weight:600;background:#ede9fe;color:#6b21a8">周报</span>
</td>
<td style="padding:10px 12px;color:#64748b">2026-04-21 ~ 2026-04-27</td>
<td style="padding:10px 12px">
<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:500;background:#fef2f2;color:#b91c1c">
<span style="width:6px;height:6px;border-radius:50%;background:#ef4444"></span>
生成失败
</span>
</td>
<td style="padding:10px 12px;color:#94a3b8;font-size:12px">2026-05-06 11:00</td>
<td style="padding:10px 12px">
<div style="display:flex;gap:4px">
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#eff6ff;color:#2563eb">预览</span>
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#fef3c7;color:#92400e">重试</span>
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;color:#dc2626">🗑</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
<div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;padding:14px">
<h4 style="margin:0 0 6px;font-size:14px;color:#15803d">✓ 类型颜色区分</h4>
<div style="display:flex;gap:12px;align-items:center;font-size:13px">
<span style="display:inline-block;padding:3px 10px;border-radius:8px;font-size:11px;font-weight:600;background:#dbeafe;color:#1e40af">月报</span>
<span style="font-size:12px;color:#64748b">蓝色背景,与报告预览章节编号色呼应</span>
</div>
<div style="display:flex;gap:12px;align-items:center;font-size:13px;margin-top:8px">
<span style="display:inline-block;padding:3px 10px;border-radius:8px;font-size:11px;font-weight:600;background:#ede9fe;color:#6b21a8">周报</span>
<span style="font-size:12px;color:#64748b">紫色背景,与月报明显区分</span>
</div>
</div>
<div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;padding:14px">
<h4 style="margin:0 0 6px;font-size:14px;color:#15803d">✓ 批量操作栏</h4>
<ul style="margin:0;padding-left:18px;font-size:13px;color:#166534;line-height:1.8">
<li>未选中任何行时不显示批量操作栏</li>
<li>选中 ≥1 行后,在表格下方出现「📦 批量下载」「🗑 批量删除」</li>
<li>与现有实现完全一致,无需改动</li>
</ul>
</div>
</div>

View File

@ -0,0 +1,226 @@
<h2>报告管理页 — 新流程重构</h2>
<p class="subtitle">核心理念预览数据先行1-2s确认后再生成 DOCX10-30s</p>
<div class="section">
<h3>流程对比</h3>
</div>
<div class="split">
<div class="mockup">
<div class="mockup-header">旧流程</div>
<div class="mockup-body" style="padding:16px;font-size:13px;text-align:center;line-height:2.2">
选择月份 → 点"生成报告" →<br/>
<span style="color:#94a3b8">等待 10-30s数据+图表+DOCX+OLE</span><br/>
报告完成 → 点"查看"预览 →<br/>
发现数据有误 → 删除重来
</div>
</div>
<div class="mockup">
<div class="mockup-header">新流程</div>
<div class="mockup-body" style="padding:16px;font-size:13px;text-align:center;line-height:2.2">
选择月份 → 点"预览报告" →<br/>
<span style="color:#059669">等待 1-2s仅数据采集</span><br/>
自动跳转预览页 → 确认数据无误 →<br/>
点"生成报告文档" → 下载 DOCX
</div>
</div>
</div>
<div class="section">
<h3>报告列表页 — 新版布局</h3>
</div>
<div class="mockup">
<div class="mockup-header">报告管理页面(列表)</div>
<div class="mockup-body">
<!-- Header -->
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 0;border-bottom:1px solid #e2e8f0;margin-bottom:16px">
<div>
<h2 style="font-size:20px;font-weight:700;color:#0f172a;margin:0">报告管理</h2>
<span style="font-size:12px;color:#94a3b8">数据预览与文档生成</span>
</div>
<button class="mock-button" style="background:#3b82f6;color:#fff;border:none;padding:8px 16px;border-radius:10px;font-size:13px;font-weight:600">+ 新建报告</button>
</div>
<!-- Table -->
<table style="width:100%;font-size:13px;border-collapse:collapse">
<thead>
<tr style="border-bottom:2px solid #e2e8f0;text-align:left">
<th style="padding:10px 12px;color:#64748b;font-weight:500;width:40px">
<input type="checkbox" style="width:16px;height:16px;border-radius:4px;border:1px solid #cbd5e1">
</th>
<th style="padding:10px 12px;color:#64748b;font-weight:500">报告名称</th>
<th style="padding:10px 12px;color:#64748b;font-weight:500">类型</th>
<th style="padding:10px 12px;color:#64748b;font-weight:500">时间段</th>
<th style="padding:10px 12px;color:#64748b;font-weight:500">状态</th>
<th style="padding:10px 12px;color:#64748b;font-weight:500">创建时间</th>
<th style="padding:10px 12px;color:#64748b;font-weight:500">操作</th>
</tr>
</thead>
<tbody>
<!-- Row 1: preview status -->
<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:10px 12px"><input type="checkbox" style="width:16px;height:16px;border-radius:4px;border:1px solid #cbd5e1"></td>
<td style="padding:10px 12px;font-weight:500;color:#0f172a;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="2026年4月图灵IT基础设施运营月报">2026年4月图灵IT基础设施运营月报</td>
<td style="padding:10px 12px;font-weight:500;color:#0f172a">月报</td>
<td style="padding:10px 12px;color:#64748b">2026-04-01 ~ 2026-04-30</td>
<td style="padding:10px 12px">
<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:500;background:#fef3c7;color:#92400e">
<span style="width:6px;height:6px;border-radius:50%;background:#f59e0b"></span>
数据已就绪
</span>
</td>
<td style="padding:10px 12px;color:#94a3b8;font-size:12px">2026-05-07 14:30</td>
<td style="padding:10px 12px">
<div style="display:flex;gap:4px">
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#eff6ff;color:#2563eb">预览</span>
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#f0fdf4;color:#059669">生成文档</span>
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;color:#dc2626">🗑</span>
</div>
</td>
</tr>
<!-- Row 2: generating status -->
<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:10px 12px"><input type="checkbox" style="width:16px;height:16px;border-radius:4px;border:1px solid #cbd5e1"></td>
<td style="padding:10px 12px;font-weight:500;color:#0f172a;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="图灵IT基础设施运营周报4月28日-5月4日">图灵IT基础设施运营周报4月28日-5月4日</td>
<td style="padding:10px 12px;font-weight:500;color:#0f172a">周报</td>
<td style="padding:10px 12px;color:#64748b">2026-04-28 ~ 2026-05-04</td>
<td style="padding:10px 12px">
<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:500;background:#dbeafe;color:#1e40af">
<span style="display:inline-block;width:10px;height:10px;border:2px solid #3b82f6;border-top-color:transparent;border-radius:50%;animation:spin 1s linear infinite"></span>
文档生成中
</span>
</td>
<td style="padding:10px 12px;color:#94a3b8;font-size:12px">2026-05-07 14:35</td>
<td style="padding:10px 12px">
<div style="display:flex;gap:4px">
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#eff6ff;color:#2563eb">预览</span>
</div>
</td>
</tr>
<!-- Row 3: completed status -->
<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:10px 12px"><input type="checkbox" style="width:16px;height:16px;border-radius:4px;border:1px solid #cbd5e1"></td>
<td style="padding:10px 12px;font-weight:500;color:#0f172a;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="2026年3月图灵IT基础设施运营月报">2026年3月图灵IT基础设施运营月报</td>
<td style="padding:10px 12px;font-weight:500;color:#0f172a">月报</td>
<td style="padding:10px 12px;color:#64748b">2026-03-01 ~ 2026-03-31</td>
<td style="padding:10px 12px">
<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:500;background:#dcfce7;color:#15803d">
<span style="width:6px;height:6px;border-radius:50%;background:#22c55e"></span>
已完成
</span>
</td>
<td style="padding:10px 12px;color:#94a3b8;font-size:12px">2026-05-06 10:15</td>
<td style="padding:10px 12px">
<div style="display:flex;gap:4px">
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#eff6ff;color:#2563eb">预览</span>
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#f0fdf4;color:#059669">⬇ 下载</span>
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;color:#dc2626">🗑</span>
</div>
</td>
</tr>
<!-- Row 4: failed -->
<tr style="border-bottom:1px solid #f1f5f9">
<td style="padding:10px 12px"><input type="checkbox" style="width:16px;height:16px;border-radius:4px;border:1px solid #cbd5e1"></td>
<td style="padding:10px 12px;font-weight:500;color:#0f172a;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="图灵IT基础设施运营周报4月21日-4月27日">图灵IT基础设施运营周报4月21日-4月27日</td>
<td style="padding:10px 12px;font-weight:500;color:#0f172a">周报</td>
<td style="padding:10px 12px;color:#64748b">2026-04-21 ~ 2026-04-27</td>
<td style="padding:10px 12px">
<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:500;background:#fef2f2;color:#b91c1c">
<span style="width:6px;height:6px;border-radius:50%;background:#ef4444"></span>
生成失败
</span>
</td>
<td style="padding:10px 12px;color:#94a3b8;font-size:12px">2026-05-06 11:00</td>
<td style="padding:10px 12px">
<div style="display:flex;gap:4px">
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#eff6ff;color:#2563eb">预览</span>
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#fef3c7;color:#92400e">重试</span>
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;color:#dc2626">🗑</span>
</div>
</td>
</tr>
</tbody>
</table>
<!-- Batch actions bar -->
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 0;margin-top:12px;border-top:1px solid #e2e8f0">
<span style="font-size:12px;color:#94a3b8">已选 2 项</span>
<div style="display:flex;gap:8px">
<span style="cursor:pointer;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:500;background:#eff6ff;color:#2563eb">📦 批量下载</span>
<span style="cursor:pointer;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:500;background:#fef2f2;color:#dc2626">🗑 批量删除</span>
</div>
</div>
</div>
</div>
<div class="section">
<h3>状态体系总览</h3>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-top:12px">
<div style="background:#fef3c7;border:1px solid #fde68a;border-radius:8px;padding:12px;text-align:center">
<div style="font-weight:700;color:#92400e;font-size:14px;margin-bottom:4px">数据已就绪</div>
<div style="font-size:11px;color:#a16207">数据采集完成<br/>文档未生成</div>
<div style="display:flex;gap:4px;justify-content:center;margin-top:8px">
<span style="padding:3px 8px;border-radius:4px;font-size:10px;background:#eff6ff;color:#2563eb">预览</span>
<span style="padding:3px 8px;border-radius:4px;font-size:10px;background:#f0fdf4;color:#059669">生成文档</span>
</div>
</div>
<div style="background:#dbeafe;border:1px solid #bfdbfe;border-radius:8px;padding:12px;text-align:center">
<div style="font-weight:700;color:#1e40af;font-size:14px;margin-bottom:4px">文档生成中</div>
<div style="font-size:11px;color:#3b82f6">DOCX 构建中<br/>图表+OLE嵌入</div>
<div style="margin-top:8px">
<span style="padding:3px 8px;border-radius:4px;font-size:10px;background:#eff6ff;color:#2563eb">预览</span>
</div>
</div>
<div style="background:#dcfce7;border:1px solid #bbf7d0;border-radius:8px;padding:12px;text-align:center">
<div style="font-weight:700;color:#15803d;font-size:14px;margin-bottom:4px">已完成</div>
<div style="font-size:11px;color:#16a34a">文档就绪<br/>可下载</div>
<div style="display:flex;gap:4px;justify-content:center;margin-top:8px">
<span style="padding:3px 8px;border-radius:4px;font-size:10px;background:#eff6ff;color:#2563eb">预览</span>
<span style="padding:3px 8px;border-radius:4px;font-size:10px;background:#f0fdf4;color:#059669">下载</span>
</div>
</div>
<div style="background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:12px;text-align:center">
<div style="font-weight:700;color:#b91c1c;font-size:14px;margin-bottom:4px">生成失败</div>
<div style="font-size:11px;color:#dc2626">文档构建出错<br/>可查看预览数据</div>
<div style="display:flex;gap:4px;justify-content:center;margin-top:8px">
<span style="padding:3px 8px;border-radius:4px;font-size:10px;background:#eff6ff;color:#2563eb">预览</span>
<span style="padding:3px 8px;border-radius:4px;font-size:10px;background:#fef3c7;color:#92400e">重试</span>
</div>
</div>
</div>
<div class="section" style="margin-top:20px">
<h3>与预览页的衔接</h3>
</div>
<div class="mockup">
<div class="mockup-header">用户操作流程</div>
<div class="mockup-body" style="padding:20px;text-align:center">
<div style="display:flex;align-items:center;justify-content:center;gap:16px;font-size:13px">
<div style="background:#f1f5f9;padding:12px 20px;border-radius:8px;font-weight:600;color:#0f172a">
列表页<br/>点"新建报告"
</div>
<span style="font-size:18px;color:#94a3b8"></span>
<div style="background:#f1f5f9;padding:12px 20px;border-radius:8px;font-weight:600;color:#0f172a">
弹窗选择<br/>月份/日期
</div>
<span style="font-size:18px;color:#94a3b8"></span>
<div style="background:#fef3c7;padding:12px 20px;border-radius:8px;font-weight:600;color:#92400e">
1-2s 数据采集<br/>自动跳转预览页
</div>
<span style="font-size:18px;color:#94a3b8"></span>
<div style="background:#eff6ff;padding:12px 20px;border-radius:8px;font-weight:600;color:#2563eb">
预览页<br/>确认数据
</div>
<span style="font-size:18px;color:#94a3b8"></span>
<div style="background:#f0fdf4;padding:12px 20px;border-radius:8px;font-weight:600;color:#059669">
点"生成文档"<br/>下载 DOCX
</div>
</div>
</div>
</div>

View File

@ -0,0 +1 @@
{"type":"server-started","port":60240,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:60240","screen_dir":"/Users/niuniu/programs/docker/issue-ai/.superpowers/brainstorm/9392-1778134394/content","state_dir":"/Users/niuniu/programs/docker/issue-ai/.superpowers/brainstorm/9392-1778134394/state"}

View File

@ -1,5 +1,143 @@
# 变更日志 # 变更日志
## 2026-07-13
- [新增] 统一结单推送:从 worker 轮询 `fault_records` 上移到 API 层事件驱动PUT [id]/batch 状态跳变触发),新增 `notifyTicketResolved``notifyTicketCreated` 并列构成完整通知层
- [新增] 微信推送独立页面 `/settings/wechat`,与邮件监控完全解耦
- [新增] `useDirtyTracker` 共享 Hook`shared/ui/hooks/`),支持"修改后才显示保存按钮"模式,微信推送页每推送组独立保存、监控页统一 section 保存
- [优化] 结单推送月度统计改为 `close_time` 归组,周报/月报按 `isRealFault` 过滤,统一排除 无故障/误判
- [修复] 月报第四章跨月时长重复计算 bug`isOngoing=true` 时不再计入 `ipDurationMap`
- [修复] `audit_logs` 表缺少 `username` 列导致 7月2日至今所有审计日志静默写入失败补全 4 处 API 路由审计日志缺失
- [修复] `getMaskedConfig()` 直接 mutate webhook URL 导致测试发送 `••••••••` 至企业微信93000 invalid url
- [优化] 故障大类删除「其他」、故障小类删除「无故障」选项3 处表单统一)
- [安全] SSRF 加固:拒绝非规范数字型 IP 写法(整数/十六进制/八进制/短式),防绕过内网黑名单
- [重构] worker `checkRecoveryUpdates` 完全移除78 行),副作用迁移至 `resolution-side-effects.ts`
## 2026-07-02
- [优化] 企业微信推送消息格式全面对齐 zabbix-01 脚本10 项修复)
- 结单消息按故障类型区分OEM诊断/OEM维修/其他),补齐机架位置、故障类型、故障时间
- OEM维修结单消息新增本月故障次数和本月总处理时长
- 新故障通知OEM维修档位标题补全「基于月度内累计故障时长计算」
- 新故障通知OEM诊断字段顺序调整到期时间在故障信息之前
- 每小时提醒显示「X小时Y分」精度
- 静默期积压消息格式对齐动态剩余时间、机架位置、diag_30min/diag_hourly 区分
- [修复] `isInSilentPeriod()` / `isTimeToFlush()` 时区问题:新增 `getBeijingHour()` 使用 UTC+8 偏移,兼容 UTC 容器环境
- [修复] 先推送后更新数据库,防止推送失败导致结单通知永久丢失
- [修复] 共享库 `WeChatPusher` 错误消息不泄露 webhook URL
- [修复] `faultDetail` 截断 500 字符,防止消息超企业微信 API 限制
- [修复] `tierPenaltyMap` 提取为类级常量 `TIER_PENALTY_MAP`,消除 3 处重复定义
- [修复] `deploy-ai.sh` Docker 构建前临时替换 shared 符号链接为实际目录,构建后恢复
## 2026-06-30
- [新增] SSO 统一认证:集成 Authelia OIDC支持统一认证登录
- [新增] OIDC 登录页面:添加「统一认证登录」按钮,支持 LDAP 回退
- [新增] `src/lib/oidc.ts`OIDC 客户端配置PKCE + state + nonce
- [新增] `src/app/api/auth/login/oidc/route.ts`OIDC 登录端点
- [新增] `src/app/api/auth/callback/route.ts`OIDC 回调处理(含权限同步)
- [新增] `src/app/api/auth/logout/route.ts`:跨域登出(支持 domain 参数)
- [修复] NODE_TLS_REJECT_UNAUTHORIZED=0Authelia 使用自签名证书
- [修复] OIDC redirect_uri 回调重定向到 localhost添加 getBaseUrl() 函数
- [修复] token_endpoint_auth_method 配置缺失:添加 client_secret_basic
- [修复] middleware 路由匹配:`/api/auth/login` 改为前缀匹配 `/api/auth/login*`
- [优化] docker-compose.yml统一环境变量管理移除 env_file
- [优化] .env.example添加 OIDC 配置模板
## 2026-06-24
- [修复] SQLite 时区根源修复:重建表修改所有时间列 DEFAULT 为 `datetime('now', '+8 hours')`,涉及 7 个表 9 个列
## 2026-06-23审计日志
- [新增] 审计日志详情可视化字段变更用表格对比message/keys 格式分别用文本和列表展示
- [新增] 保留天数可编辑:默认隐藏保存按钮,修改值后蓝色高亮显示,保存成功/失败有颜色反馈
- [优化] 审计日志列表详情列:显示操作摘要(邮件监控/微信推送/配置修改等),不再显示"-"
- [优化] 表格列宽统一:时间/用户/操作/对象列防止换行,与 assets-ai 风格一致
- [修复] 监控设置页"已保存"提示重复SectionSaveButton 移除冗余消息渲染
- [修复] 导出文件名使用北京时间(修复凌晨时区偏移)
## 2026-06-23邮件监控集成
- [新增] 邮件监控系统:将 zabbix-01 上的 mail_monitor.py 完整迁移到 issue-aiNode.js 实现
- [新增] Background Worker独立进程运行entrypoint.shnode-cron 定时调度,与 Next.js Server 同时启动
- [新增] IMAP 邮件监控:自动连接邮箱、过滤故障邮件、解析 HTML 表格、提取故障信息
- [新增] 自动工单创建:从邮件解析结果自动创建工单,关联设备信息
- [新增] 微信 webhook 推送:故障通知、可用性档位提醒、工单结单提醒
- [新增] OEM 诊断提醒每小时提醒、30 分钟特别提醒、超时提醒
- [新增] OEM 维修提醒:每档前 1 小时提醒、每档超时提醒、全部超出提醒
- [新增] 静默期管理0:00-7:00 不发定时提醒7 点自动 flush 积压消息
- [新增] 可用性计算五档位1%/3%/5%/10%),月度累计故障时长
- [新增] 6 张数据库表settings、fault_records、reminder_state、processed_emails、pending_messages、monitor_logs
- [新增] 设置页面(/settings/monitor邮箱配置、邮件过滤、微信推送、运行参数
- [新增] 分模块保存按钮 + 高亮修改字段 + 错误详情可点击展开
- [新增] 运行状态面板:今日处理、累计、错误、进行中故障均可点击展开详情
- [新增] 监控 APIstatus/start/stop/trigger/settings/test-mail/test-wechatadmin 权限)
- [新增] 审计日志集成:所有监控操作记录到 audit_logs
- [新增] 数据迁移脚本:从 zabbix-01 迁移历史故障数据
- [新增] Docker 集成entrypoint.sh 包装脚本builder 阶段编译 Worker 模块
## 2026-05-18
- [新增] 用户详情页 — 点击用户名查看完整信息,支持编辑和键盘导航
- [优化] 用户管理 UI 统一Modal/Button 拆分为独立文件、删除改用 Modal 确认、TopBar 样式统一
- [调整] 用户列表页去创建时间列,编辑功能移到详情页,仅保留删除按钮
- [新增] LDAP 邮箱自动同步:密码登录/SSO 路径均从 LLDAP 同步 `mail` 到本地 `users.email`,增加 `/api/users/sync-emails` 批量同步接口
- [新增] 编辑用户弹窗增加启用/禁用状态切换、新密码二次确认
- [优化] 角色权限页面 UI 统一:角色 Badge 按角色区分颜色、增加保存 loading、checkbox dark mode 样式
- [优化] API Key 页面 UI 统一:成功提示颜色对齐、取消按钮 ghost 统一
- [优化] 全站字体大小和颜色统一:列表页/详情页 `text-sm` 一致、dark mode 颜色补全
- [修复] 用户管理"最后登录"时间不动态更新SSO 免登录、本地 JWT 会话验证路径现在也会更新 `last_login_at`
## 2026-05-15
- [修复] middleware 移除 `better-sqlite3` 依赖,修复 Edge Runtime 不支持 Node.js 原生模块的编译报错(`The edge runtime does not support Node.js 'fs' module`
- [优化] deploy-ai.sh 连通性检查增加重试机制3次/10s间隔应对同时部署两个站点时目标容器暂不可达
## 2026-05-14
- [新增] 权限系统重构:工单细粒度权限(`tickets:create` 手动建单、`tickets:import` 导入、`tickets:export` 导出),`tickets:write` 缩窄为编辑/删除
- [新增] 报告三层权限:`reports:read`(查看列表+预览)、`reports:download`(下载)、`reports:create`(新建+生成文档+删除)
- [新增] `/api/auth/me` 返回 `permissions` 字段,前端根据权限数组控制按钮和导航显隐
- [新增] 角色管理 UI 权限项从 8 个扩展到 14 个(含 `api-keys:read/write`
- [修复] 角色管理页面admin/operator/viewer 标记为"内置"不可删除但可编辑admin 在 API 层禁止删除
- [修复] 报告 API 路由全部补全权限检查(此前列表/预览/下载/删除均无权限检查)
- [修复] 导出工单 API 新增 `tickets:export` 权限检查(此前仅验证登录态)
- [修复] POST 创建报告权限从错误的 `reports:read` 修正为 `reports:create`
- [修复] `reports:write` 全面替换为 `reports:create`
- [修复] 种子数据迁移逻辑不再每次启动覆盖用户自定义权限,仅首次安装时创建默认权限
- [修复] SSO 新用户免登录失败:根页面 `getCurrentUser()` 改为直接 `redirect('/dashboard')`,由中间件统一处理共享 JWT 认证(与 assets-ai 行为一致)
- [修复] 手动建单输入业务 IP 按回车无法查询节点名称和设备序列号assets-ai `api_keys` 表中缺少 issue-ai 使用的 API Key服务端调用返回 401且前端静默吞错无提示
- [优化] 中间件 API Key 验证统一为 ALLOWED_API_KEYS快速路径→ api_keys 数据库表(回退)两级验证;无效 key 不再被放行,中间件层直接返回 401安全加固与 assets-ai 行为一致)
- [新增] 部署后自动验证 issue→assets API 连通性(`deploy-ai.sh`API Key 不匹配时部署立即报错退出
- [修复] `next.config.ts` 添加 `ldapts``serverExternalPackages`,确保 Next.js standalone 构建包含 LLDAP 客户端模块,避免 `ldapUserExists()` 因模块缺失失败导致 SSO 自动创建用户静默中断
- [调整] 全局证书切换Cloudflare Origin CA → Let's Encrypt`/etc/letsencrypt/live/www.tlyq.ai/`),覆盖全部 7 个子域名nginx 8 个站点配置同步更新
## 2026-05-12
- [部署] 云端 JWT 密钥统一为 oa-shared-jwt-secret-tlyq-2026三站点密钥一致
- [部署] docker-compose 移除 AUTHELIA_URL添加 LDAP/COOKIE/INTERNAL_API_KEY 环境变量
- [部署] LLDAP admin 密码更新为 3Vm!Y!@RCiPs
- [部署] nginx 移除 auth_request恢复纯反向代理改为运行时 DNS 解析
- [新增] `src/lib/db-schema.ts`users 表新增 last_login_at / last_active_at 列
- [新增] `src/lib/auth.ts`getCurrentUser() 更新 last_active_at
- [新增] `src/app/api/auth/login/route.ts`:登录时更新 last_login_at 和 last_active_at
## 2026-05-11
- [新增] `src/lib/ldap.ts``ldapUserExists()` 函数,检查 LLDAP 中用户是否存在admin bind 搜索,不可达时容错放行)
- [新增] `src/lib/jwt-shared.ts`:共享 JWT 签发/验证(`tlyq_session` cookieHS256与 OA/assets 共用密钥)
- [调整] `src/lib/auth.ts``getCurrentUser()` 优先 `tlyq_session`,加入 LLDAP 存在性检查,用户被删除后自动清除 cookie 踢出
- [调整] `src/app/api/auth/login/route.ts`LDAP 优先认证 + 本地密码缓存回退 + localadmin 应急用户直连
- [调整] `src/app/api/auth/logout/route.ts`:同时清除 `session_issue``tlyq_session`
- [调整] `src/app/api/auth/me/route.ts`:移除 SSO header 路径,改用 `getCurrentUser()` 统一获取
- [调整] `src/middleware.ts`:优先 `tlyq_session` → 回退 `session_issue`,移除 SSO 代理路径,放行 `/api/internal/`
- [新增] `src/app/api/internal/roles/route.ts`:内部 API返回站点可用角色列表INTERNAL_API_KEY 鉴权)
- [新增] `src/app/api/users/[id]/route.ts`admin/localadmin 用户禁止删除和修改角色
- [修复] `src/components/tickets/TicketList.tsx`已办工单点击业务IP/节点名称跳转到待办页面,改用 `usePathname()` 保持当前页面并正确筛选
- [调整] `src/app/(app)/settings/users/page.tsx`admin/localadmin 用户隐藏删除按钮,编辑时角色字段显示为只读
- [新增] `src/lib/db-schema.ts`:预置 localadmin 应急用户admin 角色,纯本地 BCrypt 认证)
## 2026-05-07 ## 2026-05-07
- [新增] 月报跨月进行中工单支持:第一章折线图覆盖未结单离线天数,第二章标注"处理中",第三章显示"进行中"/"—",第四章标注"仅计本月部分" - [新增] 月报跨月进行中工单支持:第一章折线图覆盖未结单离线天数,第二章标注"处理中",第三章显示"进行中"/"—",第四章标注"仅计本月部分"

109
CLAUDE.md
View File

@ -13,7 +13,7 @@ issue-ai 是基于 Next.js + SQLite 的工单跟踪管理系统,部署在腾
| 站点域名 | `issue.tlyq.ai` | | 站点域名 | `issue.tlyq.ai` |
| 服务器 | txjpIP: 43.133.38.210 | | 服务器 | txjpIP: 43.133.38.210 |
| 代码路径 | `/root/docker/issue-ai/` | | 代码路径 | `/root/docker/issue-ai/` |
| 本地端口 | 5176 | | 本地端口 | 6176 |
| 容器名 | `issue-ai` | | 容器名 | `issue-ai` |
| 数据库 | SQLite`data/issue.db` | | 数据库 | SQLite`data/issue.db` |
| 报告存储 | `reports/` 目录(环境变量 `REPORTS_DIR` 可覆盖) | | 报告存储 | `reports/` 目录(环境变量 `REPORTS_DIR` 可覆盖) |
@ -48,6 +48,19 @@ npm run import # 导入工单
| `src/lib/docx-export.ts` | Word 文档生成 | | `src/lib/docx-export.ts` | Word 文档生成 |
| `src/lib/excel.ts` | Excel 解析与导出 | | `src/lib/excel.ts` | Excel 解析与导出 |
| `src/lib/pdf.ts` | PDF 生成puppeteer | | `src/lib/pdf.ts` | PDF 生成puppeteer |
| `src/lib/audit.ts` | 审计日志writeAuditLog、diffObjects、getClientIP |
| `src/lib/monitor/types.ts` | 邮件监控:类型定义 |
| `src/lib/monitor/settings-manager.ts` | 邮件监控配置管理AES 加密) |
| `src/lib/monitor/worker.ts` | 邮件监控Worker 主类cron 调度、主循环) |
| `src/lib/monitor/mail-monitor.ts` | 邮件监控IMAP 连接、邮件过滤、解析 |
| `src/lib/monitor/ticket-processor.ts` | 邮件监控:工单创建、故障记录 |
| `src/lib/monitor/wechat-pusher.ts` | 邮件监控:微信 webhook 推送 |
| `src/lib/monitor/ticket-notifier.ts` | 邮件监控:建单/结单推送(`notifyTicketCreated` + `notifyTicketResolved` |
| `src/lib/monitor/resolution-side-effects.ts` | 邮件监控:结单副作用(回写 fault_records + 清理 reminder_state |
| `src/lib/monitor/availability-engine.ts` | 邮件监控:可用性档位计算 |
| `src/lib/monitor/reminder-engine.ts` | 邮件监控OEM 诊断/维修提醒 |
| `scripts/monitor-worker.ts` | 邮件监控Worker 独立入口脚本 |
| `scripts/entrypoint.sh` | Docker 包装脚本(同时启动 Server + Worker |
| `src/types/ticket.ts` | TicketCreateInput / TicketUpdateInput | | `src/types/ticket.ts` | TicketCreateInput / TicketUpdateInput |
| `src/types/report.ts` | ReportType / ReportData | | `src/types/report.ts` | ReportType / ReportData |
@ -66,6 +79,12 @@ npm run import # 导入工单
| `ticket_steps` | 工单时间线(关联 ticket_id | | `ticket_steps` | 工单时间线(关联 ticket_id |
| `reports` | 报告记录 | | `reports` | 报告记录 |
| `audit_logs` | 审计日志 | | `audit_logs` | 审计日志 |
| `settings` | 键值对配置(含邮件监控配置) |
| `fault_records` | 邮件监控:故障记录 |
| `reminder_state` | 邮件监控:提醒状态 |
| `processed_emails` | 邮件监控:已处理邮件去重 |
| `pending_messages` | 邮件监控:静默期积压队列 |
| `monitor_logs` | 邮件监控:运行日志 |
### tickets 表核心字段 ### tickets 表核心字段
@ -99,11 +118,18 @@ npm run import # 导入工单
### 认证 ### 认证
登录逻辑v2.1LDAP 优先 + 本地密码缓存回退 + localadmin 应急用户。
登录成功签发两个 cookie`session_issue`(本地 JWT7 天)+ `tlyq_session`(共享 JWT7 天domain=.tlyq.ai
中间件优先检查 `tlyq_session`,回退 `session_issue`。`getCurrentUser()` 每次验证时检查 LLDAP 用户是否存在(已删除则清除 cookie 踢出)。
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|------|------|------| |------|------|------|
| POST | `/api/auth/login` | 登录username + password → JWT cookie | | POST | `/api/auth/login` | 登录(LDAP 优先 + 本地回退 |
| POST | `/api/auth/logout` | 登出 | | POST | `/api/auth/logout` | 登出(清除两个 cookie |
| GET | `/api/auth/me` | 当前用户信息 | | GET | `/api/auth/me` | 当前用户信息 |
| GET | `/api/internal/roles` | 内部 API返回角色列表x-internal-key 鉴权) |
| GET | `/api/internal/users` | 内部 API返回用户列表x-internal-key 鉴权) |
| POST | `/api/internal/users` | 内部 APIOA 同步用户角色x-internal-key 鉴权) |
### 工单 ### 工单
@ -131,6 +157,28 @@ npm run import # 导入工单
| GET/POST | `/api/roles` | 角色列表/创建 | | GET/POST | `/api/roles` | 角色列表/创建 |
| GET/PUT/DELETE | `/api/roles/[id]` | 单个角色操作 | | GET/PUT/DELETE | `/api/roles/[id]` | 单个角色操作 |
### 邮件监控admin 权限)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/monitor/status` | 运行状态 + 健康检查 + 详情 |
| POST | `/api/monitor/start` | 启用监控 |
| POST | `/api/monitor/stop` | 停用监控 |
| POST | `/api/monitor/trigger` | 手动触发检查30 秒冷却) |
| GET | `/api/monitor/settings` | 获取配置(密码脱敏) |
| PUT | `/api/monitor/settings` | 更新配置(支持分模块保存) |
| POST | `/api/monitor/test-mail` | 测试 IMAP 连接 |
| POST | `/api/monitor/test-wechat` | 测试微信 webhook |
| 前端页面 | `/settings/wechat` | webhook 独立管理页(微信推送配置) |
---
## 认证机制
- **Web UIv2.1**`middleware.ts` 优先检查 `tlyq_session`(共享 JWTOA 统一签发)→ 回退 `session_issue`(本地 JWT。`getCurrentUser()` 每次请求时检查 LLDAP 用户是否存在,已删除则清除 cookie 踢出
- **localadmin**:纯本地 BCrypt 认证,不依赖 LLDAP用于 LLDAP 故障时应急登录DB 预置admin 角色)
- **API Keyv2.2**`middleware.ts` 检查 `ALLOWED_API_KEYS` 环境变量(逗号分隔明文 key无效 key 在中间件层直接返回 401。注意middleware 运行在 Edge Runtime不能使用 `better-sqlite3`DB 级别的 key 验证由 route handler 中的 `auth.ts verifyApiKey()` 进行(查 `api_keys` 表 SHA-256 hash。外部系统调用本系统 API 时key 必须注册在 `ALLOWED_API_KEYS`Web UI 创建的 key 还需同步到环境变量,重启容器后生效)
--- ---
## 环境配置 ## 环境配置
@ -139,13 +187,18 @@ npm run import # 导入工单
| 环境变量 | 本地开发 | 云服务器txjp | 说明 | | 环境变量 | 本地开发 | 云服务器txjp | 说明 |
|---------|---------|----------------|------| |---------|---------|----------------|------|
| `ASSETS_API_URL` | `http://localhost:5177/api` | `http://assets-ai:3000/api` | 调用 assets API 地址 | | `ASSETS_API_URL` | `http://localhost:6177/api` | `http://assets-ai:3000/api` | 调用 assets API 地址 |
| `ASSETS_API_KEY` | 本地 assets-ai 生成 | 云上 assets-ai 生成 | **每个环境独立,不可跨环境使用** | | `ASSETS_API_KEY` | 本地 assets-ai 生成 | 云上 assets-ai 生成 | **每个环境独立,不可跨环境使用** |
| `NEXT_PUBLIC_ASSETS_URL` | `http://localhost:5177` | `https://assets.tlyq.ai` | 前端跳转链接(构建时内嵌) | | `NEXT_PUBLIC_ASSETS_URL` | `http://localhost:6177` | `https://assets.tlyq.ai` | 前端跳转链接(构建时内嵌) |
| `ALLOWED_API_KEYS` | 本地 issue-ai 生成的 Key | 云上 issue-ai 生成的 Key | 允许外部系统调本系统的 Key逗号分隔 | | `ALLOWED_API_KEYS` | 本地 issue-ai 生成的 Key | 云上 issue-ai 生成的 Key | 允许外部系统调本系统的 Key逗号分隔 |
| `JWT_SECRET` | `dev-secret-key-local` | `${ISSUE_JWT_SECRET}` | 本地两系统需一致(同 localhost 域) | | `JWT_SECRET` | `dev-secret-key-local` | `${ISSUE_JWT_SECRET}` | 本地两系统需一致(同 localhost 域) |
| `DATABASE_PATH` | `./data/issue.db` | `/app/data/issue.db` | Docker volume 挂载 | | `DATABASE_PATH` | `./data/issue.db` | `/app/data/issue.db` | Docker volume 挂载 |
| Cookie 名 | `session_issue` | `session_issue` | 本地用不同名防 localhost 域冲突 | | Cookie 名 | `session_issue` | `session_issue` | 本地用不同名防 localhost 域冲突 |
| `AUTHELIA_URL` | `https://sso.tlyq.ai` | 同 | Authelia 地址 |
| `OIDC_CLIENT_ID` | `issue-oidc` | 同 | OIDC 客户端 ID |
| `OIDC_CLIENT_SECRET` | 本地生成的哈希值 | 服务器生成的哈希值 | OIDC 客户端密钥 |
| `OIDC_REDIRECT_URI` | `http://localhost:6176/api/auth/callback` | `https://issue.tlyq.ai/api/auth/callback` | OIDC 回调地址 |
| `NODE_TLS_REJECT_UNAUTHORIZED` | 不需要 | `0`Authelia 使用自签名证书) | TLS 证书验证 |
### `.env.local` 示例 ### `.env.local` 示例
@ -154,10 +207,14 @@ DATABASE_PATH=./data/issue.db
JWT_SECRET=dev-secret-key-local JWT_SECRET=dev-secret-key-local
ADMIN_PASSWORD=admin123 ADMIN_PASSWORD=admin123
NODE_ENV=development NODE_ENV=development
ASSETS_API_URL=http://localhost:5177/api ASSETS_API_URL=http://localhost:6177/api
ASSETS_API_KEY=ak_<32字节十六进制> ASSETS_API_KEY=ak_<32字节十六进制>
ALLOWED_API_KEYS=ak_<32字节十六进制> ALLOWED_API_KEYS=ak_<32字节十六进制>
NEXT_PUBLIC_ASSETS_URL=http://localhost:5177 NEXT_PUBLIC_ASSETS_URL=http://localhost:6177
AUTHELIA_URL=https://sso.tlyq.ai
OIDC_CLIENT_ID=issue-oidc
OIDC_CLIENT_SECRET=<本地生成的哈希值>
OIDC_REDIRECT_URI=http://localhost:6176/api/auth/callback
``` ```
--- ---
@ -176,10 +233,14 @@ assets-ai ──→ GET {ISSUE_API_URL}/tickets/by-asset?ip=xxx (Authori
Key 格式:`ak_<32位十六进制>`,认证头:`Authorization: Bearer <key>`。每个环境独立创建,互不通用。 Key 格式:`ak_<32位十六进制>`,认证头:`Authorization: Bearer <key>`。每个环境独立创建,互不通用。
**issue → assets 方向**:在 assets-ai `/settings/api-keys` 创建 Key → 写入 issue-ai 的 `ASSETS_API_KEY` **验证策略v2.2**:两边中间件均采用 `ALLOWED_API_KEYS` 环境变量 → `api_keys` 数据库表两级验证。Key 注册在任意一处即可通过认证,推荐走 Web UI 创建(写入 `api_keys` 表,支持权限和追踪)。
**issue → assets 方向**:在 assets-ai `/settings/api-keys` 创建 Key → 写入 issue-ai 的 `ASSETS_API_KEY``.env` 或 `docker-compose.yml` 环境变量)
**assets → issue 方向**:在 issue-ai `/settings/api-keys` 创建 Key → 写入 assets-ai 的 `ISSUE_API_KEY` + issue-ai 的 `ALLOWED_API_KEYS` **assets → issue 方向**:在 issue-ai `/settings/api-keys` 创建 Key → 写入 assets-ai 的 `ISSUE_API_KEY` + issue-ai 的 `ALLOWED_API_KEYS`
> **部署验证**`deploy-ai.sh` 部署 issue 站点后会自动验证 issue→assets API 连通性(使用配置的 `ASSETS_API_KEY` 调 assets API若返回 401 则部署失败退出,防止 API Key 配置遗漏上线。
--- ---
## Docker 部署 ## Docker 部署
@ -225,7 +286,37 @@ NEXT_PUBLIC_ASSETS_URL=https://assets.tlyq.ai
- **新增 API**:在 `src/app/api/` 下创建路由 → 顶部调用 `initDatabase()``getCurrentUser()` 验证 → `hasPermission()` 校验 - **新增 API**:在 `src/app/api/` 下创建路由 → 顶部调用 `initDatabase()``getCurrentUser()` 验证 → `hasPermission()` 校验
- **新增页面**:在 `src/app/(app)/` 下创建 → 布局由 `(app)/layout.tsx` 提供 - **新增页面**:在 `src/app/(app)/` 下创建 → 布局由 `(app)/layout.tsx` 提供
- **权限格式**`resource:action`,如 `hasPermission(user, 'tickets:write')` - **权限格式**`resource:action`,如 `hasPermission(user, 'tickets:write')`
- **日期处理**:禁止使用 `Date.toISOString()` 格式化本地日期。`toISOString()` 返回 UTC 时间在中国时区UTC+8`new Date('2026-04-01T00:00:00').toISOString()` 会返回 `"2026-03-31T16:00:00.000Z"`,日期偏移一天。应使用本地时间方法拼接:`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}` - **审计日志**:所有写操作 APIPOST/PUT/DELETE必须添加审计日志使用 `writeAuditLog()` 函数:
```typescript
import { writeAuditLog, getClientIP } from '@/lib/audit'
// 在操作成功后调用
writeAuditLog({
userId: user.id, // issue-ai 使用 user.id
apiKeyId: null, // 或 apiKeyInfo?.id
action: 'create' | 'update' | 'delete' | 'batch_update' | 'import' | 'export' | 'generate',
entityType: 'ticket' | 'report' | 'user' | 'role' | 'api_key' | 'auth',
entityId: ticketId, // 可选
details: { created: { ticket_no, device_ip } }, // 或 { changes: diffObjects(old, new) } 或 { deleted: { ... } }
ipAddress: getClientIP(request)
})
```
- **日期处理(时区规范)**:整个系统统一使用 UTC+8北京时间。必须遵守
1. **JavaScript/TypeScript**:禁止使用 `Date.toISOString()` 格式化本地日期。`toISOString()` 返回 UTC 时间在中国时区UTC+8`new Date('2026-04-01T00:00:00').toISOString()` 会返回 `"2026-03-31T16:00:00.000Z"`,日期偏移一天。应使用本地时间方法拼接:`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
2. **SQLite**:所有 `datetime('now')` 必须写成 `datetime('now', '+8 hours')`,包括 UPDATE/SET 语句、以及查询条件中的时间比较。禁止使用不含时区偏移的 `datetime('now')`
3. **INSERT 时间列**:数据库表的 DEFAULT 已统一为 `datetime('now', '+8 hours')`INSERT 时可省略时间列。如需显式设置,使用 `datetime('now', '+8 hours')`
---
## Git Tag 规范
使用日期版本号 `vYYYY.MM.DD`(如 `v2026.05.18`)。提交后打 tag 再推送:
```bash
git tag v$(date +%Y.%m.%d) && git push origin main && git push origin v$(date +%Y.%m.%d)
```
同一天多次提交只打一个 tag。详见根目录 `CLAUDE.md`
--- ---

View File

@ -7,6 +7,8 @@ RUN --mount=type=cache,target=/root/.npm,id=issue-npm \
npm ci npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
# 编译 Worker 入口脚本 + monitor 模块 + db 模块为 JS使用专用 tsconfig 解析 @/ 路径别名)
RUN npx tsc -p tsconfig.worker.json && npx tsc-alias -p tsconfig.worker.json
# runner 阶段:使用 Debianglibc以支持 better-sqlite3 等 glibc 原生模块 # runner 阶段:使用 Debianglibc以支持 better-sqlite3 等 glibc 原生模块
FROM node:20-slim AS runner FROM node:20-slim AS runner
@ -31,12 +33,25 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libasound2 \ libasound2 \
libpango-1.0-0 \ libpango-1.0-0 \
libcairo2 \ libcairo2 \
sqlite3 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/package.json /app/package-lock.json ./ COPY --from=builder /app/package.json /app/package-lock.json ./
RUN npm install --omit=dev
COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/standalone ./
RUN npm install --omit=dev && \
npm rebuild better-sqlite3 && \
rm -rf node_modules/@img/sharp-linuxmusl-x64 node_modules/@img/sharp-libvips-linuxmusl-x64 \
node_modules/@next/swc-linux-x64-musl
COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
# 复制编译后的 Worker 文件(非 .ts 源码)
COPY --from=builder /app/.next/standalone/scripts/monitor-worker.js /app/scripts/
COPY --from=builder /app/.next/standalone/src/lib/monitor /app/src/lib/monitor
COPY --from=builder /app/.next/standalone/src/lib/db.js /app/src/lib/
COPY --from=builder /app/.next/standalone/src/lib/db-schema.js /app/src/lib/
COPY --from=builder /app/.next/standalone/src/lib/assets-client.js /app/src/lib/
RUN mkdir -p /app/data /app/uploads /app/reports RUN mkdir -p /app/data /app/uploads /app/reports
EXPOSE 3000 EXPOSE 3000
CMD ["node", "server.js"] # 使用 entrypoint.sh 同时启动 Next.js Server 和 Monitor Worker
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
CMD ["/app/entrypoint.sh"]

View File

@ -0,0 +1,47 @@
// __tests__/monitor/availability-engine.test.ts
import { AvailabilityEngine } from '../../src/lib/monitor/availability-engine'
// Mock getDb
jest.mock('../../src/lib/db', () => ({
getDb: () => ({
prepare: () => ({
get: () => ({ total: 0 }),
all: () => [],
run: () => {},
}),
}),
}))
describe('AvailabilityEngine', () => {
const engine = new AvailabilityEngine()
it('should calculate days in month correctly', () => {
expect(engine.getDaysInMonth('2026-03')).toBe(31)
expect(engine.getDaysInMonth('2026-02')).toBe(28)
expect(engine.getDaysInMonth('2026-04')).toBe(30)
})
it('should calculate OEM diag deadline (first tier only)', () => {
const deadline = engine.calculateOemDiagDeadline('2026-03', '2026-03-10 16:44:01')
expect(deadline).toBeTruthy()
// 31 days = 2678400 seconds, 1% = 26784 seconds ≈ 7.44 hours
const fault = new Date('2026-03-10T16:44:01+08:00')
const deadlineStr = deadline as string
const deadlineDate = new Date(deadlineStr.replace(' ', 'T') + '+08:00')
const diffHours = (deadlineDate.getTime() - fault.getTime()) / (1000 * 3600)
expect(diffHours).toBeCloseTo(7.44, 1)
})
it('should calculate tier deadlines', () => {
const deadlines = engine.calculateTierDeadlines('2026-03', 'SN123', '2026-03-10 16:44:01')
expect(deadlines['第一档']).toBeTruthy()
expect(deadlines['第一档'].deadline).not.toBe('已超出')
expect(deadlines['第一档'].hours).toBeGreaterThan(0)
})
it('should format deadline in UTC+8', () => {
const deadline = engine.calculateOemDiagDeadline('2026-03', '2026-03-10 16:44:01')
// 应该是 UTC+8 格式,不是 UTC
expect(deadline).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/)
})
})

15
config/.env.prod Normal file
View File

@ -0,0 +1,15 @@
# issue-ai 生产环境配置(模板)
DATABASE_PATH=/app/data/issue.db
JWT_SECRET=__JWT_SECRET__
COOKIE_DOMAIN=.tlyq.ai
NODE_ENV=production
NODE_TLS_REJECT_UNAUTHORIZED=0
LDAP_URL=ldap://lldap:3890
LDAP_BASE_DN=dc=tlyq,dc=ai
LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
AUTHELIA_URL=https://sso.tlyq.ai
OIDC_CLIENT_ID=issue-oidc
OIDC_CLIENT_SECRET=__OIDC_CLIENT_SECRET__
OIDC_REDIRECT_URI=https://issue.tlyq.ai/api/auth/callback
ASSETS_API_URL=http://assets-ai:3000/api
NEXT_PUBLIC_ASSETS_URL=https://assets.tlyq.ai

View File

@ -3,22 +3,41 @@ services:
build: . build: .
container_name: issue-ai container_name: issue-ai
ports: ports:
- "5176:3000" - "6176:3000"
volumes: volumes:
- issue-data:/app/data - issue-data:/app/data
- issue-uploads:/app/uploads - issue-uploads:/app/uploads
- issue-reports:/app/reports - issue-reports:/app/reports
# .next 目录从主机挂载npm run build 后直接生效,无需重建镜像 # .next 目录从主机挂载npm run build 后直接生效,无需重建镜像
- ./.next:/app/.next - ./.next:/app/.next
# 运行时从 LLDAP 容器动态读取 admin 密码(已迁移至环境变量注入)
- /var/run/docker.sock:/var/run/docker.sock:ro
environment: environment:
- DATABASE_PATH=/app/data/issue.db - DATABASE_PATH=/app/data/issue.db
- JWT_SECRET=${ISSUE_JWT_SECRET:-change-me-in-production} - JWT_SECRET=${JWT_SECRET:-oa-shared-jwt-secret-tlyq-2026}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
- LLDAP_ADMIN_PASSWORD=${LLDAP_ADMIN_PASSWORD}
- ASSETS_API_URL=${ASSETS_API_URL:-https://assets.tlyq.ai/api} - ASSETS_API_URL=${ASSETS_API_URL:-https://assets.tlyq.ai/api}
- ASSETS_API_KEY=${ASSETS_API_KEY} - ASSETS_API_KEY=${ASSETS_API_KEY}
- ALLOWED_API_KEYS=${ALLOWED_API_KEYS} - ALLOWED_API_KEYS=${ALLOWED_API_KEYS}
- NODE_ENV=production - NODE_ENV=production
- COOKIE_DOMAIN=.tlyq.ai
- AUTHELIA_URL=${AUTHELIA_URL:-https://sso.tlyq.ai}
- LDAP_URL=ldap://lldap:3890
- LDAP_BASE_DN=dc=tlyq,dc=ai
- LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
- NODE_TLS_REJECT_UNAUTHORIZED=0
# OIDC 配置
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-issue-oidc}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-https://issue.tlyq.ai/api/auth/callback}
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
restart: unless-stopped restart: unless-stopped
networks: networks:
- webnet - webnet

24
entrypoint.sh Normal file
View File

@ -0,0 +1,24 @@
#!/bin/bash
# entrypoint.sh — 同时启动 Next.js Server 和 Monitor Worker
HOSTNAME=0.0.0.0 node /app/server.js &
SERVER_PID=$!
cd /app && node scripts/monitor-worker.js &
WORKER_PID=$!
cleanup() {
echo "Stopping Server (PID=$SERVER_PID) and Worker (PID=$WORKER_PID)..."
kill $SERVER_PID 2>/dev/null
kill $WORKER_PID 2>/dev/null
wait $SERVER_PID 2>/dev/null
wait $WORKER_PID 2>/dev/null
echo "Shutdown complete."
}
trap cleanup SIGTERM SIGINT
# 等待任一进程退出(需要 Bash 4.3+
wait -n $SERVER_PID $WORKER_PID
EXIT_CODE=$?
cleanup
exit $EXIT_CODE

View File

@ -4,7 +4,9 @@ const nextConfig: NextConfig = {
images: { unoptimized: true }, images: { unoptimized: true },
eslint: { ignoreDuringBuilds: true }, eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true }, typescript: { ignoreBuildErrors: true },
serverExternalPackages: ['better-sqlite3'], // better-sqlite3: 原生模块,必须 external
// ldapts: SSO 自动创建用户依赖 LLDAP 验证,缺少则新用户无法免登录进入系统
serverExternalPackages: ['better-sqlite3', 'ldapts'],
// 确保 fs.readFileSync 加载的文件也被追踪到 standalone 输出中 // 确保 fs.readFileSync 加载的文件也被追踪到 standalone 输出中
// 防止 Docker 镜像中 npm install --omit=dev 漏装时缺失依赖 // 防止 Docker 镜像中 npm install --omit=dev 漏装时缺失依赖
outputFileTracingIncludes: { outputFileTracingIncludes: {

BIN
oa-cloud-fixed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
oa-cloud-login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
oa-login-current.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
oa-login-page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

1402
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "NODE_OPTIONS='--max-old-space-size=2048' next dev --port 5176", "dev": "NODE_OPTIONS='--max-old-space-size=2048' next dev --port 6176",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
@ -13,13 +13,20 @@
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^11.8.1", "better-sqlite3": "^11.8.1",
"cheerio": "^1.2.0",
"cookie": "^1.0.2", "cookie": "^1.0.2",
"docx": "^9.1.1", "docx": "^9.1.1",
"echarts": "^5.5.0", "echarts": "^5.5.0",
"imap-simple": "^5.1.0",
"imapflow": "^1.4.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"ldapts": "^8.1.7",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"mailparser": "^3.9.11",
"next": "^15.1.0", "next": "^15.1.0",
"node-cron": "^4.5.0",
"openid-client": "^5.7.1",
"puppeteer": "^23.0.0", "puppeteer": "^23.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@ -37,6 +44,7 @@
"eslint": "^10.2.1", "eslint": "^10.2.1",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"tsc-alias": "^1.8.10",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }

0
public/.gitkeep Normal file
View File

BIN
report-54-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
report-preview-53.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@ -0,0 +1,53 @@
// scripts/migrate-monitor-data.ts
// 从 zabbix-01 拉取历史数据并迁移到 issue-ai SQLite
// 用法: ssh zabbix-01 "cat /opt/scripts/.../server_availability_data.json" | tsx scripts/migrate-monitor-data.ts
import { execSync } from 'child_process'
import { getDb } from '../src/lib/db'
import { initDatabase } from '../src/lib/db-schema'
initDatabase()
const db = getDb()
interface FaultRecord { fault_time: string; fault_type: string; order_number: string; recovery_time: string | null; duration: number | null; status: string }
interface ServerData { server_ip: string; fault_records: FaultRecord[]; total_fault_duration: number; ongoing_faults: string[] }
// 拉取 server_availability_data.json
console.log('从 zabbix-01 拉取数据...')
const availData = JSON.parse(execSync('ssh zabbix-01 "cat /opt/scripts/mail_monitor_wechat/data/server_availability_data.json"', { encoding: 'utf-8' }))
let migrated = 0, matched = 0, skipped = 0, errors = 0
for (const [monthKey, servers] of Object.entries(availData) as [string, Record<string, ServerData>][]) {
for (const [sn, serverData] of Object.entries(servers)) {
for (const record of serverData.fault_records) {
try {
// 检查工单是否存在
if (record.order_number) {
const ticket = db.prepare('SELECT id FROM tickets WHERE id = ?').get(parseInt(record.order_number))
if (!ticket) { skipped++; continue }
matched++
}
// 检查是否已存在
const existing = db.prepare('SELECT id FROM fault_records WHERE server_sn = ? AND fault_time = ?').get(sn, record.fault_time)
if (existing) { skipped++; continue }
const status = record.status === 'resolved' ? 'resolved' : 'ongoing'
db.prepare(`
INSERT INTO fault_records (server_sn, server_ip, order_number, fault_type, fault_time, recovery_time, duration_seconds, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(sn, serverData.server_ip, record.order_number, record.fault_type, record.fault_time, record.recovery_time, record.duration, status)
migrated++
} catch (e) {
errors++
console.error(`迁移失败: ${sn} - ${record.fault_time}: ${e}`)
}
}
}
}
console.log(`\n迁移完成:`)
console.log(` 成功: ${migrated}`)
console.log(` 匹配工单: ${matched}`)
console.log(` 跳过: ${skipped}`)
console.log(` 错误: ${errors}`)

46
scripts/monitor-worker.ts Normal file
View File

@ -0,0 +1,46 @@
// scripts/monitor-worker.ts
const path = require('path')
const dbPath = process.env.DATABASE_PATH || path.join(process.cwd(), 'data', 'issue.db')
process.env.DATABASE_PATH = dbPath
const { getDb } = require('../src/lib/db')
const { initDatabase } = require('../src/lib/db-schema')
const { BackgroundWorker } = require('../src/lib/monitor/worker')
const { setSetting, getSetting } = require('../src/lib/monitor/settings-manager')
initDatabase()
const db = getDb()
// 生成加密密钥(首次启动)
if (!getSetting('monitor.encryption_key')) {
const crypto = require('crypto')
const newKey = crypto.randomBytes(32).toString('hex')
setSetting('monitor.encryption_key', newKey, 'security')
console.log('[Worker] 已生成加密密钥')
}
const worker = new BackgroundWorker()
if (getSetting('monitor.enabled') === 'true') {
worker.start()
console.log('[Worker] 邮件监控已启动')
} else {
console.log('[Worker] 邮件监控未启用,等待 API 启用')
}
// 保持进程存活,定期检查监控状态是否变更
const CHECK_INTERVAL = 30_000 // 30秒
setInterval(() => {
if (getSetting('monitor.enabled') === 'true' && !worker.cronJob) {
worker.start()
console.log('[Worker] 检测到监控已启用,自动启动')
} else if (getSetting('monitor.enabled') !== 'true' && worker.cronJob) {
worker.stop()
console.log('[Worker] 检测到监控已禁用,自动停止')
}
}, CHECK_INTERVAL)
process.on('SIGTERM', () => {
console.log('[Worker] 收到 SIGTERM正在停止...')
worker.stop()
process.exit(0)
})

1
shared Symbolic link
View File

@ -0,0 +1 @@
../shared

View File

@ -0,0 +1,418 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { Card, Button } from '@/components/ui'
import { Download, ArrowLeft, FileText, RefreshCw } from 'lucide-react'
import Link from 'next/link'
interface Report {
id: number
report_type: string
period_start: string | null
period_end: string | null
format: string
status: string
created_at: string
file_name?: string | null
file_path?: string | null
error_message?: string | null
metadata?: string | null
}
const typeLabel: Record<string, string> = {
weekly: '周报',
monthly: '月报',
}
export default function ReportDetailPage() {
const params = useParams()
const router = useRouter()
const [report, setReport] = useState<Report | null>(null)
const [reportData, setReportData] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [permissions, setPermissions] = useState<string[]>([])
useEffect(() => {
fetch('/api/auth/me')
.then(r => r.json())
.then(u => { if (u.user?.permissions) setPermissions(u.user.permissions) })
.catch(() => {})
}, [])
const fetchReport = () => {
fetch(`/api/reports/${params.id}`)
.then(r => r.json())
.then(d => {
if (d.report) setReport(d.report)
if (d.reportData) setReportData(d.reportData)
})
.catch(() => {})
.finally(() => setLoading(false))
}
useEffect(() => { fetchReport() }, [params.id])
const handleGenerate = async () => {
if (!report) return
setGenerating(true)
try {
const res = await fetch(`/api/reports/${report.id}/generate`, { method: 'POST' })
if (res.ok) {
const data = await res.json()
setReport(data.report)
pollUntilDone()
} else {
const data = await res.json().catch(() => ({ error: '生成失败' }))
alert(data.error || '生成失败')
setGenerating(false)
}
} catch {
alert('生成失败')
setGenerating(false)
}
}
const pollUntilDone = async () => {
if (!report) return
const start = Date.now()
while (Date.now() - start < 120000) {
await new Promise(r => setTimeout(r, 3000))
const res = await fetch(`/api/reports/${report.id}`)
if (!res.ok) continue
const data = await res.json()
const rpt = data.report
setReport(rpt)
if (rpt?.status === 'completed' || rpt?.status === 'failed') {
setGenerating(false)
if (data.reportData) setReportData(data.reportData)
return
}
}
setGenerating(false)
}
const can = (perm: string) => permissions.includes('*') || permissions.includes(perm)
const renderRightButton = () => {
if (!report) return null
switch (report.status) {
case 'ready':
return can('reports:create') ? (
<Button size="sm" onClick={handleGenerate} loading={generating} className="bg-emerald-600 hover:bg-emerald-700 text-white">
<FileText size={16} className="mr-1" />
</Button>
) : null
case 'generating':
return (
<Button size="sm" disabled className="bg-slate-400 text-white cursor-not-allowed">
<span className="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-1" />
...
</Button>
)
case 'completed':
return can('reports:download') ? (
<Button size="sm" onClick={() => window.open(`/api/reports/${report.id}/download`, '_blank')} className="bg-blue-600 hover:bg-blue-700 text-white">
<Download size={16} className="mr-1" />
</Button>
) : null
case 'failed':
return can('reports:create') ? (
<Button size="sm" onClick={handleGenerate} loading={generating} className="bg-amber-500 hover:bg-amber-600 text-white">
<RefreshCw size={16} className="mr-1" />
</Button>
) : null
default:
return null
}
}
const isNewFormat = reportData?.gpuCount !== undefined
if (loading) return <div className="text-center py-12 text-slate-500 dark:text-slate-400">...</div>
if (!report) return <div className="text-center py-12 text-slate-500 dark:text-slate-400"></div>
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between border-b border-slate-200 dark:border-slate-700 pb-4">
<div className="flex items-center gap-4">
<Link href="/reports">
<Button variant="ghost" size="sm"><ArrowLeft size={18} /></Button>
</Link>
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">
{typeLabel[report.report_type] || report.report_type}
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
{report.period_start} ~ {report.period_end}
</p>
</div>
{report.status === 'ready' && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500" />
</span>
)}
{report.status === 'generating' && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400">
<span className="inline-block w-2.5 h-2.5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</span>
)}
{report.status === 'completed' && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
</span>
)}
{report.status === 'failed' && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400">
<span className="w-1.5 h-1.5 rounded-full bg-red-500" />
</span>
)}
{report.status === 'pending' && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300">
<span className="w-1.5 h-1.5 rounded-full bg-slate-400" />
</span>
)}
</div>
{renderRightButton()}
</div>
{report.status === 'failed' && report.error_message && (
<Card className="p-4 bg-red-50 dark:bg-red-500/10 border-red-200 dark:border-red-500/20">
<p className="text-sm text-red-600 dark:text-red-400">{report.error_message}</p>
</Card>
)}
{/* ===== 旧格式兼容 ===== */}
{!isNewFormat && reportData?.summary && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="p-5 text-center">
<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">{reportData.summary.total_tickets}</p>
</Card>
<Card className="p-5 text-center">
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-2xl font-bold text-emerald-500 mt-1">{reportData.summary.resolved_tickets}</p>
</Card>
<Card className="p-5 text-center">
<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">{reportData.summary.avg_duration} </p>
</Card>
<Card className="p-5 text-center">
<p className="text-sm text-slate-500 dark:text-slate-400">SLA </p>
<p className={`text-2xl font-bold mt-1 ${reportData.summary.sla_rate >= 90 ? 'text-emerald-500' : 'text-amber-500'}`}>{reportData.summary.sla_rate}%</p>
</Card>
</div>
{reportData.categories && reportData.categories.length > 0 && (
<Card className="p-5">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3"></h3>
<div className="space-y-2">
{reportData.categories.map((c: any, i: number) => (
<div key={i} className="flex items-center justify-between py-2 border-b border-slate-100 dark:border-slate-800 last:border-0">
<span className="text-sm text-slate-700 dark:text-slate-300">{c.fault_category || '未分类'}</span>
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">{c.count} </span>
</div>
))}
</div>
</Card>
)}
</div>
)}
{/* ===== 新格式 ===== */}
{isNewFormat && reportData && (
<div className="space-y-5">
{/* KPI Cards (5列) */}
<div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
<Card className="p-4 text-center flex flex-col justify-center min-h-[96px]">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1"></p>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{reportData.totalTickets}</p>
</Card>
<Card className="p-4 text-center flex flex-col justify-center min-h-[96px]">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1"></p>
<p className="text-2xl font-bold text-emerald-500">{reportData.resolvedCount}</p>
</Card>
<Card className="p-4 text-center flex flex-col justify-center min-h-[96px]">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1"></p>
<p className={`text-2xl font-bold ${(reportData.avgAvailability ?? 100) >= 99 ? 'text-emerald-500' : 'text-amber-500'}`}>
{reportData.avgAvailability != null ? `${reportData.avgAvailability}%` : '-'}
</p>
<p className="text-xs text-slate-400 mt-1">
{reportData.avgAvailability != null && reportData.avgAvailability < 99 ? '低于99%' : ''}
</p>
</Card>
<Card className="p-4 text-center flex flex-col justify-center min-h-[96px]">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1"></p>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{reportData.avgDurationMinutes}</p>
<p className="text-xs text-slate-400 mt-1"></p>
</Card>
<Card className="p-4 text-center flex flex-col justify-center min-h-[96px]">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1"></p>
<p className="text-2xl font-bold text-sky-600">{reportData.ongoingCount}</p>
</Card>
</div>
{/* Chapter 1: 设备概况 */}
<Card className="overflow-hidden">
<div className="flex items-center gap-3 px-5 py-3 bg-slate-50 dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
<span className="inline-flex items-center justify-center w-6 h-6 rounded-md bg-blue-500 text-white text-xs font-bold">1</span>
<span className="text-sm font-semibold text-slate-700 dark:text-slate-300"></span>
</div>
<div className="flex gap-8 px-5 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-500/10 flex items-center justify-center text-lg">🖥</div>
<div>
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">{reportData.gpuCount}</span>
<span className="text-sm text-slate-500 dark:text-slate-400 ml-1"> GPU </span>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-500/10 flex items-center justify-center text-lg">🗄</div>
<div>
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">{reportData.storageCount}</span>
<span className="text-sm text-slate-500 dark:text-slate-400 ml-1"> </span>
</div>
</div>
</div>
</Card>
{/* Chapter 2: 运营数据 */}
<Card className="overflow-hidden">
<div className="flex items-center gap-3 px-5 py-3 bg-slate-50 dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
<span className="inline-flex items-center justify-center w-6 h-6 rounded-md bg-purple-500 text-white text-xs font-bold">2</span>
<span className="text-sm font-semibold text-slate-700 dark:text-slate-300"></span>
</div>
<div className="flex gap-6 px-5 py-4">
<div className="text-sm text-slate-500 dark:text-slate-400">
<span className="font-bold text-amber-600 text-base ml-1">{reportData.faultTicketCount}</span>
</div>
<div className="text-sm text-slate-500 dark:text-slate-400">
<span className="font-bold text-slate-900 dark:text-slate-100 text-base ml-1">{reportData.affectedDeviceCount}</span>
</div>
{reportData.faultFreeDays != null && (
<div className="text-sm text-slate-500 dark:text-slate-400">
<span className="font-bold text-emerald-600 text-base ml-1">{reportData.faultFreeDays}</span>
</div>
)}
</div>
</Card>
{/* Chapter 3: 故障分类 */}
<Card className="overflow-hidden">
<div className="flex items-center gap-3 px-5 py-3 bg-slate-50 dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
<span className="inline-flex items-center justify-center w-6 h-6 rounded-md bg-amber-500 text-white text-xs font-bold">3</span>
<span className="text-sm font-semibold text-slate-700 dark:text-slate-300"></span>
</div>
<div className="flex gap-4 px-5 py-4">
{(() => {
const ch3Total = reportData.gpuFaultCount + reportData.storageFaultCount + reportData.otherTicketCount
return (
<>
<div className="flex-1 p-3 rounded-lg text-center">
<p className="text-lg font-bold text-amber-500">{reportData.gpuFaultCount}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">GPU </p>
<div className="w-full h-1 bg-slate-100 dark:bg-slate-700 rounded mt-2">
<div className="h-full bg-amber-500 rounded" style={{ width: `${ch3Total > 0 ? Math.round(reportData.gpuFaultCount / ch3Total * 100) : 0}%` }} />
</div>
<p className="text-xs text-slate-400 mt-0.5">
{ch3Total > 0 ? `${Math.round(reportData.gpuFaultCount / ch3Total * 100)}%` : '—'}
</p>
</div>
<div className="flex-1 p-3 rounded-lg text-center">
<p className="text-lg font-bold text-amber-500">{reportData.storageFaultCount}</p>
<p className="text-xs text-slate-500 dark:text-slate-400"></p>
<div className="w-full h-1 bg-slate-100 dark:bg-slate-700 rounded mt-2">
<div className="h-full bg-amber-500 rounded" style={{ width: `${ch3Total > 0 ? Math.round(reportData.storageFaultCount / ch3Total * 100) : 0}%` }} />
</div>
<p className="text-xs text-slate-400 mt-0.5">
{ch3Total > 0 ? `${Math.round(reportData.storageFaultCount / ch3Total * 100)}%` : '—'}
</p>
</div>
<div className="flex-1 p-3 rounded-lg text-center">
<p className="text-lg font-bold text-blue-500">{reportData.otherTicketCount}</p>
<p className="text-xs text-slate-500 dark:text-slate-400"></p>
<div className="w-full h-1 bg-slate-100 dark:bg-slate-700 rounded mt-2">
<div className="h-full bg-blue-500 rounded" style={{ width: `${ch3Total > 0 ? Math.round(reportData.otherTicketCount / ch3Total * 100) : 0}%` }} />
</div>
<p className="text-xs text-slate-400 mt-0.5">
{ch3Total > 0 ? `${Math.round(reportData.otherTicketCount / ch3Total * 100)}%` : '—'}
</p>
</div>
</>
)
})()}
</div>
</Card>
{/* Chapter 4: 服务可用性 */}
<Card className="overflow-hidden">
<div className="flex items-center gap-3 px-5 py-3 bg-slate-50 dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
<span className="inline-flex items-center justify-center w-6 h-6 rounded-md bg-emerald-500 text-white text-xs font-bold">4</span>
<span className="text-sm font-semibold text-slate-700 dark:text-slate-300"></span>
</div>
<div className="px-5 py-4">
<div className="flex items-center gap-2 mb-4">
<span className="text-sm text-slate-500 dark:text-slate-400"></span>
<span className={`text-xl font-bold ${(reportData.avgAvailability ?? 100) >= 99 ? 'text-emerald-500' : 'text-amber-500'}`}>
{reportData.avgAvailability != null ? `${reportData.avgAvailability}%` : '-'}
</span>
</div>
{reportData.availabilityDetails && reportData.availabilityDetails.length > 0 && (
<>
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b-2 border-slate-200 dark:border-slate-700 text-left">
<th className="py-2 px-3 text-slate-500 dark:text-slate-400 font-medium text-xs">IP </th>
<th className="py-2 px-3 text-slate-500 dark:text-slate-400 font-medium text-xs"></th>
<th className="py-2 px-3 text-slate-500 dark:text-slate-400 font-medium text-xs"></th>
<th className="py-2 px-3 text-slate-500 dark:text-slate-400 font-medium text-xs"></th>
<th className="py-2 px-3 text-slate-500 dark:text-slate-400 font-medium text-xs"></th>
</tr>
</thead>
<tbody>
{reportData.availabilityDetails.map((item: any, i: number) => (
<tr key={i} className="border-b border-slate-100 dark:border-slate-800">
<td className="py-2 px-3 font-medium text-slate-900 dark:text-slate-100 text-xs">{item.ip}</td>
<td className="py-2 px-3 text-slate-600 dark:text-slate-400 text-xs">
{item.deviceType === 'gpu' ? 'GPU' : item.deviceType === 'storage' ? '存储' : '其他'}
</td>
<td className={`py-2 px-3 text-xs font-medium ${item.isOngoing ? 'text-red-500' : 'text-slate-700 dark:text-slate-300'}`}>
{item.durationMinutes.toLocaleString()}
</td>
<td className={`py-2 px-3 text-xs font-bold ${item.availabilityPercent < 95 ? 'text-red-500' : 'text-amber-500'}`}>
{item.availabilityPercent}%
</td>
<td className="py-2 px-3">
{item.isOngoing ? (
<span className="inline-flex px-2 py-0.5 rounded-full text-xs bg-red-100 text-red-600 dark:bg-red-500/10 dark:text-red-400"></span>
) : (
<span className="inline-flex px-2 py-0.5 rounded-full text-xs bg-emerald-100 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
<p className="text-xs text-slate-400 mt-3">
{reportData.availabilityDetails.length} IP 100% 100% 线
</p>
</>
)}
{(!reportData.availabilityDetails || reportData.availabilityDetails.length === 0) && (
<p className="text-xs text-slate-400">
{report.report_type === 'weekly'
? '周报不展示 IP 明细,请查看下载的 DOCX 文档获取完整可用性数据。'
: '所有设备保持 100% 在线'}
</p>
)}
</div>
</Card>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,536 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Card, Button, Badge, Toast, Modal } from '@/components/ui'
import { Plus, Eye, Download, Trash2, Archive, RefreshCw } from 'lucide-react'
interface Report {
id: number
report_type: string
period_start: string | null
period_end: string | null
format: string
status: string
created_at: string
file_name?: string | null
metadata?: string | null
}
const statusVariant: Record<string, 'info' | 'warning' | 'success' | 'danger'> = {
pending: 'info',
ready: 'warning',
generating: 'info',
completed: 'success',
failed: 'danger',
}
const statusLabel: Record<string, string> = {
pending: '待生成',
ready: '数据已就绪',
generating: '文档生成中',
completed: '已完成',
failed: '生成失败',
}
const typeLabel: Record<string, string> = {
weekly: '周报',
monthly: '月报',
}
function getReportName(r: Report): string {
if (r.metadata) {
try {
const meta = JSON.parse(r.metadata)
if (meta.reportLabel) return meta.reportLabel
} catch { /* ignore */ }
}
if (r.file_name) return r.file_name
return `${r.period_start || '?'} ~ ${r.period_end || '?'} ${typeLabel[r.report_type] || r.report_type}`
}
function lastDayOfMonth(y: number, m: number) {
return new Date(y, m, 0).getDate()
}
function pad(n: number) { return String(n).padStart(2, '0') }
function getLastWeekDates() {
const today = new Date()
const dayOfWeek = today.getDay()
const daysSinceMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1
const thisMonday = new Date(today)
thisMonday.setDate(today.getDate() - daysSinceMonday)
const lastMonday = new Date(thisMonday)
lastMonday.setDate(thisMonday.getDate() - 7)
const lastSunday = new Date(lastMonday)
lastSunday.setDate(lastMonday.getDate() + 6)
return {
start: `${lastMonday.getFullYear()}-${pad(lastMonday.getMonth()+1)}-${pad(lastMonday.getDate())}`,
end: `${lastSunday.getFullYear()}-${pad(lastSunday.getMonth()+1)}-${pad(lastSunday.getDate())}`,
}
}
export default function ReportsPage() {
const router = useRouter()
const [reports, setReports] = useState<Report[]>([])
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [reportType, setReportType] = useState<'monthly' | 'weekly'>('monthly')
const [month, setMonth] = useState('')
const [weekStart, setWeekStart] = useState('')
const [weekEnd, setWeekEnd] = useState('')
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'info' } | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Report | null>(null)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false)
const [generatingIds, setGeneratingIds] = useState<Set<number>>(new Set())
const [permissions, setPermissions] = useState<string[]>([])
useEffect(() => {
fetch('/api/auth/me')
.then(r => r.json())
.then(u => { if (u.user?.permissions) setPermissions(u.user.permissions) })
.catch(() => {})
}, [])
const toggleSelect = (id: number) => {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) { next.delete(id) } else { next.add(id) }
return next
})
}
const toggleSelectAll = () => {
const selectable = reports.filter(r =>
r.status === 'completed' || r.status === 'failed' || r.status === 'ready'
)
const allIds = selectable.map(r => r.id)
setSelectedIds(prev =>
prev.size === allIds.length && allIds.length > 0 ? new Set() : new Set(allIds)
)
}
const fetchReports = useCallback(() => {
fetch('/api/reports')
.then(r => r.json())
.then(d => { if (d.reports) setReports(d.reports) })
.catch(() => {})
.finally(() => setLoading(false))
}, [])
useEffect(() => { fetchReports() }, [fetchReports])
useEffect(() => {
if (!month) {
const now = new Date()
let lastMonth = now.getMonth() - 1
let lastMonthYear = now.getFullYear()
if (lastMonth < 0) { lastMonth = 11; lastMonthYear-- }
setMonth(`${lastMonthYear}-${pad(lastMonth + 1)}`)
}
}, [])
const handleCreate = async () => {
let period_start = ''
let period_end = ''
if (reportType === 'monthly') {
if (!month) return
const [y, m] = month.split('-').map(Number)
period_start = `${y}-${pad(m)}-01`
period_end = `${y}-${pad(m)}-${pad(lastDayOfMonth(y, m))}`
} else {
if (!weekStart || !weekEnd) return
period_start = weekStart
period_end = weekEnd
}
const res = await fetch('/api/reports', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ report_type: reportType, period_start, period_end }),
})
if (res.ok) {
const data = await res.json()
setShowCreate(false)
setToast({ message: '数据采集完成,正在跳转预览页...', type: 'success' })
router.push(`/reports/${data.report.id}`)
} else {
const data = await res.json().catch(() => ({ error: '创建失败' }))
setToast({ message: data.error || '创建失败', type: 'error' })
}
}
const handleDelete = async () => {
if (!deleteTarget) return
const res = await fetch(`/api/reports/${deleteTarget.id}`, { method: 'DELETE' })
if (res.ok) {
setReports(prev => prev.filter(r => r.id !== deleteTarget.id))
setToast({ message: '已删除', type: 'success' })
} else {
const data = await res.json().catch(() => ({ error: '删除失败' }))
setToast({ message: data.error || '删除失败', type: 'error' })
}
setDeleteTarget(null)
}
const handleBatchDelete = async () => {
const ids = Array.from(selectedIds)
if (ids.length === 0) return
const res = await fetch('/api/reports', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
})
if (res.ok) {
setReports(prev => prev.filter(r => !selectedIds.has(r.id)))
setSelectedIds(new Set())
setToast({ message: `已删除 ${ids.length} 条报告`, type: 'success' })
} else {
const data = await res.json().catch(() => ({ error: '批量删除失败' }))
setToast({ message: data.error || '批量删除失败', type: 'error' })
}
setBatchDeleteOpen(false)
}
const handleBatchDownload = async () => {
const ids = Array.from(selectedIds)
if (ids.length === 0) return
try {
const res = await fetch('/api/reports/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
})
if (res.ok) {
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const d = new Date(); a.download = `reports_${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}.zip`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
setToast({ message: `已下载 ${ids.length} 条报告`, type: 'success' })
} else {
const data = await res.json().catch(() => ({ error: '下载失败' }))
setToast({ message: data.error || '下载失败', type: 'error' })
}
} catch {
setToast({ message: '下载失败', type: 'error' })
}
}
const handleGenerate = async (reportId: number) => {
setGeneratingIds(prev => new Set(prev).add(reportId))
try {
const res = await fetch(`/api/reports/${reportId}/generate`, { method: 'POST' })
if (res.ok) {
setToast({ message: '文档生成中...', type: 'info' })
pollUntilDone(reportId)
} else {
const data = await res.json().catch(() => ({ error: '生成失败' }))
setToast({ message: data.error || '生成失败', type: 'error' })
setGeneratingIds(prev => { const n = new Set(prev); n.delete(reportId); return n })
}
} catch {
setToast({ message: '生成失败', type: 'error' })
setGeneratingIds(prev => { const n = new Set(prev); n.delete(reportId); return n })
}
}
const pollUntilDone = async (reportId: number) => {
const start = Date.now()
while (Date.now() - start < 120000) {
await new Promise(r => setTimeout(r, 3000))
const res = await fetch(`/api/reports/${reportId}`)
if (!res.ok) continue
const data = await res.json()
const status = data.report?.status
if (status === 'completed') {
setToast({ message: '报告生成完成', type: 'success' })
fetchReports()
setGeneratingIds(prev => { const n = new Set(prev); n.delete(reportId); return n })
return
}
if (status === 'failed') {
setToast({ message: data.report?.error_message || '报告生成失败', type: 'error' })
fetchReports()
setGeneratingIds(prev => { const n = new Set(prev); n.delete(reportId); return n })
return
}
}
setToast({ message: '报告生成超时,请手动刷新', type: 'error' })
fetchReports()
setGeneratingIds(prev => { const n = new Set(prev); n.delete(reportId); return n })
}
const getActionButtons = (r: Report) => {
const isGenerating = generatingIds.has(r.id)
switch (r.status) {
case 'ready':
return (
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => router.push(`/reports/${r.id}`)}>
<Eye size={14} className="mr-0.5" />
</Button>
{can('reports:create') && (
<Button variant="ghost" size="sm" onClick={() => handleGenerate(r.id)} loading={isGenerating}>
</Button>
)}
{can('reports:create') && (
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}>
<Trash2 size={14} className="text-red-500" />
</Button>
)}
</div>
)
case 'generating':
return (
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => router.push(`/reports/${r.id}`)}>
<Eye size={14} className="mr-0.5" />
</Button>
</div>
)
case 'completed':
return (
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => router.push(`/reports/${r.id}`)}>
<Eye size={14} className="mr-0.5" />
</Button>
{can('reports:download') && (
<Button variant="ghost" size="sm" onClick={() => window.open(`/api/reports/${r.id}/download`, '_blank')}>
<Download size={14} className="mr-0.5" />
</Button>
)}
{can('reports:create') && (
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}>
<Trash2 size={14} className="text-red-500" />
</Button>
)}
</div>
)
case 'failed':
return (
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => router.push(`/reports/${r.id}`)}>
<Eye size={14} className="mr-0.5" />
</Button>
{can('reports:create') && (
<Button variant="ghost" size="sm" onClick={() => handleGenerate(r.id)} loading={isGenerating}>
<RefreshCw size={14} className="mr-0.5" />
</Button>
)}
{can('reports:create') && (
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}>
<Trash2 size={14} className="text-red-500" />
</Button>
)}
</div>
)
default:
return (
<div className="flex items-center gap-1">
{can('reports:create') && (
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}>
<Trash2 size={14} className="text-red-500" />
</Button>
)}
</div>
)
}
}
const can = (perm: string) => permissions.includes('*') || permissions.includes(perm)
const renderStatusBadge = (status: string) => {
if (status === 'generating') {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400">
<span className="inline-block w-2.5 h-2.5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</span>
)
}
if (status === 'ready') {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500" />
</span>
)
}
return <Badge variant={statusVariant[status] || 'default'}>{statusLabel[status] || status}</Badge>
}
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 text-sm mt-1"></p>
</div>
<div className="flex items-center gap-2">
{selectedIds.size > 0 && (
<>
{can('reports:download') && (
<Button size="sm" onClick={handleBatchDownload}>
<Archive size={16} className="mr-1" /> ({selectedIds.size})
</Button>
)}
{can('reports:create') && (
<Button size="sm" variant="danger" onClick={() => setBatchDeleteOpen(true)}>
<Trash2 size={16} className="mr-1" /> ({selectedIds.size})
</Button>
)}
</>
)}
{can('reports:create') && (
<Button size="sm" onClick={() => setShowCreate(!showCreate)}>
<Plus size={16} className="mr-1" />
</Button>
)}
</div>
</div>
{showCreate && (
<Card className="p-5">
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3"></h3>
<div className="flex gap-3 mb-4">
<label className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer text-sm transition-colors ${reportType === 'monthly' ? 'border-blue-500 bg-blue-50 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400' : 'border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-400'}`}>
<input type="radio" name="reportType" value="monthly" checked={reportType === 'monthly'} onChange={() => setReportType('monthly')} className="sr-only" />
</label>
<label className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer text-sm transition-colors ${reportType === 'weekly' ? 'border-blue-500 bg-blue-50 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400' : 'border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-400'}`}>
<input type="radio" name="reportType" value="weekly" checked={reportType === 'weekly'} onChange={() => { setReportType('weekly'); const { start, end } = getLastWeekDates(); setWeekStart(start); setWeekEnd(end) }} className="sr-only" />
</label>
</div>
{reportType === 'monthly' ? (
<div className="space-y-1">
<label className="text-sm text-slate-500 dark:text-slate-400"></label>
<input type="month" className="w-56 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" value={month} onChange={e => setMonth(e.target.value)} />
<p className="text-xs text-slate-400 mt-1">1</p>
</div>
) : (
<div>
<div className="flex gap-4">
<div className="space-y-1">
<label className="text-sm text-slate-500 dark:text-slate-400"></label>
<input type="date" className="w-56 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" value={weekStart} onChange={e => setWeekStart(e.target.value)} />
</div>
<div className="space-y-1">
<label className="text-sm text-slate-500 dark:text-slate-400"></label>
<input type="date" className="w-56 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" value={weekEnd} onChange={e => setWeekEnd(e.target.value)} />
</div>
</div>
<p className="text-xs text-slate-400 mt-2"></p>
</div>
)}
<div className="flex gap-2 mt-4">
<Button size="sm" onClick={handleCreate}></Button>
<Button size="sm" variant="secondary" onClick={() => setShowCreate(false)}></Button>
</div>
</Card>
)}
{loading ? (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">...</div>
) : reports.length === 0 ? (
<div className="text-center py-12 text-slate-500 dark:text-slate-400"></div>
) : (
<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>
<th className="px-3 py-3 w-10">
<input
type="checkbox"
checked={
reports.filter(r => r.status === 'completed' || r.status === 'failed' || r.status === 'ready').length > 0 &&
selectedIds.size === reports.filter(r => r.status === 'completed' || r.status === 'failed' || r.status === 'ready').length
}
onChange={toggleSelectAll}
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500"
/>
</th>
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400"></th>
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400"></th>
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400"></th>
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400"></th>
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400"></th>
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-700">
{reports.map(r => (
<tr key={r.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<td className="px-3 py-3">
<input
type="checkbox"
checked={selectedIds.has(r.id)}
onChange={() => toggleSelect(r.id)}
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500"
/>
</td>
<td className="px-4 py-3 max-w-[240px]">
<a
href={`/reports/${r.id}`}
onClick={(e) => { e.preventDefault(); router.push(`/reports/${r.id}`) }}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 font-medium truncate block"
>
{getReportName(r)}
</a>
</td>
<td className="px-4 py-3">
{r.report_type === 'monthly' ? (
<span className="inline-flex px-2.5 py-0.5 rounded-lg text-xs font-semibold bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400"></span>
) : (
<span className="inline-flex px-2.5 py-0.5 rounded-lg text-xs font-semibold bg-purple-100 text-purple-700 dark:bg-purple-500/10 dark:text-purple-400"></span>
)}
</td>
<td className="px-4 py-3 text-slate-600 dark:text-slate-400 text-sm">{r.period_start} ~ {r.period_end}</td>
<td className="px-4 py-3">{renderStatusBadge(r.status)}</td>
<td className="px-4 py-3 text-slate-500 dark:text-slate-400 text-sm">{new Date(r.created_at).toLocaleString('zh-CN')}</td>
<td className="px-4 py-3">{getActionButtons(r)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除">
<p className="text-sm text-slate-600 dark:text-slate-400 mb-4">
</p>
<div className="flex gap-2 justify-end">
<Button size="sm" variant="secondary" onClick={() => setDeleteTarget(null)}></Button>
<Button size="sm" variant="danger" onClick={handleDelete}></Button>
</div>
</Modal>
<Modal open={batchDeleteOpen} onClose={() => setBatchDeleteOpen(false)} title="确认批量删除">
<p className="text-sm text-slate-600 dark:text-slate-400 mb-4">
{selectedIds.size}
</p>
<div className="flex gap-2 justify-end">
<Button size="sm" variant="secondary" onClick={() => setBatchDeleteOpen(false)}></Button>
<Button size="sm" variant="danger" onClick={handleBatchDelete}></Button>
</div>
</Modal>
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
</div>
)
}

View File

@ -86,25 +86,25 @@ export default function ApiKeysPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">API Key </h1> <h1 className="text-2xl font-bold text-slate-900 dark:text-white">API Key </h1>
<p className="text-slate-500 dark:text-slate-400 mt-1"> API</p> <p className="text-slate-500 dark:text-slate-400 mt-1"> API</p>
</div> </div>
<Button size="sm" onClick={() => { setCreateOpen(true); setNewKey(null) }}> <Button size="sm" onClick={() => { setCreateOpen(true); setNewKey(null) }}>
<Plus size={16} className="mr-1" /> Key <Plus size={16} /> Key
</Button> </Button>
</div> </div>
{newKey && ( {newKey && (
<div className="p-4 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-xl"> <div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<p className="text-sm font-medium text-emerald-800 dark:text-emerald-300 mb-2"> <p className="text-sm font-medium text-green-800 dark:text-green-300 mb-2">
API Key API Key
</p> </p>
<div className="flex items-center gap-2"> <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"> <code className="flex-1 p-2 bg-white dark:bg-slate-800 rounded border border-green-200 dark:border-green-700 text-sm font-mono break-all">
{newKey} {newKey}
</code> </code>
<Button variant="ghost" size="sm" onClick={() => copyKey(newKey)}> <Button variant="ghost" size="sm" onClick={() => copyKey(newKey)}>
{copied ? <Check size={16} className="text-emerald-500" /> : <Copy size={16} />} {copied ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
</Button> </Button>
</div> </div>
</div> </div>
@ -124,7 +124,7 @@ export default function ApiKeysPage() {
const perms: string[] = JSON.parse(k.permissions) const perms: string[] = JSON.parse(k.permissions)
return ( return (
<tr key={k.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors"> <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 font-medium text-slate-900 dark:text-white">{k.name}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{perms.map(p => <Badge key={p} variant="default">{p}</Badge>)} {perms.map(p => <Badge key={p} variant="default">{p}</Badge>)}
@ -163,7 +163,7 @@ export default function ApiKeysPage() {
{error && <p className="text-sm text-red-500">{error}</p>} {error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-3"> <div className="flex gap-3">
<Button onClick={handleCreate} loading={saving}></Button> <Button onClick={handleCreate} loading={saving}></Button>
<Button variant="secondary" onClick={() => { setCreateOpen(false); setError('') }}></Button> <Button variant="ghost" onClick={() => { setCreateOpen(false); setError('') }}></Button>
</div> </div>
</div> </div>
</Modal> </Modal>
@ -173,7 +173,7 @@ export default function ApiKeysPage() {
API Key{deleteTarget?.name}使 Key 访 API Key{deleteTarget?.name}使 Key 访
</p> </p>
<div className="flex justify-end gap-3 mt-4"> <div className="flex justify-end gap-3 mt-4">
<Button variant="secondary" onClick={() => setDeleteTarget(null)}></Button> <Button variant="ghost" onClick={() => setDeleteTarget(null)}></Button>
<Button variant="danger" onClick={handleDelete}></Button> <Button variant="danger" onClick={handleDelete}></Button>
</div> </div>
</Modal> </Modal>

View File

@ -0,0 +1,611 @@
'use client'
import { useState, useEffect } from 'react'
import Table, { Column } from '@/components/ui/Table'
import Button from '@/components/ui/Button'
import Modal from '@/components/ui/Modal'
import Badge from '@/components/ui/Badge'
interface AuditLog {
id: number
user_id: number | null
api_key_id: number | null
action: string
entity_type: string
entity_id: number | null
details: string | null
ip_address: string | null
created_at: string
username: string | null
api_key_name: string | null
}
const ACTION_LABELS: Record<string, { label: string; variant: 'success' | 'info' | 'danger' | 'default' }> = {
create: { label: '创建', variant: 'success' },
update: { label: '更新', variant: 'info' },
delete: { label: '删除', variant: 'danger' },
batch_update: { label: '批量更新', variant: 'info' },
import: { label: '导入', variant: 'info' },
export: { label: '导出', variant: 'default' },
login: { label: '登录', variant: 'default' },
logout: { label: '登出', variant: 'default' },
}
const ENTITY_LABELS: Record<string, string> = {
ticket: '工单',
report: '报告',
user: '用户',
role: '角色',
api_key: 'API Key',
audit_log: '审计日志',
auth: '认证',
}
export default function AuditLogsPage() {
const [logs, setLogs] = useState<AuditLog[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
// 过滤器
const [actionFilter, setActionFilter] = useState('')
const [entityFilter, setEntityFilter] = useState('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
// 保留天数设置
const [retentionSaved, setRetentionSaved] = useState(180)
const [retentionInput, setRetentionInput] = useState('180')
const [retentionSaving, setRetentionSaving] = useState(false)
const [retentionSavedFeedback, setRetentionSavedFeedback] = useState(false)
const [retentionError, setRetentionError] = useState(false)
const retentionDirty = retentionInput !== String(retentionSaved)
// 详情 Modal
const [detailLog, setDetailLog] = useState<AuditLog | null>(null)
async function fetchLogs() {
setLoading(true)
const params = new URLSearchParams()
params.set('page', String(page))
params.set('pageSize', String(pageSize))
if (actionFilter) params.set('action', actionFilter)
if (entityFilter) params.set('entity_type', entityFilter)
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
try {
const res = await fetch(`/api/audit-logs?${params}`)
if (res.ok) {
const data = await res.json()
setLogs(data.logs)
setTotal(data.total)
}
} catch { /* ignore */ }
setLoading(false)
}
useEffect(() => { fetchLogs() }, [page, actionFilter, entityFilter, dateFrom, dateTo])
async function fetchRetentionDays() {
try {
const res = await fetch('/api/audit-logs/cleanup')
if (res.ok) {
const data = await res.json()
setRetentionSaved(data.days)
setRetentionInput(String(data.days))
}
} catch { /* ignore */ }
}
async function saveRetentionDays() {
const days = parseInt(retentionInput, 10)
if (isNaN(days) || days < 30 || days > 365) {
setRetentionError(true)
setTimeout(() => setRetentionError(false), 2000)
return
}
setRetentionSaving(true)
setRetentionError(false)
try {
const res = await fetch('/api/audit-logs/cleanup', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ days })
})
if (res.ok) {
setRetentionSaved(days)
setRetentionSavedFeedback(true)
setTimeout(() => setRetentionSavedFeedback(false), 2000)
} else {
setRetentionError(true)
setTimeout(() => setRetentionError(false), 2000)
}
} catch {
setRetentionError(true)
setTimeout(() => setRetentionError(false), 2000)
}
setRetentionSaving(false)
}
useEffect(() => { fetchRetentionDays() }, [])
function resetFilters() {
setActionFilter('')
setEntityFilter('')
setDateFrom('')
setDateTo('')
setPage(1)
}
function setQuickDate(days: number) {
const today = new Date()
const from = new Date(today)
from.setDate(from.getDate() - days)
const fromStr = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, '0')}-${String(from.getDate()).padStart(2, '0')}`
const toStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
setDateFrom(fromStr)
setDateTo(toStr)
}
async function handleExport() {
const params = new URLSearchParams()
if (actionFilter) params.set('action', actionFilter)
if (entityFilter) params.set('entity_type', entityFilter)
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
const res = await fetch(`/api/audit-logs/export?${params}`)
if (res.ok) {
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const now = new Date()
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
a.download = `audit-logs-${dateStr}.csv`
a.click()
URL.revokeObjectURL(url)
}
}
function formatDetails(log: AuditLog): string {
if (!log.details) return '-'
try {
const details = JSON.parse(log.details)
if (details.changes) {
const keys = Object.keys(details.changes)
return keys.slice(0, 2).join(', ') + (keys.length > 2 ? '...' : '')
}
if (details.created) return '新建记录'
if (details.deleted) return '删除记录'
if (details.export) return `导出 ${details.export.count}`
if (details.batch_update) return `批量更新 ${details.batch_update.count}`
if (details.import) return `导入 ${details.import.created} 新 / ${details.import.updated} 更新`
if (details.message) return details.message
if (details.keys) return details.keys.slice(0, 2).join(', ') + (details.keys.length > 2 ? '...' : '')
if (details.username) return `用户 ${details.username} 登录`
if (details.retention_days !== undefined) return `保留 ${details.retention_days}`
if (details.cleanup) return `清理 ${details.cleanup.deleted}`
return '-'
} catch { return '-' }
}
// 字段名汉化映射
const FIELD_LABELS: Record<string, string> = {
name: '名称', status: '状态', type: '类型', category: '分类',
title: '标题', priority: '优先级', assignee: '负责人', reporter: '报告人',
description: '描述', notes: '备注', email: '邮箱', role: '角色',
username: '用户名', permissions: '权限', count: '数量',
}
function formatValue(val: unknown): string {
if (val === null || val === undefined) return '-'
if (typeof val === 'object') return JSON.stringify(val)
return String(val)
}
function renderDetails(details: Record<string, unknown>) {
// 字段变更assets-ai 格式)
if (details.changes && typeof details.changes === 'object') {
const changes = details.changes as Record<string, { from: unknown; to: unknown }>
return (
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">📋 </p>
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-slate-100 dark:bg-slate-700">
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{Object.entries(changes).map(([key, val]) => (
<tr key={key} className="border-t border-slate-200 dark:border-slate-700">
<td className="px-3 py-2 text-slate-500">{FIELD_LABELS[key] || key}</td>
<td className="px-3 py-2 text-slate-400 line-through">{formatValue(val.from)}</td>
<td className="px-3 py-2 text-green-600 dark:text-green-400 font-medium">{formatValue(val.to)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// 新建记录assets-ai 格式)
if (details.created && typeof details.created === 'object') {
const data = details.created as Record<string, unknown>
return (
<div>
<p className="text-sm font-medium text-green-600 dark:text-green-400 mb-2">🟢 </p>
<div className="grid grid-cols-2 gap-2">
{Object.entries(data).map(([key, val]) => (
<div key={key} className="flex gap-2 text-sm">
<span className="text-slate-500">{FIELD_LABELS[key] || key}</span>
<span>{formatValue(val)}</span>
</div>
))}
</div>
</div>
)
}
// 删除记录assets-ai 格式)
if (details.deleted && typeof details.deleted === 'object') {
const data = details.deleted as Record<string, unknown>
return (
<div>
<p className="text-sm font-medium text-red-600 dark:text-red-400 mb-2">🔴 </p>
<div className="grid grid-cols-2 gap-2">
{Object.entries(data).map(([key, val]) => (
<div key={key} className="flex gap-2 text-sm">
<span className="text-slate-500">{FIELD_LABELS[key] || key}</span>
<span>{formatValue(val)}</span>
</div>
))}
</div>
</div>
)
}
// 批量更新assets-ai 格式)
if (details.batch_update && typeof details.batch_update === 'object') {
const data = details.batch_update as Record<string, unknown>
return (
<div>
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2">📦 {String(data.count)} </p>
{data.ids ? <p className="text-sm text-slate-500"> ID{String(data.ids)}</p> : null}
</div>
)
}
// 导入assets-ai 格式)
if (details.import && typeof details.import === 'object') {
const data = details.import as Record<string, unknown>
return (
<div>
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2">📥 </p>
<div className="flex gap-4 text-sm">
<span>{String(data.created)} </span>
<span>{String(data.updated)} </span>
</div>
</div>
)
}
// 导出assets-ai 格式)
if (details.export && typeof details.export === 'object') {
const data = details.export as Record<string, unknown>
return (
<div>
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-2">📤 {String(data.count)} </p>
</div>
)
}
// issue-ai 特有格式:操作说明
if (details.message && typeof details.message === 'string') {
return (
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">📝 </p>
<p className="text-sm text-slate-600 dark:text-slate-400">{details.message}</p>
</div>
)
}
// issue-ai 特有格式:配置修改
if (details.keys && Array.isArray(details.keys)) {
return (
<div>
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2"> </p>
<ul className="list-disc list-inside space-y-1">
{(details.keys as string[]).map((key, i) => (
<li key={i} className="text-sm text-slate-600 dark:text-slate-400 font-mono">{key}</li>
))}
</ul>
</div>
)
}
// 登录信息
if (details.username && typeof details.username === 'string') {
return (
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">👤 </p>
<div className="grid grid-cols-2 gap-2">
<div className="flex gap-2 text-sm">
<span className="text-slate-500"></span>
<span>{details.username}</span>
</div>
{details.role && (
<div className="flex gap-2 text-sm">
<span className="text-slate-500"></span>
<span>{String(details.role)}</span>
</div>
)}
{details.displayName && (
<div className="flex gap-2 text-sm">
<span className="text-slate-500"></span>
<span>{String(details.displayName)}</span>
</div>
)}
</div>
</div>
)
}
// 保留天数设置
if (details.retention_days !== undefined) {
return (
<div>
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2"> </p>
<p className="text-sm text-slate-600 dark:text-slate-400"> <span className="font-medium">{String(details.retention_days)}</span> </p>
</div>
)
}
// 清理操作
if (details.cleanup) {
const data = details.cleanup as Record<string, unknown>
return (
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">🧹 </p>
<p className="text-sm text-slate-600 dark:text-slate-400"> {String(data.deleted)} </p>
</div>
)
}
// 兜底:格式化 JSON
return (
<pre className="mt-2 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg text-xs font-mono overflow-x-auto">
{JSON.stringify(details, null, 2)}
</pre>
)
}
const columns = [
{
key: 'created_at',
title: '时间',
width: '170px',
render: (r) => <span className="text-sm whitespace-nowrap">{r.created_at}</span>
},
{
key: 'username',
title: '用户',
width: '100px',
render: (r) => (
<div className="flex items-center gap-1 whitespace-nowrap">
<span>{r.username || '-'}</span>
{r.api_key_id && <Badge variant="warning">[API]</Badge>}
</div>
)
},
{
key: 'action',
title: '操作',
width: '70px',
render: (r) => {
const info = ACTION_LABELS[r.action] || { label: r.action, variant: 'default' as const }
return <span className="whitespace-nowrap"><Badge variant={info.variant}>{info.label}</Badge></span>
}
},
{
key: 'entity_type',
title: '对象',
width: '80px',
render: (r) => <span className="whitespace-nowrap">{ENTITY_LABELS[r.entity_type] || r.entity_type}</span>
},
{
key: 'details',
title: '详情',
render: (r) => (
<button
onClick={() => setDetailLog(r)}
className="text-left text-sm text-slate-600 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 truncate max-w-[200px]"
>
{formatDetails(r)}
</button>
)
},
{
key: 'ip_address',
title: 'IP',
width: '120px',
render: (r) => <span className="text-xs font-mono">{r.ip_address || '-'}</span>
},
]
// 保留天数按钮样式
const retentionBtnClass = retentionSaved
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: retentionError
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
: ''
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white"></h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1"></p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={handleExport}></Button>
</div>
</div>
{/* 保留天数设置 */}
<div className="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-800/50 rounded-lg text-sm">
<span className="text-slate-600 dark:text-slate-400"></span>
<input
type="number"
min={30}
max={365}
value={retentionInput}
onChange={(e) => setRetentionInput(e.target.value)}
className="w-20 px-2 py-1 text-sm border border-slate-200 dark:border-slate-700 rounded bg-white dark:bg-slate-900"
/>
<span className="text-xs text-slate-400">30-365 180 </span>
{retentionSavedFeedback ? (
<span className="text-xs font-medium text-green-600 dark:text-green-400"> </span>
) : retentionError ? (
<span className="text-xs font-medium text-red-600 dark:text-red-400"> </span>
) : retentionDirty ? (
<button
className="px-3 py-1 text-xs font-medium rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"
onClick={saveRetentionDays}
disabled={retentionSaving}
>
{retentionSaving ? '保存中...' : '保存'}
</button>
) : null}
</div>
{/* 过滤器 */}
<div className="flex flex-wrap items-center gap-3 p-4 bg-slate-50 dark:bg-slate-800/50 rounded-lg">
<select
value={actionFilter}
onChange={(e) => { setActionFilter(e.target.value); setPage(1) }}
className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900"
>
<option value=""></option>
{Object.entries(ACTION_LABELS).map(([key, { label }]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<select
value={entityFilter}
onChange={(e) => { setEntityFilter(e.target.value); setPage(1) }}
className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900"
>
<option value=""></option>
{Object.entries(ENTITY_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<input
type="date"
value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); setPage(1) }}
className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900"
/>
<span className="text-slate-400">~</span>
<input
type="date"
value={dateTo}
onChange={(e) => { setDateTo(e.target.value); setPage(1) }}
className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900"
/>
<div className="flex gap-1">
<Button size="sm" variant="ghost" onClick={() => setQuickDate(7)}>7</Button>
<Button size="sm" variant="ghost" onClick={() => setQuickDate(30)}>30</Button>
<Button size="sm" variant="ghost" onClick={() => setQuickDate(180)}>180</Button>
</div>
<Button size="sm" variant="ghost" onClick={resetFilters}></Button>
</div>
{/* 表格 */}
{loading ? (
<div className="py-12 text-center text-slate-500 dark:text-slate-400">...</div>
) : (
<Table columns={columns as any} data={logs as any} rowKey={(r: any) => String(r.id)} />
)}
{/* 分页 */}
{total > pageSize && (
<div className="flex items-center justify-center gap-2">
<Button
size="sm"
variant="ghost"
disabled={page === 1}
onClick={() => setPage(p => p - 1)}
>
</Button>
<span className="text-sm text-slate-500">
{page} / {Math.ceil(total / pageSize)}
</span>
<Button
size="sm"
variant="ghost"
disabled={page >= Math.ceil(total / pageSize)}
onClick={() => setPage(p => p + 1)}
>
</Button>
</div>
)}
{/* 详情 Modal */}
<Modal
open={!!detailLog}
onClose={() => setDetailLog(null)}
title="审计日志详情"
>
{detailLog && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-slate-500"></span>
<span>{detailLog.created_at}</span>
</div>
<div>
<span className="text-slate-500"></span>
<span>{detailLog.username || '-'}</span>
{detailLog.api_key_id && <span className="ml-1"><Badge variant="warning">[API]</Badge></span>}
</div>
<div>
<span className="text-slate-500"></span>
<Badge variant={ACTION_LABELS[detailLog.action]?.variant || 'default'}>
{ACTION_LABELS[detailLog.action]?.label || detailLog.action}
</Badge>
</div>
<div>
<span className="text-slate-500"></span>
<span>{ENTITY_LABELS[detailLog.entity_type] || detailLog.entity_type}</span>
{detailLog.entity_id && <span className="ml-1">#{detailLog.entity_id}</span>}
</div>
<div>
<span className="text-slate-500">IP</span>
<span className="font-mono">{detailLog.ip_address || '-'}</span>
</div>
</div>
{detailLog.details && (
<div className="mt-3 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
{renderDetails(JSON.parse(detailLog.details))}
</div>
)}
</div>
)}
</Modal>
</div>
)
}

View File

@ -0,0 +1,984 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Card } from '@/components/ui'
import { Search, RefreshCw } from 'lucide-react'
import { useDirtyTracker } from '@shared/ui/hooks/useDirtyTracker'
interface MonitorStatus {
enabled: boolean
status: string
workerHealth: string
lastCheck: string | null
stats: { totalProcessed: number; todayProcessed: number; errors: number; lastError: string | null; lastErrorTime: string | null }
ongoingFaults: number
details: {
todayProcessed: { ticket_no?: string; error?: string; created_at: string }[]
ongoingFaults: { id: number; server_sn: string; server_ip: string | null; order_number: string | null; fault_type: string | null; fault_time: string; fault_detail: string | null }[]
recentErrors: { error?: string; details?: string; created_at: string }[]
}
}
interface MonitorConfig {
monitor: { enabled: boolean; interval_seconds: number; push_delay_ms: number; silent_start_hour: number; silent_end_hour: number }
mail: { address: string; imap_server: string; imap_port: number; pop3_server: string; pop3_port: number; smtp_server: string; smtp_port: number; password: string }
filter: { subject_keywords: string[]; sender_email: string; oem_repair_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string }; oem_diag_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string } }
}
interface ScanState {
status: 'idle' | 'running' | 'completed' | 'error' | 'cancelling'
startedAt?: string
completedAt?: string
timeRange?: { value: number | null; unit: string }
stats?: {
total: number
matched: number
imported: number
skipped: number
errors: number
}
details?: {
msg_id: string
subject: string
date: string
order_number: string | null
status: 'imported' | 'skipped' | 'error'
ticket_no?: string
error?: string
}[]
detailsTruncated?: boolean
error?: string
}
type SectionKey = 'mail' | 'filter' | 'monitor'
export default function MonitorSettingsPage() {
const [status, setStatus] = useState<MonitorStatus | null>(null)
const [config, setConfig] = useState<MonitorConfig | null>(null)
const [loading, setLoading] = useState(true)
const { isDirty, markClean } = useDirtyTracker()
const [saving, setSaving] = useState<SectionKey | null>(null)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [newKeyword, setNewKeyword] = useState('')
const [expandedPanel, setExpandedPanel] = useState<'today' | 'total' | 'errors' | 'ongoing' | null>(null)
const [sectionMessage, setSectionMessage] = useState<Partial<Record<SectionKey, { type: 'success' | 'error'; text: string }>>>({})
// 立即检查冷却状态
const [triggerCooldown, setTriggerCooldown] = useState(0)
const cooldownTimerRef = useRef<NodeJS.Timeout | null>(null)
// 邮箱工单检测状态
const [scanState, setScanState] = useState<ScanState>({ status: 'idle' })
const [scanTimeValue, setScanTimeValue] = useState<number>(7)
const [scanTimeUnit, setScanTimeUnit] = useState<string>('day')
const scanTimerRef = useRef<NodeJS.Timeout | null>(null)
const [importing, setImporting] = useState(false)
// 从 scanState.status 派生 scanning 状态
const scanning = scanState.status === 'running' || scanState.status === 'cancelling'
// 扫描历史状态
const [scanHistory, setScanHistory] = useState<{
items: Array<{
id: number
status: string
started_at: string
completed_at: string | null
time_range_value: number | null
time_range_unit: string | null
total_count: number
matched_count: number
imported_count: number
skipped_count: number
error_count: number
details_truncated: number
error_message: string | null
created_by: number | null
created_at: string
}>
pagination: { page: number; limit: number; total: number; totalPages: number }
} | null>(null)
const [historyPage, setHistoryPage] = useState(1)
const [loadingHistory, setLoadingHistory] = useState(false)
// 刷新扫描历史
const refreshScanHistory = useCallback(async (page = 1) => {
setLoadingHistory(true)
try {
const res = await fetch(`/api/monitor/scan-history?page=${page}&limit=10`)
const data = await res.json()
setScanHistory(data)
setHistoryPage(page)
} catch (err) {
console.error('[Scan] 历史加载失败:', err)
} finally {
setLoadingHistory(false)
}
}, [])
// 自动刷新定时器
const statusTimerRef = useRef<NodeJS.Timeout | null>(null)
const errorTimerRef = useRef<NodeJS.Timeout | null>(null)
// 页面可见性状态
const isVisibleRef = useRef(true)
// 刷新运行状态
const refreshStatus = useCallback(() => {
fetch('/api/monitor/status')
.then(r => r.json())
.then(setStatus)
.catch(err => console.error('[Monitor] 状态刷新失败:', err))
}, [])
// 刷新错误列表(更频繁)- 使用相同的 API但只更新错误部分
const refreshErrors = useCallback(() => {
fetch('/api/monitor/status')
.then(r => r.json())
.then(data => {
// 安全地合并错误列表,避免路径依赖
setStatus(prev => {
if (!prev || !data) return prev
return {
...prev,
stats: data.stats || prev.stats,
details: {
...prev.details,
recentErrors: data.details?.recentErrors ?? prev.details?.recentErrors ?? [],
},
}
})
})
.catch(err => console.error('[Monitor] 错误列表刷新失败:', err))
}, [])
// 刷新扫描状态
const refreshScanState = useCallback(() => {
fetch('/api/monitor/scan-status')
.then(r => r.json())
.then(data => {
setScanState(data)
// 扫描结束时停止轮询scanning 从 status 派生)
if (data.status !== 'running' && data.status !== 'cancelling') {
if (scanTimerRef.current) {
clearInterval(scanTimerRef.current)
scanTimerRef.current = null
}
}
})
.catch(err => {
console.error('[Scan] 状态刷新失败:', err)
// 网络异常时不停止轮询,等待下次重试
})
}, [])
// 页面可见性变化
useEffect(() => {
const handler = () => {
isVisibleRef.current = !document.hidden
if (isVisibleRef.current) {
// 页面恢复可见,立即刷新
refreshStatus()
refreshErrors()
if (scanning) refreshScanState()
}
}
document.addEventListener('visibilitychange', handler)
return () => document.removeEventListener('visibilitychange', handler)
}, [scanning, refreshStatus, refreshErrors, refreshScanState])
// 自动刷新定时器
useEffect(() => {
// 运行状态60 秒刷新
statusTimerRef.current = setInterval(() => {
if (isVisibleRef.current) refreshStatus()
}, 60000)
// 错误列表30 秒刷新
errorTimerRef.current = setInterval(() => {
if (isVisibleRef.current) refreshErrors()
}, 30000)
return () => {
if (statusTimerRef.current) clearInterval(statusTimerRef.current)
if (errorTimerRef.current) clearInterval(errorTimerRef.current)
}
}, [refreshStatus, refreshErrors])
// 初始加载
useEffect(() => {
Promise.all([
fetch('/api/monitor/status').then(r => r.json()),
fetch('/api/monitor/settings').then(r => r.json()),
fetch('/api/monitor/scan-status').then(r => r.json()),
fetch('/api/monitor/scan-history?page=1&limit=10').then(r => r.json()),
]).then(([s, c, scan, history]) => {
setStatus(s)
setConfig(c)
markClean('mail', { mail: c.mail })
markClean('filter', { filter: c.filter })
markClean('monitor', { monitor: c.monitor })
setScanState(scan)
setScanHistory(history)
setLoading(false)
}).catch(() => setLoading(false))
}, [])
// 立即检查处理
const handleTrigger = async () => {
if (triggerCooldown > 0) return
const res = await fetch('/api/monitor/trigger', { method: 'POST' })
const data = await res.json()
if (data.success) {
setMessage({ type: 'success', text: data.message })
setTimeout(refreshStatus, 2000)
} else {
setMessage({ type: 'error', text: data.error })
}
// 启动 30 秒冷却
setTriggerCooldown(30)
cooldownTimerRef.current = setInterval(() => {
setTriggerCooldown(prev => {
if (prev <= 1) {
if (cooldownTimerRef.current) clearInterval(cooldownTimerRef.current)
return 0
}
return prev - 1
})
}, 1000)
}
// 清理冷却定时器
useEffect(() => {
return () => {
if (cooldownTimerRef.current) clearInterval(cooldownTimerRef.current)
}
}, [])
// 启动扫描
const handleStartScan = async () => {
if (scanning) return
setScanState({ status: 'running' })
const res = await fetch('/api/monitor/scan-emails', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ timeRange: { value: scanTimeValue, unit: scanTimeUnit } }),
})
const data = await res.json()
if (!data.success) {
setMessage({ type: 'error', text: data.error })
setScanState({ status: 'error', error: data.error })
return
}
// 启动轮询
scanTimerRef.current = setInterval(refreshScanState, 3000)
}
// 取消扫描
const handleCancelScan = async () => {
const res = await fetch('/api/monitor/scan-status', { method: 'DELETE' })
const data = await res.json()
if (data.success) {
setMessage({ type: 'success', text: data.message })
setScanState(prev => ({ ...prev, status: 'cancelling' }))
} else {
setMessage({ type: 'error', text: data.error })
}
}
// 导入跳过的邮件为工单
const handleImportSkipped = async () => {
if (!scanState.details || scanState.status !== 'completed') return
const skippedEmails = scanState.details.filter(d =>
d.status === 'skipped' && d.order_number && d.error !== '工单已存在' && d.error !== '邮件已处理过'
)
if (skippedEmails.length === 0) {
setMessage({ type: 'error', text: '没有可导入的邮件(已跳过的邮件需要有工单号)' })
return
}
if (!confirm(`确认导入 ${skippedEmails.length} 封邮件为工单?`)) return
setImporting(true)
try {
const res = await fetch('/api/monitor/scan-emails/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emails: skippedEmails }),
})
const data = await res.json()
if (data.success) {
setMessage({ type: 'success', text: data.message })
// 刷新扫描状态
refreshScanState()
} else {
setMessage({ type: 'error', text: data.error })
}
} catch (err) {
setMessage({ type: 'error', text: '导入失败' })
} finally {
setImporting(false)
}
}
// 清理扫描定时器
useEffect(() => {
return () => {
if (scanTimerRef.current) clearInterval(scanTimerRef.current)
}
}, [])
// 其他原有函数保持不变...
const handleStart = async () => {
await fetch('/api/monitor/start', { method: 'POST' })
refreshStatus()
setMessage({ type: 'success', text: '监控已启用' })
}
const handleStop = async () => {
if (!confirm('确认停用邮件监控?')) return
await fetch('/api/monitor/stop', { method: 'POST' })
refreshStatus()
setMessage({ type: 'success', text: '监控已停用' })
}
const handleTestMail = async () => {
setSectionMessage(prev => ({ ...prev, mail: { type: 'success', text: '正在测试邮箱连接...' } }))
const res = await fetch('/api/monitor/test-mail', { method: 'POST' })
const data = await res.json()
setSectionMessage(prev => ({ ...prev, mail: { type: data.success ? 'success' : 'error', text: data.success ? data.message : data.error } }))
}
const handleSectionSave = async (section: SectionKey) => {
if (!config) return
setSaving(section)
setSectionMessage(prev => ({ ...prev, [section]: undefined }))
const sectionData = { [section]: config[section] }
const res = await fetch('/api/monitor/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(sectionData) })
const data = await res.json()
setSaving(null)
if (data.success) {
markClean(section, { [section]: config[section] })
setSectionMessage(prev => ({ ...prev, [section]: { type: 'success', text: '已保存' } }))
} else {
setSectionMessage(prev => ({ ...prev, [section]: { type: 'error', text: data.error } }))
}
setTimeout(() => setSectionMessage(prev => ({ ...prev, [section]: undefined })), 3000)
}
const addKeyword = () => {
if (!config || !newKeyword.trim()) return
setConfig({ ...config, filter: { ...config.filter, subject_keywords: [...config.filter.subject_keywords, newKeyword.trim()] } })
setNewKeyword('')
}
const removeKeyword = (index: number) => {
if (!config) return
setConfig({ ...config, filter: { ...config.filter, subject_keywords: config.filter.subject_keywords.filter((_, i) => i !== index) } })
}
const inputClass = (path: string, changed = false) =>
`w-full px-3 py-2 border rounded-lg text-sm transition-colors ${changed ? 'border-amber-400 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-600' : 'border-slate-300 dark:border-slate-600'}`
const SectionSaveButton = ({ section }: { section: SectionKey }) => {
if (!config) return null
const changed = isDirty(section, { [section]: config[section] })
if (!changed) return null
return (
<div className="mt-4 flex items-center gap-3">
<button
onClick={() => handleSectionSave(section)}
disabled={saving === section}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 text-sm font-medium"
>
{saving === section ? '保存中...' : '保存设置'}
</button>
</div>
)
}
if (loading) return <div className="p-6">...</div>
if (!config) return <div className="p-6"></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>
{message && (
<div className={`p-3 rounded-lg text-sm ${message.type === 'success' ? 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400' : 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400'}`}>
{message.text}
<button onClick={() => setMessage(null)} className="float-right font-bold">×</button>
</div>
)}
{/* 运行状态 */}
<Card className="p-5">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="flex items-center gap-4 mb-4">
<span className={`inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm font-medium ${status?.workerHealth === 'healthy' ? 'bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400' : status?.workerHealth === 'error' ? 'bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400' : status?.workerHealth === 'unresponsive' ? 'bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400' : 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'}`}>
<span className={`w-2 h-2 rounded-full ${status?.workerHealth === 'healthy' ? 'bg-green-500' : status?.workerHealth === 'error' || status?.workerHealth === 'unresponsive' ? 'bg-red-500' : 'bg-slate-400'}`} />
{status?.workerHealth === 'healthy' ? '运行中' : status?.workerHealth === 'error' ? '运行异常' : status?.workerHealth === 'unresponsive' ? '无响应' : '已停止'}
</span>
{status?.lastCheck && <span className="text-sm text-slate-500">: {status.lastCheck}</span>}
</div>
{/* 统计指标 */}
<div className="grid grid-cols-4 gap-4 mb-4">
{[
{ key: 'today' as const, label: '今日处理', value: status?.stats.todayProcessed || 0, color: 'text-blue-600 dark:text-blue-400' },
{ key: 'total' as const, label: '累计', value: status?.stats.totalProcessed || 0, color: 'text-slate-700 dark:text-slate-300' },
{ key: 'errors' as const, label: '错误', value: status?.stats.errors || 0, color: 'text-red-600 dark:text-red-400' },
{ key: 'ongoing' as const, label: '进行中故障', value: status?.ongoingFaults || 0, color: 'text-amber-600 dark:text-amber-400' },
].map(item => (
<div key={item.key}>
<span className="text-slate-500 text-sm">{item.label}: </span>
<button
onClick={() => setExpandedPanel(expandedPanel === item.key ? null : item.key)}
className={`font-medium text-sm cursor-pointer hover:opacity-70 underline decoration-dotted underline-offset-2 transition-opacity ${item.color}`}
>
{item.value}
<span className="ml-1 text-xs">{expandedPanel === item.key ? '▲' : '▼'}</span>
</button>
</div>
))}
</div>
{/* 展开面板 */}
{expandedPanel && (
<div className="mb-4 border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
{expandedPanel === 'today' && status?.details?.todayProcessed && (
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<button onClick={() => setExpandedPanel(null)} className="text-xs text-slate-500 hover:text-slate-700"></button>
</div>
{status.details.todayProcessed.length === 0 ? (
<p className="text-sm text-slate-500"></p>
) : (
<div className="space-y-2 max-h-48 overflow-y-auto">
{status.details.todayProcessed.map((item, i) => (
<div key={i} className="flex items-center justify-between text-sm py-1 border-b border-slate-100 dark:border-slate-800 last:border-0">
<span className="text-slate-700 dark:text-slate-300">
{item.ticket_no ? `工单 ${item.ticket_no}` : item.error || '处理完成'}
</span>
<span className="text-slate-400 text-xs">{item.created_at}</span>
</div>
))}
</div>
)}
</div>
)}
{expandedPanel === 'total' && (
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<button onClick={() => setExpandedPanel(null)} className="text-xs text-slate-500 hover:text-slate-700"></button>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<span className="text-slate-500"></span>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{status?.stats.totalProcessed || 0}</p>
</div>
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<span className="text-slate-500"></span>
<p className="text-2xl font-bold text-blue-600">{status?.stats.todayProcessed || 0}</p>
</div>
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<span className="text-slate-500"></span>
<p className="text-2xl font-bold text-red-600">{status?.stats.errors || 0}</p>
</div>
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<span className="text-slate-500"></span>
<p className="text-2xl font-bold text-amber-600">{status?.ongoingFaults || 0}</p>
</div>
</div>
</div>
)}
{expandedPanel === 'errors' && status?.details?.recentErrors && (
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-red-700 dark:text-red-400"></span>
<button onClick={() => setExpandedPanel(null)} className="text-xs text-slate-500 hover:text-slate-700"></button>
</div>
{status.details.recentErrors.length === 0 ? (
<p className="text-sm text-slate-500"></p>
) : (
<div className="space-y-2 max-h-48 overflow-y-auto">
{status.details.recentErrors.map((item, i) => (
<div key={i} className="p-2 bg-red-50 dark:bg-red-900/20 rounded text-xs">
<div className="flex items-center justify-between mb-1">
<span className="text-red-700 dark:text-red-400 font-medium"></span>
<span className="text-slate-400">{item.created_at}</span>
</div>
<pre className="text-red-600 dark:text-red-400 whitespace-pre-wrap break-all">
{(() => {
const errStr = item.error || item.details || JSON.stringify(item)
try { return JSON.stringify(JSON.parse(errStr), null, 2) }
catch { return errStr }
})()}
</pre>
</div>
))}
</div>
)}
</div>
)}
{expandedPanel === 'ongoing' && status?.details?.ongoingFaults && (
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-amber-700 dark:text-amber-400"></span>
<button onClick={() => setExpandedPanel(null)} className="text-xs text-slate-500 hover:text-slate-700"></button>
</div>
{status.details.ongoingFaults.length === 0 ? (
<p className="text-sm text-slate-500"></p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700 text-left">
<th className="py-2 pr-4 text-slate-500 font-medium"></th>
<th className="py-2 pr-4 text-slate-500 font-medium">IP</th>
<th className="py-2 pr-4 text-slate-500 font-medium">SN</th>
<th className="py-2 pr-4 text-slate-500 font-medium"></th>
<th className="py-2 pr-4 text-slate-500 font-medium"></th>
<th className="py-2 text-slate-500 font-medium"></th>
</tr>
</thead>
<tbody>
{status.details.ongoingFaults.map((fault, i) => (
<tr key={i} className="border-b border-slate-100 dark:border-slate-800 last:border-0">
<td className="py-2 pr-4 font-mono text-xs">{fault.order_number || '-'}</td>
<td className="py-2 pr-4">{fault.server_ip || '未知'}</td>
<td className="py-2 pr-4 font-mono text-xs">{fault.server_sn}</td>
<td className="py-2 pr-4">
<span className={`px-2 py-0.5 rounded text-xs ${fault.fault_type === 'oem_diag' ? 'bg-blue-100 text-blue-700' : fault.fault_type === 'oem_repair' ? 'bg-purple-100 text-purple-700' : 'bg-slate-100 text-slate-600'}`}>
{fault.fault_type === 'oem_diag' ? '诊断' : fault.fault_type === 'oem_repair' ? '维修' : '未知'}
</span>
</td>
<td className="py-2 pr-4 text-xs">{fault.fault_time}</td>
<td className="py-2 text-xs max-w-xs truncate" title={fault.fault_detail || ''}>{fault.fault_detail || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
)}
<div className="flex gap-2">
{status?.enabled ? <button onClick={handleStop} className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 text-sm"></button> : <button onClick={handleStart} className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 text-sm"></button>}
<button
onClick={handleTrigger}
disabled={triggerCooldown > 0}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{triggerCooldown > 0 ? `请等待 ${triggerCooldown}` : '立即检查'}
</button>
</div>
</Card>
{/* 邮箱配置 */}
<Card className="p-5">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1"></label>
<input type="text" value={config.mail.address} onChange={e => setConfig({ ...config, mail: { ...config.mail, address: e.target.value } })} className={inputClass('mail.address')} />
</div>
<div>
<label className="block text-sm font-medium mb-1"></label>
<input type="password" value={config.mail.password} onChange={e => setConfig({ ...config, mail: { ...config.mail, password: e.target.value } })} className={inputClass('mail.password')} placeholder="••••••••" />
</div>
<div>
<label className="block text-sm font-medium mb-1">IMAP </label>
<input type="text" value={config.mail.imap_server} onChange={e => setConfig({ ...config, mail: { ...config.mail, imap_server: e.target.value } })} className={inputClass('mail.imap_server')} />
</div>
<div>
<label className="block text-sm font-medium mb-1">IMAP </label>
<input type="number" value={config.mail.imap_port} onChange={e => setConfig({ ...config, mail: { ...config.mail, imap_port: parseInt(e.target.value) || 993 } })} className={inputClass('mail.imap_port')} />
</div>
<div>
<label className="block text-sm font-medium mb-1">POP3 </label>
<input type="text" value={config.mail.pop3_server} onChange={e => setConfig({ ...config, mail: { ...config.mail, pop3_server: e.target.value } })} className={inputClass('mail.pop3_server')} />
</div>
<div>
<label className="block text-sm font-medium mb-1">POP3 </label>
<input type="number" value={config.mail.pop3_port} onChange={e => setConfig({ ...config, mail: { ...config.mail, pop3_port: parseInt(e.target.value) || 995 } })} className={inputClass('mail.pop3_port')} />
</div>
<div>
<label className="block text-sm font-medium mb-1">SMTP </label>
<input type="text" value={config.mail.smtp_server} onChange={e => setConfig({ ...config, mail: { ...config.mail, smtp_server: e.target.value } })} className={inputClass('mail.smtp_server')} />
</div>
<div>
<label className="block text-sm font-medium mb-1">SMTP </label>
<input type="number" value={config.mail.smtp_port} onChange={e => setConfig({ ...config, mail: { ...config.mail, smtp_port: parseInt(e.target.value) || 465 } })} className={inputClass('mail.smtp_port')} />
</div>
</div>
<div className="mt-4 flex items-center gap-3">
<button onClick={handleTestMail} className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-sm"></button>
{sectionMessage.mail && (
<span className={`text-sm ${sectionMessage.mail.type === 'success' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
{sectionMessage.mail.text}
</span>
)}
</div>
<SectionSaveButton section="mail" />
</Card>
{/* 邮件过滤 */}
<Card className="p-5">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1"></label>
<div className="flex flex-wrap gap-2 mb-2">
{config.filter.subject_keywords.map((kw, i) => (
<span key={i} className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
{kw}
<button onClick={() => removeKeyword(i)} className="hover:opacity-70">×</button>
</span>
))}
</div>
<div className="flex gap-2">
<input type="text" value={newKeyword} onChange={e => setNewKeyword(e.target.value)} onKeyDown={e => e.key === 'Enter' && addKeyword()} className="flex-1 px-3 py-2 border rounded-lg text-sm" placeholder="输入关键词后回车" />
<button onClick={addKeyword} className="px-3 py-2 bg-blue-500 text-white rounded-lg text-sm"></button>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1"></label>
<input type="text" value={config.filter.sender_email} onChange={e => setConfig({ ...config, filter: { ...config.filter, sender_email: e.target.value } })} className={inputClass('filter.sender_email')} />
</div>
</div>
<SectionSaveButton section="filter" />
</Card>
{/* 邮箱工单检测(新模块) */}
<Card className="p-5">
<h2 className="text-lg font-semibold mb-4"></h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-4"></p>
{/* 时间范围选择 */}
<div className="flex items-center gap-3 mb-4">
<span className="text-sm text-slate-600 dark:text-slate-400"></span>
<input
type="number"
value={scanTimeValue}
onChange={e => setScanTimeValue(parseInt(e.target.value) || 1)}
className="w-20 px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
min={1}
max={365}
disabled={scanning}
/>
<select
value={scanTimeUnit}
onChange={e => setScanTimeUnit(e.target.value)}
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
disabled={scanning}
>
<option value="minute"></option>
<option value="hour"></option>
<option value="day"></option>
<option value="week"></option>
<option value="month"></option>
<option value="all"></option>
</select>
<button
onClick={handleStartScan}
disabled={scanning}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2"
>
{scanning ? (
<>
<RefreshCw size={14} className="animate-spin" />
...
</>
) : (
<>
<Search size={14} />
</>
)}
</button>
{scanning && (
<button
onClick={handleCancelScan}
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 text-sm inline-flex items-center gap-2"
>
</button>
)}
</div>
{/* 扫描进度 */}
{scanState.status === 'running' && (
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div className="flex items-center gap-2 text-sm text-blue-700 dark:text-blue-400">
<RefreshCw size={14} className="animate-spin" />
...
</div>
{scanState.stats && (
<div className="mt-2 text-xs text-slate-600 dark:text-slate-400">
{scanState.stats.total} {scanState.stats.imported} {scanState.stats.skipped}
</div>
)}
</div>
)}
{/* 扫描取消中 */}
{scanState.status === 'cancelling' && (
<div className="mb-4 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg">
<div className="flex items-center gap-2 text-sm text-amber-700 dark:text-amber-400">
<RefreshCw size={14} className="animate-spin" />
...
</div>
{scanState.stats && (
<div className="mt-2 text-xs text-slate-600 dark:text-slate-400">
{scanState.stats.total} {scanState.stats.imported + scanState.stats.skipped}
</div>
)}
</div>
)}
{/* 扫描结果 */}
{(scanState.status === 'completed' || scanState.status === 'cancelling') && scanState.stats && (
<div className="space-y-4">
{scanState.error && (
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg text-sm text-amber-700 dark:text-amber-400">
{scanState.error}
</div>
)}
{scanState.detailsTruncated && (
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg text-sm text-amber-700 dark:text-amber-400">
500
</div>
)}
<div className="grid grid-cols-5 gap-4 text-sm">
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<span className="text-slate-500"></span>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">{scanState.stats.total}</p>
</div>
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<span className="text-slate-500"></span>
<p className="text-xl font-bold text-blue-600">{scanState.stats.matched}</p>
</div>
<div className="p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<span className="text-green-600"></span>
<p className="text-xl font-bold text-green-600">{scanState.stats.imported}</p>
</div>
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<span className="text-slate-500"></span>
<p className="text-xl font-bold text-slate-600">{scanState.stats.skipped}</p>
</div>
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<span className="text-red-600"></span>
<p className="text-xl font-bold text-red-600">{scanState.stats.errors}</p>
</div>
</div>
{/* 详细结果列表 */}
{scanState.details && scanState.details.length > 0 && (
<div className="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
<div className="max-h-64 overflow-y-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 dark:bg-slate-800 sticky top-0">
<tr>
<th className="py-2 px-4 text-left text-slate-500 font-medium"></th>
<th className="py-2 px-4 text-left text-slate-500 font-medium"></th>
<th className="py-2 px-4 text-left text-slate-500 font-medium"></th>
<th className="py-2 px-4 text-left text-slate-500 font-medium"></th>
</tr>
</thead>
<tbody>
{scanState.details.map((item, i) => (
<tr key={i} className="border-t border-slate-100 dark:border-slate-800">
<td className="py-2 px-4 font-mono text-xs">{item.order_number || '-'}</td>
<td className="py-2 px-4 text-xs max-w-xs truncate" title={item.subject}>{item.subject || '-'}</td>
<td className="py-2 px-4">
<span className={`px-2 py-0.5 rounded text-xs ${
item.status === 'imported' ? 'bg-green-100 text-green-700' :
item.status === 'skipped' ? 'bg-slate-100 text-slate-600' :
'bg-red-100 text-red-700'
}`}>
{item.status === 'imported' ? '已导入' : item.status === 'skipped' ? '已跳过' : '错误'}
</span>
</td>
<td className="py-2 px-4 text-xs text-slate-500">{item.error || item.ticket_no || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* 导入按钮 */}
{scanState.status === 'completed' && scanState.details && scanState.details.some(d =>
d.status === 'skipped' && d.order_number && d.error !== '工单已存在' && d.error !== '邮件已处理过'
) && (
<div className="flex items-center gap-3">
<button
onClick={handleImportSkipped}
disabled={importing}
className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 text-sm disabled:opacity-50 inline-flex items-center gap-2"
>
{importing ? (
<>
<RefreshCw size={14} className="animate-spin" />
...
</>
) : (
'导入跳过的邮件为工单'
)}
</button>
<span className="text-sm text-slate-500">
{scanState.details.filter(d =>
d.status === 'skipped' && d.order_number && d.error !== '工单已存在' && d.error !== '邮件已处理过'
).length}
</span>
</div>
)}
</div>
)}
{/* 扫描错误 */}
{scanState.status === 'error' && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg text-sm text-red-700 dark:text-red-400">
: {scanState.error || '未知错误'}
</div>
)}
</Card>
{/* 全局运行参数 */}
<Card className="p-5">
<h2 className="text-lg font-semibold mb-4"></h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-4">使</p>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium mb-1"></label>
<input type="number" value={config.monitor.interval_seconds} onChange={e => setConfig({ ...config, monitor: { ...config.monitor, interval_seconds: parseInt(e.target.value) || 60 } })} className={inputClass('monitor.interval_seconds')} min={10} max={3600} />
</div>
<div>
<label className="block text-sm font-medium mb-1"></label>
<input type="number" value={config.monitor.silent_start_hour} onChange={e => setConfig({ ...config, monitor: { ...config.monitor, silent_start_hour: parseInt(e.target.value) || 0 } })} className={inputClass('monitor.silent_start_hour')} min={0} max={23} />
</div>
<div>
<label className="block text-sm font-medium mb-1"></label>
<input type="number" value={config.monitor.silent_end_hour} onChange={e => setConfig({ ...config, monitor: { ...config.monitor, silent_end_hour: parseInt(e.target.value) || 7 } })} className={inputClass('monitor.silent_end_hour')} min={0} max={23} />
</div>
</div>
<SectionSaveButton section="monitor" />
</Card>
{/* 扫描历史 */}
<Card className="p-5">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1"></p>
</div>
<button
onClick={() => refreshScanHistory(1)}
disabled={loadingHistory}
className="px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-sm inline-flex items-center gap-1.5"
>
<RefreshCw size={14} className={loadingHistory ? 'animate-spin' : ''} />
</button>
</div>
{scanHistory && scanHistory.items.length > 0 ? (
<div className="space-y-4">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 dark:bg-slate-800">
<tr>
<th className="py-2 px-4 text-left text-slate-500 font-medium"></th>
<th className="py-2 px-4 text-left text-slate-500 font-medium"></th>
<th className="py-2 px-4 text-left text-slate-500 font-medium"></th>
<th className="py-2 px-4 text-left text-slate-500 font-medium"></th>
<th className="py-2 px-4 text-left text-slate-500 font-medium"></th>
<th className="py-2 px-4 text-left text-slate-500 font-medium"></th>
<th className="py-2 px-4 text-left text-slate-500 font-medium"></th>
<th className="py-2 px-4 text-left text-slate-500 font-medium"></th>
</tr>
</thead>
<tbody>
{scanHistory.items.map((item) => (
<tr key={item.id} className="border-t border-slate-100 dark:border-slate-800">
<td className="py-2 px-4 text-xs">{item.started_at}</td>
<td className="py-2 px-4 text-xs">
{item.time_range_unit === 'all' ? '全部' : `${item.time_range_value || ''}${item.time_range_unit === 'minute' ? '分钟' : item.time_range_unit === 'hour' ? '小时' : item.time_range_unit === 'day' ? '天' : item.time_range_unit === 'week' ? '周' : '月'}`}
</td>
<td className="py-2 px-4">
<span className={`px-2 py-0.5 rounded text-xs ${
item.status === 'completed' ? 'bg-green-100 text-green-700' :
item.status === 'error' ? 'bg-red-100 text-red-700' :
item.status === 'cancelling' ? 'bg-amber-100 text-amber-700' :
'bg-slate-100 text-slate-600'
}`}>
{item.status === 'completed' ? '完成' : item.status === 'error' ? '错误' : item.status === 'cancelling' ? '已取消' : item.status}
</span>
</td>
<td className="py-2 px-4 text-xs font-medium">{item.total_count}</td>
<td className="py-2 px-4 text-xs text-green-600">{item.imported_count}</td>
<td className="py-2 px-4 text-xs text-slate-500">{item.skipped_count}</td>
<td className="py-2 px-4 text-xs text-red-600">{item.error_count}</td>
<td className="py-2 px-4 text-xs text-slate-500">{item.created_by || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 分页 */}
{scanHistory.pagination.totalPages > 1 && (
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500">
{scanHistory.pagination.total}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => refreshScanHistory(historyPage - 1)}
disabled={historyPage <= 1 || loadingHistory}
className="px-3 py-1 border border-slate-300 dark:border-slate-600 rounded disabled:opacity-50"
>
</button>
<span className="text-slate-500">
{historyPage} / {scanHistory.pagination.totalPages}
</span>
<button
onClick={() => refreshScanHistory(historyPage + 1)}
disabled={historyPage >= scanHistory.pagination.totalPages || loadingHistory}
className="px-3 py-1 border border-slate-300 dark:border-slate-600 rounded disabled:opacity-50"
>
</button>
</div>
</div>
)}
</div>
) : (
<p className="text-sm text-slate-500 text-center py-4">
{loadingHistory ? '加载中...' : '暂无扫描历史'}
</p>
)}
</Card>
</div>
)
}

View File

@ -1,10 +1,11 @@
import Link from 'next/link' import Link from 'next/link'
import { Card } from '@/components/ui' import { Card } from '@/components/ui'
import { Users, Shield, Settings as SettingsIcon } from 'lucide-react' import { Users, Shield, Settings as SettingsIcon, Mail } from 'lucide-react'
const sections = [ const sections = [
{ href: '/settings/users', label: '用户管理', description: '管理系统用户与账号', icon: Users }, { href: '/settings/users', label: '用户管理', description: '管理系统用户与账号', icon: Users },
{ href: '/settings/roles', label: '角色权限', description: '配置角色与权限', icon: Shield }, { href: '/settings/roles', label: '角色权限', description: '配置角色与权限', icon: Shield },
{ href: '/settings/monitor', label: '邮件监控', description: '邮件监控与微信推送配置', icon: Mail },
] ]
export default function SettingsPage() { export default function SettingsPage() {

View File

@ -11,15 +11,26 @@ interface Role {
created_at: string created_at: string
} }
const BUILTIN_ROLES = ['admin', 'operator', 'viewer']
const allPermissions = [ const allPermissions = [
{ key: 'tickets:read', label: '查看工单' }, { key: 'tickets:read', label: '查看工单' },
{ key: 'tickets:write', label: '编辑工单' }, { key: 'tickets:create', label: '手动建单' },
{ key: 'tickets:import', label: '导入工单' },
{ key: 'tickets:export', label: '导出工单' },
{ key: 'tickets:edit', label: '处理工单' },
{ key: 'tickets:delete', label: '删除工单' },
{ key: 'reports:read', label: '查看报告' }, { key: 'reports:read', label: '查看报告' },
{ key: 'reports:write', label: '编辑报告' }, { key: 'reports:download', label: '下载报告' },
{ key: 'reports:create', label: '新建报告' },
{ key: 'users:read', label: '查看用户' }, { key: 'users:read', label: '查看用户' },
{ key: 'users:write', label: '编辑用户' }, { key: 'users:write', label: '编辑用户' },
{ key: 'roles:read', label: '查看角色' }, { key: 'roles:read', label: '查看角色' },
{ key: 'roles:write', label: '编辑角色' }, { key: 'roles:write', label: '编辑角色' },
{ key: 'api-keys:read', label: '查看 API Key' },
{ key: 'api-keys:write', label: '编辑 API Key' },
{ key: 'audit-logs:read', label: '查看审计日志' },
{ key: 'audit-logs:export', label: '导出审计日志' },
] ]
export default function RolesPage() { export default function RolesPage() {
@ -29,6 +40,7 @@ export default function RolesPage() {
const [editRole, setEditRole] = useState<Role | null>(null) const [editRole, setEditRole] = useState<Role | null>(null)
const [form, setForm] = useState({ name: '', display_name: '', permissions: [] as string[] }) const [form, setForm] = useState({ name: '', display_name: '', permissions: [] as string[] })
const [error, setError] = useState('') const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const fetchRoles = () => { const fetchRoles = () => {
fetch('/api/roles').then(r => r.json()).then(d => { if (d.roles) setRoles(d.roles) }).catch(() => {}).finally(() => setLoading(false)) fetch('/api/roles').then(r => r.json()).then(d => { if (d.roles) setRoles(d.roles) }).catch(() => {}).finally(() => setLoading(false))
@ -61,6 +73,7 @@ export default function RolesPage() {
const handleSave = async () => { const handleSave = async () => {
setError('') setError('')
setSaving(true)
try { try {
if (editRole) { 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 }) }) 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 }) })
@ -73,6 +86,7 @@ export default function RolesPage() {
setModalOpen(false) setModalOpen(false)
fetchRoles() fetchRoles()
} catch { setError('操作失败') } } catch { setError('操作失败') }
finally { setSaving(false) }
} }
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
@ -90,16 +104,14 @@ export default function RolesPage() {
} catch { return '无权限' } } catch { return '无权限' }
} }
const builtinRoles = ['admin', 'operator', 'viewer']
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100"></h1> <h1 className="text-2xl font-bold text-slate-900 dark:text-white"></h1>
<p className="text-slate-500 dark:text-slate-400 mt-1"></p> <p className="text-sm text-slate-500 dark:text-slate-400 mt-1"></p>
</div> </div>
<Button size="sm" onClick={openCreate}><Plus size={16} className="mr-1" /></Button> <Button size="sm" onClick={openCreate}><Plus size={16} /></Button>
</div> </div>
{loading ? ( {loading ? (
@ -109,12 +121,19 @@ export default function RolesPage() {
{roles.map(r => ( {roles.map(r => (
<tr key={r.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors"> <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 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-700 dark:text-slate-300">
<div className="flex items-center gap-2">
<span>{r.display_name}</span>
{BUILTIN_ROLES.includes(r.name) && (
<Badge variant={r.name === 'admin' ? 'info' : r.name === 'operator' ? 'success' : 'default'}></Badge>
)}
</div>
</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 text-slate-500 dark:text-slate-400 text-sm">{formatPermissions(r.permissions)}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(r)}><Pencil size={14} /></Button> <Button variant="ghost" size="sm" onClick={() => openEdit(r)}><Pencil size={14} /></Button>
{!builtinRoles.includes(r.name) && ( {!BUILTIN_ROLES.includes(r.name) && (
<Button variant="ghost" size="sm" onClick={() => handleDelete(r.id)}><Trash2 size={14} className="text-red-500" /></Button> <Button variant="ghost" size="sm" onClick={() => handleDelete(r.id)}><Trash2 size={14} className="text-red-500" /></Button>
)} )}
</div> </div>
@ -124,16 +143,21 @@ export default function RolesPage() {
</Table> </Table>
)} )}
<Modal open={modalOpen} onClose={() => setModalOpen(false)} title={editRole ? '编辑角色' : '新建角色'}> <Modal open={modalOpen} onClose={() => setModalOpen(false)} title={editRole ? '编辑角色' : '新建角色'} footer={
<>
<Button variant="ghost" onClick={() => setModalOpen(false)}></Button>
<Button onClick={handleSave} loading={saving}>{editRole ? '保存' : '创建'}</Button>
</>
}>
<div className="space-y-4"> <div className="space-y-4">
{!editRole && <Input label="角色名(英文)" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} placeholder="e.g. supervisor" />} {!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 }))} /> <Input label="显示名称" value={form.display_name} onChange={e => setForm(p => ({ ...p, display_name: e.target.value }))} />
<div> <div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"></label> <label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"></label>
<div className="space-y-2"> <div className="space-y-2 max-h-64 overflow-y-auto">
{allPermissions.map(p => ( {allPermissions.map(p => (
<label key={p.key} className="flex items-center gap-2 cursor-pointer"> <label key={p.key} className="flex items-center gap-2 cursor-pointer py-1">
<input type="checkbox" checked={form.permissions.includes(p.key) || form.permissions.includes('*')} onChange={() => togglePermission(p.key)} className="rounded border-slate-300" /> <input type="checkbox" checked={form.permissions.includes(p.key) || form.permissions.includes('*')} onChange={() => togglePermission(p.key)} className="rounded border-slate-300 dark:border-slate-600" />
<span className="text-sm text-slate-700 dark:text-slate-300">{p.label}</span> <span className="text-sm text-slate-700 dark:text-slate-300">{p.label}</span>
<span className="text-xs text-slate-400">{p.key}</span> <span className="text-xs text-slate-400">{p.key}</span>
</label> </label>
@ -141,10 +165,6 @@ export default function RolesPage() {
</div> </div>
</div> </div>
{error && <p className="text-sm text-red-500">{error}</p>} {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> </div>
</Modal> </Modal>
</div> </div>

View File

@ -0,0 +1,113 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { Button, Badge, Modal, Input, Select } from '@/components/ui'
import { ArrowLeft, Edit } from 'lucide-react'
interface UserDetail {
id: number; username: string; display_name: string; email: string | null
role: string; is_active: number; created_at: string; updated_at: string
last_login_at: string | null; is_online: number
}
export default function UserDetailPage() {
const { id } = useParams()
const router = useRouter()
const [user, setUser] = useState<UserDetail | null>(null)
const [loading, setLoading] = useState(true)
const [editOpen, setEditOpen] = useState(false)
const [form, setForm] = useState({ display_name: '', email: '', role: '', password: '', password_confirm: '', is_active: 1 })
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
async function fetchUser() {
const res = await fetch(`/api/users/${id}`)
if (res.ok) {
const d = await res.json()
setUser(d.user)
setForm({ display_name: d.user.display_name, email: d.user.email || '', role: d.user.role, password: '', password_confirm: '', is_active: d.user.is_active })
}
setLoading(false)
}
useEffect(() => { fetchUser() }, [id])
function openEdit() { setError(''); setEditOpen(true) }
async function handleSave() {
setSaving(true); setError('')
try {
if (form.password && form.password !== form.password_confirm) { setError('两次输入的密码不一致'); return }
const body: Record<string, unknown> = { display_name: form.display_name, email: form.email, is_active: form.is_active }
if (form.password) body.password = form.password
if (user!.username !== 'admin' && user!.username !== 'localadmin') body.role = form.role
const res = await fetch(`/api/users/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
if (!res.ok) { const d = await res.json(); setError(d.error || '更新失败'); return }
setEditOpen(false)
fetchUser()
} finally { setSaving(false) }
}
if (loading) return <div className="py-20 text-center text-slate-500 dark:text-slate-400">...</div>
if (!user) return <div className="py-20 text-center text-slate-500 dark:text-slate-400"></div>
const roleLabel: Record<string, string> = { admin: '管理员', operator: '运维人员', viewer: '查看者' }
return (
<div className="space-y-6">
<button onClick={() => router.back()} className="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors">
<ArrowLeft size={16} />
</button>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white"> {user.display_name}</h1>
<p className="text-slate-500 dark:text-slate-400 mt-1"></p>
</div>
<Button size="sm" onClick={openEdit}><Edit size={16} /></Button>
</div>
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-700 divide-y divide-slate-200 dark:divide-slate-700">
{[
{ label: '用户名', value: user.username },
{ label: '显示名称', value: user.display_name },
{ label: '邮箱', value: user.email || '-' },
{ label: '角色', value: <Badge variant="info">{roleLabel[user.role] || user.role}</Badge> },
{ label: '状态', value: <Badge variant={user.is_active ? 'success' : 'danger'}>{user.is_active ? '启用' : '禁用'}</Badge> },
{ label: '在线', value: <span className="inline-flex items-center gap-1.5"><span className={`inline-block w-2 h-2 rounded-full ${user.is_online ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'}`} /><span className="text-xs text-slate-500">{user.is_online ? '在线' : '离线'}</span></span> },
{ label: '最后登录', value: user.last_login_at ? <span className="text-sm text-slate-500">{user.last_login_at}</span> : <span className="text-sm text-slate-400"></span> },
{ label: '创建时间', value: <span className="text-sm text-slate-500">{user.created_at}</span> },
].map(row => (
<div key={row.label} className="flex items-center px-6 py-4">
<span className="w-24 text-sm font-medium text-slate-600 dark:text-slate-400 shrink-0">{row.label}</span>
<span className="text-sm text-slate-900 dark:text-slate-100">{row.value}</span>
</div>
))}
</div>
<Modal open={editOpen} onClose={() => setEditOpen(false)} title="编辑用户" footer={
<>
<Button variant="ghost" onClick={() => setEditOpen(false)}></Button>
<Button onClick={handleSave} loading={saving}></Button>
</>
}>
<div className="space-y-4">
{error && <p className="text-sm text-red-500">{error}</p>}
<Input label="显示名称" value={form.display_name} onChange={e => setForm(p => ({ ...p, display_name: e.target.value }))} />
<Input label="邮箱" type="email" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} />
<Select label="状态" value={String(form.is_active)} onChange={e => setForm(p => ({ ...p, is_active: parseInt(e.target.value) }))} options={[{ value: '1', label: '启用' }, { value: '0', label: '禁用' }]} />
<Input label="新密码(留空不修改)" type="password" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
{form.password && <Input label="确认新密码" type="password" value={form.password_confirm} onChange={e => setForm(p => ({ ...p, password_confirm: e.target.value }))} />}
{user.username === 'admin' || user.username === 'localadmin' ? (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"></label>
<p className="text-sm text-slate-500 dark:text-slate-400 py-2"></p>
</div>
) : (
<Select label="角色" options={[{ value: 'viewer', label: '查看者' }, { value: 'operator', label: '运维人员' }, { value: 'admin', label: '管理员' }]} value={form.role} onChange={e => setForm(p => ({ ...p, role: e.target.value }))} />
)}
</div>
</Modal>
</div>
)
}

View File

@ -1,7 +1,8 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Card, Button, Table, Badge, Modal, Input, Select } from '@/components/ui' import { Button, Table, Badge, Modal, Input, Select } from '@/components/ui'
import { Plus, Pencil, Trash2 } from 'lucide-react' import { Plus, Trash2 } from 'lucide-react'
import Link from 'next/link'
interface User { interface User {
id: number id: number
@ -11,15 +12,18 @@ interface User {
role: string role: string
is_active: number is_active: number
created_at: string created_at: string
last_login_at: string | null
is_online: number
} }
export default function UsersPage() { export default function UsersPage() {
const [users, setUsers] = useState<User[]>([]) const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [modalOpen, setModalOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [editUser, setEditUser] = useState<User | null>(null) const [createForm, setCreateForm] = useState({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
const [form, setForm] = useState({ username: '', password: '', display_name: '', email: '', role: 'viewer' }) const [createError, setCreateError] = useState('')
const [error, setError] = useState('') const [creating, setCreating] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<User | null>(null)
const fetchUsers = () => { const fetchUsers = () => {
fetch('/api/users').then(r => r.json()).then(d => { if (d.users) setUsers(d.users) }).catch(() => {}).finally(() => setLoading(false)) fetch('/api/users').then(r => r.json()).then(d => { if (d.users) setUsers(d.users) }).catch(() => {}).finally(() => setLoading(false))
@ -27,42 +31,22 @@ export default function UsersPage() {
useEffect(() => { fetchUsers() }, []) useEffect(() => { fetchUsers() }, [])
const openCreate = () => { const handleCreate = async () => {
setEditUser(null) setCreateError(''); setCreating(true)
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 { try {
if (editUser) { if (!createForm.username || !createForm.password || !createForm.display_name) { setCreateError('请填写必填项'); return }
const body: Record<string, unknown> = { display_name: form.display_name, email: form.email, role: form.role } const res = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(createForm) })
if (form.password) body.password = form.password if (!res.ok) { const d = await res.json(); setCreateError(d.error || '创建失败'); return }
const res = await fetch(`/api/users/${editUser.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) setCreateOpen(false)
if (!res.ok) { const d = await res.json(); setError(d.error || '更新失败'); return } setCreateForm({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
} 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() fetchUsers()
} catch { setError('操作失败') } } catch { setCreateError('操作失败') } finally { setCreating(false) }
} }
const handleDelete = async (id: number) => { const handleDelete = async () => {
if (!confirm('确定删除此用户?')) return if (!deleteTarget) return
const res = await fetch(`/api/users/${id}`, { method: 'DELETE' }) const res = await fetch(`/api/users/${deleteTarget.id}`, { method: 'DELETE' })
if (res.ok) fetchUsers() if (res.ok) { setDeleteTarget(null); fetchUsers() }
} }
const handleToggleActive = async (u: User) => { const handleToggleActive = async (u: User) => {
@ -76,32 +60,41 @@ export default function UsersPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100"></h1> <h1 className="text-2xl font-bold text-slate-900 dark:text-white"></h1>
<p className="text-slate-500 dark:text-slate-400 mt-1"></p> <p className="text-slate-500 dark:text-slate-400 mt-1"></p>
</div> </div>
<Button size="sm" onClick={openCreate}><Plus size={16} className="mr-1" /></Button> <Button size="sm" onClick={() => { setCreateError(''); setCreateOpen(true) }}><Plus size={16} /></Button>
</div> </div>
{loading ? ( {loading ? (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">...</div> <div className="text-center py-12 text-slate-500 dark:text-slate-400">...</div>
) : ( ) : (
<Table headers={['用户名', '显示名称', '邮箱', '角色', '状态', '创建时间', '操作']}> <Table headers={['用户名', '显示名称', '邮箱', '角色', '状态', '在线', '最后登录', '操作']}>
{users.map(u => ( {users.map(u => (
<tr key={u.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors"> <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">
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{u.display_name}</td> <Link href={`/settings/users/${u.id}`} className="text-blue-600 dark:text-blue-400 hover:underline font-medium">{u.username}</Link>
</td>
<td className="px-4 py-3 text-slate-900 dark:text-white font-medium">{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 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"><Badge variant={u.role === 'admin' ? 'info' : u.role === 'operator' ? 'success' : 'default'}>{roleLabel[u.role] || u.role}</Badge></td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<button onClick={() => handleToggleActive(u)}> <button onClick={() => handleToggleActive(u)}>
<Badge variant={u.is_active ? 'success' : 'danger'}>{u.is_active ? '启用' : '禁用'}</Badge> <Badge variant={u.is_active ? 'success' : 'danger'}>{u.is_active ? '启用' : '禁用'}</Badge>
</button> </button>
</td> </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">
<span className="inline-flex items-center gap-1.5">
<span className={`inline-block w-2 h-2 rounded-full ${u.is_online ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'}`} />
<span className="text-slate-500 dark:text-slate-400">{u.is_online ? '在线' : '离线'}</span>
</span>
</td>
<td className="px-4 py-3 text-slate-500 dark:text-slate-400">{u.last_login_at ? u.last_login_at : <span className="text-slate-400 dark:text-slate-500"></span>}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(u)}><Pencil size={14} /></Button> {u.username !== 'admin' && u.username !== 'localadmin' && (
<Button variant="ghost" size="sm" onClick={() => handleDelete(u.id)}><Trash2 size={14} className="text-red-500" /></Button> <Button variant="ghost" size="sm" onClick={() => setDeleteTarget(u)}><Trash2 size={14} className="text-red-500" /></Button>
)}
</div> </div>
</td> </td>
</tr> </tr>
@ -109,20 +102,30 @@ export default function UsersPage() {
</Table> </Table>
)} )}
<Modal open={modalOpen} onClose={() => setModalOpen(false)} title={editUser ? '编辑用户' : '新建用户'}> <Modal open={createOpen} onClose={() => setCreateOpen(false)} title="新建用户" footer={
<>
<Button variant="ghost" onClick={() => setCreateOpen(false)}></Button>
<Button onClick={handleCreate} loading={creating}></Button>
</>
}>
<div className="space-y-4"> <div className="space-y-4">
{!editUser && <Input label="用户名" value={form.username} onChange={e => setForm(p => ({ ...p, username: e.target.value }))} required />} <Input label="用户名" value={createForm.username} onChange={e => setCreateForm(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="显示名称" value={createForm.display_name} onChange={e => setCreateForm(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="password" value={createForm.password} onChange={e => setCreateForm(p => ({ ...p, password: e.target.value }))} />
<Input label="邮箱" type="email" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} /> <Input label="邮箱" type="email" value={createForm.email} onChange={e => setCreateForm(p => ({ ...p, email: e.target.value }))} />
<Select label="角色" options={[{ value: 'viewer', label: '查看者' }, { value: 'operator', label: '运维人员' }, { value: 'admin', label: '管理员' }]} value={form.role} onChange={e => setForm(p => ({ ...p, role: e.target.value }))} /> <Select label="角色" options={[{ value: 'viewer', label: '查看者' }, { value: 'operator', label: '运维人员' }, { value: 'admin', label: '管理员' }]} value={createForm.role} onChange={e => setCreateForm(p => ({ ...p, role: e.target.value }))} />
{error && <p className="text-sm text-red-500">{error}</p>} {createError && <p className="text-sm text-red-500">{createError}</p>}
<div className="flex gap-3">
<Button onClick={handleSave}>{editUser ? '保存' : '创建'}</Button>
<Button variant="secondary" onClick={() => setModalOpen(false)}></Button>
</div>
</div> </div>
</Modal> </Modal>
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除" footer={
<>
<Button variant="ghost" onClick={() => setDeleteTarget(null)}></Button>
<Button variant="danger" onClick={handleDelete}></Button>
</>
}>
<p className="text-sm text-slate-600 dark:text-slate-400">{deleteTarget?.display_name}</p>
</Modal>
</div> </div>
) )
} }

View File

@ -0,0 +1,293 @@
'use client'
import { useState, useEffect } from 'react'
import { Card } from '@/components/ui'
import { Plus, Trash2, ChevronDown, ChevronUp } from 'lucide-react'
import { useDirtyTracker } from '@shared/ui/hooks/useDirtyTracker'
interface WebhookConfig {
id: string
name: string
url: string
enabled: boolean
params: {
interval_seconds: number
push_delay_ms: number
silent_start_hour: number
silent_end_hour: number
}
}
function generateId() {
return 'wh_' + Math.random().toString(36).slice(2, 10)
}
export default function WechatSettingsPage() {
const [config, setConfig] = useState<{ wechat: { webhooks: WebhookConfig[] } } | null>(null)
const [loading, setLoading] = useState(true)
const { isDirty, markClean, removeItem } = useDirtyTracker()
const [savingId, setSavingId] = useState<string | null>(null)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [expandedWebhooks, setExpandedWebhooks] = useState<Set<string>>(new Set())
const [testingWebhook, setTestingWebhook] = useState<string | null>(null)
// 加载配置
useEffect(() => {
fetch('/api/monitor/settings')
.then(r => {
if (!r.ok) throw new Error('加载失败')
return r.json()
})
.then(data => setConfig({ wechat: data.wechat }))
.catch(err => setMessage({ type: 'error', text: '加载配置失败: ' + err.message }))
.finally(() => setLoading(false))
}, [])
// 保存单个推送组
const handleSaveSingle = async (id: string) => {
if (!config) return
const wh = config.wechat.webhooks.find(w => w.id === id)
if (!wh) return
setSavingId(id)
setMessage(null)
try {
const res = await fetch('/api/monitor/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ wechat: { webhooks: [wh] } }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || '保存失败')
markClean(id, wh)
setMessage({ type: 'success', text: `${wh.name}」保存成功` })
setTimeout(() => setMessage(null), 3000)
} catch (err) {
setMessage({ type: 'error', text: `${wh.name}」保存失败: ${err instanceof Error ? err.message : String(err)}` })
} finally {
setSavingId(null)
}
}
// 测试 webhook
const handleTestWebhook = async (webhookId: string) => {
if (!config) return
const wh = config.wechat.webhooks.find(w => w.id === webhookId)
if (!wh || !wh.url) return
setTestingWebhook(webhookId)
const res = await fetch('/api/monitor/test-wechat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ webhook_url: wh.url }),
})
const data = await res.json()
setTestingWebhook(null)
setMessage({ type: data.success ? 'success' : 'error', text: data.success ? `${wh.name}」测试成功` : `${wh.name}${data.error}` })
setTimeout(() => setMessage(null), 5000)
}
// 新增 webhook
const addWebhook = () => {
if (!config) return
const newWh: WebhookConfig = {
id: generateId(),
name: `推送 ${config.wechat.webhooks.length + 1}`,
url: '',
enabled: true,
params: { interval_seconds: 60, push_delay_ms: 2000, silent_start_hour: 0, silent_end_hour: 7 },
}
const webhooks = [...config.wechat.webhooks, newWh]
setConfig({ wechat: { ...config.wechat, webhooks } })
setExpandedWebhooks(prev => new Set(prev).add(newWh.id))
}
// 删除 webhook至少保留 1 个)
const removeWebhook = (id: string) => {
if (!config) return
if (config.wechat.webhooks.length <= 1) return
setConfig({ wechat: { ...config.wechat, webhooks: config.wechat.webhooks.filter(w => w.id !== id) } })
removeItem(id)
}
// 更新 webhook 基本字段
const updateWebhook = (id: string, updates: Partial<WebhookConfig>) => {
if (!config) return
const webhooks = config.wechat.webhooks.map(w => w.id === id ? { ...w, ...updates } : w)
setConfig({ wechat: { ...config.wechat, webhooks } })
}
// 更新 webhook 高级参数
const updateWebhookParams = (id: string, paramUpdates: Partial<WebhookConfig['params']>) => {
if (!config) return
const webhooks = config.wechat.webhooks.map(w => w.id === id ? { ...w, params: { ...w.params, ...paramUpdates } } : w)
setConfig({ wechat: { ...config.wechat, webhooks } })
}
// 展开/折叠 webhook
const toggleWebhookExpand = (id: string) => {
setExpandedWebhooks(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
if (loading) return <div className="p-6 text-slate-500 dark:text-slate-400">...</div>
if (!config) return <div className="p-6 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"> Webhook /</p>
</div>
{/* 全局消息提示 */}
{message && (
<div className={`p-3 rounded-lg text-sm ${message.type === 'success' ? 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400' : 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400'}`}>
{message.text}
<button onClick={() => setMessage(null)} className="float-right font-bold">&times;</button>
</div>
)}
{/* Webhook 列表 */}
<Card className="p-5">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100"></h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">/</p>
</div>
<button
onClick={addWebhook}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm font-medium"
>
<Plus size={14} />
</button>
</div>
<div className="space-y-3">
{config.wechat.webhooks.map((wh, index) => (
<div
key={wh.id}
className={`border rounded-lg transition-colors ${wh.enabled ? 'border-slate-200 dark:border-slate-700' : 'border-slate-200 dark:border-slate-700 opacity-60'}`}
>
{/* 主行:序号 + 名称 + 启停 + 测试 + 删除 */}
<div className="flex items-center gap-3 px-4 py-3">
<button
onClick={() => toggleWebhookExpand(wh.id)}
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
>
{expandedWebhooks.has(wh.id) ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
<span className="text-sm font-medium text-slate-500 dark:text-slate-400 w-8">#{index + 1}</span>
<input
type="text"
value={wh.name}
onChange={e => updateWebhook(wh.id, { name: e.target.value })}
className="flex-1 px-2 py-1 text-sm border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-400 rounded text-slate-900 dark:text-slate-100 font-medium"
placeholder="推送组名称"
/>
{/* 启用/停用 toggle */}
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={wh.enabled}
onChange={e => updateWebhook(wh.id, { enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-slate-200 peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-slate-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-slate-600 peer-checked:bg-blue-500"></div>
</label>
{/* 测试按钮 */}
<button
onClick={() => handleTestWebhook(wh.id)}
disabled={!wh.url || testingWebhook === wh.id}
className="px-3 py-1 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-xs disabled:opacity-50 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
>
{testingWebhook === wh.id ? '测试中...' : '测试'}
</button>
{/* 删除按钮(至少保留 1 个) */}
{config.wechat.webhooks.length > 1 && (
<button onClick={() => removeWebhook(wh.id)} className="p-1 text-slate-400 hover:text-red-500 rounded" title="删除">
<Trash2 size={14} />
</button>
)}
</div>
{/* 展开的高级参数 */}
{expandedWebhooks.has(wh.id) && (
<div className="px-4 pb-4 space-y-4 border-t border-slate-100 dark:border-slate-700/50 pt-3">
<div>
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">Webhook </label>
<input
type="text"
value={wh.url}
onChange={e => updateWebhook(wh.id, { url: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."
/>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-slate-700 dark:text-slate-300"></label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1"></label>
<input
type="number"
value={wh.params.interval_seconds}
onChange={e => updateWebhookParams(wh.id, { interval_seconds: parseInt(e.target.value) || 60 })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
min={10} max={3600}
/>
</div>
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1"></label>
<input
type="number"
value={wh.params.push_delay_ms}
onChange={e => updateWebhookParams(wh.id, { push_delay_ms: parseInt(e.target.value) || 2000 })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
min={0} max={30000}
/>
</div>
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1"></label>
<input
type="number"
value={wh.params.silent_start_hour}
onChange={e => updateWebhookParams(wh.id, { silent_start_hour: parseInt(e.target.value) || 0 })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
min={0} max={23}
/>
</div>
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1"></label>
<input
type="number"
value={wh.params.silent_end_hour}
onChange={e => updateWebhookParams(wh.id, { silent_end_hour: parseInt(e.target.value) || 7 })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
min={0} max={23}
/>
</div>
</div>
</div>
{isDirty(wh.id, wh) && (
<div className="flex justify-end pt-2 border-t border-slate-100 dark:border-slate-700/50">
<button
onClick={() => handleSaveSingle(wh.id)}
disabled={savingId === wh.id}
className="px-3 py-1.5 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 text-xs font-medium"
>
{savingId === wh.id ? '保存中...' : '保存'}
</button>
</div>
)}
</div>
)}
</div>
))}
</div>
</Card>
</div>
)
}

View File

@ -65,8 +65,17 @@ export default function TicketDetailPage() {
async function handleDelete() { async function handleDelete() {
if (!confirm('确定要删除此工单吗?')) return if (!confirm('确定要删除此工单吗?')) return
try {
const res = await fetch(`/api/tickets/${ticket.id}`, { method: 'DELETE' }) const res = await fetch(`/api/tickets/${ticket.id}`, { method: 'DELETE' })
if (res.ok) handleBack() const data = await res.json()
if (res.ok) {
handleBack()
} else {
alert(data.error || '删除失败')
}
} catch (e) {
alert('删除失败:网络错误')
}
} }
const isPending = (ticket.current_status === 'open' || ticket.current_status === 'in_progress') && !ticket.close_time const isPending = (ticket.current_status === 'open' || ticket.current_status === 'in_progress') && !ticket.close_time
const isAdmin = currentUser?.role === 'admin' const isAdmin = currentUser?.role === 'admin'

View File

@ -12,7 +12,7 @@ export default function AllTicketsPage() {
fetch('/api/auth/me') fetch('/api/auth/me')
.then(r => r.json()) .then(r => r.json())
.then(u => { .then(u => {
if (u.user?.role !== 'admin') { if (!u.user?.permissions?.includes('*')) {
router.replace('/tickets/pending') router.replace('/tickets/pending')
} else { } else {
setReady(true) setReady(true)

View File

@ -1,47 +1,7 @@
'use client' 'use client'
import { useState } from 'react' // 登录页 — 引用共享组件
import LoginPage from '@shared/ui/login-page'
export default function LoginPage() { export default function Page() {
const [username, setUsername] = useState('') return <LoginPage siteName="IT工单跟踪系统" redirectPath="/dashboard" />
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); setError(''); setLoading(true)
try {
const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) })
const data = await res.json()
if (!res.ok) { setError(data.error || '登录失败'); return }
const params = new URLSearchParams(window.location.search)
const redirect = params.get('redirect')
const dest = (redirect && redirect.startsWith('/')) ? redirect : '/dashboard'
window.location.href = dest
} catch { setError('网络错误') } finally { setLoading(false) }
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 px-4">
<div className="w-full max-w-sm bg-white dark:bg-slate-900 rounded-lg border border-blue-200/50 dark:border-blue-500/20 shadow-lg p-8">
<h1 className="text-2xl font-bold text-center mb-6 text-slate-900 dark:text-white">IT </h1>
{error && <div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"></label>
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="请输入用户名"
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"></label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="请输入密码"
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required />
</div>
<button type="submit" disabled={loading}
className="w-full py-2 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium transition-colors duration-200">
{loading ? '登录中...' : '登录'}
</button>
</form>
</div>
</div>
)
} }

View File

@ -3,6 +3,7 @@ import { cookies } from 'next/headers'
import { getDb } from '@/lib/db' import { getDb } from '@/lib/db'
import { verifyToken } from '@/lib/auth' import { verifyToken } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
import { writeAuditLog, getClientIP, diffObjects } from '@/lib/audit'
async function getSession() { async function getSession() {
const cookieStore = await cookies() const cookieStore = await cookies()
@ -19,7 +20,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
} }
const { id } = await params const { id } = await params
const existing = getDb().prepare('SELECT id FROM api_keys WHERE id = ?').get(id) type ApiKeyRow = { id: number; name: string; permissions: string; expires_at: string | null; is_active: number }
const existing = getDb().prepare<[string | number], ApiKeyRow>('SELECT id, name, permissions, expires_at, is_active FROM api_keys WHERE id = ?').get(id)
if (!existing) return NextResponse.json({ error: 'API Key 不存在' }, { status: 404 }) if (!existing) return NextResponse.json({ error: 'API Key 不存在' }, { status: 404 })
try { try {
@ -34,6 +36,20 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
is_active !== undefined ? (is_active ? 1 : 0) : 1, is_active !== undefined ? (is_active ? 1 : 0) : 1,
id id
) )
// 审计日志
const changes = diffObjects(existing as unknown as Record<string, unknown>, body)
if (Object.keys(changes).length > 0) {
writeAuditLog({
userId: session.id,
action: 'update',
entityType: 'api_key',
entityId: Number(id),
details: { changes },
ipAddress: getClientIP(request),
})
}
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : '更新失败' const msg = e instanceof Error ? e.message : '更新失败'
@ -49,9 +65,22 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{
} }
const { id } = await params const { id } = await params
const existing = getDb().prepare('SELECT id FROM api_keys WHERE id = ?').get(id) const existing = getDb().prepare('SELECT id, name FROM api_keys WHERE id = ?').get(id) as { id: number; name: string } | undefined
if (!existing) return NextResponse.json({ error: 'API Key 不存在' }, { status: 404 }) if (!existing) return NextResponse.json({ error: 'API Key 不存在' }, { status: 404 })
const snapshot = { id: existing.id, name: existing.name }
getDb().prepare('DELETE FROM api_keys WHERE id = ?').run(id) getDb().prepare('DELETE FROM api_keys WHERE id = ?').run(id)
writeAuditLog({
userId: session.id,
apiKeyId: null,
action: 'delete',
entityType: 'api_key',
entityId: Number(id),
details: { deleted: snapshot },
ipAddress: getClientIP(_request),
})
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} }

View File

@ -3,6 +3,7 @@ import { cookies } from 'next/headers'
import { getDb } from '@/lib/db' import { getDb } from '@/lib/db'
import { verifyToken, generateApiKey, hashApiKey } from '@/lib/auth' import { verifyToken, generateApiKey, hashApiKey } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions' import { checkPermission } from '@/lib/permissions'
import { writeAuditLog, getClientIP } from '@/lib/audit'
async function getSession() { async function getSession() {
const cookieStore = await cookies() const cookieStore = await cookies()
@ -44,6 +45,16 @@ export async function POST(request: Request) {
'INSERT INTO api_keys (name, key_hash, permissions, expires_at, created_by) VALUES (?, ?, ?, ?, ?)' 'INSERT INTO api_keys (name, key_hash, permissions, expires_at, created_by) VALUES (?, ?, ?, ?, ?)'
).run(name, keyHash, perms, expires_at || null, session.id) ).run(name, keyHash, perms, expires_at || null, session.id)
writeAuditLog({
userId: session.id,
apiKeyId: null,
action: 'create',
entityType: 'api_key',
entityId: result.lastInsertRowid as number,
details: { created: { name } },
ipAddress: getClientIP(request),
})
return NextResponse.json({ key, id: result.lastInsertRowid }, { status: 201 }) return NextResponse.json({ key, id: result.lastInsertRowid }, { status: 201 })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : '创建失败' const msg = e instanceof Error ? e.message : '创建失败'

View File

@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { getCurrentUser } from '@/lib/auth'
import { initDatabase } from '@/lib/db-schema'
import { writeAuditLog, getClientIP } from '@/lib/audit'
// 获取保留天数配置
export async function GET() {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
const db = getDb()
const row = db.prepare(
"SELECT value FROM settings WHERE key = 'audit_retention_days'"
).get() as { value: string } | undefined
return NextResponse.json({ days: parseInt(row?.value || '180', 10) })
}
// 更新保留天数配置
export async function PUT(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (user.role !== 'admin') {
if (!user.permissions?.includes('*') && !user.permissions?.includes('audit-logs:export')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
}
const db = getDb()
const body = await request.json()
const days = Math.max(30, Math.min(365, parseInt(body.days, 10)))
db.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('audit_retention_days', ?)"
).run(String(days))
writeAuditLog({
userId: user.id,
action: 'update',
entityType: 'audit_log',
details: { retention_days: days },
ipAddress: getClientIP(request)
})
return NextResponse.json({ days })
}
export async function DELETE(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (user.role !== 'admin') {
if (!user.permissions?.includes('*') && !user.permissions?.includes('audit-logs:export')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
}
const db = getDb()
// 从 settings 读取保留天数
const retentionRow = db.prepare(
"SELECT value FROM settings WHERE key = 'audit_retention_days'"
).get() as { value: string } | undefined
const retentionDays = Math.max(30, Math.min(365, parseInt(retentionRow?.value || '180', 10)))
const result = db.prepare(
`DELETE FROM audit_logs WHERE created_at < datetime('now', '-${retentionDays} days', '+8 hours')`
).run()
writeAuditLog({
userId: user.id,
action: 'cleanup',
entityType: 'audit_log',
details: { cleanup: { deleted: result.changes, retention_days: retentionDays } },
ipAddress: getClientIP(request)
})
return NextResponse.json({ deleted: result.changes, retention_days: retentionDays })
}

View File

@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { getCurrentUser } from '@/lib/auth'
import { initDatabase } from '@/lib/db-schema'
import { writeAuditLog, getClientIP } from '@/lib/audit'
export async function GET(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
// 检查权限
if (user.role !== 'admin') {
if (!user.permissions?.includes('*') && !user.permissions?.includes('audit-logs:export')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
}
const db = getDb()
const { searchParams } = new URL(request.url)
const action = searchParams.get('action')
const entityType = searchParams.get('entity_type')
const userId = searchParams.get('user_id')
const dateFrom = searchParams.get('date_from')
const dateTo = searchParams.get('date_to')
let whereClause = 'WHERE 1=1'
const params: unknown[] = []
if (action) {
whereClause += ' AND a.action = ?'
params.push(action)
}
if (entityType) {
whereClause += ' AND a.entity_type = ?'
params.push(entityType)
}
if (userId) {
whereClause += ' AND a.user_id = ?'
params.push(parseInt(userId))
}
if (dateFrom) {
whereClause += ' AND a.created_at >= ?'
params.push(dateFrom)
}
if (dateTo) {
whereClause += ' AND a.created_at <= ?'
params.push(dateTo + ' 23:59:59')
}
const logs = db.prepare(
`SELECT a.*, u.username, ak.name as api_key_name
FROM audit_logs a
LEFT JOIN users u ON a.user_id = u.id
LEFT JOIN api_keys ak ON a.api_key_id = ak.id
${whereClause}
ORDER BY a.created_at DESC`
).all(...params)
// 生成 CSV
const BOM = ''
const headers = ['ID', '时间', '用户', 'API Key', '操作', '对象类型', '对象ID', '详情', 'IP地址']
const rows = logs.map((log: Record<string, unknown>) => [
log.id,
log.created_at,
log.username || '',
log.api_key_name || '',
log.action,
log.entity_type,
log.entity_id || '',
log.details ? String(log.details).replace(/"/g, '""') : '',
log.ip_address || ''
])
const csvContent = BOM + [
headers.join(','),
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
].join('\n')
// 自审计:记录导出操作
writeAuditLog({
userId: user.id,
action: 'export',
entityType: 'audit_log',
details: {
export: {
filter: { entity_type: entityType, action, user_id: userId, date_from: dateFrom, date_to: dateTo },
count: logs.length
}
},
ipAddress: getClientIP(request)
})
const today = new Date()
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
return new NextResponse(csvContent, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="audit-logs-${dateStr}.csv"`
}
})
}

View File

@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { getCurrentUser } from '@/lib/auth'
import { initDatabase } from '@/lib/db-schema'
export async function GET(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
// 检查权限
if (user.role !== 'admin') {
if (!user.permissions?.includes('*') && !user.permissions?.includes('audit-logs:read')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
}
const db = getDb()
const { searchParams } = new URL(request.url)
const page = Math.max(1, parseInt(searchParams.get('page') || '1'))
const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get('pageSize') || '20')))
const action = searchParams.get('action')
const entityType = searchParams.get('entity_type')
const userId = searchParams.get('user_id')
const dateFrom = searchParams.get('date_from')
const dateTo = searchParams.get('date_to')
let whereClause = 'WHERE 1=1'
const params: unknown[] = []
if (action) {
whereClause += ' AND a.action = ?'
params.push(action)
}
if (entityType) {
whereClause += ' AND a.entity_type = ?'
params.push(entityType)
}
if (userId) {
whereClause += ' AND a.user_id = ?'
params.push(parseInt(userId))
}
if (dateFrom) {
whereClause += ' AND a.created_at >= ?'
params.push(dateFrom)
}
if (dateTo) {
whereClause += ' AND a.created_at <= ?'
params.push(dateTo + ' 23:59:59')
}
const countResult = db.prepare(
`SELECT COUNT(*) as total FROM audit_logs a ${whereClause}`
).get(...params) as { total: number }
const logs = db.prepare(
`SELECT a.*, u.username, ak.name as api_key_name
FROM audit_logs a
LEFT JOIN users u ON a.user_id = u.id
LEFT JOIN api_keys ak ON a.api_key_id = ak.id
${whereClause}
ORDER BY a.created_at DESC
LIMIT ? OFFSET ?`
).all(...params, pageSize, (page - 1) * pageSize)
return NextResponse.json({
logs,
total: countResult.total,
page,
pageSize
})
}

View File

@ -0,0 +1,54 @@
// GET /api/auth/callback — OIDC callbackV2使用 shared handleOidcCallback 工厂)
import { NextRequest } from 'next/server'
import { handleOidcCallback } from '@shared/lib/auth/handle-callback'
import { db } from '@/lib/db'
import { writeAuditLog } from '@/lib/audit'
const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
const oidcClientId = process.env.OIDC_CLIENT_ID || 'issue-oidc'
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || ''
const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://issue.tlyq.ai/api/auth/callback'
const jwtSecret = process.env.JWT_SECRET || 'oa-shared-jwt-secret-tlyq-2026'
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
export async function GET(request: NextRequest) {
return handleOidcCallback(request, {
oidc: { autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri },
jwtSecret,
cookieDomain,
getUser: (username) => {
const row = db.prepare(
'SELECT id, role FROM users WHERE username = ? AND is_active = 1'
).get(username) as { id: number; role: string } | undefined
return row ?? null
},
createUser: (username, displayName, email) => {
db.prepare(
"INSERT OR IGNORE INTO users (username, display_name, email, role, is_active, created_at, updated_at) VALUES (?, ?, ?, 'viewer', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
).run(username, displayName, email)
const row = db.prepare(
'SELECT id, role FROM users WHERE username = ? AND is_active = 1'
).get(username) as { id: number; role: string }
return { id: row?.id ?? 0, role: row?.role ?? 'viewer' }
},
updateUser: (username, displayName, email) => {
db.prepare(
"UPDATE users SET display_name = ?, email = ?, updated_at = datetime('now', '+8 hours') WHERE username = ?"
).run(displayName, email, username)
},
onAuditLog: (userId, username, req) => {
writeAuditLog({
userId,
username,
action: 'login',
entityType: 'auth',
details: { method: 'oidc' },
ipAddress: req.headers.get('x-forwarded-for') || '127.0.0.1',
})
},
})
}

View File

@ -0,0 +1,14 @@
// GET /api/auth/login/oidc — OIDC SSO 重定向V2使用 shared handleOidcLogin 工厂)
import { handleOidcLogin } from '@shared/lib/auth/handle-login'
const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
const oidcClientId = process.env.OIDC_CLIENT_ID || 'issue-oidc'
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || ''
const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://issue.tlyq.ai/api/auth/callback'
export async function GET(request: Request) {
const url = new URL(request.url)
const switchUser = url.searchParams.get('switch') === '1'
return handleOidcLogin({ autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri, switchUser })
}

View File

@ -1,16 +1,106 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { login } from '@/lib/auth' import { createToken } from '@/lib/auth'
import { initDatabase } from '@/lib/db-schema' import { initDatabase } from '@/lib/db-schema'
import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt-shared'
import { ldapAuth } from '@/lib/ldap'
import { getDb } from '@/lib/db'
import { getUserPermissions } from '@/lib/permissions'
import bcrypt from 'bcryptjs'
import { writeAuditLog, getClientIP } from '@/lib/audit'
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
initDatabase() initDatabase()
const { username, password } = await request.json() const { username, password } = await request.json()
if (!username || !password) return NextResponse.json({ error: '请输入用户名和密码' }, { status: 400 }) if (!username || !password) return NextResponse.json({ error: '请输入用户名和密码' }, { status: 400 })
const result = await login(username, password)
if (!result) return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 }) let userId: number
const response = NextResponse.json({ user: result.user }) let role: string
response.cookies.set('session_issue', result.token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 7 * 24 * 60 * 60, path: '/' }) let displayName: string
const db = getDb()
// 1. localadmin纯本地 BCrypt不依赖 LLDAP
if (username === 'localadmin') {
const localUser = db.prepare(
'SELECT * FROM users WHERE username = ? AND is_active = 1'
).get(username) as { id: number; username: string; display_name: string; role: string; password_hash: string } | undefined
if (!localUser || !bcrypt.compareSync(password, localUser.password_hash)) {
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
}
userId = localUser.id
role = localUser.role
displayName = localUser.display_name || username
} else {
// 2. 其他用户LLDAP 优先
const ldapResult = await ldapAuth(username, password)
if (ldapResult.success) {
// LDAP 认证成功 → 更新本地密码缓存 + 自动创建用户
displayName = ldapResult.displayName || username
const pwHash = bcrypt.hashSync(password, 10)
const existing = db.prepare(
'SELECT id, role FROM users WHERE username = ? AND is_active = 1'
).get(username) as { id: number; role: string } | undefined
if (existing) {
db.prepare('UPDATE users SET password_hash = ?, display_name = ?, email = ? WHERE id = ?')
.run(pwHash, displayName, ldapResult.email || null, existing.id)
userId = existing.id
role = existing.role
} else {
db.prepare(
"INSERT INTO users (username, password_hash, display_name, email, role, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, 'viewer', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
).run(username, pwHash, displayName, ldapResult.email || null)
const created = db.prepare(
'SELECT id, role FROM users WHERE username = ?'
).get(username) as { id: number; role: string } | undefined
if (!created) return NextResponse.json({ error: '用户创建失败' }, { status: 500 })
userId = created.id
role = created.role
}
} else if (ldapResult.unreachable) {
// LLDAP 不可达 → 回退本地密码缓存
const localUser = db.prepare(
'SELECT * FROM users WHERE username = ? AND is_active = 1'
).get(username) as { id: number; username: string; display_name: string; role: string; password_hash: string } | undefined
if (!localUser || !bcrypt.compareSync(password, localUser.password_hash)) {
return NextResponse.json({ error: '认证服务不可用,且本地密码不匹配' }, { status: 401 })
}
userId = localUser.id
role = localUser.role
displayName = localUser.display_name || username
} else {
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
}
}
// 3. 更新最后登录时间和活跃时间
db.prepare("UPDATE users SET last_login_at = datetime('now', '+8 hours'), last_active_at = datetime('now', '+8 hours') WHERE id = ?").run(userId)
// 4. 签发两个 cookie
const localToken = await createToken({ id: userId, username, display_name: displayName, role, permissions: [] })
const sharedToken = signSharedJwt({ username, displayName })
const sharedCfg = sharedCookieConfig()
const response = NextResponse.json({
user: { id: userId, username, display_name: displayName, role, permissions: getUserPermissions(role) },
})
writeAuditLog({
userId,
apiKeyId: null,
action: 'login',
entityType: 'auth',
entityId: userId,
details: { username, displayName },
ipAddress: getClientIP(request),
})
response.cookies.set('session_issue', localToken, {
httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60, path: '/',
})
response.cookies.set(sharedCfg.name, sharedToken, sharedCfg)
return response return response
} catch (e) { console.error('Login error:', e); return NextResponse.json({ error: '登录失败' }, { status: 500 }) } } catch (e) { console.error('Login error:', e); return NextResponse.json({ error: '登录失败' }, { status: 500 }) }
} }

View File

@ -1,6 +1,55 @@
import { NextResponse } from 'next/server' // GET + POST /api/auth/logout — 退出登录(清除 cookie + 302 跳转 /login
export async function POST() { import { NextRequest, NextResponse } from 'next/server'
const r = NextResponse.json({ success: true }) import { writeAuditLog, getClientIP } from '@/lib/audit'
r.cookies.set('session_issue', '', { maxAge: 0, path: '/' })
return r const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
/** 从 OIDC_REDIRECT_URI 提取 site URL不可信请求头见 LESSONS-LEARNED #51 */
function getSiteUrl(): string {
const redirectUri = process.env.OIDC_REDIRECT_URI || ''
try { const u = new URL(redirectUri); return `${u.protocol}//${u.host}` } catch { /* fallthrough */ }
return process.env.NEXT_PUBLIC_SITE_URL || 'https://issue.tlyq.ai'
}
/** 清除 tlyq_session + session + oidc_id_token + session_issue cookie → 302 跳转 /login */
function logoutResponse(): NextResponse {
const response = NextResponse.redirect(new URL('/login', getSiteUrl()))
response.cookies.set('tlyq_session', '', {
httpOnly: true, secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0,
})
response.cookies.set('session', '', { path: '/', maxAge: 0 })
response.cookies.set('oidc_id_token', '', {
httpOnly: true, secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0,
})
response.cookies.set('session_issue', '', { path: '/', maxAge: 0 })
return response
}
export async function GET() { return logoutResponse() }
export async function POST(request: NextRequest) {
// 从 session cookie 提取用户名用于审计
let username = 'anonymous'
try {
const token =
request.cookies.get('tlyq_session')?.value ||
request.cookies.get('session_issue')?.value
if (token) {
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString())
username = payload.username || 'unknown'
}
} catch { /* 解析失败,使用默认值 */ }
writeAuditLog({
userId: null,
username,
action: 'logout',
entityType: 'auth',
details: { message: '用户登出' },
ipAddress: getClientIP(request),
})
return logoutResponse()
} }

View File

@ -1,7 +1,12 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { getCurrentUser } from '@/lib/auth' import { getCurrentUser } from '@/lib/auth'
export async function GET() { export async function GET() {
try {
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 }) if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
return NextResponse.json({ user }) return NextResponse.json({ user })
} catch {
return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 })
}
} }

View File

@ -0,0 +1,2 @@
import { NextResponse } from 'next/server'
export async function GET() { return NextResponse.json({ status: 'OK' }) }

View File

@ -0,0 +1,13 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
const INTERNAL_KEY = process.env.INTERNAL_API_KEY || 'oa-internal-key-tlyq-2026'
export async function GET(request: NextRequest) {
const key = request.headers.get('x-internal-key')
if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const db = getDb()
const roles = db.prepare('SELECT name, display_name FROM roles ORDER BY name').all() as { name: string; display_name: string }[]
return NextResponse.json({ roles })
}

View File

@ -0,0 +1,43 @@
// GET /api/internal/users — 供 OA 查询 issue 用户列表
// POST /api/internal/users — 供 OA 同步用户角色
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
const INTERNAL_KEY = process.env.INTERNAL_API_KEY || 'oa-internal-key-tlyq-2026'
export async function GET(request: NextRequest) {
const key = request.headers.get('x-internal-key')
if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const db = getDb()
const users = db.prepare(
'SELECT username, display_name, role FROM users WHERE is_active = 1 ORDER BY username'
).all() as { username: string; display_name: string; role: string }[]
return NextResponse.json({ users })
}
export async function POST(request: NextRequest) {
const key = request.headers.get('x-internal-key')
if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const body = await request.json()
const { username, displayName, role } = body
if (!username) {
return NextResponse.json({ error: 'username 必填' }, { status: 400 })
}
const VALID_ROLES = ['admin', 'editor', 'viewer']
const safeRole = VALID_ROLES.includes(role) ? role : 'viewer'
try {
const db = getDb()
db.prepare(
`INSERT INTO users (username, password_hash, display_name, role, is_active, created_at, updated_at)
VALUES (?, '', ?, ?, 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))
ON CONFLICT(username) DO UPDATE SET role = ?, display_name = ?,
updated_at = datetime('now', '+8 hours')`
).run(username, displayName || username, safeRole, safeRole, displayName || username)
return NextResponse.json({ success: true })
} catch {
return NextResponse.json({ error: '同步失败' }, { status: 500 })
}
}

View File

@ -0,0 +1,132 @@
// src/app/api/monitor/scan-emails/import/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { initDatabase } from '@/lib/db-schema'
import { getDb } from '@/lib/db'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { writeAuditLog, getClientIP } from '@/lib/audit'
import { notifyTicketCreated } from '@/lib/monitor/ticket-notifier'
interface ImportEmail {
msg_id: string
subject: string
date: string
order_number: string | null
status: string
ticket_no?: string
error?: string
}
export async function POST(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:write')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const clientIP = getClientIP(request)
const body = await request.json()
const { emails } = body as { emails: ImportEmail[] }
if (!emails || !Array.isArray(emails) || emails.length === 0) {
return NextResponse.json({ error: '没有可导入的邮件' }, { status: 400 })
}
const db = getDb()
let imported = 0
let skipped = 0
let errors = 0
const results: { order_number: string; status: string; error?: string }[] = []
for (const email of emails) {
if (!email.order_number) {
skipped++
results.push({
order_number: '-',
status: 'skipped',
error: '无工单号',
})
continue
}
const ticketId = parseInt(email.order_number)
// 检查工单是否已存在
const existing = db.prepare('SELECT id FROM tickets WHERE id = ?').get(ticketId)
if (existing) {
skipped++
results.push({
order_number: email.order_number,
status: 'skipped',
error: '工单已存在',
})
continue
}
// 检查是否已处理过
const processed = db.prepare('SELECT msg_id FROM processed_emails WHERE msg_id = ?').get(email.msg_id)
if (processed) {
skipped++
results.push({
order_number: email.order_number,
status: 'skipped',
error: '邮件已处理过',
})
continue
}
try {
// 创建工单
db.prepare(`
INSERT INTO tickets (id, content, current_status, created_by, created_at, updated_at)
VALUES (?, ?, 'open', ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))
`).run(ticketId, `${email.subject}`, user.id)
// 记录已处理邮件
db.prepare("INSERT OR IGNORE INTO processed_emails (msg_id, subject) VALUES (?, ?)").run(email.msg_id, email.subject)
// 审计日志
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'import',
entityType: 'ticket',
entityId: ticketId,
details: { created: { ticket_no: email.order_number, source: 'email_import' } },
ipAddress: clientIP,
})
imported++
results.push({
order_number: email.order_number,
status: 'imported',
})
void notifyTicketCreated({
ticket_no: email.order_number,
device_ip: null,
device_sn: null,
device_name: null,
ticket_type: null,
content: `从邮件导入:${email.subject}`,
assign_time: null,
}).catch(e => console.error('[scan-import] notify 失败:', e))
} catch (err) {
errors++
results.push({
order_number: email.order_number,
status: 'error',
error: err instanceof Error ? err.message : '导入失败',
})
}
}
return NextResponse.json({
success: true,
message: `导入完成:成功 ${imported} 封,跳过 ${skipped} 封,错误 ${errors}`,
imported,
skipped,
errors,
results,
})
}

View File

@ -0,0 +1,648 @@
// src/app/api/monitor/scan-emails/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { initDatabase } from '@/lib/db-schema'
import { getDb } from '@/lib/db'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { getMonitorConfig } from '@/lib/monitor/settings-manager'
import Imap from 'imap'
import { simpleParser } from 'mailparser'
import * as cheerio from 'cheerio'
import { formatBeijingTime } from '@/lib/monitor/types'
import { writeAuditLog, getClientIP } from '@/lib/audit'
import { getScanState, setScanState, isCancelRequested, resetCancelFlag } from '@/lib/monitor/scan-state'
import { fetchPOP3Emails, normalizeMessageId } from '@/lib/monitor/pop3-fetcher'
import { notifyTicketCreated } from '@/lib/monitor/ticket-notifier'
interface ScanResult {
status: 'running' | 'completed' | 'error' | 'cancelling'
startedAt: string
completedAt?: string
timeRange: { value: number | null; unit: string }
stats: {
total: number
matched: number
imported: number
skipped: number
errors: number
}
details: {
msg_id: string
subject: string
date: string
order_number: string | null
status: 'imported' | 'skipped' | 'error'
ticket_no?: string
error?: string
}[]
detailsTruncated: boolean
error?: string
}
const VALID_UNITS = ['minute', 'hour', 'day', 'week', 'month', 'all']
const MAX_VALUE = 365
const MAX_DETAILS = 500
const SCAN_TIMEOUT_MS = 5 * 60 * 1000 // 5 分钟超时
// 辅助函数:添加 detail 并追踪是否被截断
function addDetail(
scanState: ScanResult,
detail: ScanResult['details'][0],
counter: { total: number }
): void {
counter.total++
if (scanState.details.length < MAX_DETAILS) {
scanState.details.push(detail)
} else {
scanState.detailsTruncated = true
}
}
function extractOrderNumber(subject: string): string | null {
const match = subject.match(/【服务器故障单】(\d+),/)
return match?.[1] ?? null
}
function getDateRange(value: number | null, unit: string): Date {
const now = new Date()
if (!value || unit === 'all') {
// 全部:搜索最近 30 天
now.setDate(now.getDate() - 30)
return now
}
switch (unit) {
case 'minute':
now.setMinutes(now.getMinutes() - value)
break
case 'hour':
now.setHours(now.getHours() - value)
break
case 'day':
now.setDate(now.getDate() - value)
break
case 'week':
now.setDate(now.getDate() - value * 7)
break
case 'month':
now.setMonth(now.getMonth() - value)
break
default:
now.setDate(now.getDate() - 7)
}
return now
}
export async function POST(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:write')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const clientIP = getClientIP(request)
// 检查是否有正在运行的扫描(包括取消中的扫描)
const currentScan = getScanState()
if (currentScan?.status === 'running' || currentScan?.status === 'cancelling') {
return NextResponse.json({ error: '扫描正在进行中,请稍后再试' }, { status: 409 })
}
const body = await request.json()
const { value, unit } = body.timeRange || { value: 7, unit: 'day' }
// 输入验证
if (unit !== 'all') {
if (typeof value !== 'number' || value < 1 || value > MAX_VALUE) {
return NextResponse.json({ error: `时间范围值必须在 1-${MAX_VALUE} 之间` }, { status: 400 })
}
}
if (!VALID_UNITS.includes(unit)) {
return NextResponse.json({ error: `无效的时间单位: ${unit}` }, { status: 400 })
}
const config = getMonitorConfig()
const db = getDb()
const since = getDateRange(value, unit)
// 初始化扫描结果
const scanState: ScanResult = {
status: 'running',
startedAt: formatBeijingTime(),
timeRange: { value, unit },
stats: { total: 0, matched: 0, imported: 0, skipped: 0, errors: 0 },
details: [],
detailsTruncated: false,
}
setScanState(scanState)
// 重置取消标志
resetCancelFlag()
// 异步执行扫描
scanEmails(config, db, since, scanState, clientIP, user.id).catch(err => {
// 内层已设置错误状态,此处不再重复设置
console.error('[Scan] Unexpected error:', err)
})
return NextResponse.json({ success: true, message: '扫描已开始' })
}
async function scanEmails(
config: ReturnType<typeof getMonitorConfig>,
db: ReturnType<typeof getDb>,
since: Date,
scanState: ScanResult,
clientIP: string | null,
userId: number
): Promise<void> {
// 使用 imap 库(与 Python 版本兼容)替代 imapflow
// imapflow 对单部分 text/html 邮件的 source 获取有 bug
const imap = new Imap({
user: config.mail.address,
password: config.mail.password,
host: config.mail.imap_server,
port: config.mail.imap_port,
tls: true,
connTimeout: 15000,
authTimeout: 15000,
})
// 整体超时控制
const startTime = Date.now()
const checkTimeout = (): boolean => {
if (Date.now() - startTime > SCAN_TIMEOUT_MS) {
scanState.status = 'error'
scanState.completedAt = formatBeijingTime()
scanState.error = `扫描超时(超过 ${SCAN_TIMEOUT_MS / 60000} 分钟)`
return true
}
return false
}
// 将 IMAP 回调风格封装为 Promise
function imapConnect(): Promise<void> {
return new Promise((resolve, reject) => {
imap.once('ready', () => resolve())
imap.once('error', (err: Error) => reject(err))
imap.connect()
})
}
function imapOpenBox(boxName: string, readOnly: boolean): Promise<Imap.Box> {
return new Promise((resolve, reject) => {
imap.openBox(boxName, readOnly, (err: Error | null, box: Imap.Box) => {
if (err) reject(err)
else resolve(box)
})
})
}
function imapSearch(criteria: any[]): Promise<number[]> {
return new Promise((resolve, reject) => {
// imap.search 返回 UIDimap.fetch 默认也使用 UID
imap.search(criteria, (err: Error | null, results: number[]) => {
if (err) reject(err)
else resolve(results)
})
})
}
// 获取单封邮件的原始内容(使用 UID 获取)
function fetchEmailRaw(uid: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
console.log(`[Scan] fetchEmailRaw(uid=${uid}) 开始获取`)
// imap.fetch 默认使用 UID无需 uid: true
const fetch = imap.fetch(uid, { bodies: '', markSeen: false })
let emailBuffer = Buffer.alloc(0)
let messageCount = 0
let bodyCount = 0
fetch.on('message', (msg: any, seqno: number) => {
messageCount++
console.log(`[Scan] fetchEmailRaw(uid=${uid}) message 事件触发, seqno=${seqno}`)
msg.on('body', (stream: any, info: any) => {
bodyCount++
console.log(`[Scan] fetchEmailRaw(uid=${uid}) body 事件触发, info=${JSON.stringify(info)}`)
const chunks: Buffer[] = []
stream.on('data', (chunk: Buffer) => {
chunks.push(chunk)
console.log(`[Scan] fetchEmailRaw(uid=${uid}) 收到数据块, 大小=${chunk.length}`)
})
stream.on('end', () => {
emailBuffer = Buffer.concat(chunks)
console.log(`[Scan] fetchEmailRaw(uid=${uid}) body 结束, 总大小=${emailBuffer.length}`)
})
})
})
fetch.once('end', () => {
console.log(`[Scan] fetchEmailRaw(uid=${uid}) fetch 结束, messageCount=${messageCount}, bodyCount=${bodyCount}, 最终大小=${emailBuffer.length}`)
resolve(emailBuffer)
})
fetch.once('error', (err: Error) => {
console.error(`[Scan] fetchEmailRaw(uid=${uid}) fetch 错误:`, err.message)
reject(err)
})
})
}
// 通过 IMAP ENVELOPE 获取邮件的 Message-IDBODY 为空时 ENVELOPE 仍可用)
// 用于与 POP3 邮件精确匹配,避免脆弱的位置对齐
// 注意:只请求 envelope不能带 bodiesBODY 为空时 attributes 事件不会触发)
function fetchEmailMessageId(uid: number): Promise<string | null> {
return new Promise((resolve) => {
let messageId: string | null = null
try {
const fetch = imap.fetch(uid, { envelope: true } as any)
fetch.on('message', (msg: any) => {
msg.on('attributes', (attrs: any) => {
messageId = attrs?.envelope?.messageId || null
})
})
fetch.once('end', () => resolve(normalizeMessageId(messageId)))
fetch.once('error', () => resolve(null))
} catch {
resolve(null)
}
})
}
const detailsCounter = { total: 0 }
try {
await imapConnect()
console.log('[Scan] IMAP 连接成功')
await imapOpenBox('INBOX', true)
// 搜索邮件:将 since 日期转换为 IMAP SINCE 格式
const sinceStr = since.toLocaleDateString('en-US', { day: '2-digit', month: 'short', year: 'numeric' })
const uids = await imapSearch([['SINCE', sinceStr]])
console.log(`[Scan] 搜索条件 SINCE "${sinceStr}",找到 ${uids.length} 封邮件`)
if (!uids || uids.length === 0) {
scanState.status = 'completed'
scanState.completedAt = formatBeijingTime()
imap.end()
saveScanHistory(scanState, userId)
return
}
scanState.stats.total = uids.length
// POP3 fallback 状态IMAP 近期邮件 BODY 为空时自动切换
// pop3Map: Message-ID → 原始邮件 Bufferpop3Attempted: 是否已尝试(无论成败,避免重连风暴)
let pop3Map: Map<string, Buffer> = new Map()
let pop3Attempted = false
// 逐封处理邮件
for (let idx = 0; idx < uids.length; idx++) {
const uid = uids[idx]
if (isCancelRequested()) {
scanState.status = 'completed'
scanState.completedAt = formatBeijingTime()
scanState.error = '用户取消'
break
}
if (checkTimeout()) break
try {
// 获取完整邮件原始内容RFC822 格式),优先 IMAP失败时 POP3 fallback
let rawEmail: Buffer | null = await fetchEmailRaw(uid)
// IMAP BODY 为空时,首次触发 POP3 fallback一次性拉取所有邮件建立 Message-ID 索引)
if ((!rawEmail || rawEmail.length === 0) && !pop3Attempted) {
console.log(`[Scan] IMAP 返回空 (uid=${uid}),启用 POP3 fallback`)
pop3Attempted = true // 无论成败都置位,避免每封空邮件都重连
try {
pop3Map = await fetchPOP3Emails(config.mail.address, config.mail.password, uids.length, config.mail.pop3_server, config.mail.pop3_port)
} catch (pop3Err) {
console.error('[Scan] POP3 fallback 失败:', pop3Err instanceof Error ? pop3Err.message : pop3Err)
}
}
// 从 POP3 缓存中按 Message-ID 精确匹配(通过 IMAP ENVELOPE 获取当前 UID 的 Message-ID
if ((!rawEmail || rawEmail.length === 0) && pop3Map.size > 0) {
const targetMsgId = await fetchEmailMessageId(uid)
if (targetMsgId && pop3Map.has(targetMsgId)) {
rawEmail = pop3Map.get(targetMsgId)!
console.log(`[Scan] POP3 fallback 命中: uid=${uid}, msgId=${targetMsgId}, size=${rawEmail.length}`)
} else {
console.log(`[Scan] POP3 fallback 未命中: uid=${uid}, msgId=${targetMsgId || '(未获取到)'}`)
}
}
if (!rawEmail || rawEmail.length === 0) {
console.log(`[Scan] 邮件 ${uid} 内容为空IMAP + POP3 均失败),跳过`)
continue
}
// 使用 mailparser 解析邮件
const parsed = await simpleParser(rawEmail)
const subject = parsed.subject || ''
const from = parsed.from?.text || ''
const date = parsed.date ? formatBeijingTime(parsed.date) : formatBeijingTime()
const msgId = parsed.messageId || String(uid)
console.log(`[Scan] 邮件 ${uid}: subject="${subject}", from="${from}", 大小=${rawEmail.length}`)
// 邮件过滤:检查发件人是否匹配
if (config.filter.sender_email) {
const senderMatch = from.toLowerCase().includes(config.filter.sender_email.toLowerCase())
if (!senderMatch) {
console.log(`[Scan] 邮件 ${uid}: 发件人不匹配,跳过`)
continue
}
}
// 邮件过滤:检查主题关键词是否匹配
if (config.filter.subject_keywords && config.filter.subject_keywords.length > 0) {
const keywordMatch = config.filter.subject_keywords.some(kw =>
subject.toLowerCase().includes(kw.toLowerCase())
)
if (!keywordMatch) {
console.log(`[Scan] 邮件 ${uid}: 主题关键词不匹配,跳过`)
continue
}
}
// 提取工单号
const orderNumber = extractOrderNumber(subject)
if (!orderNumber) {
scanState.stats.matched++
addDetail(scanState, {
msg_id: msgId, subject, date,
order_number: null, status: 'skipped', error: '无法提取工单号',
}, detailsCounter)
continue
}
// 检查工单是否已存在
const existing = db.prepare('SELECT id FROM tickets WHERE id = ?').get(parseInt(orderNumber))
if (existing) {
scanState.stats.skipped++
scanState.stats.matched++
addDetail(scanState, {
msg_id: msgId, subject, date,
order_number: orderNumber, status: 'skipped', ticket_no: orderNumber, error: '工单已存在',
}, detailsCounter)
continue
}
// 检查是否已处理过
const processed = db.prepare('SELECT msg_id FROM processed_emails WHERE msg_id = ?').get(msgId)
if (processed) {
scanState.stats.skipped++
scanState.stats.matched++
addDetail(scanState, {
msg_id: msgId, subject, date,
order_number: orderNumber, status: 'skipped', error: '邮件已处理过',
}, detailsCounter)
continue
}
// 从邮件 HTML 正文中提取故障信息(参考 Python 脚本的 extract_fault_info
const htmlContent = parsed.html || ''
const ticketInfo = buildTicketContent(subject, orderNumber, htmlContent)
// 根据设备 IP 查询 assets 站点获取节点名称
if (ticketInfo.device_ip) {
try {
const assetsUrl = process.env.ASSETS_URL || 'https://assets.tlyq.ai'
const assetsApiKey = process.env.ASSETS_API_KEY || ''
if (!assetsApiKey) {
console.log(`[Scan] 跳过设备名称查询: ASSETS_API_KEY 未配置`)
} else {
const headers: Record<string, string> = { 'Authorization': `Bearer ${assetsApiKey}` }
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
try {
const assetsResp = await fetch(`${assetsUrl}/api/assets?filter_business_ip=${encodeURIComponent(ticketInfo.device_ip)}`, { headers, signal: controller.signal })
clearTimeout(timeout)
if (assetsResp.ok) {
const assetsData = await assetsResp.json()
if (assetsData.data && assetsData.data.length > 0) {
ticketInfo.device_name = assetsData.data[0].node_name || null
console.log(`[Scan] 设备 IP ${ticketInfo.device_ip} → 节点名称: ${ticketInfo.device_name}`)
}
} else {
console.error(`[Scan] assets API 返回 ${assetsResp.status} (IP: ${ticketInfo.device_ip})`)
}
} catch (fetchErr) {
clearTimeout(timeout)
throw fetchErr
}
}
} catch (e) {
console.error(`[Scan] 查询设备名称失败 (IP: ${ticketInfo.device_ip}): ${e instanceof Error ? e.message : e}`)
}
}
// 创建工单,填入提取的字段
const ticketId = parseInt(orderNumber)
db.prepare(`
INSERT INTO tickets (id, content, device_ip, device_sn, device_name, ticket_type, assign_time, current_status, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))
`).run(ticketId, ticketInfo.content, ticketInfo.device_ip, ticketInfo.device_sn, ticketInfo.device_name, ticketInfo.ticket_type, ticketInfo.assign_time, userId)
db.prepare("INSERT OR IGNORE INTO processed_emails (msg_id, subject) VALUES (?, ?)").run(msgId, subject)
writeAuditLog({
userId, apiKeyId: null, action: 'import', entityType: 'ticket', entityId: ticketId,
details: { created: { ticket_no: orderNumber, source: 'email_scan' } },
ipAddress: clientIP,
})
void notifyTicketCreated({
ticket_no: orderNumber,
device_ip: ticketInfo.device_ip,
device_sn: ticketInfo.device_sn,
device_name: ticketInfo.device_name,
ticket_type: ticketInfo.ticket_type,
content: ticketInfo.content,
assign_time: ticketInfo.assign_time,
}).catch(e => console.error('[scan-emails] notify 失败:', e))
scanState.stats.imported++
scanState.stats.matched++
addDetail(scanState, {
msg_id: msgId, subject, date,
order_number: orderNumber, status: 'imported', ticket_no: orderNumber,
}, detailsCounter)
} catch (err) {
scanState.stats.errors++
addDetail(scanState, {
msg_id: String(uid), subject: '', date: '',
order_number: null, status: 'error',
error: err instanceof Error ? err.message : '处理失败',
}, detailsCounter)
}
}
if (scanState.detailsTruncated) {
console.log(`[Scan] 详情列表已截断: 共 ${detailsCounter.total} 条,仅保存前 ${MAX_DETAILS}`)
}
if (scanState.status === 'running') {
scanState.status = 'completed'
scanState.completedAt = formatBeijingTime()
}
} catch (err) {
scanState.status = 'error'
scanState.completedAt = formatBeijingTime()
scanState.error = err instanceof Error ? err.message : '连接失败'
} finally {
try { imap.end() } catch {}
}
saveScanHistory(scanState, userId)
}
// 提取的邮件信息结构
interface ExtractedTicketInfo {
content: string
device_ip: string | null
device_sn: string | null
device_name: string | null
ticket_type: string | null
fault_time: string | null
fault_info: string | null
auth_note: string | null
assign_time: string | null
}
// 从邮件 HTML 正文中提取故障信息,参考 Python 脚本的 extract_fault_info
function buildTicketContent(subject: string, orderNumber: string, htmlContent: string): ExtractedTicketInfo {
const result: ExtractedTicketInfo = {
content: '',
device_ip: null,
device_sn: null,
device_name: null,
ticket_type: null,
fault_time: null,
fault_info: null,
auth_note: null,
assign_time: null,
}
// 提取工单类型
if (subject.includes('OEM诊断')) {
result.ticket_type = 'OEM诊断'
} else if (subject.includes('OEM维修')) {
result.ticket_type = 'OEM维修'
}
// 使用 cheerio 解析 HTML 表格
if (htmlContent) {
const $ = cheerio.load(htmlContent)
// 先定位包含"故障信息"的目标表格,避免混入无关表格
let targetTable = $('table').filter((_, table) => {
return $(table).text().includes('故障信息')
}).first()
// 如果没找到包含"故障信息"的表格,使用第一个表格
if (targetTable.length === 0) {
targetTable = $('table').first()
}
// 遍历目标表格的行,提取键值对
targetTable.find('tr').each((_, row) => {
const cells = $(row).find('td')
if (cells.length >= 2) {
const key = $(cells[0]).text().trim()
const value = $(cells[1]).text().trim()
switch (key) {
case '故障发生时间:':
case '故障发生时间':
result.fault_time = value
result.assign_time = value
break
case '服务器SN':
case '服务器SN':
result.device_sn = value
break
case '服务器IP地址':
case '服务器IP地址':
case '服务器IP':
case '服务器IP':
result.device_ip = value
break
case '故障信息:':
case '故障信息':
result.fault_info = value
break
case '授权说明:':
case '授权说明':
result.auth_note = value
break
}
}
})
// Fallback如果表格提取失败使用正则从纯文本中提取
if (!result.fault_info) {
const textContent = $.text().replace(/\s+/g, ' ')
const faultMatch = textContent.match(/故障信息[:\s]*(.+?)(?=授权说明|$)/i)
if (faultMatch) {
result.fault_info = faultMatch[1].trim()
}
}
if (!result.device_ip) {
const textContent = $.text().replace(/\s+/g, ' ')
const ipMatch = textContent.match(/(?:服务器IP地址|服务器IP|IP)[:\s]*(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/)
if (ipMatch) {
result.device_ip = ipMatch[1]
}
}
if (!result.device_sn) {
const textContent = $.text().replace(/\s+/g, ' ')
const snMatch = textContent.match(/服务器SN[:\s]*([A-Za-z0-9]+)/)
if (snMatch) {
result.device_sn = snMatch[1]
}
}
}
// content 只包含故障信息
result.content = result.fault_info || null
return result
}
// 保存扫描历史到数据库
function saveScanHistory(scanState: ScanResult, userId: number): void {
try {
const db = getDb()
db.prepare(`
INSERT INTO scan_history (
status, started_at, completed_at,
time_range_value, time_range_unit,
total_count, matched_count, imported_count, skipped_count, error_count,
details_truncated, error_message, details_json, created_by
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
scanState.status,
scanState.startedAt,
scanState.completedAt || null,
scanState.timeRange.value,
scanState.timeRange.unit,
scanState.stats.total,
scanState.stats.matched,
scanState.stats.imported,
scanState.stats.skipped,
scanState.stats.errors,
scanState.detailsTruncated ? 1 : 0,
scanState.error || null,
JSON.stringify(scanState.details),
userId
)
// 保留最近 50 条扫描历史,删除更早的(用 id 单调排序,避免 created_at 同秒重复)
db.prepare(`DELETE FROM scan_history WHERE id NOT IN (
SELECT id FROM scan_history ORDER BY id DESC LIMIT 50
)`).run()
console.log(`[Scan] 扫描历史已保存: 状态=${scanState.status}, 导入=${scanState.stats.imported}`)
} catch (err) {
console.error('[Scan] 保存扫描历史失败:', err)
}
}

View File

@ -0,0 +1,95 @@
// src/app/api/monitor/scan-history/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { initDatabase } from '@/lib/db-schema'
import { getDb } from '@/lib/db'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { writeAuditLog, getClientIP } from '@/lib/audit'
interface ScanHistoryItem {
id: number
status: string
started_at: string
completed_at: string | null
time_range_value: number | null
time_range_unit: string | null
total_count: number
matched_count: number
imported_count: number
skipped_count: number
error_count: number
details_truncated: number
error_message: string | null
details_json: string | null
created_by: number | null
created_at: string
}
export async function GET(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:read')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const offset = (page - 1) * limit
const db = getDb()
// 获取总数
const countResult = db.prepare('SELECT COUNT(*) as total FROM scan_history').get() as { total: number }
const total = countResult.total
// 获取列表
const items = db.prepare(`
SELECT * FROM scan_history
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`).all(limit, offset) as ScanHistoryItem[]
// 解析 details_json
const itemsWithDetails = items.map(item => ({
...item,
details: item.details_json ? JSON.parse(item.details_json) : [],
details_json: undefined,
}))
return NextResponse.json({
items: itemsWithDetails,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
})
}
// DELETE 清理旧历史(保留最近 N 天)
export async function DELETE(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:write')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const body = await request.json().catch(() => ({}))
const keepDays = body.keepDays || 30
const db = getDb()
const result = db.prepare(`
DELETE FROM scan_history
WHERE created_at < datetime('now', '+8 hours', ? || ' days')
`).run(-keepDays)
writeAuditLog({ userId: user.id, apiKeyId: null, action: 'delete', entityType: 'scan_history', details: { keepDays, deleted: result.changes }, ipAddress: getClientIP(request) })
return NextResponse.json({
success: true,
deleted: result.changes,
message: `已清理 ${keepDays} 天前的扫描历史`,
})
}

View File

@ -0,0 +1,49 @@
// src/app/api/monitor/scan-status/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { getClientIP, writeAuditLog } from '@/lib/audit'
import { getScanState, requestCancel } from '@/lib/monitor/scan-state'
export async function GET(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:read')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const state = getScanState()
return NextResponse.json(state || { status: 'idle' })
}
// DELETE 请求用于取消扫描
export async function DELETE(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:write')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const state = getScanState()
if (state?.status !== 'running') {
return NextResponse.json({ error: '没有正在运行的扫描任务' }, { status: 400 })
}
const clientIP = getClientIP(request)
requestCancel()
state.status = 'cancelling'
// 审计日志
writeAuditLog({
userId: user.id || null,
apiKeyId: null,
action: 'cancel',
entityType: 'scan',
entityId: null,
details: { scan: { requestedBy: user.username } },
ipAddress: clientIP,
})
return NextResponse.json({ success: true, message: '取消请求已发送,扫描将在处理完当前邮件后停止' })
}

View File

@ -0,0 +1,93 @@
// src/app/api/monitor/settings/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { getMaskedConfig, updateMonitorConfig } from '@/lib/monitor/settings-manager'
import { writeAuditLog, getClientIP } from '@/lib/audit'
import net from 'net'
// 拒绝内网/回环/链路本地/CGNAT 的 IPv4 字面量SSRF 纵深防御,防收信指向内网主机)
function isPrivateIPv4(host: string): boolean {
if (!net.isIPv4(host)) return false
const p = host.split('.').map(Number)
if (p[0] === 0 || p[0] === 10 || p[0] === 127) return true // 0/8, 10/8, 回环 127/8
if (p[0] === 169 && p[1] === 254) return true // 链路本地 169.254/16
if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true // 172.16/12
if (p[0] === 192 && p[1] === 168) return true // 192.168/16
if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return true // CGNAT 100.64/10
return false
}
// 拒绝非规范数字型 IP 写法(整数/十六进制/八进制/短式),防止绕过内网黑名单:
// net.isIPv4 只认标准点分十进制,但 socket 层会把 2130706433 / 0x7f000001 / 127.1 / 0177.0.0.1
// 等写法同样解析为回环/内网地址,必须在校验层拦截
function isNonCanonicalNumericHost(host: string): boolean {
if (/(^|\.)0x/i.test(host)) return true // 十六进制0x7f000001 / 0x7f.0.0.1
const segs = host.split('.')
if (segs.every((s) => /^\d+$/.test(s)) && !net.isIPv4(host)) return true // 整数型/短式/八进制数字 IP含前导零
return false
}
// 主机名格式校验(纵深防御):只允许合法主机名/IP 字符IPv6 字面量含 ':' 天然被拒),
// 拒绝 URL/路径/空格、非规范数字型 IP 及内网 IPv4收窄 SSRF 面
const isValidHost = (v: unknown): boolean => {
if (typeof v !== 'string' || v.length === 0 || v.length > 253) return false
if (!/^[a-zA-Z0-9.-]+$/.test(v)) return false
if (isNonCanonicalNumericHost(v)) return false
if (isPrivateIPv4(v)) return false
return true
}
const VALIDATORS: Record<string, (v: unknown) => boolean> = {
'monitor.enabled': (v) => typeof v === 'boolean',
'monitor.interval_seconds': (v) => typeof v === 'number' && v >= 10 && v <= 3600,
'monitor.push_delay_ms': (v) => typeof v === 'number' && v >= 0 && v <= 10000,
'monitor.silent_start_hour': (v) => typeof v === 'number' && v >= 0 && v <= 23,
'monitor.silent_end_hour': (v) => typeof v === 'number' && v >= 0 && v <= 23,
'mail.address': (v) => typeof v === 'string' && v.includes('@'),
'mail.imap_server': isValidHost,
'mail.imap_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
'mail.pop3_server': isValidHost,
'mail.pop3_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
'mail.smtp_server': isValidHost,
'mail.smtp_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
'wechat.webhooks': (v) => Array.isArray(v) && v.length > 0,
}
export async function GET(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:read')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
return NextResponse.json(getMaskedConfig())
}
export async function PUT(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const body = await request.json()
// 保留未修改的密码
if (body.mail?.password === '••••••••' || body.mail?.password === undefined) {
delete body.mail?.password
}
// 先展平嵌套对象
const flat: Record<string, unknown> = {}
for (const [section, values] of Object.entries(body)) {
if (typeof values === 'object' && values !== null) {
for (const [key, value] of Object.entries(values as Record<string, unknown>)) {
flat[`${section}.${key}`] = value
}
} else {
flat[section] = values
}
}
// 展平后验证
for (const [key, value] of Object.entries(flat)) {
const validator = VALIDATORS[key]
if (validator && !validator(value)) return NextResponse.json({ error: `参数 ${key} 验证失败` }, { status: 400 })
}
updateMonitorConfig(flat)
writeAuditLog({ userId: user.id, action: 'update', entityType: 'monitor_config', details: { keys: Object.keys(flat) }, ipAddress: getClientIP(request) })
return NextResponse.json({ success: true })
}

View File

@ -0,0 +1,16 @@
// src/app/api/monitor/start/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { setSetting } from '@/lib/monitor/settings-manager'
import { writeAuditLog, getClientIP } from '@/lib/audit'
export async function POST(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
setSetting('monitor.enabled', 'true', 'monitor')
writeAuditLog({ userId: user.id, action: 'enable', entityType: 'monitor', details: { message: '启用邮件监控' }, ipAddress: getClientIP(request) })
return NextResponse.json({ success: true, message: '监控已启用Worker 将在下一个 tick 启动' })
}

View File

@ -0,0 +1,99 @@
// src/app/api/monitor/status/route.ts
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 { getSetting } from '@/lib/monitor/settings-manager'
export async function GET(request: NextRequest) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:read')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const db = getDb()
const enabled = getSetting('monitor.enabled') === 'true'
const lastTick = db.prepare("SELECT created_at, details FROM monitor_logs WHERE action = 'tick_complete' ORDER BY id DESC LIMIT 1").get() as { created_at: string; details: string } | undefined
// 使用 UTC+8 日期(数据库中 created_at 是 UTC+8
const now = new Date()
const todayStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} 00:00:00`
const todayProcessed = (db.prepare("SELECT COUNT(*) as c FROM monitor_logs WHERE action = 'ticket_created' AND created_at >= ?").get(todayStart) as { c: number }).c
const totalProcessed = (db.prepare("SELECT COUNT(*) as c FROM monitor_logs WHERE action = 'ticket_created'").get() as { c: number }).c
// 最近一次 tick 的错误状态action='error' → 1tick_complete → 该 tick 的 errors 数;无记录 → 0
const lastLog = db.prepare(
"SELECT action, details FROM monitor_logs WHERE action IN ('error', 'tick_complete') ORDER BY id DESC LIMIT 1"
).get() as { action: string; details: string | null } | undefined
let errorCount = 0
if (lastLog) {
if (lastLog.action === 'error') {
errorCount = 1
} else {
try { errorCount = JSON.parse(lastLog.details || '{}').errors || 0 } catch { errorCount = 0 }
}
}
const ongoingFaultsCount = (db.prepare("SELECT COUNT(*) as c FROM fault_records WHERE status = 'ongoing'").get() as { c: number }).c
// 获取最近的活动tick_complete 或 error
const lastActivity = db.prepare("SELECT created_at, action FROM monitor_logs WHERE action IN ('tick_complete', 'error') ORDER BY id DESC LIMIT 1").get() as { created_at: string; action: string } | undefined
let workerHealth = 'unknown'
if (enabled && lastActivity) {
const elapsed = Date.now() - new Date(lastActivity.created_at.replace(' ', 'T') + '+08:00').getTime()
if (elapsed < 180_000) {
workerHealth = lastActivity.action === 'error' ? 'error' : 'healthy'
} else {
workerHealth = 'unresponsive'
}
} else if (!enabled) {
workerHealth = 'stopped'
}
const lastError = db.prepare("SELECT details, created_at FROM monitor_logs WHERE action = 'error' ORDER BY id DESC LIMIT 1").get() as { details: string; created_at: string } | undefined
// 今日处理详情:从 monitor_logs 获取今天的工单创建记录
const todayProcessedDetails = db.prepare(
"SELECT details, created_at FROM monitor_logs WHERE action = 'ticket_created' AND created_at >= ? ORDER BY id DESC LIMIT 20"
).all(todayStart) as { details: string; created_at: string }[]
// 进行中故障详情
const ongoingFaultsDetails = db.prepare(
"SELECT * FROM fault_records WHERE status = 'ongoing' ORDER BY fault_time DESC LIMIT 20"
).all() as Record<string, unknown>[]
// 最近错误列表
const recentErrors = db.prepare(
"SELECT details, created_at FROM monitor_logs WHERE action = 'error' ORDER BY id DESC LIMIT 10"
).all() as { details: string; created_at: string }[]
return NextResponse.json({
enabled,
status: enabled ? 'running' : 'stopped',
workerHealth,
lastCheck: lastTick?.created_at || null,
stats: {
totalProcessed,
todayProcessed,
errors: errorCount,
lastError: lastError?.details || null,
lastErrorTime: lastError?.created_at || null,
},
ongoingFaults: ongoingFaultsCount,
details: {
todayProcessed: todayProcessedDetails.map(d => {
try { return { ...JSON.parse(d.details), created_at: d.created_at } }
catch { return { details: d.details, created_at: d.created_at } }
}),
ongoingFaults: ongoingFaultsDetails,
recentErrors: recentErrors.map(d => {
try { return { ...JSON.parse(d.details), created_at: d.created_at } }
catch { return { details: d.details, created_at: d.created_at } }
}),
},
})
} catch (e) {
return NextResponse.json({ error: e instanceof Error ? e.message : 'Internal error' }, { status: 500 })
}
}

View File

@ -0,0 +1,16 @@
// src/app/api/monitor/stop/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { setSetting } from '@/lib/monitor/settings-manager'
import { writeAuditLog, getClientIP } from '@/lib/audit'
export async function POST(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
setSetting('monitor.enabled', 'false', 'monitor')
writeAuditLog({ userId: user.id, action: 'disable', entityType: 'monitor', details: { message: '停用邮件监控' }, ipAddress: getClientIP(request) })
return NextResponse.json({ success: true, message: '监控已停用' })
}

View File

@ -0,0 +1,33 @@
// src/app/api/monitor/test-mail/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { getMonitorConfig } from '@/lib/monitor/settings-manager'
import { writeAuditLog, getClientIP } from '@/lib/audit'
import { ImapFlow } from 'imapflow'
export async function POST(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const config = getMonitorConfig()
const client = new ImapFlow({
host: config.mail.imap_server, port: config.mail.imap_port, secure: true,
auth: { user: config.mail.address, pass: config.mail.password },
logger: false, connectionTimeout: 10_000,
})
try {
await client.connect()
const lock = await client.getMailboxLock('INBOX')
lock.release()
await client.logout()
writeAuditLog({ userId: user.id, apiKeyId: null, action: 'test', entityType: 'monitor_mail', details: { target: 'imap', success: true, server: config?.mail?.imap_server || 'unknown' }, ipAddress: getClientIP(request) })
return NextResponse.json({ success: true, message: 'IMAP 连接成功INBOX 选择正常' })
} catch (e) {
writeAuditLog({ userId: user.id, apiKeyId: null, action: 'test', entityType: 'monitor_mail', details: { target: 'imap', success: false, server: config?.mail?.imap_server || 'unknown' }, ipAddress: getClientIP(request) })
return NextResponse.json({ success: false, error: e instanceof Error ? e.message : '连接失败' })
} finally {
try { await client.logout() } catch {}
}
}

View File

@ -0,0 +1,59 @@
// src/app/api/monitor/test-wechat/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { getMonitorConfig } from '@/lib/monitor/settings-manager'
import { writeAuditLog, getClientIP } from '@/lib/audit'
export async function POST(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
let webhookUrl: string | null = null
try {
const body = await request.json()
webhookUrl = body?.webhook_url || null
} catch { /* 无 body */ }
// 如果 URL 被 mask含 ••••••••),从 DB 查真实 URL
if (webhookUrl && webhookUrl.includes('••••••••')) {
const config = getMonitorConfig()
const found = config.wechat.webhooks.find(wh => {
if (!wh.url) return false
// 对比非 key 部分是否匹配(前缀 + 后缀),找到对应的真实 webhook
const maskedPattern = wh.url.replace(/key=([0-9a-f-]+)/, 'key=••••••••')
return maskedPattern === webhookUrl
})
if (found) webhookUrl = found.url
else webhookUrl = null // 找不到匹配的,走 fallback
}
// 如果指定了 URL使用指定的否则使用第一个启用的
if (!webhookUrl) {
const config = getMonitorConfig()
const enabled = config.wechat.webhooks.filter(wh => wh.enabled && wh.url)
if (enabled.length === 0) return NextResponse.json({ success: false, error: 'Webhook URL 未配置' })
webhookUrl = enabled[0].url
}
try {
const response = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ msgtype: 'text', text: { content: '✅ issue-ai 邮件监控测试消息' } }),
signal: AbortSignal.timeout(5_000),
})
const result = await response.json() as { errcode?: number; errmsg?: string }
if (result.errcode === 0) {
writeAuditLog({ userId: user.id, action: 'test', entityType: 'monitor_wechat', details: { message: '测试微信推送成功' }, ipAddress: getClientIP(request) })
return NextResponse.json({ success: true, message: '测试消息发送成功' })
}
writeAuditLog({ userId: user.id, action: 'test', entityType: 'monitor_wechat', details: { message: '测试微信推送失败', error: result.errmsg || '发送失败' }, ipAddress: getClientIP(request) })
return NextResponse.json({ success: false, error: result.errmsg || '发送失败' })
} catch (e) {
writeAuditLog({ userId: user.id, action: 'test', entityType: 'monitor_wechat', details: { message: '测试微信推送异常', error: e instanceof Error ? e.message : '发送失败' }, ipAddress: getClientIP(request) })
return NextResponse.json({ success: false, error: e instanceof Error ? e.message : '发送失败' })
}
}

View File

@ -0,0 +1,22 @@
// src/app/api/monitor/trigger/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { setSetting } from '@/lib/monitor/settings-manager'
import { writeAuditLog, getClientIP } from '@/lib/audit'
let lastTriggerTime = 0
const TRIGGER_COOLDOWN = 30_000
export async function POST(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
if (Date.now() - lastTriggerTime < TRIGGER_COOLDOWN) return NextResponse.json({ error: '请等待 30 秒后再试' }, { status: 429 })
lastTriggerTime = Date.now()
// 写入触发标记Worker 在下一个 tick 检测到后立即执行
setSetting('monitor.trigger_requested', 'true', 'monitor')
writeAuditLog({ userId: user.id, action: 'trigger', entityType: 'monitor', details: { message: '手动触发邮件检查' }, ipAddress: getClientIP(request) })
return NextResponse.json({ success: true, message: '已触发Worker 将在 1-2 秒内执行' })
}

View File

@ -0,0 +1,43 @@
import { 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 fs from 'fs'
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'reports:download')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { id } = await params
const db = getDb()
const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(id) as any
if (!report) return NextResponse.json({ error: '报告不存在' }, { status: 404 })
if (report.status !== 'completed' || !report.file_path) {
return NextResponse.json({ error: '报告尚未生成完成' }, { status: 400 })
}
if (!fs.existsSync(report.file_path)) {
return NextResponse.json({ error: '报告文件不存在' }, { status: 404 })
}
const buffer = fs.readFileSync(report.file_path)
const contentType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
const downloadName = report.file_name || `report_${id}.docx`
const encodedName = encodeURIComponent(downloadName)
return new NextResponse(new Uint8Array(buffer), {
headers: {
'Content-Type': contentType,
'Content-Disposition': `attachment; filename*=UTF-8''${encodedName}`,
},
})
} catch (e) {
const msg = e instanceof Error ? e.message : '下载失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,67 @@
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 { generateMonthlyReport } from '@/lib/monthly-report'
import { generateWeeklyReport } from '@/lib/weekly-report'
import { writeAuditLog, getClientIP } from '@/lib/audit'
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'reports:create')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const { id } = await params
const db = getDb()
const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(id) as any
if (!report) {
return NextResponse.json({ error: '报告不存在' }, { status: 404 })
}
if (report.status !== 'ready' && report.status !== 'failed') {
return NextResponse.json(
{ error: `当前状态 "${report.status}" 不允许生成文档` },
{ status: 409 }
)
}
db.prepare("UPDATE reports SET status = 'generating', error_message = NULL WHERE id = ?")
.run(id)
if (report.report_type === 'weekly') {
generateWeeklyReport(Number(id)).catch(err => {
console.error(`Weekly report generation failed for report ${id}:`, err)
})
} else {
generateMonthlyReport(Number(id)).catch(err => {
console.error(`Report generation failed for report ${id}:`, err)
})
}
const updated = db.prepare('SELECT * FROM reports WHERE id = ?').get(id)
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'generate',
entityType: 'report',
entityId: parseInt(id),
details: { generate: { report_type: report.report_type } },
ipAddress: getClientIP(_request)
})
return NextResponse.json({ report: updated })
} catch (e) {
const msg = e instanceof Error ? e.message : '生成失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,108 @@
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 { writeAuditLog, getClientIP } from '@/lib/audit'
import fs from 'fs'
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'reports:read')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { id } = await params
const db = getDb()
const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(id)
if (!report) return NextResponse.json({ error: '报告不存在' }, { status: 404 })
const r = report as any
let reportData: any = null
// 优先解析 metadata JSON
if (r.metadata) {
try {
reportData = JSON.parse(r.metadata)
} catch { /* ignore parse errors */ }
}
// 如果 metadata 为空(旧报告),回退到简单查询
if (!reportData && r.period_start && r.period_end) {
const tickets = db.prepare(
'SELECT * FROM tickets WHERE assign_time >= ? AND assign_time <= ? ORDER BY assign_time'
).all(r.period_start, r.period_end + ' 23:59:59')
const total = tickets.length
const resolved = (tickets as any[]).filter(
t => t.current_status === 'resolved' || t.current_status === 'closed'
).length
const avgDur = db.prepare(
"SELECT AVG(duration_minutes) as avg FROM tickets WHERE assign_time >= ? AND assign_time <= ? AND duration_minutes IS NOT NULL"
).get(r.period_start, r.period_end + ' 23:59:59') as any
const slaPass = (tickets as any[]).filter(
t => t.counted_in_sla === 1 && ['resolved', 'closed'].includes(t.current_status)
).length
const categories = db.prepare(`
SELECT fault_category, COUNT(*) as count FROM tickets
WHERE assign_time >= ? AND assign_time <= ? AND fault_category IS NOT NULL
GROUP BY fault_category ORDER BY count DESC
`).all(r.period_start, r.period_end + ' 23:59:59')
reportData = {
summary: {
total_tickets: total,
resolved_tickets: resolved,
avg_duration: Math.round(avgDur?.avg || 0),
sla_rate: resolved > 0 ? Math.round((slaPass / resolved) * 100) : 0,
},
categories,
}
}
return NextResponse.json({ report, reportData })
} 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, 'reports:create')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { id } = await params
const db = getDb()
const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(id) as any
if (!report) return NextResponse.json({ error: '报告不存在' }, { status: 404 })
// 删除磁盘文件
if (report.file_path && fs.existsSync(report.file_path)) {
try { fs.unlinkSync(report.file_path) } catch { /* 文件删除失败不影响数据库操作 */ }
}
// 删除数据库记录
db.prepare('DELETE FROM reports WHERE id = ?').run(id)
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'delete',
entityType: 'report',
entityId: Number(id),
details: { deleted: { id: Number(id), title: report.title, type: report.type, period_start: report.period_start, period_end: report.period_end, file_path: report.file_path } },
ipAddress: getClientIP(_request),
})
return NextResponse.json({ success: true })
} catch (e) {
const msg = e instanceof Error ? e.message : '删除失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,72 @@
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 { writeAuditLog, getClientIP } from '@/lib/audit'
import JSZip from 'jszip'
import fs from 'fs'
export async function POST(request: NextRequest) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'reports:download')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { ids } = await request.json()
if (!Array.isArray(ids) || ids.length === 0) {
return NextResponse.json({ error: '缺少 ids 参数' }, { status: 400 })
}
const db = getDb()
const reports = db.prepare(
`SELECT * FROM reports WHERE id IN (${ids.map(() => '?').join(',')})`
).all(...ids) as any[]
if (reports.length === 0) {
return NextResponse.json({ error: '未找到选中的报告' }, { status: 404 })
}
const zip = new JSZip()
for (const r of reports) {
if (r.status === 'completed' && r.file_path && fs.existsSync(r.file_path)) {
const buffer = fs.readFileSync(r.file_path)
const fileName = r.file_name || `report_${r.id}.docx`
zip.file(fileName, buffer)
}
}
if (Object.keys(zip.files).length === 0) {
return NextResponse.json({ error: '没有可下载的报告文件' }, { status: 400 })
}
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' })
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'export',
entityType: 'report',
entityId: null,
details: { exported_count: ids.length, ids },
ipAddress: getClientIP(request),
})
const d = new Date()
const today = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
const downloadName = `reports_${today}.zip`
const encodedName = encodeURIComponent(downloadName)
return new NextResponse(new Uint8Array(zipBuffer), {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename*=UTF-8''${encodedName}`,
},
})
} catch (e) {
const msg = e instanceof Error ? e.message : '批量下载失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,125 @@
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 fs from 'fs'
import { writeAuditLog, getClientIP } from '@/lib/audit'
export async function GET() {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'reports:read')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const db = getDb()
const reports = db.prepare('SELECT * FROM reports ORDER BY created_at DESC').all()
return NextResponse.json({ reports })
} 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, 'reports:create')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const body = await request.json()
const { report_type, period_start, period_end } = body
if (!report_type || !period_start || !period_end) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 })
}
const db = getDb()
// 1. 插入报告行,状态为 ready数据已就绪文档未生成
const result = db.prepare(`
INSERT INTO reports (report_type, period_start, period_end, format, status, created_by)
VALUES (?, ?, ?, 'docx', 'ready', ?)
`).run(report_type, period_start, period_end, user.id)
const reportId = result.lastInsertRowid as number
// 2. 采集数据并写入 metadata同步1-2s
if (report_type === 'weekly') {
const { collectWeeklyReportData, buildWeeklyMetadata } = await import('@/lib/weekly-report')
const data = await collectWeeklyReportData(period_start, period_end)
const metadata = buildWeeklyMetadata(data)
db.prepare('UPDATE reports SET metadata = ? WHERE id = ?').run(metadata, reportId)
} else {
const { collectMonthlyReportData, buildMonthlyMetadata } = await import('@/lib/monthly-report')
const data = await collectMonthlyReportData(period_start, period_end)
const metadata = buildMonthlyMetadata(data)
db.prepare('UPDATE reports SET metadata = ? WHERE id = ?').run(metadata, reportId)
}
// 3. 返回报告(状态为 ready前端自动跳转预览页
const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(reportId)
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'create',
entityType: 'report',
entityId: reportId,
details: { created: { report_type, period_start, period_end } },
ipAddress: getClientIP(request),
})
return NextResponse.json({ report }, { status: 201 })
} catch (e) {
const msg = e instanceof Error ? e.message : '创建失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
export async function DELETE(request: NextRequest) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'reports:create')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { ids } = await request.json()
if (!Array.isArray(ids) || ids.length === 0) {
return NextResponse.json({ error: '缺少 ids 参数' }, { status: 400 })
}
const db = getDb()
const reports = db.prepare(
`SELECT * FROM reports WHERE id IN (${ids.map(() => '?').join(',')})`
).all(...ids) as any[]
// 删除磁盘文件
for (const r of reports) {
if (r.file_path && fs.existsSync(r.file_path)) {
try { fs.unlinkSync(r.file_path) } catch { /* ignore */ }
}
}
// 删除数据库记录
const placeholders = ids.map(() => '?').join(',')
db.prepare(`DELETE FROM reports WHERE id IN (${placeholders})`).run(...ids)
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'delete',
entityType: 'report',
entityId: null,
details: { deleted: ids },
ipAddress: getClientIP(request),
})
return NextResponse.json({ success: true, deleted: ids.length })
} catch (e) {
const msg = e instanceof Error ? e.message : '批量删除失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -3,6 +3,7 @@ import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema' import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth' import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions' import { hasPermission } from '@/lib/permissions'
import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit'
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try { try {
@ -15,9 +16,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const body = await request.json() const body = await request.json()
const db = getDb() const db = getDb()
const existing = db.prepare('SELECT * FROM roles WHERE id = ?').get(id) const existing = db.prepare('SELECT * FROM roles WHERE id = ?').get(id) as any
if (!existing) return NextResponse.json({ error: '角色不存在' }, { status: 404 }) if (!existing) return NextResponse.json({ error: '角色不存在' }, { status: 404 })
// 获取更新前的完整记录用于审计日志
const beforeUpdate = { ...existing }
const fields: string[] = [] const fields: string[] = []
const values: unknown[] = [] const values: unknown[] = []
@ -30,6 +34,17 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
} }
const role = db.prepare('SELECT * FROM roles WHERE id = ?').get(id) const role = db.prepare('SELECT * FROM roles WHERE id = ?').get(id)
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'update',
entityType: 'role',
entityId: Number(id),
details: { changes: diffObjects(beforeUpdate, role as Record<string, unknown>) },
ipAddress: getClientIP(request),
})
return NextResponse.json({ role }) return NextResponse.json({ role })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : '更新失败' const msg = e instanceof Error ? e.message : '更新失败'
@ -52,7 +67,20 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
return NextResponse.json({ error: '系统内置角色不能删除' }, { status: 400 }) return NextResponse.json({ error: '系统内置角色不能删除' }, { status: 400 })
} }
const snapshot = { id: existing.id, name: existing.name, display_name: existing.display_name }
db.prepare('DELETE FROM roles WHERE id = ?').run(id) db.prepare('DELETE FROM roles WHERE id = ?').run(id)
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'delete',
entityType: 'role',
entityId: Number(id),
details: { deleted: snapshot },
ipAddress: getClientIP(_request),
})
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : '删除失败' const msg = e instanceof Error ? e.message : '删除失败'

View File

@ -3,6 +3,7 @@ import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema' import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth' import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions' import { hasPermission } from '@/lib/permissions'
import { writeAuditLog, getClientIP } from '@/lib/audit'
export async function GET() { export async function GET() {
try { try {
@ -39,6 +40,17 @@ export async function POST(request: NextRequest) {
const result = db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)').run(name, display_name, JSON.stringify(permissions || [])) 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) const role = db.prepare('SELECT * FROM roles WHERE id = ?').get(result.lastInsertRowid)
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'create',
entityType: 'role',
entityId: result.lastInsertRowid as number,
details: { created: { name, display_name } },
ipAddress: getClientIP(request),
})
return NextResponse.json({ role }, { status: 201 }) return NextResponse.json({ role }, { status: 201 })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : '创建失败' const msg = e instanceof Error ? e.message : '创建失败'

View File

@ -17,7 +17,7 @@ export async function GET() {
const resolved = (db.prepare("SELECT COUNT(*) as c FROM tickets WHERE current_status = 'resolved'").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 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 d = new Date(); const thisMonth = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`
const monthTotal = (db.prepare("SELECT COUNT(*) as c FROM tickets WHERE assign_time LIKE ?").get(`${thisMonth}%`) as any).c 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 avgDuration = db.prepare("SELECT AVG(duration_minutes) as avg FROM tickets WHERE duration_minutes IS NOT NULL AND duration_minutes != ''").get() as any

View File

@ -4,6 +4,9 @@ import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth' import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions' import { hasPermission } from '@/lib/permissions'
import { getAssetByIp } from '@/lib/assets-client' import { getAssetByIp } from '@/lib/assets-client'
import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit'
import { notifyTicketResolved } from '@/lib/monitor/ticket-notifier'
import { applyResolutionSideEffects } from '@/lib/monitor/resolution-side-effects'
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try { try {
@ -41,7 +44,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
initDatabase() initDatabase()
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 }) if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 }) if (!hasPermission(user, 'tickets:edit')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { id } = await params const { id } = await params
const body = await request.json() const body = await request.json()
@ -90,7 +93,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
if (fields.length > 0) { if (fields.length > 0) {
fields.push('updated_by = ?') fields.push('updated_by = ?')
values.push(user.id) values.push(user.id)
fields.push("updated_at = datetime('now')") fields.push("updated_at = datetime('now', '+8 hours')")
values.push(id) values.push(id)
db.prepare(`UPDATE tickets SET ${fields.join(', ')} WHERE id = ?`).run(...values) db.prepare(`UPDATE tickets SET ${fields.join(', ')} WHERE id = ?`).run(...values)
} }
@ -106,6 +109,32 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
} }
const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(id) const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(id)
// 审计日志:记录变更
const changes = diffObjects(existing, body)
if (Object.keys(changes).length > 0) {
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'update',
entityType: 'ticket',
entityId: Number(id),
details: { changes },
ipAddress: getClientIP(request),
})
}
// 结单推送:状态从非终态 → resolved/closed 时触发
const wasTerminal = ['resolved', 'closed'].includes(existing.current_status as string)
const nowTerminal = body.current_status && ['resolved', 'closed'].includes(body.current_status)
if (!wasTerminal && nowTerminal) {
const ticketId = Number(id)
applyResolutionSideEffects(ticketId) // 同步:回写 fault_records + 清理 reminder
void notifyTicketResolved(ticketId).catch(e =>
console.error(`[API] 结单推送失败 (ticket ${ticketId}):`, e)
)
}
return NextResponse.json({ ticket }) return NextResponse.json({ ticket })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : '更新失败' const msg = e instanceof Error ? e.message : '更新失败'
@ -118,7 +147,7 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
initDatabase() initDatabase()
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 }) if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 }) if (!hasPermission(user, 'tickets:delete')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { id } = await params const { id } = await params
const db = getDb() const db = getDb()
@ -126,6 +155,9 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
const existing = db.prepare('SELECT current_status, created_by FROM tickets WHERE id = ?').get(id) as { current_status: string; created_by: number | null } | undefined 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 }) if (!existing) return NextResponse.json({ error: '工单不存在' }, { status: 404 })
// 获取工单快照用于审计日志
const snapshot = db.prepare('SELECT id, device_ip, current_status FROM tickets WHERE id = ?').get(id) as Record<string, unknown> | undefined
// 非管理员不可删除已办工单,非创建人不可删除待办工单 // 非管理员不可删除已办工单,非创建人不可删除待办工单
const completedStatuses = ['resolved', 'closed'] const completedStatuses = ['resolved', 'closed']
if (user.role !== 'admin') { if (user.role !== 'admin') {
@ -138,6 +170,17 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
} }
db.prepare('DELETE FROM tickets WHERE id = ?').run(id) db.prepare('DELETE FROM tickets WHERE id = ?').run(id)
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'delete',
entityType: 'ticket',
entityId: Number(id),
details: { deleted: snapshot },
ipAddress: getClientIP(_request),
})
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : '删除失败' const msg = e instanceof Error ? e.message : '删除失败'

View File

@ -3,13 +3,16 @@ import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema' import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth' import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions' import { hasPermission } from '@/lib/permissions'
import { writeAuditLog, getClientIP } from '@/lib/audit'
import { notifyTicketResolved } from '@/lib/monitor/ticket-notifier'
import { applyResolutionSideEffects } from '@/lib/monitor/resolution-side-effects'
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
try { try {
initDatabase() initDatabase()
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 }) if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 }) if (!hasPermission(user, 'tickets:edit')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const body = await request.json() const body = await request.json()
const updates: Array<{ id: number; fault_category?: string; current_status?: string; counted_in_sla?: number }> = body.updates || [] const updates: Array<{ id: number; fault_category?: string; current_status?: string; counted_in_sla?: number }> = body.updates || []
@ -24,6 +27,11 @@ export async function PUT(request: NextRequest) {
for (const item of updates) { for (const item of updates) {
if (!item.id) continue if (!item.id) continue
// 读取当前状态(用于状态跳变检测)
const existing = db.prepare('SELECT current_status FROM tickets WHERE id = ?').get(item.id) as { current_status: string } | undefined
if (!existing) continue
const fields: string[] = [] const fields: string[] = []
const values: unknown[] = [] const values: unknown[] = []
@ -35,15 +43,38 @@ export async function PUT(request: NextRequest) {
} }
if (fields.length === 0) continue if (fields.length === 0) continue
fields.push("updated_at = datetime('now')") fields.push("updated_at = datetime('now', '+8 hours')")
fields.push('updated_by = ?') fields.push('updated_by = ?')
values.push(user.id) values.push(user.id)
values.push(item.id) values.push(item.id)
const result = db.prepare(`UPDATE tickets SET ${fields.join(', ')} WHERE id = ?`).run(...values) const result = db.prepare(`UPDATE tickets SET ${fields.join(', ')} WHERE id = ?`).run(...values)
// 结单推送:状态从非终态 → resolved/closed 时触发
const wasTerminal = ['resolved', 'closed'].includes(existing.current_status)
const nowTerminal = item.current_status && ['resolved', 'closed'].includes(item.current_status)
if (result.changes > 0 && !wasTerminal && nowTerminal) {
applyResolutionSideEffects(item.id)
void notifyTicketResolved(item.id).catch(e =>
console.error(`[API:batch] 结单推送失败 (ticket ${item.id}):`, e)
)
}
updated += result.changes updated += result.changes
} }
if (updated > 0) {
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'batch_update',
entityType: 'ticket',
entityId: null,
details: { updated, total: updates.length },
ipAddress: getClientIP(request),
})
}
return NextResponse.json({ updated, total: updates.length }) return NextResponse.json({ updated, total: updates.length })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : '批量更新失败' const msg = e instanceof Error ? e.message : '批量更新失败'

View File

@ -2,13 +2,16 @@ import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db' import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema' import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth' import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { exportTicketsToExcel } from '@/lib/excel' import { exportTicketsToExcel } from '@/lib/excel'
import { writeAuditLog, getClientIP } from '@/lib/audit'
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
initDatabase() initDatabase()
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 }) if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'tickets:export')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { searchParams } = request.nextUrl const { searchParams } = request.nextUrl
const idsParam = searchParams.get('ids') const idsParam = searchParams.get('ids')
@ -45,10 +48,22 @@ export async function GET(request: NextRequest) {
const buffer = exportTicketsToExcel(tickets) const buffer = exportTicketsToExcel(tickets)
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'export',
entityType: 'ticket',
entityId: null,
details: { count: tickets.length },
ipAddress: getClientIP(request),
})
const d = new Date()
const exportDate = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
return new NextResponse(new Uint8Array(buffer), { return new NextResponse(new Uint8Array(buffer), {
headers: { headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename="tickets_${new Date().toISOString().slice(0, 10)}.xlsx"`, 'Content-Disposition': `attachment; filename="tickets_${exportDate}.xlsx"`,
}, },
}) })
} catch (e) { } catch (e) {

View File

@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db' import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema' import { initDatabase } from '@/lib/db-schema'
import { verifyApiKey } from '@/lib/auth' import { verifyApiKey } from '@/lib/auth'
import { writeAuditLog, getClientIP } from '@/lib/audit'
import { notifyTicketCreated } from '@/lib/monitor/ticket-notifier'
function verifyEnvApiKey(key: string): boolean { function verifyEnvApiKey(key: string): boolean {
const allowed = process.env.ALLOWED_API_KEYS || '' const allowed = process.env.ALLOWED_API_KEYS || ''
@ -27,6 +29,7 @@ export async function POST(request: NextRequest) {
const authHeader = request.headers.get('authorization') const authHeader = request.headers.get('authorization')
let authenticated = false let authenticated = false
let apiKeyInfo: { id: number; name: string; permissions: string[] } | null = null
if (authHeader?.startsWith('Bearer ak_')) { if (authHeader?.startsWith('Bearer ak_')) {
const key = authHeader.slice(7) const key = authHeader.slice(7)
@ -34,7 +37,10 @@ export async function POST(request: NextRequest) {
authenticated = true authenticated = true
} else { } else {
const keyInfo = verifyApiKey(key) const keyInfo = verifyApiKey(key)
if (keyInfo) authenticated = true if (keyInfo) {
authenticated = true
apiKeyInfo = keyInfo
}
} }
} }
@ -81,6 +87,27 @@ export async function POST(request: NextRequest) {
) )
const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(ticketId) const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(ticketId)
writeAuditLog({
userId: null,
apiKeyId: apiKeyInfo?.id ?? null,
action: 'create',
entityType: 'ticket',
entityId: ticketId,
details: { created: { ticket_no: ticketNo, device_ip: body.device_ip || null } },
ipAddress: getClientIP(request)
})
void notifyTicketCreated({
ticket_no: ticketNo,
device_ip: body.device_ip || null,
device_sn: body.device_sn || null,
device_name: body.device_name || null,
ticket_type: body.ticket_type || null,
content: body.content || null,
assign_time: body.assign_time || null,
}).catch(e => console.error('[external] notify 失败:', e))
return NextResponse.json({ ticket, created: true }, { status: 201 }) return NextResponse.json({ ticket, created: true }, { status: 201 })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : '创建失败' const msg = e instanceof Error ? e.message : '创建失败'

View File

@ -16,7 +16,7 @@ export async function GET(request: NextRequest) {
const result: Record<string, string[]> = {} const result: Record<string, string[]> = {}
for (const field of fields) { for (const field of fields) {
const allowed = ['device_ip', 'device_name', 'fault_category', 'current_status', 'ticket_no', 'fault_subcategory'] const allowed = ['device_ip', 'device_name', 'fault_category', 'current_status', 'id', 'fault_subcategory']
if (!allowed.includes(field)) continue 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>[] 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]) result[field] = rows.map(r => r[field])

View File

@ -4,6 +4,8 @@ import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth' import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions' import { hasPermission } from '@/lib/permissions'
import { parseExcelTickets } from '@/lib/excel' import { parseExcelTickets } from '@/lib/excel'
import { writeAuditLog, getClientIP } from '@/lib/audit'
import { notifyBatchSummary } from '@/lib/monitor/ticket-notifier'
function validateTicketNo(ticketNo: string): string | null { function validateTicketNo(ticketNo: string): string | null {
if (!/^\d{14}$/.test(ticketNo)) { if (!/^\d{14}$/.test(ticketNo)) {
@ -24,7 +26,7 @@ export async function POST(request: NextRequest) {
initDatabase() initDatabase()
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 }) if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 }) if (!hasPermission(user, 'tickets:import')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const formData = await request.formData() const formData = await request.formData()
const file = formData.get('file') as File | null const file = formData.get('file') as File | null
@ -120,6 +122,25 @@ export async function POST(request: NextRequest) {
transaction() transaction()
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'import',
entityType: 'ticket',
entityId: null,
details: {
created: imported.length,
overwritten: conflicts.length,
errors: errors.length,
},
ipAddress: getClientIP(request),
})
// 事务提交后汇总推送(后台,不阻塞响应)
if (imported.length > 0) {
void notifyBatchSummary(imported).catch(e => console.error('[import] notify 失败:', e))
}
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
imported: imported.length, imported: imported.length,

View File

@ -3,6 +3,8 @@ import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema' import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth' import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions' import { hasPermission } from '@/lib/permissions'
import { writeAuditLog, getClientIP } from '@/lib/audit'
import { notifyTicketCreated } from '@/lib/monitor/ticket-notifier'
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
@ -101,7 +103,7 @@ export async function POST(request: NextRequest) {
initDatabase() initDatabase()
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 }) if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 }) if (!hasPermission(user, 'tickets:create')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const body = await request.json() const body = await request.json()
const db = getDb() const db = getDb()
@ -155,7 +157,29 @@ export async function POST(request: NextRequest) {
} }
} }
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'create',
entityType: 'ticket',
entityId: ticketId,
details: { created: { ticket_no: ticketNo, device_ip: body.device_ip, device_name: body.device_name } },
ipAddress: getClientIP(request)
})
const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(result.lastInsertRowid) const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(result.lastInsertRowid)
// fire-and-forget 微信推送,不阻塞响应
void notifyTicketCreated({
ticket_no: ticketNo,
device_ip: body.device_ip || null,
device_sn: body.device_sn || null,
device_name: body.device_name || null,
ticket_type: body.ticket_type || null,
content: body.content || null,
assign_time: body.assign_time || null,
}).catch(e => console.error('[tickets] notify 失败:', e))
return NextResponse.json({ ticket }, { status: 201 }) return NextResponse.json({ ticket }, { status: 201 })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : '创建失败' const msg = e instanceof Error ? e.message : '创建失败'

View File

@ -3,6 +3,24 @@ import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema' import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser, hashPassword } from '@/lib/auth' import { getCurrentUser, hashPassword } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions' import { hasPermission } from '@/lib/permissions'
import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit'
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
const { id } = await params
const db = getDb()
const row = db.prepare(`
SELECT id, username, display_name, email, role, is_active, created_at, updated_at,
last_login_at,
CASE WHEN last_active_at IS NOT NULL AND datetime(last_active_at, '+5 minutes') > datetime('now', '+8 hours') THEN 1 ELSE 0 END AS is_online
FROM users WHERE id = ?
`).get(id) as Record<string, unknown> | undefined
if (!row) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
return NextResponse.json({ user: row })
}
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try { try {
@ -15,9 +33,17 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const body = await request.json() const body = await request.json()
const db = getDb() const db = getDb()
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id) const existing = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as { id: number; username: string } | undefined
if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 }) if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
// 获取更新前的完整记录用于审计日志
const beforeUpdate = db.prepare('SELECT id, username, display_name, email, role, is_active FROM users WHERE id = ?').get(id) as Record<string, unknown>
// 禁止修改系统保留用户的角色
if (body.role && (existing.username === 'admin' || existing.username === 'localadmin')) {
return NextResponse.json({ error: '不能修改系统保留用户的角色' }, { status: 400 })
}
const fields: string[] = [] const fields: string[] = []
const values: unknown[] = [] const values: unknown[] = []
@ -28,12 +54,26 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
if (body.password) { fields.push('password_hash = ?'); values.push(hashPassword(body.password)) } if (body.password) { fields.push('password_hash = ?'); values.push(hashPassword(body.password)) }
if (fields.length > 0) { if (fields.length > 0) {
fields.push("updated_at = datetime('now')") fields.push("updated_at = datetime('now', '+8 hours')")
values.push(id) values.push(id)
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values) 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) const updated = db.prepare(`SELECT id, username, display_name, email, role, is_active, created_at, updated_at,
last_login_at,
CASE WHEN last_active_at IS NOT NULL AND datetime(last_active_at, '+5 minutes') > datetime('now', '+8 hours') THEN 1 ELSE 0 END AS is_online
FROM users WHERE id = ?`).get(id)
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'update',
entityType: 'user',
entityId: Number(id),
details: { changes: diffObjects(beforeUpdate, updated as Record<string, unknown>) },
ipAddress: getClientIP(request),
})
return NextResponse.json({ user: updated }) return NextResponse.json({ user: updated })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : '更新失败' const msg = e instanceof Error ? e.message : '更新失败'
@ -52,10 +92,26 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
if (String(id) === String(user.id)) return NextResponse.json({ error: '不能删除自己' }, { status: 400 }) if (String(id) === String(user.id)) return NextResponse.json({ error: '不能删除自己' }, { status: 400 })
const db = getDb() const db = getDb()
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id) const existing = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as { id: number; username: string } | undefined
if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 }) if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
if (existing.username === 'admin' || existing.username === 'localadmin') {
return NextResponse.json({ error: '不能删除系统保留用户' }, { status: 400 })
}
const snapshot = db.prepare('SELECT id, username, display_name, role FROM users WHERE id = ?').get(id) as Record<string, unknown>
db.prepare('DELETE FROM users WHERE id = ?').run(id) db.prepare('DELETE FROM users WHERE id = ?').run(id)
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'delete',
entityType: 'user',
entityId: Number(id),
details: { deleted: snapshot },
ipAddress: getClientIP(_request),
})
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : '删除失败' const msg = e instanceof Error ? e.message : '删除失败'

View File

@ -4,6 +4,7 @@ import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth' import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions' import { hasPermission } from '@/lib/permissions'
import { hashPassword } from '@/lib/auth' import { hashPassword } from '@/lib/auth'
import { writeAuditLog, getClientIP } from '@/lib/audit'
export async function GET() { export async function GET() {
try { try {
@ -13,7 +14,10 @@ export async function GET() {
if (!hasPermission(user, 'users:read')) return NextResponse.json({ error: '权限不足' }, { status: 403 }) if (!hasPermission(user, 'users:read')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const db = getDb() 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() const users = db.prepare(`SELECT id, username, display_name, email, role, is_active, created_at, updated_at,
last_login_at,
CASE WHEN last_active_at IS NOT NULL AND datetime(last_active_at, '+5 minutes') > datetime('now', '+8 hours') THEN 1 ELSE 0 END AS is_online
FROM users ORDER BY id`).all()
return NextResponse.json({ users }) return NextResponse.json({ users })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : '查询失败' const msg = e instanceof Error ? e.message : '查询失败'
@ -41,10 +45,21 @@ export async function POST(request: NextRequest) {
const hash = hashPassword(password) const hash = hashPassword(password)
const result = db.prepare( const result = db.prepare(
'INSERT INTO users (username, password_hash, display_name, email, role) VALUES (?, ?, ?, ?, ?)' "INSERT INTO users (username, password_hash, display_name, email, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
).run(username, hash, display_name, email || null, role || 'viewer') ).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) const newUser = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at FROM users WHERE id = ?').get(result.lastInsertRowid)
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'create',
entityType: 'user',
entityId: result.lastInsertRowid as number,
details: { created: { username, display_name, role: role || 'viewer' } },
ipAddress: getClientIP(request),
})
return NextResponse.json({ user: newUser }, { status: 201 }) return NextResponse.json({ user: newUser }, { status: 201 })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : '创建失败' const msg = e instanceof Error ? e.message : '创建失败'

View File

@ -0,0 +1,43 @@
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 { ldapGetUserInfo } from '@/lib/ldap'
import { writeAuditLog, getClientIP } from '@/lib/audit'
export async function POST(_request: NextRequest) {
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 db = getDb()
const users = db.prepare(
'SELECT id, username FROM users WHERE email IS NULL OR email = \'\''
).all() as { id: number; username: string }[]
let synced = 0
let failed = 0
for (const u of users) {
const info = await ldapGetUserInfo(u.username)
if (info?.email) {
db.prepare('UPDATE users SET email = ? WHERE id = ?').run(info.email, u.id)
synced++
} else {
failed++
}
}
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'sync_emails',
entityType: 'user',
details: { sync_emails: { synced, failed } },
ipAddress: getClientIP(_request)
})
return NextResponse.json({ synced, failed, total: users.length })
}

View File

@ -1,14 +1,15 @@
@import "tailwindcss"; @import "tailwindcss";
@config "../../tailwind.config.js"; @custom-variant dark (&:where(.dark, .dark *));
@source "../../shared";
@source "../../src";
@import './tlyq-design-system.css' layer(base);
@layer base { @layer base {
body { body {
background-color: #f8fafc; font-family: var(--font-body);
color: #0f172a; color: var(--fg);
} background-color: var(--bg);
html.dark body {
background-color: #020617;
color: #ffffff;
} }
} }

View File

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

View File

@ -0,0 +1,362 @@
/*
* TLYQ 统一设计系统 OKLCh 色彩空间2026-07-15 升级
* Tailwind Slate HEX 色板迁移至 OKLCh 色彩空间
* 感知均匀色域更广保持无障碍对比度WCAG AA 4.5:1
*
* 使用方式
* - CSS 变量@import './tlyq-design-system.css'
* - Tailwind颜色类见下方映射说明
*/
/* ==================== 设计令牌CSS 变量) ==================== */
:root {
/* 背景色 */
--bg: oklch(0.984 0.003 247.858); /* ≈ #f8fafc (slate-50) */
--bg-subtle: oklch(0.968 0.007 247.896); /* ≈ #f1f5f9 (slate-100) */
--surface: oklch(1 0 0); /* #ffffff (white) */
--surface-raised: oklch(1 0 0); /* #ffffff (white) */
--surface-overlay: rgba(255, 255, 255, 0.95);
/* 文本色 */
--fg: oklch(0.129 0.042 264.695); /* ≈ #0f172a (slate-900) */
--fg-subtle: oklch(0.372 0.044 257.287); /* ≈ #334155 (slate-700) */
--muted: oklch(0.446 0.043 257.281); /* ≈ #475569 (slate-600) */
--muted-subtle: oklch(0.662 0.051 257.281); /* ≈ #94a3b8 (slate-400) */
/* 边框 */
--border: oklch(0.928 0.006 264.531); /* ≈ #e2e8f0 (slate-200) */
--border-subtle: oklch(0.968 0.007 247.896);/* ≈ #f1f5f9 (slate-100) */
/* 主色调 — 蓝色系 */
--accent: oklch(0.546 0.245 262.881); /* ≈ #2563eb (blue-600) */
--accent-hover: oklch(0.479 0.247 262.881); /* ≈ #1d4ed8 (blue-700) */
--accent-active: oklch(0.426 0.228 262.881);/* ≈ #1e40af (blue-800) */
--accent-subtle: oklch(0.967 0.03 260.592); /* ≈ #eff6ff (blue-50) */
--accent-text: oklch(1 0 0); /* #ffffff */
/* 状态色 */
--success: oklch(0.627 0.194 149.214); /* ≈ #059669 (emerald-600) */
--success-subtle: oklch(0.982 0.041 152.117);/* ≈ #ecfdf5 (emerald-50) */
--warning: oklch(0.681 0.162 75.834); /* ≈ #d97706 (amber-600) */
--warning-subtle: oklch(0.986 0.03 92.708); /* ≈ #fffbeb (amber-50) */
--danger: oklch(0.577 0.245 27.325); /* ≈ #dc2626 (red-600) */
--danger-subtle: oklch(0.96 0.037 26.197); /* ≈ #fef2f2 (red-50) */
--info: oklch(0.546 0.245 262.881); /* ≈ #2563eb (blue-600) */
--info-subtle: oklch(0.967 0.03 260.592); /* ≈ #eff6ff (blue-50) */
/* 字体 */
--font-display: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, sans-serif;
--font-body: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, sans-serif;
--font-sans: var(--font-body);
--font-mono: ui-monospace, "JetBrains Mono", "IBM Plex Mono", SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
/* 字体大小 */
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
--text-base: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.25rem; /* 20px */
--text-2xl: 1.5rem; /* 24px */
--text-3xl: 1.875rem; /* 30px */
/* 行高 */
--leading-tight: 1.25;
--leading-snug: 1.375;
--leading-normal: 1.5;
--leading-relaxed: 1.625;
/* 字间距 */
--tracking-tighter: -0.05em;
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-wide: 0.02em;
--tracking-wider: 0.05em;
--tracking-widest: 0.1em;
/* 间距 */
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-5: 1.25rem; /* 20px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-10: 2.5rem; /* 40px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
/* 圆角 */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 24px;
--radius-full: 9999px;
/* 阴影 */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.04);
/* 过渡 */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-spring: 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
/* 层级系统 */
--z-base: 0;
--z-raised: 10;
--z-dropdown: 100;
--z-sticky: 200;
--z-overlay: 300;
--z-modal: 400;
--z-toast: 700;
/* 布局 */
--sidebar-width: 240px;
--sidebar-width-collapsed: 64px;
--header-height: 56px;
--content-max-width: 1440px;
--content-padding: var(--space-6);
}
/* ==================== 深色模式 ==================== */
:root.dark {
/* 背景色 */
--bg: oklch(0.129 0.042 264.695); /* ≈ #020617 (slate-950) */
--bg-subtle: oklch(0.208 0.042 264.695); /* ≈ #0f172a (slate-900) */
--surface: oklch(0.208 0.042 264.695); /* ≈ #0f172a (slate-900) */
--surface-raised: oklch(0.279 0.041 260.031);/* ≈ #1e293b (slate-800) */
--surface-overlay: rgba(15, 23, 42, 0.95);
/* 文本色 */
--fg: oklch(0.984 0.003 247.858); /* ≈ #f8fafc (slate-50) */
--fg-subtle: oklch(0.837 0.014 253.365); /* ≈ #cbd5e1 (slate-300) */
--muted: oklch(0.662 0.051 257.281); /* ≈ #94a3b8 (slate-400) */
--muted-subtle: oklch(0.527 0.045 257.281); /* ≈ #64748b (slate-500) */
/* 边框 */
--border: oklch(0.279 0.041 260.031); /* ≈ #1e293b (slate-800) */
--border-subtle: oklch(0.208 0.042 264.695);/* ≈ #0f172a (slate-900) */
/* 主色调 */
--accent: oklch(0.623 0.214 259.815); /* ≈ #3b82f6 (blue-500) */
--accent-hover: oklch(0.723 0.157 260.543); /* ≈ #60a5fa (blue-400) */
--accent-active: oklch(0.829 0.096 260.543);/* ≈ #93c5fd (blue-300) */
--accent-subtle: rgba(59, 130, 246, 0.1);
--accent-text: oklch(0.208 0.042 264.695); /* ≈ #0f172a (slate-900) */
/* 状态色 */
--success: oklch(0.696 0.17 162.48); /* ≈ #34d399 (emerald-400) */
--success-subtle: rgba(52, 211, 153, 0.1);
--warning: oklch(0.828 0.12 76.523); /* ≈ #fbbf24 (amber-400) */
--warning-subtle: rgba(251, 191, 36, 0.1);
--danger: oklch(0.715 0.143 25.059); /* ≈ #f87171 (red-400) */
--danger-subtle: rgba(248, 113, 113, 0.1);
--info: oklch(0.723 0.157 260.543); /* ≈ #60a5fa (blue-400) */
--info-subtle: rgba(96, 165, 250, 0.1);
/* 阴影(深色下更突出) */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.2);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.2);
}
/* ==================== Tailwind 暗色模式映射 ==================== */
/*
* 以下 Tailwind 类名可直接使用
* 页面背景: bg-slate-50 dark:bg-slate-950
* 卡片背景: bg-white dark:bg-slate-900
* 边框: border-slate-200 dark:border-slate-800
* 正文: text-slate-900 dark:text-slate-50
* 次要文本: text-slate-700 dark:text-slate-300
* 弱文本: text-slate-500 dark:text-slate-400
* 主按钮: bg-blue-600 hover:bg-blue-700 text-white
* 成功: text-emerald-600 dark:text-emerald-400
* 警告: text-amber-600 dark:text-amber-400
* 危险: text-red-600 dark:text-red-400
*/
/* ==================== 全局重置 ==================== */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--font-sans);
color: var(--fg);
background-color: var(--bg);
}
/* ==================== 组件规范 ==================== */
/* 按钮规范
* 主按钮: bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-sm
* 次按钮: bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 rounded-lg
* 幽灵按钮: hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg
* 尺寸: sm(px-3 py-1.5 text-xs) / md(px-4 py-2 text-sm) / lg(px-6 py-2.5 text-base)
*/
/* 输入框规范
* 边框: border border-slate-300 dark:border-slate-600
* 聚焦: focus:ring-2 focus:ring-blue-500 focus:border-transparent
* 圆角: rounded-lg
* 尺寸: h-10(px-3 text-sm)
*/
/* 卡片规范
* 基础: bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6 shadow-sm
* 悬停: hover:border-blue-200 dark:hover:border-blue-800
*/
/* 表格规范
* 头部: bg-slate-50 dark:bg-slate-800 text-slate-500 text-xs font-medium uppercase
* : border-b border-slate-200 dark:border-slate-700
* 悬停: hover:bg-slate-50 dark:hover:bg-slate-800/50
*/
/* 徽标规范
* 默认: bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300
* 成功: bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400
* 警告: bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400
* 危险: bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400
* 信息: bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400
* 圆角: rounded-full
* 尺寸: px-2.5 py-0.5 text-xs font-medium
*/
/* 弹窗规范
* 背景: bg-white dark:bg-slate-900
* 边框: border border-slate-200 dark:border-slate-800
* 圆角: rounded-xl
* 阴影: shadow-xl
* 标题: text-lg font-semibold
*/
/* 侧边栏规范
* 宽度: w-60 (240px)
* 定位: fixed left-0 top-0 bottom-0
* 背景: bg-white dark:bg-slate-900
* 边框: border-r border-slate-200 dark:border-slate-800
* 品牌区: h-14 flex items-center
* 导航项: px-3 py-2.5 rounded-lg text-sm font-medium
* 激活态: bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400
*/
/* 顶栏规范
* 高度: h-14 (56px)
* 定位: fixed top-0
* 背景: bg-white dark:bg-slate-900
* 边框: border-b border-slate-200 dark:border-slate-800
* z-index: z-30
*/
/* 主内容区规范
* 边距: ml-60 pt-14 p-6 (侧边栏+顶栏)
* 门户布局: max-w-5xl mx-auto px-6 py-8
*/
/* ==================== 统计卡片 ==================== */
/*
* 用于仪表盘/概览页的统计数据展示
* 容器: bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6
* 悬停: hover:border-blue-200 dark:hover:border-blue-800 hover:shadow-md transition-all
* 图标区: w-10 h-10 rounded-lg bg-blue-50 dark:bg-blue-500/10 flex items-center justify-center
* 数值: text-2xl font-semibold text-slate-900 dark:text-white font-display
* 标签: text-sm text-slate-500 dark:text-slate-400
* 变化趋势: text-xs font-medium (positive=text-emerald-600, negative=text-red-600)
*/
/* ==================== 骨架屏加载 ==================== */
/*
* 用于数据加载时的占位效果
* <div className="animate-pulse bg-slate-200 dark:bg-slate-700 rounded-lg [height]" />
* 文本行: h-4 w-full / w-3/4
* 标题: h-6 w-1/3
* 卡片: h-32 w-full rounded-xl
*/
/* 无缝统计条 ==================== */
/*
* 仪表盘顶部关键指标展示分段之间仅 1px 间隙
* 容器: flex gap-px bg-slate-200 dark:bg-slate-800 rounded-xl overflow-hidden
* : flex-1 bg-white dark:bg-slate-900 px-6 py-4 flex flex-col gap-1
* 标签: text-xs font-medium text-slate-500 uppercase tracking-widest
* 数值: text-2xl font-semibold font-[family-name:var(--font-display)]
*/
/* 服务状态卡片 ==================== */
/*
* 仪表盘中展示被监控服务实时状态
* 正常: bg-white dark:bg-slate-900 border rounded-xl p-5
* 悬停: hover:shadow-sm hover:-translate-y-px transition-all cursor-pointer
* 异常: bg-red-50 dark:bg-red-500/10 border-red-600 dark:border-red-400
* 红底红框突出显示
* 状态圆点: w-2.5 h-2.5 rounded-full
* ok: bg-emerald-600 dark:bg-emerald-400 shadow-[0_0_8px_var(--success)]
* err: bg-red-600 dark:bg-red-400 shadow-[0_0_12px_var(--danger)] animate-pulse
* 持续时长: text-lg font-semibold font-mono text-red-600 dark:text-red-400
* 展开区: border-t mt-3 pt-3
*/
/* ==================== 图表容器 ==================== */
/*
* 用于包裹图表组件
* <div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6">
* <div className="flex items-center justify-between mb-6">
* <h3 className="text-lg font-semibold text-slate-900 dark:text-white">标题</h3>
* <div className="flex items-center gap-4 text-sm text-slate-500">图例</div>
* </div>
* [图表内容]
* </div>
*/
/* ==================== 下拉菜单 ==================== */
/*
* 用于用户菜单操作菜单
* 容器: absolute right-0 top-full mt-1
* bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700
* rounded-lg shadow-lg py-1 min-w-[200px] z-[--z-dropdown]
* 菜单项: px-4 py-2.5 text-sm flex items-center gap-3
* hover:bg-slate-50 dark:hover:bg-slate-700
* text-slate-700 dark:text-slate-300
* 危险项: text-red-600 dark:text-red-400
* 分隔线: border-t border-slate-200 dark:border-slate-700 my-1
*/
/* ==================== 站点标识色 ==================== */
/*
* OA: blue-600
* assets: blue-600
* issue: blue-600
* monitor: indigo-600
*
* 站点卡片标识色门户首页
* 资产管理: blue-600 tag: CMDB
* 工单跟踪: violet-600 tag: ITS
* 监控中心: indigo-600 tag: MONITOR
* 官网: emerald-600 tag: WWW
* 云平台: amber-600 tag: CLOUD
* Token: rose-600 tag: TOKEN
* 代码仓库: pink-600 tag: GIT
*
* 2026-07-15 HEX 迁移至 OKLCh 色彩空间
* 主色 blue-600 OKLCh: oklch(0.546 0.245 262.881)
* 主色 indigo-600 OKLCh: oklch(0.511 0.262 276.966)
* 对比度验证白底蓝字 (oklch(0.546 0.245 262.881) on oklch(1 0 0)) 6.5:1 4.5:1 通过
*/

View File

@ -12,7 +12,7 @@ export default function AppShell({ children }: AppShellProps) {
}, []) }, [])
return ( return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-950"> <div className="min-h-screen bg-slate-50 dark:bg-slate-950">
<Sidebar /> <Sidebar role={user?.role} />
<TopBar user={user} /> <TopBar user={user} />
<main className="ml-60 pt-14 min-h-screen"><div className="p-6">{children}</div></main> <main className="ml-60 pt-14 min-h-screen"><div className="p-6">{children}</div></main>
</div> </div>

View File

@ -1,63 +1,81 @@
'use client' 'use client'
import { useState, useEffect } from 'react' // src/components/layout/Sidebar.tsx — 权限驱动侧边栏(统一标准布局)
import Link from 'next/link' import Link from 'next/link'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { LayoutDashboard, FileText, Settings, Users, Shield, Key, Clock, CheckCircle, PlusSquare, Upload, List } from 'lucide-react' import { LayoutDashboard, FileText, Settings, Users, Shield, Key, Clock, CheckCircle, PlusSquare, Upload, List, Mail, Bell } from 'lucide-react'
const navItems = [ interface NavItem { label: string; href: string; icon: React.ComponentType<{ size?: number }>; perm: string | null }
{ href: '/dashboard', label: '仪表盘', icon: LayoutDashboard }, interface NavSection { title?: string; items: NavItem[] }
{ href: '/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 = [ export default function Sidebar({ role }: { role?: string }) {
{ href: '/settings/users', label: '用户管理', icon: Users },
{ href: '/settings/roles', label: '角色权限', icon: Shield },
{ href: '/settings/api-keys', label: 'API Key', icon: Key },
]
export default function Sidebar() {
const pathname = usePathname() const pathname = usePathname()
const [isAdmin, setIsAdmin] = useState(false) const isActive = (href: string) => pathname === href || (href !== '/' && pathname.startsWith(href))
const isAdmin = role === 'admin' || role === 'localadmin'
const sections: NavSection[] = [
{ items: [{ label: '仪表盘', href: '/dashboard', icon: LayoutDashboard, perm: null }] },
{
title: '工单管理',
items: [
{ label: '待办工单', href: '/tickets/pending', icon: Clock, perm: null },
{ label: '已办工单', href: '/tickets/completed', icon: CheckCircle, perm: null },
{ label: '手动建单', href: '/tickets/create', icon: PlusSquare, perm: null },
{ label: '导入工单', href: '/tickets/import', icon: Upload, perm: null },
{ label: '全部工单', href: '/tickets/all', icon: List, perm: isAdmin ? null : 'hidden' },
],
},
{
title: '报告',
items: [{ label: '报告管理', href: '/reports', icon: FileText, perm: null }],
},
{
title: '系统设置',
items: [
{ label: '用户管理', href: '/settings/users', icon: Users, perm: isAdmin ? null : 'hidden' },
{ label: '角色权限', href: '/settings/roles', icon: Shield, perm: isAdmin ? null : 'hidden' },
{ label: 'API Key', href: '/settings/api-keys', icon: Key, perm: isAdmin ? null : 'hidden' },
{ label: '审计日志', href: '/settings/audit-logs', icon: FileText, perm: isAdmin ? null : 'hidden' },
{ label: '邮件监控', href: '/settings/monitor', icon: Mail, perm: isAdmin ? null : 'hidden' },
{ label: '微信推送', href: '/settings/wechat', icon: Bell, perm: isAdmin ? null : 'hidden' },
],
},
]
useEffect(() => {
fetch('/api/auth/me')
.then(r => r.json())
.then(u => { if (u.user?.role === 'admin') setIsAdmin(true) })
.catch(() => {})
}, [])
return ( return (
<aside className="fixed left-0 top-0 bottom-0 w-60 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex flex-col z-40"> <aside className="fixed left-0 top-0 bottom-0 w-60 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 z-[200] flex flex-col">
<div className="h-14 flex items-center px-5 border-b border-slate-200 dark:border-slate-800"> {/* 品牌区 */}
<span className="text-lg font-semibold text-blue-600 dark:text-blue-400">IT工单跟踪系统</span> <div className="h-14 flex items-center px-6 border-b border-slate-200 dark:border-slate-800 shrink-0">
</div> <Link href="/dashboard" className="flex items-center gap-3">
<nav className="flex-1 py-3 px-3 space-y-1 overflow-y-auto"> <div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center text-white font-bold text-sm"></div>
{navItems.map((item) => { <span className="font-semibold text-slate-900 dark:text-white">IT工单跟踪系统</span>
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> </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> </div>
{settingsItems.map((item) => {
const isActive = pathname === item.href {/* 导航区 */}
<nav className="flex-1 overflow-y-auto p-3 space-y-6">
{sections.map((section, i) => (
<div key={i}>
{section.title && (
<p className="px-3 mb-1 text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-widest">
{section.title}
</p>
)}
{section.items.filter(item => item.perm !== 'hidden').map(item => {
const Icon = item.icon 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>) return (
<Link key={item.href} href={item.href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors mb-0.5 ${
isActive(item.href)
? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'
}`}>
<Icon size={18} />
<span>{item.label}</span>
</Link>
)
})} })}
</div> </div>
))}
</nav> </nav>
</aside> </aside>
) )

View File

@ -2,18 +2,53 @@
import { useTheme } from '@/components/providers/ThemeProvider' import { useTheme } from '@/components/providers/ThemeProvider'
import { Sun, Moon, LogOut, User } from 'lucide-react' import { Sun, Moon, LogOut, User } from 'lucide-react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useState, useRef, useEffect } from 'react'
type Theme = 'light' | 'dark' | 'auto'
interface TopBarProps { user: { username: string; display_name: string; role: string } | null } interface TopBarProps { user: { username: string; display_name: string; role: string } | null }
export default function TopBar({ user }: TopBarProps) { export default function TopBar({ user }: TopBarProps) {
const { theme, toggleTheme } = useTheme() const { theme, setTheme } = useTheme()
const router = useRouter() const router = useRouter()
const handleLogout = async () => { await fetch('/api/auth/logout', { method: 'POST' }); router.push('/login'); router.refresh() } const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST' })
router.push('/login'); router.refresh()
}
const icons: Record<Theme, React.ReactNode> = { light: <Sun size={16} />, dark: <Moon size={16} />, auto: <span style={{fontSize:14}}></span> }
const labels: Record<Theme, string> = { light: '浅色', dark: '深色', auto: '自动' }
return ( return (
<header className="fixed top-0 left-60 right-0 h-14 bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between px-6 z-30"> <header className="fixed top-0 left-60 right-0 h-14 bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between px-6 z-30">
<div /> <div />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button onClick={toggleTheme} className="p-2 rounded-lg 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> <div ref={ref} className="relative">
<button onClick={() => setOpen(!open)} className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors" title="切换主题">
{icons[theme]}
</button>
{open && (
<div className="absolute top-full right-0 mt-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg z-50 py-1 min-w-[100px]">
{(['light', 'dark', 'auto'] as Theme[]).map(t => (
<button key={t} onClick={() => { setTheme(t); setOpen(false) }}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left ${t === theme ? 'bg-slate-100 dark:bg-slate-700' : 'hover:bg-slate-50 dark:hover:bg-slate-700/50'}`}>
<span>{icons[t]}</span>
<span>{labels[t]}</span>
{t === theme && <span className="ml-auto text-blue-600"></span>}
</button>
))}
</div>
)}
</div>
{user && (<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>)} {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> </div>
</header> </header>

View File

@ -1,25 +1,35 @@
'use client' 'use client'
import { createContext, useContext, useEffect, useState, ReactNode } from 'react' import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
type Theme = 'light' | 'dark' type Theme = 'light' | 'dark' | 'auto'
interface ThemeContextType { theme: Theme; toggleTheme: () => void } interface ThemeContextType { theme: Theme; setTheme: (t: Theme) => void }
const ThemeContext = createContext<ThemeContextType>({ theme: 'dark', toggleTheme: () => {} }) const ThemeContext = createContext<ThemeContextType>({ theme: 'auto', setTheme: () => {} })
export function useTheme() { return useContext(ThemeContext) } export function useTheme() { return useContext(ThemeContext) }
function applyTheme(t: Theme) {
const root = document.documentElement
root.classList.remove('light', 'dark')
if (t === 'auto') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
root.classList.add(prefersDark ? 'dark' : 'light')
} else {
root.classList.add(t)
}
}
export function ThemeProvider({ children }: { children: ReactNode }) { export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('dark') const [theme, setThemeState] = useState<Theme>('auto')
useEffect(() => { useEffect(() => {
const stored = localStorage.getItem('theme') as Theme | null const stored = localStorage.getItem('theme') as Theme | null
if (stored) { setTheme(stored); document.documentElement.classList.toggle('dark', stored === 'dark') } const initial = stored || 'auto'
else { setThemeState(initial)
const d = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' applyTheme(initial)
setTheme(d); document.documentElement.classList.toggle('dark', d === 'dark')
}
}, []) }, [])
const toggleTheme = () => { const setTheme = (t: Theme) => {
const n = theme === 'dark' ? 'light' : 'dark' setThemeState(t)
setTheme(n); localStorage.setItem('theme', n); document.documentElement.classList.toggle('dark', n === 'dark') localStorage.setItem('theme', t)
applyTheme(t)
} }
return <ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider> return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>
} }

View File

@ -27,7 +27,7 @@ interface ProcessFormProps {
} }
const FAULT_CATEGORIES = [ const FAULT_CATEGORIES = [
'硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障', '其他', '硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障',
] ]
const TICKET_TYPES = ['OEM诊断', 'OEM维修'] const TICKET_TYPES = ['OEM诊断', 'OEM维修']
@ -35,7 +35,7 @@ const TICKET_TYPES = ['OEM诊断', 'OEM维修']
const FAULT_SUBCATEGORIES = [ const FAULT_SUBCATEGORIES = [
'GPU故障', 'CPU故障', '内存故障', '硬盘故障', '电源故障', '风扇故障', 'GPU故障', 'CPU故障', '内存故障', '硬盘故障', '电源故障', '风扇故障',
'OS崩溃', '驱动故障', '软件崩溃', '配置错误', '网络丢包', '网络中断', 'OS崩溃', '驱动故障', '软件崩溃', '配置错误', '网络丢包', '网络中断',
'存储掉盘', 'RAID故障', '无故障', '其他', '存储掉盘', 'RAID故障', '其他',
] ]
const HANDLER_OPTIONS = ['腾讯', '图灵'] const HANDLER_OPTIONS = ['腾讯', '图灵']

View File

@ -11,7 +11,7 @@ interface TicketFormProps {
} }
const defaultFaultCategories = [ const defaultFaultCategories = [
'硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障', '其他', '硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障',
] ]
const defaultTicketTypes = ['OEM诊断', 'OEM维修'] const defaultTicketTypes = ['OEM诊断', 'OEM维修']
@ -19,7 +19,7 @@ const defaultTicketTypes = ['OEM诊断', 'OEM维修']
const defaultFaultSubcategories = [ const defaultFaultSubcategories = [
'GPU故障', 'CPU故障', '内存故障', '硬盘故障', '电源故障', '风扇故障', 'GPU故障', 'CPU故障', '内存故障', '硬盘故障', '电源故障', '风扇故障',
'OS崩溃', '驱动故障', '软件崩溃', '配置错误', '网络丢包', '网络中断', 'OS崩溃', '驱动故障', '软件崩溃', '配置错误', '网络丢包', '网络中断',
'存储掉盘', 'RAID故障', '无故障', '其他', '存储掉盘', 'RAID故障', '其他',
] ]
export default function TicketForm({ initialData, ticketId }: TicketFormProps) { export default function TicketForm({ initialData, ticketId }: TicketFormProps) {

View File

@ -1,7 +1,7 @@
'use client' 'use client'
import { useState, useEffect, useCallback, useRef, Suspense } from 'react' import { useState, useEffect, useCallback, useRef, Suspense } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams, usePathname } from 'next/navigation'
import { Button, Badge, Pagination } from '@/components/ui' 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 { Search, Download, Eye, Pencil, Trash2, Filter, ArrowUpDown, ChevronsUpDown, ChevronUp, ChevronDown, Check, X, ExternalLink } from 'lucide-react'
import SelectWithInput from '@/components/ui/SelectWithInput' import SelectWithInput from '@/components/ui/SelectWithInput'
@ -28,7 +28,7 @@ const statusMap: Record<string, { label: string; variant: 'default' | 'info' | '
} }
const STATUS_OPTIONS = ['open', 'in_progress', 'resolved', 'closed'] const STATUS_OPTIONS = ['open', 'in_progress', 'resolved', 'closed']
const FAULT_CATEGORY_OPTIONS = ['硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障', '其他'] const FAULT_CATEGORY_OPTIONS = ['硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障']
const COLUMNS = [ const COLUMNS = [
{ key: 'id', label: '工单号', sortable: true, filterable: true, filterType: 'text' as const }, { key: 'id', label: '工单号', sortable: true, filterable: true, filterType: 'text' as const },
@ -148,6 +148,7 @@ interface TicketListInnerProps {
function TicketListInner({ onPaginationChange, defaultStatusFilter, showSlaColumn, showActions = true, hideDefaultFilterChips }: TicketListInnerProps) { function TicketListInner({ onPaginationChange, defaultStatusFilter, showSlaColumn, showActions = true, hideDefaultFilterChips }: TicketListInnerProps) {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const pathname = usePathname()
const [tickets, setTickets] = useState<Ticket[]>([]) const [tickets, setTickets] = useState<Ticket[]>([])
const [pagination, setPagination] = useState({ page: 1, pageSize: 20, total: 0, totalPages: 0 }) const [pagination, setPagination] = useState({ page: 1, pageSize: 20, total: 0, totalPages: 0 })
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -165,8 +166,16 @@ function TicketListInner({ onPaginationChange, defaultStatusFilter, showSlaColum
const [dateFilter, setDateFilter] = useState<Record<string, { start: string; end: string }>>({}) const [dateFilter, setDateFilter] = useState<Record<string, { start: string; end: string }>>({})
const [fieldOptions, setFieldOptions] = useState<Record<string, string[]>>({}) const [fieldOptions, setFieldOptions] = useState<Record<string, string[]>>({})
const [ticketNoFilter, setTicketNoFilter] = useState('') const [ticketNoFilter, setTicketNoFilter] = useState('')
const [permissions, setPermissions] = useState<string[]>([])
const filterDropRef = useRef<HTMLDivElement>(null) const filterDropRef = useRef<HTMLDivElement>(null)
useEffect(() => {
fetch('/api/auth/me')
.then(r => r.json())
.then(u => { if (u.user?.permissions) setPermissions(u.user.permissions) })
.catch(() => {})
}, [])
// 列宽拖拽调整 // 列宽拖拽调整
const [colWidths, setColWidths] = useState<Record<string, number>>({}) const [colWidths, setColWidths] = useState<Record<string, number>>({})
const [resizingCol, setResizingCol] = useState<string | null>(null) const [resizingCol, setResizingCol] = useState<string | null>(null)
@ -428,7 +437,9 @@ function TicketListInner({ onPaginationChange, defaultStatusFilter, showSlaColum
<Button variant="secondary" size="sm" onClick={() => { setPage(1); fetchTickets() }}></Button> <Button variant="secondary" size="sm" onClick={() => { setPage(1); fetchTickets() }}></Button>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{(permissions.includes('*') || permissions.includes('tickets:export')) && (
<Button variant="secondary" size="sm" onClick={handleExport}><Download size={14} /></Button> <Button variant="secondary" size="sm" onClick={handleExport}><Download size={14} /></Button>
)}
</div> </div>
</div> </div>
@ -652,12 +663,12 @@ function TicketListInner({ onPaginationChange, defaultStatusFilter, showSlaColum
<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 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"> <td className="px-4 py-3">
{t.device_ip ? ( {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> <Link href={`${pathname}?device_ip=${encodeURIComponent(t.device_ip)}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.device_ip}</Link>
) : '-'} ) : '-'}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{t.device_name ? ( {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> <Link href={`${pathname}?device_name=${encodeURIComponent(t.device_name)}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.device_name}</Link>
) : '-'} ) : '-'}
</td> </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 text-slate-700 dark:text-slate-300 max-w-xs truncate">{t.content || '-'}</td>

View File

@ -0,0 +1,19 @@
import { ReactNode } from 'react'
type Variant = 'default' | 'success' | 'warning' | 'danger' | 'info'
// 兼容旧 API: blue→info, green→success, gray→default, red→danger, yellow→warning
type LegacyColor = 'blue' | 'green' | 'gray' | 'red' | 'yellow'
const legacyMap: Record<LegacyColor, Variant> = {
blue: 'info', green: 'success', gray: 'default', red: 'danger', yellow: 'warning',
}
interface BadgeProps { children: ReactNode; variant?: Variant; color?: LegacyColor }
const vs: Record<Variant, string> = {
default: 'bg-slate-100 dark:bg-slate-800 dark:text-slate-300 text-slate-700',
success: 'bg-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-400 text-emerald-700',
warning: 'bg-amber-100 dark:bg-amber-500/10 dark:text-amber-400 text-amber-700',
danger: 'bg-red-100 dark:bg-red-500/10 dark:text-red-400 text-red-700',
info: 'bg-blue-100 dark:bg-blue-500/10 dark:text-blue-400 text-blue-700',
}
export default function Badge({ children, variant = 'default', color }: BadgeProps) {
const v = color ? vs[legacyMap[color]] : vs[variant]
return <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${v}`}>{children}</span>
}

View File

@ -0,0 +1,20 @@
'use client'
import { ButtonHTMLAttributes, forwardRef } from 'react'
type Variant = 'primary' | 'secondary' | 'danger' | 'ghost'
type Size = 'sm' | 'md' | 'lg'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { variant?: Variant; size?: Size; loading?: boolean }
const v: Record<Variant, string> = {
primary: 'bg-blue-600 hover:bg-blue-700 text-white shadow-sm disabled:opacity-50 disabled:cursor-not-allowed',
secondary: 'bg-slate-100 hover:bg-slate-200 text-slate-700 dark:bg-slate-800 dark:hover:bg-slate-700 dark:text-slate-300',
danger: 'bg-red-600 hover:bg-red-700 text-white disabled:opacity-50 disabled:cursor-not-allowed',
ghost: 'text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800',
}
const s: Record<Size, string> = { sm: 'px-3 py-1.5 text-xs', md: 'px-4 py-2 text-sm', lg: 'px-6 py-2.5 text-base' }
const Button = forwardRef<HTMLButtonElement, ButtonProps>(({ variant = 'primary', size = 'md', loading, children, className = '', disabled, ...props }, ref) => (
<button ref={ref} disabled={disabled || loading} className={`inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500/50 disabled:opacity-50 disabled:cursor-not-allowed ${v[variant]} ${s[size]} ${className}`} {...props}>
{loading && <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>}
{children}
</button>
))
Button.displayName = 'Button'
export default Button

View File

@ -0,0 +1,57 @@
'use client'
import { ReactNode, useEffect, useRef } from 'react'
interface ModalProps { open: boolean; onClose: () => void; title?: string; children: ReactNode; footer?: ReactNode }
export default function Modal({ open, onClose, title, children, footer }: ModalProps) {
const footerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
document.body.style.overflow = open ? 'hidden' : ''
return () => { document.body.style.overflow = '' }
}, [open])
useEffect(() => {
if (!open) return
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') { onClose(); return }
if (!footerRef.current) return
const buttons = footerRef.current.querySelectorAll('button:not([disabled])')
if (buttons.length === 0) return
const currentIdx = Array.from(buttons).indexOf(document.activeElement as HTMLButtonElement)
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault()
const next = e.key === 'ArrowRight'
? (currentIdx + 1) % buttons.length
: (currentIdx - 1 + buttons.length) % buttons.length
;(buttons[next] as HTMLButtonElement).focus()
} else if (e.key === 'Enter' && currentIdx >= 0) {
e.preventDefault()
;(document.activeElement as HTMLButtonElement).click()
}
}
document.addEventListener('keydown', handleKeyDown)
const t = setTimeout(() => {
const btn = footerRef.current?.querySelector('button:not([disabled])')
if (btn) (btn as HTMLButtonElement).focus()
}, 100)
return () => { document.removeEventListener('keydown', handleKeyDown); clearTimeout(t) }
}, [open, onClose])
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative w-full max-w-lg mx-4 max-h-[90vh] overflow-auto bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-xl">
{title && (
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-800">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">{title}</h3>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 text-xl leading-none">&times;</button>
</div>
)}
<div className="p-6">{children}</div>
{footer && (
<div ref={footerRef} className="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-200 dark:border-slate-800">{footer}</div>
)}
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More