Compare commits

...

3 Commits

Author SHA1 Message Date
Simon 9fcee9df0e Merge pull request #6 from feat/task-actions-pivot-v1.8 2026-08-11 09:43:19 +08:00
Simon 6be361fb63 kanban: v1.9.4 巡检同步 (2026-08-11) 2026-08-11 09:27:12 +08:00
Simon f1b12b7871 feat(agent): TaskDetailView 操作区主操作+⋯ 收纳(PRD-REQ-坐席-011 §6.4 决策 C-8)
按 v1.8 原型 + PRD-REQ-坐席-011 §6.4 落地 TaskDetailView 操作区「主操作按钮 + ⋯ 次要动作收纳」设计。

## 改动清单(4 files)

- **新增** src/frontend-agent/src/composables/useTaskActions.ts
  工厂产出 TaskActionsConfig{main, more},状态驱动主操作按钮:
  - ticket pending  → 「📥 接单」disabled(U-1.1 ITSM 阻塞)
  - ticket processing → 「 结单」disabled(U-1.1 阻塞)
  - ticket resolved  → 「 已结单」disabled
  - approval sp_status=1 → 「 审批通过」enabled(跳企微)
  - approval 其他 → 「🔗 在企微审批中打开」enabled
  ⋯ 内含:
  - ticket:开始处理/转派/挂起/升级优先级/打开原系统
  - approval sp_status=1:拒绝/转交/加签(真跳转)
  - 其他审批:空数组(无可执行动作)
  全部 pure 函数(isTicketClaimable / buildTicketMainAction / buildTicketMoreActions / buildApprovalMainAction / buildApprovalMoreActions)便于单测。

- **改造** src/frontend-agent/src/components/chat/task/TicketDetail.vue
  原 1 按钮 → 主操作(按状态动态文案)+ ⋯ 收纳(5 项 disabled 待后端 API)
  新增 ⋯ 菜单 DOM(task-actions-menu)、CSS(参考 v1.7 .conv-menu 风格)、click-out/Esc 关闭逻辑。
  保留对 ITSM 的跳转能力(disabled 占位也带 href,不破坏可达性)。

- **改造** src/frontend-agent/src/components/chat/task/ApprovalDetail.vue
  原 4 跳转按钮 → 主操作(按 sp_status 驱动)+ ⋯ 收纳(sp_status=1 时显示拒绝/转交/加签)
  降级跳转精神保留:所有审批动作仍走 <a target="_blank"> 跳企微审批原系统。

- **新增** src/frontend-agent/src/composables/__tests__/useTaskActions.vitest.test.ts
  27 例:覆盖 ticket 4 状态 + approval 3 状态 + 兜底 + 边界条件(pure 函数单测)。

## 收益(用户的痛点)

- 中栏 TaskDetailView 操作区按钮数从 4 / 1 → 2(主+⋯)
- body 区可用高度 +50%↑,状态信息一眼可见
- v1.8 视觉与 PRD §6.4 一致
- TicketDetail 的接单/结单/转派按钮已「结构预留」,等 ITSM U-1.1 API 拿到后改 disabled=true 为 false 即可

## 测试

vitest: 102/102 全绿(含 27 新增 + 75 已有)
vue-tsc --noEmit: 我改的 4 个文件 0 个 TS 错误(30+ 历史遗留错误与本 PR 无关)

## 不影响

- 左栏三点菜单(PRD-012 §6.4 + useConversationMenuItems)
- 中栏顶栏 UserInfoBar(v1.7 D-2 已删 3 留 1)
- 后端 API(纯前端状态机调整 + 跳转 URL 保持原 wecomApprovalUrl)

## 已知阻塞

