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