Compare commits

...

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

135 changed files with 12478 additions and 500 deletions

34
.dockerignore Normal file
View File

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

View File

@ -1,12 +1,21 @@
# issue-ai 环境变量(本地开发)
DATABASE_PATH=./data/issue.db
JWT_SECRET=your-secret-key-change-in-production
ADMIN_PASSWORD=admin123
ASSETS_API_URL=http://assets-ai:3000/api
ASSETS_API_KEY=your-assets-api-key
# 允许调用 issue-ai API 的 Key逗号分隔支持多个由 issue-ai 管理界面生成
ALLOWED_API_KEYS=your-issue-api-key
# NEXT_PUBLIC_ 前缀:构建时内嵌到客户端 JS云上必须通过 deploy-ai.sh 设置
# 本地开发http://localhost:5177
# 云上生产https://assets.tlyq.ai
NEXT_PUBLIC_ASSETS_URL=http://localhost:5177
JWT_SECRET=dev-jwt-secret-local
COOKIE_DOMAIN=
NODE_ENV=development
NODE_TLS_REJECT_UNAUTHORIZED=0
# LDAP 配置
LDAP_URL=ldap://localhost:3890
LDAP_BASE_DN=dc=tlyq,dc=ai
LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
# OIDC 配置
AUTHELIA_URL=http://127.0.0.1:6180
OIDC_CLIENT_ID=issue-oidc
OIDC_CLIENT_SECRET=<见 Authelia 配置>
OIDC_REDIRECT_URI=http://127.0.0.1:6176/api/auth/callback
# 跨服务调用
ASSETS_API_URL=http://localhost:6177/api
NEXT_PUBLIC_ASSETS_URL=http://localhost:6177

4
.gitignore vendored
View File

@ -11,8 +11,10 @@ build/
.claude/
data/
uploads/
reports/
/reports/
docs/
db-backups/
.playwright-mcp/
*.tsbuildinfo
.env.local
.DS_Store

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,157 @@
# 变更日志
## 2026-07-13
- [新增] 统一结单推送:从 worker 轮询 `fault_records` 上移到 API 层事件驱动PUT [id]/batch 状态跳变触发),新增 `notifyTicketResolved``notifyTicketCreated` 并列构成完整通知层
- [新增] 微信推送独立页面 `/settings/wechat`,与邮件监控完全解耦
- [新增] `useDirtyTracker` 共享 Hook`shared/ui/hooks/`),支持"修改后才显示保存按钮"模式,微信推送页每推送组独立保存、监控页统一 section 保存
- [优化] 结单推送月度统计改为 `close_time` 归组,周报/月报按 `isRealFault` 过滤,统一排除 无故障/误判
- [修复] 月报第四章跨月时长重复计算 bug`isOngoing=true` 时不再计入 `ipDurationMap`
- [修复] `audit_logs` 表缺少 `username` 列导致 7月2日至今所有审计日志静默写入失败补全 4 处 API 路由审计日志缺失
- [修复] `getMaskedConfig()` 直接 mutate webhook URL 导致测试发送 `••••••••` 至企业微信93000 invalid url
- [优化] 故障大类删除「其他」、故障小类删除「无故障」选项3 处表单统一)
- [安全] SSRF 加固:拒绝非规范数字型 IP 写法(整数/十六进制/八进制/短式),防绕过内网黑名单
- [重构] worker `checkRecoveryUpdates` 完全移除78 行),副作用迁移至 `resolution-side-effects.ts`
## 2026-07-02
- [优化] 企业微信推送消息格式全面对齐 zabbix-01 脚本10 项修复)
- 结单消息按故障类型区分OEM诊断/OEM维修/其他),补齐机架位置、故障类型、故障时间
- OEM维修结单消息新增本月故障次数和本月总处理时长
- 新故障通知OEM维修档位标题补全「基于月度内累计故障时长计算」
- 新故障通知OEM诊断字段顺序调整到期时间在故障信息之前
- 每小时提醒显示「X小时Y分」精度
- 静默期积压消息格式对齐动态剩余时间、机架位置、diag_30min/diag_hourly 区分
- [修复] `isInSilentPeriod()` / `isTimeToFlush()` 时区问题:新增 `getBeijingHour()` 使用 UTC+8 偏移,兼容 UTC 容器环境
- [修复] 先推送后更新数据库,防止推送失败导致结单通知永久丢失
- [修复] 共享库 `WeChatPusher` 错误消息不泄露 webhook URL
- [修复] `faultDetail` 截断 500 字符,防止消息超企业微信 API 限制
- [修复] `tierPenaltyMap` 提取为类级常量 `TIER_PENALTY_MAP`,消除 3 处重复定义
- [修复] `deploy-ai.sh` Docker 构建前临时替换 shared 符号链接为实际目录,构建后恢复
## 2026-06-30
- [新增] SSO 统一认证:集成 Authelia OIDC支持统一认证登录
- [新增] OIDC 登录页面:添加「统一认证登录」按钮,支持 LDAP 回退
- [新增] `src/lib/oidc.ts`OIDC 客户端配置PKCE + state + nonce
- [新增] `src/app/api/auth/login/oidc/route.ts`OIDC 登录端点
- [新增] `src/app/api/auth/callback/route.ts`OIDC 回调处理(含权限同步)
- [新增] `src/app/api/auth/logout/route.ts`:跨域登出(支持 domain 参数)
- [修复] NODE_TLS_REJECT_UNAUTHORIZED=0Authelia 使用自签名证书
- [修复] OIDC redirect_uri 回调重定向到 localhost添加 getBaseUrl() 函数
- [修复] token_endpoint_auth_method 配置缺失:添加 client_secret_basic
- [修复] middleware 路由匹配:`/api/auth/login` 改为前缀匹配 `/api/auth/login*`
- [优化] docker-compose.yml统一环境变量管理移除 env_file
- [优化] .env.example添加 OIDC 配置模板
## 2026-06-24
- [修复] SQLite 时区根源修复:重建表修改所有时间列 DEFAULT 为 `datetime('now', '+8 hours')`,涉及 7 个表 9 个列
## 2026-06-23审计日志
- [新增] 审计日志详情可视化字段变更用表格对比message/keys 格式分别用文本和列表展示
- [新增] 保留天数可编辑:默认隐藏保存按钮,修改值后蓝色高亮显示,保存成功/失败有颜色反馈
- [优化] 审计日志列表详情列:显示操作摘要(邮件监控/微信推送/配置修改等),不再显示"-"
- [优化] 表格列宽统一:时间/用户/操作/对象列防止换行,与 assets-ai 风格一致
- [修复] 监控设置页"已保存"提示重复SectionSaveButton 移除冗余消息渲染
- [修复] 导出文件名使用北京时间(修复凌晨时区偏移)
## 2026-06-23邮件监控集成
- [新增] 邮件监控系统:将 zabbix-01 上的 mail_monitor.py 完整迁移到 issue-aiNode.js 实现
- [新增] Background Worker独立进程运行entrypoint.shnode-cron 定时调度,与 Next.js Server 同时启动
- [新增] IMAP 邮件监控:自动连接邮箱、过滤故障邮件、解析 HTML 表格、提取故障信息
- [新增] 自动工单创建:从邮件解析结果自动创建工单,关联设备信息
- [新增] 微信 webhook 推送:故障通知、可用性档位提醒、工单结单提醒
- [新增] OEM 诊断提醒每小时提醒、30 分钟特别提醒、超时提醒
- [新增] OEM 维修提醒:每档前 1 小时提醒、每档超时提醒、全部超出提醒
- [新增] 静默期管理0:00-7:00 不发定时提醒7 点自动 flush 积压消息
- [新增] 可用性计算五档位1%/3%/5%/10%),月度累计故障时长
- [新增] 6 张数据库表settings、fault_records、reminder_state、processed_emails、pending_messages、monitor_logs
- [新增] 设置页面(/settings/monitor邮箱配置、邮件过滤、微信推送、运行参数
- [新增] 分模块保存按钮 + 高亮修改字段 + 错误详情可点击展开
- [新增] 运行状态面板:今日处理、累计、错误、进行中故障均可点击展开详情
- [新增] 监控 APIstatus/start/stop/trigger/settings/test-mail/test-wechatadmin 权限)
- [新增] 审计日志集成:所有监控操作记录到 audit_logs
- [新增] 数据迁移脚本:从 zabbix-01 迁移历史故障数据
- [新增] Docker 集成entrypoint.sh 包装脚本builder 阶段编译 Worker 模块
## 2026-05-18
- [新增] 用户详情页 — 点击用户名查看完整信息,支持编辑和键盘导航
- [优化] 用户管理 UI 统一Modal/Button 拆分为独立文件、删除改用 Modal 确认、TopBar 样式统一
- [调整] 用户列表页去创建时间列,编辑功能移到详情页,仅保留删除按钮
- [新增] LDAP 邮箱自动同步:密码登录/SSO 路径均从 LLDAP 同步 `mail` 到本地 `users.email`,增加 `/api/users/sync-emails` 批量同步接口
- [新增] 编辑用户弹窗增加启用/禁用状态切换、新密码二次确认
- [优化] 角色权限页面 UI 统一:角色 Badge 按角色区分颜色、增加保存 loading、checkbox dark mode 样式
- [优化] API Key 页面 UI 统一:成功提示颜色对齐、取消按钮 ghost 统一
- [优化] 全站字体大小和颜色统一:列表页/详情页 `text-sm` 一致、dark mode 颜色补全
- [修复] 用户管理"最后登录"时间不动态更新SSO 免登录、本地 JWT 会话验证路径现在也会更新 `last_login_at`
## 2026-05-15
- [修复] middleware 移除 `better-sqlite3` 依赖,修复 Edge Runtime 不支持 Node.js 原生模块的编译报错(`The edge runtime does not support Node.js 'fs' module`
- [优化] deploy-ai.sh 连通性检查增加重试机制3次/10s间隔应对同时部署两个站点时目标容器暂不可达
## 2026-05-14
- [新增] 权限系统重构:工单细粒度权限(`tickets:create` 手动建单、`tickets:import` 导入、`tickets:export` 导出),`tickets:write` 缩窄为编辑/删除
- [新增] 报告三层权限:`reports:read`(查看列表+预览)、`reports:download`(下载)、`reports:create`(新建+生成文档+删除)
- [新增] `/api/auth/me` 返回 `permissions` 字段,前端根据权限数组控制按钮和导航显隐
- [新增] 角色管理 UI 权限项从 8 个扩展到 14 个(含 `api-keys:read/write`
- [修复] 角色管理页面admin/operator/viewer 标记为"内置"不可删除但可编辑admin 在 API 层禁止删除
- [修复] 报告 API 路由全部补全权限检查(此前列表/预览/下载/删除均无权限检查)
- [修复] 导出工单 API 新增 `tickets:export` 权限检查(此前仅验证登录态)
- [修复] POST 创建报告权限从错误的 `reports:read` 修正为 `reports:create`
- [修复] `reports:write` 全面替换为 `reports:create`
- [修复] 种子数据迁移逻辑不再每次启动覆盖用户自定义权限,仅首次安装时创建默认权限
- [修复] SSO 新用户免登录失败:根页面 `getCurrentUser()` 改为直接 `redirect('/dashboard')`,由中间件统一处理共享 JWT 认证(与 assets-ai 行为一致)
- [修复] 手动建单输入业务 IP 按回车无法查询节点名称和设备序列号assets-ai `api_keys` 表中缺少 issue-ai 使用的 API Key服务端调用返回 401且前端静默吞错无提示
- [优化] 中间件 API Key 验证统一为 ALLOWED_API_KEYS快速路径→ api_keys 数据库表(回退)两级验证;无效 key 不再被放行,中间件层直接返回 401安全加固与 assets-ai 行为一致)
- [新增] 部署后自动验证 issue→assets API 连通性(`deploy-ai.sh`API Key 不匹配时部署立即报错退出
- [修复] `next.config.ts` 添加 `ldapts``serverExternalPackages`,确保 Next.js standalone 构建包含 LLDAP 客户端模块,避免 `ldapUserExists()` 因模块缺失失败导致 SSO 自动创建用户静默中断
- [调整] 全局证书切换Cloudflare Origin CA → Let's Encrypt`/etc/letsencrypt/live/www.tlyq.ai/`),覆盖全部 7 个子域名nginx 8 个站点配置同步更新
## 2026-05-12
- [部署] 云端 JWT 密钥统一为 oa-shared-jwt-secret-tlyq-2026三站点密钥一致
- [部署] docker-compose 移除 AUTHELIA_URL添加 LDAP/COOKIE/INTERNAL_API_KEY 环境变量
- [部署] LLDAP admin 密码更新为 3Vm!Y!@RCiPs
- [部署] nginx 移除 auth_request恢复纯反向代理改为运行时 DNS 解析
- [新增] `src/lib/db-schema.ts`users 表新增 last_login_at / last_active_at 列
- [新增] `src/lib/auth.ts`getCurrentUser() 更新 last_active_at
- [新增] `src/app/api/auth/login/route.ts`:登录时更新 last_login_at 和 last_active_at
## 2026-05-11
- [新增] `src/lib/ldap.ts``ldapUserExists()` 函数,检查 LLDAP 中用户是否存在admin bind 搜索,不可达时容错放行)
- [新增] `src/lib/jwt-shared.ts`:共享 JWT 签发/验证(`tlyq_session` 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 个)

108
CLAUDE.md
View File

@ -13,7 +13,7 @@ issue-ai 是基于 Next.js + SQLite 的工单跟踪管理系统,部署在腾
| 站点域名 | `issue.tlyq.ai` |
| 服务器 | txjpIP: 43.133.38.210 |
| 代码路径 | `/root/docker/issue-ai/` |
| 本地端口 | 5176 |
| 本地端口 | 6176 |
| 容器名 | `issue-ai` |
| 数据库 | SQLite`data/issue.db` |
| 报告存储 | `reports/` 目录(环境变量 `REPORTS_DIR` 可覆盖) |
@ -48,6 +48,19 @@ npm run import # 导入工单
| `src/lib/docx-export.ts` | Word 文档生成 |
| `src/lib/excel.ts` | Excel 解析与导出 |
| `src/lib/pdf.ts` | PDF 生成puppeteer |
| `src/lib/audit.ts` | 审计日志writeAuditLog、diffObjects、getClientIP |
| `src/lib/monitor/types.ts` | 邮件监控:类型定义 |
| `src/lib/monitor/settings-manager.ts` | 邮件监控配置管理AES 加密) |
| `src/lib/monitor/worker.ts` | 邮件监控Worker 主类cron 调度、主循环) |
| `src/lib/monitor/mail-monitor.ts` | 邮件监控IMAP 连接、邮件过滤、解析 |
| `src/lib/monitor/ticket-processor.ts` | 邮件监控:工单创建、故障记录 |
| `src/lib/monitor/wechat-pusher.ts` | 邮件监控:微信 webhook 推送 |
| `src/lib/monitor/ticket-notifier.ts` | 邮件监控:建单/结单推送(`notifyTicketCreated` + `notifyTicketResolved` |
| `src/lib/monitor/resolution-side-effects.ts` | 邮件监控:结单副作用(回写 fault_records + 清理 reminder_state |
| `src/lib/monitor/availability-engine.ts` | 邮件监控:可用性档位计算 |
| `src/lib/monitor/reminder-engine.ts` | 邮件监控OEM 诊断/维修提醒 |
| `scripts/monitor-worker.ts` | 邮件监控Worker 独立入口脚本 |
| `scripts/entrypoint.sh` | Docker 包装脚本(同时启动 Server + Worker |
| `src/types/ticket.ts` | TicketCreateInput / TicketUpdateInput |
| `src/types/report.ts` | ReportType / ReportData |
@ -66,6 +79,12 @@ npm run import # 导入工单
| `ticket_steps` | 工单时间线(关联 ticket_id |
| `reports` | 报告记录 |
| `audit_logs` | 审计日志 |
| `settings` | 键值对配置(含邮件监控配置) |
| `fault_records` | 邮件监控:故障记录 |
| `reminder_state` | 邮件监控:提醒状态 |
| `processed_emails` | 邮件监控:已处理邮件去重 |
| `pending_messages` | 邮件监控:静默期积压队列 |
| `monitor_logs` | 邮件监控:运行日志 |
### tickets 表核心字段
@ -99,11 +118,18 @@ npm run import # 导入工单
### 认证
登录逻辑v2.1LDAP 优先 + 本地密码缓存回退 + localadmin 应急用户。
登录成功签发两个 cookie`session_issue`(本地 JWT7 天)+ `tlyq_session`(共享 JWT7 天domain=.tlyq.ai
中间件优先检查 `tlyq_session`,回退 `session_issue`。`getCurrentUser()` 每次验证时检查 LLDAP 用户是否存在(已删除则清除 cookie 踢出)。
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/auth/login` | 登录username + password → JWT cookie |
| POST | `/api/auth/logout` | 登出 |
| POST | `/api/auth/login` | 登录(LDAP 优先 + 本地回退 |
| POST | `/api/auth/logout` | 登出(清除两个 cookie |
| GET | `/api/auth/me` | 当前用户信息 |
| GET | `/api/internal/roles` | 内部 API返回角色列表x-internal-key 鉴权) |
| GET | `/api/internal/users` | 内部 API返回用户列表x-internal-key 鉴权) |
| POST | `/api/internal/users` | 内部 APIOA 同步用户角色x-internal-key 鉴权) |
### 工单
@ -131,6 +157,28 @@ npm run import # 导入工单
| GET/POST | `/api/roles` | 角色列表/创建 |
| GET/PUT/DELETE | `/api/roles/[id]` | 单个角色操作 |
### 邮件监控admin 权限)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/monitor/status` | 运行状态 + 健康检查 + 详情 |
| POST | `/api/monitor/start` | 启用监控 |
| POST | `/api/monitor/stop` | 停用监控 |
| POST | `/api/monitor/trigger` | 手动触发检查30 秒冷却) |
| GET | `/api/monitor/settings` | 获取配置(密码脱敏) |
| PUT | `/api/monitor/settings` | 更新配置(支持分模块保存) |
| POST | `/api/monitor/test-mail` | 测试 IMAP 连接 |
| POST | `/api/monitor/test-wechat` | 测试微信 webhook |
| 前端页面 | `/settings/wechat` | webhook 独立管理页(微信推送配置) |
---
## 认证机制
- **Web 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 还需同步到环境变量,重启容器后生效)
---
## 环境配置
@ -139,13 +187,18 @@ npm run import # 导入工单
| 环境变量 | 本地开发 | 云服务器txjp | 说明 |
|---------|---------|----------------|------|
| `ASSETS_API_URL` | `http://localhost:5177/api` | `http://assets-ai:3000/api` | 调用 assets API 地址 |
| `ASSETS_API_URL` | `http://localhost:6177/api` | `http://assets-ai:3000/api` | 调用 assets API 地址 |
| `ASSETS_API_KEY` | 本地 assets-ai 生成 | 云上 assets-ai 生成 | **每个环境独立,不可跨环境使用** |
| `NEXT_PUBLIC_ASSETS_URL` | `http://localhost:5177` | `https://assets.tlyq.ai` | 前端跳转链接(构建时内嵌) |
| `NEXT_PUBLIC_ASSETS_URL` | `http://localhost:6177` | `https://assets.tlyq.ai` | 前端跳转链接(构建时内嵌) |
| `ALLOWED_API_KEYS` | 本地 issue-ai 生成的 Key | 云上 issue-ai 生成的 Key | 允许外部系统调本系统的 Key逗号分隔 |
| `JWT_SECRET` | `dev-secret-key-local` | `${ISSUE_JWT_SECRET}` | 本地两系统需一致(同 localhost 域) |
| `DATABASE_PATH` | `./data/issue.db` | `/app/data/issue.db` | Docker volume 挂载 |
| Cookie 名 | `session_issue` | `session_issue` | 本地用不同名防 localhost 域冲突 |
| `AUTHELIA_URL` | `https://sso.tlyq.ai` | 同 | Authelia 地址 |
| `OIDC_CLIENT_ID` | `issue-oidc` | 同 | OIDC 客户端 ID |
| `OIDC_CLIENT_SECRET` | 本地生成的哈希值 | 服务器生成的哈希值 | OIDC 客户端密钥 |
| `OIDC_REDIRECT_URI` | `http://localhost:6176/api/auth/callback` | `https://issue.tlyq.ai/api/auth/callback` | OIDC 回调地址 |
| `NODE_TLS_REJECT_UNAUTHORIZED` | 不需要 | `0`Authelia 使用自签名证书) | TLS 证书验证 |
### `.env.local` 示例
@ -154,10 +207,14 @@ DATABASE_PATH=./data/issue.db
JWT_SECRET=dev-secret-key-local
ADMIN_PASSWORD=admin123
NODE_ENV=development
ASSETS_API_URL=http://localhost:5177/api
ASSETS_API_URL=http://localhost:6177/api
ASSETS_API_KEY=ak_<32字节十六进制>
ALLOWED_API_KEYS=ak_<32字节十六进制>
NEXT_PUBLIC_ASSETS_URL=http://localhost:5177
NEXT_PUBLIC_ASSETS_URL=http://localhost:6177
AUTHELIA_URL=https://sso.tlyq.ai
OIDC_CLIENT_ID=issue-oidc
OIDC_CLIENT_SECRET=<本地生成的哈希值>
OIDC_REDIRECT_URI=http://localhost:6176/api/auth/callback
```
---
@ -176,10 +233,14 @@ assets-ai ──→ GET {ISSUE_API_URL}/tickets/by-asset?ip=xxx (Authori
Key 格式:`ak_<32位十六进制>`,认证头:`Authorization: Bearer <key>`。每个环境独立创建,互不通用。
**issue → assets 方向**:在 assets-ai `/settings/api-keys` 创建 Key → 写入 issue-ai 的 `ASSETS_API_KEY`
**验证策略v2.2**:两边中间件均采用 `ALLOWED_API_KEYS` 环境变量 → `api_keys` 数据库表两级验证。Key 注册在任意一处即可通过认证,推荐走 Web UI 创建(写入 `api_keys` 表,支持权限和追踪)。
**issue → assets 方向**:在 assets-ai `/settings/api-keys` 创建 Key → 写入 issue-ai 的 `ASSETS_API_KEY``.env` 或 `docker-compose.yml` 环境变量)
**assets → issue 方向**:在 issue-ai `/settings/api-keys` 创建 Key → 写入 assets-ai 的 `ISSUE_API_KEY` + issue-ai 的 `ALLOWED_API_KEYS`
> **部署验证**`deploy-ai.sh` 部署 issue 站点后会自动验证 issue→assets API 连通性(使用配置的 `ASSETS_API_KEY` 调 assets API若返回 401 则部署失败退出,防止 API Key 配置遗漏上线。
---
## Docker 部署
@ -225,6 +286,37 @@ NEXT_PUBLIC_ASSETS_URL=https://assets.tlyq.ai
- **新增 API**:在 `src/app/api/` 下创建路由 → 顶部调用 `initDatabase()``getCurrentUser()` 验证 → `hasPermission()` 校验
- **新增页面**:在 `src/app/(app)/` 下创建 → 布局由 `(app)/layout.tsx` 提供
- **权限格式**`resource:action`,如 `hasPermission(user, 'tickets:write')`
- **审计日志**:所有写操作 APIPOST/PUT/DELETE必须添加审计日志使用 `writeAuditLog()` 函数:
```typescript
import { writeAuditLog, getClientIP } from '@/lib/audit'
// 在操作成功后调用
writeAuditLog({
userId: user.id, // issue-ai 使用 user.id
apiKeyId: null, // 或 apiKeyInfo?.id
action: 'create' | 'update' | 'delete' | 'batch_update' | 'import' | 'export' | 'generate',
entityType: 'ticket' | 'report' | 'user' | 'role' | 'api_key' | 'auth',
entityId: ticketId, // 可选
details: { created: { ticket_no, device_ip } }, // 或 { changes: diffObjects(old, new) } 或 { deleted: { ... } }
ipAddress: getClientIP(request)
})
```
- **日期处理(时区规范)**:整个系统统一使用 UTC+8北京时间。必须遵守
1. **JavaScript/TypeScript**:禁止使用 `Date.toISOString()` 格式化本地日期。`toISOString()` 返回 UTC 时间在中国时区UTC+8`new Date('2026-04-01T00:00:00').toISOString()` 会返回 `"2026-03-31T16:00:00.000Z"`,日期偏移一天。应使用本地时间方法拼接:`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
2. **SQLite**:所有 `datetime('now')` 必须写成 `datetime('now', '+8 hours')`,包括 UPDATE/SET 语句、以及查询条件中的时间比较。禁止使用不含时区偏移的 `datetime('now')`
3. **INSERT 时间列**:数据库表的 DEFAULT 已统一为 `datetime('now', '+8 hours')`INSERT 时可省略时间列。如需显式设置,使用 `datetime('now', '+8 hours')`
---
## Git Tag 规范
使用日期版本号 `vYYYY.MM.DD`(如 `v2026.05.18`)。提交后打 tag 再推送:
```bash
git tag v$(date +%Y.%m.%d) && git push origin main && git push origin v$(date +%Y.%m.%d)
```
同一天多次提交只打一个 tag。详见根目录 `CLAUDE.md`
---

View File

@ -7,6 +7,8 @@ RUN --mount=type=cache,target=/root/.npm,id=issue-npm \
npm ci
COPY . .
RUN npm run build
# 编译 Worker 入口脚本 + monitor 模块 + db 模块为 JS使用专用 tsconfig 解析 @/ 路径别名)
RUN npx tsc -p tsconfig.worker.json && npx tsc-alias -p tsconfig.worker.json
# runner 阶段:使用 Debianglibc以支持 better-sqlite3 等 glibc 原生模块
FROM node:20-slim AS runner
@ -31,12 +33,25 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libasound2 \
libpango-1.0-0 \
libcairo2 \
sqlite3 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/package.json /app/package-lock.json ./
RUN npm install --omit=dev
COPY --from=builder /app/.next/standalone ./
RUN npm install --omit=dev && \
npm rebuild better-sqlite3 && \
rm -rf node_modules/@img/sharp-linuxmusl-x64 node_modules/@img/sharp-libvips-linuxmusl-x64 \
node_modules/@next/swc-linux-x64-musl
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
# 复制编译后的 Worker 文件(非 .ts 源码)
COPY --from=builder /app/.next/standalone/scripts/monitor-worker.js /app/scripts/
COPY --from=builder /app/.next/standalone/src/lib/monitor /app/src/lib/monitor
COPY --from=builder /app/.next/standalone/src/lib/db.js /app/src/lib/
COPY --from=builder /app/.next/standalone/src/lib/db-schema.js /app/src/lib/
COPY --from=builder /app/.next/standalone/src/lib/assets-client.js /app/src/lib/
RUN mkdir -p /app/data /app/uploads /app/reports
EXPOSE 3000
CMD ["node", "server.js"]
# 使用 entrypoint.sh 同时启动 Next.js Server 和 Monitor Worker
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
CMD ["/app/entrypoint.sh"]

View File

@ -28,7 +28,7 @@
### 概述
月报按自然月生成,统计当月已结单工单,输出为 DOCX 文档。包含封面、目录及四个章节。
月报按自然月生成,统计当月已结单工单及跨月进行中工单,输出为 DOCX 文档。包含封面、目录及四个章节。
### 报告结构
@ -45,21 +45,27 @@
**设备清单**:从 assets-ai 拉取 `filter_status=腾讯使用` 的设备,按 `filter_device_type=GPU服务器` / `存储服务器` 分类,构建 business_ip → device_type 映射。
**工单筛选**:按 `close_time` 范围查询monthly report 统计当月结单),条件
**工单筛选**:按 `close_time` `assign_time` 双条件查询,同时覆盖当月结单工单和跨月进行中工单
```sql
close_time >= periodStart AND close_time <= periodEnd + ' 23:59:59'
AND current_status IN ('resolved', 'closed')
AND duration_minutes IS NOT NULL
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'))
)
ORDER BY assign_time
```
- 第一条件:当月内结单的工单(含上月派发本月恢复的跨月工单)
- 第二条件:当月结束时仍未结单的工单(含当月派发和上月派发的进行中工单)
- 使用 `close_time` 比较而非 `current_status`,保证事后补生成报告时判断不变
**工单分类**:按 device_ip 在设备清单中查找对应 device_typegpu / storage / other
### 第一章规则
- 遍历当月每一天,计算当日在线节点数
- **统计范围**:排除 `fault_category = '无故障'` 的工单(跨月工单如 7/31 故障 8/3 恢复正常计入8/1、8/2 各减 1 台)
- **统计范围**:排除 `fault_category = '无故障'` `fault_subcategory = '其他'` 的工单("其他"工单不计入节点在线数;跨月工单如 7/31 故障 8/3 恢复正常计入8/1、8/2 各减 1 台)
- 不在线判断:`assign_time日期 ≤ 当前日期 < close_time日期`
- 当日发生故障次日恢复 → 发生日计入不在线,恢复日不计入
- 当日发生故障当日恢复 → 不计入不在线
@ -68,13 +74,18 @@ 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 = '无故障'`
- 仅统计 GPU / 存储工单,排除 `fault_category = '无故障'``fault_subcategory = '其他'`
- 按 `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日后
- 进行中:固定显示 `处理中。`(不含恢复描述)
### 第三章规则
@ -89,36 +100,39 @@ 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`
- 按 device_ip 分组求和故障时长:
- **已结单工单**:使用实际 `duration_minutes`
- **进行中工单**:仅计算本月内部分,时长 = `(min(assign_date, 月初) → 月末最后一天) × 24 × 60` 分钟
- 公式:`可用性 = (monthDays × 24 × 60 - totalDurationMinutes) / (monthDays × 24 × 60) × 100`
- monthDays 为当月实际天数(动态计算)
- 百分比 **< 99%** 该值以黄底红字加粗标记
- 百分比 **≥ 99%** 时,正常样式
- 该 IP 存在进行中工单时,公式后追加橙色标注 `(故障处理中,仅计本月部分)`
### SLA 判定规则

View File

@ -0,0 +1,47 @@
// __tests__/monitor/availability-engine.test.ts
import { AvailabilityEngine } from '../../src/lib/monitor/availability-engine'
// Mock getDb
jest.mock('../../src/lib/db', () => ({
getDb: () => ({
prepare: () => ({
get: () => ({ total: 0 }),
all: () => [],
run: () => {},
}),
}),
}))
describe('AvailabilityEngine', () => {
const engine = new AvailabilityEngine()
it('should calculate days in month correctly', () => {
expect(engine.getDaysInMonth('2026-03')).toBe(31)
expect(engine.getDaysInMonth('2026-02')).toBe(28)
expect(engine.getDaysInMonth('2026-04')).toBe(30)
})
it('should calculate OEM diag deadline (first tier only)', () => {
const deadline = engine.calculateOemDiagDeadline('2026-03', '2026-03-10 16:44:01')
expect(deadline).toBeTruthy()
// 31 days = 2678400 seconds, 1% = 26784 seconds ≈ 7.44 hours
const fault = new Date('2026-03-10T16:44:01+08:00')
const deadlineStr = deadline as string
const deadlineDate = new Date(deadlineStr.replace(' ', 'T') + '+08:00')
const diffHours = (deadlineDate.getTime() - fault.getTime()) / (1000 * 3600)
expect(diffHours).toBeCloseTo(7.44, 1)
})
it('should calculate tier deadlines', () => {
const deadlines = engine.calculateTierDeadlines('2026-03', 'SN123', '2026-03-10 16:44:01')
expect(deadlines['第一档']).toBeTruthy()
expect(deadlines['第一档'].deadline).not.toBe('已超出')
expect(deadlines['第一档'].hours).toBeGreaterThan(0)
})
it('should format deadline in UTC+8', () => {
const deadline = engine.calculateOemDiagDeadline('2026-03', '2026-03-10 16:44:01')
// 应该是 UTC+8 格式,不是 UTC
expect(deadline).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/)
})
})

15
config/.env.prod Normal file
View File

@ -0,0 +1,15 @@
# issue-ai 生产环境配置(模板)
DATABASE_PATH=/app/data/issue.db
JWT_SECRET=__JWT_SECRET__
COOKIE_DOMAIN=.tlyq.ai
NODE_ENV=production
NODE_TLS_REJECT_UNAUTHORIZED=0
LDAP_URL=ldap://lldap:3890
LDAP_BASE_DN=dc=tlyq,dc=ai
LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
AUTHELIA_URL=https://sso.tlyq.ai
OIDC_CLIENT_ID=issue-oidc
OIDC_CLIENT_SECRET=__OIDC_CLIENT_SECRET__
OIDC_REDIRECT_URI=https://issue.tlyq.ai/api/auth/callback
ASSETS_API_URL=http://assets-ai:3000/api
NEXT_PUBLIC_ASSETS_URL=https://assets.tlyq.ai

View File

@ -3,22 +3,41 @@ services:
build: .
container_name: issue-ai
ports:
- "5176:3000"
- "6176:3000"
volumes:
- issue-data:/app/data
- issue-uploads:/app/uploads
- issue-reports:/app/reports
# .next 目录从主机挂载npm run build 后直接生效,无需重建镜像
- ./.next:/app/.next
# 运行时从 LLDAP 容器动态读取 admin 密码(已迁移至环境变量注入)
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- DATABASE_PATH=/app/data/issue.db
- JWT_SECRET=${ISSUE_JWT_SECRET:-change-me-in-production}
- JWT_SECRET=${JWT_SECRET:-oa-shared-jwt-secret-tlyq-2026}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
- LLDAP_ADMIN_PASSWORD=${LLDAP_ADMIN_PASSWORD}
- ASSETS_API_URL=${ASSETS_API_URL:-https://assets.tlyq.ai/api}
- ASSETS_API_KEY=${ASSETS_API_KEY}
- ALLOWED_API_KEYS=${ALLOWED_API_KEYS}
- NODE_ENV=production
- COOKIE_DOMAIN=.tlyq.ai
- AUTHELIA_URL=${AUTHELIA_URL:-https://sso.tlyq.ai}
- LDAP_URL=ldap://lldap:3890
- LDAP_BASE_DN=dc=tlyq,dc=ai
- LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
- TZ=Asia/Shanghai
- NODE_TLS_REJECT_UNAUTHORIZED=0
# OIDC 配置
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-issue-oidc}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-https://issue.tlyq.ai/api/auth/callback}
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
restart: unless-stopped
networks:
- webnet

24
entrypoint.sh Normal file
View File

@ -0,0 +1,24 @@
#!/bin/bash
# entrypoint.sh — 同时启动 Next.js Server 和 Monitor Worker
HOSTNAME=0.0.0.0 node /app/server.js &
SERVER_PID=$!
cd /app && node scripts/monitor-worker.js &
WORKER_PID=$!
cleanup() {
echo "Stopping Server (PID=$SERVER_PID) and Worker (PID=$WORKER_PID)..."
kill $SERVER_PID 2>/dev/null
kill $WORKER_PID 2>/dev/null
wait $SERVER_PID 2>/dev/null
wait $WORKER_PID 2>/dev/null
echo "Shutdown complete."
}
trap cleanup SIGTERM SIGINT
# 等待任一进程退出(需要 Bash 4.3+
wait -n $SERVER_PID $WORKER_PID
EXIT_CODE=$?
cleanup
exit $EXIT_CODE

View File

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

BIN
oa-cloud-fixed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
oa-cloud-login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
oa-login-current.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
oa-login-page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

1403
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "NODE_OPTIONS='--max-old-space-size=2048' next dev --port 5176",
"dev": "NODE_OPTIONS='--max-old-space-size=2048' next dev --port 6176",
"build": "next build",
"start": "next start",
"lint": "next lint",
@ -13,12 +13,20 @@
"dependencies": {
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.8.1",
"cheerio": "^1.2.0",
"cookie": "^1.0.2",
"docx": "^9.1.1",
"echarts": "^5.5.0",
"imap-simple": "^5.1.0",
"imapflow": "^1.4.2",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
"ldapts": "^8.1.7",
"lucide-react": "^1.8.0",
"mailparser": "^3.9.11",
"next": "^15.1.0",
"node-cron": "^4.5.0",
"openid-client": "^5.7.1",
"puppeteer": "^23.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@ -36,6 +44,7 @@
"eslint": "^10.2.1",
"postcss": "^8.4.49",
"tailwindcss": "^4.0.0",
"tsc-alias": "^1.8.10",
"tsx": "^4.19.0",
"typescript": "^5.6.3"
}

0
public/.gitkeep Normal file
View File

BIN
report-54-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
report-preview-53.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@ -199,6 +199,10 @@ function parseFaultSheet(data: (string | number | null)[][]): {
// 主逻辑
// ---------------------------------------------------------------------------
function main() {
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

@ -0,0 +1,53 @@
// scripts/migrate-monitor-data.ts
// 从 zabbix-01 拉取历史数据并迁移到 issue-ai SQLite
// 用法: ssh zabbix-01 "cat /opt/scripts/.../server_availability_data.json" | tsx scripts/migrate-monitor-data.ts
import { execSync } from 'child_process'
import { getDb } from '../src/lib/db'
import { initDatabase } from '../src/lib/db-schema'
initDatabase()
const db = getDb()
interface FaultRecord { fault_time: string; fault_type: string; order_number: string; recovery_time: string | null; duration: number | null; status: string }
interface ServerData { server_ip: string; fault_records: FaultRecord[]; total_fault_duration: number; ongoing_faults: string[] }
// 拉取 server_availability_data.json
console.log('从 zabbix-01 拉取数据...')
const availData = JSON.parse(execSync('ssh zabbix-01 "cat /opt/scripts/mail_monitor_wechat/data/server_availability_data.json"', { encoding: 'utf-8' }))
let migrated = 0, matched = 0, skipped = 0, errors = 0
for (const [monthKey, servers] of Object.entries(availData) as [string, Record<string, ServerData>][]) {
for (const [sn, serverData] of Object.entries(servers)) {
for (const record of serverData.fault_records) {
try {
// 检查工单是否存在
if (record.order_number) {
const ticket = db.prepare('SELECT id FROM tickets WHERE id = ?').get(parseInt(record.order_number))
if (!ticket) { skipped++; continue }
matched++
}
// 检查是否已存在
const existing = db.prepare('SELECT id FROM fault_records WHERE server_sn = ? AND fault_time = ?').get(sn, record.fault_time)
if (existing) { skipped++; continue }
const status = record.status === 'resolved' ? 'resolved' : 'ongoing'
db.prepare(`
INSERT INTO fault_records (server_sn, server_ip, order_number, fault_type, fault_time, recovery_time, duration_seconds, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(sn, serverData.server_ip, record.order_number, record.fault_type, record.fault_time, record.recovery_time, record.duration, status)
migrated++
} catch (e) {
errors++
console.error(`迁移失败: ${sn} - ${record.fault_time}: ${e}`)
}
}
}
}
console.log(`\n迁移完成:`)
console.log(` 成功: ${migrated}`)
console.log(` 匹配工单: ${matched}`)
console.log(` 跳过: ${skipped}`)
console.log(` 错误: ${errors}`)