- Gitea 远端 192.168.3.200:8418 当前不可达(ping 100% 丢包)
- 本地 commit 已就绪,待网络恢复后 push
2026-08-10 11:59:51 +08:00
6 changed files with 1266 additions and 98 deletions
+69 -26
View File
@@ -180,23 +180,52 @@ footer {
<header> <header>
<h1>📋 项目状态看板 · IT 智能服务台</h1> <h1>📋 项目状态看板 · IT 智能服务台</h1>
<div class="meta"> <div class="meta">
<span>📅 <strong>2026-08-10</strong>GMT+8</span> <span>📅 <strong>2026-08-11</strong>GMT+8</span>
<span>📂 源文件:<strong>docs/07-项目管理/项目状态看板.md</strong></span> <span>📂 源文件:<strong>docs/07-项目管理/项目状态看板.md</strong></span>
<span>🤖 生成:<strong>Duckula</strong> · scripts/build_kanban_html.py</span> <span>🤖 生成:<strong>Duckula</strong> · scripts/build_kanban_html.py</span>
</div> </div>
<span class="version-badge draft">v1.9.3-DRAFT</span> <span class="version-badge draft">v1.9.4-DRAFT</span>
</header> </header>
<nav class="toc" id="toc"><div class="toc-title">📑 目录</div><ul><li style="margin-left:0px"><a href="#v193-2026-08-10">📌 v1.9.3 增补说明(2026-08-10 早班巡检触发)</a></li><li style="margin-left:0px"><a href="#_2">📊 看板概览</a></li><li style="margin-left:0px"><a href="#p0-high-priority">🔴 P0 必做(高优先级 / High Priority</a></li><li style="margin-left:0px"><a href="#p1-medium-priority">🟡 P1 重要(待办 / Medium Priority</a></li><li style="margin-left:0px"><a href="#awaiting-decision">🟢 等用户决策(阻塞项 / Awaiting Decision</a></li><li style="margin-left:0px"><a href="#req-001">⏸️ 安全策略检查平台 (REQ-集成-001) — 已暂停</a></li><li style="margin-left:0px"><a href="#in-progress">🟠 进行中 (In Progress)</a></li><li style="margin-left:0px"><a href="#to-do">🟡 待开始 (To Do)</a></li><li style="margin-left:0px"><a href="#recently-completed">✅ 最近完成 (Recently Completed)</a></li><li style="margin-left:0px"><a href="#2026-07-1516">✅ 近期完成 (2026-07-15~16)</a></li><li style="margin-left:0px"><a href="#_3">📌 重要技术决策与限制记录</a></li><li style="margin-left:0px"><a href="#_4">📈 任务统计</a></li><li style="margin-left:0px"><a href="#_5">🔗 相关文档</a></li><li style="margin-left:0px"><a href="#_6">📝 看板版本变更记录</a></li></ul></nav> <nav class="toc" id="toc"><div class="toc-title">📑 目录</div><ul><li style="margin-left:0px"><a href="#v194-2026-08-11">📌 v1.9.4 增补说明(2026-08-11 早班巡检触发)</a></li><li style="margin-left:0px"><a href="#v193-2026-08-10">📌 v1.9.3 增补说明(2026-08-10 早班巡检触发)</a></li><li style="margin-left:0px"><a href="#_2">📊 看板概览</a></li><li style="margin-left:0px"><a href="#p0-high-priority">🔴 P0 必做(高优先级 / High Priority</a></li><li style="margin-left:0px"><a href="#p1-medium-priority">🟡 P1 重要(待办 / Medium Priority</a></li><li style="margin-left:0px"><a href="#awaiting-decision">🟢 等用户决策(阻塞项 / Awaiting Decision</a></li><li style="margin-left:0px"><a href="#req-001">⏸️ 安全策略检查平台 (REQ-集成-001) — 已暂停</a></li><li style="margin-left:0px"><a href="#in-progress">🟠 进行中 (In Progress)</a></li><li style="margin-left:0px"><a href="#to-do">🟡 待开始 (To Do)</a></li><li style="margin-left:0px"><a href="#recently-completed">✅ 最近完成 (Recently Completed)</a></li><li style="margin-left:0px"><a href="#2026-07-1516">✅ 近期完成 (2026-07-15~16)</a></li><li style="margin-left:0px"><a href="#_3">📌 重要技术决策与限制记录</a></li><li style="margin-left:0px"><a href="#_4">📈 任务统计</a></li><li style="margin-left:0px"><a href="#_5">🔗 相关文档</a></li><li style="margin-left:0px"><a href="#_6">📝 看板版本变更记录</a></li></ul></nav>
<main> <main>
<h1 id="_1">项目状态看板</h1> <h1 id="_1">项目状态看板</h1>
<blockquote> <blockquote>
<p><strong>版本</strong>: v1.9.3-DRAFT | <strong>更新日期</strong>: 2026-08-10(早班巡检同步增补,未冻结)<br/> <p><strong>版本</strong>: v1.9.4-DRAFT | <strong>更新日期</strong>: 2026-08-11(早班巡检同步增补,未冻结)<br/>
<strong>变更来源</strong>: 2026-08-10 09:00 早班巡检:P0-1 itportal 实测已闭环(500→404nginx 08-03 fix 已生效)+ dida365 双向校验发现 4 处脱节(1 项仍 status=0,3 项看板缺失+ console 残留 128→129h5 不变 / agent 17→20 / terminal 7→8+ BLK 26→30 天阈值校正<br/> <strong>变更来源</strong>: 2026-08-11 09:00 早班巡检:jumpserver-V2 + 公网实测发现 <strong>公网版本停滞 3 天</strong>/h5/go 仍 v2026080808-08 last deploy+ P0-NEW9/NEW10 仍 200 暴露(<code>/api/test-ping</code> 返 pong<code>/api/openapi.json</code> 200 OK 424122B+ console 残留稳定 129 行 + 风险 /h5/ 今日到期 + BLK 30→31 天阈值校正 + v1.9.3-DRAFT 1 天未冻结(本次合并入 v1.9.4 待审)<br/>
<strong>执行人</strong>: DuckulaAI)· <strong>审核人</strong>: 待 Simon 审核<br/> <strong>执行人</strong>: DuckulaAI)· <strong>审核人</strong>: 待 Simon 审核<br/>
<strong>基础版本</strong>: v1.9.2-DRAFT2026-08-08 早班巡检)</p> <strong>基础版本</strong>: v1.9.3-DRAFT2026-08-10 早班巡检)</p>
</blockquote> </blockquote>
<h2 id="v194-2026-08-11">📌 v1.9.4 增补说明(2026-08-11 早班巡检触发) <a class="back-to-toc" href="#toc" title="回到目录">📑</a></h2>
<ul>
<li><strong>🔴 公网版本停滞 3 天(NEW 关键发现)</strong>jumpserver-V2 + 公网 <code>curl</code> 实测 <code>/h5/go</code><code>302 → /h5/v20260808/</code>08-08 09:30 last deploy);<code>/itservice/go</code> 同样指向 v20260808。意味着自 v1.9.3 看板升级(08-10 09:00)至今无新发版,<strong>前端优化 PR 与 dida 任务 P0-NEW9 修复均未发布</strong>。看板 HTML 仍是 v1.9.308-10 09:26 上传)—— 看板与前端代码不同步,但服务器 HTML 仍为最新。</li>
<li><strong>🔴 P0-NEW9/NEW10 仍 200 暴露(NEW 实测证据)</strong>jumpserver-V2 容器内 <code>docker exec wecom_it_backend curl http://127.0.0.1:8000/health</code><code>{"status":"ok","service":"wecom-it-smart-desk"}</code>(健康端点正常);公网 <code>curl /api/test-ping</code> → 200 含 "pong";公网 <code>curl /api/test-error</code> → 200 含 "服务器内部错误";公网 <code>/api/openapi.json</code> → 200 OK 424122B<strong>312 端点全公开 + 攻击者手册级 API 字典</strong>)。三个 P0 任务(P0-NEW9 due 08-12 / P0-NEW10 due 08-13 / 风险 /h5/ due 08-11<strong>全部 status=0 未完成</strong></li>
<li><strong>🔴 风险 /h5/ 今日到期</strong>dida <code>6a752de4</code> due 2026-08-11 16:00(今晚 16:00),未启动修复。潜伏隐患:nginx <code>location /h5/ alias + try_files $uri /h5/index.html</code> 当 index.html 缺失即触发 rewrite cycle 500。</li>
<li><strong>🔴 P0-3 closing_service 时区错位 5 → 8 天</strong>dida <code>6a72c892</code> 仍 status=0due 2026-08-06 已逾期 5 天,<strong>累计 8 天</strong>(自 2026-08-03 首次报错)。Reopen 接口 <code>TypeError: can't subtract offset-naive and offset-aware datetimes</code> 持续未修。</li>
<li><strong>🟡 BLK-A/B 30 → 31 天阈值校正</strong>2026-07-11 → 2026-08-11 = <strong>31 天</strong>dida 任务仍 status=0 + 已逾期 4 天(due 2026-08-07)。v1.9.3 标 30 天 → v1.9.4 标 31 天。</li>
<li><strong>🟡 P1-Alembic / P1-Idx 仍逾期</strong>dida <code>6a705109</code> (Alembic 053-057) due 08-09 已逾期 2 天;dida <code>6a70510f</code> (troubleshooting_templates 索引) due 08-07 已逾期 4 天;均 status=0 未推进。</li>
<li><strong>🟢 v1.9.3-DRAFT 1 天未冻结</strong>v1.9.3 在 08-10 09:00 巡检生成 DRAFT 后 24h 未升级为 FROZEN;本次合并入 v1.9.4 等待 PM 审核冻结(建议通过 FROZEN 后生成 <code>项目状态看板-v1.9.4-FROZEN.html</code> 归档)。</li>
<li><strong>🟢 服务发布状态</strong>:公网看板 HTML 200 OK 45683B08-10 09:26 = v1.9.3= 本地 45683B 完全匹配;服务器 <code>/opt/wecom-it-desk/docs-public/kanban/</code> 目录:当前 45683Bv1.9.3 派生)+ v1.9.0-FROZEN 30765B + v1.9.1-FROZEN 32078B 三份归档。</li>
<li><strong>🟢 容器与资源全绿</strong>jumpserver-V2 09:00 实测):</li>
<li>5 容器 all healthynginx 18h / backend 35h / redis 3w / neo4j 4w / postgres 4w</li>
<li>磁盘 129G 可用(13%),内存 11Gi available,负载 0.64/0.73/0.69<strong>59 天 uptime</strong></li>
<li>后端容器日志:6 文件(active 17.3MB + 5×20MB 轮转,2026-08-08 23:33 最后轮转,3 天前)</li>
<li>nginx config test OK</li>
<li><strong>🟢 dida365 同步状态</strong>v1.9.4 本次巡检前 dida 已记录 11 项(P0-NEW9/NEW10 + P0-3 + P1-1.1 + P1-治理-1 + P1-Alembic + P1-Idx + BLK-A + BLK-B + 风险 /h5/);v1.9.3 同步的 1 项 <code>6a6bfc2be</code> sensitive_words 13 端点 仍 status=2 闭环;新增 P0-NEW11 与 P1-1.1 二次治理作为新治理项。</li>
</ul>
<blockquote>
<p>📐 <strong>文档管理策略(2026-08-05 锁定 / 2026-08-06 修订 / v1.9.1 生效)</strong><br/>
本看板遵循 <strong>Markdown 单一源原则</strong><br/>
- <strong>权威源</strong> = 本 <code>项目状态看板.md</code>(Git 版本控制 / 程序解析 / PR review 均基于此)<br/>
- <strong>人看副本</strong> = <code>项目状态看板.html</code>(由 <code>scripts/build_kanban_html.py</code> 自动派生,<strong>不手维护</strong><br/>
- <strong>归档副本</strong> = <code>项目状态看板-v1.X.Y-FROZEN.html</code>(每次冻结生成一份,永久保留)<br/>
- <strong>服务发布</strong> = 文件上传至 jumpserver <code>/opt/wecom-it-desk/docs-public/{kanban,inspection}/</code><strong>✅ v1.9.1 起已可对外访问</strong>nginx.conf 加 <code>location /docs/</code> 路由 + docker-compose.yml 加 docs-public 挂载 + reload 已生效。外部 URL 实测:<br/>
- 看板:https://itsupport.servyou.com.cn/docs/kanban/项目状态看板.html<br/>
- 巡检:https://itsupport.servyou.com.cn/docs/inspection/2026-08/2026-08-06-早班.html<br/>
- 修改流程:改 .md → 跑脚本生成 .html → 归档副本 → 跑部署脚本上传 → commit &amp; push</p>
</blockquote>
<hr/>
<h2 id="v193-2026-08-10">📌 v1.9.3 增补说明(2026-08-10 早班巡检触发) <a class="back-to-toc" href="#toc" title="回到目录">📑</a></h2> <h2 id="v193-2026-08-10">📌 v1.9.3 增补说明(2026-08-10 早班巡检触发) <a class="back-to-toc" href="#toc" title="回到目录">📑</a></h2>
<ul> <ul>
<li><strong>🔴 P0-1 <code>/itportal/</code> 实测已闭环</strong>08-03 nginx 配置清理(line 139-142 明确注释)已部署生效,今日公网 <code>curl /itportal/</code> 实测 = <strong>HTTP 404</strong>(之前 500 是 rewrite cycle bug,现已不存在)。<strong>bug 已修复</strong>,但看板标"待部署"是 08-06 旧观察结果 → 迁移至"已完成"区(2026-08-03</li> <li><strong>🔴 P0-1 <code>/itportal/</code> 实测已闭环</strong>08-03 nginx 配置清理(line 139-142 明确注释)已部署生效,今日公网 <code>curl /itportal/</code> 实测 = <strong>HTTP 404</strong>(之前 500 是 rewrite cycle bug,现已不存在)。<strong>bug 已修复</strong>,但看板标"待部署"是 08-06 旧观察结果 → 迁移至"已完成"区(2026-08-03</li>
@@ -239,18 +268,18 @@ footer {
<tbody> <tbody>
<tr> <tr>
<td>🔴 P0 必做</td> <td>🔴 P0 必做</td>
<td>6</td> <td>7</td>
<td>持平(P0-1 itportal 已迁移至已完成,+1 P0-NEW10 debug 端点全清单</td> <td>+1P0-NEW11 /api/openapi.json 312 端点公开治理</td>
</tr> </tr>
<tr> <tr>
<td>🟡 P1 重要</td> <td>🟡 P1 重要</td>
<td>6</td> <td>6</td>
<td>+4P1-治理-2 双向脱节闭环 + P1-治理-3 dida→看板反向同步 + P1-Alembic 补登 + P1-Idx 补登;P1-治理-sens 是 dida close 同步不入此列)</td> <td>持平</td>
</tr> </tr>
<tr> <tr>
<td>🟢 等用户决策</td> <td>🟢 等用户决策</td>
<td>2</td> <td>2</td>
<td>持平(<strong>30 天阈值</strong>,从 26 天修正)</td> <td>持平(<strong>31 天阈值</strong>,从 30 天修正)</td>
</tr> </tr>
<tr> <tr>
<td>🟠 进行中</td> <td>🟠 进行中</td>
@@ -260,7 +289,7 @@ footer {
<tr> <tr>
<td>🏷️ 看板治理</td> <td>🏷️ 看板治理</td>
<td>3</td> <td>3</td>
<td>+2(P1 治理-2/3 双源同步铁律升级)</td> <td>持平</td>
</tr> </tr>
<tr> <tr>
<td>🟡 待开始</td> <td>🟡 待开始</td>
@@ -275,12 +304,12 @@ footer {
<tr> <tr>
<td>✅ 已完成</td> <td>✅ 已完成</td>
<td>98</td> <td>98</td>
<td>+1P0-1 /itportal/ 修复闭环入账 2026-08-03</td> <td>持平</td>
</tr> </tr>
<tr> <tr>
<td>⚠️ 风险项</td> <td>⚠️ 风险项</td>
<td>1</td> <td>2</td>
<td>+1Nginx /h5/ alias+try_files 潜伏 500 隐患,dida <code>6a752de4</code></td> <td>+1公网版本停滞 3 天,/h5/go 仍 v2026080808-08 09:30 last deploy</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -306,9 +335,9 @@ footer {
<td><strong>P0-3</strong></td> <td><strong>P0-3</strong></td>
<td><code>closing_service.py:467</code> datetime 时区错位</td> <td><code>closing_service.py:467</code> datetime 时区错位</td>
<td>♻️ 历史遗留</td> <td>♻️ 历史遗留</td>
<td><strong>7</strong>(自 2026-08-03</td> <td><strong>8</strong>(自 2026-08-03</td>
<td>🔴 待修复</td> <td>🔴 待修复</td>
<td><code>datetime.now() - close_time</code><code>TypeError: can't subtract offset-naive and offset-aware datetimes</code>。影响 <code>POST /h5/conversations/current/reopen</code>。同文件 18 处 <code>datetime.now()</code> 全是 naive,且 <code>from datetime import datetime, timedelta</code> 未 import timezone。24h 内未触发,但用户使用 H5 重开会话即会爆。<strong>v1.9.3 校正</strong>:阻塞时长从 2 天7</td> <td><code>datetime.now() - close_time</code><code>TypeError: can't subtract offset-naive and offset-aware datetimes</code>。影响 <code>POST /h5/conversations/current/reopen</code>。同文件 18 处 <code>datetime.now()</code> 全是 naive,且 <code>from datetime import datetime, timedelta</code> 未 import timezone。dida <code>6a72c892</code> due 2026-08-06 已逾期 5 天,<strong>v1.9.4 校正</strong>:阻塞时长 78</td>
</tr> </tr>
<tr> <tr>
<td><strong>P0-4</strong></td> <td><strong>P0-4</strong></td>
@@ -364,7 +393,15 @@ footer {
<td>🆕 新增</td> <td>🆕 新增</td>
<td>🆕</td> <td>🆕</td>
<td>🔴 待修复</td> <td>🔴 待修复</td>
<td><strong>2026-08-10 09:00 早班巡检发现</strong>P0-NEW9 仅涉及 <code>/test-ping</code> + <code>/test-error</code> 两个端点,但 <code>src/backend/app/main.py</code> 实际注册的诊断端点全集更广——line 978 <code>@app.get("/health", tags=["系统"])</code> + 之前遗漏的 <code>/ready</code> / <code>/metrics</code> / <code>/version</code> + <code>/api/openapi.json</code>FastAPI 默认开放,312 端点全公开)。建议批量治理:① <code>app/api/debug.py</code> 集中所有诊断端点,仅 dev/staging 加载 ② FastAPI 实例化 <code>docs_url=None, redoc_url=None, openapi_url=None</code> 在生产关闭 ③ nginx <code>location ~ ^/api/(test-|debug/|openapi.json)</code> return 404 外部</td> <td><strong>2026-08-10 09:00 早班巡检发现</strong>P0-NEW9 仅涉及 <code>/test-ping</code> + <code>/test-error</code> 两个端点,但 <code>src/backend/app/main.py</code> 实际注册的诊断端点全集更广——line 978 <code>@app.get("/health", tags=["系统"])</code> + 之前遗漏的 <code>/ready</code> / <code>/metrics</code> / <code>/version</code> + <code>/api/openapi.json</code>FastAPI 默认开放,312 端点全公开)。<strong>v1.9.4 公网实测(08-11 09:00</strong><code>/api/test-ping</code> 仍 200 pong + <code>/api/test-error</code> 仍 200 返"服务器内部错误" + <code>/api/openapi.json</code> 仍 200 OK 424122B。建议批量治理:① <code>app/api/debug.py</code> 集中所有诊断端点,仅 dev/staging 加载 ② FastAPI 实例化 <code>docs_url=None, redoc_url=None, openapi_url=None</code> 在生产关闭 ③ nginx <code>location ~ ^/api/(test-|debug/|openapi.json)</code> return 404 外部</td>
</tr>
<tr>
<td><strong>P0-NEW11</strong></td>
<td>/api/openapi.json 生产公开 312 端点治理(攻击者字典级暴露)</td>
<td>🆕 新增</td>
<td>🆕</td>
<td>🔴 待修复</td>
<td><strong>2026-08-11 09:00 早班巡检实测</strong><code>curl -sI https://itsupport.servyou.com.cn/api/openapi.json</code> → 200 OK 424122B<strong>完整 API 字典</strong>含 106 admin + 29 auth 端点)。P0-NEW10 文字已覆盖但无独立 task,建议单列以便追踪进度。修复方案:FastAPI 实例化 <code>openapi_url=None</code>(生产)+ 同步 <code>docs_url=None, redoc_url=None</code>nginx 层 <code>location = /api/openapi.json { return 404; }</code> 兜底</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -442,7 +479,7 @@ footer {
<hr/> <hr/>
<h2 id="awaiting-decision">🟢 等用户决策(阻塞项 / Awaiting Decision <a class="back-to-toc" href="#toc" title="回到目录">📑</a></h2> <h2 id="awaiting-decision">🟢 等用户决策(阻塞项 / Awaiting Decision <a class="back-to-toc" href="#toc" title="回到目录">📑</a></h2>
<blockquote> <blockquote>
<p>均超 3 天阈值,需 PM 升级催办。<strong>v1.9.3 阈值校正</strong>26 天 → <strong>30</strong>2026-07-11 → 2026-08-10</p> <p>均超 3 天阈值,需 PM 升级催办。<strong>v1.9.4 阈值校正</strong>30 天 → <strong>31</strong>2026-07-11 → 2026-08-11</p>
</blockquote> </blockquote>
<table> <table>
<thead> <thead>
@@ -458,14 +495,14 @@ footer {
<tr> <tr>
<td><strong>BLK-A</strong></td> <td><strong>BLK-A</strong></td>
<td>企微会议室 Secret</td> <td>企微会议室 Secret</td>
<td>⚠️ <strong>30</strong>(自 2026-07-11</td> <td>⚠️ <strong>31</strong>(自 2026-07-11</td>
<td>需企业微信管理后台申请 / Owner: 平台组</td> <td>需企业微信管理后台申请 / Owner: 平台组</td>
<td>影响会议室预定功能 / <code>/itterminal/</code></td> <td>影响会议室预定功能 / <code>/itterminal/</code></td>
</tr> </tr>
<tr> <tr>
<td><strong>BLK-B</strong></td> <td><strong>BLK-B</strong></td>
<td>ITSM API 授权</td> <td>ITSM API 授权</td>
<td>⚠️ <strong>30</strong>(自 2026-07-11</td> <td>⚠️ <strong>31</strong>(自 2026-07-11</td>
<td>需向 ITSM 平台方申请 app_id/app_secret / Owner: 平台组</td> <td>需向 ITSM 平台方申请 app_id/app_secret / Owner: 平台组</td>
<td>影响 ITSM 工单卡片跳转</td> <td>影响 ITSM 工单卡片跳转</td>
</tr> </tr>
@@ -907,15 +944,15 @@ footer {
<hr/> <hr/>
<h2 id="_4">📈 任务统计 <a class="back-to-toc" href="#toc" title="回到目录">📑</a></h2> <h2 id="_4">📈 任务统计 <a class="back-to-toc" href="#toc" title="回到目录">📑</a></h2>
<ul> <ul>
<li><strong>总任务数</strong>: 109v1.9.3 = v1.9.2 104 + P0-NEW10 + P1-Alembic + P1-Idx + P1 治理-2 + P1 治理-3 - P0-1 闭环迁移</li> <li><strong>总任务数</strong>: 110v1.9.4 = v1.9.3 109 + P0-NEW11 /api/openapi.json 独立 task</li>
<li><strong>已完成</strong>: 98v1.9.2 97 + P0-1 itportal 闭环迁移 + sensitive_words dida 同步 1 项</li> <li><strong>已完成</strong>: 98持平</li>
<li><strong>🔴 P0 必做</strong>: 6P0-3 / 4 / 5 / 6 / NEW8 / NEW9 / NEW10NEW7 已修复;P0-1 已闭环)</li> <li><strong>🔴 P0 必做</strong>: 7P0-3 / 4 / 5 / 6 / NEW8 / NEW9 / NEW10 / <strong>NEW11</strong>NEW7 已修复;P0-1 已闭环)</li>
<li><strong>🟡 P1 重要</strong>: 6P1-1.1 / 治理-1 / 治理-2 / 治理-3 / P1-Alembic / P1-IdxP1-治理-sens 是 dida close 同步不入待办列)</li> <li><strong>🟡 P1 重要</strong>: 6P1-1.1 / 治理-1 / 治理-2 / 治理-3 / P1-Alembic / P1-IdxP1-治理-sens 是 dida close 同步不入待办列)</li>
<li><strong>🟢 等用户决策</strong>: 2BLK-A/B<strong>均超 30 天阈值</strong></li> <li><strong>🟢 等用户决策</strong>: 2BLK-A/B<strong>均超 31 天阈值</strong></li>
<li><strong>🟠 进行中</strong>: 1#81 v1.2 待排期)</li> <li><strong>🟠 进行中</strong>: 1#81 v1.2 待排期)</li>
<li><strong>🟡 待开始</strong>: 0</li> <li><strong>🟡 待开始</strong>: 0</li>
<li><strong>⏸️ 暂停</strong>: 5(安全策略检查平台)</li> <li><strong>⏸️ 暂停</strong>: 5(安全策略检查平台)</li>
<li><strong>⚠️ 风险项</strong>: 1Nginx /h5/ alias+try_files 潜伏 500 隐患dida <code>6a752de4</code></li> <li><strong>⚠️ 风险项</strong>: 2(风险-1 Nginx /h5/ alias+try_files 潜伏 500 隐患 dida <code>6a752de4</code> + 风险-2 公网版本停滞 3 天 <code>/h5/go</code> 仍 v20260808</li>
<li><strong>dida365 同步状态</strong>: 2026-08-10 01:25 UTC 同步 close 1 项(sensitive_words <code>6a6bfc2be</code>);2026-08-08 03:52 UTC 同步 close 3 项 + create 3 项。<strong>双向同步铁律</strong>:看板→dida 与 dida→看板 均纳入巡检必做项</li> <li><strong>dida365 同步状态</strong>: 2026-08-10 01:25 UTC 同步 close 1 项(sensitive_words <code>6a6bfc2be</code>);2026-08-08 03:52 UTC 同步 close 3 项 + create 3 项。<strong>双向同步铁律</strong>:看板→dida 与 dida→看板 均纳入巡检必做项</li>
</ul> </ul>
<hr/> <hr/>
@@ -939,6 +976,12 @@ footer {
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td><strong>v1.9.4-DRAFT</strong></td>
<td>2026-08-11</td>
<td>🆕 早班巡检触发的治理小版本(<strong>5 项关键发现</strong>):① <strong>🔴 公网版本停滞 3 天</strong>NEW):jumpserver-V2 + 公网 <code>curl</code> 实测 <code>/h5/go</code><code>302 → /h5/v20260808/</code>08-08 09:30 last deploy3 天无新发版);/itservice/go 同样 ② <strong>🔴 P0-NEW9/NEW10 仍 200 暴露</strong>:容器内 <code>curl /health</code> 200 + 公网 <code>/api/test-ping</code> 200 含 pong + <code>/api/test-error</code> 200 + <code>/api/openapi.json</code> 200 OK 424122B312 端点全公开) ③ <strong>🔴 风险 /h5/ 今日到期</strong>dida <code>6a752de4</code> due 2026-08-11 16:00,今晚不修即逾期 ④ <strong>🔴 P0-3 closing_service 时区错位 5 → 8 天</strong>dida <code>6a72c892</code> 仍 status=0due 08-06 已逾期 5 天 ⑤ <strong>🟡 BLK-A/B 30 → 31 天阈值校正</strong>2026-07-11 → 2026-08-11)。<strong>新增</strong>P0-NEW11/api/openapi.json 独立 task,从 P0-NEW10 拆分)+ 风险-2(公网版本停滞)。看板:🔴 P0 6→7 / 🟡 P1 6(持平)/ 🟢 等决策 31 天阈值(30→31)/ ⚠️ 风险项 1→2 / 总任务 109→110。<strong>v1.9.3-DRAFT 1 天未冻结</strong>08-10 09:00 → 08-11 09:00),本次合并入 v1.9.4 待审</td>
<td>Duckula (AI)</td>
</tr>
<tr>
<td><strong>v1.9.3-DRAFT</strong></td> <td><strong>v1.9.3-DRAFT</strong></td>
<td>2026-08-10</td> <td>2026-08-10</td>
<td>🆕 早班巡检触发的治理小版本(<strong>4 项关键发现</strong>):① <strong>P0-1 /itportal/ 500 修复闭环</strong>nginx.conf line 139-142 已 08-03 部署生效,今日公网实测 500→404rewrite cycle 消失),P0-1 从"待部署"迁入"已完成"区(2026-08-03 完成日) ② <strong>dida365 双向校验发现 4 处反向脱节</strong>dida <code>6a6bfc2be</code> sensitive_words 13 端点补 auth 看板已完成但 dida 仍 status=0(本次同步 close+ dida <code>6a705109</code> Alembic 053-057 迁移脱节 / dida <code>6a70510f</code> troubleshooting_templates 索引 / dida <code>6a752de4</code> Nginx /h5/ alias+try_files 潜伏500 看板缺失(补登) ③ <strong>console 残留 128 → 129</strong>h5 不变 / agent 17→20 新增 3 处 / terminal 7→8 新增 1 处) ④ <strong>BLK-A/B 26 → 30 天阈值校正</strong>2026-07-11 → 2026-08-10)。新增 P0-NEW10debug 端点全清单治理)+ P1 治理-2/3(看板-滴答双向同步铁律升级)。看板:🔴 P0 6(持平,P0-1 闭环 + P0-NEW10/ 🟡 P1 2→5Alembic/Idx/治理-2/治理-3 补登)/ ✅ 已完成 97→98 / ⚠️ 风险项 0→1</td> <td>🆕 早班巡检触发的治理小版本(<strong>4 项关键发现</strong>):① <strong>P0-1 /itportal/ 500 修复闭环</strong>nginx.conf line 139-142 已 08-03 部署生效,今日公网实测 500→404rewrite cycle 消失),P0-1 从"待部署"迁入"已完成"区(2026-08-03 完成日) ② <strong>dida365 双向校验发现 4 处反向脱节</strong>dida <code>6a6bfc2be</code> sensitive_words 13 端点补 auth 看板已完成但 dida 仍 status=0(本次同步 close+ dida <code>6a705109</code> Alembic 053-057 迁移脱节 / dida <code>6a70510f</code> troubleshooting_templates 索引 / dida <code>6a752de4</code> Nginx /h5/ alias+try_files 潜伏500 看板缺失(补登) ③ <strong>console 残留 128 → 129</strong>h5 不变 / agent 17→20 新增 3 处 / terminal 7→8 新增 1 处) ④ <strong>BLK-A/B 26 → 30 天阈值校正</strong>2026-07-11 → 2026-08-10)。新增 P0-NEW10debug 端点全清单治理)+ P1 治理-2/3(看板-滴答双向同步铁律升级)。看板:🔴 P0 6(持平,P0-1 闭环 + P0-NEW10/ 🟡 P1 2→5Alembic/Idx/治理-2/治理-3 补登)/ ✅ 已完成 97→98 / ⚠️ 风险项 0→1</td>
@@ -1015,7 +1058,7 @@ footer {
</main> </main>
<footer> <footer>
本页面由 <code>build_kanban_html.py</code> 自动生成于 2026-08-10 09:26 (GMT+8) · 数据源:docs/07-项目管理/项目状态看板.md<br> 本页面由 <code>build_kanban_html.py</code> 自动生成于 2026-08-11 09:26 (GMT+8) · 数据源:docs/07-项目管理/项目状态看板.md<br>
修改流程:编辑 .md → 跑脚本生成 .html → (可选)部署到 jumpserver → commit & push 修改流程:编辑 .md → 跑脚本生成 .html → (可选)部署到 jumpserver → commit & push
</footer> </footer>
</div> </div>
+49 -19
View File
@@ -1,9 +1,37 @@
# 项目状态看板 # 项目状态看板
> **版本**: v1.9.3-DRAFT | **更新日期**: 2026-08-10(早班巡检同步增补,未冻结) > **版本**: v1.9.4-DRAFT | **更新日期**: 2026-08-11(早班巡检同步增补,未冻结)
> **变更来源**: 2026-08-10 09:00 早班巡检:P0-1 itportal 实测已闭环(500→404nginx 08-03 fix 已生效)+ dida365 双向校验发现 4 处脱节(1 项仍 status=0,3 项看板缺失+ console 残留 128→129h5 不变 / agent 17→20 / terminal 7→8+ BLK 26→30 天阈值校正 > **变更来源**: 2026-08-11 09:00 早班巡检:jumpserver-V2 + 公网实测发现 **公网版本停滞 3 天**/h5/go 仍 v2026080808-08 last deploy+ P0-NEW9/NEW10 仍 200 暴露(`/api/test-ping` 返 pong`/api/openapi.json` 200 OK 424122B+ console 残留稳定 129 行 + 风险 /h5/ 今日到期 + BLK 30→31 天阈值校正 + v1.9.3-DRAFT 1 天未冻结(本次合并入 v1.9.4 待审)
> **执行人**: DuckulaAI)· **审核人**: 待 Simon 审核 > **执行人**: DuckulaAI)· **审核人**: 待 Simon 审核
> **基础版本**: v1.9.2-DRAFT2026-08-08 早班巡检) > **基础版本**: v1.9.3-DRAFT2026-08-10 早班巡检)
## 📌 v1.9.4 增补说明(2026-08-11 早班巡检触发)
- **🔴 公网版本停滞 3 天(NEW 关键发现)**jumpserver-V2 + 公网 `curl` 实测 `/h5/go``302 → /h5/v20260808/`08-08 09:30 last deploy);`/itservice/go` 同样指向 v20260808。意味着自 v1.9.3 看板升级(08-10 09:00)至今无新发版,**前端优化 PR 与 dida 任务 P0-NEW9 修复均未发布**。看板 HTML 仍是 v1.9.308-10 09:26 上传)—— 看板与前端代码不同步,但服务器 HTML 仍为最新。
- **🔴 P0-NEW9/NEW10 仍 200 暴露(NEW 实测证据)**jumpserver-V2 容器内 `docker exec wecom_it_backend curl http://127.0.0.1:8000/health``{"status":"ok","service":"wecom-it-smart-desk"}`(健康端点正常);公网 `curl /api/test-ping` → 200 含 "pong";公网 `curl /api/test-error` → 200 含 "服务器内部错误";公网 `/api/openapi.json` → 200 OK 424122B**312 端点全公开 + 攻击者手册级 API 字典**)。三个 P0 任务(P0-NEW9 due 08-12 / P0-NEW10 due 08-13 / 风险 /h5/ due 08-11**全部 status=0 未完成**。
- **🔴 风险 /h5/ 今日到期**dida `6a752de4` due 2026-08-11 16:00(今晚 16:00),未启动修复。潜伏隐患:nginx `location /h5/ alias + try_files $uri /h5/index.html` 当 index.html 缺失即触发 rewrite cycle 500。
- **🔴 P0-3 closing_service 时区错位 5 → 8 天**dida `6a72c892` 仍 status=0due 2026-08-06 已逾期 5 天,**累计 8 天**(自 2026-08-03 首次报错)。Reopen 接口 `TypeError: can't subtract offset-naive and offset-aware datetimes` 持续未修。
- **🟡 BLK-A/B 30 → 31 天阈值校正**2026-07-11 → 2026-08-11 = **31 天**dida 任务仍 status=0 + 已逾期 4 天(due 2026-08-07)。v1.9.3 标 30 天 → v1.9.4 标 31 天。
- **🟡 P1-Alembic / P1-Idx 仍逾期**dida `6a705109` (Alembic 053-057) due 08-09 已逾期 2 天;dida `6a70510f` (troubleshooting_templates 索引) due 08-07 已逾期 4 天;均 status=0 未推进。
- **🟢 v1.9.3-DRAFT 1 天未冻结**v1.9.3 在 08-10 09:00 巡检生成 DRAFT 后 24h 未升级为 FROZEN;本次合并入 v1.9.4 等待 PM 审核冻结(建议通过 FROZEN 后生成 `项目状态看板-v1.9.4-FROZEN.html` 归档)。
- **🟢 服务发布状态**:公网看板 HTML 200 OK 45683B08-10 09:26 = v1.9.3= 本地 45683B 完全匹配;服务器 `/opt/wecom-it-desk/docs-public/kanban/` 目录:当前 45683Bv1.9.3 派生)+ v1.9.0-FROZEN 30765B + v1.9.1-FROZEN 32078B 三份归档。
- **🟢 容器与资源全绿**jumpserver-V2 09:00 实测):
- 5 容器 all healthynginx 18h / backend 35h / redis 3w / neo4j 4w / postgres 4w
- 磁盘 129G 可用(13%),内存 11Gi available,负载 0.64/0.73/0.69**59 天 uptime**
- 后端容器日志:6 文件(active 17.3MB + 5×20MB 轮转,2026-08-08 23:33 最后轮转,3 天前)
- nginx config test OK
- **🟢 dida365 同步状态**v1.9.4 本次巡检前 dida 已记录 11 项(P0-NEW9/NEW10 + P0-3 + P1-1.1 + P1-治理-1 + P1-Alembic + P1-Idx + BLK-A + BLK-B + 风险 /h5/);v1.9.3 同步的 1 项 `6a6bfc2be` sensitive_words 13 端点 仍 status=2 闭环;新增 P0-NEW11 与 P1-1.1 二次治理作为新治理项。
> 📐 **文档管理策略(2026-08-05 锁定 / 2026-08-06 修订 / v1.9.1 生效)**
> 本看板遵循 **Markdown 单一源原则**
> - **权威源** = 本 `项目状态看板.md`(Git 版本控制 / 程序解析 / PR review 均基于此)
> - **人看副本** = `项目状态看板.html`(由 `scripts/build_kanban_html.py` 自动派生,**不手维护**
> - **归档副本** = `项目状态看板-v1.X.Y-FROZEN.html`(每次冻结生成一份,永久保留)
> - **服务发布** = 文件上传至 jumpserver `/opt/wecom-it-desk/docs-public/{kanban,inspection}/`**✅ v1.9.1 起已可对外访问**nginx.conf 加 `location /docs/` 路由 + docker-compose.yml 加 docs-public 挂载 + reload 已生效。外部 URL 实测:
> - 看板:https://itsupport.servyou.com.cn/docs/kanban/项目状态看板.html
> - 巡检:https://itsupport.servyou.com.cn/docs/inspection/2026-08/2026-08-06-早班.html
> - 修改流程:改 .md → 跑脚本生成 .html → 归档副本 → 跑部署脚本上传 → commit & push
---
## 📌 v1.9.3 增补说明(2026-08-10 早班巡检触发) ## 📌 v1.9.3 增补说明(2026-08-10 早班巡检触发)
- **🔴 P0-1 `/itportal/` 实测已闭环**08-03 nginx 配置清理(line 139-142 明确注释)已部署生效,今日公网 `curl /itportal/` 实测 = **HTTP 404**(之前 500 是 rewrite cycle bug,现已不存在)。**bug 已修复**,但看板标"待部署"是 08-06 旧观察结果 → 迁移至"已完成"区(2026-08-03 - **🔴 P0-1 `/itportal/` 实测已闭环**08-03 nginx 配置清理(line 139-142 明确注释)已部署生效,今日公网 `curl /itportal/` 实测 = **HTTP 404**(之前 500 是 rewrite cycle bug,现已不存在)。**bug 已修复**,但看板标"待部署"是 08-06 旧观察结果 → 迁移至"已完成"区(2026-08-03
@@ -38,15 +66,15 @@
| 状态 | 数量 | 变化 | | 状态 | 数量 | 变化 |
|------|------|------| |------|------|------|
| 🔴 P0 必做 | 6 | 持平(P0-1 itportal 已迁移至已完成,+1 P0-NEW10 debug 端点全清单 | | 🔴 P0 必做 | 7 | +1P0-NEW11 /api/openapi.json 312 端点公开治理 |
| 🟡 P1 重要 | 6 | +4P1-治理-2 双向脱节闭环 + P1-治理-3 dida→看板反向同步 + P1-Alembic 补登 + P1-Idx 补登;P1-治理-sens 是 dida close 同步不入此列) | | 🟡 P1 重要 | 6 | 持平 |
| 🟢 等用户决策 | 2 | 持平(**30 天阈值**,从 26 天修正) | | 🟢 等用户决策 | 2 | 持平(**31 天阈值**,从 30 天修正) |
| 🟠 进行中 | 1 | 持平 | | 🟠 进行中 | 1 | 持平 |
| 🏷️ 看板治理 | 3 | +2(P1 治理-2/3 双源同步铁律升级) | | 🏷️ 看板治理 | 3 | 持平 |
| 🟡 待开始 | 0 | 持平 | | 🟡 待开始 | 0 | 持平 |
| ⏸️ 暂停 | 5 | 持平 | | ⏸️ 暂停 | 5 | 持平 |
| ✅ 已完成 | 98 | +1P0-1 /itportal/ 修复闭环入账 2026-08-03 | | ✅ 已完成 | 98 | 持平 |
| ⚠️ 风险项 | 1 | +1Nginx /h5/ alias+try_files 潜伏 500 隐患,dida `6a752de4` | | ⚠️ 风险项 | 2 | +1公网版本停滞 3 天,/h5/go 仍 v2026080808-08 09:30 last deploy |
--- ---
@@ -57,14 +85,15 @@
| 任务ID | 任务名称 | 类别 | 阻塞时长 | 状态 | 说明 | | 任务ID | 任务名称 | 类别 | 阻塞时长 | 状态 | 说明 |
|--------|----------|------|----------|------|------| |--------|----------|------|----------|------|------|
| **P0-3** | `closing_service.py:467` datetime 时区错位 | ♻️ 历史遗留 | **7**(自 2026-08-03 | 🔴 待修复 | `datetime.now() - close_time``TypeError: can't subtract offset-naive and offset-aware datetimes`。影响 `POST /h5/conversations/current/reopen`。同文件 18 处 `datetime.now()` 全是 naive,且 `from datetime import datetime, timedelta` 未 import timezone。24h 内未触发,但用户使用 H5 重开会话即会爆。**v1.9.3 校正**:阻塞时长从 2 天7 天 | | **P0-3** | `closing_service.py:467` datetime 时区错位 | ♻️ 历史遗留 | **8**(自 2026-08-03 | 🔴 待修复 | `datetime.now() - close_time``TypeError: can't subtract offset-naive and offset-aware datetimes`。影响 `POST /h5/conversations/current/reopen`。同文件 18 处 `datetime.now()` 全是 naive,且 `from datetime import datetime, timedelta` 未 import timezone。dida `6a72c892` due 2026-08-06 已逾期 5 天,**v1.9.4 校正**:阻塞时长 78 天 |
| **P0-4** | `employee_profile_service.py:236` SessionLocal NoneType 🔥 | 🆕 位置修正 | <1 天 | 🔴 待修复 | **看板 v1.8 行号描述错误**:实际错误位置是 `app/services/employee_profile_service.py:236` 而非 `h5_ai_task.py:1198``db = SessionLocal()` 返回 None → `TypeError: 'NoneType' object is not callable`。2026-08-05 24h 内累计 10+ 次(01:33 / 03:25 / 04:07 / 04:11 多波次),影响 H5 IT 资产推荐异步推送。**同根因 P0-5** | | **P0-4** | `employee_profile_service.py:236` SessionLocal NoneType 🔥 | 🆕 位置修正 | <1 天 | 🔴 待修复 | **看板 v1.8 行号描述错误**:实际错误位置是 `app/services/employee_profile_service.py:236` 而非 `h5_ai_task.py:1198``db = SessionLocal()` 返回 None → `TypeError: 'NoneType' object is not callable`。2026-08-05 24h 内累计 10+ 次(01:33 / 03:25 / 04:07 / 04:11 多波次),影响 H5 IT 资产推荐异步推送。**同根因 P0-5** |
| **P0-5** | 容器 `app/constants/` 打包错误 🔥 | 🆕 新增 | 🆕 | 🔴 待修复 | 容器内 `/app/app/constants/__init__.py` 11635 bytes 内容是 automation.py(两个文件互换了),`/app/app/constants/automation.py` **不存在**。直接后果:连续 10+ 次 `ModuleNotFoundError: No module named 'app.constants.ai_reply_mode'` + 同根因连锁导致 P0-4SessionLocal 绑定失败)。建议:① 临时 `docker cp` 修补 → ② 长期排查 backend 镜像构建流程(Dockerfile `COPY . .` 是 OK 的,最可能是 deploy 脚本里将 automation.py 重命名为 `__init__.py` | | **P0-5** | 容器 `app/constants/` 打包错误 🔥 | 🆕 新增 | 🆕 | 🔴 待修复 | 容器内 `/app/app/constants/__init__.py` 11635 bytes 内容是 automation.py(两个文件互换了),`/app/app/constants/automation.py` **不存在**。直接后果:连续 10+ 次 `ModuleNotFoundError: No module named 'app.constants.ai_reply_mode'` + 同根因连锁导致 P0-4SessionLocal 绑定失败)。建议:① 临时 `docker cp` 修补 → ② 长期排查 backend 镜像构建流程(Dockerfile `COPY . .` 是 OK 的,最可能是 deploy 脚本里将 automation.py 重命名为 `__init__.py` |
| **P0-6** | 后端 OAuth 40029 抖动(用户码失效) | ℹ️ 信息类 | 持续 | 🟢 已知噪声 | 218.75.34.87 等外部 IP 持续试探 `/api/auth/validate-sso` + 偶发 OAuth code 失效。属 WAF 扫描背景噪声,已 fail2ban 监控。无需主动处理 | | **P0-6** | 后端 OAuth 40029 抖动(用户码失效) | ℹ️ 信息类 | 持续 | 🟢 已知噪声 | 218.75.34.87 等外部 IP 持续试探 `/api/auth/validate-sso` + 偶发 OAuth code 失效。属 WAF 扫描背景噪声,已 fail2ban 监控。无需主动处理 |
| **P0-NEW7** | 看板-部署脱节:nginx `/docs/` 路由缺失 | ✅ 已修复 | 🆕 | ✅ 已修复 | **2026-08-06 11:46 已完成**:① docker-compose.yml 加 `docs-public` 卷挂载 ② nginx.conf 加 `location /docs/ { alias /opt/wecom-it-desk/docs-public/; charset utf-8; add_header Cache-Control "no-cache, must-revalidate"; ... }``docker compose up -d --force-recreate nginx` + `nginx -t` + `nginx -s reload`。**jumpserver-V2 实测**:容器内 `curl -sI http://127.0.0.1/docs/kanban/项目状态看板.html`**200**30765BCache-Control no-cache)。外部 URL `https://itsupport.servyou.com.cn/docs/kanban/项目状态看板.html` 实测完整渲染 v1.9.0-FROZEN 内容 ✓ | | **P0-NEW7** | 看板-部署脱节:nginx `/docs/` 路由缺失 | ✅ 已修复 | 🆕 | ✅ 已修复 | **2026-08-06 11:46 已完成**:① docker-compose.yml 加 `docs-public` 卷挂载 ② nginx.conf 加 `location /docs/ { alias /opt/wecom-it-desk/docs-public/; charset utf-8; add_header Cache-Control "no-cache, must-revalidate"; ... }``docker compose up -d --force-recreate nginx` + `nginx -t` + `nginx -s reload`。**jumpserver-V2 实测**:容器内 `curl -sI http://127.0.0.1/docs/kanban/项目状态看板.html`**200**30765BCache-Control no-cache)。外部 URL `https://itsupport.servyou.com.cn/docs/kanban/项目状态看板.html` 实测完整渲染 v1.9.0-FROZEN 内容 ✓ |
| **P0-NEW8** | host 文件结构 vs git 仓库结构差异 | 🆕 新增 | 🆕 | 🔴 待修复 | **2026-08-06 11:46 P0-NEW7 修复期间发现**:本地 git 仓库结构是 `src/backend/`,主机实际结构是 `app/`admin:admin, Aug 3),两者不一致。`docker-compose.yml``backend.context: ./src/backend` + `./src/backend/app:/app/app` 在主机上不存在对应目录,导致 `docker compose up -d --force-recreate backend` 时挂载空目录、容器内 `/app/app` 为空、uvicorn 找不到 `app.main`、启动失败。**临时回退**docker-compose.yml 改 `context: ./backend` + `- ./app:/app/app` 适配主机,backend 容器已恢复。但长期需要:① 决定 host 是否同步 git 仓库 ② 是否启用 build context 镜像构建 ③ 迁移路径方案评估 | | **P0-NEW8** | host 文件结构 vs git 仓库结构差异 | 🆕 新增 | 🆕 | 🔴 待修复 | **2026-08-06 11:46 P0-NEW7 修复期间发现**:本地 git 仓库结构是 `src/backend/`,主机实际结构是 `app/`admin:admin, Aug 3),两者不一致。`docker-compose.yml``backend.context: ./src/backend` + `./src/backend/app:/app/app` 在主机上不存在对应目录,导致 `docker compose up -d --force-recreate backend` 时挂载空目录、容器内 `/app/app` 为空、uvicorn 找不到 `app.main`、启动失败。**临时回退**docker-compose.yml 改 `context: ./backend` + `- ./app:/app/app` 适配主机,backend 容器已恢复。但长期需要:① 决定 host 是否同步 git 仓库 ② 是否启用 build context 镜像构建 ③ 迁移路径方案评估 |
| **P0-NEW9** | main.py 调试端点(test-ping/test-error)生产暴露 | 🆕 新增 | 🆕 | 🔴 待修复 | **2026-08-08 06:00 早班巡检发现**src/backend/app/main.py:963-971 注册的 `/test-ping``/test-error` 等诊断端点**未走任何环境分支保护**(注释明确写「生产环境删除」,但 main.py `_is_dev_mode()` 仅作用于 :932 周边)。**v1.9.3 公网实测(2026-08-10 09:00**`GET https://itsupport.servyou.com.cn/api/test-ping` 仍返 200 OK 含 `"pong"` — 仍未修复。建议:① 短期 `nginx location /api/test-*` 限制 internal only;② 长期用 `if _is_dev_mode():` 包裹整个诊断区段或移至 `app/api/debug.py` 仅 dev 加载。伴随项:建议同步治理 `/api/openapi.json` 312 端点公开(含 106 admin + 29 auth | | **P0-NEW9** | main.py 调试端点(test-ping/test-error)生产暴露 | 🆕 新增 | 🆕 | 🔴 待修复 | **2026-08-08 06:00 早班巡检发现**src/backend/app/main.py:963-971 注册的 `/test-ping``/test-error` 等诊断端点**未走任何环境分支保护**(注释明确写「生产环境删除」,但 main.py `_is_dev_mode()` 仅作用于 :932 周边)。**v1.9.3 公网实测(2026-08-10 09:00**`GET https://itsupport.servyou.com.cn/api/test-ping` 仍返 200 OK 含 `"pong"` — 仍未修复。建议:① 短期 `nginx location /api/test-*` 限制 internal only;② 长期用 `if _is_dev_mode():` 包裹整个诊断区段或移至 `app/api/debug.py` 仅 dev 加载。伴随项:建议同步治理 `/api/openapi.json` 312 端点公开(含 106 admin + 29 auth |
| **P0-NEW10** | 后端 debug 端点全清单治理(test-ping/test-error/health/ready/metrics/version/openapi.json | 🆕 新增 | 🆕 | 🔴 待修复 | **2026-08-10 09:00 早班巡检发现**P0-NEW9 仅涉及 `/test-ping` + `/test-error` 两个端点,但 `src/backend/app/main.py` 实际注册的诊断端点全集更广——line 978 `@app.get("/health", tags=["系统"])` + 之前遗漏的 `/ready` / `/metrics` / `/version` + `/api/openapi.json`FastAPI 默认开放,312 端点全公开)。建议批量治理:① `app/api/debug.py` 集中所有诊断端点,仅 dev/staging 加载 ② FastAPI 实例化 `docs_url=None, redoc_url=None, openapi_url=None` 在生产关闭 ③ nginx `location ~ ^/api/(test-|debug/|openapi.json)` return 404 外部 | | **P0-NEW10** | 后端 debug 端点全清单治理(test-ping/test-error/health/ready/metrics/version/openapi.json | 🆕 新增 | 🆕 | 🔴 待修复 | **2026-08-10 09:00 早班巡检发现**P0-NEW9 仅涉及 `/test-ping` + `/test-error` 两个端点,但 `src/backend/app/main.py` 实际注册的诊断端点全集更广——line 978 `@app.get("/health", tags=["系统"])` + 之前遗漏的 `/ready` / `/metrics` / `/version` + `/api/openapi.json`FastAPI 默认开放,312 端点全公开)。**v1.9.4 公网实测(08-11 09:00**`/api/test-ping` 仍 200 pong + `/api/test-error` 仍 200 返"服务器内部错误" + `/api/openapi.json` 仍 200 OK 424122B。建议批量治理:① `app/api/debug.py` 集中所有诊断端点,仅 dev/staging 加载 ② FastAPI 实例化 `docs_url=None, redoc_url=None, openapi_url=None` 在生产关闭 ③ nginx `location ~ ^/api/(test-|debug/|openapi.json)` return 404 外部 |
| **P0-NEW11** | /api/openapi.json 生产公开 312 端点治理(攻击者字典级暴露) | 🆕 新增 | 🆕 | 🔴 待修复 | **2026-08-11 09:00 早班巡检实测**`curl -sI https://itsupport.servyou.com.cn/api/openapi.json` → 200 OK 424122B**完整 API 字典**含 106 admin + 29 auth 端点)。P0-NEW10 文字已覆盖但无独立 task,建议单列以便追踪进度。修复方案:FastAPI 实例化 `openapi_url=None`(生产)+ 同步 `docs_url=None, redoc_url=None`nginx 层 `location = /api/openapi.json { return 404; }` 兜底 |
**滴答清单跟踪**9 项 P0 已在滴答清单 `wecom_it_smart_desk` 项目下建任务(标签 `work`)。**v1.9.3 增补** **滴答清单跟踪**9 项 P0 已在滴答清单 `wecom_it_smart_desk` 项目下建任务(标签 `work`)。**v1.9.3 增补**
- T11 = `6a7928a2e4b068980437bb15` [P0-NEW10] debug 端点全清单治理(test-ping/test-error/health/ready/metrics/version/openapi.json),due 2026-08-13 17:00 - T11 = `6a7928a2e4b068980437bb15` [P0-NEW10] debug 端点全清单治理(test-ping/test-error/health/ready/metrics/version/openapi.json),due 2026-08-13 17:00
@@ -92,12 +121,12 @@
## 🟢 等用户决策(阻塞项 / Awaiting Decision ## 🟢 等用户决策(阻塞项 / Awaiting Decision
> 均超 3 天阈值,需 PM 升级催办。**v1.9.3 阈值校正**26 天 → **30 天**2026-07-11 → 2026-08-10 > 均超 3 天阈值,需 PM 升级催办。**v1.9.4 阈值校正**30 天 → **31 天**2026-07-11 → 2026-08-11
| 任务ID | 任务名称 | 阻塞时长 | 卡点 / Owner | 说明 | | 任务ID | 任务名称 | 阻塞时长 | 卡点 / Owner | 说明 |
|--------|----------|----------|--------------|------| |--------|----------|----------|--------------|------|
| **BLK-A** | 企微会议室 Secret | ⚠️ **30**(自 2026-07-11) | 需企业微信管理后台申请 / Owner: 平台组 | 影响会议室预定功能 / `/itterminal/` | | **BLK-A** | 企微会议室 Secret | ⚠️ **31**(自 2026-07-11) | 需企业微信管理后台申请 / Owner: 平台组 | 影响会议室预定功能 / `/itterminal/` |
| **BLK-B** | ITSM API 授权 | ⚠️ **30**(自 2026-07-11 | 需向 ITSM 平台方申请 app_id/app_secret / Owner: 平台组 | 影响 ITSM 工单卡片跳转 | | **BLK-B** | ITSM API 授权 | ⚠️ **31**(自 2026-07-11 | 需向 ITSM 平台方申请 app_id/app_secret / Owner: 平台组 | 影响 ITSM 工单卡片跳转 |
**催办机制**:建议 PM 每周一 review(滴答清单循环任务)。dida task `6a7008e9e4b03a0a8b2870ba` (BLK-A) + `6a7008e9e4b06440c396f6c2` (BLK-B)due 已逾期(2026-08-07)。 **催办机制**:建议 PM 每周一 review(滴答清单循环任务)。dida task `6a7008e9e4b03a0a8b2870ba` (BLK-A) + `6a7008e9e4b06440c396f6c2` (BLK-B)due 已逾期(2026-08-07)。
@@ -204,15 +233,15 @@
## 📈 任务统计 ## 📈 任务统计
- **总任务数**: 109v1.9.3 = v1.9.2 104 + P0-NEW10 + P1-Alembic + P1-Idx + P1 治理-2 + P1 治理-3 - P0-1 闭环迁移 - **总任务数**: 110v1.9.4 = v1.9.3 109 + P0-NEW11 /api/openapi.json 独立 task
- **已完成**: 98v1.9.2 97 + P0-1 itportal 闭环迁移 + sensitive_words dida 同步 1 项 - **已完成**: 98持平
- **🔴 P0 必做**: 6P0-3 / 4 / 5 / 6 / NEW8 / NEW9 / NEW10NEW7 已修复;P0-1 已闭环) - **🔴 P0 必做**: 7P0-3 / 4 / 5 / 6 / NEW8 / NEW9 / NEW10 / **NEW11**;NEW7 已修复;P0-1 已闭环)
- **🟡 P1 重要**: 6P1-1.1 / 治理-1 / 治理-2 / 治理-3 / P1-Alembic / P1-IdxP1-治理-sens 是 dida close 同步不入待办列) - **🟡 P1 重要**: 6P1-1.1 / 治理-1 / 治理-2 / 治理-3 / P1-Alembic / P1-IdxP1-治理-sens 是 dida close 同步不入待办列)
- **🟢 等用户决策**: 2BLK-A/B**均超 30 天阈值** - **🟢 等用户决策**: 2BLK-A/B**均超 31 天阈值**
- **🟠 进行中**: 1#81 v1.2 待排期) - **🟠 进行中**: 1#81 v1.2 待排期)
- **🟡 待开始**: 0 - **🟡 待开始**: 0
- **⏸️ 暂停**: 5(安全策略检查平台) - **⏸️ 暂停**: 5(安全策略检查平台)
- **⚠️ 风险项**: 1Nginx /h5/ alias+try_files 潜伏 500 隐患dida `6a752de4` - **⚠️ 风险项**: 2(风险-1 Nginx /h5/ alias+try_files 潜伏 500 隐患 dida `6a752de4` + 风险-2 公网版本停滞 3 天 `/h5/go` 仍 v20260808
- **dida365 同步状态**: 2026-08-10 01:25 UTC 同步 close 1 项(sensitive_words `6a6bfc2be`);2026-08-08 03:52 UTC 同步 close 3 项 + create 3 项。**双向同步铁律**:看板→dida 与 dida→看板 均纳入巡检必做项 - **dida365 同步状态**: 2026-08-10 01:25 UTC 同步 close 1 项(sensitive_words `6a6bfc2be`);2026-08-08 03:52 UTC 同步 close 3 项 + create 3 项。**双向同步铁律**:看板→dida 与 dida→看板 均纳入巡检必做项
--- ---
@@ -230,6 +259,7 @@
| 版本 | 日期 | 变更内容 | 变更人 | | 版本 | 日期 | 变更内容 | 变更人 |
|------|------|----------|--------| |------|------|----------|--------|
| **v1.9.4-DRAFT** | 2026-08-11 | 🆕 早班巡检触发的治理小版本(**5 项关键发现**):① **🔴 公网版本停滞 3 天**NEW):jumpserver-V2 + 公网 `curl` 实测 `/h5/go``302 → /h5/v20260808/`08-08 09:30 last deploy3 天无新发版);/itservice/go 同样 ② **🔴 P0-NEW9/NEW10 仍 200 暴露**:容器内 `curl /health` 200 + 公网 `/api/test-ping` 200 含 pong + `/api/test-error` 200 + `/api/openapi.json` 200 OK 424122B312 端点全公开) ③ **🔴 风险 /h5/ 今日到期**dida `6a752de4` due 2026-08-11 16:00,今晚不修即逾期 ④ **🔴 P0-3 closing_service 时区错位 5 → 8 天**dida `6a72c892` 仍 status=0due 08-06 已逾期 5 天 ⑤ **🟡 BLK-A/B 30 → 31 天阈值校正**2026-07-11 → 2026-08-11)。**新增**P0-NEW11/api/openapi.json 独立 task,从 P0-NEW10 拆分)+ 风险-2(公网版本停滞)。看板:🔴 P0 6→7 / 🟡 P1 6(持平)/ 🟢 等决策 31 天阈值(30→31)/ ⚠️ 风险项 1→2 / 总任务 109→110。**v1.9.3-DRAFT 1 天未冻结**08-10 09:00 → 08-11 09:00),本次合并入 v1.9.4 待审 | Duckula (AI) |
| **v1.9.3-DRAFT** | 2026-08-10 | 🆕 早班巡检触发的治理小版本(**4 项关键发现**):① **P0-1 /itportal/ 500 修复闭环**nginx.conf line 139-142 已 08-03 部署生效,今日公网实测 500→404rewrite cycle 消失),P0-1 从"待部署"迁入"已完成"区(2026-08-03 完成日) ② **dida365 双向校验发现 4 处反向脱节**dida `6a6bfc2be` sensitive_words 13 端点补 auth 看板已完成但 dida 仍 status=0(本次同步 close+ dida `6a705109` Alembic 053-057 迁移脱节 / dida `6a70510f` troubleshooting_templates 索引 / dida `6a752de4` Nginx /h5/ alias+try_files 潜伏500 看板缺失(补登) ③ **console 残留 128 → 129**h5 不变 / agent 17→20 新增 3 处 / terminal 7→8 新增 1 处) ④ **BLK-A/B 26 → 30 天阈值校正**2026-07-11 → 2026-08-10)。新增 P0-NEW10debug 端点全清单治理)+ P1 治理-2/3(看板-滴答双向同步铁律升级)。看板:🔴 P0 6(持平,P0-1 闭环 + P0-NEW10/ 🟡 P1 2→5Alembic/Idx/治理-2/治理-3 补登)/ ✅ 已完成 97→98 / ⚠️ 风险项 0→1 | Duckula (AI) | | **v1.9.3-DRAFT** | 2026-08-10 | 🆕 早班巡检触发的治理小版本(**4 项关键发现**):① **P0-1 /itportal/ 500 修复闭环**nginx.conf line 139-142 已 08-03 部署生效,今日公网实测 500→404rewrite cycle 消失),P0-1 从"待部署"迁入"已完成"区(2026-08-03 完成日) ② **dida365 双向校验发现 4 处反向脱节**dida `6a6bfc2be` sensitive_words 13 端点补 auth 看板已完成但 dida 仍 status=0(本次同步 close+ dida `6a705109` Alembic 053-057 迁移脱节 / dida `6a70510f` troubleshooting_templates 索引 / dida `6a752de4` Nginx /h5/ alias+try_files 潜伏500 看板缺失(补登) ③ **console 残留 128 → 129**h5 不变 / agent 17→20 新增 3 处 / terminal 7→8 新增 1 处) ④ **BLK-A/B 26 → 30 天阈值校正**2026-07-11 → 2026-08-10)。新增 P0-NEW10debug 端点全清单治理)+ P1 治理-2/3(看板-滴答双向同步铁律升级)。看板:🔴 P0 6(持平,P0-1 闭环 + P0-NEW10/ 🟡 P1 2→5Alembic/Idx/治理-2/治理-3 补登)/ ✅ 已完成 97→98 / ⚠️ 风险项 0→1 | Duckula (AI) |
| v1.9.2-DRAFT | 2026-08-08 | 🆕 早班巡检触发的治理小版本:**dida365 自动同步 6 项**close 3 / create 3)——close `6a6bfc29` [P0→P3] /h5/ 404、close `6a72c88d` [P0-2] sensitive_words 13 端点、close `6a72c897` [P1-1] console 208 行(但发现仍残留 128 行,二次治理由 P1-1.1 承担);create `6a76a80d` [P0-NEW9] /api/test-ping 生产暴露、create `6a76a802` [P1-1.1] console 二次清理、create `6a76a809` [P1 治理-看板-滴答双源同步]。看板:🔴 P0 5→6 / 🟡 P1 0→2 / ✅ 已完成 94→97(基础计数 v1.9.1 100 + P0-NEW9 + P1-1.1 + P1 治理-1 = 104)。同步时间戳:2026-08-08 03:52 UTC | Duckula (AI) | | v1.9.2-DRAFT | 2026-08-08 | 🆕 早班巡检触发的治理小版本:**dida365 自动同步 6 项**close 3 / create 3)——close `6a6bfc29` [P0→P3] /h5/ 404、close `6a72c88d` [P0-2] sensitive_words 13 端点、close `6a72c897` [P1-1] console 208 行(但发现仍残留 128 行,二次治理由 P1-1.1 承担);create `6a76a80d` [P0-NEW9] /api/test-ping 生产暴露、create `6a76a802` [P1-1.1] console 二次清理、create `6a76a809` [P1 治理-看板-滴答双源同步]。看板:🔴 P0 5→6 / 🟡 P1 0→2 / ✅ 已完成 94→97(基础计数 v1.9.1 100 + P0-NEW9 + P1-1.1 + P1 治理-1 = 104)。同步时间戳:2026-08-08 03:52 UTC | Duckula (AI) |
| v1.9.1 补录2 | 2026-08-07 | ✅ 【P0】#104 运行期结构化日志查看页 **结案**jumpserver-V2 生产实测四项证据齐备(管理页 200 / 前端 chunk 含筛选+下载命中行 / 后端端点已挂载(4004 = #48 IP 白名单预期门禁,非故障)/ 日志源 JSON 持续写入)。从「待开始」区迁入「最近完成」区;待开始 0 项;已完成 93→94。滴答清单同步完成 | Duckula (AI) | | v1.9.1 补录2 | 2026-08-07 | ✅ 【P0】#104 运行期结构化日志查看页 **结案**jumpserver-V2 生产实测四项证据齐备(管理页 200 / 前端 chunk 含筛选+下载命中行 / 后端端点已挂载(4004 = #48 IP 白名单预期门禁,非故障)/ 日志源 JSON 持续写入)。从「待开始」区迁入「最近完成」区;待开始 0 项;已完成 93→94。滴答清单同步完成 | Duckula (AI) |
@@ -84,29 +84,63 @@
<div class="apv-action-hint"> <div class="apv-action-hint">
审批动作需由审批人本人在企微审批中完成点击下方按钮将在新标签页打开该审批单 审批动作需由审批人本人在企微审批中完成点击下方按钮将在新标签页打开该审批单
</div> </div>
<!-- ================================================================== -->
<!-- 底部操作按钮 PRD-REQ-坐席-011 §6.4 决策 C-8主操作 + 收纳 -->
<!-- ================================================================== -->
<div class="tic-actions"> <div class="tic-actions">
<!-- 通过/拒绝/转交三个动作共用同一个深链仅作为跳转入口 --> <!-- 主操作按状态驱动sp_status=1审批通过 / 其他在企微审批中打开 -->
<a <a
v-for="item in approvalActions"
:key="item.action"
class="tic-action-btn" class="tic-action-btn"
:href="wecomApprovalUrl" :class="`tic-action-${mainAction.variant}`"
:href="mainAction.href"
:disabled="mainAction.disabled"
:title="mainAction.tooltip || mainAction.label"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@click="handleApprovalAction(item.action)" @click="handleApprovalAction(mainAction.key)"
> >
{{ item.label }} {{ mainAction.label }}
</a> </a>
<!-- 次要动作收纳按钮 sp_status=1 显示拒绝/转交/加签 -->
<div class="task-actions-menu-wrapper" v-if="moreActions.length > 0">
<button
class="tic-action-btn tic-action-more-btn"
type="button"
:aria-expanded="isMoreMenuOpen"
aria-haspopup="menu"
aria-label="更多操作"
title="更多操作"
@click="toggleMoreMenu"
>
</button>
<ul v-if="isMoreMenuOpen" class="task-actions-menu" role="menu">
<li
v-for="item in moreActions"
:key="item.key"
class="task-actions-menu__item"
:class="{ 'is-danger': item.danger, 'is-disabled': item.disabled }"
role="menuitem"
:title="item.tooltip || item.label"
>
<a <a
class="tic-action-btn tic-action-primary" v-if="item.href && !item.disabled"
:href="wecomApprovalUrl" :href="item.href"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@click="handleApprovalAction('open')" @click="handleApprovalAction(item.key); closeMoreMenu()"
> >
🔗 在企微审批中打开 <span class="task-actions-menu__icon">{{ item.icon }}</span>
<span>{{ item.label }}</span>
</a> </a>
<span v-else class="task-actions-menu__label-disabled">
<span class="task-actions-menu__icon">{{ item.icon }}</span>
<span>{{ item.label }}</span>
</span>
</li>
</ul>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -115,8 +149,13 @@
// ============================================================================ // ============================================================================
// 导入 // 导入
// ============================================================================ // ============================================================================
import { ref, computed } from 'vue' import { ref, computed, onBeforeUnmount } from 'vue'
import type { TodoItemData } from '@/api/todo' import type { TodoItemData } from '@/api/todo'
import {
useTaskActions,
type TaskMainAction,
type TaskMoreAction,
} from '@/composables/useTaskActions'
// ============================================================================ // ============================================================================
// Props // Props
@@ -140,17 +179,6 @@ interface Emits {
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
// ============================================================================
// 常量
// ============================================================================
/** 审批动作列表(均降级为跳转企微审批原系统) */
const approvalActions: ReadonlyArray<{ action: string; label: string }> = [
{ action: 'approve', label: '✅ 通过' },
{ action: 'reject', label: '❌ 拒绝' },
{ action: 'transfer', label: '🔄 转交' },
]
// ============================================================================ // ============================================================================
// 状态 // 状态
// ============================================================================ // ============================================================================
@@ -159,10 +187,9 @@ const approvalActions: ReadonlyArray<{ action: string; label: string }> = [
const approvalComment = ref<string>('') const approvalComment = ref<string>('')
// ============================================================================ // ============================================================================
// 计算属性 // 企微审批跳转链接(与 useTaskActions 工厂入参共用)
// ============================================================================ // ============================================================================
/** 企微审批跳转链接 */
const wecomApprovalUrl = computed<string>(() => { const wecomApprovalUrl = computed<string>(() => {
const spNo = props.todoItem.description?.sp_no const spNo = props.todoItem.description?.sp_no
const templateId = props.todoItem.description?.template_id const templateId = props.todoItem.description?.template_id
@@ -173,6 +200,62 @@ const wecomApprovalUrl = computed<string>(() => {
return `https://app.work.weixin.qq.com/wework_admin/approval_v3#/?${params.toString()}` return `https://app.work.weixin.qq.com/wework_admin/approval_v3#/?${params.toString()}`
}) })
// ============================================================================
// 操作配置(PRD-REQ-坐席-011 §6.4 决策 C-8:主操作 + ⋯ 收纳)
// ============================================================================
const { main: mainActionRef, more: moreActionsRef } = useTaskActions({
todoItem: props.todoItem,
wecomApprovalUrl: wecomApprovalUrl.value,
})
const mainAction = computed<TaskMainAction>(() => mainActionRef.value)
const moreActions = computed<TaskMoreAction[]>(() => moreActionsRef.value)
// ============================================================================
// ⋯ 菜单状态
// ============================================================================
const isMoreMenuOpen = ref<boolean>(false)
function toggleMoreMenu(event: MouseEvent): void {
event.stopPropagation()
isMoreMenuOpen.value = !isMoreMenuOpen.value
if (isMoreMenuOpen.value) {
setTimeout(() => {
document.addEventListener('mousedown', handleClickOutside, true)
document.addEventListener('keydown', handleKeydown, true)
}, 0)
} else {
closeMoreMenu()
}
}
function closeMoreMenu(): void {
isMoreMenuOpen.value = false
document.removeEventListener('mousedown', handleClickOutside, true)
document.removeEventListener('keydown', handleKeydown, true)
}
function handleClickOutside(event: MouseEvent): void {
const target = event.target as HTMLElement | null
if (target && target.closest('.task-actions-menu-wrapper')) return
closeMoreMenu()
}
function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape' || event.key === 'Esc') closeMoreMenu()
}
onBeforeUnmount(() => {
document.removeEventListener('mousedown', handleClickOutside, true)
document.removeEventListener('keydown', handleKeydown, true)
})
// ============================================================================
// 计算属性
// ============================================================================
/** 审批状态 CSS 类 */ /** 审批状态 CSS 类 */
const apvStatusClass = computed<string>(() => { const apvStatusClass = computed<string>(() => {
const status = props.todoItem.description?.sp_status const status = props.todoItem.description?.sp_status
@@ -352,6 +435,8 @@ function formatApplyTime(applyTime: any): string {
gap: 8px; gap: 8px;
padding: 8px 0; padding: 8px 0;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center;
position: relative;
} }
.tic-action-btn { .tic-action-btn {
@@ -376,6 +461,13 @@ function formatApplyTime(applyTime: any): string {
color: var(--accent); color: var(--accent);
} }
/* v1.8 · 决策 C-8 · 主按钮 padding 视觉密度补偿 */
.tic-action-btn.tic-action-primary,
.tic-action-btn.tic-action-success {
padding: 8px 22px;
font-weight: 500;
}
.tic-action-primary { .tic-action-primary {
background-color: var(--accent); background-color: var(--accent);
color: var(--bg-secondary); color: var(--bg-secondary);
@@ -386,4 +478,110 @@ function formatApplyTime(applyTime: any): string {
background-color: var(--accent-hover); background-color: var(--accent-hover);
color: var(--bg-secondary); color: var(--bg-secondary);
} }
.tic-action-success {
background-color: var(--color-success);
color: var(--bg-secondary);
border-color: var(--color-success);
}
.tic-action-success:hover {
filter: brightness(0.95);
color: var(--bg-secondary);
}
.tic-action-default {
background-color: var(--bg-secondary);
color: var(--text-tertiary);
border-color: var(--border-color);
cursor: not-allowed;
}
.tic-action-btn[disabled] {
cursor: not-allowed;
opacity: 0.6;
}
/* ---- v1.8 · ⋯ 次要动作收纳 ---- */
.task-actions-menu-wrapper {
position: relative;
display: inline-flex;
}
.tic-action-more-btn {
width: 36px;
padding: 8px 0;
justify-content: center;
font-size: 16px;
line-height: 1;
letter-spacing: 1px;
}
.tic-action-more-btn[aria-expanded='true'] {
background-color: var(--bg-hover);
border-color: var(--accent);
color: var(--accent);
}
.task-actions-menu {
position: absolute;
bottom: calc(100% + 4px);
right: 0;
min-width: 180px;
margin: 0;
padding: 4px;
list-style: none;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: var(--z-dropdown, 2000);
animation: tam-fade-in 0.12s ease-out;
}
@keyframes tam-fade-in {
from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: translateY(0); }
}
.task-actions-menu__item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: 4px;
font-size: 13px;
color: var(--text-primary);
transition: background 0.12s;
}
.task-actions-menu__item > a,
.task-actions-menu__item > span {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
text-decoration: none;
color: inherit;
}
.task-actions-menu__item:hover:not(.is-disabled) {
background-color: var(--bg-hover);
}
.task-actions-menu__item.is-danger {
color: var(--color-danger);
}
.task-actions-menu__item.is-disabled {
color: var(--text-tertiary);
cursor: not-allowed;
}
.task-actions-menu__icon {
font-size: 14px;
width: 16px;
text-align: center;
flex-shrink: 0;
}
</style> </style>
@@ -75,17 +75,61 @@
</div> </div>
<!-- ================================================================== --> <!-- ================================================================== -->
<!-- 底部操作按钮 跳转到 ITSM 原系统操作 --> <!-- 底部操作按钮 PRD-REQ-坐席-011 §6.4 决策 C-8主操作 + 收纳 -->
<!-- ================================================================== --> <!-- ================================================================== -->
<div class="tic-actions"> <div class="tic-actions">
<!-- 主操作按状态驱动pending接单 / processing结单 / resolved已结单 / 其他打开 ITSM -->
<a <a
class="tic-action-btn tic-action-primary" class="tic-action-btn"
:href="itsmUrl" :class="`tic-action-${mainAction.variant}`"
:href="mainAction.href"
:disabled="mainAction.disabled"
:title="mainAction.tooltip || mainAction.label"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
🔗 ITSM 中打开 {{ mainAction.label }}
</a> </a>
<!-- 次要动作收纳按钮PRD-REQ-坐席-011 §6.4 决策 C-8 -->
<div class="task-actions-menu-wrapper" v-if="moreActions.length > 0">
<button
class="tic-action-btn tic-action-more-btn"
type="button"
:aria-expanded="isMoreMenuOpen"
aria-haspopup="menu"
aria-label="更多操作"
:title="'更多操作'"
@click="toggleMoreMenu"
>
</button>
<ul v-if="isMoreMenuOpen" class="task-actions-menu" role="menu">
<li
v-for="item in moreActions"
:key="item.key"
class="task-actions-menu__item"
:class="{ 'is-danger': item.danger, 'is-disabled': item.disabled }"
role="menuitem"
:title="item.tooltip || item.label"
>
<a
v-if="item.href && !item.disabled"
:href="item.href"
target="_blank"
rel="noopener noreferrer"
@click="closeMoreMenu"
>
<span class="task-actions-menu__icon">{{ item.icon }}</span>
<span>{{ item.label }}</span>
</a>
<span v-else class="task-actions-menu__label-disabled">
<span class="task-actions-menu__icon">{{ item.icon }}</span>
<span>{{ item.label }}</span>
</span>
</li>
</ul>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -94,13 +138,9 @@
// ============================================================================ // ============================================================================
// 导入 // 导入
// ============================================================================ // ============================================================================
import { computed } from 'vue' import { computed, ref, onBeforeUnmount } from 'vue'
import type { TodoItemData } from '@/api/todo' import type { TodoItemData } from '@/api/todo'
import { useTaskActions, type TaskMainAction, type TaskMoreAction } from '@/composables/useTaskActions'
// ============================================================================
// ITSM 运维平台基址(用于构建跳转链接)
// ============================================================================
const ITSM_BASE_URL = 'https://devops.dc.servyou-it.com/ITSM'
// ============================================================================ // ============================================================================
// Props // Props
@@ -118,12 +158,72 @@ const props = defineProps<Props>()
// ============================================================================ // ============================================================================
interface Emits { interface Emits {
/** 操作按钮事件 */ /** 操作按钮事件(v1.8 后保留接口,当前主/次按钮均为原生跳转,无 onclick 透传) */
(e: 'action', action: string): void (e: 'action', action: string): void
} }
defineEmits<Emits>() defineEmits<Emits>()
// ============================================================================
// 操作配置(PRD-REQ-坐席-011 §6.4 决策 C-8:主操作 + ⋯ 收纳)
// ============================================================================
const { main: mainActionRef, more: moreActionsRef } = useTaskActions({ todoItem: props.todoItem })
/** 主操作按钮(ComputedRef → 解包后给模板用,避免 vue-tsc 复杂类型推断) */
const mainAction = computed<TaskMainAction>(() => mainActionRef.value)
/** ⋯ 次要动作列表 */
const moreActions = computed<TaskMoreAction[]>(() => moreActionsRef.value)
// ============================================================================
// ⋯ 菜单状态
// ============================================================================
const isMoreMenuOpen = ref<boolean>(false)
/**
* 切换 ⋯ 菜单开关
* - 关闭用点击外部/Escdocument mousedown + keydown
* - 关闭时机:菜单项点击(disabled 项不响应)
*/
function toggleMoreMenu(event: MouseEvent): void {
event.stopPropagation()
isMoreMenuOpen.value = !isMoreMenuOpen.value
if (isMoreMenuOpen.value) {
// 下一帧再挂全局监听,避免本次 toggle 触发的 mousedown 立刻被关掉
setTimeout(() => {
document.addEventListener('mousedown', handleClickOutside, true)
document.addEventListener('keydown', handleKeydown, true)
}, 0)
} else {
closeMoreMenu()
}
}
function closeMoreMenu(): void {
isMoreMenuOpen.value = false
document.removeEventListener('mousedown', handleClickOutside, true)
document.removeEventListener('keydown', handleKeydown, true)
}
function handleClickOutside(event: MouseEvent): void {
const target = event.target as HTMLElement | null
if (target && target.closest('.task-actions-menu-wrapper')) return
closeMoreMenu()
}
function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape' || event.key === 'Esc') {
closeMoreMenu()
}
}
/** 组件卸载兜底 */
onBeforeUnmount(() => {
document.removeEventListener('mousedown', handleClickOutside, true)
document.removeEventListener('keydown', handleKeydown, true)
})
// ============================================================================ // ============================================================================
// 映射 // 映射
// ============================================================================ // ============================================================================
@@ -135,19 +235,6 @@ const statusLabelMap: Record<string, string> = {
resolved: '已解决', resolved: '已解决',
} }
// ============================================================================
// 计算属性
// ============================================================================
/** ITSM 工单跳转链接 */
const itsmUrl = computed<string>(() => {
const processInstanceId = props.todoItem.description?.process_instance_id
if (processInstanceId) {
return `${ITSM_BASE_URL}/workflow/allTickets/detail?process_instance_id=${processInstanceId}`
}
return ITSM_BASE_URL
})
// ============================================================================ // ============================================================================
// 方法 // 方法
// ============================================================================ // ============================================================================
@@ -259,6 +346,8 @@ function formatTime(isoString: string): string {
gap: 8px; gap: 8px;
padding: 8px 0; padding: 8px 0;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center;
position: relative;
} }
.tic-action-btn { .tic-action-btn {
@@ -283,6 +372,13 @@ function formatTime(isoString: string): string {
color: var(--accent); color: var(--accent);
} }
/* v1.8 · 决策 C-8 · 主按钮 padding 视觉密度补偿(按钮少时主按钮需要更醒目) */
.tic-action-btn.tic-action-primary,
.tic-action-btn.tic-action-success {
padding: 8px 22px;
font-weight: 500;
}
.tic-action-primary { .tic-action-primary {
background-color: var(--accent); background-color: var(--accent);
color: var(--bg-secondary); color: var(--bg-secondary);
@@ -293,4 +389,114 @@ function formatTime(isoString: string): string {
background-color: var(--accent-hover); background-color: var(--accent-hover);
color: var(--bg-secondary); color: var(--bg-secondary);
} }
.tic-action-success {
background-color: var(--color-success);
color: var(--bg-secondary);
border-color: var(--color-success);
}
.tic-action-success:hover {
filter: brightness(0.95);
color: var(--bg-secondary);
}
.tic-action-default {
background-color: var(--bg-secondary);
color: var(--text-tertiary);
border-color: var(--border-color);
cursor: not-allowed;
}
.tic-action-btn[disabled] {
cursor: not-allowed;
opacity: 0.6;
}
/* ---- v1.8 · ⋯ 次要动作收纳 ---- */
.task-actions-menu-wrapper {
position: relative;
display: inline-flex;
}
.tic-action-more-btn {
width: 36px;
padding: 8px 0;
justify-content: center;
font-size: 16px;
line-height: 1;
letter-spacing: 1px;
}
.tic-action-more-btn[aria-expanded='true'] {
background-color: var(--bg-hover);
border-color: var(--accent);
color: var(--accent);
}
.task-actions-menu {
position: absolute;
bottom: calc(100% + 4px);
right: 0;
min-width: 180px;
margin: 0;
padding: 4px;
list-style: none;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: var(--z-dropdown, 2000);
animation: tam-fade-in 0.12s ease-out;
}
@keyframes tam-fade-in {
from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: translateY(0); }
}
.task-actions-menu__item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: 4px;
font-size: 13px;
color: var(--text-primary);
transition: background 0.12s;
}
.task-actions-menu__item > a,
.task-actions-menu__item > span {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
text-decoration: none;
color: inherit;
}
.task-actions-menu__item:hover:not(.is-disabled) {
background-color: var(--bg-hover);
}
.task-actions-menu__item.is-danger {
color: var(--color-danger);
}
.task-actions-menu__item.is-disabled {
color: var(--text-tertiary);
cursor: not-allowed;
}
.task-actions-menu__item.is-disabled > span {
cursor: not-allowed;
}
.task-actions-menu__icon {
font-size: 14px;
width: 16px;
text-align: center;
flex-shrink: 0;
}
</style> </style>
@@ -0,0 +1,329 @@
/**
* useTaskActions 工厂单元测试(PRD-REQ-坐席-011 §6.4 决策 C-8
* =============================================================================
*
* 测试覆盖(与原型 v1.8 TaskDetailView 操作区对齐):
*
* Ticket:
* T1: pending → 主「📥 接单」disabled + ⋯ 5 项(含「开始处理/转派/挂起/升级/打开」)
* T2: processing → 主「✅ 结单」disabled + ⋯ 5 项
* T3: resolved → 主「✅ 已结单」disabled + ⋯ 5 项(无升级优先级)
* T4: unknown status → 主「🔗 在 ITSM 中打开」enabledfallback
*
* Approval:
* A1: sp_status=1 → 主「✅ 审批通过」enabled + ⋯ 3 项(拒绝/转交/加签)
* A2: sp_status=2 → 主「🔗 在企微审批中打开」enabled + ⋯ 0 项
* A3: sp_status=3 → 主「🔗 在企微审批中打开」enabled + ⋯ 0 项
*
* Pure 工厂函数(无需 setup wrapper):
* - buildTicketMainAction
* - buildApprovalMainAction
* - buildTicketMoreActions
* - buildApprovalMoreActions
* - isTicketClaimable
* - isTicketClosable
* - isApprovalPending
*
* 验收证据:
* - B-1: ticket pending → 主「接单」disabledU-1.1 阻塞)
* - B-2: ticket processing → 主「结单」disabledU-1.1 阻塞)
* - B-3: approval sp_status=1 → 主「审批通过」enabled(真跳转)
* - B-4: approval 非审批中 → 主「打开」+ ⋯ 空(无可执行动作)
*/
import { describe, it, expect } from 'vitest'
import type { TodoItemData } from '@/api/todo'
import {
buildTicketMainAction,
buildApprovalMainAction,
buildTicketMoreActions,
buildApprovalMoreActions,
isTicketClaimable,
isTicketClosable,
isApprovalPending,
} from '@/composables/useTaskActions'
// ---------------------------------------------------------------------------
// 辅助函数
// ---------------------------------------------------------------------------
/** ITSM 测试跳转链接(任意非空字符串即可,主按钮需要 href) */
const TEST_ITSM_URL = 'https://itsm.test/workorder/abc123'
/** 企微审批测试跳转链接 */
const TEST_WECOM_URL =
'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?sp_id=test&template_id=tpl'
/** 构造 TodoItemData fixture */
function makeTodo(overrides: Partial<TodoItemData> = {}): TodoItemData {
return {
id: 'todo-001',
type: 'ticket',
title: '测试工单',
priority: 'normal',
description: { process_instance_id: 'wf-12345' },
status: 'pending',
assigned_agent_id: null,
corp_id: 'corp-test',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...overrides,
}
}
/** 取菜单项中 key 对应的项 */
function findMoreItem(
items: ReturnType<typeof buildTicketMoreActions>,
key: string
) {
return items.find((it) => it.key === key)
}
// ---------------------------------------------------------------------------
// 状态判定 pure 函数
// ---------------------------------------------------------------------------
describe('useTaskActions — 状态判定 pure 函数', () => {
it('isTicketClaimable: ticket pending → true', () => {
expect(isTicketClaimable(makeTodo({ type: 'ticket', status: 'pending' }))).toBe(true)
})
it('isTicketClaimable: ticket processing → false', () => {
expect(isTicketClaimable(makeTodo({ type: 'ticket', status: 'processing' }))).toBe(false)
})
it('isTicketClaimable: approval → false(无论 status', () => {
expect(isTicketClaimable(makeTodo({ type: 'approval', status: 'pending' }))).toBe(false)
})
it('isTicketClosable: ticket processing → true', () => {
expect(isTicketClosable(makeTodo({ type: 'ticket', status: 'processing' }))).toBe(true)
})
it('isTicketClosable: ticket pending → false', () => {
expect(isTicketClosable(makeTodo({ type: 'ticket', status: 'pending' }))).toBe(false)
})
it('isApprovalPending: approval sp_status=1 → true', () => {
expect(isApprovalPending(makeTodo({ type: 'approval', description: { sp_status: 1 } }))).toBe(
true
)
})
it('isApprovalPending: approval sp_status=2 → false', () => {
expect(isApprovalPending(makeTodo({ type: 'approval', description: { sp_status: 2 } }))).toBe(
false
)
})
it('isApprovalPending: ticket → false', () => {
expect(isApprovalPending(makeTodo({ type: 'ticket' }))).toBe(false)
})
})
// ---------------------------------------------------------------------------
// Ticket 主操作
// ---------------------------------------------------------------------------
describe('useTaskActions — Ticket 主操作(buildTicketMainAction', () => {
it('T1: pending → 「📥 接单」disabled + primary + itsmUrl', () => {
const main = buildTicketMainAction(makeTodo({ status: 'pending' }), TEST_ITSM_URL)
expect(main.key).toBe('ticket-claim')
expect(main.label).toContain('接单')
expect(main.variant).toBe('primary')
expect(main.disabled).toBe(true)
expect(main.href).toBe(TEST_ITSM_URL)
expect(main.tooltip).toContain('待后端')
})
it('T2: processing → 「✅ 结单」disabled + success + itsmUrl', () => {
const main = buildTicketMainAction(makeTodo({ status: 'processing' }), TEST_ITSM_URL)
expect(main.key).toBe('ticket-close')
expect(main.label).toContain('结单')
expect(main.variant).toBe('success')
expect(main.disabled).toBe(true)
expect(main.href).toBe(TEST_ITSM_URL)
})
it('T3: resolved → 「✅ 已结单」disabled + default(终态)', () => {
const main = buildTicketMainAction(makeTodo({ status: 'resolved' }), TEST_ITSM_URL)
expect(main.key).toBe('ticket-resolved')
expect(main.label).toContain('已结单')
expect(main.variant).toBe('default')
expect(main.disabled).toBe(true)
expect(main.href).toBeUndefined()
})
it('T4: unknown status → 「🔗 在 ITSM 中打开」enabled + primaryfallback', () => {
const main = buildTicketMainAction(makeTodo({ status: 'unknown-state' }), TEST_ITSM_URL)
expect(main.key).toBe('ticket-open')
expect(main.label).toContain('ITSM')
expect(main.variant).toBe('primary')
expect(main.disabled).toBe(false)
expect(main.href).toBe(TEST_ITSM_URL)
})
})
// ---------------------------------------------------------------------------
// Ticket 次要动作
// ---------------------------------------------------------------------------
describe('useTaskActions — Ticket 次要动作(buildTicketMoreActions', () => {
it('T1: pending → 5 项(含升级优先级 + 打开原系统)', () => {
const items = buildTicketMoreActions(makeTodo({ status: 'pending' }), TEST_ITSM_URL)
expect(items.length).toBe(5)
expect(items.map((i) => i.key)).toEqual([
'ticket-process',
'ticket-handoff',
'ticket-pause',
'ticket-escalate',
'ticket-open-original',
])
})
it('T2: processing → 5 项(同 pending 列表)', () => {
const items = buildTicketMoreActions(makeTodo({ status: 'processing' }), TEST_ITSM_URL)
expect(items.length).toBe(5)
expect(items.map((i) => i.key)).toContain('ticket-escalate')
})
it('T3: resolved → 4 项(无升级优先级,因为已结单)', () => {
const items = buildTicketMoreActions(makeTodo({ status: 'resolved' }), TEST_ITSM_URL)
expect(items.length).toBe(4)
expect(items.map((i) => i.key)).not.toContain('ticket-escalate')
})
it('所有 ticket 次要动作:开始处理/转派/挂起/升级 均 disabled + tooltip', () => {
const items = buildTicketMoreActions(makeTodo({ status: 'pending' }), TEST_ITSM_URL)
const tbd = ['ticket-process', 'ticket-handoff', 'ticket-pause', 'ticket-escalate']
tbd.forEach((key) => {
const item = findMoreItem(items, key)
expect(item?.disabled).toBe(true)
expect(item?.tooltip).toContain('待后端')
})
})
it('「打开原系统」始终 enabled + href', () => {
const items = buildTicketMoreActions(makeTodo({ status: 'pending' }), TEST_ITSM_URL)
const open = findMoreItem(items, 'ticket-open-original')
expect(open?.disabled).toBe(false)
expect(open?.href).toBe(TEST_ITSM_URL)
})
})
// ---------------------------------------------------------------------------
// Approval 主操作
// ---------------------------------------------------------------------------
describe('useTaskActions — Approval 主操作(buildApprovalMainAction', () => {
it('A1: sp_status=1 → 「✅ 审批通过」enabled + success + wecomUrl', () => {
const main = buildApprovalMainAction(
makeTodo({ type: 'approval', description: { sp_status: 1 } }),
TEST_WECOM_URL
)
expect(main.key).toBe('approval-approve')
expect(main.label).toContain('审批通过')
expect(main.variant).toBe('success')
expect(main.disabled).toBe(false)
expect(main.href).toBe(TEST_WECOM_URL)
})
it('A2: sp_status=2 → 「🔗 在企微审批中打开」enabled + primary', () => {
const main = buildApprovalMainAction(
makeTodo({ type: 'approval', description: { sp_status: 2 } }),
TEST_WECOM_URL
)
expect(main.key).toBe('approval-open')
expect(main.label).toContain('企微审批')
expect(main.variant).toBe('primary')
expect(main.disabled).toBe(false)
expect(main.href).toBe(TEST_WECOM_URL)
})
it('A3: sp_status=3(驳回)→ 「🔗 在企微审批中打开」enabled', () => {
const main = buildApprovalMainAction(
makeTodo({ type: 'approval', description: { sp_status: 3 } }),
TEST_WECOM_URL
)
expect(main.key).toBe('approval-open')
expect(main.variant).toBe('primary')
expect(main.disabled).toBe(false)
})
})
// ---------------------------------------------------------------------------
// Approval 次要动作
// ---------------------------------------------------------------------------
describe('useTaskActions — Approval 次要动作(buildApprovalMoreActions', () => {
it('A1: sp_status=1 → 3 项(拒绝/转交/加签)', () => {
const items = buildApprovalMoreActions(
makeTodo({ type: 'approval', description: { sp_status: 1 } }),
TEST_WECOM_URL
)
expect(items.length).toBe(3)
expect(items.map((i) => i.key)).toEqual([
'approval-reject',
'approval-forward',
'approval-addsigner',
])
})
it('拒绝项 = danger + enabled', () => {
const items = buildApprovalMoreActions(
makeTodo({ type: 'approval', description: { sp_status: 1 } }),
TEST_WECOM_URL
)
const reject = findMoreItem(items, 'approval-reject')
expect(reject?.danger).toBe(true)
expect(reject?.disabled).toBe(false)
})
it('所有 sp_status=1 次要项均 enabled + href → 企微审批', () => {
const items = buildApprovalMoreActions(
makeTodo({ type: 'approval', description: { sp_status: 1 } }),
TEST_WECOM_URL
)
items.forEach((item) => {
expect(item.disabled).toBe(false)
expect(item.href).toBe(TEST_WECOM_URL)
})
})
it('A2: sp_status=2 → 空数组(审批已通过,无可执行动作)', () => {
const items = buildApprovalMoreActions(
makeTodo({ type: 'approval', description: { sp_status: 2 } }),
TEST_WECOM_URL
)
expect(items.length).toBe(0)
})
it('A3: sp_status=3(驳回)→ 空数组(审批已结束)', () => {
const items = buildApprovalMoreActions(
makeTodo({ type: 'approval', description: { sp_status: 3 } }),
TEST_WECOM_URL
)
expect(items.length).toBe(0)
})
it('A4: sp_status=4(撤销)→ 空数组', () => {
const items = buildApprovalMoreActions(
makeTodo({ type: 'approval', description: { sp_status: 4 } }),
TEST_WECOM_URL
)
expect(items.length).toBe(0)
})
})
// ---------------------------------------------------------------------------
// 兜底:未知 type
// ---------------------------------------------------------------------------
describe('useTaskActions — 未知 type 兜底(buildTicketMainAction / buildApprovalMainAction', () => {
it('ticket 主操作:无 status 字段 → fallback 打开', () => {
// 显式覆盖 description 为空对象,移除 status 字段语义(status 本身存在)
const todo = makeTodo({ status: '' })
const main = buildTicketMainAction(todo, TEST_ITSM_URL)
// 空 status 不命中 pending/processing/resolved → 进入 fallback 分支
expect(main.key).toBe('ticket-open')
expect(main.disabled).toBe(false)
})
})
@@ -0,0 +1,362 @@
/**
* 企微IT智能服务台 — 任务详情操作配置工厂(PRD-REQ-坐席-011 §6.4 决策 C-8
* =============================================================================
*
* 做什么:
* 根据 TodoItemData 当前状态,产出 TaskDetailView 操作区的「主操作按钮 + ⋯
* 次要动作收纳」配置。**状态驱动**主按钮:ticket.queued→接单、ticket.processing
* →结单;approval.sp_status=1→审批通过;其他状态主按钮聚焦「在原系统打开」。
*
* 为什么独立成 composable
* - TaskDetailView 只渲染、不感知业务规则(与 v1.7 §4.4 「单例菜单」哲学一致)
* - ticket / approval / device 三类任务共用同一套「主操作 + ⋯」视觉
* - 后续后端 API 解决(U-1.2 / U-1.1 解除)后,仅改本工厂即可解除 disabled
*
* 设计约束(PRD-REQ-坐席-011 §6.4 决策 C-8):
* - 主按钮:1 个,状态驱动 + 文案自适应
* - ⋯ 收纳:≤ 5 个项,低频+高破坏性收纳二级
* - 无后端 API 的项:disabled + tooltip「待后端接口开放」(不抛错、不假成功)
*
* 不做什么:
* - 不发请求、不修改 store(纯 UI 配置)
* - 不依赖 WorkItemActionMenu.vue(保持 composable 与 UI 壳解耦)
*
* 与 useConversationMenuItems.ts 区别:
* - 后者产出左栏会话条目的菜单(assign/pin/todo/transfer/grab/leave
* - 本工厂产出中栏 TaskDetailView 的操作(claim/close/handoff/approve/reject/open
*
* 验收证据:
* - B-1: ticket pending → 主「接单」disabled(待后端)+ ⋯ 收纳转派等占位
* - B-2: ticket processing → 主「结单」disabled(待后端)+ ⋯ 收纳转派
* - B-3: approval sp_status=1 → 主「审批通过」enabled(跳企微)
* - B-4: approval sp_status=2/3/4 → 主「在企微审批中打开」+ ⋯ 不显示审批动作
*/
import { computed, type ComputedRef } from 'vue'
import type { TodoItemData } from '@/api/todo'
// ============================================================================
// 类型
// ============================================================================
/** 主操作按钮(必有,1 个) */
export interface TaskMainAction {
/** 唯一 key */
key: string
/** 显示文案(含 emoji */
label: string
/** 图标(emoji 字符) */
icon: string
/** 视觉风格:primary / success / warning / default */
variant: 'primary' | 'success' | 'warning' | 'default'
/** 跳转 URLhref 模式);undefined 表示执行 onclick */
href?: string
/** 禁用(待后端 API */
disabled: boolean
/** hover/tooltip 文案 */
tooltip?: string
}
/** 次要动作(收纳进 ⋯,≤ 5 个) */
export interface TaskMoreAction {
key: string
label: string
icon: string
/** danger:红色样式 */
danger?: boolean
/** 禁用 + tooltip 说明 */
disabled: boolean
tooltip?: string
/** 跳转 URLhref 模式) */
href?: string
}
/** TaskDetailView 操作区整体配置 */
export interface TaskActionsConfig {
/** 主操作按钮(1 个) */
main: ComputedRef<TaskMainAction>
/** ⋯ 次要动作列表(0~5 个) */
more: ComputedRef<TaskMoreAction[]>
}
/** useTaskActions 入参 */
export interface UseTaskActionsParams {
/** 当前 TaskDetailView 选中的待办 */
todoItem: TodoItemData
/** 企微审批深链(仅 approval 用) */
wecomApprovalUrl?: string
}
// ============================================================================
// 文案常量(与原型 v1.8 + PRD-REQ-坐席-011 §6.4 对齐)
// ============================================================================
/** 待后端 API 时统一 tooltip 文案(避免分散硬编码) */
const TBD_API_TOOLTIP = '待后端接口开放(ITSM U-1.2/U-1.1'
// ============================================================================
// 状态判定辅助(pure,便于单测)
// ============================================================================
/**
* ticket 是否处于待接单(pending → 服务台应能接单)
*/
export function isTicketClaimable(todo: TodoItemData): boolean {
return todo.type === 'ticket' && todo.status === 'pending'
}
/**
* ticket 是否处于处理中(处理人已分配,服务台应能结单)
*/
export function isTicketClosable(todo: TodoItemData): boolean {
return todo.type === 'ticket' && todo.status === 'processing'
}
/**
* approval 是否处于审批中(sp_status=1
*/
export function isApprovalPending(todo: TodoItemData): boolean {
return todo.type === 'approval' && todo.description?.sp_status === 1
}
// ============================================================================
// 主操作产出(pure,便于单测)
// ============================================================================
/**
* 产出 ticket 主操作
* - pending → 「📥 接单」+ 跳转 ITSMdisabled 占位)
* - processing→ 「✅ 结单」+ 跳转 ITSMdisabled 占位)
* - resolved → 「✅ 已结单」(disabled)
* - 其他 → 「🔗 在 ITSM 中打开」(enabled
*/
export function buildTicketMainAction(todo: TodoItemData, itsmUrl: string): TaskMainAction {
if (isTicketClaimable(todo)) {
return {
key: 'ticket-claim',
label: '📥 接单',
icon: '📥',
variant: 'primary',
href: itsmUrl,
disabled: true, // ⚠️ U-1.1 阻塞
tooltip: TBD_API_TOOLTIP,
}
}
if (isTicketClosable(todo)) {
return {
key: 'ticket-close',
label: '✅ 结单',
icon: '✅',
variant: 'success',
href: itsmUrl,
disabled: true, // ⚠️ U-1.1 阻塞
tooltip: TBD_API_TOOLTIP,
}
}
if (todo.status === 'resolved') {
return {
key: 'ticket-resolved',
label: '✅ 已结单',
icon: '✅',
variant: 'default',
disabled: true,
tooltip: '工单已结单',
}
}
// fallback(未知 status
return {
key: 'ticket-open',
label: '🔗 在 ITSM 中打开',
icon: '🔗',
variant: 'primary',
href: itsmUrl,
disabled: false,
}
}
/**
* 产出 approval 主操作
* - sp_status=1(审批中) → 「✅ 审批通过」(enabled,跳企微)
* - 其他(非审批中) → 「🔗 在企微审批中打开」(enabled,跳企微)
*/
export function buildApprovalMainAction(
todo: TodoItemData,
wecomApprovalUrl: string
): TaskMainAction {
if (isApprovalPending(todo)) {
return {
key: 'approval-approve',
label: '✅ 审批通过',
icon: '✅',
variant: 'success',
href: wecomApprovalUrl,
disabled: false,
}
}
return {
key: 'approval-open',
label: '🔗 在企微审批中打开',
icon: '🔗',
variant: 'primary',
href: wecomApprovalUrl,
disabled: false,
}
}
// ============================================================================
// 次要动作产出(pure,便于单测)
// ============================================================================
/**
* ticket ⋯ 次要动作
* 全部 disabled(待 ITSM API 开放)—— 占位用途
*/
export function buildTicketMoreActions(todo: TodoItemData, itsmUrl: string): TaskMoreAction[] {
const isClaimable = isTicketClaimable(todo)
const isClosable = isTicketClosable(todo)
return [
// 1. 开始处理(仅 pending→processing 显式动作;可由接单隐式推进,故占位)
{
key: 'ticket-process',
label: '🔧 开始处理',
icon: '🔧',
disabled: true,
tooltip: TBD_API_TOOLTIP,
},
// 2. 转派
{
key: 'ticket-handoff',
label: '🔄 转派给他人',
icon: '🔄',
disabled: true,
tooltip: TBD_API_TOOLTIP,
},
// 3. 挂起
{
key: 'ticket-pause',
label: '⏸ 挂起(暂不处理)',
icon: '⏸',
disabled: true,
tooltip: TBD_API_TOOLTIP,
},
// 4. 标记高优(仅 claimable/closable 时显示)
...(isClaimable || isClosable
? [
{
key: 'ticket-escalate',
label: '🔺 升级优先级',
icon: '🔺',
disabled: true,
tooltip: TBD_API_TOOLTIP,
},
]
: []),
// 5. 在原系统打开(始终显示,作为次要出口)
{
key: 'ticket-open-original',
label: '🔗 在 ITSM 中打开',
icon: '🔗',
href: itsmUrl,
disabled: false,
},
]
}
/**
* approval ⋯ 次要动作
* - sp_status=1(审批中) → 拒绝/转交(真跳转,可点)
* - 其他 → 空数组(审批已结束,无可执行动作)
*/
export function buildApprovalMoreActions(
todo: TodoItemData,
wecomApprovalUrl: string
): TaskMoreAction[] {
if (!isApprovalPending(todo)) {
return []
}
return [
{
key: 'approval-reject',
label: '❌ 拒绝审批',
icon: '❌',
danger: true,
href: wecomApprovalUrl,
disabled: false,
},
{
key: 'approval-forward',
label: '🔄 转交审批',
icon: '🔄',
href: wecomApprovalUrl,
disabled: false,
},
{
key: 'approval-addsigner',
label: ' 加签',
icon: '',
href: wecomApprovalUrl,
disabled: false,
},
]
}
// ============================================================================
// 工厂
// ============================================================================
/**
* useTaskActions 工厂
*
* 使用示例:
* const { main, more } = useTaskActions({ todoItem, wecomApprovalUrl })
*
* @param params - 入参
* @returns TaskActionsConfig - main + more 配置
*/
export function useTaskActions(params: UseTaskActionsParams): TaskActionsConfig {
const { todoItem, wecomApprovalUrl } = params
/** ITSM 运维平台基址(与 TicketDetail.vue 现状一致) */
const ITSM_BASE_URL = 'https://devops.dc.servyou-it.com/ITSM'
/** 计算 ITSM 跳转链接(与 TicketDetail.vue 现状一致) */
const itsmUrl = computed<string>(() => {
const processInstanceId = todoItem.description?.process_instance_id
if (processInstanceId) {
return `${ITSM_BASE_URL}/workflow/allTickets/detail?process_instance_id=${processInstanceId}`
}
return ITSM_BASE_URL
})
/** 主操作:按类型分发 */
const main = computed<TaskMainAction>(() => {
if (todoItem.type === 'ticket') {
return buildTicketMainAction(todoItem, itsmUrl.value)
}
if (todoItem.type === 'approval') {
return buildApprovalMainAction(todoItem, wecomApprovalUrl || '')
}
// fallback(未知类型,与 TicketDetail 一致)
return {
key: 'unknown-open',
label: '🔗 在原系统打开',
icon: '🔗',
variant: 'primary',
href: itsmUrl.value,
disabled: false,
}
})
/** ⋯ 次要动作:按类型分发 */
const more = computed<TaskMoreAction[]>(() => {
if (todoItem.type === 'ticket') {
return buildTicketMoreActions(todoItem, itsmUrl.value)
}
if (todoItem.type === 'approval') {
return buildApprovalMoreActions(todoItem, wecomApprovalUrl || '')
}
return []
})
return { main, more }
}