交互与体验设计指南

💡 本文是人类阅读版(决策时读、理解 why)。对应的写代码护栏在开发规范的 UX-001~UX-010(已 sync 进项目 CLAUDE.md,AI 写代码时强制遵守)。两者同主题、两层:这里讲透「为什么」,护栏给「照着做」。

交互细节统一,是「专业感」最廉价、也最有效的来源。用户不会逐条说出「这个按钮 hover 怎么和那个不一样」,但会模糊地觉得「这站做得糙、不太可信」。本文把 swift-web 推荐的交互体验基线讲清楚,并给出在 framework 组件上的落地方式。

为什么一致性 = 专业感

用户在产品里会建立预期:hover 一个红按钮看到它变暗,大脑就默认「所有红按钮 hover 都变暗」。这叫可预测性(predictability)。

一旦同类元素表现不一(这里变亮、那里变暗),用户每遇到一次「咦怎么不一样」,就要多花一次**认知(cognitive load)**去重新理解。单次微不足道,但全站累积起来,体感就是「不精致」。反过来,严格一致让界面「隐形」—— 用户不用思考交互本身,注意力全留给内容。

这正是 Nielsen 可用性十大启发式里**「Consistency and standards(一致性与标准)」**的核心。下面四组就是把这条原则落到 swift-web 的具体场景。

一、反馈与状态

1. 交互反馈一致性:同类元素同表现(UX-001)

同类交互元素(按钮 / 链接 / 卡片)的 hover / active / focus 反馈必须全站一致。反馈方式按填充类型分三档:

填充类型hover 反馈为什么
实色填充(品牌色 / 浅色 / accent / 彩色图标)变暗 brightness(0.92)(active 0.85)绝大多数按钮;变暗在实色上永远可感知
深色 / 半透明深色填充(深背景上)变亮 brightness(1.12)深色上再变暗会沉进背景看不见,需变亮
半透明纯白 ghost提高背景透明度(如 12% → 22%)纯白已到最大值,brightness 推不动它

最容易踩的两个坑:

/* ❌ 同一组品牌红 CTA,登录页变暗、付费墙变亮 —— 同种按钮两种行为,用户察觉「不一样」 */
.login-submit:hover { filter: brightness(0.92); } /* 变暗 */
.paywall-cta:hover  { filter: brightness(1.08); } /* 变亮,不一致 */

/* ❌ 半透明白底按钮想靠 brightness 提亮 —— 纯白(255)乘任何倍数仍被 clamp 回 255,hover 毫无反馈 */
.ghost-btn { background: rgb(255 255 255 / 12%); }
.ghost-btn:hover { filter: brightness(1.08); } /* 零效果 */
/* ✅ 按填充类型对档,全站统一 */
.cta:hover  { filter: brightness(0.92); }
.cta:active { filter: brightness(0.85); }

.dark-btn:hover { filter: brightness(1.12); }

.ghost-btn { background: rgb(255 255 255 / 12%); transition: background 0.15s; }
.ghost-btn:hover { background: rgb(255 255 255 / 22%); }

这条规则的由来:一次真实的付费墙开发里,品牌红 CTA 在付费墙写成了变亮、而全站其它地方都是变暗 —— 同一种红按钮两种 hover。统一回变暗后,「专业感」立刻回来了。CSS 写法(filter: brightness vs 背景透明度)的完整说明见样式与 Token。

2. 异步操作必须锁住入口(UX-002)

任何异步操作(提交 / 上传 / 拉取 / 删除)触发的可点元素,在 async 进行期间必须禁用或显 loading,否则用户能反复点 → double-submit、状态错乱、重复请求。

用 SwButton 时配 :loading 即可,组件会自动禁用并显示加载态:

<SwButton :loading="submitting" @click="onSubmit">提交</SwButton>
<script setup>
const submitting = ref(false)
async function onSubmit() {
  submitting.value = true
  try {
    await api.submit()
  } finally {
    submitting.value = false
  }
}
</script>

非按钮的可点元素(卡片点击、自定义触发)同理 —— pending 期把入口锁住。

3. 数据驱动视图必须备齐四态(UX-003)

「拉数据再渲染」的视图(列表 / 详情 / feed)只画成功态,会在另外三态翻车:加载时白屏、空数据一片空白让人以为坏了、请求失败卡死没重试。四态都要画:

  • loading → 骨架屏(优先,与真实内容等尺寸,顺带防 CLS)或 spinner,别白屏
  • empty → 明确空状态文案 + 下一步引导(「还没有内容,去逛逛」+ 入口),别只留空白
  • error → 错误说明 + 重试入口,别卡死或静默
  • success → 正常渲染

framework 多个组件已内建四态钩子,优先用:SwGallery(loading / error / finished)、SwVideoFeed(finished + 内置逐条加载 / 缓冲指示器,卡顿反馈无需业务自己画)。

<template>
  <SkeletonList v-if="pending" />
  <ErrorState v-else-if="error" @retry="refresh" />
  <EmptyState v-else-if="!list.length" />
  <ContentList v-else :items="list" />
</template>

二、无障碍与可操作

无障碍不是「给少数人的额外功能」—— 键盘用户、读屏用户、前庭敏感用户都是真实用户,下面几条还直接对应 WCAG 标准条款。

1. 尊重 prefers-reduced-motion(UX-004)

位移 / 缩放 / 视差 / 自动轮播 / 大幅过渡等动效,对前庭功能障碍用户会引发眩晕、恶心(WCAG 2.3.3 Animation from Interactions)。非必要动效必须在 @media (prefers-reduced-motion: reduce) 下关闭或降级:

.card { transition: transform 0.3s; }
.card:hover { transform: scale(1.05) translateY(-8px); }

