first commit

This commit is contained in:
Your Name
2026-09-08 11:40:15 +08:00
commit a5353f7eb5
9568 changed files with 1646214 additions and 0 deletions
@@ -0,0 +1,142 @@
<template>
<view v-if="show" class="celebrate-root" @touchmove.stop.prevent>
<view
v-for="p in particles"
:key="p.id"
class="celebrate-particle"
:style="p.style"
/>
<view v-if="title" class="celebrate-card" :class="{ pop: cardPop }">
<view class="celebrate-card-glow" />
<TongjiIcon name="sparkles" size="lg" color="#204E2B" />
<text class="celebrate-card-title">{{ title }}</text>
<text v-if="subtitle" class="celebrate-card-sub">{{ subtitle }}</text>
</view>
</view>
</template>
<script setup>
import { ref, watch } from 'vue'
import TongjiIcon from './TongjiIcon.vue'
const props = defineProps({
show: { type: Boolean, default: false },
title: { type: String, default: '' },
subtitle: { type: String, default: '' }
})
const particles = ref([])
const cardPop = ref(false)
const COLORS = ['#204E2B', '#386641', '#AFE2B3', '#FBBF24', '#727970', '#DC2626']
function buildParticles() {
const list = []
for (let i = 0; i < 18; i++) {
const left = 8 + Math.random() * 84
const delay = Math.random() * 0.35
const hue = COLORS[i % COLORS.length]
const size = 10 + Math.floor(Math.random() * 14)
list.push({
id: `${Date.now()}_${i}`,
style: {
left: `${left}%`,
width: `${size}rpx`,
height: `${size}rpx`,
background: hue,
animationDelay: `${delay}s`
}
})
}
return list
}
watch(
() => props.show,
(v) => {
if (v) {
particles.value = buildParticles()
cardPop.value = false
setTimeout(() => {
cardPop.value = true
}, 30)
} else {
particles.value = []
cardPop.value = false
}
}
)
</script>
<style lang="scss" scoped>
.celebrate-root {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
overflow: hidden;
}
.celebrate-particle {
position: absolute;
top: -20rpx;
border-radius: 4rpx;
opacity: 0.9;
animation: celebrate-fall 1.6s ease-in forwards;
}
@keyframes celebrate-fall {
0% {
transform: translateY(0) rotate(0deg) scale(1);
opacity: 1;
}
100% {
transform: translateY(110vh) rotate(540deg) scale(0.4);
opacity: 0;
}
}
.celebrate-card {
position: absolute;
left: 50%;
top: 38%;
transform: translate(-50%, -50%) scale(0.82);
width: 78%;
max-width: 560rpx;
padding: 36rpx 32rpx 32rpx;
background: rgba(255, 255, 255, 0.96);
border-radius: 28rpx;
box-shadow: 0 20rpx 60rpx rgba(8, 145, 178, 0.22);
border: 2rpx solid rgba(8, 145, 178, 0.12);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
opacity: 0;
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;
&.pop {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
.celebrate-card-glow {
position: absolute;
inset: -20rpx;
border-radius: 36rpx;
background: radial-gradient(circle, rgba(34, 211, 238, 0.2), transparent 70%);
pointer-events: none;
}
.celebrate-card-title {
position: relative;
z-index: 1;
margin-top: 16rpx;
font-size: 36rpx;
font-weight: 800;
color: #1b1c1a;
}
.celebrate-card-sub {
position: relative;
z-index: 1;
margin-top: 10rpx;
font-size: 26rpx;
color: #475569;
line-height: 1.45;
}
</style>
@@ -0,0 +1,199 @@
<template>
<view
class="sugar-tree-graphic"
:class="[`lv-${clampedLevel}`, `tier-${visualTier}`, `size-${size}`, { watering: watering, 'is-max': clampedLevel >= MAX_LEVEL }]"
>
<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" />
<text v-else class="stg-emoji">{{ treeEmoji }}</text>
<view v-if="clampedLevel >= 7" class="stg-sparkle stg-sparkle-a" />
<view v-if="clampedLevel >= 8" class="stg-sparkle stg-sparkle-b" />
<view v-if="clampedLevel >= MAX_LEVEL" class="stg-sparkle stg-sparkle-c" />
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
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
const treeUseFallback = ref(true)
// #endif
// #ifndef MP-WEIXIN
const treeUseFallback = ref(false)
// #endif
const props = defineProps({
level: { type: Number, default: 0 },
size: { type: String, default: 'md' },
watering: { type: Boolean, default: false }
})
const clampedLevel = computed(() => Math.min(MAX_LEVEL, Math.max(0, Number(props.level) || 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)
function buildTreeSvg(level) {
const pot = `
<ellipse cx="24" cy="50" rx="15" ry="3" fill="#204e2b" opacity="0.12"/>
<path d="M11 46h26c1.2 0 2 1 2 2.2v3.8c0 1-.8 1.8-1.8 1.8H10.8c-1 0-1.8-.8-1.8-1.8v-3.8c0-1.2.8-2.2 2-2.2z" fill="#E7E5E4"/>
<path d="M12.5 46h23c.8 0 1.5.7 1.5 1.5v2.2c0 .6-.5 1.1-1.1 1.1H12.1c-.6 0-1.1-.5-1.1-1.1v-2.2c0-.8.7-1.5 1.5-1.5z" fill="#D6D3D1"/>
<ellipse cx="24" cy="46.5" rx="9" ry="1.6" fill="#A8A29E" opacity="0.35"/>
`
const soil = `<ellipse cx="24" cy="44.5" rx="8" ry="2.2" fill="#386641" opacity="0.18"/>`
if (level <= 0) {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 56" fill="none">
${pot}${soil}
<circle cx="24" cy="41.5" r="2.2" fill="#a8a29e" opacity="0.7"/>
<path d="M24 41.5v3.5" stroke="#78716c" stroke-width="1" stroke-linecap="round"/>
</svg>`
}
const trunkH = level >= 6 ? 16 : level >= 3 ? 14 : 10
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) =>
`<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="#eef6ef" opacity="0.55"/>`
const bloom =
level >= 7
? leaf(17, 18, 3, '#fda4af', 0.95) +
leaf(31, 17, 2.8, '#f9a8d4', 0.9) +
leaf(24, 13, 3.2, '#fb7185', 0.95) +
`<circle cx="24" cy="12" r="1.3" fill="#fef3c7"/>`
: ''
const crown =
level >= 9
? leaf(12, 22, 6, '#34d399', 0.9) +
leaf(36, 22, 6, '#34d399', 0.9) +
leaf(24, 10, 7, '#386641', 0.95) +
bloom
: bloom
let canopy = ''
if (level === 1) {
canopy = leaf(24, 38, 4, '#afe2b3') + `<path d="M24 38v-5" stroke="#386641" stroke-width="1.2" stroke-linecap="round"/>`
} else if (level === 2) {
canopy = leaf(24, 32, 5.5, '#34d399') + leaf(20, 34, 3.5, '#6ee7b7', 0.9)
} else if (level <= 4) {
canopy =
leaf(24, 28, 7, '#34d399') +
leaf(17, 30, 5, '#6ee7b7', 0.9) +
leaf(31, 30, 5, '#6ee7b7', 0.9)
} else if (level <= 6) {
canopy =
leaf(24, 24, 9, '#22c55e') +
leaf(14, 26, 7, '#4ade80', 0.92) +
leaf(34, 26, 7, '#4ade80', 0.92) +
leaf(24, 16, 6, '#386641', 0.88)
} else {
canopy =
leaf(24, 20, 10, '#204e2b') +
leaf(13, 24, 8, '#34d399', 0.95) +
leaf(35, 24, 8, '#34d399', 0.95) +
crown
}
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 56" fill="none">${pot}${soil}${trunk}${canopy}</svg>`
}
const treeImageSrc = computed(() => svgToDataUrl(buildTreeSvg(clampedLevel.value)))
</script>
<style lang="scss" scoped>
.sugar-tree-graphic {
position: relative;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.size-sm { width: 64rpx; height: 72rpx; }
.size-md { width: 80rpx; height: 88rpx; }
.size-lg { width: 112rpx; height: 124rpx; }
.stg-glow {
position: absolute;
inset: 6%;
border-radius: 50%;
background: radial-gradient(circle, rgba(32, 78, 43, 0.22) 0%, transparent 68%);
pointer-events: none;
}
.lv-0 .stg-glow { background: radial-gradient(circle, rgba(148, 163, 184, 0.25) 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(32, 78, 43, 0.15) 55%, transparent 72%);
}
.stg-aura {
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 {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
transition: transform 0.35s ease;
}
.sugar-tree-graphic.watering .stg-image,
.sugar-tree-graphic.watering .stg-emoji {
animation: stg-water-bounce 0.65s ease;
}
.stg-emoji {
position: relative;
z-index: 1;
line-height: 1;
font-size: 72rpx;
}
.size-sm .stg-emoji { font-size: 48rpx; }
.size-md .stg-emoji { font-size: 60rpx; }
.size-lg .stg-emoji { font-size: 88rpx; }
@keyframes stg-water-bounce {
0%, 100% { transform: scale(1); }
35% { transform: scale(1.1) translateY(-6rpx); }
60% { transform: scale(0.96) translateY(2rpx); }
}
.stg-sparkle {
position: absolute;
z-index: 2;
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: #fde68a;
box-shadow: 0 0 6rpx rgba(253, 224, 71, 0.8);
pointer-events: none;
animation: stg-sparkle-twinkle 1.8s ease-in-out infinite;
}
.stg-sparkle-a { top: 4%; right: 16%; }
.stg-sparkle-b { top: 12%; left: 10%; width: 6rpx; height: 6rpx; animation-delay: 0.4s; }
.stg-sparkle-c { top: 22%; right: 28%; width: 10rpx; height: 10rpx; animation-delay: 0.8s; }
@keyframes stg-sparkle-twinkle {
0%, 100% { opacity: 0.4; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1.2); }
}
</style>
@@ -0,0 +1,190 @@
<template>
<view class="tj-icon-wrap" :class="[`tj-icon-wrap--${size}`]">
<image
v-if="!useFallback"
class="tj-icon"
:class="[`tj-icon--${name}`, `tj-icon--${size}`]"
:src="iconSrc"
mode="aspectFit"
@error="onImageError"
/>
<!-- <text
v-else
class="tj-icon-fallback"
:class="[`tj-icon-fallback--${size}`]"
:style="{ color }"
>{{ fallbackGlyph }}</text> -->
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { svgToDataUrl } from '../utils/svgDataUrl.js'
const props = defineProps({
name: { type: String, required: true },
size: { type: String, default: 'md' },
color: { type: String, default: '#204E2B' }
})
const useFallback = ref(false)
/** Lucide 风格描边路径 */
const ICON_PATHS = {
view: '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
ticket: '<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v2"/><path d="M13 17v2"/><path d="M13 11v2"/>',
calendar: '<rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
flame: '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
'check-circle': '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/>',
glucose: '<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>',
heart: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>',
activity: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
user: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
person: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
send: '<path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/>',
'alert-triangle': '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
minus: '<path d="M5 12h14"/>',
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"/>',
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"/>',
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"/>',
mic: '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/>',
camera: '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>',
bulb: '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1.3.5 2.6 1.5 3.5.8.8 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
leaf: '<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z"/><path d="M2 21c0-3 1.85-5.36 5.08-6"/>',
utensils: '<path d="M3 2v7c0 1.1.9 2 2 2a2 2 0 0 0 2-2V2"/><path d="M7 2v20"/><path d="M21 15V2a5 5 0 0 0-3 4.5v6a2 2 0 0 0 2 2h1Z"/><path d="M18 15v7"/>',
egg: '<path d="M12 22c4.97 0 8-3.27 8-7.31C20 9.65 16.42 2 12 2S4 9.65 4 14.69C4 18.73 7.03 22 12 22Z"/>',
home: '<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
'plus-circle': '<circle cx="12" cy="12" r="10"/><path d="M8 12h8M12 8v8"/>',
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
'chevron-left': '<path d="m15 18-6-6 6-6"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
syringe: '<path d="m18 2 4 4"/><path d="m17 7 3-3"/><path d="M19 9 8.7 19.3c-1 1-2.5 1-3.4 0l-.6-.6c-1-1-1-2.5 0-3.4L15 5"/><path d="m9 11 4 4"/><path d="m5 19-3 3"/><path d="m14 4 6 6"/>',
zap: '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>'
}
/** 图片加载失败时的 emoji 回退 */
const ICON_FALLBACK = {
view: '👁',
ticket: '🎫',
calendar: '📅',
flame: '🔥',
'check-circle': '✓',
glucose: '💧',
heart: '❤',
activity: '🏃',
users: '👥',
user: '👤',
person: '👤',
send: '➤',
'alert-triangle': '⚠',
minus: '—',
droplet: '💧',
sparkles: '✨',
trophy: '🏆',
share: '↗',
plus: '',
refresh: '↻',
volume: '🔊',
pause: '⏸',
play: '▶',
info: '!',
sun: '☀',
moon: '🌙',
sunset: '☀',
mic: '🎤',
camera: '📷',
bulb: '💡',
leaf: '🥬',
utensils: '🍴',
egg: '🍳',
home: '🏠',
'plus-circle': '⊕',
'chevron-right': '',
'chevron-left': '',
check: '✓',
settings: '⚙',
syringe: '💉',
zap: '⚡'
}
const strokeColor = computed(() => {
const c = String(props.color || '#204E2B').trim()
return /^#[0-9A-Fa-f]{3,8}$/.test(c) ? c : '#204E2B'
})
const iconSrc = computed(() => {
const path = ICON_PATHS[props.name] || ICON_PATHS.view
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${strokeColor.value}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`
return svgToDataUrl(svg)
})
const fallbackGlyph = computed(() => ICON_FALLBACK[props.name] || ICON_FALLBACK.view)
function onImageError() {
useFallback.value = true
}
</script>
<style lang="scss" scoped>
.tj-icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.tj-icon-wrap--sm {
width: 32rpx;
height: 32rpx;
}
.tj-icon-wrap--md {
width: 40rpx;
height: 40rpx;
}
.tj-icon-wrap--lg {
width: 48rpx;
height: 48rpx;
}
.tj-icon-wrap--xl {
width: 56rpx;
height: 56rpx;
}
.tj-icon {
display: block;
width: 100%;
height: 100%;
}
/* Lucide send 路径重心偏右上,微调使圆形按钮内视觉居中 */
.tj-icon--send {
transform: translate(-10%, 10%);
}
.tj-icon-fallback {
display: block;
line-height: 1;
font-weight: 700;
text-align: center;
}
.tj-icon-fallback--sm {
font-size: 24rpx;
}
.tj-icon-fallback--md {
font-size: 30rpx;
}
.tj-icon-fallback--lg {
font-size: 36rpx;
}
.tj-icon-fallback--xl {
font-size: 42rpx;
}
</style>
@@ -0,0 +1,189 @@
<template>
<view class="vsm" :class="[`vsm--${size}`, { 'vsm--active': active, 'vsm--on-dark': onDark }]">
<view class="vsm-body">
<view class="vsm-leaf" aria-hidden="true" />
<view class="vsm-face">
<view class="vsm-eye vsm-eye--l" />
<view class="vsm-eye vsm-eye--r" />
<view class="vsm-blush vsm-blush--l" />
<view class="vsm-blush vsm-blush--r" />
<view class="vsm-mouth" />
</view>
<view v-if="active" class="vsm-waves" aria-hidden="true">
<view class="vsm-wave vsm-wave--1" />
<view class="vsm-wave vsm-wave--2" />
<view class="vsm-wave vsm-wave--3" />
</view>
</view>
</view>
</template>
<script setup>
defineProps({
/** 正在按住说话 / 录音中 */
active: { type: Boolean, default: false },
/** 深色条背景上使用浅色卡通 */
onDark: { type: Boolean, default: false },
size: { type: String, default: 'md' }
})
</script>
<style scoped lang="scss">
.vsm {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.vsm--md { width: 72rpx; height: 72rpx; }
.vsm--sm { width: 64rpx; height: 64rpx; }
.vsm-body {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.vsm-leaf {
position: absolute;
top: 2rpx;
left: 50%;
width: 16rpx;
height: 16rpx;
margin-left: -8rpx;
border-radius: 0 100% 0 100%;
background: #34d399;
transform: rotate(-18deg);
z-index: 2;
}
.vsm--on-dark .vsm-leaf {
background: #a7f3d0;
}
.vsm-face {
position: relative;
width: 52rpx;
height: 52rpx;
border-radius: 50%;
background: #fef9c3;
border: 3rpx solid #047857;
box-shadow: 0 4rpx 10rpx rgba(4, 120, 87, 0.18);
z-index: 1;
}
.vsm--sm .vsm-face {
width: 46rpx;
height: 46rpx;
}
.vsm--on-dark .vsm-face {
background: #fffbeb;
border-color: rgba(255, 255, 255, 0.85);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.12);
}
.vsm-eye {
position: absolute;
top: 16rpx;
width: 6rpx;
height: 8rpx;
border-radius: 50%;
background: #064e3b;
}
.vsm--sm .vsm-eye { top: 14rpx; width: 5rpx; height: 7rpx; }
.vsm--on-dark .vsm-eye { background: #134e4a; }
.vsm-eye--l { left: 12rpx; }
.vsm-eye--r { right: 12rpx; }
.vsm--sm .vsm-eye--l { left: 10rpx; }
.vsm--sm .vsm-eye--r { right: 10rpx; }
.vsm-blush {
position: absolute;
top: 24rpx;
width: 10rpx;
height: 6rpx;
border-radius: 50%;
background: rgba(251, 113, 133, 0.45);
opacity: 0.85;
}
.vsm--sm .vsm-blush { top: 21rpx; width: 8rpx; height: 5rpx; }
.vsm-blush--l { left: 6rpx; }
.vsm-blush--r { right: 6rpx; }
.vsm--on-dark .vsm-blush { background: rgba(255, 255, 255, 0.35); }
.vsm-mouth {
position: absolute;
left: 50%;
bottom: 10rpx;
width: 18rpx;
height: 8rpx;
margin-left: -9rpx;
border-radius: 0 0 18rpx 18rpx;
background: #047857;
transform-origin: center top;
}
.vsm--sm .vsm-mouth {
bottom: 9rpx;
width: 16rpx;
height: 7rpx;
margin-left: -8rpx;
}
.vsm--on-dark .vsm-mouth { background: #ecfdf5; }
.vsm-waves {
position: absolute;
right: -2rpx;
top: 50%;
display: flex;
align-items: flex-end;
gap: 4rpx;
height: 28rpx;
margin-top: -14rpx;
z-index: 0;
}
.vsm-wave {
width: 5rpx;
border-radius: 4rpx;
background: #047857;
animation: vsm-wave-jump 0.72s ease-in-out infinite;
}
.vsm--on-dark .vsm-wave { background: rgba(255, 255, 255, 0.9); }
.vsm-wave--1 { height: 10rpx; animation-delay: 0s; }
.vsm-wave--2 { height: 18rpx; animation-delay: 0.12s; }
.vsm-wave--3 { height: 12rpx; animation-delay: 0.24s; }
.vsm--active .vsm-body {
animation: vsm-bob 0.9s ease-in-out infinite;
}
.vsm--active .vsm-mouth {
animation: vsm-talk 0.32s ease-in-out infinite alternate;
}
.vsm--active .vsm-eye {
animation: vsm-blink 2.4s ease-in-out infinite;
}
@keyframes vsm-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4rpx); }
}
@keyframes vsm-talk {
0% {
transform: scaleY(0.45);
border-radius: 0 0 18rpx 18rpx;
}
100% {
transform: scaleY(1.15);
height: 12rpx;
border-radius: 50%;
}
}
@keyframes vsm-blink {
0%, 42%, 46%, 100% { transform: scaleY(1); }
44% { transform: scaleY(0.15); }
}
@keyframes vsm-wave-jump {
0%, 100% { transform: scaleY(0.45); opacity: 0.55; }
50% { transform: scaleY(1); opacity: 1; }
}
</style>
@@ -0,0 +1,203 @@
import { ref, computed } from 'vue'
import { requestAiStream, parsePartialBloodCarePlainText } from '../utils/aiStreamRequest.js'
const emptyBloodCarePlan = () => ({
summary: '',
blood_advice: '',
tcm_plan: '',
watch_points: [],
next_steps: '',
control_level: '',
control_label: '',
disclaimer: '',
analysis: [],
source: '',
date: ''
})
/** AI 血糖照护建议(累计 7 天有记录后) */
export function useBloodCareAi(proxy, { diagnosisId, chartUseMockData, showUserToast, formatUserMessage }) {
const bloodCarePlan = ref(emptyBloodCarePlan())
const bloodCareLoading = ref(false)
const bloodCareStreaming = ref(false)
const bloodCareStreamField = ref('')
let careStreamBuffer = ''
/** 用户点击后才展开照护建议卡片 */
const bloodCarePanelOpen = ref(false)
const bloodCareEligible = computed(() => {
return !!diagnosisId.value && !chartUseMockData.value
})
const hasBloodCareContent = computed(() => {
const p = bloodCarePlan.value
return !!(p.summary || p.blood_advice || p.tcm_plan)
})
const bloodCareAnalysis = computed(() => {
const list = bloodCarePlan.value?.analysis
return Array.isArray(list) ? list : []
})
const bloodCareControlLabel = computed(() => {
const label = String(bloodCarePlan.value?.control_label || '').trim()
return label
})
const bloodCareDisclaimer = computed(() => {
return String(bloodCarePlan.value?.disclaimer || '').trim()
})
function detectCareStreamField(text) {
const tail = String(text || '').match(/(?:^|\n)(总评|血糖|中医|留意|复诊)[:]\s*([^\n]*)$/)
if (!tail) return 'summary'
const map = { '总评': 'summary', '血糖': 'blood_advice', '中医': 'tcm_plan', '留意': 'watch_points', '复诊': 'next_steps' }
return map[tail[1]] || 'summary'
}
function mergePartialCareFromStream() {
const partial = parsePartialBloodCarePlainText(careStreamBuffer)
const next = { ...bloodCarePlan.value }
for (const key of ['summary', 'blood_advice', 'tcm_plan', 'next_steps']) {
if (partial[key] != null) next[key] = partial[key]
}
if (Array.isArray(partial.watch_points)) next.watch_points = partial.watch_points
bloodCarePlan.value = next
bloodCareStreamField.value = detectCareStreamField(careStreamBuffer)
}
function applyBloodCarePayload(payload) {
if (!payload) return
const prevAnalysis = bloodCarePlan.value.analysis
bloodCarePlan.value = {
...emptyBloodCarePlan(),
...payload,
watch_points: Array.isArray(payload.watch_points) ? payload.watch_points : [],
analysis: Array.isArray(payload.analysis) && payload.analysis.length
? payload.analysis
: (Array.isArray(prevAnalysis) ? prevAnalysis : [])
}
}
function onBloodCareStreamEvent(event, payload) {
if (event === 'analysis') {
if (Array.isArray(payload?.analysis)) {
bloodCarePlan.value = { ...bloodCarePlan.value, analysis: payload.analysis }
}
} else if (event === 'delta') {
bloodCareStreaming.value = true
if (payload.buffer != null) {
careStreamBuffer = payload.buffer
} else {
careStreamBuffer += payload.text || ''
}
mergePartialCareFromStream()
} else if (event === 'done') {
applyBloodCarePayload(payload)
careStreamBuffer = ''
bloodCareStreamField.value = ''
} else if (event === 'error') {
showUserToast(formatUserMessage(payload?.message, '照护建议加载失败'))
}
}
async function fetchBloodCareAiFallback(refresh) {
const res = await proxy.apiUrl({
url: '/api/tcm/dailyBloodCareAiRecommend',
method: 'GET',
timeout: 35000,
data: {
diagnosis_id: diagnosisId.value,
refresh: refresh ? 1 : 0,
...(refresh ? { _t: Date.now() } : {})
}
}, false)
if (res?.code === 1 && res.data) {
applyBloodCarePayload(res.data)
} else if (res?.msg) {
showUserToast(res.msg)
}
}
function closeBloodCarePanel() {
bloodCarePanelOpen.value = false
}
/** 趋势图 AI 按钮:未展开则打开并生成;已展开则收起 */
async function toggleBloodCareFromChart() {
if (!bloodCareEligible.value || bloodCareLoading.value) return
if (bloodCarePanelOpen.value) {
closeBloodCarePanel()
return
}
bloodCarePanelOpen.value = true
if (!hasBloodCareContent.value) {
await fetchBloodCareAi(false)
}
}
async function fetchBloodCareAi(refresh = false) {
if (!bloodCareEligible.value || bloodCareLoading.value) return
bloodCareLoading.value = true
bloodCareStreaming.value = false
careStreamBuffer = ''
bloodCareStreamField.value = ''
if (refresh) {
applyBloodCarePayload(emptyBloodCarePlan())
}
try {
await requestAiStream({
baseUrl: proxy.$url,
url: '/api/tcm/dailyBloodCareAiRecommendStream',
method: 'GET',
data: {
diagnosis_id: diagnosisId.value,
refresh: refresh ? 1 : 0,
...(refresh ? { _t: Date.now() } : {})
},
onEvent: onBloodCareStreamEvent,
fallback: () => fetchBloodCareAiFallback(refresh)
})
} catch (e) {
try {
await fetchBloodCareAiFallback(refresh)
} catch (e2) {
showUserToast('照护建议加载失败')
}
} finally {
bloodCareLoading.value = false
bloodCareStreaming.value = false
bloodCareStreamField.value = ''
}
}
function refreshBloodCareAi() {
if (bloodCareLoading.value) return
fetchBloodCareAi(true)
}
function resetBloodCarePlan() {
applyBloodCarePayload(emptyBloodCarePlan())
careStreamBuffer = ''
bloodCareStreamField.value = ''
bloodCarePanelOpen.value = false
}
return {
bloodCarePlan,
bloodCareLoading,
bloodCareStreaming,
bloodCareStreamField,
bloodCareEligible,
bloodCarePanelOpen,
hasBloodCareContent,
bloodCareAnalysis,
bloodCareControlLabel,
bloodCareDisclaimer,
fetchBloodCareAi,
toggleBloodCareFromChart,
closeBloodCarePanel,
refreshBloodCareAi,
resetBloodCarePlan
}
}
@@ -0,0 +1,317 @@
import { ref, computed } from 'vue'
import { requestAiStream, parsePartialDietPlainText } from '../utils/aiStreamRequest.js'
const DIET_AI_PREFILL_KEY = 'tongji_diet_ai_prefill'
const emptyDietAiRecommend = () => ({
breakfast: '',
drinks: '',
lunch: '',
dinner: '',
tips: '',
avoid: [],
analysis: [],
exercise: null,
disclaimer: '',
rules_summary: '',
source: '',
date: ''
})
/** AI 饮食助手(今日推荐 + 能不能吃) */
export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage }) {
const dietAiRecommend = ref(emptyDietAiRecommend())
const dietAiLoading = ref(false)
const dietAiStreaming = ref(false)
const dietAiReplacing = ref(false)
const dietAiStreamField = ref('')
let dietStreamBuffer = ''
const dietAiAskText = ref('')
const dietAiAsking = ref(false)
const dietAiAskResult = ref({ advice: '', level: '', level_label: '', portion: '', food: '' })
const hasDietAiMealContent = computed(() => {
const r = dietAiRecommend.value
return !!(r.breakfast || r.drinks || r.lunch || r.dinner || r.tips)
})
function detectDietStreamField(text) {
const tail = String(text || '').match(/(?:^|\n)(早餐|喝的|午餐|晚餐|提示|少碰)[:]\s*([^\n]*)$/)
if (!tail) return 'breakfast'
const map = { '早餐': 'breakfast', '喝的': 'drinks', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'tips' }
return map[tail[1]] || 'breakfast'
}
function mergePartialDietFromStream() {
const partial = parsePartialDietPlainText(dietStreamBuffer)
const next = { ...dietAiRecommend.value }
for (const key of ['breakfast', 'drinks', 'lunch', 'dinner', 'tips']) {
if (partial[key] != null) next[key] = partial[key]
}
if (Array.isArray(partial.avoid)) next.avoid = partial.avoid
dietAiRecommend.value = next
dietAiStreamField.value = detectDietStreamField(dietStreamBuffer)
}
function applyDietRecommendPayload(payload) {
if (!payload) return
const prevAnalysis = dietAiRecommend.value.analysis
const prevExercise = dietAiRecommend.value.exercise
dietAiRecommend.value = {
...emptyDietAiRecommend(),
...payload,
avoid: Array.isArray(payload.avoid) ? payload.avoid : [],
analysis: Array.isArray(payload.analysis) && payload.analysis.length
? payload.analysis
: (Array.isArray(prevAnalysis) ? prevAnalysis : []),
exercise: (payload.exercise && typeof payload.exercise === 'object')
? payload.exercise
: (prevExercise || null)
}
}
function resolveDietDonePayload(payload) {
if (!payload) return payload
if (payload.source !== 'rule' || !dietStreamBuffer) return payload
const streamed = parsePartialDietPlainText(dietStreamBuffer)
if (!streamed.breakfast) return payload
return {
...emptyDietAiRecommend(),
...payload,
breakfast: streamed.breakfast,
drinks: streamed.drinks || payload.drinks || '',
lunch: streamed.lunch || payload.lunch || '',
dinner: streamed.dinner || payload.dinner || '',
tips: streamed.tips || payload.tips || '',
avoid: (Array.isArray(streamed.avoid) && streamed.avoid.length) ? streamed.avoid : (payload.avoid || []),
source: 'ai',
disclaimer: 'AI 建议供参考,仍请结合医嘱与血糖监测。'
}
}
function onDietAiStreamEvent(event, payload) {
if (event === 'analysis') {
if (Array.isArray(payload?.analysis)) {
dietAiRecommend.value = { ...dietAiRecommend.value, analysis: payload.analysis }
}
} else if (event === 'exercise') {
if (payload?.exercise && typeof payload.exercise === 'object') {
dietAiRecommend.value = { ...dietAiRecommend.value, exercise: payload.exercise }
}
} else if (event === 'delta') {
dietAiStreaming.value = true
if (payload.buffer != null) {
dietStreamBuffer = payload.buffer
} else {
dietStreamBuffer += payload.text || ''
}
mergePartialDietFromStream()
} else if (event === 'done') {
applyDietRecommendPayload(resolveDietDonePayload(payload))
dietStreamBuffer = ''
dietAiStreamField.value = ''
} else if (event === 'error') {
showUserToast(formatUserMessage(payload?.message, '饮食建议加载失败'))
}
}
async function fetchDietAiRecommendFallback(refresh) {
const res = await proxy.apiUrl({
url: '/api/tcm/dailyDietAiRecommend',
method: 'GET',
timeout: 35000,
data: {
diagnosis_id: diagnosisId.value,
refresh: refresh ? 1 : 0,
...(refresh ? { _t: Date.now() } : {})
}
}, false)
if (res?.code === 1 && res.data) {
applyDietRecommendPayload(res.data)
} else if (res?.msg) {
showUserToast(res.msg)
}
}
async function fetchDietAiRecommend(refresh = false) {
if (!diagnosisId.value || dietAiLoading.value) return
dietAiLoading.value = true
dietAiStreaming.value = false
dietAiReplacing.value = !!refresh
dietStreamBuffer = ''
dietAiStreamField.value = ''
if (refresh) {
applyDietRecommendPayload(emptyDietAiRecommend())
}
try {
await requestAiStream({
baseUrl: proxy.$url,
url: '/api/tcm/dailyDietAiRecommendStream',
method: 'GET',
data: {
diagnosis_id: diagnosisId.value,
refresh: refresh ? 1 : 0,
...(refresh ? { _t: Date.now() } : {})
},
onEvent: onDietAiStreamEvent,
fallback: () => fetchDietAiRecommendFallback(refresh)
})
} catch (e) {
try {
await fetchDietAiRecommendFallback(refresh)
} catch (e2) {
showUserToast('饮食建议加载失败')
}
} finally {
dietAiLoading.value = false
dietAiStreaming.value = false
dietAiReplacing.value = false
dietAiStreamField.value = ''
}
}
function refreshDietAiRecommend() {
if (dietAiLoading.value) return
fetchDietAiRecommend(true)
}
async function askDietAiFoodFallback(q) {
const res = await proxy.apiUrl({
url: '/api/tcm/dailyDietAiAsk',
method: 'POST',
timeout: 35000,
data: {
diagnosis_id: diagnosisId.value,
question: q
}
}, false)
if (res?.code === 1 && res.data) {
dietAiAskResult.value = {
advice: res.data.advice || '',
level: res.data.level || '',
level_label: res.data.level_label || '',
portion: res.data.portion || '',
food: res.data.food || q
}
} else {
showUserToast(formatUserMessage(res?.msg, '咨询失败'))
}
}
async function askDietAiFood() {
const q = String(dietAiAskText.value || '').trim()
if (!q) {
showUserToast('请输入想咨询的食物')
return
}
if (!diagnosisId.value) return
// 发送后立即清空输入框
dietAiAskText.value = ''
dietAiAsking.value = true
dietAiAskResult.value = { advice: '', level: '', level_label: '', portion: '', food: '' }
try {
await requestAiStream({
baseUrl: proxy.$url,
url: '/api/tcm/dailyDietAiAskStream',
method: 'POST',
data: {
diagnosis_id: diagnosisId.value,
question: q
},
onEvent: (event, payload) => {
if (event === 'start' && !dietAiAskResult.value.advice) {
dietAiAskResult.value = { ...dietAiAskResult.value, advice: payload.message || '正在想…' }
} else if (event === 'delta') {
dietAiAskResult.value = {
...dietAiAskResult.value,
advice: payload.advice || dietAiAskResult.value.advice
}
} else if (event === 'done') {
dietAiAskResult.value = {
advice: payload.advice || '',
level: payload.level || '',
level_label: payload.level_label || '',
portion: payload.portion || '',
food: payload.food || q
}
} else if (event === 'error') {
showUserToast(formatUserMessage(payload?.message, '咨询失败'))
}
},
fallback: () => askDietAiFoodFallback(q)
})
} catch (e) {
try {
await askDietAiFoodFallback(q)
} catch (e2) {
showUserToast('网络异常,请稍后再试')
}
} finally {
dietAiAsking.value = false
}
}
function onDietAiAskInput(e) {
dietAiAskText.value = e?.detail?.value ?? ''
}
function saveDietPrefillToStorage() {
const r = dietAiRecommend.value
if (!r.breakfast) return
try {
uni.setStorageSync(DIET_AI_PREFILL_KEY, {
breakfast: r.breakfast,
drinks: r.drinks || '',
lunch: r.lunch,
dinner: r.dinner,
tips: r.tips || ''
})
} catch (e) {}
}
function goMoreDietForm(withPrefill = false) {
if (withPrefill) saveDietPrefillToStorage()
uni.navigateTo({ url: '/tongji/pages/more?openDiet=1' })
}
async function applyDietAiToForm() {
if (!dietAiRecommend.value.breakfast) {
await fetchDietAiRecommend(false)
}
goMoreDietForm(true)
}
function openDietForm() {
goMoreDietForm(false)
}
return {
dietAiRecommend,
dietAiLoading,
dietAiStreaming,
dietAiReplacing,
dietAiStreamField,
dietAiAskText,
dietAiAsking,
dietAiAskResult,
hasDietAiMealContent,
fetchDietAiRecommend,
refreshDietAiRecommend,
askDietAiFood,
onDietAiAskInput,
applyDietAiToForm,
openDietForm
}
}
/** more 页打开饮食表单时读取并清除预填 */
export function consumeDietAiPrefill() {
try {
const data = uni.getStorageSync(DIET_AI_PREFILL_KEY)
uni.removeStorageSync(DIET_AI_PREFILL_KEY)
if (data && typeof data === 'object' && data.breakfast) return data
} catch (e) {}
return null
}
@@ -0,0 +1,450 @@
import { ref, onUnmounted } from 'vue'
const MIN_HOLD_MS = 280
/** 短按/过早松手时插件常见错误,不应打扰用户 */
function isBenignSttError(msg, retcode) {
const m = String(msg || '').toLowerCase()
if (/internal voice data failed/i.test(m)) return true
if (/voice data failed/i.test(m)) return true
if (/recordfailed|record failed|record manager/i.test(m)) return true
if (/please stop after start/i.test(m)) return true
if (/no recognition|not start|未开始|无识别/i.test(m)) return true
const code = Number(retcode)
if (code === -30002 || code === -30003 || code === -30012) return true
return false
}
function friendlySttError(msg) {
const m = String(msg || '').trim()
if (!m || isBenignSttError(m)) return ''
if (/not allowed|auth|permission|权限|麦克风/i.test(m)) return '请允许使用麦克风'
if (/network|网络|timeout/i.test(m)) return '网络异常,请稍后再试'
return '语音识别失败,请重试'
}
// #ifdef MP-WEIXIN
// WechatSI 录音管理器是全局单例:回调只在模块级绑定一次,
// 再分发给「当前活跃实例」。否则跨页面(如 more → weekly)后,
// 回调仍指向已销毁页面的闭包,新页面收不到 onStart/onStop
// 其本地状态卡在 recording,表现为一直录制/无法再次语音。
let sharedManager = null
let sharedManagerInited = false
let sharedRecording = false
let activeCtl = null
function getSharedRecordManager() {
if (sharedManagerInited) return sharedManager
sharedManagerInited = true
try {
// eslint-disable-next-line no-undef
const plugin = requirePlugin('WechatSI')
sharedManager = plugin.getRecordRecognitionManager()
} catch (e) {
console.warn('WechatSI record recognition not available', e)
sharedManager = null
return null
}
if (!sharedManager) return null
sharedManager.onStart = () => {
sharedRecording = true
if (activeCtl) activeCtl.handleStart()
}
sharedManager.onRecognize = (res) => {
if (activeCtl) activeCtl.handleRecognize(res)
}
sharedManager.onStop = (res) => {
sharedRecording = false
if (activeCtl) activeCtl.handleStop(res)
}
sharedManager.onError = (res) => {
sharedRecording = false
if (activeCtl) activeCtl.handleError(res)
}
return sharedManager
}
// #endif
/**
* 语音转文字(长按说话):微信小程序 WechatSIH5 Web Speech API。
*
* @param {{
* onResult?: (text: string) => void,
* onSettle?: (text: string, info: { heldMs: number }) => boolean | void,
* onPartial?: (text: string) => void,
* onError?: (msg: string) => boolean | void,
* showToast?: (msg: string) => void
* }} options
*
* onSettle 在每次录音结束时都会触发(含空结果),用于语音一问一答等需要
* 完全接管识别结果的场景。若 onSettle 返回 true,则跳过默认的 onResult 回调
* 与「没听清」提示,避免与上层流程重复处理。
*
* onPartial 在录音过程中实时返回中间识别结果(微信 onRecognize / H5 interim),
* 用于「抢答打断」等场景:检测到用户已开口作答即可提前结束播报/录音。
*
* onError 在录音/识别出错时触发(如 "record manager recordfailed")。
* 若返回 true,则跳过默认的错误 Toast,由上层自行处理(如自动重试)。
*/
export function useSpeechToText({ onResult, onSettle, onPartial, onError, showToast } = {}) {
const sttListening = ref(false)
const sttHolding = ref(false)
const sttSupported = ref(false)
let wxRecordManager = null
let h5Recognition = null
let h5GotResult = false
let holdStartTs = 0
let holdSessionId = 0
let startRequested = false
let recordAuthorized = null
// 快速点按可能残留会话:用排队标记把 start/stop 串行化,
// 避免 "please stop after start"
let pendingStartSession = 0
function notify(msg) {
if (!msg) return
if (typeof showToast === 'function') {
showToast(msg)
return
}
uni.showToast({ title: msg, icon: 'none', duration: 2000 })
}
function emitResult(text) {
const t = String(text || '').trim()
const heldMs = Date.now() - holdStartTs
// onSettle 总会收到结果(含空),返回 true 表示已完全接管
if (typeof onSettle === 'function') {
const handled = onSettle(t, { heldMs })
if (handled === true) return
}
if (!t) {
if (heldMs < MIN_HOLD_MS) return
notify('没听清,请再试一次')
return
}
if (typeof onResult === 'function') {
onResult(t)
}
}
function stopListening() {
// #ifdef MP-WEIXIN
const ownsRecording = sharedRecording && activeCtl === controller
// 仅在识别已真正开始后再 stop,避免 -30012 / internal voice data failed
if (wxRecordManager && (sttListening.value || ownsRecording)) {
try {
wxRecordManager.stop()
} catch (e) {}
}
// #endif
// #ifdef H5
if (h5Recognition && sttListening.value) {
try {
h5Recognition.stop()
} catch (e) {}
}
// #endif
startRequested = false
sttHolding.value = false
sttListening.value = false
}
function ensureRecordAuth(onGranted) {
// #ifdef MP-WEIXIN
if (recordAuthorized === true) {
onGranted()
return
}
uni.authorize({
scope: 'scope.record',
success() {
recordAuthorized = true
onGranted()
},
fail() {
recordAuthorized = false
sttHolding.value = false
uni.showModal({
title: '需要麦克风权限',
content: '请在设置中允许使用麦克风,以便长按说话',
confirmText: '去设置',
success(res) {
if (res.confirm) {
uni.openSetting({})
}
}
})
}
})
// #endif
// #ifndef MP-WEIXIN
onGranted()
// #endif
}
// #ifdef MP-WEIXIN
function actuallyStartWechat(session) {
if (!wxRecordManager || session !== holdSessionId || !sttHolding.value) {
return
}
try {
startRequested = true
wxRecordManager.start({
duration: 60000,
lang: 'zh_CN'
})
} catch (e) {
startRequested = false
sharedRecording = false
sttHolding.value = false
notify('无法开始录音')
}
}
// #endif
function startWechatRecord(session) {
// #ifdef MP-WEIXIN
if (!wxRecordManager || session !== holdSessionId || !sttHolding.value) {
return
}
// 上一段会话还没结束(含全局单例残留):先停止并排队,
// 待 onStop 回调后再真正开始,避免 "please stop after start"
if (sharedRecording || startRequested) {
pendingStartSession = session
try {
wxRecordManager.stop()
} catch (e) {}
return
}
actuallyStartWechat(session)
// #endif
}
function startH5Record() {
// #ifdef H5
if (!h5Recognition) {
notify('当前浏览器不支持语音输入')
sttHolding.value = false
return
}
try {
startRequested = true
h5GotResult = false
h5Recognition.start()
sttListening.value = true
} catch (e) {
startRequested = false
sttHolding.value = false
notify('无法开始语音识别,请检查麦克风权限')
}
// #endif
}
/** 长按开始:手指按下 */
function beginHoldSpeech() {
if (!sttSupported.value) {
notify('当前环境不支持语音输入')
return
}
if (sttHolding.value) return
const session = ++holdSessionId
holdStartTs = Date.now()
sttHolding.value = true
// #ifdef MP-WEIXIN
// 把全局录音回调切到当前实例
activeCtl = controller
ensureRecordAuth(() => startWechatRecord(session))
// #endif
// #ifdef H5
startH5Record()
// #endif
// #ifndef MP-WEIXIN
// #ifndef H5
sttHolding.value = false
notify('当前端暂不支持语音输入')
// #endif
// #endif
}
/** 松手结束:手指抬起;force=true 时强制取消尚未真正开始的按住会话 */
function endHoldSpeech(force = false) {
let recording = false
// #ifdef MP-WEIXIN
recording = sharedRecording && activeCtl === controller
// #endif
if (!force && !sttHolding.value && !sttListening.value && !startRequested && !recording) {
return
}
sttHolding.value = false
// 松手后不再补开排队的录音
pendingStartSession = 0
if (sttListening.value || recording) {
stopListening()
return
}
if (startRequested) {
// start 已发出但 onStart 未到:取消会话,勿调 stop()
startRequested = false
holdSessionId += 1
return
}
holdSessionId += 1
}
// #ifdef MP-WEIXIN
// 当前实例的回调控制器:全局单例 manager 的事件由模块级分发器
// 转发给 activeCtl(最后一次 beginHoldSpeech 的实例)
const controller = {
handleStart() {
if (!sttHolding.value) {
// 用户已松手,忽略迟到的 onStart,不再 stop() 以免触发插件报错
startRequested = false
sttListening.value = false
return
}
startRequested = false
sttListening.value = true
},
handleRecognize(res) {
if (typeof onPartial !== 'function') return
const t = String(res?.result || '').trim()
if (t) onPartial(t)
},
handleStop(res) {
sttListening.value = false
startRequested = false
// 有排队的开始请求(残留会话已停止 / 用户仍按住):现在再真正开始
if (pendingStartSession && pendingStartSession === holdSessionId && sttHolding.value) {
const s = pendingStartSession
pendingStartSession = 0
actuallyStartWechat(s)
return
}
pendingStartSession = 0
emitResult(res?.result)
},
handleError(res) {
sttListening.value = false
startRequested = false
const msg = (res && res.msg) || ''
const retcode = res && (res.retcode ?? res.errCode ?? res.code)
// 上一段未停止就再次 start 触发:停止后若仍按住则自动重试,不打扰用户
if (/please stop after start/i.test(msg)) {
try {
wxRecordManager.stop()
} catch (e) {}
pendingStartSession = sttHolding.value ? holdSessionId : 0
return
}
pendingStartSession = 0
sttHolding.value = false
const heldMs = Date.now() - holdStartTs
if (isBenignSttError(msg, retcode) || heldMs < MIN_HOLD_MS) {
if (typeof onError === 'function') onError(msg || '')
return
}
// 上层(如一问一答流程)可接管错误并自行重试,返回 true 时不再弹默认提示
if (typeof onError === 'function') {
const handled = onError(msg || '')
if (handled === true) return
}
const tip = friendlySttError(msg)
if (tip) notify(tip)
}
}
wxRecordManager = getSharedRecordManager()
sttSupported.value = !!wxRecordManager
// #endif
// #ifdef H5
try {
if (typeof window !== 'undefined') {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition
if (SR) {
h5Recognition = new SR()
h5Recognition.lang = 'zh-CN'
h5Recognition.interimResults = true
h5Recognition.continuous = false
h5Recognition.maxAlternatives = 1
sttSupported.value = true
h5Recognition.onresult = (event) => {
let interim = ''
let finalText = ''
for (let i = event.resultIndex; i < event.results.length; i++) {
const r = event.results[i]
const txt = r?.[0]?.transcript || ''
if (r.isFinal) finalText += txt
else interim += txt
}
if (interim && typeof onPartial === 'function') onPartial(interim)
if (finalText) {
h5GotResult = true
emitResult(finalText)
}
}
h5Recognition.onend = () => {
sttListening.value = false
startRequested = false
sttHolding.value = false
// 无识别结果也要兜底触发一次 settle,便于一问一答流程重试
if (!h5GotResult) {
emitResult('')
}
h5GotResult = false
}
h5Recognition.onerror = (event) => {
sttListening.value = false
startRequested = false
sttHolding.value = false
const err = event?.error || ''
if (err === 'aborted') return
if (typeof onError === 'function') {
const handled = onError(err)
if (handled === true) return
}
if (err === 'not-allowed') {
notify('请允许浏览器使用麦克风')
} else {
notify('语音识别失败')
}
}
}
}
} catch (e) {
console.warn('H5 SpeechRecognition not available', e)
}
// #endif
onUnmounted(() => {
sttHolding.value = false
holdSessionId += 1
pendingStartSession = 0
stopListening()
// #ifdef MP-WEIXIN
// 释放全局回调指向,避免事件继续派发给已销毁的实例
if (activeCtl === controller) {
activeCtl = null
}
// #endif
})
return {
sttListening,
sttHolding,
sttSupported,
beginHoldSpeech,
endHoldSpeech,
stopSpeechToText: endHoldSpeech
}
}
export { isBenignSttError, friendlySttError }
@@ -0,0 +1,115 @@
import { ref } from 'vue'
import { formatUserMessage } from '../utils/tongjiHelpers.js'
/** 登录 / 就诊卡门禁(index 与 more 共用) */
export function useTongjiAuth(proxy) {
const authChecking = ref(false)
let gateRedirected = false
let authSessionPromise = null
function hasAuthToken() {
return !!String(uni.getStorageSync('token') || '').trim()
}
function clearAuthStorage() {
uni.removeStorageSync('token')
authSessionPromise = null
}
function wxLoginGetCode() {
return new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (res) => {
if (res && res.code) resolve(res.code)
else reject(new Error('微信登录未返回 code'))
},
fail: reject
})
})
}
async function mnpLoginWithCode(code) {
const res = await proxy.apiUrl({
url: '/api/login/mnpLogin',
method: 'POST',
data: { code }
}, false)
if (res && res.code === 1 && res.data && res.data.token) {
uni.setStorageSync('token', res.data.token)
uni.setStorageSync('userData', res.data)
return res.data
}
clearAuthStorage()
throw new Error(formatUserMessage(res?.msg, '登录失败'))
}
async function doWxLogin() {
const code = await wxLoginGetCode()
await mnpLoginWithCode(code)
return hasAuthToken()
}
async function verifyOrLogin() {
if (!hasAuthToken()) {
return doWxLogin()
}
try {
const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)
if (res && res.code === 1 && res.data) {
uni.setStorageSync('userData', res.data)
return true
}
} catch (e) {
/* 网络异常走重新登录 */
}
clearAuthStorage()
try {
return await doWxLogin()
} catch (e2) {
return false
}
}
async function ensureLoggedIn() {
if (authSessionPromise) {
return authSessionPromise
}
authSessionPromise = verifyOrLogin()
.then((ok) => {
if (!ok) authSessionPromise = null
return !!ok
})
.catch(() => {
authSessionPromise = null
return false
})
return authSessionPromise
}
function redirectToCardEntry(returnPath) {
if (gateRedirected) return
gateRedirected = true
const returnUrl = encodeURIComponent(returnPath || '/tongji/pages/index')
uni.redirectTo({
url: `/pages/Card/edit_card?add=1&returnUrl=${returnUrl}`
})
}
function resetGateRedirect() {
gateRedirected = false
}
function isGateRedirected() {
return gateRedirected
}
return {
authChecking,
hasAuthToken,
ensureLoggedIn,
redirectToCardEntry,
resetGateRedirect,
isGateRedirected
}
}
@@ -0,0 +1,28 @@
# VitalMint Health (Stitch)
Source: Google Stitch MCP · project `Modern WeChat UI Redesign`
Screen HTML: `design-system/stitch-weekly.html`
## Colors
- background / surface: `#f4fbf4`
- primary: `#006c49`
- primary-container: `#10b981`
- tertiary-container (餐后): `#fc7c78`
- on-surface: `#161d19`
- error (high glucose): `#ba1a1a`
- warning: `#ea580c`
## Layout (weekly.vue)
1. TopAppBar — 头像 + 问候 + 朗读按钮
2. 录入今日血糖 — 全宽圆角主按钮
3. 今日血糖 — 双列卡片 + 查看更多
4. 血糖趋势 — 单卡片内含统计、图表、7/30 切换
5. AI 饮食 — 渐变边框白底卡片
6. 能不能吃 — 独立卡片
## Styles
- `styles/stitch-vitalmint-theme.scss` — 设计 token
- `styles/weekly-stitch.scss` — 页面组件样式 (vm-*)
@@ -0,0 +1,398 @@
<!DOCTYPE html><html lang="zh-CN" style=""><head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>血糖管理</title>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&amp;display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"on-error-container": "#93000a",
"on-secondary-fixed-variant": "#3e4943",
"on-background": "#161d19",
"error-container": "#ffdad6",
"surface-container-highest": "#dde4dd",
"surface-container-high": "#e3eae3",
"primary-fixed-dim": "#4edea3",
"on-secondary-container": "#5b6760",
"on-primary": "#ffffff",
"inverse-surface": "#2b322d",
"tertiary-fixed-dim": "#ffb3af",
"primary": "#006c49",
"surface-container-lowest": "#ffffff",
"on-primary-fixed-variant": "#005236",
"on-surface-variant": "#3c4a42",
"surface-container": "#e8f0e9",
"tertiary-container": "#fc7c78",
"on-tertiary": "#ffffff",
"on-tertiary-container": "#711419",
"surface-container-low": "#eef6ee",
"on-secondary-fixed": "#131e19",
"inverse-primary": "#4edea3",
"surface-tint": "#006c49",
"secondary-fixed-dim": "#bdcac1",
"on-primary-container": "#00422b",
"on-secondary": "#ffffff",
"secondary-container": "#d9e6dd",
"tertiary": "#a43a3a",
"on-error": "#ffffff",
"secondary-fixed": "#d9e6dd",
"background": "#f4fbf4",
"tertiary-fixed": "#ffdad7",
"outline": "#6c7a71",
"primary-fixed": "#6ffbbe",
"on-tertiary-fixed": "#410005",
"outline-variant": "#bbcabf",
"primary-container": "#10b981",
"on-tertiary-fixed-variant": "#842225",
"surface-variant": "#dde4dd",
"on-primary-fixed": "#002113",
"on-surface": "#161d19",
"surface-bright": "#f4fbf4",
"inverse-on-surface": "#ebf3eb",
"surface": "#f4fbf4",
"surface-dim": "#d4dcd5",
"secondary": "#55615a",
"error": "#ba1a1a"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px",
"2xl": "1.5rem",
"3xl": "2rem"
},
"spacing": {
"container-margin": "20px",
"stack-gap": "16px",
"section-margin": "32px",
"inline-gap": "12px",
"card-padding": "20px"
},
"fontFamily": {
"body-lg": [
"Manrope"
],
"headline-lg": [
"Manrope"
],
"headline-md": [
"Manrope"
],
"display-lg": [
"Manrope"
],
"headline-lg-mobile": [
"Manrope"
],
"label-md": [
"Manrope"
],
"body-md": [
"Manrope"
]
},
"fontSize": {
"body-lg": [
"16px",
{
"lineHeight": "24px",
"fontWeight": "500"
}
],
"headline-lg": [
"24px",
{
"lineHeight": "32px",
"letterSpacing": "-0.01em",
"fontWeight": "700"
}
],
"headline-md": [
"20px",
{
"lineHeight": "28px",
"fontWeight": "700"
}
],
"display-lg": [
"32px",
{
"lineHeight": "40px",
"letterSpacing": "-0.02em",
"fontWeight": "800"
}
],
"headline-lg-mobile": [
"22px",
{
"lineHeight": "28px",
"fontWeight": "700"
}
],
"label-md": [
"12px",
{
"lineHeight": "16px",
"letterSpacing": "0.02em",
"fontWeight": "600"
}
],
"body-md": [
"14px",
{
"lineHeight": "20px",
"fontWeight": "400"
}
]
}
},
},
}
</script>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.icon-fill {
font-variation-settings: 'FILL' 1;
}
.ambient-shadow {
box-shadow: 0 12px 30px -10px rgba(0, 108, 73, 0.08), 0 4px 10px -4px rgba(0, 0, 0, 0.03);
}
.btn-press:active {
transform: scale(0.98);
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-background text-on-surface font-body-lg antialiased pb-24 md:pb-0 min-h-screen">
<!-- Desktop Sidebar Shell (Hidden on Mobile) -->
<div class="hidden md:flex fixed left-0 top-0 h-full w-64 bg-surface-container-lowest border-r border-surface-dim z-50 flex-col">
<div class="p-6">
<h1 class="text-headline-lg font-headline-lg text-primary">VitalMint</h1>
</div>
<nav class="flex-1 px-4 flex flex-col gap-2 mt-4">
<a class="flex items-center gap-3 px-4 py-3 rounded-xl bg-primary-container text-on-primary-container font-bold" href="#">
<span class="material-symbols-outlined icon-fill">home</span>
<span class="">首页</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 rounded-xl text-on-surface-variant hover:bg-surface-container-high transition-colors" href="#">
<span class="material-symbols-outlined">add_circle</span>
<span class="">记录</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 rounded-xl text-on-surface-variant hover:bg-surface-container-high transition-colors" href="#">
<span class="material-symbols-outlined">analytics</span>
<span class="">动态</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 rounded-xl text-on-surface-variant hover:bg-surface-container-high transition-colors" href="#">
<span class="material-symbols-outlined">person</span>
<span class="">我的</span>
</a>
</nav>
</div>
<!-- Main Content Area -->
<main class="md:ml-64 w-full max-w-[1200px] mx-auto min-h-screen flex flex-col md:flex-row">
<!-- Left/Main Column: Dashboard -->
<div class="flex-1 w-full">
<!-- TopAppBar -->
<header class="bg-surface dark:bg-surface-dim docked full-width top-0 flex justify-between items-center px-container-margin py-4 w-full sticky z-40">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-primary-container flex items-center justify-center overflow-hidden border-2 border-surface-container-lowest">
<span class="material-symbols-outlined text-primary">person</span>
</div>
<div>
<h1 class="text-headline-lg-mobile font-headline-lg-mobile text-on-surface dark:text-on-background">下午好,用户16295733</h1>
<p class="text-label-md font-label-md text-on-surface-variant opacity-80">今日血糖已记录</p>
</div>
</div>
<button class="w-10 h-10 rounded-full flex items-center justify-center hover:bg-surface-container dark:hover:bg-surface-container-high transition-colors text-primary dark:text-primary-fixed-dim">
<span class="material-symbols-outlined" data-icon="volume_up">volume_up</span>
</button>
</header>
<!-- Content Canvas -->
<div class="px-container-margin pb-section-margin flex flex-col gap-6 mt-2">
<!-- 1. Primary Action (Prominent) -->
<section>
<button class="w-full bg-primary text-on-primary h-14 rounded-2xl flex items-center justify-center gap-2 text-body-lg font-bold btn-press shadow-[0_8px_16px_rgba(0,108,73,0.2)] transition-all">
<span class="material-symbols-outlined icon-fill">add</span>
录入今日血糖
</button>
</section>
<!-- 2. 血糖趋势 (Trends) - Moved to top -->
<section class="bg-surface-container-lowest rounded-3xl p-5 ambient-shadow border border-surface-container-highest/30">
<div class="flex justify-between items-center mb-6">
<div>
<h2 class="text-headline-md font-headline-md text-on-surface">血糖趋势</h2>
<p class="text-label-md font-label-md text-on-surface-variant">最近 7 天 · mmol/L</p>
</div>
<div class="flex items-center gap-3 text-label-md font-label-md text-on-surface-variant">
<div class="flex items-center gap-1"><div class="w-2 h-2 rounded-full bg-primary"></div>空腹</div>
<div class="flex items-center gap-1"><div class="w-2 h-2 rounded-full bg-tertiary"></div>餐后</div>
</div>
</div>
<!-- Stats Row -->
<div class="flex justify-between items-center bg-surface-container-low rounded-xl p-4 mb-6">
<div class="flex flex-col items-center flex-1 border-r border-surface-container-highest">
<span class="text-headline-md font-headline-md text-tertiary">1</span>
<span class="text-label-md font-label-md text-on-surface-variant mt-1">天偏高</span>
</div>
<div class="flex flex-col items-center flex-1 border-r border-surface-container-highest">
<span class="text-headline-md font-headline-md text-primary">1</span>
<span class="text-label-md font-label-md text-on-surface-variant mt-1">天正常</span>
</div>
<div class="flex flex-col items-center flex-1">
<span class="text-headline-md font-headline-md text-on-surface">2</span>
<span class="text-label-md font-label-md text-on-surface-variant mt-1">天有记录</span>
</div>
</div>
<!-- Faux Chart Area -->
<div class="h-[180px] w-full relative flex items-end pt-4 pb-6 border-b border-surface-container-highest border-dashed">
<!-- Y Axis -->
<div class="absolute left-0 top-0 bottom-6 flex flex-col justify-between text-[10px] text-on-surface-variant">
<span class="">23.0</span>
<span class="">18.0</span>
<span class="">13.0</span>
<span class="">8.0</span>
<span class="">3.0</span>
</div>
<!-- Target Line -->
<div class="absolute left-6 right-0 bottom-12 border-b border-tertiary border-dashed opacity-30"></div>
<span class="absolute left-6 bottom-12 text-[10px] text-tertiary -translate-y-full mb-1">空腹阈 7</span>
<!-- Chart Lines -->
<div class="absolute inset-0 left-8 overflow-hidden">
<div class="absolute bottom-6 right-8 w-1 h-[100px] bg-primary/20 rounded-t-full transform rotate-45 origin-bottom"></div>
<div class="absolute bottom-[80px] right-2 w-1 h-[60px] bg-tertiary/20 rounded-t-full transform rotate-12 origin-bottom"></div>
<div class="absolute right-[15%] bottom-[15%] w-2 h-2 rounded-full border-[1.5px] border-primary bg-white z-10"></div>
<div class="absolute right-[5%] bottom-[75%] w-2 h-2 rounded-full border-[1.5px] border-tertiary bg-white z-10"></div>
<!-- Tooltips -->
<div class="absolute right-[2%] bottom-[85%] bg-tertiary text-white text-[10px] px-1.5 py-0.5 rounded font-bold">18</div>
<div class="absolute right-[2%] bottom-[65%] bg-tertiary text-white text-[10px] px-1.5 py-0.5 rounded font-bold">20</div>
</div>
<!-- X Axis -->
<div class="absolute left-8 right-0 bottom-0 flex justify-between text-[10px] text-on-surface-variant translate-y-full pt-2">
<span class="">05-23</span>
<span class="">05-25</span>
<span class="">05-27</span>
<span class="">05-29</span>
</div>
</div>
</section>
<!-- 3. 最新测量 & 概览 (Combined Section) -->
<section class="flex flex-col gap-4">
<!-- Latest Reading -->
<div class="bg-error-container text-on-error-container rounded-3xl p-6 ambient-shadow relative overflow-hidden flex flex-col items-center text-center justify-center min-h-[160px]">
<div class="absolute inset-0 opacity-10 pointer-events-none" style="background-image: radial-gradient(circle at 2px 2px, currentColor 1px, transparent 0); background-size: 16px 16px;"></div>
<span class="text-label-md font-label-md uppercase tracking-wider mb-1 opacity-90 z-10">最新测量 · 餐后</span>
<div class="flex items-baseline gap-1 z-10">
<span class="text-[56px] font-extrabold leading-none tracking-tighter">20.00</span>
<span class="text-body-lg font-body-lg opacity-80 font-medium">mmol/L</span>
</div>
<div class="mt-3 inline-flex items-center gap-1.5 bg-white/30 backdrop-blur-sm px-3 py-1 rounded-full z-10">
<span class="material-symbols-outlined text-[16px] icon-fill">warning</span>
<span class="text-label-md font-label-md font-bold">偏高,请遵医嘱</span>
</div>
</div>
<!-- Today's Summary -->
<div class="grid grid-cols-2 gap-3">
<div class="bg-surface-container-lowest rounded-2xl p-4 ambient-shadow flex flex-col gap-2 border border-surface-container-highest/50">
<span class="text-label-md font-label-md text-on-surface-variant">空腹</span>
<div class="flex items-baseline gap-1 text-tertiary">
<span class="text-headline-lg font-headline-lg">18.00</span>
</div>
<span class="text-[10px] text-tertiary bg-error-container/50 px-2 py-0.5 rounded-sm inline-block w-fit">偏高</span>
</div>
<div class="bg-surface-container-lowest rounded-2xl p-4 ambient-shadow flex flex-col gap-2 border border-surface-container-highest/50">
<span class="text-label-md font-label-md text-on-surface-variant">餐后</span>
<div class="flex items-baseline gap-1 text-tertiary">
<span class="text-headline-lg font-headline-lg">20.00</span>
</div>
<span class="text-[10px] text-tertiary bg-error-container/50 px-2 py-0.5 rounded-sm inline-block w-fit">偏高</span>
</div>
</div>
</section>
<!-- 4. AI 饮食建议 -->
<section class="bg-gradient-to-br from-primary/10 to-primary/5 rounded-3xl p-5 border border-primary/10 relative overflow-hidden">
<div class="absolute -right-8 -top-8 w-32 h-32 bg-primary-fixed-dim/20 rounded-full blur-2xl"></div>
<div class="flex justify-between items-center mb-4 relative z-10">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center">
<span class="material-symbols-outlined text-[18px]">temp_preferences_custom</span>
</div>
<h3 class="text-headline-md font-headline-md text-on-surface">AI 饮食建议</h3>
</div>
<button class="text-label-md font-label-md text-primary flex items-center gap-1 bg-white/50 px-3 py-1.5 rounded-full hover:bg-white transition-colors">
<span class="material-symbols-outlined text-[14px]">refresh</span>
换一换
</button>
</div>
<p class="text-body-md font-body-md text-on-surface-variant mb-4 relative z-10">读您近 7 天 · 30 天血糖,定制今日三餐</p>
<div class="flex flex-col gap-3 relative z-10">
<div class="bg-white/80 backdrop-blur-md rounded-2xl p-4 shadow-sm border border-white">
<div class="flex items-center gap-2 mb-2">
<span class="material-symbols-outlined text-orange-400">light_mode</span>
<span class="font-bold text-on-surface">早餐</span>
</div>
<p class="text-body-md font-body-md text-on-surface-variant">小米粥(少米多水)加煮鸡蛋一个</p>
</div>
<div class="bg-white/80 backdrop-blur-md rounded-2xl p-4 shadow-sm border border-white">
<div class="flex items-center gap-2 mb-2">
<span class="material-symbols-outlined text-primary">eco</span>
<span class="font-bold text-on-surface">午餐</span>
</div>
<p class="text-body-md font-body-md text-on-surface-variant">清蒸鱼块、蒜蓉炒苋菜、二米饭(小米掺大米)</p>
</div>
</div>
</section>
<!-- Spacer for bottom nav on mobile -->
<section class="sticky bottom-20 md:bottom-6 z-30 px-container-margin md:px-0 mb-4">
<div class="bg-white/90 backdrop-blur-md rounded-full p-2 pl-5 flex items-center gap-3 shadow-[0_8px_32px_rgba(0,108,73,0.15)] border border-primary/10">
<div class="w-8 h-8 rounded-full bg-primary/10 text-primary flex items-center justify-center">
<span class="material-symbols-outlined icon-fill text-[18px]">sparkles</span>
</div>
<input type="text" placeholder="输入食物名称或健康疑问,咨询 AI..." class="flex-1 bg-transparent border-none focus:ring-0 text-body-md placeholder:text-on-surface-variant/60 py-2">
<button class="bg-primary text-on-primary h-10 w-10 rounded-full flex items-center justify-center btn-press shadow-sm">
<span class="material-symbols-outlined">send</span>
</button>
</div>
</section><div class="h-8 md:hidden"></div>
</div>
</div>
<!-- Right Column: Secondary Info (Visible on Tablet/Desktop) -->
<aside class="hidden lg:flex w-80 flex-col gap-section-margin pt-4 pr-container-margin">
<div class="bg-surface-container-lowest rounded-3xl p-6 ambient-shadow">
<h3 class="text-headline-md font-headline-md text-on-surface mb-4">运动降糖</h3>
<div class="bg-error-container/30 rounded-xl p-4 mb-4">
<p class="text-body-md font-body-md text-on-surface">近期血糖偏高,三顿饭后都动一动,最能帮着把糖降下来。</p>
</div>
<ul class="flex flex-col gap-3 text-body-md text-on-surface-variant">
<li class="flex gap-2"><span class="font-bold text-tertiary">1</span> <span class="">早饭后快走 15 分钟</span></li>
<li class="flex gap-2"><span class="font-bold text-tertiary">2</span> <span class="">午饭后原地踏步 20-30 分钟</span></li>
</ul>
</div>
</aside>
</main>
<!-- BottomNavBar (Mobile Only) -->
</body></html>
@@ -0,0 +1,596 @@
<!DOCTYPE html><html class="light" lang="zh-CN"><head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>Daily Care - Health Dashboard</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&amp;display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet">
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"secondary-fixed": "#d9e6dd",
"surface-container": "#e8f0e9",
"surface-dim": "#d4dcd5",
"surface-variant": "#dde4dd",
"error-container": "#ffdad6",
"inverse-surface": "#2b322d",
"secondary": "#55615a",
"primary": "#006c49",
"on-secondary-fixed": "#131e19",
"surface-bright": "#f4fbf4",
"on-tertiary-container": "#711419",
"on-primary-fixed-variant": "#005236",
"outline-variant": "#bbcabf",
"primary-container": "#10b981",
"surface": "#f4fbf4",
"on-tertiary-fixed-variant": "#842225",
"tertiary-fixed-dim": "#ffb3af",
"tertiary-fixed": "#ffdad7",
"error": "#ba1a1a",
"surface-container-lowest": "#ffffff",
"surface-container-highest": "#dde4dd",
"primary-fixed": "#6ffbbe",
"on-primary-container": "#00422b",
"secondary-fixed-dim": "#bdcac1",
"on-tertiary": "#ffffff",
"on-primary-fixed": "#002113",
"on-error-container": "#93000a",
"tertiary-container": "#fc7c78",
"on-error": "#ffffff",
"inverse-primary": "#4edea3",
"inverse-on-surface": "#ebf3eb",
"on-secondary-container": "#5b6760",
"surface-container-high": "#e3eae3",
"surface-container-low": "#eef6ee",
"on-secondary-fixed-variant": "#3e4943",
"outline": "#6c7a71",
"secondary-container": "#d9e6dd",
"on-primary": "#ffffff",
"on-surface": "#161d19",
"on-background": "#161d19",
"tertiary": "#a43a3a",
"primary-fixed-dim": "#4edea3",
"on-secondary": "#ffffff",
"background": "#f4fbf4",
"on-surface-variant": "#3c4a42",
"surface-tint": "#006c49",
"on-tertiary-fixed": "#410005"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"2xl": "1rem",
"3xl": "1.5rem",
"full": "9999px"
},
"spacing": {
"card-padding": "20px",
"section-margin": "32px",
"inline-gap": "12px",
"stack-gap": "16px",
"container-margin": "20px"
},
"fontFamily": {
"headline-md": ["Manrope"],
"headline-lg-mobile": ["Manrope"],
"body-lg": ["Manrope"],
"headline-lg": ["Manrope"],
"display-lg": ["Manrope"],
"body-md": ["Manrope"],
"label-md": ["Manrope"]
},
"fontSize": {
"headline-md": ["20px", {"lineHeight": "28px", "fontWeight": "700"}],
"headline-lg-mobile": ["22px", {"lineHeight": "28px", "fontWeight": "700"}],
"body-lg": ["16px", {"lineHeight": "24px", "fontWeight": "500"}],
"headline-lg": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
"display-lg": ["32px", {"lineHeight": "40px", "letterSpacing": "-0.02em", "fontWeight": "800"}],
"body-md": ["14px", {"lineHeight": "20px", "fontWeight": "400"}],
"label-md": ["12px", {"lineHeight": "16px", "letterSpacing": "0.02em", "fontWeight": "600"}]
}
}
}
}
</script>
<style>
body { font-family: 'Manrope', sans-serif; -webkit-tap-highlight-color: transparent; }
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; display: inline-block; vertical-align: middle; }
.ambient-shadow { box-shadow: 0 20px 30px -10px rgba(0, 108, 73, 0.08); }
.active-scale:active { transform: scale(0.98); transition: all 0.2s ease; }
.hide-scrollbar::-webkit-scrollbar { display: none; }
.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
/* Watering Animation Styles */
@keyframes fall {
0% { transform: translateY(-20px); opacity: 0; }
50% { opacity: 1; }
100% { transform: translateY(40px); opacity: 0; }
}
.droplet {
position: absolute;
width: 4px;
height: 10px;
background: #3b82f6;
border-radius: 50%;
animation: fall 0.6s linear infinite;
}
@keyframes pulse-growth {
0% { transform: scale(1); filter: drop-shadow(0 0 0px rgba(78, 222, 163, 0)); }
50% { transform: scale(1.15); filter: drop-shadow(0 0 15px rgba(78, 222, 163, 0.6)); }
100% { transform: scale(1); filter: drop-shadow(0 0 0px rgba(78, 222, 163, 0)); }
}
.growth-pulse {
animation: pulse-growth 0.8s ease-out;
}
@keyframes shimmer {
0% { opacity: 0; transform: scale(0) rotate(0deg); }
50% { opacity: 1; transform: scale(1.2) rotate(180deg); }
100% { opacity: 0; transform: scale(0) rotate(360deg); }
}
.shimmer-particle {
position: absolute;
color: #4edea3;
font-size: 14px;
pointer-events: none;
animation: shimmer 1s ease-out forwards;
}
/* Blood Sugar Decrease Animation */
@keyframes float-up-fade {
0% { transform: translateY(20px); opacity: 0; }
20% { opacity: 1; }
80% { opacity: 1; }
100% { transform: translateY(-100px); opacity: 0; }
}
.sugar-decrease-text {
position: fixed;
color: #10b981;
font-weight: 800;
font-size: 24px;
pointer-events: none;
z-index: 100;
text-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
animation: float-up-fade 2s ease-out forwards;
}
@keyframes fall-arrow {
0% { transform: translateY(-50vh) scale(0.5); opacity: 0; }
50% { opacity: 0.8; }
100% { transform: translateY(110vh) scale(1.2); opacity: 0; }
}
.sugar-particle {
position: fixed;
color: #10b981;
pointer-events: none;
z-index: 90;
animation: fall-arrow 1.5s linear forwards;
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-background text-on-background min-h-screen pb-24">
<!-- Top App Bar -->
<header class="bg-primary docked full-width top-0 h-48 flex items-end pb-6 px-5 fixed left-0 right-0 z-40">
<div class="flex justify-between items-center w-full">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full border-2 border-on-primary overflow-hidden">
<img class="w-full h-full object-cover" data-alt="A professional studio portrait" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCXIPbeivladmw0-SmLno2vkwVvsObW2D5HUtodItlBnxdhFF8TvZdQYrTt14QCk3n5VXXIpdk3cuWTJHXJgDXd_clQyjLsb477e5h2313zh76yRVns01Q5pLpM-2_RbheRcUioLvObwJtIPJbkDcbkNA61-LyBnbStRcPoh6YOvNHSHjUUR_nDJpPm18ga87ggphbFWF-pt7lRF027swmyitUeVF7HXQDfcG7Mq7E0pqsbhyA7jKxtzmbN-HLh7GIrhc6LOAUaXiX9">
</div>
<div>
<h1 class="text-on-primary font-bold text-headline-lg-mobile leading-tight">Daily Care</h1>
<p class="text-on-primary/80 text-body-md">吕帅</p>
</div>
</div>
<div class="flex gap-2">
<button class="bg-on-primary/10 hover:bg-on-primary/20 text-on-primary rounded-full px-4 py-1.5 flex items-center gap-2 active-scale transition-all">
<span class="material-symbols-outlined text-[18px]" style="font-variation-settings: 'FILL' 1;">bloodtype</span>
<span class="text-label-md">血糖</span>
</button>
<button class="bg-on-primary/10 hover:bg-on-primary/20 text-on-primary w-10 h-10 rounded-full flex items-center justify-center active-scale transition-all">
<span class="material-symbols-outlined text-[20px]">refresh</span>
</button>
</div>
</div>
</header>
<main class="pt-52 px-container-margin space-y-6">
<!-- Toggle Switch -->
<div class="bg-surface-container-high rounded-full p-1 flex items-center">
<button class="flex-1 bg-primary text-on-primary rounded-full py-2 text-label-md font-bold shadow-md transition-all">最近 7 天</button>
<button class="flex-1 text-on-surface-variant py-2 text-label-md font-bold transition-all">最近 30 天</button>
</div>
<!-- Health Calendar -->
<section class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow">
<div class="flex justify-between items-start mb-4">
<div>
<h2 class="text-headline-md font-headline-md text-on-surface">健康日历</h2>
<p class="text-body-md text-outline">2026年5月 · 已记录 3 / 7 天</p>
</div>
<button class="text-primary"><span class="material-symbols-outlined">chevron_right</span></button>
</div>
<div class="grid grid-cols-7 gap-1 text-center mb-4">
<span class="text-label-md text-outline"></span>
<span class="text-label-md text-outline"></span>
<span class="text-label-md text-outline"></span>
<span class="text-label-md text-outline"></span>
<span class="text-label-md text-outline"></span>
<span class="text-label-md text-outline"></span>
<span class="text-label-md text-tertiary"></span>
<span class="py-2 text-body-md text-outline/30">18</span>
<span class="py-2 text-body-md text-outline/30">19</span>
<span class="py-2 text-body-md text-outline/30">20</span>
<span class="py-2 text-body-md text-outline/30">21</span>
<span class="py-2 text-body-md text-outline/30">22</span>
<div class="py-2 relative"><span class="text-body-md font-bold text-on-surface">23</span></div>
<div class="py-2 relative bg-primary-container/10 rounded-lg">
<span class="text-body-md font-bold text-primary">24</span>
<span class="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 bg-primary rounded-full"></span>
</div>
<div class="py-2 relative bg-primary-container/10 rounded-lg">
<span class="text-body-md font-bold text-primary">25</span>
<span class="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 bg-primary rounded-full"></span>
</div>
<div class="py-2 relative bg-primary-container/10 rounded-lg">
<span class="text-body-md font-bold text-primary">26</span>
<span class="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 bg-primary rounded-full"></span>
</div>
<span class="py-2 text-body-md text-on-surface">27</span>
<span class="py-2 text-body-md text-on-surface">28</span>
<div class="py-2 relative bg-tertiary-container rounded-lg">
<span class="text-body-md font-bold text-on-tertiary">29</span>
</div>
<span class="py-2 text-body-md text-outline/40">30</span>
<span class="py-2 text-body-md text-outline/40">31</span>
</div>
<div class="flex items-center justify-between border-t border-outline-variant/30 pt-4 mt-2">
<span class="text-body-md text-outline">今日还没有记录</span>
<button class="bg-primary text-on-primary px-6 py-3 rounded-2xl text-label-md font-bold active-scale transition-all flex items-center gap-2">去记血糖</button>
</div>
</section>
<!-- Blood Sugar Details Section -->
<section class="space-y-4">
<div class="flex justify-between items-center">
<h3 class="text-headline-md font-headline-md text-on-surface">血糖明细</h3>
<span class="text-body-md text-outline">共 3 天</span>
</div>
<div class="space-y-4">
<div class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow">
<div class="flex justify-between items-center mb-4">
<span class="text-headline-md font-bold">05-26</span>
<span class="text-body-md text-outline">周二</span>
</div>
<div class="space-y-2">
<div class="bg-background/50 border border-outline-variant/20 rounded-2xl p-4 flex justify-between items-center">
<span class="text-body-md text-outline">空腹</span>
<div class="text-right">
<span class="text-headline-lg font-extrabold text-error">10.4</span>
<span class="text-label-md text-outline ml-1">mmol/L</span>
</div>
</div>
<div class="bg-background/50 border border-outline-variant/20 rounded-2xl p-4 flex justify-between items-center">
<span class="text-body-md text-outline">餐后</span>
<span class="text-headline-md font-bold text-outline-variant">—— <small class="text-label-md">mmol/L</small></span>
</div>
</div>
</div>
<div class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow">
<div class="flex justify-between items-center mb-4">
<span class="text-headline-md font-bold">05-25</span>
<span class="text-body-md text-outline">周一</span>
</div>
<div class="bg-background/50 border border-outline-variant/20 rounded-2xl p-4 flex justify-between items-center">
<span class="text-body-md text-outline">空腹</span>
<div class="text-right">
<span class="text-headline-lg font-extrabold text-error">10.2</span>
<span class="text-label-md text-outline ml-1">mmol/L</span>
</div>
</div>
</div>
</div>
</section>
<!-- Reward Section: 稳糖乐园 -->
<section class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow overflow-hidden relative" id="sugar-paradise">
<div class="flex justify-between items-start mb-6">
<div>
<h3 class="text-headline-md font-headline-md text-on-surface">稳糖乐园</h3>
<p class="text-body-md text-outline">用健康打卡浇灌你的稳糖树</p>
</div>
<div class="bg-primary/10 rounded-xl px-3 py-1 text-center">
<span class="block text-headline-lg font-extrabold text-primary" id="total-points">110</span>
<span class="text-[10px] text-primary/70 font-bold">稳糖积分</span>
</div>
</div>
<!-- Mini Quick Actions -->
<div class="grid grid-cols-4 gap-2 mb-8">
<button class="bg-surface-container-low p-3 rounded-2xl flex flex-col items-center gap-1 active-scale transition-all">
<span class="material-symbols-outlined text-primary" style="font-variation-settings: 'FILL' 1;">bloodtype</span>
<span class="text-label-md text-on-surface">血糖</span>
</button>
<button class="bg-surface-container-low p-3 rounded-2xl flex flex-col items-center gap-1 active-scale transition-all">
<span class="material-symbols-outlined text-primary">monitoring</span>
<span class="text-label-md text-on-surface">血压</span>
</button>
<button class="bg-surface-container-low p-3 rounded-2xl flex flex-col items-center gap-1 active-scale transition-all">
<span class="material-symbols-outlined text-primary">restaurant</span>
<span class="text-label-md text-on-surface">饮食</span>
</button>
<button class="bg-surface-container-low p-3 rounded-2xl flex flex-col items-center gap-1 active-scale transition-all">
<span class="material-symbols-outlined text-primary">exercise</span>
<span class="text-label-md text-on-surface">运动</span>
</button>
</div>
<h4 class="text-label-md font-bold text-on-surface mb-3">今日控糖任务</h4>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full border-2 border-primary flex items-center justify-center">
<span class="material-symbols-outlined text-[16px] text-primary" style="font-variation-settings: 'wght' 700;">check</span>
</div>
<span class="text-body-md text-on-surface">测血糖血压</span>
</div>
<div class="flex items-center gap-3">
<span class="text-label-md text-primary font-bold">+10积分</span>
<span class="text-outline text-label-md font-bold italic">已完成</span>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full border-2 border-outline-variant/30 flex items-center justify-center">
<span class="w-2 h-2 rounded-full bg-outline-variant"></span>
</div>
<span class="text-body-md text-on-surface">记今日饮食</span>
</div>
<div class="flex items-center gap-3">
<span class="text-label-md text-primary font-bold">+10积分</span>
<button class="bg-on-surface text-surface rounded-full px-4 py-1 text-label-md font-bold active-scale transition-all">去记录</button>
</div>
</div>
</div>
<!-- Optimized Level System & Gamification -->
<div class="mt-8 bg-gradient-to-br from-primary/5 to-primary/20 rounded-3xl p-6 border border-primary/10 relative overflow-hidden">
<!-- Level Indicator Header -->
<div class="flex justify-between items-end mb-6">
<div class="space-y-1">
<div class="flex items-center gap-2">
<span class="bg-primary text-on-primary text-[10px] px-2 py-0.5 rounded-full font-extrabold" id="level-label">Lv.4</span>
<span class="text-body-lg font-extrabold text-primary" id="stage-name">枝繁叶茂</span>
</div>
<p class="text-[11px] text-primary/60">还差 <span class="font-bold">15 XP</span> 升级至 Lv.4</p>
</div>
<div class="text-right">
<span class="text-label-md font-bold text-primary" id="xp-text">5/50 XP</span>
</div>
</div>
<!-- Progress Bar -->
<div class="w-full h-2.5 bg-white/50 rounded-full mb-8 overflow-hidden backdrop-blur-sm border border-white/20">
<div class="h-full bg-gradient-to-r from-primary to-primary-container rounded-full transition-all duration-500 shadow-inner" id="xp-progress" style="width: 10%;"></div>
</div>
<!-- Plant Growth Display -->
<div class="flex items-center justify-center py-8 relative min-h-[160px]">
<!-- Watering Animation Container -->
<div class="absolute top-0 left-0 w-full h-full pointer-events-none z-10" id="rain-container"></div>
<!-- Visual Stage Indicator Background -->
<div class="absolute inset-0 flex items-center justify-center opacity-10 pointer-events-none">
<span class="text-[120px] material-symbols-outlined text-primary" style="font-variation-settings: 'FILL' 1;">eco</span>
</div>
<!-- Current Plant State -->
<div class="relative z-20 transition-all duration-300" id="plant-container">
<span class="text-7xl drop-shadow-2xl filter block" id="plant-emoji">🪴</span>
</div>
</div>
<!-- Watering Action -->
<div class="relative z-30">
<button class="w-full bg-primary text-on-primary py-4 rounded-2xl text-body-lg font-bold flex items-center justify-center gap-3 shadow-lg active:scale-95 transition-all active:bg-primary-container group" id="water-btn">
<span class="material-symbols-outlined text-[24px] transition-transform group-active:-rotate-12 group-hover:scale-110">opacity</span>
给小树浇水
</button>
<p class="text-[11px] text-center text-outline mt-3 italic">浇水可消耗 5 积分,获得 10 XP</p>
</div>
</div>
</section>
<!-- Social Interaction -->
<section class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-tertiary-container/20 rounded-2xl flex items-center justify-center">
<span class="material-symbols-outlined text-tertiary" style="font-variation-settings: 'FILL' 1;">favorite</span>
</div>
<div>
<h4 class="text-body-md font-bold text-on-surface">邀请家人点赞</h4>
<p class="text-[12px] text-outline">分享战报给家人,邀请他们为您点赞鼓励</p>
</div>
</div>
<span class="material-symbols-outlined text-outline">chevron_right</span>
</section>
<!-- Status Tip -->
<div class="flex items-start gap-3 px-2">
<span class="material-symbols-outlined text-primary-container text-[20px]">chat_bubble</span>
<div>
<p class="text-body-md text-on-surface">先完成血糖记录,<span class="text-primary font-bold">今日第一格就亮起来</span></p>
<button class="text-outline text-label-md hover:text-primary transition-colors">轻触换一句鼓励 </button>
</div>
</div>
<!-- Care List Horizontal -->
<div class="flex items-center gap-3 overflow-x-auto hide-scrollbar -mx-container-margin px-container-margin pb-2">
<div class="shrink-0 flex items-center gap-2 bg-white px-3 py-2 rounded-xl shadow-sm border border-outline-variant/20">
<span class="text-label-md text-outline">就诊卡</span>
<span class="text-label-md font-extrabold text-on-surface">10 张</span>
</div>
<div class="shrink-0 flex items-center gap-2 bg-primary text-on-primary px-3 py-2 rounded-xl shadow-md">
<span class="text-label-md font-bold">吕帅</span>
<span class="text-[10px] bg-white/20 rounded px-1">23岁</span>
</div>
<div class="shrink-0 flex items-center gap-2 bg-surface-container-high text-on-surface-variant px-3 py-2 rounded-xl border border-outline-variant/30">
<span class="text-label-md font-bold">霍爱玲</span>
<span class="text-[10px] text-outline">57岁</span>
</div>
</div>
</main>
<!-- Footer Note -->
<footer class="mt-8 text-center px-container-margin pb-12">
<p class="text-[11px] text-outline-variant/60">数据每日同步 · 阈值仅作参考,请遵医嘱</p>
</footer>
<!-- Navigation Bar -->
<script>
// State Management
let currentXP = 35;
let currentLevel = 3;
let totalPoints = 120;
const maxLevel = 10;
const xpPerLevel = 50;
const stageNames = [
"种子眠", "破土而出", "茁壮成长", "枝繁叶茂", "初绽花蕾",
"繁花似锦", "硕果初步", "丰收在望", "参天大树", "森林守护"
];
const plantEmojis = [
"🫘", "🌱", "🌿", "🪴", "🎍",
"🌸", "🍒", "🍎", "🌳", "🌲"
];
// DOM Elements
const waterBtn = document.getElementById('water-btn');
const rainContainer = document.getElementById('rain-container');
const plantContainer = document.getElementById('plant-container');
const plantEmoji = document.getElementById('plant-emoji');
const xpProgress = document.getElementById('xp-progress');
const xpText = document.getElementById('xp-text');
const levelLabel = document.getElementById('level-label');
const stageNameLabel = document.getElementById('stage-name');
const totalPointsLabel = document.getElementById('total-points');
// Watering Animation Logic
function createRain() {
for (let i = 0; i < 15; i++) {
const droplet = document.createElement('div');
droplet.className = 'droplet';
droplet.style.left = `${Math.random() * 100}%`;
droplet.style.animationDelay = `${Math.random() * 0.4}s`;
rainContainer.appendChild(droplet);
// Cleanup
setTimeout(() => droplet.remove(), 600);
}
}
function triggerGrowthPulse() {
plantContainer.classList.add('growth-pulse');
setTimeout(() => {
plantContainer.classList.remove('growth-pulse');
}, 800);
}
function createSparkles() {
for (let i = 0; i < 12; i++) {
const sparkle = document.createElement('span');
sparkle.className = 'material-symbols-outlined shimmer-particle';
sparkle.textContent = 'sparkles';
sparkle.style.left = `${40 + (Math.random() - 0.5) * 40}%`;
sparkle.style.top = `${40 + (Math.random() - 0.5) * 40}%`;
sparkle.style.animationDuration = `${0.6 + Math.random() * 0.4}s`;
plantContainer.appendChild(sparkle);
setTimeout(() => sparkle.remove(), 1000);
}
}
function triggerSugarDecreaseEffect() {
// 1. Floating Text
const floatingText = document.createElement('div');
floatingText.className = 'sugar-decrease-text';
floatingText.textContent = '-0.5 mmol/L';
// Randomize spawn position near the plant
const rect = plantContainer.getBoundingClientRect();
floatingText.style.left = `${rect.left + rect.width / 2}px`;
floatingText.style.top = `${rect.top}px`;
document.body.appendChild(floatingText);
setTimeout(() => floatingText.remove(), 2000);
// 2. Full-screen Downward Particles
for (let i = 0; i < 20; i++) {
setTimeout(() => {
const particle = document.createElement('div');
particle.className = 'sugar-particle text-primary-container';
// Mix arrows and symbols
particle.innerHTML = Math.random() > 0.5 ? '<span class="material-symbols-outlined">south</span>' : '▼';
particle.style.left = `${Math.random() * 100}vw`;
particle.style.fontSize = `${16 + Math.random() * 16}px`;
particle.style.opacity = '0';
document.body.appendChild(particle);
setTimeout(() => particle.remove(), 1500);
}, i * 50);
}
}
function updateDisplay() {
const progress = (currentXP / xpPerLevel) * 100;
xpProgress.style.width = `${progress}%`;
xpText.textContent = `${currentXP}/${xpPerLevel} XP`;
levelLabel.textContent = `Lv.${currentLevel}`;
stageNameLabel.textContent = stageNames[currentLevel - 1];
plantEmoji.textContent = plantEmojis[currentLevel - 1];
totalPointsLabel.textContent = totalPoints;
}
// Action Logic
waterBtn.addEventListener('click', () => {
if (totalPoints < 5) {
alert("积分不足,快去完成任务领积分吧!");
return;
}
// Lock button briefly
waterBtn.disabled = true;
waterBtn.classList.add('opacity-50');
// 1. Consume Points
totalPoints -= 5;
// 2. Trigger Animations
createRain();
setTimeout(() => {
triggerGrowthPulse();
createSparkles();
triggerSugarDecreaseEffect(); // New Effect
// 3. Update State
currentXP += 10;
if (currentXP >= xpPerLevel && currentLevel < maxLevel) {
currentXP -= xpPerLevel;
currentLevel++;
}
updateDisplay();
// Unlock button
waterBtn.disabled = false;
waterBtn.classList.remove('opacity-50');
}, 600);
});
// Initialize
updateDisplay();
// Simple button micro-interactions for others
document.querySelectorAll('button:not(#water-btn)').forEach(btn => {
btn.addEventListener('click', () => {
if(!btn.classList.contains('cursor-not-allowed')) {
// Interaction logic
}
});
});
</script>
</body></html>
@@ -0,0 +1 @@
{"name":"projects/15362588878567114380/screens/259c8c233434481881d0bacddb59fa7f","title":"血糖管理重构版","screenshot":{"name":"projects/15362588878567114380/files/c18dd3e3d7774a16897d78b40073b9f2","downloadUrl":"https://lh3.googleusercontent.com/aida/ADBb0ugpswBosy3eCG_j3kJlxCWpntXQGvRJvMuepINJTcB47Nf4ymWU81mrjex1ICPdAXn-p_JjyUokOtms4zIJpXL49692zt16mIJhEC16nfU9kH5hwAj1s7v3PlAZbUGWjYjPTdfVMaOCveS6Ohuh_IeyRTD6MmdsFZ2-H7Whg6zy4MiZeGLxRw0ckCOrNbTRObtuzk_dlUc49eDGeRagYtuL9xyS6pno5CiwBzSkGp872chdZVn9JOK-2n1P"},"htmlCode":{"name":"projects/15362588878567114380/files/ca56c13fc2d6481d9de60727f7b70f3f","downloadUrl":"https://contribution.usercontent.google.com/download?c=CgthaWRhX2NvZGVmeBJ8Eh1hcHBfY29tcGFuaW9uX2dlbmVyYXRlZF9maWxlcxpbCiVodG1sXzk5NTVmZjZjNTFkODQxMmY4MzIxNWNkYmIwNWI2MTMzEgsSBxCEnv3unRAYAZIBJAoKcHJvamVjdF9pZBIWQhQxNTM2MjU4ODg3ODU2NzExNDM4MA&filename=&opi=89354086","mimeType":"text/html"},"width":"780","height":"4122","deviceType":"MOBILE"}
@@ -0,0 +1,351 @@
<!DOCTYPE html>
<html class="light" lang="zh-CN"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>血糖管理</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"on-tertiary-fixed": "#410005",
"surface-bright": "#f4fbf4",
"on-primary-container": "#00422b",
"surface-container": "#e8f0e9",
"surface-container-high": "#e3eae3",
"surface-dim": "#d4dcd5",
"primary-container": "#10b981",
"on-secondary-fixed": "#131e19",
"surface": "#f4fbf4",
"on-surface": "#161d19",
"on-tertiary": "#ffffff",
"tertiary-container": "#fc7c78",
"secondary": "#55615a",
"on-error-container": "#93000a",
"on-background": "#161d19",
"secondary-fixed-dim": "#bdcac1",
"inverse-on-surface": "#ebf3eb",
"surface-container-highest": "#dde4dd",
"on-error": "#ffffff",
"on-surface-variant": "#3c4a42",
"secondary-fixed": "#d9e6dd",
"inverse-surface": "#2b322d",
"on-secondary": "#ffffff",
"on-tertiary-fixed-variant": "#842225",
"outline-variant": "#bbcabf",
"on-primary-fixed-variant": "#005236",
"on-secondary-container": "#5b6760",
"on-primary": "#ffffff",
"primary": "#006c49",
"outline": "#6c7a71",
"tertiary-fixed": "#ffdad7",
"error": "#ba1a1a",
"tertiary": "#a43a3a",
"surface-tint": "#006c49",
"inverse-primary": "#4edea3",
"on-tertiary-container": "#711419",
"primary-fixed-dim": "#4edea3",
"on-secondary-fixed-variant": "#3e4943",
"tertiary-fixed-dim": "#ffb3af",
"surface-container-low": "#eef6ee",
"error-container": "#ffdad6",
"on-primary-fixed": "#002113",
"background": "#f4fbf4",
"surface-container-lowest": "#ffffff",
"primary-fixed": "#6ffbbe",
"surface-variant": "#dde4dd",
"secondary-container": "#d9e6dd"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px",
"2xl": "1.5rem"
},
"spacing": {
"section-margin": "32px",
"stack-gap": "16px",
"card-padding": "20px",
"inline-gap": "12px",
"container-margin": "20px"
},
"fontFamily": {
"headline-md": ["Manrope"],
"label-md": ["Manrope"],
"body-lg": ["Manrope"],
"headline-lg-mobile": ["Manrope"],
"headline-lg": ["Manrope"],
"display-lg": ["Manrope"],
"body-md": ["Manrope"]
},
"boxShadow": {
'ambient': '0 10px 30px -10px rgba(0, 108, 73, 0.08)',
}
},
},
}
</script>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.material-symbols-outlined.fill-icon {
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-surface text-on-surface font-body-md antialiased md:max-w-[600px] md:mx-auto pb-24">
<!-- TopAppBar -->
<header class="flex justify-between items-center px-container-margin py-4 w-full bg-surface top-0 sticky z-40">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-surface-container-high flex items-center justify-center overflow-hidden">
<span class="material-symbols-outlined text-outline">person</span>
</div>
<div>
<h1 class="text-headline-lg-mobile font-headline-lg-mobile text-on-surface">下午好,用户16295733</h1>
<p class="text-label-md font-label-md text-on-surface-variant">今日血糖已记录</p>
</div>
</div>
<button class="flex flex-col items-center justify-center bg-surface-container-low border border-outline-variant rounded-xl px-3 py-1.5 hover:bg-surface-container transition-colors">
<span class="material-symbols-outlined text-primary mb-0.5">volume_up</span>
<span class="text-[10px] font-label-md text-on-surface-variant">朗读血糖</span>
</button>
</header>
<main class="px-container-margin flex flex-col gap-section-margin mt-4">
<!-- Primary Action -->
<section>
<button class="w-full bg-primary text-on-primary rounded-full py-4 flex items-center justify-center gap-2 hover:bg-on-primary-fixed-variant transition-colors shadow-ambient active:scale-95 duration-150">
<span class="material-symbols-outlined">add</span>
<span class="text-body-lg font-body-lg font-bold">录入今日血糖</span>
</button>
</section>
<!-- Current Status Cards -->
<section class="flex flex-col gap-3">
<h2 class="text-headline-md font-headline-md">今日血糖</h2>
<div class="grid grid-cols-2 gap-3">
<!-- Fasting -->
<div class="bg-surface-container-lowest rounded-2xl p-card-padding shadow-ambient border border-error-container">
<div class="text-label-md font-label-md text-on-surface-variant mb-1">空腹</div>
<div class="flex items-baseline gap-1 mb-2">
<span class="text-display-lg font-display-lg text-error">18.00</span>
<span class="text-label-md font-label-md text-error">mmol/L</span>
</div>
<div class="flex items-center gap-1 text-error bg-error-container/30 px-2 py-1 rounded-md w-fit">
<span class="material-symbols-outlined text-[14px]">warning</span>
<span class="text-[10px] font-label-md">偏高,请遵医嘱</span>
</div>
</div>
<!-- Post-meal -->
<div class="bg-surface-container-lowest rounded-2xl p-card-padding shadow-ambient border border-error-container">
<div class="text-label-md font-label-md text-on-surface-variant mb-1">餐后</div>
<div class="flex items-baseline gap-1 mb-2">
<span class="text-display-lg font-display-lg text-error">20.00</span>
<span class="text-label-md font-label-md text-error">mmol/L</span>
</div>
<div class="flex items-center gap-1 text-error bg-error-container/30 px-2 py-1 rounded-md w-fit">
<span class="material-symbols-outlined text-[14px]">warning</span>
<span class="text-[10px] font-label-md">偏高,请遵医嘱</span>
</div>
</div>
</div>
<button class="w-full flex items-center justify-between bg-surface-container-lowest rounded-xl p-4 shadow-ambient mt-2">
<div>
<div class="text-body-md font-body-md font-bold">查看更多</div>
<div class="text-label-md font-label-md text-on-surface-variant">健康日历 · 运动 · 家人互动</div>
</div>
<span class="material-symbols-outlined text-outline">chevron_right</span>
</button>
</section>
<!-- Data Overview: Trend -->
<section class="bg-surface-container-lowest rounded-2xl p-card-padding shadow-ambient">
<div class="flex justify-between items-end mb-4">
<div>
<h2 class="text-headline-md font-headline-md">血糖趋势</h2>
<p class="text-label-md font-label-md text-on-surface-variant">最近 7 天 · mmol/L</p>
</div>
<div class="flex gap-3 text-label-md font-label-md">
<div class="flex items-center gap-1"><div class="w-2 h-2 rounded-full bg-primary"></div>空腹</div>
<div class="flex items-center gap-1"><div class="w-2 h-2 rounded-full bg-tertiary-container"></div>餐后</div>
</div>
</div>
<!-- Summary Stats -->
<div class="flex bg-surface-container-low rounded-xl p-3 mb-6 divide-x divide-outline-variant">
<div class="flex-1 flex flex-col items-center">
<span class="text-headline-md font-headline-md text-error">1</span>
<span class="text-label-md font-label-md text-on-surface-variant">天偏高</span>
</div>
<div class="flex-1 flex flex-col items-center">
<span class="text-headline-md font-headline-md text-primary">1</span>
<span class="text-label-md font-label-md text-on-surface-variant">天正常</span>
</div>
<div class="flex-1 flex flex-col items-center">
<span class="text-headline-md font-headline-md text-on-surface">2</span>
<span class="text-label-md font-label-md text-on-surface-variant">天有记录</span>
</div>
</div>
<!-- Chart Area Placeholder -->
<div class="relative h-48 w-full mb-6 border-b border-l border-outline-variant">
<!-- Y Axis Labels -->
<div class="absolute -left-6 top-0 bottom-0 flex flex-col justify-between text-[10px] text-on-surface-variant">
<span>23.0</span>
<span>18.0</span>
<span>13.0</span>
<span>8.0</span>
<span>3.0</span>
</div>
<!-- X Axis Labels -->
<div class="absolute -bottom-5 left-0 right-0 flex justify-between text-[10px] text-on-surface-variant">
<span>05-23</span>
<span>05-25</span>
<span>05-27</span>
<span>05-29</span>
</div>
<!-- Threshold Line -->
<div class="absolute bottom-[25%] w-full border-t border-dashed border-error opacity-50">
<span class="absolute -top-4 text-[10px] text-error">空腹阈值 7</span>
</div>
<!-- Abstract Chart representation -->
<svg class="absolute inset-0 h-full w-full" preserveaspectratio="none" viewbox="0 0 100 100">
<path d="M 80 80 L 95 30" fill="none" stroke="#006c49" stroke-width="2"></path>
<path d="M 85 85 L 95 15" fill="none" stroke="#fc7c78" stroke-width="2"></path>
<circle cx="80" cy="80" fill="#ffffff" r="2" stroke="#006c49" stroke-width="1.5"></circle>
<circle cx="95" cy="30" fill="#ffffff" r="2" stroke="#006c49" stroke-width="1.5"></circle>
<circle cx="85" cy="85" fill="#ffffff" r="2" stroke="#fc7c78" stroke-width="1.5"></circle>
<circle cx="95" cy="15" fill="#ffffff" r="2" stroke="#fc7c78" stroke-width="1.5"></circle>
</svg>
</div>
<!-- Time Toggles -->
<div class="flex bg-surface-container-low rounded-full p-1 w-full max-w-[240px] mx-auto mt-8">
<button class="flex-1 py-2 text-label-md font-label-md bg-primary text-on-primary rounded-full shadow-sm">最近 7 天</button>
<button class="flex-1 py-2 text-label-md font-label-md text-on-surface-variant rounded-full">最近 30 天</button>
</div>
</section>
<!-- AI Nutrition Section -->
<section class="bg-gradient-to-br from-primary-container/20 to-primary/10 rounded-2xl p-[2px] shadow-ambient overflow-hidden relative">
<div class="bg-surface-container-lowest rounded-2xl p-card-padding relative z-10">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
<span class="material-symbols-outlined text-on-primary text-[18px]">auto_awesome</span>
</div>
<div>
<div class="flex items-center gap-2">
<h3 class="text-body-lg font-body-lg font-bold">AI 饮食建议</h3>
<span class="text-[10px] bg-primary/10 text-primary px-2 py-0.5 rounded-full border border-primary/20">智能定制</span>
</div>
<p class="text-[10px] text-on-surface-variant mt-0.5">读您近 7 天 · 30 天血糖,定制今日三餐</p>
</div>
</div>
<button class="flex items-center gap-1 text-label-md font-label-md text-primary bg-primary/10 px-3 py-1.5 rounded-full">
<span class="material-symbols-outlined text-[14px]">refresh</span>换一换
</button>
</div>
<!-- Meals List -->
<div class="flex flex-col gap-3 mb-4">
<div class="text-label-md font-label-md text-primary font-bold flex items-center gap-1">
<div class="w-1 h-3 bg-primary rounded-full"></div> 今日三餐
</div>
<!-- Breakfast -->
<div class="bg-surface p-3 rounded-xl border border-surface-container-highest">
<div class="flex items-center gap-1.5 mb-1.5">
<span class="material-symbols-outlined text-tertiary-container text-[16px] fill-icon">wb_sunny</span>
<span class="text-label-md font-label-md font-bold">早餐</span>
</div>
<p class="text-body-md font-body-md">小米粥(少米多水)加煮鸡蛋一个</p>
</div>
<!-- Lunch -->
<div class="bg-primary/5 p-3 rounded-xl border-l-2 border-primary">
<div class="flex items-center gap-1.5 mb-1.5">
<span class="material-symbols-outlined text-primary text-[16px] fill-icon">wb_twilight</span>
<span class="text-label-md font-label-md font-bold text-primary">午餐</span>
</div>
<p class="text-body-md font-body-md">清蒸鱼块、蒜蓉炒苋菜、二米饭(小米掺大米)</p>
</div>
<!-- Dinner -->
<div class="bg-surface p-3 rounded-xl border border-surface-container-highest">
<div class="flex items-center gap-1.5 mb-1.5">
<span class="material-symbols-outlined text-[#7e85cc] text-[16px] fill-icon">dark_mode</span>
<span class="text-label-md font-label-md font-bold">晚餐</span>
</div>
<p class="text-body-md font-body-md">玉米糝粥(稀)配凉拌黄瓜</p>
</div>
</div>
<!-- Tips -->
<div class="bg-surface-container-low rounded-xl p-3 mb-4 text-body-md font-body-md text-on-surface-variant flex gap-2">
<span class="material-symbols-outlined text-primary text-[16px] mt-0.5">info</span>
<p>小贴士:先吃菜和肉再吃主食,每餐七分饱,血糖高就少喝粥汤。</p>
</div>
<!-- Avoid List -->
<div class="mb-6">
<div class="flex items-center gap-1 text-error text-label-md font-label-md mb-2">
<span class="material-symbols-outlined text-[14px]">warning</span> 今日尽量少碰
</div>
<div class="flex flex-wrap gap-2">
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">白馒头</span>
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">油条</span>
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">粘豆包</span>
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">糯米饭</span>
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">西瓜</span>
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">含糖饮料</span>
</div>
</div>
<!-- Actions -->
<div class="flex gap-3">
<button class="flex-1 bg-primary text-on-primary rounded-xl py-3 text-body-md font-body-md font-bold hover:bg-on-primary-fixed-variant transition-colors">填入今日饮食</button>
<button class="flex-1 bg-surface-container border border-outline-variant text-on-surface rounded-xl py-3 text-body-md font-body-md font-bold hover:bg-surface-container-high transition-colors">手动记录</button>
</div>
</div>
<!-- Decorative background elements -->
<div class="absolute top-0 right-0 w-32 h-32 bg-primary/10 rounded-full blur-2xl -mr-10 -mt-10 pointer-events-none"></div>
<div class="absolute bottom-0 left-0 w-24 h-24 bg-tertiary-container/10 rounded-full blur-xl -ml-10 -mb-10 pointer-events-none"></div>
</section>
<!-- Ask AI -->
<section class="bg-surface-container-lowest rounded-2xl p-4 shadow-ambient border border-surface-container-highest">
<h3 class="text-body-md font-body-md font-bold mb-1">能不能吃?</h3>
<p class="text-[10px] text-on-surface-variant mb-3">输入食物名称,结合您的档案判断</p>
<div class="flex gap-2">
<input class="flex-1 bg-surface rounded-xl border border-outline-variant px-3 py-2 text-body-md focus:ring-2 focus:ring-primary focus:border-primary outline-none" placeholder="如:白馒头、地瓜、粘豆包" type="text"/>
<button class="bg-primary text-on-primary px-4 py-2 rounded-xl flex items-center gap-1 hover:bg-on-primary-fixed-variant transition-colors">
<span class="material-symbols-outlined text-[18px]">auto_awesome</span>
<span class="text-body-md font-body-md font-bold">问 AI</span>
</button>
</div>
</section>
<!-- Disclaimer -->
<footer class="text-center py-6">
<p class="text-[10px] text-outline">数据仅供参考,请遵医嘱</p>
</footer>
</main>
<!-- BottomNavBar -->
<nav class="fixed bottom-0 w-full z-50 rounded-t-2xl shadow-[0_-4px_20px_0_rgba(0,0,0,0.05)] bg-surface-container-lowest md:hidden flex justify-around items-center h-16 px-4">
<button class="flex flex-col items-center justify-center text-primary font-bold w-16">
<span class="material-symbols-outlined fill-icon mb-1">home</span>
<span class="text-[10px] font-label-md">首页</span>
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant opacity-60 w-16 hover:bg-surface-container-high transition-all rounded-xl py-1">
<span class="material-symbols-outlined mb-1">add_circle</span>
<span class="text-[10px] font-label-md">记录</span>
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant opacity-60 w-16 hover:bg-surface-container-high transition-all rounded-xl py-1">
<span class="material-symbols-outlined mb-1">analytics</span>
<span class="text-[10px] font-label-md">动态</span>
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant opacity-60 w-16 hover:bg-surface-container-high transition-all rounded-xl py-1">
<span class="material-symbols-outlined mb-1">person</span>
<span class="text-[10px] font-label-md">我的</span>
</button>
</nav>
</body></html>
@@ -0,0 +1,40 @@
# 识糖小课堂平台接入说明
## 已接入能力
- 复用主小程序 `token` 与微信小程序登录,不创建第二套游戏账号。
- 按周一日期、性别自动分配最多 7 人的同行组。
- 首次入组使用数据库分配锁,多个用户同时进入也不会重复分组或超过 7 人。
- 以真实平台昵称、头像、认糖数和本周最高分排序。
- 每局用 `session_key` 上报绝对进度,断网重试不会重复加分。
- 待同步成绩最多本地保留 8 局,重新联网后自动补传。
- 微信好友与朋友圈分享使用随机分享码,不在链接中暴露用户 ID。
- 每张周分享卡对同一受邀人只记录一次轻量访问,不自动建立家庭或好友绑定。
## 接口
- `GET /api/tcm/gameWeeklyLeaderboard`:获取或创建当前周同行榜。
- `POST /api/tcm/gameSubmitProgress`:上报 `session_key``learned_count``score``ended`
- `POST /api/tcm/gameRecordShare`:记录分享动作并获取本周分享码。
- `POST /api/tcm/gameAcceptShare`:受邀用户登录后提交 `invite_code`
四个接口都使用现有 `LoginMiddleware` 校验主小程序 `token`
## 部署顺序
1. 执行 `server/sql/1.9.20260717/add_tcm_endless_game_platform.sql`
2. 发布 `server/app/api/logic/tcm/GamePlatformLogic.php``TcmController.php`
3. 重新构建并上传小程序前端。
如果数据库已经执行过本功能的旧版建表脚本,再执行一次
`server/sql/1.9.20260717/upgrade_tcm_endless_game_platform_20260717.sql`,用于补充分组锁并把分享去重范围修正为“每张周分享卡”。
如果后端或数据表尚未发布,游戏仍可离线游玩,榜单会显示“离线记录中”;联网且接口可用后自动补传。
## 上线前检查
- 用男女各两个测试账号进入,确认被分入对应性别组。
- 同一局重复提交相同 `session_key`,确认周认糖数不重复增加。
- 断网完成几次消除,再联网打开榜单,确认成绩补传。
- 分享给另一个微信账号,确认能直接进入游戏且链接中没有用户 ID。
- 周一验证新周重新分组,旧周成绩不带入新周。
@@ -0,0 +1,43 @@
# 识糖小课堂独立功能包
此目录包含“识糖小课堂”无尽三消版的页面与运行代码。食品图片由远端 COS 提供,图标组件复用 `tongji` 分包的公共组件。
## 目录内容
- `index.vue`:页面、棋盘算法、关卡主题、任务、道具、三/四/五连奖励和适老化交互。
- `game-endless.scss`:完整页面与动画样式。
- `composables/useGameAuth.js`:复用主小程序账号并处理 token 过期重登。
- `composables/useGamePlatform.js`:周榜、成绩补传与微信分享的平台连接层。
- `composables/useGameSfx.js`:滑动、掉落、消除、连击和大奖音效。
## 迁移步骤
1. 复制 `endless-game` 文件夹到目标项目的 `tongji/` 目录,并确保目标项目同时提供 `tongji/components/TongjiIcon.vue``tongji/utils/svgDataUrl.js`
2. 在目标项目 `pages.json``tongji` 分包 `pages` 数组中加入:
```json
{
"path": "endless-game/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "识糖小课堂",
"backgroundColor": "#eefbf4",
"disableScroll": false,
"enableShareAppMessage": true,
"enableShareTimeline": true
}
}
```
3. 使用 `/tongji/endless-game/index` 打开游戏。
4. 微信小程序后台需要将 `https://gz-1349751149.cos.ap-guangzhou.myqcloud.com` 配置为合法的音频与图片下载域名。
## 说明
- 旧版“糖分突袭”页面及其独占依赖已经移除,项目只注册 `/tongji/endless-game/index` 这一款小游戏。
- 食品图片固定使用 `https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/games/food/` 前缀,文件名必须与 `FOODS` 配置 key 的大小写保持一致。
- 连消小目标按“连消×2”累计 2 次;连续 6 次普通消除没有连消时,下一次掉落会提供连消机会。“连消×3”保留为额外积分、驼乳粉与撒花惊喜,不阻挡主线任务。
- 横向 3 个与竖向 3 个相交形成 L/T 形五消时,会在交点生成带高对比 L 标记的范围爆破棋子;该棋子再次被消除时清除周围九格。
- 进入游戏时展示“本周 7 人同行榜”,包含真实平台昵称、前后名次、差距提示和可切换的同行/亲友鼓励;顶部“本周同行”可再次打开。后端部署方式见 `PLATFORM_INTEGRATION.md`
- 本功能包依赖 uni-app Vue 3、`@dcloudio/uni-app` 以及 `tongji` 分包内的公共 `TongjiIcon` 组件。
- 食物风险等级和科普文案集中在 `index.vue``FOODS` 配置中,正式上线前仍需由医院医生审核。
@@ -0,0 +1,71 @@
/**
* 识糖小课堂独立登录适配层。复用主小程序账号,但不依赖 tongji 其他页面代码。
*/
export function useGameAuth(proxy) {
let loginPromise = null
function hasToken() {
return !!String(uni.getStorageSync('token') || '').trim()
}
function clearToken() {
uni.removeStorageSync('token')
}
function wxLoginCode() {
return new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (res) => res?.code ? resolve(res.code) : reject(new Error('微信登录未返回 code')),
fail: reject
})
})
}
async function loginWithCode(code) {
const res = await proxy.apiUrl({
url: '/api/login/mnpLogin',
method: 'POST',
data: { code }
}, false)
if (res?.code !== 1 || !res.data?.token) {
clearToken()
throw new Error(res?.msg || '登录失败')
}
uni.setStorageSync('token', res.data.token)
uni.setStorageSync('userData', res.data)
return true
}
async function verifyOrLogin() {
if (hasToken()) {
try {
const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)
if (res?.code === 1 && res.data) {
uni.setStorageSync('userData', res.data)
return true
}
// 只有明确的登录失效才重新换取 token;其他业务错误先保留原登录。
if (res?.code !== -1) return true
} catch (_) {
// 断网不清除仍可能有效的 token,成绩由离线队列稍后补传。
return true
}
clearToken()
}
const code = await wxLoginCode()
return loginWithCode(code)
}
async function ensureLoggedIn() {
if (loginPromise) return loginPromise
loginPromise = verifyOrLogin()
.then(ok => !!ok)
.catch(() => false)
// 只合并同时发生的登录;成功结果不能永久缓存,否则 token 过期后无法恢复。
.finally(() => { loginPromise = null })
return loginPromise
}
return { ensureLoggedIn }
}
@@ -0,0 +1,251 @@
import { ref } from 'vue'
const EMPTY_BOARD = {
week_start: '',
week_end: '',
sex_label: '同行',
group_size: 7,
member_count: 0,
players: [],
me: { count: 0, rank: 1, best_score: 0, distance: 0, is_first: true },
invite_code: ''
}
const PENDING_SYNC_KEY = 'tongji_endless_pending_sync_v1'
function createSessionKey() {
const random = Math.random().toString(36).slice(2, 12)
return `game_${Date.now().toString(36)}_${random}`
}
function readPendingPayloads() {
try {
const stored = uni.getStorageSync(PENDING_SYNC_KEY)
if (!Array.isArray(stored)) return []
return stored.filter(item => (
item
&& /^[A-Za-z0-9_-]{16,64}$/.test(String(item.session_key || ''))
&& Number(item.learned_count) >= 0
)).slice(-8)
} catch (_) {
return []
}
}
/**
* 小游戏的平台连接层。复用主小程序 token,不在游戏里另建账号。
* 每次上报的是本局绝对值,后端按 session_key 去重,断网重试也不会重复加分。
*/
export function useGamePlatform(proxy, ensureLoggedIn) {
const connected = ref(false)
const loading = ref(false)
const syncStatus = ref('idle')
const leaderboard = ref({ ...EMPTY_BOARD })
const confirmedSessionLearned = ref(0)
let sessionKey = createSessionKey()
let syncTimer = null
let syncing = false
let queuedPayloads = readPendingPayloads()
let connectPromise = null
function persistPendingPayloads() {
try { uni.setStorageSync(PENDING_SYNC_KEY, queuedPayloads.slice(-8)) } catch (_) {}
}
async function api(request) {
if (!proxy?.apiUrl) throw new Error('平台接口未初始化')
return proxy.apiUrl(request, false)
}
function applyLeaderboard(data, confirmedForSession = '') {
if (!data || !Array.isArray(data.players)) return
const inactiveSessionResponse = confirmedForSession && confirmedForSession !== sessionKey
if (!inactiveSessionResponse) {
leaderboard.value = {
...EMPTY_BOARD,
...data,
me: { ...EMPTY_BOARD.me, ...(data.me || {}) },
players: data.players
}
}
if (confirmedForSession === sessionKey && data.confirmed_session_learned != null) {
confirmedSessionLearned.value = Number(data.confirmed_session_learned) || 0
}
connected.value = true
syncStatus.value = 'synced'
}
async function refreshLeaderboard() {
const res = await api({
url: '/api/tcm/gameWeeklyLeaderboard',
method: 'GET'
})
if (res?.code !== 1 || !res.data) {
throw new Error(res?.msg || '同行榜加载失败')
}
applyLeaderboard(res.data)
return res.data
}
async function acceptShare(inviteCode) {
const code = String(inviteCode || '').trim().toUpperCase()
if (!code) return
try {
await api({
url: '/api/tcm/gameAcceptShare',
method: 'POST',
data: { invite_code: code }
})
} catch (_) {
// 分享关系是辅助能力,不阻断进入游戏。
}
}
async function connect(inviteCode = '') {
if (connectPromise) return connectPromise
connectPromise = (async () => {
loading.value = true
try {
const loggedIn = await ensureLoggedIn()
if (!loggedIn) throw new Error('登录失败')
await acceptShare(inviteCode)
await refreshLeaderboard()
if (queuedPayloads.length) flushProgress()
return true
} catch (_) {
connected.value = false
syncStatus.value = 'offline'
return false
} finally {
loading.value = false
connectPromise = null
}
})()
return connectPromise
}
function beginSession() {
if (syncTimer) clearTimeout(syncTimer)
sessionKey = createSessionKey()
confirmedSessionLearned.value = 0
syncStatus.value = queuedPayloads.length ? 'pending' : (connected.value ? 'synced' : 'offline')
return sessionKey
}
function queueProgress(learnedCount, score, ended = false) {
const nextPayload = {
session_key: sessionKey,
learned_count: Math.max(0, Number(learnedCount) || 0),
score: Math.max(0, Number(score) || 0),
ended: ended ? 1 : 0
}
const existingIndex = queuedPayloads.findIndex(item => item.session_key === sessionKey)
if (existingIndex >= 0) {
const existing = queuedPayloads[existingIndex]
queuedPayloads[existingIndex] = {
...nextPayload,
learned_count: Math.max(existing.learned_count, nextPayload.learned_count),
score: Math.max(existing.score, nextPayload.score),
ended: Math.max(existing.ended, nextPayload.ended)
}
} else {
queuedPayloads.push(nextPayload)
}
persistPendingPayloads()
syncStatus.value = 'pending'
if (syncTimer) clearTimeout(syncTimer)
if (ended) {
flushProgress()
} else {
syncTimer = setTimeout(flushProgress, 1000)
}
}
async function flushProgress() {
if (syncTimer) clearTimeout(syncTimer)
syncTimer = null
if (syncing || !queuedPayloads.length) return
const payload = queuedPayloads[0]
syncing = true
syncStatus.value = 'syncing'
try {
if (!connected.value) {
const loggedIn = await ensureLoggedIn()
if (!loggedIn) throw new Error('未登录')
}
const res = await api({
url: '/api/tcm/gameSubmitProgress',
method: 'POST',
data: payload
})
if (res?.code !== 1 || !res.data) {
throw new Error(res?.msg || '成绩保存失败')
}
applyLeaderboard(res.data, payload.session_key)
// 请求发出后玩家可能又完成了消除。只移除已经被本次请求覆盖的进度,
// 不能按 session_key 整局删除,否则会丢失请求进行期间产生的新进度。
queuedPayloads = queuedPayloads.filter(item => (
item.session_key !== payload.session_key
|| Number(item.learned_count) > Number(payload.learned_count)
|| Number(item.score) > Number(payload.score)
|| Number(item.ended) > Number(payload.ended)
))
persistPendingPayloads()
} catch (_) {
connected.value = false
syncStatus.value = 'offline'
// 队首保留原绝对值,下次连接或打开榜单时安全重试。
persistPendingPayloads()
} finally {
syncing = false
if (queuedPayloads.length && connected.value) {
setTimeout(flushProgress, 80)
}
}
}
async function syncAndRefresh(learnedCount, score) {
queueProgress(learnedCount, score, false)
await flushProgress()
if (!connected.value) {
await connect()
if (queuedPayloads.length) await flushProgress()
} else {
try { await refreshLeaderboard() } catch (_) {}
}
}
async function recordShare() {
try {
if (!connected.value && !(await connect())) return
const res = await api({ url: '/api/tcm/gameRecordShare', method: 'POST' })
if (res?.code === 1 && res.data?.invite_code) {
leaderboard.value = { ...leaderboard.value, invite_code: res.data.invite_code }
}
} catch (_) {
// 分享本身仍可进行,统计失败不影响用户。
}
}
function dispose() {
if (syncTimer) clearTimeout(syncTimer)
syncTimer = null
persistPendingPayloads()
}
return {
connected,
loading,
syncStatus,
leaderboard,
confirmedSessionLearned,
connect,
beginSession,
queueProgress,
flushProgress,
syncAndRefresh,
refreshLeaderboard,
recordShare,
dispose
}
}
@@ -0,0 +1,350 @@
/**
* 糖分突袭 · 游戏音效(InnerAudioContext 池化,H5/小程序通用)
* 每种操作独立音轨 + playbackRate 变调,避免“全是同一个声”
*/
import { ref, onUnmounted } from 'vue'
const STORAGE_KEY = 'tongji_game_sfx_enabled'
const COS = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file'
/** 9 条基础素材(已上传 COS,小程序域名白名单内) */
const SRC = {
tap: `${COS}/20260526/20260526105128557ef8669.mp3`,
swoosh: `${COS}/20260527/202605271539371ebe13672.mp3`,
pop: `${COS}/20260526/202605261051282a0a94508.mp3`,
buzz: `${COS}/20260527/202605271539376ff628472.mp3`,
chime: `${COS}/20260528/202605280937077bad76716.mp3`,
fanfare: `${COS}/20260528/20260528093707b021f5794.mp3`,
blast: `${COS}/20260528/20260528093707ebd4d5733.mp3`,
sparkle: `${COS}/20260528/20260528093707016f07427.mp3`,
stinger: `${COS}/20260528/20260528093709c669f4659.mp3`
}
/**
* 音效表:src / volume / rate(playbackRate) / pool
* rate 不同可在同一素材上听出明显差异
*/
const SFX = {
// —— 基础素材别名(供分层音效直接调用)——
tap: { src: SRC.tap, volume: 0.32, rate: 1, pool: 3 },
pop: { src: SRC.pop, volume: 0.3, rate: 1, pool: 4 },
fanfare: { src: SRC.fanfare, volume: 0.56, rate: 1, pool: 2 },
blast: { src: SRC.blast, volume: 0.62, rate: 1, pool: 2 },
sparkle: { src: SRC.sparkle, volume: 0.5, rate: 1, pool: 2 },
// —— 交互 ——
select: { src: SRC.tap, volume: 0.32, rate: 1.05, pool: 3 },
deselect: { src: SRC.tap, volume: 0.22, rate: 0.82, pool: 2 },
hint: { src: SRC.tap, volume: 0.26, rate: 1.28, pool: 2 },
swap: { src: SRC.swoosh, volume: 0.38, rate: 1.0, pool: 3 },
swapFail: { src: SRC.buzz, volume: 0.36, rate: 1.18, pool: 2 },
drop: { src: SRC.pop, volume: 0.3, rate: 0.88, pool: 4 },
dropLight: { src: SRC.pop, volume: 0.22, rate: 1.22, pool: 2 },
// —— 消除(按连数)——
match3: { src: SRC.chime, volume: 0.48, rate: 1.0, pool: 4 },
match4: { src: SRC.fanfare, volume: 0.54, rate: 1.06, pool: 3 },
match5: { src: SRC.blast, volume: 0.62, rate: 1.0, pool: 3 },
// —— 消除(按 GI 主色)——
matchLowGi: { src: SRC.chime, volume: 0.42, rate: 0.86, pool: 2 },
matchMidGi: { src: SRC.chime, volume: 0.46, rate: 1.02, pool: 2 },
matchHighGi: { src: SRC.buzz, volume: 0.44, rate: 0.92, pool: 2 },
// —— 连消 ——
combo2: { src: SRC.fanfare, volume: 0.56, rate: 1.12, pool: 2 },
combo3: { src: SRC.blast, volume: 0.64, rate: 1.08, pool: 2 },
comboMega: { src: SRC.blast, volume: 0.72, rate: 1.22, pool: 2 },
// —— 道具 ——
insulin: { src: SRC.sparkle, volume: 0.5, rate: 1.15, pool: 2 },
fiber: { src: SRC.swoosh, volume: 0.48, rate: 1.32, pool: 2 },
meal: { src: SRC.stinger, volume: 0.42, rate: 1.35, pool: 1 },
reshuffle: { src: SRC.swoosh, volume: 0.46, rate: 0.72, pool: 2 },
// —— 反馈 ——
score: { src: SRC.tap, volume: 0.28, rate: 1.38, pool: 2 },
goalTick: { src: SRC.chime, volume: 0.36, rate: 1.45, pool: 2 },
meterWarn: { src: SRC.buzz, volume: 0.5, rate: 1.0, pool: 2 },
meterDanger: { src: SRC.buzz, volume: 0.58, rate: 0.78, pool: 2 },
meterStable: { src: SRC.pop, volume: 0.24, rate: 1.05, pool: 1 },
win: { src: SRC.fanfare, volume: 0.66, rate: 1.0, pool: 1 },
winStinger: { src: SRC.stinger, volume: 0.38, rate: 1.5, pool: 1 },
lose: { src: SRC.buzz, volume: 0.52, rate: 0.68, pool: 1 },
// 兼容旧名
match: { src: SRC.chime, volume: 0.48, rate: 1.0, pool: 4 },
combo: { src: SRC.fanfare, volume: 0.56, rate: 1.1, pool: 3 },
heal: { src: SRC.pop, volume: 0.44, rate: 1.18, pool: 2 },
boost: { src: SRC.sparkle, volume: 0.52, rate: 1.0, pool: 2 },
invalid: { src: SRC.buzz, volume: 0.4, rate: 1.15, pool: 2 },
danger: { src: SRC.blast, volume: 0.58, rate: 0.95, pool: 2 }
}
const WARMUP_SFX = [
'select', 'swap', 'swapFail', 'drop', 'dropLight',
'match3', 'match4', 'match5', 'combo2', 'combo3', 'comboMega',
'insulin', 'sparkle', 'reshuffle', 'lose'
]
function createAudioContext() {
if (typeof Audio !== 'undefined') {
const audio = new Audio()
audio.preload = 'auto'
return {
get src() { return audio.src },
set src(value) { audio.src = value },
get volume() { return audio.volume },
set volume(value) { audio.volume = value },
get playbackRate() { return audio.playbackRate },
set playbackRate(value) { audio.playbackRate = value },
play() {
const promise = audio.play()
if (promise?.catch) promise.catch(() => {})
},
stop() {
audio.pause()
try { audio.currentTime = 0 } catch (_) {}
},
seek(time) {
try { audio.currentTime = time } catch (_) {}
},
destroy() {
audio.pause()
audio.removeAttribute('src')
}
}
}
return uni.createInnerAudioContext()
}
class SfxPool {
constructor(src, size, volume, rate = 1) {
this.src = src
this.size = size
this.volume = volume
this.rate = rate
this.list = []
this.cursor = 0
this.warmedUp = false
}
createOne() {
const ctx = createAudioContext()
ctx.src = this.src
ctx.obeyMuteSwitch = false
ctx.autoplay = false
ctx.volume = this.volume
try {
ctx.playbackRate = this.rate
} catch (_) {}
this.list.push(ctx)
return ctx
}
create() {
while (this.list.length < this.size) this.createOne()
}
warmUp() {
if (this.warmedUp) return
const ctx = this.list[0] || this.createOne()
try {
ctx.volume = 0
ctx.play()
setTimeout(() => {
try {
ctx.stop()
ctx.volume = this.volume
} catch (_) {}
}, 60)
} catch (_) {}
if (this.size > 1) this.cursor = 1
this.warmedUp = true
}
play(scale = 1, rateMul = 1) {
if (this.list.length < this.size) this.create()
const ctx = this.list[this.cursor % this.list.length]
this.cursor += 1
const vol = Math.min(1, this.volume * scale)
const rate = Math.min(2, Math.max(0.5, this.rate * rateMul))
try {
ctx.volume = vol
try {
ctx.playbackRate = rate
} catch (_) {}
ctx.stop()
ctx.seek(0)
ctx.play()
} catch (_) {
try {
ctx.play()
} catch (e) {}
}
}
destroy() {
this.list.forEach((ctx) => {
try {
ctx.stop()
ctx.destroy()
} catch (_) {}
})
this.list = []
this.warmedUp = false
}
}
function dominantGi(tiles = []) {
const cnt = { low: 0, mid: 0, high: 0 }
tiles.forEach((t) => {
const g = t?.type?.gi
if (g && cnt[g] !== undefined) cnt[g] += 1
})
let best = 'mid'
let max = 0
Object.entries(cnt).forEach(([k, v]) => {
if (v > max) {
max = v
best = k
}
})
return best
}
export function useGameSfx() {
const enabled = ref(true)
const pools = {}
try {
const stored = uni.getStorageSync(STORAGE_KEY)
if (stored === false || stored === '0' || stored === 0) {
enabled.value = false
}
} catch (_) {}
function ensureAudioOption() {
// #ifdef MP-WEIXIN
try {
uni.setInnerAudioOption({
obeyMuteSwitch: false,
mixWithOther: true
})
} catch (_) {}
// #endif
}
function getPool(name) {
const cfg = SFX[name]
if (!cfg) return null
if (!pools[name]) {
pools[name] = new SfxPool(cfg.src, cfg.pool, cfg.volume, cfg.rate ?? 1)
}
return pools[name]
}
function warmUp() {
if (!enabled.value) return
ensureAudioOption()
const names = typeof Audio !== 'undefined' ? ['select'] : WARMUP_SFX
names.forEach((name) => {
getPool(name)?.warmUp()
})
}
function play(name, scale = 1, rateMul = 1) {
if (!enabled.value) return
getPool(name)?.play(scale, rateMul)
}
function playLayer(primary, secondary, delayMs = 70, secScale = 0.6) {
play(primary)
if (secondary) {
setTimeout(() => play(secondary, secScale), delayMs)
}
}
function playMatch({ matchCount = 3, chain = 0, tiles = [] } = {}) {
if (!enabled.value) return
const giTrack = (() => {
const gi = dominantGi(tiles)
if (gi === 'low') return 'matchLowGi'
if (gi === 'high') return 'matchHighGi'
return 'matchMidGi'
})()
if (chain >= 3) {
playLayer('comboMega', giTrack, 90, 0.55)
return
}
if (chain === 2) {
playLayer('combo3', giTrack, 80, 0.5)
return
}
if (chain === 1) {
playLayer('combo2', giTrack, 70, 0.45)
return
}
if (matchCount >= 5) {
playLayer('match5', 'matchHighGi', 85, 0.5)
} else if (matchCount >= 4) {
playLayer('match4', giTrack, 65, 0.48)
} else {
play(giTrack, 1, 1)
setTimeout(() => play('match3', 0.85), 40)
}
}
function playDrop(count = 1) {
if (!enabled.value || count <= 0) return
const name = count >= 4 ? 'drop' : 'dropLight'
play(name, Math.min(1.2, 0.85 + count * 0.04), count >= 6 ? 0.9 : 1)
}
function playMeter(delta, level) {
if (!enabled.value) return
if (level > 70) play('meterDanger', 1 + (level - 70) * 0.02)
else if (level < 30) play('meterWarn', 0.9, 0.95)
else if (delta > 8) play('meterWarn', 0.75)
else if (delta < -5) play('meterStable', 1.1)
}
function toggleEnabled() {
enabled.value = !enabled.value
try {
uni.setStorageSync(STORAGE_KEY, enabled.value ? '1' : '0')
} catch (_) {}
if (enabled.value) {
warmUp()
play('select')
}
}
function destroyAll() {
Object.keys(pools).forEach((key) => {
pools[key]?.destroy()
delete pools[key]
})
}
onUnmounted(() => {
destroyAll()
})
return {
enabled,
play,
playLayer,
playMatch,
playDrop,
playMeter,
warmUp,
toggleEnabled,
destroyAll
}
}
@@ -0,0 +1,570 @@
.eg-page {
min-height: 100vh;
color: #173b32;
background: linear-gradient(180deg, #eefbf4 0%, #f9f3dd 56%, #fffaf1 100%);
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
.eg-confetti-layer { position: fixed; z-index: 80; inset: 0; overflow: hidden; pointer-events: none; }
.eg-confetti-piece {
position: absolute;
top: -70rpx;
display: block;
border-radius: 3rpx;
box-shadow: 0 2rpx 4rpx rgba(0,0,0,.12);
animation-name: egConfettiFall;
animation-timing-function: cubic-bezier(.18,.72,.35,1);
animation-fill-mode: both;
}
.eg-confetti-piece.is-emoji { width: auto !important; height: auto !important; background: transparent !important; box-shadow: none; font-size: 42rpx; }
.eg-nav {
position: relative;
display: grid;
box-sizing: border-box;
grid-template-columns: 156rpx minmax(0, 1fr) 156rpx;
align-items: center;
column-gap: 12rpx;
padding-right: 28rpx;
padding-bottom: 16rpx;
padding-left: 28rpx;
}
.eg-nav-leading { display: flex; width: 156rpx; justify-content: flex-start; }
.eg-nav-btn {
display: flex;
width: 72rpx;
height: 72rpx;
align-items: center;
justify-content: center;
border: 2rpx solid rgba(21, 94, 75, .12);
border-radius: 24rpx;
background: rgba(255,255,255,.82);
}
.eg-nav-actions { display: flex; width: 156rpx; flex-shrink: 0; justify-content: flex-end; gap: 10rpx; }
.eg-nav-btn--small { width: 68rpx; height: 68rpx; border-radius: 22rpx; }
.eg-title-wrap { display: flex; min-width: 0; flex-direction: column; align-items: center; }
.eg-title, .eg-subtitle { overflow: hidden; max-width: 100%; white-space: nowrap; text-overflow: ellipsis; }
.eg-title { color: #155e4b; font-size: 40rpx; font-weight: 800; }
.eg-subtitle { margin-top: 2rpx; color: #648278; font-size: 24rpx; }
.eg-content { padding: 0 16rpx 48rpx; }
.eg-score-card {
display: flex;
align-items: center;
margin-bottom: 16rpx;
padding: 18rpx 24rpx;
border: 2rpx solid rgba(21, 94, 75, .1);
border-radius: 28rpx;
background: rgba(255,255,255,.9);
box-shadow: 0 10rpx 28rpx rgba(32, 78, 43, .08);
}
.eg-stat { display: flex; flex: 1; flex-direction: column; align-items: center; }
.eg-stat--main { border-right: 2rpx solid #e2eee8; border-left: 2rpx solid #e2eee8; }
.eg-stat-label { color: #71847d; font-size: 24rpx; }
.eg-stat-value { margin-top: 4rpx; color: #204e2b; font-size: 38rpx; font-weight: 800; }
.eg-stat-score { margin-top: 2rpx; color: #e16f24; font-size: 46rpx; font-weight: 900; }
.eg-stat--rank { position: relative; cursor: pointer; }
.eg-stat--rank.is-locked { opacity: .52; }
.eg-stat-rank-value { margin-top: 1rpx; color: #176b52; font-size: 35rpx; font-weight: 1000; line-height: 1.08; }
.eg-stat-rank-hint { margin-top: 2rpx; color: #df7427; font-size: 18rpx; font-weight: 800; }
.eg-task-card {
margin-bottom: 16rpx;
padding: 18rpx 22rpx;
border-radius: 24rpx;
background: rgba(255, 249, 223, .94);
box-shadow: 0 8rpx 22rpx rgba(118, 89, 25, .08);
}
.eg-task-card { position: relative; overflow: visible; }
.eg-task-reward-flight {
position: absolute;
z-index: 72;
top: 4rpx;
left: 42%;
display: flex;
width: 112rpx;
height: 92rpx;
align-items: center;
justify-content: center;
border: 4rpx solid rgba(255,255,255,.96);
border-radius: 24rpx;
background: #fff7e8;
box-shadow: 0 10rpx 24rpx rgba(159,94,23,.28);
animation: egTaskRewardFlight 1.12s cubic-bezier(.18,.78,.22,1) both;
pointer-events: none;
}
.eg-task-reward-flight > image { position: relative; z-index: 2; width: 78rpx; height: 66rpx; }
.eg-task-reward-flight > text { position: absolute; z-index: 3; top: -14rpx; right: -16rpx; display: flex; width: 48rpx; height: 48rpx; align-items: center; justify-content: center; border: 4rpx solid #fff; border-radius: 50%; color: #fff; background: #ed7625; box-shadow: 0 5rpx 12rpx rgba(191,75,19,.3); font-size: 25rpx; font-weight: 1000; }
.eg-task-reward-glow { position: absolute; z-index: 1; inset: -15rpx; border-radius: 32rpx; background: radial-gradient(circle, rgba(255,210,67,.52), rgba(255,210,67,0) 68%); animation: egRewardGlow .42s ease-in-out infinite alternate; }
.eg-task-head { display: flex; align-items: center; justify-content: space-between; color: #7c5b1b; font-size: 27rpx; font-weight: 700; }
.eg-task-name { display: flex; align-items: center; gap: 10rpx; }
.eg-task-count { color: #9a6718; font-size: 29rpx; }
.eg-progress { height: 14rpx; margin-top: 12rpx; overflow: hidden; border-radius: 999rpx; background: #eadfbd; }
.eg-progress-fill { height: 100%; border-radius: inherit; background: linear-gradient(90deg, #f0a128, #f4c34f); transition: width .25s; }
.eg-board-shell {
position: relative;
padding: 14rpx;
border: 2rpx solid rgba(34, 103, 79, .16);
border-radius: 34rpx;
background: rgba(255,255,255,.92);
box-shadow: 0 18rpx 44rpx rgba(29, 78, 59, .12);
}
.eg-board-top { display: flex; align-items: center; justify-content: space-between; margin: 0 4rpx 14rpx; }
.eg-board-bonuses { display: flex; align-items: center; gap: 8rpx; }
.eg-combo { padding: 8rpx 16rpx; border-radius: 999rpx; color: #45685d; background: #e8f3ee; font-size: 25rpx; font-weight: 700; }
.eg-combo.is-hot { color: #fff; background: linear-gradient(135deg, #f59e0b, #ea580c); }
.eg-double-state { padding: 8rpx 14rpx; border: 2rpx solid rgba(255,255,255,.9); border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #7c3aed, #d946ef); box-shadow: 0 4rpx 12rpx rgba(124,58,237,.24); font-size: 23rpx; font-weight: 900; white-space: nowrap; }
.eg-risk { display: flex; align-items: center; gap: 10rpx; color: #8d512c; font-size: 24rpx; }
.eg-risk-dots { display: flex; gap: 5rpx; }
.eg-risk-dot { width: 12rpx; height: 12rpx; border-radius: 50%; background: #ead8c9; }
.eg-risk-dot.on { background: #e96b3b; box-shadow: 0 0 0 3rpx rgba(233,107,59,.12); }
.eg-board { display: flex; flex-direction: column; gap: 8rpx; opacity: 1; transition: opacity .15s; }
.eg-board.is-busy { opacity: .82; }
.eg-row { display: flex; gap: 8rpx; }
.eg-cell {
position: relative;
display: flex;
height: 166rpx;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
border: 5rpx solid transparent;
border-radius: 26rpx;
box-shadow: inset 0 -5rpx 0 rgba(0,0,0,.05), 0 5rpx 12rpx rgba(31, 66, 53, .08);
transition: transform .16s cubic-bezier(.2,.8,.2,1), border-color .15s, opacity .16s, filter .16s;
will-change: transform;
}
.eg-cell.is-dragging { transition: transform .055s linear, border-color .15s; }
.eg-cell.is-selected { z-index: 2; border-color: #167d61; transform: scale(1.06); box-shadow: 0 0 0 6rpx rgba(22,125,97,.14); }
.eg-cell.is-camel-target { border-color: #f59e0b; animation: egPulse 1s infinite; }
.eg-replace-target { position: absolute; z-index: 7; right: 7rpx; bottom: 6rpx; padding: 4rpx 10rpx; border: 2rpx solid #fff; border-radius: 999rpx; color: #fff; background: #1570a6; box-shadow: 0 3rpx 9rpx rgba(20,91,135,.28); font-size: 20rpx; font-weight: 900; }
.eg-cell.is-clearing {
z-index: 8;
border-color: #fff;
animation: egHit .22s cubic-bezier(.2,.9,.3,1) forwards;
filter: brightness(1.28) saturate(1.25);
}
.eg-cell.is-yellow,
.eg-cell.is-cream,
.eg-cell.is-red,
.eg-cell.is-purple { background: #fffaf0; }
.eg-cell.is-level-low { background: #f4faf6; }
.eg-cell.is-level-mid { background: #fff9eb; }
.eg-cell.is-orange,
.eg-cell.is-level-high { background: #fff0e4; }
.eg-cell.is-milk { background: #edf7ff; }
.eg-cell.is-high { border-color: #ef7b45; }
.eg-food-image { width: 132rpx; height: 92rpx; margin-top: 7rpx; border-radius: 17rpx; filter: saturate(1.06) contrast(1.03); }
.eg-food-name { margin-top: 8rpx; color: #203d34; font-size: 29rpx; font-weight: 900; letter-spacing: 1rpx; line-height: 1.1; }
.eg-sugar-tag { position: absolute; top: 5rpx; right: 5rpx; min-width: 52rpx; padding: 4rpx 10rpx; border: 2rpx solid #fff; border-radius: 999rpx; color: #fff; box-shadow: 0 3rpx 8rpx rgba(25,55,45,.22); font-size: 23rpx; font-weight: 900; line-height: 1.25; text-align: center; }
.eg-sugar-tag.is-low { color: #316a54; border-color: rgba(255,255,255,.82); background: #dcefe6; box-shadow: 0 2rpx 5rpx rgba(32,101,75,.1); }
.eg-sugar-tag.is-mid { color: #735a15; border-color: rgba(255,255,255,.9); background: #f7e7a8; box-shadow: 0 2rpx 6rpx rgba(137,91,0,.15); }
.eg-sugar-tag.is-high { background: #d9431f; box-shadow: 0 4rpx 10rpx rgba(163,45,21,.32); }
.eg-sugar-tag.is-prop { background: #2563a8; }
.eg-special-mark { position: absolute; z-index: 12; bottom: 5rpx; left: 6rpx; display: flex; width: 40rpx; height: 40rpx; align-items: center; justify-content: center; border: 3rpx solid rgba(255,255,255,.96); border-radius: 50%; color: #fff; background: #6d28d9; box-shadow: 0 4rpx 11rpx rgba(73,31,130,.3); font-size: 23rpx; font-weight: 900; }
.eg-special-mark.is-row { width: 58rpx; height: 40rpx; border-radius: 999rpx; background: linear-gradient(135deg, #ffd64d, #ff8a18 48%, #e83b22); box-shadow: 0 0 0 4rpx rgba(255,172,30,.2), 0 4rpx 15rpx rgba(203,59,26,.45); animation: egFlameReady .7s ease-in-out infinite alternate; }
.eg-special-mark.is-col { width: 40rpx; height: 58rpx; border-radius: 999rpx; background: linear-gradient(180deg, #ffd64d, #ff8a18 48%, #e83b22); box-shadow: 0 0 0 4rpx rgba(255,172,30,.2), 0 4rpx 15rpx rgba(203,59,26,.45); animation: egFlameReady .7s ease-in-out infinite alternate; }
.eg-special-mark.is-burst { width: 48rpx; height: 48rpx; border-color: #fff3a8; border-radius: 15rpx 50% 50%; background: linear-gradient(145deg, #7c3aed 5%, #dd3b70 48%, #ff8a18 100%); box-shadow: 0 4rpx 13rpx rgba(108,35,155,.38), 0 0 12rpx rgba(255,179,38,.3); animation: egLReady 1.25s ease-in-out infinite alternate; }
.eg-mini-flame { position: relative; width: 25rpx; height: 30rpx; border-radius: 70% 32% 68% 40%; background: linear-gradient(135deg, #fff8a8 8%, #ffc21f 42%, #f04420 84%); box-shadow: 0 0 14rpx rgba(255,230,92,.95); transform: rotate(43deg); }
.eg-mini-flame-core { position: absolute; right: 4rpx; bottom: 3rpx; width: 10rpx; height: 15rpx; border-radius: 70% 35% 70% 42%; background: #fffbd1; box-shadow: 0 0 7rpx rgba(255,255,210,.95); }
.eg-l-special { position: relative; width: 29rpx; height: 29rpx; filter: drop-shadow(0 1rpx 2rpx rgba(73,22,105,.3)); }
.eg-l-arm { position: absolute; left: 3rpx; bottom: 3rpx; border-radius: 999rpx; background: #fff9b8; box-shadow: 0 0 6rpx rgba(255,249,184,.92); }
.eg-l-arm.is-vertical { width: 8rpx; height: 25rpx; }
.eg-l-arm.is-horizontal { width: 25rpx; height: 8rpx; }
.eg-l-core { position: absolute; left: 1rpx; bottom: 1rpx; width: 12rpx; height: 12rpx; border: 2rpx solid #fff; border-radius: 50%; background: #ff5b25; }
.eg-cell.is-fire-clearing { border-color: #ff7a1a; box-shadow: inset 0 0 25rpx rgba(255,102,18,.36), 0 0 18rpx rgba(255,105,20,.48); filter: brightness(1.35) saturate(1.42); }
.eg-cell.is-clearing.is-fire-clearing { animation: egFireCellHit .36s cubic-bezier(.2,.78,.22,1) forwards; }
.eg-fire-clear { position: absolute; z-index: 14; inset: 2rpx; overflow: hidden; border: 3rpx solid rgba(255,210,71,.92); border-radius: 22rpx; opacity: .96; background: linear-gradient(180deg, rgba(255,230,86,.12), rgba(239,63,27,.22)); pointer-events: none; }
.eg-fire-glow { position: absolute; inset: 6%; border-radius: 18rpx; background: radial-gradient(circle, rgba(255,246,177,.74), rgba(255,131,22,.32) 47%, rgba(222,47,23,0) 75%); animation: egFireGlow .34s ease-out both; }
.eg-fire-sweep { position: absolute; top: 23%; left: -48%; width: 168%; height: 55%; border-radius: 60% 45% 55% 42%; background: linear-gradient(90deg, rgba(255,184,38,0), rgba(255,238,124,.94) 28%, rgba(255,133,22,.92) 53%, rgba(226,54,25,.82) 72%, rgba(255,184,38,0)); box-shadow: 0 0 27rpx rgba(255,91,17,.72); animation: egFireSweep .34s cubic-bezier(.2,.78,.22,1) both; }
.eg-fire-flame { position: absolute; bottom: 13%; width: 25rpx; height: 39rpx; border-radius: 72% 35% 68% 42%; background: linear-gradient(145deg, #fff7a6 4%, #ffc329 39%, #ff7419 66%, #dc321f 100%); box-shadow: 0 0 15rpx rgba(255,124,21,.88); transform: rotate(42deg); animation: egFireFlame .34s ease-out both; }
.eg-fire-flame > view { position: absolute; right: 5rpx; bottom: 4rpx; width: 10rpx; height: 18rpx; border-radius: 70% 35% 70% 42%; background: #fffbd6; }
.eg-fire-flame.is-left { left: 16%; animation-delay: .01s; }
.eg-fire-flame.is-center { left: 43%; bottom: 19%; transform: rotate(42deg) scale(1.18); animation-delay: .035s; }
.eg-fire-flame.is-right { right: 15%; animation-delay: .065s; }
.eg-fire-spark { position: absolute; width: 10rpx; height: 17rpx; border-radius: 70% 35% 70% 42%; background: #ffe45e; box-shadow: 0 0 11rpx rgba(255,139,28,.9); animation: egFireSpark .34s ease-out both; }
.eg-fire-spark.is-one { top: 46%; left: 25%; }
.eg-fire-spark.is-two { top: 34%; left: 62%; animation-delay: .035s; }
.eg-fire-spark.is-three { top: 52%; left: 78%; animation-delay: .07s; }
.eg-board-tip { display: flex; align-items: center; justify-content: center; gap: 8rpx; min-height: 58rpx; margin-top: 12rpx; color: #684817; font-size: 26rpx; font-weight: 700; text-align: center; }
.eg-impact-text {
position: absolute;
z-index: 20;
top: 46%;
left: 50%;
padding: 10rpx 24rpx;
border: 4rpx solid rgba(255,255,255,.9);
border-radius: 999rpx;
color: #fff;
background: linear-gradient(135deg, #f59e0b, #e4572e);
box-shadow: 0 10rpx 28rpx rgba(190, 70, 25, .35);
font-size: 34rpx;
font-weight: 900;
transform: translate(-50%, -50%);
animation: egImpact .52s cubic-bezier(.16,.82,.3,1) forwards;
pointer-events: none;
}
.eg-impact-text.is-level-2 { font-size: 40rpx; background: linear-gradient(135deg, #8b5cf6, #ec4899); }
.eg-impact-text.is-level-3 { font-size: 46rpx; background: linear-gradient(135deg, #ef4444, #f59e0b); box-shadow: 0 12rpx 36rpx rgba(239,68,68,.42); }
.eg-jackpot {
position: absolute;
z-index: 30;
top: 48%;
left: 50%;
width: 540rpx;
padding: 24rpx 22rpx 20rpx;
overflow: hidden;
border: 7rpx solid #ffd83d;
border-radius: 36rpx;
color: #fff;
background: radial-gradient(circle at 50% 0%, #ef4444, #9f1239 65%, #64102a);
box-shadow: 0 0 0 7rpx #fff2a8, 0 22rpx 52rpx rgba(100,16,42,.5);
text-align: center;
transform: translate(-50%, -50%);
animation: egJackpotIn 1.35s cubic-bezier(.16,.85,.25,1) forwards;
pointer-events: none;
}
.eg-jackpot.is-match-4 { background: radial-gradient(circle at 50% 0%, #8b5cf6, #5b21b6 68%, #35106d); }
.eg-jackpot.is-match-5 { border-color: #fff36a; background: radial-gradient(circle at 50% 0%, #ff6b24, #d7193f 58%, #760c2a); animation-duration: 1.9s; }
.eg-jackpot.is-match-3 {
width: 414rpx;
padding: 20rpx 22rpx 18rpx;
border-width: 4rpx;
border-color: #f1c663;
color: #31574b;
background: linear-gradient(155deg, #f7fff9, #fff5d9);
box-shadow: 0 0 0 4rpx rgba(255,255,255,.84), 0 14rpx 34rpx rgba(98,82,39,.2);
animation: egTripleIn 1.12s cubic-bezier(.2,.82,.28,1) forwards;
}
.eg-jackpot.is-match-3 .eg-jackpot-lights { display: none; }
.eg-jackpot.is-match-3 .eg-jackpot-kicker { color: #5b8c73; font-size: 21rpx; letter-spacing: 3rpx; text-shadow: none; }
.eg-jackpot.is-match-3 .eg-jackpot-title { color: #365b4e; font-size: 31rpx; text-shadow: none; }
.eg-jackpot.is-match-3 .eg-jackpot-reward { color: #ad7021; font-size: 25rpx; }
.eg-jackpot.is-match-3 .eg-jackpot-note { color: #829087; }
.eg-triple-cans { position: relative; display: flex; justify-content: center; gap: 12rpx; margin: 12rpx 0 10rpx; }
.eg-triple-can { display: flex; width: 78rpx; height: 76rpx; align-items: center; justify-content: center; border: 3rpx solid #f4d996; border-radius: 19rpx; background: rgba(255,255,255,.9); box-shadow: 0 5rpx 12rpx rgba(128,92,30,.12); animation: egTripleCan .42s cubic-bezier(.2,.86,.26,1) both; }
.eg-triple-can:nth-child(2) { animation-delay: .07s; }
.eg-triple-can:nth-child(3) { animation-delay: .14s; }
.eg-triple-can > image { width: 62rpx; height: 58rpx; }
.eg-jackpot.is-match-4 {
width: 548rpx;
padding: 25rpx 22rpx 22rpx;
border-color: #ffd95b;
background: radial-gradient(circle at 50% -10%, #a97cff, #6031b5 58%, #341066);
box-shadow: 0 0 0 6rpx #fff0a9, 0 0 36rpx rgba(255,210,76,.5), 0 24rpx 58rpx rgba(53,16,109,.48);
animation: egFourIn 1.88s cubic-bezier(.16,.84,.24,1) forwards;
}
.eg-jackpot.is-match-4 .eg-jackpot-kicker { color: #ffef8d; font-size: 24rpx; letter-spacing: 3rpx; }
.eg-jackpot.is-match-4 .eg-jackpot-title { margin-top: 9rpx; color: #fff; font-size: 39rpx; }
.eg-jackpot.is-match-4 .eg-jackpot-reward { color: #fff2a8; }
.eg-four-teaser { position: relative; margin-top: 13rpx; }
.eg-four-cans { display: flex; align-items: center; justify-content: center; gap: 8rpx; }
.eg-four-can { display: flex; width: 76rpx; height: 92rpx; align-items: center; justify-content: center; border: 4rpx solid #ffe078; border-radius: 18rpx; background: linear-gradient(180deg, #fffef7, #ffe9a5); box-shadow: inset 0 -5rpx 0 rgba(171,106,17,.12), 0 7rpx 13rpx rgba(34,12,67,.28); animation: egFourCan .48s cubic-bezier(.18,.9,.25,1.2) both; }
.eg-four-can:nth-child(2) { animation-delay: .07s; }
.eg-four-can:nth-child(3) { animation-delay: .14s; }
.eg-four-can:nth-child(4) { animation-delay: .21s; }
.eg-four-can > image { width: 64rpx; height: 70rpx; }
.eg-four-can.is-locked { border-style: dashed; border-color: rgba(255,241,161,.86); color: #fff6ae; background: rgba(48,18,91,.54); box-shadow: inset 0 0 16rpx rgba(255,225,91,.18), 0 0 18rpx rgba(255,222,91,.36); font-size: 48rpx; font-weight: 1000; animation: egLockedCan .68s .34s ease-in-out infinite alternate; }
.eg-four-promise { display: inline-flex; margin-top: 15rpx; padding: 8rpx 18rpx; border: 2rpx solid rgba(255,247,184,.72); border-radius: 999rpx; color: #3d176f; background: linear-gradient(90deg, #fff3a3, #ffd85b); box-shadow: 0 5rpx 15rpx rgba(28,7,62,.22); font-size: 25rpx; font-weight: 1000; animation: egPromisePulse .72s ease-in-out infinite alternate; }
.eg-five-celebration { position: relative; margin: 15rpx 0 10rpx; }
.eg-five-cans { display: flex; align-items: center; justify-content: center; gap: 8rpx; }
.eg-five-can { display: flex; width: 78rpx; height: 93rpx; align-items: center; justify-content: center; border: 4rpx solid #fff17b; border-radius: 18rpx; background: linear-gradient(180deg, #fff, #fff2ac); box-shadow: inset 0 -5rpx 0 rgba(184,91,12,.13), 0 7rpx 0 #bd5b13, 0 0 16rpx rgba(255,240,101,.5); animation: egFiveCan .56s cubic-bezier(.16,.92,.24,1.2) both; }
.eg-five-can:nth-child(2) { animation-delay: .07s; }
.eg-five-can:nth-child(3) { animation-delay: .14s; }
.eg-five-can:nth-child(4) { animation-delay: .21s; }
.eg-five-can:nth-child(5) { animation-delay: .28s; }
.eg-five-can > image { width: 65rpx; height: 72rpx; }
.eg-five-sevens { display: flex; justify-content: center; gap: 8rpx; margin-top: 13rpx; }
.eg-five-sevens > text { display: flex; width: 58rpx; height: 55rpx; align-items: center; justify-content: center; border: 3rpx solid #fff5a7; border-radius: 13rpx; color: #fff36a; background: linear-gradient(155deg, #d7193f, #9f1239); box-shadow: 0 5rpx 0 #70102b, 0 0 13rpx rgba(255,243,106,.45); font-size: 43rpx; font-weight: 1000; line-height: 1; text-shadow: 0 3rpx 0 #8f1733; }
.eg-jackpot-lights { position: absolute; inset: 8rpx; border: 4rpx dotted rgba(255,255,255,.88); border-radius: 25rpx; animation: egLights .24s steps(2) infinite; }
.eg-jackpot-kicker { position: relative; display: block; color: #fff36a; font-size: 28rpx; font-weight: 900; letter-spacing: 6rpx; text-shadow: 0 3rpx 0 rgba(83,19,25,.45); }
.eg-jackpot-reels { position: relative; display: flex; justify-content: center; gap: 12rpx; margin: 13rpx 0; }
.eg-jackpot-reel { display: flex; width: 112rpx; height: 126rpx; flex-direction: column; align-items: center; justify-content: center; border: 6rpx solid #ffcf31; border-radius: 20rpx; background: linear-gradient(180deg, #fff 0%, #fff7cf 48%, #ffd55e 50%, #fff 53%, #fff8dc 100%); box-shadow: inset 0 0 18rpx rgba(137,78,10,.25), 0 7rpx 0 #a85a0a; animation: egReelStop .52s cubic-bezier(.2,.9,.25,1) both; }
.eg-jackpot-reel:nth-child(2) { animation-delay: .09s; }
.eg-jackpot-reel:nth-child(3) { animation-delay: .18s; }
.eg-jackpot-seven { color: #e11d48; font-size: 61rpx; font-weight: 1000; line-height: .85; text-shadow: 0 3rpx 0 #ffd1d8; }
.eg-jackpot-milk { width: 54rpx; height: 36rpx; margin-top: 3rpx; border-radius: 7rpx; }
.eg-jackpot-title { position: relative; display: block; font-size: 37rpx; font-weight: 900; text-shadow: 0 4rpx 0 rgba(73,10,30,.5); }
.eg-jackpot-points { position: relative; display: block; margin-top: 2rpx; color: #fff36a; font-size: 66rpx; font-weight: 1000; line-height: 1.05; letter-spacing: 2rpx; text-shadow: 0 5rpx 0 #9e2813, 0 0 18rpx rgba(255,243,106,.85); animation: egPointsPop .62s .42s cubic-bezier(.16,.9,.25,1.25) both; }
.eg-jackpot-reward { position: relative; display: block; margin-top: 7rpx; color: #fff5a5; font-size: 27rpx; font-weight: 800; line-height: 1.35; }
.eg-jackpot-note { position: relative; display: block; margin-top: 8rpx; color: rgba(255,255,255,.8); font-size: 22rpx; }
.eg-tools { margin-top: 16rpx; }
.eg-tool { display: flex; align-items: center; padding: 16rpx 18rpx; border: 3rpx solid #c9e0d6; border-radius: 26rpx; background: rgba(255,255,255,.92); box-shadow: 0 7rpx 18rpx rgba(24, 84, 63, .07); }
.eg-tool.active { border-color: #f59e0b; background: #fffbeb; }
.eg-tool.disabled { opacity: .45; }
.eg-tool-icon { position: relative; display: flex; width: 88rpx; height: 88rpx; align-items: center; justify-content: center; overflow: visible; border-radius: 24rpx; font-size: 46rpx; background: linear-gradient(145deg, #e8f7ff, #d4ecff); box-shadow: inset 0 -4rpx 0 rgba(33,104,151,.08); }
.eg-tool-food-image { width: 76rpx; height: 58rpx; border-radius: 14rpx; }
.eg-replace-badge { position: absolute; right: -12rpx; bottom: -8rpx; display: flex; min-width: 78rpx; height: 38rpx; align-items: center; justify-content: center; gap: 2rpx; padding: 0 8rpx; border: 3rpx solid #fff; border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #2389b9, #145d91); box-shadow: 0 5rpx 12rpx rgba(20,93,145,.3); }
.eg-replace-badge .tj-icon-wrap { width: 22rpx; height: 22rpx; }
.eg-replace-badge > text { font-size: 20rpx; font-weight: 900; line-height: 1; }
.eg-tool-copy { display: flex; flex: 1; flex-direction: column; margin-left: 16rpx; }
.eg-tool-name { color: #244a3d; font-size: 29rpx; font-weight: 800; }
.eg-tool-desc { margin-top: 5rpx; color: #6c8179; font-size: 25rpx; }
.eg-tool-count { color: #155e4b; font-size: 32rpx; font-weight: 900; }
.eg-legend { display: flex; justify-content: center; gap: 18rpx; margin-top: 18rpx; color: #53685f; font-size: 25rpx; font-weight: 700; }
.eg-dot--safe { color: #4cae7b; }
.eg-dot--mid { color: #e2aa19; }
.eg-dot--high { color: #e4572e; }
.eg-weekly-overlay { position: fixed; z-index: 90; inset: 0; display: flex; align-items: center; justify-content: center; padding: 28rpx; background: rgba(16, 55, 43, .66); backdrop-filter: blur(5px); }
.eg-weekly-card { width: 100%; max-width: 664rpx; max-height: calc(100vh - 56rpx); overflow-y: auto; padding: 28rpx 26rpx 22rpx; border: 5rpx solid rgba(255,255,255,.96); border-radius: 38rpx; background: linear-gradient(160deg, #f5fff9 0%, #fffdf3 56%, #fff4db 100%); box-shadow: 0 30rpx 80rpx rgba(5,45,32,.38); animation: egWeeklyIn .36s cubic-bezier(.18,.88,.27,1.08) both; }
.eg-weekly-head { display: flex; align-items: center; justify-content: space-between; }
.eg-weekly-title-line { display: flex; align-items: center; gap: 10rpx; }
.eg-weekly-title { color: #155e4b; font-size: 37rpx; font-weight: 1000; }
.eg-weekly-preview { padding: 5rpx 10rpx; border-radius: 999rpx; color: #9a5b14; background: #fff0bd; font-size: 19rpx; font-weight: 900; }
.eg-weekly-preview.is-offline { color: #69766f; background: #e8eeeb; }
.eg-weekly-sub { display: block; margin-top: 5rpx; color: #6b837a; font-size: 23rpx; font-weight: 700; }
.eg-weekly-medal { display: flex; width: 76rpx; height: 76rpx; align-items: center; justify-content: center; border: 5rpx solid #fff3b2; border-radius: 50%; color: #fff; background: linear-gradient(145deg, #f5ad24, #e26925); box-shadow: 0 8rpx 0 #b85421, 0 12rpx 24rpx rgba(181,84,33,.24); font-size: 39rpx; font-weight: 1000; }
.eg-weekly-progress-card { display: flex; align-items: center; justify-content: space-between; margin-top: 20rpx; padding: 16rpx 18rpx; border: 3rpx solid #d7ebe2; border-radius: 24rpx; background: rgba(255,255,255,.9); }
.eg-weekly-progress-label { display: block; color: #657b73; font-size: 22rpx; font-weight: 700; }
.eg-weekly-progress-value { display: block; margin-top: -2rpx; color: #e06b24; font-size: 43rpx; font-weight: 1000; }
.eg-weekly-progress-copy { display: flex; flex-direction: column; align-items: flex-end; color: #235b49; font-size: 24rpx; font-weight: 900; line-height: 1.55; }
.eg-weekly-progress-copy text:last-child { color: #b86820; }
.eg-weekly-list { display: flex; flex-direction: column; gap: 7rpx; margin-top: 15rpx; }
.eg-weekly-row { display: flex; min-height: 68rpx; align-items: center; padding: 7rpx 14rpx; border: 2rpx solid transparent; border-radius: 19rpx; color: #345c50; background: rgba(255,255,255,.72); }
.eg-weekly-row.is-me { border-color: #f2bd48; color: #174f3e; background: linear-gradient(90deg, #fff2bb, #fff9e3); box-shadow: 0 5rpx 14rpx rgba(153,102,17,.12); transform: scale(1.015); }
.eg-weekly-place { width: 43rpx; color: #6b7d76; font-size: 27rpx; font-weight: 1000; text-align: center; }
.eg-weekly-row:nth-child(1) .eg-weekly-place { color: #dd7b17; font-size: 31rpx; }
.eg-weekly-avatar { display: flex; width: 52rpx; height: 52rpx; flex: 0 0 52rpx; align-items: center; justify-content: center; margin-left: 6rpx; border: 3rpx solid rgba(255,255,255,.9); border-radius: 50%; color: #fff; background: #5d9b84; box-shadow: 0 3rpx 9rpx rgba(30,78,61,.16); font-size: 24rpx; font-weight: 1000; }
.eg-weekly-avatar-image { display: block; width: 100%; height: 100%; border-radius: 50%; }
.eg-weekly-avatar.is-tone-2 { background: #e39932; }
.eg-weekly-avatar.is-tone-3 { background: #6c83c8; }
.eg-weekly-avatar.is-tone-4 { background: #e5683e; }
.eg-weekly-avatar.is-tone-5 { background: #a673bd; }
.eg-weekly-avatar.is-tone-6 { background: #448e9c; }
.eg-weekly-avatar.is-tone-7 { background: #c27b49; }
.eg-weekly-name { flex: 1; margin-left: 13rpx; overflow: hidden; font-size: 27rpx; font-weight: 850; text-overflow: ellipsis; white-space: nowrap; }
.eg-weekly-me { margin-right: 8rpx; padding: 3rpx 9rpx; border-radius: 999rpx; color: #fff; background: #dd6b27; font-size: 18rpx; font-weight: 900; }
.eg-weekly-count { min-width: 70rpx; color: #315b4d; font-size: 29rpx; font-weight: 1000; text-align: right; }
.eg-cheer-card { margin-top: 16rpx; padding: 15rpx 17rpx 14rpx; border: 3rpx solid #f0d38d; border-radius: 23rpx; background: linear-gradient(135deg, #fff9d9, #fff1c2); }
.eg-cheer-card.is-family { border-color: #d9c8ef; background: linear-gradient(135deg, #f8f2ff, #efe7ff); }
.eg-cheer-card.is-bright { border-color: #f4c77b; background: linear-gradient(135deg, #fff8dc, #ffeec7); }
.eg-cheer-top { display: flex; align-items: center; justify-content: space-between; }
.eg-cheer-source { color: #8e5c18; font-size: 21rpx; font-weight: 900; }
.eg-cheer-card.is-family .eg-cheer-source { color: #77539c; }
.eg-cheer-change { padding: 5rpx 12rpx; border-radius: 999rpx; color: #176b52; background: rgba(255,255,255,.75); font-size: 19rpx; font-weight: 900; }
.eg-cheer-copy { display: block; margin-top: 7rpx; color: #55462d; font-size: 28rpx; font-weight: 900; line-height: 1.42; }
.eg-cheer-note { display: block; margin-top: 5rpx; color: #8d7c77; font-size: 18rpx; }
.eg-weekly-start { display: flex; height: 84rpx; align-items: center; justify-content: center; margin-top: 18rpx; border-radius: 24rpx; color: #fff; background: linear-gradient(135deg, #24906c, #12654a); box-shadow: 0 9rpx 20rpx rgba(18,101,74,.24); font-size: 30rpx; font-weight: 1000; }
.eg-weekly-share { display: flex; width: 100%; height: 76rpx; align-items: center; justify-content: center; margin: 12rpx 0 0; padding: 0; border: 3rpx solid #b9d9cc; border-radius: 22rpx; color: #17684f; background: rgba(255,255,255,.82); font-size: 27rpx; font-weight: 900; line-height: 1; }
.eg-weekly-share::after { border: 0; }
.eg-weekly-footnote { display: block; margin-top: 10rpx; color: #8b8b7d; font-size: 18rpx; text-align: center; }
.eg-stage-overlay { position: fixed; z-index: 58; inset: 0; display: flex; align-items: center; justify-content: center; padding: 40rpx; background: rgba(22,62,50,.42); pointer-events: none; }
.eg-stage-card { width: 100%; max-width: 620rpx; padding: 34rpx 28rpx 30rpx; border: 6rpx solid #fff; border-radius: 38rpx; background: linear-gradient(155deg, #f5fff9, #fff8da); box-shadow: 0 26rpx 70rpx rgba(10,56,41,.3); text-align: center; animation: egStageIn 1.8s cubic-bezier(.16,.86,.28,1) both; }
.eg-stage-kicker { display: block; color: #16815f; font-size: 23rpx; font-weight: 900; letter-spacing: 3rpx; }
.eg-stage-number { display: block; margin-top: 5rpx; color: #e47722; font-size: 48rpx; font-weight: 1000; line-height: 1.1; }
.eg-stage-title { display: block; margin-top: 7rpx; color: #194e3d; font-size: 38rpx; font-weight: 900; }
.eg-stage-foods { display: flex; justify-content: center; gap: 9rpx; margin-top: 22rpx; }
.eg-stage-food { display: flex; width: 92rpx; min-height: 110rpx; flex-direction: column; align-items: center; justify-content: center; border: 3rpx solid #dbece4; border-radius: 20rpx; color: #315b4e; background: #fff; font-size: 20rpx; font-weight: 800; }
.eg-stage-food > image { width: 72rpx; height: 52rpx; border-radius: 12rpx; }
.eg-stage-food > text { margin-top: 8rpx; }
.eg-stage-food.is-danger { border-color: #ef8b52; color: #a43c20; background: #fff1e6; box-shadow: 0 0 0 4rpx rgba(239,139,82,.12); }
.eg-stage-tip { display: block; margin-top: 20rpx; color: #6e776d; font-size: 21rpx; font-weight: 700; }
.eg-reshuffle-overlay { position: fixed; z-index: 65; inset: 0; display: flex; align-items: center; justify-content: center; padding: 46rpx; background: rgba(19,55,45,.5); }
.eg-reshuffle-card { width: 100%; max-width: 540rpx; padding: 38rpx 30rpx; border: 5rpx solid rgba(255,255,255,.95); border-radius: 36rpx; background: linear-gradient(155deg, #f4fff9, #e6f6ef); box-shadow: 0 24rpx 64rpx rgba(10,48,37,.3); text-align: center; }
.eg-reshuffle-icon { display: flex; width: 94rpx; height: 94rpx; align-items: center; justify-content: center; margin: 0 auto 18rpx; border-radius: 50%; background: linear-gradient(135deg, #2aa87b, #126a50); box-shadow: 0 10rpx 24rpx rgba(18,106,80,.28); animation: egReshuffleSpin .85s linear infinite; }
.eg-reshuffle-title { display: block; color: #174e3d; font-size: 36rpx; font-weight: 900; }
.eg-reshuffle-desc { display: block; margin-top: 10rpx; color: #5b746b; font-size: 24rpx; font-weight: 700; }
.eg-overlay { position: fixed; z-index: 50; top: 0; right: 0; bottom: 0; left: 0; display: flex; align-items: center; justify-content: center; padding: 36rpx; background: rgba(20, 48, 40, .62); }
.eg-modal { width: 100%; max-width: 650rpx; padding: 34rpx 28rpx 28rpx; border-radius: 38rpx; background: #fffdf8; box-shadow: 0 28rpx 70rpx rgba(0,0,0,.22); }
.eg-modal-icon { display: block; font-size: 68rpx; text-align: center; }
.eg-modal-title { display: block; margin-top: 6rpx; color: #204e3d; font-size: 38rpx; font-weight: 900; text-align: center; }
.eg-modal-sub { display: block; margin: 8rpx 0 22rpx; color: #718078; font-size: 24rpx; text-align: center; }
.eg-result-warning { display: flex; width: 92rpx; height: 92rpx; align-items: center; justify-content: center; margin: 0 auto; border-radius: 50%; color: #fff; background: #e4572e; font-size: 60rpx; font-weight: 900; }
.eg-result-food { display: block; margin: 12rpx 0 18rpx; color: #c34b27; font-size: 28rpx; font-weight: 800; text-align: center; }
.eg-knowledge-card { padding: 22rpx; border-radius: 24rpx; background: #fff3e7; }
.eg-knowledge-title { display: block; color: #8d4427; font-size: 26rpx; font-weight: 800; }
.eg-knowledge-copy { display: block; margin-top: 9rpx; color: #5f514a; font-size: 23rpx; line-height: 1.65; }
.eg-knowledge-review { display: block; margin-top: 12rpx; color: #9b7768; font-size: 19rpx; }
.eg-result-score { display: flex; align-items: center; justify-content: space-between; margin: 20rpx 6rpx; color: #466258; font-size: 25rpx; }
.eg-result-score text:last-child { color: #e16f24; font-size: 36rpx; font-weight: 900; }
.eg-result-best { display: flex; align-items: center; justify-content: space-between; margin: -12rpx 6rpx 20rpx; color: #75867f; font-size: 22rpx; }
.eg-result-best text:last-child { color: #315d4e; font-size: 27rpx; font-weight: 900; }
.eg-primary-btn, .eg-secondary-btn { display: flex; height: 88rpx; align-items: center; justify-content: center; border-radius: 24rpx; font-size: 28rpx; font-weight: 800; }
.eg-primary-btn { color: #fff; background: linear-gradient(135deg, #238b68, #126649); box-shadow: 0 10rpx 22rpx rgba(18,102,73,.23); }
.eg-share-btn { display: flex; width: 100%; height: 80rpx; align-items: center; justify-content: center; margin: 12rpx 0 0; padding: 0; border: 3rpx solid #bddbce; border-radius: 24rpx; color: #17664e; background: #f4fbf7; font-size: 27rpx; font-weight: 900; line-height: 1; }
.eg-share-btn::after { border: 0; }
.eg-secondary-btn { margin-top: 12rpx; color: #45665b; background: #edf4f1; }
@keyframes egPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(245,158,11,.2); }
50% { box-shadow: 0 0 0 8rpx rgba(245,158,11,.12); }
}
@keyframes egWeeklyIn {
0% { opacity: 0; transform: translateY(34rpx) scale(.91); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes egHit {
0% { opacity: 1; transform: scale(1); }
42% { opacity: 1; transform: scale(1.18) rotate(-3deg); }
100% { opacity: 0; transform: scale(.32) rotate(7deg); }
}
@keyframes egTaskRewardFlight {
0% { opacity: 0; transform: translate3d(-20rpx, 0, 0) scale(.62) rotate(-8deg); }
16% { opacity: 1; transform: translate3d(0, -24rpx, 0) scale(1.1) rotate(3deg); }
72% { opacity: 1; transform: translate3d(270rpx, 900rpx, 0) scale(.68) rotate(8deg); }
100% { opacity: 0; transform: translate3d(350rpx, 1205rpx, 0) scale(.3) rotate(2deg); }
}
@keyframes egRewardGlow {
from { opacity: .4; transform: scale(.86); }
to { opacity: .9; transform: scale(1.12); }
}
@keyframes egFireSweep {
0% { opacity: 0; transform: translate3d(-38%, 20rpx, 0) scaleX(.6); }
28% { opacity: 1; }
72% { opacity: .96; }
100% { opacity: 0; transform: translate3d(58%, -15rpx, 0) scaleX(1.08); }
}
@keyframes egFireSpark {
0% { opacity: 0; transform: translateY(14rpx) scale(.5) rotate(35deg); }
38% { opacity: 1; }
100% { opacity: 0; transform: translateY(-38rpx) scale(1.05) rotate(58deg); }
}
@keyframes egFireGlow {
0% { opacity: 0; transform: scale(.72); }
38% { opacity: 1; transform: scale(1.08); }
100% { opacity: 0; transform: scale(1.28); }
}
@keyframes egFireCellHit {
0% { opacity: 1; transform: scale(1); }
24% { opacity: 1; transform: scale(1.09); }
72% { opacity: 1; transform: scale(1.02); }
100% { opacity: 0; transform: scale(.48); }
}
@keyframes egFireFlame {
0% { opacity: 0; transform: translateY(20rpx) rotate(42deg) scale(.55); }
38% { opacity: 1; transform: translateY(0) rotate(42deg) scale(1.05); }
100% { opacity: 0; transform: translateY(-31rpx) rotate(48deg) scale(.86); }
}
@keyframes egFlameReady {
from { transform: scale(.94); filter: brightness(.96); }
to { transform: scale(1.08); filter: brightness(1.18); }
}
@keyframes egLReady {
from { transform: scale(.94) rotate(-2deg); filter: brightness(.96); }
to { transform: scale(1.06) rotate(2deg); filter: brightness(1.12); }
}
@keyframes egImpact {
0% { opacity: 0; transform: translate(-50%, -42%) scale(.55); }
35% { opacity: 1; transform: translate(-50%, -50%) scale(1.16); }
72% { opacity: 1; transform: translate(-50%, -54%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -76%) scale(.92); }
}
@keyframes egJackpotIn {
0% { opacity: 0; transform: translate(-50%, -45%) scale(.48) rotate(-4deg); }
20% { opacity: 1; transform: translate(-50%, -50%) scale(1.09) rotate(2deg); }
31% { transform: translate(-50%, -50%) scale(.98) rotate(0); }
78% { opacity: 1; transform: translate(-50%, -53%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -68%) scale(.92); }
}
@keyframes egTripleIn {
0% { opacity: 0; transform: translate(-50%, -44%) scale(.82); }
20% { opacity: 1; transform: translate(-50%, -50%) scale(1.03); }
74% { opacity: 1; transform: translate(-50%, -52%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -60%) scale(.96); }
}
@keyframes egTripleCan {
0% { opacity: 0; transform: translateY(18rpx) scale(.72); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes egFourIn {
0% { opacity: 0; transform: translate(-50%, -44%) scale(.62) rotate(-3deg); }
18% { opacity: 1; transform: translate(-50%, -50%) scale(1.08) rotate(1deg); }
29% { transform: translate(-50%, -50%) scale(.98) rotate(0); }
78% { opacity: 1; transform: translate(-50%, -52%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -65%) scale(.94); }
}
@keyframes egFourCan {
0% { opacity: 0; transform: translateY(-22rpx) rotate(-7deg) scale(.65); }
75% { opacity: 1; transform: translateY(5rpx) rotate(2deg) scale(1.05); }
100% { opacity: 1; transform: translateY(0) rotate(0) scale(1); }
}
@keyframes egFiveCan {
0% { opacity: 0; transform: translateY(-40rpx) scale(.52) rotate(-8deg); filter: brightness(1.5); }
70% { opacity: 1; transform: translateY(7rpx) scale(1.08) rotate(2deg); }
100% { opacity: 1; transform: translateY(0) scale(1) rotate(0); filter: brightness(1); }
}
@keyframes egLockedCan {
from { opacity: .62; transform: scale(.92); filter: brightness(.9); }
to { opacity: 1; transform: scale(1.06); filter: brightness(1.2); }
}
@keyframes egPromisePulse {
from { transform: scale(.97); box-shadow: 0 5rpx 15rpx rgba(28,7,62,.22); }
to { transform: scale(1.03); box-shadow: 0 7rpx 21rpx rgba(255,221,89,.38); }
}
@keyframes egReelStop {
0% { opacity: .2; transform: translateY(-48rpx) scaleY(1.4); filter: blur(5rpx); }
70% { opacity: 1; transform: translateY(7rpx) scaleY(.94); filter: blur(0); }
100% { transform: translateY(0) scaleY(1); }
}
@keyframes egLights {
0% { opacity: .35; }
100% { opacity: 1; }
}
@keyframes egConfettiFall {
0% { opacity: 0; transform: translate3d(0, -8vh, 0) rotate(0deg) scale(.7); }
9% { opacity: 1; }
48% { transform: translate3d(42rpx, 48vh, 0) rotate(420deg) scale(1); }
100% { opacity: .92; transform: translate3d(-28rpx, 112vh, 0) rotate(920deg) scale(.86); }
}
@keyframes egPointsPop {
0% { opacity: 0; transform: scale(.35) rotate(-7deg); }
55% { opacity: 1; transform: scale(1.28) rotate(3deg); }
78% { transform: scale(.92) rotate(0); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes egStageIn {
0% { opacity: 0; transform: scale(.7) translateY(34rpx); }
18% { opacity: 1; transform: scale(1.06) translateY(0); }
30%, 76% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(.96) translateY(-22rpx); }
}
@keyframes egReshuffleSpin {
from { transform: rotate(0); }
to { transform: rotate(360deg); }
}
@media screen and (max-height: 760px) {
.eg-task-card { padding-top: 13rpx; padding-bottom: 13rpx; }
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,993 @@
/**
* 日常记录统一主题 — 与甄养堂小程序 TCM Care 品牌色一致
* 来源:pages/index/index.vue · tabBar selectedColor #204e2b
*/
.daily-page {
/* 品牌主色 */
--primary: #204e2b;
--primary-dark: #163d22;
--primary-container: #386641;
--primary-grad: linear-gradient(160deg, #204e2b 0%, #386641 100%);
--primary-light: #eef6ef;
--primary-soft: #cfe8d3;
--primary-glow: rgba(32, 78, 43, 0.14);
--on-primary-container: #afe2b3;
--success: #386641;
--success-light: #eef6ef;
--danger: #dc2626;
--danger-light: #fef2f2;
--danger-glow: rgba(220, 38, 38, 0.1);
--warning: #d97706;
--warning-light: #fff7ed;
--text-primary: #1b1c1a;
--text-secondary: #414941;
--text-muted: #727970;
--surface: #ffffff;
--surface-muted: #f4f4f0;
--border: #e3e2df;
--border-soft: rgba(32, 78, 43, 0.12);
--slate-50: #faf9f5;
--slate-100: #f4f4f0;
--slate-200: #e9e8e4;
--slate-300: #e3e2df;
--slate-400: #727970;
--slate-600: #414941;
--slate-900: #1b1c1a;
--shadow-premium: 0 8rpx 28rpx rgba(32, 78, 43, 0.06);
--shadow-sm: 0 4rpx 16rpx rgba(32, 78, 43, 0.05);
--shadow-glow-primary: 0 8rpx 20rpx rgba(32, 78, 43, 0.18);
--shadow-glow-danger: 0 8rpx 20rpx rgba(220, 38, 38, 0.12);
--border-premium: 1rpx solid var(--border-soft);
background: var(--slate-50);
}
/* Hero:品牌绿渐变 */
.daily-page .hero-bg {
background: var(--primary-grad);
&::after {
background: radial-gradient(circle at 88% 12%, rgba(255, 255, 255, 0.1) 0%, transparent 55%);
}
}
.daily-page .hero-orb {
display: none;
}
.daily-page .hero-pill.voice.speaking {
background: rgba(255, 255, 255, 0.92);
border-color: rgba(255, 255, 255, 0.95);
animation: none;
box-shadow: 0 0 0 3rpx rgba(255, 255, 255, 0.35);
}
.daily-page .hero-status-chip.pending {
background: rgba(255, 255, 255, 0.16);
border-color: rgba(255, 255, 255, 0.28);
}
.daily-page .hero-status-chip.done {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.32);
}
/* 指标卡 */
.daily-page .stat-fasting,
.daily-page .stat-postprandial,
.daily-page .stat-other {
background: var(--surface);
}
.daily-page .stat-fasting .stat-card-tag,
.daily-page .stat-postprandial .stat-card-tag,
.daily-page .stat-other .stat-card-tag {
background: var(--primary-light);
color: var(--primary-dark);
}
.daily-page .stat-card-value {
background: none;
-webkit-background-clip: unset;
background-clip: unset;
color: var(--text-primary);
}
.daily-page .stat-card-value.is-high {
background: none;
color: var(--danger);
}
.daily-page .stat-card-trend.trend-down {
background: var(--primary-light);
.stat-card-trend-arrow,
.stat-card-trend-delta {
color: var(--primary-dark);
}
}
.daily-page .more-entry-arrow {
color: var(--primary);
}
.daily-page .card-chip.active {
background: var(--primary);
box-shadow: var(--shadow-glow-primary);
}
.daily-page .range-item.active {
background: var(--primary);
box-shadow: var(--shadow-glow-primary);
}
.daily-page .legend-dot-fasting {
background: var(--primary);
}
.daily-page .legend-dot-postprandial {
background: var(--warning);
}
.daily-page .streak-strip {
background: var(--surface);
border-color: var(--border-soft);
&::after {
background: radial-gradient(circle, rgba(32, 78, 43, 0.08) 0%, transparent 70%);
}
}
.daily-page .streak-num {
color: var(--primary-dark);
}
/* 日历:品牌绿深浅 */
.daily-page .calendar-cell.level-1 .calendar-cell-mark,
.daily-page .calendar-legend-cell.level-1 {
background: var(--primary-soft);
border-color: #b8dcc0;
}
.daily-page .calendar-cell.level-2 .calendar-cell-mark,
.daily-page .calendar-legend-cell.level-2 {
background: var(--on-primary-container);
border-color: #86c992;
}
.daily-page .calendar-cell.level-3 .calendar-cell-mark,
.daily-page .calendar-legend-cell.level-3 {
background: var(--primary);
border-color: var(--primary-dark);
}
.daily-page .day-block-tag.tag-blood,
.daily-page .day-block-tag.tag-diet,
.daily-page .day-block-tag.tag-exercise,
.daily-page .day-block-tag.tag-tracking {
background: var(--primary-light);
color: var(--primary-dark);
border: 1rpx solid var(--border-soft);
}
.daily-page .day-block-tag.tag-diet,
.daily-page .day-block-tag.tag-exercise {
background: var(--surface-muted);
color: var(--text-secondary);
border-color: var(--border);
}
.daily-page .pg-points-badge {
background: var(--primary-light);
border-color: var(--border-soft);
}
.daily-page .pg-points-num {
color: var(--primary-dark);
}
.daily-page .record-quick-chip.pending {
border-color: var(--border);
background: var(--surface-muted);
}
.daily-page .record-quick-chip.done {
border-color: rgba(56, 102, 65, 0.35);
background: var(--success-light);
}
.daily-page .task-action-btn.pending {
background: var(--primary);
}
.daily-page .water-btn {
background: var(--primary);
}
.daily-page .family-like-strip,
.daily-page .encourage-strip {
background: var(--surface);
border: 1rpx solid var(--border);
box-shadow: var(--shadow-premium);
}
.daily-page .card-title,
.daily-page .hero-title {
color: inherit;
}
.daily-page .report-share-btn,
.daily-page .report-share-btn::after {
background: var(--primary);
box-shadow: var(--shadow-glow-primary);
}
.daily-page .input-modal-btn.primary {
background: var(--primary);
}
.daily-page .input-field-dot-fasting {
background: var(--primary);
}
.daily-page .input-field-dot-postprandial {
background: var(--primary-container);
}
.daily-page .input-field-dot-other {
background: var(--text-muted);
}
.daily-page .voice-switch.on .voice-switch-track {
background: var(--primary);
}
.daily-page .heatmap-foot-cta {
background: var(--primary);
}
/* ========== 适老极简首页(index ========== */
.daily-page .elder-header {
padding: 48rpx 32rpx 24rpx;
}
.daily-page .elder-greet {
display: block;
font-size: 44rpx;
font-weight: 700;
color: var(--text-primary);
line-height: 1.35;
}
.daily-page .elder-status {
display: block;
margin-top: 16rpx;
font-size: 32rpx;
font-weight: 600;
line-height: 1.4;
&.is-done {
color: var(--success);
}
&.is-pending {
color: var(--text-secondary);
}
}
.daily-page .elder-card-scroll {
margin-top: 24rpx;
width: 100%;
white-space: nowrap;
}
.daily-page .elder-card-row {
display: inline-flex;
flex-wrap: nowrap;
gap: 16rpx;
padding: 2rpx 0;
}
.daily-page .elder-card-chip {
flex-shrink: 0;
padding: 12rpx 28rpx;
border-radius: 999rpx;
background: var(--surface);
border: 2rpx solid var(--border);
text {
font-size: 28rpx;
color: var(--text-secondary);
}
&.active {
background: var(--primary);
border-color: var(--primary);
text {
color: #fff;
font-weight: 600;
}
}
}
.daily-page .elder-actions {
padding: 0 32rpx 24rpx;
display: flex;
flex-direction: row;
align-items: stretch;
gap: 16rpx;
}
.daily-page .elder-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
min-height: 112rpx;
min-width: 0;
border-radius: 24rpx;
box-shadow: var(--shadow-sm);
box-sizing: border-box;
}
.daily-page .elder-btn-text {
font-size: 36rpx;
font-weight: 700;
}
.daily-page .elder-btn-primary {
flex: 8;
background: var(--primary);
.elder-btn-text {
color: #fff;
}
}
.daily-page .elder-btn-voice {
flex: 2;
flex-direction: column;
gap: 8rpx;
padding: 12rpx 8rpx;
background: var(--surface);
border: 2rpx solid var(--border-soft);
.elder-btn-text {
color: var(--primary-dark);
font-size: 24rpx;
line-height: 1.25;
text-align: center;
}
&.speaking {
border-color: var(--primary);
background: var(--primary-light);
}
&.disabled {
opacity: 0.45;
}
}
.daily-page .elder-today-panel {
margin: 0 32rpx 24rpx;
padding: 32rpx;
background: var(--surface);
border-radius: 24rpx;
border: 1rpx solid var(--border);
box-shadow: var(--shadow-premium);
}
.daily-page .elder-panel-title {
display: block;
font-size: 34rpx;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 24rpx;
}
.daily-page .elder-today-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20rpx;
}
.daily-page .elder-metric {
padding: 24rpx;
border-radius: 20rpx;
background: var(--surface-muted);
border: 2rpx solid var(--border);
&.is-high {
border-color: rgba(220, 38, 38, 0.35);
background: var(--danger-light);
}
}
.daily-page .elder-metric-label {
display: block;
font-size: 28rpx;
color: var(--text-secondary);
margin-bottom: 12rpx;
}
.daily-page .elder-metric-value-row {
display: flex;
align-items: baseline;
gap: 8rpx;
}
.daily-page .elder-metric-value {
font-size: 56rpx;
font-weight: 800;
color: var(--text-primary);
line-height: 1.1;
}
.daily-page .elder-metric.is-high .elder-metric-value {
color: var(--danger);
}
.daily-page .elder-metric-unit {
font-size: 26rpx;
color: var(--text-muted);
}
.daily-page .elder-metric-flag {
display: block;
margin-top: 12rpx;
font-size: 26rpx;
color: var(--danger);
font-weight: 600;
}
.daily-page .elder-more-link {
margin: 0 32rpx 16rpx;
padding: 28rpx 32rpx;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--surface);
border-radius: 24rpx;
border: 1rpx solid var(--border);
}
.daily-page .elder-more-title {
display: block;
font-size: 32rpx;
font-weight: 700;
color: var(--text-primary);
}
.daily-page .elder-more-sub {
display: block;
margin-top: 8rpx;
font-size: 26rpx;
color: var(--text-muted);
line-height: 1.45;
}
.daily-page .elder-more-arrow {
font-size: 48rpx;
color: var(--primary);
font-weight: 300;
padding-left: 16rpx;
}
.daily-page .footer-tip {
text-align: center;
font-size: 26rpx;
color: var(--text-muted);
padding: 16rpx 32rpx 48rpx;
line-height: 1.6;
}
.daily-page .elder-chart-block .range-bar {
margin-bottom: 16rpx;
}
.daily-page .glucose-history-block {
padding-bottom: 8rpx;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
/**
* Google Stitch MCP · VitalMint Health
* 设计 token 与 stitch-weekly.html tailwind.config 一一对应
*/
.stitch-vitalmint {
--primary: #006c49;
--on-primary: #ffffff;
--on-primary-fixed-variant: #005236;
--primary-container: #10b981;
--on-primary-container: #00422b;
--inverse-primary: #4edea3;
--surface: #f4fbf4;
--on-surface: #161d19;
--on-surface-variant: #3c4a42;
--surface-container-lowest: #ffffff;
--surface-container-low: #eef6ee;
--surface-container: #e8f0e9;
--surface-container-high: #e3eae3;
--surface-container-highest: #dde4dd;
--surface-variant: #dde4dd;
--outline: #6c7a71;
--outline-variant: #bbcabf;
--error: #ba1a1a;
--on-error-container: #93000a;
--error-container: #ffdad6;
--tertiary: #a43a3a;
--tertiary-container: #fc7c78;
--on-tertiary: #ffffff;
--on-tertiary-container: #711419;
--st-container-margin: 40rpx;
--st-section-gap: 64rpx;
--st-card-padding: 40rpx;
--st-stack-gap: 32rpx;
--st-inline-gap: 24rpx;
--st-radius-2xl: 48rpx;
--st-radius-xl: 24rpx;
--st-radius-lg: 16rpx;
--st-shadow-ambient: 0 20rpx 60rpx -20rpx rgba(0, 108, 73, 0.08);
}
.weekly-page.stitch-vitalmint {
min-height: 100vh;
background: var(--surface);
color: var(--on-surface);
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
}
@@ -0,0 +1,309 @@
/* weekly.vue 录入弹窗 — 沿用 MCP 主色 */
.weekly-page .input-modal-mask {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(12px);
z-index: 2000;
display: flex;
align-items: flex-end;
justify-content: center;
animation: input-modal-mask-in 0.38s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.weekly-page .input-modal {
width: 100%;
max-width: 750rpx;
background: var(--surface-container-lowest);
border-radius: 36rpx 36rpx 0 0;
padding: 16rpx 0 calc(20rpx + env(safe-area-inset-bottom));
box-shadow: 0 -12rpx 36rpx rgba(15, 23, 42, 0.12);
max-height: 88vh;
display: flex;
flex-direction: column;
animation: input-modal-rise 0.46s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes input-modal-mask-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes input-modal-rise {
from { transform: translate3d(0, 100%, 0); opacity: 0.96; }
to { transform: translate3d(0, 0, 0); opacity: 1; }
}
@keyframes input-modal-content-in {
from { opacity: 0; transform: translate3d(0, 24rpx, 0); }
to { opacity: 1; transform: translate3d(0, 0, 0); }
}
.weekly-page .input-modal-grip {
width: 80rpx;
height: 6rpx;
border-radius: 999rpx;
background: var(--surface-container-highest);
margin: 0 auto 8rpx;
}
.weekly-page .input-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12rpx 32rpx 16rpx;
border-bottom: 1rpx solid var(--surface-container-low);
}
.weekly-page .input-modal-title {
font-size: 40rpx;
font-weight: 800;
color: var(--on-surface);
}
.weekly-page .input-modal-sub {
font-size: 26rpx;
color: var(--on-surface-variant);
margin-top: 4rpx;
}
.weekly-page .input-modal-close {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999rpx;
background: var(--surface-container-low);
}
.weekly-page .input-modal-close-icon {
font-size: 42rpx;
color: var(--outline);
line-height: 1;
font-weight: 700;
}
.weekly-page .phone-gate-body {
padding: 32rpx;
}
.weekly-page .phone-gate-error {
margin-bottom: 20rpx;
padding: 20rpx 22rpx;
border-radius: 16rpx;
background: #fef2f2;
border: 1rpx solid rgba(220, 38, 38, 0.2);
box-sizing: border-box;
text {
font-size: 26rpx;
font-weight: 600;
color: #991b1b;
line-height: 1.45;
}
}
.weekly-page .phone-gate-tip {
display: block;
font-size: 28rpx;
color: var(--on-surface-variant);
line-height: 1.6;
margin-bottom: 24rpx;
}
.weekly-page .phone-gate-gender-label {
display: block;
font-size: 28rpx;
font-weight: 700;
color: var(--primary);
margin-bottom: 16rpx;
}
.weekly-page .phone-gate-gender-row {
display: flex;
gap: 20rpx;
margin-bottom: 32rpx;
}
.weekly-page .phone-gate-gender-opt {
flex: 1;
min-height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--st-radius-xl);
background: var(--surface-container-lowest);
border: 2rpx solid var(--outline-variant);
color: var(--on-surface-variant);
font-size: 32rpx;
font-weight: 600;
}
.weekly-page .phone-gate-gender-opt.active {
border-color: var(--primary);
color: var(--primary);
}
.weekly-page .phone-gate-btn {
width: 100%;
min-height: 96rpx;
line-height: 96rpx;
border-radius: var(--st-radius-xl);
background: var(--primary);
color: var(--on-primary);
font-size: 32rpx;
font-weight: 700;
border: none;
}
.weekly-page .phone-gate-btn::after {
border: none;
}
.weekly-page .input-modal-loading {
min-height: 520rpx;
padding: 120rpx 0;
text-align: center;
color: var(--outline);
font-size: 30rpx;
display: flex;
align-items: center;
justify-content: center;
}
.weekly-page .input-modal-body {
flex: 1;
min-height: 520rpx;
padding: 20rpx 32rpx 28rpx;
box-sizing: border-box;
width: 100%;
}
.weekly-page .input-modal-body-ready {
animation: input-modal-content-in 0.34s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.weekly-page .input-modal-tip {
background: var(--surface-container-low);
border: 1rpx dashed var(--outline-variant);
border-radius: 14rpx;
padding: 18rpx 20rpx;
font-size: 26rpx;
color: var(--on-surface-variant);
line-height: 1.5;
margin-bottom: 22rpx;
}
.weekly-page .input-section-title {
font-size: 28rpx;
font-weight: 700;
color: var(--on-surface);
margin: 18rpx 0 14rpx;
}
.weekly-page .input-field {
display: flex;
align-items: center;
gap: 18rpx;
padding: 14rpx 0;
border-bottom: 1rpx solid var(--surface-container-low);
min-height: 88rpx;
}
.weekly-page .input-field-label {
display: flex;
align-items: center;
gap: 10rpx;
width: 180rpx;
font-size: 30rpx;
color: var(--on-surface);
font-weight: 700;
}
.weekly-page .input-field-dot {
width: 14rpx;
height: 14rpx;
border-radius: 999rpx;
}
.weekly-page .input-field-dot-fasting {
background: var(--primary);
}
.weekly-page .input-field-dot-postprandial {
background: var(--tertiary-container);
}
.weekly-page .input-field-dot-other {
background: var(--outline);
}
.weekly-page .input-field-input {
flex: 1;
min-width: 0;
background: var(--surface);
border-radius: 16rpx;
padding: 18rpx 20rpx;
min-height: 76rpx;
border: 2rpx solid transparent;
}
.weekly-page .input-field-input input {
width: 100%;
font-size: 34rpx;
color: var(--on-surface);
text-align: right;
font-weight: 600;
}
.weekly-page .input-placeholder {
color: var(--outline);
font-size: 28rpx;
}
.weekly-page .input-field-textarea textarea {
width: 100%;
min-height: 140rpx;
background: var(--surface);
border-radius: 14rpx;
padding: 20rpx 22rpx;
font-size: 30rpx;
color: var(--on-surface);
border: 2rpx solid var(--outline-variant);
}
.weekly-page .input-modal-actions {
display: flex;
gap: 18rpx;
padding: 20rpx 32rpx 0;
border-top: 1rpx solid var(--surface-container-low);
}
.weekly-page .input-modal-btn {
flex: 1;
height: 100rpx;
border-radius: var(--st-radius-xl);
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
font-weight: 700;
}
.weekly-page .input-modal-btn.primary {
background: var(--primary);
color: var(--on-primary);
}
.weekly-page .input-modal-btn.danger {
flex: 0 0 180rpx;
background: var(--error-container);
color: var(--error);
}
.weekly-page .input-modal-btn.disabled {
opacity: 0.5;
pointer-events: none;
}
@@ -0,0 +1,981 @@
/**
* weekly.vue — 与 code.html (Stitch Modern WeChat UI) 1:1
*/
.weekly-page {
--st-shadow-ambient: 0 24rpx 60rpx -20rpx rgba(0, 108, 73, 0.08), 0 8rpx 20rpx -8rpx rgba(0, 0, 0, 0.03);
--st-shadow-cta: 0 16rpx 32rpx rgba(0, 108, 73, 0.2);
--st-shadow-float: 0 16rpx 64rpx rgba(0, 108, 73, 0.15);
--tab-dock-full-offset: calc(220rpx + env(safe-area-inset-bottom));
padding-bottom: var(--tab-dock-full-offset);
}
/* ===== Header ===== */
.weekly-page .st-header {
position: relative;
display: flex;
align-items: center;
padding-left: var(--st-container-margin);
padding-right: var(--st-container-margin);
padding-bottom: 32rpx;
/* padding-top / padding-right 由 initHeaderSafeArea 动态设置 */
background: var(--surface);
}
.weekly-page .st-header-text {
flex: 1;
min-width: 0;
overflow: hidden;
}
.weekly-page .st-header-left {
display: flex;
align-items: center;
gap: var(--st-inline-gap);
flex: 1;
min-width: 0;
}
.weekly-page .st-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: var(--primary-container);
border: 4rpx solid var(--surface-container-lowest);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.weekly-page .st-greet {
display: block;
font-size: 40rpx;
font-weight: 700;
line-height: 1.27;
color: var(--on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.weekly-page .st-status {
display: block;
margin-top: 4rpx;
font-size: 24rpx;
font-weight: 600;
color: var(--on-surface-variant);
opacity: 0.8;
}
.weekly-page .st-voice-round {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.weekly-page .st-voice-round.disabled {
opacity: 0.45;
}
.weekly-page .st-card-scroll {
margin: 0 var(--st-container-margin) 8rpx;
white-space: nowrap;
}
.weekly-page .st-card-row {
display: inline-flex;
gap: 16rpx;
}
.weekly-page .st-card-chip {
padding: 12rpx 28rpx;
border-radius: 999rpx;
font-size: 26rpx;
font-weight: 600;
color: var(--on-surface-variant);
background: var(--surface-container-lowest);
border: 1rpx solid var(--outline-variant);
}
.weekly-page .st-card-chip.active {
background: var(--primary);
border-color: var(--primary);
color: var(--on-primary);
}
/* ===== 居家训练(DEV 内嵌) ===== */
.weekly-page .st-training-section {
width: 100%;
box-sizing: border-box;
}
.weekly-page .st-training-section .dev-entry-inline--weekly {
overflow: hidden;
}
.weekly-page .st-training-section .inline-scroll {
margin-left: -4rpx;
margin-right: -4rpx;
width: calc(100% + 8rpx);
}
.weekly-page .st-training-section .inline-card:first-child {
border-color: rgba(0, 108, 73, 0.22);
background: linear-gradient(160deg, rgba(0, 108, 73, 0.08) 0%, #f4f7f5 55%);
}
/* ===== Main ===== */
.weekly-page .st-main {
padding: 0 var(--st-container-margin) 32rpx;
display: flex;
flex-direction: column;
gap: 48rpx;
margin-top: 16rpx;
}
.weekly-page .st-daily-context-error {
padding: 24rpx 28rpx;
border-radius: 24rpx;
background: #fef2f2;
border: 1rpx solid rgba(220, 38, 38, 0.22);
box-sizing: border-box;
}
.weekly-page .st-daily-context-error-text {
font-size: 28rpx;
font-weight: 600;
color: #991b1b;
line-height: 1.5;
}
.weekly-page .st-main-spacer {
height: 32rpx;
}
/* CTA — rounded-2xl h-14 */
.weekly-page .st-cta {
width: 100%;
height: 112rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
border-radius: var(--st-radius-2xl);
background: var(--primary);
color: var(--on-primary);
font-size: 32rpx;
font-weight: 700;
box-shadow: var(--st-shadow-cta);
}
.weekly-page .st-cta:active {
transform: scale(0.98);
}
/* 游戏入口 */
.weekly-page .st-game-entry {
margin-top: 24rpx;
padding: 28rpx 32rpx;
display: flex;
align-items: center;
gap: 24rpx;
border-radius: var(--st-radius-2xl);
background: var(--surface-container-lowest);
border: 1rpx solid rgba(0, 108, 73, 0.14);
box-shadow: var(--st-shadow-ambient);
box-sizing: border-box;
}
.weekly-page .st-game-entry:active {
transform: scale(0.98);
opacity: 0.92;
}
.weekly-page .st-game-entry-icon {
width: 88rpx;
height: 88rpx;
border-radius: 24rpx;
background: rgba(0, 108, 73, 0.1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.weekly-page .st-game-entry-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.weekly-page .st-game-entry-title {
font-size: 32rpx;
font-weight: 700;
color: var(--on-surface);
line-height: 1.25;
}
.weekly-page .st-game-entry-sub {
font-size: 24rpx;
font-weight: 500;
color: var(--on-surface-variant, #64748b);
line-height: 1.35;
}
/* ===== Chart card — rounded-3xl ===== */
.weekly-page .st-chart-card {
padding: 40rpx 10rpx 40rpx 10rpx;
border-radius: 48rpx;
background: var(--surface-container-lowest);
border: 1rpx solid rgba(221, 228, 221, 0.3);
box-shadow: var(--st-shadow-ambient);
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
}
.weekly-page .st-chart-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
gap: 16rpx;
}
.weekly-page .st-chart-title {
display: block;
font-size: 30rpx;
font-weight: 700;
color: var(--on-surface);
}
.weekly-page .st-chart-sub {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
font-weight: 600;
color: var(--on-surface-variant);
}
.weekly-page .st-chart-more {
display: inline-flex;
align-items: center;
gap: 4rpx;
margin-top: 6rpx;
}
.weekly-page .st-chart-more-text {
font-size: 22rpx;
font-weight: 600;
color: var(--primary);
}
.weekly-page .st-chart-more:active {
opacity: 0.75;
}
.weekly-page .st-chart-legend {
display: flex;
gap: var(--st-inline-gap);
}
.weekly-page .st-legend-item {
display: flex;
align-items: center;
gap: 8rpx;
font-size: 24rpx;
font-weight: 600;
color: var(--on-surface-variant);
}
.weekly-page .st-legend-item.inactive {
opacity: 0.4;
}
.weekly-page .st-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
}
.weekly-page .st-dot-fasting {
background: var(--primary);
}
.weekly-page .st-dot-post {
background: var(--tertiary);
}
.weekly-page .st-chart-summary {
display: flex;
padding: 32rpx;
margin-bottom: 48rpx;
border-radius: var(--st-radius-xl);
background: var(--surface-container-low);
}
.weekly-page .st-chart-summary-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
border-right: 1rpx solid var(--surface-container-highest);
}
.weekly-page .st-chart-summary-item:last-child {
border-right: none;
}
.weekly-page .st-chart-summary-num {
font-size: 40rpx;
font-weight: 700;
color: var(--on-surface);
}
.weekly-page .st-chart-summary-num.is-high {
color: var(--tertiary);
}
.weekly-page .st-chart-summary-num.is-good {
color: var(--primary);
}
.weekly-page .st-chart-summary-label {
margin-top: 8rpx;
font-size: 24rpx;
font-weight: 600;
color: var(--on-surface-variant);
}
.weekly-page .st-chart-foot {
padding: 20rpx 0 20rpx;
}
.weekly-page .st-chart-mock-hint {
display: block;
margin: 0;
font-size: 30rpx;
font-weight: 600;
color: var(--outline);
line-height: 1.35;
text-align: center;
}
.weekly-page .st-chart-area {
position: relative;
height: 360rpx;
overflow: hidden;
cursor: pointer;
}
.weekly-page .st-chart-area:active {
opacity: 0.92;
}
.weekly-page .st-chart-canvas {
width: 100%;
height: 360rpx;
display: block;
}
/* 识糖入口嵌在趋势卡内:轻量分隔,避免大块留白 */
.weekly-page .st-chart-card .st-game-entry-list {
display: flex;
flex-direction: column;
gap: 12rpx;
margin: 12rpx 20rpx 0;
}
.weekly-page .st-chart-card .st-game-entry--in-card {
margin-top: 0;
padding: 16rpx 20rpx;
gap: 16rpx;
border-radius: 24rpx;
background: var(--surface-container-low);
border: 1rpx solid rgba(0, 108, 73, 0.08);
box-shadow: none;
}
.weekly-page .st-chart-card .st-game-entry--in-card .st-game-entry-icon {
width: 64rpx;
height: 64rpx;
border-radius: 16rpx;
}
.weekly-page .st-chart-card .st-game-entry--in-card .st-game-entry-title {
font-size: 28rpx;
}
.weekly-page .st-chart-card .st-game-entry--in-card .st-game-entry-sub {
font-size: 22rpx;
}
.weekly-page .st-chart-empty,
.weekly-page .st-chart-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
color: var(--outline);
}
/* ===== 最新测量 ===== */
.weekly-page .st-latest-section {
display: flex;
flex-direction: column;
gap: 32rpx;
}
.weekly-page .st-latest-hero {
position: relative;
overflow: hidden;
min-height: 320rpx;
padding: 48rpx 40rpx;
border-radius: 48rpx;
background: var(--surface-container-lowest);
box-shadow: var(--st-shadow-ambient);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.weekly-page .st-latest-hero.is-high {
background: var(--error-container);
color: var(--on-error-container);
}
.weekly-page .st-latest-hero-pattern {
position: absolute;
inset: 0;
opacity: 0.1;
background-image: radial-gradient(circle at 4rpx 4rpx, currentColor 2rpx, transparent 0);
background-size: 32rpx 32rpx;
pointer-events: none;
}
.weekly-page .st-latest-label {
position: relative;
z-index: 1;
font-size: 24rpx;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.9;
margin-bottom: 8rpx;
}
.weekly-page .st-latest-value-row {
position: relative;
z-index: 1;
display: flex;
align-items: baseline;
gap: 8rpx;
}
.weekly-page .st-latest-value {
font-size: 112rpx;
font-weight: 800;
line-height: 1;
letter-spacing: -0.02em;
}
.weekly-page .st-latest-hero.is-high .st-latest-value,
.weekly-page .st-latest-hero.is-high .st-latest-unit {
color: var(--on-error-container);
}
.weekly-page .st-latest-unit {
font-size: 32rpx;
font-weight: 500;
opacity: 0.8;
}
.weekly-page .st-latest-flag {
position: relative;
z-index: 1;
margin-top: 24rpx;
display: inline-flex;
align-items: center;
gap: 12rpx;
padding: 8rpx 24rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.3);
font-size: 24rpx;
font-weight: 700;
}
.weekly-page .st-summary-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24rpx;
}
.weekly-page .st-summary-card {
padding: 32rpx;
border-radius: var(--st-radius-2xl);
background: var(--surface-container-lowest);
border: 1rpx solid rgba(221, 228, 221, 0.5);
box-shadow: var(--st-shadow-ambient);
display: flex;
flex-direction: column;
gap: 8rpx;
}
.weekly-page .st-summary-label {
font-size: 24rpx;
font-weight: 600;
color: var(--on-surface-variant);
}
.weekly-page .st-summary-value {
font-size: 48rpx;
font-weight: 700;
color: var(--on-surface);
}
.weekly-page .st-summary-value.is-high {
color: var(--tertiary);
}
.weekly-page .st-summary-tag {
align-self: flex-start;
padding: 4rpx 16rpx;
border-radius: 8rpx;
font-size: 20rpx;
font-weight: 600;
color: var(--tertiary);
background: rgba(255, 218, 214, 0.5);
}
/* ===== AI 饮食 ===== */
.weekly-page .st-ai-section {
position: relative;
overflow: hidden;
padding: 40rpx;
border-radius: 48rpx;
background: linear-gradient(135deg, rgba(0, 108, 73, 0.1) 0%, rgba(0, 108, 73, 0.05) 100%);
border: 1rpx solid rgba(0, 108, 73, 0.1);
}
.weekly-page .st-ai-deco {
position: absolute;
width: 256rpx;
height: 256rpx;
top: -64rpx;
right: -64rpx;
border-radius: 50%;
background: rgba(78, 222, 163, 0.2);
filter: blur(40rpx);
pointer-events: none;
}
.weekly-page .st-ai-head {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.weekly-page .st-ai-head-left {
display: flex;
align-items: center;
gap: 16rpx;
}
.weekly-page .st-ai-icon {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: var(--primary);
display: flex;
align-items: center;
justify-content: center;
}
.weekly-page .st-ai-icon.thinking {
animation: st-pulse 1.2s ease-in-out infinite;
}
@keyframes st-pulse {
50% { opacity: 0.65; }
}
.weekly-page .st-ai-title {
font-size: 40rpx;
font-weight: 700;
color: var(--on-surface);
}
.weekly-page .st-ai-refresh {
display: flex;
align-items: center;
gap: 8rpx;
padding: 12rpx 24rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.5);
font-size: 24rpx;
font-weight: 600;
color: var(--primary);
}
.weekly-page .st-ai-refresh.disabled {
opacity: 0.5;
}
.weekly-page .st-ai-sub {
position: relative;
z-index: 1;
display: block;
margin-bottom: 32rpx;
font-size: 28rpx;
line-height: 1.43;
color: var(--on-surface-variant);
}
.weekly-page .st-meal-list {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 24rpx;
}
.weekly-page .st-meal-glass {
padding: 32rpx;
border-radius: var(--st-radius-2xl);
background: rgba(255, 255, 255, 0.8);
border: 1rpx solid #ffffff;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.04);
}
.weekly-page .st-meal-glass--drinks {
background: rgba(224, 242, 254, 0.65);
border-color: rgba(14, 165, 233, 0.15);
}
.weekly-page .st-meal-head {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
}
.weekly-page .st-meal-label {
font-size: 28rpx;
font-weight: 700;
color: var(--on-surface);
}
.weekly-page .st-meal-text {
font-size: 28rpx;
line-height: 1.43;
color: var(--on-surface-variant);
}
.weekly-page .st-skeleton-line {
height: 28rpx;
margin-bottom: 16rpx;
border-radius: 8rpx;
background: rgba(255, 255, 255, 0.5);
}
.weekly-page .st-skeleton-hint {
font-size: 26rpx;
color: var(--outline);
text-align: center;
}
/* ===== 底部浮动输入 ===== */
.weekly-page .st-float-ask {
position: fixed;
left: var(--st-container-margin);
right: var(--st-container-margin);
bottom: var(--tab-dock-full-offset);
z-index: 210;
}
.weekly-page .st-float-close {
position: absolute;
top: -18rpx;
right: -6rpx;
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.weekly-page .st-float-close-icon {
color: #ffffff;
font-size: 30rpx;
line-height: 1;
}
.weekly-page .st-float-reopen {
position: fixed;
right: var(--st-container-margin);
bottom: var(--tab-dock-full-offset);
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background: linear-gradient(180deg, #2d7340 0%, #006c49 100%);
box-shadow: var(--st-shadow-float);
display: flex;
align-items: center;
justify-content: center;
z-index: 210;
}
.weekly-page .st-float-inner {
display: flex;
align-items: center;
gap: 16rpx;
padding: 16rpx 16rpx 16rpx 28rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(20rpx);
border: 1rpx solid rgba(0, 108, 73, 0.1);
box-shadow: var(--st-shadow-float);
transition: border-radius 0.22s ease, padding 0.22s ease, border-color 0.22s ease;
}
/* 聚焦/有内容时展开:底部对齐让输入框向上增高,圆角收敛为圆角矩形 */
.weekly-page .st-float-inner.is-expanded {
align-items: flex-end;
border-radius: 32rpx;
padding: 18rpx 16rpx 18rpx 32rpx;
border-color: rgba(0, 108, 73, 0.28);
}
.weekly-page .st-float-icon {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: rgba(0, 108, 73, 0.1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.weekly-page .st-float-input {
flex: 1;
min-width: 0;
width: 100%;
min-height: 64rpx;
max-height: 240rpx;
font-size: 28rpx;
line-height: 40rpx;
color: var(--on-surface);
background: transparent;
padding: 12rpx 0;
box-sizing: border-box;
transition: min-height 0.2s ease;
}
/* 聚焦时输入框变大;内容超长时由 auto-height 继续向上增高,超过上限可滚动 */
.weekly-page .st-float-inner.is-expanded .st-float-input {
min-height: 104rpx;
}
.weekly-page .st-float-placeholder {
color: rgba(60, 74, 66, 0.6);
font-size: 26rpx;
line-height: 40rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.weekly-page .st-float-mic {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: rgba(0, 108, 73, 0.1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.2s ease, transform 0.15s ease;
}
.weekly-page .st-float-mic.listening {
background: var(--primary);
animation: st-float-mic-pulse 1.2s ease-in-out infinite;
}
.weekly-page .st-float-mic.disabled {
opacity: 0.45;
pointer-events: none;
}
.weekly-page .st-float-mic:active {
transform: scale(0.94);
}
.weekly-page .st-float-hold-hint {
margin-top: 12rpx;
text-align: center;
font-size: 24rpx;
color: var(--primary);
line-height: 1.4;
}
.weekly-page .st-voice-overlay {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.32);
pointer-events: none;
}
.weekly-page .st-voice-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
padding: 56rpx 64rpx;
border-radius: 32rpx;
background: rgba(20, 24, 22, 0.86);
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.35);
}
.weekly-page .st-voice-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, #2d7340 0%, #006c49 100%);
}
.weekly-page .st-voice-icon.active {
animation: st-voice-pulse 1.1s ease-in-out infinite;
}
@keyframes st-voice-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(0, 108, 73, 0.45); }
50% { box-shadow: 0 0 0 20rpx rgba(0, 108, 73, 0); }
}
.weekly-page .st-voice-title {
font-size: 32rpx;
font-weight: 700;
color: #ffffff;
line-height: 1.3;
}
.weekly-page .st-voice-sub {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.7);
line-height: 1.3;
}
@keyframes st-float-mic-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(0, 108, 73, 0.35); }
50% { box-shadow: 0 0 0 12rpx rgba(0, 108, 73, 0); }
}
.weekly-page .st-float-send {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: var(--primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4rpx 12rpx rgba(0, 108, 73, 0.2);
line-height: 0;
overflow: hidden;
}
.weekly-page .st-float-send.disabled {
opacity: 0.55;
}
.weekly-page .st-float-send:active {
transform: scale(0.95);
}
.weekly-page .st-float-result {
margin-top: 16rpx;
padding: 24rpx 32rpx;
border-radius: var(--st-radius-xl);
background: rgba(255, 255, 255, 0.95);
border: 1rpx solid var(--outline-variant);
box-shadow: var(--st-shadow-ambient);
}
.weekly-page .st-float-badge {
display: inline-block;
margin-bottom: 8rpx;
padding: 4rpx 12rpx;
border-radius: 8rpx;
font-size: 22rpx;
font-weight: 700;
color: var(--primary);
background: rgba(0, 108, 73, 0.1);
}
.weekly-page .st-float-advice {
font-size: 28rpx;
line-height: 1.5;
color: var(--on-surface-variant);
}
.weekly-page .ai-stream-cursor {
animation: st-blink 0.8s step-end infinite;
}
@keyframes st-blink {
50% { opacity: 0; }
}
.weekly-page .chart-tip {
position: absolute;
z-index: 10;
padding: 12rpx 16rpx;
border-radius: 12rpx;
background: rgba(22, 29, 25, 0.88);
color: #fff;
font-size: 22rpx;
pointer-events: none;
}
.weekly-page .chart-tip-date {
font-weight: 700;
margin-bottom: 8rpx;
}
.weekly-page .chart-tip-row {
display: flex;
align-items: center;
gap: 8rpx;
margin-top: 4rpx;
}
@@ -0,0 +1,168 @@
/** 拼接 API 根路径与相对路径,避免双斜杠 */
export function joinApiUrl(base, path) {
const b = String(base || '').replace(/\/+$/, '')
const p = String(path || '').replace(/^\/+/, '')
return p ? `${b}/${p}` : b
}
function appendQuery(url, data) {
if (!data || typeof data !== 'object') return url
const keys = Object.keys(data)
if (!keys.length) return url
const qs = keys
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(data[k] ?? '')}`)
.join('&')
return url + (url.includes('?') ? '&' : '?') + qs
}
function decodeChunkData(data) {
if (typeof data === 'string') return data
if (!data) return ''
if (typeof TextDecoder !== 'undefined' && data instanceof ArrayBuffer) {
return new TextDecoder('utf-8').decode(new Uint8Array(data))
}
if (data instanceof ArrayBuffer) {
const bytes = new Uint8Array(data)
let str = ''
for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i])
try {
return decodeURIComponent(escape(str))
} catch (e) {
return str
}
}
return ''
}
/** 从 SSE 文本缓冲中解析并消费完整事件 */
export function consumeSseBuffer(buffer, onEvent) {
let rest = buffer
let idx = rest.indexOf('\n\n')
while (idx !== -1) {
const block = rest.slice(0, idx)
rest = rest.slice(idx + 2)
let event = 'message'
let dataLine = ''
block.split('\n').forEach((line) => {
if (line.startsWith('event:')) event = line.slice(6).trim()
else if (line.startsWith('data:')) dataLine += line.slice(5).trim()
})
if (dataLine) {
try {
onEvent(event, JSON.parse(dataLine))
} catch (e) {
onEvent(event, dataLine)
}
}
idx = rest.indexOf('\n\n')
}
return rest
}
/** 从流式纯文本中提取三餐(支持未写完的最后一行) */
export function parsePartialDietPlainText(text) {
const out = {}
if (!text) return out
const lineRe = /^(早餐|喝的|午餐|晚餐|提示|少碰)[:]\s*(.*)$/gm
let match
while ((match = lineRe.exec(text)) !== null) {
const key = { '早餐': 'breakfast', '喝的': 'drinks', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'avoid' }[match[1]]
const val = (match[2] || '').trim()
if (key === 'avoid') {
out.avoid = val.split(/[、,;\s]+/).map((s) => s.trim()).filter(Boolean)
} else {
out[key] = val
}
}
const tail = text.match(/(?:^|\n)(早餐|喝的|午餐|晚餐|提示|少碰)[:]\s*([^\n]*)$/)
if (tail) {
const key = { '早餐': 'breakfast', '喝的': 'drinks', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'avoid' }[tail[1]]
const val = (tail[2] || '').trim()
if (key === 'avoid') {
if (val) out.avoid = val.split(/[、,;\s]+/).map((s) => s.trim()).filter(Boolean)
} else {
out[key] = val
}
}
return out
}
/** @deprecated 使用 parsePartialDietPlainText */
export function parsePartialDietJson(text) {
return parsePartialDietPlainText(text)
}
/**
* SSE 流式请求(微信小程序 enableChunked;其它端走 fallback
*
* @param {object} opts
* @param {string} opts.baseUrl
* @param {string} opts.url
* @param {string} [opts.method]
* @param {object} [opts.data]
* @param {function(string, object):void} opts.onEvent
* @param {function():Promise<object>} [opts.fallback] 不支持流式时的降级请求
*/
export function requestAiStream(opts) {
const {
baseUrl,
url,
method = 'GET',
data = {},
onEvent,
fallback
} = opts
// #ifdef MP-WEIXIN
return new Promise((resolve, reject) => {
const token = uni.getStorageSync('token') || ''
let sseBuffer = ''
let requestUrl = joinApiUrl(baseUrl, url)
if (method === 'GET') {
requestUrl = appendQuery(requestUrl, data)
}
const task = wx.request({
url: requestUrl,
method,
data: method === 'POST' ? data : {},
enableChunked: true,
timeout: 60000,
header: {
token,
'content-type': 'application/x-www-form-urlencoded',
'Cache-Control': 'no-cache'
},
success: (res) => {
if (res && res.data) {
sseBuffer += decodeChunkData(res.data)
sseBuffer = consumeSseBuffer(sseBuffer, onEvent)
}
resolve(res)
},
fail: (err) => reject(err)
})
if (task && typeof task.onChunkReceived === 'function') {
task.onChunkReceived((res) => {
sseBuffer += decodeChunkData(res.data)
sseBuffer = consumeSseBuffer(sseBuffer, onEvent)
})
} else if (typeof fallback === 'function') {
fallback().then(resolve).catch(reject)
} else {
reject(new Error('当前环境不支持流式请求'))
}
})
// #endif
// #ifndef MP-WEIXIN
if (typeof fallback === 'function') {
return fallback()
}
return Promise.reject(new Error('当前环境不支持流式请求'))
// #endif
}
@@ -0,0 +1,49 @@
/**
* 将 SVG 字符串转为小程序可用的 data URL(base64 在真机上更稳定)
*/
function utf8ToBytes(str) {
const encoded = encodeURIComponent(str)
const bytes = []
for (let i = 0; i < encoded.length; i++) {
if (encoded.charCodeAt(i) === 37) {
bytes.push(parseInt(encoded.substring(i + 1, i + 3), 16))
i += 2
} else {
bytes.push(encoded.charCodeAt(i))
}
}
return new Uint8Array(bytes)
}
function bytesToBase64(bytes) {
if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') {
return uni.arrayBufferToBase64(bytes.buffer)
}
if (typeof btoa !== 'undefined') {
let binary = ''
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
let result = ''
for (let i = 0; i < bytes.length; i += 3) {
const a = bytes[i]
const b = i + 1 < bytes.length ? bytes[i + 1] : 0
const c = i + 2 < bytes.length ? bytes[i + 2] : 0
result += chars[a >> 2]
result += chars[((a & 3) << 4) | (b >> 4)]
result += i + 1 < bytes.length ? chars[((b & 15) << 2) | (c >> 6)] : '='
result += i + 2 < bytes.length ? chars[c & 63] : '='
}
return result
}
export function svgToDataUrl(svg) {
const normalized = String(svg || '').trim()
if (!normalized) return ''
const base64 = bytesToBase64(utf8ToBytes(normalized))
return `data:image/svg+xml;base64,${base64}`
}
@@ -0,0 +1,56 @@
/** 将接口 msg / 异常对象转为可展示的字符串,避免 [object Object] */
export function formatUserMessage(msg, fallback = '') {
if (msg == null || msg === '') return fallback
if (typeof msg === 'string') return msg
if (typeof msg === 'number' || typeof msg === 'boolean') return String(msg)
if (Array.isArray(msg)) {
const parts = msg.map((item) => {
if (item == null) return ''
if (typeof item === 'string') return item
if (typeof item === 'number' || typeof item === 'boolean') return String(item)
if (typeof item === 'object') {
return item.msg || item.message || item.error || item.title || item.label || ''
}
return String(item)
}).filter(Boolean)
return parts.length ? parts.join('') : fallback
}
if (typeof msg === 'object') {
return msg.msg || msg.message || msg.error || msg.title || msg.label || fallback
}
return String(msg)
}
export function showUserToast(title, options = {}) {
const text = formatUserMessage(title, '')
if (!text) return
uni.showToast({
title: text,
icon: options.icon || 'none',
duration: options.duration
})
}
export function formatDate(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
export function parseDate(str) {
if (!str) return new Date()
const [y, m, d] = str.split('-').map(Number)
return new Date(y, (m || 1) - 1, d || 1)
}
export function toNumber(v) {
if (v === null || v === undefined || v === '') return null
const n = Number(v)
return Number.isFinite(n) && n !== 0 ? n : null
}
export function getWeekday(dateStr) {
const w = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return w[parseDate(dateStr).getDay()]
}
@@ -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)]
}
@@ -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))
}
+307
View File
@@ -0,0 +1,307 @@
/**
* 健康数据语音文本解析。
*
* 把语音识别得到的中文口语文本解析为结构化字段:
* - 血糖(空腹 / 餐后 / 其他)
* - 血压(高压 / 低压 / 西药 / 胰岛素)
* - 饮食(早 / 午 / 晚餐 + 备注)
* - 运动(类型 / 时长 / 强度)
*
* 兼容中文数字(六点五 / 一百二十 / 一百二)与阿拉伯数字(6.5 / 120)。
* 纯前端解析,无网络依赖,识别失败时返回空对象。
*/
const CN_DIGIT = {
: 0, : 0, : 1, : 1, : 1, : 2, : 2, : 2,
: 3, : 3, : 4, : 4, : 5, : 5, : 6, : 6,
: 7, : 7, : 8, : 8, : 9, : 9
}
const CN_UNIT = { : 10, : 10, : 100, : 100, : 1000, : 1000, : 10000, 亿: 100000000 }
/** 中文整数串 → 数字,例如 "一百二十" → 120、"八十" → 80、"十" → 10、"一百二" → 120 */
function cnIntToNumber(s) {
let total = 0
let section = 0
let number = 0
let hadUnit = false
let lastUnit = 0
let sawZeroAfterUnit = false
for (const ch of s) {
if (CN_DIGIT[ch] !== undefined) {
number = CN_DIGIT[ch]
if (number === 0) sawZeroAfterUnit = true
} else if (CN_UNIT[ch] !== undefined) {
hadUnit = true
const unit = CN_UNIT[ch]
lastUnit = unit
sawZeroAfterUnit = false
if (unit >= 10000) {
section = (section + number) * unit
total += section
section = 0
} else {
if (number === 0) number = 1 // 十 = 10
section += number * unit
}
number = 0
}
}
// 没有任何单位且为纯数字串(如 "一二零")时按位拼接更符合口语
if (!hadUnit && s.length > 1) {
let joined = ''
for (const ch of s) {
if (CN_DIGIT[ch] !== undefined) joined += CN_DIGIT[ch]
}
if (joined) return Number(joined)
}
// 口语省略尾部单位:"一百二" → 120、"一千五" → 1500(避开 "一百零五" 这类带零的)
if (number > 0 && lastUnit >= 100 && !sawZeroAfterUnit) {
number = number * (lastUnit / 10)
}
return total + section + number
}
/** 中文数字串(含小数点)→ 数字,例如 "六点五" → 6.5 */
function cnSeqToNumber(seq) {
if (seq.includes('点')) {
const parts = seq.split('点')
const intPart = parts[0]
const decPart = parts.slice(1).join('')
const intVal = intPart ? cnIntToNumber(intPart) : 0
let decStr = ''
for (const ch of decPart) {
if (CN_DIGIT[ch] !== undefined) decStr += CN_DIGIT[ch]
}
if (!decStr) return intVal
return Number(`${intVal}.${decStr}`)
}
return cnIntToNumber(seq)
}
/** 是否包含中文数字字符(用于过滤“点”“重点”等误匹配) */
function hasCnDigit(seq) {
for (const ch of seq) {
if (CN_DIGIT[ch] !== undefined || CN_UNIT[ch] !== undefined) return true
}
return false
}
/** 把文本中的中文数字段落替换为阿拉伯数字 */
export function normalizeNumbers(text) {
const raw = String(text || '')
return raw.replace(/[零〇一壹幺二贰两三叁四肆五伍六陆七柒八捌九玖十拾百佰千仟万亿点]+/g, (m) => {
if (!hasCnDigit(m)) return m
const n = cnSeqToNumber(m)
return Number.isFinite(n) ? String(n) : m
})
}
/** 在文本中找到关键词后紧随的第一个数字 */
function numAfter(text, keys) {
for (const k of keys) {
const i = text.indexOf(k)
if (i >= 0) {
const rest = text.slice(i + k.length)
const m = rest.match(/-?\d+(?:\.\d+)?/)
if (m) return m[0]
}
}
return ''
}
/** 关键词后紧随的一段文本(截止到标点或下一个分隔关键词) */
function textAfter(text, keys, stopKeys = []) {
for (const k of keys) {
const i = text.indexOf(k)
if (i >= 0) {
let rest = text.slice(i + k.length)
// 去掉口语连接词
rest = rest.replace(/^[是为吃了喝了吃的喝的有打了用了::,,、。\s]+/, '')
let end = rest.length
const mStop = rest.match(/[,。.;!?\n]/)
if (mStop && mStop.index < end) end = mStop.index
for (const sk of stopKeys) {
const si = rest.indexOf(sk)
if (si >= 0 && si < end) end = si
}
const seg = rest.slice(0, end).trim()
if (seg) return seg
}
}
return ''
}
/**
* 解析血糖:返回 { fasting_blood_sugar, postprandial_blood_sugar, other_blood_sugar }
* 例:"空腹六点五餐后八点二" → { fasting:6.5, postprandial:8.2 }
*/
export function parseGlucose(raw) {
const t = normalizeNumbers(raw)
const result = {}
const fasting = numAfter(t, ['空腹'])
const post = numAfter(t, ['餐后', '饭后'])
const other = numAfter(t, ['其他', '随机', '睡前', '凌晨', '夜间', '晚上'])
if (fasting) result.fasting_blood_sugar = fasting
if (post) result.postprandial_blood_sugar = post
if (other) result.other_blood_sugar = other
// 没有关键词但只有一个数字 → 视为“其他血糖”
if (!fasting && !post && !other) {
const nums = t.match(/\d+(?:\.\d+)?/g)
if (nums && nums.length === 1) result.other_blood_sugar = nums[0]
}
return result
}
/**
* 解析血压:返回 { systolic_pressure, diastolic_pressure, western_medicine, insulin }
* 例:"高压一百二低压八十" → { systolic:120, diastolic:80 }
*/
export function parseBloodPressure(raw) {
const t = normalizeNumbers(raw)
const result = {}
let sys = numAfter(t, ['高压', '收缩压', '收缩'])
let dia = numAfter(t, ['低压', '舒张压', '舒张'])
if (!sys || !dia) {
const nums = (t.match(/\d{2,3}/g) || [])
.map(Number)
.filter((n) => n >= 30 && n <= 300)
if (!sys && !dia && nums.length >= 2) {
sys = String(nums[0])
dia = String(nums[1])
} else if (!sys && nums.length === 1 && nums[0] >= 90) {
sys = String(nums[0])
}
}
// 高压应不低于低压,顺序异常时交换
if (sys && dia && Number(sys) < Number(dia)) {
const tmp = sys
sys = dia
dia = tmp
}
if (sys) result.systolic_pressure = sys
if (dia) result.diastolic_pressure = dia
const insulin = textAfter(raw, ['胰岛素'], ['高压', '低压', '血压', '西药', '备注'])
if (insulin) result.insulin = insulin
const western = textAfter(raw, ['西药', '降糖药', '口服药'], ['胰岛素', '高压', '低压', '血压', '备注'])
if (western) result.western_medicine = western
return result
}
const DIET_MARKERS = [
{ keys: ['早餐', '早饭', '早上', '早点', '早晨'], field: 'breakfast_foods' },
{ keys: ['午餐', '午饭', '中午'], field: 'lunch_foods' },
{ keys: ['晚餐', '晚饭', '晚上', '夜宵', '夜里'], field: 'dinner_foods' }
]
function cleanFoodSeg(seg) {
return String(seg || '')
.replace(/^[是为吃了吃的喝了喝的有::,,、。\s]+/, '')
.replace(/[。.\s]+$/, '')
.trim()
}
/**
* 解析饮食:返回 { breakfast_foods, lunch_foods, dinner_foods, note }
* 例:"早餐鸡蛋粥中午米饭炒青菜晚上面条" → 分别归位
* 无餐别关键词时整句写入 note
*/
export function parseDiet(raw) {
const t = String(raw || '').trim()
const result = {}
const hits = []
DIET_MARKERS.forEach((m) => {
for (const k of m.keys) {
const idx = t.indexOf(k)
if (idx >= 0) {
hits.push({ idx, field: m.field, klen: k.length })
break
}
}
})
if (!hits.length) {
if (t) result.note = t
return result
}
hits.sort((a, b) => a.idx - b.idx)
hits.forEach((h, i) => {
const start = h.idx + h.klen
const end = i + 1 < hits.length ? hits[i + 1].idx : t.length
const seg = cleanFoodSeg(t.slice(start, end))
if (seg && !result[h.field]) result[h.field] = seg
})
// 第一个餐别关键词之前的内容作为备注
const head = cleanFoodSeg(t.slice(0, hits[0].idx))
if (head) result.note = head
return result
}
const EXERCISE_FILLERS = [
'今天', '我', '做了', '做', '进行了', '进行', '运动了', '运动', '锻炼了', '锻炼',
'了', '大概', '左右', '持续', '差不多', '一共', '总共', '的', '走了', '打了', '跑了'
]
/**
* 解析运动:返回 { exercise_type, duration, intensity }
* 例:"散步三十分钟中强度" → { type:'散步', duration:30, intensity:2 }
*/
export function parseExercise(raw) {
const t = normalizeNumbers(raw)
const result = {}
const durMatch = t.match(/(\d+(?:\.\d+)?)\s*(个小时|小时|钟头|时|分钟|分)/)
if (durMatch) {
const val = parseFloat(durMatch[1])
const isHour = /个小时|小时|钟头|时/.test(durMatch[2])
result.duration = String(Math.round(isHour ? val * 60 : val))
}
if (/高强度|剧烈|很累|大汗|气喘/.test(t)) result.intensity = 3
else if (/中强度|中等强度|中等|有点累|微微出汗|微汗/.test(t)) result.intensity = 2
else if (/低强度|轻松|轻微|溜达|缓慢/.test(t)) result.intensity = 1
// 运动类型:移除时长、强度、填充词后剩余文本
let type = t
if (durMatch) type = type.replace(durMatch[0], '')
type = type
.replace(/高强度|中强度|中等强度|低强度|剧烈|很累|大汗|气喘|有点累|微微出汗|微汗|轻松|轻微|缓慢/g, '')
.replace(/\d+(?:\.\d+)?/g, '')
.replace(/[,。.;、\s]+/g, '')
EXERCISE_FILLERS.forEach((f) => {
type = type.split(f).join('')
})
type = type.trim()
if (type && type.length <= 20) result.exercise_type = type
return result
}
const FIELD_LABELS = {
fasting_blood_sugar: '空腹',
postprandial_blood_sugar: '餐后',
other_blood_sugar: '其他血糖',
systolic_pressure: '高压',
diastolic_pressure: '低压',
western_medicine: '西药',
insulin: '胰岛素',
breakfast_foods: '早餐',
lunch_foods: '午餐',
dinner_foods: '晚餐',
note: '备注',
exercise_type: '运动',
duration: '时长',
intensity: '强度'
}
const INTENSITY_LABELS = { 1: '低强度', 2: '中强度', 3: '高强度' }
/** 把解析结果转成可读的反馈文案,例如 "空腹6.5 · 餐后8.2" */
export function summarizeParsed(parsed) {
const parts = []
Object.keys(parsed).forEach((k) => {
const label = FIELD_LABELS[k] || k
let val = parsed[k]
if (k === 'intensity') val = INTENSITY_LABELS[val] || val
parts.push(`${label}${val}`)
})
return parts.join(' · ')
}