feat: rebuild pet app and backend around core care flows

This commit is contained in:
2026-10-01 13:11:30 +08:00
parent 51ead8f411
commit bfcf8788a4
321 changed files with 4918 additions and 25199 deletions
-170
View File
@@ -1,170 +0,0 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const upload = require('../../utils/upload.js');
const recordTypes = require('../../utils/recordTypes.js');
const { toastErr } = require('../../utils/ui.js');
function pad2(n) {
return n < 10 ? '0' + n : '' + n;
}
function today() {
const d = new Date();
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
}
// "2026-07-30" → "2026.07.30"
function fmtDate(s) {
return (s || '').replace(/-/g, '.');
}
// 后端用 time.Parse(time.RFC3339) 解 occurred_at,只给日期它解不出来会静默回退成
// time.Now()——选「昨天」会存成今天且不报错。时区要带真实偏移:转成 UTC 的 Z 形式
// 落库再转回本地时会把边界日期挪一天。取正午同样是为了远离日界。
function dayToISO(day) {
if (!day) return '';
const off = -new Date().getTimezoneOffset();
const sign = off >= 0 ? '+' : '-';
const a = Math.abs(off);
return day + 'T12:00:00' + sign + pad2(Math.floor(a / 60)) + ':' + pad2(a % 60);
}
Page({
data: {
code: '',
type: {},
fields: [],
values: [], // 和 fields 同下标
pet: {},
date: '',
dateLabel: '',
note: '',
images: [],
saving: false,
},
onLoad(q) {
const code = (q && q.type) || '';
// 从首页「今日任务」勾选进来会带 taskId:这一笔记录保存后要把那条任务标完成
this.taskId = (q && q.taskId) || '';
const d = today();
this.setData({ code, date: d, dateLabel: fmtDate(d) });
store
.ready()
.then(() => recordTypes.load())
.then(() => this.applyType())
.catch((e) => toastErr(e));
},
applyType() {
const t = recordTypes.get(this.data.code);
if (!t) {
wx.showToast({ title: '这个记录类型不存在了', icon: 'none' });
return setTimeout(() => wx.navigateBack(), 800);
}
const fields = t.fields || [];
// 单选默认不预选:预选了用户不点也会存下一个他没确认过的值
this.setData({ type: t, fields, values: fields.map(() => ''), pet: store.getPet() || {} });
},
// 多宠时切换记到哪只身上。用 actionSheet 而不是自己画一个选择器
pickPet() {
const pets = store.getPets() || [];
if (pets.length < 2) return;
wx.showActionSheet({
itemList: pets.map((p) => p.name),
success: (r) => {
const p = pets[r.tapIndex];
store.switchPet(p.id);
this.setData({ pet: p });
},
fail: () => {},
});
},
onDate(e) {
this.setData({ date: e.detail.value, dateLabel: fmtDate(e.detail.value) });
},
onField(e) {
this.setData({ ['values[' + e.currentTarget.dataset.index + ']']: e.detail.value });
},
onPickOpt(e) {
const { index, val } = e.currentTarget.dataset;
// 再点一下取消选择——单选组里没有「不选」按钮,只能靠反选
this.setData({ ['values[' + index + ']']: this.data.values[index] === val ? '' : val });
},
onNote(e) {
this.setData({ note: e.detail.value });
},
onPickImage() {
upload
.chooseAndUploadImage()
.then((f) => this.setData({ images: this.data.images.concat([f]) }))
.catch((e) => {
if (e && e.canceled) return;
toastErr(e, '上传失败');
});
},
onRemoveImage(e) {
const imgs = this.data.images.slice();
imgs.splice(e.currentTarget.dataset.index, 1);
this.setData({ images: imgs });
},
// 按字段配置组装请求体。哪个字段落到 num_value / category 完全由后端的
// to_category / kind 决定,前端不认识「体重」「金额」这些业务词
buildBody() {
const { fields, values } = this.data;
const body = { type: this.data.code, occurred_at: dayToISO(this.data.date) };
const parts = [];
fields.forEach((f, i) => {
const v = (values[i] || '').toString().trim();
if (!v) return;
if (f.kind === 'number') {
const n = parseFloat(v);
if (!isNaN(n)) {
body.num_value = n;
parts.push(v + (f.unit || ''));
}
return;
}
if (f.to_category) body.category = v;
parts.push(v);
});
body.title = parts.length ? this.data.type.label + ':' + parts.join(' ') : this.data.type.label;
body.description = (this.data.note || '').trim();
const img = this.data.images[0];
if (img) {
body.image_file_id = img.id;
body.image_url = img.url;
}
return body;
},
// 必填校验:数值字段空着存下来是一条没有数值的体重记录,趋势图上是个洞
missingField() {
const { fields, values } = this.data;
for (let i = 0; i < fields.length; i++) {
if (fields[i].kind === 'number' && !(values[i] || '').toString().trim()) {
return fields[i].label;
}
}
return '';
},
onSave() {
if (this.data.saving) return;
const id = store.currentPetId();
if (!id) return wx.showToast({ title: '先建一份宠物档案', icon: 'none' });
const miss = this.missingField();
if (miss) return wx.showToast({ title: '请填写' + miss, icon: 'none' });
this.setData({ saving: true });
api
.createRecord(id, this.buildBody())
.then(() => {
// 从任务勾选进来的:记录存好后把那条任务标完成(失败不影响记录已保存)
if (this.taskId) return api.toggleTask(this.taskId).catch(() => {});
})
.then(() => {
this.setData({ saving: false });
wx.showToast({ title: this.taskId ? '已完成' : '已记录', icon: 'success' });
setTimeout(() => wx.navigateBack(), 600);
})
.catch((e) => {
this.setData({ saving: false });
toastErr(e, '保存失败');
});
},
});
-6
View File
@@ -1,6 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pt-icon": "/components/pt-icon/index"
}
}
-70
View File
@@ -1,70 +0,0 @@
<nav-bar title="添加{{type.label}}记录" show-back="{{true}}"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body ar-body">
<!-- 宠物 + 时间。多宠家庭最容易记错到别的宠物身上,所以放第一行 -->
<view class="card ar-card">
<view class="ar-row" bindtap="pickPet">
<text class="ar-k">记录宠物</text>
<text class="ar-v">{{pet.name || '选择'}}</text>
<pt-icon name="next" size="{{26}}"></pt-icon>
</view>
<picker mode="date" value="{{date}}" bindchange="onDate">
<view class="ar-row ar-last">
<text class="ar-k">记录时间</text>
<text class="ar-v">{{dateLabel}}</text>
<pt-icon name="next" size="{{26}}"></pt-icon>
</view>
</picker>
</view>
<!-- 类型专属字段。整段由后端 record_types.fields 驱动,
前端不认识「体重」「金额」这些词,只认识 number / options / text -->
<view wx:if="{{fields.length}}" class="card ar-card">
<block wx:for="{{fields}}" wx:key="index">
<!-- 数值 -->
<view wx:if="{{item.kind === 'number'}}" class="ar-row {{index === fields.length - 1 ? 'ar-last' : ''}}">
<text class="ar-k">{{item.label}}</text>
<input class="ar-in" type="digit" placeholder="填写{{item.label}}" placeholder-class="placeholder"
value="{{values[index]}}" data-index="{{index}}" bindinput="onField"/>
<text wx:if="{{item.unit}}" class="ar-unit">{{item.unit}}</text>
</view>
<!-- 文本 -->
<view wx:elif="{{item.kind === 'text'}}" class="ar-row {{index === fields.length - 1 ? 'ar-last' : ''}}">
<text class="ar-k">{{item.label}}</text>
<input class="ar-in" placeholder="填写{{item.label}}" placeholder-class="placeholder"
value="{{values[index]}}" data-index="{{index}}" bindinput="onField"/>
</view>
<!-- 单选 -->
<view wx:else class="ar-opt-block {{index === fields.length - 1 ? 'ar-last' : ''}}">
<text class="ar-k">{{item.label}}</text>
<view class="ar-opts">
<view wx:for="{{item.options}}" wx:for-item="o" wx:key="*this"
class="ar-opt {{values[index] === o ? 'on' : ''}}"
data-index="{{index}}" data-val="{{o}}" bindtap="onPickOpt">{{o}}</view>
</view>
</view>
</block>
</view>
<view class="card ar-card">
<view class="ar-h">描述</view>
<textarea class="ar-ta" placeholder="请输入你想要记的内容~" placeholder-class="placeholder"
value="{{note}}" maxlength="500" bindinput="onNote"></textarea>
<view class="ar-media">
<view wx:for="{{images}}" wx:key="id" class="ar-thumb">
<image src="{{item.url}}" mode="aspectFill"></image>
<view class="ar-del" catchtap="onRemoveImage" data-index="{{index}}"><pt-icon name="close" size="{{22}}"></pt-icon></view>
</view>
<view wx:if="{{images.length < 3}}" class="ar-add" bindtap="onPickImage">
<pt-icon name="photo" size="{{40}}"></pt-icon>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 保存键固定在底部:表单长了以后不该滚到最下面才能保存 -->
<view class="ar-foot">
<view class="btn btn-primary btn-block {{saving ? 'ar-busy' : ''}}" bindtap="onSave">{{saving ? '保存中…' : '保存'}}</view>
</view>
-50
View File
@@ -1,50 +0,0 @@
/* 底部固定保存键,内容要让开它 */
.ar-body{padding-bottom:calc(180rpx + env(safe-area-inset-bottom))}
.ar-card{padding:0 var(--pad-x)}
.ar-row{
display:flex;align-items:center;gap:var(--sp-3);
min-height:112rpx;border-bottom:1rpx solid var(--line);
}
.ar-last,.ar-row:last-child{border-bottom:0}
.ar-k{flex:none;font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)}
.ar-v{flex:1;text-align:right;font-size:var(--fs-md);color:var(--text-2)}
.ar-in{flex:1;text-align:right;font-size:var(--fs-md);color:var(--text)}
.ar-unit{flex:none;font-size:var(--fs-sm);color:var(--muted)}
/* 单选独占一段:选项多的时候挤在一行右侧会换行成一团 */
.ar-opt-block{padding:var(--sp-4) 0;border-bottom:1rpx solid var(--line)}
.ar-opts{display:flex;flex-wrap:wrap;gap:var(--sp-2);margin-top:var(--sp-3)}
.ar-opt{
padding:var(--sp-2) var(--sp-4);border-radius:var(--r-full);
background:var(--surface-2);border:1rpx solid var(--line);
font-size:var(--fs-sm);color:var(--text-2);
}
.ar-opt.on{
background:var(--primary-soft);border-color:var(--primary);
color:var(--primary-ink);font-weight:var(--fw-b);
}
.ar-h{font-size:var(--fs-md);font-weight:var(--fw-b);padding:var(--sp-4) 0 var(--sp-2)}
.ar-ta{width:100%;min-height:200rpx;font-size:var(--fs-md);line-height:1.6}
.ar-media{display:flex;gap:var(--sp-3);padding:var(--sp-3) 0 var(--sp-4)}
.ar-thumb{position:relative;width:132rpx;height:132rpx;border-radius:var(--r-sm);overflow:hidden}
.ar-thumb image{width:100%;height:100%}
.ar-del{
position:absolute;top:0;right:0;width:40rpx;height:40rpx;
display:flex;align-items:center;justify-content:center;
background:rgba(0,0,0,.55);color:#fff;border-radius:0 var(--r-sm) 0 var(--r-sm);
}
.ar-add{
width:132rpx;height:132rpx;border-radius:var(--r-sm);
background:var(--surface-2);color:var(--muted2);
display:flex;align-items:center;justify-content:center;
}
.ar-foot{
position:fixed;left:0;right:0;bottom:0;z-index:50;
padding:var(--sp-4) var(--pad-x) calc(var(--sp-4) + env(safe-area-inset-bottom));
background:#fff;box-shadow:0 -12rpx 32rpx rgba(70,48,25,.06);
}
.ar-busy{opacity:.6}
-31
View File
@@ -1,31 +0,0 @@
const api = require('../../utils/api.js');
Page({
data: {
article: null,
loading: true,
error: '',
},
onLoad(options) {
const id = options.id;
if (!id) {
this.setData({ loading: false, error: '文章不存在' });
return;
}
api
.getArticle(id)
.then((a) => this.setData({ article: a, loading: false }))
.catch((e) => this.setData({ loading: false, error: e.message || '加载失败' }));
},
// 从文章跳到对应的记录入口
goRecord() {
wx.navigateTo({ url: '/pages/record/record' });
},
onShareAppMessage() {
const a = this.data.article;
return {
title: a ? a.title : '肉垫计划 · 新手养宠知识',
path: a ? `/pages/article/article?id=${a.id}` : '/pages/home/home',
};
},
});
-8
View File
@@ -1,8 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pt-icon": "/components/pt-icon/index"
},
"navigationStyle": "custom",
"navigationBarTitleText": "新手知识"
}
-24
View File
@@ -1,24 +0,0 @@
<nav-bar title="新手知识" show-back="{{true}}"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body no-fab">
<view wx:if="{{loading}}" class="empty lg">加载中…</view>
<view wx:elif="{{error}}" class="empty lg">{{error}}</view>
<block wx:elif="{{article}}">
<view class="art-head">
<view class="art-icon"><pt-icon name="{{article.icon}}" size="{{56}}" fallback="book"></pt-icon></view>
<view class="art-title">{{article.title}}</view>
<view wx:if="{{article.description}}" class="art-desc">{{article.description}}</view>
</view>
<view class="card">
<view wx:if="{{article.content}}" class="art-content">{{article.content}}</view>
<view wx:else class="empty">这篇文章还没有正文。</view>
</view>
<button class="btn btn-primary btn-block" bindtap="goRecord">去记一笔</button>
<view class="art-foot">内容仅供日常照护参考,不能替代医生诊断。</view>
</block>
</view>
</scroll-view>
-15
View File
@@ -1,15 +0,0 @@
.art-head{padding:var(--sp-2) 0 var(--sp-4)}
.art-icon{
width:96rpx;height:96rpx;border-radius:var(--r-md);margin-bottom:var(--sp-3);
display:flex;align-items:center;justify-content:center;
background:var(--primary-soft);color:var(--primary-ink);
}
.art-title{font-size:var(--fs-2xl);font-weight:var(--fw-b);line-height:1.35;letter-spacing:-.5rpx}
.art-desc{margin-top:var(--sp-2);color:var(--muted);font-size:var(--fs-md);line-height:1.6}
.art-content{
font-size:var(--fs-md);line-height:1.85;color:var(--text-2);
white-space:pre-line; /* 正文用真实换行排版 */
}
.art-foot{margin-top:var(--sp-4);text-align:center;color:var(--muted);font-size:var(--fs-cap)}
-187
View File
@@ -1,187 +0,0 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const { toastErr } = require('../../utils/ui.js');
const { syncTabBar } = require('../../utils/tabbar.js');
// 相对时间。社区里关心的是「多久以前发的」,精确到秒没意义
function fmtAgo(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const min = Math.floor((Date.now() - d.getTime()) / 60000);
if (min < 1) return '刚刚';
if (min < 60) return min + ' 分钟前';
if (min < 60 * 24) return Math.floor(min / 60) + ' 小时前';
if (min < 60 * 24 * 7) return Math.floor(min / 1440) + ' 天前';
const p = (n) => (n < 10 ? '0' + n : '' + n);
const sameYear = d.getFullYear() === new Date().getFullYear();
return (sameYear ? '' : d.getFullYear() + '-') + p(d.getMonth() + 1) + '-' + p(d.getDate());
}
const TAG_CLASS = { 求助: 'warn', 经验: 'blue', 精选: 'purple', 避坑: 'red', 晒宠: '' };
function mapPost(p) {
const tags = p.tags || [];
const tag = tags[0] || '';
return {
id: p.id,
author_name: p.author_name,
author_emoji: p.author_emoji || '🐾',
content: p.content,
images: p.images || [],
like_count: p.like_count,
comment_count: p.comment_count,
liked: false,
tag,
tagClass: TAG_CLASS[tag] || '',
is_ai: !!p.is_ai,
user_id: p.user_id,
followed: !!p.followed,
is_self: !!p.is_self,
timeText: fmtAgo(p.created_at),
};
}
Page({
data: {
pet: {},
feedTabs: ['推荐', '关注', '新手求助', '晒宠', '经验'],
feedIdx: 0,
posts: [],
sheetShow: false,
sheetType: '',
sheetPostId: '',
loaded: false,
loadErr: '',
intoView: '',
},
onLoad() {
this._unsub = store.subscribe((pet) => this.setData({ pet }));
},
onUnload() {
if (this._unsub) this._unsub();
},
onShow() {
syncTabBar(this);
store
.ready()
.then(() => {
this.setData({ pet: store.getPet() });
this.loadPosts();
})
.catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' }));
},
loadPosts() {
const tab = this.data.feedTabs[this.data.feedIdx];
this.setData({ loadErr: '' });
api
.listPosts(tab, 1)
.then((page) => this.setData({ posts: (page.list || []).map(mapPost), loaded: true }))
.catch((e) => {
// 不能让「加载失败」显示成「还没有帖子」,那是在骗用户
this.setData({ loadErr: e.message || '加载失败', loaded: true });
toastErr(e);
});
},
// 来自 seg-tabs 的 change 事件
switchFeedTab(e) {
// 切分类必须回到顶部:不然从「推荐」滚到一半切到「关注」,
// 看到的是新列表的中间,会以为内容错乱了
this.setData({ feedIdx: Number(e.detail.index), intoView: 'feed-top' });
this.loadPosts();
},
// 滚动时清掉锚点,否则下次再设同一个值不会触发
onFeedScroll() {
if (this.data.intoView) this.setData({ intoView: '' });
},
likePost(e) {
const i = e.currentTarget.dataset.index;
const post = this.data.posts[i];
api
.likePost(post.id)
.then((res) => {
this.setData({ [`posts[${i}].like_count`]: res.like_count, [`posts[${i}].liked`]: true });
})
.catch((e) => toastErr(e, '点赞失败'));
},
openComments(e) {
this.setData({ sheetPostId: e.currentTarget.dataset.id, sheetType: 'comments', sheetShow: true });
},
openSheet(e) {
this.setData({ sheetType: e.currentTarget.dataset.type, sheetPostId: '', sheetShow: true });
},
closeSheet() {
this.setData({ sheetShow: false });
},
onPosted() {
this.setData({ sheetShow: false });
this.loadPosts();
},
onCommented() {
this.loadPosts();
},
// 点头像或昵称进 TA 的主页。关注做了却没有主页,关注完就石沉大海
goUser(e) {
const id = e.currentTarget.dataset.id;
if (id) wx.navigateTo({ url: `/pages/user/user?id=${id}` });
},
goLearn() {
wx.navigateTo({ url: '/pages/learn/learn' });
},
// 社区的悬浮键本来就该是发帖,不是记录(原来它跳 AI 聊天页)
onCreatePost() {
this.setData({ sheetType: 'createPost', sheetShow: true });
},
// 点缩略图放大预览。只取 http 图(机器人帖的 emoji 占位不参与)
onPreviewImage(e) {
const { urls, cur } = e.currentTarget.dataset;
const list = (urls || []).filter((u) => typeof u === 'string' && u.indexOf('http') === 0);
if (!list.length) return;
wx.previewImage({ current: cur, urls: list });
},
// 长按自己的帖子可删除
onLongPressPost(e) {
const p = this.data.posts[e.currentTarget.dataset.index];
if (!p || !p.is_self) return;
wx.showModal({
title: '删除帖子',
content: '确定删除这条帖子?删除后其他人将看不到。',
confirmColor: '#D9534F',
success: (res) => {
if (!res.confirm) return;
api.deletePost(p.id)
.then(() => { wx.showToast({ title: '已删除', icon: 'success' }); this.loadPosts(); })
.catch((err) => wx.showToast({ title: err.message || '删除失败', icon: 'none' }));
},
});
},
// 关注 / 取关帖子作者
toggleFollow(e) {
const i = e.currentTarget.dataset.index;
const p = this.data.posts[i];
if (!p || !p.user_id || p.is_self) return;
const fn = p.followed ? api.unfollowUser : api.followUser;
fn(p.user_id)
.then(() => {
// 同一作者的所有帖子一起更新状态
const posts = this.data.posts.map((x) =>
x.user_id === p.user_id ? { ...x, followed: !p.followed } : x,
);
this.setData({ posts });
wx.showToast({ title: p.followed ? '已取消关注' : '已关注', icon: 'none' });
if (this.data.feedTabs[this.data.feedIdx] === '关注') this.loadPosts();
})
.catch((err) => wx.showToast({ title: err.message || '操作失败', icon: 'none' }));
},
onShareAppMessage(e) {
// 从帖子上的分享按钮转发:带上该帖内容做标题
if (e && e.from === 'button') {
const c = (e.target.dataset.content || '').slice(0, 40);
return { title: c ? '宠友圈:' + c : '来宠友圈看看大家的毛孩子', path: '/pages/community/community' };
}
return { title: '宠友圈 · 和铲屎官们交流养宠经验', path: '/pages/community/community' };
},
onShareTimeline() {
return { title: '宠友圈 · 和铲屎官们交流养宠经验' };
},
});
-9
View File
@@ -1,9 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"bottom-sheet": "/components/bottom-sheet/bottom-sheet",
"fab": "/components/fab/fab",
"pt-icon": "/components/pt-icon/index",
"seg-tabs": "/components/seg-tabs/index"
}
}
-61
View File
@@ -1,61 +0,0 @@
<wxs module="im">
module.exports.isUrl = function (s) { return s && s.indexOf('http') === 0; }
</wxs>
<nav-bar title="宠友圈"></nav-bar>
<!-- 发帖统一走右下角悬浮按钮,顶部不再放发布按钮 -->
<view class="top-bar feed-bar">
<seg-tabs items="{{feedTabs}}" current="{{feedIdx}}" bind:change="switchFeedTab"></seg-tabs>
</view>
<scroll-view class="page-scroll" scroll-y="{{true}}" scroll-into-view="{{intoView}}" bindscroll="onFeedScroll" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body">
<view id="feed-top"></view>
<view wx:for="{{posts}}" wx:key="id" class="post-card" bindlongpress="onLongPressPost" data-index="{{index}}">
<view class="post-head">
<view class="post-avatar" catchtap="goUser" data-id="{{item.user_id}}">{{item.author_emoji}}</view>
<view class="post-user" catchtap="goUser" data-id="{{item.user_id}}">
<view class="pu-b">{{item.author_name}}<text wx:if="{{item.is_ai}}" class="ai-tag">AI</text></view>
<view class="pu-s">{{item.timeText}}</view>
</view>
<view wx:if="{{!item.is_self && item.user_id}}" class="follow-btn {{item.followed ? 'on' : ''}}"
catchtap="toggleFollow" data-index="{{index}}">{{item.followed ? '已关注' : '+ 关注'}}</view>
<view wx:if="{{item.tag}}" class="tag {{item.tagClass}}">{{item.tag}}</view>
</view>
<view class="post-content">{{item.content}}</view>
<view wx:if="{{item.images.length}}" class="photo-grid">
<block wx:for="{{item.images}}" wx:for-item="img" wx:key="*this">
<image wx:if="{{im.isUrl(img)}}" class="photo-tile" src="{{img}}" mode="aspectFill"
catchtap="onPreviewImage" data-urls="{{item.images}}" data-cur="{{img}}"></image>
<view wx:else class="photo-tile">{{img}}</view>
</block>
</view>
<view class="post-actions">
<view class="pa-btn {{item.liked ? 'liked' : ''}}" data-index="{{index}}" bindtap="likePost">
<pt-icon name="{{item.liked ? 'like-on' : 'like'}}" size="{{32}}"></pt-icon>
<text class="pa-n">{{item.like_count || '点赞'}}</text>
</view>
<view class="pa-btn" data-id="{{item.id}}" bindtap="openComments">
<pt-icon name="comment" size="{{32}}"></pt-icon>
<text class="pa-n">{{item.comment_count || '评论'}}</text>
</view>
<button class="pa-btn pa-share" open-type="share" data-content="{{item.content}}">
<pt-icon name="share" size="{{32}}"></pt-icon>
<text class="pa-n">分享</text>
</button>
</view>
</view>
<view wx:if="{{loadErr}}" class="empty lg">
{{loadErr}}
<view class="retry-btn" bindtap="loadPosts">点击重试</view>
</view>
<view wx:elif="{{loaded && posts.length === 0}}" class="empty lg">
{{feedTabs[feedIdx] === '关注' ? '还没有关注的人,去「推荐」里关注几个宠友吧 🐾' : '还没有帖子,来发第一条吧 🐾'}}
</view>
</view>
</scroll-view>
<fab icon="edit" bind:tap="onCreatePost"></fab>
<bottom-sheet show="{{sheetShow}}" type="{{sheetType}}" post-id="{{sheetPostId}}"
bind:close="closeSheet" bind:posted="onPosted" bind:commented="onCommented"></bottom-sheet>
-19
View File
@@ -1,19 +0,0 @@
.compose-card{margin-bottom:var(--sp-4)}
/* 关注按钮 */
.retry-btn{
margin-top:var(--sp-3);display:inline-block;padding:var(--sp-2) var(--sp-6);border-radius:var(--r-full);
background:var(--primary-soft);color:var(--primary-dark);font-weight:var(--fw-b);font-size:var(--fs-sm);
}
/* 分类标签吸顶,发布按钮收进同一条:原来那个大黑按钮独占 124rpx,
而它是低频操作,不该在每屏都占着首屏 */
.feed-bar{display:flex;align-items:flex-start;gap:var(--sp-3)}
.feed-bar .st{flex:1;min-width:0}
.feed-post{
flex:none;display:flex;align-items:center;gap:4rpx;
height:var(--h-sm);padding:0 var(--sp-3);border-radius:var(--r-full);
background:var(--cta);color:#fff;font-size:var(--fs-sm);font-weight:var(--fw-b);
}
-129
View File
@@ -1,129 +0,0 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const { toastErr } = require('../../utils/ui.js');
// key 要和后端 service.UserThemes 一致,色值只在 profile-head 的 wxss 里
const THEMES = [
{ key: 'warm', label: '暖橙' },
{ key: 'mint', label: '薄荷' },
{ key: 'sky', label: '天青' },
{ key: 'violet', label: '藕紫' },
{ key: 'ink', label: '墨灰' },
];
Page({
data: {
themes: THEMES,
form: { bio: '', bg_url: '', bg_file_id: '', theme: 'warm', show_pets: true },
bioLen: 0,
preview: null,
saving: false,
},
onLoad() {
store
.ready()
.then(() => this.loadCard())
.catch((e) => toastErr(e));
},
loadCard() {
const uid = (store.getUser() || {}).id;
if (!uid) return;
api
.userCard(uid)
.then((card) => {
this.setData({
form: {
bio: card.bio || '',
bg_url: card.bg_url || '',
bg_file_id: '', // 没重新上传就不带这个字段,避免把已有头图覆盖成空
theme: card.theme || 'warm',
show_pets: card.show_pets !== false,
},
bioLen: (card.bio || '').length,
});
this._card = card;
this.syncPreview();
})
.catch((e) => toastErr(e));
},
// 预览喂给 profile-head 的就是一份改过的 card,组件那边不用知道自己在被预览
syncPreview() {
const f = this.data.form;
this.setData({
preview: Object.assign({}, this._card || {}, {
bio: f.bio,
bg_url: f.bg_url,
theme: f.theme,
show_pets: f.show_pets,
}),
});
},
pickBg() {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sizeType: ['compressed'],
success: (res) => {
const path = res.tempFiles[0].tempFilePath;
// 先用本地路径顶上,别让用户对着转圈等上传
this.setData({ 'form.bg_url': path });
this.syncPreview();
wx.showLoading({ title: '上传中…' });
api
.uploadFile(path)
.then((file) => {
wx.hideLoading();
this.setData({ 'form.bg_file_id': file.id, 'form.bg_url': file.url });
this.syncPreview();
})
.catch((e) => {
wx.hideLoading();
this.setData({ 'form.bg_url': (this._card && this._card.bg_url) || '' });
this.syncPreview();
toastErr(e, '上传失败');
});
},
});
},
clearBg() {
// 空字符串是「清掉头图」的显式信号,和「没传」不是一回事
this.setData({ 'form.bg_url': '', 'form.bg_file_id': '' });
this.syncPreview();
},
pickTheme(e) {
this.setData({ 'form.theme': e.currentTarget.dataset.key });
this.syncPreview();
},
onBio(e) {
this.setData({ 'form.bio': e.detail.value, bioLen: e.detail.value.length });
this.syncPreview();
},
onShowPets(e) {
this.setData({ 'form.show_pets': e.detail.value });
this.syncPreview();
},
save() {
if (this.data.saving) return;
const f = this.data.form;
const body = { bio: f.bio, theme: f.theme, show_pets: f.show_pets };
// 只有真的动过头图才带 bg_file_id:新上传带新 id,点了「恢复默认渐变」带空串,
// 两者都没有就不传,让后端保持原样
const had = (this._card && this._card.bg_url) || '';
if (f.bg_file_id) body.bg_file_id = f.bg_file_id;
else if (had && !f.bg_url) body.bg_file_id = '';
this.setData({ saving: true });
api
.updateDecoration(body)
.then((user) => {
this.setData({ saving: false });
store.setUser(user); // 返回的就是改完的 user,别再多请求一次
wx.showToast({ title: '已保存', icon: 'success' });
setTimeout(() => wx.navigateBack(), 600);
})
.catch((e) => {
this.setData({ saving: false });
toastErr(e, '保存失败');
});
},
});
-8
View File
@@ -1,8 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pt-icon": "/components/pt-icon/index",
"profile-head": "/components/profile-head/index"
},
"navigationBarTitleText": "装扮主页"
}
-53
View File
@@ -1,53 +0,0 @@
<nav-bar title="装扮主页" show-back="{{true}}"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body no-fab">
<!-- 预览用的就是主页那个组件本身,不是仿一个。仿的迟早会和真的长得不一样 -->
<profile-head card="{{preview}}" preview="{{true}}"></profile-head>
<view class="card">
<view class="section-head"><view class="sh-title">头图</view></view>
<view class="dc-bg" bindtap="pickBg">
<image wx:if="{{form.bg_url}}" class="dc-bg-img" src="{{form.bg_url}}" mode="aspectFill"></image>
<view wx:else class="dc-bg-empty">
<pt-icon name="photo" size="{{48}}"></pt-icon>
<text class="dc-hint">点这里换一张头图</text>
</view>
</view>
<view wx:if="{{form.bg_url}}" class="link dc-clear" bindtap="clearBg">恢复默认渐变</view>
</view>
<view class="card">
<view class="section-head"><view class="sh-title">主题色</view></view>
<view class="dc-themes">
<view wx:for="{{themes}}" wx:key="key"
class="dc-theme {{form.theme === item.key ? 'on' : ''}}"
data-key="{{item.key}}" bindtap="pickTheme">
<view class="dc-swatch sw-{{item.key}}"></view>
<text class="dc-tname">{{item.label}}</text>
</view>
</view>
</view>
<view class="card">
<view class="section-head">
<view class="sh-title">个性签名</view>
<view class="dc-count">{{bioLen}}/60</view>
</view>
<textarea class="textarea" placeholder="介绍一下你和毛孩子" placeholder-class="placeholder"
value="{{form.bio}}" maxlength="60" bindinput="onBio"></textarea>
</view>
<view class="card">
<view class="dc-row">
<view class="dc-row-main">
<view class="tt-b">展示我的毛孩子</view>
<view class="tt-p">关掉后别人访问你的主页看不到宠物名片墙</view>
</view>
<switch checked="{{form.show_pets}}" color="#F5A947" bindchange="onShowPets"></switch>
</view>
</view>
<view class="btn btn-dark btn-block" bindtap="save">{{saving ? '保存中…' : '保存'}}</view>
</view>
</scroll-view>
-30
View File
@@ -1,30 +0,0 @@
.dc-bg{
height:240rpx;border-radius:var(--r-md);overflow:hidden;
background:var(--surface-2);display:flex;align-items:center;justify-content:center;
}
.dc-bg-img{width:100%;height:100%;display:block}
.dc-bg-empty{display:flex;flex-direction:column;align-items:center;gap:var(--sp-2);color:var(--muted)}
.dc-hint{font-size:var(--fs-cap)}
.dc-clear{margin-top:var(--sp-3);text-align:center}
.dc-themes{display:flex;gap:var(--sp-3)}
.dc-theme{
flex:1;display:flex;flex-direction:column;align-items:center;gap:8rpx;
padding:var(--sp-3) 0;border-radius:var(--r-sm);border:2rpx solid transparent;
}
.dc-theme.on{border-color:var(--primary);background:var(--primary-soft)}
.dc-swatch{width:64rpx;height:64rpx;border-radius:50%;box-shadow:var(--sd-1)}
.dc-tname{font-size:var(--fs-cap);color:var(--muted)}
.dc-theme.on .dc-tname{color:var(--primary-dark);font-weight:var(--fw-b)}
/* 色值和 profile-head 的主题保持一致,改那边这里也要跟着改 */
.sw-warm{background:linear-gradient(135deg,#F5A947,#FFE6BA)}
.sw-mint{background:linear-gradient(135deg,#3FB98A,#D7F3E7)}
.sw-sky{background:linear-gradient(135deg,#4A9BE0,#D8ECFB)}
.sw-violet{background:linear-gradient(135deg,#9B7BE0,#E8DFFA)}
.sw-ink{background:linear-gradient(135deg,#5A5550,#E4DED7)}
.dc-count{font-size:var(--fs-cap);color:var(--muted)}
.dc-row{display:flex;align-items:center;gap:var(--sp-4)}
.dc-row-main{flex:1;min-width:0}
-158
View File
@@ -1,158 +0,0 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const recordTypes = require('../../utils/recordTypes.js');
const { toastErr } = require('../../utils/ui.js');
function pad2(n) {
return n < 10 ? '0' + n : '' + n;
}
function today() {
const d = new Date();
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
}
// 同 addrecord:后端用 time.Parse(time.RFC3339) 解 occurred_at,只给日期解不出来会
// 静默回退成 now;时区要带真实偏移,转 UTC 的 Z 形式落库会把边界日期挪一天
function dayToISO(day) {
if (!day) return '';
const off = -new Date().getTimezoneOffset();
const sign = off >= 0 ? '+' : '-';
const a = Math.abs(off);
return day + 'T12:00:00' + sign + pad2(Math.floor(a / 60)) + ':' + pad2(a % 60);
}
function fmtDay(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.getMonth() + 1 + '月' + d.getDate() + '日';
}
const PAGE = 50;
Page({
data: {
bill: {},
cats: [],
cat: '',
customOn: false,
customCat: '',
amount: '',
date: '',
note: '',
list: [],
saving: false,
},
onLoad() {
this.setData({ date: today() });
store
.ready()
.then(() => recordTypes.load())
.then(() => this.loadCats())
.catch((e) => toastErr(e));
},
onShow() {
if (this._inited) this.reload();
this._inited = true;
},
onReady() {
this.reload();
},
// 类别预置读 record_types 里 cost 的字段配置——后台改一处,
// 这页和 addrecord 同时生效,不会出现两套类别把账单统计切开
loadCats() {
const t = recordTypes.get('cost');
const opt = ((t && t.fields) || []).find((f) => f.kind === 'options');
this.setData({ cats: (opt && opt.options) || [] });
},
reload() {
const id = store.currentPetId();
if (!id) return;
api
.getBill(id, 'month')
.then((bill) => this.setData({ bill: bill || {} }))
.catch(() => {});
api
.getRecords(id, { type: 'cost', page: 1, pageSize: PAGE })
.then((res) => {
const now = new Date();
const list = (res.list || [])
// 只显示本月:接口按时间倒序返回,这里前端切一刀,
// 不为了「本月」再加一个后端参数
.filter((r) => {
const d = new Date(r.occurred_at);
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth();
})
.map((r) => ({ ...r, timeText: fmtDay(r.occurred_at) }));
this.setData({ list });
})
.catch(() => {});
},
onAmount(e) {
this.setData({ amount: e.detail.value });
},
onPickCat(e) {
const v = e.currentTarget.dataset.val;
this.setData({ cat: this.data.cat === v ? '' : v, customOn: false, customCat: '' });
},
toggleCustom() {
const on = !this.data.customOn;
this.setData({ customOn: on, cat: on ? '' : this.data.cat });
},
onCustomCat(e) {
this.setData({ customCat: e.detail.value });
},
onDate(e) {
this.setData({ date: e.detail.value });
},
onNote(e) {
this.setData({ note: e.detail.value });
},
onSave() {
if (this.data.saving) return;
const id = store.currentPetId();
if (!id) return wx.showToast({ title: '先建一份宠物档案', icon: 'none' });
const amt = parseFloat(this.data.amount);
if (!(amt > 0)) return wx.showToast({ title: '填一个金额', icon: 'none' });
const cat = (this.data.customOn ? this.data.customCat : this.data.cat).trim();
if (!cat) return wx.showToast({ title: '选一个类别', icon: 'none' });
this.setData({ saving: true });
// 落的是 type='cost' 的普通记录:金额进 num_value、类别进 category,
// 和 addrecord 走同一条路,报告页的账单聚合照样认
api
.createRecord(id, {
type: 'cost',
title: '记账:' + amt + '元 ' + cat,
num_value: amt,
category: cat,
description: (this.data.note || '').trim(),
occurred_at: dayToISO(this.data.date),
})
.then(() => {
this.setData({ saving: false, amount: '', note: '', customCat: '', customOn: false, cat: '' });
wx.showToast({ title: '已记下', icon: 'success' });
this.reload();
})
.catch((e) => {
this.setData({ saving: false });
toastErr(e, '保存失败');
});
},
// 长按删除。流水里记错一笔很常见,不给删的话账单就一直是错的
onDelete(e) {
const id = e.currentTarget.dataset.id;
wx.showModal({
title: '删掉这笔?',
content: '删了账单统计会跟着变。',
confirmColor: '#EE7D73',
success: (r) => {
if (!r.confirm) return;
api
.deleteRecord(id)
.then(() => this.reload())
.catch((err) => toastErr(err, '删除失败'));
},
});
},
onShareAppMessage() {
return { title: '养宠一个月花了多少?', path: '/pages/expense/expense' };
},
});
-6
View File
@@ -1,6 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pt-icon": "/components/pt-icon/index"
}
}
-71
View File
@@ -1,71 +0,0 @@
<nav-bar title="养宠花销" show-back="{{true}}"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body ex-body">
<!-- 本月合计 + 分类占比。走 /bill 接口,和报告页的账单是同一份数据 -->
<view class="card ex-sum">
<view class="ex-sum-k">{{bill.period || '本月'}}花销</view>
<view class="ex-sum-n">¥{{bill.total || 0}}</view>
<view wx:if="{{bill.max_single}}" class="ex-sum-s">单笔最高 ¥{{bill.max_single}}</view>
<view wx:if="{{bill.categories.length}}" class="ex-cats">
<view wx:for="{{bill.categories}}" wx:key="category" class="ex-cat">
<view class="ex-cat-top"><text>{{item.category}}</text><text>¥{{item.amount}}</text></view>
<view class="bar"><view class="bar-fill" style="width:{{item.percent}}%"></view></view>
</view>
</view>
<view wx:else class="empty">这个月还没记过花销</view>
</view>
<!-- 记一笔 -->
<view class="card">
<view class="section-head"><view class="sh-title">记一笔</view></view>
<view class="ex-amt">
<text class="ex-cur">¥</text>
<input class="ex-in" type="digit" placeholder="0.00" placeholder-class="placeholder"
value="{{amount}}" bindinput="onAmount"/>
</view>
<view class="ex-label">类别</view>
<view class="ex-chips">
<view wx:for="{{cats}}" wx:key="*this"
class="ex-chip {{cat === item ? 'on' : ''}}" data-val="{{item}}" bindtap="onPickCat">{{item}}</view>
<!-- 自定义类别:预置那几个盖不住所有花法(寄养、驱虫药、玩具…) -->
<view class="ex-chip ex-chip-add {{customOn ? 'on' : ''}}" bindtap="toggleCustom">
<pt-icon name="plus" size="{{22}}"></pt-icon>自定义
</view>
</view>
<input wx:if="{{customOn}}" class="input ex-custom" placeholder="填一个类别名,比如 寄养"
placeholder-class="placeholder" value="{{customCat}}" bindinput="onCustomCat"/>
<view class="ex-label">日期</view>
<picker mode="date" value="{{date}}" bindchange="onDate">
<view class="picker-box">{{date}}</view>
</picker>
<view class="ex-label">备注</view>
<input class="input" placeholder="买了什么、在哪买的" placeholder-class="placeholder"
value="{{note}}" bindinput="onNote"/>
<view class="btn btn-primary btn-block ex-save {{saving ? 'ex-busy' : ''}}" bindtap="onSave">
{{saving ? '保存中…' : '记下这笔'}}
</view>
</view>
<!-- 本月流水 -->
<view class="card">
<view class="section-head">
<view class="sh-title">本月流水</view>
<view wx:if="{{list.length}}" class="tiny">{{list.length}} 笔</view>
</view>
<view wx:for="{{list}}" wx:key="id" class="ex-row" data-id="{{item.id}}" bindlongpress="onDelete">
<view class="ex-row-main">
<view class="tt-b">{{item.category || '未分类'}}</view>
<view class="tt-p">{{item.timeText}}{{item.description ? '|' + item.description : ''}}</view>
</view>
<view class="ex-row-amt">¥{{item.num_value}}</view>
</view>
<view wx:if="{{!list.length}}" class="empty">还没有流水。长按某笔可以删除。</view>
</view>
</view>
</scroll-view>
-34
View File
@@ -1,34 +0,0 @@
.ex-body{padding-bottom:var(--pad-b-plain)}
/* 合计卡:数字要够大,这页第一眼就该看到花了多少 */
.ex-sum{text-align:center}
.ex-sum-k{font-size:var(--fs-sm);color:var(--muted)}
.ex-sum-n{font-size:72rpx;font-weight:var(--fw-b);color:var(--text);line-height:1.2;margin:var(--sp-1) 0}
.ex-sum-s{font-size:var(--fs-cap);color:var(--muted2)}
.ex-cats{margin-top:var(--sp-5);text-align:left}
.ex-cat{margin-bottom:var(--sp-3)}
.ex-cat:last-child{margin-bottom:0}
.ex-cat-top{display:flex;justify-content:space-between;font-size:var(--fs-sm);color:var(--text-2);margin-bottom:6rpx}
/* 金额输入做大,和键盘上的数字对得上 */
.ex-amt{display:flex;align-items:baseline;gap:var(--sp-2);padding:var(--sp-4) 0 var(--sp-2);border-bottom:2rpx solid var(--primary-line)}
.ex-cur{font-size:var(--fs-xl);font-weight:var(--fw-b);color:var(--primary-dark)}
.ex-in{flex:1;font-size:64rpx;font-weight:var(--fw-b);color:var(--text);height:84rpx}
.ex-label{font-size:var(--fs-sm);color:var(--muted);margin:var(--sp-4) 0 var(--sp-2)}
.ex-chips{display:flex;flex-wrap:wrap;gap:var(--sp-2)}
.ex-chip{
padding:var(--sp-2) var(--sp-4);border-radius:var(--r-full);
background:var(--surface-2);border:1rpx solid var(--line);
font-size:var(--fs-sm);color:var(--text-2);
}
.ex-chip.on{background:var(--primary-soft);border-color:var(--primary);color:var(--primary-ink);font-weight:var(--fw-b)}
.ex-chip-add{display:inline-flex;align-items:center;gap:4rpx}
.ex-custom{margin-top:var(--sp-3)}
.ex-save{margin-top:var(--sp-5)}
.ex-busy{opacity:.6}
.ex-row{display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-3) 0;border-bottom:1rpx solid var(--line)}
.ex-row:last-of-type{border-bottom:0}
.ex-row-main{flex:1;min-width:0}
.ex-row-amt{flex:none;font-size:var(--fs-lg);font-weight:var(--fw-b);color:var(--text)}
-149
View File
@@ -1,149 +0,0 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const { toastErr } = require('../../utils/ui.js');
const recordTypes = require('../../utils/recordTypes.js');
function pad(n) {
return n < 10 ? '0' + n : '' + n;
}
function fmtTime(iso) {
if (!iso) return '';
const d = new Date(iso);
const now = new Date();
const hm = pad(d.getHours()) + ':' + pad(d.getMinutes());
if (d.toDateString() === now.toDateString()) return '今天 ' + hm;
const y = new Date(now.getTime() - 86400000);
if (d.toDateString() === y.toDateString()) return '昨天 ' + hm;
return d.getMonth() + 1 + '月' + d.getDate() + '日';
}
// 每条记录的分组配色。这页只读历史,引一份轻量的 code→组 映射就够
function toneOf(type) {
const t = recordTypes.get(type);
return (t && t.tone) || 'tone-1';
}
function mapRecord(r) {
return {
id: r.id,
// 图标按 type 取(可靠);老数据 icon 字段里的 emoji 只作兜底
type: r.type || r.icon || '',
tone: toneOf(r.type),
title: r.title,
desc: fmtTime(r.occurred_at) + (r.description ? '|' + r.description : ''),
image: r.image_url || '',
};
}
// 时间轴类型筛选。常用的一组,不是全部 24 种——seg-tabs 放满会挤成一条难点的横条
const REC_FILTERS = [
{ key: '', label: '全部' },
{ key: 'weight', label: '体重' },
{ key: 'poop', label: '排便' },
{ key: 'food', label: '饮食' },
{ key: 'symptom', label: '异常' },
{ key: 'vaccine', label: '疫苗' },
{ key: 'cost', label: '记账' },
{ key: 'photo', label: '照片' },
];
const REC_PAGE = 8;
Page({
data: {
pet: {},
timeline: [],
recPage: 1,
recTotal: 0,
recHasMore: false,
recFilters: REC_FILTERS,
recFilterIdx: 0,
},
onLoad() {
this._unsub = store.subscribe((pet) => {
this.setData({ pet });
if (!this._inited) return;
this.loadRecords();
});
},
onUnload() {
if (this._unsub) this._unsub();
},
onShow() {
// 分组配色要读类型缓存。这页可能直接从报告页进来,缓存没经过首页热过,
// 自己拉一次(load 内部只真请求一次),拉完刷新已有列表的配色
recordTypes.load().then(() => this.setData({ timeline: this.data.timeline.map((t) => ({ ...t, tone: toneOf(t.type) })) })).catch(() => {});
store
.ready()
.then(() => {
this._inited = true;
this.setData({ pet: store.getPet() });
this.loadRecords();
})
.catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' }));
},
curFilter() {
return this.data.recFilters[this.data.recFilterIdx].key;
},
onFilterTap(e) {
const i = Number(e.detail.index);
if (i === this.data.recFilterIdx) return;
this.setData({ recFilterIdx: i }, () => this.loadRecords());
},
loadRecords() {
const id = store.currentPetId();
if (!id) return;
api
.getRecords(id, { page: 1, pageSize: REC_PAGE, type: this.curFilter() })
.then((res) => {
const list = (res.list || []).map(mapRecord);
this.setData({ timeline: list, recPage: 1, recTotal: res.total || 0, recHasMore: list.length < (res.total || 0) });
})
.catch((e) => toastErr(e));
},
loadMoreRecords() {
const id = store.currentPetId();
if (!id) return;
const next = this.data.recPage + 1;
api
.getRecords(id, { page: next, pageSize: REC_PAGE, type: this.curFilter() })
.then((res) => {
const merged = this.data.timeline.concat((res.list || []).map(mapRecord));
this.setData({ timeline: merged, recPage: next, recTotal: res.total || 0, recHasMore: merged.length < (res.total || 0) });
})
.catch(() => {});
},
collapseRecords() {
this.loadRecords();
},
onDeleteRecord(e) {
const r = this.data.timeline[e.currentTarget.dataset.index];
if (!r || !r.id) return;
wx.showModal({
title: '删除记录',
content: '确定删除「' + r.title + '」?删除后不可恢复。',
confirmColor: '#D9534F',
success: (res) => {
if (!res.confirm) return;
api
.deleteRecord(r.id)
.then(() => {
wx.showToast({ title: '已删除', icon: 'success' });
this.loadRecords();
})
.catch((err) => wx.showToast({ title: err.message || '删除失败', icon: 'none' }));
},
});
},
previewImage(e) {
const src = e.currentTarget.dataset.src;
if (src) wx.previewImage({ urls: [src], current: src });
},
onAddPet() {
wx.navigateTo({ url: '/pages/petform/petform' });
},
onShareAppMessage() {
return { title: '用肉垫计划记录毛孩子的成长', path: '/pages/record/record' };
},
onShareTimeline() {
return { title: '用肉垫计划记录毛孩子的成长' };
},
});
-8
View File
@@ -1,8 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pet-switch": "/components/pet-switch/pet-switch",
"pt-icon": "/components/pt-icon/index",
"seg-tabs": "/components/seg-tabs/index"
}
}
-36
View File
@@ -1,36 +0,0 @@
<nav-bar title="全部记录" show-back="{{true}}"></nav-bar>
<view class="top-bar on-band"><pet-switch bind:add="onAddPet"></pet-switch></view>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body no-fab">
<view wx:if="{{!pet.id}}" class="no-pet">
<view class="no-pet-ic"><pt-icon name="community" size="{{52}}"></pt-icon></view>
<view class="no-pet-b">还没有毛孩子的档案</view>
<view class="no-pet-p">建好档并记上几笔,这里就会长出完整的记录流水。</view>
<view class="btn btn-dark" bindtap="onAddPet"><pt-icon name="plus" size="{{30}}"></pt-icon>建一份档案</view>
</view>
<!-- 全部记录时间轴。洞察和体重趋势已经提到报告页,这里只做「翻流水 + 按类型筛」 -->
<block wx:else>
<seg-tabs items="{{recFilters}}" current="{{recFilterIdx}}" bind:change="onFilterTap"></seg-tabs>
<view class="card">
<view class="health-timeline">
<view wx:for="{{timeline}}" wx:key="id" class="health-event"
bindlongpress="onDeleteRecord" data-index="{{index}}">
<view class="event-dot {{item.tone}}"><pt-icon name="{{item.type}}" size="{{34}}" fallback="note"></pt-icon></view>
<view class="he-body">
<text class="he-b">{{item.title}}</text><view class="he-p">{{item.desc}}</view>
<image wx:if="{{item.image}}" class="he-img" src="{{item.image}}" mode="aspectFill" catchtap="previewImage" data-src="{{item.image}}"></image>
</view>
</view>
<view wx:if="{{timeline.length === 0}}" class="empty lg">这个分类下还没有记录</view>
</view>
<view wx:if="{{timeline.length}}" class="tl-tip">长按任意一条可删除</view>
<view wx:if="{{recHasMore}}" class="tl-more" bindtap="loadMoreRecords">查看更多(共 {{recTotal}} 条)</view>
<view wx:elif="{{recPage > 1}}" class="tl-more" bindtap="collapseRecords">收起</view>
</view>
</block>
</view>
</scroll-view>
-19
View File
@@ -1,19 +0,0 @@
/* 全部记录时间轴。洞察和体重趋势已经搬到报告页,这里只留流水 + 筛选 */
.health-timeline{display:flex;flex-direction:column;gap:var(--sp-2)}
.health-event{display:flex;gap:var(--sp-3);background:var(--surface-2);border-radius:var(--r-md);padding:var(--sp-4)}
/* 底色和文字色由 .tone-N 提供(记录类型决定),这里只管形状 */
.event-dot{
width:64rpx;height:64rpx;border-radius:var(--r-sm);flex:none;
display:flex;align-items:center;justify-content:center;
}
.he-b{font-size:var(--fs-md);font-weight:var(--fw-b)}
.he-p{color:var(--muted);font-size:var(--fs-sm);line-height:1.45;margin-top:6rpx}
.he-body{flex:1;min-width:0}
.he-img{margin-top:var(--sp-2);width:220rpx;height:220rpx;border-radius:var(--r-sm)}
.tl-tip{margin-top:var(--sp-3);text-align:center;color:var(--muted);font-size:var(--fs-cap)}
.tl-more{
margin-top:var(--sp-3);padding:var(--sp-3);text-align:center;color:var(--primary-dark);
background:var(--surface-2);border-radius:var(--r-md);font-size:var(--fs-sm);font-weight:var(--fw-b);
}
+53 -221
View File
@@ -1,236 +1,68 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const { toastErr } = require('../../utils/ui.js');
const { syncTabBar } = require('../../utils/tabbar.js');
const recordTypes = require('../../utils/recordTypes.js');
const petmeta = require('../../utils/petmeta.js');
function mapTask(t) {
return { id: t.id, title: t.title, sub: t.description, priority: t.priority, done: t.done, sheet: t.sheet_type };
}
// 时间轴上的相对时间。首页关心的是「什么时候记的」,精确到分只在今天有意义
function fmtWhen(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const p = (n) => (n < 10 ? '0' + n : '' + n);
const now = new Date();
const sameDay = d.toDateString() === now.toDateString();
if (sameDay) return '今天 ' + p(d.getHours()) + ':' + p(d.getMinutes());
const y = new Date(now.getTime() - 86400000);
if (d.toDateString() === y.toDateString()) return '昨天 ' + p(d.getHours()) + ':' + p(d.getMinutes());
return d.getMonth() + 1 + '月' + d.getDate() + '日';
}
// 首页最近记录只是速览,固定最新几条,翻全量去「全部记录」页,避免首页无限滚
const TL_LIMIT = 6;
const { secure, toast } = require("../../utils/api");
Page({
data: {
pet: {},
zodiac: '',
daysTogether: 0,
sexIcon: '',
sexTone: '',
pets: [],
selected: [],
tasks: [],
planHint: '',
drift: {},
needNextPlan: false,
monthCost: 0,
timeline: [],
hasMore: false,
},
onLoad() {
this._unsub = store.subscribe((pet) => {
this.applyPet(pet);
if (!this._inited) return;
this.loadAll();
});
},
onUnload() {
if (this._unsub) this._unsub();
walkCount: 0,
name: "铲屎官",
loading: true,
},
onShow() {
syncTabBar(this);
recordTypes.load().catch(() => {});
store
.ready()
.then(() => {
this._inited = true;
this.applyPet(store.getPet());
this.loadAll();
})
.catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' }));
this.load();
},
// 门面上那几样都是本地算的,不用等接口
applyPet(pet) {
const p = pet || {};
const badge = petmeta.genderBadge(p.gender);
this.setData({
pet: p,
zodiac: petmeta.zodiac(p.birthday),
daysTogether: petmeta.daysTogether(p),
sexIcon: badge.icon,
sexTone: badge.tone,
});
async load() {
try {
const [pets, tasks, walks, me] = await Promise.all([
secure("/pets"),
secure("/tasks"),
secure("/walks"),
secure("/me"),
]);
const chosen = wx.getStorageSync("walk_pet_ids") || [],
month = new Date().toISOString().slice(0, 7);
this.setData({
pets,
selected: chosen.filter((id) => pets.some((p) => p.id === id)),
tasks: tasks.items,
walkCount: walks.filter((w) => w.started_at.slice(0, 7) === month)
.length,
name: me.nickname || "铲屎官",
loading: false,
});
} catch (e) {
this.setData({ loading: false });
toast(e);
}
},
loadAll() {
this.loadTasks();
this.loadTimeline(1);
this.loadEntries();
this.loadDrift();
pick(e) {
this.setData({ selected: e.detail.selected });
wx.setStorageSync("walk_pet_ids", e.detail.selected);
},
loadTasks() {
const id = store.currentPetId();
if (!id) return;
api
.getTasks(id)
.then((tasks) => this.setData({ tasks: (tasks || []).map(mapTask) }))
.catch((e) => toastErr(e));
},
// 两个入口上的副标题:计划还剩几件、本月花了多少。
// 空着的话入口就是两个没有信息量的图标
// 阶段漂移检查。放在 loadEntries 里而不是单独一次 —— 它和「计划还剩几件」
// 都是首页那一屏要的,一起拉省一个来回
loadDrift() {
const id = store.currentPetId();
if (!id) return;
api
.petStageDrift(id)
.then((d) => this.setData({ drift: d || {} }))
.catch(() => this.setData({ drift: {} }));
},
loadEntries() {
const id = store.currentPetId();
if (!id) return;
// 副标题优先说「该排下月计划了」——那是有时效的事;
// 本月还剩几件是随时能看的,让位给它
api
.planMonthStatus(id)
.then((st) => {
const s = st || {};
this.setData({
needNextPlan: !!s.need_next,
planHint: s.need_next
? (s.next_label || '下月') + '计划还没排'
: s.this_undone > 0
? '本月还有 ' + s.this_undone + ' 件'
: s.this_month > 0
? '本月都做完了'
: '还没有安排',
});
})
.catch(() => this.setData({ needNextPlan: false, planHint: '' }));
api
.getBill(id, 'month')
.then((b) => this.setData({ monthCost: (b && b.total) || 0 }))
.catch(() => this.setData({ monthCost: 0 }));
},
loadTimeline() {
const id = store.currentPetId();
if (!id) return;
api
.getRecords(id, { page: 1, pageSize: TL_LIMIT })
.then((res) => {
const list = (res.list || []).map((r) => ({
id: r.id,
type: r.type,
tone: (recordTypes.get(r.type) || {}).tone || 'tone-1',
title: r.title,
description: r.description,
image: r.image_url || '',
timeText: fmtWhen(r.occurred_at),
}));
// 有更多就露出「查看全部」入口,跳到能分页筛选的全部记录页
this.setData({ timeline: list, hasMore: (res.total || 0) > list.length });
})
.catch(() => {});
},
goHistory() {
wx.navigateTo({ url: '/pages/history/history' });
},
previewImage(e) {
const url = e.currentTarget.dataset.url;
if (url) wx.previewImage({ urls: [url], current: url });
},
onDeleteRecord(e) {
const id = e.currentTarget.dataset.id;
wx.showModal({
title: '删掉这条记录?',
content: '趋势和统计会跟着变。',
confirmColor: '#EE7D73',
success: (r) => {
if (!r.confirm) return;
api
.deleteRecord(id)
.then(() => this.loadTimeline(1))
.catch((err) => toastErr(err, '删除失败'));
},
});
},
onTapTask(e) {
const i = e.currentTarget.dataset.index;
const t = this.data.tasks[i];
// 任务关联了记录类型:去记那一笔。未完成的带上 taskId,
// 在记录页保存成功后才回填这条任务完成(onShow 回来自动刷新任务+最近记录)
if (t.sheet) {
let url = '/pages/addrecord/addrecord?type=' + t.sheet;
if (!t.done) url += '&taskId=' + t.id;
wx.navigateTo({ url });
start() {
if (!this.data.selected.length) {
wx.showToast({ title: "先选择一起出门的狗狗", icon: "none" });
return;
}
// 没有关联记录类型的任务(自定义/无 sheet_type):直接切换完成。
// 后端 toggle 会在完成时补一条 note 记录,这里完成后刷新最近记录即可
api
.toggleTask(t.id)
.then((res) => {
const tasks = this.data.tasks.slice();
tasks[i] = Object.assign({}, tasks[i], { done: res.done });
this.setData({ tasks });
if (res.done) this.loadTimeline(1);
})
.catch((err) => toastErr(err, '操作失败'));
wx.redirectTo({ url: "/pages/walk/walk" });
},
goPlan() {
wx.navigateTo({ url: '/pages/plan/plan' });
goTasks() {
wx.redirectTo({ url: "/pages/tasks/tasks" });
},
// 去建档页改阶段。改完回来 onShow 会重新拉一次漂移状态,提示自然消失。
// 不在首页直接改:改阶段之后要不要套新方案是同一件事,放在一起做才连贯
// 点「一起生活的天数」空态,去建档页补到家日期
onDaysTap() {
const id = this.data.pet && this.data.pet.id;
if (!this.data.daysTogether && id) wx.navigateTo({ url: '/pages/petform/petform?id=' + id });
goWalks() {
wx.redirectTo({ url: "/pages/walk/walk" });
},
// 身份卡:门面卡右上角进,用当前宠物
goIDCard() {
const id = this.data.pet && this.data.pet.id;
if (id) wx.navigateTo({ url: '/pages/idcard/idcard?id=' + id });
},
goStageUpdate() {
const id = this.data.pet && this.data.pet.id;
if (id) wx.navigateTo({ url: '/pages/petform/petform?id=' + id });
},
goExpense() {
wx.navigateTo({ url: '/pages/expense/expense' });
},
goRecord() {
wx.navigateTo({ url: '/pages/record/record' });
},
onQuickRecord() {
wx.navigateTo({ url: '/pages/record/record' });
},
onAddPet() {
wx.navigateTo({ url: '/pages/petform/petform' });
},
onHeroTap() {
const id = this.data.pet && this.data.pet.id;
wx.navigateTo({ url: '/pages/petform/petform' + (id ? '?id=' + id : '') });
},
onShareAppMessage() {
return { title: '肉垫计划 · 每天照顾好毛孩子', path: '/pages/home/home' };
},
onShareTimeline() {
return { title: '肉垫计划 · 每天照顾好毛孩子' };
async complete(e) {
const id = e.currentTarget.dataset.id,
task = this.data.tasks.find((t) => t.id === id);
try {
await secure("/tasks/" + id + "/completion", "PUT", {
date: new Date().toLocaleDateString("sv-SE"),
completed: !task.completed,
});
await this.load();
} catch (err) {
toast(err);
}
},
});
+1 -8
View File
@@ -1,8 +1 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pet-switch": "/components/pet-switch/pet-switch",
"fab": "/components/fab/fab",
"pt-icon": "/components/pt-icon/index"
}
}
{ "usingComponents": { "tab-bar": "/components/tab-bar/index", "pet-picker": "/components/pet-picker/index" } }
+72 -105
View File
@@ -1,116 +1,83 @@
<nav-bar title="肉垫计划"></nav-bar>
<view class="top-bar on-band"><pet-switch bind:add="onAddPet"></pet-switch></view>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body">
<!-- 门面:宠物名片。点进去编辑档案 -->
<view class="card hero-card" bindtap="onHeroTap">
<view class="hero-row">
<view class="hero-av">
<image wx:if="{{pet.avatar_url}}" class="hero-av-img" src="{{pet.avatar_url}}" mode="aspectFill"></image>
<block wx:else>{{pet.emoji || '🐾'}}</block>
<view wx:if="{{sexIcon}}" class="hero-sex {{sexTone}}">{{sexIcon}}</view>
</view>
<view class="hero-main">
<view class="hero-title">{{pet.name || '还没有毛孩子'}}</view>
<view class="hero-meta">
<text wx:if="{{pet.breed}}">{{pet.breed}}</text>
<text wx:if="{{pet.age}}">{{pet.age}}</text>
<text wx:if="{{zodiac}}">{{zodiac}}</text>
</view>
</view>
<view wx:if="{{pet.id}}" class="hero-idcard" catchtap="goIDCard">
<pt-icon name="user" size="{{30}}"></pt-icon>身份卡
<view class="screen">
<view class="eyebrow">你好,今天也一起好好生活</view>
<view class="row top">
<view class="title">早安,{{name}} ☀️</view>
<image src="/assets/logo-corgi-v2.png" class="avatar" />
</view>
<view class="hero">
<view class="hero-copy">
<text class="hero-small">今日的主角</text>
<text class="hero-title">小短腿,出发吧!</text>
<text class="hero-meta">多只狗狗 · 每一天都是好日子</text>
<view class="hero-caption">● 陪伴它们的每一天,都值得记录</view>
</view>
<image src="/assets/corgi-family-five.png" mode="aspectFit" />
</view>
<view class="row heading">
<text class="section-title">带它们出去走走</text>
<text class="link" bindtap="goWalks">遛狗记录 ›</text>
</view>
<view class="card walk-card">
<view class="walk-intro">
<view class="dog-emoji">🐕</view>
<view>
<view class="walk-title">今天的风刚刚好</view>
<view class="muted">选择同行的狗狗,一起出门转转。</view>
</view>
</view>
<!-- 一起生活的天数从到家日算。没填到家日就显示「-」,点一下去补 -->
<view wx:if="{{pet.id}}" class="hero-days {{daysTogether ? '' : 'hero-days-empty'}}" catchtap="onDaysTap">
<block wx:if="{{daysTogether}}">我们一起生活的第 <text class="hd-n">{{daysTogether}}</text> 天</block>
<block wx:else>还没填到家日期,一起生活的天数显示 <text class="hd-n">-</text></block>
<view class="picker-label">这次带谁?可以多选</view>
<pet-picker pets="{{pets}}" selected="{{selected}}" bind:change="pick" />
<button class="primary start" bindtap="start">开始遛狗 →</button>
</view>
<view class="stats">
<view>
<text>本月遛狗</text>
<strong>
{{walkCount}}
<small>次</small>
</strong>
</view>
<view>
<text>一起出发</text>
<strong>
{{selected.length}}
<small>只</small>
</strong>
</view>
<view>
<text>今日任务</text>
<strong>
{{tasks.length}}
<small>项</small>
</strong>
</view>
</view>
<!-- 阶段漂移。系统只负责发现,换不换由用户点 —— 偷偷换会让他莫名发现
计划变了却找不到原因,而且已经排好的这个月不该被推翻 -->
<view wx:if="{{drift.drifted}}" class="card drift-card" bindtap="goStageUpdate">
<view class="drift-top">
<pt-icon name="trend" size="{{32}}"></pt-icon>
<view class="drift-b">{{pet.name}}已经 {{drift.age_label}},从{{drift.stored}}进入{{drift.computed}}了</view>
</view>
<view class="drift-p">{{drift.basis}}</view>
<view class="drift-focus">这个阶段的重点:{{drift.focus}}</view>
<view wx:if="{{drift.size}}" class="drift-size">{{drift.size}}</view>
<view class="btn btn-primary btn-block drift-cta">去更新阶段和方案</view>
<view class="row heading">
<text class="section-title">今天要做的事</text>
<text class="link" bindtap="goTasks">全部任务 ›</text>
</view>
<!-- 两个入口。竞品那一排是买商品/上豪车/领猫砂盆,是电商和广告位,去掉 -->
<view class="card entry-card">
<view class="entry" bindtap="goPlan">
<view class="entry-ic tone-1">
<pt-icon name="plan" size="{{48}}"></pt-icon>
<!-- 月底还没排下月计划时提醒。角标做在图标上而不是弹 toast:
每次进首页弹一下会很烦,而这件事不紧急、看见就行 -->
<view wx:if="{{needNextPlan}}" class="entry-dot"></view>
<view class="card tasks">
<view
wx:for="{{tasks}}"
wx:key="id"
class="task"
data-id="{{item.id}}"
bindtap="complete"
>
<view class="check {{item.completed?'done':''}}">
{{item.completed?'✓':''}}
</view>
<view class="entry-b">管理计划</view>
<view class="entry-p {{needNextPlan ? 'warn' : ''}}">{{planHint}}</view>
</view>
<view class="entry" bindtap="goExpense">
<view class="entry-ic tone-2"><pt-icon name="cost" size="{{48}}"></pt-icon></view>
<view class="entry-b">记花销</view>
<view class="entry-p">{{monthCost > 0 ? '本月 ¥' + monthCost : '还没记过'}}</view>
</view>
</view>
<!-- 今日任务 -->
<view wx:if="{{tasks.length}}" class="card">
<view class="section-head">
<view class="sh-title">今日任务</view>
<!-- 任务的增删改都在计划页 —— 合并之后首页这个「管理」不该再开一个弹层 -->
<view class="head-links">
<view class="link" bindtap="goPlan">管理</view>
<view class="task-icon">
{{item.category==='water'?'💧':item.category==='food'?'🥣':'🐾'}}
</view>
<view class="task-main">
<text class="task-name">{{item.title}}</text>
<text class="muted">{{item.time_of_day}}</text>
</view>
</view>
<view class="task-list">
<view wx:for="{{tasks}}" wx:key="id" class="task {{item.done ? 'done' : ''}}" data-index="{{index}}" bindtap="onTapTask">
<view class="circle"><pt-icon wx:if="{{item.done}}" name="check" size="{{22}}"></pt-icon></view>
<view class="task-text"><text class="tt-b">{{item.title}}</text><view wx:if="{{item.sub}}" class="tt-p">{{item.sub}}</view></view>
<view wx:if="{{item.priority}}" class="priority">{{item.priority}}</view>
</view>
<view wx:if="{{!tasks.length}}" class="empty">
今天还没有任务,给狗狗安排一件小事吧 🐾
</view>
</view>
<!-- 记录时间轴。首页原来是任务/日历/接下来的堆叠,看不到「我记了什么」;
日历挪回计划页了(它本来就属于那儿),这里换成时间轴 —— 首页应该是
一条能往下翻的流水,而不是一屏聚合卡 -->
<view class="tl-head">
<view class="sh-title">最近记录</view>
<view class="link" bindtap="goRecord">+ 记一笔</view>
</view>
<view wx:for="{{timeline}}" wx:key="id" class="tl-item">
<view class="tl-rail">
<view class="tl-dot {{item.tone}}"><pt-icon name="{{item.type}}" size="{{28}}" fallback="note"></pt-icon></view>
<view class="tl-line"></view>
</view>
<view class="tl-body">
<view class="tl-when">{{item.timeText}}</view>
<view class="tl-card" data-id="{{item.id}}" bindlongpress="onDeleteRecord">
<view class="tl-title">{{item.title}}</view>
<view wx:if="{{item.description}}" class="tl-desc">{{item.description}}</view>
<image wx:if="{{item.image}}" class="tl-img" src="{{item.image}}" mode="aspectFill"
data-url="{{item.image}}" catchtap="previewImage"></image>
</view>
</view>
</view>
<view wx:if="{{!timeline.length}}" class="card">
<view class="empty lg">还没有记录。点上面的「记一笔」开始,
体重趋势和健康洞察都会从这些记录里长出来。</view>
</view>
<view wx:if="{{hasMore}}" class="tl-more" bindtap="goHistory">查看全部记录 ›</view>
</view>
</scroll-view>
<fab paw bind:tap="onQuickRecord"></fab>
<tab-bar current="/pages/home/home" />
+165 -94
View File
@@ -1,102 +1,173 @@
/* 门面卡 */
.hero-card{
background:
radial-gradient(circle at 95% 16%, rgba(245,169,71,.22), transparent 32%),
linear-gradient(135deg,#FFFFFF,#FFF6E9);
.top {
margin-top: 10rpx;
}
/* 一周日期条 */
/* ===== 日期带(原计划页「日历」tab 并过来) ===== */
/* 某天的安排 */
.dd-title{font-weight:var(--fw-b);font-size:var(--fs-md);margin-bottom:var(--sp-3)}
/* ===== 接下来 ===== */
/* ===== 门面:宠物名片 ===== */
.hero-card{padding:var(--sp-5)}
.hero-row{display:flex;align-items:center;gap:var(--sp-4)}
.hero-av{
position:relative;flex:none;width:128rpx;height:128rpx;border-radius:50%;
background:var(--primary-soft);display:flex;align-items:center;justify-content:center;
font-size:60rpx;overflow:visible;
.avatar {
width: 70rpx;
height: 70rpx;
border-radius: 50%;
border: 6rpx solid white;
box-shadow: 0 5rpx 22rpx #174b3520;
}
.hero-av-img{width:100%;height:100%;border-radius:50%}
/* 性别徽章叠在头像右下:一个很省事但很出效果的细节 */
.hero-sex{
position:absolute;right:-4rpx;bottom:-4rpx;width:42rpx;height:42rpx;border-radius:50%;
border:4rpx solid #fff;color:#fff;font-size:22rpx;line-height:1;
display:flex;align-items:center;justify-content:center;
.hero {
height: 330rpx;
background: #194f42;
border-radius: 38rpx;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
padding: 35rpx;
color: white;
}
.hero-sex.male{background:#5B9BE8}
.hero-sex.female{background:#EE85A8}
.hero-main{flex:1;min-width:0}
.hero-title{font-size:var(--fs-xl);font-weight:var(--fw-b);color:var(--text)}
.hero-meta{display:flex;flex-wrap:wrap;gap:var(--sp-3);font-size:var(--fs-sm);color:var(--muted);margin-top:6rpx}
.hero-arrow{flex:none;color:var(--muted2);display:flex;align-items:center}
.hero-days{
margin-top:var(--sp-4);background:var(--primary-soft);color:var(--primary-ink);
border-radius:var(--r-md);text-align:center;padding:var(--sp-3);
font-size:var(--fs-md);font-weight:var(--fw-b);
.hero-copy {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
width: 62%;
}
.hd-n{font-size:var(--fs-lg);margin:0 4rpx}
/* ===== 两个入口 ===== */
.entry-card{display:flex;padding:var(--sp-4) var(--sp-3)}
.entry{flex:1;display:flex;flex-direction:column;align-items:center;gap:4rpx}
.entry-ic{
width:76rpx;height:76rpx;border-radius:var(--r-md);
display:flex;align-items:center;justify-content:center;margin-bottom:2rpx;
.hero-small {
color: #abd9b8;
font-size: 23rpx;
font-weight: 700;
}
.entry-b{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)}
.entry-p{font-size:var(--fs-cap);color:var(--muted)}
/* ===== 记录时间轴 ===== */
.tl-head{display:flex;align-items:center;justify-content:space-between;margin:var(--sp-5) 0 var(--sp-3)}
.tl-item{display:flex;gap:var(--sp-3)}
.tl-rail{flex:none;width:56rpx;display:flex;flex-direction:column;align-items:center}
.tl-dot{
width:56rpx;height:56rpx;border-radius:50%;flex:none;
display:flex;align-items:center;justify-content:center;
.hero-title {
font-size: 43rpx;
font-weight: 800;
margin: 16rpx 0;
}
/* 竖线连起来才像一条时间轴;最后一条的线会被 body 的高度自然截断 */
.tl-line{flex:1;width:2rpx;background:var(--line);margin:6rpx 0}
.tl-item:last-of-type .tl-line{display:none}
.tl-body{flex:1;min-width:0;padding-bottom:var(--sp-4)}
.tl-when{font-size:var(--fs-cap);color:var(--muted);margin-bottom:6rpx}
.tl-card{background:#fff;border-radius:var(--r-md);box-shadow:var(--sd-1);padding:var(--sp-4)}
.tl-title{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)}
.tl-desc{font-size:var(--fs-sm);color:var(--text-2);margin-top:6rpx;line-height:1.5}
.tl-img{width:100%;height:320rpx;border-radius:var(--r-sm);margin-top:var(--sp-3);display:block}
.tl-more{text-align:center;padding:var(--sp-4);font-size:var(--fs-sm);color:var(--muted)}
.entry-ic{position:relative}
.entry-dot{
position:absolute;top:-2rpx;right:-2rpx;width:18rpx;height:18rpx;
border-radius:50%;background:var(--red);border:3rpx solid #fff;
.hero-meta {
font-size: 23rpx;
color: #d9eadd;
}
.entry-p.warn{color:var(--red-ink);font-weight:var(--fw-b)}
/* 阶段漂移提示。用主色不用红色:这是「可以做一下」不是「出问题了」 */
.drift-card{border:2rpx solid var(--primary-line);background:var(--primary-soft)}
.drift-top{display:flex;align-items:flex-start;gap:var(--sp-2);color:var(--primary-dark)}
.drift-b{flex:1;min-width:0;font-size:var(--fs-md);font-weight:var(--fw-b);line-height:1.5}
.drift-p{font-size:var(--fs-sm);color:var(--primary-ink);line-height:1.6;margin-top:var(--sp-2)}
.drift-focus{font-size:var(--fs-sm);color:var(--primary-ink);line-height:1.6;margin-top:6rpx}
.drift-size{font-size:var(--fs-cap);color:var(--primary-dark);opacity:.8;margin-top:6rpx}
.drift-cta{margin-top:var(--sp-4)}
/* 没填到家日期时的天数空态:压成灰、可点去补 */
.hero-days-empty{background:var(--surface-2);color:var(--muted)}
.hero-days-empty .hd-n{color:var(--muted)}
/* 门面卡右上角的身份卡入口。原来那里是个 next 箭头(点整卡进编辑),
身份卡做成小胶囊,和整卡点击分开 */
.hero-idcard{
flex:none;align-self:flex-start;display:flex;align-items:center;gap:4rpx;
padding:8rpx var(--sp-3);border-radius:var(--r-full);
background:var(--primary-soft);color:var(--primary-dark);
font-size:var(--fs-cap);font-weight:var(--fw-b);
.hero-caption {
font-size: 20rpx;
color: #b5e2be;
margin-top: 36rpx;
}
.hero image {
position: absolute;
width: 310rpx;
height: 290rpx;
right: -24rpx;
bottom: 8rpx;
}
.heading {
margin-top: 8rpx;
}
.link {
font-size: 24rpx;
color: #398668;
font-weight: 700;
}
.walk-card {
padding: 26rpx;
}
.walk-intro {
display: flex;
gap: 22rpx;
align-items: center;
}
.dog-emoji {
width: 145rpx;
height: 145rpx;
border-radius: 26rpx;
background: #dfeedd;
display: grid;
place-items: center;
font-size: 72rpx;
}
.walk-title {
font-size: 31rpx;
font-weight: 800;
margin-bottom: 12rpx;
}
.walk-intro .muted {
font-size: 23rpx;
}
.picker-label {
font-size: 23rpx;
color: #7d9989;
margin: 27rpx 0 16rpx;
}
.start {
margin: 24rpx 0 0;
width: 100%;
}
.stats {
display: flex;
gap: 14rpx;
margin-top: 20rpx;
}
.stats > view {
flex: 1;
background: white;
border-radius: 28rpx;
padding: 25rpx 18rpx;
}
.stats > view:nth-child(2) {
background: #f7eadc;
}
.stats > view:nth-child(3) {
background: #e8efea;
}
.stats text {
display: block;
color: #8da49a;
font-size: 22rpx;
}
.stats strong {
display: block;
font-size: 40rpx;
margin-top: 12rpx;
}
.stats small {
font-size: 22rpx;
color: #94a69d;
}
.tasks {
padding: 12rpx 25rpx;
}
.task {
display: flex;
align-items: center;
gap: 17rpx;
padding: 22rpx 0;
border-bottom: 1rpx solid #edf1ed;
}
.task:last-child {
border: 0;
}
.check {
width: 40rpx;
height: 40rpx;
border: 3rpx solid #cfdfd2;
border-radius: 13rpx;
text-align: center;
line-height: 37rpx;
color: white;
}
.check.done {
background: #3b9164;
border-color: #3b9164;
}
.task-icon {
width: 65rpx;
height: 65rpx;
border-radius: 16rpx;
background: #f0f5ee;
display: grid;
place-items: center;
font-size: 34rpx;
}
.task-main {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.task-name {
font-weight: 700;
}
.task-main .muted {
font-size: 22rpx;
}
-419
View File
@@ -1,419 +0,0 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const { toastErr } = require('../../utils/ui.js');
// 卡片画布尺寸(逻辑像素)。真实导出会按 dpr 放大
const W = 620;
const H = 1480;
// 在 canvas 2d 上加载一张图(远程或本地),resolve 出可直接 drawImage 的 image 对象。
// 加载失败 resolve(null)——头像或小程序码缺一张不该让整张卡画不出来
function loadImage(canvas, src) {
return new Promise((resolve) => {
if (!src) return resolve(null);
const img = canvas.createImage();
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
img.src = src;
});
}
Page({
data: {
petId: '',
card: null,
cardImg: '',
qrReady: false,
saving: false,
needPhone: false, // 没手机号:门槛,先验证才生成
phoneRefused: false, // 用户拒绝了授权
manualMode: false, // 手动填手机号(getPhoneNumber 唤不起时的兜底)
manualPhone: '',
},
onLoad(q) {
store
.ready()
.then(() => {
const id = (q && q.id) || store.currentPetId();
if (!id) {
wx.showToast({ title: '先选一只毛孩子', icon: 'none' });
return;
}
this.setData({ petId: id });
return api.petIDCard(id);
})
.then((card) => {
if (!card) return;
this.setData({ card, qrReady: !!card.qr_ready });
// 门槛:没手机号不生成,先引导用微信验证
if (!card.phone) {
this.setData({ needPhone: true });
return;
}
// 布局要等页面渲染完 canvas 节点才拿得到,放 nextTick
wx.nextTick(() => this.draw());
})
.catch((e) => toastErr(e));
},
draw() {
wx.createSelectorQuery()
.select('#idCanvas')
.fields({ node: true, size: true })
.exec((res) => {
const node = res && res[0] && res[0].node;
if (!node) return;
let dpr = 2;
try {
dpr = (wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync()).pixelRatio || 2;
} catch (e) {
dpr = 2;
}
this._dpr = dpr;
node.width = W * dpr;
node.height = H * dpr;
const ctx = node.getContext('2d');
ctx.scale(dpr, dpr);
const c = this.data.card || {};
// 两张远程图都就绪再开画,否则小程序码会画不上或画一半(plan 里点过名的坑)
Promise.all([loadImage(node, c.avatar_url), loadImage(node, c.qr_url)]).then(([avatar, qr]) => {
this.paint(ctx, node, c, avatar, qr);
});
});
},
paint(ctx, node, c, avatar, qr) {
ctx.clearRect(0, 0, W, H);
// 外层留白背景
ctx.fillStyle = '#F3EFE8';
ctx.fillRect(0, 0, W, H);
const pad = 30;
const cardW = W - pad * 2;
const fx = pad, cardW2 = cardW;
// ══════════ 正面卡(暖黄)══════════
// 正反两张卡等高(都用 cardH),中间内容垂直居中,避免正面留空
const cardH = 510;
const fH = cardH;
const fY = pad;
const gf = ctx.createLinearGradient(fx, fY, fx, fY + fH);
gf.addColorStop(0, '#FFF7E0');
gf.addColorStop(1, '#FFE7AE');
this.roundRect(ctx, fx, fY, cardW2, fH, 30);
ctx.fillStyle = gf;
ctx.fill();
// 头像圆 + 白边
const avD = 150, avX = fx + 36, avY = fY + 52;
ctx.save();
ctx.beginPath();
ctx.arc(avX + avD / 2, avY + avD / 2, avD / 2, 0, Math.PI * 2);
ctx.clip();
if (avatar) {
const iw = avatar.width || avD, ih = avatar.height || avD, side = Math.min(iw, ih);
ctx.drawImage(avatar, (iw - side) / 2, (ih - side) / 2, side, side, avX, avY, avD, avD);
} else {
ctx.fillStyle = '#FFD9A3';
ctx.fillRect(avX, avY, avD, avD);
ctx.fillStyle = '#8A5A18';
ctx.font = '64px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(c.species === '狗狗' ? '🐶' : '🐱', avX + avD / 2, avY + avD / 2);
}
ctx.restore();
ctx.textBaseline = 'alphabetic';
ctx.beginPath();
ctx.arc(avX + avD / 2, avY + avD / 2, avD / 2, 0, Math.PI * 2);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 8;
ctx.stroke();
// 名字 + 性别(头像右侧)。性别符号必须用「名字的字号」量宽度再定位,
// 否则换成小字号后 measureText 量出的宽度偏小,符号会怼到名字上
const nx = avX + avD + 28;
const nm = c.name || '毛孩子';
ctx.textAlign = 'left';
ctx.fillStyle = '#2D2925';
ctx.font = '700 52px sans-serif';
ctx.fillText(nm, nx, avY + 46);
const nmW = ctx.measureText(nm).width;
if (c.gender) {
const isF = c.gender === '女孩';
ctx.fillStyle = isF ? '#E86A9B' : '#5B9BE8';
ctx.font = '600 34px sans-serif';
ctx.fillText(isF ? '♀' : '♂', nx + nmW + 16, avY + 44);
}
// 头像右的资料行
const kvF = (label, value, yy) => {
ctx.textAlign = 'left';
ctx.font = '24px sans-serif';
ctx.fillStyle = '#A98C5A';
ctx.fillText(label, nx, yy);
ctx.fillStyle = '#3D3427';
ctx.font = '600 26px sans-serif';
ctx.fillText(value || '—', nx + 78, yy);
};
kvF('性别', c.gender, avY + 92);
kvF('品种', c.breed, avY + 130);
kvF('毛色', c.color, avY + 168);
kvF('年龄', c.age, avY + 206);
// 介绍 quote(在 info 和底栏之间垂直居中)
if (c.intro) {
const ty = fY + 300 + Math.max(0, Math.floor(((fY + fH - 92) - (fY + 300) - 60) / 2));
ctx.fillStyle = 'rgba(255,255,255,.6)';
this.roundRect(ctx, fx + 34, ty, cardW2 - 68, 60, 16);
ctx.fill();
ctx.fillStyle = '#7A5A2E';
ctx.font = '25px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('“ ' + c.intro + ' ”', fx + 54, ty + 40);
}
// 底部:身份ID + 一起生活天数(贴卡片底边)
ctx.textAlign = 'left';
ctx.fillStyle = '#9C7A3E';
ctx.font = '22px sans-serif';
ctx.fillText('ID ' + (c.id_no || ''), fx + 34, fY + fH - 56);
if (c.days > 0) {
ctx.fillText('和主人一起生活的第 ' + c.days + ' 天', fx + 34, fY + fH - 22);
}
// 正面印章(右上角)
this.drawStamp(ctx, fx + cardW2 - 78, fY + 82);
// ══════════ 照护卡(淡紫,仿证件版式)══════════
const bY = fY + fH + 40, bH = cardH;
const gbc = ctx.createLinearGradient(fx, bY, fx, bY + bH);
gbc.addColorStop(0, '#E6E7FB');
gbc.addColorStop(1, '#F2F0FA');
this.roundRect(ctx, fx, bY, cardW2, bH, 30);
ctx.fillStyle = gbc;
ctx.fill();
// 大标题(两行左对齐):小字「X星人地球移民」+ 大字「居民身份卡」
// 汪星人=狗、喵星人=猫,其它兜底宠星人
const star = c.species === '狗狗' ? '汪' : c.species === '猫猫' ? '喵' : '宠';
ctx.textAlign = 'left';
ctx.fillStyle = '#8A90AE';
ctx.font = '600 27px sans-serif';
this.spacedText(ctx, star + '星人地球移民', fx + 44, bY + 64, 8);
ctx.fillStyle = '#3E4A63';
ctx.font = '800 58px sans-serif';
ctx.fillText('居民身份卡', fx + 44, bY + 140);
// 资料行(标签 + 值)
const rowX = fx + 44;
const kvB = (label, value, yy) => {
ctx.textAlign = 'left';
ctx.font = '25px sans-serif';
ctx.fillStyle = '#9A9FB5';
ctx.fillText(label, rowX, yy);
ctx.font = '600 26px sans-serif';
ctx.fillStyle = '#3E4A63';
ctx.fillText(value || '—', rowX + 160, yy);
};
let ry = bY + 198;
kvB('主人', c.owner, ry); ry += 46;
kvB('联系电话', c.phone || '未验证', ry); ry += 46;
kvB('签发机构', c.org || '肉垫计划', ry); ry += 46;
kvB('有效期限', c.validity || '永久有效', ry);
// 小程序码(右下角,直接落在淡紫底上)
const qrD = 114, qrX = fx + cardW2 - 44 - qrD, qrY = bY + bH - 30 - qrD;
ctx.fillStyle = '#fff';
this.roundRect(ctx, qrX, qrY, qrD, qrD, 14);
ctx.fill();
if (qr) {
ctx.drawImage(qr, qrX + 9, qrY + 9, qrD - 18, qrD - 18);
} else {
ctx.fillStyle = '#A7ABC4';
ctx.font = '18px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('小程序码', qrX + qrD / 2, qrY + qrD / 2 - 2);
ctx.fillText('待发布', qrX + qrD / 2, qrY + qrD / 2 + 24);
}
// 扫码说明(左侧,和小程序码同高)
ctx.textAlign = 'left';
ctx.fillStyle = '#8A90AE';
ctx.font = '22px sans-serif';
ctx.fillText('微信扫一扫', fx + 44, qrY + qrD / 2 - 6);
ctx.fillText('查看' + (c.name || '') + '的身份信息', fx + 44, qrY + qrD / 2 + 28);
// 背面印章(右上角)
this.drawStamp(ctx, fx + cardW2 - 84, bY + 92);
// 只导出到实际内容底部,避免卡片下面拖一条空白
const dpr = this._dpr || 2;
const contentH = bY + bH + pad;
wx.canvasToTempFilePath(
{
canvas: node,
x: 0,
y: 0,
width: W,
height: contentH,
destWidth: W * dpr,
destHeight: contentH * dpr,
fileType: 'png',
success: (r) => this.setData({ cardImg: r.tempFilePath }),
fail: () => wx.showToast({ title: '生成失败,稍后再试', icon: 'none' }),
},
this,
);
},
roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
},
// 逐字加字距地画一行(canvas 的 letterSpacing 各端支持不一,手动排最稳)
spacedText(ctx, text, x, y, gap) {
let cx = x;
for (const ch of text) {
ctx.fillText(ch, cx, y);
cx += ctx.measureText(ch).width + gap;
}
},
// 五角星路径(调用方负责 fill/stroke)
star(ctx, cx, cy, rO, rI, n) {
ctx.beginPath();
for (let i = 0; i < n * 2; i++) {
const r = i % 2 === 0 ? rO : rI;
const a = -Math.PI / 2 + (i * Math.PI) / n;
const x = cx + r * Math.cos(a), y = cy + r * Math.sin(a);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
},
// 红色公章:双圈 + 顶部五角星 + 「肉垫计划 / 官方认证」,略微旋转、半透明
drawStamp(ctx, cx, cy) {
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(-0.16);
ctx.globalAlpha = 0.68;
ctx.strokeStyle = '#CE4534';
ctx.fillStyle = '#CE4534';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(0, 0, 52, 0, Math.PI * 2);
ctx.stroke();
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(0, 0, 43, 0, Math.PI * 2);
ctx.stroke();
this.star(ctx, 0, -23, 11, 4.6, 5);
ctx.fill();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '700 25px sans-serif';
ctx.fillText('肉垫计划', 0, 10);
ctx.font = '13px sans-serif';
ctx.fillText('官方认证', 0, 32);
ctx.restore();
ctx.globalAlpha = 1;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
},
// 微信取手机号:新版 getPhoneNumber 返回动态 code,后端换成真实手机号存库,
// 拿回后重新取卡(此时电话就有了)并重画
onGetPhone(e) {
const d = e.detail || {};
if (!d.code) {
// 用户拒绝授权:不给生成,明确提示
this.setData({ phoneRefused: true });
return wx.showToast({ title: '没有手机号,无法生成身份卡', icon: 'none' });
}
wx.showLoading({ title: '验证中…', mask: true });
api
.bindPhone(d.code)
.then(() => api.petIDCard(this.data.petId))
.then((card) => {
wx.hideLoading();
if (!card) return;
this.setData({ card, qrReady: !!card.qr_ready, needPhone: false, phoneRefused: false });
wx.nextTick(() => this.draw());
wx.showToast({ title: '手机号已验证', icon: 'success' });
})
.catch((err) => {
wx.hideLoading();
toastErr(err, '验证失败');
});
},
// 手动填手机号兜底(getPhoneNumber 需企业认证,唤不起时用这个)
showManual() {
this.setData({ manualMode: true });
},
onManualPhone(e) {
this.setData({ manualPhone: e.detail.value });
},
submitManual() {
const p = (this.data.manualPhone || '').trim();
if (!/^1\d{10}$/.test(p)) return wx.showToast({ title: '手机号格式不对', icon: 'none' });
wx.showLoading({ title: '保存中…', mask: true });
api
.updateProfile({ phone: p })
.then(() => api.petIDCard(this.data.petId))
.then((card) => {
wx.hideLoading();
if (!card) return;
this.setData({ card, qrReady: !!card.qr_ready, needPhone: false, manualMode: false });
wx.nextTick(() => this.draw());
})
.catch((err) => {
wx.hideLoading();
toastErr(err, '保存失败');
});
},
onSave() {
if (!this.data.cardImg) return wx.showToast({ title: '还在生成,稍等一下', icon: 'none' });
if (this.data.saving) return;
this.setData({ saving: true });
wx.saveImageToPhotosAlbum({
filePath: this.data.cardImg,
success: () => wx.showToast({ title: '已保存到相册', icon: 'success' }),
fail: (err) => {
const msg = (err && err.errMsg) || '';
if (msg.indexOf('cancel') >= 0) return;
// 拒过一次后 authorize 不再弹,必须引导去设置页
if (msg.indexOf('auth deny') >= 0 || msg.indexOf('authorize') >= 0) {
wx.showModal({
title: '需要相册权限',
content: '保存身份卡需要允许访问相册,去设置里打开一下?',
confirmText: '去设置',
success: (r) => r.confirm && wx.openSetting({}),
});
return;
}
wx.showToast({ title: '保存失败', icon: 'none' });
},
complete: () => this.setData({ saving: false }),
});
},
onShareAppMessage() {
const c = this.data.card || {};
return {
title: `${c.name || '我家毛孩子'} 的居民身份卡`,
path: '/pages/idcard/idcard?id=' + this.data.petId,
imageUrl: this.data.cardImg || '', // 用画好的卡当分享封面
};
},
});
-6
View File
@@ -1,6 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pt-icon": "/components/pt-icon/index"
}
}
-43
View File
@@ -1,43 +0,0 @@
<nav-bar title="身份卡" show-back="{{true}}"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body ic-body">
<!-- 手机号门槛:没验证不生成,先唤起微信取号 -->
<view wx:if="{{needPhone}}" class="ic-gate">
<view class="ic-gate-ic"><pt-icon name="user" size="{{72}}"></pt-icon></view>
<view class="ic-gate-t">生成身份卡需要手机号</view>
<view class="ic-gate-p">身份卡背面的「联系电话」用于走失时联系你,验证后才能生成</view>
<block wx:if="{{!manualMode}}">
<button wx:if="{{!phoneRefused}}" class="btn btn-primary btn-block ic-gate-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhone">用微信验证手机号</button>
<view wx:else class="ic-gate-refuse">没有手机号,无法生成身份卡</view>
<view class="ic-gate-manual" bindtap="showManual">唤不起来?手动填写手机号</view>
</block>
<block wx:else>
<input class="input ic-gate-input" type="number" maxlength="11" placeholder="输入 11 位手机号" placeholder-class="placeholder" value="{{manualPhone}}" bindinput="onManualPhone"/>
<button class="btn btn-primary btn-block ic-gate-btn" bindtap="submitManual">保存并生成</button>
</block>
</view>
<!-- 画好的卡当成图片展示。canvas 本身是离屏的(挪到屏幕外),只用来生成图 -->
<block wx:else>
<view class="ic-stage">
<image wx:if="{{cardImg}}" class="ic-img" src="{{cardImg}}" mode="widthFix" show-menu-by-longpress="{{true}}"></image>
<view wx:else class="ic-loading">正在生成身份卡…</view>
</view>
<view class="ic-actions">
<view class="btn btn-soft ic-btn" bindtap="onSave">
<pt-icon name="photo" size="{{32}}"></pt-icon>下载到相册
</view>
<button class="btn btn-dark ic-btn" open-type="share">
<pt-icon name="share" size="{{32}}"></pt-icon>分享给好友
</button>
</view>
</block>
</view>
</scroll-view>
<!-- 离屏 canvas:挪到屏幕外,只用来画图导出 -->
<canvas type="2d" id="idCanvas" class="ic-canvas"></canvas>
-38
View File
@@ -1,38 +0,0 @@
.ic-body{padding-top:var(--sp-4)}
/* 画好的卡片图。宽度撑满、按比例高 */
.ic-stage{border-radius:var(--r-lg);overflow:hidden;box-shadow:var(--sd-2);background:#F3EFE8;min-height:600rpx}
.ic-img{width:100%;display:block}
.ic-loading{padding:200rpx 0;text-align:center;color:var(--muted);font-size:var(--fs-sm)}
.ic-tip{
margin-top:var(--sp-4);padding:var(--sp-3);border-radius:var(--r-sm);
background:var(--primary-soft);color:var(--primary-ink);
font-size:var(--fs-cap);line-height:1.6;text-align:center;
}
.ic-actions{display:flex;gap:var(--sp-3);margin-top:var(--sp-5)}
.ic-btn{flex:1}
/* button 元素默认有边框和外边距,压平成和 .btn 一样 */
button.ic-btn{padding:0;line-height:normal}
button.ic-btn::after{border:none}
/* 离屏 canvas:挪出可视区,只用来生成图 */
.ic-canvas{position:fixed;left:-9999rpx;top:0;width:620px;height:1480px}
/* 手机号门槛卡 */
.ic-gate{
background:#fff;border-radius:var(--r-lg);box-shadow:var(--sd-1);
padding:var(--sp-6) var(--sp-5);margin-top:var(--sp-5);text-align:center;
}
.ic-gate-ic{
width:140rpx;height:140rpx;margin:0 auto var(--sp-4);border-radius:50%;
background:var(--primary-soft);color:var(--primary);
display:flex;align-items:center;justify-content:center;
}
.ic-gate-t{font-size:var(--fs-lg);font-weight:var(--fw-b);color:var(--text)}
.ic-gate-p{font-size:var(--fs-sm);color:var(--muted);line-height:1.6;margin:var(--sp-2) 0 var(--sp-5)}
.ic-gate-refuse{color:var(--red-ink);font-weight:var(--fw-b);font-size:var(--fs-md)}
.ic-gate-btn{margin-top:var(--sp-2)}
.ic-gate-manual{margin-top:var(--sp-4);color:var(--muted);font-size:var(--fs-sm);text-decoration:underline}
.ic-gate-input{margin-bottom:var(--sp-3);text-align:center}
-43
View File
@@ -1,43 +0,0 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
Page({
data: {
articles: [],
sheetShow: false,
sheetType: '',
},
onLoad() {
store
.ready()
.then(() => api.listArticles())
.then((list) => {
this.setData({
articles: (list || []).map((a) => ({
id: a.id,
icon: a.icon,
title: a.title,
desc: a.description,
})),
});
})
.catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' }));
},
// 打开文章正文详情页
openArticle(e) {
const id = e.currentTarget.dataset.id;
if (id) wx.navigateTo({ url: `/pages/article/article?id=${id}` });
},
openSheet(e) {
this.setData({ sheetType: e.currentTarget.dataset.type, sheetShow: true });
},
closeSheet() {
this.setData({ sheetShow: false });
},
onShareAppMessage() {
return { title: '新手养宠知识合集', path: '/pages/learn/learn' };
},
onShareTimeline() {
return { title: '新手养宠知识合集' };
},
});
-8
View File
@@ -1,8 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"bottom-sheet": "/components/bottom-sheet/bottom-sheet",
"fab": "/components/fab/fab",
"pt-icon": "/components/pt-icon/index"
}
}
-17
View File
@@ -1,17 +0,0 @@
<nav-bar title="新手知识" show-back="{{true}}"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body no-fab">
<view class="page-title">
<view class="pt-p">看完顺手生成你的计划。</view>
</view>
<view wx:if="{{articles.length === 0}}" class="empty lg">还没有文章</view>
<view wx:for="{{articles}}" wx:key="id" class="article-card" data-id="{{item.id}}" bindtap="openArticle">
<view class="article-icon"><pt-icon name="{{item.icon}}" size="{{48}}" fallback="book"></pt-icon></view>
<view><text class="ac-b">{{item.title}}</text><view class="ac-p">{{item.desc}}</view></view>
</view>
</view>
</scroll-view>
<bottom-sheet show="{{sheetShow}}" type="{{sheetType}}" bind:close="closeSheet"></bottom-sheet>
+77
View File
@@ -0,0 +1,77 @@
const { secure, toast } = require("../../utils/api");
Page({
data: {
pets: [],
selected: [],
title: "",
description: "",
place_name: "",
latitude: 0,
longitude: 0,
date: "",
time: "",
max_people: 8,
},
onLoad() {
this.load();
},
async load() {
try {
this.setData({ pets: await secure("/pets") });
} catch (e) {
toast(e);
}
},
field(e) {
this.setData({ [e.currentTarget.dataset.key]: e.detail.value });
},
pick(e) {
this.setData({ selected: e.detail.selected });
},
date(e) {
this.setData({ date: e.detail.value });
},
time(e) {
this.setData({ time: e.detail.value });
},
async location() {
try {
const r = await wx.chooseLocation();
this.setData({
place_name: r.name || r.address,
latitude: r.latitude,
longitude: r.longitude,
});
} catch (e) {
toast(e);
}
},
async save() {
const d = this.data;
if (
!d.title.trim() ||
!d.place_name ||
!d.date ||
!d.time ||
!d.selected.length
) {
wx.showToast({ title: "请填完时间地点并选择狗狗", icon: "none" });
return;
}
try {
await secure("/meetups", "POST", {
title: d.title,
description: d.description,
place_name: d.place_name,
latitude: d.latitude,
longitude: d.longitude,
starts_at: new Date(d.date + "T" + d.time + ":00+08:00").toISOString(),
max_people: Number(d.max_people),
pet_ids: d.selected,
});
wx.navigateBack();
} catch (e) {
toast(e);
}
},
});
@@ -0,0 +1 @@
{ "usingComponents": { "pet-picker": "/components/pet-picker/index" } }
@@ -0,0 +1,53 @@
<view class="screen">
<view class="eyebrow">HOST A WALK</view>
<view class="title">发起约遛 🌿</view>
<view class="field">
<view class="field-label">活动名称</view>
<input
placeholder="周末一起逛公园"
value="{{title}}"
data-key="title"
bindinput="field"
/>
</view>
<view class="field">
<view class="field-label">时间</view>
<view class="row">
<picker mode="date" bindchange="date">
<view class="picker">{{date||'选择日期'}}</view>
</picker>
<picker mode="time" bindchange="time">
<view class="picker">{{time||'选择时间'}}</view>
</picker>
</view>
</view>
<view class="field">
<view class="field-label">集合地点</view>
<view class="picker" bindtap="location">
📍 {{place_name||'选择地图位置'}}
</view>
</view>
<view class="field">
<view class="field-label">人数上限</view>
<input
type="number"
value="{{max_people}}"
data-key="max_people"
bindinput="field"
/>
</view>
<view class="field">
<view class="field-label">介绍一下</view>
<textarea
placeholder="聊聊这次约遛安排"
value="{{description}}"
data-key="description"
bindinput="field"
/>
</view>
<view class="field">
<view class="field-label">带哪些狗狗?可以多选</view>
<pet-picker pets="{{pets}}" selected="{{selected}}" bind:change="pick" />
</view>
<button class="primary" bindtap="save">发布约遛 →</button>
</view>
@@ -0,0 +1,13 @@
.picker {
background: white;
border-radius: 20rpx;
padding: 23rpx;
min-width: 260rpx;
}
.field .row {
gap: 15rpx;
justify-content: flex-start;
}
.field textarea {
padding: 20rpx;
}
+71
View File
@@ -0,0 +1,71 @@
const { secure, toast } = require("../../utils/api");
Page({
data: { meetups: [], pets: [], selected: [], active: null, userId: "" },
onShow() {
this.load();
},
async load() {
try {
const [meetups, pets, me] = await Promise.all([
secure("/meetups"),
secure("/pets"),
secure("/me"),
]);
this.setData({
meetups: meetups.map((m) => ({
...m,
date: new Date(m.starts_at).toLocaleString("zh-CN"),
})),
pets,
userId: me.id,
});
} catch (e) {
toast(e);
}
},
create() {
wx.navigateTo({ url: "/pages/meetup-edit/meetup-edit" });
},
open(e) {
this.setData({
active: this.data.meetups[e.currentTarget.dataset.index],
selected: [],
});
},
close() {
this.setData({ active: null });
},
pick(e) {
this.setData({ selected: e.detail.selected });
},
async join() {
if (!this.data.selected.length) {
wx.showToast({ title: "请选择同行的狗狗", icon: "none" });
return;
}
try {
await secure("/meetups/" + this.data.active.id + "/join", "POST", {
pet_ids: this.data.selected,
});
wx.showToast({ title: "报名成功", icon: "success" });
this.close();
this.load();
} catch (e) {
toast(e);
}
},
async cancel() {
const r = await wx.showModal({
title: "取消约遛",
content: "确定取消这场约遛吗?",
});
if (!r.confirm) return;
try {
await secure("/meetups/" + this.data.active.id + "/cancel", "POST");
this.close();
this.load();
} catch (e) {
toast(e);
}
},
});
+1
View File
@@ -0,0 +1 @@
{ "usingComponents": { "tab-bar": "/components/tab-bar/index", "pet-picker": "/components/pet-picker/index" } }
+62
View File
@@ -0,0 +1,62 @@
<view class="screen">
<view class="eyebrow">LET'S WALK TOGETHER</view>
<view class="title">约个伴一起遛 🌿</view>
<view class="banner">
<text>好天气,和新朋友一起走走</text>
<strong>
下一次快乐
<br />
从这里出发
</strong>
<image src="/assets/corgi-family-five.png" mode="aspectFit" />
</view>
<view class="row">
<text class="section-title">附近的约遛</text>
<text class="add" bindtap="create">+ 发起约遛</text>
</view>
<view
wx:for="{{meetups}}"
wx:key="id"
class="card meetup"
data-index="{{index}}"
bindtap="open"
>
<view class="row">
<text class="badge">
🐾 {{item.participants}} / {{item.max_people}} 人
</text>
<text class="arrow">›</text>
</view>
<strong>{{item.title}}</strong>
<view class="meta">📍 {{item.place_name}}</view>
<view class="meta">🗓 {{item.date}}</view>
<view class="desc">{{item.description}}</view>
</view>
<view wx:if="{{!meetups.length}}" class="empty">
还没有约遛,发起第一场吧 🐕
</view>
<button class="primary" bindtap="create">+ 发起一场约遛</button>
</view>
<view wx:if="{{active}}" class="overlay">
<view class="sheet">
<view class="row">
<strong>{{active.title}}</strong>
<text bindtap="close">×</text>
</view>
<view class="detail">
📍 {{active.place_name}}
<br />
🗓 {{active.date}}
<br />
🐕 {{active.participants}} / {{active.max_people}} 人已报名
</view>
<view class="desc">{{active.description}}</view>
<view wx:if="{{active.host_user_id!==userId}}">
<view class="select-title">带哪几只?可以多选</view>
<pet-picker pets="{{pets}}" selected="{{selected}}" bind:change="pick" />
<button class="primary" bindtap="join">带狗狗报名</button>
</view>
<button wx:else class="secondary" bindtap="cancel">取消这场约遛</button>
</view>
</view>
<tab-bar current="/pages/meetups/meetups" />
+96
View File
@@ -0,0 +1,96 @@
.banner {
background: #dfeee2;
border-radius: 34rpx;
min-height: 245rpx;
padding: 36rpx;
position: relative;
overflow: hidden;
}
.banner text,
.banner strong {
display: block;
position: relative;
z-index: 2;
}
.banner text {
color: #549375;
font-size: 22rpx;
}
.banner strong {
font-size: 35rpx;
line-height: 1.3;
margin-top: 16rpx;
}
.banner image {
width: 290rpx;
height: 220rpx;
position: absolute;
right: -30rpx;
bottom: 0;
}
.add {
font-size: 24rpx;
color: #328965;
font-weight: 700;
}
.meetup strong {
display: block;
font-size: 32rpx;
margin: 15rpx 0;
}
.badge {
background: #e8f4ea;
color: #429369;
border-radius: 99rpx;
padding: 8rpx 15rpx;
font-size: 22rpx;
}
.arrow {
font-size: 39rpx;
color: #a1b2a8;
}
.meta {
font-size: 24rpx;
color: #748c7d;
margin-top: 9rpx;
}
.desc {
font-size: 24rpx;
color: #9aaa9f;
margin-top: 15rpx;
}
.overlay {
position: fixed;
inset: 0;
background: #102a24a0;
z-index: 30;
display: flex;
align-items: flex-end;
}
.sheet {
width: 100%;
background: #fff;
border-radius: 36rpx 36rpx 0 0;
padding: 35rpx 32rpx calc(env(safe-area-inset-bottom) + 35rpx);
}
.sheet > .row strong {
font-size: 34rpx;
}
.sheet > .row text {
font-size: 45rpx;
}
.detail {
font-size: 25rpx;
color: #61816e;
line-height: 2;
margin: 25rpx 0;
}
.select-title {
font-size: 25rpx;
font-weight: 700;
margin: 30rpx 0 15rpx;
}
.sheet button {
margin-top: 26rpx;
width: 100%;
}
-87
View File
@@ -1,87 +0,0 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const { toastErr } = require('../../utils/ui.js');
const { syncTabBar } = require('../../utils/tabbar.js');
Page({
data: { user: {}, pets: [], card: null, sheetShow: false, sheetType: '' },
onLoad() {
this._unsub = store.subscribe(() => this.applyUser());
},
onUnload() {
if (this._unsub) this._unsub();
},
onShow() {
syncTabBar(this);
store
.ready()
.then(() => {
this.applyUser();
this.loadCard();
})
.catch((e) => toastErr(e));
},
applyUser() {
this.setData({ pets: store.getPets(), user: store.getUser() || {} });
},
// 头部数据走 /users/:id/profile,和别人看我时是同一个接口——
// 换成 /user/summary 的话两边字段会对不上,装扮效果也预览不到
loadCard() {
const uid = (this.data.user && this.data.user.id) || '';
if (!uid) return;
api
.userCard(uid)
.then((card) => this.setData({ card }))
.catch(() => {});
},
// 看自己的公开主页,和别人看到的是同一个页面、同一套渲染
previewPublic() {
const uid = this.data.user && this.data.user.id;
if (uid) wx.navigateTo({ url: `/pages/user/user?id=${uid}` });
},
// 我的帖子:进自己的主页,能看到待审核/未通过的帖子及状态
goMyPosts() {
const uid = this.data.user && this.data.user.id;
if (!uid) return wx.showToast({ title: '请先登录', icon: 'none' });
wx.navigateTo({ url: '/pages/user/user?id=' + uid });
},
goSettings() {
wx.navigateTo({ url: '/pages/settings/settings' });
},
goRelations(e) {
const uid = (this.data.user && this.data.user.id) || '';
if (!uid) return wx.showToast({ title: '登录信息还没就绪', icon: 'none' });
wx.navigateTo({ url: `/pages/relations/relations?id=${uid}&kind=${e.detail.kind}` });
},
// 宠物档案:当前这只的编辑页
goPetForm() {
const id = store.currentPetId();
if (!id) return wx.navigateTo({ url: '/pages/petform/petform' });
wx.navigateTo({ url: '/pages/petform/petform?id=' + id });
},
goDecorate() {
wx.navigateTo({ url: '/pages/decorate/decorate' });
},
goPlan() {
wx.navigateTo({ url: '/pages/plan/plan' });
},
// 列表行传 dataset,宠物名片墙传 detail,两边都进这里
onPickPet(e) {
const id = (e.detail && e.detail.id) || e.currentTarget.dataset.id;
if (!id) return;
store.switchPet(id);
wx.switchTab({ url: '/pages/home/home' });
},
openSheet(e) {
this.setData({ sheetType: e.currentTarget.dataset.type, sheetShow: true });
},
closeSheet() {
this.setData({ sheetShow: false });
},
onAddPet() {
wx.navigateTo({ url: '/pages/petform/petform' });
},
onShareAppMessage() {
return { title: '肉垫计划 · 每天照顾好毛孩子', path: '/pages/home/home' };
},
});
-9
View File
@@ -1,9 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"bottom-sheet": "/components/bottom-sheet/bottom-sheet",
"fab": "/components/fab/fab",
"pt-icon": "/components/pt-icon/index",
"profile-head": "/components/profile-head/index"
}
}
-53
View File
@@ -1,53 +0,0 @@
<nav-bar title="我的"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body tab-nofab">
<!-- 和「宠友主页」同一个组件。自己看到的就是别人看到的,装扮才有意义 -->
<profile-head card="{{card}}" hide-wall="{{true}}" bind:relations="goRelations"
bind:decorate="goDecorate" bind:pet="onPickPet"></profile-head>
<!-- 装扮 + 预览,一行两列 -->
<view class="me-actions">
<view class="btn btn-ghost me-act" bindtap="goDecorate">
<pt-icon name="edit" size="{{28}}"></pt-icon>装扮主页
</view>
<view class="btn btn-ghost me-act" bindtap="previewPublic">
<pt-icon name="user" size="{{28}}"></pt-icon>别人眼里的我
</view>
</view>
<!-- 我的毛孩子 -->
<view class="card">
<view class="section-head">
<view class="sh-title">我的毛孩子</view>
</view>
<view wx:for="{{pets}}" wx:key="id" class="pet-row" data-id="{{item.id}}" bindtap="onPickPet">
<view class="pet-emoji">
<image wx:if="{{item.avatar_url}}" class="pet-emoji-img" src="{{item.avatar_url}}" mode="aspectFill"></image>
<block wx:else>{{item.emoji}}</block>
</view>
<view class="pet-info">
<view class="tt-b">{{item.name}}</view>
<view class="tt-p">{{item.age}}|{{item.weight}}|{{item.stage}}</view>
</view>
<view class="pr-arrow"><pt-icon name="next" size="{{28}}"></pt-icon></view>
</view>
<view wx:if="{{!pets.length}}" class="empty">还没有毛孩子,先建一份档案</view>
</view>
<!-- 档案/计划/多宠都从首页和宠物卡进,这里留「我的帖子」+ 设置 -->
<view class="profile-list">
<view class="profile-row" bindtap="goMyPosts">
<view class="pr-ic"><pt-icon name="community" size="{{34}}"></pt-icon></view>
<text class="pr-label">我的帖子</text><text class="pr-val">看审核状态</text>
<view class="pr-arrow"><pt-icon name="next" size="{{28}}"></pt-icon></view>
</view>
<view class="profile-row" bindtap="goSettings">
<view class="pr-ic"><pt-icon name="settings" size="{{34}}"></pt-icon></view>
<text class="pr-label">设置</text><view class="pr-arrow"><pt-icon name="next" size="{{28}}"></pt-icon></view>
</view>
</view>
</view>
</scroll-view>
<bottom-sheet show="{{sheetShow}}" type="{{sheetType}}" bind:close="closeSheet"></bottom-sheet>
-14
View File
@@ -1,14 +0,0 @@
.pet-row{display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-3) 0;border-bottom:1rpx solid var(--line)}
.pet-row:last-child{border-bottom:0}
.pet-emoji{overflow:hidden;
width:76rpx;height:76rpx;border-radius:var(--r-sm);flex:none;
display:flex;align-items:center;justify-content:center;
background:var(--primary-soft);font-size:40rpx;
}
.pet-info{flex:1;min-width:0}
/* 装扮 + 预览:一行两列,文字缩小、图标也小一号 */
.me-actions{display:flex;gap:var(--sp-3);margin-bottom:var(--sp-5)}
.me-act{flex:1;height:var(--h-md);padding:0 var(--sp-2);font-size:var(--fs-sm);gap:var(--sp-1)}
.pet-emoji-img{width:100%;height:100%;display:block}
-179
View File
@@ -1,179 +0,0 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const GENDERS = ['男孩', '女孩', '不确定'];
// 与后端 service.AgeStages 一致:「刚到家」不是年龄阶段,它由到家日期单独决定
const STAGE_OPTIONS = [
{ key: '幼年期', desc: '疫苗针次、驱虫、体重增长、社会化训练' },
{ key: '成年期', desc: '体重控制、口腔、日常健康管理' },
{ key: '老年期', desc: '慢病监测、半年体检、用药与护理' },
];
function today() {
const d = new Date();
const p = (n) => (n < 10 ? '0' + n : '' + n);
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
}
function monthsSince(dateStr) {
const b = new Date(dateStr);
const now = new Date();
let m = (now.getFullYear() - b.getFullYear()) * 12 + (now.getMonth() - b.getMonth());
if (now.getDate() < b.getDate()) m--;
return m < 0 ? 0 : m;
}
// 与后端 StageFromBirthday 同一套规则:12 月龄成年、7 岁进入老年
function stageFromBirthday(birthday) {
if (!birthday) return '';
const m = monthsSince(birthday);
if (m < 12) return '幼年期';
if (m >= 84) return '老年期';
return '成年期';
}
function ageFromBirthday(bdayStr) {
if (!bdayStr) return '';
const b = new Date(bdayStr);
const now = new Date();
let months = (now.getFullYear() - b.getFullYear()) * 12 + (now.getMonth() - b.getMonth());
if (months < 0) months = 0;
return months < 24 ? months + '个月' : Math.floor(months / 12) + '岁';
}
Page({
data: {
booting: true, // 冷启动先显示闪屏,静默登录 + 拉宠物期间不闪建档向导
step: 0,
petType: '猫猫',
petEmoji: '🐱',
petName: '',
birthday: '',
genderIdx: 0,
weight: '',
arrivedAt: '',
today: '',
stage: '',
stageAuto: '',
stageOptions: STAGE_OPTIONS,
goals: [
{ label: '不知道每天该做什么', on: true },
{ label: '疫苗驱虫怕忘记', on: true },
{ label: '想记录体重和健康', on: false },
{ label: '担心生病不会判断', on: false },
{ label: '想控制养宠开销', on: false },
{ label: '想生成成长报告', on: false },
],
submitting: false,
},
onLoad() {
this.setData({ today: today(), arrivedAt: today() });
// 闪屏期间就把登录 + 宠物拉完,保证进首页时数据已经到位。
// 有宠物直接进首页;没有才落到建档向导。登录失败也别卡在闪屏,
// 放用户进向导(提交时会再 store.ready 重试)
store
.ready()
.then(() => {
if (store.getPets().length > 0) {
wx.switchTab({ url: '/pages/home/home' });
} else {
this.setData({ booting: false });
}
})
.catch(() => this.setData({ booting: false }));
},
nextStep() {
this.setData({ step: Math.min(this.data.step + 1, 5) });
},
choosePet(e) {
const { type, emoji } = e.currentTarget.dataset;
this.setData({ petType: type, petEmoji: emoji });
},
onName(e) { this.setData({ petName: e.detail.value }); },
onWeight(e) { this.setData({ weight: e.detail.value }); },
onBirthday(e) {
const birthday = e.detail.value;
const auto = stageFromBirthday(birthday);
const patch = { birthday, stageAuto: auto };
// 用户手动选过就尊重他的选择,否则跟着生日走
if (!this._stageTouched) patch.stage = auto;
this.setData(patch);
},
onArrived(e) { this.setData({ arrivedAt: e.detail.value }); },
selectGender(e) { this.setData({ genderIdx: Number(e.currentTarget.dataset.index) }); },
// 先随便看看:不建数据,直接进首页浏览(无宠物时首页显示空态)
skipBrowse() { wx.switchTab({ url: '/pages/home/home' }); },
savePetNext() {
if (!(this.data.petName || '').trim()) {
wx.showToast({ title: '给它起个名字吧', icon: 'none' });
return;
}
const w = parseFloat(this.data.weight);
// 体重会进趋势图和用药剂量参考,乱填不如不填
if (this.data.weight && !(w > 0 && w < 120)) {
wx.showToast({ title: '体重看起来不太对', icon: 'none' });
return;
}
if (!this.data.birthday) {
wx.showToast({ title: '生日决定疫苗和驱虫节奏,估个大概也行', icon: 'none' });
return;
}
this.nextStep();
},
chooseStage(e) {
const stage = e.currentTarget.dataset.stage;
const auto = this.data.stageAuto;
// 改成和生日矛盾的阶段是允许的(领养估的生日可能不准,大型犬发育也慢),
// 但要先说清楚代价:阶段决定疫苗针次怎么排,选错会漏针
if (auto && stage !== auto) {
wx.showModal({
title: '和生日对不上',
content: `按生日算它应该在「${auto}」。改成「${stage}」的话,疫苗针次和驱虫周期会按${stage}安排。确定要改吗?`,
confirmText: '确定改',
success: (res) => {
if (res.confirm) {
this._stageTouched = true;
this.setData({ stage });
}
},
});
return;
}
this._stageTouched = true;
this.setData({ stage });
},
toggleGoal(e) {
const i = e.currentTarget.dataset.index;
this.setData({ [`goals[${i}].on`]: !this.data.goals[i].on });
},
async submit() {
if (this.data.submitting) return;
this.setData({ submitting: true });
wx.showLoading({ title: '生成计划中...' });
const body = {
name: (this.data.petName || '').trim(),
emoji: this.data.petEmoji,
type: this.data.petType,
gender: GENDERS[this.data.genderIdx],
birthday: this.data.birthday,
arrived_at: this.data.arrivedAt,
weight: this.data.weight,
stage: this.data.stage || this.data.stageAuto,
age: ageFromBirthday(this.data.birthday),
goals: this.data.goals.filter((g) => g.on).map((g) => g.label),
};
try {
await store.ready();
await api.onboarding(body);
await store.loadPets();
wx.hideLoading();
wx.switchTab({ url: '/pages/home/home' });
} catch (e) {
wx.hideLoading();
wx.showToast({ title: e.message || '创建失败', icon: 'none' });
this.setData({ submitting: false });
}
},
finishOnboard() { this.submit(); },
});
-7
View File
@@ -1,7 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pt-icon": "/components/pt-icon/index"
},
"navigationBarTitleText": "肉垫计划"
}
-127
View File
@@ -1,127 +0,0 @@
<!-- 冷启动闪屏:登录 + 拉宠物期间显示,不再闪一下建档向导 -->
<view wx:if="{{booting}}" class="boot">
<view class="boot-badge">
<view class="paw">
<view class="bean bean-1"></view><view class="bean bean-2"></view>
<view class="bean bean-3"></view><view class="bean bean-4"></view>
<view class="pad"></view>
</view>
</view>
<view class="boot-name">肉垫计划</view>
<view class="boot-tip">正在准备今天的养护计划…</view>
</view>
<block wx:else>
<nav-bar title="肉垫计划"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="onboard-page">
<!-- step 0 欢迎 -->
<view wx:if="{{step === 0}}" class="step">
<view class="hero">
<view class="pet-logo">
<view class="paw">
<view class="bean bean-1"></view><view class="bean bean-2"></view>
<view class="bean bean-3"></view><view class="bean bean-4"></view>
<view class="pad"></view>
</view>
</view>
<view class="hero-h1">肉垫计划</view>
<view class="hero-sub">新手养宠的养护日历、健康记录和成长报告。先生成计划,再每天照着做。</view>
</view>
<view class="proof-row">
<view class="proof"><text class="proof-b">30天</text><text class="proof-s">新手计划</text></view>
<view class="proof"><text class="proof-b">24类</text><text class="proof-s">快速记录</text></view>
<view class="proof"><text class="proof-b">成长</text><text class="proof-s">报告卡片</text></view>
</view>
<button class="btn btn-primary btn-block" bindtap="nextStep">开始建档,生成计划</button>
<view class="skip-link" bindtap="skipBrowse">先随便看看</view>
</view>
<!-- step 1 选类型 -->
<view wx:elif="{{step === 1}}" class="step">
<view class="step-title">你的毛孩子是?</view>
<view class="step-sub">目前支持猫和狗,选一个开始。</view>
<view class="choice-grid">
<view class="choice-card {{petType === '猫猫' ? 'selected' : ''}}" data-type="猫猫" data-emoji="🐱" bindtap="choosePet">
<view class="animal"><pt-icon name="cat" size="{{52}}"></pt-icon></view><view class="choice-b">猫猫</view><view class="choice-s">幼猫、成年猫、老年猫</view>
</view>
<view class="choice-card {{petType === '狗狗' ? 'selected' : ''}}" data-type="狗狗" data-emoji="🐶" bindtap="choosePet">
<view class="animal"><pt-icon name="dog" size="{{52}}"></pt-icon></view><view class="choice-b">狗狗</view><view class="choice-s">幼犬、成年犬、老年犬</view>
</view>
</view>
<button class="btn btn-primary btn-block" bindtap="nextStep">下一步</button>
</view>
<!-- step 2 信息 -->
<view wx:elif="{{step === 2}}" class="step">
<view class="step-title">告诉我一点它的信息</view>
<view class="step-sub">先填基础信息,之后可随时补充。</view>
<view class="form-card">
<view class="field"><label>宠物名字</label>
<input class="input" value="{{petName}}" placeholder="例如:奶球" placeholder-class="placeholder" bindinput="onName"/></view>
<view class="field"><label>生日</label>
<picker mode="date" value="{{birthday}}" end="{{today}}" bindchange="onBirthday">
<view class="picker-box">{{birthday || '选择日期(不确定可以估个大概)'}}</view>
</picker></view>
<view class="field"><label>到家日期</label>
<picker mode="date" value="{{arrivedAt}}" end="{{today}}" bindchange="onArrived">
<view class="picker-box">{{arrivedAt || '选择日期'}}</view>
</picker>
<view class="field-tip">刚到家不满 30 天的话,会额外安排一组安置期任务</view></view>
<view class="field"><label>性别</label><view class="seg">
<view class="seg-btn {{genderIdx === 0 ? 'selected' : ''}}" data-index="0" bindtap="selectGender">男孩</view>
<view class="seg-btn {{genderIdx === 1 ? 'selected' : ''}}" data-index="1" bindtap="selectGender">女孩</view>
<view class="seg-btn {{genderIdx === 2 ? 'selected' : ''}}" data-index="2" bindtap="selectGender">不确定</view>
</view></view>
<view class="field"><label>当前体重(kg)</label>
<input class="input" value="{{weight}}" placeholder="例如:2.8" placeholder-class="placeholder" bindinput="onWeight" type="digit"/></view>
</view>
<button class="btn btn-primary btn-block" bindtap="savePetNext">下一步</button>
</view>
<!-- step 3 阶段 -->
<view wx:elif="{{step === 3}}" class="step">
<view class="step-title">它现在处于哪个阶段?</view>
<view class="step-sub">阶段决定疫苗节奏、驱虫周期和每天要做的事,选错整套计划都会偏。</view>
<view wx:if="{{stageAuto}}" class="stage-auto">
<pt-icon name="check" size="{{28}}"></pt-icon>已按生日自动判断为「{{stageAuto}}」,不对可以改
</view>
<view class="stage-list">
<view wx:for="{{stageOptions}}" wx:key="key"
class="stage-card {{stage === item.key ? 'selected' : ''}}" data-stage="{{item.key}}" bindtap="chooseStage">
<view class="stage-b">{{item.key}}<text wx:if="{{item.key === stageAuto}}" class="stage-hint">按生日推荐</text></view>
<view class="stage-p">{{item.desc}}</view>
</view>
</view>
<button class="btn btn-primary btn-block" bindtap="nextStep">下一步</button>
</view>
<!-- step 4 目标 -->
<view wx:elif="{{step === 4}}" class="step">
<view class="step-title">你最想解决什么?</view>
<view class="step-sub">勾上的会变成真的提醒和任务,没勾的不会打扰你。</view>
<view class="check-list">
<view wx:for="{{goals}}" wx:key="label" class="check {{item.on ? 'selected' : ''}}" data-index="{{index}}" bindtap="toggleGoal">
<view class="box"><pt-icon wx:if="{{item.on}}" name="check" size="{{26}}"></pt-icon></view>{{item.label}}
</view>
</view>
<button class="btn btn-primary btn-block" bindtap="nextStep">生成专属计划</button>
</view>
<!-- step 5 生成 -->
<view wx:elif="{{step === 5}}" class="step">
<view class="step-title">正在生成专属养宠计划</view>
<view class="step-sub">正在根据它的信息生成专属计划…</view>
<view class="gen-card">
<view class="gen-line"><view class="tick"><pt-icon name="check" size="{{26}}"></pt-icon></view> 已识别:{{stage}}</view>
<view class="gen-line"><view class="tick"><pt-icon name="check" size="{{26}}"></pt-icon></view> 已生成:30 天新手计划</view>
<view class="gen-line"><view class="tick"><pt-icon name="check" size="{{26}}"></pt-icon></view> 已安排:疫苗 / 驱虫提醒</view>
<view class="gen-line"><view class="tick"><pt-icon name="check" size="{{26}}"></pt-icon></view> 已开启:体重、便便、饮食记录</view>
<view class="gen-line"><view class="tick"><pt-icon name="check" size="{{26}}"></pt-icon></view> 已准备:首张成长报告卡片</view>
</view>
<button class="btn btn-primary btn-block" style="margin-top:36rpx" bindtap="finishOnboard">查看今日任务</button>
</view>
</view>
</scroll-view>
</block>
-103
View File
@@ -1,103 +0,0 @@
.onboard-page{
padding:0 var(--pad-x) 56rpx;
min-height:100%;
background:
radial-gradient(circle at top right, rgba(245,169,71,.22), transparent 45%),
var(--bg);
}
.step{animation:fade .24s ease}
.skip-link{text-align:center;margin-top:var(--sp-4);color:var(--muted);font-size:var(--fs-md);padding:var(--sp-2)}
@keyframes fade{from{opacity:0;transform:translateY(16rpx)}to{opacity:1;transform:none}}
/* 冷启动闪屏 */
.boot{
position:fixed;inset:0;z-index:200;
display:flex;flex-direction:column;align-items:center;justify-content:center;
background:
radial-gradient(circle at 50% 32%, rgba(253,179,92,.28), transparent 55%),
var(--bg);
}
.boot-badge{
width:184rpx;height:184rpx;border-radius:44rpx;
background:linear-gradient(150deg,#FFB865,#F5943E);
display:flex;align-items:center;justify-content:center;
box-shadow:0 18rpx 42rpx rgba(214,138,52,.4);
animation:boot-pop .5s ease both, boot-breathe 2.4s ease-in-out .5s infinite;
}
.boot-name{margin-top:var(--sp-5);font-size:var(--fs-2xl);font-weight:var(--fw-b);letter-spacing:-1rpx}
.boot-tip{margin-top:var(--sp-2);color:var(--muted);font-size:var(--fs-md)}
@keyframes boot-pop{from{opacity:0;transform:scale(.8)}to{opacity:1;transform:scale(1)}}
@keyframes boot-breathe{0%,100%{transform:translateY(0)}50%{transform:translateY(-10rpx)}}
.hero{text-align:center;padding:60rpx 0 var(--sp-5)}
.pet-logo{
width:176rpx;height:176rpx;margin:0 auto var(--sp-5);border-radius:var(--r-lg);
background:linear-gradient(135deg,#FFB865,#F5943E);
display:flex;align-items:center;justify-content:center;
box-shadow:var(--sd-2);
}
/* 肉垫(呼应「肉垫计划」)——奶白掌垫 + 四趾垫,闪屏和欢迎屏共用 */
.paw{position:relative;width:100rpx;height:92rpx}
.paw .pad{
position:absolute;left:50%;bottom:0;transform:translateX(-50%);
width:66rpx;height:54rpx;background:#FFF7EC;
border-radius:48% 48% 44% 44%/62% 62% 40% 40%;
}
.paw .bean{position:absolute;background:#FFF7EC;border-radius:50%}
.paw .bean-1{width:22rpx;height:30rpx;left:0;top:30rpx;transform:rotate(-20deg)}
.paw .bean-2{width:24rpx;height:33rpx;left:27rpx;top:6rpx;transform:rotate(-7deg)}
.paw .bean-3{width:24rpx;height:33rpx;right:27rpx;top:6rpx;transform:rotate(7deg)}
.paw .bean-4{width:22rpx;height:30rpx;right:0;top:30rpx;transform:rotate(20deg)}
.hero-h1{font-size:var(--fs-2xl);letter-spacing:-1.2rpx;font-weight:var(--fw-b)}
.hero-sub{margin-top:var(--sp-3);line-height:1.65;color:var(--muted);font-size:var(--fs-md)}
.proof-row{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--sp-3);margin:var(--sp-6) 0}
.proof{background:#fff;border-radius:var(--r-md);padding:var(--sp-4) var(--sp-3);text-align:center;box-shadow:var(--sd-1)}
.proof-b{display:block;font-size:var(--fs-lg);font-weight:var(--fw-b)}
.proof-s{color:var(--muted);font-size:var(--fs-cap)}
.step-title{font-size:var(--fs-2xl);letter-spacing:-.8rpx;line-height:1.24;margin:var(--sp-3) 0;font-weight:var(--fw-b)}
.step-sub{color:var(--muted);line-height:1.6;font-size:var(--fs-md);margin-bottom:var(--sp-5)}
.choice-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-4);margin-bottom:var(--sp-5)}
.choice-card{
background:#fff;border:4rpx solid transparent;border-radius:var(--r-lg);min-height:248rpx;
padding:var(--sp-5) var(--sp-4);box-shadow:var(--sd-1);transition:.18s ease;
display:flex;flex-direction:column;align-items:flex-start;
}
.choice-card.selected{border-color:var(--primary);background:var(--primary-soft)}
.choice-card .animal{
width:96rpx;height:96rpx;border-radius:var(--r-md);margin-bottom:var(--sp-3);
display:flex;align-items:center;justify-content:center;
background:var(--primary-soft);color:var(--primary-ink);
}
.choice-card.selected .animal{background:#fff}
.choice-b{font-size:var(--fs-lg);font-weight:var(--fw-b)}
.choice-s{display:block;color:var(--muted);font-size:var(--fs-sm);margin-top:var(--sp-2)}
.form-card{background:#fff;border-radius:var(--r-lg);padding:var(--sp-5);box-shadow:var(--sd-1);margin-bottom:var(--sp-5)}
.stage-list{display:flex;flex-direction:column;gap:var(--sp-3);margin-bottom:var(--sp-5)}
.stage-card{background:#fff;border-radius:var(--r-md);border:4rpx solid transparent;padding:var(--sp-4);box-shadow:var(--sd-1)}
.stage-card.selected{border-color:var(--green);background:var(--green-soft)}
.stage-b{font-size:var(--fs-lg);font-weight:var(--fw-b)}
.stage-p{color:var(--muted);font-size:var(--fs-sm);margin-top:var(--sp-1)}
.gen-card{margin-top:var(--sp-5);background:#fff;border-radius:var(--r-lg);padding:var(--sp-6);box-shadow:var(--sd-2)}
.gen-line{display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-3) 0;color:var(--text-2);font-size:var(--fs-md)}
.tick{
width:44rpx;height:44rpx;border-radius:50%;background:var(--green);color:#fff;
display:flex;align-items:center;justify-content:center;flex:none;
}
.field-tip{margin-top:var(--sp-2);color:var(--muted);font-size:var(--fs-cap);line-height:1.5}
.stage-auto{
display:flex;align-items:center;gap:var(--sp-2);margin-bottom:var(--sp-3);
padding:var(--sp-3) var(--sp-4);border-radius:var(--r-md);
background:var(--green-soft);color:var(--green-ink);font-size:var(--fs-sm);
}
.stage-hint{
margin-left:var(--sp-2);padding:2rpx var(--sp-2);border-radius:var(--r-full);
background:var(--primary-soft);color:var(--primary-ink);font-size:var(--fs-cap);font-weight:var(--fw);
}
+88
View File
@@ -0,0 +1,88 @@
const { secure, upload, toast } = require("../../utils/api");
Page({
data: {
id: "",
name: "",
breed: "柯基",
gender: "male",
birth_date: "",
weight_kg: "",
coat: "",
allergies: "",
care_notes: "",
avatar_file_id: "",
avatar_url: "",
},
onLoad(options) {
if (options.id) {
this.setData({ id: options.id });
this.load(options.id);
}
},
async load(id) {
try {
this.setData(await secure("/pets/" + id));
} catch (e) {
toast(e);
}
},
field(e) {
this.setData({ [e.currentTarget.dataset.key]: e.detail.value });
},
date(e) {
this.setData({ birth_date: e.detail.value });
},
gender(e) {
this.setData({ gender: e.detail.value });
},
async choose() {
try {
const r = await wx.chooseMedia({ count: 1, mediaType: ["image"] });
const file = await upload(r.tempFiles[0].tempFilePath);
this.setData({ avatar_file_id: file.id, avatar_url: file.url });
} catch (e) {
toast(e);
}
},
async save() {
if (!this.data.name.trim()) {
wx.showToast({ title: "请输入名字", icon: "none" });
return;
}
const body = {
name: this.data.name,
species: "dog",
breed: this.data.breed,
gender: this.data.gender,
birth_date: this.data.birth_date || "",
weight_kg: Number(this.data.weight_kg) || 0,
coat: this.data.coat,
allergies: this.data.allergies,
care_notes: this.data.care_notes,
avatar_file_id: this.data.avatar_file_id || null,
};
try {
await secure(
"/pets" + (this.data.id ? "/" + this.data.id : ""),
this.data.id ? "PUT" : "POST",
body,
);
wx.navigateBack();
} catch (e) {
toast(e);
}
},
async archive() {
const confirm = await wx.showModal({
title: "归档宠物",
content: "归档后不会再出现在遛狗和任务中。",
});
if (!confirm.confirm) return;
try {
await secure("/pets/" + this.data.id, "DELETE");
wx.navigateBack();
} catch (e) {
toast(e);
}
},
});
+1
View File
@@ -0,0 +1 @@
{}
+64
View File
@@ -0,0 +1,64 @@
<view class="screen">
<view class="eyebrow">PET PROFILE</view>
<view class="title">{{id?'编辑档案':'认识新朋友'}} 🐶</view>
<view class="photo" bindtap="choose">
<image wx:if="{{avatar_url}}" src="{{avatar_url}}" mode="aspectFill" />
<text wx:else>🐕</text>
<view>点击上传照片</view>
</view>
<view class="field">
<view class="field-label">名字 *</view>
<input
placeholder="它叫什么名字?"
value="{{name}}"
data-key="name"
bindinput="field"
/>
</view>
<view class="field">
<view class="field-label">品种</view>
<input value="{{breed}}" data-key="breed" bindinput="field" />
</view>
<view class="field">
<view class="field-label">性别</view>
<radio-group bindchange="gender">
<label>
<radio value="male" checked="{{gender==='male'}}" color="#194f42" />
男孩
</label>
<label>
<radio value="female" checked="{{gender==='female'}}" color="#194f42" />
女孩
</label>
</radio-group>
</view>
<view class="field">
<view class="field-label">生日</view>
<picker mode="date" value="{{birth_date}}" bindchange="date">
<view class="picker">{{birth_date||'选择生日'}}</view>
</picker>
</view>
<view class="field">
<view class="field-label">体重 kg</view>
<input
type="digit"
value="{{weight_kg}}"
data-key="weight_kg"
bindinput="field"
/>
</view>
<view class="field">
<view class="field-label">毛色</view>
<input value="{{coat}}" data-key="coat" bindinput="field" />
</view>
<view class="field">
<view class="field-label">过敏提醒</view>
<input value="{{allergies}}" data-key="allergies" bindinput="field" />
</view>
<view class="field">
<view class="field-label">照顾备注</view>
<textarea value="{{care_notes}}" data-key="care_notes" bindinput="field" />
</view>
<button class="primary" bindtap="save">保存档案</button>
<button wx:if="{{id}}" class="archive" bindtap="archive">归档这只宠物</button>
</view>
+50
View File
@@ -0,0 +1,50 @@
.photo {
width: 190rpx;
height: 190rpx;
border-radius: 42rpx;
background: #f2d5aa;
display: grid;
place-items: center;
margin: 0 auto 35rpx;
position: relative;
font-size: 80rpx;
overflow: hidden;
}
.photo image {
width: 100%;
height: 100%;
}
.photo view {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: #194f42b5;
color: white;
text-align: center;
font-size: 18rpx;
padding: 6rpx;
}
.field radio-group {
display: flex;
gap: 30rpx;
background: #fff;
padding: 20rpx;
border-radius: 20rpx;
}
.field label {
display: flex;
align-items: center;
gap: 5rpx;
}
.picker {
background: white;
border-radius: 20rpx;
padding: 22rpx;
}
.archive {
background: transparent;
color: #a66c5a;
font-size: 24rpx;
margin-top: 20rpx;
}
-318
View File
@@ -1,318 +0,0 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const upload = require('../../utils/upload.js');
const { toastErr } = require('../../utils/ui.js');
const GENDERS = ['男孩', '女孩', '不确定'];
// 性格标签预设,多选,存成逗号分隔串
const PERSONA_TAGS = ['活泼', '亲人', '粘人', '高冷', '贪吃', '胆小', '好奇', '温顺'];
// 阶段列表和划分标准都从后端来。原来这里写死 4 个,后端细分成 5 段
// (而且狗还按体型走不同阈值)之后就对不上了 —— 而且不会报错,只是少两个选项。
// 「刚到家 0-30 天」在后端列表的第一个,它和年龄正交,是叠加层
function pad2(n) {
return n < 10 ? '0' + n : '' + n;
}
function ymd(d) {
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
}
function monthsSince(bday) {
const b = new Date(String(bday).slice(0, 10) + 'T00:00:00');
if (isNaN(b.getTime())) return -1;
const n = new Date();
const m = (n.getFullYear() - b.getFullYear()) * 12 + (n.getMonth() - b.getMonth());
return m < 0 ? 0 : m;
}
function ageLabel(bday) {
const m = monthsSince(bday);
if (m < 0) return '';
return m < 24 ? m + '个月' : Math.floor(m / 12) + '岁';
}
// 和后端 service.StageFromBirthday 一套口径:<12 月幼年,>=7 年老年,其余成年。
// 前端算是为了在用户填完生日时就给出建议值,最终仍以用户选的为准
function stageFromBirthday(bday) {
const m = monthsSince(bday);
if (m < 0) return '';
if (m < 12) return '幼年期';
if (m >= 84) return '老年期';
return '成年期';
}
Page({
data: {
editing: false,
petId: '',
step: 0,
steps: ['名字', '品种', '资料', '方案'],
genders: GENDERS,
stages: [],
stageRules: [],
stageBasis: '',
sizeLabel: '',
today: '',
stageAuto: '',
form: {
name: '', avatar_file_id: '', avatar_url: '',
species: 'cat', breed: '',
gender: '不确定', birthday: '', arrived_at: '',
weight: '', color: '', stage: '',
personality: '', allergy: '', notes: '',
// 第四步挑的方案。'' 是「先空着自己排」,不是「没选」——
// 所以初始值给 null,用来区分「还没走到第四步」
template_id: null,
},
personaTags: PERSONA_TAGS,
personaSel: {}, // 性格标签选中态 map
tplOptions: [],
breedShow: false,
breedGroups: [],
breedAnchor: '',
saving: false,
},
onLoad(q) {
this.setData({ today: ymd(new Date()) });
this.loadStages();
const id = (q && q.id) || '';
if (!id) return;
// 编辑:不分步,一屏全放开——用户是来改某一项的,不该被按顺序走一遍
store
.ready()
.then(() => {
const p = (store.getPets() || []).find((x) => x.id === id) || store.getPet() || {};
this.setData({
editing: true,
petId: id,
form: {
name: p.name || '',
avatar_file_id: '',
avatar_url: p.avatar_url || '',
species: p.type === '狗狗' ? 'dog' : 'cat',
breed: p.breed || '',
gender: GENDERS.indexOf(p.gender) >= 0 ? p.gender : '不确定',
birthday: p.birthday ? String(p.birthday).slice(0, 10) : '',
arrived_at: p.arrived_at ? String(p.arrived_at).slice(0, 10) : '',
weight: (p.weight || '').replace('kg', ''),
color: p.color || '',
personality: p.personality || '',
allergy: p.allergy || '',
notes: p.notes || '',
stage: p.stage || '',
},
personaSel: (p.personality || '').split(',').filter(Boolean).reduce((m, t) => ((m[t] = true), m), {}),
});
})
.catch((e) => toastErr(e));
},
onName(e) {
this.setData({ 'form.name': e.detail.value });
},
pickAvatar() {
upload
.chooseAndUploadImage()
.then((f) => this.setData({ 'form.avatar_file_id': f.id, 'form.avatar_url': f.url }))
.catch((e) => {
if (e && e.canceled) return;
toastErr(e, '上传失败');
});
},
pickSpecies(e) {
const sp = e.currentTarget.dataset.sp;
if (sp === this.data.form.species) return;
// 换了物种,原来选的品种一定不对了
this.setData({ 'form.species': sp, 'form.breed': '', breedGroups: [] });
this.loadStages();
},
pickGender(e) {
this.setData({ 'form.gender': e.currentTarget.dataset.v });
},
pickStage(e) {
const v = e.currentTarget.dataset.v;
this.setData({ 'form.stage': v, stageBasis: this.basisOf(v) });
},
basisOf(stage) {
const r = (this.data.stageRules || []).find((x) => x.stage === stage);
return r ? r.range + ' · ' + r.basis : '';
},
// 阶段的划分标准要按物种和体型取:同样 10 个月,吉娃娃已经是青年期,
// 大丹犬还在幼年期。体型从选中品种的 size_class 来
loadStages() {
const f = this.data.form;
let size = '';
(this.data.breedGroups || []).forEach((g) =>
g.items.forEach((b) => {
if (b.name === f.breed) size = b.size_class || '';
}),
);
api
.lifeStages(f.species, size)
.then((d) => {
const rules = (d && d.stages) || [];
this.setData({
stageRules: rules,
// 阶段选项 = 「刚到家」+ 五个年龄段。刚到家是叠加层,后端的
// CareStages 里也在第一个,这里保持一致
stages: ['刚到家 0-30 天'].concat(rules.map((r) => r.stage)),
sizeLabel: (d && d.sizes && d.sizes[size]) || '',
stageBasis: this.basisOf(f.stage),
});
})
.catch(() => {});
},
onBirthday(e) {
const v = e.detail.value;
const auto = stageFromBirthday(v);
const patch = { 'form.birthday': v, stageAuto: auto };
// 用户还没手选过阶段就跟着生日走;选过就不动他的选择
if (auto && !this.data.form.stage) {
patch['form.stage'] = auto;
patch.stageBasis = this.basisOf(auto);
}
this.setData(patch);
},
onArrived(e) {
this.setData({ 'form.arrived_at': e.detail.value });
},
onWeight(e) {
this.setData({ 'form.weight': e.detail.value });
},
onColor(e) {
this.setData({ 'form.color': e.detail.value });
},
onAllergy(e) {
this.setData({ 'form.allergy': e.detail.value });
},
onNotes(e) {
this.setData({ 'form.notes': e.detail.value });
},
// 性格标签多选:切换选中态,再拼回逗号串存进 form.personality
togglePersona(e) {
const tag = e.currentTarget.dataset.v;
const sel = Object.assign({}, this.data.personaSel);
if (sel[tag]) delete sel[tag];
else sel[tag] = true;
const personality = this.data.personaTags.filter((t) => sel[t]).join(',');
this.setData({ personaSel: sel, 'form.personality': personality });
},
// ── 品种选择器 ──
openBreed() {
this.setData({ breedShow: true });
if (this.data.breedGroups.length) return;
api
.breeds(this.data.form.species)
.then((groups) => this.setData({ breedGroups: groups || [] }))
.catch((e) => toastErr(e));
},
closeBreed() {
this.setData({ breedShow: false });
},
noop() {},
pickBreed(e) {
this.setData({ 'form.breed': e.currentTarget.dataset.name, breedShow: false });
this.loadStages(); // 换了品种可能换了体型,阶段阈值跟着变
},
clearBreed() {
// 「不确定」要留:领养的串串确实说不出品种,逼着选一个只会得到假数据
this.setData({ 'form.breed': '', breedShow: false });
},
jumpInitial(e) {
this.setData({ breedAnchor: 'bi-' + e.currentTarget.dataset.i });
},
loadTemplates() {
const f = this.data.form;
api
.careTemplates(f.species, f.stage)
.then((opts) => {
const list = opts || [];
this.setData({
tplOptions: list,
// 默认选第一套:多数用户是新手,给一个合理默认比让他面对空白好。
// 但「先空着」就在下面,一眼能看到,不是藏起来的
'form.template_id': list.length ? list[0].id : '',
});
})
.catch(() => this.setData({ tplOptions: [], 'form.template_id': '' }));
},
pickTemplate(e) {
this.setData({ 'form.template_id': e.currentTarget.dataset.id });
},
// ── 分步 ──
prev() {
if (this.data.step > 0) this.setData({ step: this.data.step - 1 });
},
// 每一步只校验这一步的东西。三步全填完才校验的话,用户在第三步才被告知
// 第一步没填名字,还得翻回去
stepError() {
const f = this.data.form;
if (this.data.step === 0 && !f.name.trim()) return '先给它起个名字';
if (this.data.step === 1 && !f.species) return '选一下是猫还是狗';
if (this.data.step === 2 && !f.stage) return '选一下现在是哪个阶段 —— 方案是按阶段配的';
return '';
},
next() {
if (this.data.saving) return;
if (!this.data.editing) {
const err = this.stepError();
if (err) return wx.showToast({ title: err, icon: 'none' });
if (this.data.step < 3) {
const next = this.data.step + 1;
this.setData({ step: next });
if (next === 3) this.loadTemplates(); // 方案按阶段取,得等第三步填完
return;
}
} else if (!this.data.form.name.trim()) {
return wx.showToast({ title: '名字不能空', icon: 'none' });
}
this.submit();
},
body() {
const f = this.data.form;
const isDog = f.species === 'dog';
const body = {
name: f.name.trim(),
type: isDog ? '狗狗' : '猫猫',
// emoji 是老字段,列表和海报还在用它兜底;有头像时它不显示
emoji: isDog ? '🐶' : '🐱',
gender: f.gender,
birthday: f.birthday,
arrived_at: f.arrived_at,
weight: f.weight,
color: f.color.trim(),
breed: f.breed,
personality: f.personality,
allergy: f.allergy.trim(),
notes: f.notes.trim(),
stage: f.stage || stageFromBirthday(f.birthday) || '成年期',
age: ageLabel(f.birthday),
};
// 没重新传头像就不带这个字段,别把已有头像覆盖成空
if (f.avatar_file_id) body.avatar_file_id = f.avatar_file_id;
return body;
},
submit() {
this.setData({ saving: true });
const b = this.body();
const tplID = this.data.form.template_id;
const req = this.data.editing ? api.updatePet(this.data.petId, b) : api.createPet(b);
req
// 套方案要等档案建出来才有 pet id。套用失败不算建档失败——
// 档案已经建好了,方案之后在计划页还能套,不该把用户退回表单
.then((pet) => {
if (this.data.editing || !tplID || !pet || !pet.id) return;
return api.applyTemplate(pet.id, tplID, 0).catch(() => {});
})
.then(() => store.loadPets())
.then(() => {
this.setData({ saving: false });
wx.showToast({ title: this.data.editing ? '已保存' : '建好啦', icon: 'success' });
setTimeout(() => wx.navigateBack(), 700);
})
.catch((e) => {
this.setData({ saving: false });
toastErr(e, '保存失败');
});
},
});
-6
View File
@@ -1,6 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pt-icon": "/components/pt-icon/index"
}
}
-168
View File
@@ -1,168 +0,0 @@
<nav-bar title="{{editing ? '编辑档案' : '建一份档案'}}" show-back="{{true}}"></nav-bar>
<!-- 三步进度。编辑时不分步(用户是来改某一项的,不该被按顺序走一遍) -->
<view wx:if="{{!editing}}" class="pf-steps">
<view wx:for="{{steps}}" wx:key="*this" class="pf-step {{index <= step ? 'on' : ''}}">
<view class="pf-dot">{{index < step ? '✓' : index + 1}}</view>
<text class="pf-slabel">{{item}}</text>
</view>
</view>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body pf-body">
<!-- ① 名字 + 头像 -->
<block wx:if="{{editing || step === 0}}">
<view class="card">
<view class="pf-h">先给它起个名字</view>
<view class="pf-av-wrap" bindtap="pickAvatar">
<view class="pf-av">
<image wx:if="{{form.avatar_url}}" class="pf-av-img" src="{{form.avatar_url}}" mode="aspectFill"></image>
<block wx:else><pt-icon name="photo" size="{{56}}"></pt-icon></block>
</view>
<view class="pf-av-tip">{{form.avatar_url ? '换一张' : '传张照片'}}</view>
</view>
<view class="field"><label>名字</label>
<input class="input" placeholder="例如:布丁" placeholder-class="placeholder"
value="{{form.name}}" maxlength="16" bindinput="onName"/></view>
</view>
</block>
<!-- ② 猫还是狗 + 品种 -->
<block wx:if="{{editing || step === 1}}">
<view class="card">
<view class="pf-h">它是猫还是狗?</view>
<view class="pf-species">
<view class="pf-sp {{form.species === 'cat' ? 'on' : ''}}" data-sp="cat" bindtap="pickSpecies">
<pt-icon name="cat" size="{{64}}"></pt-icon><text>猫猫</text>
</view>
<view class="pf-sp {{form.species === 'dog' ? 'on' : ''}}" data-sp="dog" bindtap="pickSpecies">
<pt-icon name="dog" size="{{64}}"></pt-icon><text>狗狗</text>
</view>
</view>
<view class="field"><label>品种</label>
<view class="picker-box {{form.breed ? '' : 'pf-empty'}}" bindtap="openBreed">
{{form.breed || '选一个品种'}}
</view>
</view>
</view>
</block>
<!-- ③ 其他基本信息 -->
<block wx:if="{{editing || step === 2}}">
<view class="card">
<view class="pf-h">再补几项,之后都能改</view>
<view class="field"><label>性别</label><view class="seg">
<view wx:for="{{genders}}" wx:key="*this" class="seg-btn {{form.gender === item ? 'selected' : ''}}"
data-v="{{item}}" bindtap="pickGender">{{item}}</view>
</view></view>
<!-- 生日和到家日分开:领养的成年猫狗生日常常是不知道的,
而「一起生活了多少天」要按到家日算。合成一个字段两件事都说不准 -->
<view class="field"><label>生日</label>
<picker mode="date" value="{{form.birthday}}" end="{{today}}" bindchange="onBirthday">
<view class="picker-box {{form.birthday ? '' : 'pf-empty'}}">{{form.birthday || '不知道可以先跳过'}}</view>
</picker></view>
<view class="field"><label>到家日期</label>
<picker mode="date" value="{{form.arrived_at}}" end="{{today}}" bindchange="onArrived">
<view class="picker-box {{form.arrived_at ? '' : 'pf-empty'}}">{{form.arrived_at || '哪天接回家的'}}</view>
</picker></view>
<view class="field"><label>当前体重(kg)</label>
<input class="input" type="digit" placeholder="例如:2.8" placeholder-class="placeholder"
value="{{form.weight}}" bindinput="onWeight"/></view>
<view class="field"><label>毛色</label>
<input class="input" placeholder="例如:橘白 / 奶牛 / 黑" placeholder-class="placeholder"
value="{{form.color}}" maxlength="16" bindinput="onColor"/></view>
<view class="field"><label>性格标签</label><view class="mini-options">
<view wx:for="{{personaTags}}" wx:key="*this" class="option {{personaSel[item] ? 'selected' : ''}}"
data-v="{{item}}" bindtap="togglePersona">{{item}}</view>
</view></view>
<view class="field"><label>备注(身份卡背面显示)</label>
<textarea class="textarea" placeholder="如:肠胃敏感,请勿喂陌生食物" placeholder-class="placeholder"
value="{{form.notes}}" maxlength="80" bindinput="onNotes"></textarea></view>
<view class="field"><label>当前阶段</label><view class="mini-options">
<view wx:for="{{stages}}" wx:key="*this" class="option {{form.stage === item ? 'selected' : ''}}"
data-v="{{item}}" bindtap="pickStage">{{item}}</view>
</view>
<!-- 把划分标准摆出来。用户不该猜「幼年期」是几个月到几个月,
狗还得知道自己这个体型的阈值和别的体型不一样 -->
<view wx:if="{{stageBasis}}" class="pf-basis">{{stageBasis}}</view>
<view wx:if="{{sizeLabel}}" class="tiny">{{sizeLabel}}——阶段阈值按体型算</view>
<view wx:if="{{stageAuto && stageAuto !== form.stage}}" class="tiny">按生日推算是「{{stageAuto}}」,可以手改</view>
</view>
</view>
</block>
<!-- ④ 挑一套养护方案(编辑时不出现:改档案不该顺手改计划) -->
<block wx:if="{{!editing && step === 3}}">
<view class="card">
<view class="pf-h">要不要套一套养护方案?</view>
<view class="pf-sub">按 {{form.stage}} 配的,套用后会排进本月的计划。之后随时能改、能删。</view>
<view wx:for="{{tplOptions}}" wx:key="id"
class="pf-tpl {{form.template_id === item.id ? 'on' : ''}}"
data-id="{{item.id}}" bindtap="pickTemplate">
<view class="pf-tpl-top">
<view class="pf-tpl-name">{{item.name}}</view>
<view wx:if="{{form.template_id === item.id}}" class="pf-tpl-tick"><pt-icon name="check" size="{{26}}"></pt-icon></view>
</view>
<view class="pf-tpl-sum">{{item.summary}}</view>
<view class="pf-tpl-meta">
<text wx:if="{{item.daily}}">每天 {{item.daily}} 项</text>
<text wx:if="{{item.weekly}}">每周 {{item.weekly}} 项</text>
<text wx:if="{{item.monthly}}">每月 {{item.monthly}} 项</text>
<text wx:if="{{item.once}}">一次性 {{item.once}} 项</text>
</view>
</view>
<!-- 「先空着」必须是个平等的选项,不是藏起来的退路:
已经养熟的宠物主人有自己的节奏,硬塞一套只会被删掉 -->
<view class="pf-tpl {{form.template_id === '' ? 'on' : ''}}" data-id="" bindtap="pickTemplate">
<view class="pf-tpl-top">
<view class="pf-tpl-name">先空着,我自己排</view>
<view wx:if="{{form.template_id === ''}}" class="pf-tpl-tick"><pt-icon name="check" size="{{26}}"></pt-icon></view>
</view>
<view class="pf-tpl-sum">之后在「管理计划」里随时能套用</view>
</view>
<view wx:if="{{!tplOptions.length}}" class="empty">这个阶段还没有配方案,建完档可以自己排</view>
<view class="tiny pf-tpl-note">疫苗和驱虫提醒不管选哪个都会建 —— 漏针是真会出事的</view>
</view>
</block>
</view>
</scroll-view>
<!-- 底部按钮固定 -->
<view class="pf-foot">
<view wx:if="{{!editing && step > 0}}" class="btn btn-ghost pf-back" bindtap="prev">上一步</view>
<view class="btn btn-primary pf-next {{saving ? 'pf-busy' : ''}}" bindtap="next">
{{saving ? '保存中…' : (editing ? '保存' : (step < 3 ? '下一步' : '完成建档'))}}
</view>
</view>
<!-- 品种选择器:左侧列表 + 右侧 A-Z 索引 -->
<view wx:if="{{breedShow}}" class="pf-mask" bindtap="closeBreed">
<view class="pf-sheet" catchtap="noop">
<view class="pf-sheet-h">
<text>选择品种</text>
<view class="link" catchtap="clearBreed">不确定</view>
</view>
<view class="pf-breed-wrap">
<scroll-view class="pf-breed-list" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}"
scroll-into-view="{{breedAnchor}}" scroll-with-animation="{{true}}">
<block wx:for="{{breedGroups}}" wx:key="initial">
<view class="pf-bi" id="bi-{{item.initial}}">{{item.initial}}</view>
<view wx:for="{{item.items}}" wx:for-item="b" wx:key="id"
class="pf-brow {{form.breed === b.name ? 'on' : ''}}"
data-name="{{b.name}}" bindtap="pickBreed">{{b.name}}</view>
</block>
<view wx:if="{{!breedGroups.length}}" class="empty">还没有配置品种</view>
</scroll-view>
<view class="pf-index">
<view wx:for="{{breedGroups}}" wx:key="initial" class="pf-ichar"
data-i="{{item.initial}}" bindtap="jumpInitial">{{item.initial}}</view>
</view>
</view>
</view>
</view>
-108
View File
@@ -1,108 +0,0 @@
.pf-body{padding-bottom:calc(200rpx + env(safe-area-inset-bottom))}
/* 三步进度条。不用 seg-tabs:那是「可以随意切」的语义,
这里是有顺序的,点第三步跳过前两步会拿到一份没名字的档案 */
.pf-steps{
flex:none;display:flex;align-items:flex-start;justify-content:center;
gap:var(--sp-6);padding:var(--sp-3) var(--pad-x) var(--sp-4);
}
.pf-step{display:flex;flex-direction:column;align-items:center;gap:6rpx}
.pf-dot{
width:48rpx;height:48rpx;border-radius:50%;
display:flex;align-items:center;justify-content:center;
background:#fff;border:1rpx solid var(--line);
color:var(--muted2);font-size:var(--fs-sm);font-weight:var(--fw-b);
}
.pf-slabel{font-size:var(--fs-cap);color:var(--muted2)}
.pf-step.on .pf-dot{background:var(--primary);border-color:var(--primary);color:#fff}
.pf-step.on .pf-slabel{color:var(--primary-dark);font-weight:var(--fw-b)}
.pf-h{font-size:var(--fs-lg);font-weight:var(--fw-b);margin-bottom:var(--sp-4)}
/* 头像 */
.pf-av-wrap{display:flex;flex-direction:column;align-items:center;gap:var(--sp-2);margin-bottom:var(--sp-5)}
.pf-av{
width:176rpx;height:176rpx;border-radius:50%;overflow:hidden;
background:var(--primary-soft);color:var(--primary-dark);
display:flex;align-items:center;justify-content:center;
}
.pf-av-img{width:100%;height:100%}
.pf-av-tip{font-size:var(--fs-cap);color:var(--primary-dark)}
/* 猫 / 狗 */
.pf-species{display:flex;gap:var(--sp-3);margin-bottom:var(--sp-4)}
.pf-sp{
flex:1;display:flex;flex-direction:column;align-items:center;gap:var(--sp-2);
padding:var(--sp-5) 0;border-radius:var(--r-md);
background:var(--surface-2);border:2rpx solid var(--line);
color:var(--muted);font-size:var(--fs-md);
}
.pf-sp.on{background:var(--primary-soft);border-color:var(--primary);color:var(--primary-ink);font-weight:var(--fw-b)}
.pf-empty{color:var(--muted2)}
/* 底部按钮 */
.pf-foot{
position:fixed;left:0;right:0;bottom:0;z-index:50;display:flex;gap:var(--sp-3);
padding:var(--sp-4) var(--pad-x) calc(var(--sp-4) + env(safe-area-inset-bottom));
background:#fff;box-shadow:0 -12rpx 32rpx rgba(70,48,25,.06);
}
.pf-back{flex:none;width:200rpx}
.pf-next{flex:1}
.pf-busy{opacity:.6}
/* 品种选择器 */
.pf-mask{position:fixed;inset:0;z-index:200;background:rgba(45,41,37,.45);display:flex;align-items:flex-end}
.pf-sheet{
width:100%;background:var(--bg2);border-radius:var(--r-lg) var(--r-lg) 0 0;
padding:var(--sp-5) 0 calc(var(--sp-3) + env(safe-area-inset-bottom));
box-shadow:var(--sd-3);
}
.pf-sheet-h{
display:flex;align-items:center;justify-content:space-between;
padding:0 var(--pad-x) var(--sp-4);
font-size:var(--fs-lg);font-weight:var(--fw-b);
}
.pf-breed-wrap{display:flex;height:60vh}
.pf-breed-list{flex:1;min-width:0;padding-left:var(--pad-x)}
/* 首字母吸顶:滚长列表时得知道现在在哪一段 */
.pf-bi{
position:sticky;top:0;z-index:1;
background:var(--bg2);color:var(--primary-dark);
font-size:var(--fs-sm);font-weight:var(--fw-b);padding:var(--sp-2) 0;
}
.pf-brow{
padding:var(--sp-3) 0;font-size:var(--fs-md);color:var(--text);
border-bottom:1rpx solid var(--line);
}
.pf-brow.on{color:var(--primary-dark);font-weight:var(--fw-b)}
/* 右侧 A-Z 索引 */
.pf-index{
flex:none;width:56rpx;display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:2rpx;
}
.pf-ichar{
font-size:var(--fs-cap);color:var(--muted);
padding:2rpx 8rpx;line-height:1.4;
}
/* 阶段的划分依据。摆出来而不是让用户猜「幼年期」是几个月到几个月 */
.pf-basis{
margin-top:var(--sp-2);background:var(--primary-soft);color:var(--primary-ink);
border-radius:var(--r-sm);padding:var(--sp-2) var(--sp-3);
font-size:var(--fs-cap);line-height:1.6;
}
/* 第四步:方案卡 */
.pf-sub{font-size:var(--fs-sm);color:var(--muted);line-height:1.6;margin:calc(-1 * var(--sp-2)) 0 var(--sp-4)}
.pf-tpl{
border:2rpx solid var(--line);border-radius:var(--r-md);
padding:var(--sp-4);margin-bottom:var(--sp-3);background:var(--surface-2);
}
.pf-tpl.on{border-color:var(--primary);background:var(--primary-soft)}
.pf-tpl-top{display:flex;align-items:center;gap:var(--sp-2)}
.pf-tpl-name{flex:1;font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)}
.pf-tpl-tick{flex:none;color:var(--primary-dark);display:flex;align-items:center}
.pf-tpl-sum{font-size:var(--fs-sm);color:var(--text-2);margin-top:6rpx;line-height:1.5}
.pf-tpl-meta{display:flex;flex-wrap:wrap;gap:var(--sp-3);margin-top:var(--sp-2);
font-size:var(--fs-cap);color:var(--muted)}
.pf-tpl-note{margin-top:var(--sp-2)}
+22
View File
@@ -0,0 +1,22 @@
const { secure, toast } = require("../../utils/api");
Page({
data: { pets: [] },
onShow() {
this.load();
},
async load() {
try {
this.setData({ pets: await secure("/pets") });
} catch (e) {
toast(e);
}
},
add() {
wx.navigateTo({ url: "/pages/pet-edit/pet-edit" });
},
edit(e) {
wx.navigateTo({
url: "/pages/pet-edit/pet-edit?id=" + e.currentTarget.dataset.id,
});
},
});
+1
View File
@@ -0,0 +1 @@
{ "usingComponents": { "tab-bar": "/components/tab-bar/index" } }
+43
View File
@@ -0,0 +1,43 @@
<view class="screen">
<view class="eyebrow">MY LITTLE FAMILY</view>
<view class="title">我的毛孩子 🐕</view>
<view class="family">
<view>
<text>五只柯基,也能一起遛</text>
<strong>每一只都有自己的故事</strong>
</view>
<image src="/assets/corgi-family-five.png" mode="aspectFit" />
</view>
<view class="row">
<text class="section-title">宠物档案 · {{pets.length}}</text>
<text class="add" bindtap="add">+ 添加</text>
</view>
<view
wx:for="{{pets}}"
wx:key="id"
class="card pet"
data-id="{{item.id}}"
bindtap="edit"
>
<view class="pet-avatar">
<image
wx:if="{{item.avatar_url}}"
src="{{item.avatar_url}}"
mode="aspectFill"
/>
<text wx:else>🐕</text>
</view>
<view class="pet-info">
<strong>{{item.name}}</strong>
<text>
{{item.breed||'小可爱'}} ·
{{item.gender==='male'?'男孩':item.gender==='female'?'女孩':'未知'}}
</text>
<text>体重 {{item.weight_kg||'—'}} kg</text>
</view>
<text class="arrow">›</text>
</view>
<view wx:if="{{!pets.length}}" class="empty">给第一只狗狗建个档案吧 🐾</view>
<button class="primary add-button" bindtap="add">+ 新建宠物档案</button>
</view>
<tab-bar current="/pages/pets/pets" />
+75
View File
@@ -0,0 +1,75 @@
.family {
background: #194f42;
border-radius: 35rpx;
color: white;
min-height: 220rpx;
padding: 35rpx;
position: relative;
overflow: hidden;
}
.family text,
.family strong {
display: block;
position: relative;
z-index: 2;
}
.family text {
color: #a4d6b8;
font-size: 23rpx;
}
.family strong {
font-size: 32rpx;
width: 55%;
margin-top: 20rpx;
}
.family image {
width: 270rpx;
height: 205rpx;
position: absolute;
right: -5rpx;
bottom: 0;
}
.add {
font-weight: 800;
color: #368764;
}
.pet {
display: flex;
align-items: center;
gap: 22rpx;
}
.pet-avatar {
width: 125rpx;
height: 125rpx;
flex: none;
border-radius: 28rpx;
background: #f1d2a3;
display: grid;
place-items: center;
font-size: 63rpx;
overflow: hidden;
}
.pet-avatar image {
width: 100%;
height: 100%;
}
.pet-info {
display: flex;
flex-direction: column;
gap: 6rpx;
flex: 1;
}
.pet-info strong {
font-size: 32rpx;
}
.pet-info text {
font-size: 23rpx;
color: #8a9c90;
}
.arrow {
font-size: 42rpx;
color: #97a99d;
}
.add-button {
margin-top: 25rpx;
}
-282
View File
@@ -1,282 +0,0 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const { toastErr } = require('../../utils/ui.js');
function pad2(n) {
return n < 10 ? '0' + n : '' + n;
}
function ymd(d) {
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
}
// "2026-07-04" → "7月4日"
function fmtMD(date) {
const p = (date || '').split('-');
return p.length < 3 ? '' : +p[1] + '月' + +p[2] + '日';
}
function parseYMD(s) {
const p = (s || '').split('-');
return p.length < 3 ? null : new Date(+p[0], +p[1] - 1, +p[2]);
}
// 自然月网格。周一为一周起点,首尾补空格对齐星期。
// 按月切而不是「从计划开始日起 30 天」:那样窗口跨两个月,
// 用户看着一个 7月26日—8月25日 的日历,既不像本月也不像下月。
function buildCells(year, month, byDate, todayStr) {
const first = new Date(year, month - 1, 1);
const lead = (first.getDay() + 6) % 7;
const total = new Date(year, month, 0).getDate();
const cells = [];
for (let i = 0; i < lead; i++) cells.push({ blank: true });
for (let d = 1; d <= total; d++) {
const s = year + '-' + pad2(month) + '-' + pad2(d);
cells.push({ day: d, date: s, count: (byDate[s] || []).length, today: s === todayStr });
}
while (cells.length % 7) cells.push({ blank: true });
return cells;
}
// off=0 本月,off=1 下月
function monthOf(off) {
const n = new Date();
const d = new Date(n.getFullYear(), n.getMonth() + off, 1);
return { year: d.getFullYear(), month: d.getMonth() + 1 };
}
function monthEnd(off) {
const m = monthOf(off);
return m.year + '-' + pad2(m.month) + '-' + pad2(new Date(m.year, m.month, 0).getDate());
}
Page({
data: {
pet: {},
plan: null,
cells: [],
monthOff: 0,
monthLabel: '',
monthEndLabel: '',
nextCount: 0,
monthEmpty: false,
tplOptions: [],
tplShow: false,
pickStart: '',
pickEnd: '',
selDate: '',
selLabel: '',
dayTasks: [],
editShow: false,
editId: '',
form: { title: '', date: '', description: '', daily: false },
saving: false,
sheetShow: false,
sheetType: '',
},
onLoad() {
this._unsub = store.subscribe((pet) => {
this.setData({ pet });
if (this._inited) this.load();
});
},
onUnload() {
if (this._unsub) this._unsub();
},
onShow() {
store
.ready()
.then(() => {
this._inited = true;
this.setData({ pet: store.getPet() });
this.load();
})
.catch((e) => toastErr(e));
},
load() {
const id = store.currentPetId();
if (!id) return;
this.loadTemplates();
api
.getPlan(id)
.then((plan) => this.apply(plan))
.catch(() => this.setData({ plan: null, cells: [], dayTasks: [] }));
},
apply(plan) {
const tasks = plan.tasks || [];
const byDate = {};
tasks.forEach((t) => {
if (!t.date) return;
(byDate[t.date] = byDate[t.date] || []).push(t);
});
this._byDate = byDate;
this.setData({
plan: { ...plan, tasks },
// 能排的区间:不早于计划开始日,不晚于下月月底。和后端 dayOffset 的校验一致
pickStart: (plan.start_date || '').slice(0, 10),
pickEnd: monthEnd(1),
});
this.renderMonth(this.data.monthOff);
if (this._pendingSel) {
// 刚加/改完,跳到那一天,不然用户看不到东西去哪了
this.selectDay(this._pendingSel);
this._pendingSel = '';
}
},
renderMonth(off) {
const m = monthOf(off);
const todayStr = ymd(new Date());
const byDate = this._byDate || {};
const prefix = m.year + '-' + pad2(m.month);
this.setData({
monthOff: off,
monthLabel: m.year + ' 年 ' + m.month + ' 月',
monthEndLabel: fmtMD(monthEnd(off)),
cells: buildCells(m.year, m.month, byDate, todayStr),
nextCount: Object.keys(byDate).filter((d) => d.indexOf(monthOf(1).year + '-' + pad2(monthOf(1).month)) === 0)
.reduce((n, d) => n + byDate[d].length, 0),
// 当前看的这个月一条都没有 —— 空月份要给套方案的入口
monthEmpty: !Object.keys(byDate).some((d) => d.indexOf(prefix) === 0),
});
// 切到本月默认选今天;切到下月默认选 1 号(今天不在那个月里)
const inMonth = todayStr.indexOf(prefix) === 0;
this.selectDay(inMonth ? todayStr : prefix + '-01', todayStr);
},
// 方案按宠物当前阶段取。宠物换了(pet-switch)阶段也可能不同,所以跟着 load 走
loadTemplates() {
const pet = this.data.pet || {};
if (!pet.stage) return;
api
.careTemplates(pet.type === '狗狗' ? 'dog' : 'cat', pet.stage)
.then((opts) => this.setData({ tplOptions: opts || [] }))
.catch(() => this.setData({ tplOptions: [] }));
},
openTpl() {
this.setData({ tplShow: true });
},
closeTpl() {
this.setData({ tplShow: false });
},
applyTpl(e) {
const id = e.currentTarget.dataset.id;
const petID = store.currentPetId();
if (!petID || !id) return;
wx.showLoading({ title: '排进去…' });
api
.applyTemplate(petID, id, this.data.monthOff)
.then((res) => {
wx.hideLoading();
this.setData({ tplShow: false });
const r = res || {};
wx.showToast({
title: r.plans > 0 ? '排了 ' + r.plans + ' 件事' : '这个月已经有这些安排了',
icon: 'none',
});
this.load();
})
.catch((err) => {
wx.hideLoading();
toastErr(err, '套用失败');
});
},
switchMonth(e) {
this.renderMonth(Number(e.currentTarget.dataset.off));
},
selectDay(date, todayStr) {
const t = todayStr || ymd(new Date());
this.setData({
selDate: date,
selLabel: date === t ? '今天的安排' : fmtMD(date) + '的安排',
dayTasks: (this._byDate || {})[date] || [],
});
},
onTapDay(e) {
const d = e.currentTarget.dataset.date;
if (d) this.selectDay(d);
},
toggleTask(e) {
api
.togglePlanTask(e.currentTarget.dataset.id)
.then(() => this.load())
.catch((err) => toastErr(err, '操作失败'));
},
// ── 加 / 改 ──
openAdd() {
this.setData({
editShow: true, editId: '',
form: { title: '', date: this.data.selDate || ymd(new Date()), description: '', daily: false },
});
},
openEdit(e) {
const t = (this.data.dayTasks || []).find((x) => x.id === e.currentTarget.dataset.id);
if (!t) return;
this.setData({
editShow: true, editId: t.id,
form: { title: t.title, date: t.date || this.data.selDate, description: t.description || '', daily: false },
});
},
closeEdit() {
this.setData({ editShow: false });
},
noop() {},
onFormTitle(e) {
this.setData({ 'form.title': e.detail.value });
},
onFormDate(e) {
this.setData({ 'form.date': e.detail.value });
},
onFormDesc(e) {
this.setData({ 'form.description': e.detail.value });
},
onFormDaily(e) {
this.setData({ 'form.daily': e.detail.value });
},
onSubmit() {
if (this.data.saving) return;
const f = this.data.form;
if (!(f.title || '').trim()) return wx.showToast({ title: '写一下做什么', icon: 'none' });
const id = store.currentPetId();
if (!id) return;
this.setData({ saving: true });
const req = this.data.editId
? api.updatePlanTask(this.data.editId, f)
: api.addPlanTask(id, f);
req
.then(() => {
this.setData({ saving: false, editShow: false });
// 加完/改完要跳到那一天,不然用户改了日期却看不到东西去哪了
this._pendingSel = f.date;
this.load();
})
.catch((e) => {
this.setData({ saving: false });
toastErr(e, '保存失败');
});
},
onDelete(e) {
const { id, title } = e.currentTarget.dataset;
wx.showModal({
title: '删掉这件事?',
content: title || '',
confirmColor: '#EE7D73',
success: (r) => {
if (!r.confirm) return;
api
.deletePlanTask(id)
.then(() => this.load())
.catch((err) => toastErr(err, '删除失败'));
},
});
},
closeSheet() {
this.setData({ sheetShow: false });
},
onAddPet() {
wx.navigateTo({ url: '/pages/petform/petform' });
},
onShareAppMessage() {
return { title: '我给毛孩子做了份养护计划', path: '/pages/plan/plan' };
},
onShareTimeline() {
return { title: '我给毛孩子做了份养护计划' };
},
});
-10
View File
@@ -1,10 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pet-switch": "/components/pet-switch/pet-switch",
"bottom-sheet": "/components/bottom-sheet/bottom-sheet",
"fab": "/components/fab/fab",
"pt-icon": "/components/pt-icon/index",
"seg-tabs": "/components/seg-tabs/index"
}
}
-117
View File
@@ -1,117 +0,0 @@
<nav-bar title="养宠计划" show-back="{{true}}"></nav-bar>
<view class="top-bar on-band"><pet-switch show-add="{{false}}"></pet-switch></view>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body">
<view wx:if="{{!pet.id}}" class="no-pet">
<view class="no-pet-ic"><pt-icon name="community" size="{{52}}"></pt-icon></view>
<view class="no-pet-b">还没有毛孩子的档案</view>
<view class="no-pet-p">建好档会按它的年龄阶段自动排好 30 天计划和疫苗驱虫提醒。</view>
<view class="btn btn-dark" bindtap="onAddPet"><pt-icon name="plus" size="{{30}}"></pt-icon>建一份档案</view>
</view>
<block wx:else>
<!-- 月历。按自然月切而不是「从计划开始日起 30 天」:那样窗口会跨两个月,
用户看着 7月26日—8月25日 既不像本月也不像下月,「这个月还剩什么」对不上。
能翻到下月,否则「做下月计划」这件事本身做不了 -->
<view class="card">
<view class="section-head">
<view class="sh-title">{{monthLabel}}</view>
<view class="link-tabs">
<view class="link {{monthOff === 0 ? 'on' : ''}}" data-off="0" bindtap="switchMonth">本月</view>
<view class="link {{monthOff === 1 ? 'on' : ''}}" data-off="1" bindtap="switchMonth">下月</view>
</view>
</view>
<!-- 这个月一条都没有时给套方案的入口。空着让用户自己从零排,
多数人会直接退出去 —— 但「自己排」也得留着,不能只给套用 -->
<view wx:if="{{monthEmpty}}" class="plan-tip">
<pt-icon name="warn" size="{{28}}"></pt-icon>
<view class="plan-tip-b">{{monthLabel}}还没有安排</view>
<view wx:if="{{tplOptions.length}}" class="link" catchtap="openTpl">套一套方案</view>
</view>
<view class="cal-grid">
<text class="cal-w">一</text><text class="cal-w">二</text><text class="cal-w">三</text><text class="cal-w">四</text><text class="cal-w">五</text><text class="cal-w">六</text><text class="cal-w">日</text>
<view wx:for="{{cells}}" wx:key="index"
class="cal-cell {{item.blank ? 'blank' : ''}} {{item.today ? 'today' : ''}} {{item.count ? 'tasked' : ''}} {{item.date === selDate ? 'sel' : ''}}"
data-date="{{item.date}}" bindtap="onTapDay">{{item.day || ''}}</view>
</view>
</view>
<!-- 选中那天的安排 -->
<view class="card">
<view class="section-head">
<view class="sh-title">{{selLabel}}</view>
<view class="link-tabs">
<view wx:if="{{tplOptions.length}}" class="link on" bindtap="openTpl">套方案</view>
<view class="link on" bindtap="openAdd">+ 加一件</view>
</view>
</view>
<view wx:for="{{dayTasks}}" wx:key="id" class="plan-task {{item.done ? 'done' : ''}}">
<view class="pk-check" catchtap="toggleTask" data-id="{{item.id}}">
<pt-icon wx:if="{{item.done}}" name="check" size="{{22}}"></pt-icon>
</view>
<view class="pk-body" data-id="{{item.id}}" bindtap="openEdit">
<text class="pk-b">{{item.title}}</text>
<view wx:if="{{item.description}}" class="pk-p">{{item.description}}</view>
</view>
<view class="pk-del" catchtap="onDelete" data-id="{{item.id}}" data-title="{{item.title}}">
<pt-icon name="trash" size="{{28}}"></pt-icon>
</view>
</view>
<view wx:if="{{!dayTasks.length}}" class="empty">这天还没有安排,点右上角加一件</view>
</view>
</block>
</view>
</scroll-view>
<!-- 加/改一条计划。字段少,用弹层比再开一页合适 -->
<view wx:if="{{editShow}}" class="pe-mask" bindtap="closeEdit">
<view class="pe-sheet" catchtap="noop">
<view class="pe-h">{{editId ? '改这件事' : '加一件事'}}</view>
<view class="field"><label>做什么</label>
<input class="input" placeholder="比如 带去打第二针" placeholder-class="placeholder"
value="{{form.title}}" bindinput="onFormTitle"/></view>
<view class="field"><label>哪天</label>
<picker mode="date" value="{{form.date}}" start="{{pickStart}}" end="{{pickEnd}}" bindchange="onFormDate">
<view class="picker-box">{{form.date}}</view>
</picker>
<view class="tiny">只能安排 {{pickStart}} 到 {{pickEnd}} 之间</view>
</view>
<view class="field"><label>备注</label>
<textarea class="textarea" placeholder="可不填" placeholder-class="placeholder"
value="{{form.description}}" bindinput="onFormDesc"></textarea></view>
<!-- 只有新增能选「每天」:已有的那条改成每天等于凭空复制 29 份,
用户想要的是「以后每天」,不是「把这条撒开」 -->
<view wx:if="{{!editId}}" class="field pe-daily">
<view class="pe-daily-main">
<view class="tt-b">应用到每天</view>
<view class="tt-p">从这天排到 {{monthEndLabel}},每天一条,可以分别打勾</view>
</view>
<switch checked="{{form.daily}}" color="#F5A947" bindchange="onFormDaily"></switch>
</view>
<view class="btn btn-primary btn-block" bindtap="onSubmit">{{saving ? '保存中…' : '保存'}}</view>
</view>
</view>
<!-- 套用养护方案 -->
<view wx:if="{{tplShow}}" class="pe-mask" bindtap="closeTpl">
<view class="pe-sheet" catchtap="noop">
<view class="pe-h">套一套方案到{{monthLabel}}</view>
<view class="tiny pt-sub">按 {{pet.stage}} 配的。套用是「往里加」,不会覆盖你已经排好的。</view>
<view wx:for="{{tplOptions}}" wx:key="id" class="pt-item" data-id="{{item.id}}" bindtap="applyTpl">
<view class="pt-name">{{item.name}}</view>
<view class="pt-sum">{{item.summary}}</view>
<view class="pt-meta">
<text wx:if="{{item.daily}}">每天 {{item.daily}} 项</text>
<text wx:if="{{item.weekly}}">每周 {{item.weekly}} 项</text>
<text wx:if="{{item.monthly}}">每月 {{item.monthly}} 项</text>
<text wx:if="{{item.once}}">一次性 {{item.once}} 项</text>
</view>
</view>
<view wx:if="{{!tplOptions.length}}" class="empty">这个阶段还没有配方案</view>
</view>
</view>
<bottom-sheet show="{{sheetShow}}" type="{{sheetType}}" bind:close="closeSheet"></bottom-sheet>
-70
View File
@@ -1,70 +0,0 @@
/* 竖线时间轴 */
.timeline{padding-left:var(--sp-6);position:relative}
.timeline::before{content:"";position:absolute;left:12rpx;top:12rpx;bottom:16rpx;width:4rpx;background:#E8DED0}
.time-block{position:relative;margin-bottom:var(--sp-4)}
.time-label{font-weight:var(--fw-b);color:var(--primary-dark);font-size:var(--fs-sm);margin-bottom:var(--sp-3);position:relative}
.time-date{margin-left:var(--sp-2);font-weight:var(--fw);color:var(--muted);font-size:var(--fs-cap)}
.time-label::before{
content:"";position:absolute;left:-34rpx;top:8rpx;width:24rpx;height:24rpx;border-radius:50%;
background:var(--primary);border:6rpx solid #fff;box-shadow:0 0 0 4rpx var(--primary-line);
}
.plan-task{
background:#fff;border-radius:var(--r-md);padding:var(--sp-4);box-shadow:var(--sd-1);
border:1rpx solid transparent;display:flex;gap:var(--sp-3);align-items:flex-start;
}
.plan-task.done{opacity:.55}
.pk-check{
width:44rpx;height:44rpx;border-radius:50%;border:4rpx solid #DED1C4;flex:none;margin-top:2rpx;
display:flex;align-items:center;justify-content:center;color:#fff;
}
.plan-task.done .pk-check{background:var(--green);border-color:var(--green)}
.pk-body{flex:1}
.pk-b{font-size:var(--fs-md);font-weight:var(--fw-b)}
.pk-p{color:var(--muted);font-size:var(--fs-sm);line-height:1.45;margin-top:var(--sp-1)}
.ai-plan-tip{color:var(--muted);font-size:var(--fs-sm);line-height:1.6;margin-bottom:var(--sp-3)}
/* 日历 */
/* AI 提取信息 */
.extracted{display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-3);margin:var(--sp-4) 0}
.extract{background:var(--surface-2);border-radius:var(--r-sm);padding:var(--sp-3);font-size:var(--fs-sm)}
.ex-b{display:block;font-size:var(--fs-sm);margin-bottom:6rpx;font-weight:var(--fw-b);color:var(--muted)}
/* 计划节点右侧的删除键。计划是用户自己排的,就得能删 */
.pk-del{flex:none;padding:0 var(--sp-1);color:var(--muted2);display:flex;align-items:center}
/* 加/改计划的浮层。字段只有三个,再开一页太重 */
.pe-mask{
position:fixed;inset:0;z-index:200;background:rgba(45,41,37,.45);
display:flex;align-items:flex-end;
}
.pe-sheet{
width:100%;background:var(--bg2);border-radius:var(--r-lg) var(--r-lg) 0 0;
padding:var(--sp-5) var(--pad-x) calc(var(--sp-5) + env(safe-area-inset-bottom));
box-shadow:var(--sd-3);max-height:82vh;overflow-y:auto;
}
.pe-h{font-size:var(--fs-lg);font-weight:var(--fw-b);margin-bottom:var(--sp-4);text-align:center}
/* 下月还空着时的提示。放在月历里而不是弹 toast——用户切到下月本来就是来排的 */
.plan-tip{
display:flex;align-items:center;gap:var(--sp-2);
background:var(--primary-soft);color:var(--primary-ink);
border-radius:var(--r-sm);padding:var(--sp-3);
font-size:var(--fs-sm);margin-bottom:var(--sp-3);
}
.pe-daily{display:flex;align-items:center;gap:var(--sp-4)}
.pe-daily-main{flex:1;min-width:0}
/* 空月份的提示条:一行放不下「文案 + 按钮」,改成 flex */
.plan-tip-b{flex:1;min-width:0}
/* 套方案浮层 */
.pt-sub{margin:calc(-1 * var(--sp-2)) 0 var(--sp-4);text-align:center}
.pt-item{
border:2rpx solid var(--line);border-radius:var(--r-md);
padding:var(--sp-4);margin-bottom:var(--sp-3);background:#fff;
}
.pt-name{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)}
.pt-sum{font-size:var(--fs-sm);color:var(--text-2);margin-top:6rpx;line-height:1.5}
.pt-meta{display:flex;flex-wrap:wrap;gap:var(--sp-3);margin-top:var(--sp-2);
font-size:var(--fs-cap);color:var(--muted)}
-53
View File
@@ -1,53 +0,0 @@
const store = require('../../utils/store.js');
const recordTypes = require('../../utils/recordTypes.js');
// 这一页只干一件事:把后端配置的记录事项按分组列出来,点一个进添加页。
// 原来它还带着健康洞察、体重趋势、健康时间轴——那三块搬到 pages/history 了,
// 从报告页进。一个「我要记一笔」的页面上摆着三块只读图表,
// 用户每次都得先滚过去才能找到要点的东西。
// tone-N 只给格子用(它自带背景色);组标题的颜色走 g-N。
// short 是去掉「记录」后缀的组名——「日常记录」在标题位置念着啰嗦
const G = { 'tone-1': 'g-1', 'tone-2': 'g-2', 'tone-3': 'g-3', 'tone-4': 'g-4' };
function decorate(g) {
return { ...g, g: G[g.tone] || 'g-1', short: (g.label || '').replace(/记录$/, '') };
}
Page({
data: { pet: {}, typeGroups: [], sheetShow: false, sheetType: '' },
onLoad() {
this._unsub = store.subscribe((pet) => this.setData({ pet }));
},
onUnload() {
if (this._unsub) this._unsub();
},
onShow() {
this.loadTypes();
store
.ready()
.then(() => this.setData({ pet: store.getPet() }))
.catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' }));
},
loadTypes() {
if (this.data.typeGroups.length) return;
recordTypes
.load()
.then((groups) => this.setData({ typeGroups: groups.map(decorate) }))
.catch(() => {});
},
goAdd(e) {
wx.navigateTo({ url: '/pages/addrecord/addrecord?type=' + e.currentTarget.dataset.type });
},
// 没建档时要能建档,所以这页留一个只用于 addPet 的弹层
onAddPet() {
wx.navigateTo({ url: '/pages/petform/petform' });
},
closeSheet() {
this.setData({ sheetShow: false });
},
onShareAppMessage() {
return { title: '用肉垫计划记录毛孩子的成长', path: '/pages/record/record' };
},
onShareTimeline() {
return { title: '用肉垫计划记录毛孩子的成长' };
},
});
-7
View File
@@ -1,7 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"bottom-sheet": "/components/bottom-sheet/bottom-sheet",
"pt-icon": "/components/pt-icon/index"
}
}
-32
View File
@@ -1,32 +0,0 @@
<nav-bar title="记录" show-back="{{true}}"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body no-fab">
<view wx:if="{{!pet.id}}" class="no-pet">
<view class="no-pet-ic"><pt-icon name="community" size="{{52}}"></pt-icon></view>
<view class="no-pet-b">还没有毛孩子的档案</view>
<view class="no-pet-p">建好档才能记体重、便便、饮食这些,趋势图也是从这些记录里长出来的。</view>
<view class="btn btn-dark" bindtap="onAddPet"><pt-icon name="plus" size="{{30}}"></pt-icon>建一份档案</view>
</view>
<!-- 分组来自后端 record_types,后台加一种不用发版。
每组一张白卡,图标收进小圆角色块(.rec-ic + .tone-N),标签用纯深色。
原来是整格铺 tone 底 + 浮在页面渐变上,一片肥色块。 -->
<block wx:for="{{typeGroups}}" wx:key="key">
<view class="rec-group card {{item.g}}">
<view class="rec-ghead"><view class="rec-bar"></view>{{item.short}}</view>
<view class="rec-grid">
<view wx:for="{{item.items}}" wx:for-item="t" wx:key="code" class="rec-item"
data-type="{{t.code}}" bindtap="goAdd" hover-class="rec-item-hover" hover-stay-time="60">
<view class="rec-ic {{item.tone}}"><pt-icon name="{{t.icon}}" size="{{46}}" fallback="note"></pt-icon></view>
<text class="rec-label">{{t.label}}</text>
</view>
</view>
</view>
</block>
<view wx:if="{{!typeGroups.length && pet.id}}" class="empty lg">还没有配置可记事项</view>
</view>
</scroll-view>
<!-- 只用于「没建档 → 建一份档案」 -->
<bottom-sheet show="{{sheetShow}}" type="{{sheetType}}" bind:close="closeSheet"></bottom-sheet>
-38
View File
@@ -1,38 +0,0 @@
/* 记录页只有这一页在用这套分组宫格,样式就放这里(不进 app.wxss)。
原来是整格铺 tone 浅色底 + 直接浮在页面渐变上,四个一行是一片肥色块。
改成:每组一张白卡收纳,颜色只集中在图标那个小圆角块上,标签用纯深色。 */
/* .card 自带 --sp-5 padding 和 margin-bottom,这里只把上下内边距收紧一点,
底部留白靠 card 的 margin-bottom,不再叠 margin-top */
.rec-group{padding:var(--sp-4) var(--sp-4) var(--sp-3)}
/* 组标题:一根同色系竖条 + 组名。颜色由外层 .g-N 决定 */
.rec-ghead{
display:flex;align-items:center;gap:var(--sp-2);
font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text);
margin-bottom:var(--sp-3);
}
.rec-bar{width:6rpx;height:28rpx;border-radius:3rpx;background:currentColor}
.g-1 .rec-bar{color:var(--primary)}
.g-2 .rec-bar{color:var(--green)}
.g-3 .rec-bar{color:var(--purple)}
.g-4 .rec-bar{color:var(--blue)}
.rec-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--sp-4) var(--sp-2)}
.rec-item{
display:flex;flex-direction:column;align-items:center;gap:var(--sp-2);
padding-bottom:var(--sp-2);border-radius:var(--r-md);
transition:transform .06s;
}
.rec-item-hover{transform:scale(.94)}
/* 图标的小圆角色块。tone-N(app.wxss)给它浅色底 + 深色 ink,
pt-icon 走 currentColor 正好染成 ink 色 */
.rec-ic{
width:96rpx;height:96rpx;border-radius:26rpx;
display:flex;align-items:center;justify-content:center;
}
/* 标签用纯深色,不跟着 tone 变 —— 四组底下的字统一是深灰,才不花 */
.rec-label{font-size:var(--fs-sm);color:var(--text-2);line-height:1.2}
-43
View File
@@ -1,43 +0,0 @@
const api = require('../../utils/api.js');
const { toastErr } = require('../../utils/ui.js');
Page({
data: { uid: '', kind: 'following', title: '关注', list: [], page: 1, hasMore: false, loaded: false },
onLoad(q) {
const kind = q && q.kind === 'followers' ? 'followers' : 'following';
this.setData({ uid: (q && q.id) || '', kind, title: kind === 'followers' ? '粉丝' : '关注' });
this.load(1);
},
load(page) {
api
.relations(this.data.uid, this.data.kind, page)
.then((res) => {
// WXML 表达式不支持字符串下标,首字母得在这里算好
const list = (res.list || []).map((u) => ({ ...u, initial: (u.nickname || '?').slice(0, 1) }));
const merged = page === 1 ? list : this.data.list.concat(list);
this.setData({ list: merged, page, loaded: true, hasMore: merged.length < (res.total || 0) });
})
.catch((e) => {
this.setData({ loaded: true });
toastErr(e);
});
},
loadMore() {
if (this.data.hasMore) this.load(this.data.page + 1);
},
goUser(e) {
wx.navigateTo({ url: `/pages/user/user?id=${e.currentTarget.dataset.id}` });
},
// 同样先本地翻转再发请求,失败回滚
toggleFollow(e) {
const i = e.currentTarget.dataset.index;
const u = this.data.list[i];
if (!u || u.is_self) return;
const next = !u.followed;
this.setData({ [`list[${i}].followed`]: next });
(next ? api.followUser(u.id) : api.unfollowUser(u.id)).catch((err) => {
this.setData({ [`list[${i}].followed`]: !next });
toastErr(err, '操作失败');
});
},
});
-6
View File
@@ -1,6 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pt-icon": "/components/pt-icon/index"
}
}
-20
View File
@@ -1,20 +0,0 @@
<nav-bar title="{{title}}" show-back="{{true}}"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}"
bindscrolltolower="loadMore">
<view class="page-body no-fab">
<view wx:for="{{list}}" wx:key="id" class="rel" bindtap="goUser" data-id="{{item.id}}">
<view class="rel-av">
<image wx:if="{{item.avatar_url}}" class="rel-av-img" src="{{item.avatar_url}}" mode="aspectFill"></image>
<block wx:else>{{item.initial}}</block>
</view>
<view class="rel-name">{{item.nickname}}<text wx:if="{{item.is_bot}}" class="ai-tag">AI</text></view>
<view wx:if="{{!item.is_self}}" class="follow-btn {{item.followed ? 'on' : ''}}"
catchtap="toggleFollow" data-index="{{index}}">{{item.followed ? '已关注' : '+ 关注'}}</view>
</view>
<view wx:if="{{loaded && !list.length}}" class="empty lg">
{{kind === 'followers' ? '还没有人关注 TA' : '还没有关注任何人,去宠友圈逛逛'}}
</view>
</view>
</scroll-view>
-12
View File
@@ -1,12 +0,0 @@
.rel{
display:flex;align-items:center;gap:var(--sp-3);
background:#fff;border-radius:var(--r-md);box-shadow:var(--sd-1);
padding:var(--sp-3) var(--sp-4);margin-bottom:var(--sp-2);
}
.rel-av{
width:80rpx;height:80rpx;border-radius:50%;flex:none;overflow:hidden;
display:flex;align-items:center;justify-content:center;
background:var(--primary-soft);color:var(--primary-ink);font-size:var(--fs-lg);font-weight:var(--fw-b);
}
.rel-av-img{width:100%;height:100%}
.rel-name{flex:1;min-width:0;font-size:var(--fs-md);font-weight:var(--fw-b)}
-168
View File
@@ -1,168 +0,0 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const { syncTabBar } = require('../../utils/tabbar.js');
function fmtDay(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.getMonth() + 1 + '月' + d.getDate() + '日';
}
// 体重点 → 趋势图。返回 { svg(折线 data-uri), dots(可点圆点,坐标为百分比) }。
// 从历史页搬过来的:报告页才是「看数据」的地方,图不该埋在二级页
function buildChart(points) {
const vals = points.map((p) => Number(p.value) || 0);
const W = 300, H = 100, pad = 12, n = vals.length;
let min = Math.min.apply(null, vals);
let max = Math.max.apply(null, vals);
if (max === min) max = min + 1;
const xs = (i) => (n === 1 ? W / 2 : pad + ((W - 2 * pad) * i) / (n - 1));
const ys = (v) => H - pad - ((H - 2 * pad) * (v - min)) / (max - min);
const pts = vals.map((v, i) => xs(i).toFixed(1) + ',' + ys(v).toFixed(1)).join(' ');
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="none">' +
'<polyline points="' + pts + '" fill="none" stroke="#73BE9D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>' +
'</svg>';
const dots = points.map((p, i) => ({
xp: Number(((xs(i) / W) * 100).toFixed(2)),
yp: Number(((ys(vals[i]) / H) * 100).toFixed(2)),
value: vals[i],
date: fmtDay(p.occurred_at),
note: p.note || '',
delta: i > 0 ? Number((vals[i] - vals[i - 1]).toFixed(2)) : null,
}));
return { svg: 'data:image/svg+xml,' + encodeURIComponent(svg), dots };
}
// 洞察等级 → 图标。alert/warn 用警告号,info 用趋势
function mapInsight(i) {
return { ...i, icon: i.level === 'info' ? 'trend' : 'warn' };
}
Page({
data: {
pet: {},
report: null,
gainText: '+0.0',
insights: [],
trendSvg: '',
trendStats: null,
trendDots: [],
selDot: null,
selIdx: -1,
summary: null,
bill: null,
billTop: null,
sheetShow: false,
sheetType: '',
},
onLoad() {
this._unsub = store.subscribe((pet) => {
this.setData({ pet });
if (!this._inited) return;
this.loadData();
});
},
onUnload() {
if (this._unsub) this._unsub();
},
onShow() {
syncTabBar(this);
store
.ready()
.then(() => {
this._inited = true;
this.setData({ pet: store.getPet() });
this.loadData();
})
.catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' }));
},
loadData() {
const id = store.currentPetId();
if (!id) return;
api
.weeklyReport(id)
.then((report) => {
const g = (report && report.weight_gain) || 0;
this.setData({ report, gainText: (g >= 0 ? '+' : '') + g.toFixed(1) });
})
.catch(() => {});
api
.petInsights(id)
.then((list) => this.setData({ insights: (list || []).map(mapInsight) }))
.catch(() => this.setData({ insights: [] }));
api
.healthSummary(id)
.then((summary) => this.setData({ summary }))
.catch(() => {});
// 账单只在报告页取一个概要,详细录入和流水在花销页
api
.getBill(id, 'month')
.then((bill) => {
const cats = (bill && bill.categories) || [];
this.setData({ bill, billTop: cats.length ? cats[0] : null });
})
.catch(() => {});
this.loadTrend(id);
},
loadTrend(id) {
api
.weightTrend(id)
.then((points) => {
points = points || [];
if (points.length < 2) {
this.setData({ trendSvg: '', trendStats: null, trendDots: [], selDot: null, selIdx: -1 });
return;
}
const vals = points.map((p) => Number(p.value) || 0);
const latest = vals[vals.length - 1];
const delta = Number((latest - vals[0]).toFixed(2));
const chart = buildChart(points);
const last = chart.dots.length - 1;
this.setData({
trendSvg: chart.svg,
trendDots: chart.dots,
selDot: chart.dots[last], // 默认选中最新一次
selIdx: last,
trendStats: { latest, delta, count: points.length },
});
})
.catch(() => {});
},
onTapPoint(e) {
const i = e.currentTarget.dataset.index;
this.setData({ selDot: this.data.trendDots[i], selIdx: i });
},
// 点一条洞察,跳到对应类型的记录入口(洞察带 action = 记录类型 code)
onTapInsight(e) {
const it = this.data.insights[e.currentTarget.dataset.index];
if (it && it.action) wx.navigateTo({ url: '/pages/addrecord/addrecord?type=' + it.action });
},
// 体重趋势的「记一笔」直接去体重记录页(原来开的 weight 弹层已经搬成独立页)
goWeight() {
wx.navigateTo({ url: '/pages/addrecord/addrecord?type=weight' });
},
goExpense() {
wx.navigateTo({ url: '/pages/expense/expense' });
},
goHistory() {
wx.navigateTo({ url: '/pages/history/history' });
},
openSheet(e) {
this.setData({ sheetType: e.currentTarget.dataset.type, sheetShow: true });
},
closeSheet() {
this.setData({ sheetShow: false });
},
onAddPet() {
wx.navigateTo({ url: '/pages/petform/petform' });
},
onShareAppMessage() {
const n = (this.data.pet && this.data.pet.name) || '我家毛孩子';
return { title: `${n} 的成长报告`, path: '/pages/report/report' };
},
onShareTimeline() {
const n = (this.data.pet && this.data.pet.name) || '我家毛孩子';
return { title: `${n} 的成长报告` };
},
});
-8
View File
@@ -1,8 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pet-switch": "/components/pet-switch/pet-switch",
"bottom-sheet": "/components/bottom-sheet/bottom-sheet",
"pt-icon": "/components/pt-icon/index"
}
}
-112
View File
@@ -1,112 +0,0 @@
<nav-bar title="成长报告"></nav-bar>
<view class="top-bar on-band"><pet-switch show-add="{{false}}"></pet-switch></view>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body tab-nofab">
<view wx:if="{{!pet.id}}" class="no-pet">
<view class="no-pet-ic"><pt-icon name="community" size="{{52}}"></pt-icon></view>
<view class="no-pet-b">还没有毛孩子的档案</view>
<view class="no-pet-p">成长报告和账单都基于记录生成,先建个档开始记吧。</view>
<view class="btn btn-dark" bindtap="onAddPet"><pt-icon name="plus" size="{{30}}"></pt-icon>建一份档案</view>
</view>
<block wx:else>
<!-- 本周概览:一句话小结 + 三个数 + 分享卡片 -->
<view class="card report-card">
<view class="section-head"><view class="sh-title">本周概览</view><view class="tag">{{report.health_status || '稳定成长'}}</view></view>
<view class="rc-text">{{report.summary || '这周还没有足够的记录,多记几笔就有小结了'}}</view>
<view class="stats-grid">
<view class="stat"><text class="stat-b">{{report.tasks_completed || 0}}</text><text class="stat-s">完成任务</text></view>
<view class="stat"><text class="stat-b">{{gainText}}</text><text class="stat-s">kg 变化</text></view>
<view class="stat"><text class="stat-b">{{report.high_risk_count || 0}}</text><text class="stat-s">高风险异常</text></view>
</view>
<view class="btn btn-primary btn-block rc-share" data-type="poster" bindtap="openSheet">
<pt-icon name="share" size="{{30}}"></pt-icon>生成分享卡片
</view>
</view>
<!-- 健康洞察:把散落的记录变成结论。以前埋在历史页,现在是报告页的主角。
点一条跳去记那一类。有洞察才显示这张卡 -->
<view wx:if="{{insights.length}}" class="card">
<view class="section-head"><view class="sh-title">健康洞察</view><view class="tiny">来自你的记录</view></view>
<view wx:for="{{insights}}" wx:key="title" class="ins ins-{{item.level}}" data-index="{{index}}" bindtap="onTapInsight">
<view class="ins-head">
<view class="ins-ic"><pt-icon name="{{item.icon}}" size="{{30}}"></pt-icon></view>
<view class="ins-title">{{item.title}}</view>
<view wx:if="{{item.action}}" class="ins-go"><pt-icon name="next" size="{{26}}"></pt-icon></view>
</view>
<view wx:if="{{item.detail}}" class="ins-detail">{{item.detail}}</view>
<view wx:if="{{item.evidence}}" class="ins-ev">{{item.evidence}}</view>
</view>
</view>
<!-- 体重趋势 -->
<view class="card">
<view class="section-head"><view class="sh-title">体重趋势</view><view class="link" bindtap="goWeight">记一笔</view></view>
<view wx:if="{{trendStats}}" class="trend-stats">最新 {{trendStats.latest}}kg · 较首次 {{trendStats.delta >= 0 ? '+' : ''}}{{trendStats.delta}}kg · 共 {{trendStats.count}} 次</view>
<block wx:if="{{trendSvg}}">
<view class="mini-chart">
<image class="chart-line" mode="scaleToFill" src="{{trendSvg}}"></image>
<view class="dot-layer">
<view wx:for="{{trendDots}}" wx:key="index"
class="dot {{selIdx === index ? 'on' : ''}}"
style="left:{{item.xp}}%;top:{{item.yp}}%"
data-index="{{index}}" bindtap="onTapPoint"></view>
</view>
</view>
<view wx:if="{{selDot}}" class="point-detail">
<view class="pd-main">
<text class="pd-date">{{selDot.date}}</text>
<text class="pd-weight">{{selDot.value}}kg</text>
<text wx:if="{{selDot.delta !== null}}" class="pd-delta {{selDot.delta > 0 ? 'up' : (selDot.delta < 0 ? 'down' : '')}}">{{selDot.delta > 0 ? '+' : ''}}{{selDot.delta}}kg</text>
</view>
<view wx:if="{{selDot.note}}" class="pd-note"><pt-icon name="note" size="{{26}}"></pt-icon> {{selDot.note}}</view>
<view wx:else class="pd-note muted">这次没有填备注</view>
</view>
<view class="chart-hint">点击圆点查看每次记录</view>
</block>
<view wx:else class="empty">先记录几次体重,这里会自动画出趋势曲线</view>
</view>
<!-- 健康摘要:紧凑状态带 + 导出就医摘要 -->
<view class="card">
<view class="section-head"><view class="sh-title">健康摘要</view><view class="link" data-type="vetSummary" bindtap="openSheet">就医摘要</view></view>
<view class="record-row"><text class="bold">疫苗进度</text><text class="muted">{{summary.vaccine_progress || '—'}}</text></view>
<view class="record-row"><text class="bold">驱虫状态</text><text class="muted">{{summary.deworm_status || '—'}}</text></view>
<view class="record-row"><text class="bold">体重趋势</text><text class="muted">{{summary.weight_trend || '—'}}</text></view>
<view class="record-row"><text class="bold">异常记录</text><text class="muted">{{summary.anomaly_count || 0}} 次</text></view>
</view>
<!-- 养宠账单:只给概要,详细录入和流水在花销页(原来这里内嵌了半套花销 UI,
还带个已经断掉的「记一笔」按钮)-->
<view class="card bill-card" bindtap="goExpense">
<view class="section-head"><view class="sh-title">养宠花销</view><view class="link">去记账 ›</view></view>
<view class="bill-sum">
<view class="bill-total"><text class="bill-cur">¥</text>{{bill.total || 0}}</view>
<view class="bill-sub">本月{{billTop ? ',' + billTop.category + '花得最多' : ''}}</view>
</view>
<view wx:if="{{bill.categories.length}}" class="bill-bars">
<view wx:for="{{bill.categories}}" wx:key="category" class="bill-row">
<text class="bill-cat">{{item.category}}</text>
<view class="mini-bar"><view class="mini-bar-fill" style="width:{{item.percent}}%"></view></view>
<text class="bill-amt">¥{{item.amount}}</text>
</view>
</view>
<view wx:else class="empty">这个月还没有记过花销</view>
</view>
<!-- 记录与趋势的完整流水在「全部记录」,报告页只放洞察和趋势 -->
<view class="profile-list">
<view class="profile-row" bindtap="goHistory">
<view class="pr-ic"><pt-icon name="note" size="{{34}}"></pt-icon></view>
<text class="pr-label">全部记录</text>
<text class="pr-val">按类型筛选、翻看历史</text>
<view class="pr-arrow"><pt-icon name="next" size="{{28}}"></pt-icon></view>
</view>
</view>
</block>
</view>
</scroll-view>
<bottom-sheet show="{{sheetShow}}" type="{{sheetType}}" bind:close="closeSheet"></bottom-sheet>
-60
View File
@@ -1,60 +0,0 @@
.report-card{background:linear-gradient(135deg,#FFFFFF,#F2FAF6)}
.rc-text{color:var(--text-2);font-size:var(--fs-md);line-height:1.6}
.rc-share{margin-top:var(--sp-4)}
/* ===== 健康洞察(从历史页搬来,报告页的主角)===== */
.ins{border-radius:var(--r-md);padding:var(--sp-4);margin-bottom:var(--sp-2)}
.ins:last-child{margin-bottom:0}
.ins-info{background:var(--surface-2)}
.ins-warn{background:var(--primary-soft)}
.ins-alert{background:var(--red-soft)}
.ins-head{display:flex;align-items:flex-start;gap:var(--sp-2)}
.ins-ic{flex:none;margin-top:2rpx}
.ins-info .ins-ic{color:var(--green-ink)}
.ins-warn .ins-ic{color:var(--primary-ink)}
.ins-alert .ins-ic{color:var(--red-ink)}
.ins-title{flex:1;font-size:var(--fs-md);font-weight:var(--fw-b);line-height:1.4}
.ins-go{flex:none;color:var(--muted2);margin-top:2rpx}
.ins-detail{color:var(--text-2);font-size:var(--fs-sm);line-height:1.6;margin-top:var(--sp-2)}
.ins-ev{color:var(--muted);font-size:var(--fs-cap);margin-top:var(--sp-2)}
/* ===== 体重趋势曲线(从历史页搬来)===== */
.trend-stats{margin-top:var(--sp-3);font-size:var(--fs-sm);color:var(--muted)}
.mini-chart{
height:256rpx;border-radius:var(--r-md);margin-top:var(--sp-4);position:relative;overflow:hidden;
background:
linear-gradient(180deg,rgba(115,190,157,.16),rgba(115,190,157,0)),
repeating-linear-gradient(0deg,#fff,#fff 60rpx,#EFE8DE 62rpx);
}
.chart-line{position:absolute;left:36rpx;right:36rpx;top:44rpx;bottom:44rpx;width:auto;height:auto}
.dot-layer{position:absolute;left:36rpx;right:36rpx;top:44rpx;bottom:44rpx}
.dot{position:absolute;width:40rpx;height:40rpx;margin:-20rpx 0 0 -20rpx;border-radius:50%}
.dot::after{content:'';position:absolute;left:50%;top:50%;width:18rpx;height:18rpx;margin:-9rpx 0 0 -9rpx;border-radius:50%;background:#fff;border:4rpx solid var(--green);box-sizing:border-box}
.dot.on::after{width:26rpx;height:26rpx;margin:-13rpx 0 0 -13rpx;background:var(--green);box-shadow:0 0 0 6rpx rgba(115,190,157,.25)}
.point-detail{
margin-top:var(--sp-3);padding:var(--sp-4);border-radius:var(--r-md);
background:var(--green-soft);border:1rpx solid #DCEFE4;
}
.pd-main{display:flex;align-items:baseline;gap:var(--sp-3)}
.pd-date{font-size:var(--fs-sm);color:var(--muted)}
.pd-weight{font-size:var(--fs-xl);font-weight:var(--fw-b);color:var(--green-ink)}
.pd-delta{font-size:var(--fs-sm);font-weight:var(--fw-b)}
.pd-delta.up{color:var(--red-ink)}
.pd-delta.down{color:var(--green-ink)}
.pd-note{margin-top:var(--sp-2);font-size:var(--fs-md);color:var(--text-2);line-height:1.5}
.pd-note.muted{color:var(--muted)}
.chart-hint{margin-top:var(--sp-2);font-size:var(--fs-cap);color:var(--muted);text-align:center}
/* ===== 养宠花销概要(详细在花销页)===== */
.bill-card:active{opacity:.85}
.bill-sum{margin:var(--sp-2) 0 var(--sp-4)}
.bill-total{font-size:56rpx;font-weight:var(--fw-b);color:var(--text);line-height:1.1}
.bill-cur{font-size:var(--fs-lg);color:var(--muted);margin-right:4rpx}
.bill-sub{font-size:var(--fs-sm);color:var(--muted);margin-top:4rpx}
.bill-bars{display:flex;flex-direction:column;gap:var(--sp-3)}
.bill-row{display:grid;grid-template-columns:88rpx 1fr 108rpx;gap:var(--sp-3);align-items:center;font-size:var(--fs-sm)}
.bill-cat{color:var(--text)}
.bill-amt{font-weight:var(--fw-b);text-align:right}
/* 账单条比全局 .bar 细一档,蓝色是「花费」的语义色 */
.mini-bar{height:var(--sp-2);border-radius:var(--r-full);background:#F0E7DD;overflow:hidden}
.mini-bar-fill{display:block;height:100%;border-radius:var(--r-full);background:var(--blue)}
-80
View File
@@ -1,80 +0,0 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const upload = require('../../utils/upload.js');
function completion(pet) {
if (!pet) return 0;
const fields = ['name', 'emoji', 'type', 'gender', 'birthday', 'weight', 'stage', 'age', 'color', 'breed'];
const filled = fields.filter((f) => pet[f]).length;
return Math.round((filled / fields.length) * 100);
}
Page({
data: {
pet: {},
user: {},
summary: { pets: 0, records: 0, reminders: 0, pro_status: 'none' },
profilePct: 0,
sheetShow: false,
sheetType: '',
},
onLoad() {
this._unsub = store.subscribe((pet) => this.setData({ pet, profilePct: completion(pet) }));
},
onUnload() {
if (this._unsub) this._unsub();
},
onShow() {
store
.ready()
.then(() => {
const pet = store.getPet();
this.setData({ pet, user: store.getUser() || {}, profilePct: completion(pet) });
this.loadSummary();
})
.catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' }));
},
loadSummary() {
api.userSummary().then((summary) => this.setData({ summary })).catch(() => {});
},
openSheet(e) {
this.setData({ sheetType: e.currentTarget.dataset.type, sheetShow: true });
},
// 自己的关注/粉丝。之前只有帖子上的「+关注」按钮,关注完没有任何地方能看
goRelations(e) {
const uid = (this.data.user && this.data.user.id) || '';
if (!uid) return wx.showToast({ title: '登录信息还没就绪', icon: 'none' });
wx.navigateTo({ url: `/pages/relations/relations?id=${uid}&kind=${e.currentTarget.dataset.kind}` });
},
goRecord() {
wx.navigateTo({ url: '/pages/record/record' });
},
onPickAvatar() {
upload
.chooseAndUploadImage()
.then((f) => api.updateProfile({ avatar_file_id: f.id }))
.then((user) => {
this.setData({ user });
store.setUser(user);
wx.showToast({ title: '头像已更新', icon: 'success' });
})
.catch((e) => {
if (e && e.canceled) return;
wx.showToast({ title: (e && e.message) || '上传失败', icon: 'none' });
});
},
onAddPet() {
wx.navigateTo({ url: '/pages/petform/petform' });
},
onEditPet() {
const id = store.currentPetId();
wx.navigateTo({ url: '/pages/petform/petform' + (id ? '?id=' + id : '') });
},
closeSheet() {
this.setData({ sheetShow: false });
this.loadSummary();
},
onShareAppMessage() {
return { title: '肉垫计划 · 新手养宠助手', path: '/pages/home/home' };
},
});
-8
View File
@@ -1,8 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"bottom-sheet": "/components/bottom-sheet/bottom-sheet",
"fab": "/components/fab/fab",
"pt-icon": "/components/pt-icon/index"
}
}
-19
View File
@@ -1,19 +0,0 @@
<nav-bar title="设置" show-back="{{true}}"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body no-fab">
<view class="profile-list">
<view class="profile-row" data-type="reminders" bindtap="openSheet"><view class="pr-ic"><pt-icon name="bell" size="{{34}}"></pt-icon></view><text class="pr-label">提醒设置</text><text class="pr-val">{{summary.reminders}} 条</text><view class="pr-arrow"><pt-icon name="next" size="{{28}}"></pt-icon></view></view>
</view>
<view class="profile-list">
<view class="profile-row" data-type="pro" bindtap="openSheet"><view class="pr-ic"><pt-icon name="like" size="{{34}}"></pt-icon></view><text class="pr-label">会员权益</text><text class="pr-val">{{summary.pro_status === 'active' ? '已开通' : '未开通'}}</text><view class="pr-arrow"><pt-icon name="next" size="{{28}}"></pt-icon></view></view>
<view class="profile-row" data-type="exportData" bindtap="openSheet"><view class="pr-ic"><pt-icon name="doc" size="{{34}}"></pt-icon></view><text class="pr-label">数据导出</text><text class="pr-val">文本</text><view class="pr-arrow"><pt-icon name="next" size="{{28}}"></pt-icon></view></view>
<view class="profile-row" data-type="feedback" bindtap="openSheet"><view class="pr-ic"><pt-icon name="comment" size="{{34}}"></pt-icon></view><text class="pr-label">意见反馈</text><text class="pr-val">去填写</text><view class="pr-arrow"><pt-icon name="next" size="{{28}}"></pt-icon></view></view>
<view class="profile-row" data-type="about" bindtap="openSheet"><view class="pr-ic"><pt-icon name="book" size="{{34}}"></pt-icon></view><text class="pr-label">关于我们</text><text class="pr-val">v0.2</text><view class="pr-arrow"><pt-icon name="next" size="{{28}}"></pt-icon></view></view>
</view>
</view>
</scroll-view>
<bottom-sheet show="{{sheetShow}}" type="{{sheetType}}" bind:close="closeSheet"></bottom-sheet>
-11
View File
@@ -1,11 +0,0 @@
.profile-header{text-align:center;padding:var(--sp-4) 0 var(--sp-5)}
.ph-avatar{margin:0 auto var(--sp-3);width:152rpx;height:152rpx;font-size:76rpx;border-radius:var(--r-lg);position:relative;overflow:visible}
.ph-avatar-img{width:100%;height:100%;border-radius:var(--r-lg)}
.ph-avatar-cam{
position:absolute;right:-6rpx;bottom:-6rpx;width:52rpx;height:52rpx;
display:flex;align-items:center;justify-content:center;color:var(--text-2);
background:#fff;border-radius:50%;box-shadow:var(--sd-1);
}
.ph-name{font-size:var(--fs-2xl);font-weight:var(--fw-b)}
.ph-meta{font-size:var(--fs-sm);margin-top:var(--sp-1)}
+39
View File
@@ -0,0 +1,39 @@
const { request } = require("../../utils/api");
Page({
data: {
latitude: 31.23,
longitude: 121.47,
markers: [],
status: "加载中",
updated: "",
},
onLoad(options) {
this.token = options.token;
this.load();
this.timer = setInterval(() => this.load(), 15000);
},
onUnload() {
clearInterval(this.timer);
},
async load() {
try {
const p = await request("/share/walk/" + this.token);
this.setData({
latitude: p.latitude,
longitude: p.longitude,
markers: [
{
id: 1,
latitude: p.latitude,
longitude: p.longitude,
title: "目前在这里",
},
],
status: p.status === "paused" ? "暂时停下休息" : "正在遛狗",
updated: new Date(p.recorded_at).toLocaleTimeString(),
});
} catch (e) {
this.setData({ status: "分享已结束或尚无位置" });
}
},
});
+1
View File
@@ -0,0 +1 @@
{}
+15
View File
@@ -0,0 +1,15 @@
<view class="screen">
<view class="eyebrow">肉垫计划 · 位置分享</view>
<view class="title">找到小短腿的脚印 🐾</view>
<map
latitude="{{latitude}}"
longitude="{{longitude}}"
scale="16"
markers="{{markers}}"
style="width: 100%; height: 70vh; border-radius: 32rpx"
/>
<view class="card">
<view>{{status}}</view>
<view class="muted">上次更新 {{updated}}</view>
</view>
</view>
+6
View File
@@ -0,0 +1,6 @@
page {
background: #f6f9f6;
}
.card {
margin-top: 20rpx;
}
+104
View File
@@ -0,0 +1,104 @@
const { secure, toast } = require("../../utils/api");
const today = () => new Date().toLocaleDateString("sv-SE");
Page({
data: {
pets: [],
tasks: [],
date: today(),
showForm: false,
title: "",
petIndex: 0,
category: "food",
time_of_day: "08:30",
repeat_rule: "daily",
done: 0,
},
onShow() {
this.load();
},
async load() {
try {
const [pets, result] = await Promise.all([
secure("/pets"),
secure("/tasks?date=" + this.data.date),
]);
const tasks = result.items.map((t) => ({
...t,
petName: (pets.find((p) => p.id === t.pet_id) || {}).name || "宠物",
}));
this.setData({
pets,
tasks,
done: tasks.filter((t) => t.completed).length,
});
} catch (e) {
toast(e);
}
},
async toggle(e) {
const t = this.data.tasks[e.currentTarget.dataset.index];
try {
await secure("/tasks/" + t.id + "/completion", "PUT", {
date: this.data.date,
completed: !t.completed,
});
this.load();
} catch (err) {
toast(err);
}
},
open() {
this.setData({ showForm: true });
},
close() {
this.setData({ showForm: false });
},
field(e) {
this.setData({ [e.currentTarget.dataset.key]: e.detail.value });
},
petChange(e) {
this.setData({ petIndex: Number(e.detail.value) });
},
timeChange(e) {
this.setData({ time_of_day: e.detail.value });
},
categoryChange(e) {
this.setData({ category: e.detail.value });
},
repeatChange(e) {
this.setData({ repeat_rule: e.detail.value });
},
async save() {
if (!this.data.pets.length || !this.data.title.trim()) {
wx.showToast({ title: "请选择宠物并填写任务名", icon: "none" });
return;
}
try {
await secure("/tasks", "POST", {
pet_id: this.data.pets[this.data.petIndex].id,
title: this.data.title,
category: this.data.category,
time_of_day: this.data.time_of_day,
repeat_rule: this.data.repeat_rule,
});
this.setData({ showForm: false, title: "" });
this.load();
} catch (e) {
toast(e);
}
},
async remove(e) {
const t = this.data.tasks[e.currentTarget.dataset.index];
const result = await wx.showModal({
title: "删除任务",
content: "确定删除「" + t.title + "」吗?",
});
if (!result.confirm) return;
try {
await secure("/tasks/" + t.id, "DELETE");
this.load();
} catch (err) {
toast(err);
}
},
});
+1
View File
@@ -0,0 +1 @@
{ "usingComponents": { "tab-bar": "/components/tab-bar/index" } }
+134
View File
@@ -0,0 +1,134 @@
<view class="screen">
<view class="eyebrow">LITTLE THINGS, BIG LOVE</view>
<view class="title">今日小任务 ☑</view>
<view class="summary">
<view>今天也要照顾好它们</view>
<strong>
{{done}}
<text>/ {{tasks.length}}</text>
</strong>
<view class="progress">
<view style="width:{{tasks.length?done/tasks.length*100:0}}%"></view>
</view>
</view>
<view class="row">
<text class="section-title">{{date}} 的日常</text>
<text class="add" bindtap="open">+ 添加任务</text>
</view>
<view class="card list">
<view wx:for="{{tasks}}" wx:key="id" class="task">
<view
class="check {{item.completed?'done':''}}"
data-index="{{index}}"
bindtap="toggle"
>
{{item.completed?'✓':''}}
</view>
<view class="icon">
{{item.category==='water'?'💧':item.category==='food'?'🥣':'🐾'}}
</view>
<view class="info" data-index="{{index}}" bindtap="toggle">
<strong>{{item.title}}</strong>
<text>
{{item.petName}} · {{item.time_of_day}} ·
{{item.repeat_rule==='daily'?'每天':item.repeat_rule==='weekdays'?'工作日':'周末'}}
</text>
</view>
<text class="remove" data-index="{{index}}" bindtap="remove">×</text>
</view>
<view wx:if="{{!tasks.length}}" class="empty">
从喝水、喂食开始,安排温柔的小日常 ✨
</view>
</view>
<button class="primary" bindtap="open">+ 新建日常任务</button>
</view>
<view wx:if="{{showForm}}" class="overlay">
<view class="sheet">
<view class="row">
<strong>新建小任务</strong>
<text bindtap="close">×</text>
</view>
<view class="field">
<view class="field-label">任务名称</view>
<input
placeholder="比如:更换饮水"
value="{{title}}"
data-key="title"
bindinput="field"
/>
</view>
<view class="field">
<view class="field-label">照顾谁</view>
<picker
range="{{pets}}"
range-key="name"
value="{{petIndex}}"
bindchange="petChange"
>
<view class="picker">{{pets[petIndex].name||'请先添加宠物'}}</view>
</picker>
</view>
<view class="field">
<view class="field-label">类型</view>
<radio-group bindchange="categoryChange">
<label>
<radio value="food" checked="{{category==='food'}}" color="#194f42" />
喂食
</label>
<label>
<radio
value="water"
checked="{{category==='water'}}"
color="#194f42"
/>
喝水
</label>
<label>
<radio
value="other"
checked="{{category==='other'}}"
color="#194f42"
/>
其他
</label>
</radio-group>
</view>
<view class="field">
<view class="field-label">时间</view>
<picker mode="time" value="{{time_of_day}}" bindchange="timeChange">
<view class="picker">{{time_of_day}}</view>
</picker>
</view>
<view class="field">
<view class="field-label">重复</view>
<radio-group bindchange="repeatChange">
<label>
<radio
value="daily"
checked="{{repeat_rule==='daily'}}"
color="#194f42"
/>
每天
</label>
<label>
<radio
value="weekdays"
checked="{{repeat_rule==='weekdays'}}"
color="#194f42"
/>
工作日
</label>
<label>
<radio
value="weekends"
checked="{{repeat_rule==='weekends'}}"
color="#194f42"
/>
周末
</label>
</radio-group>
</view>
<button class="primary" bindtap="save">保存任务</button>
</view>
</view>
<tab-bar current="/pages/tasks/tasks" />
+123
View File
@@ -0,0 +1,123 @@
.summary {
background: #194f42;
color: white;
border-radius: 34rpx;
padding: 34rpx;
}
.summary > view:first-child {
font-size: 24rpx;
color: #bbe3c4;
}
.summary strong {
display: block;
font-size: 55rpx;
margin: 10rpx 0;
}
.summary strong text {
font-size: 30rpx;
color: #b4d8be;
}
.progress {
height: 13rpx;
background: #3f7566;
border-radius: 99rpx;
overflow: hidden;
}
.progress view {
height: 100%;
background: #e9ad6a;
border-radius: 99rpx;
}
.add {
color: #2b8a63;
font-size: 24rpx;
font-weight: 700;
}
.list {
padding: 8rpx 26rpx;
}
.task {
display: flex;
align-items: center;
gap: 15rpx;
padding: 22rpx 0;
border-bottom: 1rpx solid #eef2ee;
}
.task:last-child {
border: 0;
}
.check {
width: 42rpx;
height: 42rpx;
border: 3rpx solid #cbdcd1;
border-radius: 13rpx;
text-align: center;
line-height: 37rpx;
color: white;
}
.check.done {
background: #389867;
border-color: #389867;
}
.icon {
width: 65rpx;
height: 65rpx;
background: #eff5ee;
border-radius: 18rpx;
display: grid;
place-items: center;
font-size: 34rpx;
}
.info {
flex: 1;
}
.info strong,
.info text {
display: block;
}
.info text {
font-size: 21rpx;
color: #92a498;
margin-top: 5rpx;
}
.remove {
font-size: 36rpx;
color: #c4d0c7;
}
.overlay {
position: fixed;
inset: 0;
background: #102a24a0;
z-index: 30;
display: flex;
align-items: flex-end;
}
.sheet {
width: 100%;
background: #f6f9f6;
border-radius: 35rpx 35rpx 0 0;
padding: 32rpx 32rpx calc(env(safe-area-inset-bottom) + 30rpx);
max-height: 85vh;
overflow: auto;
}
.sheet > .row strong {
font-size: 33rpx;
}
.sheet > .row text {
font-size: 43rpx;
}
.sheet radio-group {
display: flex;
flex-wrap: wrap;
gap: 15rpx;
}
.sheet label {
font-size: 23rpx;
display: flex;
align-items: center;
}
.picker {
background: white;
padding: 23rpx;
border-radius: 20rpx;
}
-101
View File
@@ -1,101 +0,0 @@
const api = require('../../utils/api.js');
const store = require('../../utils/store.js');
const { toastErr } = require('../../utils/ui.js');
const TAG_CLASS = { 求助: 'warn', 经验: 'blue', 精选: 'purple', 避坑: 'red', 晒宠: '' };
// 审核状态 → 展示文案(只在看自己主页时出现)
const STATUS_TEXT = { pending: '审核中', rejected: '未通过' };
function fmtAgo(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const min = Math.floor((Date.now() - d.getTime()) / 60000);
if (min < 1) return '刚刚';
if (min < 60) return min + ' 分钟前';
if (min < 60 * 24) return Math.floor(min / 60) + ' 小时前';
if (min < 60 * 24 * 7) return Math.floor(min / 1440) + ' 天前';
const p = (n) => (n < 10 ? '0' + n : '' + n);
return p(d.getMonth() + 1) + '-' + p(d.getDate());
}
Page({
data: { uid: '', card: null, posts: [], page: 1, hasMore: false, loading: true },
onLoad(q) {
const uid = q && q.id;
if (!uid) return wx.showToast({ title: '缺少用户', icon: 'none' });
this.setData({ uid });
this.loadCard();
this.loadPosts(1);
},
loadCard() {
api
.userCard(this.data.uid)
.then((card) => this.setData({ card, loading: false }))
.catch((e) => {
this.setData({ loading: false });
toastErr(e);
});
},
loadPosts(page) {
api
.userPosts(this.data.uid, page)
.then((res) => {
const list = (res.list || []).map((p) => {
const tag = (p.tags || [])[0] || '';
return {
...p,
timeText: fmtAgo(p.created_at),
author_emoji: p.author_emoji || '🐾',
tag,
tagClass: TAG_CLASS[tag] || '',
// 只有看自己主页才会拿到非「已发布」的帖子,给个审核状态标
statusText: STATUS_TEXT[p.status] || '',
};
});
const merged = page === 1 ? list : this.data.posts.concat(list);
this.setData({ posts: merged, page, hasMore: merged.length < (res.total || 0) });
})
.catch(() => {});
},
loadMore() {
if (this.data.hasMore) this.loadPosts(this.data.page + 1);
},
// 关注状态先本地翻转,请求失败再翻回去——等接口返回按钮才变会显得很迟钝
toggleFollow() {
const c = this.data.card;
if (!c || c.is_self) return;
const next = !c.followed;
this.setData({
'card.followed': next,
'card.follower_count': Math.max(0, c.follower_count + (next ? 1 : -1)),
});
(next ? api.followUser(c.id) : api.unfollowUser(c.id)).catch((e) => {
this.setData({ 'card.followed': !next, 'card.follower_count': c.follower_count });
toastErr(e, '操作失败');
});
},
goRelations(e) {
const kind = e.detail.kind;
wx.navigateTo({ url: `/pages/relations/relations?id=${this.data.uid}&kind=${kind}` });
},
// 自己看自己的主页时,头部按钮是「装扮」而不是「关注」
goDecorate() {
wx.navigateTo({ url: '/pages/decorate/decorate' });
},
// 别人的宠物只是名片,点了不跳;自己的才切过去看
goPet(e) {
const c = this.data.card;
if (!c || !c.is_self) return;
store.switchPet(e.detail.id);
wx.switchTab({ url: '/pages/home/home' });
},
previewImage(e) {
const { urls, cur } = e.currentTarget.dataset;
if (urls && urls.length) wx.previewImage({ urls, current: cur });
},
onShareAppMessage() {
const n = (this.data.card && this.data.card.nickname) || '宠友';
return { title: `${n} 的主页`, path: `/pages/user/user?id=${this.data.uid}` };
},
});
-7
View File
@@ -1,7 +0,0 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pt-icon": "/components/pt-icon/index",
"profile-head": "/components/profile-head/index"
}
}
-45
View File
@@ -1,45 +0,0 @@
<wxs module="im">
module.exports.isUrl = function (s) { return s && s.indexOf('http') === 0; }
</wxs>
<nav-bar title="{{card ? card.nickname : '宠友主页'}}" show-back="{{true}}"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}"
bindscrolltolower="loadMore">
<view class="page-body no-fab">
<view wx:if="{{loading}}" class="empty lg">加载中…</view>
<block wx:elif="{{card}}">
<profile-head card="{{card}}" bind:follow="toggleFollow"
bind:relations="goRelations" bind:decorate="goDecorate"
bind:pet="goPet"></profile-head>
<view wx:for="{{posts}}" wx:key="id" class="post-card">
<view class="post-head">
<view class="post-avatar">{{item.author_emoji}}</view>
<view class="post-user">
<view class="pu-b">{{item.author_name}}</view>
<view class="pu-s">{{item.timeText}}</view>
</view>
<view wx:if="{{item.statusText}}" class="post-status st-{{item.status}}">{{item.statusText}}</view>
<view wx:if="{{item.tag}}" class="tag {{item.tagClass}}">{{item.tag}}</view>
</view>
<view wx:if="{{item.status === 'pending'}}" class="status-note">内容审核中,通过后其他人才能看到</view>
<view wx:elif="{{item.status === 'rejected'}}" class="status-note st-note-red">未通过内容安全审核,仅你可见</view>
<view class="post-content">{{item.content}}</view>
<view wx:if="{{item.images.length}}" class="photo-grid">
<block wx:for="{{item.images}}" wx:for-item="img" wx:key="*this">
<image wx:if="{{im.isUrl(img)}}" class="photo-tile" src="{{img}}" mode="aspectFill"
bindtap="previewImage" data-urls="{{item.images}}" data-cur="{{img}}"></image>
<view wx:else class="photo-tile">{{img}}</view>
</block>
</view>
<view class="post-actions">
<view class="pa-btn"><pt-icon name="like" size="{{32}}"></pt-icon><text class="pa-n">{{item.like_count || 0}}</text></view>
<view class="pa-btn"><pt-icon name="comment" size="{{32}}"></pt-icon><text class="pa-n">{{item.comment_count || 0}}</text></view>
</view>
</view>
<view wx:if="{{!posts.length}}" class="empty lg">TA 还没有发过帖子</view>
</block>
</view>
</scroll-view>
-15
View File
@@ -1,15 +0,0 @@
/* 头部整块(头像/昵称/签名/计数/养宠数据/宠物墙)已抽成 components/profile-head,
帖子卡走 app.wxss 的 .post-card。这里没有页面独有样式了。 */
/* 审核状态标(只在看自己主页出现) */
.post-status{
padding:2rpx var(--sp-2);border-radius:var(--r-full);
font-size:var(--fs-cap);font-weight:var(--fw-b);margin-right:var(--sp-2);
}
.post-status.st-pending{background:#FDECC8;color:#B26A00}
.post-status.st-rejected{background:var(--red-soft);color:var(--red-ink)}
.status-note{
margin:0 0 var(--sp-2);padding:var(--sp-2) var(--sp-3);border-radius:var(--r-sm);
background:var(--surface-2);color:var(--muted);font-size:var(--fs-cap);line-height:1.5;
}
.status-note.st-note-red{background:var(--red-soft);color:var(--red-ink)}
+165
View File
@@ -0,0 +1,165 @@
const { secure, toast } = require("../../utils/api");
Page({
data: {
pets: [],
selected: [],
walk: null,
points: [],
polyline: [],
latitude: 31.23,
longitude: 121.47,
duration: "00:00",
distance: "0.00",
share: false,
},
onShow() {
this.load();
this.clock = setInterval(() => this.tick(), 1000);
},
onHide() {
this.stopTimers();
},
onUnload() {
this.stopTimers();
},
stopTimers() {
clearInterval(this.clock);
clearInterval(this.tracker);
},
async load() {
try {
const [pets, walks] = await Promise.all([
secure("/pets"),
secure("/walks"),
]);
const selected = (wx.getStorageSync("walk_pet_ids") || []).filter((id) =>
pets.some((p) => p.id === id),
);
const walk =
walks.find((w) => w.status === "active" || w.status === "paused") ||
null;
this.setData({ pets, selected, walk });
if (walk) {
this.renderWalk(walk);
this.track();
}
} catch (e) {
toast(e);
}
},
pick(e) {
if (this.data.walk) return;
this.setData({ selected: e.detail.selected });
wx.setStorageSync("walk_pet_ids", e.detail.selected);
},
async start() {
if (!this.data.selected.length) {
wx.showToast({ title: "请选择同行的狗狗", icon: "none" });
return;
}
try {
const walk = await secure("/walks", "POST", {
pet_ids: this.data.selected,
});
this.setData({ walk });
this.track();
this.point();
} catch (e) {
toast(e);
}
},
track() {
clearInterval(this.tracker);
this.tracker = setInterval(() => this.point(), 15000);
},
async point() {
if (!this.data.walk || this.data.walk.status !== "active") return;
try {
const pos = await wx.getLocation({ type: "gcj02" });
const walk = await secure(
"/walks/" + this.data.walk.id + "/points",
"POST",
{ latitude: pos.latitude, longitude: pos.longitude },
);
this.renderWalk(walk);
} catch (e) {
console.warn(e);
}
},
renderWalk(walk) {
const pts = (walk.points || []).map((p) => ({
latitude: p.latitude,
longitude: p.longitude,
}));
const last = pts[pts.length - 1];
this.setData({
walk,
points: pts,
polyline:
pts.length > 1 ? [{ points: pts, color: "#268e6a", width: 5 }] : [],
latitude: last ? last.latitude : this.data.latitude,
longitude: last ? last.longitude : this.data.longitude,
distance: (walk.distance_meters / 1000).toFixed(2),
share: walk.share_enabled,
});
this.tick();
},
tick() {
const walk = this.data.walk;
if (!walk) return;
const end = walk.ended_at
? new Date(walk.ended_at)
: walk.status === "paused"
? new Date(walk.paused_at || Date.now())
: new Date();
const sec = Math.max(
0,
Math.floor((end - new Date(walk.started_at)) / 1000) -
(walk.paused_seconds || 0),
);
this.setData({
duration:
String(Math.floor(sec / 60)).padStart(2, "0") +
":" +
String(sec % 60).padStart(2, "0"),
});
},
async action(e) {
const kind = e.currentTarget.dataset.kind;
try {
const walk = await secure(
"/walks/" + this.data.walk.id + "/" + kind,
"POST",
);
this.renderWalk(walk);
if (kind === "finish") {
clearInterval(this.tracker);
wx.showToast({ title: "今天的快乐已记录", icon: "success" });
}
} catch (err) {
toast(err);
}
},
async shareChange(e) {
try {
const walk = await secure(
"/walks/" + this.data.walk.id + "/share",
"PUT",
{ enabled: e.detail.value },
);
this.renderWalk(walk);
} catch (err) {
toast(err);
}
},
onShareAppMessage() {
const walk = this.data.walk;
return {
title: "肉垫计划 · 看看我们遛到哪儿了 🐾",
path:
walk && walk.share_enabled
? "/pages/share/share?token=" + walk.share_token
: "/pages/home/home",
};
},
});
+1
View File
@@ -0,0 +1 @@
{ "usingComponents": { "tab-bar": "/components/tab-bar/index", "pet-picker": "/components/pet-picker/index" } }
+80
View File
@@ -0,0 +1,80 @@
<view class="screen walk-screen">
<view class="eyebrow">PAWPRINT WALK</view>
<view class="title">一起去兜风 🐾</view>
<view class="map-box">
<map
id="walk-map"
latitude="{{latitude}}"
longitude="{{longitude}}"
scale="16"
show-location="{{walk!=null}}"
polyline="{{polyline}}"
style="width: 100%; height: 100%"
/>
<view class="map-tag">
{{walk?walk.status==='active'?'记录中':'已暂停':'等待出发'}}
</view>
</view>
<view class="card session">
<view class="eyebrow">{{walk?'快乐进行时':'出门走走吧'}}</view>
<view class="session-title">
{{walk?'跟小短腿一起探索':'选择今天同行的狗狗'}}
</view>
<view class="stats">
<view>
<strong>{{duration}}</strong>
<text>时长</text>
</view>
<view>
<strong>{{distance}}</strong>
<text>公里</text>
</view>
<view>
<strong>{{points.length}}</strong>
<text>足迹</text>
</view>
</view>
<view class="picker-title">同行伙伴 · 可多选</view>
<pet-picker
pets="{{pets}}"
selected="{{walk?walk.pet_ids:selected}}"
bind:change="pick"
/>
<view wx:if="{{!pets.length}}" class="empty">
先到「宠物」添加你的柯基吧 🐕
</view>
<button
wx:if="{{!walk||walk.status==='finished'}}"
class="primary main-button"
bindtap="start"
>
开始遛狗 →
</button>
<view wx:else class="actions">
<button
class="secondary"
data-kind="{{walk.status==='active'?'pause':'resume'}}"
bindtap="action"
>
{{walk.status==='active'?'暂停':'继续'}}
</button>
<button class="primary" data-kind="finish" bindtap="action">
结束遛狗
</button>
</view>
<view wx:if="{{walk&&walk.status!=='finished'}}" class="share-row">
<view>
<strong>分享实时位置</strong>
<text>打开后可分享给朋友,结束遛狗会自动关闭</text>
</view>
<switch color="#278465" checked="{{share}}" bindchange="shareChange" />
</view>
<button wx:if="{{share}}" class="share-button" open-type="share">
发给朋友看位置 ↗
</button>
</view>
<view class="privacy">
🔒 只有你主动打开分享时,朋友才能查看这次遛狗的位置。
</view>
</view>
<tab-bar current="/pages/walk/walk" />
+105
View File
@@ -0,0 +1,105 @@
.walk-screen {
padding-left: 0;
padding-right: 0;
}
.walk-screen > .eyebrow,
.walk-screen > .title {
margin-left: 32rpx;
}
.map-box {
height: 450rpx;
position: relative;
overflow: hidden;
}
.map-tag {
position: absolute;
top: 25rpx;
left: 30rpx;
background: white;
color: #288363;
padding: 12rpx 21rpx;
border-radius: 99rpx;
font-size: 23rpx;
box-shadow: 0 8rpx 25rpx #173e3622;
}
.session {
margin: -30rpx 25rpx 0;
position: relative;
z-index: 2;
padding: 32rpx;
}
.session-title {
font-size: 35rpx;
font-weight: 800;
margin: 12rpx 0 30rpx;
}
.stats {
display: flex;
border-bottom: 2rpx solid #edf1eb;
padding-bottom: 25rpx;
}
.stats > view {
flex: 1;
border-right: 2rpx solid #e9efea;
padding-left: 22rpx;
}
.stats > view:first-child {
padding-left: 0;
}
.stats > view:last-child {
border: 0;
}
.stats strong {
display: block;
font-size: 38rpx;
}
.stats text {
font-size: 22rpx;
color: #99aa9d;
}
.picker-title {
font-weight: 700;
margin: 26rpx 0 18rpx;
}
.main-button {
margin-top: 30rpx;
}
.actions {
display: flex;
gap: 14rpx;
margin-top: 28rpx;
}
.actions button {
flex: 1;
}
.share-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 28rpx;
}
.share-row strong,
.share-row text {
display: block;
}
.share-row text {
font-size: 21rpx;
color: #90a199;
max-width: 450rpx;
margin-top: 5rpx;
}
.share-button {
margin-top: 20rpx;
background: #e6f1e8;
color: #267d59;
border-radius: 20rpx;
font-size: 25rpx;
}
.privacy {
font-size: 22rpx;
color: #739180;
background: #e9f3e7;
border-radius: 22rpx;
padding: 22rpx;
margin: 20rpx 25rpx;
}