Compare commits
54 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
46b220d6ca | |
|
|
7346031fc4 | |
|
|
fc8dd6f0c8 | |
|
|
e20189d5f8 | |
|
|
459e19cbb8 | |
|
|
991b7b85c3 | |
|
|
ada95e4e7f | |
|
|
b5e9cfbcd2 | |
|
|
629924c342 | |
|
|
7ea37afc75 | |
|
|
f7bc9fd5fb | |
|
|
28c37f1069 | |
|
|
5152d7d4f2 | |
|
|
c2715d78d3 | |
|
|
f70675412f | |
|
|
5430f51885 | |
|
|
c11e9fbc64 | |
|
|
75ca749db7 | |
|
|
73b442717e | |
|
|
b6f0393450 | |
|
|
d64c950894 | |
|
|
c179e47c6b | |
|
|
c50c06f095 | |
|
|
248ccaeb66 | |
|
|
36aee38160 | |
|
|
6bba264927 | |
|
|
f507adfaa1 | |
|
|
38129bdb67 | |
|
|
02918fa907 | |
|
|
d39d2e65b3 | |
|
|
091ef426db | |
|
|
565f891e1e | |
|
|
d8c65d5121 | |
|
|
24581166bc | |
|
|
3fd09dec25 | |
|
|
b7b2b34a1f | |
|
|
1c1555e417 | |
|
|
5f0c312e9c | |
|
|
703ed1a8d4 | |
|
|
b40802efbf | |
|
|
726c8bee27 | |
|
|
651971e0fe | |
|
|
dce8b0b6cc | |
|
|
2697aaaa75 | |
|
|
2d74f0a05b | |
|
|
152241e666 | |
|
|
48f8084b9b | |
|
|
200fd7d3a1 | |
|
|
a52241f4db | |
|
|
1ae84294bb | |
|
|
4b6bee1868 | |
|
|
01a717e8b2 | |
|
|
f578198cf9 | |
|
|
f692546281 |
|
|
@ -0,0 +1,34 @@
|
|||
# 依赖 —— Dockerfile 内用 npm ci 安装
|
||||
node_modules
|
||||
|
||||
# 构建产物 —— Dockerfile 内用 npm run build 生成
|
||||
.next
|
||||
|
||||
# 运行时数据 —— volume 挂载
|
||||
data
|
||||
uploads
|
||||
reports
|
||||
db-backups
|
||||
|
||||
# 开发工具
|
||||
.claude
|
||||
.playwright-mcp
|
||||
.superpowers
|
||||
.git
|
||||
.gitignore
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
Caddyfile
|
||||
.env
|
||||
.env.*
|
||||
.DS_Store
|
||||
|
||||
# 文档
|
||||
*.md
|
||||
*.png
|
||||
docs
|
||||
CHANGELOG.md
|
||||
README.md
|
||||
|
||||
# 构建缓存
|
||||
tsconfig.tsbuildinfo
|
||||
29
.env.example
29
.env.example
|
|
@ -1,12 +1,21 @@
|
|||
# issue-ai 环境变量(本地开发)
|
||||
DATABASE_PATH=./data/issue.db
|
||||
JWT_SECRET=your-secret-key-change-in-production
|
||||
ADMIN_PASSWORD=admin123
|
||||
ASSETS_API_URL=http://assets-ai:3000/api
|
||||
ASSETS_API_KEY=your-assets-api-key
|
||||
# 允许调用 issue-ai API 的 Key(逗号分隔,支持多个),由 issue-ai 管理界面生成
|
||||
ALLOWED_API_KEYS=your-issue-api-key
|
||||
# NEXT_PUBLIC_ 前缀:构建时内嵌到客户端 JS,云上必须通过 deploy-ai.sh 设置
|
||||
# 本地开发:http://localhost:5177
|
||||
# 云上生产:https://assets.tlyq.ai
|
||||
NEXT_PUBLIC_ASSETS_URL=http://localhost:5177
|
||||
JWT_SECRET=dev-jwt-secret-local
|
||||
COOKIE_DOMAIN=
|
||||
NODE_ENV=development
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
|
||||
# LDAP 配置
|
||||
LDAP_URL=ldap://localhost:3890
|
||||
LDAP_BASE_DN=dc=tlyq,dc=ai
|
||||
LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
|
||||
|
||||
# OIDC 配置
|
||||
AUTHELIA_URL=http://127.0.0.1:6180
|
||||
OIDC_CLIENT_ID=issue-oidc
|
||||
OIDC_CLIENT_SECRET=<见 Authelia 配置>
|
||||
OIDC_REDIRECT_URI=http://127.0.0.1:6176/api/auth/callback
|
||||
|
||||
# 跨服务调用
|
||||
ASSETS_API_URL=http://localhost:6177/api
|
||||
NEXT_PUBLIC_ASSETS_URL=http://localhost:6177
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@ build/
|
|||
.claude/
|
||||
data/
|
||||
uploads/
|
||||
reports/
|
||||
/reports/
|
||||
docs/
|
||||
db-backups/
|
||||
.playwright-mcp/
|
||||
*.tsbuildinfo
|
||||
.env.local
|
||||
.DS_Store
|
||||
|
|
|
|||
|
|
@ -0,0 +1,192 @@
|
|||
<h2>报告预览页</h2>
|
||||
<p class="subtitle">从列表点击报告名称或"预览"按钮进入。右上角按钮根据状态动态切换。</p>
|
||||
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">预览页 — 状态:数据已就绪(待生成文档)</div>
|
||||
<div class="mockup-body">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 0;border-bottom:1px solid #e2e8f0;margin-bottom:20px">
|
||||
<div style="display:flex;align-items:center;gap:16px">
|
||||
<span style="color:#94a3b8;font-size:18px;cursor:pointer">←</span>
|
||||
<div>
|
||||
<h2 style="font-size:22px;font-weight:700;color:#0f172a;margin:0">月报</h2>
|
||||
<span style="font-size:13px;color:#64748b">2026-04-01 ~ 2026-04-30</span>
|
||||
</div>
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:500;background:#fef3c7;color:#92400e">
|
||||
<span style="width:6px;height:6px;border-radius:50%;background:#f59e0b"></span>
|
||||
数据已就绪
|
||||
</span>
|
||||
</div>
|
||||
<button class="mock-button" style="background:#059669;color:#fff">生成报告文档</button>
|
||||
</div>
|
||||
|
||||
<!-- KPI Row -->
|
||||
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-bottom:20px">
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:4px">工单总数</div>
|
||||
<div style="font-size:28px;font-weight:700;color:#0f172a">12</div>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:4px">已解决</div>
|
||||
<div style="font-size:28px;font-weight:700;color:#059669">10</div>
|
||||
<div style="font-size:11px;color:#94a3b8">83.3%</div>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:4px">整体可用性</div>
|
||||
<div style="font-size:28px;font-weight:700;color:#f59e0b">98.52%</div>
|
||||
<div style="font-size:11px;color:#94a3b8">低于99%</div>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:4px">平均处理时长</div>
|
||||
<div style="font-size:28px;font-weight:700;color:#0f172a">240</div>
|
||||
<div style="font-size:11px;color:#94a3b8">分钟</div>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:4px">进行中</div>
|
||||
<div style="font-size:28px;font-weight:700;color:#0369a1">2</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ch1 -->
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden;margin-bottom:12px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#3b82f6;color:#fff;border-radius:6px;font-size:12px;font-weight:700">1</span>
|
||||
<span style="font-size:15px;font-weight:600;color:#1e293b">设备概况</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:32px;padding:16px 20px">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<div style="width:44px;height:44px;border-radius:10px;background:#dbeafe;display:flex;align-items:center;justify-content:center;font-size:20px">🖥</div>
|
||||
<div><span style="font-size:24px;font-weight:700;color:#0f172a">8</span><span style="font-size:13px;color:#64748b;margin-left:4px">台 GPU 服务器</span></div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<div style="width:44px;height:44px;border-radius:10px;background:#fef3c7;display:flex;align-items:center;justify-content:center;font-size:20px">🗄</div>
|
||||
<div><span style="font-size:24px;font-weight:700;color:#0f172a">3</span><span style="font-size:13px;color:#64748b;margin-left:4px">台 存储服务器</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ch2 -->
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden;margin-bottom:12px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#8b5cf6;color:#fff;border-radius:6px;font-size:12px;font-weight:700">2</span>
|
||||
<span style="font-size:15px;font-weight:600;color:#1e293b">运营数据</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:24px;padding:16px 20px">
|
||||
<div style="font-size:13px;color:#64748b">故障工单 <span style="font-weight:700;color:#d97706;font-size:16px;margin-left:4px">8</span> 件</div>
|
||||
<div style="font-size:13px;color:#64748b">涉及设备 <span style="font-weight:700;color:#0f172a;font-size:16px;margin-left:4px">5</span> 台</div>
|
||||
<div style="font-size:13px;color:#64748b">无故障天数 <span style="font-weight:700;color:#059669;font-size:16px;margin-left:4px">24</span> 天</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ch3 -->
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden;margin-bottom:12px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#f59e0b;color:#fff;border-radius:6px;font-size:12px;font-weight:700">3</span>
|
||||
<span style="font-size:15px;font-weight:600;color:#1e293b">故障分类</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:16px;padding:16px 20px">
|
||||
<div style="flex:1;padding:12px;border-radius:8px;background:#fff7ed;text-align:center">
|
||||
<div style="font-size:20px;font-weight:700;color:#f59e0b">5</div>
|
||||
<div style="font-size:12px;color:#64748b">GPU 故障</div>
|
||||
<div style="width:100%;height:4px;background:#fef3c7;border-radius:2px;margin-top:6px"><div style="width:62.5%;height:100%;background:#f59e0b;border-radius:2px"></div></div>
|
||||
<div style="font-size:11px;color:#94a3b8;margin-top:2px">62.5%</div>
|
||||
</div>
|
||||
<div style="flex:1;padding:12px;border-radius:8px;background:#fff7ed;text-align:center">
|
||||
<div style="font-size:20px;font-weight:700;color:#f59e0b">1</div>
|
||||
<div style="font-size:12px;color:#64748b">存储故障</div>
|
||||
<div style="width:100%;height:4px;background:#fef3c7;border-radius:2px;margin-top:6px"><div style="width:12.5%;height:100%;background:#f59e0b;border-radius:2px"></div></div>
|
||||
<div style="font-size:11px;color:#94a3b8;margin-top:2px">12.5%</div>
|
||||
</div>
|
||||
<div style="flex:1;padding:12px;border-radius:8px;background:#eff6ff;text-align:center">
|
||||
<div style="font-size:20px;font-weight:700;color:#3b82f6">2</div>
|
||||
<div style="font-size:12px;color:#64748b">其他工单</div>
|
||||
<div style="width:100%;height:4px;background:#dbeafe;border-radius:2px;margin-top:6px"><div style="width:25%;height:100%;background:#3b82f6;border-radius:2px"></div></div>
|
||||
<div style="font-size:11px;color:#94a3b8;margin-top:2px">25.0%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ch4 -->
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#10b981;color:#fff;border-radius:6px;font-size:12px;font-weight:700">4</span>
|
||||
<span style="font-size:15px;font-weight:600;color:#1e293b">服务可用性</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:16px 20px">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
|
||||
<span style="font-size:13px;color:#64748b">整体可用性:</span>
|
||||
<span style="font-size:22px;font-weight:700;color:#f59e0b">98.52%</span>
|
||||
</div>
|
||||
<table style="width:100%;font-size:13px;border-collapse:collapse">
|
||||
<thead>
|
||||
<tr style="border-bottom:2px solid #e2e8f0;text-align:left">
|
||||
<th style="padding:8px 12px;color:#64748b;font-weight:500">IP 地址</th>
|
||||
<th style="padding:8px 12px;color:#64748b;font-weight:500">设备类型</th>
|
||||
<th style="padding:8px 12px;color:#64748b;font-weight:500">故障时长</th>
|
||||
<th style="padding:8px 12px;color:#64748b;font-weight:500">可用性</th>
|
||||
<th style="padding:8px 12px;color:#64748b;font-weight:500">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border-bottom:1px solid #f1f5f9">
|
||||
<td style="padding:8px 12px;font-weight:500;color:#0f172a">10.0.0.35</td>
|
||||
<td style="padding:8px 12px;color:#64748b">GPU</td>
|
||||
<td style="padding:8px 12px;color:#dc2626;font-weight:500">2,160 分钟</td>
|
||||
<td style="padding:8px 12px;font-weight:700;color:#dc2626">95.12%</td>
|
||||
<td style="padding:8px 12px"><span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:#fef2f2;color:#dc2626">进行中</span></td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #f1f5f9">
|
||||
<td style="padding:8px 12px;font-weight:500;color:#0f172a">10.0.0.42</td>
|
||||
<td style="padding:8px 12px;color:#64748b">GPU</td>
|
||||
<td style="padding:8px 12px;color:#0f172a">1,440 分钟</td>
|
||||
<td style="padding:8px 12px;font-weight:700;color:#f59e0b">96.78%</td>
|
||||
<td style="padding:8px 12px"><span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:#dcfce7;color:#15803d">已恢复</span></td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #f1f5f9">
|
||||
<td style="padding:8px 12px;font-weight:500;color:#0f172a">10.0.0.18</td>
|
||||
<td style="padding:8px 12px;color:#64748b">存储</td>
|
||||
<td style="padding:8px 12px;color:#0f172a">720 分钟</td>
|
||||
<td style="padding:8px 12px;font-weight:700;color:#f59e0b">98.41%</td>
|
||||
<td style="padding:8px 12px"><span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:#dcfce7;color:#15803d">已恢复</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="font-size:11px;color:#94a3b8;margin-top:8px">共 3 个 IP 可用性低于 100%,其余设备保持 100% 在线</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 不同状态下的按钮变化 -->
|
||||
<div class="section">
|
||||
<h3>右上角按钮 — 按状态切换</h3>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-top:12px">
|
||||
<div style="background:#fef3c7;border:1px solid #fde68a;border-radius:8px;padding:12px;text-align:center">
|
||||
<div style="font-size:11px;color:#a16207;margin-bottom:6px">数据已就绪</div>
|
||||
<span style="display:inline-block;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;background:#059669;color:#fff">生成报告文档</span>
|
||||
</div>
|
||||
<div style="background:#dbeafe;border:1px solid #bfdbfe;border-radius:8px;padding:12px;text-align:center">
|
||||
<div style="font-size:11px;color:#1e40af;margin-bottom:6px">文档生成中</div>
|
||||
<span style="display:inline-block;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;background:#94a3b8;color:#fff">生成中...</span>
|
||||
</div>
|
||||
<div style="background:#dcfce7;border:1px solid #bbf7d0;border-radius:8px;padding:12px;text-align:center">
|
||||
<div style="font-size:11px;color:#15803d;margin-bottom:6px">已完成</div>
|
||||
<span style="display:inline-block;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;background:#2563eb;color:#fff">⬇ 下载报告</span>
|
||||
</div>
|
||||
<div style="background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:12px;text-align:center">
|
||||
<div style="font-size:11px;color:#b91c1c;margin-bottom:6px">生成失败</div>
|
||||
<span style="display:inline-block;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;background:#f59e0b;color:#fff">重新生成</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"type":"server-started","port":50153,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:50153","screen_dir":"/Users/niuniu/programs/docker/issue-ai/.superpowers/brainstorm/65974-1778137360/content","state_dir":"/Users/niuniu/programs/docker/issue-ai/.superpowers/brainstorm/65974-1778137360/state"}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<h2>报告预览页 — 设计方向</h2>
|
||||
<p class="subtitle">当前页面只展示 4 个数字卡片 + 3 个故障分类卡片。以下三个方向兼顾数据丰富度与视觉提升,各有侧重。</p>
|
||||
|
||||
<div class="options" data-multiselect>
|
||||
<div class="option" data-choice="a" onclick="toggleSelect(this)">
|
||||
<div class="letter">A</div>
|
||||
<div class="content">
|
||||
<h3>仪表盘风格</h3>
|
||||
<p>将预览页做成迷你仪表盘:顶部 KPI 卡片带环比/同比趋势箭头,中间用横向条状图展示故障分类,底部展示服务可用性分布。数据量大但不杂乱,适合快速扫读。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option" data-choice="b" onclick="toggleSelect(this)">
|
||||
<div class="letter">B</div>
|
||||
<div class="content">
|
||||
<h3>摘要 + 详情分区</h3>
|
||||
<p>顶部为摘要区(设备规模 + 核心指标如可用性/SLA/解决率),下方可展开的折叠面板展示故障明细(按分类、按设备、按日期)。信息层级清晰,用户按需深入。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option" data-choice="c" onclick="toggleSelect(this)">
|
||||
<div class="letter">C</div>
|
||||
<div class="content">
|
||||
<h3>单页报告预览</h3>
|
||||
<p>接近真实报告的排版风格:带章节标题(一、设备概况 二、运营数据 三、故障分类 四、服务可用性),每个章节内嵌简表或数据卡片。预览即缩略版报告,视觉专业正式。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>共同增强的数据指标</h3>
|
||||
<p>无论选哪个方向,以下字段都会从 metadata 中新增暴露:</p>
|
||||
</div>
|
||||
|
||||
<div class="pros-cons">
|
||||
<div class="pros">
|
||||
<h4>新增指标</h4>
|
||||
<ul>
|
||||
<li>解决率(已解决 / 故障总数)</li>
|
||||
<li>SLA 达标率</li>
|
||||
<li>平均处理时长</li>
|
||||
<li>故障分类分布(带具体数量)</li>
|
||||
<li>进行中工单数</li>
|
||||
<li>月报独有:设备级最低可用性 IP</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="cons">
|
||||
<h4>改动范围</h4>
|
||||
<ul>
|
||||
<li>前后端都需改:metadata 字段扩展 + 预览页 UI 重写</li>
|
||||
<li>旧报告不含新字段,需兼容降级展示</li>
|
||||
<li>周报/月报数据结构不同,需分别处理</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
<h2>报告预览页 — 最终方案</h2>
|
||||
<p class="subtitle">5 KPI + 4 章节全部展开。第 4 章列出所有可用性 < 100% 的 IP。</p>
|
||||
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">完整页面布局 — 月报示例(2026年4月)</div>
|
||||
<div class="mockup-body">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 0;border-bottom:1px solid #e2e8f0;margin-bottom:20px">
|
||||
<div style="display:flex;align-items:center;gap:16px">
|
||||
<span style="color:#94a3b8;font-size:18px">←</span>
|
||||
<div>
|
||||
<h2 style="font-size:22px;font-weight:700;color:#0f172a;margin:0">月报</h2>
|
||||
<span style="font-size:13px;color:#64748b">2026-04-01 ~ 2026-04-30</span>
|
||||
</div>
|
||||
<span style="background:#dcfce7;color:#15803d;padding:2px 10px;border-radius:12px;font-size:12px;font-weight:600">已完成</span>
|
||||
</div>
|
||||
<button class="mock-button">⬇ 下载报告</button>
|
||||
</div>
|
||||
|
||||
<!-- KPI Row -->
|
||||
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-bottom:20px">
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:4px">工单总数</div>
|
||||
<div style="font-size:28px;font-weight:700;color:#0f172a">12</div>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:4px">已解决</div>
|
||||
<div style="font-size:28px;font-weight:700;color:#059669">10</div>
|
||||
<div style="font-size:11px;color:#94a3b8">83.3%</div>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:4px">整体可用性</div>
|
||||
<div style="font-size:28px;font-weight:700;color:#f59e0b">98.52%</div>
|
||||
<div style="font-size:11px;color:#94a3b8">低于99%</div>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:4px">平均处理时长</div>
|
||||
<div style="font-size:28px;font-weight:700;color:#0f172a">240</div>
|
||||
<div style="font-size:11px;color:#94a3b8">分钟</div>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:4px">进行中</div>
|
||||
<div style="font-size:28px;font-weight:700;color:#0369a1">2</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chapter Sections — all expanded -->
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
|
||||
<!-- Ch1: 设备概况 -->
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#3b82f6;color:#fff;border-radius:6px;font-size:12px;font-weight:700">1</span>
|
||||
<span style="font-size:15px;font-weight:600;color:#1e293b">设备概况</span>
|
||||
</div>
|
||||
<span style="font-size:20px;color:#94a3b8">▾</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:32px;padding:16px 20px">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<div style="width:44px;height:44px;border-radius:10px;background:#dbeafe;display:flex;align-items:center;justify-content:center;font-size:20px">🖥</div>
|
||||
<div><span style="font-size:24px;font-weight:700;color:#0f172a">8</span><span style="font-size:13px;color:#64748b;margin-left:4px">台 GPU 服务器</span></div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<div style="width:44px;height:44px;border-radius:10px;background:#fef3c7;display:flex;align-items:center;justify-content:center;font-size:20px">🗄</div>
|
||||
<div><span style="font-size:24px;font-weight:700;color:#0f172a">3</span><span style="font-size:13px;color:#64748b;margin-left:4px">台 存储服务器</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ch2: 运营数据 -->
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#8b5cf6;color:#fff;border-radius:6px;font-size:12px;font-weight:700">2</span>
|
||||
<span style="font-size:15px;font-weight:600;color:#1e293b">运营数据</span>
|
||||
</div>
|
||||
<span style="font-size:20px;color:#94a3b8">▾</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:24px;padding:16px 20px">
|
||||
<div style="font-size:13px;color:#64748b">故障工单 <span style="font-weight:700;color:#d97706;font-size:16px;margin-left:4px">8</span> 件</div>
|
||||
<div style="font-size:13px;color:#64748b">涉及设备 <span style="font-weight:700;color:#0f172a;font-size:16px;margin-left:4px">5</span> 台</div>
|
||||
<div style="font-size:13px;color:#64748b">无故障天数 <span style="font-weight:700;color:#059669;font-size:16px;margin-left:4px">24</span> 天</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ch3: 故障分类 -->
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#f59e0b;color:#fff;border-radius:6px;font-size:12px;font-weight:700">3</span>
|
||||
<span style="font-size:15px;font-weight:600;color:#1e293b">故障分类</span>
|
||||
</div>
|
||||
<span style="font-size:20px;color:#94a3b8">▾</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:16px;padding:16px 20px">
|
||||
<div style="flex:1;padding:12px;border-radius:8px;background:#fff7ed;text-align:center">
|
||||
<div style="font-size:20px;font-weight:700;color:#f59e0b">5</div>
|
||||
<div style="font-size:12px;color:#64748b">GPU 故障</div>
|
||||
<div style="width:100%;height:4px;background:#fef3c7;border-radius:2px;margin-top:6px"><div style="width:62.5%;height:100%;background:#f59e0b;border-radius:2px"></div></div>
|
||||
<div style="font-size:11px;color:#94a3b8;margin-top:2px">62.5%</div>
|
||||
</div>
|
||||
<div style="flex:1;padding:12px;border-radius:8px;background:#fff7ed;text-align:center">
|
||||
<div style="font-size:20px;font-weight:700;color:#f59e0b">1</div>
|
||||
<div style="font-size:12px;color:#64748b">存储故障</div>
|
||||
<div style="width:100%;height:4px;background:#fef3c7;border-radius:2px;margin-top:6px"><div style="width:12.5%;height:100%;background:#f59e0b;border-radius:2px"></div></div>
|
||||
<div style="font-size:11px;color:#94a3b8;margin-top:2px">12.5%</div>
|
||||
</div>
|
||||
<div style="flex:1;padding:12px;border-radius:8px;background:#eff6ff;text-align:center">
|
||||
<div style="font-size:20px;font-weight:700;color:#3b82f6">2</div>
|
||||
<div style="font-size:12px;color:#64748b">其他工单</div>
|
||||
<div style="width:100%;height:4px;background:#dbeafe;border-radius:2px;margin-top:6px"><div style="width:25%;height:100%;background:#3b82f6;border-radius:2px"></div></div>
|
||||
<div style="font-size:11px;color:#94a3b8;margin-top:2px">25.0%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ch4: 服务可用性 — 列出所有可用性 <100% 的 IP -->
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#10b981;color:#fff;border-radius:6px;font-size:12px;font-weight:700">4</span>
|
||||
<span style="font-size:15px;font-weight:600;color:#1e293b">服务可用性</span>
|
||||
</div>
|
||||
<span style="font-size:20px;color:#94a3b8">▾</span>
|
||||
</div>
|
||||
<div style="padding:16px 20px">
|
||||
<!-- Overall availability banner -->
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
|
||||
<span style="font-size:13px;color:#64748b">整体可用性:</span>
|
||||
<span style="font-size:22px;font-weight:700;color:#f59e0b">98.52%</span>
|
||||
</div>
|
||||
<!-- IP table -->
|
||||
<table style="width:100%;font-size:13px;border-collapse:collapse">
|
||||
<thead>
|
||||
<tr style="border-bottom:2px solid #e2e8f0;text-align:left">
|
||||
<th style="padding:8px 12px;color:#64748b;font-weight:500">IP 地址</th>
|
||||
<th style="padding:8px 12px;color:#64748b;font-weight:500">设备类型</th>
|
||||
<th style="padding:8px 12px;color:#64748b;font-weight:500">故障时长</th>
|
||||
<th style="padding:8px 12px;color:#64748b;font-weight:500">可用性</th>
|
||||
<th style="padding:8px 12px;color:#64748b;font-weight:500">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border-bottom:1px solid #f1f5f9">
|
||||
<td style="padding:8px 12px;font-weight:500;color:#0f172a">10.0.0.35</td>
|
||||
<td style="padding:8px 12px;color:#64748b">GPU</td>
|
||||
<td style="padding:8px 12px;color:#dc2626;font-weight:500">2,160 分钟</td>
|
||||
<td style="padding:8px 12px;font-weight:700;color:#dc2626">95.12%</td>
|
||||
<td style="padding:8px 12px"><span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:#fef2f2;color:#dc2626">进行中</span></td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #f1f5f9">
|
||||
<td style="padding:8px 12px;font-weight:500;color:#0f172a">10.0.0.42</td>
|
||||
<td style="padding:8px 12px;color:#64748b">GPU</td>
|
||||
<td style="padding:8px 12px;color:#0f172a">1,440 分钟</td>
|
||||
<td style="padding:8px 12px;font-weight:700;color:#f59e0b">96.78%</td>
|
||||
<td style="padding:8px 12px"><span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:#dcfce7;color:#15803d">已恢复</span></td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #f1f5f9">
|
||||
<td style="padding:8px 12px;font-weight:500;color:#0f172a">10.0.0.18</td>
|
||||
<td style="padding:8px 12px;color:#64748b">存储</td>
|
||||
<td style="padding:8px 12px;color:#0f172a">720 分钟</td>
|
||||
<td style="padding:8px 12px;font-weight:700;color:#f59e0b">98.41%</td>
|
||||
<td style="padding:8px 12px"><span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:#dcfce7;color:#15803d">已恢复</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="font-size:11px;color:#94a3b8;margin-top:8px">共 3 个 IP 可用性低于 100%,其余设备保持 100% 在线</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>方案确认清单</h3>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
|
||||
<div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;padding:14px">
|
||||
<h4 style="margin:0 0 6px;font-size:14px;color:#15803d">✓ 已定</h4>
|
||||
<ul style="margin:0;padding-left:18px;font-size:13px;color:#166534;line-height:1.8">
|
||||
<li>5 KPI 卡片(工单总数 / 已解决 / 整体可用性 / 平均处理时长 / 进行中)</li>
|
||||
<li>4 章节全部默认展开</li>
|
||||
<li>Ch4 列出所有可用性 <100% 的 IP + 明细表</li>
|
||||
<li>故障分类带占比进度条</li>
|
||||
<li>周报/月报自适应章节内容</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style="background:#fff7ed;border:1px solid #fed7aa;border-radius:8px;padding:14px">
|
||||
<h4 style="margin:0 0 6px;font-size:14px;color:#c2410c">? 待确认</h4>
|
||||
<ul style="margin:0;padding-left:18px;font-size:13px;color:#7c2d12;line-height:1.8">
|
||||
<li>Ch3 展开后是否需要按 fault_subcategory 二级明细?</li>
|
||||
<li>Ch2 展开后是否需要按日故障统计简表?</li>
|
||||
<li>后端 metadata 需扩展 5-6 个新字段</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
<h2>报告预览页 — 融合方案全景</h2>
|
||||
<p class="subtitle">B(摘要+详情分区)+ C(报告章节结构)。周报/月报共用同一布局,章节内容自适应。</p>
|
||||
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">完整页面布局 — 月报示例(2026年4月)</div>
|
||||
<div class="mockup-body">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 0;border-bottom:1px solid #e2e8f0;margin-bottom:20px">
|
||||
<div style="display:flex;align-items:center;gap:16px">
|
||||
<span style="color:#94a3b8;font-size:18px">←</span>
|
||||
<div>
|
||||
<h2 style="font-size:22px;font-weight:700;color:#0f172a;margin:0">月报</h2>
|
||||
<span style="font-size:13px;color:#64748b">2026-04-01 ~ 2026-04-30</span>
|
||||
</div>
|
||||
<span style="background:#dcfce7;color:#15803d;padding:2px 10px;border-radius:12px;font-size:12px;font-weight:600">已完成</span>
|
||||
</div>
|
||||
<button class="mock-button">⬇ 下载报告</button>
|
||||
</div>
|
||||
|
||||
<!-- KPI Row -->
|
||||
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:12px;margin-bottom:20px">
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:4px">工单总数</div>
|
||||
<div style="font-size:28px;font-weight:700;color:#0f172a">12</div>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:4px">已解决</div>
|
||||
<div style="font-size:28px;font-weight:700;color:#059669">10</div>
|
||||
<div style="font-size:11px;color:#94a3b8">83.3%</div>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:4px">可用性均值</div>
|
||||
<div style="font-size:28px;font-weight:700;color:#f59e0b">98.52%</div>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:4px">SLA 达标率</div>
|
||||
<div style="font-size:28px;font-weight:700;color:#059669">91.7%</div>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:4px">平均处理时长</div>
|
||||
<div style="font-size:28px;font-weight:700;color:#0f172a">240</div>
|
||||
<div style="font-size:11px;color:#94a3b8">分钟</div>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:4px">进行中</div>
|
||||
<div style="font-size:28px;font-weight:700;color:#0369a1">2</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chapter Sections -->
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
|
||||
<!-- Ch1: 设备概况 -->
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#3b82f6;color:#fff;border-radius:6px;font-size:12px;font-weight:700">1</span>
|
||||
<span style="font-size:15px;font-weight:600;color:#1e293b">设备概况</span>
|
||||
</div>
|
||||
<span style="font-size:20px;color:#94a3b8">▾</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:32px;padding:16px 20px">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<div style="width:44px;height:44px;border-radius:10px;background:#dbeafe;display:flex;align-items:center;justify-content:center;font-size:20px">🖥</div>
|
||||
<div><span style="font-size:24px;font-weight:700;color:#0f172a">8</span><span style="font-size:13px;color:#64748b;margin-left:4px">台 GPU 服务器</span></div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<div style="width:44px;height:44px;border-radius:10px;background:#fef3c7;display:flex;align-items:center;justify-content:center;font-size:20px">🗄</div>
|
||||
<div><span style="font-size:24px;font-weight:700;color:#0f172a">3</span><span style="font-size:13px;color:#64748b;margin-left:4px">台 存储服务器</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ch2: 运营数据 -->
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#8b5cf6;color:#fff;border-radius:6px;font-size:12px;font-weight:700">2</span>
|
||||
<span style="font-size:15px;font-weight:600;color:#1e293b">运营数据</span>
|
||||
</div>
|
||||
<span style="font-size:20px;color:#94a3b8">▸</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:24px;padding:16px 20px">
|
||||
<div style="font-size:13px;color:#64748b">故障工单 <span style="font-weight:700;color:#d97706;font-size:16px;margin-left:4px">8</span> 件</div>
|
||||
<div style="font-size:13px;color:#64748b">涉及设备 <span style="font-weight:700;color:#0f172a;font-size:16px;margin-left:4px">5</span> 台</div>
|
||||
<div style="font-size:13px;color:#64748b">无故障天数 <span style="font-weight:700;color:#059669;font-size:16px;margin-left:4px">24</span> 天</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ch3: 故障分类 -->
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#f59e0b;color:#fff;border-radius:6px;font-size:12px;font-weight:700">3</span>
|
||||
<span style="font-size:15px;font-weight:600;color:#1e293b">故障分类</span>
|
||||
</div>
|
||||
<span style="font-size:20px;color:#94a3b8">▾</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:16px;padding:16px 20px">
|
||||
<div style="flex:1;padding:12px;border-radius:8px;background:#fff7ed;text-align:center">
|
||||
<div style="font-size:20px;font-weight:700;color:#f59e0b">5</div>
|
||||
<div style="font-size:12px;color:#64748b">GPU 故障</div>
|
||||
<div style="width:100%;height:4px;background:#fef3c7;border-radius:2px;margin-top:6px"><div style="width:62.5%;height:100%;background:#f59e0b;border-radius:2px"></div></div>
|
||||
<div style="font-size:11px;color:#94a3b8;margin-top:2px">62.5%</div>
|
||||
</div>
|
||||
<div style="flex:1;padding:12px;border-radius:8px;background:#fff7ed;text-align:center">
|
||||
<div style="font-size:20px;font-weight:700;color:#f59e0b">1</div>
|
||||
<div style="font-size:12px;color:#64748b">存储故障</div>
|
||||
<div style="width:100%;height:4px;background:#fef3c7;border-radius:2px;margin-top:6px"><div style="width:12.5%;height:100%;background:#f59e0b;border-radius:2px"></div></div>
|
||||
<div style="font-size:11px;color:#94a3b8;margin-top:2px">12.5%</div>
|
||||
</div>
|
||||
<div style="flex:1;padding:12px;border-radius:8px;background:#eff6ff;text-align:center">
|
||||
<div style="font-size:20px;font-weight:700;color:#3b82f6">2</div>
|
||||
<div style="font-size:12px;color:#64748b">其他工单</div>
|
||||
<div style="width:100%;height:4px;background:#dbeafe;border-radius:2px;margin-top:6px"><div style="width:25%;height:100%;background:#3b82f6;border-radius:2px"></div></div>
|
||||
<div style="font-size:11px;color:#94a3b8;margin-top:2px">25.0%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ch4: 服务可用性 -->
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#10b981;color:#fff;border-radius:6px;font-size:12px;font-weight:700">4</span>
|
||||
<span style="font-size:15px;font-weight:600;color:#1e293b">服务可用性</span>
|
||||
</div>
|
||||
<span style="font-size:20px;color:#94a3b8">▸</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px">
|
||||
<div>
|
||||
<span style="font-size:28px;font-weight:700;color:#f59e0b">98.52%</span>
|
||||
<span style="font-size:13px;color:#64748b;margin-left:6px">整体可用性</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:#64748b">
|
||||
<span>最低可用 IP:</span>
|
||||
<span style="font-weight:600;color:#dc2626">10.0.0.35</span>
|
||||
<span style="color:#dc2626;font-weight:600;margin-left:4px">(95.12%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key features -->
|
||||
<div class="section">
|
||||
<h3>设计要点</h3>
|
||||
</div>
|
||||
|
||||
<div class="pros-cons">
|
||||
<div class="pros">
|
||||
<h4>交互特性</h4>
|
||||
<ul>
|
||||
<li>章节卡片默认全部展开,可折叠</li>
|
||||
<li>折叠态显示摘要数据,不丢失关键信息</li>
|
||||
<li>有数据的章节正常展示,无数据的章节(如无GPU故障)保留卡片但数量为 0</li>
|
||||
<li>周报/月报章节内容自适应(周报无"按IP可用性"明细,改为按日统计)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="cons">
|
||||
<h4>待确认</h4>
|
||||
<ul>
|
||||
<li>KPI 行 5-6 个指标是否太多?是否需要精简到 4 个?</li>
|
||||
<li>章节是否默认全部展开,还是默认折叠只显示摘要?</li>
|
||||
<li>故障分类是否需要按 fault_subcategory 二级展开?</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
<h2>报告预览页</h2>
|
||||
<p class="subtitle">从列表点击报告名称或"预览"按钮进入。右上角按钮根据状态动态切换。</p>
|
||||
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">预览页 — 状态:数据已就绪(待生成文档)</div>
|
||||
<div class="mockup-body">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 0;border-bottom:1px solid #e2e8f0;margin-bottom:20px">
|
||||
<div style="display:flex;align-items:center;gap:16px">
|
||||
<span style="color:#94a3b8;font-size:18px;cursor:pointer">←</span>
|
||||
<div>
|
||||
<h2 style="font-size:22px;font-weight:700;color:#0f172a;margin:0">月报</h2>
|
||||
<span style="font-size:13px;color:#64748b">2026-04-01 ~ 2026-04-30</span>
|
||||
</div>
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:500;background:#fef3c7;color:#92400e">
|
||||
<span style="width:6px;height:6px;border-radius:50%;background:#f59e0b"></span>
|
||||
数据已就绪
|
||||
</span>
|
||||
</div>
|
||||
<button class="mock-button" style="background:#059669;color:#fff">生成报告文档</button>
|
||||
</div>
|
||||
|
||||
<!-- KPI Row -->
|
||||
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-bottom:20px">
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:4px">工单总数</div>
|
||||
<div style="font-size:28px;font-weight:700;color:#0f172a">12</div>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:4px">已解决</div>
|
||||
<div style="font-size:28px;font-weight:700;color:#059669">10</div>
|
||||
<div style="font-size:11px;color:#94a3b8">83.3%</div>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:4px">整体可用性</div>
|
||||
<div style="font-size:28px;font-weight:700;color:#f59e0b">98.52%</div>
|
||||
<div style="font-size:11px;color:#94a3b8">低于99%</div>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:4px">平均处理时长</div>
|
||||
<div style="font-size:28px;font-weight:700;color:#0f172a">240</div>
|
||||
<div style="font-size:11px;color:#94a3b8">分钟</div>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:16px;text-align:center">
|
||||
<div style="font-size:12px;color:#64748b;margin-bottom:4px">进行中</div>
|
||||
<div style="font-size:28px;font-weight:700;color:#0369a1">2</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ch1 -->
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden;margin-bottom:12px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#3b82f6;color:#fff;border-radius:6px;font-size:12px;font-weight:700">1</span>
|
||||
<span style="font-size:15px;font-weight:600;color:#1e293b">设备概况</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:32px;padding:16px 20px">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<div style="width:44px;height:44px;border-radius:10px;background:#dbeafe;display:flex;align-items:center;justify-content:center;font-size:20px">🖥</div>
|
||||
<div><span style="font-size:24px;font-weight:700;color:#0f172a">8</span><span style="font-size:13px;color:#64748b;margin-left:4px">台 GPU 服务器</span></div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<div style="width:44px;height:44px;border-radius:10px;background:#fef3c7;display:flex;align-items:center;justify-content:center;font-size:20px">🗄</div>
|
||||
<div><span style="font-size:24px;font-weight:700;color:#0f172a">3</span><span style="font-size:13px;color:#64748b;margin-left:4px">台 存储服务器</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ch2 -->
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden;margin-bottom:12px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#8b5cf6;color:#fff;border-radius:6px;font-size:12px;font-weight:700">2</span>
|
||||
<span style="font-size:15px;font-weight:600;color:#1e293b">运营数据</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:24px;padding:16px 20px">
|
||||
<div style="font-size:13px;color:#64748b">故障工单 <span style="font-weight:700;color:#d97706;font-size:16px;margin-left:4px">8</span> 件</div>
|
||||
<div style="font-size:13px;color:#64748b">涉及设备 <span style="font-weight:700;color:#0f172a;font-size:16px;margin-left:4px">5</span> 台</div>
|
||||
<div style="font-size:13px;color:#64748b">无故障天数 <span style="font-weight:700;color:#059669;font-size:16px;margin-left:4px">24</span> 天</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ch3 -->
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden;margin-bottom:12px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#f59e0b;color:#fff;border-radius:6px;font-size:12px;font-weight:700">3</span>
|
||||
<span style="font-size:15px;font-weight:600;color:#1e293b">故障分类</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:16px;padding:16px 20px">
|
||||
<div style="flex:1;padding:12px;border-radius:8px;background:#fff7ed;text-align:center">
|
||||
<div style="font-size:20px;font-weight:700;color:#f59e0b">5</div>
|
||||
<div style="font-size:12px;color:#64748b">GPU 故障</div>
|
||||
<div style="width:100%;height:4px;background:#fef3c7;border-radius:2px;margin-top:6px"><div style="width:62.5%;height:100%;background:#f59e0b;border-radius:2px"></div></div>
|
||||
<div style="font-size:11px;color:#94a3b8;margin-top:2px">62.5%</div>
|
||||
</div>
|
||||
<div style="flex:1;padding:12px;border-radius:8px;background:#fff7ed;text-align:center">
|
||||
<div style="font-size:20px;font-weight:700;color:#f59e0b">1</div>
|
||||
<div style="font-size:12px;color:#64748b">存储故障</div>
|
||||
<div style="width:100%;height:4px;background:#fef3c7;border-radius:2px;margin-top:6px"><div style="width:12.5%;height:100%;background:#f59e0b;border-radius:2px"></div></div>
|
||||
<div style="font-size:11px;color:#94a3b8;margin-top:2px">12.5%</div>
|
||||
</div>
|
||||
<div style="flex:1;padding:12px;border-radius:8px;background:#eff6ff;text-align:center">
|
||||
<div style="font-size:20px;font-weight:700;color:#3b82f6">2</div>
|
||||
<div style="font-size:12px;color:#64748b">其他工单</div>
|
||||
<div style="width:100%;height:4px;background:#dbeafe;border-radius:2px;margin-top:6px"><div style="width:25%;height:100%;background:#3b82f6;border-radius:2px"></div></div>
|
||||
<div style="font-size:11px;color:#94a3b8;margin-top:2px">25.0%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ch4 -->
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:#10b981;color:#fff;border-radius:6px;font-size:12px;font-weight:700">4</span>
|
||||
<span style="font-size:15px;font-weight:600;color:#1e293b">服务可用性</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:16px 20px">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
|
||||
<span style="font-size:13px;color:#64748b">整体可用性:</span>
|
||||
<span style="font-size:22px;font-weight:700;color:#f59e0b">98.52%</span>
|
||||
</div>
|
||||
<table style="width:100%;font-size:13px;border-collapse:collapse">
|
||||
<thead>
|
||||
<tr style="border-bottom:2px solid #e2e8f0;text-align:left">
|
||||
<th style="padding:8px 12px;color:#64748b;font-weight:500">IP 地址</th>
|
||||
<th style="padding:8px 12px;color:#64748b;font-weight:500">设备类型</th>
|
||||
<th style="padding:8px 12px;color:#64748b;font-weight:500">故障时长</th>
|
||||
<th style="padding:8px 12px;color:#64748b;font-weight:500">可用性</th>
|
||||
<th style="padding:8px 12px;color:#64748b;font-weight:500">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border-bottom:1px solid #f1f5f9">
|
||||
<td style="padding:8px 12px;font-weight:500;color:#0f172a">10.0.0.35</td>
|
||||
<td style="padding:8px 12px;color:#64748b">GPU</td>
|
||||
<td style="padding:8px 12px;color:#dc2626;font-weight:500">2,160 分钟</td>
|
||||
<td style="padding:8px 12px;font-weight:700;color:#dc2626">95.12%</td>
|
||||
<td style="padding:8px 12px"><span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:#fef2f2;color:#dc2626">进行中</span></td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #f1f5f9">
|
||||
<td style="padding:8px 12px;font-weight:500;color:#0f172a">10.0.0.42</td>
|
||||
<td style="padding:8px 12px;color:#64748b">GPU</td>
|
||||
<td style="padding:8px 12px;color:#0f172a">1,440 分钟</td>
|
||||
<td style="padding:8px 12px;font-weight:700;color:#f59e0b">96.78%</td>
|
||||
<td style="padding:8px 12px"><span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:#dcfce7;color:#15803d">已恢复</span></td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #f1f5f9">
|
||||
<td style="padding:8px 12px;font-weight:500;color:#0f172a">10.0.0.18</td>
|
||||
<td style="padding:8px 12px;color:#64748b">存储</td>
|
||||
<td style="padding:8px 12px;color:#0f172a">720 分钟</td>
|
||||
<td style="padding:8px 12px;font-weight:700;color:#f59e0b">98.41%</td>
|
||||
<td style="padding:8px 12px"><span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:#dcfce7;color:#15803d">已恢复</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="font-size:11px;color:#94a3b8;margin-top:8px">共 3 个 IP 可用性低于 100%,其余设备保持 100% 在线</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 不同状态下的按钮变化 -->
|
||||
<div class="section">
|
||||
<h3>右上角按钮 — 按状态切换</h3>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-top:12px">
|
||||
<div style="background:#fef3c7;border:1px solid #fde68a;border-radius:8px;padding:12px;text-align:center">
|
||||
<div style="font-size:11px;color:#a16207;margin-bottom:6px">数据已就绪</div>
|
||||
<span style="display:inline-block;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;background:#059669;color:#fff">生成报告文档</span>
|
||||
</div>
|
||||
<div style="background:#dbeafe;border:1px solid #bfdbfe;border-radius:8px;padding:12px;text-align:center">
|
||||
<div style="font-size:11px;color:#1e40af;margin-bottom:6px">文档生成中</div>
|
||||
<span style="display:inline-block;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;background:#94a3b8;color:#fff">生成中...</span>
|
||||
</div>
|
||||
<div style="background:#dcfce7;border:1px solid #bbf7d0;border-radius:8px;padding:12px;text-align:center">
|
||||
<div style="font-size:11px;color:#15803d;margin-bottom:6px">已完成</div>
|
||||
<span style="display:inline-block;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;background:#2563eb;color:#fff">⬇ 下载报告</span>
|
||||
</div>
|
||||
<div style="background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:12px;text-align:center">
|
||||
<div style="font-size:11px;color:#b91c1c;margin-bottom:6px">生成失败</div>
|
||||
<span style="display:inline-block;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;background:#f59e0b;color:#fff">重新生成</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
<h2>报告管理列表</h2>
|
||||
<p class="subtitle">类型列用颜色区分月报/周报。批量操作按钮仅在选中后出现。</p>
|
||||
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">报告管理页面</div>
|
||||
<div class="mockup-body">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 0;border-bottom:1px solid #e2e8f0;margin-bottom:16px">
|
||||
<div>
|
||||
<h2 style="font-size:20px;font-weight:700;color:#0f172a;margin:0">报告管理</h2>
|
||||
<span style="font-size:12px;color:#94a3b8">数据预览与文档生成</span>
|
||||
</div>
|
||||
<button class="mock-button">+ 新建报告</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<table style="width:100%;font-size:13px;border-collapse:collapse">
|
||||
<thead>
|
||||
<tr style="border-bottom:2px solid #e2e8f0;text-align:left">
|
||||
<th style="padding:10px 12px;color:#64748b;font-weight:500;width:40px">
|
||||
<input type="checkbox" style="width:16px;height:16px;border-radius:4px;border:1px solid #cbd5e1">
|
||||
</th>
|
||||
<th style="padding:10px 12px;color:#64748b;font-weight:500">报告名称</th>
|
||||
<th style="padding:10px 12px;color:#64748b;font-weight:500">类型</th>
|
||||
<th style="padding:10px 12px;color:#64748b;font-weight:500">时间段</th>
|
||||
<th style="padding:10px 12px;color:#64748b;font-weight:500">状态</th>
|
||||
<th style="padding:10px 12px;color:#64748b;font-weight:500">创建时间</th>
|
||||
<th style="padding:10px 12px;color:#64748b;font-weight:500">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Row 1: preview -->
|
||||
<tr style="border-bottom:1px solid #f1f5f9">
|
||||
<td style="padding:10px 12px"><input type="checkbox" style="width:16px;height:16px;border-radius:4px;border:1px solid #cbd5e1"></td>
|
||||
<td style="padding:10px 12px;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
||||
<a href="#" style="color:#2563eb;font-weight:500;text-decoration:none;cursor:pointer" onclick="event.preventDefault()">2026年4月图灵IT基础设施运营月报</a>
|
||||
</td>
|
||||
<td style="padding:10px 12px">
|
||||
<span style="display:inline-block;padding:3px 10px;border-radius:8px;font-size:11px;font-weight:600;background:#dbeafe;color:#1e40af">月报</span>
|
||||
</td>
|
||||
<td style="padding:10px 12px;color:#64748b">2026-04-01 ~ 2026-04-30</td>
|
||||
<td style="padding:10px 12px">
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:500;background:#fef3c7;color:#92400e">
|
||||
<span style="width:6px;height:6px;border-radius:50%;background:#f59e0b"></span>
|
||||
数据已就绪
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding:10px 12px;color:#94a3b8;font-size:12px">2026-05-07 14:30</td>
|
||||
<td style="padding:10px 12px">
|
||||
<div style="display:flex;gap:4px">
|
||||
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#eff6ff;color:#2563eb">预览</span>
|
||||
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#f0fdf4;color:#059669">生成文档</span>
|
||||
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;color:#dc2626">🗑</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Row 2: generating -->
|
||||
<tr style="border-bottom:1px solid #f1f5f9">
|
||||
<td style="padding:10px 12px"><input type="checkbox" style="width:16px;height:16px;border-radius:4px;border:1px solid #cbd5e1"></td>
|
||||
<td style="padding:10px 12px;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
||||
<a href="#" style="color:#2563eb;font-weight:500;text-decoration:none;cursor:pointer" onclick="event.preventDefault()">图灵IT基础设施运营周报(4月28日-5月4日)</a>
|
||||
</td>
|
||||
<td style="padding:10px 12px">
|
||||
<span style="display:inline-block;padding:3px 10px;border-radius:8px;font-size:11px;font-weight:600;background:#ede9fe;color:#6b21a8">周报</span>
|
||||
</td>
|
||||
<td style="padding:10px 12px;color:#64748b">2026-04-28 ~ 2026-05-04</td>
|
||||
<td style="padding:10px 12px">
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:500;background:#dbeafe;color:#1e40af">
|
||||
<span style="display:inline-block;width:10px;height:10px;border:2px solid #3b82f6;border-top-color:transparent;border-radius:50%"></span>
|
||||
文档生成中
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding:10px 12px;color:#94a3b8;font-size:12px">2026-05-07 14:35</td>
|
||||
<td style="padding:10px 12px">
|
||||
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#eff6ff;color:#2563eb">预览</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Row 3: completed -->
|
||||
<tr style="border-bottom:1px solid #f1f5f9">
|
||||
<td style="padding:10px 12px"><input type="checkbox" style="width:16px;height:16px;border-radius:4px;border:1px solid #cbd5e1"></td>
|
||||
<td style="padding:10px 12px;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
||||
<a href="#" style="color:#2563eb;font-weight:500;text-decoration:none;cursor:pointer" onclick="event.preventDefault()">2026年3月图灵IT基础设施运营月报</a>
|
||||
</td>
|
||||
<td style="padding:10px 12px">
|
||||
<span style="display:inline-block;padding:3px 10px;border-radius:8px;font-size:11px;font-weight:600;background:#dbeafe;color:#1e40af">月报</span>
|
||||
</td>
|
||||
<td style="padding:10px 12px;color:#64748b">2026-03-01 ~ 2026-03-31</td>
|
||||
<td style="padding:10px 12px">
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:500;background:#dcfce7;color:#15803d">
|
||||
<span style="width:6px;height:6px;border-radius:50%;background:#22c55e"></span>
|
||||
已完成
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding:10px 12px;color:#94a3b8;font-size:12px">2026-05-06 10:15</td>
|
||||
<td style="padding:10px 12px">
|
||||
<div style="display:flex;gap:4px">
|
||||
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#eff6ff;color:#2563eb">预览</span>
|
||||
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#f0fdf4;color:#059669">⬇ 下载</span>
|
||||
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;color:#dc2626">🗑</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Row 4: failed -->
|
||||
<tr style="border-bottom:1px solid #f1f5f9">
|
||||
<td style="padding:10px 12px"><input type="checkbox" style="width:16px;height:16px;border-radius:4px;border:1px solid #cbd5e1"></td>
|
||||
<td style="padding:10px 12px;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
||||
<a href="#" style="color:#2563eb;font-weight:500;text-decoration:none;cursor:pointer" onclick="event.preventDefault()">图灵IT基础设施运营周报(4月21日-4月27日)</a>
|
||||
</td>
|
||||
<td style="padding:10px 12px">
|
||||
<span style="display:inline-block;padding:3px 10px;border-radius:8px;font-size:11px;font-weight:600;background:#ede9fe;color:#6b21a8">周报</span>
|
||||
</td>
|
||||
<td style="padding:10px 12px;color:#64748b">2026-04-21 ~ 2026-04-27</td>
|
||||
<td style="padding:10px 12px">
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:500;background:#fef2f2;color:#b91c1c">
|
||||
<span style="width:6px;height:6px;border-radius:50%;background:#ef4444"></span>
|
||||
生成失败
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding:10px 12px;color:#94a3b8;font-size:12px">2026-05-06 11:00</td>
|
||||
<td style="padding:10px 12px">
|
||||
<div style="display:flex;gap:4px">
|
||||
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#eff6ff;color:#2563eb">预览</span>
|
||||
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#fef3c7;color:#92400e">重试</span>
|
||||
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;color:#dc2626">🗑</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
|
||||
<div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;padding:14px">
|
||||
<h4 style="margin:0 0 6px;font-size:14px;color:#15803d">✓ 类型颜色区分</h4>
|
||||
<div style="display:flex;gap:12px;align-items:center;font-size:13px">
|
||||
<span style="display:inline-block;padding:3px 10px;border-radius:8px;font-size:11px;font-weight:600;background:#dbeafe;color:#1e40af">月报</span>
|
||||
<span style="font-size:12px;color:#64748b">蓝色背景,与报告预览章节编号色呼应</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;align-items:center;font-size:13px;margin-top:8px">
|
||||
<span style="display:inline-block;padding:3px 10px;border-radius:8px;font-size:11px;font-weight:600;background:#ede9fe;color:#6b21a8">周报</span>
|
||||
<span style="font-size:12px;color:#64748b">紫色背景,与月报明显区分</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;padding:14px">
|
||||
<h4 style="margin:0 0 6px;font-size:14px;color:#15803d">✓ 批量操作栏</h4>
|
||||
<ul style="margin:0;padding-left:18px;font-size:13px;color:#166534;line-height:1.8">
|
||||
<li>未选中任何行时不显示批量操作栏</li>
|
||||
<li>选中 ≥1 行后,在表格下方出现「📦 批量下载」「🗑 批量删除」</li>
|
||||
<li>与现有实现完全一致,无需改动</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
<h2>报告管理页 — 新流程重构</h2>
|
||||
<p class="subtitle">核心理念:预览数据先行(1-2s),确认后再生成 DOCX(10-30s)。</p>
|
||||
|
||||
<div class="section">
|
||||
<h3>流程对比</h3>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">旧流程</div>
|
||||
<div class="mockup-body" style="padding:16px;font-size:13px;text-align:center;line-height:2.2">
|
||||
选择月份 → 点"生成报告" →<br/>
|
||||
<span style="color:#94a3b8">等待 10-30s(数据+图表+DOCX+OLE)</span> →<br/>
|
||||
报告完成 → 点"查看"预览 →<br/>
|
||||
发现数据有误 → 删除重来
|
||||
</div>
|
||||
</div>
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">新流程</div>
|
||||
<div class="mockup-body" style="padding:16px;font-size:13px;text-align:center;line-height:2.2">
|
||||
选择月份 → 点"预览报告" →<br/>
|
||||
<span style="color:#059669">等待 1-2s(仅数据采集)</span> →<br/>
|
||||
自动跳转预览页 → 确认数据无误 →<br/>
|
||||
点"生成报告文档" → 下载 DOCX
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>报告列表页 — 新版布局</h3>
|
||||
</div>
|
||||
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">报告管理页面(列表)</div>
|
||||
<div class="mockup-body">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 0;border-bottom:1px solid #e2e8f0;margin-bottom:16px">
|
||||
<div>
|
||||
<h2 style="font-size:20px;font-weight:700;color:#0f172a;margin:0">报告管理</h2>
|
||||
<span style="font-size:12px;color:#94a3b8">数据预览与文档生成</span>
|
||||
</div>
|
||||
<button class="mock-button" style="background:#3b82f6;color:#fff;border:none;padding:8px 16px;border-radius:10px;font-size:13px;font-weight:600">+ 新建报告</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<table style="width:100%;font-size:13px;border-collapse:collapse">
|
||||
<thead>
|
||||
<tr style="border-bottom:2px solid #e2e8f0;text-align:left">
|
||||
<th style="padding:10px 12px;color:#64748b;font-weight:500;width:40px">
|
||||
<input type="checkbox" style="width:16px;height:16px;border-radius:4px;border:1px solid #cbd5e1">
|
||||
</th>
|
||||
<th style="padding:10px 12px;color:#64748b;font-weight:500">报告名称</th>
|
||||
<th style="padding:10px 12px;color:#64748b;font-weight:500">类型</th>
|
||||
<th style="padding:10px 12px;color:#64748b;font-weight:500">时间段</th>
|
||||
<th style="padding:10px 12px;color:#64748b;font-weight:500">状态</th>
|
||||
<th style="padding:10px 12px;color:#64748b;font-weight:500">创建时间</th>
|
||||
<th style="padding:10px 12px;color:#64748b;font-weight:500">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Row 1: preview status -->
|
||||
<tr style="border-bottom:1px solid #f1f5f9">
|
||||
<td style="padding:10px 12px"><input type="checkbox" style="width:16px;height:16px;border-radius:4px;border:1px solid #cbd5e1"></td>
|
||||
<td style="padding:10px 12px;font-weight:500;color:#0f172a;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="2026年4月图灵IT基础设施运营月报">2026年4月图灵IT基础设施运营月报</td>
|
||||
<td style="padding:10px 12px;font-weight:500;color:#0f172a">月报</td>
|
||||
<td style="padding:10px 12px;color:#64748b">2026-04-01 ~ 2026-04-30</td>
|
||||
<td style="padding:10px 12px">
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:500;background:#fef3c7;color:#92400e">
|
||||
<span style="width:6px;height:6px;border-radius:50%;background:#f59e0b"></span>
|
||||
数据已就绪
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding:10px 12px;color:#94a3b8;font-size:12px">2026-05-07 14:30</td>
|
||||
<td style="padding:10px 12px">
|
||||
<div style="display:flex;gap:4px">
|
||||
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#eff6ff;color:#2563eb">预览</span>
|
||||
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#f0fdf4;color:#059669">生成文档</span>
|
||||
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;color:#dc2626">🗑</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Row 2: generating status -->
|
||||
<tr style="border-bottom:1px solid #f1f5f9">
|
||||
<td style="padding:10px 12px"><input type="checkbox" style="width:16px;height:16px;border-radius:4px;border:1px solid #cbd5e1"></td>
|
||||
<td style="padding:10px 12px;font-weight:500;color:#0f172a;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="图灵IT基础设施运营周报(4月28日-5月4日)">图灵IT基础设施运营周报(4月28日-5月4日)</td>
|
||||
<td style="padding:10px 12px;font-weight:500;color:#0f172a">周报</td>
|
||||
<td style="padding:10px 12px;color:#64748b">2026-04-28 ~ 2026-05-04</td>
|
||||
<td style="padding:10px 12px">
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:500;background:#dbeafe;color:#1e40af">
|
||||
<span style="display:inline-block;width:10px;height:10px;border:2px solid #3b82f6;border-top-color:transparent;border-radius:50%;animation:spin 1s linear infinite"></span>
|
||||
文档生成中
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding:10px 12px;color:#94a3b8;font-size:12px">2026-05-07 14:35</td>
|
||||
<td style="padding:10px 12px">
|
||||
<div style="display:flex;gap:4px">
|
||||
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#eff6ff;color:#2563eb">预览</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Row 3: completed status -->
|
||||
<tr style="border-bottom:1px solid #f1f5f9">
|
||||
<td style="padding:10px 12px"><input type="checkbox" style="width:16px;height:16px;border-radius:4px;border:1px solid #cbd5e1"></td>
|
||||
<td style="padding:10px 12px;font-weight:500;color:#0f172a;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="2026年3月图灵IT基础设施运营月报">2026年3月图灵IT基础设施运营月报</td>
|
||||
<td style="padding:10px 12px;font-weight:500;color:#0f172a">月报</td>
|
||||
<td style="padding:10px 12px;color:#64748b">2026-03-01 ~ 2026-03-31</td>
|
||||
<td style="padding:10px 12px">
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:500;background:#dcfce7;color:#15803d">
|
||||
<span style="width:6px;height:6px;border-radius:50%;background:#22c55e"></span>
|
||||
已完成
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding:10px 12px;color:#94a3b8;font-size:12px">2026-05-06 10:15</td>
|
||||
<td style="padding:10px 12px">
|
||||
<div style="display:flex;gap:4px">
|
||||
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#eff6ff;color:#2563eb">预览</span>
|
||||
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#f0fdf4;color:#059669">⬇ 下载</span>
|
||||
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;color:#dc2626">🗑</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Row 4: failed -->
|
||||
<tr style="border-bottom:1px solid #f1f5f9">
|
||||
<td style="padding:10px 12px"><input type="checkbox" style="width:16px;height:16px;border-radius:4px;border:1px solid #cbd5e1"></td>
|
||||
<td style="padding:10px 12px;font-weight:500;color:#0f172a;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="图灵IT基础设施运营周报(4月21日-4月27日)">图灵IT基础设施运营周报(4月21日-4月27日)</td>
|
||||
<td style="padding:10px 12px;font-weight:500;color:#0f172a">周报</td>
|
||||
<td style="padding:10px 12px;color:#64748b">2026-04-21 ~ 2026-04-27</td>
|
||||
<td style="padding:10px 12px">
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:500;background:#fef2f2;color:#b91c1c">
|
||||
<span style="width:6px;height:6px;border-radius:50%;background:#ef4444"></span>
|
||||
生成失败
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding:10px 12px;color:#94a3b8;font-size:12px">2026-05-06 11:00</td>
|
||||
<td style="padding:10px 12px">
|
||||
<div style="display:flex;gap:4px">
|
||||
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#eff6ff;color:#2563eb">预览</span>
|
||||
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;background:#fef3c7;color:#92400e">重试</span>
|
||||
<span style="cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500;color:#dc2626">🗑</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Batch actions bar -->
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 0;margin-top:12px;border-top:1px solid #e2e8f0">
|
||||
<span style="font-size:12px;color:#94a3b8">已选 2 项</span>
|
||||
<div style="display:flex;gap:8px">
|
||||
<span style="cursor:pointer;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:500;background:#eff6ff;color:#2563eb">📦 批量下载</span>
|
||||
<span style="cursor:pointer;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:500;background:#fef2f2;color:#dc2626">🗑 批量删除</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>状态体系总览</h3>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-top:12px">
|
||||
<div style="background:#fef3c7;border:1px solid #fde68a;border-radius:8px;padding:12px;text-align:center">
|
||||
<div style="font-weight:700;color:#92400e;font-size:14px;margin-bottom:4px">数据已就绪</div>
|
||||
<div style="font-size:11px;color:#a16207">数据采集完成<br/>文档未生成</div>
|
||||
<div style="display:flex;gap:4px;justify-content:center;margin-top:8px">
|
||||
<span style="padding:3px 8px;border-radius:4px;font-size:10px;background:#eff6ff;color:#2563eb">预览</span>
|
||||
<span style="padding:3px 8px;border-radius:4px;font-size:10px;background:#f0fdf4;color:#059669">生成文档</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:#dbeafe;border:1px solid #bfdbfe;border-radius:8px;padding:12px;text-align:center">
|
||||
<div style="font-weight:700;color:#1e40af;font-size:14px;margin-bottom:4px">文档生成中</div>
|
||||
<div style="font-size:11px;color:#3b82f6">DOCX 构建中<br/>图表+OLE嵌入</div>
|
||||
<div style="margin-top:8px">
|
||||
<span style="padding:3px 8px;border-radius:4px;font-size:10px;background:#eff6ff;color:#2563eb">预览</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:#dcfce7;border:1px solid #bbf7d0;border-radius:8px;padding:12px;text-align:center">
|
||||
<div style="font-weight:700;color:#15803d;font-size:14px;margin-bottom:4px">已完成</div>
|
||||
<div style="font-size:11px;color:#16a34a">文档就绪<br/>可下载</div>
|
||||
<div style="display:flex;gap:4px;justify-content:center;margin-top:8px">
|
||||
<span style="padding:3px 8px;border-radius:4px;font-size:10px;background:#eff6ff;color:#2563eb">预览</span>
|
||||
<span style="padding:3px 8px;border-radius:4px;font-size:10px;background:#f0fdf4;color:#059669">下载</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:12px;text-align:center">
|
||||
<div style="font-weight:700;color:#b91c1c;font-size:14px;margin-bottom:4px">生成失败</div>
|
||||
<div style="font-size:11px;color:#dc2626">文档构建出错<br/>可查看预览数据</div>
|
||||
<div style="display:flex;gap:4px;justify-content:center;margin-top:8px">
|
||||
<span style="padding:3px 8px;border-radius:4px;font-size:10px;background:#eff6ff;color:#2563eb">预览</span>
|
||||
<span style="padding:3px 8px;border-radius:4px;font-size:10px;background:#fef3c7;color:#92400e">重试</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" style="margin-top:20px">
|
||||
<h3>与预览页的衔接</h3>
|
||||
</div>
|
||||
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">用户操作流程</div>
|
||||
<div class="mockup-body" style="padding:20px;text-align:center">
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:16px;font-size:13px">
|
||||
<div style="background:#f1f5f9;padding:12px 20px;border-radius:8px;font-weight:600;color:#0f172a">
|
||||
列表页<br/>点"新建报告"
|
||||
</div>
|
||||
<span style="font-size:18px;color:#94a3b8">→</span>
|
||||
<div style="background:#f1f5f9;padding:12px 20px;border-radius:8px;font-weight:600;color:#0f172a">
|
||||
弹窗选择<br/>月份/日期
|
||||
</div>
|
||||
<span style="font-size:18px;color:#94a3b8">→</span>
|
||||
<div style="background:#fef3c7;padding:12px 20px;border-radius:8px;font-weight:600;color:#92400e">
|
||||
1-2s 数据采集<br/>自动跳转预览页
|
||||
</div>
|
||||
<span style="font-size:18px;color:#94a3b8">→</span>
|
||||
<div style="background:#eff6ff;padding:12px 20px;border-radius:8px;font-weight:600;color:#2563eb">
|
||||
预览页<br/>确认数据
|
||||
</div>
|
||||
<span style="font-size:18px;color:#94a3b8">→</span>
|
||||
<div style="background:#f0fdf4;padding:12px 20px;border-radius:8px;font-weight:600;color:#059669">
|
||||
点"生成文档"<br/>下载 DOCX
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"type":"server-started","port":60240,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:60240","screen_dir":"/Users/niuniu/programs/docker/issue-ai/.superpowers/brainstorm/9392-1778134394/content","state_dir":"/Users/niuniu/programs/docker/issue-ai/.superpowers/brainstorm/9392-1778134394/state"}
|
||||
138
CHANGELOG.md
138
CHANGELOG.md
|
|
@ -1,5 +1,143 @@
|
|||
# 变更日志
|
||||
|
||||
## 2026-07-13
|
||||
|
||||
- [新增] 统一结单推送:从 worker 轮询 `fault_records` 上移到 API 层事件驱动(PUT [id]/batch 状态跳变触发),新增 `notifyTicketResolved` 与 `notifyTicketCreated` 并列构成完整通知层
|
||||
- [新增] 微信推送独立页面 `/settings/wechat`,与邮件监控完全解耦
|
||||
- [新增] `useDirtyTracker` 共享 Hook(`shared/ui/hooks/`),支持"修改后才显示保存按钮"模式,微信推送页每推送组独立保存、监控页统一 section 保存
|
||||
- [优化] 结单推送月度统计改为 `close_time` 归组,周报/月报按 `isRealFault` 过滤,统一排除 无故障/误判
|
||||
- [修复] 月报第四章跨月时长重复计算 bug(`isOngoing=true` 时不再计入 `ipDurationMap`)
|
||||
- [修复] `audit_logs` 表缺少 `username` 列导致 7月2日至今所有审计日志静默写入失败,补全 4 处 API 路由审计日志缺失
|
||||
- [修复] `getMaskedConfig()` 直接 mutate webhook URL 导致测试发送 `••••••••` 至企业微信(93000 invalid url)
|
||||
- [优化] 故障大类删除「其他」、故障小类删除「无故障」选项(3 处表单统一)
|
||||
- [安全] SSRF 加固:拒绝非规范数字型 IP 写法(整数/十六进制/八进制/短式),防绕过内网黑名单
|
||||
- [重构] worker `checkRecoveryUpdates` 完全移除(78 行),副作用迁移至 `resolution-side-effects.ts`
|
||||
|
||||
## 2026-07-02
|
||||
|
||||
- [优化] 企业微信推送消息格式全面对齐 zabbix-01 脚本(10 项修复)
|
||||
- 结单消息按故障类型区分(OEM诊断/OEM维修/其他),补齐机架位置、故障类型、故障时间
|
||||
- OEM维修结单消息新增本月故障次数和本月总处理时长
|
||||
- 新故障通知:OEM维修档位标题补全「基于月度内累计故障时长计算」
|
||||
- 新故障通知:OEM诊断字段顺序调整(到期时间在故障信息之前)
|
||||
- 每小时提醒显示「X小时Y分」精度
|
||||
- 静默期积压消息格式对齐:动态剩余时间、机架位置、diag_30min/diag_hourly 区分
|
||||
- [修复] `isInSilentPeriod()` / `isTimeToFlush()` 时区问题:新增 `getBeijingHour()` 使用 UTC+8 偏移,兼容 UTC 容器环境
|
||||
- [修复] 先推送后更新数据库,防止推送失败导致结单通知永久丢失
|
||||
- [修复] 共享库 `WeChatPusher` 错误消息不泄露 webhook URL
|
||||
- [修复] `faultDetail` 截断 500 字符,防止消息超企业微信 API 限制
|
||||
- [修复] `tierPenaltyMap` 提取为类级常量 `TIER_PENALTY_MAP`,消除 3 处重复定义
|
||||
- [修复] `deploy-ai.sh` Docker 构建前临时替换 shared 符号链接为实际目录,构建后恢复
|
||||
|
||||
## 2026-06-30
|
||||
|
||||
- [新增] SSO 统一认证:集成 Authelia OIDC,支持统一认证登录
|
||||
- [新增] OIDC 登录页面:添加「统一认证登录」按钮,支持 LDAP 回退
|
||||
- [新增] `src/lib/oidc.ts`:OIDC 客户端配置(PKCE + state + nonce)
|
||||
- [新增] `src/app/api/auth/login/oidc/route.ts`:OIDC 登录端点
|
||||
- [新增] `src/app/api/auth/callback/route.ts`:OIDC 回调处理(含权限同步)
|
||||
- [新增] `src/app/api/auth/logout/route.ts`:跨域登出(支持 domain 参数)
|
||||
- [修复] NODE_TLS_REJECT_UNAUTHORIZED=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
|
||||
|
||||
- [修复] SQLite 时区根源修复:重建表修改所有时间列 DEFAULT 为 `datetime('now', '+8 hours')`,涉及 7 个表 9 个列
|
||||
|
||||
## 2026-06-23(审计日志)
|
||||
|
||||
- [新增] 审计日志详情可视化:字段变更用表格对比,message/keys 格式分别用文本和列表展示
|
||||
- [新增] 保留天数可编辑:默认隐藏保存按钮,修改值后蓝色高亮显示,保存成功/失败有颜色反馈
|
||||
- [优化] 审计日志列表详情列:显示操作摘要(邮件监控/微信推送/配置修改等),不再显示"-"
|
||||
- [优化] 表格列宽统一:时间/用户/操作/对象列防止换行,与 assets-ai 风格一致
|
||||
- [修复] 监控设置页"已保存"提示重复:SectionSaveButton 移除冗余消息渲染
|
||||
- [修复] 导出文件名使用北京时间(修复凌晨时区偏移)
|
||||
|
||||
## 2026-06-23(邮件监控集成)
|
||||
|
||||
- [新增] 邮件监控系统:将 zabbix-01 上的 mail_monitor.py 完整迁移到 issue-ai,Node.js 实现
|
||||
- [新增] Background Worker:独立进程运行(entrypoint.sh),node-cron 定时调度,与 Next.js Server 同时启动
|
||||
- [新增] IMAP 邮件监控:自动连接邮箱、过滤故障邮件、解析 HTML 表格、提取故障信息
|
||||
- [新增] 自动工单创建:从邮件解析结果自动创建工单,关联设备信息
|
||||
- [新增] 微信 webhook 推送:故障通知、可用性档位提醒、工单结单提醒
|
||||
- [新增] OEM 诊断提醒:每小时提醒、30 分钟特别提醒、超时提醒
|
||||
- [新增] OEM 维修提醒:每档前 1 小时提醒、每档超时提醒、全部超出提醒
|
||||
- [新增] 静默期管理:0:00-7:00 不发定时提醒,7 点自动 flush 积压消息
|
||||
- [新增] 可用性计算:五档位(1%/3%/5%/10%),月度累计故障时长
|
||||
- [新增] 6 张数据库表:settings、fault_records、reminder_state、processed_emails、pending_messages、monitor_logs
|
||||
- [新增] 设置页面(/settings/monitor):邮箱配置、邮件过滤、微信推送、运行参数
|
||||
- [新增] 分模块保存按钮 + 高亮修改字段 + 错误详情可点击展开
|
||||
- [新增] 运行状态面板:今日处理、累计、错误、进行中故障均可点击展开详情
|
||||
- [新增] 监控 API:status/start/stop/trigger/settings/test-mail/test-wechat(admin 权限)
|
||||
- [新增] 审计日志集成:所有监控操作记录到 audit_logs
|
||||
- [新增] 数据迁移脚本:从 zabbix-01 迁移历史故障数据
|
||||
- [新增] Docker 集成:entrypoint.sh 包装脚本,builder 阶段编译 Worker 模块
|
||||
|
||||
## 2026-05-18
|
||||
|
||||
- [新增] 用户详情页 — 点击用户名查看完整信息,支持编辑和键盘导航
|
||||
- [优化] 用户管理 UI 统一:Modal/Button 拆分为独立文件、删除改用 Modal 确认、TopBar 样式统一
|
||||
- [调整] 用户列表页去创建时间列,编辑功能移到详情页,仅保留删除按钮
|
||||
- [新增] LDAP 邮箱自动同步:密码登录/SSO 路径均从 LLDAP 同步 `mail` 到本地 `users.email`,增加 `/api/users/sync-emails` 批量同步接口
|
||||
- [新增] 编辑用户弹窗增加启用/禁用状态切换、新密码二次确认
|
||||
- [优化] 角色权限页面 UI 统一:角色 Badge 按角色区分颜色、增加保存 loading、checkbox dark mode 样式
|
||||
- [优化] API Key 页面 UI 统一:成功提示颜色对齐、取消按钮 ghost 统一
|
||||
- [优化] 全站字体大小和颜色统一:列表页/详情页 `text-sm` 一致、dark mode 颜色补全
|
||||
- [修复] 用户管理"最后登录"时间不动态更新:SSO 免登录、本地 JWT 会话验证路径现在也会更新 `last_login_at`
|
||||
|
||||
## 2026-05-15
|
||||
|
||||
- [修复] middleware 移除 `better-sqlite3` 依赖,修复 Edge Runtime 不支持 Node.js 原生模块的编译报错(`The edge runtime does not support Node.js 'fs' module`)
|
||||
- [优化] deploy-ai.sh 连通性检查增加重试机制(3次/10s间隔),应对同时部署两个站点时目标容器暂不可达
|
||||
|
||||
## 2026-05-14
|
||||
|
||||
- [新增] 权限系统重构:工单细粒度权限(`tickets:create` 手动建单、`tickets:import` 导入、`tickets:export` 导出),`tickets:write` 缩窄为编辑/删除
|
||||
- [新增] 报告三层权限:`reports:read`(查看列表+预览)、`reports:download`(下载)、`reports:create`(新建+生成文档+删除)
|
||||
- [新增] `/api/auth/me` 返回 `permissions` 字段,前端根据权限数组控制按钮和导航显隐
|
||||
- [新增] 角色管理 UI 权限项从 8 个扩展到 14 个(含 `api-keys:read/write`)
|
||||
- [修复] 角色管理页面:admin/operator/viewer 标记为"内置"不可删除但可编辑,admin 在 API 层禁止删除
|
||||
- [修复] 报告 API 路由全部补全权限检查(此前列表/预览/下载/删除均无权限检查)
|
||||
- [修复] 导出工单 API 新增 `tickets:export` 权限检查(此前仅验证登录态)
|
||||
- [修复] POST 创建报告权限从错误的 `reports:read` 修正为 `reports:create`
|
||||
- [修复] `reports:write` 全面替换为 `reports:create`
|
||||
- [修复] 种子数据迁移逻辑不再每次启动覆盖用户自定义权限,仅首次安装时创建默认权限
|
||||
- [修复] SSO 新用户免登录失败:根页面 `getCurrentUser()` 改为直接 `redirect('/dashboard')`,由中间件统一处理共享 JWT 认证(与 assets-ai 行为一致)
|
||||
- [修复] 手动建单输入业务 IP 按回车无法查询节点名称和设备序列号:assets-ai `api_keys` 表中缺少 issue-ai 使用的 API Key,服务端调用返回 401,且前端静默吞错无提示
|
||||
- [优化] 中间件 API Key 验证统一为 ALLOWED_API_KEYS(快速路径)→ api_keys 数据库表(回退)两级验证;无效 key 不再被放行,中间件层直接返回 401(安全加固,与 assets-ai 行为一致)
|
||||
- [新增] 部署后自动验证 issue→assets API 连通性(`deploy-ai.sh`),API Key 不匹配时部署立即报错退出
|
||||
- [修复] `next.config.ts` 添加 `ldapts` 到 `serverExternalPackages`,确保 Next.js standalone 构建包含 LLDAP 客户端模块,避免 `ldapUserExists()` 因模块缺失失败导致 SSO 自动创建用户静默中断
|
||||
- [调整] 全局证书切换:Cloudflare Origin CA → Let's Encrypt(`/etc/letsencrypt/live/www.tlyq.ai/`),覆盖全部 7 个子域名,nginx 8 个站点配置同步更新
|
||||
|
||||
## 2026-05-12
|
||||
|
||||
- [部署] 云端 JWT 密钥统一为 oa-shared-jwt-secret-tlyq-2026,三站点密钥一致
|
||||
- [部署] docker-compose 移除 AUTHELIA_URL,添加 LDAP/COOKIE/INTERNAL_API_KEY 环境变量
|
||||
- [部署] LLDAP admin 密码更新为 3Vm!Y!@RCiPs
|
||||
- [部署] nginx 移除 auth_request,恢复纯反向代理;改为运行时 DNS 解析
|
||||
- [新增] `src/lib/db-schema.ts`:users 表新增 last_login_at / last_active_at 列
|
||||
- [新增] `src/lib/auth.ts`:getCurrentUser() 更新 last_active_at
|
||||
- [新增] `src/app/api/auth/login/route.ts`:登录时更新 last_login_at 和 last_active_at
|
||||
|
||||
## 2026-05-11
|
||||
|
||||
- [新增] `src/lib/ldap.ts`:`ldapUserExists()` 函数,检查 LLDAP 中用户是否存在(admin bind 搜索,不可达时容错放行)
|
||||
- [新增] `src/lib/jwt-shared.ts`:共享 JWT 签发/验证(`tlyq_session` cookie,HS256,与 OA/assets 共用密钥)
|
||||
- [调整] `src/lib/auth.ts`:`getCurrentUser()` 优先 `tlyq_session`,加入 LLDAP 存在性检查,用户被删除后自动清除 cookie 踢出
|
||||
- [调整] `src/app/api/auth/login/route.ts`:LDAP 优先认证 + 本地密码缓存回退 + localadmin 应急用户直连
|
||||
- [调整] `src/app/api/auth/logout/route.ts`:同时清除 `session_issue` 和 `tlyq_session`
|
||||
- [调整] `src/app/api/auth/me/route.ts`:移除 SSO header 路径,改用 `getCurrentUser()` 统一获取
|
||||
- [调整] `src/middleware.ts`:优先 `tlyq_session` → 回退 `session_issue`,移除 SSO 代理路径,放行 `/api/internal/`
|
||||
- [新增] `src/app/api/internal/roles/route.ts`:内部 API,返回站点可用角色列表(INTERNAL_API_KEY 鉴权)
|
||||
- [新增] `src/app/api/users/[id]/route.ts`:admin/localadmin 用户禁止删除和修改角色
|
||||
- [修复] `src/components/tickets/TicketList.tsx`:已办工单点击业务IP/节点名称跳转到待办页面,改用 `usePathname()` 保持当前页面并正确筛选
|
||||
- [调整] `src/app/(app)/settings/users/page.tsx`:admin/localadmin 用户隐藏删除按钮,编辑时角色字段显示为只读
|
||||
- [新增] `src/lib/db-schema.ts`:预置 localadmin 应急用户(admin 角色,纯本地 BCrypt 认证)
|
||||
|
||||
## 2026-05-07
|
||||
|
||||
- [新增] 月报跨月进行中工单支持:第一章折线图覆盖未结单离线天数,第二章标注"处理中",第三章显示"进行中"/"—",第四章标注"仅计本月部分"
|
||||
|
|
|
|||
109
CLAUDE.md
109
CLAUDE.md
|
|
@ -13,7 +13,7 @@ issue-ai 是基于 Next.js + SQLite 的工单跟踪管理系统,部署在腾
|
|||
| 站点域名 | `issue.tlyq.ai` |
|
||||
| 服务器 | txjp(IP: 43.133.38.210) |
|
||||
| 代码路径 | `/root/docker/issue-ai/` |
|
||||
| 本地端口 | 5176 |
|
||||
| 本地端口 | 6176 |
|
||||
| 容器名 | `issue-ai` |
|
||||
| 数据库 | SQLite:`data/issue.db` |
|
||||
| 报告存储 | `reports/` 目录(环境变量 `REPORTS_DIR` 可覆盖) |
|
||||
|
|
@ -48,6 +48,19 @@ npm run import # 导入工单
|
|||
| `src/lib/docx-export.ts` | Word 文档生成 |
|
||||
| `src/lib/excel.ts` | Excel 解析与导出 |
|
||||
| `src/lib/pdf.ts` | PDF 生成(puppeteer) |
|
||||
| `src/lib/audit.ts` | 审计日志(writeAuditLog、diffObjects、getClientIP) |
|
||||
| `src/lib/monitor/types.ts` | 邮件监控:类型定义 |
|
||||
| `src/lib/monitor/settings-manager.ts` | 邮件监控:配置管理(AES 加密) |
|
||||
| `src/lib/monitor/worker.ts` | 邮件监控:Worker 主类(cron 调度、主循环) |
|
||||
| `src/lib/monitor/mail-monitor.ts` | 邮件监控:IMAP 连接、邮件过滤、解析 |
|
||||
| `src/lib/monitor/ticket-processor.ts` | 邮件监控:工单创建、故障记录 |
|
||||
| `src/lib/monitor/wechat-pusher.ts` | 邮件监控:微信 webhook 推送 |
|
||||
| `src/lib/monitor/ticket-notifier.ts` | 邮件监控:建单/结单推送(`notifyTicketCreated` + `notifyTicketResolved`) |
|
||||
| `src/lib/monitor/resolution-side-effects.ts` | 邮件监控:结单副作用(回写 fault_records + 清理 reminder_state) |
|
||||
| `src/lib/monitor/availability-engine.ts` | 邮件监控:可用性档位计算 |
|
||||
| `src/lib/monitor/reminder-engine.ts` | 邮件监控:OEM 诊断/维修提醒 |
|
||||
| `scripts/monitor-worker.ts` | 邮件监控:Worker 独立入口脚本 |
|
||||
| `scripts/entrypoint.sh` | Docker 包装脚本(同时启动 Server + Worker) |
|
||||
| `src/types/ticket.ts` | TicketCreateInput / TicketUpdateInput |
|
||||
| `src/types/report.ts` | ReportType / ReportData |
|
||||
|
||||
|
|
@ -66,6 +79,12 @@ npm run import # 导入工单
|
|||
| `ticket_steps` | 工单时间线(关联 ticket_id) |
|
||||
| `reports` | 报告记录 |
|
||||
| `audit_logs` | 审计日志 |
|
||||
| `settings` | 键值对配置(含邮件监控配置) |
|
||||
| `fault_records` | 邮件监控:故障记录 |
|
||||
| `reminder_state` | 邮件监控:提醒状态 |
|
||||
| `processed_emails` | 邮件监控:已处理邮件去重 |
|
||||
| `pending_messages` | 邮件监控:静默期积压队列 |
|
||||
| `monitor_logs` | 邮件监控:运行日志 |
|
||||
|
||||
### tickets 表核心字段
|
||||
|
||||
|
|
@ -99,11 +118,18 @@ npm run import # 导入工单
|
|||
|
||||
### 认证
|
||||
|
||||
登录逻辑(v2.1):LDAP 优先 + 本地密码缓存回退 + localadmin 应急用户。
|
||||
登录成功签发两个 cookie:`session_issue`(本地 JWT,7 天)+ `tlyq_session`(共享 JWT,7 天,domain=.tlyq.ai)。
|
||||
中间件优先检查 `tlyq_session`,回退 `session_issue`。`getCurrentUser()` 每次验证时检查 LLDAP 用户是否存在(已删除则清除 cookie 踢出)。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/api/auth/login` | 登录(username + password → JWT cookie) |
|
||||
| POST | `/api/auth/logout` | 登出 |
|
||||
| POST | `/api/auth/login` | 登录(LDAP 优先 + 本地回退) |
|
||||
| POST | `/api/auth/logout` | 登出(清除两个 cookie) |
|
||||
| GET | `/api/auth/me` | 当前用户信息 |
|
||||
| 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 鉴权) |
|
||||
|
||||
### 工单
|
||||
|
||||
|
|
@ -131,6 +157,28 @@ npm run import # 导入工单
|
|||
| GET/POST | `/api/roles` | 角色列表/创建 |
|
||||
| GET/PUT/DELETE | `/api/roles/[id]` | 单个角色操作 |
|
||||
|
||||
### 邮件监控(admin 权限)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/monitor/status` | 运行状态 + 健康检查 + 详情 |
|
||||
| POST | `/api/monitor/start` | 启用监控 |
|
||||
| POST | `/api/monitor/stop` | 停用监控 |
|
||||
| POST | `/api/monitor/trigger` | 手动触发检查(30 秒冷却) |
|
||||
| GET | `/api/monitor/settings` | 获取配置(密码脱敏) |
|
||||
| PUT | `/api/monitor/settings` | 更新配置(支持分模块保存) |
|
||||
| POST | `/api/monitor/test-mail` | 测试 IMAP 连接 |
|
||||
| POST | `/api/monitor/test-wechat` | 测试微信 webhook |
|
||||
| 前端页面 | `/settings/wechat` | webhook 独立管理页(微信推送配置) |
|
||||
|
||||
---
|
||||
|
||||
## 认证机制
|
||||
|
||||
- **Web UI(v2.1)**:`middleware.ts` 优先检查 `tlyq_session`(共享 JWT,OA 统一签发)→ 回退 `session_issue`(本地 JWT)。`getCurrentUser()` 每次请求时检查 LLDAP 用户是否存在,已删除则清除 cookie 踢出
|
||||
- **localadmin**:纯本地 BCrypt 认证,不依赖 LLDAP,用于 LLDAP 故障时应急登录(DB 预置,admin 角色)
|
||||
- **API Key(v2.2)**:`middleware.ts` 检查 `ALLOWED_API_KEYS` 环境变量(逗号分隔明文 key),无效 key 在中间件层直接返回 401。注意:middleware 运行在 Edge Runtime,不能使用 `better-sqlite3`,DB 级别的 key 验证由 route handler 中的 `auth.ts verifyApiKey()` 进行(查 `api_keys` 表 SHA-256 hash)。外部系统调用本系统 API 时,key 必须注册在 `ALLOWED_API_KEYS` 中(Web UI 创建的 key 还需同步到环境变量,重启容器后生效)
|
||||
|
||||
---
|
||||
|
||||
## 环境配置
|
||||
|
|
@ -139,13 +187,18 @@ npm run import # 导入工单
|
|||
|
||||
| 环境变量 | 本地开发 | 云服务器(txjp) | 说明 |
|
||||
|---------|---------|----------------|------|
|
||||
| `ASSETS_API_URL` | `http://localhost:5177/api` | `http://assets-ai:3000/api` | 调用 assets API 地址 |
|
||||
| `ASSETS_API_URL` | `http://localhost:6177/api` | `http://assets-ai:3000/api` | 调用 assets API 地址 |
|
||||
| `ASSETS_API_KEY` | 本地 assets-ai 生成 | 云上 assets-ai 生成 | **每个环境独立,不可跨环境使用** |
|
||||
| `NEXT_PUBLIC_ASSETS_URL` | `http://localhost:5177` | `https://assets.tlyq.ai` | 前端跳转链接(构建时内嵌) |
|
||||
| `NEXT_PUBLIC_ASSETS_URL` | `http://localhost:6177` | `https://assets.tlyq.ai` | 前端跳转链接(构建时内嵌) |
|
||||
| `ALLOWED_API_KEYS` | 本地 issue-ai 生成的 Key | 云上 issue-ai 生成的 Key | 允许外部系统调本系统的 Key,逗号分隔 |
|
||||
| `JWT_SECRET` | `dev-secret-key-local` | `${ISSUE_JWT_SECRET}` | 本地两系统需一致(同 localhost 域) |
|
||||
| `DATABASE_PATH` | `./data/issue.db` | `/app/data/issue.db` | Docker volume 挂载 |
|
||||
| Cookie 名 | `session_issue` | `session_issue` | 本地用不同名防 localhost 域冲突 |
|
||||
| `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` 示例
|
||||
|
||||
|
|
@ -154,10 +207,14 @@ DATABASE_PATH=./data/issue.db
|
|||
JWT_SECRET=dev-secret-key-local
|
||||
ADMIN_PASSWORD=admin123
|
||||
NODE_ENV=development
|
||||
ASSETS_API_URL=http://localhost:5177/api
|
||||
ASSETS_API_URL=http://localhost:6177/api
|
||||
ASSETS_API_KEY=ak_<32字节十六进制>
|
||||
ALLOWED_API_KEYS=ak_<32字节十六进制>
|
||||
NEXT_PUBLIC_ASSETS_URL=http://localhost:5177
|
||||
NEXT_PUBLIC_ASSETS_URL=http://localhost:6177
|
||||
AUTHELIA_URL=https://sso.tlyq.ai
|
||||
OIDC_CLIENT_ID=issue-oidc
|
||||
OIDC_CLIENT_SECRET=<本地生成的哈希值>
|
||||
OIDC_REDIRECT_URI=http://localhost:6176/api/auth/callback
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -176,10 +233,14 @@ assets-ai ──→ GET {ISSUE_API_URL}/tickets/by-asset?ip=xxx (Authori
|
|||
|
||||
Key 格式:`ak_<32位十六进制>`,认证头:`Authorization: Bearer <key>`。每个环境独立创建,互不通用。
|
||||
|
||||
**issue → assets 方向**:在 assets-ai `/settings/api-keys` 创建 Key → 写入 issue-ai 的 `ASSETS_API_KEY`
|
||||
**验证策略(v2.2)**:两边中间件均采用 `ALLOWED_API_KEYS` 环境变量 → `api_keys` 数据库表两级验证。Key 注册在任意一处即可通过认证,推荐走 Web UI 创建(写入 `api_keys` 表,支持权限和追踪)。
|
||||
|
||||
**issue → assets 方向**:在 assets-ai `/settings/api-keys` 创建 Key → 写入 issue-ai 的 `ASSETS_API_KEY`(`.env` 或 `docker-compose.yml` 环境变量)
|
||||
|
||||
**assets → issue 方向**:在 issue-ai `/settings/api-keys` 创建 Key → 写入 assets-ai 的 `ISSUE_API_KEY` + issue-ai 的 `ALLOWED_API_KEYS`
|
||||
|
||||
> **部署验证**:`deploy-ai.sh` 部署 issue 站点后会自动验证 issue→assets API 连通性(使用配置的 `ASSETS_API_KEY` 调 assets API),若返回 401 则部署失败退出,防止 API Key 配置遗漏上线。
|
||||
|
||||
---
|
||||
|
||||
## Docker 部署
|
||||
|
|
@ -225,7 +286,37 @@ NEXT_PUBLIC_ASSETS_URL=https://assets.tlyq.ai
|
|||
- **新增 API**:在 `src/app/api/` 下创建路由 → 顶部调用 `initDatabase()` → `getCurrentUser()` 验证 → `hasPermission()` 校验
|
||||
- **新增页面**:在 `src/app/(app)/` 下创建 → 布局由 `(app)/layout.tsx` 提供
|
||||
- **权限格式**:`resource:action`,如 `hasPermission(user, 'tickets:write')`
|
||||
- **日期处理**:禁止使用 `Date.toISOString()` 格式化本地日期。`toISOString()` 返回 UTC 时间,在中国时区(UTC+8)下 `new Date('2026-04-01T00:00:00').toISOString()` 会返回 `"2026-03-31T16:00:00.000Z"`,日期偏移一天。应使用本地时间方法拼接:`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||
- **审计日志**:所有写操作 API(POST/PUT/DELETE)必须添加审计日志,使用 `writeAuditLog()` 函数:
|
||||
```typescript
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
// 在操作成功后调用
|
||||
writeAuditLog({
|
||||
userId: user.id, // issue-ai 使用 user.id
|
||||
apiKeyId: null, // 或 apiKeyInfo?.id
|
||||
action: 'create' | 'update' | 'delete' | 'batch_update' | 'import' | 'export' | 'generate',
|
||||
entityType: 'ticket' | 'report' | 'user' | 'role' | 'api_key' | 'auth',
|
||||
entityId: ticketId, // 可选
|
||||
details: { created: { ticket_no, device_ip } }, // 或 { changes: diffObjects(old, new) } 或 { deleted: { ... } }
|
||||
ipAddress: getClientIP(request)
|
||||
})
|
||||
```
|
||||
- **日期处理(时区规范)**:整个系统统一使用 UTC+8(北京时间)。必须遵守:
|
||||
1. **JavaScript/TypeScript**:禁止使用 `Date.toISOString()` 格式化本地日期。`toISOString()` 返回 UTC 时间,在中国时区(UTC+8)下 `new Date('2026-04-01T00:00:00').toISOString()` 会返回 `"2026-03-31T16:00:00.000Z"`,日期偏移一天。应使用本地时间方法拼接:`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||
2. **SQLite**:所有 `datetime('now')` 必须写成 `datetime('now', '+8 hours')`,包括 UPDATE/SET 语句、以及查询条件中的时间比较。禁止使用不含时区偏移的 `datetime('now')`。
|
||||
3. **INSERT 时间列**:数据库表的 DEFAULT 已统一为 `datetime('now', '+8 hours')`,INSERT 时可省略时间列。如需显式设置,使用 `datetime('now', '+8 hours')`。
|
||||
|
||||
---
|
||||
|
||||
## Git Tag 规范
|
||||
|
||||
使用日期版本号 `vYYYY.MM.DD`(如 `v2026.05.18`)。提交后打 tag 再推送:
|
||||
|
||||
```bash
|
||||
git tag v$(date +%Y.%m.%d) && git push origin main && git push origin v$(date +%Y.%m.%d)
|
||||
```
|
||||
|
||||
同一天多次提交只打一个 tag。详见根目录 `CLAUDE.md`。
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
19
Dockerfile
19
Dockerfile
|
|
@ -7,6 +7,8 @@ RUN --mount=type=cache,target=/root/.npm,id=issue-npm \
|
|||
npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
# 编译 Worker 入口脚本 + monitor 模块 + db 模块为 JS(使用专用 tsconfig 解析 @/ 路径别名)
|
||||
RUN npx tsc -p tsconfig.worker.json && npx tsc-alias -p tsconfig.worker.json
|
||||
|
||||
# runner 阶段:使用 Debian(glibc)以支持 better-sqlite3 等 glibc 原生模块
|
||||
FROM node:20-slim AS runner
|
||||
|
|
@ -31,12 +33,25 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||
libasound2 \
|
||||
libpango-1.0-0 \
|
||||
libcairo2 \
|
||||
sqlite3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /app/package.json /app/package-lock.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
RUN npm install --omit=dev && \
|
||||
npm rebuild better-sqlite3 && \
|
||||
rm -rf node_modules/@img/sharp-linuxmusl-x64 node_modules/@img/sharp-libvips-linuxmusl-x64 \
|
||||
node_modules/@next/swc-linux-x64-musl
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
# 复制编译后的 Worker 文件(非 .ts 源码)
|
||||
COPY --from=builder /app/.next/standalone/scripts/monitor-worker.js /app/scripts/
|
||||
COPY --from=builder /app/.next/standalone/src/lib/monitor /app/src/lib/monitor
|
||||
COPY --from=builder /app/.next/standalone/src/lib/db.js /app/src/lib/
|
||||
COPY --from=builder /app/.next/standalone/src/lib/db-schema.js /app/src/lib/
|
||||
COPY --from=builder /app/.next/standalone/src/lib/assets-client.js /app/src/lib/
|
||||
RUN mkdir -p /app/data /app/uploads /app/reports
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
# 使用 entrypoint.sh 同时启动 Next.js Server 和 Monitor Worker
|
||||
COPY entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
CMD ["/app/entrypoint.sh"]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
// __tests__/monitor/availability-engine.test.ts
|
||||
import { AvailabilityEngine } from '../../src/lib/monitor/availability-engine'
|
||||
|
||||
// Mock getDb
|
||||
jest.mock('../../src/lib/db', () => ({
|
||||
getDb: () => ({
|
||||
prepare: () => ({
|
||||
get: () => ({ total: 0 }),
|
||||
all: () => [],
|
||||
run: () => {},
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('AvailabilityEngine', () => {
|
||||
const engine = new AvailabilityEngine()
|
||||
|
||||
it('should calculate days in month correctly', () => {
|
||||
expect(engine.getDaysInMonth('2026-03')).toBe(31)
|
||||
expect(engine.getDaysInMonth('2026-02')).toBe(28)
|
||||
expect(engine.getDaysInMonth('2026-04')).toBe(30)
|
||||
})
|
||||
|
||||
it('should calculate OEM diag deadline (first tier only)', () => {
|
||||
const deadline = engine.calculateOemDiagDeadline('2026-03', '2026-03-10 16:44:01')
|
||||
expect(deadline).toBeTruthy()
|
||||
// 31 days = 2678400 seconds, 1% = 26784 seconds ≈ 7.44 hours
|
||||
const fault = new Date('2026-03-10T16:44:01+08:00')
|
||||
const deadlineStr = deadline as string
|
||||
const deadlineDate = new Date(deadlineStr.replace(' ', 'T') + '+08:00')
|
||||
const diffHours = (deadlineDate.getTime() - fault.getTime()) / (1000 * 3600)
|
||||
expect(diffHours).toBeCloseTo(7.44, 1)
|
||||
})
|
||||
|
||||
it('should calculate tier deadlines', () => {
|
||||
const deadlines = engine.calculateTierDeadlines('2026-03', 'SN123', '2026-03-10 16:44:01')
|
||||
expect(deadlines['第一档']).toBeTruthy()
|
||||
expect(deadlines['第一档'].deadline).not.toBe('已超出')
|
||||
expect(deadlines['第一档'].hours).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should format deadline in UTC+8', () => {
|
||||
const deadline = engine.calculateOemDiagDeadline('2026-03', '2026-03-10 16:44:01')
|
||||
// 应该是 UTC+8 格式,不是 UTC
|
||||
expect(deadline).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
# issue-ai 生产环境配置(模板)
|
||||
DATABASE_PATH=/app/data/issue.db
|
||||
JWT_SECRET=__JWT_SECRET__
|
||||
COOKIE_DOMAIN=.tlyq.ai
|
||||
NODE_ENV=production
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
LDAP_URL=ldap://lldap:3890
|
||||
LDAP_BASE_DN=dc=tlyq,dc=ai
|
||||
LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
|
||||
AUTHELIA_URL=https://sso.tlyq.ai
|
||||
OIDC_CLIENT_ID=issue-oidc
|
||||
OIDC_CLIENT_SECRET=__OIDC_CLIENT_SECRET__
|
||||
OIDC_REDIRECT_URI=https://issue.tlyq.ai/api/auth/callback
|
||||
ASSETS_API_URL=http://assets-ai:3000/api
|
||||
NEXT_PUBLIC_ASSETS_URL=https://assets.tlyq.ai
|
||||
|
|
@ -3,22 +3,41 @@ services:
|
|||
build: .
|
||||
container_name: issue-ai
|
||||
ports:
|
||||
- "5176:3000"
|
||||
- "6176:3000"
|
||||
volumes:
|
||||
- issue-data:/app/data
|
||||
- issue-uploads:/app/uploads
|
||||
- issue-reports:/app/reports
|
||||
# .next 目录从主机挂载,npm run build 后直接生效,无需重建镜像
|
||||
- ./.next:/app/.next
|
||||
# 运行时从 LLDAP 容器动态读取 admin 密码(已迁移至环境变量注入)
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
environment:
|
||||
- DATABASE_PATH=/app/data/issue.db
|
||||
- JWT_SECRET=${ISSUE_JWT_SECRET:-change-me-in-production}
|
||||
- JWT_SECRET=${JWT_SECRET:-oa-shared-jwt-secret-tlyq-2026}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||
- LLDAP_ADMIN_PASSWORD=${LLDAP_ADMIN_PASSWORD}
|
||||
- ASSETS_API_URL=${ASSETS_API_URL:-https://assets.tlyq.ai/api}
|
||||
- ASSETS_API_KEY=${ASSETS_API_KEY}
|
||||
- ALLOWED_API_KEYS=${ALLOWED_API_KEYS}
|
||||
- NODE_ENV=production
|
||||
- COOKIE_DOMAIN=.tlyq.ai
|
||||
- AUTHELIA_URL=${AUTHELIA_URL:-https://sso.tlyq.ai}
|
||||
- LDAP_URL=ldap://lldap:3890
|
||||
- LDAP_BASE_DN=dc=tlyq,dc=ai
|
||||
- LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
|
||||
- TZ=Asia/Shanghai
|
||||
- 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
|
||||
networks:
|
||||
- webnet
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
#!/bin/bash
|
||||
# entrypoint.sh — 同时启动 Next.js Server 和 Monitor Worker
|
||||
|
||||
HOSTNAME=0.0.0.0 node /app/server.js &
|
||||
SERVER_PID=$!
|
||||
|
||||
cd /app && node scripts/monitor-worker.js &
|
||||
WORKER_PID=$!
|
||||
|
||||
cleanup() {
|
||||
echo "Stopping Server (PID=$SERVER_PID) and Worker (PID=$WORKER_PID)..."
|
||||
kill $SERVER_PID 2>/dev/null
|
||||
kill $WORKER_PID 2>/dev/null
|
||||
wait $SERVER_PID 2>/dev/null
|
||||
wait $WORKER_PID 2>/dev/null
|
||||
echo "Shutdown complete."
|
||||
}
|
||||
trap cleanup SIGTERM SIGINT
|
||||
|
||||
# 等待任一进程退出(需要 Bash 4.3+)
|
||||
wait -n $SERVER_PID $WORKER_PID
|
||||
EXIT_CODE=$?
|
||||
cleanup
|
||||
exit $EXIT_CODE
|
||||
|
|
@ -4,7 +4,9 @@ const nextConfig: NextConfig = {
|
|||
images: { unoptimized: true },
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
serverExternalPackages: ['better-sqlite3'],
|
||||
// better-sqlite3: 原生模块,必须 external
|
||||
// ldapts: SSO 自动创建用户依赖 LLDAP 验证,缺少则新用户无法免登录进入系统
|
||||
serverExternalPackages: ['better-sqlite3', 'ldapts'],
|
||||
// 确保 fs.readFileSync 加载的文件也被追踪到 standalone 输出中
|
||||
// 防止 Docker 镜像中 npm install --omit=dev 漏装时缺失依赖
|
||||
outputFileTracingIncludes: {
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
|
|
@ -3,7 +3,7 @@
|
|||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "NODE_OPTIONS='--max-old-space-size=2048' next dev --port 5176",
|
||||
"dev": "NODE_OPTIONS='--max-old-space-size=2048' next dev --port 6176",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
|
|
@ -13,13 +13,20 @@
|
|||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"cheerio": "^1.2.0",
|
||||
"cookie": "^1.0.2",
|
||||
"docx": "^9.1.1",
|
||||
"echarts": "^5.5.0",
|
||||
"imap-simple": "^5.1.0",
|
||||
"imapflow": "^1.4.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"ldapts": "^8.1.7",
|
||||
"lucide-react": "^1.8.0",
|
||||
"mailparser": "^3.9.11",
|
||||
"next": "^15.1.0",
|
||||
"node-cron": "^4.5.0",
|
||||
"openid-client": "^5.7.1",
|
||||
"puppeteer": "^23.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
|
@ -37,6 +44,7 @@
|
|||
"eslint": "^10.2.1",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tsc-alias": "^1.8.10",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
|
|
@ -0,0 +1,53 @@
|
|||
// scripts/migrate-monitor-data.ts
|
||||
// 从 zabbix-01 拉取历史数据并迁移到 issue-ai SQLite
|
||||
// 用法: ssh zabbix-01 "cat /opt/scripts/.../server_availability_data.json" | tsx scripts/migrate-monitor-data.ts
|
||||
|
||||
import { execSync } from 'child_process'
|
||||
import { getDb } from '../src/lib/db'
|
||||
import { initDatabase } from '../src/lib/db-schema'
|
||||
|
||||
initDatabase()
|
||||
const db = getDb()
|
||||
|
||||
interface FaultRecord { fault_time: string; fault_type: string; order_number: string; recovery_time: string | null; duration: number | null; status: string }
|
||||
interface ServerData { server_ip: string; fault_records: FaultRecord[]; total_fault_duration: number; ongoing_faults: string[] }
|
||||
|
||||
// 拉取 server_availability_data.json
|
||||
console.log('从 zabbix-01 拉取数据...')
|
||||
const availData = JSON.parse(execSync('ssh zabbix-01 "cat /opt/scripts/mail_monitor_wechat/data/server_availability_data.json"', { encoding: 'utf-8' }))
|
||||
|
||||
let migrated = 0, matched = 0, skipped = 0, errors = 0
|
||||
|
||||
for (const [monthKey, servers] of Object.entries(availData) as [string, Record<string, ServerData>][]) {
|
||||
for (const [sn, serverData] of Object.entries(servers)) {
|
||||
for (const record of serverData.fault_records) {
|
||||
try {
|
||||
// 检查工单是否存在
|
||||
if (record.order_number) {
|
||||
const ticket = db.prepare('SELECT id FROM tickets WHERE id = ?').get(parseInt(record.order_number))
|
||||
if (!ticket) { skipped++; continue }
|
||||
matched++
|
||||
}
|
||||
// 检查是否已存在
|
||||
const existing = db.prepare('SELECT id FROM fault_records WHERE server_sn = ? AND fault_time = ?').get(sn, record.fault_time)
|
||||
if (existing) { skipped++; continue }
|
||||
|
||||
const status = record.status === 'resolved' ? 'resolved' : 'ongoing'
|
||||
db.prepare(`
|
||||
INSERT INTO fault_records (server_sn, server_ip, order_number, fault_type, fault_time, recovery_time, duration_seconds, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(sn, serverData.server_ip, record.order_number, record.fault_type, record.fault_time, record.recovery_time, record.duration, status)
|
||||
migrated++
|
||||
} catch (e) {
|
||||
errors++
|
||||
console.error(`迁移失败: ${sn} - ${record.fault_time}: ${e}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n迁移完成:`)
|
||||
console.log(` 成功: ${migrated}`)
|
||||
console.log(` 匹配工单: ${matched}`)
|
||||
console.log(` 跳过: ${skipped}`)
|
||||
console.log(` 错误: ${errors}`)
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
// scripts/monitor-worker.ts
|
||||
const path = require('path')
|
||||
const dbPath = process.env.DATABASE_PATH || path.join(process.cwd(), 'data', 'issue.db')
|
||||
process.env.DATABASE_PATH = dbPath
|
||||
|
||||
const { getDb } = require('../src/lib/db')
|
||||
const { initDatabase } = require('../src/lib/db-schema')
|
||||
const { BackgroundWorker } = require('../src/lib/monitor/worker')
|
||||
const { setSetting, getSetting } = require('../src/lib/monitor/settings-manager')
|
||||
|
||||
initDatabase()
|
||||
const db = getDb()
|
||||
|
||||
// 生成加密密钥(首次启动)
|
||||
if (!getSetting('monitor.encryption_key')) {
|
||||
const crypto = require('crypto')
|
||||
const newKey = crypto.randomBytes(32).toString('hex')
|
||||
setSetting('monitor.encryption_key', newKey, 'security')
|
||||
console.log('[Worker] 已生成加密密钥')
|
||||
}
|
||||
|
||||
const worker = new BackgroundWorker()
|
||||
if (getSetting('monitor.enabled') === 'true') {
|
||||
worker.start()
|
||||
console.log('[Worker] 邮件监控已启动')
|
||||
} else {
|
||||
console.log('[Worker] 邮件监控未启用,等待 API 启用')
|
||||
}
|
||||
|
||||
// 保持进程存活,定期检查监控状态是否变更
|
||||
const CHECK_INTERVAL = 30_000 // 30秒
|
||||
setInterval(() => {
|
||||
if (getSetting('monitor.enabled') === 'true' && !worker.cronJob) {
|
||||
worker.start()
|
||||
console.log('[Worker] 检测到监控已启用,自动启动')
|
||||
} else if (getSetting('monitor.enabled') !== 'true' && worker.cronJob) {
|
||||
worker.stop()
|
||||
console.log('[Worker] 检测到监控已禁用,自动停止')
|
||||
}
|
||||
}, CHECK_INTERVAL)
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('[Worker] 收到 SIGTERM,正在停止...')
|
||||
worker.stop()
|
||||
process.exit(0)
|
||||
})
|
||||
|
|
@ -0,0 +1,418 @@
|
|||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Card, Button } from '@/components/ui'
|
||||
import { Download, ArrowLeft, FileText, RefreshCw } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Report {
|
||||
id: number
|
||||
report_type: string
|
||||
period_start: string | null
|
||||
period_end: string | null
|
||||
format: string
|
||||
status: string
|
||||
created_at: string
|
||||
file_name?: string | null
|
||||
file_path?: string | null
|
||||
error_message?: string | null
|
||||
metadata?: string | null
|
||||
}
|
||||
|
||||
const typeLabel: Record<string, string> = {
|
||||
weekly: '周报',
|
||||
monthly: '月报',
|
||||
}
|
||||
|
||||
export default function ReportDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const [report, setReport] = useState<Report | null>(null)
|
||||
const [reportData, setReportData] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [permissions, setPermissions] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
.then(r => r.json())
|
||||
.then(u => { if (u.user?.permissions) setPermissions(u.user.permissions) })
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const fetchReport = () => {
|
||||
fetch(`/api/reports/${params.id}`)
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.report) setReport(d.report)
|
||||
if (d.reportData) setReportData(d.reportData)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => { fetchReport() }, [params.id])
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!report) return
|
||||
setGenerating(true)
|
||||
try {
|
||||
const res = await fetch(`/api/reports/${report.id}/generate`, { method: 'POST' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setReport(data.report)
|
||||
pollUntilDone()
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({ error: '生成失败' }))
|
||||
alert(data.error || '生成失败')
|
||||
setGenerating(false)
|
||||
}
|
||||
} catch {
|
||||
alert('生成失败')
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const pollUntilDone = async () => {
|
||||
if (!report) return
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < 120000) {
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
const res = await fetch(`/api/reports/${report.id}`)
|
||||
if (!res.ok) continue
|
||||
const data = await res.json()
|
||||
const rpt = data.report
|
||||
setReport(rpt)
|
||||
if (rpt?.status === 'completed' || rpt?.status === 'failed') {
|
||||
setGenerating(false)
|
||||
if (data.reportData) setReportData(data.reportData)
|
||||
return
|
||||
}
|
||||
}
|
||||
setGenerating(false)
|
||||
}
|
||||
|
||||
const can = (perm: string) => permissions.includes('*') || permissions.includes(perm)
|
||||
|
||||
const renderRightButton = () => {
|
||||
if (!report) return null
|
||||
|
||||
switch (report.status) {
|
||||
case 'ready':
|
||||
return can('reports:create') ? (
|
||||
<Button size="sm" onClick={handleGenerate} loading={generating} className="bg-emerald-600 hover:bg-emerald-700 text-white">
|
||||
<FileText size={16} className="mr-1" />生成报告文档
|
||||
</Button>
|
||||
) : null
|
||||
case 'generating':
|
||||
return (
|
||||
<Button size="sm" disabled className="bg-slate-400 text-white cursor-not-allowed">
|
||||
<span className="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-1" />
|
||||
生成中...
|
||||
</Button>
|
||||
)
|
||||
case 'completed':
|
||||
return can('reports:download') ? (
|
||||
<Button size="sm" onClick={() => window.open(`/api/reports/${report.id}/download`, '_blank')} className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Download size={16} className="mr-1" />下载报告
|
||||
</Button>
|
||||
) : null
|
||||
case 'failed':
|
||||
return can('reports:create') ? (
|
||||
<Button size="sm" onClick={handleGenerate} loading={generating} className="bg-amber-500 hover:bg-amber-600 text-white">
|
||||
<RefreshCw size={16} className="mr-1" />重新生成
|
||||
</Button>
|
||||
) : null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const isNewFormat = reportData?.gpuCount !== undefined
|
||||
|
||||
if (loading) return <div className="text-center py-12 text-slate-500 dark:text-slate-400">加载中...</div>
|
||||
if (!report) return <div className="text-center py-12 text-slate-500 dark:text-slate-400">报告不存在</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-slate-200 dark:border-slate-700 pb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/reports">
|
||||
<Button variant="ghost" size="sm"><ArrowLeft size={18} /></Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{typeLabel[report.report_type] || report.report_type}
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{report.period_start} ~ {report.period_end}
|
||||
</p>
|
||||
</div>
|
||||
{report.status === 'ready' && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500" />数据已就绪
|
||||
</span>
|
||||
)}
|
||||
{report.status === 'generating' && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400">
|
||||
<span className="inline-block w-2.5 h-2.5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />文档生成中
|
||||
</span>
|
||||
)}
|
||||
{report.status === 'completed' && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />已完成
|
||||
</span>
|
||||
)}
|
||||
{report.status === 'failed' && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-500" />生成失败
|
||||
</span>
|
||||
)}
|
||||
{report.status === 'pending' && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-slate-400" />待生成
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{renderRightButton()}
|
||||
</div>
|
||||
|
||||
{report.status === 'failed' && report.error_message && (
|
||||
<Card className="p-4 bg-red-50 dark:bg-red-500/10 border-red-200 dark:border-red-500/20">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{report.error_message}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ===== 旧格式兼容 ===== */}
|
||||
{!isNewFormat && reportData?.summary && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="p-5 text-center">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">工单总数</p>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100 mt-1">{reportData.summary.total_tickets}</p>
|
||||
</Card>
|
||||
<Card className="p-5 text-center">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">已解决</p>
|
||||
<p className="text-2xl font-bold text-emerald-500 mt-1">{reportData.summary.resolved_tickets}</p>
|
||||
</Card>
|
||||
<Card className="p-5 text-center">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">平均处理时长</p>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100 mt-1">{reportData.summary.avg_duration} 分钟</p>
|
||||
</Card>
|
||||
<Card className="p-5 text-center">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">SLA 达标率</p>
|
||||
<p className={`text-2xl font-bold mt-1 ${reportData.summary.sla_rate >= 90 ? 'text-emerald-500' : 'text-amber-500'}`}>{reportData.summary.sla_rate}%</p>
|
||||
</Card>
|
||||
</div>
|
||||
{reportData.categories && reportData.categories.length > 0 && (
|
||||
<Card className="p-5">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">故障分类</h3>
|
||||
<div className="space-y-2">
|
||||
{reportData.categories.map((c: any, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between py-2 border-b border-slate-100 dark:border-slate-800 last:border-0">
|
||||
<span className="text-sm text-slate-700 dark:text-slate-300">{c.fault_category || '未分类'}</span>
|
||||
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">{c.count} 件</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 新格式 ===== */}
|
||||
{isNewFormat && reportData && (
|
||||
<div className="space-y-5">
|
||||
{/* KPI Cards (5列) */}
|
||||
<div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
|
||||
<Card className="p-4 text-center flex flex-col justify-center min-h-[96px]">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">工单总数</p>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{reportData.totalTickets}</p>
|
||||
</Card>
|
||||
<Card className="p-4 text-center flex flex-col justify-center min-h-[96px]">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">故障已解决</p>
|
||||
<p className="text-2xl font-bold text-emerald-500">{reportData.resolvedCount}</p>
|
||||
</Card>
|
||||
<Card className="p-4 text-center flex flex-col justify-center min-h-[96px]">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">整体可用性</p>
|
||||
<p className={`text-2xl font-bold ${(reportData.avgAvailability ?? 100) >= 99 ? 'text-emerald-500' : 'text-amber-500'}`}>
|
||||
{reportData.avgAvailability != null ? `${reportData.avgAvailability}%` : '-'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
{reportData.avgAvailability != null && reportData.avgAvailability < 99 ? '低于99%' : ''}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-4 text-center flex flex-col justify-center min-h-[96px]">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">平均处理时长</p>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{reportData.avgDurationMinutes}</p>
|
||||
<p className="text-xs text-slate-400 mt-1">分钟</p>
|
||||
</Card>
|
||||
<Card className="p-4 text-center flex flex-col justify-center min-h-[96px]">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">进行中</p>
|
||||
<p className="text-2xl font-bold text-sky-600">{reportData.ongoingCount}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Chapter 1: 设备概况 */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-5 py-3 bg-slate-50 dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-md bg-blue-500 text-white text-xs font-bold">1</span>
|
||||
<span className="text-sm font-semibold text-slate-700 dark:text-slate-300">设备概况</span>
|
||||
</div>
|
||||
<div className="flex gap-8 px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-500/10 flex items-center justify-center text-lg">🖥</div>
|
||||
<div>
|
||||
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">{reportData.gpuCount}</span>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400 ml-1">台 GPU 服务器</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-500/10 flex items-center justify-center text-lg">🗄</div>
|
||||
<div>
|
||||
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">{reportData.storageCount}</span>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400 ml-1">台 存储服务器</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Chapter 2: 运营数据 */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-5 py-3 bg-slate-50 dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-md bg-purple-500 text-white text-xs font-bold">2</span>
|
||||
<span className="text-sm font-semibold text-slate-700 dark:text-slate-300">运营数据</span>
|
||||
</div>
|
||||
<div className="flex gap-6 px-5 py-4">
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||
故障工单 <span className="font-bold text-amber-600 text-base ml-1">{reportData.faultTicketCount}</span> 件
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||
涉及设备 <span className="font-bold text-slate-900 dark:text-slate-100 text-base ml-1">{reportData.affectedDeviceCount}</span> 台
|
||||
</div>
|
||||
{reportData.faultFreeDays != null && (
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||
无故障天数 <span className="font-bold text-emerald-600 text-base ml-1">{reportData.faultFreeDays}</span> 天
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Chapter 3: 故障分类 */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-5 py-3 bg-slate-50 dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-md bg-amber-500 text-white text-xs font-bold">3</span>
|
||||
<span className="text-sm font-semibold text-slate-700 dark:text-slate-300">故障分类</span>
|
||||
</div>
|
||||
<div className="flex gap-4 px-5 py-4">
|
||||
{(() => {
|
||||
const ch3Total = reportData.gpuFaultCount + reportData.storageFaultCount + reportData.otherTicketCount
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 p-3 rounded-lg text-center">
|
||||
<p className="text-lg font-bold text-amber-500">{reportData.gpuFaultCount}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">GPU 故障</p>
|
||||
<div className="w-full h-1 bg-slate-100 dark:bg-slate-700 rounded mt-2">
|
||||
<div className="h-full bg-amber-500 rounded" style={{ width: `${ch3Total > 0 ? Math.round(reportData.gpuFaultCount / ch3Total * 100) : 0}%` }} />
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
{ch3Total > 0 ? `${Math.round(reportData.gpuFaultCount / ch3Total * 100)}%` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 p-3 rounded-lg text-center">
|
||||
<p className="text-lg font-bold text-amber-500">{reportData.storageFaultCount}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">存储故障</p>
|
||||
<div className="w-full h-1 bg-slate-100 dark:bg-slate-700 rounded mt-2">
|
||||
<div className="h-full bg-amber-500 rounded" style={{ width: `${ch3Total > 0 ? Math.round(reportData.storageFaultCount / ch3Total * 100) : 0}%` }} />
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
{ch3Total > 0 ? `${Math.round(reportData.storageFaultCount / ch3Total * 100)}%` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 p-3 rounded-lg text-center">
|
||||
<p className="text-lg font-bold text-blue-500">{reportData.otherTicketCount}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">其他工单</p>
|
||||
<div className="w-full h-1 bg-slate-100 dark:bg-slate-700 rounded mt-2">
|
||||
<div className="h-full bg-blue-500 rounded" style={{ width: `${ch3Total > 0 ? Math.round(reportData.otherTicketCount / ch3Total * 100) : 0}%` }} />
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
{ch3Total > 0 ? `${Math.round(reportData.otherTicketCount / ch3Total * 100)}%` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Chapter 4: 服务可用性 */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-5 py-3 bg-slate-50 dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-md bg-emerald-500 text-white text-xs font-bold">4</span>
|
||||
<span className="text-sm font-semibold text-slate-700 dark:text-slate-300">服务可用性</span>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">整体可用性:</span>
|
||||
<span className={`text-xl font-bold ${(reportData.avgAvailability ?? 100) >= 99 ? 'text-emerald-500' : 'text-amber-500'}`}>
|
||||
{reportData.avgAvailability != null ? `${reportData.avgAvailability}%` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{reportData.availabilityDetails && reportData.availabilityDetails.length > 0 && (
|
||||
<>
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-slate-200 dark:border-slate-700 text-left">
|
||||
<th className="py-2 px-3 text-slate-500 dark:text-slate-400 font-medium text-xs">IP 地址</th>
|
||||
<th className="py-2 px-3 text-slate-500 dark:text-slate-400 font-medium text-xs">设备类型</th>
|
||||
<th className="py-2 px-3 text-slate-500 dark:text-slate-400 font-medium text-xs">故障时长</th>
|
||||
<th className="py-2 px-3 text-slate-500 dark:text-slate-400 font-medium text-xs">可用性</th>
|
||||
<th className="py-2 px-3 text-slate-500 dark:text-slate-400 font-medium text-xs">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reportData.availabilityDetails.map((item: any, i: number) => (
|
||||
<tr key={i} className="border-b border-slate-100 dark:border-slate-800">
|
||||
<td className="py-2 px-3 font-medium text-slate-900 dark:text-slate-100 text-xs">{item.ip}</td>
|
||||
<td className="py-2 px-3 text-slate-600 dark:text-slate-400 text-xs">
|
||||
{item.deviceType === 'gpu' ? 'GPU' : item.deviceType === 'storage' ? '存储' : '其他'}
|
||||
</td>
|
||||
<td className={`py-2 px-3 text-xs font-medium ${item.isOngoing ? 'text-red-500' : 'text-slate-700 dark:text-slate-300'}`}>
|
||||
{item.durationMinutes.toLocaleString()} 分钟
|
||||
</td>
|
||||
<td className={`py-2 px-3 text-xs font-bold ${item.availabilityPercent < 95 ? 'text-red-500' : 'text-amber-500'}`}>
|
||||
{item.availabilityPercent}%
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
{item.isOngoing ? (
|
||||
<span className="inline-flex px-2 py-0.5 rounded-full text-xs bg-red-100 text-red-600 dark:bg-red-500/10 dark:text-red-400">进行中</span>
|
||||
) : (
|
||||
<span className="inline-flex px-2 py-0.5 rounded-full text-xs bg-emerald-100 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400">已恢复</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="text-xs text-slate-400 mt-3">
|
||||
共 {reportData.availabilityDetails.length} 个 IP 可用性低于 100%,其余设备保持 100% 在线
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(!reportData.availabilityDetails || reportData.availabilityDetails.length === 0) && (
|
||||
<p className="text-xs text-slate-400">
|
||||
{report.report_type === 'weekly'
|
||||
? '周报不展示 IP 明细,请查看下载的 DOCX 文档获取完整可用性数据。'
|
||||
: '所有设备保持 100% 在线'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,536 @@
|
|||
'use client'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Card, Button, Badge, Toast, Modal } from '@/components/ui'
|
||||
import { Plus, Eye, Download, Trash2, Archive, RefreshCw } from 'lucide-react'
|
||||
|
||||
interface Report {
|
||||
id: number
|
||||
report_type: string
|
||||
period_start: string | null
|
||||
period_end: string | null
|
||||
format: string
|
||||
status: string
|
||||
created_at: string
|
||||
file_name?: string | null
|
||||
metadata?: string | null
|
||||
}
|
||||
|
||||
const statusVariant: Record<string, 'info' | 'warning' | 'success' | 'danger'> = {
|
||||
pending: 'info',
|
||||
ready: 'warning',
|
||||
generating: 'info',
|
||||
completed: 'success',
|
||||
failed: 'danger',
|
||||
}
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
pending: '待生成',
|
||||
ready: '数据已就绪',
|
||||
generating: '文档生成中',
|
||||
completed: '已完成',
|
||||
failed: '生成失败',
|
||||
}
|
||||
|
||||
const typeLabel: Record<string, string> = {
|
||||
weekly: '周报',
|
||||
monthly: '月报',
|
||||
}
|
||||
|
||||
function getReportName(r: Report): string {
|
||||
if (r.metadata) {
|
||||
try {
|
||||
const meta = JSON.parse(r.metadata)
|
||||
if (meta.reportLabel) return meta.reportLabel
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
if (r.file_name) return r.file_name
|
||||
return `${r.period_start || '?'} ~ ${r.period_end || '?'} ${typeLabel[r.report_type] || r.report_type}`
|
||||
}
|
||||
|
||||
function lastDayOfMonth(y: number, m: number) {
|
||||
return new Date(y, m, 0).getDate()
|
||||
}
|
||||
|
||||
function pad(n: number) { return String(n).padStart(2, '0') }
|
||||
|
||||
function getLastWeekDates() {
|
||||
const today = new Date()
|
||||
const dayOfWeek = today.getDay()
|
||||
const daysSinceMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1
|
||||
const thisMonday = new Date(today)
|
||||
thisMonday.setDate(today.getDate() - daysSinceMonday)
|
||||
const lastMonday = new Date(thisMonday)
|
||||
lastMonday.setDate(thisMonday.getDate() - 7)
|
||||
const lastSunday = new Date(lastMonday)
|
||||
lastSunday.setDate(lastMonday.getDate() + 6)
|
||||
return {
|
||||
start: `${lastMonday.getFullYear()}-${pad(lastMonday.getMonth()+1)}-${pad(lastMonday.getDate())}`,
|
||||
end: `${lastSunday.getFullYear()}-${pad(lastSunday.getMonth()+1)}-${pad(lastSunday.getDate())}`,
|
||||
}
|
||||
}
|
||||
|
||||
export default function ReportsPage() {
|
||||
const router = useRouter()
|
||||
const [reports, setReports] = useState<Report[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [reportType, setReportType] = useState<'monthly' | 'weekly'>('monthly')
|
||||
const [month, setMonth] = useState('')
|
||||
const [weekStart, setWeekStart] = useState('')
|
||||
const [weekEnd, setWeekEnd] = useState('')
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'info' } | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<Report | null>(null)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false)
|
||||
const [generatingIds, setGeneratingIds] = useState<Set<number>>(new Set())
|
||||
const [permissions, setPermissions] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
.then(r => r.json())
|
||||
.then(u => { if (u.user?.permissions) setPermissions(u.user.permissions) })
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const toggleSelect = (id: number) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) { next.delete(id) } else { next.add(id) }
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
const selectable = reports.filter(r =>
|
||||
r.status === 'completed' || r.status === 'failed' || r.status === 'ready'
|
||||
)
|
||||
const allIds = selectable.map(r => r.id)
|
||||
setSelectedIds(prev =>
|
||||
prev.size === allIds.length && allIds.length > 0 ? new Set() : new Set(allIds)
|
||||
)
|
||||
}
|
||||
|
||||
const fetchReports = useCallback(() => {
|
||||
fetch('/api/reports')
|
||||
.then(r => r.json())
|
||||
.then(d => { if (d.reports) setReports(d.reports) })
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchReports() }, [fetchReports])
|
||||
|
||||
useEffect(() => {
|
||||
if (!month) {
|
||||
const now = new Date()
|
||||
let lastMonth = now.getMonth() - 1
|
||||
let lastMonthYear = now.getFullYear()
|
||||
if (lastMonth < 0) { lastMonth = 11; lastMonthYear-- }
|
||||
setMonth(`${lastMonthYear}-${pad(lastMonth + 1)}`)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleCreate = async () => {
|
||||
let period_start = ''
|
||||
let period_end = ''
|
||||
|
||||
if (reportType === 'monthly') {
|
||||
if (!month) return
|
||||
const [y, m] = month.split('-').map(Number)
|
||||
period_start = `${y}-${pad(m)}-01`
|
||||
period_end = `${y}-${pad(m)}-${pad(lastDayOfMonth(y, m))}`
|
||||
} else {
|
||||
if (!weekStart || !weekEnd) return
|
||||
period_start = weekStart
|
||||
period_end = weekEnd
|
||||
}
|
||||
|
||||
const res = await fetch('/api/reports', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ report_type: reportType, period_start, period_end }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setShowCreate(false)
|
||||
setToast({ message: '数据采集完成,正在跳转预览页...', type: 'success' })
|
||||
router.push(`/reports/${data.report.id}`)
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({ error: '创建失败' }))
|
||||
setToast({ message: data.error || '创建失败', type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
const res = await fetch(`/api/reports/${deleteTarget.id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
setReports(prev => prev.filter(r => r.id !== deleteTarget.id))
|
||||
setToast({ message: '已删除', type: 'success' })
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({ error: '删除失败' }))
|
||||
setToast({ message: data.error || '删除失败', type: 'error' })
|
||||
}
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
if (ids.length === 0) return
|
||||
const res = await fetch('/api/reports', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setReports(prev => prev.filter(r => !selectedIds.has(r.id)))
|
||||
setSelectedIds(new Set())
|
||||
setToast({ message: `已删除 ${ids.length} 条报告`, type: 'success' })
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({ error: '批量删除失败' }))
|
||||
setToast({ message: data.error || '批量删除失败', type: 'error' })
|
||||
}
|
||||
setBatchDeleteOpen(false)
|
||||
}
|
||||
|
||||
const handleBatchDownload = async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
if (ids.length === 0) return
|
||||
try {
|
||||
const res = await fetch('/api/reports/download', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const d = new Date(); a.download = `reports_${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}.zip`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
setToast({ message: `已下载 ${ids.length} 条报告`, type: 'success' })
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({ error: '下载失败' }))
|
||||
setToast({ message: data.error || '下载失败', type: 'error' })
|
||||
}
|
||||
} catch {
|
||||
setToast({ message: '下载失败', type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerate = async (reportId: number) => {
|
||||
setGeneratingIds(prev => new Set(prev).add(reportId))
|
||||
try {
|
||||
const res = await fetch(`/api/reports/${reportId}/generate`, { method: 'POST' })
|
||||
if (res.ok) {
|
||||
setToast({ message: '文档生成中...', type: 'info' })
|
||||
pollUntilDone(reportId)
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({ error: '生成失败' }))
|
||||
setToast({ message: data.error || '生成失败', type: 'error' })
|
||||
setGeneratingIds(prev => { const n = new Set(prev); n.delete(reportId); return n })
|
||||
}
|
||||
} catch {
|
||||
setToast({ message: '生成失败', type: 'error' })
|
||||
setGeneratingIds(prev => { const n = new Set(prev); n.delete(reportId); return n })
|
||||
}
|
||||
}
|
||||
|
||||
const pollUntilDone = async (reportId: number) => {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < 120000) {
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
const res = await fetch(`/api/reports/${reportId}`)
|
||||
if (!res.ok) continue
|
||||
const data = await res.json()
|
||||
const status = data.report?.status
|
||||
if (status === 'completed') {
|
||||
setToast({ message: '报告生成完成', type: 'success' })
|
||||
fetchReports()
|
||||
setGeneratingIds(prev => { const n = new Set(prev); n.delete(reportId); return n })
|
||||
return
|
||||
}
|
||||
if (status === 'failed') {
|
||||
setToast({ message: data.report?.error_message || '报告生成失败', type: 'error' })
|
||||
fetchReports()
|
||||
setGeneratingIds(prev => { const n = new Set(prev); n.delete(reportId); return n })
|
||||
return
|
||||
}
|
||||
}
|
||||
setToast({ message: '报告生成超时,请手动刷新', type: 'error' })
|
||||
fetchReports()
|
||||
setGeneratingIds(prev => { const n = new Set(prev); n.delete(reportId); return n })
|
||||
}
|
||||
|
||||
const getActionButtons = (r: Report) => {
|
||||
const isGenerating = generatingIds.has(r.id)
|
||||
|
||||
switch (r.status) {
|
||||
case 'ready':
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push(`/reports/${r.id}`)}>
|
||||
<Eye size={14} className="mr-0.5" />预览
|
||||
</Button>
|
||||
{can('reports:create') && (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleGenerate(r.id)} loading={isGenerating}>
|
||||
生成文档
|
||||
</Button>
|
||||
)}
|
||||
{can('reports:create') && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}>
|
||||
<Trash2 size={14} className="text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
case 'generating':
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push(`/reports/${r.id}`)}>
|
||||
<Eye size={14} className="mr-0.5" />预览
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
case 'completed':
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push(`/reports/${r.id}`)}>
|
||||
<Eye size={14} className="mr-0.5" />预览
|
||||
</Button>
|
||||
{can('reports:download') && (
|
||||
<Button variant="ghost" size="sm" onClick={() => window.open(`/api/reports/${r.id}/download`, '_blank')}>
|
||||
<Download size={14} className="mr-0.5" />下载
|
||||
</Button>
|
||||
)}
|
||||
{can('reports:create') && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}>
|
||||
<Trash2 size={14} className="text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
case 'failed':
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push(`/reports/${r.id}`)}>
|
||||
<Eye size={14} className="mr-0.5" />预览
|
||||
</Button>
|
||||
{can('reports:create') && (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleGenerate(r.id)} loading={isGenerating}>
|
||||
<RefreshCw size={14} className="mr-0.5" />重试
|
||||
</Button>
|
||||
)}
|
||||
{can('reports:create') && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}>
|
||||
<Trash2 size={14} className="text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{can('reports:create') && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}>
|
||||
<Trash2 size={14} className="text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const can = (perm: string) => permissions.includes('*') || permissions.includes(perm)
|
||||
|
||||
const renderStatusBadge = (status: string) => {
|
||||
if (status === 'generating') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400">
|
||||
<span className="inline-block w-2.5 h-2.5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
文档生成中
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'ready') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500" />
|
||||
数据已就绪
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return <Badge variant={statusVariant[status] || 'default'}>{statusLabel[status] || status}</Badge>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">报告管理</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">数据预览与文档生成</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedIds.size > 0 && (
|
||||
<>
|
||||
{can('reports:download') && (
|
||||
<Button size="sm" onClick={handleBatchDownload}>
|
||||
<Archive size={16} className="mr-1" />批量下载 ({selectedIds.size})
|
||||
</Button>
|
||||
)}
|
||||
{can('reports:create') && (
|
||||
<Button size="sm" variant="danger" onClick={() => setBatchDeleteOpen(true)}>
|
||||
<Trash2 size={16} className="mr-1" />批量删除 ({selectedIds.size})
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{can('reports:create') && (
|
||||
<Button size="sm" onClick={() => setShowCreate(!showCreate)}>
|
||||
<Plus size={16} className="mr-1" />新建报告
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<Card className="p-5">
|
||||
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">新建报告</h3>
|
||||
|
||||
<div className="flex gap-3 mb-4">
|
||||
<label className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer text-sm transition-colors ${reportType === 'monthly' ? 'border-blue-500 bg-blue-50 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400' : 'border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-400'}`}>
|
||||
<input type="radio" name="reportType" value="monthly" checked={reportType === 'monthly'} onChange={() => setReportType('monthly')} className="sr-only" />
|
||||
月报
|
||||
</label>
|
||||
<label className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer text-sm transition-colors ${reportType === 'weekly' ? 'border-blue-500 bg-blue-50 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400' : 'border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-400'}`}>
|
||||
<input type="radio" name="reportType" value="weekly" checked={reportType === 'weekly'} onChange={() => { setReportType('weekly'); const { start, end } = getLastWeekDates(); setWeekStart(start); setWeekEnd(end) }} className="sr-only" />
|
||||
周报
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{reportType === 'monthly' ? (
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-slate-500 dark:text-slate-400">选择月份</label>
|
||||
<input type="month" className="w-56 px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 text-sm" value={month} onChange={e => setMonth(e.target.value)} />
|
||||
<p className="text-xs text-slate-400 mt-1">月报统计当月工单(以结单时间为准),默认统计该月1日至最后一日。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-slate-500 dark:text-slate-400">开始日期</label>
|
||||
<input type="date" className="w-56 px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 text-sm" value={weekStart} onChange={e => setWeekStart(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-slate-500 dark:text-slate-400">结束日期</label>
|
||||
<input type="date" className="w-56 px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 text-sm" value={weekEnd} onChange={e => setWeekEnd(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-2">周报统计当周工单(周一到周日),含处理中和已结单工单。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button size="sm" onClick={handleCreate}>确认</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowCreate(false)}>取消</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-500 dark:text-slate-400">加载中...</div>
|
||||
) : reports.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500 dark:text-slate-400">暂无报告</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 dark:bg-slate-800">
|
||||
<tr>
|
||||
<th className="px-3 py-3 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
reports.filter(r => r.status === 'completed' || r.status === 'failed' || r.status === 'ready').length > 0 &&
|
||||
selectedIds.size === reports.filter(r => r.status === 'completed' || r.status === 'failed' || r.status === 'ready').length
|
||||
}
|
||||
onChange={toggleSelectAll}
|
||||
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400">报告名称</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400">类型</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400">时间段</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400">状态</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400">创建时间</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{reports.map(r => (
|
||||
<tr key={r.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||
<td className="px-3 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(r.id)}
|
||||
onChange={() => toggleSelect(r.id)}
|
||||
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 max-w-[240px]">
|
||||
<a
|
||||
href={`/reports/${r.id}`}
|
||||
onClick={(e) => { e.preventDefault(); router.push(`/reports/${r.id}`) }}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 font-medium truncate block"
|
||||
>
|
||||
{getReportName(r)}
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{r.report_type === 'monthly' ? (
|
||||
<span className="inline-flex px-2.5 py-0.5 rounded-lg text-xs font-semibold bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400">月报</span>
|
||||
) : (
|
||||
<span className="inline-flex px-2.5 py-0.5 rounded-lg text-xs font-semibold bg-purple-100 text-purple-700 dark:bg-purple-500/10 dark:text-purple-400">周报</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-600 dark:text-slate-400 text-sm">{r.period_start} ~ {r.period_end}</td>
|
||||
<td className="px-4 py-3">{renderStatusBadge(r.status)}</td>
|
||||
<td className="px-4 py-3 text-slate-500 dark:text-slate-400 text-sm">{new Date(r.created_at).toLocaleString('zh-CN')}</td>
|
||||
<td className="px-4 py-3">{getActionButtons(r)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 mb-4">
|
||||
确定删除此报告吗?将同时删除报告文件和数据库记录,此操作不可撤销。
|
||||
</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button size="sm" variant="secondary" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||
<Button size="sm" variant="danger" onClick={handleDelete}>确认删除</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal open={batchDeleteOpen} onClose={() => setBatchDeleteOpen(false)} title="确认批量删除">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 mb-4">
|
||||
确定删除选中的 {selectedIds.size} 条报告吗?将同时删除报告文件和数据库记录,此操作不可撤销。
|
||||
</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button size="sm" variant="secondary" onClick={() => setBatchDeleteOpen(false)}>取消</Button>
|
||||
<Button size="sm" variant="danger" onClick={handleBatchDelete}>确认删除</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -86,25 +86,25 @@ export default function ApiKeysPage() {
|
|||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">API Key 管理</h1>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">API Key 管理</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1">用于第三方系统调用工单系统 API</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => { setCreateOpen(true); setNewKey(null) }}>
|
||||
<Plus size={16} className="mr-1" />创建 Key
|
||||
<Plus size={16} />创建 Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{newKey && (
|
||||
<div className="p-4 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-xl">
|
||||
<p className="text-sm font-medium text-emerald-800 dark:text-emerald-300 mb-2">
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<p className="text-sm font-medium text-green-800 dark:text-green-300 mb-2">
|
||||
API Key 已创建(仅显示一次,请妥善保存)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 p-3 bg-white dark:bg-slate-800 rounded-lg border border-emerald-200 dark:border-emerald-700 text-sm font-mono break-all text-slate-800 dark:text-slate-200">
|
||||
<code className="flex-1 p-2 bg-white dark:bg-slate-800 rounded border border-green-200 dark:border-green-700 text-sm font-mono break-all">
|
||||
{newKey}
|
||||
</code>
|
||||
<Button variant="ghost" size="sm" onClick={() => copyKey(newKey)}>
|
||||
{copied ? <Check size={16} className="text-emerald-500" /> : <Copy size={16} />}
|
||||
{copied ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -124,7 +124,7 @@ export default function ApiKeysPage() {
|
|||
const perms: string[] = JSON.parse(k.permissions)
|
||||
return (
|
||||
<tr key={k.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||
<td className="px-4 py-3 font-medium text-slate-900 dark:text-slate-100">{k.name}</td>
|
||||
<td className="px-4 py-3 font-medium text-slate-900 dark:text-white">{k.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{perms.map(p => <Badge key={p} variant="default">{p}</Badge>)}
|
||||
|
|
@ -163,7 +163,7 @@ export default function ApiKeysPage() {
|
|||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleCreate} loading={saving}>创建</Button>
|
||||
<Button variant="secondary" onClick={() => { setCreateOpen(false); setError('') }}>取消</Button>
|
||||
<Button variant="ghost" onClick={() => { setCreateOpen(false); setError('') }}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
@ -173,7 +173,7 @@ export default function ApiKeysPage() {
|
|||
确定要删除 API Key「{deleteTarget?.name}」吗?使用该 Key 的应用将无法再访问此系统。
|
||||
</p>
|
||||
<div className="flex justify-end gap-3 mt-4">
|
||||
<Button variant="secondary" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||
<Button variant="ghost" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||
<Button variant="danger" onClick={handleDelete}>删除</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,611 @@
|
|||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import Table, { Column } from '@/components/ui/Table'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
|
||||
interface AuditLog {
|
||||
id: number
|
||||
user_id: number | null
|
||||
api_key_id: number | null
|
||||
action: string
|
||||
entity_type: string
|
||||
entity_id: number | null
|
||||
details: string | null
|
||||
ip_address: string | null
|
||||
created_at: string
|
||||
username: string | null
|
||||
api_key_name: string | null
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, { label: string; variant: 'success' | 'info' | 'danger' | 'default' }> = {
|
||||
create: { label: '创建', variant: 'success' },
|
||||
update: { label: '更新', variant: 'info' },
|
||||
delete: { label: '删除', variant: 'danger' },
|
||||
batch_update: { label: '批量更新', variant: 'info' },
|
||||
import: { label: '导入', variant: 'info' },
|
||||
export: { label: '导出', variant: 'default' },
|
||||
login: { label: '登录', variant: 'default' },
|
||||
logout: { label: '登出', variant: 'default' },
|
||||
}
|
||||
|
||||
const ENTITY_LABELS: Record<string, string> = {
|
||||
ticket: '工单',
|
||||
report: '报告',
|
||||
user: '用户',
|
||||
role: '角色',
|
||||
api_key: 'API Key',
|
||||
audit_log: '审计日志',
|
||||
auth: '认证',
|
||||
}
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize] = useState(20)
|
||||
|
||||
// 过滤器
|
||||
const [actionFilter, setActionFilter] = useState('')
|
||||
const [entityFilter, setEntityFilter] = useState('')
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
|
||||
// 保留天数设置
|
||||
const [retentionSaved, setRetentionSaved] = useState(180)
|
||||
const [retentionInput, setRetentionInput] = useState('180')
|
||||
const [retentionSaving, setRetentionSaving] = useState(false)
|
||||
const [retentionSavedFeedback, setRetentionSavedFeedback] = useState(false)
|
||||
const [retentionError, setRetentionError] = useState(false)
|
||||
const retentionDirty = retentionInput !== String(retentionSaved)
|
||||
|
||||
// 详情 Modal
|
||||
const [detailLog, setDetailLog] = useState<AuditLog | null>(null)
|
||||
|
||||
async function fetchLogs() {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams()
|
||||
params.set('page', String(page))
|
||||
params.set('pageSize', String(pageSize))
|
||||
if (actionFilter) params.set('action', actionFilter)
|
||||
if (entityFilter) params.set('entity_type', entityFilter)
|
||||
if (dateFrom) params.set('date_from', dateFrom)
|
||||
if (dateTo) params.set('date_to', dateTo)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/audit-logs?${params}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setLogs(data.logs)
|
||||
setTotal(data.total)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { fetchLogs() }, [page, actionFilter, entityFilter, dateFrom, dateTo])
|
||||
|
||||
async function fetchRetentionDays() {
|
||||
try {
|
||||
const res = await fetch('/api/audit-logs/cleanup')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setRetentionSaved(data.days)
|
||||
setRetentionInput(String(data.days))
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function saveRetentionDays() {
|
||||
const days = parseInt(retentionInput, 10)
|
||||
if (isNaN(days) || days < 30 || days > 365) {
|
||||
setRetentionError(true)
|
||||
setTimeout(() => setRetentionError(false), 2000)
|
||||
return
|
||||
}
|
||||
setRetentionSaving(true)
|
||||
setRetentionError(false)
|
||||
try {
|
||||
const res = await fetch('/api/audit-logs/cleanup', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ days })
|
||||
})
|
||||
if (res.ok) {
|
||||
setRetentionSaved(days)
|
||||
setRetentionSavedFeedback(true)
|
||||
setTimeout(() => setRetentionSavedFeedback(false), 2000)
|
||||
} else {
|
||||
setRetentionError(true)
|
||||
setTimeout(() => setRetentionError(false), 2000)
|
||||
}
|
||||
} catch {
|
||||
setRetentionError(true)
|
||||
setTimeout(() => setRetentionError(false), 2000)
|
||||
}
|
||||
setRetentionSaving(false)
|
||||
}
|
||||
|
||||
useEffect(() => { fetchRetentionDays() }, [])
|
||||
|
||||
function resetFilters() {
|
||||
setActionFilter('')
|
||||
setEntityFilter('')
|
||||
setDateFrom('')
|
||||
setDateTo('')
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
function setQuickDate(days: number) {
|
||||
const today = new Date()
|
||||
const from = new Date(today)
|
||||
from.setDate(from.getDate() - days)
|
||||
const fromStr = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, '0')}-${String(from.getDate()).padStart(2, '0')}`
|
||||
const toStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||
setDateFrom(fromStr)
|
||||
setDateTo(toStr)
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
const params = new URLSearchParams()
|
||||
if (actionFilter) params.set('action', actionFilter)
|
||||
if (entityFilter) params.set('entity_type', entityFilter)
|
||||
if (dateFrom) params.set('date_from', dateFrom)
|
||||
if (dateTo) params.set('date_to', dateTo)
|
||||
|
||||
const res = await fetch(`/api/audit-logs/export?${params}`)
|
||||
if (res.ok) {
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const now = new Date()
|
||||
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
a.download = `audit-logs-${dateStr}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDetails(log: AuditLog): string {
|
||||
if (!log.details) return '-'
|
||||
try {
|
||||
const details = JSON.parse(log.details)
|
||||
if (details.changes) {
|
||||
const keys = Object.keys(details.changes)
|
||||
return keys.slice(0, 2).join(', ') + (keys.length > 2 ? '...' : '')
|
||||
}
|
||||
if (details.created) return '新建记录'
|
||||
if (details.deleted) return '删除记录'
|
||||
if (details.export) return `导出 ${details.export.count} 条`
|
||||
if (details.batch_update) return `批量更新 ${details.batch_update.count} 条`
|
||||
if (details.import) return `导入 ${details.import.created} 新 / ${details.import.updated} 更新`
|
||||
if (details.message) return details.message
|
||||
if (details.keys) return details.keys.slice(0, 2).join(', ') + (details.keys.length > 2 ? '...' : '')
|
||||
if (details.username) return `用户 ${details.username} 登录`
|
||||
if (details.retention_days !== undefined) return `保留 ${details.retention_days} 天`
|
||||
if (details.cleanup) return `清理 ${details.cleanup.deleted} 条`
|
||||
return '-'
|
||||
} catch { return '-' }
|
||||
}
|
||||
|
||||
// 字段名汉化映射
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
name: '名称', status: '状态', type: '类型', category: '分类',
|
||||
title: '标题', priority: '优先级', assignee: '负责人', reporter: '报告人',
|
||||
description: '描述', notes: '备注', email: '邮箱', role: '角色',
|
||||
username: '用户名', permissions: '权限', count: '数量',
|
||||
}
|
||||
|
||||
function formatValue(val: unknown): string {
|
||||
if (val === null || val === undefined) return '-'
|
||||
if (typeof val === 'object') return JSON.stringify(val)
|
||||
return String(val)
|
||||
}
|
||||
|
||||
function renderDetails(details: Record<string, unknown>) {
|
||||
// 字段变更(assets-ai 格式)
|
||||
if (details.changes && typeof details.changes === 'object') {
|
||||
const changes = details.changes as Record<string, { from: unknown; to: unknown }>
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">📋 修改了以下字段:</p>
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-100 dark:bg-slate-700">
|
||||
<th className="px-3 py-2 text-left font-medium">字段</th>
|
||||
<th className="px-3 py-2 text-left font-medium">旧值</th>
|
||||
<th className="px-3 py-2 text-left font-medium">新值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(changes).map(([key, val]) => (
|
||||
<tr key={key} className="border-t border-slate-200 dark:border-slate-700">
|
||||
<td className="px-3 py-2 text-slate-500">{FIELD_LABELS[key] || key}</td>
|
||||
<td className="px-3 py-2 text-slate-400 line-through">{formatValue(val.from)}</td>
|
||||
<td className="px-3 py-2 text-green-600 dark:text-green-400 font-medium">{formatValue(val.to)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 新建记录(assets-ai 格式)
|
||||
if (details.created && typeof details.created === 'object') {
|
||||
const data = details.created as Record<string, unknown>
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-600 dark:text-green-400 mb-2">🟢 新建记录:</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(data).map(([key, val]) => (
|
||||
<div key={key} className="flex gap-2 text-sm">
|
||||
<span className="text-slate-500">{FIELD_LABELS[key] || key}:</span>
|
||||
<span>{formatValue(val)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 删除记录(assets-ai 格式)
|
||||
if (details.deleted && typeof details.deleted === 'object') {
|
||||
const data = details.deleted as Record<string, unknown>
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-600 dark:text-red-400 mb-2">🔴 删除记录:</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(data).map(([key, val]) => (
|
||||
<div key={key} className="flex gap-2 text-sm">
|
||||
<span className="text-slate-500">{FIELD_LABELS[key] || key}:</span>
|
||||
<span>{formatValue(val)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 批量更新(assets-ai 格式)
|
||||
if (details.batch_update && typeof details.batch_update === 'object') {
|
||||
const data = details.batch_update as Record<string, unknown>
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2">📦 批量更新 {String(data.count)} 条记录</p>
|
||||
{data.ids ? <p className="text-sm text-slate-500">涉及 ID:{String(data.ids)}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 导入(assets-ai 格式)
|
||||
if (details.import && typeof details.import === 'object') {
|
||||
const data = details.import as Record<string, unknown>
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2">📥 导入操作</p>
|
||||
<div className="flex gap-4 text-sm">
|
||||
<span>新增:{String(data.created)} 条</span>
|
||||
<span>更新:{String(data.updated)} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 导出(assets-ai 格式)
|
||||
if (details.export && typeof details.export === 'object') {
|
||||
const data = details.export as Record<string, unknown>
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-2">📤 导出 {String(data.count)} 条记录</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// issue-ai 特有格式:操作说明
|
||||
if (details.message && typeof details.message === 'string') {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">📝 操作说明:</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">{details.message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// issue-ai 特有格式:配置修改
|
||||
if (details.keys && Array.isArray(details.keys)) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2">⚙️ 修改了以下配置:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{(details.keys as string[]).map((key, i) => (
|
||||
<li key={i} className="text-sm text-slate-600 dark:text-slate-400 font-mono">{key}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 登录信息
|
||||
if (details.username && typeof details.username === 'string') {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">👤 登录信息:</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex gap-2 text-sm">
|
||||
<span className="text-slate-500">用户名:</span>
|
||||
<span>{details.username}</span>
|
||||
</div>
|
||||
{details.role && (
|
||||
<div className="flex gap-2 text-sm">
|
||||
<span className="text-slate-500">角色:</span>
|
||||
<span>{String(details.role)}</span>
|
||||
</div>
|
||||
)}
|
||||
{details.displayName && (
|
||||
<div className="flex gap-2 text-sm">
|
||||
<span className="text-slate-500">显示名:</span>
|
||||
<span>{String(details.displayName)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 保留天数设置
|
||||
if (details.retention_days !== undefined) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2">⚙️ 保留天数设置:</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">日志保留期限调整为 <span className="font-medium">{String(details.retention_days)}</span> 天</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 清理操作
|
||||
if (details.cleanup) {
|
||||
const data = details.cleanup as Record<string, unknown>
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">🧹 清理操作:</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">已清理 {String(data.deleted)} 条过期日志</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 兜底:格式化 JSON
|
||||
return (
|
||||
<pre className="mt-2 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg text-xs font-mono overflow-x-auto">
|
||||
{JSON.stringify(details, null, 2)}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'created_at',
|
||||
title: '时间',
|
||||
width: '170px',
|
||||
render: (r) => <span className="text-sm whitespace-nowrap">{r.created_at}</span>
|
||||
},
|
||||
{
|
||||
key: 'username',
|
||||
title: '用户',
|
||||
width: '100px',
|
||||
render: (r) => (
|
||||
<div className="flex items-center gap-1 whitespace-nowrap">
|
||||
<span>{r.username || '-'}</span>
|
||||
{r.api_key_id && <Badge variant="warning">[API]</Badge>}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
title: '操作',
|
||||
width: '70px',
|
||||
render: (r) => {
|
||||
const info = ACTION_LABELS[r.action] || { label: r.action, variant: 'default' as const }
|
||||
return <span className="whitespace-nowrap"><Badge variant={info.variant}>{info.label}</Badge></span>
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'entity_type',
|
||||
title: '对象',
|
||||
width: '80px',
|
||||
render: (r) => <span className="whitespace-nowrap">{ENTITY_LABELS[r.entity_type] || r.entity_type}</span>
|
||||
},
|
||||
{
|
||||
key: 'details',
|
||||
title: '详情',
|
||||
render: (r) => (
|
||||
<button
|
||||
onClick={() => setDetailLog(r)}
|
||||
className="text-left text-sm text-slate-600 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 truncate max-w-[200px]"
|
||||
>
|
||||
{formatDetails(r)}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'ip_address',
|
||||
title: 'IP',
|
||||
width: '120px',
|
||||
render: (r) => <span className="text-xs font-mono">{r.ip_address || '-'}</span>
|
||||
},
|
||||
]
|
||||
|
||||
// 保留天数按钮样式
|
||||
const retentionBtnClass = retentionSaved
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: retentionError
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">审计日志</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">系统操作与事件记录</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={handleExport}>导出</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 保留天数设置 */}
|
||||
<div className="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-800/50 rounded-lg text-sm">
|
||||
<span className="text-slate-600 dark:text-slate-400">保留天数:</span>
|
||||
<input
|
||||
type="number"
|
||||
min={30}
|
||||
max={365}
|
||||
value={retentionInput}
|
||||
onChange={(e) => setRetentionInput(e.target.value)}
|
||||
className="w-20 px-2 py-1 text-sm border border-slate-200 dark:border-slate-700 rounded bg-white dark:bg-slate-900"
|
||||
/>
|
||||
<span className="text-xs text-slate-400">(30-365 天,默认 180 天)</span>
|
||||
{retentionSavedFeedback ? (
|
||||
<span className="text-xs font-medium text-green-600 dark:text-green-400">✓ 已保存</span>
|
||||
) : retentionError ? (
|
||||
<span className="text-xs font-medium text-red-600 dark:text-red-400">✗ 保存失败</span>
|
||||
) : retentionDirty ? (
|
||||
<button
|
||||
className="px-3 py-1 text-xs font-medium rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"
|
||||
onClick={saveRetentionDays}
|
||||
disabled={retentionSaving}
|
||||
>
|
||||
{retentionSaving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 过滤器 */}
|
||||
<div className="flex flex-wrap items-center gap-3 p-4 bg-slate-50 dark:bg-slate-800/50 rounded-lg">
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => { setActionFilter(e.target.value); setPage(1) }}
|
||||
className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900"
|
||||
>
|
||||
<option value="">全部操作</option>
|
||||
{Object.entries(ACTION_LABELS).map(([key, { label }]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={entityFilter}
|
||||
onChange={(e) => { setEntityFilter(e.target.value); setPage(1) }}
|
||||
className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900"
|
||||
>
|
||||
<option value="">全部对象</option>
|
||||
{Object.entries(ENTITY_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => { setDateFrom(e.target.value); setPage(1) }}
|
||||
className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900"
|
||||
/>
|
||||
<span className="text-slate-400">~</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => { setDateTo(e.target.value); setPage(1) }}
|
||||
className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900"
|
||||
/>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => setQuickDate(7)}>7天</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setQuickDate(30)}>30天</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setQuickDate(180)}>180天</Button>
|
||||
</div>
|
||||
|
||||
<Button size="sm" variant="ghost" onClick={resetFilters}>重置</Button>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
{loading ? (
|
||||
<div className="py-12 text-center text-slate-500 dark:text-slate-400">加载中...</div>
|
||||
) : (
|
||||
<Table columns={columns as any} data={logs as any} rowKey={(r: any) => String(r.id)} />
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{total > pageSize && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-slate-500">
|
||||
第 {page} 页 / 共 {Math.ceil(total / pageSize)} 页
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 详情 Modal */}
|
||||
<Modal
|
||||
open={!!detailLog}
|
||||
onClose={() => setDetailLog(null)}
|
||||
title="审计日志详情"
|
||||
>
|
||||
{detailLog && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-slate-500">时间:</span>
|
||||
<span>{detailLog.created_at}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">用户:</span>
|
||||
<span>{detailLog.username || '-'}</span>
|
||||
{detailLog.api_key_id && <span className="ml-1"><Badge variant="warning">[API]</Badge></span>}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">操作:</span>
|
||||
<Badge variant={ACTION_LABELS[detailLog.action]?.variant || 'default'}>
|
||||
{ACTION_LABELS[detailLog.action]?.label || detailLog.action}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">对象:</span>
|
||||
<span>{ENTITY_LABELS[detailLog.entity_type] || detailLog.entity_type}</span>
|
||||
{detailLog.entity_id && <span className="ml-1">#{detailLog.entity_id}</span>}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">IP:</span>
|
||||
<span className="font-mono">{detailLog.ip_address || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailLog.details && (
|
||||
<div className="mt-3 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
{renderDetails(JSON.parse(detailLog.details))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,984 @@
|
|||
'use client'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Card } from '@/components/ui'
|
||||
import { Search, RefreshCw } from 'lucide-react'
|
||||
import { useDirtyTracker } from '@shared/ui/hooks/useDirtyTracker'
|
||||
|
||||
interface MonitorStatus {
|
||||
enabled: boolean
|
||||
status: string
|
||||
workerHealth: string
|
||||
lastCheck: string | null
|
||||
stats: { totalProcessed: number; todayProcessed: number; errors: number; lastError: string | null; lastErrorTime: string | null }
|
||||
ongoingFaults: number
|
||||
details: {
|
||||
todayProcessed: { ticket_no?: string; error?: string; created_at: string }[]
|
||||
ongoingFaults: { id: number; server_sn: string; server_ip: string | null; order_number: string | null; fault_type: string | null; fault_time: string; fault_detail: string | null }[]
|
||||
recentErrors: { error?: string; details?: string; created_at: string }[]
|
||||
}
|
||||
}
|
||||
|
||||
interface MonitorConfig {
|
||||
monitor: { enabled: boolean; interval_seconds: number; push_delay_ms: number; silent_start_hour: number; silent_end_hour: number }
|
||||
mail: { address: string; imap_server: string; imap_port: number; pop3_server: string; pop3_port: number; smtp_server: string; smtp_port: number; password: string }
|
||||
filter: { subject_keywords: string[]; sender_email: string; oem_repair_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string }; oem_diag_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string } }
|
||||
}
|
||||
|
||||
interface ScanState {
|
||||
status: 'idle' | 'running' | 'completed' | 'error' | 'cancelling'
|
||||
startedAt?: string
|
||||
completedAt?: string
|
||||
timeRange?: { value: number | null; unit: string }
|
||||
stats?: {
|
||||
total: number
|
||||
matched: number
|
||||
imported: number
|
||||
skipped: number
|
||||
errors: number
|
||||
}
|
||||
details?: {
|
||||
msg_id: string
|
||||
subject: string
|
||||
date: string
|
||||
order_number: string | null
|
||||
status: 'imported' | 'skipped' | 'error'
|
||||
ticket_no?: string
|
||||
error?: string
|
||||
}[]
|
||||
detailsTruncated?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
type SectionKey = 'mail' | 'filter' | 'monitor'
|
||||
|
||||
export default function MonitorSettingsPage() {
|
||||
const [status, setStatus] = useState<MonitorStatus | null>(null)
|
||||
const [config, setConfig] = useState<MonitorConfig | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { isDirty, markClean } = useDirtyTracker()
|
||||
const [saving, setSaving] = useState<SectionKey | null>(null)
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
const [newKeyword, setNewKeyword] = useState('')
|
||||
const [expandedPanel, setExpandedPanel] = useState<'today' | 'total' | 'errors' | 'ongoing' | null>(null)
|
||||
const [sectionMessage, setSectionMessage] = useState<Partial<Record<SectionKey, { type: 'success' | 'error'; text: string }>>>({})
|
||||
// 立即检查冷却状态
|
||||
const [triggerCooldown, setTriggerCooldown] = useState(0)
|
||||
const cooldownTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// 邮箱工单检测状态
|
||||
const [scanState, setScanState] = useState<ScanState>({ status: 'idle' })
|
||||
const [scanTimeValue, setScanTimeValue] = useState<number>(7)
|
||||
const [scanTimeUnit, setScanTimeUnit] = useState<string>('day')
|
||||
const scanTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const [importing, setImporting] = useState(false)
|
||||
|
||||
// 从 scanState.status 派生 scanning 状态
|
||||
const scanning = scanState.status === 'running' || scanState.status === 'cancelling'
|
||||
|
||||
// 扫描历史状态
|
||||
const [scanHistory, setScanHistory] = useState<{
|
||||
items: Array<{
|
||||
id: number
|
||||
status: string
|
||||
started_at: string
|
||||
completed_at: string | null
|
||||
time_range_value: number | null
|
||||
time_range_unit: string | null
|
||||
total_count: number
|
||||
matched_count: number
|
||||
imported_count: number
|
||||
skipped_count: number
|
||||
error_count: number
|
||||
details_truncated: number
|
||||
error_message: string | null
|
||||
created_by: number | null
|
||||
created_at: string
|
||||
}>
|
||||
pagination: { page: number; limit: number; total: number; totalPages: number }
|
||||
} | null>(null)
|
||||
const [historyPage, setHistoryPage] = useState(1)
|
||||
const [loadingHistory, setLoadingHistory] = useState(false)
|
||||
|
||||
// 刷新扫描历史
|
||||
const refreshScanHistory = useCallback(async (page = 1) => {
|
||||
setLoadingHistory(true)
|
||||
try {
|
||||
const res = await fetch(`/api/monitor/scan-history?page=${page}&limit=10`)
|
||||
const data = await res.json()
|
||||
setScanHistory(data)
|
||||
setHistoryPage(page)
|
||||
} catch (err) {
|
||||
console.error('[Scan] 历史加载失败:', err)
|
||||
} finally {
|
||||
setLoadingHistory(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 自动刷新定时器
|
||||
const statusTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const errorTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// 页面可见性状态
|
||||
const isVisibleRef = useRef(true)
|
||||
|
||||
// 刷新运行状态
|
||||
const refreshStatus = useCallback(() => {
|
||||
fetch('/api/monitor/status')
|
||||
.then(r => r.json())
|
||||
.then(setStatus)
|
||||
.catch(err => console.error('[Monitor] 状态刷新失败:', err))
|
||||
}, [])
|
||||
|
||||
// 刷新错误列表(更频繁)- 使用相同的 API,但只更新错误部分
|
||||
const refreshErrors = useCallback(() => {
|
||||
fetch('/api/monitor/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
// 安全地合并错误列表,避免路径依赖
|
||||
setStatus(prev => {
|
||||
if (!prev || !data) return prev
|
||||
return {
|
||||
...prev,
|
||||
stats: data.stats || prev.stats,
|
||||
details: {
|
||||
...prev.details,
|
||||
recentErrors: data.details?.recentErrors ?? prev.details?.recentErrors ?? [],
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch(err => console.error('[Monitor] 错误列表刷新失败:', err))
|
||||
}, [])
|
||||
|
||||
// 刷新扫描状态
|
||||
const refreshScanState = useCallback(() => {
|
||||
fetch('/api/monitor/scan-status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
setScanState(data)
|
||||
// 扫描结束时停止轮询(scanning 从 status 派生)
|
||||
if (data.status !== 'running' && data.status !== 'cancelling') {
|
||||
if (scanTimerRef.current) {
|
||||
clearInterval(scanTimerRef.current)
|
||||
scanTimerRef.current = null
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[Scan] 状态刷新失败:', err)
|
||||
// 网络异常时不停止轮询,等待下次重试
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 页面可见性变化
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
isVisibleRef.current = !document.hidden
|
||||
if (isVisibleRef.current) {
|
||||
// 页面恢复可见,立即刷新
|
||||
refreshStatus()
|
||||
refreshErrors()
|
||||
if (scanning) refreshScanState()
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', handler)
|
||||
return () => document.removeEventListener('visibilitychange', handler)
|
||||
}, [scanning, refreshStatus, refreshErrors, refreshScanState])
|
||||
|
||||
// 自动刷新定时器
|
||||
useEffect(() => {
|
||||
// 运行状态:60 秒刷新
|
||||
statusTimerRef.current = setInterval(() => {
|
||||
if (isVisibleRef.current) refreshStatus()
|
||||
}, 60000)
|
||||
|
||||
// 错误列表:30 秒刷新
|
||||
errorTimerRef.current = setInterval(() => {
|
||||
if (isVisibleRef.current) refreshErrors()
|
||||
}, 30000)
|
||||
|
||||
return () => {
|
||||
if (statusTimerRef.current) clearInterval(statusTimerRef.current)
|
||||
if (errorTimerRef.current) clearInterval(errorTimerRef.current)
|
||||
}
|
||||
}, [refreshStatus, refreshErrors])
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch('/api/monitor/status').then(r => r.json()),
|
||||
fetch('/api/monitor/settings').then(r => r.json()),
|
||||
fetch('/api/monitor/scan-status').then(r => r.json()),
|
||||
fetch('/api/monitor/scan-history?page=1&limit=10').then(r => r.json()),
|
||||
]).then(([s, c, scan, history]) => {
|
||||
setStatus(s)
|
||||
setConfig(c)
|
||||
markClean('mail', { mail: c.mail })
|
||||
markClean('filter', { filter: c.filter })
|
||||
markClean('monitor', { monitor: c.monitor })
|
||||
setScanState(scan)
|
||||
setScanHistory(history)
|
||||
setLoading(false)
|
||||
}).catch(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
// 立即检查处理
|
||||
const handleTrigger = async () => {
|
||||
if (triggerCooldown > 0) return
|
||||
|
||||
const res = await fetch('/api/monitor/trigger', { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setMessage({ type: 'success', text: data.message })
|
||||
setTimeout(refreshStatus, 2000)
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error })
|
||||
}
|
||||
|
||||
// 启动 30 秒冷却
|
||||
setTriggerCooldown(30)
|
||||
cooldownTimerRef.current = setInterval(() => {
|
||||
setTriggerCooldown(prev => {
|
||||
if (prev <= 1) {
|
||||
if (cooldownTimerRef.current) clearInterval(cooldownTimerRef.current)
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// 清理冷却定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (cooldownTimerRef.current) clearInterval(cooldownTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 启动扫描
|
||||
const handleStartScan = async () => {
|
||||
if (scanning) return
|
||||
|
||||
setScanState({ status: 'running' })
|
||||
|
||||
const res = await fetch('/api/monitor/scan-emails', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ timeRange: { value: scanTimeValue, unit: scanTimeUnit } }),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (!data.success) {
|
||||
setMessage({ type: 'error', text: data.error })
|
||||
setScanState({ status: 'error', error: data.error })
|
||||
return
|
||||
}
|
||||
|
||||
// 启动轮询
|
||||
scanTimerRef.current = setInterval(refreshScanState, 3000)
|
||||
}
|
||||
|
||||
// 取消扫描
|
||||
const handleCancelScan = async () => {
|
||||
const res = await fetch('/api/monitor/scan-status', { method: 'DELETE' })
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setMessage({ type: 'success', text: data.message })
|
||||
setScanState(prev => ({ ...prev, status: 'cancelling' }))
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error })
|
||||
}
|
||||
}
|
||||
|
||||
// 导入跳过的邮件为工单
|
||||
const handleImportSkipped = async () => {
|
||||
if (!scanState.details || scanState.status !== 'completed') return
|
||||
|
||||
const skippedEmails = scanState.details.filter(d =>
|
||||
d.status === 'skipped' && d.order_number && d.error !== '工单已存在' && d.error !== '邮件已处理过'
|
||||
)
|
||||
|
||||
if (skippedEmails.length === 0) {
|
||||
setMessage({ type: 'error', text: '没有可导入的邮件(已跳过的邮件需要有工单号)' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(`确认导入 ${skippedEmails.length} 封邮件为工单?`)) return
|
||||
|
||||
setImporting(true)
|
||||
try {
|
||||
const res = await fetch('/api/monitor/scan-emails/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ emails: skippedEmails }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setMessage({ type: 'success', text: data.message })
|
||||
// 刷新扫描状态
|
||||
refreshScanState()
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error })
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '导入失败' })
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 清理扫描定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scanTimerRef.current) clearInterval(scanTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 其他原有函数保持不变...
|
||||
const handleStart = async () => {
|
||||
await fetch('/api/monitor/start', { method: 'POST' })
|
||||
refreshStatus()
|
||||
setMessage({ type: 'success', text: '监控已启用' })
|
||||
}
|
||||
|
||||
const handleStop = async () => {
|
||||
if (!confirm('确认停用邮件监控?')) return
|
||||
await fetch('/api/monitor/stop', { method: 'POST' })
|
||||
refreshStatus()
|
||||
setMessage({ type: 'success', text: '监控已停用' })
|
||||
}
|
||||
|
||||
const handleTestMail = async () => {
|
||||
setSectionMessage(prev => ({ ...prev, mail: { type: 'success', text: '正在测试邮箱连接...' } }))
|
||||
const res = await fetch('/api/monitor/test-mail', { method: 'POST' })
|
||||
const data = await res.json()
|
||||
setSectionMessage(prev => ({ ...prev, mail: { type: data.success ? 'success' : 'error', text: data.success ? data.message : data.error } }))
|
||||
}
|
||||
|
||||
const handleSectionSave = async (section: SectionKey) => {
|
||||
if (!config) return
|
||||
setSaving(section)
|
||||
setSectionMessage(prev => ({ ...prev, [section]: undefined }))
|
||||
const sectionData = { [section]: config[section] }
|
||||
const res = await fetch('/api/monitor/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(sectionData) })
|
||||
const data = await res.json()
|
||||
setSaving(null)
|
||||
if (data.success) {
|
||||
markClean(section, { [section]: config[section] })
|
||||
setSectionMessage(prev => ({ ...prev, [section]: { type: 'success', text: '已保存' } }))
|
||||
} else {
|
||||
setSectionMessage(prev => ({ ...prev, [section]: { type: 'error', text: data.error } }))
|
||||
}
|
||||
setTimeout(() => setSectionMessage(prev => ({ ...prev, [section]: undefined })), 3000)
|
||||
}
|
||||
|
||||
const addKeyword = () => {
|
||||
if (!config || !newKeyword.trim()) return
|
||||
setConfig({ ...config, filter: { ...config.filter, subject_keywords: [...config.filter.subject_keywords, newKeyword.trim()] } })
|
||||
setNewKeyword('')
|
||||
}
|
||||
|
||||
const removeKeyword = (index: number) => {
|
||||
if (!config) return
|
||||
setConfig({ ...config, filter: { ...config.filter, subject_keywords: config.filter.subject_keywords.filter((_, i) => i !== index) } })
|
||||
}
|
||||
|
||||
const inputClass = (path: string, changed = false) =>
|
||||
`w-full px-3 py-2 border rounded-lg text-sm transition-colors ${changed ? 'border-amber-400 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-600' : 'border-slate-300 dark:border-slate-600'}`
|
||||
|
||||
const SectionSaveButton = ({ section }: { section: SectionKey }) => {
|
||||
if (!config) return null
|
||||
const changed = isDirty(section, { [section]: config[section] })
|
||||
if (!changed) return null
|
||||
return (
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => handleSectionSave(section)}
|
||||
disabled={saving === section}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 text-sm font-medium"
|
||||
>
|
||||
{saving === section ? '保存中...' : '保存设置'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-6">加载中...</div>
|
||||
if (!config) return <div className="p-6">加载失败</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">邮件监控设置</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1">配置邮件监控、微信推送与提醒规则</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`p-3 rounded-lg text-sm ${message.type === 'success' ? 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400' : 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400'}`}>
|
||||
{message.text}
|
||||
<button onClick={() => setMessage(null)} className="float-right font-bold">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 运行状态 */}
|
||||
<Card className="p-5">
|
||||
<h2 className="text-lg font-semibold mb-4">运行状态</h2>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<span className={`inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm font-medium ${status?.workerHealth === 'healthy' ? 'bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400' : status?.workerHealth === 'error' ? 'bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400' : status?.workerHealth === 'unresponsive' ? 'bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400' : 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'}`}>
|
||||
<span className={`w-2 h-2 rounded-full ${status?.workerHealth === 'healthy' ? 'bg-green-500' : status?.workerHealth === 'error' || status?.workerHealth === 'unresponsive' ? 'bg-red-500' : 'bg-slate-400'}`} />
|
||||
{status?.workerHealth === 'healthy' ? '运行中' : status?.workerHealth === 'error' ? '运行异常' : status?.workerHealth === 'unresponsive' ? '无响应' : '已停止'}
|
||||
</span>
|
||||
{status?.lastCheck && <span className="text-sm text-slate-500">上次检查: {status.lastCheck}</span>}
|
||||
</div>
|
||||
|
||||
{/* 统计指标 */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-4">
|
||||
{[
|
||||
{ key: 'today' as const, label: '今日处理', value: status?.stats.todayProcessed || 0, color: 'text-blue-600 dark:text-blue-400' },
|
||||
{ key: 'total' as const, label: '累计', value: status?.stats.totalProcessed || 0, color: 'text-slate-700 dark:text-slate-300' },
|
||||
{ key: 'errors' as const, label: '错误', value: status?.stats.errors || 0, color: 'text-red-600 dark:text-red-400' },
|
||||
{ key: 'ongoing' as const, label: '进行中故障', value: status?.ongoingFaults || 0, color: 'text-amber-600 dark:text-amber-400' },
|
||||
].map(item => (
|
||||
<div key={item.key}>
|
||||
<span className="text-slate-500 text-sm">{item.label}: </span>
|
||||
<button
|
||||
onClick={() => setExpandedPanel(expandedPanel === item.key ? null : item.key)}
|
||||
className={`font-medium text-sm cursor-pointer hover:opacity-70 underline decoration-dotted underline-offset-2 transition-opacity ${item.color}`}
|
||||
>
|
||||
{item.value}
|
||||
<span className="ml-1 text-xs">{expandedPanel === item.key ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 展开面板 */}
|
||||
{expandedPanel && (
|
||||
<div className="mb-4 border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||
{expandedPanel === 'today' && status?.details?.todayProcessed && (
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">今日处理记录</span>
|
||||
<button onClick={() => setExpandedPanel(null)} className="text-xs text-slate-500 hover:text-slate-700">收起</button>
|
||||
</div>
|
||||
{status.details.todayProcessed.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">今日暂无处理记录</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{status.details.todayProcessed.map((item, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-sm py-1 border-b border-slate-100 dark:border-slate-800 last:border-0">
|
||||
<span className="text-slate-700 dark:text-slate-300">
|
||||
{item.ticket_no ? `工单 ${item.ticket_no}` : item.error || '处理完成'}
|
||||
</span>
|
||||
<span className="text-slate-400 text-xs">{item.created_at}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expandedPanel === 'total' && (
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">累计统计</span>
|
||||
<button onClick={() => setExpandedPanel(null)} className="text-xs text-slate-500 hover:text-slate-700">收起</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<span className="text-slate-500">总处理工单</span>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{status?.stats.totalProcessed || 0}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<span className="text-slate-500">今日处理</span>
|
||||
<p className="text-2xl font-bold text-blue-600">{status?.stats.todayProcessed || 0}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<span className="text-slate-500">总错误数</span>
|
||||
<p className="text-2xl font-bold text-red-600">{status?.stats.errors || 0}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<span className="text-slate-500">进行中故障</span>
|
||||
<p className="text-2xl font-bold text-amber-600">{status?.ongoingFaults || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expandedPanel === 'errors' && status?.details?.recentErrors && (
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-red-700 dark:text-red-400">最近错误记录</span>
|
||||
<button onClick={() => setExpandedPanel(null)} className="text-xs text-slate-500 hover:text-slate-700">收起</button>
|
||||
</div>
|
||||
{status.details.recentErrors.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">暂无错误记录</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{status.details.recentErrors.map((item, i) => (
|
||||
<div key={i} className="p-2 bg-red-50 dark:bg-red-900/20 rounded text-xs">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-red-700 dark:text-red-400 font-medium">错误</span>
|
||||
<span className="text-slate-400">{item.created_at}</span>
|
||||
</div>
|
||||
<pre className="text-red-600 dark:text-red-400 whitespace-pre-wrap break-all">
|
||||
{(() => {
|
||||
const errStr = item.error || item.details || JSON.stringify(item)
|
||||
try { return JSON.stringify(JSON.parse(errStr), null, 2) }
|
||||
catch { return errStr }
|
||||
})()}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expandedPanel === 'ongoing' && status?.details?.ongoingFaults && (
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-amber-700 dark:text-amber-400">进行中故障列表</span>
|
||||
<button onClick={() => setExpandedPanel(null)} className="text-xs text-slate-500 hover:text-slate-700">收起</button>
|
||||
</div>
|
||||
{status.details.ongoingFaults.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">暂无进行中的故障</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 dark:border-slate-700 text-left">
|
||||
<th className="py-2 pr-4 text-slate-500 font-medium">工单号</th>
|
||||
<th className="py-2 pr-4 text-slate-500 font-medium">服务器IP</th>
|
||||
<th className="py-2 pr-4 text-slate-500 font-medium">SN</th>
|
||||
<th className="py-2 pr-4 text-slate-500 font-medium">类型</th>
|
||||
<th className="py-2 pr-4 text-slate-500 font-medium">故障时间</th>
|
||||
<th className="py-2 text-slate-500 font-medium">故障信息</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{status.details.ongoingFaults.map((fault, i) => (
|
||||
<tr key={i} className="border-b border-slate-100 dark:border-slate-800 last:border-0">
|
||||
<td className="py-2 pr-4 font-mono text-xs">{fault.order_number || '-'}</td>
|
||||
<td className="py-2 pr-4">{fault.server_ip || '未知'}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">{fault.server_sn}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${fault.fault_type === 'oem_diag' ? 'bg-blue-100 text-blue-700' : fault.fault_type === 'oem_repair' ? 'bg-purple-100 text-purple-700' : 'bg-slate-100 text-slate-600'}`}>
|
||||
{fault.fault_type === 'oem_diag' ? '诊断' : fault.fault_type === 'oem_repair' ? '维修' : '未知'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-xs">{fault.fault_time}</td>
|
||||
<td className="py-2 text-xs max-w-xs truncate" title={fault.fault_detail || ''}>{fault.fault_detail || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{status?.enabled ? <button onClick={handleStop} className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 text-sm">停止监控</button> : <button onClick={handleStart} className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 text-sm">启动监控</button>}
|
||||
<button
|
||||
onClick={handleTrigger}
|
||||
disabled={triggerCooldown > 0}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{triggerCooldown > 0 ? `请等待 ${triggerCooldown} 秒` : '立即检查'}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 邮箱配置 */}
|
||||
<Card className="p-5">
|
||||
<h2 className="text-lg font-semibold mb-4">邮箱配置</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">邮箱地址</label>
|
||||
<input type="text" value={config.mail.address} onChange={e => setConfig({ ...config, mail: { ...config.mail, address: e.target.value } })} className={inputClass('mail.address')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">邮箱密码</label>
|
||||
<input type="password" value={config.mail.password} onChange={e => setConfig({ ...config, mail: { ...config.mail, password: e.target.value } })} className={inputClass('mail.password')} placeholder="••••••••" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">IMAP 服务器</label>
|
||||
<input type="text" value={config.mail.imap_server} onChange={e => setConfig({ ...config, mail: { ...config.mail, imap_server: e.target.value } })} className={inputClass('mail.imap_server')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">IMAP 端口</label>
|
||||
<input type="number" value={config.mail.imap_port} onChange={e => setConfig({ ...config, mail: { ...config.mail, imap_port: parseInt(e.target.value) || 993 } })} className={inputClass('mail.imap_port')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">POP3 备用服务器</label>
|
||||
<input type="text" value={config.mail.pop3_server} onChange={e => setConfig({ ...config, mail: { ...config.mail, pop3_server: e.target.value } })} className={inputClass('mail.pop3_server')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">POP3 备用端口</label>
|
||||
<input type="number" value={config.mail.pop3_port} onChange={e => setConfig({ ...config, mail: { ...config.mail, pop3_port: parseInt(e.target.value) || 995 } })} className={inputClass('mail.pop3_port')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">SMTP 服务器</label>
|
||||
<input type="text" value={config.mail.smtp_server} onChange={e => setConfig({ ...config, mail: { ...config.mail, smtp_server: e.target.value } })} className={inputClass('mail.smtp_server')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">SMTP 端口</label>
|
||||
<input type="number" value={config.mail.smtp_port} onChange={e => setConfig({ ...config, mail: { ...config.mail, smtp_port: parseInt(e.target.value) || 465 } })} className={inputClass('mail.smtp_port')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button onClick={handleTestMail} className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-sm">测试邮箱连接</button>
|
||||
{sectionMessage.mail && (
|
||||
<span className={`text-sm ${sectionMessage.mail.type === 'success' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||
{sectionMessage.mail.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<SectionSaveButton section="mail" />
|
||||
</Card>
|
||||
|
||||
{/* 邮件过滤 */}
|
||||
<Card className="p-5">
|
||||
<h2 className="text-lg font-semibold mb-4">邮件过滤</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">主题关键词(匹配任一即通过)</label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{config.filter.subject_keywords.map((kw, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
{kw}
|
||||
<button onClick={() => removeKeyword(i)} className="hover:opacity-70">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input type="text" value={newKeyword} onChange={e => setNewKeyword(e.target.value)} onKeyDown={e => e.key === 'Enter' && addKeyword()} className="flex-1 px-3 py-2 border rounded-lg text-sm" placeholder="输入关键词后回车" />
|
||||
<button onClick={addKeyword} className="px-3 py-2 bg-blue-500 text-white rounded-lg text-sm">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">发件人邮箱</label>
|
||||
<input type="text" value={config.filter.sender_email} onChange={e => setConfig({ ...config, filter: { ...config.filter, sender_email: e.target.value } })} className={inputClass('filter.sender_email')} />
|
||||
</div>
|
||||
</div>
|
||||
<SectionSaveButton section="filter" />
|
||||
</Card>
|
||||
|
||||
{/* 邮箱工单检测(新模块) */}
|
||||
<Card className="p-5">
|
||||
<h2 className="text-lg font-semibold mb-4">邮箱工单检测</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mb-4">扫描邮箱中的工单邮件,自动导入未存在的工单</p>
|
||||
|
||||
{/* 时间范围选择 */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">时间范围:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={scanTimeValue}
|
||||
onChange={e => setScanTimeValue(parseInt(e.target.value) || 1)}
|
||||
className="w-20 px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
|
||||
min={1}
|
||||
max={365}
|
||||
disabled={scanning}
|
||||
/>
|
||||
<select
|
||||
value={scanTimeUnit}
|
||||
onChange={e => setScanTimeUnit(e.target.value)}
|
||||
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
|
||||
disabled={scanning}
|
||||
>
|
||||
<option value="minute">分钟</option>
|
||||
<option value="hour">小时</option>
|
||||
<option value="day">天</option>
|
||||
<option value="week">周</option>
|
||||
<option value="month">月</option>
|
||||
<option value="all">全部</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleStartScan}
|
||||
disabled={scanning}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2"
|
||||
>
|
||||
{scanning ? (
|
||||
<>
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
扫描中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search size={14} />
|
||||
开始扫描
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{scanning && (
|
||||
<button
|
||||
onClick={handleCancelScan}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 text-sm inline-flex items-center gap-2"
|
||||
>
|
||||
取消扫描
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 扫描进度 */}
|
||||
{scanState.status === 'running' && (
|
||||
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm text-blue-700 dark:text-blue-400">
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
扫描进行中...
|
||||
</div>
|
||||
{scanState.stats && (
|
||||
<div className="mt-2 text-xs text-slate-600 dark:text-slate-400">
|
||||
已扫描 {scanState.stats.total} 封,导入 {scanState.stats.imported} 封,跳过 {scanState.stats.skipped} 封
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 扫描取消中 */}
|
||||
{scanState.status === 'cancelling' && (
|
||||
<div className="mb-4 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm text-amber-700 dark:text-amber-400">
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
正在取消扫描...
|
||||
</div>
|
||||
{scanState.stats && (
|
||||
<div className="mt-2 text-xs text-slate-600 dark:text-slate-400">
|
||||
已扫描 {scanState.stats.total} 封,已处理 {scanState.stats.imported + scanState.stats.skipped} 封
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 扫描结果 */}
|
||||
{(scanState.status === 'completed' || scanState.status === 'cancelling') && scanState.stats && (
|
||||
<div className="space-y-4">
|
||||
{scanState.error && (
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg text-sm text-amber-700 dark:text-amber-400">
|
||||
{scanState.error}
|
||||
</div>
|
||||
)}
|
||||
{scanState.detailsTruncated && (
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg text-sm text-amber-700 dark:text-amber-400">
|
||||
详情列表已截断(最多显示 500 条),完整统计请查看服务器日志
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-5 gap-4 text-sm">
|
||||
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<span className="text-slate-500">已扫描</span>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">{scanState.stats.total}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<span className="text-slate-500">匹配工单号</span>
|
||||
<p className="text-xl font-bold text-blue-600">{scanState.stats.matched}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<span className="text-green-600">已导入</span>
|
||||
<p className="text-xl font-bold text-green-600">{scanState.stats.imported}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<span className="text-slate-500">已跳过</span>
|
||||
<p className="text-xl font-bold text-slate-600">{scanState.stats.skipped}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<span className="text-red-600">错误</span>
|
||||
<p className="text-xl font-bold text-red-600">{scanState.stats.errors}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 详细结果列表 */}
|
||||
{scanState.details && scanState.details.length > 0 && (
|
||||
<div className="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 dark:bg-slate-800 sticky top-0">
|
||||
<tr>
|
||||
<th className="py-2 px-4 text-left text-slate-500 font-medium">工单号</th>
|
||||
<th className="py-2 px-4 text-left text-slate-500 font-medium">主题</th>
|
||||
<th className="py-2 px-4 text-left text-slate-500 font-medium">状态</th>
|
||||
<th className="py-2 px-4 text-left text-slate-500 font-medium">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scanState.details.map((item, i) => (
|
||||
<tr key={i} className="border-t border-slate-100 dark:border-slate-800">
|
||||
<td className="py-2 px-4 font-mono text-xs">{item.order_number || '-'}</td>
|
||||
<td className="py-2 px-4 text-xs max-w-xs truncate" title={item.subject}>{item.subject || '-'}</td>
|
||||
<td className="py-2 px-4">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||
item.status === 'imported' ? 'bg-green-100 text-green-700' :
|
||||
item.status === 'skipped' ? 'bg-slate-100 text-slate-600' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{item.status === 'imported' ? '已导入' : item.status === 'skipped' ? '已跳过' : '错误'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-4 text-xs text-slate-500">{item.error || item.ticket_no || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 导入按钮 */}
|
||||
{scanState.status === 'completed' && scanState.details && scanState.details.some(d =>
|
||||
d.status === 'skipped' && d.order_number && d.error !== '工单已存在' && d.error !== '邮件已处理过'
|
||||
) && (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleImportSkipped}
|
||||
disabled={importing}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 text-sm disabled:opacity-50 inline-flex items-center gap-2"
|
||||
>
|
||||
{importing ? (
|
||||
<>
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
导入中...
|
||||
</>
|
||||
) : (
|
||||
'导入跳过的邮件为工单'
|
||||
)}
|
||||
</button>
|
||||
<span className="text-sm text-slate-500">
|
||||
可导入 {scanState.details.filter(d =>
|
||||
d.status === 'skipped' && d.order_number && d.error !== '工单已存在' && d.error !== '邮件已处理过'
|
||||
).length} 封邮件
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 扫描错误 */}
|
||||
{scanState.status === 'error' && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg text-sm text-red-700 dark:text-red-400">
|
||||
扫描失败: {scanState.error || '未知错误'}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 全局运行参数 */}
|
||||
<Card className="p-5">
|
||||
<h2 className="text-lg font-semibold mb-4">全局运行参数</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mb-4">未在推送组中单独配置的参数将使用以下全局默认值</p>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">检查间隔(秒)</label>
|
||||
<input type="number" value={config.monitor.interval_seconds} onChange={e => setConfig({ ...config, monitor: { ...config.monitor, interval_seconds: parseInt(e.target.value) || 60 } })} className={inputClass('monitor.interval_seconds')} min={10} max={3600} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">静默期开始(时)</label>
|
||||
<input type="number" value={config.monitor.silent_start_hour} onChange={e => setConfig({ ...config, monitor: { ...config.monitor, silent_start_hour: parseInt(e.target.value) || 0 } })} className={inputClass('monitor.silent_start_hour')} min={0} max={23} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">静默期结束(时)</label>
|
||||
<input type="number" value={config.monitor.silent_end_hour} onChange={e => setConfig({ ...config, monitor: { ...config.monitor, silent_end_hour: parseInt(e.target.value) || 7 } })} className={inputClass('monitor.silent_end_hour')} min={0} max={23} />
|
||||
</div>
|
||||
</div>
|
||||
<SectionSaveButton section="monitor" />
|
||||
</Card>
|
||||
|
||||
{/* 扫描历史 */}
|
||||
<Card className="p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">扫描历史</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">查看历史扫描记录</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refreshScanHistory(1)}
|
||||
disabled={loadingHistory}
|
||||
className="px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-sm inline-flex items-center gap-1.5"
|
||||
>
|
||||
<RefreshCw size={14} className={loadingHistory ? 'animate-spin' : ''} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{scanHistory && scanHistory.items.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 dark:bg-slate-800">
|
||||
<tr>
|
||||
<th className="py-2 px-4 text-left text-slate-500 font-medium">时间</th>
|
||||
<th className="py-2 px-4 text-left text-slate-500 font-medium">范围</th>
|
||||
<th className="py-2 px-4 text-left text-slate-500 font-medium">状态</th>
|
||||
<th className="py-2 px-4 text-left text-slate-500 font-medium">扫描</th>
|
||||
<th className="py-2 px-4 text-left text-slate-500 font-medium">导入</th>
|
||||
<th className="py-2 px-4 text-left text-slate-500 font-medium">跳过</th>
|
||||
<th className="py-2 px-4 text-left text-slate-500 font-medium">错误</th>
|
||||
<th className="py-2 px-4 text-left text-slate-500 font-medium">操作人</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scanHistory.items.map((item) => (
|
||||
<tr key={item.id} className="border-t border-slate-100 dark:border-slate-800">
|
||||
<td className="py-2 px-4 text-xs">{item.started_at}</td>
|
||||
<td className="py-2 px-4 text-xs">
|
||||
{item.time_range_unit === 'all' ? '全部' : `${item.time_range_value || ''}${item.time_range_unit === 'minute' ? '分钟' : item.time_range_unit === 'hour' ? '小时' : item.time_range_unit === 'day' ? '天' : item.time_range_unit === 'week' ? '周' : '月'}`}
|
||||
</td>
|
||||
<td className="py-2 px-4">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||
item.status === 'completed' ? 'bg-green-100 text-green-700' :
|
||||
item.status === 'error' ? 'bg-red-100 text-red-700' :
|
||||
item.status === 'cancelling' ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{item.status === 'completed' ? '完成' : item.status === 'error' ? '错误' : item.status === 'cancelling' ? '已取消' : item.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-4 text-xs font-medium">{item.total_count}</td>
|
||||
<td className="py-2 px-4 text-xs text-green-600">{item.imported_count}</td>
|
||||
<td className="py-2 px-4 text-xs text-slate-500">{item.skipped_count}</td>
|
||||
<td className="py-2 px-4 text-xs text-red-600">{item.error_count}</td>
|
||||
<td className="py-2 px-4 text-xs text-slate-500">{item.created_by || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{scanHistory.pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">
|
||||
共 {scanHistory.pagination.total} 条记录
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => refreshScanHistory(historyPage - 1)}
|
||||
disabled={historyPage <= 1 || loadingHistory}
|
||||
className="px-3 py-1 border border-slate-300 dark:border-slate-600 rounded disabled:opacity-50"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="text-slate-500">
|
||||
{historyPage} / {scanHistory.pagination.totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => refreshScanHistory(historyPage + 1)}
|
||||
disabled={historyPage >= scanHistory.pagination.totalPages || loadingHistory}
|
||||
className="px-3 py-1 border border-slate-300 dark:border-slate-600 rounded disabled:opacity-50"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500 text-center py-4">
|
||||
{loadingHistory ? '加载中...' : '暂无扫描历史'}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import Link from 'next/link'
|
||||
import { Card } from '@/components/ui'
|
||||
import { Users, Shield, Settings as SettingsIcon } from 'lucide-react'
|
||||
import { Users, Shield, Settings as SettingsIcon, Mail } from 'lucide-react'
|
||||
|
||||
const sections = [
|
||||
{ href: '/settings/users', label: '用户管理', description: '管理系统用户与账号', icon: Users },
|
||||
{ href: '/settings/roles', label: '角色权限', description: '配置角色与权限', icon: Shield },
|
||||
{ href: '/settings/monitor', label: '邮件监控', description: '邮件监控与微信推送配置', icon: Mail },
|
||||
]
|
||||
|
||||
export default function SettingsPage() {
|
||||
|
|
|
|||
|
|
@ -11,15 +11,26 @@ interface Role {
|
|||
created_at: string
|
||||
}
|
||||
|
||||
const BUILTIN_ROLES = ['admin', 'operator', 'viewer']
|
||||
|
||||
const allPermissions = [
|
||||
{ key: 'tickets:read', label: '查看工单' },
|
||||
{ key: 'tickets:write', label: '编辑工单' },
|
||||
{ key: 'tickets:create', label: '手动建单' },
|
||||
{ key: 'tickets:import', label: '导入工单' },
|
||||
{ key: 'tickets:export', label: '导出工单' },
|
||||
{ key: 'tickets:edit', label: '处理工单' },
|
||||
{ key: 'tickets:delete', label: '删除工单' },
|
||||
{ key: 'reports:read', label: '查看报告' },
|
||||
{ key: 'reports:write', label: '编辑报告' },
|
||||
{ key: 'reports:download', label: '下载报告' },
|
||||
{ key: 'reports:create', label: '新建报告' },
|
||||
{ key: 'users:read', label: '查看用户' },
|
||||
{ key: 'users:write', label: '编辑用户' },
|
||||
{ key: 'roles:read', label: '查看角色' },
|
||||
{ key: 'roles:write', label: '编辑角色' },
|
||||
{ key: 'api-keys:read', label: '查看 API Key' },
|
||||
{ key: 'api-keys:write', label: '编辑 API Key' },
|
||||
{ key: 'audit-logs:read', label: '查看审计日志' },
|
||||
{ key: 'audit-logs:export', label: '导出审计日志' },
|
||||
]
|
||||
|
||||
export default function RolesPage() {
|
||||
|
|
@ -29,6 +40,7 @@ export default function RolesPage() {
|
|||
const [editRole, setEditRole] = useState<Role | null>(null)
|
||||
const [form, setForm] = useState({ name: '', display_name: '', permissions: [] as string[] })
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const fetchRoles = () => {
|
||||
fetch('/api/roles').then(r => r.json()).then(d => { if (d.roles) setRoles(d.roles) }).catch(() => {}).finally(() => setLoading(false))
|
||||
|
|
@ -61,6 +73,7 @@ export default function RolesPage() {
|
|||
|
||||
const handleSave = async () => {
|
||||
setError('')
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editRole) {
|
||||
const res = await fetch(`/api/roles/${editRole.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: form.display_name, permissions: form.permissions }) })
|
||||
|
|
@ -73,6 +86,7 @@ export default function RolesPage() {
|
|||
setModalOpen(false)
|
||||
fetchRoles()
|
||||
} catch { setError('操作失败') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
|
|
@ -90,16 +104,14 @@ export default function RolesPage() {
|
|||
} catch { return '无权限' }
|
||||
}
|
||||
|
||||
const builtinRoles = ['admin', 'operator', 'viewer']
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">角色权限</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1">角色与权限配置</p>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">角色权限</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">角色与权限配置</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={openCreate}><Plus size={16} className="mr-1" />新建角色</Button>
|
||||
<Button size="sm" onClick={openCreate}><Plus size={16} />新建角色</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
|
|
@ -109,12 +121,19 @@ export default function RolesPage() {
|
|||
{roles.map(r => (
|
||||
<tr key={r.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||
<td className="px-4 py-3 font-medium text-slate-900 dark:text-slate-100">{r.name}</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{r.display_name}</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{r.display_name}</span>
|
||||
{BUILTIN_ROLES.includes(r.name) && (
|
||||
<Badge variant={r.name === 'admin' ? 'info' : r.name === 'operator' ? 'success' : 'default'}>内置</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-500 dark:text-slate-400 text-sm">{formatPermissions(r.permissions)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(r)}><Pencil size={14} /></Button>
|
||||
{!builtinRoles.includes(r.name) && (
|
||||
{!BUILTIN_ROLES.includes(r.name) && (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(r.id)}><Trash2 size={14} className="text-red-500" /></Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -124,16 +143,21 @@ export default function RolesPage() {
|
|||
</Table>
|
||||
)}
|
||||
|
||||
<Modal open={modalOpen} onClose={() => setModalOpen(false)} title={editRole ? '编辑角色' : '新建角色'}>
|
||||
<Modal open={modalOpen} onClose={() => setModalOpen(false)} title={editRole ? '编辑角色' : '新建角色'} footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setModalOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSave} loading={saving}>{editRole ? '保存' : '创建'}</Button>
|
||||
</>
|
||||
}>
|
||||
<div className="space-y-4">
|
||||
{!editRole && <Input label="角色名(英文)" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} placeholder="e.g. supervisor" />}
|
||||
<Input label="显示名称" value={form.display_name} onChange={e => setForm(p => ({ ...p, display_name: e.target.value }))} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">权限</label>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{allPermissions.map(p => (
|
||||
<label key={p.key} className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={form.permissions.includes(p.key) || form.permissions.includes('*')} onChange={() => togglePermission(p.key)} className="rounded border-slate-300" />
|
||||
<label key={p.key} className="flex items-center gap-2 cursor-pointer py-1">
|
||||
<input type="checkbox" checked={form.permissions.includes(p.key) || form.permissions.includes('*')} onChange={() => togglePermission(p.key)} className="rounded border-slate-300 dark:border-slate-600" />
|
||||
<span className="text-sm text-slate-700 dark:text-slate-300">{p.label}</span>
|
||||
<span className="text-xs text-slate-400">{p.key}</span>
|
||||
</label>
|
||||
|
|
@ -141,10 +165,6 @@ export default function RolesPage() {
|
|||
</div>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleSave}>{editRole ? '保存' : '创建'}</Button>
|
||||
<Button variant="secondary" onClick={() => setModalOpen(false)}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { Button, Badge, Modal, Input, Select } from '@/components/ui'
|
||||
import { ArrowLeft, Edit } from 'lucide-react'
|
||||
|
||||
interface UserDetail {
|
||||
id: number; username: string; display_name: string; email: string | null
|
||||
role: string; is_active: number; created_at: string; updated_at: string
|
||||
last_login_at: string | null; is_online: number
|
||||
}
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const { id } = useParams()
|
||||
const router = useRouter()
|
||||
const [user, setUser] = useState<UserDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [form, setForm] = useState({ display_name: '', email: '', role: '', password: '', password_confirm: '', is_active: 1 })
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function fetchUser() {
|
||||
const res = await fetch(`/api/users/${id}`)
|
||||
if (res.ok) {
|
||||
const d = await res.json()
|
||||
setUser(d.user)
|
||||
setForm({ display_name: d.user.display_name, email: d.user.email || '', role: d.user.role, password: '', password_confirm: '', is_active: d.user.is_active })
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { fetchUser() }, [id])
|
||||
|
||||
function openEdit() { setError(''); setEditOpen(true) }
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true); setError('')
|
||||
try {
|
||||
if (form.password && form.password !== form.password_confirm) { setError('两次输入的密码不一致'); return }
|
||||
const body: Record<string, unknown> = { display_name: form.display_name, email: form.email, is_active: form.is_active }
|
||||
if (form.password) body.password = form.password
|
||||
if (user!.username !== 'admin' && user!.username !== 'localadmin') body.role = form.role
|
||||
const res = await fetch(`/api/users/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||
if (!res.ok) { const d = await res.json(); setError(d.error || '更新失败'); return }
|
||||
setEditOpen(false)
|
||||
fetchUser()
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
if (loading) return <div className="py-20 text-center text-slate-500 dark:text-slate-400">加载中...</div>
|
||||
if (!user) return <div className="py-20 text-center text-slate-500 dark:text-slate-400">用户不存在</div>
|
||||
|
||||
const roleLabel: Record<string, string> = { admin: '管理员', operator: '运维人员', viewer: '查看者' }
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<button onClick={() => router.back()} className="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors">
|
||||
<ArrowLeft size={16} /> 返回用户列表
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">用户详情 — {user.display_name}</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1">查看和管理用户信息</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={openEdit}><Edit size={16} />编辑用户</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-700 divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{[
|
||||
{ label: '用户名', value: user.username },
|
||||
{ label: '显示名称', value: user.display_name },
|
||||
{ label: '邮箱', value: user.email || '-' },
|
||||
{ label: '角色', value: <Badge variant="info">{roleLabel[user.role] || user.role}</Badge> },
|
||||
{ label: '状态', value: <Badge variant={user.is_active ? 'success' : 'danger'}>{user.is_active ? '启用' : '禁用'}</Badge> },
|
||||
{ label: '在线', value: <span className="inline-flex items-center gap-1.5"><span className={`inline-block w-2 h-2 rounded-full ${user.is_online ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'}`} /><span className="text-xs text-slate-500">{user.is_online ? '在线' : '离线'}</span></span> },
|
||||
{ label: '最后登录', value: user.last_login_at ? <span className="text-sm text-slate-500">{user.last_login_at}</span> : <span className="text-sm text-slate-400">从未登录</span> },
|
||||
{ label: '创建时间', value: <span className="text-sm text-slate-500">{user.created_at}</span> },
|
||||
].map(row => (
|
||||
<div key={row.label} className="flex items-center px-6 py-4">
|
||||
<span className="w-24 text-sm font-medium text-slate-600 dark:text-slate-400 shrink-0">{row.label}</span>
|
||||
<span className="text-sm text-slate-900 dark:text-slate-100">{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Modal open={editOpen} onClose={() => setEditOpen(false)} title="编辑用户" footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setEditOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSave} loading={saving}>保存</Button>
|
||||
</>
|
||||
}>
|
||||
<div className="space-y-4">
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Input label="显示名称" value={form.display_name} onChange={e => setForm(p => ({ ...p, display_name: e.target.value }))} />
|
||||
<Input label="邮箱" type="email" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} />
|
||||
<Select label="状态" value={String(form.is_active)} onChange={e => setForm(p => ({ ...p, is_active: parseInt(e.target.value) }))} options={[{ value: '1', label: '启用' }, { value: '0', label: '禁用' }]} />
|
||||
<Input label="新密码(留空不修改)" type="password" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
|
||||
{form.password && <Input label="确认新密码" type="password" value={form.password_confirm} onChange={e => setForm(p => ({ ...p, password_confirm: e.target.value }))} />}
|
||||
{user.username === 'admin' || user.username === 'localadmin' ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">角色</label>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 py-2">管理员(系统保留)</p>
|
||||
</div>
|
||||
) : (
|
||||
<Select label="角色" options={[{ value: 'viewer', label: '查看者' }, { value: 'operator', label: '运维人员' }, { value: 'admin', label: '管理员' }]} value={form.role} onChange={e => setForm(p => ({ ...p, role: e.target.value }))} />
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, Button, Table, Badge, Modal, Input, Select } from '@/components/ui'
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Button, Table, Badge, Modal, Input, Select } from '@/components/ui'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
|
|
@ -11,15 +12,18 @@ interface User {
|
|||
role: string
|
||||
is_active: number
|
||||
created_at: string
|
||||
last_login_at: string | null
|
||||
is_online: number
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editUser, setEditUser] = useState<User | null>(null)
|
||||
const [form, setForm] = useState({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
|
||||
const [error, setError] = useState('')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
|
||||
const [createError, setCreateError] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<User | null>(null)
|
||||
|
||||
const fetchUsers = () => {
|
||||
fetch('/api/users').then(r => r.json()).then(d => { if (d.users) setUsers(d.users) }).catch(() => {}).finally(() => setLoading(false))
|
||||
|
|
@ -27,42 +31,22 @@ export default function UsersPage() {
|
|||
|
||||
useEffect(() => { fetchUsers() }, [])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditUser(null)
|
||||
setForm({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
|
||||
setError('')
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (u: User) => {
|
||||
setEditUser(u)
|
||||
setForm({ username: u.username, password: '', display_name: u.display_name, email: u.email || '', role: u.role })
|
||||
setError('')
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setError('')
|
||||
const handleCreate = async () => {
|
||||
setCreateError(''); setCreating(true)
|
||||
try {
|
||||
if (editUser) {
|
||||
const body: Record<string, unknown> = { display_name: form.display_name, email: form.email, role: form.role }
|
||||
if (form.password) body.password = form.password
|
||||
const res = await fetch(`/api/users/${editUser.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||
if (!res.ok) { const d = await res.json(); setError(d.error || '更新失败'); return }
|
||||
} else {
|
||||
if (!form.username || !form.password || !form.display_name) { setError('请填写必填项'); return }
|
||||
const res = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) })
|
||||
if (!res.ok) { const d = await res.json(); setError(d.error || '创建失败'); return }
|
||||
}
|
||||
setModalOpen(false)
|
||||
if (!createForm.username || !createForm.password || !createForm.display_name) { setCreateError('请填写必填项'); return }
|
||||
const res = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(createForm) })
|
||||
if (!res.ok) { const d = await res.json(); setCreateError(d.error || '创建失败'); return }
|
||||
setCreateOpen(false)
|
||||
setCreateForm({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
|
||||
fetchUsers()
|
||||
} catch { setError('操作失败') }
|
||||
} catch { setCreateError('操作失败') } finally { setCreating(false) }
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确定删除此用户?')) return
|
||||
const res = await fetch(`/api/users/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) fetchUsers()
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
const res = await fetch(`/api/users/${deleteTarget.id}`, { method: 'DELETE' })
|
||||
if (res.ok) { setDeleteTarget(null); fetchUsers() }
|
||||
}
|
||||
|
||||
const handleToggleActive = async (u: User) => {
|
||||
|
|
@ -76,32 +60,41 @@ export default function UsersPage() {
|
|||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">用户管理</h1>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">用户管理</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1">系统用户与账号管理</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={openCreate}><Plus size={16} className="mr-1" />新建用户</Button>
|
||||
<Button size="sm" onClick={() => { setCreateError(''); setCreateOpen(true) }}><Plus size={16} />新建用户</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-500 dark:text-slate-400">加载中...</div>
|
||||
) : (
|
||||
<Table headers={['用户名', '显示名称', '邮箱', '角色', '状态', '创建时间', '操作']}>
|
||||
<Table headers={['用户名', '显示名称', '邮箱', '角色', '状态', '在线', '最后登录', '操作']}>
|
||||
{users.map(u => (
|
||||
<tr key={u.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||
<td className="px-4 py-3 font-medium text-slate-900 dark:text-slate-100">{u.username}</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{u.display_name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/settings/users/${u.id}`} className="text-blue-600 dark:text-blue-400 hover:underline font-medium">{u.username}</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-900 dark:text-white font-medium">{u.display_name}</td>
|
||||
<td className="px-4 py-3 text-slate-500 dark:text-slate-400">{u.email || '-'}</td>
|
||||
<td className="px-4 py-3"><Badge variant="info">{roleLabel[u.role] || u.role}</Badge></td>
|
||||
<td className="px-4 py-3"><Badge variant={u.role === 'admin' ? 'info' : u.role === 'operator' ? 'success' : 'default'}>{roleLabel[u.role] || u.role}</Badge></td>
|
||||
<td className="px-4 py-3">
|
||||
<button onClick={() => handleToggleActive(u)}>
|
||||
<Badge variant={u.is_active ? 'success' : 'danger'}>{u.is_active ? '启用' : '禁用'}</Badge>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-500 dark:text-slate-400">{u.created_at || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${u.is_online ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'}`} />
|
||||
<span className="text-slate-500 dark:text-slate-400">{u.is_online ? '在线' : '离线'}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-500 dark:text-slate-400">{u.last_login_at ? u.last_login_at : <span className="text-slate-400 dark:text-slate-500">从未登录</span>}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(u)}><Pencil size={14} /></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(u.id)}><Trash2 size={14} className="text-red-500" /></Button>
|
||||
{u.username !== 'admin' && u.username !== 'localadmin' && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(u)}><Trash2 size={14} className="text-red-500" /></Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -109,20 +102,30 @@ export default function UsersPage() {
|
|||
</Table>
|
||||
)}
|
||||
|
||||
<Modal open={modalOpen} onClose={() => setModalOpen(false)} title={editUser ? '编辑用户' : '新建用户'}>
|
||||
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="新建用户" footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setCreateOpen(false)}>取消</Button>
|
||||
<Button onClick={handleCreate} loading={creating}>创建</Button>
|
||||
</>
|
||||
}>
|
||||
<div className="space-y-4">
|
||||
{!editUser && <Input label="用户名" value={form.username} onChange={e => setForm(p => ({ ...p, username: e.target.value }))} required />}
|
||||
<Input label="显示名称" value={form.display_name} onChange={e => setForm(p => ({ ...p, display_name: e.target.value }))} required />
|
||||
<Input label={editUser ? '新密码(留空不修改)' : '密码'} type="password" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
|
||||
<Input label="邮箱" type="email" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} />
|
||||
<Select label="角色" options={[{ value: 'viewer', label: '查看者' }, { value: 'operator', label: '运维人员' }, { value: 'admin', label: '管理员' }]} value={form.role} onChange={e => setForm(p => ({ ...p, role: e.target.value }))} />
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleSave}>{editUser ? '保存' : '创建'}</Button>
|
||||
<Button variant="secondary" onClick={() => setModalOpen(false)}>取消</Button>
|
||||
</div>
|
||||
<Input label="用户名" value={createForm.username} onChange={e => setCreateForm(p => ({ ...p, username: e.target.value }))} required />
|
||||
<Input label="显示名称" value={createForm.display_name} onChange={e => setCreateForm(p => ({ ...p, display_name: e.target.value }))} required />
|
||||
<Input label="密码" type="password" value={createForm.password} onChange={e => setCreateForm(p => ({ ...p, password: e.target.value }))} />
|
||||
<Input label="邮箱" type="email" value={createForm.email} onChange={e => setCreateForm(p => ({ ...p, email: e.target.value }))} />
|
||||
<Select label="角色" options={[{ value: 'viewer', label: '查看者' }, { value: 'operator', label: '运维人员' }, { value: 'admin', label: '管理员' }]} value={createForm.role} onChange={e => setCreateForm(p => ({ ...p, role: e.target.value }))} />
|
||||
{createError && <p className="text-sm text-red-500">{createError}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除" footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||
<Button variant="danger" onClick={handleDelete}>删除</Button>
|
||||
</>
|
||||
}>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">确定要删除用户「{deleteTarget?.display_name}」吗?此操作不可撤销。</p>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,293 @@
|
|||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card } from '@/components/ui'
|
||||
import { Plus, Trash2, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { useDirtyTracker } from '@shared/ui/hooks/useDirtyTracker'
|
||||
|
||||
interface WebhookConfig {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
enabled: boolean
|
||||
params: {
|
||||
interval_seconds: number
|
||||
push_delay_ms: number
|
||||
silent_start_hour: number
|
||||
silent_end_hour: number
|
||||
}
|
||||
}
|
||||
|
||||
function generateId() {
|
||||
return 'wh_' + Math.random().toString(36).slice(2, 10)
|
||||
}
|
||||
|
||||
export default function WechatSettingsPage() {
|
||||
const [config, setConfig] = useState<{ wechat: { webhooks: WebhookConfig[] } } | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { isDirty, markClean, removeItem } = useDirtyTracker()
|
||||
const [savingId, setSavingId] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
const [expandedWebhooks, setExpandedWebhooks] = useState<Set<string>>(new Set())
|
||||
const [testingWebhook, setTestingWebhook] = useState<string | null>(null)
|
||||
|
||||
// 加载配置
|
||||
useEffect(() => {
|
||||
fetch('/api/monitor/settings')
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('加载失败')
|
||||
return r.json()
|
||||
})
|
||||
.then(data => setConfig({ wechat: data.wechat }))
|
||||
.catch(err => setMessage({ type: 'error', text: '加载配置失败: ' + err.message }))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
// 保存单个推送组
|
||||
const handleSaveSingle = async (id: string) => {
|
||||
if (!config) return
|
||||
const wh = config.wechat.webhooks.find(w => w.id === id)
|
||||
if (!wh) return
|
||||
setSavingId(id)
|
||||
setMessage(null)
|
||||
try {
|
||||
const res = await fetch('/api/monitor/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ wechat: { webhooks: [wh] } }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || '保存失败')
|
||||
markClean(id, wh)
|
||||
setMessage({ type: 'success', text: `「${wh.name}」保存成功` })
|
||||
setTimeout(() => setMessage(null), 3000)
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: `「${wh.name}」保存失败: ${err instanceof Error ? err.message : String(err)}` })
|
||||
} finally {
|
||||
setSavingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
// 测试 webhook
|
||||
const handleTestWebhook = async (webhookId: string) => {
|
||||
if (!config) return
|
||||
const wh = config.wechat.webhooks.find(w => w.id === webhookId)
|
||||
if (!wh || !wh.url) return
|
||||
setTestingWebhook(webhookId)
|
||||
const res = await fetch('/api/monitor/test-wechat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ webhook_url: wh.url }),
|
||||
})
|
||||
const data = await res.json()
|
||||
setTestingWebhook(null)
|
||||
setMessage({ type: data.success ? 'success' : 'error', text: data.success ? `「${wh.name}」测试成功` : `「${wh.name}」${data.error}` })
|
||||
setTimeout(() => setMessage(null), 5000)
|
||||
}
|
||||
|
||||
// 新增 webhook
|
||||
const addWebhook = () => {
|
||||
if (!config) return
|
||||
const newWh: WebhookConfig = {
|
||||
id: generateId(),
|
||||
name: `推送 ${config.wechat.webhooks.length + 1}`,
|
||||
url: '',
|
||||
enabled: true,
|
||||
params: { interval_seconds: 60, push_delay_ms: 2000, silent_start_hour: 0, silent_end_hour: 7 },
|
||||
}
|
||||
const webhooks = [...config.wechat.webhooks, newWh]
|
||||
setConfig({ wechat: { ...config.wechat, webhooks } })
|
||||
setExpandedWebhooks(prev => new Set(prev).add(newWh.id))
|
||||
}
|
||||
|
||||
// 删除 webhook(至少保留 1 个)
|
||||
const removeWebhook = (id: string) => {
|
||||
if (!config) return
|
||||
if (config.wechat.webhooks.length <= 1) return
|
||||
setConfig({ wechat: { ...config.wechat, webhooks: config.wechat.webhooks.filter(w => w.id !== id) } })
|
||||
removeItem(id)
|
||||
}
|
||||
|
||||
// 更新 webhook 基本字段
|
||||
const updateWebhook = (id: string, updates: Partial<WebhookConfig>) => {
|
||||
if (!config) return
|
||||
const webhooks = config.wechat.webhooks.map(w => w.id === id ? { ...w, ...updates } : w)
|
||||
setConfig({ wechat: { ...config.wechat, webhooks } })
|
||||
}
|
||||
|
||||
// 更新 webhook 高级参数
|
||||
const updateWebhookParams = (id: string, paramUpdates: Partial<WebhookConfig['params']>) => {
|
||||
if (!config) return
|
||||
const webhooks = config.wechat.webhooks.map(w => w.id === id ? { ...w, params: { ...w.params, ...paramUpdates } } : w)
|
||||
setConfig({ wechat: { ...config.wechat, webhooks } })
|
||||
}
|
||||
|
||||
// 展开/折叠 webhook
|
||||
const toggleWebhookExpand = (id: string) => {
|
||||
setExpandedWebhooks(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-6 text-slate-500 dark:text-slate-400">加载中...</div>
|
||||
if (!config) return <div className="p-6 text-slate-500 dark:text-slate-400">加载失败</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">微信推送配置</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1">管理企业微信 Webhook 推送,每个推送组可独立启用/禁用并配置运行参数</p>
|
||||
</div>
|
||||
|
||||
{/* 全局消息提示 */}
|
||||
{message && (
|
||||
<div className={`p-3 rounded-lg text-sm ${message.type === 'success' ? 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400' : 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400'}`}>
|
||||
{message.text}
|
||||
<button onClick={() => setMessage(null)} className="float-right font-bold">×</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -65,8 +65,17 @@ export default function TicketDetailPage() {
|
|||
|
||||
async function handleDelete() {
|
||||
if (!confirm('确定要删除此工单吗?')) return
|
||||
try {
|
||||
const res = await fetch(`/api/tickets/${ticket.id}`, { method: 'DELETE' })
|
||||
if (res.ok) handleBack()
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
handleBack()
|
||||
} else {
|
||||
alert(data.error || '删除失败')
|
||||
}
|
||||
} catch (e) {
|
||||
alert('删除失败:网络错误')
|
||||
}
|
||||
}
|
||||
const isPending = (ticket.current_status === 'open' || ticket.current_status === 'in_progress') && !ticket.close_time
|
||||
const isAdmin = currentUser?.role === 'admin'
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export default function AllTicketsPage() {
|
|||
fetch('/api/auth/me')
|
||||
.then(r => r.json())
|
||||
.then(u => {
|
||||
if (u.user?.role !== 'admin') {
|
||||
if (!u.user?.permissions?.includes('*')) {
|
||||
router.replace('/tickets/pending')
|
||||
} else {
|
||||
setReady(true)
|
||||
|
|
|
|||
|
|
@ -1,47 +1,7 @@
|
|||
'use client'
|
||||
import { useState } from 'react'
|
||||
// 登录页 — 引用共享组件
|
||||
import LoginPage from '@shared/ui/login-page'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setError(''); setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) })
|
||||
const data = await res.json()
|
||||
if (!res.ok) { setError(data.error || '登录失败'); return }
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const redirect = params.get('redirect')
|
||||
const dest = (redirect && redirect.startsWith('/')) ? redirect : '/dashboard'
|
||||
window.location.href = dest
|
||||
} catch { setError('网络错误') } finally { setLoading(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 px-4">
|
||||
<div className="w-full max-w-sm bg-white dark:bg-slate-900 rounded-lg border border-blue-200/50 dark:border-blue-500/20 shadow-lg p-8">
|
||||
<h1 className="text-2xl font-bold text-center mb-6 text-slate-900 dark:text-white">IT 工单跟踪系统</h1>
|
||||
{error && <div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">{error}</div>}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">用户名</label>
|
||||
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="请输入用户名"
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">密码</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="请输入密码"
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required />
|
||||
</div>
|
||||
<button type="submit" disabled={loading}
|
||||
className="w-full py-2 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium transition-colors duration-200">
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
export default function Page() {
|
||||
return <LoginPage siteName="IT工单跟踪系统" redirectPath="/dashboard" />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { cookies } from 'next/headers'
|
|||
import { getDb } from '@/lib/db'
|
||||
import { verifyToken } from '@/lib/auth'
|
||||
import { checkPermission } from '@/lib/permissions'
|
||||
import { writeAuditLog, getClientIP, diffObjects } from '@/lib/audit'
|
||||
|
||||
async function getSession() {
|
||||
const cookieStore = await cookies()
|
||||
|
|
@ -19,7 +20,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
|||
}
|
||||
|
||||
const { id } = await params
|
||||
const existing = getDb().prepare('SELECT id FROM api_keys WHERE id = ?').get(id)
|
||||
type ApiKeyRow = { id: number; name: string; permissions: string; expires_at: string | null; is_active: number }
|
||||
const existing = getDb().prepare<[string | number], ApiKeyRow>('SELECT id, name, permissions, expires_at, is_active FROM api_keys WHERE id = ?').get(id)
|
||||
if (!existing) return NextResponse.json({ error: 'API Key 不存在' }, { status: 404 })
|
||||
|
||||
try {
|
||||
|
|
@ -34,6 +36,20 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
|||
is_active !== undefined ? (is_active ? 1 : 0) : 1,
|
||||
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 })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '更新失败'
|
||||
|
|
@ -49,9 +65,22 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{
|
|||
}
|
||||
|
||||
const { id } = await params
|
||||
const existing = getDb().prepare('SELECT id FROM api_keys WHERE id = ?').get(id)
|
||||
const existing = getDb().prepare('SELECT id, name FROM api_keys WHERE id = ?').get(id) as { id: number; name: string } | undefined
|
||||
if (!existing) return NextResponse.json({ error: 'API Key 不存在' }, { status: 404 })
|
||||
|
||||
const snapshot = { id: existing.id, name: existing.name }
|
||||
|
||||
getDb().prepare('DELETE FROM api_keys WHERE id = ?').run(id)
|
||||
|
||||
writeAuditLog({
|
||||
userId: session.id,
|
||||
apiKeyId: null,
|
||||
action: 'delete',
|
||||
entityType: 'api_key',
|
||||
entityId: Number(id),
|
||||
details: { deleted: snapshot },
|
||||
ipAddress: getClientIP(_request),
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { cookies } from 'next/headers'
|
|||
import { getDb } from '@/lib/db'
|
||||
import { verifyToken, generateApiKey, hashApiKey } from '@/lib/auth'
|
||||
import { checkPermission } from '@/lib/permissions'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
async function getSession() {
|
||||
const cookieStore = await cookies()
|
||||
|
|
@ -44,6 +45,16 @@ export async function POST(request: Request) {
|
|||
'INSERT INTO api_keys (name, key_hash, permissions, expires_at, created_by) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(name, keyHash, perms, expires_at || null, session.id)
|
||||
|
||||
writeAuditLog({
|
||||
userId: session.id,
|
||||
apiKeyId: null,
|
||||
action: 'create',
|
||||
entityType: 'api_key',
|
||||
entityId: result.lastInsertRowid as number,
|
||||
details: { created: { name } },
|
||||
ipAddress: getClientIP(request),
|
||||
})
|
||||
|
||||
return NextResponse.json({ key, id: result.lastInsertRowid }, { status: 201 })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '创建失败'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
// 获取保留天数配置
|
||||
export async function GET() {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
|
||||
const db = getDb()
|
||||
const row = db.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'audit_retention_days'"
|
||||
).get() as { value: string } | undefined
|
||||
|
||||
return NextResponse.json({ days: parseInt(row?.value || '180', 10) })
|
||||
}
|
||||
|
||||
// 更新保留天数配置
|
||||
export async function PUT(request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
|
||||
if (user.role !== 'admin') {
|
||||
if (!user.permissions?.includes('*') && !user.permissions?.includes('audit-logs:export')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const body = await request.json()
|
||||
const days = Math.max(30, Math.min(365, parseInt(body.days, 10)))
|
||||
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('audit_retention_days', ?)"
|
||||
).run(String(days))
|
||||
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
action: 'update',
|
||||
entityType: 'audit_log',
|
||||
details: { retention_days: days },
|
||||
ipAddress: getClientIP(request)
|
||||
})
|
||||
|
||||
return NextResponse.json({ days })
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
|
||||
if (user.role !== 'admin') {
|
||||
if (!user.permissions?.includes('*') && !user.permissions?.includes('audit-logs:export')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// 从 settings 读取保留天数
|
||||
const retentionRow = db.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'audit_retention_days'"
|
||||
).get() as { value: string } | undefined
|
||||
const retentionDays = Math.max(30, Math.min(365, parseInt(retentionRow?.value || '180', 10)))
|
||||
|
||||
const result = db.prepare(
|
||||
`DELETE FROM audit_logs WHERE created_at < datetime('now', '-${retentionDays} days', '+8 hours')`
|
||||
).run()
|
||||
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
action: 'cleanup',
|
||||
entityType: 'audit_log',
|
||||
details: { cleanup: { deleted: result.changes, retention_days: retentionDays } },
|
||||
ipAddress: getClientIP(request)
|
||||
})
|
||||
|
||||
return NextResponse.json({ deleted: result.changes, retention_days: retentionDays })
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
|
||||
// 检查权限
|
||||
if (user.role !== 'admin') {
|
||||
if (!user.permissions?.includes('*') && !user.permissions?.includes('audit-logs:export')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
const entityType = searchParams.get('entity_type')
|
||||
const userId = searchParams.get('user_id')
|
||||
const dateFrom = searchParams.get('date_from')
|
||||
const dateTo = searchParams.get('date_to')
|
||||
|
||||
let whereClause = 'WHERE 1=1'
|
||||
const params: unknown[] = []
|
||||
|
||||
if (action) {
|
||||
whereClause += ' AND a.action = ?'
|
||||
params.push(action)
|
||||
}
|
||||
if (entityType) {
|
||||
whereClause += ' AND a.entity_type = ?'
|
||||
params.push(entityType)
|
||||
}
|
||||
if (userId) {
|
||||
whereClause += ' AND a.user_id = ?'
|
||||
params.push(parseInt(userId))
|
||||
}
|
||||
if (dateFrom) {
|
||||
whereClause += ' AND a.created_at >= ?'
|
||||
params.push(dateFrom)
|
||||
}
|
||||
if (dateTo) {
|
||||
whereClause += ' AND a.created_at <= ?'
|
||||
params.push(dateTo + ' 23:59:59')
|
||||
}
|
||||
|
||||
const logs = db.prepare(
|
||||
`SELECT a.*, u.username, ak.name as api_key_name
|
||||
FROM audit_logs a
|
||||
LEFT JOIN users u ON a.user_id = u.id
|
||||
LEFT JOIN api_keys ak ON a.api_key_id = ak.id
|
||||
${whereClause}
|
||||
ORDER BY a.created_at DESC`
|
||||
).all(...params)
|
||||
|
||||
// 生成 CSV
|
||||
const BOM = ''
|
||||
const headers = ['ID', '时间', '用户', 'API Key', '操作', '对象类型', '对象ID', '详情', 'IP地址']
|
||||
const rows = logs.map((log: Record<string, unknown>) => [
|
||||
log.id,
|
||||
log.created_at,
|
||||
log.username || '',
|
||||
log.api_key_name || '',
|
||||
log.action,
|
||||
log.entity_type,
|
||||
log.entity_id || '',
|
||||
log.details ? String(log.details).replace(/"/g, '""') : '',
|
||||
log.ip_address || ''
|
||||
])
|
||||
|
||||
const csvContent = BOM + [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
|
||||
].join('\n')
|
||||
|
||||
// 自审计:记录导出操作
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
action: 'export',
|
||||
entityType: 'audit_log',
|
||||
details: {
|
||||
export: {
|
||||
filter: { entity_type: entityType, action, user_id: userId, date_from: dateFrom, date_to: dateTo },
|
||||
count: logs.length
|
||||
}
|
||||
},
|
||||
ipAddress: getClientIP(request)
|
||||
})
|
||||
|
||||
const today = new Date()
|
||||
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||
|
||||
return new NextResponse(csvContent, {
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="audit-logs-${dateStr}.csv"`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
|
||||
// 检查权限
|
||||
if (user.role !== 'admin') {
|
||||
if (!user.permissions?.includes('*') && !user.permissions?.includes('audit-logs:read')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = Math.max(1, parseInt(searchParams.get('page') || '1'))
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get('pageSize') || '20')))
|
||||
const action = searchParams.get('action')
|
||||
const entityType = searchParams.get('entity_type')
|
||||
const userId = searchParams.get('user_id')
|
||||
const dateFrom = searchParams.get('date_from')
|
||||
const dateTo = searchParams.get('date_to')
|
||||
|
||||
let whereClause = 'WHERE 1=1'
|
||||
const params: unknown[] = []
|
||||
|
||||
if (action) {
|
||||
whereClause += ' AND a.action = ?'
|
||||
params.push(action)
|
||||
}
|
||||
if (entityType) {
|
||||
whereClause += ' AND a.entity_type = ?'
|
||||
params.push(entityType)
|
||||
}
|
||||
if (userId) {
|
||||
whereClause += ' AND a.user_id = ?'
|
||||
params.push(parseInt(userId))
|
||||
}
|
||||
if (dateFrom) {
|
||||
whereClause += ' AND a.created_at >= ?'
|
||||
params.push(dateFrom)
|
||||
}
|
||||
if (dateTo) {
|
||||
whereClause += ' AND a.created_at <= ?'
|
||||
params.push(dateTo + ' 23:59:59')
|
||||
}
|
||||
|
||||
const countResult = db.prepare(
|
||||
`SELECT COUNT(*) as total FROM audit_logs a ${whereClause}`
|
||||
).get(...params) as { total: number }
|
||||
|
||||
const logs = db.prepare(
|
||||
`SELECT a.*, u.username, ak.name as api_key_name
|
||||
FROM audit_logs a
|
||||
LEFT JOIN users u ON a.user_id = u.id
|
||||
LEFT JOIN api_keys ak ON a.api_key_id = ak.id
|
||||
${whereClause}
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
).all(...params, pageSize, (page - 1) * pageSize)
|
||||
|
||||
return NextResponse.json({
|
||||
logs,
|
||||
total: countResult.total,
|
||||
page,
|
||||
pageSize
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
// 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',
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
// GET /api/auth/login/oidc — OIDC SSO 重定向(V2:使用 shared handleOidcLogin 工厂)
|
||||
import { handleOidcLogin } from '@shared/lib/auth/handle-login'
|
||||
|
||||
const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
|
||||
const oidcClientId = process.env.OIDC_CLIENT_ID || 'issue-oidc'
|
||||
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || ''
|
||||
const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://issue.tlyq.ai/api/auth/callback'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url)
|
||||
const switchUser = url.searchParams.get('switch') === '1'
|
||||
|
||||
return handleOidcLogin({ autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri, switchUser })
|
||||
}
|
||||
|
|
@ -1,16 +1,106 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { login } from '@/lib/auth'
|
||||
import { createToken } from '@/lib/auth'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt-shared'
|
||||
import { ldapAuth } from '@/lib/ldap'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { getUserPermissions } from '@/lib/permissions'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
initDatabase()
|
||||
const { username, password } = await request.json()
|
||||
if (!username || !password) return NextResponse.json({ error: '请输入用户名和密码' }, { status: 400 })
|
||||
const result = await login(username, password)
|
||||
if (!result) return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
|
||||
const response = NextResponse.json({ user: result.user })
|
||||
response.cookies.set('session_issue', result.token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 7 * 24 * 60 * 60, path: '/' })
|
||||
|
||||
let userId: number
|
||||
let role: string
|
||||
let displayName: string
|
||||
const db = getDb()
|
||||
|
||||
// 1. localadmin:纯本地 BCrypt,不依赖 LLDAP
|
||||
if (username === 'localadmin') {
|
||||
const localUser = db.prepare(
|
||||
'SELECT * FROM users WHERE username = ? AND is_active = 1'
|
||||
).get(username) as { id: number; username: string; display_name: string; role: string; password_hash: string } | undefined
|
||||
if (!localUser || !bcrypt.compareSync(password, localUser.password_hash)) {
|
||||
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
|
||||
}
|
||||
userId = localUser.id
|
||||
role = localUser.role
|
||||
displayName = localUser.display_name || username
|
||||
} else {
|
||||
// 2. 其他用户:LLDAP 优先
|
||||
const ldapResult = await ldapAuth(username, password)
|
||||
|
||||
if (ldapResult.success) {
|
||||
// LDAP 认证成功 → 更新本地密码缓存 + 自动创建用户
|
||||
displayName = ldapResult.displayName || username
|
||||
const pwHash = bcrypt.hashSync(password, 10)
|
||||
const existing = db.prepare(
|
||||
'SELECT id, role FROM users WHERE username = ? AND is_active = 1'
|
||||
).get(username) as { id: number; role: string } | undefined
|
||||
|
||||
if (existing) {
|
||||
db.prepare('UPDATE users SET password_hash = ?, display_name = ?, email = ? WHERE id = ?')
|
||||
.run(pwHash, displayName, ldapResult.email || null, existing.id)
|
||||
userId = existing.id
|
||||
role = existing.role
|
||||
} else {
|
||||
db.prepare(
|
||||
"INSERT INTO users (username, password_hash, display_name, email, role, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, 'viewer', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
|
||||
).run(username, pwHash, displayName, ldapResult.email || null)
|
||||
const created = db.prepare(
|
||||
'SELECT id, role FROM users WHERE username = ?'
|
||||
).get(username) as { id: number; role: string } | undefined
|
||||
if (!created) return NextResponse.json({ error: '用户创建失败' }, { status: 500 })
|
||||
userId = created.id
|
||||
role = created.role
|
||||
}
|
||||
} else if (ldapResult.unreachable) {
|
||||
// LLDAP 不可达 → 回退本地密码缓存
|
||||
const localUser = db.prepare(
|
||||
'SELECT * FROM users WHERE username = ? AND is_active = 1'
|
||||
).get(username) as { id: number; username: string; display_name: string; role: string; password_hash: string } | undefined
|
||||
if (!localUser || !bcrypt.compareSync(password, localUser.password_hash)) {
|
||||
return NextResponse.json({ error: '认证服务不可用,且本地密码不匹配' }, { status: 401 })
|
||||
}
|
||||
userId = localUser.id
|
||||
role = localUser.role
|
||||
displayName = localUser.display_name || username
|
||||
} else {
|
||||
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 更新最后登录时间和活跃时间
|
||||
db.prepare("UPDATE users SET last_login_at = datetime('now', '+8 hours'), last_active_at = datetime('now', '+8 hours') WHERE id = ?").run(userId)
|
||||
|
||||
// 4. 签发两个 cookie
|
||||
const localToken = await createToken({ id: userId, username, display_name: displayName, role, permissions: [] })
|
||||
const sharedToken = signSharedJwt({ username, displayName })
|
||||
const sharedCfg = sharedCookieConfig()
|
||||
|
||||
const response = NextResponse.json({
|
||||
user: { id: userId, username, display_name: displayName, role, permissions: getUserPermissions(role) },
|
||||
})
|
||||
|
||||
writeAuditLog({
|
||||
userId,
|
||||
apiKeyId: null,
|
||||
action: 'login',
|
||||
entityType: 'auth',
|
||||
entityId: userId,
|
||||
details: { username, displayName },
|
||||
ipAddress: getClientIP(request),
|
||||
})
|
||||
|
||||
response.cookies.set('session_issue', localToken, {
|
||||
httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax',
|
||||
maxAge: 7 * 24 * 60 * 60, path: '/',
|
||||
})
|
||||
response.cookies.set(sharedCfg.name, sharedToken, sharedCfg)
|
||||
return response
|
||||
} catch (e) { console.error('Login error:', e); return NextResponse.json({ error: '登录失败' }, { status: 500 }) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,55 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
export async function POST() {
|
||||
const r = NextResponse.json({ success: true })
|
||||
r.cookies.set('session_issue', '', { maxAge: 0, path: '/' })
|
||||
return r
|
||||
// GET + POST /api/auth/logout — 退出登录(清除 cookie + 302 跳转 /login)
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
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) {
|
||||
// 从 session cookie 提取用户名用于审计
|
||||
let username = 'anonymous'
|
||||
try {
|
||||
const token =
|
||||
request.cookies.get('tlyq_session')?.value ||
|
||||
request.cookies.get('session_issue')?.value
|
||||
if (token) {
|
||||
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString())
|
||||
username = payload.username || 'unknown'
|
||||
}
|
||||
} catch { /* 解析失败,使用默认值 */ }
|
||||
|
||||
writeAuditLog({
|
||||
userId: null,
|
||||
username,
|
||||
action: 'logout',
|
||||
entityType: 'auth',
|
||||
details: { message: '用户登出' },
|
||||
ipAddress: getClientIP(request),
|
||||
})
|
||||
|
||||
return logoutResponse()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
return NextResponse.json({ user })
|
||||
} catch {
|
||||
return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
export async function GET() { return NextResponse.json({ status: 'OK' }) }
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
const INTERNAL_KEY = process.env.INTERNAL_API_KEY || 'oa-internal-key-tlyq-2026'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const key = request.headers.get('x-internal-key')
|
||||
if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const db = getDb()
|
||||
const roles = db.prepare('SELECT name, display_name FROM roles ORDER BY name').all() as { name: string; display_name: string }[]
|
||||
return NextResponse.json({ roles })
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
// GET /api/internal/users — 供 OA 查询 issue 用户列表
|
||||
// POST /api/internal/users — 供 OA 同步用户角色
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
const INTERNAL_KEY = process.env.INTERNAL_API_KEY || 'oa-internal-key-tlyq-2026'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const key = request.headers.get('x-internal-key')
|
||||
if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const db = getDb()
|
||||
const users = db.prepare(
|
||||
'SELECT username, display_name, role FROM users WHERE is_active = 1 ORDER BY username'
|
||||
).all() as { username: string; display_name: string; role: string }[]
|
||||
return NextResponse.json({ users })
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const key = request.headers.get('x-internal-key')
|
||||
if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const body = await request.json()
|
||||
const { username, displayName, role } = body
|
||||
if (!username) {
|
||||
return NextResponse.json({ error: 'username 必填' }, { status: 400 })
|
||||
}
|
||||
const VALID_ROLES = ['admin', 'editor', 'viewer']
|
||||
const safeRole = VALID_ROLES.includes(role) ? role : 'viewer'
|
||||
|
||||
try {
|
||||
const db = getDb()
|
||||
db.prepare(
|
||||
`INSERT INTO users (username, password_hash, display_name, role, is_active, created_at, updated_at)
|
||||
VALUES (?, '', ?, ?, 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))
|
||||
ON CONFLICT(username) DO UPDATE SET role = ?, display_name = ?,
|
||||
updated_at = datetime('now', '+8 hours')`
|
||||
).run(username, displayName || username, safeRole, safeRole, displayName || username)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch {
|
||||
return NextResponse.json({ error: '同步失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
// src/app/api/monitor/scan-emails/import/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { notifyTicketCreated } from '@/lib/monitor/ticket-notifier'
|
||||
|
||||
interface ImportEmail {
|
||||
msg_id: string
|
||||
subject: string
|
||||
date: string
|
||||
order_number: string | null
|
||||
status: string
|
||||
ticket_no?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user || !hasPermission(user, 'monitor:write')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
|
||||
const clientIP = getClientIP(request)
|
||||
const body = await request.json()
|
||||
const { emails } = body as { emails: ImportEmail[] }
|
||||
|
||||
if (!emails || !Array.isArray(emails) || emails.length === 0) {
|
||||
return NextResponse.json({ error: '没有可导入的邮件' }, { status: 400 })
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
let imported = 0
|
||||
let skipped = 0
|
||||
let errors = 0
|
||||
const results: { order_number: string; status: string; error?: string }[] = []
|
||||
|
||||
for (const email of emails) {
|
||||
if (!email.order_number) {
|
||||
skipped++
|
||||
results.push({
|
||||
order_number: '-',
|
||||
status: 'skipped',
|
||||
error: '无工单号',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const ticketId = parseInt(email.order_number)
|
||||
|
||||
// 检查工单是否已存在
|
||||
const existing = db.prepare('SELECT id FROM tickets WHERE id = ?').get(ticketId)
|
||||
if (existing) {
|
||||
skipped++
|
||||
results.push({
|
||||
order_number: email.order_number,
|
||||
status: 'skipped',
|
||||
error: '工单已存在',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否已处理过
|
||||
const processed = db.prepare('SELECT msg_id FROM processed_emails WHERE msg_id = ?').get(email.msg_id)
|
||||
if (processed) {
|
||||
skipped++
|
||||
results.push({
|
||||
order_number: email.order_number,
|
||||
status: 'skipped',
|
||||
error: '邮件已处理过',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建工单
|
||||
db.prepare(`
|
||||
INSERT INTO tickets (id, content, current_status, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, 'open', ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))
|
||||
`).run(ticketId, `从邮件导入:${email.subject}`, user.id)
|
||||
|
||||
// 记录已处理邮件
|
||||
db.prepare("INSERT OR IGNORE INTO processed_emails (msg_id, subject) VALUES (?, ?)").run(email.msg_id, email.subject)
|
||||
|
||||
// 审计日志
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
apiKeyId: null,
|
||||
action: 'import',
|
||||
entityType: 'ticket',
|
||||
entityId: ticketId,
|
||||
details: { created: { ticket_no: email.order_number, source: 'email_import' } },
|
||||
ipAddress: clientIP,
|
||||
})
|
||||
|
||||
imported++
|
||||
results.push({
|
||||
order_number: email.order_number,
|
||||
status: 'imported',
|
||||
})
|
||||
|
||||
void notifyTicketCreated({
|
||||
ticket_no: email.order_number,
|
||||
device_ip: null,
|
||||
device_sn: null,
|
||||
device_name: null,
|
||||
ticket_type: null,
|
||||
content: `从邮件导入:${email.subject}`,
|
||||
assign_time: null,
|
||||
}).catch(e => console.error('[scan-import] notify 失败:', e))
|
||||
} catch (err) {
|
||||
errors++
|
||||
results.push({
|
||||
order_number: email.order_number,
|
||||
status: 'error',
|
||||
error: err instanceof Error ? err.message : '导入失败',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `导入完成:成功 ${imported} 封,跳过 ${skipped} 封,错误 ${errors} 封`,
|
||||
imported,
|
||||
skipped,
|
||||
errors,
|
||||
results,
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,648 @@
|
|||
// src/app/api/monitor/scan-emails/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { getMonitorConfig } from '@/lib/monitor/settings-manager'
|
||||
import Imap from 'imap'
|
||||
import { simpleParser } from 'mailparser'
|
||||
import * as cheerio from 'cheerio'
|
||||
import { formatBeijingTime } from '@/lib/monitor/types'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { getScanState, setScanState, isCancelRequested, resetCancelFlag } from '@/lib/monitor/scan-state'
|
||||
import { fetchPOP3Emails, normalizeMessageId } from '@/lib/monitor/pop3-fetcher'
|
||||
import { notifyTicketCreated } from '@/lib/monitor/ticket-notifier'
|
||||
|
||||
interface ScanResult {
|
||||
status: 'running' | 'completed' | 'error' | 'cancelling'
|
||||
startedAt: string
|
||||
completedAt?: string
|
||||
timeRange: { value: number | null; unit: string }
|
||||
stats: {
|
||||
total: number
|
||||
matched: number
|
||||
imported: number
|
||||
skipped: number
|
||||
errors: number
|
||||
}
|
||||
details: {
|
||||
msg_id: string
|
||||
subject: string
|
||||
date: string
|
||||
order_number: string | null
|
||||
status: 'imported' | 'skipped' | 'error'
|
||||
ticket_no?: string
|
||||
error?: string
|
||||
}[]
|
||||
detailsTruncated: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
const VALID_UNITS = ['minute', 'hour', 'day', 'week', 'month', 'all']
|
||||
const MAX_VALUE = 365
|
||||
const MAX_DETAILS = 500
|
||||
const SCAN_TIMEOUT_MS = 5 * 60 * 1000 // 5 分钟超时
|
||||
|
||||
// 辅助函数:添加 detail 并追踪是否被截断
|
||||
function addDetail(
|
||||
scanState: ScanResult,
|
||||
detail: ScanResult['details'][0],
|
||||
counter: { total: number }
|
||||
): void {
|
||||
counter.total++
|
||||
if (scanState.details.length < MAX_DETAILS) {
|
||||
scanState.details.push(detail)
|
||||
} else {
|
||||
scanState.detailsTruncated = true
|
||||
}
|
||||
}
|
||||
|
||||
function extractOrderNumber(subject: string): string | null {
|
||||
const match = subject.match(/【服务器故障单】(\d+),/)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
function getDateRange(value: number | null, unit: string): Date {
|
||||
const now = new Date()
|
||||
if (!value || unit === 'all') {
|
||||
// 全部:搜索最近 30 天
|
||||
now.setDate(now.getDate() - 30)
|
||||
return now
|
||||
}
|
||||
|
||||
switch (unit) {
|
||||
case 'minute':
|
||||
now.setMinutes(now.getMinutes() - value)
|
||||
break
|
||||
case 'hour':
|
||||
now.setHours(now.getHours() - value)
|
||||
break
|
||||
case 'day':
|
||||
now.setDate(now.getDate() - value)
|
||||
break
|
||||
case 'week':
|
||||
now.setDate(now.getDate() - value * 7)
|
||||
break
|
||||
case 'month':
|
||||
now.setMonth(now.getMonth() - value)
|
||||
break
|
||||
default:
|
||||
now.setDate(now.getDate() - 7)
|
||||
}
|
||||
return now
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user || !hasPermission(user, 'monitor:write')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
// 检查是否有正在运行的扫描(包括取消中的扫描)
|
||||
const currentScan = getScanState()
|
||||
if (currentScan?.status === 'running' || currentScan?.status === 'cancelling') {
|
||||
return NextResponse.json({ error: '扫描正在进行中,请稍后再试' }, { status: 409 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { value, unit } = body.timeRange || { value: 7, unit: 'day' }
|
||||
|
||||
// 输入验证
|
||||
if (unit !== 'all') {
|
||||
if (typeof value !== 'number' || value < 1 || value > MAX_VALUE) {
|
||||
return NextResponse.json({ error: `时间范围值必须在 1-${MAX_VALUE} 之间` }, { status: 400 })
|
||||
}
|
||||
}
|
||||
if (!VALID_UNITS.includes(unit)) {
|
||||
return NextResponse.json({ error: `无效的时间单位: ${unit}` }, { status: 400 })
|
||||
}
|
||||
|
||||
const config = getMonitorConfig()
|
||||
const db = getDb()
|
||||
const since = getDateRange(value, unit)
|
||||
|
||||
// 初始化扫描结果
|
||||
const scanState: ScanResult = {
|
||||
status: 'running',
|
||||
startedAt: formatBeijingTime(),
|
||||
timeRange: { value, unit },
|
||||
stats: { total: 0, matched: 0, imported: 0, skipped: 0, errors: 0 },
|
||||
details: [],
|
||||
detailsTruncated: false,
|
||||
}
|
||||
setScanState(scanState)
|
||||
|
||||
// 重置取消标志
|
||||
resetCancelFlag()
|
||||
|
||||
// 异步执行扫描
|
||||
scanEmails(config, db, since, scanState, clientIP, user.id).catch(err => {
|
||||
// 内层已设置错误状态,此处不再重复设置
|
||||
console.error('[Scan] Unexpected error:', err)
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, message: '扫描已开始' })
|
||||
}
|
||||
|
||||
async function scanEmails(
|
||||
config: ReturnType<typeof getMonitorConfig>,
|
||||
db: ReturnType<typeof getDb>,
|
||||
since: Date,
|
||||
scanState: ScanResult,
|
||||
clientIP: string | null,
|
||||
userId: number
|
||||
): Promise<void> {
|
||||
// 使用 imap 库(与 Python 版本兼容)替代 imapflow
|
||||
// imapflow 对单部分 text/html 邮件的 source 获取有 bug
|
||||
const imap = new Imap({
|
||||
user: config.mail.address,
|
||||
password: config.mail.password,
|
||||
host: config.mail.imap_server,
|
||||
port: config.mail.imap_port,
|
||||
tls: true,
|
||||
connTimeout: 15000,
|
||||
authTimeout: 15000,
|
||||
})
|
||||
|
||||
// 整体超时控制
|
||||
const startTime = Date.now()
|
||||
const checkTimeout = (): boolean => {
|
||||
if (Date.now() - startTime > SCAN_TIMEOUT_MS) {
|
||||
scanState.status = 'error'
|
||||
scanState.completedAt = formatBeijingTime()
|
||||
scanState.error = `扫描超时(超过 ${SCAN_TIMEOUT_MS / 60000} 分钟)`
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 将 IMAP 回调风格封装为 Promise
|
||||
function imapConnect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
imap.once('ready', () => resolve())
|
||||
imap.once('error', (err: Error) => reject(err))
|
||||
imap.connect()
|
||||
})
|
||||
}
|
||||
|
||||
function imapOpenBox(boxName: string, readOnly: boolean): Promise<Imap.Box> {
|
||||
return new Promise((resolve, reject) => {
|
||||
imap.openBox(boxName, readOnly, (err: Error | null, box: Imap.Box) => {
|
||||
if (err) reject(err)
|
||||
else resolve(box)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function imapSearch(criteria: any[]): Promise<number[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// imap.search 返回 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
// src/app/api/monitor/scan-history/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
interface ScanHistoryItem {
|
||||
id: number
|
||||
status: string
|
||||
started_at: string
|
||||
completed_at: string | null
|
||||
time_range_value: number | null
|
||||
time_range_unit: string | null
|
||||
total_count: number
|
||||
matched_count: number
|
||||
imported_count: number
|
||||
skipped_count: number
|
||||
error_count: number
|
||||
details_truncated: number
|
||||
error_message: string | null
|
||||
details_json: string | null
|
||||
created_by: number | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user || !hasPermission(user, 'monitor:read')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const offset = (page - 1) * limit
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// 获取总数
|
||||
const countResult = db.prepare('SELECT COUNT(*) as total FROM scan_history').get() as { total: number }
|
||||
const total = countResult.total
|
||||
|
||||
// 获取列表
|
||||
const items = db.prepare(`
|
||||
SELECT * FROM scan_history
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(limit, offset) as ScanHistoryItem[]
|
||||
|
||||
// 解析 details_json
|
||||
const itemsWithDetails = items.map(item => ({
|
||||
...item,
|
||||
details: item.details_json ? JSON.parse(item.details_json) : [],
|
||||
details_json: undefined,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
items: itemsWithDetails,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// DELETE 清理旧历史(保留最近 N 天)
|
||||
export async function DELETE(request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user || !hasPermission(user, 'monitor:write')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const keepDays = body.keepDays || 30
|
||||
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
DELETE FROM scan_history
|
||||
WHERE created_at < datetime('now', '+8 hours', ? || ' days')
|
||||
`).run(-keepDays)
|
||||
|
||||
writeAuditLog({ userId: user.id, apiKeyId: null, action: 'delete', entityType: 'scan_history', details: { keepDays, deleted: result.changes }, ipAddress: getClientIP(request) })
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deleted: result.changes,
|
||||
message: `已清理 ${keepDays} 天前的扫描历史`,
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
// src/app/api/monitor/scan-status/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { getClientIP, writeAuditLog } from '@/lib/audit'
|
||||
import { getScanState, requestCancel } from '@/lib/monitor/scan-state'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user || !hasPermission(user, 'monitor:read')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
|
||||
const state = getScanState()
|
||||
return NextResponse.json(state || { status: 'idle' })
|
||||
}
|
||||
|
||||
// DELETE 请求用于取消扫描
|
||||
export async function DELETE(request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user || !hasPermission(user, 'monitor:write')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
|
||||
const state = getScanState()
|
||||
if (state?.status !== 'running') {
|
||||
return NextResponse.json({ error: '没有正在运行的扫描任务' }, { status: 400 })
|
||||
}
|
||||
|
||||
const clientIP = getClientIP(request)
|
||||
requestCancel()
|
||||
state.status = 'cancelling'
|
||||
|
||||
// 审计日志
|
||||
writeAuditLog({
|
||||
userId: user.id || null,
|
||||
apiKeyId: null,
|
||||
action: 'cancel',
|
||||
entityType: 'scan',
|
||||
entityId: null,
|
||||
details: { scan: { requestedBy: user.username } },
|
||||
ipAddress: clientIP,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, message: '取消请求已发送,扫描将在处理完当前邮件后停止' })
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
// src/app/api/monitor/settings/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { getMaskedConfig, updateMonitorConfig } from '@/lib/monitor/settings-manager'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
import net from 'net'
|
||||
|
||||
// 拒绝内网/回环/链路本地/CGNAT 的 IPv4 字面量(SSRF 纵深防御,防收信指向内网主机)
|
||||
function isPrivateIPv4(host: string): boolean {
|
||||
if (!net.isIPv4(host)) return false
|
||||
const p = host.split('.').map(Number)
|
||||
if (p[0] === 0 || p[0] === 10 || p[0] === 127) return true // 0/8, 10/8, 回环 127/8
|
||||
if (p[0] === 169 && p[1] === 254) return true // 链路本地 169.254/16
|
||||
if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true // 172.16/12
|
||||
if (p[0] === 192 && p[1] === 168) return true // 192.168/16
|
||||
if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return true // CGNAT 100.64/10
|
||||
return false
|
||||
}
|
||||
|
||||
// 拒绝非规范数字型 IP 写法(整数/十六进制/八进制/短式),防止绕过内网黑名单:
|
||||
// net.isIPv4 只认标准点分十进制,但 socket 层会把 2130706433 / 0x7f000001 / 127.1 / 0177.0.0.1
|
||||
// 等写法同样解析为回环/内网地址,必须在校验层拦截
|
||||
function isNonCanonicalNumericHost(host: string): boolean {
|
||||
if (/(^|\.)0x/i.test(host)) return true // 十六进制:0x7f000001 / 0x7f.0.0.1
|
||||
const segs = host.split('.')
|
||||
if (segs.every((s) => /^\d+$/.test(s)) && !net.isIPv4(host)) return true // 整数型/短式/八进制数字 IP(含前导零)
|
||||
return false
|
||||
}
|
||||
|
||||
// 主机名格式校验(纵深防御):只允许合法主机名/IP 字符(IPv6 字面量含 ':' 天然被拒),
|
||||
// 拒绝 URL/路径/空格、非规范数字型 IP 及内网 IPv4,收窄 SSRF 面
|
||||
const isValidHost = (v: unknown): boolean => {
|
||||
if (typeof v !== 'string' || v.length === 0 || v.length > 253) return false
|
||||
if (!/^[a-zA-Z0-9.-]+$/.test(v)) return false
|
||||
if (isNonCanonicalNumericHost(v)) return false
|
||||
if (isPrivateIPv4(v)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const VALIDATORS: Record<string, (v: unknown) => boolean> = {
|
||||
'monitor.enabled': (v) => typeof v === 'boolean',
|
||||
'monitor.interval_seconds': (v) => typeof v === 'number' && v >= 10 && v <= 3600,
|
||||
'monitor.push_delay_ms': (v) => typeof v === 'number' && v >= 0 && v <= 10000,
|
||||
'monitor.silent_start_hour': (v) => typeof v === 'number' && v >= 0 && v <= 23,
|
||||
'monitor.silent_end_hour': (v) => typeof v === 'number' && v >= 0 && v <= 23,
|
||||
'mail.address': (v) => typeof v === 'string' && v.includes('@'),
|
||||
'mail.imap_server': isValidHost,
|
||||
'mail.imap_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
|
||||
'mail.pop3_server': isValidHost,
|
||||
'mail.pop3_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
|
||||
'mail.smtp_server': isValidHost,
|
||||
'mail.smtp_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
|
||||
'wechat.webhooks': (v) => Array.isArray(v) && v.length > 0,
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user || !hasPermission(user, 'monitor:read')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
return NextResponse.json(getMaskedConfig())
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
const body = await request.json()
|
||||
// 保留未修改的密码
|
||||
if (body.mail?.password === '••••••••' || body.mail?.password === undefined) {
|
||||
delete body.mail?.password
|
||||
}
|
||||
// 先展平嵌套对象
|
||||
const flat: Record<string, unknown> = {}
|
||||
for (const [section, values] of Object.entries(body)) {
|
||||
if (typeof values === 'object' && values !== null) {
|
||||
for (const [key, value] of Object.entries(values as Record<string, unknown>)) {
|
||||
flat[`${section}.${key}`] = value
|
||||
}
|
||||
} else {
|
||||
flat[section] = values
|
||||
}
|
||||
}
|
||||
// 展平后验证
|
||||
for (const [key, value] of Object.entries(flat)) {
|
||||
const validator = VALIDATORS[key]
|
||||
if (validator && !validator(value)) return NextResponse.json({ error: `参数 ${key} 验证失败` }, { status: 400 })
|
||||
}
|
||||
updateMonitorConfig(flat)
|
||||
writeAuditLog({ userId: user.id, action: 'update', entityType: 'monitor_config', details: { keys: Object.keys(flat) }, ipAddress: getClientIP(request) })
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
// src/app/api/monitor/start/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { setSetting } from '@/lib/monitor/settings-manager'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
setSetting('monitor.enabled', 'true', 'monitor')
|
||||
writeAuditLog({ userId: user.id, action: 'enable', entityType: 'monitor', details: { message: '启用邮件监控' }, ipAddress: getClientIP(request) })
|
||||
return NextResponse.json({ success: true, message: '监控已启用,Worker 将在下一个 tick 启动' })
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
// src/app/api/monitor/status/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { getSetting } from '@/lib/monitor/settings-manager'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user || !hasPermission(user, 'monitor:read')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
const db = getDb()
|
||||
const enabled = getSetting('monitor.enabled') === 'true'
|
||||
const lastTick = db.prepare("SELECT created_at, details FROM monitor_logs WHERE action = 'tick_complete' ORDER BY id DESC LIMIT 1").get() as { created_at: string; details: string } | undefined
|
||||
|
||||
// 使用 UTC+8 日期(数据库中 created_at 是 UTC+8)
|
||||
const now = new Date()
|
||||
const todayStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} 00:00:00`
|
||||
const todayProcessed = (db.prepare("SELECT COUNT(*) as c FROM monitor_logs WHERE action = 'ticket_created' AND created_at >= ?").get(todayStart) as { c: number }).c
|
||||
const totalProcessed = (db.prepare("SELECT COUNT(*) as c FROM monitor_logs WHERE action = 'ticket_created'").get() as { c: number }).c
|
||||
// 最近一次 tick 的错误状态:action='error' → 1;tick_complete → 该 tick 的 errors 数;无记录 → 0
|
||||
const lastLog = db.prepare(
|
||||
"SELECT action, details FROM monitor_logs WHERE action IN ('error', 'tick_complete') ORDER BY id DESC LIMIT 1"
|
||||
).get() as { action: string; details: string | null } | undefined
|
||||
let errorCount = 0
|
||||
if (lastLog) {
|
||||
if (lastLog.action === 'error') {
|
||||
errorCount = 1
|
||||
} else {
|
||||
try { errorCount = JSON.parse(lastLog.details || '{}').errors || 0 } catch { errorCount = 0 }
|
||||
}
|
||||
}
|
||||
const ongoingFaultsCount = (db.prepare("SELECT COUNT(*) as c FROM fault_records WHERE status = 'ongoing'").get() as { c: number }).c
|
||||
|
||||
// 获取最近的活动(tick_complete 或 error)
|
||||
const lastActivity = db.prepare("SELECT created_at, action FROM monitor_logs WHERE action IN ('tick_complete', 'error') ORDER BY id DESC LIMIT 1").get() as { created_at: string; action: string } | undefined
|
||||
|
||||
let workerHealth = 'unknown'
|
||||
if (enabled && lastActivity) {
|
||||
const elapsed = Date.now() - new Date(lastActivity.created_at.replace(' ', 'T') + '+08:00').getTime()
|
||||
if (elapsed < 180_000) {
|
||||
workerHealth = lastActivity.action === 'error' ? 'error' : 'healthy'
|
||||
} else {
|
||||
workerHealth = 'unresponsive'
|
||||
}
|
||||
} else if (!enabled) {
|
||||
workerHealth = 'stopped'
|
||||
}
|
||||
|
||||
const lastError = db.prepare("SELECT details, created_at FROM monitor_logs WHERE action = 'error' ORDER BY id DESC LIMIT 1").get() as { details: string; created_at: string } | undefined
|
||||
|
||||
// 今日处理详情:从 monitor_logs 获取今天的工单创建记录
|
||||
const todayProcessedDetails = db.prepare(
|
||||
"SELECT details, created_at FROM monitor_logs WHERE action = 'ticket_created' AND created_at >= ? ORDER BY id DESC LIMIT 20"
|
||||
).all(todayStart) as { details: string; created_at: string }[]
|
||||
|
||||
// 进行中故障详情
|
||||
const ongoingFaultsDetails = db.prepare(
|
||||
"SELECT * FROM fault_records WHERE status = 'ongoing' ORDER BY fault_time DESC LIMIT 20"
|
||||
).all() as Record<string, unknown>[]
|
||||
|
||||
// 最近错误列表
|
||||
const recentErrors = db.prepare(
|
||||
"SELECT details, created_at FROM monitor_logs WHERE action = 'error' ORDER BY id DESC LIMIT 10"
|
||||
).all() as { details: string; created_at: string }[]
|
||||
|
||||
return NextResponse.json({
|
||||
enabled,
|
||||
status: enabled ? 'running' : 'stopped',
|
||||
workerHealth,
|
||||
lastCheck: lastTick?.created_at || null,
|
||||
stats: {
|
||||
totalProcessed,
|
||||
todayProcessed,
|
||||
errors: errorCount,
|
||||
lastError: lastError?.details || null,
|
||||
lastErrorTime: lastError?.created_at || null,
|
||||
},
|
||||
ongoingFaults: ongoingFaultsCount,
|
||||
details: {
|
||||
todayProcessed: todayProcessedDetails.map(d => {
|
||||
try { return { ...JSON.parse(d.details), created_at: d.created_at } }
|
||||
catch { return { details: d.details, created_at: d.created_at } }
|
||||
}),
|
||||
ongoingFaults: ongoingFaultsDetails,
|
||||
recentErrors: recentErrors.map(d => {
|
||||
try { return { ...JSON.parse(d.details), created_at: d.created_at } }
|
||||
catch { return { details: d.details, created_at: d.created_at } }
|
||||
}),
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: e instanceof Error ? e.message : 'Internal error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
// src/app/api/monitor/stop/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { setSetting } from '@/lib/monitor/settings-manager'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
setSetting('monitor.enabled', 'false', 'monitor')
|
||||
writeAuditLog({ userId: user.id, action: 'disable', entityType: 'monitor', details: { message: '停用邮件监控' }, ipAddress: getClientIP(request) })
|
||||
return NextResponse.json({ success: true, message: '监控已停用' })
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
// src/app/api/monitor/test-mail/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { getMonitorConfig } from '@/lib/monitor/settings-manager'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { ImapFlow } from 'imapflow'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
const config = getMonitorConfig()
|
||||
const client = new ImapFlow({
|
||||
host: config.mail.imap_server, port: config.mail.imap_port, secure: true,
|
||||
auth: { user: config.mail.address, pass: config.mail.password },
|
||||
logger: false, connectionTimeout: 10_000,
|
||||
})
|
||||
try {
|
||||
await client.connect()
|
||||
const lock = await client.getMailboxLock('INBOX')
|
||||
lock.release()
|
||||
await client.logout()
|
||||
writeAuditLog({ userId: user.id, apiKeyId: null, action: 'test', entityType: 'monitor_mail', details: { target: 'imap', success: true, server: config?.mail?.imap_server || 'unknown' }, ipAddress: getClientIP(request) })
|
||||
return NextResponse.json({ success: true, message: 'IMAP 连接成功,INBOX 选择正常' })
|
||||
} catch (e) {
|
||||
writeAuditLog({ userId: user.id, apiKeyId: null, action: 'test', entityType: 'monitor_mail', details: { target: 'imap', success: false, server: config?.mail?.imap_server || 'unknown' }, ipAddress: getClientIP(request) })
|
||||
return NextResponse.json({ success: false, error: e instanceof Error ? e.message : '连接失败' })
|
||||
} finally {
|
||||
try { await client.logout() } catch {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
// src/app/api/monitor/test-wechat/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { getMonitorConfig } from '@/lib/monitor/settings-manager'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
|
||||
let webhookUrl: string | null = null
|
||||
try {
|
||||
const body = await request.json()
|
||||
webhookUrl = body?.webhook_url || null
|
||||
} catch { /* 无 body */ }
|
||||
|
||||
// 如果 URL 被 mask(含 ••••••••),从 DB 查真实 URL
|
||||
if (webhookUrl && webhookUrl.includes('••••••••')) {
|
||||
const config = getMonitorConfig()
|
||||
const found = config.wechat.webhooks.find(wh => {
|
||||
if (!wh.url) return false
|
||||
// 对比非 key 部分是否匹配(前缀 + 后缀),找到对应的真实 webhook
|
||||
const maskedPattern = wh.url.replace(/key=([0-9a-f-]+)/, 'key=••••••••')
|
||||
return maskedPattern === webhookUrl
|
||||
})
|
||||
if (found) webhookUrl = found.url
|
||||
else webhookUrl = null // 找不到匹配的,走 fallback
|
||||
}
|
||||
|
||||
// 如果指定了 URL,使用指定的;否则使用第一个启用的
|
||||
if (!webhookUrl) {
|
||||
const config = getMonitorConfig()
|
||||
const enabled = config.wechat.webhooks.filter(wh => wh.enabled && wh.url)
|
||||
if (enabled.length === 0) return NextResponse.json({ success: false, error: 'Webhook URL 未配置' })
|
||||
webhookUrl = enabled[0].url
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ msgtype: 'text', text: { content: '✅ issue-ai 邮件监控测试消息' } }),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
const result = await response.json() as { errcode?: number; errmsg?: string }
|
||||
if (result.errcode === 0) {
|
||||
writeAuditLog({ userId: user.id, action: 'test', entityType: 'monitor_wechat', details: { message: '测试微信推送成功' }, ipAddress: getClientIP(request) })
|
||||
return NextResponse.json({ success: true, message: '测试消息发送成功' })
|
||||
}
|
||||
writeAuditLog({ userId: user.id, action: 'test', entityType: 'monitor_wechat', details: { message: '测试微信推送失败', error: result.errmsg || '发送失败' }, ipAddress: getClientIP(request) })
|
||||
return NextResponse.json({ success: false, error: result.errmsg || '发送失败' })
|
||||
} catch (e) {
|
||||
writeAuditLog({ userId: user.id, action: 'test', entityType: 'monitor_wechat', details: { message: '测试微信推送异常', error: e instanceof Error ? e.message : '发送失败' }, ipAddress: getClientIP(request) })
|
||||
return NextResponse.json({ success: false, error: e instanceof Error ? e.message : '发送失败' })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
// src/app/api/monitor/trigger/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { setSetting } from '@/lib/monitor/settings-manager'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
let lastTriggerTime = 0
|
||||
const TRIGGER_COOLDOWN = 30_000
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
if (Date.now() - lastTriggerTime < TRIGGER_COOLDOWN) return NextResponse.json({ error: '请等待 30 秒后再试' }, { status: 429 })
|
||||
lastTriggerTime = Date.now()
|
||||
// 写入触发标记,Worker 在下一个 tick 检测到后立即执行
|
||||
setSetting('monitor.trigger_requested', 'true', 'monitor')
|
||||
writeAuditLog({ userId: user.id, action: 'trigger', entityType: 'monitor', details: { message: '手动触发邮件检查' }, ipAddress: getClientIP(request) })
|
||||
return NextResponse.json({ success: true, message: '已触发,Worker 将在 1-2 秒内执行' })
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import fs from 'fs'
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
if (!hasPermission(user, 'reports:download')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
|
||||
const { id } = await params
|
||||
const db = getDb()
|
||||
const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(id) as any
|
||||
if (!report) return NextResponse.json({ error: '报告不存在' }, { status: 404 })
|
||||
|
||||
if (report.status !== 'completed' || !report.file_path) {
|
||||
return NextResponse.json({ error: '报告尚未生成完成' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!fs.existsSync(report.file_path)) {
|
||||
return NextResponse.json({ error: '报告文件不存在' }, { status: 404 })
|
||||
}
|
||||
|
||||
const buffer = fs.readFileSync(report.file_path)
|
||||
const contentType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
|
||||
const downloadName = report.file_name || `report_${id}.docx`
|
||||
const encodedName = encodeURIComponent(downloadName)
|
||||
return new NextResponse(new Uint8Array(buffer), {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': `attachment; filename*=UTF-8''${encodedName}`,
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '下载失败'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { generateMonthlyReport } from '@/lib/monthly-report'
|
||||
import { generateWeeklyReport } from '@/lib/weekly-report'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
export async function POST(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
if (!hasPermission(user, 'reports:create')) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const db = getDb()
|
||||
const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(id) as any
|
||||
|
||||
if (!report) {
|
||||
return NextResponse.json({ error: '报告不存在' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (report.status !== 'ready' && report.status !== 'failed') {
|
||||
return NextResponse.json(
|
||||
{ error: `当前状态 "${report.status}" 不允许生成文档` },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
db.prepare("UPDATE reports SET status = 'generating', error_message = NULL WHERE id = ?")
|
||||
.run(id)
|
||||
|
||||
if (report.report_type === 'weekly') {
|
||||
generateWeeklyReport(Number(id)).catch(err => {
|
||||
console.error(`Weekly report generation failed for report ${id}:`, err)
|
||||
})
|
||||
} else {
|
||||
generateMonthlyReport(Number(id)).catch(err => {
|
||||
console.error(`Report generation failed for report ${id}:`, err)
|
||||
})
|
||||
}
|
||||
|
||||
const updated = db.prepare('SELECT * FROM reports WHERE id = ?').get(id)
|
||||
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
apiKeyId: null,
|
||||
action: 'generate',
|
||||
entityType: 'report',
|
||||
entityId: parseInt(id),
|
||||
details: { generate: { report_type: report.report_type } },
|
||||
ipAddress: getClientIP(_request)
|
||||
})
|
||||
|
||||
return NextResponse.json({ report: updated })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '生成失败'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
import fs from 'fs'
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
if (!hasPermission(user, 'reports:read')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
|
||||
const { id } = await params
|
||||
const db = getDb()
|
||||
const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(id)
|
||||
if (!report) return NextResponse.json({ error: '报告不存在' }, { status: 404 })
|
||||
|
||||
const r = report as any
|
||||
let reportData: any = null
|
||||
|
||||
// 优先解析 metadata JSON
|
||||
if (r.metadata) {
|
||||
try {
|
||||
reportData = JSON.parse(r.metadata)
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
|
||||
// 如果 metadata 为空(旧报告),回退到简单查询
|
||||
if (!reportData && r.period_start && r.period_end) {
|
||||
const tickets = db.prepare(
|
||||
'SELECT * FROM tickets WHERE assign_time >= ? AND assign_time <= ? ORDER BY assign_time'
|
||||
).all(r.period_start, r.period_end + ' 23:59:59')
|
||||
|
||||
const total = tickets.length
|
||||
const resolved = (tickets as any[]).filter(
|
||||
t => t.current_status === 'resolved' || t.current_status === 'closed'
|
||||
).length
|
||||
const avgDur = db.prepare(
|
||||
"SELECT AVG(duration_minutes) as avg FROM tickets WHERE assign_time >= ? AND assign_time <= ? AND duration_minutes IS NOT NULL"
|
||||
).get(r.period_start, r.period_end + ' 23:59:59') as any
|
||||
|
||||
const slaPass = (tickets as any[]).filter(
|
||||
t => t.counted_in_sla === 1 && ['resolved', 'closed'].includes(t.current_status)
|
||||
).length
|
||||
|
||||
const categories = db.prepare(`
|
||||
SELECT fault_category, COUNT(*) as count FROM tickets
|
||||
WHERE assign_time >= ? AND assign_time <= ? AND fault_category IS NOT NULL
|
||||
GROUP BY fault_category ORDER BY count DESC
|
||||
`).all(r.period_start, r.period_end + ' 23:59:59')
|
||||
|
||||
reportData = {
|
||||
summary: {
|
||||
total_tickets: total,
|
||||
resolved_tickets: resolved,
|
||||
avg_duration: Math.round(avgDur?.avg || 0),
|
||||
sla_rate: resolved > 0 ? Math.round((slaPass / resolved) * 100) : 0,
|
||||
},
|
||||
categories,
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ report, reportData })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '查询失败'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
if (!hasPermission(user, 'reports:create')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
|
||||
const { id } = await params
|
||||
const db = getDb()
|
||||
const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(id) as any
|
||||
if (!report) return NextResponse.json({ error: '报告不存在' }, { status: 404 })
|
||||
|
||||
// 删除磁盘文件
|
||||
if (report.file_path && fs.existsSync(report.file_path)) {
|
||||
try { fs.unlinkSync(report.file_path) } catch { /* 文件删除失败不影响数据库操作 */ }
|
||||
}
|
||||
|
||||
// 删除数据库记录
|
||||
db.prepare('DELETE FROM reports WHERE id = ?').run(id)
|
||||
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
apiKeyId: null,
|
||||
action: 'delete',
|
||||
entityType: 'report',
|
||||
entityId: Number(id),
|
||||
details: { deleted: { id: Number(id), title: report.title, type: report.type, period_start: report.period_start, period_end: report.period_end, file_path: report.file_path } },
|
||||
ipAddress: getClientIP(_request),
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '删除失败'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
import JSZip from 'jszip'
|
||||
import fs from 'fs'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
if (!hasPermission(user, 'reports:download')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
|
||||
const { ids } = await request.json()
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return NextResponse.json({ error: '缺少 ids 参数' }, { status: 400 })
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const reports = db.prepare(
|
||||
`SELECT * FROM reports WHERE id IN (${ids.map(() => '?').join(',')})`
|
||||
).all(...ids) as any[]
|
||||
|
||||
if (reports.length === 0) {
|
||||
return NextResponse.json({ error: '未找到选中的报告' }, { status: 404 })
|
||||
}
|
||||
|
||||
const zip = new JSZip()
|
||||
|
||||
for (const r of reports) {
|
||||
if (r.status === 'completed' && r.file_path && fs.existsSync(r.file_path)) {
|
||||
const buffer = fs.readFileSync(r.file_path)
|
||||
const fileName = r.file_name || `report_${r.id}.docx`
|
||||
zip.file(fileName, buffer)
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(zip.files).length === 0) {
|
||||
return NextResponse.json({ error: '没有可下载的报告文件' }, { status: 400 })
|
||||
}
|
||||
|
||||
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' })
|
||||
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
apiKeyId: null,
|
||||
action: 'export',
|
||||
entityType: 'report',
|
||||
entityId: null,
|
||||
details: { exported_count: ids.length, ids },
|
||||
ipAddress: getClientIP(request),
|
||||
})
|
||||
|
||||
const d = new Date()
|
||||
const today = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||
const downloadName = `reports_${today}.zip`
|
||||
const encodedName = encodeURIComponent(downloadName)
|
||||
|
||||
return new NextResponse(new Uint8Array(zipBuffer), {
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename*=UTF-8''${encodedName}`,
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '批量下载失败'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import fs from 'fs'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
if (!hasPermission(user, 'reports:read')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
|
||||
const db = getDb()
|
||||
const reports = db.prepare('SELECT * FROM reports ORDER BY created_at DESC').all()
|
||||
return NextResponse.json({ reports })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '查询失败'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
if (!hasPermission(user, 'reports:create')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
|
||||
const body = await request.json()
|
||||
const { report_type, period_start, period_end } = body
|
||||
|
||||
if (!report_type || !period_start || !period_end) {
|
||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 })
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// 1. 插入报告行,状态为 ready(数据已就绪,文档未生成)
|
||||
const result = db.prepare(`
|
||||
INSERT INTO reports (report_type, period_start, period_end, format, status, created_by)
|
||||
VALUES (?, ?, ?, 'docx', 'ready', ?)
|
||||
`).run(report_type, period_start, period_end, user.id)
|
||||
|
||||
const reportId = result.lastInsertRowid as number
|
||||
|
||||
// 2. 采集数据并写入 metadata(同步,1-2s)
|
||||
if (report_type === 'weekly') {
|
||||
const { collectWeeklyReportData, buildWeeklyMetadata } = await import('@/lib/weekly-report')
|
||||
const data = await collectWeeklyReportData(period_start, period_end)
|
||||
const metadata = buildWeeklyMetadata(data)
|
||||
db.prepare('UPDATE reports SET metadata = ? WHERE id = ?').run(metadata, reportId)
|
||||
} else {
|
||||
const { collectMonthlyReportData, buildMonthlyMetadata } = await import('@/lib/monthly-report')
|
||||
const data = await collectMonthlyReportData(period_start, period_end)
|
||||
const metadata = buildMonthlyMetadata(data)
|
||||
db.prepare('UPDATE reports SET metadata = ? WHERE id = ?').run(metadata, reportId)
|
||||
}
|
||||
|
||||
// 3. 返回报告(状态为 ready),前端自动跳转预览页
|
||||
const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(reportId)
|
||||
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
apiKeyId: null,
|
||||
action: 'create',
|
||||
entityType: 'report',
|
||||
entityId: reportId,
|
||||
details: { created: { report_type, period_start, period_end } },
|
||||
ipAddress: getClientIP(request),
|
||||
})
|
||||
|
||||
return NextResponse.json({ report }, { status: 201 })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '创建失败'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
if (!hasPermission(user, 'reports:create')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
|
||||
const { ids } = await request.json()
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return NextResponse.json({ error: '缺少 ids 参数' }, { status: 400 })
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const reports = db.prepare(
|
||||
`SELECT * FROM reports WHERE id IN (${ids.map(() => '?').join(',')})`
|
||||
).all(...ids) as any[]
|
||||
|
||||
// 删除磁盘文件
|
||||
for (const r of reports) {
|
||||
if (r.file_path && fs.existsSync(r.file_path)) {
|
||||
try { fs.unlinkSync(r.file_path) } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// 删除数据库记录
|
||||
const placeholders = ids.map(() => '?').join(',')
|
||||
db.prepare(`DELETE FROM reports WHERE id IN (${placeholders})`).run(...ids)
|
||||
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
apiKeyId: null,
|
||||
action: 'delete',
|
||||
entityType: 'report',
|
||||
entityId: null,
|
||||
details: { deleted: ids },
|
||||
ipAddress: getClientIP(request),
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, deleted: ids.length })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '批量删除失败'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { getDb } from '@/lib/db'
|
|||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit'
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
|
|
@ -15,9 +16,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||
const body = await request.json()
|
||||
const db = getDb()
|
||||
|
||||
const existing = db.prepare('SELECT * FROM roles WHERE id = ?').get(id)
|
||||
const existing = db.prepare('SELECT * FROM roles WHERE id = ?').get(id) as any
|
||||
if (!existing) return NextResponse.json({ error: '角色不存在' }, { status: 404 })
|
||||
|
||||
// 获取更新前的完整记录用于审计日志
|
||||
const beforeUpdate = { ...existing }
|
||||
|
||||
const fields: string[] = []
|
||||
const values: unknown[] = []
|
||||
|
||||
|
|
@ -30,6 +34,17 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||
}
|
||||
|
||||
const role = db.prepare('SELECT * FROM roles WHERE id = ?').get(id)
|
||||
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
apiKeyId: null,
|
||||
action: 'update',
|
||||
entityType: 'role',
|
||||
entityId: Number(id),
|
||||
details: { changes: diffObjects(beforeUpdate, role as Record<string, unknown>) },
|
||||
ipAddress: getClientIP(request),
|
||||
})
|
||||
|
||||
return NextResponse.json({ role })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '更新失败'
|
||||
|
|
@ -52,7 +67,20 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
|
|||
return NextResponse.json({ error: '系统内置角色不能删除' }, { status: 400 })
|
||||
}
|
||||
|
||||
const snapshot = { id: existing.id, name: existing.name, display_name: existing.display_name }
|
||||
|
||||
db.prepare('DELETE FROM roles WHERE id = ?').run(id)
|
||||
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
apiKeyId: null,
|
||||
action: 'delete',
|
||||
entityType: 'role',
|
||||
entityId: Number(id),
|
||||
details: { deleted: snapshot },
|
||||
ipAddress: getClientIP(_request),
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '删除失败'
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { getDb } from '@/lib/db'
|
|||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
|
|
@ -39,6 +40,17 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
const result = db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)').run(name, display_name, JSON.stringify(permissions || []))
|
||||
const role = db.prepare('SELECT * FROM roles WHERE id = ?').get(result.lastInsertRowid)
|
||||
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
apiKeyId: null,
|
||||
action: 'create',
|
||||
entityType: 'role',
|
||||
entityId: result.lastInsertRowid as number,
|
||||
details: { created: { name, display_name } },
|
||||
ipAddress: getClientIP(request),
|
||||
})
|
||||
|
||||
return NextResponse.json({ role }, { status: 201 })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '创建失败'
|
||||
|
|
|
|||
|
|
@ -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 closed = (db.prepare("SELECT COUNT(*) as c FROM tickets WHERE current_status = 'closed'").get() as any).c
|
||||
|
||||
const thisMonth = new Date().toISOString().slice(0, 7)
|
||||
const d = new Date(); const thisMonth = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`
|
||||
const monthTotal = (db.prepare("SELECT COUNT(*) as c FROM tickets WHERE assign_time LIKE ?").get(`${thisMonth}%`) as any).c
|
||||
|
||||
const avgDuration = db.prepare("SELECT AVG(duration_minutes) as avg FROM tickets WHERE duration_minutes IS NOT NULL AND duration_minutes != ''").get() as any
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import { initDatabase } from '@/lib/db-schema'
|
|||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { getAssetByIp } from '@/lib/assets-client'
|
||||
import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit'
|
||||
import { notifyTicketResolved } from '@/lib/monitor/ticket-notifier'
|
||||
import { applyResolutionSideEffects } from '@/lib/monitor/resolution-side-effects'
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
|
|
@ -41,7 +44,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
if (!hasPermission(user, 'tickets:edit')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
|
|
@ -90,7 +93,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||
if (fields.length > 0) {
|
||||
fields.push('updated_by = ?')
|
||||
values.push(user.id)
|
||||
fields.push("updated_at = datetime('now')")
|
||||
fields.push("updated_at = datetime('now', '+8 hours')")
|
||||
values.push(id)
|
||||
db.prepare(`UPDATE tickets SET ${fields.join(', ')} WHERE id = ?`).run(...values)
|
||||
}
|
||||
|
|
@ -106,6 +109,32 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||
}
|
||||
|
||||
const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(id)
|
||||
|
||||
// 审计日志:记录变更
|
||||
const changes = diffObjects(existing, body)
|
||||
if (Object.keys(changes).length > 0) {
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
apiKeyId: null,
|
||||
action: 'update',
|
||||
entityType: 'ticket',
|
||||
entityId: Number(id),
|
||||
details: { changes },
|
||||
ipAddress: getClientIP(request),
|
||||
})
|
||||
}
|
||||
|
||||
// 结单推送:状态从非终态 → resolved/closed 时触发
|
||||
const wasTerminal = ['resolved', 'closed'].includes(existing.current_status as string)
|
||||
const nowTerminal = body.current_status && ['resolved', 'closed'].includes(body.current_status)
|
||||
if (!wasTerminal && nowTerminal) {
|
||||
const ticketId = Number(id)
|
||||
applyResolutionSideEffects(ticketId) // 同步:回写 fault_records + 清理 reminder
|
||||
void notifyTicketResolved(ticketId).catch(e =>
|
||||
console.error(`[API] 结单推送失败 (ticket ${ticketId}):`, e)
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ ticket })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '更新失败'
|
||||
|
|
@ -118,7 +147,7 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
|
|||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
if (!hasPermission(user, 'tickets:delete')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
|
||||
const { id } = await params
|
||||
const db = getDb()
|
||||
|
|
@ -126,6 +155,9 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
|
|||
const existing = db.prepare('SELECT current_status, created_by FROM tickets WHERE id = ?').get(id) as { current_status: string; created_by: number | null } | undefined
|
||||
if (!existing) return NextResponse.json({ error: '工单不存在' }, { status: 404 })
|
||||
|
||||
// 获取工单快照用于审计日志
|
||||
const snapshot = db.prepare('SELECT id, device_ip, current_status FROM tickets WHERE id = ?').get(id) as Record<string, unknown> | undefined
|
||||
|
||||
// 非管理员不可删除已办工单,非创建人不可删除待办工单
|
||||
const completedStatuses = ['resolved', 'closed']
|
||||
if (user.role !== 'admin') {
|
||||
|
|
@ -138,6 +170,17 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
|
|||
}
|
||||
|
||||
db.prepare('DELETE FROM tickets WHERE id = ?').run(id)
|
||||
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
apiKeyId: null,
|
||||
action: 'delete',
|
||||
entityType: 'ticket',
|
||||
entityId: Number(id),
|
||||
details: { deleted: snapshot },
|
||||
ipAddress: getClientIP(_request),
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '删除失败'
|
||||
|
|
|
|||
|
|
@ -3,13 +3,16 @@ import { getDb } from '@/lib/db'
|
|||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { notifyTicketResolved } from '@/lib/monitor/ticket-notifier'
|
||||
import { applyResolutionSideEffects } from '@/lib/monitor/resolution-side-effects'
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
if (!hasPermission(user, 'tickets:edit')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
|
||||
const body = await request.json()
|
||||
const updates: Array<{ id: number; fault_category?: string; current_status?: string; counted_in_sla?: number }> = body.updates || []
|
||||
|
|
@ -24,6 +27,11 @@ export async function PUT(request: NextRequest) {
|
|||
|
||||
for (const item of updates) {
|
||||
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 values: unknown[] = []
|
||||
|
||||
|
|
@ -35,15 +43,38 @@ export async function PUT(request: NextRequest) {
|
|||
}
|
||||
|
||||
if (fields.length === 0) continue
|
||||
fields.push("updated_at = datetime('now')")
|
||||
fields.push("updated_at = datetime('now', '+8 hours')")
|
||||
fields.push('updated_by = ?')
|
||||
values.push(user.id)
|
||||
values.push(item.id)
|
||||
|
||||
const result = db.prepare(`UPDATE tickets SET ${fields.join(', ')} WHERE id = ?`).run(...values)
|
||||
|
||||
// 结单推送:状态从非终态 → 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
|
||||
}
|
||||
|
||||
if (updated > 0) {
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
apiKeyId: null,
|
||||
action: 'batch_update',
|
||||
entityType: 'ticket',
|
||||
entityId: null,
|
||||
details: { updated, total: updates.length },
|
||||
ipAddress: getClientIP(request),
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ updated, total: updates.length })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '批量更新失败'
|
||||
|
|
|
|||
|
|
@ -2,13 +2,16 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||
import { getDb } from '@/lib/db'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { exportTicketsToExcel } from '@/lib/excel'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
if (!hasPermission(user, 'tickets:export')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
|
||||
const { searchParams } = request.nextUrl
|
||||
const idsParam = searchParams.get('ids')
|
||||
|
|
@ -45,10 +48,22 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
const buffer = exportTicketsToExcel(tickets)
|
||||
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
apiKeyId: null,
|
||||
action: 'export',
|
||||
entityType: 'ticket',
|
||||
entityId: null,
|
||||
details: { count: tickets.length },
|
||||
ipAddress: getClientIP(request),
|
||||
})
|
||||
|
||||
const d = new Date()
|
||||
const exportDate = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||
return new NextResponse(new Uint8Array(buffer), {
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': `attachment; filename="tickets_${new Date().toISOString().slice(0, 10)}.xlsx"`,
|
||||
'Content-Disposition': `attachment; filename="tickets_${exportDate}.xlsx"`,
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||
import { getDb } from '@/lib/db'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { verifyApiKey } from '@/lib/auth'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { notifyTicketCreated } from '@/lib/monitor/ticket-notifier'
|
||||
|
||||
function verifyEnvApiKey(key: string): boolean {
|
||||
const allowed = process.env.ALLOWED_API_KEYS || ''
|
||||
|
|
@ -27,6 +29,7 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
const authHeader = request.headers.get('authorization')
|
||||
let authenticated = false
|
||||
let apiKeyInfo: { id: number; name: string; permissions: string[] } | null = null
|
||||
|
||||
if (authHeader?.startsWith('Bearer ak_')) {
|
||||
const key = authHeader.slice(7)
|
||||
|
|
@ -34,7 +37,10 @@ export async function POST(request: NextRequest) {
|
|||
authenticated = true
|
||||
} else {
|
||||
const keyInfo = verifyApiKey(key)
|
||||
if (keyInfo) authenticated = true
|
||||
if (keyInfo) {
|
||||
authenticated = true
|
||||
apiKeyInfo = keyInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -81,6 +87,27 @@ export async function POST(request: NextRequest) {
|
|||
)
|
||||
|
||||
const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(ticketId)
|
||||
|
||||
writeAuditLog({
|
||||
userId: null,
|
||||
apiKeyId: apiKeyInfo?.id ?? null,
|
||||
action: 'create',
|
||||
entityType: 'ticket',
|
||||
entityId: ticketId,
|
||||
details: { created: { ticket_no: ticketNo, device_ip: body.device_ip || null } },
|
||||
ipAddress: getClientIP(request)
|
||||
})
|
||||
|
||||
void notifyTicketCreated({
|
||||
ticket_no: ticketNo,
|
||||
device_ip: body.device_ip || null,
|
||||
device_sn: body.device_sn || null,
|
||||
device_name: body.device_name || null,
|
||||
ticket_type: body.ticket_type || null,
|
||||
content: body.content || null,
|
||||
assign_time: body.assign_time || null,
|
||||
}).catch(e => console.error('[external] notify 失败:', e))
|
||||
|
||||
return NextResponse.json({ ticket, created: true }, { status: 201 })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '创建失败'
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export async function GET(request: NextRequest) {
|
|||
const result: Record<string, string[]> = {}
|
||||
|
||||
for (const field of fields) {
|
||||
const allowed = ['device_ip', 'device_name', 'fault_category', 'current_status', 'ticket_no', 'fault_subcategory']
|
||||
const allowed = ['device_ip', 'device_name', 'fault_category', 'current_status', 'id', 'fault_subcategory']
|
||||
if (!allowed.includes(field)) continue
|
||||
const rows = db.prepare(`SELECT DISTINCT ${field} FROM tickets WHERE ${field} IS NOT NULL AND ${field} != '' ORDER BY ${field}`).all() as Record<string, string>[]
|
||||
result[field] = rows.map(r => r[field])
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { initDatabase } from '@/lib/db-schema'
|
|||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { parseExcelTickets } from '@/lib/excel'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { notifyBatchSummary } from '@/lib/monitor/ticket-notifier'
|
||||
|
||||
function validateTicketNo(ticketNo: string): string | null {
|
||||
if (!/^\d{14}$/.test(ticketNo)) {
|
||||
|
|
@ -24,7 +26,7 @@ export async function POST(request: NextRequest) {
|
|||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
if (!hasPermission(user, 'tickets:import')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
|
|
@ -120,6 +122,25 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
transaction()
|
||||
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
apiKeyId: null,
|
||||
action: 'import',
|
||||
entityType: 'ticket',
|
||||
entityId: null,
|
||||
details: {
|
||||
created: imported.length,
|
||||
overwritten: conflicts.length,
|
||||
errors: errors.length,
|
||||
},
|
||||
ipAddress: getClientIP(request),
|
||||
})
|
||||
|
||||
// 事务提交后汇总推送(后台,不阻塞响应)
|
||||
if (imported.length > 0) {
|
||||
void notifyBatchSummary(imported).catch(e => console.error('[import] notify 失败:', e))
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
imported: imported.length,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { getDb } from '@/lib/db'
|
|||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { notifyTicketCreated } from '@/lib/monitor/ticket-notifier'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
|
@ -101,7 +103,7 @@ export async function POST(request: NextRequest) {
|
|||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
if (!hasPermission(user, 'tickets:create')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
|
||||
const body = await request.json()
|
||||
const db = getDb()
|
||||
|
|
@ -155,7 +157,29 @@ export async function POST(request: NextRequest) {
|
|||
}
|
||||
}
|
||||
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
apiKeyId: null,
|
||||
action: 'create',
|
||||
entityType: 'ticket',
|
||||
entityId: ticketId,
|
||||
details: { created: { ticket_no: ticketNo, device_ip: body.device_ip, device_name: body.device_name } },
|
||||
ipAddress: getClientIP(request)
|
||||
})
|
||||
|
||||
const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(result.lastInsertRowid)
|
||||
|
||||
// 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 })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '创建失败'
|
||||
|
|
|
|||
|
|
@ -3,6 +3,24 @@ import { getDb } from '@/lib/db'
|
|||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getCurrentUser, hashPassword } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit'
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
const db = getDb()
|
||||
const row = db.prepare(`
|
||||
SELECT id, username, display_name, email, role, is_active, created_at, updated_at,
|
||||
last_login_at,
|
||||
CASE WHEN last_active_at IS NOT NULL AND datetime(last_active_at, '+5 minutes') > datetime('now', '+8 hours') THEN 1 ELSE 0 END AS is_online
|
||||
FROM users WHERE id = ?
|
||||
`).get(id) as Record<string, unknown> | undefined
|
||||
if (!row) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
|
||||
return NextResponse.json({ user: row })
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
|
|
@ -15,9 +33,17 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||
const body = await request.json()
|
||||
const db = getDb()
|
||||
|
||||
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id)
|
||||
const existing = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as { id: number; username: string } | undefined
|
||||
if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
|
||||
|
||||
// 获取更新前的完整记录用于审计日志
|
||||
const beforeUpdate = db.prepare('SELECT id, username, display_name, email, role, is_active FROM users WHERE id = ?').get(id) as Record<string, unknown>
|
||||
|
||||
// 禁止修改系统保留用户的角色
|
||||
if (body.role && (existing.username === 'admin' || existing.username === 'localadmin')) {
|
||||
return NextResponse.json({ error: '不能修改系统保留用户的角色' }, { status: 400 })
|
||||
}
|
||||
|
||||
const fields: string[] = []
|
||||
const values: unknown[] = []
|
||||
|
||||
|
|
@ -28,12 +54,26 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||
if (body.password) { fields.push('password_hash = ?'); values.push(hashPassword(body.password)) }
|
||||
|
||||
if (fields.length > 0) {
|
||||
fields.push("updated_at = datetime('now')")
|
||||
fields.push("updated_at = datetime('now', '+8 hours')")
|
||||
values.push(id)
|
||||
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values)
|
||||
}
|
||||
|
||||
const updated = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at, updated_at FROM users WHERE id = ?').get(id)
|
||||
const updated = db.prepare(`SELECT id, username, display_name, email, role, is_active, created_at, updated_at,
|
||||
last_login_at,
|
||||
CASE WHEN last_active_at IS NOT NULL AND datetime(last_active_at, '+5 minutes') > datetime('now', '+8 hours') THEN 1 ELSE 0 END AS is_online
|
||||
FROM users WHERE id = ?`).get(id)
|
||||
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
apiKeyId: null,
|
||||
action: 'update',
|
||||
entityType: 'user',
|
||||
entityId: Number(id),
|
||||
details: { changes: diffObjects(beforeUpdate, updated as Record<string, unknown>) },
|
||||
ipAddress: getClientIP(request),
|
||||
})
|
||||
|
||||
return NextResponse.json({ user: updated })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '更新失败'
|
||||
|
|
@ -52,10 +92,26 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
|
|||
if (String(id) === String(user.id)) return NextResponse.json({ error: '不能删除自己' }, { status: 400 })
|
||||
|
||||
const db = getDb()
|
||||
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id)
|
||||
const existing = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as { id: number; username: string } | undefined
|
||||
if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
|
||||
if (existing.username === 'admin' || existing.username === 'localadmin') {
|
||||
return NextResponse.json({ error: '不能删除系统保留用户' }, { status: 400 })
|
||||
}
|
||||
|
||||
const snapshot = db.prepare('SELECT id, username, display_name, role FROM users WHERE id = ?').get(id) as Record<string, unknown>
|
||||
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(id)
|
||||
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
apiKeyId: null,
|
||||
action: 'delete',
|
||||
entityType: 'user',
|
||||
entityId: Number(id),
|
||||
details: { deleted: snapshot },
|
||||
ipAddress: getClientIP(_request),
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '删除失败'
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { initDatabase } from '@/lib/db-schema'
|
|||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { hashPassword } from '@/lib/auth'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
|
|
@ -13,7 +14,10 @@ export async function GET() {
|
|||
if (!hasPermission(user, 'users:read')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
|
||||
const db = getDb()
|
||||
const users = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at, updated_at FROM users ORDER BY id').all()
|
||||
const users = db.prepare(`SELECT id, username, display_name, email, role, is_active, created_at, updated_at,
|
||||
last_login_at,
|
||||
CASE WHEN last_active_at IS NOT NULL AND datetime(last_active_at, '+5 minutes') > datetime('now', '+8 hours') THEN 1 ELSE 0 END AS is_online
|
||||
FROM users ORDER BY id`).all()
|
||||
return NextResponse.json({ users })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '查询失败'
|
||||
|
|
@ -41,10 +45,21 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
const hash = hashPassword(password)
|
||||
const result = db.prepare(
|
||||
'INSERT INTO users (username, password_hash, display_name, email, role) VALUES (?, ?, ?, ?, ?)'
|
||||
"INSERT INTO users (username, password_hash, display_name, email, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
|
||||
).run(username, hash, display_name, email || null, role || 'viewer')
|
||||
|
||||
const newUser = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at FROM users WHERE id = ?').get(result.lastInsertRowid)
|
||||
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
apiKeyId: null,
|
||||
action: 'create',
|
||||
entityType: 'user',
|
||||
entityId: result.lastInsertRowid as number,
|
||||
details: { created: { username, display_name, role: role || 'viewer' } },
|
||||
ipAddress: getClientIP(request),
|
||||
})
|
||||
|
||||
return NextResponse.json({ user: newUser }, { status: 201 })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '创建失败'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { ldapGetUserInfo } from '@/lib/ldap'
|
||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
export async function POST(_request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
if (!hasPermission(user, 'users:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
|
||||
const db = getDb()
|
||||
const users = db.prepare(
|
||||
'SELECT id, username FROM users WHERE email IS NULL OR email = \'\''
|
||||
).all() as { id: number; username: string }[]
|
||||
|
||||
let synced = 0
|
||||
let failed = 0
|
||||
|
||||
for (const u of users) {
|
||||
const info = await ldapGetUserInfo(u.username)
|
||||
if (info?.email) {
|
||||
db.prepare('UPDATE users SET email = ? WHERE id = ?').run(info.email, u.id)
|
||||
synced++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
apiKeyId: null,
|
||||
action: 'sync_emails',
|
||||
entityType: 'user',
|
||||
details: { sync_emails: { synced, failed } },
|
||||
ipAddress: getClientIP(_request)
|
||||
})
|
||||
|
||||
return NextResponse.json({ synced, failed, total: users.length })
|
||||
}
|
||||
|
|
@ -1,14 +1,15 @@
|
|||
@import "tailwindcss";
|
||||
@config "../../tailwind.config.js";
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
@source "../../shared";
|
||||
@source "../../src";
|
||||
|
||||
@import './tlyq-design-system.css' layer(base);
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
background-color: #f8fafc;
|
||||
color: #0f172a;
|
||||
}
|
||||
html.dark body {
|
||||
background-color: #020617;
|
||||
color: #ffffff;
|
||||
font-family: var(--font-body);
|
||||
color: var(--fg);
|
||||
background-color: var(--bg);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,5 @@
|
|||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
|
||||
export default async function Home() {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (user) redirect('/dashboard')
|
||||
else redirect('/login')
|
||||
export default function Home() {
|
||||
redirect('/dashboard')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,362 @@
|
|||
/*
|
||||
* TLYQ 统一设计系统 — OKLCh 色彩空间(2026-07-15 升级)
|
||||
* 从 Tailwind Slate HEX 色板迁移至 OKLCh 色彩空间
|
||||
* 感知均匀、色域更广,保持无障碍对比度(WCAG AA ≥ 4.5:1)
|
||||
*
|
||||
* 使用方式:
|
||||
* - CSS 变量:@import './tlyq-design-system.css'
|
||||
* - Tailwind:颜色类见下方映射说明
|
||||
*/
|
||||
|
||||
/* ==================== 设计令牌(CSS 变量) ==================== */
|
||||
:root {
|
||||
/* 背景色 */
|
||||
--bg: oklch(0.984 0.003 247.858); /* ≈ #f8fafc (slate-50) */
|
||||
--bg-subtle: oklch(0.968 0.007 247.896); /* ≈ #f1f5f9 (slate-100) */
|
||||
--surface: oklch(1 0 0); /* #ffffff (white) */
|
||||
--surface-raised: oklch(1 0 0); /* #ffffff (white) */
|
||||
--surface-overlay: rgba(255, 255, 255, 0.95);
|
||||
|
||||
/* 文本色 */
|
||||
--fg: oklch(0.129 0.042 264.695); /* ≈ #0f172a (slate-900) */
|
||||
--fg-subtle: oklch(0.372 0.044 257.287); /* ≈ #334155 (slate-700) */
|
||||
--muted: oklch(0.446 0.043 257.281); /* ≈ #475569 (slate-600) */
|
||||
--muted-subtle: oklch(0.662 0.051 257.281); /* ≈ #94a3b8 (slate-400) */
|
||||
|
||||
/* 边框 */
|
||||
--border: oklch(0.928 0.006 264.531); /* ≈ #e2e8f0 (slate-200) */
|
||||
--border-subtle: oklch(0.968 0.007 247.896);/* ≈ #f1f5f9 (slate-100) */
|
||||
|
||||
/* 主色调 — 蓝色系 */
|
||||
--accent: oklch(0.546 0.245 262.881); /* ≈ #2563eb (blue-600) */
|
||||
--accent-hover: oklch(0.479 0.247 262.881); /* ≈ #1d4ed8 (blue-700) */
|
||||
--accent-active: oklch(0.426 0.228 262.881);/* ≈ #1e40af (blue-800) */
|
||||
--accent-subtle: oklch(0.967 0.03 260.592); /* ≈ #eff6ff (blue-50) */
|
||||
--accent-text: oklch(1 0 0); /* #ffffff */
|
||||
|
||||
/* 状态色 */
|
||||
--success: oklch(0.627 0.194 149.214); /* ≈ #059669 (emerald-600) */
|
||||
--success-subtle: oklch(0.982 0.041 152.117);/* ≈ #ecfdf5 (emerald-50) */
|
||||
--warning: oklch(0.681 0.162 75.834); /* ≈ #d97706 (amber-600) */
|
||||
--warning-subtle: oklch(0.986 0.03 92.708); /* ≈ #fffbeb (amber-50) */
|
||||
--danger: oklch(0.577 0.245 27.325); /* ≈ #dc2626 (red-600) */
|
||||
--danger-subtle: oklch(0.96 0.037 26.197); /* ≈ #fef2f2 (red-50) */
|
||||
--info: oklch(0.546 0.245 262.881); /* ≈ #2563eb (blue-600) */
|
||||
--info-subtle: oklch(0.967 0.03 260.592); /* ≈ #eff6ff (blue-50) */
|
||||
|
||||
/* 字体 */
|
||||
--font-display: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, sans-serif;
|
||||
--font-body: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, sans-serif;
|
||||
--font-sans: var(--font-body);
|
||||
--font-mono: ui-monospace, "JetBrains Mono", "IBM Plex Mono", SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
|
||||
/* 字体大小 */
|
||||
--text-xs: 0.75rem; /* 12px */
|
||||
--text-sm: 0.875rem; /* 14px */
|
||||
--text-base: 1rem; /* 16px */
|
||||
--text-lg: 1.125rem; /* 18px */
|
||||
--text-xl: 1.25rem; /* 20px */
|
||||
--text-2xl: 1.5rem; /* 24px */
|
||||
--text-3xl: 1.875rem; /* 30px */
|
||||
|
||||
/* 行高 */
|
||||
--leading-tight: 1.25;
|
||||
--leading-snug: 1.375;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.625;
|
||||
|
||||
/* 字间距 */
|
||||
--tracking-tighter: -0.05em;
|
||||
--tracking-tight: -0.02em;
|
||||
--tracking-normal: 0;
|
||||
--tracking-wide: 0.02em;
|
||||
--tracking-wider: 0.05em;
|
||||
--tracking-widest: 0.1em;
|
||||
|
||||
/* 间距 */
|
||||
--space-1: 0.25rem; /* 4px */
|
||||
--space-2: 0.5rem; /* 8px */
|
||||
--space-3: 0.75rem; /* 12px */
|
||||
--space-4: 1rem; /* 16px */
|
||||
--space-5: 1.25rem; /* 20px */
|
||||
--space-6: 1.5rem; /* 24px */
|
||||
--space-8: 2rem; /* 32px */
|
||||
--space-10: 2.5rem; /* 40px */
|
||||
--space-12: 3rem; /* 48px */
|
||||
--space-16: 4rem; /* 64px */
|
||||
|
||||
/* 圆角 */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-2xl: 24px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* 阴影 */
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.04);
|
||||
|
||||
/* 过渡 */
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-spring: 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
/* 层级系统 */
|
||||
--z-base: 0;
|
||||
--z-raised: 10;
|
||||
--z-dropdown: 100;
|
||||
--z-sticky: 200;
|
||||
--z-overlay: 300;
|
||||
--z-modal: 400;
|
||||
--z-toast: 700;
|
||||
|
||||
/* 布局 */
|
||||
--sidebar-width: 240px;
|
||||
--sidebar-width-collapsed: 64px;
|
||||
--header-height: 56px;
|
||||
--content-max-width: 1440px;
|
||||
--content-padding: var(--space-6);
|
||||
}
|
||||
|
||||
/* ==================== 深色模式 ==================== */
|
||||
:root.dark {
|
||||
/* 背景色 */
|
||||
--bg: oklch(0.129 0.042 264.695); /* ≈ #020617 (slate-950) */
|
||||
--bg-subtle: oklch(0.208 0.042 264.695); /* ≈ #0f172a (slate-900) */
|
||||
--surface: oklch(0.208 0.042 264.695); /* ≈ #0f172a (slate-900) */
|
||||
--surface-raised: oklch(0.279 0.041 260.031);/* ≈ #1e293b (slate-800) */
|
||||
--surface-overlay: rgba(15, 23, 42, 0.95);
|
||||
|
||||
/* 文本色 */
|
||||
--fg: oklch(0.984 0.003 247.858); /* ≈ #f8fafc (slate-50) */
|
||||
--fg-subtle: oklch(0.837 0.014 253.365); /* ≈ #cbd5e1 (slate-300) */
|
||||
--muted: oklch(0.662 0.051 257.281); /* ≈ #94a3b8 (slate-400) */
|
||||
--muted-subtle: oklch(0.527 0.045 257.281); /* ≈ #64748b (slate-500) */
|
||||
|
||||
/* 边框 */
|
||||
--border: oklch(0.279 0.041 260.031); /* ≈ #1e293b (slate-800) */
|
||||
--border-subtle: oklch(0.208 0.042 264.695);/* ≈ #0f172a (slate-900) */
|
||||
|
||||
/* 主色调 */
|
||||
--accent: oklch(0.623 0.214 259.815); /* ≈ #3b82f6 (blue-500) */
|
||||
--accent-hover: oklch(0.723 0.157 260.543); /* ≈ #60a5fa (blue-400) */
|
||||
--accent-active: oklch(0.829 0.096 260.543);/* ≈ #93c5fd (blue-300) */
|
||||
--accent-subtle: rgba(59, 130, 246, 0.1);
|
||||
--accent-text: oklch(0.208 0.042 264.695); /* ≈ #0f172a (slate-900) */
|
||||
|
||||
/* 状态色 */
|
||||
--success: oklch(0.696 0.17 162.48); /* ≈ #34d399 (emerald-400) */
|
||||
--success-subtle: rgba(52, 211, 153, 0.1);
|
||||
--warning: oklch(0.828 0.12 76.523); /* ≈ #fbbf24 (amber-400) */
|
||||
--warning-subtle: rgba(251, 191, 36, 0.1);
|
||||
--danger: oklch(0.715 0.143 25.059); /* ≈ #f87171 (red-400) */
|
||||
--danger-subtle: rgba(248, 113, 113, 0.1);
|
||||
--info: oklch(0.723 0.157 260.543); /* ≈ #60a5fa (blue-400) */
|
||||
--info-subtle: rgba(96, 165, 250, 0.1);
|
||||
|
||||
/* 阴影(深色下更突出) */
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.2);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.2);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* ==================== Tailwind 暗色模式映射 ==================== */
|
||||
/*
|
||||
* 以下 Tailwind 类名可直接使用:
|
||||
* 页面背景: bg-slate-50 dark:bg-slate-950
|
||||
* 卡片背景: bg-white dark:bg-slate-900
|
||||
* 边框: border-slate-200 dark:border-slate-800
|
||||
* 正文: text-slate-900 dark:text-slate-50
|
||||
* 次要文本: text-slate-700 dark:text-slate-300
|
||||
* 弱文本: text-slate-500 dark:text-slate-400
|
||||
* 主按钮: bg-blue-600 hover:bg-blue-700 text-white
|
||||
* 成功: text-emerald-600 dark:text-emerald-400
|
||||
* 警告: text-amber-600 dark:text-amber-400
|
||||
* 危险: text-red-600 dark:text-red-400
|
||||
*/
|
||||
|
||||
/* ==================== 全局重置 ==================== */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
color: var(--fg);
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
/* ==================== 组件规范 ==================== */
|
||||
|
||||
/* 按钮规范
|
||||
* 主按钮: bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-sm
|
||||
* 次按钮: bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 rounded-lg
|
||||
* 幽灵按钮: hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg
|
||||
* 尺寸: sm(px-3 py-1.5 text-xs) / md(px-4 py-2 text-sm) / lg(px-6 py-2.5 text-base)
|
||||
*/
|
||||
|
||||
/* 输入框规范
|
||||
* 边框: border border-slate-300 dark:border-slate-600
|
||||
* 聚焦: focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
* 圆角: rounded-lg
|
||||
* 尺寸: h-10(px-3 text-sm)
|
||||
*/
|
||||
|
||||
/* 卡片规范
|
||||
* 基础: bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6 shadow-sm
|
||||
* 悬停: hover:border-blue-200 dark:hover:border-blue-800
|
||||
*/
|
||||
|
||||
/* 表格规范
|
||||
* 头部: bg-slate-50 dark:bg-slate-800 text-slate-500 text-xs font-medium uppercase
|
||||
* 行: border-b border-slate-200 dark:border-slate-700
|
||||
* 悬停: hover:bg-slate-50 dark:hover:bg-slate-800/50
|
||||
*/
|
||||
|
||||
/* 徽标规范
|
||||
* 默认: bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300
|
||||
* 成功: bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400
|
||||
* 警告: bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400
|
||||
* 危险: bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400
|
||||
* 信息: bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400
|
||||
* 圆角: rounded-full
|
||||
* 尺寸: px-2.5 py-0.5 text-xs font-medium
|
||||
*/
|
||||
|
||||
/* 弹窗规范
|
||||
* 背景: bg-white dark:bg-slate-900
|
||||
* 边框: border border-slate-200 dark:border-slate-800
|
||||
* 圆角: rounded-xl
|
||||
* 阴影: shadow-xl
|
||||
* 标题: text-lg font-semibold
|
||||
*/
|
||||
|
||||
/* 侧边栏规范
|
||||
* 宽度: w-60 (240px)
|
||||
* 定位: fixed left-0 top-0 bottom-0
|
||||
* 背景: bg-white dark:bg-slate-900
|
||||
* 边框: border-r border-slate-200 dark:border-slate-800
|
||||
* 品牌区: h-14 flex items-center
|
||||
* 导航项: px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
* 激活态: bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400
|
||||
*/
|
||||
|
||||
/* 顶栏规范
|
||||
* 高度: h-14 (56px)
|
||||
* 定位: fixed top-0
|
||||
* 背景: bg-white dark:bg-slate-900
|
||||
* 边框: border-b border-slate-200 dark:border-slate-800
|
||||
* z-index: z-30
|
||||
*/
|
||||
|
||||
/* 主内容区规范
|
||||
* 边距: ml-60 pt-14 p-6 (侧边栏+顶栏)
|
||||
* 门户布局: max-w-5xl mx-auto px-6 py-8
|
||||
*/
|
||||
|
||||
/* ==================== 统计卡片 ==================== */
|
||||
/*
|
||||
* 用于仪表盘/概览页的统计数据展示
|
||||
* 容器: bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6
|
||||
* 悬停: hover:border-blue-200 dark:hover:border-blue-800 hover:shadow-md transition-all
|
||||
* 图标区: w-10 h-10 rounded-lg bg-blue-50 dark:bg-blue-500/10 flex items-center justify-center
|
||||
* 数值: text-2xl font-semibold text-slate-900 dark:text-white font-display
|
||||
* 标签: text-sm text-slate-500 dark:text-slate-400
|
||||
* 变化趋势: text-xs font-medium (positive=text-emerald-600, negative=text-red-600)
|
||||
*/
|
||||
|
||||
/* ==================== 骨架屏加载 ==================== */
|
||||
/*
|
||||
* 用于数据加载时的占位效果
|
||||
* <div className="animate-pulse bg-slate-200 dark:bg-slate-700 rounded-lg [height]" />
|
||||
* 文本行: h-4 w-full / w-3/4
|
||||
* 标题: h-6 w-1/3
|
||||
* 卡片: h-32 w-full rounded-xl
|
||||
*/
|
||||
|
||||
/* 无缝统计条 ==================== */
|
||||
/*
|
||||
* 仪表盘顶部关键指标展示,分段之间仅 1px 间隙
|
||||
* 容器: flex gap-px bg-slate-200 dark:bg-slate-800 rounded-xl overflow-hidden
|
||||
* 段: flex-1 bg-white dark:bg-slate-900 px-6 py-4 flex flex-col gap-1
|
||||
* 标签: text-xs font-medium text-slate-500 uppercase tracking-widest
|
||||
* 数值: text-2xl font-semibold font-[family-name:var(--font-display)]
|
||||
*/
|
||||
|
||||
/* 服务状态卡片 ==================== */
|
||||
/*
|
||||
* 仪表盘中展示被监控服务实时状态
|
||||
* 正常: bg-white dark:bg-slate-900 border rounded-xl p-5
|
||||
* 悬停: hover:shadow-sm hover:-translate-y-px transition-all cursor-pointer
|
||||
* 异常: bg-red-50 dark:bg-red-500/10 border-red-600 dark:border-red-400
|
||||
* 红底红框,突出显示
|
||||
* 状态圆点: w-2.5 h-2.5 rounded-full
|
||||
* ok: bg-emerald-600 dark:bg-emerald-400 shadow-[0_0_8px_var(--success)]
|
||||
* err: bg-red-600 dark:bg-red-400 shadow-[0_0_12px_var(--danger)] animate-pulse
|
||||
* 持续时长: text-lg font-semibold font-mono text-red-600 dark:text-red-400
|
||||
* 展开区: border-t mt-3 pt-3
|
||||
*/
|
||||
|
||||
/* ==================== 图表容器 ==================== */
|
||||
/*
|
||||
* 用于包裹图表组件
|
||||
* <div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6">
|
||||
* <div className="flex items-center justify-between mb-6">
|
||||
* <h3 className="text-lg font-semibold text-slate-900 dark:text-white">标题</h3>
|
||||
* <div className="flex items-center gap-4 text-sm text-slate-500">图例</div>
|
||||
* </div>
|
||||
* [图表内容]
|
||||
* </div>
|
||||
*/
|
||||
|
||||
/* ==================== 下拉菜单 ==================== */
|
||||
/*
|
||||
* 用于用户菜单、操作菜单
|
||||
* 容器: absolute right-0 top-full mt-1
|
||||
* bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700
|
||||
* rounded-lg shadow-lg py-1 min-w-[200px] z-[--z-dropdown]
|
||||
* 菜单项: px-4 py-2.5 text-sm flex items-center gap-3
|
||||
* hover:bg-slate-50 dark:hover:bg-slate-700
|
||||
* text-slate-700 dark:text-slate-300
|
||||
* 危险项: text-red-600 dark:text-red-400
|
||||
* 分隔线: border-t border-slate-200 dark:border-slate-700 my-1
|
||||
*/
|
||||
|
||||
/* ==================== 站点标识色 ==================== */
|
||||
/*
|
||||
* OA: blue-600
|
||||
* assets: blue-600
|
||||
* issue: blue-600
|
||||
* monitor: indigo-600
|
||||
*
|
||||
* 站点卡片标识色(门户首页):
|
||||
* 资产管理: blue-600 tag: CMDB
|
||||
* 工单跟踪: violet-600 tag: ITS
|
||||
* 监控中心: indigo-600 tag: MONITOR
|
||||
* 官网: emerald-600 tag: WWW
|
||||
* 云平台: amber-600 tag: CLOUD
|
||||
* Token: rose-600 tag: TOKEN
|
||||
* 代码仓库: pink-600 tag: GIT
|
||||
*
|
||||
* 2026-07-15:从 HEX 迁移至 OKLCh 色彩空间
|
||||
* 主色 blue-600 OKLCh: oklch(0.546 0.245 262.881)
|
||||
* 主色 indigo-600 OKLCh: oklch(0.511 0.262 276.966)
|
||||
* 对比度验证:白底蓝字 (oklch(0.546 0.245 262.881) on oklch(1 0 0)) → 约 6.5:1(≥ 4.5:1 通过)
|
||||
*/
|
||||
|
|
@ -12,7 +12,7 @@ export default function AppShell({ children }: AppShellProps) {
|
|||
}, [])
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950">
|
||||
<Sidebar />
|
||||
<Sidebar role={user?.role} />
|
||||
<TopBar user={user} />
|
||||
<main className="ml-60 pt-14 min-h-screen"><div className="p-6">{children}</div></main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,63 +1,81 @@
|
|||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
// src/components/layout/Sidebar.tsx — 权限驱动侧边栏(统一标准布局)
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { LayoutDashboard, FileText, Settings, Users, Shield, Key, Clock, CheckCircle, PlusSquare, Upload, List } from 'lucide-react'
|
||||
import { LayoutDashboard, FileText, Settings, Users, Shield, Key, Clock, CheckCircle, PlusSquare, Upload, List, Mail, Bell } from 'lucide-react'
|
||||
|
||||
const navItems = [
|
||||
{ href: '/dashboard', label: '仪表盘', icon: LayoutDashboard },
|
||||
{ href: '/tickets/pending', label: '待办工单', icon: Clock },
|
||||
{ href: '/tickets/completed', label: '已办工单', icon: CheckCircle },
|
||||
{ href: '/tickets/create', label: '手动建单', icon: PlusSquare },
|
||||
{ href: '/tickets/import', label: '导入工单', icon: Upload },
|
||||
{ href: '/reports', label: '报告管理', icon: FileText },
|
||||
]
|
||||
interface NavItem { label: string; href: string; icon: React.ComponentType<{ size?: number }>; perm: string | null }
|
||||
interface NavSection { title?: string; items: NavItem[] }
|
||||
|
||||
const settingsItems = [
|
||||
{ href: '/settings/users', label: '用户管理', icon: Users },
|
||||
{ href: '/settings/roles', label: '角色权限', icon: Shield },
|
||||
{ href: '/settings/api-keys', label: 'API Key', icon: Key },
|
||||
]
|
||||
|
||||
export default function Sidebar() {
|
||||
export default function Sidebar({ role }: { role?: string }) {
|
||||
const pathname = usePathname()
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const isActive = (href: string) => pathname === href || (href !== '/' && pathname.startsWith(href))
|
||||
const isAdmin = role === 'admin' || role === 'localadmin'
|
||||
|
||||
const sections: NavSection[] = [
|
||||
{ items: [{ label: '仪表盘', href: '/dashboard', icon: LayoutDashboard, perm: null }] },
|
||||
{
|
||||
title: '工单管理',
|
||||
items: [
|
||||
{ label: '待办工单', href: '/tickets/pending', icon: Clock, perm: null },
|
||||
{ label: '已办工单', href: '/tickets/completed', icon: CheckCircle, perm: null },
|
||||
{ label: '手动建单', href: '/tickets/create', icon: PlusSquare, perm: null },
|
||||
{ label: '导入工单', href: '/tickets/import', icon: Upload, perm: null },
|
||||
{ label: '全部工单', href: '/tickets/all', icon: List, perm: isAdmin ? null : 'hidden' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '报告',
|
||||
items: [{ label: '报告管理', href: '/reports', icon: FileText, perm: null }],
|
||||
},
|
||||
{
|
||||
title: '系统设置',
|
||||
items: [
|
||||
{ label: '用户管理', href: '/settings/users', icon: Users, perm: isAdmin ? null : 'hidden' },
|
||||
{ label: '角色权限', href: '/settings/roles', icon: Shield, perm: isAdmin ? null : 'hidden' },
|
||||
{ label: 'API Key', href: '/settings/api-keys', icon: Key, perm: isAdmin ? null : 'hidden' },
|
||||
{ label: '审计日志', href: '/settings/audit-logs', icon: FileText, perm: isAdmin ? null : 'hidden' },
|
||||
{ label: '邮件监控', href: '/settings/monitor', icon: Mail, perm: isAdmin ? null : 'hidden' },
|
||||
{ label: '微信推送', href: '/settings/wechat', icon: Bell, perm: isAdmin ? null : 'hidden' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
.then(r => r.json())
|
||||
.then(u => { if (u.user?.role === 'admin') setIsAdmin(true) })
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 bottom-0 w-60 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex flex-col z-40">
|
||||
<div className="h-14 flex items-center px-5 border-b border-slate-200 dark:border-slate-800">
|
||||
<span className="text-lg font-semibold text-blue-600 dark:text-blue-400">IT工单跟踪系统</span>
|
||||
</div>
|
||||
<nav className="flex-1 py-3 px-3 space-y-1 overflow-y-auto">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(item.href + '/')
|
||||
const Icon = item.icon
|
||||
return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>)
|
||||
})}
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/tickets/all"
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${pathname === '/tickets/all' ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}
|
||||
>
|
||||
<List size={18} />全部工单
|
||||
<aside className="fixed left-0 top-0 bottom-0 w-60 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 z-[200] flex flex-col">
|
||||
{/* 品牌区 */}
|
||||
<div className="h-14 flex items-center px-6 border-b border-slate-200 dark:border-slate-800 shrink-0">
|
||||
<Link href="/dashboard" className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center text-white font-bold text-sm">工</div>
|
||||
<span className="font-semibold text-slate-900 dark:text-white">IT工单跟踪系统</span>
|
||||
</Link>
|
||||
)}
|
||||
<div className="pt-3 border-t border-slate-200 dark:border-slate-800 mt-3">
|
||||
<div className="flex items-center gap-3 px-3 py-2 text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
|
||||
<Settings size={14} />系统设置
|
||||
</div>
|
||||
{settingsItems.map((item) => {
|
||||
const isActive = pathname === item.href
|
||||
|
||||
{/* 导航区 */}
|
||||
<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 (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>)
|
||||
return (
|
||||
<Link key={item.href} href={item.href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors mb-0.5 ${
|
||||
isActive(item.href)
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'
|
||||
}`}>
|
||||
<Icon size={18} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,18 +2,53 @@
|
|||
import { useTheme } from '@/components/providers/ThemeProvider'
|
||||
import { Sun, Moon, LogOut, User } from 'lucide-react'
|
||||
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 }
|
||||
|
||||
export default function TopBar({ user }: TopBarProps) {
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const router = useRouter()
|
||||
const handleLogout = async () => { await fetch('/api/auth/logout', { method: 'POST' }); router.push('/login'); router.refresh() }
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
const handleLogout = async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
router.push('/login'); router.refresh()
|
||||
}
|
||||
|
||||
const icons: Record<Theme, React.ReactNode> = { light: <Sun size={16} />, dark: <Moon size={16} />, auto: <span style={{fontSize:14}}>◐</span> }
|
||||
const labels: Record<Theme, string> = { light: '浅色', dark: '深色', auto: '自动' }
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 left-60 right-0 h-14 bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between px-6 z-30">
|
||||
<div />
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={toggleTheme} className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors" title={theme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'}>{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}</button>
|
||||
<div ref={ref} className="relative">
|
||||
<button onClick={() => setOpen(!open)} className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors" title="切换主题">
|
||||
{icons[theme]}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute top-full right-0 mt-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg z-50 py-1 min-w-[100px]">
|
||||
{(['light', 'dark', 'auto'] as Theme[]).map(t => (
|
||||
<button key={t} onClick={() => { setTheme(t); setOpen(false) }}
|
||||
className={`w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left ${t === theme ? 'bg-slate-100 dark:bg-slate-700' : 'hover:bg-slate-50 dark:hover:bg-slate-700/50'}`}>
|
||||
<span>{icons[t]}</span>
|
||||
<span>{labels[t]}</span>
|
||||
{t === theme && <span className="ml-auto text-blue-600">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{user && (<div className="flex items-center gap-3"><div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300"><User size={16} /><span>{user.display_name}</span></div><button onClick={handleLogout} className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors" title="退出登录"><LogOut size={18} /></button></div>)}
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -1,25 +1,35 @@
|
|||
'use client'
|
||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
interface ThemeContextType { theme: Theme; toggleTheme: () => void }
|
||||
const ThemeContext = createContext<ThemeContextType>({ theme: 'dark', toggleTheme: () => {} })
|
||||
type Theme = 'light' | 'dark' | 'auto'
|
||||
interface ThemeContextType { theme: Theme; setTheme: (t: Theme) => void }
|
||||
const ThemeContext = createContext<ThemeContextType>({ theme: 'auto', setTheme: () => {} })
|
||||
|
||||
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 }) {
|
||||
const [theme, setTheme] = useState<Theme>('dark')
|
||||
const [theme, setThemeState] = useState<Theme>('auto')
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('theme') as Theme | null
|
||||
if (stored) { setTheme(stored); document.documentElement.classList.toggle('dark', stored === 'dark') }
|
||||
else {
|
||||
const d = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
setTheme(d); document.documentElement.classList.toggle('dark', d === 'dark')
|
||||
}
|
||||
const initial = stored || 'auto'
|
||||
setThemeState(initial)
|
||||
applyTheme(initial)
|
||||
}, [])
|
||||
const toggleTheme = () => {
|
||||
const n = theme === 'dark' ? 'light' : 'dark'
|
||||
setTheme(n); localStorage.setItem('theme', n); document.documentElement.classList.toggle('dark', n === 'dark')
|
||||
const setTheme = (t: Theme) => {
|
||||
setThemeState(t)
|
||||
localStorage.setItem('theme', t)
|
||||
applyTheme(t)
|
||||
}
|
||||
return <ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>
|
||||
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ interface ProcessFormProps {
|
|||
}
|
||||
|
||||
const FAULT_CATEGORIES = [
|
||||
'硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障', '其他',
|
||||
'硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障',
|
||||
]
|
||||
|
||||
const TICKET_TYPES = ['OEM诊断', 'OEM维修']
|
||||
|
|
@ -35,7 +35,7 @@ const TICKET_TYPES = ['OEM诊断', 'OEM维修']
|
|||
const FAULT_SUBCATEGORIES = [
|
||||
'GPU故障', 'CPU故障', '内存故障', '硬盘故障', '电源故障', '风扇故障',
|
||||
'OS崩溃', '驱动故障', '软件崩溃', '配置错误', '网络丢包', '网络中断',
|
||||
'存储掉盘', 'RAID故障', '无故障', '其他',
|
||||
'存储掉盘', 'RAID故障', '其他',
|
||||
]
|
||||
|
||||
const HANDLER_OPTIONS = ['腾讯', '图灵']
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ interface TicketFormProps {
|
|||
}
|
||||
|
||||
const defaultFaultCategories = [
|
||||
'硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障', '其他',
|
||||
'硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障',
|
||||
]
|
||||
|
||||
const defaultTicketTypes = ['OEM诊断', 'OEM维修']
|
||||
|
|
@ -19,7 +19,7 @@ const defaultTicketTypes = ['OEM诊断', 'OEM维修']
|
|||
const defaultFaultSubcategories = [
|
||||
'GPU故障', 'CPU故障', '内存故障', '硬盘故障', '电源故障', '风扇故障',
|
||||
'OS崩溃', '驱动故障', '软件崩溃', '配置错误', '网络丢包', '网络中断',
|
||||
'存储掉盘', 'RAID故障', '无故障', '其他',
|
||||
'存储掉盘', 'RAID故障', '其他',
|
||||
]
|
||||
|
||||
export default function TicketForm({ initialData, ticketId }: TicketFormProps) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
import { useState, useEffect, useCallback, useRef, Suspense } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
|
||||
import { Button, Badge, Pagination } from '@/components/ui'
|
||||
import { Search, Download, Eye, Pencil, Trash2, Filter, ArrowUpDown, ChevronsUpDown, ChevronUp, ChevronDown, Check, X, ExternalLink } from 'lucide-react'
|
||||
import SelectWithInput from '@/components/ui/SelectWithInput'
|
||||
|
|
@ -28,7 +28,7 @@ const statusMap: Record<string, { label: string; variant: 'default' | 'info' | '
|
|||
}
|
||||
|
||||
const STATUS_OPTIONS = ['open', 'in_progress', 'resolved', 'closed']
|
||||
const FAULT_CATEGORY_OPTIONS = ['硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障', '其他']
|
||||
const FAULT_CATEGORY_OPTIONS = ['硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障']
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'id', label: '工单号', sortable: true, filterable: true, filterType: 'text' as const },
|
||||
|
|
@ -148,6 +148,7 @@ interface TicketListInnerProps {
|
|||
function TicketListInner({ onPaginationChange, defaultStatusFilter, showSlaColumn, showActions = true, hideDefaultFilterChips }: TicketListInnerProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const pathname = usePathname()
|
||||
const [tickets, setTickets] = useState<Ticket[]>([])
|
||||
const [pagination, setPagination] = useState({ page: 1, pageSize: 20, total: 0, totalPages: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
|
@ -165,8 +166,16 @@ function TicketListInner({ onPaginationChange, defaultStatusFilter, showSlaColum
|
|||
const [dateFilter, setDateFilter] = useState<Record<string, { start: string; end: string }>>({})
|
||||
const [fieldOptions, setFieldOptions] = useState<Record<string, string[]>>({})
|
||||
const [ticketNoFilter, setTicketNoFilter] = useState('')
|
||||
const [permissions, setPermissions] = useState<string[]>([])
|
||||
const filterDropRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
.then(r => r.json())
|
||||
.then(u => { if (u.user?.permissions) setPermissions(u.user.permissions) })
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// 列宽拖拽调整
|
||||
const [colWidths, setColWidths] = useState<Record<string, number>>({})
|
||||
const [resizingCol, setResizingCol] = useState<string | null>(null)
|
||||
|
|
@ -428,7 +437,9 @@ function TicketListInner({ onPaginationChange, defaultStatusFilter, showSlaColum
|
|||
<Button variant="secondary" size="sm" onClick={() => { setPage(1); fetchTickets() }}>搜索</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(permissions.includes('*') || permissions.includes('tickets:export')) && (
|
||||
<Button variant="secondary" size="sm" onClick={handleExport}><Download size={14} />导出</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -652,12 +663,12 @@ function TicketListInner({ onPaginationChange, defaultStatusFilter, showSlaColum
|
|||
<td className="px-4 py-3 font-medium"><Link href={`/tickets/${t.id}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.id}</Link></td>
|
||||
<td className="px-4 py-3">
|
||||
{t.device_ip ? (
|
||||
<Link href={`/tickets?device_ip=${encodeURIComponent(t.device_ip)}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.device_ip}</Link>
|
||||
<Link href={`${pathname}?device_ip=${encodeURIComponent(t.device_ip)}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.device_ip}</Link>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{t.device_name ? (
|
||||
<Link href={`/tickets?device_name=${encodeURIComponent(t.device_name)}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.device_name}</Link>
|
||||
<Link href={`${pathname}?device_name=${encodeURIComponent(t.device_name)}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.device_name}</Link>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 max-w-xs truncate">{t.content || '-'}</td>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import { ReactNode } from 'react'
|
||||
type Variant = 'default' | 'success' | 'warning' | 'danger' | 'info'
|
||||
// 兼容旧 API: blue→info, green→success, gray→default, red→danger, yellow→warning
|
||||
type LegacyColor = 'blue' | 'green' | 'gray' | 'red' | 'yellow'
|
||||
const legacyMap: Record<LegacyColor, Variant> = {
|
||||
blue: 'info', green: 'success', gray: 'default', red: 'danger', yellow: 'warning',
|
||||
}
|
||||
interface BadgeProps { children: ReactNode; variant?: Variant; color?: LegacyColor }
|
||||
const vs: Record<Variant, string> = {
|
||||
default: 'bg-slate-100 dark:bg-slate-800 dark:text-slate-300 text-slate-700',
|
||||
success: 'bg-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-400 text-emerald-700',
|
||||
warning: 'bg-amber-100 dark:bg-amber-500/10 dark:text-amber-400 text-amber-700',
|
||||
danger: 'bg-red-100 dark:bg-red-500/10 dark:text-red-400 text-red-700',
|
||||
info: 'bg-blue-100 dark:bg-blue-500/10 dark:text-blue-400 text-blue-700',
|
||||
}
|
||||
export default function Badge({ children, variant = 'default', color }: BadgeProps) {
|
||||
const v = color ? vs[legacyMap[color]] : vs[variant]
|
||||
return <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${v}`}>{children}</span>
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
'use client'
|
||||
import { ButtonHTMLAttributes, forwardRef } from 'react'
|
||||
type Variant = 'primary' | 'secondary' | 'danger' | 'ghost'
|
||||
type Size = 'sm' | 'md' | 'lg'
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { variant?: Variant; size?: Size; loading?: boolean }
|
||||
const v: Record<Variant, string> = {
|
||||
primary: 'bg-blue-600 hover:bg-blue-700 text-white shadow-sm disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
secondary: 'bg-slate-100 hover:bg-slate-200 text-slate-700 dark:bg-slate-800 dark:hover:bg-slate-700 dark:text-slate-300',
|
||||
danger: 'bg-red-600 hover:bg-red-700 text-white disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
ghost: 'text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800',
|
||||
}
|
||||
const s: Record<Size, string> = { sm: 'px-3 py-1.5 text-xs', md: 'px-4 py-2 text-sm', lg: 'px-6 py-2.5 text-base' }
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(({ variant = 'primary', size = 'md', loading, children, className = '', disabled, ...props }, ref) => (
|
||||
<button ref={ref} disabled={disabled || loading} className={`inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500/50 disabled:opacity-50 disabled:cursor-not-allowed ${v[variant]} ${s[size]} ${className}`} {...props}>
|
||||
{loading && <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>}
|
||||
{children}
|
||||
</button>
|
||||
))
|
||||
Button.displayName = 'Button'
|
||||
export default Button
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
'use client'
|
||||
import { ReactNode, useEffect, useRef } from 'react'
|
||||
interface ModalProps { open: boolean; onClose: () => void; title?: string; children: ReactNode; footer?: ReactNode }
|
||||
export default function Modal({ open, onClose, title, children, footer }: ModalProps) {
|
||||
const footerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') { onClose(); return }
|
||||
if (!footerRef.current) return
|
||||
const buttons = footerRef.current.querySelectorAll('button:not([disabled])')
|
||||
if (buttons.length === 0) return
|
||||
const currentIdx = Array.from(buttons).indexOf(document.activeElement as HTMLButtonElement)
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
const next = e.key === 'ArrowRight'
|
||||
? (currentIdx + 1) % buttons.length
|
||||
: (currentIdx - 1 + buttons.length) % buttons.length
|
||||
;(buttons[next] as HTMLButtonElement).focus()
|
||||
} else if (e.key === 'Enter' && currentIdx >= 0) {
|
||||
e.preventDefault()
|
||||
;(document.activeElement as HTMLButtonElement).click()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
const t = setTimeout(() => {
|
||||
const btn = footerRef.current?.querySelector('button:not([disabled])')
|
||||
if (btn) (btn as HTMLButtonElement).focus()
|
||||
}, 100)
|
||||
return () => { document.removeEventListener('keydown', handleKeyDown); clearTimeout(t) }
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative w-full max-w-lg mx-4 max-h-[90vh] overflow-auto bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-xl">
|
||||
{title && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-800">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">{title}</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 text-xl leading-none">×</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">{children}</div>
|
||||
{footer && (
|
||||
<div ref={footerRef} className="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-200 dark:border-slate-800">{footer}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue