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:
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,6 +17,7 @@ function mapPost(p) {
|
||||
liked: false,
|
||||
tag,
|
||||
tagClass: TAG_CLASS[tag] || '',
|
||||
is_ai: !!p.is_ai,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(() => {});
|
||||
},
|
||||
|
||||
@@ -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 计划 -->
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// 公共图片上传:选图(仅图片)→ 上传后端(/api/upload,MD5 去重)→ 返回 {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 };
|
||||
Reference in New Issue
Block a user