This commit is contained in:
2026-06-13 17:57:43 +08:00
parent 68678304ff
commit a0f441d8ae
28 changed files with 1933 additions and 2 deletions
+17
View File
@@ -0,0 +1,17 @@
import api from './client'
export interface CapsuleCreateData {
recipient_id: string
title: string
content: string
unlock_at: string
mood?: string
}
export const capsulesApi = {
getAll: () => api.get('/capsules/'),
getOne: (id: string) => api.get(`/capsules/${id}`),
create: (data: CapsuleCreateData) => api.post('/capsules/', data),
}
+10
View File
@@ -0,0 +1,10 @@
import api from './client'
export const leavesApi = {
getToday: () => api.get('/leaves/today'),
updateLeaf: (leafId: string, data: { mood?: string; note?: string }) =>
api.put(`/leaves/${leafId}`, data),
getCollection: () => api.get('/leaves/collection'),
}
+9
View File
@@ -0,0 +1,9 @@
import api from './client'
export const treesApi = {
getAll: () => api.get('/trees/'),
getTree: (friendId: string) => api.get(`/trees/${friendId}`),
water: (friendId: string) => api.post(`/trees/${friendId}/water`),
}
+4
View File
@@ -20,6 +20,9 @@
<router-link to="/moments" class="rail-item" :class="{ active: activeFeature === 'moments' }" title="朋友圈">
<span class="rail-icon">🌿</span>
</router-link>
<router-link to="/garden" class="rail-item" :class="{ active: activeFeature === 'garden' }" title="花园">
<span class="rail-icon"></span>
</router-link>
</nav>
<div class="rail-bottom">
<router-link to="/settings" class="rail-item" :class="{ active: activeFeature === 'settings' }" title="设置">
@@ -100,6 +103,7 @@ const activeFeature = computed(() => {
if (path.startsWith('/chat')) return 'chat'
if (path.startsWith('/contacts')) return 'contacts'
if (path.startsWith('/moments')) return 'moments'
if (path.startsWith('/garden')) return 'garden'
if (path.startsWith('/settings')) return 'settings'
return 'chat'
})
+43
View File
@@ -90,6 +90,49 @@ const routes: RouteRecordRaw[] = [
],
},
// 花园(特色功能)
{
path: 'garden',
children: [
{
path: '',
name: 'Garden',
meta: { hideSecondary: true },
components: {
secondary: () => import('@/views/garden/GardenSidebar.vue'),
default: () => import('@/views/garden/GardenView.vue'),
},
},
{
path: 'leaf',
name: 'GardenLeaf',
meta: { hideSecondary: true },
components: {
secondary: () => import('@/views/garden/GardenSidebar.vue'),
default: () => import('@/views/garden/LeafView.vue'),
},
},
{
path: 'tree',
name: 'GardenTree',
meta: { hideSecondary: true },
components: {
secondary: () => import('@/views/garden/GardenSidebar.vue'),
default: () => import('@/views/garden/TreeView.vue'),
},
},
{
path: 'capsule',
name: 'GardenCapsule',
meta: { hideSecondary: true },
components: {
secondary: () => import('@/views/garden/GardenSidebar.vue'),
default: () => import('@/views/garden/CapsuleView.vue'),
},
},
],
},
// 设置
{
path: 'settings',
+26
View File
@@ -0,0 +1,26 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { capsulesApi, type CapsuleCreateData } from '@/api/capsules'
export const useCapsulesStore = defineStore('capsules', () => {
const capsules = ref<any[]>([])
const isLoading = ref(false)
async function fetchAll() {
isLoading.value = true
try {
const { data } = await capsulesApi.getAll()
capsules.value = data
} finally {
isLoading.value = false
}
}
async function create(payload: CapsuleCreateData) {
const { data } = await capsulesApi.create(payload)
capsules.value.unshift(data)
return data
}
return { capsules, isLoading, fetchAll, create }
})
+33
View File
@@ -0,0 +1,33 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { leavesApi } from '@/api/leaves'
export const useLeavesStore = defineStore('leaves', () => {
const todayLeaf = ref<any>(null)
const collection = ref<any[]>([])
const isLoading = ref(false)
async function fetchToday() {
const { data } = await leavesApi.getToday()
todayLeaf.value = data
return data
}
async function updateLeaf(leafId: string, payload: { mood?: string; note?: string }) {
const { data } = await leavesApi.updateLeaf(leafId, payload)
todayLeaf.value = data
return data
}
async function fetchCollection() {
isLoading.value = true
try {
const { data } = await leavesApi.getCollection()
collection.value = data
} finally {
isLoading.value = false
}
}
return { todayLeaf, collection, isLoading, fetchToday, updateLeaf, fetchCollection }
})
+89
View File
@@ -0,0 +1,89 @@
/**
* 程序化叶子生成器
* 由确定性种子(hash(userId+date))派生独一无二的叶子形态
*/
export interface LeafStyle {
hue: number // 色相 0-360
saturation: number // 饱和度
lightness: number // 亮度
shapeVariant: number // 形态变体 0-3(不同叶形 path)
veinCount: number // 叶脉数量 3-7
angle: number // 叶子倾斜角度
spots: number // 斑点数量
size: number // 相对大小 0.85-1.1
}
/** 从 16 进制种子派生叶子样式 */
export function generateLeafStyle(seed: string): LeafStyle {
// 将 16 位 hex 种子拆成多段用于不同属性
const n = (start: number, len: number) => parseInt(seed.slice(start, start + len), 16)
const hue = n(0, 3) % 80 + 70 // 70-150 区间:黄绿到青绿
const saturation = 40 + (n(3, 2) % 35) // 40-75
const lightness = 45 + (n(5, 2) % 20) // 45-65
const shapeVariant = n(7, 1) % 4
const veinCount = 3 + (n(8, 1) % 5)
const angle = (n(9, 2) % 30) - 15 // -15 到 +15 度
const spots = n(11, 1) % 4
const size = 0.85 + (n(12, 2) % 26) / 100
return { hue, saturation, lightness, shapeVariant, veinCount, angle, spots, size }
}
export function leafColor(style: LeafStyle, lightDelta = 0): string {
return `hsl(${style.hue}, ${style.saturation}%, ${style.lightness + lightDelta}%)`
}
/** 4 种叶形 SVG pathviewBox 0 0 100 120 */
const LEAF_SHAPES = [
// 经典椭圆叶
'M50 8 C72 18 82 45 78 78 C74 104 60 116 50 116 C40 116 26 104 22 78 C18 45 28 18 50 8 Z',
// 心形叶
'M50 12 C60 4 78 8 80 28 C82 52 64 78 50 116 C36 78 18 52 20 28 C22 8 40 4 50 12 Z',
// 长披针叶
'M50 6 C62 30 66 60 62 92 C58 110 54 118 50 118 C46 118 42 110 38 92 C34 60 38 30 50 6 Z',
// 枫叶状
'M50 8 C58 22 56 30 68 34 C78 38 72 50 66 54 C74 62 70 74 58 72 C56 90 54 108 50 118 C46 108 44 90 42 72 C30 74 26 62 34 54 C28 50 22 38 32 34 C44 30 42 22 50 8 Z',
]
export function leafPath(variant: number): string {
return LEAF_SHAPES[variant % LEAF_SHAPES.length]
}
/** 生成叶脉 path(沿中线对称分支) */
export function veinPaths(style: LeafStyle): string[] {
const paths: string[] = []
// 主脉
paths.push('M50 16 L50 112')
// 侧脉
for (let i = 0; i < style.veinCount; i++) {
const y = 28 + i * (70 / style.veinCount)
const len = 16 + (i % 2) * 6
paths.push(`M50 ${y} Q38 ${y + 8} ${50 - len} ${y + 16}`)
paths.push(`M50 ${y} Q62 ${y + 8} ${50 + len} ${y + 16}`)
}
return paths
}
/** 生成斑点坐标(装饰) */
export function spotPositions(style: LeafStyle): { x: number; y: number; r: number }[] {
const spots: { x: number; y: number; r: number }[] = []
let s = parseInt(seedHash(style), 10) || 1
const rand = () => {
s = (s * 9301 + 49297) % 233280
return s / 233280
}
for (let i = 0; i < style.spots; i++) {
spots.push({
x: 30 + rand() * 40,
y: 30 + rand() * 60,
r: 1.5 + rand() * 2,
})
}
return spots
}
function seedHash(style: LeafStyle): string {
return '' + (style.hue * 7 + style.veinCount * 13 + style.spots * 31)
}
+77
View File
@@ -0,0 +1,77 @@
/**
* 程序化树生成器
* 根据阶段索引 + 种子渲染不同形态的树(SVG)
*/
export interface TreeStyle {
trunkWidth: number
canopyRadius: number
canopyCount: number
height: number
hue: number
dropletCount: number // 露珠数 = 浇水相关
}
/** 根据阶段和分数生成树样式 */
export function generateTreeStyle(stageIndex: number, seed: string, waterCount: number): TreeStyle {
const n = (start: number) => parseInt(seed.slice(start, start + 2) || '0', 16)
const baseCanopy = 18 + stageIndex * 10
const baseTrunk = 4 + stageIndex * 1.5
const hue = 90 + (n(0) % 40) // 90-130 绿色系
return {
trunkWidth: baseTrunk,
canopyRadius: baseCanopy,
canopyCount: 3 + stageIndex, // 树冠数量随阶段增加
height: 80 + stageIndex * 15,
hue,
dropletCount: Math.min(waterCount, 6),
}
}
export function treeTrunkColor(style: TreeStyle): string {
return `hsl(25, 45%, ${35 + style.trunkWidth}%)`
}
export function treeCanopyColor(style: TreeStyle, delta = 0): string {
return `hsl(${style.hue}, 55%, ${42 + delta}%)`
}
/** 生成树冠圆圈位置(围绕顶部) */
export function canopyPositions(style: TreeStyle, centerX: number, topY: number): { cx: number; cy: number; r: number }[] {
const positions: { cx: number; cy: number; r: number }[] = []
const count = style.canopyCount
// 主树冠
positions.push({ cx: centerX, cy: topY, r: style.canopyRadius })
// 侧边树冠
for (let i = 1; i < count; i++) {
const offset = i % 2 === 1 ? 1 : -1
const layer = Math.ceil(i / 2)
positions.push({
cx: centerX + offset * (style.canopyRadius * 0.6 * layer),
cy: topY + layer * style.canopyRadius * 0.3,
r: style.canopyRadius * (0.75 - layer * 0.1),
})
}
return positions
}
/** 生成露珠位置(挂在树冠上) */
export function dropletPositions(style: TreeStyle, centerX: number, topY: number): { x: number; y: number }[] {
const droplets: { x: number; y: number }[] = []
const positions = canopyPositions(style, centerX, topY)
let seed = parseInt(style.hue.toString() + style.dropletCount, 10) || 1
const rand = () => {
seed = (seed * 9301 + 49297) % 233280
return seed / 233280
}
for (let i = 0; i < style.dropletCount && i < positions.length; i++) {
const p = positions[i]
droplets.push({
x: p.cx + (rand() - 0.5) * p.r,
y: p.cy + (rand() - 0.5) * p.r * 0.6,
})
}
return droplets
}
+373
View File
@@ -0,0 +1,373 @@
<template>
<div class="capsule-view">
<div class="capsule-header">
<h2> 时光胶囊</h2>
<p class="subtitle">寄一颗种子给未来时间到了它会发芽</p>
<n-button type="primary" round size="small" @click="openCreate" style="margin-top: 8px">
寄一颗胶囊
</n-button>
</div>
<div v-if="filteredCapsules.length === 0 && !store.isLoading" class="empty">
<div style="font-size: 56px">🌰</div>
<p>还没有胶囊种下第一颗吧</p>
</div>
<!-- 标签切换 -->
<div v-else class="tabs">
<button :class="{ active: tab === 'all' }" @click="tab = 'all'">全部 ({{ store.capsules.length }})</button>
<button :class="{ active: tab === 'received' }" @click="tab = 'received'">收到的 ({{ receivedCount }})</button>
<button :class="{ active: tab === 'sent' }" @click="tab = 'sent'">寄出的 ({{ sentCount }})</button>
</div>
<!-- 胶囊列表 -->
<div class="capsule-list">
<div v-for="c in filteredCapsules" :key="c.id" class="capsule-card"
:class="{ locked: c.locked, [moodClass(c.mood)]: true }"
@click="openCapsule(c)">
<!-- 锁定显示种子 -->
<div v-if="c.locked" class="locked-view">
<div class="seed-icon" :class="`seed-${moodClass(c.mood)}`">🌰</div>
<div class="locked-info">
<div class="capsule-title">{{ c.title }}</div>
<div class="countdown">{{ formatCountdown(c.seconds_left) }} 后发芽</div>
</div>
<span class="badge">{{ c.is_mine_received ? '收' : '寄' }}</span>
</div>
<!-- 已解锁 -->
<div v-else class="unlocked-view">
<div class="capsule-title">{{ c.title }}</div>
<div class="capsule-content">{{ c.content?.slice(0, 80) }}{{ (c.content?.length || 0) > 80 ? '...' : '' }}</div>
<div class="capsule-footer">
<span class="footer-tag">{{ c.is_mine_received ? '📬 收到的' : '📤 寄出的' }}</span>
<span class="footer-time">{{ formatDate(c.unlock_at) }}</span>
</div>
</div>
</div>
</div>
<!-- 查看弹窗 -->
<div v-if="viewing" class="modal-overlay" @click.self="viewing = null">
<div class="view-modal" :class="moodClass(viewing.mood)">
<div class="modal-header">
<h3>{{ viewing.locked ? '🔒 还在沉睡的种子' : '🌱 胶囊发芽了' }}</h3>
<span class="close-btn" @click="viewing = null"></span>
</div>
<div class="modal-body">
<div class="view-title">{{ viewing.title }}</div>
<div v-if="viewing.locked" class="view-locked">
<div class="big-seed">🌰</div>
<p>这颗胶囊还在沉睡</p>
<p class="countdown-big">{{ formatCountdown(viewing.seconds_left) }}</p>
<p class="unlock-at">{{ formatDate(viewing.unlock_at) }} 发芽</p>
</div>
<div v-else class="view-content">{{ viewing.content }}</div>
</div>
</div>
</div>
<!-- 创建弹窗 -->
<div v-if="showCreate" class="modal-overlay" @click.self="showCreate = false">
<div class="create-modal">
<div class="modal-header">
<h3> 寄一颗时光胶囊</h3>
<span class="close-btn" @click="showCreate = false"></span>
</div>
<div class="create-body">
<div class="form-row">
<span class="form-label">寄给谁</span>
<n-select v-model:value="form.recipient_id" :options="recipientOptions" placeholder="选择好友或自己" />
</div>
<div class="form-row">
<span class="form-label">标题</span>
<n-input v-model:value="form.title" placeholder="给这颗种子起个名字" maxlength="100" />
</div>
<div class="form-row">
<span class="form-label">想说的话</span>
<n-input v-model:value="form.content" type="textarea" :rows="4"
placeholder="写下此刻想留存的话..." maxlength="5000" show-count />
</div>
<div class="form-row">
<span class="form-label">发芽时间</span>
<div class="quick-times">
<button v-for="opt in quickOptions" :key="opt.value"
:class="{ active: selectedQuick === opt.value }"
@click="selectQuick(opt)">{{ opt.label }}</button>
</div>
<n-date-picker v-if="selectedQuick === 'custom'" v-model:value="customTime"
type="datetime" :is-date-disabled="(ts: number) => ts < Date.now()" style="margin-top: 8px" />
</div>
<div class="form-row">
<span class="form-label">心情色(种子颜色)</span>
<div class="mood-colors">
<span v-for="m in moodColors" :key="m.key" class="mood-color"
:class="{ active: form.mood === m.key }" :style="{ background: m.color }"
@click="form.mood = m.key">{{ m.emoji }}</span>
</div>
</div>
</div>
<div class="modal-footer">
<n-button @click="showCreate = false">取消</n-button>
<n-button type="primary" :loading="creating"
:disabled="!form.recipient_id || !form.title.trim() || !form.content.trim()"
@click="doCreate">种下种子</n-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useMessage } from 'naive-ui'
import { useCapsulesStore } from '@/stores/capsules'
import { friendsApi } from '@/api/friends'
import { useAuthStore } from '@/stores/auth'
const store = useCapsulesStore()
const auth = useAuthStore()
const message = useMessage()
const tab = ref('all')
const viewing = ref<any>(null)
const showCreate = ref(false)
const creating = ref(false)
const friends = ref<any[]>([])
const tickTimer = ref<ReturnType<typeof setInterval>>()
const form = ref({
recipient_id: '',
title: '',
content: '',
mood: 'green',
})
const selectedQuick = ref('1d')
const customTime = ref<number>(Date.now() + 86400000)
const quickOptions = [
{ value: '1h', label: '1 小时后', ms: 3600000 },
{ value: '1d', label: '明天', ms: 86400000 },
{ value: '1w', label: '下周', ms: 604800000 },
{ value: '1m', label: '下个月', ms: 2592000000 },
{ value: '6m', label: '半年后', ms: 15552000000 },
{ value: 'custom', label: '自定义', ms: 0 },
]
const moodColors = [
{ key: 'green', emoji: '🍃', color: '#66BB6A' },
{ key: 'pink', emoji: '🌸', color: '#EC407A' },
{ key: 'amber', emoji: '🍯', color: '#FFA726' },
{ key: 'blue', emoji: '💧', color: '#42A5F5' },
{ key: 'purple', emoji: '🍇', color: '#AB47BC' },
]
const recipientOptions = computed(() => {
const opts = [{ label: '🌱 写给未来的自己', value: auth.user?.id || '' }]
friends.value.forEach((f) => {
opts.push({ label: f.remark || f.nickname || f.username, value: f.friend_user_id })
})
return opts
})
const filteredCapsules = computed(() => {
if (tab.value === 'received') return store.capsules.filter((c) => c.is_mine_received)
if (tab.value === 'sent') return store.capsules.filter((c) => c.is_mine_sent && !c.is_mine_received)
return store.capsules
})
const receivedCount = computed(() => store.capsules.filter((c) => c.is_mine_received).length)
const sentCount = computed(() => store.capsules.filter((c) => c.is_mine_sent && !c.is_mine_received).length)
onMounted(async () => {
await store.fetchAll()
// 每秒刷新倒计时
tickTimer.value = setInterval(() => {
const now = Date.now()
store.capsules.forEach((c) => {
if (c.locked && c.seconds_left > 0) {
const unlockTs = new Date(c.unlock_at).getTime()
c.seconds_left = Math.max(0, Math.floor((unlockTs - now) / 1000))
}
})
}, 1000)
})
onUnmounted(() => {
if (tickTimer.value) clearInterval(tickTimer.value)
})
function openCapsule(c: any) {
viewing.value = c
}
function openCreate() {
form.value = { recipient_id: '', title: '', content: '', mood: 'green' }
selectedQuick.value = '1d'
showCreate.value = true
// 加载好友列表
if (friends.value.length === 0) {
friendsApi.getFriends().then(({ data }) => (friends.value = data)).catch(() => {})
}
}
function selectQuick(opt: any) {
selectedQuick.value = opt.value
}
async function doCreate() {
let unlockAt: number
if (selectedQuick.value === 'custom') {
unlockAt = customTime.value
} else {
const opt = quickOptions.find((o) => o.value === selectedQuick.value)
unlockAt = Date.now() + (opt?.ms || 86400000)
}
if (unlockAt <= Date.now()) {
message.error('发芽时间必须是未来')
return
}
creating.value = true
try {
await store.create({
recipient_id: form.value.recipient_id,
title: form.value.title.trim(),
content: form.value.content.trim(),
unlock_at: new Date(unlockAt).toISOString(),
mood: form.value.mood,
})
message.success('🌱 种子已种下,期待它发芽')
showCreate.value = false
} catch (e: any) {
message.error(e.response?.data?.detail || '创建失败')
} finally {
creating.value = false
}
}
function moodClass(mood: string) {
return mood || 'green'
}
function formatCountdown(seconds: number): string {
if (seconds <= 0) return '即将发芽'
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
if (d > 0) return `${d}${h} 小时`
if (h > 0) return `${h}${m}`
if (m > 0) return `${m}${s}`
return `${s}`
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
</script>
<style scoped>
.capsule-view {
flex: 1; overflow-y: auto; padding: 24px;
background: linear-gradient(180deg, var(--color-bg) 0%, var(--color-primary-lightest) 100%);
}
.capsule-header { text-align: center; margin-bottom: 20px; }
.capsule-header h2 { margin: 0; font-size: 22px; color: var(--color-primary-darker); }
.subtitle { font-size: 13px; color: var(--color-text-hint); margin: 4px 0 0; }
.empty { text-align: center; padding: 60px 20px; color: var(--color-text-hint); }
.empty p { margin-top: 12px; font-size: 14px; }
.tabs { display: flex; gap: 4px; max-width: 480px; margin: 0 auto 16px; }
.tabs button {
flex: 1; padding: 8px; border: none; background: var(--color-surface);
border-radius: 8px; cursor: pointer; font-size: 13px; color: var(--color-text-secondary);
border: 1px solid var(--color-border); transition: all 0.2s;
}
.tabs button.active { background: var(--color-primary); color: white; border-color: var(--color-primary); }
.capsule-list { max-width: 480px; margin: 0 auto; display: flex; flex-direction: column; gap: 12px; }
.capsule-card {
background: var(--color-surface); border-radius: 14px; padding: 16px;
border: 1px solid var(--color-border); cursor: pointer; transition: all 0.2s;
border-left: 4px solid var(--color-primary);
}
.capsule-card:hover { transform: translateX(2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.capsule-card.pink { border-left-color: #EC407A; }
.capsule-card.amber { border-left-color: #FFA726; }
.capsule-card.blue { border-left-color: #42A5F5; }
.capsule-card.purple { border-left-color: #AB47BC; }
.locked-view { display: flex; align-items: center; gap: 14px; }
.seed-icon { font-size: 32px; animation: seed-pulse 2s ease-in-out infinite; }
.seed-pink { filter: hue-rotate(280deg); }
.seed-amber { filter: hue-rotate(330deg); }
.seed-blue { filter: hue-rotate(200deg); }
.seed-purple { filter: hue-rotate(260deg); }
@keyframes seed-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.1); } }
.locked-info { flex: 1; }
.capsule-title { font-weight: 600; font-size: 15px; color: var(--color-text-primary); }
.countdown { font-size: 12px; color: var(--color-primary); margin-top: 2px; }
.badge {
font-size: 11px; padding: 2px 8px; border-radius: 10px;
background: var(--color-primary-lightest); color: var(--color-primary);
}
.unlocked-view .capsule-content {
font-size: 13px; color: var(--color-text-secondary); margin: 6px 0; line-height: 1.5;
}
.capsule-footer { display: flex; justify-content: space-between; font-size: 11px; color: var(--color-text-hint); margin-top: 8px; }
/* 弹窗 */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px;
}
.view-modal, .create-modal {
width: 100%; max-width: 460px; max-height: 80vh; background: var(--color-surface);
border-radius: 16px; display: flex; flex-direction: column; overflow: hidden;
box-shadow: 0 12px 40px rgba(0,0,0,0.2);
}
.modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 18px 24px; border-bottom: 1px solid var(--color-border);
}
.modal-header h3 { margin: 0; font-size: 17px; }
.close-btn { cursor: pointer; font-size: 18px; color: var(--color-text-hint); }
.modal-body { padding: 24px; overflow-y: auto; }
.view-modal.green { border-top: 4px solid var(--color-primary); }
.view-modal.pink { border-top: 4px solid #EC407A; }
.view-modal.amber { border-top: 4px solid #FFA726; }
.view-modal.blue { border-top: 4px solid #42A5F5; }
.view-modal.purple { border-top: 4px solid #AB47BC; }
.view-title { font-size: 18px; font-weight: 700; margin-bottom: 16px; text-align: center; }
.view-locked { text-align: center; padding: 20px 0; }
.big-seed { font-size: 72px; animation: seed-pulse 2s ease-in-out infinite; }
.view-locked p { margin: 8px 0; color: var(--color-text-secondary); }
.countdown-big { font-size: 22px; font-weight: 700; color: var(--color-primary) !important; }
.unlock-at { font-size: 13px; color: var(--color-text-hint) !important; }
.view-content { font-size: 15px; line-height: 1.8; white-space: pre-wrap; color: var(--color-text-primary); }
/* 创建表单 */
.create-body { padding: 20px 24px; overflow-y: auto; }
.form-row { margin-bottom: 16px; }
.form-label { display: block; font-size: 13px; color: var(--color-text-secondary); margin-bottom: 6px; font-weight: 500; }
.quick-times { display: flex; flex-wrap: wrap; gap: 6px; }
.quick-times button {
padding: 6px 12px; border: 1px solid var(--color-border); background: var(--color-surface);
border-radius: 16px; cursor: pointer; font-size: 12px; color: var(--color-text-secondary); transition: all 0.2s;
}
.quick-times button.active { background: var(--color-primary); color: white; border-color: var(--color-primary); }
.mood-colors { display: flex; gap: 10px; }
.mood-color {
width: 36px; height: 36px; border-radius: 50%; cursor: pointer;
display: flex; align-items: center; justify-content: center; font-size: 16px;
border: 2px solid transparent; transition: all 0.2s;
}
.mood-color.active { border-color: var(--color-text-primary); transform: scale(1.1); }
.modal-footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 16px 24px; border-top: 1px solid var(--color-border);
}
</style>
@@ -0,0 +1,53 @@
<template>
<div class="garden-sidebar">
<div class="panel-header">
<h3 class="panel-title">🌿 花园</h3>
</div>
<div class="sidebar-content">
<div class="intro">
<div class="intro-icon">🌱</div>
<p class="intro-text">关系会在这里生长</p>
</div>
<router-link to="/garden" class="menu-item" active-class="active">
<span class="menu-icon">🏠</span>
<span class="menu-label">花园首页</span>
</router-link>
<router-link to="/garden/leaf" class="menu-item" active-class="active">
<span class="menu-icon">🍃</span>
<span class="menu-label">每日心情叶</span>
</router-link>
<router-link to="/garden/tree" class="menu-item" active-class="active">
<span class="menu-icon">🌳</span>
<span class="menu-label">好友之树</span>
</router-link>
<router-link to="/garden/capsule" class="menu-item" active-class="active">
<span class="menu-icon"></span>
<span class="menu-label">时光胶囊</span>
</router-link>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
.garden-sidebar { display: flex; flex-direction: column; height: 100%; }
.panel-header { padding: 16px; border-bottom: 1px solid var(--color-border); }
.panel-title { margin: 0; font-size: 16px; font-weight: 600; }
.sidebar-content { flex: 1; padding: 16px; }
.intro {
text-align: center; padding: 24px 16px; margin-bottom: 16px;
background: var(--color-primary-lightest); border-radius: 12px;
}
.intro-icon { font-size: 32px; margin-bottom: 6px; }
.intro-text { font-size: 13px; color: var(--color-text-secondary); margin: 0; }
.menu-item {
display: flex; align-items: center; gap: 12px; padding: 12px;
text-decoration: none; color: var(--color-text-primary); font-size: 14px;
border-radius: 8px; transition: background 0.15s; margin-bottom: 4px;
}
.menu-item:hover { background: var(--color-primary-lightest); }
.menu-item.active { background: var(--color-primary-lightest); color: var(--color-primary); font-weight: 500; }
.menu-icon { font-size: 18px; }
</style>
+79
View File
@@ -0,0 +1,79 @@
<template>
<div class="garden-view">
<div class="garden-header">
<h2 class="garden-title">🌿 花园</h2>
<p class="garden-subtitle">在这里关系会生长 · 每一片叶子都是独一无二的回忆</p>
</div>
<div class="feature-cards">
<div class="feature-card leaf-card" @click="$router.push('/garden/leaf')">
<div class="card-icon">🍃</div>
<div class="card-body">
<h3>每日心情叶</h3>
<p>每天收获一片独一无二的叶子记录今日心情</p>
</div>
<span class="card-arrow"></span>
</div>
<div class="feature-card tree-card" @click="$router.push('/garden/tree')">
<div class="card-icon">🌳</div>
<div class="card-body">
<h3>好友之树</h3>
<p>你们的友谊会长成一棵树越聊越茂盛</p>
</div>
<span class="card-arrow"></span>
</div>
<div class="feature-card capsule-card" @click="$router.push('/garden/capsule')">
<div class="card-icon"></div>
<div class="card-body">
<h3>时光胶囊</h3>
<p>给未来的自己或好友寄一颗会发芽的种子</p>
</div>
<span class="card-arrow"></span>
</div>
</div>
<div class="garden-quote">
<p>过去 · 当下 · 未来都在这片花园里生长</p>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
.garden-view {
flex: 1; overflow-y: auto; padding: 32px;
background: linear-gradient(160deg, var(--color-bg) 0%, var(--color-primary-lightest) 100%);
}
.garden-header { text-align: center; margin-bottom: 36px; }
.garden-title { font-size: 28px; font-weight: 700; margin: 0 0 8px; color: var(--color-primary-darker); }
.garden-subtitle { font-size: 14px; color: var(--color-text-secondary); margin: 0; }
.feature-cards { max-width: 560px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
.feature-card {
display: flex; align-items: center; gap: 16px; padding: 20px 24px;
background: var(--color-surface); border-radius: 16px;
border: 1px solid var(--color-border); cursor: pointer;
transition: all 0.25s; box-shadow: 0 2px 8px rgba(0,0,0,0.03);
}
.feature-card:hover {
transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,150,136,0.15);
border-color: var(--color-primary-light);
}
.card-icon { font-size: 40px; flex-shrink: 0; }
.card-body { flex: 1; }
.card-body h3 { margin: 0 0 4px; font-size: 17px; color: var(--color-text-primary); }
.card-body p { margin: 0; font-size: 13px; color: var(--color-text-secondary); }
.card-arrow { font-size: 24px; color: var(--color-text-hint); flex-shrink: 0; }
.feature-card:hover .card-arrow { color: var(--color-primary); }
.leaf-card { border-left: 4px solid #66BB6A; }
.tree-card { border-left: 4px solid #8D6E63; }
.capsule-card { border-left: 4px solid #FFB74D; }
.garden-quote { text-align: center; margin-top: 48px; }
.garden-quote p { font-size: 13px; color: var(--color-text-hint); font-style: italic; margin: 0; }
</style>
+240
View File
@@ -0,0 +1,240 @@
<template>
<div class="leaf-view">
<div class="leaf-header">
<h2>🍃 每日心情叶</h2>
<p class="subtitle">{{ today }}</p>
</div>
<div v-if="leaf" class="leaf-main">
<!-- 叶子大图 -->
<div class="leaf-stage">
<div class="leaf-wrapper" :style="{ transform: `scale(${style.size}) rotate(${style.angle}deg)` }">
<svg viewBox="0 0 100 120" class="leaf-svg" :class="{ sway: !editingMood }">
<defs>
<linearGradient :id="`grad-${leaf.id}`" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" :stop-color="leafColor(style, 10)" />
<stop offset="100%" :stop-color="leafColor(style, -10)" />
</linearGradient>
</defs>
<path :d="leafPath(style.shapeVariant)" :fill="`url(#grad-${leaf.id})`" stroke="rgba(0,0,0,0.15)" stroke-width="0.5" />
<!-- 叶脉 -->
<g stroke="rgba(255,255,255,0.4)" stroke-width="0.8" fill="none" stroke-linecap="round">
<path v-for="(vp, i) in veinPaths(style)" :key="i" :d="vp" />
</g>
<!-- 斑点 -->
<circle v-for="(sp, i) in spotPositions(style)" :key="'s'+i" :cx="sp.x" :cy="sp.y" :r="sp.r" fill="rgba(255,255,255,0.25)" />
</svg>
</div>
</div>
<!-- 心情 -->
<div class="mood-section">
<div class="section-label">今日心情</div>
<div class="mood-grid">
<div v-for="m in moods" :key="m.key" class="mood-chip"
:class="{ active: leaf.mood === m.key }" @click="selectMood(m.key)">
<span class="mood-emoji">{{ m.emoji }}</span>
<span class="mood-name">{{ m.name }}</span>
</div>
</div>
</div>
<!-- 备注 -->
<div class="note-section">
<div class="section-label">写点什么可选</div>
<n-input v-model:value="note" type="textarea" :rows="2" placeholder="今天这片叶子想说什么..."
maxlength="200" show-count @blur="saveNote" />
</div>
<div v-if="leaf.mood" class="today-mood-display">
<span class="mood-emoji large">{{ currentMoodEmoji }}</span>
<span>{{ currentMoodName }}</span>
</div>
</div>
<div v-else class="loading">生长中...</div>
<!-- 收藏入口 -->
<div class="collection-entry">
<n-button quaternary type="primary" @click="showCollection = !showCollection">
📖 我的叶子图鉴 ({{ collection.length }})
</n-button>
</div>
<!-- 图鉴弹窗 -->
<div v-if="showCollection" class="modal-overlay" @click.self="showCollection = false">
<div class="collection-modal">
<div class="modal-header">
<h3>🍃 叶子图鉴</h3>
<span class="close-btn" @click="showCollection = false"></span>
</div>
<div class="collection-grid">
<div v-if="collection.length === 0" class="empty">还没有收藏的叶子</div>
<div v-for="l in collection" :key="l.id" class="collection-leaf" :title="l.leaf_date">
<svg viewBox="0 0 100 120" class="mini-leaf">
<path :d="leafPath(generateLeafStyle(l.leaf_seed).shapeVariant)"
:fill="leafColor(generateLeafStyle(l.leaf_seed))" />
</svg>
<span class="leaf-date">{{ l.leaf_date?.slice(5) }}</span>
<span v-if="l.mood" class="leaf-mood">{{ moodEmoji(l.mood) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useMessage } from 'naive-ui'
import { useLeavesStore } from '@/stores/leaves'
import {
generateLeafStyle, leafColor, leafPath, veinPaths, spotPositions,
} from '@/utils/leafGenerator'
const leavesStore = useLeavesStore()
const message = useMessage()
const note = ref('')
const showCollection = ref(false)
const editingMood = ref(false)
const moods = [
{ key: 'calm', emoji: '🌙', name: '平静' },
{ key: 'happy', emoji: '😊', name: '开心' },
{ key: 'energetic', emoji: '⚡', name: '活力' },
{ key: 'thoughtful', emoji: '🤔', name: '沉思' },
{ key: 'lazy', emoji: '😴', name: '慵懒' },
{ key: 'grateful', emoji: '🙏', name: '感恩' },
]
const leaf = computed(() => leavesStore.todayLeaf)
const style = computed(() => leaf.value ? generateLeafStyle(leaf.value.leaf_seed) : generateLeafStyle('0000000000000000'))
const collection = computed(() => leavesStore.collection)
const today = computed(() => new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' }))
const currentMoodEmoji = computed(() => moods.find((m) => m.key === leaf.value?.mood)?.emoji || '')
const currentMoodName = computed(() => moods.find((m) => m.key === leaf.value?.mood)?.name || '')
onMounted(async () => {
await leavesStore.fetchToday()
note.value = leaf.value?.note || ''
})
watch(() => leavesStore.todayLeaf?.note, (n) => {
if (n !== undefined) note.value = n
})
async function selectMood(mood: string) {
editingMood.value = true
try {
await leavesStore.updateLeaf(leaf.value.id, { mood })
message.success('心情已记录')
} catch {
message.error('保存失败')
} finally {
setTimeout(() => (editingMood.value = false), 600)
}
}
let noteTimer: ReturnType<typeof setTimeout>
function saveNote() {
clearTimeout(noteTimer)
noteTimer = setTimeout(async () => {
if (!leaf.value) return
try {
await leavesStore.updateLeaf(leaf.value.id, { note: note.value })
} catch {}
}, 300)
}
function moodEmoji(key: string) {
return moods.find((m) => m.key === key)?.emoji || ''
}
// 加载收藏
watch(showCollection, async (val) => {
if (val) await leavesStore.fetchCollection()
})
</script>
<style scoped>
.leaf-view {
flex: 1; overflow-y: auto; padding: 24px;
background: linear-gradient(180deg, var(--color-bg) 0%, var(--color-primary-lightest) 100%);
}
.leaf-header { text-align: center; margin-bottom: 8px; }
.leaf-header h2 { margin: 0; font-size: 22px; color: var(--color-primary-darker); }
.subtitle { font-size: 13px; color: var(--color-text-hint); margin: 4px 0 0; }
.leaf-main { max-width: 480px; margin: 0 auto; }
/* 叶子舞台 */
.leaf-stage {
display: flex; justify-content: center; align-items: center;
height: 240px; margin-bottom: 20px;
filter: drop-shadow(0 8px 16px rgba(0,150,136,0.2));
}
.leaf-wrapper { transition: transform 0.4s; transform-origin: 50% 90%; }
.leaf-svg { width: 160px; height: 192px; }
.leaf-svg.sway { animation: leaf-sway 4s ease-in-out infinite; transform-origin: 50% 100%; }
@keyframes leaf-sway {
0%, 100% { transform: rotate(-2deg); }
50% { transform: rotate(2deg); }
}
/* 心情 */
.section-label { font-size: 13px; color: var(--color-text-secondary); margin-bottom: 10px; font-weight: 500; }
.mood-section { margin-bottom: 20px; }
.mood-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.mood-chip {
display: flex; flex-direction: column; align-items: center; gap: 4px;
padding: 12px 8px; border-radius: 12px; background: var(--color-surface);
border: 2px solid transparent; cursor: pointer; transition: all 0.2s;
}
.mood-chip:hover { transform: translateY(-2px); border-color: var(--color-primary-lighter); }
.mood-chip.active { border-color: var(--color-primary); background: var(--color-primary-lightest); }
.mood-emoji { font-size: 24px; }
.mood-emoji.large { font-size: 28px; }
.mood-name { font-size: 12px; color: var(--color-text-secondary); }
.note-section { margin-bottom: 16px; }
.today-mood-display {
display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 12px; background: var(--color-primary-lightest); border-radius: 12px;
font-size: 15px; color: var(--color-primary-darker); font-weight: 500;
}
.loading { text-align: center; padding: 60px; color: var(--color-text-hint); }
.collection-entry { text-align: center; margin-top: 20px; }
/* 图鉴弹窗 */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
display: flex; align-items: center; justify-content: center; z-index: 1000;
}
.collection-modal {
width: 560px; max-height: 70vh; background: var(--color-surface); border-radius: 16px;
display: flex; flex-direction: column; box-shadow: 0 12px 40px rgba(0,0,0,0.15);
}
.modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 20px 24px 16px; border-bottom: 1px solid var(--color-border);
}
.modal-header h3 { margin: 0; font-size: 18px; }
.close-btn { cursor: pointer; font-size: 18px; color: var(--color-text-hint); }
.collection-grid {
flex: 1; overflow-y: auto; padding: 20px 24px;
display: grid; grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); gap: 16px;
}
.empty { grid-column: 1 / -1; text-align: center; color: var(--color-text-hint); padding: 30px; }
.collection-leaf {
display: flex; flex-direction: column; align-items: center; gap: 4px;
padding: 8px; border-radius: 10px; background: var(--color-bg); position: relative;
}
.mini-leaf { width: 56px; height: 67px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1)); }
.leaf-date { font-size: 11px; color: var(--color-text-hint); }
.leaf-mood { position: absolute; top: 4px; right: 4px; font-size: 14px; }
</style>
+295
View File
@@ -0,0 +1,295 @@
<template>
<div class="tree-view">
<div class="tree-header">
<h2>🌳 好友之树</h2>
<p class="subtitle">你们的友谊会长成一棵树</p>
</div>
<div v-if="trees.length === 0 && !loading" class="empty-state">
<div style="font-size: 56px">🌱</div>
<p>还没有好友加个好友一起种树吧</p>
</div>
<div v-else class="tree-content">
<!-- 好友选择 -->
<div class="friend-selector">
<div v-for="t in trees" :key="t.friend_id" class="friend-chip"
:class="{ active: selectedFriend === t.friend_id }"
@click="selectFriend(t.friend_id)">
<n-avatar :size="32" round :style="{ background: 'var(--color-primary)' }">
{{ (t.friend_name || '?')[0] }}
</n-avatar>
<span class="chip-emoji">{{ t.stage_emoji }}</span>
</div>
</div>
<!-- 树展示 -->
<div v-if="currentTree" class="tree-stage" :class="{ 'just-watered': waterAnim }">
<svg viewBox="0 0 200 220" class="tree-svg">
<!-- 地面 -->
<ellipse cx="100" cy="205" rx="60" ry="8" fill="rgba(139,90,43,0.15)" />
<!-- 种子阶段只显示种子 -->
<template v-if="currentTree.stage_index === 0">
<ellipse cx="100" cy="200" rx="10" ry="7" :fill="treeTrunkColor(style)" />
</template>
<!-- 树干 + 树冠 -->
<template v-else>
<rect :x="100 - style.trunkWidth / 2" y="140" :width="style.trunkWidth" :height="65"
:fill="treeTrunkColor(style)" rx="2" />
<g v-for="(p, i) in canopyPositions(style, 100, 100)" :key="i">
<circle :cx="p.cx" :cy="p.cy" :r="p.r" :fill="treeCanopyColor(style, i % 2 ? 5 : -3)" />
</g>
<!-- 露珠 -->
<g v-for="(d, i) in dropletPositions(style, 100, 100)" :key="'d'+i">
<circle :cx="d.x" :cy="d.y" r="2.5" fill="rgba(120,200,255,0.85)" />
<circle :cx="d.x - 0.8" :cy="d.y - 0.8" r="0.8" fill="rgba(255,255,255,0.9)" />
</g>
</template>
</svg>
<!-- 浇水时的花瓣飘落 -->
<div v-if="petals.length" class="petals">
<span v-for="(p, i) in petals" :key="i" class="petal" :style="p">🌸</span>
</div>
</div>
<!-- 信息卡片 -->
<div v-if="currentTree" class="tree-info">
<div class="stage-badge">
<span class="stage-emoji">{{ currentTree.stage_emoji }}</span>
<span class="stage-name">{{ currentTree.stage_name }}</span>
<span class="level">Lv.{{ currentTree.stage_index + 1 }}</span>
</div>
<div class="stats">
<div class="stat">
<span class="stat-value">{{ currentTree.message_count }}</span>
<span class="stat-label">条消息</span>
</div>
<div class="stat">
<span class="stat-value">{{ currentTree.water_count }}</span>
<span class="stat-label">次浇水</span>
</div>
<div class="stat">
<span class="stat-value">{{ currentTree.total_score }}</span>
<span class="stat-label">成长值</span>
</div>
</div>
<!-- 进度条 -->
<div v-if="currentTree.next_threshold" class="progress-section">
<div class="progress-bar">
<div class="progress-fill"
:style="{ width: progressPercent + '%' }"></div>
</div>
<span class="progress-text">距离{{ nextStageName }}还差 {{ currentTree.next_threshold - currentTree.total_score }} 成长值</span>
</div>
<div v-else class="max-stage">已达最高阶段这是一棵参天古树 🎉</div>
<!-- 浇水按钮 -->
<n-button type="primary" size="large" round :loading="watering" @click="doWater"
style="margin-top: 16px">
💧 {{ currentTree.friend_name }} 的树浇水
</n-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useMessage } from 'naive-ui'
import { treesApi } from '@/api/trees'
import {
generateTreeStyle, treeTrunkColor, treeCanopyColor,
canopyPositions, dropletPositions,
} from '@/utils/treeGenerator'
const message = useMessage()
const trees = ref<any[]>([])
const selectedFriend = ref<string | null>(null)
const currentTree = ref<any>(null)
const loading = ref(true)
const watering = ref(false)
const waterAnim = ref(false)
const petals = ref<any[]>([])
const STAGE_NAMES = ['种子', '萌芽', '幼苗', '小树', '大树', '古树']
const style = computed(() => {
if (!currentTree.value) return generateTreeStyle(0, '0000000000000000', 0)
return generateTreeStyle(currentTree.value.stage_index, currentTree.value.seed, currentTree.value.water_count)
})
const progressPercent = computed(() => {
if (!currentTree.value?.next_threshold) return 100
const cur = currentTree.value.stage_index
const curBase = cur === 0 ? 0 : [0, 11, 41, 151, 401, 1001][cur]
const next = currentTree.value.next_threshold
const range = next - curBase
const into = currentTree.value.total_score - curBase
return Math.min(100, Math.max(5, (into / range) * 100))
})
const nextStageName = computed(() => {
if (!currentTree.value) return ''
const nextIdx = currentTree.value.stage_index + 1
return STAGE_NAMES[nextIdx] || ''
})
onMounted(async () => {
await loadTrees()
})
async function loadTrees() {
loading.value = true
try {
const { data } = await treesApi.getAll()
trees.value = data
if (data.length > 0 && !selectedFriend.value) {
await selectFriend(data[0].friend_id)
}
} catch {
message.error('加载失败')
} finally {
loading.value = false
}
}
async function selectFriend(friendId: string) {
selectedFriend.value = friendId
try {
const { data } = await treesApi.getTree(friendId)
currentTree.value = data
} catch {
message.error('加载失败')
}
}
async function doWater() {
if (!selectedFriend.value) return
watering.value = true
waterAnim.value = true
setTimeout(() => (waterAnim.value = false), 800)
try {
const { data } = await treesApi.water(selectedFriend.value)
const leveledUp = data.leveled_up
currentTree.value = data
// 更新列表中的 emoji
const t = trees.value.find((t) => t.friend_id === selectedFriend.value)
if (t) t.stage_emoji = data.stage_emoji
if (leveledUp) {
message.success(`🎉 树长大了!现在是「${data.stage_name}`)
spawnPetals()
} else {
message.success('💧 浇水成功,+5 成长值')
}
} catch {
message.error('浇水失败')
} finally {
watering.value = false
}
}
function spawnPetals() {
petals.value = []
for (let i = 0; i < 12; i++) {
petals.value.push({
left: Math.random() * 80 + 10 + '%',
top: -20 + 'px',
animationDelay: Math.random() * 0.5 + 's',
fontSize: 16 + Math.random() * 12 + 'px',
})
}
setTimeout(() => (petals.value = []), 3000)
}
</script>
<style scoped>
.tree-view {
flex: 1; overflow-y: auto; padding: 24px;
background: linear-gradient(180deg, var(--color-bg) 0%, #E8F5E9 100%);
}
.tree-header { text-align: center; margin-bottom: 16px; }
.tree-header h2 { margin: 0; font-size: 22px; color: var(--color-primary-darker); }
.subtitle { font-size: 13px; color: var(--color-text-hint); margin: 4px 0 0; }
.empty-state { text-align: center; padding: 80px 20px; color: var(--color-text-hint); }
.empty-state p { margin-top: 12px; font-size: 14px; }
.tree-content { max-width: 480px; margin: 0 auto; }
.friend-selector {
display: flex; gap: 10px; overflow-x: auto; padding: 8px 0 16px;
justify-content: center; flex-wrap: wrap;
}
.friend-chip {
position: relative; cursor: pointer; padding: 4px;
border-radius: 50%; border: 2px solid transparent; transition: all 0.2s;
}
.friend-chip:hover { border-color: var(--color-primary-lighter); }
.friend-chip.active { border-color: var(--color-primary); }
.chip-emoji { position: absolute; bottom: -2px; right: -2px; font-size: 14px; }
/* 树舞台 */
.tree-stage {
position: relative; display: flex; justify-content: center; align-items: flex-end;
height: 280px; margin-bottom: 20px;
}
.tree-stage.just-watered { animation: water-shake 0.5s ease; }
@keyframes water-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-3px); }
75% { transform: translateX(3px); }
}
.tree-svg { width: 220px; height: 242px; filter: drop-shadow(0 6px 12px rgba(0,0,0,0.1)); }
/* 花瓣 */
.petals { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.petal {
position: absolute; animation: petal-fall 2.5s ease-in forwards;
}
@keyframes petal-fall {
0% { transform: translateY(0) rotate(0deg); opacity: 1; }
100% { transform: translateY(280px) rotate(360deg); opacity: 0; }
}
/* 信息卡 */
.tree-info {
background: var(--color-surface); border-radius: 16px; padding: 20px;
border: 1px solid var(--color-border); text-align: center;
}
.stage-badge {
display: flex; align-items: center; justify-content: center; gap: 8px;
margin-bottom: 16px;
}
.stage-emoji { font-size: 32px; }
.stage-name { font-size: 20px; font-weight: 700; color: var(--color-primary-darker); }
.level {
font-size: 12px; padding: 2px 8px; border-radius: 10px;
background: var(--color-primary-lightest); color: var(--color-primary); font-weight: 600;
}
.stats {
display: flex; justify-content: space-around; margin-bottom: 16px;
padding: 12px 0; border-top: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
}
.stat { display: flex; flex-direction: column; gap: 2px; }
.stat-value { font-size: 20px; font-weight: 700; color: var(--color-primary); }
.stat-label { font-size: 12px; color: var(--color-text-hint); }
.progress-section { margin-bottom: 8px; }
.progress-bar {
height: 8px; background: var(--color-border); border-radius: 4px;
overflow: hidden; margin-bottom: 6px;
}
.progress-fill {
height: 100%; background: linear-gradient(90deg, var(--color-primary-light), var(--color-primary));
border-radius: 4px; transition: width 0.6s;
}
.progress-text { font-size: 12px; color: var(--color-text-hint); }
.max-stage { font-size: 13px; color: var(--color-primary); margin-bottom: 8px; }
</style>