DESIGN SPECIFICATION

喝水打卡 · 家庭版
设计规范 V1.4

一份「能照着做」的规范:每个元素都给出设计说明、可复制的代码、真实渲染效果。所有效果的尺寸按 375px 逻辑宽还原,小程序里 1rpx = 0.5px,视觉一比一。

版本 V1.4 基准机型 小米 15 · 375×834 技术栈 原生小程序 + 云开发 资产库 01-资产库/
01

设计基调

principle

整个产品的视觉气质由三句话定死,所有新增界面都往里靠:

基调落地表现反面(禁止)
清爽不寡淡白卡 + 极浅蓝底 + 带品牌色的淡阴影,靠留白和层次出质感灰底灰卡、纯黑描边、堆高饱和色块
克制有反馈动效只服务「进度 / 达标 / 解锁」三件事;点按 0.5s 微反馈,主进度 800ms无意义漂浮、循环闪烁、每屏都在动
语义唯一蓝=过程、绿=完成、金=成就、红=危险。一色一义,全局不混用同一颜色在不同页面表达不同含义
为什么底色是 #f4f8ff 而不是 #f5f5f5? 冷调浅蓝能让白色卡片「浮」起来,纯灰底会让白卡发脏;同时它与主蓝同色相,整屏色彩更统一。
02

色彩令牌

color tokens

颜色只在 page 上定义一次(CSS 变量),页面与组件一律 var(--x) 引用,杜绝散落硬编码。下面是全部令牌——点任意色块可复制色值。

令牌定义(app.wxss 顶部)

tokens.wxss01-资产库/tokens.wxss
/* 品牌色 */
--primary: #2f7cf6;        /* 主蓝:进度 0% 起点、主按钮、选中态 */
--primary-dark: #1f63d6;
--primary-soft: #e9f1ff;     /* 主色浅底:ghost 按钮 */
/* 完成色(进度 100% 终点) */
--ok: #22b07d;
/* 语义色 */
--danger: #f2555a;  --danger-soft: #fff0f0;  --warn: #f5a623;
/* 中性 */
--bg: #f4f8ff;  --card: #ffffff;
--text: #17233d;      /* 主文本:深蓝黑,不用纯黑 #000 */
--text-sub: #7d879c;  --text-light: #a6b0c2;  --line: #eef2f8;
/* 阴影:带品牌色调,不用中性灰 */
--shadow: 0 8rpx 32rpx rgba(31, 99, 214, 0.08);

语义映射(一色一义)

语义颜色使用位置
过程 / 进行中--primary进度 0–99%、主按钮、Tab 选中、天数徽标
完成 / 达标--ok进度 100%、达标文案、「已达目标」标签
成就 / 稀有金 #ffd67a→#f0a93c成就卡描边、解锁标识、终极卡
危险 / 删除--danger退出小队、清空记录、解绑
等待 / 提醒--warn贴士「注意」类标签、未达标提示
03

字体、圆角与间距

typography & rhythm

字号只允许 6 档(20 / 22 / 24 / 26 / 28 / 30 rpx + 数字特例),圆角只允许 3 档,节奏靠 margin-top: 24rpx 的卡片序列撑起来。

44rpx · 进度大数字
34rpx · 页面主标题
30rpx · 卡片标题
28rpx · 正文
26rpx · 次级说明
24rpx · 辅助 / 免责
圆角卡片 28rpx · 按钮 46rpx(药丸)· 弹层 32rpx(顶角)
页边距左右 28rpx,卡片内 32rpx
卡间距24rpx(统一,不做「同组更紧」的例外)
数字font-variant-numeric: tabular-nums,count-up 不抖
等宽数字是硬要求。 进度数字每秒都在变,非等宽字体(如 PingFang 默认比例数字)会导致「1280 → 960」整行左右跳动,用户会感觉界面在抖——这是最容易忽略的「顿挫感」来源。
04

进度可视化(环 / 条 双模式)

progress

