This commit is contained in:
2026-06-25 18:24:29 +08:00
commit a1190d7d9a
90 changed files with 5459 additions and 0 deletions

17
cloudbaserc.json Normal file
View File

@@ -0,0 +1,17 @@
{
"version": "2.0",
"envId": "cloud1-d0gmb6bjde8af3266",
"functionRoot": "cloudfunctions",
"functions": [
{ "name": "login", "timeout": 20, "runtime": "Nodejs18.15", "installDependency": true },
{ "name": "dogManage", "timeout": 20, "runtime": "Nodejs18.15", "installDependency": true },
{ "name": "walkSave", "timeout": 20, "runtime": "Nodejs18.15", "installDependency": true },
{ "name": "walkManual", "timeout": 20, "runtime": "Nodejs18.15", "installDependency": true },
{ "name": "getDogDetail", "timeout": 20, "runtime": "Nodejs18.15", "installDependency": true },
{ "name": "getHistory", "timeout": 20, "runtime": "Nodejs18.15", "installDependency": true },
{
"name": "sendReminder", "timeout": 30, "runtime": "Nodejs18.15", "installDependency": true,
"triggers": [{ "name": "dailyReminder", "type": "timer", "config": "0 0 19 * * * *" }]
}
]
}

View File

@@ -0,0 +1,96 @@
// 云函数 dogManage — 狗狗 create/update/list建档与编辑复用
// 契约见 docs/03-云函数设计.md §2
const cloud = require('wx-server-sdk');
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
const db = cloud.database();
// 体型档位 → 步幅模型(与 miniprogram/utils/dogStep.js 保持一致docs/05 R3
const BASE_STRIDE = { xs: 0.20, s: 0.25, m: 0.35, l: 0.45, xl: 0.55 }; // 米/步
const STD_WEIGHT = { xs: 3, s: 7, m: 15, l: 28, xl: 45 }; // 档位标准体重 kg
// 犬种 → 体型档位;未列出的(中华田园犬/串串/其他)需前端让用户手动选 sizeLevel
const BREED_SIZE = {
柯基: 's', 比熊: 'xs', 泰迪: 's', 柴犬: 'm', 金毛: 'l',
拉布拉多: 'l', 哈士奇: 'l', 边境牧羊犬: 'm', 萨摩耶: 'l', 法斗: 's',
};
const resolveSize = (breed, clientSize) => BREED_SIZE[breed] || clientSize || null;
function computeStride(sizeLevel, weight) {
const base = BASE_STRIDE[sizeLevel];
if (!base) return { baseStride: null, stride: null };
let stride = base;
if (weight) {
let f = 1 + (Number(weight) - STD_WEIGHT[sizeLevel]) * 0.005;
f = Math.max(0.85, Math.min(1.15, f));
stride = +(base * f).toFixed(3);
}
return { baseStride: base, stride };
}
// 规范化狗狗字段;返回 { error } 表示校验失败
function normalize(dog) {
const sizeLevel = resolveSize(dog.breed, dog.sizeLevel);
if (!sizeLevel) return { error: '请选择体型档位' }; // 未知犬种必须手动选
const weight = dog.weight != null ? Number(dog.weight) : null;
const { baseStride, stride } = computeStride(sizeLevel, weight);
return {
data: {
name: String(dog.name || '').trim(),
breed: String(dog.breed || '').trim(),
sizeLevel,
baseStride,
stride,
gender: dog.gender || null,
ageStage: dog.ageStage || null,
ageText: dog.ageText || '',
weight,
avatar: dog.avatar || '',
},
};
}
exports.main = async (event, context) => {
const { OPENID } = cloud.getWXContext();
const { action, dog } = event;
const dogsCol = db.collection('dogs');
// ---- list列出我的狗 ----
if (action === 'list') {
const res = await dogsCol.where({ _openid: OPENID }).orderBy('createdAt', 'asc').get();
return { ok: true, data: res.data };
}
// ---- create建档 ----
if (action === 'create') {
if (!dog || !dog.name || !dog.breed) {
return { ok: false, code: 'INVALID', msg: '名字和犬种必填' };
}
const norm = normalize(dog);
if (norm.error) return { ok: false, code: 'INVALID', msg: norm.error };
const now = Date.now();
const doc = {
_openid: OPENID,
...norm.data,
stats: { totalWalks: 0, totalDistance: 0, totalSteps: 0, currentStreak: 0, lastWalkDate: '' },
createdAt: now,
};
const addRes = await dogsCol.add({ data: doc });
return { ok: true, data: { _id: addRes._id, ...doc } };
}
// ---- update编辑校验归属防越权改他人狗----
if (action === 'update') {
if (!dog || !dog._id) return { ok: false, code: 'INVALID', msg: '缺少狗狗ID' };
if (!dog.name || !dog.breed) return { ok: false, code: 'INVALID', msg: '名字和犬种必填' };
const target = await dogsCol.doc(dog._id).get().catch(() => null);
if (!target || !target.data || target.data._openid !== OPENID) {
return { ok: false, code: 'FORBIDDEN', msg: '无权操作' };
}
const norm = normalize(dog);
if (norm.error) return { ok: false, code: 'INVALID', msg: norm.error };
await dogsCol.doc(dog._id).update({ data: norm.data });
return { ok: true, data: { ...target.data, ...norm.data } };
}
return { ok: false, code: 'UNKNOWN_ACTION', msg: '未知操作' };
};

View File

@@ -0,0 +1,9 @@
{
"name": "dogManage",
"version": "1.0.0",
"description": "狗狗 create/update/list建档与编辑复用",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "~2.6.3"
}
}

View File

@@ -0,0 +1,40 @@
// 云函数 getDogDetail — 狗狗主页聚合dog + 成就墙 + 近期记录
// 契约见 docs/03-云函数设计.md §5
const cloud = require('wx-server-sdk');
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
const db = cloud.database();
const TOTAL_ACHIEVEMENTS = 6; // 与 miniprogram/utils/achievements.js 保持一致
exports.main = async (event) => {
const { OPENID } = cloud.getWXContext();
const { dogId } = event;
if (!dogId) return { ok: false, code: 'INVALID', msg: '缺少 dogId' };
// 校验归属
const dogRes = await db.collection('dogs').doc(dogId).get().catch(() => null);
if (!dogRes || !dogRes.data || dogRes.data._openid !== OPENID) {
return { ok: false, code: 'FORBIDDEN', msg: '无权访问' };
}
const unlocked = await db.collection('dog_achievements')
.where({ dogId })
.orderBy('unlockedAt', 'desc')
.get();
// dogIds 是数组,等值查询匹配「数组包含」语义
const recent = await db.collection('walks')
.where({ _openid: OPENID, dogIds: dogId })
.orderBy('startAt', 'desc')
.limit(10)
.get();
return {
ok: true,
data: {
dog: dogRes.data,
achievements: { unlocked: unlocked.data, total: TOTAL_ACHIEVEMENTS },
recentWalks: recent.data,
},
};
};

View File

@@ -0,0 +1,9 @@
{
"name": "getDogDetail",
"version": "1.0.0",
"description": "狗狗主页聚合dog + 成就墙 + 近期记录",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "~2.6.3"
}
}

View File

@@ -0,0 +1,35 @@
// 云函数 getHistory — 遛狗历史列表(跨全部狗狗,附狗名)
// 供「遛狗历史」页与「记录」Tab 使用。
const cloud = require('wx-server-sdk');
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
const db = cloud.database();
exports.main = async (event) => {
const { OPENID } = cloud.getWXContext();
const limit = Math.min(event.limit || 50, 100);
// 狗名映射
const dogsRes = await db.collection('dogs').where({ _openid: OPENID }).get();
const nameMap = {};
dogsRes.data.forEach((d) => { nameMap[d._id] = d.name; });
const res = await db.collection('walks')
.where({ _openid: OPENID })
.orderBy('startAt', 'desc')
.limit(limit)
.get();
const walks = res.data.map((w) => ({
_id: w._id,
dogIds: w.dogIds,
dogNames: (w.dogIds || []).map((id) => nameMap[id] || '狗狗'),
startAt: w.startAt,
duration: w.duration,
distance: w.distance,
dogSteps: w.dogSteps,
isManual: w.isManual,
walkDate: w.walkDate,
}));
return { ok: true, data: { walks, total: walks.length } };
};

View File

@@ -0,0 +1,9 @@
{
"name": "getHistory",
"version": "1.0.0",
"description": "遛狗历史列表(跨全部狗狗,附狗名)",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "~2.6.3"
}
}

View File

@@ -0,0 +1,48 @@
// 云函数 login — 登录 + 首次建用户,返回 hasDog 决定跳建档/首页
// 契约见 docs/03-云函数设计.md §1
const cloud = require('wx-server-sdk');
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
const db = cloud.database();
exports.main = async (event, context) => {
const { OPENID } = cloud.getWXContext(); // 可信用户标识
const usersCol = db.collection('users');
const dogsCol = db.collection('dogs');
// 1. 查用户,不存在则建档
let userInfo;
const userRes = await usersCol.where({ _openid: OPENID }).limit(1).get();
if (userRes.data.length === 0) {
const now = Date.now();
const doc = {
_openid: OPENID,
nickname: '铲屎官',
avatar: '',
walkCount: 0,
joinedAt: now,
createdAt: now,
};
const addRes = await usersCol.add({ data: doc });
userInfo = { _id: addRes._id, ...doc };
} else {
userInfo = userRes.data[0];
}
// 2. 是否已有狗狗 → 决定前端跳建档(0b)还是首页(1)
const countRes = await dogsCol.where({ _openid: OPENID }).count();
const hasDog = countRes.total > 0;
return {
ok: true,
data: {
userInfo: {
openid: OPENID,
nickname: userInfo.nickname,
avatar: userInfo.avatar,
walkCount: userInfo.walkCount,
joinedAt: userInfo.joinedAt,
},
hasDog,
},
};
};

View File

@@ -0,0 +1,9 @@
{
"name": "login",
"version": "1.0.0",
"description": "登录 + 首次建用户,返回 hasDog 决定跳建档/首页",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "~2.6.3"
}
}

View File

@@ -0,0 +1,46 @@
// 云函数 sendReminder — 每日定时给「今天还没遛」的主人发订阅提醒(云调用)
// 依赖:① 公众平台创建订阅消息模板,把 ID 填到下方 TEMPLATE_ID与前端 utils/config.js 一致)
// ② 定时触发器(见 cloudbaserc.json 的 triggers / 或控制台配置)
// ③ 用户在成果卡点「完成」时已 requestSubscribeMessage 授权(一次授权=一次可发)
const cloud = require('wx-server-sdk');
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
const db = cloud.database();
const _ = db.command;
const TEMPLATE_ID = ''; // TODO: 填入订阅消息模板 ID
function todayStr(tz = 8) {
const d = new Date(Date.now() + tz * 3600 * 1000);
const p = (n) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
}
exports.main = async () => {
if (!TEMPLATE_ID) return { ok: true, skipped: true, msg: '未配置 TEMPLATE_ID跳过' };
const today = todayStr();
// 今天 lastWalkDate 不是今天的狗 → 其主人需要提醒
const dogs = await db.collection('dogs')
.where({ 'stats.lastWalkDate': _.neq(today) })
.field({ _openid: true })
.get();
const owners = [...new Set(dogs.data.map((d) => d._openid))];
let sent = 0;
let failed = 0;
for (const openid of owners) {
try {
await cloud.openapi.subscribeMessage.send({
touser: openid,
templateId: TEMPLATE_ID,
page: 'pages/home/home',
// data 字段需与实际模板字段一一对应,下面为占位示例
data: { thing1: { value: '该带狗狗出门遛一圈啦' } },
});
sent++;
} catch (e) {
failed++; // 多为「无有效订阅授权」,属正常
}
}
return { ok: true, sent, failed, owners: owners.length };
};

View File

@@ -0,0 +1,9 @@
{
"name": "sendReminder",
"version": "1.0.0",
"description": "每日定时:给今天还没遛狗的主人发订阅提醒",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "~2.6.3"
}
}

View File

@@ -0,0 +1,111 @@
// 云函数 walkManual — 手动补记docs/05 R7
// 规则isManual=true、countsForRanking=false不计排行榜防刷可维持连续打卡不断签。
const cloud = require('wx-server-sdk');
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
const db = cloud.database();
const _ = db.command;
const DAY = 86400000;
function toDateStr(ts, tz = 8) {
const d = new Date(ts + tz * 3600 * 1000);
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function diffDays(a, b) {
return Math.round((Date.parse(b + 'T00:00:00Z') - Date.parse(a + 'T00:00:00Z')) / DAY);
}
// 连续天数:从该狗全部 walkDate 重算(与 walkSave 一致),保证补记任意顺序都能正确续/断签docs/05 R10
async function computeStreak(openid, dogId) {
const res = await db.collection('walks')
.where({ _openid: openid, dogIds: dogId })
.field({ walkDate: true })
.orderBy('walkDate', 'desc')
.limit(1000)
.get();
const dates = Array.from(new Set(res.data.map((w) => w.walkDate).filter(Boolean))).sort(); // 升序
if (!dates.length) return { currentStreak: 0, lastWalkDate: '' };
const lastWalkDate = dates[dates.length - 1];
let streak = 1;
for (let i = dates.length - 1; i > 0; i--) {
if (diffDays(dates[i - 1], dates[i]) === 1) streak++;
else break;
}
return { currentStreak: streak, lastWalkDate };
}
// 补记仅触发连续/累计类成就(不触发分享/场景类)
const STAT_ACHIEVEMENTS = [
{ key: 'first_walk', name: '首遛', cond: (s) => s.totalWalks >= 1 },
{ key: 'week_streak', name: '一周不断', cond: (s) => s.currentStreak >= 7 },
{ key: 'month_streak', name: '满月坚持', cond: (s) => s.currentStreak >= 30 },
{ key: 'hundred_km', name: '百里同行', cond: (s) => s.totalDistance >= 100 },
];
async function unlockAchievements(openid, dogId, candidates) {
if (!candidates.length) return [];
const col = db.collection('dog_achievements');
const existing = await col.where({ dogId }).field({ achievementKey: true }).get();
const have = new Set(existing.data.map((d) => d.achievementKey));
const fresh = candidates.filter((c) => !have.has(c.key));
const now = Date.now();
for (const c of fresh) {
await col.add({ data: { _openid: openid, dogId, achievementKey: c.key, unlockedAt: now } });
}
return fresh.map((c) => ({ key: c.key, name: c.name }));
}
exports.main = async (event) => {
const { OPENID } = cloud.getWXContext();
const { dogId, startAt, duration } = event;
const distance = event.distance != null ? event.distance : null;
if (!dogId || !startAt || !duration) {
return { ok: false, code: 'INVALID', msg: '缺少必要信息' };
}
// 校验归属
const dogRes = await db.collection('dogs').doc(dogId).get().catch(() => null);
if (!dogRes || !dogRes.data || dogRes.data._openid !== OPENID) {
return { ok: false, code: 'FORBIDDEN', msg: '无权操作' };
}
const now = Date.now();
const walkDate = toDateStr(startAt);
const addRes = await db.collection('walks').add({
data: {
_openid: OPENID,
dogIds: [dogId],
startAt,
endAt: startAt + duration * 1000,
duration,
distance, dogSteps: null, pooped: false,
weather: null, mood: null, note: null, photos: [],
source: 'manual', // 来源标识
isManual: true, // 补记标识
countsForRanking: false, // 不计排行榜
walkDate,
createdAt: now,
},
});
// 更新狗狗统计(维持连续);连续天数按全部历史重算(已含本条补记)
const dog = dogRes.data;
const s = dog.stats || { totalWalks: 0, totalDistance: 0, totalSteps: 0, currentStreak: 0, lastWalkDate: '' };
const streakInfo = await computeStreak(OPENID, dogId);
const newStats = {
totalWalks: (s.totalWalks || 0) + 1,
totalDistance: +(((s.totalDistance || 0) + (distance || 0)).toFixed(2)),
totalSteps: s.totalSteps || 0,
earlyWalks: s.earlyWalks || 0, // 补记不触发场景类成就,早遛计数保持不变
currentStreak: streakInfo.currentStreak,
lastWalkDate: streakInfo.lastWalkDate,
};
await db.collection('dogs').doc(dogId).update({ data: { stats: newStats } });
const satisfied = STAT_ACHIEVEMENTS.filter((a) => a.cond(newStats)).map((a) => ({ key: a.key, name: a.name }));
const newAchievements = await unlockAchievements(OPENID, dogId, satisfied);
await db.collection('users').where({ _openid: OPENID }).update({ data: { walkCount: _.inc(1) } });
return { ok: true, data: { walkId: addRes._id, newAchievements } };
};

View File

@@ -0,0 +1,9 @@
{
"name": "walkManual",
"version": "1.0.0",
"description": "手动补记:维持连续但不计排行榜",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "~2.6.3"
}
}

View File

@@ -0,0 +1,173 @@
// 云函数 walkSave — 保存遛狗 + 原子更新 stats + 连续天数 + 成就判定
// 契约见 docs/03-云函数设计.md §3 / §6
// action: 'create'(默认,结束遛狗时调)| 'attach'(成果卡补充选填字段时调)
const cloud = require('wx-server-sdk');
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
const db = cloud.database();
const _ = db.command;
const DAY = 86400000;
const BASE_STRIDE = { xs: 0.20, s: 0.25, m: 0.35, l: 0.45, xl: 0.55 }; // 旧档无 stride 时兜底用
// 时间戳 → 本地 yyyy-mm-dd默认 +8
function toDateStr(ts, tz = 8) {
const d = new Date(ts + tz * 3600 * 1000);
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function diffDays(a, b) {
return Math.round((Date.parse(b + 'T00:00:00Z') - Date.parse(a + 'T00:00:00Z')) / DAY);
}
// 本地小时(默认 +8用于「早起鸟」场景判定
function localHour(ts, tz = 8) {
return new Date(ts + tz * 3600 * 1000).getUTCHours();
}
// 连续天数算法docs/05 R10从该狗全部 walkDate 重算,保证补记任意顺序都能正确续/断签。
// 同日多次自动去重;以最新日期为锚,向前连续 +1遇断签停止。
async function computeStreak(openid, dogId) {
const res = await db.collection('walks')
.where({ _openid: openid, dogIds: dogId })
.field({ walkDate: true })
.orderBy('walkDate', 'desc')
.limit(1000)
.get();
const dates = Array.from(new Set(res.data.map((w) => w.walkDate).filter(Boolean))).sort(); // 升序
if (!dates.length) return { currentStreak: 0, lastWalkDate: '' };
const lastWalkDate = dates[dates.length - 1];
let streak = 1;
for (let i = dates.length - 1; i > 0; i--) {
if (diffDays(dates[i - 1], dates[i]) === 1) streak++;
else break;
}
return { currentStreak: streak, lastWalkDate };
}
// 累计/连续类成就(基于更新后的 stats
const STAT_ACHIEVEMENTS = [
{ key: 'first_walk', name: '首遛', cond: (s) => s.totalWalks >= 1 },
{ key: 'week_streak', name: '一周不断', cond: (s) => s.currentStreak >= 7 },
{ key: 'month_streak', name: '满月坚持', cond: (s) => s.currentStreak >= 30 },
{ key: 'hundred_km', name: '百里同行', cond: (s) => s.totalDistance >= 100 },
{ key: 'early_bird', name: '早起鸟', cond: (s) => (s.earlyWalks || 0) >= 10 }, // 场景类:累计早遛 10 次(仅实时遛狗计入)
];
// 解锁成就(去重:同一 dog 同一 key 不重复)
async function unlockAchievements(openid, dogId, candidates) {
if (!candidates.length) return [];
const col = db.collection('dog_achievements');
const existing = await col.where({ dogId }).field({ achievementKey: true }).get();
const have = new Set(existing.data.map((d) => d.achievementKey));
const fresh = candidates.filter((c) => !have.has(c.key));
const now = Date.now();
for (const c of fresh) {
await col.add({ data: { _openid: openid, dogId, achievementKey: c.key, unlockedAt: now } });
}
return fresh.map((c) => ({ key: c.key, name: c.name }));
}
exports.main = async (event) => {
const { OPENID } = cloud.getWXContext();
const action = event.action || 'create';
// ===== attach成果卡补充选填字段docs/05 R6=====
if (action === 'attach') {
const { walkId } = event;
if (!walkId) return { ok: false, code: 'INVALID', msg: '缺少 walkId' };
const walkRes = await db.collection('walks').doc(walkId).get().catch(() => null);
if (!walkRes || !walkRes.data || walkRes.data._openid !== OPENID) {
return { ok: false, code: 'FORBIDDEN', msg: '无权操作' };
}
const weather = event.weather || null;
const patch = {
weather,
mood: event.mood || null,
note: event.note || null,
photos: event.photos || [],
};
await db.collection('walks').doc(walkId).update({ data: patch });
// 场景类成就:雨天战士(天气是事后补的,故在此判定)
let newAchievements = [];
if (weather === 'rainy') {
for (const dogId of walkRes.data.dogIds || []) {
const got = await unlockAchievements(OPENID, dogId, [{ key: 'rainy_warrior', name: '雨天战士' }]);
newAchievements = newAchievements.concat(got);
}
}
return { ok: true, data: { newAchievements } };
}
// ===== finish结束遛狗落库 + 统计 + 成就 =====
const { dogIds, startAt, endAt, duration } = event;
// 客户端只传 GPS 距离(米,未授权为 null狗步由服务端按每只狗 stride 算docs/05 R3
const distanceMeters = event.distanceMeters != null ? Number(event.distanceMeters) : null;
const distance = distanceMeters != null ? +(distanceMeters / 1000).toFixed(2) : null; // 公里
const pooped = !!event.pooped;
if (!Array.isArray(dogIds) || dogIds.length === 0) {
return { ok: false, code: 'INVALID', msg: '缺少遛狗对象' };
}
const now = Date.now();
const walkDate = toDateStr(endAt || now);
// 早起鸟:以开始时间的本地小时判定,<7 点记一次「早遛」(场景类,仅实时遛狗计入)
const isEarly = startAt != null && localHour(startAt) < 7;
const addRes = await db.collection('walks').add({
data: {
_openid: OPENID,
dogIds, startAt, endAt, duration,
distance, dogSteps: null, pooped, // dogSteps代表狗循环后回填
weather: null, mood: null, note: null, photos: [],
source: 'realtime',
isManual: false,
countsForRanking: true,
walkDate,
createdAt: now,
},
});
const walkId = addRes._id;
const results = [];
let newAchievements = [];
let repSteps = null; // 代表狗dogIds[0])的狗步,写回 walk 用
for (const dogId of dogIds) {
const dogRes = await db.collection('dogs').doc(dogId).get().catch(() => null);
if (!dogRes || !dogRes.data || dogRes.data._openid !== OPENID) continue;
const dog = dogRes.data;
const s = dog.stats || { totalWalks: 0, totalDistance: 0, totalSteps: 0, currentStreak: 0, lastWalkDate: '' };
// 每只狗按自身 stride 换算狗步(旧档无 stride 时兜底 0.35m
const stride = dog.stride || BASE_STRIDE[dog.sizeLevel] || 0.35;
const steps = distanceMeters != null ? Math.round(distanceMeters / stride) : null;
if (dogId === dogIds[0]) repSteps = steps;
// 连续天数按全部历史重算(已含本条,刚 add 入库)
const streakInfo = await computeStreak(OPENID, dogId);
const newStats = {
totalWalks: (s.totalWalks || 0) + 1,
totalDistance: +(((s.totalDistance || 0) + (distance || 0)).toFixed(2)),
totalSteps: (s.totalSteps || 0) + (steps || 0),
earlyWalks: (s.earlyWalks || 0) + (isEarly ? 1 : 0),
currentStreak: streakInfo.currentStreak,
lastWalkDate: streakInfo.lastWalkDate,
};
await db.collection('dogs').doc(dogId).update({ data: { stats: newStats } });
const satisfied = STAT_ACHIEVEMENTS
.filter((a) => a.cond(newStats))
.map((a) => ({ key: a.key, name: a.name }));
newAchievements = newAchievements.concat(await unlockAchievements(OPENID, dogId, satisfied));
results.push({ dogId, name: dog.name, duration, dogSteps: steps, distance, streak: newStats.currentStreak });
}
if (repSteps != null) {
await db.collection('walks').doc(walkId).update({ data: { dogSteps: repSteps } });
}
// 用户累计次数 +1
await db.collection('users').where({ _openid: OPENID }).update({ data: { walkCount: _.inc(1) } });
return { ok: true, data: { walkId, results, newAchievements } };
};

View File

@@ -0,0 +1,9 @@
{
"name": "walkSave",
"version": "1.0.0",
"description": "保存遛狗 + 原子更新stats + 连续天数 + 成就判定",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "~2.6.3"
}
}

140
docs/01-架构设计.md Normal file
View File

