1.6
This commit is contained in:
@@ -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),
|
||||
}
|
||||
@@ -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'),
|
||||
}
|
||||
@@ -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`),
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 path(viewBox 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user