This commit is contained in:
2026-06-14 10:01:47 +08:00
parent 6fbf610277
commit ca39190ad7
11 changed files with 556 additions and 13 deletions
+146 -5
View File
@@ -1,7 +1,61 @@
<template>
<div class="welcome-panel">
<div class="welcome-bg"></div>
<div class="welcome-content">
<!-- 空状态邀请你最重要的人 -->
<div v-if="isEmpty" class="onboarding-content">
<div class="seed-illustration">
<div class="seed-glow"></div>
<div class="seed-emoji">🌱</div>
</div>
<h1 class="onb-title">种下你们的第一棵树</h1>
<p class="onb-subtitle">青叶不是又一个微信<br>这里是为你<strong>最重要的那几个人</strong>准备的花园</p>
<!-- 价值主张 -->
<div class="value-props">
<div class="vp-item">
<span class="vp-icon">🌳</span>
<span class="vp-text">你们的友谊会长成一棵树</span>
</div>
<div class="vp-item">
<span class="vp-icon">🍃</span>
<span class="vp-text">想念 TA就寄一片回音叶</span>
</div>
<div class="vp-item">
<span class="vp-icon">💓</span>
<span class="vp-text">深夜里叶子陪你们同步呼吸</span>
</div>
</div>
<!-- 邀请那一个人 -->
<div class="invite-box">
<p class="invite-label">第一步邀请最重要的那个人</p>
<div class="invite-text">{{ inviteText }}</div>
<div class="invite-actions">
<n-button type="primary" round @click="copyInvite">📋 复制邀请</n-button>
<n-button quaternary round @click="$router.push('/contacts/search')">我已经有好友了</n-button>
</div>
</div>
</div>
<!-- 有内容时的欢迎页 -->
<div v-else class="welcome-content">
<!-- 今日花园状态每日召回 -->
<div v-if="daily" class="daily-banner" @click="$router.push('/garden/tree')">
<div class="daily-streak">
<span class="streak-fire">🔥</span>
<div>
<span class="streak-num">{{ daily.streak }}</span>
<span class="streak-label">天连续</span>
</div>
</div>
<div v-if="daily.thirsty_count > 0" class="daily-thirsty">
<span class="thirsty-icon">💧</span>
<span>{{ daily.thirsty_count }} 棵树渴了去浇水</span>
</div>
<div v-else class="daily-ok">今日花园已照料 🌿</div>
</div>
<div class="welcome-header">
<div class="logo-circle">🌿</div>
<h1 class="app-name">青叶</h1>
@@ -55,9 +109,9 @@
<span class="action-icon">🌿</span>
<span class="action-text">朋友圈</span>
</div>
<div class="action-card" @click="$router.push('/settings')">
<span class="action-icon"></span>
<span class="action-text">设置</span>
<div class="action-card" @click="$router.push('/garden')">
<span class="action-icon"></span>
<span class="action-text">花园</span>
</div>
</div>
</div>
@@ -67,12 +121,18 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useMessage } from 'naive-ui'
import { useChatStore } from '@/stores/chat'
import { useAuthStore } from '@/stores/auth'
import { friendsApi } from '@/api/friends'
import { gardenApi } from '@/api/garden'
import CreateGroupModal from './CreateGroupModal.vue'
const chatStore = useChatStore()
const auth = useAuthStore()
const message = useMessage()
const friendCount = ref(0)
const daily = ref<any>(null)
const showCreateGroup = ref(false)
const totalUnread = computed(() =>
@@ -83,25 +143,85 @@ const recentConversations = computed(() =>
chatStore.conversations.slice(0, 3)
)
// 空状态:既无会话又无好友 → 显示邀请引导
const isEmpty = computed(() =>
chatStore.conversations.length === 0 && friendCount.value === 0
)
const inviteText = computed(() => {
const name = auth.user?.nickname || auth.user?.username || '我'
return `嘿,我在用一个叫「青叶」的 app——它不是聊天软件,是一座花园。我们的友谊在里面会长成一棵树,想念你时我能寄一片叶子。来注册陪我种树吧:${window.location.origin}/register`
})
function avatarStyle(conv: any) {
const colors = ['#009688', '#26A69A', '#00796B', '#00897B', '#4DB6AC']
const idx = (conv.name || '').charCodeAt(0) % colors.length
return { background: colors[idx] }
}
async function copyInvite() {
try {
await navigator.clipboard.writeText(inviteText.value)
message.success('邀请已复制,去微信/qq 粘贴给那个最重要的人吧')
} catch {
message.info(inviteText.value)
}
}
onMounted(async () => {
try {
const { data } = await friendsApi.getFriends()
friendCount.value = Array.isArray(data) ? data.length : 0
} catch {}
try {
const { data } = await gardenApi.getDailyStatus()
daily.value = data
} catch {}
})
</script>
<style scoped>
.welcome-panel {
flex: 1; display: flex; align-items: center; justify-content: center;
position: relative; overflow: hidden;
position: relative; overflow: auto; padding: 20px;
}
/* 空状态:邀请引导 */
.onboarding-content {
position: relative; width: 420px; max-width: 100%; text-align: center;
animation: fadeUp 0.6s ease;
}
.seed-illustration { position: relative; width: 96px; height: 96px; margin: 0 auto 16px; }
.seed-glow {
position: absolute; inset: -20px; border-radius: 50%;
background: radial-gradient(circle, rgba(0,200,150,0.35) 0%, transparent 70%);
animation: seed-pulse 2.5s ease-in-out infinite;
}
@keyframes seed-pulse { 0%,100% { transform: scale(0.9); opacity: 0.6; } 50% { transform: scale(1.1); opacity: 1; } }
.seed-emoji { position: relative; font-size: 64px; line-height: 96px; }
.onb-title { font-size: 24px; font-weight: 700; color: var(--color-primary-darker); margin: 0 0 8px; }
.onb-subtitle { font-size: 14px; color: var(--color-text-secondary); line-height: 1.7; margin: 0 0 24px; }
.onb-subtitle strong { color: var(--color-primary); }
.value-props { display: flex; flex-direction: column; gap: 10px; margin-bottom: 28px; text-align: left; }
.vp-item {
display: flex; align-items: center; gap: 12px; padding: 12px 16px;
background: var(--color-surface); border-radius: 12px; border: 1px solid var(--color-border);
}
.vp-icon { font-size: 24px; }
.vp-text { font-size: 14px; color: var(--color-text-primary); }
.invite-box {
background: linear-gradient(135deg, var(--color-primary-lightest), var(--color-surface));
border: 1px solid var(--color-primary-lighter); border-radius: 16px; padding: 20px;
}
.invite-label { font-size: 13px; color: var(--color-primary); font-weight: 600; margin: 0 0 10px; }
.invite-text {
font-size: 12px; color: var(--color-text-secondary); line-height: 1.6; text-align: left;
background: var(--color-surface); padding: 10px 12px; border-radius: 8px; margin-bottom: 12px;
border: 1px solid var(--color-border);
}
.invite-actions { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; }
.welcome-bg {
position: absolute; inset: 0;
background: radial-gradient(ellipse at 30% 20%, rgba(0,150,136,0.08) 0%, transparent 50%),
@@ -117,6 +237,27 @@ onMounted(async () => {
}
.welcome-header { margin-bottom: 32px; }
/* 今日花园状态横幅 */
.daily-banner {
display: flex; align-items: center; gap: 16px; padding: 14px 18px; margin-bottom: 24px;
background: linear-gradient(135deg, var(--color-surface), var(--color-primary-lightest));
border: 1px solid var(--color-primary-lighter); border-radius: 14px; cursor: pointer;
transition: transform 0.2s;
}
.daily-banner:hover { transform: translateY(-2px); }
.daily-streak { display: flex; align-items: center; gap: 10px; }
.streak-fire { font-size: 28px; animation: flicker 1.5s ease-in-out infinite; }
@keyframes flicker { 0%,100% { transform: scale(1); } 50% { transform: scale(1.15); } }
.streak-num { font-size: 22px; font-weight: 800; color: var(--color-primary); }
.streak-label { font-size: 12px; color: var(--color-text-hint); margin-left: 4px; }
.daily-thirsty {
display: flex; align-items: center; gap: 6px; margin-left: auto;
font-size: 13px; color: #FF9800; font-weight: 500;
}
.thirsty-icon { font-size: 18px; animation: drip 1.8s ease-in-out infinite; }
@keyframes drip { 0%,100% { transform: translateY(0); } 50% { transform: translateY(3px); } }
.daily-ok { margin-left: auto; font-size: 13px; color: var(--color-success); }
.logo-circle {
width: 72px; height: 72px; border-radius: 50%;
background: linear-gradient(135deg, #009688, #26A69A);
+59 -1
View File
@@ -54,6 +54,20 @@
</div>
<div class="chat-body">
<!-- 关系花园背景私聊时你们的树在背景里轻轻呼吸 -->
<div v-if="convDetail?.type === 'private' && gardenBg" class="garden-bg">
<svg class="bg-tree" viewBox="0 0 200 220" preserveAspectRatio="xMidYMax meet">
<template v-if="gardenBg.stage_index > 0">
<rect :x="100 - bgStyle.trunkWidth/2" y="140" :width="bgStyle.trunkWidth" height="65" :fill="treeTrunkColor(bgStyle)" rx="2" opacity="0.18" />
<g v-for="(p, i) in canopyPositions(bgStyle, 100, 100)" :key="i">
<circle :cx="p.cx" :cy="p.cy" :r="p.r" :fill="treeCanopyColor(bgStyle, i%2?5:-3)" opacity="0.14" />
</g>
</template>
<ellipse v-else cx="100" cy="200" rx="10" ry="7" :fill="treeTrunkColor(bgStyle)" opacity="0.18" />
</svg>
<div class="bg-glow" :style="{ animationDuration: bgBreathMs + 'ms' }"></div>
</div>
<div class="message-list" ref="messageListRef" @contextmenu.prevent @scroll="onScroll">
<!-- 加载更多指示器 -->
<div v-if="chatStore.isLoadingMore" class="load-more-indicator">
@@ -184,6 +198,10 @@ import { useUiStore } from '@/stores/ui'
import { useWebSocket } from '@/composables/useWebSocket'
import { chatApi } from '@/api/chat'
import { climatesApi } from '@/api/climates'
import { treesApi } from '@/api/trees'
import {
generateTreeStyle, treeTrunkColor, treeCanopyColor, canopyPositions,
} from '@/utils/treeGenerator'
import api from '@/api/client'
import GroupInfoPanel from './GroupInfoPanel.vue'
import dayjs from 'dayjs'
@@ -218,8 +236,20 @@ const forwardSender = ref('')
const climate = ref<any>(null)
const calendar = ref<any[]>([])
const showCalendar = ref(false)
const gardenBg = ref<any>(null) // 关系花园背景:好友之树数据
const emojis = EMOJIS
const bgStyle = computed(() => gardenBg.value
? generateTreeStyle(gardenBg.value.stage_index, gardenBg.value.seed, gardenBg.value.water_count)
: generateTreeStyle(0, '0000', 0))
// 呼吸周期:BPM 越高(越亲密)呼吸越快,60-90s
const bgBreathMs = computed(() => {
const score = gardenBg.value?.total_score || 0
const bpm = Math.min(75, 50 + score / 10)
return Math.round(60000 / bpm)
})
const SEASON_LABEL: Record<string, string> = {
spring: '春', summer: '夏', autumn: '秋', winter: '冬',
}
@@ -268,6 +298,7 @@ onMounted(async () => {
scrollToBottom()
markRead()
loadClimate(id)
loadGardenBg()
}
document.addEventListener('click', closeCtxMenu)
})
@@ -302,6 +333,17 @@ async function loadClimate(id: string) {
} catch {}
}
async function loadGardenBg() {
// 私聊时加载关系之树作为背景
if (convDetail.value?.type !== 'private') return
const other = convDetail.value.members?.find((m: any) => m.user_id !== auth.user?.id)
if (!other) return
try {
const { data } = await treesApi.getTree(other.user_id)
gardenBg.value = data
} catch {}
}
function markRead() {
const msgs = chatStore.currentMessages
if (msgs.length > 0 && chatStore.activeConversation) {
@@ -568,7 +610,23 @@ function formatTimeDivider(time: string) {
.cal-empty { grid-column: 1 / -1; text-align: center; font-size: 12px; color: var(--color-text-hint); padding: 12px; }
.chat-body { flex: 1; display: flex; overflow: hidden; position: relative; }
.message-list { flex: 1; overflow-y: auto; padding: 16px 20px; }
.message-list { flex: 1; overflow-y: auto; padding: 16px 20px; position: relative; z-index: 1; }
/* 关系花园背景 */
.garden-bg {
position: absolute; inset: 0; pointer-events: none; overflow: hidden; z-index: 0;
}
.bg-tree {
position: absolute; bottom: -20px; right: -30px; width: 320px; height: 352px; opacity: 0.5;
animation: bg-sway 6s ease-in-out infinite; transform-origin: 50% 100%;
}
@keyframes bg-sway { 0%,100% { transform: rotate(-1deg); } 50% { transform: rotate(1deg); } }
.bg-glow {
position: absolute; bottom: 10%; right: 5%; width: 280px; height: 280px; border-radius: 50%;
background: radial-gradient(circle, rgba(0,200,150,0.12) 0%, transparent 65%);
animation: bg-breathe ease-in-out infinite;
}
@keyframes bg-breathe { 0%,100% { transform: scale(0.85); opacity: 0.6; } 50% { transform: scale(1.05); opacity: 1; } }
/* Load more indicator */
.load-more-indicator { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 0; color: var(--color-text-hint); font-size: 13px; }
+75 -5
View File
@@ -88,11 +88,31 @@
</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 class="action-row">
<n-button type="primary" size="large" round :loading="watering" @click="doWater">
💧 浇水
</n-button>
<n-button size="large" round quaternary @click="generateShare">
📸 分享我们的树
</n-button>
</div>
</div>
</div>
<!-- 分享图预览 -->
<div v-if="shareImg" class="modal-overlay" @click.self="shareImg = null">
<div class="share-modal">
<div class="share-header">
<h3>📸 长按保存发到朋友圈</h3>
<span class="close-btn" @click="shareImg = null"></span>
</div>
<img :src="shareImg" class="share-preview" alt="分享图" />
<p class="share-tip">长按图片保存或右键另存为 发到微信/QQ 朋友圈拉好友来青叶</p>
<div class="share-actions">
<n-button type="primary" round @click="downloadShare"> 下载图片</n-button>
<n-button quaternary round @click="shareImg = null">关闭</n-button>
</div>
</div>
</div>
</div>
@@ -102,10 +122,14 @@
import { ref, computed, onMounted } from 'vue'
import { useMessage } from 'naive-ui'
import { treesApi } from '@/api/trees'
import { useAuthStore } from '@/stores/auth'
import {
generateTreeStyle, treeTrunkColor, treeCanopyColor,
canopyPositions, dropletPositions,
} from '@/utils/treeGenerator'
import { generateTreeShareCard } from '@/utils/shareCard'
const auth = useAuthStore()
const message = useMessage()
@@ -116,6 +140,7 @@ const loading = ref(true)
const watering = ref(false)
const waterAnim = ref(false)
const petals = ref<any[]>([])
const shareImg = ref<string | null>(null)
const STAGE_NAMES = ['种子', '萌芽', '幼苗', '小树', '大树', '古树']
@@ -207,6 +232,31 @@ function spawnPetals() {
}
setTimeout(() => (petals.value = []), 3000)
}
function generateShare() {
if (!currentTree.value) return
const myName = auth.user?.nickname || auth.user?.username || '我'
shareImg.value = generateTreeShareCard({
friendName: currentTree.value.friend_name,
myName,
stageIndex: currentTree.value.stage_index,
stageName: currentTree.value.stage_name,
stageEmoji: currentTree.value.stage_emoji,
messageCount: currentTree.value.message_count,
waterCount: currentTree.value.water_count,
totalScore: currentTree.value.total_score,
hue: style.value.hue,
})
}
function downloadShare() {
if (!shareImg.value) return
const a = document.createElement('a')
a.href = shareImg.value
a.download = `青叶-好友之树-${currentTree.value?.friend_name}.png`
a.click()
message.success('已下载,去发朋友圈吧')
}
</script>
<style scoped>
@@ -292,4 +342,24 @@ function spawnPetals() {
}
.progress-text { font-size: 12px; color: var(--color-text-hint); }
.max-stage { font-size: 13px; color: var(--color-primary); margin-bottom: 8px; }
/* 操作按钮行 */
.action-row { display: flex; gap: 8px; justify-content: center; margin-top: 16px; }
/* 分享弹窗 */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px;
}
.share-modal {
background: var(--color-surface); border-radius: 16px; padding: 20px;
max-width: 420px; max-height: 90vh; overflow-y: auto;
box-shadow: 0 12px 40px rgba(0,0,0,0.2);
}
.share-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.share-header h3 { margin: 0; font-size: 16px; }
.close-btn { cursor: pointer; font-size: 18px; color: var(--color-text-hint); }
.share-preview { width: 100%; border-radius: 12px; display: block; }
.share-tip { font-size: 12px; color: var(--color-text-hint); text-align: center; margin: 10px 0; }
.share-actions { display: flex; gap: 8px; justify-content: center; }
</style>