feat: rebuild pet app and backend around core care flows
This commit is contained in:
@@ -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, '保存失败');
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"nav-bar": "/components/nav-bar/nav-bar",
|
||||
"pt-icon": "/components/pt-icon/index"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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',
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"nav-bar": "/components/nav-bar/nav-bar",
|
||||
"pt-icon": "/components/pt-icon/index"
|
||||
},
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "新手知识"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)}
|
||||
@@ -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: '宠友圈 · 和铲屎官们交流养宠经验' };
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, '保存失败');
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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": "装扮主页"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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' };
|
||||
},
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"nav-bar": "/components/nav-bar/nav-bar",
|
||||
"pt-icon": "/components/pt-icon/index"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)}
|
||||
@@ -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: '用肉垫计划记录毛孩子的成长' };
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
@@ -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 +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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 || '', // 用画好的卡当分享封面
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"nav-bar": "/components/nav-bar/nav-bar",
|
||||
"pt-icon": "/components/pt-icon/index"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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: '新手养宠知识合集' };
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{ "usingComponents": { "tab-bar": "/components/tab-bar/index", "pet-picker": "/components/pet-picker/index" } }
|
||||
@@ -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" />
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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' };
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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(); },
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"nav-bar": "/components/nav-bar/nav-bar",
|
||||
"pt-icon": "/components/pt-icon/index"
|
||||
},
|
||||
"navigationBarTitleText": "肉垫计划"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, '保存失败');
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"nav-bar": "/components/nav-bar/nav-bar",
|
||||
"pt-icon": "/components/pt-icon/index"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)}
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{ "usingComponents": { "tab-bar": "/components/tab-bar/index" } }
|
||||
@@ -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" />
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: '我给毛孩子做了份养护计划' };
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)}
|
||||
@@ -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: '用肉垫计划记录毛孩子的成长' };
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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, '操作失败');
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"nav-bar": "/components/nav-bar/nav-bar",
|
||||
"pt-icon": "/components/pt-icon/index"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)}
|
||||
@@ -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} 的成长报告` };
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)}
|
||||
@@ -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' };
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)}
|
||||
|
||||
@@ -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: "分享已结束或尚无位置" });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
page {
|
||||
background: #f6f9f6;
|
||||
}
|
||||
.card {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{ "usingComponents": { "tab-bar": "/components/tab-bar/index" } }
|
||||
@@ -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" />
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}` };
|
||||
},
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"nav-bar": "/components/nav-bar/nav-bar",
|
||||
"pt-icon": "/components/pt-icon/index",
|
||||
"profile-head": "/components/profile-head/index"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)}
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{ "usingComponents": { "tab-bar": "/components/tab-bar/index", "pet-picker": "/components/pet-picker/index" } }
|
||||
@@ -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" />
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user