This commit is contained in:
Your Name
2026-06-03 11:39:36 +08:00
parent a3bae1555a
commit ec4e0b9f29
22 changed files with 1353 additions and 646 deletions
+211 -125
View File
@@ -67,11 +67,31 @@
<text class="gg-food-card-tip">{{ foodCard.tip }}</text>
</view>
<!-- 消除高糖红色警示弹窗短暂强调后进入结算 -->
<view v-if="highClearAlertVisible" class="gg-high-alert-overlay" @tap.stop>
<view class="gg-high-alert-card">
<view class="gg-high-alert-icon-wrap" aria-hidden="true">
<TongjiIcon name="alert-triangle" size="lg" color="#ffffff" />
</view>
<view class="gg-high-alert-badge-row">
<text class="gg-high-alert-badge">高糖</text>
<text class="gg-high-alert-badge-sub">红色标识 · 升糖快</text>
</view>
<text class="gg-high-alert-title">已消除高糖食物</text>
<text class="gg-high-alert-name">{{ clearedHighFoodName }}</text>
<text class="gg-high-alert-tip">请记住这类食物要少吃</text>
</view>
</view>
<!-- 学习结果 -->
<view v-if="gamePhase !== 'playing'" class="gg-result-overlay" @tap.stop>
<view class="gg-result-card">
<text class="gg-result-title">{{ gamePhase === 'won' ? '学会啦!' : '再认一认' }}</text>
<view v-if="gamePhase === 'won'" class="gg-result-stars">
<view class="gg-result-card" :class="{ 'is-high-clear': winReason === 'high_cleared' }">
<view v-if="winReason === 'high_cleared'" class="gg-result-high-head">
<text class="gg-result-high-badge">高糖</text>
<text class="gg-result-high-tag">已消除</text>
</view>
<text class="gg-result-title">{{ resultTitle }}</text>
<view v-if="gamePhase === 'won' && winReason !== 'high_cleared'" class="gg-result-stars">
<text
v-for="i in 3"
:key="'star-' + i"
@@ -80,16 +100,20 @@
></text>
</view>
<text class="gg-result-sub">{{ resultMessage }}</text>
<view v-if="resultStats.length" class="gg-result-stats">
<view v-if="resultStats.length" class="gg-result-stats" :class="{ 'is-high-clear': winReason === 'high_cleared' }">
<view v-for="(row, idx) in resultStats" :key="'rs-' + idx" class="gg-result-stat">
<text class="gg-result-stat-label">{{ row.label }}</text>
<text class="gg-result-stat-val">{{ row.value }}</text>
<text class="gg-result-stat-val" :class="{ 'is-high-val': winReason === 'high_cleared' && idx === 0 }">
<text v-if="winReason === 'high_cleared' && idx === 0" class="gg-result-inline-badge">高糖</text>
{{ row.value }}
</text>
</view>
</view>
<view class="gg-result-actions">
<view
v-if="gamePhase === 'won'"
class="gg-result-btn is-primary"
:class="{ 'is-high-primary': winReason === 'high_cleared' }"
@tap="goNextLevel"
>认下一组</view>
<view class="gg-result-btn" :class="{ 'is-primary': gamePhase !== 'won' }" @tap="restartLevel">
@@ -128,104 +152,49 @@
</view>
</view>
</view>
<!-- 核心数据面板三栏网格布局 -->
<view class="gg-hud-dashboard">
<!-- 左侧血糖监控指示器 -->
<view class="gg-dash-glucose">
<view class="gg-glucose-ring" :class="meterFillClass" :style="glucoseRingStyle">
<view class="gg-glucose-ring-inner">
<TongjiIcon name="glucose" size="sm" :color="glucoseAccentColor" />
<text class="gg-glucose-value">{{ glucoseLevel }}</text>
</view>
</view>
<view class="gg-glucose-detail">
<view class="gg-glucose-detail-head">
<text class="gg-glucose-title">血糖</text>
<text class="gg-glucose-status" :class="statusLabelClass">{{ glucoseShortStatus }}</text>
</view>
<view class="gg-meter-bar">
<view class="gg-meter-track">
<view class="gg-meter-zone is-low" />
<view class="gg-meter-zone is-ok" />
<view class="gg-meter-zone is-high" />
</view>
<view class="gg-meter-fill" :class="meterFillClass" :style="{ width: glucoseLevel + '%' }" />
<view class="gg-meter-pointer" :class="meterFillClass" :style="{ left: glucoseLevel + '%' }" />
</view>
<view class="gg-meter-legend">
<text class="is-low-text">偏低</text>
<text class="is-ok-text">稳定</text>
<text class="is-high-text">偏高</text>
</view>
</view>
</view>
<!-- 垂直分割线 1 -->
<view class="gg-dash-divider" />
<!-- 中间步数看板仅展示无失败惩罚 -->
<view class="gg-dash-moves">
<text class="gg-moves-label">步数</text>
<text class="gg-moves-val">{{ movesLeft }}</text>
</view>
<!-- 垂直分割线 2 -->
<view class="gg-dash-divider" />
<!-- 右侧关卡任务目标 -->
<view class="gg-dash-mission">
<view class="gg-mission-head">
<view class="gg-mission-title-wrap">
<text class="gg-mission-title">认识这些食物</text>
<text class="gg-mission-pct">{{ goalProgressPct }}%</text>
</view>
<view v-if="dailyStreak >= 2" class="gg-mission-streak">
<TongjiIcon name="flame" size="sm" color="#059669" />
<text>{{ dailyStreak }} </text>
</view>
</view>
<view class="gg-mission-goals">
<view
v-for="g in goalList"
:key="g.key"
class="gg-goal-chip"
:class="{ 'is-done': g.done }"
>
<view class="gg-goal-icon-wrap">
<FoodTileIcon :food="g.food" size="goal" />
</view>
<view class="gg-goal-chip-body">
<view class="gg-goal-chip-bar">
<view
class="gg-goal-chip-fill"
:style="{ width: Math.min(100, Math.round((g.current / g.target) * 100)) + '%' }"
/>
</view>
<text class="gg-goal-chip-text">{{ g.current }}/{{ g.target }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 底部棋盘说明认知导向无倒计时 -->
<view v-if="gamePhase === 'playing'" class="gg-board-hint-bar">
<view class="gg-board-hint-left">
<view class="gg-hint-tag">认糖</view>
<text class="gg-board-hint-text">点一下食物看它含糖高不高</text>
</view>
</view>
</view>
<!-- 棋盘 -->
<!-- 主区棋盘为主辅助信息弱化 -->
<view class="gg-main" :style="mainStyle">
<view class="gg-board-stack" :style="boardStackStyle">
<!-- 仅展示上次消除的食物与含糖量 -->
<view v-if="gamePhase === 'playing'" class="gg-hud-last-food">
<text class="gg-hud-last-food-kicker">上次消除</text>
<view
v-if="lastClearedFood"
class="gg-hud-last-food-card"
:class="'is-' + lastClearedFood.gi"
>
<view class="gg-hud-last-food-icon">
<FoodTileIcon :food="lastClearedFood.food" size="goal" />
</view>
<view class="gg-hud-last-food-body">
<text class="gg-hud-last-food-name">{{ lastClearedFood.name }}</text>
<view class="gg-hud-last-food-sugar-row">
<text class="gg-hud-last-food-sugar-label">含糖量</text>
<text
class="gg-hud-last-food-gi"
:class="'is-' + lastClearedFood.gi"
:style="{ backgroundColor: lastClearedFood.color }"
>{{ lastClearedFood.giShort }}</text>
<text class="gg-hud-last-food-sugar-hint">{{ lastClearedFood.eatHint }}</text>
</view>
</view>
</view>
<view v-else class="gg-hud-last-food-placeholder">
<text>三连消除后这里显示食物名称与含糖量</text>
</view>
</view>
<!-- 棋盘 -->
<view class="gg-board-hero">
<view v-if="gamePhase === 'playing'" class="gg-board-hint-inline">
<text class="gg-hint-tag">认糖</text>
<text class="gg-board-hint-text">点食物看含糖 · 消掉红色即过关</text>
</view>
<view class="gg-board-stack" :style="boardStackStyle">
<view class="gg-board-zone">
<view v-if="hintTipVisible" class="gg-no-move-tip">
<TongjiIcon name="alert-circle" size="sm" color="#ea580c" />
<TongjiIcon name="alert-triangle" size="sm" color="#ea580c" />
<text class="gg-no-move-tip-text">{{ hintTipText }}</text>
</view>
<view class="gg-board-wrap" :style="boardWrapStyle">
@@ -284,11 +253,12 @@
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 底部道具 -->
<view class="gg-footer gg-glass" :style="footerStyle">
<!-- 道具栏弱化 -->
<view class="gg-footer gg-glass gg-footer--sub" :style="footerStyle">
<view class="gg-boosters">
<view
v-for="booster in boosters"
@@ -314,7 +284,15 @@ import { computed, getCurrentInstance, nextTick, onMounted, onUnmounted, ref } f
import { onLoad, onReady } from '@dcloudio/uni-app'
import TongjiIcon from '../components/TongjiIcon.vue'
import FoodTileIcon from '../components/FoodTileIcon.vue'
import { FOOD_LIBRARY, cloneFoodType, getClearVal, getFoodTip, GI_SHORT, GI_COLOR } from '../data/gameFoodLibrary.js'
import {
FOOD_LIBRARY,
cloneFoodType,
getClearVal,
getFoodTip,
formatEatLabel,
GI_SHORT,
GI_COLOR
} from '../data/gameFoodLibrary.js'
import { warmUpFoodImgs } from '../data/gameFoodImg.js'
import { useGameSfx } from '../composables/useGameSfx.js'
import { useGameFx } from '../composables/useGameFx.js'
@@ -341,11 +319,11 @@ const sessionMaxCombo = ref(0)
const menuHeight = ref(32)
const menuTop = ref(0)
const ROWS = 6
const ROWS = 5
const COLS = 4
const ROW_AXES = Array.from({ length: ROWS }, (_, i) => i)
const COL_AXES = Array.from({ length: COLS }, (_, i) => i)
/** 6 行 × 4 列棋盘:单格像素由可用区域反算 */
/** 5 行 × 4 列棋盘:单格像素由可用区域反算 */
const BOARD_INSET_RPX = 4
/** 棋盘与底部道具栏之间的安全间隙,避免最后一行被遮挡 */
const BOARD_FOOTER_GAP_RPX = 28
@@ -366,9 +344,11 @@ const dragPreview = ref(null)
let tileTouchStart = null
let touchHandledInteraction = false
// 每局选中的食材子集(保证消除可玩,并让每次进入素材不同
const SESSION_FOODS = { low: 3, mid: 2, high: 2 }
// 每局选中的食材子集;高糖仅 1 种(消除该种即通关
const SESSION_FOODS = { low: 3, mid: 2, high: 1 }
const activeFoods = ref([])
/** 本局唯一的高糖食物类型 */
const sessionHighFood = ref(null)
const goalDefs = ref([])
function pickRandom(pool, n) {
@@ -382,17 +362,20 @@ function pickRandom(pool, n) {
/**
* 随机挑选本局食材与收集目标。每次调用结果不同:
* 低/中/高 各取若干,组成 7 种棋子;再从中挑 2 种作为收集目标。
* 低/中各若干 + 仅 1 种高糖;再从中挑 2 种作为收集目标。
*/
function selectSessionFoods() {
const lows = FOOD_LIBRARY.filter((f) => f.gi === 'low')
const mids = FOOD_LIBRARY.filter((f) => f.gi === 'mid')
const highs = FOOD_LIBRARY.filter((f) => f.gi === 'high')
const highPick = pickRandom(highs, 1)
sessionHighFood.value = highPick[0] ? cloneFoodType(highPick[0]) : null
const selected = [
...pickRandom(lows, SESSION_FOODS.low),
...pickRandom(mids, SESSION_FOODS.mid),
...pickRandom(highs, SESSION_FOODS.high)
...highPick
]
activeFoods.value = selected
warmUpFoodImgs(selected)
@@ -507,8 +490,21 @@ const selectedUid = ref(null)
const matchChain = ref(0)
const movesLeft = ref(25)
const gamePhase = ref('playing')
/** goals | high_cleared */
const winReason = ref('')
const loseReason = ref('')
/** 消除高糖时的红色警示弹窗 */
const highClearAlertVisible = ref(false)
const clearedHighFoodName = ref('')
const resultTitle = computed(() => {
if (gamePhase.value !== 'won') return '再认一认'
if (winReason.value === 'high_cleared') return '高糖已消除!'
return '学会啦!'
})
const resultMessage = ref('')
/** 上一次三连消除的食物(摘要条展示) */
const lastClearedFood = ref(null)
const foodCard = ref(null)
let foodCardTimer = null
const floatingVals = ref([])
@@ -666,14 +662,15 @@ const glucoseShortStatus = computed(() => {
const glucoseAccentColor = computed(() => {
if (glucoseLevel.value < 30) return '#3b82f6'
if (glucoseLevel.value > 70) return '#dc2626'
return '#006c49'
return '#ea580c'
})
const glucoseRingStyle = computed(() => {
const pct = glucoseLevel.value
const color = glucoseAccentColor.value
const track = glucoseLevel.value < 30 || glucoseLevel.value > 70 ? '#e2ece6' : '#ffedd5'
return {
background: `conic-gradient(${color} 0 ${pct}%, #e2ece6 ${pct}% 100%)`
background: `conic-gradient(${color} 0 ${pct}%, ${track} ${pct}% 100%)`
}
})
@@ -723,6 +720,17 @@ function randomFood() {
return cloneFoodType(pick)
}
/** 本局棋盘上是否还存在高糖食物 */
function boardHasHighGiFood() {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const tile = board.value[r]?.[c]
if (tile && !tile.isRemoved && tile.type.gi === 'high') return true
}
}
return false
}
function wouldCreateInitialMatch(r, c, type, currentBoard, currentRow) {
const key = type.key
if (r >= 2 && currentBoard[r - 1][c]?.type.key === key && currentBoard[r - 2][c]?.type.key === key) {
@@ -833,7 +841,11 @@ function setupLevel(levelId) {
const cfg = getLevelConfig(levelId)
applyLevelConfig(cfg)
sessionMaxCombo.value = 0
winReason.value = ''
loseReason.value = ''
highClearAlertVisible.value = false
clearedHighFoodName.value = ''
lastClearedFood.value = null
tileTouchStart = null
clearDragPreview()
selectSessionFoods()
@@ -869,6 +881,10 @@ function recomputeGlucoseFromBoard() {
function restartLevel() {
gamePhase.value = 'playing'
winReason.value = ''
highClearAlertVisible.value = false
clearedHighFoodName.value = ''
lastClearedFood.value = null
resultMessage.value = ''
resultStars.value = 0
resultStats.value = []
@@ -884,6 +900,9 @@ function restartLevel() {
function goNextLevel() {
gamePhase.value = 'playing'
winReason.value = ''
highClearAlertVisible.value = false
clearedHighFoodName.value = ''
resultMessage.value = ''
resultStars.value = 0
resultStats.value = []
@@ -1407,6 +1426,24 @@ function updateGoal(key) {
}
}
/** 记录本批消除中代表性食物(取最大匹配组) */
function recordLastClearedFood(groups) {
if (!Array.isArray(groups) || !groups.length) return
const main = groups.reduce((a, b) => ((b.size || 0) > (a.size || 0) ? b : a))
const rep = main?.tiles?.[Math.floor((main.tiles.length || 1) / 2)]
const t = rep?.type
if (!t) return
const gi = t.gi || 'low'
lastClearedFood.value = {
name: t.name || '食物',
gi,
giShort: GI_SHORT[gi] || '低糖',
eatHint: formatEatLabel(t),
color: GI_COLOR[gi] || '#64748b',
food: cloneFoodType(t)
}
}
function updateScore(pts) {
const bonus = Math.round(pts)
score.value += bonus
@@ -1448,10 +1485,28 @@ function showLearnFx(groups) {
function checkGameEnd() {
if (gamePhase.value !== 'playing') return
// 无失败惩罚:仅在认全目标食物通关,步数耗尽不判负
// 无失败惩罚:认全目标或已消除场上全部高糖食物通关
if (allGoalsDone.value) {
endGame('won')
endGame('won', { reason: 'goals' })
return
}
if (sessionHighFood.value && !boardHasHighGiFood()) {
endGame('won', {
reason: 'high_cleared',
foodName: sessionHighFood.value.name
})
}
}
/** 三连消除含高糖:先红色警示弹窗,再进入结算 */
async function finishHighSugarClear(foodType) {
const name = foodType?.name || sessionHighFood.value?.name || '高糖食物'
clearedHighFoodName.value = name
highClearAlertVisible.value = true
triggerFlash('rgba(220, 38, 38, 0.22)', 0.35)
await delay(2000)
highClearAlertVisible.value = false
endGame('won', { reason: 'high_cleared', foodName: name })
}
function buildResultStats() {
@@ -1463,11 +1518,12 @@ function buildResultStats() {
]
}
function endGame(phase) {
function endGame(phase, opts = {}) {
gamePhase.value = phase
clearHints()
if (phase === 'won') {
winReason.value = opts.reason || 'goals'
const { stars, newBest } = recordWin({
levelId: levelConfig.value.id,
movesLeft: movesLeft.value,
@@ -1477,14 +1533,25 @@ function endGame(phase) {
maxCombo: sessionMaxCombo.value
})
resultStars.value = stars
const learnedNames = goalList.value.map((g) => g.food?.name).filter(Boolean).join('、')
resultMessage.value = learnedNames
? `这一组您认识了:${learnedNames}。记住它们的含糖高低啦!`
: '又认识了几种食物的含糖高低,真棒!'
if (newBest && gameProgress.value.maxUnlocked > levelConfig.value.id) {
resultMessage.value += ` 已解锁第 ${levelConfig.value.id + 1} 组。`
if (winReason.value === 'high_cleared') {
const name = opts.foodName || sessionHighFood.value?.name || '高糖食物'
resultMessage.value =
`您已消除高糖食物「${name}」!这类食物升糖快,日常要少吃。`
resultStats.value = [
{ label: '本局高糖食物', value: name },
{ label: '消除练习', value: '达标' },
{ label: '得分', value: scoreText.value }
]
} else {
const learnedNames = goalList.value.map((g) => g.food?.name).filter(Boolean).join('、')
resultMessage.value = learnedNames
? `这一组您认识了:${learnedNames}。记住它们的含糖高低啦!`
: '又认识了几种食物的含糖高低,真棒!'
if (newBest && gameProgress.value.maxUnlocked > levelConfig.value.id) {
resultMessage.value += ` 已解锁第 ${levelConfig.value.id + 1} 组。`
}
resultStats.value = buildResultStats()
}
resultStats.value = buildResultStats()
playSfxLayer('win', 'winStinger', 120, 0.7)
} else {
recordLoss()
@@ -1545,7 +1612,11 @@ async function processSwap(t1, t2, opts = {}) {
const matches = findMatches()
if (matches.length > 0) {
movesLeft.value = Math.max(0, movesLeft.value - 1)
await clearAndRefill()
const endedByHigh = await clearAndRefill()
if (endedByHigh) {
isProcessing.value = false
return
}
// 所有连消结束后,检查棋盘是否死锁并自动重排
await reshuffleIfStuck()
} else {
@@ -1561,7 +1632,10 @@ async function processSwap(t1, t2, opts = {}) {
async function clearAndRefill() {
const { groups, tiles } = findMatchGroups()
if (!tiles.length) return
if (!tiles.length) return false
const clearedHighTiles = tiles.filter((t) => t.type.gi === 'high')
recordLastClearedFood(groups)
let totalVal = 0
let maxSize = 3
@@ -1600,14 +1674,22 @@ async function clearAndRefill() {
await delay(MATCH_MS)
removeTilesFromBoard(tiles)
if (clearedHighTiles.length > 0) {
await finishHighSugarClear(clearedHighTiles[0].type)
return true
}
await applyGravity()
const next = findMatchGroups()
if (next.tiles.length > 0) {
matchChain.value += 1
await delay(200)
await clearAndRefill()
const ended = await clearAndRefill()
if (ended) return true
}
return false
}
async function applyGravity() {
@@ -1771,7 +1853,11 @@ async function onBooster(booster) {
const matches = findMatches()
if (matches.length > 0) {
await clearAndRefill()
const endedByHigh = await clearAndRefill()
if (endedByHigh) {
isProcessing.value = false
return
}
}
// fiber 改变了棋盘,保险检查是否死锁
await reshuffleIfStuck()
@@ -1937,7 +2023,7 @@ function initLayout() {
paddingTop: `${paddingTop}px`,
paddingLeft: '32rpx',
paddingRight: '32rpx',
paddingBottom: '16rpx'
paddingBottom: '10rpx'
}
navStyle.value = {
paddingRight: `${Math.max(0, headerPaddingRight - uni.upx2px(32))}px`
@@ -1971,7 +2057,7 @@ function recalcBoardSize() {
// 预留底部安全间隙,棋盘不贴到道具栏
const zoneH = Math.max(0, footerTop - headerBottom - footerGap)
// 6 行 × 4 列:宽高分别约束,取较小单格像素
// 5 行 × 4 列:宽高分别约束,取较小单格像素
cellSizePx.value = calcMaxCellSize(zoneW - inset * 2, zoneH - inset * 2)
const boardDim = calcBoardDimensions(cellSizePx.value)
boardWidthPx.value = boardDim.width
+30 -10
View File
@@ -419,7 +419,7 @@
<view v-else class="ai-diet-body" :class="{ 'is-streaming': dietAiStreaming }">
<view class="ai-diet-section-head">
<text class="ai-diet-section-title">今日三餐</text>
<text class="ai-diet-section-title">今日饮食</text>
<text v-if="dietAiStreaming" class="ai-diet-section-tag">生成中</text>
</view>
<view class="ai-diet-meal-list">
@@ -434,6 +434,20 @@
{{ dietAiRecommend.breakfast || (dietAiLoading ? '…' : '—') }}<text v-if="dietAiLoading && dietAiStreamField === 'breakfast'" class="ai-stream-cursor"></text>
</text>
</view>
<view
v-if="dietAiRecommend.drinks || (dietAiLoading && dietAiStreamField === 'drinks')"
class="ai-diet-meal-card meal-drinks"
>
<view class="ai-diet-meal-head">
<view class="ai-diet-meal-icon" aria-hidden="true">
<TongjiIcon name="droplet" size="sm" color="#ffffff" />
</view>
<text class="ai-diet-meal-label">喝的</text>
</view>
<text class="ai-diet-meal-text">
{{ dietAiRecommend.drinks || '…' }}<text v-if="dietAiLoading && dietAiStreamField === 'drinks'" class="ai-stream-cursor"></text>
</text>
</view>
<view class="ai-diet-meal-card meal-lunch">
<view class="ai-diet-meal-head">
<view class="ai-diet-meal-icon" aria-hidden="true">
@@ -2657,16 +2671,13 @@ async function bootstrap(options) {
if (!target) target = list[0]
await applyCard(target)
} else {
if (passedDiagnosisId) diagnosisId.value = passedDiagnosisId
if (passedPatientId) patientId.value = passedPatientId
if (passedPatientName) patientName.value = passedPatientName
if (passedAge) age.value = passedAge
if (passedGender) gender.value = passedGender
if (!diagnosisId.value || !patientId.value) {
readUserContext()
}
diagnosisId.value = 0
patientId.value = 0
patientName.value = ''
age.value = 0
gender.value = 0
readUserMobile()
if (!diagnosisId.value && userMobile.value) {
if (userMobile.value) {
await ensureDailyContextByPhone()
}
}
@@ -6558,6 +6569,12 @@ async function onFamilyLike() {
.ai-diet-meal-card.meal-breakfast::before {
background: linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%);
}
.ai-diet-meal-card.meal-drinks {
background: linear-gradient(120deg, rgba(14, 165, 233, 0.14) 0%, rgba(255, 255, 255, 0) 45%);
}
.ai-diet-meal-card.meal-drinks::before {
background: linear-gradient(180deg, #38bdf8 0%, #0ea5e9 100%);
}
.ai-diet-meal-card.meal-lunch {
background: linear-gradient(120deg, rgba(16, 185, 129, 0.16) 0%, rgba(255, 255, 255, 0) 45%);
}
@@ -6588,6 +6605,9 @@ async function onFamilyLike() {
.meal-breakfast .ai-diet-meal-icon {
background: linear-gradient(150deg, #fde68a 0%, #fbbf24 100%);
}
.meal-drinks .ai-diet-meal-icon {
background: linear-gradient(150deg, #7dd3fc 0%, #0ea5e9 100%);
}
.meal-lunch .ai-diet-meal-icon {
background: linear-gradient(150deg, #6ee7b7 0%, #10b981 100%);
}
+8 -9
View File
@@ -520,7 +520,7 @@
<scroll-view v-else scroll-y class="input-modal-body">
<view v-if="dietAiPrefill && dietAiPrefill.breakfast" class="input-modal-ai-tip">
<text class="input-modal-ai-tip-label">AI 推荐</text>
<text class="input-modal-ai-tip-text"> {{ dietAiPrefill.breakfast }} · {{ dietAiPrefill.lunch }} · {{ dietAiPrefill.dinner }}</text>
<text class="input-modal-ai-tip-text"> {{ dietAiPrefill.breakfast }}<text v-if="dietAiPrefill.drinks"> · {{ dietAiPrefill.drinks }}</text> · {{ dietAiPrefill.lunch }} · {{ dietAiPrefill.dinner }}</text>
<view class="input-modal-ai-tip-btn" @click="fillDietFormFromAi">
<text>一键填入</text>
</view>
@@ -1291,7 +1291,7 @@ async function ensureDailyContextByPhone() {
age: res.data.age || 0,
gender: res.data.gender || 0
})
if (res.data.created) {
if (res.data.created || res.data.linked_existing) {
await fetchCardList()
}
return true
@@ -2795,14 +2795,13 @@ async function bootstrap(options) {
if (!target) target = list[0]
await applyCard(target)
} else {
diagnosisId.value = passedDiagnosisId || 0
patientId.value = passedPatientId || 0
if (passedPatientName) patientName.value = passedPatientName
if (passedAge) age.value = passedAge
if (passedGender) gender.value = passedGender
if (!patientName.value) readUserContext()
diagnosisId.value = 0
patientId.value = 0
patientName.value = ''
age.value = 0
gender.value = 0
if (!diagnosisId.value && readUserMobile()) {
if (readUserMobile()) {
await ensureDailyContextByPhone()
}
if (!diagnosisId.value) {
+141 -43
View File
@@ -48,13 +48,15 @@
<view class="st-main">
<!-- 1. 录入今日血糖 -->
<!-- 快捷建档失败提示toast 字数有限完整文案展示在页面 -->
<view v-if="dailyContextError && !diagnosisId" class="st-daily-context-error" role="alert">
<text class="st-daily-context-error-text">{{ dailyContextError }}</text>
</view>
<!-- 2. 血糖趋势无就诊卡或录入不足 7 天显示示例曲线 -->
<!-- 2. 血糖趋势无就诊卡或累计录入不足 7 天显示示例曲线 -->
<view class="st-chart-card">
<view class="st-chart-head">
<view>
<view style="padding: 0 20rpx;">
<text class="st-chart-title">血糖趋势</text>
<text class="st-chart-sub">{{ rangeText }} · mmol/L</text>
@@ -94,9 +96,9 @@
</view>
</view>
<view v-if="chartUseMockData" class="st-chart-foot">
<text class="st-chart-mock-hint">示例数据连续记录满 7 天后显示您的真实趋势</text>
<text class="st-chart-mock-hint">示例数据累计记录满 7 天后显示您的真实趋势</text>
</view>
<view class="st-game-entry st-game-entry--in-card" @click="goGamePage">
<view class="st-game-entry st-game-entry--in-card" @click="goGamePage" style="margin: 0 20rpx 0 20rpx;">
<view class="st-game-entry-icon">
<TongjiIcon name="activity" size="sm" color="#006c49" />
</view>
@@ -107,9 +109,9 @@
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
</view>
</view>
<view class="st-training-module">
<dev-training-entry inline />
</view>
<view class="st-training-section">
<dev-training-entry inline tone="weekly" />
</view>
<!-- 3. AI 饮食建议 -->
<view v-if="diagnosisId" class="st-ai-section">
<view class="st-ai-deco" aria-hidden="true" />
@@ -129,7 +131,7 @@
<text>{{ dietAiLoading ? '生成中' : '换一换' }}</text>
</view>
</view>
<text class="st-ai-sub">读您近 7 · 30 天血糖定制今日三餐</text>
<text class="st-ai-sub">读您近 7 · 30 天血糖定制早餐含驼奶粉喝的与三餐</text>
<view v-if="dietAiLoading && !hasDietAiMealContent && !dietAiStreaming && !dietAiReplacing" class="st-skeleton">
<view v-for="n in 2" :key="'sk-' + n" class="st-skeleton-line" />
@@ -145,6 +147,18 @@
{{ dietAiRecommend.breakfast || (dietAiLoading ? '…' : '—') }}<text v-if="dietAiLoading && dietAiStreamField === 'breakfast'" class="ai-stream-cursor"></text>
</text>
</view>
<view
v-if="dietAiRecommend.drinks || (dietAiLoading && dietAiStreamField === 'drinks')"
class="st-meal-glass st-meal-glass--drinks"
>
<view class="st-meal-head">
<TongjiIcon name="droplet" size="sm" color="#0ea5e9" />
<text class="st-meal-label">喝的</text>
</view>
<text class="st-meal-text">
{{ dietAiRecommend.drinks || '…' }}<text v-if="dietAiLoading && dietAiStreamField === 'drinks'" class="ai-stream-cursor"></text>
</text>
</view>
<view class="st-meal-glass">
<view class="st-meal-head">
<TongjiIcon name="leaf" size="sm" color="#006c49" />
@@ -248,6 +262,9 @@
</view>
</view>
<view class="phone-gate-body">
<view v-if="dailyContextError" class="phone-gate-error" role="alert">
<text>{{ dailyContextError }}</text>
</view>
<text class="phone-gate-tip">微信仅提供手机号请选择性别以便写入就诊档案</text>
<view class="phone-gate-gender">
<text class="phone-gate-gender-label">性别</text>
@@ -432,11 +449,42 @@ function showUserToast(title, options = {}) {
uni.showToast({
title: text,
icon: options.icon || 'none',
duration: options.duration
duration: options.duration || 2800
})
}
/** 解析接口错误文案(兼容 msg / message / data.error */
function extractApiError(res, fallback = '') {
if (!res) return fallback
const raw = res.msg ?? res.message ?? res.data?.error ?? res.data?.msg ?? res.errMsg
return formatUserMessage(raw, fallback)
}
const diagnosisId = ref(0)
/** 手机号快捷建档失败:页面常驻提示(避免 toast 被 7 字截断) */
const dailyContextError = ref('')
function clearDailyContextError() {
dailyContextError.value = ''
}
function reportDailyContextError(res, fallback = '无法开始录入', options = {}) {
const text = extractApiError(res, fallback)
if (!text) return
dailyContextError.value = text
if (options.alert) {
uni.showModal({
title: '无法录入血糖',
content: text,
showCancel: false,
confirmText: '知道了'
})
} else {
const short = text.length > 14 ? `${text.slice(0, 14)}` : text
showUserToast(short, { duration: 3200 })
}
}
const patientId = ref(0)
const patientName = ref('')
const displayPatientName = computed(() => {
@@ -632,6 +680,19 @@ const currentRange = computed(() => {
return { start: formatDate(start), end: formatDate(end) }
})
/** 拉取血糖时向前多看几天,用于判断「累计满 7 天」是否达标(图表仍只画 currentRange */
const BLOOD_LOOKBACK_DAYS = 30
const fetchRange = computed(() => {
const displayDays = rangeMode.value === '30' ? 30 : 7
const lookback = Math.max(BLOOD_LOOKBACK_DAYS, displayDays)
const end = new Date()
end.setHours(0, 0, 0, 0)
const start = new Date(end)
start.setDate(start.getDate() - (lookback - 1))
return { start: formatDate(start), end: formatDate(end) }
})
const visibleDates = computed(() => {
const { start, end } = currentRange.value
if (!start || !end) return []
@@ -794,27 +855,37 @@ const recordDays = computed(() => {
return result
})
function bloodDayHasSugar(b) {
if (!b) return false
return toNumber(b.fasting_blood_sugar) !== null
|| toNumber(b.postprandial_blood_sugar) !== null
|| toNumber(b.other_blood_sugar) !== null
}
const bloodHasData = computed(() => {
return chartDates.value.some((date) => {
const b = bloodByDate.value[date]
return b && (toNumber(b.fasting_blood_sugar) !== null || toNumber(b.postprandial_blood_sugar) !== null)
})
return chartDates.value.some((date) => bloodDayHasSugar(bloodByDate.value[date]))
})
/** 当前时间范围内有真实录入的天数 */
/** 当前图表时间范围内有真实录入的天数(用于小结统计) */
const bloodRecordDayCount = computed(() => {
return chartDates.value.filter((date) => {
const b = bloodByDate.value[date]
return b && (toNumber(b.fasting_blood_sugar) !== null || toNumber(b.postprandial_blood_sugar) !== null)
}).length
return chartDates.value.filter((date) => bloodDayHasSugar(bloodByDate.value[date])).length
})
/** 拉取窗口内累计有血糖录入的天数(不要求连续) */
const bloodCumulativeDayCount = computed(() => {
let n = 0
for (const date of Object.keys(bloodByDate.value)) {
if (bloodDayHasSugar(bloodByDate.value[date])) n++
}
return n
})
const REAL_BLOOD_DAYS_MIN = 7
/** 无就诊卡或真实录入不足 7 天时,图表用示例数据 */
/** 无就诊卡或累计录入不足 7 天时,图表用示例数据 */
const chartUseMockData = computed(() => {
if (!diagnosisId.value) return true
return bloodRecordDayCount.value < REAL_BLOOD_DAYS_MIN
return bloodCumulativeDayCount.value < REAL_BLOOD_DAYS_MIN
})
/** 示例曲线:7 天内有波动的空腹/餐后,便于老人理解趋势图形态 */
@@ -1154,8 +1225,14 @@ function syncPhoneGateSexFromUser() {
}
}
function openPhoneGate() {
async function openPhoneGate() {
syncPhoneGateSexFromUser()
readUserMobile()
if (userMobile.value) {
phoneGateVisible.value = false
await resolveDailyPhoneContext(false)
return
}
phoneGateVisible.value = true
}
@@ -1172,8 +1249,11 @@ async function refreshUserMobile() {
return userMobile.value
}
async function ensureDailyContextByPhone(sex) {
if (diagnosisId.value) return true
async function ensureDailyContextByPhone(sex, options = {}) {
if (diagnosisId.value) {
clearDailyContextError()
return true
}
const payload = {}
const sexVal = sex ?? phoneGateSex.value
@@ -1188,6 +1268,7 @@ async function ensureDailyContextByPhone(sex) {
}, false)
if (res && res.code === 1 && res.data && res.data.diagnosis_id) {
clearDailyContextError()
await applyCard({
id: res.data.diagnosis_id,
patient_id: res.data.patient_id,
@@ -1195,17 +1276,32 @@ async function ensureDailyContextByPhone(sex) {
age: res.data.age || 0,
gender: res.data.gender || 0
})
if (res.data.created) {
if (res.data.created || res.data.linked_existing) {
await fetchCardList()
}
return true
}
if (res && res.data && res.data.need_mobile) {
clearDailyContextError()
return 'need_mobile'
}
showUserToast(formatUserMessage(res?.msg, '无法开始录入'))
reportDailyContextError(res, '无法开始录入', options)
return false
}
/** 已绑定手机号时直接关联档案,不再弹出授权建档 */
async function resolveDailyPhoneContext(openFormAfter = false) {
const ok = await ensureDailyContextByPhone(phoneGateSex.value, { alert: true })
if (ok === true) {
if (openFormAfter) await openInputForm(true)
return true
}
if (ok === 'need_mobile') {
syncPhoneGateSexFromUser()
phoneGateVisible.value = true
}
return false
}
@@ -1218,7 +1314,7 @@ async function ensureCanRecordGlucose() {
return false
}
const ok = await ensureDailyContextByPhone()
const ok = await ensureDailyContextByPhone(undefined, { alert: true })
if (ok === 'need_mobile') {
openPhoneGate()
return false
@@ -1251,6 +1347,13 @@ async function onPhoneGateAuthorize(e) {
phoneBinding.value = true
try {
readUserMobile()
if (userMobile.value) {
phoneGateVisible.value = false
await resolveDailyPhoneContext(true)
return
}
const res = await proxy.apiUrl({
url: '/api/user/getMobileByMnp',
method: 'POST',
@@ -1262,12 +1365,7 @@ async function onPhoneGateAuthorize(e) {
}
await refreshUserMobile()
phoneGateVisible.value = false
const ok = await ensureDailyContextByPhone(phoneGateSex.value)
if (ok === true) {
await openInputForm(true)
} else if (ok === 'need_mobile') {
openPhoneGate()
}
await resolveDailyPhoneContext(true)
} catch (err) {
showUserToast('网络异常,请稍后再试')
} finally {
@@ -1290,7 +1388,7 @@ async function onSelectCard(card) {
async function fetchAll() {
if (!hasAuthToken() || !diagnosisId.value) return
loading.value = true
const { start, end } = currentRange.value
const { start, end } = fetchRange.value
try {
const [winRes, noteRes] = await Promise.all([
proxy.apiUrl({
@@ -2490,15 +2588,15 @@ async function bootstrap(options) {
if (!target) target = list[0]
await applyCard(target)
} else {
diagnosisId.value = passedDiagnosisId || 0
patientId.value = passedPatientId || 0
if (passedPatientName) patientName.value = passedPatientName
if (passedAge) age.value = passedAge
if (passedGender) gender.value = passedGender
if (!patientName.value) readUserContext()
// URL/ ID
diagnosisId.value = 0
patientId.value = 0
patientName.value = ''
age.value = 0
gender.value = 0
readUserMobile()
if (!diagnosisId.value && userMobile.value) {
await ensureDailyContextByPhone()
if (userMobile.value) {
await ensureDailyContextByPhone(undefined, { alert: false })
}
}