核心组件,也是被反复打磨过的地方。三个必须遵守的点:

  1. 整体变色,不是空间渐变。0% 时整圈/整条全蓝,100% 时整体全绿;中途是整体统一的插值色。左边蓝右边绿、或沿弧渐变,都是错的。
  2. 两端圆头与弧同帧。圆头坐标由 JS 逐帧算好写入 left/top,绝不给它挂 CSS 过渡,否则会和弧「追不上」。圆头必须是与弧同色的实心圆——白底描边的小球会被看成「珠子」,而不是弧的圆润端头。
  3. 数字、弧、条、光点四者同源同色。都由 gradColor(p/100) 一个函数产出,不允许各自算一遍。
10月28日 周三 21:20 🔥 连续 12 天
晚上好,该补水了 环条
0%
0/2000ml
距离今日目标还差 2000ml,随时来一杯
+200
+300
+500
今日记录
21:02300ml删除
17:40250ml删除
打卡
小队
我的
LIVE DEMO

拖动滑块看整体变色,切换「环 / 条」对比两种形态。

0%
0%整体 #2f7cf6 全蓝
50%整体 #2a967a 蓝绿之间
100%整体 #22b07d 全绿 + 轨道转绿

渐进色引擎(唯一色源)

grad-color.js01-资产库/js/grad-color.js
/** 蓝→绿渐进色:f∈[0,1],0=#2f7cf6 蓝,1=#22b07d 绿 */
function gradColor(f) {
  const r = Math.round(47 + (34 - 47) * f)
  const g = Math.round(124 + (176 - 124) * f)
  const b = Math.round(246 + (125 - 246) * f)
  const hex = '#' + [r, g, b].map((v) => (v < 16 ? '0' : '') + v.toString(16)).join('')
  return { hex, glow: `rgba(${r}, ${g}, ${b}, 0.4)` }
}

/**
 * 环形背景样式:整环是一张 SVG(viewBox 380),弧端圆头由 stroke-linecap="round" 负责
 * 为什么不用 conic-gradient:它的弧端是径向硬边,想圆润只能补一个同色实心圆盖住 ——
 * 那本质就是「一颗珠子」,用户一眼看穿。SVG 圆头是渲染器画进线段的,DOM 里没有端点元素。
 */
