Compare commits
No commits in common. "main" and "v2026.06.23" have entirely different histories.
main
...
v2026.06.2
29
.env.example
29
.env.example
|
|
@ -1,21 +1,12 @@
|
||||||
# issue-ai 环境变量(本地开发)
|
|
||||||
DATABASE_PATH=./data/issue.db
|
DATABASE_PATH=./data/issue.db
|
||||||
JWT_SECRET=dev-jwt-secret-local
|
JWT_SECRET=your-secret-key-change-in-production
|
||||||
COOKIE_DOMAIN=
|
ADMIN_PASSWORD=admin123
|
||||||
NODE_ENV=development
|
ASSETS_API_URL=http://assets-ai:3000/api
|
||||||
NODE_TLS_REJECT_UNAUTHORIZED=0
|
ASSETS_API_KEY=your-assets-api-key
|
||||||
|
# 允许调用 issue-ai API 的 Key(逗号分隔,支持多个),由 issue-ai 管理界面生成
|
||||||
# LDAP 配置
|
ALLOWED_API_KEYS=your-issue-api-key
|
||||||
LDAP_URL=ldap://localhost:3890
|
# NEXT_PUBLIC_ 前缀:构建时内嵌到客户端 JS,云上必须通过 deploy-ai.sh 设置
|
||||||
LDAP_BASE_DN=dc=tlyq,dc=ai
|
# 本地开发:http://localhost:6177
|
||||||
LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
|
# 云上生产:https://assets.tlyq.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
|
NEXT_PUBLIC_ASSETS_URL=http://localhost:6177
|
||||||
|
NODE_ENV=development
|
||||||
|
|
|
||||||
|
|
@ -16,5 +16,3 @@ docs/
|
||||||
db-backups/
|
db-backups/
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.env.local
|
|
||||||
.DS_Store
|
|
||||||
|
|
|
||||||
44
CHANGELOG.md
44
CHANGELOG.md
|
|
@ -1,49 +1,5 @@
|
||||||
# 变更日志
|
# 变更日志
|
||||||
|
|
||||||
## 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=0:Authelia 使用自签名证书
|
|
||||||
- [修复] 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
|
## 2026-06-24
|
||||||
|
|
||||||
- [修复] SQLite 时区根源修复:重建表修改所有时间列 DEFAULT 为 `datetime('now', '+8 hours')`,涉及 7 个表 9 个列
|
- [修复] SQLite 时区根源修复:重建表修改所有时间列 DEFAULT 为 `datetime('now', '+8 hours')`,涉及 7 个表 9 个列
|
||||||
|
|
|
||||||
14
CLAUDE.md
14
CLAUDE.md
|
|
@ -55,8 +55,6 @@ npm run import # 导入工单
|
||||||
| `src/lib/monitor/mail-monitor.ts` | 邮件监控:IMAP 连接、邮件过滤、解析 |
|
| `src/lib/monitor/mail-monitor.ts` | 邮件监控:IMAP 连接、邮件过滤、解析 |
|
||||||
| `src/lib/monitor/ticket-processor.ts` | 邮件监控:工单创建、故障记录 |
|
| `src/lib/monitor/ticket-processor.ts` | 邮件监控:工单创建、故障记录 |
|
||||||
| `src/lib/monitor/wechat-pusher.ts` | 邮件监控:微信 webhook 推送 |
|
| `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/availability-engine.ts` | 邮件监控:可用性档位计算 |
|
||||||
| `src/lib/monitor/reminder-engine.ts` | 邮件监控:OEM 诊断/维修提醒 |
|
| `src/lib/monitor/reminder-engine.ts` | 邮件监控:OEM 诊断/维修提醒 |
|
||||||
| `scripts/monitor-worker.ts` | 邮件监控:Worker 独立入口脚本 |
|
| `scripts/monitor-worker.ts` | 邮件监控:Worker 独立入口脚本 |
|
||||||
|
|
@ -128,8 +126,6 @@ npm run import # 导入工单
|
||||||
| POST | `/api/auth/logout` | 登出(清除两个 cookie) |
|
| POST | `/api/auth/logout` | 登出(清除两个 cookie) |
|
||||||
| GET | `/api/auth/me` | 当前用户信息 |
|
| GET | `/api/auth/me` | 当前用户信息 |
|
||||||
| GET | `/api/internal/roles` | 内部 API:返回角色列表(x-internal-key 鉴权) |
|
| GET | `/api/internal/roles` | 内部 API:返回角色列表(x-internal-key 鉴权) |
|
||||||
| GET | `/api/internal/users` | 内部 API:返回用户列表(x-internal-key 鉴权) |
|
|
||||||
| POST | `/api/internal/users` | 内部 API:OA 同步用户角色(x-internal-key 鉴权) |
|
|
||||||
|
|
||||||
### 工单
|
### 工单
|
||||||
|
|
||||||
|
|
@ -169,7 +165,6 @@ npm run import # 导入工单
|
||||||
| PUT | `/api/monitor/settings` | 更新配置(支持分模块保存) |
|
| PUT | `/api/monitor/settings` | 更新配置(支持分模块保存) |
|
||||||
| POST | `/api/monitor/test-mail` | 测试 IMAP 连接 |
|
| POST | `/api/monitor/test-mail` | 测试 IMAP 连接 |
|
||||||
| POST | `/api/monitor/test-wechat` | 测试微信 webhook |
|
| POST | `/api/monitor/test-wechat` | 测试微信 webhook |
|
||||||
| 前端页面 | `/settings/wechat` | webhook 独立管理页(微信推送配置) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -194,11 +189,6 @@ npm run import # 导入工单
|
||||||
| `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` 示例
|
||||||
|
|
||||||
|
|
@ -211,10 +201,6 @@ 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:6177
|
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ 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 ./
|
||||||
COPY --from=builder /app/.next/standalone ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -10,13 +10,12 @@ services:
|
||||||
- issue-reports:/app/reports
|
- issue-reports:/app/reports
|
||||||
# .next 目录从主机挂载,npm run build 后直接生效,无需重建镜像
|
# .next 目录从主机挂载,npm run build 后直接生效,无需重建镜像
|
||||||
- ./.next:/app/.next
|
- ./.next:/app/.next
|
||||||
# 运行时从 LLDAP 容器动态读取 admin 密码(已迁移至环境变量注入)
|
# 运行时从 LLDAP 容器动态读取 admin 密码
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_PATH=/app/data/issue.db
|
- DATABASE_PATH=/app/data/issue.db
|
||||||
- JWT_SECRET=${JWT_SECRET:-oa-shared-jwt-secret-tlyq-2026}
|
- 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}
|
||||||
|
|
@ -27,17 +26,6 @@ services:
|
||||||
- LDAP_BASE_DN=dc=tlyq,dc=ai
|
- LDAP_BASE_DN=dc=tlyq,dc=ai
|
||||||
- LDAP_ADMIN_DN=uid=admin,ou=people,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
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# entrypoint.sh — 同时启动 Next.js Server 和 Monitor Worker
|
# entrypoint.sh — 同时启动 Next.js Server 和 Monitor Worker
|
||||||
|
|
||||||
HOSTNAME=0.0.0.0 node /app/server.js &
|
node /app/server.js &
|
||||||
SERVER_PID=$!
|
SERVER_PID=$!
|
||||||
|
|
||||||
cd /app && node scripts/monitor-worker.js &
|
cd /app && node scripts/monitor-worker.js &
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
"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",
|
"imapflow": "^1.4.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
|
@ -23,7 +22,6 @@
|
||||||
"mailparser": "^3.9.11",
|
"mailparser": "^3.9.11",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
"node-cron": "^4.5.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",
|
||||||
|
|
@ -41,7 +39,6 @@
|
||||||
"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"
|
||||||
}
|
}
|
||||||
|
|
@ -1426,44 +1423,6 @@
|
||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
|
||||||
"version": "2.1.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
|
||||||
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@nodelib/fs.stat": "2.0.5",
|
|
||||||
"run-parallel": "^1.1.9"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nodelib/fs.stat": {
|
|
||||||
"version": "2.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
|
||||||
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nodelib/fs.walk": {
|
|
||||||
"version": "1.2.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
|
||||||
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@nodelib/fs.scandir": "2.1.5",
|
|
||||||
"fastq": "^1.6.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@pinojs/redact": {
|
"node_modules/@pinojs/redact": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||||
|
|
@ -2084,36 +2043,12 @@
|
||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/anymatch": {
|
|
||||||
"version": "3.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
|
||||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"normalize-path": "^3.0.0",
|
|
||||||
"picomatch": "^2.0.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/argparse": {
|
"node_modules/argparse": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/array-union": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ast-types": {
|
"node_modules/ast-types": {
|
||||||
"version": "0.13.4",
|
"version": "0.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
|
||||||
|
|
@ -2296,19 +2231,6 @@
|
||||||
"prebuild-install": "^7.1.1"
|
"prebuild-install": "^7.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/binary-extensions": {
|
|
||||||
"version": "2.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
|
||||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bindings": {
|
"node_modules/bindings": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||||
|
|
@ -2362,19 +2284,6 @@
|
||||||
"node": "18 || 20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/braces": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"fill-range": "^7.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/buffer": {
|
"node_modules/buffer": {
|
||||||
"version": "5.7.1",
|
"version": "5.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||||
|
|
@ -2498,44 +2407,6 @@
|
||||||
"url": "https://github.com/sponsors/fb55"
|
"url": "https://github.com/sponsors/fb55"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/chokidar": {
|
|
||||||
"version": "3.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
|
||||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"anymatch": "~3.1.2",
|
|
||||||
"braces": "~3.0.2",
|
|
||||||
"glob-parent": "~5.1.2",
|
|
||||||
"is-binary-path": "~2.1.0",
|
|
||||||
"is-glob": "~4.0.1",
|
|
||||||
"normalize-path": "~3.0.0",
|
|
||||||
"readdirp": "~3.6.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 8.10.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"fsevents": "~2.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/chokidar/node_modules/glob-parent": {
|
|
||||||
"version": "5.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
|
||||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"is-glob": "^4.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/chownr": {
|
"node_modules/chownr": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||||
|
|
@ -2611,16 +2482,6 @@
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/commander": {
|
|
||||||
"version": "9.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
|
|
||||||
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "^12.20.0 || >=14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cookie": {
|
"node_modules/cookie": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
|
@ -2949,19 +2810,6 @@
|
||||||
"integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==",
|
"integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/dir-glob": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"path-type": "^4.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/docx": {
|
"node_modules/docx": {
|
||||||
"version": "9.6.1",
|
"version": "9.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz",
|
||||||
|
|
@ -3479,36 +3327,6 @@
|
||||||
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
|
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-glob": {
|
|
||||||
"version": "3.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
|
||||||
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@nodelib/fs.stat": "^2.0.2",
|
|
||||||
"@nodelib/fs.walk": "^1.2.3",
|
|
||||||
"glob-parent": "^5.1.2",
|
|
||||||
"merge2": "^1.3.0",
|
|
||||||
"micromatch": "^4.0.8"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.6.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fast-glob/node_modules/glob-parent": {
|
|
||||||
"version": "5.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
|
||||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"is-glob": "^4.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fast-json-stable-stringify": {
|
"node_modules/fast-json-stable-stringify": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||||
|
|
@ -3523,16 +3341,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fastq": {
|
|
||||||
"version": "1.20.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
|
||||||
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"reusify": "^1.0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fd-slicer": {
|
"node_modules/fd-slicer": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||||
|
|
@ -3561,19 +3369,6 @@
|
||||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fill-range": {
|
|
||||||
"version": "7.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
|
||||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"to-regex-range": "^5.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/find-up": {
|
"node_modules/find-up": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||||
|
|
@ -3712,27 +3507,6 @@
|
||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/globby": {
|
|
||||||
"version": "11.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
|
|
||||||
"integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"array-union": "^2.1.0",
|
|
||||||
"dir-glob": "^3.0.1",
|
|
||||||
"fast-glob": "^3.2.9",
|
|
||||||
"ignore": "^5.2.0",
|
|
||||||
"merge2": "^1.4.1",
|
|
||||||
"slash": "^3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/graceful-fs": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
|
|
@ -3877,71 +3651,6 @@
|
||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/imap": {
|
|
||||||
"version": "0.8.19",
|
|
||||||
"resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz",
|
|
||||||
"integrity": "sha512-z5DxEA1uRnZG73UcPA4ES5NSCGnPuuouUx43OPX7KZx1yzq3N8/vx2mtXEShT5inxB3pRgnfG1hijfu7XN2YMw==",
|
|
||||||
"dependencies": {
|
|
||||||
"readable-stream": "1.1.x",
|
|
||||||
"utf7": ">=1.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/imap-simple": {
|
|
||||||
"version": "5.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/imap-simple/-/imap-simple-5.1.0.tgz",
|
|
||||||
"integrity": "sha512-FLZm1v38C5ekN46l/9X5gBRNMQNVc5TSLYQ3Hsq3xBLvKwt1i5fcuShyth8MYMPuvId1R46oaPNrH92hFGHr/g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"iconv-lite": "~0.4.13",
|
|
||||||
"imap": "^0.8.18",
|
|
||||||
"nodeify": "^1.0.0",
|
|
||||||
"quoted-printable": "^1.0.0",
|
|
||||||
"utf8": "^2.1.1",
|
|
||||||
"uuencode": "0.0.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/imap-simple/node_modules/iconv-lite": {
|
|
||||||
"version": "0.4.24",
|
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
|
||||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safer-buffer": ">= 2.1.2 < 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/imap/node_modules/isarray": {
|
|
||||||
"version": "0.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
|
|
||||||
"integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/imap/node_modules/readable-stream": {
|
|
||||||
"version": "1.1.14",
|
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
|
|
||||||
"integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"core-util-is": "~1.0.0",
|
|
||||||
"inherits": "~2.0.1",
|
|
||||||
"isarray": "0.0.1",
|
|
||||||
"string_decoder": "~0.10.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/imap/node_modules/string_decoder": {
|
|
||||||
"version": "0.10.31",
|
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
|
|
||||||
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/imapflow": {
|
"node_modules/imapflow": {
|
||||||
"version": "1.4.2",
|
"version": "1.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.4.2.tgz",
|
||||||
|
|
@ -4043,19 +3752,6 @@
|
||||||
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
|
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/is-binary-path": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"binary-extensions": "^2.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-extglob": {
|
"node_modules/is-extglob": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
|
|
@ -4088,22 +3784,6 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-number": {
|
|
||||||
"version": "7.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
|
||||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-promise": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-mjWH5XxnhMA8cFnDchr6qRP9S/kLntKuEfIYku+PaN1CnS8v+OG9O/BKpRCVRJvpIkgAZm0Pf5Is3iSSOILlcg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/isarray": {
|
"node_modules/isarray": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
|
@ -4127,15 +3807,6 @@
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jose": {
|
|
||||||
"version": "4.15.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
|
||||||
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/panva"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
|
@ -4766,30 +4437,6 @@
|
||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/merge2": {
|
|
||||||
"version": "1.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
|
||||||
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/micromatch": {
|
|
||||||
"version": "4.0.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
|
||||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"braces": "^3.0.3",
|
|
||||||
"picomatch": "^2.3.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mimic-response": {
|
"node_modules/mimic-response": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||||
|
|
@ -4851,20 +4498,6 @@
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/mylas": {
|
|
||||||
"version": "2.1.14",
|
|
||||||
"resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.14.tgz",
|
|
||||||
"integrity": "sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/raouldeheer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "5.1.9",
|
"version": "5.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.9.tgz",
|
||||||
|
|
@ -5024,16 +4657,6 @@
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodeify": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/nodeify/-/nodeify-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-n7C2NyEze8GCo/z73KdbjRsBiLbv6eBn1FxwYKQ23IqGo7pQY3mhQan61Sv7eEDJCiyUjTVrVkXTzJCo1dW7Aw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"is-promise": "~1.0.0",
|
|
||||||
"promise": "~1.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "9.0.1",
|
"version": "9.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
|
||||||
|
|
@ -5043,16 +4666,6 @@
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/normalize-path": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nth-check": {
|
"node_modules/nth-check": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||||
|
|
@ -5074,24 +4687,6 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/object-hash": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/oidc-token-hash": {
|
|
||||||
"version": "5.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
|
|
||||||
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "^10.13.0 || >=12.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/on-exit-leak-free": {
|
"node_modules/on-exit-leak-free": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||||
|
|
@ -5110,33 +4705,6 @@
|
||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/openid-client": {
|
|
||||||
"version": "5.7.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
|
||||||
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"jose": "^4.15.9",
|
|
||||||
"lru-cache": "^6.0.0",
|
|
||||||
"object-hash": "^2.2.0",
|
|
||||||
"oidc-token-hash": "^5.0.3"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/panva"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/openid-client/node_modules/lru-cache": {
|
|
||||||
"version": "6.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"yallist": "^4.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
|
|
@ -5337,16 +4905,6 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path-type": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/peberminta": {
|
"node_modules/peberminta": {
|
||||||
"version": "0.10.0",
|
"version": "0.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz",
|
||||||
|
|
@ -5368,19 +4926,6 @@
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
|
||||||
"version": "2.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
|
||||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pino": {
|
"node_modules/pino": {
|
||||||
"version": "10.3.1",
|
"version": "10.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
||||||
|
|
@ -5418,19 +4963,6 @@
|
||||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/plimit-lit": {
|
|
||||||
"version": "1.6.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz",
|
|
||||||
"integrity": "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"queue-lit": "^1.5.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.10",
|
"version": "8.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||||
|
|
@ -5547,15 +5079,6 @@
|
||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/promise": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/promise/-/promise-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-R9WrbTF3EPkVtWjp7B7umQGVndpsi+rsDAfrR4xAALQpFLa/+2OriecLhawxzvii2gd9+DZFwROWDuUUaqS5yA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"is-promise": "~1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
|
|
@ -5666,55 +5189,12 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/queue-lit": {
|
|
||||||
"version": "1.5.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz",
|
|
||||||
"integrity": "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/queue-microtask": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/quick-format-unescaped": {
|
"node_modules/quick-format-unescaped": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/quoted-printable": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/quoted-printable/-/quoted-printable-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-cihC68OcGiQOjGiXuo5Jk6XHANTHl1K4JLk/xlEJRTIXfy19Sg6XzB95XonYgr+1rB88bCpr7WZE7D7AlZow4g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"utf8": "^2.1.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"quoted-printable": "bin/quoted-printable"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/rc": {
|
"node_modules/rc": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||||
|
|
@ -5809,19 +5289,6 @@
|
||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/readdirp": {
|
|
||||||
"version": "3.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
|
||||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"picomatch": "^2.2.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/real-require": {
|
"node_modules/real-require": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||||
|
|
@ -5891,41 +5358,6 @@
|
||||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/reusify": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"iojs": ">=1.0.0",
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/run-parallel": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
|
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"queue-microtask": "^1.2.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
|
@ -6119,16 +5551,6 @@
|
||||||
"simple-concat": "^1.0.0"
|
"simple-concat": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/slash": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/smart-buffer": {
|
"node_modules/smart-buffer": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||||
|
|
@ -6426,41 +5848,6 @@
|
||||||
"tlds": "bin.js"
|
"tlds": "bin.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/to-regex-range": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"is-number": "^7.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tsc-alias": {
|
|
||||||
"version": "1.8.17",
|
|
||||||
"resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.8.17.tgz",
|
|
||||||
"integrity": "sha512-EIduCZHqbNwPm8BZYfq1aD7BQ697A4h6uSGMOFQfYGoQwfrYFTKwYfy9Bv42YxHkduVBcn9Zx0DkX111DKskyg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"chokidar": "^3.5.3",
|
|
||||||
"commander": "^9.0.0",
|
|
||||||
"get-tsconfig": "^4.10.0",
|
|
||||||
"globby": "^11.0.4",
|
|
||||||
"mylas": "^2.1.9",
|
|
||||||
"normalize-path": "^3.0.0",
|
|
||||||
"plimit-lit": "^1.2.6"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"tsc-alias": "dist/bin/index.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.20.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
|
@ -6574,40 +5961,12 @@
|
||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/utf7": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/utf7/-/utf7-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-qQrPtYLLLl12NF4DrM9CvfkxkYI97xOb5dsnGZHE3teFr0tWiEZ9UdgMPczv24vl708cYMpe6mGXGHrotIp3Bw==",
|
|
||||||
"dependencies": {
|
|
||||||
"semver": "~5.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/utf7/node_modules/semver": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-mfmm3/H9+67MCVix1h+IXTpDwL6710LyHuk7+cWC9T1mE0qz4iHhh6r4hU2wrIT9iTsAAC2XQRvfblL028cpLw==",
|
|
||||||
"license": "ISC",
|
|
||||||
"bin": {
|
|
||||||
"semver": "bin/semver"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/utf8": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/utf8/-/utf8-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-QXo+O/QkLP/x1nyi54uQiG0XrODxdysuQvE5dtVqv7F5K2Qb6FsN+qbr6KhF5wQ20tfcV3VQp0/2x1e1MRSPWg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/uuencode": {
|
|
||||||
"version": "0.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/uuencode/-/uuencode-0.0.4.tgz",
|
|
||||||
"integrity": "sha512-yEEhCuCi5wRV7Z5ZVf9iV2gWMvUZqKJhAs1ecFdKJ0qzbyaVelmsE3QjYAamehfp9FKLiZbKldd+jklG3O0LfA=="
|
|
||||||
},
|
|
||||||
"node_modules/victory-vendor": {
|
"node_modules/victory-vendor": {
|
||||||
"version": "36.9.2",
|
"version": "36.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||||
|
|
@ -6788,12 +6147,6 @@
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/yallist": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/yargs": {
|
"node_modules/yargs": {
|
||||||
"version": "17.7.2",
|
"version": "17.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
"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",
|
"imapflow": "^1.4.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
|
@ -26,7 +25,6 @@
|
||||||
"mailparser": "^3.9.11",
|
"mailparser": "^3.9.11",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
"node-cron": "^4.5.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",
|
||||||
|
|
|
||||||
|
|
@ -65,8 +65,8 @@ function getLastWeekDates() {
|
||||||
const lastSunday = new Date(lastMonday)
|
const lastSunday = new Date(lastMonday)
|
||||||
lastSunday.setDate(lastMonday.getDate() + 6)
|
lastSunday.setDate(lastMonday.getDate() + 6)
|
||||||
return {
|
return {
|
||||||
start: `${lastMonday.getFullYear()}-${pad(lastMonday.getMonth()+1)}-${pad(lastMonday.getDate())}`,
|
start: lastMonday.toISOString().split('T')[0],
|
||||||
end: `${lastSunday.getFullYear()}-${pad(lastSunday.getMonth()+1)}-${pad(lastSunday.getDate())}`,
|
end: lastSunday.toISOString().split('T')[0],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -208,7 +208,7 @@ export default function ReportsPage() {
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
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`
|
a.download = `reports_${new Date().toISOString().slice(0, 10)}.zip`
|
||||||
document.body.appendChild(a)
|
document.body.appendChild(a)
|
||||||
a.click()
|
a.click()
|
||||||
document.body.removeChild(a)
|
document.body.removeChild(a)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { Card } from '@/components/ui'
|
import { Card } from '@/components/ui'
|
||||||
import { Search, RefreshCw } from 'lucide-react'
|
|
||||||
import { useDirtyTracker } from '@shared/ui/hooks/useDirtyTracker'
|
|
||||||
|
|
||||||
interface MonitorStatus {
|
interface MonitorStatus {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
|
@ -20,321 +18,67 @@ interface MonitorStatus {
|
||||||
|
|
||||||
interface MonitorConfig {
|
interface MonitorConfig {
|
||||||
monitor: { enabled: boolean; interval_seconds: number; push_delay_ms: number; silent_start_hour: number; silent_end_hour: number }
|
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 }
|
mail: { address: string; imap_server: string; imap_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 } }
|
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 } }
|
||||||
|
wechat: { webhook_url: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScanState {
|
type SectionKey = 'mail' | 'filter' | 'wechat' | 'monitor'
|
||||||
status: 'idle' | 'running' | 'completed' | 'error' | 'cancelling'
|
|
||||||
startedAt?: string
|
// 深比较两个对象,返回变化的字段路径
|
||||||
completedAt?: string
|
function getChangedFields(original: unknown, current: unknown, prefix = ''): string[] {
|
||||||
timeRange?: { value: number | null; unit: string }
|
const changes: string[] = []
|
||||||
stats?: {
|
if (original === current) return changes
|
||||||
total: number
|
if (typeof original !== typeof current) { changes.push(prefix); return changes }
|
||||||
matched: number
|
if (typeof original !== 'object' || original === null || current === null) {
|
||||||
imported: number
|
if (original !== current) changes.push(prefix)
|
||||||
skipped: number
|
return changes
|
||||||
errors: number
|
|
||||||
}
|
}
|
||||||
details?: {
|
const orig = original as Record<string, unknown>
|
||||||
msg_id: string
|
const curr = current as Record<string, unknown>
|
||||||
subject: string
|
const allKeys = new Set([...Object.keys(orig), ...Object.keys(curr)])
|
||||||
date: string
|
for (const key of allKeys) {
|
||||||
order_number: string | null
|
const path = prefix ? `${prefix}.${key}` : key
|
||||||
status: 'imported' | 'skipped' | 'error'
|
if (JSON.stringify(orig[key]) !== JSON.stringify(curr[key])) {
|
||||||
ticket_no?: string
|
changes.push(path)
|
||||||
error?: string
|
}
|
||||||
}[]
|
}
|
||||||
detailsTruncated?: boolean
|
return changes
|
||||||
error?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SectionKey = 'mail' | 'filter' | 'monitor'
|
// 检查某个 section 是否有变化
|
||||||
|
function hasSectionChanged(original: MonitorConfig | null, current: MonitorConfig | null, section: SectionKey): boolean {
|
||||||
|
if (!original || !current) return false
|
||||||
|
return JSON.stringify(original[section]) !== JSON.stringify(current[section])
|
||||||
|
}
|
||||||
|
|
||||||
export default function MonitorSettingsPage() {
|
export default function MonitorSettingsPage() {
|
||||||
const [status, setStatus] = useState<MonitorStatus | null>(null)
|
const [status, setStatus] = useState<MonitorStatus | null>(null)
|
||||||
const [config, setConfig] = useState<MonitorConfig | null>(null)
|
const [config, setConfig] = useState<MonitorConfig | null>(null)
|
||||||
|
const [originalConfig, setOriginalConfig] = useState<MonitorConfig | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const { isDirty, markClean } = useDirtyTracker()
|
|
||||||
const [saving, setSaving] = useState<SectionKey | null>(null)
|
const [saving, setSaving] = useState<SectionKey | null>(null)
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||||
const [newKeyword, setNewKeyword] = useState('')
|
const [newKeyword, setNewKeyword] = useState('')
|
||||||
const [expandedPanel, setExpandedPanel] = useState<'today' | 'total' | 'errors' | 'ongoing' | null>(null)
|
const [expandedPanel, setExpandedPanel] = useState<'today' | 'total' | 'errors' | 'ongoing' | null>(null)
|
||||||
const [sectionMessage, setSectionMessage] = useState<Partial<Record<SectionKey, { type: 'success' | 'error'; text: string }>>>({})
|
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(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetch('/api/monitor/status').then(r => r.json()),
|
fetch('/api/monitor/status').then(r => r.json()),
|
||||||
fetch('/api/monitor/settings').then(r => r.json()),
|
fetch('/api/monitor/settings').then(r => r.json()),
|
||||||
fetch('/api/monitor/scan-status').then(r => r.json()),
|
]).then(([s, c]) => {
|
||||||
fetch('/api/monitor/scan-history?page=1&limit=10').then(r => r.json()),
|
|
||||||
]).then(([s, c, scan, history]) => {
|
|
||||||
setStatus(s)
|
setStatus(s)
|
||||||
setConfig(c)
|
setConfig(c)
|
||||||
markClean('mail', { mail: c.mail })
|
setOriginalConfig(JSON.parse(JSON.stringify(c)))
|
||||||
markClean('filter', { filter: c.filter })
|
|
||||||
markClean('monitor', { monitor: c.monitor })
|
|
||||||
setScanState(scan)
|
|
||||||
setScanHistory(history)
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}).catch(() => setLoading(false))
|
}).catch(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 立即检查处理
|
const refreshStatus = () => {
|
||||||
const handleTrigger = async () => {
|
fetch('/api/monitor/status').then(r => r.json()).then(setStatus)
|
||||||
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 () => {
|
const handleStart = async () => {
|
||||||
await fetch('/api/monitor/start', { method: 'POST' })
|
await fetch('/api/monitor/start', { method: 'POST' })
|
||||||
refreshStatus()
|
refreshStatus()
|
||||||
|
|
@ -348,6 +92,17 @@ export default function MonitorSettingsPage() {
|
||||||
setMessage({ type: 'success', text: '监控已停用' })
|
setMessage({ type: 'success', text: '监控已停用' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleTrigger = async () => {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleTestMail = async () => {
|
const handleTestMail = async () => {
|
||||||
setSectionMessage(prev => ({ ...prev, mail: { type: 'success', text: '正在测试邮箱连接...' } }))
|
setSectionMessage(prev => ({ ...prev, mail: { type: 'success', text: '正在测试邮箱连接...' } }))
|
||||||
const res = await fetch('/api/monitor/test-mail', { method: 'POST' })
|
const res = await fetch('/api/monitor/test-mail', { method: 'POST' })
|
||||||
|
|
@ -355,6 +110,13 @@ export default function MonitorSettingsPage() {
|
||||||
setSectionMessage(prev => ({ ...prev, mail: { type: data.success ? 'success' : 'error', text: data.success ? data.message : data.error } }))
|
setSectionMessage(prev => ({ ...prev, mail: { type: data.success ? 'success' : 'error', text: data.success ? data.message : data.error } }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleTestWechat = async () => {
|
||||||
|
setSectionMessage(prev => ({ ...prev, wechat: { type: 'success', text: '正在测试微信推送...' } }))
|
||||||
|
const res = await fetch('/api/monitor/test-wechat', { method: 'POST' })
|
||||||
|
const data = await res.json()
|
||||||
|
setSectionMessage(prev => ({ ...prev, wechat: { type: data.success ? 'success' : 'error', text: data.success ? data.message : data.error } }))
|
||||||
|
}
|
||||||
|
|
||||||
const handleSectionSave = async (section: SectionKey) => {
|
const handleSectionSave = async (section: SectionKey) => {
|
||||||
if (!config) return
|
if (!config) return
|
||||||
setSaving(section)
|
setSaving(section)
|
||||||
|
|
@ -364,7 +126,7 @@ export default function MonitorSettingsPage() {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setSaving(null)
|
setSaving(null)
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
markClean(section, { [section]: config[section] })
|
setOriginalConfig(prev => prev ? { ...prev, [section]: JSON.parse(JSON.stringify(config[section])) } : prev)
|
||||||
setSectionMessage(prev => ({ ...prev, [section]: { type: 'success', text: '已保存' } }))
|
setSectionMessage(prev => ({ ...prev, [section]: { type: 'success', text: '已保存' } }))
|
||||||
} else {
|
} else {
|
||||||
setSectionMessage(prev => ({ ...prev, [section]: { type: 'error', text: data.error } }))
|
setSectionMessage(prev => ({ ...prev, [section]: { type: 'error', text: data.error } }))
|
||||||
|
|
@ -383,12 +145,20 @@ export default function MonitorSettingsPage() {
|
||||||
setConfig({ ...config, filter: { ...config.filter, subject_keywords: config.filter.subject_keywords.filter((_, i) => i !== index) } })
|
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 isFieldChanged = (path: string): boolean => {
|
||||||
|
if (!originalConfig || !config) return false
|
||||||
|
const changes = getChangedFields(originalConfig, config)
|
||||||
|
return changes.some(c => c === path || c.startsWith(path + '.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输入框样式:高亮修改的字段
|
||||||
|
const inputClass = (path: string) =>
|
||||||
|
`w-full px-3 py-2 border rounded-lg text-sm transition-colors ${isFieldChanged(path) ? 'border-amber-400 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-600' : 'border-slate-300 dark:border-slate-600'}`
|
||||||
|
|
||||||
|
// Section 保存按钮组件(消息反馈由各 section 自行渲染,此处只渲染按钮)
|
||||||
const SectionSaveButton = ({ section }: { section: SectionKey }) => {
|
const SectionSaveButton = ({ section }: { section: SectionKey }) => {
|
||||||
if (!config) return null
|
const changed = hasSectionChanged(originalConfig, config, section)
|
||||||
const changed = isDirty(section, { [section]: config[section] })
|
|
||||||
if (!changed) return null
|
if (!changed) return null
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 flex items-center gap-3">
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
|
@ -431,7 +201,7 @@ export default function MonitorSettingsPage() {
|
||||||
{status?.lastCheck && <span className="text-sm text-slate-500">上次检查: {status.lastCheck}</span>}
|
{status?.lastCheck && <span className="text-sm text-slate-500">上次检查: {status.lastCheck}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 统计指标 */}
|
{/* 统计指标 - 可点击展开 */}
|
||||||
<div className="grid grid-cols-4 gap-4 mb-4">
|
<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: 'today' as const, label: '今日处理', value: status?.stats.todayProcessed || 0, color: 'text-blue-600 dark:text-blue-400' },
|
||||||
|
|
@ -455,6 +225,7 @@ export default function MonitorSettingsPage() {
|
||||||
{/* 展开面板 */}
|
{/* 展开面板 */}
|
||||||
{expandedPanel && (
|
{expandedPanel && (
|
||||||
<div className="mb-4 border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
<div className="mb-4 border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||||
|
{/* 今日处理详情 */}
|
||||||
{expandedPanel === 'today' && status?.details?.todayProcessed && (
|
{expandedPanel === 'today' && status?.details?.todayProcessed && (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
|
@ -478,6 +249,7 @@ export default function MonitorSettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 累计统计详情 */}
|
||||||
{expandedPanel === 'total' && (
|
{expandedPanel === 'total' && (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
|
@ -505,6 +277,7 @@ export default function MonitorSettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 错误详情 */}
|
||||||
{expandedPanel === 'errors' && status?.details?.recentErrors && (
|
{expandedPanel === 'errors' && status?.details?.recentErrors && (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
|
@ -535,6 +308,7 @@ export default function MonitorSettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 进行中故障详情 */}
|
||||||
{expandedPanel === 'ongoing' && status?.details?.ongoingFaults && (
|
{expandedPanel === 'ongoing' && status?.details?.ongoingFaults && (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
|
@ -582,13 +356,7 @@ export default function MonitorSettingsPage() {
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<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>}
|
{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
|
<button onClick={handleTrigger} className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm">立即检查</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -612,14 +380,6 @@ export default function MonitorSettingsPage() {
|
||||||
<label className="block text-sm font-medium mb-1">IMAP 端口</label>
|
<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')} />
|
<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>
|
||||||
<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>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">SMTP 服务器</label>
|
<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')} />
|
<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')} />
|
||||||
|
|
@ -648,7 +408,7 @@ export default function MonitorSettingsPage() {
|
||||||
<label className="block text-sm font-medium mb-1">主题关键词(匹配任一即通过)</label>
|
<label className="block text-sm font-medium mb-1">主题关键词(匹配任一即通过)</label>
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
{config.filter.subject_keywords.map((kw, i) => (
|
{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">
|
<span key={i} className={`inline-flex items-center gap-1 px-2 py-1 rounded text-sm ${isFieldChanged('filter.subject_keywords') ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' : 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'}`}>
|
||||||
{kw}
|
{kw}
|
||||||
<button onClick={() => removeKeyword(i)} className="hover:opacity-70">×</button>
|
<button onClick={() => removeKeyword(i)} className="hover:opacity-70">×</button>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -667,206 +427,29 @@ export default function MonitorSettingsPage() {
|
||||||
<SectionSaveButton section="filter" />
|
<SectionSaveButton section="filter" />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 邮箱工单检测(新模块) */}
|
{/* 微信推送 */}
|
||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<h2 className="text-lg font-semibold mb-4">邮箱工单检测</h2>
|
<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">
|
<div className="space-y-4">
|
||||||
{scanState.error && (
|
<div>
|
||||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg text-sm text-amber-700 dark:text-amber-400">
|
<label className="block text-sm font-medium mb-1">Webhook 地址</label>
|
||||||
{scanState.error}
|
<input type="text" value={config.wechat.webhook_url} onChange={e => setConfig({ ...config, wechat: { ...config.wechat, webhook_url: e.target.value } })} className={inputClass('wechat.webhook_url')} placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." />
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button onClick={handleTestWechat} className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-sm">测试微信推送</button>
|
||||||
onClick={handleImportSkipped}
|
{sectionMessage.wechat && (
|
||||||
disabled={importing}
|
<span className={`text-sm ${sectionMessage.wechat.type === 'success' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||||
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"
|
{sectionMessage.wechat.text}
|
||||||
>
|
|
||||||
{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>
|
</span>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
)}
|
<SectionSaveButton section="wechat" />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 全局运行参数 */}
|
{/* 运行参数 */}
|
||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<h2 className="text-lg font-semibold mb-4">全局运行参数</h2>
|
<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 className="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">检查间隔(秒)</label>
|
<label className="block text-sm font-medium mb-1">检查间隔(秒)</label>
|
||||||
|
|
@ -883,102 +466,6 @@ export default function MonitorSettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
<SectionSaveButton section="monitor" />
|
<SectionSaveButton section="monitor" />
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,7 @@ const allPermissions = [
|
||||||
{ key: 'tickets:create', label: '手动建单' },
|
{ key: 'tickets:create', label: '手动建单' },
|
||||||
{ key: 'tickets:import', label: '导入工单' },
|
{ key: 'tickets:import', label: '导入工单' },
|
||||||
{ key: 'tickets:export', label: '导出工单' },
|
{ key: 'tickets:export', label: '导出工单' },
|
||||||
{ key: 'tickets:edit', label: '处理工单' },
|
{ key: 'tickets:write', label: '编辑/删除工单' },
|
||||||
{ key: 'tickets:delete', label: '删除工单' },
|
|
||||||
{ key: 'reports:read', label: '查看报告' },
|
{ key: 'reports:read', label: '查看报告' },
|
||||||
{ key: 'reports:download', label: '下载报告' },
|
{ key: 'reports:download', label: '下载报告' },
|
||||||
{ key: 'reports:create', label: '新建报告' },
|
{ key: 'reports:create', label: '新建报告' },
|
||||||
|
|
|
||||||
|
|
@ -1,293 +0,0 @@
|
||||||
'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">×</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,47 @@
|
||||||
'use client'
|
'use client'
|
||||||
// 登录页 — 引用共享组件
|
import { useState } from 'react'
|
||||||
import LoginPage from '@shared/ui/login-page'
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function LoginPage() {
|
||||||
return <LoginPage siteName="IT工单跟踪系统" redirectPath="/dashboard" />
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault(); setError(''); setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { setError(data.error || '登录失败'); return }
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const redirect = params.get('redirect')
|
||||||
|
const dest = (redirect && redirect.startsWith('/')) ? redirect : '/dashboard'
|
||||||
|
window.location.href = dest
|
||||||
|
} catch { setError('网络错误') } finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 px-4">
|
||||||
|
<div className="w-full max-w-sm bg-white dark:bg-slate-900 rounded-lg border border-blue-200/50 dark:border-blue-500/20 shadow-lg p-8">
|
||||||
|
<h1 className="text-2xl font-bold text-center mb-6 text-slate-900 dark:text-white">IT 工单跟踪系统</h1>
|
||||||
|
{error && <div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">{error}</div>}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">用户名</label>
|
||||||
|
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="请输入用户名"
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">密码</label>
|
||||||
|
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="请输入密码"
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required />
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={loading}
|
||||||
|
className="w-full py-2 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium transition-colors duration-200">
|
||||||
|
{loading ? '登录中...' : '登录'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +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'
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
async function getSession() {
|
async function getSession() {
|
||||||
const cookieStore = await cookies()
|
const cookieStore = await cookies()
|
||||||
|
|
@ -20,8 +20,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
type ApiKeyRow = { id: number; name: string; permissions: string; expires_at: string | null; is_active: number }
|
const existing = getDb().prepare('SELECT id FROM api_keys WHERE id = ?').get(id)
|
||||||
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 {
|
||||||
|
|
@ -36,20 +35,6 @@ 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 : '更新失败'
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
// GET /api/auth/callback — OIDC callback(V2:使用 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',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
// 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 })
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +1,26 @@
|
||||||
// GET + POST /api/auth/logout — 退出登录(清除 cookie + 302 跳转 /login)
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
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) {
|
export async function POST(request: NextRequest) {
|
||||||
// 从 session cookie 提取用户名用于审计
|
// 先获取当前用户信息(用于审计日志),再清除 cookie
|
||||||
let username = 'anonymous'
|
const user = await getCurrentUser()
|
||||||
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 { /* 解析失败,使用默认值 */ }
|
|
||||||
|
|
||||||
|
const r = NextResponse.json({ success: true })
|
||||||
|
|
||||||
|
if (user) {
|
||||||
writeAuditLog({
|
writeAuditLog({
|
||||||
userId: null,
|
userId: user.id,
|
||||||
username,
|
apiKeyId: null,
|
||||||
action: 'logout',
|
action: 'logout',
|
||||||
entityType: 'auth',
|
entityType: 'auth',
|
||||||
details: { message: '用户登出' },
|
entityId: user.id,
|
||||||
|
details: { username: user.username },
|
||||||
ipAddress: getClientIP(request),
|
ipAddress: getClientIP(request),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
return logoutResponse()
|
|
||||||
|
r.cookies.set('session_issue', '', { maxAge: 0, path: '/' })
|
||||||
|
r.cookies.set('tlyq_session', '', { maxAge: 0, path: '/' })
|
||||||
|
return r
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
import { NextResponse } from 'next/server'
|
|
||||||
export async function GET() { return NextResponse.json({ status: 'OK' }) }
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
// 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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
// 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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,648 +0,0 @@
|
||||||
// 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 返回 UID(imap.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-ID(BODY 为空时 ENVELOPE 仍可用)
|
|
||||||
// 用于与 POP3 邮件精确匹配,避免脆弱的位置对齐
|
|
||||||
// 注意:只请求 envelope,不能带 bodies(BODY 为空时 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 → 原始邮件 Buffer;pop3Attempted: 是否已尝试(无论成败,避免重连风暴)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
// 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} 天前的扫描历史`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
// 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: '取消请求已发送,扫描将在处理完当前邮件后停止' })
|
|
||||||
}
|
|
||||||
|
|
@ -5,39 +5,6 @@ import { getCurrentUser } from '@/lib/auth'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { getMaskedConfig, updateMonitorConfig } from '@/lib/monitor/settings-manager'
|
import { getMaskedConfig, updateMonitorConfig } from '@/lib/monitor/settings-manager'
|
||||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
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> = {
|
const VALIDATORS: Record<string, (v: unknown) => boolean> = {
|
||||||
'monitor.enabled': (v) => typeof v === 'boolean',
|
'monitor.enabled': (v) => typeof v === 'boolean',
|
||||||
|
|
@ -46,13 +13,9 @@ const VALIDATORS: Record<string, (v: unknown) => boolean> = {
|
||||||
'monitor.silent_start_hour': (v) => typeof v === 'number' && v >= 0 && v <= 23,
|
'monitor.silent_start_hour': (v) => typeof v === 'number' && v >= 0 && v <= 23,
|
||||||
'monitor.silent_end_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.address': (v) => typeof v === 'string' && v.includes('@'),
|
||||||
'mail.imap_server': isValidHost,
|
|
||||||
'mail.imap_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
|
'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,
|
'mail.smtp_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
|
||||||
'wechat.webhooks': (v) => Array.isArray(v) && v.length > 0,
|
'wechat.webhook_url': (v) => typeof v === 'string' && (v === '' || v.startsWith('https://qyapi.weixin.qq.com/')),
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
|
|
||||||
|
|
@ -22,18 +22,7 @@ export async function GET(request: NextRequest) {
|
||||||
const todayStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} 00:00:00`
|
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 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
|
const totalProcessed = (db.prepare("SELECT COUNT(*) as c FROM monitor_logs WHERE action = 'ticket_created'").get() as { c: number }).c
|
||||||
// 最近一次 tick 的错误状态:action='error' → 1;tick_complete → 该 tick 的 errors 数;无记录 → 0
|
const errorCount = (db.prepare("SELECT COUNT(*) as c FROM monitor_logs WHERE action = 'error'").get() as { c: number }).c
|
||||||
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
|
const ongoingFaultsCount = (db.prepare("SELECT COUNT(*) as c FROM fault_records WHERE status = 'ongoing'").get() as { c: number }).c
|
||||||
|
|
||||||
// 获取最近的活动(tick_complete 或 error)
|
// 获取最近的活动(tick_complete 或 error)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ 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 { getMonitorConfig } from '@/lib/monitor/settings-manager'
|
import { getMonitorConfig } from '@/lib/monitor/settings-manager'
|
||||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
|
||||||
import { ImapFlow } from 'imapflow'
|
import { ImapFlow } from 'imapflow'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
|
@ -22,10 +21,8 @@ export async function POST(request: NextRequest) {
|
||||||
const lock = await client.getMailboxLock('INBOX')
|
const lock = await client.getMailboxLock('INBOX')
|
||||||
lock.release()
|
lock.release()
|
||||||
await client.logout()
|
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 选择正常' })
|
return NextResponse.json({ success: true, message: 'IMAP 连接成功,INBOX 选择正常' })
|
||||||
} catch (e) {
|
} 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 : '连接失败' })
|
return NextResponse.json({ success: false, error: e instanceof Error ? e.message : '连接失败' })
|
||||||
} finally {
|
} finally {
|
||||||
try { await client.logout() } catch {}
|
try { await client.logout() } catch {}
|
||||||
|
|
|
||||||
|
|
@ -10,36 +10,10 @@ export async function POST(request: NextRequest) {
|
||||||
initDatabase()
|
initDatabase()
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
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 config = getMonitorConfig()
|
||||||
const found = config.wechat.webhooks.find(wh => {
|
if (!config.wechat.webhook_url) return NextResponse.json({ success: false, error: 'Webhook URL 未配置' })
|
||||||
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 {
|
try {
|
||||||
const response = await fetch(webhookUrl, {
|
const response = await fetch(config.wechat.webhook_url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ msgtype: 'text', text: { content: '✅ issue-ai 邮件监控测试消息' } }),
|
body: JSON.stringify({ msgtype: 'text', text: { content: '✅ issue-ai 邮件监控测试消息' } }),
|
||||||
|
|
@ -50,10 +24,8 @@ export async function POST(request: NextRequest) {
|
||||||
writeAuditLog({ userId: user.id, action: 'test', entityType: 'monitor_wechat', details: { message: '测试微信推送成功' }, ipAddress: getClientIP(request) })
|
writeAuditLog({ userId: user.id, action: 'test', entityType: 'monitor_wechat', details: { message: '测试微信推送成功' }, ipAddress: getClientIP(request) })
|
||||||
return NextResponse.json({ success: true, message: '测试消息发送成功' })
|
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 || '发送失败' })
|
return NextResponse.json({ success: false, error: result.errmsg || '发送失败' })
|
||||||
} catch (e) {
|
} 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 : '发送失败' })
|
return NextResponse.json({ success: false, error: e instanceof Error ? e.message : '发送失败' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ 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 fs from 'fs'
|
import fs from 'fs'
|
||||||
|
|
||||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
|
@ -90,16 +89,6 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
|
||||||
// 删除数据库记录
|
// 删除数据库记录
|
||||||
db.prepare('DELETE FROM reports WHERE id = ?').run(id)
|
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 })
|
return NextResponse.json({ success: true })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '删除失败'
|
const msg = e instanceof Error ? e.message : '删除失败'
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ 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 JSZip from 'jszip'
|
import JSZip from 'jszip'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
|
|
||||||
|
|
@ -44,19 +43,7 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' })
|
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' })
|
||||||
|
|
||||||
writeAuditLog({
|
const downloadName = `reports_${new Date().toISOString().slice(0, 10)}.zip`
|
||||||
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)
|
const encodedName = encodeURIComponent(downloadName)
|
||||||
|
|
||||||
return new NextResponse(new Uint8Array(zipBuffer), {
|
return new NextResponse(new Uint8Array(zipBuffer), {
|
||||||
|
|
|
||||||
|
|
@ -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 d = new Date(); const thisMonth = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`
|
const thisMonth = new Date().toISOString().slice(0, 7)
|
||||||
const monthTotal = (db.prepare("SELECT COUNT(*) as c FROM tickets WHERE assign_time LIKE ?").get(`${thisMonth}%`) as any).c
|
const 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
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@ 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 { 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 {
|
||||||
|
|
@ -44,7 +42,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:edit')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
|
@ -124,17 +122,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 结单推送:状态从非终态 → 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 : '更新失败'
|
||||||
|
|
@ -147,7 +134,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:delete')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,13 @@ 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 { 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:edit')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
if (!hasPermission(user, 'tickets:write')) 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 || []
|
||||||
|
|
@ -27,11 +25,6 @@ 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[] = []
|
||||||
|
|
||||||
|
|
@ -49,17 +42,6 @@ export async function PUT(request: NextRequest) {
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,12 +58,10 @@ export async function GET(request: NextRequest) {
|
||||||
ipAddress: getClientIP(request),
|
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_${exportDate}.xlsx"`,
|
'Content-Disposition': `attachment; filename="tickets_${new Date().toISOString().slice(0, 10)}.xlsx"`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ 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 { 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 || ''
|
||||||
|
|
@ -98,16 +97,6 @@ export async function POST(request: NextRequest) {
|
||||||
ipAddress: getClientIP(request)
|
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 : '创建失败'
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ 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 { 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)) {
|
||||||
|
|
@ -136,11 +135,6 @@ export async function POST(request: NextRequest) {
|
||||||
ipAddress: getClientIP(request),
|
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,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ 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 { 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 {
|
||||||
|
|
@ -168,18 +167,6 @@ export async function POST(request: NextRequest) {
|
||||||
})
|
})
|
||||||
|
|
||||||
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 : '创建失败'
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@config "../../tailwind.config.js";
|
||||||
@source "../../shared";
|
|
||||||
@source "../../src";
|
|
||||||
|
|
||||||
@import './tlyq-design-system.css' layer(base);
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-body);
|
background-color: #f8fafc;
|
||||||
color: var(--fg);
|
color: #0f172a;
|
||||||
background-color: var(--bg);
|
}
|
||||||
|
html.dark body {
|
||||||
|
background-color: #020617;
|
||||||
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,362 +0,0 @@
|
||||||
/*
|
|
||||||
* 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 通过)
|
|
||||||
*/
|
|
||||||
|
|
@ -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 role={user?.role} />
|
<Sidebar />
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -1,81 +1,78 @@
|
||||||
'use client'
|
'use client'
|
||||||
// src/components/layout/Sidebar.tsx — 权限驱动侧边栏(统一标准布局)
|
import { useState, useEffect } from 'react'
|
||||||
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, Mail, Bell } from 'lucide-react'
|
import { LayoutDashboard, FileText, Settings, Users, Shield, Key, Clock, CheckCircle, PlusSquare, Upload, List, Mail } from 'lucide-react'
|
||||||
|
|
||||||
interface NavItem { label: string; href: string; icon: React.ComponentType<{ size?: number }>; perm: string | null }
|
const navItems = [
|
||||||
interface NavSection { title?: string; items: NavItem[] }
|
{ href: '/dashboard', label: '仪表盘', icon: LayoutDashboard, perm: null },
|
||||||
|
{ href: '/tickets/pending', label: '待办工单', icon: Clock, perm: 'tickets:read' },
|
||||||
export default function Sidebar({ role }: { role?: string }) {
|
{ href: '/tickets/completed', label: '已办工单', icon: CheckCircle, perm: 'tickets:read' },
|
||||||
const pathname = usePathname()
|
{ href: '/tickets/create', label: '手动建单', icon: PlusSquare, perm: 'tickets:create' },
|
||||||
const isActive = (href: string) => pathname === href || (href !== '/' && pathname.startsWith(href))
|
{ href: '/tickets/import', label: '导入工单', icon: Upload, perm: 'tickets:import' },
|
||||||
const isAdmin = role === 'admin' || role === 'localadmin'
|
{ href: '/reports', label: '报告管理', icon: FileText, perm: 'reports:read' },
|
||||||
|
|
||||||
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' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
const settingsItems = [
|
||||||
<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">
|
{ href: '/settings/users', label: '用户管理', icon: Users, perm: 'users:read' },
|
||||||
{/* 品牌区 */}
|
{ href: '/settings/roles', label: '角色权限', icon: Shield, perm: 'roles:read' },
|
||||||
<div className="h-14 flex items-center px-6 border-b border-slate-200 dark:border-slate-800 shrink-0">
|
{ href: '/settings/api-keys', label: 'API Key', icon: Key, perm: 'api-keys:read' },
|
||||||
<Link href="/dashboard" className="flex items-center gap-3">
|
{ href: '/settings/audit-logs', label: '审计日志', icon: FileText, perm: 'audit-logs:read' },
|
||||||
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center text-white font-bold text-sm">工</div>
|
{ href: '/settings/monitor', label: '邮件监控', icon: Mail, perm: 'monitor:read' },
|
||||||
<span className="font-semibold text-slate-900 dark:text-white">IT工单跟踪系统</span>
|
]
|
||||||
</Link>
|
|
||||||
</div>
|
function hasAnyAdminPerm(permissions: string[]): boolean {
|
||||||
|
return permissions.includes('*') || permissions.some(p => p.startsWith('users:') || p.startsWith('roles:') || p.startsWith('api-keys:'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
const pathname = usePathname()
|
||||||
|
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 canSee = (perm: string | null) => {
|
||||||
|
if (perm === null) return true
|
||||||
|
if (permissions.includes('*')) return true
|
||||||
|
return permissions.includes(perm)
|
||||||
|
}
|
||||||
|
|
||||||
{/* 导航区 */}
|
|
||||||
<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
|
|
||||||
return (
|
return (
|
||||||
<Link key={item.href} href={item.href}
|
<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">
|
||||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors mb-0.5 ${
|
<div className="h-14 flex items-center px-5 border-b border-slate-200 dark:border-slate-800">
|
||||||
isActive(item.href)
|
<span className="text-lg font-semibold text-blue-600 dark:text-blue-400">IT工单跟踪系统</span>
|
||||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400'
|
</div>
|
||||||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'
|
<nav className="flex-1 py-3 px-3 space-y-1 overflow-y-auto">
|
||||||
}`}>
|
{navItems.filter(item => canSee(item.perm)).map((item) => {
|
||||||
<Icon size={18} />
|
const isActive = pathname === item.href || pathname.startsWith(item.href + '/')
|
||||||
<span>{item.label}</span>
|
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>)
|
||||||
|
})}
|
||||||
|
{permissions.includes('*') && (
|
||||||
|
<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>
|
||||||
)
|
)}
|
||||||
|
{hasAnyAdminPerm(permissions) && (
|
||||||
|
<div className="pt-3 border-t border-slate-200 dark:border-slate-800 mt-3">
|
||||||
|
<div className="flex items-center gap-3 px-3 py-2 text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
|
||||||
|
<Settings size={14} />系统设置
|
||||||
|
</div>
|
||||||
|
{settingsItems.filter(item => canSee(item.perm)).map((item) => {
|
||||||
|
const isActive = pathname === item.href
|
||||||
|
const Icon = item.icon
|
||||||
|
return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,53 +2,21 @@
|
||||||
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, setTheme } = useTheme()
|
const { theme, toggleTheme } = useTheme()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
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 () => {
|
const handleLogout = async () => {
|
||||||
await fetch('/api/auth/logout', { method: 'POST' })
|
await fetch('/api/auth/logout', { method: 'POST' })
|
||||||
router.push('/login'); router.refresh()
|
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">
|
||||||
<div ref={ref} className="relative">
|
<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>
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,25 @@
|
||||||
'use client'
|
'use client'
|
||||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
|
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
|
||||||
|
|
||||||
type Theme = 'light' | 'dark' | 'auto'
|
type Theme = 'light' | 'dark'
|
||||||
interface ThemeContextType { theme: Theme; setTheme: (t: Theme) => void }
|
interface ThemeContextType { theme: Theme; toggleTheme: () => void }
|
||||||
const ThemeContext = createContext<ThemeContextType>({ theme: 'auto', setTheme: () => {} })
|
const ThemeContext = createContext<ThemeContextType>({ theme: 'dark', toggleTheme: () => {} })
|
||||||
|
|
||||||
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, setThemeState] = useState<Theme>('auto')
|
const [theme, setTheme] = useState<Theme>('dark')
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = localStorage.getItem('theme') as Theme | null
|
const stored = localStorage.getItem('theme') as Theme | null
|
||||||
const initial = stored || 'auto'
|
if (stored) { setTheme(stored); document.documentElement.classList.toggle('dark', stored === 'dark') }
|
||||||
setThemeState(initial)
|
else {
|
||||||
applyTheme(initial)
|
const d = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
setTheme(d); document.documentElement.classList.toggle('dark', d === 'dark')
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
const setTheme = (t: Theme) => {
|
const toggleTheme = () => {
|
||||||
setThemeState(t)
|
const n = theme === 'dark' ? 'light' : 'dark'
|
||||||
localStorage.setItem('theme', t)
|
setTheme(n); localStorage.setItem('theme', n); document.documentElement.classList.toggle('dark', n === 'dark')
|
||||||
applyTheme(t)
|
|
||||||
}
|
}
|
||||||
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>
|
return <ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = ['腾讯', '图灵']
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ export function Badge({ variant = 'default', children }: BadgeProps) {
|
||||||
|
|
||||||
interface CardProps { children: ReactNode; className?: string }
|
interface CardProps { children: ReactNode; className?: string }
|
||||||
export function Card({ children, className = '' }: CardProps) {
|
export function Card({ children, className = '' }: CardProps) {
|
||||||
return (<div className={`bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm ${className}`}>{children}</div>)
|
return (<div className={`bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-700 shadow-sm ${className}`}>{children}</div>)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToastProps { message: string; type?: 'success' | 'error' | 'info'; onClose: () => void }
|
interface ToastProps { message: string; type?: 'success' | 'error' | 'info'; onClose: () => void }
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,87 @@
|
||||||
// issue-ai/src/lib/audit.ts — 站点审计包装器(引用共享库)
|
|
||||||
import { getDb } from './db'
|
import { getDb } from './db'
|
||||||
import { writeAuditLog as sharedWriteAuditLog, diffObjects } from '@shared/lib/audit/write-audit-log'
|
|
||||||
import type { AuditLogEntry, AuditStore } from '@shared/lib/audit/write-audit-log'
|
|
||||||
|
|
||||||
// 创建 issue-ai 的 store 适配器
|
interface AuditLogOptions {
|
||||||
const store: AuditStore = {
|
userId?: number | null
|
||||||
exec: (sql: string) => {
|
apiKeyId?: number | null
|
||||||
|
action: string
|
||||||
|
entityType: string
|
||||||
|
entityId?: number | null
|
||||||
|
details?: Record<string, unknown> | null
|
||||||
|
ipAddress?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeAuditLog(opts: AuditLogOptions): void {
|
||||||
|
const { userId, apiKeyId, action, entityType, entityId, details, ipAddress } = opts
|
||||||
|
|
||||||
try {
|
try {
|
||||||
getDb().exec(sql)
|
const db = getDb()
|
||||||
|
|
||||||
|
// 每日清理(每天首次写入触发)
|
||||||
|
// 注意:禁止使用 toISOString(),会返回 UTC 时间导致时区偏移
|
||||||
|
const now = new Date()
|
||||||
|
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||||
|
|
||||||
|
const lastCleanup = db.prepare(
|
||||||
|
"SELECT value FROM settings WHERE key = 'audit_cleanup_date'"
|
||||||
|
).get() as { value: string } | undefined
|
||||||
|
|
||||||
|
if (!lastCleanup || lastCleanup.value !== today) {
|
||||||
|
// 从 settings 读取保留天数,默认 180 天
|
||||||
|
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)))
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`DELETE FROM audit_logs WHERE created_at < datetime('now', '-${retentionDays} days', '+8 hours')`
|
||||||
|
).run()
|
||||||
|
db.prepare(
|
||||||
|
"INSERT OR REPLACE INTO settings (key, value) VALUES ('audit_cleanup_date', ?)"
|
||||||
|
).run(today)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入审计日志(显式设置 created_at 为北京时间)
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO audit_logs (user_id, api_key_id, action, entity_type, entity_id, details, ip_address, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now', '+8 hours'))
|
||||||
|
`).run(
|
||||||
|
userId ?? null,
|
||||||
|
apiKeyId ?? null,
|
||||||
|
action,
|
||||||
|
entityType,
|
||||||
|
entityId ?? null,
|
||||||
|
details ? JSON.stringify(details) : null,
|
||||||
|
ipAddress ?? null
|
||||||
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// 审计写入失败不阻断主操作
|
||||||
console.error('审计日志写入失败:', e)
|
console.error('审计日志写入失败:', e)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保持原有调用方式:writeAuditLog(opts) —— 内部调用共享库
|
export function diffObjects(
|
||||||
export function writeAuditLog(opts: AuditLogEntry): void {
|
before: Record<string, unknown>,
|
||||||
sharedWriteAuditLog(store, opts)
|
after: Record<string, unknown>
|
||||||
|
): Record<string, { from: unknown; to: unknown }> {
|
||||||
|
const changes: Record<string, { from: unknown; to: unknown }> = {}
|
||||||
|
const keys = new Set([...Object.keys(before), ...Object.keys(after)])
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
// 跳过系统字段
|
||||||
|
if (['created_at', 'updated_at'].includes(key)) continue
|
||||||
|
|
||||||
|
const from = before[key]
|
||||||
|
const to = after[key]
|
||||||
|
if (JSON.stringify(from) !== JSON.stringify(to)) {
|
||||||
|
changes[key] = { from, to }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes
|
||||||
}
|
}
|
||||||
|
|
||||||
// 直接定义 getClientIP,避免 Next.js tree-shaking 导致 re-export 丢失
|
|
||||||
// (shared 库的 re-export 在 standalone 构建中可能被优化掉)
|
|
||||||
export function getClientIP(request: Request): string | null {
|
export function getClientIP(request: Request): string | null {
|
||||||
const forwarded = request.headers.get('x-forwarded-for')
|
const forwarded = request.headers.get('x-forwarded-for')
|
||||||
if (forwarded) return forwarded.split(',')[0].trim()
|
if (forwarded) return forwarded.split(',')[0].trim()
|
||||||
return request.headers.get('x-real-ip') ?? null
|
return request.headers.get('x-real-ip') ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
// re-export 共享工具函数
|
|
||||||
export { diffObjects }
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { createToken, verifyToken, type UserPayload } from './jwt'
|
||||||
export { createToken, verifyToken, type UserPayload }
|
export { createToken, verifyToken, type UserPayload }
|
||||||
|
|
||||||
import { verifySharedJwt } from './jwt-shared'
|
import { verifySharedJwt } from './jwt-shared'
|
||||||
import { ldapUserExists, ldapGetUserInfo, ldapIsAdmin } from './ldap'
|
import { ldapUserExists, ldapGetUserInfo } from './ldap'
|
||||||
import { getUserPermissions } from './permissions'
|
import { getUserPermissions } from './permissions'
|
||||||
|
|
||||||
export async function getCurrentUser(): Promise<UserPayload | null> {
|
export async function getCurrentUser(): Promise<UserPayload | null> {
|
||||||
|
|
@ -37,14 +37,13 @@ export async function getCurrentUser(): Promise<UserPayload | null> {
|
||||||
row.permissions = getUserPermissions(row.role)
|
row.permissions = getUserPermissions(row.role)
|
||||||
return row
|
return row
|
||||||
}
|
}
|
||||||
// SSO 免登录:LLDAP 验证通过但本地无记录 → 自动创建(从 LLDAP 判断角色,oa-ai 同步更新)
|
// SSO 免登录:LLDAP 验证通过但本地无记录 → 自动创建(viewer 角色)
|
||||||
const ldapInfo = await ldapGetUserInfo(sharedPayload.username)
|
const ldapInfo = await ldapGetUserInfo(sharedPayload.username)
|
||||||
const displayName = ldapInfo?.displayName || sharedPayload.displayName
|
const displayName = ldapInfo?.displayName || sharedPayload.displayName
|
||||||
const email = ldapInfo?.email ?? null
|
const email = ldapInfo?.email ?? null
|
||||||
const role = (await ldapIsAdmin(sharedPayload.username)) ? 'admin' : 'viewer'
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
"INSERT OR IGNORE INTO users (username, display_name, email, role, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
|
"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(sharedPayload.username, displayName, email, role)
|
).run(sharedPayload.username, displayName, email)
|
||||||
const newRow = db.prepare(
|
const newRow = db.prepare(
|
||||||
'SELECT id, username, display_name, role FROM users WHERE username = ? AND is_active = 1'
|
'SELECT id, username, display_name, role FROM users WHERE username = ? AND is_active = 1'
|
||||||
).get(sharedPayload.username) as UserPayload | undefined
|
).get(sharedPayload.username) as UserPayload | undefined
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,6 @@ export function initDatabase(): void {
|
||||||
|
|
||||||
// 迁移:添加 parts_name 列
|
// 迁移:添加 parts_name 列
|
||||||
try { db.exec('ALTER TABLE tickets ADD COLUMN parts_name TEXT') } catch { /* 列已存在 */ }
|
try { db.exec('ALTER TABLE tickets ADD COLUMN parts_name TEXT') } catch { /* 列已存在 */ }
|
||||||
// 迁移:添加 audit_logs.username 列(共享库 write-audit-log.ts 写入此列)
|
|
||||||
try { db.exec('ALTER TABLE audit_logs ADD COLUMN username TEXT') } catch { /* 列已存在 */ }
|
|
||||||
// 迁移:添加 audit_logs.api_key_id 列(共享库 audit-schema.ts 已定义)
|
|
||||||
try { db.exec('ALTER TABLE audit_logs ADD COLUMN api_key_id INTEGER REFERENCES api_keys(id)') } catch { /* 列已存在 */ }
|
|
||||||
|
|
||||||
// 迁移:添加 ticket_type 列
|
// 迁移:添加 ticket_type 列
|
||||||
try { db.exec('ALTER TABLE tickets ADD COLUMN ticket_type TEXT') } catch { /* 列已存在 */ }
|
try { db.exec('ALTER TABLE tickets ADD COLUMN ticket_type TEXT') } catch { /* 列已存在 */ }
|
||||||
|
|
@ -62,20 +58,6 @@ export function initDatabase(): void {
|
||||||
db.exec("CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at)")
|
db.exec("CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at)")
|
||||||
} catch { /* 索引已存在 */ }
|
} catch { /* 索引已存在 */ }
|
||||||
|
|
||||||
// 迁移:tickets:write 拆分为 tickets:edit + tickets:delete
|
|
||||||
try {
|
|
||||||
const roleRows = db.prepare('SELECT id, permissions FROM roles').all() as { id: number; permissions: string }[]
|
|
||||||
for (const row of roleRows) {
|
|
||||||
try {
|
|
||||||
const perms: string[] = JSON.parse(row.permissions)
|
|
||||||
if (perms.includes('tickets:write') && !perms.includes('tickets:edit')) {
|
|
||||||
const newPerms = perms.flatMap(p => p === 'tickets:write' ? ['tickets:edit', 'tickets:delete'] : [p])
|
|
||||||
db.prepare('UPDATE roles SET permissions = ? WHERE id = ?').run(JSON.stringify(newPerms), row.id)
|
|
||||||
}
|
|
||||||
} catch { /* JSON 解析失败跳过 */ }
|
|
||||||
}
|
|
||||||
} catch { /* 迁移失败则保持原样 */ }
|
|
||||||
|
|
||||||
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get('admin')
|
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get('admin')
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
const defaultPassword = process.env.ADMIN_PASSWORD || 'admin123'
|
const defaultPassword = process.env.ADMIN_PASSWORD || 'admin123'
|
||||||
|
|
@ -90,7 +72,7 @@ export function initDatabase(): void {
|
||||||
}
|
}
|
||||||
const roles = [
|
const roles = [
|
||||||
{ name: 'admin', display_name: '管理员', permissions: '["*"]' },
|
{ name: 'admin', display_name: '管理员', permissions: '["*"]' },
|
||||||
{ name: 'operator', display_name: '运维人员', permissions: '["tickets:read","tickets:create","tickets:import","tickets:export","tickets:edit","tickets:delete","reports:read","reports:download","reports:create"]' },
|
{ name: 'operator', display_name: '运维人员', permissions: '["tickets:read","tickets:create","tickets:import","tickets:export","tickets:write","reports:read","reports:download","reports:create"]' },
|
||||||
{ name: 'viewer', display_name: '查看者', permissions: '["tickets:read","tickets:export","reports:read","reports:download"]' },
|
{ name: 'viewer', display_name: '查看者', permissions: '["tickets:read","tickets:export","reports:read","reports:download"]' },
|
||||||
]
|
]
|
||||||
for (const r of roles) {
|
for (const r of roles) {
|
||||||
|
|
@ -170,25 +152,4 @@ export function initDatabase(): void {
|
||||||
created_at TEXT DEFAULT (datetime('now', '+8 hours'))
|
created_at TEXT DEFAULT (datetime('now', '+8 hours'))
|
||||||
)`)
|
)`)
|
||||||
try { db.exec("CREATE INDEX IF NOT EXISTS idx_monitor_logs_msg ON monitor_logs(email_msg_id)") } catch { /* 索引已存在 */ }
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_monitor_logs_msg ON monitor_logs(email_msg_id)") } catch { /* 索引已存在 */ }
|
||||||
|
|
||||||
// scan_history — 扫描历史记录
|
|
||||||
db.exec(`CREATE TABLE IF NOT EXISTS scan_history (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
started_at TEXT NOT NULL,
|
|
||||||
completed_at TEXT,
|
|
||||||
time_range_value INTEGER,
|
|
||||||
time_range_unit TEXT,
|
|
||||||
total_count INTEGER DEFAULT 0,
|
|
||||||
matched_count INTEGER DEFAULT 0,
|
|
||||||
imported_count INTEGER DEFAULT 0,
|
|
||||||
skipped_count INTEGER DEFAULT 0,
|
|
||||||
error_count INTEGER DEFAULT 0,
|
|
||||||
details_truncated INTEGER DEFAULT 0,
|
|
||||||
error_message TEXT,
|
|
||||||
details_json TEXT,
|
|
||||||
created_by INTEGER,
|
|
||||||
created_at TEXT DEFAULT (datetime('now', '+8 hours'))
|
|
||||||
)`)
|
|
||||||
try { db.exec("CREATE INDEX IF NOT EXISTS idx_scan_history_created_at ON scan_history(created_at)") } catch { /* 索引已存在 */ }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
// issue-ai/src/lib/jwt-shared.ts — 引用共享 JWT,保持原有接口
|
import crypto from 'crypto'
|
||||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
|
||||||
import { signJwtV2 } from '@shared/lib/auth/jwt-v2'
|
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'default-secret-change-me'
|
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-same-across-all-sites'
|
||||||
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || ''
|
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || ''
|
||||||
|
|
||||||
export interface SharedSession {
|
export interface SharedSession {
|
||||||
|
|
@ -12,34 +10,51 @@ export interface SharedSession {
|
||||||
exp: number
|
exp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保持原有签名:signSharedJwt(payload, expiresIn)
|
function base64url(str: string): string {
|
||||||
|
return Buffer.from(str).toString('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
export function signSharedJwt(
|
export function signSharedJwt(
|
||||||
payload: { username: string; displayName: string },
|
payload: { username: string; displayName: string },
|
||||||
expiresIn: number = 7 * 24 * 60 * 60
|
expiresIn: number = 7 * 24 * 60 * 60
|
||||||
): string {
|
): string {
|
||||||
return signJwtV2({ secret: JWT_SECRET, payload, iss: 'issue.tlyq.ai', expiresInSeconds: expiresIn })
|
const header = { alg: 'HS256', typ: 'JWT' }
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const body = { ...payload, iat: now, exp: now + expiresIn }
|
||||||
|
const segments = [base64url(JSON.stringify(header)), base64url(JSON.stringify(body))]
|
||||||
|
const signingInput = segments.join('.')
|
||||||
|
segments.push(
|
||||||
|
crypto.createHmac('sha256', JWT_SECRET).update(signingInput).digest('base64url')
|
||||||
|
)
|
||||||
|
return segments.join('.')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保持原有签名:verifySharedJwt(token)
|
|
||||||
export function verifySharedJwt(token: string): SharedSession | null {
|
export function verifySharedJwt(token: string): SharedSession | null {
|
||||||
const payload = verifyJwt(token, JWT_SECRET)
|
try {
|
||||||
if (!payload) return null
|
const parts = token.split('.')
|
||||||
|
if (parts.length !== 3) return null
|
||||||
|
const signingInput = parts.slice(0, 2).join('.')
|
||||||
|
const expectedSig = crypto.createHmac('sha256', JWT_SECRET)
|
||||||
|
.update(signingInput).digest('base64url')
|
||||||
|
if (parts[2] !== expectedSig) return null
|
||||||
|
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString())
|
||||||
|
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null
|
||||||
return {
|
return {
|
||||||
username: payload.username as string,
|
username: payload.username,
|
||||||
displayName: (payload.displayName || payload.username) as string,
|
displayName: payload.displayName,
|
||||||
iat: payload.iat as number,
|
iat: payload.iat,
|
||||||
exp: payload.exp as number,
|
exp: payload.exp,
|
||||||
}
|
}
|
||||||
|
} catch { return null }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保持原有签名:sharedCookieConfig(maxAge)
|
|
||||||
export function sharedCookieConfig(maxAge: number = 7 * 24 * 60 * 60) {
|
export function sharedCookieConfig(maxAge: number = 7 * 24 * 60 * 60) {
|
||||||
return {
|
return {
|
||||||
name: 'tlyq_session',
|
name: 'tlyq_session',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
sameSite: 'lax' as const,
|
sameSite: 'lax' as const,
|
||||||
domain: COOKIE_DOMAIN || undefined,
|
domain: COOKIE_DOMAIN,
|
||||||
path: '/',
|
path: '/',
|
||||||
maxAge,
|
maxAge,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
import { Client, InvalidCredentialsError } from 'ldapts'
|
import { Client, InvalidCredentialsError } from 'ldapts'
|
||||||
|
import { execFileSync } from 'child_process'
|
||||||
|
|
||||||
const LDAP_URL = process.env.LDAP_URL || 'ldap://localhost:3890'
|
const LDAP_URL = process.env.LDAP_URL || 'ldap://localhost:3890'
|
||||||
const LDAP_BASE_DN = process.env.LDAP_BASE_DN || 'dc=tlyq,dc=ai'
|
const LDAP_BASE_DN = process.env.LDAP_BASE_DN || 'dc=tlyq,dc=ai'
|
||||||
|
|
||||||
// 从环境变量获取 LLDAP admin 密码(容器内无法执行 docker exec,见 LESSONS-LEARNED #41)
|
// 运行时从 LLDAP 容器动态获取 admin 密码,避免明文存于多个 .env
|
||||||
|
// 需要容器挂载 /var/run/docker.sock
|
||||||
function getLdapAdminPassword(): string {
|
function getLdapAdminPassword(): string {
|
||||||
return process.env.LLDAP_ADMIN_PASSWORD || 'admin123'
|
try {
|
||||||
|
return execFileSync('docker', ['exec', 'lldap', 'printenv', 'LLDAP_ADMIN_PASSWORD'],
|
||||||
|
{ timeout: 3000 }).toString().trim()
|
||||||
|
} catch { return 'admin123' }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LdapResult {
|
export interface LdapResult {
|
||||||
|
|
@ -65,22 +70,6 @@ export async function ldapGetUserInfo(username: string): Promise<{ displayName:
|
||||||
finally { await client.unbind() }
|
finally { await client.unbind() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查 LLDAP 用户是否为 lldap_admin 组成员
|
|
||||||
export async function ldapIsAdmin(username: string): Promise<boolean> {
|
|
||||||
if (username === 'admin') return true
|
|
||||||
const adminDn = process.env.LDAP_ADMIN_DN || 'uid=admin,ou=people,dc=tlyq,dc=ai'
|
|
||||||
const adminPass = getLdapAdminPassword()
|
|
||||||
const client = new Client({ url: LDAP_URL, timeout: 5000 })
|
|
||||||
try {
|
|
||||||
await client.bind(adminDn, adminPass)
|
|
||||||
const { searchEntries } = await client.search(`ou=groups,${LDAP_BASE_DN}`, {
|
|
||||||
scope: 'sub', filter: `(&(cn=lldap_admin)(member=uid=${username},ou=people,${LDAP_BASE_DN}))`, timeLimit: 3,
|
|
||||||
})
|
|
||||||
return searchEntries.length > 0
|
|
||||||
} catch { return false }
|
|
||||||
finally { try { await client.unbind() } catch { /* */ } }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Q1: 检查 LLDAP 中用户是否存在(用 admin bind 搜索,不在/不可达均返回 true 保证容错)
|
// Q1: 检查 LLDAP 中用户是否存在(用 admin bind 搜索,不在/不可达均返回 true 保证容错)
|
||||||
export async function ldapUserExists(username: string): Promise<boolean> {
|
export async function ldapUserExists(username: string): Promise<boolean> {
|
||||||
const adminDn = process.env.LDAP_ADMIN_DN || 'uid=admin,ou=people,dc=tlyq,dc=ai'
|
const adminDn = process.env.LDAP_ADMIN_DN || 'uid=admin,ou=people,dc=tlyq,dc=ai'
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { ImapFlow } from 'imapflow'
|
||||||
import { simpleParser } from 'mailparser'
|
import { simpleParser } from 'mailparser'
|
||||||
import * as cheerio from 'cheerio'
|
import * as cheerio from 'cheerio'
|
||||||
import type { EmailInfo, FaultInfo, MonitorConfig } from './types'
|
import type { EmailInfo, FaultInfo, MonitorConfig } from './types'
|
||||||
import { fetchPOP3Emails, normalizeMessageId } from './pop3-fetcher'
|
|
||||||
|
|
||||||
export class MailMonitor {
|
export class MailMonitor {
|
||||||
private client: ImapFlow | null = null
|
private client: ImapFlow | null = null
|
||||||
|
|
@ -19,13 +18,8 @@ export class MailMonitor {
|
||||||
logger: false,
|
logger: false,
|
||||||
connectionTimeout: 15_000,
|
connectionTimeout: 15_000,
|
||||||
})
|
})
|
||||||
try {
|
|
||||||
await this.client.connect()
|
await this.client.connect()
|
||||||
this.retryCount = 0
|
this.retryCount = 0
|
||||||
} catch (e) {
|
|
||||||
this.client = null
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
|
|
@ -62,42 +56,18 @@ export class MailMonitor {
|
||||||
if (!this.client) return []
|
if (!this.client) return []
|
||||||
const lock = await this.client.getMailboxLock('INBOX')
|
const lock = await this.client.getMailboxLock('INBOX')
|
||||||
try {
|
try {
|
||||||
// 搜索最近 7 天的邮件(避免遗漏昨天未处理的邮件),通过 processed_emails 表去重
|
const today = new Date()
|
||||||
const since = new Date()
|
const since = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
||||||
since.setDate(since.getDate() - 7)
|
// 不使用 unseen 条件(IMAP 标记不可靠),依赖 processed_emails 表去重
|
||||||
const uids = await this.client.search({ since }, { uid: true })
|
const uids = await this.client.search({ since }, { uid: true })
|
||||||
if (!uids || uids.length === 0) return []
|
if (!uids || uids.length === 0) return []
|
||||||
|
|
||||||
// IMAP BODY 为空时的 POP3 fallback 状态(网易企业邮箱已知问题)
|
|
||||||
let pop3Map: Map<string, Buffer> = new Map()
|
|
||||||
let pop3Attempted = false
|
|
||||||
|
|
||||||
const emails: EmailInfo[] = []
|
const emails: EmailInfo[] = []
|
||||||
for (const uid of uids) {
|
for (const uid of uids) {
|
||||||
try {
|
try {
|
||||||
let source: Buffer | null = null
|
|
||||||
const msg = await this.client.fetchOne(uid, { source: true, uid: true }, { uid: true })
|
const msg = await this.client.fetchOne(uid, { source: true, uid: true }, { uid: true })
|
||||||
if (msg && 'source' in msg && (msg as any).source) {
|
if (!msg || !('source' in msg) || !(msg as any).source) continue
|
||||||
source = (msg as any).source as Buffer
|
const parsed = await simpleParser((msg as any).source as Buffer)
|
||||||
}
|
|
||||||
// IMAP source 为空 → 首次触发 POP3 fallback(一次性拉取建索引)
|
|
||||||
if (!source && !pop3Attempted) {
|
|
||||||
pop3Attempted = true
|
|
||||||
try {
|
|
||||||
pop3Map = await fetchPOP3Emails(config.mail.address, config.mail.password, uids.length, config.mail.pop3_server, config.mail.pop3_port)
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[Worker] POP3 fallback 失败: ${e instanceof Error ? e.message : e}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 通过 imapflow ENVELOPE 取 Message-ID,归一化后与 POP3 Map 精确匹配
|
|
||||||
if (!source && pop3Map.size > 0) {
|
|
||||||
const envMsg = await this.client.fetchOne(uid, { envelope: true, uid: true }, { uid: true })
|
|
||||||
const mid = normalizeMessageId((envMsg as any)?.envelope?.messageId || null)
|
|
||||||
if (mid && pop3Map.has(mid)) source = pop3Map.get(mid)!
|
|
||||||
}
|
|
||||||
if (!source) continue
|
|
||||||
|
|
||||||
const parsed = await simpleParser(source)
|
|
||||||
const emailInfo: EmailInfo = {
|
const emailInfo: EmailInfo = {
|
||||||
msg_id: parsed.messageId || String(uid),
|
msg_id: parsed.messageId || String(uid),
|
||||||
subject: parsed.subject || '',
|
subject: parsed.subject || '',
|
||||||
|
|
|
||||||
|
|
@ -1,237 +0,0 @@
|
||||||
// src/lib/monitor/pop3-fetcher.ts
|
|
||||||
// 从 scan-emails 抽取的共享 POP3 获取模块。
|
|
||||||
// 用途:IMAP 服务器返回空 BODY 时的 fallback(网易企业邮箱 imaphz.qiye.163.com 已知问题)。
|
|
||||||
import * as tls from 'tls'
|
|
||||||
|
|
||||||
// 规范化 Message-ID:去除尖括号和首尾空白,统一为可比较的键
|
|
||||||
export function normalizeMessageId(raw: string | null | undefined): string | null {
|
|
||||||
if (!raw) return null
|
|
||||||
const m = raw.match(/<([^>]+)>/)
|
|
||||||
const id = (m ? m[1] : raw).trim()
|
|
||||||
return id || null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从原始邮件 Buffer 的头部提取 Message-ID(正则,避免完整解析开销)
|
|
||||||
export function extractMessageIdFromRaw(buf: Buffer): string | null {
|
|
||||||
// 只在头部区域查找(第一个空行之前)
|
|
||||||
const headerEnd = buf.indexOf('\r\n\r\n', 0, 'latin1')
|
|
||||||
const headerRegion = (headerEnd !== -1 ? buf.subarray(0, headerEnd) : buf).toString('latin1')
|
|
||||||
const m = headerRegion.match(/^Message-I[dD]:\s*(.+)$/im)
|
|
||||||
return m ? normalizeMessageId(m[1]) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
// POP3 邮件获取(IMAP 返回空 BODY 时的 fallback)
|
|
||||||
// 返回 Map<规范化 Message-ID, 原始邮件 Buffer>,通过 Message-ID 精确匹配,避免脆弱的位置对齐
|
|
||||||
export async function fetchPOP3Emails(
|
|
||||||
mailAddress: string,
|
|
||||||
mailPassword: string,
|
|
||||||
count: number,
|
|
||||||
host: string,
|
|
||||||
port: number,
|
|
||||||
): Promise<Map<string, Buffer>> {
|
|
||||||
const POP3_CMD_TIMEOUT = 30000
|
|
||||||
const POP3_MAX_RESPONSE = 50 * 1024 * 1024 // 单封邮件响应上限 50MB,防内存耗尽
|
|
||||||
|
|
||||||
// 校验凭据不含 CRLF,防止 POP3 命令注入
|
|
||||||
if (/[\r\n]/.test(mailAddress) || /[\r\n]/.test(mailPassword)) {
|
|
||||||
throw new Error('POP3 凭据包含非法字符(CRLF)')
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
console.log(`[Scan] POP3: 连接 ${host}:${port}, 获取最近 ${count} 封`)
|
|
||||||
// 网易企业邮箱持有受信任 CA 证书,启用完整证书 + SNI 校验
|
|
||||||
const socket = tls.connect(port, host, { servername: host })
|
|
||||||
|
|
||||||
let settled = false
|
|
||||||
let cmdResolve: ((v: Buffer) => void) | null = null
|
|
||||||
let cmdReject: ((e: Error) => void) | null = null
|
|
||||||
let cmdTimeout: NodeJS.Timeout | null = null
|
|
||||||
let isMultiline = false
|
|
||||||
let chunks: Buffer[] = []
|
|
||||||
let bufLen = 0
|
|
||||||
|
|
||||||
function resetCmd(): void {
|
|
||||||
cmdResolve = null
|
|
||||||
cmdReject = null
|
|
||||||
if (cmdTimeout) { clearTimeout(cmdTimeout); cmdTimeout = null }
|
|
||||||
isMultiline = false
|
|
||||||
chunks = []
|
|
||||||
bufLen = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// 统一收尾:确保 socket 关闭且 Promise 只 settle 一次
|
|
||||||
function finish(err: Error | null, value?: Map<string, Buffer>): void {
|
|
||||||
if (settled) return
|
|
||||||
settled = true
|
|
||||||
try { socket.destroy() } catch {}
|
|
||||||
if (err) reject(err)
|
|
||||||
else resolve(value || new Map())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 索引 Buffer 中的子串位置(避免字符串往返)
|
|
||||||
function indexOfCRLFDotCRLF(buf: Buffer): number {
|
|
||||||
return buf.indexOf('\r\n.\r\n', 0, 'latin1')
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.on('data', (data: Buffer) => {
|
|
||||||
if (settled) return
|
|
||||||
chunks.push(data)
|
|
||||||
bufLen += data.length
|
|
||||||
|
|
||||||
if (bufLen > POP3_MAX_RESPONSE) {
|
|
||||||
// 超限直接销毁连接放弃本次 fallback,避免脏连接续用导致后续响应错位
|
|
||||||
finish(new Error('POP3 响应超过大小上限'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cmdResolve) return
|
|
||||||
const buf = Buffer.concat(chunks)
|
|
||||||
|
|
||||||
if (isMultiline) {
|
|
||||||
// 单行错误响应(-ERR ...\r\n)在多行模式下也需即时识别,避免白等超时
|
|
||||||
if (buf.length >= 4 && buf.subarray(0, 4).toString('latin1') === '-ERR') {
|
|
||||||
if (buf.indexOf('\r\n', 0, 'latin1') !== -1) {
|
|
||||||
const r = cmdResolve
|
|
||||||
resetCmd()
|
|
||||||
r(buf)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 多行响应以 \r\n.\r\n 结束
|
|
||||||
if (indexOfCRLFDotCRLF(buf) !== -1) {
|
|
||||||
const r = cmdResolve
|
|
||||||
resetCmd()
|
|
||||||
r(buf)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 单行响应以 \r\n 结束
|
|
||||||
if (buf.indexOf('\r\n', 0, 'latin1') !== -1) {
|
|
||||||
const r = cmdResolve
|
|
||||||
resetCmd()
|
|
||||||
r(buf)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function sendCmd(cmd: string, multiline = false): Promise<Buffer> {
|
|
||||||
return new Promise((res, rej) => {
|
|
||||||
resetCmd()
|
|
||||||
cmdResolve = res
|
|
||||||
cmdReject = rej
|
|
||||||
isMultiline = multiline
|
|
||||||
cmdTimeout = setTimeout(() => {
|
|
||||||
const r = cmdReject
|
|
||||||
resetCmd()
|
|
||||||
if (r) r(new Error(`POP3 命令超时: ${cmd.split(' ')[0]}`))
|
|
||||||
}, POP3_CMD_TIMEOUT)
|
|
||||||
socket.write(cmd + '\r\n')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 等待 greeting(带超时,防止服务器静默导致整个扫描永久挂起)
|
|
||||||
function waitGreeting(): Promise<Buffer> {
|
|
||||||
return new Promise((res, rej) => {
|
|
||||||
resetCmd()
|
|
||||||
cmdResolve = res
|
|
||||||
cmdReject = rej
|
|
||||||
isMultiline = false
|
|
||||||
cmdTimeout = setTimeout(() => {
|
|
||||||
const r = cmdReject
|
|
||||||
resetCmd()
|
|
||||||
if (r) r(new Error('POP3 greeting 超时'))
|
|
||||||
}, POP3_CMD_TIMEOUT)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.on('error', (err: Error) => {
|
|
||||||
const rej = cmdReject
|
|
||||||
resetCmd()
|
|
||||||
if (rej) rej(err)
|
|
||||||
else finish(err)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 服务器主动关闭连接时,拒绝任何 pending 命令,避免永久挂起
|
|
||||||
socket.on('close', () => {
|
|
||||||
const rej = cmdReject
|
|
||||||
resetCmd()
|
|
||||||
if (rej) rej(new Error('POP3 连接被关闭'))
|
|
||||||
else finish(new Error('POP3 连接被关闭'))
|
|
||||||
})
|
|
||||||
|
|
||||||
// 从原始邮件 Buffer 提取正文(去掉 POP3 状态行 + 结束标记 + dot-stuffing 反填充)
|
|
||||||
function extractEmailBody(resp: Buffer): Buffer {
|
|
||||||
const headerEnd = resp.indexOf('\r\n', 0, 'latin1')
|
|
||||||
const bodyEnd = indexOfCRLFDotCRLF(resp)
|
|
||||||
const start = headerEnd + 2
|
|
||||||
const end = bodyEnd !== -1 ? bodyEnd : resp.length
|
|
||||||
let body = resp.subarray(start, end)
|
|
||||||
// dot-stuffing 反填充:行首 "\r\n.." → "\r\n.",开头 ".." → "."(RFC 1939)
|
|
||||||
const text = body.toString('latin1')
|
|
||||||
const unstuffed = text.replace(/\r\n\.\./g, '\r\n.').replace(/^\.\./, '.')
|
|
||||||
body = Buffer.from(unstuffed, 'latin1')
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
// POP3 协议流程
|
|
||||||
;(async () => {
|
|
||||||
try {
|
|
||||||
const greeting = await waitGreeting()
|
|
||||||
if (!greeting.toString('latin1').startsWith('+OK')) {
|
|
||||||
throw new Error(`POP3 greeting 异常: ${greeting.toString('latin1').substring(0, 80)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 登录
|
|
||||||
let resp = await sendCmd(`USER ${mailAddress}`)
|
|
||||||
if (!resp.toString('latin1').startsWith('+OK')) throw new Error('POP3 USER 失败')
|
|
||||||
|
|
||||||
resp = await sendCmd(`PASS ${mailPassword}`)
|
|
||||||
if (!resp.toString('latin1').startsWith('+OK')) throw new Error('POP3 PASS 失败')
|
|
||||||
console.log('[Scan] POP3: 登录成功')
|
|
||||||
|
|
||||||
// STAT 获取总数
|
|
||||||
resp = await sendCmd('STAT')
|
|
||||||
const totalMatch = resp.toString('latin1').match(/\+OK (\d+)/)
|
|
||||||
const total = totalMatch ? parseInt(totalMatch[1]) : 0
|
|
||||||
console.log(`[Scan] POP3: 共 ${total} 封邮件`)
|
|
||||||
|
|
||||||
if (total === 0) {
|
|
||||||
await sendCmd('QUIT').catch(() => {})
|
|
||||||
finish(null, new Map())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// RETR 最后 count 封(POP3 seqno 升序 = 时间升序,与 IMAP UID 顺序一致)
|
|
||||||
// 用 Message-ID 作为键,与 IMAP ENVELOPE 精确匹配,避免位置错位
|
|
||||||
const fetchCount = Math.min(count, total)
|
|
||||||
const emailMap = new Map<string, Buffer>()
|
|
||||||
let okCount = 0
|
|
||||||
for (let i = total - fetchCount + 1; i <= total; i++) {
|
|
||||||
try {
|
|
||||||
resp = await sendCmd(`RETR ${i}`, true)
|
|
||||||
if (resp.toString('latin1', 0, 3) === '+OK') {
|
|
||||||
const body = extractEmailBody(resp)
|
|
||||||
const msgId = extractMessageIdFromRaw(body)
|
|
||||||
if (msgId) {
|
|
||||||
emailMap.set(msgId, body)
|
|
||||||
okCount++
|
|
||||||
} else {
|
|
||||||
console.error(`[Scan] POP3 RETR ${i}: 无法提取 Message-ID,跳过`)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error(`[Scan] POP3 RETR ${i} 失败: ${resp.toString('latin1').substring(0, 80)}`)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[Scan] POP3 RETR ${i} 异常:`, e instanceof Error ? e.message : e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[Scan] POP3: 成功获取 ${okCount}/${fetchCount} 封(按 Message-ID 索引 ${emailMap.size} 封)`)
|
|
||||||
await sendCmd('QUIT').catch(() => {})
|
|
||||||
finish(null, emailMap)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[Scan] POP3 流程失败:', e instanceof Error ? e.message : e)
|
|
||||||
finish(e instanceof Error ? e : new Error(String(e)))
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -10,20 +10,15 @@ const logger = {
|
||||||
|
|
||||||
export class ReminderEngine {
|
export class ReminderEngine {
|
||||||
private db = getDb()
|
private db = getDb()
|
||||||
private static readonly TIER_PENALTY_MAP: Record<string, string> = { '第一档': '10%', '第二档': '25%', '第三档': '50%', '第四档': '100%' }
|
|
||||||
|
|
||||||
/** 获取北京时间的小时数(兼容 UTC 容器环境) */
|
|
||||||
private getBeijingHour(): number {
|
|
||||||
return new Date(new Date().getTime() + 8 * 3600_000).getUTCHours()
|
|
||||||
}
|
|
||||||
|
|
||||||
isInSilentPeriod(config: MonitorConfig): boolean {
|
isInSilentPeriod(config: MonitorConfig): boolean {
|
||||||
const hour = this.getBeijingHour()
|
const hour = new Date().getHours()
|
||||||
return hour >= config.monitor.silent_start_hour && hour < config.monitor.silent_end_hour
|
return hour >= config.monitor.silent_start_hour && hour < config.monitor.silent_end_hour
|
||||||
}
|
}
|
||||||
|
|
||||||
isTimeToFlush(): boolean {
|
isTimeToFlush(): boolean {
|
||||||
if (this.getBeijingHour() !== 7) return false
|
const now = new Date()
|
||||||
|
if (now.getHours() !== 7) return false
|
||||||
// 检查今天是否已 flush 过(防止重复执行)
|
// 检查今天是否已 flush 过(防止重复执行)
|
||||||
const flushed = this.db.prepare("SELECT id FROM monitor_logs WHERE action = 'flush_complete' AND date(created_at, '+8 hours') = date('now', '+8 hours')").get()
|
const flushed = this.db.prepare("SELECT id FROM monitor_logs WHERE action = 'flush_complete' AND date(created_at, '+8 hours') = date('now', '+8 hours')").get()
|
||||||
return !flushed
|
return !flushed
|
||||||
|
|
@ -111,13 +106,11 @@ export class ReminderEngine {
|
||||||
}
|
}
|
||||||
const rackH = getRack ? await getRack(r.server_ip, r.server_sn) : null
|
const rackH = getRack ? await getRack(r.server_ip, r.server_sn) : null
|
||||||
const remainingHours = Math.floor(timeToDeadline / 3600)
|
const remainingHours = Math.floor(timeToDeadline / 3600)
|
||||||
const remainingMinutes = Math.floor((timeToDeadline % 3600) / 60)
|
let text = `⏰ OEM诊断工单提醒(剩余${remainingHours}小时)\n\n`
|
||||||
const timeStr = remainingHours > 0 ? `${remainingHours}小时${remainingMinutes}分` : `${remainingMinutes}分`
|
|
||||||
let text = `⏰ OEM诊断工单提醒(剩余${timeStr})\n\n`
|
|
||||||
if (r.order_number) text += `工单号:${r.order_number}\n`
|
if (r.order_number) text += `工单号:${r.order_number}\n`
|
||||||
text += `服务器IP:${r.server_ip || '未知'}\n服务器SN:${r.server_sn}\n`
|
text += `服务器IP:${r.server_ip || '未知'}\n服务器SN:${r.server_sn}\n`
|
||||||
if (rackH) text += `机架位置:${rackH}\n`
|
if (rackH) text += `机架位置:${rackH}\n`
|
||||||
text += `到期时间:${r.deadline_time}\n\n服务器尚未录入恢复时间,请及时处理!`
|
text += `到期时间:${r.deadline_time}\n服务器尚未录入恢复时间,请及时处理!`
|
||||||
if (await pushText(text)) {
|
if (await pushText(text)) {
|
||||||
this.db.prepare("UPDATE reminder_state SET notification_phase = 'before', notified_at = datetime('now', '+8 hours') WHERE id = ?").run(r.id)
|
this.db.prepare("UPDATE reminder_state SET notification_phase = 'before', notified_at = datetime('now', '+8 hours') WHERE id = ?").run(r.id)
|
||||||
}
|
}
|
||||||
|
|
@ -126,7 +119,7 @@ export class ReminderEngine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async flushPendingMessages(pushText: (text: string) => Promise<boolean>, getRack?: (ip: string | null, sn: string | null) => Promise<string | null>): Promise<void> {
|
async flushPendingMessages(pushText: (text: string) => Promise<boolean>): Promise<void> {
|
||||||
const pending = this.db.prepare('SELECT * FROM pending_messages').all() as { id: number; message_type: string; server_sn: string; payload: string }[]
|
const pending = this.db.prepare('SELECT * FROM pending_messages').all() as { id: number; message_type: string; server_sn: string; payload: string }[]
|
||||||
for (const p of pending) {
|
for (const p of pending) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -135,12 +128,11 @@ export class ReminderEngine {
|
||||||
this.db.prepare('DELETE FROM pending_messages WHERE id = ?').run(p.id)
|
this.db.prepare('DELETE FROM pending_messages WHERE id = ?').run(p.id)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 重新计算剩余时间(提升到 try 块作用域,供下方消息构建使用)
|
// 重新计算剩余时间
|
||||||
let remainingSec = 0
|
|
||||||
if (payload.deadline) {
|
if (payload.deadline) {
|
||||||
const deadline = new Date(payload.deadline.replace(' ', 'T') + '+08:00')
|
const deadline = new Date(payload.deadline.replace(' ', 'T') + '+08:00')
|
||||||
remainingSec = (deadline.getTime() - Date.now()) / 1000
|
const remaining = (deadline.getTime() - Date.now()) / 1000
|
||||||
if (remainingSec <= 0) {
|
if (remaining <= 0) {
|
||||||
this.db.prepare('DELETE FROM pending_messages WHERE id = ?').run(p.id)
|
this.db.prepare('DELETE FROM pending_messages WHERE id = ?').run(p.id)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -148,46 +140,13 @@ export class ReminderEngine {
|
||||||
// 构建消息
|
// 构建消息
|
||||||
let text = ''
|
let text = ''
|
||||||
if (p.message_type === 'oem_diag') {
|
if (p.message_type === 'oem_diag') {
|
||||||
// 获取机架位置
|
text = `⏰ OEM诊断工单提醒\n\n`
|
||||||
let rackFlush: string | null = null
|
|
||||||
if (getRack) {
|
|
||||||
try { rackFlush = await getRack(payload.server_ip, payload.server_sn) } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
// 区分 diag_30min 和 diag_hourly(与 zabbix-01 flush 一致)
|
|
||||||
if (payload.type === 'diag_30min') {
|
|
||||||
text = `⚠️ OEM诊断工单即将到期(剩余30分钟)\n\n`
|
|
||||||
if (payload.order_number) text += `工单号:${payload.order_number}\n`
|
if (payload.order_number) text += `工单号:${payload.order_number}\n`
|
||||||
text += `服务器IP:${payload.server_ip || '未知'}\n服务器SN:${payload.server_sn}\n`
|
text += `服务器IP:${payload.server_ip || '未知'}\n服务器SN:${payload.server_sn}\n到期时间:${payload.deadline}\n服务器尚未录入恢复时间,请及时处理!`
|
||||||
if (rackFlush) text += `机架位置:${rackFlush}\n`
|
|
||||||
text += `到期时间:${payload.deadline}\n\n服务器尚未录入恢复时间,请尽快处理!`
|
|
||||||
} else {
|
|
||||||
// diag_hourly(默认):动态计算剩余时间
|
|
||||||
const remainingH = Math.floor(remainingSec / 3600)
|
|
||||||
const remainingM = Math.floor((remainingSec % 3600) / 60)
|
|
||||||
const timeStr = remainingH > 0 ? `${remainingH}小时${remainingM}分` : `${remainingM}分`
|
|
||||||
text = `⏰ OEM诊断工单提醒(剩余${timeStr})\n\n`
|
|
||||||
if (payload.order_number) text += `工单号:${payload.order_number}\n`
|
|
||||||
text += `服务器IP:${payload.server_ip || '未知'}\n服务器SN:${payload.server_sn}\n`
|
|
||||||
if (rackFlush) text += `机架位置:${rackFlush}\n`
|
|
||||||
text += `到期时间:${payload.deadline}\n\n服务器尚未录入恢复时间,请及时处理!`
|
|
||||||
}
|
|
||||||
} else if (p.message_type === 'oem_repair' && payload.type === 'repair_before') {
|
} else if (p.message_type === 'oem_repair' && payload.type === 'repair_before') {
|
||||||
// 动态计算剩余时间(替代硬编码"1小时")
|
text = `⏰ OEM维修工单提醒\n\n`
|
||||||
const remainingH = Math.floor(remainingSec / 3600)
|
|
||||||
const remainingM = Math.floor((remainingSec % 3600) / 60)
|
|
||||||
const timeStr = remainingH > 0 ? `${remainingH}小时${remainingM}分` : `${remainingM}分`
|
|
||||||
// 获取机架位置
|
|
||||||
let rackFlush: string | null = null
|
|
||||||
if (getRack) {
|
|
||||||
try { rackFlush = await getRack(payload.server_ip, payload.server_sn) } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
const penalty = payload.tier_name ? ReminderEngine.TIER_PENALTY_MAP[payload.tier_name] || '' : ''
|
|
||||||
const tierLabel = payload.tier_name === '第四档' ? '全额扣费(100%)' : `${payload.tier_name}(${penalty})`
|
|
||||||
text = `OEM维修工单超时提醒(距离${tierLabel}还剩${timeStr})\n\n`
|
|
||||||
if (payload.order_number) text += `工单号:${payload.order_number}\n`
|
if (payload.order_number) text += `工单号:${payload.order_number}\n`
|
||||||
text += `服务器IP:${payload.server_ip || '未知'}\n服务器SN:${payload.server_sn}\n`
|
text += `服务器IP:${payload.server_ip || '未知'}\n服务器SN:${payload.server_sn}\n到期时间:${payload.deadline}\n服务器尚未录入恢复时间,请及时处理!`
|
||||||
if (rackFlush) text += `机架位置:${rackFlush}\n`
|
|
||||||
text += `${payload.tier_name || ''}到期时间:${payload.deadline}\n\n服务器尚未录入恢复时间,请及时处理!`
|
|
||||||
}
|
}
|
||||||
if (text && await pushText(text)) {
|
if (text && await pushText(text)) {
|
||||||
this.db.prepare('DELETE FROM pending_messages WHERE id = ?').run(p.id)
|
this.db.prepare('DELETE FROM pending_messages WHERE id = ?').run(p.id)
|
||||||
|
|
@ -210,12 +169,7 @@ export class ReminderEngine {
|
||||||
|
|
||||||
if (timeToDeadline <= 0 && r.notification_phase !== 'overdue') {
|
if (timeToDeadline <= 0 && r.notification_phase !== 'overdue') {
|
||||||
const rackRO = getRack ? await getRack(r.server_ip, r.server_sn) : null
|
const rackRO = getRack ? await getRack(r.server_ip, r.server_sn) : null
|
||||||
// 超时提醒格式:第X档已超时,达到第Y档扣XX%
|
let text = `⚠️ OEM维修工单已触发${r.tier_name}超时\n\n`
|
||||||
const penalty = ReminderEngine.TIER_PENALTY_MAP[r.tier_name] || ''
|
|
||||||
const isFinalTier = r.tier_name === '第四档'
|
|
||||||
let text = isFinalTier
|
|
||||||
? `OEM维修工单超时提醒(${r.tier_name}已超时,已触发全额扣费(100%))\n\n`
|
|
||||||
: `OEM维修工单超时提醒(${r.tier_name}已超时,达到${r.tier_name}扣${penalty})\n\n`
|
|
||||||
if (r.order_number) text += `工单号:${r.order_number}\n`
|
if (r.order_number) text += `工单号:${r.order_number}\n`
|
||||||
text += `服务器IP:${r.server_ip || '未知'}\n服务器SN:${r.server_sn}\n`
|
text += `服务器IP:${r.server_ip || '未知'}\n服务器SN:${r.server_sn}\n`
|
||||||
if (rackRO) text += `机架位置:${rackRO}\n`
|
if (rackRO) text += `机架位置:${rackRO}\n`
|
||||||
|
|
@ -234,15 +188,11 @@ export class ReminderEngine {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const rackRB = getRack ? await getRack(r.server_ip, r.server_sn) : null
|
const rackRB = getRack ? await getRack(r.server_ip, r.server_sn) : null
|
||||||
// 提前1小时提醒格式:距离第X档(扣XX%)还剩1小时
|
let text = `⏰ OEM维修工单提醒(距离${r.tier_name}超时还剩1小时)\n\n`
|
||||||
const penalty = ReminderEngine.TIER_PENALTY_MAP[r.tier_name] || ''
|
|
||||||
const isFinalTier = r.tier_name === '第四档'
|
|
||||||
const tierLabel = isFinalTier ? '全额扣费(100%)' : `${r.tier_name}(${penalty})`
|
|
||||||
let text = `OEM维修工单超时提醒(距离${tierLabel}还剩1小时)\n\n`
|
|
||||||
if (r.order_number) text += `工单号:${r.order_number}\n`
|
if (r.order_number) text += `工单号:${r.order_number}\n`
|
||||||
text += `服务器IP:${r.server_ip || '未知'}\n服务器SN:${r.server_sn}\n`
|
text += `服务器IP:${r.server_ip || '未知'}\n服务器SN:${r.server_sn}\n`
|
||||||
if (rackRB) text += `机架位置:${rackRB}\n`
|
if (rackRB) text += `机架位置:${rackRB}\n`
|
||||||
text += `${r.tier_name}到期时间:${r.deadline_time}\n\n服务器尚未录入恢复时间,请及时处理!`
|
text += `${r.tier_name}到期时间:${r.deadline_time}\n服务器尚未录入恢复时间,请及时处理!`
|
||||||
if (await pushText(text)) {
|
if (await pushText(text)) {
|
||||||
this.db.prepare("UPDATE reminder_state SET notification_phase = 'before', notified_at = datetime('now', '+8 hours') WHERE id = ?").run(r.id)
|
this.db.prepare("UPDATE reminder_state SET notification_phase = 'before', notified_at = datetime('now', '+8 hours') WHERE id = ?").run(r.id)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
// src/lib/monitor/resolution-side-effects.ts
|
|
||||||
import { getDb } from '@/lib/db'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 结单副作用:回写 fault_records + 清理 reminder_state。
|
|
||||||
* 同步执行(better-sqlite3),对手动/外部/导入工单(无 fault_records)是安全 no-op。
|
|
||||||
* 必须在 notifyTicketResolved 之前调用(写 DB 不依赖推送结果)。
|
|
||||||
*/
|
|
||||||
export function applyResolutionSideEffects(ticketId: number): void {
|
|
||||||
const db = getDb()
|
|
||||||
const ticket = db.prepare(`
|
|
||||||
SELECT ticket_no, close_time, assign_time, device_sn
|
|
||||||
FROM tickets WHERE id = ?
|
|
||||||
`).get(ticketId) as {
|
|
||||||
ticket_no: string; close_time: string | null; assign_time: string; device_sn: string
|
|
||||||
} | undefined
|
|
||||||
if (!ticket || !ticket.close_time) return
|
|
||||||
|
|
||||||
// 1. 回写 fault_records(若存在 ongoing 记录)
|
|
||||||
const fault = db.prepare(
|
|
||||||
"SELECT id FROM fault_records WHERE order_number = ? AND status = 'ongoing'"
|
|
||||||
).get(ticket.ticket_no) as { id: number } | undefined
|
|
||||||
if (fault) {
|
|
||||||
const assignMs = new Date(ticket.assign_time.replace(' ', 'T') + '+08:00').getTime()
|
|
||||||
const closeMs = new Date(ticket.close_time.replace(' ', 'T') + '+08:00').getTime()
|
|
||||||
const durationSec = Math.floor((closeMs - assignMs) / 1000)
|
|
||||||
db.prepare(
|
|
||||||
"UPDATE fault_records SET status = 'resolved', recovery_time = ?, duration_seconds = ? WHERE id = ?"
|
|
||||||
).run(ticket.close_time, durationSec, fault.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 清理 reminder_state(无对应记录时是安全 no-op)
|
|
||||||
db.prepare("DELETE FROM reminder_state WHERE order_number = ?").run(ticket.ticket_no)
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
// src/lib/monitor/scan-state.ts
|
|
||||||
// 全局扫描状态管理(与 scan-emails 和 scan-status 路由共享)
|
|
||||||
|
|
||||||
export 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模块级变量存储扫描状态
|
|
||||||
let scanState: ScanResult | null = null
|
|
||||||
|
|
||||||
// 取消标志
|
|
||||||
let cancelRequested = false
|
|
||||||
|
|
||||||
export function getScanState(): ScanResult | null {
|
|
||||||
return scanState
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setScanState(state: ScanResult | null): void {
|
|
||||||
scanState = state
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isCancelRequested(): boolean {
|
|
||||||
return cancelRequested
|
|
||||||
}
|
|
||||||
|
|
||||||
export function requestCancel(): void {
|
|
||||||
cancelRequested = true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetCancelFlag(): void {
|
|
||||||
cancelRequested = false
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// src/lib/monitor/settings-manager.ts
|
// src/lib/monitor/settings-manager.ts
|
||||||
import { getDb } from '@/lib/db'
|
import { getDb } from '@/lib/db'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import type { MonitorConfig, WebhookConfig } from './types'
|
import type { MonitorConfig } from './types'
|
||||||
|
|
||||||
const ALGORITHM = 'aes-256-cbc'
|
const ALGORITHM = 'aes-256-cbc'
|
||||||
|
|
||||||
|
|
@ -54,53 +54,20 @@ export function setSetting(key: string, value: string, category = 'general'): vo
|
||||||
).run(key, value, category)
|
).run(key, value, category)
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_WEBHOOK: WebhookConfig = {
|
|
||||||
id: 'default',
|
|
||||||
name: '默认推送',
|
|
||||||
url: '',
|
|
||||||
enabled: true,
|
|
||||||
params: { interval_seconds: 60, push_delay_ms: 2000, silent_start_hour: 0, silent_end_hour: 7 },
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_CONFIG: MonitorConfig = {
|
const DEFAULT_CONFIG: MonitorConfig = {
|
||||||
monitor: { enabled: false, interval_seconds: 60, push_delay_ms: 2000, silent_start_hour: 0, silent_end_hour: 7 },
|
monitor: { enabled: false, interval_seconds: 60, push_delay_ms: 2000, silent_start_hour: 0, silent_end_hour: 7 },
|
||||||
mail: { address: 'gxp@qx002575.com', imap_server: 'imaphz.qiye.163.com', imap_port: 993, pop3_server: 'pophz.qiye.163.com', pop3_port: 995, smtp_server: 'smtphz.qiye.163.com', smtp_port: 465, password: '' },
|
mail: { address: 'gxp@qx002575.com', imap_server: 'imaphz.qiye.163.com', imap_port: 993, smtp_server: 'smtphz.qiye.163.com', smtp_port: 465, password: '' },
|
||||||
filter: {
|
filter: {
|
||||||
subject_keywords: ['服务器故障单'],
|
subject_keywords: ['服务器故障单'],
|
||||||
sender_email: 'tencent_dcops@tencent.com',
|
sender_email: 'tencent_dcops@tencent.com',
|
||||||
oem_repair_keywords: { base_keyword: '服务器故障单', type_keyword: 'OEM维修', exclude_keyword: 'OEM诊断' },
|
oem_repair_keywords: { base_keyword: '服务器故障单', type_keyword: 'OEM维修', exclude_keyword: 'OEM诊断' },
|
||||||
oem_diag_keywords: { base_keyword: '服务器故障单', type_keyword: 'OEM诊断', exclude_keyword: 'OEM维修' },
|
oem_diag_keywords: { base_keyword: '服务器故障单', type_keyword: 'OEM诊断', exclude_keyword: 'OEM维修' },
|
||||||
},
|
},
|
||||||
wechat: { webhooks: [JSON.parse(JSON.stringify(DEFAULT_WEBHOOK))] },
|
wechat: { webhook_url: '' },
|
||||||
}
|
|
||||||
|
|
||||||
export function generateWebhookId(): string {
|
|
||||||
return 'wh_' + crypto.randomBytes(8).toString('hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将旧的 wechat.webhook_url 迁移为 wechat.webhooks 数组
|
|
||||||
function migrateWebhookSettings(db: ReturnType<typeof getDb>): void {
|
|
||||||
const legacyUrl = db.prepare("SELECT value FROM settings WHERE key = 'wechat.webhook_url'").get() as { value: string } | undefined
|
|
||||||
if (legacyUrl?.value) {
|
|
||||||
const webhook: WebhookConfig = {
|
|
||||||
id: generateWebhookId(),
|
|
||||||
name: '默认推送',
|
|
||||||
url: legacyUrl.value,
|
|
||||||
enabled: true,
|
|
||||||
params: { interval_seconds: 60, push_delay_ms: 2000, silent_start_hour: 0, silent_end_hour: 7 },
|
|
||||||
}
|
|
||||||
setSetting('wechat.webhooks', JSON.stringify([webhook]), 'wechat')
|
|
||||||
db.prepare("DELETE FROM settings WHERE key = 'wechat.webhook_url'").run()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMonitorConfig(): MonitorConfig {
|
export function getMonitorConfig(): MonitorConfig {
|
||||||
const config = JSON.parse(JSON.stringify(DEFAULT_CONFIG))
|
const config = JSON.parse(JSON.stringify(DEFAULT_CONFIG))
|
||||||
const db = getDb()
|
|
||||||
|
|
||||||
// 迁移旧格式
|
|
||||||
migrateWebhookSettings(db)
|
|
||||||
|
|
||||||
const mappings: Record<string, (v: string) => void> = {
|
const mappings: Record<string, (v: string) => void> = {
|
||||||
'monitor.enabled': (v) => { config.monitor.enabled = v === 'true' },
|
'monitor.enabled': (v) => { config.monitor.enabled = v === 'true' },
|
||||||
'monitor.interval_seconds': (v) => { config.monitor.interval_seconds = parseInt(v) || 60 },
|
'monitor.interval_seconds': (v) => { config.monitor.interval_seconds = parseInt(v) || 60 },
|
||||||
|
|
@ -110,8 +77,6 @@ export function getMonitorConfig(): MonitorConfig {
|
||||||
'mail.address': (v) => { config.mail.address = v },
|
'mail.address': (v) => { config.mail.address = v },
|
||||||
'mail.imap_server': (v) => { config.mail.imap_server = v },
|
'mail.imap_server': (v) => { config.mail.imap_server = v },
|
||||||
'mail.imap_port': (v) => { config.mail.imap_port = parseInt(v) || 993 },
|
'mail.imap_port': (v) => { config.mail.imap_port = parseInt(v) || 993 },
|
||||||
'mail.pop3_server': (v) => { config.mail.pop3_server = v },
|
|
||||||
'mail.pop3_port': (v) => { config.mail.pop3_port = parseInt(v) || 995 },
|
|
||||||
'mail.smtp_server': (v) => { config.mail.smtp_server = v },
|
'mail.smtp_server': (v) => { config.mail.smtp_server = v },
|
||||||
'mail.smtp_port': (v) => { config.mail.smtp_port = parseInt(v) || 465 },
|
'mail.smtp_port': (v) => { config.mail.smtp_port = parseInt(v) || 465 },
|
||||||
'mail.password': (v) => { config.mail.password = v ? decryptValue(v) : '' },
|
'mail.password': (v) => { config.mail.password = v ? decryptValue(v) : '' },
|
||||||
|
|
@ -119,13 +84,9 @@ export function getMonitorConfig(): MonitorConfig {
|
||||||
'filter.sender_email': (v) => { config.filter.sender_email = v },
|
'filter.sender_email': (v) => { config.filter.sender_email = v },
|
||||||
'filter.oem_repair_keywords': (v) => { try { config.filter.oem_repair_keywords = JSON.parse(v) } catch {} },
|
'filter.oem_repair_keywords': (v) => { try { config.filter.oem_repair_keywords = JSON.parse(v) } catch {} },
|
||||||
'filter.oem_diag_keywords': (v) => { try { config.filter.oem_diag_keywords = JSON.parse(v) } catch {} },
|
'filter.oem_diag_keywords': (v) => { try { config.filter.oem_diag_keywords = JSON.parse(v) } catch {} },
|
||||||
'wechat.webhooks': (v) => {
|
'wechat.webhook_url': (v) => { config.wechat.webhook_url = v },
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(v)
|
|
||||||
if (Array.isArray(parsed) && parsed.length > 0) config.wechat.webhooks = parsed
|
|
||||||
} catch {}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
const db = getDb()
|
||||||
const rows = db.prepare("SELECT key, value FROM settings WHERE key LIKE 'monitor.%' OR key LIKE 'mail.%' OR key LIKE 'filter.%' OR key LIKE 'wechat.%'").all() as { key: string; value: string }[]
|
const rows = db.prepare("SELECT key, value FROM settings WHERE key LIKE 'monitor.%' OR key LIKE 'mail.%' OR key LIKE 'filter.%' OR key LIKE 'wechat.%'").all() as { key: string; value: string }[]
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
mappings[row.key]?.(row.value)
|
mappings[row.key]?.(row.value)
|
||||||
|
|
@ -153,16 +114,9 @@ export function updateMonitorConfig(updates: Record<string, unknown>): void {
|
||||||
export function getMaskedConfig(): MonitorConfig {
|
export function getMaskedConfig(): MonitorConfig {
|
||||||
const config = getMonitorConfig()
|
const config = getMonitorConfig()
|
||||||
if (config.mail.password) config.mail.password = '••••••••'
|
if (config.mail.password) config.mail.password = '••••••••'
|
||||||
// 深拷贝 webhooks 数组,避免 mask 操作污染 getMonitorConfig() 返回的原对象
|
if (config.wechat.webhook_url) {
|
||||||
// (前端拿到 masked URL 后若不重新输入就测试,会用 •••••••• 调企业微信导致 93000 invalid url)
|
const keyMatch = config.wechat.webhook_url.match(/key=([0-9a-f-]+)/)
|
||||||
config.wechat.webhooks = config.wechat.webhooks.map(wh => {
|
if (keyMatch) config.wechat.webhook_url = config.wechat.webhook_url.replace(keyMatch[1], '••••••••')
|
||||||
if (wh.url) {
|
|
||||||
const keyMatch = wh.url.match(/key=([0-9a-f-]+)/)
|
|
||||||
if (keyMatch) {
|
|
||||||
return { ...wh, url: wh.url.replace(keyMatch[1], '••••••••') }
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return wh
|
|
||||||
})
|
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,270 +0,0 @@
|
||||||
// src/lib/monitor/ticket-notifier.ts
|
|
||||||
import { getMonitorConfig } from './settings-manager'
|
|
||||||
import { WeChatPusher } from './wechat-pusher'
|
|
||||||
import { AvailabilityEngine } from './availability-engine'
|
|
||||||
import { getRackPosition } from '@/lib/assets-client'
|
|
||||||
import { getDb } from '@/lib/db'
|
|
||||||
import { formatBeijingTime } from './types'
|
|
||||||
|
|
||||||
export interface TicketNotifyInput {
|
|
||||||
ticket_no: string
|
|
||||||
device_ip: string | null
|
|
||||||
device_sn: string | null
|
|
||||||
device_name: string | null
|
|
||||||
ticket_type: string | null
|
|
||||||
content: string | null
|
|
||||||
assign_time: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const WEBHOOK_GAP_MS = 300
|
|
||||||
const RATE_WINDOW_MS = 60_000
|
|
||||||
const RATE_MAX = 15
|
|
||||||
|
|
||||||
const recentPushed = new Map<string, number>()
|
|
||||||
let windowStart = 0
|
|
||||||
let windowCount = 0
|
|
||||||
|
|
||||||
function isValidAssignTime(t: string | null): boolean {
|
|
||||||
return !!t && /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/.test(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
function trunc(s: string | null, n: number): string | null {
|
|
||||||
if (!s) return s
|
|
||||||
return s.length > n ? s.slice(0, n) : s
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBasicMessage(t: TicketNotifyInput): string {
|
|
||||||
let msg = '【新建工单】\n\n'
|
|
||||||
msg += `工单号:${t.ticket_no}\n`
|
|
||||||
if (t.device_ip) msg += `服务器IP:${trunc(t.device_ip, 64)}\n`
|
|
||||||
if (t.device_sn) msg += `服务器SN:${trunc(t.device_sn, 64)}\n`
|
|
||||||
if (t.device_name) msg += `设备名称:${trunc(t.device_name, 128)}\n`
|
|
||||||
if (t.ticket_type) msg += `工单类型:${trunc(t.ticket_type, 32)}\n`
|
|
||||||
if (t.assign_time) msg += `派单时间:${t.assign_time}\n`
|
|
||||||
if (t.content) msg += `工单内容:${trunc(t.content, 500)}\n`
|
|
||||||
return msg.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 格式化处理时长为 "X小时Y分钟"(从分钟数转换) */
|
|
||||||
function formatDuration(minutes: number): string {
|
|
||||||
const h = Math.floor(minutes / 60)
|
|
||||||
const m = minutes % 60
|
|
||||||
return `${h}小时${m}分钟`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 构建 OEM 诊断结单消息 */
|
|
||||||
function buildOemDiagResolvedMessage(ticket: ResolvedTicketInfo, rack: string | null): string {
|
|
||||||
let msg = `✅ ${ticket.ticket_no}工单已结单\n\n`
|
|
||||||
msg += `服务器IP:${ticket.device_ip || '未知'}\n`
|
|
||||||
msg += `服务器SN:${ticket.device_sn || '未知'}\n`
|
|
||||||
if (rack) msg += `机架位置:${rack}\n`
|
|
||||||
msg += `故障类型:OEM诊断\n`
|
|
||||||
if (ticket.assign_time) msg += `故障时间:${ticket.assign_time}\n`
|
|
||||||
if (ticket.close_time) msg += `结单时间:${ticket.close_time}\n`
|
|
||||||
msg += `本次处理时长:${formatDuration(ticket.duration_minutes || 0)}`
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 构建 OEM 维修结单消息(月度统计按 close_time 月 + tickets 表 + isRealFault 过滤) */
|
|
||||||
function buildOemRepairResolvedMessage(ticket: ResolvedTicketInfo, rack: string | null): string {
|
|
||||||
const db = getDb()
|
|
||||||
const closeMonth = ticket.close_time ? ticket.close_time.substring(0, 7) : null
|
|
||||||
|
|
||||||
let monthCount = 0
|
|
||||||
let monthTotalMin = 0
|
|
||||||
if (closeMonth && ticket.device_sn) {
|
|
||||||
const countRow = db.prepare(`
|
|
||||||
SELECT COUNT(*) as cnt FROM tickets
|
|
||||||
WHERE device_sn = ? AND current_status IN ('resolved','closed')
|
|
||||||
AND fault_category IS NOT NULL AND fault_category NOT IN ('无故障','误判')
|
|
||||||
AND strftime('%Y-%m', close_time) = ?
|
|
||||||
`).get(ticket.device_sn, closeMonth) as { cnt: number } | undefined
|
|
||||||
monthCount = countRow?.cnt ?? 0
|
|
||||||
|
|
||||||
const durationRow = db.prepare(`
|
|
||||||
SELECT COALESCE(SUM(duration_minutes), 0) as total_min FROM tickets
|
|
||||||
WHERE device_sn = ? AND current_status IN ('resolved','closed')
|
|
||||||
AND fault_category IS NOT NULL AND fault_category NOT IN ('无故障','误判')
|
|
||||||
AND strftime('%Y-%m', close_time) = ?
|
|
||||||
`).get(ticket.device_sn, closeMonth) as { total_min: number } | undefined
|
|
||||||
monthTotalMin = durationRow?.total_min ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const thisDurationMin = ticket.duration_minutes || 0
|
|
||||||
const totalMin = monthTotalMin + thisDurationMin
|
|
||||||
const totalHours = Math.floor(totalMin / 60)
|
|
||||||
const totalMinutes = Math.floor(totalMin % 60)
|
|
||||||
|
|
||||||
let msg = `✅ ${ticket.ticket_no}工单已结单\n\n`
|
|
||||||
msg += `服务器IP:${ticket.device_ip || '未知'}\n`
|
|
||||||
msg += `服务器SN:${ticket.device_sn || '未知'}\n`
|
|
||||||
if (rack) msg += `机架位置:${rack}\n`
|
|
||||||
msg += `故障类型:OEM维修\n`
|
|
||||||
if (ticket.assign_time) msg += `故障时间:${ticket.assign_time}\n`
|
|
||||||
if (ticket.close_time) msg += `结单时间:${ticket.close_time}\n`
|
|
||||||
msg += `本次处理时长:${formatDuration(thisDurationMin)}\n`
|
|
||||||
msg += `本月故障次数:${monthCount + 1}次\n`
|
|
||||||
msg += `本月总处理时长:${totalHours}小时${totalMinutes}分钟`
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 构建基础结单消息(非 OEM 工单) */
|
|
||||||
function buildBasicResolvedMessage(ticket: ResolvedTicketInfo, rack: string | null): string {
|
|
||||||
let msg = `✅ ${ticket.ticket_no}工单已结单\n\n`
|
|
||||||
msg += `服务器IP:${ticket.device_ip || '未知'}\n`
|
|
||||||
msg += `服务器SN:${ticket.device_sn || '未知'}\n`
|
|
||||||
if (ticket.device_name) msg += `设备名称:${trunc(ticket.device_name, 128)}\n`
|
|
||||||
if (rack) msg += `机架位置:${rack}\n`
|
|
||||||
if (ticket.fault_category) msg += `故障大类:${ticket.fault_category}\n`
|
|
||||||
if (ticket.assign_time) msg += `故障时间:${ticket.assign_time}\n`
|
|
||||||
if (ticket.close_time) msg += `结单时间:${ticket.close_time}\n`
|
|
||||||
msg += `本次处理时长:${formatDuration(ticket.duration_minutes || 0)}`
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getRackPositionSafe(ip: string | null, sn: string | null): Promise<string | null> {
|
|
||||||
// 注:assets-client.getRackPosition 内部有 encodeURIComponent,但【无】超时。
|
|
||||||
// 这里用 Promise.race 加 5s 超时兜底(assets 不可达时 fetch 会挂到 Node 默认 TCP 超时 30-120s)。
|
|
||||||
if (!ip && !sn) return null
|
|
||||||
try {
|
|
||||||
const timeout = new Promise<null>(resolve => setTimeout(() => resolve(null), 5000))
|
|
||||||
return await Promise.race([getRackPosition(ip, sn), timeout])
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function allowPush(ticketNo: string): boolean {
|
|
||||||
const now = Date.now()
|
|
||||||
const last = recentPushed.get(ticketNo)
|
|
||||||
if (last && now - last < RATE_WINDOW_MS) return false
|
|
||||||
if (now - windowStart >= RATE_WINDOW_MS) { windowStart = now; windowCount = 0 }
|
|
||||||
if (windowCount >= RATE_MAX) {
|
|
||||||
console.error(`[Notifier] 限频丢弃推送: ${ticketNo}(1分钟内超 ${RATE_MAX} 条)`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
windowCount++
|
|
||||||
recentPushed.set(ticketNo, now)
|
|
||||||
for (const [k, ts] of recentPushed) { if (now - ts >= RATE_WINDOW_MS) recentPushed.delete(k) }
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取当前启用的 webhook(动态,后加的自动生效)
|
|
||||||
function getEnabledHooks(): { enabled: boolean; url: string }[] {
|
|
||||||
const config = getMonitorConfig()
|
|
||||||
return config.wechat.webhooks.filter(wh => wh.enabled && wh.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 依次推送到给定 webhook 列表,webhook 间加固定小间隔节流
|
|
||||||
async function pushHooks(hooks: { url: string }[], message: string): Promise<void> {
|
|
||||||
const pusher = new WeChatPusher()
|
|
||||||
for (let i = 0; i < hooks.length; i++) {
|
|
||||||
await pusher.pushText(message, hooks[i].url)
|
|
||||||
if (i < hooks.length - 1) await new Promise(r => setTimeout(r, WEBHOOK_GAP_MS))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function notifyTicketCreated(t: TicketNotifyInput): Promise<void> {
|
|
||||||
try {
|
|
||||||
// 先检查是否有启用的 webhook:无则直接返回,不消耗限频计数
|
|
||||||
const hooks = getEnabledHooks()
|
|
||||||
if (hooks.length === 0) return
|
|
||||||
if (!allowPush(t.ticket_no)) return
|
|
||||||
const rack = await getRackPositionSafe(t.device_ip, t.device_sn)
|
|
||||||
const faultInfo = {
|
|
||||||
// server_ip/server_sn 进入消息前截断(formatAvailabilityMessage 为唯一真源不可改,故在此截断)
|
|
||||||
server_ip: t.device_ip ? t.device_ip.slice(0, 64) : null,
|
|
||||||
server_sn: t.device_sn ? t.device_sn.slice(0, 64) : null,
|
|
||||||
fault_time: isValidAssignTime(t.assign_time) ? t.assign_time!.replace('T', ' ').slice(0, 19) : null,
|
|
||||||
order_number: t.ticket_no, fault_detail: t.content,
|
|
||||||
}
|
|
||||||
let message: string
|
|
||||||
const normTime = faultInfo.fault_time
|
|
||||||
if (t.ticket_type === 'OEM诊断' && normTime) {
|
|
||||||
// 仅 OEM 分支才需要可用性引擎与推送器
|
|
||||||
const engine = new AvailabilityEngine()
|
|
||||||
const pusher = new WeChatPusher()
|
|
||||||
const monthKey = normTime.slice(0, 7)
|
|
||||||
const oemDeadline = engine.calculateOemDiagDeadline(monthKey, normTime)
|
|
||||||
message = pusher.formatAvailabilityMessage({}, faultInfo, true, oemDeadline, t.ticket_no, rack, t.content ? t.content.slice(0, 500) : null)
|
|
||||||
} else if (t.ticket_type === 'OEM维修' && t.device_sn && normTime) {
|
|
||||||
const engine = new AvailabilityEngine()
|
|
||||||
const pusher = new WeChatPusher()
|
|
||||||
const monthKey = normTime.slice(0, 7)
|
|
||||||
// 可用性计算用原始 device_sn(未截断),保证匹配 fault_records
|
|
||||||
const deadlines = engine.calculateTierDeadlines(monthKey, t.device_sn, normTime)
|
|
||||||
message = pusher.formatAvailabilityMessage(deadlines, faultInfo, false, null, t.ticket_no, rack, t.content ? t.content.slice(0, 500) : null)
|
|
||||||
} else {
|
|
||||||
message = formatBasicMessage(t)
|
|
||||||
}
|
|
||||||
await pushHooks(hooks, message)
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[Notifier] notifyTicketCreated 失败 (${t.ticket_no}): ${e instanceof Error ? e.message : e}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function notifyBatchSummary(ticketNos: string[]): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (!ticketNos || ticketNos.length === 0) return
|
|
||||||
const hooks = getEnabledHooks()
|
|
||||||
if (hooks.length === 0) return
|
|
||||||
const shown = ticketNos.slice(0, 20)
|
|
||||||
let list = shown.join('、')
|
|
||||||
if (ticketNos.length > 20) list += `等 ${ticketNos.length} 张`
|
|
||||||
const message = `【批量导入工单】\n\n本次导入 ${ticketNos.length} 张工单:\n${list}\n导入时间:${formatBeijingTime()}`
|
|
||||||
await pushHooks(hooks, message)
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[Notifier] notifyBatchSummary 失败: ${e instanceof Error ? e.message : e}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResolvedTicketInfo {
|
|
||||||
ticket_no: string
|
|
||||||
device_ip: string | null
|
|
||||||
device_sn: string | null
|
|
||||||
device_name: string | null
|
|
||||||
ticket_type: string | null
|
|
||||||
fault_category: string | null
|
|
||||||
assign_time: string | null
|
|
||||||
close_time: string | null
|
|
||||||
duration_minutes: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 结单/恢复推送。当工单状态从非终态变为 resolved/closed 时调用。
|
|
||||||
* fire-and-forget,全程 try/catch。
|
|
||||||
*/
|
|
||||||
export async function notifyTicketResolved(ticketId: number): Promise<void> {
|
|
||||||
try {
|
|
||||||
const db = getDb()
|
|
||||||
const ticket = db.prepare(`
|
|
||||||
SELECT ticket_no, device_ip, device_sn, device_name, ticket_type,
|
|
||||||
fault_category, assign_time, close_time, duration_minutes
|
|
||||||
FROM tickets WHERE id = ?
|
|
||||||
`).get(ticketId) as ResolvedTicketInfo | undefined
|
|
||||||
if (!ticket) return
|
|
||||||
|
|
||||||
// 只推送"真实故障"结单
|
|
||||||
if (!ticket.fault_category
|
|
||||||
|| ticket.fault_category === '无故障'
|
|
||||||
|| ticket.fault_category === '误判') return
|
|
||||||
|
|
||||||
const hooks = getEnabledHooks()
|
|
||||||
if (hooks.length === 0) return
|
|
||||||
|
|
||||||
const rack = await getRackPositionSafe(ticket.device_ip, ticket.device_sn)
|
|
||||||
|
|
||||||
let message: string
|
|
||||||
if (ticket.ticket_type === 'OEM诊断') {
|
|
||||||
message = buildOemDiagResolvedMessage(ticket, rack)
|
|
||||||
} else if (ticket.ticket_type === 'OEM维修') {
|
|
||||||
message = buildOemRepairResolvedMessage(ticket, rack)
|
|
||||||
} else {
|
|
||||||
message = buildBasicResolvedMessage(ticket, rack)
|
|
||||||
}
|
|
||||||
|
|
||||||
await pushHooks(hooks, message)
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[Notifier] notifyTicketResolved 失败 (id=${ticketId}): ${e instanceof Error ? e.message : e}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -52,19 +52,6 @@ export interface MonitorLog {
|
||||||
created_at?: string
|
created_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MonitorConfig {
|
export interface MonitorConfig {
|
||||||
monitor: {
|
monitor: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
|
@ -77,8 +64,6 @@ export interface MonitorConfig {
|
||||||
address: string
|
address: string
|
||||||
imap_server: string
|
imap_server: string
|
||||||
imap_port: number
|
imap_port: number
|
||||||
pop3_server: string
|
|
||||||
pop3_port: number
|
|
||||||
smtp_server: string
|
smtp_server: string
|
||||||
smtp_port: number
|
smtp_port: number
|
||||||
password: string
|
password: string
|
||||||
|
|
@ -90,7 +75,7 @@ export interface MonitorConfig {
|
||||||
oem_diag_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string }
|
oem_diag_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string }
|
||||||
}
|
}
|
||||||
wechat: {
|
wechat: {
|
||||||
webhooks: WebhookConfig[]
|
webhook_url: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// src/lib/monitor/wechat-pusher.ts — 引用共享 WeChatPusher,保留本地业务方法
|
// src/lib/monitor/wechat-pusher.ts
|
||||||
import { WeChatPusher as SharedWeChatPusher } from '@shared/lib/wechat/wechat-pusher'
|
import type { MonitorConfig } from './types'
|
||||||
import { formatBeijingTime } from './types'
|
import { formatBeijingTime } from './types'
|
||||||
|
|
||||||
const logger = {
|
const logger = {
|
||||||
|
|
@ -7,34 +7,30 @@ const logger = {
|
||||||
error: (msg: string) => console.error(`[Worker] ${formatBeijingTime()} ERROR ${msg}`),
|
error: (msg: string) => console.error(`[Worker] ${formatBeijingTime()} ERROR ${msg}`),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用共享 WeChatPusher,保持原有 pushText(text, webhookUrl) 签名
|
|
||||||
export class WeChatPusher {
|
export class WeChatPusher {
|
||||||
async pushText(text: string, webhookUrl: string): Promise<boolean> {
|
async pushText(text: string, webhookUrl: string): Promise<boolean> {
|
||||||
if (!webhookUrl) { logger.error('Webhook URL not configured'); return false }
|
if (!webhookUrl) { logger.error('Webhook URL not configured'); return false }
|
||||||
try {
|
try {
|
||||||
const pusher = new SharedWeChatPusher(webhookUrl)
|
const response = await fetch(webhookUrl, {
|
||||||
const result = await pusher.pushText(text)
|
method: 'POST',
|
||||||
if (!result.success) { logger.error(`Webhook HTTP ${result.responseCode}`); return false }
|
headers: { 'Content-Type': 'application/json' },
|
||||||
// 检查 WeChat API 级错误码
|
body: JSON.stringify({ msgtype: 'text', text: { content: text } }),
|
||||||
try {
|
signal: AbortSignal.timeout(5_000),
|
||||||
const apiResult = JSON.parse(result.responseBody || '{}') as { errcode?: number; errmsg?: string }
|
})
|
||||||
if (apiResult.errcode && apiResult.errcode !== 0) {
|
if (!response.ok) { logger.error(`Webhook HTTP ${response.status}`); return false }
|
||||||
logger.error(`WeChat error: ${apiResult.errmsg} (code: ${apiResult.errcode})`)
|
const result = await response.json() as { errcode?: number; errmsg?: string }
|
||||||
|
if (result.errcode === 0) { logger.info('WeChat message sent'); return true }
|
||||||
|
logger.error(`WeChat error: ${result.errmsg}`)
|
||||||
return false
|
return false
|
||||||
}
|
|
||||||
} catch { /* 解析失败视为成功(HTTP 200) */ }
|
|
||||||
logger.info('WeChat message sent')
|
|
||||||
return true
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`Webhook failed: ${e instanceof Error ? e.message : e}`)
|
logger.error(`Webhook failed: ${e instanceof Error ? e.message : e}`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保留 issue-ai 专有的业务格式化方法
|
|
||||||
formatAvailabilityMessage(
|
formatAvailabilityMessage(
|
||||||
deadlines: Record<string, { deadline: string; hours: number }>,
|
deadlines: Record<string, { deadline: string; hours: number }>,
|
||||||
faultInfo: { server_ip: string | null; server_sn: string | null; fault_time: string | null; order_number?: string | null; fault_detail?: string | null },
|
faultInfo: { server_ip: string | null; server_sn: string | null; fault_time: string | null },
|
||||||
isOemDiag: boolean,
|
isOemDiag: boolean,
|
||||||
oemDeadline: string | null,
|
oemDeadline: string | null,
|
||||||
orderNumber: string | null,
|
orderNumber: string | null,
|
||||||
|
|
@ -49,14 +45,12 @@ export class WeChatPusher {
|
||||||
msg += `服务器SN:${faultInfo.server_sn || '未知'}\n`
|
msg += `服务器SN:${faultInfo.server_sn || '未知'}\n`
|
||||||
msg += `机架位置:${rackPosition || '未知'}\n`
|
msg += `机架位置:${rackPosition || '未知'}\n`
|
||||||
if (faultInfo.fault_time) msg += `故障时间:${faultInfo.fault_time}\n`
|
if (faultInfo.fault_time) msg += `故障时间:${faultInfo.fault_time}\n`
|
||||||
const truncatedDetail = faultDetail ? faultDetail.slice(0, 500) : null
|
if (faultDetail) msg += `故障信息:${faultDetail}\n`
|
||||||
|
|
||||||
if (isOemDiag && oemDeadline) {
|
if (isOemDiag && oemDeadline) {
|
||||||
msg += `到期时间:${oemDeadline}\n`
|
msg += `到期时间:${oemDeadline}\n`
|
||||||
if (truncatedDetail) msg += `故障信息:${truncatedDetail}\n`
|
|
||||||
} else if (!isOemDiag) {
|
} else if (!isOemDiag) {
|
||||||
if (truncatedDetail) msg += `故障信息:${truncatedDetail}\n`
|
msg += '\n📊 服务可用性各档位截止时间:\n\n'
|
||||||
msg += '\n📊 服务可用性各档位截止时间(基于月度内累计故障时长计算):\n\n'
|
|
||||||
const tierDesc: Record<string, string> = {
|
const tierDesc: Record<string, string> = {
|
||||||
'第一档': '99%(含)以上(不扣费)',
|
'第一档': '99%(含)以上(不扣费)',
|
||||||
'第二档': '99%-97%(含)(扣10%)',
|
'第二档': '99%-97%(含)(扣10%)',
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { WeChatPusher } from './wechat-pusher'
|
||||||
import { AvailabilityEngine } from './availability-engine'
|
import { AvailabilityEngine } from './availability-engine'
|
||||||
import { ReminderEngine } from './reminder-engine'
|
import { ReminderEngine } from './reminder-engine'
|
||||||
import { getMonitorConfig, getSetting, setSetting } from './settings-manager'
|
import { getMonitorConfig, getSetting, setSetting } from './settings-manager'
|
||||||
import type { MonitorConfig, WebhookConfig } from './types'
|
import type { MonitorConfig } from './types'
|
||||||
import { formatBeijingTime } from './types'
|
import { formatBeijingTime } from './types'
|
||||||
|
|
||||||
const logger = {
|
const logger = {
|
||||||
|
|
@ -26,29 +26,6 @@ export class BackgroundWorker {
|
||||||
private lastTickTime = 0
|
private lastTickTime = 0
|
||||||
private db = getDb()
|
private db = getDb()
|
||||||
|
|
||||||
// 推送消息到所有启用的 webhook
|
|
||||||
private async pushToAllWebhooks(text: string, config: MonitorConfig): Promise<boolean> {
|
|
||||||
const enabledHooks = config.wechat.webhooks.filter(wh => wh.enabled && wh.url)
|
|
||||||
if (enabledHooks.length === 0) return false
|
|
||||||
let anySuccess = false
|
|
||||||
for (const wh of enabledHooks) {
|
|
||||||
const ok = await this.wechatPusher.pushText(text, wh.url)
|
|
||||||
if (ok) anySuccess = true
|
|
||||||
}
|
|
||||||
return anySuccess
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取指定 webhook 的参数(如无匹配则用全局 monitor 参数)
|
|
||||||
private getWebhookParams(wh: WebhookConfig, config: MonitorConfig): MonitorConfig['monitor'] {
|
|
||||||
return {
|
|
||||||
enabled: config.monitor.enabled,
|
|
||||||
interval_seconds: wh.params?.interval_seconds ?? config.monitor.interval_seconds,
|
|
||||||
push_delay_ms: wh.params?.push_delay_ms ?? config.monitor.push_delay_ms,
|
|
||||||
silent_start_hour: wh.params?.silent_start_hour ?? config.monitor.silent_start_hour,
|
|
||||||
silent_end_hour: wh.params?.silent_end_hour ?? config.monitor.silent_end_hour,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
if (this.cronJob) return
|
if (this.cronJob) return
|
||||||
this.cronJob = cron.schedule('* * * * *', () => { this.tick() })
|
this.cronJob = cron.schedule('* * * * *', () => { this.tick() })
|
||||||
|
|
@ -87,28 +64,28 @@ export class BackgroundWorker {
|
||||||
}
|
}
|
||||||
this.lastTickTime = Date.now()
|
this.lastTickTime = Date.now()
|
||||||
|
|
||||||
// Step 3: 7 点 flush
|
// Step 3: 检查恢复
|
||||||
|
await this.checkRecoveryUpdates(config)
|
||||||
|
|
||||||
|
// Step 4: 7 点 flush
|
||||||
if (this.reminderEngine.isTimeToFlush()) {
|
if (this.reminderEngine.isTimeToFlush()) {
|
||||||
await this.reminderEngine.flushPendingMessages(
|
await this.reminderEngine.flushPendingMessages((text) => this.wechatPusher.pushText(text, config.wechat.webhook_url))
|
||||||
(text) => this.pushToAllWebhooks(text, config),
|
|
||||||
(ip, sn) => this.ticketProcessor.getRackPosition(ip, sn)
|
|
||||||
)
|
|
||||||
this.reminderEngine.markFlushed()
|
this.reminderEngine.markFlushed()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4-5: 检查提醒
|
// Step 5-6: 检查提醒
|
||||||
const pushText = (text: string) => this.pushToAllWebhooks(text, config)
|
const pushText = (text: string) => this.wechatPusher.pushText(text, config.wechat.webhook_url)
|
||||||
const getRack = (ip: string | null, sn: string | null) => this.ticketProcessor.getRackPosition(ip, sn)
|
const getRack = (ip: string | null, sn: string | null) => this.ticketProcessor.getRackPosition(ip, sn)
|
||||||
await this.reminderEngine.checkOemDiagReminders(pushText, config.monitor.push_delay_ms, getRack, config)
|
await this.reminderEngine.checkOemDiagReminders(pushText, config.monitor.push_delay_ms, getRack, config)
|
||||||
await this.reminderEngine.checkOemRepairReminders(pushText, config.monitor.push_delay_ms, getRack, config)
|
await this.reminderEngine.checkOemRepairReminders(pushText, config.monitor.push_delay_ms, getRack, config)
|
||||||
|
|
||||||
// Step 6: IMAP 连接
|
// Step 7: IMAP 连接
|
||||||
if (!this.mailMonitor.isConnected()) {
|
if (!this.mailMonitor.isConnected()) {
|
||||||
try { await this.mailMonitor.connect(config.mail) }
|
try { await this.mailMonitor.connect(config.mail) }
|
||||||
catch (e) { await this.mailMonitor.reconnect(config.mail) }
|
catch (e) { await this.mailMonitor.reconnect(config.mail) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 7-8: 搜索并处理邮件
|
// Step 8-9: 搜索并处理邮件
|
||||||
const emails = await this.mailMonitor.fetchUnread(config)
|
const emails = await this.mailMonitor.fetchUnread(config)
|
||||||
let processed = 0, errors = 0
|
let processed = 0, errors = 0
|
||||||
|
|
||||||
|
|
@ -127,10 +104,9 @@ export class BackgroundWorker {
|
||||||
const faultInfo = email.html ? this.mailMonitor.extractFaultInfo(email.html, tableData) : null
|
const faultInfo = email.html ? this.mailMonitor.extractFaultInfo(email.html, tableData) : null
|
||||||
if (!faultInfo?.order_number || !faultInfo?.fault_time) continue
|
if (!faultInfo?.order_number || !faultInfo?.fault_time) continue
|
||||||
|
|
||||||
// 创建工单(返回 null = 工单已存在/未创建,跳过后续推送与记录,防止与 notifier 双推)
|
// 创建工单
|
||||||
const content = faultInfo.fault_detail || ''
|
const content = faultInfo.fault_detail || ''
|
||||||
const createdOrderNo = this.ticketProcessor.createTicketFromEmail(faultInfo, type, config, content)
|
this.ticketProcessor.createTicketFromEmail(faultInfo, type, config, content)
|
||||||
if (!createdOrderNo) continue
|
|
||||||
|
|
||||||
// 获取机架位置
|
// 获取机架位置
|
||||||
const rackPosition = await this.ticketProcessor.getRackPosition(faultInfo.server_ip, faultInfo.server_sn)
|
const rackPosition = await this.ticketProcessor.getRackPosition(faultInfo.server_ip, faultInfo.server_sn)
|
||||||
|
|
@ -147,7 +123,7 @@ export class BackgroundWorker {
|
||||||
|
|
||||||
// 推送微信
|
// 推送微信
|
||||||
const message = this.wechatPusher.formatAvailabilityMessage(deadlines, faultInfo, type === 'oem_diag', oemDeadline, faultInfo.order_number, rackPosition, faultInfo.fault_detail)
|
const message = this.wechatPusher.formatAvailabilityMessage(deadlines, faultInfo, type === 'oem_diag', oemDeadline, faultInfo.order_number, rackPosition, faultInfo.fault_detail)
|
||||||
await this.pushToAllWebhooks(message, config)
|
await this.wechatPusher.pushText(message, config.wechat.webhook_url)
|
||||||
|
|
||||||
// 记录故障
|
// 记录故障
|
||||||
this.ticketProcessor.recordFault({
|
this.ticketProcessor.recordFault({
|
||||||
|
|
@ -213,4 +189,22 @@ export class BackgroundWorker {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async checkRecoveryUpdates(config: MonitorConfig): Promise<void> {
|
||||||
|
const ongoing = this.db.prepare("SELECT * FROM fault_records WHERE status = 'ongoing'").all() as { id: number; order_number: string | null; server_sn: string; server_ip: string | null; fault_time: string }[]
|
||||||
|
for (const fault of ongoing) {
|
||||||
|
if (!fault.order_number) continue
|
||||||
|
const ticket = this.db.prepare('SELECT current_status, close_time FROM tickets WHERE id = ?').get(parseInt(fault.order_number)) as { current_status: string; close_time: string | null } | undefined
|
||||||
|
if (ticket && ['resolved', 'closed'].includes(ticket.current_status)) {
|
||||||
|
const recoveryTime = ticket.close_time || formatBeijingTime(new Date())
|
||||||
|
const faultDate = new Date(fault.fault_time.replace(' ', 'T') + '+08:00')
|
||||||
|
const recoveryDate = new Date(recoveryTime.replace(' ', 'T') + '+08:00')
|
||||||
|
const duration = Math.floor((recoveryDate.getTime() - faultDate.getTime()) / 1000)
|
||||||
|
this.db.prepare("UPDATE fault_records SET status = 'resolved', recovery_time = ?, duration_seconds = ? WHERE id = ?").run(recoveryTime, duration, fault.id)
|
||||||
|
this.reminderEngine.cleanupForTicket(fault.order_number)
|
||||||
|
// 发送结单提醒
|
||||||
|
await this.wechatPusher.pushText(`✅ ${fault.order_number}工单已结单\n\n服务器IP:${fault.server_ip || '未知'}\n服务器SN:${fault.server_sn}\n结单时间:${recoveryTime}\n本次处理时长:${Math.floor(duration / 3600)}小时${Math.floor((duration % 3600) / 60)}分钟`, config.wechat.webhook_url)
|
||||||
|
logger.info(`Recovery detected: ${fault.order_number}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ export async function collectMonthlyReportData(
|
||||||
const dates = getDateRange(periodStart, periodEnd)
|
const dates = getDateRange(periodStart, periodEnd)
|
||||||
// 排除"无故障"分类和"其他"子分类(其他工单不计入节点在线数)
|
// 排除"无故障"分类和"其他"子分类(其他工单不计入节点在线数)
|
||||||
const monthFaults = tickets.filter(t =>
|
const monthFaults = tickets.filter(t =>
|
||||||
t.fault_category !== '无故障' && t.fault_category !== '误判' && t.fault_subcategory !== '其他'
|
t.fault_category !== '无故障' && t.fault_subcategory !== '其他'
|
||||||
)
|
)
|
||||||
|
|
||||||
const dailyStats: DailyOnlineStats[] = dates.map(date => {
|
const dailyStats: DailyOnlineStats[] = dates.map(date => {
|
||||||
|
|
@ -137,7 +137,7 @@ export async function collectMonthlyReportData(
|
||||||
})
|
})
|
||||||
|
|
||||||
// 5. 第二章:运营数据总览(仅 gpu/storage,排除"无故障"和"其他"工单)
|
// 5. 第二章:运营数据总览(仅 gpu/storage,排除"无故障"和"其他"工单)
|
||||||
const gpuStorageTickets = tickets.filter(t => t.device_type !== 'other' && t.fault_category !== '无故障' && t.fault_category !== '误判' && t.fault_subcategory !== '其他')
|
const gpuStorageTickets = tickets.filter(t => t.device_type !== 'other' && t.fault_category !== '无故障' && t.fault_subcategory !== '其他')
|
||||||
const chapter2Map = new Map<string, Chapter2FaultItem[]>()
|
const chapter2Map = new Map<string, Chapter2FaultItem[]>()
|
||||||
for (const t of gpuStorageTickets) {
|
for (const t of gpuStorageTickets) {
|
||||||
const assignDate = t.assign_time.slice(0, 10)
|
const assignDate = t.assign_time.slice(0, 10)
|
||||||
|
|
@ -205,20 +205,23 @@ export async function collectMonthlyReportData(
|
||||||
const storageFaults = storageFaultTickets.map(toFaultEntry)
|
const storageFaults = storageFaultTickets.map(toFaultEntry)
|
||||||
const allOtherTickets = [...otherTickets, ...remainingOthers].map(toOtherEntry)
|
const allOtherTickets = [...otherTickets, ...remainingOthers].map(toOtherEntry)
|
||||||
|
|
||||||
// 7. 第四章:服务可用性说明(进行中工单暂不计入,完整时长归入结单月后统计)
|
// 7. 第四章:服务可用性说明(已结单工单按实际时长,进行中工单按本月部分)
|
||||||
const ipDurationMap = new Map<string, number>()
|
const ipDurationMap = new Map<string, number>()
|
||||||
const ipHasOngoing = new Map<string, boolean>()
|
const ipHasOngoing = new Map<string, boolean>()
|
||||||
for (const t of tickets) {
|
for (const t of tickets) {
|
||||||
if (t.fault_category === '无故障') continue
|
if (t.fault_category === '无故障') continue
|
||||||
const dur = ipDurationMap.get(t.device_ip) || 0
|
const dur = ipDurationMap.get(t.device_ip) || 0
|
||||||
if (t.isOngoing) {
|
if (t.isOngoing) {
|
||||||
// 进行中工单:暂不计入可用性时长,完整时长归入结单月后统计
|
// 进行中工单:计算本月内部分(从 max(assign_date, periodStart) 到月末最后一天)
|
||||||
|
const assignDate = t.assign_time.slice(0, 10)
|
||||||
|
const effectiveStart = assignDate > periodStart ? assignDate : periodStart
|
||||||
|
const affectedDays = daysBetween(effectiveStart, periodEnd) + 1
|
||||||
|
ipDurationMap.set(t.device_ip, dur + affectedDays * 24 * 60)
|
||||||
ipHasOngoing.set(t.device_ip, true)
|
ipHasOngoing.set(t.device_ip, true)
|
||||||
continue
|
} else {
|
||||||
}
|
|
||||||
// 已结单工单:用完整 duration_minutes 归入结单月
|
|
||||||
ipDurationMap.set(t.device_ip, dur + t.duration_minutes)
|
ipDurationMap.set(t.device_ip, dur + t.duration_minutes)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const chapter4: Chapter4Entry[] = []
|
const chapter4: Chapter4Entry[] = []
|
||||||
for (const [ip, totalDuration] of ipDurationMap) {
|
for (const [ip, totalDuration] of ipDurationMap) {
|
||||||
const totalMinutes = monthDays * 24 * 60
|
const totalMinutes = monthDays * 24 * 60
|
||||||
|
|
@ -253,7 +256,7 @@ export async function collectMonthlyReportData(
|
||||||
/** 从采集数据构建 metadata JSON(供 API 路由和 generate 函数共用) */
|
/** 从采集数据构建 metadata JSON(供 API 路由和 generate 函数共用) */
|
||||||
export function buildMonthlyMetadata(data: MonthlyReportData): string {
|
export function buildMonthlyMetadata(data: MonthlyReportData): string {
|
||||||
const gpuStorageTickets = data.tickets.filter(t =>
|
const gpuStorageTickets = data.tickets.filter(t =>
|
||||||
t.device_type !== 'other' && t.fault_category !== '无故障' && t.fault_category !== '误判' && t.fault_subcategory !== '其他'
|
t.device_type !== 'other' && t.fault_category !== '无故障' && t.fault_subcategory !== '其他'
|
||||||
)
|
)
|
||||||
const resolvedTickets = gpuStorageTickets.filter(t => !t.isOngoing)
|
const resolvedTickets = gpuStorageTickets.filter(t => !t.isOngoing)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
import { Issuer } from 'openid-client'
|
|
||||||
import crypto from 'crypto'
|
|
||||||
|
|
||||||
const AUTHELIA_URL = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
|
|
||||||
const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || 'issue-oidc'
|
|
||||||
const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || ''
|
|
||||||
const OIDC_REDIRECT_URI = process.env.OIDC_REDIRECT_URI || 'https://issue.tlyq.ai/api/auth/callback'
|
|
||||||
|
|
||||||
let oidcClient: any = null
|
|
||||||
let lastDiscovery = 0
|
|
||||||
const DISCOVERY_TTL = 3600000 // 1 小时
|
|
||||||
|
|
||||||
export async function getOidcClient() {
|
|
||||||
const now = Date.now()
|
|
||||||
if (oidcClient && (now - lastDiscovery) < DISCOVERY_TTL) {
|
|
||||||
return oidcClient
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const issuer = await Issuer.discover(AUTHELIA_URL)
|
|
||||||
oidcClient = new issuer.Client({
|
|
||||||
client_id: OIDC_CLIENT_ID,
|
|
||||||
client_secret: OIDC_CLIENT_SECRET,
|
|
||||||
redirect_uris: [OIDC_REDIRECT_URI],
|
|
||||||
response_types: ['code'],
|
|
||||||
token_endpoint_auth_method: 'client_secret_basic',
|
|
||||||
})
|
|
||||||
lastDiscovery = now
|
|
||||||
return oidcClient
|
|
||||||
} catch (error) {
|
|
||||||
console.error('OIDC discovery 失败:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generatePKCE() {
|
|
||||||
const codeVerifier = crypto.randomBytes(32).toString('base64url')
|
|
||||||
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url')
|
|
||||||
return { codeVerifier, codeChallenge }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateState() {
|
|
||||||
return crypto.randomBytes(32).toString('base64url')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateNonce() {
|
|
||||||
return crypto.randomBytes(32).toString('base64url')
|
|
||||||
}
|
|
||||||
|
|
@ -92,20 +92,20 @@ export async function collectWeeklyReportData(
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 4. 按规则分三类(是否有真实故障 + 设备类型)
|
// 4. 按规则分三类
|
||||||
const isRealFault = (t: typeof entries[number]): boolean =>
|
// GPU服务器故障:OEM维修 + fault_category != '无故障' + device_type = 'gpu'
|
||||||
t.faultCategory !== null && t.faultCategory !== '无故障' && t.faultCategory !== '误判'
|
|
||||||
|
|
||||||
// GPU服务器故障
|
|
||||||
const gpuFaultTickets = entries.filter(t =>
|
const gpuFaultTickets = entries.filter(t =>
|
||||||
isRealFault(t) && t.deviceType === 'gpu'
|
t.ticketType === 'OEM维修' && t.faultCategory !== '无故障' && t.deviceType === 'gpu'
|
||||||
)
|
)
|
||||||
// 存储服务器故障
|
// 存储服务器故障
|
||||||
const storageFaultTickets = entries.filter(t =>
|
const storageFaultTickets = entries.filter(t =>
|
||||||
isRealFault(t) && t.deviceType === 'storage'
|
t.ticketType === 'OEM维修' && t.faultCategory !== '无故障' && t.deviceType === 'storage'
|
||||||
|
)
|
||||||
|
// 其他工单:OEM诊断 + (OEM维修且fault_category='无故障')
|
||||||
|
const otherTickets = entries.filter(t =>
|
||||||
|
t.ticketType === 'OEM诊断' ||
|
||||||
|
(t.ticketType === 'OEM维修' && t.faultCategory === '无故障')
|
||||||
)
|
)
|
||||||
// 其他工单:无故障、误判、或 deviceType 未匹配
|
|
||||||
const otherTickets = entries.filter(t => !isRealFault(t))
|
|
||||||
|
|
||||||
// 5. 每日在线统计(GPU + 存储分别统计)
|
// 5. 每日在线统计(GPU + 存储分别统计)
|
||||||
const dates = getDateRange(periodStart, periodEnd)
|
const dates = getDateRange(periodStart, periodEnd)
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,93 @@
|
||||||
// src/middleware.ts — V2 单 cookie 模型,Edge 验签 + iss 校验
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { createMiddlewareV2 } from '@shared/lib/auth/middleware-v2'
|
|
||||||
|
|
||||||
const jwtSecret = process.env.JWT_SECRET || 'oa-shared-jwt-secret-tlyq-2026'
|
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
|
try {
|
||||||
|
const parts = token.split('.')
|
||||||
|
if (parts.length !== 3) return null
|
||||||
|
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
while (payload.length % 4) payload += '='
|
||||||
|
return JSON.parse(atob(payload))
|
||||||
|
} catch { return null }
|
||||||
|
}
|
||||||
|
|
||||||
export const middleware = createMiddlewareV2({
|
function isValidPayload(payload: Record<string, unknown> | null): boolean {
|
||||||
jwtSecret,
|
if (!payload) return false
|
||||||
cookieDomain,
|
return !(payload.exp && (payload.exp as number) < Math.floor(Date.now() / 1000))
|
||||||
allowedIssuers: ['*'],
|
}
|
||||||
enableApiKey: true,
|
|
||||||
publicPaths: ['/login', '/api/auth', '/api/health', '/api/internal', '/_next', '/favicon.ico'],
|
// API Key 验证:检查 ALLOWED_API_KEYS 环境变量(逗号分隔明文 key)
|
||||||
|
// 注意:middleware 运行在 Edge Runtime,不能使用 better-sqlite3 等 Node.js 原生模块
|
||||||
|
// DB 级别的 key 验证在 route handler 中进行(auth.ts verifyApiKey)
|
||||||
|
function verifyApiKey(key: string): boolean {
|
||||||
|
if (!key.startsWith('ak_')) return false
|
||||||
|
const allowedKeys = process.env.ALLOWED_API_KEYS || ''
|
||||||
|
if (!allowedKeys) return false
|
||||||
|
return allowedKeys.split(',').map(k => k.trim()).includes(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLoginRedirect(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl
|
||||||
|
const loginUrl = new URL('/login', request.url)
|
||||||
|
const dest = pathname + (request.nextUrl.search || '')
|
||||||
|
loginUrl.searchParams.set('redirect', dest)
|
||||||
|
return NextResponse.redirect(loginUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl
|
||||||
|
|
||||||
|
// 登录/退出路径 + 内部 API 放行(自有 key 认证)
|
||||||
|
if (pathname.startsWith('/login') || pathname === '/' ||
|
||||||
|
pathname === '/api/auth/login' || pathname === '/api/auth/logout' ||
|
||||||
|
pathname.startsWith('/api/internal/')) {
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Key 认证(外部系统调用)
|
||||||
|
const authHeader = request.headers.get('authorization')
|
||||||
|
if (authHeader?.startsWith('Bearer ak_')) {
|
||||||
|
const key = authHeader.slice(7)
|
||||||
|
if (verifyApiKey(key)) return NextResponse.next()
|
||||||
|
// 无效 key:API 路由返回 401
|
||||||
|
if (pathname.startsWith('/api/')) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先检查 tlyq_session(共享 JWT)
|
||||||
|
const sharedToken = request.cookies.get('tlyq_session')?.value
|
||||||
|
const sharedPayload = sharedToken ? decodeJwtPayload(sharedToken) : null
|
||||||
|
if (isValidPayload(sharedPayload)) {
|
||||||
|
const response = pathname.startsWith('/api/') ? NextResponse.next() : NextResponse.next()
|
||||||
|
response.cookies.set('session', JSON.stringify({ username: sharedPayload.username }), {
|
||||||
|
httpOnly: true, sameSite: 'lax', path: '/',
|
||||||
})
|
})
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退 session_issue(本地 JWT)
|
||||||
|
const localToken = request.cookies.get('session_issue')?.value
|
||||||
|
const localPayload = localToken ? decodeJwtPayload(localToken) : null
|
||||||
|
|
||||||
|
if (pathname.startsWith('/api/')) {
|
||||||
|
if (!isValidPayload(localPayload)) {
|
||||||
|
return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||||
|
}
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidPayload(localPayload)) {
|
||||||
|
const response = buildLoginRedirect(request)
|
||||||
|
if (localToken) response.cookies.delete('session_issue')
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = NextResponse.next()
|
||||||
|
response.cookies.set('session', JSON.stringify({ username: localPayload.username }), {
|
||||||
|
httpOnly: true, sameSite: 'lax', path: '/',
|
||||||
|
})
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
|
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: 'class',
|
||||||
|
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
|
||||||
|
theme: { extend: {} },
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
@ -14,8 +14,7 @@
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [{ "name": "next" }],
|
"plugins": [{ "name": "next" }],
|
||||||
"baseUrl": ".",
|
"paths": { "@/*": ["./src/*"] }
|
||||||
"paths": { "@/*": ["./src/*"], "@shared/*": ["./shared/*"] }
|
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue