Compare commits

..

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

73 changed files with 319 additions and 4198 deletions

View File

@ -1,34 +0,0 @@
# 依赖 —— 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 管理界面生成
ALLOWED_API_KEYS=your-issue-api-key
# NEXT_PUBLIC_ 前缀:构建时内嵌到客户端 JS云上必须通过 deploy-ai.sh 设置
# 本地开发http://localhost:6177
# 本地开发http://localhost:5177
# 云上生产https://assets.tlyq.ai
NEXT_PUBLIC_ASSETS_URL=http://localhost:6177
NEXT_PUBLIC_ASSETS_URL=http://localhost:5177
NODE_ENV=development

2
.gitignore vendored
View File

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

View File

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

@ -1 +0,0 @@
{"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

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

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

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

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

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

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

@ -1 +0,0 @@
{"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,80 +1,5 @@
# 变更日志
## 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
- [修复] 云服务器月报生成失败:重建 Docker 镜像安装 echartsDockerfile 补全 Chromium 系统依赖库libglib2.0、libnss3 等 18 个)

View File

@ -13,7 +13,7 @@ issue-ai 是基于 Next.js + SQLite 的工单跟踪管理系统,部署在腾
| 站点域名 | `issue.tlyq.ai` |
| 服务器 | txjpIP: 43.133.38.210 |
| 代码路径 | `/root/docker/issue-ai/` |
| 本地端口 | 6176 |
| 本地端口 | 5176 |
| 容器名 | `issue-ai` |
| 数据库 | SQLite`data/issue.db` |
| 报告存储 | `reports/` 目录(环境变量 `REPORTS_DIR` 可覆盖) |
@ -99,16 +99,11 @@ 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` | 登录(LDAP 优先 + 本地回退 |
| POST | `/api/auth/logout` | 登出(清除两个 cookie |
| POST | `/api/auth/login` | 登录username + password → JWT cookie |
| POST | `/api/auth/logout` | 登出 |
| GET | `/api/auth/me` | 当前用户信息 |
| GET | `/api/internal/roles` | 内部 API返回角色列表x-internal-key 鉴权) |
### 工单
@ -138,23 +133,15 @@ 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 | 说明 |
|---------|---------|----------------|------|
| `ASSETS_API_URL` | `http://localhost:6177/api` | `http://assets-ai:3000/api` | 调用 assets API 地址 |
| `ASSETS_API_URL` | `http://localhost:5177/api` | `http://assets-ai:3000/api` | 调用 assets API 地址 |
| `ASSETS_API_KEY` | 本地 assets-ai 生成 | 云上 assets-ai 生成 | **每个环境独立,不可跨环境使用** |
| `NEXT_PUBLIC_ASSETS_URL` | `http://localhost:6177` | `https://assets.tlyq.ai` | 前端跳转链接(构建时内嵌) |
| `NEXT_PUBLIC_ASSETS_URL` | `http://localhost:5177` | `https://assets.tlyq.ai` | 前端跳转链接(构建时内嵌) |
| `ALLOWED_API_KEYS` | 本地 issue-ai 生成的 Key | 云上 issue-ai 生成的 Key | 允许外部系统调本系统的 Key逗号分隔 |
| `JWT_SECRET` | `dev-secret-key-local` | `${ISSUE_JWT_SECRET}` | 本地两系统需一致(同 localhost 域) |
| `DATABASE_PATH` | `./data/issue.db` | `/app/data/issue.db` | Docker volume 挂载 |
@ -167,10 +154,10 @@ DATABASE_PATH=./data/issue.db
JWT_SECRET=dev-secret-key-local
ADMIN_PASSWORD=admin123
NODE_ENV=development
ASSETS_API_URL=http://localhost:6177/api
ASSETS_API_URL=http://localhost:5177/api
ASSETS_API_KEY=ak_<32字节十六进制>
ALLOWED_API_KEYS=ak_<32字节十六进制>
NEXT_PUBLIC_ASSETS_URL=http://localhost:6177
NEXT_PUBLIC_ASSETS_URL=http://localhost:5177
```
---
@ -189,14 +176,10 @@ assets-ai ──→ GET {ISSUE_API_URL}/tickets/by-asset?ip=xxx (Authori
Key 格式:`ak_<32位十六进制>`,认证头:`Authorization: Bearer <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` 环境变量)
**issue → assets 方向**:在 assets-ai `/settings/api-keys` 创建 Key → 写入 issue-ai 的 `ASSETS_API_KEY`
**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 部署
@ -242,21 +225,6 @@ NEXT_PUBLIC_ASSETS_URL=https://assets.tlyq.ai
- **新增 API**:在 `src/app/api/` 下创建路由 → 顶部调用 `initDatabase()``getCurrentUser()` 验证 → `hasPermission()` 校验
- **新增页面**:在 `src/app/(app)/` 下创建 → 布局由 `(app)/layout.tsx` 提供
- **权限格式**`resource:action`,如 `hasPermission(user, 'tickets:write')`
- **日期处理(时区规范)**:整个系统统一使用 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,11 +33,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libcairo2 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/package.json /app/package-lock.json ./
RUN npm install --omit=dev
COPY --from=builder /app/.next/standalone ./
RUN npm install --omit=dev && \
npm rebuild better-sqlite3 && \
rm -rf node_modules/@img/sharp-linuxmusl-x64 node_modules/@img/sharp-libvips-linuxmusl-x64 \
node_modules/@next/swc-linux-x64-musl
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
RUN mkdir -p /app/data /app/uploads /app/reports

View File

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

View File

@ -3,28 +3,21 @@ services:
build: .
container_name: issue-ai
ports:
- "6176:3000"
- "5176:3000"
volumes:
- issue-data:/app/data
- issue-uploads:/app/uploads
- issue-reports:/app/reports
# .next 目录从主机挂载npm run build 后直接生效,无需重建镜像
- ./.next:/app/.next
# 运行时从 LLDAP 容器动态读取 admin 密码
- /var/run/docker.sock:/var/run/docker.sock
environment:
- DATABASE_PATH=/app/data/issue.db
- JWT_SECRET=oa-shared-jwt-secret-tlyq-2026
- JWT_SECRET=${ISSUE_JWT_SECRET:-change-me-in-production}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
- ASSETS_API_URL=${ASSETS_API_URL:-https://assets.tlyq.ai/api}
- ASSETS_API_KEY=${ASSETS_API_KEY}
- ALLOWED_API_KEYS=${ALLOWED_API_KEYS}
- NODE_ENV=production
- COOKIE_DOMAIN=.tlyq.ai
- AUTHELIA_URL=${AUTHELIA_URL:-https://sso.tlyq.ai}
- LDAP_URL=ldap://lldap:3890
- LDAP_BASE_DN=dc=tlyq,dc=ai
- LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
- TZ=Asia/Shanghai
restart: unless-stopped
networks:

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

20
package-lock.json generated
View File

@ -14,8 +14,6 @@
"docx": "^9.1.1",
"echarts": "^5.5.0",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
"ldapts": "^8.1.7",
"lucide-react": "^1.8.0",
"next": "^15.1.0",
"puppeteer": "^23.0.0",
@ -3593,18 +3591,6 @@
"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": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -4991,12 +4977,6 @@
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",

View File

@ -3,7 +3,7 @@
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "NODE_OPTIONS='--max-old-space-size=2048' next dev --port 6176",
"dev": "NODE_OPTIONS='--max-old-space-size=2048' next dev --port 5176",
"build": "next build",
"start": "next start",
"lint": "next lint",
@ -17,8 +17,6 @@
"docx": "^9.1.1",
"echarts": "^5.5.0",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
"ldapts": "^8.1.7",
"lucide-react": "^1.8.0",
"next": "^15.1.0",
"puppeteer": "^23.0.0",

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

View File

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

View File

@ -1,418 +0,0 @@
'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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,6 @@
import { NextResponse } from 'next/server'
export async function POST() {
const r = NextResponse.json({ success: true })
r.cookies.set('session_issue', '', { maxAge: 0, path: '/' })
r.cookies.set('tlyq_session', '', { maxAge: 0, path: '/' })
return r
}

View File

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

View File

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

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

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

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

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

@ -1,103 +0,0 @@
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 db = getDb()
const existing = db.prepare('SELECT * FROM roles WHERE id = ?').get(id) as any
const existing = db.prepare('SELECT * FROM roles WHERE id = ?').get(id)
if (!existing) return NextResponse.json({ error: '角色不存在' }, { status: 404 })
const fields: string[] = []

View File

@ -90,7 +90,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
if (fields.length > 0) {
fields.push('updated_by = ?')
values.push(user.id)
fields.push("updated_at = datetime('now', '+8 hours')")
fields.push("updated_at = datetime('now')")
values.push(id)
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
fields.push("updated_at = datetime('now', '+8 hours')")
fields.push("updated_at = datetime('now')")
fields.push('updated_by = ?')
values.push(user.id)
values.push(item.id)

View File

@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { exportTicketsToExcel } from '@/lib/excel'
export async function GET(request: NextRequest) {
@ -10,7 +9,6 @@ export async function GET(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'tickets:export')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { searchParams } = request.nextUrl
const idsParam = searchParams.get('ids')

View File

@ -24,7 +24,7 @@ export async function POST(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'tickets:import')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const formData = await request.formData()
const file = formData.get('file') as File | null

View File

@ -101,7 +101,7 @@ export async function POST(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'tickets:create')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const body = await request.json()
const db = getDb()

View File

@ -4,23 +4,6 @@ import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser, hashPassword } from '@/lib/auth'
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 }> }) {
try {
initDatabase()
@ -32,14 +15,9 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const body = await request.json()
const db = getDb()
const existing = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as { id: number; username: string } | undefined
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id)
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 values: unknown[] = []
@ -50,15 +28,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
if (body.password) { fields.push('password_hash = ?'); values.push(hashPassword(body.password)) }
if (fields.length > 0) {
fields.push("updated_at = datetime('now', '+8 hours')")
fields.push("updated_at = datetime('now')")
values.push(id)
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values)
}
const updated = db.prepare(`SELECT id, username, display_name, email, role, is_active, created_at, updated_at,
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)
const updated = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at, updated_at FROM users WHERE id = ?').get(id)
return NextResponse.json({ user: updated })
} catch (e) {
const msg = e instanceof Error ? e.message : '更新失败'
@ -77,11 +52,8 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
if (String(id) === String(user.id)) return NextResponse.json({ error: '不能删除自己' }, { status: 400 })
const db = getDb()
const existing = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as { id: number; username: string } | undefined
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id)
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)
return NextResponse.json({ success: true })

