This commit is contained in:
2026-06-13 17:57:43 +08:00
parent 68678304ff
commit a0f441d8ae
28 changed files with 1933 additions and 2 deletions
+4 -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
from app.routers import auth, users, conversations, messages, friends, admin, uploads, moments, leaves, trees, capsules
app.include_router(auth.router, prefix="/api/v1/auth", tags=["认证"])
app.include_router(users.router, prefix="/api/v1/users", tags=["用户"])
@@ -74,6 +74,9 @@ app.include_router(friends.router, prefix="/api/v1/friends", tags=["好友"])
app.include_router(admin.router, prefix="/api/v1/admin", tags=["管理"])
app.include_router(uploads.router, prefix="/api/v1/uploads", tags=["上传"])
app.include_router(moments.router, prefix="/api/v1/moments", tags=["朋友圈"])
app.include_router(leaves.router, prefix="/api/v1/leaves", tags=["心情叶"])
app.include_router(trees.router, prefix="/api/v1/trees", tags=["好友之树"])
app.include_router(capsules.router, prefix="/api/v1/capsules", tags=["时光胶囊"])
# WebSocket
from app.websocket.router import websocket_router
+6
View File
@@ -8,6 +8,9 @@ from app.models.friend import Friend
from app.models.friend_request import FriendRequest
from app.models.system_config import SystemConfig
from app.models.moment import Moment, MomentLike, MomentComment
from app.models.daily_leaf import DailyMoodLeaf
from app.models.friendship_tree import FriendshipTree
from app.models.time_capsule import TimeCapsule
__all__ = [
"User",
@@ -20,4 +23,7 @@ __all__ = [
"Moment",
"MomentLike",
"MomentComment",
"DailyMoodLeaf",
"FriendshipTree",
"TimeCapsule",
]
+25
View File
@@ -0,0 +1,25 @@
"""每日心情叶模型"""
from datetime import datetime, date
from sqlalchemy import String, Text, DateTime, Date, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class DailyMoodLeaf(Base):
__tablename__ = "daily_mood_leaves"
__table_args__ = (
UniqueConstraint("user_id", "leaf_date", name="uq_user_daily_leaf"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True)
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"))
leaf_date: Mapped[date] = mapped_column(Date, nullable=False)
mood: Mapped[str | None] = mapped_column(String(20), nullable=True) # 预设心情标签
note: Mapped[str | None] = mapped_column(Text, nullable=True) # 心情备注
leaf_seed: Mapped[str] = mapped_column(String(32), nullable=False) # 确定性生成种子
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow())
user = relationship("User", foreign_keys=[user_id])
+25
View File
@@ -0,0 +1,25 @@
"""好友之树模型"""
from datetime import datetime
from sqlalchemy import String, Integer, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class FriendshipTree(Base):
__tablename__ = "friendship_trees"
__table_args__ = (
UniqueConstraint("user_a_id", "user_b_id", name="uq_friendship_tree"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True)
user_a_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"))
user_b_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"))
water_count: Mapped[int] = mapped_column(Integer, default=0)
last_watered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow())
user_a = relationship("User", foreign_keys=[user_a_id])
user_b = relationship("User", foreign_keys=[user_b_id])
+25
View File
@@ -0,0 +1,25 @@
"""时光胶囊模型"""
from datetime import datetime
from sqlalchemy import String, Text, DateTime, Boolean, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class TimeCapsule(Base):
__tablename__ = "time_capsules"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
sender_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"))
recipient_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"))
title: Mapped[str] = mapped_column(String(100), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
unlock_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
mood: Mapped[str | None] = mapped_column(String(20), nullable=True) # 种子心情色
is_opened: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow())
sender = relationship("User", foreign_keys=[sender_id])
recipient = relationship("User", foreign_keys=[recipient_id])
+51
View File
@@ -0,0 +1,51 @@
"""时光胶囊路由"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.schemas.capsule import CapsuleCreate
from app.services.capsule_service import CapsuleService
router = APIRouter()
@router.get("/")
async def list_capsules(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取我的时光胶囊"""
service = CapsuleService(db)
return await service.get_capsules(user.id)
@router.get("/{capsule_id}")
async def get_capsule(
capsule_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取单个胶囊"""
service = CapsuleService(db)
try:
return await service.get_capsule(user.id, capsule_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/")
async def create_capsule(
req: CapsuleCreate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""创建时光胶囊"""
service = CapsuleService(db)
try:
return await service.create_capsule(
user.id, req.recipient_id, req.title, req.content, req.unlock_at, req.mood
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
+46
View File
@@ -0,0 +1,46 @@
"""心情叶路由"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.schemas.leaf import LeafUpdate
from app.services.leaf_service import LeafService
router = APIRouter()
@router.get("/today")
async def get_today_leaf(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取或创建今日心情叶"""
service = LeafService(db)
return await service.get_or_create_today(user.id)
@router.put("/{leaf_id}")
async def update_leaf(
leaf_id: str,
req: LeafUpdate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""更新今日叶子的心情和备注"""
service = LeafService(db)
try:
return await service.update_leaf(user.id, leaf_id, req.mood, req.note)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/collection")
async def get_collection(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取叶子收藏"""
service = LeafService(db)
return await service.get_collection(user.id)
+45
View File
@@ -0,0 +1,45 @@
"""好友之树路由"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.services.tree_service import TreeService
router = APIRouter()
@router.get("/")
async def list_trees(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取所有好友的树"""
service = TreeService(db)
return await service.get_all_trees(user.id)
@router.get("/{friend_id}")
async def get_tree(
friend_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取与某好友的树"""
service = TreeService(db)
return await service.get_tree(user.id, friend_id)
@router.post("/{friend_id}/water")
async def water_tree(
friend_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""浇水"""
service = TreeService(db)
try:
return await service.water(user.id, friend_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
+13
View File
@@ -0,0 +1,13 @@
"""时光胶囊 Schema"""
from datetime import datetime
from pydantic import BaseModel, Field
class CapsuleCreate(BaseModel):
recipient_id: str
title: str = Field(..., min_length=1, max_length=100)
content: str = Field(..., min_length=1, max_length=5000)
unlock_at: datetime
mood: str | None = Field(None, max_length=20)
+8
View File
@@ -0,0 +1,8 @@
"""心情叶 Schema"""
from pydantic import BaseModel, Field
class LeafUpdate(BaseModel):
mood: str | None = Field(None, max_length=20)
note: str | None = Field(None, max_length=500)
+105
View File
@@ -0,0 +1,105 @@
"""时光胶囊服务(懒解锁)"""
import uuid
from datetime import datetime
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.time_capsule import TimeCapsule
from app.models.friend import Friend
class CapsuleService:
def __init__(self, db: AsyncSession):
self.db = db
async def create_capsule(self, sender_id: str, recipient_id: str, title: str,
content: str, unlock_at: datetime,
mood: str | None = None) -> dict:
"""创建时光胶囊"""
# 校验解锁时间是未来
if unlock_at <= datetime.utcnow():
raise ValueError("解锁时间必须是未来时间")
# 校验收件人:自己 或 好友
if recipient_id != sender_id:
friend_result = await self.db.execute(
select(Friend).where(
Friend.user_id == sender_id,
Friend.friend_user_id == recipient_id,
)
)
if not friend_result.scalars().first():
raise ValueError("只能给好友或自己寄胶囊")
capsule = TimeCapsule(
id=str(uuid.uuid4()),
sender_id=sender_id,
recipient_id=recipient_id,
title=title,
content=content,
unlock_at=unlock_at,
mood=mood,
)
self.db.add(capsule)
await self.db.flush()
return self._capsule_to_dict(capsule, sender_id)
async def get_capsules(self, user_id: str) -> list[dict]:
"""获取我发的 + 我收的胶囊"""
result = await self.db.execute(
select(TimeCapsule).where(
or_(
TimeCapsule.sender_id == user_id,
TimeCapsule.recipient_id == user_id,
)
).order_by(TimeCapsule.unlock_at.desc())
)
return [self._capsule_to_dict(c, user_id) for c in result.scalars().all()]
async def get_capsule(self, user_id: str, capsule_id: str) -> dict:
"""获取单个胶囊"""
result = await self.db.execute(
select(TimeCapsule).where(TimeCapsule.id == capsule_id)
)
capsule = result.scalars().first()
if not capsule:
raise ValueError("胶囊不存在")
if capsule.sender_id != user_id and capsule.recipient_id != user_id:
raise ValueError("无权查看此胶囊")
# 若已解锁且收件人首次查看,标记 opened
now = datetime.utcnow()
if now >= capsule.unlock_at and capsule.recipient_id == user_id and not capsule.is_opened:
capsule.is_opened = True
await self.db.flush()
return self._capsule_to_dict(capsule, user_id)
def _capsule_to_dict(self, capsule: TimeCapsule, viewer_id: str) -> dict:
"""转字典,应用懒解锁:未到期则隐藏内容"""
now = datetime.utcnow()
locked = now < capsule.unlock_at
seconds_left = int((capsule.unlock_at - now).total_seconds()) if locked else 0
# 内容可见性:发送人可看自己的(即使锁定);收件人锁定时不可看
can_see_content = (
capsule.sender_id == viewer_id # 发送人始终能看自己发的
or not locked # 已解锁
)
return {
"id": capsule.id,
"sender_id": capsule.sender_id,
"recipient_id": capsule.recipient_id,
"is_mine_sent": capsule.sender_id == viewer_id,
"is_mine_received": capsule.recipient_id == viewer_id,
"title": capsule.title,
"content": capsule.content if can_see_content else None,
"unlock_at": capsule.unlock_at.isoformat(),
"mood": capsule.mood,
"locked": locked,
"seconds_left": seconds_left,
"is_opened": capsule.is_opened,
"created_at": capsule.created_at.isoformat(),
}
+76
View File
@@ -0,0 +1,76 @@
"""每日心情叶服务"""
import hashlib
import uuid
from datetime import date
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.daily_leaf import DailyMoodLeaf
class LeafService:
def __init__(self, db: AsyncSession):
self.db = db
async def get_or_create_today(self, user_id: str) -> dict:
"""获取或创建今日心情叶"""
today = date.today()
result = await self.db.execute(
select(DailyMoodLeaf).where(
DailyMoodLeaf.user_id == user_id,
DailyMoodLeaf.leaf_date == today,
)
)
leaf = result.scalars().first()
if not leaf:
seed = hashlib.md5(f"{user_id}:{today.isoformat()}".encode()).hexdigest()[:16]
leaf = DailyMoodLeaf(
id=str(uuid.uuid4()),
user_id=user_id,
leaf_date=today,
leaf_seed=seed,
)
self.db.add(leaf)
await self.db.flush()
return self._leaf_to_dict(leaf)
async def update_leaf(self, user_id: str, leaf_id: str, mood: str | None,
note: str | None) -> dict:
"""更新今日叶子的心情和备注"""
result = await self.db.execute(
select(DailyMoodLeaf).where(DailyMoodLeaf.id == leaf_id)
)
leaf = result.scalars().first()
if not leaf:
raise ValueError("叶子不存在")
if leaf.user_id != user_id:
raise ValueError("无权操作此叶子")
if mood is not None:
leaf.mood = mood
if note is not None:
leaf.note = note
await self.db.flush()
return self._leaf_to_dict(leaf)
async def get_collection(self, user_id: str, limit: int = 60) -> list[dict]:
"""获取叶子收藏(历史)"""
result = await self.db.execute(
select(DailyMoodLeaf)
.where(DailyMoodLeaf.user_id == user_id)
.order_by(DailyMoodLeaf.leaf_date.desc())
.limit(limit)
)
return [self._leaf_to_dict(l) for l in result.scalars().all()]
def _leaf_to_dict(self, leaf: DailyMoodLeaf) -> dict:
return {
"id": leaf.id,
"user_id": leaf.user_id,
"leaf_date": leaf.leaf_date.isoformat() if leaf.leaf_date else None,
"mood": leaf.mood,
"note": leaf.note,
"leaf_seed": leaf.leaf_seed,
"created_at": leaf.created_at,
}
+149
View File
@@ -0,0 +1,149 @@
"""好友之树服务"""
import uuid
from datetime import datetime
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.friendship_tree import FriendshipTree
from app.models.conversation import Conversation
from app.models.conversation_member import ConversationMember
from app.models.message import Message
from app.models.friend import Friend
from app.models.user import User
# 阶段定义:分数 -> (阶段索引, 名称, emoji)
STAGES = [
(0, "种子", "🌱"),
(11, "萌芽", "🌿"),
(41, "幼苗", "🪴"),
(151, "小树", "🌲"),
(401, "大树", "🌳"),
(1001, "古树", "🌲"),
]
def stage_for_score(score: int) -> tuple[int, str, str]:
"""根据分数返回 (阶段索引, 名称, emoji)"""
idx = 0
for i, (threshold, name, emoji) in enumerate(STAGES):
if score >= threshold:
idx = i
return idx, STAGES[idx][1], STAGES[idx][2]
class TreeService:
def __init__(self, db: AsyncSession):
self.db = db
async def _get_or_create_tree_row(self, user_id: str, friend_id: str) -> FriendshipTree:
"""规范化(a < b)后查/建树行"""
a, b = (user_id, friend_id) if user_id < friend_id else (friend_id, user_id)
result = await self.db.execute(
select(FriendshipTree).where(
FriendshipTree.user_a_id == a,
FriendshipTree.user_b_id == b,
)
)
tree = result.scalars().first()
if not tree:
tree = FriendshipTree(
id=str(uuid.uuid4()),
user_a_id=a,
user_b_id=b,
)
self.db.add(tree)
await self.db.flush()
return tree
async def _count_messages_between(self, user_id: str, friend_id: str) -> int:
"""统计两人私聊会话中的消息数(复用 get_or_create_private 的查找逻辑)"""
# 找到两人共有的私聊会话
result = await self.db.execute(
select(Conversation).join(ConversationMember)
.where(
Conversation.type == "private",
ConversationMember.user_id == user_id,
)
)
conv_id = None
for conv in result.scalars().all():
member_result = await self.db.execute(
select(ConversationMember).where(
ConversationMember.conversation_id == conv.id,
ConversationMember.user_id == friend_id,
)
)
if member_result.scalars().first():
conv_id = conv.id
break
if not conv_id:
return 0
count_result = await self.db.execute(
select(func.count(Message.id)).where(
Message.conversation_id == conv_id,
Message.is_deleted == False,
)
)
return count_result.scalar() or 0
async def get_tree(self, user_id: str, friend_id: str) -> dict:
"""获取好友之树"""
tree = await self._get_or_create_tree_row(user_id, friend_id)
msg_count = await self._count_messages_between(user_id, friend_id)
total_score = msg_count + tree.water_count * 5
stage_idx, stage_name, stage_emoji = stage_for_score(total_score)
# 下个阶段的分数门槛
next_threshold = STAGES[stage_idx + 1][0] if stage_idx + 1 < len(STAGES) else None
# 好友信息
friend_result = await self.db.execute(select(User).where(User.id == friend_id))
friend = friend_result.scalars().first()
return {
"tree_id": tree.id,
"friend_id": friend_id,
"friend_name": friend.nickname or friend.username if friend else "未知",
"friend_avatar": friend.avatar_url if friend else None,
"message_count": msg_count,
"water_count": tree.water_count,
"total_score": total_score,
"stage_index": stage_idx,
"stage_name": stage_name,
"stage_emoji": stage_emoji,
"next_threshold": next_threshold,
"last_watered_at": tree.last_watered_at,
"seed": tree.id[:16], # 用于程序化树形态
}
async def water(self, user_id: str, friend_id: str) -> dict:
"""浇水,返回带 leveled_up 标志"""
tree = await self._get_or_create_tree_row(user_id, friend_id)
old_score = (await self._count_messages_between(user_id, friend_id)) + tree.water_count * 5
old_stage_idx = stage_for_score(old_score)[0]
tree.water_count += 1
tree.last_watered_at = datetime.utcnow()
await self.db.flush()
result = await self.get_tree(user_id, friend_id)
result["leveled_up"] = result["stage_index"] > old_stage_idx
return result
async def get_all_trees(self, user_id: str) -> list[dict]:
"""获取所有好友的树(花园概览用)"""
# 获取好友列表
friends_result = await self.db.execute(
select(Friend.friend_user_id).where(Friend.user_id == user_id)
)
friend_ids = [r[0] for r in friends_result.all()]
trees = []
for fid in friend_ids:
trees.append(await self.get_tree(user_id, fid))
# 按分数排序
trees.sort(key=lambda t: t["total_score"], reverse=True)
return trees
+17
View File
@@ -0,0 +1,17 @@
import api from './client'
export interface CapsuleCreateData {
recipient_id: string
title: string
content: string
unlock_at: string
mood?: string
}
export const capsulesApi = {
getAll: () => api.get('/capsules/'),
getOne: (id: string) => api.get(`/capsules/${id}`),
create: (data: CapsuleCreateData) => api.post('/capsules/', data),
}
+10
View File
@@ -0,0 +1,10 @@
import api from './client'
export const leavesApi = {
getToday: () => api.get('/leaves/today'),
updateLeaf: (leafId: string, data: { mood?: string; note?: string }) =>
api.put(`/leaves/${leafId}`, data),
getCollection: () => api.get('/leaves/collection'),
}
+9
View File
@@ -0,0 +1,9 @@
import api from './client'
export const treesApi = {
getAll: () => api.get('/trees/'),
getTree: (friendId: string) => api.get(`/trees/${friendId}`),
water: (friendId: string) => api.post(`/trees/${friendId}/water`),
}
+4
View File
@@ -20,6 +20,9 @@
<router-link to="/moments" class="rail-item" :class="{ active: activeFeature === 'moments' }" title="朋友圈">
<span class="rail-icon">🌿</span>
</router-link>
<router-link to="/garden" class="rail-item" :class="{ active: activeFeature === 'garden' }" title="花园">
<span class="rail-icon"></span>
</router-link>
</nav>
<div class="rail-bottom">
<router-link to="/settings" class="rail-item" :class="{ active: activeFeature === 'settings' }" title="设置">
@@ -100,6 +103,7 @@ const activeFeature = computed(() => {
if (path.startsWith('/chat')) return 'chat'
if (path.startsWith('/contacts')) return 'contacts'
if (path.startsWith('/moments')) return 'moments'
if (path.startsWith('/garden')) return 'garden'
if (path.startsWith('/settings')) return 'settings'
return 'chat'
})
+43
View File
@@ -90,6 +90,49 @@ const routes: RouteRecordRaw[] = [
],
},
// 花园(特色功能)
{
path: 'garden',
children: [
{
path: '',
name: 'Garden',
meta: { hideSecondary: true },
components: {
secondary: () => import('@/views/garden/GardenSidebar.vue'),
default: () => import('@/views/garden/GardenView.vue'),
},
},
{
path: 'leaf',
name: 'GardenLeaf',
meta: { hideSecondary: true },
components: {
secondary: () => import('@/views/garden/GardenSidebar.vue'),
default: () => import('@/views/garden/LeafView.vue'),
},
},
{
path: 'tree',
name: 'GardenTree',
meta: { hideSecondary: true },
components: {
secondary: () => import('@/views/garden/GardenSidebar.vue'),
default: () => import('@/views/garden/TreeView.vue'),
},
},
{
path: 'capsule',
name: 'GardenCapsule',
meta: { hideSecondary: true },
components: {
secondary: () => import('@/views/garden/GardenSidebar.vue'),
default: () => import('@/views/garden/CapsuleView.vue'),
},
},
],
},
// 设置
{
path: 'settings',
+26
View File
@@ -0,0 +1,26 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { capsulesApi, type CapsuleCreateData } from '@/api/capsules'
export const useCapsulesStore = defineStore('capsules', () => {
const capsules = ref<any[]>([])
const isLoading = ref(false)
async function fetchAll() {
isLoading.value = true
try {
const { data } = await capsulesApi.getAll()
capsules.value = data
} finally {
isLoading.value = false
}
}
async function create(payload: CapsuleCreateData) {
const { data } = await capsulesApi.create(payload)
capsules.value.unshift(data)
return data
}
return { capsules, isLoading, fetchAll, create }
})
+33
View File
@@ -0,0 +1,33 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { leavesApi } from '@/api/leaves'
export const useLeavesStore = defineStore('leaves', () => {
const todayLeaf = ref<any>(null)
const collection = ref<any[]>([])
const isLoading = ref(false)
async function fetchToday() {
const { data } = await leavesApi.getToday()
todayLeaf.value = data
return data
}
async function updateLeaf(leafId: string, payload: { mood?: string; note?: string }) {
const { data } = await leavesApi.updateLeaf(leafId, payload)
todayLeaf.value = data
return data
}
async function fetchCollection() {
isLoading.value = true
try {
const { data } = await leavesApi.getCollection()
collection.value = data
} finally {
isLoading.value = false
}
}
return { todayLeaf, collection, isLoading, fetchToday, updateLeaf, fetchCollection }
})
+89
View File
@@ -0,0 +1,89 @@
/**
* 程序化叶子生成器
* 由确定性种子(hash(userId+date))派生独一无二的叶子形态
*/
export interface LeafStyle {
hue: number // 色相 0-360
saturation: number // 饱和度
lightness: number // 亮度
shapeVariant: number // 形态变体 0-3(不同叶形 path)
veinCount: number // 叶脉数量 3-7
angle: number // 叶子倾斜角度
spots: number // 斑点数量
size: number // 相对大小 0.85-1.1
}
/** 从 16 进制种子派生叶子样式 */
export function generateLeafStyle(seed: string): LeafStyle {
// 将 16 位 hex 种子拆成多段用于不同属性
const n = (start: number, len: number) => parseInt(seed.slice(start, start + len), 16)
const hue = n(0, 3) % 80 + 70 // 70-150 区间:黄绿到青绿
const saturation = 40 + (n(3, 2) % 35) // 40-75
const lightness = 45 + (n(5, 2) % 20) // 45-65
const shapeVariant = n(7, 1) % 4
const veinCount = 3 + (n(8, 1) % 5)
const angle = (n(9, 2) % 30) - 15 // -15 到 +15 度
const spots = n(11, 1) % 4
const size = 0.85 + (n(12, 2) % 26) / 100
return { hue, saturation, lightness, shapeVariant, veinCount, angle, spots, size }
}
export function leafColor(style: LeafStyle, lightDelta = 0): string {
return `hsl(${style.hue}, ${style.saturation}%, ${style.lightness + lightDelta}%)`
}
/** 4 种叶形 SVG pathviewBox 0 0 100 120 */
const LEAF_SHAPES = [
// 经典椭圆叶
'M50 8 C72 18 82 45 78 78 C74 104 60 116 50 116 C40 116 26 104 22 78 C18 45 28 18 50 8 Z',
// 心形叶
'M50 12 C60 4 78 8 80 28 C82 52 64 78 50 116 C36 78 18 52 20 28 C22 8 40 4 50 12 Z',
// 长披针叶
'M50 6 C62 30 66 60 62 92 C58 110 54 118 50 118 C46 118 42 110 38 92 C34 60 38 30 50 6 Z',
// 枫叶状
'M50 8 C58 22 56 30 68 34 C78 38 72 50 66 54 C74 62 70 74 58 72 C56 90 54 108 50 118 C46 108 44 90 42 72 C30 74 26 62 34 54 C28 50 22 38 32 34 C44 30 42 22 50 8 Z',
]
export function leafPath(variant: number): string {
return LEAF_SHAPES[variant % LEAF_SHAPES.length]
}
/** 生成叶脉 path(沿中线对称分支) */
export function veinPaths(style: LeafStyle): string[] {
const paths: string[] = []
// 主脉
paths.push('M50 16 L50 112')
// 侧脉
for (let i = 0; i < style.veinCount; i++) {
const y = 28 + i * (70 / style.veinCount)
const len = 16 + (i % 2) * 6
paths.push(`M50 ${y} Q38 ${y + 8} ${50 - len} ${y + 16}`)
paths.push(`M50 ${y} Q62 ${y + 8} ${50 + len} ${y + 16}`)
}
return paths
}
/** 生成斑点坐标(装饰) */
export function spotPositions(style: LeafStyle): { x: number; y: number; r: number }[] {
const spots: { x: number; y: number; r: number }[] = []
let s = parseInt(seedHash(style), 10) || 1
const rand = () => {
s = (s * 9301 + 49297) % 233280
return s / 233280
}
for (let i = 0; i < style.spots; i++) {
spots.push({
x: 30 + rand() * 40,
y: 30 + rand() * 60,
r: 1.5 + rand() * 2,
})
}
return spots
}
function seedHash(style: LeafStyle): string {
return '' + (style.hue * 7 + style.veinCount * 13 + style.spots * 31)
}
+77
View File
@@ -0,0 +1,77 @@
/**
* 程序化树生成器
* 根据阶段索引 + 种子渲染不同形态的树(SVG)
*/
export interface TreeStyle {
trunkWidth: number
canopyRadius: number
canopyCount: number
height: number
hue: number
dropletCount: number // 露珠数 = 浇水相关
}
/** 根据阶段和分数生成树样式 */
export function generateTreeStyle(stageIndex: number, seed: string, waterCount: number): TreeStyle {
const n = (start: number) => parseInt(seed.slice(start, start + 2) || '0', 16)
const baseCanopy = 18 + stageIndex * 10
const baseTrunk = 4 + stageIndex * 1.5
const hue = 90 + (n(0) % 40) // 90-130 绿色系
return {
trunkWidth: baseTrunk,
canopyRadius: baseCanopy,
canopyCount: 3 + stageIndex, // 树冠数量随阶段增加
height: 80 + stageIndex * 15,
hue,
dropletCount: Math.min(waterCount, 6),
}
}
export function treeTrunkColor(style: TreeStyle): string {
return `hsl(25, 45%, ${35 + style.trunkWidth}%)`
}
export function treeCanopyColor(style: TreeStyle, delta = 0): string {
return `hsl(${style.hue}, 55%, ${42 + delta}%)`
}
/** 生成树冠圆圈位置(围绕顶部) */
export function canopyPositions(style: TreeStyle, centerX: number, topY: number): { cx: number; cy: number; r: number }[] {
const positions: { cx: number; cy: number; r: number }[] = []
const count = style.canopyCount
// 主树冠
positions.push({ cx: centerX, cy: topY, r: style.canopyRadius })
// 侧边树冠
for (let i = 1; i < count; i++) {
const offset = i % 2 === 1 ? 1 : -1
const layer = Math.ceil(i / 2)
positions.push({
cx: centerX + offset * (style.canopyRadius * 0.6 * layer),
cy: topY + layer * style.canopyRadius * 0.3,
r: style.canopyRadius * (0.75 - layer * 0.1),
})
}
return positions
}
/** 生成露珠位置(挂在树冠上) */
export function dropletPositions(style: TreeStyle, centerX: number, topY: number): { x: number; y: number }[] {
const droplets: { x: number; y: number }[] = []
const positions = canopyPositions(style, centerX, topY)
let seed = parseInt(style.hue.toString() + style.dropletCount, 10) || 1
const rand = () => {
seed = (seed * 9301 + 49297) % 233280
return seed / 233280
}
for (let i = 0; i < style.dropletCount && i < positions.length; i++) {
const p = positions[i]
droplets.push({
x: p.cx + (rand() - 0.5) * p.r,
y: p.cy + (rand() - 0.5) * p.r * 0.6,
})
}
return droplets
}
+373
View File
@@ -0,0 +1,373 @@
<template>
<div class="capsule-view">
<div class="capsule-header">
<h2> 时光胶囊</h2>
<p class="subtitle">寄一颗种子给未来时间到了它会发芽</p>
<n-button type="primary" round size="small" @click="openCreate" style="margin-top: 8px">
寄一颗胶囊
</n-button>
</div>
<div v-if="filteredCapsules.length === 0 && !store.isLoading" class="empty">
<div style="font-size: 56px">🌰</div>
<p>还没有胶囊种下第一颗吧</p>
</div>
<!-- 标签切换 -->
<div v-else class="tabs">
<button :class="{ active: tab === 'all' }" @click="tab = 'all'">全部 ({{ store.capsules.length }})</button>
<button :class="{ active: tab === 'received' }" @click="tab = 'received'">收到的 ({{ receivedCount }})</button>
<button :class="{ active: tab === 'sent' }" @click="tab = 'sent'">寄出的 ({{ sentCount }})</button>
</div>
<!-- 胶囊列表 -->
<div class="capsule-list">
<div v-for="c in filteredCapsules" :key="c.id" class="capsule-card"
:class="{ locked: c.locked, [moodClass(c.mood)]: true }"
@click="openCapsule(c)">
<!-- 锁定显示种子 -->
<div v-if="c.locked" class="locked-view">
<div class="seed-icon" :class="`seed-${moodClass(c.mood)}`">🌰</div>
<div class="locked-info">
<div class="capsule-title">{{ c.title }}</div>
<div class="countdown">{{ formatCountdown(c.seconds_left) }} 后发芽</div>
</div>
<span class="badge">{{ c.is_mine_received ? '收' : '寄' }}</span>
</div>
<!-- 已解锁 -->
<div v-else class="unlocked-view">
<div class="capsule-title">{{ c.title }}</div>
<div class="capsule-content">{{ c.content?.slice(0, 80) }}{{ (c.content?.length || 0) > 80 ? '...' : '' }}</div>
<div class="capsule-footer">
<span class="footer-tag">{{ c.is_mine_received ? '📬 收到的' : '📤 寄出的' }}</span>
<span class="footer-time">{{ formatDate(c.unlock_at) }}</span>
</div>
</div>
</div>
</div>
<!-- 查看弹窗 -->
<div v-if="viewing" class="modal-overlay" @click.self="viewing = null">
<div class="view-modal" :class="moodClass(viewing.mood)">
<div class="modal-header">
<h3>{{ viewing.locked ? '🔒 还在沉睡的种子' : '🌱 胶囊发芽了' }}</h3>
<span class="close-btn" @click="viewing = null"></span>
</div>
<div class="modal-body">
<div class="view-title">{{ viewing.title }}</div>
<div v-if="viewing.locked" class="view-locked">
<div class="big-seed">🌰</div>
<p>这颗胶囊还在沉睡</p>
<p class="countdown-big">{{ formatCountdown(viewing.seconds_left) }}</p>
<p class="unlock-at">{{ formatDate(viewing.unlock_at) }} 发芽</p>
</div>
<div v-else class="view-content">{{ viewing.content }}</div>
</div>
</div>
</div>
<!-- 创建弹窗 -->
<div v-if="showCreate" class="modal-overlay" @click.self="showCreate = false">
<div class="create-modal">
<div class="modal-header">
<h3> 寄一颗时光胶囊</h3>
<span class="close-btn" @click="showCreate = false"></span>
</div>
<div class="create-body">
<div class="form-row">
<span class="form-label">寄给谁</span>
<n-select v-model:value="form.recipient_id" :options="recipientOptions" placeholder="选择好友或自己" />
</div>
<div class="form-row">
<span class="form-label">标题</span>
<n-input v-model:value="form.title" placeholder="给这颗种子起个名字" maxlength="100" />
</div>
<div class="form-row">
<span class="form-label">想说的话</span>
<n-input v-model:value="form.content" type="textarea" :rows="4"
placeholder="写下此刻想留存的话..." maxlength="5000" show-count />
</div>
<div class="form-row">
<span class="form-label">发芽时间</span>
<div class="quick-times">
<button v-for="opt in quickOptions" :key="opt.value"
:class="{ active: selectedQuick === opt.value }"
@click="selectQuick(opt)">{{ opt.label }}</button>
</div>
<n-date-picker v-if="selectedQuick === 'custom'" v-model:value="customTime"
type="datetime" :is-date-disabled="(ts: number) => ts < Date.now()" style="margin-top: 8px" />
</div>
<div class="form-row">
<span class="form-label">心情色(种子颜色)</span>
<div class="mood-colors">
<span v-for="m in moodColors" :key="m.key" class="mood-color"
:class="{ active: form.mood === m.key }" :style="{ background: m.color }"
@click="form.mood = m.key">{{ m.emoji }}</span>
</div>
</div>
</div>
<div class="modal-footer">
<n-button @click="showCreate = false">取消</n-button>
<n-button type="primary" :loading="creating"
:disabled="!form.recipient_id || !form.title.trim() || !form.content.trim()"
@click="doCreate">种下种子</n-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useMessage } from 'naive-ui'
import { useCapsulesStore } from '@/stores/capsules'
import { friendsApi } from '@/api/friends'
import { useAuthStore } from '@/stores/auth'
const store = useCapsulesStore()
const auth = useAuthStore()
const message = useMessage()
const tab = ref('all')
const viewing = ref<any>(null)
const showCreate = ref(false)
const creating = ref(false)
const friends = ref<any[]>([])
const tickTimer = ref<ReturnType<typeof setInterval>>()
const form = ref({
recipient_id: '',
title: '',
content: '',
mood: 'green',
})
const selectedQuick = ref('1d')
const customTime = ref<number>(Date.now() + 86400000)
const quickOptions = [
{ value: '1h', label: '1 小时后', ms: 3600000 },
{ value: '1d', label: '明天', ms: 86400000 },
{ value: '1w', label: '下周', ms: 604800000 },
{ value: '1m', label: '下个月', ms: 2592000000 },
{ value: '6m', label: '半年后', ms: 15552000000 },
{ value: 'custom', label: '自定义', ms: 0 },
]
const moodColors = [
{ key: 'green', emoji: '🍃', color: '#66BB6A' },
{ key: 'pink', emoji: '🌸', color: '#EC407A' },
{ key: 'amber', emoji: '🍯', color: '#FFA726' },
{ key: 'blue', emoji: '💧', color: '#42A5F5' },
{ key: 'purple', emoji: '🍇', color: '#AB47BC' },
]
const recipientOptions = computed(() => {
const opts = [{ label: '🌱 写给未来的自己', value: auth.user?.id || '' }]
friends.value.forEach((f) => {
opts.push({ label: f.remark || f.nickname || f.username, value: f.friend_user_id })
})
return opts
})
const filteredCapsules = computed(() => {
if (tab.value === 'received') return store.capsules.filter((c) => c.is_mine_received)
if (tab.value === 'sent') return store.capsules.filter((c) => c.is_mine_sent && !c.is_mine_received)
return store.capsules
})
const receivedCount = computed(() => store.capsules.filter((c) => c.is_mine_received).length)
const sentCount = computed(() => store.capsules.filter((c) => c.is_mine_sent && !c.is_mine_received).length)
onMounted(async () => {
await store.fetchAll()
// 每秒刷新倒计时
tickTimer.value = setInterval(() => {
const now = Date.now()
store.capsules.forEach((c) => {
if (c.locked && c.seconds_left > 0) {
const unlockTs = new Date(c.unlock_at).getTime()
c.seconds_left = Math.max(0, Math.floor((unlockTs - now) / 1000))
}
})
}, 1000)
})
onUnmounted(() => {
if (tickTimer.value) clearInterval(tickTimer.value)
})
function openCapsule(c: any) {
viewing.value = c
}
function openCreate() {
form.value = { recipient_id: '', title: '', content: '', mood: 'green' }
selectedQuick.value = '1d'
showCreate.value = true
// 加载好友列表
if (friends.value.length === 0) {
friendsApi.getFriends().then(({ data }) => (friends.value = data)).catch(() => {})
}
}
function selectQuick(opt: any) {
selectedQuick.value = opt.value
}
async function doCreate() {
let unlockAt: number
if (selectedQuick.value === 'custom') {
unlockAt = customTime.value
} else {
const opt = quickOptions.find((o) => o.value === selectedQuick.value)
unlockAt = Date.now() + (opt?.ms || 86400000)
}
if (unlockAt <= Date.now()) {
message.error('发芽时间必须是未来')
return
}
creating.value = true
try {
await store.create({
recipient_id: form.value.recipient_id,
title: form.value.title.trim(),
content: form.value.content.trim(),
unlock_at: new Date(unlockAt).toISOString(),
mood: form.value.mood,
})
message.success('🌱 种子已种下,期待它发芽')
showCreate.value = false
} catch (e: any) {
message.error(e.response?.data?.detail || '创建失败')
} finally {
creating.value = false
}
}
function moodClass(mood: string) {
return mood || 'green'
}
function formatCountdown(seconds: number): string {
if (seconds <= 0) return '即将发芽'
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
if (d > 0) return `${d}${h} 小时`
if (h > 0) return `${h}${m}`
if (m > 0) return `${m}${s}`
return `${s}`
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
</script>
<style scoped>
.capsule-view {
flex: 1; overflow-y: auto; padding: 24px;
background: linear-gradient(180deg, var(--color-bg) 0%, var(--color-primary-lightest) 100%);
}
.capsule-header { text-align: center; margin-bottom: 20px; }
.capsule-header h2 { margin: 0; font-size: 22px; color: var(--color-primary-darker); }
.subtitle { font-size: 13px; color: var(--color-text-hint); margin: 4px 0 0; }
.empty { text-align: center; padding: 60px 20px; color: var(--color-text-hint); }
.empty p { margin-top: 12px; font-size: 14px; }
.tabs { display: flex; gap: 4px; max-width: 480px; margin: 0 auto 16px; }
.tabs button {
flex: 1; padding: 8px; border: none; background: var(--color-surface);
border-radius: 8px; cursor: pointer; font-size: 13px; color: var(--color-text-secondary);
border: 1px solid var(--color-border); transition: all 0.2s;
}
.tabs button.active { background: var(--color-primary); color: white; border-color: var(--color-primary); }
.capsule-list { max-width: 480px; margin: 0 auto; display: flex; flex-direction: column; gap: 12px; }
.capsule-card {
background: var(--color-surface); border-radius: 14px; padding: 16px;
border: 1px solid var(--color-border); cursor: pointer; transition: all 0.2s;
border-left: 4px solid var(--color-primary);
}
.capsule-card:hover { transform: translateX(2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.capsule-card.pink { border-left-color: #EC407A; }
.capsule-card.amber { border-left-color: #FFA726; }
.capsule-card.blue { border-left-color: #42A5F5; }
.capsule-card.purple { border-left-color: #AB47BC; }
.locked-view { display: flex; align-items: center; gap: 14px; }
.seed-icon { font-size: 32px; animation: seed-pulse 2s ease-in-out infinite; }
.seed-pink { filter: hue-rotate(280deg); }
.seed-amber { filter: hue-rotate(330deg); }
.seed-blue { filter: hue-rotate(200deg); }
.seed-purple { filter: hue-rotate(260deg); }
@keyframes seed-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.1); } }
.locked-info { flex: 1; }
.capsule-title { font-weight: 600; font-size: 15px; color: var(--color-text-primary); }
.countdown { font-size: 12px; color: var(--color-primary); margin-top: 2px; }
.badge {
font-size: 11px; padding: 2px 8px; border-radius: 10px;
background: var(--color-primary-lightest); color: var(--color-primary);
}
.unlocked-view .capsule-content {
font-size: 13px; color: var(--color-text-secondary); margin: 6px 0; line-height: 1.5;
}
.capsule-footer { display: flex; justify-content: space-between; font-size: 11px; color: var(--color-text-hint); margin-top: 8px; }
/* 弹窗 */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px;
}
.view-modal, .create-modal {
width: 100%; max-width: 460px; max-height: 80vh; background: var(--color-surface);
border-radius: 16px; display: flex; flex-direction: column; overflow: hidden;
box-shadow: 0 12px 40px rgba(0,0,0,0.2);
}
.modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 18px 24px; border-bottom: 1px solid var(--color-border);
}
.modal-header h3 { margin: 0; font-size: 17px; }
.close-btn { cursor: pointer; font-size: 18px; color: var(--color-text-hint); }
.modal-body { padding: 24px; overflow-y: auto; }
.view-modal.green { border-top: 4px solid var(--color-primary); }
.view-modal.pink { border-top: 4px solid #EC407A; }
.view-modal.amber { border-top: 4px solid #FFA726; }
.view-modal.blue { border-top: 4px solid #42A5F5; }
.view-modal.purple { border-top: 4px solid #AB47BC; }
.view-title { font-size: 18px; font-weight: 700; margin-bottom: 16px; text-align: center; }
.view-locked { text-align: center; padding: 20px 0; }
.big-seed { font-size: 72px; animation: seed-pulse 2s ease-in-out infinite; }
.view-locked p { margin: 8px 0; color: var(--color-text-secondary); }
.countdown-big { font-size: 22px; font-weight: 700; color: var(--color-primary) !important; }
.unlock-at { font-size: 13px; color: var(--color-text-hint) !important; }
.view-content { font-size: 15px; line-height: 1.8; white-space: pre-wrap; color: var(--color-text-primary); }
/* 创建表单 */
.create-body { padding: 20px 24px; overflow-y: auto; }
.form-row { margin-bottom: 16px; }
.form-label { display: block; font-size: 13px; color: var(--color-text-secondary); margin-bottom: 6px; font-weight: 500; }
.quick-times { display: flex; flex-wrap: wrap; gap: 6px; }
.quick-times button {
padding: 6px 12px; border: 1px solid var(--color-border); background: var(--color-surface);
border-radius: 16px; cursor: pointer; font-size: 12px; color: var(--color-text-secondary); transition: all 0.2s;
}
.quick-times button.active { background: var(--color-primary); color: white; border-color: var(--color-primary); }
.mood-colors { display: flex; gap: 10px; }
.mood-color {
width: 36px; height: 36px; border-radius: 50%; cursor: pointer;
display: flex; align-items: center; justify-content: center; font-size: 16px;
border: 2px solid transparent; transition: all 0.2s;
}
.mood-color.active { border-color: var(--color-text-primary); transform: scale(1.1); }
.modal-footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 16px 24px; border-top: 1px solid var(--color-border);
}
</style>
@@ -0,0 +1,53 @@
<template>
<div class="garden-sidebar">
<div class="panel-header">
<h3 class="panel-title">🌿 花园</h3>
</div>
<div class="sidebar-content">
<div class="intro">
<div class="intro-icon">🌱</div>
<p class="intro-text">关系会在这里生长</p>
</div>
<router-link to="/garden" class="menu-item" active-class="active">
<span class="menu-icon">🏠</span>
<span class="menu-label">花园首页</span>
</router-link>
<router-link to="/garden/leaf" class="menu-item" active-class="active">
<span class="menu-icon">🍃</span>
<span class="menu-label">每日心情叶</span>
</router-link>
<router-link to="/garden/tree" class="menu-item" active-class="active">
<span class="menu-icon">🌳</span>
<span class="menu-label">好友之树</span>
</router-link>
<router-link to="/garden/capsule" class="menu-item" active-class="active">
<span class="menu-icon"></span>
<span class="menu-label">时光胶囊</span>
</router-link>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
.garden-sidebar { display: flex; flex-direction: column; height: 100%; }
.panel-header { padding: 16px; border-bottom: 1px solid var(--color-border); }
.panel-title { margin: 0; font-size: 16px; font-weight: 600; }
.sidebar-content { flex: 1; padding: 16px; }
.intro {
text-align: center; padding: 24px 16px; margin-bottom: 16px;
background: var(--color-primary-lightest); border-radius: 12px;
}
.intro-icon { font-size: 32px; margin-bottom: 6px; }
.intro-text { font-size: 13px; color: var(--color-text-secondary); margin: 0; }
.menu-item {
display: flex; align-items: center; gap: 12px; padding: 12px;
text-decoration: none; color: var(--color-text-primary); font-size: 14px;
border-radius: 8px; transition: background 0.15s; margin-bottom: 4px;
}
.menu-item:hover { background: var(--color-primary-lightest); }
.menu-item.active { background: var(--color-primary-lightest); color: var(--color-primary); font-weight: 500; }
.menu-icon { font-size: 18px; }
</style>
+79
View File
@@ -0,0 +1,79 @@
<template>
<div class="garden-view">
<div class="garden-header">
<h2 class="garden-title">🌿 花园</h2>
<p class="garden-subtitle">在这里关系会生长 · 每一片叶子都是独一无二的回忆</p>
</div>
<div class="feature-cards">
<div class="feature-card leaf-card" @click="$router.push('/garden/leaf')">
<div class="card-icon">🍃</div>
<div class="card-body">
<h3>每日心情叶</h3>
<p>每天收获一片独一无二的叶子记录今日心情</p>
</div>
<span class="card-arrow"></span>
</div>
<div class="feature-card tree-card" @click="$router.push('/garden/tree')">
<div class="card-icon">🌳</div>
<div class="card-body">
<h3>好友之树</h3>
<p>你们的友谊会长成一棵树越聊越茂盛</p>
</div>
<span class="card-arrow"></span>
</div>
<div class="feature-card capsule-card" @click="$router.push('/garden/capsule')">
<div class="card-icon"></div>
<div class="card-body">
<h3>时光胶囊</h3>
<p>给未来的自己或好友寄一颗会发芽的种子</p>
</div>
<span class="card-arrow"></span>
</div>
</div>
<div class="garden-quote">
<p>过去 · 当下 · 未来都在这片花园里生长</p>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
.garden-view {
flex: 1; overflow-y: auto; padding: 32px;
background: linear-gradient(160deg, var(--color-bg) 0%, var(--color-primary-lightest) 100%);
}
.garden-header { text-align: center; margin-bottom: 36px; }
.garden-title { font-size: 28px; font-weight: 700; margin: 0 0 8px; color: var(--color-primary-darker); }
.garden-subtitle { font-size: 14px; color: var(--color-text-secondary); margin: 0; }
.feature-cards { max-width: 560px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
.feature-card {
display: flex; align-items: center; gap: 16px; padding: 20px 24px;
background: var(--color-surface); border-radius: 16px;
border: 1px solid var(--color-border); cursor: pointer;
transition: all 0.25s; box-shadow: 0 2px 8px rgba(0,0,0,0.03);
}
.feature-card:hover {
transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,150,136,0.15);
border-color: var(--color-primary-light);
}
.card-icon { font-size: 40px; flex-shrink: 0; }
.card-body { flex: 1; }
.card-body h3 { margin: 0 0 4px; font-size: 17px; color: var(--color-text-primary); }
.card-body p { margin: 0; font-size: 13px; color: var(--color-text-secondary); }
.card-arrow { font-size: 24px; color: var(--color-text-hint); flex-shrink: 0; }
.feature-card:hover .card-arrow { color: var(--color-primary); }
.leaf-card { border-left: 4px solid #66BB6A; }
.tree-card { border-left: 4px solid #8D6E63; }
.capsule-card { border-left: 4px solid #FFB74D; }
.garden-quote { text-align: center; margin-top: 48px; }
.garden-quote p { font-size: 13px; color: var(--color-text-hint); font-style: italic; margin: 0; }
</style>
+240
View File
@@ -0,0 +1,240 @@
<template>
<div class="leaf-view">
<div class="leaf-header">
<h2>🍃 每日心情叶</h2>
<p class="subtitle">{{ today }}</p>
</div>
<div v-if="leaf" class="leaf-main">
<!-- 叶子大图 -->
<div class="leaf-stage">
<div class="leaf-wrapper" :style="{ transform: `scale(${style.size}) rotate(${style.angle}deg)` }">
<svg viewBox="0 0 100 120" class="leaf-svg" :class="{ sway: !editingMood }">
<defs>
<linearGradient :id="`grad-${leaf.id}`" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" :stop-color="leafColor(style, 10)" />
<stop offset="100%" :stop-color="leafColor(style, -10)" />
</linearGradient>
</defs>
<path :d="leafPath(style.shapeVariant)" :fill="`url(#grad-${leaf.id})`" stroke="rgba(0,0,0,0.15)" stroke-width="0.5" />
<!-- 叶脉 -->
<g stroke="rgba(255,255,255,0.4)" stroke-width="0.8" fill="none" stroke-linecap="round">
<path v-for="(vp, i) in veinPaths(style)" :key="i" :d="vp" />
</g>
<!-- 斑点 -->
<circle v-for="(sp, i) in spotPositions(style)" :key="'s'+i" :cx="sp.x" :cy="sp.y" :r="sp.r" fill="rgba(255,255,255,0.25)" />
</svg>
</div>
</div>
<!-- 心情 -->
<div class="mood-section">
<div class="section-label">今日心情</div>
<div class="mood-grid">
<div v-for="m in moods" :key="m.key" class="mood-chip"
:class="{ active: leaf.mood === m.key }" @click="selectMood(m.key)">
<span class="mood-emoji">{{ m.emoji }}</span>
<span class="mood-name">{{ m.name }}</span>
</div>
</div>
</div>
<!-- 备注 -->
<div class="note-section">
<div class="section-label">写点什么可选</div>
<n-input v-model:value="note" type="textarea" :rows="2" placeholder="今天这片叶子想说什么..."
maxlength="200" show-count @blur="saveNote" />
</div>
<div v-if="leaf.mood" class="today-mood-display">
<span class="mood-emoji large">{{ currentMoodEmoji }}</span>
<span>{{ currentMoodName }}</span>
</div>
</div>
<div v-else class="loading">生长中...</div>
<!-- 收藏入口 -->
<div class="collection-entry">
<n-button quaternary type="primary" @click="showCollection = !showCollection">
📖 我的叶子图鉴 ({{ collection.length }})
</n-button>
</div>
<!-- 图鉴弹窗 -->
<div v-if="showCollection" class="modal-overlay" @click.self="showCollection = false">
<div class="collection-modal">
<div class="modal-header">
<h3>🍃 叶子图鉴</h3>
<span class="close-btn" @click="showCollection = false"></span>
</div>
<div class="collection-grid">
<div v-if="collection.length === 0" class="empty">还没有收藏的叶子</div>
<div v-for="l in collection" :key="l.id" class="collection-leaf" :title="l.leaf_date">
<svg viewBox="0 0 100 120" class="mini-leaf">
<path :d="leafPath(generateLeafStyle(l.leaf_seed).shapeVariant)"
:fill="leafColor(generateLeafStyle(l.leaf_seed))" />
</svg>
<span class="leaf-date">{{ l.leaf_date?.slice(5) }}</span>
<span v-if="l.mood" class="leaf-mood">{{ moodEmoji(l.mood) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useMessage } from 'naive-ui'
import { useLeavesStore } from '@/stores/leaves'
import {
generateLeafStyle, leafColor, leafPath, veinPaths, spotPositions,
} from '@/utils/leafGenerator'
const leavesStore = useLeavesStore()
const message = useMessage()
const note = ref('')
const showCollection = ref(false)
const editingMood = ref(false)
const moods = [
{ key: 'calm', emoji: '🌙', name: '平静' },
{ key: 'happy', emoji: '😊', name: '开心' },
{ key: 'energetic', emoji: '⚡', name: '活力' },
{ key: 'thoughtful', emoji: '🤔', name: '沉思' },
{ key: 'lazy', emoji: '😴', name: '慵懒' },
{ key: 'grateful', emoji: '🙏', name: '感恩' },
]
const leaf = computed(() => leavesStore.todayLeaf)
const style = computed(() => leaf.value ? generateLeafStyle(leaf.value.leaf_seed) : generateLeafStyle('0000000000000000'))
const collection = computed(() => leavesStore.collection)
const today = computed(() => new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' }))
const currentMoodEmoji = computed(() => moods.find((m) => m.key === leaf.value?.mood)?.emoji || '')
const currentMoodName = computed(() => moods.find((m) => m.key === leaf.value?.mood)?.name || '')
onMounted(async () => {
await leavesStore.fetchToday()
note.value = leaf.value?.note || ''
})
watch(() => leavesStore.todayLeaf?.note, (n) => {
if (n !== undefined) note.value = n
})
async function selectMood(mood: string) {
editingMood.value = true
try {
await leavesStore.updateLeaf(leaf.value.id, { mood })
message.success('心情已记录')
} catch {
message.error('保存失败')
} finally {
setTimeout(() => (editingMood.value = false), 600)
}
}
let noteTimer: ReturnType<typeof setTimeout>
function saveNote() {
clearTimeout(noteTimer)
noteTimer = setTimeout(async () => {
if (!leaf.value) return
try {
await leavesStore.updateLeaf(leaf.value.id, { note: note.value })
} catch {}
}, 300)
}
function moodEmoji(key: string) {
return moods.find((m) => m.key === key)?.emoji || ''
}
// 加载收藏
watch(showCollection, async (val) => {
if (val) await leavesStore.fetchCollection()
})
</script>
<style scoped>
.leaf-view {
flex: 1; overflow-y: auto; padding: 24px;
background: linear-gradient(180deg, var(--color-bg) 0%, var(--color-primary-lightest) 100%);
}
.leaf-header { text-align: center; margin-bottom: 8px; }
.leaf-header h2 { margin: 0; font-size: 22px; color: var(--color-primary-darker); }
.subtitle { font-size: 13px; color: var(--color-text-hint); margin: 4px 0 0; }
.leaf-main { max-width: 480px; margin: 0 auto; }
/* 叶子舞台 */
.leaf-stage {
display: flex; justify-content: center; align-items: center;
height: 240px; margin-bottom: 20px;
filter: drop-shadow(0 8px 16px rgba(0,150,136,0.2));
}
.leaf-wrapper { transition: transform 0.4s; transform-origin: 50% 90%; }
.leaf-svg { width: 160px; height: 192px; }
.leaf-svg.sway { animation: leaf-sway 4s ease-in-out infinite; transform-origin: 50% 100%; }
@keyframes leaf-sway {
0%, 100% { transform: rotate(-2deg); }
50% { transform: rotate(2deg); }
}
/* 心情 */
.section-label { font-size: 13px; color: var(--color-text-secondary); margin-bottom: 10px; font-weight: 500; }
.mood-section { margin-bottom: 20px; }
.mood-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.mood-chip {
display: flex; flex-direction: column; align-items: center; gap: 4px;
padding: 12px 8px; border-radius: 12px; background: var(--color-surface);
border: 2px solid transparent; cursor: pointer; transition: all 0.2s;
}
.mood-chip:hover { transform: translateY(-2px); border-color: var(--color-primary-lighter); }
.mood-chip.active { border-color: var(--color-primary); background: var(--color-primary-lightest); }
.mood-emoji { font-size: 24px; }
.mood-emoji.large { font-size: 28px; }
.mood-name { font-size: 12px; color: var(--color-text-secondary); }
.note-section { margin-bottom: 16px; }
.today-mood-display {
display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 12px; background: var(--color-primary-lightest); border-radius: 12px;
font-size: 15px; color: var(--color-primary-darker); font-weight: 500;
}
.loading { text-align: center; padding: 60px; color: var(--color-text-hint); }
.collection-entry { text-align: center; margin-top: 20px; }
/* 图鉴弹窗 */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
display: flex; align-items: center; justify-content: center; z-index: 1000;
}
.collection-modal {
width: 560px; max-height: 70vh; background: var(--color-surface); border-radius: 16px;
display: flex; flex-direction: column; box-shadow: 0 12px 40px rgba(0,0,0,0.15);
}
.modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 20px 24px 16px; border-bottom: 1px solid var(--color-border);
}
.modal-header h3 { margin: 0; font-size: 18px; }
.close-btn { cursor: pointer; font-size: 18px; color: var(--color-text-hint); }
.collection-grid {
flex: 1; overflow-y: auto; padding: 20px 24px;
display: grid; grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); gap: 16px;
}
.empty { grid-column: 1 / -1; text-align: center; color: var(--color-text-hint); padding: 30px; }
.collection-leaf {
display: flex; flex-direction: column; align-items: center; gap: 4px;
padding: 8px; border-radius: 10px; background: var(--color-bg); position: relative;
}
.mini-leaf { width: 56px; height: 67px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1)); }
.leaf-date { font-size: 11px; color: var(--color-text-hint); }
.leaf-mood { position: absolute; top: 4px; right: 4px; font-size: 14px; }
</style>
+295
View File
@@ -0,0 +1,295 @@
<template>
<div class="tree-view">
<div class="tree-header">
<h2>🌳 好友之树</h2>
<p class="subtitle">你们的友谊会长成一棵树</p>
</div>
<div v-if="trees.length === 0 && !loading" class="empty-state">
<div style="font-size: 56px">🌱</div>
<p>还没有好友加个好友一起种树吧</p>
</div>
<div v-else class="tree-content">
<!-- 好友选择 -->
<div class="friend-selector">
<div v-for="t in trees" :key="t.friend_id" class="friend-chip"
:class="{ active: selectedFriend === t.friend_id }"
@click="selectFriend(t.friend_id)">
<n-avatar :size="32" round :style="{ background: 'var(--color-primary)' }">
{{ (t.friend_name || '?')[0] }}
</n-avatar>
<span class="chip-emoji">{{ t.stage_emoji }}</span>
</div>
</div>
<!-- 树展示 -->
<div v-if="currentTree" class="tree-stage" :class="{ 'just-watered': waterAnim }">
<svg viewBox="0 0 200 220" class="tree-svg">
<!-- 地面 -->
<ellipse cx="100" cy="205" rx="60" ry="8" fill="rgba(139,90,43,0.15)" />
<!-- 种子阶段只显示种子 -->
<template v-if="currentTree.stage_index === 0">
<ellipse cx="100" cy="200" rx="10" ry="7" :fill="treeTrunkColor(style)" />
</template>
<!-- 树干 + 树冠 -->
<template v-else>
<rect :x="100 - style.trunkWidth / 2" y="140" :width="style.trunkWidth" :height="65"
:fill="treeTrunkColor(style)" rx="2" />
<g v-for="(p, i) in canopyPositions(style, 100, 100)" :key="i">
<circle :cx="p.cx" :cy="p.cy" :r="p.r" :fill="treeCanopyColor(style, i % 2 ? 5 : -3)" />
</g>
<!-- 露珠 -->
<g v-for="(d, i) in dropletPositions(style, 100, 100)" :key="'d'+i">
<circle :cx="d.x" :cy="d.y" r="2.5" fill="rgba(120,200,255,0.85)" />
<circle :cx="d.x - 0.8" :cy="d.y - 0.8" r="0.8" fill="rgba(255,255,255,0.9)" />
</g>
</template>
</svg>
<!-- 浇水时的花瓣飘落 -->
<div v-if="petals.length" class="petals">
<span v-for="(p, i) in petals" :key="i" class="petal" :style="p">🌸</span>
</div>
</div>
<!-- 信息卡片 -->
<div v-if="currentTree" class="tree-info">
<div class="stage-badge">
<span class="stage-emoji">{{ currentTree.stage_emoji }}</span>
<span class="stage-name">{{ currentTree.stage_name }}</span>
<span class="level">Lv.{{ currentTree.stage_index + 1 }}</span>
</div>
<div class="stats">
<div class="stat">
<span class="stat-value">{{ currentTree.message_count }}</span>
<span class="stat-label">条消息</span>
</div>
<div class="stat">
<span class="stat-value">{{ currentTree.water_count }}</span>
<span class="stat-label">次浇水</span>
</div>
<div class="stat">
<span class="stat-value">{{ currentTree.total_score }}</span>
<span class="stat-label">成长值</span>
</div>
</div>
<!-- 进度条 -->
<div v-if="currentTree.next_threshold" class="progress-section">
<div class="progress-bar">
<div class="progress-fill"
:style="{ width: progressPercent + '%' }"></div>
</div>
<span class="progress-text">距离{{ nextStageName }}还差 {{ currentTree.next_threshold - currentTree.total_score }} 成长值</span>
</div>
<div v-else class="max-stage">已达最高阶段这是一棵参天古树 🎉</div>
<!-- 浇水按钮 -->
<n-button type="primary" size="large" round :loading="watering" @click="doWater"
style="margin-top: 16px">
💧 {{ currentTree.friend_name }} 的树浇水
</n-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useMessage } from 'naive-ui'
import { treesApi } from '@/api/trees'
import {
generateTreeStyle, treeTrunkColor, treeCanopyColor,
canopyPositions, dropletPositions,
} from '@/utils/treeGenerator'
const message = useMessage()
const trees = ref<any[]>([])
const selectedFriend = ref<string | null>(null)
const currentTree = ref<any>(null)
const loading = ref(true)
const watering = ref(false)
const waterAnim = ref(false)
const petals = ref<any[]>([])
const STAGE_NAMES = ['种子', '萌芽', '幼苗', '小树', '大树', '古树']
const style = computed(() => {
if (!currentTree.value) return generateTreeStyle(0, '0000000000000000', 0)
return generateTreeStyle(currentTree.value.stage_index, currentTree.value.seed, currentTree.value.water_count)
})
const progressPercent = computed(() => {
if (!currentTree.value?.next_threshold) return 100
const cur = currentTree.value.stage_index
const curBase = cur === 0 ? 0 : [0, 11, 41, 151, 401, 1001][cur]
const next = currentTree.value.next_threshold
const range = next - curBase
const into = currentTree.value.total_score - curBase
return Math.min(100, Math.max(5, (into / range) * 100))
})
const nextStageName = computed(() => {
if (!currentTree.value) return ''
const nextIdx = currentTree.value.stage_index + 1
return STAGE_NAMES[nextIdx] || ''
})
onMounted(async () => {
await loadTrees()
})
async function loadTrees() {
loading.value = true
try {
const { data } = await treesApi.getAll()
trees.value = data
if (data.length > 0 && !selectedFriend.value) {
await selectFriend(data[0].friend_id)
}
} catch {
message.error('加载失败')
} finally {
loading.value = false
}
}
async function selectFriend(friendId: string) {
selectedFriend.value = friendId
try {
const { data } = await treesApi.getTree(friendId)
currentTree.value = data
} catch {
message.error('加载失败')
}
}
async function doWater() {
if (!selectedFriend.value) return
watering.value = true
waterAnim.value = true
setTimeout(() => (waterAnim.value = false), 800)
try {
const { data } = await treesApi.water(selectedFriend.value)
const leveledUp = data.leveled_up
currentTree.value = data
// 更新列表中的 emoji
const t = trees.value.find((t) => t.friend_id === selectedFriend.value)
if (t) t.stage_emoji = data.stage_emoji
if (leveledUp) {
message.success(`🎉 树长大了!现在是「${data.stage_name}`)
spawnPetals()
} else {
message.success('💧 浇水成功,+5 成长值')
}
} catch {
message.error('浇水失败')
} finally {
watering.value = false
}
}
function spawnPetals() {
petals.value = []
for (let i = 0; i < 12; i++) {
petals.value.push({
left: Math.random() * 80 + 10 + '%',
top: -20 + 'px',
animationDelay: Math.random() * 0.5 + 's',
fontSize: 16 + Math.random() * 12 + 'px',
})
}
setTimeout(() => (petals.value = []), 3000)
}
</script>
<style scoped>
.tree-view {
flex: 1; overflow-y: auto; padding: 24px;
background: linear-gradient(180deg, var(--color-bg) 0%, #E8F5E9 100%);
}
.tree-header { text-align: center; margin-bottom: 16px; }
.tree-header h2 { margin: 0; font-size: 22px; color: var(--color-primary-darker); }
.subtitle { font-size: 13px; color: var(--color-text-hint); margin: 4px 0 0; }
.empty-state { text-align: center; padding: 80px 20px; color: var(--color-text-hint); }
.empty-state p { margin-top: 12px; font-size: 14px; }
.tree-content { max-width: 480px; margin: 0 auto; }
.friend-selector {
display: flex; gap: 10px; overflow-x: auto; padding: 8px 0 16px;
justify-content: center; flex-wrap: wrap;
}
.friend-chip {
position: relative; cursor: pointer; padding: 4px;
border-radius: 50%; border: 2px solid transparent; transition: all 0.2s;
}
.friend-chip:hover { border-color: var(--color-primary-lighter); }
.friend-chip.active { border-color: var(--color-primary); }
.chip-emoji { position: absolute; bottom: -2px; right: -2px; font-size: 14px; }
/* 树舞台 */
.tree-stage {
position: relative; display: flex; justify-content: center; align-items: flex-end;
height: 280px; margin-bottom: 20px;
}
.tree-stage.just-watered { animation: water-shake 0.5s ease; }
@keyframes water-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-3px); }
75% { transform: translateX(3px); }
}
.tree-svg { width: 220px; height: 242px; filter: drop-shadow(0 6px 12px rgba(0,0,0,0.1)); }
/* 花瓣 */
.petals { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.petal {
position: absolute; animation: petal-fall 2.5s ease-in forwards;
}
@keyframes petal-fall {
0% { transform: translateY(0) rotate(0deg); opacity: 1; }
100% { transform: translateY(280px) rotate(360deg); opacity: 0; }
}
/* 信息卡 */
.tree-info {
background: var(--color-surface); border-radius: 16px; padding: 20px;
border: 1px solid var(--color-border); text-align: center;
}
.stage-badge {
display: flex; align-items: center; justify-content: center; gap: 8px;
margin-bottom: 16px;
}
.stage-emoji { font-size: 32px; }
.stage-name { font-size: 20px; font-weight: 700; color: var(--color-primary-darker); }
.level {
font-size: 12px; padding: 2px 8px; border-radius: 10px;
background: var(--color-primary-lightest); color: var(--color-primary); font-weight: 600;
}
.stats {
display: flex; justify-content: space-around; margin-bottom: 16px;
padding: 12px 0; border-top: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
}
.stat { display: flex; flex-direction: column; gap: 2px; }
.stat-value { font-size: 20px; font-weight: 700; color: var(--color-primary); }
.stat-label { font-size: 12px; color: var(--color-text-hint); }
.progress-section { margin-bottom: 8px; }
.progress-bar {
height: 8px; background: var(--color-border); border-radius: 4px;
overflow: hidden; margin-bottom: 6px;
}
.progress-fill {
height: 100%; background: linear-gradient(90deg, var(--color-primary-light), var(--color-primary));
border-radius: 4px; transition: width 0.6s;
}
.progress-text { font-size: 12px; color: var(--color-text-hint); }
.max-stage { font-size: 13px; color: var(--color-primary); margin-bottom: 8px; }
</style>
+6
View File
@@ -23,3 +23,9 @@
再做中优先
最后做低优先
如何使其他人也可以用青叶?
我现在有一台服务器,可以用上吗?
我现在想要一些特色的亮点,是其他社交软件没有的