Files
workspace/code/fms/.codebuddy/plans/组件库动效体系落地_671819b0.md
T
2026-09-13 23:16:08 +08:00

16 KiB
Raw Blame History

name, overview, todos
name overview todos
组件库动效体系落地 按动效规范为 fms-vue 组件库补齐动画体系:9 个 motion token 落进 tokens.css,浮层组件(popover/modal/drawer)补离场状态机,纯 CSS 过渡组件批量补课,修复 Spin 组件不旋转的缺陷并收敛业务页手写 keyframes,全站适配 prefers-reduced-motion。
id content status
motion-tokens 用 [subagent:code-explorer] 盘点组件样式入口,并把 9 个动效 token 与 reduced-motion 归零块写入 tokens.css completed
id content status dependencies
popover-motion 给 popover 补进出场过渡,覆盖 dropdown / tooltip / select 并确认 select 是否继承 completed
motion-tokens
id content status dependencies
modal-drawer-motion modal 与 drawer 分层进出场,Drawer 用组件级变量承载 240/200ms completed
motion-tokens
id content status dependencies
button-input-motion button 补颜色过渡与按下缩放,input 补聚焦过渡 completed
motion-tokens
id content status dependencies
form-control-motion switch、checkbox、radio、segmented、tabs 补控件微交互过渡 completed
motion-tokens
id content status dependencies
collapse-motion FormGroup 与树节点用 grid 1fr 到 0fr 实现折叠过渡 completed
motion-tokens
id content status dependencies
feedback-motion message 与 notification 用 TransitionGroup 补进出场,scrollbar 补淡出 completed
motion-tokens
id content status dependencies
spin-motion 修复 Spin 旋转并收敛业务页面两处手写 keyframes completed
motion-tokens
id content status dependencies
motion-verify 用 [skill:ui-ux-pro-max] 核对动效与无障碍,跑 oxlint 与编译确认无裸写时长 completed
popover-motion
modal-drawer-motion
button-input-motion
form-control-motion
collapse-motion
feedback-motion
spin-motion

产品概述

为 fms-vue 内置组件库(src/components/ui,26 个组件)建立统一的动效体系:此前组件库里已有的动画代码被移除过,现按用户给定的动效规范全部恢复并补齐。目标是在高频后台操作场景下,让状态变化"可被感知但不拖慢操作"。

核心功能

  • 动效契约:9 个动效 token(3 档时长、3 档缓动、2 档位移幅度)写入 src/theme/tokens.css,沿用 --fms- 命名空间
  • 浮层进出场:popover、modal、drawer、下拉菜单、tooltip、select 具备进场与退场动画(遮罩与内容分层、进场错峰、退场同时走)
  • 控件微交互:按钮 hover 变色 + 按下缩放、输入框聚焦、开关滑块位移、分段控制器与标签页指示器滑动、勾选态变化
  • 折叠展开:表单分组与树节点用网格行高过渡实现展开收起
  • 反馈提示:message / notification 进出场、滚动条淡出
  • 加载指示器:修复 Spin 组件不旋转的缺陷,并将业务页面中各自手写的旋转动画收敛回组件
  • 无障碍:系统开启"减少动态效果"时时长归零(保留状态变化、去掉过渡),浮层动画期间不移动焦点

视觉与体验效果

按钮按下时有极轻微的收缩反馈;浮层从遮罩淡入到内容浮起有清晰的先后层次,关闭时干脆利落;开关拨动、指示器切换跟随手指;折叠区域平滑展开。整体节奏偏快(100~200ms),不出现回弹、位移、页面转场等干扰性效果。

技术栈

  • 框架与样式:Vue 3(script setup)+ SCSS(沿用 @use './index.scss' 拆分约定)
  • 变量体系:CSS 自定义属性,复用现有 --fms- 命名空间与 src/theme/tokens.css 单源
  • 过渡机制:CSS transition + Vue 内置 Transition / TransitionGroup 组件
  • 校验:pnpm exec oxlint + Vite 编译检查(dev server 端口 5082)

实现方案