View File

@ -13,10 +13,7 @@ export async function GET() {
if (!hasPermission(user, 'users:read')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const db = getDb()
const users = db.prepare(`SELECT id, username, display_name, email, role, is_active, created_at, updated_at,
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()
const users = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at, updated_at FROM users ORDER BY id').all()
return NextResponse.json({ users })
} catch (e) {
const msg = e instanceof Error ? e.message : '查询失败'
@ -44,7 +41,7 @@ export async function POST(request: NextRequest) {
const hash = hashPassword(password)
const result = db.prepare(
"INSERT INTO users (username, password_hash, display_name, email, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
'INSERT INTO users (username, password_hash, display_name, email, role) VALUES (?, ?, ?, ?, ?)'
).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)

View File

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

View File

@ -5,53 +5,42 @@ import { usePathname } from 'next/navigation'
import { LayoutDashboard, FileText, Settings, Users, Shield, Key, Clock, CheckCircle, PlusSquare, Upload, List } from 'lucide-react'
const navItems = [
{ href: '/dashboard', label: '仪表盘', icon: LayoutDashboard, perm: null },
{ href: '/tickets/pending', label: '待办工单', icon: Clock, perm: 'tickets:read' },
{ href: '/tickets/completed', label: '已办工单', icon: CheckCircle, perm: 'tickets:read' },
{ href: '/tickets/create', label: '手动建单', icon: PlusSquare, perm: 'tickets:create' },
{ href: '/tickets/import', label: '导入工单', icon: Upload, perm: 'tickets:import' },
{ href: '/reports', label: '报告管理', icon: FileText, perm: 'reports:read' },
{ href: '/dashboard', label: '仪表盘', icon: LayoutDashboard },
{ href: '/tickets/pending', label: '待办工单', icon: Clock },
{ href: '/tickets/completed', label: '已办工单', icon: CheckCircle },
{ href: '/tickets/create', label: '手动建单', icon: PlusSquare },
{ href: '/tickets/import', label: '导入工单', icon: Upload },
{ href: '/reports', label: '报告管理', icon: FileText },
]
const settingsItems = [
{ href: '/settings/users', label: '用户管理', icon: Users, perm: 'users:read' },
{ href: '/settings/roles', label: '角色权限', icon: Shield, perm: 'roles:read' },
{ href: '/settings/api-keys', label: 'API Key', icon: Key, perm: 'api-keys:read' },
{ href: '/settings/users', label: '用户管理', icon: Users },
{ href: '/settings/roles', label: '角色权限', icon: Shield },
{ href: '/settings/api-keys', label: 'API Key', icon: Key },
]
function hasAnyAdminPerm(permissions: string[]): boolean {
return permissions.includes('*') || permissions.some(p => p.startsWith('users:') || p.startsWith('roles:') || p.startsWith('api-keys:'))
}
export default function Sidebar() {
const pathname = usePathname()
const [permissions, setPermissions] = useState<string[]>([])
const [isAdmin, setIsAdmin] = useState(false)
useEffect(() => {
fetch('/api/auth/me')
.then(r => r.json())
.then(u => { if (u.user?.permissions) setPermissions(u.user.permissions) })
.then(u => { if (u.user?.role === 'admin') setIsAdmin(true) })
.catch(() => {})
}, [])
const canSee = (perm: string | null) => {
if (perm === null) return true
if (permissions.includes('*')) return true
return permissions.includes(perm)
}
return (
<aside className="fixed left-0 top-0 bottom-0 w-60 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex flex-col z-40">
<div className="h-14 flex items-center px-5 border-b border-slate-200 dark:border-slate-800">
<span className="text-lg font-semibold text-blue-600 dark:text-blue-400">IT工单跟踪系统</span>
</div>
<nav className="flex-1 py-3 px-3 space-y-1 overflow-y-auto">
{navItems.filter(item => canSee(item.perm)).map((item) => {
{navItems.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + '/')
const Icon = item.icon
return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>)
})}
{permissions.includes('*') && (
{isAdmin && (
<Link
href="/tickets/all"
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${pathname === '/tickets/all' ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}
@ -59,18 +48,16 @@ export default function Sidebar() {
<List size={18} />
</Link>
)}
{hasAnyAdminPerm(permissions) && (
<div className="pt-3 border-t border-slate-200 dark:border-slate-800 mt-3">
<div className="flex items-center gap-3 px-3 py-2 text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
<Settings size={14} />
</div>
{settingsItems.filter(item => canSee(item.perm)).map((item) => {
{settingsItems.map((item) => {
const isActive = pathname === item.href
const Icon = item.icon
return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>)
})}
</div>
)}
</nav>
</aside>
)

View File

@ -8,10 +8,7 @@ interface TopBarProps { user: { username: string; display_name: string; role: st
export default function TopBar({ user }: TopBarProps) {
const { theme, toggleTheme } = useTheme()
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 (
<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 />

View File

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

View File

@ -1,20 +0,0 @@
'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

@ -1,57 +0,0 @@
'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,8 +1,13 @@
'use client'
import { InputHTMLAttributes, SelectHTMLAttributes, ReactNode } from 'react'
import { ButtonHTMLAttributes, InputHTMLAttributes, SelectHTMLAttributes, ReactNode } from 'react'
export { default as Modal } from './Modal'
export { default as Button } from './Button'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; size?: 'sm' | 'md' | 'lg' }
export function Button({ variant = 'primary', size = 'md', className = '', children, ...props }: ButtonProps) {
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 }
export function Input({ label, error, className = '', ...props }: InputProps) {
@ -14,9 +19,15 @@ 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>)
}
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 }
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-300">{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-400">{h}</th>))}</tr></thead><tbody className="divide-y divide-slate-200 dark:divide-slate-700">{children}</tbody></table></div>)
}
import { useState } from 'react'

View File

@ -1,4 +1,4 @@
const ASSETS_API_URL = process.env.ASSETS_API_URL || 'http://localhost:6177/api'
const ASSETS_API_URL = process.env.ASSETS_API_URL || 'http://localhost:5177/api'
const ASSETS_API_KEY = process.env.ASSETS_API_KEY || ''
export interface Asset {

View File

@ -6,64 +6,11 @@ import { createToken, verifyToken, type UserPayload } from './jwt'
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> {
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
if (!token) return null
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
return verifyToken(token)
}
export async function login(username: string, password: string) {
@ -71,7 +18,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
if (!user) 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, permissions: getUserPermissions(user.role) }
const payload: UserPayload = { id: user.id, username: user.username, display_name: user.display_name, role: user.role }
return { token: await createToken(payload), user: payload }
}
@ -101,10 +48,10 @@ export function verifyApiKey(key: string): ApiKeyInfo | null {
if (!key.startsWith('ak_')) return null
const db = getDb()
const keyHash = hashApiKey(key)
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; expires_at: string | null } | undefined
const row = db.prepare('SELECT id, name, permissions FROM api_keys WHERE key_hash = ? AND is_active = 1')
.get(keyHash) as { id: number; name: string; permissions: string } | undefined
if (!row) 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', '+8 hours') WHERE id = ?").run(row.id)
db.prepare("UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?").run(row.id)
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 {
const db = getDb()
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', '+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', '+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', '+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', '+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', '+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', '+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', '+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', '+8 hours')));"
"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 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 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 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 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 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 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 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')));"
]
for (const sql of schema) db.exec(sql)
@ -29,10 +29,6 @@ export function initDatabase(): void {
db.prepare("UPDATE tickets SET ticket_type = 'OEM维修' WHERE ticket_type IS NULL AND fault_category IN ('硬件故障', '网络故障', '存储故障', '电源故障')").run()
} 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
try { db.exec('ALTER TABLE reports ADD COLUMN metadata TEXT') } catch { /* 已存在 */ }
@ -49,18 +45,12 @@ export function initDatabase(): void {
if (!existing) {
const defaultPassword = process.env.ADMIN_PASSWORD || 'admin123'
const hash = bcrypt.hashSync(defaultPassword, 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('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')
db.prepare('INSERT INTO users (username, password_hash, display_name, role) VALUES (?, ?, ?, ?)').run('admin', hash, '系统管理员', 'admin')
}
const roles = [
{ name: 'admin', display_name: '管理员', permissions: '["*"]' },
{ name: 'operator', display_name: '运维人员', permissions: '["tickets:read","tickets:create","tickets:import","tickets:export","tickets:write","reports:read","reports:download","reports:create"]' },
{ name: 'viewer', display_name: '查看者', permissions: '["tickets:read","tickets:export","reports:read","reports:download"]' },
{ name: 'operator', display_name: '运维人员', permissions: '["tickets:read","tickets:write","reports:read"]' },
{ name: 'viewer', display_name: '查看者', permissions: '["tickets:read","reports:read"]' },
]
for (const r of roles) {
const ex = db.prepare('SELECT id FROM roles WHERE name = ?').get(r.name)

View File

@ -1,61 +0,0 @@
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,7 +8,6 @@ export interface UserPayload {
username: string
display_name: string
role: string
permissions: string[] // 用户权限列表
}
// Encode Uint8Array to base64url
@ -73,7 +72,7 @@ export async function verifyToken(token: string): Promise<UserPayload | null> {
const payload = JSON.parse(base64urlToStr(parts[1]))
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null
if (payload.id == null) return null
return { id: payload.id, username: payload.username, display_name: payload.display_name, role: payload.role, permissions: payload.permissions || [] }
return { id: payload.id, username: payload.username, display_name: payload.display_name, role: payload.role }
} catch {
return null
}

View File

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

View File

@ -2,13 +2,9 @@ import { getDb } from './db'
import { getActiveDevices, DEVICE_TYPE_GPU, DEVICE_TYPE_STORAGE } from './assets-client'
import { generateDailyOnlineChart } from './monthly-report-charts'
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 {
ClassifiedTicket, DailyOnlineStats, Chapter2Entry, Chapter2FaultItem,
Chapter3FaultEntry, Chapter3OtherEntry, Chapter4Entry, MonthlyReportData,
WeeklyAttachment,
} from '@/types/report'
import fs from 'fs'
import path from 'path'
@ -61,43 +57,34 @@ export async function collectMonthlyReportData(
if (d.hdm_ip) ipTypeMap.set(d.hdm_ip, 'storage')
}
// 2. 查询当月已结单工单 + 跨月进行中工单
// 2. 查询当月已结单工单(按 close_time 范围)
const db = getDb()
const endFull = periodEnd + ' 23:59:59'
const ticketsRaw = db.prepare(`
SELECT * FROM tickets WHERE (
(close_time >= ? AND close_time <= ?)
OR
(assign_time <= ? AND (close_time IS NULL OR close_time > ?))
)
SELECT * FROM tickets
WHERE close_time >= ? AND close_time <= ?
AND current_status IN ('resolved', 'closed')
AND duration_minutes IS NOT NULL
ORDER BY assign_time
`).all(periodStart, endFull, endFull, endFull) as any[]
`).all(periodStart, endFull) as any[]
// 3. 分类工单(进行中工单使用 periodEnd+1 天作为合成 close_time 供后续计算)
const periodEndNext = new Date(periodEnd.replace(/-/g, '/'))
periodEndNext.setDate(periodEndNext.getDate() + 1)
const periodEndNextStr = `${periodEndNext.getFullYear()}-${String(periodEndNext.getMonth()+1).padStart(2,'0')}-${String(periodEndNext.getDate()).padStart(2,'0')}`
const tickets: ClassifiedTicket[] = ticketsRaw.map(t => {
const isOngoing = !t.close_time || t.close_time > endFull
return {
id: t.id,
device_ip: t.device_ip || '',
device_name: t.device_name,
device_type: ipTypeMap.get(t.device_ip) || 'other',
fault_category: t.fault_category,
fault_subcategory: t.fault_subcategory,
parts_replaced: t.parts_replaced,
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,
}
})
// 3. 分类工单
const tickets: ClassifiedTicket[] = ticketsRaw.map(t => ({
id: t.id,
device_ip: t.device_ip || '',
device_name: t.device_name,
device_type: ipTypeMap.get(t.device_ip) || 'other',
fault_category: t.fault_category,
fault_subcategory: t.fault_subcategory,
parts_replaced: t.parts_replaced,
parts_name: t.parts_name,
content: t.content,
conclusion: t.conclusion,
assign_time: t.assign_time,
close_time: t.close_time,
duration_minutes: t.duration_minutes || 0,
availability: t.availability,
}))
const monthDays = getDateRange(periodStart, periodEnd).length
@ -109,13 +96,13 @@ export async function collectMonthlyReportData(
// 4. 第一章:每日在线节点数
const dates = getDateRange(periodStart, periodEnd)
// 排除"无故障"分类和"其他"子分类(其他工单不计入节点在线数)
// 排除"无故障"分类的工单agent上报异常等跨月工单上月派发本月恢复正常计入
const monthFaults = tickets.filter(t =>
t.fault_category !== '无故障' && t.fault_subcategory !== '其他'
t.fault_category !== '无故障'
)
const dailyStats: DailyOnlineStats[] = dates.map(date => {
// 当天不在线assign 日期 ≤ date < close 日期(当日恢复不计入离线
// 当天不在线assign 日期 ≤ date < close 日期(跨月工单也会正确计入
const gpuOffline = monthFaults.filter(t =>
t.device_type === 'gpu' &&
t.assign_time.slice(0, 10) <= date &&
@ -136,8 +123,8 @@ export async function collectMonthlyReportData(
}
})
// 5. 第二章:运营数据总览(仅 gpu/storage排除"无故障"和"其他"工单)
const gpuStorageTickets = tickets.filter(t => t.device_type !== 'other' && t.fault_category !== '无故障' && t.fault_subcategory !== '其他')
// 5. 第二章:运营数据总览(仅 gpu/storage排除"无故障"工单)
const gpuStorageTickets = tickets.filter(t => t.device_type !== 'other' && t.fault_category !== '无故障')
const chapter2Map = new Map<string, Chapter2FaultItem[]>()
for (const t of gpuStorageTickets) {
const assignDate = t.assign_time.slice(0, 10)
@ -146,8 +133,7 @@ export async function collectMonthlyReportData(
chapter2Map.get(key)!.push({
ip: t.device_ip,
fault_subcategory: t.fault_subcategory || '未知故障',
recoveryDays: t.isOngoing ? 0 : daysBetween(t.assign_time, t.close_time),
isOngoing: t.isOngoing || false,
recoveryDays: daysBetween(t.assign_time, t.close_time),
})
}
const chapter2: Chapter2Entry[] = []
@ -184,7 +170,6 @@ export async function collectMonthlyReportData(
faultCause: t.parts_name ? `更换${t.parts_name}` : '-',
durationMinutes: t.duration_minutes,
countedInSla: (t.availability !== null && t.availability < 0.99 && !t.conclusion?.includes('无异常')) ? '是' : '否',
isOngoing: t.isOngoing || false,
}
}
@ -197,7 +182,6 @@ export async function collectMonthlyReportData(
ticketConclusion: t.conclusion || '',
durationMinutes: t.duration_minutes,
countedInSla: (t.availability !== null && t.availability < 0.99 && !t.conclusion?.includes('无异常')) ? '是' : '否',
isOngoing: t.isOngoing || false,
}
}
@ -205,22 +189,12 @@ export async function collectMonthlyReportData(
const storageFaults = storageFaultTickets.map(toFaultEntry)
const allOtherTickets = [...otherTickets, ...remainingOthers].map(toOtherEntry)
// 7. 第四章:服务可用性说明(已结单工单按实际时长,进行中工单按本月部分
// 7. 第四章:服务可用性说明(仅已结单工单,按 IP 分组求和,排除"无故障"工单
const ipDurationMap = new Map<string, number>()
const ipHasOngoing = new Map<string, boolean>()
for (const t of tickets) {
if (t.fault_category === '无故障') continue
const dur = ipDurationMap.get(t.device_ip) || 0
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)
}
ipDurationMap.set(t.device_ip, dur + t.duration_minutes)
}
const chapter4: Chapter4Entry[] = []
for (const [ip, totalDuration] of ipDurationMap) {
@ -231,7 +205,6 @@ export async function collectMonthlyReportData(
totalDurationMinutes: totalDuration,
monthDays,
availabilityPercent: Math.round(availabilityPercent * 100) / 100,
hasOngoing: ipHasOngoing.get(ip) || false,
})
}
// 按 IP 排序
@ -253,82 +226,6 @@ 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 风格) */
export async function generateMonthlyReport(reportId: number): Promise<void> {
const db = getDb()
@ -341,48 +238,31 @@ export async function generateMonthlyReport(reportId: number): Promise<void> {
try {
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([
generateDailyOnlineChart(data.dailyStats, 'gpu'),
generateDailyOnlineChart(data.dailyStats, 'storage'),
])
// ---- 3. 组装月报 DOCX ----
let buffer = await buildMonthlyReportDocx(
data,
{ gpuPng: gpuChartPng, storagePng: storageChartPng },
weeklyLabels,
)
// ---- 4. ZIP 后处理:嵌入周报 OLE 对象 ----
if (weeklyAttachments.length > 0) {
buffer = await embedWeeklyReports(buffer, weeklyAttachments)
}
// 组装 DOCX
const buffer = await buildMonthlyReportDocx(data, { gpuPng: gpuChartPng, storagePng: storageChartPng })
ensureReportsDir()
const fileName = `${data.monthLabel}图灵IT基础设施运营月报.docx`
const filePath = path.join(REPORTS_DIR, fileName)
fs.writeFileSync(filePath, buffer)
const metadata = buildMonthlyMetadata(data)
const metadata = 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: data.chapter4.length > 0
? Math.round(data.chapter4.reduce((s, e) => s + e.availabilityPercent, 0) / data.chapter4.length * 100) / 100
: null,
})
db.prepare(
"UPDATE reports SET status = 'completed', file_path = ?, file_name = ?, metadata = ? WHERE id = ?"

View File

@ -1,283 +0,0 @@
// 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,15 +31,3 @@ export function requirePermission(user: UserPayload | null, permission: string):
if (!user) 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,7 +88,6 @@ export async function collectWeeklyReportData(
currentStatus: t.current_status,
isResolved,
steps,
availability: t.availability,
}
})
@ -167,80 +166,6 @@ 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> {
const db = getDb()
@ -260,7 +185,14 @@ export async function generateWeeklyReport(reportId: number): Promise<void> {
const filePath = path.join(REPORTS_DIR, fileName)
fs.writeFileSync(filePath, buffer)
const metadata = buildWeeklyMetadata(data)
const metadata = 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,
})
db.prepare(
"UPDATE reports SET status = 'completed', file_path = ?, file_name = ?, metadata = ? WHERE id = ?"

View File

@ -1,91 +1,55 @@
import { NextRequest, NextResponse } from 'next/server'
import { verifyToken } from '@/lib/jwt'
function decodeJwtPayload(token: string): Record<string, unknown> | null {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/')
while (payload.length % 4) payload += '='
return JSON.parse(atob(payload))
} catch { return null }
}
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
// API Key 以环境变量形式存储,支持多个 Key逗号分隔
const allowedKeys = process.env.ALLOWED_API_KEYS || ''
if (!allowedKeys) return false
return allowedKeys.split(',').map(k => k.trim()).includes(key)
const keys = allowedKeys.split(',').map(k => k.trim())
return keys.includes(key)
}
function buildLoginRedirect(request: NextRequest) {
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
const loginUrl = new URL('/login', request.url)
const dest = pathname + (request.nextUrl.search || '')
loginUrl.searchParams.set('redirect', dest)
return NextResponse.redirect(loginUrl)
}
if (pathname.startsWith('/login') || pathname === '/') return NextResponse.next()
if (pathname === '/api/auth/login') return NextResponse.next()
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// 登录/退出路径 + 内部 API 放行(自有 key 认证)
if (pathname.startsWith('/login') || pathname === '/' ||
pathname === '/api/auth/login' || pathname === '/api/auth/logout' ||
pathname.startsWith('/api/internal/')) {
return NextResponse.next()
}
// API Key 认证(外部系统调用)
const authHeader = request.headers.get('authorization')
// API Key 认证Bearer ak_xxx 格式
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 })
}
// 环境变量中未匹配API 路由仍放行route handler 可查询数据库二次验证)
if (pathname.startsWith('/api/')) return NextResponse.next()
}
// 优先检查 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: '/',
})
// 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
}
// 回退 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 })
}
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
const valid = await verifyToken(token)
if (!valid) 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
}
if (!token) return buildLoginRedirect()
const valid = await verifyToken(token)
if (!valid) return buildLoginRedirect()
const response = NextResponse.next()
response.cookies.set('session', JSON.stringify({ username: localPayload.username }), {
httpOnly: true, sameSite: 'lax', path: '/',
})
response.headers.set('x-original-pathname', pathname + (request.nextUrl.search || ''))
return response
}