@@ -0,0 +1,140 @@
# 01 · 架构设计
## 1. 技术选型理由
**原生小程序 + 云开发**,因为:
- 云开发是微信官方为原生小程序量身打造,集成度最高、免运维、免自建鉴权。
- 汪圈 V1 是单机闭环无跨端App / H5 / 支付宝)需求,原生比 Taro / uni-app 更直接。
- 云函数里 `cloud.getWXContext().OPENID` 直接拿到可信用户标识,省掉整套登录鉴权后端。
## 2. 整体架构
```
┌─────────────────────────────────────────────┐
│ 微信小程序客户端 │
│ pages/ (12屏) · components/ · utils/ │
│ globalData + 事件总线 │
└───────────────────┬─────────────────────────┘
│ wx.cloud.callFunction
┌────────────┴────────────┐
▼ ▼
┌───────────────┐ ┌───────────────┐
│ 云函数 │ │ 云数据库 │
│ login │◄───────►│ users │
│ dogManage │ │ dogs │
│ walkSave │ │ walks │
│ walkManual │ │ dog_achievements│
│ getDogDetail │ └───────────────┘
│ getHistory │
└───────────────┘ │
│ ▼
▼ ┌───────────────┐
成就判定/统计累加 │ 云存储 │
│ 头像/照片/分享图 │
└───────────────┘
```
**核心原则:写操作 + 敏感计算全部走云函数**,客户端只读(或经云函数读聚合结果)。连续天数、成就解锁、补记规则等绝不在客户端算。
## 3. 工程目录结构
```
Pet3/
├── cloudfunctions/ # 云函数Node.js
│ ├── login/ # 登录 + 首次建用户
│ │ ├── index.js
│ │ └── package.json
│ ├── dogManage/ # 狗狗增改查
│ ├── walkSave/ # 保存遛狗 + 触发统计/成就
│ ├── walkManual/ # 手动补记
│ ├── getDogDetail/ # 狗狗主页聚合数据
│ └── getHistory/ # 历史/记录 Tab 列表聚合
├── miniprogram/
│ ├── app.js # 初始化 wx.cloud、globalData
│ ├── app.json # 路由、tabBar、分包
│ ├── app.wxss # 全局样式 + 设计 token
│ ├── pages/
│ │ ├── login/ # 0a 登录
│ │ ├── dog-edit/ # 0b 建档/编辑(复用)
│ │ ├── home/ # 1 我的tab
│ │ ├── walking/ # 2/2b 遛狗中(含暂停态)
│ │ ├── summary/ # 3 成果卡
│ │ ├── dog-detail/ # 4 狗狗主页
│ │ ├── history/ # 5 遛狗历史
│ │ ├── records/ # 6/6b 记录tab含空状态
│ │ └── achievements/ # 7 完整成就页
│ ├── components/
│ │ ├── dog-card/ # 狗狗卡片
│ │ ├── walk-row/ # 历史/记录行
│ │ ├── stat-grid/ # 统计三宫格
│ │ ├── achievement-medal/ # 成就勋章(解锁/锁定)
│ │ ├── multi-dog-sheet/ # 0c 多狗选择弹层
│ │ └── manual-add-sheet/ # 补记弹层
│ ├── utils/
│ │ ├── cloud.js # callFunction 统一封装 + 错误处理
│ │ ├── format.js # 时长/距离/日期格式化
│ │ ├── dogStep.js # 狗步换算(体型档位/步幅/体重校准)
│ │ └── store.js # 全局状态 + 订阅
│ └── images/ # 本地图标/占位图
├── docs/ # 设计文档(本目录)
├── wangquan-prototype.html # 交互原型
└── project.config.json
```
## 4. 全局状态store
不引入重型状态库。`miniprogram/utils/store.js` 维护:
```js
const store = {
userInfo: null, // { openid, nickname, avatar, joinedAt, walkCount }
dogs: [], // 当前用户的狗狗列表
currentDogId: null, // 默认/上次选中的狗
walkSession: null, // 进行中的遛狗会话(见下)
};
```
**遛狗会话walkSession** 是 V1 最关键的运行时状态,需支持小程序退到后台后恢复:
```js
walkSession = {
dogIds: ['xxx'], // 本次遛的狗(支持多狗)
startAt: 1719230000000,
pausedTotal: 0, // 累计暂停时长(ms),结算时扣除
pausedAt: null, // 当前暂停起点null=进行中
pooped: false, // 便便打卡
locationEnabled: false, // 是否获得定位授权
distanceMeters: null, // GPS 累计距离;未授权为 null
lastLocation: null, // 最近一次有效定位点,用于增量累计距离
status: 'walking' | 'paused',
}
```
落地策略:会话写入 `wx.setStorageSync('walkSession')`App `onShow` 时恢复,避免锁屏/切后台丢失计时。计时基于 `startAt` 与当前时间差实时计算,**不依赖 setInterval 累加**(后台被冻结也不丢)。定位为可选增强:获得授权时累计 GPS 距离并换算狗步;未授权时 `distanceMeters` / `dogStepsByDog``null`,界面隐藏距离与狗步,闭环仍成立。
## 5. 设计 Token迁移自原型
原型 CSS 变量直接转为 `app.wxss` 全局变量,保证视觉一致:
| Token | 值 | 用途 |
|-------|-----|------|
| `--bg` | `#F2EDE6` | 页面背景 |
| `--surface` | `#FBF8F3` | 卡片 |
| `--ink` | `#2E2A24` | 主文字 |
| `--ink-2` | `#6E665B` | 次文字 |
| `--accent` | `#E8A04B` | 主色(按钮/FAB |
| `--accent-deep` | `#C77E2E` | 主色深 |
| `--success` | `#6B8E5A` | 遛狗中 |
| `--warn` | `#D98A4E` | 暂停态 |
| `--danger` | `#C25A4E` | 结束/二次确认 |
> 小程序 WXSS 支持 CSS 变量,可直接 `var(--accent)`。不引第三方组件库,全部基础 UI按钮、卡片、chip、toggle、勋章等按原型纯手写为 `components/` 下的可复用组件。

136
docs/02-数据模型.md Normal file
View File

@@ -0,0 +1,136 @@
# 02 · 数据模型(云数据库)
云数据库为文档型(类 MongoDB。共 4 个集合。约定:`_openid` 由云开发自动写入作为归属与权限依据时间统一存毫秒时间戳Number
---
## 1. `users` — 用户
| 字段 | 类型 | 说明 |
|------|------|------|
| `_id` | String | 自动 |
| `_openid` | String | 微信 openid唯一 |
| `nickname` | String | 昵称,默认「铲屎官」 |
| `avatar` | String | 云存储 fileID可空 |
| `walkCount` | Number | 累计遛狗次数(冗余,展示用) |
| `joinedAt` | Number | 加入时间戳 |
| `createdAt` | Number | 创建时间 |
> 「加入 92 天」由 `joinedAt` 客户端算;「遛狗 87 次」读 `walkCount`。
---
## 2. `dogs` — 狗狗
| 字段 | 类型 | 说明 |
|------|------|------|
| `_id` | String | 狗狗 ID |
| `_openid` | String | 归属用户 |
| `name` | String | **必填**,名字 |
| `breed` | String | **必填**,犬种(如「柯基」) |
| `sizeLevel` | String | 体型档位:`xs`/`s`/`m`/`l`/`xl`,由犬种映射;未知犬种由用户手动选择 |
| `baseStride` | Number | 档位基础步幅,单位米/步 |
| `stride` | Number | 最终步幅,填体重时在 `baseStride` 上校准,封顶 ±15% |
| `gender` | String | `male` / `female` / null |
| `ageStage` | String | `puppy` / `adult` / `senior` / null |
| `ageText` | String | 展示文案,如「成犬 3 岁」,可空 |
| `weight` | Number | kg可空二次校准狗步 |
| `avatar` | String | 云存储 fileID可空 |
| `stats` | Object | 累计统计(见下,冗余加速主页) |
| `createdAt` | Number | 建档时间 |
**`stats` 内嵌对象**(每次遛狗结算时在云函数原子更新):
```js
stats: {
totalWalks: 87, // 总次数
totalDistance: 142.0, // 总公里
totalSteps: 0, // 总狗步(可选)
currentStreak: 7, // 连续天数
lastWalkDate: '2026-06-24', // 最近一次有效遛狗的日期(yyyy-mm-dd),算连续用
}
```
> 仅「名字 + 犬种」必填——降低首次门槛。`sizeLevel/baseStride/stride` 由 `utils/dogStep.js` 按「犬种 → 体型档位 → 步幅」计算;中华田园犬、串串、找不到的犬种必须让用户手动选体型档位。
---
## 3. `walks` — 遛狗记录
| 字段 | 类型 | 说明 |
|------|------|------|
| `_id` | String | 记录 ID |
| `_openid` | String | 归属用户 |
| `dogIds` | Array&lt;String&gt; | 本次遛的狗(支持多狗一起遛) |
| `startAt` | Number | 开始时间戳 |
| `endAt` | Number | 结束时间戳 |
| `duration` | Number | 净遛狗秒数(已扣暂停) |
| `distance` | Number | 公里,未授权定位时为 null |
| `dogStepsByDog` | Object | `{ [dogId]: steps }`,按每只狗的 `stride` 分别换算;未授权定位时为 null |
| `pooped` | Boolean | 便便打卡 |
| `weather` | String | `sunny`/`cloudy`/`rainy`,可空 |
| `mood` | String | 状态标签,可空 |
| `note` | String | 备注,可空 |
| `photos` | Array&lt;String&gt; | 云存储 fileID可空 |
| `source` | String | `realtime` / `manual`,对应 PRD 的数据来源 |
| `isManual` | Boolean | 是否手动补记;兼容展示判断,等价于 `source === 'manual'` |
| `countsForRanking` | Boolean | 是否计入排行榜;补记恒为 `false` |
| `walkDate` | String | `yyyy-mm-dd`,按本地时区,连续/分组用 |
| `createdAt` | Number | 入库时间 |
> 成果卡所有补充字段photos/weather/mood/note全部选填跳过也能保存。`dogStepsByDog` 仅用于拟人化展示;成就与未来排行榜只使用距离、时长、连续天数等客观量。
---
## 4. `dog_achievements` — 成就解锁记录
成就**按狗维度**(原型「成就墙 · 豆豆」)。成就定义不入库,固化在代码 `utils/achievements.js`(含 key、名称、描述、判定规则、图标。本集合只存「某狗解锁了某成就」。
| 字段 | 类型 | 说明 |
|------|------|------|
| `_id` | String | 自动 |
| `_openid` | String | 归属用户 |
| `dogId` | String | 狗狗 ID |
| `achievementKey` | String | 成就标识,如 `week_streak` |
| `unlockedAt` | Number | 解锁时间戳 |
唯一性:`(dogId, achievementKey)` 不重复解锁(云函数判定前先查重)。
**成就 key 参考**(来自原型):
| key | 名称 | 解锁条件 |
|-----|------|---------|
| `first_walk` | 首遛 | 第一次记录遛狗 |
| `week_streak` | 一周不断 | 连续遛狗满 7 天 |
| `month_streak` | 满月坚持 | 连续遛狗满 30 天 |
| `rainy_warrior` | 雨天战士 | 雨天也坚持遛狗 |
| `hundred_km` | 百里同行 | 累计满 100 公里 |
| `early_bird` | 早起鸟 | 7 点前遛狗 10 次 |
---
## 5. 索引
| 集合 | 索引字段 | 说明 |
|------|---------|------|
| `users` | `_openid`(唯一) | 登录查重 |
| `dogs` | `_openid` | 列出我的狗 |
| `walks` | `_openid` + `walkDate`(降序) | 历史列表、连续判定 |
| `walks` | `dogIds`(多键) | 按狗查记录 |
| `dog_achievements` | `dogId` + `achievementKey`(联合唯一) | 查重与成就墙 |
## 6. 数据库权限(安全规则)
全部集合设为 **「仅创建者可读写」**,写操作再经云函数二次校验。客户端只允许读自己的数据;统计累加、成就解锁等只能由云函数(管理员权限)写,防止刷量。
```json
// 示例walks 集合权限
{
"read": "doc._openid == auth.openid",
"write": false // 客户端禁写,统一走云函数
}
```

195
docs/03-云函数设计.md Normal file
View File

@@ -0,0 +1,195 @@
# 03 · 云函数设计
约定:所有云函数用 `wx-server-sdk`,统一返回 `{ ok: true, data }``{ ok: false, code, msg }``openid` 一律从 `cloud.getWXContext().OPENID` 取,**绝不信任客户端传入的 openid**。
---
## 1. `login` — 登录 / 首次建用户
**职责**:拿 openid若用户不存在则建档返回用户信息 + 是否已有狗狗(决定跳建档还是首页)。
```
入参: {}
出参: {
ok, data: {
userInfo: { openid, nickname, avatar, walkCount, joinedAt },
hasDog: Boolean // false → 前端跳 0b 建档true → 跳首页
}
}
```
逻辑:`users``_openid`,无则插入默认用户;再 `dogs` count 判断 `hasDog`
---
## 2. `dogManage` — 狗狗增 / 改 / 查
**职责**:建档、编辑、列表(建档与编辑复用,由 `action` 区分)。
```
入参: {
action: 'create' | 'update' | 'list',
dog?: { _id?, name, breed, sizeLevel?, gender, ageStage, ageText, weight, avatar }
}
出参: { ok, data: dog | dogs[] }
```
要点:
- 服务端校验 `name``breed` 非空。
-`breed` 查表得到体型档位 `sizeLevel`;中华田园犬、串串、找不到的犬种必须由前端让用户手动选择 `sizeLevel` 后再提交。
-`sizeLevel` 得到 `baseStride`,填了 `weight` 时计算最终 `stride``baseStride * (1 + (weight - 档位标准体重) * 0.005)`,修正幅度封顶 ±15%。
- `create` 时初始化 `stats` 全 0。
- `update` 校验该 dog 的 `_openid` == 调用者,防越权改他人狗。
---
## 3. `walkSave` — 保存遛狗(核心)
**职责**:写入一条 `walks`**事务式**更新每只狗 `stats`,触发成就判定;并支持成果卡页回写选填字段。这是 V1 最重的云函数。
### 3.1 结束遛狗:`action='finish'`
```
入参: {
action: 'finish',
dogIds: [String],
startAt, endAt, duration,
distance?,
pooped
}
出参: {
ok, data: {
walkId,
dogStepsByDog,
newAchievements: [{ key, name, desc }] // 本次新解锁,成果卡展示
}
}
```
执行步骤:
1. 校验 `dogIds` 均归属当前 openid未建档任何狗时拒绝保存。
2. 计算 `walkDate`(按用户本地时区,前端传时区偏移或统一 +8
3. 插入 `walks``source='realtime'``isManual=false``countsForRanking=true`
4. 获得定位授权时写入 `distance`,并按每只狗的 `stride` 在服务端计算 `dogStepsByDog`;未授权时 `distance``dogStepsByDog``null`,只保存时长。
5. 对每个 dogId
- `totalWalks += 1``totalDistance += distance||0``totalSteps += dogStepsByDog[dogId]||0`
- **连续天数**:比较 `lastWalkDate``walkDate`——同日不变;昨日则 `currentStreak += 1`;更早则重置为 1。更新 `lastWalkDate`
-`db.command.inc()` 做原子自增,避免并发覆盖。
6. `users.walkCount += 1`
7. 调成就判定(见 `achievementCheck`),收集新解锁返回。
### 3.2 成果卡补充字段:`action='attach'`
```
入参: {
action: 'attach',
walkId,
weather?, mood?, note?, photos?
}
出参: { ok, data: { walkId, newAchievements?: [{ key, name, desc }] } }
```
要点:
- `weather/mood/note/photos` 全部选填,用户跳过也不影响 `finish` 已保存的核心记录。
- 只允许回写当前 openid 自己的 walk。
-`weather='rainy'`,可在 attach 后补触发「雨天战士」等场景成就,并返回新增成就给成果卡刷新。
- 旧记录回看不复用 attach后续单次记录详情页若支持编辑应单独定义记录编辑能力。
---
## 4. `walkManual` — 手动补记
**职责**:补记一次遛狗。规则与 `walkSave` 不同。
```
入参: { dogId, startAt, duration, distance? }
出参: { ok, data: { walkId, newAchievements?: [{ key, name, desc }] } }
```
关键差异(**业务规则,必落地**
- `source='manual'``isManual=true``countsForRanking=false`(不计排行榜,防刷量)。
- **可维持连续打卡**:同样参与 `currentStreak` 计算(不断签)。
- 不触发「分享/成果卡」类成就,但可触发「连续」类成就。
- 列表展示带「补记」标识(前端按 `isManual` 渲染)。
---
## 5. `getDogDetail` — 狗狗主页聚合
**职责**:一次返回狗狗主页所需全部数据,减少往返。
```
入参: { dogId }
出参: {
ok, data: {
dog: {...}, // 含 stats
achievements: { // 成就墙
unlocked: [{key,name,unlockedAt}],
total: 12
},
recentWalks: [walk, ...] // 近期记录(取最近 5~10 条)
}
}
```
---
## 6. `getHistory` — 历史 / 记录 Tab 聚合
**职责**:返回当前用户全部狗狗的遛狗记录列表,供历史页和记录 Tab 复用,减少前端跨集合拼装。
```
入参: { dogId?, page?, pageSize? }
出参: {
ok, data: {
list: [{ walk, dogNames, manualLabel }],
hasMore: Boolean
}
}
```
要点:
- 默认查当前 openid 下全部记录;传 `dogId` 时只查某只狗。
-`walkDate/startAt` 倒序返回,可由前端按周或日期分组。
- 返回项带狗名、是否补记标识,避免列表层再多次请求 `dogs`
- 只读,不写库。
---
## 7. 内部模块:`achievementCheck`(被 walkSave/walkManual 复用)
不是独立云函数,是云函数内共享的判定模块(也可做成被 `cloud.callFunction` 内部调用的函数)。
```
输入: dogId, 该狗最新 stats, 本次 walk含 weather/时间)
流程:
for 每个成就定义:
if 已解锁(查 dog_achievements) → skip
if 满足条件(基于 stats / walk):
插入 dog_achievements
收集进 newAchievements
返回: newAchievements[]
```
判定基于 **连续 / 累计 / 场景**,不基于「最多 / 最快」。
---
## 8. 云函数清单速查
| 云函数 | 触发时机 | 写库 |
|--------|---------|------|
| `login` | App 启动 | users |
| `dogManage` | 建档/编辑/进首页 | dogs |
| `walkSave` | 结束遛狗、成果卡补充字段回写 | walks, dogs, users, dog_achievements |
| `walkManual` | 历史页补记保存 | walks, dogs, dog_achievements |
| `getDogDetail` | 进狗狗主页 | 只读 |
| `getHistory` | 进历史页 / 记录 Tab | 只读(跨全部狗狗,附狗名) |
> 后续若上排行榜pending新增定时触发云函数做聚合按 `countsForRanking=true` 统计。

View File

@@ -0,0 +1,89 @@
# 04 · 页面与路由
## 1. 页面清单(对应原型 12 屏)
| 原型 Frame | 页面/组件 | 路径 | 类型 | 说明 |
|-----------|----------|------|------|------|
| 0a 登录 | login | `pages/login/login` | 普通页 | 微信授权登录 |
| 0b 建档/编辑 | dog-edit | `pages/dog-edit/dog-edit` | 普通页 | 建档与编辑复用,`?id=` 区分 |
| 0c 多狗选择 | multi-dog-sheet | `components/multi-dog-sheet` | 组件(弹层) | 点遛狗时多狗才弹 |
| 1 我的 | home | `pages/home/home` | **Tab** | 启动默认页 |
| 2/2b 遛狗中 | walking | `pages/walking/walking` | 普通页 | 进行/暂停同页切状态 |
| 3 成果卡 | summary | `pages/summary/summary` | 普通页 | 遛完总结 |
| 4 狗狗主页 | dog-detail | `pages/dog-detail/dog-detail` | 普通页 | `?id=` |
| 5 遛狗历史 | history | `pages/history/history` | 普通页 | 含补记弹层 |
| 6/6b 记录 | records | `pages/records/records` | **Tab** | 含空状态 |
| 7 完整成就 | achievements | `pages/achievements/achievements` | 普通页 | `?dogId=`,两入口共用 |
## 2. tabBar 配置app.json
底部三 Tab中央「遛狗」为凸起按钮。微信原生 tabBar 不支持凸起 FAB因此采用 **自定义 tabBar**`custom: true` + `components/custom-tab-bar/`)实现原型的橙色凸起按钮和状态变色。
```jsonc
{
"tabBar": {
"custom": true, // 自定义,实现中央凸起 FAB
"list": [
{ "pagePath": "pages/records/records", "text": "记录" },
{ "pagePath": "pages/home/home", "text": "我的" }
// 中央“遛狗”由自定义 tabBar 渲染,非真实 tab 页
]
}
}
```
中央「遛狗」按钮行为:
- 点击 → 单狗直接进 `walking`;多狗弹 `multi-dog-sheet` 选择后再进。
- 遛狗中时按钮变绿显示「遛狗中」,点击回到 `walking`;暂停态变黄「已暂停」。
## 3. 路由跳转表
```
login ──(新用户)──► dog-edit ──完成──► home(tab)
login ──(老用户)──────────────────► home(tab)
home ──点狗卡片──► dog-detail
home ──点「成就」──► achievements
home ──中央按钮──┬─(单狗)─► walking
└─(多狗)─► multi-dog-sheet ─选定─► walking
walking ⇄ (暂停态同页切换)
walking ──结束+二次确认──► summary ──完成──► dog-detail / home
walking ──定位/数据降级──► (未授权时隐藏距离·狗步;授权时累计 GPS 距离并换算狗步)
dog-detail ──右上编辑──► dog-edit?id=xxx预填
dog-detail ──近期记录──► summary只读回看占位后续替换为 record-detail
records(tab) ──成就概览/全部──► achievements
records(tab) ──+补记──► manual-add-sheet
records(tab) ──历史项──► summary只读回看占位后续替换为 record-detail
history ──右上+──► manual-add-sheet ──保存──► 刷新历史
```
## 4. 公共组件拆分
| 组件 | 用于页面 | 职责 |
|------|---------|------|
| `custom-tab-bar` | 全局 | 三 Tab + 中央状态化 FAB |
| `dog-card` | home | 狗狗卡片(连续天数、犬种) |
| `stat-grid` | summary, dog-detail | 三宫格统计 |
| `walk-row` | history, records, dog-detail | 一条遛狗记录(含补记标识) |
| `achievement-medal` | dog-detail, records, achievements | 勋章(解锁/锁定/进度) |
| `multi-dog-sheet` | tabBar 触发 | 多狗选择弹层 |
| `manual-add-sheet` | history, records | 补记表单弹层 |
| `chip-group` | dog-edit, summary | 单选标签组(性别/年龄/天气/状态) |
## 5. 关键交互细节(迁移自原型注释)
- **遛狗中计时**:基于 `startAt` 实时差值计算,扣除累计暂停时长;切后台不丢(见架构文档 §4
- **定位增强**:不在首次打开强制索权;开始遛狗后可请求定位。授权时累计 GPS 距离并按狗狗 `stride` 换算狗步,未授权时隐藏距离/狗步,只保留时长。
- **便便打卡**:行内 toggle遛狗中即时写入会话状态结束时随 walk 落库。
- **暂停态**:停止计时、数据置灰,主按钮变「继续」——防止等狗/捡便便污染数据。
- **结束**:必须二次确认弹窗,再进成果卡。
- **成果卡视角**:文案「豆豆今天走了 3,480 狗步」,非「你遛了」。
- **空状态6b**新用户「记录」Tab 显俏皮引导 +「带豆豆出门」按钮,成就墙全灰待解锁;遛完第一次切正常态。
- **分享**:成果卡「生成图片分享朋友圈」用 Canvas 2D 绘制成果图,是拉新主入口。
- **历史回看**V1 可复用 `summary` 做只读态,但不得显示「完成」这类结束流程按钮;正式形态后续补 `record-detail`

69
docs/05-业务规则.md Normal file
View File

@@ -0,0 +1,69 @@
# 05 · 业务规则(必落地)
> 这些规则散落在 PRD 与原型的橙色虚线注释里,是产品的「魂」。开发时务必逐条实现,不可省略。
## R1 · 登录与建档分流
- 微信授权登录后:**无任何狗狗 → 直接进建档0b**;已有狗狗 → 跳过,进首页。
- 协议/隐私为必须合规要素,登录页底部展示。
## R2 · 建档门槛最低
-**名字、犬种** 必填,其余(性别/年龄/体重/头像)全选填——降低首次门槛。
- 建档与编辑**复用同一表单**;编辑时预填已有值,标题改「编辑资料」。
- 犬种用于映射体型档位;中华田园犬、串串、找不到的犬种必须让用户手动选体型档位。
## R3 · 狗步算法
- 狗步公式:`dogStepsByDog[dogId] = round(distanceMeters / dog.stride)`
- `stride` 由体型档位决定,不做「每品种一个系数」。体型档位与基础步幅:超小型 `0.20m`、小型/短腿 `0.25m`、中型 `0.35m`、大型 `0.45m`、超大型 `0.55m`
- 填了体重时,在基础步幅上做二次校准:`stride = baseStride * (1 + (weight - 档位标准体重) * 0.005)`,修正幅度封顶 ±15%。
- 狗步只是展示层:多狗同遛时按每只狗分别计算;成就、未来排行榜和防刷统计只用距离、时长、连续天数等客观量。
## R4 · 多狗选择
- 仅多狗用户点遛狗时弹出选择层;单狗直接进遛狗中。
- 选择层**可多选**,支持多只狗一起遛(一条 walk 关联多个 dogId
## R5 · 遛狗计时与暂停
- 计时基于时间戳差值,扣除暂停时长;**切后台/锁屏不丢**。
- **暂停 = 停止计时 + 数据置灰**,主按钮变「继续」。意义:遛狗常有等狗、捡便便的停顿,暂停防止污染数据。
- 结束必须**二次确认**再生成成果卡。
## R6 · 定位与数据降级
- V1 纳入可选 GPS 距离与狗步:授权时累计距离并换算狗步。
- 未授权定位时,**距离 / 狗步隐藏**(存 null时长照常。不阻断遛狗闭环。
- 首次打开不强制索权;定位应在开始遛狗或用户主动开启增强能力时请求。
- 地图轨迹回放、精准轨迹去噪后置,不阻塞 V1。
## R7 · 成果卡全选填
- 照片、备注、天气、状态等所有输入字段**全部选填,跳过也能保存**——遛狗时用户腾不出手。
- 视角为「狗狗走了」而非「你遛了」。
- **分享朋友圈是首选 CTA**(拉新入口)。
- 核心记录在结束遛狗时先保存;成果卡补充字段通过回写接口保存,跳过不影响记录沉淀。
## R8 · 手动补记
- 入口:历史页 / 记录 Tab 右上「+」。
- 数据层标记 `source='manual'``isManual=true`
- **补记可维持连续打卡不断签**(参与连续天数计算)。
- **补记不计入排行榜**`countsForRanking=false`,防刷量)。
- 补记记录列表带「补记」标识区分。
## R9 · 统计维度
- 累计只用 **次数 / 公里 / 连续天数**
- **不要配速、不要卡路里**——记录陪伴厚度,而非运动表现。
## R10 · 连续天数算法
-`walkDate`(本地时区 yyyy-mm-dd为单位。
- 同日多次遛狗:连续天数不变。
- 距上次为「昨天」:`currentStreak += 1`
- 距上次 > 1 天(断签):重置为 1。
- 补记同样按其 `walkDate` 参与此计算。
## R11 · 成就体系
- 成就**按狗维度**,基于 **连续 / 累计 / 场景**,而非「最多 / 最快」。
- 未解锁项显示进度(「还差 X」强化收集与坚持动机。
- 成就墙露灰锁制造收集欲;新用户也显示全灰待解锁(含「首遛」)。
- 「记录」Tab 与「我的→成就」两入口指向**同一页**,文案统一叫「成就」。
## R12 · 安全与防刷
- 所有写操作 + 统计累加 + 成就解锁**只在云函数执行**,客户端集合权限禁写。
- openid 一律服务端取,不信任客户端传参。
- 同一 `(dogId, achievementKey)` 不重复解锁。

145
docs/06-开发计划.md Normal file
View File

@@ -0,0 +1,145 @@
# 06 · 开发计划
按「先跑通核心闭环,再做沉淀与增长」的顺序。每阶段可独立验收。
---
## ✅ 进度总览(截至 2026-06-25
V1 开发主体(阶段 0 → 4全部完成并部署。
**环境**`cloud1-d0gmb6bjde8af3266` · AppId `wx6cfaffb865048e83` · 部署方式 CloudBase CLI`tcb`,配置见根目录 `cloudbaserc.json`)。
**已部署云函数7**`login``dogManage``walkSave``walkManual``getDogDetail``getHistory``sendReminder`+ 每日 19:00 定时触发器 `dailyReminder`)。
**数据库集合4已建**`users``dogs``walks``dog_achievements`
**页面9全部真实功能**login / dog-edit / home / walking / summary / dog-detail / history / records / achievements组件custom-tab-bar、manual-add-sheet。
| 阶段 | 状态 |
|------|------|
| 0 工程搭建 | ✅ 完成 |
| 1 账号与建档闭环 | ✅ 完成 |
| 2 遛狗核心闭环 | ✅ 完成 |
| 2.5 定位与狗步 | ✅ 完成 |
| 3 沉淀与激励 | ✅ 完成 |
| 4 增长 | ✅ 完成(订阅消息待填模板 ID |
| 5 Pending | ⏸ 未启动(不在 V1 |
---
## 🐛 逻辑漏洞修正2026-06-25 代码审查)
对照 [05-业务规则](./05-业务规则.md) 审查现网代码,发现并修正以下逻辑漏洞:
### 1. 补记无法维持连续打卡(违反 R8 / R10
- **问题**`walkSave`/`walkManual` 的连续天数只依据 `lastWalkDate + currentStreak` 做增量推算。当用户「先实时遛了今天 → 再补记昨天」时,补记日期早于 `lastWalkDate`,算法走 `diff<=0` 分支原样返回,**连续天数不会向前补全**,直接违背 R8「补记可维持连续打卡不断签」。
- **修正**:新增 `computeStreak(openid, dogId)`,落库后**从该狗全部 `walkDate` 历史重算**连续天数(`Set` 去重同日多次、以最新日期为锚向前数连续日、遇断签停止)。补记以任意顺序插入都能正确续签/断签。`walkSave``walkManual` 均改用此函数,`lastWalkDate` 也取自重算结果。
### 2. 「早起鸟」成就永不可解锁(违反 R11
- **问题**`utils/achievements.js` 定义了 6 个成就且 `getDogDetail` 也按 6 统计,但「早起鸟」(早遛 10 次) **在任何云函数中都没有解锁逻辑、也无计数字段**,导致用户永远停在 5/6成就墙存在一个不可达成项。
- **修正**`walkSave`(仅实时遛狗,场景类不计补记)按开始时间本地小时 `<7` 累加 `stats.earlyWalks`,达 10 次解锁;`STAT_ACHIEVEMENTS` 增加 `early_bird` 判定。前端 `achievements.js` 进度与「还差 X 次」文案改为基于 `earlyWalks` 真实计算。
### 3. 成果卡选填字段「先分享后补填」会丢失(违反 R7
- **问题**`summary.js``ensureAttached` 在字段为空时即把 `_attached``true`。若用户先点「分享」(此时未填)再补填天气/备注/照片后点「完成」,回写被提前锁定跳过,**选填字段与「雨天战士」成就一并丢失**。
- **修正**:空内容时仅 `return` 不置位,只有真正成功回写后才锁定 `_attached`,保证后续补填仍能写入。
> 数据兼容:旧狗档无 `earlyWalks` 字段,云函数与前端均以 `|| 0` 兜底;连续天数在下一次遛狗/补记时按历史自动重算修正,无需迁移。
---
**待你手动处理**
1. 订阅消息模板 ID — 公众平台创建后填入 `utils/config.js``sendReminder/index.js`,重新部署。
2. (可选)数据库索引与权限收紧 — 见 [02-数据模型](./02-数据模型.md) §5/§6当前客户端不直读库默认管理端权限不影响功能。
3. (可选)存量旧狗数据迁移 — 阶段1建的狗无 `sizeLevel/stride`进编辑页重存即可补齐walkSave 已兜底 0.35m 不报错)。
---
## 阶段 0 · 工程搭建(地基)
**目标**:能在微信开发者工具里跑起空壳,云开发环境就绪。
- [x] 创建小程序 + 开通云开发,建环境(实际 env `cloud1-d0gmb6bjde8af3266`)。
- [x] 初始化目录结构(见 [01-架构设计](./01-架构设计.md))。
- [x] `app.js` 初始化 `wx.cloud``app.wxss` 落地设计 token。
- [x] 纯手写基础 UI不引第三方库全局 `.btn/.card` 等 + 组件 `custom-tab-bar`/`manual-add-sheet``dog-card`/`stat-grid` 等暂内联在页面,未抽独立组件)。
- [x] 建 4 个数据库集合CLI 建好)。 ⚠️ 索引未建、权限为默认管理端(客户端不直读,暂不影响)—— 见 [02-数据模型](./02-数据模型.md) §5/§6。
- [x] `utils/cloud.js` 封装 callFunction统一错误/loading
**验收**:✅ 工程可在开发者工具导入运行;云函数经 `tcb fn deploy` 部署可调通。
---
## 阶段 1 · 账号与建档闭环
**对应屏**0a 登录 → 0b 建档 → 1 我的
- [x] `login` 云函数 + 登录页R1 分流)。
- [x] `dogManage` 云函数create/update/listR2
- [x] 建档/编辑页(复用表单,犬种 pickerchip 单选)。
- [x] 首页:用户卡 + 狗狗卡 + 功能入口列表。
- [x] 自定义 tabBar中央状态化 FABidle/walking/paused 三态变色)。
**验收**:✅ 新用户登录→建档→进首页看到自己的狗;编辑能预填回写。
---
## 阶段 2 · 遛狗核心闭环V1 重点)⭐
**对应屏**2/2b 遛狗中 → 3 成果卡 → 4 狗狗主页
- [x] 遛狗会话状态机walking/paused时间戳计时后台恢复R5
- [x] 多狗选择弹层R4
- [x] 便便打卡、暂停/继续、结束二次确认R5
- [x] 定位增强 + 降级处理R6授权时累计 GPS 距离并换算狗步;未授权时距离/狗步存 null 且界面隐藏。
- 狗步模型按 R3 落地体型档位→步幅→体重±15%校准(`utils/dogStep.js` + `dogManage`);未知犬种建档强制选体型档位。
- `walkSave``action:'finish'`、收 `distanceMeters`、按每只狗 stride 算狗步walk 增 `source` 字段。
- [x] `walkSave` 云函数:落库 + stats 更新 + 连续天数R10+ 成就判定R11`finish/attach` 动作回写选填字段 + 雨天战士。
- [x] 成果卡页(全选填 R7新成就提示三宫格统计
- [x] `getDogDetail` + 狗狗主页(累计 R9、成就墙、近期记录
**验收**:完整走通「点遛狗→计时/距离/狗步(定位授权时)→结束→成果卡→记录沉淀进狗狗主页」;未授权定位时只保留时长,连续天数与统计正确累加。
---
## 阶段 3 · 沉淀与激励
**对应屏**5 历史 → 6/6b 记录 → 7 成就
- [x] 遛狗历史页(按周分组,补记标识)。
- [x] `walkManual` 补记云函数 + 补记弹层组件R8
- [x] 新增 `getHistory` 云函数(跨全部狗狗的历史列表,附狗名)。
- [x] 记录 Tab成就概览 + 历史;空状态 6b 引导)。
- [x] 完整成就页(已解锁/未解锁含进度,两入口共用)。
- [x] 成就定义表 `utils/achievements.js` 全量落地。
**验收**:补记不计排行但维持连续;成就解锁与进度展示正确;新用户空状态引导生效。
---
## 阶段 4 · 增长
- [x] 成果卡 Canvas 生成分享图(`canvas type=2d``showShareImageMenu`+ `onShareAppMessage/onShareTimeline` 转发。
- [x] 头像/照片云存储上传与压缩(`utils/media.js`chooseMedia 压缩 → cloud.uploadFile头像在首页/狗狗主页/成果卡显示。
- [x] 连续打卡订阅消息:`sendReminder` 云函数 + 每日 19:00 定时触发器;成果卡「完成」时 `requestSubscribeMessage`
- ⚠️ 需手动:公众平台创建订阅消息模板 → 把模板 ID 填入 `miniprogram/utils/config.js``cloudfunctions/sendReminder/index.js`,并按模板字段改 `data`。未配置时该功能静默跳过。
**验收**:成果卡能生成带数据的分享图并分享;头像/照片可上传并展示;模板配置后定时提醒可发。
---
## 阶段 5 · 后置 / Pending不在 V1
- 地图轨迹回放、GPS 去噪与更精细的轨迹纠偏。
- 广场、附近、排行榜、信息站。
- 排行榜需新增定时聚合云函数,按 `countsForRanking=true` 统计。
---
## 里程碑建议
| 里程碑 | 含阶段 | 可交付 | 状态 |
|--------|--------|--------|------|
| **M1 可建档** | 0 + 1 | 登录建档跑通 | ✅ 达成 |
| **M2 可遛狗** ⭐ | 2 + 2.5 | 核心闭环可用(含 GPS/狗步),可内测 | ✅ 达成 |
| **M3 可沉淀** | 3 | 完整 V1 体验 | ✅ 达成 |
| **M4 可传播** | 4 | 上线 + 拉新 | ✅ 达成(订阅消息待填模板 ID |
> 下一步建议:真机全流程联调 → 处理上方「待你手动」三项 → 提审上线。阶段 5排行榜/广场等)需先做产品设计再排期。

View File

@@ -0,0 +1,42 @@
# 07 · PRD 对齐评审
> 目的:逐条对比 `wangquan-PRD.md` 与 `docs/` 落地设计,判断哪一边方案更合理,并记录已经写回设计文档的结论。
## 结论总览
整体方向以 PRD 为产品边界,以 `docs/` 为工程落地方式。PRD 在产品范围、狗步算法、定位降级、拟人化表达上更完整;`docs/` 在技术选型、云函数拆分、安全、防刷、后台计时恢复上更落地。最终方案采用「PRD 定边界docs 定实现」的组合。
## 逐条对比
| 项 | PRD 方案 | docs 原方案 | 哪边更好 | 最终修改 |
|---|---|---|---|---|
| V1 范围 | V1 包含计时、距离、狗步;轨迹可后置 | 计时先跑通GPS 距离/狗步后置 | PRD 更好。距离/狗步是成果卡拟人化的关键,不应整体后置 | V1 纳入可选 GPS 距离与狗步后置仅保留轨迹回放、GPS 去噪 |
| 定位权限 | 首版不强制定位,未授权隐藏距离/狗步 | 降级处理有,但把距离/狗步存 null 作为阶段方案 | PRD 更清晰 | 明确“授权则增强,未授权则降级”,首次打开不强制索权 |
| 狗步算法 | 体型档位决定步幅,体重可二次校准 | `breedFactor` 犬种系数 | PRD 更好。犬种系数会把模型复杂化,也不利于串串/未知犬种 | 改为 `sizeLevel/baseStride/stride`,未知犬种手动选体型 |
| 多狗同遛狗步 | 支持多狗一起遛,但 PRD 未展开字段结构 | 单个 `dogSteps` 字段 | 组合后更好。多狗同遛时每只狗步幅不同,不能只存一个狗步 | 改为 `dogStepsByDog: { [dogId]: steps }`,服务端按每只狗 `stride` 计算 |
| 狗步用途 | 狗步只是展示层,不用于跨狗比较、成就、排行 | 有 `totalSteps`,但约束不够明确 | PRD 更好 | 文档补充:成就/排行只用距离、时长、连续天数等客观量 |
| 登录建档分流 | 登录后按是否已有狗判断进入建档或首页 | `login` 返回 `hasDog` | docs 更落地 | 保留 `hasDog` 云函数方案 |
| 建档字段 | 名字、犬种必填,其余选填 | 同 PRD | 一致 | 保留,补充未知犬种体型选择 |
| 计时与暂停 | 暂停停止计时,结束二次确认 | 时间戳差值 + `pausedTotal`,支持后台恢复 | docs 更好 | 保留 docs 的时间戳实现PRD 规则不变 |
| 成果卡补充字段 | 照片/文字/天气/心情集中成果卡,全部选填 | `walkSave` 入参混在结束保存里,计划里提到 attach 但设计不完整 | PRD 交互更好docs 的 attach 思路更落地 | `walkSave` 明确拆成 `finish``attach` 两个动作 |
| 成果卡分享 | 分享是首选 CTA分享图样式建议尽早做 | Canvas 分享图在增长阶段 | docs 更务实 | 保留增长阶段,但成果卡仍保留分享 CTA |
| 狗狗主页统计 | 总次数/总公里/连续天数,不要配速/卡路里 | 同 PRD | 一致 | 保留 |
| 记录 Tab | 成就概览 + 历史 + 补记;空状态露出灰锁 | 同 PRD | 一致 | 保留 |
| 手动补记 | 可维持连续,不计未来排行榜,标记数据来源 | `isManual` + `countsForRanking` | 组合后更好 | 增加 `source='manual'/'realtime'`,保留 `isManual` 便于展示 |
| 连续天数算法 | 补记可不断签,以日期判断 | docs 写了同日/昨日/断签逻辑 | docs 更落地 | 保留 docs 算法 |
| 成就体系 | 按连续/累计/场景,不按最多 | 同 PRD且有 `dog_achievements` 集合 | docs 更落地 | 保留集合设计,强调按狗维度 |
| 单次记录详情页 | 建议补,只读详情页更合理;首版可暂用成果卡只读态 | 历史项直接跳 summary 回看 | PRD 更完整docs 更省实现 | V1 允许 `summary` 只读占位,但禁止出现“完成”等流程按钮;后续补 `record-detail` |
| 设置页 | PRD 建议补定位权限、关于、隐私政策 | docs 未纳入页面清单 | PRD 更完整,但不阻塞 V1 主闭环 | 暂不扩页面清单,作为后续待补项处理 |
| 技术选型 | PRD 不限定 | 原生小程序 + 云开发 | docs 更好 | 保留 |
| 安全与防刷 | 需要区分实时/补记,防排行刷量 | 所有写操作走云函数openid 服务端取,客户端禁写 | docs 更好 | 保留并补充 `source` 字段 |
| Pending 功能 | 社区、附近、排行榜、信息站、商城不进首版 | 同 PRD | 一致 | 保留 |
## 已写回的文档
- `README.md`:更新 V1 范围和文档索引。
- `01-架构设计.md`:补充定位会话字段,修正 `dogStep.js` 职责。
- `02-数据模型.md`:改为体型档位/步幅模型,新增 `source``dogStepsByDog`
- `03-云函数设计.md`:补齐 `dogManage` 步幅计算、`walkSave finish/attach`、服务端狗步计算。
- `04-页面与路由.md`:明确定位增强、历史回看只读占位。
- `05-业务规则.md`:重新整理必落地规则,新增狗步算法和定位规则。
- `06-开发计划.md`:把 V1 距离/狗步拉回核心闭环,后置项改为轨迹回放与去噪。

37
docs/README.md Normal file
View File

@@ -0,0 +1,37 @@
# 汪圈 · 遛狗小程序 — 设计文档
> 微信原生小程序 + 云开发CloudBase。V1 聚焦「遛狗单机闭环」:登录 → 建档 → 首页 → 遛狗 → 成果卡 → 沉淀。
## 一句话定位
记录「陪伴的厚度」而非运动表现。视角是「狗狗走了」而非「你遛了」。用 **连续 / 累计 / 成就** 沉淀情感,用 **成果卡分享朋友圈** 做拉新。
## 技术栈
| 层 | 选型 |
|----|------|
| 客户端 | 微信原生小程序WXML / WXSS / JS |
| UI 组件库 | 不用第三方库,纯手写 WXSS最贴合原型视觉、包体最小 |
| 后端 | 微信云开发:云数据库 + 云存储 + 云函数 |
| 鉴权 | 云调用 `cloud.getWXContext().OPENID`(免自建 token |
| 状态管理 | 轻量全局 store`app.globalData` + 事件总线,不引 Redux |
## 文档索引
| 文档 | 内容 |
|------|------|
| [01-架构设计.md](./01-架构设计.md) | 技术选型、工程目录、全局状态、设计 token |
| [02-数据模型.md](./02-数据模型.md) | 云数据库集合设计、索引、权限 |
| [03-云函数设计.md](./03-云函数设计.md) | 云函数清单、入参出参、职责划分 |
| [04-页面与路由.md](./04-页面与路由.md) | 页面清单(对应原型 12 屏)、路由表、组件拆分 |
| [05-业务规则.md](./05-业务规则.md) | 关键业务规则(必落地)汇总 |
| [06-开发计划.md](./06-开发计划.md) | 里程碑与阶段拆分 |
| [07-PRD对齐评审.md](./07-PRD对齐评审.md) | PRD 与落地设计逐条对比、取舍结论 |
## 范围边界V1
- ✅ 纳入:登录、建档、遛狗计时闭环、可选 GPS 距离、狗步换算、成果卡、狗狗主页、历史、补记、成就。
- ⏸ 后置:地图轨迹回放、精准轨迹去噪、单次记录详情页完整形态。
- ❌ 不做pending广场、附近、排行榜、信息站。

30
miniprogram/app.js Normal file
View File

@@ -0,0 +1,30 @@
// app.js
App({
globalData: {
env: 'cloud1-d0gmb6bjde8af3266', // TODO: 替换为你的云开发环境 ID
userInfo: null,
dogs: [],
currentDogId: null,
walkSession: null,
},
onLaunch() {
if (!wx.cloud) {
console.error('请使用 2.2.3 或以上的基础库以使用云能力');
return;
}
const { platform } = wx.getDeviceInfo();
const isDevtools = platform === 'devtools';
wx.cloud.init({
env: this.globalData.env,
traceUser: !isDevtools,
});
// 恢复未结束的遛狗会话(切后台/重启不丢,见 docs/01 §4
const session = wx.getStorageSync('walkSession');
if (session) {
this.globalData.walkSession = session;
}
},
});

42
miniprogram/app.json Normal file
View File

@@ -0,0 +1,42 @@
{
"pages": [
"pages/login/login",
"pages/home/home",
"pages/records/records",
"pages/dog-edit/dog-edit",
"pages/walking/walking",
"pages/summary/summary",
"pages/dog-detail/dog-detail",
"pages/history/history",
"pages/achievements/achievements",
"pages/agreement/agreement",
"pages/privacy/privacy"
],
"window": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "汪圈",
"navigationBarBackgroundColor": "#F2EDE6",
"backgroundColor": "#F2EDE6",
"backgroundTextStyle": "dark"
},
"tabBar": {
"custom": true,
"color": "#A89F92",
"selectedColor": "#C77E2E",
"backgroundColor": "#FBF8F3",
"borderStyle": "white",
"list": [
{ "pagePath": "pages/records/records", "text": "记录" },
{ "pagePath": "pages/home/home", "text": "我的" }
]
},
"style": "v2",
"sitemapLocation": "sitemap.json",
"lazyCodeLoading": "requiredComponents",
"permission": {
"scope.userLocation": {
"desc": "用于记录遛狗的距离与狗步"
}
},
"requiredPrivateInfos": ["startLocationUpdate", "onLocationChange"]
}

195
miniprogram/app.wxss Normal file
View File

@@ -0,0 +1,195 @@
/* app.wxss — 全局样式 + 设计 token迁移自原型 */
page {
/* ===== 颜色 ===== */
--bg: #F2EDE6;
--surface: #FBF8F3;
--surface-2: #EDE6DC;
--ink: #2E2A24;
--ink-2: #6E665B;
--ink-3: #A89F92;
--accent: #E8A04B;
--accent-deep: #C77E2E;
--accent-soft: #F7E5CC;
--success: #6B8E5A;
--success-soft: #E2EBD9;
--warn: #D98A4E;
--danger: #C25A4E;
--danger-soft: #F2DDD8;
--line: rgba(46, 42, 36, 0.10);
--line-2: rgba(46, 42, 36, 0.16);
/* ===== 圆角 / 阴影 ===== */
--radius: 36rpx;
--radius-sm: 24rpx;
--radius-pill: 999rpx;
--shadow: 0 2rpx 4rpx rgba(46,42,36,0.04), 0 16rpx 48rpx rgba(46,42,36,0.06);
--font: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
--icon-size: 40rpx;
--icon-size-sm: 36rpx;
--tab-icon-size: 44rpx;
--fab-icon-size: 52rpx;
--form-label-size: 24rpx;
--input-height: 84rpx;
--input-font-size: 26rpx;
--input-radius: 20rpx;
--input-padding-y: 20rpx;
--input-padding-x: 24rpx;
--chip-font-size: 25rpx;
--chip-padding-y: 12rpx;
--chip-padding-x: 24rpx;
--btn-height: 92rpx;
background: var(--bg);
color: var(--ink);
font-family: var(--font);
-webkit-font-smoothing: antialiased;
line-height: 1.5;
box-sizing: border-box;
}
view, text, button, input, textarea, image, scroll-view, picker { box-sizing: border-box; }
view, text, button, input, textarea, picker { font-family: var(--font); }
button, input, textarea { line-height: 1.5; }
/* ===== 通用工具类 ===== */
.muted { color: var(--ink-2); }
.tiny { font-size: 22rpx; }
.center { text-align: center; }
.row { display: flex; align-items: center; }
.card {
background: var(--surface);
border-radius: var(--radius);
border: 1rpx solid var(--line);
box-shadow: var(--shadow);
}
.field {
padding: 22rpx 32rpx;
border-bottom: 1rpx solid var(--line);
}
.field.last { border-bottom: none; }
.flabel {
font-size: var(--form-label-size);
color: var(--ink-2);
margin-bottom: 12rpx;
display: flex;
align-items: center;
gap: 12rpx;
}
.finput,
.fpicker,
.fpick,
.note-input,
.input-fake {
min-height: var(--input-height);
background: var(--surface-2);
border-radius: var(--input-radius);
padding: var(--input-padding-y) var(--input-padding-x);
font-size: var(--input-font-size);
color: var(--ink);
line-height: 1.4;
}
input.finput,
input.note-input {
height: var(--input-height);
}
.placeholder,
.fpicker.placeholder,
.input-fake.placeholder {
color: var(--ink-3);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.chip {
font-size: var(--chip-font-size);
padding: var(--chip-padding-y) var(--chip-padding-x);
border-radius: var(--radius-pill);
border: 1rpx solid var(--line-2);
background: var(--surface-2);
color: var(--ink);
line-height: 1.4;
}
.chip.sel {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent-deep);
font-weight: 600;
}
.chev,
.wr-ico,
.tab-ico,
.fab-ico,
.medal,
.ach-medal,
.photo-add,
.logo,
.paw,
.avatar,
.user-avatar,
.dog-avatar,
.hero-avatar,
.sel-avatar,
.sel-check,
.close {
font-family: var(--font);
line-height: 1;
}
.ui-ico {
width: var(--icon-size);
height: var(--icon-size);
font-size: var(--icon-size);
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.btn-ico {
width: var(--icon-size-sm);
height: var(--icon-size-sm);
font-size: var(--icon-size-sm);
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* ===== 基础按钮 ===== */
.btn {
height: var(--btn-height);
border-radius: var(--radius-pill);
border: 1rpx solid var(--line-2);
background: var(--surface);
color: var(--ink);
font-size: 28rpx;
font-weight: 600;
font-family: var(--font);
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
width: 100%;
line-height: 1;
transition: transform .2s, opacity .2s, background .2s;
}
.btn::after { border: none; }
.btn.primary { background: var(--accent); color: #fff; border-color: transparent; }
.btn.danger { color: var(--danger); border-color: var(--danger-soft); }
.btn.ghost { background: transparent; }
.btn:active { transform: scale(0.98); opacity: 0.96; }

View File

@@ -0,0 +1,62 @@
// components/manual-add-sheet — 手动补记弹层docs/05 R7
// 用法:父页放 <manual-add-sheet id="addSheet" dogs="{{dogs}}" bind:saved="onSaved"/>
// this.selectComponent('#addSheet').open()
const { call } = require('../../utils/cloud.js');
function pad(n) { return String(n).padStart(2, '0'); }
Component({
properties: {
dogs: { type: Array, value: [] },
},
data: {
show: false,
dogId: '',
date: '',
time: '18:00',
durationMin: '',
distance: '',
submitting: false,
},
methods: {
open() {
const dogs = this.data.dogs;
const t = new Date();
const date = `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`;
this.setData({
show: true,
dogId: dogs[0] ? dogs[0]._id : '',
date, time: '18:00', durationMin: '', distance: '',
});
},
close() { this.setData({ show: false }); },
noop() {},
pickDog(e) { this.setData({ dogId: e.currentTarget.dataset.id }); },
onDate(e) { this.setData({ date: e.detail.value }); },
onTime(e) { this.setData({ time: e.detail.value }); },
onDuration(e) { this.setData({ durationMin: e.detail.value }); },
onDistance(e) { this.setData({ distance: e.detail.value }); },
async save() {
const { dogId, date, time, durationMin, distance, submitting } = this.data;
if (submitting) return;
if (!dogId) return wx.showToast({ title: '选择狗狗', icon: 'none' });
if (!durationMin || Number(durationMin) <= 0) return wx.showToast({ title: '填写时长', icon: 'none' });
const startAt = new Date(`${date}T${time || '00:00'}:00`).getTime();
this.setData({ submitting: true });
const res = await call('walkManual', {
dogId,
startAt,
duration: Math.round(Number(durationMin) * 60),
distance: distance ? Number(distance) : null,
}, { loading: true, loadingText: '保存中' });
this.setData({ submitting: false });
if (!res.ok) return;
this.setData({ show: false });
wx.showToast({ title: '补记成功', icon: 'success' });
this.triggerEvent('saved');
},
},
});

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@@ -0,0 +1,47 @@
<view class="overlay {{show?'show':''}}" bindtap="close">
<view class="sheet" catchtap="noop">
<view class="handle"></view>
<view class="title">手动补记</view>
<view class="subtitle">忘记开 App 也能补上这次遛狗</view>
<scroll-view scroll-y class="sheet-body">
<view class="field">
<view class="flabel">哪只狗</view>
<view class="chips">
<view wx:for="{{dogs}}" wx:key="_id" class="chip {{dogId===item._id?'sel':''}}" data-id="{{item._id}}" bindtap="pickDog">{{item.name}}</view>
</view>
</view>
<view class="field">
<view class="flabel">日期</view>
<picker mode="date" value="{{date}}" bindchange="onDate">
<view class="fpick">{{date}} <text class="chev"></text></view>
</picker>
</view>
<view class="field">
<view class="flabel">时间</view>
<picker mode="time" value="{{time}}" bindchange="onTime">
<view class="fpick">{{time}} <text class="chev"></text></view>
</picker>
</view>
<view class="field">
<view class="flabel">时长(分钟)</view>
<input class="finput" type="digit" placeholder="如 15" value="{{durationMin}}" bindinput="onDuration" />
</view>
<view class="field last">
<view class="flabel">距离(公里,可选)</view>
<input class="finput" type="digit" placeholder="可不填" value="{{distance}}" bindinput="onDistance" />
</view>
<view class="rule">补记不影响连续打卡,但不计入排行榜。</view>
</scroll-view>
<view class="btns">
<button class="btn ghost" bindtap="close">取消</button>
<button class="btn primary" bindtap="save" loading="{{submitting}}">保存补记</button>
</view>
</view>
</view>

View File

@@ -0,0 +1,59 @@
.overlay {
font-family: var(--font);
position: fixed; inset: 0; z-index: 60;
background: rgba(46,42,36,0.42);
display: flex; align-items: flex-end;
opacity: 0; pointer-events: none; transition: opacity .22s;
}
.overlay.show { opacity: 1; pointer-events: auto; }
.sheet {
width: 100%;
background: var(--surface);
border-radius: 44rpx 44rpx 0 0;
padding: 16rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
transform: translateY(40rpx); transition: transform .26s;
max-height: 88vh;
display: flex; flex-direction: column; /* 头部/底部固定,中间表单滚动 */
}
.overlay.show .sheet { transform: translateY(0); }
.handle { flex-shrink: 0; width: 72rpx; height: 8rpx; border-radius: 4rpx; background: var(--line-2); margin: 12rpx auto 24rpx; }
.title { flex-shrink: 0; font-size: 32rpx; font-weight: 600; text-align: center; }
.subtitle { flex-shrink: 0; font-size: 22rpx; color: var(--ink-2); text-align: center; margin: 8rpx 0 20rpx; }
/* 可滚动表单区:内容再多,底部按钮也不会被挤出可视区 */
.sheet-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
.field { padding: 22rpx 0; border-bottom: 1rpx solid var(--line); }
.field.last { border-bottom: none; }
.flabel { font-size: var(--form-label-size); color: var(--ink-2); margin-bottom: 12rpx; }
.chips { display: flex; flex-wrap: wrap; gap: 16rpx; }
.chip { font-size: var(--chip-font-size); padding: var(--chip-padding-y) var(--chip-padding-x); border-radius: var(--radius-pill); border: 1rpx solid var(--line-2); background: var(--surface-2); line-height: 1.4; }
.chip.sel { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-deep); font-weight: 600; }
.fpick { min-height: var(--input-height); font-size: var(--input-font-size); background: var(--surface-2); border-radius: var(--input-radius); padding: var(--input-padding-y) var(--input-padding-x); line-height: 1.4; display: flex; align-items: center; justify-content: space-between; }
.fpick .chev { color: var(--ink-3); font-size: var(--icon-size-sm); line-height: 1; }
.finput { height: var(--input-height); min-height: var(--input-height); font-size: var(--input-font-size); background: var(--surface-2); border-radius: var(--input-radius); padding: var(--input-padding-y) var(--input-padding-x); line-height: 1.4; }
.rule {
margin: 16rpx 0;
font-size: 22rpx; color: #7a5a1e;
background: #FFF7E8; border: 1rpx dashed var(--accent);
border-radius: 12rpx; padding: 16rpx 18rpx; line-height: 1.5;
}
.btns { flex-shrink: 0; display: flex; gap: 20rpx; padding-top: 16rpx; }
/* 组件样式隔离app.wxss 的 .btn 不会透传进来,按本组件已有做法在此本地补全 */
.btn {
height: var(--btn-height);
border-radius: var(--radius-pill);
border: 1rpx solid var(--line-2);
background: var(--surface);
color: var(--ink);
font-size: 28rpx; font-weight: 600;
display: flex; align-items: center; justify-content: center; gap: 12rpx;
width: 100%; line-height: 1;
}
.btn::after { border: none; }
.btn.primary { background: var(--accent); color: #fff; border-color: transparent; }
.btn.ghost { background: transparent; }
.btn:active { opacity: 0.96; }

View File

@@ -0,0 +1,45 @@
// custom-tab-bar — 三 Tab + 中央状态化「遛狗」FABdocs/04 §2
const store = require('../utils/store.js');
Component({
data: {
selected: 1, // 0=记录 1=我的
// walkState: idle | walking | paused —— 决定中央 FAB 颜色与文案
walkState: 'idle',
},
lifetimes: {
attached() {
this.syncWalkState();
},
},
pageLifetimes: {
show() {
this.syncWalkState();
},
},
methods: {
syncWalkState() {
const session = store.getWalkSession();
this.setData({ walkState: session ? session.status : 'idle' });
},
switchTab(e) {
const { path, index } = e.currentTarget.dataset;
this.setData({ selected: index });
wx.switchTab({ url: path });
},
// 中央「遛狗」按钮:进行中→回遛狗页;空闲→开始(单狗直进/多狗弹层)
onCenterTap() {
if (this.data.walkState !== 'idle') {
wx.navigateTo({ url: '/pages/walking/walking' });
return;
}
// TODO 阶段2单狗直接进 walking多狗触发 multi-dog-sheet
wx.navigateTo({ url: '/pages/walking/walking' });
},
},
});

View File

@@ -0,0 +1,3 @@
{
"component": true
}

View File

@@ -0,0 +1,21 @@
<!-- 自定义 tabBar左[记录] 中[遛狗FAB] 右[我的] -->
<view class="tabbar">
<view class="tab {{selected===0?'active':''}}" data-index="0" data-path="/pages/records/records" bindtap="switchTab">
<view class="tab-ico">▤</view>
<text class="tab-txt">记录</text>
</view>
<view class="tab-center">
<view class="fab fab-{{walkState}}" bindtap="onCenterTap">
<text class="fab-ico">{{walkState==='paused' ? '❚❚' : '🐾'}}</text>
</view>
<text class="tab-txt center-txt-{{walkState}}">
{{walkState==='walking' ? '遛狗中' : walkState==='paused' ? '已暂停' : '遛狗'}}
</text>
</view>
<view class="tab {{selected===1?'active':''}}" data-index="1" data-path="/pages/home/home" bindtap="switchTab">
<view class="tab-ico">◉</view>
<text class="tab-txt">我的</text>
</view>
</view>

View File

@@ -0,0 +1,43 @@
.tabbar {
font-family: var(--font);
position: fixed;
left: 0; right: 0; bottom: 0;
height: calc(128rpx + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
background: rgba(251, 248, 243, 0.92);
backdrop-filter: blur(20px);
border-top: 1rpx solid var(--line);
display: flex;
align-items: flex-start;
padding-top: 14rpx;
z-index: 100;
}
.tab, .tab-center {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
}
.tab-ico { font-size: var(--tab-icon-size); color: var(--ink-3); line-height: 1; }
.tab.active .tab-ico { color: var(--accent-deep); }
.tab-txt { font-size: 21rpx; color: var(--ink-3); }
.tab.active .tab-txt { color: var(--accent-deep); }
/* 中央凸起 FAB */
.fab {
width: 108rpx; height: 108rpx;
border-radius: 50%;
background: var(--accent);
display: flex; align-items: center; justify-content: center;
margin-top: -52rpx;
border: 8rpx solid var(--bg);
box-shadow: 0 8rpx 24rpx rgba(200,126,46,0.35);
}
.fab-ico { font-size: var(--fab-icon-size); line-height: 1; }
.fab-walking { background: var(--success); box-shadow: 0 8rpx 24rpx rgba(107,142,90,0.35); }
.fab-paused { background: var(--warn); box-shadow: none; }
.center-txt-walking { color: var(--success); }
.center-txt-paused { color: var(--warn); }
.center-txt-idle { color: var(--ink-2); }

View File

@@ -0,0 +1,60 @@
// pages/achievements/achievements — 7 完整成就页两入口共用docs/05 R10
const { call } = require('../../utils/cloud.js');
const { ACHIEVEMENTS, TOTAL } = require('../../utils/achievements.js');
const { toDateStr } = require('../../utils/format.js');
Page({
data: {
dogId: '',
dogName: '',
unlockedCount: 0,
total: TOTAL,
unlockedList: [],
lockedList: [],
},
onLoad(o) { this.dogId = o.dogId || ''; },
onShow() { if (this.dogId) this.load(); },
async load() {
const res = await call('getDogDetail', { dogId: this.dogId });
if (!res.ok) return;
const dog = res.data.dog;
const stats = dog.stats || { totalWalks: 0, totalDistance: 0, currentStreak: 0 };
const unlockedAtMap = {};
res.data.achievements.unlocked.forEach((u) => { unlockedAtMap[u.achievementKey] = u.unlockedAt; });
const unlockedList = [];
const lockedList = [];
ACHIEVEMENTS.forEach((a) => {
if (unlockedAtMap[a.key] != null) {
unlockedList.push({ key: a.key, name: a.name, desc: a.desc, when: toDateStr(unlockedAtMap[a.key]) });
} else {
lockedList.push({ key: a.key, name: a.name, desc: this.lockedDesc(a, stats) });
}
});
this.setData({
dogName: dog.name,
unlockedCount: unlockedList.length,
total: res.data.achievements.total,
unlockedList,
lockedList,
});
},
// 「还差 X」进度文案
lockedDesc(a, stats) {
const streak = stats.currentStreak || 0;
const dist = stats.totalDistance || 0;
const early = stats.earlyWalks || 0;
switch (a.key) {
case 'week_streak': return `连续遛狗满 7 天 · 还差 ${Math.max(0, 7 - streak)}`;
case 'month_streak': return `连续遛狗满 30 天 · 还差 ${Math.max(0, 30 - streak)}`;
case 'hundred_km': return `累计满 100 公里 · 还差 ${Math.max(0, +(100 - dist).toFixed(1))} km`;
case 'early_bird': return `7 点前遛狗 10 次 · 还差 ${Math.max(0, 10 - early)}`;
default: return a.desc;
}
},
});

View File

@@ -0,0 +1,4 @@
{
"usingComponents": {},
"navigationBarTitleText": "成就"
}

View File

@@ -0,0 +1,29 @@
<view class="page">
<view class="head">
<view class="head-num">{{unlockedCount}}<text class="head-total"> / {{total}}</text></view>
<view class="head-sub">已解锁成就 · {{dogName}}</view>
</view>
<view wx:if="{{unlockedList.length}}" class="section">已解锁</view>
<view class="list">
<view wx:for="{{unlockedList}}" wx:key="key" class="card ach-row">
<view class="medal on">🏅</view>
<view class="ach-body">
<view class="ach-name">{{item.name}}</view>
<view class="ach-desc">{{item.desc}}</view>
</view>
<view class="ach-when">{{item.when}}</view>
</view>
</view>
<view wx:if="{{lockedList.length}}" class="section">未解锁</view>
<view class="list">
<view wx:for="{{lockedList}}" wx:key="key" class="card ach-row locked">
<view class="medal">🔒</view>
<view class="ach-body">
<view class="ach-name">{{item.name}}</view>
<view class="ach-desc">{{item.desc}}</view>
</view>
</view>
</view>
</view>

View File

@@ -0,0 +1,18 @@
.page { min-height: 100vh; padding-bottom: calc(48rpx + env(safe-area-inset-bottom)); }
.head { text-align: center; padding: 32rpx; border-bottom: 1rpx solid var(--line); }
.head-num { font-size: 60rpx; font-weight: 600; font-variant-numeric: tabular-nums; }
.head-total { font-size: 32rpx; color: var(--ink-2); }
.head-sub { font-size: 22rpx; color: var(--ink-2); margin-top: 4rpx; }
.section { font-size: 24rpx; color: var(--ink-2); padding: 24rpx 32rpx 8rpx; }
.list { padding: 0 32rpx; display: flex; flex-direction: column; gap: 16rpx; }
.ach-row { display: flex; align-items: center; gap: 22rpx; padding: 22rpx 24rpx; }
.ach-row.locked { opacity: 0.6; }
.medal { width: 84rpx; height: 84rpx; border-radius: 50%; background: var(--surface-2); display: flex; align-items: center; justify-content: center; font-size: var(--icon-size); line-height: 1; flex-shrink: 0; }
.medal.on { background: var(--accent-soft); }
.ach-body { flex: 1; }
.ach-name { font-size: 26rpx; font-weight: 600; }
.ach-desc { font-size: 22rpx; color: var(--ink-2); margin-top: 4rpx; }
.ach-when { font-size: 22rpx; color: var(--ink-2); }

View File

@@ -0,0 +1,111 @@
// pages/agreement/agreement — 用户协议(登录授权前置法律文本)
// 文本以结构化数据维护,渲染层统一排版。
// ⚠️ 上线前请将下方 OPERATOR / CONTACT 占位符替换为真实运营主体与联系方式。
const OPERATOR = '【请替换为运营主体全称如「XX 科技有限公司」】';
const CONTACT = '【请替换为客服邮箱,如 support@example.com】';
Page({
data: {
title: '汪圈用户协议',
updated: '2026-06-25',
intro: [
`欢迎使用「汪圈」遛狗记录小程序(下称“本小程序”或“本服务”)。本协议是您与本小程序运营者 ${OPERATOR}(下称“我们”)之间就使用本服务所订立的协议。`,
'请您在使用前审慎阅读并充分理解本协议全部条款,尤其是以加粗或下划线标识的免除或限制责任、争议解决与法律适用等条款。您点击“微信一键登录”、勾选同意或实际使用本服务,即表示您已阅读、理解并同意接受本协议及《隐私政策》的全部内容。若您不同意本协议任何内容,请勿登录或使用本服务。',
],
sections: [
{
h: '一、协议的范围与接受',
p: [
'1.1 本协议内容包括协议正文及所有我们已经发布或将来可能发布的各类规则、操作流程、公告或通知。所有规则为本协议不可分割的组成部分,与协议正文具有同等法律效力。',
'1.2 您应当具有完全民事行为能力。若您为无民事行为能力人或限制民事行为能力人(含未满 18 周岁的未成年人),请在监护人陪同下阅读本协议,并在取得监护人同意后使用本服务。',
],
},
{
h: '二、账号与登录',
p: [
'2.1 本服务通过微信授权登录。您理解并同意我们将依据微信开放平台规则获取您的微信用户标识OpenID以创建并识别您的账号。',
'2.2 您的账号与您的微信身份相绑定,仅供您本人使用。您应妥善保管账号及微信登录凭证,因您自身原因导致账号被他人使用而产生的责任由您自行承担。',
'2.3 您承诺登录及使用过程中提交的信息真实、合法、有效,不冒用他人身份或提供虚假资料。',
],
},
{
h: '三、服务内容',
p: [
'3.1 本服务为您提供宠物(犬只)档案建立、遛狗过程记录(计时、距离、狗步换算、便便打卡等)、成果卡生成与分享、成就墙及历史记录查看等功能。',
'3.2 本服务中的距离、轨迹、“狗步”等数据基于设备定位与算法估算,仅供参考与娱乐性记录之用,可能因定位精度、设备状态、网络环境等因素存在误差,不构成任何精确测量或专业结论。',
'3.3 本服务涉及的犬只品种、体型、运动量等内容仅为通用性记录与展示,不构成兽医、医疗、健康或养护方面的专业建议。涉及宠物健康与安全的决策,请咨询专业兽医或机构。',
'3.4 我们有权根据运营需要对服务内容进行增加、调整、升级或下线,并将以适当方式予以公示。',
],
},
{
h: '四、用户行为规范',
p: [
'4.1 您在使用本服务时,应遵守《中华人民共和国民法典》《网络安全法》《个人信息保护法》等法律法规及相关地区的养犬管理规定(如遛狗牵引、清理粪便、办理犬证等)。',
'4.2 您不得利用本服务从事任何违法违规或侵害他人合法权益的行为,包括但不限于:上传违法、侵权、淫秽、暴力或其他不良信息;侵犯他人肖像权、隐私权、知识产权;以非正常手段刷取数据、成就或排名;干扰、破坏本服务正常运行或规避安全机制。',
'4.3 您理解遛狗为线下活动,应自行注意人身、宠物及第三方的安全,遵守交通与公共秩序。因您的遛狗行为引发的人身、财产损害或纠纷,由您自行承担相应责任。',
],
},
{
h: '五、用户内容与授权',
p: [
'5.1 “用户内容”指您通过本服务创建、上传或发布的信息,包括但不限于宠物名称、备注、照片、心情标签等。您保证对所提供的用户内容拥有合法权利。',
'5.2 您理解并同意,为实现数据存储、展示、分享等服务功能,您授予我们一项免费的、非独占的许可,在本服务范围内对用户内容进行存储、使用、复制与展示。该等授权不改变用户内容的归属,您仍享有相应权利。',
'5.3 您通过“生成图片分享”等功能主动对外分享的内容,可能被分享对象进一步传播,相关后果由您自行评估与承担。',
],
},
{
h: '六、知识产权',
p: [
'6.1 本服务所包含的界面设计、文字、图标、商标、软件及其代码等知识产权,除用户内容外,均归我们或相关权利人所有,受法律保护。',
'6.2 未经我们书面许可,您不得擅自复制、修改、传播、出售或以其他方式商业利用上述内容,或对本服务进行反向工程、反编译。',
],
},
{
h: '七、免责声明与责任限制',
p: [
'7.1 本服务按“现状”提供。在法律允许的最大范围内,我们不对服务的及时性、准确性、连续性及无差错作出明示或默示担保。',
'7.2 对于因不可抗力、网络中断、第三方平台(如微信、云服务)故障、设备问题或您的操作不当所导致的服务中断、数据延迟或丢失,我们在已尽合理义务的前提下不承担责任。',
'7.3 在法律允许的范围内,我们就本服务向您承担的责任总额(如有)以您可能遭受的直接损失为限,且不包括任何间接、偶发或后果性损失。',
],
},
{
h: '八、服务的变更、中断与终止',
p: [
'8.1 我们可能因系统维护、升级、调整或法律法规要求等原因,暂停或终止全部或部分服务,并尽量提前通知。',
'8.2 如您违反本协议或法律法规,我们有权视情节限制、暂停或终止向您提供服务。',
'8.3 您可随时停止使用本服务,并可通过本协议载明的方式申请注销账号。账号注销后相关数据的处理见《隐私政策》。',
],
},
{
h: '九、未成年人保护',
p: [
'9.1 我们高度重视未成年人个人信息的保护。若您是未成年人,请在监护人指导下使用本服务,并在监护人同意后提交个人信息。',
'9.2 监护人如对未成年人使用本服务或其个人信息存有疑问,可通过本协议载明的联系方式与我们联系。',
],
},
{
h: '十、协议的变更',
p: [
'10.1 我们可根据法律法规变化及运营需要适时修订本协议,并以小程序内公告、更新生效日期等适当方式通知。',
'10.2 变更后的协议自公示之日起生效。若您不同意变更内容,应停止使用本服务;若您继续使用,则视为接受修订后的协议。',
],
},
{
h: '十一、法律适用与争议解决',
p: [
'11.1 本协议的订立、效力、解释及争议解决,均适用中华人民共和国大陆地区法律(不含冲突法规则)。',
'11.2 因本协议或本服务引起的争议,双方应友好协商解决;协商不成的,任一方可向我们运营者所在地有管辖权的人民法院提起诉讼。',
],
},
{
h: '十二、联系我们',
p: [
`12.1 如您对本协议或本服务有任何问题、意见或投诉,可通过以下方式与我们联系:`,
`运营主体:${OPERATOR}`,
`联系方式:${CONTACT}`,
'我们将在收到您的反馈后尽快核实并处理。',
],
},
],
},
});

View File

@@ -0,0 +1,4 @@
{
"usingComponents": {},
"navigationBarTitleText": "用户协议"
}

View File

@@ -0,0 +1,19 @@
<scroll-view scroll-y class="legal">
<view class="legal-head">
<view class="legal-title">{{title}}</view>
<view class="legal-meta">生效 / 更新日期:{{updated}}</view>
</view>
<view class="legal-intro">
<view class="sec-p" wx:for="{{intro}}" wx:key="*this" wx:for-item="line">{{line}}</view>
</view>
<view class="legal-body">
<block wx:for="{{sections}}" wx:key="h" wx:for-item="sec">
<view class="sec-h">{{sec.h}}</view>
<view class="sec-p" wx:for="{{sec.p}}" wx:key="*this" wx:for-item="line">{{line}}</view>
</block>
</view>
<view class="legal-foot">本文档为通用模板,正式上线前请由运营主体结合实际业务核对并补全占位信息。</view>
</scroll-view>

View File

@@ -0,0 +1,44 @@
/* 法律文本通用排版(用户协议 / 隐私政策共用同一套视觉) */
.legal {
height: 100vh;
padding: 40rpx 48rpx calc(56rpx + env(safe-area-inset-bottom));
}
.legal-head {
padding-bottom: 28rpx;
border-bottom: 2rpx solid var(--line);
margin-bottom: 28rpx;
}
.legal-title {
font-size: 40rpx;
font-weight: 600;
letter-spacing: 1rpx;
}
.legal-meta {
margin-top: 12rpx;
font-size: 22rpx;
color: var(--ink-3);
}
.legal-intro {
margin-bottom: 12rpx;
}
.sec-h {
margin: 36rpx 0 12rpx;
font-size: 30rpx;
font-weight: 600;
color: var(--ink);
}
.sec-p {
margin-top: 12rpx;
font-size: 26rpx;
line-height: 1.8;
color: var(--ink-2);
text-align: justify;
}
.legal-foot {
margin-top: 48rpx;
padding-top: 24rpx;
border-top: 2rpx solid var(--line);
font-size: 22rpx;
color: var(--ink-3);
line-height: 1.7;
}

View File

@@ -0,0 +1,59 @@
// pages/dog-detail/dog-detail — 4 狗狗主页
// 累计只用 次数/公里/连续天数成就墙露灰锁docs/05 R8, R10
const { call } = require('../../utils/cloud.js');
const { ACHIEVEMENTS, TOTAL } = require('../../utils/achievements.js');
const { formatDurationCN, formatDistance, toDateStr } = require('../../utils/format.js');
const GENDER_TEXT = { male: '公', female: '母' };
Page({
data: {
loading: true,
dog: null,
stats: null,
metaText: '',
wall: [],
unlockedCount: 0,
total: TOTAL,
recent: [],
},
onLoad(o) { this.dogId = o.id; },
onShow() { if (this.dogId) this.load(); },
async load() {
const res = await call('getDogDetail', { dogId: this.dogId });
if (!res.ok) return;
const { dog, achievements, recentWalks } = res.data;
const metaParts = [dog.breed];
if (dog.ageText) metaParts.push(dog.ageText);
if (GENDER_TEXT[dog.gender]) metaParts.push(GENDER_TEXT[dog.gender]);
const unlockedKeys = new Set(achievements.unlocked.map((u) => u.achievementKey));
const wall = ACHIEVEMENTS.map((a) => ({
key: a.key, name: a.name, unlocked: unlockedKeys.has(a.key),
}));
const recent = recentWalks.map((w) => ({
_id: w._id,
title: `${toDateStr(w.startAt)}${w.isManual ? ' · 补记' : ''}`,
sub: `${formatDurationCN(w.duration)} · ${formatDistance(w.distance)}` +
(w.dogSteps != null ? ` · ${w.dogSteps}狗步` : ''),
}));
this.setData({
loading: false,
dog,
stats: dog.stats || {},
metaText: metaParts.join(' · '),
wall,
unlockedCount: achievements.unlocked.length,
total: achievements.total,
recent,
});
},
goEdit() { wx.navigateTo({ url: `/pages/dog-edit/dog-edit?id=${this.dogId}` }); },
goAchievements() { wx.navigateTo({ url: `/pages/achievements/achievements?dogId=${this.dogId}` }); },
});

View File

@@ -0,0 +1,4 @@
{
"usingComponents": {},
"navigationBarTitleText": "狗狗主页"
}

View File

@@ -0,0 +1,42 @@
<view wx:if="{{!loading}}" class="page">
<!-- 头部 -->
<view class="header">
<view class="edit" bindtap="goEdit">编辑</view>
<image wx:if="{{dog.avatar}}" class="dog-avatar" src="{{dog.avatar}}" mode="aspectFill" />
<view wx:else class="dog-avatar">🐶</view>
<view class="dog-name">{{dog.name}}</view>
<view class="dog-meta">{{metaText}}</view>
</view>
<!-- 累计:次数/公里/连续 -->
<view class="grid3">
<view class="g"><view class="g-v">{{stats.totalWalks || 0}}</view><view class="g-k">总次数</view></view>
<view class="g"><view class="g-v">{{stats.totalDistance || 0}}</view><view class="g-k">总公里</view></view>
<view class="g"><view class="g-v streak">{{stats.currentStreak || 0}}</view><view class="g-k">连续天数</view></view>
</view>
<!-- 成就墙 -->
<view class="row-head" bindtap="goAchievements">
<text class="rh-title">成就墙</text>
<text class="rh-more">{{unlockedCount}} / {{total}} </text>
</view>
<view class="wall">
<view wx:for="{{wall}}" wx:key="key" class="medal-item {{item.unlocked?'':'locked'}}">
<view class="medal">{{item.unlocked ? '🏅' : '🔒'}}</view>
<view class="medal-name">{{item.name}}</view>
</view>
</view>
<!-- 近期记录 -->
<view class="row-head"><text class="rh-title">近期记录</text></view>
<view wx:if="{{recent.length}}" class="recent">
<view wx:for="{{recent}}" wx:key="_id" class="walk-row">
<view class="wr-ico">🐾</view>
<view class="wr-body">
<view class="wr-title">{{item.title}}</view>
<view class="wr-sub">{{item.sub}}</view>
</view>
</view>
</view>
<view wx:else class="empty">还没有遛狗记录,点底部按钮带它出门吧</view>
</view>

View File

@@ -0,0 +1,33 @@
.page { min-height: 100vh; padding-bottom: calc(180rpx + env(safe-area-inset-bottom)); }
.header { position: relative; text-align: center; padding: 32rpx; border-bottom: 1rpx solid var(--line); }
.edit { position: absolute; top: 28rpx; right: 32rpx; font-size: 26rpx; color: var(--accent-deep); }
.dog-avatar { width: 124rpx; height: 124rpx; border-radius: 50%; background: var(--surface-2); display: flex; align-items: center; justify-content: center; font-size: 60rpx; margin: 0 auto 14rpx; }
.dog-name { font-size: 34rpx; font-weight: 600; }
.dog-meta { font-size: 24rpx; color: var(--ink-2); margin-top: 6rpx; }
.grid3 { display: flex; padding: 28rpx 12rpx; border-bottom: 1rpx solid var(--line); }
.g { flex: 1; text-align: center; }
.g-v { font-size: 40rpx; font-weight: 600; }
.g-v.streak { color: var(--success); }
.g-k { font-size: 22rpx; color: var(--ink-2); margin-top: 4rpx; }
.row-head { display: flex; align-items: center; justify-content: space-between; padding: 28rpx 32rpx 12rpx; }
.rh-title { font-size: 26rpx; font-weight: 600; }
.rh-more { font-size: 22rpx; color: var(--accent-deep); }
.wall { display: flex; flex-wrap: wrap; padding: 0 24rpx 20rpx; border-bottom: 1rpx solid var(--line); }
.medal-item { width: 25%; display: flex; flex-direction: column; align-items: center; gap: 8rpx; padding: 12rpx 0; }
.medal { width: 84rpx; height: 84rpx; border-radius: 50%; background: var(--surface-2); display: flex; align-items: center; justify-content: center; font-size: var(--icon-size); line-height: 1; }
.medal-item.locked .medal { opacity: 0.55; }
.medal-name { font-size: 20rpx; color: var(--ink-2); }
.medal-item.locked .medal-name { color: var(--ink-3); }
.recent { padding: 0 32rpx; display: flex; flex-direction: column; gap: 16rpx; }
.walk-row { display: flex; align-items: center; gap: 18rpx; padding: 22rpx; background: var(--surface-2); border-radius: var(--radius-sm); }
.wr-ico { width: var(--icon-size); font-size: var(--icon-size); line-height: 1; text-align: center; }
.wr-body { flex: 1; }
.wr-title { font-size: 26rpx; }
.wr-sub { font-size: 22rpx; color: var(--ink-2); margin-top: 4rpx; }
.empty { text-align: center; font-size: 24rpx; color: var(--ink-3); padding: 40rpx 32rpx; }

View File

@@ -0,0 +1,130 @@
// pages/dog-edit/dog-edit — 0b 建档/编辑复用docs/05 R2
// 仅名字、犬种必填。未知犬种(中华田园犬/其他)需手动选体型档位。?id= 进编辑模式。
const { call } = require('../../utils/cloud.js');
const store = require('../../utils/store.js');
const { SIZE_LABELS, sizeOfBreed } = require('../../utils/dogStep.js');
const { chooseAndUpload } = require('../../utils/media.js');
const BREEDS = [
'柯基', '比熊', '泰迪', '柴犬', '金毛', '拉布拉多',
'哈士奇', '边境牧羊犬', '萨摩耶', '法斗', '中华田园犬', '其他',
];
const AGE_STAGES = [
{ key: 'puppy', label: '幼犬' },
{ key: 'adult', label: '成犬' },
{ key: 'senior', label: '老年犬' },
];
Page({
data: {
isEdit: false,
dogId: '',
breeds: BREEDS,
ageStages: AGE_STAGES,
sizeLabels: SIZE_LABELS,
breedIndex: -1,
needSize: false, // 未知犬种时需手动选体型
form: {
name: '',
breed: '',
sizeLevel: '',
gender: '',
ageStage: '',
weight: '',
avatar: '',
},
submitting: false,
},
onLoad(options) {
const id = options.id;
if (id) {
const dog = (store.getState().dogs || []).find((d) => d._id === id);
if (dog) {
wx.setNavigationBarTitle({ title: '编辑资料' });
this.setData({
isEdit: true,
dogId: id,
breedIndex: Math.max(0, BREEDS.indexOf(dog.breed)),
needSize: !sizeOfBreed(dog.breed),
form: {
name: dog.name || '',
breed: dog.breed || '',
sizeLevel: dog.sizeLevel || '',
gender: dog.gender || '',
ageStage: dog.ageStage || '',
weight: dog.weight != null ? String(dog.weight) : '',
avatar: dog.avatar || '',
},
});
}
}
},
onNameInput(e) { this.setData({ 'form.name': e.detail.value }); },
onWeightInput(e) { this.setData({ 'form.weight': e.detail.value }); },
async onPickAvatar() {
const fileID = await chooseAndUpload('avatars');
if (fileID) this.setData({ 'form.avatar': fileID });
},
onBreedChange(e) {
const idx = Number(e.detail.value);
const breed = BREEDS[idx];
const mapped = sizeOfBreed(breed);
this.setData({
breedIndex: idx,
'form.breed': breed,
needSize: !mapped, // 未知犬种 → 手动选
'form.sizeLevel': mapped || '',
});
},
onSizeTap(e) { this.setData({ 'form.sizeLevel': e.currentTarget.dataset.v }); },
onGenderTap(e) {
const v = e.currentTarget.dataset.v;
this.setData({ 'form.gender': this.data.form.gender === v ? '' : v });
},
onAgeTap(e) {
const v = e.currentTarget.dataset.v;
this.setData({ 'form.ageStage': this.data.form.ageStage === v ? '' : v });
},
async onSubmit() {
const { form, isEdit, dogId, needSize, submitting } = this.data;
if (submitting) return;
if (!form.name.trim()) return wx.showToast({ title: '请填写名字', icon: 'none' });
if (!form.breed) return wx.showToast({ title: '请选择犬种', icon: 'none' });
if (needSize && !form.sizeLevel) return wx.showToast({ title: '请选择体型档位', icon: 'none' });
const ageText = (AGE_STAGES.find((a) => a.key === form.ageStage) || {}).label || '';
const payload = {
name: form.name.trim(),
breed: form.breed,
sizeLevel: form.sizeLevel || null, // 已知犬种服务端会覆盖
gender: form.gender || null,
ageStage: form.ageStage || null,
ageText,
weight: form.weight ? Number(form.weight) : null,
avatar: form.avatar || '',
};
this.setData({ submitting: true });
const res = await call('dogManage', {
action: isEdit ? 'update' : 'create',
dog: isEdit ? { _id: dogId, ...payload } : payload,
}, { loading: true, loadingText: '保存中' });
this.setData({ submitting: false });
if (!res.ok) return;
const dogs = (store.getState().dogs || []).slice();
const i = dogs.findIndex((d) => d._id === res.data._id);
if (i >= 0) dogs[i] = res.data; else dogs.push(res.data);
store.set({ dogs, currentDogId: res.data._id });
if (isEdit) wx.navigateBack();
else wx.switchTab({ url: '/pages/home/home' });
},
});

View File

@@ -0,0 +1,4 @@
{
"usingComponents": {},
"navigationBarTitleText": "认识你的狗狗"
}

View File

@@ -0,0 +1,70 @@
<view class="page">
<!-- 头像(可选) -->
<view class="avatar-block" bindtap="onPickAvatar">
<image wx:if="{{form.avatar}}" class="avatar" src="{{form.avatar}}" mode="aspectFill" />
<view wx:else class="avatar">🐶</view>
<view class="avatar-tip">点击上传头像(可选)</view>
</view>
<!-- 名字 * -->
<view class="field">
<view class="flabel">名字 <text class="req">*</text></view>
<input class="finput" placeholder="给狗狗起个名字" value="{{form.name}}" bindinput="onNameInput" maxlength="12" />
</view>
<!-- 犬种 * -->
<view class="field">
<view class="flabel">犬种 <text class="req">*</text></view>
<picker mode="selector" range="{{breeds}}" value="{{breedIndex}}" bindchange="onBreedChange">
<view class="fpicker {{form.breed ? '' : 'placeholder'}}">
{{form.breed || '请选择犬种'}}
<text class="chev"></text>
</view>
</picker>
</view>
<!-- 体型档位(未知犬种必选,用于狗步换算) -->
<view wx:if="{{needSize}}" class="field">
<view class="flabel">体型档位 <text class="req">*</text></view>
<view class="chips">
<view wx:for="{{sizeLabels}}" wx:key="key"
class="chip {{form.sizeLevel===item.key?'sel':''}}"
data-v="{{item.key}}" bindtap="onSizeTap">{{item.label}}</view>
</view>
<view class="size-tip">找不到品种时,按腿长/体型选一个,用于估算狗步</view>
</view>
<!-- 性别 -->
<view class="field">
<view class="flabel">性别</view>
<view class="chips">
<view class="chip {{form.gender==='male'?'sel':''}}" data-v="male" bindtap="onGenderTap">公</view>
<view class="chip {{form.gender==='female'?'sel':''}}" data-v="female" bindtap="onGenderTap">母</view>
</view>
</view>
<!-- 年龄(可选) -->
<view class="field">
<view class="flabel">年龄(可选)</view>
<view class="chips">
<view wx:for="{{ageStages}}" wx:key="key"
class="chip {{form.ageStage===item.key?'sel':''}}"
data-v="{{item.key}}" bindtap="onAgeTap">{{item.label}}</view>
</view>
</view>
<!-- 体重(可选) -->
<view class="field last">
<view class="flabel">体重(可选,用于估算狗步)</view>
<view class="weight-row">
<input class="finput" type="digit" placeholder="如 12" value="{{form.weight}}" bindinput="onWeightInput" />
<text class="unit">kg</text>
</view>
</view>
<view class="submit">
<button class="btn primary" bindtap="onSubmit" loading="{{submitting}}">
{{isEdit ? '保存' : '完成,开始遛狗'}}
</button>
</view>
</view>

View File

@@ -0,0 +1,70 @@
.page { min-height: 100vh; padding-bottom: calc(48rpx + env(safe-area-inset-bottom)); }
.avatar-block {
display: flex; flex-direction: column; align-items: center;
padding: 36rpx 0 8rpx;
}
.avatar {
width: 156rpx; height: 156rpx; border-radius: 50%;
background: var(--surface-2);
display: flex; align-items: center; justify-content: center;
font-size: 72rpx;
border: 1rpx solid var(--line);
}
.avatar-tip { margin-top: 16rpx; font-size: 22rpx; color: var(--ink-2); }
.field {
padding: 22rpx 32rpx;
border-bottom: 1rpx solid var(--line);
}
.field.last { border-bottom: none; }
.flabel { font-size: var(--form-label-size); color: var(--ink-2); margin-bottom: 12rpx; }
.req { color: var(--danger); }
.finput {
height: var(--input-height);
min-height: var(--input-height);
font-size: var(--input-font-size);
color: var(--ink);
background: var(--surface-2);
border-radius: var(--input-radius);
padding: var(--input-padding-y) var(--input-padding-x);
line-height: 1.4;
}
.fpicker {
min-height: var(--input-height);
font-size: var(--input-font-size);
color: var(--ink);
background: var(--surface-2);
border-radius: var(--input-radius);
padding: var(--input-padding-y) var(--input-padding-x);
line-height: 1.4;
display: flex; align-items: center; justify-content: space-between;
}
.fpicker.placeholder { color: var(--ink-3); }
.fpicker .chev { color: var(--ink-3); font-size: var(--icon-size-sm); line-height: 1; }
.chips { display: flex; flex-wrap: wrap; gap: 16rpx; }
.chip {
font-size: var(--chip-font-size);
padding: var(--chip-padding-y) var(--chip-padding-x);
border-radius: var(--radius-pill);
border: 1rpx solid var(--line-2);
background: var(--surface-2);
color: var(--ink);
line-height: 1.4;
}
.chip.sel {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent-deep);
font-weight: 600;
}
.size-tip { font-size: 21rpx; color: var(--ink-3); margin-top: 12rpx; }
.weight-row { display: flex; align-items: center; gap: 16rpx; }
.weight-row .finput { flex: 1; }
.weight-row .unit { font-size: var(--input-font-size); color: var(--ink-2); }
.submit { padding: 40rpx 32rpx 0; }

View File

@@ -0,0 +1,43 @@
// pages/history/history — 5 遛狗历史 + 手动补记
const { call } = require('../../utils/cloud.js');
const store = require('../../utils/store.js');
const { formatDurationCN, formatDistance, toDateStr } = require('../../utils/format.js');
Page({
data: { groups: [], dogs: [] },
onShow() { this.load(); },
async load() {
const dogs = store.getState().dogs || [];
this.setData({ dogs });
const res = await call('getHistory', {});
if (!res.ok) return;
this.setData({ groups: this.groupByWeek(res.data.walks) });
},
// 本周 / 更早
groupByWeek(walks) {
const now = new Date();
const dow = now.getDay() || 7; // 周一=1
const weekStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - (dow - 1)).getTime();
const thisWeek = [];
const earlier = [];
walks.forEach((w) => {
const item = {
_id: w._id,
title: `${toDateStr(w.startAt)} · ${(w.dogNames || []).join('、')}`,
sub: `${formatDurationCN(w.duration)} · ${formatDistance(w.distance)}`,
isManual: w.isManual,
};
(w.startAt >= weekStart ? thisWeek : earlier).push(item);
});
const groups = [];
if (thisWeek.length) groups.push({ label: '本周', items: thisWeek });
if (earlier.length) groups.push({ label: '更早', items: earlier });
return groups;
},
openAdd() { this.selectComponent('#addSheet').open(); },
onSaved() { this.load(); },
});

View File

@@ -0,0 +1,6 @@
{
"usingComponents": {
"manual-add-sheet": "/components/manual-add-sheet/index"
},
"navigationBarTitleText": "遛狗历史"
}

View File

@@ -0,0 +1,23 @@
<view class="page">
<view class="top">
<text class="top-title">全部记录</text>
<text class="add-btn" bindtap="openAdd"> 补记</text>
</view>
<block wx:for="{{groups}}" wx:key="label">
<view class="group-label">{{item.label}}</view>
<view class="rows">
<view wx:for="{{item.items}}" wx:for-item="w" wx:key="_id" class="walk-row {{w.isManual?'manual':''}}">
<view class="wr-ico">{{w.isManual ? '✎' : '🐾'}}</view>
<view class="wr-body">
<view class="wr-title">{{w.title}}<text wx:if="{{w.isManual}}" class="tag">补记</text></view>
<view class="wr-sub">{{w.sub}}</view>
</view>
</view>
</view>
</block>
<view wx:if="{{!groups.length}}" class="empty">还没有遛狗记录</view>
<manual-add-sheet id="addSheet" dogs="{{dogs}}" bind:saved="onSaved" />
</view>

View File

@@ -0,0 +1,19 @@
.page { min-height: 100vh; padding: 24rpx 32rpx calc(48rpx + env(safe-area-inset-bottom)); }
.top { display: flex; align-items: center; justify-content: space-between; padding: 12rpx 0 20rpx; }
.top-title { font-size: 28rpx; font-weight: 600; }
.add-btn { font-size: 24rpx; color: var(--accent-deep); }
.group-label { font-size: 22rpx; color: var(--ink-2); padding: 16rpx 4rpx 10rpx; }
.rows { display: flex; flex-direction: column; gap: 16rpx; }
.walk-row { display: flex; align-items: center; gap: 18rpx; padding: 22rpx; background: var(--surface-2); border-radius: var(--radius-sm); }
.walk-row.manual { background: transparent; border: 1rpx dashed var(--line-2); }
.wr-ico { width: var(--icon-size); font-size: var(--icon-size); line-height: 1; text-align: center; }
.walk-row.manual .wr-ico { color: var(--accent-deep); }
.wr-body { flex: 1; }
.wr-title { font-size: 26rpx; }
.wr-sub { font-size: 22rpx; color: var(--ink-2); margin-top: 4rpx; }
.tag { font-size: 20rpx; color: var(--ink-2); background: var(--surface-2); border: 1rpx solid var(--line-2); border-radius: var(--radius-pill); padding: 2rpx 12rpx; margin-left: 12rpx; }
.empty { text-align: center; font-size: 24rpx; color: var(--ink-3); padding: 80rpx 0; }

View File

@@ -0,0 +1,58 @@
// pages/home/home — 1 我的Tab启动默认页
const { call } = require('../../utils/cloud.js');
const store = require('../../utils/store.js');
Page({
data: {
userInfo: null,
joinDays: 0,
dogs: [],
},
onShow() {
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 1, walkState: (store.getWalkSession() || {}).status || 'idle' });
}
this.ensureUser();
this.loadDogs();
},
// 兜底:若直接进首页(未经登录页)补一次 login 拿 userInfo
async ensureUser() {
let userInfo = store.getState().userInfo;
if (!userInfo) {
const res = await call('login');
if (res.ok) {
userInfo = res.data.userInfo;
store.set({ userInfo });
}
}
if (userInfo) {
const joinDays = Math.max(0, Math.floor((Date.now() - userInfo.joinedAt) / 86400000));
this.setData({ userInfo, joinDays });
}
},
async loadDogs() {
const res = await call('dogManage', { action: 'list' });
if (res.ok) {
store.set({ dogs: res.data });
this.setData({ dogs: res.data });
}
},
goDogDetail(e) {
const id = e.currentTarget.dataset.id;
wx.navigateTo({ url: `/pages/dog-detail/dog-detail?id=${id}` });
},
goAddDog() {
wx.navigateTo({ url: '/pages/dog-edit/dog-edit' });
},
goHistory() {
wx.navigateTo({ url: '/pages/history/history' });
},
goAchievements() {
const id = this.data.dogs[0] && this.data.dogs[0]._id;
wx.navigateTo({ url: `/pages/achievements/achievements${id ? '?dogId=' + id : ''}` });
},
});

View File

@@ -0,0 +1,4 @@
{
"usingComponents": {},
"navigationBarTitleText": "我的"
}

View File

@@ -0,0 +1,37 @@
<view class="page">
<!-- 用户卡 -->
<view class="user-row">
<view class="user-avatar">😀</view>
<view class="user-meta">
<view class="user-name">{{userInfo.nickname || '铲屎官'}}</view>
<view class="user-sub">遛狗 {{userInfo.walkCount || 0}} 次 · 加入 {{joinDays}} 天</view>
</view>
<view class="chev"></view>
</view>
<!-- 我的狗狗 -->
<view class="section-title">我的狗狗</view>
<view class="dogs">
<view wx:for="{{dogs}}" wx:key="_id" class="card dog-card" data-id="{{item._id}}" bindtap="goDogDetail">
<image wx:if="{{item.avatar}}" class="dog-avatar" src="{{item.avatar}}" mode="aspectFill" />
<view wx:else class="dog-avatar">🐶</view>
<view class="dog-meta">
<view class="dog-name">{{item.name}}</view>
<view class="dog-sub">
<text wx:if="{{item.stats.currentStreak > 0}}" class="streak">● 连续 {{item.stats.currentStreak}} 天</text>
<text wx:if="{{item.stats.currentStreak > 0}}"> · </text>{{item.breed}}
</view>
</view>
<view class="chev"></view>
</view>
<view class="add-dog" bindtap="goAddDog"> 添加狗狗</view>
</view>
<!-- 功能入口 -->
<view class="list">
<view class="listrow" bindtap="goHistory"><text class="lbl">遛狗历史</text><text class="chev"></text></view>
<view class="listrow" bindtap="goAchievements"><text class="lbl">成就</text><text class="chev"></text></view>
<view class="listrow"><text class="lbl">设置</text><text class="chev"></text></view>
</view>
</view>

View File

@@ -0,0 +1,52 @@
.page { min-height: 100vh; padding-bottom: calc(180rpx + env(safe-area-inset-bottom)); }
/* 用户卡 */
.user-row {
display: flex; align-items: center; gap: 24rpx;
padding: 32rpx;
border-bottom: 1rpx solid var(--line);
}
.user-avatar {
width: 100rpx; height: 100rpx; border-radius: 50%;
background: var(--surface-2);
display: flex; align-items: center; justify-content: center;
font-size: 52rpx;
}
.user-meta { flex: 1; }
.user-name { font-size: 30rpx; font-weight: 600; }
.user-sub { margin-top: 6rpx; font-size: 22rpx; color: var(--ink-2); }
.section-title { font-size: 24rpx; color: var(--ink-2); padding: 28rpx 32rpx 12rpx; }
/* 狗狗卡 */
.dogs { padding: 0 32rpx; }
.dog-card { display: flex; align-items: center; gap: 24rpx; padding: 24rpx; }
.dog-avatar {
width: 88rpx; height: 88rpx; border-radius: 50%;
background: var(--surface-2);
display: flex; align-items: center; justify-content: center;
font-size: 44rpx;
}
.dog-meta { flex: 1; }
.dog-name { font-size: 28rpx; font-weight: 600; }
.dog-sub { margin-top: 6rpx; font-size: 22rpx; color: var(--ink-2); }
.dog-sub .streak { color: var(--success); }
.add-dog {
margin-top: 20rpx;
border: 1rpx dashed var(--line-2);
border-radius: var(--radius);
padding: 24rpx;
text-align: center;
font-size: 26rpx; color: var(--ink-2);
}
/* 功能入口 */
.list { margin-top: 24rpx; border-top: 1rpx solid var(--line); }
.listrow {
display: flex; align-items: center;
padding: 28rpx 32rpx;
border-bottom: 1rpx solid var(--line);
}
.listrow .lbl { flex: 1; font-size: 28rpx; }
.chev { color: var(--ink-3); width: var(--icon-size); font-size: var(--icon-size); line-height: 1; text-align: center; }

View File

@@ -0,0 +1,59 @@
// pages/login/login — 0a 微信授权登录
// 登录后:无狗 → 建档(0b);有狗 → 首页(1)docs/05 R1
// 持久化登录:老用户曾登录过则自动静默登录,无需每次手动点击。
const { call } = require('../../utils/cloud.js');
const store = require('../../utils/store.js');
const LOGGED_FLAG = 'hasLoggedIn'; // 本地标记:是否已完成过一次登录授权
Page({
data: { loading: false, autoLogging: false },
onLoad() {
// 已登录过的用户:跳过手动点击,静默登录后直接进入
if (wx.getStorageSync(LOGGED_FLAG)) {
this.autoLogin();
}
},
// 静默登录(老用户),失败则回退到手动登录按钮
async autoLogin() {
this.setData({ autoLogging: true });
const res = await call('login');
if (res.ok) {
this.handleLoggedIn(res.data);
} else {
this.setData({ autoLogging: false });
}
},
// 手动登录(点击「微信一键登录」=同意用户协议与隐私政策)
async onLogin() {
if (this.data.loading) return;
this.setData({ loading: true });
const res = await call('login', {}, { loading: true, loadingText: '登录中' });
this.setData({ loading: false });
if (!res.ok) return;
wx.setStorageSync(LOGGED_FLAG, true); // 记住已登录,下次自动登录
this.handleLoggedIn(res.data);
},
// 登录成功后的统一跳转:有狗进首页,无狗去建档
handleLoggedIn({ userInfo, hasDog }) {
store.set({ userInfo });
if (hasDog) {
wx.switchTab({ url: '/pages/home/home' });
} else {
wx.redirectTo({ url: '/pages/dog-edit/dog-edit' });
}
},
openAgreement() {
wx.navigateTo({ url: '/pages/agreement/agreement' });
},
openPrivacy() {
wx.navigateTo({ url: '/pages/privacy/privacy' });
},
});

View File

@@ -0,0 +1,4 @@
{
"usingComponents": {},
"navigationBarTitleText": "汪圈"
}

View File

@@ -0,0 +1,22 @@
<view class="login">
<view class="hero">
<view class="logo">🐾</view>
<view class="brand">汪圈</view>
<view class="slogan">每一次遛狗,都值得被记录</view>
</view>
<view class="actions">
<block wx:if="{{autoLogging}}">
<view class="auto-tip">正在登录…</view>
</block>
<block wx:else>
<button class="btn primary" bindtap="onLogin" loading="{{loading}}">微信一键登录</button>
<view class="agreement">
登录即代表同意
<text class="link" bindtap="openAgreement">《用户协议》</text>
<text class="link" bindtap="openPrivacy">《隐私政策》</text>
</view>
</block>
</view>
</view>

View File

@@ -0,0 +1,47 @@
.login {
min-height: 100vh;
display: flex;
flex-direction: column;
padding: 0 56rpx calc(64rpx + env(safe-area-inset-bottom));
}
.hero {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.logo {
width: 176rpx; height: 176rpx;
border-radius: 52rpx;
background: var(--accent);
display: flex; align-items: center; justify-content: center;
font-size: 84rpx;
box-shadow: 0 12rpx 36rpx rgba(200,126,46,0.30);
}
.brand {
margin-top: 40rpx;
font-size: 44rpx; font-weight: 600; letter-spacing: 2rpx;
}
.slogan {
margin-top: 12rpx;
font-size: 26rpx; color: var(--ink-2);
}
.actions { flex-shrink: 0; }
.agreement {
margin-top: 28rpx;
text-align: center;
font-size: 22rpx; color: var(--ink-3);
line-height: 1.6;
}
.agreement .link {
color: var(--accent-deep);
padding: 6rpx 0;
}
.agreement .link:active { opacity: 0.6; }
.auto-tip {
text-align: center;
font-size: 26rpx;
color: var(--ink-3);
padding: 28rpx 0;
}

View File

@@ -0,0 +1,105 @@
// pages/privacy/privacy — 隐私政策(依据《个人信息保护法》等编写)
// 文本以结构化数据维护,渲染层与用户协议共用排版。
// ⚠️ 上线前请将 OPERATOR / CONTACT 占位符替换为真实运营主体与联系方式,
// 并核对“第三方/SDK 清单”是否与实际接入一致。
const OPERATOR = '【请替换为运营主体全称如「XX 科技有限公司」】';
const CONTACT = '【请替换为客服邮箱,如 support@example.com】';
Page({
data: {
title: '汪圈隐私政策',
updated: '2026-06-25',
intro: [
`${OPERATOR}(下称“我们”)作为「汪圈」遛狗记录小程序的运营者,深知个人信息对您的重要性,并会尽力保护您的个人信息安全可靠。我们致力于按照《中华人民共和国个人信息保护法》《网络安全法》《数据安全法》及微信小程序相关规范处理您的个人信息。`,
'本政策旨在帮助您了解:我们如何收集、使用、存储、共享和保护您的个人信息,以及您如何行使相关权利。请您在使用本服务前,仔细阅读并理解本政策,尤其是加粗内容。您点击“微信一键登录”或开始使用本服务,即表示您已充分理解并同意本政策。',
],
sections: [
{
h: '一、我们如何收集和使用您的个人信息',
p: [
'我们仅会出于本政策所述目的、遵循合法、正当、必要与诚信原则收集和使用您的个人信息。具体场景如下:',
'1. 登录与账号创建当您使用微信授权登录时我们会收集您的微信用户标识OpenID用于创建并识别您的账号若您主动授权还可能收集您的微信昵称、头像用于个人主页展示。OpenID 属于实现登录的必要信息,缺少该信息将无法为您提供服务。',
'2. 宠物建档:当您创建犬只档案时,我们会收集您填写的宠物名称、犬种、体型、性别、年龄、体重及您上传的宠物照片,用于生成与展示宠物主页、计算“狗步”等记录功能。其中仅名称与犬种为必填,其余均可选填。',
'3. 遛狗记录与定位当您开始遛狗并授权位置权限scope.userLocation我们会在遛狗过程中收集您的地理位置信息用于计算遛狗距离、轨迹里程与“狗步”。位置权限为可选授权您可拒绝拒绝后我们将仅记录时长不收集位置、不计算距离。我们不会在您未开始遛狗时收集位置。',
'4. 记录补充信息:在生成成果卡或手动补记时,您可选择性提供天气、心情标签、文字备注及照片,用于丰富您的遛狗记录。这些信息均为选填。',
'5. 设备与日志信息:为保障服务安全与正常运行,我们可能收集必要的设备信息(如操作系统类型、基础库版本)及服务日志。',
'我们不会收集与本服务无关的个人信息,也不会收集您的身份证件、银行账户等敏感金融信息。除位置、相册照片外,本服务不涉及其他个人敏感信息的处理。',
],
},
{
h: '二、设备权限的调用',
p: [
'在您使用相应功能时,我们会向您申请以下设备权限,您均可选择拒绝或在系统设置中随时关闭:',
'· 位置权限(用于遛狗时记录距离与轨迹);',
'· 相册 / 摄像头权限(用于上传宠物头像或遛狗照片)。',
'权限关闭后,相应功能将不可用或降级,但不影响您使用其他功能。',
],
},
{
h: '三、我们如何使用本地存储',
p: [
'为提升体验,我们会在您的设备本地缓存少量数据,例如登录状态标识、未结束的遛狗会话等,以便在切换后台或重启后恢复进度、减少重复登录。您可通过微信“清理小程序缓存”清除此类数据。',
],
},
{
h: '四、我们如何共享、转让与公开披露',
p: [
'我们不会向任何第三方出售您的个人信息。仅在以下情形下,您的信息可能被共享:',
'1. 实现服务所必需的第三方服务本服务基于微信小程序平台及腾讯云开发CloudBase提供您的账号与业务数据存储于腾讯云开发为本小程序分配的云环境中。相关第三方在为实现本服务目的所必需的范围内处理数据并受其自身隐私政策与协议约束。',
'2. 法律要求:在符合法律法规规定,或应行政、司法机关等有权机关依法要求时,我们可能披露您的个人信息。',
'3. 经您单独同意:在获得您明确同意后,我们方会与其他方共享您的个人信息。',
'除上述情形外,我们不会主动转让或公开披露您的个人信息;确需转让的,我们将告知接收方的名称及处理目的,并征得您的同意。',
],
},
{
h: '五、第三方服务清单',
p: [
'为实现核心功能,本服务接入以下第三方服务:',
'· 微信开放平台 / 微信小程序提供登录鉴权OpenID、运行环境与分享能力由腾讯公司提供。',
'· 腾讯云开发 CloudBase提供云数据库、云函数与云存储用于保存您的账号、宠物档案、遛狗记录与照片。',
'上述服务的数据处理受其各自的隐私政策约束,建议您一并阅读。',
],
},
{
h: '六、我们如何存储与保护您的信息',
p: [
'1. 存储地点与期限:您的个人信息存储于中华人民共和国境内的腾讯云开发环境。我们仅在实现本政策所述目的所必需的期限内保留您的信息;超出保留期限或您注销账号后,我们将依法删除或匿名化处理,但法律法规另有规定的除外。',
'2. 安全措施:我们采用“仅创建者可读写”的数据库访问规则、云函数服务端校验、传输加密等技术与管理措施,防止信息未经授权的访问、泄露、篡改或丢失。',
'3. 安全事件处置:如不幸发生个人信息安全事件,我们将按法律要求及时启动应急预案,并以适当方式告知您事件情况及应对建议。',
],
},
{
h: '七、您的权利',
p: [
'按照相关法律法规,您对自己的个人信息享有以下权利:',
'· 查阅与复制:您可在小程序内查看您的账号信息、宠物档案与遛狗记录。',
'· 更正与补充:您可对宠物档案等信息进行编辑更正。',
'· 删除:您可删除您创建的宠物档案、遛狗记录等内容;在符合法定情形时,可要求我们删除相应个人信息。',
'· 撤回同意:您可通过关闭系统权限(如位置、相册)撤回相应授权。',
'· 注销账号:您可通过本政策载明的联系方式申请注销账号;注销后,我们将删除或匿名化您的个人信息,但法律法规另有要求的除外。',
'如您需行使上述权利或对个人信息处理存有疑问,可通过第九条所列方式与我们联系。',
],
},
{
h: '八、未成年人信息保护',
p: [
'本服务主要面向成年宠物饲养者。若您是未满 18 周岁的未成年人,请在监护人的同意与指导下使用本服务并提供个人信息。我们不会在未取得监护人同意的情况下,主动收集未成年人的个人信息。监护人如发现相关情形,可联系我们删除。',
],
},
{
h: '九、如何联系我们',
p: [
'如您对本政策内容或个人信息处理有任何疑问、意见或投诉,可通过以下方式与我们联系,我们将在十五个工作日内予以答复:',
`运营主体:${OPERATOR}`,
`联系方式:${CONTACT}`,
],
},
{
h: '十、本政策的更新',
p: [
'我们可能适时更新本政策。当本政策发生重大变更时,我们将通过小程序内公告、更新生效日期等方式提示您。变更后您继续使用本服务的,即表示您同意受更新后的政策约束。',
],
},
],
},
});

View File

@@ -0,0 +1,4 @@
{
"usingComponents": {},
"navigationBarTitleText": "隐私政策"
}

View File

@@ -0,0 +1,19 @@
<scroll-view scroll-y class="legal">
<view class="legal-head">
<view class="legal-title">{{title}}</view>
<view class="legal-meta">生效 / 更新日期:{{updated}}</view>
</view>
<view class="legal-intro">
<view class="sec-p" wx:for="{{intro}}" wx:key="*this" wx:for-item="line">{{line}}</view>
</view>
<view class="legal-body">
<block wx:for="{{sections}}" wx:key="h" wx:for-item="sec">
<view class="sec-h">{{sec.h}}</view>
<view class="sec-p" wx:for="{{sec.p}}" wx:key="*this" wx:for-item="line">{{line}}</view>
</block>
</view>
<view class="legal-foot">本文档为通用模板,正式上线前请由运营主体结合实际业务核对并补全占位信息。</view>
</scroll-view>

View File

@@ -0,0 +1 @@
@import "../agreement/agreement.wxss";

View File

@@ -0,0 +1,62 @@
// pages/records/records — 6/6b 记录Tab含空状态
const { call } = require('../../utils/cloud.js');
const store = require('../../utils/store.js');
const { ACHIEVEMENTS, TOTAL } = require('../../utils/achievements.js');
const { formatDurationCN, formatDistance, toDateStr } = require('../../utils/format.js');
Page({
data: {
isEmpty: false,
dogName: '豆豆',
primaryDogId: '',
wall: [],
unlockedCount: 0,
total: TOTAL,
walks: [],
dogs: [],
},
onShow() {
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 0, walkState: (store.getWalkSession() || {}).status || 'idle' });
}
this.load();
},
async load() {
const dogs = store.getState().dogs || [];
const primaryDogId = store.getState().currentDogId || (dogs[0] && dogs[0]._id) || '';
this.setData({ dogs, primaryDogId, dogName: (dogs[0] && dogs[0].name) || '狗狗' });
// 历史
const hist = await call('getHistory', {});
const walks = (hist.ok ? hist.data.walks : []).slice(0, 8).map((w) => ({
_id: w._id,
title: `${toDateStr(w.startAt)} · ${(w.dogNames || []).join('、')}`,
sub: `${formatDurationCN(w.duration)} · ${formatDistance(w.distance)}`,
isManual: w.isManual,
}));
// 成就概览(取前 4基于主狗解锁状态
let wall = ACHIEVEMENTS.slice(0, 4).map((a) => ({ key: a.key, name: a.name, unlocked: false }));
let unlockedCount = 0;
if (primaryDogId) {
const dd = await call('getDogDetail', { dogId: primaryDogId });
if (dd.ok) {
const keys = new Set(dd.data.achievements.unlocked.map((u) => u.achievementKey));
unlockedCount = dd.data.achievements.unlocked.length;
wall = ACHIEVEMENTS.slice(0, 4).map((a) => ({ key: a.key, name: a.name, unlocked: keys.has(a.key) }));
}
}
this.setData({ isEmpty: walks.length === 0, walks, wall, unlockedCount });
},
goAch() {
const q = this.data.primaryDogId ? `?dogId=${this.data.primaryDogId}` : '';
wx.navigateTo({ url: `/pages/achievements/achievements${q}` });
},
startWalk() { wx.navigateTo({ url: '/pages/walking/walking' }); },
openAdd() { this.selectComponent('#addSheet').open(); },
onSaved() { this.load(); },
});

View File

@@ -0,0 +1,6 @@
{
"usingComponents": {
"manual-add-sheet": "/components/manual-add-sheet/index"
},
"navigationBarTitleText": "记录"
}

View File

@@ -0,0 +1,48 @@
<view class="page">
<!-- ===== 空状态 6b ===== -->
<block wx:if="{{isEmpty}}">
<view class="empty-hero">
<view class="paw">🐾</view>
<view class="empty-title">{{dogName}}已经在门口等了</view>
<view class="empty-sub">第一次遛狗,从下面那个橙色按钮开始。走完一程,这里就有它的第一条记录啦。</view>
<button class="btn primary start-btn" bindtap="startWalk"><text class="btn-ico">🐾</text>带{{dogName}}出门</button>
</view>
<view class="row-head"><text class="rh-title">成就墙</text><text class="rh-more">0 / {{total}} 待解锁</text></view>
<view class="wall">
<view wx:for="{{wall}}" wx:key="key" class="medal-item locked">
<view class="medal">🔒</view>
<view class="medal-name">{{item.name}}</view>
</view>
</view>
</block>
<!-- ===== 正常态 6 ===== -->
<block wx:else>
<view class="row-head" bindtap="goAch">
<text class="rh-title">成就墙</text>
<text class="rh-more">全部 {{unlockedCount}}/{{total}} </text>
</view>
<view class="wall" bindtap="goAch">
<view wx:for="{{wall}}" wx:key="key" class="medal-item {{item.unlocked?'':'locked'}}">
<view class="medal">{{item.unlocked ? '🏅' : '🔒'}}</view>
<view class="medal-name">{{item.name}}</view>
</view>
</view>
<view class="row-head bordered">
<text class="rh-title">遛狗历史</text>
<text class="rh-more" catchtap="openAdd"> 补记</text>
</view>
<view class="rows">
<view wx:for="{{walks}}" wx:key="_id" class="walk-row {{item.isManual?'manual':''}}">
<view class="wr-ico">{{item.isManual ? '✎' : '🐾'}}</view>
<view class="wr-body">
<view class="wr-title">{{item.title}}<text wx:if="{{item.isManual}}" class="tag">补记</text></view>
<view class="wr-sub">{{item.sub}}</view>
</view>
</view>
</view>
</block>
<manual-add-sheet id="addSheet" dogs="{{dogs}}" bind:saved="onSaved" />
</view>

View File

@@ -0,0 +1,33 @@
.page { min-height: 100vh; padding: 24rpx 32rpx calc(180rpx + env(safe-area-inset-bottom)); }
/* 空状态 */
.empty-hero { text-align: center; padding: 56rpx 40rpx 40rpx; display: flex; flex-direction: column; align-items: center; }
.paw { width: 168rpx; height: 168rpx; border-radius: 50%; background: var(--accent-soft); display: flex; align-items: center; justify-content: center; font-size: 80rpx; line-height: 1; margin-bottom: 28rpx; }
.empty-title { font-size: 32rpx; font-weight: 600; margin-bottom: 14rpx; }
.empty-sub { font-size: 26rpx; color: var(--ink-2); line-height: 1.6; max-width: 440rpx; }
.start-btn { width: auto; padding: 0 48rpx; margin-top: 36rpx; }
/* 区块标题 */
.row-head { display: flex; align-items: center; justify-content: space-between; padding: 24rpx 4rpx 14rpx; }
.row-head.bordered { border-top: 1rpx solid var(--line); margin-top: 16rpx; }
.rh-title { font-size: 26rpx; font-weight: 600; }
.rh-more { font-size: 22rpx; color: var(--accent-deep); }
/* 成就墙 */
.wall { display: flex; flex-wrap: wrap; padding-bottom: 8rpx; }
.medal-item { width: 25%; display: flex; flex-direction: column; align-items: center; gap: 8rpx; padding: 12rpx 0; }
.medal { width: 84rpx; height: 84rpx; border-radius: 50%; background: var(--surface-2); display: flex; align-items: center; justify-content: center; font-size: var(--icon-size); line-height: 1; }
.medal-item.locked .medal { opacity: 0.55; }
.medal-name { font-size: 20rpx; color: var(--ink-2); }
.medal-item.locked .medal-name { color: var(--ink-3); }
/* 历史 */
.rows { display: flex; flex-direction: column; gap: 16rpx; }
.walk-row { display: flex; align-items: center; gap: 18rpx; padding: 22rpx; background: var(--surface-2); border-radius: var(--radius-sm); }
.walk-row.manual { background: transparent; border: 1rpx dashed var(--line-2); }
.wr-ico { width: var(--icon-size); font-size: var(--icon-size); line-height: 1; text-align: center; }
.walk-row.manual .wr-ico { color: var(--accent-deep); }
.wr-body { flex: 1; }
.wr-title { font-size: 26rpx; }
.wr-sub { font-size: 22rpx; color: var(--ink-2); margin-top: 4rpx; }
.tag { font-size: 20rpx; color: var(--ink-2); background: var(--surface-2); border: 1rpx solid var(--line-2); border-radius: var(--radius-pill); padding: 2rpx 12rpx; margin-left: 12rpx; }

View File

@@ -0,0 +1,198 @@
// pages/summary/summary — 3 成果卡
// 「狗狗走了」视角补充字段全选填R7Canvas 生成分享图;订阅提醒
const { call } = require('../../utils/cloud.js');
const store = require('../../utils/store.js');
const { formatDuration } = require('../../utils/format.js');
const { chooseAndUpload } = require('../../utils/media.js');
const { SUBSCRIBE_TEMPLATE_ID } = require('../../utils/config.js');
Page({
data: {
loaded: false,
walkId: '',
primaryDogId: '',
dogName: '',
dogAvatar: '',
headline: '',
bigValue: '',
bigUnit: '',
timeText: '',
distanceText: '—',
streak: 0,
newAchievements: [],
weathers: [{ k: 'sunny', l: '☀️ 晴' }, { k: 'cloudy', l: '☁️ 阴' }, { k: 'rainy', l: '🌧️ 雨' }],
moods: [{ k: 'happy', l: '遛得开心' }, { k: 'tired', l: '有点累' }, { k: 'excited', l: '很兴奋' }],
weather: '',
mood: '',
note: '',
photos: [],
submitting: false,
},
_attached: false,
onLoad() {
const sum = getApp().globalData.lastSummary;
if (!sum) {
wx.showToast({ title: '数据缺失', icon: 'none' });
setTimeout(() => wx.switchTab({ url: '/pages/home/home' }), 1000);
return;
}
const r = (sum.results && sum.results[0]) || {};
const hasSteps = r.dogSteps != null;
const dog = (store.getState().dogs || []).find((d) => d._id === r.dogId);
this.setData({
loaded: true,
walkId: sum.walkId,
primaryDogId: r.dogId || '',
dogName: r.name || '狗狗',
dogAvatar: (dog && dog.avatar) || '',
headline: hasSteps ? `${r.name}今天走了` : `${r.name}今天遛了`,
bigValue: hasSteps ? String(r.dogSteps) : formatDuration(r.duration || 0),
bigUnit: hasSteps ? '狗步' : '',
timeText: formatDuration(r.duration || 0),
distanceText: r.distance != null ? Number(r.distance).toFixed(1) : '—',
streak: r.streak || 0,
newAchievements: sum.newAchievements || [],
});
},
pickWeather(e) { const k = e.currentTarget.dataset.k; this.setData({ weather: this.data.weather === k ? '' : k }); },
pickMood(e) { const k = e.currentTarget.dataset.k; this.setData({ mood: this.data.mood === k ? '' : k }); },
onNote(e) { this.setData({ note: e.detail.value }); },
async onPickPhoto() {
const fileID = await chooseAndUpload('walk-photos');
if (fileID) this.setData({ photos: [fileID] });
},
// 选填字段回写(只回写一次;含雨天战士成就判定)
async ensureAttached() {
if (this._attached) return;
const { weather, mood, note, photos, walkId } = this.data;
// 无任何选填内容时直接返回,但不锁定:用户可能稍后再补填,待真正回写后才置位
if (!weather && !mood && !note && !photos.length) return;
const res = await call('walkSave', {
action: 'attach', walkId,
weather: weather || null, mood: mood || null, note: note || null, photos,
});
this._attached = true;
if (res && res.ok && res.data.newAchievements && res.data.newAchievements.length) {
wx.showToast({ title: `解锁 ${res.data.newAchievements[0].name}`, icon: 'none' });
}
},
// ===== Canvas 生成分享图 =====
drawShareCard() {
return new Promise((resolve) => {
const q = wx.createSelectorQuery();
q.select('#shareCanvas').fields({ node: true, size: true }).exec((res) => {
if (!res[0] || !res[0].node) return resolve(null);
const canvas = res[0].node;
const ctx = canvas.getContext('2d');
const dpr = (wx.getWindowInfo && wx.getWindowInfo().pixelRatio) || 2;
const W = 600, H = 820;
canvas.width = W * dpr;
canvas.height = H * dpr;
ctx.scale(dpr, dpr);
// 背景
ctx.fillStyle = '#FBF8F3';
ctx.fillRect(0, 0, W, H);
// 顶部色带
ctx.fillStyle = '#E8A04B';
ctx.fillRect(0, 0, W, 12);
// 头像圆
ctx.fillStyle = '#F7E5CC';
ctx.beginPath();
ctx.arc(W / 2, 150, 70, 0, Math.PI * 2);
ctx.fill();
ctx.textAlign = 'center';
ctx.fillStyle = '#2E2A24';
ctx.font = '600 34px sans-serif';
ctx.fillText(this.data.headline, W / 2, 290);
ctx.fillStyle = '#C77E2E';
ctx.font = '700 96px sans-serif';
ctx.fillText(this.data.bigValue, W / 2, 400);
if (this.data.bigUnit) {
ctx.fillStyle = '#6E665B';
ctx.font = '500 28px sans-serif';
ctx.fillText(this.data.bigUnit, W / 2, 445);
}
// 三宫格
const cols = [
{ v: this.data.timeText, k: '时长' },
{ v: this.data.distanceText, k: '公里' },
{ v: `${this.data.streak}`, k: '连续' },
];
const baseY = 560;
cols.forEach((c, i) => {
const x = (W / 4) * (i + 1);
ctx.fillStyle = '#2E2A24';
ctx.font = '600 36px sans-serif';
ctx.fillText(c.v, x, baseY);
ctx.fillStyle = '#A89F92';
ctx.font = '400 24px sans-serif';
ctx.fillText(c.k, x, baseY + 40);
});
// 页脚
ctx.fillStyle = '#A89F92';
ctx.font = '500 26px sans-serif';
ctx.fillText('汪圈 · 每一次遛狗都值得被记录', W / 2, 760);
wx.canvasToTempFilePath({
canvas,
success: (r) => resolve(r.tempFilePath),
fail: () => resolve(null),
});
});
});
},
async onShare() {
if (this.data.submitting) return;
this.setData({ submitting: true });
await this.ensureAttached();
const path = await this.drawShareCard();
this.setData({ submitting: false });
if (!path) return wx.showToast({ title: '生成失败', icon: 'none' });
wx.showShareImageMenu({ path }); // 用户可分享到朋友圈/好友或保存
},
// 订阅「遛狗提醒」(模板未配置时静默跳过)
requestSubscribe() {
if (!SUBSCRIBE_TEMPLATE_ID) return Promise.resolve();
return new Promise((resolve) => {
wx.requestSubscribeMessage({ tmplIds: [SUBSCRIBE_TEMPLATE_ID], complete: resolve });
});
},
async onDone() {
if (this.data.submitting) return;
this.setData({ submitting: true });
await this.requestSubscribe(); // 需在用户点击手势内触发
await this.ensureAttached();
this.setData({ submitting: false });
this.sediment();
},
// 转发好友 / 朋友圈(小程序卡片)
onShareAppMessage() {
return { title: `${this.data.dogName}今天的遛狗成果`, path: '/pages/login/login' };
},
onShareTimeline() {
return { title: `${this.data.dogName}今天也出门遛了一圈` };
},
onClose() { wx.switchTab({ url: '/pages/home/home' }); },
sediment() {
const id = this.data.primaryDogId;
if (id) wx.redirectTo({ url: `/pages/dog-detail/dog-detail?id=${id}` });
else wx.switchTab({ url: '/pages/home/home' });
},
});

View File

@@ -0,0 +1,4 @@
{
"usingComponents": {},
"navigationBarTitleText": "今日成果"
}

View File

@@ -0,0 +1,55 @@
<view wx:if="{{loaded}}" class="page">
<view class="close" bindtap="onClose">✕</view>
<!-- 头部成果 -->
<view class="hero">
<image wx:if="{{dogAvatar}}" class="hero-avatar" src="{{dogAvatar}}" mode="aspectFill" />
<view wx:else class="hero-avatar">🐶</view>
<view class="hero-headline">{{headline}}</view>
<view class="hero-big"><text class="big-v">{{bigValue}}</text><text wx:if="{{bigUnit}}" class="big-u"> {{bigUnit}}</text></view>
</view>
<!-- 三宫格 -->
<view class="grid3">
<view class="g"><view class="g-v">{{timeText}}</view><view class="g-k">时长</view></view>
<view class="g"><view class="g-v">{{distanceText}}</view><view class="g-k">公里</view></view>
<view class="g"><view class="g-v streak">第{{streak}}天</view><view class="g-k">连续</view></view>
</view>
<!-- 解锁成就 -->
<view wx:for="{{newAchievements}}" wx:key="key" class="ach-banner">
<text class="ach-medal">🏅</text>
<view><view class="ach-name">解锁成就 · {{item.name}}</view></view>
</view>
<!-- 选填补充 -->
<view class="section">补充(可选,跳过也能保存)</view>
<view class="note-row">
<view class="photo-add" bindtap="onPickPhoto">
<image wx:if="{{photos.length}}" class="photo-thumb" src="{{photos[0]}}" mode="aspectFill" />
<text wx:else>📷</text>
</view>
<input class="note-input" placeholder="给这次遛狗写点什么…" value="{{note}}" bindinput="onNote" />
</view>
<view class="field">
<view class="flabel">天气</view>
<view class="chips">
<view wx:for="{{weathers}}" wx:key="k" class="chip {{weather===item.k?'sel':''}}" data-k="{{item.k}}" bindtap="pickWeather">{{item.l}}</view>
</view>
</view>
<view class="field">
<view class="flabel">状态</view>
<view class="chips">
<view wx:for="{{moods}}" wx:key="k" class="chip {{mood===item.k?'sel':''}}" data-k="{{item.k}}" bindtap="pickMood">{{item.l}}</view>
</view>
</view>
<view class="actions">
<button class="btn primary" bindtap="onShare">分享图片到朋友圈</button>
<button class="btn ghost" bindtap="onDone" loading="{{submitting}}">完成</button>
</view>
<!-- 离屏 Canvas生成分享图用 -->
<canvas type="2d" id="shareCanvas" class="share-canvas"></canvas>
</view>

View File

@@ -0,0 +1,34 @@
.page { min-height: 100vh; padding-bottom: calc(48rpx + env(safe-area-inset-bottom)); position: relative; }
.close { position: absolute; top: 24rpx; right: 32rpx; font-size: 36rpx; color: var(--ink-3); z-index: 2; }
.hero { text-align: center; padding: 40rpx 32rpx 24rpx; border-bottom: 1rpx solid var(--line); }
.hero-avatar { width: 116rpx; height: 116rpx; border-radius: 50%; background: var(--surface-2); display: flex; align-items: center; justify-content: center; font-size: 56rpx; margin: 0 auto 16rpx; }
.hero-headline { font-size: 30rpx; font-weight: 600; }
.hero-big { margin-top: 8rpx; }
.big-v { font-size: 72rpx; font-weight: 600; font-variant-numeric: tabular-nums; }
.big-u { font-size: 30rpx; color: var(--ink-2); }
.grid3 { display: flex; padding: 28rpx 12rpx; }
.g { flex: 1; text-align: center; }
.g-v { font-size: 36rpx; font-weight: 600; }
.g-v.streak { color: var(--success); }
.g-k { font-size: 22rpx; color: var(--ink-2); margin-top: 4rpx; }
.ach-banner { display: flex; align-items: center; gap: 18rpx; margin: 0 32rpx 20rpx; padding: 22rpx 24rpx; background: var(--accent-soft); border-radius: var(--radius-sm); }
.ach-medal { font-size: var(--icon-size); line-height: 1; }
.ach-name { font-size: 26rpx; font-weight: 600; color: var(--accent-deep); }
.section { font-size: 24rpx; color: var(--ink-2); padding: 24rpx 32rpx 8rpx; }
.note-row { display: flex; align-items: center; gap: 16rpx; padding: 0 32rpx 16rpx; }
.photo-add { width: 128rpx; height: 128rpx; border-radius: 24rpx; border: 1rpx dashed var(--line-2); display: flex; align-items: center; justify-content: center; font-size: var(--icon-size); color: var(--ink-3); overflow: hidden; line-height: 1; }
.photo-thumb { width: 128rpx; height: 128rpx; }
.share-canvas { position: fixed; left: -9999rpx; top: 0; width: 600px; height: 820px; }
.note-input { flex: 1; height: var(--input-height); min-height: var(--input-height); background: var(--surface-2); border-radius: var(--input-radius); padding: var(--input-padding-y) var(--input-padding-x); font-size: var(--input-font-size); line-height: 1.4; }
.field { padding: 22rpx 32rpx; border-top: 1rpx solid var(--line); }
.flabel { font-size: var(--form-label-size); color: var(--ink-2); margin-bottom: 12rpx; }
.chips { display: flex; flex-wrap: wrap; gap: 16rpx; }
.chip { font-size: var(--chip-font-size); padding: var(--chip-padding-y) var(--chip-padding-x); border-radius: var(--radius-pill); border: 1rpx solid var(--line-2); background: var(--surface-2); line-height: 1.4; }
.chip.sel { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-deep); font-weight: 600; }
.actions { display: flex; flex-direction: column; gap: 20rpx; padding: 36rpx 32rpx 0; }

View File

@@ -0,0 +1,226 @@
// pages/walking/walking — 2/2b 遛狗中(进行/暂停同页)
// 计时基于时间戳差值切后台不丢R5GPS 距离可选累计、按 stride 换算狗步R3/R6
const { call } = require('../../utils/cloud.js');
const store = require('../../utils/store.js');
const { formatDuration } = require('../../utils/format.js');
const { BASE_STRIDE, dogStepsFromMeters } = require('../../utils/dogStep.js');
// 两点球面距离(米)
function haversine(lat1, lon1, lat2, lon2) {
const R = 6371000;
const toRad = (d) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(a));
}
Page({
data: {
status: 'idle', // idle | selecting | walking | paused
timeText: '00:00',
pooped: false,
dogs: [],
selectedIds: [],
selectedMap: {}, // id→true供 WXML 判断选中WXML 表达式不能调用 indexOf
activeDogNames: '',
showEndConfirm: false,
gps: 'off', // off | on | denied
distanceText: '—',
stepsText: '—',
},
_timer: null,
_locOn: false,
_locCb: null,
_lastPoint: null,
_stride: 0.35,
onLoad() {
const dogs = store.getState().dogs || [];
this.setData({ dogs });
const session = store.getWalkSession();
if (session) { this.resumeFrom(session); return; }
if (dogs.length === 0) {
wx.showToast({ title: '先添加一只狗狗', icon: 'none' });
setTimeout(() => wx.navigateBack(), 1200);
return;
}
if (dogs.length === 1) this.startSession([dogs[0]._id]);
else {
const def = store.getState().currentDogId || dogs[0]._id;
this.setData({ status: 'selecting', selectedIds: [def], selectedMap: this.buildSelectMap([def]) });
}
},
onUnload() { this.clearTimer(); this.stopLocation(); },
// ===== 多狗选择 =====
// WXML 表达式不支持方法调用(如 selectedIds.indexOf用 id→true 映射给视图层判断选中
buildSelectMap(ids) {
const map = {};
ids.forEach((id) => { map[id] = true; });
return map;
},
toggleSelect(e) {
const id = e.currentTarget.dataset.id;
const ids = this.data.selectedIds.slice();
const i = ids.indexOf(id);
if (i >= 0) ids.splice(i, 1); else ids.push(id);
this.setData({ selectedIds: ids, selectedMap: this.buildSelectMap(ids) });
},
confirmSelect() {
if (!this.data.selectedIds.length) return wx.showToast({ title: '至少选一只', icon: 'none' });
this.startSession(this.data.selectedIds);
},
// ===== 会话生命周期 =====
startSession(dogIds) {
const session = { dogIds, startAt: Date.now(), pausedTotal: 0, pausedAt: null, pooped: false, meters: 0, status: 'walking' };
store.setWalkSession(session);
store.set({ currentDogId: dogIds[0] });
this.setActiveNames(dogIds);
this.setData({ status: 'walking', pooped: false });
this.startTimer();
this.startLocation();
},
resumeFrom(session) {
this.setActiveNames(session.dogIds);
this.setData({ status: session.status, pooped: session.pooped });
if (session.status === 'walking') { this.startTimer(); this.startLocation(); }
else this.renderTime();
this.refreshGps();
},
setActiveNames(dogIds) {
const map = {};
(this.data.dogs || []).forEach((d) => { map[d._id] = d.name; });
this.setData({ activeDogNames: dogIds.map((id) => map[id] || '狗狗').join('、') });
},
// ===== 计时(时间戳差值)=====
elapsedSec() {
const s = store.getWalkSession();
if (!s) return 0;
const end = s.status === 'paused' ? s.pausedAt : Date.now();
return Math.max(0, Math.floor((end - s.startAt - s.pausedTotal) / 1000));
},
renderTime() { this.setData({ timeText: formatDuration(this.elapsedSec()) }); },
startTimer() { this.clearTimer(); this.renderTime(); this._timer = setInterval(() => this.renderTime(), 1000); },
clearTimer() { if (this._timer) { clearInterval(this._timer); this._timer = null; } },
// ===== GPS 距离累计可选增强R6=====
startLocation() {
if (this._locOn) return;
const s = store.getWalkSession();
const primary = (this.data.dogs || []).find((d) => s && d._id === s.dogIds[0]);
this._stride = (primary && (primary.stride || BASE_STRIDE[primary.sizeLevel])) || 0.35;
this._lastPoint = null;
this._locCb = (res) => this.onLoc(res);
wx.startLocationUpdate({
success: () => {
this._locOn = true;
wx.onLocationChange(this._locCb);
this.setData({ gps: 'on' });
this.refreshGps();
},
fail: () => { this.setData({ gps: 'denied' }); }, // 未授权 → 降级,只保留时长
});
},
onLoc(res) {
const s = store.getWalkSession();
if (!s || s.status !== 'walking') { this._lastPoint = null; return; } // 暂停不累计
if (res.accuracy && res.accuracy > 50) return; // 精度差丢弃
const p = { lat: res.latitude, lon: res.longitude };
if (this._lastPoint) {
const seg = haversine(this._lastPoint.lat, this._lastPoint.lon, p.lat, p.lon);
if (seg >= 2 && seg <= 50) { // 过滤静止抖动(<2m)与跳点(>50m)
s.meters = (s.meters || 0) + seg;
store.setWalkSession(s);
this.refreshGps();
}
}
this._lastPoint = p;
},
refreshGps() {
if (this.data.gps !== 'on') return;
const s = store.getWalkSession();
const m = (s && s.meters) || 0;
this.setData({
distanceText: (m / 1000).toFixed(2),
stepsText: String(dogStepsFromMeters(m, this._stride) || 0),
});
},
stopLocation() {
if (this._locOn) {
wx.offLocationChange(this._locCb);
wx.stopLocationUpdate();
this._locOn = false;
}
},
// ===== 便便打卡 =====
togglePoop() {
const s = store.getWalkSession();
if (!s) return;
s.pooped = !s.pooped;
store.setWalkSession(s);
this.setData({ pooped: s.pooped });
},
// ===== 暂停 / 继续 =====
pause() {
const s = store.getWalkSession();
if (!s || s.status !== 'walking') return;
s.status = 'paused';
s.pausedAt = Date.now();
store.setWalkSession(s);
this.clearTimer();
this._lastPoint = null;
this.setData({ status: 'paused' });
this.renderTime();
},
resume() {
const s = store.getWalkSession();
if (!s || s.status !== 'paused') return;
s.pausedTotal += Date.now() - s.pausedAt;
s.pausedAt = null;
s.status = 'walking';
store.setWalkSession(s);
this.setData({ status: 'walking' });
this.startTimer();
},
// ===== 结束 =====
askEnd() { this.setData({ showEndConfirm: true }); },
cancelEnd() { this.setData({ showEndConfirm: false }); },
async confirmEnd() {
const s = store.getWalkSession();
if (!s) return;
this.clearTimer();
this.stopLocation();
const duration = this.elapsedSec();
const endAt = Date.now();
const distanceMeters = this.data.gps === 'on' ? Math.round(s.meters || 0) : null;
this.setData({ showEndConfirm: false });
const res = await call('walkSave', {
action: 'finish',
dogIds: s.dogIds,
startAt: s.startAt,
endAt,
duration,
distanceMeters, // 米;未授权定位时为 nullR6
pooped: s.pooped,
}, { loading: true, loadingText: '生成成果卡' });
if (!res.ok) return;
store.setWalkSession(null);
getApp().globalData.lastSummary = res.data;
wx.redirectTo({ url: '/pages/summary/summary' });
},
});

View File

@@ -0,0 +1,4 @@
{
"usingComponents": {},
"navigationBarTitleText": "遛狗中"
}

View File

@@ -0,0 +1,63 @@
<!-- ===== 多狗选择 ===== -->
<view wx:if="{{status==='selecting'}}" class="select-wrap">
<view class="select-title">今天遛哪只?</view>
<view class="select-sub">可多选,一起遛</view>
<view class="select-list">
<view wx:for="{{dogs}}" wx:key="_id"
class="card sel-row {{selectedMap[item._id]?'on':''}}"
data-id="{{item._id}}" bindtap="toggleSelect">
<view class="sel-avatar">🐶</view>
<view class="sel-meta">
<view class="sel-name">{{item.name}}</view>
<view class="sel-breed">{{item.breed}}<text class="sel-days">· 连续 {{item.stats.currentStreak || 0}} 天</text></view>
</view>
<view class="sel-check">{{selectedMap[item._id] ? '✓' : ''}}</view>
</view>
</view>
<button class="btn primary" bindtap="confirmSelect"><text class="btn-ico">🐾</text>开始遛狗</button>
</view>
<!-- ===== 遛狗中 / 暂停 ===== -->
<view wx:else class="walk {{status==='paused'?'paused':''}}">
<view class="status-pill {{status}}">
<text class="dot"></text>{{status==='paused' ? '已暂停' : '遛狗中'}}
</view>
<view class="map">{{status==='paused' ? '计时已停止' : (gps==='on' ? '定位中 · 累计距离' : '地图轨迹(首版可后置)')}}</view>
<view class="timer-block">
<view class="timer-label">已遛时长</view>
<view class="timer">{{timeText}}</view>
</view>
<view class="stat-grid">
<view class="stat"><view class="stat-v">{{distanceText}}<text wx:if="{{gps==='on'}}" class="stat-u"> km</text></view><view class="stat-k">距离</view></view>
<view class="stat"><view class="stat-v">{{stepsText}}</view><view class="stat-k">狗步</view></view>
</view>
<view wx:if="{{gps==='denied'}}" class="degrade-tip">未开启定位,仅记录时长(距离/狗步不计)</view>
<view class="poop-row">
<text class="poop-label">💩 便便打卡</text>
<view class="toggle {{pooped?'on':''}}" bindtap="togglePoop"><view class="knob"></view></view>
</view>
<view class="actions">
<button wx:if="{{status==='walking'}}" class="btn ghost" bindtap="pause"><text class="btn-ico">‖</text>暂停</button>
<button wx:else class="btn primary" bindtap="resume"><text class="btn-ico">▶</text>继续</button>
<button class="btn danger" bindtap="askEnd"><text class="btn-ico">■</text>结束</button>
</view>
<view class="active-dog">🐶 {{activeDogNames}}</view>
</view>
<!-- ===== 结束二次确认 ===== -->
<view wx:if="{{showEndConfirm}}" class="mask" bindtap="cancelEnd">
<view class="confirm" catchtap="">
<view class="confirm-title">结束这次遛狗?</view>
<view class="confirm-text">已遛 {{timeText}},确认结束并生成成果卡。</view>
<view class="confirm-btns">
<button class="btn ghost" bindtap="cancelEnd">再遛会</button>
<button class="btn primary" bindtap="confirmEnd">结束</button>
</view>
</view>
</view>

View File

@@ -0,0 +1,61 @@
/* ===== 多狗选择 ===== */
.select-wrap { padding: 40rpx 32rpx; }
.select-title { font-size: 34rpx; font-weight: 600; text-align: center; }
.select-sub { font-size: 24rpx; color: var(--ink-2); text-align: center; margin-top: 8rpx; margin-bottom: 28rpx; }
.select-list { display: flex; flex-direction: column; gap: 20rpx; margin-bottom: 32rpx; }
.sel-row { display: flex; align-items: center; gap: 20rpx; padding: 22rpx; border: 2rpx solid transparent; }
.sel-row.on { border-color: var(--accent); }
.sel-avatar { width: 80rpx; height: 80rpx; border-radius: 50%; background: var(--surface-2); display: flex; align-items: center; justify-content: center; font-size: 40rpx; }
.sel-meta { flex: 1; }
.sel-name { font-size: 28rpx; font-weight: 600; }
.sel-breed { font-size: 22rpx; color: var(--ink-2); margin-top: 4rpx; }
.sel-days { margin-left: 8rpx; color: var(--success); }
.sel-check {
width: 44rpx; height: 44rpx; border-radius: 50%;
border: 2rpx solid var(--line-2); color: #fff;
display: flex; align-items: center; justify-content: center; font-size: 28rpx; line-height: 1;
}
.sel-row.on .sel-check { background: var(--accent); border-color: var(--accent); }
/* ===== 遛狗中 ===== */
.walk { min-height: 100vh; padding: 24rpx 32rpx calc(48rpx + env(safe-area-inset-bottom)); }
.status-pill { display: flex; align-items: center; gap: 10rpx; justify-content: center; font-size: 24rpx; font-weight: 600; color: var(--success); }
.status-pill.paused { color: var(--warn); }
.status-pill .dot { width: 16rpx; height: 16rpx; border-radius: 50%; background: currentColor; }
.map {
height: 260rpx; margin-top: 20rpx;
border-radius: var(--radius); background: var(--surface-2);
display: flex; align-items: center; justify-content: center;
font-size: 24rpx; color: var(--ink-3);
}
.timer-block { text-align: center; padding: 36rpx 0 12rpx; }
.timer-label { font-size: 24rpx; color: var(--ink-2); }
.timer { font-size: 96rpx; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.1; }
.stat-grid { display: flex; gap: 20rpx; }
.stat { flex: 1; background: var(--surface-2); border-radius: var(--radius-sm); padding: 22rpx 0; text-align: center; }
.stat-v { font-size: 38rpx; font-weight: 600; }
.stat-u { font-size: 22rpx; color: var(--ink-2); }
.stat-k { font-size: 22rpx; color: var(--ink-2); margin-top: 4rpx; }
.degrade-tip { font-size: 21rpx; color: var(--ink-3); text-align: center; margin-top: 14rpx; }
.paused .timer-block, .paused .stat-grid { opacity: 0.45; }
.poop-row { display: flex; align-items: center; justify-content: space-between; padding: 28rpx 4rpx; margin-top: 8rpx; border-top: 1rpx solid var(--line); }
.poop-label { font-size: 26rpx; }
.toggle { width: 76rpx; height: 44rpx; border-radius: var(--radius-pill); background: var(--line-2); position: relative; transition: background .2s; }
.toggle.on { background: var(--success); }
.toggle .knob { position: absolute; top: 4rpx; left: 4rpx; width: 36rpx; height: 36rpx; border-radius: 50%; background: #fff; transition: transform .2s; }
.toggle.on .knob { transform: translateX(32rpx); }
.actions { display: flex; gap: 20rpx; margin-top: 16rpx; }
.active-dog { margin-top: 28rpx; padding-top: 20rpx; border-top: 1rpx solid var(--line); font-size: 24rpx; color: var(--ink-2); }
/* ===== 结束确认 ===== */
.mask { position: fixed; inset: 0; background: rgba(46,42,36,0.42); display: flex; align-items: center; justify-content: center; z-index: 50; }
.confirm { width: 520rpx; background: var(--surface); border-radius: 28rpx; padding: 44rpx 36rpx 32rpx; text-align: center; }
.confirm-title { font-size: 32rpx; font-weight: 600; }
.confirm-text { font-size: 26rpx; color: var(--ink-2); margin: 16rpx 0 32rpx; line-height: 1.6; }
.confirm-btns { display: flex; gap: 20rpx; }

7
miniprogram/sitemap.json Normal file
View File

@@ -0,0 +1,7 @@
{
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
"rules": [{
"action": "allow",
"page": "*"
}]
}

View File

@@ -0,0 +1,56 @@
// utils/achievements.js — 成就定义(前端展示用副本)
// 解锁判定的"权威"逻辑在云函数侧(防刷);此处仅供前端渲染成就墙/进度。
// 成就基于「连续 / 累计 / 场景」,不基于「最多 / 最快」docs/05 R10
const ACHIEVEMENTS = [
{
key: 'first_walk',
name: '首遛',
desc: '第一次记录遛狗',
icon: 'flag',
progress: (stats) => Math.min(1, stats.totalWalks / 1),
},
{
key: 'week_streak',
name: '一周不断',
desc: '连续遛狗满 7 天',
icon: 'medal',
progress: (stats) => Math.min(1, stats.currentStreak / 7),
},
{
key: 'month_streak',
name: '满月坚持',
desc: '连续遛狗满 30 天',
icon: 'trophy',
progress: (stats) => Math.min(1, stats.currentStreak / 30),
},
{
key: 'rainy_warrior',
name: '雨天战士',
desc: '雨天也坚持遛狗',
icon: 'rain',
progress: () => 0, // 场景类,由事件触发
},
{
key: 'hundred_km',
name: '百里同行',
desc: '累计遛狗满 100 公里',
icon: 'walk',
progress: (stats) => Math.min(1, stats.totalDistance / 100),
},
{
key: 'early_bird',
name: '早起鸟',
desc: '在 7 点前遛狗 10 次',
icon: 'sun',
progress: (stats) => Math.min(1, (stats.earlyWalks || 0) / 10), // 场景类:累计早遛次数
},
];
const TOTAL = ACHIEVEMENTS.length;
function getDef(key) {
return ACHIEVEMENTS.find((a) => a.key === key);
}
module.exports = { ACHIEVEMENTS, TOTAL, getDef };

View File

@@ -0,0 +1,34 @@
// utils/cloud.js — 云函数调用统一封装
// 用法: const { ok, data } = await call('login', { ... })
/**
* 调用云函数,统一处理 loading 与错误提示。
* @param {string} name 云函数名
* @param {object} data 入参
* @param {object} [opts] { loading: boolean, loadingText: string }
* @returns {Promise<{ok:boolean, data?:any, code?:string, msg?:string}>}
*/
function call(name, data = {}, opts = {}) {
const { loading = false, loadingText = '加载中' } = opts;
if (loading) wx.showLoading({ title: loadingText, mask: true });
return wx.cloud
.callFunction({ name, data })
.then((res) => {
const result = res.result || {};
if (!result.ok) {
wx.showToast({ title: result.msg || '操作失败', icon: 'none' });
}
return result;
})
.catch((err) => {
console.error(`[cloud] ${name} 调用失败`, err);
wx.showToast({ title: '网络异常,请重试', icon: 'none' });
return { ok: false, code: 'NETWORK', msg: '网络异常' };
})
.finally(() => {
if (loading) wx.hideLoading();
});
}
module.exports = { call };

View File

@@ -0,0 +1,6 @@
// utils/config.js — 前端可配置常量
module.exports = {
// 订阅消息模板 ID在「微信公众平台 → 功能 → 订阅消息」创建「遛狗提醒」类模板后填入。
// 留空时,订阅请求与提醒功能自动跳过,不影响其它功能。
SUBSCRIBE_TEMPLATE_ID: '',
};

View File

@@ -0,0 +1,49 @@
// utils/dogStep.js — 体型档位 → 步幅 → 狗步docs/05 R3
// 狗步 = round(distanceMeters / stride)stride 由体型档位决定,体重做 ±15% 校准。
// 与 cloudfunctions/dogManage/index.js 的同名常量保持一致。
const SIZE_LABELS = [
{ key: 'xs', label: '超小型' },
{ key: 's', label: '小型/短腿' },
{ key: 'm', label: '中型' },
{ key: 'l', label: '大型' },
{ key: 'xl', label: '超大型' },
];
const BASE_STRIDE = { xs: 0.20, s: 0.25, m: 0.35, l: 0.45, xl: 0.55 }; // 米/步
const STD_WEIGHT = { xs: 3, s: 7, m: 15, l: 28, xl: 45 }; // 档位标准体重 kg
// 犬种 → 体型档位;未列出的(中华田园犬/串串/其他)返回 null需用户手动选
const BREED_SIZE = {
柯基: 's', 比熊: 'xs', 泰迪: 's', 柴犬: 'm', 金毛: 'l',
拉布拉多: 'l', 哈士奇: 'l', 边境牧羊犬: 'm', 萨摩耶: 'l', 法斗: 's',
};
function sizeOfBreed(breed) {
return BREED_SIZE[breed] || null;
}
// 由体型档位(+可选体重)算步幅
function computeStride(sizeLevel, weight) {
const base = BASE_STRIDE[sizeLevel];
if (!base) return { baseStride: null, stride: null };
let stride = base;
if (weight) {
const std = STD_WEIGHT[sizeLevel];
let f = 1 + (Number(weight) - std) * 0.005;
f = Math.max(0.85, Math.min(1.15, f)); // 封顶 ±15%
stride = +(base * f).toFixed(3);
}
return { baseStride: base, stride };
}
// 距离(米) + 步幅 → 狗步;无步幅返回 null
function dogStepsFromMeters(meters, stride) {
if (!stride || meters == null) return null;
return Math.round(meters / stride);
}
module.exports = {
SIZE_LABELS, BASE_STRIDE, STD_WEIGHT, BREED_SIZE,
sizeOfBreed, computeStride, dogStepsFromMeters,
};

View File

@@ -0,0 +1,48 @@
// utils/format.js — 时长 / 距离 / 日期格式化
/** 秒 → "MM:SS" 或 "HH:MM:SS" */
function formatDuration(sec) {
sec = Math.max(0, Math.floor(sec || 0));
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
const pad = (n) => String(n).padStart(2, '0');
return h > 0 ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
}
/** 秒 → "12分34秒"(历史列表用) */
function formatDurationCN(sec) {
sec = Math.max(0, Math.floor(sec || 0));
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}${String(s).padStart(2, '0')}`;
}
/** 公里数 → "1.2km"null → "距离未记录" */
function formatDistance(km) {
if (km == null) return '距离未记录';
return `${Number(km).toFixed(1)}km`;
}
/** 时间戳 → 本地 "yyyy-mm-dd" */
function toDateStr(ts, tzOffsetHours = 8) {
const d = new Date(ts + tzOffsetHours * 3600 * 1000);
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
/** 时间戳 → "今天 早上 / 昨天 傍晚" 之类的友好时段(简版占位) */
function friendlyTime(ts) {
// TODO: 阶段 2 完整实现(今天/昨天/前天 + 时段)
return new Date(ts).toLocaleString();
}
module.exports = {
formatDuration,
formatDurationCN,
formatDistance,
toDateStr,
friendlyTime,
};

View File

@@ -0,0 +1,38 @@
// utils/media.js — 选图 + 压缩 + 上传云存储,返回 fileID
// 用法const fileID = await chooseAndUpload('avatars')
/**
* 选择一张图片(已压缩)并上传到云存储。
* @param {string} prefix 云存储目录前缀,如 'avatars' / 'walk-photos'
* @returns {Promise<string|null>} fileIDcloud://...)或 null
*/
function chooseAndUpload(prefix = 'uploads') {
return new Promise((resolve) => {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (r) => {
const temp = r.tempFiles && r.tempFiles[0] && r.tempFiles[0].tempFilePath;
if (!temp) return resolve(null);
wx.showLoading({ title: '上传中', mask: true });
try {
const up = await wx.cloud.uploadFile({
cloudPath: `${prefix}/${Date.now()}-${Math.floor(Math.random() * 1e6)}.jpg`,
filePath: temp,
});
resolve(up.fileID);
} catch (e) {
wx.showToast({ title: '上传失败', icon: 'none' });
resolve(null);
} finally {
wx.hideLoading();
}
},
fail: () => resolve(null),
});
});
}
module.exports = { chooseAndUpload };

View File

@@ -0,0 +1,43 @@
// utils/store.js — 轻量全局状态 + 订阅(不引第三方状态库)
// 读 app.globalData变更通过 set() 并广播给订阅者。
const app = () => getApp();
const listeners = new Set();
function getState() {
return app().globalData;
}
/**
* 合并更新全局状态并广播。
* @param {object} patch 要合并进 globalData 的字段
*/
function set(patch) {
Object.assign(app().globalData, patch);
listeners.forEach((fn) => fn(app().globalData));
}
/**
* 订阅状态变更,返回取消订阅函数。
* @param {(state:object)=>void} fn
*/
function subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
// ===== 遛狗会话V1 核心运行时状态,需持久化防丢) =====
function setWalkSession(session) {
set({ walkSession: session });
if (session) {
wx.setStorageSync('walkSession', session);
} else {
wx.removeStorageSync('walkSession');
}
}
function getWalkSession() {
return app().globalData.walkSession;
}
module.exports = { getState, set, subscribe, setWalkSession, getWalkSession };

42
project.config.json Normal file
View File

@@ -0,0 +1,42 @@
{
"miniprogramRoot": "miniprogram/",
"cloudfunctionRoot": "cloudfunctions/",
"setting": {
"urlCheck": true,
"es6": true,
"enhance": true,
"postcss": true,
"minified": true,
"newFeature": true,
"nodeModules": false,
"compileWorklet": false,
"uglifyFileName": false,
"uploadWithSourceMap": true,
"packNpmManually": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"minifyWXML": true,
"localPlugins": false,
"disableUseStrict": false,
"useCompilerPlugins": false,
"condition": false,
"swc": false,
"disableSWC": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
}
},
"appid": "wx6cfaffb865048e83",
"projectname": "wangquan",
"description": "汪圈 · 遛狗小程序",
"compileType": "miniprogram",
"libVersion": "latest",
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [],
"include": []
},
"editorSetting": {}
}

View File

@@ -0,0 +1,21 @@
{
"libVersion": "3.15.2",
"projectname": "Pet3",
"setting": {
"urlCheck": true,
"coverView": false,
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
"preloadBackgroundData": false,
"autoAudits": false,
"useApiHook": false,
"showShadowRootInWxmlPanel": false,
"useStaticServer": false,
"useLanDebug": false,
"showES6CompileOption": false,
"compileHotReLoad": true,
"checkInvalidKey": true,
"ignoreDevUnusedFiles": true,
"bigPackageSizeSupport": false
}
}

260
wangquan-PRD.md Normal file
View File

@@ -0,0 +1,260 @@
# 汪圈 · 遛狗小程序 产品需求文档PRDV1
> 配套交付物:可点击交互原型 `wangquan-prototype.html`
> 本文档定义首版范围、页面、字段、交互规则与算法。原型看交互PRD 核字段与逻辑边界,两者配套使用。
---
## 0. 产品定位与首版范围
**一句话定位**:记录每一次遛狗,把单机行为变成有反馈、可积累的陪伴成果。
**核心闭环**:登录 → 建档狗狗 → 遛狗(实时记录)→ 生成成果卡 → 沉淀进狗狗主页。
**首版只做遛狗单机闭环**。所有社区、商业、本地信息功能均为 pending不进首版。理由单机闭环不依赖用户密度和内容供给是冷启动期唯一能保证有内容、有回访的部分。
### 首版做In Scope
| 模块 | 说明 |
|---|---|
| 微信登录 + 狗狗建档 | 首次进入链路 |
| 遛狗记录 | 计时、距离、狗步,含暂停、便便打卡 |
| 成果卡 | 遛完总结,拟人化反馈,分享 |
| 狗狗主页 | 累计数据、成就墙、近期记录 |
| 记录 Tab | 成就墙概览 + 遛狗历史 + 手动补记 |
| 完整成就页 | 已解锁/未解锁,进度展示 |
### 首版不做Pending见第 7 节 Roadmap
附近实时在线地图、夸一夸/拍一拍轻社交、坚持排行榜、汪友好友系统、广场/养宠知识信息站、宠物友好地点与市集、商城与今日上新。
---
## 1. 页面清单
底部导航三 Tab**记录 · 遛狗(中央凸起按钮)· 我的**。
中央按钮是「开始遛狗」动作入口,非页面 Tab。
| 编号 | 页面 | 类型 | 入口 |
|---|---|---|---|
| 0a | 欢迎 / 微信授权登录 | 全屏 | 首次打开 |
| 0b | 建档 / 编辑狗狗 | 表单 | 登录后(新用户)/ 主页编辑 |
| 0c | 多狗选择 | 弹层 | 多狗用户点遛狗 |
| 1 | 我的(首页/入口) | Tab 页 | 启动默认 / 底部「我的」 |
| 2 | 遛狗中 · 进行态 | 全屏 | 中央按钮 |
| 2b | 遛狗中 · 暂停态 | 全屏 | 进行态点暂停 |
| 3 | 成果卡 | 全屏 | 结束遛狗 |
| 4 | 狗狗主页 | 二级页 | 首页狗狗卡片 |
| 5 | 遛狗历史 + 补记 | 二级页 | 我的→遛狗历史 |
| 6 | 记录 Tab | Tab 页 | 底部「记录」 |
| 6b | 记录 Tab 空状态 | 状态 | 无记录的新用户 |
| 7 | 完整成就页 | 二级页 | 记录Tab成就墙 / 我的→成就 |
---
## 2. 字段表(遛狗记录)
核心原则:**自动的尽量自动,需要输入的全部后置到遛完且全部选填**。哪怕用户什么都不填,遛一次狗也能产出完整成果卡。
| 字段 | 采集方式 | 必填 | 用途 | 备注 |
|---|---|---|---|---|
| 时长 | 自动计时 | 是 | 核心数据、成果卡主轴 | 暂停时停止计时 |
| 距离 | GPS 累计 | 否 | 次级数据 | 未授权定位时隐藏 |
| 狗步 | 距离换算 | 否 | 拟人化展示 | 见第 4 节算法 |
| 连续天数 | 系统计算 | 是 | 坚持叙事、成就 | 补记可维持不断签 |
| 轨迹 | GPS 点序列 | 否 | 地图轨迹 | 首版可后置 |
| 哪只狗 | 选择/默认 | 是 | 多狗归属 | 单狗自动带入 |
| 照片 | 手动 | 否 | 未来 feed 内容 | 成果卡补充 |
| 文字 | 手动 | 否 | 记录心情 | 成果卡补充 |
| 天气 | 自动/手动 | 否 | 雨天战士等成就 | 可自动获取 |
| 便便打卡 | 手动开关 | 否 | 健康场景 | 遛狗中即时记录 |
| 心情标签 | 手动 | 否 | 情感记录 | 成果卡补充 |
| 数据来源 | 系统标记 | 是 | 区分实时/补记 | 见第 5 节补记规则 |
### 狗狗建档字段0b
| 字段 | 必填 | 用途 |
|---|---|---|
| 名字 | 是 | 拟人化文案(「豆豆走了…」),未建档时文案降级为通用版 |
| 犬种 | 是 | 映射体型档位,决定狗步步幅 |
| 性别 | 否 | 资料展示 |
| 年龄 | 否 | 资料展示 |
| 体重 | 否 | 狗步二次校准(见第 4 节) |
| 头像 | 否 | 资料展示 |
---
## 3. 交互规则
### 3.1 登录与首次进入0a → 0b → 1
- 首次打开进登录页0a微信一键授权登录。
- 登录后判断:**用户是否建档过狗狗** —— 这是状态判断,非固定跳转。
- 未建档 → 进建档页0b完成后进首页。
- 已建档(老用户)→ 跳过建档,直接进首页。
- 0a 必须包含用户协议、隐私政策(合规要素)。
### 3.2 开始遛狗(中央按钮)
- 单只狗 → 跳过选择直接进遛狗中2
- 多只狗 → 弹多狗选择层0c可多选一起遛确认后进遛狗中。
- 边界:未建档任何狗 → 拦截并引导先建档,不可直接遛狗。
### 3.3 遛狗中2 / 2b
- 进行态:计时器每秒自增;距离/狗步实时累计(依赖定位)。
- 便便打卡为行内开关,遛狗中即时记录。
- 暂停(→ 2b停止计时时长与数据置灰地图区提示「计时已停止」。常见停顿等狗、捡便便用暂停避免污染数据。
- 暂停态主按钮为「继续」(蓝色,提示恢复),并列「结束」。
- 结束必须二次确认弹窗防误触毁掉记录确认后生成成果卡3
### 3.4 定位降级
- 未授权定位时:距离、狗步两项隐藏,仅保留时长。闭环依然成立。
- 首版不强制请求定位权限,避免首次使用被权限弹窗劝退;定位作为可选增强。
### 3.5 成果卡3
- 视角是「豆豆走了 X 狗步」,非「你遛了」——拟人化是与运动 App 的根本区别。
- 所有补充字段(照片/文字/天气/心情)集中此页且全部选填,跳过可保存。
- 分享按钮为首选项(拉新入口)。
- 「完成」后记录沉淀进狗狗主页近期记录。
### 3.6 狗狗主页4
- 累计数据:总次数 / 总公里 / 连续天数。**不展示配速、卡路里**——记录陪伴厚度而非运动表现。
- 成就墙显示进度(如 3/12露出未解锁灰锁制造收集欲。
- 近期记录点击 → 单次记录详情页(见第 6 节待补项,首版可暂跳成果卡只读态)。
- 右上编辑 → 建档/编辑表单0b预填已有值标题改「编辑资料」。
### 3.7 记录 Tab6 / 6b
- 内部两块:成就墙概览(横向)+ 遛狗历史列表,顶部「+补记」入口。
- 成就墙概览点击 → 完整成就页7
- 空状态6b新用户无记录时显示。俏皮文案引导去遛狗 + 「带豆豆出门」按钮。成就墙不藏显示全灰「0/X 待解锁」制造期待。文案中的狗名动态替换;未建档时降级为通用文案。
- 遛完第一次 → 自动从空状态切为正常态。
### 3.8 成就系统
- 成就基于「连续 / 累计 / 场景」,**不基于「最多」**——避免刷量与过度遛狗,强化坚持叙事。
- 示例首遛、一周不断连续7天、满月坚持连续30天、百里同行累计100km、雨天战士、早起鸟。
- 「记录」Tab 与「我的→成就」两个入口指向**同一完整成就页**,文案统一叫「成就」,避免用户误以为是两个东西。
---
## 4. 狗步计算算法
### 4.1 核心公式
```
狗步 = round( 距离(米) / 步幅 S )
```
步幅 S 由**体型档位**决定(非具体品种),是品种间差异的来源。
### 4.2 体型档位表
| 体型档位 | 代表品种 | 步幅 S米/步) | 走 1km ≈ 狗步 |
|---|---|---|---|
| 超小型 | 吉娃娃、博美 | 0.20 | 5,000 |
| 小型/短腿 | 柯基、腊肠、比熊 | 0.25 | 4,000 |
| 中型 | 柴犬、边牧、斗牛 | 0.35 | 2,860 |
| 大型 | 金毛、拉布拉多 | 0.45 | 2,220 |
| 超大型 | 德牧、阿拉斯加 | 0.55 | 1,820 |
### 4.3 品种 → 档位映射
- 建档选具体品种 → 系统自动归入对应体型档位(映射表内置)。
- 中华田园犬 / 串串 / 找不到的品种 → 让用户**手动选体型档位**。
- 不做「每品种一个系数」——犬种过多、混血无法归类,会卡住用户。
### 4.4 体重二次校准(可选)
填了体重时,在档位基础步幅上做微调:
```
S = 基础步幅 × ( 1 + (体重 - 档位标准体重) × 0.005 )
修正幅度封顶 ±15%
```
未填体重 → 用档位基础步幅,不影响闭环。
### 4.5 关键约束:狗步只是展示层
- 狗步被品种系数放大/缩小过,**跨狗比较无意义**(柯基天生狗步多 ≠ 遛得多)。
- 底层入库、用于成就与未来排行榜计算的,一律是**距离和时长**等客观量。
- 狗步仅在成果卡、狗狗主页做拟人化展示。
- 一句话:**客观量入库,狗步只露脸。**
---
## 5. 手动补记规则
- 入口:记录 Tab / 遛狗历史页 右上「+」。
- 用途:用户忘记开 App 就出门遛,遛完事后补填时长/距离。
- 字段:哪只狗、时间、时长(必填),距离(选填)。
- 标识:补记记录带「补记」标识,数据层标记 `数据来源 = 补记`
- **计分规则(关键)**
- 补记**可维持连续打卡不断签**(保护坚持叙事,避免用户因忘开 App 而委屈)。
- 补记**不计入未来排行榜竞争**(防刷量)。
- 故数据层必须区分「实时记录」与「补记记录」两种来源。
---
## 6. 待补项(建议补,不阻塞首版上线)
这些不一定都要画屏,但开发实现时会碰到,需在本文档明确:
1. **单次记录详情页**:回看旧记录应为只读详情页(数据 + 当时填的天气/心情/照片 + 删除/编辑),不应复用带「分享/完成」按钮的成果卡。原型中暂用成果卡占位。
2. **设置页**:至少含定位权限管理(数据降级开关)、关于、隐私政策。
3. **边界态文案**
- 定位未授权时遛狗中页的降级样式(距离/狗步隐藏)。
- 未建档狗就想遛狗的拦截引导。
- 补记成功后的反馈 toast。
- 删除记录的二次确认。
4. **分享卡片样式**(建议尽早做,拉新关键):点「生成图片分享」后那张图的视觉。
---
## 7. RoadmapPending 功能与时序)
首版后按以下时序逐步迭代,每一步都以「单机闭环已跑通、有局部用户密度」为前提:
| 阶段 | 功能 | 说明 |
|---|---|---|
| 首版 | 遛狗单机闭环 | 本文档定义范围 |
| 阶段二 | 成果卡被夸(轻社交第一张牌) | 见 7.1 |
| 阶段三 | 坚持排行榜 | 奖励连续/达标,非「最多」;竞争圈切小(附近/同犬种) |
| 阶段四 | 实时在线地图 | 见 7.1,验证后再上,重隐私工程 |
| 更后 | 汪友、本地信息站、商城 | 视社区牵引力决定 |
### 7.1 附近轻社交:先做异步「被夸」,慎做实时地图
**情感钩子**:遛狗时/遛完能「被夸一夸、拍一拍」,成果卡显示「这次遛狗被 N 人夸了夸」,给单机行为注入社交正反馈。
**两种形态对比**
| | 实时在线地图 | 成果卡被夸(异步,建议先做) |
|---|---|---|
| 体验 | 即时、好玩 | 温和 |
| 位置隐私 | 暴露实时位置(红线) | 不暴露(遛完才发,位置模糊到行政区) |
| 密度依赖 | 强依赖同时在线 | 异步,不靠同时在线 |
| 冷启动 | 0 在线时是空壳 | 旧记录也能被夸,供给更稳 |
**建议**:先做异步「成果卡被夸」——成果卡进入同城/同区可见的轻量动态流,他人对已完成记录点「夸夸」。验证用户确实爱「被夸」后,再评估是否上「实时在线地图」这一重形态。
**实时地图若要做,隐私是硬约束(按隐私工程对待,非地图功能)**
- 默认模糊化:只显示「附近约 N 只在线」或网格化区域,**绝不显示精确点位和轨迹**。
- 必须显式授权,提供一键隐身。
- 绝不展示狗主人身份信息。
- 「拍一拍」需频率限制 + 举报机制,防骚扰与刷量。
---
## 8. 设计风格
视觉方向:「晨霜 V3」暖色磨砂玻璃。暖米底色、磨砂玻璃导航栏、琥珀暖橙作主色呼应狗步的温度感刻意避开冷色运动 App 观感。具体设计令牌见原型 `wangquan-prototype.html` 的 CSS 变量定义。
---
*文档版本 V1 · 配套原型 wangquan-prototype.html*

824
wangquan-prototype.html Normal file
View File

@@ -0,0 +1,824 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>汪圈 · 遛狗小程序原型 V1</title>
<style>
:root {
--bg: #F2EDE6;
--surface: #FBF8F3;
--surface-glass: rgba(251, 248, 243, 0.72);
--surface-2: #EDE6DC;
--ink: #2E2A24;
--ink-2: #6E665B;
--ink-3: #A89F92;
--accent: #E8A04B;
--accent-deep: #C77E2E;
--accent-soft: #F7E5CC;
--success: #6B8E5A;
--success-soft: #E2EBD9;
--warn: #D98A4E;
--danger: #C25A4E;
--danger-soft: #F2DDD8;
--line: rgba(46, 42, 36, 0.10);
--line-2: rgba(46, 42, 36, 0.16);
--radius: 18px;
--radius-sm: 12px;
--radius-pill: 999px;
--shadow: 0 1px 2px rgba(46,42,36,0.04), 0 8px 24px rgba(46,42,36,0.06);
--font: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
body {
font-family: var(--font);
background: #DED6CA;
color: var(--ink);
padding: 32px 16px 64px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.page-head { max-width: 1180px; margin: 0 auto 28px; }
.page-head h1 { font-size: 22px; font-weight: 600; letter-spacing: -0.01em; }
.page-head p { font-size: 14px; color: #6b6357; margin-top: 6px; max-width: 640px; }
.legend { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 16px; font-size: 12.5px; color: #6b6357; }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
.stage {
max-width: 1180px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 40px 28px;
align-items: start;
}
.frame-wrap { display: flex; flex-direction: column; gap: 12px; }
.frame-label { font-size: 13px; font-weight: 600; color: #4a4339; display: flex; align-items: center; gap: 8px; }
.frame-label .num {
width: 20px; height: 20px; border-radius: 50%; background: var(--ink); color: #fff;
font-size: 11px; display: inline-flex; align-items: center; justify-content: center; font-weight: 600;
}
.frame-note { font-size: 12px; color: #756c5f; line-height: 1.5; }
.phone {
width: 300px;
height: 620px;
background: var(--bg);
border-radius: 36px;
border: 7px solid #2E2A24;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
box-shadow: var(--shadow);
}
.statusbar {
height: 32px; flex-shrink: 0; display: flex; align-items: center; justify-content: space-between;
padding: 0 18px 0 22px; font-size: 12px; font-weight: 600; color: var(--ink); padding-top: 6px;
}
.statusbar .right { display: flex; gap: 5px; align-items: center; font-size: 11px; }
.navbar {
height: 44px; flex-shrink: 0; display: flex; align-items: center; justify-content: center;
position: relative; font-size: 15px; font-weight: 600;
}
.navbar .nav-left, .navbar .nav-right {
position: absolute; top: 50%; transform: translateY(-50%); color: var(--ink-2);
width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; cursor: pointer;
}
.navbar .nav-left { left: 12px; }
.navbar .nav-right { right: 12px; }
.screen-body { flex: 1; overflow-y: auto; overflow-x: hidden; }
.screen-body::-webkit-scrollbar { display: none; }
.tabbar {
height: 64px; flex-shrink: 0; background: var(--surface-glass);
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border-top: 0.5px solid var(--line); display: flex; align-items: flex-start;
justify-content: space-around; padding-top: 8px; position: relative;
}
.tab { display: flex; flex-direction: column; align-items: center; gap: 3px; color: var(--ink-3); font-size: 10.5px; cursor: pointer; flex: 1; }
.tab.active { color: var(--accent-deep); }
.tab svg { width: 22px; height: 22px; }
.tab-center { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; }
.tab-fab {
width: 54px; height: 54px; border-radius: 50%; background: var(--accent);
display: flex; align-items: center; justify-content: center; margin-top: -26px;
border: 4px solid var(--bg); box-shadow: 0 4px 12px rgba(200,126,46,0.35); cursor: pointer;
}
.tab-fab svg { width: 26px; height: 26px; stroke: #fff; }
.tab-center span { font-size: 10.5px; color: var(--ink-2); }
svg.ic { width: 20px; height: 20px; stroke: currentColor; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
/* generic blocks */
.pad { padding: 16px; }
.card {
background: var(--surface); border-radius: var(--radius); border: 0.5px solid var(--line);
box-shadow: var(--shadow);
}
.row { display: flex; align-items: center; gap: 12px; }
.muted { color: var(--ink-2); }
.tiny { font-size: 11px; }
.center { text-align: center; }
.avatar {
border-radius: 50%; background: var(--surface-2); display: flex; align-items: center; justify-content: center;
color: var(--ink-3); flex-shrink: 0;
}
/* map placeholder */
.mapfill {
height: 132px; background:
repeating-linear-gradient(45deg, rgba(46,42,36,0.04), rgba(46,42,36,0.04) 10px, transparent 10px, transparent 20px);
border-top: 0.5px solid var(--line); border-bottom: 0.5px solid var(--line);
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; color: var(--ink-3);
}
.timer { font-size: 46px; font-weight: 600; letter-spacing: 1px; line-height: 1.05; font-variant-numeric: tabular-nums; }
.statgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.statgrid.three { grid-template-columns: repeat(3,1fr); }
.stat { background: var(--surface-2); border-radius: var(--radius-sm); padding: 11px 8px; text-align: center; }
.stat .v { font-size: 19px; font-weight: 600; font-variant-numeric: tabular-nums; }
.stat .k { font-size: 11px; color: var(--ink-2); margin-top: 1px; }
.btn {
height: 46px; border-radius: var(--radius-pill); border: 0.5px solid var(--line-2);
background: var(--surface); color: var(--ink); font-size: 14px; font-weight: 600; font-family: var(--font);
display: inline-flex; align-items: center; justify-content: center; gap: 6px; cursor: pointer; width: 100%;
}
.btn:active { transform: scale(0.98); }
.btn.primary { background: var(--accent); color: #fff; border-color: transparent; }
.btn.danger { color: var(--danger); border-color: var(--danger-soft); }
.btn.ghost { background: transparent; }
.btn svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.toggle { width: 38px; height: 22px; border-radius: var(--radius-pill); background: var(--line-2); position: relative; transition: background .2s; flex-shrink: 0; cursor: pointer; }
.toggle::after { content:""; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: transform .2s; box-shadow: 0 1px 2px rgba(0,0,0,0.2); }
.toggle.on { background: var(--success); }
.toggle.on::after { transform: translateX(16px); }
.listrow { display: flex; align-items: center; gap: 10px; padding: 13px 16px; border-bottom: 0.5px solid var(--line); cursor: pointer; }
.listrow:last-child { border-bottom: none; }
.listrow .ti { color: var(--ink-2); }
.listrow .lbl { flex: 1; font-size: 14px; }
.listrow .chev { color: var(--ink-3); }
.badge { display: inline-flex; align-items: center; gap: 5px; background: var(--accent-soft); color: var(--accent-deep); font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius: var(--radius-pill); }
.pill-status { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 600; }
.ach-grid { display: grid; grid-template-columns: repeat(4,1fr); gap: 8px; }
.ach { display: flex; flex-direction: column; align-items: center; gap: 4px; text-align: center; }
.ach .medal { width: 42px; height: 42px; border-radius: 50%; background: var(--surface-2); display: flex; align-items: center; justify-content: center; }
.ach .medal svg { width: 21px; height: 21px; }
.ach.locked .medal { background: var(--surface-2); opacity: 0.55; }
.ach .nm { font-size: 10px; color: var(--ink-2); }
.ach.locked .nm { color: var(--ink-3); }
/* modal / sheet */
.overlay {
position: absolute; inset: 0; background: rgba(46,42,36,0.42);
display: flex; align-items: flex-end; justify-content: center; z-index: 20;
opacity: 0; pointer-events: none; transition: opacity .22s;
}
.overlay.show { opacity: 1; pointer-events: auto; }
.sheet {
width: 100%; background: var(--surface); border-radius: 22px 22px 0 0; padding: 8px 16px 18px;
transform: translateY(20px); transition: transform .26s; max-height: 92%; overflow-y: auto;
}
.overlay.show .sheet { transform: translateY(0); }
.sheet-handle { width: 36px; height: 4px; border-radius: 2px; background: var(--line-2); margin: 6px auto 14px; }
.confirm {
width: 240px; background: var(--surface); border-radius: 18px; padding: 22px 18px 16px; text-align: center;
transform: scale(0.94); transition: transform .2s;
}
.overlay.center { align-items: center; }
.overlay.center.show .confirm { transform: scale(1); }
.confirm h4 { font-size: 16px; font-weight: 600; }
.confirm p { font-size: 13px; color: var(--ink-2); margin: 6px 0 18px; }
.confirm .btns { display: flex; gap: 10px; }
.confirm .btn { height: 42px; }
/* annotations for devs */
.anno {
background: #FFF7E8; border: 1px dashed var(--accent); border-radius: 10px;
padding: 9px 11px; font-size: 11.5px; color: #7a5a1e; line-height: 1.5;
}
.anno b { color: #5e4615; }
.section-title { font-size: 12px; color: var(--ink-2); padding: 14px 16px 6px; }
.field { padding: 11px 16px; border-bottom: 0.5px solid var(--line); }
.field .flabel { font-size: 12px; color: var(--ink-2); margin-bottom: 6px; display: flex; align-items: center; gap: 6px; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; }
.chip { font-size: 12.5px; padding: 6px 12px; border-radius: var(--radius-pill); border: 0.5px solid var(--line-2); background: var(--surface-2); cursor: pointer; }
.chip.sel { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-deep); font-weight: 600; }
.photo-add { width: 64px; height: 64px; border-radius: 12px; border: 1px dashed var(--line-2); display: flex; align-items: center; justify-content: center; color: var(--ink-3); cursor: pointer; }
.input-fake { background: var(--surface-2); border-radius: 10px; padding: 10px 12px; font-size: 13px; color: var(--ink-3); }
.hist-row { display: flex; align-items: center; gap: 10px; padding: 11px; background: var(--surface-2); border-radius: var(--radius-sm); cursor: pointer; }
.hist-row .ti { color: var(--ink-2); }
</style>
</head>
<body>
<div class="page-head">
<h1>汪圈 · 遛狗小程序 — 可交互原型 V1</h1>
<p>首版聚焦遛狗单机闭环,覆盖从「打开小程序」到「第一次遛狗」的完整路径。点击屏幕内的按钮可真实跳转、切换状态。橙色虚线框是给开发的交互/逻辑注释,不属于界面本身。完整流程:登录(0a) → 建档(0b) → 首页(1) → 遛狗(2) → 成果卡(3) → 沉淀(4)。底部导航「记录 + 遛狗 + 我的」三 Tab广场、附近、排行榜、信息站均为 pending。</p>
<div class="legend">
<span><i class="dot" style="background:var(--accent)"></i>主行为入口</span>
<span><i class="dot" style="background:var(--success)"></i>进行中状态</span>
<span><i class="dot" style="background:var(--warn)"></i>暂停状态</span>
<span><i class="dot" style="background:var(--danger)"></i>危险/二次确认</span>
<span><i class="dot" style="background:var(--accent);border:2px dashed var(--accent-deep)"></i>开发注释</span>
</div>
</div>
<div class="stage">
<!-- ============ FRAME 0a: 登录/欢迎 ============ -->
<div class="frame-wrap">
<div class="frame-label"><span class="num">0a</span>欢迎 / 微信授权登录</div>
<div class="phone" id="screen-login">
<div class="statusbar"><span>9:41</span><div class="right"><span>●●●</span><span>5G</span><span>100%</span></div></div>
<div class="navbar"></div>
<div class="screen-body" style="display:flex;flex-direction:column;">
<div class="center" style="padding:48px 28px 24px;flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;">
<div style="width:88px;height:88px;border-radius:26px;background:var(--accent);display:flex;align-items:center;justify-content:center;margin-bottom:20px;box-shadow:0 6px 18px rgba(200,126,46,0.3);">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:42px;height:42px;"><use href="#i-walk"/></svg>
</div>
<div style="font-size:22px;font-weight:600;letter-spacing:1px;">汪圈</div>
<div class="muted" style="font-size:13px;margin-top:6px;line-height:1.6;max-width:200px;">每一次遛狗,都值得被记录</div>
</div>
<div class="pad" style="padding-bottom:14px;">
<button class="btn primary" onclick="go('screen-create-dog')"><svg><use href="#i-wechat"/></svg>微信一键登录</button>
<div class="tiny muted center" style="margin-top:14px;line-height:1.5;">登录即代表同意 <span style="color:var(--accent-deep)">用户协议</span><span style="color:var(--accent-deep)">隐私政策</span></div>
</div>
<div class="pad" style="padding-top:0;"><div class="anno"><b>注释:</b>首次打开的第一屏。微信授权登录后,<b>若用户尚未建档任何狗狗 → 直接进建档页0b</b>;已建档老用户 → 跳过,直接进首页。协议/隐私为必须的合规要素。</div></div>
</div>
</div>
<div class="frame-note">登录 → 新用户进建档页;老用户直接进首页。</div>
</div>
<!-- ============ FRAME 0b: 建档 / 编辑狗狗 ============ -->
<div class="frame-wrap">
<div class="frame-label"><span class="num">0b</span>建档 / 编辑狗狗(同一表单)</div>
<div class="phone" id="screen-create-dog">
<div class="statusbar"><span>9:41</span><div class="right"><span>●●●</span><span>5G</span><span>100%</span></div></div>
<div class="navbar"><div class="nav-left" onclick="go('screen-login')"><svg class="ic" style="width:18px;height:18px;"><use href="#i-chev-l"/></svg></div>认识你的狗狗</div>
<div class="screen-body">
<div class="center" style="padding:18px 16px 10px;">
<div style="position:relative;width:78px;margin:0 auto;cursor:pointer;">
<div class="avatar" style="width:78px;height:78px;border:0.5px solid var(--line);"><svg class="ic" style="width:36px;height:36px;"><use href="#i-dog"/></svg></div>
<div style="position:absolute;right:-2px;bottom:-2px;width:26px;height:26px;border-radius:50%;background:var(--accent);border:2px solid var(--bg);display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" style="width:14px;height:14px;"><use href="#i-camera-plus"/></svg></div>
</div>
<div class="tiny muted" style="margin-top:8px;">点击上传头像(可选)</div>
</div>
<div class="field" style="border-top:0.5px solid var(--line);">
<div class="flabel">名字 <span style="color:var(--danger)">*</span></div>
<div class="input-fake" style="color:var(--ink);">豆豆</div>
</div>
<div class="field">
<div class="flabel">犬种 <span style="color:var(--danger)">*</span></div>
<div class="input-fake row" style="justify-content:space-between;"><span style="color:var(--ink);">柯基</span><svg class="ic" style="width:16px;height:16px;color:var(--ink-3)"><use href="#i-chev-r"/></svg></div>
</div>
<div class="field">
<div class="flabel">性别</div>
<div class="chips"><span class="chip sel"></span><span class="chip"></span></div>
</div>
<div class="field">
<div class="flabel">年龄(可选)</div>
<div class="chips"><span class="chip">幼犬</span><span class="chip sel">成犬 3 岁</span><span class="chip">老年犬</span></div>
</div>
<div class="field" style="border-bottom:none;">
<div class="flabel">体重(可选,用于估算狗步)</div>
<div class="input-fake">12 kg</div>
</div>
<div class="pad">
<button class="btn primary" onclick="go('screen-home')"><svg><use href="#i-check"/></svg>完成,开始遛狗</button>
</div>
<div class="pad" style="padding-top:0;"><div class="anno"><b>注释:</b>建档与编辑<b>复用此表单</b>(编辑时预填已有值,标题改「编辑资料」)。仅名字、犬种必填——降低首次门槛。犬种用于狗步换算系数;体重可选做二次校准。完成后进首页,首页即出现这只狗。</div></div>
</div>
</div>
<div class="frame-note">完成 → 首页(狗狗已建档)。狗狗主页右上「编辑」也进此页(预填)。</div>
</div>
<!-- ============ FRAME 0c: 多狗选择弹层 ============ -->
<div class="frame-wrap">
<div class="frame-label"><span class="num">0c</span>多狗选择(点遛狗时)</div>
<div class="phone" id="screen-multidog">
<div class="statusbar"><span>9:41</span><div class="right"><span>●●●</span><span>5G</span><span>100%</span></div></div>
<div class="navbar">我的</div>
<div class="screen-body" style="filter:brightness(0.96);">
<div class="pad row" style="padding-bottom:16px;opacity:0.5;">
<div class="avatar" style="width:50px;height:50px;"><svg class="ic" style="width:26px;height:26px;"><use href="#i-user"/></svg></div>
<div style="flex:1;"><div style="font-size:15px;font-weight:600;">铲屎官小J</div><div class="tiny muted">遛狗 87 次 · 加入 92 天</div></div>
</div>
<div class="section-title" style="opacity:0.5;">我的狗狗</div>
<div class="pad" style="opacity:0.5;"><div class="card row" style="padding:12px;"><div class="avatar" style="width:44px;height:44px;"><svg class="ic" style="width:22px;height:22px;"><use href="#i-dog"/></svg></div><div style="flex:1;"><div style="font-size:14px;font-weight:600;">豆豆</div><div class="tiny muted">柯基</div></div></div></div>
</div>
<div class="overlay show" style="position:absolute;">
<div class="sheet" style="transform:translateY(0);">
<div class="sheet-handle"></div>
<div style="font-size:16px;font-weight:600;text-align:center;margin-bottom:4px;">今天遛哪只?</div>
<div class="tiny muted center" style="margin-bottom:16px;">可多选,一起遛</div>
<div style="display:flex;flex-direction:column;gap:10px;">
<div class="card row" style="padding:12px;border:2px solid var(--accent);"><div class="avatar" style="width:42px;height:42px;"><svg class="ic" style="width:21px;height:21px;"><use href="#i-dog"/></svg></div><div style="flex:1;"><div style="font-size:14px;font-weight:600;">豆豆</div><div class="tiny muted">柯基 · 连续 7 天</div></div><div style="width:24px;height:24px;border-radius:50%;background:var(--accent);display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" style="width:14px;height:14px;"><use href="#i-check"/></svg></div></div>
<div class="card row" style="padding:12px;"><div class="avatar" style="width:42px;height:42px;"><svg class="ic" style="width:21px;height:21px;"><use href="#i-dog"/></svg></div><div style="flex:1;"><div style="font-size:14px;font-weight:600;">奶糖</div><div class="tiny muted">比熊 · 连续 2 天</div></div><div style="width:24px;height:24px;border-radius:50%;border:1.5px solid var(--line-2);"></div></div>
</div>
<div style="margin-top:16px;"><button class="btn primary" onclick="go('screen-walking')"><svg><use href="#i-walk"/></svg>开始遛狗</button></div>
</div>
</div>
</div>
<div class="frame-note">仅多狗用户出现;单狗用户点遛狗直接进遛狗中。选定 → 遛狗中。</div>
</div>
<!-- ============ FRAME 1: 我的(首页/入口) ============ -->
<div class="frame-wrap">
<div class="frame-label"><span class="num">1</span>我的(启动后默认页)</div>
<div class="phone" id="screen-home">
<div class="statusbar"><span>9:41</span><div class="right"><span>●●●</span><span>5G</span><span>100%</span></div></div>
<div class="navbar">我的</div>
<div class="screen-body">
<div class="pad row" style="border-bottom:0.5px solid var(--line);padding-bottom:16px;">
<div class="avatar" style="width:50px;height:50px;"><svg class="ic" style="width:26px;height:26px;"><use href="#i-user"/></svg></div>
<div style="flex:1;">
<div style="font-size:15px;font-weight:600;">铲屎官小J</div>
<div class="tiny muted" style="margin-top:2px;">遛狗 87 次 · 加入 92 天</div>
</div>
<svg class="ic" style="color:var(--ink-3)"><use href="#i-chev-r"/></svg>
</div>
<div class="section-title">我的狗狗</div>
<div class="pad" style="padding-top:0;">
<div class="card row" style="padding:12px;cursor:pointer;" onclick="go('screen-dog')">
<div class="avatar" style="width:44px;height:44px;"><svg class="ic" style="width:22px;height:22px;"><use href="#i-dog"/></svg></div>
<div style="flex:1;">
<div style="font-size:14px;font-weight:600;">豆豆</div>
<div class="tiny" style="margin-top:2px;"><span class="pill-status" style="color:var(--success)"><i class="dot" style="background:var(--success)"></i>连续 7 天</span> · 柯基</div>
</div>
<svg class="ic" style="color:var(--ink-3)"><use href="#i-chev-r"/></svg>
</div>
<div style="margin-top:10px;border:1px dashed var(--line-2);border-radius:var(--radius);padding:12px;display:flex;align-items:center;justify-content:center;gap:6px;color:var(--ink-2);font-size:13px;cursor:pointer;">
<svg class="ic"><use href="#i-plus"/></svg> 添加狗狗
</div>
</div>
<div style="border-top:0.5px solid var(--line);">
<div class="listrow" onclick="go('screen-history')"><svg class="ic ti"><use href="#i-history"/></svg><span class="lbl">遛狗历史</span><svg class="ic chev"><use href="#i-chev-r"/></svg></div>
<div class="listrow" onclick="go('screen-achievements')"><svg class="ic ti"><use href="#i-medal"/></svg><span class="lbl">成就</span><svg class="ic chev"><use href="#i-chev-r"/></svg></div>
<div class="listrow"><svg class="ic ti"><use href="#i-settings"/></svg><span class="lbl">设置</span><svg class="ic chev"><use href="#i-chev-r"/></svg></div>
</div>
<div class="pad"><div class="anno"><b>注释:</b>启动后默认进此页。单只狗时点中央「遛狗」按钮直接开始;多只狗弹出选择。「添加狗狗」为多狗预留。</div></div>
</div>
<div class="tabbar">
<div class="tab" onclick="go('screen-records')"><svg class="ic" style="width:22px;height:22px;"><use href="#i-list"/></svg><span>记录</span></div>
<div class="tab-center">
<div class="tab-fab" onclick="go('screen-walking')"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-walk"/></svg></div>
<span>遛狗</span>
</div>
<div class="tab active"><svg class="ic" style="width:22px;height:22px;"><use href="#i-user"/></svg><span>我的</span></div>
</div>
</div>
<div class="frame-note">点豆豆卡片或「成就」→ 狗狗主页;点中央橙色按钮 → 开始遛狗。</div>
</div>
<!-- ============ FRAME 2: 遛狗中(正常态) ============ -->
<div class="frame-wrap">
<div class="frame-label"><span class="num">2</span>遛狗中 · 进行态</div>
<div class="phone" id="screen-walking">
<div class="statusbar"><span>9:41</span><div class="right"><span>●●●</span><span>5G</span><span>100%</span></div></div>
<div class="navbar" style="font-size:13px;color:var(--ink-2);">
<span class="pill-status" style="color:var(--success)"><i class="dot" style="background:var(--success)"></i>遛狗中</span>
</div>
<div class="screen-body">
<div class="mapfill"><svg class="ic" style="width:26px;height:26px;"><use href="#i-map"/></svg><span class="tiny">地图轨迹(首版可后置)</span></div>
<div class="center" style="padding:18px 16px 8px;">
<div class="tiny muted">已遛时长</div>
<div class="timer" id="live-timer">12:34</div>
</div>
<div class="pad statgrid" style="padding-top:6px;padding-bottom:10px;">
<div class="stat"><div class="v">1.2<span style="font-size:12px;"> km</span></div><div class="k">距离</div></div>
<div class="stat"><div class="v">3,480</div><div class="k">狗步</div></div>
</div>
<div class="row" style="padding:0 16px 12px;justify-content:space-between;">
<span class="row" style="font-size:13px;gap:7px;"><svg class="ic" style="color:var(--ink-2)"><use href="#i-poo"/></svg>便便打卡</span>
<span class="toggle" onclick="this.classList.toggle('on')"></span>
</div>
<div class="pad row" style="gap:10px;padding-top:0;">
<button class="btn ghost" onclick="go('screen-paused')"><svg><use href="#i-pause"/></svg>暂停</button>
<button class="btn danger" onclick="openConfirm()"><svg><use href="#i-stop"/></svg>结束</button>
</div>
<div class="row" style="border-top:0.5px solid var(--line);padding:10px 16px;font-size:12px;color:var(--ink-2);gap:8px;cursor:pointer;">
<svg class="ic" style="width:16px;height:16px;"><use href="#i-dog"/></svg>豆豆 · 柯基 <svg class="ic" style="width:14px;height:14px;margin-left:auto;color:var(--ink-3)"><use href="#i-chev-d"/></svg>
</div>
<div class="pad" style="padding-top:0;"><div class="anno"><b>注释:</b>计时器每秒自增(演示中模拟)。便便打卡为行内开关,遛狗中即时记录。底部可切换当前遛的是哪只狗(单狗不显示箭头)。距离/狗步在未授权定位时降级隐藏。</div></div>
</div>
<div class="tabbar">
<div class="tab" onclick="go('screen-records')"><svg class="ic" style="width:22px;height:22px;"><use href="#i-list"/></svg><span>记录</span></div>
<div class="tab-center"><div class="tab-fab" style="background:var(--success);box-shadow:0 4px 12px rgba(107,142,90,0.35);"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-walk"/></svg></div><span style="color:var(--success)">遛狗中</span></div>
<div class="tab"><svg class="ic" style="width:22px;height:22px;"><use href="#i-user"/></svg><span>我的</span></div>
</div>
<!-- 结束二次确认 -->
<div class="overlay center" id="confirm-end">
<div class="confirm">
<h4>结束这次遛狗?</h4>
<p>豆豆已经走了 12 分钟,<br>确认结束并生成成果卡。</p>
<div class="btns">
<button class="btn ghost" onclick="closeConfirm()">再遛会</button>
<button class="btn primary" onclick="closeConfirm();go('screen-summary')">结束</button>
</div>
</div>
</div>
</div>
<div class="frame-note">「暂停」→ 暂停态;「结束」→ 二次确认 → 成果卡。</div>
</div>
<!-- ============ FRAME 2b: 暂停态 ============ -->
<div class="frame-wrap">
<div class="frame-label"><span class="num">2b</span>遛狗中 · 暂停态</div>
<div class="phone" id="screen-paused">
<div class="statusbar"><span>9:41</span><div class="right"><span>●●●</span><span>5G</span><span>100%</span></div></div>
<div class="navbar" style="font-size:13px;color:var(--warn);">
<span class="pill-status" style="color:var(--warn)"><i class="dot" style="background:var(--warn)"></i>已暂停</span>
</div>
<div class="screen-body">
<div class="mapfill" style="background:var(--surface-2);"><svg class="ic" style="width:26px;height:26px;"><use href="#i-pause"/></svg><span class="tiny">计时已停止</span></div>
<div class="center" style="padding:18px 16px 8px;opacity:0.45;">
<div class="tiny muted">已遛时长</div>
<div class="timer">12:34</div>
</div>
<div class="pad statgrid" style="padding-top:6px;padding-bottom:10px;opacity:0.45;">
<div class="stat"><div class="v">1.2<span style="font-size:12px;"> km</span></div><div class="k">距离</div></div>
<div class="stat"><div class="v">3,480</div><div class="k">狗步</div></div>
</div>
<div class="pad row" style="gap:10px;padding-top:8px;">
<button class="btn primary" onclick="go('screen-walking')"><svg><use href="#i-play"/></svg>继续</button>
<button class="btn danger" onclick="go('screen-summary')"><svg><use href="#i-stop"/></svg>结束</button>
</div>
<div class="row" style="border-top:0.5px solid var(--line);padding:10px 16px;font-size:12px;color:var(--ink-2);gap:8px;margin-top:8px;">
<svg class="ic" style="width:16px;height:16px;"><use href="#i-dog"/></svg>豆豆 · 柯基
</div>
<div class="pad" style="padding-top:0;"><div class="anno"><b>注释:</b>暂停时停止计时,时长/数据置灰,避免误以为仍在记录。主按钮变「继续」(提示恢复)。遛狗常有等狗、捡便便的停顿,暂停防止污染数据。</div></div>
</div>
<div class="tabbar">
<div class="tab" onclick="go('screen-records')"><svg class="ic" style="width:22px;height:22px;"><use href="#i-list"/></svg><span>记录</span></div>
<div class="tab-center"><div class="tab-fab" style="background:var(--warn);box-shadow:none;"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-pause"/></svg></div><span style="color:var(--warn)">已暂停</span></div>
<div class="tab"><svg class="ic" style="width:22px;height:22px;"><use href="#i-user"/></svg><span>我的</span></div>
</div>
</div>
<div class="frame-note">「继续」→ 回到进行态;「结束」→ 成果卡。</div>
</div>
<!-- ============ FRAME 3: 成果卡 ============ -->
<div class="frame-wrap">
<div class="frame-label"><span class="num">3</span>成果卡(遛完总结)</div>
<div class="phone" id="screen-summary">
<div class="statusbar"><span>9:41</span><div class="right"><span>●●●</span><span>5G</span><span>100%</span></div></div>
<div class="navbar">今日成果<div class="nav-right" onclick="go('screen-home')"><svg class="ic" style="width:18px;height:18px;"><use href="#i-x"/></svg></div></div>
<div class="screen-body">
<div class="center" style="padding:18px 16px 14px;border-bottom:0.5px solid var(--line);">
<div class="avatar" style="width:58px;height:58px;margin:0 auto 8px;border:0.5px solid var(--line);"><svg class="ic" style="width:28px;height:28px;"><use href="#i-dog"/></svg></div>
<div style="font-size:15px;font-weight:600;">豆豆今天走了</div>
<div style="margin:2px 0;"><span style="font-size:36px;font-weight:600;letter-spacing:1px;font-variant-numeric:tabular-nums;">3,480</span> <span class="muted" style="font-size:15px;">狗步</span></div>
<div class="tiny" style="color:var(--success);font-weight:600;">超过了昨天的 2,900 步</div>
</div>
<div class="statgrid three center" style="padding:14px 12px;">
<div><div style="font-size:18px;font-weight:600;">12:34</div><div class="tiny muted">时长</div></div>
<div><div style="font-size:18px;font-weight:600;">1.2</div><div class="tiny muted">公里</div></div>
<div><div style="font-size:18px;font-weight:600;color:var(--success);">第7天</div><div class="tiny muted">连续</div></div>
</div>
<div style="margin:0 16px 14px;padding:11px 12px;background:var(--accent-soft);border-radius:var(--radius-sm);display:flex;align-items:center;gap:10px;">
<svg class="ic" style="width:22px;height:22px;color:var(--accent-deep)"><use href="#i-medal"/></svg>
<div><div style="font-size:13px;font-weight:600;color:var(--accent-deep)">解锁成就 · 一周不断</div><div class="tiny" style="color:var(--accent-deep);opacity:.8;">连续遛狗满 7 天</div></div>
</div>
<!-- 选填补充信息 -->
<div class="section-title">补充(可选,跳过也能保存)</div>
<div class="pad" style="padding-top:4px;padding-bottom:8px;">
<div class="row" style="gap:8px;">
<div class="photo-add"><svg class="ic"><use href="#i-camera"/></svg></div>
<div class="input-fake" style="flex:1;">给这次遛狗写点什么…</div>
</div>
</div>
<div class="field" style="border-top:0.5px solid var(--line);">
<div class="flabel"><svg class="ic" style="width:14px;height:14px;"><use href="#i-sun"/></svg>天气</div>
<div class="chips">
<span class="chip sel">☀️ 晴</span><span class="chip">☁️ 阴</span><span class="chip">🌧️ 雨</span>
</div>
</div>
<div class="field">
<div class="flabel"><svg class="ic" style="width:14px;height:14px;"><use href="#i-heart"/></svg>状态</div>
<div class="chips">
<span class="chip sel">遛得开心</span><span class="chip">有点累</span><span class="chip">很兴奋</span>
</div>
</div>
<div class="pad" style="display:flex;flex-direction:column;gap:10px;">
<button class="btn primary"><svg><use href="#i-share"/></svg>生成图片分享到朋友圈</button>
<button class="btn ghost" onclick="go('screen-dog')">完成</button>
</div>
<div class="pad" style="padding-top:0;"><div class="anno"><b>注释:</b>所有需要输入的字段集中在此页且全部选填——遛狗时用户腾不出手。视角是「豆豆走了」非「你遛了」。分享为首选项(拉新入口)。「完成」后记录沉淀进狗狗主页。</div></div>
</div>
</div>
<div class="frame-note">「完成」或「分享」→ 沉淀进狗狗主页近期记录。</div>
</div>
<!-- ============ FRAME 4: 狗狗主页 ============ -->
<div class="frame-wrap">
<div class="frame-label"><span class="num">4</span>狗狗主页</div>
<div class="phone" id="screen-dog">
<div class="statusbar"><span>9:41</span><div class="right"><span>●●●</span><span>5G</span><span>100%</span></div></div>
<div class="navbar"><div class="nav-left" onclick="go('screen-home')"><svg class="ic" style="width:18px;height:18px;"><use href="#i-chev-l"/></svg></div>豆豆<div class="nav-right" onclick="go('screen-create-dog')"><svg class="ic" style="width:16px;height:16px;"><use href="#i-edit"/></svg></div></div>
<div class="screen-body">
<div class="center" style="padding:16px;border-bottom:0.5px solid var(--line);">
<div class="avatar" style="width:62px;height:62px;margin:0 auto 8px;border:0.5px solid var(--line);"><svg class="ic" style="width:30px;height:30px;"><use href="#i-dog"/></svg></div>
<div style="font-size:16px;font-weight:600;">豆豆</div>
<div class="tiny muted">柯基 · 3 岁 · 公</div>
</div>
<div class="statgrid three center" style="padding:14px 12px;border-bottom:0.5px solid var(--line);">
<div><div style="font-size:20px;font-weight:600;">87</div><div class="tiny muted">总次数</div></div>
<div><div style="font-size:20px;font-weight:600;">142</div><div class="tiny muted">总公里</div></div>
<div><div style="font-size:20px;font-weight:600;color:var(--success);">7</div><div class="tiny muted">连续天数</div></div>
</div>
<div class="row" style="padding:14px 16px 8px;justify-content:space-between;">
<span style="font-size:13px;font-weight:600;">成就墙</span><span class="tiny" style="color:var(--ink-3)">3 / 12</span>
</div>
<div class="pad ach-grid" style="padding-top:0;padding-bottom:14px;border-bottom:0.5px solid var(--line);">
<div class="ach"><div class="medal"><svg class="ic" style="color:var(--accent)"><use href="#i-medal"/></svg></div><span class="nm">一周不断</span></div>
<div class="ach"><div class="medal"><svg class="ic" style="color:#5b86b3"><use href="#i-rain"/></svg></div><span class="nm">雨天战士</span></div>
<div class="ach"><div class="medal"><svg class="ic" style="color:var(--success)"><use href="#i-flag"/></svg></div><span class="nm">首遛</span></div>
<div class="ach locked"><div class="medal"><svg class="ic" style="color:var(--ink-3)"><use href="#i-lock"/></svg></div><span class="nm">未解锁</span></div>
</div>
<div style="font-size:13px;font-weight:600;padding:14px 16px 8px;">近期记录</div>
<div class="pad" style="padding-top:0;display:flex;flex-direction:column;gap:8px;">
<div class="hist-row" onclick="go('screen-summary')"><svg class="ic ti"><use href="#i-walk"/></svg><div style="flex:1;"><div style="font-size:13px;">今天 早上</div><div class="tiny muted">12分34秒 · 1.2km · 3480狗步</div></div></div>
<div class="hist-row"><svg class="ic ti"><use href="#i-walk"/></svg><div style="flex:1;"><div style="font-size:13px;">昨天 傍晚</div><div class="tiny muted">18分02秒 · 1.8km · 5100狗步</div></div></div>
</div>
<div class="pad"><div class="anno"><b>注释:</b>累计选「次数/公里/连续天数」,不要配速/卡路里——记录陪伴厚度而非运动表现。成就墙露灰锁制造收集欲。点近期记录可回看成果卡。</div></div>
</div>
<div class="tabbar">
<div class="tab" onclick="go('screen-records')"><svg class="ic" style="width:22px;height:22px;"><use href="#i-list"/></svg><span>记录</span></div>
<div class="tab-center"><div class="tab-fab" onclick="go('screen-walking')"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-walk"/></svg></div><span>遛狗</span></div>
<div class="tab active"><svg class="ic" style="width:22px;height:22px;"><use href="#i-user"/></svg><span>我的</span></div>
</div>
</div>
<div class="frame-note">返回 → 我的页;近期记录 → 回看成果卡。</div>
</div>
<!-- ============ FRAME 5: 遛狗历史 + 手动补记 ============ -->
<div class="frame-wrap">
<div class="frame-label"><span class="num">5</span>遛狗历史 + 手动补记</div>
<div class="phone" id="screen-history">
<div class="statusbar"><span>9:41</span><div class="right"><span>●●●</span><span>5G</span><span>100%</span></div></div>
<div class="navbar"><div class="nav-left" onclick="go('screen-home')"><svg class="ic" style="width:18px;height:18px;"><use href="#i-chev-l"/></svg></div>遛狗历史<div class="nav-right" onclick="openSheet()"><svg class="ic" style="width:18px;height:18px;"><use href="#i-plus"/></svg></div></div>
<div class="screen-body">
<div class="pad" style="display:flex;flex-direction:column;gap:8px;">
<div class="tiny muted" style="padding:4px 2px;">本周</div>
<div class="hist-row"><svg class="ic ti"><use href="#i-walk"/></svg><div style="flex:1;"><div style="font-size:13px;">今天 早上 · 豆豆</div><div class="tiny muted">12分34秒 · 1.2km</div></div></div>
<div class="hist-row"><svg class="ic ti"><use href="#i-walk"/></svg><div style="flex:1;"><div style="font-size:13px;">昨天 傍晚 · 豆豆</div><div class="tiny muted">18分02秒 · 1.8km</div></div></div>
<div class="hist-row" style="border:1px dashed var(--line-2);background:transparent;"><svg class="ic ti" style="color:var(--accent-deep)"><use href="#i-edit"/></svg><div style="flex:1;"><div style="font-size:13px;">前天 · 豆豆 <span class="badge" style="background:var(--surface-2);color:var(--ink-2);">补记</span></div><div class="tiny muted">15分00秒 · 距离未记录</div></div></div>
</div>
<div class="pad"><div class="anno"><b>注释:</b>右上「+」= 手动补记入口(忘记开 App 时事后补)。规则:补记<b>可维持连续打卡不断签</b>,但<b>不计入排行榜</b>(防刷量)。补记记录带标识区分。</div></div>
</div>
<!-- 补记底部弹层 -->
<div class="overlay" id="sheet-add">
<div class="sheet">
<div class="sheet-handle"></div>
<div style="font-size:16px;font-weight:600;text-align:center;margin-bottom:4px;">手动补记</div>
<div class="tiny muted center" style="margin-bottom:14px;">忘记开 App 也能补上这次遛狗</div>
<div class="field" style="border-top:0.5px solid var(--line);"><div class="flabel">哪只狗</div><div class="chips"><span class="chip sel">豆豆</span></div></div>
<div class="field"><div class="flabel">时间</div><div class="input-fake">前天 18:00</div></div>
<div class="field"><div class="flabel">时长</div><div class="input-fake">15 分钟</div></div>
<div class="field" style="border-bottom:none;"><div class="flabel">距离(可选)</div><div class="input-fake">未填写</div></div>
<div class="anno" style="margin:6px 0 12px;">补记不影响连续打卡,但不计入排行榜。</div>
<div class="row" style="gap:10px;">
<button class="btn ghost" onclick="closeSheet()">取消</button>
<button class="btn primary" onclick="closeSheet()">保存补记</button>
</div>
</div>
</div>
<div class="tabbar">
<div class="tab" onclick="go('screen-records')"><svg class="ic" style="width:22px;height:22px;"><use href="#i-list"/></svg><span>记录</span></div>
<div class="tab-center"><div class="tab-fab" onclick="go('screen-walking')"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-walk"/></svg></div><span>遛狗</span></div>
<div class="tab active"><svg class="ic" style="width:22px;height:22px;"><use href="#i-user"/></svg><span>我的</span></div>
</div>
</div>
<div class="frame-note">右上「+」→ 弹出补记表单。</div>
</div>
<!-- ============ FRAME 6: 记录 Tab成就墙概览 + 历史) ============ -->
<div class="frame-wrap">
<div class="frame-label"><span class="num">6</span>记录 Tab左侧 Tab</div>
<div class="phone" id="screen-records">
<div class="statusbar"><span>9:41</span><div class="right"><span>●●●</span><span>5G</span><span>100%</span></div></div>
<div class="navbar">记录</div>
<div class="screen-body">
<div class="row" style="padding:14px 16px 8px;justify-content:space-between;">
<span style="font-size:13px;font-weight:600;">成就墙</span>
<span class="tiny" style="color:var(--accent-deep);cursor:pointer;" onclick="go('screen-achievements')">全部 3/12 </span>
</div>
<div class="pad ach-grid" style="padding-top:0;padding-bottom:14px;cursor:pointer;" onclick="go('screen-achievements')">
<div class="ach"><div class="medal"><svg class="ic" style="color:var(--accent)"><use href="#i-medal"/></svg></div><span class="nm">一周不断</span></div>
<div class="ach"><div class="medal"><svg class="ic" style="color:#5b86b3"><use href="#i-rain"/></svg></div><span class="nm">雨天战士</span></div>
<div class="ach"><div class="medal"><svg class="ic" style="color:var(--success)"><use href="#i-flag"/></svg></div><span class="nm">首遛</span></div>
<div class="ach locked"><div class="medal"><svg class="ic" style="color:var(--ink-3)"><use href="#i-lock"/></svg></div><span class="nm">未解锁</span></div>
</div>
<div class="row" style="padding:6px 16px 8px;justify-content:space-between;border-top:0.5px solid var(--line);">
<span style="font-size:13px;font-weight:600;">遛狗历史</span>
<span class="tiny" style="color:var(--accent-deep);cursor:pointer;" onclick="openSheet()">+ 补记</span>
</div>
<div class="pad" style="padding-top:0;display:flex;flex-direction:column;gap:8px;">
<div class="tiny muted" style="padding:2px;">本周</div>
<div class="hist-row" onclick="go('screen-summary')"><svg class="ic ti"><use href="#i-walk"/></svg><div style="flex:1;"><div style="font-size:13px;">今天 早上 · 豆豆</div><div class="tiny muted">12分34秒 · 1.2km · 3480狗步</div></div></div>
<div class="hist-row"><svg class="ic ti"><use href="#i-walk"/></svg><div style="flex:1;"><div style="font-size:13px;">昨天 傍晚 · 豆豆</div><div class="tiny muted">18分02秒 · 1.8km · 5100狗步</div></div></div>
<div class="hist-row" style="border:1px dashed var(--line-2);background:transparent;"><svg class="ic ti" style="color:var(--accent-deep)"><use href="#i-edit"/></svg><div style="flex:1;"><div style="font-size:13px;">前天 · 豆豆 <span class="badge" style="background:var(--surface-2);color:var(--ink-2);">补记</span></div><div class="tiny muted">15分00秒 · 距离未记录</div></div></div>
</div>
<div class="pad"><div class="anno"><b>注释:</b>「记录」Tab = 成就墙概览(横向)+ 遛狗历史。成就墙点任意处或「全部 3/12」→ 完整成就页。补记入口在此页和成就页都可达。这是左侧 Tab解决之前底部双「遛狗」重名问题。</div></div>
</div>
<div class="tabbar">
<div class="tab active" onclick="go('screen-records')"><svg class="ic" style="width:22px;height:22px;"><use href="#i-list"/></svg><span>记录</span></div>
<div class="tab-center"><div class="tab-fab" onclick="go('screen-walking')"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-walk"/></svg></div><span>遛狗</span></div>
<div class="tab" onclick="go('screen-home')"><svg class="ic" style="width:22px;height:22px;"><use href="#i-user"/></svg><span>我的</span></div>
</div>
</div>
<div class="frame-note">成就墙概览 → 完整成就页;「+ 补记」→ 补记弹层;历史项 → 回看成果卡。</div>
</div>
<!-- ============ FRAME 6b: 记录 Tab 空状态 ============ -->
<div class="frame-wrap">
<div class="frame-label"><span class="num">6b</span>记录 Tab · 空状态(新用户)</div>
<div class="phone" id="screen-records-empty">
<div class="statusbar"><span>9:41</span><div class="right"><span>●●●</span><span>5G</span><span>100%</span></div></div>
<div class="navbar">记录</div>
<div class="screen-body" style="display:flex;flex-direction:column;">
<div class="center" style="padding:40px 28px 24px;flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;">
<div style="width:84px;height:84px;border-radius:50%;background:var(--accent-soft);display:flex;align-items:center;justify-content:center;margin-bottom:18px;">
<svg class="ic" style="width:40px;height:40px;color:var(--accent-deep);stroke-width:1.6;"><use href="#i-paw"/></svg>
</div>
<div style="font-size:16px;font-weight:600;margin-bottom:8px;">豆豆已经在门口等了</div>
<div class="muted" style="font-size:13px;line-height:1.6;max-width:220px;">第一次遛狗,从下面那个橙色按钮开始。走完一程,这里就有它的第一条记录啦。</div>
<button class="btn primary" style="width:auto;padding:0 28px;margin-top:22px;" onclick="go('screen-walking')"><svg><use href="#i-walk"/></svg>带豆豆出门</button>
</div>
<div style="border-top:0.5px solid var(--line);">
<div class="row" style="padding:14px 16px 8px;justify-content:space-between;">
<span style="font-size:13px;font-weight:600;">成就墙</span><span class="tiny" style="color:var(--ink-3)">0 / 12 待解锁</span>
</div>
<div class="pad ach-grid" style="padding-top:0;padding-bottom:16px;">
<div class="ach locked"><div class="medal"><svg class="ic" style="color:var(--ink-3)"><use href="#i-flag"/></svg></div><span class="nm">首遛</span></div>
<div class="ach locked"><div class="medal"><svg class="ic" style="color:var(--ink-3)"><use href="#i-medal"/></svg></div><span class="nm">一周不断</span></div>
<div class="ach locked"><div class="medal"><svg class="ic" style="color:var(--ink-3)"><use href="#i-rain"/></svg></div><span class="nm">雨天战士</span></div>
<div class="ach locked"><div class="medal"><svg class="ic" style="color:var(--ink-3)"><use href="#i-lock"/></svg></div><span class="nm">还有 9 个</span></div>
</div>
</div>
<div class="pad" style="padding-top:0;"><div class="anno"><b>注释:</b>新用户第一次进「记录」Tab 的空状态。文案俏皮且把视线引向中央遛狗按钮,"带豆豆出门"按钮 = 第二条引导路径。成就墙不藏,显示全灰待解锁(含"首遛"),制造期待。遛完第一次即切换到正常态(第 6 屏)。</div></div>
</div>
<div class="tabbar">
<div class="tab active" onclick="go('screen-records')"><svg class="ic" style="width:22px;height:22px;"><use href="#i-list"/></svg><span>记录</span></div>
<div class="tab-center"><div class="tab-fab" onclick="go('screen-walking')"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-walk"/></svg></div><span>遛狗</span></div>
<div class="tab" onclick="go('screen-home')"><svg class="ic" style="width:22px;height:22px;"><use href="#i-user"/></svg><span>我的</span></div>
</div>
</div>
<div class="frame-note">遛完第一次 → 切换为正常态(第 6 屏)。「带豆豆出门」或中央按钮 → 开始遛狗。</div>
</div>
<!-- ============ FRAME 7: 完整成就页 ============ -->
<div class="frame-wrap">
<div class="frame-label"><span class="num">7</span>成就(完整页 · 两入口共用)</div>
<div class="phone" id="screen-achievements">
<div class="statusbar"><span>9:41</span><div class="right"><span>●●●</span><span>5G</span><span>100%</span></div></div>
<div class="navbar"><div class="nav-left" onclick="go('screen-records')"><svg class="ic" style="width:18px;height:18px;"><use href="#i-chev-l"/></svg></div>成就</div>
<div class="screen-body">
<div class="center" style="padding:16px;border-bottom:0.5px solid var(--line);">
<div style="font-size:30px;font-weight:600;font-variant-numeric:tabular-nums;">3<span class="muted" style="font-size:18px;"> / 12</span></div>
<div class="tiny muted">已解锁成就 · 豆豆</div>
</div>
<div class="section-title">已解锁</div>
<div class="pad" style="padding-top:0;display:flex;flex-direction:column;gap:8px;">
<div class="row card" style="padding:11px 12px;gap:11px;">
<div class="medal" style="width:42px;height:42px;border-radius:50%;background:var(--accent-soft);display:flex;align-items:center;justify-content:center;flex-shrink:0;"><svg class="ic" style="color:var(--accent-deep)"><use href="#i-medal"/></svg></div>
<div style="flex:1;"><div style="font-size:13px;font-weight:600;">一周不断</div><div class="tiny muted">连续遛狗满 7 天</div></div>
<span class="tiny muted">今天</span>
</div>
<div class="row card" style="padding:11px 12px;gap:11px;">
<div class="medal" style="width:42px;height:42px;border-radius:50%;background:#E3EBF2;display:flex;align-items:center;justify-content:center;flex-shrink:0;"><svg class="ic" style="color:#5b86b3"><use href="#i-rain"/></svg></div>
<div style="flex:1;"><div style="font-size:13px;font-weight:600;">雨天战士</div><div class="tiny muted">雨天也坚持遛狗</div></div>
<span class="tiny muted">3天前</span>
</div>
<div class="row card" style="padding:11px 12px;gap:11px;">
<div class="medal" style="width:42px;height:42px;border-radius:50%;background:var(--success-soft);display:flex;align-items:center;justify-content:center;flex-shrink:0;"><svg class="ic" style="color:var(--success)"><use href="#i-flag"/></svg></div>
<div style="flex:1;"><div style="font-size:13px;font-weight:600;">首遛</div><div class="tiny muted">第一次记录遛狗</div></div>
<span class="tiny muted">92天前</span>
</div>
</div>
<div class="section-title">未解锁</div>
<div class="pad" style="padding-top:0;display:flex;flex-direction:column;gap:8px;">
<div class="row card" style="padding:11px 12px;gap:11px;opacity:0.6;">
<div class="medal" style="width:42px;height:42px;border-radius:50%;background:var(--surface-2);display:flex;align-items:center;justify-content:center;flex-shrink:0;"><svg class="ic" style="color:var(--ink-3)"><use href="#i-trophy"/></svg></div>
<div style="flex:1;"><div style="font-size:13px;font-weight:600;">满月坚持</div><div class="tiny muted">连续遛狗满 30 天 · 还差 23 天</div></div>
</div>
<div class="row card" style="padding:11px 12px;gap:11px;opacity:0.6;">
<div class="medal" style="width:42px;height:42px;border-radius:50%;background:var(--surface-2);display:flex;align-items:center;justify-content:center;flex-shrink:0;"><svg class="ic" style="color:var(--ink-3)"><use href="#i-walk"/></svg></div>
<div style="flex:1;"><div style="font-size:13px;font-weight:600;">百里同行</div><div class="tiny muted">累计遛狗满 100 公里 · 还差 8 km</div></div>
</div>
<div class="row card" style="padding:11px 12px;gap:11px;opacity:0.6;">
<div class="medal" style="width:42px;height:42px;border-radius:50%;background:var(--surface-2);display:flex;align-items:center;justify-content:center;flex-shrink:0;"><svg class="ic" style="color:var(--ink-3)"><use href="#i-lock"/></svg></div>
<div style="flex:1;"><div style="font-size:13px;font-weight:600;">早起鸟</div><div class="tiny muted">在 7 点前遛狗 10 次</div></div>
</div>
</div>
<div class="pad"><div class="anno"><b>注释:</b>「记录」Tab 和「我的→成就」两个入口指向<b>同一页</b>,文案统一叫「成就」,避免用户误以为是两个东西。未解锁项显示进度(还差 X强化收集与坚持动机。成就基于「连续/累计/场景」而非「最多」。</div></div>
</div>
<div class="tabbar">
<div class="tab active" onclick="go('screen-records')"><svg class="ic" style="width:22px;height:22px;"><use href="#i-list"/></svg><span>记录</span></div>
<div class="tab-center"><div class="tab-fab" onclick="go('screen-walking')"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-walk"/></svg></div><span>遛狗</span></div>
<div class="tab" onclick="go('screen-home')"><svg class="ic" style="width:22px;height:22px;"><use href="#i-user"/></svg><span>我的</span></div>
</div>
</div>
<div class="frame-note">从「记录」Tab 或「我的→成就」均到达此页。返回回到来源页。</div>
</div>
</div>
<!-- SVG icon defs -->
<svg width="0" height="0" style="position:absolute;">
<defs>
<g id="i-walk"><path d="M13 4a1 1 0 1 0 0 2 1 1 0 0 0 0-2"/><path d="M7 21l2.5-7 2-2"/><path d="M11.5 12l1.5-4 3 2 2 1"/><path d="M9.5 7l3-1 2 3"/><path d="M14 14l1 7"/></g>
<g id="i-user"><circle cx="12" cy="8" r="4"/><path d="M5 21v-1a7 7 0 0 1 14 0v1"/></g>
<g id="i-dog"><path d="M11 5l-1-1-2 1v3l2 2"/><path d="M13 5l1-1 2 1v3l-2 2"/><path d="M8 10c-1 1-2 3-2 6 0 2 2 3 6 3s6-1 6-3c0-3-1-5-2-6"/><circle cx="10" cy="14" r="0.6" fill="currentColor"/><circle cx="14" cy="14" r="0.6" fill="currentColor"/><path d="M12 16v1"/></g>
<g id="i-chev-r"><path d="M9 6l6 6-6 6"/></g>
<g id="i-chev-l"><path d="M15 6l-6 6 6 6"/></g>
<g id="i-chev-d"><path d="M6 9l6 6 6-6"/></g>
<g id="i-plus"><path d="M12 5v14M5 12h14"/></g>
<g id="i-history"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 4v4h4"/><path d="M12 8v4l3 2"/></g>
<g id="i-medal"><circle cx="12" cy="14" r="5"/><path d="M9 9L7 3h10l-2 6"/><path d="M12 12l1 2 2 .3-1.5 1.4.4 2-1.9-1-1.9 1 .4-2L9 14l2-.3z"/></g>
<g id="i-settings"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></g>
<g id="i-map"><path d="M9 4L4 6v14l5-2 6 2 5-2V4l-5 2-6-2z"/><path d="M9 4v14M15 6v14"/></g>
<g id="i-poo"><path d="M9 21h6c2 0 3-1 3-2.5 0-1-.5-1.5-1-2 .5-.5 1-1 1-2s-1-2-2.5-2c.3-.5.5-1 .5-1.5 0-1.5-1.5-2.5-3-2.5-.5-2-2-3-3.5-2.5C8 4 7 5 7 6.5c-1.5.3-2.5 1.3-2.5 2.5 0 1 .5 1.7 1 2-1 .5-1.5 1.3-1.5 2.3 0 1 .7 1.7 1.5 2-.5.5-1 1-1 2C4.5 20 5.5 21 9 21z"/></g>
<g id="i-pause"><path d="M9 5v14M15 5v14"/></g>
<g id="i-play"><path d="M7 5l12 7-12 7z"/></g>
<g id="i-stop"><rect x="6" y="6" width="12" height="12" rx="2"/></g>
<g id="i-x"><path d="M6 6l12 12M18 6L6 18"/></g>
<g id="i-share"><path d="M4 12v7a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-7"/><path d="M12 3v13M8 7l4-4 4 4"/></g>
<g id="i-camera"><path d="M4 8h3l1.5-2h7L17 8h3v11H4z"/><circle cx="12" cy="13" r="3.5"/></g>
<g id="i-sun"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5L19 19M5 19l1.5-1.5M17.5 6.5L19 5"/></g>
<g id="i-heart"><path d="M12 20s-7-4.5-7-9.5C5 7 7 5.5 9 5.5c1.5 0 2.5 1 3 2 .5-1 1.5-2 3-2 2 0 4 1.5 4 5C19 15.5 12 20 12 20z"/></g>
<g id="i-rain"><path d="M6 14a4 4 0 0 1 .5-8 5 5 0 0 1 9.5 1 3.5 3.5 0 0 1 0 7"/><path d="M8 18l-1 2M12 18l-1 2M16 18l-1 2"/></g>
<g id="i-flag"><path d="M5 21V4M5 4h11l-2 4 2 4H5"/></g>
<g id="i-lock"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></g>
<g id="i-edit"><path d="M4 20h4L18 10l-4-4L4 16v4z"/><path d="M14 6l4 4"/></g>
<g id="i-list"><path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01"/></g>
<g id="i-trophy"><path d="M7 4h10v4a5 5 0 0 1-10 0z"/><path d="M7 5H4v2a3 3 0 0 0 3 3M17 5h3v2a3 3 0 0 1-3 3"/><path d="M12 13v4M9 21h6M10 17h4l1 4H9z"/></g>
<g id="i-paw"><ellipse cx="12" cy="15" rx="4" ry="3.2"/><ellipse cx="7" cy="10" rx="1.6" ry="2.2"/><ellipse cx="11" cy="8" rx="1.6" ry="2.4"/><ellipse cx="15" cy="9" rx="1.6" ry="2.3"/><ellipse cx="17.5" cy="12.5" rx="1.5" ry="2"/></g>
<g id="i-wechat"><path d="M9 4C5 4 2 6.7 2 10c0 1.9 1 3.6 2.6 4.7L4 17l2.6-1.3c.8.2 1.6.3 2.4.3M16 9c3.3 0 6 2.2 6 5s-2.7 5-6 5c-.7 0-1.4-.1-2-.3L11 20l.5-2.2C10 16.8 10 15 10 14c0-2.8 2.7-5 6-5z"/><circle cx="7" cy="9" r="0.5" fill="currentColor"/><circle cx="11" cy="9" r="0.5" fill="currentColor"/><circle cx="14" cy="13" r="0.5" fill="currentColor"/><circle cx="18" cy="13" r="0.5" fill="currentColor"/></g>
<g id="i-camera-plus"><path d="M4 8h3l1.5-2h7L17 8h3v11H4z"/><circle cx="12" cy="13" r="3"/><path d="M12 11.5v3M10.5 13h3" stroke-width="1.4"/></g>
<g id="i-check"><path d="M5 13l4 4L19 7"/></g>
</defs>
</svg>
<script>
function go(id) {
document.getElementById(id).scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
const el = document.getElementById(id);
el.style.transition = 'box-shadow .3s';
el.style.boxShadow = '0 0 0 3px var(--accent), var(--shadow)';
setTimeout(()=> el.style.boxShadow = 'var(--shadow)', 900);
}
function openConfirm(){ document.getElementById('confirm-end').classList.add('show'); }
function closeConfirm(){ document.getElementById('confirm-end').classList.remove('show'); }
function openSheet(){ document.getElementById('sheet-add').classList.add('show'); }
function closeSheet(){ document.getElementById('sheet-add').classList.remove('show'); }
// 演示用:遛狗中计时器自增
let sec = 12*60+34;
setInterval(()=>{
sec++;
const m = String(Math.floor(sec/60)).padStart(2,'0');
const s = String(sec%60).padStart(2,'0');
const t = document.getElementById('live-timer');
if(t) t.textContent = m+':'+s;
}, 1000);
// chip 单选
document.querySelectorAll('.field .chips').forEach(group=>{
group.querySelectorAll('.chip').forEach(c=>{
c.addEventListener('click', ()=>{
group.querySelectorAll('.chip').forEach(x=>x.classList.remove('sel'));
c.classList.add('sel');
});
});
});
</script>
</body>
</html>