This commit is contained in:
Your Name
2026-05-27 17:29:27 +08:00
parent 19af50c344
commit 36be8fedad
5 changed files with 519 additions and 325 deletions
@@ -1,16 +1,24 @@
<template> <template>
<view class="sugar-tree-graphic" :class="[`lv-${clampedLevel}`, `size-${size}`, { watering: watering }]"> <view
class="sugar-tree-graphic"
:class="[`lv-${clampedLevel}`, `tier-${visualTier}`, `size-${size}`, { watering: watering, 'is-max': clampedLevel >= MAX_LEVEL }]"
>
<view class="stg-glow" /> <view class="stg-glow" />
<view v-if="clampedLevel >= 7" class="stg-aura" />
<image v-if="!treeUseFallback" class="stg-image" :src="treeImageSrc" mode="aspectFit" @error="treeUseFallback = true" /> <image v-if="!treeUseFallback" class="stg-image" :src="treeImageSrc" mode="aspectFit" @error="treeUseFallback = true" />
<text v-else class="stg-emoji">{{ treeEmoji }}</text> <text v-else class="stg-emoji">{{ treeEmoji }}</text>
<view v-if="clampedLevel >= 4" class="stg-sparkle stg-sparkle-a" /> <view v-if="clampedLevel >= 7" class="stg-sparkle stg-sparkle-a" />
<view v-if="clampedLevel >= 4" class="stg-sparkle stg-sparkle-b" /> <view v-if="clampedLevel >= 8" class="stg-sparkle stg-sparkle-b" />
<view v-if="clampedLevel >= MAX_LEVEL" class="stg-sparkle stg-sparkle-c" />
</view> </view>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { svgToDataUrl } from '../utils/svgDataUrl.js' import { svgToDataUrl } from '../utils/svgDataUrl.js'
import { TREE_MAX_LEVEL, TREE_LEVELS } from '../utils/treeLevels.js'
const MAX_LEVEL = TREE_MAX_LEVEL
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
const treeUseFallback = ref(true) const treeUseFallback = ref(true)
@@ -19,18 +27,24 @@ const treeUseFallback = ref(true)
const treeUseFallback = ref(false) const treeUseFallback = ref(false)
// #endif // #endif
const TREE_EMOJI = ['🌱', '🌿', '🌳', '🌸', '🌺']
const props = defineProps({ const props = defineProps({
level: { type: Number, default: 0 }, level: { type: Number, default: 0 },
size: { type: String, default: 'md' }, size: { type: String, default: 'md' },
watering: { type: Boolean, default: false } watering: { type: Boolean, default: false }
}) })
const clampedLevel = computed(() => Math.min(4, Math.max(0, Number(props.level) || 0))) const clampedLevel = computed(() => Math.min(MAX_LEVEL, Math.max(0, Number(props.level) || 0)))
const treeEmoji = computed(() => TREE_EMOJI[clampedLevel.value] || TREE_EMOJI[0]) const visualTier = computed(() => {
const lv = clampedLevel.value
if (lv <= 0) return 0
if (lv <= 2) return 1
if (lv <= 4) return 2
if (lv <= 6) return 3
if (lv <= 8) return 4
return 5
})
const treeEmoji = computed(() => (TREE_LEVELS[clampedLevel.value] || TREE_LEVELS[0]).emoji)
/** 医疗青绿 + 柔和陶土盆,5 档成长态 */
function buildTreeSvg(level) { function buildTreeSvg(level) {
const pot = ` const pot = `
<ellipse cx="24" cy="50" rx="15" ry="3" fill="#0ea5a4" opacity="0.12"/> <ellipse cx="24" cy="50" rx="15" ry="3" fill="#0ea5a4" opacity="0.12"/>
@@ -40,63 +54,63 @@ function buildTreeSvg(level) {
` `
const soil = `<ellipse cx="24" cy="44.5" rx="8" ry="2.2" fill="#0d9488" opacity="0.18"/>` const soil = `<ellipse cx="24" cy="44.5" rx="8" ry="2.2" fill="#0d9488" opacity="0.18"/>`
if (level === 0) { if (level <= 0) {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 56" fill="none"> return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 56" fill="none">
${pot} ${pot}${soil}
${soil} <circle cx="24" cy="41.5" r="2.2" fill="#a8a29e" opacity="0.7"/>
<circle cx="24" cy="41.5" r="2" fill="#6ee7b7" opacity="0.55"/> <path d="M24 41.5v3.5" stroke="#78716c" stroke-width="1" stroke-linecap="round"/>
<path d="M24 41.5v4" stroke="#14b8a6" stroke-width="1.2" stroke-linecap="round" opacity="0.7"/>
</svg>` </svg>`
} }
const trunk = `<rect x="22.2" y="30" width="3.6" height="14" rx="1.8" fill="#78716C"/> const trunkH = level >= 6 ? 16 : level >= 3 ? 14 : 10
<rect x="22.6" y="30" width="2.8" height="14" rx="1.4" fill="#A8A29E" opacity="0.35"/>` const trunkY = 46 - trunkH
const trunk = `<rect x="22.2" y="${trunkY}" width="3.6" height="${trunkH}" rx="1.8" fill="#78716C"/>
<rect x="22.6" y="${trunkY + 1}" width="2.8" height="${trunkH - 1}" rx="1.4" fill="#A8A29E" opacity="0.35"/>`
const leaf = (cx, cy, r, fill, opacity = 1) => const leaf = (cx, cy, r, fill, opacity = 1) =>
`<circle cx="${cx}" cy="${cy}" r="${r}" fill="${fill}" opacity="${opacity}"/> `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${fill}" opacity="${opacity}"/>
<circle cx="${cx - r * 0.25}" cy="${cy - r * 0.2}" r="${r * 0.35}" fill="#ecfdf5" opacity="0.55"/>` <circle cx="${cx - r * 0.25}" cy="${cy - r * 0.2}" r="${r * 0.35}" fill="#ecfdf5" opacity="0.55"/>`
const blooms = const bloom =
level >= 4 level >= 7
? ` ? leaf(17, 18, 3, '#fda4af', 0.95) +
${leaf(17, 19, 3.2, '#fda4af', 0.95)} leaf(31, 17, 2.8, '#f9a8d4', 0.9) +
${leaf(30, 17, 3, '#f9a8d4', 0.9)} leaf(24, 13, 3.2, '#fb7185', 0.95) +
${leaf(24, 14, 3.4, '#fb7185', 0.95)} `<circle cx="24" cy="12" r="1.3" fill="#fef3c7"/>`
<circle cx="24" cy="14" r="1.2" fill="#fef3c7"/>
<circle cx="17" cy="19" r="0.9" fill="#fef3c7"/>
<circle cx="30" cy="17" r="0.8" fill="#fef3c7"/>
`
: '' : ''
const crown =
level >= 9
? leaf(12, 22, 6, '#34d399', 0.9) +
leaf(36, 22, 6, '#34d399', 0.9) +
leaf(24, 10, 7, '#10b981', 0.95) +
bloom
: bloom
let canopy = '' let canopy = ''
if (level === 1) { if (level === 1) {
canopy = leaf(24, 26, 7, '#34d399') + leaf(24, 25, 4.5, '#10b981', 0.85) canopy = leaf(24, 38, 4, '#6ee7b7') + `<path d="M24 38v-5" stroke="#14b8a6" stroke-width="1.2" stroke-linecap="round"/>`
} else if (level === 2) { } else if (level === 2) {
canopy = leaf(24, 32, 5.5, '#34d399') + leaf(20, 34, 3.5, '#6ee7b7', 0.9)
} else if (level <= 4) {
canopy = canopy =
leaf(24, 24, 8, '#34d399') + leaf(24, 28, 7, '#34d399') +
leaf(17, 27, 5.5, '#6ee7b7', 0.9) + leaf(17, 30, 5, '#6ee7b7', 0.9) +
leaf(31, 27, 5.5, '#6ee7b7', 0.9) leaf(31, 30, 5, '#6ee7b7', 0.9)
} else if (level === 3) { } else if (level <= 6) {
canopy = canopy =
leaf(24, 21, 10, '#22c55e') + leaf(24, 24, 9, '#22c55e') +
leaf(15, 25, 7, '#4ade80', 0.92) + leaf(14, 26, 7, '#4ade80', 0.92) +
leaf(33, 25, 7, '#4ade80', 0.92) + leaf(34, 26, 7, '#4ade80', 0.92) +
leaf(24, 15, 6, '#10b981', 0.88) leaf(24, 16, 6, '#10b981', 0.88)
} else { } else {
canopy = canopy =
leaf(24, 20, 11, '#059669') + leaf(24, 20, 10, '#059669') +
leaf(14, 24, 8, '#34d399', 0.95) + leaf(13, 24, 8, '#34d399', 0.95) +
leaf(34, 24, 8, '#34d399', 0.95) + leaf(35, 24, 8, '#34d399', 0.95) +
leaf(24, 13, 7.5, '#10b981', 0.9) + crown
blooms
} }
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 56" fill="none"> return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 56" fill="none">${pot}${soil}${trunk}${canopy}</svg>`
${pot}
${soil}
${trunk}
${canopy}
</svg>`
} }
const treeImageSrc = computed(() => svgToDataUrl(buildTreeSvg(clampedLevel.value))) const treeImageSrc = computed(() => svgToDataUrl(buildTreeSvg(clampedLevel.value)))
@@ -110,34 +124,35 @@ const treeImageSrc = computed(() => svgToDataUrl(buildTreeSvg(clampedLevel.value
justify-content: center; justify-content: center;
box-sizing: border-box; box-sizing: border-box;
} }
.size-sm { width: 64rpx; height: 72rpx; }
.size-sm { .size-md { width: 80rpx; height: 88rpx; }
width: 64rpx; .size-lg { width: 112rpx; height: 124rpx; }
height: 72rpx;
}
.size-md {
width: 80rpx;
height: 88rpx;
}
.size-lg {
width: 96rpx;
height: 108rpx;
}
.stg-glow { .stg-glow {
position: absolute; position: absolute;
inset: 8%; inset: 6%;
border-radius: 50%; border-radius: 50%;
background: radial-gradient(circle, rgba(14, 165, 164, 0.22) 0%, transparent 68%); background: radial-gradient(circle, rgba(14, 165, 164, 0.22) 0%, transparent 68%);
pointer-events: none; pointer-events: none;
} }
.lv-0 .stg-glow { .lv-0 .stg-glow { background: radial-gradient(circle, rgba(148, 163, 184, 0.25) 0%, transparent 70%); }
background: radial-gradient(circle, rgba(148, 163, 184, 0.2) 0%, transparent 70%); .tier-4 .stg-glow,
.tier-5 .stg-glow,
.is-max .stg-glow {
background: radial-gradient(circle, rgba(251, 191, 36, 0.3) 0%, rgba(14, 165, 164, 0.15) 55%, transparent 72%);
} }
.lv-4 .stg-glow { .stg-aura {
background: radial-gradient(circle, rgba(251, 191, 36, 0.28) 0%, rgba(14, 165, 164, 0.12) 55%, transparent 72%); position: absolute;
inset: -8%;
border-radius: 50%;
border: 2rpx solid rgba(253, 224, 71, 0.35);
animation: stg-aura-pulse 2.4s ease-in-out infinite;
pointer-events: none;
}
@keyframes stg-aura-pulse {
0%, 100% { transform: scale(0.92); opacity: 0.5; }
50% { transform: scale(1.05); opacity: 1; }
} }
.stg-image { .stg-image {
position: relative; position: relative;
z-index: 1; z-index: 1;
@@ -155,29 +170,14 @@ const treeImageSrc = computed(() => svgToDataUrl(buildTreeSvg(clampedLevel.value
line-height: 1; line-height: 1;
font-size: 72rpx; font-size: 72rpx;
} }
.size-sm .stg-emoji { .size-sm .stg-emoji { font-size: 48rpx; }
font-size: 48rpx; .size-md .stg-emoji { font-size: 60rpx; }
} .size-lg .stg-emoji { font-size: 88rpx; }
.size-md .stg-emoji {
font-size: 60rpx;
}
.size-lg .stg-emoji {
font-size: 72rpx;
}
@keyframes stg-water-bounce { @keyframes stg-water-bounce {
0%, 0%, 100% { transform: scale(1); }
100% { 35% { transform: scale(1.1) translateY(-6rpx); }
transform: scale(1); 60% { transform: scale(0.96) translateY(2rpx); }
}
35% {
transform: scale(1.08) translateY(-4rpx);
}
60% {
transform: scale(0.96) translateY(2rpx);
}
} }
/* 开花态微光点缀 */
.stg-sparkle { .stg-sparkle {
position: absolute; position: absolute;
z-index: 2; z-index: 2;
@@ -187,16 +187,13 @@ const treeImageSrc = computed(() => svgToDataUrl(buildTreeSvg(clampedLevel.value
background: #fde68a; background: #fde68a;
box-shadow: 0 0 6rpx rgba(253, 224, 71, 0.8); box-shadow: 0 0 6rpx rgba(253, 224, 71, 0.8);
pointer-events: none; pointer-events: none;
animation: stg-sparkle-twinkle 1.8s ease-in-out infinite;
} }
.stg-sparkle-a { .stg-sparkle-a { top: 4%; right: 16%; }
top: 6%; .stg-sparkle-b { top: 12%; left: 10%; width: 6rpx; height: 6rpx; animation-delay: 0.4s; }
right: 18%; .stg-sparkle-c { top: 22%; right: 28%; width: 10rpx; height: 10rpx; animation-delay: 0.8s; }
} @keyframes stg-sparkle-twinkle {
.stg-sparkle-b { 0%, 100% { opacity: 0.4; transform: scale(0.8); }
top: 14%; 50% { opacity: 1; transform: scale(1.2); }
left: 12%;
width: 6rpx;
height: 6rpx;
opacity: 0.85;
} }
</style> </style>
@@ -27,13 +27,7 @@ const props = defineProps({
color: { type: String, default: '#0891B2' } color: { type: String, default: '#0891B2' }
}) })
/** 微信小程序 image 对 data:svg 支持不稳定,默认用文字图标保证可见 */
// #ifdef MP-WEIXIN
const useFallback = ref(true)
// #endif
// #ifndef MP-WEIXIN
const useFallback = ref(false) const useFallback = ref(false)
// #endif
/** Lucide 风格描边路径 */ /** Lucide 风格描边路径 */
const ICON_PATHS = { const ICON_PATHS = {
@@ -51,7 +45,16 @@ const ICON_PATHS = {
droplet: '<path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.5-2.5-5.5C15 7 12 2 12 2S9 7 7.5 9.5 5 13 5 15a7 7 0 0 0 7 7z"/>', droplet: '<path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.5-2.5-5.5C15 7 12 2 12 2S9 7 7.5 9.5 5 13 5 15a7 7 0 0 0 7 7z"/>',
sparkles: '<path d="m12 3-1.9 5.8L4 12l5.8 1.9L12 21l1.9-5.8L20 12l-5.8-1.9L12 3Z"/><path d="M5 3v4M19 17v4M3 5h4M17 19h4"/>', sparkles: '<path d="m12 3-1.9 5.8L4 12l5.8 1.9L12 21l1.9-5.8L20 12l-5.8-1.9L12 3Z"/><path d="M5 3v4M19 17v4M3 5h4M17 19h4"/>',
trophy: '<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20 17 22M18 2H6v7a6 6 0 0 0 12 0V2Z"/>', trophy: '<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20 17 22M18 2H6v7a6 6 0 0 0 12 0V2Z"/>',
share: '<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13"/>' share: '<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13"/>',
plus: '<path d="M5 12h14M12 5v14"/>',
refresh: '<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8M3 3v5h5M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16M21 21v-5h-5"/>',
volume: '<path d="M11 5 6 9H2v6h4l5 4V5zM15.54 8.46a5 5 0 0 1 0 7.07M19.07 4.93a10 10 0 0 1 0 14.14"/>',
pause: '<rect width="4" height="16" x="14" y="4" rx="1"/><rect width="4" height="16" x="6" y="4" rx="1"/>',
play: '<polygon points="6 3 20 12 6 21 6 3"/>',
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>',
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41-1.41M17.66 6.34l1.41-1.41M6.34 4.93l1.41 1.41"/>',
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
sunset: '<path d="M12 10V2M18.364 5.636l-2.12 2.12M5.636 18.364l2.12-2.12M22 18h-3M5 18H2M18.364 18.364l-2.12-2.12M5.636 5.636l2.12 2.12M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"/>'
} }
/** 图片加载失败时的 emoji 回退 */ /** 图片加载失败时的 emoji 回退 */
@@ -70,7 +73,16 @@ const ICON_FALLBACK = {
droplet: '💧', droplet: '💧',
sparkles: '✨', sparkles: '✨',
trophy: '🏆', trophy: '🏆',
share: '↗' share: '↗',
plus: '',
refresh: '↻',
volume: '🔊',
pause: '⏸',
play: '▶',
info: '!',
sun: '☀',
moon: '🌙',
sunset: '☀'
} }
const strokeColor = computed(() => { const strokeColor = computed(() => {
+325 -217
View File
@@ -224,8 +224,10 @@
<view class="hero-row"> <view class="hero-row">
<view class="hero-main"> <view class="hero-main">
<view class="hero-greet-row"> <view class="hero-greet-row">
<text class="hero-greet-emoji">{{ timeGreetingEmoji }}</text> <view class="hero-greet-icon-wrap">
<text class="hero-greet-text">{{ timeGreeting }}</text> <TongjiIcon :name="timeGreetingMeta.icon" size="md" color="#FFFFFF" />
</view>
<text class="hero-greet-text">{{ timeGreetingMeta.text }}</text>
</view> </view>
<text class="hero-title">{{ patientName || '健康档案' }}</text> <text class="hero-title">{{ patientName || '健康档案' }}</text>
<view class="hero-subtitle-row"> <view class="hero-subtitle-row">
@@ -236,14 +238,18 @@
class="hero-status-chip" class="hero-status-chip"
:class="todayChecked ? 'done' : 'pending'" :class="todayChecked ? 'done' : 'pending'"
> >
<text class="hero-status-icon">{{ todayChecked ? '✓' : '!' }}</text> <view class="hero-status-icon">
<TongjiIcon :name="todayChecked ? 'check-circle' : 'info'" size="sm" color="#ffffff" />
</view>
<text class="hero-status-text">{{ todayChecked ? '今日已记录' : '今日待记录' }}</text> <text class="hero-status-text">{{ todayChecked ? '今日已记录' : '今日待记录' }}</text>
</view> </view>
</view> </view>
</view> </view>
<view class="hero-pill-group"> <view class="hero-pill-group">
<view class="hero-pill input" @click="openInputForm()"> <view class="hero-pill input" @click="openInputForm()">
<text class="hero-pill-icon"></text> <view class="hero-pill-icon-wrap">
<TongjiIcon name="plus" size="sm" color="#0d9488" />
</view>
<text class="hero-pill-text">录入今日</text> <text class="hero-pill-text">录入今日</text>
</view> </view>
<view <view
@@ -251,11 +257,15 @@
:class="{ speaking: ttsSpeaking, disabled: !ttsEnabled }" :class="{ speaking: ttsSpeaking, disabled: !ttsEnabled }"
@click="ttsSpeaking ? ttsStop() : speakLatestSummary()" @click="ttsSpeaking ? ttsStop() : speakLatestSummary()"
> >
<text class="hero-pill-icon">{{ ttsSpeaking ? '⏸' : '🔊' }}</text> <view class="hero-pill-icon-wrap">
<TongjiIcon :name="ttsSpeaking ? 'pause' : 'volume'" size="sm" :color="ttsSpeaking ? '#0f172a' : '#ffffff'" />
</view>
<text class="hero-pill-text">{{ ttsSpeaking ? '停止' : '朗读' }}</text> <text class="hero-pill-text">{{ ttsSpeaking ? '停止' : '朗读' }}</text>
</view> </view>
<view class="hero-pill" @click="onRefresh"> <view class="hero-pill" @click="onRefresh">
<text class="hero-pill-icon"></text> <view class="hero-pill-icon-wrap">
<TongjiIcon name="refresh" size="sm" color="#ffffff" />
</view>
<text class="hero-pill-text">刷新</text> <text class="hero-pill-text">刷新</text>
</view> </view>
</view> </view>
@@ -508,24 +518,42 @@
</view> </view>
</view> </view>
<!-- 控糖树成长区 --> <!-- 控糖树成长区10 级成长 + 路线图-->
<view class="tree-growth-zone"> <view class="tree-growth-zone">
<view class="tree-graphic-wrap tree-tappable" @click="onTreeWhisper"> <view class="tree-graphic-wrap tree-tappable" @click="onTreeWhisper">
<view class="tree-lv-badge">Lv.{{ treeLevel }}</view>
<view class="tree-mood-pill">
<text class="tree-mood-emoji">{{ treeEmoji }}</text>
<text class="tree-mood-text">{{ treeMood }}</text>
</view>
<SugarTreeGraphic :level="treeLevel" size="lg" :watering="treeWatering" /> <SugarTreeGraphic :level="treeLevel" size="lg" :watering="treeWatering" />
<view v-if="floatReward" :key="floatReward.key" class="float-reward"> <view v-if="floatReward" :key="floatReward.key" class="float-reward">
<text>+{{ floatReward.text }}</text> <text>+{{ floatReward.text }}</text>
</view> </view>
<text class="tree-tap-hint">我聊聊天</text> <text class="tree-tap-hint">树听鼓励 </text>
</view> </view>
<view class="tree-progress-wrap"> <view class="tree-progress-wrap">
<view class="tree-level-info"> <view class="tree-level-info">
<text class="tree-level-tag">Lv.{{ treeLevel }} {{ treeLevelName }}</text> <text class="tree-level-tag">{{ treeLevelName }}</text>
<text class="tree-progress-text" v-if="treeLevel < 4">{{ treeProgress }}/100 XP</text> <text class="tree-progress-text">{{ treeProgressLabel }}</text>
<text class="tree-progress-text" v-else>已满级</text>
</view> </view>
<view class="tree-progress-track"> <view class="tree-progress-track">
<view class="tree-progress-bar" :style="{ width: treeProgress + '%' }" /> <view class="tree-progress-bar" :style="{ width: treeProgress + '%' }" />
</view> </view>
<text v-if="treeNextHint" class="tree-next-hint">{{ treeNextHint }}</text>
<scroll-view scroll-x class="tree-roadmap-scroll" :show-scrollbar="false">
<view class="tree-roadmap-row">
<view
v-for="node in TREE_LEVEL_ROADMAP"
:key="node.level"
class="tree-roadmap-node"
:class="{ done: node.level < treeLevel, current: node.level === treeLevel, locked: node.level > treeLevel }"
>
<text class="tree-roadmap-emoji">{{ node.emoji }}</text>
<text class="tree-roadmap-lv">Lv{{ node.level }}</text>
</view>
</view>
</scroll-view>
<view class="tree-water-action"> <view class="tree-water-action">
<button class="water-btn" :class="{ disabled: treeWatering, pulse: claimablePoints > 0 }" @click="waterTree"> <button class="water-btn" :class="{ disabled: treeWatering, pulse: claimablePoints > 0 }" @click="waterTree">
<TongjiIcon name="droplet" size="sm" color="#FFFFFF" /> <TongjiIcon name="droplet" size="sm" color="#FFFFFF" />
@@ -1313,6 +1341,7 @@ import { onLoad, onShow, onHide, onUnload, onPullDownRefresh, onShareAppMessage
import SugarTreeGraphic from '../components/SugarTreeGraphic.vue' import SugarTreeGraphic from '../components/SugarTreeGraphic.vue'
import TongjiIcon from '../components/TongjiIcon.vue' import TongjiIcon from '../components/TongjiIcon.vue'
import CelebrateBurst from '../components/CelebrateBurst.vue' import CelebrateBurst from '../components/CelebrateBurst.vue'
import { TREE_LEVELS, TREE_MAX_LEVEL, calcTreeFromPoints, pickTreeWhisper } from '../utils/treeLevels.js'
const { proxy } = getCurrentInstance() const { proxy } = getCurrentInstance()
@@ -1776,9 +1805,6 @@ async function applyCard(card) {
summary_line: '分享战报给家人,邀请他们为您点赞鼓劲', summary_line: '分享战报给家人,邀请他们为您点赞鼓劲',
recent: [] recent: []
} }
gamifyTreeLevel.value = null
gamifyTreeProgress.value = null
gamifyTreeLevelName.value = ''
gamifyStatusTip.value = '' gamifyStatusTip.value = ''
await fetchGamifyState() await fetchGamifyState()
} }
@@ -1875,25 +1901,18 @@ function onRefresh() {
} }
// ============ 时段问候 / 打卡 / 健康日历 ============ // ============ 时段问候 / 打卡 / 健康日历 ============
const timeGreeting = computed(() => { /** 时段问候:文案 + 统一 TongjiIcon(与全页图标体系一致)*/
const h = new Date().getHours() function buildTimeGreetingMeta(hour = new Date().getHours()) {
if (h < 5) return '夜深了' const h = Number(hour) || 0
if (h < 11) return '早上好' if (h < 5) return { text: '夜深了', icon: 'moon' }
if (h < 13) return '中午好' if (h < 11) return { text: '早上好', icon: 'sun' }
if (h < 18) return '午好' if (h < 13) return { text: '午好', icon: 'sun' }
if (h < 22) return '晚上好' if (h < 18) return { text: '下午好', icon: 'sun' }
return '夜深了' if (h < 22) return { text: '晚上好', icon: 'sunset' }
}) return { text: '夜深了', icon: 'moon' }
}
const timeGreetingEmoji = computed(() => { const timeGreetingMeta = computed(() => buildTimeGreetingMeta())
const h = new Date().getHours()
if (h < 5) return '🌙'
if (h < 11) return '☀️'
if (h < 13) return '🌤'
if (h < 18) return '🌅'
if (h < 22) return '🌆'
return '🌙'
})
// 复用已有的 formatDate(date) -> 'YYYY-MM-DD' // 复用已有的 formatDate(date) -> 'YYYY-MM-DD'
@@ -3163,10 +3182,8 @@ const gamifyBadges = ref([])
const gamifyTaskAwards = ref({}) const gamifyTaskAwards = ref({})
const gamifyTodayTasks = ref([]) const gamifyTodayTasks = ref([])
const gamifyClaimablePoints = ref(0) const gamifyClaimablePoints = ref(0)
const gamifyTreeLevel = ref(null)
const gamifyTreeProgress = ref(null)
const gamifyTreeLevelName = ref('')
const gamifyStatusTip = ref('') const gamifyStatusTip = ref('')
const TREE_LEVEL_ROADMAP = TREE_LEVELS
const treeWatering = ref(false) const treeWatering = ref(false)
function isApiSuccess(res) { function isApiSuccess(res) {
@@ -3192,15 +3209,6 @@ function applyGamifyPayload(data) {
})) }))
: [] : []
gamifyClaimablePoints.value = Number(data.claimable_points) || 0 gamifyClaimablePoints.value = Number(data.claimable_points) || 0
if (data.tree_level != null && data.tree_level !== '') {
gamifyTreeLevel.value = Math.min(4, Math.max(0, Number(data.tree_level) || 0))
}
if (data.tree_progress != null && data.tree_progress !== '') {
gamifyTreeProgress.value = Math.min(100, Math.max(0, Number(data.tree_progress) || 0))
}
if (data.tree_level_name) {
gamifyTreeLevelName.value = String(data.tree_level_name)
}
if (data.message) { if (data.message) {
gamifyStatusTip.value = String(data.message) gamifyStatusTip.value = String(data.message)
} }
@@ -3300,8 +3308,20 @@ const unlockedBadges = computed(() => {
if (gamifyPoints.value >= 100) { if (gamifyPoints.value >= 100) {
list.push({ id: 'p100', label: '积分破百', icon: 'trophy', color: '#7C3AED' }) list.push({ id: 'p100', label: '积分破百', icon: 'trophy', color: '#7C3AED' })
} }
if (gamifyPoints.value >= 200) {
list.push({ id: 'p200', label: '积分200+', icon: 'sparkles', color: '#7C3AED' })
}
if (treeLevel.value >= 3) { if (treeLevel.value >= 3) {
list.push({ id: 'tree', label: treeLevelName.value, icon: 'glucose', color: '#0D9488' }) list.push({ id: 'tree3', label: '小树苗', icon: 'glucose', color: '#0D9488' })
}
if (treeLevel.value >= 5) {
list.push({ id: 'tree5', label: '拔节高', icon: 'activity', color: '#059669' })
}
if (treeLevel.value >= 7) {
list.push({ id: 'tree7', label: '初绽香', icon: 'heart', color: '#EA580C' })
}
if (treeState.value.isMax) {
list.push({ id: 'tree9', label: '圆满树', icon: 'trophy', color: '#CA8A04' })
} }
return list return list
}) })
@@ -3315,14 +3335,6 @@ const taskJustDoneId = ref('')
let celebrateTimer = null let celebrateTimer = null
let taskPopTimer = null let taskPopTimer = null
const TREE_WHISPERS = [
'再浇一点水,我就长高啦。',
'您今天真棒,小树都开心得晃叶子。',
'坚持记录,我会开出稳糖花。',
'空腹餐后都记全,我会长得更快。',
'家人点赞的时候,我也会发光。'
]
const INVITE_TREE_WHISPERS = [ const INVITE_TREE_WHISPERS = [
'Ta 正在努力控糖,您的鼓励很重要。', 'Ta 正在努力控糖,您的鼓励很重要。',
'好习惯比完美数值更珍贵,一起加油。', '好习惯比完美数值更珍贵,一起加油。',
@@ -3365,7 +3377,7 @@ function popTaskDone(taskId) {
function onTreeWhisper() { function onTreeWhisper() {
if (treeWatering.value) return if (treeWatering.value) return
const line = TREE_WHISPERS[Math.floor(Math.random() * TREE_WHISPERS.length)] const line = pickTreeWhisper(treeLevel.value)
showUserToast(line, { duration: 2800 }) showUserToast(line, { duration: 2800 })
hapticLight() hapticLight()
} }
@@ -3411,11 +3423,11 @@ async function waterTree() {
triggerCelebrate('浇水成功', tip || `+${addedPoints} 稳糖积分,小树咕咚喝饱啦`) triggerCelebrate('浇水成功', tip || `+${addedPoints} 稳糖积分,小树咕咚喝饱啦`)
const claimedIds = Array.isArray(data.claimed_tasks) ? data.claimed_tasks : [] const claimedIds = Array.isArray(data.claimed_tasks) ? data.claimed_tasks : []
claimedIds.forEach((id) => popTaskDone(id)) claimedIds.forEach((id) => popTaskDone(id))
const newLevel = treeLevel.value const newLevel = treeLevel.value
if (newLevel > oldLevel) { if (newLevel > oldLevel) {
const names = ['嫩芽期', '成长雏形', '枝繁叶茂', '含苞待放', '花开稳糖'] const meta = TREE_LEVELS[newLevel] || TREE_LEVELS[0]
setTimeout(() => triggerCelebrate('小树升级啦', `恭喜进入 ${names[newLevel] || treeLevelName.value}`), 520) setTimeout(() => triggerCelebrate('小树升级啦', `Lv.${newLevel} ${meta.name} · ${meta.desc}`), 520)
} }
setTimeout(() => { setTimeout(() => {
if (todayTasks.value.every((t) => t.completed && t.claimed)) { if (todayTasks.value.every((t) => t.completed && t.claimed)) {
triggerCelebrate('今日任务大满贯', '明天继续来浇浇水吧') triggerCelebrate('今日任务大满贯', '明天继续来浇浇水吧')
@@ -3460,25 +3472,20 @@ async function waterTree() {
} }
} }
const treeLevel = computed(() => { const treeState = computed(() => calcTreeFromPoints(gamifyPoints.value))
if (gamifyTreeLevel.value != null) { const treeLevel = computed(() => treeState.value.level)
return Math.min(4, Math.max(0, gamifyTreeLevel.value)) const treeProgress = computed(() => treeState.value.progress)
} const treeLevelName = computed(() => treeState.value.name)
return Math.min(4, Math.floor(gamifyPoints.value / 100)) const treeEmoji = computed(() => treeState.value.emoji)
const treeMood = computed(() => treeState.value.mood)
const treeProgressLabel = computed(() => {
if (treeState.value.isMax) return '已满级 · 圆满树'
return `${treeState.value.xpInLevel}/${treeState.value.xpNeed} XP`
}) })
const treeNextHint = computed(() => {
const treeProgress = computed(() => { if (treeState.value.isMax) return '您已养成满级稳糖树,习惯就是最好的奖励'
if (treeLevel.value >= 4) return 100 if (claimablePoints.value > 0) return `浇水后可再积 ${claimablePoints.value} 分,离升级更近一步`
if (gamifyTreeProgress.value != null) { return `再积 ${treeState.value.pointsToNext} 分,升级「${treeState.value.nextName}`
return Math.min(100, Math.max(0, gamifyTreeProgress.value))
}
return gamifyPoints.value % 100
})
const treeLevelName = computed(() => {
if (gamifyTreeLevelName.value) return gamifyTreeLevelName.value
const names = ['嫩芽期', '成长雏形', '枝繁叶茂', '含苞待放', '花开稳糖']
return names[treeLevel.value] || '嫩芽期'
}) })
// ============ 分享 / 家人点赞 / 邀请观看 ============ // ============ 分享 / 家人点赞 / 邀请观看 ============
@@ -3969,7 +3976,7 @@ async function onFamilyLike() {
border-color: #fde68a; border-color: #fde68a;
color: var(--slate-900); color: var(--slate-900);
animation: voice-pulse 1.4s ease-in-out infinite; animation: voice-pulse 1.4s ease-in-out infinite;
.hero-pill-text, .hero-pill-icon { .hero-pill-text {
color: var(--slate-900); color: var(--slate-900);
} }
} }
@@ -3981,11 +3988,6 @@ async function onFamilyLike() {
border: 2rpx solid #ffffff; border: 2rpx solid #ffffff;
padding: 16rpx 32rpx; padding: 16rpx 32rpx;
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.1); box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.1);
.hero-pill-icon {
color: var(--primary);
font-weight: 800;
font-size: 38rpx;
}
.hero-pill-text { .hero-pill-text {
color: var(--primary); color: var(--primary);
font-weight: 700; font-weight: 700;
@@ -3999,10 +4001,13 @@ async function onFamilyLike() {
0%, 100% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.55); } 0%, 100% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.55); }
50% { box-shadow: 0 0 0 12rpx rgba(251, 191, 36, 0); } 50% { box-shadow: 0 0 0 12rpx rgba(251, 191, 36, 0); }
} }
.hero-pill-icon { .hero-pill-icon-wrap {
font-size: 34rpx; width: 36rpx;
color: #ffffff; height: 36rpx;
line-height: 1; display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
} }
.hero-pill-text { .hero-pill-text {
font-size: 32rpx; font-size: 32rpx;
@@ -4012,88 +4017,81 @@ async function onFamilyLike() {
/* === Bento 风格指标卡 === */ /* === Bento 风格指标卡 === */
.stat-grid { .stat-grid {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: repeat(2, 1fr);
gap: 16rpx; gap: 20rpx;
margin: -60rpx 24rpx 0; margin: -60rpx 24rpx 0;
position: relative; position: relative;
z-index: 3; z-index: 3;
} }
.stat-card { .stat-card {
flex: 1 1 calc(50% - 8rpx);
min-width: 0;
background: #ffffff; background: #ffffff;
border-radius: var(--radius-card); border-radius: var(--radius-card);
padding: 30rpx 24rpx 26rpx; padding: 30rpx 26rpx;
box-shadow: var(--shadow-premium); box-shadow: var(--shadow-premium);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12rpx; justify-content: space-between;
position: relative; min-height: 250rpx;
overflow: hidden;
border-top: 8rpx solid transparent;
border: 1rpx solid rgba(15, 23, 42, 0.04); border: 1rpx solid rgba(15, 23, 42, 0.04);
box-sizing: border-box; box-sizing: border-box;
transition: var(--transition-smooth); transition: var(--transition-smooth);
position: relative;
overflow: hidden;
&:active { &:active {
transform: translateY(-2rpx); transform: translateY(-2rpx);
box-shadow: 0 16rpx 36rpx rgba(15, 23, 42, 0.08); box-shadow: 0 16rpx 36rpx rgba(15, 23, 42, 0.08);
} }
} }
.stat-card::before {
content: '';
position: absolute;
top: 0;
right: -30rpx;
width: 120rpx;
height: 120rpx;
border-radius: 999rpx;
opacity: 0.06;
pointer-events: none;
}
.stat-fasting { .stat-fasting {
border-top-color: var(--primary); background: radial-gradient(circle at 100% 0%, #e6fdf9 0%, #ffffff 65%);
&::before { background: var(--primary); }
} }
.stat-postprandial { .stat-postprandial {
border-top-color: var(--warning); background: radial-gradient(circle at 100% 0%, #fff7ed 0%, #ffffff 65%);
&::before { background: var(--warning); }
} }
.stat-other { .stat-other {
flex: 1 1 100% !important; grid-column: span 2;
flex-direction: row !important; background: radial-gradient(circle at 100% 0%, #f5f3ff 0%, #ffffff 65%);
display: grid !important;
grid-template-areas:
"head value"
"foot value" !important;
grid-template-columns: 1fr auto !important;
grid-template-rows: auto auto !important;
align-items: center !important; align-items: center !important;
justify-content: space-between !important; min-height: 170rpx !important;
padding: 24rpx 32rpx !important; padding: 26rpx 32rpx !important;
border-top: none !important;
border-left: 8rpx solid var(--violet) !important;
gap: 16rpx !important;
&::before { display: none !important; }
.stat-card-head { .stat-card-head {
display: flex; grid-area: head !important;
flex-direction: row; display: flex !important;
align-items: center; align-items: center !important;
gap: 12rpx; justify-content: flex-start !important;
flex: 1; margin: 0 !important;
padding: 0 !important;
gap: 12rpx !important;
} }
.stat-card-value-row { .stat-card-value-row {
margin-left: auto; grid-area: value !important;
display: flex; display: flex !important;
align-items: baseline; align-items: baseline !important;
gap: 6rpx; margin: 0 !important;
margin-top: 0; padding: 0 !important;
gap: 6rpx !important;
} }
.stat-card-foot { .stat-card-foot {
display: flex; grid-area: foot !important;
flex-direction: row; display: flex !important;
align-items: center; align-items: center !important;
gap: 12rpx; justify-content: flex-start !important;
margin-top: 0; margin-top: 12rpx !important;
padding: 0 !important;
gap: 16rpx !important;
} }
} }
.stat-card.is-high { .stat-card.is-high {
box-shadow: var(--shadow-glow-danger); box-shadow: var(--shadow-glow-danger);
border-color: rgba(239, 68, 68, 0.1); border-color: rgba(239, 68, 68, 0.08);
} }
.stat-card-head { .stat-card-head {
display: flex; display: flex;
@@ -4102,52 +4100,61 @@ async function onFamilyLike() {
gap: 8rpx; gap: 8rpx;
} }
.stat-card-tag { .stat-card-tag {
display: flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6rpx; padding: 8rpx 18rpx;
border-radius: 12rpx;
font-size: 26rpx;
font-weight: 700;
}
.stat-fasting .stat-card-tag {
background: rgba(13, 148, 136, 0.09);
color: #0d9488;
}
.stat-postprandial .stat-card-tag {
background: rgba(245, 158, 11, 0.09);
color: #d97706;
}
.stat-other .stat-card-tag {
background: rgba(99, 102, 241, 0.09);
color: #4f46e5;
} }
.stat-card-tag-dot { .stat-card-tag-dot {
width: 16rpx; display: none;
height: 16rpx;
border-radius: var(--radius-pill);
} }
.stat-fasting .stat-card-tag-dot { background: var(--primary); }
.stat-postprandial .stat-card-tag-dot { background: var(--warning); }
.stat-other .stat-card-tag-dot { background: var(--violet); }
.stat-card-tag-text { .stat-card-tag-text {
font-size: 26rpx; font-size: 26rpx;
color: var(--slate-900);
font-weight: 700; font-weight: 700;
} }
.stat-card-flag { .stat-card-flag {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4rpx; gap: 6rpx;
padding: 4rpx 12rpx 4rpx 8rpx; padding: 6rpx 14rpx;
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
background: var(--danger-grad); background: rgba(239, 68, 68, 0.08);
box-shadow: 0 4rpx 10rpx rgba(239, 68, 68, 0.2); border: 1rpx solid rgba(239, 68, 68, 0.15);
} }
.stat-card-flag-icon { .stat-card-flag-icon {
font-size: 20rpx; font-size: 20rpx;
color: #ffffff; color: var(--danger);
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
} }
.stat-card-flag-text { .stat-card-flag-text {
font-size: 22rpx; font-size: 22rpx;
color: #ffffff; color: var(--danger);
font-weight: 700; font-weight: 700;
} }
.stat-card-value-row { .stat-card-value-row {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 8rpx; gap: 6rpx;
flex-wrap: wrap; margin-top: 16rpx;
margin-bottom: 8rpx;
} }
.stat-card-value { .stat-card-value {
font-size: 60rpx; /* 更大数字 */ font-size: 64rpx;
font-weight: 900; font-weight: 900;
background: linear-gradient(180deg, var(--slate-900) 0%, #334155 100%); background: linear-gradient(180deg, var(--slate-900) 0%, #334155 100%);
-webkit-background-clip: text; -webkit-background-clip: text;
@@ -4183,25 +4190,25 @@ async function onFamilyLike() {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4rpx; gap: 4rpx;
padding: 6rpx 12rpx 6rpx 9rpx; padding: 6rpx 14rpx;
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
background: var(--slate-100);
transition: var(--transition-smooth); transition: var(--transition-smooth);
&.trend-up { &.trend-up {
background: #fef3c7; background: rgba(239, 68, 68, 0.08);
.stat-card-trend-arrow, .stat-card-trend-arrow,
.stat-card-trend-delta { .stat-card-trend-delta {
color: #b45309; color: var(--danger);
} }
} }
&.trend-down { &.trend-down {
background: #dcfce7; background: rgba(16, 185, 129, 0.08);
.stat-card-trend-arrow, .stat-card-trend-arrow,
.stat-card-trend-delta { .stat-card-trend-delta {
color: #15803d; color: var(--success);
} }
} }
&.trend-flat { &.trend-flat {
background: var(--slate-100);
.stat-card-trend-arrow, .stat-card-trend-arrow,
.stat-card-trend-delta { .stat-card-trend-delta {
color: var(--slate-600); color: var(--slate-600);
@@ -5043,8 +5050,16 @@ async function onFamilyLike() {
gap: 12rpx; gap: 12rpx;
margin-bottom: 6rpx; margin-bottom: 6rpx;
} }
.hero-greet-emoji { .hero-greet-icon-wrap {
font-size: 36rpx; width: 52rpx;
height: 52rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.18);
border: 1rpx solid rgba(255, 255, 255, 0.28);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
} }
.hero-greet-text { .hero-greet-text {
font-size: 30rpx; font-size: 30rpx;
@@ -5080,16 +5095,12 @@ async function onFamilyLike() {
} }
} }
.hero-status-icon { .hero-status-icon {
width: 36rpx; width: 32rpx;
height: 36rpx; height: 32rpx;
border-radius: var(--radius-pill);
background: rgba(255, 255, 255, 0.4);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 24rpx; flex-shrink: 0;
line-height: 1;
font-weight: 800;
} }
.hero-status-text { .hero-status-text {
line-height: 1.2; line-height: 1.2;
@@ -5879,23 +5890,117 @@ async function onFamilyLike() {
} }
.tree-growth-zone { .tree-growth-zone {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 24rpx; gap: 20rpx;
background: var(--slate-50); background: linear-gradient(145deg, #f0fdfa 0%, #f8fafc 55%, #fffbeb 100%);
border-radius: 24rpx; border-radius: 24rpx;
padding: 20rpx 24rpx; padding: 24rpx 20rpx;
border: 1rpx solid var(--slate-100); border: 2rpx solid #99f6e4;
} }
.tree-graphic-wrap { .tree-graphic-wrap {
position: relative; position: relative;
flex-shrink: 0; flex-shrink: 0;
width: 140rpx; width: 152rpx;
height: 154rpx; min-height: 200rpx;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.tree-lv-badge {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
z-index: 2;
padding: 4rpx 14rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #0ea5a4, #14b8a6);
color: #ffffff;
font-size: 20rpx;
font-weight: 800;
box-shadow: 0 4rpx 12rpx rgba(14, 165, 164, 0.35);
}
.tree-mood-pill {
position: absolute;
top: 36rpx;
right: -8rpx;
z-index: 2;
display: flex;
align-items: center;
gap: 4rpx;
padding: 4rpx 10rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.92);
border: 1rpx solid #e2e8f0;
box-shadow: 0 2rpx 8rpx rgba(15, 23, 42, 0.08);
}
.tree-mood-emoji {
font-size: 22rpx;
line-height: 1;
}
.tree-mood-text {
font-size: 20rpx;
font-weight: 700;
color: #64748b;
}
.tree-next-hint {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
color: #0f766e;
font-weight: 600;
line-height: 1.45;
}
.tree-roadmap-scroll {
width: 100%;
margin-top: 14rpx;
white-space: nowrap;
}
.tree-roadmap-row {
display: inline-flex;
gap: 10rpx;
padding-bottom: 4rpx;
}
.tree-roadmap-node {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
min-width: 56rpx;
padding: 8rpx 6rpx;
border-radius: 14rpx;
background: #f1f5f9;
border: 1rpx solid #e2e8f0;
opacity: 0.55;
&.done {
opacity: 0.85;
background: #ecfdf5;
border-color: #a7f3d0;
}
&.current {
opacity: 1;
background: linear-gradient(180deg, #ccfbf1, #ffffff);
border-color: #14b8a6;
box-shadow: 0 4rpx 14rpx rgba(14, 165, 164, 0.25);
transform: scale(1.06);
}
&.locked {
opacity: 0.4;
}
}
.tree-roadmap-emoji {
font-size: 26rpx;
line-height: 1;
}
.tree-roadmap-lv {
font-size: 18rpx;
font-weight: 700;
color: #64748b;
.tree-roadmap-node.current & {
color: #0f766e;
}
}
.tree-tappable:active { .tree-tappable:active {
opacity: 0.92; opacity: 0.92;
} }
@@ -5956,14 +6061,14 @@ async function onFamilyLike() {
color: #b45309; color: #b45309;
} }
.badge-wall { .badge-wall {
margin-top: 4rpx; margin-top: 12rpx;
} }
.badge-wall-title { .badge-wall-title {
display: block; display: block;
font-size: 26rpx; font-size: 32rpx;
font-weight: 800; font-weight: 800;
color: #475569; color: #475569;
margin-bottom: 12rpx; margin-bottom: 16rpx;
padding-left: 6rpx; padding-left: 6rpx;
} }
.badge-wall-scroll { .badge-wall-scroll {
@@ -5972,24 +6077,24 @@ async function onFamilyLike() {
} }
.badge-wall-row { .badge-wall-row {
display: inline-flex; display: inline-flex;
gap: 12rpx; gap: 14rpx;
padding-bottom: 4rpx; padding-bottom: 8rpx;
} }
.badge-chip { .badge-chip {
display: inline-flex; display: inline-flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 8rpx; gap: 10rpx;
min-width: 120rpx; min-width: 140rpx;
padding: 14rpx 16rpx; padding: 18rpx 20rpx;
background: #ffffff; background: #ffffff;
border: 1rpx solid var(--slate-200); border: 1rpx solid var(--slate-200);
border-radius: 16rpx; border-radius: 18rpx;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
.badge-chip-icon { .badge-chip-icon {
width: 52rpx; width: 58rpx;
height: 52rpx; height: 58rpx;
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
background: var(--slate-50); background: var(--slate-50);
display: flex; display: flex;
@@ -5997,7 +6102,7 @@ async function onFamilyLike() {
justify-content: center; justify-content: center;
} }
.badge-chip-label { .badge-chip-label {
font-size: 22rpx; font-size: 28rpx;
font-weight: 700; font-weight: 700;
color: #334155; color: #334155;
white-space: nowrap; white-space: nowrap;
@@ -6016,15 +6121,15 @@ async function onFamilyLike() {
gap: 12rpx; gap: 12rpx;
} }
.tree-level-tag { .tree-level-tag {
font-size: 26rpx; font-size: 30rpx;
font-weight: 700; font-weight: 700;
color: var(--slate-900); color: var(--slate-900);
background: var(--slate-200); background: var(--slate-200);
padding: 4rpx 14rpx; padding: 6rpx 18rpx;
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
} }
.tree-progress-text { .tree-progress-text {
font-size: 24rpx; font-size: 28rpx;
color: var(--slate-600); color: var(--slate-600);
font-weight: 600; font-weight: 600;
} }
@@ -6246,14 +6351,14 @@ async function onFamilyLike() {
} }
.pg-share-title { .pg-share-title {
display: block; display: block;
font-size: 28rpx; font-size: 34rpx;
font-weight: 700; font-weight: 800;
color: #0f766e; color: #0f766e;
} }
.pg-share-sub { .pg-share-sub {
display: block; display: block;
margin-top: 4rpx; margin-top: 6rpx;
font-size: 22rpx; font-size: 28rpx;
color: var(--slate-600); color: var(--slate-600);
} }
@@ -6261,12 +6366,12 @@ async function onFamilyLike() {
.family-like-strip { .family-like-strip {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16rpx; gap: 18rpx;
margin: 20rpx 24rpx 0; margin: 20rpx 24rpx 0;
padding: 22rpx 24rpx; padding: 24rpx 28rpx;
background: #ffffff; background: #ffffff;
border-radius: 24rpx; border-radius: 24rpx;
border: 1rpx solid #fecdd3; border: 1.5rpx solid #fecdd3;
box-shadow: 0 4rpx 16rpx rgba(225, 29, 72, 0.06); box-shadow: 0 4rpx 16rpx rgba(225, 29, 72, 0.06);
transition: var(--transition-smooth); transition: var(--transition-smooth);
&.has-likes { &.has-likes {
@@ -6277,7 +6382,7 @@ async function onFamilyLike() {
} }
} }
.family-like-icon { .family-like-icon {
font-size: 36rpx; font-size: 40rpx;
} }
.family-like-body { .family-like-body {
flex: 1; flex: 1;
@@ -6285,40 +6390,42 @@ async function onFamilyLike() {
} }
.family-like-title { .family-like-title {
display: block; display: block;
font-size: 28rpx; font-size: 34rpx;
font-weight: 700; font-weight: 800;
color: #9f1239; color: #9f1239;
} }
.family-like-sub { .family-like-sub {
display: block; display: block;
margin-top: 6rpx; margin-top: 8rpx;
font-size: 24rpx; font-size: 30rpx;
color: var(--slate-600); color: var(--slate-600);
font-weight: 500;
} }
.family-like-recent { .family-like-recent {
margin-top: 8rpx; margin-top: 10rpx;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8rpx; gap: 10rpx;
} }
.family-like-recent-item { .family-like-recent-item {
font-size: 22rpx; font-size: 26rpx;
color: #be123c; color: #be123c;
background: #ffe4e6; background: #ffe4e6;
padding: 4rpx 12rpx; padding: 6rpx 16rpx;
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
font-weight: 600;
} }
.family-like-arrow { .family-like-arrow {
font-size: 32rpx; font-size: 36rpx;
color: var(--slate-400); color: var(--slate-400);
} }
.encourage-strip { .encourage-strip {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 14rpx; gap: 16rpx;
margin: 16rpx 24rpx 0; margin: 16rpx 24rpx 0;
padding: 20rpx 22rpx; padding: 24rpx 24rpx;
background: #ecfdf5; background: #ecfdf5;
border-radius: 20rpx; border-radius: 20rpx;
border: 1rpx solid #a7f3d0; border: 1rpx solid #a7f3d0;
@@ -6328,20 +6435,21 @@ async function onFamilyLike() {
} }
} }
.encourage-icon { .encourage-icon {
font-size: 32rpx; font-size: 36rpx;
} }
.encourage-text { .encourage-text {
display: block; display: block;
font-size: 26rpx; font-size: 32rpx;
font-weight: 600; font-weight: 700;
color: #065f46; color: #065f46;
line-height: 1.45; line-height: 1.5;
} }
.encourage-tip { .encourage-tip {
display: block; display: block;
margin-top: 6rpx; margin-top: 8rpx;
font-size: 22rpx; font-size: 28rpx;
color: var(--slate-600); color: var(--slate-600);
font-weight: 500;
} }
/* === 共享/邀请查看样式 === */ /* === 共享/邀请查看样式 === */
@@ -0,0 +1,58 @@
/** 控糖树等级配置(前后端算法需保持一致:每级 50 积分,最高 Lv.9) */
export const TREE_MAX_LEVEL = 9
export const TREE_XP_PER_LEVEL = 50
export const TREE_LEVELS = [
{ level: 0, name: '种子眠', emoji: '🫘', mood: '困困', desc: '稳糖种子在土里打盹' },
{ level: 1, name: '破土芽', emoji: '🌱', mood: '探头', desc: '探出第一抹新绿' },
{ level: 2, name: '展两叶', emoji: '🌿', mood: '好奇', desc: '两片嫩叶迎风展' },
{ level: 3, name: '小树苗', emoji: '🪴', mood: '精神', desc: '身子骨硬朗起来' },
{ level: 4, name: '青枝繁', emoji: '🌳', mood: '茁壮', desc: '枝叶渐密,元气足' },
{ level: 5, name: '拔节高', emoji: '🌲', mood: '挺拔', desc: '一节一节往上蹿' },
{ level: 6, name: '稳糖冠', emoji: '💚', mood: '沉稳', desc: '树冠成形,习惯成自然' },
{ level: 7, name: '初绽香', emoji: '🌸', mood: '开心', desc: '枝头冒出第一朵花' },
{ level: 8, name: '漫开花', emoji: '🌺', mood: '灿烂', desc: '花开满枝,越记越稳' },
{ level: 9, name: '圆满树', emoji: '🏆', mood: '荣耀', desc: '满级大树,习惯大师' }
]
const WHISPERS_BY_LEVEL = {
0: ['种子在睡觉,记一笔就醒啦。', '今天浇第一滴水,芽就要冒出来。'],
1: ['破土啦!再坚持几天就长高。', '小芽最喜欢规律的记录了。'],
2: ['两片叶子为你鼓掌。', '空腹餐后都记全,我会长得更快。'],
3: ['我已经是一棵小树啦。', '家人点赞的时候,我也会发光。'],
4: ['枝叶越来越密,您真棒。', '连续记录,我会开出更多叶子。'],
5: ['拔节中!习惯比完美更重要。', '再浇一点水,我就更高啦。'],
6: ['习惯成自然,树冠成形啦。', '您今天的坚持,小树都记得。'],
7: ['开花啦!闻到春天的味道了吗?', '稳糖花只开给坚持的人。'],
8: ['满树花香,您已是控糖达人。', '明天继续来,花儿会更艳。'],
9: ['满级大树陪您一路稳糖。', '圆满不是终点,习惯才是。']
}
export function calcTreeFromPoints(points) {
const pts = Math.max(0, Number(points) || 0)
const level = Math.min(TREE_MAX_LEVEL, Math.floor(pts / TREE_XP_PER_LEVEL))
const xpInLevel = level >= TREE_MAX_LEVEL ? TREE_XP_PER_LEVEL : pts % TREE_XP_PER_LEVEL
const progress = level >= TREE_MAX_LEVEL ? 100 : Math.round((xpInLevel / TREE_XP_PER_LEVEL) * 100)
const meta = TREE_LEVELS[level] || TREE_LEVELS[0]
const nextMeta = level < TREE_MAX_LEVEL ? TREE_LEVELS[level + 1] : null
const pointsToNext = level >= TREE_MAX_LEVEL ? 0 : TREE_XP_PER_LEVEL - xpInLevel
return {
level,
progress,
name: meta.name,
emoji: meta.emoji,
mood: meta.mood,
desc: meta.desc,
xpInLevel,
xpNeed: TREE_XP_PER_LEVEL,
pointsToNext,
nextName: nextMeta?.name || '',
isMax: level >= TREE_MAX_LEVEL
}
}
export function pickTreeWhisper(level) {
const lv = Math.min(TREE_MAX_LEVEL, Math.max(0, Number(level) || 0))
const list = WHISPERS_BY_LEVEL[lv] || WHISPERS_BY_LEVEL[0]
return list[Math.floor(Math.random() * list.length)]
}
+24 -5
View File
@@ -148,14 +148,33 @@ class DailyGamifyLogic
return $list; return $list;
} }
/** 与前端 tongji/utils/treeLevels.js 保持一致 */
protected const TREE_MAX_LEVEL = 9;
protected const TREE_XP_PER_LEVEL = 50;
protected static function treeMeta(int $points): array protected static function treeMeta(int $points): array
{ {
$level = min(4, (int) floor($points / 100)); $points = max(0, (int) $points);
$names = ['嫩芽期', '成长雏形', '枝繁叶茂', '含苞待放', '花开稳糖']; $level = min(self::TREE_MAX_LEVEL, (int) floor($points / self::TREE_XP_PER_LEVEL));
$names = ['种子眠', '破土芽', '展两叶', '小树苗', '青枝繁', '拔节高', '稳糖冠', '初绽香', '漫开花', '圆满树'];
$xpIn = $level >= self::TREE_MAX_LEVEL ? self::TREE_XP_PER_LEVEL : ($points % self::TREE_XP_PER_LEVEL);
$progress = $level >= self::TREE_MAX_LEVEL
? 100
: (int) round(($xpIn / self::TREE_XP_PER_LEVEL) * 100);
$nextName = $level < self::TREE_MAX_LEVEL ? ($names[$level + 1] ?? '') : '';
$pointsToNext = $level >= self::TREE_MAX_LEVEL
? 0
: (self::TREE_XP_PER_LEVEL - $xpIn);
return [ return [
'tree_level' => $level, 'tree_level' => $level,
'tree_progress' => $level >= 4 ? 100 : ($points % 100), 'tree_progress' => $progress,
'tree_level_name' => $names[$level] ?? '嫩芽期', 'tree_level_name' => $names[$level] ?? '种子眠',
'tree_xp_in_level' => $xpIn,
'tree_xp_need' => self::TREE_XP_PER_LEVEL,
'tree_points_next' => $pointsToNext,
'tree_next_name' => $nextName,
'tree_is_max' => $level >= self::TREE_MAX_LEVEL,
]; ];
} }