43-untitled.md 19 KB

前端执行票 —— 工单堆叠图语义间隔与饼图交互

Label: ready-for-agent Category: frontend-execution

来源

承接票37收口时维护者反馈第1、2条。

范围

  1. 调查工单横向堆叠统计图中部分类别间的小间隔(待接单/已接单、已结案/已取消):确认是浏览器像素舍入、条段绘制策略还是有意表达语义边界;不得把不明原因的视觉现象直接当成设计。
  2. 若为设计语义,补充图上说明/图例提示;若为绘制缺陷,修复为连续、可解释的条段。
  3. 为饼图/环图补齐交互:悬停放大或高亮、共享详情浮层、键盘焦点/可访问名称;交互风格与已验收横条统计保持一致。
  4. 验收:不同统计块的颜色、间隔含义明确;饼图扇区 hover/focus 有明显反馈并展示类别与数量;不改变统计口径。

非目标

  • 不改变后端统计字段与工单状态口径。
  • 不重做票37已收口的共享省略组件。

进度

  • 2026-10-15 上午~傍晚:三轮(接力取整 → 整楔命中区 → shallowRef+放大档),每轮一次驳回,详见下方历史与本票 Comments。
  • 2026-10-16(第四轮根因,用户反馈:hover 停留几秒、鼠标未移出,放大与联动高亮自己消失):外壳时钟每秒 clockText 变化重渲染 + :view="panelViewOf(panelId)" 是模板方法调用 → 每帧重算出全新 block 对象;第三轮的 owner === toRaw(block) 身份比较在下一帧静默失效——浮层存的是标量所以还在,高亮却掉了,症状完全吻合。修复=联动键改 chartScope(index, block)=视图名|块序号|块类型|块标题的稳定标量串(数据刷新前后不变,仍区分并排同类图与块内不同行)。批量替换时漏掉 clearChartHover 6 处(前缀不含 set),mouseleave 仍传 block → 高亮清不掉,被 t43 P1 假红逮出修正;16 个调用点全部统一。
  • 2026-10-16 09:21(第四轮完成态走查,用户授权延续,real):hover 持续 3.5 秒跨 3+ 时钟 tick,饼图「报警」/环形图「排水」的放大+图例联动+浮层全程保持(页面时钟 09:20:23→09:21:41 跨多次重渲染)——不再自灭。t43 扩到 23/23(P8 标量 scope 守卫 + P8b 运行时探针:对象身份键在新对象下查不回、scope 键查得回;变异 M8 退化回对象身份 → P8 打红);11 组离线回归全绿。

Status: resolved Type: task Blocked by: Labels: ready-for-agent

诊断结论(范围 1)

小间隔=绘制缺陷(像素舍入),不是设计语义。 判定过程没有把视觉现象当设计:

  • 机制:components/PanelBlocks.vue 的堆叠段样式原先对 left 与 width 各自独立取整 —— left: Math.round(row.start*100)%、width: Math.round(row.ratio*100)%。相邻段之间露出的底色缝 = round(nextStart*100) − (round(start*100) + round(ratio*100)),该值可为 ±1 个百分点(取决于两段各自的小数部分)。
  • 复现:用 mock 报警族工单向量 [0,1,2,1,1,3,1,0](mocks/contractMocks.js:2419 alarmWorkOrderStates,total=9)逐段复算,「待验收→已结案」边界 left+width=55 而下一段 left=56,实测 1 个百分点缝,与维护者所见一致(预警效率/报警族两处堆叠条同一算法,同一缺陷)。
  • 反证「非有意设计」:CSS 里 .panel-stacked__seg 无 margin、条容器无 gap/分隔声明,分段色由 barTone 逐态给(八态八色相)——若真要表达语义边界,应当是显式的分隔样式,而不是随数据总数随机出现在某些边界上的缝。同一对类别是否露缝取决于计数(待派单/待接单边界 0→0 不露,待验收/已结案露 1),这正是舍入噪声的特征,不是语义设计的特征。
  • 结论:走票面第 2 条的「绘制缺陷」分支 → 修复为连续、可解释的条段(不加「语义间隔」说明,因为不存在这种语义)。

实施(范围 2/3)

