This commit is contained in:
Your Name
2026-06-02 18:33:25 +08:00
parent 62f74888f3
commit 15e9c486f9
11 changed files with 731 additions and 674 deletions
+118 -261
View File
@@ -42,15 +42,6 @@
>
<text v-if="p.char">{{ p.char }}</text>
</view>
<view
v-for="tt in foodTooltips"
:key="tt.id"
class="food-tooltip"
:style="{ left: tt.x + 'px', top: tt.y + 'px' }"
>
<text>{{ tt.name }}</text>
<text class="gi-badge" :style="{ backgroundColor: tt.color }">{{ tt.giLabel }}</text>
</view>
<view
v-for="ft in floatingVals"
:key="ft.id"
@@ -67,15 +58,19 @@
</view>
</view>
<!-- 心理激励提示目标/步数等非升糖类 -->
<view v-if="urgeToast.show" class="gg-urge-toast" :class="'is-' + urgeToast.type">
<text>{{ urgeToast.text }}</text>
<!-- 点击食物的大字科普卡名称 + 含糖等级 + 一句话说明 -->
<view v-if="foodCard" class="gg-food-card" :class="'is-' + foodCard.gi">
<view class="gg-food-card-head">
<text class="gg-food-card-name">{{ foodCard.name }}</text>
<text class="gg-food-card-gi" :style="{ backgroundColor: foodCard.color }">{{ foodCard.giShort }}</text>
</view>
<text class="gg-food-card-tip">{{ foodCard.tip }}</text>
</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>
<text class="gg-result-title">{{ gamePhase === 'won' ? '学会啦' : '再认一认' }}</text>
<view v-if="gamePhase === 'won'" class="gg-result-stars">
<text
v-for="i in 3"
@@ -96,9 +91,9 @@
v-if="gamePhase === 'won'"
class="gg-result-btn is-primary"
@tap="goNextLevel"
>下一</view>
>下一</view>
<view class="gg-result-btn" :class="{ 'is-primary': gamePhase !== 'won' }" @tap="restartLevel">
{{ gamePhase === 'won' ? '再玩本关' : '再试一次' }}
再认一遍
</view>
<view class="gg-result-btn" @tap="goBack">返回</view>
</view>
@@ -120,21 +115,16 @@
hover-class="gg-hud-btn--pressed"
@tap="onSoundToggle"
>
<TongjiIcon name="volume" size="md" :color="sfxEnabled ? '#006c49' : '#cbd5e1'" />
</view>
<view v-if="winStreak >= 2" class="gg-hud-streak" hover-class="gg-hud-btn--pressed">
<TongjiIcon name="flame" size="sm" color="#ea580c" />
<text>{{ winStreak }}</text>
<TongjiIcon name="volume" size="md" :color="sfxEnabled ? '#006c49' : '#94a3b8'" />
</view>
</view>
<!-- 绝对居中的关卡与得分 -->
<view class="gg-hud-level-center">
<text class="gg-hud-level-text"> {{ levelConfig.id }} </text>
<text class="gg-hud-level-text"> {{ levelConfig.id }} </text>
<view class="gg-hud-score" :class="{ 'is-pop': scoreAnimating }">
<text class="gg-hud-score-val">{{ scoreText }}</text>
<text class="gg-hud-score-unit"></text>
<text v-if="scoreMultiplier > 1" class="gg-hud-mult">×{{ scoreMultiplier }}</text>
</view>
</view>
</view>
@@ -174,8 +164,8 @@
<!-- 垂直分割线 1 -->
<view class="gg-dash-divider" />
<!-- 中间步数看板 -->
<view class="gg-dash-moves" :class="{ 'is-warn': movesLeft <= 3 && gamePhase === 'playing' }">
<!-- 中间步数看板仅展示无失败惩罚 -->
<view class="gg-dash-moves">
<text class="gg-moves-label">步数</text>
<text class="gg-moves-val">{{ movesLeft }}</text>
</view>
@@ -187,7 +177,7 @@
<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-title">认识这些食物</text>
<text class="gg-mission-pct">{{ goalProgressPct }}%</text>
</view>
<view v-if="dailyStreak >= 2" class="gg-mission-streak">
@@ -220,19 +210,11 @@
</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
class="gg-digest-pill"
:class="{ 'is-warn': digestCountdown <= 3 }"
>
<TongjiIcon name="info" size="sm" color="#ffffff" />
<text class="gg-digest-num">{{ digestCountdown }}</text>
<text class="gg-digest-label">秒后升糖</text>
<view class="gg-hint-tag"></view>
<text class="gg-board-hint-text">点一下食物看它含糖高不高</text>
</view>
</view>
</view>
@@ -242,14 +224,6 @@
<view class="gg-board-stack" :style="boardStackStyle">
<view class="gg-board-zone">
<view
v-if="gamePhase === 'playing'"
class="gg-digest-urge-bar"
:class="{ 'is-show': digestUrgent }"
>
<TongjiIcon name="alert-circle" size="sm" color="#ffffff" />
<text class="gg-digest-urge-text">餐后升糖倒计时快消除棋盘运动降糖</text>
</view>
<view v-if="hintTipVisible" class="gg-no-move-tip">
<TongjiIcon name="alert-circle" size="sm" color="#ea580c" />
<text class="gg-no-move-tip-text">{{ hintTipText }}</text>
@@ -303,6 +277,7 @@
@touchcancel.stop="onTileTouchCancel"
>
<FoodTileIcon :food="cell.tile.type" size="tile" />
<view class="gg-tile-gi" :class="'is-' + cell.tile.type.gi">{{ giChar(cell.tile.type.gi) }}</view>
</view>
</view>
</view>
@@ -339,7 +314,7 @@ 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, formatEatLabel, getClearVal } from '../data/gameFoodLibrary.js'
import { FOOD_LIBRARY, cloneFoodType, getClearVal, getFoodTip, 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'
@@ -356,25 +331,24 @@ const {
warmUp: warmUpSfx,
toggleEnabled: toggleSfx
} = useGameSfx()
const { burstParticles, ripples, flash, comboBanner, spawnBurst, spawnRipple, triggerShake, triggerFlash, showComboBanner } = useGameFx()
const { burstParticles, ripples, flash, comboBanner, spawnBurst, spawnRipple, triggerFlash, showComboBanner } = useGameFx()
const { progress: gameProgress, touchDailyPlay, recordWin, recordLoss } = useGameProgress()
const levelConfig = ref(getLevelConfig(1))
const resultStars = ref(0)
const resultStats = ref([])
const sessionMaxCombo = ref(0)
const urgeToast = ref({ show: false, text: '', type: 'warm' })
const menuHeight = ref(32)
const menuTop = ref(0)
let urgeTimer = null
let lastUrgeKey = ''
const ROWS = 7
const COLS = 6
const ROWS = 6
const COLS = 4
const ROW_AXES = Array.from({ length: ROWS }, (_, i) => i)
const COL_AXES = Array.from({ length: COLS }, (_, i) => i)
/** 7× 6 列棋盘:单格像素由可用区域反算 */
/** 6× 4 列棋盘:单格像素由可用区域反算 */
const BOARD_INSET_RPX = 4
/** 棋盘与底部道具栏之间的安全间隙,避免最后一行被遮挡 */
const BOARD_FOOTER_GAP_RPX = 28
const GRID_PAD_RPX = 8
const GRID_GAP_RPX = 8
const SWAP_MS = 280
@@ -449,6 +423,11 @@ function tileStyle() {
return { background: 'transparent' }
}
/** 棋子角标用:低 / 中 / 高(含糖等级) */
function giChar(gi) {
return gi === 'high' ? '高' : gi === 'mid' ? '中' : '低'
}
function gridPadGapPx() {
return {
padPx: Math.round(uni.upx2px(GRID_PAD_RPX)),
@@ -527,16 +506,15 @@ const isProcessing = ref(false)
const selectedUid = ref(null)
const matchChain = ref(0)
const movesLeft = ref(25)
const digestCountdown = ref(8)
const gamePhase = ref('playing')
const loseReason = ref('')
const resultMessage = ref('')
const foodTooltips = ref([])
const foodCard = ref(null)
let foodCardTimer = null
const floatingVals = ref([])
const hintUids = ref(new Set())
const hintMove = ref(null) // { t1, t2, isHorizontal }
let hintTimer = null
let digestTimer = null
let startedSound = false
// 目标列表在 selectSessionFoods() 中按本局食材动态生成
@@ -634,16 +612,8 @@ const gridRows = computed(() => {
const scoreText = computed(() => score.value.toLocaleString('zh-CN'))
const winStreak = computed(() => gameProgress.value.winStreak || 0)
const dailyStreak = computed(() => gameProgress.value.dailyStreak || 0)
/** 连胜加成:2 连胜起每多 1 场 +10% 得分 */
const scoreMultiplier = computed(() => {
const s = winStreak.value
if (s < 2) return 1
return 1 + (s - 1) * 0.1
})
const goalProgressPct = computed(() => {
const list = goalList.value
if (!list.length) return 0
@@ -665,11 +635,6 @@ const goalList = computed(() =>
})
)
/** 升糖倒计时 ≤3 秒时在棋盘上方显示告急条 */
const digestUrgent = computed(() => (
gamePhase.value === 'playing' && digestCountdown.value > 0 && digestCountdown.value <= 3
))
const allGoalsDone = computed(() =>
goalList.value.length > 0 && goalList.value.every((g) => g.done)
)
@@ -868,108 +833,38 @@ function setupLevel(levelId) {
const cfg = getLevelConfig(levelId)
applyLevelConfig(cfg)
sessionMaxCombo.value = 0
lastUrgeKey = ''
loseReason.value = ''
tileTouchStart = null
clearDragPreview()
selectSessionFoods()
initBoard()
startDigestTimer()
recomputeGlucoseFromBoard()
}
/** 统计棋盘上未消化食物量(高 GI 权重更大) */
function calcBoardDigestRise() {
/** 统计棋盘上当前含糖负荷(高 GI 权重更大),用于血糖表信息性反算 */
function calcBoardSugarLoad() {
const loads = { high: 0, mid: 0, low: 0 }
let total = 0
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const tile = board.value[r]?.[c]
if (tile && !tile.isRemoved) {
loads[tile.type.gi] = (loads[tile.type.gi] || 0) + 1
total++
}
}
}
const mult = levelConfig.value.digestMultiplier ?? 1
const raw = loads.high * 2.5 + loads.mid * 0.9 + loads.low * 0.15
return Math.min(14, Math.max(1, Math.round(raw * 0.07 * mult)))
if (total <= 0) return 50
// 高糖占比越大,血糖表越高;纯信息展示,不判负、不随时间变化
const weighted = loads.high * 1 + loads.mid * 0.5 + loads.low * 0.1
const ratio = weighted / total
return Math.max(5, Math.min(95, Math.round(ratio * 100)))
}
function triggerDigestTick() {
if (gamePhase.value !== 'playing' || isProcessing.value) return
const rise = calcBoardDigestRise()
if (rise <= 0) return
updateMeter(rise)
showMeterDeltaFx(rise, '消化')
playSfx('meterWarn', 0.65, 1.05)
if (glucoseLevel.value >= 95) {
loseReason.value = 'glucose'
endGame('lost')
return
}
}
function resetDigestCountdown() {
digestCountdown.value = levelConfig.value.digestIntervalSec ?? 8
}
function startDigestTimer() {
stopDigestTimer()
resetDigestCountdown()
digestTimer = setInterval(() => {
if (gamePhase.value !== 'playing' || isProcessing.value) return
digestCountdown.value = Math.max(0, digestCountdown.value - 1)
if (digestCountdown.value <= 0) {
triggerDigestTick()
resetDigestCountdown()
}
}, 1000)
}
function stopDigestTimer() {
if (digestTimer) {
clearInterval(digestTimer)
digestTimer = null
}
}
function showUrge(text, type = 'warm') {
if (gamePhase.value !== 'playing') return
if (urgeTimer) clearTimeout(urgeTimer)
urgeToast.value = { show: true, text, type }
urgeTimer = setTimeout(() => {
urgeToast.value = { ...urgeToast.value, show: false }
}, 2200)
}
/** 心理激励:接近胜利 / 步数告急时给正反馈,促继续玩 */
function checkUrge() {
if (gamePhase.value !== 'playing') return
const pct = goalProgressPct.value
let key = ''
let text = ''
let type = 'warm'
if (pct >= 100) return
if (pct >= 88) {
key = 'almost'
text = '就差一点点!再消一组就过关 🎯'
type = 'hot'
} else if (pct >= 65) {
key = 'close'
text = `目标已完成 ${pct}%,继续保持!`
type = 'warm'
} else if (movesLeft.value <= 3 && movesLeft.value > 0) {
key = 'moves'
text = `仅剩 ${movesLeft.value} 步,试试提示箭头交换!`
type = 'warn'
} else if (winStreak.value >= 3) {
key = 'streak'
text = `${winStreak.value} 连胜中!得分 ×${scoreMultiplier.value.toFixed(1)}`
type = 'hot'
}
if (!key || key === lastUrgeKey) return
lastUrgeKey = key
showUrge(text, type)
/** 棋盘稳定后,把血糖表同步为当前含糖负荷(消除高糖食物后会变好) */
function recomputeGlucoseFromBoard() {
const target = calcBoardSugarLoad()
glucoseLevel.value = target
}
function restartLevel() {
@@ -981,9 +876,8 @@ function restartLevel() {
selectedUid.value = null
matchChain.value = 0
screenShaking.value = false
foodTooltips.value = []
foodCard.value = null
floatingVals.value = []
lastUrgeKey = ''
setupLevel(levelConfig.value.id)
nextTick(() => recalcBoardSize())
}
@@ -997,9 +891,8 @@ function goNextLevel() {
selectedUid.value = null
matchChain.value = 0
screenShaking.value = false
foodTooltips.value = []
foodCard.value = null
floatingVals.value = []
lastUrgeKey = ''
setupLevel(gameProgress.value.currentLevel || levelConfig.value.id + 1)
nextTick(() => recalcBoardSize())
}
@@ -1428,10 +1321,10 @@ async function reshuffleIfStuck() {
}
/**
* 消除组视觉特效(开心消消乐风格):
* 3 连:柔光圈 + 少量星屑(轻量“噗”)
* 4 连:糖果 confetti彩虹星屑+ 双层光环 + 轻闪屏 + 轻震屏
* 5 连+:彩虹大爆炸 + 多重金/白光环 + 闪屏 + 重震屏
* 消除组视觉特效(适老化:柔和为主,去掉震屏与强闪屏):
* 3 连:柔光圈 + 少量星屑
* 4 连:彩虹星屑 + 双层光环 + 轻闪屏
* 5 连+:彩虹光环 + 闪屏(不震屏
* 支持对每一组同时触发(并行查询中心坐标)。
*/
async function spawnGroupFx(groups, maxSize) {
@@ -1450,7 +1343,7 @@ async function spawnGroupFx(groups, maxSize) {
const main = centers.reduce((a, b) => (b.size > a.size ? b : a))
if (maxSize >= 5) {
// 5 连+:彩虹大爆炸
// 5 连+:彩虹光环(柔和,不震屏)
centers.forEach(({ center, color }) => {
spawnRipple(center.x, center.y, color, 4)
spawnBurst(center.x, center.y, color, 16, { rainbow: true, distMax: 130, sizeMax: 18 })
@@ -1458,18 +1351,16 @@ async function spawnGroupFx(groups, maxSize) {
spawnRipple(main.center.x, main.center.y, '#FFD54A', 6)
spawnRipple(main.center.x, main.center.y, '#ffffff', 2, { life: 520 })
spawnBurst(main.center.x, main.center.y, '#FFD54A', 18, { rainbow: true, sizeMax: 22, distMax: 160 })
triggerFlash('#fff7d6', 0.5)
triggerShake(2)
triggerFlash('#fff7d6', 0.18)
} else if (maxSize >= 4) {
// 4 连:糖果 confetti + 双层光环
// 4 连:彩虹星屑 + 双层光环(不震屏)
centers.forEach(({ center, color }) => {
spawnRipple(center.x, center.y, color, 3)
spawnBurst(center.x, center.y, color, 12, { rainbow: true })
})
spawnRipple(main.center.x, main.center.y, '#ffffff', 1, { life: 480 })
spawnBurst(main.center.x, main.center.y, '#FFE06B', 6, { sizeMax: 18 })
triggerFlash('#ffffff', 0.28)
triggerShake(1)
triggerFlash('#ffffff', 0.12)
} else {
// 3 连:轻量星屑 + 柔光圈
centers.forEach(({ center, color }) => {
@@ -1492,36 +1383,20 @@ async function queryTileCenter(tile) {
})
}
/** 点击食物:弹出大字科普卡,强化「这是高/中/低糖食物」的认知 */
function showFoodTooltip(tile) {
queryTileCenter(tile).then((center) => {
if (!center) return
const id = `tt-${Date.now()}`
foodTooltips.value.push({
id,
name: tile.type.name,
giLabel: tile.type.giLabel,
color: tile.type.color,
x: center.x,
y: center.y
})
setTimeout(() => {
foodTooltips.value = foodTooltips.value.filter((t) => t.id !== id)
}, 1500)
const valId = `fv-${Date.now()}`
const valText = formatEatLabel(tile.type)
const valColor = tile.type.gi === 'high' ? '#ef5350' : tile.type.gi === 'mid' ? '#fb8c00' : '#43a047'
floatingVals.value.push({
id: valId,
text: valText,
color: valColor,
x: center.x,
y: center.y
})
setTimeout(() => {
floatingVals.value = floatingVals.value.filter((t) => t.id !== valId)
}, 1000)
})
const t = tile.type
if (foodCardTimer) clearTimeout(foodCardTimer)
foodCard.value = {
name: t.name,
gi: t.gi,
giShort: GI_SHORT[t.gi] || '',
color: GI_COLOR[t.gi] || '#16a34a',
tip: getFoodTip(t)
}
foodCardTimer = setTimeout(() => {
foodCard.value = null
}, 3200)
}
function updateGoal(key) {
@@ -1529,12 +1404,11 @@ function updateGoal(key) {
if (g && g.current < g.target) {
g.current += 1
playSfx('goalTick', 0.9)
nextTick(() => checkUrge())
}
}
function updateScore(pts) {
const bonus = Math.round(pts * scoreMultiplier.value)
const bonus = Math.round(pts)
score.value += bonus
if (bonus >= 40) playSfx('score', Math.min(1.15, 0.85 + bonus / 200))
scoreAnimating.value = false
@@ -1545,64 +1419,52 @@ function updateScore(pts) {
}
function updateMeter(totalVal) {
const prev = glucoseLevel.value
glucoseLevel.value = Math.max(5, Math.min(95, glucoseLevel.value + totalVal))
playMeterSfx(totalVal, glucoseLevel.value)
if (prev <= 70 && glucoseLevel.value > 70) {
screenShaking.value = true
setTimeout(() => { screenShaking.value = false }, 500)
}
}
function showMeterDeltaFx(delta, label = '血糖') {
const query = uni.createSelectorQuery().in(instance?.proxy)
query.select('.game-grid').boundingClientRect((rect) => {
if (!rect || rect.width <= 0) return
const cx = rect.left + rect.width / 2
const cy = rect.top + rect.height / 2
const sign = delta > 0 ? '+' : ''
const valId = `fv-meter-${Date.now()}`
/** 消除时给认知正反馈:在最大一组中心浮出「认识了 X·高糖」 */
function showLearnFx(groups) {
if (!groups || !groups.length) return
const g = groups.reduce((a, b) => (b.size > a.size ? b : a))
const rep = g.tiles[Math.floor(g.tiles.length / 2)]
if (!rep) return
const t = rep.type
queryTileCenter(rep).then((center) => {
if (!center) return
const id = `learn-${Date.now()}`
floatingVals.value.push({
id: valId,
text: `${label} ${sign}${delta}`,
color: delta < 0 ? '#2ecc71' : '#ef5350',
x: cx,
y: cy - 24
id,
text: `认识了 ${t.name}·${GI_SHORT[t.gi] || ''}`,
color: GI_COLOR[t.gi] || '#16a34a',
x: center.x,
y: center.y - 30
})
setTimeout(() => {
floatingVals.value = floatingVals.value.filter((t) => t.id !== valId)
}, 1100)
}).exec()
floatingVals.value = floatingVals.value.filter((v) => v.id !== id)
}, 1400)
})
}
function checkGameEnd() {
if (gamePhase.value !== 'playing') return
// 无失败惩罚:仅在认全目标食物时通关,步数耗尽不判负
if (allGoalsDone.value) {
endGame('won')
return
}
if (movesLeft.value <= 0) {
endGame('lost')
} else {
checkUrge()
}
}
function buildResultStats() {
const rows = [
{ label: '得分', value: scoreText.value },
{ label: '剩余步数', value: String(movesLeft.value) },
{ label: '最高连消', value: `${sessionMaxCombo.value} ` }
const learned = goalList.value.reduce((s, g) => s + g.current, 0)
return [
{ label: '本组认识食物', value: `${goalList.value.length}` },
{ label: '消除练习', value: `${learned} ` },
{ label: '得分', value: scoreText.value }
]
if (winStreak.value >= 1) {
rows.push({ label: '连胜', value: `${winStreak.value}` })
}
return rows
}
function endGame(phase) {
gamePhase.value = phase
stopDigestTimer()
clearHints()
if (phase === 'won') {
@@ -1615,25 +1477,19 @@ function endGame(phase) {
maxCombo: sessionMaxCombo.value
})
resultStars.value = stars
const starTip = stars >= 3 ? '完美控糖!' : stars >= 2 ? '表现不错!' : '再接再厉,争取更多星!'
resultMessage.value = newBest
? `${starTip} 新纪录 ${stars} 星,已解锁第 ${levelConfig.value.id + 1}`
: `${starTip} 获得 ${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} 组。`
}
resultStats.value = buildResultStats()
playSfxLayer('win', 'winStinger', 120, 0.7)
} else {
recordLoss()
const pct = goalProgressPct.value
resultStars.value = 0
if (loseReason.value === 'glucose') {
resultMessage.value = '血糖过高!多消除棋盘上的食物,相当于运动降糖'
} else if (pct >= 70) {
resultMessage.value = `目标已完成 ${pct}%,只差一步!再试一次很可能过关`
} else if (pct >= 40) {
resultMessage.value = `已完成 ${pct}%,换种交换顺序试试`
} else {
resultMessage.value = '步数用尽,调整策略再挑战'
}
resultMessage.value = '再认一认这些食物,下次更熟练。'
resultStats.value = buildResultStats()
playSfx('lose')
}
@@ -1738,11 +1594,7 @@ async function clearAndRefill() {
updateScore(tiles.length * 20 * (matchChain.value + 1))
updateMeter(totalVal)
if (totalVal !== 0) {
showMeterDeltaFx(totalVal, totalVal < 0 ? '运动' : '血糖')
}
// 消除食物 = 运动,升糖倒计时恢复初始
resetDigestCountdown()
showLearnFx(groups)
bumpLayout()
await delay(MATCH_MS)
@@ -2110,27 +1962,31 @@ function recalcBoardSize() {
const sys = uni.getSystemInfoSync()
const header = res?.[0]
const footer = res?.[1]
const headerBottom = Math.ceil(header?.bottom ?? header?.height ?? uni.upx2px(200))
const footerTop = Math.floor(footer?.top ?? (sys.windowHeight - uni.upx2px(180)))
const headerBottom = Math.ceil(header?.bottom ?? header?.height ?? uni.upx2px(360))
// 底部道具栏较高,测量失败时用更保守的兜底高度,避免最后一行被遮挡
const footerTop = Math.floor(footer?.top ?? (sys.windowHeight - uni.upx2px(300)))
const inset = uni.upx2px(BOARD_INSET_RPX)
const footerGap = uni.upx2px(BOARD_FOOTER_GAP_RPX)
const zoneW = sys.windowWidth
const zoneH = Math.max(0, footerTop - headerBottom)
// 预留底部安全间隙,棋盘不贴到道具栏
const zoneH = Math.max(0, footerTop - headerBottom - footerGap)
// 7× 6 列:宽高分别约束,取较小单格像素
// 6× 4 列:宽高分别约束,取较小单格像素
cellSizePx.value = calcMaxCellSize(zoneW - inset * 2, zoneH - inset * 2)
const boardDim = calcBoardDimensions(cellSizePx.value)
boardWidthPx.value = boardDim.width
boardHeightPx.value = boardDim.height
const areaBottom = Math.max(0, sys.windowHeight - footerTop + footerGap)
mainStyle.value = {
top: `${headerBottom}px`,
bottom: `${Math.max(0, sys.windowHeight - footerTop)}px`
bottom: `${areaBottom}px`
}
pageStyle.value = {
...pageStyle.value,
'--board-area-top': `${headerBottom}px`,
'--board-area-bottom': `${Math.max(0, sys.windowHeight - footerTop)}px`,
'--board-area-bottom': `${areaBottom}px`,
'--cell-size': `${cellSizePx.value}px`,
'--tile-cell-px': `${cellSizePx.value}px`,
'--tile-emoji-size': `${Math.floor(cellSizePx.value * 0.82)}px`
@@ -2157,6 +2013,8 @@ onLoad(() => {
onReady(() => {
recalcBoardSize()
// 等头/底栏(含安全区、道具栏)完全布局后再量一次,确保最后一行不被遮挡
setTimeout(() => recalcBoardSize(), 180)
})
onMounted(() => {
@@ -2165,8 +2023,7 @@ onMounted(() => {
onUnmounted(() => {
clearHints()
stopDigestTimer()
if (urgeTimer) clearTimeout(urgeTimer)
if (foodCardTimer) clearTimeout(foodCardTimer)
})
</script>
+63 -12
View File
@@ -1264,6 +1264,47 @@ async function applyCard(card) {
await fetchGamifyState()
}
function readUserMobile() {
try {
const userData = uni.getStorageSync('userData') || {}
return String(userData.mobile || userData.phone || '').trim()
} catch (e) {
return ''
}
}
/** 无就诊卡时:已绑定手机号则快捷建档(与 weekly 一致) */
async function ensureDailyContextByPhone() {
if (diagnosisId.value) return true
const res = await proxy.apiUrl({
url: '/api/tcm/dailyEnsurePhoneContext',
method: 'POST',
data: {}
}, false)
if (res && res.code === 1 && res.data && res.data.diagnosis_id) {
await applyCard({
id: res.data.diagnosis_id,
patient_id: res.data.patient_id,
patient_name: res.data.patient_name || '',
age: res.data.age || 0,
gender: res.data.gender || 0
})
if (res.data.created) {
await fetchCardList()
}
return true
}
if (res && res.data && res.data.need_mobile) {
return 'need_mobile'
}
showUserToast(formatUserMessage(res?.msg, '无法加载记录'))
return false
}
async function onSelectCard(card) {
if (!card || Number(card.id) === Number(diagnosisId.value)) return
bloodRecords.value = []
@@ -2680,13 +2721,12 @@ function onChartTouch(e) {
function readUserContext() {
try {
const userData = uni.getStorageSync('userData')
if (userData && userData.diagnosis) {
if (!patientId.value) patientId.value = Number(userData.diagnosis.patient_id) || 0
if (!diagnosisId.value) diagnosisId.value = Number(userData.diagnosis.id) || 0
if (!patientName.value) patientName.value = userData.diagnosis.patient_name || ''
if (!age.value) age.value = Number(userData.diagnosis.age) || 0
if (!gender.value) gender.value = Number(userData.diagnosis.gender) || 0
}
if (!userData) return
const name = String(userData.real_name || userData.nickname || '').trim()
if (!patientName.value && name) patientName.value = name
const sex = Number(userData.sex)
if (!gender.value && (sex === 1 || sex === 2)) gender.value = sex === 1 ? 1 : 0
if (!age.value && userData.age) age.value = Number(userData.age) || 0
} catch (e) {}
}
@@ -2755,17 +2795,28 @@ async function bootstrap(options) {
if (!target) target = list[0]
await applyCard(target)
} else {
if (passedDiagnosisId) diagnosisId.value = passedDiagnosisId
if (passedPatientId) patientId.value = passedPatientId
diagnosisId.value = passedDiagnosisId || 0
patientId.value = passedPatientId || 0
if (passedPatientName) patientName.value = passedPatientName
if (passedAge) age.value = passedAge
if (passedGender) gender.value = passedGender
if (!diagnosisId.value || !patientId.value) {
readUserContext()
if (!patientName.value) readUserContext()
if (!diagnosisId.value && readUserMobile()) {
await ensureDailyContextByPhone()
}
if (!diagnosisId.value) {
authChecking.value = false
redirectToCardEntry()
if (readUserMobile()) {
showUserToast('暂时无法加载记录,请稍后重试')
} else {
showUserToast('请先在统计页授权手机号后再查看记录')
}
setTimeout(() => {
const pages = getCurrentPages()
if (pages.length > 1) uni.navigateBack()
else uni.redirectTo({ url: '/tongji/pages/weekly' })
}, 1600)
return
}
}
+105 -140
View File
@@ -45,20 +45,19 @@
</view>
</scroll-view>
<view class="st-main">
<!-- 1. 录入今日血糖 -->
<!-- 2. 血糖趋势 -->
<view v-if="diagnosisId" class="st-chart-card">
<!-- 2. 血糖趋势无就诊卡或录入不足 7 天显示示例曲线 -->
<view class="st-chart-card">
<view class="st-chart-head">
<view>
<text class="st-chart-title">血糖趋势</text>
<text class="st-chart-sub">{{ rangeText }} · mmol/L</text>
<view class="st-chart-more" @click="goRecordsPage">
<text class="st-chart-more-text">查看更多记录</text>
<TongjiIcon name="chevron-right" size="sm" color="#006c49" />
</view>
</view>
<view class="st-chart-legend">
<view
@@ -79,97 +78,39 @@
</view>
</view>
</view>
<view v-if="bloodHasData" class="st-chart-summary">
<view class="st-chart-summary-item">
<text class="st-chart-summary-num is-high">{{ bloodHighDayCount }}</text>
<text class="st-chart-summary-label">天偏高</text>
</view>
<view class="st-chart-summary-item">
<text class="st-chart-summary-num is-good">{{ bloodNormalDayCount }}</text>
<text class="st-chart-summary-label">天正常</text>
</view>
<view class="st-chart-summary-item">
<text class="st-chart-summary-num">{{ bloodRecordDayCount }}</text>
<text class="st-chart-summary-label">天有记录</text>
</view>
</view>
<view class="st-chart-area">
<view class="st-chart-area" @click="onChartTap">
<canvas
v-if="!recordOverlayOpen"
id="bloodWaveChart"
type="2d"
class="st-chart-canvas"
@touchstart="onChartTouch"
@touchmove="onChartTouch"
@click.stop="onChartTap"
/>
<view v-if="recordOverlayOpen" class="st-chart-placeholder">
<text>图表已暂停显示</text>
</view>
<view v-if="!bloodHasData && !loading && !recordOverlayOpen" class="st-chart-empty">
<view v-if="!chartHasDisplayData && !loading && !recordOverlayOpen" class="st-chart-empty">
<text>当前时间范围暂无血糖数据</text>
</view>
<cover-view v-if="hoverInfo && !recordOverlayOpen" class="chart-tip" :style="{ left: hoverInfo.left + 'px', top: hoverInfo.top + 'px' }">
<cover-view class="chart-tip-date">{{ hoverInfo.date }}</cover-view>
<cover-view v-if="hoverInfo.fasting != null" class="chart-tip-row">
<cover-view class="st-dot st-dot-fasting" />
<cover-view>空腹 {{ hoverInfo.fasting }}</cover-view>
</cover-view>
<cover-view v-if="hoverInfo.postprandial != null" class="chart-tip-row">
<cover-view class="st-dot st-dot-post" />
<cover-view>餐后 {{ hoverInfo.postprandial }}</cover-view>
</cover-view>
</cover-view>
</view>
<view class="st-cta" @click="openInputForm()">
<TongjiIcon name="plus" size="md" color="#FFFFFF" />
<text>录入今日血糖</text>
</view>
<view class="st-game-entry" @click="goGamePage">
<view class="st-game-entry-icon">
<TongjiIcon name="activity" size="md" color="#006c49" />
<view v-if="chartUseMockData" class="st-chart-foot">
<text class="st-chart-mock-hint">示例数据连续记录满 7 天后显示您的真实趋势</text>
</view>
<view class="st-game-entry-body">
<text class="st-game-entry-title">糖分突袭</text>
<text class="st-game-entry-sub">消除棋盘食物边玩边学控糖</text>
</view>
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
</view>
</view>
<!-- 3. 最新测量 & 概览 -->
<view class="st-latest-section">
<view
v-if="latestMeasurement.hasValue"
class="st-latest-hero"
:class="{ 'is-high': latestMeasurement.isHigh }"
>
<view class="st-latest-hero-pattern" aria-hidden="true" />
<text class="st-latest-label">最新测量 · {{ latestMeasurement.typeLabel }}</text>
<view class="st-latest-value-row">
<text class="st-latest-value">{{ latestMeasurement.value }}</text>
<text class="st-latest-unit">mmol/L</text>
<view class="st-game-entry st-game-entry--in-card" @click="goGamePage">
<view class="st-game-entry-icon">
<TongjiIcon name="activity" size="sm" color="#006c49" />
</view>
<view v-if="latestMeasurement.isHigh" class="st-latest-flag">
<TongjiIcon name="alert-triangle" size="sm" color="#93000a" />
<text>偏高请遵医嘱</text>
</view>
</view>
<view class="st-summary-grid">
<view class="st-summary-card">
<text class="st-summary-label">空腹</text>
<text class="st-summary-value" :class="{ 'is-high': elderFasting.isHigh }">{{ elderFasting.value }}</text>
<text v-if="elderFasting.isHigh" class="st-summary-tag">偏高</text>
</view>
<view class="st-summary-card">
<text class="st-summary-label">餐后</text>
<text class="st-summary-value" :class="{ 'is-high': elderPostprandial.isHigh }">{{ elderPostprandial.value }}</text>
<text v-if="elderPostprandial.isHigh" class="st-summary-tag">偏高</text>
<view class="st-game-entry-body">
<text class="st-game-entry-title">识糖小课堂</text>
<text class="st-game-entry-sub">认一认哪些食物含糖高</text>
</view>
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
</view>
</view>
<!-- 4. AI 饮食建议 -->
<view class="st-training-module">
<dev-training-entry inline />
</view>
<!-- 3. AI 饮食建议 -->
<view v-if="diagnosisId" class="st-ai-section">
<view class="st-ai-deco" aria-hidden="true" />
<view class="st-ai-head">
@@ -227,7 +168,7 @@
<view class="st-main-spacer" />
</view>
<!-- 收起后的重新唤起按钮 -->
<view v-if="diagnosisId && floatAskClosed" class="st-float-reopen" @click="floatAskClosed = false">
<TongjiIcon name="sparkles" size="sm" color="#ffffff" />
@@ -458,6 +399,7 @@ import TongjiIcon from '../components/TongjiIcon.vue'
import { useDietAi } from '../composables/useDietAi.js'
import { useSpeechToText } from '../composables/useSpeechToText.js'
import TabBarDock from '@/components/app-tab-bar/tab-bar-dock.vue'
import DevTrainingEntry from '@/components/dev-training-entry/index.vue'
const { proxy } = getCurrentInstance()
@@ -859,6 +801,7 @@ const bloodHasData = computed(() => {
})
})
/** 当前时间范围内有真实录入的天数 */
const bloodRecordDayCount = computed(() => {
return chartDates.value.filter((date) => {
const b = bloodByDate.value[date]
@@ -866,6 +809,50 @@ const bloodRecordDayCount = computed(() => {
}).length
})
const REAL_BLOOD_DAYS_MIN = 7
/** 无就诊卡或真实录入不足 7 天时,图表用示例数据 */
const chartUseMockData = computed(() => {
if (!diagnosisId.value) return true
return bloodRecordDayCount.value < REAL_BLOOD_DAYS_MIN
})
/** 示例曲线:7 天内有波动的空腹/餐后,便于老人理解趋势图形态 */
const mockBloodByDate = computed(() => {
const map = {}
const dates = chartDates.value
dates.forEach((date, i) => {
const t = dates.length > 1 ? i / (dates.length - 1) : 0
const fasting = Number((5.2 + Math.sin(t * Math.PI) * 0.5 + (i % 2) * 0.15).toFixed(1))
const post = Number((7.6 + Math.cos(t * Math.PI) * 1.1 + (i % 3) * 0.25).toFixed(1))
const row = {
record_date: date,
fasting_blood_sugar: fasting,
postprandial_blood_sugar: post,
other_blood_sugar: '',
systolic_pressure: '',
diastolic_pressure: ''
}
row.isHighFasting = isHighFasting(row.fasting_blood_sugar)
row.isHighPostprandial = isHighPostprandial(row.postprandial_blood_sugar)
row.isHighOther = false
row.isHighBp = false
row.bpText = '—'
map[date] = row
})
return map
})
/** 图表绘制用数据源:示例或真实 */
const chartBloodByDate = computed(() => (
chartUseMockData.value ? mockBloodByDate.value : bloodByDate.value
))
const chartHasDisplayData = computed(() => {
if (chartUseMockData.value) return chartDates.value.length > 0
return bloodHasData.value
})
const bloodHighDayCount = computed(() => {
return chartDates.value.filter((date) => {
const b = bloodByDate.value[date]
@@ -1377,16 +1364,26 @@ function onRefresh() {
fetchAll()
}
async function goRecordsPage() {
if (!diagnosisId.value) {
const ok = await ensureCanRecordGlucose()
if (!ok || !diagnosisId.value) return
}
function buildMorePageUrl() {
if (!diagnosisId.value) return '/tongji/pages/more'
const q = [
`diagnosis_id=${diagnosisId.value}`,
patientId.value ? `patient_id=${patientId.value}` : ''
].filter(Boolean).join('&')
uni.navigateTo({ url: `/tongji/pages/more?${q}` })
return `/tongji/pages/more?${q}`
}
/** 点击血糖趋势图:进入记录页 more(无就诊卡时先手机号快捷建档) */
async function onChartTap() {
if (!diagnosisId.value) {
const ok = await ensureCanRecordGlucose()
if (!ok || !diagnosisId.value) return
}
uni.navigateTo({ url: buildMorePageUrl() })
}
async function goRecordsPage() {
onChartTap()
}
async function goMorePage() {
@@ -1394,7 +1391,7 @@ async function goMorePage() {
const ok = await ensureCanRecordGlucose()
if (!ok || !diagnosisId.value) return
}
uni.navigateTo({ url: '/tongji/pages/more' })
uni.navigateTo({ url: buildMorePageUrl() })
}
function goGamePage() {
@@ -2102,7 +2099,7 @@ function scheduleChartRedraw() {
function buildSeries(field) {
return chartDates.value.map((date) => {
const b = bloodByDate.value[date]
const b = chartBloodByDate.value[date]
return b ? toNumber(b[field]) : null
})
}
@@ -2136,7 +2133,7 @@ function drawChart() {
ctx.clearRect(0, 0, w, h)
const padding = { left: 40, right: 18, top: 26, bottom: 36 }
const padding = { left: 40, right: 18, top: 12, bottom: 28 }
const cw = w - padding.left - padding.right
const ch = h - padding.top - padding.bottom
@@ -2392,59 +2389,25 @@ function toggleChartSeries(key) {
scheduleChartRedraw()
}
function onChartTouch(e) {
if (!chartCoords || !canvasWidth) return
if (!chartDates.value.length) return
let touchX = 0
let touchY = 0
if (e && e.touches && e.touches[0]) {
touchX = e.touches[0].x ?? e.touches[0].clientX ?? 0
touchY = e.touches[0].y ?? e.touches[0].clientY ?? 0
}
const { padding, cw, n } = chartCoords
if (touchX < padding.left || touchX > padding.left + cw) {
return
}
const ratio = n <= 1 ? 0 : (touchX - padding.left) / cw
let idx = Math.round(ratio * (n - 1))
if (idx < 0) idx = 0
if (idx > n - 1) idx = n - 1
const date = chartDates.value[idx]
const fp = chartSeriesVisible.value.fasting ? chartCoords.fasting[idx] : null
const pp = chartSeriesVisible.value.postprandial ? chartCoords.postprandial[idx] : null
const anchorX = fp?.x ?? pp?.x ?? padding.left + cw * (idx / Math.max(1, n - 1))
const left = Math.max(8, Math.min(canvasWidth - 130, anchorX - 60))
const top = Math.max(4, touchY - 70)
hoverInfo.value = {
date: date.slice(5),
fasting: fp?.value ?? null,
postprandial: pp?.value ?? null,
left,
top
}
if (hoverTimer) clearTimeout(hoverTimer)
hoverTimer = setTimeout(() => {
hoverInfo.value = null
}, 2500)
}
watch(rangeMode, () => {
scheduleChartRedraw()
})
watch([chartUseMockData, bloodRecords], () => {
scheduleChartRedraw()
})
// ============ 生命周期 ============
function readUserContext() {
try {
const userData = uni.getStorageSync('userData')
if (userData && userData.diagnosis) {
if (!patientId.value) patientId.value = Number(userData.diagnosis.patient_id) || 0
if (!diagnosisId.value) diagnosisId.value = Number(userData.diagnosis.id) || 0
if (!patientName.value) patientName.value = userData.diagnosis.patient_name || ''
if (!age.value) age.value = Number(userData.diagnosis.age) || 0
if (!gender.value) gender.value = Number(userData.diagnosis.gender) || 0
}
if (!userData) return
// 勿用问诊会话里的 diagnosis 作为日常血糖诊单(无 view_records 会报无权)
const name = String(userData.real_name || userData.nickname || '').trim()
if (!patientName.value && name) patientName.value = name
const sex = Number(userData.sex)
if (!gender.value && (sex === 1 || sex === 2)) gender.value = sex === 1 ? 1 : 0
if (!age.value && userData.age) age.value = Number(userData.age) || 0
} catch (e) {}
}
@@ -2527,14 +2490,12 @@ async function bootstrap(options) {
if (!target) target = list[0]
await applyCard(target)
} else {
if (passedDiagnosisId) diagnosisId.value = passedDiagnosisId
if (passedPatientId) patientId.value = passedPatientId
diagnosisId.value = passedDiagnosisId || 0
patientId.value = passedPatientId || 0
if (passedPatientName) patientName.value = passedPatientName
if (passedAge) age.value = passedAge
if (passedGender) gender.value = passedGender
if (!diagnosisId.value || !patientId.value) {
readUserContext()
}
if (!patientName.value) readUserContext()
readUserMobile()
if (!diagnosisId.value && userMobile.value) {
await ensureDailyContextByPhone()
@@ -2544,6 +2505,10 @@ async function bootstrap(options) {
authChecking.value = false
if (diagnosisId.value) {
await fetchAll()
} else {
bloodRecords.value = []
await nextTick()
scheduleChartRedraw()
}
}