init: 毛孩子计划 小程序 + Go 后端 + 内嵌后台

- pets-fe: 微信原生小程序(首页/计划/记录/报告/社区/引导),
  服务端驱动、无假数据;弹层改用 scroll-view,打开时隐藏自定义 tabBar
- pets-be: Gin + GORM(MySQL, sundynix_ 前缀) + MinIO,统一响应/分页,
  微信 code2session 登录,provider-neutral AI(DeepSeek),go:embed React 后台
- 修复:分段选择类型不匹配(字符串 vs 数字)导致选不中

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-03 15:33:31 +08:00
commit 609f7d06cf
180 changed files with 15259 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const TAG_CLASS = { 求助: 'warn', 经验: 'blue', 精选: 'purple', 避坑: 'red', 晒宠: '' };
function mapPost(p) {
const tags = p.tags || [];
const tag = tags[0] || '';
return {
id: p.id,
author_name: p.author_name,
author_emoji: p.author_emoji || '🐾',
content: p.content,
images: p.images || [],
like_count: p.like_count,
comment_count: p.comment_count,
liked: false,
tag,
tagClass: TAG_CLASS[tag] || '',
};
}
Page({
data: {
pet: {},
feedTabs: ['推荐', '关注', '新手求助', '晒宠', '经验'],
feedIdx: 0,
posts: [],
sheetShow: false,
sheetType: '',
sheetPostId: 0,
},
onLoad() {
this._unsub = store.subscribe((pet) => this.setData({ pet }));
},
onUnload() {
if (this._unsub) this._unsub();
},
onShow() {
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 4 });
}
store
.ready()
.then(() => {
this.setData({ pet: store.getPet() });
this.loadPosts();
})
.catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' }));
},
loadPosts() {
const tab = this.data.feedTabs[this.data.feedIdx];
api
.listPosts(tab, 1)
.then((page) => this.setData({ posts: (page.list || []).map(mapPost) }))
.catch(() => {});
},
switchFeedTab(e) {
this.setData({ feedIdx: e.currentTarget.dataset.index });
this.loadPosts();
},
likePost(e) {
const i = e.currentTarget.dataset.index;
const post = this.data.posts[i];
api
.likePost(post.id)
.then((res) => {
this.setData({ [`posts[${i}].like_count`]: res.like_count, [`posts[${i}].liked`]: true });
})
.catch(() => {});
},
openComments(e) {
this.setData({ sheetPostId: e.currentTarget.dataset.id, sheetType: 'comments', sheetShow: true });
},
openSheet(e) {
this.setData({ sheetType: e.currentTarget.dataset.type, sheetPostId: 0, sheetShow: true });
},
closeSheet() {
this.setData({ sheetShow: false });
},
onPosted() {
this.setData({ sheetShow: false });
this.loadPosts();
},
onCommented() {
this.loadPosts();
},
goLearn() {
wx.navigateTo({ url: '/pages/learn/learn' });
},
onFab() {
this.setData({ sheetType: 'ai', sheetPostId: 0, sheetShow: true });
},
});
+7
View File
@@ -0,0 +1,7 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"bottom-sheet": "/components/bottom-sheet/bottom-sheet",
"fab": "/components/fab/fab"
}
}
+44
View File
@@ -0,0 +1,44 @@
<nav-bar title="宠友圈"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body">
<view class="page-title">
<view class="pt-h2">宠友圈</view>
<view class="pt-p">和宠友交流、晒宠、求助与经验分享。</view>
</view>
<scroll-view class="feed-tabs" scroll-x="true" enhanced="{{true}}" show-scrollbar="{{false}}">
<view wx:for="{{feedTabs}}" wx:key="*this"
class="feed-tab {{feedIdx === index ? 'active' : ''}}" data-index="{{index}}" bindtap="switchFeedTab">{{item}}</view>
</scroll-view>
<view class="compose-card" data-type="createPost" bindtap="openSheet">
<view class="post-avatar">{{pet.emoji}}</view>
<view class="compose-input">分享一下今天和 {{pet.name}} 的日常...</view>
<button class="btn btn-primary btn-small" catchtap="openSheet" data-type="createPost">发布</button>
</view>
<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 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>
</view>
<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>
</view>
</view>
<view wx:if="{{posts.length === 0}}" class="empty-hint">还没有帖子,来发第一条吧 🐾</view>
</view>
</scroll-view>
<fab bind:tap="onFab"></fab>
<bottom-sheet show="{{sheetShow}}" type="{{sheetType}}" post-id="{{sheetPostId}}"
bind:close="closeSheet" bind:posted="onPosted" bind:commented="onCommented"></bottom-sheet>
+48
View File
@@ -0,0 +1,48 @@
.page-body{padding:8rpx 40rpx calc(210rpx + env(safe-area-inset-bottom))}
.pt-h2{font-size:46rpx;letter-spacing:-.6rpx;font-weight:800}
.pt-p{color:var(--muted);font-size:26rpx;margin-top:10rpx}
.feed-tabs{white-space:nowrap;padding:0 0 24rpx}
.feed-tab{
display:inline-block;border:1rpx solid var(--line);background:#fff;border-radius:999rpx;
height:68rpx;line-height:68rpx;padding:0 26rpx;color:var(--muted);font-size:26rpx;font-weight:900;
box-shadow:0 12rpx 32rpx rgba(70,48,25,.05);margin-right:16rpx;
}
.feed-tab.active{color:var(--primary-dark);background:#FFF4E4;border-color:#FFD39A}
.compose-card{
display:flex;gap:20rpx;align-items:center;background:#fff;border-radius:44rpx;
box-shadow:var(--shadow);padding:24rpx;margin-bottom:28rpx;
}
.compose-input{
flex:1;height:76rpx;line-height:76rpx;border-radius:999rpx;background:#FBFAF8;
border:1rpx solid var(--line);color:var(--muted);padding:0 26rpx;font-size:26rpx;
overflow:hidden;white-space:nowrap;text-overflow:ellipsis;
}
.post-card{background:#fff;border-radius:48rpx;box-shadow:var(--shadow);padding:30rpx;margin-bottom:28rpx}
.post-head{display:flex;align-items:center;gap:20rpx;margin-bottom:22rpx}
.post-avatar{
width:84rpx;height:84rpx;border-radius:32rpx;background:#FFF0D8;
display:flex;align-items:center;justify-content:center;font-size:44rpx;flex:none;
}
.post-user{flex:1}
.pu-b{font-size:28rpx;display:block;font-weight:700}
.pu-s{font-size:24rpx;color:var(--muted)}
.post-content{font-size:28rpx;line-height:1.62;color:#443D37;margin-bottom:24rpx}
.photo-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12rpx;margin-bottom:24rpx}
.photo-tile{
aspect-ratio:1;border-radius:32rpx;background:linear-gradient(135deg,#FFE6BA,#FFF8ED);
display:flex;align-items:center;justify-content:center;font-size:60rpx;
}
.post-actions{display:flex;justify-content:space-around;border-top:1rpx solid var(--line);padding-top:20rpx}
.pa-btn{color:var(--muted);font-weight:900;font-size:26rpx}
.pa-btn.liked{color:var(--red)}
.empty-hint{text-align:center;color:var(--muted);font-size:26rpx;padding:80rpx 0}
/* 页面不自身滚动,改由 page-scroll 承载滚动(隐藏滚动条)*/
page{height:100vh;overflow:hidden;display:flex;flex-direction:column}
.page-scroll{flex:1;min-height:0}
+130
View File
@@ -0,0 +1,130 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
function mapTask(t) {
return {
id: t.id,
title: t.title,
sub: t.description,
priority: t.priority,
done: t.done,
sheet: t.sheet_type,
};
}
Page({
data: {
pet: {},
summary: { greeting: '', insights: [], week: [], advice: '', health_pct: 0, health_status: '正常' },
tasks: [],
firstArticle: null,
sheetShow: false,
sheetType: '',
},
onLoad() {
this._unsub = store.subscribe((pet) => {
this.setData({ pet });
this.loadAll();
});
},
onUnload() {
if (this._unsub) this._unsub();
},
onShow() {
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 0 });
}
store
.ready()
.then(() => {
this.setData({ pet: store.getPet() });
this.loadAll();
})
.catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' }));
},
loadAll() {
this.loadSummary();
this.loadTasks();
if (!this.data.firstArticle) {
api
.listArticles()
.then((list) => {
if (list && list[0]) {
this.setData({ firstArticle: { icon: list[0].icon, title: list[0].title, desc: list[0].description } });
}
})
.catch(() => {});
}
},
loadSummary() {
const id = store.currentPetId();
if (!id) return;
api
.homeSummary(id)
.then((summary) => this.setData({ summary }))
.catch(() => {});
},
loadTasks() {
const id = store.currentPetId();
if (!id) return;
api
.getTasks(id)
.then((tasks) => this.setData({ tasks: (tasks || []).map(mapTask) }))
.catch(() => {});
},
onTapTask(e) {
const i = e.currentTarget.dataset.index;
const t = this.data.tasks[i];
if (t.sheet) {
this.openSheetType(t.sheet);
return;
}
api
.toggleTask(t.id)
.then((res) => {
this.setData({ [`tasks[${i}].done`]: res.done });
this.loadSummary();
})
.catch(() => {});
},
completeTasks() {
const id = store.currentPetId();
if (!id) return;
api
.completeAllTasks(id)
.then((tasks) => {
this.setData({ tasks: (tasks || []).map(mapTask) });
this.loadSummary();
})
.catch(() => {});
},
openSheet(e) {
this.openSheetType(e.currentTarget.dataset.type);
},
openSheetType(type) {
this.setData({ sheetType: type, sheetShow: true });
},
closeSheet() {
this.setData({ sheetShow: false });
this.loadAll();
},
onSheetSave() {},
onAddPet() {
this.openSheetType('addPet');
},
onEditPet() {
this.openSheetType('editPet');
},
onFab() {
this.openSheetType('ai');
},
goProfile() {
wx.navigateTo({ url: '/pages/profile/profile' });
},
goLearn() {
wx.navigateTo({ url: '/pages/learn/learn' });
},
goRecord() {
wx.switchTab({ url: '/pages/record/record' });
},
});
+8
View File
@@ -0,0 +1,8 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pet-switch": "/components/pet-switch/pet-switch",
"bottom-sheet": "/components/bottom-sheet/bottom-sheet",
"fab": "/components/fab/fab"
}
}
+93
View File
@@ -0,0 +1,93 @@
<nav-bar title="毛孩子计划"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body">
<view class="top-row">
<view class="greeting">
<view class="greet-h2">{{summary.greeting || '你好'}}</view>
<view class="greet-p">今天也要照顾好 {{pet.name}} 🐾</view>
</view>
<view class="icon-stack">
<view class="icon-btn" data-type="reminders" bindtap="openSheet">🔔</view>
<view class="icon-btn" bindtap="goProfile">⚙</view>
</view>
</view>
<pet-switch bind:add="onAddPet"></pet-switch>
<view class="card hero-card">
<view class="pet-row" bindtap="onEditPet">
<view class="avatar">{{pet.emoji}}</view>
<view class="pet-main">
<view class="pet-name-b">{{pet.name}}</view>
<view class="pet-meta">{{pet.age}}{{pet.weight}}{{pet.stage}}</view>
</view>
<view class="tag">{{summary.health_status || '正常'}}</view>
</view>
<view class="progress-row"><text>本周健康完成度</text><text>{{summary.health_pct}}%</text></view>
<view class="bar"><view class="bar-fill" style="width:{{summary.health_pct}}%"></view></view>
<scroll-view wx:if="{{summary.insights.length}}" class="insight-row" scroll-x="true" enhanced="{{true}}" show-scrollbar="{{false}}">
<view wx:for="{{summary.insights}}" wx:key="text" class="insight"><text class="insight-b">{{item.bold}}</text>{{item.text}}</view>
</scroll-view>
</view>
<view class="date-strip">
<view wx:for="{{summary.week}}" wx:key="day" class="day {{item.active ? 'active' : ''}} {{item.has_dot ? 'has-dot' : ''}}">
<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>
<view class="task-list">
<view wx:for="{{tasks}}" wx:key="title" class="task {{item.done ? 'done' : ''}}" data-index="{{index}}" bindtap="onTapTask">
<view class="circle">✓</view>
<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>
</view>
<view class="card">
<view class="section-head">
<view class="sh-title">快速记录</view>
<view class="link" bindtap="goRecord">更多</view>
</view>
<view class="quick-grid">
<view class="quick" data-type="weight" bindtap="openSheet"><text class="q-ic">⚖️</text>体重</view>
<view class="quick" data-type="poop" bindtap="openSheet"><text class="q-ic">💩</text>便便</view>
<view class="quick" data-type="food" bindtap="openSheet"><text class="q-ic">🍽️</text>饮食</view>
<view class="quick" data-type="symptom" bindtap="openSheet"><text class="q-ic">🤒</text>异常</view>
<view class="quick" data-type="cost" bindtap="openSheet"><text class="q-ic">💰</text>消费</view>
<view class="quick" data-type="medicine" bindtap="openSheet"><text class="q-ic">💊</text>用药</view>
<view class="quick" data-type="photo" bindtap="openSheet"><text class="q-ic">📷</text>照片</view>
<view class="quick" data-type="vaccine" bindtap="openSheet"><text class="q-ic">💉</text>疫苗</view>
</view>
</view>
<view class="card ai-banner">
<view class="section-head">
<view class="sh-title">AI 今日建议</view>
<view class="link" data-type="ai" bindtap="openSheet">问问 AI</view>
</view>
<view class="ai-text">{{summary.advice || '正在生成今日建议…'}}</view>
</view>
<view class="card">
<view class="section-head">
<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 class="article-icon">{{firstArticle.icon}}</view>
<view><text class="ac-b">{{firstArticle.title}}</text><view class="ac-p">{{firstArticle.desc}}</view></view>
</view>
</view>
</view>
</scroll-view>
<fab bind:tap="onFab"></fab>
<bottom-sheet show="{{sheetShow}}" type="{{sheetType}}" bind:close="closeSheet" bind:save="onSheetSave"></bottom-sheet>
+59
View File
@@ -0,0 +1,59 @@
.page-body{padding:8rpx 36rpx calc(210rpx + env(safe-area-inset-bottom))}
.top-row{display:flex;align-items:center;justify-content:space-between;margin:4rpx 0 28rpx}
.greet-h2{font-size:44rpx;line-height:1.15;letter-spacing:-.8rpx;font-weight:800}
.greet-p{font-size:26rpx;color:var(--muted);margin-top:10rpx}
.icon-stack{display:flex;gap:18rpx}
.icon-btn{
width:80rpx;height:80rpx;border-radius:50%;background:#fff;box-shadow:var(--shadow);
font-size:36rpx;display:flex;align-items:center;justify-content:center;
}
.hero-card{
background:
radial-gradient(circle at 95% 16%, rgba(245,169,71,.25), transparent 30%),
linear-gradient(135deg,#FFFFFF,#FFF5E6);
min-height:340rpx;
}
.pet-row{display:flex;align-items:center;gap:26rpx;margin-bottom:28rpx}
.pet-main{flex:1}
.pet-name-b{font-size:42rpx;letter-spacing:-.4rpx;font-weight:800}
.pet-meta{color:var(--muted);font-size:26rpx;margin-top:8rpx}
.insight-row{white-space:nowrap;margin-top:24rpx}
.insight{
display:inline-block;min-width:224rpx;border-radius:32rpx;
background:rgba(255,255,255,.72);border:1rpx solid rgba(255,255,255,.9);
padding:20rpx;font-size:24rpx;margin-right:16rpx;vertical-align:top;
}
.insight-b{display:block;font-size:28rpx;margin-bottom:8rpx;font-weight:900}
.date-strip{display:grid;grid-template-columns:repeat(7,1fr);gap:14rpx;margin-bottom:28rpx}
.day{
background:#fff;border-radius:32rpx;min-height:116rpx;text-align:center;padding:16rpx 8rpx;
border:1rpx solid var(--line);color:var(--muted);font-size:22rpx;
}
.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.has-dot::after{content:"";display:block;width:10rpx;height:10rpx;border-radius:50%;background:var(--green);margin:8rpx auto 0}
.tt-b{font-size:28rpx;font-weight:700}
.tt-p{font-size:24rpx;color:var(--muted);margin-top:8rpx;line-height:1.45}
.quick-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:18rpx}
.quick{
background:#FBFAF8;border-radius:32rpx;min-height:132rpx;
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12rpx;
font-weight:900;color:var(--text);font-size:24rpx;
}
.q-ic{font-size:44rpx}
.ai-banner{background:linear-gradient(135deg,#F2FAF6,#FFFFFF)}
.ai-text{color:#59635D;font-size:28rpx;line-height:1.6}
.ac-b{font-size:30rpx;font-weight:700}
.ac-p{color:var(--muted);font-size:24rpx;line-height:1.45;margin-top:10rpx}
/* 页面不自身滚动,改由 page-scroll 承载滚动(隐藏滚动条)*/
page{height:100vh;overflow:hidden;display:flex;flex-direction:column}
.page-scroll{flex:1;min-height:0}
+35
View File
@@ -0,0 +1,35 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
Page({
data: {
articles: [],
sheetShow: false,
sheetType: '',
},
onLoad() {
store
.ready()
.then(() => api.listArticles())
.then((list) => {
this.setData({
articles: (list || []).map((a) => ({
icon: a.icon,
title: a.title,
desc: a.description,
type: a.related_sheet_type || 'vaccine',
})),
});
})
.catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' }));
},
openSheet(e) {
this.setData({ sheetType: e.currentTarget.dataset.type, sheetShow: true });
},
closeSheet() {
this.setData({ sheetShow: false });
},
onFab() {
this.setData({ sheetType: 'ai', sheetShow: true });
},
});
+7
View File
@@ -0,0 +1,7 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"bottom-sheet": "/components/bottom-sheet/bottom-sheet",
"fab": "/components/fab/fab"
}
}
+18
View File
@@ -0,0 +1,18 @@
<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 class="page-title">
<view class="pt-h2">新手养宠知识</view>
<view class="pt-p">新手养宠知识,看完顺手生成你的计划。</view>
</view>
<view wx:for="{{articles}}" wx:key="type" class="article-card" data-type="{{item.type}}" bindtap="openSheet">
<view class="article-icon">{{item.icon}}</view>
<view><text class="ac-b">{{item.title}}</text><view class="ac-p">{{item.desc}}</view></view>
</view>
</view>
</scroll-view>
<fab bind:tap="onFab"></fab>
<bottom-sheet show="{{sheetShow}}" type="{{sheetType}}" bind:close="closeSheet"></bottom-sheet>
+9
View File
@@ -0,0 +1,9 @@
.page-body{padding:8rpx 40rpx calc(60rpx + env(safe-area-inset-bottom))}
.pt-h2{font-size:46rpx;letter-spacing:-.6rpx;font-weight:800}
.pt-p{color:var(--muted);font-size:26rpx;margin-top:10rpx}
.ac-b{font-size:30rpx;font-weight:700}
.ac-p{color:var(--muted);font-size:24rpx;line-height:1.45;margin-top:10rpx}
/* 页面不自身滚动,改由 page-scroll 承载滚动(隐藏滚动条)*/
page{height:100vh;overflow:hidden;display:flex;flex-direction:column}
.page-scroll{flex:1;min-height:0}
+101
View File
@@ -0,0 +1,101 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const GENDERS = ['男孩', '女孩', '不确定'];
function ageFromBirthday(bdayStr) {
if (!bdayStr) return '';
const b = new Date(bdayStr);
const now = new Date();
let months = (now.getFullYear() - b.getFullYear()) * 12 + (now.getMonth() - b.getMonth());
if (months < 0) months = 0;
return months < 24 ? months + '个月' : Math.floor(months / 12) + '岁';
}
Page({
data: {
step: 0,
petType: '猫猫',
petEmoji: '🐱',
petName: '',
birthday: '',
genderIdx: 0,
weight: '',
stage: '幼年期',
goals: [
{ label: '不知道每天该做什么', on: true },
{ label: '疫苗驱虫怕忘记', on: true },
{ label: '想记录体重和健康', on: false },
{ label: '担心生病不会判断', on: false },
{ label: '想控制养宠开销', on: false },
{ label: '想生成成长报告', on: false },
],
submitting: false,
},
onLoad() {
// 已有宠物则直接进首页
store
.ready()
.then(() => {
if (store.getPets().length > 0) {
wx.switchTab({ url: '/pages/home/home' });
}
})
.catch(() => {});
},
nextStep() {
this.setData({ step: Math.min(this.data.step + 1, 5) });
},
choosePet(e) {
const { type, emoji } = e.currentTarget.dataset;
this.setData({ petType: type, petEmoji: emoji });
},
onName(e) { this.setData({ petName: e.detail.value }); },
onWeight(e) { this.setData({ weight: e.detail.value }); },
onBirthday(e) { this.setData({ birthday: e.detail.value }); },
selectGender(e) { this.setData({ genderIdx: Number(e.currentTarget.dataset.index) }); },
// 先随便看看:不建数据,直接进首页浏览(无宠物时首页显示空态)
skipBrowse() { wx.switchTab({ url: '/pages/home/home' }); },
savePetNext() {
if (!(this.data.petName || '').trim()) {
wx.showToast({ title: '给它起个名字吧', icon: 'none' });
return;
}
this.nextStep();
},
chooseStage(e) {
this.setData({ stage: e.currentTarget.dataset.stage });
},
toggleGoal(e) {
const i = e.currentTarget.dataset.index;
this.setData({ [`goals[${i}].on`]: !this.data.goals[i].on });
},
async submit() {
if (this.data.submitting) return;
this.setData({ submitting: true });
wx.showLoading({ title: '生成计划中...' });
const body = {
name: (this.data.petName || '').trim(),
emoji: this.data.petEmoji,
type: this.data.petType,
gender: GENDERS[this.data.genderIdx],
birthday: this.data.birthday,
weight: this.data.weight,
stage: this.data.stage,
age: ageFromBirthday(this.data.birthday),
goals: this.data.goals.filter((g) => g.on).map((g) => g.label),
};
try {
await store.ready();
await api.onboarding(body);
await store.loadPets();
wx.hideLoading();
wx.switchTab({ url: '/pages/home/home' });
} catch (e) {
wx.hideLoading();
wx.showToast({ title: e.message || '创建失败', icon: 'none' });
this.setData({ submitting: false });
}
},
finishOnboard() { this.submit(); },
});
+6
View File
@@ -0,0 +1,6 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar"
},
"navigationBarTitleText": "毛孩子计划"
}
+101
View File
@@ -0,0 +1,101 @@
<nav-bar title="毛孩子计划"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="onboard-page">
<!-- step 0 欢迎 -->
<view wx:if="{{step === 0}}" class="step">
<view class="hero">
<view class="pet-logo">🐾</view>
<view class="hero-h1">毛孩子计划</view>
<view class="hero-sub">新手养宠的 AI 日历、健康记录和成长报告。先生成计划,再每天照着做。</view>
</view>
<view class="proof-row">
<view class="proof"><text class="proof-b">30天</text><text class="proof-s">新手计划</text></view>
<view class="proof"><text class="proof-b">8类</text><text class="proof-s">快速记录</text></view>
<view class="proof"><text class="proof-b">AI</text><text class="proof-s">观察建议</text></view>
</view>
<button class="btn btn-primary btn-block" bindtap="nextStep">开始建档,生成计划</button>
<view class="skip-link" bindtap="skipBrowse">先随便看看</view>
</view>
<!-- step 1 选类型 -->
<view wx:elif="{{step === 1}}" class="step">
<view class="step-title">你的毛孩子是?</view>
<view class="step-sub">目前支持猫和狗,选一个开始。</view>
<view class="choice-grid">
<view class="choice-card {{petType === '猫猫' ? 'selected' : ''}}" data-type="猫猫" data-emoji="🐱" bindtap="choosePet">
<view class="animal">🐱</view><view class="choice-b">猫猫</view><view class="choice-s">幼猫、成年猫、老年猫</view>
</view>
<view class="choice-card {{petType === '狗狗' ? 'selected' : ''}}" data-type="狗狗" data-emoji="🐶" bindtap="choosePet">
<view class="animal">🐶</view><view class="choice-b">狗狗</view><view class="choice-s">幼犬、成年犬、老年犬</view>
</view>
</view>
<button class="btn btn-primary btn-block" bindtap="nextStep">下一步</button>
</view>
<!-- step 2 信息 -->
<view wx:elif="{{step === 2}}" class="step">
<view class="step-title">告诉我一点它的信息</view>
<view class="step-sub">先填基础信息,之后可随时补充。</view>
<view class="form-card">
<view class="field"><label>宠物名字</label>
<input class="input" value="{{petName}}" placeholder="例如:奶球" placeholder-class="placeholder" bindinput="onName"/></view>
<view class="field"><label>生日 / 到家时间</label>
<picker mode="date" value="{{birthday}}" bindchange="onBirthday">
<view class="picker-box">{{birthday || '选择日期'}}</view>
</picker></view>
<view class="field"><label>性别</label><view class="seg">
<view class="seg-btn {{genderIdx === 0 ? 'selected' : ''}}" data-index="0" bindtap="selectGender">男孩</view>
<view class="seg-btn {{genderIdx === 1 ? 'selected' : ''}}" data-index="1" bindtap="selectGender">女孩</view>
<view class="seg-btn {{genderIdx === 2 ? 'selected' : ''}}" data-index="2" bindtap="selectGender">不确定</view>
</view></view>
<view class="field"><label>当前体重(kg)</label>
<input class="input" value="{{weight}}" placeholder="例如:2.8" placeholder-class="placeholder" bindinput="onWeight" type="digit"/></view>
</view>
<button class="btn btn-primary btn-block" bindtap="savePetNext">下一步</button>
</view>
<!-- step 3 阶段 -->
<view wx:elif="{{step === 3}}" class="step">
<view class="step-title">它现在处于哪个阶段?</view>
<view class="step-sub">阶段会决定今日任务、提醒和 AI 建议。</view>
<view class="stage-list">
<view class="stage-card {{stage === '幼年期' ? 'selected' : ''}}" data-stage="幼年期" bindtap="chooseStage">
<view class="stage-b">幼年期</view><view class="stage-p">疫苗、驱虫、体重增长、行为训练</view></view>
<view class="stage-card {{stage === '刚到家 0-30 天' ? 'selected' : ''}}" data-stage="刚到家 0-30 天" bindtap="chooseStage">
<view class="stage-b">刚到家 0-30 天</view><view class="stage-p">适应环境、饮食稳定、基础照护</view></view>
<view class="stage-card {{stage === '成年期' ? 'selected' : ''}}" data-stage="成年期" bindtap="chooseStage">
<view class="stage-b">成年期</view><view class="stage-p">体重、饮食、日常健康管理</view></view>
<view class="stage-card {{stage === '老年期' ? 'selected' : ''}}" data-stage="老年期" bindtap="chooseStage">
<view class="stage-b">老年期</view><view class="stage-p">慢病、体检、用药和护理</view></view>
</view>
<button class="btn btn-primary btn-block" bindtap="nextStep">下一步</button>
</view>
<!-- step 4 目标 -->
<view wx:elif="{{step === 4}}" class="step">
<view class="step-title">你最想解决什么?</view>
<view class="step-sub">多选,我们会据此为你定制计划。</view>
<view class="check-list">
<view wx:for="{{goals}}" wx:key="label" class="check {{item.on ? 'selected' : ''}}" data-index="{{index}}" bindtap="toggleGoal">
<view class="box">{{item.on ? '✓' : ''}}</view>{{item.label}}
</view>
</view>
<button class="btn btn-primary btn-block" bindtap="nextStep">生成专属计划</button>
</view>
<!-- step 5 生成 -->
<view wx:elif="{{step === 5}}" class="step">
<view class="step-title">正在生成专属养宠计划</view>
<view class="step-sub">正在根据它的信息生成专属计划…</view>
<view class="gen-card">
<view class="gen-line"><view class="tick">✓</view> 已识别:{{stage}}</view>
<view class="gen-line"><view class="tick">✓</view> 已生成:30 天新手计划</view>
<view class="gen-line"><view class="tick">✓</view> 已安排:疫苗 / 驱虫提醒</view>
<view class="gen-line"><view class="tick">✓</view> 已开启:体重、便便、饮食记录</view>
<view class="gen-line"><view class="tick">✓</view> 已准备:首张成长报告卡片</view>
</view>
<button class="btn btn-primary btn-block" style="margin-top:36rpx" bindtap="finishOnboard">查看今日任务</button>
</view>
</view>
</scroll-view>
+55
View File
@@ -0,0 +1,55 @@
page{height:100vh;overflow:hidden;display:flex;flex-direction:column}
.page-scroll{flex:1;min-height:0}
.onboard-page{
padding:0 40rpx 56rpx;
min-height:100%;
background:
radial-gradient(circle at top right, rgba(245,169,71,.22), transparent 45%),
var(--bg);
}
.step{animation:fade .24s ease}
.skip-link{text-align:center;margin-top:28rpx;color:var(--muted);font-size:28rpx;padding:12rpx}
@keyframes fade{from{opacity:0;transform:translateY(16rpx)}to{opacity:1;transform:none}}
.hero{text-align:center;padding:60rpx 0 32rpx}
.pet-logo{
width:192rpx;height:192rpx;margin:0 auto 36rpx;border-radius:60rpx;
background:linear-gradient(135deg,#FFE0AB,#FDB35C);
display:flex;align-items:center;justify-content:center;font-size:96rpx;
box-shadow:0 32rpx 72rpx rgba(245,169,71,.26);
}
.hero-h1{font-size:64rpx;letter-spacing:-1.2rpx;font-weight:800}
.hero-sub{margin-top:20rpx;line-height:1.65;color:var(--muted);font-size:30rpx}
.proof-row{display:grid;grid-template-columns:repeat(3,1fr);gap:16rpx;margin:40rpx 0}
.proof{background:#fff;border-radius:36rpx;padding:24rpx 16rpx;text-align:center;box-shadow:var(--shadow)}
.proof-b{display:block;font-size:32rpx;font-weight:800}
.proof-s{color:var(--muted);font-size:22rpx}
.step-title{font-size:48rpx;letter-spacing:-.8rpx;line-height:1.24;margin:16rpx 0;font-weight:800}
.step-sub{color:var(--muted);line-height:1.6;font-size:28rpx;margin-bottom:32rpx}
.choice-grid{display:grid;grid-template-columns:1fr 1fr;gap:24rpx;margin-bottom:36rpx}
.choice-card{
background:#fff;border:4rpx solid transparent;border-radius:48rpx;min-height:264rpx;
padding:36rpx 28rpx;box-shadow:var(--shadow);transition:.18s ease;
}
.choice-card.selected{border-color:var(--primary);background:#FFF8EC}
.choice-card .animal{font-size:88rpx;margin-bottom:20rpx}
.choice-b{font-size:34rpx;font-weight:900}
.choice-s{display:block;color:var(--muted);font-size:24rpx;margin-top:12rpx}
.form-card{background:#fff;border-radius:48rpx;padding:32rpx;box-shadow:var(--shadow);margin-bottom:36rpx}
.stage-list{display:flex;flex-direction:column;gap:22rpx;margin-bottom:36rpx}
.stage-card{background:#fff;border-radius:40rpx;border:4rpx solid transparent;padding:30rpx;box-shadow:var(--shadow)}
.stage-card.selected{border-color:var(--green);background:var(--green-soft)}
.stage-b{font-size:32rpx;font-weight:900}
.stage-p{color:var(--muted);font-size:24rpx;margin-top:10rpx}
.gen-card{margin-top:32rpx;background:#fff;border-radius:56rpx;padding:48rpx;box-shadow:var(--shadow2)}
.gen-line{display:flex;align-items:center;gap:20rpx;padding:20rpx 0;color:#5F554D;font-size:28rpx}
.tick{
width:48rpx;height:48rpx;border-radius:50%;background:var(--green);color:#fff;
display:flex;align-items:center;justify-content:center;font-size:28rpx;font-weight:900;flex:none;
}
+133
View File
@@ -0,0 +1,133 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
function buildCells(year, month, taskedDays, today) {
const first = new Date(year, month - 1, 1);
const lead = (first.getDay() + 6) % 7; // 周一为一周起点
const daysInMonth = new Date(year, month, 0).getDate();
const set = {};
(taskedDays || []).forEach((d) => (set[d] = true));
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 });
}
return cells;
}
Page({
data: {
pet: {},
planView: 'timeline',
plan: null,
calCells: [],
calLabel: '',
calLoaded: false,
aiInput: '',
aiPlan: null,
aiBusy: false,
sheetShow: false,
sheetType: '',
},
onLoad() {
this._unsub = store.subscribe((pet) => {
this.setData({ pet, calLoaded: false });
this.loadTimeline();
if (this.data.planView === 'calendar') this.loadCalendar();
});
},
onUnload() {
if (this._unsub) this._unsub();
},
onShow() {
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 1 });
}
store
.ready()
.then(() => {
this.setData({ pet: store.getPet() });
this.loadTimeline();
})
.catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' }));
},
loadTimeline() {
const id = store.currentPetId();
if (!id) return;
api
.getPlan(id)
.then((plan) => this.setData({ plan }))
.catch(() => this.setData({ plan: null }));
},
loadCalendar() {
const id = store.currentPetId();
if (!id) return;
api
.planCalendar(id)
.then((res) => {
this.setData({
calCells: buildCells(res.year, res.month, res.tasked_days, res.today),
calLabel: `${res.year}${res.month}`,
calLoaded: true,
});
})
.catch(() => {});
},
switchPlanView(e) {
const view = e.currentTarget.dataset.view;
this.setData({ planView: view });
if (view === 'calendar' && !this.data.calLoaded) this.loadCalendar();
},
// 计划明细勾选
toggleTimelineTask(e) {
const id = e.currentTarget.dataset.id;
api.togglePlanTask(id).then(() => this.loadTimeline()).catch(() => {});
},
// AI 计划
onAiInput(e) {
this.setData({ aiInput: e.detail.value });
},
genAIPlan() {
const id = store.currentPetId();
const input = (this.data.aiInput || '').trim();
if (!id || !input) {
wx.showToast({ title: '先描述一下情况', icon: 'none' });
return;
}
this.setData({ aiBusy: true });
wx.showLoading({ title: 'AI 生成中...' });
api
.createAIPlan(id, input)
.then((plan) => {
wx.hideLoading();
this.setData({ aiPlan: plan, aiBusy: false });
})
.catch((err) => {
wx.hideLoading();
this.setData({ aiBusy: false });
wx.showToast({ title: err.message || '生成失败', icon: 'none' });
});
},
applyAIPlan() {
const p = this.data.aiPlan;
if (!p) return;
api
.applyAIPlan(p.id)
.then(() => wx.showToast({ title: '已加入我的计划', icon: 'success' }))
.catch((err) => wx.showToast({ title: err.message || '加入失败', icon: 'none' }));
},
openSheet(e) {
const type = e.currentTarget.dataset.type;
if (!type) return; // 无关联动作的计划项只可勾选,不弹层
this.setData({ sheetType: type, sheetShow: true });
},
closeSheet() {
this.setData({ sheetShow: false });
},
onAddPet() {
this.setData({ sheetType: 'addPet', sheetShow: true });
},
onFab() {
this.setData({ sheetType: 'ai', sheetShow: true });
},
});
+8
View File
@@ -0,0 +1,8 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pet-switch": "/components/pet-switch/pet-switch",
"bottom-sheet": "/components/bottom-sheet/bottom-sheet",
"fab": "/components/fab/fab"
}
}
+85
View File
@@ -0,0 +1,85 @@
<nav-bar title="养宠计划"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body">
<view class="page-title">
<view class="pt-h2">养宠计划</view>
<view class="pt-p">按宠物查看路线图、日历与 AI 计划。</view>
</view>
<pet-switch bind:add="onAddPet"></pet-switch>
<view class="inline-tabs">
<view class="it-btn {{planView === 'timeline' ? 'active' : ''}}" data-view="timeline" bindtap="switchPlanView">路线图</view>
<view class="it-btn {{planView === 'calendar' ? 'active' : ''}}" data-view="calendar" bindtap="switchPlanView">日历</view>
<view class="it-btn {{planView === 'aiPlan' ? 'active' : ''}}" data-view="aiPlan" bindtap="switchPlanView">AI 计划</view>
</view>
<!-- 路线图 -->
<view wx:if="{{planView === 'timeline'}}">
<view class="card">
<view class="section-head">
<view class="sh-title">{{pet.name}}的 30 天计划</view>
<view class="tag warn">{{plan.completion_pct || 0}}%</view>
</view>
<view class="progress-row"><text>完成度</text><text>{{plan.completion_pct || 0}}%</text></view>
<view class="bar"><view class="bar-fill" style="width:{{plan.completion_pct || 0}}%"></view></view>
</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="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">
<text class="pk-b">{{item.title}}</text><view class="pk-p">{{item.description}}</view>
</view>
</view>
</view>
<view wx:if="{{!plan}}" class="pt-p" style="padding:20rpx 0">暂无计划</view>
</view>
</view>
<!-- 日历 -->
<view wx:elif="{{planView === 'calendar'}}">
<view class="calendar">
<view class="cal-head"><text>{{calLabel}}</text><view class="link" data-type="reminders" bindtap="openSheet">提醒</view></view>
<view class="cal-grid">
<text class="cal-w">一</text><text class="cal-w">二</text><text class="cal-w">三</text><text class="cal-w">四</text><text class="cal-w">五</text><text class="cal-w">六</text><text class="cal-w">日</text>
<view wx:for="{{calCells}}" wx:key="index"
class="cal-cell {{item.today ? 'today' : ''}} {{item.tasked ? 'tasked' : ''}} {{item.day === 0 ? 'blank' : ''}}">{{item.day || ''}}</view>
</view>
</view>
</view>
<!-- AI 计划 -->
<view wx:elif="{{planView === 'aiPlan'}}">
<view class="card">
<view class="section-head"><view class="sh-title">AI 计划模式</view><view class="tag purple">Plan</view></view>
<view class="ai-plan-tip">描述 {{pet.name}} 的近况(阶段、近期变化、担心的问题),AI 会提取关键信息并生成可执行计划。</view>
<textarea class="textarea" placeholder="例如:4 个月,刚换粮有点软便,也快打第二针疫苗" placeholder-class="placeholder" value="{{aiInput}}" bindinput="onAiInput"></textarea>
<button class="btn btn-primary btn-block" style="margin-top:16rpx" bindtap="genAIPlan" disabled="{{aiBusy}}">{{aiBusy ? '生成中…' : '生成计划'}}</button>
</view>
<view wx:if="{{aiPlan}}" class="card">
<view class="section-head"><view class="sh-title">已提取信息</view></view>
<view class="extracted">
<view class="extract"><text class="ex-b">阶段</text>{{aiPlan.extracted.stage}}</view>
<view class="extract"><text class="ex-b">风险</text>{{aiPlan.extracted.risk}}</view>
<view class="extract"><text class="ex-b">重点</text>{{aiPlan.extracted.priority}}</view>
<view class="extract"><text class="ex-b">提醒</text>{{aiPlan.extracted.reminder}}</view>
</view>
<view class="section-head" style="margin-top:20rpx"><view class="sh-title">生成的计划</view></view>
<view class="task-list">
<view wx:for="{{aiPlan.tasks}}" wx:key="id" class="task">
<view class="circle"></view>
<view class="task-text"><text class="tt-b">{{item.day_label}}· {{item.title}}</text><view class="tt-p">{{item.description}}</view></view>
</view>
</view>
<button class="btn btn-primary btn-block" style="margin-top:16rpx" bindtap="applyAIPlan">加入我的计划</button>
</view>
</view>
</view>
</scroll-view>
<fab bind:tap="onFab"></fab>
<bottom-sheet show="{{sheetShow}}" type="{{sheetType}}" bind:close="closeSheet"></bottom-sheet>
+54
View File
@@ -0,0 +1,54 @@
.page-body{padding:8rpx 40rpx calc(210rpx + env(safe-area-inset-bottom))}
.pt-h2{font-size:46rpx;letter-spacing:-.6rpx;font-weight:800}
.pt-p{color:var(--muted);font-size:26rpx;margin-top:10rpx}
.tt-b{font-size:28rpx;font-weight:700}
.tt-p{font-size:24rpx;color:var(--muted);margin-top:8rpx;line-height:1.45}
.ac-b{font-size:30rpx;font-weight:700}
.ac-p{color:var(--muted);font-size:24rpx;line-height:1.45;margin-top:10rpx}
.inline-tabs{display:flex;padding:8rpx;gap:8rpx;border-radius:32rpx;background:#EFE8DE;margin-bottom:28rpx}
.it-btn{
flex:1;height:68rpx;line-height:68rpx;text-align:center;border-radius:24rpx;
color:var(--muted);font-weight:900;font-size:26rpx;
}
.it-btn.active{background:#fff;color:var(--primary-dark);box-shadow:0 12rpx 28rpx rgba(0,0,0,.06)}
.timeline{padding-left:36rpx;position:relative}
.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-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;
}
.plan-task{background:#fff;border-radius:36rpx;padding:26rpx;box-shadow:var(--shadow);border:1rpx solid transparent;display:flex;gap:18rpx;align-items:flex-start}
.plan-task.done{opacity:.55}
.pk-check{
width:44rpx;height:44rpx;border-radius:50%;border:4rpx solid #DED1C4;flex:none;margin-top:2rpx;
display:flex;align-items:center;justify-content:center;color:#fff;font-size:24rpx;font-weight:900;
}
.plan-task.done .pk-check{background:var(--green);border-color:var(--green)}
.pk-body{flex:1}
.pk-b{font-size:28rpx;font-weight:700}
.pk-p{color:var(--muted);font-size:24rpx;line-height:1.45;margin-top:10rpx}
.ai-plan-tip{color:var(--muted);font-size:26rpx;line-height:1.6;margin-bottom:16rpx}
.calendar{background:#fff;border-radius:44rpx;padding:28rpx;box-shadow:var(--shadow)}
.cal-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:20rpx;font-weight:900;font-size:30rpx}
.cal-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:12rpx;text-align:center}
.cal-w{font-size:22rpx;color:var(--muted);padding:8rpx 0}
.cal-cell{
height:76rpx;border-radius:24rpx;display:flex;align-items:center;justify-content:center;
font-weight:800;font-size:26rpx;background:#FBFAF8;position:relative;
}
.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)}
.extracted{display:grid;grid-template-columns:1fr 1fr;gap:16rpx;margin:24rpx 0}
.extract{background:#FBFAF8;border-radius:30rpx;padding:20rpx;font-size:24rpx}
.ex-b{display:block;font-size:26rpx;margin-bottom:6rpx;font-weight:700}
/* 页面不自身滚动,改由 page-scroll 承载滚动(隐藏滚动条)*/
page{height:100vh;overflow:hidden;display:flex;flex-direction:column}
.page-scroll{flex:1;min-height:0}
+55
View File
@@ -0,0 +1,55 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
function completion(pet) {
if (!pet) return 0;
const fields = ['name', 'emoji', 'type', 'gender', 'birthday', 'weight', 'stage', 'age', 'color', 'breed'];
const filled = fields.filter((f) => pet[f]).length;
return Math.round((filled / fields.length) * 100);
}
Page({
data: {
pet: {},
user: {},
summary: { pets: 0, records: 0, reminders: 0, pro_status: 'none' },
profilePct: 0,
sheetShow: false,
sheetType: '',
},
onLoad() {
this._unsub = store.subscribe((pet) => this.setData({ pet, profilePct: completion(pet) }));
},
onUnload() {
if (this._unsub) this._unsub();
},
onShow() {
store
.ready()
.then(() => {
const pet = store.getPet();
this.setData({ pet, user: store.getUser() || {}, profilePct: completion(pet) });
this.loadSummary();
})
.catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' }));
},
loadSummary() {
api.userSummary().then((summary) => this.setData({ summary })).catch(() => {});
},
openSheet(e) {
this.setData({ sheetType: e.currentTarget.dataset.type, sheetShow: true });
},
onAddPet() {
this.setData({ sheetType: 'addPet', sheetShow: true });
},
onEditPet() {
this.setData({ sheetType: 'editPet', sheetShow: true });
},
closeSheet() {
this.setData({ sheetShow: false });
this.loadSummary();
},
onFab() {
this.setData({ sheetType: 'ai', sheetShow: true });
},
});
+7
View File
@@ -0,0 +1,7 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"bottom-sheet": "/components/bottom-sheet/bottom-sheet",
"fab": "/components/fab/fab"
}
}
+33
View File
@@ -0,0 +1,33 @@
<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 class="profile-header">
<view class="avatar ph-avatar">{{pet.emoji}}</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>
</view>
<button class="btn btn-ghost btn-block" style="margin-bottom:28rpx" bindtap="onAddPet"> 添加毛孩子</button>
<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>
<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>
<button class="btn btn-primary btn-block" data-type="pro" bindtap="openSheet">开通 Pro</button>
</view>
</scroll-view>
<fab bind:tap="onFab"></fab>
<bottom-sheet show="{{sheetShow}}" type="{{sheetType}}" bind:close="closeSheet"></bottom-sheet>
+18
View File
@@ -0,0 +1,18 @@
.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-name{font-size:44rpx;font-weight:800}
.ph-meta{font-size:26rpx;margin-top:10rpx}
.profile-list{background:#fff;border-radius:48rpx;box-shadow:var(--shadow);overflow:hidden;margin-bottom:28rpx}
.profile-row{
display:flex;align-items:center;justify-content:space-between;min-height:104rpx;padding:0 32rpx;
border-bottom:1rpx solid var(--line);font-size:28rpx;font-weight:800;
}
.profile-row:last-child{border-bottom:0}
.pr-val{color:var(--muted);font-weight:700;font-size:26rpx}
/* 页面不自身滚动,改由 page-scroll 承载滚动(隐藏滚动条)*/
page{height:100vh;overflow:hidden;display:flex;flex-direction:column}
.page-scroll{flex:1;min-height:0}
+79
View File
@@ -0,0 +1,79 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
function pad(n) {
return n < 10 ? '0' + n : '' + n;
}
function fmtTime(iso) {
if (!iso) return '';
const d = new Date(iso);
const now = new Date();
const hm = pad(d.getHours()) + ':' + pad(d.getMinutes());
if (d.toDateString() === now.toDateString()) return '今天 ' + hm;
const y = new Date(now.getTime() - 86400000);
if (d.toDateString() === y.toDateString()) return '昨天 ' + hm;
return d.getMonth() + 1 + '月' + d.getDate() + '日';
}
function mapRecord(r) {
return {
icon: r.icon || '✍️',
title: r.title,
desc: fmtTime(r.occurred_at) + (r.description ? '' + r.description : ''),
};
}
Page({
data: {
pet: {},
timeline: [],
sheetShow: false,
sheetType: '',
},
onLoad() {
this._unsub = store.subscribe((pet) => {
this.setData({ pet });
this.loadRecords();
});
},
onUnload() {
if (this._unsub) this._unsub();
},
onShow() {
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 2 });
}
store
.ready()
.then(() => {
this.setData({ pet: store.getPet() });
this.loadRecords();
})
.catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' }));
},
loadRecords() {
const id = store.currentPetId();
if (!id) return;
api
.getRecords(id)
.then((records) => this.setData({ timeline: (records || []).map(mapRecord) }))
.catch(() => {});
},
openSheet(e) {
this.openSheetType(e.currentTarget.dataset.type);
},
openSheetType(type) {
this.setData({ sheetType: type, sheetShow: true });
},
closeSheet() {
this.setData({ sheetShow: false });
},
onSheetSaved() {
this.loadRecords();
},
onAddPet() {
this.openSheetType('addPet');
},
onFab() {
this.openSheetType('ai');
},
});
+8
View File
@@ -0,0 +1,8 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pet-switch": "/components/pet-switch/pet-switch",
"bottom-sheet": "/components/bottom-sheet/bottom-sheet",
"fab": "/components/fab/fab"
}
}
+44
View File
@@ -0,0 +1,44 @@
<nav-bar title="快速记录"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body">
<view class="page-title">
<view class="pt-h2">快速记录</view>
<view class="pt-p">先选择宠物,再记录体重、饮食、异常和消费。</view>
</view>
<pet-switch bind:add="onAddPet"></pet-switch>
<view class="record-grid">
<view class="record-item" data-type="weight" bindtap="openSheet"><text class="ri-ic">⚖️</text>体重</view>
<view class="record-item" data-type="food" bindtap="openSheet"><text class="ri-ic">🍽️</text>饮食</view>
<view class="record-item" data-type="poop" bindtap="openSheet"><text class="ri-ic">💩</text>便便</view>
<view class="record-item" data-type="symptom" bindtap="openSheet"><text class="ri-ic">🤒</text>异常</view>
<view class="record-item" data-type="medicine" bindtap="openSheet"><text class="ri-ic">💊</text>用药</view>
<view class="record-item" data-type="cost" bindtap="openSheet"><text class="ri-ic">💰</text>消费</view>
<view class="record-item" data-type="vaccine" bindtap="openSheet"><text class="ri-ic">💉</text>疫苗</view>
<view class="record-item" data-type="deworm" bindtap="openSheet"><text class="ri-ic">🛡️</text>驱虫</view>
<view class="record-item" data-type="photo" bindtap="openSheet"><text class="ri-ic">📷</text>照片</view>
</view>
<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>
<view class="card">
<view class="section-head"><view class="sh-title">健康时间轴</view><view class="link" data-type="vetSummary" bindtap="openSheet">摘要</view></view>
<view class="health-timeline">
<view wx:for="{{timeline}}" wx:key="index" class="health-event">
<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>
</view>
</view>
</scroll-view>
<fab bind:tap="onFab"></fab>
<bottom-sheet show="{{sheetShow}}" type="{{sheetType}}" bind:close="closeSheet" bind:saved="onSheetSaved"></bottom-sheet>
+32
View File
@@ -0,0 +1,32 @@
.page-body{padding:8rpx 40rpx calc(210rpx + env(safe-area-inset-bottom))}
.pt-h2{font-size:46rpx;letter-spacing:-.6rpx;font-weight:800}
.pt-p{color:var(--muted);font-size:26rpx;margin-top:10rpx}
.record-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24rpx;margin-bottom:28rpx}
.record-item{
height:184rpx;background:#fff;border-radius:44rpx;box-shadow:var(--shadow);
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16rpx;
font-weight:900;color:var(--text);font-size:28rpx;
}
.ri-ic{font-size:56rpx}
.mini-chart{
height:256rpx;border-radius:36rpx;margin-top:24rpx;position:relative;overflow:hidden;
background:
linear-gradient(180deg,rgba(115,190,157,.16),rgba(115,190,157,0)),
repeating-linear-gradient(0deg,#fff,#fff 60rpx,#EFE8DE 62rpx);
}
.chart-line{position:absolute;left:36rpx;right:36rpx;top:44rpx;bottom:44rpx;width:auto;height:auto}
.health-timeline{display:flex;flex-direction:column;gap:20rpx}
.health-event{display:flex;gap:20rpx;background:#FBFAF8;border-radius:32rpx;padding:24rpx}
.event-dot{
width:68rpx;height:68rpx;border-radius:26rpx;flex:none;
display:flex;align-items:center;justify-content:center;background:#FFF0D7;font-size:34rpx;
}
.he-b{font-size:28rpx;font-weight:700}
.he-p{color:var(--muted);font-size:24rpx;line-height:1.45;margin-top:6rpx}
/* 页面不自身滚动,改由 page-scroll 承载滚动(隐藏滚动条)*/
page{height:100vh;overflow:hidden;display:flex;flex-direction:column}
.page-scroll{flex:1;min-height:0}
+62
View File
@@ -0,0 +1,62 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
Page({
data: {
pet: {},
report: null,
gainText: '+0.0',
bill: null,
summary: null,
sheetShow: false,
sheetType: '',
},
onLoad() {
this._unsub = store.subscribe((pet) => {
this.setData({ pet });
this.loadData();
});
},
onUnload() {
if (this._unsub) this._unsub();
},
onShow() {
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 3 });
}
store
.ready()
.then(() => {
this.setData({ pet: store.getPet() });
this.loadData();
})
.catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' }));
},
loadData() {
const id = store.currentPetId();
if (!id) return;
Promise.all([api.weeklyReport(id), api.getBill(id, 'month'), api.healthSummary(id)])
.then(([report, bill, summary]) => {
const g = report.weight_gain || 0;
this.setData({
report,
gainText: (g >= 0 ? '+' : '') + g.toFixed(1),
bill,
summary,
});
})
.catch(() => {});
},
openSheet(e) {
this.setData({ sheetType: e.currentTarget.dataset.type, sheetShow: true });
},
closeSheet() {
this.setData({ sheetShow: false });
},
onAddPet() {
this.setData({ sheetType: 'addPet', sheetShow: true });
},
onFab() {
this.setData({ sheetType: 'ai', sheetShow: true });
},
});
+8
View File
@@ -0,0 +1,8 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pet-switch": "/components/pet-switch/pet-switch",
"bottom-sheet": "/components/bottom-sheet/bottom-sheet",
"fab": "/components/fab/fab"
}
}
+59
View File
@@ -0,0 +1,59 @@
<nav-bar title="成长报告"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body">
<view class="page-title">
<view class="pt-h2">成长报告</view>
<view class="pt-p">每只宠物的成长报告、账单与健康摘要。</view>
</view>
<pet-switch bind:add="onAddPet"></pet-switch>
<view class="card report-card">
<view class="section-head"><view class="sh-title">本周成长报告</view><view class="tag">{{report.health_status || '稳定成长'}}</view></view>
<view class="rc-text">{{report.summary || '暂无本周数据'}}</view>
<view class="stats-grid">
<view class="stat"><text class="stat-b">{{report.tasks_completed || 0}}</text><text class="stat-s">完成任务</text></view>
<view class="stat"><text class="stat-b">{{gainText}}</text><text class="stat-s">kg 增长</text></view>
<view class="stat"><text class="stat-b">{{report.high_risk_count || 0}}</text><text class="stat-s">高风险异常</text></view>
</view>
<view class="sheet-actions">
<button class="btn btn-ghost" data-type="reportDetail" bindtap="openSheet">查看报告</button>
<button class="btn btn-primary" data-type="poster" bindtap="openSheet">分享卡片</button>
</view>
</view>
<view class="card">
<view class="section-head"><view class="sh-title">健康摘要</view><view class="link" data-type="vetSummary" bindtap="openSheet">导出</view></view>
<view class="record-row"><text class="bold">疫苗进度</text><text class="muted">{{summary.vaccine_progress || '—'}}</text></view>
<view class="record-row"><text class="bold">驱虫状态</text><text class="muted">{{summary.deworm_status || '—'}}</text></view>
<view class="record-row"><text class="bold">体重趋势</text><text class="muted">{{summary.weight_trend || '—'}}</text></view>
<view class="record-row"><text class="bold">异常记录</text><text class="muted">{{summary.anomaly_count || 0}} 次</text></view>
</view>
<view class="card">
<view class="section-head"><view class="sh-title">养宠账单</view><view class="link" data-type="cost" bindtap="openSheet">记一笔</view></view>
<view class="stats-grid">
<view class="stat"><text class="stat-b">¥{{bill.total || 0}}</text><text class="stat-s">本月花费</text></view>
<view class="stat"><text class="stat-b">¥{{bill.max_single || 0}}</text><text class="stat-s">最大支出</text></view>
<view class="stat"><text class="stat-b">{{bill.categories.length || 0}}</text><text class="stat-s">消费类别</text></view>
</view>
<view class="bill-bars">
<view wx:for="{{bill.categories}}" wx:key="category" class="bill-row">
<text class="bill-cat">{{item.category}}</text>
<view class="mini-bar"><view class="mini-bar-fill" style="width:{{item.percent}}%"></view></view>
<text class="bill-amt">¥{{item.amount}}</text>
</view>
</view>
</view>
<view class="card">
<view class="section-head"><view class="sh-title">Pro 会员</view></view>
<view class="rc-text">解锁 365 天计划、PDF 健康档案、月度报告、多宠物管理与年度账单。</view>
<button class="btn btn-primary btn-block" style="margin-top:24rpx" data-type="pro" bindtap="openSheet">查看 Pro 权益</button>
</view>
</view>
</scroll-view>
<fab bind:tap="onFab"></fab>
<bottom-sheet show="{{sheetShow}}" type="{{sheetType}}" bind:close="closeSheet"></bottom-sheet>
+19
View File
@@ -0,0 +1,19 @@
.page-body{padding:8rpx 40rpx calc(210rpx + env(safe-area-inset-bottom))}
.pt-h2{font-size:46rpx;letter-spacing:-.6rpx;font-weight:800}
.pt-p{color:var(--muted);font-size:26rpx;margin-top:10rpx}
.report-card{background:linear-gradient(135deg,#FFFFFF,#F2FAF6)}
.rc-text{color:var(--muted);font-size:28rpx;line-height:1.6}
.stat-b{display:block;font-size:36rpx;margin-bottom:8rpx;font-weight:800}
.stat-s{font-size:22rpx;color:var(--muted)}
.bill-bars{display:flex;flex-direction:column;gap:20rpx;margin-top:24rpx}
.bill-row{display:grid;grid-template-columns:88rpx 1fr 108rpx;gap:16rpx;align-items:center;font-size:24rpx}
.bill-cat{color:var(--text)}
.bill-amt{font-weight:900;text-align:right}
.mini-bar{height:18rpx;border-radius:999rpx;background:#F0E7DD;overflow:hidden}
.mini-bar-fill{display:block;height:100%;border-radius:999rpx;background:linear-gradient(90deg,var(--blue),#A9C8F6)}
/* 页面不自身滚动,改由 page-scroll 承载滚动(隐藏滚动条)*/
page{height:100vh;overflow:hidden;display:flex;flex-direction:column}
.page-scroll{flex:1;min-height:0}