交付 1:堆叠条段边界改「接力取整」,连续由构造保证

  • domain/panelBlocks.js stackedBlock() 每行新增 startPct / widthPct:由同一个累加器取整接力(endPct = round(acc/total*100),下一段 startPct = 上一段 endPct),末段终点恒为 100。无缝不是运气舍入的结果,而是代码结构保证(每段起点定义上就是上一段终点)。
  • components/PanelBlocks.vue 堆叠段样式改消费这两个整数百分比,删除段内的 Math.round(...) 独立取整。
  • 口径不变(票面非目标):total / start / ratio 原样保留未取整的精确占比,只改绘制边界;后端统计字段与工单状态码一字未动。

交付 2:饼图/环图交互(第三轮最终形态 —— 整楔命中区 + shallowRef 联动 + 明显放大档)

第一轮把 tabindex/hover 挂在可见细环带上(46/25、42/26),真实指针落在圆心孔/环带外全部不触发; 第二轮改整楔命中区后浮层触发修好了,但联动状态 chartHover = ref(...) 被 Vue 深层代理, owner === block 恒 false,.is-hovered 从未生效——扇区放大/图例高亮全部落空(第三轮驳回)。 第三轮:shallowRef 存联动状态(身份保持原始对象,整体替换即触发渲染);放大档加强到 scale 1.1 + brightness 1.35 + 白描边(原 1.05/1.06 在 64px 图上仅 ~1.5px 位移,不够"明显")。

  • 整楔命中区(hitPath):statusPie.js 的 hitWedge 与 panelBlocks.js 的 donutBlock 为每个扇区额外产出一条从圆心出发的整楔(内半径 0)路径。渲染层把它画在可见扇区之上(fill: transparent),悬停/聚焦/键盘全部挂命中楔——热区与视觉扇区同角度同边界,圆心孔不再是死区。
  • 扇区 ⇄ 图例双向联动(chartHover = shallowRef):任一侧悬停/聚焦,可见扇区加 .is-hovered(放大+提亮+描边)、对应图例行加 .is-hovered(底色+文字提亮),同一个共享详情浮层(票 37 的 showChartTip)。按 owner=块 + key=行 记账:两块同类图并排不串亮;清除比对 owner+key 防闪烁。
  • 键盘可达:命中楔 tabindex="0" + role="img" + 每片 aria-label(类别:数量);:focus-visible 画白色描边。外层 <svg> role="group"(img 会藏子树使名称不可达)。
  • 详情入口只留一个:扇区不带原生 <title>(票 37 趋势点成例)。
  • 图例标注与数值成对(维护者反馈②):饼图行去 justify-content: space-between、环形计数去 margin-left: auto、标签 flex: 0 1 auto(只缩不涨——1 1 auto 会把数值顶回行尾)。环形中心读数 pointer-events: none 让位给命中楔。

验收对照(票面四条)

  1. 不同统计块的颜色、间隔含义明确 —— 间隔缺陷已消除(连续条段),颜色沿用票 37 的 barTone 八态八色相,未新增含义不明的视觉元素。
  2. 饼图扇区 hover/focus 有明显反馈并展示类别与数量 —— 第三轮以运行时证据判(不再以浮层出现代判):悬停/聚焦任一侧后,可见扇区 class 真带 is-hovered 且计算样式 transform/filter 变化、对应图例行同亮;浏览器正反证见「进度」与 Comments。
  3. 不改变统计口径 —— total/start/ratio 保持精确值;闸门 S4 钉住这一点。
  4. 零依赖逻辑闸门 —— t43-stacked-gap-and-pie-interactions.mjs 第三轮扩到 22 条(新增 P9 shallowRef 静态守卫 + P9c 运行时身份探针)。

验证与对抗式自查

闸门 t43(第三轮 22/22,全离线):S 组堆叠连续(接力构造、旧缺陷实证复现 + 新公式缝=0、宽度和=100、0 计数行保留、口径不变、渲染层消费接力值);P 组交互(P0 两图扇区带 hitPath、P1 命中楔承载 tabindex/aria-label/hover/focus、P1b 可见扇区被动化、P2 无原生 title、P3 svg role=group、P4 .is-hovered 放大档 + 命中楔焦点描边、P5 图例⇄扇区双向、P6 数值紧跟标签、P7 中心读数让位、P8 联动记账结构、P9 联动状态必须 shallowRef + P9c Vue 运行时身份探针(ref 深层代理会破坏 owner===block,shallowRef 不会));D 组 donutBlock 形状与可点接缝不回归。

