Compare commits

..

No commits in common. "0.0.1" and "main" have entirely different histories.
0.0.1 ... main

73 changed files with 4207 additions and 328 deletions

34
.dockerignore Normal file
View File

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

View File

@ -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

2
.gitignore vendored
View File

@ -11,7 +11,7 @@ build/
.claude/ .claude/
data/ data/
uploads/ uploads/
reports/ /reports/
docs/ docs/
db-backups/ db-backups/
.playwright-mcp/ .playwright-mcp/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,80 @@
# 变更日志 # 变更日志
## 2026-05-18
- [新增] 用户详情页 — 点击用户名查看完整信息,支持编辑和键盘导航
- [优化] 用户管理 UI 统一Modal/Button 拆分为独立文件、删除改用 Modal 确认、TopBar 样式统一
- [调整] 用户列表页去创建时间列,编辑功能移到详情页,仅保留删除按钮
- [新增] LDAP 邮箱自动同步:密码登录/SSO 路径均从 LLDAP 同步 `mail` 到本地 `users.email`,增加 `/api/users/sync-emails` 批量同步接口
- [新增] 编辑用户弹窗增加启用/禁用状态切换、新密码二次确认
- [优化] 角色权限页面 UI 统一:角色 Badge 按角色区分颜色、增加保存 loading、checkbox dark mode 样式
- [优化] API Key 页面 UI 统一:成功提示颜色对齐、取消按钮 ghost 统一
- [优化] 全站字体大小和颜色统一:列表页/详情页 `text-sm` 一致、dark mode 颜色补全
- [修复] 用户管理"最后登录"时间不动态更新SSO 免登录、本地 JWT 会话验证路径现在也会更新 `last_login_at`
## 2026-05-15
- [修复] middleware 移除 `better-sqlite3` 依赖,修复 Edge Runtime 不支持 Node.js 原生模块的编译报错(`The edge runtime does not support Node.js 'fs' module`
- [优化] deploy-ai.sh 连通性检查增加重试机制3次/10s间隔应对同时部署两个站点时目标容器暂不可达
## 2026-05-14
- [新增] 权限系统重构:工单细粒度权限(`tickets:create` 手动建单、`tickets:import` 导入、`tickets:export` 导出),`tickets:write` 缩窄为编辑/删除
- [新增] 报告三层权限:`reports:read`(查看列表+预览)、`reports:download`(下载)、`reports:create`(新建+生成文档+删除)
- [新增] `/api/auth/me` 返回 `permissions` 字段,前端根据权限数组控制按钮和导航显隐
- [新增] 角色管理 UI 权限项从 8 个扩展到 14 个(含 `api-keys:read/write`
- [修复] 角色管理页面admin/operator/viewer 标记为"内置"不可删除但可编辑admin 在 API 层禁止删除
- [修复] 报告 API 路由全部补全权限检查(此前列表/预览/下载/删除均无权限检查)
- [修复] 导出工单 API 新增 `tickets:export` 权限检查(此前仅验证登录态)
- [修复] POST 创建报告权限从错误的 `reports:read` 修正为 `reports:create`
- [修复] `reports:write` 全面替换为 `reports:create`
- [修复] 种子数据迁移逻辑不再每次启动覆盖用户自定义权限,仅首次安装时创建默认权限
- [修复] SSO 新用户免登录失败:根页面 `getCurrentUser()` 改为直接 `redirect('/dashboard')`,由中间件统一处理共享 JWT 认证(与 assets-ai 行为一致)
- [修复] 手动建单输入业务 IP 按回车无法查询节点名称和设备序列号assets-ai `api_keys` 表中缺少 issue-ai 使用的 API Key服务端调用返回 401且前端静默吞错无提示
- [优化] 中间件 API Key 验证统一为 ALLOWED_API_KEYS快速路径→ api_keys 数据库表(回退)两级验证;无效 key 不再被放行,中间件层直接返回 401安全加固与 assets-ai 行为一致)
- [新增] 部署后自动验证 issue→assets API 连通性(`deploy-ai.sh`API Key 不匹配时部署立即报错退出
- [修复] `next.config.ts` 添加 `ldapts``serverExternalPackages`,确保 Next.js standalone 构建包含 LLDAP 客户端模块,避免 `ldapUserExists()` 因模块缺失失败导致 SSO 自动创建用户静默中断
- [调整] 全局证书切换Cloudflare Origin CA → Let's Encrypt`/etc/letsencrypt/live/www.tlyq.ai/`),覆盖全部 7 个子域名nginx 8 个站点配置同步更新
## 2026-05-12
- [部署] 云端 JWT 密钥统一为 oa-shared-jwt-secret-tlyq-2026三站点密钥一致
- [部署] docker-compose 移除 AUTHELIA_URL添加 LDAP/COOKIE/INTERNAL_API_KEY 环境变量
- [部署] LLDAP admin 密码更新为 3Vm!Y!@RCiPs
- [部署] nginx 移除 auth_request恢复纯反向代理改为运行时 DNS 解析
- [新增] `src/lib/db-schema.ts`users 表新增 last_login_at / last_active_at 列
- [新增] `src/lib/auth.ts`getCurrentUser() 更新 last_active_at
- [新增] `src/app/api/auth/login/route.ts`:登录时更新 last_login_at 和 last_active_at
## 2026-05-11
- [新增] `src/lib/ldap.ts``ldapUserExists()` 函数,检查 LLDAP 中用户是否存在admin bind 搜索,不可达时容错放行)
- [新增] `src/lib/jwt-shared.ts`:共享 JWT 签发/验证(`tlyq_session` cookieHS256与 OA/assets 共用密钥)
- [调整] `src/lib/auth.ts``getCurrentUser()` 优先 `tlyq_session`,加入 LLDAP 存在性检查,用户被删除后自动清除 cookie 踢出
- [调整] `src/app/api/auth/login/route.ts`LDAP 优先认证 + 本地密码缓存回退 + localadmin 应急用户直连
- [调整] `src/app/api/auth/logout/route.ts`:同时清除 `session_issue``tlyq_session`
- [调整] `src/app/api/auth/me/route.ts`:移除 SSO header 路径,改用 `getCurrentUser()` 统一获取
- [调整] `src/middleware.ts`:优先 `tlyq_session` → 回退 `session_issue`,移除 SSO 代理路径,放行 `/api/internal/`
- [新增] `src/app/api/internal/roles/route.ts`:内部 API返回站点可用角色列表INTERNAL_API_KEY 鉴权)
- [新增] `src/app/api/users/[id]/route.ts`admin/localadmin 用户禁止删除和修改角色
- [修复] `src/components/tickets/TicketList.tsx`已办工单点击业务IP/节点名称跳转到待办页面,改用 `usePathname()` 保持当前页面并正确筛选
- [调整] `src/app/(app)/settings/users/page.tsx`admin/localadmin 用户隐藏删除按钮,编辑时角色字段显示为只读
- [新增] `src/lib/db-schema.ts`:预置 localadmin 应急用户admin 角色,纯本地 BCrypt 认证)
## 2026-05-07
- [新增] 月报跨月进行中工单支持:第一章折线图覆盖未结单离线天数,第二章标注"处理中",第三章显示"进行中"/"—",第四章标注"仅计本月部分"
- [修复] `monthly-report-docx.ts` 6 个 TypeScript 类型错误HeadingLevel 值类型、createCell align 推断过窄、children 数组类型改为 FileChild[]
- [修复] API Key 过期检查失效:`verifyApiKey` 查询缺 `expires_at` 字段,导致过期判断永不触发
- [修复] Button 组件不支持 `loading` 属性:增加 `loading?: boolean` 并在 loading 时禁用 + spinner 动画
- [修复] `scripts/import-steps.ts` 参数类型错误:`main()` 增加 `excelPath` 判空守卫
- [新增] 月报生成时自动生成当月自然周周报,以 OLE Package 嵌入「五、附件」章节,双击图标可在 Word 中打开
- [新增] 报告管理页面重新设计:预览页 KPI 5 列布局、故障分类去背景色、报告列表状态标签颜色体系、操作按钮按状态显示
- [新增] POST `/api/reports/[id]/generate` 异步生成路由,报告创建与 DOCX 生成分离,状态机 ready→generating→completed/failed
- [修复] 月报第二章和第一章图表:排除 `fault_subcategory = '其他'` 工单,当日恢复故障不计入离线节点
- [修复] 报告预览无故障天数改为按故障完整日期范围计算,与 DOCX 图表规则区分
## 2026-05-05 ## 2026-05-05
- [修复] 云服务器月报生成失败:重建 Docker 镜像安装 echartsDockerfile 补全 Chromium 系统依赖库libglib2.0、libnss3 等 18 个) - [修复] 云服务器月报生成失败:重建 Docker 镜像安装 echartsDockerfile 补全 Chromium 系统依赖库libglib2.0、libnss3 等 18 个)

View File