46
scripts/monitor-worker.ts Normal file
View File

@ -0,0 +1,46 @@
// scripts/monitor-worker.ts
const path = require('path')
const dbPath = process.env.DATABASE_PATH || path.join(process.cwd(), 'data', 'issue.db')
process.env.DATABASE_PATH = dbPath
const { getDb } = require('../src/lib/db')
const { initDatabase } = require('../src/lib/db-schema')
const { BackgroundWorker } = require('../src/lib/monitor/worker')
const { setSetting, getSetting } = require('../src/lib/monitor/settings-manager')
initDatabase()
const db = getDb()
// 生成加密密钥(首次启动)
if (!getSetting('monitor.encryption_key')) {
const crypto = require('crypto')
const newKey = crypto.randomBytes(32).toString('hex')
setSetting('monitor.encryption_key', newKey, 'security')
console.log('[Worker] 已生成加密密钥')
}
const worker = new BackgroundWorker()
if (getSetting('monitor.enabled') === 'true') {
worker.start()
console.log('[Worker] 邮件监控已启动')
} else {
console.log('[Worker] 邮件监控未启用,等待 API 启用')
}
// 保持进程存活,定期检查监控状态是否变更
const CHECK_INTERVAL = 30_000 // 30秒
setInterval(() => {
if (getSetting('monitor.enabled') === 'true' && !worker.cronJob) {
worker.start()
console.log('[Worker] 检测到监控已启用,自动启动')
} else if (getSetting('monitor.enabled') !== 'true' && worker.cronJob) {
worker.stop()
console.log('[Worker] 检测到监控已禁用,自动停止')
}
}, CHECK_INTERVAL)
process.on('SIGTERM', () => {
console.log('[Worker] 收到 SIGTERM正在停止...')
worker.stop()
process.exit(0)
})

1
shared Symbolic link
View File

@ -0,0 +1 @@
../shared

View File

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

View File