const RING_R = 174
const RING_CIRC = 2 * Math.PI * RING_R   // ≈ 1093.27
function svgUrl(svg) {
  const enc = encodeURIComponent(svg).replace(/'/g, '%27').replace(/\(/g, '%28').replace(/\)/g, '%29')
  return "url('data:image/svg+xml," + enc + "')"
}
function ringBgStyle(p, color, track) {
  const dash = Math.max(0.01, RING_CIRC * Math.min(100, p) / 100)
  let svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 380 380">'
  // 底轨道(满圈)
  svg += '<circle cx="190" cy="190" r="' + RING_R + '" fill="none" stroke="' + track + '" stroke-width="32"/>'
  // 进度弧:round 线帽 → 起点终点都是天然半圆,不需要任何补圆元素
  if (p > 0) {
    svg += '<circle cx="190" cy="190" r="' + RING_R + '" fill="none" stroke="' + color + '"' +
      ' stroke-width="32" stroke-linecap="round"' +
      ' stroke-dasharray="' + dash.toFixed(2) + ' ' + RING_CIRC.toFixed(2) + '"' +
      ' transform="rotate(-90 190 190)"/>'
  }
  return 'background-image:' + svgUrl(svg + '</svg>')
}

单帧渲染(一次 setData 打满所有元素)

index.js · applyPctStylepages/index/index.js
applyPctStyle(ml, pct) {
  const p = Math.max(0, Math.min(100, pct))
  const done = p >= 100
  const track = done ? 'rgba(34, 176, 125, 0.15)' : '#eef0f4'
  // 整体渐变:中间进度「整体统一」为插值色,不是沿弧的空间渐变
  const color = gradColor(p / 100).hex
  this.setData({           // ← 一次 setData,绝不分多次
    displayMl: ml, percent: Math.round(p), percentText: `${Math.round(p)}%`,
    // 环:整个环就一个 view,背景是 SVG data URI,圆头在 SVG 内部
    ringStyle: ringBgStyle(p, color, track),
    pctColor: color,
    barPct: Math.round(p * 10) / 10,
    done
  })
  // ⚠️ 没有 dotX / dotY / dotGlow / barDotX —— 端点不再由任何元素承担
}

WXML 结构

progress-ring.wxml01-资产库/snippets/progress-ring.wxml
<!-- 环:整个环只有一个 .ring,背景是 SVG;两端圆头由 stroke-linecap="round" 画进线段 -->
<view wx:if="{{progressStyle === 'ring'}}" class="ring-wrap">
  <view class="ring" style="{{ringStyle}}"></view>
  <view class="ring-inner">
    <view class="ring-pct" style="color:{{pctColor}}">{{percentText}}</view>
    <view class="ring-goal">{{displayMl}}/{{targetMl}}ml</view>
  </view>
</view>

<!-- 条:填充自带 border-radius:999rpx,两端天然圆润,同样不需要端点元素 -->
<view wx:else class="bar-wrap">
  <view class="bar-num" style="color:{{pctColor}}">{{displayMl}}<text class="bar-unit">ml</text></view>
  <view class="bar-track">
    <view class="bar-fill" style="width:{{barPct}}%;background:{{pctColor}}"></view>
  </view>
  <view class="bar-goal">目标 {{targetMl}} ml · {{percentText}}</view>
</view>

关键样式(节选)

01-progress.wxss01-资产库/styles/01-progress.wxss
.ring-wrap { position: relative; width: 380rpx; height: 380rpx; margin: 36rpx auto 0; }
.ring { position: absolute; inset: 0; border-radius: 50%; }  /* 背景由 ringStyle 注入 conic-gradient */

/* 环:背景由 ringStyle 注入 SVG data URI;必须关掉 repeat 并拉伸到 100%,
   否则 SVG 会按固有尺寸平铺,看起来像一圈碎弧 */
.ring {
  position: absolute; left: 0; top: 0; width: 380rpx; height: 380rpx;
  border-radius: 50%; background-repeat: no-repeat; background-size: 100% 100%;
}
/* ❌ 不要再出现 .ring-cap / .ring-head / .ring-dot:
   同色圆盖硬边 = 换皮的珠子,用户照样看得出来。判定标准:停在 12% 放大看,
   能看到一颗独立珠子轮廓就是错的。 */

/* 数字条:轨道 24rpx,填充整体色。填充自带 999rpx 圆角 → 两端天然圆润,不需要任何端点元素 */
.bar-track { position: relative; height: 24rpx; border-radius: 999rpx; background: #edf1f6; }
.bar-fill  { position: relative; height: 100%; border-radius: 999rpx; overflow: hidden; }
踩坑记录 ①:第一版把「渐变」理解成沿弧从左到右的色彩过渡,被要求返工三次才纠正。判断标准很简单——暂停在 50% 时,弧上任何一点的色值应该完全一样。
踩坑记录 ②(小球):环的端点返工了两轮。第一轮是 conic-gradient 配一颗白底彩边的圆,谁都看得出是球;第二轮改成与弧同色的实心圆盖住硬边——看着像解决了,其实是「换皮的珠子」,被当场指出。最终方案是彻底弃用 conic-gradient,改 SVG stroke-linecap="round":圆头是渲染器画进线段两端的,DOM 里根本没有端点元素,从结构上杜绝了小球。
05

动效与达标庆祝

motion

动效分三层:微反馈(点击涟漪)→ 主进度(800ms count-up + 弧增长)→ 庆祝(达标粒子,2.6–4.6s 自动收)。三层不叠加触发,避免「一次打卡满屏乱飞」。

层时长缓动规则
点击涟漪0.5scubic-bezier(.22,.61,.36,1)细圈 scale 0.4→2.6,透明度 0.85→0,不抢主体
主进度800msease-outrequestAnimationFrame 逐帧算百分比,数字用 tabular-nums
达标回弹0.7scubic-bezier(.34,1.56,.64,1)卡片轻微过冲回弹,仅达标瞬间一次
庆祝粒子2.6–4.6slinear / ease-out六模式随机(彩带/水滴/气泡/星芒/流光/光环),冷色水蓝家族,全程无红橙
弹层0.28scubic-bezier(.22,.61,.36,1)自底部升起,遮罩 0.2s 淡入
LIVE DEMO

点一下,触发达标庆祝层(真实粒子参数,随机位置/延迟/旋转)。

庆祝层舞台
六模式粒子在真机上叠加于整屏,z-index 80
02-celebrate.wxss · 节选01-资产库/styles/02-celebrate.wxss
/* 点击涟漪:细圈 scale 2–3,0.5s,不抢主体 */
.tap-ripple {
  position: absolute; width: 56rpx; height: 56rpx; margin: -28rpx 0 0 -28rpx;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(72,202,228,.5), rgba(72,202,228,0) 70%);
  animation: tap-ripple 0.5s cubic-bezier(.22,.61,.36,1) forwards;
}
@keyframes tap-ripple {
  0%   { transform: scale(.4); opacity: .85; }
  100% { transform: scale(2.6); opacity: 0; }
}

/* 庆祝层容器:整屏、不接收事件 */
.celebrate { position: fixed; inset: 0; pointer-events: none; z-index: 80; overflow: hidden; }
粒子参数走 CSS 变量。JS 只负责生成 --sx / --sy / --rot / --rz / --delay / --color 与 animation-duration,样式全部内联注入,避免为每个粒子写一套 keyframes。
06

成就卡弹层

achievement

成就卡的视觉逻辑是「暗底 + 金边 + 流光」:深蓝黑渐变底让金色发光有色可依,conic 旋转描边制造「开箱感」,图标缓慢呼吸。它是全产品唯一允许用金色和深色底的地方。

今日已解锁 2 枚
继续保持,解锁更多纪念徽章
打卡
小队
我的
新 成 就 解 锁
🏅
晨光第一杯
早上 8 点前喝下第一杯水,给身体一个清爽的开场
解锁条件 · 连续 3 天 08:00 前完成首次打卡
知道了
底色深蓝黑渐变 #232a38→#141922
金边0 0 0 1px rgba(255,214,120,.34)
流光conic-gradient 旋转 5.2s linear
图标46px + 金色 drop-shadow,呼吸 2.6s
主按钮金色实心 38px 高,深褐字 #3a2508
03-achievement.wxss · 核心01-资产库/styles/03-achievement.wxss
.achv-card {
  position: relative; border-radius: 34rpx; padding: 44rpx 36rpx; text-align: center; overflow: hidden;
  background: linear-gradient(160deg, #232a38, #141922 55%, #1d232f);
  box-shadow: 0 0 0 1rpx rgba(255,214,120,.34),
              0 36rpx 80rpx rgba(0,0,0,.42),
              0 0 92rpx rgba(255,196,84,.16);
}
/* 旋转流光 */
.achv-card::before {
  content: ''; position: absolute; inset: -60%; pointer-events: none;
  background: conic-gradient(from 0deg, transparent 0 32%,
              rgba(255,214,120,.42) 42%, transparent 52% 100%);
  animation: achvSpin 5.2s linear infinite;
}
@keyframes achvSpin { to { transform: rotate(360deg); } }
关键帧必须带命名空间。同一页面里达标庆祝层和成就弹层都有粒子动画,第一版重名导致互相覆盖、粒子全飞错方向。现在统一前缀:庆祝层 cf- / drop- / bubble-,成就层 achv-。
07

导航与 tabBar

navigation

首页用自定义导航(navigationStyle: custom):左侧是小程序自身 app icon + 标题「喝水打卡」,右侧「成就墙」胶囊贴着系统胶囊左侧,保证不打架。tabBar 是自定义组件,只有三个文字项,选中项放大加粗。

LIVE DEMO

下面是导航与 tabBar 的真实尺寸还原(状态栏 34 + 标题区 34 = 68px;tabBar 55px + 安全区)。

页面内容区 padding-top: 68px
打卡
小队
我的

导航高度必须用 API 算,不能写死

index.js · onLoadpages/index/index.js
const sys = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync()
const menu = wx.getMenuButtonBoundingClientRect()   // 系统胶囊位置
const statusBarH = sys.statusBarHeight
const navH = (menu.top - statusBarH) * 2 + menu.height
// 成就墙胶囊贴到系统胶囊左侧,留 8px 间距
const achvRight = sys.windowWidth - menu.left + 8
this.setData({ statusBarH, navH, navTotal: statusBarH + navH, achvRight })

tabBar 隐藏机制(本版修复的关键问题)

自定义 tabBar 渲染在页面之上,页面内弹层把 z-index 调到 9999 也压不住它——个人资料弹窗的「保存」按钮就曾被它挡住。正确做法是打开弹层时主动收起 tabBar。

custom-tab-bar + 页面联动custom-tab-bar/index.*
/* 1. 组件加 hidden 字段与过渡 */
Component({ data: { selected: 0, hidden: false, list: [...] } })

/* wxml */
<view class="tab-bar {{hidden ? 'is-hidden' : ''}}"> … </view>

/* wxss */
.tab-bar { transition: transform 0.24s cubic-bezier(.22,.61,.36,1), opacity 0.24s ease; }
.tab-bar.is-hidden { transform: translateY(100%); opacity: 0; pointer-events: none; }

/* 2. 页面封装 */
_setTabBarHidden(hidden) {
  if (typeof this.getTabBar === 'function' && this.getTabBar()) {
    this.getTabBar().setData({ hidden })
  }
}
// 打开弹层 → true;关闭/保存成功 → false;onShow 里再兜底置 false
验证方法:打开个人资料弹窗,看底部「清空 / 保存」是否完整可见且可点。tabBar 收起动画 0.24s,与弹层升起 0.28s 基本同步,观感上是「tabBar 让位」而不是消失。

同一机制也适用于首页的成就卡弹层(全屏遮罩):celebrateAchv() 打开时收起、closeAchvPop() 关闭时恢复。三个 tabBar 页面的 onShow 都统一重置 hidden: false,防止状态残留。

08

底部弹窗 · 分段控件 · 双列选择

sheet & picker

底部弹窗统一规格:顶角 32rpx 圆角、遮罩 rgba(20,28,46,.5)、底部留 env(safe-area-inset-bottom)。表单类弹窗的操作按钮固定在弹层底部(不跟随滚动)。

生日/纪念日用 月 + 日两列 picker-view:公历按当月实际天数生成日列(闰年自动 29 天),农历固定 30 天;换月或换历法时对已选日做钳位,不会出现「2月30日」。

个人资料公历 10月28日
提醒方案每日 3 次 ›
打卡
小队
我的
个人资料✕
生日 / 纪念日仅用于生成纪念徽章,不会向队友展示具体日期
我的生日
公历农历
9月
10月
11月
27日
28日
29日
纪念日
公历农历
9月
10月
11月
27日
28日
29日
清空
保存
LIVE DEMO

点按钮切换 tabBar 状态,直观看到「遮挡 → 让位」的差别。

显示状态下,tabBar(z-index 999)会盖住弹层底部操作区,保存按钮点不到,这就是本次修复的问题。
06-sheet-picker.wxss + 联动逻辑01-资产库/styles/06-sheet-picker.wxss
/* 弹层:底部安全区留白,操作按钮固定在最底部 */
.sheet {
  width: 100%; background: #fff; border-radius: 32rpx 32rpx 0 0;
  padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
  animation: sheetUp 0.28s cubic-bezier(.22,.61,.36,1);
}

/* 两列联动:换月/换历法后重建日列并钳位 */
onProfilePick(e) {
  const m = Number(e.detail.value[0]) + 1
  const d = Number(e.detail.value[1]) + 1
  const max = this._dayCount(this.data.pfBirthCal, m)   // 公历实天数 / 农历 30
  const day = Math.min(d, max)                            // 钳位,绝不出现 2月30日
  this.setData({ pfBirthMonth: m, pfBirthDay: day, pfBirthDays: this._dayNames(cal, m) })
}
09

列表、卡片与小贴士

lists & cards

列表块统一「白卡 + 32rpx 内边距 + 24rpx 卡间距」。时间线用 1rpx 竖线 + 7rpx 圆点,只标今日记录;贴士卡用竖向 swiper(5s 自动、circular 循环),每条一个彩色分类 chip。

🔔
「一家人的水杯」的老爸 提醒你喝水
21:12 发来 · 18 分钟内来一杯吧
✕
今日记录
21:02300ml删除
17:40250ml删除
09:15500ml删除
💧 健康喝水小贴士
时机 起床后先喝 200ml 温水,再吃早餐,帮助唤醒代谢。
贴士因人而异,不构成医学建议;特殊情况请咨询专业人士。
打卡
小队
我的
时间线竖线 1rpx + 圆点 7rpx,时间列固定 44px
提醒卡浅渐变底 #eef6ff→#e6fbf3,区别于普通白卡
贴士 chip时机蓝 / 科学绿 / 注意黄 / 过量红
免责单行省略,24rpx 灰字,不换行不占两行

成员行「提醒」按钮:可用 / 已提醒 两态

每个成员对其他每个成员,每自然日各 1 次提醒额度(服务端按北京时间自然日频控,到次日 00:00 自动恢复)。A 提醒过 B 后,只有 B 那一行按钮灰度;A 对 C、D… 仍各有 1 次。点击灰度按钮不再发请求,直接给一句文案——比让用户点了再弹失败提示体验好得多。

LIVE DEMO

点蓝色发提醒 → 该成员行置灰(对 TA 今日额度用尽),点灰色看文案提示。

提醒 提醒 ← 左:可发 右:今日已提醒
可用态底 rgba(47,124,246,.08) 字 #2f7cf6
灰度态底 #f2f4f8 字 #b3bdcc
尺寸padding 10/22rpx,圆角 999rpx,字 22rpx
状态来源云函数 getFamilyDailySummary 按成员富集 remindedToday(当前用户当日是否已提醒过该成员)
family 成员行 · 三件套改动pages/family/family.*
<!-- wxml:按该成员是否被提醒过挂 dim 类,并把状态带进 dataset -->
<view wx:if="{{!item.isMe && item.status !== 'left'}}"
      class="member-remind {{item.remindedToday ? 'dim' : ''}}"
      data-id="{{item.userId}}" data-name="{{item.displayName}}"
      data-reminded="{{item.remindedToday ? 1 : 0}}"
      catchtap="onRemindMember">提醒</view>

/* wxss:灰度态 —— 只降饱和,不降透明度(避免看起来像禁用按钮点不动) */
.member-remind.dim { background: #f2f4f8; color: #b3bdcc; }

// js:已提醒该成员则不发请求,直接给文案;发送成功后本地即时置灰该行
onRemindMember(e) {
  const reminded = Number(e.currentTarget.dataset.reminded) === 1
  if (reminded) {
    wx.showToast({ title: '今天已提醒过该成员了', icon: 'none' })
    return
  }
  // …确认弹窗 → api.call('sendFamilyReminder') → 本地置灰该行
  const idx = this.data.members.findIndex((m) => m.userId === toOpenid)
  if (idx >= 0) this.setData({ [`members[${idx}].remindedToday`]: true })
}

// 云函数 getFamilyDailySummary:按「我当日发出的、按目标分组」富集状态
const today = beijingDate()
const logRes = await db.collection('reminder_log')
  .where({ fromOpenid: OPENID, date: today })
  .limit(200).get()
const remindedMap = {}
;(logRes.data || []).forEach((l) => { remindedMap[l.toOpenid] = true })
members.forEach((m) => { m.remindedToday = !!remindedMap[m.userId] })

// 云函数 sendFamilyReminder:发送前按「fromOpenid + toOpenid + 自然日」频控,逐对用尽即拒
const c = await db.collection('reminder_log')
  .where({ fromOpenid: OPENID, toOpenid, date: today }).count()
if (c.total >= 1) return fail('ERR_RATE_LIMITED', '今天已提醒过该成员了')
状态口径必须与频控一致。按钮灰度用的是「当前用户当日是否提醒过该成员(北京时间自然日)」,服务端频控也按同一自然日、同一对算——所以不会出现「按钮亮着但点了被拒」的错位。额度到次日 00:00 自动恢复(前端重新拉 summary 即读回该行 remindedToday=false)。另外服务端拒绝时前端也会同步置灰,双保险。
10

头像体系

avatars

两套池子:用户头像 40 个动物 emoji;小队头像 14 个友谊符号(现成 emoji,全中性、不指代人数)。头像一律无彩色底——emoji 本身即视觉主体,加蓝渐变底会显脏;只有「字母回退」态才给淡蓝底。

小队头像 · 14 个友谊符号(V1.4 定稿)

用户头像 · 40 个动物(节选 12)

avatars.js01-资产库/js/avatars.js
// 小队头像池:友谊符号主题(14 个,全中性无人数指代)
const FAMILY_AVATARS = [
  { value: '🤝', type: 'emoji', label: '握手同盟' },
  { value: '🫂', type: 'emoji', label: '好友拥抱' },
  { value: '🪢', type: 'emoji', label: '同心结' },
  { value: '🫶', type: 'emoji', label: '比心' },
  { value: '💛', type: 'emoji', label: '友谊黄心' },
  { value: '💞', type: 'emoji', label: '心心相印' },
  { value: '✨', type: 'emoji', label: '闪闪友谊' },
  { value: '📸', type: 'emoji', label: '合影留念' },
  { value: '🍀', type: 'emoji', label: '幸运同队' },
  { value: '🎉', type: 'emoji', label: '派对时刻' },
  { value: '🍻', type: 'emoji', label: '举杯碰杯' },
  { value: '☕', type: 'emoji', label: '咖啡之约' },
  { value: '🧩', type: 'emoji', label: '缺一不可' },
  { value: '♾️', type: 'emoji', label: '友谊无限' }
]
白名单双端同步。前端 utils/avatars.js 与云函数 createFamily / updateFamilyName 的 ALLOWED_AVATARS 必须一字不差,否则用户选了新头像保存会「莫名失败」。
11

音效

audio feedback

不用音频文件,用 WebAudio 振荡器合成:零资源、零包体、点击瞬间即响。三个音色对应三个语义,音量 0.18、时长 120ms、正弦波,短促不吵。

LIVE DEMO · 点击试听
波形sine(正弦)
包络0.01s 起音 → 0.12s 指数衰减到 0.0001
降级基础库 < 2.19.0 无 API 时静默跳过,不报错
sound.js01-资产库/js/sound.js
function beep(type) {
  var freq = type === 'unlock' ? 1046 : type === 'done' ? 1318 : 880;
  try {
    if (typeof wx === 'undefined' || !wx.createWebAudioContext) return;
    var ctx = wx.createWebAudioContext();
    var osc = ctx.createOscillator(), gain = ctx.createGain();
    osc.connect(gain); gain.connect(ctx.destination);
    osc.type = 'sine'; osc.frequency.value = freq;
    gain.gain.setValueAtTime(0.0001, ctx.currentTime);
    gain.gain.exponentialRampToValueAtTime(0.18, ctx.currentTime + 0.01);
    gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.12);
    osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.13);
  } catch (e) { /* 静默降级 */ }
}
音效要跟手:调用必须写在 bindtap 处理函数的第一行。第一版写在 await api.call() 之后,听起来比点击晚半秒,被判定为「顿挫感」。现在的写法是:点击 → 立即响 → 并行发请求。
12

工程规范

engineering

代码层面的硬约束,改代码前先过一遍,能避免 90% 的性能与体验坑。

类别规则
setData一次渲染只调一次,合并所有字段;不 setData 整个 data 对象;列表项字段用精确路径 'list[3].ml'
动画只动 transform / opacity;绝不动 width / top / left(环光点例外:由 JS 逐帧写入,不加过渡)
请求统一走 utils/api.js 的 api.call(name, data),禁止散调用 wx.cloud.callFunction
云函数统一返回 { ok: true, data } 或 { ok: false, msg };错误由 api.showError 统一提示
时间统一用北京时间:new Date(Date.now() + 8*3600*1000) 取 UTC 字段,不用本地时区
隐私首屏不强制登录,浏览可用;收集项必须在隐私弹窗内勾选同意后继续
包体主包 < 2MB;设计稿/文档一律不放进工程根,`packOptions.ignore` 排除 _assets、设计规范
命名wxss 用 block-element 短横线;关键帧带模块前缀(achv- / cf-),禁止跨模块重名
注释「为什么」写注释,「做什么」靠命名;重要踩坑记录写在文件头

统一请求层

utils/api.jsv1.4/utils/api.js
/** 云函数调用统一包装:{ ok, data } / { ok:false, msg } */
function call(name, data = {}) {
  return wx.cloud.callFunction({ name, data }).then((res) => {
    const r = res.result || {}
    if (r.ok === false) throw new Error(r.msg || '操作失败')
    return r.data
  })
}

function showError(e) {
  wx.showToast({ title: (e && e.message) || '网络开小差了', icon: 'none' })
}

module.exports = { call, showError }
13

资产索引

assets

全部可复用资产在 v1.4-附件/01-资产库/,wxss / wxml 均为工程源文件原样切片,可直接粘贴运行。详细依赖与复用步骤见 01-资产库/README.md。

#资产路径说明
00设计令牌01-资产库/tokens.wxss全局变量 + 原子类,可作新工程 app.wxss 骨架
01进度环 / 条styles/01-progress.wxss
js/grad-color.js
snippets/progress-ring.wxml
双模式 + 整体变色引擎 + 两端同色圆头(无端点小球)
02涟漪 / 庆祝层styles/02-celebrate.wxss六模式粒子,CSS 变量驱动
03成就卡styles/03-achievement.wxss
snippets/achievement-card.wxml
金色流光卡 + 独立关键帧命名空间
04自定义导航styles/04-custom-nav.wxss
snippets/custom-nav.wxml
品牌居左 + 成就墙胶囊贴系统胶囊
05列表块styles/05-blocks.wxss时间线 / 提醒卡 / 贴士轮播
06底部弹窗styles/06-sheet-picker.wxss
snippets/sheet-picker.wxml
sheet + seg + 双列 picker(需 tabBar hidden)
07音效js/sound.jsWebAudio 合成,零资源
08头像体系js/avatars.js40 动物 + 14 友谊符号,双端白名单
09–10贴士 / 趋势js/healthTips.js
js/trend.js
贴士数据 + 趋势计算
11组件components/avatar-picker/
components/privacy-modal/
四件套完整目录
12–14云函数cloudfunctions/getWeather/
getMyAchievements/
getFamilyDailySummary/
天气代理 / 成就结算 / 小队日汇总
07–08订阅提醒 UI + 自检面板styles/07-subscribe.wxss
styles/08-diag.wxss
snippets/slot-grid.wxml
snippets/quota-card.wxml
snippets/diag-panel.wxml
卡头开关 / 整点格子 / 额度卡 / 授权按钮 / 诊断弹层
详见 喝水打卡_推送提醒设计规范_V1.4.html
19–22推送云函数pushScheduledReminders/
checkPushStatus/
sendFamilyReminder/
setReminderPlan/
定时推送 / 链路自检 / 队友互提醒 / 计划读写(含额度自愈)
推送相关的一切有单独一份规范。订阅消息的坑(一次性授权语义、额度池共用、两条链路解耦、时间窗口、诊断口径)在视觉规范里讲不清,已单独拆成 07-设计规范/喝水打卡_推送提醒设计规范_V1.4.html(10 章)。本页只管「长什么样」,那份管「为什么这么做、怎么做才不会静默失效」。
维护建议:后续迭代只改 01-资产库/ 里的资产 + 本规范文档,页面里保持引用一致;两者一旦分叉,新页面就会「长歪」。改资产时同步更新本页对应的代码块。