变异测试累计 7 处打红:M1 独立取整→S 组红;M2 删 tabindex→P1 红;M3 恢复 <title>→P2 红;M4 图例删 setChartHover→P5 红;M5 恢复 margin-left:auto→P6 红;M6 标签改回生长(同时暴露 CSS 守卫必须先剥注释)→P6 红;M7 shallowRef 改回 ref→P9 红(本轮根因的反向锁定)。

回归(离线 11 组全绿):t43、t50 30/30、t42、t15、t23、t27、t16-stores、t16-alarm-order、t22、t19、t09。@vue/compiler-sfc 结构校验全过。

既有红闸门归因:t20-overview-panels 60/66 的 6 条红与票 42 记录的 HEAD 基线同 6 条,与本票 diff 零交集。

教训留档:① 归因实验禁用 git stash push -- <文件>(本工作树会写坏文件),用票 42 的 HEAD worktree 对照法;② 带孔/细环 SVG 图形的 hover 断言必须打在用户视角位置(圆心、环带外);③ 联动类交互的验收判据必须是"被联动元素的状态本身"(class/计算样式),不是旁证(浮层);④ 把对象存进深层 ref 再用 === 比身份,会被 Vue 代理拆穿——跨响应式边界比身份要用 shallowRef/toRaw 或稳定标量键。

遗留与下一步

  • 入库状态(2026-10-16 更新):第一轮 hunks 已随并行票 37 批次 a40aedc 推送 origin/master;第二/三/四轮 hunks(domain/panelBlocks.js、domain/statusPie.js、components/PanelBlocks.vue、verify/t43-*.mjs)经独立临时 index 的 plumbing 提交为 7f71992 并推送 origin/master。工作树混有并行票 38/47 在制品,故只摘本票 hunks(PanelBlocks.vue 13 处逐个复核归属,剔除票 38 的维度切换与 KPI wide 布局共 6 处),未碰他人暂存区与工作树。
  • 相邻票边界:票 44 的「条段之间有明确分割」是有意装饰,与本票消除的舍入缝无关,勿混用。

Answer

(第三轮最终) 双向联动的真正根因不在几何、不在 CSS,而在响应式身份比较:chartHover = ref(null) 存入 {owner: block} 后被 Vue 深层代理,读回的 owner 是代理对象,与模板 v-for 拿到的原始 block === 恒 false → .is-hovered 永不生效(扇区不放大、图例不亮),而浮层不依赖这个比较所以照常弹出——一个根因同时解释「只出详情」与「联动失败」。修复=shallowRef + 两侧 toRaw() 比较;放大档加到 scale(1.1)+brightness(1.35)+白描边(原 1.05/1.06 在 64–72px 图上仅 1.5–2px 位移,不够"明显")。验收判据本轮改为"被联动元素自身的状态"(class/视觉放大),不再以浮层出现代判:真实浏览器(real)悬停扇区→扇区放大+描边+图例亮、悬停图例→扇区放大+描边(双向成立)、环形图与键盘 focus 同法通过;变异 M7(shallowRef 改回 ref)被 P9 打红,P9c 在真 Vue 里钉住机制本身。t43 22/22、11 组回归全绿。第二/三轮 hunks 已于 2026-10-16 随第四轮一并提交(7f71992,plumbing 限定提交)并推送 origin/master。

Comments

local — 2026-10-15T03:55:21.458Z

诊断+实施完成(2026-10-15):① 间隔根因=绘制缺陷——堆叠段 left/width 各自独立取整露出底色缝(mock 报警向量 [0,1,2,1,1,3,1,0] 实测「待验收→已结案」边界 1 个百分点,反证非语义设计:CSS 无任何分隔声明、露缝与否随计数变化)。修复=domain 单累加器接力取整(startPct/widthPct),连续由构造保证;total/start/ratio 口径原样保留。② 饼/环交互补齐:扇区 tabindex=0 + 每片 aria-label(类别:数量)、focus/blur 接同一共享浮层、:focus-visible 与 :hover 同档放大+描边、外层 svg role=img→group(img 会藏子树使名称不可达)、删原生