@ -0,0 +1,536 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Card, Button, Badge, Toast, Modal } from '@/components/ui'
import { Plus, Eye, Download, Trash2, Archive, RefreshCw } from 'lucide-react'
interface Report {
id: number
report_type: string
period_start: string | null
period_end: string | null
format: string
status: string
created_at: string
file_name?: string | null
metadata?: string | null
}
const statusVariant: Record<string, 'info' | 'warning' | 'success' | 'danger'> = {
pending: 'info',
ready: 'warning',
generating: 'info',
completed: 'success',
failed: 'danger',
}
const statusLabel: Record<string, string> = {
pending: '待生成',
ready: '数据已就绪',
generating: '文档生成中',
completed: '已完成',
failed: '生成失败',
}
const typeLabel: Record<string, string> = {
weekly: '周报',
monthly: '月报',
}
function getReportName(r: Report): string {
if (r.metadata) {
try {
const meta = JSON.parse(r.metadata)
if (meta.reportLabel) return meta.reportLabel
} catch { /* ignore */ }
}
if (r.file_name) return r.file_name
return `${r.period_start || '?'} ~ ${r.period_end || '?'} ${typeLabel[r.report_type] || r.report_type}`
}
function lastDayOfMonth(y: number, m: number) {
return new Date(y, m, 0).getDate()
}
function pad(n: number) { return String(n).padStart(2, '0') }
function getLastWeekDates() {
const today = new Date()
const dayOfWeek = today.getDay()
const daysSinceMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1
const thisMonday = new Date(today)
thisMonday.setDate(today.getDate() - daysSinceMonday)
const lastMonday = new Date(thisMonday)
lastMonday.setDate(thisMonday.getDate() - 7)
const lastSunday = new Date(lastMonday)
lastSunday.setDate(lastMonday.getDate() + 6)
return {
start: `${lastMonday.getFullYear()}-${pad(lastMonday.getMonth()+1)}-${pad(lastMonday.getDate())}`,
end: `${lastSunday.getFullYear()}-${pad(lastSunday.getMonth()+1)}-${pad(lastSunday.getDate())}`,
}
}
export default function ReportsPage() {
const router = useRouter()
const [reports, setReports] = useState<Report[]>([])
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [reportType, setReportType] = useState<'monthly' | 'weekly'>('monthly')
const [month, setMonth] = useState('')
const [weekStart, setWeekStart] = useState('')
const [weekEnd, setWeekEnd] = useState('')
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'info' } | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Report | null>(null)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false)
const [generatingIds, setGeneratingIds] = useState<Set<number>>(new Set())
const [permissions, setPermissions] = useState<string[]>([])
useEffect(() => {
fetch('/api/auth/me')
.then(r => r.json())
.then(u => { if (u.user?.permissions) setPermissions(u.user.permissions) })
.catch(() => {})
}, [])
const toggleSelect = (id: number) => {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) { next.delete(id) } else { next.add(id) }
return next
})
}
const toggleSelectAll = () => {
const selectable = reports.filter(r =>
r.status === 'completed' || r.status === 'failed' || r.status === 'ready'
)
const allIds = selectable.map(r => r.id)
setSelectedIds(prev =>
prev.size === allIds.length && allIds.length > 0 ? new Set() : new Set(allIds)
)
}
const fetchReports = useCallback(() => {
fetch('/api/reports')
.then(r => r.json())
.then(d => { if (d.reports) setReports(d.reports) })
.catch(() => {})
.finally(() => setLoading(false))
}, [])
useEffect(() => { fetchReports() }, [fetchReports])
useEffect(() => {
if (!month) {
const now = new Date()
let lastMonth = now.getMonth() - 1
let lastMonthYear = now.getFullYear()
if (lastMonth < 0) { lastMonth = 11; lastMonthYear-- }
setMonth(`${lastMonthYear}-${pad(lastMonth + 1)}`)
}
}, [])
const handleCreate = async () => {
let period_start = ''
let period_end = ''
if (reportType === 'monthly') {
if (!month) return
const [y, m] = month.split('-').map(Number)
period_start = `${y}-${pad(m)}-01`
period_end = `${y}-${pad(m)}-${pad(lastDayOfMonth(y, m))}`
} else {
if (!weekStart || !weekEnd) return
period_start = weekStart
period_end = weekEnd
}
const res = await fetch('/api/reports', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ report_type: reportType, period_start, period_end }),
})
if (res.ok) {
const data = await res.json()
setShowCreate(false)
setToast({ message: '数据采集完成,正在跳转预览页...', type: 'success' })
router.push(`/reports/${data.report.id}`)
} else {
const data = await res.json().catch(() => ({ error: '创建失败' }))
setToast({ message: data.error || '创建失败', type: 'error' })
}
}
const handleDelete = async () => {
if (!deleteTarget) return
const res = await fetch(`/api/reports/${deleteTarget.id}`, { method: 'DELETE' })
if (res.ok) {
setReports(prev => prev.filter(r => r.id !== deleteTarget.id))
setToast({ message: '已删除', type: 'success' })
} else {
const data = await res.json().catch(() => ({ error: '删除失败' }))
setToast({ message: data.error || '删除失败', type: 'error' })
}
setDeleteTarget(null)
}
const handleBatchDelete = async () => {
const ids = Array.from(selectedIds)
if (ids.length === 0) return
const res = await fetch('/api/reports', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
})
if (res.ok) {
setReports(prev => prev.filter(r => !selectedIds.has(r.id)))
setSelectedIds(new Set())
setToast({ message: `已删除 ${ids.length} 条报告`, type: 'success' })
} else {
const data = await res.json().catch(() => ({ error: '批量删除失败' }))
setToast({ message: data.error || '批量删除失败', type: 'error' })
}
setBatchDeleteOpen(false)
}
const handleBatchDownload = async () => {
const ids = Array.from(selectedIds)
if (ids.length === 0) return
try {
const res = await fetch('/api/reports/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
})
if (res.ok) {
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const d = new Date(); a.download = `reports_${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}.zip`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
setToast({ message: `已下载 ${ids.length} 条报告`, type: 'success' })
} else {
const data = await res.json().catch(() => ({ error: '下载失败' }))
setToast({ message: data.error || '下载失败', type: 'error' })
}
} catch {
setToast({ message: '下载失败', type: 'error' })
}
}
const handleGenerate = async (reportId: number) => {
setGeneratingIds(prev => new Set(prev).add(reportId))
try {
const res = await fetch(`/api/reports/${reportId}/generate`, { method: 'POST' })
if (res.ok) {
setToast({ message: '文档生成中...', type: 'info' })
pollUntilDone(reportId)
} else {
const data = await res.json().catch(() => ({ error: '生成失败' }))
setToast({ message: data.error || '生成失败', type: 'error' })
setGeneratingIds(prev => { const n = new Set(prev); n.delete(reportId); return n })
}
} catch {
setToast({ message: '生成失败', type: 'error' })
setGeneratingIds(prev => { const n = new Set(prev); n.delete(reportId); return n })
}
}
const pollUntilDone = async (reportId: number) => {
const start = Date.now()
while (Date.now() - start < 120000) {
await new Promise(r => setTimeout(r, 3000))
const res = await fetch(`/api/reports/${reportId}`)
if (!res.ok) continue
const data = await res.json()
const status = data.report?.status
if (status === 'completed') {
setToast({ message: '报告生成完成', type: 'success' })
fetchReports()
setGeneratingIds(prev => { const n = new Set(prev); n.delete(reportId); return n })
return
}
if (status === 'failed') {
setToast({ message: data.report?.error_message || '报告生成失败', type: 'error' })
fetchReports()
setGeneratingIds(prev => { const n = new Set(prev); n.delete(reportId); return n })
return
}
}
setToast({ message: '报告生成超时,请手动刷新', type: 'error' })
fetchReports()
setGeneratingIds(prev => { const n = new Set(prev); n.delete(reportId); return n })
}
const getActionButtons = (r: Report) => {
const isGenerating = generatingIds.has(r.id)
switch (r.status) {
case 'ready':
return (
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => router.push(`/reports/${r.id}`)}>
<Eye size={14} className="mr-0.5" />
</Button>
{can('reports:create') && (
<Button variant="ghost" size="sm" onClick={() => handleGenerate(r.id)} loading={isGenerating}>
</Button>
)}
{can('reports:create') && (
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}>
<Trash2 size={14} className="text-red-500" />
</Button>
)}
</div>
)
case 'generating':
return (
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => router.push(`/reports/${r.id}`)}>
<Eye size={14} className="mr-0.5" />
</Button>
</div>
)
case 'completed':
return (
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => router.push(`/reports/${r.id}`)}>
<Eye size={14} className="mr-0.5" />
</Button>
{can('reports:download') && (
<Button variant="ghost" size="sm" onClick={() => window.open(`/api/reports/${r.id}/download`, '_blank')}>
<Download size={14} className="mr-0.5" />
</Button>
)}
{can('reports:create') && (
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}>
<Trash2 size={14} className="text-red-500" />
</Button>
)}
</div>
)
case 'failed':
return (
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => router.push(`/reports/${r.id}`)}>
<Eye size={14} className="mr-0.5" />
</Button>
{can('reports:create') && (
<Button variant="ghost" size="sm" onClick={() => handleGenerate(r.id)} loading={isGenerating}>
<RefreshCw size={14} className="mr-0.5" />
</Button>
)}
{can('reports:create') && (
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}>
<Trash2 size={14} className="text-red-500" />
</Button>
)}
</div>
)
default:
return (
<div className="flex items-center gap-1">
{can('reports:create') && (
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}>
<Trash2 size={14} className="text-red-500" />
</Button>
)}
</div>
)
}
}
const can = (perm: string) => permissions.includes('*') || permissions.includes(perm)
const renderStatusBadge = (status: string) => {
if (status === 'generating') {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400">
<span className="inline-block w-2.5 h-2.5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</span>
)
}
if (status === 'ready') {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500" />
</span>
)
}
return <Badge variant={statusVariant[status] || 'default'}>{statusLabel[status] || status}</Badge>
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100"></h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1"></p>
</div>
<div className="flex items-center gap-2">
{selectedIds.size > 0 && (
<>
{can('reports:download') && (
<Button size="sm" onClick={handleBatchDownload}>
<Archive size={16} className="mr-1" /> ({selectedIds.size})
</Button>
)}
{can('reports:create') && (
<Button size="sm" variant="danger" onClick={() => setBatchDeleteOpen(true)}>
<Trash2 size={16} className="mr-1" /> ({selectedIds.size})
</Button>
)}
</>
)}
{can('reports:create') && (
<Button size="sm" onClick={() => setShowCreate(!showCreate)}>
<Plus size={16} className="mr-1" />
</Button>
)}
</div>
</div>
{showCreate && (
<Card className="p-5">
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3"></h3>
<div className="flex gap-3 mb-4">
<label className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer text-sm transition-colors ${reportType === 'monthly' ? 'border-blue-500 bg-blue-50 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400' : 'border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-400'}`}>
<input type="radio" name="reportType" value="monthly" checked={reportType === 'monthly'} onChange={() => setReportType('monthly')} className="sr-only" />
</label>
<label className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer text-sm transition-colors ${reportType === 'weekly' ? 'border-blue-500 bg-blue-50 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400' : 'border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-400'}`}>
<input type="radio" name="reportType" value="weekly" checked={reportType === 'weekly'} onChange={() => { setReportType('weekly'); const { start, end } = getLastWeekDates(); setWeekStart(start); setWeekEnd(end) }} className="sr-only" />
</label>
</div>
{reportType === 'monthly' ? (
<div className="space-y-1">
<label className="text-sm text-slate-500 dark:text-slate-400"></label>
<input type="month" className="w-56 px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 text-sm" value={month} onChange={e => setMonth(e.target.value)} />
<p className="text-xs text-slate-400 mt-1">1</p>
</div>
) : (
<div>
<div className="flex gap-4">
<div className="space-y-1">
<label className="text-sm text-slate-500 dark:text-slate-400"></label>
<input type="date" className="w-56 px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 text-sm" value={weekStart} onChange={e => setWeekStart(e.target.value)} />
</div>
<div className="space-y-1">
<label className="text-sm text-slate-500 dark:text-slate-400"></label>
<input type="date" className="w-56 px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 text-sm" value={weekEnd} onChange={e => setWeekEnd(e.target.value)} />
</div>
</div>
<p className="text-xs text-slate-400 mt-2"></p>
</div>
)}
<div className="flex gap-2 mt-4">
<Button size="sm" onClick={handleCreate}></Button>
<Button size="sm" variant="secondary" onClick={() => setShowCreate(false)}></Button>
</div>
</Card>
)}
{loading ? (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">...</div>
) : reports.length === 0 ? (
<div className="text-center py-12 text-slate-500 dark:text-slate-400"></div>
) : (
<div className="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700">
<table className="w-full text-sm">
<thead className="bg-slate-50 dark:bg-slate-800">
<tr>
<th className="px-3 py-3 w-10">
<input
type="checkbox"
checked={
reports.filter(r => r.status === 'completed' || r.status === 'failed' || r.status === 'ready').length > 0 &&
selectedIds.size === reports.filter(r => r.status === 'completed' || r.status === 'failed' || r.status === 'ready').length
}
onChange={toggleSelectAll}
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500"
/>
</th>
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400"></th>
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400"></th>
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400"></th>
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400"></th>
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400"></th>
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-700">
{reports.map(r => (
<tr key={r.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<td className="px-3 py-3">
<input
type="checkbox"
checked={selectedIds.has(r.id)}
onChange={() => toggleSelect(r.id)}
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500"
/>
</td>
<td className="px-4 py-3 max-w-[240px]">
<a
href={`/reports/${r.id}`}
onClick={(e) => { e.preventDefault(); router.push(`/reports/${r.id}`) }}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 font-medium truncate block"
>
{getReportName(r)}
</a>
</td>
<td className="px-4 py-3">
{r.report_type === 'monthly' ? (
<span className="inline-flex px-2.5 py-0.5 rounded-lg text-xs font-semibold bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400"></span>
) : (
<span className="inline-flex px-2.5 py-0.5 rounded-lg text-xs font-semibold bg-purple-100 text-purple-700 dark:bg-purple-500/10 dark:text-purple-400"></span>
)}
</td>
<td className="px-4 py-3 text-slate-600 dark:text-slate-400 text-sm">{r.period_start} ~ {r.period_end}</td>
<td className="px-4 py-3">{renderStatusBadge(r.status)}</td>
<td className="px-4 py-3 text-slate-500 dark:text-slate-400 text-sm">{new Date(r.created_at).toLocaleString('zh-CN')}</td>
<td className="px-4 py-3">{getActionButtons(r)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除">
<p className="text-sm text-slate-600 dark:text-slate-400 mb-4">
</p>
<div className="flex gap-2 justify-end">
<Button size="sm" variant="secondary" onClick={() => setDeleteTarget(null)}></Button>
<Button size="sm" variant="danger" onClick={handleDelete}></Button>
</div>
</Modal>
<Modal open={batchDeleteOpen} onClose={() => setBatchDeleteOpen(false)} title="确认批量删除">
<p className="text-sm text-slate-600 dark:text-slate-400 mb-4">
{selectedIds.size}
</p>
<div className="flex gap-2 justify-end">
<Button size="sm" variant="secondary" onClick={() => setBatchDeleteOpen(false)}></Button>
<Button size="sm" variant="danger" onClick={handleBatchDelete}></Button>
</div>
</Modal>
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
</div>
)
}

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

View File

@ -0,0 +1,611 @@
'use client'
import { useState, useEffect } from 'react'
import Table, { Column } from '@/components/ui/Table'
import Button from '@/components/ui/Button'
import Modal from '@/components/ui/Modal'
import Badge from '@/components/ui/Badge'
interface AuditLog {
id: number
user_id: number | null
api_key_id: number | null
action: string
entity_type: string
entity_id: number | null
details: string | null
ip_address: string | null
created_at: string
username: string | null
api_key_name: string | null
}
const ACTION_LABELS: Record<string, { label: string; variant: 'success' | 'info' | 'danger' | 'default' }> = {
create: { label: '创建', variant: 'success' },
update: { label: '更新', variant: 'info' },
delete: { label: '删除', variant: 'danger' },
batch_update: { label: '批量更新', variant: 'info' },
import: { label: '导入', variant: 'info' },
export: { label: '导出', variant: 'default' },
login: { label: '登录', variant: 'default' },
logout: { label: '登出', variant: 'default' },
}
const ENTITY_LABELS: Record<string, string> = {
ticket: '工单',
report: '报告',
user: '用户',
role: '角色',
api_key: 'API Key',
audit_log: '审计日志',
auth: '认证',
}
export default function AuditLogsPage() {
const [logs, setLogs] = useState<AuditLog[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
// 过滤器
const [actionFilter, setActionFilter] = useState('')
const [entityFilter, setEntityFilter] = useState('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
// 保留天数设置
const [retentionSaved, setRetentionSaved] = useState(180)
const [retentionInput, setRetentionInput] = useState('180')
const [retentionSaving, setRetentionSaving] = useState(false)
const [retentionSavedFeedback, setRetentionSavedFeedback] = useState(false)
const [retentionError, setRetentionError] = useState(false)
const retentionDirty = retentionInput !== String(retentionSaved)
// 详情 Modal
const [detailLog, setDetailLog] = useState<AuditLog | null>(null)
async function fetchLogs() {
setLoading(true)
const params = new URLSearchParams()
params.set('page', String(page))
params.set('pageSize', String(pageSize))
if (actionFilter) params.set('action', actionFilter)
if (entityFilter) params.set('entity_type', entityFilter)
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
try {
const res = await fetch(`/api/audit-logs?${params}`)
if (res.ok) {
const data = await res.json()
setLogs(data.logs)
setTotal(data.total)
}
} catch { /* ignore */ }
setLoading(false)
}
useEffect(() => { fetchLogs() }, [page, actionFilter, entityFilter, dateFrom, dateTo])
async function fetchRetentionDays() {
try {
const res = await fetch('/api/audit-logs/cleanup')
if (res.ok) {
const data = await res.json()
setRetentionSaved(data.days)
setRetentionInput(String(data.days))
}
} catch { /* ignore */ }
}
async function saveRetentionDays() {
const days = parseInt(retentionInput, 10)
if (isNaN(days) || days < 30 || days > 365) {
setRetentionError(true)
setTimeout(() => setRetentionError(false), 2000)
return
}
setRetentionSaving(true)
setRetentionError(false)
try {
const res = await fetch('/api/audit-logs/cleanup', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ days })
})
if (res.ok) {
setRetentionSaved(days)
setRetentionSavedFeedback(true)
setTimeout(() => setRetentionSavedFeedback(false), 2000)
} else {
setRetentionError(true)
setTimeout(() => setRetentionError(false), 2000)
}
} catch {
setRetentionError(true)
setTimeout(() => setRetentionError(false), 2000)
}
setRetentionSaving(false)
}
useEffect(() => { fetchRetentionDays() }, [])
function resetFilters() {
setActionFilter('')
setEntityFilter('')
setDateFrom('')
setDateTo('')
setPage(1)
}
function setQuickDate(days: number) {
const today = new Date()
const from = new Date(today)
from.setDate(from.getDate() - days)
const fromStr = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, '0')}-${String(from.getDate()).padStart(2, '0')}`
const toStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
setDateFrom(fromStr)
setDateTo(toStr)
}
async function handleExport() {
const params = new URLSearchParams()
if (actionFilter) params.set('action', actionFilter)
if (entityFilter) params.set('entity_type', entityFilter)
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
const res = await fetch(`/api/audit-logs/export?${params}`)
if (res.ok) {
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const now = new Date()
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
a.download = `audit-logs-${dateStr}.csv`
a.click()
URL.revokeObjectURL(url)
}
}
function formatDetails(log: AuditLog): string {
if (!log.details) return '-'
try {
const details = JSON.parse(log.details)
if (details.changes) {
const keys = Object.keys(details.changes)
return keys.slice(0, 2).join(', ') + (keys.length > 2 ? '...' : '')
}
if (details.created) return '新建记录'
if (details.deleted) return '删除记录'
if (details.export) return `导出 ${details.export.count}`
if (details.batch_update) return `批量更新 ${details.batch_update.count}`
if (details.import) return `导入 ${details.import.created} 新 / ${details.import.updated} 更新`
if (details.message) return details.message
if (details.keys) return details.keys.slice(0, 2).join(', ') + (details.keys.length > 2 ? '...' : '')
if (details.username) return `用户 ${details.username} 登录`
if (details.retention_days !== undefined) return `保留 ${details.retention_days}`
if (details.cleanup) return `清理 ${details.cleanup.deleted}`
return '-'
} catch { return '-' }
}
// 字段名汉化映射
const FIELD_LABELS: Record<string, string> = {
name: '名称', status: '状态', type: '类型', category: '分类',
title: '标题', priority: '优先级', assignee: '负责人', reporter: '报告人',
description: '描述', notes: '备注', email: '邮箱', role: '角色',
username: '用户名', permissions: '权限', count: '数量',
}
function formatValue(val: unknown): string {
if (val === null || val === undefined) return '-'
if (typeof val === 'object') return JSON.stringify(val)
return String(val)
}
function renderDetails(details: Record<string, unknown>) {
// 字段变更assets-ai 格式)
if (details.changes && typeof details.changes === 'object') {
const changes = details.changes as Record<string, { from: unknown; to: unknown }>
return (
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">📋 </p>
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-slate-100 dark:bg-slate-700">
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{Object.entries(changes).map(([key, val]) => (
<tr key={key} className="border-t border-slate-200 dark:border-slate-700">
<td className="px-3 py-2 text-slate-500">{FIELD_LABELS[key] || key}</td>
<td className="px-3 py-2 text-slate-400 line-through">{formatValue(val.from)}</td>
<td className="px-3 py-2 text-green-600 dark:text-green-400 font-medium">{formatValue(val.to)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// 新建记录assets-ai 格式)
if (details.created && typeof details.created === 'object') {
const data = details.created as Record<string, unknown>
return (
<div>
<p className="text-sm font-medium text-green-600 dark:text-green-400 mb-2">🟢 </p>
<div className="grid grid-cols-2 gap-2">
{Object.entries(data).map(([key, val]) => (
<div key={key} className="flex gap-2 text-sm">
<span className="text-slate-500">{FIELD_LABELS[key] || key}</span>
<span>{formatValue(val)}</span>
</div>
))}
</div>
</div>
)
}
// 删除记录assets-ai 格式)
if (details.deleted && typeof details.deleted === 'object') {
const data = details.deleted as Record<string, unknown>
return (
<div>
<p className="text-sm font-medium text-red-600 dark:text-red-400 mb-2">🔴 </p>
<div className="grid grid-cols-2 gap-2">
{Object.entries(data).map(([key, val]) => (
<div key={key} className="flex gap-2 text-sm">
<span className="text-slate-500">{FIELD_LABELS[key] || key}</span>
<span>{formatValue(val)}</span>
</div>
))}
</div>
</div>
)
}
// 批量更新assets-ai 格式)
if (details.batch_update && typeof details.batch_update === 'object') {
const data = details.batch_update as Record<string, unknown>
return (
<div>
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2">📦 {String(data.count)} </p>
{data.ids ? <p className="text-sm text-slate-500"> ID{String(data.ids)}</p> : null}
</div>
)
}
// 导入assets-ai 格式)
if (details.import && typeof details.import === 'object') {
const data = details.import as Record<string, unknown>
return (
<div>
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2">📥 </p>
<div className="flex gap-4 text-sm">
<span>{String(data.created)} </span>
<span>{String(data.updated)} </span>
</div>
</div>
)
}
// 导出assets-ai 格式)
if (details.export && typeof details.export === 'object') {
const data = details.export as Record<string, unknown>
return (
<div>
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-2">📤 {String(data.count)} </p>
</div>
)
}
// issue-ai 特有格式:操作说明
if (details.message && typeof details.message === 'string') {
return (
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">📝 </p>
<p className="text-sm text-slate-600 dark:text-slate-400">{details.message}</p>
</div>
)
}
// issue-ai 特有格式:配置修改
if (details.keys && Array.isArray(details.keys)) {
return (
<div>
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2"> </p>
<ul className="list-disc list-inside space-y-1">
{(details.keys as string[]).map((key, i) => (
<li key={i} className="text-sm text-slate-600 dark:text-slate-400 font-mono">{key}</li>
))}
</ul>
</div>
)
}
// 登录信息
if (details.username && typeof details.username === 'string') {
return (
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">👤 </p>
<div className="grid grid-cols-2 gap-2">
<div className="flex gap-2 text-sm">
<span className="text-slate-500"></span>
<span>{details.username}</span>
</div>
{details.role && (
<div className="flex gap-2 text-sm">
<span className="text-slate-500"></span>
<span>{String(details.role)}</span>
</div>
)}
{details.displayName && (
<div className="flex gap-2 text-sm">
<span className="text-slate-500"></span>
<span>{String(details.displayName)}</span>
</div>
)}
</div>
</div>
)
}
// 保留天数设置
if (details.retention_days !== undefined) {
return (
<div>
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2"> </p>
<p className="text-sm text-slate-600 dark:text-slate-400"> <span className="font-medium">{String(details.retention_days)}</span> </p>
</div>
)
}
// 清理操作
if (details.cleanup) {
const data = details.cleanup as Record<string, unknown>
return (
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">🧹 </p>
<p className="text-sm text-slate-600 dark:text-slate-400"> {String(data.deleted)} </p>
</div>
)
}
// 兜底:格式化 JSON
return (
<pre className="mt-2 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg text-xs font-mono overflow-x-auto">
{JSON.stringify(details, null, 2)}
</pre>
)
}
const columns = [
{
key: 'created_at',
title: '时间',
width: '170px',
render: (r) => <span className="text-sm whitespace-nowrap">{r.created_at}</span>
},
{
key: 'username',
title: '用户',
width: '100px',
render: (r) => (
<div className="flex items-center gap-1 whitespace-nowrap">
<span>{r.username || '-'}</span>
{r.api_key_id && <Badge variant="warning">[API]</Badge>}
</div>
)
},
{
key: 'action',
title: '操作',
width: '70px',
render: (r) => {
const info = ACTION_LABELS[r.action] || { label: r.action, variant: 'default' as const }
return <span className="whitespace-nowrap"><Badge variant={info.variant}>{info.label}</Badge></span>
}
},
{
key: 'entity_type',
title: '对象',
width: '80px',
render: (r) => <span className="whitespace-nowrap">{ENTITY_LABELS[r.entity_type] || r.entity_type}</span>
},
{
key: 'details',
title: '详情',
render: (r) => (
<button
onClick={() => setDetailLog(r)}
className="text-left text-sm text-slate-600 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 truncate max-w-[200px]"
>
{formatDetails(r)}
</button>
)
},
{
key: 'ip_address',
title: 'IP',
width: '120px',
render: (r) => <span className="text-xs font-mono">{r.ip_address || '-'}</span>
},
]
// 保留天数按钮样式
const retentionBtnClass = retentionSaved
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: retentionError
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
: ''
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white"></h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1"></p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={handleExport}></Button>
</div>
</div>
{/* 保留天数设置 */}
<div className="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-800/50 rounded-lg text-sm">
<span className="text-slate-600 dark:text-slate-400"></span>
<input
type="number"
min={30}
max={365}
value={retentionInput}
onChange={(e) => setRetentionInput(e.target.value)}
className="w-20 px-2 py-1 text-sm border border-slate-200 dark:border-slate-700 rounded bg-white dark:bg-slate-900"
/>
<span className="text-xs text-slate-400">30-365 180 </span>
{retentionSavedFeedback ? (
<span className="text-xs font-medium text-green-600 dark:text-green-400"> </span>
) : retentionError ? (
<span className="text-xs font-medium text-red-600 dark:text-red-400"> </span>
) : retentionDirty ? (
<button
className="px-3 py-1 text-xs font-medium rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"
onClick={saveRetentionDays}
disabled={retentionSaving}
>
{retentionSaving ? '保存中...' : '保存'}
</button>
) : null}
</div>
{/* 过滤器 */}
<div className="flex flex-wrap items-center gap-3 p-4 bg-slate-50 dark:bg-slate-800/50 rounded-lg">
<select
value={actionFilter}
onChange={(e) => { setActionFilter(e.target.value); setPage(1) }}
className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900"
>
<option value=""></option>
{Object.entries(ACTION_LABELS).map(([key, { label }]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<select
value={entityFilter}
onChange={(e) => { setEntityFilter(e.target.value); setPage(1) }}
className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900"
>
<option value=""></option>
{Object.entries(ENTITY_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<input
type="date"
value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); setPage(1) }}
className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900"
/>
<span className="text-slate-400">~</span>
<input
type="date"
value={dateTo}
onChange={(e) => { setDateTo(e.target.value); setPage(1) }}
className="px-3 py-1.5 text-sm border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-900"
/>
<div className="flex gap-1">
<Button size="sm" variant="ghost" onClick={() => setQuickDate(7)}>7</Button>
<Button size="sm" variant="ghost" onClick={() => setQuickDate(30)}>30</Button>
<Button size="sm" variant="ghost" onClick={() => setQuickDate(180)}>180</Button>
</div>
<Button size="sm" variant="ghost" onClick={resetFilters}></Button>
</div>
{/* 表格 */}
{loading ? (
<div className="py-12 text-center text-slate-500 dark:text-slate-400">...</div>
) : (
<Table columns={columns as any} data={logs as any} rowKey={(r: any) => String(r.id)} />
)}
{/* 分页 */}
{total > pageSize && (
<div className="flex items-center justify-center gap-2">
<Button
size="sm"
variant="ghost"
disabled={page === 1}
onClick={() => setPage(p => p - 1)}
>
</Button>
<span className="text-sm text-slate-500">
{page} / {Math.ceil(total / pageSize)}
</span>
<Button
size="sm"
variant="ghost"
disabled={page >= Math.ceil(total / pageSize)}
onClick={() => setPage(p => p + 1)}
>
</Button>
</div>
)}
{/* 详情 Modal */}
<Modal
open={!!detailLog}
onClose={() => setDetailLog(null)}
title="审计日志详情"
>
{detailLog && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-slate-500"></span>
<span>{detailLog.created_at}</span>
</div>
<div>
<span className="text-slate-500"></span>
<span>{detailLog.username || '-'}</span>
{detailLog.api_key_id && <span className="ml-1"><Badge variant="warning">[API]</Badge></span>}
</div>
<div>
<span className="text-slate-500"></span>
<Badge variant={ACTION_LABELS[detailLog.action]?.variant || 'default'}>
{ACTION_LABELS[detailLog.action]?.label || detailLog.action}
</Badge>
</div>
<div>
<span className="text-slate-500"></span>
<span>{ENTITY_LABELS[detailLog.entity_type] || detailLog.entity_type}</span>
{detailLog.entity_id && <span className="ml-1">#{detailLog.entity_id}</span>}
</div>
<div>
<span className="text-slate-500">IP</span>
<span className="font-mono">{detailLog.ip_address || '-'}</span>
</div>
</div>
{detailLog.details && (
<div className="mt-3 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
{renderDetails(JSON.parse(detailLog.details))}
</div>
)}
</div>
)}
</Modal>
</div>
)
}

View File

@ -0,0 +1,984 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Card } from '@/components/ui'
import { Search, RefreshCw } from 'lucide-react'
import { useDirtyTracker } from '@shared/ui/hooks/useDirtyTracker'
interface MonitorStatus {
enabled: boolean
status: string
workerHealth: string
lastCheck: string | null
stats: { totalProcessed: number; todayProcessed: number; errors: number; lastError: string | null; lastErrorTime: string | null }
ongoingFaults: number
details: {
todayProcessed: { ticket_no?: string; error?: string; created_at: string }[]
ongoingFaults: { id: number; server_sn: string; server_ip: string | null; order_number: string | null; fault_type: string | null; fault_time: string; fault_detail: string | null }[]
recentErrors: { error?: string; details?: string; created_at: string }[]
}
}
interface MonitorConfig {
monitor: { enabled: boolean; interval_seconds: number; push_delay_ms: number; silent_start_hour: number; silent_end_hour: number }
mail: { address: string; imap_server: string; imap_port: number; pop3_server: string; pop3_port: number; smtp_server: string; smtp_port: number; password: string }
filter: { subject_keywords: string[]; sender_email: string; oem_repair_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string }; oem_diag_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string } }
}
interface ScanState {
status: 'idle' | 'running' | 'completed' | 'error' | 'cancelling'
startedAt?: string
completedAt?: string
timeRange?: { value: number | null; unit: string }
stats?: {
total: number
matched: number
imported: number
skipped: number
errors: number
}
details?: {
msg_id: string
subject: string
date: string
order_number: string | null
status: 'imported' | 'skipped' | 'error'
ticket_no?: string
error?: string
}[]
detailsTruncated?: boolean
error?: string
}
type SectionKey = 'mail' | 'filter' | 'monitor'
export default function MonitorSettingsPage() {
const [status, setStatus] = useState<MonitorStatus | null>(null)
const [config, setConfig] = useState<MonitorConfig | null>(null)
const [loading, setLoading] = useState(true)
const { isDirty, markClean } = useDirtyTracker()
const [saving, setSaving] = useState<SectionKey | null>(null)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [newKeyword, setNewKeyword] = useState('')
const [expandedPanel, setExpandedPanel] = useState<'today' | 'total' | 'errors' | 'ongoing' | null>(null)
const [sectionMessage, setSectionMessage] = useState<Partial<Record<SectionKey, { type: 'success' | 'error'; text: string }>>>({})
// 立即检查冷却状态
const [triggerCooldown, setTriggerCooldown] = useState(0)
const cooldownTimerRef = useRef<NodeJS.Timeout | null>(null)
// 邮箱工单检测状态
const [scanState, setScanState] = useState<ScanState>({ status: 'idle' })
const [scanTimeValue, setScanTimeValue] = useState<number>(7)
const [scanTimeUnit, setScanTimeUnit] = useState<string>('day')
const scanTimerRef = useRef<NodeJS.Timeout | null>(null)
const [importing, setImporting] = useState(false)
// 从 scanState.status 派生 scanning 状态
const scanning = scanState.status === 'running' || scanState.status === 'cancelling'
// 扫描历史状态
const [scanHistory, setScanHistory] = useState<{
items: Array<{
id: number
status: string
started_at: string
completed_at: string | null
time_range_value: number | null
time_range_unit: string | null
total_count: number
matched_count: number
imported_count: number
skipped_count: number
error_count: number
details_truncated: number
error_message: string | null
created_by: number | null
created_at: string
}>
pagination: { page: number; limit: number; total: number; totalPages: number }
} | null>(null)
const [historyPage, setHistoryPage] = useState(1)
const [loadingHistory, setLoadingHistory] = useState(false)
// 刷新扫描历史
const refreshScanHistory = useCallback(async (page = 1) => {
setLoadingHistory(true)
try {
const res = await fetch(`/api/monitor/scan-history?page=${page}&limit=10`)
const data = await res.json()
setScanHistory(data)
setHistoryPage(page)
} catch (err) {
console.error('[Scan] 历史加载失败:', err)
} finally {
setLoadingHistory(false)
}
}, [])
// 自动刷新定时器
const statusTimerRef = useRef<NodeJS.Timeout | null>(null)
const errorTimerRef = useRef<NodeJS.Timeout | null>(null)
// 页面可见性状态
const isVisibleRef = useRef(true)
// 刷新运行状态
const refreshStatus = useCallback(() => {
fetch('/api/monitor/status')
.then(r => r.json())
.then(setStatus)
.catch(err => console.error('[Monitor] 状态刷新失败:', err))
}, [])
// 刷新错误列表(更频繁)- 使用相同的 API但只更新错误部分
const refreshErrors = useCallback(() => {
fetch('/api/monitor/status')
.then(r => r.json())
.then(data => {
// 安全地合并错误列表,避免路径依赖
setStatus(prev => {
if (!prev || !data) return prev
return {
...prev,
stats: data.stats || prev.stats,
details: {
...prev.details,
recentErrors: data.details?.recentErrors ?? prev.details?.recentErrors ?? [],
},
}
})
})
.catch(err => console.error('[Monitor] 错误列表刷新失败:', err))
}, [])
// 刷新扫描状态
const refreshScanState = useCallback(() => {
fetch('/api/monitor/scan-status')
.then(r => r.json())
.then(data => {
setScanState(data)
// 扫描结束时停止轮询scanning 从 status 派生)
if (data.status !== 'running' && data.status !== 'cancelling') {
if (scanTimerRef.current) {
clearInterval(scanTimerRef.current)
scanTimerRef.current = null
}
}
})
.catch(err => {
console.error('[Scan] 状态刷新失败:', err)
// 网络异常时不停止轮询,等待下次重试
})
}, [])
// 页面可见性变化
useEffect(() => {
const handler = () => {
isVisibleRef.current = !document.hidden
if (isVisibleRef.current) {
// 页面恢复可见,立即刷新
refreshStatus()
refreshErrors()
if (scanning) refreshScanState()
}
}
document.addEventListener('visibilitychange', handler)
return () => document.removeEventListener('visibilitychange', handler)
}, [scanning, refreshStatus, refreshErrors, refreshScanState])
// 自动刷新定时器
useEffect(() => {
// 运行状态60 秒刷新
statusTimerRef.current = setInterval(() => {
if (isVisibleRef.current) refreshStatus()
}, 60000)
// 错误列表30 秒刷新
errorTimerRef.current = setInterval(() => {
if (isVisibleRef.current) refreshErrors()
}, 30000)
return () => {
if (statusTimerRef.current) clearInterval(statusTimerRef.current)
if (errorTimerRef.current) clearInterval(errorTimerRef.current)
}
}, [refreshStatus, refreshErrors])
// 初始加载
useEffect(() => {
Promise.all([
fetch('/api/monitor/status').then(r => r.json()),
fetch('/api/monitor/settings').then(r => r.json()),
fetch('/api/monitor/scan-status').then(r => r.json()),
fetch('/api/monitor/scan-history?page=1&limit=10').then(r => r.json()),
]).then(([s, c, scan, history]) => {
setStatus(s)
setConfig(c)
markClean('mail', { mail: c.mail })
markClean('filter', { filter: c.filter })
markClean('monitor', { monitor: c.monitor })
setScanState(scan)
setScanHistory(history)
setLoading(false)
}).catch(() => setLoading(false))
}, [])
// 立即检查处理
const handleTrigger = async () => {
if (triggerCooldown > 0) return
const res = await fetch('/api/monitor/trigger', { method: 'POST' })
const data = await res.json()
if (data.success) {
setMessage({ type: 'success', text: data.message })
setTimeout(refreshStatus, 2000)
} else {
setMessage({ type: 'error', text: data.error })
}
// 启动 30 秒冷却
setTriggerCooldown(30)
cooldownTimerRef.current = setInterval(() => {
setTriggerCooldown(prev => {
if (prev <= 1) {
if (cooldownTimerRef.current) clearInterval(cooldownTimerRef.current)
return 0
}
return prev - 1
})
}, 1000)
}
// 清理冷却定时器
useEffect(() => {
return () => {
if (cooldownTimerRef.current) clearInterval(cooldownTimerRef.current)
}
}, [])
// 启动扫描
const handleStartScan = async () => {
if (scanning) return
setScanState({ status: 'running' })
const res = await fetch('/api/monitor/scan-emails', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ timeRange: { value: scanTimeValue, unit: scanTimeUnit } }),
})
const data = await res.json()
if (!data.success) {
setMessage({ type: 'error', text: data.error })
setScanState({ status: 'error', error: data.error })
return
}
// 启动轮询
scanTimerRef.current = setInterval(refreshScanState, 3000)
}
// 取消扫描
const handleCancelScan = async () => {
const res = await fetch('/api/monitor/scan-status', { method: 'DELETE' })
const data = await res.json()
if (data.success) {
setMessage({ type: 'success', text: data.message })
setScanState(prev => ({ ...prev, status: 'cancelling' }))
} else {
setMessage({ type: 'error', text: data.error })
}
}
// 导入跳过的邮件为工单
const handleImportSkipped = async () => {
if (!scanState.details || scanState.status !== 'completed') return
const skippedEmails = scanState.details.filter(d =>
d.status === 'skipped' && d.order_number && d.error !== '工单已存在' && d.error !== '邮件已处理过'
)
if (skippedEmails.length === 0) {
setMessage({ type: 'error', text: '没有可导入的邮件(已跳过的邮件需要有工单号)' })
return
}
if (!confirm(`确认导入 ${skippedEmails.length} 封邮件为工单?`)) return
setImporting(true)
try {
const res = await fetch('/api/monitor/scan-emails/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emails: skippedEmails }),
})
const data = await res.json()
if (data.success) {
setMessage({ type: 'success', text: data.message })
// 刷新扫描状态
refreshScanState()
} else {
setMessage({ type: 'error', text: data.error })
}
} catch (err) {
setMessage({ type: 'error', text: '导入失败' })
} finally {
setImporting(false)
}
}
// 清理扫描定时器
useEffect(() => {
return () => {
if (scanTimerRef.current) clearInterval(scanTimerRef.current)
}
}, [])
// 其他原有函数保持不变...
const handleStart = async () => {
await fetch('/api/monitor/start', { method: 'POST' })
refreshStatus()
setMessage({ type: 'success', text: '监控已启用' })
}
const handleStop = async () => {
if (!confirm('确认停用邮件监控?')) return
await fetch('/api/monitor/stop', { method: 'POST' })
refreshStatus()
setMessage({ type: 'success', text: '监控已停用' })
}
const handleTestMail = async () => {
setSectionMessage(prev => ({ ...prev, mail: { type: 'success', text: '正在测试邮箱连接...' } }))
const res = await fetch('/api/monitor/test-mail', { method: 'POST' })
const data = await res.json()
setSectionMessage(prev => ({ ...prev, mail: { type: data.success ? 'success' : 'error', text: data.success ? data.message : data.error } }))
}
const handleSectionSave = async (section: SectionKey) => {
if (!config) return
setSaving(section)
setSectionMessage(prev => ({ ...prev, [section]: undefined }))
const sectionData = { [section]: config[section] }
const res = await fetch('/api/monitor/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(sectionData) })
const data = await res.json()
setSaving(null)
if (data.success) {
markClean(section, { [section]: config[section] })
setSectionMessage(prev => ({ ...prev, [section]: { type: 'success', text: '已保存' } }))
} else {
setSectionMessage(prev => ({ ...prev, [section]: { type: 'error', text: data.error } }))
}
setTimeout(() => setSectionMessage(prev => ({ ...prev, [section]: undefined })), 3000)
}
const addKeyword = () => {
if (!config || !newKeyword.trim()) return
setConfig({ ...config, filter: { ...config.filter, subject_keywords: [...config.filter.subject_keywords, newKeyword.trim()] } })
setNewKeyword('')
}
const removeKeyword = (index: number) => {
if (!config) return
setConfig({ ...config, filter: { ...config.filter, subject_keywords: config.filter.subject_keywords.filter((_, i) => i !== index) } })
}
const inputClass = (path: string, changed = false) =>
`w-full px-3 py-2 border rounded-lg text-sm transition-colors ${changed ? 'border-amber-400 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-600' : 'border-slate-300 dark:border-slate-600'}`
const SectionSaveButton = ({ section }: { section: SectionKey }) => {
if (!config) return null
const changed = isDirty(section, { [section]: config[section] })
if (!changed) return null
return (
<div className="mt-4 flex items-center gap-3">
<button
onClick={() => handleSectionSave(section)}
disabled={saving === section}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 text-sm font-medium"
>
{saving === section ? '保存中...' : '保存设置'}
</button>
</div>
)
}
if (loading) return <div className="p-6">...</div>
if (!config) return <div className="p-6"></div>
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100"></h1>
<p className="text-slate-500 dark:text-slate-400 mt-1"></p>
</div>
{message && (
<div className={`p-3 rounded-lg text-sm ${message.type === 'success' ? 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400' : 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400'}`}>
{message.text}
<button onClick={() => setMessage(null)} className="float-right font-bold">×</button>
</div>
)}
{/* 运行状态 */}
<Card className="p-5">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="flex items-center gap-4 mb-4">
<span className={`inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm font-medium ${status?.workerHealth === 'healthy' ? 'bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400' : status?.workerHealth === 'error' ? 'bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400' : status?.workerHealth === 'unresponsive' ? 'bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400' : 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'}`}>
<span className={`w-2 h-2 rounded-full ${status?.workerHealth === 'healthy' ? 'bg-green-500' : status?.workerHealth === 'error' || status?.workerHealth === 'unresponsive' ? 'bg-red-500' : 'bg-slate-400'}`} />
{status?.workerHealth === 'healthy' ? '运行中' : status?.workerHealth === 'error' ? '运行异常' : status?.workerHealth === 'unresponsive' ? '无响应' : '已停止'}
</span>
{status?.lastCheck && <span className="text-sm text-slate-500">: {status.lastCheck}</span>}
</div>
{/* 统计指标 */}
<div className="grid grid-cols-4 gap-4 mb-4">
{[
{ key: 'today' as const, label: '今日处理', value: status?.stats.todayProcessed || 0, color: 'text-blue-600 dark:text-blue-400' },
{ key: 'total' as const, label: '累计', value: status?.stats.totalProcessed || 0, color: 'text-slate-700 dark:text-slate-300' },
{ key: 'errors' as const, label: '错误', value: status?.stats.errors || 0, color: 'text-red-600 dark:text-red-400' },
{ key: 'ongoing' as const, label: '进行中故障', value: status?.ongoingFaults || 0, color: 'text-amber-600 dark:text-amber-400' },
].map(item => (
<div key={item.key}>
<span className="text-slate-500 text-sm">{item.label}: </span>
<button
onClick={() => setExpandedPanel(expandedPanel === item.key ? null : item.key)}
className={`font-medium text-sm cursor-pointer hover:opacity-70 underline decoration-dotted underline-offset-2 transition-opacity ${item.color}`}
>
{item.value}
<span className="ml-1 text-xs">{expandedPanel === item.key ? '▲' : '▼'}</span>
</button>
</div>
))}
</div>
{/* 展开面板 */}
{expandedPanel && (
<div className="mb-4 border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
{expandedPanel === 'today' && status?.details?.todayProcessed && (
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<button onClick={() => setExpandedPanel(null)} className="text-xs text-slate-500 hover:text-slate-700"></button>
</div>
{status.details.todayProcessed.length === 0 ? (
<p className="text-sm text-slate-500"></p>
) : (
<div className="space-y-2 max-h-48 overflow-y-auto">
{status.details.todayProcessed.map((item, i) => (
<div key={i} className="flex items-center justify-between text-sm py-1 border-b border-slate-100 dark:border-slate-800 last:border-0">
<span className="text-slate-700 dark:text-slate-300">
{item.ticket_no ? `工单 ${item.ticket_no}` : item.error || '处理完成'}
</span>
<span className="text-slate-400 text-xs">{item.created_at}</span>
</div>
))}
</div>
)}
</div>
)}
{expandedPanel === 'total' && (
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<button onClick={() => setExpandedPanel(null)} className="text-xs text-slate-500 hover:text-slate-700"></button>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<span className="text-slate-500"></span>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{status?.stats.totalProcessed || 0}</p>
</div>
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<span className="text-slate-500"></span>
<p className="text-2xl font-bold text-blue-600">{status?.stats.todayProcessed || 0}</p>
</div>
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<span className="text-slate-500"></span>
<p className="text-2xl font-bold text-red-600">{status?.stats.errors || 0}</p>
</div>
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<span className="text-slate-500"></span>
<p className="text-2xl font-bold text-amber-600">{status?.ongoingFaults || 0}</p>
</div>
</div>
</div>
)}
{expandedPanel === 'errors' && status?.details?.recentErrors && (
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-red-700 dark:text-red-400"></span>
<button onClick={() => setExpandedPanel(null)} className="text-xs text-slate-500 hover:text-slate-700"></button>
</div>
{status.details.recentErrors.length === 0 ? (
<p className="text-sm text-slate-500"></p>
) : (
<div className="space-y-2 max-h-48 overflow-y-auto">
{status.details.recentErrors.map((item, i) => (
<div key={i} className="p-2 bg-red-50 dark:bg-red-900/20 rounded text-xs">
<div className="flex items-center justify-between mb-1">
<span className="text-red-700 dark:text-red-400 font-medium"></span>
<span className="text-slate-400">{item.created_at}</span>
</div>
<pre className="text-red-600 dark:text-red-400 whitespace-pre-wrap break-all">
{(() => {
const errStr = item.error || item.details || JSON.stringify(item)
try { return JSON.stringify(JSON.parse(errStr), null, 2) }
catch { return errStr }
})()}
</pre>
</div>
))}
</div>
)}
</div>
)}
{expandedPanel === 'ongoing' && status?.details?.ongoingFaults && (
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-amber-700 dark:text-amber-400"></span>
<button onClick={() => setExpandedPanel(null)} className="text-xs text-slate-500 hover:text-slate-700"></button>
</div>
{status.details.ongoingFaults.length === 0 ? (
<p className="text-sm text-slate-500"></p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700 text-left">
<th className="py-2 pr-4 text-slate-500 font-medium"></th>
<th className="py-2 pr-4 text-slate-500 font-medium">IP</th>
<th className="py-2 pr-4 text-slate-500 font-medium">SN</th>
<th className="py-2 pr-4 text-slate-500 font-medium"></th>
<th className="py-2 pr-4 text-slate-500 font-medium"></th>
<th className="py-2 text-slate-500 font-medium"></th>
</tr>
</thead>
<tbody>
{status.details.ongoingFaults.map((fault, i) => (
<tr key={i} className="border-b border-slate-100 dark:border-slate-800 last:border-0">
<td className="py-2 pr-4 font-mono text-xs">{fault.order_number || '-'}</td>
<td className="py-2 pr-4">{fault.server_ip || '未知'}</td>
<td className="py-2 pr-4 font-mono text-xs">{fault.server_sn}</td>
<td className="py-2 pr-4">
<span className={`px-2 py-0.5 rounded text-xs ${fault.fault_type === 'oem_diag' ? 'bg-blue-100 text-blue-700' : fault.fault_type === 'oem_repair' ? 'bg-purple-100 text-purple-700' : 'bg-slate-100 text-slate-600'}`}>
{fault.fault_type === 'oem_diag' ? '诊断' : fault.fault_type === 'oem_repair' ? '维修' : '未知'}
</span>
</td>
<td className="py-2 pr-4 text-xs">{fault.fault_time}</td>
<td className="py-2 text-xs max-w-xs truncate" title={fault.fault_detail || ''}>{fault.fault_detail || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
)}
<div className="flex gap-2">
{status?.enabled ? <button onClick={handleStop} className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 text-sm"></button> : <button onClick={handleStart} className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 text-sm"></button>}
<button
onClick={handleTrigger}
disabled={triggerCooldown > 0}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{triggerCooldown > 0 ? `请等待 ${triggerCooldown}` : '立即检查'}
</button>
</div>
</Card>
{/* 邮箱配置 */}
<Card className="p-5">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1"></label>
<input type="text" value={config.mail.address} onChange={e => setConfig({ ...config, mail: { ...config.mail, address: e.target.value } })} className={inputClass('mail.address')} />
</div>
<div>
<label className="block text-sm font-medium mb-1"></label>
<input type="password" value={config.mail.password} onChange={e => setConfig({ ...config, mail: { ...config.mail, password: e.target.value } })} className={inputClass('mail.password')} placeholder="••••••••" />
</div>
<div>
<label className="block text-sm font-medium mb-1">IMAP </label>
<input type="text" value={config.mail.imap_server} onChange={e => setConfig({ ...config, mail: { ...config.mail, imap_server: e.target.value } })} className={inputClass('mail.imap_server')} />
</div>
<div>
<label className="block text-sm font-medium mb-1">IMAP </label>
<input type="number" value={config.mail.imap_port} onChange={e => setConfig({ ...config, mail: { ...config.mail, imap_port: parseInt(e.target.value) || 993 } })} className={inputClass('mail.imap_port')} />
</div>
<div>
<label className="block text-sm font-medium mb-1">POP3 </label>
<input type="text" value={config.mail.pop3_server} onChange={e => setConfig({ ...config, mail: { ...config.mail, pop3_server: e.target.value } })} className={inputClass('mail.pop3_server')} />
</div>
<div>
<label className="block text-sm font-medium mb-1">POP3 </label>
<input type="number" value={config.mail.pop3_port} onChange={e => setConfig({ ...config, mail: { ...config.mail, pop3_port: parseInt(e.target.value) || 995 } })} className={inputClass('mail.pop3_port')} />
</div>
<div>
<label className="block text-sm font-medium mb-1">SMTP </label>
<input type="text" value={config.mail.smtp_server} onChange={e => setConfig({ ...config, mail: { ...config.mail, smtp_server: e.target.value } })} className={inputClass('mail.smtp_server')} />
</div>
<div>
<label className="block text-sm font-medium mb-1">SMTP </label>
<input type="number" value={config.mail.smtp_port} onChange={e => setConfig({ ...config, mail: { ...config.mail, smtp_port: parseInt(e.target.value) || 465 } })} className={inputClass('mail.smtp_port')} />
</div>
</div>
<div className="mt-4 flex items-center gap-3">
<button onClick={handleTestMail} className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-sm"></button>
{sectionMessage.mail && (
<span className={`text-sm ${sectionMessage.mail.type === 'success' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
{sectionMessage.mail.text}
</span>
)}
</div>
<SectionSaveButton section="mail" />
</Card>
{/* 邮件过滤 */}
<Card className="p-5">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1"></label>
<div className="flex flex-wrap gap-2 mb-2">
{config.filter.subject_keywords.map((kw, i) => (
<span key={i} className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
{kw}
<button onClick={() => removeKeyword(i)} className="hover:opacity-70">×</button>
</span>
))}
</div>
<div className="flex gap-2">
<input type="text" value={newKeyword} onChange={e => setNewKeyword(e.target.value)} onKeyDown={e => e.key === 'Enter' && addKeyword()} className="flex-1 px-3 py-2 border rounded-lg text-sm" placeholder="输入关键词后回车" />
<button onClick={addKeyword} className="px-3 py-2 bg-blue-500 text-white rounded-lg text-sm"></button>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1"></label>
<input type="text" value={config.filter.sender_email} onChange={e => setConfig({ ...config, filter: { ...config.filter, sender_email: e.target.value } })} className={inputClass('filter.sender_email')} />
</div>
</div>
<SectionSaveButton section="filter" />
</Card>
{/* 邮箱工单检测(新模块) */}
<Card className="p-5">
<h2 className="text-lg font-semibold mb-4"></h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-4"></p>
{/* 时间范围选择 */}
<div className="flex items-center gap-3 mb-4">
<span className="text-sm text-slate-600 dark:text-slate-400"></span>
<input
type="number"
value={scanTimeValue}
onChange={e => setScanTimeValue(parseInt(e.target.value) || 1)}
className="w-20 px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
min={1}
max={365}
disabled={scanning}
/>
<select
value={scanTimeUnit}
onChange={e => setScanTimeUnit(e.target.value)}
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
disabled={scanning}
>
<option value="minute"></option>
<option value="hour"></option>
<option value="day"></option>
<option value="week"></option>
<option value="month"></option>
<option value="all"></option>
</select>
<button
onClick={handleStartScan}
disabled={scanning}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2"
>
{scanning ? (
<>
<RefreshCw size={14} className="animate-spin" />
...
</>
) : (
<>
<Search size={14} />
</>
)}
</button>
{scanning && (
<button
onClick={handleCancelScan}
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 text-sm inline-flex items-center gap-2"
>
</button>
)}
</div>
{/* 扫描进度 */}
{scanState.status === 'running' && (
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div className="flex items-center gap-2 text-sm text-blue-700 dark:text-blue-400">
<RefreshCw size={14} className="animate-spin" />
...
</div>
{scanState.stats && (
<div className="mt-2 text-xs text-slate-600 dark:text-slate-400">
{scanState.stats.total} {scanState.stats.imported} {scanState.stats.skipped}
</div>
)}
</div>
)}
{/* 扫描取消中 */}
{scanState.status === 'cancelling' && (
<div className="mb-4 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg">
<div className="flex items-center gap-2 text-sm text-amber-700 dark:text-amber-400">
<RefreshCw size={14} className="animate-spin" />
...
</div>
{scanState.stats && (
<div className="mt-2 text-xs text-slate-600 dark:text-slate-400">
{scanState.stats.total} {scanState.stats.imported + scanState.stats.skipped}
</div>
)}
</div>
)}
{/* 扫描结果 */}
{(scanState.status === 'completed' || scanState.status === 'cancelling') && scanState.stats && (
<div className="space-y-4">
{scanState.error && (
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg text-sm text-amber-700 dark:text-amber-400">
{scanState.error}
</div>
)}
{scanState.detailsTruncated && (
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg text-sm text-amber-700 dark:text-amber-400">
500
</div>
)}
<div className="grid grid-cols-5 gap-4 text-sm">
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<span className="text-slate-500"></span>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">{scanState.stats.total}</p>
</div>
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<span className="text-slate-500"></span>
<p className="text-xl font-bold text-blue-600">{scanState.stats.matched}</p>
</div>
<div className="p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<span className="text-green-600"></span>
<p className="text-xl font-bold text-green-600">{scanState.stats.imported}</p>
</div>
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<span className="text-slate-500"></span>
<p className="text-xl font-bold text-slate-600">{scanState.stats.skipped}</p>
</div>
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<span className="text-red-600"></span>
<p className="text-xl font-bold text-red-600">{scanState.stats.errors}</p>
</div>
</div>
{/* 详细结果列表 */}
{scanState.details && scanState.details.length > 0 && (
<div className="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
<div className="max-h-64 overflow-y-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 dark:bg-slate-800 sticky top-0">
<tr>
<th className="py-2 px-4 text-left text-slate-500 font-medium"></th>
<th className="py-2 px-4 text-left text-slate-500 font-medium"></th>
<th className="py-2 px-4 text-left text-slate-500 font-medium"></th>
<th className="py-2 px-4 text-left text-slate-500 font-medium"></th>
</tr>
</thead>
<tbody>
{scanState.details.map((item, i) => (
<tr key={i} className="border-t border-slate-100 dark:border-slate-800">
<td className="py-2 px-4 font-mono text-xs">{item.order_number || '-'}</td>
<td className="py-2 px-4 text-xs max-w-xs truncate" title={item.subject}>{item.subject || '-'}</td>
<td className="py-2 px-4">
<span className={`px-2 py-0.5 rounded text-xs ${
item.status === 'imported' ? 'bg-green-100 text-green-700' :
item.status === 'skipped' ? 'bg-slate-100 text-slate-600' :
'bg-red-100 text-red-700'
}`}>
{item.status === 'imported' ? '已导入' : item.status === 'skipped' ? '已跳过' : '错误'}
</span>
</td>
<td className="py-2 px-4 text-xs text-slate-500">{item.error || item.ticket_no || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* 导入按钮 */}
{scanState.status === 'completed' && scanState.details && scanState.details.some(d =>
d.status === 'skipped' && d.order_number && d.error !== '工单已存在' && d.error !== '邮件已处理过'
) && (
<div className="flex items-center gap-3">
<button
onClick={handleImportSkipped}
disabled={importing}
className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 text-sm disabled:opacity-50 inline-flex items-center gap-2"
>
{importing ? (
<>
<RefreshCw size={14} className="animate-spin" />
...
</>
) : (
'导入跳过的邮件为工单'
)}
</button>
<span className="text-sm text-slate-500">
{scanState.details.filter(d =>
d.status === 'skipped' && d.order_number && d.error !== '工单已存在' && d.error !== '邮件已处理过'
).length}
</span>
</div>
)}
</div>
)}
{/* 扫描错误 */}
{scanState.status === 'error' && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg text-sm text-red-700 dark:text-red-400">
: {scanState.error || '未知错误'}
</div>
)}
</Card>
{/* 全局运行参数 */}
<Card className="p-5">
<h2 className="text-lg font-semibold mb-4"></h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-4">使</p>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium mb-1"></label>
<input type="number" value={config.monitor.interval_seconds} onChange={e => setConfig({ ...config, monitor: { ...config.monitor, interval_seconds: parseInt(e.target.value) || 60 } })} className={inputClass('monitor.interval_seconds')} min={10} max={3600} />
</div>
<div>
<label className="block text-sm font-medium mb-1"></label>
<input type="number" value={config.monitor.silent_start_hour} onChange={e => setConfig({ ...config, monitor: { ...config.monitor, silent_start_hour: parseInt(e.target.value) || 0 } })} className={inputClass('monitor.silent_start_hour')} min={0} max={23} />
</div>
<div>
<label className="block text-sm font-medium mb-1"></label>
<input type="number" value={config.monitor.silent_end_hour} onChange={e => setConfig({ ...config, monitor: { ...config.monitor, silent_end_hour: parseInt(e.target.value) || 7 } })} className={inputClass('monitor.silent_end_hour')} min={0} max={23} />
</div>
</div>
<SectionSaveButton section="monitor" />
</Card>
{/* 扫描历史 */}
<Card className="p-5">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1"></p>
</div>
<button
onClick={() => refreshScanHistory(1)}
disabled={loadingHistory}
className="px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-sm inline-flex items-center gap-1.5"
>
<RefreshCw size={14} className={loadingHistory ? 'animate-spin' : ''} />
</button>
</div>
{scanHistory && scanHistory.items.length > 0 ? (
<div className="space-y-4">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 dark:bg-slate-800">
<tr>
<th className="py-2 px-4 text-left text-slate-500 font-medium"></th>
<th className="py-2 px-4 text-left text-slate-500 font-medium"></th>
<th className="py-2 px-4 text-left text-slate-500 font-medium"></th>
<th className="py-2 px-4 text-left text-slate-500 font-medium"></th>
<th className="py-2 px-4 text-left text-slate-500 font-medium"></th>
<th className="py-2 px-4 text-left text-slate-500 font-medium"></th>
<th className="py-2 px-4 text-left text-slate-500 font-medium"></th>
<th className="py-2 px-4 text-left text-slate-500 font-medium"></th>
</tr>
</thead>
<tbody>
{scanHistory.items.map((item) => (
<tr key={item.id} className="border-t border-slate-100 dark:border-slate-800">
<td className="py-2 px-4 text-xs">{item.started_at}</td>
<td className="py-2 px-4 text-xs">
{item.time_range_unit === 'all' ? '全部' : `${item.time_range_value || ''}${item.time_range_unit === 'minute' ? '分钟' : item.time_range_unit === 'hour' ? '小时' : item.time_range_unit === 'day' ? '天' : item.time_range_unit === 'week' ? '周' : '月'}`}
</td>
<td className="py-2 px-4">
<span className={`px-2 py-0.5 rounded text-xs ${
item.status === 'completed' ? 'bg-green-100 text-green-700' :
item.status === 'error' ? 'bg-red-100 text-red-700' :
item.status === 'cancelling' ? 'bg-amber-100 text-amber-700' :
'bg-slate-100 text-slate-600'
}`}>
{item.status === 'completed' ? '完成' : item.status === 'error' ? '错误' : item.status === 'cancelling' ? '已取消' : item.status}
</span>
</td>
<td className="py-2 px-4 text-xs font-medium">{item.total_count}</td>
<td className="py-2 px-4 text-xs text-green-600">{item.imported_count}</td>
<td className="py-2 px-4 text-xs text-slate-500">{item.skipped_count}</td>
<td className="py-2 px-4 text-xs text-red-600">{item.error_count}</td>
<td className="py-2 px-4 text-xs text-slate-500">{item.created_by || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 分页 */}
{scanHistory.pagination.totalPages > 1 && (
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500">
{scanHistory.pagination.total}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => refreshScanHistory(historyPage - 1)}
disabled={historyPage <= 1 || loadingHistory}
className="px-3 py-1 border border-slate-300 dark:border-slate-600 rounded disabled:opacity-50"
>
</button>
<span className="text-slate-500">
{historyPage} / {scanHistory.pagination.totalPages}
</span>
<button
onClick={() => refreshScanHistory(historyPage + 1)}
disabled={historyPage >= scanHistory.pagination.totalPages || loadingHistory}
className="px-3 py-1 border border-slate-300 dark:border-slate-600 rounded disabled:opacity-50"
>
</button>
</div>
</div>
)}
</div>
) : (
<p className="text-sm text-slate-500 text-center py-4">
{loadingHistory ? '加载中...' : '暂无扫描历史'}
</p>
)}
</Card>
</div>
)
}

View File

@ -1,10 +1,11 @@
import Link from 'next/link'
import { Card } from '@/components/ui'
import { Users, Shield, Settings as SettingsIcon } from 'lucide-react'
import { Users, Shield, Settings as SettingsIcon, Mail } from 'lucide-react'
const sections = [
{ href: '/settings/users', label: '用户管理', description: '管理系统用户与账号', icon: Users },
{ href: '/settings/roles', label: '角色权限', description: '配置角色与权限', icon: Shield },
{ href: '/settings/monitor', label: '邮件监控', description: '邮件监控与微信推送配置', icon: Mail },
]
export default function SettingsPage() {

View File

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

View File

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

View File

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

View File

@ -0,0 +1,293 @@
'use client'
import { useState, useEffect } from 'react'
import { Card } from '@/components/ui'
import { Plus, Trash2, ChevronDown, ChevronUp } from 'lucide-react'
import { useDirtyTracker } from '@shared/ui/hooks/useDirtyTracker'
interface WebhookConfig {
id: string
name: string
url: string
enabled: boolean
params: {
interval_seconds: number
push_delay_ms: number
silent_start_hour: number
silent_end_hour: number
}
}
function generateId() {
return 'wh_' + Math.random().toString(36).slice(2, 10)
}
export default function WechatSettingsPage() {
const [config, setConfig] = useState<{ wechat: { webhooks: WebhookConfig[] } } | null>(null)
const [loading, setLoading] = useState(true)
const { isDirty, markClean, removeItem } = useDirtyTracker()
const [savingId, setSavingId] = useState<string | null>(null)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [expandedWebhooks, setExpandedWebhooks] = useState<Set<string>>(new Set())
const [testingWebhook, setTestingWebhook] = useState<string | null>(null)
// 加载配置
useEffect(() => {
fetch('/api/monitor/settings')
.then(r => {
if (!r.ok) throw new Error('加载失败')
return r.json()
})
.then(data => setConfig({ wechat: data.wechat }))
.catch(err => setMessage({ type: 'error', text: '加载配置失败: ' + err.message }))
.finally(() => setLoading(false))
}, [])
// 保存单个推送组
const handleSaveSingle = async (id: string) => {
if (!config) return
const wh = config.wechat.webhooks.find(w => w.id === id)
if (!wh) return
setSavingId(id)
setMessage(null)
try {
const res = await fetch('/api/monitor/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ wechat: { webhooks: [wh] } }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || '保存失败')
markClean(id, wh)
setMessage({ type: 'success', text: `${wh.name}」保存成功` })
setTimeout(() => setMessage(null), 3000)
} catch (err) {
setMessage({ type: 'error', text: `${wh.name}」保存失败: ${err instanceof Error ? err.message : String(err)}` })
} finally {
setSavingId(null)
}
}
// 测试 webhook
const handleTestWebhook = async (webhookId: string) => {
if (!config) return
const wh = config.wechat.webhooks.find(w => w.id === webhookId)
if (!wh || !wh.url) return
setTestingWebhook(webhookId)
const res = await fetch('/api/monitor/test-wechat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ webhook_url: wh.url }),
})
const data = await res.json()
setTestingWebhook(null)
setMessage({ type: data.success ? 'success' : 'error', text: data.success ? `${wh.name}」测试成功` : `${wh.name}${data.error}` })
setTimeout(() => setMessage(null), 5000)
}
// 新增 webhook
const addWebhook = () => {
if (!config) return
const newWh: WebhookConfig = {
id: generateId(),
name: `推送 ${config.wechat.webhooks.length + 1}`,
url: '',
enabled: true,
params: { interval_seconds: 60, push_delay_ms: 2000, silent_start_hour: 0, silent_end_hour: 7 },
}
const webhooks = [...config.wechat.webhooks, newWh]
setConfig({ wechat: { ...config.wechat, webhooks } })
setExpandedWebhooks(prev => new Set(prev).add(newWh.id))
}
// 删除 webhook至少保留 1 个)
const removeWebhook = (id: string) => {
if (!config) return
if (config.wechat.webhooks.length <= 1) return
setConfig({ wechat: { ...config.wechat, webhooks: config.wechat.webhooks.filter(w => w.id !== id) } })
removeItem(id)
}
// 更新 webhook 基本字段
const updateWebhook = (id: string, updates: Partial<WebhookConfig>) => {
if (!config) return
const webhooks = config.wechat.webhooks.map(w => w.id === id ? { ...w, ...updates } : w)
setConfig({ wechat: { ...config.wechat, webhooks } })
}
// 更新 webhook 高级参数
const updateWebhookParams = (id: string, paramUpdates: Partial<WebhookConfig['params']>) => {
if (!config) return
const webhooks = config.wechat.webhooks.map(w => w.id === id ? { ...w, params: { ...w.params, ...paramUpdates } } : w)
setConfig({ wechat: { ...config.wechat, webhooks } })
}
// 展开/折叠 webhook
const toggleWebhookExpand = (id: string) => {
setExpandedWebhooks(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
if (loading) return <div className="p-6 text-slate-500 dark:text-slate-400">...</div>
if (!config) return <div className="p-6 text-slate-500 dark:text-slate-400"></div>
return (
<div className="space-y-6">
{/* 页面标题 */}
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100"></h1>
<p className="text-slate-500 dark:text-slate-400 mt-1"> Webhook /</p>
</div>
{/* 全局消息提示 */}
{message && (
<div className={`p-3 rounded-lg text-sm ${message.type === 'success' ? 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400' : 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400'}`}>
{message.text}
<button onClick={() => setMessage(null)} className="float-right font-bold">&times;</button>
</div>
)}
{/* Webhook 列表 */}
<Card className="p-5">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100"></h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">/</p>
</div>
<button
onClick={addWebhook}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm font-medium"
>
<Plus size={14} />
</button>
</div>
<div className="space-y-3">
{config.wechat.webhooks.map((wh, index) => (
<div
key={wh.id}
className={`border rounded-lg transition-colors ${wh.enabled ? 'border-slate-200 dark:border-slate-700' : 'border-slate-200 dark:border-slate-700 opacity-60'}`}
>
{/* 主行:序号 + 名称 + 启停 + 测试 + 删除 */}
<div className="flex items-center gap-3 px-4 py-3">
<button
onClick={() => toggleWebhookExpand(wh.id)}
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
>
{expandedWebhooks.has(wh.id) ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
<span className="text-sm font-medium text-slate-500 dark:text-slate-400 w-8">#{index + 1}</span>
<input
type="text"
value={wh.name}
onChange={e => updateWebhook(wh.id, { name: e.target.value })}
className="flex-1 px-2 py-1 text-sm border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-400 rounded text-slate-900 dark:text-slate-100 font-medium"
placeholder="推送组名称"
/>
{/* 启用/停用 toggle */}
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={wh.enabled}
onChange={e => updateWebhook(wh.id, { enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-slate-200 peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-slate-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-slate-600 peer-checked:bg-blue-500"></div>
</label>
{/* 测试按钮 */}
<button
onClick={() => handleTestWebhook(wh.id)}
disabled={!wh.url || testingWebhook === wh.id}
className="px-3 py-1 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-xs disabled:opacity-50 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
>
{testingWebhook === wh.id ? '测试中...' : '测试'}
</button>
{/* 删除按钮(至少保留 1 个) */}
{config.wechat.webhooks.length > 1 && (
<button onClick={() => removeWebhook(wh.id)} className="p-1 text-slate-400 hover:text-red-500 rounded" title="删除">
<Trash2 size={14} />
</button>
)}
</div>
{/* 展开的高级参数 */}
{expandedWebhooks.has(wh.id) && (
<div className="px-4 pb-4 space-y-4 border-t border-slate-100 dark:border-slate-700/50 pt-3">
<div>
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">Webhook </label>
<input
type="text"
value={wh.url}
onChange={e => updateWebhook(wh.id, { url: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."
/>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-slate-700 dark:text-slate-300"></label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1"></label>
<input
type="number"
value={wh.params.interval_seconds}
onChange={e => updateWebhookParams(wh.id, { interval_seconds: parseInt(e.target.value) || 60 })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
min={10} max={3600}
/>
</div>
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1"></label>
<input
type="number"
value={wh.params.push_delay_ms}
onChange={e => updateWebhookParams(wh.id, { push_delay_ms: parseInt(e.target.value) || 2000 })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
min={0} max={30000}
/>
</div>
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1"></label>
<input
type="number"
value={wh.params.silent_start_hour}
onChange={e => updateWebhookParams(wh.id, { silent_start_hour: parseInt(e.target.value) || 0 })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
min={0} max={23}
/>
</div>
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1"></label>
<input
type="number"
value={wh.params.silent_end_hour}
onChange={e => updateWebhookParams(wh.id, { silent_end_hour: parseInt(e.target.value) || 7 })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
min={0} max={23}
/>
</div>
</div>
</div>
{isDirty(wh.id, wh) && (
<div className="flex justify-end pt-2 border-t border-slate-100 dark:border-slate-700/50">
<button
onClick={() => handleSaveSingle(wh.id)}
disabled={savingId === wh.id}
className="px-3 py-1.5 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 text-xs font-medium"
>
{savingId === wh.id ? '保存中...' : '保存'}
</button>
</div>
)}
</div>
)}
</div>
))}
</div>
</Card>
</div>
)
}

View File

@ -65,8 +65,17 @@ export default function TicketDetailPage() {
async function handleDelete() {
if (!confirm('确定要删除此工单吗?')) return
try {
const res = await fetch(`/api/tickets/${ticket.id}`, { method: 'DELETE' })
if (res.ok) handleBack()
const data = await res.json()
if (res.ok) {
handleBack()
} else {
alert(data.error || '删除失败')
}
} catch (e) {
alert('删除失败:网络错误')
}
}
const isPending = (ticket.current_status === 'open' || ticket.current_status === 'in_progress') && !ticket.close_time
const isAdmin = currentUser?.role === 'admin'

View File

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

View File

@ -1,47 +1,7 @@
'use client'
import { useState } from 'react'
// 登录页 — 引用共享组件
import LoginPage from '@shared/ui/login-page'
export default function LoginPage() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); setError(''); setLoading(true)
try {
const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) })
const data = await res.json()
if (!res.ok) { setError(data.error || '登录失败'); return }
const params = new URLSearchParams(window.location.search)
const redirect = params.get('redirect')
const dest = (redirect && redirect.startsWith('/')) ? redirect : '/dashboard'
window.location.href = dest
} catch { setError('网络错误') } finally { setLoading(false) }
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 px-4">
<div className="w-full max-w-sm bg-white dark:bg-slate-900 rounded-lg border border-blue-200/50 dark:border-blue-500/20 shadow-lg p-8">
<h1 className="text-2xl font-bold text-center mb-6 text-slate-900 dark:text-white">IT </h1>
{error && <div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"></label>
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="请输入用户名"
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"></label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="请输入密码"
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required />
</div>
<button type="submit" disabled={loading}
className="w-full py-2 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium transition-colors duration-200">
{loading ? '登录中...' : '登录'}
</button>
</form>
</div>
</div>
)
export default function Page() {
return <LoginPage siteName="IT工单跟踪系统" redirectPath="/dashboard" />
}

View File

@ -3,6 +3,7 @@ import { cookies } from 'next/headers'
import { getDb } from '@/lib/db'
import { verifyToken } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions'
import { writeAuditLog, getClientIP, diffObjects } from '@/lib/audit'
async function getSession() {
const cookieStore = await cookies()
@ -19,7 +20,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
}
const { id } = await params
const existing = getDb().prepare('SELECT id FROM api_keys WHERE id = ?').get(id)
type ApiKeyRow = { id: number; name: string; permissions: string; expires_at: string | null; is_active: number }
const existing = getDb().prepare<[string | number], ApiKeyRow>('SELECT id, name, permissions, expires_at, is_active FROM api_keys WHERE id = ?').get(id)
if (!existing) return NextResponse.json({ error: 'API Key 不存在' }, { status: 404 })
try {
@ -34,6 +36,20 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
is_active !== undefined ? (is_active ? 1 : 0) : 1,
id
)
// 审计日志
const changes = diffObjects(existing as unknown as Record<string, unknown>, body)
if (Object.keys(changes).length > 0) {
writeAuditLog({
userId: session.id,
action: 'update',
entityType: 'api_key',
entityId: Number(id),
details: { changes },
ipAddress: getClientIP(request),
})
}
return NextResponse.json({ success: true })
} catch (e) {
const msg = e instanceof Error ? e.message : '更新失败'
@ -49,9 +65,22 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{
}
const { id } = await params
const existing = getDb().prepare('SELECT id FROM api_keys WHERE id = ?').get(id)
const existing = getDb().prepare('SELECT id, name FROM api_keys WHERE id = ?').get(id) as { id: number; name: string } | undefined
if (!existing) return NextResponse.json({ error: 'API Key 不存在' }, { status: 404 })
const snapshot = { id: existing.id, name: existing.name }
getDb().prepare('DELETE FROM api_keys WHERE id = ?').run(id)
writeAuditLog({
userId: session.id,
apiKeyId: null,
action: 'delete',
entityType: 'api_key',
entityId: Number(id),
details: { deleted: snapshot },
ipAddress: getClientIP(_request),
})
return NextResponse.json({ success: true })
}

View File

@ -3,6 +3,7 @@ import { cookies } from 'next/headers'
import { getDb } from '@/lib/db'
import { verifyToken, generateApiKey, hashApiKey } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions'
import { writeAuditLog, getClientIP } from '@/lib/audit'
async function getSession() {
const cookieStore = await cookies()
@ -44,6 +45,16 @@ export async function POST(request: Request) {
'INSERT INTO api_keys (name, key_hash, permissions, expires_at, created_by) VALUES (?, ?, ?, ?, ?)'
).run(name, keyHash, perms, expires_at || null, session.id)
writeAuditLog({
userId: session.id,
apiKeyId: null,
action: 'create',
entityType: 'api_key',
entityId: result.lastInsertRowid as number,
details: { created: { name } },
ipAddress: getClientIP(request),
})
return NextResponse.json({ key, id: result.lastInsertRowid }, { status: 201 })
} catch (e) {
const msg = e instanceof Error ? e.message : '创建失败'

View File

@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { getCurrentUser } from '@/lib/auth'
import { initDatabase } from '@/lib/db-schema'
import { writeAuditLog, getClientIP } from '@/lib/audit'
// 获取保留天数配置
export async function GET() {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
const db = getDb()
const row = db.prepare(
"SELECT value FROM settings WHERE key = 'audit_retention_days'"
).get() as { value: string } | undefined
return NextResponse.json({ days: parseInt(row?.value || '180', 10) })
}
// 更新保留天数配置
export async function PUT(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (user.role !== 'admin') {
if (!user.permissions?.includes('*') && !user.permissions?.includes('audit-logs:export')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
}
const db = getDb()
const body = await request.json()
const days = Math.max(30, Math.min(365, parseInt(body.days, 10)))
db.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('audit_retention_days', ?)"
).run(String(days))
writeAuditLog({
userId: user.id,
action: 'update',
entityType: 'audit_log',
details: { retention_days: days },
ipAddress: getClientIP(request)
})
return NextResponse.json({ days })
}
export async function DELETE(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (user.role !== 'admin') {
if (!user.permissions?.includes('*') && !user.permissions?.includes('audit-logs:export')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
}
const db = getDb()
// 从 settings 读取保留天数
const retentionRow = db.prepare(
"SELECT value FROM settings WHERE key = 'audit_retention_days'"
).get() as { value: string } | undefined
const retentionDays = Math.max(30, Math.min(365, parseInt(retentionRow?.value || '180', 10)))
const result = db.prepare(
`DELETE FROM audit_logs WHERE created_at < datetime('now', '-${retentionDays} days', '+8 hours')`
).run()
writeAuditLog({
userId: user.id,
action: 'cleanup',
entityType: 'audit_log',
details: { cleanup: { deleted: result.changes, retention_days: retentionDays } },
ipAddress: getClientIP(request)
})
return NextResponse.json({ deleted: result.changes, retention_days: retentionDays })
}

View File

@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { getCurrentUser } from '@/lib/auth'
import { initDatabase } from '@/lib/db-schema'
import { writeAuditLog, getClientIP } from '@/lib/audit'
export async function GET(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
// 检查权限
if (user.role !== 'admin') {
if (!user.permissions?.includes('*') && !user.permissions?.includes('audit-logs:export')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
}
const db = getDb()
const { searchParams } = new URL(request.url)
const action = searchParams.get('action')
const entityType = searchParams.get('entity_type')
const userId = searchParams.get('user_id')
const dateFrom = searchParams.get('date_from')
const dateTo = searchParams.get('date_to')
let whereClause = 'WHERE 1=1'
const params: unknown[] = []
if (action) {
whereClause += ' AND a.action = ?'
params.push(action)
}
if (entityType) {
whereClause += ' AND a.entity_type = ?'
params.push(entityType)
}
if (userId) {
whereClause += ' AND a.user_id = ?'
params.push(parseInt(userId))
}
if (dateFrom) {
whereClause += ' AND a.created_at >= ?'
params.push(dateFrom)
}
if (dateTo) {
whereClause += ' AND a.created_at <= ?'
params.push(dateTo + ' 23:59:59')
}
const logs = db.prepare(
`SELECT a.*, u.username, ak.name as api_key_name
FROM audit_logs a
LEFT JOIN users u ON a.user_id = u.id
LEFT JOIN api_keys ak ON a.api_key_id = ak.id
${whereClause}
ORDER BY a.created_at DESC`
).all(...params)
// 生成 CSV
const BOM = ''
const headers = ['ID', '时间', '用户', 'API Key', '操作', '对象类型', '对象ID', '详情', 'IP地址']
const rows = logs.map((log: Record<string, unknown>) => [
log.id,
log.created_at,
log.username || '',
log.api_key_name || '',
log.action,
log.entity_type,
log.entity_id || '',
log.details ? String(log.details).replace(/"/g, '""') : '',
log.ip_address || ''
])
const csvContent = BOM + [
headers.join(','),
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
].join('\n')
// 自审计:记录导出操作
writeAuditLog({
userId: user.id,
action: 'export',
entityType: 'audit_log',
details: {
export: {
filter: { entity_type: entityType, action, user_id: userId, date_from: dateFrom, date_to: dateTo },
count: logs.length
}
},
ipAddress: getClientIP(request)
})
const today = new Date()
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
return new NextResponse(csvContent, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="audit-logs-${dateStr}.csv"`
}
})
}

View File

@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { getCurrentUser } from '@/lib/auth'
import { initDatabase } from '@/lib/db-schema'
export async function GET(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
// 检查权限
if (user.role !== 'admin') {
if (!user.permissions?.includes('*') && !user.permissions?.includes('audit-logs:read')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
}
const db = getDb()
const { searchParams } = new URL(request.url)
const page = Math.max(1, parseInt(searchParams.get('page') || '1'))
const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get('pageSize') || '20')))
const action = searchParams.get('action')
const entityType = searchParams.get('entity_type')
const userId = searchParams.get('user_id')
const dateFrom = searchParams.get('date_from')
const dateTo = searchParams.get('date_to')
let whereClause = 'WHERE 1=1'
const params: unknown[] = []
if (action) {
whereClause += ' AND a.action = ?'
params.push(action)
}
if (entityType) {
whereClause += ' AND a.entity_type = ?'
params.push(entityType)
}
if (userId) {
whereClause += ' AND a.user_id = ?'
params.push(parseInt(userId))
}
if (dateFrom) {
whereClause += ' AND a.created_at >= ?'
params.push(dateFrom)
}
if (dateTo) {
whereClause += ' AND a.created_at <= ?'
params.push(dateTo + ' 23:59:59')
}
const countResult = db.prepare(
`SELECT COUNT(*) as total FROM audit_logs a ${whereClause}`
).get(...params) as { total: number }
const logs = db.prepare(
`SELECT a.*, u.username, ak.name as api_key_name
FROM audit_logs a
LEFT JOIN users u ON a.user_id = u.id
LEFT JOIN api_keys ak ON a.api_key_id = ak.id
${whereClause}
ORDER BY a.created_at DESC
LIMIT ? OFFSET ?`
).all(...params, pageSize, (page - 1) * pageSize)
return NextResponse.json({
logs,
total: countResult.total,
page,
pageSize
})
}

View File

@ -0,0 +1,54 @@
// GET /api/auth/callback — OIDC callbackV2使用 shared handleOidcCallback 工厂)
import { NextRequest } from 'next/server'
import { handleOidcCallback } from '@shared/lib/auth/handle-callback'
import { db } from '@/lib/db'
import { writeAuditLog } from '@/lib/audit'
const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
const oidcClientId = process.env.OIDC_CLIENT_ID || 'issue-oidc'
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || ''
const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://issue.tlyq.ai/api/auth/callback'
const jwtSecret = process.env.JWT_SECRET || 'oa-shared-jwt-secret-tlyq-2026'
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
export async function GET(request: NextRequest) {
return handleOidcCallback(request, {
oidc: { autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri },
jwtSecret,
cookieDomain,
getUser: (username) => {
const row = db.prepare(
'SELECT id, role FROM users WHERE username = ? AND is_active = 1'
).get(username) as { id: number; role: string } | undefined
return row ?? null
},
createUser: (username, displayName, email) => {
db.prepare(
"INSERT OR IGNORE INTO users (username, display_name, email, role, is_active, created_at, updated_at) VALUES (?, ?, ?, 'viewer', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
).run(username, displayName, email)
const row = db.prepare(
'SELECT id, role FROM users WHERE username = ? AND is_active = 1'
).get(username) as { id: number; role: string }
return { id: row?.id ?? 0, role: row?.role ?? 'viewer' }
},
updateUser: (username, displayName, email) => {
db.prepare(
"UPDATE users SET display_name = ?, email = ?, updated_at = datetime('now', '+8 hours') WHERE username = ?"
).run(displayName, email, username)
},
onAuditLog: (userId, username, req) => {
writeAuditLog({
userId,
username,
action: 'login',
entityType: 'auth',
details: { method: 'oidc' },
ipAddress: req.headers.get('x-forwarded-for') || '127.0.0.1',
})
},
})
}

View File

@ -0,0 +1,14 @@
// GET /api/auth/login/oidc — OIDC SSO 重定向V2使用 shared handleOidcLogin 工厂)
import { handleOidcLogin } from '@shared/lib/auth/handle-login'
const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
const oidcClientId = process.env.OIDC_CLIENT_ID || 'issue-oidc'
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || ''
const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://issue.tlyq.ai/api/auth/callback'
export async function GET(request: Request) {
const url = new URL(request.url)
const switchUser = url.searchParams.get('switch') === '1'
return handleOidcLogin({ autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri, switchUser })
}

View File

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

View File

@ -1,6 +1,55 @@
import { NextResponse } from 'next/server'
export async function POST() {
const r = NextResponse.json({ success: true })
r.cookies.set('session_issue', '', { maxAge: 0, path: '/' })
return r
// GET + POST /api/auth/logout — 退出登录(清除 cookie + 302 跳转 /login
import { NextRequest, NextResponse } from 'next/server'
import { writeAuditLog, getClientIP } from '@/lib/audit'
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
/** 从 OIDC_REDIRECT_URI 提取 site URL不可信请求头见 LESSONS-LEARNED #51 */
function getSiteUrl(): string {
const redirectUri = process.env.OIDC_REDIRECT_URI || ''
try { const u = new URL(redirectUri); return `${u.protocol}//${u.host}` } catch { /* fallthrough */ }
return process.env.NEXT_PUBLIC_SITE_URL || 'https://issue.tlyq.ai'
}
/** 清除 tlyq_session + session + oidc_id_token + session_issue cookie → 302 跳转 /login */
function logoutResponse(): NextResponse {
const response = NextResponse.redirect(new URL('/login', getSiteUrl()))
response.cookies.set('tlyq_session', '', {
httpOnly: true, secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0,
})
response.cookies.set('session', '', { path: '/', maxAge: 0 })
response.cookies.set('oidc_id_token', '', {
httpOnly: true, secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0,
})
response.cookies.set('session_issue', '', { path: '/', maxAge: 0 })
return response
}
export async function GET() { return logoutResponse() }
export async function POST(request: NextRequest) {
// 从 session cookie 提取用户名用于审计
let username = 'anonymous'
try {
const token =
request.cookies.get('tlyq_session')?.value ||
request.cookies.get('session_issue')?.value
if (token) {
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString())
username = payload.username || 'unknown'
}
} catch { /* 解析失败,使用默认值 */ }
writeAuditLog({
userId: null,
username,
action: 'logout',
entityType: 'auth',
details: { message: '用户登出' },
ipAddress: getClientIP(request),
})
return logoutResponse()
}

View File

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

View File

@ -0,0 +1,2 @@
import { NextResponse } from 'next/server'
export async function GET() { return NextResponse.json({ status: 'OK' }) }

View File

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

View File

@ -0,0 +1,43 @@
// GET /api/internal/users — 供 OA 查询 issue 用户列表
// POST /api/internal/users — 供 OA 同步用户角色
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
const INTERNAL_KEY = process.env.INTERNAL_API_KEY || 'oa-internal-key-tlyq-2026'
export async function GET(request: NextRequest) {
const key = request.headers.get('x-internal-key')
if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const db = getDb()
const users = db.prepare(
'SELECT username, display_name, role FROM users WHERE is_active = 1 ORDER BY username'
).all() as { username: string; display_name: string; role: string }[]
return NextResponse.json({ users })
}
export async function POST(request: NextRequest) {
const key = request.headers.get('x-internal-key')
if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const body = await request.json()
const { username, displayName, role } = body
if (!username) {
return NextResponse.json({ error: 'username 必填' }, { status: 400 })
}
const VALID_ROLES = ['admin', 'editor', 'viewer']
const safeRole = VALID_ROLES.includes(role) ? role : 'viewer'
try {
const db = getDb()
db.prepare(
`INSERT INTO users (username, password_hash, display_name, role, is_active, created_at, updated_at)
VALUES (?, '', ?, ?, 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))
ON CONFLICT(username) DO UPDATE SET role = ?, display_name = ?,
updated_at = datetime('now', '+8 hours')`
).run(username, displayName || username, safeRole, safeRole, displayName || username)
return NextResponse.json({ success: true })
} catch {
return NextResponse.json({ error: '同步失败' }, { status: 500 })
}
}

View File

@ -0,0 +1,132 @@
// src/app/api/monitor/scan-emails/import/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { initDatabase } from '@/lib/db-schema'
import { getDb } from '@/lib/db'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { writeAuditLog, getClientIP } from '@/lib/audit'
import { notifyTicketCreated } from '@/lib/monitor/ticket-notifier'
interface ImportEmail {
msg_id: string
subject: string
date: string
order_number: string | null
status: string
ticket_no?: string
error?: string
}
export async function POST(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:write')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const clientIP = getClientIP(request)
const body = await request.json()
const { emails } = body as { emails: ImportEmail[] }
if (!emails || !Array.isArray(emails) || emails.length === 0) {
return NextResponse.json({ error: '没有可导入的邮件' }, { status: 400 })
}
const db = getDb()
let imported = 0
let skipped = 0
let errors = 0
const results: { order_number: string; status: string; error?: string }[] = []
for (const email of emails) {
if (!email.order_number) {
skipped++
results.push({
order_number: '-',
status: 'skipped',
error: '无工单号',
})
continue
}
const ticketId = parseInt(email.order_number)
// 检查工单是否已存在
const existing = db.prepare('SELECT id FROM tickets WHERE id = ?').get(ticketId)
if (existing) {
skipped++
results.push({
order_number: email.order_number,
status: 'skipped',
error: '工单已存在',
})
continue
}
// 检查是否已处理过
const processed = db.prepare('SELECT msg_id FROM processed_emails WHERE msg_id = ?').get(email.msg_id)
if (processed) {
skipped++
results.push({
order_number: email.order_number,
status: 'skipped',
error: '邮件已处理过',
})
continue
}
try {
// 创建工单
db.prepare(`
INSERT INTO tickets (id, content, current_status, created_by, created_at, updated_at)
VALUES (?, ?, 'open', ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))
`).run(ticketId, `${email.subject}`, user.id)
// 记录已处理邮件
db.prepare("INSERT OR IGNORE INTO processed_emails (msg_id, subject) VALUES (?, ?)").run(email.msg_id, email.subject)
// 审计日志
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'import',
entityType: 'ticket',
entityId: ticketId,
details: { created: { ticket_no: email.order_number, source: 'email_import' } },
ipAddress: clientIP,
})
imported++
results.push({
order_number: email.order_number,
status: 'imported',
})
void notifyTicketCreated({
ticket_no: email.order_number,
device_ip: null,
device_sn: null,
device_name: null,
ticket_type: null,
content: `从邮件导入:${email.subject}`,
assign_time: null,
}).catch(e => console.error('[scan-import] notify 失败:', e))
} catch (err) {
errors++
results.push({
order_number: email.order_number,
status: 'error',
error: err instanceof Error ? err.message : '导入失败',
})
}
}
return NextResponse.json({
success: true,
message: `导入完成:成功 ${imported} 封,跳过 ${skipped} 封,错误 ${errors}`,
imported,
skipped,
errors,
results,
})
}

View File

@ -0,0 +1,648 @@
// src/app/api/monitor/scan-emails/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { initDatabase } from '@/lib/db-schema'
import { getDb } from '@/lib/db'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { getMonitorConfig } from '@/lib/monitor/settings-manager'
import Imap from 'imap'
import { simpleParser } from 'mailparser'
import * as cheerio from 'cheerio'
import { formatBeijingTime } from '@/lib/monitor/types'
import { writeAuditLog, getClientIP } from '@/lib/audit'
import { getScanState, setScanState, isCancelRequested, resetCancelFlag } from '@/lib/monitor/scan-state'
import { fetchPOP3Emails, normalizeMessageId } from '@/lib/monitor/pop3-fetcher'
import { notifyTicketCreated } from '@/lib/monitor/ticket-notifier'
interface ScanResult {
status: 'running' | 'completed' | 'error' | 'cancelling'
startedAt: string
completedAt?: string
timeRange: { value: number | null; unit: string }
stats: {
total: number
matched: number
imported: number
skipped: number
errors: number
}
details: {
msg_id: string
subject: string
date: string
order_number: string | null
status: 'imported' | 'skipped' | 'error'
ticket_no?: string
error?: string
}[]
detailsTruncated: boolean
error?: string
}
const VALID_UNITS = ['minute', 'hour', 'day', 'week', 'month', 'all']
const MAX_VALUE = 365
const MAX_DETAILS = 500
const SCAN_TIMEOUT_MS = 5 * 60 * 1000 // 5 分钟超时
// 辅助函数:添加 detail 并追踪是否被截断
function addDetail(
scanState: ScanResult,
detail: ScanResult['details'][0],
counter: { total: number }
): void {
counter.total++
if (scanState.details.length < MAX_DETAILS) {
scanState.details.push(detail)
} else {
scanState.detailsTruncated = true
}
}
function extractOrderNumber(subject: string): string | null {
const match = subject.match(/【服务器故障单】(\d+),/)
return match?.[1] ?? null
}
function getDateRange(value: number | null, unit: string): Date {
const now = new Date()
if (!value || unit === 'all') {
// 全部:搜索最近 30 天
now.setDate(now.getDate() - 30)
return now
}
switch (unit) {
case 'minute':
now.setMinutes(now.getMinutes() - value)
break
case 'hour':
now.setHours(now.getHours() - value)
break
case 'day':
now.setDate(now.getDate() - value)
break
case 'week':
now.setDate(now.getDate() - value * 7)
break
case 'month':
now.setMonth(now.getMonth() - value)
break
default:
now.setDate(now.getDate() - 7)
}
return now
}
export async function POST(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:write')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const clientIP = getClientIP(request)
// 检查是否有正在运行的扫描(包括取消中的扫描)
const currentScan = getScanState()
if (currentScan?.status === 'running' || currentScan?.status === 'cancelling') {
return NextResponse.json({ error: '扫描正在进行中,请稍后再试' }, { status: 409 })
}
const body = await request.json()
const { value, unit } = body.timeRange || { value: 7, unit: 'day' }
// 输入验证
if (unit !== 'all') {
if (typeof value !== 'number' || value < 1 || value > MAX_VALUE) {
return NextResponse.json({ error: `时间范围值必须在 1-${MAX_VALUE} 之间` }, { status: 400 })
}
}
if (!VALID_UNITS.includes(unit)) {
return NextResponse.json({ error: `无效的时间单位: ${unit}` }, { status: 400 })
}
const config = getMonitorConfig()
const db = getDb()
const since = getDateRange(value, unit)
// 初始化扫描结果
const scanState: ScanResult = {
status: 'running',
startedAt: formatBeijingTime(),
timeRange: { value, unit },
stats: { total: 0, matched: 0, imported: 0, skipped: 0, errors: 0 },
details: [],
detailsTruncated: false,
}
setScanState(scanState)
// 重置取消标志
resetCancelFlag()
// 异步执行扫描
scanEmails(config, db, since, scanState, clientIP, user.id).catch(err => {
// 内层已设置错误状态,此处不再重复设置
console.error('[Scan] Unexpected error:', err)
})
return NextResponse.json({ success: true, message: '扫描已开始' })
}
async function scanEmails(
config: ReturnType<typeof getMonitorConfig>,
db: ReturnType<typeof getDb>,
since: Date,
scanState: ScanResult,
clientIP: string | null,
userId: number
): Promise<void> {
// 使用 imap 库(与 Python 版本兼容)替代 imapflow
// imapflow 对单部分 text/html 邮件的 source 获取有 bug
const imap = new Imap({
user: config.mail.address,
password: config.mail.password,
host: config.mail.imap_server,
port: config.mail.imap_port,
tls: true,
connTimeout: 15000,
authTimeout: 15000,
})
// 整体超时控制
const startTime = Date.now()
const checkTimeout = (): boolean => {
if (Date.now() - startTime > SCAN_TIMEOUT_MS) {
scanState.status = 'error'
scanState.completedAt = formatBeijingTime()
scanState.error = `扫描超时(超过 ${SCAN_TIMEOUT_MS / 60000} 分钟)`
return true
}
return false
}
// 将 IMAP 回调风格封装为 Promise
function imapConnect(): Promise<void> {
return new Promise((resolve, reject) => {
imap.once('ready', () => resolve())
imap.once('error', (err: Error) => reject(err))
imap.connect()
})
}
function imapOpenBox(boxName: string, readOnly: boolean): Promise<Imap.Box> {
return new Promise((resolve, reject) => {
imap.openBox(boxName, readOnly, (err: Error | null, box: Imap.Box) => {
if (err) reject(err)
else resolve(box)
})
})
}
function imapSearch(criteria: any[]): Promise<number[]> {
return new Promise((resolve, reject) => {
// imap.search 返回 UIDimap.fetch 默认也使用 UID
imap.search(criteria, (err: Error | null, results: number[]) => {
if (err) reject(err)
else resolve(results)
})
})
}
// 获取单封邮件的原始内容(使用 UID 获取)
function fetchEmailRaw(uid: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
console.log(`[Scan] fetchEmailRaw(uid=${uid}) 开始获取`)
// imap.fetch 默认使用 UID无需 uid: true
const fetch = imap.fetch(uid, { bodies: '', markSeen: false })
let emailBuffer = Buffer.alloc(0)
let messageCount = 0
let bodyCount = 0
fetch.on('message', (msg: any, seqno: number) => {
messageCount++
console.log(`[Scan] fetchEmailRaw(uid=${uid}) message 事件触发, seqno=${seqno}`)
msg.on('body', (stream: any, info: any) => {
bodyCount++
console.log(`[Scan] fetchEmailRaw(uid=${uid}) body 事件触发, info=${JSON.stringify(info)}`)
const chunks: Buffer[] = []
stream.on('data', (chunk: Buffer) => {
chunks.push(chunk)
console.log(`[Scan] fetchEmailRaw(uid=${uid}) 收到数据块, 大小=${chunk.length}`)
})
stream.on('end', () => {
emailBuffer = Buffer.concat(chunks)
console.log(`[Scan] fetchEmailRaw(uid=${uid}) body 结束, 总大小=${emailBuffer.length}`)
})
})
})
fetch.once('end', () => {
console.log(`[Scan] fetchEmailRaw(uid=${uid}) fetch 结束, messageCount=${messageCount}, bodyCount=${bodyCount}, 最终大小=${emailBuffer.length}`)
resolve(emailBuffer)
})
fetch.once('error', (err: Error) => {
console.error(`[Scan] fetchEmailRaw(uid=${uid}) fetch 错误:`, err.message)
reject(err)
})
})
}
// 通过 IMAP ENVELOPE 获取邮件的 Message-IDBODY 为空时 ENVELOPE 仍可用)
// 用于与 POP3 邮件精确匹配,避免脆弱的位置对齐
// 注意:只请求 envelope不能带 bodiesBODY 为空时 attributes 事件不会触发)
function fetchEmailMessageId(uid: number): Promise<string | null> {
return new Promise((resolve) => {
let messageId: string | null = null
try {
const fetch = imap.fetch(uid, { envelope: true } as any)
fetch.on('message', (msg: any) => {
msg.on('attributes', (attrs: any) => {
messageId = attrs?.envelope?.messageId || null
})
})
fetch.once('end', () => resolve(normalizeMessageId(messageId)))
fetch.once('error', () => resolve(null))
} catch {
resolve(null)
}
})
}
const detailsCounter = { total: 0 }
try {
await imapConnect()
console.log('[Scan] IMAP 连接成功')
await imapOpenBox('INBOX', true)
// 搜索邮件:将 since 日期转换为 IMAP SINCE 格式
const sinceStr = since.toLocaleDateString('en-US', { day: '2-digit', month: 'short', year: 'numeric' })
const uids = await imapSearch([['SINCE', sinceStr]])
console.log(`[Scan] 搜索条件 SINCE "${sinceStr}",找到 ${uids.length} 封邮件`)
if (!uids || uids.length === 0) {
scanState.status = 'completed'
scanState.completedAt = formatBeijingTime()
imap.end()
saveScanHistory(scanState, userId)
return
}
scanState.stats.total = uids.length
// POP3 fallback 状态IMAP 近期邮件 BODY 为空时自动切换
// pop3Map: Message-ID → 原始邮件 Bufferpop3Attempted: 是否已尝试(无论成败,避免重连风暴)
let pop3Map: Map<string, Buffer> = new Map()
let pop3Attempted = false
// 逐封处理邮件
for (let idx = 0; idx < uids.length; idx++) {
const uid = uids[idx]
if (isCancelRequested()) {
scanState.status = 'completed'
scanState.completedAt = formatBeijingTime()
scanState.error = '用户取消'
break
}
if (checkTimeout()) break
try {
// 获取完整邮件原始内容RFC822 格式),优先 IMAP失败时 POP3 fallback
let rawEmail: Buffer | null = await fetchEmailRaw(uid)
// IMAP BODY 为空时,首次触发 POP3 fallback一次性拉取所有邮件建立 Message-ID 索引)
if ((!rawEmail || rawEmail.length === 0) && !pop3Attempted) {
console.log(`[Scan] IMAP 返回空 (uid=${uid}),启用 POP3 fallback`)
pop3Attempted = true // 无论成败都置位,避免每封空邮件都重连
try {
pop3Map = await fetchPOP3Emails(config.mail.address, config.mail.password, uids.length, config.mail.pop3_server, config.mail.pop3_port)
} catch (pop3Err) {
console.error('[Scan] POP3 fallback 失败:', pop3Err instanceof Error ? pop3Err.message : pop3Err)
}
}
// 从 POP3 缓存中按 Message-ID 精确匹配(通过 IMAP ENVELOPE 获取当前 UID 的 Message-ID
if ((!rawEmail || rawEmail.length === 0) && pop3Map.size > 0) {
const targetMsgId = await fetchEmailMessageId(uid)
if (targetMsgId && pop3Map.has(targetMsgId)) {
rawEmail = pop3Map.get(targetMsgId)!
console.log(`[Scan] POP3 fallback 命中: uid=${uid}, msgId=${targetMsgId}, size=${rawEmail.length}`)
} else {
console.log(`[Scan] POP3 fallback 未命中: uid=${uid}, msgId=${targetMsgId || '(未获取到)'}`)
}
}
if (!rawEmail || rawEmail.length === 0) {
console.log(`[Scan] 邮件 ${uid} 内容为空IMAP + POP3 均失败),跳过`)
continue
}
// 使用 mailparser 解析邮件
const parsed = await simpleParser(rawEmail)
const subject = parsed.subject || ''
const from = parsed.from?.text || ''
const date = parsed.date ? formatBeijingTime(parsed.date) : formatBeijingTime()
const msgId = parsed.messageId || String(uid)
console.log(`[Scan] 邮件 ${uid}: subject="${subject}", from="${from}", 大小=${rawEmail.length}`)
// 邮件过滤:检查发件人是否匹配
if (config.filter.sender_email) {
const senderMatch = from.toLowerCase().includes(config.filter.sender_email.toLowerCase())
if (!senderMatch) {
console.log(`[Scan] 邮件 ${uid}: 发件人不匹配,跳过`)
continue
}
}
// 邮件过滤:检查主题关键词是否匹配
if (config.filter.subject_keywords && config.filter.subject_keywords.length > 0) {
const keywordMatch = config.filter.subject_keywords.some(kw =>
subject.toLowerCase().includes(kw.toLowerCase())
)
if (!keywordMatch) {
console.log(`[Scan] 邮件 ${uid}: 主题关键词不匹配,跳过`)
continue
}
}
// 提取工单号
const orderNumber = extractOrderNumber(subject)
if (!orderNumber) {
scanState.stats.matched++
addDetail(scanState, {
msg_id: msgId, subject, date,
order_number: null, status: 'skipped', error: '无法提取工单号',
}, detailsCounter)
continue
}
// 检查工单是否已存在
const existing = db.prepare('SELECT id FROM tickets WHERE id = ?').get(parseInt(orderNumber))
if (existing) {
scanState.stats.skipped++
scanState.stats.matched++
addDetail(scanState, {
msg_id: msgId, subject, date,
order_number: orderNumber, status: 'skipped', ticket_no: orderNumber, error: '工单已存在',
}, detailsCounter)
continue
}
// 检查是否已处理过
const processed = db.prepare('SELECT msg_id FROM processed_emails WHERE msg_id = ?').get(msgId)
if (processed) {
scanState.stats.skipped++
scanState.stats.matched++
addDetail(scanState, {
msg_id: msgId, subject, date,
order_number: orderNumber, status: 'skipped', error: '邮件已处理过',
}, detailsCounter)
continue
}
// 从邮件 HTML 正文中提取故障信息(参考 Python 脚本的 extract_fault_info
const htmlContent = parsed.html || ''
const ticketInfo = buildTicketContent(subject, orderNumber, htmlContent)
// 根据设备 IP 查询 assets 站点获取节点名称
if (ticketInfo.device_ip) {
try {
const assetsUrl = process.env.ASSETS_URL || 'https://assets.tlyq.ai'
const assetsApiKey = process.env.ASSETS_API_KEY || ''
if (!assetsApiKey) {
console.log(`[Scan] 跳过设备名称查询: ASSETS_API_KEY 未配置`)
} else {
const headers: Record<string, string> = { 'Authorization': `Bearer ${assetsApiKey}` }
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
try {
const assetsResp = await fetch(`${assetsUrl}/api/assets?filter_business_ip=${encodeURIComponent(ticketInfo.device_ip)}`, { headers, signal: controller.signal })
clearTimeout(timeout)
if (assetsResp.ok) {
const assetsData = await assetsResp.json()
if (assetsData.data && assetsData.data.length > 0) {
ticketInfo.device_name = assetsData.data[0].node_name || null
console.log(`[Scan] 设备 IP ${ticketInfo.device_ip} → 节点名称: ${ticketInfo.device_name}`)
}
} else {
console.error(`[Scan] assets API 返回 ${assetsResp.status} (IP: ${ticketInfo.device_ip})`)
}
} catch (fetchErr) {
clearTimeout(timeout)
throw fetchErr
}
}
} catch (e) {
console.error(`[Scan] 查询设备名称失败 (IP: ${ticketInfo.device_ip}): ${e instanceof Error ? e.message : e}`)
}
}
// 创建工单,填入提取的字段
const ticketId = parseInt(orderNumber)
db.prepare(`
INSERT INTO tickets (id, content, device_ip, device_sn, device_name, ticket_type, assign_time, current_status, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))
`).run(ticketId, ticketInfo.content, ticketInfo.device_ip, ticketInfo.device_sn, ticketInfo.device_name, ticketInfo.ticket_type, ticketInfo.assign_time, userId)
db.prepare("INSERT OR IGNORE INTO processed_emails (msg_id, subject) VALUES (?, ?)").run(msgId, subject)
writeAuditLog({
userId, apiKeyId: null, action: 'import', entityType: 'ticket', entityId: ticketId,
details: { created: { ticket_no: orderNumber, source: 'email_scan' } },
ipAddress: clientIP,
})
void notifyTicketCreated({
ticket_no: orderNumber,
device_ip: ticketInfo.device_ip,
device_sn: ticketInfo.device_sn,
device_name: ticketInfo.device_name,
ticket_type: ticketInfo.ticket_type,
content: ticketInfo.content,
assign_time: ticketInfo.assign_time,
}).catch(e => console.error('[scan-emails] notify 失败:', e))
scanState.stats.imported++
scanState.stats.matched++
addDetail(scanState, {
msg_id: msgId, subject, date,
order_number: orderNumber, status: 'imported', ticket_no: orderNumber,
}, detailsCounter)
} catch (err) {
scanState.stats.errors++
addDetail(scanState, {
msg_id: String(uid), subject: '', date: '',
order_number: null, status: 'error',
error: err instanceof Error ? err.message : '处理失败',
}, detailsCounter)
}
}
if (scanState.detailsTruncated) {
console.log(`[Scan] 详情列表已截断: 共 ${detailsCounter.total} 条,仅保存前 ${MAX_DETAILS}`)
}
if (scanState.status === 'running') {
scanState.status = 'completed'
scanState.completedAt = formatBeijingTime()
}
} catch (err) {
scanState.status = 'error'
scanState.completedAt = formatBeijingTime()
scanState.error = err instanceof Error ? err.message : '连接失败'
} finally {
try { imap.end() } catch {}
}
saveScanHistory(scanState, userId)
}
// 提取的邮件信息结构
interface ExtractedTicketInfo {
content: string
device_ip: string | null
device_sn: string | null
device_name: string | null
ticket_type: string | null
fault_time: string | null
fault_info: string | null
auth_note: string | null
assign_time: string | null
}
// 从邮件 HTML 正文中提取故障信息,参考 Python 脚本的 extract_fault_info
function buildTicketContent(subject: string, orderNumber: string, htmlContent: string): ExtractedTicketInfo {
const result: ExtractedTicketInfo = {
content: '',
device_ip: null,
device_sn: null,
device_name: null,
ticket_type: null,
fault_time: null,
fault_info: null,
auth_note: null,
assign_time: null,
}
// 提取工单类型
if (subject.includes('OEM诊断')) {
result.ticket_type = 'OEM诊断'
} else if (subject.includes('OEM维修')) {
result.ticket_type = 'OEM维修'
}
// 使用 cheerio 解析 HTML 表格
if (htmlContent) {
const $ = cheerio.load(htmlContent)
// 先定位包含"故障信息"的目标表格,避免混入无关表格
let targetTable = $('table').filter((_, table) => {
return $(table).text().includes('故障信息')
}).first()
// 如果没找到包含"故障信息"的表格,使用第一个表格
if (targetTable.length === 0) {
targetTable = $('table').first()
}
// 遍历目标表格的行,提取键值对
targetTable.find('tr').each((_, row) => {
const cells = $(row).find('td')
if (cells.length >= 2) {
const key = $(cells[0]).text().trim()
const value = $(cells[1]).text().trim()
switch (key) {
case '故障发生时间:':
case '故障发生时间':
result.fault_time = value
result.assign_time = value
break
case '服务器SN':
case '服务器SN':
result.device_sn = value
break
case '服务器IP地址':
case '服务器IP地址':
case '服务器IP':
case '服务器IP':
result.device_ip = value
break
case '故障信息:':
case '故障信息':
result.fault_info = value
break
case '授权说明:':
case '授权说明':
result.auth_note = value
break
}
}
})
// Fallback如果表格提取失败使用正则从纯文本中提取
if (!result.fault_info) {
const textContent = $.text().replace(/\s+/g, ' ')
const faultMatch = textContent.match(/故障信息[:\s]*(.+?)(?=授权说明|$)/i)
if (faultMatch) {
result.fault_info = faultMatch[1].trim()
}
}
if (!result.device_ip) {
const textContent = $.text().replace(/\s+/g, ' ')
const ipMatch = textContent.match(/(?:服务器IP地址|服务器IP|IP)[:\s]*(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/)
if (ipMatch) {
result.device_ip = ipMatch[1]
}
}
if (!result.device_sn) {
const textContent = $.text().replace(/\s+/g, ' ')
const snMatch = textContent.match(/服务器SN[:\s]*([A-Za-z0-9]+)/)
if (snMatch) {
result.device_sn = snMatch[1]
}
}
}
// content 只包含故障信息
result.content = result.fault_info || null
return result
}
// 保存扫描历史到数据库
function saveScanHistory(scanState: ScanResult, userId: number): void {
try {
const db = getDb()
db.prepare(`
INSERT INTO scan_history (
status, started_at, completed_at,
time_range_value, time_range_unit,
total_count, matched_count, imported_count, skipped_count, error_count,
details_truncated, error_message, details_json, created_by
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
scanState.status,
scanState.startedAt,
scanState.completedAt || null,
scanState.timeRange.value,
scanState.timeRange.unit,
scanState.stats.total,
scanState.stats.matched,
scanState.stats.imported,
scanState.stats.skipped,
scanState.stats.errors,
scanState.detailsTruncated ? 1 : 0,
scanState.error || null,
JSON.stringify(scanState.details),
userId
)
// 保留最近 50 条扫描历史,删除更早的(用 id 单调排序,避免 created_at 同秒重复)
db.prepare(`DELETE FROM scan_history WHERE id NOT IN (
SELECT id FROM scan_history ORDER BY id DESC LIMIT 50
)`).run()
console.log(`[Scan] 扫描历史已保存: 状态=${scanState.status}, 导入=${scanState.stats.imported}`)
} catch (err) {
console.error('[Scan] 保存扫描历史失败:', err)
}
}

View File

@ -0,0 +1,95 @@
// src/app/api/monitor/scan-history/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { initDatabase } from '@/lib/db-schema'
import { getDb } from '@/lib/db'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { writeAuditLog, getClientIP } from '@/lib/audit'
interface ScanHistoryItem {
id: number
status: string
started_at: string
completed_at: string | null
time_range_value: number | null
time_range_unit: string | null
total_count: number
matched_count: number
imported_count: number
skipped_count: number
error_count: number
details_truncated: number
error_message: string | null
details_json: string | null
created_by: number | null
created_at: string
}
export async function GET(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:read')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const offset = (page - 1) * limit
const db = getDb()
// 获取总数
const countResult = db.prepare('SELECT COUNT(*) as total FROM scan_history').get() as { total: number }
const total = countResult.total
// 获取列表
const items = db.prepare(`
SELECT * FROM scan_history
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`).all(limit, offset) as ScanHistoryItem[]
// 解析 details_json
const itemsWithDetails = items.map(item => ({
...item,
details: item.details_json ? JSON.parse(item.details_json) : [],
details_json: undefined,
}))
return NextResponse.json({
items: itemsWithDetails,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
})
}
// DELETE 清理旧历史(保留最近 N 天)
export async function DELETE(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:write')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const body = await request.json().catch(() => ({}))
const keepDays = body.keepDays || 30
const db = getDb()
const result = db.prepare(`
DELETE FROM scan_history
WHERE created_at < datetime('now', '+8 hours', ? || ' days')
`).run(-keepDays)
writeAuditLog({ userId: user.id, apiKeyId: null, action: 'delete', entityType: 'scan_history', details: { keepDays, deleted: result.changes }, ipAddress: getClientIP(request) })
return NextResponse.json({
success: true,
deleted: result.changes,
message: `已清理 ${keepDays} 天前的扫描历史`,
})
}

View File

@ -0,0 +1,49 @@
// src/app/api/monitor/scan-status/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { getClientIP, writeAuditLog } from '@/lib/audit'
import { getScanState, requestCancel } from '@/lib/monitor/scan-state'
export async function GET(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:read')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const state = getScanState()
return NextResponse.json(state || { status: 'idle' })
}
// DELETE 请求用于取消扫描
export async function DELETE(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:write')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const state = getScanState()
if (state?.status !== 'running') {
return NextResponse.json({ error: '没有正在运行的扫描任务' }, { status: 400 })
}
const clientIP = getClientIP(request)
requestCancel()
state.status = 'cancelling'
// 审计日志
writeAuditLog({
userId: user.id || null,
apiKeyId: null,
action: 'cancel',
entityType: 'scan',
entityId: null,
details: { scan: { requestedBy: user.username } },
ipAddress: clientIP,
})
return NextResponse.json({ success: true, message: '取消请求已发送,扫描将在处理完当前邮件后停止' })
}

View File

@ -0,0 +1,93 @@
// src/app/api/monitor/settings/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { getMaskedConfig, updateMonitorConfig } from '@/lib/monitor/settings-manager'
import { writeAuditLog, getClientIP } from '@/lib/audit'
import net from 'net'
// 拒绝内网/回环/链路本地/CGNAT 的 IPv4 字面量SSRF 纵深防御,防收信指向内网主机)
function isPrivateIPv4(host: string): boolean {
if (!net.isIPv4(host)) return false
const p = host.split('.').map(Number)
if (p[0] === 0 || p[0] === 10 || p[0] === 127) return true // 0/8, 10/8, 回环 127/8
if (p[0] === 169 && p[1] === 254) return true // 链路本地 169.254/16
if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true // 172.16/12
if (p[0] === 192 && p[1] === 168) return true // 192.168/16
if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return true // CGNAT 100.64/10
return false
}
// 拒绝非规范数字型 IP 写法(整数/十六进制/八进制/短式),防止绕过内网黑名单:
// net.isIPv4 只认标准点分十进制,但 socket 层会把 2130706433 / 0x7f000001 / 127.1 / 0177.0.0.1
// 等写法同样解析为回环/内网地址,必须在校验层拦截
function isNonCanonicalNumericHost(host: string): boolean {
if (/(^|\.)0x/i.test(host)) return true // 十六进制0x7f000001 / 0x7f.0.0.1
const segs = host.split('.')
if (segs.every((s) => /^\d+$/.test(s)) && !net.isIPv4(host)) return true // 整数型/短式/八进制数字 IP含前导零
return false
}
// 主机名格式校验(纵深防御):只允许合法主机名/IP 字符IPv6 字面量含 ':' 天然被拒),
// 拒绝 URL/路径/空格、非规范数字型 IP 及内网 IPv4收窄 SSRF 面
const isValidHost = (v: unknown): boolean => {
if (typeof v !== 'string' || v.length === 0 || v.length > 253) return false
if (!/^[a-zA-Z0-9.-]+$/.test(v)) return false
if (isNonCanonicalNumericHost(v)) return false
if (isPrivateIPv4(v)) return false
return true
}
const VALIDATORS: Record<string, (v: unknown) => boolean> = {
'monitor.enabled': (v) => typeof v === 'boolean',
'monitor.interval_seconds': (v) => typeof v === 'number' && v >= 10 && v <= 3600,
'monitor.push_delay_ms': (v) => typeof v === 'number' && v >= 0 && v <= 10000,
'monitor.silent_start_hour': (v) => typeof v === 'number' && v >= 0 && v <= 23,
'monitor.silent_end_hour': (v) => typeof v === 'number' && v >= 0 && v <= 23,
'mail.address': (v) => typeof v === 'string' && v.includes('@'),
'mail.imap_server': isValidHost,
'mail.imap_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
'mail.pop3_server': isValidHost,
'mail.pop3_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
'mail.smtp_server': isValidHost,
'mail.smtp_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
'wechat.webhooks': (v) => Array.isArray(v) && v.length > 0,
}
export async function GET(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:read')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
return NextResponse.json(getMaskedConfig())
}
export async function PUT(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const body = await request.json()
// 保留未修改的密码
if (body.mail?.password === '••••••••' || body.mail?.password === undefined) {
delete body.mail?.password
}
// 先展平嵌套对象
const flat: Record<string, unknown> = {}
for (const [section, values] of Object.entries(body)) {
if (typeof values === 'object' && values !== null) {
for (const [key, value] of Object.entries(values as Record<string, unknown>)) {
flat[`${section}.${key}`] = value
}
} else {
flat[section] = values
}
}
// 展平后验证
for (const [key, value] of Object.entries(flat)) {
const validator = VALIDATORS[key]
if (validator && !validator(value)) return NextResponse.json({ error: `参数 ${key} 验证失败` }, { status: 400 })
}
updateMonitorConfig(flat)
writeAuditLog({ userId: user.id, action: 'update', entityType: 'monitor_config', details: { keys: Object.keys(flat) }, ipAddress: getClientIP(request) })
return NextResponse.json({ success: true })
}

View File

@ -0,0 +1,16 @@
// src/app/api/monitor/start/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { setSetting } from '@/lib/monitor/settings-manager'
import { writeAuditLog, getClientIP } from '@/lib/audit'
export async function POST(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
setSetting('monitor.enabled', 'true', 'monitor')
writeAuditLog({ userId: user.id, action: 'enable', entityType: 'monitor', details: { message: '启用邮件监控' }, ipAddress: getClientIP(request) })
return NextResponse.json({ success: true, message: '监控已启用Worker 将在下一个 tick 启动' })
}

View File

@ -0,0 +1,99 @@
// src/app/api/monitor/status/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { getSetting } from '@/lib/monitor/settings-manager'
export async function GET(request: NextRequest) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:read')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const db = getDb()
const enabled = getSetting('monitor.enabled') === 'true'
const lastTick = db.prepare("SELECT created_at, details FROM monitor_logs WHERE action = 'tick_complete' ORDER BY id DESC LIMIT 1").get() as { created_at: string; details: string } | undefined
// 使用 UTC+8 日期(数据库中 created_at 是 UTC+8
const now = new Date()
const todayStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} 00:00:00`
const todayProcessed = (db.prepare("SELECT COUNT(*) as c FROM monitor_logs WHERE action = 'ticket_created' AND created_at >= ?").get(todayStart) as { c: number }).c
const totalProcessed = (db.prepare("SELECT COUNT(*) as c FROM monitor_logs WHERE action = 'ticket_created'").get() as { c: number }).c
// 最近一次 tick 的错误状态action='error' → 1tick_complete → 该 tick 的 errors 数;无记录 → 0
const lastLog = db.prepare(
"SELECT action, details FROM monitor_logs WHERE action IN ('error', 'tick_complete') ORDER BY id DESC LIMIT 1"
).get() as { action: string; details: string | null } | undefined
let errorCount = 0
if (lastLog) {
if (lastLog.action === 'error') {
errorCount = 1
} else {
try { errorCount = JSON.parse(lastLog.details || '{}').errors || 0 } catch { errorCount = 0 }
}
}
const ongoingFaultsCount = (db.prepare("SELECT COUNT(*) as c FROM fault_records WHERE status = 'ongoing'").get() as { c: number }).c
// 获取最近的活动tick_complete 或 error
const lastActivity = db.prepare("SELECT created_at, action FROM monitor_logs WHERE action IN ('tick_complete', 'error') ORDER BY id DESC LIMIT 1").get() as { created_at: string; action: string } | undefined
let workerHealth = 'unknown'
if (enabled && lastActivity) {
const elapsed = Date.now() - new Date(lastActivity.created_at.replace(' ', 'T') + '+08:00').getTime()
if (elapsed < 180_000) {
workerHealth = lastActivity.action === 'error' ? 'error' : 'healthy'
} else {
workerHealth = 'unresponsive'
}
} else if (!enabled) {
workerHealth = 'stopped'
}
const lastError = db.prepare("SELECT details, created_at FROM monitor_logs WHERE action = 'error' ORDER BY id DESC LIMIT 1").get() as { details: string; created_at: string } | undefined
// 今日处理详情:从 monitor_logs 获取今天的工单创建记录
const todayProcessedDetails = db.prepare(
"SELECT details, created_at FROM monitor_logs WHERE action = 'ticket_created' AND created_at >= ? ORDER BY id DESC LIMIT 20"
).all(todayStart) as { details: string; created_at: string }[]
// 进行中故障详情
const ongoingFaultsDetails = db.prepare(
"SELECT * FROM fault_records WHERE status = 'ongoing' ORDER BY fault_time DESC LIMIT 20"
).all() as Record<string, unknown>[]
// 最近错误列表
const recentErrors = db.prepare(
"SELECT details, created_at FROM monitor_logs WHERE action = 'error' ORDER BY id DESC LIMIT 10"
).all() as { details: string; created_at: string }[]
return NextResponse.json({
enabled,
status: enabled ? 'running' : 'stopped',
workerHealth,
lastCheck: lastTick?.created_at || null,
stats: {
totalProcessed,
todayProcessed,
errors: errorCount,
lastError: lastError?.details || null,
lastErrorTime: lastError?.created_at || null,
},
ongoingFaults: ongoingFaultsCount,
details: {
todayProcessed: todayProcessedDetails.map(d => {
try { return { ...JSON.parse(d.details), created_at: d.created_at } }
catch { return { details: d.details, created_at: d.created_at } }
}),
ongoingFaults: ongoingFaultsDetails,
recentErrors: recentErrors.map(d => {
try { return { ...JSON.parse(d.details), created_at: d.created_at } }
catch { return { details: d.details, created_at: d.created_at } }
}),
},
})
} catch (e) {
return NextResponse.json({ error: e instanceof Error ? e.message : 'Internal error' }, { status: 500 })
}
}

View File

@ -0,0 +1,16 @@
// src/app/api/monitor/stop/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { setSetting } from '@/lib/monitor/settings-manager'
import { writeAuditLog, getClientIP } from '@/lib/audit'
export async function POST(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
setSetting('monitor.enabled', 'false', 'monitor')
writeAuditLog({ userId: user.id, action: 'disable', entityType: 'monitor', details: { message: '停用邮件监控' }, ipAddress: getClientIP(request) })
return NextResponse.json({ success: true, message: '监控已停用' })
}

View File

@ -0,0 +1,33 @@
// src/app/api/monitor/test-mail/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { getMonitorConfig } from '@/lib/monitor/settings-manager'
import { writeAuditLog, getClientIP } from '@/lib/audit'
import { ImapFlow } from 'imapflow'
export async function POST(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const config = getMonitorConfig()
const client = new ImapFlow({
host: config.mail.imap_server, port: config.mail.imap_port, secure: true,
auth: { user: config.mail.address, pass: config.mail.password },
logger: false, connectionTimeout: 10_000,
})
try {
await client.connect()
const lock = await client.getMailboxLock('INBOX')
lock.release()
await client.logout()
writeAuditLog({ userId: user.id, apiKeyId: null, action: 'test', entityType: 'monitor_mail', details: { target: 'imap', success: true, server: config?.mail?.imap_server || 'unknown' }, ipAddress: getClientIP(request) })
return NextResponse.json({ success: true, message: 'IMAP 连接成功INBOX 选择正常' })
} catch (e) {
writeAuditLog({ userId: user.id, apiKeyId: null, action: 'test', entityType: 'monitor_mail', details: { target: 'imap', success: false, server: config?.mail?.imap_server || 'unknown' }, ipAddress: getClientIP(request) })
return NextResponse.json({ success: false, error: e instanceof Error ? e.message : '连接失败' })
} finally {
try { await client.logout() } catch {}
}
}

View File

@ -0,0 +1,59 @@
// src/app/api/monitor/test-wechat/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { getMonitorConfig } from '@/lib/monitor/settings-manager'
import { writeAuditLog, getClientIP } from '@/lib/audit'
export async function POST(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
let webhookUrl: string | null = null
try {
const body = await request.json()
webhookUrl = body?.webhook_url || null
} catch { /* 无 body */ }
// 如果 URL 被 mask含 ••••••••),从 DB 查真实 URL
if (webhookUrl && webhookUrl.includes('••••••••')) {
const config = getMonitorConfig()
const found = config.wechat.webhooks.find(wh => {
if (!wh.url) return false
// 对比非 key 部分是否匹配(前缀 + 后缀),找到对应的真实 webhook
const maskedPattern = wh.url.replace(/key=([0-9a-f-]+)/, 'key=••••••••')
return maskedPattern === webhookUrl
})
if (found) webhookUrl = found.url
else webhookUrl = null // 找不到匹配的,走 fallback
}
// 如果指定了 URL使用指定的否则使用第一个启用的
if (!webhookUrl) {
const config = getMonitorConfig()
const enabled = config.wechat.webhooks.filter(wh => wh.enabled && wh.url)
if (enabled.length === 0) return NextResponse.json({ success: false, error: 'Webhook URL 未配置' })
webhookUrl = enabled[0].url
}
try {
const response = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ msgtype: 'text', text: { content: '✅ issue-ai 邮件监控测试消息' } }),
signal: AbortSignal.timeout(5_000),
})
const result = await response.json() as { errcode?: number; errmsg?: string }
if (result.errcode === 0) {
writeAuditLog({ userId: user.id, action: 'test', entityType: 'monitor_wechat', details: { message: '测试微信推送成功' }, ipAddress: getClientIP(request) })
return NextResponse.json({ success: true, message: '测试消息发送成功' })
}
writeAuditLog({ userId: user.id, action: 'test', entityType: 'monitor_wechat', details: { message: '测试微信推送失败', error: result.errmsg || '发送失败' }, ipAddress: getClientIP(request) })
return NextResponse.json({ success: false, error: result.errmsg || '发送失败' })
} catch (e) {
writeAuditLog({ userId: user.id, action: 'test', entityType: 'monitor_wechat', details: { message: '测试微信推送异常', error: e instanceof Error ? e.message : '发送失败' }, ipAddress: getClientIP(request) })
return NextResponse.json({ success: false, error: e instanceof Error ? e.message : '发送失败' })
}
}

View File

@ -0,0 +1,22 @@
// src/app/api/monitor/trigger/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { setSetting } from '@/lib/monitor/settings-manager'
import { writeAuditLog, getClientIP } from '@/lib/audit'
let lastTriggerTime = 0
const TRIGGER_COOLDOWN = 30_000
export async function POST(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
if (Date.now() - lastTriggerTime < TRIGGER_COOLDOWN) return NextResponse.json({ error: '请等待 30 秒后再试' }, { status: 429 })
lastTriggerTime = Date.now()
// 写入触发标记Worker 在下一个 tick 检测到后立即执行
setSetting('monitor.trigger_requested', 'true', 'monitor')
writeAuditLog({ userId: user.id, action: 'trigger', entityType: 'monitor', details: { message: '手动触发邮件检查' }, ipAddress: getClientIP(request) })
return NextResponse.json({ success: true, message: '已触发Worker 将在 1-2 秒内执行' })
}

View File

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

View File

@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { generateMonthlyReport } from '@/lib/monthly-report'
import { generateWeeklyReport } from '@/lib/weekly-report'
import { writeAuditLog, getClientIP } from '@/lib/audit'
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'reports:create')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const { id } = await params
const db = getDb()
const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(id) as any
if (!report) {
return NextResponse.json({ error: '报告不存在' }, { status: 404 })
}
if (report.status !== 'ready' && report.status !== 'failed') {
return NextResponse.json(
{ error: `当前状态 "${report.status}" 不允许生成文档` },
{ status: 409 }
)
}
db.prepare("UPDATE reports SET status = 'generating', error_message = NULL WHERE id = ?")
.run(id)
if (report.report_type === 'weekly') {
generateWeeklyReport(Number(id)).catch(err => {
console.error(`Weekly report generation failed for report ${id}:`, err)
})
} else {
generateMonthlyReport(Number(id)).catch(err => {
console.error(`Report generation failed for report ${id}:`, err)
})
}
const updated = db.prepare('SELECT * FROM reports WHERE id = ?').get(id)
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'generate',
entityType: 'report',
entityId: parseInt(id),
details: { generate: { report_type: report.report_type } },
ipAddress: getClientIP(_request)
})
return NextResponse.json({ report: updated })
} catch (e) {
const msg = e instanceof Error ? e.message : '生成失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,108 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { writeAuditLog, getClientIP } from '@/lib/audit'
import fs from 'fs'
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'reports:read')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { id } = await params
const db = getDb()
const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(id)
if (!report) return NextResponse.json({ error: '报告不存在' }, { status: 404 })
const r = report as any
let reportData: any = null
// 优先解析 metadata JSON
if (r.metadata) {
try {
reportData = JSON.parse(r.metadata)
} catch { /* ignore parse errors */ }
}
// 如果 metadata 为空(旧报告),回退到简单查询
if (!reportData && r.period_start && r.period_end) {
const tickets = db.prepare(
'SELECT * FROM tickets WHERE assign_time >= ? AND assign_time <= ? ORDER BY assign_time'
).all(r.period_start, r.period_end + ' 23:59:59')
const total = tickets.length
const resolved = (tickets as any[]).filter(
t => t.current_status === 'resolved' || t.current_status === 'closed'
).length
const avgDur = db.prepare(
"SELECT AVG(duration_minutes) as avg FROM tickets WHERE assign_time >= ? AND assign_time <= ? AND duration_minutes IS NOT NULL"
).get(r.period_start, r.period_end + ' 23:59:59') as any
const slaPass = (tickets as any[]).filter(
t => t.counted_in_sla === 1 && ['resolved', 'closed'].includes(t.current_status)
).length
const categories = db.prepare(`
SELECT fault_category, COUNT(*) as count FROM tickets
WHERE assign_time >= ? AND assign_time <= ? AND fault_category IS NOT NULL
GROUP BY fault_category ORDER BY count DESC
`).all(r.period_start, r.period_end + ' 23:59:59')
reportData = {
summary: {
total_tickets: total,
resolved_tickets: resolved,
avg_duration: Math.round(avgDur?.avg || 0),
sla_rate: resolved > 0 ? Math.round((slaPass / resolved) * 100) : 0,
},
categories,
}
}
return NextResponse.json({ report, reportData })
} catch (e) {
const msg = e instanceof Error ? e.message : '查询失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'reports:create')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { id } = await params
const db = getDb()
const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(id) as any
if (!report) return NextResponse.json({ error: '报告不存在' }, { status: 404 })
// 删除磁盘文件
if (report.file_path && fs.existsSync(report.file_path)) {
try { fs.unlinkSync(report.file_path) } catch { /* 文件删除失败不影响数据库操作 */ }
}
// 删除数据库记录
db.prepare('DELETE FROM reports WHERE id = ?').run(id)
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'delete',
entityType: 'report',
entityId: Number(id),
details: { deleted: { id: Number(id), title: report.title, type: report.type, period_start: report.period_start, period_end: report.period_end, file_path: report.file_path } },
ipAddress: getClientIP(_request),
})
return NextResponse.json({ success: true })
} catch (e) {
const msg = e instanceof Error ? e.message : '删除失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { writeAuditLog, getClientIP } from '@/lib/audit'
import JSZip from 'jszip'
import fs from 'fs'
export async function POST(request: NextRequest) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'reports:download')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { ids } = await request.json()
if (!Array.isArray(ids) || ids.length === 0) {
return NextResponse.json({ error: '缺少 ids 参数' }, { status: 400 })
}
const db = getDb()
const reports = db.prepare(
`SELECT * FROM reports WHERE id IN (${ids.map(() => '?').join(',')})`
).all(...ids) as any[]
if (reports.length === 0) {
return NextResponse.json({ error: '未找到选中的报告' }, { status: 404 })
}
const zip = new JSZip()
for (const r of reports) {
if (r.status === 'completed' && r.file_path && fs.existsSync(r.file_path)) {
const buffer = fs.readFileSync(r.file_path)
const fileName = r.file_name || `report_${r.id}.docx`
zip.file(fileName, buffer)
}
}
if (Object.keys(zip.files).length === 0) {
return NextResponse.json({ error: '没有可下载的报告文件' }, { status: 400 })
}
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' })
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'export',
entityType: 'report',
entityId: null,
details: { exported_count: ids.length, ids },
ipAddress: getClientIP(request),
})
const d = new Date()
const today = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
const downloadName = `reports_${today}.zip`
const encodedName = encodeURIComponent(downloadName)
return new NextResponse(new Uint8Array(zipBuffer), {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename*=UTF-8''${encodedName}`,
},
})
} catch (e) {
const msg = e instanceof Error ? e.message : '批量下载失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,125 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import fs from 'fs'
import { writeAuditLog, getClientIP } from '@/lib/audit'
export async function GET() {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'reports:read')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const db = getDb()
const reports = db.prepare('SELECT * FROM reports ORDER BY created_at DESC').all()
return NextResponse.json({ reports })
} catch (e) {
const msg = e instanceof Error ? e.message : '查询失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'reports:create')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const body = await request.json()
const { report_type, period_start, period_end } = body
if (!report_type || !period_start || !period_end) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 })
}
const db = getDb()
// 1. 插入报告行,状态为 ready数据已就绪文档未生成
const result = db.prepare(`
INSERT INTO reports (report_type, period_start, period_end, format, status, created_by)
VALUES (?, ?, ?, 'docx', 'ready', ?)
`).run(report_type, period_start, period_end, user.id)
const reportId = result.lastInsertRowid as number
// 2. 采集数据并写入 metadata同步1-2s
if (report_type === 'weekly') {
const { collectWeeklyReportData, buildWeeklyMetadata } = await import('@/lib/weekly-report')
const data = await collectWeeklyReportData(period_start, period_end)
const metadata = buildWeeklyMetadata(data)
db.prepare('UPDATE reports SET metadata = ? WHERE id = ?').run(metadata, reportId)
} else {
const { collectMonthlyReportData, buildMonthlyMetadata } = await import('@/lib/monthly-report')
const data = await collectMonthlyReportData(period_start, period_end)
const metadata = buildMonthlyMetadata(data)
db.prepare('UPDATE reports SET metadata = ? WHERE id = ?').run(metadata, reportId)
}
// 3. 返回报告(状态为 ready前端自动跳转预览页
const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(reportId)
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'create',
entityType: 'report',
entityId: reportId,
details: { created: { report_type, period_start, period_end } },
ipAddress: getClientIP(request),
})
return NextResponse.json({ report }, { status: 201 })
} catch (e) {
const msg = e instanceof Error ? e.message : '创建失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
export async function DELETE(request: NextRequest) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'reports:create')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { ids } = await request.json()
if (!Array.isArray(ids) || ids.length === 0) {
return NextResponse.json({ error: '缺少 ids 参数' }, { status: 400 })
}
const db = getDb()
const reports = db.prepare(
`SELECT * FROM reports WHERE id IN (${ids.map(() => '?').join(',')})`
).all(...ids) as any[]
// 删除磁盘文件
for (const r of reports) {
if (r.file_path && fs.existsSync(r.file_path)) {
try { fs.unlinkSync(r.file_path) } catch { /* ignore */ }
}
}
// 删除数据库记录
const placeholders = ids.map(() => '?').join(',')
db.prepare(`DELETE FROM reports WHERE id IN (${placeholders})`).run(...ids)
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'delete',
entityType: 'report',
entityId: null,
details: { deleted: ids },
ipAddress: getClientIP(request),
})
return NextResponse.json({ success: true, deleted: ids.length })
} catch (e) {
const msg = e instanceof Error ? e.message : '批量删除失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -3,6 +3,7 @@ import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit'
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
@ -15,9 +16,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const body = await request.json()
const db = getDb()
const existing = db.prepare('SELECT * FROM roles WHERE id = ?').get(id)
const existing = db.prepare('SELECT * FROM roles WHERE id = ?').get(id) as any
if (!existing) return NextResponse.json({ error: '角色不存在' }, { status: 404 })
// 获取更新前的完整记录用于审计日志
const beforeUpdate = { ...existing }
const fields: string[] = []
const values: unknown[] = []
@ -30,6 +34,17 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
}
const role = db.prepare('SELECT * FROM roles WHERE id = ?').get(id)
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'update',
entityType: 'role',
entityId: Number(id),
details: { changes: diffObjects(beforeUpdate, role as Record<string, unknown>) },
ipAddress: getClientIP(request),
})
return NextResponse.json({ role })
} catch (e) {
const msg = e instanceof Error ? e.message : '更新失败'
@ -52,7 +67,20 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
return NextResponse.json({ error: '系统内置角色不能删除' }, { status: 400 })
}
const snapshot = { id: existing.id, name: existing.name, display_name: existing.display_name }
db.prepare('DELETE FROM roles WHERE id = ?').run(id)
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'delete',
entityType: 'role',
entityId: Number(id),
details: { deleted: snapshot },
ipAddress: getClientIP(_request),
})
return NextResponse.json({ success: true })
} catch (e) {
const msg = e instanceof Error ? e.message : '删除失败'

View File

@ -3,6 +3,7 @@ import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { writeAuditLog, getClientIP } from '@/lib/audit'
export async function GET() {
try {
@ -39,6 +40,17 @@ export async function POST(request: NextRequest) {
const result = db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)').run(name, display_name, JSON.stringify(permissions || []))
const role = db.prepare('SELECT * FROM roles WHERE id = ?').get(result.lastInsertRowid)
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'create',
entityType: 'role',
entityId: result.lastInsertRowid as number,
details: { created: { name, display_name } },
ipAddress: getClientIP(request),
})
return NextResponse.json({ role }, { status: 201 })
} catch (e) {
const msg = e instanceof Error ? e.message : '创建失败'

View File

@ -17,7 +17,7 @@ export async function GET() {
const resolved = (db.prepare("SELECT COUNT(*) as c FROM tickets WHERE current_status = 'resolved'").get() as any).c
const closed = (db.prepare("SELECT COUNT(*) as c FROM tickets WHERE current_status = 'closed'").get() as any).c
const thisMonth = new Date().toISOString().slice(0, 7)
const d = new Date(); const thisMonth = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`
const monthTotal = (db.prepare("SELECT COUNT(*) as c FROM tickets WHERE assign_time LIKE ?").get(`${thisMonth}%`) as any).c
const avgDuration = db.prepare("SELECT AVG(duration_minutes) as avg FROM tickets WHERE duration_minutes IS NOT NULL AND duration_minutes != ''").get() as any

View File

@ -4,6 +4,9 @@ import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { getAssetByIp } from '@/lib/assets-client'
import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit'
import { notifyTicketResolved } from '@/lib/monitor/ticket-notifier'
import { applyResolutionSideEffects } from '@/lib/monitor/resolution-side-effects'
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
@ -41,7 +44,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
if (!hasPermission(user, 'tickets:edit')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { id } = await params
const body = await request.json()
@ -90,7 +93,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
if (fields.length > 0) {
fields.push('updated_by = ?')
values.push(user.id)
fields.push("updated_at = datetime('now')")
fields.push("updated_at = datetime('now', '+8 hours')")
values.push(id)
db.prepare(`UPDATE tickets SET ${fields.join(', ')} WHERE id = ?`).run(...values)
}
@ -106,6 +109,32 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
}
const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(id)
// 审计日志:记录变更
const changes = diffObjects(existing, body)
if (Object.keys(changes).length > 0) {
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'update',
entityType: 'ticket',
entityId: Number(id),
details: { changes },
ipAddress: getClientIP(request),
})
}
// 结单推送:状态从非终态 → resolved/closed 时触发
const wasTerminal = ['resolved', 'closed'].includes(existing.current_status as string)
const nowTerminal = body.current_status && ['resolved', 'closed'].includes(body.current_status)
if (!wasTerminal && nowTerminal) {
const ticketId = Number(id)
applyResolutionSideEffects(ticketId) // 同步:回写 fault_records + 清理 reminder
void notifyTicketResolved(ticketId).catch(e =>
console.error(`[API] 结单推送失败 (ticket ${ticketId}):`, e)
)
}
return NextResponse.json({ ticket })
} catch (e) {
const msg = e instanceof Error ? e.message : '更新失败'
@ -118,7 +147,7 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
if (!hasPermission(user, 'tickets:delete')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { id } = await params
const db = getDb()
@ -126,6 +155,9 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
const existing = db.prepare('SELECT current_status, created_by FROM tickets WHERE id = ?').get(id) as { current_status: string; created_by: number | null } | undefined
if (!existing) return NextResponse.json({ error: '工单不存在' }, { status: 404 })
// 获取工单快照用于审计日志
const snapshot = db.prepare('SELECT id, device_ip, current_status FROM tickets WHERE id = ?').get(id) as Record<string, unknown> | undefined
// 非管理员不可删除已办工单,非创建人不可删除待办工单
const completedStatuses = ['resolved', 'closed']
if (user.role !== 'admin') {
@ -138,6 +170,17 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
}
db.prepare('DELETE FROM tickets WHERE id = ?').run(id)
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'delete',
entityType: 'ticket',
entityId: Number(id),
details: { deleted: snapshot },
ipAddress: getClientIP(_request),
})
return NextResponse.json({ success: true })
} catch (e) {
const msg = e instanceof Error ? e.message : '删除失败'

View File

@ -3,13 +3,16 @@ import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { writeAuditLog, getClientIP } from '@/lib/audit'
import { notifyTicketResolved } from '@/lib/monitor/ticket-notifier'
import { applyResolutionSideEffects } from '@/lib/monitor/resolution-side-effects'
export async function PUT(request: NextRequest) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
if (!hasPermission(user, 'tickets:edit')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const body = await request.json()
const updates: Array<{ id: number; fault_category?: string; current_status?: string; counted_in_sla?: number }> = body.updates || []
@ -24,6 +27,11 @@ export async function PUT(request: NextRequest) {
for (const item of updates) {
if (!item.id) continue
// 读取当前状态(用于状态跳变检测)
const existing = db.prepare('SELECT current_status FROM tickets WHERE id = ?').get(item.id) as { current_status: string } | undefined
if (!existing) continue
const fields: string[] = []
const values: unknown[] = []
@ -35,15 +43,38 @@ export async function PUT(request: NextRequest) {
}
if (fields.length === 0) continue
fields.push("updated_at = datetime('now')")
fields.push("updated_at = datetime('now', '+8 hours')")
fields.push('updated_by = ?')
values.push(user.id)
values.push(item.id)
const result = db.prepare(`UPDATE tickets SET ${fields.join(', ')} WHERE id = ?`).run(...values)
// 结单推送:状态从非终态 → resolved/closed 时触发
const wasTerminal = ['resolved', 'closed'].includes(existing.current_status)
const nowTerminal = item.current_status && ['resolved', 'closed'].includes(item.current_status)
if (result.changes > 0 && !wasTerminal && nowTerminal) {
applyResolutionSideEffects(item.id)
void notifyTicketResolved(item.id).catch(e =>
console.error(`[API:batch] 结单推送失败 (ticket ${item.id}):`, e)
)
}
updated += result.changes
}
if (updated > 0) {
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'batch_update',
entityType: 'ticket',
entityId: null,
details: { updated, total: updates.length },
ipAddress: getClientIP(request),
})
}
return NextResponse.json({ updated, total: updates.length })
} catch (e) {
const msg = e instanceof Error ? e.message : '批量更新失败'

View File

@ -2,13 +2,16 @@ import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { exportTicketsToExcel } from '@/lib/excel'
import { writeAuditLog, getClientIP } from '@/lib/audit'
export async function GET(request: NextRequest) {
try {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'tickets:export')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const { searchParams } = request.nextUrl
const idsParam = searchParams.get('ids')
@ -45,10 +48,22 @@ export async function GET(request: NextRequest) {
const buffer = exportTicketsToExcel(tickets)
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'export',
entityType: 'ticket',
entityId: null,
details: { count: tickets.length },
ipAddress: getClientIP(request),
})
const d = new Date()
const exportDate = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
return new NextResponse(new Uint8Array(buffer), {
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename="tickets_${new Date().toISOString().slice(0, 10)}.xlsx"`,
'Content-Disposition': `attachment; filename="tickets_${exportDate}.xlsx"`,
},
})
} catch (e) {

View File

@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { verifyApiKey } from '@/lib/auth'
import { writeAuditLog, getClientIP } from '@/lib/audit'
import { notifyTicketCreated } from '@/lib/monitor/ticket-notifier'
function verifyEnvApiKey(key: string): boolean {
const allowed = process.env.ALLOWED_API_KEYS || ''
@ -27,6 +29,7 @@ export async function POST(request: NextRequest) {
const authHeader = request.headers.get('authorization')
let authenticated = false
let apiKeyInfo: { id: number; name: string; permissions: string[] } | null = null
if (authHeader?.startsWith('Bearer ak_')) {
const key = authHeader.slice(7)
@ -34,7 +37,10 @@ export async function POST(request: NextRequest) {
authenticated = true
} else {
const keyInfo = verifyApiKey(key)
if (keyInfo) authenticated = true
if (keyInfo) {
authenticated = true
apiKeyInfo = keyInfo
}
}
}
@ -81,6 +87,27 @@ export async function POST(request: NextRequest) {
)
const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(ticketId)
writeAuditLog({
userId: null,
apiKeyId: apiKeyInfo?.id ?? null,
action: 'create',
entityType: 'ticket',
entityId: ticketId,
details: { created: { ticket_no: ticketNo, device_ip: body.device_ip || null } },
ipAddress: getClientIP(request)
})
void notifyTicketCreated({
ticket_no: ticketNo,
device_ip: body.device_ip || null,
device_sn: body.device_sn || null,
device_name: body.device_name || null,
ticket_type: body.ticket_type || null,
content: body.content || null,
assign_time: body.assign_time || null,
}).catch(e => console.error('[external] notify 失败:', e))
return NextResponse.json({ ticket, created: true }, { status: 201 })
} catch (e) {
const msg = e instanceof Error ? e.message : '创建失败'

View File

@ -16,7 +16,7 @@ export async function GET(request: NextRequest) {
const result: Record<string, string[]> = {}
for (const field of fields) {
const allowed = ['device_ip', 'device_name', 'fault_category', 'current_status', 'ticket_no', 'fault_subcategory']
const allowed = ['device_ip', 'device_name', 'fault_category', 'current_status', 'id', 'fault_subcategory']
if (!allowed.includes(field)) continue
const rows = db.prepare(`SELECT DISTINCT ${field} FROM tickets WHERE ${field} IS NOT NULL AND ${field} != '' ORDER BY ${field}`).all() as Record<string, string>[]
result[field] = rows.map(r => r[field])

View File

@ -4,6 +4,8 @@ import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { parseExcelTickets } from '@/lib/excel'
import { writeAuditLog, getClientIP } from '@/lib/audit'
import { notifyBatchSummary } from '@/lib/monitor/ticket-notifier'
function validateTicketNo(ticketNo: string): string | null {
if (!/^\d{14}$/.test(ticketNo)) {
@ -24,7 +26,7 @@ export async function POST(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
if (!hasPermission(user, 'tickets:import')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const formData = await request.formData()
const file = formData.get('file') as File | null
@ -120,6 +122,25 @@ export async function POST(request: NextRequest) {
transaction()
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'import',
entityType: 'ticket',
entityId: null,
details: {
created: imported.length,
overwritten: conflicts.length,
errors: errors.length,
},
ipAddress: getClientIP(request),
})
// 事务提交后汇总推送(后台,不阻塞响应)
if (imported.length > 0) {
void notifyBatchSummary(imported).catch(e => console.error('[import] notify 失败:', e))
}
return NextResponse.json({
success: true,
imported: imported.length,

View File

@ -3,6 +3,8 @@ import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { writeAuditLog, getClientIP } from '@/lib/audit'
import { notifyTicketCreated } from '@/lib/monitor/ticket-notifier'
export async function GET(request: NextRequest) {
try {
@ -101,7 +103,7 @@ export async function POST(request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'tickets:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
if (!hasPermission(user, 'tickets:create')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const body = await request.json()
const db = getDb()
@ -155,7 +157,29 @@ export async function POST(request: NextRequest) {
}
}
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'create',
entityType: 'ticket',
entityId: ticketId,
details: { created: { ticket_no: ticketNo, device_ip: body.device_ip, device_name: body.device_name } },
ipAddress: getClientIP(request)
})
const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(result.lastInsertRowid)
// fire-and-forget 微信推送,不阻塞响应
void notifyTicketCreated({
ticket_no: ticketNo,
device_ip: body.device_ip || null,
device_sn: body.device_sn || null,
device_name: body.device_name || null,
ticket_type: body.ticket_type || null,
content: body.content || null,
assign_time: body.assign_time || null,
}).catch(e => console.error('[tickets] notify 失败:', e))
return NextResponse.json({ ticket }, { status: 201 })
} catch (e) {
const msg = e instanceof Error ? e.message : '创建失败'

View File

@ -3,6 +3,24 @@ import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser, hashPassword } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { writeAuditLog, diffObjects, getClientIP } from '@/lib/audit'
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
const { id } = await params
const db = getDb()
const row = db.prepare(`
SELECT id, username, display_name, email, role, is_active, created_at, updated_at,
last_login_at,
CASE WHEN last_active_at IS NOT NULL AND datetime(last_active_at, '+5 minutes') > datetime('now', '+8 hours') THEN 1 ELSE 0 END AS is_online
FROM users WHERE id = ?
`).get(id) as Record<string, unknown> | undefined
if (!row) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
return NextResponse.json({ user: row })
}
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
@ -15,9 +33,17 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const body = await request.json()
const db = getDb()
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id)
const existing = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as { id: number; username: string } | undefined
if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
// 获取更新前的完整记录用于审计日志
const beforeUpdate = db.prepare('SELECT id, username, display_name, email, role, is_active FROM users WHERE id = ?').get(id) as Record<string, unknown>
// 禁止修改系统保留用户的角色
if (body.role && (existing.username === 'admin' || existing.username === 'localadmin')) {
return NextResponse.json({ error: '不能修改系统保留用户的角色' }, { status: 400 })
}
const fields: string[] = []
const values: unknown[] = []
@ -28,12 +54,26 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
if (body.password) { fields.push('password_hash = ?'); values.push(hashPassword(body.password)) }
if (fields.length > 0) {
fields.push("updated_at = datetime('now')")
fields.push("updated_at = datetime('now', '+8 hours')")
values.push(id)
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values)
}
const updated = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at, updated_at FROM users WHERE id = ?').get(id)
const updated = db.prepare(`SELECT id, username, display_name, email, role, is_active, created_at, updated_at,
last_login_at,
CASE WHEN last_active_at IS NOT NULL AND datetime(last_active_at, '+5 minutes') > datetime('now', '+8 hours') THEN 1 ELSE 0 END AS is_online
FROM users WHERE id = ?`).get(id)
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'update',
entityType: 'user',
entityId: Number(id),
details: { changes: diffObjects(beforeUpdate, updated as Record<string, unknown>) },
ipAddress: getClientIP(request),
})
return NextResponse.json({ user: updated })
} catch (e) {
const msg = e instanceof Error ? e.message : '更新失败'
@ -52,10 +92,26 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
if (String(id) === String(user.id)) return NextResponse.json({ error: '不能删除自己' }, { status: 400 })
const db = getDb()
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id)
const existing = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as { id: number; username: string } | undefined
if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
if (existing.username === 'admin' || existing.username === 'localadmin') {
return NextResponse.json({ error: '不能删除系统保留用户' }, { status: 400 })
}
const snapshot = db.prepare('SELECT id, username, display_name, role FROM users WHERE id = ?').get(id) as Record<string, unknown>
db.prepare('DELETE FROM users WHERE id = ?').run(id)
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'delete',
entityType: 'user',
entityId: Number(id),
details: { deleted: snapshot },
ipAddress: getClientIP(_request),
})
return NextResponse.json({ success: true })
} catch (e) {
const msg = e instanceof Error ? e.message : '删除失败'

View File

@ -4,6 +4,7 @@ import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { hashPassword } from '@/lib/auth'
import { writeAuditLog, getClientIP } from '@/lib/audit'
export async function GET() {
try {
@ -13,7 +14,10 @@ export async function GET() {
if (!hasPermission(user, 'users:read')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const db = getDb()
const users = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at, updated_at FROM users ORDER BY id').all()
const users = db.prepare(`SELECT id, username, display_name, email, role, is_active, created_at, updated_at,
last_login_at,
CASE WHEN last_active_at IS NOT NULL AND datetime(last_active_at, '+5 minutes') > datetime('now', '+8 hours') THEN 1 ELSE 0 END AS is_online
FROM users ORDER BY id`).all()
return NextResponse.json({ users })
} catch (e) {
const msg = e instanceof Error ? e.message : '查询失败'
@ -41,10 +45,21 @@ export async function POST(request: NextRequest) {
const hash = hashPassword(password)
const result = db.prepare(
'INSERT INTO users (username, password_hash, display_name, email, role) VALUES (?, ?, ?, ?, ?)'
"INSERT INTO users (username, password_hash, display_name, email, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
).run(username, hash, display_name, email || null, role || 'viewer')
const newUser = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at FROM users WHERE id = ?').get(result.lastInsertRowid)
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'create',
entityType: 'user',
entityId: result.lastInsertRowid as number,
details: { created: { username, display_name, role: role || 'viewer' } },
ipAddress: getClientIP(request),
})
return NextResponse.json({ user: newUser }, { status: 201 })
} catch (e) {
const msg = e instanceof Error ? e.message : '创建失败'

View File

@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDb } from '@/lib/db'
import { initDatabase } from '@/lib/db-schema'
import { getCurrentUser } from '@/lib/auth'
import { hasPermission } from '@/lib/permissions'
import { ldapGetUserInfo } from '@/lib/ldap'
import { writeAuditLog, getClientIP } from '@/lib/audit'
export async function POST(_request: NextRequest) {
initDatabase()
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
if (!hasPermission(user, 'users:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
const db = getDb()
const users = db.prepare(
'SELECT id, username FROM users WHERE email IS NULL OR email = \'\''
).all() as { id: number; username: string }[]
let synced = 0
let failed = 0
for (const u of users) {
const info = await ldapGetUserInfo(u.username)
if (info?.email) {
db.prepare('UPDATE users SET email = ? WHERE id = ?').run(info.email, u.id)
synced++
} else {
failed++
}
}
writeAuditLog({
userId: user.id,
apiKeyId: null,
action: 'sync_emails',
entityType: 'user',
details: { sync_emails: { synced, failed } },
ipAddress: getClientIP(_request)
})
return NextResponse.json({ synced, failed, total: users.length })
}

View File

@ -1,14 +1,15 @@
@import "tailwindcss";
@config "../../tailwind.config.js";
@custom-variant dark (&:where(.dark, .dark *));
@source "../../shared";
@source "../../src";
@import './tlyq-design-system.css' layer(base);
@layer base {
body {
background-color: #f8fafc;
color: #0f172a;
}
html.dark body {
background-color: #020617;
color: #ffffff;
font-family: var(--font-body);
color: var(--fg);
background-color: var(--bg);
}
}

View File

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

View File

@ -0,0 +1,362 @@
/*
* TLYQ 统一设计系统 OKLCh 色彩空间2026-07-15 升级
* Tailwind Slate HEX 色板迁移至 OKLCh 色彩空间
* 感知均匀色域更广保持无障碍对比度WCAG AA 4.5:1
*
* 使用方式
* - CSS 变量@import './tlyq-design-system.css'
* - Tailwind颜色类见下方映射说明
*/
/* ==================== 设计令牌CSS 变量) ==================== */
:root {
/* 背景色 */
--bg: oklch(0.984 0.003 247.858); /* ≈ #f8fafc (slate-50) */
--bg-subtle: oklch(0.968 0.007 247.896); /* ≈ #f1f5f9 (slate-100) */
--surface: oklch(1 0 0); /* #ffffff (white) */
--surface-raised: oklch(1 0 0); /* #ffffff (white) */
--surface-overlay: rgba(255, 255, 255, 0.95);
/* 文本色 */
--fg: oklch(0.129 0.042 264.695); /* ≈ #0f172a (slate-900) */
--fg-subtle: oklch(0.372 0.044 257.287); /* ≈ #334155 (slate-700) */
--muted: oklch(0.446 0.043 257.281); /* ≈ #475569 (slate-600) */
--muted-subtle: oklch(0.662 0.051 257.281); /* ≈ #94a3b8 (slate-400) */
/* 边框 */
--border: oklch(0.928 0.006 264.531); /* ≈ #e2e8f0 (slate-200) */
--border-subtle: oklch(0.968 0.007 247.896);/* ≈ #f1f5f9 (slate-100) */
/* 主色调 — 蓝色系 */
--accent: oklch(0.546 0.245 262.881); /* ≈ #2563eb (blue-600) */
--accent-hover: oklch(0.479 0.247 262.881); /* ≈ #1d4ed8 (blue-700) */
--accent-active: oklch(0.426 0.228 262.881);/* ≈ #1e40af (blue-800) */
--accent-subtle: oklch(0.967 0.03 260.592); /* ≈ #eff6ff (blue-50) */
--accent-text: oklch(1 0 0); /* #ffffff */
/* 状态色 */
--success: oklch(0.627 0.194 149.214); /* ≈ #059669 (emerald-600) */
--success-subtle: oklch(0.982 0.041 152.117);/* ≈ #ecfdf5 (emerald-50) */
--warning: oklch(0.681 0.162 75.834); /* ≈ #d97706 (amber-600) */
--warning-subtle: oklch(0.986 0.03 92.708); /* ≈ #fffbeb (amber-50) */
--danger: oklch(0.577 0.245 27.325); /* ≈ #dc2626 (red-600) */
--danger-subtle: oklch(0.96 0.037 26.197); /* ≈ #fef2f2 (red-50) */
--info: oklch(0.546 0.245 262.881); /* ≈ #2563eb (blue-600) */
--info-subtle: oklch(0.967 0.03 260.592); /* ≈ #eff6ff (blue-50) */
/* 字体 */
--font-display: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, sans-serif;
--font-body: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, sans-serif;
--font-sans: var(--font-body);
--font-mono: ui-monospace, "JetBrains Mono", "IBM Plex Mono", SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
/* 字体大小 */
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
--text-base: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.25rem; /* 20px */
--text-2xl: 1.5rem; /* 24px */
--text-3xl: 1.875rem; /* 30px */
/* 行高 */
--leading-tight: 1.25;
--leading-snug: 1.375;
--leading-normal: 1.5;
--leading-relaxed: 1.625;
/* 字间距 */
--tracking-tighter: -0.05em;
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-wide: 0.02em;
--tracking-wider: 0.05em;
--tracking-widest: 0.1em;
/* 间距 */
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-5: 1.25rem; /* 20px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-10: 2.5rem; /* 40px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
/* 圆角 */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 24px;
--radius-full: 9999px;
/* 阴影 */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.04);
/* 过渡 */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-spring: 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
/* 层级系统 */
--z-base: 0;
--z-raised: 10;
--z-dropdown: 100;
--z-sticky: 200;
--z-overlay: 300;
--z-modal: 400;
--z-toast: 700;
/* 布局 */
--sidebar-width: 240px;
--sidebar-width-collapsed: 64px;
--header-height: 56px;
--content-max-width: 1440px;
--content-padding: var(--space-6);
}
/* ==================== 深色模式 ==================== */
:root.dark {
/* 背景色 */
--bg: oklch(0.129 0.042 264.695); /* ≈ #020617 (slate-950) */
--bg-subtle: oklch(0.208 0.042 264.695); /* ≈ #0f172a (slate-900) */
--surface: oklch(0.208 0.042 264.695); /* ≈ #0f172a (slate-900) */
--surface-raised: oklch(0.279 0.041 260.031);/* ≈ #1e293b (slate-800) */
--surface-overlay: rgba(15, 23, 42, 0.95);
/* 文本色 */
--fg: oklch(0.984 0.003 247.858); /* ≈ #f8fafc (slate-50) */
--fg-subtle: oklch(0.837 0.014 253.365); /* ≈ #cbd5e1 (slate-300) */
--muted: oklch(0.662 0.051 257.281); /* ≈ #94a3b8 (slate-400) */
--muted-subtle: oklch(0.527 0.045 257.281); /* ≈ #64748b (slate-500) */
/* 边框 */
--border: oklch(0.279 0.041 260.031); /* ≈ #1e293b (slate-800) */
--border-subtle: oklch(0.208 0.042 264.695);/* ≈ #0f172a (slate-900) */
/* 主色调 */
--accent: oklch(0.623 0.214 259.815); /* ≈ #3b82f6 (blue-500) */
--accent-hover: oklch(0.723 0.157 260.543); /* ≈ #60a5fa (blue-400) */
--accent-active: oklch(0.829 0.096 260.543);/* ≈ #93c5fd (blue-300) */
--accent-subtle: rgba(59, 130, 246, 0.1);
--accent-text: oklch(0.208 0.042 264.695); /* ≈ #0f172a (slate-900) */
/* 状态色 */
--success: oklch(0.696 0.17 162.48); /* ≈ #34d399 (emerald-400) */
--success-subtle: rgba(52, 211, 153, 0.1);
--warning: oklch(0.828 0.12 76.523); /* ≈ #fbbf24 (amber-400) */
--warning-subtle: rgba(251, 191, 36, 0.1);
--danger: oklch(0.715 0.143 25.059); /* ≈ #f87171 (red-400) */
--danger-subtle: rgba(248, 113, 113, 0.1);
--info: oklch(0.723 0.157 260.543); /* ≈ #60a5fa (blue-400) */
--info-subtle: rgba(96, 165, 250, 0.1);
/* 阴影(深色下更突出) */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.2);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.2);
}
/* ==================== Tailwind 暗色模式映射 ==================== */
/*
* 以下 Tailwind 类名可直接使用
* 页面背景: bg-slate-50 dark:bg-slate-950
* 卡片背景: bg-white dark:bg-slate-900
* 边框: border-slate-200 dark:border-slate-800
* 正文: text-slate-900 dark:text-slate-50
* 次要文本: text-slate-700 dark:text-slate-300
* 弱文本: text-slate-500 dark:text-slate-400
* 主按钮: bg-blue-600 hover:bg-blue-700 text-white
* 成功: text-emerald-600 dark:text-emerald-400
* 警告: text-amber-600 dark:text-amber-400
* 危险: text-red-600 dark:text-red-400
*/
/* ==================== 全局重置 ==================== */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--font-sans);
color: var(--fg);
background-color: var(--bg);
}
/* ==================== 组件规范 ==================== */
/* 按钮规范
* 主按钮: bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-sm
* 次按钮: bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 rounded-lg
* 幽灵按钮: hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg
* 尺寸: sm(px-3 py-1.5 text-xs) / md(px-4 py-2 text-sm) / lg(px-6 py-2.5 text-base)
*/
/* 输入框规范
* 边框: border border-slate-300 dark:border-slate-600
* 聚焦: focus:ring-2 focus:ring-blue-500 focus:border-transparent
* 圆角: rounded-lg
* 尺寸: h-10(px-3 text-sm)
*/
/* 卡片规范
* 基础: bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6 shadow-sm
* 悬停: hover:border-blue-200 dark:hover:border-blue-800
*/
/* 表格规范
* 头部: bg-slate-50 dark:bg-slate-800 text-slate-500 text-xs font-medium uppercase
* : border-b border-slate-200 dark:border-slate-700
* 悬停: hover:bg-slate-50 dark:hover:bg-slate-800/50
*/
/* 徽标规范
* 默认: bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300
* 成功: bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400
* 警告: bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400
* 危险: bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400
* 信息: bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400
* 圆角: rounded-full
* 尺寸: px-2.5 py-0.5 text-xs font-medium
*/
/* 弹窗规范
* 背景: bg-white dark:bg-slate-900
* 边框: border border-slate-200 dark:border-slate-800
* 圆角: rounded-xl
* 阴影: shadow-xl
* 标题: text-lg font-semibold
*/
/* 侧边栏规范
* 宽度: w-60 (240px)
* 定位: fixed left-0 top-0 bottom-0
* 背景: bg-white dark:bg-slate-900
* 边框: border-r border-slate-200 dark:border-slate-800
* 品牌区: h-14 flex items-center
* 导航项: px-3 py-2.5 rounded-lg text-sm font-medium
* 激活态: bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400
*/
/* 顶栏规范
* 高度: h-14 (56px)
* 定位: fixed top-0
* 背景: bg-white dark:bg-slate-900
* 边框: border-b border-slate-200 dark:border-slate-800
* z-index: z-30
*/
/* 主内容区规范
* 边距: ml-60 pt-14 p-6 (侧边栏+顶栏)
* 门户布局: max-w-5xl mx-auto px-6 py-8
*/
/* ==================== 统计卡片 ==================== */
/*
* 用于仪表盘/概览页的统计数据展示
* 容器: bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6
* 悬停: hover:border-blue-200 dark:hover:border-blue-800 hover:shadow-md transition-all
* 图标区: w-10 h-10 rounded-lg bg-blue-50 dark:bg-blue-500/10 flex items-center justify-center
* 数值: text-2xl font-semibold text-slate-900 dark:text-white font-display
* 标签: text-sm text-slate-500 dark:text-slate-400
* 变化趋势: text-xs font-medium (positive=text-emerald-600, negative=text-red-600)
*/
/* ==================== 骨架屏加载 ==================== */
/*
* 用于数据加载时的占位效果
* <div className="animate-pulse bg-slate-200 dark:bg-slate-700 rounded-lg [height]" />
* 文本行: h-4 w-full / w-3/4
* 标题: h-6 w-1/3
* 卡片: h-32 w-full rounded-xl
*/
/* 无缝统计条 ==================== */
/*
* 仪表盘顶部关键指标展示分段之间仅 1px 间隙
* 容器: flex gap-px bg-slate-200 dark:bg-slate-800 rounded-xl overflow-hidden
* : flex-1 bg-white dark:bg-slate-900 px-6 py-4 flex flex-col gap-1
* 标签: text-xs font-medium text-slate-500 uppercase tracking-widest
* 数值: text-2xl font-semibold font-[family-name:var(--font-display)]
*/
/* 服务状态卡片 ==================== */
/*
* 仪表盘中展示被监控服务实时状态
* 正常: bg-white dark:bg-slate-900 border rounded-xl p-5
* 悬停: hover:shadow-sm hover:-translate-y-px transition-all cursor-pointer
* 异常: bg-red-50 dark:bg-red-500/10 border-red-600 dark:border-red-400
* 红底红框突出显示
* 状态圆点: w-2.5 h-2.5 rounded-full
* ok: bg-emerald-600 dark:bg-emerald-400 shadow-[0_0_8px_var(--success)]
* err: bg-red-600 dark:bg-red-400 shadow-[0_0_12px_var(--danger)] animate-pulse
* 持续时长: text-lg font-semibold font-mono text-red-600 dark:text-red-400
* 展开区: border-t mt-3 pt-3
*/
/* ==================== 图表容器 ==================== */
/*
* 用于包裹图表组件
* <div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6">
* <div className="flex items-center justify-between mb-6">
* <h3 className="text-lg font-semibold text-slate-900 dark:text-white">标题</h3>
* <div className="flex items-center gap-4 text-sm text-slate-500">图例</div>
* </div>
* [图表内容]
* </div>
*/
/* ==================== 下拉菜单 ==================== */
/*
* 用于用户菜单操作菜单
* 容器: absolute right-0 top-full mt-1
* bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700
* rounded-lg shadow-lg py-1 min-w-[200px] z-[--z-dropdown]
* 菜单项: px-4 py-2.5 text-sm flex items-center gap-3
* hover:bg-slate-50 dark:hover:bg-slate-700
* text-slate-700 dark:text-slate-300
* 危险项: text-red-600 dark:text-red-400
* 分隔线: border-t border-slate-200 dark:border-slate-700 my-1
*/
/* ==================== 站点标识色 ==================== */
/*
* OA: blue-600
* assets: blue-600
* issue: blue-600
* monitor: indigo-600
*
* 站点卡片标识色门户首页
* 资产管理: blue-600 tag: CMDB
* 工单跟踪: violet-600 tag: ITS
* 监控中心: indigo-600 tag: MONITOR
* 官网: emerald-600 tag: WWW
* 云平台: amber-600 tag: CLOUD
* Token: rose-600 tag: TOKEN
* 代码仓库: pink-600 tag: GIT
*
* 2026-07-15 HEX 迁移至 OKLCh 色彩空间
* 主色 blue-600 OKLCh: oklch(0.546 0.245 262.881)
* 主色 indigo-600 OKLCh: oklch(0.511 0.262 276.966)
* 对比度验证白底蓝字 (oklch(0.546 0.245 262.881) on oklch(1 0 0)) 6.5:1 4.5:1 通过
*/

View File

@ -12,7 +12,7 @@ export default function AppShell({ children }: AppShellProps) {
}, [])
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-950">
<Sidebar />
<Sidebar role={user?.role} />
<TopBar user={user} />
<main className="ml-60 pt-14 min-h-screen"><div className="p-6">{children}</div></main>
</div>

View File

@ -1,63 +1,81 @@
'use client'
import { useState, useEffect } from 'react'
// src/components/layout/Sidebar.tsx — 权限驱动侧边栏(统一标准布局)
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { LayoutDashboard, FileText, Settings, Users, Shield, Key, Clock, CheckCircle, PlusSquare, Upload, List } from 'lucide-react'
import { LayoutDashboard, FileText, Settings, Users, Shield, Key, Clock, CheckCircle, PlusSquare, Upload, List, Mail, Bell } from 'lucide-react'
const navItems = [
{ href: '/dashboard', label: '仪表盘', icon: LayoutDashboard },
{ href: '/tickets/pending', label: '待办工单', icon: Clock },
{ href: '/tickets/completed', label: '已办工单', icon: CheckCircle },
{ href: '/tickets/create', label: '手动建单', icon: PlusSquare },
{ href: '/tickets/import', label: '导入工单', icon: Upload },
{ href: '/reports', label: '报告管理', icon: FileText },
]
interface NavItem { label: string; href: string; icon: React.ComponentType<{ size?: number }>; perm: string | null }
interface NavSection { title?: string; items: NavItem[] }
const settingsItems = [
{ href: '/settings/users', label: '用户管理', icon: Users },
{ href: '/settings/roles', label: '角色权限', icon: Shield },
{ href: '/settings/api-keys', label: 'API Key', icon: Key },
]
export default function Sidebar() {
export default function Sidebar({ role }: { role?: string }) {
const pathname = usePathname()
const [isAdmin, setIsAdmin] = useState(false)
const isActive = (href: string) => pathname === href || (href !== '/' && pathname.startsWith(href))
const isAdmin = role === 'admin' || role === 'localadmin'
const sections: NavSection[] = [
{ items: [{ label: '仪表盘', href: '/dashboard', icon: LayoutDashboard, perm: null }] },
{
title: '工单管理',
items: [
{ label: '待办工单', href: '/tickets/pending', icon: Clock, perm: null },
{ label: '已办工单', href: '/tickets/completed', icon: CheckCircle, perm: null },
{ label: '手动建单', href: '/tickets/create', icon: PlusSquare, perm: null },
{ label: '导入工单', href: '/tickets/import', icon: Upload, perm: null },
{ label: '全部工单', href: '/tickets/all', icon: List, perm: isAdmin ? null : 'hidden' },
],
},
{
title: '报告',
items: [{ label: '报告管理', href: '/reports', icon: FileText, perm: null }],
},
{
title: '系统设置',
items: [
{ label: '用户管理', href: '/settings/users', icon: Users, perm: isAdmin ? null : 'hidden' },
{ label: '角色权限', href: '/settings/roles', icon: Shield, perm: isAdmin ? null : 'hidden' },
{ label: 'API Key', href: '/settings/api-keys', icon: Key, perm: isAdmin ? null : 'hidden' },
{ label: '审计日志', href: '/settings/audit-logs', icon: FileText, perm: isAdmin ? null : 'hidden' },
{ label: '邮件监控', href: '/settings/monitor', icon: Mail, perm: isAdmin ? null : 'hidden' },
{ label: '微信推送', href: '/settings/wechat', icon: Bell, perm: isAdmin ? null : 'hidden' },
],
},
]
useEffect(() => {
fetch('/api/auth/me')
.then(r => r.json())
.then(u => { if (u.user?.role === 'admin') setIsAdmin(true) })
.catch(() => {})
}, [])
return (
<aside className="fixed left-0 top-0 bottom-0 w-60 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex flex-col z-40">
<div className="h-14 flex items-center px-5 border-b border-slate-200 dark:border-slate-800">
<span className="text-lg font-semibold text-blue-600 dark:text-blue-400">IT工单跟踪系统</span>
</div>
<nav className="flex-1 py-3 px-3 space-y-1 overflow-y-auto">
{navItems.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + '/')
const Icon = item.icon
return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>)
})}
{isAdmin && (
<Link
href="/tickets/all"
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${pathname === '/tickets/all' ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}
>
<List size={18} />
<aside className="fixed left-0 top-0 bottom-0 w-60 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 z-[200] flex flex-col">
{/* 品牌区 */}
<div className="h-14 flex items-center px-6 border-b border-slate-200 dark:border-slate-800 shrink-0">
<Link href="/dashboard" className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center text-white font-bold text-sm"></div>
<span className="font-semibold text-slate-900 dark:text-white">IT工单跟踪系统</span>
</Link>
)}
<div className="pt-3 border-t border-slate-200 dark:border-slate-800 mt-3">
<div className="flex items-center gap-3 px-3 py-2 text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
<Settings size={14} />
</div>
{settingsItems.map((item) => {
const isActive = pathname === item.href
{/* 导航区 */}
<nav className="flex-1 overflow-y-auto p-3 space-y-6">
{sections.map((section, i) => (
<div key={i}>
{section.title && (
<p className="px-3 mb-1 text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-widest">
{section.title}
</p>
)}
{section.items.filter(item => item.perm !== 'hidden').map(item => {
const Icon = item.icon
return (<Link key={item.href} href={item.href} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive ? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'}`}><Icon size={18} />{item.label}</Link>)
return (
<Link key={item.href} href={item.href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors mb-0.5 ${
isActive(item.href)
? 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'
}`}>
<Icon size={18} />
<span>{item.label}</span>
</Link>
)
})}
</div>
))}
</nav>
</aside>
)

View File

@ -2,18 +2,53 @@
import { useTheme } from '@/components/providers/ThemeProvider'
import { Sun, Moon, LogOut, User } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useState, useRef, useEffect } from 'react'
type Theme = 'light' | 'dark' | 'auto'
interface TopBarProps { user: { username: string; display_name: string; role: string } | null }
export default function TopBar({ user }: TopBarProps) {
const { theme, toggleTheme } = useTheme()
const { theme, setTheme } = useTheme()
const router = useRouter()
const handleLogout = async () => { await fetch('/api/auth/logout', { method: 'POST' }); router.push('/login'); router.refresh() }
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST' })
router.push('/login'); router.refresh()
}
const icons: Record<Theme, React.ReactNode> = { light: <Sun size={16} />, dark: <Moon size={16} />, auto: <span style={{fontSize:14}}></span> }
const labels: Record<Theme, string> = { light: '浅色', dark: '深色', auto: '自动' }
return (
<header className="fixed top-0 left-60 right-0 h-14 bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between px-6 z-30">
<div />
<div className="flex items-center gap-4">
<button onClick={toggleTheme} className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors" title={theme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'}>{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}</button>
<div ref={ref} className="relative">
<button onClick={() => setOpen(!open)} className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors" title="切换主题">
{icons[theme]}
</button>
{open && (
<div className="absolute top-full right-0 mt-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg z-50 py-1 min-w-[100px]">
{(['light', 'dark', 'auto'] as Theme[]).map(t => (
<button key={t} onClick={() => { setTheme(t); setOpen(false) }}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left ${t === theme ? 'bg-slate-100 dark:bg-slate-700' : 'hover:bg-slate-50 dark:hover:bg-slate-700/50'}`}>
<span>{icons[t]}</span>
<span>{labels[t]}</span>
{t === theme && <span className="ml-auto text-blue-600"></span>}
</button>
))}
</div>
)}
</div>
{user && (<div className="flex items-center gap-3"><div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300"><User size={16} /><span>{user.display_name}</span></div><button onClick={handleLogout} className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors" title="退出登录"><LogOut size={18} /></button></div>)}
</div>
</header>

