feat(fe): AI 独立成页(打字机)+ 修快速记录 6 处问题 + 导航栏去接缝

AI 从底部弹层改成独立页面 pages/ai/ai:
- 整屏聊天,输入框固定在底部,不再是「问题都打不完」的小格子
- 回复逐字吐出(30ms/2 字),点一下可跳过
- 会话 session 从 sheet 改为 chat,历史拉 50 条
- 组件里对应的 ai 分支和 aiMessages/aiInput/sendAI 一并删掉,
  避免两套实现并存

快速记录九个入口逐个排查,修掉 6 处:
1. 疫苗/驱虫的默认日期写死成 2026-07-21 / 2026-07-15,早已是过去的
   日期 —— 改成打开时按今天 +30 / +90 天算
2. 异常观察没选症状时,optIdx 返回 0 会被当成选了「呕吐」,等于替
   用户瞎填 —— 加 optPicked 判断,没选就提示
3. 体重为空时会静默存成「上次的体重」,凭空造一条假数据 —— 改为必填校验
4. 消费金额为空会存成 ¥0 —— 必填校验
5. 用药药品名为空 —— 必填校验
6. 照片弹层里「模拟上传预览」是没删掉的假文案

顺带清理:保存按钮上的 data-text / data-icon 从来没被读过(onSave 只用
buildRecord 的返回值),是纯误导的死属性,删掉。我上一条 commit 把它们
说成「写库值」是说错了 —— 真正写库的是 buildRecord 里的 icon 字段,那部分
仍然没动。

