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
+107
View File
@@ -0,0 +1,107 @@
const store = require('../../utils/store.js');
const api = require('../../utils/api.js');
const { toastErr } = require('../../utils/ui.js');
const SESSION = 'chat';
const PRESETS = ['猫咪今天吐了一次怎么办?', '幼猫多久驱虫一次?', '换粮后软便怎么办?', '疫苗打完要注意什么?'];
// 打字机:每帧吐几个字。太快没效果,太慢让人等,30ms/2 字接近正常朗读速度。
const TYPE_STEP = 2;
const TYPE_INTERVAL = 30;
Page({
data: {
pet: {},
messages: [],
input: '',
presets: PRESETS,
sending: false,
loaded: false,
},
onLoad() {
this._unsub = store.subscribe((pet) => this.setData({ pet }));
store
.ready()
.then(() => {
this.setData({ pet: store.getPet() });
this.loadHistory();
})
.catch((e) => toastErr(e));
},
onUnload() {
if (this._unsub) this._unsub();
this.stopTyping();
},
loadHistory() {
const opening = {
role: 'ai',
text: '我是' + (store.getPet().name || '毛孩子') + '的养宠助手。我会优先根据档案、计划和历史记录回答,而不是泛泛聊天。',
};
api
.aiMessages(SESSION, 50)
.then((list) => {
const history = (list || []).map((m) => ({ role: m.role === 'user' ? 'user' : 'ai', text: m.text }));
this.setData({ messages: [opening].concat(history), loaded: true }, () => this.scrollToBottom());
})
.catch(() => this.setData({ messages: [opening], loaded: true }));
},
onInput(e) {
this.setData({ input: e.detail.value });
},
onSend(e) {
const preset = e.currentTarget && e.currentTarget.dataset.text;
const text = (preset || this.data.input || '').trim();
if (!text || this.data.sending) return;
// 先把用户消息和一个空的 AI 气泡放上去,AI 气泡随后逐字填充
const messages = this.data.messages.concat([
{ role: 'user', text },
{ role: 'ai', text: '', pending: true },
]);
this.setData({ messages, input: '', sending: true }, () => this.scrollToBottom());
api
.aiChat({ pet_id: store.currentPetId() || null, session: SESSION, text })
.then((res) => this.typewrite(res.reply || '(没有返回内容)'))
.catch((e) => this.typewrite(e.message || '网络异常,请稍后再试。'));
},
// 逐字把回复填进最后一个气泡
typewrite(full) {
this.stopTyping();
const idx = this.data.messages.length - 1;
let n = 0;
this._timer = setInterval(() => {
n = Math.min(full.length, n + TYPE_STEP);
this.setData({ [`messages[${idx}].text`]: full.slice(0, n) });
if (n % 20 === 0) this.scrollToBottom();
if (n >= full.length) {
this.stopTyping();
this.setData({ [`messages[${idx}].pending`]: false, sending: false }, () => this.scrollToBottom());
}
}, TYPE_INTERVAL);
},
stopTyping() {
if (this._timer) {
clearInterval(this._timer);
this._timer = null;
}
},
// 打字过程中点一下可以跳过,不用干等
onSkip() {
if (!this.data.sending) return;
this.stopTyping();
this.setData({ sending: false });
},
scrollToBottom() {
this.setData({ scrollInto: 'chat-bottom' });
},
onShareAppMessage() {
return { title: '肉垫计划 · AI 养宠助手', path: '/pages/ai/ai' };
},
});