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
+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}