feat: 补齐五块缺失功能(记录丢数据/文章正文/提醒管理/我的页入口/分享/关注)

P0 修数据问题
- 记录弹层有 5 个输入框从未绑定,用户填的便便备注、饮食内容、药品名称、
  剂量、消费备注全部丢弃;用药记录因此只存下「用药记录」四个字。已全部绑定并入库
- 异常观察保存时写死「风险中」,AI 实际判定的等级被丢掉。改为使用 riskData
  的真实 risk_level,并把成因与建议存进备注

文章正文
- 早期 seed 的 4 篇文章 Content 全空,点开只是打开关联记录弹层,内容运营等于白做
- 补写 4 篇真实正文(疫苗驱虫/软便判断/换粮方案/养宠花销),遵循不诊断、
  异常必提就医的护栏;对已存在的空正文做幂等回填
- 新增文章详情页,学习页与首页卡片改为打开正文

提醒管理
- 后端 CRUD 早已具备但小程序只读,用户改不了疫苗驱虫日期。提醒弹层支持增删改
- 修复后端 UpdateReminder 漏掉 type 字段导致改类型不生效

「我的」页 7 个死入口接活
- 提醒设置、健康记录、多宠物管理、会员权益分别落到对应功能
- 数据导出:汇总档案与记录为文本,可复制发给医生
- 意见反馈:新增 feedback 表与提交接口,后台加「意见反馈」管理页
- 关于我们:应用说明与免责声明

分享
- 各页面补 onShareAppMessage/onShareTimeline
- 帖子分享按钮改用微信原生 open-type="share",删除原来点了没反应的空壳弹层