View File

@ -1,7 +1,7 @@
// 报告基础类型
export type ReportType = 'weekly' | 'monthly'
export type ReportFormat = 'pdf' | 'docx'
export type ReportStatus = 'pending' | 'ready' | 'generating' | 'completed' | 'failed'
export type ReportStatus = 'pending' | 'generating' | 'completed' | 'failed'
export interface ReportCreateInput {
report_type: ReportType
@ -39,7 +39,6 @@ export interface ClassifiedTicket {
close_time: string
duration_minutes: number
availability: number | null
isOngoing?: boolean
}
// 第一章:每日在线节点统计
@ -55,8 +54,7 @@ export interface DailyOnlineStats {
export interface Chapter2FaultItem {
ip: string
fault_subcategory: string
recoveryDays: number // 0=当日, 1=次日, ≥2=N日后仅已结单有效进行中时为 0
isOngoing?: boolean
recoveryDays: number // 0=当日, 1=次日, ≥2=N日后
}
export interface Chapter2Entry {
@ -74,7 +72,6 @@ export interface Chapter3FaultEntry {
faultCause: string // "更换" + parts_replaced 或 "-"
durationMinutes: number
countedInSla: '是' | '否'
isOngoing?: boolean
}
// 第三章其他工单表行7列表头不同
@ -86,7 +83,6 @@ export interface Chapter3OtherEntry {
ticketConclusion: string // conclusion
durationMinutes: number
countedInSla: '是' | '否'
isOngoing?: boolean
}
// 第四章每IP服务可用性
@ -95,7 +91,6 @@ export interface Chapter4Entry {
totalDurationMinutes: number
monthDays: number
availabilityPercent: number // e.g. 97.28
hasOngoing?: boolean
}
// 月报顶层聚合数据
@ -130,38 +125,6 @@ export interface MonthlyReportPreview {
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 {
@ -194,7 +157,6 @@ export interface WeeklyTicketEntry {
currentStatus: string // 'resolved' | 'closed' | 'in_progress' | 'open'
isResolved: boolean
steps: TicketStepRaw[]
availability: number | null
}
export interface WeeklyReportData {
@ -213,17 +175,3 @@ export interface WeeklyReportData {
resolvedCount: 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
}