This commit is contained in:
Your Name
2026-05-27 15:50:54 +08:00
parent 8b0fcd7050
commit 19af50c344
16 changed files with 5372 additions and 684 deletions
+9 -1
View File
@@ -137,7 +137,15 @@
"navigationBarBackgroundColor": "#0ea5a4",
"navigationBarTextStyle": "white",
"backgroundColor": "#f1f5f9",
"enablePullDownRefresh": true
"enablePullDownRefresh": true,
"mp-weixin": {
"usingPlugins": {
"WechatSI": {
"version": "0.3.5",
"provider": "wx069ba97219f66d99"
}
}
}
}
}
]
@@ -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="#0891B2" />
<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 = ['#0891B2', '#22D3EE', '#059669', '#FBBF24', '#F472B6', '#A78BFA']
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: #164e63;
}
.celebrate-card-sub {
position: relative;
z-index: 1;
margin-top: 10rpx;
font-size: 26rpx;
color: #475569;
line-height: 1.45;
}
</style>
@@ -0,0 +1,202 @@
<template>
<view class="sugar-tree-graphic" :class="[`lv-${clampedLevel}`, `size-${size}`, { watering: watering }]">
<view class="stg-glow" />
<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 >= 4" class="stg-sparkle stg-sparkle-a" />
<view v-if="clampedLevel >= 4" class="stg-sparkle stg-sparkle-b" />
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { svgToDataUrl } from '../utils/svgDataUrl.js'
// #ifdef MP-WEIXIN
const treeUseFallback = ref(true)
// #endif
// #ifndef MP-WEIXIN
const treeUseFallback = ref(false)
// #endif
const TREE_EMOJI = ['🌱', '🌿', '🌳', '🌸', '🌺']
const props = defineProps({
level: { type: Number, default: 0 },
size: { type: String, default: 'md' },
watering: { type: Boolean, default: false }
})
const clampedLevel = computed(() => Math.min(4, Math.max(0, Number(props.level) || 0)))
const treeEmoji = computed(() => TREE_EMOJI[clampedLevel.value] || TREE_EMOJI[0])
/** 医疗青绿 + 柔和陶土盆,5 档成长态 */
function buildTreeSvg(level) {
const pot = `
<ellipse cx="24" cy="50" rx="15" ry="3" fill="#0ea5a4" 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="#0d9488" 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" fill="#6ee7b7" opacity="0.55"/>
<path d="M24 41.5v4" stroke="#14b8a6" stroke-width="1.2" stroke-linecap="round" opacity="0.7"/>
</svg>`
}
const trunk = `<rect x="22.2" y="30" width="3.6" height="14" rx="1.8" fill="#78716C"/>
<rect x="22.6" y="30" width="2.8" height="14" 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="#ecfdf5" opacity="0.55"/>`
const blooms =
level >= 4
? `
${leaf(17, 19, 3.2, '#fda4af', 0.95)}
${leaf(30, 17, 3, '#f9a8d4', 0.9)}
${leaf(24, 14, 3.4, '#fb7185', 0.95)}
<circle cx="24" cy="14" r="1.2" fill="#fef3c7"/>
<circle cx="17" cy="19" r="0.9" fill="#fef3c7"/>
<circle cx="30" cy="17" r="0.8" fill="#fef3c7"/>
`
: ''
let canopy = ''
if (level === 1) {
canopy = leaf(24, 26, 7, '#34d399') + leaf(24, 25, 4.5, '#10b981', 0.85)
} else if (level === 2) {
canopy =
leaf(24, 24, 8, '#34d399') +
leaf(17, 27, 5.5, '#6ee7b7', 0.9) +
leaf(31, 27, 5.5, '#6ee7b7', 0.9)
} else if (level === 3) {
canopy =
leaf(24, 21, 10, '#22c55e') +
leaf(15, 25, 7, '#4ade80', 0.92) +
leaf(33, 25, 7, '#4ade80', 0.92) +
leaf(24, 15, 6, '#10b981', 0.88)
} else {
canopy =
leaf(24, 20, 11, '#059669') +
leaf(14, 24, 8, '#34d399', 0.95) +
leaf(34, 24, 8, '#34d399', 0.95) +
leaf(24, 13, 7.5, '#10b981', 0.9) +
blooms
}
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: 96rpx;
height: 108rpx;
}
.stg-glow {
position: absolute;
inset: 8%;
border-radius: 50%;
background: radial-gradient(circle, rgba(14, 165, 164, 0.22) 0%, transparent 68%);
pointer-events: none;
}
.lv-0 .stg-glow {
background: radial-gradient(circle, rgba(148, 163, 184, 0.2) 0%, transparent 70%);
}
.lv-4 .stg-glow {
background: radial-gradient(circle, rgba(251, 191, 36, 0.28) 0%, rgba(14, 165, 164, 0.12) 55%, transparent 72%);
}
.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: 72rpx;
}
@keyframes stg-water-bounce {
0%,
100% {
transform: scale(1);
}
35% {
transform: scale(1.08) translateY(-4rpx);
}
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;
}
.stg-sparkle-a {
top: 6%;
right: 18%;
}
.stg-sparkle-b {
top: 14%;
left: 12%;
width: 6rpx;
height: 6rpx;
opacity: 0.85;
}
</style>
@@ -0,0 +1,140 @@
<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: '#0891B2' }
})
/** 微信小程序 image 对 data:svg 支持不稳定,默认用文字图标保证可见 */
// #ifdef MP-WEIXIN
const useFallback = ref(true)
// #endif
// #ifndef MP-WEIXIN
const useFallback = ref(false)
// #endif
/** 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"/>',
'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"/>'
}
/** 图片加载失败时的 emoji 回退 */
const ICON_FALLBACK = {
view: '👁',
ticket: '🎫',
calendar: '📅',
flame: '🔥',
'check-circle': '✓',
glucose: '💧',
heart: '❤',
activity: '🏃',
users: '👥',
'alert-triangle': '⚠',
minus: '—',
droplet: '💧',
sparkles: '✨',
trophy: '🏆',
share: '↗'
}
const strokeColor = computed(() => {
const c = String(props.color || '#0891B2').trim()
return /^#[0-9A-Fa-f]{3,8}$/.test(c) ? c : '#0891B2'
})
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%;
}
.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>
File diff suppressed because it is too large Load Diff
@@ -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}`
}