--- name: 组件库动效体系落地 overview: 按动效规范为 fms-vue 组件库补齐动画体系:9 个 motion token 落进 tokens.css,浮层组件(popover/modal/drawer)补离场状态机,纯 CSS 过渡组件批量补课,修复 Spin 组件不旋转的缺陷并收敛业务页手写 keyframes,全站适配 prefers-reduced-motion。 todos: - id: motion-tokens content: 用 [subagent:code-explorer] 盘点组件样式入口,并把 9 个动效 token 与 reduced-motion 归零块写入 tokens.css status: completed - id: popover-motion content: 给 popover 补进出场过渡,覆盖 dropdown / tooltip / select 并确认 select 是否继承 status: completed dependencies: - motion-tokens - id: modal-drawer-motion content: modal 与 drawer 分层进出场,Drawer 用组件级变量承载 240/200ms status: completed dependencies: - motion-tokens - id: button-input-motion content: button 补颜色过渡与按下缩放,input 补聚焦过渡 status: completed dependencies: - motion-tokens - id: form-control-motion content: switch、checkbox、radio、segmented、tabs 补控件微交互过渡 status: completed dependencies: - motion-tokens - id: collapse-motion content: FormGroup 与树节点用 grid 1fr 到 0fr 实现折叠过渡 status: completed dependencies: - motion-tokens - id: feedback-motion content: message 与 notification 用 TransitionGroup 补进出场,scrollbar 补淡出 status: completed dependencies: - motion-tokens - id: spin-motion content: 修复 Spin 旋转并收敛业务页面两处手写 keyframes status: completed dependencies: - motion-tokens - id: motion-verify content: 用 [skill:ui-ux-pro-max] 核对动效与无障碍,跑 oxlint 与编译确认无裸写时长 status: completed dependencies: - 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 需在实施时确认,若为独立实现在其内部补同样处理 - 控件类动画不影响键盘操作与焦点顺序;浮层禁止在动画期间抢焦点 - 不为动画而动画:无状态变化的组件(栅格、分割器等拖拽即时类)不强行加过渡 ## 架构设计 本次改动是"在既有组件库上加一层动效契约",不引入新的架构模式: ```mermaid graph TD A["tokens.css
9 个动效 token + reduced-motion 归零"] --> B["浮层组件
popover / modal / drawer"] A --> C["表单控件
button / input / switch / checkbox / radio"] A --> D["导航与容器
tabs / segmented / form-group / tree / scrollbar"] A --> E["反馈与加载
message / notification / spin"] B --> B1["dropdown / tooltip / select
经 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`)是全部组件依赖的唯一来源,必须精确定义: ```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: 一份针对动效参数与无障碍的核对结论,确认无系统性偏差后再收尾。