@ -13,7 +13,7 @@ issue-ai 是基于 Next.js + SQLite 的工单跟踪管理系统,部署在腾
| 站点域名 | `issue.tlyq.ai` | | 站点域名 | `issue.tlyq.ai` |
| 服务器 | txjpIP: 43.133.38.210 | | 服务器 | txjpIP: 43.133.38.210 |
| 代码路径 | `/root/docker/issue-ai/` | | 代码路径 | `/root/docker/issue-ai/` |
| 本地端口 | 5176 | | 本地端口 | 6176 |
| 容器名 | `issue-ai` | | 容器名 | `issue-ai` |
| 数据库 | SQLite`data/issue.db` | | 数据库 | SQLite`data/issue.db` |
| 报告存储 | `reports/` 目录(环境变量 `REPORTS_DIR` 可覆盖) | | 报告存储 | `reports/` 目录(环境变量 `REPORTS_DIR` 可覆盖) |
@ -99,11 +99,16 @@ npm run import # 导入工单
### 认证 ### 认证
登录逻辑v2.1LDAP 优先 + 本地密码缓存回退 + localadmin 应急用户。
登录成功签发两个 cookie`session_issue`(本地 JWT7 天)+ `tlyq_session`(共享 JWT7 天domain=.tlyq.ai
中间件优先检查 `tlyq_session`,回退 `session_issue`。`getCurrentUser()` 每次验证时检查 LLDAP 用户是否存在(已删除则清除 cookie 踢出)。
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|------|------|------| |------|------|------|
| POST | `/api/auth/login` | 登录username + password → JWT cookie | | POST | `/api/auth/login` | 登录(LDAP 优先 + 本地回退 |
| POST | `/api/auth/logout` | 登出 | | POST | `/api/auth/logout` | 登出(清除两个 cookie |
| GET | `/api/auth/me` | 当前用户信息 | | GET | `/api/auth/me` | 当前用户信息 |
| GET | `/api/internal/roles` | 内部 API返回角色列表x-internal-key 鉴权) |
### 工单 ### 工单
@ -133,15 +138,23 @@ npm run import # 导入工单
--- ---
## 认证机制
- **Web UIv2.1**`middleware.ts` 优先检查 `tlyq_session`(共享 JWTOA 统一签发)→ 回退 `session_issue`(本地 JWT。`getCurrentUser()` 每次请求时检查 LLDAP 用户是否存在,已删除则清除 cookie 踢出
- **localadmin**:纯本地 BCrypt 认证,不依赖 LLDAP用于 LLDAP 故障时应急登录DB 预置admin 角色)
- **API Keyv2.2**`middleware.ts` 检查 `ALLOWED_API_KEYS` 环境变量(逗号分隔明文 key无效 key 在中间件层直接返回 401。注意middleware 运行在 Edge Runtime不能使用 `better-sqlite3`DB 级别的 key 验证由 route handler 中的 `auth.ts verifyApiKey()` 进行(查 `api_keys` 表 SHA-256 hash。外部系统调用本系统 API 时key 必须注册在 `ALLOWED_API_KEYS`Web UI 创建的 key 还需同步到环境变量,重启容器后生效)
---
## 环境配置 ## 环境配置
### 本地与云端差异 ### 本地与云端差异
| 环境变量 | 本地开发 | 云服务器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,6 +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')`
- **日期处理(时区规范)**:整个系统统一使用 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`
--- ---

View File

@ -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

View File

@ -28,7 +28,7 @@
### 概述 ### 概述
月报按自然月生成,统计当月已结单工单,输出为 DOCX 文档。包含封面、目录及四个章节。 月报按自然月生成,统计当月已结单工单及跨月进行中工单,输出为 DOCX 文档。包含封面、目录及四个章节。
### 报告结构 ### 报告结构
@ -45,21 +45,27 @@
**设备清单**:从 assets-ai 拉取 `filter_status=腾讯使用` 的设备,按 `filter_device_type=GPU服务器` / `存储服务器` 分类,构建 business_ip → device_type 映射。 **设备清单**:从 assets-ai 拉取 `filter_status=腾讯使用` 的设备,按 `filter_device_type=GPU服务器` / `存储服务器` 分类,构建 business_ip → device_type 映射。
**工单筛选**:按 `close_time` 范围查询monthly report 统计当月结单),条件 **工单筛选**:按 `close_time` `assign_time` 双条件查询,同时覆盖当月结单工单和跨月进行中工单
```sql ```sql
close_time >= periodStart AND close_time <= periodEnd + ' 23:59:59' SELECT * FROM tickets WHERE (
AND current_status IN ('resolved', 'closed') (close_time >= periodStart AND close_time <= periodEnd + ' 23:59:59')
AND duration_minutes IS NOT NULL OR
(assign_time <= periodEnd + ' 23:59:59' AND (close_time IS NULL OR close_time > periodEnd + ' 23:59:59'))
)
ORDER BY assign_time ORDER BY assign_time
``` ```
- 第一条件:当月内结单的工单(含上月派发本月恢复的跨月工单)
- 第二条件:当月结束时仍未结单的工单(含当月派发和上月派发的进行中工单)
- 使用 `close_time` 比较而非 `current_status`,保证事后补生成报告时判断不变
**工单分类**:按 device_ip 在设备清单中查找对应 device_typegpu / storage / other **工单分类**:按 device_ip 在设备清单中查找对应 device_typegpu / storage / other
### 第一章规则 ### 第一章规则
- 遍历当月每一天,计算当日在线节点数 - 遍历当月每一天,计算当日在线节点数
- **统计范围**:排除 `fault_category = '无故障'` 的工单(跨月工单如 7/31 故障 8/3 恢复正常计入8/1、8/2 各减 1 台) - **统计范围**:排除 `fault_category = '无故障'` `fault_subcategory = '其他'` 的工单("其他"工单不计入节点在线数;跨月工单如 7/31 故障 8/3 恢复正常计入8/1、8/2 各减 1 台)
- 不在线判断:`assign_time日期 ≤ 当前日期 < close_time日期` - 不在线判断:`assign_time日期 ≤ 当前日期 < close_time日期`
- 当日发生故障次日恢复 → 发生日计入不在线,恢复日不计入 - 当日发生故障次日恢复 → 发生日计入不在线,恢复日不计入
- 当日发生故障当日恢复 → 不计入不在线 - 当日发生故障当日恢复 → 不计入不在线
@ -68,13 +74,18 @@ ORDER BY assign_time
- **Y 轴动态范围**:根据实际数据波动自动调整 min/max/interval避免总节点数较大时微小变化无法分辨 - **Y 轴动态范围**:根据实际数据波动自动调整 min/max/interval避免总节点数较大时微小变化无法分辨
- 无波动时 Y 轴范围 = total ± 2 - 无波动时 Y 轴范围 = total ± 2
- 有波动时根据实际最值加 buffer确保 8~15 个刻度 - 有波动时根据实际最值加 buffer确保 8~15 个刻度
- **报告预览"无故障天数"与 DOCX 图表计算方式不同**
- **预览**:按故障完整日期范围(`assign ~ close`,含 close 当天)计算,当日恢复也计入故障天数
- **DOCX 图表**:按 `assign ≤ date < close` 计算,当日恢复不计入离线节点
### 第二章规则 ### 第二章规则
- 仅统计 GPU / 存储工单,排除 `fault_category = '无故障'` - 仅统计 GPU / 存储工单,排除 `fault_category = '无故障'``fault_subcategory = '其他'`
- 按 `device_type + assign_time日期` 分组 - 按 `device_type + assign_time日期` 分组
- 每条格式:`X月X日发生1次<故障子类>,故障节点为<IP><恢复描述>恢复。` - 每条格式:`X月X日发生1次<故障子类>,故障节点为<IP><恢复描述>。`
- 恢复描述:`assign_date` 与 `close_date` 天数差0 → 当日1 → 次日≥2 → N日后 - 恢复描述:
- 已结单:`assign_date` 与 `close_date` 天数差0 → 当日1 → 次日≥2 → N日后
- 进行中:固定显示 `处理中。`(不含恢复描述)
### 第三章规则 ### 第三章规则
@ -89,36 +100,39 @@ ORDER BY assign_time
**GPU/存储故障表**7 列): **GPU/存储故障表**7 列):
| 列 | 来源 | | 列 | 来源 | 进行中工单 |
|----|------| |----|------|-----------|
| 工单编号 | `ticket_id` | | 工单编号 | `ticket_id` | 正常显示 |
| 故障节点 | `device_ip` | | 故障节点 | `device_ip` | 正常显示 |
| 故障日期 | `assign_time`(完整时间,精确到秒) | | 故障日期 | `assign_time`(完整时间,精确到秒) | 正常显示 |
| 故障问题 | `fault_subcategory` | | 故障问题 | `fault_subcategory` | 正常显示 |
| 故障原因 | `parts_name` 有值 → `更换{parts_name}`,否则 → `-` | | 故障原因 | `parts_name` 有值 → `更换{parts_name}`,否则 → `-` | 正常显示 |
| 处理时长(分钟) | `duration_minutes` | | 处理时长(分钟) | `duration_minutes`(已结单) | 显示 `进行中` |
| 是否计入 SLA | 见下方 SLA 规则 | | 是否计入 SLA | 见下方 SLA 规则 | 显示 `—` |
**其他工单表**7 列): **其他工单表**7 列):
| 列 | 来源 | | 列 | 来源 | 进行中工单 |
|----|------| |----|------|-----------|
| 工单编号 | `ticket_id` | | 工单编号 | `ticket_id` | 正常显示 |
| 设备 IP 地址 | `device_ip` | | 设备 IP 地址 | `device_ip` | 正常显示 |
| 工单日期 | `assign_time`(完整时间,精确到秒) | | 工单日期 | `assign_time`(完整时间,精确到秒) | 正常显示 |
| 工单内容 | `content` | | 工单内容 | `content` | 正常显示 |
| 工单结论 | `conclusion` | | 工单结论 | `conclusion` | 正常显示 |
| 处理时长(分钟) | `duration_minutes` | | 处理时长(分钟) | `duration_minutes`(已结单) | 显示 `进行中` |
| 是否计入 SLA | 见下方 SLA 规则 | | 是否计入 SLA | 见下方 SLA 规则 | 显示 `—` |
### 第四章规则 ### 第四章规则
- 排除 `fault_category = '无故障'` 的工单 - 排除 `fault_category = '无故障'` 的工单
- 按 device_ip 分组求和 `duration_minutes` - 按 device_ip 分组求和故障时长:
- **已结单工单**:使用实际 `duration_minutes`
- **进行中工单**:仅计算本月内部分,时长 = `(min(assign_date, 月初) → 月末最后一天) × 24 × 60` 分钟
- 公式:`可用性 = (monthDays × 24 × 60 - totalDurationMinutes) / (monthDays × 24 × 60) × 100` - 公式:`可用性 = (monthDays × 24 × 60 - totalDurationMinutes) / (monthDays × 24 × 60) × 100`
- monthDays 为当月实际天数(动态计算) - monthDays 为当月实际天数(动态计算)
- 百分比 **< 99%** 该值以黄底红字加粗标记 - 百分比 **< 99%** 该值以黄底红字加粗标记
- 百分比 **≥ 99%** 时,正常样式 - 百分比 **≥ 99%** 时,正常样式
- 该 IP 存在进行中工单时,公式后追加橙色标注 `(故障处理中,仅计本月部分)`
### SLA 判定规则 ### SLA 判定规则

View File

@ -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:

View File

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

BIN
oa-cloud-fixed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
oa-cloud-login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
oa-login-current.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
oa-login-page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

20
package-lock.json generated
View File

@ -14,6 +14,8 @@
"docx": "^9.1.1", "docx": "^9.1.1",
"echarts": "^5.5.0", "echarts": "^5.5.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"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",
@ -3591,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",
@ -4977,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",

View File

@ -3,7 +3,7 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "NODE_OPTIONS='--max-old-space-size=2048' next dev --port 5176", "dev": "NODE_OPTIONS='--max-old-space-size=2048' next dev --port 6176",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
@ -17,6 +17,8 @@
"docx": "^9.1.1", "docx": "^9.1.1",
"echarts": "^5.5.0", "echarts": "^5.5.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"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",

0
public/.gitkeep Normal file
View File

BIN
report-54-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
report-preview-53.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@ -199,6 +199,10 @@ function parseFaultSheet(data: (string | number | null)[][]): {
// 主逻辑 // 主逻辑
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function main() { function main() {
if (!excelPath) {
console.error('用法: npx tsx scripts/import-steps.ts <Excel文件路径> [dbPath]')
process.exit(1)
}
if (!fs.existsSync(dbPath)) { if (!fs.existsSync(dbPath)) {
console.error(`数据库文件不存在: ${dbPath}`) console.error(`数据库文件不存在: ${dbPath}`)
process.exit(1) process.exit(1)

View File

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

View File

@ -0,0 +1,536 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Card, Button, Badge, Toast, Modal } from '@/components/ui'
import { Plus, Eye, Download, Trash2, Archive, RefreshCw } from 'lucide-react'
interface Report {
id: number
report_type: string
period_start: string | null
period_end: string | null
format: string
status: string
created_at: string
file_name?: string | null
metadata?: string | null
}
const statusVariant: Record<string, 'info' | 'warning' | 'success' | 'danger'> = {
pending: 'info',
ready: 'warning',
generating: 'info',
completed: 'success',
failed: 'danger',
}
const statusLabel: Record<string, string> = {
pending: '待生成',
ready: '数据已就绪',
generating: '文档生成中',
completed: '已完成',
failed: '生成失败',
}
const typeLabel: Record<string, string> = {
weekly: '周报',
monthly: '月报',
}
function getReportName(r: Report): string {
if (r.metadata) {
try {
const meta = JSON.parse(r.metadata)
if (meta.reportLabel) return meta.reportLabel
} catch { /* ignore */ }
}
if (r.file_name) return r.file_name
return `${r.period_start || '?'} ~ ${r.period_end || '?'} ${typeLabel[r.report_type] || r.report_type}`
}
function lastDayOfMonth(y: number, m: number) {
return new Date(y, m, 0).getDate()
}
function pad(n: number) { return String(n).padStart(2, '0') }
function getLastWeekDates() {
const today = new Date()
const dayOfWeek = today.getDay()
const daysSinceMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1
const thisMonday = new Date(today)
thisMonday.setDate(today.getDate() - daysSinceMonday)
const lastMonday = new Date(thisMonday)
lastMonday.setDate(thisMonday.getDate() - 7)
const lastSunday = new Date(lastMonday)
lastSunday.setDate(lastMonday.getDate() + 6)
return {
start: lastMonday.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>
)
}

View File

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

View File

@ -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>

View File

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

View File

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

View File

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

View File

@ -1,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 }) }
} }

View File

@ -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
} }

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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 })
}
}

View File

@ -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 })
}
}

View File

@ -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 })
}
}

View File

@ -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 })
}
}

View File

@ -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[] = []

View File

@ -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)
} }

View File

@ -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)

View File

@ -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')

View File

@ -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

View File

@ -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()

View File

@ -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 })

View File

@ -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)

View File

@ -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 })
}

View File

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

View File

@ -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>
) )

View File

@ -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 />

View File

@ -1,7 +1,7 @@
'use client' 'use client'
import { useState, useEffect, useCallback, useRef, Suspense } from 'react' import { useState, useEffect, useCallback, useRef, Suspense } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams, usePathname } from 'next/navigation'
import { Button, Badge, Pagination } from '@/components/ui' import { Button, Badge, Pagination } from '@/components/ui'
import { Search, Download, Eye, Pencil, Trash2, Filter, ArrowUpDown, ChevronsUpDown, ChevronUp, ChevronDown, Check, X, ExternalLink } from 'lucide-react' import { Search, Download, Eye, Pencil, Trash2, Filter, ArrowUpDown, ChevronsUpDown, ChevronUp, ChevronDown, Check, X, ExternalLink } from 'lucide-react'
import SelectWithInput from '@/components/ui/SelectWithInput' import SelectWithInput from '@/components/ui/SelectWithInput'
@ -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">
<Button variant="secondary" size="sm" onClick={handleExport}><Download size={14} /></Button> {(permissions.includes('*') || permissions.includes('tickets:export')) && (
<Button variant="secondary" size="sm" onClick={handleExport}><Download size={14} /></Button>
)}
</div> </div>
</div> </div>
@ -652,12 +663,12 @@ function TicketListInner({ onPaginationChange, defaultStatusFilter, showSlaColum
<td className="px-4 py-3 font-medium"><Link href={`/tickets/${t.id}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.id}</Link></td> <td className="px-4 py-3 font-medium"><Link href={`/tickets/${t.id}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.id}</Link></td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{t.device_ip ? ( {t.device_ip ? (
<Link href={`/tickets?device_ip=${encodeURIComponent(t.device_ip)}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.device_ip}</Link> <Link href={`${pathname}?device_ip=${encodeURIComponent(t.device_ip)}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.device_ip}</Link>
) : '-'} ) : '-'}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{t.device_name ? ( {t.device_name ? (
<Link href={`/tickets?device_name=${encodeURIComponent(t.device_name)}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.device_name}</Link> <Link href={`${pathname}?device_name=${encodeURIComponent(t.device_name)}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.device_name}</Link>
) : '-'} ) : '-'}
</td> </td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 max-w-xs truncate">{t.content || '-'}</td> <td className="px-4 py-3 text-slate-700 dark:text-slate-300 max-w-xs truncate">{t.content || '-'}</td>

View File

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

View File

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

View File

@ -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' } export { default as Modal } from './Modal'
export function Button({ variant = 'primary', size = 'md', className = '', children, ...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}`} {...props}>{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">&times;</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'

View File

@ -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 {

View File

@ -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共享 JWTLDAP 用户)
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本地 JWTadmin 账号或紧急绕过)
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 }
} }
@ -48,10 +101,10 @@ export function verifyApiKey(key: string): ApiKeyInfo | null {
if (!key.startsWith('ak_')) return null if (!key.startsWith('ak_')) return null
const db = getDb() const db = getDb()
const keyHash = hashApiKey(key) const keyHash = hashApiKey(key)
const row = db.prepare('SELECT id, name, permissions FROM api_keys WHERE key_hash = ? AND is_active = 1') const row = db.prepare('SELECT id, name, permissions, expires_at FROM api_keys WHERE key_hash = ? AND is_active = 1')
.get(keyHash) as { id: number; name: string; permissions: string } | 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) }
} }

