This commit is contained in:
2026-06-14 09:25:59 +08:00
parent a0f441d8ae
commit 6fbf610277
39 changed files with 2492 additions and 2 deletions
+128
View File
@@ -0,0 +1,128 @@
<template>
<div class="firefly-view">
<div class="ff-header">
<h2> 萤火虫时刻</h2>
<p class="subtitle">全服随机降临一起集气点亮掉落永不复刻的限定叶</p>
</div>
<!-- 状态卡 -->
<div class="status-card">
<div v-if="active" class="active-event">
<div class="event-title">🟢 萤火虫正在降临</div>
<div class="event-progress">
<div class="prog-bar"><div class="prog-fill" :style="{ width: (active.progress * 100) + '%' }"></div></div>
<span>{{ active.total_clicks }} / {{ active.target_clicks }}</span>
</div>
<div class="countdown"> 剩余 {{ countdown }}</div>
<p class="hint">点击屏幕中央的萤火虫面板参与集气</p>
</div>
<div v-else class="no-event">
<div class="no-icon">🌌</div>
<p>此刻森林很安静</p>
<p class="sub-hint">萤火虫会在不经意时降临留意闪烁的光点</p>
</div>
</div>
<!-- 图鉴 -->
<div class="album-section">
<h3>🌟 我的限定叶图鉴</h3>
<div v-if="album.length === 0" class="album-empty">
还没有收集到限定叶下次萤火虫降临别忘了参与
</div>
<div v-else class="album-grid">
<div v-for="(item, i) in album" :key="i" class="album-leaf">
<svg viewBox="0 0 100 120" class="album-svg">
<path :d="leafPath(generateLeafStyle(item.leaf_seed).shapeVariant)"
:fill="leafColor(generateLeafStyle(item.leaf_seed))" />
<circle v-for="j in 3" :key="j" :cx="30 + j*18" :cy="45 + (j%2)*25" r="2" fill="rgba(255,235,100,0.85)" />
</svg>
<span class="album-variant">{{ item.leaf_variant }}</span>
<span class="album-clicks">贡献 {{ item.my_clicks }} </span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { flashApi } from '@/api/flash'
import { generateLeafStyle, leafColor, leafPath } from '@/utils/leafGenerator'
const active = ref<any>(null)
const album = ref<any[]>([])
const now = ref(Date.now())
let pollTimer: ReturnType<typeof setInterval>
let tickTimer: ReturnType<typeof setInterval>
const countdown = computed(() => {
if (!active.value) return ''
const left = Math.max(0, Math.floor((new Date(active.value.end_at).getTime() - now.value) / 1000))
const m = Math.floor(left / 60)
const s = left % 60
return `${m}:${s.toString().padStart(2, '0')}`
})
async function poll() {
try {
const { data } = await flashApi.getActive()
active.value = data.event
} catch {}
}
onMounted(async () => {
await poll()
await loadAlbum()
pollTimer = setInterval(poll, 5000)
tickTimer = setInterval(() => (now.value = Date.now()), 1000)
})
onUnmounted(() => {
clearInterval(pollTimer)
clearInterval(tickTimer)
})
async function loadAlbum() {
try {
const { data } = await flashApi.getAlbum()
album.value = data
} catch {}
}
</script>
<style scoped>
.firefly-view {
flex: 1; overflow-y: auto; padding: 24px;
background: linear-gradient(180deg, var(--color-bg) 0%, #1a2e2a 200%);
}
.ff-header { text-align: center; margin-bottom: 20px; }
.ff-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; }
.status-card {
max-width: 480px; margin: 0 auto 24px; padding: 24px; border-radius: 16px;
background: var(--color-surface); border: 1px solid var(--color-border); text-align: center;
}
.active-event .event-title { font-size: 16px; color: var(--color-success); font-weight: 700; margin-bottom: 12px; }
.event-progress { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.prog-bar { flex: 1; height: 10px; background: var(--color-bg); border-radius: 5px; overflow: hidden; }
.prog-fill { height: 100%; background: linear-gradient(90deg, #FFC107, #FFEB3B); transition: width 0.4s; }
.countdown { font-size: 20px; font-weight: 700; color: #FF9800; margin: 8px 0; }
.hint { font-size: 12px; color: var(--color-text-hint); }
.no-event .no-icon { font-size: 56px; margin-bottom: 8px; }
.no-event p { margin: 4px 0; color: var(--color-text-secondary); }
.sub-hint { font-size: 12px; color: var(--color-text-hint) !important; }
.album-section { max-width: 560px; margin: 0 auto; }
.album-section h3 { font-size: 16px; color: var(--color-text-primary); margin-bottom: 12px; }
.album-empty { text-align: center; padding: 30px; color: var(--color-text-hint); font-size: 13px; background: var(--color-surface); border-radius: 12px; }
.album-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 12px; }
.album-leaf {
display: flex; flex-direction: column; align-items: center; gap: 4px;
padding: 12px 8px; background: var(--color-surface); border-radius: 12px;
border: 1px solid var(--color-border); position: relative;
}
.album-svg { width: 64px; height: 77px; filter: drop-shadow(0 2px 6px rgba(255,200,0,0.3)); }
.album-variant { font-size: 10px; color: var(--color-text-hint); font-family: monospace; }
.album-clicks { font-size: 11px; color: var(--color-primary); }
</style>
@@ -24,6 +24,22 @@
<span class="menu-icon"></span>
<span class="menu-label">时光胶囊</span>
</router-link>
<router-link to="/garden/sync" class="menu-item" active-class="active">
<span class="menu-icon">🌱</span>
<span class="menu-label">默契种子</span>
</router-link>
<router-link to="/garden/grove" class="menu-item" active-class="active">
<span class="menu-icon">🌲</span>
<span class="menu-label">情绪共鸣林</span>
</router-link>
<router-link to="/garden/heartbeat" class="menu-item" active-class="active">
<span class="menu-icon">💓</span>
<span class="menu-label">心跳同步</span>
</router-link>
<router-link to="/garden/firefly" class="menu-item" active-class="active">
<span class="menu-icon"></span>
<span class="menu-label">萤火虫时刻</span>
</router-link>
</div>
</div>
</template>
+50
View File
@@ -32,6 +32,51 @@
</div>
<span class="card-arrow"></span>
</div>
<div class="feature-card echo-card" @click="$router.push('/contacts')">
<div class="card-icon">🍃</div>
<div class="card-body">
<h3>念念回音</h3>
<p>在好友资料卡寄一片"我在想你"的回音叶</p>
</div>
<span class="card-arrow"></span>
</div>
<div class="feature-card sync-card" @click="$router.push('/garden/sync')">
<div class="card-icon">🌱</div>
<div class="card-body">
<h3>默契种子</h3>
<p>和好友盲答一题默契会长出独特的叶子</p>
</div>
<span class="card-arrow"></span>
</div>
<div class="feature-card grove-card" @click="$router.push('/garden/grove')">
<div class="card-icon">🌲</div>
<div class="card-body">
<h3>情绪共鸣林</h3>
<p>俯瞰朋友圈今日的情绪天气你并不孤单</p>
</div>
<span class="card-arrow"></span>
</div>
<div class="feature-card heartbeat-card" @click="$router.push('/garden/heartbeat')">
<div class="card-icon">💓</div>
<div class="card-body">
<h3>心跳同步森林</h3>
<p>你们的叶子在同一节拍上呼吸</p>
</div>
<span class="card-arrow"></span>
</div>
<div class="feature-card firefly-card" @click="$router.push('/garden/firefly')">
<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">
@@ -73,6 +118,11 @@
.leaf-card { border-left: 4px solid #66BB6A; }
.tree-card { border-left: 4px solid #8D6E63; }
.capsule-card { border-left: 4px solid #FFB74D; }
.echo-card { border-left: 4px solid #26A69A; }
.sync-card { border-left: 4px solid #9CCC65; }
.grove-card { border-left: 4px solid #7CB342; }
.heartbeat-card { border-left: 4px solid #EC407A; }
.firefly-card { border-left: 4px solid #FFC107; }
.garden-quote { text-align: center; margin-top: 48px; }
.garden-quote p { font-size: 13px; color: var(--color-text-hint); font-style: italic; margin: 0; }
+212
View File
@@ -0,0 +1,212 @@
<template>
<div class="grove-view">
<div class="grove-header">
<h2>🌲 情绪共鸣林</h2>
<p class="subtitle">{{ weatherText }}</p>
</div>
<div class="grove-stage" :class="weatherClass">
<!-- 天气遮罩层 -->
<div class="weather-overlay" :class="weatherClass"></div>
<!-- 雾气低落多时 -->
<div v-if="gloomyRatio > 0.5" class="mist"></div>
<!-- 暖光开心多时 -->
<div v-if="happyRatio > 0.5" class="warm-glow"></div>
<!-- 叶子们 -->
<div v-if="grove && grove.total > 0" class="forest">
<div v-for="(leaf, i) in grove.leaves" :key="i" class="grove-leaf"
:class="{ self: leaf.is_self }"
:style="leafPosition(leaf, i)">
<svg viewBox="0 0 100 120" class="mini-leaf-svg" :class="{ sway: !leaf.is_self }">
<path :d="leafPath(generateLeafStyle(leaf.leaf_seed).shapeVariant)"
:fill="leaf.is_self ? leafColor(generateLeafStyle(leaf.leaf_seed), 15) : leafColor(generateLeafStyle(leaf.leaf_seed))"
:opacity="leaf.is_self ? 1 : 0.85" />
</svg>
<span v-if="leaf.mood" class="leaf-mood-emoji">{{ moodEmoji(leaf.mood) }}</span>
<div v-if="leaf.is_self" class="self-glow"></div>
</div>
</div>
<div v-else class="empty-forest">
<div style="font-size: 56px">🌳</div>
<p>今天还是一片空林</p>
<p class="empty-hint">领取你的今日心情叶森林就会亮起来</p>
<n-button type="primary" round size="small" @click="$router.push('/garden/leaf')">去领今日叶</n-button>
</div>
</div>
<!-- 情绪统计 -->
<div v-if="grove && grove.total > 0" class="mood-stats">
<span class="stat-label">今日朋友圈情绪</span>
<div class="mood-bars">
<div v-for="m in moodStats" :key="m.key" class="mood-bar">
<span class="mood-emoji">{{ m.emoji }}</span>
<div class="bar-track">
<div class="bar-fill" :style="{ width: m.percent + '%', background: m.color }"></div>
</div>
<span class="mood-count">{{ m.count }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { leavesApi } from '@/api/leaves'
import { generateLeafStyle, leafColor, leafPath } from '@/utils/leafGenerator'
const grove = ref<any>(null)
const MOOD_META: Record<string, { emoji: string; color: string; happy: number }> = {
happy: { emoji: '😊', color: '#FFD54F', happy: 1 },
energetic: { emoji: '⚡', color: '#66BB6A', happy: 1 },
grateful: { emoji: '🙏', color: '#26A69A', happy: 1 },
calm: { emoji: '🌙', color: '#90A4AE', happy: 0.5 },
thoughtful: { emoji: '🤔', color: '#7E57C2', happy: 0.3 },
lazy: { emoji: '😴', color: '#78909C', happy: 0.2 },
unknown: { emoji: '🍃', color: '#A5D6A7', happy: 0.5 },
}
const happyRatio = computed(() => {
if (!grove.value) return 0
const counts = grove.value.mood_counts
let happy = 0, total = 0
for (const [k, v] of Object.entries(counts)) {
total += v as number
if ((MOOD_META[k]?.happy || 0.5) >= 1) happy += v as number
}
return total ? happy / total : 0
})
const gloomyRatio = computed(() => {
if (!grove.value) return 0
const counts = grove.value.mood_counts
let gloomy = 0, total = 0
for (const [k, v] of Object.entries(counts)) {
total += v as number
if ((MOOD_META[k]?.happy || 0.5) <= 0.3) gloomy += v as number
}
return total ? gloomy / total : 0
})
const weatherClass = computed(() => {
if (happyRatio.value > 0.5) return 'sunny'
if (gloomyRatio.value > 0.5) return 'misty'
return 'balanced'
})
const weatherText = computed(() => {
if (!grove.value || grove.value.total === 0) return '俯瞰整片森林的情绪天气'
if (happyRatio.value > 0.5) return '☀️ 今天朋友圈阳光正好,暖意融融'
if (gloomyRatio.value > 0.5) return '🌫️ 今天林子有些低沉,给朋友一个拥抱吧'
return '🌤️ 森林平静地呼吸着'
})
const moodStats = computed(() => {
if (!grove.value) return []
const counts = grove.value.mood_counts
const total = grove.value.total
return Object.entries(counts).map(([k, v]) => ({
key: k,
emoji: MOOD_META[k]?.emoji || '🍃',
color: MOOD_META[k]?.color || '#A5D6A7',
count: v as number,
percent: Math.round(((v as number) / total) * 100),
})).sort((a, b) => b.count - a.count)
})
function leafPosition(leaf: any, i: number): Record<string, string> {
if (leaf.is_self) {
return { left: '50%', top: '50%', transform: 'translate(-50%, -50%)' }
}
// 环形排布
const angle = leaf.angle
const radius = 38 + (i % 2) * 6 // %
const x = 50 + Math.cos(angle) * radius
const y = 50 + Math.sin(angle) * radius * 0.7
return { left: x + '%', top: y + '%', transform: 'translate(-50%, -50%)' }
}
function moodEmoji(mood: string): string {
return MOOD_META[mood]?.emoji || ''
}
onMounted(async () => {
try {
const { data } = await leavesApi.getGrove()
grove.value = data
} catch {}
})
</script>
<style scoped>
.grove-view {
flex: 1; overflow-y: auto; padding: 24px;
}
.grove-header { text-align: center; margin-bottom: 12px; }
.grove-header h2 { margin: 0; font-size: 22px; color: var(--color-primary-darker); }
.subtitle { font-size: 14px; color: var(--color-text-secondary); margin: 6px 0 0; }
.grove-stage {
position: relative; height: 420px; max-width: 560px; margin: 0 auto 20px;
border-radius: 20px; overflow: hidden;
background: radial-gradient(ellipse at center, #E8F5E9 0%, #C8E6C9 100%);
transition: background 1s;
}
.grove-stage.misty { background: radial-gradient(ellipse at center, #CFD8DC 0%, #B0BEC5 100%); }
.grove-stage.sunny { background: radial-gradient(ellipse at center, #FFF8E1 0%, #FFE082 100%); }
.weather-overlay { position: absolute; inset: 0; pointer-events: none; }
.mist {
position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(180deg, rgba(255,255,255,0) 0%, rgba(200,210,215,0.5) 100%);
backdrop-filter: blur(1px); animation: mist-drift 8s ease-in-out infinite;
}
@keyframes mist-drift { 0%,100% { opacity: 0.6; } 50% { opacity: 0.9; } }
.warm-glow {
position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(circle at 50% 40%, rgba(255,213,79,0.35) 0%, transparent 60%);
animation: glow-pulse 4s ease-in-out infinite;
}
@keyframes glow-pulse { 0%,100% { opacity: 0.7; } 50% { opacity: 1; } }
.forest { position: absolute; inset: 0; }
.grove-leaf { position: absolute; transition: all 0.6s ease; }
.grove-leaf.self { z-index: 10; }
.mini-leaf-svg { width: 48px; height: 58px; filter: drop-shadow(0 3px 4px rgba(0,0,0,0.15)); }
.grove-leaf.self .mini-leaf-svg { width: 72px; height: 86px; }
.mini-leaf-svg.sway { animation: grove-sway 3s ease-in-out infinite; transform-origin: 50% 90%; }
.mini-leaf-svg.sway:nth-child(odd) { animation-delay: -1.5s; }
@keyframes grove-sway { 0%,100% { transform: rotate(-2deg); } 50% { transform: rotate(2deg); } }
.leaf-mood-emoji { position: absolute; top: -6px; right: -6px; font-size: 16px; }
.self-glow {
position: absolute; inset: -16px; border-radius: 50%;
background: radial-gradient(circle, rgba(0,200,150,0.4) 0%, transparent 70%);
animation: self-pulse 2s ease-in-out infinite; pointer-events: none;
}
@keyframes self-pulse { 0%,100% { transform: scale(1); opacity: 0.6; } 50% { transform: scale(1.2); opacity: 1; } }
.empty-forest {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 8px; color: var(--color-text-hint);
}
.empty-hint { font-size: 12px; }
.mood-stats {
max-width: 560px; margin: 0 auto; background: var(--color-surface);
border-radius: 14px; padding: 16px 20px; border: 1px solid var(--color-border);
}
.stat-label { font-size: 13px; color: var(--color-text-secondary); font-weight: 500; display: block; margin-bottom: 12px; }
.mood-bars { display: flex; flex-direction: column; gap: 8px; }
.mood-bar { display: flex; align-items: center; gap: 8px; }
.mood-emoji { font-size: 16px; width: 24px; text-align: center; }
.bar-track { flex: 1; height: 10px; background: var(--color-bg); border-radius: 5px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 5px; transition: width 0.8s; }
.mood-count { font-size: 12px; color: var(--color-text-hint); width: 20px; text-align: right; }
</style>
+187
View File
@@ -0,0 +1,187 @@
<template>
<div class="heartbeat-view">
<div class="hb-header">
<h2>💓 心跳同步森林</h2>
<p class="subtitle">你们的呼吸在同一节拍上</p>
</div>
<div v-if="trees.length === 0 && !loading" class="empty">
<div style="font-size: 56px">🌱</div>
<p>加个好友开始同步心跳</p>
</div>
<template v-else>
<!-- 好友选择 -->
<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>
</div>
</div>
<!-- 心跳舞台 -->
<div v-if="hb" class="hb-stage">
<!-- 两片叶子 -->
<div class="leaves-pair">
<!-- 我的叶子 -->
<div class="leaf-side">
<svg viewBox="0 0 100 120" class="hb-leaf mine" :style="myLeafStyle">
<path :d="leafPath(myStyle.shapeVariant)" :fill="leafColor(myStyle)" />
<g stroke="rgba(255,255,255,0.4)" stroke-width="0.8" fill="none">
<path v-for="(vp, i) in veinPaths(myStyle)" :key="i" :d="vp" />
</g>
</svg>
<span class="leaf-label"></span>
</div>
<!-- 连接线心跳波 -->
<div class="hb-link" :style="{ animationDuration: cycleMs + 'ms' }">
<span class="pulse-dot" :style="{ animationDuration: cycleMs + 'ms' }"></span>
</div>
<!-- 好友的叶子 -->
<div class="leaf-side" :class="{ offline: !hb.is_online }">
<svg viewBox="0 0 100 120" class="hb-leaf friend" :style="friendLeafStyle">
<path :d="leafPath(friendStyle.shapeVariant)" :fill="leafColor(friendStyle)" />
<g stroke="rgba(255,255,255,0.4)" stroke-width="0.8" fill="none">
<path v-for="(vp, i) in veinPaths(friendStyle)" :key="i" :d="vp" />
</g>
</svg>
<span class="leaf-label">{{ hb.friend_name }}</span>
<span v-if="!hb.is_online" class="offline-tag">已下线</span>
</div>
</div>
<!-- BPM 信息 -->
<div class="bpm-info">
<span class="bpm-num">{{ hb.bpm }}</span>
<span class="bpm-unit">BPM</span>
<span class="bpm-desc">{{ bpmDesc }}</span>
</div>
<div class="hb-stats"> 7 {{ hb.msg_7d }} 条消息 · {{ hb.is_online ? '在线' : '离线' }}</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { treesApi } from '@/api/trees'
import { generateLeafStyle, leafColor, leafPath, veinPaths } from '@/utils/leafGenerator'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const trees = ref<any[]>([])
const selectedFriend = ref<string | null>(null)
const hb = ref<any>(null)
const loading = ref(true)
const myStyle = computed(() => {
const seed = (auth.user?.id || '0').padEnd(16, '0').slice(0, 16)
return generateLeafStyle(seed)
})
const friendStyle = computed(() =>
hb.value ? generateLeafStyle(hb.value.friend_leaf_seed) : generateLeafStyle('0000000000000000')
)
// 一次呼吸的毫秒数(BPM → 周期)。心跳周期 = 60s/bpm
const cycleMs = computed(() => Math.round(60000 / (hb.value?.bpm || 60)))
// 关键同步巧思:基于墙钟时间算相位,双方天然同步
// animation-delay 让动画的"起点"对齐到全局时间,即使双方分别加载也对齐
const phaseDelay = computed(() => {
const now = Date.now()
const cycle = cycleMs.value
return -(now % cycle) + 'ms'
})
const myLeafStyle = computed(() => ({
animation: `heartbeat-breathe ${cycleMs.value}ms ease-in-out infinite`,
animationDelay: phaseDelay.value,
}))
const friendLeafStyle = computed(() => ({
animation: `heartbeat-breathe ${cycleMs.value}ms ease-in-out infinite`,
animationDelay: phaseDelay.value,
filter: hb.value?.is_online ? 'none' : 'grayscale(0.6) opacity(0.5)',
}))
const bpmDesc = computed(() => {
const bpm = hb.value?.bpm || 0
if (bpm < 50) return '沉睡中,静静陪伴'
if (bpm < 60) return '平静的呼吸'
if (bpm < 75) return '温暖的同频'
if (bpm < 85) return '活跃的心跳'
return '热烈的共振'
})
onMounted(async () => {
loading.value = true
try {
const { data } = await treesApi.getAll()
trees.value = data
if (data.length > 0) await selectFriend(data[0].friend_id)
} catch {} finally {
loading.value = false
}
})
async function selectFriend(friendId: string) {
selectedFriend.value = friendId
try {
const { data } = await treesApi.heartbeat(friendId)
hb.value = data
} catch {}
}
</script>
<style scoped>
.heartbeat-view {
flex: 1; overflow-y: auto; padding: 24px;
background: linear-gradient(180deg, var(--color-bg) 0%, #E8F5E9 100%);
}
.hb-header { text-align: center; margin-bottom: 16px; }
.hb-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; color: var(--color-text-hint); }
.friend-selector { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-bottom: 24px; }
.friend-chip { cursor: pointer; padding: 3px; border-radius: 50%; border: 2px solid transparent; }
.friend-chip:hover { border-color: var(--color-primary-lighter); }
.friend-chip.active { border-color: var(--color-primary); }
.hb-stage { max-width: 480px; margin: 0 auto; text-align: center; }
.leaves-pair { display: flex; align-items: center; justify-content: center; gap: 12px; height: 240px; }
.leaf-side { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.leaf-side.offline .hb-leaf { animation: leaf-fall 2s ease forwards; }
@keyframes leaf-fall {
0% { transform: rotate(0); }
100% { transform: rotate(30deg) translateY(20px); opacity: 0.4; }
}
.hb-leaf { width: 110px; height: 132px; filter: drop-shadow(0 6px 12px rgba(0,150,136,0.2)); transform-origin: 50% 100%; }
@keyframes heartbeat-breathe {
0%, 100% { transform: scale(0.92); }
50% { transform: scale(1.05); }
}
.leaf-label { font-size: 12px; color: var(--color-text-secondary); }
.offline-tag { font-size: 10px; color: var(--color-text-hint); }
.hb-link { flex: 0 0 60px; height: 4px; background: var(--color-border); border-radius: 2px; position: relative; overflow: hidden; }
.pulse-dot {
position: absolute; top: -3px; left: 0; width: 10px; height: 10px;
border-radius: 50%; background: var(--color-primary);
animation: pulse-travel linear infinite;
box-shadow: 0 0 8px var(--color-primary);
}
@keyframes pulse-travel {
0% { left: -10px; }
100% { left: 60px; }
}
.bpm-info { margin-top: 20px; }
.bpm-num { font-size: 48px; font-weight: 800; color: var(--color-primary); }
.bpm-unit { font-size: 16px; color: var(--color-text-hint); margin-left: 4px; }
.bpm-desc { display: block; font-size: 14px; color: var(--color-text-secondary); margin-top: 4px; }
.hb-stats { font-size: 12px; color: var(--color-text-hint); margin-top: 8px; }
</style>
+198
View File
@@ -0,0 +1,198 @@
<template>
<div class="sync-view">
<div class="sync-header">
<h2>🌱 默契种子</h2>
<p class="subtitle">和好友盲答同一题看看默契长出什么样的叶子</p>
</div>
<div class="sync-main">
<!-- 今日题目 -->
<div v-if="question" class="question-card">
<div class="q-label">今日默契题</div>
<div class="q-content">{{ question.content }}</div>
</div>
<!-- 选好友 + 作答 -->
<div v-if="question" class="answer-section">
<div class="form-row">
<span class="form-label">和谁默契</span>
<n-select v-model:value="selectedFriend" :options="friendOptions" placeholder="选择好友" />
</div>
<div class="form-row">
<span class="form-label">你的答案</span>
<n-input v-model:value="myAnswer" type="textarea" :rows="3"
placeholder="凭直觉写,越真诚越默契..." maxlength="500" show-count />
</div>
<n-button type="primary" round block :loading="submitting"
:disabled="!selectedFriend || !myAnswer.trim()" @click="submit">
种下默契种子
</n-button>
</div>
<!-- 结果区 -->
<div v-if="result" class="result-card" :class="{ revealed: result.score != null }">
<template v-if="result.score != null">
<!-- 揭晓 -->
<div class="leaf-stage">
<svg viewBox="0 0 100 120" class="result-leaf" :class="{ bloom: true }">
<defs>
<linearGradient id="sync-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" :stop-color="leafColor(style, 15)" />
<stop offset="100%" :stop-color="leafColor(style, -10)" />
</linearGradient>
</defs>
<path :d="leafPath(style.shapeVariant)" fill="url(#sync-grad)" />
<g stroke="rgba(255,255,255,0.4)" stroke-width="0.8" fill="none">
<path v-for="(vp, i) in veinPaths(style)" :key="i" :d="vp" />
</g>
</svg>
</div>
<div class="score-display">
<span class="score-num">{{ result.score }}</span>
<span class="score-unit">默契度</span>
</div>
<div class="score-comment">{{ scoreComment(result.score) }}</div>
<div class="answers-compare">
<div class="answer-box mine">
<span class="ans-label"></span>
<span class="ans-text">{{ result.my_answer }}</span>
</div>
<div class="answer-box partner">
<span class="ans-label">TA</span>
<span class="ans-text">{{ result.partner_answer }}</span>
</div>
</div>
</template>
<template v-else>
<!-- 等待对方 -->
<div class="waiting">
<div class="seed-sprout">🌱</div>
<p>种子已种下 TA 来回答</p>
<p class="waiting-hint">你们都答完默契叶就会发芽</p>
</div>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useMessage } from 'naive-ui'
import { syncApi } from '@/api/sync'
import { friendsApi } from '@/api/friends'
import { generateLeafStyle, leafColor, leafPath, veinPaths } from '@/utils/leafGenerator'
const message = useMessage()
const question = ref<any>(null)
const friends = ref<any[]>([])
const selectedFriend = ref<string | null>(null)
const myAnswer = ref('')
const submitting = ref(false)
const result = ref<any>(null)
const friendOptions = computed(() =>
friends.value.map((f) => ({
label: f.remark || f.nickname || f.username,
value: f.friend_user_id,
}))
)
const style = computed(() =>
result.value?.leaf_seed
? generateLeafStyle(result.value.leaf_seed)
: generateLeafStyle('0000000000000000')
)
onMounted(async () => {
try {
const { data } = await syncApi.getQuestion()
question.value = data
} catch {
message.error('加载题目失败')
}
try {
const { data } = await friendsApi.getFriends()
friends.value = data
} catch {}
})
async function submit() {
if (!selectedFriend.value || !myAnswer.value.trim()) return
submitting.value = true
try {
const { data } = await syncApi.submitAnswer(question.value.id, selectedFriend.value, myAnswer.value.trim())
result.value = { ...data, my_answer: myAnswer.value.trim() }
if (data.score != null) {
message.success('默契叶发芽了!')
} else {
message.success('种子已种下,等 TA 回答')
}
} catch (e: any) {
message.error(e.response?.data?.detail || '提交失败')
} finally {
submitting.value = false
}
}
function scoreComment(score: number): string {
if (score >= 80) return '心有灵犀!这片叶子饱满得像要发光 ✨'
if (score >= 60) return '相当默契,叶子长得很精神 🌿'
if (score >= 40) return '有点默契,叶子还在成长 🌱'
if (score >= 20) return '需要多聊聊,叶子还嫩着呢'
return '可能想到了不同的方向,但这也很可爱'
}
</script>
<style scoped>
.sync-view {
flex: 1; overflow-y: auto; padding: 24px;
background: linear-gradient(180deg, var(--color-bg) 0%, var(--color-primary-lightest) 100%);
}
.sync-header { text-align: center; margin-bottom: 20px; }
.sync-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; }
.sync-main { max-width: 480px; margin: 0 auto; }
.question-card {
background: var(--color-surface); border-radius: 14px; padding: 20px;
margin-bottom: 16px; border-left: 4px solid var(--color-primary);
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.q-label { font-size: 12px; color: var(--color-primary); font-weight: 600; margin-bottom: 6px; }
.q-content { font-size: 17px; font-weight: 600; color: var(--color-text-primary); line-height: 1.5; }
.answer-section { background: var(--color-surface); border-radius: 14px; padding: 20px; margin-bottom: 16px; }
.form-row { margin-bottom: 16px; }
.form-label { display: block; font-size: 13px; color: var(--color-text-secondary); margin-bottom: 6px; font-weight: 500; }
.result-card {
background: var(--color-surface); border-radius: 14px; padding: 24px; text-align: center;
border: 1px solid var(--color-border);
}
.result-card.revealed { border-color: var(--color-primary); box-shadow: 0 4px 16px rgba(0,150,136,0.15); }
.leaf-stage { display: flex; justify-content: center; margin-bottom: 12px; filter: drop-shadow(0 6px 12px rgba(0,150,136,0.2)); }
.result-leaf { width: 120px; height: 144px; }
.result-leaf.bloom { animation: bloom 0.8s ease; }
@keyframes bloom { 0% { transform: scale(0.3); opacity: 0; } 60% { transform: scale(1.15); } 100% { transform: scale(1); opacity: 1; } }
.score-display { margin-bottom: 4px; }
.score-num { font-size: 48px; font-weight: 800; color: var(--color-primary); }
.score-unit { font-size: 14px; color: var(--color-text-hint); margin-left: 4px; }
.score-comment { font-size: 14px; color: var(--color-text-secondary); margin-bottom: 20px; }
.answers-compare { display: flex; gap: 12px; text-align: left; }
.answer-box { flex: 1; padding: 12px; border-radius: 10px; background: var(--color-bg); }
.answer-box.mine { border-left: 3px solid var(--color-primary); }
.answer-box.partner { border-left: 3px solid var(--color-primary-lighter); }
.ans-label { display: block; font-size: 11px; font-weight: 700; color: var(--color-primary); margin-bottom: 4px; }
.ans-text { font-size: 13px; color: var(--color-text-primary); line-height: 1.5; }
.waiting { padding: 30px 0; }
.seed-sprout { font-size: 64px; animation: sprout 2s ease-in-out infinite; }
@keyframes sprout { 0%,100% { transform: scale(1); } 50% { transform: scale(1.1); } }
.waiting p { margin: 8px 0; color: var(--color-text-secondary); }
.waiting-hint { font-size: 12px; color: var(--color-text-hint) !important; }
</style>