This commit is contained in:
Your Name
2026-07-02 14:35:20 +08:00
parent b6cdd20c56
commit efd058bce5
312 changed files with 1376 additions and 400 deletions
@@ -1,11 +1,14 @@
<template>
<view class="food-tile-icon" :class="[`food-tile-icon--${size}`]">
<text class="food-tile-icon__emoji">{{ fallbackIcon }}</text>
<view
class="food-tile-icon"
:class="[`food-tile-icon--${size}`, { 'is-custom-img': hasCustomImg }]"
>
<text v-if="showEmojiFallback" class="food-tile-icon__emoji">{{ fallbackIcon }}</text>
<image
v-if="src && !imageFailed"
class="food-tile-icon__img"
:src="src"
mode="aspectFit"
:mode="imageMode"
@error="onImageError"
/>
</view>
@@ -27,12 +30,20 @@ const fallbackIcon = computed(() => {
return '🍽'
})
const hasCustomImg = computed(() => typeof props.food === 'object' && !!props.food?.img)
const src = computed(() => {
if (typeof props.food === 'object' && props.food?.img) return props.food.img
if (typeof props.food === 'object' && props.food?.icon) return getFoodImgUrl(props.food.icon)
return ''
})
/** 有可用图片时不渲染 emoji,避免 PNG 透明区域露出底层符号 */
const showEmojiFallback = computed(() => !src.value || imageFailed.value)
/** 自定义素材用 aspectFill 铺满,减少边缘露底 */
const imageMode = computed(() => (hasCustomImg.value ? 'aspectFill' : 'aspectFit'))
watch(
() => src.value,
() => {
@@ -71,6 +82,11 @@ function onImageError() {
z-index: 2;
}
.food-tile-icon--tile.is-custom-img .food-tile-icon__img {
width: 82%;
height: 82%;
}
/* 玻璃格上的食材图片:投影增强立体感(参考稿) */
.food-tile-icon--tile .food-tile-icon__img {
filter: drop-shadow(0 6rpx 10rpx rgba(0, 0, 0, 0.16));
@@ -109,6 +125,11 @@ function onImageError() {
font-size: 34rpx;
}
.food-tile-icon--goal.is-custom-img .food-tile-icon__img,
.food-tile-icon--tooltip.is-custom-img .food-tile-icon__img {
border-radius: 8rpx;
}
.food-tile-icon__img {
position: absolute;
left: 0;
+20 -4
View File
@@ -63,7 +63,15 @@ const FOOD_TIP_OVERRIDE = {
grainMantou: '杂粮做的,比白馒头稳,仍要控量',
riceNoodleSoup: '米粉升糖快,汤粉要少吃',
friedNoodles: '油多又是精面,升糖快',
centuryEggCongee: '白粥熬得软烂,升糖很快,糖友要少喝'
centuryEggCongee: '白粥熬得软烂,升糖很快,糖友要少喝',
sandwich: '夹心面包精制碳多,升糖快',
eightTreasureCongee: '八宝粥甜糯,升糖快,要少喝',
milletCongee: '小米粥熬软升糖快,糖友控量',
shandongPancake: '煎饼加油面,升糖中等偏快',
cake: '蛋糕又甜又油,升糖快',
biscuit: '饼干又甜又碎,升糖快',
wonton: '馄饨面皮精白,升糖中等,别多吃',
blackCoffee: '不加糖的黑咖啡,升糖很低'
}
/** 含糖等级通用科普文案 */
@@ -109,7 +117,7 @@ const RAW_FOODS = [
// 中 GI
{ key: 'brownRice', icon: '🍙', name: '糙米饭', gi: 'mid', val: 4, color: '#C9A26B' },
{ key: 'sweetPotato', icon: '🍠', name: '番薯', gi: 'mid', val: 5, color: '#C75B39' },
{ key: 'sweetPotato', icon: '🍠', name: '番薯', gi: 'mid', val: 5, color: '#C75B39', img: `${GAMES_IMG_BASE}/%E7%95%AA%E8%96%AF.png` },
{ key: 'taro', icon: '🥔', name: '芋头', gi: 'mid', val: 5, color: '#B39DDB' },
{ key: 'fish', icon: '🐟', name: '鱼肉', gi: 'mid', val: 2, color: '#4FA3C7' },
{ key: 'chicken', icon: '🍗', name: '鸡肉', gi: 'mid', val: 3, color: '#D6A15A' },
@@ -126,16 +134,24 @@ const RAW_FOODS = [
{ key: 'wine', icon: '🍷', name: '红酒', gi: 'mid', val: 4, color: '#9B2C3B' },
{ key: 'beer', icon: '🍺', name: '啤酒', gi: 'mid', val: 5, color: '#E0A83E' },
{ key: 'coffee', icon: '☕', name: '咖啡', gi: 'mid', val: 2, color: '#6F4E37' },
{ key: 'blackCoffee', icon: '☕', name: '黑咖啡', gi: 'low', val: -1, color: '#4E342E', img: `${GAMES_IMG_BASE}/%E9%BB%91%E5%92%96%E5%95%A1.png` },
{ key: 'wonton', icon: '🥟', name: '馄饨', gi: 'mid', val: 5, color: '#E8DCC8', img: `${GAMES_IMG_BASE}/%E9%A6%84%E9%A5%A8.png` },
{ key: 'milletCongee', icon: '🥣', name: '小米粥', gi: 'mid', val: 5, color: '#F5E6B8', img: `${GAMES_IMG_BASE}/%E5%B0%8F%E7%B1%B3%E7%B2%A5.png` },
{ key: 'shandongPancake', icon: '🥞', name: '山东煎饼', gi: 'high', val: 16, color: '#D4A574', img: `${GAMES_IMG_BASE}/%E5%B1%B1%E4%B8%9C%E7%85%8E%E9%A5%BC.png` },
{ key: 'corn', icon: '🌽', name: '玉米', gi: 'mid', val: 5, color: '#F9C513' },
{ key: 'udon', icon: '🍲', name: '乌冬面', gi: 'mid', val: 5, color: '#D9B88F' },
{ key: 'milkOats', icon: '🥣', name: '奶冲麦片', gi: 'mid', val: 6, color: '#E8D9B5', img: `${GAMES_IMG_BASE}/%E5%A5%B6%E5%86%B2%E9%BA%A6%E7%89%87.png` },
{ key: 'grainMantou', icon: '🫓', name: '杂粮馒头', gi: 'mid', val: 5, color: '#C8A878', img: `${GAMES_IMG_BASE}/%E6%9D%82%E7%B2%AE%E9%A6%92%E5%A4%B4.png` },
// 高 GI
{ key: 'whiteRice', icon: '🍚', name: '白米饭', gi: 'high', val: 18, color: '#E0E0E0' },
{ key: 'whiteRice', icon: '🍚', name: '白米饭', gi: 'high', val: 18, color: '#E0E0E0', img: `${GAMES_IMG_BASE}/%E7%B1%B3%E9%A5%AD.png` },
{ key: 'whiteBread', icon: '🍞', name: '白面包', gi: 'high', val: 16, color: '#D9A85C' },
{ key: 'mantou', icon: '🥯', name: '馒头', gi: 'high', val: 17, color: '#ECE0C8' },
{ key: 'youtiao', icon: '🥖', name: '油条', gi: 'high', val: 20, color: '#D4943F' },
{ key: 'youtiao', icon: '🥖', name: '油条', gi: 'high', val: 20, color: '#D4943F', img: `${GAMES_IMG_BASE}/%E6%B2%B9%E6%9D%A1.png` },
{ key: 'sandwich', icon: '🥪', name: '三明治', gi: 'high', val: 16, color: '#D9B88F', img: `${GAMES_IMG_BASE}/%E4%B8%89%E6%98%8E%E6%B2%BB.png` },
{ key: 'biscuit', icon: '🍪', name: '饼干', gi: 'high', val: 18, color: '#C9A063', img: `${GAMES_IMG_BASE}/%E9%A5%BC%E5%B9%B2.png` },
{ key: 'cake', icon: '🎂', name: '蛋糕', gi: 'high', val: 22, color: '#F48FB1', img: `${GAMES_IMG_BASE}/%E8%9B%8B%E7%B3%95.png` },
{ key: 'eightTreasureCongee', icon: '🥣', name: '八宝粥', gi: 'high', val: 17, color: '#E8C4A0', img: `${GAMES_IMG_BASE}/%E5%85%AB%E5%AE%9D%E7%B2%A5.png` },
{ key: 'donut', icon: '🍩', name: '甜甜圈', gi: 'high', val: 22, color: '#E87FB0' },
{ key: 'popcorn', icon: '🍿', name: '爆米花', gi: 'high', val: 18, color: '#F5E6B3' },
{ key: 'ramen', icon: '🍜', name: '拉面', gi: 'high', val: 17, color: '#E0A96D' },
+19 -2
View File
@@ -4,10 +4,27 @@
*/
/**
* 指定关卡固定食材(按 key)。未配置的关卡走随机抽取逻辑。
* 第一关固定为这 5 种带真实图片素材的食物。
* 第一关固定为这 16 种带真实图片素材的食物。
*/
const FIXED_LEVEL_FOODS = {
1: ['milkOats', 'grainMantou', 'riceNoodleSoup', 'friedNoodles', 'centuryEggCongee']
1: [
'sandwich',
'eightTreasureCongee',
'milkOats',
'milletCongee',
'shandongPancake',
'grainMantou',
'riceNoodleSoup',
'youtiao',
'friedNoodles',
'sweetPotato',
'centuryEggCongee',
'whiteRice',
'cake',
'biscuit',
'wonton',
'blackCoffee'
]
}
export function getLevelConfig(levelId) {
+261 -56
View File
@@ -54,27 +54,6 @@
</view>
</view>
<view class="mc-quick-grid">
<view class="mc-quick-item" @click="openInputForm()">
<TongjiIcon name="glucose" size="md" color="#006c49" />
<text class="mc-quick-label">血糖</text>
</view>
<view class="mc-quick-item" @click="openBpForm()">
<TongjiIcon name="activity" size="md" color="#006c49" />
<text class="mc-quick-label">血压</text>
</view>
<view class="mc-quick-item" @click="openDietForm()">
<TongjiIcon name="utensils" size="md" color="#006c49" />
<text class="mc-quick-label">饮食</text>
</view>
<view class="mc-quick-item" @click="openExerciseForm()">
<TongjiIcon name="flame" size="md" color="#006c49" />
<text class="mc-quick-label">运动</text>
</view>
</view>
<view class="mc-tasks-section">
<text class="mc-tasks-title">今日控糖任务</text>
<view class="mc-tasks-grid">
@@ -117,6 +96,10 @@
<view class="mc-tree-level-row">
<text class="mc-tree-lv-badge">Lv.{{ treeLevel }}</text>
<text class="mc-tree-stage-name">{{ treeLevelName }}</text>
<view class="mc-tree-species-btn" @click.stop="openTreeSpeciesPicker">
<text>{{ currentTreeSpecies.name }}</text>
<TongjiIcon name="chevron-right" size="sm" color="#006c49" />
</view>
</view>
<text v-if="treeState.isMax" class="mc-tree-xp-hint">{{ treeXpUpgradeHint }}</text>
<text v-else class="mc-tree-xp-hint">
@@ -161,7 +144,7 @@
<button
class="mc-water-btn"
:class="{ disabled: treeWaterLocked }"
@click="waterTree"
@click="onWaterTreeTap"
>
<TongjiIcon name="droplet" size="md" color="#FFFFFF" />
<text>给小树浇水</text>
@@ -841,6 +824,100 @@
</view>
</view>
</view>
<!-- 今日任务 / 浇水状态 -->
<view v-if="waterStatusVisible" class="input-modal-mask" @click="closeWaterStatusPanel">
<view class="mc-water-panel" @click.stop>
<view class="mc-water-panel-head">
<text class="mc-water-panel-title">今日稳糖进度</text>
<view class="mc-water-panel-close" @click="closeWaterStatusPanel">
<text>×</text>
</view>
</view>
<text class="mc-water-panel-summary">{{ waterStatusSummary }}</text>
<view class="mc-water-panel-list">
<view
v-for="row in waterStatusRows"
:key="row.id"
class="mc-water-panel-row"
:class="'is-' + row.statusKind"
>
<TongjiIcon :name="gamifyTaskIcon(row.id)" size="sm" color="#006c49" />
<view class="mc-water-panel-row-main">
<text class="mc-water-panel-row-name">{{ row.name }}</text>
<text class="mc-water-panel-row-sub">{{ row.statusSub }}</text>
</view>
<text class="mc-water-panel-row-tag">{{ row.statusLabel }}</text>
</view>
</view>
<view v-if="waterStatusGrowthHint" class="mc-water-panel-growth">
<text>{{ waterStatusGrowthHint }}</text>
</view>
<view class="mc-water-panel-actions">
<button
v-if="canWaterTree"
class="mc-water-panel-btn primary"
@click="waterTreeFromPanel"
>
立即浇水领 {{ claimablePoints }} 积分
</button>
<button
v-else-if="firstPendingDisplayTask"
class="mc-water-panel-btn"
@click="goRecordFromWaterPanel"
>
去记录{{ firstPendingDisplayTask.name }}
</button>
<button v-else class="mc-water-panel-btn ghost" @click="closeWaterStatusPanel">
知道了
</button>
</view>
</view>
</view>
<!-- 树种选择 -->
<view v-if="treeSpeciesPickerVisible" class="input-modal-mask" @click="closeTreeSpeciesPicker">
<view class="mc-species-panel" @click.stop>
<view class="mc-water-panel-head">
<text class="mc-water-panel-title">选择你的稳糖树</text>
<view class="mc-water-panel-close" @click="closeTreeSpeciesPicker">
<text>×</text>
</view>
</view>
<text class="mc-species-panel-tip">
全勤打卡约 {{ speciesDaysToMax }} 天满级 · 每级 {{ TREE_XP_PER_LEVEL }} 积分 · 从小到大的 10 个阶段见下方
</text>
<scroll-view scroll-y class="mc-species-scroll">
<view
v-for="sp in TREE_SPECIES_LIST"
:key="sp.id"
class="mc-species-card"
:class="{ active: treeSpeciesId === sp.id }"
@click="selectTreeSpecies(sp.id)"
>
<view class="mc-species-card-head">
<text class="mc-species-card-emoji">{{ speciesEmojiAtLevel(sp.id, treeLevel) }}</text>
<view>
<text class="mc-species-card-name">{{ sp.name }}</text>
<text class="mc-species-card-tagline">{{ sp.tagline }}</text>
</view>
</view>
<view class="mc-species-stages">
<view
v-for="stage in speciesGrowthRoadmap(sp.id)"
:key="stage.level"
class="mc-species-stage"
:class="{ reached: treeLevel >= stage.level, current: treeLevel === stage.level }"
>
<text class="mc-species-stage-emoji">{{ stage.emoji }}</text>
<text class="mc-species-stage-lv">Lv.{{ stage.level }}</text>
<text class="mc-species-stage-name">{{ stage.name }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
</block>
</view>
</template>
@@ -852,7 +929,15 @@ import SugarTreeGraphic from '../components/SugarTreeGraphic.vue'
import TongjiIcon from '../components/TongjiIcon.vue'
import CelebrateBurst from '../components/CelebrateBurst.vue'
import VoiceSpeakMascot from '../components/VoiceSpeakMascot.vue'
import { TREE_LEVELS, TREE_MAX_LEVEL, calcTreeFromPoints, pickTreeWhisper } from '../utils/treeLevels.js'
import { TREE_LEVELS, TREE_MAX_LEVEL, TREE_XP_PER_LEVEL, calcTreeFromPoints, pickTreeWhisper } from '../utils/treeLevels.js'
import {
TREE_SPECIES_LIST,
DAILY_MAX_TASK_POINTS,
getTreeSpecies,
speciesEmojiAtLevel,
speciesGrowthRoadmap,
estimateDaysToMaxLevel
} from '../utils/treeSpecies.js'
import { consumeDietAiPrefill } from '../composables/useDietAi.js'
import { useSpeechToText, isBenignSttError } from '../composables/useSpeechToText.js'
import { parseGlucose, parseBloodPressure, parseDiet, parseExercise, summarizeParsed, normalizeNumbers } from '../utils/voiceParse.js'
@@ -1461,6 +1546,7 @@ async function applyCard(card) {
recent: []
}
gamifyStatusTip.value = ''
loadTreeSpecies()
await fetchGamifyState()
}
@@ -1878,6 +1964,9 @@ async function openInputForm() {
inputForm.value.postprandial_blood_sugar = numToStr(r.postprandial_blood_sugar)
inputForm.value.other_blood_sugar = numToStr(r.other_blood_sugar)
inputForm.value.remark = String(r.remark || '')
if (todayHasGlucose.value) {
showUserToast('今日血糖已录入,如需修改可在下方调整', { duration: 2600 })
}
}
} catch (e) {
/* 静默:拉取失败时仍允许新增录入 */
@@ -1918,7 +2007,7 @@ async function submitInputForm() {
}
}, false)
if (res && res.code === 1) {
showUserToast(res.msg || '已保存', { icon: 'success' })
showUserToast(res.msg || '今日血糖已录入完成', { icon: 'success' })
inputForm.value.visible = false
await fetchAll()
await fetchGamifyState()
@@ -2004,6 +2093,9 @@ async function openBpForm() {
bpForm.value.western_medicine = String(r.western_medicine || '')
bpForm.value.insulin = String(r.insulin || '')
bpForm.value.remark = String(r.remark || '')
if (todayHasBpPressure.value) {
showUserToast('今日血压已录入,如需修改可在下方调整', { duration: 2600 })
}
}
} catch (e) {
//
@@ -2084,6 +2176,9 @@ async function openDietForm(options = {}) {
dietForm.value.lunch_foods = String(r.lunch_foods || '')
dietForm.value.dinner_foods = String(r.dinner_foods || '')
dietForm.value.note = String(r.note || '')
if (todayHasDiet.value) {
showUserToast('今日饮食已录入,如需修改可在下方调整', { duration: 2600 })
}
}
} catch (e) {}
finally {
@@ -2123,7 +2218,7 @@ async function submitDietForm() {
}
}, false)
if (res?.code === 1) {
showUserToast(res.msg || '已保存', { icon: 'success' })
showUserToast(res.msg || '今日饮食已录入完成', { icon: 'success' })
dietForm.value.visible = false
await fetchAll()
await fetchGamifyState()
@@ -2822,6 +2917,9 @@ async function openExerciseForm() {
exerciseForm.value.duration = numToStr(r.duration)
exerciseForm.value.intensity = Number(r.intensity) || 2
exerciseForm.value.note = String(r.note || '')
if (todayHasExercise.value) {
showUserToast('今日运动已录入,如需修改可在下方调整', { duration: 2600 })
}
}
} catch (e) {}
finally {
@@ -2857,7 +2955,7 @@ async function submitExerciseForm() {
}
}, false)
if (res?.code === 1) {
showUserToast(res.msg || '已保存', { icon: 'success' })
showUserToast(res.msg || '今日运动已录入完成', { icon: 'success' })
exerciseForm.value.visible = false
await fetchAll()
await fetchGamifyState()
@@ -2901,7 +2999,7 @@ async function submitBpForm() {
}
}, false)
if (res && res.code === 1) {
showUserToast(res.msg || '已保存', { icon: 'success' })
showUserToast(res.msg || '今日血压已录入完成', { icon: 'success' })
bpForm.value.visible = false
await fetchAll()
await fetchGamifyState()
@@ -3951,16 +4049,16 @@ const firstPendingDisplayTask = computed(() => {
const waterHintText = computed(() => {
if (canWaterTree.value) {
return `${claimablePoints.value} 积分待领,点击「给小树浇水」`
return `${claimablePoints.value} 积分待领,点击浇水查看进度并领取`
}
if (displayTasksAllCleared.value) {
return '今日任务已全部完成,小树喝饱啦 🌱'
}
const pending = firstPendingDisplayTask.value
if (pending) {
return `先完成${pending.name},再给小树浇水领积分`
return `还差${pending.name}等项,点击浇水查看今日进度`
}
return '先完成今日任务,再给小树浇水领积分'
return '点击浇水查看今日任务进度'
})
const allTasksCleared = computed(() => {
@@ -4145,7 +4243,7 @@ function onInviteTreeWhisper() {
function promptWaterTreeGuide(taskLabel = '', delayMs = 900) {
setTimeout(() => {
if (claimablePoints.value > 0) {
const prefix = taskLabel ? `${taskLabel}录,` : ''
const prefix = taskLabel ? `今日${taskLabel}已录入完成` : '今日已录入完成,'
showUserToast(
`${prefix}点击「给小树浇水」领取 ${claimablePoints.value} 积分`,
{ duration: 3200 }
@@ -4154,7 +4252,7 @@ function promptWaterTreeGuide(taskLabel = '', delayMs = 900) {
}
const pending = firstPendingDisplayTask.value
if (pending) {
const prefix = taskLabel ? `${taskLabel}录。` : ''
const prefix = taskLabel ? `今日${taskLabel}已录入完成` : '今日该项已录入完成。'
showUserToast(
`${prefix}继续完成「${pending.name}」,再给小树浇水领积分`,
{ duration: 3200 }
@@ -4170,23 +4268,7 @@ function promptWaterTreeGuide(taskLabel = '', delayMs = 900) {
async function waterTree() {
if (!diagnosisId.value) return
if (treeWaterLocked.value) return
// 有可领取积分 → 正常浇水
if (!canWaterTree.value) {
if (displayTasksAllCleared.value) {
showUserToast('今日控糖任务已全部完成,小树喝饱啦,明天继续加油 🌱', { duration: 2800 })
hapticLight()
return
}
const pending = firstPendingDisplayTask.value
if (pending) {
showUserToast(`先完成「${pending.name}」,再给小树浇水领积分`, { duration: 2800 })
onGamifyTaskTap(pending, { skipGuide: true })
return
}
showUserToast('暂无可领取积分,请先完成任务再给小树浇水', { duration: 2800 })
return
}
if (!canWaterTree.value) return
const oldLevel = treeLevel.value
treeWaterLocked.value = true
@@ -4218,6 +4300,7 @@ async function waterTree() {
if (addedPoints > 0) {
showFloatReward(addedPoints)
hapticLight()
closeWaterStatusPanel()
triggerCelebrate('浇水成功', tip || `+${addedPoints} 稳糖积分,小树咕咚喝饱啦`)
const claimedIds = Array.isArray(data.claimed_tasks) ? data.claimed_tasks : []
claimedIds.forEach((id) => popTaskDone(id))
@@ -4274,8 +4357,126 @@ const treeState = computed(() => calcTreeFromPoints(gamifyPoints.value))
const treeLevel = computed(() => treeState.value.level)
const treeProgress = computed(() => treeState.value.progress)
const treeLevelName = computed(() => treeState.value.name)
const treeEmoji = computed(() => treeState.value.emoji)
const treeEmoji = computed(() => speciesEmojiAtLevel(treeSpeciesId.value, treeLevel.value))
const treeMood = computed(() => treeState.value.mood)
// ===== =====
const treeSpeciesId = ref('classic')
const treeSpeciesPickerVisible = ref(false)
const speciesDaysToMax = estimateDaysToMaxLevel()
const currentTreeSpecies = computed(() => getTreeSpecies(treeSpeciesId.value))
function treeSpeciesStorageKey(dId = diagnosisId.value) {
return `tongji_tree_species_v1_${Number(dId) || 0}`
}
function loadTreeSpecies(dId = diagnosisId.value) {
if (!dId) {
treeSpeciesId.value = 'classic'
return
}
try {
const saved = uni.getStorageSync(treeSpeciesStorageKey(dId))
if (saved && getTreeSpecies(saved)) {
treeSpeciesId.value = saved
return
}
} catch (e) {}
treeSpeciesId.value = 'classic'
}
function selectTreeSpecies(id) {
if (!getTreeSpecies(id)) return
treeSpeciesId.value = id
try {
uni.setStorageSync(treeSpeciesStorageKey(), id)
} catch (e) {}
treeSpeciesPickerVisible.value = false
showUserToast(`已切换为「${getTreeSpecies(id).name}`, { duration: 2200 })
hapticLight()
}
function openTreeSpeciesPicker() {
treeSpeciesPickerVisible.value = true
}
function closeTreeSpeciesPicker() {
treeSpeciesPickerVisible.value = false
}
// ===== / =====
const waterStatusVisible = ref(false)
const waterStatusRows = computed(() => {
return displayTasksForUi.value.map((t) => {
let statusLabel = '未记录'
let statusKind = 'pending'
let statusSub = '今日尚未打开此项'
if (t.claimed) {
statusLabel = '已完成'
statusKind = 'claimed'
statusSub = '今日已录入,积分已领取'
} else if (t.completed) {
statusLabel = '待浇水'
statusKind = 'water'
statusSub = `今日已录入,浇水可领 +${t.points} 积分`
}
return { ...t, statusLabel, statusKind, statusSub }
})
})
const waterStatusSummary = computed(() => {
if (displayTasksAllCleared.value) {
return '今日四项任务已全部完成并领取,小树喝饱啦,明天继续加油 🌱'
}
const pending = displayTasksForUi.value.filter((t) => !t.completed)
const toWater = displayTasksForUi.value.filter((t) => t.completed && !t.claimed)
if (canWaterTree.value) {
return `今日已打开 ${toWater.length} 项记录,浇水可领 ${claimablePoints.value} 积分`
}
if (pending.length) {
return `今日还差 ${pending.length} 项未打开:${pending.map((t) => t.name).join('、')}`
}
return '先完成今日记录,再给小树浇水领积分'
})
const waterStatusGrowthHint = computed(() => {
const sp = currentTreeSpecies.value
if (treeState.value.isMax) {
return `${sp.name} 已满级 · 继续打卡巩固习惯`
}
return `${sp.name} · Lv.${treeLevel.value} ${treeLevelName.value} · 再积 ${treeState.value.pointsToNext} 分升级「${treeState.value.nextName}`
})
function onWaterTreeTap() {
if (treeWaterLocked.value) return
openWaterStatusPanel()
hapticLight()
}
function openWaterStatusPanel() {
waterStatusVisible.value = true
}
function closeWaterStatusPanel() {
waterStatusVisible.value = false
}
function waterTreeFromPanel() {
closeWaterStatusPanel()
waterTree()
}
function goRecordFromWaterPanel() {
const pending = firstPendingDisplayTask.value
if (!pending) {
closeWaterStatusPanel()
return
}
closeWaterStatusPanel()
onGamifyTaskTap(pending, { skipGuide: true })
}
const treeProgressLabel = computed(() => {
if (treeState.value.isMax) return '已满级 · 圆满树'
return `${treeState.value.xpInLevel}/${treeState.value.xpNeed} XP`
@@ -4451,6 +4652,14 @@ function onGamifyTaskTap(task, options = {}) {
if (!task) return
const skipGuide = !!options.skipGuide
if (!task.completed) {
if (resolveGamifyTaskCompleted(task.id, task.completed)) {
showUserToast(
`今日「${task.name}」已录入${task.claimed ? ',积分已领取' : ',请去浇水领积分'}`,
{ duration: 2800 }
)
if (!task.claimed) openWaterStatusPanel()
return
}
if (!diagnosisId.value) {
showUserToast('请先选择就诊卡')
return
@@ -4487,14 +4696,10 @@ function onGamifyTaskTap(task, options = {}) {
return
}
if (task.claimed) {
showUserToast('该任务积分已领取')
showUserToast(`今日「${task.name}」已录入,积分已领取`)
return
}
if (!canWaterTree.value) {
showUserToast('暂无可领取积分,请先完成其他任务', { duration: 2800 })
return
}
waterTree()
openWaterStatusPanel()
}
function shareInviteCacheKey() {
+258 -1
View File
@@ -639,7 +639,8 @@
}
.more-page .mc-tasks-section {
margin-top: 40rpx;
margin-top: 0;
margin-bottom: 32rpx;
}
.more-page .mc-tasks-grid {
@@ -1167,3 +1168,259 @@
font-size: 22rpx;
color: rgba(108, 122, 113, 0.6);
}
.more-page .mc-tree-species-btn {
display: inline-flex;
align-items: center;
gap: 4rpx;
margin-left: 8rpx;
padding: 4rpx 12rpx;
border-radius: 999rpx;
background: rgba(0, 108, 73, 0.08);
font-size: 20rpx;
font-weight: 700;
color: var(--primary);
}
.more-page .mc-water-panel,
.more-page .mc-species-panel {
width: calc(100% - 48rpx);
max-width: 680rpx;
margin: 0 auto;
position: absolute;
left: 0;
right: 0;
bottom: 0;
max-height: 78vh;
background: var(--surface);
border-radius: 32rpx 32rpx 0 0;
padding: 32rpx 28rpx calc(32rpx + env(safe-area-inset-bottom));
box-shadow: 0 -12rpx 40rpx rgba(15, 23, 42, 0.12);
}
.more-page .mc-water-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.more-page .mc-water-panel-title {
font-size: 34rpx;
font-weight: 800;
color: var(--on-surface);
}
.more-page .mc-water-panel-close {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: var(--surface-container-low);
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: var(--outline);
}
.more-page .mc-water-panel-summary {
display: block;
font-size: 26rpx;
line-height: 1.5;
color: var(--on-surface-variant);
margin-bottom: 24rpx;
}
.more-page .mc-water-panel-list {
display: flex;
flex-direction: column;
gap: 12rpx;
margin-bottom: 20rpx;
}
.more-page .mc-water-panel-row {
display: flex;
align-items: center;
gap: 16rpx;
padding: 18rpx 16rpx;
border-radius: 20rpx;
background: var(--surface-container-low);
border: 1rpx solid rgba(0, 108, 73, 0.08);
}
.more-page .mc-water-panel-row.is-water {
border-color: rgba(0, 108, 73, 0.22);
background: rgba(0, 108, 73, 0.06);
}
.more-page .mc-water-panel-row.is-claimed {
opacity: 0.72;
}
.more-page .mc-water-panel-row-main {
flex: 1;
min-width: 0;
}
.more-page .mc-water-panel-row-name {
display: block;
font-size: 28rpx;
font-weight: 700;
color: var(--on-surface);
}
.more-page .mc-water-panel-row-sub {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
color: var(--outline);
}
.more-page .mc-water-panel-row-tag {
flex-shrink: 0;
font-size: 22rpx;
font-weight: 700;
color: var(--primary);
}
.more-page .mc-water-panel-row.is-pending .mc-water-panel-row-tag {
color: #b45309;
}
.more-page .mc-water-panel-growth {
padding: 16rpx 18rpx;
border-radius: 16rpx;
background: rgba(0, 108, 73, 0.06);
margin-bottom: 20rpx;
font-size: 24rpx;
line-height: 1.45;
color: var(--primary);
}
.more-page .mc-water-panel-actions {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.more-page .mc-water-panel-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
border-radius: 999rpx;
font-size: 28rpx;
font-weight: 700;
background: var(--on-surface);
color: var(--surface);
border: none;
}
.more-page .mc-water-panel-btn.primary {
background: var(--primary);
color: var(--on-primary);
}
.more-page .mc-water-panel-btn.ghost {
background: var(--surface-container-low);
color: var(--on-surface);
}
.more-page .mc-species-panel-tip {
display: block;
font-size: 22rpx;
line-height: 1.45;
color: var(--outline);
margin-bottom: 20rpx;
}
.more-page .mc-species-scroll {
max-height: 56vh;
}
.more-page .mc-species-card {
padding: 20rpx;
border-radius: 24rpx;
border: 2rpx solid rgba(0, 108, 73, 0.1);
background: var(--surface-container-low);
margin-bottom: 16rpx;
}
.more-page .mc-species-card.active {
border-color: var(--primary);
background: rgba(0, 108, 73, 0.06);
}
.more-page .mc-species-card-head {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
}
.more-page .mc-species-card-emoji {
font-size: 56rpx;
line-height: 1;
}
.more-page .mc-species-card-name {
display: block;
font-size: 30rpx;
font-weight: 800;
color: var(--on-surface);
}
.more-page .mc-species-card-tagline {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
color: var(--outline);
}
.more-page .mc-species-stages {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
}
.more-page .mc-species-stage {
width: calc(20% - 8rpx);
min-width: 96rpx;
padding: 10rpx 6rpx;
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.7);
text-align: center;
opacity: 0.45;
}
.more-page .mc-species-stage.reached {
opacity: 1;
}
.more-page .mc-species-stage.current {
box-shadow: 0 0 0 2rpx var(--primary);
background: rgba(0, 108, 73, 0.1);
}
.more-page .mc-species-stage-emoji {
display: block;
font-size: 32rpx;
line-height: 1.1;
}
.more-page .mc-species-stage-lv {
display: block;
font-size: 18rpx;
font-weight: 700;
color: var(--primary);
margin-top: 4rpx;
}
.more-page .mc-species-stage-name {
display: block;
font-size: 18rpx;
color: var(--outline);
margin-top: 2rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@@ -0,0 +1,75 @@
import { TREE_LEVELS, TREE_MAX_LEVEL, TREE_XP_PER_LEVEL } from './treeLevels.js'
/** 可选树种:每级 emoji 与 treeLevels.js Lv.09 一一对应 */
export const TREE_SPECIES_LIST = [
{
id: 'classic',
name: '稳糖灵树',
tagline: '经典路线,从种子到圆满',
stages: TREE_LEVELS.map((l) => l.emoji)
},
{
id: 'bonsai',
name: '雅韵盆景',
tagline: '小巧精致,书桌旁的温柔陪伴',
stages: ['🫘', '🌱', '🪴', '🎋', '🪴', '🌳', '🎍', '🌸', '🏵️', '🏆']
},
{
id: 'pine',
name: '苍劲青松',
tagline: '挺拔向上,越记越稳',
stages: ['🫘', '🌱', '🌿', '🌲', '🌲', '🌲', '⛰️', '🌲', '🏔️', '🏆']
},
{
id: 'sakura',
name: '樱花小树',
tagline: '坚持打卡,枝头渐开',
stages: ['🫘', '🌱', '🌿', '🌳', '🌸', '🌸', '🌸', '🌸', '🌺', '🏆']
},
{
id: 'ginkgo',
name: '金秋银杏',
tagline: '叶色渐变,见证每一天',
stages: ['🫘', '🌱', '🍃', '🌳', '🍂', '🍂', '🌳', '🍁', '🌟', '🏆']
}
]
export const TREE_SPECIES_STORAGE_KEY = 'tongji_tree_species_v1'
/** 四项任务全勤时每日最多可领积分(与后端 taskDefs 一致) */
export const DAILY_MAX_TASK_POINTS = 40
export function getTreeSpecies(id) {
return TREE_SPECIES_LIST.find((s) => s.id === id) || TREE_SPECIES_LIST[0]
}
export function speciesEmojiAtLevel(speciesId, level) {
const sp = getTreeSpecies(speciesId)
const lv = Math.min(TREE_MAX_LEVEL, Math.max(0, Number(level) || 0))
return sp.stages[lv] || sp.stages[0] || '🌱'
}
/** 当前树种成长路线图(供选择面板展示) */
export function speciesGrowthRoadmap(speciesId) {
const sp = getTreeSpecies(speciesId)
return TREE_LEVELS.map((meta, i) => {
const xpTotal = i * TREE_XP_PER_LEVEL
const daysHint = i === 0
? 0
: Math.max(1, Math.ceil(xpTotal / DAILY_MAX_TASK_POINTS))
return {
level: i,
name: meta.name,
emoji: sp.stages[i] || meta.emoji,
desc: meta.desc,
xpTotal,
daysHint
}
})
}
/** 全勤打卡大约多少天满级 */
export function estimateDaysToMaxLevel() {
const totalXp = TREE_MAX_LEVEL * TREE_XP_PER_LEVEL
return Math.max(1, Math.ceil(totalXp / DAILY_MAX_TASK_POINTS))
}