View File

@ -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)

61
src/lib/jwt-shared.ts Normal file
View File

@ -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,
}
}

View File

@ -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
} }

90
src/lib/ldap.ts Normal file
View File

@ -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()
}
}

View File

@ -1,8 +1,11 @@
import { import {
Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell, Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell,
WidthType, AlignmentType, HeadingLevel, PageBreak, ImageRun, WidthType, AlignmentType, HeadingLevel, PageBreak, ImageRun,
TableOfContents, VerticalAlign, TableOfContents, VerticalAlign, FileChild,
} from 'docx' } from 'docx'
type Heading = (typeof HeadingLevel)[keyof typeof HeadingLevel]
type HAlign = (typeof AlignmentType)[keyof typeof AlignmentType]
import type { import type {
MonthlyReportData, Chapter3FaultEntry, Chapter3OtherEntry, MonthlyReportData, Chapter3FaultEntry, Chapter3OtherEntry,
} from '@/types/report' } from '@/types/report'
@ -22,7 +25,7 @@ function createHeaderCell(text: string, width?: number): TableCell {
}) })
} }
function createCell(text: string, align = AlignmentType.CENTER): TableCell { function createCell(text: string, align: HAlign = AlignmentType.CENTER): TableCell {
return new TableCell({ return new TableCell({
children: [new Paragraph({ children: [new Paragraph({
children: [new TextRun({ text: text || '', size: 18, font: 'SimSun' })], children: [new TextRun({ text: text || '', size: 18, font: 'SimSun' })],
@ -34,7 +37,7 @@ function createCell(text: string, align = AlignmentType.CENTER): TableCell {
} }
// 标题段落 // 标题段落
function chapterTitle(text: string, heading: HeadingLevel, spaceBefore = 400, spaceAfter = 200): Paragraph { function chapterTitle(text: string, heading: Heading, spaceBefore = 400, spaceAfter = 200): Paragraph {
return new Paragraph({ return new Paragraph({
children: [new TextRun({ text, bold: true, size: 28, font: 'SimSun' })], children: [new TextRun({ text, bold: true, size: 28, font: 'SimSun' })],
heading, heading,
@ -77,8 +80,8 @@ function buildFaultTable(entries: Chapter3FaultEntry[]): Table {
createCell(e.faultDate), createCell(e.faultDate),
createCell(e.faultProblem), createCell(e.faultProblem),
createCell(e.faultCause), createCell(e.faultCause),
createCell(String(e.durationMinutes)), createCell(e.isOngoing ? '进行中' : String(e.durationMinutes)),
createCell(e.countedInSla), createCell(e.isOngoing ? '—' : e.countedInSla),
], ],
})) }))
return new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows: [headerRow, ...dataRows] }) return new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows: [headerRow, ...dataRows] })
@ -94,8 +97,8 @@ function buildOtherTable(entries: Chapter3OtherEntry[]): Table {
createCell(e.ticketDate), createCell(e.ticketDate),
createCell(e.ticketContent, AlignmentType.LEFT), createCell(e.ticketContent, AlignmentType.LEFT),
createCell(e.ticketConclusion, AlignmentType.LEFT), createCell(e.ticketConclusion, AlignmentType.LEFT),
createCell(String(e.durationMinutes)), createCell(e.isOngoing ? '进行中' : String(e.durationMinutes)),
createCell(e.countedInSla), createCell(e.isOngoing ? '—' : e.countedInSla),
], ],
})) }))
return new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows: [headerRow, ...dataRows] }) return new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows: [headerRow, ...dataRows] })
@ -106,8 +109,9 @@ function buildOtherTable(entries: Chapter3OtherEntry[]): Table {
export async function buildMonthlyReportDocx( export async function buildMonthlyReportDocx(
data: MonthlyReportData, data: MonthlyReportData,
charts: { gpuPng: Buffer; storagePng: Buffer }, charts: { gpuPng: Buffer; storagePng: Buffer },
weeklyLabels: string[] = [],
): Promise<Buffer> { ): Promise<Buffer> {
const children: Paragraph[] = [] const children: FileChild[] = []
// ========== 封面页 ========== // ========== 封面页 ==========
// 上方留白 // 上方留白
@ -162,7 +166,7 @@ export async function buildMonthlyReportDocx(
children.push(new TableOfContents('目录', { children.push(new TableOfContents('目录', {
hyperlink: true, hyperlink: true,
headingStyleRange: '1-2', headingStyleRange: '1-2',
}) as any) }))
// ========== 第一章:总体运营概况 ========== // ========== 第一章:总体运营概况 ==========
children.push(pageBreak()) children.push(pageBreak())
@ -206,11 +210,11 @@ export async function buildMonthlyReportDocx(
for (const f of entry.faults) { for (const f of entry.faults) {
const dateParts = entry.date.split('-') const dateParts = entry.date.split('-')
const monthDay = `${parseInt(dateParts[1])}${parseInt(dateParts[2])}` const monthDay = `${parseInt(dateParts[1])}${parseInt(dateParts[2])}`
const recoveryText = f.recoveryDays === 0 ? '当日' const statusText = f.isOngoing
: f.recoveryDays === 1 ? '次日' ? '处理中。'
: `${f.recoveryDays}日后` : `${f.recoveryDays === 0 ? '当日' : f.recoveryDays === 1 ? '次日' : `${f.recoveryDays}日后`}恢复。`
children.push(bodyPara( children.push(bodyPara(
`${monthDay}发生1次${f.fault_subcategory},故障节点为${f.ip}${recoveryText}恢复。` `${monthDay}发生1次${f.fault_subcategory},故障节点为${f.ip}${statusText}`
)) ))
} }
} }
@ -225,11 +229,11 @@ export async function buildMonthlyReportDocx(
for (const f of entry.faults) { for (const f of entry.faults) {
const dateParts = entry.date.split('-') const dateParts = entry.date.split('-')
const monthDay = `${parseInt(dateParts[1])}${parseInt(dateParts[2])}` const monthDay = `${parseInt(dateParts[1])}${parseInt(dateParts[2])}`
const recoveryText = f.recoveryDays === 0 ? '当日' const statusText = f.isOngoing
: f.recoveryDays === 1 ? '次日' ? '处理中。'
: `${f.recoveryDays}日后` : `${f.recoveryDays === 0 ? '当日' : f.recoveryDays === 1 ? '次日' : `${f.recoveryDays}日后`}恢复。`
children.push(bodyPara( children.push(bodyPara(
`${monthDay}发生1次${f.fault_subcategory},故障节点为${f.ip}${recoveryText}恢复。` `${monthDay}发生1次${f.fault_subcategory},故障节点为${f.ip}${statusText}`
)) ))
} }
} }
@ -269,20 +273,30 @@ export async function buildMonthlyReportDocx(
const formula = `${entry.ip}服务可用性=(${entry.monthDays}*24*60-${entry.totalDurationMinutes})/(${entry.monthDays}*24*60)*100%=` const formula = `${entry.ip}服务可用性=(${entry.monthDays}*24*60-${entry.totalDurationMinutes})/(${entry.monthDays}*24*60)*100%=`
const percent = `${entry.availabilityPercent.toFixed(2)}%` const percent = `${entry.availabilityPercent.toFixed(2)}%`
const below99 = entry.availabilityPercent < 99 const below99 = entry.availabilityPercent < 99
const children_: TextRun[] = [
new TextRun({ text: formula, size: 22, font: 'SimSun' }),
new TextRun(below99
? { text: percent, size: 22, font: 'SimSun', bold: true, color: 'FF0000', highlight: 'yellow' }
: { text: percent, size: 22, font: 'SimSun' }
),
]
if (entry.hasOngoing) {
children_.push(new TextRun({ text: '(故障处理中,仅计本月部分)', size: 22, font: 'SimSun', color: 'FF6600' }))
}
children.push(new Paragraph({ children.push(new Paragraph({
children: [ children: children_,
new TextRun({ text: formula, size: 22, font: 'SimSun' }),
new TextRun(below99
? { text: percent, size: 22, font: 'SimSun', bold: true, color: 'FF0000', highlight: 'yellow' }
: { text: percent, size: 22, font: 'SimSun' }
),
],
spacing: { after: 80, line: 360 }, spacing: { after: 80, line: 360 },
indent: { firstLine: 480 }, indent: { firstLine: 480 },
})) }))
} }
} }
// ========== 第五章:附件 ==========
if (weeklyLabels.length > 0) {
children.push(chapterTitle('五、附件', HeadingLevel.HEADING_1))
children.push(bodyPara(`本月共 ${weeklyLabels.length} 份周报,双击图标可打开查看:`))
}
// ========== 组装文档 ========== // ========== 组装文档 ==========
const doc = new Document({ const doc = new Document({
sections: [{ sections: [{

View File

@ -2,9 +2,13 @@ import { getDb } from './db'
import { getActiveDevices, DEVICE_TYPE_GPU, DEVICE_TYPE_STORAGE } from './assets-client' import { getActiveDevices, DEVICE_TYPE_GPU, DEVICE_TYPE_STORAGE } from './assets-client'
import { generateDailyOnlineChart } from './monthly-report-charts' import { generateDailyOnlineChart } from './monthly-report-charts'
import { buildMonthlyReportDocx } from './monthly-report-docx' import { buildMonthlyReportDocx } from './monthly-report-docx'
import { getMonthNaturalWeeks, embedWeeklyReports } from './ole-embed'
import { collectWeeklyReportData } from './weekly-report'
import { buildWeeklyReportDocx } from './weekly-report-docx'
import type { import type {
ClassifiedTicket, DailyOnlineStats, Chapter2Entry, Chapter2FaultItem, ClassifiedTicket, DailyOnlineStats, Chapter2Entry, Chapter2FaultItem,
Chapter3FaultEntry, Chapter3OtherEntry, Chapter4Entry, MonthlyReportData, Chapter3FaultEntry, Chapter3OtherEntry, Chapter4Entry, MonthlyReportData,
WeeklyAttachment,
} from '@/types/report' } from '@/types/report'
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
@ -57,34 +61,43 @@ export async function collectMonthlyReportData(
if (d.hdm_ip) ipTypeMap.set(d.hdm_ip, 'storage') if (d.hdm_ip) ipTypeMap.set(d.hdm_ip, 'storage')
} }
// 2. 查询当月已结单工单(按 close_time 范围) // 2. 查询当月已结单工单 + 跨月进行中工单
const db = getDb() const db = getDb()
const endFull = periodEnd + ' 23:59:59' const endFull = periodEnd + ' 23:59:59'
const ticketsRaw = db.prepare(` const ticketsRaw = db.prepare(`
SELECT * FROM tickets SELECT * FROM tickets WHERE (
WHERE close_time >= ? AND close_time <= ? (close_time >= ? AND close_time <= ?)
AND current_status IN ('resolved', 'closed') OR
AND duration_minutes IS NOT NULL (assign_time <= ? AND (close_time IS NULL OR close_time > ?))
)
ORDER BY assign_time ORDER BY assign_time
`).all(periodStart, endFull) as any[] `).all(periodStart, endFull, endFull, endFull) as any[]
// 3. 分类工单 // 3. 分类工单(进行中工单使用 periodEnd+1 天作为合成 close_time 供后续计算)
const tickets: ClassifiedTicket[] = ticketsRaw.map(t => ({ const periodEndNext = new Date(periodEnd.replace(/-/g, '/'))
id: t.id, periodEndNext.setDate(periodEndNext.getDate() + 1)
device_ip: t.device_ip || '', const periodEndNextStr = `${periodEndNext.getFullYear()}-${String(periodEndNext.getMonth()+1).padStart(2,'0')}-${String(periodEndNext.getDate()).padStart(2,'0')}`
device_name: t.device_name,
device_type: ipTypeMap.get(t.device_ip) || 'other', const tickets: ClassifiedTicket[] = ticketsRaw.map(t => {
fault_category: t.fault_category, const isOngoing = !t.close_time || t.close_time > endFull
fault_subcategory: t.fault_subcategory, return {
parts_replaced: t.parts_replaced, id: t.id,
parts_name: t.parts_name, device_ip: t.device_ip || '',
content: t.content, device_name: t.device_name,
conclusion: t.conclusion, device_type: ipTypeMap.get(t.device_ip) || 'other',
assign_time: t.assign_time, fault_category: t.fault_category,
close_time: t.close_time, fault_subcategory: t.fault_subcategory,
duration_minutes: t.duration_minutes || 0, parts_replaced: t.parts_replaced,
availability: t.availability, parts_name: t.parts_name,
})) content: t.content,
conclusion: t.conclusion,
assign_time: t.assign_time,
close_time: isOngoing ? periodEndNextStr : (t.close_time || ''),
duration_minutes: t.duration_minutes || 0,
availability: t.availability,
isOngoing,
}
})
const monthDays = getDateRange(periodStart, periodEnd).length const monthDays = getDateRange(periodStart, periodEnd).length
@ -96,13 +109,13 @@ export async function collectMonthlyReportData(
// 4. 第一章:每日在线节点数 // 4. 第一章:每日在线节点数
const dates = getDateRange(periodStart, periodEnd) const dates = getDateRange(periodStart, periodEnd)
// 排除"无故障"分类的工单agent上报异常等跨月工单上月派发本月恢复正常计入 // 排除"无故障"分类和"其他"子分类(其他工单不计入节点在线数)
const monthFaults = tickets.filter(t => const monthFaults = tickets.filter(t =>
t.fault_category !== '无故障' t.fault_category !== '无故障' && t.fault_subcategory !== '其他'
) )
const dailyStats: DailyOnlineStats[] = dates.map(date => { const dailyStats: DailyOnlineStats[] = dates.map(date => {
// 当天不在线assign 日期 ≤ date < close 日期(跨月工单也会正确计入 // 当天不在线assign 日期 ≤ date < close 日期(当日恢复不计入离线
const gpuOffline = monthFaults.filter(t => const gpuOffline = monthFaults.filter(t =>
t.device_type === 'gpu' && t.device_type === 'gpu' &&
t.assign_time.slice(0, 10) <= date && t.assign_time.slice(0, 10) <= date &&
@ -123,8 +136,8 @@ export async function collectMonthlyReportData(
} }
}) })
// 5. 第二章:运营数据总览(仅 gpu/storage排除"无故障"工单) // 5. 第二章:运营数据总览(仅 gpu/storage排除"无故障"和"其他"工单)
const gpuStorageTickets = tickets.filter(t => t.device_type !== 'other' && t.fault_category !== '无故障') const gpuStorageTickets = tickets.filter(t => t.device_type !== 'other' && t.fault_category !== '无故障' && t.fault_subcategory !== '其他')
const chapter2Map = new Map<string, Chapter2FaultItem[]>() const chapter2Map = new Map<string, Chapter2FaultItem[]>()
for (const t of gpuStorageTickets) { for (const t of gpuStorageTickets) {
const assignDate = t.assign_time.slice(0, 10) const assignDate = t.assign_time.slice(0, 10)
@ -133,7 +146,8 @@ export async function collectMonthlyReportData(
chapter2Map.get(key)!.push({ chapter2Map.get(key)!.push({
ip: t.device_ip, ip: t.device_ip,
fault_subcategory: t.fault_subcategory || '未知故障', fault_subcategory: t.fault_subcategory || '未知故障',
recoveryDays: daysBetween(t.assign_time, t.close_time), recoveryDays: t.isOngoing ? 0 : daysBetween(t.assign_time, t.close_time),
isOngoing: t.isOngoing || false,
}) })
} }
const chapter2: Chapter2Entry[] = [] const chapter2: Chapter2Entry[] = []
@ -170,6 +184,7 @@ export async function collectMonthlyReportData(
faultCause: t.parts_name ? `更换${t.parts_name}` : '-', faultCause: t.parts_name ? `更换${t.parts_name}` : '-',
durationMinutes: t.duration_minutes, durationMinutes: t.duration_minutes,
countedInSla: (t.availability !== null && t.availability < 0.99 && !t.conclusion?.includes('无异常')) ? '是' : '否', countedInSla: (t.availability !== null && t.availability < 0.99 && !t.conclusion?.includes('无异常')) ? '是' : '否',
isOngoing: t.isOngoing || false,
} }
} }
@ -182,6 +197,7 @@ export async function collectMonthlyReportData(
ticketConclusion: t.conclusion || '', ticketConclusion: t.conclusion || '',
durationMinutes: t.duration_minutes, durationMinutes: t.duration_minutes,
countedInSla: (t.availability !== null && t.availability < 0.99 && !t.conclusion?.includes('无异常')) ? '是' : '否', countedInSla: (t.availability !== null && t.availability < 0.99 && !t.conclusion?.includes('无异常')) ? '是' : '否',
isOngoing: t.isOngoing || false,
} }
} }
@ -189,12 +205,22 @@ export async function collectMonthlyReportData(
const storageFaults = storageFaultTickets.map(toFaultEntry) const storageFaults = storageFaultTickets.map(toFaultEntry)
const allOtherTickets = [...otherTickets, ...remainingOthers].map(toOtherEntry) const allOtherTickets = [...otherTickets, ...remainingOthers].map(toOtherEntry)
// 7. 第四章:服务可用性说明(仅已结单工单,按 IP 分组求和,排除"无故障"工单 // 7. 第四章:服务可用性说明(已结单工单按实际时长,进行中工单按本月部分
const ipDurationMap = new Map<string, number>() const ipDurationMap = new Map<string, number>()
const ipHasOngoing = new Map<string, boolean>()
for (const t of tickets) { for (const t of tickets) {
if (t.fault_category === '无故障') continue if (t.fault_category === '无故障') continue
const dur = ipDurationMap.get(t.device_ip) || 0 const dur = ipDurationMap.get(t.device_ip) || 0
ipDurationMap.set(t.device_ip, dur + t.duration_minutes) if (t.isOngoing) {
// 进行中工单:计算本月内部分(从 max(assign_date, periodStart) 到月末最后一天)
const assignDate = t.assign_time.slice(0, 10)
const effectiveStart = assignDate > periodStart ? assignDate : periodStart
const affectedDays = daysBetween(effectiveStart, periodEnd) + 1
ipDurationMap.set(t.device_ip, dur + affectedDays * 24 * 60)
ipHasOngoing.set(t.device_ip, true)
} else {
ipDurationMap.set(t.device_ip, dur + t.duration_minutes)
}
} }
const chapter4: Chapter4Entry[] = [] const chapter4: Chapter4Entry[] = []
for (const [ip, totalDuration] of ipDurationMap) { for (const [ip, totalDuration] of ipDurationMap) {
@ -205,6 +231,7 @@ export async function collectMonthlyReportData(
totalDurationMinutes: totalDuration, totalDurationMinutes: totalDuration,
monthDays, monthDays,
availabilityPercent: Math.round(availabilityPercent * 100) / 100, availabilityPercent: Math.round(availabilityPercent * 100) / 100,
hasOngoing: ipHasOngoing.get(ip) || false,
}) })
} }
// 按 IP 排序 // 按 IP 排序
@ -226,6 +253,82 @@ export async function collectMonthlyReportData(
} }
} }
/** 从采集数据构建 metadata JSON供 API 路由和 generate 函数共用) */
export function buildMonthlyMetadata(data: MonthlyReportData): string {
const gpuStorageTickets = data.tickets.filter(t =>
t.device_type !== 'other' && t.fault_category !== '无故障' && t.fault_subcategory !== '其他'
)
const resolvedTickets = gpuStorageTickets.filter(t => !t.isOngoing)
const resolvedCount = resolvedTickets.length
const durations = resolvedTickets
.map(t => t.duration_minutes)
.filter(d => d > 0)
const avgDurationMinutes = durations.length > 0
? Math.round(durations.reduce((s, d) => s + d, 0) / durations.length)
: 0
const ongoingCount = data.tickets.filter(t => t.isOngoing).length
const faultTicketCount = data.chapter3.gpuFaults.length + data.chapter3.storageFaults.length
const affectedDeviceIps = new Set(gpuStorageTickets.map(t => t.device_ip))
const affectedDeviceCount = affectedDeviceIps.size
// 无故障天数按故障影响的全部日期范围assign ~ close含 close当日恢复也计入
// 预览与 DOCX 图表计算方式不同:图表用 date < close 排除当日恢复,预览用全部日期范围
const faultDateSet = new Set<string>()
for (const t of gpuStorageTickets) {
const start = new Date(t.assign_time.slice(0, 10).replace(/-/g, '/'))
const endRaw = t.isOngoing ? data.periodEnd : t.close_time.slice(0, 10)
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 = data.monthDays - faultDateSet.size
const avgAvailability = data.chapter4.length > 0
? Math.round(data.chapter4.reduce((s, e) => s + e.availabilityPercent, 0) / data.chapter4.length * 100) / 100
: 100
const availabilityDetails = data.chapter4
.filter(e => e.availabilityPercent < 100)
.map(e => {
const ticket = data.tickets.find(t => t.device_ip === e.ip)
return {
ip: e.ip,
deviceType: ticket?.device_type || 'other',
durationMinutes: e.totalDurationMinutes,
availabilityPercent: e.availabilityPercent,
isOngoing: e.hasOngoing || false,
}
})
const reportLabel = `${data.monthLabel}图灵IT基础设施运营月报`
return JSON.stringify({
gpuCount: data.gpuTotal,
storageCount: data.storageTotal,
totalTickets: data.tickets.length,
gpuFaultCount: data.chapter3.gpuFaults.length,
storageFaultCount: data.chapter3.storageFaults.length,
otherTicketCount: data.chapter3.otherTickets.length,
avgAvailability,
resolvedCount,
avgDurationMinutes,
ongoingCount,
faultTicketCount,
affectedDeviceCount,
faultFreeDays,
availabilityDetails,
reportLabel,
})
}
/** 异步生成月报fire-and-forget 风格) */ /** 异步生成月报fire-and-forget 风格) */
export async function generateMonthlyReport(reportId: number): Promise<void> { export async function generateMonthlyReport(reportId: number): Promise<void> {
const db = getDb() const db = getDb()
@ -238,31 +341,48 @@ export async function generateMonthlyReport(reportId: number): Promise<void> {
try { try {
const data = await collectMonthlyReportData(report.period_start, report.period_end) const data = await collectMonthlyReportData(report.period_start, report.period_end)
// 并行生成两张图表 // ---- 1. 计算自然周并生成周报 Buffer纯内存 ----
const weeks = getMonthNaturalWeeks(report.period_start, report.period_end)
const weeklyAttachments: WeeklyAttachment[] = []
const weeklyLabels: string[] = []
for (const week of weeks) {
try {
const weekData = await collectWeeklyReportData(week.start, week.end)
const weekBuffer = await buildWeeklyReportDocx(weekData)
weeklyAttachments.push({ label: week.label, buffer: weekBuffer })
weeklyLabels.push(week.label)
console.log(`Weekly report generated: ${week.label}`)
} catch (err) {
console.error(`Failed to generate weekly report for ${week.label}:`, err)
// 周报生成失败不阻塞月报生成
}
}
// ---- 2. 并行生成两张图表 ----
const [gpuChartPng, storageChartPng] = await Promise.all([ const [gpuChartPng, storageChartPng] = await Promise.all([
generateDailyOnlineChart(data.dailyStats, 'gpu'), generateDailyOnlineChart(data.dailyStats, 'gpu'),
generateDailyOnlineChart(data.dailyStats, 'storage'), generateDailyOnlineChart(data.dailyStats, 'storage'),
]) ])
// 组装 DOCX // ---- 3. 组装月报 DOCX ----
const buffer = await buildMonthlyReportDocx(data, { gpuPng: gpuChartPng, storagePng: storageChartPng }) let buffer = await buildMonthlyReportDocx(
data,
{ gpuPng: gpuChartPng, storagePng: storageChartPng },
weeklyLabels,
)
// ---- 4. ZIP 后处理:嵌入周报 OLE 对象 ----
if (weeklyAttachments.length > 0) {
buffer = await embedWeeklyReports(buffer, weeklyAttachments)
}
ensureReportsDir() ensureReportsDir()
const fileName = `${data.monthLabel}图灵IT基础设施运营月报.docx` const fileName = `${data.monthLabel}图灵IT基础设施运营月报.docx`
const filePath = path.join(REPORTS_DIR, fileName) const filePath = path.join(REPORTS_DIR, fileName)
fs.writeFileSync(filePath, buffer) fs.writeFileSync(filePath, buffer)
const metadata = JSON.stringify({ const metadata = buildMonthlyMetadata(data)
gpuCount: data.gpuTotal,
storageCount: data.storageTotal,
totalTickets: data.tickets.length,
gpuFaultCount: data.chapter3.gpuFaults.length,
storageFaultCount: data.chapter3.storageFaults.length,
otherTicketCount: data.chapter3.otherTickets.length,
avgAvailability: data.chapter4.length > 0
? Math.round(data.chapter4.reduce((s, e) => s + e.availabilityPercent, 0) / data.chapter4.length * 100) / 100
: null,
})
db.prepare( db.prepare(
"UPDATE reports SET status = 'completed', file_path = ?, file_name = ?, metadata = ? WHERE id = ?" "UPDATE reports SET status = 'completed', file_path = ?, file_name = ?, metadata = ? WHERE id = ?"

283
src/lib/ole-embed.ts Normal file
View File

@ -0,0 +1,283 @@
// src/lib/ole-embed.ts
import JSZip from 'jszip'
import type { WeekRange, WeeklyAttachment } from '@/types/report'
/**
* ~
*
*/
export function getMonthNaturalWeeks(periodStart: string, periodEnd: string): WeekRange[] {
const start = new Date(periodStart + 'T00:00:00')
const end = new Date(periodEnd + 'T00:00:00')
const weeks: WeekRange[] = []
let index = 1
// 辅助:获取某天所在周的周日
const getSunday = (d: Date): Date => {
const r = new Date(d)
const day = r.getDay()
const diff = day === 0 ? 0 : 7 - day
r.setDate(r.getDate() + diff)
return r
}
// 格式化日期(使用本地时区,避免 toISOString 的 UTC 偏移问题)
const fmt = (d: Date): string => {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
// 首周:从 monthStart 到其所在周的周日
const firstSunday = getSunday(start)
const firstWeekEnd = firstSunday > end ? end : firstSunday
weeks.push({
index: index++,
start: fmt(start),
end: fmt(firstWeekEnd),
label: label(fmt(start), fmt(firstWeekEnd), index - 1),
})
// 如果首周结束已经 >= 月末,直接返回(整月不足一周)
if (firstWeekEnd.getTime() >= end.getTime()) return weeks
// 中间周从首周周日次日周一开始每次取完整7天
let cursor = new Date(firstWeekEnd)
cursor.setDate(cursor.getDate() + 1) // 跳到周一
while (cursor <= end) {
const weekEnd = new Date(cursor)
weekEnd.setDate(weekEnd.getDate() + 6) // 周日
const actualEnd = weekEnd > end ? end : weekEnd
weeks.push({
index: index++,
start: fmt(cursor),
end: fmt(actualEnd),
label: label(fmt(cursor), fmt(actualEnd), index - 1),
})
if (weekEnd >= end) break
cursor.setDate(cursor.getDate() + 7)
}
return weeks
}
function fmtDateDisplay(dateStr: string): string {
const [_, m, d] = dateStr.split('-')
return `${parseInt(m)}/${parseInt(d)}`
}
function label(start: string, end: string, index: number): string {
return `${index}周(${fmtDateDisplay(start)}-${fmtDateDisplay(end)}`
}
/**
* DOCX OLE Package DOCX ZIP
* </w:sectPr> OLE
*/
export async function embedWeeklyReports(
monthlyDocxBuffer: Buffer,
weeklyReports: WeeklyAttachment[],
): Promise<Buffer> {
const zip = await JSZip.loadAsync(monthlyDocxBuffer)
// 1. 注入周报文件到 word/embeddings/
for (let i = 0; i < weeklyReports.length; i++) {
zip.file(`word/embeddings/weekly_${i + 1}.docx`, weeklyReports[i].buffer)
}
// 2. 注入 DOCX 图标(通用 Word 文档图标 EMF
const iconEmf = generateDocxIconEmf()
zip.file('word/media/docx-icon.emf', iconEmf)
// 3. 更新 [Content_Types].xml
await updateContentTypes(zip)
// 4. 更新 word/_rels/document.xml.rels
await updateRels(zip, weeklyReports.length)
// 5. 更新 word/document.xml在 </w:sectPr> 之前插入 OLE 段落
await updateDocumentXml(zip, weeklyReports)
// 6. 重新打包
return Buffer.from(await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }))
}
async function updateContentTypes(zip: JSZip): Promise<void> {
const ctFile = zip.file('[Content_Types].xml')
if (!ctFile) throw new Error('[Content_Types].xml not found')
let xml = await ctFile.async('text')
// 确保 docx 扩展名已注册
if (!xml.includes('Extension="docx"')) {
xml = xml.replace(
'</Types>',
' <Default Extension="docx" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document"/>\n</Types>'
)
}
// 确保 emf 扩展名已注册
if (!xml.includes('Extension="emf"')) {
xml = xml.replace(
'</Types>',
' <Default Extension="emf" ContentType="image/x-emf"/>\n</Types>'
)
}
zip.file('[Content_Types].xml', xml)
}
async function updateRels(zip: JSZip, count: number): Promise<void> {
const relsFile = zip.file('word/_rels/document.xml.rels')
if (!relsFile) throw new Error('word/_rels/document.xml.rels not found')
let xml = await relsFile.async('text')
const additions: string[] = []
for (let i = 1; i <= count; i++) {
additions.push(
` <Relationship Id="rId_W${i}_pkg" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" Target="embeddings/weekly_${i}.docx"/>`
)
additions.push(
` <Relationship Id="rId_W${i}_icon" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/docx-icon.emf"/>`
)
}
xml = xml.replace('</Relationships>', additions.join('\n') + '\n</Relationships>')
zip.file('word/_rels/document.xml.rels', xml)
}
async function updateDocumentXml(zip: JSZip, weeklyReports: WeeklyAttachment[]): Promise<void> {
const docFile = zip.file('word/document.xml')
if (!docFile) throw new Error('word/document.xml not found')
let xml = await docFile.async('text')
// 确保 document 元素有 v: 和 o: 命名空间声明
if (!xml.includes('xmlns:v="urn:schemas-microsoft-com:vml"')) {
xml = xml.replace(
'<w:document ',
'<w:document xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" '
)
}
// 构建所有 OLE 段落
const oleParagraphs = weeklyReports.map((wr, i) => {
const n = i + 1
return `<w:p><w:pPr><w:spacing w:before="100" w:after="100" w:line="360"/><w:ind w:firstLine="480"/></w:pPr><w:r><w:object w:dxaOrig="2880" w:dyaOrig="2880"><v:shape id="weekly_${n}" o:ole="" style="width:36pt;height:36pt"><v:imagedata r:id="rId_W${n}_icon" o:title=""/></v:shape><o:OLEObject Type="Embed" ProgID="Word.Document.12" ShapeID="weekly_${n}" DrawAspect="Icon" ObjectID="_w${n}" r:id="rId_W${n}_pkg"/></w:object></w:r><w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun"/><w:sz w:val="22"/></w:rPr><w:t>周报(${wr.label}</w:t></w:r></w:p>`
})
// 在 </w:sectPr> 之前插入(文档中只出现一次,在末尾)
const insertionPoint = xml.lastIndexOf('</w:sectPr>')
if (insertionPoint === -1) throw new Error('</w:sectPr> not found in document.xml')
xml = xml.slice(0, insertionPoint) + oleParagraphs.join('') + xml.slice(insertionPoint)
zip.file('word/document.xml', xml)
}
/** 生成通用 DOCX 文件图标 EMF32x32 像素,白色文档主体 + 蓝色标题栏) */
function generateDocxIconEmf(): Buffer {
// EMF 记录尺寸常量(单位:字节)
const HDR_SIZE = 108 // EMR_HEADER
const BRUSH_SIZE = 24 // EMR_CREATEBRUSHINDIRECT (ihBrush + LogBrushEx)
const SELECT_SIZE = 12 // EMR_SELECTOBJECT (ihObject)
const RECT_SIZE = 24 // EMR_RECTANGLE (rclBox: 16 bytes, no extra fields)
const EOF_SIZE = 20 // EMR_EOF
// 总字节数和记录数
const totalSize = HDR_SIZE
+ BRUSH_SIZE + SELECT_SIZE + RECT_SIZE // white brush → body rect
+ BRUSH_SIZE + SELECT_SIZE + RECT_SIZE // blue brush → title bar
+ EOF_SIZE
// 8 records: header + 6 GDI commands + EOF
const nRecords = 8
const buf = Buffer.alloc(totalSize, 0)
let o = 0
// ---- EMR_HEADER (108 bytes) ----
buf.writeUInt32LE(1, o); o += 4 // iType = EMR_HEADER
buf.writeUInt32LE(HDR_SIZE, o); o += 4 // nSize
buf.writeInt32LE(0, o); o += 4 // rclBounds.left
buf.writeInt32LE(0, o); o += 4 // rclBounds.top
buf.writeInt32LE(32, o); o += 4 // rclBounds.right (device units, = szlDevice)
buf.writeInt32LE(32, o); o += 4 // rclBounds.bottom
buf.writeInt32LE(0, o); o += 4 // rclFrame.left
buf.writeInt32LE(0, o); o += 4 // rclFrame.top
buf.writeInt32LE(1100, o); o += 4 // rclFrame.right (0.01mm, = szlMillimeters*100)
buf.writeInt32LE(1100, o); o += 4 // rclFrame.bottom
buf.writeUInt32LE(0x464D4520, o); o += 4 // dSignature
buf.writeUInt32LE(0x00010000, o); o += 4 // nVersion
buf.writeUInt32LE(totalSize, o); o += 4 // nBytes
buf.writeUInt32LE(nRecords, o); o += 4 // nRecords
buf.writeUInt16LE(2, o); o += 2 // nHandles (2 brushes)
buf.writeUInt16LE(0, o); o += 2 // sReserved
buf.writeUInt32LE(0, o); o += 4 // nDescription
buf.writeUInt32LE(0, o); o += 4 // offDescription
buf.writeUInt32LE(0, o); o += 4 // nPalEntries
buf.writeUInt32LE(32, o); o += 4 // szlDevice.cx
buf.writeUInt32LE(32, o); o += 4 // szlDevice.cy
buf.writeUInt32LE(11, o); o += 4 // szlMillimeters.cx
buf.writeUInt32LE(11, o); o += 4 // szlMillimeters.cy
buf.writeUInt32LE(0, o); o += 4 // cbPixelFormat
buf.writeUInt32LE(0, o); o += 4 // offPixelFormat
buf.writeUInt32LE(0, o); o += 4 // bOpenGL
buf.writeUInt32LE(11000, o); o += 4 // szlMicrometers.cx
buf.writeUInt32LE(11000, o); o += 4 // szlMicrometers.cy
// o == 108
// ---- Record 1: EMR_CREATEBRUSHINDIRECT (white, ihBrush=1) ----
buf.writeUInt32LE(39, o); o += 4 // iType
buf.writeUInt32LE(BRUSH_SIZE, o); o += 4 // nSize
buf.writeUInt32LE(1, o); o += 4 // ihBrush
buf.writeUInt32LE(0, o); o += 4 // lbStyle = BS_SOLID
buf.writeUInt32LE(0x00FFFFFF, o); o += 4 // lbColor = white
buf.writeUInt32LE(0, o); o += 4 // lbHatch
// ---- Record 2: EMR_SELECTOBJECT (handle 1) ----
buf.writeUInt32LE(37, o); o += 4 // iType
buf.writeUInt32LE(SELECT_SIZE, o); o += 4 // nSize
buf.writeUInt32LE(1, o); o += 4 // ihObject
// ---- Record 3: EMR_RECTANGLE (document body: 3,5 ~ 29,29) ----
buf.writeUInt32LE(43, o); o += 4 // iType
buf.writeUInt32LE(RECT_SIZE, o); o += 4 // nSize = 24
buf.writeInt32LE(3, o); o += 4 // left
buf.writeInt32LE(5, o); o += 4 // top
buf.writeInt32LE(29, o); o += 4 // right
buf.writeInt32LE(29, o); o += 4 // bottom
// ---- Record 4: EMR_CREATEBRUSHINDIRECT (blue #4472C4, ihBrush=2) ----
buf.writeUInt32LE(39, o); o += 4
buf.writeUInt32LE(BRUSH_SIZE, o); o += 4
buf.writeUInt32LE(2, o); o += 4
buf.writeUInt32LE(0, o); o += 4 // BS_SOLID
buf.writeUInt32LE(0x00C47244, o); o += 4 // BGR: 4472C4
buf.writeUInt32LE(0, o); o += 4
// ---- Record 5: EMR_SELECTOBJECT (handle 2) ----
buf.writeUInt32LE(37, o); o += 4
buf.writeUInt32LE(SELECT_SIZE, o); o += 4
buf.writeUInt32LE(2, o); o += 4
// ---- Record 6: EMR_RECTANGLE (blue title bar: 3,5 ~ 29,12) ----
buf.writeUInt32LE(43, o); o += 4
buf.writeUInt32LE(RECT_SIZE, o); o += 4
buf.writeInt32LE(3, o); o += 4
buf.writeInt32LE(5, o); o += 4
buf.writeInt32LE(29, o); o += 4
buf.writeInt32LE(12, o); o += 4
// ---- Record 7: EMR_EOF ----
buf.writeUInt32LE(14, o); o += 4 // iType
buf.writeUInt32LE(EOF_SIZE, o); o += 4 // nSize = 20
buf.writeUInt32LE(0, o); o += 4 // nPalEntries
buf.writeUInt32LE(0, o); o += 4 // offLast → 0 (first/only EMR_EOF per MS-EMF 2.3.5.2)
buf.writeUInt32LE(EOF_SIZE, o); o += 4 // nSizeLast (MUST equal nSize)
return buf
}

View File

@ -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 []
}
}

View File

@ -88,6 +88,7 @@ export async function collectWeeklyReportData(
currentStatus: t.current_status, currentStatus: t.current_status,
isResolved, isResolved,
steps, steps,
availability: t.availability,
} }
}) })
@ -166,6 +167,80 @@ export async function collectWeeklyReportData(
} }
} }
/** 从采集数据构建 metadata JSON */
export function buildWeeklyMetadata(data: WeeklyReportData): string {
const faultTickets = [...data.gpuFaultTickets, ...data.storageFaultTickets]
const resolvedTickets = faultTickets.filter(t => t.isResolved)
const resolvedCount = resolvedTickets.length
const durations = resolvedTickets
.map(t => {
if (!t.closeTime) return 0
const assign = new Date(t.assignTime).getTime()
const close = new Date(t.closeTime).getTime()
return Math.round((close - assign) / 60000)
})
.filter(d => d > 0)
const avgDurationMinutes = durations.length > 0
? Math.round(durations.reduce((s, d) => s + d, 0) / durations.length)
: 0
const allTickets = [...data.gpuFaultTickets, ...data.storageFaultTickets, ...data.otherTickets]
const ongoingCount = allTickets.filter(t => !t.isResolved).length
const faultTicketCount = data.totalFaultCount
const affectedDeviceIps = new Set(faultTickets.map(t => t.deviceIp))
const affectedDeviceCount = affectedDeviceIps.size
const availabilities = faultTickets
.map(t => t.availability)
.filter((a): a is number => a !== null && a !== undefined)
const avgAvailability = availabilities.length > 0
? Math.round(availabilities.reduce((s, v) => s + v, 0) / availabilities.length * 10000) / 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}`
return JSON.stringify({
gpuCount: data.gpuTotal,
storageCount: data.storageTotal,
totalTickets: data.gpuFaultTickets.length + data.storageFaultTickets.length + data.otherTickets.length,
gpuFaultCount: data.gpuFaultTickets.length,
storageFaultCount: data.storageFaultTickets.length,
otherTicketCount: data.otherTickets.length,
avgAvailability,
resolvedCount,
avgDurationMinutes,
ongoingCount,
faultTicketCount,
affectedDeviceCount,
faultFreeDays,
availabilityDetails: null,
reportLabel,
})
}
/** 异步生成周报 */ /** 异步生成周报 */
export async function generateWeeklyReport(reportId: number): Promise<void> { export async function generateWeeklyReport(reportId: number): Promise<void> {
const db = getDb() const db = getDb()
@ -185,14 +260,7 @@ export async function generateWeeklyReport(reportId: number): Promise<void> {
const filePath = path.join(REPORTS_DIR, fileName) const filePath = path.join(REPORTS_DIR, fileName)
fs.writeFileSync(filePath, buffer) fs.writeFileSync(filePath, buffer)
const metadata = JSON.stringify({ const metadata = buildWeeklyMetadata(data)
gpuCount: data.gpuTotal,
storageCount: data.storageTotal,
totalTickets: data.gpuFaultTickets.length + data.storageFaultTickets.length + data.otherTickets.length,
gpuFaultCount: data.gpuFaultTickets.length,
storageFaultCount: data.storageFaultTickets.length,
otherTicketCount: data.otherTickets.length,
})
db.prepare( db.prepare(
"UPDATE reports SET status = 'completed', file_path = ?, file_name = ?, metadata = ? WHERE id = ?" "UPDATE reports SET status = 'completed', file_path = ?, file_name = ?, metadata = ? WHERE id = ?"

View File

@ -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() const loginUrl = new URL('/login', request.url)
if (pathname === '/api/auth/login') return NextResponse.next() const dest = pathname + (request.nextUrl.search || '')
loginUrl.searchParams.set('redirect', dest)
return NextResponse.redirect(loginUrl)
}
const authHeader = request.headers.get('authorization') export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// API Key 认证Bearer ak_xxx 格式 // 登录/退出路径 + 内部 API 放行(自有 key 认证)
if (authHeader?.startsWith('Bearer ak_')) { if (pathname.startsWith('/login') || pathname === '/' ||
const key = authHeader.slice(7) pathname === '/api/auth/login' || pathname === '/api/auth/logout' ||
if (verifyApiKey(key)) return NextResponse.next() pathname.startsWith('/api/internal/')) {
// 环境变量中未匹配API 路由仍放行route handler 可查询数据库二次验证)
if (pathname.startsWith('/api/')) return NextResponse.next()
}
// Cookie 认证
const token = request.cookies.get('session_issue')?.value
// 构建带 redirect 参数的登录 URL
function buildLoginRedirect() {
const loginUrl = new URL('/login', request.url)
const dest = pathname + (request.nextUrl.search || '')
loginUrl.searchParams.set('redirect', dest)
const response = NextResponse.redirect(loginUrl)
if (token) response.cookies.delete('session_issue')
return response
}
if (pathname.startsWith('/api/')) {
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
const valid = await verifyToken(token)
if (!valid) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
return NextResponse.next() 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()
// 无效 keyAPI 路由返回 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
} }

View File

@ -1,7 +1,7 @@
// 报告基础类型 // 报告基础类型
export type ReportType = 'weekly' | 'monthly' export type ReportType = 'weekly' | 'monthly'
export type ReportFormat = 'pdf' | 'docx' export type ReportFormat = 'pdf' | 'docx'
export type ReportStatus = 'pending' | 'generating' | 'completed' | 'failed' export type ReportStatus = 'pending' | 'ready' | 'generating' | 'completed' | 'failed'
export interface ReportCreateInput { export interface ReportCreateInput {
report_type: ReportType report_type: ReportType
@ -39,6 +39,7 @@ export interface ClassifiedTicket {
close_time: string close_time: string
duration_minutes: number duration_minutes: number
availability: number | null availability: number | null
isOngoing?: boolean
} }
// 第一章:每日在线节点统计 // 第一章:每日在线节点统计
@ -54,7 +55,8 @@ export interface DailyOnlineStats {
export interface Chapter2FaultItem { export interface Chapter2FaultItem {
ip: string ip: string
fault_subcategory: string fault_subcategory: string
recoveryDays: number // 0=当日, 1=次日, ≥2=N日后 recoveryDays: number // 0=当日, 1=次日, ≥2=N日后仅已结单有效进行中时为 0
isOngoing?: boolean
} }
export interface Chapter2Entry { export interface Chapter2Entry {
@ -72,6 +74,7 @@ export interface Chapter3FaultEntry {
faultCause: string // "更换" + parts_replaced 或 "-" faultCause: string // "更换" + parts_replaced 或 "-"
durationMinutes: number durationMinutes: number
countedInSla: '是' | '否' countedInSla: '是' | '否'
isOngoing?: boolean
} }
// 第三章其他工单表行7列表头不同 // 第三章其他工单表行7列表头不同
@ -83,6 +86,7 @@ export interface Chapter3OtherEntry {
ticketConclusion: string // conclusion ticketConclusion: string // conclusion
durationMinutes: number durationMinutes: number
countedInSla: '是' | '否' countedInSla: '是' | '否'
isOngoing?: boolean
} }
// 第四章每IP服务可用性 // 第四章每IP服务可用性
@ -91,6 +95,7 @@ export interface Chapter4Entry {
totalDurationMinutes: number totalDurationMinutes: number
monthDays: number monthDays: number
availabilityPercent: number // e.g. 97.28 availabilityPercent: number // e.g. 97.28
hasOngoing?: boolean
} }
// 月报顶层聚合数据 // 月报顶层聚合数据
@ -125,6 +130,38 @@ export interface MonthlyReportPreview {
avgAvailability: number | null avgAvailability: number | null
} }
// 预览页完整 metadata 结构(新格式)
export interface ReportPreviewData {
// 原有字段
gpuCount: number
storageCount: number
totalTickets: number
gpuFaultCount: number
storageFaultCount: number
otherTicketCount: number
avgAvailability: number | null // 无工单时为 null否则为百分比数值
// 新增字段
resolvedCount: number
avgDurationMinutes: number
ongoingCount: number
faultTicketCount: number
affectedDeviceCount: number
faultFreeDays: number | null // 周报为 null
availabilityDetails: AvailabilityDetail[] | null // 周报为 null
reportLabel: string
// 旧格式兼容
summary?: { total_tickets: number; resolved_tickets: number; avg_duration: number; sla_rate: number }
categories?: Array<{ fault_category: string; count: number }>
}
export interface AvailabilityDetail {
ip: string
deviceType: 'gpu' | 'storage' | 'other'
durationMinutes: number
availabilityPercent: number
isOngoing: boolean
}
// ===== 周报类型 ===== // ===== 周报类型 =====
export interface WeeklyDailyStats { export interface WeeklyDailyStats {
@ -157,6 +194,7 @@ export interface WeeklyTicketEntry {
currentStatus: string // 'resolved' | 'closed' | 'in_progress' | 'open' currentStatus: string // 'resolved' | 'closed' | 'in_progress' | 'open'
isResolved: boolean isResolved: boolean
steps: TicketStepRaw[] steps: TicketStepRaw[]
availability: number | null
} }
export interface WeeklyReportData { export interface WeeklyReportData {
@ -175,3 +213,17 @@ export interface WeeklyReportData {
resolvedCount: number resolvedCount: number
pendingCount: number pendingCount: number
} }
// 自然周范围(用于月报嵌入周报)
export interface WeekRange {
index: number // 第几周 (1, 2, 3, ...)
start: string // "2026-04-01"
end: string // "2026-04-05"
label: string // "第1周04/01-04/05"
}
// 周报附件(嵌入用)
export interface WeeklyAttachment {
label: string
buffer: Buffer
}