Compare commits
17 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
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
|
||||||
|
|
@ -6,7 +6,7 @@ ASSETS_API_KEY=your-assets-api-key
|
||||||
# 允许调用 issue-ai API 的 Key(逗号分隔,支持多个),由 issue-ai 管理界面生成
|
# 允许调用 issue-ai API 的 Key(逗号分隔,支持多个),由 issue-ai 管理界面生成
|
||||||
ALLOWED_API_KEYS=your-issue-api-key
|
ALLOWED_API_KEYS=your-issue-api-key
|
||||||
# NEXT_PUBLIC_ 前缀:构建时内嵌到客户端 JS,云上必须通过 deploy-ai.sh 设置
|
# NEXT_PUBLIC_ 前缀:构建时内嵌到客户端 JS,云上必须通过 deploy-ai.sh 设置
|
||||||
# 本地开发:http://localhost:5177
|
# 本地开发:http://localhost:6177
|
||||||
# 云上生产:https://assets.tlyq.ai
|
# 云上生产:https://assets.tlyq.ai
|
||||||
NEXT_PUBLIC_ASSETS_URL=http://localhost:5177
|
NEXT_PUBLIC_ASSETS_URL=http://localhost:6177
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ build/
|
||||||
.claude/
|
.claude/
|
||||||
data/
|
data/
|
||||||
uploads/
|
uploads/
|
||||||
reports/
|
/reports/
|
||||||
docs/
|
docs/
|
||||||
db-backups/
|
db-backups/
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
61
CHANGELOG.md
61
CHANGELOG.md
|
|
@ -1,5 +1,66 @@
|
||||||
# 变更日志
|
# 变更日志
|
||||||
|
|
||||||
|
## 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
|
## 2026-05-07
|
||||||
|
|
||||||
- [新增] 月报跨月进行中工单支持:第一章折线图覆盖未结单离线天数,第二章标注"处理中",第三章显示"进行中"/"—",第四章标注"仅计本月部分"
|
- [新增] 月报跨月进行中工单支持:第一章折线图覆盖未结单离线天数,第二章标注"处理中",第三章显示"进行中"/"—",第四章标注"仅计本月部分"
|
||||||
|
|
|
||||||
49
CLAUDE.md
49
CLAUDE.md
|
|
@ -13,7 +13,7 @@ issue-ai 是基于 Next.js + SQLite 的工单跟踪管理系统,部署在腾
|
||||||
| 站点域名 | `issue.tlyq.ai` |
|
| 站点域名 | `issue.tlyq.ai` |
|
||||||
| 服务器 | txjp(IP: 43.133.38.210) |
|
| 服务器 | txjp(IP: 43.133.38.210) |
|
||||||
| 代码路径 | `/root/docker/issue-ai/` |
|
| 代码路径 | `/root/docker/issue-ai/` |
|
||||||
| 本地端口 | 5176 |
|
| 本地端口 | 6176 |
|
||||||
| 容器名 | `issue-ai` |
|
| 容器名 | `issue-ai` |
|
||||||
| 数据库 | SQLite:`data/issue.db` |
|
| 数据库 | SQLite:`data/issue.db` |
|
||||||
| 报告存储 | `reports/` 目录(环境变量 `REPORTS_DIR` 可覆盖) |
|
| 报告存储 | `reports/` 目录(环境变量 `REPORTS_DIR` 可覆盖) |
|
||||||
|
|
@ -99,11 +99,16 @@ 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/login` | 登录(LDAP 优先 + 本地回退) |
|
||||||
| POST | `/api/auth/logout` | 登出 |
|
| POST | `/api/auth/logout` | 登出(清除两个 cookie) |
|
||||||
| GET | `/api/auth/me` | 当前用户信息 |
|
| GET | `/api/auth/me` | 当前用户信息 |
|
||||||
|
| GET | `/api/internal/roles` | 内部 API:返回角色列表(x-internal-key 鉴权) |
|
||||||
|
|
||||||
### 工单
|
### 工单
|
||||||
|
|
||||||
|
|
@ -133,15 +138,23 @@ npm run import # 导入工单
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 认证机制
|
||||||
|
|
||||||
|
- **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 还需同步到环境变量,重启容器后生效)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 环境配置
|
## 环境配置
|
||||||
|
|
||||||
### 本地与云端差异
|
### 本地与云端差异
|
||||||
|
|
||||||
| 环境变量 | 本地开发 | 云服务器(txjp) | 说明 |
|
| 环境变量 | 本地开发 | 云服务器(txjp) | 说明 |
|
||||||
|---------|---------|----------------|------|
|
|---------|---------|----------------|------|
|
||||||
| `ASSETS_API_URL` | `http://localhost:5177/api` | `http://assets-ai:3000/api` | 调用 assets API 地址 |
|
| `ASSETS_API_URL` | `http://localhost:6177/api` | `http://assets-ai:3000/api` | 调用 assets API 地址 |
|
||||||
| `ASSETS_API_KEY` | 本地 assets-ai 生成 | 云上 assets-ai 生成 | **每个环境独立,不可跨环境使用** |
|
| `ASSETS_API_KEY` | 本地 assets-ai 生成 | 云上 assets-ai 生成 | **每个环境独立,不可跨环境使用** |
|
||||||
| `NEXT_PUBLIC_ASSETS_URL` | `http://localhost:5177` | `https://assets.tlyq.ai` | 前端跳转链接(构建时内嵌) |
|
| `NEXT_PUBLIC_ASSETS_URL` | `http://localhost:6177` | `https://assets.tlyq.ai` | 前端跳转链接(构建时内嵌) |
|
||||||
| `ALLOWED_API_KEYS` | 本地 issue-ai 生成的 Key | 云上 issue-ai 生成的 Key | 允许外部系统调本系统的 Key,逗号分隔 |
|
| `ALLOWED_API_KEYS` | 本地 issue-ai 生成的 Key | 云上 issue-ai 生成的 Key | 允许外部系统调本系统的 Key,逗号分隔 |
|
||||||
| `JWT_SECRET` | `dev-secret-key-local` | `${ISSUE_JWT_SECRET}` | 本地两系统需一致(同 localhost 域) |
|
| `JWT_SECRET` | `dev-secret-key-local` | `${ISSUE_JWT_SECRET}` | 本地两系统需一致(同 localhost 域) |
|
||||||
| `DATABASE_PATH` | `./data/issue.db` | `/app/data/issue.db` | Docker volume 挂载 |
|
| `DATABASE_PATH` | `./data/issue.db` | `/app/data/issue.db` | Docker volume 挂载 |
|
||||||
|
|
@ -154,10 +167,10 @@ DATABASE_PATH=./data/issue.db
|
||||||
JWT_SECRET=dev-secret-key-local
|
JWT_SECRET=dev-secret-key-local
|
||||||
ADMIN_PASSWORD=admin123
|
ADMIN_PASSWORD=admin123
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
ASSETS_API_URL=http://localhost:5177/api
|
ASSETS_API_URL=http://localhost:6177/api
|
||||||
ASSETS_API_KEY=ak_<32字节十六进制>
|
ASSETS_API_KEY=ak_<32字节十六进制>
|
||||||
ALLOWED_API_KEYS=ak_<32字节十六进制>
|
ALLOWED_API_KEYS=ak_<32字节十六进制>
|
||||||
NEXT_PUBLIC_ASSETS_URL=http://localhost:5177
|
NEXT_PUBLIC_ASSETS_URL=http://localhost:6177
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -176,10 +189,14 @@ assets-ai ──→ GET {ISSUE_API_URL}/tickets/by-asset?ip=xxx (Authori
|
||||||
|
|
||||||
Key 格式:`ak_<32位十六进制>`,认证头:`Authorization: Bearer <key>`。每个环境独立创建,互不通用。
|
Key 格式:`ak_<32位十六进制>`,认证头:`Authorization: Bearer <key>`。每个环境独立创建,互不通用。
|
||||||
|
|
||||||
**issue → assets 方向**:在 assets-ai `/settings/api-keys` 创建 Key → 写入 issue-ai 的 `ASSETS_API_KEY`
|
**验证策略(v2.2)**:两边中间件均采用 `ALLOWED_API_KEYS` 环境变量 → `api_keys` 数据库表两级验证。Key 注册在任意一处即可通过认证,推荐走 Web UI 创建(写入 `api_keys` 表,支持权限和追踪)。
|
||||||
|
|
||||||
|
**issue → assets 方向**:在 assets-ai `/settings/api-keys` 创建 Key → 写入 issue-ai 的 `ASSETS_API_KEY`(`.env` 或 `docker-compose.yml` 环境变量)
|
||||||
|
|
||||||
**assets → issue 方向**:在 issue-ai `/settings/api-keys` 创建 Key → 写入 assets-ai 的 `ISSUE_API_KEY` + issue-ai 的 `ALLOWED_API_KEYS`
|
**assets → issue 方向**:在 issue-ai `/settings/api-keys` 创建 Key → 写入 assets-ai 的 `ISSUE_API_KEY` + issue-ai 的 `ALLOWED_API_KEYS`
|
||||||
|
|
||||||
|
> **部署验证**:`deploy-ai.sh` 部署 issue 站点后会自动验证 issue→assets API 连通性(使用配置的 `ASSETS_API_KEY` 调 assets API),若返回 401 则部署失败退出,防止 API Key 配置遗漏上线。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Docker 部署
|
## Docker 部署
|
||||||
|
|
@ -225,7 +242,21 @@ NEXT_PUBLIC_ASSETS_URL=https://assets.tlyq.ai
|
||||||
- **新增 API**:在 `src/app/api/` 下创建路由 → 顶部调用 `initDatabase()` → `getCurrentUser()` 验证 → `hasPermission()` 校验
|
- **新增 API**:在 `src/app/api/` 下创建路由 → 顶部调用 `initDatabase()` → `getCurrentUser()` 验证 → `hasPermission()` 校验
|
||||||
- **新增页面**:在 `src/app/(app)/` 下创建 → 布局由 `(app)/layout.tsx` 提供
|
- **新增页面**:在 `src/app/(app)/` 下创建 → 布局由 `(app)/layout.tsx` 提供
|
||||||
- **权限格式**:`resource:action`,如 `hasPermission(user, 'tickets:write')`
|
- **权限格式**:`resource:action`,如 `hasPermission(user, 'tickets:write')`
|
||||||
- **日期处理**:禁止使用 `Date.toISOString()` 格式化本地日期。`toISOString()` 返回 UTC 时间,在中国时区(UTC+8)下 `new Date('2026-04-01T00:00:00').toISOString()` 会返回 `"2026-03-31T16:00:00.000Z"`,日期偏移一天。应使用本地时间方法拼接:`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
- **日期处理(时区规范)**:整个系统统一使用 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')`,包括 CREATE TABLE 的 DEFAULT 值、UPDATE/SET 语句、以及查询条件中的时间比较。禁止使用不含时区偏移的 `datetime('now')`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libcairo2 \
|
libcairo2 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY --from=builder /app/package.json /app/package-lock.json ./
|
COPY --from=builder /app/package.json /app/package-lock.json ./
|
||||||
RUN npm install --omit=dev
|
|
||||||
COPY --from=builder /app/.next/standalone ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
RUN npm install --omit=dev && \
|
||||||
|
npm rebuild better-sqlite3 && \
|
||||||
|
rm -rf node_modules/@img/sharp-linuxmusl-x64 node_modules/@img/sharp-libvips-linuxmusl-x64 \
|
||||||
|
node_modules/@next/swc-linux-x64-musl
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
RUN mkdir -p /app/data /app/uploads /app/reports
|
RUN mkdir -p /app/data /app/uploads /app/reports
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,28 @@ services:
|
||||||
build: .
|
build: .
|
||||||
container_name: issue-ai
|
container_name: issue-ai
|
||||||
ports:
|
ports:
|
||||||
- "5176:3000"
|
- "6176:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- issue-data:/app/data
|
- issue-data:/app/data
|
||||||
- issue-uploads:/app/uploads
|
- issue-uploads:/app/uploads
|
||||||
- issue-reports:/app/reports
|
- issue-reports:/app/reports
|
||||||
# .next 目录从主机挂载,npm run build 后直接生效,无需重建镜像
|
# .next 目录从主机挂载,npm run build 后直接生效,无需重建镜像
|
||||||
- ./.next:/app/.next
|
- ./.next:/app/.next
|
||||||
|
# 运行时从 LLDAP 容器动态读取 admin 密码
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_PATH=/app/data/issue.db
|
- DATABASE_PATH=/app/data/issue.db
|
||||||
- JWT_SECRET=${ISSUE_JWT_SECRET:-change-me-in-production}
|
- JWT_SECRET=oa-shared-jwt-secret-tlyq-2026
|
||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||||
- ASSETS_API_URL=${ASSETS_API_URL:-https://assets.tlyq.ai/api}
|
- ASSETS_API_URL=${ASSETS_API_URL:-https://assets.tlyq.ai/api}
|
||||||
- ASSETS_API_KEY=${ASSETS_API_KEY}
|
- ASSETS_API_KEY=${ASSETS_API_KEY}
|
||||||
- ALLOWED_API_KEYS=${ALLOWED_API_KEYS}
|
- ALLOWED_API_KEYS=${ALLOWED_API_KEYS}
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
- COOKIE_DOMAIN=.tlyq.ai
|
||||||
|
- AUTHELIA_URL=${AUTHELIA_URL:-https://sso.tlyq.ai}
|
||||||
|
- LDAP_URL=ldap://lldap:3890
|
||||||
|
- LDAP_BASE_DN=dc=tlyq,dc=ai
|
||||||
|
- LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ const nextConfig: NextConfig = {
|
||||||
images: { unoptimized: true },
|
images: { unoptimized: true },
|
||||||
eslint: { ignoreDuringBuilds: true },
|
eslint: { ignoreDuringBuilds: true },
|
||||||
typescript: { ignoreBuildErrors: true },
|
typescript: { ignoreBuildErrors: true },
|
||||||
serverExternalPackages: ['better-sqlite3'],
|
// better-sqlite3: 原生模块,必须 external
|
||||||
|
// ldapts: SSO 自动创建用户依赖 LLDAP 验证,缺少则新用户无法免登录进入系统
|
||||||
|
serverExternalPackages: ['better-sqlite3', 'ldapts'],
|
||||||
// 确保 fs.readFileSync 加载的文件也被追踪到 standalone 输出中
|
// 确保 fs.readFileSync 加载的文件也被追踪到 standalone 输出中
|
||||||
// 防止 Docker 镜像中 npm install --omit=dev 漏装时缺失依赖
|
// 防止 Docker 镜像中 npm install --omit=dev 漏装时缺失依赖
|
||||||
outputFileTracingIncludes: {
|
outputFileTracingIncludes: {
|
||||||
|
|
|
||||||
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 |
|
|
@ -15,6 +15,7 @@
|
||||||
"echarts": "^5.5.0",
|
"echarts": "^5.5.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
"ldapts": "^8.1.7",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
"puppeteer": "^23.0.0",
|
"puppeteer": "^23.0.0",
|
||||||
|
|
@ -3592,6 +3593,18 @@
|
||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ldapts": {
|
||||||
|
"version": "8.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.1.7.tgz",
|
||||||
|
"integrity": "sha512-TJl6T92eIwMf/OJ0hDfKVa6ISwzo+lqCWCI5Mf//ARlKa3LKQZaSrme/H2rCRBhW0DZCQlrsV+fgoW5YHRNLUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"strict-event-emitter-types": "2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
|
|
@ -4978,6 +4991,12 @@
|
||||||
"text-decoder": "^1.1.0"
|
"text-decoder": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strict-event-emitter-types": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/string_decoder": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NODE_OPTIONS='--max-old-space-size=2048' next dev --port 5176",
|
"dev": "NODE_OPTIONS='--max-old-space-size=2048' next dev --port 6176",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"echarts": "^5.5.0",
|
"echarts": "^5.5.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
"ldapts": "^8.1.7",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
"puppeteer": "^23.0.0",
|
"puppeteer": "^23.0.0",
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
|
|
@ -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.toISOString().split('T')[0],
|
||||||
|
end: lastSunday.toISOString().split('T')[0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
a.download = `reports_${new Date().toISOString().slice(0, 10)}.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="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">API Key 管理</h1>
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">API Key 管理</h1>
|
||||||
<p className="text-slate-500 dark:text-slate-400 mt-1">用于第三方系统调用工单系统 API</p>
|
<p className="text-slate-500 dark:text-slate-400 mt-1">用于第三方系统调用工单系统 API</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={() => { setCreateOpen(true); setNewKey(null) }}>
|
<Button size="sm" onClick={() => { setCreateOpen(true); setNewKey(null) }}>
|
||||||
<Plus size={16} className="mr-1" />创建 Key
|
<Plus size={16} />创建 Key
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{newKey && (
|
{newKey && (
|
||||||
<div className="p-4 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-xl">
|
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||||
<p className="text-sm font-medium text-emerald-800 dark:text-emerald-300 mb-2">
|
<p className="text-sm font-medium text-green-800 dark:text-green-300 mb-2">
|
||||||
API Key 已创建(仅显示一次,请妥善保存)
|
API Key 已创建(仅显示一次,请妥善保存)
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="flex-1 p-3 bg-white dark:bg-slate-800 rounded-lg border border-emerald-200 dark:border-emerald-700 text-sm font-mono break-all text-slate-800 dark:text-slate-200">
|
<code className="flex-1 p-2 bg-white dark:bg-slate-800 rounded border border-green-200 dark:border-green-700 text-sm font-mono break-all">
|
||||||
{newKey}
|
{newKey}
|
||||||
</code>
|
</code>
|
||||||
<Button variant="ghost" size="sm" onClick={() => copyKey(newKey)}>
|
<Button variant="ghost" size="sm" onClick={() => copyKey(newKey)}>
|
||||||
{copied ? <Check size={16} className="text-emerald-500" /> : <Copy size={16} />}
|
{copied ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -124,7 +124,7 @@ export default function ApiKeysPage() {
|
||||||
const perms: string[] = JSON.parse(k.permissions)
|
const perms: string[] = JSON.parse(k.permissions)
|
||||||
return (
|
return (
|
||||||
<tr key={k.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
<tr key={k.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||||
<td className="px-4 py-3 font-medium text-slate-900 dark:text-slate-100">{k.name}</td>
|
<td className="px-4 py-3 font-medium text-slate-900 dark:text-white">{k.name}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{perms.map(p => <Badge key={p} variant="default">{p}</Badge>)}
|
{perms.map(p => <Badge key={p} variant="default">{p}</Badge>)}
|
||||||
|
|
@ -163,7 +163,7 @@ export default function ApiKeysPage() {
|
||||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button onClick={handleCreate} loading={saving}>创建</Button>
|
<Button onClick={handleCreate} loading={saving}>创建</Button>
|
||||||
<Button variant="secondary" onClick={() => { setCreateOpen(false); setError('') }}>取消</Button>
|
<Button variant="ghost" onClick={() => { setCreateOpen(false); setError('') }}>取消</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
@ -173,7 +173,7 @@ export default function ApiKeysPage() {
|
||||||
确定要删除 API Key「{deleteTarget?.name}」吗?使用该 Key 的应用将无法再访问此系统。
|
确定要删除 API Key「{deleteTarget?.name}」吗?使用该 Key 的应用将无法再访问此系统。
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-end gap-3 mt-4">
|
<div className="flex justify-end gap-3 mt-4">
|
||||||
<Button variant="secondary" onClick={() => setDeleteTarget(null)}>取消</Button>
|
<Button variant="ghost" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||||
<Button variant="danger" onClick={handleDelete}>删除</Button>
|
<Button variant="danger" onClick={handleDelete}>删除</Button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,23 @@ interface Role {
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BUILTIN_ROLES = ['admin', 'operator', 'viewer']
|
||||||
|
|
||||||
const allPermissions = [
|
const allPermissions = [
|
||||||
{ key: 'tickets:read', label: '查看工单' },
|
{ key: 'tickets:read', label: '查看工单' },
|
||||||
{ key: 'tickets:write', label: '编辑工单' },
|
{ key: 'tickets:create', label: '手动建单' },
|
||||||
|
{ key: 'tickets:import', label: '导入工单' },
|
||||||
|
{ key: 'tickets:export', label: '导出工单' },
|
||||||
|
{ key: 'tickets:write', label: '编辑/删除工单' },
|
||||||
{ key: 'reports:read', label: '查看报告' },
|
{ key: 'reports:read', label: '查看报告' },
|
||||||
{ key: 'reports:write', label: '编辑报告' },
|
{ key: 'reports:download', label: '下载报告' },
|
||||||
|
{ key: 'reports:create', label: '新建报告' },
|
||||||
{ key: 'users:read', label: '查看用户' },
|
{ key: 'users:read', label: '查看用户' },
|
||||||
{ key: 'users:write', label: '编辑用户' },
|
{ key: 'users:write', label: '编辑用户' },
|
||||||
{ key: 'roles:read', label: '查看角色' },
|
{ key: 'roles:read', label: '查看角色' },
|
||||||
{ key: 'roles:write', label: '编辑角色' },
|
{ key: 'roles:write', label: '编辑角色' },
|
||||||
|
{ key: 'api-keys:read', label: '查看 API Key' },
|
||||||
|
{ key: 'api-keys:write', label: '编辑 API Key' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function RolesPage() {
|
export default function RolesPage() {
|
||||||
|
|
@ -29,6 +37,7 @@ export default function RolesPage() {
|
||||||
const [editRole, setEditRole] = useState<Role | null>(null)
|
const [editRole, setEditRole] = useState<Role | null>(null)
|
||||||
const [form, setForm] = useState({ name: '', display_name: '', permissions: [] as string[] })
|
const [form, setForm] = useState({ name: '', display_name: '', permissions: [] as string[] })
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
const fetchRoles = () => {
|
const fetchRoles = () => {
|
||||||
fetch('/api/roles').then(r => r.json()).then(d => { if (d.roles) setRoles(d.roles) }).catch(() => {}).finally(() => setLoading(false))
|
fetch('/api/roles').then(r => r.json()).then(d => { if (d.roles) setRoles(d.roles) }).catch(() => {}).finally(() => setLoading(false))
|
||||||
|
|
@ -61,6 +70,7 @@ export default function RolesPage() {
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setError('')
|
setError('')
|
||||||
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
if (editRole) {
|
if (editRole) {
|
||||||
const res = await fetch(`/api/roles/${editRole.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: form.display_name, permissions: form.permissions }) })
|
const res = await fetch(`/api/roles/${editRole.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: form.display_name, permissions: form.permissions }) })
|
||||||
|
|
@ -73,6 +83,7 @@ export default function RolesPage() {
|
||||||
setModalOpen(false)
|
setModalOpen(false)
|
||||||
fetchRoles()
|
fetchRoles()
|
||||||
} catch { setError('操作失败') }
|
} catch { setError('操作失败') }
|
||||||
|
finally { setSaving(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
|
|
@ -90,16 +101,14 @@ export default function RolesPage() {
|
||||||
} catch { return '无权限' }
|
} catch { return '无权限' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const builtinRoles = ['admin', 'operator', 'viewer']
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">角色权限</h1>
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">角色权限</h1>
|
||||||
<p className="text-slate-500 dark:text-slate-400 mt-1">角色与权限配置</p>
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">角色与权限配置</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={openCreate}><Plus size={16} className="mr-1" />新建角色</Button>
|
<Button size="sm" onClick={openCreate}><Plus size={16} />新建角色</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|
@ -109,12 +118,19 @@ export default function RolesPage() {
|
||||||
{roles.map(r => (
|
{roles.map(r => (
|
||||||
<tr key={r.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
<tr key={r.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||||
<td className="px-4 py-3 font-medium text-slate-900 dark:text-slate-100">{r.name}</td>
|
<td className="px-4 py-3 font-medium text-slate-900 dark:text-slate-100">{r.name}</td>
|
||||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{r.display_name}</td>
|
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{r.display_name}</span>
|
||||||
|
{BUILTIN_ROLES.includes(r.name) && (
|
||||||
|
<Badge variant={r.name === 'admin' ? 'info' : r.name === 'operator' ? 'success' : 'default'}>内置</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-slate-500 dark:text-slate-400 text-sm">{formatPermissions(r.permissions)}</td>
|
<td className="px-4 py-3 text-slate-500 dark:text-slate-400 text-sm">{formatPermissions(r.permissions)}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button variant="ghost" size="sm" onClick={() => openEdit(r)}><Pencil size={14} /></Button>
|
<Button variant="ghost" size="sm" onClick={() => openEdit(r)}><Pencil size={14} /></Button>
|
||||||
{!builtinRoles.includes(r.name) && (
|
{!BUILTIN_ROLES.includes(r.name) && (
|
||||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(r.id)}><Trash2 size={14} className="text-red-500" /></Button>
|
<Button variant="ghost" size="sm" onClick={() => handleDelete(r.id)}><Trash2 size={14} className="text-red-500" /></Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -124,16 +140,21 @@ export default function RolesPage() {
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal open={modalOpen} onClose={() => setModalOpen(false)} title={editRole ? '编辑角色' : '新建角色'}>
|
<Modal open={modalOpen} onClose={() => setModalOpen(false)} title={editRole ? '编辑角色' : '新建角色'} footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setModalOpen(false)}>取消</Button>
|
||||||
|
<Button onClick={handleSave} loading={saving}>{editRole ? '保存' : '创建'}</Button>
|
||||||
|
</>
|
||||||
|
}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{!editRole && <Input label="角色名(英文)" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} placeholder="e.g. supervisor" />}
|
{!editRole && <Input label="角色名(英文)" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} placeholder="e.g. supervisor" />}
|
||||||
<Input label="显示名称" value={form.display_name} onChange={e => setForm(p => ({ ...p, display_name: e.target.value }))} />
|
<Input label="显示名称" value={form.display_name} onChange={e => setForm(p => ({ ...p, display_name: e.target.value }))} />
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">权限</label>
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">权限</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||||
{allPermissions.map(p => (
|
{allPermissions.map(p => (
|
||||||
<label key={p.key} className="flex items-center gap-2 cursor-pointer">
|
<label key={p.key} className="flex items-center gap-2 cursor-pointer py-1">
|
||||||
<input type="checkbox" checked={form.permissions.includes(p.key) || form.permissions.includes('*')} onChange={() => togglePermission(p.key)} className="rounded border-slate-300" />
|
<input type="checkbox" checked={form.permissions.includes(p.key) || form.permissions.includes('*')} onChange={() => togglePermission(p.key)} className="rounded border-slate-300 dark:border-slate-600" />
|
||||||
<span className="text-sm text-slate-700 dark:text-slate-300">{p.label}</span>
|
<span className="text-sm text-slate-700 dark:text-slate-300">{p.label}</span>
|
||||||
<span className="text-xs text-slate-400">{p.key}</span>
|
<span className="text-xs text-slate-400">{p.key}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -141,10 +162,6 @@ export default function RolesPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button onClick={handleSave}>{editRole ? '保存' : '创建'}</Button>
|
|
||||||
<Button variant="secondary" onClick={() => setModalOpen(false)}>取消</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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'
|
'use client'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Card, Button, Table, Badge, Modal, Input, Select } from '@/components/ui'
|
import { Button, Table, Badge, Modal, Input, Select } from '@/components/ui'
|
||||||
import { Plus, Pencil, Trash2 } from 'lucide-react'
|
import { Plus, Trash2 } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -11,15 +12,18 @@ interface User {
|
||||||
role: string
|
role: string
|
||||||
is_active: number
|
is_active: number
|
||||||
created_at: string
|
created_at: string
|
||||||
|
last_login_at: string | null
|
||||||
|
is_online: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const [users, setUsers] = useState<User[]>([])
|
const [users, setUsers] = useState<User[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [editUser, setEditUser] = useState<User | null>(null)
|
const [createForm, setCreateForm] = useState({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
|
||||||
const [form, setForm] = useState({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
|
const [createError, setCreateError] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<User | null>(null)
|
||||||
|
|
||||||
const fetchUsers = () => {
|
const fetchUsers = () => {
|
||||||
fetch('/api/users').then(r => r.json()).then(d => { if (d.users) setUsers(d.users) }).catch(() => {}).finally(() => setLoading(false))
|
fetch('/api/users').then(r => r.json()).then(d => { if (d.users) setUsers(d.users) }).catch(() => {}).finally(() => setLoading(false))
|
||||||
|
|
@ -27,42 +31,22 @@ export default function UsersPage() {
|
||||||
|
|
||||||
useEffect(() => { fetchUsers() }, [])
|
useEffect(() => { fetchUsers() }, [])
|
||||||
|
|
||||||
const openCreate = () => {
|
const handleCreate = async () => {
|
||||||
setEditUser(null)
|
setCreateError(''); setCreating(true)
|
||||||
setForm({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
|
|
||||||
setError('')
|
|
||||||
setModalOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const openEdit = (u: User) => {
|
|
||||||
setEditUser(u)
|
|
||||||
setForm({ username: u.username, password: '', display_name: u.display_name, email: u.email || '', role: u.role })
|
|
||||||
setError('')
|
|
||||||
setModalOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setError('')
|
|
||||||
try {
|
try {
|
||||||
if (editUser) {
|
if (!createForm.username || !createForm.password || !createForm.display_name) { setCreateError('请填写必填项'); return }
|
||||||
const body: Record<string, unknown> = { display_name: form.display_name, email: form.email, role: form.role }
|
const res = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(createForm) })
|
||||||
if (form.password) body.password = form.password
|
if (!res.ok) { const d = await res.json(); setCreateError(d.error || '创建失败'); return }
|
||||||
const res = await fetch(`/api/users/${editUser.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
setCreateOpen(false)
|
||||||
if (!res.ok) { const d = await res.json(); setError(d.error || '更新失败'); return }
|
setCreateForm({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
|
||||||
} else {
|
|
||||||
if (!form.username || !form.password || !form.display_name) { setError('请填写必填项'); return }
|
|
||||||
const res = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) })
|
|
||||||
if (!res.ok) { const d = await res.json(); setError(d.error || '创建失败'); return }
|
|
||||||
}
|
|
||||||
setModalOpen(false)
|
|
||||||
fetchUsers()
|
fetchUsers()
|
||||||
} catch { setError('操作失败') }
|
} catch { setCreateError('操作失败') } finally { setCreating(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async () => {
|
||||||
if (!confirm('确定删除此用户?')) return
|
if (!deleteTarget) return
|
||||||
const res = await fetch(`/api/users/${id}`, { method: 'DELETE' })
|
const res = await fetch(`/api/users/${deleteTarget.id}`, { method: 'DELETE' })
|
||||||
if (res.ok) fetchUsers()
|
if (res.ok) { setDeleteTarget(null); fetchUsers() }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleActive = async (u: User) => {
|
const handleToggleActive = async (u: User) => {
|
||||||
|
|
@ -76,32 +60,41 @@ export default function UsersPage() {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">用户管理</h1>
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">用户管理</h1>
|
||||||
<p className="text-slate-500 dark:text-slate-400 mt-1">系统用户与账号管理</p>
|
<p className="text-slate-500 dark:text-slate-400 mt-1">系统用户与账号管理</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={openCreate}><Plus size={16} className="mr-1" />新建用户</Button>
|
<Button size="sm" onClick={() => { setCreateError(''); setCreateOpen(true) }}><Plus size={16} />新建用户</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-12 text-slate-500 dark:text-slate-400">加载中...</div>
|
<div className="text-center py-12 text-slate-500 dark:text-slate-400">加载中...</div>
|
||||||
) : (
|
) : (
|
||||||
<Table headers={['用户名', '显示名称', '邮箱', '角色', '状态', '创建时间', '操作']}>
|
<Table headers={['用户名', '显示名称', '邮箱', '角色', '状态', '在线', '最后登录', '操作']}>
|
||||||
{users.map(u => (
|
{users.map(u => (
|
||||||
<tr key={u.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
<tr key={u.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||||
<td className="px-4 py-3 font-medium text-slate-900 dark:text-slate-100">{u.username}</td>
|
<td className="px-4 py-3">
|
||||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{u.display_name}</td>
|
<Link href={`/settings/users/${u.id}`} className="text-blue-600 dark:text-blue-400 hover:underline font-medium">{u.username}</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-slate-900 dark:text-white font-medium">{u.display_name}</td>
|
||||||
<td className="px-4 py-3 text-slate-500 dark:text-slate-400">{u.email || '-'}</td>
|
<td className="px-4 py-3 text-slate-500 dark:text-slate-400">{u.email || '-'}</td>
|
||||||
<td className="px-4 py-3"><Badge variant="info">{roleLabel[u.role] || u.role}</Badge></td>
|
<td className="px-4 py-3"><Badge variant={u.role === 'admin' ? 'info' : u.role === 'operator' ? 'success' : 'default'}>{roleLabel[u.role] || u.role}</Badge></td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<button onClick={() => handleToggleActive(u)}>
|
<button onClick={() => handleToggleActive(u)}>
|
||||||
<Badge variant={u.is_active ? 'success' : 'danger'}>{u.is_active ? '启用' : '禁用'}</Badge>
|
<Badge variant={u.is_active ? 'success' : 'danger'}>{u.is_active ? '启用' : '禁用'}</Badge>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-slate-500 dark:text-slate-400">{u.created_at || '-'}</td>
|
<td className="px-4 py-3">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className={`inline-block w-2 h-2 rounded-full ${u.is_online ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'}`} />
|
||||||
|
<span className="text-slate-500 dark:text-slate-400">{u.is_online ? '在线' : '离线'}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-slate-500 dark:text-slate-400">{u.last_login_at ? u.last_login_at : <span className="text-slate-400 dark:text-slate-500">从未登录</span>}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button variant="ghost" size="sm" onClick={() => openEdit(u)}><Pencil size={14} /></Button>
|
{u.username !== 'admin' && u.username !== 'localadmin' && (
|
||||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(u.id)}><Trash2 size={14} className="text-red-500" /></Button>
|
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(u)}><Trash2 size={14} className="text-red-500" /></Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -109,20 +102,30 @@ export default function UsersPage() {
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal open={modalOpen} onClose={() => setModalOpen(false)} title={editUser ? '编辑用户' : '新建用户'}>
|
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="新建用户" footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setCreateOpen(false)}>取消</Button>
|
||||||
|
<Button onClick={handleCreate} loading={creating}>创建</Button>
|
||||||
|
</>
|
||||||
|
}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{!editUser && <Input label="用户名" value={form.username} onChange={e => setForm(p => ({ ...p, username: e.target.value }))} required />}
|
<Input label="用户名" value={createForm.username} onChange={e => setCreateForm(p => ({ ...p, username: e.target.value }))} required />
|
||||||
<Input label="显示名称" value={form.display_name} onChange={e => setForm(p => ({ ...p, display_name: e.target.value }))} required />
|
<Input label="显示名称" value={createForm.display_name} onChange={e => setCreateForm(p => ({ ...p, display_name: e.target.value }))} required />
|
||||||
<Input label={editUser ? '新密码(留空不修改)' : '密码'} type="password" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
|
<Input label="密码" type="password" value={createForm.password} onChange={e => setCreateForm(p => ({ ...p, password: e.target.value }))} />
|
||||||
<Input label="邮箱" type="email" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} />
|
<Input label="邮箱" type="email" value={createForm.email} onChange={e => setCreateForm(p => ({ ...p, email: e.target.value }))} />
|
||||||
<Select label="角色" options={[{ value: 'viewer', label: '查看者' }, { value: 'operator', label: '运维人员' }, { value: 'admin', label: '管理员' }]} value={form.role} onChange={e => setForm(p => ({ ...p, role: e.target.value }))} />
|
<Select label="角色" options={[{ value: 'viewer', label: '查看者' }, { value: 'operator', label: '运维人员' }, { value: 'admin', label: '管理员' }]} value={createForm.role} onChange={e => setCreateForm(p => ({ ...p, role: e.target.value }))} />
|
||||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
{createError && <p className="text-sm text-red-500">{createError}</p>}
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button onClick={handleSave}>{editUser ? '保存' : '创建'}</Button>
|
|
||||||
<Button variant="secondary" onClick={() => setModalOpen(false)}>取消</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除" footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||||
|
<Button variant="danger" onClick={handleDelete}>删除</Button>
|
||||||
|
</>
|
||||||
|
}>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">确定要删除用户「{deleteTarget?.display_name}」吗?此操作不可撤销。</p>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export default function AllTicketsPage() {
|
||||||
fetch('/api/auth/me')
|
fetch('/api/auth/me')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(u => {
|
.then(u => {
|
||||||
if (u.user?.role !== 'admin') {
|
if (!u.user?.permissions?.includes('*')) {
|
||||||
router.replace('/tickets/pending')
|
router.replace('/tickets/pending')
|
||||||
} else {
|
} else {
|
||||||
setReady(true)
|
setReady(true)
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,94 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { login } from '@/lib/auth'
|
import { createToken } from '@/lib/auth'
|
||||||
import { initDatabase } from '@/lib/db-schema'
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
|
import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt-shared'
|
||||||
|
import { ldapAuth } from '@/lib/ldap'
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
import { getUserPermissions } from '@/lib/permissions'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
initDatabase()
|
initDatabase()
|
||||||
const { username, password } = await request.json()
|
const { username, password } = await request.json()
|
||||||
if (!username || !password) return NextResponse.json({ error: '请输入用户名和密码' }, { status: 400 })
|
if (!username || !password) return NextResponse.json({ error: '请输入用户名和密码' }, { status: 400 })
|
||||||
const result = await login(username, password)
|
|
||||||
if (!result) return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
|
let userId: number
|
||||||
const response = NextResponse.json({ user: result.user })
|
let role: string
|
||||||
response.cookies.set('session_issue', result.token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 7 * 24 * 60 * 60, path: '/' })
|
let displayName: string
|
||||||
|
const db = getDb()
|
||||||
|
|
||||||
|
// 1. localadmin:纯本地 BCrypt,不依赖 LLDAP
|
||||||
|
if (username === 'localadmin') {
|
||||||
|
const localUser = db.prepare(
|
||||||
|
'SELECT * FROM users WHERE username = ? AND is_active = 1'
|
||||||
|
).get(username) as { id: number; username: string; display_name: string; role: string; password_hash: string } | undefined
|
||||||
|
if (!localUser || !bcrypt.compareSync(password, localUser.password_hash)) {
|
||||||
|
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
|
||||||
|
}
|
||||||
|
userId = localUser.id
|
||||||
|
role = localUser.role
|
||||||
|
displayName = localUser.display_name || username
|
||||||
|
} else {
|
||||||
|
// 2. 其他用户:LLDAP 优先
|
||||||
|
const ldapResult = await ldapAuth(username, password)
|
||||||
|
|
||||||
|
if (ldapResult.success) {
|
||||||
|
// LDAP 认证成功 → 更新本地密码缓存 + 自动创建用户
|
||||||
|
displayName = ldapResult.displayName || username
|
||||||
|
const pwHash = bcrypt.hashSync(password, 10)
|
||||||
|
const existing = db.prepare(
|
||||||
|
'SELECT id, role FROM users WHERE username = ? AND is_active = 1'
|
||||||
|
).get(username) as { id: number; role: string } | undefined
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
db.prepare('UPDATE users SET password_hash = ?, display_name = ?, email = ? WHERE id = ?')
|
||||||
|
.run(pwHash, displayName, ldapResult.email || null, existing.id)
|
||||||
|
userId = existing.id
|
||||||
|
role = existing.role
|
||||||
|
} else {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO users (username, password_hash, display_name, email, role, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, 'viewer', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
|
||||||
|
).run(username, pwHash, displayName, ldapResult.email || null)
|
||||||
|
const created = db.prepare(
|
||||||
|
'SELECT id, role FROM users WHERE username = ?'
|
||||||
|
).get(username) as { id: number; role: string } | undefined
|
||||||
|
if (!created) return NextResponse.json({ error: '用户创建失败' }, { status: 500 })
|
||||||
|
userId = created.id
|
||||||
|
role = created.role
|
||||||
|
}
|
||||||
|
} else if (ldapResult.unreachable) {
|
||||||
|
// LLDAP 不可达 → 回退本地密码缓存
|
||||||
|
const localUser = db.prepare(
|
||||||
|
'SELECT * FROM users WHERE username = ? AND is_active = 1'
|
||||||
|
).get(username) as { id: number; username: string; display_name: string; role: string; password_hash: string } | undefined
|
||||||
|
if (!localUser || !bcrypt.compareSync(password, localUser.password_hash)) {
|
||||||
|
return NextResponse.json({ error: '认证服务不可用,且本地密码不匹配' }, { status: 401 })
|
||||||
|
}
|
||||||
|
userId = localUser.id
|
||||||
|
role = localUser.role
|
||||||
|
displayName = localUser.display_name || username
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 更新最后登录时间和活跃时间
|
||||||
|
db.prepare("UPDATE users SET last_login_at = datetime('now', '+8 hours'), last_active_at = datetime('now', '+8 hours') WHERE id = ?").run(userId)
|
||||||
|
|
||||||
|
// 4. 签发两个 cookie
|
||||||
|
const localToken = await createToken({ id: userId, username, display_name: displayName, role, permissions: [] })
|
||||||
|
const sharedToken = signSharedJwt({ username, displayName })
|
||||||
|
const sharedCfg = sharedCookieConfig()
|
||||||
|
|
||||||
|
const response = NextResponse.json({
|
||||||
|
user: { id: userId, username, display_name: displayName, role, permissions: getUserPermissions(role) },
|
||||||
|
})
|
||||||
|
response.cookies.set('session_issue', localToken, {
|
||||||
|
httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax',
|
||||||
|
maxAge: 7 * 24 * 60 * 60, path: '/',
|
||||||
|
})
|
||||||
|
response.cookies.set(sharedCfg.name, sharedToken, sharedCfg)
|
||||||
return response
|
return response
|
||||||
} catch (e) { console.error('Login error:', e); return NextResponse.json({ error: '登录失败' }, { status: 500 }) }
|
} catch (e) { console.error('Login error:', e); return NextResponse.json({ error: '登录失败' }, { status: 500 }) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
const r = NextResponse.json({ success: true })
|
const r = NextResponse.json({ success: true })
|
||||||
r.cookies.set('session_issue', '', { maxAge: 0, path: '/' })
|
r.cookies.set('session_issue', '', { maxAge: 0, path: '/' })
|
||||||
|
r.cookies.set('tlyq_session', '', { maxAge: 0, path: '/' })
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
try {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||||
return NextResponse.json({ user })
|
return NextResponse.json({ user })
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 @@
|
||||||
|
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,55 @@
|
||||||
|
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'
|
||||||
|
|
||||||
|
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)
|
||||||
|
return NextResponse.json({ report: updated })
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : '生成失败'
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
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'
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : '删除失败'
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
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 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' })
|
||||||
|
|
||||||
|
const downloadName = `reports_${new Date().toISOString().slice(0, 10)}.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,103 @@
|
||||||
|
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'
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, deleted: ids.length })
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : '批量删除失败'
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
|
|
||||||
const existing = db.prepare('SELECT * FROM roles WHERE id = ?').get(id)
|
const existing = db.prepare('SELECT * FROM roles WHERE id = ?').get(id) as any
|
||||||
if (!existing) return NextResponse.json({ error: '角色不存在' }, { status: 404 })
|
if (!existing) return NextResponse.json({ error: '角色不存在' }, { status: 404 })
|
||||||
|
|
||||||
const fields: string[] = []
|
const fields: string[] = []
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||||
if (fields.length > 0) {
|
if (fields.length > 0) {
|
||||||
fields.push('updated_by = ?')
|
fields.push('updated_by = ?')
|
||||||
values.push(user.id)
|
values.push(user.id)
|
||||||
fields.push("updated_at = datetime('now')")
|
fields.push("updated_at = datetime('now', '+8 hours')")
|
||||||
values.push(id)
|
values.push(id)
|
||||||
db.prepare(`UPDATE tickets SET ${fields.join(', ')} WHERE id = ?`).run(...values)
|
db.prepare(`UPDATE tickets SET ${fields.join(', ')} WHERE id = ?`).run(...values)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export async function PUT(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fields.length === 0) continue
|
if (fields.length === 0) continue
|
||||||
fields.push("updated_at = datetime('now')")
|
fields.push("updated_at = datetime('now', '+8 hours')")
|
||||||
fields.push('updated_by = ?')
|
fields.push('updated_by = ?')
|
||||||
values.push(user.id)
|
values.push(user.id)
|
||||||
values.push(item.id)
|
values.push(item.id)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getDb } from '@/lib/db'
|
import { getDb } from '@/lib/db'
|
||||||
import { initDatabase } from '@/lib/db-schema'
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
|
import { hasPermission } from '@/lib/permissions'
|
||||||
import { exportTicketsToExcel } from '@/lib/excel'
|
import { exportTicketsToExcel } from '@/lib/excel'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
|
@ -9,6 +10,7 @@ export async function GET(request: NextRequest) {
|
||||||
initDatabase()
|
initDatabase()
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||||
|
if (!hasPermission(user, 'tickets:export')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
|
||||||
const { searchParams } = request.nextUrl
|
const { searchParams } = request.nextUrl
|
||||||
const idsParam = searchParams.get('ids')
|
const idsParam = searchParams.get('ids')
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export async function POST(request: NextRequest) {
|
||||||
initDatabase()
|
initDatabase()
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||||
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
if (!hasPermission(user, 'tickets:import')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
|
||||||
const formData = await request.formData()
|
const formData = await request.formData()
|
||||||
const file = formData.get('file') as File | null
|
const file = formData.get('file') as File | null
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ export async function POST(request: NextRequest) {
|
||||||
initDatabase()
|
initDatabase()
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||||
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
if (!hasPermission(user, 'tickets:create')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,23 @@ import { initDatabase } from '@/lib/db-schema'
|
||||||
import { getCurrentUser, hashPassword } from '@/lib/auth'
|
import { getCurrentUser, hashPassword } from '@/lib/auth'
|
||||||
import { hasPermission } from '@/lib/permissions'
|
import { hasPermission } from '@/lib/permissions'
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
initDatabase()
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const db = getDb()
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT id, username, display_name, email, role, is_active, created_at, updated_at,
|
||||||
|
last_login_at,
|
||||||
|
CASE WHEN last_active_at IS NOT NULL AND datetime(last_active_at, '+5 minutes') > datetime('now', '+8 hours') THEN 1 ELSE 0 END AS is_online
|
||||||
|
FROM users WHERE id = ?
|
||||||
|
`).get(id) as Record<string, unknown> | undefined
|
||||||
|
if (!row) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
|
||||||
|
return NextResponse.json({ user: row })
|
||||||
|
}
|
||||||
|
|
||||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
try {
|
try {
|
||||||
initDatabase()
|
initDatabase()
|
||||||
|
|
@ -15,9 +32,14 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
|
|
||||||
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id)
|
const existing = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as { id: number; username: string } | undefined
|
||||||
if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
|
if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
|
||||||
|
|
||||||
|
// 禁止修改系统保留用户的角色
|
||||||
|
if (body.role && (existing.username === 'admin' || existing.username === 'localadmin')) {
|
||||||
|
return NextResponse.json({ error: '不能修改系统保留用户的角色' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
const fields: string[] = []
|
const fields: string[] = []
|
||||||
const values: unknown[] = []
|
const values: unknown[] = []
|
||||||
|
|
||||||
|
|
@ -28,12 +50,15 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||||
if (body.password) { fields.push('password_hash = ?'); values.push(hashPassword(body.password)) }
|
if (body.password) { fields.push('password_hash = ?'); values.push(hashPassword(body.password)) }
|
||||||
|
|
||||||
if (fields.length > 0) {
|
if (fields.length > 0) {
|
||||||
fields.push("updated_at = datetime('now')")
|
fields.push("updated_at = datetime('now', '+8 hours')")
|
||||||
values.push(id)
|
values.push(id)
|
||||||
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values)
|
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at, updated_at FROM users WHERE id = ?').get(id)
|
const updated = db.prepare(`SELECT id, username, display_name, email, role, is_active, created_at, updated_at,
|
||||||
|
last_login_at,
|
||||||
|
CASE WHEN last_active_at IS NOT NULL AND datetime(last_active_at, '+5 minutes') > datetime('now', '+8 hours') THEN 1 ELSE 0 END AS is_online
|
||||||
|
FROM users WHERE id = ?`).get(id)
|
||||||
return NextResponse.json({ user: updated })
|
return NextResponse.json({ user: updated })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '更新失败'
|
const msg = e instanceof Error ? e.message : '更新失败'
|
||||||
|
|
@ -52,8 +77,11 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
|
||||||
if (String(id) === String(user.id)) return NextResponse.json({ error: '不能删除自己' }, { status: 400 })
|
if (String(id) === String(user.id)) return NextResponse.json({ error: '不能删除自己' }, { status: 400 })
|
||||||
|
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id)
|
const existing = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as { id: number; username: string } | undefined
|
||||||
if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
|
if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
|
||||||
|
if (existing.username === 'admin' || existing.username === 'localadmin') {
|
||||||
|
return NextResponse.json({ error: '不能删除系统保留用户' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
db.prepare('DELETE FROM users WHERE id = ?').run(id)
|
db.prepare('DELETE FROM users WHERE id = ?').run(id)
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,10 @@ export async function GET() {
|
||||||
if (!hasPermission(user, 'users:read')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
if (!hasPermission(user, 'users:read')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const users = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at, updated_at FROM users ORDER BY id').all()
|
const users = db.prepare(`SELECT id, username, display_name, email, role, is_active, created_at, updated_at,
|
||||||
|
last_login_at,
|
||||||
|
CASE WHEN last_active_at IS NOT NULL AND datetime(last_active_at, '+5 minutes') > datetime('now', '+8 hours') THEN 1 ELSE 0 END AS is_online
|
||||||
|
FROM users ORDER BY id`).all()
|
||||||
return NextResponse.json({ users })
|
return NextResponse.json({ users })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : '查询失败'
|
const msg = e instanceof Error ? e.message : '查询失败'
|
||||||
|
|
@ -41,7 +44,7 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
const hash = hashPassword(password)
|
const hash = hashPassword(password)
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
'INSERT INTO users (username, password_hash, display_name, email, role) VALUES (?, ?, ?, ?, ?)'
|
"INSERT INTO users (username, password_hash, display_name, email, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
|
||||||
).run(username, hash, display_name, email || null, role || 'viewer')
|
).run(username, hash, display_name, email || null, role || 'viewer')
|
||||||
|
|
||||||
const newUser = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at FROM users WHERE id = ?').get(result.lastInsertRowid)
|
const newUser = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at FROM users WHERE id = ?').get(result.lastInsertRowid)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
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'
|
||||||
|
|
||||||
|
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++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ synced, failed, total: users.length })
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,5 @@
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
|
||||||
import { initDatabase } from '@/lib/db-schema'
|
|
||||||
|
|
||||||
export default async function Home() {
|
export default function Home() {
|
||||||
initDatabase()
|
redirect('/dashboard')
|
||||||
const user = await getCurrentUser()
|
|
||||||
if (user) redirect('/dashboard')
|
|
||||||
else redirect('/login')
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,42 +5,53 @@ 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 } from 'lucide-react'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: '/dashboard', label: '仪表盘', icon: LayoutDashboard },
|
{ href: '/dashboard', label: '仪表盘', icon: LayoutDashboard, perm: null },
|
||||||
{ href: '/tickets/pending', label: '待办工单', icon: Clock },
|
{ href: '/tickets/pending', label: '待办工单', icon: Clock, perm: 'tickets:read' },
|
||||||
{ href: '/tickets/completed', label: '已办工单', icon: CheckCircle },
|
{ href: '/tickets/completed', label: '已办工单', icon: CheckCircle, perm: 'tickets:read' },
|
||||||
{ href: '/tickets/create', label: '手动建单', icon: PlusSquare },
|
{ href: '/tickets/create', label: '手动建单', icon: PlusSquare, perm: 'tickets:create' },
|
||||||
{ href: '/tickets/import', label: '导入工单', icon: Upload },
|
{ href: '/tickets/import', label: '导入工单', icon: Upload, perm: 'tickets:import' },
|
||||||
{ href: '/reports', label: '报告管理', icon: FileText },
|
{ href: '/reports', label: '报告管理', icon: FileText, perm: 'reports:read' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const settingsItems = [
|
const settingsItems = [
|
||||||
{ href: '/settings/users', label: '用户管理', icon: Users },
|
{ href: '/settings/users', label: '用户管理', icon: Users, perm: 'users:read' },
|
||||||
{ href: '/settings/roles', label: '角色权限', icon: Shield },
|
{ href: '/settings/roles', label: '角色权限', icon: Shield, perm: 'roles:read' },
|
||||||
{ href: '/settings/api-keys', label: 'API Key', icon: Key },
|
{ href: '/settings/api-keys', label: 'API Key', icon: Key, perm: 'api-keys:read' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function hasAnyAdminPerm(permissions: string[]): boolean {
|
||||||
|
return permissions.includes('*') || permissions.some(p => p.startsWith('users:') || p.startsWith('roles:') || p.startsWith('api-keys:'))
|
||||||
|
}
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const [isAdmin, setIsAdmin] = useState(false)
|
const [permissions, setPermissions] = useState<string[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/auth/me')
|
fetch('/api/auth/me')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(u => { if (u.user?.role === 'admin') setIsAdmin(true) })
|
.then(u => { if (u.user?.permissions) setPermissions(u.user.permissions) })
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const canSee = (perm: string | null) => {
|
||||||
|
if (perm === null) return true
|
||||||
|
if (permissions.includes('*')) return true
|
||||||
|
return permissions.includes(perm)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="fixed left-0 top-0 bottom-0 w-60 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex flex-col z-40">
|
<aside className="fixed left-0 top-0 bottom-0 w-60 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex flex-col z-40">
|
||||||
<div className="h-14 flex items-center px-5 border-b border-slate-200 dark:border-slate-800">
|
<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>
|
<span className="text-lg font-semibold text-blue-600 dark:text-blue-400">IT工单跟踪系统</span>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 py-3 px-3 space-y-1 overflow-y-auto">
|
<nav className="flex-1 py-3 px-3 space-y-1 overflow-y-auto">
|
||||||
{navItems.map((item) => {
|
{navItems.filter(item => canSee(item.perm)).map((item) => {
|
||||||
const isActive = pathname === item.href || pathname.startsWith(item.href + '/')
|
const isActive = pathname === item.href || pathname.startsWith(item.href + '/')
|
||||||
const Icon = item.icon
|
const Icon = item.icon
|
||||||
return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>)
|
return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-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 && (
|
{permissions.includes('*') && (
|
||||||
<Link
|
<Link
|
||||||
href="/tickets/all"
|
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'}`}
|
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'}`}
|
||||||
|
|
@ -48,16 +59,18 @@ export default function Sidebar() {
|
||||||
<List size={18} />全部工单
|
<List size={18} />全部工单
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
{hasAnyAdminPerm(permissions) && (
|
||||||
<div className="pt-3 border-t border-slate-200 dark:border-slate-800 mt-3">
|
<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">
|
<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} />系统设置
|
<Settings size={14} />系统设置
|
||||||
</div>
|
</div>
|
||||||
{settingsItems.map((item) => {
|
{settingsItems.filter(item => canSee(item.perm)).map((item) => {
|
||||||
const isActive = pathname === item.href
|
const isActive = pathname === item.href
|
||||||
const Icon = item.icon
|
const Icon = item.icon
|
||||||
return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>)
|
return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,10 @@ interface TopBarProps { user: { username: string; display_name: string; role: st
|
||||||
export default function TopBar({ user }: TopBarProps) {
|
export default function TopBar({ user }: TopBarProps) {
|
||||||
const { theme, toggleTheme } = useTheme()
|
const { theme, toggleTheme } = useTheme()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const handleLogout = async () => { await fetch('/api/auth/logout', { method: 'POST' }); router.push('/login'); router.refresh() }
|
const handleLogout = async () => {
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST' })
|
||||||
|
router.push('/login'); router.refresh()
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<header className="fixed top-0 left-60 right-0 h-14 bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between px-6 z-30">
|
<header className="fixed top-0 left-60 right-0 h-14 bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between px-6 z-30">
|
||||||
<div />
|
<div />
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client'
|
'use client'
|
||||||
import { useState, useEffect, useCallback, useRef, Suspense } from 'react'
|
import { useState, useEffect, useCallback, useRef, Suspense } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
|
||||||
import { Button, Badge, Pagination } from '@/components/ui'
|
import { Button, Badge, Pagination } from '@/components/ui'
|
||||||
import { Search, Download, Eye, Pencil, Trash2, Filter, ArrowUpDown, ChevronsUpDown, ChevronUp, ChevronDown, Check, X, ExternalLink } from 'lucide-react'
|
import { Search, Download, Eye, Pencil, Trash2, Filter, ArrowUpDown, ChevronsUpDown, ChevronUp, ChevronDown, Check, X, ExternalLink } from 'lucide-react'
|
||||||
import SelectWithInput from '@/components/ui/SelectWithInput'
|
import SelectWithInput from '@/components/ui/SelectWithInput'
|
||||||
|
|
@ -148,6 +148,7 @@ interface TicketListInnerProps {
|
||||||
function TicketListInner({ onPaginationChange, defaultStatusFilter, showSlaColumn, showActions = true, hideDefaultFilterChips }: TicketListInnerProps) {
|
function TicketListInner({ onPaginationChange, defaultStatusFilter, showSlaColumn, showActions = true, hideDefaultFilterChips }: TicketListInnerProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
const pathname = usePathname()
|
||||||
const [tickets, setTickets] = useState<Ticket[]>([])
|
const [tickets, setTickets] = useState<Ticket[]>([])
|
||||||
const [pagination, setPagination] = useState({ page: 1, pageSize: 20, total: 0, totalPages: 0 })
|
const [pagination, setPagination] = useState({ page: 1, pageSize: 20, total: 0, totalPages: 0 })
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
@ -165,8 +166,16 @@ function TicketListInner({ onPaginationChange, defaultStatusFilter, showSlaColum
|
||||||
const [dateFilter, setDateFilter] = useState<Record<string, { start: string; end: string }>>({})
|
const [dateFilter, setDateFilter] = useState<Record<string, { start: string; end: string }>>({})
|
||||||
const [fieldOptions, setFieldOptions] = useState<Record<string, string[]>>({})
|
const [fieldOptions, setFieldOptions] = useState<Record<string, string[]>>({})
|
||||||
const [ticketNoFilter, setTicketNoFilter] = useState('')
|
const [ticketNoFilter, setTicketNoFilter] = useState('')
|
||||||
|
const [permissions, setPermissions] = useState<string[]>([])
|
||||||
const filterDropRef = useRef<HTMLDivElement>(null)
|
const filterDropRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/auth/me')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(u => { if (u.user?.permissions) setPermissions(u.user.permissions) })
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
// 列宽拖拽调整
|
// 列宽拖拽调整
|
||||||
const [colWidths, setColWidths] = useState<Record<string, number>>({})
|
const [colWidths, setColWidths] = useState<Record<string, number>>({})
|
||||||
const [resizingCol, setResizingCol] = useState<string | null>(null)
|
const [resizingCol, setResizingCol] = useState<string | null>(null)
|
||||||
|
|
@ -428,7 +437,9 @@ function TicketListInner({ onPaginationChange, defaultStatusFilter, showSlaColum
|
||||||
<Button variant="secondary" size="sm" onClick={() => { setPage(1); fetchTickets() }}>搜索</Button>
|
<Button variant="secondary" size="sm" onClick={() => { setPage(1); fetchTickets() }}>搜索</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{(permissions.includes('*') || permissions.includes('tickets:export')) && (
|
||||||
<Button variant="secondary" size="sm" onClick={handleExport}><Download size={14} />导出</Button>
|
<Button variant="secondary" size="sm" onClick={handleExport}><Download size={14} />导出</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -652,12 +663,12 @@ function TicketListInner({ onPaginationChange, defaultStatusFilter, showSlaColum
|
||||||
<td className="px-4 py-3 font-medium"><Link href={`/tickets/${t.id}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.id}</Link></td>
|
<td className="px-4 py-3 font-medium"><Link href={`/tickets/${t.id}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.id}</Link></td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{t.device_ip ? (
|
{t.device_ip ? (
|
||||||
<Link href={`/tickets?device_ip=${encodeURIComponent(t.device_ip)}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.device_ip}</Link>
|
<Link href={`${pathname}?device_ip=${encodeURIComponent(t.device_ip)}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.device_ip}</Link>
|
||||||
) : '-'}
|
) : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{t.device_name ? (
|
{t.device_name ? (
|
||||||
<Link href={`/tickets?device_name=${encodeURIComponent(t.device_name)}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.device_name}</Link>
|
<Link href={`${pathname}?device_name=${encodeURIComponent(t.device_name)}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.device_name}</Link>
|
||||||
) : '-'}
|
) : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 max-w-xs truncate">{t.content || '-'}</td>
|
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 max-w-xs truncate">{t.content || '-'}</td>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,8 @@
|
||||||
'use client'
|
'use client'
|
||||||
import { ButtonHTMLAttributes, InputHTMLAttributes, SelectHTMLAttributes, ReactNode } from 'react'
|
import { InputHTMLAttributes, SelectHTMLAttributes, ReactNode } from 'react'
|
||||||
|
|
||||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; size?: 'sm' | 'md' | 'lg'; loading?: boolean }
|
export { default as Modal } from './Modal'
|
||||||
export function Button({ variant = 'primary', size = 'md', className = '', children, loading, disabled, ...props }: ButtonProps) {
|
export { default as Button } from './Button'
|
||||||
const base = 'inline-flex items-center justify-center font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500/50 disabled:opacity-50 disabled:cursor-not-allowed'
|
|
||||||
const v = { primary: 'bg-blue-600 text-white hover:bg-blue-700 shadow-sm', secondary: 'bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700', danger: 'bg-red-600 text-white hover:bg-red-700', ghost: 'text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800' }
|
|
||||||
const s = { sm: 'px-3 py-1.5 text-sm', md: 'px-4 py-2 text-sm', lg: 'px-6 py-2.5 text-base' }
|
|
||||||
return <button className={`${base} ${v[variant]} ${s[size]} ${className}`} disabled={disabled || loading} {...props}>{loading ? <span className="mr-2 inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" /> : null}{children}</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> { label?: string; error?: string }
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> { label?: string; error?: string }
|
||||||
export function Input({ label, error, className = '', ...props }: InputProps) {
|
export function Input({ label, error, className = '', ...props }: InputProps) {
|
||||||
|
|
@ -19,15 +14,9 @@ export function Select({ label, options, className = '', ...props }: SelectProps
|
||||||
return (<div className="space-y-1">{label && <label className="block text-sm font-medium text-slate-700 dark:text-slate-300">{label}</label>}<select className={`w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition-colors text-sm ${className}`} {...props}>{options.map((o) => (<option key={o.value} value={o.value}>{o.label}</option>))}</select></div>)
|
return (<div className="space-y-1">{label && <label className="block text-sm font-medium text-slate-700 dark:text-slate-300">{label}</label>}<select className={`w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition-colors text-sm ${className}`} {...props}>{options.map((o) => (<option key={o.value} value={o.value}>{o.label}</option>))}</select></div>)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModalProps { open: boolean; onClose: () => void; title: string; children: ReactNode }
|
|
||||||
export function Modal({ open, onClose, title, children }: ModalProps) {
|
|
||||||
if (!open) return null
|
|
||||||
return (<div className="fixed inset-0 z-50 flex items-center justify-center"><div className="fixed inset-0 bg-black/50" onClick={onClose} /><div className="relative bg-white dark:bg-slate-900 rounded-xl shadow-xl border border-slate-200 dark:border-slate-800 w-full max-w-lg mx-4 max-h-[90vh] overflow-auto"><div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-800"><h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{title}</h3><button onClick={onClose} className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 text-xl leading-none">×</button></div><div className="p-6">{children}</div></div></div>)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TableProps { headers: string[]; children: ReactNode }
|
interface TableProps { headers: string[]; children: ReactNode }
|
||||||
export function Table({ headers, children }: TableProps) {
|
export function Table({ headers, children }: TableProps) {
|
||||||
return (<div className="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700"><table className="w-full text-sm"><thead className="bg-slate-50 dark:bg-slate-800"><tr>{headers.map((h) => (<th key={h} className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400">{h}</th>))}</tr></thead><tbody className="divide-y divide-slate-200 dark:divide-slate-700">{children}</tbody></table></div>)
|
return (<div className="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700"><table className="w-full text-sm"><thead className="bg-slate-50 dark:bg-slate-800"><tr>{headers.map((h) => (<th key={h} className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-300">{h}</th>))}</tr></thead><tbody className="divide-y divide-slate-200 dark:divide-slate-700">{children}</tbody></table></div>)
|
||||||
}
|
}
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
const ASSETS_API_URL = process.env.ASSETS_API_URL || 'http://localhost:5177/api'
|
const ASSETS_API_URL = process.env.ASSETS_API_URL || 'http://localhost:6177/api'
|
||||||
const ASSETS_API_KEY = process.env.ASSETS_API_KEY || ''
|
const ASSETS_API_KEY = process.env.ASSETS_API_KEY || ''
|
||||||
|
|
||||||
export interface Asset {
|
export interface Asset {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,64 @@ import { createToken, verifyToken, type UserPayload } from './jwt'
|
||||||
|
|
||||||
export { createToken, verifyToken, type UserPayload }
|
export { createToken, verifyToken, type UserPayload }
|
||||||
|
|
||||||
|
import { verifySharedJwt } from './jwt-shared'
|
||||||
|
import { ldapUserExists, ldapGetUserInfo } from './ldap'
|
||||||
|
import { getUserPermissions } from './permissions'
|
||||||
|
|
||||||
export async function getCurrentUser(): Promise<UserPayload | null> {
|
export async function getCurrentUser(): Promise<UserPayload | null> {
|
||||||
const cookieStore = await cookies()
|
const cookieStore = await cookies()
|
||||||
|
|
||||||
|
// 优先 tlyq_session(共享 JWT,LDAP 用户)
|
||||||
|
const sharedToken = cookieStore.get('tlyq_session')?.value
|
||||||
|
if (sharedToken) {
|
||||||
|
const sharedPayload = verifySharedJwt(sharedToken)
|
||||||
|
if (sharedPayload) {
|
||||||
|
// Q1: 检查 LLDAP 中用户是否仍存在(已删除则强制退出)
|
||||||
|
if (!(await ldapUserExists(sharedPayload.username))) {
|
||||||
|
cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/' })
|
||||||
|
cookieStore.set('session_issue', '', { maxAge: 0, path: '/' })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const db = getDb()
|
||||||
|
const row = db.prepare(
|
||||||
|
'SELECT id, username, display_name, role, email FROM users WHERE username = ? AND is_active = 1'
|
||||||
|
).get(sharedPayload.username) as (UserPayload & { email: string | null }) | undefined
|
||||||
|
if (row) {
|
||||||
|
if (!row.email) {
|
||||||
|
const info = await ldapGetUserInfo(sharedPayload.username)
|
||||||
|
if (info?.email) db.prepare('UPDATE users SET email = ? WHERE id = ?').run(info.email, row.id)
|
||||||
|
}
|
||||||
|
db.prepare("UPDATE users SET last_login_at = datetime('now', '+8 hours'), last_active_at = datetime('now', '+8 hours') WHERE id = ?").run(row.id)
|
||||||
|
row.permissions = getUserPermissions(row.role)
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
// SSO 免登录:LLDAP 验证通过但本地无记录 → 自动创建(viewer 角色)
|
||||||
|
const ldapInfo = await ldapGetUserInfo(sharedPayload.username)
|
||||||
|
const displayName = ldapInfo?.displayName || sharedPayload.displayName
|
||||||
|
const email = ldapInfo?.email ?? null
|
||||||
|
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(sharedPayload.username, displayName, email)
|
||||||
|
const newRow = db.prepare(
|
||||||
|
'SELECT id, username, display_name, role FROM users WHERE username = ? AND is_active = 1'
|
||||||
|
).get(sharedPayload.username) as UserPayload | undefined
|
||||||
|
if (newRow) {
|
||||||
|
newRow.permissions = getUserPermissions(newRow.role)
|
||||||
|
return newRow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退 session_issue(本地 JWT,admin 账号或紧急绕过)
|
||||||
const token = cookieStore.get('session_issue')?.value
|
const token = cookieStore.get('session_issue')?.value
|
||||||
if (!token) return null
|
if (!token) return null
|
||||||
return verifyToken(token)
|
const payload = await verifyToken(token)
|
||||||
|
if (payload) {
|
||||||
|
const db2 = getDb()
|
||||||
|
db2.prepare("UPDATE users SET last_login_at = datetime('now', '+8 hours'), last_active_at = datetime('now', '+8 hours') WHERE id = ?").run(payload.id)
|
||||||
|
payload.permissions = getUserPermissions(payload.role)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login(username: string, password: string) {
|
export async function login(username: string, password: string) {
|
||||||
|
|
@ -18,7 +71,7 @@ export async function login(username: string, password: string) {
|
||||||
const user = db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username) as any
|
const user = db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username) as any
|
||||||
if (!user) return null
|
if (!user) return null
|
||||||
if (!bcrypt.compareSync(password, user.password_hash)) return null
|
if (!bcrypt.compareSync(password, user.password_hash)) return null
|
||||||
const payload: UserPayload = { id: user.id, username: user.username, display_name: user.display_name, role: user.role }
|
const payload: UserPayload = { id: user.id, username: user.username, display_name: user.display_name, role: user.role, permissions: getUserPermissions(user.role) }
|
||||||
return { token: await createToken(payload), user: payload }
|
return { token: await createToken(payload), user: payload }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,6 +105,6 @@ export function verifyApiKey(key: string): ApiKeyInfo | null {
|
||||||
.get(keyHash) as { id: number; name: string; permissions: string; expires_at: string | null } | undefined
|
.get(keyHash) as { id: number; name: string; permissions: string; expires_at: string | null } | undefined
|
||||||
if (!row) return null
|
if (!row) return null
|
||||||
if (row.expires_at && new Date(row.expires_at) < new Date()) return null
|
if (row.expires_at && new Date(row.expires_at) < new Date()) return null
|
||||||
db.prepare("UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?").run(row.id)
|
db.prepare("UPDATE api_keys SET last_used_at = datetime('now', '+8 hours') WHERE id = ?").run(row.id)
|
||||||
return { id: row.id, name: row.name, permissions: JSON.parse(row.permissions) }
|
return { id: row.id, name: row.name, permissions: JSON.parse(row.permissions) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,14 @@ import bcrypt from 'bcryptjs'
|
||||||
export function initDatabase(): void {
|
export function initDatabase(): void {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const schema = [
|
const schema = [
|
||||||
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, display_name TEXT NOT NULL, email TEXT, role TEXT NOT NULL DEFAULT 'viewer', is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')));",
|
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, display_name TEXT NOT NULL, email TEXT, role TEXT NOT NULL DEFAULT 'viewer', is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')), updated_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')));",
|
||||||
"CREATE TABLE IF NOT EXISTS roles (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, permissions TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL DEFAULT (datetime('now')));",
|
"CREATE TABLE IF NOT EXISTS roles (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, permissions TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')));",
|
||||||
"CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')));",
|
"CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')));",
|
||||||
"CREATE TABLE IF NOT EXISTS tickets (id INTEGER PRIMARY KEY, device_ip TEXT, device_sn TEXT, device_name TEXT, content TEXT, assign_time TEXT, close_time TEXT, duration_minutes INTEGER, availability REAL, process_summary TEXT, conclusion TEXT, fault_category TEXT, fault_subcategory TEXT, parts_replaced TEXT, parts_name TEXT, current_status TEXT NOT NULL DEFAULT 'open', counted_in_sla INTEGER NOT NULL DEFAULT 1, responsibility TEXT, created_by INTEGER REFERENCES users(id), updated_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')));",
|
"CREATE TABLE IF NOT EXISTS tickets (id INTEGER PRIMARY KEY, device_ip TEXT, device_sn TEXT, device_name TEXT, content TEXT, assign_time TEXT, close_time TEXT, duration_minutes INTEGER, availability REAL, process_summary TEXT, conclusion TEXT, fault_category TEXT, fault_subcategory TEXT, parts_replaced TEXT, parts_name TEXT, current_status TEXT NOT NULL DEFAULT 'open', counted_in_sla INTEGER NOT NULL DEFAULT 1, responsibility TEXT, created_by INTEGER REFERENCES users(id), updated_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')), updated_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')));",
|
||||||
"CREATE TABLE IF NOT EXISTS ticket_steps (id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, step_order INTEGER NOT NULL, time_node TEXT, handler TEXT, description TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')));",
|
"CREATE TABLE IF NOT EXISTS ticket_steps (id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, step_order INTEGER NOT NULL, time_node TEXT, handler TEXT, description TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')));",
|
||||||
"CREATE TABLE IF NOT EXISTS reports (id INTEGER PRIMARY KEY AUTOINCREMENT, report_type TEXT NOT NULL, period_start TEXT, period_end TEXT, format TEXT NOT NULL DEFAULT 'pdf', file_path TEXT, file_name TEXT, status TEXT NOT NULL DEFAULT 'pending', error_message TEXT, created_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')));",
|
"CREATE TABLE IF NOT EXISTS reports (id INTEGER PRIMARY KEY AUTOINCREMENT, report_type TEXT NOT NULL, period_start TEXT, period_end TEXT, format TEXT NOT NULL DEFAULT 'pdf', file_path TEXT, file_name TEXT, status TEXT NOT NULL DEFAULT 'pending', error_message TEXT, created_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')));",
|
||||||
"CREATE TABLE IF NOT EXISTS audit_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id), action TEXT NOT NULL, entity_type TEXT NOT NULL, entity_id INTEGER, details TEXT, ip_address TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')));",
|
"CREATE TABLE IF NOT EXISTS audit_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id), action TEXT NOT NULL, entity_type TEXT NOT NULL, entity_id INTEGER, details TEXT, ip_address TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')));",
|
||||||
"CREATE TABLE IF NOT EXISTS api_keys (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, key_hash TEXT NOT NULL, permissions TEXT NOT NULL DEFAULT '[\"tickets:read\"]', last_used_at TEXT, expires_at TEXT, is_active INTEGER NOT NULL DEFAULT 1, created_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')));"
|
"CREATE TABLE IF NOT EXISTS api_keys (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, key_hash TEXT NOT NULL, permissions TEXT NOT NULL DEFAULT '[\"tickets:read\"]', last_used_at TEXT, expires_at TEXT, is_active INTEGER NOT NULL DEFAULT 1, created_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')));"
|
||||||
]
|
]
|
||||||
for (const sql of schema) db.exec(sql)
|
for (const sql of schema) db.exec(sql)
|
||||||
|
|
||||||
|
|
@ -29,6 +29,10 @@ export function initDatabase(): void {
|
||||||
db.prepare("UPDATE tickets SET ticket_type = 'OEM维修' WHERE ticket_type IS NULL AND fault_category IN ('硬件故障', '网络故障', '存储故障', '电源故障')").run()
|
db.prepare("UPDATE tickets SET ticket_type = 'OEM维修' WHERE ticket_type IS NULL AND fault_category IN ('硬件故障', '网络故障', '存储故障', '电源故障')").run()
|
||||||
} catch { /* 迁移失败则保持原样 */ }
|
} catch { /* 迁移失败则保持原样 */ }
|
||||||
|
|
||||||
|
// 迁移:添加 last_login_at 和 last_active_at 列
|
||||||
|
try { db.exec('ALTER TABLE users ADD COLUMN last_login_at TEXT') } catch { /* 列已存在 */ }
|
||||||
|
try { db.exec('ALTER TABLE users ADD COLUMN last_active_at TEXT') } catch { /* 列已存在 */ }
|
||||||
|
|
||||||
// 迁移:metadata 列(报告元数据 JSON)
|
// 迁移:metadata 列(报告元数据 JSON)
|
||||||
try { db.exec('ALTER TABLE reports ADD COLUMN metadata TEXT') } catch { /* 已存在 */ }
|
try { db.exec('ALTER TABLE reports ADD COLUMN metadata TEXT') } catch { /* 已存在 */ }
|
||||||
|
|
||||||
|
|
@ -45,12 +49,18 @@ export function initDatabase(): void {
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
const defaultPassword = process.env.ADMIN_PASSWORD || 'admin123'
|
const defaultPassword = process.env.ADMIN_PASSWORD || 'admin123'
|
||||||
const hash = bcrypt.hashSync(defaultPassword, 10)
|
const hash = bcrypt.hashSync(defaultPassword, 10)
|
||||||
db.prepare('INSERT INTO users (username, password_hash, display_name, role) VALUES (?, ?, ?, ?)').run('admin', hash, '系统管理员', 'admin')
|
db.prepare("INSERT INTO users (username, password_hash, display_name, role, created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))").run('admin', hash, '系统管理员', 'admin')
|
||||||
|
}
|
||||||
|
const existingLocalAdmin = db.prepare('SELECT id FROM users WHERE username = ?').get('localadmin')
|
||||||
|
if (!existingLocalAdmin) {
|
||||||
|
const localPassword = process.env.LOCALADMIN_PASSWORD || 'admin123'
|
||||||
|
const localHash = bcrypt.hashSync(localPassword, 10)
|
||||||
|
db.prepare("INSERT INTO users (username, password_hash, display_name, role, created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))").run('localadmin', localHash, '本地管理员', 'admin')
|
||||||
}
|
}
|
||||||
const roles = [
|
const roles = [
|
||||||
{ name: 'admin', display_name: '管理员', permissions: '["*"]' },
|
{ name: 'admin', display_name: '管理员', permissions: '["*"]' },
|
||||||
{ name: 'operator', display_name: '运维人员', permissions: '["tickets:read","tickets:write","reports:read"]' },
|
{ name: 'operator', display_name: '运维人员', permissions: '["tickets:read","tickets:create","tickets:import","tickets:export","tickets:write","reports:read","reports:download","reports:create"]' },
|
||||||
{ name: 'viewer', display_name: '查看者', permissions: '["tickets:read","reports:read"]' },
|
{ name: 'viewer', display_name: '查看者', permissions: '["tickets:read","tickets:export","reports:read","reports:download"]' },
|
||||||
]
|
]
|
||||||
for (const r of roles) {
|
for (const r of roles) {
|
||||||
const ex = db.prepare('SELECT id FROM roles WHERE name = ?').get(r.name)
|
const ex = db.prepare('SELECT id FROM roles WHERE name = ?').get(r.name)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-same-across-all-sites'
|
||||||
|
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || ''
|
||||||
|
|
||||||
|
export interface SharedSession {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
iat: number
|
||||||
|
exp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64url(str: string): string {
|
||||||
|
return Buffer.from(str).toString('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signSharedJwt(
|
||||||
|
payload: { username: string; displayName: string },
|
||||||
|
expiresIn: number = 7 * 24 * 60 * 60
|
||||||
|
): string {
|
||||||
|
const header = { alg: 'HS256', typ: 'JWT' }
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const body = { ...payload, iat: now, exp: now + expiresIn }
|
||||||
|
const segments = [base64url(JSON.stringify(header)), base64url(JSON.stringify(body))]
|
||||||
|
const signingInput = segments.join('.')
|
||||||
|
segments.push(
|
||||||
|
crypto.createHmac('sha256', JWT_SECRET).update(signingInput).digest('base64url')
|
||||||
|
)
|
||||||
|
return segments.join('.')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifySharedJwt(token: string): SharedSession | null {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.')
|
||||||
|
if (parts.length !== 3) return null
|
||||||
|
const signingInput = parts.slice(0, 2).join('.')
|
||||||
|
const expectedSig = crypto.createHmac('sha256', JWT_SECRET)
|
||||||
|
.update(signingInput).digest('base64url')
|
||||||
|
if (parts[2] !== expectedSig) return null
|
||||||
|
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString())
|
||||||
|
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null
|
||||||
|
return {
|
||||||
|
username: payload.username,
|
||||||
|
displayName: payload.displayName,
|
||||||
|
iat: payload.iat,
|
||||||
|
exp: payload.exp,
|
||||||
|
}
|
||||||
|
} catch { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sharedCookieConfig(maxAge: number = 7 * 24 * 60 * 60) {
|
||||||
|
return {
|
||||||
|
name: 'tlyq_session',
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
domain: COOKIE_DOMAIN,
|
||||||
|
path: '/',
|
||||||
|
maxAge,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ export interface UserPayload {
|
||||||
username: string
|
username: string
|
||||||
display_name: string
|
display_name: string
|
||||||
role: string
|
role: string
|
||||||
|
permissions: string[] // 用户权限列表
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode Uint8Array to base64url
|
// Encode Uint8Array to base64url
|
||||||
|
|
@ -72,7 +73,7 @@ export async function verifyToken(token: string): Promise<UserPayload | null> {
|
||||||
const payload = JSON.parse(base64urlToStr(parts[1]))
|
const payload = JSON.parse(base64urlToStr(parts[1]))
|
||||||
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null
|
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null
|
||||||
if (payload.id == null) return null
|
if (payload.id == null) return null
|
||||||
return { id: payload.id, username: payload.username, display_name: payload.display_name, role: payload.role }
|
return { id: payload.id, username: payload.username, display_name: payload.display_name, role: payload.role, permissions: payload.permissions || [] }
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { Client, InvalidCredentialsError } from 'ldapts'
|
||||||
|
import { execFileSync } from 'child_process'
|
||||||
|
|
||||||
|
const LDAP_URL = process.env.LDAP_URL || 'ldap://localhost:3890'
|
||||||
|
const LDAP_BASE_DN = process.env.LDAP_BASE_DN || 'dc=tlyq,dc=ai'
|
||||||
|
|
||||||
|
// 运行时从 LLDAP 容器动态获取 admin 密码,避免明文存于多个 .env
|
||||||
|
// 需要容器挂载 /var/run/docker.sock
|
||||||
|
function getLdapAdminPassword(): string {
|
||||||
|
try {
|
||||||
|
return execFileSync('docker', ['exec', 'lldap', 'printenv', 'LLDAP_ADMIN_PASSWORD'],
|
||||||
|
{ timeout: 3000 }).toString().trim()
|
||||||
|
} catch { return 'admin123' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LdapResult {
|
||||||
|
success: boolean
|
||||||
|
unreachable: boolean
|
||||||
|
username?: string
|
||||||
|
displayName?: string
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ldapAuth(
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
): Promise<LdapResult> {
|
||||||
|
const userDn = `uid=${username},ou=people,${LDAP_BASE_DN}`
|
||||||
|
const client = new Client({ url: LDAP_URL, timeout: 5000 })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.bind(userDn, password)
|
||||||
|
try {
|
||||||
|
const { searchEntries } = await client.search(LDAP_BASE_DN, {
|
||||||
|
scope: 'sub',
|
||||||
|
filter: `(uid=${username})`,
|
||||||
|
attributes: ['displayName', 'mail'],
|
||||||
|
timeLimit: 3,
|
||||||
|
})
|
||||||
|
const entry = searchEntries[0] as any
|
||||||
|
const displayName = entry?.displayName || username
|
||||||
|
const email = entry?.mail || null
|
||||||
|
return { success: true, unreachable: false, username, displayName, email }
|
||||||
|
} catch {
|
||||||
|
return { success: true, unreachable: false, username, displayName: username }
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof InvalidCredentialsError) {
|
||||||
|
return { success: false, unreachable: false }
|
||||||
|
}
|
||||||
|
return { success: false, unreachable: true }
|
||||||
|
} finally {
|
||||||
|
await client.unbind()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 LLDAP 获取用户信息(displayName + email),不可达返回 null
|
||||||
|
export async function ldapGetUserInfo(username: string): Promise<{ displayName: string; email: string | null } | null> {
|
||||||
|
const adminDn = process.env.LDAP_ADMIN_DN || 'uid=admin,ou=people,dc=tlyq,dc=ai'
|
||||||
|
const adminPass = getLdapAdminPassword()
|
||||||
|
const client = new Client({ url: LDAP_URL, timeout: 5000 })
|
||||||
|
try {
|
||||||
|
await client.bind(adminDn, adminPass)
|
||||||
|
const { searchEntries } = await client.search(LDAP_BASE_DN, {
|
||||||
|
scope: 'sub', filter: `(uid=${username})`, attributes: ['displayName', 'mail'], timeLimit: 3,
|
||||||
|
})
|
||||||
|
const entry = searchEntries[0] as any
|
||||||
|
return entry ? { displayName: entry.displayName || username, email: entry.mail || null } : null
|
||||||
|
} catch { return null }
|
||||||
|
finally { await client.unbind() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Q1: 检查 LLDAP 中用户是否存在(用 admin bind 搜索,不在/不可达均返回 true 保证容错)
|
||||||
|
export async function ldapUserExists(username: string): Promise<boolean> {
|
||||||
|
const adminDn = process.env.LDAP_ADMIN_DN || 'uid=admin,ou=people,dc=tlyq,dc=ai'
|
||||||
|
const adminPass = getLdapAdminPassword()
|
||||||
|
const client = new Client({ url: LDAP_URL, timeout: 5000 })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.bind(adminDn, adminPass)
|
||||||
|
const { searchEntries } = await client.search(LDAP_BASE_DN, {
|
||||||
|
scope: 'sub', filter: `(uid=${username})`, timeLimit: 3,
|
||||||
|
})
|
||||||
|
return searchEntries.length > 0
|
||||||
|
} catch {
|
||||||
|
return true // LLDAP 不可达 → 不阻断,容错放行
|
||||||
|
} finally {
|
||||||
|
await client.unbind()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,3 +31,15 @@ export function requirePermission(user: UserPayload | null, permission: string):
|
||||||
if (!user) throw new Error('未登录')
|
if (!user) throw new Error('未登录')
|
||||||
if (!hasPermission(user, permission)) throw new Error('权限不足')
|
if (!hasPermission(user, permission)) throw new Error('权限不足')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getUserPermissions(role: string): string[] {
|
||||||
|
if (role === 'admin') return ['*']
|
||||||
|
const db = getDb()
|
||||||
|
const row = db.prepare('SELECT permissions FROM roles WHERE name = ?').get(role) as { permissions: string } | undefined
|
||||||
|
if (!row) return []
|
||||||
|
try {
|
||||||
|
return JSON.parse(row.permissions)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,25 @@ export function buildWeeklyMetadata(data: WeeklyReportData): string {
|
||||||
? Math.round(availabilities.reduce((s, v) => s + v, 0) / availabilities.length * 10000) / 100
|
? Math.round(availabilities.reduce((s, v) => s + v, 0) / availabilities.length * 10000) / 100
|
||||||
: 100
|
: 100
|
||||||
|
|
||||||
|
// 无故障天数:排除"其他"子分类,按故障影响日期范围计算
|
||||||
|
const faultTicketsForFreeDays = faultTickets.filter(t => t.faultSubcategory !== '其他')
|
||||||
|
const periodStartDate = new Date(data.periodStart.replace(/-/g, '/'))
|
||||||
|
const periodEndDate = new Date(data.periodEnd.replace(/-/g, '/'))
|
||||||
|
const periodDays = Math.floor((periodEndDate.getTime() - periodStartDate.getTime()) / (1000 * 60 * 60 * 24)) + 1
|
||||||
|
|
||||||
|
const faultDateSet = new Set<string>()
|
||||||
|
for (const t of faultTicketsForFreeDays) {
|
||||||
|
const start = new Date(t.assignTime.slice(0, 10).replace(/-/g, '/'))
|
||||||
|
const endRaw = t.closeTime ? t.closeTime.slice(0, 10) : data.periodEnd
|
||||||
|
const end = new Date(endRaw.replace(/-/g, '/'))
|
||||||
|
const cur = new Date(start)
|
||||||
|
while (cur <= end) {
|
||||||
|
faultDateSet.add(`${cur.getFullYear()}-${String(cur.getMonth()+1).padStart(2,'0')}-${String(cur.getDate()).padStart(2,'0')}`)
|
||||||
|
cur.setDate(cur.getDate() + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const faultFreeDays = periodDays - faultDateSet.size
|
||||||
|
|
||||||
const reportLabel = `图灵IT基础设施运营周报(${data.weekLabel})`
|
const reportLabel = `图灵IT基础设施运营周报(${data.weekLabel})`
|
||||||
|
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
|
|
@ -216,7 +235,7 @@ export function buildWeeklyMetadata(data: WeeklyReportData): string {
|
||||||
ongoingCount,
|
ongoingCount,
|
||||||
faultTicketCount,
|
faultTicketCount,
|
||||||
affectedDeviceCount,
|
affectedDeviceCount,
|
||||||
faultFreeDays: null,
|
faultFreeDays,
|
||||||
availabilityDetails: null,
|
availabilityDetails: null,
|
||||||
reportLabel,
|
reportLabel,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,91 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { verifyToken } from '@/lib/jwt'
|
|
||||||
|
|
||||||
function verifyApiKey(key: string): boolean {
|
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||||
// API Key 以环境变量形式存储,支持多个 Key(逗号分隔)
|
try {
|
||||||
const allowedKeys = process.env.ALLOWED_API_KEYS || ''
|
const parts = token.split('.')
|
||||||
if (!allowedKeys) return false
|
if (parts.length !== 3) return null
|
||||||
const keys = allowedKeys.split(',').map(k => k.trim())
|
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||||
return keys.includes(key)
|
while (payload.length % 4) payload += '='
|
||||||
|
return JSON.parse(atob(payload))
|
||||||
|
} catch { return null }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
function isValidPayload(payload: Record<string, unknown> | null): boolean {
|
||||||
|
if (!payload) return false
|
||||||
|
return !(payload.exp && (payload.exp as number) < Math.floor(Date.now() / 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Key 验证:检查 ALLOWED_API_KEYS 环境变量(逗号分隔明文 key)
|
||||||
|
// 注意:middleware 运行在 Edge Runtime,不能使用 better-sqlite3 等 Node.js 原生模块
|
||||||
|
// DB 级别的 key 验证在 route handler 中进行(auth.ts verifyApiKey)
|
||||||
|
function verifyApiKey(key: string): boolean {
|
||||||
|
if (!key.startsWith('ak_')) return false
|
||||||
|
const allowedKeys = process.env.ALLOWED_API_KEYS || ''
|
||||||
|
if (!allowedKeys) return false
|
||||||
|
return allowedKeys.split(',').map(k => k.trim()).includes(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLoginRedirect(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl
|
const { pathname } = request.nextUrl
|
||||||
if (pathname.startsWith('/login') || pathname === '/') return NextResponse.next()
|
|
||||||
if (pathname === '/api/auth/login') return NextResponse.next()
|
|
||||||
|
|
||||||
const authHeader = request.headers.get('authorization')
|
|
||||||
|
|
||||||
// API Key 认证:Bearer ak_xxx 格式
|
|
||||||
if (authHeader?.startsWith('Bearer ak_')) {
|
|
||||||
const key = authHeader.slice(7)
|
|
||||||
if (verifyApiKey(key)) return NextResponse.next()
|
|
||||||
// 环境变量中未匹配,API 路由仍放行(route handler 可查询数据库二次验证)
|
|
||||||
if (pathname.startsWith('/api/')) return NextResponse.next()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cookie 认证
|
|
||||||
const token = request.cookies.get('session_issue')?.value
|
|
||||||
|
|
||||||
// 构建带 redirect 参数的登录 URL
|
|
||||||
function buildLoginRedirect() {
|
|
||||||
const loginUrl = new URL('/login', request.url)
|
const loginUrl = new URL('/login', request.url)
|
||||||
const dest = pathname + (request.nextUrl.search || '')
|
const dest = pathname + (request.nextUrl.search || '')
|
||||||
loginUrl.searchParams.set('redirect', dest)
|
loginUrl.searchParams.set('redirect', dest)
|
||||||
const response = NextResponse.redirect(loginUrl)
|
return NextResponse.redirect(loginUrl)
|
||||||
if (token) response.cookies.delete('session_issue')
|
}
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname.startsWith('/api/')) {
|
export function middleware(request: NextRequest) {
|
||||||
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
const { pathname } = request.nextUrl
|
||||||
const valid = await verifyToken(token)
|
|
||||||
if (!valid) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
|
// 登录/退出路径 + 内部 API 放行(自有 key 认证)
|
||||||
|
if (pathname.startsWith('/login') || pathname === '/' ||
|
||||||
|
pathname === '/api/auth/login' || pathname === '/api/auth/logout' ||
|
||||||
|
pathname.startsWith('/api/internal/')) {
|
||||||
return NextResponse.next()
|
return NextResponse.next()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!token) return buildLoginRedirect()
|
// API Key 认证(外部系统调用)
|
||||||
const valid = await verifyToken(token)
|
const authHeader = request.headers.get('authorization')
|
||||||
if (!valid) return buildLoginRedirect()
|
if (authHeader?.startsWith('Bearer ak_')) {
|
||||||
|
const key = authHeader.slice(7)
|
||||||
|
if (verifyApiKey(key)) return NextResponse.next()
|
||||||
|
// 无效 key:API 路由返回 401
|
||||||
|
if (pathname.startsWith('/api/')) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先检查 tlyq_session(共享 JWT)
|
||||||
|
const sharedToken = request.cookies.get('tlyq_session')?.value
|
||||||
|
const sharedPayload = sharedToken ? decodeJwtPayload(sharedToken) : null
|
||||||
|
if (isValidPayload(sharedPayload)) {
|
||||||
|
const response = pathname.startsWith('/api/') ? NextResponse.next() : NextResponse.next()
|
||||||
|
response.cookies.set('session', JSON.stringify({ username: sharedPayload.username }), {
|
||||||
|
httpOnly: true, sameSite: 'lax', path: '/',
|
||||||
|
})
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退 session_issue(本地 JWT)
|
||||||
|
const localToken = request.cookies.get('session_issue')?.value
|
||||||
|
const localPayload = localToken ? decodeJwtPayload(localToken) : null
|
||||||
|
|
||||||
|
if (pathname.startsWith('/api/')) {
|
||||||
|
if (!isValidPayload(localPayload)) {
|
||||||
|
return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||||
|
}
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidPayload(localPayload)) {
|
||||||
|
const response = buildLoginRedirect(request)
|
||||||
|
if (localToken) response.cookies.delete('session_issue')
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
const response = NextResponse.next()
|
const response = NextResponse.next()
|
||||||
response.headers.set('x-original-pathname', pathname + (request.nextUrl.search || ''))
|
response.cookies.set('session', JSON.stringify({ username: localPayload.username }), {
|
||||||
|
httpOnly: true, sameSite: 'lax', path: '/',
|
||||||
|
})
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue