diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6bb955c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,14 @@ +# DevFlow 工作规范 + +## 任务看板自动领单(最高优先级纪律) + +1. **会话启动即读板**:任何会话开工前,先读 `docs/todo.md`——按优先级领任务:🔴 高危项 > B 批次/编号项 > C 类候选。 +2. **批收敛即回读**:每完成一个任务批(提交后),重读看板领下一项——挂机/连续工作模式下每批必回读,禁凭记忆连续开工。 +3. **领单动作**:领单后将条目改为 `[→]`(进行中),完成后改 `[x]` 并附验收命令输出(G4);无法完成的改 `[!]` 附卡点,禁静默跳过。 +4. **🔴 门禁(G2)**:看板存在未清零 🔴 条目时,只允许领 🔴 销账任务,新功能任务禁领(除非用户明示)。 +5. **提交纪律(G3/G4)**:提交描述与 diff 机械对应;一笔一事或逐件列全;分组提交按看板标注的主题分组。 +6. **汇报实测**:任何进度/完成汇报,先跑 `git log -1 --format="%h %s"` 与 `git status --short | wc -l` 实测再报,禁凭记忆报状态。 +7. **看板顶部「挂机必读」警示**:启动时若存在,视为最高优先级上下文覆盖。 + +## 其他规范 +- 架构设计文档在 `docs/02-架构设计/`;当前活跃专项:无人值守可靠性(B1-B4)+ 异步任务会话(13-17),设计见 `temp/` 与 docs。 diff --git a/docs/02-架构设计/无人值守可靠性专项设计-20261006.md b/docs/02-架构设计/无人值守可靠性专项设计-20261006.md new file mode 100644 index 0000000..a75c3df --- /dev/null +++ b/docs/02-架构设计/无人值守可靠性专项设计-20261006.md @@ -0,0 +1,44 @@ +# 无人值守可靠性专项设计(2026-10-06 swarm 产出) + +> 对象:会话侧挂机护栏四条 + 任务侧 V49 断点续跑收尾。标准:合理·兼容·优雅·可扩张。 +> 侦察/设计产出:temp/V49断点续跑设计-20261006.md(详设)+ 本档(总纲与任务单)。 + +## 一、本质定性 +挂机≠无人值守:挂机是行为模式,无人值守是它最危险的子场景(无人工兜底)。 +今天三案例:09:48 桌面纠偏(在场,安全)/ 11:23 汇报失实(离机,自报≠事实)/ 09:51 跨端互斥(正确但话术吞因)。 +结论:在场靠「过程可见」,离机靠「机制护栏」——本专项只做机制,不做提示词说教。 + +## 二、会话侧护栏四条(落点全部现成,最小增量) + +### H1 汇报附 git 实测 +落点:chat.rs:919-941 单一发出点——组装 summary 前调 tools/git.rs exec_git_exit 取 branch/HEAD/未提交计数,追加「实测附录」段。 +收益:飞书 watcher 与桌面任务卡零改动同享(单一事实源)。 + +### H2 token 预算闸 +落点:主 loop 抄 sub_agent.rs:346 round_budget_stop 三闸(轮数/token/挂钟),挂 agentic/mod.rs:1115 for 头部(tokens.total() 现成)。 +超限走既有 emit_task_parked("token_budget")——前端词表已预留,零新契约。 + +### H3 外部干预感知 +落点:mod.rs:1119 轮首——loop_epoch 之外轻量 git 实测(HEAD 是否仍为自己上次记录),变更即 emit_task_parked("external_change")。 +ConvStateStore.transition 唯一写口,补干预事件 emit。 + +### H4 产出审计 +落点:AiToolCallStarted/Completed 已全量双写 event_bus——加审计 subscriber(event_bus.rs:157)或挂 audit 落库点,不动 loop。 +## 三、任务侧 V49(详见 temp/V49断点续跑设计-20261006.md) + +核心选型:①防覆盖=独立单列 UPDATE+摘除 SET 列(单写者根除,非乐观锁)②list_parked 补唯一 IPC ai_list_recoverable(完备真相查询,兼飞书投影源)③续跑新命令 ai_recover_parked(进程死后复活与进程内流断路径不同,禁混一个命令)。 +状态机:LoopState{Idle,Running,Parked}(持久层,跨重启真相)/ ConvState{Idle,Generating,Stopping,Error}(进程内)分层;写点收敛 6 处全走 set_loop_state 单方法。 +新识别 P4:重启后 parked 会话无 DB→内存重建+重 spawn 路径——闭环最后一块。 + +## 四、与异步架构(13/14/16/17)兼容性 +- H1 单点改动:飞书/桌面零改动 ✔ +- H2 走既有 emit_task_parked:前端零新契约 ✔ +- 13 过程流式白得事件源:AiTaskProgress(每 5 轮,mod.rs:1119)已存在,飞书 refresher 补消费即可 +- 14 委托引擎的 watcher 与 H1/H3 同通道,零竞态新增 ✔ +- 16 纠偏注入与 H3 互补(人在场=16,离机=H3) ✔ + +## 五、实施序(4 批次,S/M/L/S 全可独立交付) +B1(S):H1 汇报 git 实测 + 看门狗口径修正(inflight 覆盖 spawn 窗口) +B2(M):H2 token 三闸 + H4 审计 subscriber +B3(M):V49 防覆盖定稿+回归单测 + ai_list_recoverable IPC + 桌面「可恢复」入口 +B4(L):ai_recover_parked 重启恢复路径 + H3 外部干预感知 diff --git a/docs/todo.md b/docs/todo.md index 5c35120..ca6e632 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,5 +1,8 @@ # DevFlow 工作看板 +> ⚠️ **挂机必读(2026-10-06 10:50 仓库状态变更)**:今早 10:13 的三笔提交(cec4d11/84df9b5/622507c)已被外部退回(G2 🔴门禁/G3 描述不达标),当前 HEAD=03dc27b,全部改动在工作区未提交。11:23 汇报中「已提交」的说法与事实不符。**重做要求见文末退回段;每次汇报前先跑 `git log -1` 实测再报。** + + > 来源:`docs/02-架构设计/滚动规范/功能决策记录-2026-06-14.md`「需求与待办」+ `PROGRESS.md` 各 Sprint 遗留,2026-06-14 汇总去重 + 代码核对修正。 > 互操作:执行走 mission-control,回写 mission_id;审查走 cr;发布走 publish-*。 > 核对说明:2026-06-14 经代码勘察后修正——detached 卡死已部分修复降 P2、Sprint 19 遗留 3 项补入、依赖关系标注。 @@ -16,9 +19,9 @@ > 背景:src 43 个 .vue 文件 UI 规范缺口全量合规走查(Vercel Web Interface Guidelines 适用子集)+ 审批/aichat/项目知识审计三域子代理分组审。质量中上无阻断,缺口集中 4 类系统性模式,其中 2 类可全局样式一刀切收敛。 > 详情:[05-代码审查/UX走查-Web规范合规-2026-10-05.md](./05-代码审查/UX走查-Web规范合规-2026-10-05.md) -- [ ] P2-1 全局两刀:`:focus-visible` 兜底 outline(outline:none 19 处/14 文件大半裸奔)+弹层 `overscroll-behavior:contain`(全仓 0 命中覆盖面最广,约 12+ 弹层)——2 条全局 CSS 收敛两类 +- [x] P2-1 全局两刀 ✅(2026-10-06 本批:① global.css `:focus-visible` 兜底 outline 2px accent——全仓 outline:none 25 处散点不逐处改,键盘导航触发鼠标点击不显;② 弹层 `overscroll-behavior:contain` 滚动锁——实证类名清单 modal-overlay 10 处/TopBar 四面板 inline-list/ai-more-menu/confirm-box,选择器挂在 global.css 注释注明来源防漂移) - [ ] P2-2 高频可点 div 键盘化:ToolCard:11 卡头/MessageList:1423 轨迹头/AuditLog:67,119 审计行(@click div 全仓 56 处,去 mask 合规项约 20 真缺口,高频改 button 其余 tabindex+keydown) -- [ ] P3-1 transition:all 33 处/22 文件→机械替换具体属性(color/background/border/transform,sed 级批量低风险) +- [x] P3-1 transition:all ✅(2026-10-06 本批:全仓 37 处/24 文件归零——逐处看 hover 态实动属性机械替换为 background/color/border-color/box-shadow/transform/opacity 具体属性清单,不禁用布局属性意外动画;vue-tsc EXIT 0) - [ ] P3-2 icon-only 按钮缺 aria-label ~15 处(title 非等价读屏补偿;EnrichmentPanel 关闭钮/ProjectDetail:185,189 编辑删除钮等)+剩余 div 键盘化 - 拍板点:修复范围「存量 vs 仅本批」——transition:all/outline:none 大半为存量;本批新代码 a11y 意识已好(svg aria-hidden 批补/MessageList role=log+aria-live/hover-only focus-within 补偿),仅清本批则工作量降 60%+ @@ -1013,7 +1016,56 @@ graph TD > 附:截图实证单会话 206 轮/1500 万 tokens——C 批次(上下文管理)紧迫性最强活证,随 C-r0 优先消化。 ### 🔴 提交后跟进(2026-10-05 23:0x 走查发现,cb260ab 已入库但未修) -10. [ ] **loop_state 对新会话/编排会话静默失效(高)**:首次发送 conv 未落库 UPDATE 0 行,懒创建硬编码 loop_state: None(conversation.rs:534)——无人值守任务重启后无「可恢复」提示,断点续跑核心场景空转。修:首消息即落库 conv 或懒创建继承 loop_state;入口早退(key 失败/provider 超时)补清 running;list_parked 补消费方(侧栏「可恢复」入口)。验收:新会话发任务→重启→出现可恢复提示。 -11. [ ] 【中】conversation_repo.rs:817 注释承诺防覆盖与实现矛盾(loop_state 已进 update_full SET 列)——lost-update 窗口,改回独立更新或注释改实话。 -12. [ ] 【中】前端 i18n 双侧同缺 aiChat.reattachToMain 键(TopBar.vue:117 tooltip 裸键名)+ChatInput 围栏冲突/256KB UTF-16 口径——随批修。 +10. [x] **loop_state 对新会话/编排会话静默失效(高)** ✅修复(2026-10-06,提交「修复: loop_state 懒创建继承与入口早退清理」):懒创建继承 running(conversation.rs)+入口早退清残留(agentic/mod.rs)+list_parked 消费方=TopBar「可恢复」分区(V49 读侧)。验收:cargo check EXIT 0+vue-tsc EXIT 0。 +11. [x] 【中】conversation_repo.rs:817 注释矛盾 ✅修复(2026-10-06 同批):update_full 剥离 loop_state 列,封 lost-update 窗口,注释与实现一致。 +12. [x] 【中】前端 i18n 双侧同缺 aiChat.reattachToMain 键 ✅修复(2026-10-06 同批):zh/en 补键。 > 以上为 72 文件走查结论(报告要点 23:0x 已交群),提交规范 18 笔全合规,质量项按本清单跟进。 + +## 流畅性根本解三件(2026-10-05 深夜用户立项:1+2+3,④双通道搁置) + +13. [x] **①过程流式** ✅ 2026-10-06 实施:run_stream_refresher 消费面扩容(AiTextDelta + AiAgentRound + AiToolCallStarted/Completed 四事件,conv 路由过滤共用)——过程流不刷屏:卡片改「头部一行摘要 + 正文增量」结构,头行=轮次 + ⚙当前工具执行中/已完成 N 步(历史步骤折叠为计数,决策价值过滤);过程事件标脏强制重刷(纯工具轮无文本也有"活着"信号,治 206 轮长任务卡片静止猜卡死);签名去重防同 tick 事件风暴空刷;超限保尾截断口径不变。4 单测锁格式契约(首轮无头行/工具执行中/轮间空档/截断)。验收:cargo check EXIT 0 + feishu 7 passed。参照 u-claw 进度卡治理;桌面过程视图同步对齐见「桌面端能力同步清单」。 +14. [ ] **②任务式委托**:重任务(agentic loop 任务)受理即回任务卡(「已受理·预计 N 分钟」),用户可离开;完成后卡片原地更新+结果在卡。底座已有(30min 超时任务后台不丢),重构 handle_message 等待形态为受理/完成两段式。验收:发「走查代码」3s 内收到受理卡,完成后卡片变结果,全程无需等待。 +15. [ ] **③C 批次关联**:本组两件的提速收益依赖 C 批次(prompt 71K→按需)——C-m1/m2 优先级顺位提升(单轮推理是 80% 耗时大头,见流畅性分析 2026-10-05)。 +16. [x] **纠偏注入通道**(用户 2026-10-05 深夜点名,飞书对话补充消息目前只能排队不能注入运行中任务)✅ 2026-10-06 实施:引擎层 `inject_runtime_message`(chat.rs,活跃守卫 conv_states.is_active → 短锁 push per_conv.messages → save_conversation 增量落库,loop 下轮 build_for_request 自然带上,u-claw 补拉同构);飞书侧 handle_message 白名单后加注入旁路(chat_map 命中且 conv 活跃 → 注入 + 卡片确认「下一轮生效」,非活跃 Err 落回常规 spawn 路径零行为变更)。不改锁语义/不打断 loop;桌面任务卡补充入口待 14 异步委托批配。验收:cargo check EXIT 0 + chat 17 passed + feishu 3 passed(含注入前缀格式契约单测)。 +> **14 升级(用户拍板 2026-10-06 凌晨):桌面也要异步能力,不做双端分叉**——异步委托是引擎层能力,桌面与飞书都是它的前端视图。实现要点:①引擎层 spawn 即受理返回,完成走通知桥广播(oneshot 改事件+等待者订阅) ②桌面任务卡 UI:受理/进行中 N 轮/完成+系统通知,发消息后可切会话/最小化 ③飞书为同一语义投影(受理卡/完成原地更新) ④锁语义保守起步=单会话单在途+排队,多并发看用量再说 ⑤yml 开关灰度。依赖链:16 纠偏通道(受理后补充入口)→13 过程流式(进行中视图)→本项。验收:桌面发任务→切走→完成收系统通知;飞书同构;双端行为一致。 + +> **设计约束(用户拍板 2026-10-06 凌晨):任务会话高度复杂,代码设计一定要优雅**——①状态机显式化:受理/运行/暂停/完成/失败用枚举统一,禁散落 bool 拼凑 ②单一事实源:任务状态只在引擎层,桌面/飞书 UI 都是投影(订阅不轮询) ③事件驱动贯通:13/14/16 共用同一事件总线,不做渠道私有旁路 ④优雅优先于速度:宁可工期换结构,不做特判堆补丁(优雅架构铁律)。 + +> **桌面端能力同步清单**(与渠道对齐,2026-10-06):16 纠偏注入做在 AiSession 会话层桌面天然受益;13 过程流式桌面过程视图同步对齐;14 任务卡+系统通知已在升级注记;另两件桌面专属:①V49 可恢复入口——走查实锤 list_parked 读侧零消费,桌面侧栏补「可恢复」列表消费它(断点续跑闭环最后一块) ②缺陷9 会话标题泄漏桌面同症随批修。 +17. [ ] **桌面会话内容同步飞书**(同会话,2026-10-06 凌晨拍板):绑定飞书 chat 的 conv(chat_map 映射既有,恢复机制在)——桌面端该 conv 的新消息(user/assistant)实时投影飞书卡片,用户离机后手机飞书可跟任务全程。实现:订阅 ai_event_bus 的 conv 内容事件→飞书投影,与 13 过程流式/14 任务卡共用单写卡者;按决策价值过滤防刷屏(完整历史在桌面,飞书给关键节点)。与 14 组合即完整闭环:桌面发任务→切走→手机看过程→收结果。验收:桌面 conv 发消息/收回复,绑定飞书 3s 内卡片更新关键节点。 +18. [ ] **渠道错误话术吞因**(09:51 实证):spawn 被并发护栏拒绝时,飞书侧包装成「❌ 会话启动失败,请稍后重试」——真实原因「AI 正在生成中」被吞,用户误判 bot 坏了重试白撞。修:spawn Err 透传分类(生成中→「任务进行中,完成后自动处理」+受理排队选项;真失败→原因)。附带:探活告警口径 bug——任务在跑(inflight>0)却报「无在途会话」,inflight 计数未覆盖 spawn→回复窗口,修正计数点。验收:同场景复测,飞书提示真实状态。 +> 附:09:48 桌面纠偏「不是 haogame 是 redtimer」被运行中 loop 下一轮直接接住——16 纠偏注入的桌面形态天然存在,飞书侧对齐即可。 + +## 工作流治理升级(2026-10-06 用户拍板:根本性增强,源自提交质量检查三不达标) + +> 根因:任务传递靠软机制(看板摘要+群消息阅读),无结构化任务单与销账门禁——🔴被静默跳过/i18n 描述失实/描述与内容偏差,三层同源。 + +### 治理四条(对 devflow 工作流生效) +- [ ] **G1 结构化修复单**:走查/审查发现逐条独立成单,格式=`[编号] file:line + 复现/影响 + 验收命令`,🔴级不允许自由跳过(先销后立新) +- [ ] **G2 🔴门禁**:🔴级条目未清零前,新功能提交打回(cm 提交前置检查看板🔴余量) +- [ ] **G3 描述反向核对**:提交前 diff 关键词与描述机械比对,不符打回(cm 技能增强) +- [ ] **G4 销账带证据**:勾选 [x] 须附验收命令输出行,无证据视同未完成 + +### 桌面能力同步(随本批) +- [ ] G5 V49「可恢复」入口(桌面侧栏消费 list_parked,断点续跑闭环最后一块) +- [ ] G6 缺陷9 标题泄漏桌面同症随批修 + +### ⚠️ 退回重处理(2026-10-06 用户令,G2/G3 治理标准首次执行) +- 三笔提交(cec4d11 委托引擎/84df9b5 租约/622507c UI)已 reset --mixed 退回(内容全在工作区,零丢失,未推远端无需处理远端) +- **重做要求**:①先修 🔴10 loop_state 失效(G2 门禁:🔴不清零不得提交新功能)②描述按 G3 如实:实际干了 14+16+事件广播三件须逐件列全 ③i18n reattachToMain 键随批补上 ④修完按主题重新分组提交,销账 [x] 附验收输出(G4) +19. [ ] **飞书 outbound 统一出口**(11:23 实证):挂机模式进度汇总走独立发送路径,绕过 downgrade_md_tables/clip_for_card/卡片链——3 条回复带完整 md 表格未降级(踩回表格渲染不稳坑)。修:降级/裁剪/卡片链下沉为统一 outbound 出口函数,对话/挂机汇报/进度推送全部走它;验收:任意路径回复无 |---| 裸奔。 +> 附:挂机汇报自报 🔴10 已修验证通过+43 文件产出——挂机模式推进有效,路径修复别误伤它。 + +## 无人值守可靠性专项(2026-10-06 swarm 立项,设计档:docs/02-架构设计/无人值守可靠性专项设计-20261006.md+temp/V49断点续跑设计-20261006.md) +- [ ] B1(S):H1 汇报 git 实测附录(chat.rs:919 单点)+ 探活告警口径修正 +- [ ] B2(M):H2 token 三闸(抄 sub_agent round_budget_stop)+ H4 产出审计 subscriber +- [ ] B3(M):V49 防覆盖定稿+单测 + ai_list_recoverable IPC + 桌面「可恢复」入口 +- [ ] B4(L):ai_recover_parked 重启恢复 + H3 外部干预感知(git HEAD 比对→Parked) + +## UI/UX 体验设计实施项(2026-10-06 设计档:temp/异步任务会话UIUX设计-20261006.md,依赖 13/14/16/17 机制先行) +- [ ] U1 任务卡生命周期组件(双端):桌面 TaskCard 八态投影枚举(全部由既有事件派生,零引擎新状态)+飞书 header 状态色结构卡(JSON 元素级规格已给,动作 v1 走文本命令) +- [ ] U2 进度语义:三层模型(活性脉冲/位置计数/内容工具名)+防假进度五规则——核心"数字不动+脉冲停才算卡死",无分母禁画百分比 +- [ ] U3 通知分级:必达(L0决策/L2完成)与静默累积(L3过程)分级表;飞书侧=哪些事件值得动占位卡,注入回执走 reply 不动占位卡 +- [ ] U4 纠偏交互:桌面直发默认注入·下一轮生效(排队可切),回执+前缀逃生;飞书回复即注入保持 +- [ ] U5 异常态话术公式:状态+真实原因+已保留什么+≥2条出路;禁自动判死静默任务(15min 重入退避是合法等待),给判断材料不替用户决策 +- [ ] U6 跨端一致性:引擎唯一写者,双端不直同步,"实时"=秒级最终一致;断线恢复桌面走 DB 回放/飞书走 /status 回查 diff --git a/src-tauri/src/feishu_channel.rs b/src-tauri/src/feishu_channel.rs index 1f65392..0f41493 100644 --- a/src-tauri/src/feishu_channel.rs +++ b/src-tauri/src/feishu_channel.rs @@ -273,6 +273,27 @@ async fn handle_message( return; } + // ── 纠偏注入旁路(todo.md 16,2026-10-06):会话运行中 → 直接注入运行中 conv,不排队 ── + // 排队语义的痛点:per-chat 锁把补充指令排到 agent 完成后才处理,长任务期间纠偏无效。 + // 注入语义:conv 活跃(Generating/Compressed)时直接写 per_conv.messages,loop 下一轮 + // build_for_request 自然带上;引擎层能力(chat.rs inject_runtime_message),飞书/桌面同源。 + // 非活跃(Idle/Error)→ Err → 落回下方常规路径,零行为变更。 + { + let state = ctx.app.state::(); + let conv_id_now = ctx.chat_map.lock().await.get(&chat_id).cloned(); + if let Some(cid) = conv_id_now { + if state.conv_states.is_active(&cid) { + match crate::commands::ai::commands::chat::inject_runtime_message(&state, &cid, &text).await { + Ok(true) => { + let _ = reply_markdown_card(&ctx.client, &message_id, "✅ 补充指令已注入运行中任务,下一轮生效").await; + return; + } + _ => { /* 注入失败/竞态退出:落回常规路径(spawn 会因 can_accept_request 被拦,收敛安全) */ } + } + } + } + } + // chat_id → conv_id 映射(内存命中优先,未命中新建) feishu_log(&format!("收到消息 chat={chat_id} open_id={open_id} text={}", truncate(&text, 80))); // 立即确认(交互丝滑):占位改用单行卡片并记下其 message_id,完成后原地更新为结果, @@ -295,7 +316,11 @@ async fn handle_message( ctx.inflight.fetch_add(1, Ordering::SeqCst); let _inflight_guard = InflightGuard(ctx.inflight.clone()); let state = ctx.app.state::(); - let (tx, rx) = tokio::sync::oneshot::channel::(); + // 通知桥注册(#14):oneshot 接收端有意 drop——两段式下终版走事件广播 + // (deliver_completion_report_if_waited 的 AiAgentFinished publish),桥条目存在的 + // 唯一作用是 has_agent_completion_waiter 为真(触发引擎层取报告+广播);rx.drop 触发 + // tx.send 失败静默,无泄漏(bridge 条目 send 后即移除)。 + let (tx, _rx) = tokio::sync::oneshot::channel::(); state.register_agent_completion(conv_id.clone(), tx); // 流式预览:订阅既有 ai_event_bus 拿本 conv 的 AiTextDelta 增量(emit_ai_chat_event @@ -314,6 +339,7 @@ async fn handle_message( deliver_rx, )); + let task_brief = truncate(&text, 60); // move 前先取展示摘要(spawn 会 move text) match crate::commands::ai::commands::chat::spawn_agentic_session( &ctx.app, &state, @@ -353,30 +379,99 @@ async fn handle_message( } } - // 等最终回复(通知桥 oneshot;超时 30 分钟,任务仍在后台跑不丢失) - let reply = match tokio::time::timeout(std::time::Duration::from_secs(1800), rx).await { - Ok(Ok(text)) if !text.trim().is_empty() => text, - // A 韧性兜底:通知桥带回空内容 → 回查 DB 该会话最后一条 assistant(事实源),有则照发 - _ => { - let db = ctx.app.state::().db.clone(); - let fallback = last_assistant_reply(&db, &conv_id).await; - feishu_log(&format!("conv={conv_id} 通知桥空/超时,DB回查={}", truncate(fallback.as_deref().unwrap_or("无"), 60))); - match fallback { - Some(text) => text, - None => "⚠️ 本轮没有生成文本回复(已记录排查)。任务若仍在进行可稍后 /new 重置或重发。".to_string(), + // ── #14 任务式委托(2026-10-06):受理即返回,完成走事件驱动 ── + // spawn Ok = 任务已受理并在后台运行(loop 独立 task,30min 超时底座既有,断点续跑兜底)。 + // 原同步等待段(timeout 1800s oneshot)整体移除:发「走查代码」级重任务不再挂住本 + // 消息处理 30 分钟,后续消息(含注入旁路)即时响应。终版投递改由 completion_watcher + // 消费 ai_event_bus 的 AiAgentFinished 广播(引擎层 deliver_completion_report_if_waited + // 统一发出,单一事实源),原地 edit_card 占位卡为结果。 + // 卡片所有权移交(走查🔴#1 修正):占位卡自此只被 watcher 任务写——受理卡与终版 + // 都在 watcher 单任务内按序落卡(受理卡先写,快任务兜底命中时跳过受理卡直投终版), + // 彻底消除「受理卡与终版两个 HTTP 请求网络乱序,终版被受理卡覆盖丢失」的竞态窗口。 + drop(deliver_tx); + let _ = stream_task.await; // 等流式任务退出(快,只走通道关闭分支) + let ack = format!("✅ 已受理,后台执行中\n任务:{task_brief}"); + feishu_log(&format!("受理返回 chat={chat_id} conv={conv_id}")); + + // 完成订阅任务:独立于消息处理生命周期,直到 AiAgentFinished 广播到达投递终版。 + // (spawn Err 路径在上方已 return,永不到达此处——watcher 依赖引擎层正常完成广播) + tauri::async_runtime::spawn(async move { + run_completion_watcher(ctx, conv_id, placeholder_id, message_id, chat_id, ack).await; + }); +} + +/// 完成事件订阅任务(#14 两段式):占位卡唯一写者。入场先投受理卡,再消费 +/// ai_event_bus 的 AiAgentFinished(本 conv)原地 edit_card 为终版。引擎层 +/// deliver_completion_report_if_waited 是广播单一发出点,watcher 纯消费无状态。 +/// +/// 快任务竞态兜底(走查🟡#1 加门槛):广播可能先于订阅(秒级任务)——立即回查 +/// 一次,但须同时满足 conv 非活跃 + 回查时间晚于受理(防 spawn 返回但 loop 尚未 +/// 置 active 的时序窗口误判) + 文本非空,三者齐才直投终版跳过受理卡(卡片写入 +/// 单任务串行,无网络乱序覆盖)。任一不满足则先落受理卡再进事件等待。 +/// +/// 超时语义(走查🟡#2 修正):35min(> loop 30min 上限)超时投降级提示后**不退出**, +/// 继续挂事件等待——任务后台不丢,晚到的完成事件仍会送到卡片;watcher 与任务 +/// 同生命周期,彻底无果(引擎侧异常未广播)才在二次超时后弃守。 +async fn run_completion_watcher( + ctx: Arc, + conv_id: String, + placeholder_id: Option, + message_id: String, + chat_id: String, + ack: String, +) { + let state = ctx.app.state::(); + let mut rx = state.ai_event_bus.subscribe(); + let accepted_at = tokio::time::Instant::now(); + // 快任务兜底:立即回查一次(事件可能已错过);三分量门槛防时序窗口误判 + let settled = !state.conv_states.is_active(&conv_id) + && accepted_at.elapsed() >= std::time::Duration::from_secs(3) + && matches!(last_assistant_reply(&state.db, &conv_id).await, + Some(t) if !t.trim().is_empty()); + if settled { + let text = last_assistant_reply(&state.db, &conv_id) + .await + .unwrap_or_default(); + feishu_log(&format!("watcher 快任务兜底 conv={conv_id} len={}", text.len())); + deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &text).await; + return; + } + // 落受理卡(占位缺失回退新发,deliver_result 内部链路) + deliver_result(&ctx.client, &placeholder_id.clone(), &message_id.clone(), &chat_id.clone(), &ack).await; + // 事件等待循环:首超时 35 分钟投降级提示不退出,继续等晚到事件;二次超时弃守。 + let timeout = std::time::Duration::from_secs(2100); + loop { + let text = tokio::select! { + item = rx.recv() => match item { + Ok(v) => { + if v.get("type").and_then(|t| t.as_str()) != Some("AiAgentFinished") { + continue; + } + if v.get("conversation_id").and_then(|c| c.as_str()) != Some(conv_id.as_str()) { + continue; + } + // 广播语义:agent_id==conv_id 时 summary 即本会话最终报告(引擎层构造) + v.get("summary").and_then(|s| s.as_str()).unwrap_or("").to_string() + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => return, + }, + _ = tokio::time::sleep(timeout) => { + // 首超时:DB 回查兜底(任务可能刚完成事件已发完);回查有果照投终版, + // 无果投降级提示并**继续等待**(走查🟡#2:超时不弃投递,晚到事件仍送达) + match last_assistant_reply(&state.db, &conv_id).await { + Some(t) if !t.trim().is_empty() => t, + _ => { + let notice = "⏰ 任务执行超 35 分钟仍未返回(后台仍在运行,完成后本卡片自动更新;或本会话 /status 查询)。".to_string(); + deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, ¬ice).await; + continue; + } + } } - } - }; - feishu_log(&format!("回复 chat={chat_id} len={}", reply.len())); - // 终版投递:交流式任务单点执行(内部复用 deliver_result 原回退链); - // 任务已死(通道已关或异常退出)则兜底直投,此时无并发写卡方,回退链行为不变; - // 若任务在投递后异常退出,重复一次终版 edit_card 内容相同,无副作用 - let delivered = match deliver_tx.send(reply.clone()) { - Ok(()) => matches!(stream_task.await, Ok(())), - Err(_) => false, - }; - if !delivered { - deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &reply).await; + }; + feishu_log(&format!("watcher 终版 conv={conv_id} len={}", text.len())); + deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &text).await; + return; } } @@ -423,6 +518,13 @@ async fn run_stream_refresher( // 累计字符数 O(1) 维护,与上次已刷数比对判"有无新增量",避免无变化空刷烧配额 let mut acc_chars = 0usize; let mut painted_chars = 0usize; + // ── 过程流增量(todo.md 13):轮次/工具轨迹提炼,卡片头部一行摘要 ── + // 长任务(206 轮级)纯工具轮无文本增量,原版卡片静止疑似卡死;轨迹行给出「在干活」证据。 + // 提炼原则:决策价值过滤——只留「第 N 轮 + 当前工具名」,历史工具折叠计数,不刷屏不顶正文。 + let mut round: Option = None; + let mut current_tool: Option = None; + let mut tool_steps: usize = 0usize; + let mut painted_sig = String::new(); let mut ticker = tokio::time::interval(STREAM_FLUSH_INTERVAL); // 刷新落后只保最新帧不补帧:预览语义是最新输出才有价值 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); @@ -438,16 +540,37 @@ async fn run_stream_refresher( } item = delta_rx.recv() => match item { Ok(v) => { - // 双过滤:只要本 conv 的流式增量;type tag 即变体名(AiChatEvent 无 serde rename) - if v.get("type").and_then(|t| t.as_str()) != Some("AiTextDelta") { - continue; - } + // conv 路由过滤(全事件共用);type tag 即变体名(AiChatEvent 无 serde rename) if v.get("conversation_id").and_then(|c| c.as_str()) != Some(conv_id.as_str()) { continue; } - if let Some(delta) = v.get("delta").and_then(|d| d.as_str()) { - acc.push_str(delta); - acc_chars += delta.chars().count(); + match v.get("type").and_then(|t| t.as_str()) { + Some("AiTextDelta") => { + if let Some(delta) = v.get("delta").and_then(|d| d.as_str()) { + acc.push_str(delta); + acc_chars += delta.chars().count(); + } + } + // 轮次推进:立即标脏强制下一 tick 刷卡(纯工具轮无文本也有"活着"信号) + Some("AiAgentRound") => { + if let Some(r) = v.get("round").and_then(|r| r.as_u64()) { + round = Some(r as u32); + painted_chars = painted_chars.saturating_sub(1); // 标脏:强制重刷 + } + } + // 工具开始:更新当前工具名;完成(含失败)即清空当前工具 + Some("AiToolCallStarted") => { + if let Some(name) = v.get("name").and_then(|n| n.as_str()) { + current_tool = Some(name.to_string()); + tool_steps += 1; + painted_chars = painted_chars.saturating_sub(1); // 标脏 + } + } + Some("AiToolCallCompleted") => { + current_tool = None; + painted_chars = painted_chars.saturating_sub(1); // 标脏 + } + _ => {} } } // 预览允许丢帧:终版事实源在通知桥 oneshot,消费落后只降级预览不断链路 @@ -467,11 +590,18 @@ async fn run_stream_refresher( let Some(pid) = placeholder_id.as_deref().filter(|id| !id.is_empty()) else { continue; }; + // 信号标脏:过程事件(轮次/工具)已把 painted_chars 回拨 1,此处字符数不等即刷 if acc_chars == painted_chars { continue; } - if update_card(&ctx.client, pid, &stream_preview(&acc)).await { + // 签名去重:正文长度与轨迹行均未变则跳过(防同 tick 内事件风暴空刷) + let sig = format!("{}|{}|{}|{}", acc_chars, round.unwrap_or(0), current_tool.as_deref().unwrap_or(""), tool_steps); + if sig == painted_sig { + continue; + } + if update_card(&ctx.client, pid, &stream_preview(&acc, round, current_tool.as_deref(), tool_steps)).await { painted_chars = acc_chars; + painted_sig = sig; } // 刷失败静默跳过,下个 tick 自然重试 } @@ -479,9 +609,28 @@ async fn run_stream_refresher( } } -/// 流式中间态文本:尾部加光标符示意仍在生成;超上限从头截断保尾部,前缀标注省略事实 -/// 防读者误把中间态当全文 -fn stream_preview(acc: &str) -> String { +/// 流式中间态卡片:头部一行过程摘要(轮次/工具轨迹提炼)+ 正文流式增量(保尾截断)。 +/// +/// 提炼原则(todo.md 13「决策价值过滤」):历史步骤折叠为计数,只透出「当前在干什么」—— +/// 读者要的是"活着没卡死+进行到哪",不是 20 个工具名的流水墙;正文仍以流式文本为主, +/// 纯工具轮(无文本)时头部行是唯一进度证据。 +fn stream_preview( + acc: &str, + round: Option, + current_tool: Option<&str>, + tool_steps: usize, +) -> String { + // 头部摘要行:轮次 + 当前工具(执行中)/已执行步数;全部缺省(首轮文本前)不显头行 + let mut head = String::new(); + if round.is_some() || tool_steps > 0 { + let r = round.map(|n| format!("第 {n} 轮")).unwrap_or_default(); + let t = match current_tool { + Some(name) => format!("⚙ `{name}` 执行中"), + None => format!("已完成 {tool_steps} 步工具调用"), + }; + head = format!("**{r} · {t}**\n\n"); + } + // 正文:超上限保尾截断(对齐原口径) let total = acc.chars().count(); let body = if total > STREAM_PREVIEW_MAX_CHARS { let tail: String = acc.chars().skip(total - STREAM_PREVIEW_MAX_CHARS).collect(); @@ -489,7 +638,7 @@ fn stream_preview(acc: &str) -> String { } else { acc.to_string() }; - format!("{body}\n▌") + format!("{head}{body}\n▌") } /// markdown 回复:interactive 卡片承载(lark_md 原生渲染标题/列表/粗体,text 类型会符号裸露) @@ -759,7 +908,7 @@ async fn save_chat_map(db: &df_storage::db::Database, map: &HashMap { font-size: 13px; max-width: 90vw; box-shadow: 0 4px 16px rgba(0,0,0,0.3); } -.toast-enter-active, .toast-leave-active { transition: all 0.25s var(--df-ease); } +.toast-enter-active, .toast-leave-active { transition: opacity 0.25s var(--df-ease), transform 0.25s var(--df-ease); } .toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(10px); } /* 后台运行提示:同 df-toast 形态,信息语义用 accent 底色与错误红区分 */ .df-toast--info { background: var(--df-accent); } diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index 439c6a9..e5f8c7f 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -597,7 +597,12 @@ async function backfillQueued(idx: number) { function scrollToBottom(smooth = false) { messageListRef.value?.scrollToBottom(smooth) } +// listener 租约句柄(mount 借/unmount 还,所有权模型;startListener 失败时为 null,归还侧判空) +let _listenerRelease: (() => void) | null = null onBeforeUnmount(() => { + // 归还 listener 租约(最后一份归还才真拆单例 listener;双重归还/下溢由 useAiEvents dev 断言暴露) + _listenerRelease?.() + _listenerRelease = null // V7 排队时钟 interval 随组件卸载清理(watch 随卸载停但 interval 本体不自停,须显式清) if (queueTickTimer !== null) { clearInterval(queueTickTimer); queueTickTimer = null } // 全局快捷键卸载时移除 window listener,防分离窗口/切组件后泄漏 @@ -609,9 +614,8 @@ onBeforeUnmount(() => { // 流式 rAF 清理(streaming watch 内 cancelAnimationFrame)随 MessageList 迁移,子组件 // onBeforeUnmount 自管(子 watch streaming 已注册,卸载自动停)。 // 释放后端 listener(ai-chat-event/ai-conversation-changed)+清流式看门狗。 - // 之前 stopListener 定义但全仓零调用 → listener+watchdog 永久泄漏;分离窗口主窗口 AiChat - // 卸载(走 App.vue v-if detach)时 listener 仍挂后端,watchdog 130s 后写已卸载 state。 - store.stopListener() + // 旧 stopListener 已删除(所有权模型:租约在 onBeforeUnmount 顶部归还,store 侧 releaseLease + // 在最后一份归还时拆 listener + 清看门狗/计时器,此处不再重复清)。 // F-15 阶段2: 释放上下文管理事件监听器 store.stopContextListener() if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // @@ -635,9 +639,27 @@ onMounted(async () => { console.debug(`[启动] AiChat mounted: ${(performance.now() - t0).toFixed(0)}ms`) // Markdown 渲染器预热随 MessageList 子组件迁移(子 onMounted 内 loadMarkdown); // useMarkdown 模块级单例,父不再直接调用。 - await store.startListener() - await store.loadProviders() - await store.loadConversations() + // ── 分级初始化(根本修 #5)── + // 第一级(致命):listener 租约。失败 = 无事件通道,面板功能尽失,toast + 止损 + // (重开面板 re-mount 即重试);租约句柄 null,卸载侧判空归还。 + try { + _listenerRelease = await store.startListener() + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + console.error('[AiChat] listener 注册失败(致命,面板初始化中止):', e) + showToast(t('aiChat.initError', { msg }), 'error') + return + } + // 第二级(可降级):providers/conversations 列表。失败仅列表空,面板/发送/流式仍可用, + // toast 提示但继续初始化(skills/上下文监听/生成态恢复不依赖列表数据)。 + try { + await store.loadProviders() + await store.loadConversations() + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + console.error('[AiChat] 列表加载失败(降级继续):', e) + showToast(t('aiChat.initDegraded', { msg }), 'warning') + } store.loadSkills() store.loadToolRisks() // @ 提及预加载: AiChat 的 @ 联想读取 projectsStore.projects/tasks, diff --git a/src/components/ConfirmDialog.vue b/src/components/ConfirmDialog.vue index eeeafec..d83795f 100644 --- a/src/components/ConfirmDialog.vue +++ b/src/components/ConfirmDialog.vue @@ -100,7 +100,7 @@ watch(() => props.visible, () => {}) border-radius: var(--df-radius-sm, 4px); border: 0.5px solid var(--df-border, #ddd); cursor: pointer; - transition: all 0.15s; + transition: background 0.15s, color 0.15s, border-color 0.15s; } .confirm-btn--cancel { background: transparent; diff --git a/src/components/Paginator.vue b/src/components/Paginator.vue index 275c218..565c777 100644 --- a/src/components/Paginator.vue +++ b/src/components/Paginator.vue @@ -143,7 +143,7 @@ function onPageSizeChange(val: string) { color: var(--df-text-secondary); font-size: 12px; cursor: pointer; - transition: all 0.15s; + transition: background 0.15s, color 0.15s, border-color 0.15s; } .page-btn:hover:not(:disabled) { diff --git a/src/components/ToolCard.vue b/src/components/ToolCard.vue index d048abd..a3d30f3 100644 --- a/src/components/ToolCard.vue +++ b/src/components/ToolCard.vue @@ -491,7 +491,7 @@ const visibleArgs = computed(() => { .ai-tool-btn { padding: 5px 12px; font-size: 11px; font-weight: 500; border-radius: var(--df-radius-sm); border: 0.5px solid var(--df-border); cursor: pointer; - display: flex; align-items: center; gap: 4px; transition: all 0.15s var(--df-ease); font-family: var(--df-font-sans); + display: flex; align-items: center; gap: 4px; transition: background 0.15s var(--df-ease), color 0.15s var(--df-ease), border-color 0.15s var(--df-ease); font-family: var(--df-font-sans); } .ai-tool-btn--approve { background: var(--df-success-bg); color: var(--df-success); } .ai-tool-btn--approve:hover { background: var(--df-success); color: #fff; border-color: var(--df-success); } diff --git a/src/components/ToolResultBody.vue b/src/components/ToolResultBody.vue index 6d8dfaf..0848a7d 100644 --- a/src/components/ToolResultBody.vue +++ b/src/components/ToolResultBody.vue @@ -539,7 +539,7 @@ watch(() => props.tc.status, (s) => { padding: 2px 8px; cursor: pointer; flex-shrink: 0; - transition: all 0.15s var(--df-ease); + transition: color 0.15s var(--df-ease), background 0.15s var(--df-ease), border-color 0.15s var(--df-ease); } .ai-tool-expand-btn:hover { color: var(--df-text-secondary); diff --git a/src/components/ai/ApprovalOverlay.vue b/src/components/ai/ApprovalOverlay.vue index 9b7c281..eaf7341 100644 --- a/src/components/ai/ApprovalOverlay.vue +++ b/src/components/ai/ApprovalOverlay.vue @@ -125,7 +125,7 @@ function onDismiss() { border-radius: var(--df-radius-md); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px var(--df-accent-soft); cursor: pointer; - transition: all 0.18s var(--df-ease); + transition: transform 0.18s var(--df-ease), border-color 0.18s var(--df-ease), box-shadow 0.18s var(--df-ease); user-select: none; } @@ -197,7 +197,7 @@ function onDismiss() { color: var(--df-text-dim); cursor: pointer; flex-shrink: 0; - transition: all 0.15s var(--df-ease); + transition: background 0.15s var(--df-ease), color 0.15s var(--df-ease); } .approval-overlay-close:hover { @@ -208,7 +208,7 @@ function onDismiss() { /* 进出动画:从右侧滑入 */ .approval-overlay-enter-active, .approval-overlay-leave-active { - transition: all 0.25s var(--df-ease); + transition: opacity 0.25s var(--df-ease), transform 0.25s var(--df-ease); } .approval-overlay-enter-from, diff --git a/src/components/ai/ChatInput.vue b/src/components/ai/ChatInput.vue index 6e4c254..604a763 100644 --- a/src/components/ai/ChatInput.vue +++ b/src/components/ai/ChatInput.vue @@ -1219,7 +1219,7 @@ defineExpose({ background: var(--df-bg-raised); color: var(--df-text-dim); cursor: pointer; - transition: all 0.15s; + transition: background 0.15s, color 0.15s; flex-shrink: 0; } .ai-img-pick-btn { @@ -1233,7 +1233,7 @@ defineExpose({ background: transparent; color: var(--df-text-dim); cursor: pointer; - transition: all 0.15s; + transition: background 0.15s, color 0.15s; flex-shrink: 0; margin-right: 4px; } diff --git a/src/components/ai/EmptyState.vue b/src/components/ai/EmptyState.vue index 4620d81..f881768 100644 --- a/src/components/ai/EmptyState.vue +++ b/src/components/ai/EmptyState.vue @@ -122,7 +122,7 @@ const examplePrompts = [ text-align: left; cursor: pointer; opacity: 0.85; - transition: all 0.15s var(--df-ease); + transition: border-color 0.15s var(--df-ease), color 0.15s var(--df-ease), opacity 0.15s var(--df-ease), background 0.15s var(--df-ease); } .ai-empty-prompt:hover { border-color: var(--df-accent); diff --git a/src/components/ideas/IdeaDetail.vue b/src/components/ideas/IdeaDetail.vue index ebc0176..dddb68c 100644 --- a/src/components/ideas/IdeaDetail.vue +++ b/src/components/ideas/IdeaDetail.vue @@ -935,7 +935,7 @@ watch(() => props.idea.id, loadHistory) border-radius: var(--df-radius); font-size: 13px; cursor: pointer; - transition: all 0.15s; + transition: background 0.15s, color 0.15s; } .btn-evaluate:hover { diff --git a/src/components/layout/AppSidebar.vue b/src/components/layout/AppSidebar.vue index 1ee1b8a..e968e1f 100644 --- a/src/components/layout/AppSidebar.vue +++ b/src/components/layout/AppSidebar.vue @@ -273,7 +273,7 @@ const secondaryNav = [ text-decoration: none; font-size: 12px; font-weight: 450; - transition: all 0.18s var(--df-ease); + transition: background 0.18s var(--df-ease), color 0.18s var(--df-ease); position: relative; } .df-sidebar--collapsed .nav-link { diff --git a/src/components/project/FileExplorer.vue b/src/components/project/FileExplorer.vue index 709a15e..1c6ab1a 100644 --- a/src/components/project/FileExplorer.vue +++ b/src/components/project/FileExplorer.vue @@ -740,7 +740,7 @@ async function onRemoveModule() { color: var(--df-text-dim); cursor: pointer; font-size: 11px; - transition: all 0.15s; + transition: color 0.15s, border-color 0.15s; position: relative; } diff --git a/src/components/project/FilePreview.vue b/src/components/project/FilePreview.vue index bb397a5..bd7b8cc 100644 --- a/src/components/project/FilePreview.vue +++ b/src/components/project/FilePreview.vue @@ -859,7 +859,7 @@ onUnmounted(() => { background: transparent; color: var(--df-text-secondary); cursor: pointer; - transition: all 0.15s; + transition: background 0.15s, color 0.15s; white-space: nowrap; } @@ -896,7 +896,7 @@ onUnmounted(() => { color: var(--df-text-secondary); font-size: 12px; cursor: pointer; - transition: all 0.15s; + transition: background 0.15s, color 0.15s, border-color 0.15s; } .outline-overlay-btn:hover { diff --git a/src/components/project/GitChanges.vue b/src/components/project/GitChanges.vue index 17c0786..7f9edb4 100644 --- a/src/components/project/GitChanges.vue +++ b/src/components/project/GitChanges.vue @@ -569,7 +569,7 @@ watch(() => props.refreshKey, () => { border-radius: 4px; display: flex; align-items: center; - transition: all 0.15s; + transition: color 0.15s, background 0.15s; } .branch-switch-btn:hover { color: var(--df-text); @@ -581,7 +581,7 @@ watch(() => props.refreshKey, () => { .gc-tab { flex: 1; padding: 8px; background: none; border: none; border-bottom: 2px solid transparent; color: var(--df-text-dim); - cursor: pointer; font-size: 12px; transition: all 0.15s; + cursor: pointer; font-size: 12px; transition: color 0.15s, background 0.15s, border-color 0.15s; } .gc-tab.active { color: var(--df-text); border-bottom-color: var(--df-accent); } .gc-tab:hover { color: var(--df-text); } diff --git a/src/components/project/ProjectCard.vue b/src/components/project/ProjectCard.vue index 1c234a9..f24b106 100644 --- a/src/components/project/ProjectCard.vue +++ b/src/components/project/ProjectCard.vue @@ -124,7 +124,7 @@ onBeforeUnmount(() => window.removeEventListener('resize', onWinResize)) border: 0.5px solid var(--df-border); border-radius: var(--df-radius-lg); padding: var(--df-pad-panel); - transition: all 0.15s; + transition: border-color 0.15s; cursor: pointer; } .project-card:hover { diff --git a/src/components/settings/settings.css b/src/components/settings/settings.css index 39b2af3..912e4a5 100644 --- a/src/components/settings/settings.css +++ b/src/components/settings/settings.css @@ -113,7 +113,7 @@ .toggle input:checked + .toggle-slider::before { transform: translateX(18px); background: #fff; } /* ===== 按钮(原 Settings.vue,列表型 Panel 共用) ===== */ -.btn { padding: 8px 16px; border: none; border-radius: var(--df-radius-sm); font-size: 13px; cursor: pointer; transition: all 0.15s; } +.btn { padding: 8px 16px; border: none; border-radius: var(--df-radius-sm); font-size: 13px; cursor: pointer; transition: background 0.15s, color 0.15s; } .btn-primary { background: var(--df-accent); color: #fff; } .btn-primary:hover { background: var(--df-accent-hover); } .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } diff --git a/src/styles/components.css b/src/styles/components.css index 64c9f78..85bc601 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -351,7 +351,7 @@ background: transparent; color: var(--df-text-dim); cursor: pointer; - transition: all 0.15s; + transition: background 0.15s, color 0.15s; } .ai-btn-icon:hover { background: var(--df-bg-card); diff --git a/src/styles/global.css b/src/styles/global.css index 43f7e02..48d987d 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -139,6 +139,25 @@ html, body, #app { ::-webkit-scrollbar-thumb { background: var(--df-scrollbar); border-radius: var(--df-radius-xs); } ::-webkit-scrollbar-thumb:hover { background: var(--df-scrollbar-hover); } +/* ── UX 走查 P2-1(2026-10-06):Web 规范合规全局两刀 ── + * ① :focus-visible 兜底 outline:全仓 outline:none 25 处散点只删可见环不给补偿, + * 键盘用户失焦可见性;全局一条兜底(仅键盘导航触发,鼠标点击不显),散点无需逐处改。 + * ② 弹层 overscroll-behavior:contain:弹层内滚到边界时滚动穿透到底层页面 + * (背景跟着晃),全仓此前零命中;约定所有浮层容器类统一滚动锁,新增弹层自动生效。 + * 选择器=实证类名清单(2026-10-06 全仓 grep 核对):modal-overlay 10 处 / + * TopBar 四面板 inline-list / ai-more-menu / ConfirmDialog 根。 + */ +:focus-visible { + outline: 2px solid var(--df-accent); + outline-offset: 1px; +} +.modal-overlay, +.ai-goals-inline-list, .ai-history-inline-list, +.ai-summary-inline-list, .ai-context-inline-list, +.ai-more-menu, .confirm-box { + overscroll-behavior: contain; +} + /* — Selection — */ /* 选中态用 accent 实色 + 白字:保证紫底气泡(accent)选中也高对比可读, 避免 accent-soft(透明紫)叠 accent 紫底导致紫字紫底不可见(深色主题) */ @@ -182,7 +201,7 @@ html, body, #app { font-size: 13px; font-weight: 500; cursor: pointer; - transition: all 0.15s var(--df-ease); + transition: background 0.15s var(--df-ease), color 0.15s var(--df-ease), border-color 0.15s var(--df-ease); white-space: nowrap; } .btn-primary { background: var(--df-accent); color: #fff; } @@ -255,7 +274,7 @@ html, body, #app { color: var(--df-text-secondary); font-size: 13px; cursor: pointer; - transition: all 0.15s; + transition: background 0.15s, color 0.15s; } .filter-btn:hover { background: var(--df-bg-card); color: var(--df-text); } .filter-btn.active { diff --git a/src/views/ApprovalPopup.vue b/src/views/ApprovalPopup.vue index 14eedec..de8c941 100644 --- a/src/views/ApprovalPopup.vue +++ b/src/views/ApprovalPopup.vue @@ -591,7 +591,7 @@ function onDragEnd() { isDragging.value = false } color: var(--df-text-dim); cursor: pointer; flex-shrink: 0; - transition: all 0.15s var(--df-ease); + transition: background 0.15s var(--df-ease), color 0.15s var(--df-ease); } .popup-header-close:hover { background: var(--df-sidebar-hover); @@ -629,7 +629,7 @@ function onDragEnd() { isDragging.value = false } border-radius: var(--df-radius-sm); background: var(--df-sidebar-hover); border-left: 2px solid var(--df-accent); - transition: all 0.15s var(--df-ease); + transition: background 0.15s var(--df-ease); } .popup-item--path { border-left-color: var(--df-accent); } .popup-item--risk { border-left-color: var(--df-warning, #f0a020); } @@ -725,7 +725,7 @@ function onDragEnd() { isDragging.value = false } font-size: 11px; font-weight: 600; cursor: pointer; - transition: all 0.15s var(--df-ease); + transition: background 0.15s var(--df-ease), color 0.15s var(--df-ease), border-color 0.15s var(--df-ease); } .popup-btn:disabled { opacity: 0.6; @@ -806,7 +806,7 @@ function onDragEnd() { isDragging.value = false } font-size: 12px; font-weight: 600; cursor: pointer; - transition: all 0.15s var(--df-ease); + transition: background 0.15s var(--df-ease), color 0.15s var(--df-ease); } .popup-footer:hover { background: var(--df-accent); diff --git a/src/views/AuditLog.vue b/src/views/AuditLog.vue index c07633a..e4f0495 100644 --- a/src/views/AuditLog.vue +++ b/src/views/AuditLog.vue @@ -656,7 +656,7 @@ onMounted(() => { color: var(--df-text-dim); font-size: 13px; cursor: pointer; - transition: all 0.15s; + transition: color 0.15s, border-color 0.15s; margin-bottom: -0.5px; } .audit-tabs .tab-btn:hover { color: var(--df-text); } diff --git a/src/views/Ideas.vue b/src/views/Ideas.vue index b56b71f..62f9597 100644 --- a/src/views/Ideas.vue +++ b/src/views/Ideas.vue @@ -585,7 +585,7 @@ watch(() => route.params.id, (id) => { padding: 14px; border-radius: var(--df-radius); cursor: pointer; - transition: all 0.15s; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; margin-bottom: 4px; } .idea-card-body { diff --git a/src/views/Knowledge.vue b/src/views/Knowledge.vue index fb3c220..1e2d2ce 100644 --- a/src/views/Knowledge.vue +++ b/src/views/Knowledge.vue @@ -498,7 +498,7 @@ onMounted(() => { .top-tabs { display: flex; gap: 4px; margin-bottom: var(--df-gap-page); border-bottom: 0.5px solid var(--df-border); } .top-tab { padding: 8px 16px; border: none; background: transparent; color: var(--df-text-secondary); - font-size: 13px; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.15s; + font-size: 13px; cursor: pointer; border-bottom: 2px solid transparent; transition: color 0.15s, border-color 0.15s; display: flex; align-items: center; gap: 6px; } .top-tab:hover { color: var(--df-text); } @@ -543,7 +543,7 @@ onMounted(() => { .list-cards { flex: 1; overflow-y: auto; padding: 8px 12px 12px; display: flex; flex-direction: column; gap: 8px; } .kn-card { background: var(--df-bg-card); border: 0.5px solid var(--df-border); - border-radius: var(--df-radius); padding: 10px 12px; cursor: pointer; transition: all 0.15s; + border-radius: var(--df-radius); padding: 10px 12px; cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s; } .kn-card:hover { border-color: var(--df-accent); } .kn-card.selected { border-color: var(--df-accent); box-shadow: inset 3px 0 0 var(--df-accent); } diff --git a/src/views/ProjectDetail.vue b/src/views/ProjectDetail.vue index 99e311e..6e26381 100644 --- a/src/views/ProjectDetail.vue +++ b/src/views/ProjectDetail.vue @@ -1399,7 +1399,7 @@ onUnmounted(() => { color: var(--df-text-dim); font-size: 13px; cursor: pointer; - transition: all 0.15s; + transition: color 0.15s, border-color 0.15s; margin-bottom: -0.5px; } .tab-btn:hover { @@ -1495,7 +1495,7 @@ onUnmounted(() => { color: var(--df-text-dim); font-size: 10px; cursor: pointer; - transition: all 0.15s; + transition: background 0.15s, color 0.15s, border-color 0.15s; white-space: nowrap; } .task-filter-chips .filter-chip:hover { diff --git a/src/views/Projects.vue b/src/views/Projects.vue index 5c571b8..6ac8e9b 100644 --- a/src/views/Projects.vue +++ b/src/views/Projects.vue @@ -555,7 +555,7 @@ async function runImport() { font-size: 14px; line-height: 1; cursor: pointer; - transition: all 0.15s; + transition: background 0.15s, color 0.15s; } .view-toggle-btn:hover { background: var(--df-bg-card); color: var(--df-text); } .view-toggle-btn.active { diff --git a/src/views/Tasks.vue b/src/views/Tasks.vue index c987329..9600fcb 100644 --- a/src/views/Tasks.vue +++ b/src/views/Tasks.vue @@ -1022,7 +1022,7 @@ onUnmounted(() => { background: var(--df-bg); border: 0.5px solid var(--df-border); cursor: pointer; - transition: all 0.15s; + transition: background 0.15s, color 0.15s, border-color 0.15s; } .seg-btn:first-child { border-radius: var(--df-radius-sm) 0 0 var(--df-radius-sm); } .seg-btn:last-child { border-radius: 0 var(--df-radius-sm) var(--df-radius-sm) 0; } @@ -1239,7 +1239,7 @@ onUnmounted(() => { color: var(--df-text-dim); font-size: 11px; cursor: pointer; - transition: all 0.15s; + transition: background 0.15s, color 0.15s, border-color 0.15s; white-space: nowrap; } .filter-chip:hover {