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
+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>