实战日期:2026-06-11
实战对象:Otto Robot 跨端控制应用(H5 / 微信小程序 / 支付宝小程序 / 抖音小程序 / Android+iOS App)
框架:uniAPP(Vue 3 + Vite + TypeScript)
前置阅读:design.md 概念扫盲(ID 125)、微信小程序实战(ID 126)
一、为什么 uniAPP 才是 design.md 的最佳载体
上一篇文章我写了微信小程序版 design.md 落地。但飞熊跟我说了关键信息:项目用的是 uniAPP。
这一换,design.md 落地的 复杂度反而降了一半。
我对比下三套方案跑 design.md 的工程量:
| 维度 | 微信原生小程序 | uniAPP | Taro |
|---|---|---|---|
| 单位系统 | rpx(手写 2 倍) | px(自动转换) | px(自动转换) |
| 设计令牌 | WXSS 变量 / 手写 | SCSS 变量一等公民 | SCSS / Less |
| 跨端复用 design.md | 5 套规范 | 1 套规范跑 5 端 | 1 套 |
| 条件编译 | 不支持 | 原生 #ifdef 支持 | process.env 模拟 |
| AI 编程友好度 | 一般(WXML 偏 HTML) | 优(Vue 模板,Cursor 理解最深) | 优 |
| 官方设计语言 | 杂乱 | uni-ui 提供 design tokens | TDesign |
结论 :uniAPP 是当前 最适合 design.md 落地 的跨端方案。下面是 Otto Robot 的完整实战。
二、Step 1:理解 uniAPP 跟微信小程序的 5 个关键差异
| 维度 | 微信原生小程序 | uniAPP | design.md 应对 |
|---|---|---|---|
| 样式单位 | rpx 强制 | px 自动转 rpx/upx/pt | 令牌直接用 px,框架自动适配 |
| 样式预处理 | 弱(less/wxss) | SCSS 一等公民 | 令牌用 SCSS 变量管理,编译时注入 |
| 组件 | WXML + 4 文件 | .vue 单文件组件 | 一个 .vue 文件搞定 |
| 条件编译 | 无 | #ifdef MP-WEIXIN 块 |
不同端的设计差异用条件编译处理 |
| 设计令牌共享 | 手写 WXSS 变量 | SCSS 变量 + uni-ui tokens | 一份 _tokens.scss 跨端共用 |
关键洞察 :uniAPP 的 px 在小程序端会 自动转换为 rpx,在 App 端会按设计稿宽度换算为 px。这意味着 design.md 里的所有令牌只用写一份 px,编译时会自动适配。
三、Step 2:把 design.md 落到 SCSS 变量
设计令牌不再是 ” 文档里写写 ”,而是直接变成代码可用的 SCSS 变量。Otto Robot 的 design tokens 落地代码:
// /src/styles/_tokens.scss
// Otto Design Tokens — 单一真源,跟 design.md 一一对应
// 修改此处必须同步更新 /docs/design.md
// ============ 颜色 ============
$color-primary: #1E5AE8;
$color-primary-hover: #1648C0;
$color-primary-pressed:#0F3BA8;
$color-danger: #E5484D;
$color-warning: #F5A524;
$color-success: #30A46C;
$color-surface: #FFFFFF;
$color-surface-alt: #F7F8FA;
$color-border: #E4E7EB;
$color-divider: #F1F3F5;
$color-text-primary: #101828;
$color-text-secondary: #475467;
$color-text-disabled: #98A2B3;
$color-bg-dark: #0B0F19;
$color-text-dark: #E6E8EB;
// ============ 排版 ============
$font-family-sans: Inter, -apple-system, 'PingFang SC', sans-serif;
$font-family-mono: 'JetBrains Mono', monospace;
$font-size-caption: 12px;
$font-size-body: 14px;
$font-size-subtitle: 16px;
$font-size-title: 18px;
$font-size-display: 24px;
$font-weight-regular: 400;
$font-weight-medium: 500;
$font-weight-bold: 700;
// ============ 间距(4 的倍数)============
$spacing-unit: 4px;
$spacing-xxs: 2px;
$spacing-xs: 4px;
$spacing-sm: 8px;
$spacing-md: 12px;
$spacing-lg: 16px;
$spacing-xl: 24px;
$spacing-xxl: 32px;
// ============ 圆角 ============
$radius-sm: 4px;
$radius-md: 8px;
$radius-lg: 12px;
$radius-pill: 9999px;
// ============ 阴影 ============
$shadow-card: 0 2px 8px rgba(16, 24, 40, 0.06);
$shadow-modal: 0 8px 24px rgba(16, 24, 40, 0.12);
// ============ 动效 ============
$duration-fast: 120ms;
$duration-base: 200ms;
$duration-slow: 320ms;
$easing: cubic-bezier(0.2, 0, 0, 1);
// ============ z-index ============
$z-base: 0;
$z-dropdown: 100;
$z-modal: 1000;
$z-toast: 2000;
// /src/styles/_mixins.scss
// 设计原理层 — 跟 design.md 第二节 " 设计原理 " 对应
@import './tokens';
@mixin otto-button-base {
font-family: $font-family-sans;
font-size: $font-size-body;
font-weight: $font-weight-medium;
border-radius: $radius-md;
transition: all $duration-fast $easing;
}
@mixin otto-card {
background: $color-surface;
border-radius: $radius-md;
box-shadow: $shadow-card;
padding: $spacing-lg;
}
@mixin text-ellipsis($lines: 1) {
@if $lines == 1 {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
} @else {
display: -webkit-box;
-webkit-line-clamp: $lines;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
这一步把 design.md 真正变成了 ” 代码的源 ”,AI 生成的组件如果想用令牌,只需要 @import '@/styles/tokens'。
四、Step 3:用 Cursor 生成 3 个真实页面
Otto 跨端应用需要 3 个核心页面:
- 设备列表页(
/src/pages/devices/index.vue) - 设备详情页(
/src/pages/device/detail.vue) - 配网页(
/src/pages/device/setup.vue)
Prompt(直接抄)
请按 /docs/design.md 和 /src/styles/_tokens.scss 的规范,生成 Otto 跨端应用的
" 设备列表页 "。要求:1. 单文件组件 .vue(<template> + <script setup> + <style lang="scss" scoped>)2. 顶部用 uni-nav-bar 组件,标题 " 我的设备 ",右上角 "+" 按钮
3. 设备卡片用 <otto-device-tile> 自研组件
4. 列表为空时显示 <empty-state> 组件
5. 所有色值、间距、圆角严格使用 _tokens.scss 里的 SCSS 变量
6. SCSS 用 @use 引入 tokens,不要用 @import
7. 点击卡片跳转 /pages/device/detail
不要:- 不要写硬编码色值(如 #1E5AE8 改为 $color-primary)- 不要写魔法数字(如 padding: 12px 改为 padding: $spacing-md)- 不要用 any 类型(TypeScript strict)- 不要做花式动画(旋转 / 抖动)
生成结果(设备列表页 .vue 完整代码)
<template>
<view class="page">
<uni-nav-bar
title=" 我的设备 "
:right-icon="iconAdd"
@clickRight="onAddDevice"
/>
<view v-if="devices.length > 0" class="device-list">
<otto-device-tile
v-for="device in devices"
:key="device.id"
:name="device.name"
:battery="device.battery"
:status="device.status"
@tap="onDeviceTap(device.id)"
/>
</view>
<empty-state
v-else
illustration="/static/empty-device.png"
title=" 还没有设备 "
subtitle=" 去添加你的第一个 Otto"
>
<otto-button variant="primary" @tap="onAddDevice">
添加设备
</otto-button>
</empty-state>
</view>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import {onShow} from '@dcloudio/uni-app'
import iconAdd from '@/static/icons/add.svg'
interface Device {
id: string
name: string
battery: number
status: 'online' | 'offline' | 'connecting'
}
const devices = ref<Device[]>([])
onShow(() => {loadDevices()
})
async function loadDevices() {
// 实际项目里调用 uni.request 拉数据
devices.value = [{ id: '1', name: '客厅的 Otto', battery: 89, status: 'online'},
{id: '2', name: '卧室的 Otto', battery: 42, status: 'offline'}
]
}
function onDeviceTap(id: string) {uni.navigateTo({ url: `/pages/device/detail?id=${id}` })
}
function onAddDevice() {uni.navigateTo({ url: '/pages/device/setup'})
}
</script>
<style lang="scss" scoped>
@use '@/styles/tokens' as *;
.page {
background-color: $color-surface;
min-height: 100vh;
padding: 0 $spacing-xl;
}
.device-list {
margin-top: $spacing-xl;
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
</style>
实测(3 个页面):
- AI 生成耗时:约 5 分钟(比微信小程序多 1 分钟,因为 .vue 比 .wxml 长)
- 样式一致性:100% 命中(因为是 SCSS 变量,编译错的可能性为 0)
- 跨端编译:5 端全跑通(H5 / 微信 / 支付宝 / 抖音 / App-PLUS)
五、Step 4:组件沉淀(uni-ui + 自研双线)
uniAPP 生态里已经有一半的设计系统了——uni-ui(官方组件库)覆盖了 60+ 基础组件。Otto 的策略是:
src/components/
├── ui/ # uni-ui 改造(design tokens 注入)│ ├── otto-button/
│ ├── otto-input/
│ ├── otto-modal/
│ └── ...
├── business/ # 业务组件
│ ├── device-tile/ # 设备卡片
│ ├── status-badge/ # 状态徽章
│ ├── empty-state/ # 空状态
│ └── ...
└── layout/ # 布局组件
├── page-container/
└── bottom-bar/
组件示例:OttoButton(design tokens 完整版)
<template>
<button
class="otto-button"
:class="[`otto-button--${variant}`,
`otto-button--${size}`,
{'is-disabled': disabled, 'is-loading': loading}
]"
:disabled="disabled"
@click="handleClick"
>
<view v-if="loading" class="otto-button__loading" />
<slot />
</button>
</template>
<script setup lang="ts">
/**
* Otto Button 组件
* 设计规范:/docs/design.md §radius §motion §palette
* 主操作蓝仅在 primary 变体使用,且单页最多 2 个
*/
interface Props {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
loading?: boolean
}
const props = withDefaults(defineProps
<Props>(), {
variant: 'primary',
size: 'md',
disabled: false,
loading: false
})
const emit = defineEmits<{(e: 'click', event: Event): void
}>()
function handleClick(e: Event) {if (!props.disabled && !props.loading) {emit('click', e)
}
}
</script>
<style lang="scss" scoped>
@use '@/styles/tokens' as *;
.otto-button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: $radius-md;
font-weight: $font-weight-medium;
font-family: $font-family-sans;
transition: all $duration-fast $easing;
border: none;
cursor: pointer;
&--primary {
background-color: $color-primary;
color: $color-surface;
&:active {background-color: $color-primary-pressed;}
}
&--secondary {
background-color: $color-surface-alt;
color: $color-text-primary;
}
&--ghost {
background-color: transparent;
color: $color-primary;
border: 1px solid $color-border;
}
&--danger {
background-color: $color-danger;
color: $color-surface;
}
&--sm {height: 28px; padding: 0 $spacing-md; font-size: $font-size-caption;}
&--md {height: 40px; padding: 0 $spacing-lg; font-size: $font-size-body;}
&--lg {height: 48px; padding: 0 $spacing-xl; font-size: $font-size-subtitle;}
&.is-disabled {
opacity: 0.5;
color: $color-text-disabled;
cursor: not-allowed;
}
}
</style>
关键 :组件顶部注释 反向引用 design.md 章节。AI 改这个组件时,Cursor 的 Rules 会自动提示它 ” 先去读 design.md §radius §motion §palette”。
六、Step 5:跨端适配(条件编译 + design.md 差异表)
uniAPP 的杀手锏是 条件编译。design.md 里的 ” 跨端差异速查表 ” 可以直接落到代码里:
<template>
<view class="device-card">
<!-- #ifdef MP-WEIXIN -->
<view class="status-bar-wechat">
<text> 微信小程序专属状态栏 </text>
</view>
<!-- #endif -->
<!-- #ifdef APP-PLUS -->
<view class="status-bar-app">
<text>App 端用原生导航栏 </text>
</view>
<!-- #endif -->
<view class="device-info">
<text class="device-name">{{name}}</text>
<text class="device-battery">{{battery}}%</text>
</view>
</view>
</template>
<style lang="scss" scoped>
@use '@/styles/tokens' as *;
.device-card {
background: $color-surface;
border-radius: $radius-md;
padding: $spacing-lg;
/* #ifdef H5 */
box-shadow: $shadow-card; // H5 用阴影
/* #endif */
/* #ifdef MP-WEIXIN */
border: 1rpx solid $color-border; // 小程序用边框(避免阴影在低端机失效)/* #endif */
/* #ifdef APP-PLUS */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); // App 端阴影深一点
/* #endif */
}
</style>
跨端设计差异表(design.md 必备章节)
| 维度 | H5 | 微信小程序 | App | 备注 |
|---|---|---|---|---|
| 阴影 | 支持 | 低端机失效 | 完全支持 | 小程序优先用 border |
| 圆角 | 完全支持 | 完全支持 | 8px 以下模糊 | 最小圆角 ≥ 4px |
| 字体 | 系统字体 | PingFang SC | SF Pro | 跨端用 sans-serif |
| 字号 | 14px 起 | 12px 起 | 14px 起 | 老人 / 远视友好 |
| 下拉刷新 | scroll-view | onPullDownRefresh | native | 交互差异 |
| 返回手势 | 无 | 右滑 | 物理返回键 | 设计时显式说明 |
这一节必须写进 design.md,否则 AI 会在 H5 上加阴影、在小程序上渲染失败。
七、6 个真实踩坑(uniAPP 特有)
| 坑 | 现象 | 解决 |
|---|---|---|
| 1. rpx 误用 | 部分 AI 还在写 rpx | 强制用 _tokens.scss 里的 px 变量,配置 ESLint 禁止 rpx |
| 2. @import 编译慢 | 大项目 @import 多层嵌套编译卡死 | 用 @use '@/styles/tokens' as *(Sass module 系统) |
| 3. Vue 3 响应式丢失 | reactive 解构后失去响应 |
用 ref + .value 或 toRefs |
| 4. H5 端原生组件样式失效 | button 边框去不掉 | button::after {border: none;} 加到 App.vue 全局样式 |
| 5. App 端安全区问题 | 内容顶到刘海屏 | 全局 <view class="page-safe"> + padding-top: env(safe-area-inset-top) |
| 6. 跨端 API 差异 | uni.getSystemInfo 在不同端返回值不同 | 封装 useSystem() 组合式函数,做平台适配层 |
ESLint 配置(防 rpx 误用)
// .eslintrc.js
module.exports = {
rules: {
'no-restricted-syntax': [
'error',
{selector: "Literal[value=/\\d+rpx/]",
message: 'uniAPP 项目禁止使用 rpx,请用 _tokens.scss 里的 px 变量'
}
]
}
}
八、对比:微信原生 vs uniAPP vs Taro
| 维度 | 微信原生 | uniAPP | Taro |
|---|---|---|---|
| design.md 落地难度 | ⭐⭐⭐ 中(要写 WXSS 变量 + rpx 转换) | ⭐ 最易(SCSS 变量 + px 自动转) | ⭐⭐ 较易(SCSS + px 自动转) |
| 跨端 | 仅微信 | 5 端(H5+4 小程序 +App) | 4 端(无 App) |
| AI 编程支持 | 一般 | 优(Vue 模板是 Cursor 训练最深的) | 优 |
| 性能 | 最佳(原生渲染) | 良好(小程序端编译优化) | 良好 |
| 生态 | 微信专有 | uni-ui + uView + 第三方 丰富 | TDesign |
| 学习成本 | 中(要学 WXML) | 低(Vue 3 是主流) | 中(React 风格) |
飞熊选 uniAPP 是正确决策:用 Vue 3 + SCSS + 跨端编译,design.md 落地的工程量比微信原生少 40%。
九、迁移路径(如果你之前用别的方案)
场景 A:微信原生小程序 → uniAPP
- 第 1 周:把 WXSS 变量映射到 _tokens.scss(1:1 转换,px 不变)
- 第 2 周:WXML 模板改为
.vue模板(用 `
` 替换 ` `,`v-if` 替换 `wx:if`) - 第 3 周:
.js逻辑改为<script setup>,API 替换wx.*→uni.* - 第 4 周:跑 5 端编译,修复跨端差异
工具:uniAPP 官方有 WXML→Vue 转换器(wxml-to-uni),能转换 70% 代码。
场景 B:Taro → uniAPP
- 第 1 周:把 React JSX 改为 Vue 3 SFC(语法差异最大)
- 第 2 周:SCSS 变量保留,重新组织到 _tokens.scss
- 第 3 周:编译验证 5 端
场景 C:H5 → uniAPP(要做 App)
- 第 1 周:把 px 全部映射到 _tokens.scss
- 第 2 周:组件从 .vue(标准)改为 .vue(uniAPP,差异小)
- 第 3 周:H5 → 小程序时处理 API 差异(
uni.*替代window.*)
十、完整 design.md 模板(uniAPP 专用)
把上面所有点整合,给一份可直接复用的 uniAPP design.md 模板:
# Otto Cross-platform Design System
# 框架: uniAPP (Vue 3 + Vite + TypeScript + SCSS)
# 版本: v1.0 (2026-06-11)
# 适用端: H5 / 微信小程序 / 支付宝小程序 / 抖音小程序 / Android+iOS App
## §0 单位约定
- 所有令牌用 px 表达(uniAPP 自动转 rpx/pt)- _tokens.scss 是单一真源,design.md 仅做 " 原理 " 说明
- 禁止在 .vue 文件里直接写 rpx(ESLint 强制)## §1 设计令牌
[见 _tokens.scss 文件]
## §2 设计原理
[品牌基调、颜色规则、排版规则、圆角语义、阴影语义、动效规则]
## §3 原生组件改造
- button 边框: button::after {border: none;} (全局 App.vue)
- input padding: padding: 0 + 外层 view 控制
- 跨端差异表 [见正文 §6]
## §4 跨端适配
- H5 优先 CSS 变量
- 小程序优先 SCSS 编译
- App 端优先原生体验
- 条件编译必须用 #ifdef,不要在 JS 里写 if (uni.getSystemInfoSync().platform === 'android')
## §5 暗色模式
- 在 _tokens.scss 末尾定义暗色 SCSS 变量
- 使用 <view class="theme-{{theme}}"> 切换
- 主题来源: uni.getSystemInfoSync().theme
## §6 组件命名
- 业务组件: otto-* 前缀
- 通用组件: ot-* 前缀
- 文件目录: /src/components/{namespace}/{name}/index.vue
## §7 维护规则
- 修改 _tokens.scss 必须同步 design.md
- 修改 design.md 必须审视 _tokens.scss
- 漂移检测脚本: /scripts/check-design-drift.ts(每周跑 1 次)
十一、结论:uniAPP + design.md 的 4 个核心优势
- 一份令牌跑 5 端:H5 / 微信 / 支付宝 / 抖音 / App,设计成本只算 1 次
- SCSS 编译时校验 :令牌值错了编译就报错, 比微信小程序的 WXSS 变量更安全
- Vue 3 模板 + Cursor 深度适配:AI 生成的代码质量更高(Cursor 训练数据 Vue 占比最大)
- 条件编译原生支持:跨端差异用 #ifdef 块处理,design.md 差异表直接落到代码
Otto Robot 的下一步:把这份 design.md 落到 east196.cn/otto/design.md,下个月开发小程序时直接 @docs/design.md 让 Cursor 生成。
系列文章:
- design.md 概念扫盲 → ID 125
- design.md 微信小程序实战 → ID 126
- design.md uniAPP 实战 → 本篇(ID 127)
- 下一篇预告:design.md ↔ Figma 双向同步
实战记录人:增长运营官
实战日期:2026-06-11
框架版本: uniAPP 3.x + Vue 3.4 + Vite 5.x
适配 uniAPP 用户: 飞熊(Otto Robot 跨端控制应用)