feat: 养护模板/社区运营/体重趋势/计划日历 + 雪花ID重构 + 文件表与图片上传

后端
- 主键改雪花字符串 ID(pkg/idgen + Base.BeforeCreate),全表外键/JWT/中间件随之调整
- 新增 files 表:/api/upload 按 MD5 去重,返回 {id,url,md5},服务端只收图片
- 头像/记录附图/帖子图改 file_id 关联,读取解析为 URL
- 养护模板(物种×阶段)后台可配 + AI 生成草稿;建档按模板生成任务/计划/提醒
- 社区 AI 运营:虚拟账号池 + 每日定时/手动生成,帖子带 AI 标
- 计划路线图节点带真实日期;首页周历与计划日历同源;新增 day-plan 当日安排
- 体重趋势接口带备注;记录列表分页

小程序
- 公共图片上传 utils/upload.js(仅图片);记录拍照/我的头像/社区发图三处接入
- 首页日历点选查当日任务;记录页体重趋势可点看备注 + 时间轴分页折叠
- 计划页日历点选查当日计划;自定义 tabBar 高度调整

后台(React)
- 新增「养护模板」「社区运营」页;用户管理加机器人筛选

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-06 08:48:15 +08:00
parent 609f7d06cf
commit 2f5dd6eefc
83 changed files with 2253 additions and 355 deletions
+41 -25
View File
@@ -1,5 +1,6 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const upload = require('../../utils/upload.js');
function fmtDate(iso) {
if (!iso) return '';
@@ -39,8 +40,10 @@ Component({
optSel: {},
dateVals: { vaccine: '2026-07-21', deworm: '2026-07-15' },
wInput: '',
wNote: '',
costAmount: '',
postContent: '',
postImages: [],
addName: '',
addBirthday: '',
addWeight: '',
@@ -81,6 +84,7 @@ Component({
patch.optSel = {};
patch.costAmount = '';
patch.postContent = '';
patch.postImages = [];
patch.addName = '';
patch.addBirthday = '';
patch.addWeight = '';
@@ -90,7 +94,10 @@ Component({
patch.riskData = null;
patch.saving = false;
}
if (type === 'weight') patch.wInput = (pet.weight || '').replace('kg', '');
if (type === 'weight') {
patch.wInput = (pet.weight || '').replace('kg', '');
patch.wNote = '';
}
if (type === 'editPet') {
// 预填当前宠物档案
patch.addName = pet.name || '';
@@ -185,8 +192,26 @@ Component({
this.setData({ [`dateVals.${e.currentTarget.dataset.key}`]: e.detail.value });
},
onWInput(e) { this.setData({ wInput: e.detail.value }); },
onWNote(e) { this.setData({ wNote: e.detail.value }); },
onCostInput(e) { this.setData({ costAmount: e.detail.value }); },
onPostInput(e) { this.setData({ postContent: e.detail.value }); },
onPickPostImages() {
const left = 9 - this.data.postImages.length;
if (left <= 0) return wx.showToast({ title: '最多 9 张', icon: 'none' });
upload
.chooseAndUploadImages(left)
.then((list) => this.setData({ postImages: this.data.postImages.concat(list) }))
.catch((e) => {
if (e && e.canceled) return;
wx.showToast({ title: (e && e.message) || '上传失败', icon: 'none' });
});
},
onRemovePostImage(e) {
const i = e.currentTarget.dataset.index;
const arr = this.data.postImages.slice();
arr.splice(i, 1);
this.setData({ postImages: arr });
},
onAddName(e) { this.setData({ addName: e.detail.value }); },
onAddBirthday(e) { this.setData({ addBirthday: e.detail.value }); },
onAddWeight(e) { this.setData({ addWeight: e.detail.value }); },
@@ -290,33 +315,24 @@ Component({
if (!w) w = (this.data.pet.weight || '').replace('kg', '');
const num = parseFloat(w) || 0;
const weight = w.includes('kg') ? w : w + 'kg';
this.createAndClose({ type: 'weight', icon: '⚖️', title: '体重 ' + weight, num_value: num });
this.createAndClose({ type: 'weight', icon: '⚖️', title: '体重 ' + weight, num_value: num, description: (this.data.wNote || '').trim() });
},
onSavePhoto() {
const id = store.currentPetId();
if (!id) return this.close();
wx.chooseMedia({
count: 1,
mediaType: ['image'],
success: (res) => {
const path = res.tempFiles[0].tempFilePath;
wx.showLoading({ title: '上传中...' });
api
.uploadFile(path)
.then((up) =>
api.createRecord(id, { type: 'photo', icon: '📷', title: '成长照片', image_url: up.url }),
)
.then((saved) => {
wx.hideLoading();
this.triggerEvent('saved', saved);
this.close();
})
.catch((e) => {
wx.hideLoading();
wx.showToast({ title: e.message || '上传失败', icon: 'none' });
});
},
});
upload
.chooseAndUploadImage()
.then((f) =>
api.createRecord(id, { type: 'photo', icon: '📷', title: '成长照片', image_file_id: f.id, image_url: f.url }),
)
.then((saved) => {
this.triggerEvent('saved', saved);
this.close();
})
.catch((e) => {
if (e && e.canceled) return;
wx.showToast({ title: (e && e.message) || '上传失败', icon: 'none' });
});
},
// 组装宠物档案表单
@@ -416,7 +432,7 @@ Component({
identity,
content,
tags: [tag],
images: [],
image_file_ids: this.data.postImages.map((i) => i.id),
});
this.triggerEvent('posted');
this.close();
@@ -18,7 +18,7 @@ module.exports.sel = function (map, key, index, def) {
<view class="field"><label>{{pet.name}} 当前体重</label>
<input class="input" value="{{wInput}}" bindinput="onWInput" type="digit"/></view>
<view class="field"><label>备注</label>
<textarea class="textarea" placeholder="例如:最近食欲不错,活动量正常" placeholder-class="placeholder"></textarea></view>
<textarea class="textarea" placeholder="例如:最近食欲不错,活动量正常" placeholder-class="placeholder" value="{{wNote}}" bindinput="onWNote"></textarea></view>
<button class="btn btn-primary btn-block" bindtap="onSaveWeight">保存记录</button>
</block>
@@ -261,6 +261,15 @@ module.exports.sel = function (map, key, index, def) {
<view class="option {{u.sel(optSel,'ptag',2,0)?'selected':''}}" data-group="ptag" data-index="2" bindtap="onOpt">经验</view>
<view class="option {{u.sel(optSel,'ptag',3,0)?'selected':''}}" data-group="ptag" data-index="3" bindtap="onOpt">避坑</view>
</view>
<view class="field"><label>图片(可选,仅图片)</label>
<view class="img-picker">
<view wx:for="{{postImages}}" wx:key="id" class="img-thumb">
<image src="{{item.url}}" mode="aspectFill"></image>
<view class="img-del" catchtap="onRemovePostImage" data-index="{{index}}">×</view>
</view>
<view wx:if="{{postImages.length < 9}}" class="img-add" bindtap="onPickPostImages"></view>
</view>
</view>
<button class="btn btn-primary btn-block" bindtap="onCreatePost">发布到宠友圈</button>
</block>
@@ -16,7 +16,7 @@
.sheet-scroll{
box-sizing:border-box;
max-height:78vh;
padding:0 36rpx calc(48rpx + env(safe-area-inset-bottom));
padding:0 36rpx calc(120rpx + env(safe-area-inset-bottom));
}
.sheet-scroll::-webkit-scrollbar,.plan-chat::-webkit-scrollbar{width:0;height:0;display:none}
.sheetbar{width:88rpx;height:10rpx;border-radius:999rpx;background:#E1D8CF;margin:0 auto 24rpx}
@@ -34,3 +34,10 @@
/* 弹层内的日期选择器展示 */
.picker-box{color:var(--text)}
.del-btn{background:var(--red-soft);color:var(--red)}
/* 图片选择器 */
.img-picker{display:flex;flex-wrap:wrap;gap:16rpx}
.img-thumb{position:relative;width:160rpx;height:160rpx;border-radius:20rpx;overflow:hidden}
.img-thumb image{width:100%;height:100%}
.img-del{position:absolute;top:0;right:0;width:40rpx;height:40rpx;line-height:38rpx;text-align:center;background:rgba(0,0,0,.55);color:#fff;font-size:30rpx;border-radius:0 20rpx 0 20rpx}
.img-add{width:160rpx;height:160rpx;border-radius:20rpx;border:2rpx dashed #D8CFC4;display:flex;align-items:center;justify-content:center;font-size:56rpx;color:#B9AE9F;background:#FBFAF8}
+2 -2
View File
@@ -1,7 +1,7 @@
.tabbar{
position:fixed;left:0;right:0;bottom:0;z-index:50;
height:calc(156rpx + env(safe-area-inset-bottom));
padding-bottom:calc(16rpx + env(safe-area-inset-bottom));
height:calc(108rpx + env(safe-area-inset-bottom));
padding-bottom:calc(6rpx + env(safe-area-inset-bottom));
background:rgba(255,255,255,.94);
border-top:1rpx solid #EEE6DC;
display:flex;
+1
View File
@@ -17,6 +17,7 @@ function mapPost(p) {
liked: false,
tag,
tagClass: TAG_CLASS[tag] || '',
is_ai: !!p.is_ai,
};
}
+8 -2
View File
@@ -1,3 +1,6 @@
<wxs module="im">
module.exports.isUrl = function (s) { return s && s.indexOf('http') === 0; }
</wxs>
<nav-bar title="宠友圈"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
@@ -21,12 +24,15 @@
<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"><text class="pu-b">{{item.author_name}}</text><text class="pu-s">宠友</text></view>
<view class="post-user"><text class="pu-b">{{item.author_name}}<text wx:if="{{item.is_ai}}" class="ai-tag">AI</text></text><text class="pu-s">宠友</text></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">
<view wx:for="{{item.images}}" wx:for-item="img" wx:key="*this" class="photo-tile">{{img}}</view>
<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"></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">{{item.liked ? '♥' : '♡'}} {{item.like_count}}</view>
+1
View File
@@ -28,6 +28,7 @@
}
.post-user{flex:1}
.pu-b{font-size:28rpx;display:block;font-weight:700}
.ai-tag{display:inline-block;margin-left:10rpx;padding:0 10rpx;border-radius:8rpx;background:#EAF0FF;color:#3B6BE5;font-size:20rpx;font-weight:700;vertical-align:1rpx}
.pu-s{font-size:24rpx;color:var(--muted)}
.post-content{font-size:28rpx;line-height:1.62;color:#443D37;margin-bottom:24rpx}
+26 -1
View File
@@ -1,6 +1,12 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
function todayStr() {
const d = new Date();
const p = (n) => (n < 10 ? '0' + n : '' + n);
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
}
function mapTask(t) {
return {
id: t.id,
@@ -18,10 +24,14 @@ Page({
summary: { greeting: '', insights: [], week: [], advice: '', health_pct: 0, health_status: '正常' },
tasks: [],
firstArticle: null,
selectedDate: '',
taskLabel: '今日任务',
todayDate: '',
sheetShow: false,
sheetType: '',
},
onLoad() {
this.setData({ todayDate: todayStr() });
this._unsub = store.subscribe((pet) => {
this.setData({ pet });
this.loadAll();
@@ -67,11 +77,26 @@ Page({
loadTasks() {
const id = store.currentPetId();
if (!id) return;
const date = this.data.selectedDate || this.data.todayDate;
api
.getTasks(id)
.getTasks(id, date)
.then((tasks) => this.setData({ tasks: (tasks || []).map(mapTask) }))
.catch(() => {});
},
onTapDay(e) {
const { date, active } = e.currentTarget.dataset;
if (!date) return;
const parts = date.split('-');
this.setData({
selectedDate: date,
taskLabel: active ? '今日任务' : +parts[1] + '月' + +parts[2] + '日',
});
this.loadTasks();
},
backToToday() {
this.setData({ selectedDate: this.data.todayDate, taskLabel: '今日任务' });
this.loadTasks();
},
onTapTask(e) {
const i = e.currentTarget.dataset.index;
const t = this.data.tasks[i];
+7 -3
View File
@@ -32,15 +32,18 @@
</view>
<view class="date-strip">
<view wx:for="{{summary.week}}" wx:key="day" class="day {{item.active ? 'active' : ''}} {{item.has_dot ? 'has-dot' : ''}}">
<view wx:for="{{summary.week}}" wx:key="date"
class="day {{item.active ? 'active' : ''}} {{item.date === selectedDate ? 'sel' : ''}} {{item.has_dot ? 'has-dot' : ''}}"
data-date="{{item.date}}" data-active="{{item.active}}" bindtap="onTapDay">
<text>{{item.weekday}}</text><text class="day-b">{{item.day}}</text>
</view>
</view>
<view class="card">
<view class="section-head">
<view class="sh-title">今日任务</view>
<view class="link" bindtap="completeTasks">全部完成</view>
<view class="sh-title">{{taskLabel}}</view>
<view wx:if="{{selectedDate && selectedDate !== todayDate}}" class="link" bindtap="backToToday">回今天</view>
<view wx:else class="link" bindtap="completeTasks">全部完成</view>
</view>
<view class="task-list">
<view wx:for="{{tasks}}" wx:key="title" class="task {{item.done ? 'done' : ''}}" data-index="{{index}}" bindtap="onTapTask">
@@ -48,6 +51,7 @@
<view class="task-text"><text class="tt-b">{{item.title}}</text><view class="tt-p">{{item.sub}}</view></view>
<view wx:if="{{item.priority}}" class="priority">{{item.priority}}</view>
</view>
<view wx:if="{{tasks.length === 0}}" class="task-empty">这天没有任务记录</view>
</view>
</view>
+2
View File
@@ -35,7 +35,9 @@
}
.day-b{display:block;color:var(--text);font-size:30rpx;margin-top:8rpx;font-weight:700}
.day.active{border-color:#FFD39A;background:#FFF4E4;color:var(--primary-dark)}
.day.sel{border-color:var(--primary);background:#FFEFD6;color:var(--primary-dark);box-shadow:0 0 0 2rpx rgba(245,169,71,.35)}
.day.has-dot::after{content:"";display:block;width:10rpx;height:10rpx;border-radius:50%;background:var(--green);margin:8rpx auto 0}
.task-empty{padding:36rpx 8rpx;text-align:center;color:var(--muted);font-size:26rpx}
.tt-b{font-size:28rpx;font-weight:700}
.tt-p{font-size:24rpx;color:var(--muted);margin-top:8rpx;line-height:1.45}
+44 -2
View File
@@ -1,6 +1,18 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
// "2026-07-04" → "7月4日"
function fmtMD(date) {
if (!date) return '';
const p = date.split('-');
if (p.length < 3) return '';
return +p[1] + '月' + +p[2] + '日';
}
function pad2(n) {
return n < 10 ? '0' + n : '' + n;
}
function buildCells(year, month, taskedDays, today) {
const first = new Date(year, month - 1, 1);
const lead = (first.getDay() + 6) % 7; // 周一为一周起点
@@ -10,7 +22,7 @@ function buildCells(year, month, taskedDays, today) {
const cells = [];
for (let i = 0; i < lead; i++) cells.push({ day: 0 });
for (let d = 1; d <= daysInMonth; d++) {
cells.push({ day: d, tasked: !!set[d], today: d === today });
cells.push({ day: d, date: year + '-' + pad2(month) + '-' + pad2(d), tasked: !!set[d], today: d === today });
}
return cells;
}
@@ -23,6 +35,8 @@ Page({
calCells: [],
calLabel: '',
calLoaded: false,
selectedCalDate: '',
dayDetail: null,
aiInput: '',
aiPlan: null,
aiBusy: false,
@@ -56,7 +70,12 @@ Page({
if (!id) return;
api
.getPlan(id)
.then((plan) => this.setData({ plan }))
.then((plan) => {
if (plan && plan.tasks) {
plan.tasks = plan.tasks.map((t) => ({ ...t, dateLabel: fmtMD(t.date) }));
}
this.setData({ plan });
})
.catch(() => this.setData({ plan: null }));
},
loadCalendar() {
@@ -70,6 +89,29 @@ Page({
calLabel: `${res.year}${res.month}`,
calLoaded: true,
});
if (res.today) {
this.selectCalDay(res.year + '-' + pad2(res.month) + '-' + pad2(res.today));
}
})
.catch(() => {});
},
onTapCalDay(e) {
const date = e.currentTarget.dataset.date;
if (date) this.selectCalDay(date);
},
selectCalDay(date) {
const id = store.currentPetId();
if (!id) return;
this.setData({ selectedCalDate: date });
api
.dayPlan(id, date)
.then((res) => {
const nodes = (res.plan_nodes || []).map((n) => ({ title: n.title, desc: n.description, done: n.done }));
const tasks = (res.tasks || []).map((t) => ({ title: t.title, desc: t.description, done: t.done }));
const reminders = (res.reminders || []).map((r) => ({ title: r.title }));
this.setData({
dayDetail: { date, nodes, tasks, reminders, empty: !nodes.length && !tasks.length && !reminders.length },
});
})
.catch(() => {});
},
+19 -2
View File
@@ -27,7 +27,7 @@
</view>
<view class="timeline">
<view wx:for="{{plan.tasks}}" wx:key="id" class="time-block">
<view class="time-label">{{item.day_label}}</view>
<view class="time-label">{{item.day_label}}<text wx:if="{{item.dateLabel}}" class="time-date">{{item.dateLabel}}</text></view>
<view class="plan-task {{item.done ? 'done' : ''}}">
<view class="pk-check" catchtap="toggleTimelineTask" data-id="{{item.id}}">{{item.done ? '✓' : ''}}</view>
<view class="pk-body" data-type="{{item.sheet_type}}" bindtap="openSheet">
@@ -46,9 +46,26 @@
<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="{{calCells}}" wx:key="index"
class="cal-cell {{item.today ? 'today' : ''}} {{item.tasked ? 'tasked' : ''}} {{item.day === 0 ? 'blank' : ''}}">{{item.day || ''}}</view>
class="cal-cell {{item.today ? 'today' : ''}} {{item.tasked ? 'tasked' : ''}} {{item.day === 0 ? 'blank' : ''}} {{item.date && item.date === selectedCalDate ? 'sel' : ''}}"
data-date="{{item.date}}" bindtap="onTapCalDay">{{item.day || ''}}</view>
</view>
</view>
<view wx:if="{{dayDetail}}" class="day-detail">
<view class="dd-title">{{dayDetail.date}} 的安排</view>
<block wx:if="{{!dayDetail.empty}}">
<view wx:for="{{dayDetail.nodes}}" wx:key="title" class="dd-item">
<text class="dd-tag plan">计划</text><text class="dd-b {{item.done ? 'done' : ''}}">{{item.title}}</text>
</view>
<view wx:for="{{dayDetail.tasks}}" wx:key="title" class="dd-item">
<text class="dd-tag task">任务</text><text class="dd-b {{item.done ? 'done' : ''}}">{{item.title}}</text>
</view>
<view wx:for="{{dayDetail.reminders}}" wx:key="title" class="dd-item">
<text class="dd-tag rem">提醒</text><text class="dd-b">{{item.title}}</text>
</view>
</block>
<view wx:else class="dd-empty">这天没有安排</view>
</view>
</view>
<!-- AI 计划 -->
+14
View File
@@ -17,6 +17,7 @@
.timeline::before{content:"";position:absolute;left:12rpx;top:12rpx;bottom:16rpx;width:4rpx;background:#E8DED0}
.time-block{position:relative;margin-bottom:28rpx}
.time-label{font-weight:900;color:var(--primary-dark);font-size:26rpx;margin-bottom:16rpx;position:relative}
.time-date{margin-left:12rpx;font-weight:600;color:var(--muted);font-size:22rpx}
.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 #F5D5A8;
@@ -44,6 +45,19 @@
.cal-cell.blank{background:transparent}
.cal-cell.today{background:#FFF3DF;color:var(--primary-dark)}
.cal-cell.tasked::after{content:"";position:absolute;bottom:10rpx;width:10rpx;height:10rpx;border-radius:50%;background:var(--green)}
.cal-cell.sel{background:var(--primary);color:#fff}
.cal-cell.sel.tasked::after{background:#fff}
.day-detail{margin-top:24rpx;border-top:1rpx solid var(--line);padding-top:24rpx}
.dd-title{font-weight:900;font-size:28rpx;margin-bottom:16rpx}
.dd-item{display:flex;align-items:center;gap:16rpx;padding:14rpx 0}
.dd-tag{flex:none;font-size:20rpx;font-weight:700;padding:2rpx 12rpx;border-radius:999rpx}
.dd-tag.plan{background:#FFF2DC;color:var(--primary-dark)}
.dd-tag.task{background:#E6F4EA;color:#2E7D5B}
.dd-tag.rem{background:#EAF0FF;color:#3B6BE5}
.dd-b{font-size:27rpx;color:var(--text)}
.dd-b.done{text-decoration:line-through;color:var(--muted)}
.dd-empty{padding:20rpx 0;color:var(--muted);font-size:26rpx;text-align:center}
.extracted{display:grid;grid-template-columns:1fr 1fr;gap:16rpx;margin:24rpx 0}
.extract{background:#FBFAF8;border-radius:30rpx;padding:20rpx;font-size:24rpx}
+15
View File
@@ -1,5 +1,6 @@
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;
@@ -39,6 +40,20 @@ Page({
openSheet(e) {
this.setData({ sheetType: e.currentTarget.dataset.type, sheetShow: true });
},
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() {
this.setData({ sheetType: 'addPet', sheetShow: true });
},
+5 -1
View File
@@ -3,7 +3,11 @@
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body">
<view class="profile-header">
<view class="avatar ph-avatar">{{pet.emoji}}</view>
<view class="avatar ph-avatar" bindtap="onPickAvatar">
<image wx:if="{{user.avatar_url}}" class="ph-avatar-img" src="{{user.avatar_url}}" mode="aspectFill"></image>
<block wx:else>{{pet.emoji}}</block>
<view class="ph-avatar-cam">📷</view>
</view>
<view class="ph-name">{{pet.name}}</view>
<view class="muted ph-meta">{{pet.age}}{{pet.weight}}{{pet.stage}}</view>
<view wx:if="{{pet.breed || pet.color}}" class="muted ph-meta">{{pet.breed}}{{pet.breed && pet.color ? ' · ' : ''}}{{pet.color}}</view>
+3 -1
View File
@@ -1,7 +1,9 @@
.page-body{padding:8rpx 40rpx calc(60rpx + env(safe-area-inset-bottom))}
.profile-header{text-align:center;padding:24rpx 0 32rpx}
.ph-avatar{margin:0 auto 20rpx;width:152rpx;height:152rpx;font-size:76rpx;border-radius:56rpx}
.ph-avatar{margin:0 auto 20rpx;width:152rpx;height:152rpx;font-size:76rpx;border-radius:56rpx;position:relative;overflow:visible}
.ph-avatar-img{width:100%;height:100%;border-radius:56rpx}
.ph-avatar-cam{position:absolute;right:-6rpx;bottom:-6rpx;width:52rpx;height:52rpx;line-height:52rpx;text-align:center;font-size:26rpx;background:#fff;border-radius:50%;box-shadow:var(--shadow)}
.ph-name{font-size:44rpx;font-weight:800}
.ph-meta{font-size:26rpx;margin-top:10rpx}
+95 -2
View File
@@ -22,10 +22,51 @@ function mapRecord(r) {
};
}
function fmtDay(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.getMonth() + 1 + '月' + d.getDate() + '日';
}
// 用真实体重点生成趋势图:返回 { svg(折线), 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 };
}
const REC_PAGE = 6;
Page({
data: {
pet: {},
timeline: [],
recPage: 1,
recTotal: 0,
recHasMore: false,
trendSvg: '',
trendStats: null,
trendDots: [],
selDot: null,
selIdx: -1,
sheetShow: false,
sheetType: '',
},
@@ -54,9 +95,61 @@ Page({
const id = store.currentPetId();
if (!id) return;
api
.getRecords(id)
.then((records) => this.setData({ timeline: (records || []).map(mapRecord) }))
.getRecords(id, { page: 1, pageSize: REC_PAGE })
.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(() => {});
this.loadTrend();
},
loadMoreRecords() {
const id = store.currentPetId();
if (!id) return;
const next = this.data.recPage + 1;
api
.getRecords(id, { page: next, pageSize: REC_PAGE })
.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();
},
loadTrend() {
const id = store.currentPetId();
if (!id) {
this.setData({ trendSvg: '', trendStats: null, trendDots: [], selDot: null, selIdx: -1 });
return;
}
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 lastIdx = chart.dots.length - 1;
this.setData({
trendSvg: chart.svg,
trendDots: chart.dots,
selDot: chart.dots[lastIdx], // 默认选中最新一次
selIdx: lastIdx,
trendStats: { latest, delta, count: points.length },
});
})
.catch(() => {});
},
onTapPoint(e) {
const i = e.currentTarget.dataset.index;
this.setData({ selDot: this.data.trendDots[i], selIdx: i });
},
openSheet(e) {
this.openSheetType(e.currentTarget.dataset.type);
+26 -3
View File
@@ -23,9 +23,29 @@
<view class="card">
<view class="section-head"><view class="sh-title">体重趋势</view><view class="link" data-type="weight" bindtap="openSheet">记录</view></view>
<view class="mini-chart">
<image class="chart-line" mode="scaleToFill" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMDAgMTAwIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJub25lIj48cG9seWxpbmUgcG9pbnRzPSIwLDgwIDUwLDcyIDEwMCw2NiAxNTAsNTQgMjAwLDQ1IDI1MCwzMiAzMDAsMjYiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzczQkU5RCIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48ZyBmaWxsPSIjNzNCRTlEIj48Y2lyY2xlIGN4PSIwIiBjeT0iODAiIHI9IjQiLz48Y2lyY2xlIGN4PSI1MCIgY3k9IjcyIiByPSI0Ii8+PGNpcmNsZSBjeD0iMTAwIiBjeT0iNjYiIHI9IjQiLz48Y2lyY2xlIGN4PSIxNTAiIGN5PSI1NCIgcj0iNCIvPjxjaXJjbGUgY3g9IjIwMCIgY3k9IjQ1IiByPSI0Ii8+PGNpcmNsZSBjeD0iMjUwIiBjeT0iMzIiIHI9IjQiLz48Y2lyY2xlIGN4PSIzMDAiIGN5PSIyNiIgcj0iNCIvPjwvZz48L3N2Zz4="></image>
</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">📝 {{selDot.note}}</view>
<view wx:else class="pd-note muted">这次没有填备注</view>
</view>
<view class="chart-hint">点击圆点查看每次记录</view>
</block>
<view wx:else class="chart-empty">先记录几次体重,这里会自动画出趋势曲线</view>
</view>
<view class="card">
@@ -35,7 +55,10 @@
<view class="event-dot">{{item.icon}}</view>
<view><text class="he-b">{{item.title}}</text><view class="he-p">{{item.desc}}</view></view>
</view>
<view wx:if="{{timeline.length === 0}}" class="tl-empty">还没有记录,用上面的按钮记一条吧</view>
</view>
<view wx:if="{{recHasMore}}" class="tl-more" bindtap="loadMoreRecords">查看更多(共 {{recTotal}} 条)</view>
<view wx:elif="{{recPage > 1}}" class="tl-more" bindtap="collapseRecords">收起</view>
</view>
</view>
</scroll-view>
+18
View File
@@ -17,8 +17,26 @@
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 #73BE9D;box-sizing:border-box}
.dot.on::after{width:26rpx;height:26rpx;margin:-13rpx 0 0 -13rpx;background:#73BE9D;box-shadow:0 0 0 6rpx rgba(115,190,157,.25)}
.trend-stats{margin-top:16rpx;font-size:26rpx;color:var(--muted)}
.point-detail{margin-top:20rpx;padding:24rpx 28rpx;border-radius:32rpx;background:#F3FAF6;border:1rpx solid #DCEFE4}
.pd-main{display:flex;align-items:baseline;gap:20rpx}
.pd-date{font-size:26rpx;color:var(--muted)}
.pd-weight{font-size:40rpx;font-weight:800;color:#2E7D5B}
.pd-delta{font-size:26rpx;font-weight:700}
.pd-delta.up{color:var(--red)}
.pd-delta.down{color:#2E7D5B}
.pd-note{margin-top:12rpx;font-size:28rpx;color:#4A4A46;line-height:1.5}
.pd-note.muted{color:var(--muted)}
.chart-hint{margin-top:12rpx;font-size:22rpx;color:var(--muted);text-align:center}
.chart-empty{margin-top:24rpx;padding:48rpx 24rpx;border-radius:36rpx;background:#FBFAF8;color:var(--muted);font-size:26rpx;text-align:center}
.health-timeline{display:flex;flex-direction:column;gap:20rpx}
.tl-empty{padding:36rpx 8rpx;text-align:center;color:var(--muted);font-size:26rpx}
.tl-more{margin-top:20rpx;padding:20rpx;text-align:center;color:var(--primary-dark);background:#FBFAF8;border-radius:28rpx;font-size:26rpx;font-weight:700}
.health-event{display:flex;gap:20rpx;background:#FBFAF8;border-radius:32rpx;padding:24rpx}
.event-dot{
width:68rpx;height:68rpx;border-radius:26rpx;flex:none;
+9 -3
View File
@@ -46,13 +46,18 @@ const api = {
onboarding: (body) => request({ url: '/api/onboarding', method: 'POST', data: body }),
// 记录
getRecords: (id, type) =>
request({ url: `/api/pets/${id}/records${type ? `?type=${type}` : ''}` }),
getRecords: (id, { type, page, pageSize } = {}) => {
const qs = [];
if (type) qs.push(`type=${type}`);
if (page) qs.push(`page=${page}`);
if (pageSize) qs.push(`page_size=${pageSize}`);
return request({ url: `/api/pets/${id}/records${qs.length ? '?' + qs.join('&') : ''}` });
},
createRecord: (id, body) => request({ url: `/api/pets/${id}/records`, method: 'POST', data: body }),
weightTrend: (id) => request({ url: `/api/pets/${id}/records/weight-trend` }),
// 任务
getTasks: (id) => request({ url: `/api/pets/${id}/tasks` }),
getTasks: (id, date) => request({ url: `/api/pets/${id}/tasks${date ? `?date=${date}` : ''}` }),
toggleTask: (taskId) => request({ url: `/api/tasks/${taskId}/toggle`, method: 'POST' }),
completeAllTasks: (id) => request({ url: `/api/pets/${id}/tasks/complete-all`, method: 'POST' }),
@@ -60,6 +65,7 @@ const api = {
getPlan: (id) => request({ url: `/api/pets/${id}/plan` }),
planCalendar: (id, month) =>
request({ url: `/api/pets/${id}/plan/calendar${month ? `?month=${month}` : ''}` }),
dayPlan: (id, date) => request({ url: `/api/pets/${id}/day-plan?date=${date}` }),
createAIPlan: (id, input) => request({ url: `/api/pets/${id}/ai-plan`, method: 'POST', data: { input } }),
applyAIPlan: (planId) => request({ url: `/api/ai-plan/${planId}/apply`, method: 'POST' }),
togglePlanTask: (taskId) => request({ url: `/api/plan-tasks/${taskId}/toggle`, method: 'POST' }),
+55
View File
@@ -0,0 +1,55 @@
// 公共图片上传:选图(仅图片)→ 上传后端(/api/uploadMD5 去重)→ 返回 {id,url,md5}
const { uploadFile } = require('./request.js');
const IMG_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif'];
// 仅允许图片:按扩展名二次校验(chooseMedia mediaType 已限制,这里双保险)
function isImagePath(p) {
const m = /\.([a-zA-Z0-9]+)(\?.*)?$/.exec(p || '');
return !!m && IMG_EXT.indexOf(m[1].toLowerCase()) >= 0;
}
// 选择并上传图片,返回 Promise<[{id,url,md5}]>。用户取消时 reject({canceled:true})。
function chooseAndUploadImages(count) {
count = count || 1;
return new Promise((resolve, reject) => {
wx.chooseMedia({
count,
mediaType: ['image'], // 仅图片,不允许视频/文件
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success(res) {
const files = (res.tempFiles || []).filter((f) => isImagePath(f.tempFilePath));
if (!files.length) {
reject(new Error('只能上传图片文件'));
return;
}
wx.showLoading({ title: '上传中...', mask: true });
Promise.all(files.map((f) => uploadFile(f.tempFilePath)))
.then((list) => {
wx.hideLoading();
resolve(list);
})
.catch((e) => {
wx.hideLoading();
reject(e);
});
},
fail(err) {
const msg = (err && err.errMsg) || '';
if (/cancel/i.test(msg)) {
reject({ canceled: true });
return;
}
reject(new Error(msg || '选择图片失败'));
},
});
});
}
// 单张图片,返回 Promise<{id,url,md5}>
function chooseAndUploadImage() {
return chooseAndUploadImages(1).then((list) => list[0]);
}
module.exports = { chooseAndUploadImages, chooseAndUploadImage, isImagePath };