@media (prefers-reduced-motion: reduce) {
  .card { transition: none; }
  .card:hover { transform: none; } /* 或仅留极轻微 opacity 反馈 */
}

必要的状态反馈(loading 转圈等)可保留,但应克制。

2. 弹层必须做焦点管理(UX-005)

模态弹层(modal / dialog / 抽屉)若不管焦点 —— 打开后焦点仍在背景、键盘 Tab 跑到被遮挡内容、关闭后焦点丢失 —— 键盘和读屏用户就完全无法操作。合规要求:打开时焦点移入弹层、内部 Tab 不逃逸(focus trap)、Esc 可关、关闭时焦点还原到触发元素。

好消息:SwModal / useModal() 基于 reka-ui,这些开箱即合规。所以——用框架弹层就对了,别自己写 div overlay(自建必须自己实现 trap + restore,很容易漏)。

3. 触摸热区达标(≠ 撑大视觉)+ focus 指示不 reflow(UX-006)

  • 要约束的是「热区」,不是视觉尺寸:移动端点击区太小会频繁误触,但规范要求的是可点区域 —— WCAG 对 target 的定义是「可接受指针操作的区域」,Material Design 3 也明确热区「extending beyond the visual bounds of an element」。合规基线是 WCAG 2.5.8 AA 的 24×24(44×44 出自 AAA 的 2.5.5),且 inline 文本链接明文豁免(正文内链 / 面包屑 / 页脚链接不受最小尺寸约束)。
  • framework 不做全局 min-height 兜底(framework#265 移除;曾做过,导致图标钮被撑大、面包屑错位)。视觉小于 44px 的紧凑控件加 sw-hit-area class 主动扩热区、视觉不变;framework 自带紧凑图标钮已在组件内部挂好,用 Sw* 组件无需触摸端补丁。见样式与 Token · 响应式工具类。
  • focus 指示不 reflow:focus 视觉指示不要靠新增 / 加宽 border 实现(会改盒模型尺寸、挤动布局)。已有 border 的改 border-color,无 border 的用 outline 或 box-shadow ring。

两条的 CSS 细节见样式与 Token。

三、导航与确认

1. 破坏性操作必须二次确认(UX-007)

删除 / 清空 / 退出登录 / 不可逆扣费等破坏性操作,点击即执行 = 误触一次就造成无法挽回的损失。

  • 高风险 / 不可逆 → 执行前二次确认,用 useModal() 确认框(别用原生 confirm():样式不可控、阻塞主线程、移动端体验差):
    async function onDelete() {
      const ok = await useModal().open({ title: '确认注销?', description: '此操作不可恢复' })
      if (ok) await api.deleteAccount()
    }
    
  • 低风险 / 可撤销(归档、移除单项)→ 可用「先执行 + 撤销 toast」替代硬确认,少打断用户。

2. 反馈渠道按场景选(UX-008)

反馈渠道用错场景会让反馈失效 —— 把需要决策的内容塞进一闪而过的 toast、或用 toast 报字段校验错(用户来不及看)。按场景选:

场景用为什么
成功 / 轻量提示(非阻断)toast —— useToast()短暂、不打断、自动消失
需用户决策 / 确认modal —— useModal()阻断、需主动响应、不会错过
字段级校验错误inline 就近显示 —— SwFormField错在哪显在哪、可反复看

原生 alert() 一律不用(样式不可控、阻塞)。

// ❌ 表单校验失败弹 toast —— 一闪而过,用户没看清就消失
useToast().error('手机号格式不对')

// ✅ 字段错就近 inline,提交成功才 toast
setFieldError('phone', '手机号格式不对')
// ...提交成功后:
useToast().success('已保存')

四、感知性能

「感知性能」关心的是用户感觉快不快,不只是真实加载时间。

1. 防布局跳动(CLS)(UX-009)

图片 / 广告 / 懒加载内容 / web 字体到达时挤动已渲染内容(Cumulative Layout Shift)—— 用户正要点的按钮被推走、阅读位置乱跳,体感很差,移动端 Lighthouse 也明显掉分。

核心做法:任何异步插入的内容都预留占位尺寸。图片用 SwImage 传 ratio(组件用 aspect-ratio 撑住容器);广告位给固定高;骨架屏与真实内容等尺寸。

<!-- ratio 撑住高度,图片加载前后容器不跳 -->
<SwImage :src="cover" alt="封面" ratio="16/9" />

2. 乐观更新(opt-in)(UX-010)

点赞 / 收藏 / 关注等低风险高频操作,如果每次都等网络往返才变 UI,体感会迟钝。可以乐观更新:先改 UI 再发请求,失败回滚 + 提示。

// ✅ 点赞乐观 + 失败回滚
liked.value = true
try {
  await api.like(id)
} catch {
  liked.value = false
  useToast().error('操作失败,请重试')
}

边界(重要):只有低风险、可回滚的操作适用。有副作用 / 不可逆 / 涉及金额的操作(支付、删除、发布、提交订单)绝不要乐观 —— 必须等服务端确认,否则「乐观显示成功、实际失败」会误导用户、酿成事故。

业界理论速览

本文只讲 swift-web 的可执行落地。想深入理解通用 UX / 无障碍理论,看下面这些权威源(本站不复制其内容,避免过时):

与开发护栏的关系

本文(人类长文)与开发规范里的 UX-001~UX-010(AI 护栏 / MR review 检查项)是同一套原则的两层:

  • 写代码、过 review → 以护栏(项目 CLAUDE.md 的「交互与体验」段)为准,每条带反例 + 兜底标注。
  • 理解为什么、做设计决策 → 读本文,展开的 why + 范例 + 业界理论都在这。

两层互相链接、不互抄;护栏更新时本文手工同步。