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
+2 -1
View File
@@ -64,7 +64,7 @@ app.add_middleware(
app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads")
# 注册路由
from app.routers import auth, users, conversations, messages, friends, admin, uploads, moments, leaves, trees, capsules, echoes, sync, climates, flash
from app.routers import auth, users, conversations, messages, friends, admin, uploads, moments, leaves, trees, capsules, echoes, sync, climates, flash, garden
app.include_router(auth.router, prefix="/api/v1/auth", tags=["认证"])
app.include_router(users.router, prefix="/api/v1/users", tags=["用户"])
@@ -81,6 +81,7 @@ app.include_router(echoes.router, prefix="/api/v1/echoes", tags=["念念回音"]
app.include_router(sync.router, prefix="/api/v1/sync", tags=["默契种子"])
app.include_router(climates.router, prefix="/api/v1/climates", tags=["聊天气候"])
app.include_router(flash.router, prefix="/api/v1/flash", tags=["萤火虫时刻"])
app.include_router(garden.router, prefix="/api/v1/garden", tags=["花园状态"])
# WebSocket
from app.websocket.router import websocket_router
+2
View File
@@ -15,6 +15,7 @@ from app.models.miss_echo import MissEcho
from app.models.sync_seed import SyncQuestion, SyncSeed
from app.models.chat_climate import ChatClimate
from app.models.flash_event import FlashEvent, FlashParticipation
from app.models.user_streak import UserStreak
__all__ = [
"User",
@@ -36,4 +37,5 @@ __all__ = [
"ChatClimate",
"FlashEvent",
"FlashParticipation",
"UserStreak",
]
+22
View File
@@ -0,0 +1,22 @@
"""用户每日签到/连续天数模型"""
from datetime import date, datetime
from sqlalchemy import String, Integer, Date, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class UserStreak(Base):
__tablename__ = "user_streaks"
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
streak: Mapped[int] = mapped_column(Integer, default=0) # 连续打开天数
last_open_date: Mapped[date | None] = mapped_column(Date, nullable=True)
total_days: Mapped[int] = mapped_column(Integer, default=0) # 累计打开天数
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.utcnow(), onupdate=lambda: datetime.utcnow()
)
user = relationship("User", foreign_keys=[user_id])
+20
View File
@@ -0,0 +1,20 @@
"""花园综合状态路由"""
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.services.daily_service import DailyService
router = APIRouter()
@router.get("/daily-status")
async def daily_status(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""今日花园状态:连续天数 + 口渴的树"""
service = DailyService(db)
return await service.get_daily_status(user.id)
+80
View File
@@ -0,0 +1,80 @@
"""每日花园状态服务:连续天数 + 树渴了提醒"""
from datetime import date, datetime, timedelta
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user_streak import UserStreak
from app.models.friendship_tree import FriendshipTree
from app.models.friend import Friend
from app.models.user import User
class DailyService:
def __init__(self, db: AsyncSession):
self.db = db
async def get_daily_status(self, user_id: str) -> dict:
"""获取/更新今日花园状态"""
today = date.today()
result = await self.db.execute(
select(UserStreak).where(UserStreak.user_id == user_id)
)
streak_row = result.scalars().first()
is_new_day_open = False
if not streak_row:
streak_row = UserStreak(
user_id=user_id, streak=1, last_open_date=today, total_days=1,
)
self.db.add(streak_row)
is_new_day_open = True
elif streak_row.last_open_date != today:
# 计算连续天数
if streak_row.last_open_date == today - timedelta(days=1):
streak_row.streak += 1 # 连续
else:
streak_row.streak = 1 # 断了,重新计数
streak_row.last_open_date = today
streak_row.total_days += 1
is_new_day_open = True
await self.db.flush()
# 找口渴的树(24 小时没浇水的)
thirsty_trees = await self._get_thirsty_trees(user_id)
return {
"streak": streak_row.streak,
"total_days": streak_row.total_days,
"is_new_day_open": is_new_day_open,
"thirsty_count": len(thirsty_trees),
"thirsty_trees": thirsty_trees[:3], # 最多提示 3 棵
}
async def _get_thirsty_trees(self, user_id: str) -> list[dict]:
"""获取口渴的好友之树(24h 未浇水)"""
cutoff = datetime.utcnow() - timedelta(hours=24)
# 查该用户参与的所有树
result = await self.db.execute(
select(FriendshipTree).where(
or_(
FriendshipTree.user_a_id == user_id,
FriendshipTree.user_b_id == user_id,
)
)
)
thirsty = []
for tree in result.scalars().all():
if tree.last_watered_at is None or tree.last_watered_at < cutoff:
friend_id = tree.user_b_id if tree.user_a_id == user_id else tree.user_a_id
# 取好友信息
fr = await self.db.execute(select(User).where(User.id == friend_id))
friend = fr.scalars().first()
if friend:
thirsty.append({
"friend_id": friend_id,
"friend_name": friend.nickname or friend.username,
"hours_since": int((datetime.utcnow() - (tree.last_watered_at or tree.created_at)).total_seconds() / 3600),
})
return thirsty
+5
View File
@@ -0,0 +1,5 @@
import api from './client'
export const gardenApi = {
getDailyStatus: () => api.get('/garden/daily-status'),
}
+140
View File
@@ -0,0 +1,140 @@
/**
* 生成好友之树分享图(Canvas 绘制,可下载发到微信朋友圈)
*/
export interface ShareTreeData {
friendName: string
myName: string
stageIndex: number
stageName: string
stageEmoji: string
messageCount: number
waterCount: number
totalScore: number
hue: number
}
function hsl(h: number, s: number, l: number): string {
return `hsl(${h}, ${s}%, ${l}%)`
}
/** 把树画到 canvas 上 */
function drawTree(ctx: CanvasRenderingContext2D, cx: number, baseY: number, stageIndex: number, hue: number) {
if (stageIndex === 0) {
// 种子
ctx.fillStyle = hsl(25, 45, 40)
ctx.beginPath()
ctx.ellipse(cx, baseY, 14, 10, 0, 0, Math.PI * 2)
ctx.fill()
return
}
const trunkW = 4 + stageIndex * 2.2
const canopyR = 22 + stageIndex * 9
const canopyN = 3 + stageIndex
// 树干
ctx.fillStyle = hsl(25, 45, 35 + stageIndex * 2)
ctx.fillRect(cx - trunkW / 2, baseY - 90, trunkW, 90)
// 树冠(多个圆叠加)
ctx.fillStyle = hsl(hue, 55, 42)
ctx.beginPath()
ctx.arc(cx, baseY - 100, canopyR, 0, Math.PI * 2)
ctx.fill()
for (let i = 1; i < canopyN; i++) {
const offset = i % 2 === 1 ? 1 : -1
const layer = Math.ceil(i / 2)
ctx.fillStyle = hsl(hue, 55, 42 + (i % 2) * 6)
ctx.beginPath()
ctx.arc(
cx + offset * canopyR * 0.6 * layer,
baseY - 100 + layer * canopyR * 0.3,
canopyR * (0.75 - layer * 0.1),
0, Math.PI * 2
)
ctx.fill()
}
// 高光
ctx.fillStyle = hsl(hue, 60, 60)
ctx.globalAlpha = 0.3
ctx.beginPath()
ctx.arc(cx - canopyR * 0.3, baseY - 100 - canopyR * 0.3, canopyR * 0.3, 0, Math.PI * 2)
ctx.fill()
ctx.globalAlpha = 1
}
/** 生成完整分享卡片,返回 dataURL */
export function generateTreeShareCard(data: ShareTreeData): string {
const W = 600, H = 800
const canvas = document.createElement('canvas')
canvas.width = W
canvas.height = H
const ctx = canvas.getContext('2d')!
// 背景渐变
const bg = ctx.createLinearGradient(0, 0, 0, H)
bg.addColorStop(0, '#E8F5E9')
bg.addColorStop(0.5, '#C8E6C9')
bg.addColorStop(1, '#A5D6A7')
ctx.fillStyle = bg
ctx.fillRect(0, 0, W, H)
// 顶部光晕
const glow = ctx.createRadialGradient(W / 2, 200, 0, W / 2, 200, 300)
glow.addColorStop(0, 'rgba(255,255,255,0.5)')
glow.addColorStop(1, 'rgba(255,255,255,0)')
ctx.fillStyle = glow
ctx.fillRect(0, 0, W, 400)
// logo + 标题
ctx.fillStyle = '#004D40'
ctx.font = 'bold 28px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif'
ctx.textAlign = 'center'
ctx.fillText('🌿 我们在青叶种了一棵树', W / 2, 70)
// 名字
ctx.font = '20px -apple-system, "PingFang SC", sans-serif'
ctx.fillStyle = '#00695C'
ctx.fillText(`${data.myName} × ${data.friendName}`, W / 2, 105)
// 地面
ctx.fillStyle = 'rgba(139,90,43,0.15)'
ctx.beginPath()
ctx.ellipse(W / 2, 560, 140, 16, 0, 0, Math.PI * 2)
ctx.fill()
// 画树
drawTree(ctx, W / 2, 560, data.stageIndex, data.hue)
// 阶段标签
ctx.font = 'bold 22px sans-serif'
ctx.fillStyle = '#00796B'
ctx.fillText(`${data.stageEmoji} ${data.stageName} Lv.${data.stageIndex + 1}`, W / 2, 620)
// 统计三栏
const stats = [
{ label: '条消息', value: data.messageCount },
{ label: '次浇水', value: data.waterCount },
{ label: '成长值', value: data.totalScore },
]
const statY = 670
stats.forEach((s, i) => {
const x = W / 4 + (W / 4) * i - 30
ctx.fillStyle = '#009688'
ctx.font = 'bold 26px sans-serif'
ctx.fillText(String(s.value), x, statY)
ctx.fillStyle = '#5F7A74'
ctx.font = '13px sans-serif'
ctx.fillText(s.label, x, statY + 22)
})
// 底部邀请
ctx.fillStyle = '#004D40'
ctx.font = '15px sans-serif'
ctx.fillText('来青叶,和你在意的人一起种树', W / 2, 745)
ctx.font = '12px sans-serif'
ctx.fillStyle = '#5F7A74'
ctx.fillText('微信给不了你的,是一片会生长的关系', W / 2, 770)
return canvas.toDataURL('image/png')
}
+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>
+5 -1
View File
@@ -36,4 +36,8 @@
这个窗口一直不关上
可以
可以
给我一个其他人用青叶而不是微信QQ的理由,没有就继续改进
把其他社交软件有的功能补全