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

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; }