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:
@@ -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' };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"nav-bar": "/components/nav-bar/nav-bar",
|
||||
"pt-icon": "/components/pt-icon/index"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<nav-bar title="AI 养宠助手" show-back="{{true}}"></nav-bar>
|
||||
|
||||
<scroll-view class="chat-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}"
|
||||
scroll-into-view="{{scrollInto}}" scroll-with-animation="{{true}}" bindtap="onSkip">
|
||||
<view class="chat-body">
|
||||
<view wx:for="{{messages}}" wx:key="index" class="row row-{{item.role}}">
|
||||
<view wx:if="{{item.role === 'ai'}}" class="ai-avatar"><pt-icon name="ai" size="{{34}}"></pt-icon></view>
|
||||
<view class="bubble bubble-{{item.role}}">
|
||||
<text>{{item.text}}</text><text wx:if="{{item.pending}}" class="caret">▌</text>
|
||||
</view>
|
||||
</view>
|
||||
<view id="chat-bottom" class="chat-bottom"></view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="dock">
|
||||
<scroll-view wx:if="{{!sending}}" class="presets" scroll-x="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
|
||||
<view wx:for="{{presets}}" wx:key="*this" class="preset" data-text="{{item}}" bindtap="onSend">{{item}}</view>
|
||||
</scroll-view>
|
||||
<view class="input-row">
|
||||
<input class="input" placeholder="描述一下 {{pet.name || '毛孩子'}} 的情况…" placeholder-class="placeholder"
|
||||
value="{{input}}" bindinput="onInput" confirm-type="send" bindconfirm="onSend"
|
||||
adjust-position="{{true}}" cursor-spacing="20"/>
|
||||
<view class="send {{input ? 'on' : ''}}" bindtap="onSend">
|
||||
<pt-icon name="{{sending ? 'clock' : 'next'}}" size="{{34}}"></pt-icon>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,43 @@
|
||||
/* 整页聊天:上面滚动区自适应,下面输入区固定,中间不留空 */
|
||||
.chat-scroll{flex:1;min-height:0}
|
||||
.chat-body{padding:var(--sp-3) var(--pad-x) var(--sp-5)}
|
||||
.chat-bottom{height:1rpx}
|
||||
|
||||
.row{display:flex;align-items:flex-start;gap:var(--sp-2);margin-bottom:var(--sp-4)}
|
||||
.row-user{justify-content:flex-end}
|
||||
.ai-avatar{
|
||||
width:60rpx;height:60rpx;border-radius:var(--r-sm);flex:none;margin-top:4rpx;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
background:var(--cta);color:#fff;
|
||||
}
|
||||
.bubble{
|
||||
max-width:76%;border-radius:var(--r-md);padding:var(--sp-3) var(--sp-4);
|
||||
font-size:var(--fs-md);line-height:1.62;word-break:break-word;
|
||||
}
|
||||
.bubble-ai{background:#fff;box-shadow:var(--sd-1);border-top-left-radius:var(--r-xs)}
|
||||
.bubble-user{background:var(--primary-soft);color:var(--primary-ink);border-top-right-radius:var(--r-xs)}
|
||||
/* 打字光标 */
|
||||
.caret{color:var(--primary);animation:blink .9s steps(1) infinite}
|
||||
@keyframes blink{50%{opacity:0}}
|
||||
|
||||
/* 底部输入区 */
|
||||
.dock{
|
||||
flex:none;background:rgba(255,255,255,.94);
|
||||
backdrop-filter:blur(28rpx);-webkit-backdrop-filter:blur(28rpx);
|
||||
border-top:1rpx solid var(--line);
|
||||
padding:var(--sp-3) var(--pad-x) calc(var(--sp-3) + env(safe-area-inset-bottom));
|
||||
}
|
||||
.presets{white-space:nowrap;margin-bottom:var(--sp-3)}
|
||||
.preset{
|
||||
display:inline-block;height:var(--h-sm);line-height:var(--h-sm);padding:0 var(--sp-4);
|
||||
margin-right:var(--sp-2);border-radius:var(--r-full);border:1rpx solid var(--line);
|
||||
background:#fff;color:var(--text-2);font-size:var(--fs-sm);
|
||||
}
|
||||
.input-row{display:flex;align-items:center;gap:var(--sp-3)}
|
||||
.input-row .input{flex:1;background:var(--surface-2)}
|
||||
.send{
|
||||
width:88rpx;height:88rpx;border-radius:var(--r-md);flex:none;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
background:var(--line);color:#fff;transition:.16s ease;
|
||||
}
|
||||
.send.on{background:var(--cta)}
|
||||
@@ -102,7 +102,7 @@ Page({
|
||||
wx.navigateTo({ url: '/pages/learn/learn' });
|
||||
},
|
||||
onFab() {
|
||||
this.setData({ sheetType: 'ai', sheetPostId: '', sheetShow: true });
|
||||
wx.navigateTo({ url: '/pages/ai/ai' });
|
||||
},
|
||||
// 长按自己的帖子可删除
|
||||
onLongPressPost(e) {
|
||||
|
||||
@@ -189,7 +189,7 @@ Page({
|
||||
this.openSheetType('editPet');
|
||||
},
|
||||
onFab() {
|
||||
this.openSheetType('ai');
|
||||
wx.navigateTo({ url: '/pages/ai/ai' });
|
||||
},
|
||||
goProfile() {
|
||||
wx.navigateTo({ url: '/pages/profile/profile' });
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
<view class="sh-title">AI 今日建议</view>
|
||||
</view>
|
||||
<view class="ai-text">{{summary.advice || '正在生成今日建议…'}}</view>
|
||||
<view class="btn btn-dark btn-block ai-cta" data-type="ai" bindtap="openSheet">
|
||||
<view class="btn btn-dark btn-block ai-cta" bindtap="onFab">
|
||||
<pt-icon name="ai" size="{{32}}"></pt-icon>问问 AI 养宠助手
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -35,7 +35,7 @@ Page({
|
||||
this.setData({ sheetShow: false });
|
||||
},
|
||||
onFab() {
|
||||
this.setData({ sheetType: 'ai', sheetShow: true });
|
||||
wx.navigateTo({ url: '/pages/ai/ai' });
|
||||
},
|
||||
onShareAppMessage() {
|
||||
return { title: '新手养宠知识合集', path: '/pages/learn/learn' };
|
||||
|
||||
@@ -178,7 +178,7 @@ Page({
|
||||
this.setData({ sheetType: 'addPet', sheetShow: true });
|
||||
},
|
||||
onFab() {
|
||||
this.setData({ sheetType: 'ai', sheetShow: true });
|
||||
wx.navigateTo({ url: '/pages/ai/ai' });
|
||||
},
|
||||
onShareAppMessage() {
|
||||
return { title: '我给毛孩子做了份养护计划', path: '/pages/plan/plan' };
|
||||
|
||||
@@ -68,7 +68,7 @@ Page({
|
||||
this.loadSummary();
|
||||
},
|
||||
onFab() {
|
||||
this.setData({ sheetType: 'ai', sheetShow: true });
|
||||
wx.navigateTo({ url: '/pages/ai/ai' });
|
||||
},
|
||||
onShareAppMessage() {
|
||||
return { title: '肉垫计划 · 新手养宠助手', path: '/pages/home/home' };
|
||||
|
||||
@@ -245,7 +245,7 @@ Page({
|
||||
this.openSheetType('addPet');
|
||||
},
|
||||
onFab() {
|
||||
this.openSheetType('ai');
|
||||
wx.navigateTo({ url: '/pages/ai/ai' });
|
||||
},
|
||||
onShareAppMessage() {
|
||||
return { title: '用肉垫计划记录毛孩子的成长', path: '/pages/record/record' };
|
||||
|
||||
@@ -59,7 +59,7 @@ Page({
|
||||
this.setData({ sheetType: 'addPet', sheetShow: true });
|
||||
},
|
||||
onFab() {
|
||||
this.setData({ sheetType: 'ai', sheetShow: true });
|
||||
wx.navigateTo({ url: '/pages/ai/ai' });
|
||||
},
|
||||
onShareAppMessage() {
|
||||
return { title: '看看我家毛孩子的成长报告', path: '/pages/report/report' };
|
||||
|
||||
Reference in New Issue
Block a user