diff --git a/pets-fe/app.json b/pets-fe/app.json index 7f5a8cc..15629f2 100644 --- a/pets-fe/app.json +++ b/pets-fe/app.json @@ -8,7 +8,8 @@ "pages/community/community", "pages/learn/learn", "pages/article/article", - "pages/profile/profile" + "pages/profile/profile", + "pages/ai/ai" ], "window": { "navigationStyle": "custom", @@ -21,11 +22,26 @@ "selectedColor": "#DD8420", "backgroundColor": "#FFFFFF", "list": [ - { "pagePath": "pages/home/home", "text": "首页" }, - { "pagePath": "pages/plan/plan", "text": "计划" }, - { "pagePath": "pages/record/record", "text": "记录" }, - { "pagePath": "pages/report/report", "text": "报告" }, - { "pagePath": "pages/community/community", "text": "社区" } + { + "pagePath": "pages/home/home", + "text": "首页" + }, + { + "pagePath": "pages/plan/plan", + "text": "计划" + }, + { + "pagePath": "pages/record/record", + "text": "记录" + }, + { + "pagePath": "pages/report/report", + "text": "报告" + }, + { + "pagePath": "pages/community/community", + "text": "社区" + } ] }, "sitemapLocation": "sitemap.json", diff --git a/pets-fe/components/bottom-sheet/bottom-sheet.js b/pets-fe/components/bottom-sheet/bottom-sheet.js index 6484aa3..1be5ea5 100644 --- a/pets-fe/components/bottom-sheet/bottom-sheet.js +++ b/pets-fe/components/bottom-sheet/bottom-sheet.js @@ -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: '网络异常,请稍后再试。' }]), - }); - }); - }, }, }); diff --git a/pets-fe/components/bottom-sheet/bottom-sheet.wxml b/pets-fe/components/bottom-sheet/bottom-sheet.wxml index c1f5ee7..49d23cb 100644 --- a/pets-fe/components/bottom-sheet/bottom-sheet.wxml +++ b/pets-fe/components/bottom-sheet/bottom-sheet.wxml @@ -33,7 +33,7 @@ module.exports.sel = function (map, key, index, def) { - + @@ -46,7 +46,7 @@ module.exports.sel = function (map, key, index, def) { 偏少 不吃 - + @@ -80,7 +80,7 @@ module.exports.sel = function (map, key, index, def) { 建议:{{riskData ? riskData.suggestion : '继续观察精神、食欲和排便;暂停新食物;记录呕吐或腹泻次数。'}} 建议就医:{{riskData ? riskData.seek_care : '如果持续超过 24 小时,或伴随便血、精神明显变差、频繁呕吐,建议尽快就医。'}} - + @@ -93,7 +93,7 @@ module.exports.sel = function (map, key, index, def) { 当前体重:{{pet.weight}} 近期重点:疫苗期、换粮观察、排便记录 建议携带:排便/呕吐照片、饮食变化、疫苗驱虫记录。 - + @@ -101,7 +101,7 @@ module.exports.sel = function (map, key, index, def) { 记录用药 - + @@ -114,7 +114,7 @@ module.exports.sel = function (map, key, index, def) { 用品 - + @@ -125,7 +125,7 @@ module.exports.sel = function (map, key, index, def) { {{dateVals.vaccine}} - + @@ -136,7 +136,7 @@ module.exports.sel = function (map, key, index, def) { {{dateVals.deworm}} - + @@ -146,7 +146,7 @@ module.exports.sel = function (map, key, index, def) { {{pet.emoji}} {{pet.name}} 的成长照片 - 模拟上传预览 + 支持 jpg / png,单张不超过 10MB @@ -184,24 +184,6 @@ module.exports.sel = function (map, key, index, def) { - - - 问问 AI - - {{item.text}} - - - - 猫咪吐了一次 - 多久驱虫 - 换粮软便 - - - - - - diff --git a/pets-fe/components/nav-bar/nav-bar.wxss b/pets-fe/components/nav-bar/nav-bar.wxss index 7ca13bc..b6fc5ec 100644 --- a/pets-fe/components/nav-bar/nav-bar.wxss +++ b/pets-fe/components/nav-bar/nav-bar.wxss @@ -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)} diff --git a/pets-fe/pages/ai/ai.js b/pets-fe/pages/ai/ai.js new file mode 100644 index 0000000..b2a28bd --- /dev/null +++ b/pets-fe/pages/ai/ai.js @@ -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' }; + }, +}); diff --git a/pets-fe/pages/ai/ai.json b/pets-fe/pages/ai/ai.json new file mode 100644 index 0000000..6594cd8 --- /dev/null +++ b/pets-fe/pages/ai/ai.json @@ -0,0 +1,6 @@ +{ + "usingComponents": { + "nav-bar": "/components/nav-bar/nav-bar", + "pt-icon": "/components/pt-icon/index" + } +} diff --git a/pets-fe/pages/ai/ai.wxml b/pets-fe/pages/ai/ai.wxml new file mode 100644 index 0000000..e444230 --- /dev/null +++ b/pets-fe/pages/ai/ai.wxml @@ -0,0 +1,28 @@ + + + + + + + + {{item.text}} + + + + + + + + + {{item}} + + + + + + + + diff --git a/pets-fe/pages/ai/ai.wxss b/pets-fe/pages/ai/ai.wxss new file mode 100644 index 0000000..c06e288 --- /dev/null +++ b/pets-fe/pages/ai/ai.wxss @@ -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)} diff --git a/pets-fe/pages/community/community.js b/pets-fe/pages/community/community.js index 65bc7b9..b2c994d 100644 --- a/pets-fe/pages/community/community.js +++ b/pets-fe/pages/community/community.js @@ -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) { diff --git a/pets-fe/pages/home/home.js b/pets-fe/pages/home/home.js index 22f96a7..488a97d 100644 --- a/pets-fe/pages/home/home.js +++ b/pets-fe/pages/home/home.js @@ -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' }); diff --git a/pets-fe/pages/home/home.wxml b/pets-fe/pages/home/home.wxml index 4f2ff22..6aa8c07 100644 --- a/pets-fe/pages/home/home.wxml +++ b/pets-fe/pages/home/home.wxml @@ -79,7 +79,7 @@ AI 今日建议 {{summary.advice || '正在生成今日建议…'}} - + 问问 AI 养宠助手 diff --git a/pets-fe/pages/learn/learn.js b/pets-fe/pages/learn/learn.js index fc92938..ddec272 100644 --- a/pets-fe/pages/learn/learn.js +++ b/pets-fe/pages/learn/learn.js @@ -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' }; diff --git a/pets-fe/pages/plan/plan.js b/pets-fe/pages/plan/plan.js index 111159b..528b853 100644 --- a/pets-fe/pages/plan/plan.js +++ b/pets-fe/pages/plan/plan.js @@ -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' }; diff --git a/pets-fe/pages/profile/profile.js b/pets-fe/pages/profile/profile.js index 167b9e0..7f6d451 100644 --- a/pets-fe/pages/profile/profile.js +++ b/pets-fe/pages/profile/profile.js @@ -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' }; diff --git a/pets-fe/pages/record/record.js b/pets-fe/pages/record/record.js index 6adc7a2..7a7beef 100644 --- a/pets-fe/pages/record/record.js +++ b/pets-fe/pages/record/record.js @@ -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' }; diff --git a/pets-fe/pages/report/report.js b/pets-fe/pages/report/report.js index c064f85..f8eeb97 100644 --- a/pets-fe/pages/report/report.js +++ b/pets-fe/pages/report/report.js @@ -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' };