View File

@ -1,25 +1,35 @@
'use client'
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
type Theme = 'light' | 'dark'
interface ThemeContextType { theme: Theme; toggleTheme: () => void }
const ThemeContext = createContext<ThemeContextType>({ theme: 'dark', toggleTheme: () => {} })
type Theme = 'light' | 'dark' | 'auto'
interface ThemeContextType { theme: Theme; setTheme: (t: Theme) => void }
const ThemeContext = createContext<ThemeContextType>({ theme: 'auto', setTheme: () => {} })
export function useTheme() { return useContext(ThemeContext) }
function applyTheme(t: Theme) {
const root = document.documentElement
root.classList.remove('light', 'dark')
if (t === 'auto') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
root.classList.add(prefersDark ? 'dark' : 'light')
} else {
root.classList.add(t)
}
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('dark')
const [theme, setThemeState] = useState<Theme>('auto')
useEffect(() => {
const stored = localStorage.getItem('theme') as Theme | null
if (stored) { setTheme(stored); document.documentElement.classList.toggle('dark', stored === 'dark') }
else {
const d = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
setTheme(d); document.documentElement.classList.toggle('dark', d === 'dark')
}
const initial = stored || 'auto'
setThemeState(initial)
applyTheme(initial)
}, [])
const toggleTheme = () => {
const n = theme === 'dark' ? 'light' : 'dark'
setTheme(n); localStorage.setItem('theme', n); document.documentElement.classList.toggle('dark', n === 'dark')
const setTheme = (t: Theme) => {
setThemeState(t)
localStorage.setItem('theme', t)
applyTheme(t)
}
return <ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>
}