社区关注
- 新增 follows 表与关注/取关接口,「关注」tab 此前等同「推荐」,
  现只显示已关注用户的帖子;帖子头部可直接关注,标记 followed / is_self

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-25 18:11:08 +08:00
parent 11c85da65d
commit 3fde90e97b
42 changed files with 1028 additions and 117 deletions
+1
View File
@@ -7,6 +7,7 @@
"pages/report/report",
"pages/community/community",
"pages/learn/learn",
"pages/article/article",
"pages/profile/profile"
],
"window": {
+216 -18
View File
@@ -32,6 +32,15 @@ const TASK_SHEETS = [
];
const TASK_SHEET_LABELS = TASK_SHEETS.map((s) => s.label);
// 提醒类型(与后端 model.Reminder* 常量对应)
const REMINDER_TYPES = [
{ key: 'vaccine', label: '疫苗' },
{ key: 'deworm', label: '驱虫' },
{ key: 'weight', label: '体重' },
{ key: 'monthlyReport', label: '月度报告' },
];
const REMINDER_LABELS = REMINDER_TYPES.map((t) => t.label);
const POOP = ['正常', '软便', '拉稀'];
const FOOD = ['正常', '偏少', '不吃'];
const COST = ['食品', '医疗', '用品'];
@@ -53,12 +62,24 @@ Component({
dateVals: { vaccine: '2026-07-21', deworm: '2026-07-15' },
wInput: '',
wNote: '',
poopNote: '',
foodText: '',
medName: '',
medDose: '',
costNote: '',
costAmount: '',
postContent: '',
postImages: [],
manageTasks: [],
taskForm: { id: '', title: '', description: '', priority: '', sheetIdx: 0 },
taskSheetLabels: TASK_SHEET_LABELS,
remForm: { id: '', typeIdx: 0, title: '', date: '', freq: '' },
remTypeLabels: REMINDER_LABELS,
myPets: [],
exportText: '',
fbContent: '',
fbContact: '',
fbDone: false,
addName: '',
addBirthday: '',
addWeight: '',
@@ -100,8 +121,18 @@ Component({
patch.costAmount = '';
patch.postContent = '';
patch.postImages = [];
patch.poopNote = '';
patch.foodText = '';
patch.medName = '';
patch.medDose = '';
patch.costNote = '';
patch.manageTasks = [];
patch.taskForm = { id: '', title: '', description: '', priority: '', sheetIdx: 0 };
patch.remForm = { id: '', typeIdx: 0, title: '', date: '', freq: '' };
patch.exportText = '';
patch.fbContent = '';
patch.fbContact = '';
patch.fbDone = false;
patch.addName = '';
patch.addBirthday = '';
patch.addWeight = '';
@@ -146,20 +177,14 @@ Component({
const id = store.currentPetId();
if (type === 'manageTasks') {
this.loadManageTasks();
} else if (type === 'managePets') {
this.loadMyPets();
} else if (type === 'exportData') {
this.buildExport();
} else if (type === 'comments' && this.data.postId) {
api.listComments(this.data.postId).then((page) => this.setData({ comments: page.list || [] })).catch(() => {});
} else if (type === 'reminders' && id) {
api
.getReminders(id)
.then((list) =>
this.setData({
reminders: (list || []).map((r) => ({
title: r.title,
when: r.next_due_date ? fmtDate(r.next_due_date) : r.frequency || '',
})),
}),
)
.catch(() => {});
this.loadReminders();
} else if (type === 'poster' && id) {
api.getPoster(id).then((p) => this.setData({ poster: p })).catch(() => {});
} else if (type === 'reportDetail' && id) {
@@ -212,8 +237,157 @@ Component({
},
onWInput(e) { this.setData({ wInput: e.detail.value }); },
onWNote(e) { this.setData({ wNote: e.detail.value }); },
onPoopNote(e) { this.setData({ poopNote: e.detail.value }); },
onFoodText(e) { this.setData({ foodText: e.detail.value }); },
onMedName(e) { this.setData({ medName: e.detail.value }); },
onMedDose(e) { this.setData({ medDose: e.detail.value }); },
onCostNote(e) { this.setData({ costNote: e.detail.value }); },
onCostInput(e) { this.setData({ costAmount: e.detail.value }); },
onPostInput(e) { this.setData({ postContent: e.detail.value }); },
// ---- 多宠物管理 ----
loadMyPets() {
const cur = store.currentPetId();
this.setData({
myPets: (store.getPets() || []).map((p) => ({
id: p.id, name: p.name, emoji: p.emoji,
meta: [p.type, p.stage, p.weight].filter(Boolean).join(' · '),
current: p.id === cur,
})),
});
},
onSwitchPet(e) {
const id = e.currentTarget.dataset.id;
store.switchPet(id);
this.loadMyPets();
wx.showToast({ title: '已切换', icon: 'success' });
setTimeout(() => this.close(), 500);
},
onEditPetFromList(e) {
store.switchPet(e.currentTarget.dataset.id);
this.setType('editPet', true);
},
// ---- 导出健康档案 ----
buildExport() {
const id = store.currentPetId();
const pet = store.getPet() || {};
if (!id) {
this.setData({ exportText: '还没有建档,先添加一只毛孩子吧。' });
return;
}
Promise.all([
api.getRecords(id, { page: 1, pageSize: 50 }).catch(() => ({ list: [], total: 0 })),
api.getReminders(id).catch(() => []),
]).then(([recPage, rems]) => {
const lines = [];
lines.push('【肉垫计划 · 健康档案】');
lines.push(`宠物:${pet.name || ''}${pet.type || ''}${pet.gender || ''}`);
lines.push(`阶段:${pet.stage || '-'} 年龄:${pet.age || '-'} 体重:${pet.weight || '-'}`);
if (pet.breed || pet.color) lines.push(`品种/毛色:${pet.breed || '-'} / ${pet.color || '-'}`);
lines.push('');
lines.push(`— 健康记录(共 ${recPage.total || 0} 条,导出最近 ${(recPage.list || []).length} 条)—`);
(recPage.list || []).forEach((r) => {
lines.push(`· ${fmtDate(r.occurred_at)} ${r.title}${r.description ? '' + r.description : ''}`);
});
lines.push('');
lines.push('— 提醒 —');
(rems || []).forEach((r) => {
lines.push(`· ${r.title}${r.next_due_date ? fmtDate(r.next_due_date) : r.frequency || '未设置'}`);
});
lines.push('');
lines.push('(本档案仅供日常照护与就医参考,不构成诊断意见)');
this.setData({ exportText: lines.join('\n') });
});
},
onCopyExport() {
const t = this.data.exportText;
if (!t) return;
wx.setClipboardData({
data: t,
success: () => wx.showToast({ title: '已复制到剪贴板', icon: 'success' }),
});
},
// ---- 意见反馈 ----
onFbContent(e) { this.setData({ fbContent: e.detail.value }); },
onFbContact(e) { this.setData({ fbContact: e.detail.value }); },
onSubmitFeedback() {
const content = (this.data.fbContent || '').trim();
if (!content) return wx.showToast({ title: '写点什么再提交', icon: 'none' });
if (this.data.saving) return;
this.setData({ saving: true });
api
.submitFeedback({ content, contact: (this.data.fbContact || '').trim() })
.then(() => this.setData({ fbDone: true, saving: false }))
.catch((e) => {
wx.showToast({ title: e.message || '提交失败', icon: 'none' });
this.setData({ saving: false });
});
},
// ---- 提醒增删改 ----
loadReminders() {
const id = store.currentPetId();
if (!id) return;
api
.getReminders(id)
.then((list) =>
this.setData({
reminders: (list || []).map((r) => ({
id: r.id,
type: r.type,
title: r.title,
date: r.next_due_date ? String(r.next_due_date).slice(0, 10) : '',
freq: r.frequency || '',
when: r.next_due_date ? fmtDate(r.next_due_date) : r.frequency || '未设置',
})),
}),
)
.catch(() => {});
},
onRemTitle(e) { this.setData({ 'remForm.title': e.detail.value }); },
onRemFreq(e) { this.setData({ 'remForm.freq': e.detail.value }); },
onRemType(e) { this.setData({ 'remForm.typeIdx': Number(e.detail.value) }); },
onRemDate(e) { this.setData({ 'remForm.date': e.detail.value }); },
onEditReminder(e) {
const r = this.data.reminders[e.currentTarget.dataset.index];
let idx = REMINDER_TYPES.findIndex((t) => t.key === r.type);
if (idx < 0) idx = 0;
this.setData({ remForm: { id: r.id, typeIdx: idx, title: r.title, date: r.date, freq: r.freq } });
},
onSaveReminder() {
const f = this.data.remForm;
const title = (f.title || '').trim();
if (!title) return wx.showToast({ title: '填个提醒名', icon: 'none' });
const body = {
type: REMINDER_TYPES[f.typeIdx].key,
title,
next_due_date: f.date || '',
frequency: (f.freq || '').trim(),
};
const petId = store.currentPetId();
const p = f.id ? api.updateReminder(f.id, body) : api.createReminder(petId, body);
p.then(() => {
this.setData({ remForm: { id: '', typeIdx: 0, title: '', date: '', freq: '' } });
this.loadReminders();
this.triggerEvent('saved');
}).catch((e) => wx.showToast({ title: e.message || '保存失败', icon: 'none' }));
},
onDeleteReminder(e) {
const r = this.data.reminders[e.currentTarget.dataset.index];
wx.showModal({
title: '删除提醒',
content: '确定删除「' + r.title + '」?',
success: (res) => {
if (!res.confirm) return;
api.deleteReminder(r.id).then(() => {
this.loadReminders();
this.triggerEvent('saved');
}).catch((err) => wx.showToast({ title: err.message || '删除失败', icon: 'none' }));
},
});
},
// ---- 管理任务 ----
loadManageTasks() {
const id = store.currentPetId();
@@ -328,25 +502,49 @@ Component({
switch (t) {
case 'poop': {
const s = POOP[this.segIdx('poopState')];
return { type: 'poop', icon: '💩', title: '排便记录:' + s, category: s };
return {
type: 'poop', icon: '💩', title: '排便记录:' + s, category: s,
description: (this.data.poopNote || '').trim(),
};
}
case 'food': {
const a = FOOD[this.segIdx('food')];
return { type: 'food', icon: '🍽️', title: '饮食记录:食欲' + a };
const what = (this.data.foodText || '').trim();
return {
type: 'food', icon: '🍽️',
title: what ? '饮食记录:' + what : '饮食记录:食欲' + a,
category: a,
description: what ? '食欲' + a : '',
};
}
case 'medicine': {
const name = (this.data.medName || '').trim();
const dose = (this.data.medDose || '').trim();
return {
type: 'medicine', icon: '💊',
title: name ? '用药:' + name : '用药记录',
description: dose,
};
}
case 'medicine':
return { type: 'medicine', icon: '💊', title: '用药记录' };
case 'cost': {
const c = COST[this.segIdx('cost')];
const amt = parseFloat(this.data.costAmount) || 0;
return { type: 'cost', icon: '💰', title: '消费记录:¥' + amt, num_value: amt, category: c };
return {
type: 'cost', icon: '💰', title: '消费记录:¥' + amt, num_value: amt, category: c,
description: (this.data.costNote || '').trim(),
};
}
case 'vaccine':
return { type: 'vaccine', icon: '💉', title: '疫苗提醒:' + this.data.dateVals.vaccine };
case 'deworm':
return { type: 'deworm', icon: '🛡️', title: '驱虫提醒:' + this.data.dateVals.deworm };
case 'risk':
return { type: 'symptom', icon: '🤒', title: '异常观察:风险中', category: '中' };
case 'risk': {
// 用 AI 返回的真实风险等级,而不是写死「中」
const r = this.data.riskData || {};
const lv = r.risk_level || '中';
const desc = [r.causes, r.suggestion].filter(Boolean).join('');
return { type: 'symptom', icon: '🤒', title: '异常观察:风险' + lv, category: lv, description: desc };
}
case 'vetSummary':
return { type: 'note', icon: '📄', title: '生成就医前摘要' };
default:
@@ -32,7 +32,7 @@ module.exports.sel = function (map, key, index, def) {
<view class="seg-btn {{u.sel(segSel,'poopState',2,0)?'selected':''}}" data-group="poopState" data-index="2" bindtap="onSeg">拉稀</view>
</view></view>
<view class="field"><label>颜色/备注</label>
<textarea class="textarea" placeholder="颜色、次数、是否带血、是否有异味等" placeholder-class="placeholder"></textarea></view>
<textarea class="textarea" placeholder="颜色、次数、是否带血、是否有异味等" placeholder-class="placeholder" value="{{poopNote}}" bindinput="onPoopNote"></textarea></view>
<button class="btn btn-primary btn-block" data-text="排便记录:正常" data-icon="💩" bindtap="onSave">保存记录</button>
</block>
@@ -40,7 +40,7 @@ module.exports.sel = function (map, key, index, def) {
<block wx:elif="{{innerType === 'food'}}">
<view class="sheet-h3">记录饮食</view>
<view class="field"><label>今天吃了什么?</label>
<input class="input" placeholder="例如:幼猫粮 45g" placeholder-class="placeholder"/></view>
<input class="input" placeholder="例如:幼猫粮 45g" placeholder-class="placeholder" value="{{foodText}}" bindinput="onFoodText"/></view>
<view class="field"><label>食欲</label><view class="seg">
<view class="seg-btn {{u.sel(segSel,'food',0,0)?'selected':''}}" data-group="food" data-index="0" bindtap="onSeg">正常</view>
<view class="seg-btn {{u.sel(segSel,'food',1,0)?'selected':''}}" data-group="food" data-index="1" bindtap="onSeg">偏少</view>
@@ -99,8 +99,8 @@ module.exports.sel = function (map, key, index, def) {
<!-- 记录用药 -->
<block wx:elif="{{innerType === 'medicine'}}">
<view class="sheet-h3">记录用药</view>
<view class="field"><label>药品名称</label><input class="input" placeholder="例如:体内驱虫药" placeholder-class="placeholder"/></view>
<view class="field"><label>剂量/备注</label><input class="input" placeholder="例如:按 2.8kg 剂量" placeholder-class="placeholder"/></view>
<view class="field"><label>药品名称</label><input class="input" placeholder="例如:体内驱虫药" placeholder-class="placeholder" value="{{medName}}" bindinput="onMedName"/></view>
<view class="field"><label>剂量/备注</label><input class="input" placeholder="例如:按 2.8kg 剂量" placeholder-class="placeholder" value="{{medDose}}" bindinput="onMedDose"/></view>
<button class="btn btn-primary btn-block" data-text="用药记录" data-icon="💊" bindtap="onSave">保存记录</button>
</block>
@@ -113,7 +113,7 @@ module.exports.sel = function (map, key, index, def) {
<view class="seg-btn {{u.sel(segSel,'cost',1,0)?'selected':''}}" data-group="cost" data-index="1" bindtap="onSeg">医疗</view>
<view class="seg-btn {{u.sel(segSel,'cost',2,0)?'selected':''}}" data-group="cost" data-index="2" bindtap="onSeg">用品</view>
</view></view>
<view class="field"><label>备注</label><input class="input" placeholder="例如:幼猫粮" placeholder-class="placeholder"/></view>
<view class="field"><label>备注</label><input class="input" placeholder="例如:幼猫粮" placeholder-class="placeholder" value="{{costNote}}" bindinput="onCostNote"/></view>
<button class="btn btn-primary btn-block" data-text="消费记录:¥168" data-icon="💰" bindtap="onSave">保存记录</button>
</block>
@@ -154,9 +154,33 @@ module.exports.sel = function (map, key, index, def) {
<!-- 提醒中心 -->
<block wx:elif="{{innerType === 'reminders'}}">
<view class="sheet-h3">提醒中心</view>
<view wx:for="{{reminders}}" wx:key="title" class="record-row"><text class="bold">{{item.title}}</text><text class="muted">{{item.when}}</text></view>
<view wx:if="{{!reminders.length}}" class="sheet-p">暂无提醒</view>
<button class="btn btn-primary btn-block" style="margin-top:24rpx" bindtap="close">完成</button>
<view class="sheet-p">疫苗、驱虫这些别靠记,设好日期到时候提醒你。</view>
<view wx:for="{{reminders}}" wx:key="id" class="mtask-row">
<view class="mtask-body">
<text class="mtask-b">{{item.title}}</text>
<view class="mtask-p">{{item.when}}</view>
</view>
<view class="mtask-op" catchtap="onEditReminder" data-index="{{index}}">改</view>
<view class="mtask-op del" catchtap="onDeleteReminder" data-index="{{index}}">删</view>
</view>
<view wx:if="{{!reminders.length}}" class="sheet-p">还没有提醒,下面加一条吧。</view>
<view class="field" style="margin-top:12rpx"><label>{{remForm.id ? '修改提醒' : '新增提醒'}}</label>
<input class="input" placeholder="提醒名,例如:第 3 针疫苗" placeholder-class="placeholder" value="{{remForm.title}}" bindinput="onRemTitle"/></view>
<view class="field"><label>类型 / 到期日</label>
<view class="mtask-form-row">
<picker mode="selector" range="{{remTypeLabels}}" value="{{remForm.typeIdx}}" bindchange="onRemType">
<view class="picker-box">{{remTypeLabels[remForm.typeIdx]}}</view>
</picker>
<picker mode="date" value="{{remForm.date}}" bindchange="onRemDate">
<view class="picker-box">{{remForm.date || '选择到期日'}}</view>
</picker>
</view>
</view>
<view class="field"><label>重复频率(可选)</label>
<input class="input" placeholder="例如:每月一次;填了可不选到期日" placeholder-class="placeholder" value="{{remForm.freq}}" bindinput="onRemFreq"/></view>
<button class="btn btn-primary btn-block" bindtap="onSaveReminder">{{remForm.id ? '保存修改' : '添加提醒'}}</button>
</block>
<!-- 任务详情 -->
@@ -312,12 +336,59 @@ module.exports.sel = function (map, key, index, def) {
</view>
</block>
<!-- 分享帖子 -->
<block wx:elif="{{innerType === 'sharePost'}}">
<view class="sheet-h3">分享帖子</view>
<view class="sheet-p">转发给微信好友、微信群,或生成一张分享卡片。</view>
<button class="btn btn-primary btn-block" bindtap="close">生成分享卡片</button>
<!-- 多宠物管理 -->
<block wx:elif="{{innerType === 'managePets'}}">
<view class="sheet-h3">多宠物管理</view>
<view class="sheet-p">点一只切换为当前宠物,或编辑它的档案。</view>
<view wx:for="{{myPets}}" wx:key="id" class="mtask-row">
<view class="mtask-body" catchtap="onSwitchPet" data-id="{{item.id}}">
<text class="mtask-b">{{item.emoji}} {{item.name}}<text wx:if="{{item.current}}" class="mtask-pri">当前</text></text>
<view class="mtask-p">{{item.meta}}</view>
</view>
<view class="mtask-op" catchtap="onEditPetFromList" data-id="{{item.id}}">编辑</view>
</view>
<view wx:if="{{!myPets.length}}" class="sheet-p">还没有毛孩子。</view>
<button class="btn btn-primary btn-block" style="margin-top:16rpx" data-type="addPet" bindtap="goSheet"> 添加毛孩子</button>
</block>
<!-- 导出健康档案 -->
<block wx:elif="{{innerType === 'exportData'}}">
<view class="sheet-h3">导出健康档案</view>
<view class="sheet-p">整理成一段文字,可复制后发给医生或存到备忘录。</view>
<view class="export-box">{{exportText || '正在整理…'}}</view>
<button class="btn btn-primary btn-block" bindtap="onCopyExport">复制全部内容</button>
</block>
<!-- 意见反馈 -->
<block wx:elif="{{innerType === 'feedback'}}">
<view class="sheet-h3">意见反馈</view>
<block wx:if="{{!fbDone}}">
<view class="sheet-p">用着哪儿别扭、想要什么功能,都可以告诉我们。</view>
<view class="field"><label>你的反馈</label>
<textarea class="textarea" placeholder="例如:希望能记录洗澡时间" placeholder-class="placeholder" value="{{fbContent}}" bindinput="onFbContent"></textarea></view>
<view class="field"><label>联系方式(可选)</label>
<input class="input" placeholder="微信号或邮箱,方便回复你" placeholder-class="placeholder" value="{{fbContact}}" bindinput="onFbContact"/></view>
<button class="btn btn-primary btn-block" bindtap="onSubmitFeedback" disabled="{{saving}}">{{saving ? '提交中…' : '提交反馈'}}</button>
</block>
<block wx:else>
<view class="sheet-p">已收到,谢谢你的反馈 🐾</view>
<button class="btn btn-primary btn-block" bindtap="close">好的</button>
</block>
</block>
<!-- 关于我们 -->
<block wx:elif="{{innerType === 'about'}}">
<view class="sheet-h3">关于肉垫计划</view>
<view class="sheet-p pre">肉垫计划是给新手养宠人做的照护记录工具。
按猫狗的品类和成长阶段生成每日照护任务和养护计划,随手记录体重、饮食、排便、用药、疫苗和开支,疫苗驱虫到期前提醒你,并把这些整理成成长报告。
版本:v0.2
反馈:我的 → 意见反馈</view>
<view class="sheet-p" style="color:var(--muted);font-size:24rpx">本小程序提供的是记录与观察建议,不能替代医生诊断。宠物出现持续异常请及时就医。</view>
<button class="btn btn-primary btn-block" bindtap="close">知道了</button>
</block>
</scroll-view>
</view>
@@ -53,3 +53,11 @@
.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}
/* 导出健康档案 */
.export-box{
max-height:560rpx;overflow-y:auto;background:#FBFAF8;border-radius:28rpx;
padding:24rpx;font-size:25rpx;line-height:1.7;color:#4A4A46;white-space:pre-line;
border:1rpx solid var(--line);margin-bottom:24rpx;
}
.export-box::-webkit-scrollbar{width:0;height:0;display:none}
+31
View File
@@ -0,0 +1,31 @@
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.switchTab({ 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',
};
},
});
+7
View File
@@ -0,0 +1,7 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar"
},
"navigationStyle": "custom",
"navigationBarTitleText": "新手知识"
}
+24
View File
@@ -0,0 +1,24 @@
<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">
<view wx:if="{{loading}}" class="hint">加载中…</view>
<view wx:elif="{{error}}" class="hint">{{error}}</view>
<block wx:elif="{{article}}">
<view class="art-head">
<view class="art-icon">{{article.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="hint">这篇文章还没有正文。</view>
</view>
<button class="btn btn-primary btn-block" bindtap="goRecord">去记一笔</button>
<view class="art-foot">内容仅供日常照护参考,不能替代医生诊断。</view>
</block>
</view>
</scroll-view>
+17
View File
@@ -0,0 +1,17 @@
page{height:100vh;overflow:hidden;display:flex;flex-direction:column}
.page-scroll{flex:1;min-height:0}
.page-body{padding:8rpx 36rpx calc(60rpx + env(safe-area-inset-bottom))}
.hint{padding:60rpx 0;text-align:center;color:var(--muted);font-size:28rpx}
.art-head{padding:12rpx 0 28rpx}
.art-icon{font-size:72rpx;line-height:1;margin-bottom:16rpx}
.art-title{font-size:44rpx;font-weight:800;line-height:1.35;letter-spacing:-.5rpx}
.art-desc{margin-top:14rpx;color:var(--muted);font-size:27rpx;line-height:1.6}
.art-content{
font-size:29rpx;line-height:1.85;color:#3D352E;
white-space:pre-line; /* 正文用真实换行排版 */
}
.art-foot{margin-top:28rpx;text-align:center;color:var(--muted);font-size:23rpx}
+32
View File
@@ -18,6 +18,9 @@ function mapPost(p) {
tag,
tagClass: TAG_CLASS[tag] || '',
is_ai: !!p.is_ai,
user_id: p.user_id,
followed: !!p.followed,
is_self: !!p.is_self,
};
}
@@ -92,4 +95,33 @@ Page({
onFab() {
this.setData({ sheetType: 'ai', sheetPostId: 0, sheetShow: true });
},
// 关注 / 取关帖子作者
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: '宠友圈 · 和铲屎官们交流养宠经验' };
},
});
+3 -1
View File
@@ -23,6 +23,8 @@ module.exports.isUrl = function (s) { return s && s.indexOf('http') === 0; }
<view class="post-head">
<view class="post-avatar">{{item.author_emoji}}</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.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>
@@ -35,7 +37,7 @@ module.exports.isUrl = function (s) { return s && s.indexOf('http') === 0; }
<view class="post-actions">
<view class="pa-btn {{item.liked ? 'liked' : ''}}" data-index="{{index}}" bindtap="likePost">{{item.liked ? '♥' : '♡'}} {{item.like_count}}</view>
<view class="pa-btn" data-id="{{item.id}}" bindtap="openComments">💬 {{item.comment_count}}</view>
<view class="pa-btn" data-type="sharePost" bindtap="openSheet">↗ 分享</view>
<button class="pa-btn pa-share" open-type="share" data-content="{{item.content}}">↗ 分享</button>
</view>
</view>
+14
View File
@@ -39,3 +39,17 @@
/* 页面不自身滚动,改由 page-scroll 承载滚动(隐藏滚动条)*/
page{height:100vh;overflow:hidden;display:flex;flex-direction:column}
.page-scroll{flex:1;min-height:0}
/* 帖子分享按钮:用微信原生 open-type=share,需抹掉 button 默认外观 */
.pa-share{
background:transparent;border:none;padding:0;margin:0;line-height:inherit;
font-size:26rpx;font-weight:900;color:var(--muted);
}
.pa-share::after{border:none}
/* 关注按钮 */
.follow-btn{
flex:none;font-size:22rpx;font-weight:700;padding:6rpx 18rpx;border-radius:999rpx;
color:var(--primary-dark);background:#FFF2DC;border:1rpx solid #FFD39A;
}
.follow-btn.on{color:var(--muted);background:#F4F1EC;border-color:var(--line)}
+13 -1
View File
@@ -60,7 +60,7 @@ Page({
.listArticles()
.then((list) => {
if (list && list[0]) {
this.setData({ firstArticle: { icon: list[0].icon, title: list[0].title, desc: list[0].description } });
this.setData({ firstArticle: { id: list[0].id, icon: list[0].icon, title: list[0].title, desc: list[0].description } });
}
})
.catch(() => {});
@@ -152,7 +152,19 @@ Page({
goLearn() {
wx.navigateTo({ url: '/pages/learn/learn' });
},
// 首页那张卡片直接打开这篇文章正文
openFirstArticle() {
const a = this.data.firstArticle;
if (a && a.id) wx.navigateTo({ url: `/pages/article/article?id=${a.id}` });
else this.goLearn();
},
goRecord() {
wx.switchTab({ url: '/pages/record/record' });
},
onShareAppMessage() {
return { title: '肉垫计划 · 每天照顾好毛孩子', path: '/pages/home/home' };
},
onShareTimeline() {
return { title: '肉垫计划 · 每天照顾好毛孩子' };
},
});
+1 -1
View File
@@ -88,7 +88,7 @@
<view class="sh-title">新手内容</view>
<view class="link" bindtap="goLearn">查看</view>
</view>
<view wx:if="{{firstArticle}}" class="article-card no-shadow" style="margin-bottom:0" bindtap="goLearn">
<view wx:if="{{firstArticle}}" class="article-card no-shadow" style="margin-bottom:0" bindtap="openFirstArticle">
<view class="article-icon">{{firstArticle.icon}}</view>
<view><text class="ac-b">{{firstArticle.title}}</text><view class="ac-p">{{firstArticle.desc}}</view></view>
</view>
+12 -1
View File
@@ -14,15 +14,20 @@ Page({
.then((list) => {
this.setData({
articles: (list || []).map((a) => ({
id: a.id,
icon: a.icon,
title: a.title,
desc: a.description,
type: a.related_sheet_type || 'vaccine',
})),
});
})
.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 });
},
@@ -32,4 +37,10 @@ Page({
onFab() {
this.setData({ sheetType: 'ai', sheetShow: true });
},
onShareAppMessage() {
return { title: '新手养宠知识合集', path: '/pages/learn/learn' };
},
onShareTimeline() {
return { title: '新手养宠知识合集' };
},
});
+1 -1
View File
@@ -7,7 +7,7 @@
<view class="pt-p">新手养宠知识,看完顺手生成你的计划。</view>
</view>
<view wx:for="{{articles}}" wx:key="type" class="article-card" data-type="{{item.type}}" bindtap="openSheet">
<view wx:for="{{articles}}" wx:key="id" class="article-card" data-id="{{item.id}}" bindtap="openArticle">
<view class="article-icon">{{item.icon}}</view>
<view><text class="ac-b">{{item.title}}</text><view class="ac-p">{{item.desc}}</view></view>
</view>
+6
View File
@@ -172,4 +172,10 @@ Page({
onFab() {
this.setData({ sheetType: 'ai', sheetShow: true });
},
onShareAppMessage() {
return { title: '我给毛孩子做了份养护计划', path: '/pages/plan/plan' };
},
onShareTimeline() {
return { title: '我给毛孩子做了份养护计划' };
},
});
+6
View File
@@ -40,6 +40,9 @@ Page({
openSheet(e) {
this.setData({ sheetType: e.currentTarget.dataset.type, sheetShow: true });
},
goRecord() {
wx.switchTab({ url: '/pages/record/record' });
},
onPickAvatar() {
upload
.chooseAndUploadImage()
@@ -67,4 +70,7 @@ Page({
onFab() {
this.setData({ sheetType: 'ai', sheetShow: true });
},
onShareAppMessage() {
return { title: '肉垫计划 · 新手养宠助手', path: '/pages/home/home' };
},
});
+7 -7
View File
@@ -17,16 +17,16 @@
<view class="profile-list">
<view class="profile-row" bindtap="onEditPet">宠物档案(编辑)<text class="pr-val">已完善 {{profilePct}}% </text></view>
<view class="profile-row">提醒设置 <text class="pr-val">{{summary.reminders}} 条</text></view>
<view class="profile-row">健康记录 <text class="pr-val">{{summary.records}} 条</text></view>
<view class="profile-row">多宠物管理 <text class="pr-val">{{summary.pets}} 只</text></view>
<view class="profile-row" data-type="reminders" bindtap="openSheet">提醒设置 <text class="pr-val">{{summary.reminders}} 条 </text></view>
<view class="profile-row" bindtap="goRecord">健康记录 <text class="pr-val">{{summary.records}} 条 </text></view>
<view class="profile-row" data-type="managePets" bindtap="openSheet">多宠物管理 <text class="pr-val">{{summary.pets}} 只 </text></view>
</view>
<view class="profile-list">
<view class="profile-row">会员权益 <text class="pr-val">{{summary.pro_status === 'active' ? '已开通' : '未开通'}}</text></view>
<view class="profile-row">数据导出 <text class="pr-val">PDF</text></view>
<view class="profile-row">意见反馈 <text class="pr-val">去填写</text></view>
<view class="profile-row">关于我们 <text class="pr-val">v0.2</text></view>
<view class="profile-row" data-type="pro" bindtap="openSheet">会员权益 <text class="pr-val">{{summary.pro_status === 'active' ? '已开通' : '未开通'}} </text></view>
<view class="profile-row" data-type="exportData" bindtap="openSheet">数据导出 <text class="pr-val">文本 </text></view>
<view class="profile-row" data-type="feedback" bindtap="openSheet">意见反馈 <text class="pr-val">去填写 </text></view>
<view class="profile-row" data-type="about" bindtap="openSheet">关于我们 <text class="pr-val">v0.2 </text></view>
</view>
<button class="btn btn-primary btn-block" data-type="pro" bindtap="openSheet">开通 Pro</button>
+6
View File
@@ -174,4 +174,10 @@ Page({
onFab() {
this.openSheetType('ai');
},
onShareAppMessage() {
return { title: '用肉垫计划记录毛孩子的成长', path: '/pages/record/record' };
},
onShareTimeline() {
return { title: '用肉垫计划记录毛孩子的成长' };
},
});
+6
View File
@@ -59,4 +59,10 @@ Page({
onFab() {
this.setData({ sheetType: 'ai', sheetShow: true });
},
onShareAppMessage() {
return { title: '看看我家毛孩子的成长报告', path: '/pages/report/report' };
},
onShareTimeline() {
return { title: '看看我家毛孩子的成长报告' };
},
});
+6
View File
@@ -75,6 +75,9 @@ const api = {
// 提醒
getReminders: (id) => request({ url: `/api/pets/${id}/reminders` }),
createReminder: (id, body) => request({ url: `/api/pets/${id}/reminders`, method: 'POST', data: body }),
updateReminder: (remId, body) => request({ url: `/api/reminders/${remId}`, method: 'PUT', data: body }),
deleteReminder: (remId) => request({ url: `/api/reminders/${remId}`, method: 'DELETE' }),
// 报告
weeklyReport: (id) => request({ url: `/api/pets/${id}/report/weekly` }),
@@ -87,12 +90,15 @@ const api = {
request({ url: `/api/posts?tab=${encodeURIComponent(tab || '')}&page=${page || 1}&page_size=10` }),
createPost: (body) => request({ url: '/api/posts', method: 'POST', data: body }),
likePost: (id) => request({ url: `/api/posts/${id}/like`, method: 'POST' }),
followUser: (userId) => request({ url: `/api/users/${userId}/follow`, method: 'POST' }),
unfollowUser: (userId) => request({ url: `/api/users/${userId}/follow`, method: 'DELETE' }),
listComments: (id) => request({ url: `/api/posts/${id}/comments` }),
createComment: (id, content) =>
request({ url: `/api/posts/${id}/comments`, method: 'POST', data: { content } }),
// 文章
listArticles: () => request({ url: '/api/articles' }),
submitFeedback: (body) => request({ url: '/api/feedback', method: 'POST', data: body }),
getArticle: (id) => request({ url: `/api/articles/${id}` }),
// Pro