分层策略

  1. 契约层:9 个 token 落到 tokens.css 的 :root,并追加 prefers-reduced-motion 归零块。所有组件只引用变量、不写裸时长,保证"改一处、全站一致"。
  2. 行为层:浮层组件用 Vue 内置 Transition 承载进出场 —— 这是本次工作量最大、也最容易出 bug 的部分,先做 popover(dropdown / tooltip / select 的共同基座)。选择:全局开关只做系统级自动适配,不新建 ConfigProvider、不引入手动开关状态。
  3. 表现层:纯 CSS 补控件微交互,无卸载时序风险,可并行推进。

关键决策与理由

决策 1:浮层离场用 Vue 内置 Transition,不手写 isLeaving 状态类 + setTimeout 规范给出的 .is-leaving 写法是框架无关方案;本项目是 Vue 3,用内置过渡组件更省代码。更关键的理由是:reduced-motion 下 token 归零会让 transition-duration 变成 0ms,此时 transitionend 事件不会触发 —— 若卸载靠该事件驱动,浮层会卡在页面上关不掉。Vue 的 Transition 会读取计算样式,时长为 0 时直接结束过渡,天然不会卡死。代价是过渡类名变成 .fms-xxx-enter-active 形态,但时长与缓动参数完全按规范取值。

决策 2:Drawer 的 240ms / 200ms 用组件级 CSS 变量承载,不裸写数字 规范建议这两个值写死在组件里,但那样会逃出 reduced-motion 的变量归零覆盖,无障碍要求落空。改为组件级变量(默认值保持 240/200 的视觉意图),并在组件级补一条 reduced-motion 覆盖,兼顾"不进全局 token"与"可被关闭"。

决策 3:只动 opacity / transform / color 系 所有新增过渡严格限定这三类属性,走合成层,不触发布局与重绘。表格行、数字滚动、页面转场、transition: all 一律不做。

决策 4:折叠展开用网格 1fr → 0fr height: auto 无法过渡。FormGroup 当前已是 v-show + grid 结构,直接可用;树节点同步采用同一手法,避免 JS 测高。

决策 5:补齐规范对照表遗漏的场景 Switch 滑块、Segmented 滑块、Scrollbar 淡出、Spin 循环旋转(为"禁止无意义图标旋转"开显式豁免)、Tooltip 用更快的档位(高频鼠标扫过场景,避免拖残影);同时明确位移幅度取值用途:下拉 / 提示用 4px、Modal 用 8px、Drawer 用整屏位移。

决策 6:按钮 hover 采用规范原值(base 档同时管进出) 此前工具栏 ghost 按钮的"背景色闪烁"真因是性能浮层压住按钮命中区(已单独修复),与过渡时长无关。此处不设"离开即时"之类的例外规则,保持全站一致。

性能与可靠性

  • 只改合成层属性,无布局抖动;无新增 DOM 节点(Transition 不额外渲染)
  • 每个组件改动互相独立,浮层与纯 CSS 组分批推进,避免一次性全量改动难以定位问题
  • 退场时序统一由 Transition 管理,不引入自定义定时器,杜绝"浮层关不掉 / 提前卸载闪烁"两类经典 bug

验证方式

用户明确要求不自动启动浏览器实测。本次以静态检查为主:oxlint + Vite 编译 + 逐文件核对"无裸写时长数字、无禁用属性、reduced-motion 覆盖完整"。

实现注意

  • 严格沿用存量风格:SCSS 独立文件用 @use './index.scss' 引入;注释用中文说明"为什么这样做",与现有代码一致
  • 除 Transition 包裹层外不改动组件 DOM 结构与既有 props / emits,保证向后兼容
  • popover 是 dropdown / tooltip / select 的基座,优先改造收益最大;select 是否完全复用 popover 需在实施时确认,若为独立实现在其内部补同样处理
  • 控件类动画不影响键盘操作与焦点顺序;浮层禁止在动画期间抢焦点
  • 不为动画而动画:无状态变化的组件(栅格、分割器等拖拽即时类)不强行加过渡

架构设计

本次改动是"在既有组件库上加一层动效契约",不引入新的架构模式:

graph TD
    A["tokens.css<br/>9 个动效 token + reduced-motion 归零"] --> B["浮层组件<br/>popover / modal / drawer"]
    A --> C["表单控件<br/>button / input / switch / checkbox / radio"]
    A --> D["导航与容器<br/>tabs / segmented / form-group / tree / scrollbar"]
    A --> E["反馈与加载<br/>message / notification / spin"]
    B --> B1["dropdown / tooltip / select<br/>经 popover 继承"]
    F["业务页面手写旋转动画"] --> E
  • 契约层:src/theme/tokens.css 提供唯一动效参数来源
  • 行为层:浮层组件内置过渡状态,dropdown / tooltip / select 通过基座继承
  • 表现层:各组件 SCSS 引用 token,声明自己用哪几档

目录结构

本次改动为既有项目补动效,涉及文件如下(未列出的文件不动):

fms-vue/
├── src/
│   ├── theme/
│   │   └── tokens.css                        # [MODIFY] 追加 9 个动效 token(3 档时长 / 3 档缓动 / 2 档位移)到 :root,
│   │                                          #   并追加 @media (prefers-reduced-motion: reduce) 块把三档时长归零。
│   │                                          #   深色 .dark 块不需要覆盖(动效参数与色彩无关)。
│   ├── components/ui/
│   │   ├── popover/
│   │   │   ├── popover.vue                    # [MODIFY] 浮层基座:把 v-if 内容包进 Transition,补进退场。
│   │   │   │                                  #   进场 base + ease-out,退场 base + ease-in;定位逻辑不动。
│   │   │   └── index.scss                     # [MODIFY] 补 enter/leave 过渡类(opacity + 4px 位移)
│   │   ├── modal/
│   │   │   ├── modal.vue                      # [MODIFY] 遮罩与内容分层过渡,进场遮罩先铺、内容延迟 20ms 落
│   │   │   └── index.scss                     # [MODIFY] 进场 slow + ease-out(scale 0.96→1),退场 base + ease-in;
│   │   │                                      #   遮罩进场 base + ease-out、退场 base + ease-in
│   │   ├── drawer/
│   │   │   ├── drawer.vue                     # [MODIFY] 补进场/退场过渡,复用与 modal 相同的分层写法
│   │   │   └── index.scss                     # [MODIFY] 用组件级变量承载 240ms 进 / 200ms 出(不裸写),
│   │   │                                      #   并在组件级加 reduced-motion 覆盖
│   │   ├── select/
│   │   │   ├── select.vue                     # [MODIFY] 确认是否复用 popover;若独立实现则补下拉进出场
│   │   │   └── index.scss                     # [MODIFY] 同上,补选项面板过渡类
│   │   ├── dropdown/index.scss                # [MODIFY] 下拉项 hover 底色过渡(菜单容器过渡由 popover 提供)
│   │   ├── tooltip/index.scss                 # [MODIFY] 用更快档位(100ms),高频鼠标扫过场景避免拖残影
│   │   ├── button/index.scss                  # [MODIFY] 补 background-color / border-color / box-shadow(base + standard)
│   │   │                                      #   与 transform(fast + standard),补 :active 的 scale 0.97
│   │   ├── input/
│   │   │   ├── input.vue                      # [MODIFY] 核对聚焦/校验态切换所依赖的 class 是否齐备
│   │   │   └── index.scss                     # [MODIFY] 补边框色与阴影的聚焦过渡(base + standard)
│   │   ├── switch/index.scss                  # [MODIFY] 滑块 translateX 过渡(fast + standard),轨道颜色同步
│   │   ├── checkbox/index.scss                # [MODIFY] 勾选态颜色/描边过渡
│   │   ├── radio/index.scss                   # [MODIFY] 选中态过渡,与 checkbox 同参数
│   │   ├── segmented/index.scss               # [MODIFY] 滑块位移过渡(base + standard),与 tabs 指示器同类
│   │   ├── tabs/
│   │   │   ├── tabs.vue                       # [MODIFY] 若已有滑动指示器元素,补其位移态;无则仅补 pane 淡入
│   │   │   └── index.scss                     # [MODIFY] 指示器位移过渡(base + standard)
│   │   ├── form/
│   │   │   ├── FormGroup.vue                  # [MODIFY] 折叠容器改为 grid 1fr↔0fr 过渡(分组图标同步旋转)
│   │   │   └── index.scss                     # [MODIFY] 折叠过渡类(base + standard),禁止动 height
│   │   ├── tree/
│   │   │   ├── tree-node.vue                  # [MODIFY] 子节点容器套用 grid 1fr↔0fr 手法
│   │   │   └── index.scss                     # [MODIFY] 展开收起过渡 + 展开箭头旋转(fast + standard)
│   │   ├── message/
│   │   │   ├── message-container.vue          # [MODIFY] 列表渲染改为 TransitionGroup,使项进出有过渡
│   │   │   └── index.scss                     # [MODIFY] 进场 base + ease-out、退场 base + ease-in
│   │   ├── notification/
│   │   │   ├── notification-container.vue     # [MODIFY] 同 message 的 TransitionGroup 处理
│   │   │   └── index.scss                     # [MODIFY] 同 message 参数
│   │   ├── scrollbar/scrollbar.vue            # [MODIFY] leave 模式下滚动条淡出(opacity 过渡,样式为组件内联)
│   │   ├── spin/index.scss                    # [MODIFY] 补 @keyframes 旋转动画到 .spin-indicator,
│   │   │                                      #   时长用组件级变量承载以便 reduced-motion 归零
│   │   ├── tag/index.scss                     # [MODIFY] 关闭/删除类交互的轻量过渡(若存在)
│   │   └── pagination/index.scss              # [MODIFY] 页码切换态过渡(若存在)
│   └── views/module/
│       ├── module-management/index.vue        # [MODIFY] 移除手写 .mm-config-state__spinner 与 @keyframes mm-config-spin,
│       │                                      #   改用 Spin 组件,复用统一旋转参数
│       └── menu-management/index.vue          # [MODIFY] 同上,移除 @keyframes mm-spin