View File

@ -27,7 +27,7 @@ interface ProcessFormProps {
}
const FAULT_CATEGORIES = [
'硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障', '其他',
'硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障',
]
const TICKET_TYPES = ['OEM诊断', 'OEM维修']
@ -35,7 +35,7 @@ const TICKET_TYPES = ['OEM诊断', 'OEM维修']
const FAULT_SUBCATEGORIES = [
'GPU故障', 'CPU故障', '内存故障', '硬盘故障', '电源故障', '风扇故障',
'OS崩溃', '驱动故障', '软件崩溃', '配置错误', '网络丢包', '网络中断',
'存储掉盘', 'RAID故障', '无故障', '其他',
'存储掉盘', 'RAID故障', '其他',
]
const HANDLER_OPTIONS = ['腾讯', '图灵']

View File

@ -11,7 +11,7 @@ interface TicketFormProps {
}
const defaultFaultCategories = [
'硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障', '其他',
'硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障',
]
const defaultTicketTypes = ['OEM诊断', 'OEM维修']
@ -19,7 +19,7 @@ const defaultTicketTypes = ['OEM诊断', 'OEM维修']
const defaultFaultSubcategories = [
'GPU故障', 'CPU故障', '内存故障', '硬盘故障', '电源故障', '风扇故障',
'OS崩溃', '驱动故障', '软件崩溃', '配置错误', '网络丢包', '网络中断',
'存储掉盘', 'RAID故障', '无故障', '其他',
'存储掉盘', 'RAID故障', '其他',
]
export default function TicketForm({ initialData, ticketId }: TicketFormProps) {

View File

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

View File

@ -0,0 +1,19 @@
import { ReactNode } from 'react'
type Variant = 'default' | 'success' | 'warning' | 'danger' | 'info'
// 兼容旧 API: blue→info, green→success, gray→default, red→danger, yellow→warning
type LegacyColor = 'blue' | 'green' | 'gray' | 'red' | 'yellow'
const legacyMap: Record<LegacyColor, Variant> = {
blue: 'info', green: 'success', gray: 'default', red: 'danger', yellow: 'warning',
}
interface BadgeProps { children: ReactNode; variant?: Variant; color?: LegacyColor }
const vs: Record<Variant, string> = {
default: 'bg-slate-100 dark:bg-slate-800 dark:text-slate-300 text-slate-700',
success: 'bg-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-400 text-emerald-700',
warning: 'bg-amber-100 dark:bg-amber-500/10 dark:text-amber-400 text-amber-700',
danger: 'bg-red-100 dark:bg-red-500/10 dark:text-red-400 text-red-700',
info: 'bg-blue-100 dark:bg-blue-500/10 dark:text-blue-400 text-blue-700',
}
export default function Badge({ children, variant = 'default', color }: BadgeProps) {
const v = color ? vs[legacyMap[color]] : vs[variant]
return <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${v}`}>{children}</span>
}

Some files were not shown because too many files have changed in this diff Show More