导航栏原来是一整条半透明色块,底边和页面渐变硬碰硬,屏幕上横着一道接缝。
改成上实下虚的渐变,并用同一条 mask 把毛玻璃一起淡出(只淡背景不淡 blur
的话,接缝会从色差变成糊边)。返回键做成圆形,和右上角胶囊视觉对称。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-29 11:10:41 +08:00
parent cab8b9ee28
commit 35fc57e8de
16 changed files with 281 additions and 97 deletions
+36 -48
View File
@@ -8,6 +8,13 @@ function fmtDate(iso) {
return d.getMonth() + 1 + '月' + d.getDate() + '日';
}
// n 天后的 YYYY-MM-DD,给提醒类弹层做默认日期
function daysLater(n) {
const d = new Date(Date.now() + n * 86400000);
const p = (x) => (x < 10 ? '0' + x : '' + x);
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
}
function ageFromBirthday(bday) {
if (!bday) return '';
const b = new Date(bday);
@@ -60,7 +67,7 @@ Component({
pet: {},
segSel: {},
optSel: {},
dateVals: { vaccine: '2026-07-21', deworm: '2026-07-15' },
dateVals: { vaccine: '', deworm: '' },
wInput: '',
wNote: '',
poopNote: '',
@@ -87,8 +94,6 @@ Component({
addColor: '',
addBreed: '',
commentText: '',
aiInput: '',
aiMessages: [],
riskData: null,
comments: [],
reminders: [],
@@ -147,6 +152,9 @@ Component({
patch.wInput = (pet.weight || '').replace('kg', '');
patch.wNote = '';
}
// 提醒日期给个合理的默认值。原本写死成固定日期,时间一过就成了「提醒昨天」
if (type === 'vaccine') patch.dateVals = { ...this.data.dateVals, vaccine: daysLater(30) };
if (type === 'deworm') patch.dateVals = { ...this.data.dateVals, deworm: daysLater(90) };
if (type === 'editPet') {
// 预填当前宠物档案
patch.addName = pet.name || '';
@@ -160,15 +168,6 @@ Component({
};
patch.optSel = { addStage: Math.max(0, STAGES.indexOf(pet.stage)) };
}
if (type === 'ai') {
patch.aiMessages = [
{
role: 'ai',
text: `我是 ${pet.name || '毛孩子'} 的养宠助手。我会优先基于档案、计划和历史记录回答,而不是泛泛聊天。`,
},
];
patch.aiInput = '';
}
this.setData(patch);
this.loadSheetData(type);
},
@@ -190,8 +189,6 @@ Component({
api.getPoster(id).then((p) => this.setData({ poster: p })).catch(() => {});
} else if (type === 'reportDetail' && id) {
api.weeklyReport(id).then((r) => this.setData({ reportDetail: r })).catch(() => {});
} else if (type === 'ai') {
this.loadAIHistory();
} else if (type === 'pro') {
api.getPro().then((p) => this.setData({ proInfo: p })).catch(() => {});
}
@@ -492,10 +489,18 @@ Component({
const v = this.data.optSel[group];
return v === undefined ? 0 : v;
},
// 与 optIdx 的区别:没选就是没选,不当成选了第 0 项
optPicked(group) {
return this.data.optSel[group] !== undefined;
},
// 异常观察 → 生成风险评估(真调后端 AI,失败回退)
onGenRisk() {
const id = store.currentPetId();
if (!this.optPicked('symp')) {
wx.showToast({ title: '先选一下发生了什么', icon: 'none' });
return;
}
const symptoms = ['呕吐', '拉稀', '精神差', '皮肤红'];
const durations = ['刚刚', '半天', '1天以上'];
const spirits = ['正常', '一般', '明显变差'];
@@ -597,13 +602,26 @@ Component({
},
onSave() {
const t = this.data.innerType;
if (t === 'cost' && !(parseFloat(this.data.costAmount) > 0)) {
wx.showToast({ title: '先填个金额', icon: 'none' });
return;
}
if (t === 'medicine' && !(this.data.medName || '').trim()) {
wx.showToast({ title: '先填药品名称', icon: 'none' });
return;
}
this.createAndClose(this.buildRecord());
},
onSaveWeight() {
let w = (this.data.wInput || '').toString().replace(/\s+/g, '');
if (!w) w = (this.data.pet.weight || '').replace('kg', '');
const num = parseFloat(w) || 0;
const weight = w.includes('kg') ? w : w + 'kg';
const w = (this.data.wInput || '').toString().replace(/\s+/g, '').replace('kg', '');
const num = parseFloat(w);
// 原来空值会静默存成「上次的体重」,等于凭空造一条假数据
if (!w || !(num > 0)) {
wx.showToast({ title: '填一个有效体重', icon: 'none' });
return;
}
const weight = w + 'kg';
this.createAndClose({ type: 'weight', icon: '⚖️', title: '体重 ' + weight, num_value: num, description: (this.data.wNote || '').trim() });
},
onSavePhoto() {
@@ -745,35 +763,5 @@ Component({
this.close();
},
// AI 聊天
onAiInput(e) { this.setData({ aiInput: e.detail.value }); },
// 拉取历史对话,接在开场白之后;没有历史就保持开场白
loadAIHistory() {
api
.aiMessages('sheet', 30)
.then((list) => {
if (!list || !list.length) return;
const history = list.map((m) => ({ role: m.role === 'user' ? 'user' : 'ai', text: m.text }));
this.setData({ aiMessages: this.data.aiMessages.concat(history) });
})
.catch(() => {});
},
sendAI(e) {
const preset = e.currentTarget.dataset.text;
const text = (preset || this.data.aiInput || '').trim();
if (!text) return;
const msgs = this.data.aiMessages.concat([{ role: 'user', text }]);
this.setData({ aiMessages: msgs, aiInput: '' });
api
.aiChat({ pet_id: store.currentPetId() || null, session: 'sheet', text })
.then((res) => {
this.setData({ aiMessages: this.data.aiMessages.concat([{ role: 'ai', text: res.reply }]) });
})
.catch(() => {
this.setData({
aiMessages: this.data.aiMessages.concat([{ role: 'ai', text: '网络异常,请稍后再试。' }]),
});
});
},
},
});
@@ -33,7 +33,7 @@ module.exports.sel = function (map, key, index, def) {
</view></view>
<view class="field"><label>颜色/备注</label>
<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>
<button class="btn btn-primary btn-block" bindtap="onSave">保存记录</button>
</block>
<!-- 记录饮食 -->
@@ -46,7 +46,7 @@ module.exports.sel = function (map, key, index, def) {
<view class="seg-btn {{u.sel(segSel,'food',1,0)?'selected':''}}" data-group="food" data-index="1" bindtap="onSeg">偏少</view>
<view class="seg-btn {{u.sel(segSel,'food',2,0)?'selected':''}}" data-group="food" data-index="2" bindtap="onSeg">不吃</view>
</view></view>
<button class="btn btn-primary btn-block" data-text="饮食记录:食欲正常" data-icon="🍽️" bindtap="onSave">保存记录</button>
<button class="btn btn-primary btn-block" bindtap="onSave">保存记录</button>
</block>
<!-- 异常观察 -->
@@ -80,7 +80,7 @@ module.exports.sel = function (map, key, index, def) {
<view class="sheet-p"><text class="bold">建议:</text>{{riskData ? riskData.suggestion : '继续观察精神、食欲和排便;暂停新食物;记录呕吐或腹泻次数。'}}</view>
<view class="sheet-p"><text class="bold">建议就医:</text>{{riskData ? riskData.seek_care : '如果持续超过 24 小时,或伴随便血、精神明显变差、频繁呕吐,建议尽快就医。'}}</view>
<view class="sheet-actions">
<button class="btn btn-ghost" data-text="异常观察:风险中" data-icon="🤒" bindtap="onSave">保存记录</button>
<button class="btn btn-ghost" bindtap="onSave">保存记录</button>
<button class="btn btn-primary" data-type="vetSummary" bindtap="goSheet">就医摘要</button>
</view>
</block>
@@ -93,7 +93,7 @@ module.exports.sel = function (map, key, index, def) {
当前体重:{{pet.weight}}
近期重点:疫苗期、换粮观察、排便记录
建议携带:排便/呕吐照片、饮食变化、疫苗驱虫记录。</view>
<button class="btn btn-primary btn-block" data-text="生成就医前摘要" data-icon="📄" bindtap="onSave">保存摘要</button>
<button class="btn btn-primary btn-block" bindtap="onSave">保存摘要</button>
</block>
<!-- 记录用药 -->
@@ -101,7 +101,7 @@ module.exports.sel = function (map, key, index, def) {
<view class="sheet-h3">记录用药</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>
<button class="btn btn-primary btn-block" bindtap="onSave">保存记录</button>
</block>
<!-- 记一笔消费 -->
@@ -114,7 +114,7 @@ module.exports.sel = function (map, key, index, def) {
<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" value="{{costNote}}" bindinput="onCostNote"/></view>
<button class="btn btn-primary btn-block" data-text="消费记录:¥168" data-icon="💰" bindtap="onSave">保存记录</button>
<button class="btn btn-primary btn-block" bindtap="onSave">保存记录</button>
</block>
<!-- 疫苗提醒 -->
@@ -125,7 +125,7 @@ module.exports.sel = function (map, key, index, def) {
<picker mode="date" value="{{dateVals.vaccine}}" data-key="vaccine" bindchange="onDate">
<view class="picker-box">{{dateVals.vaccine}}</view>
</picker></view>
<button class="btn btn-primary btn-block" data-text="疫苗提醒:7月21日" data-icon="💉" bindtap="onSave">保存提醒</button>
<button class="btn btn-primary btn-block" bindtap="onSave">保存提醒</button>
</block>
<!-- 驱虫提醒 -->
@@ -136,7 +136,7 @@ module.exports.sel = function (map, key, index, def) {
<picker mode="date" value="{{dateVals.deworm}}" data-key="deworm" bindchange="onDate">
<view class="picker-box">{{dateVals.deworm}}</view>
</picker></view>
<button class="btn btn-primary btn-block" data-text="驱虫提醒:7月15日" data-icon="🛡️" bindtap="onSave">保存提醒</button>
<button class="btn btn-primary btn-block" bindtap="onSave">保存提醒</button>
</block>
<!-- 成长照片 -->
@@ -146,7 +146,7 @@ module.exports.sel = function (map, key, index, def) {
<view class="poster" style="margin-bottom:28rpx">
<view class="poster-avatar">{{pet.emoji}}</view>
<view class="bold" style="font-size:30rpx">{{pet.name}} 的成长照片</view>
<view class="muted" style="font-size:24rpx;margin-top:12rpx">模拟上传预览</view>
<view class="muted" style="font-size:24rpx;margin-top:12rpx">支持 jpg / png,单张不超过 10MB</view>
</view>
<button class="btn btn-primary btn-block" bindtap="onSavePhoto">选择图片上传</button>
</block>
@@ -184,24 +184,6 @@ module.exports.sel = function (map, key, index, def) {
</block>
<!-- 任务详情 -->
<!-- 问问 AI -->
<block wx:elif="{{innerType === 'ai'}}">
<view class="sheet-h3">问问 AI</view>
<scroll-view class="plan-chat" scroll-y="true" enhanced="{{true}}" show-scrollbar="{{false}}" style="max-height:660rpx" scroll-into-view="ai-bottom">
<view wx:for="{{aiMessages}}" wx:key="index" class="msg {{item.role}}">{{item.text}}</view>
<view id="ai-bottom"></view>
</scroll-view>
<view class="mini-options">
<view class="option" data-text="猫咪今天吐了一次怎么办?" bindtap="sendAI">猫咪吐了一次</view>
<view class="option" data-text="幼猫多久驱虫一次?" bindtap="sendAI">多久驱虫</view>
<view class="option" data-text="换粮后软便怎么办?" bindtap="sendAI">换粮软便</view>
</view>
<view class="ai-input-row">
<input class="input" placeholder="描述一下问题..." placeholder-class="placeholder"
value="{{aiInput}}" bindinput="onAiInput" confirm-type="send" bindconfirm="sendAI"/>
<button class="btn btn-primary ai-send" bindtap="sendAI">发</button>
</view>
</block>
<!-- 成长海报 -->
<block wx:elif="{{innerType === 'poster'}}">
+22 -8
View File
@@ -1,19 +1,33 @@
:host{display:block}
/* 原来是一整条半透明色块,底边和页面渐变硬碰硬,屏幕上横着一道接缝。
改成上实下虚的渐变,并且用同一条 mask 把毛玻璃也一起淡出——
只淡化背景色而不淡化 blur 的话,接缝会从「色差」变成「糊边」,一样难看。 */
.nav-wrap{
position:fixed;top:0;left:0;right:0;z-index:100;
background:rgba(248,246,242,.82);
backdrop-filter:blur(28rpx);
-webkit-backdrop-filter:blur(28rpx);
background:linear-gradient(180deg,
rgba(250,247,242,.94) 0%,
rgba(250,247,242,.88) 58%,
rgba(250,247,242,0) 100%);
backdrop-filter:blur(20rpx);
-webkit-backdrop-filter:blur(20rpx);
-webkit-mask-image:linear-gradient(180deg,#000 0%,#000 62%,transparent 100%);
mask-image:linear-gradient(180deg,#000 0%,#000 62%,transparent 100%);
}
.nav-inner{
position:relative;display:flex;align-items:center;justify-content:center;
}
.nav-title{
font-size:var(--fs-lg);font-weight:var(--fw-b);color:var(--text);
max-width:60%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text);
letter-spacing:.5rpx;
max-width:56%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
/* 返回键做成有边界的圆形,和右上角胶囊在视觉上对称 */
.nav-back{
position:absolute;left:20rpx;top:50%;transform:translateY(-50%);
width:64rpx;height:64rpx;display:flex;align-items:center;justify-content:center;
color:var(--text);
position:absolute;left:var(--sp-3);top:50%;transform:translateY(-50%);
width:60rpx;height:60rpx;border-radius:50%;
display:flex;align-items:center;justify-content:center;
color:var(--text);background:rgba(255,255,255,.72);
box-shadow:0 4rpx 12rpx rgba(70,48,25,.06);
}
.nav-back:active{background:rgba(255,255,255,.95)}