关键代码结构

动效契约(src/theme/tokens.css)是全部组件依赖的唯一来源,必须精确定义:

:root {
  /* 时长:跟手操作 / 同层小元素 / 占满屏幕 三档封顶 */
  --fms-duration-fast: 100ms;
  --fms-duration-base: 150ms;
  --fms-duration-slow: 200ms;

  /* 缓动:进场用 out、退场用 in、纯颜色变化用 standard */
  --fms-ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
  --fms-ease-out: cubic-bezier(0, 0, 0.2, 1);
  --fms-ease-in: cubic-bezier(0.4, 0, 1, 1);

  /* 位移幅度:下拉 / 提示用 sm,Modal 用 md */
  --fms-offset-sm: 4px;
  --fms-offset-md: 8px;
}

/* 无障碍:时长归零保留状态变化,去掉过渡过程 */
@media (prefers-reduced-motion: reduce) {
  :root {
    --fms-duration-fast: 0ms;
    --fms-duration-base: 0ms;
    --fms-duration-slow: 0ms;
  }
}

/* 组件级变量示例(Drawer):不进全局 token,但同样可被上面的覆盖命中 */
.fms-drawer {
  --fms-drawer-duration-in: 240ms;
  --fms-drawer-duration-out: 200ms;
}

@media (prefers-reduced-motion: reduce) {
  .fms-drawer {
    --fms-drawer-duration-in: 0ms;
    --fms-drawer-duration-out: 0ms;
  }
}

Agent Extensions

SubAgent

  • code-explorer
  • Purpose: 在动工前一次性盘点 src/components/ui 下 26 个组件的样式入口、可动画属性与浮层显隐结构,确认"哪些组件走 Transition、哪些纯 CSS 补课、select 是否复用 popover",避免逐文件试探。
  • Expected outcome: 输出一份带路径的改动清单与每类组件的处理方式,作为后续实现的任务依据。

Skill

  • ui-ux-pro-max
  • Purpose: 核对本次动效参数与无障碍设计的合理性(时长是否落在后台高频场景的舒适区间、缓动方向是否正确、reduced-motion 与焦点处理是否到位),并检查是否有遗漏的交互态。
  • Expected outcome: 一份针对动效参数与无障碍的核对结论,确认无系统性偏差后再收尾。