1.8
This commit is contained in:
+2
-1
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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])
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
import api from './client'
|
||||
|
||||
export const gardenApi = {
|
||||
getDailyStatus: () => api.get('/garden/daily-status'),
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user