From a0f441d8ae268a4c26e07a66f14af803ca2bac83 Mon Sep 17 00:00:00 2001 From: hefanyang Date: Sat, 13 Jun 2026 17:57:43 +0800 Subject: [PATCH] 1.6 --- backend/app/main.py | 5 +- backend/app/models/__init__.py | 6 + backend/app/models/daily_leaf.py | 25 ++ backend/app/models/friendship_tree.py | 25 ++ backend/app/models/time_capsule.py | 25 ++ backend/app/routers/capsules.py | 51 +++ backend/app/routers/leaves.py | 46 +++ backend/app/routers/trees.py | 45 +++ backend/app/schemas/capsule.py | 13 + backend/app/schemas/leaf.py | 8 + backend/app/services/capsule_service.py | 105 ++++++ backend/app/services/leaf_service.py | 76 ++++ backend/app/services/tree_service.py | 149 ++++++++ frontend/src/api/capsules.ts | 17 + frontend/src/api/leaves.ts | 10 + frontend/src/api/trees.ts | 9 + frontend/src/layouts/UnifiedLayout.vue | 4 + frontend/src/router/index.ts | 43 +++ frontend/src/stores/capsules.ts | 26 ++ frontend/src/stores/leaves.ts | 33 ++ frontend/src/utils/leafGenerator.ts | 89 +++++ frontend/src/utils/treeGenerator.ts | 77 ++++ frontend/src/views/garden/CapsuleView.vue | 373 ++++++++++++++++++++ frontend/src/views/garden/GardenSidebar.vue | 53 +++ frontend/src/views/garden/GardenView.vue | 79 +++++ frontend/src/views/garden/LeafView.vue | 240 +++++++++++++ frontend/src/views/garden/TreeView.vue | 295 ++++++++++++++++ 提示词.md | 8 +- 28 files changed, 1933 insertions(+), 2 deletions(-) create mode 100644 backend/app/models/daily_leaf.py create mode 100644 backend/app/models/friendship_tree.py create mode 100644 backend/app/models/time_capsule.py create mode 100644 backend/app/routers/capsules.py create mode 100644 backend/app/routers/leaves.py create mode 100644 backend/app/routers/trees.py create mode 100644 backend/app/schemas/capsule.py create mode 100644 backend/app/schemas/leaf.py create mode 100644 backend/app/services/capsule_service.py create mode 100644 backend/app/services/leaf_service.py create mode 100644 backend/app/services/tree_service.py create mode 100644 frontend/src/api/capsules.ts create mode 100644 frontend/src/api/leaves.ts create mode 100644 frontend/src/api/trees.ts create mode 100644 frontend/src/stores/capsules.ts create mode 100644 frontend/src/stores/leaves.ts create mode 100644 frontend/src/utils/leafGenerator.ts create mode 100644 frontend/src/utils/treeGenerator.ts create mode 100644 frontend/src/views/garden/CapsuleView.vue create mode 100644 frontend/src/views/garden/GardenSidebar.vue create mode 100644 frontend/src/views/garden/GardenView.vue create mode 100644 frontend/src/views/garden/LeafView.vue create mode 100644 frontend/src/views/garden/TreeView.vue diff --git a/backend/app/main.py b/backend/app/main.py index ab6b9aa..eef4fa7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8c20269..8d4a1ca 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/daily_leaf.py b/backend/app/models/daily_leaf.py new file mode 100644 index 0000000..2df7501 --- /dev/null +++ b/backend/app/models/daily_leaf.py @@ -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]) diff --git a/backend/app/models/friendship_tree.py b/backend/app/models/friendship_tree.py new file mode 100644 index 0000000..240c33d --- /dev/null +++ b/backend/app/models/friendship_tree.py @@ -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]) diff --git a/backend/app/models/time_capsule.py b/backend/app/models/time_capsule.py new file mode 100644 index 0000000..6e5b8df --- /dev/null +++ b/backend/app/models/time_capsule.py @@ -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]) diff --git a/backend/app/routers/capsules.py b/backend/app/routers/capsules.py new file mode 100644 index 0000000..367c23f --- /dev/null +++ b/backend/app/routers/capsules.py @@ -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)) diff --git a/backend/app/routers/leaves.py b/backend/app/routers/leaves.py new file mode 100644 index 0000000..711a767 --- /dev/null +++ b/backend/app/routers/leaves.py @@ -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) diff --git a/backend/app/routers/trees.py b/backend/app/routers/trees.py new file mode 100644 index 0000000..82e9a2e --- /dev/null +++ b/backend/app/routers/trees.py @@ -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)) diff --git a/backend/app/schemas/capsule.py b/backend/app/schemas/capsule.py new file mode 100644 index 0000000..9763b34 --- /dev/null +++ b/backend/app/schemas/capsule.py @@ -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) diff --git a/backend/app/schemas/leaf.py b/backend/app/schemas/leaf.py new file mode 100644 index 0000000..6b61c68 --- /dev/null +++ b/backend/app/schemas/leaf.py @@ -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) diff --git a/backend/app/services/capsule_service.py b/backend/app/services/capsule_service.py new file mode 100644 index 0000000..e778e2f --- /dev/null +++ b/backend/app/services/capsule_service.py @@ -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(), + } diff --git a/backend/app/services/leaf_service.py b/backend/app/services/leaf_service.py new file mode 100644 index 0000000..c2fe1e4 --- /dev/null +++ b/backend/app/services/leaf_service.py @@ -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, + } diff --git a/backend/app/services/tree_service.py b/backend/app/services/tree_service.py new file mode 100644 index 0000000..61e78ad --- /dev/null +++ b/backend/app/services/tree_service.py @@ -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 diff --git a/frontend/src/api/capsules.ts b/frontend/src/api/capsules.ts new file mode 100644 index 0000000..bad12c3 --- /dev/null +++ b/frontend/src/api/capsules.ts @@ -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), +} diff --git a/frontend/src/api/leaves.ts b/frontend/src/api/leaves.ts new file mode 100644 index 0000000..08674cc --- /dev/null +++ b/frontend/src/api/leaves.ts @@ -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'), +} diff --git a/frontend/src/api/trees.ts b/frontend/src/api/trees.ts new file mode 100644 index 0000000..c273786 --- /dev/null +++ b/frontend/src/api/trees.ts @@ -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`), +} diff --git a/frontend/src/layouts/UnifiedLayout.vue b/frontend/src/layouts/UnifiedLayout.vue index 10998a4..b5ec6a5 100644 --- a/frontend/src/layouts/UnifiedLayout.vue +++ b/frontend/src/layouts/UnifiedLayout.vue @@ -20,6 +20,9 @@ 🌿 + + +
@@ -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' }) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 16960e0..79ac232 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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', diff --git a/frontend/src/stores/capsules.ts b/frontend/src/stores/capsules.ts new file mode 100644 index 0000000..8426ec4 --- /dev/null +++ b/frontend/src/stores/capsules.ts @@ -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([]) + 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 } +}) diff --git a/frontend/src/stores/leaves.ts b/frontend/src/stores/leaves.ts new file mode 100644 index 0000000..59b1c06 --- /dev/null +++ b/frontend/src/stores/leaves.ts @@ -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(null) + const collection = ref([]) + 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 } +}) diff --git a/frontend/src/utils/leafGenerator.ts b/frontend/src/utils/leafGenerator.ts new file mode 100644 index 0000000..21a3660 --- /dev/null +++ b/frontend/src/utils/leafGenerator.ts @@ -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 path(viewBox 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) +} diff --git a/frontend/src/utils/treeGenerator.ts b/frontend/src/utils/treeGenerator.ts new file mode 100644 index 0000000..c845364 --- /dev/null +++ b/frontend/src/utils/treeGenerator.ts @@ -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 +} diff --git a/frontend/src/views/garden/CapsuleView.vue b/frontend/src/views/garden/CapsuleView.vue new file mode 100644 index 0000000..b6b7af2 --- /dev/null +++ b/frontend/src/views/garden/CapsuleView.vue @@ -0,0 +1,373 @@ + + + + + diff --git a/frontend/src/views/garden/GardenSidebar.vue b/frontend/src/views/garden/GardenSidebar.vue new file mode 100644 index 0000000..24c5858 --- /dev/null +++ b/frontend/src/views/garden/GardenSidebar.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/frontend/src/views/garden/GardenView.vue b/frontend/src/views/garden/GardenView.vue new file mode 100644 index 0000000..9f3951d --- /dev/null +++ b/frontend/src/views/garden/GardenView.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/frontend/src/views/garden/LeafView.vue b/frontend/src/views/garden/LeafView.vue new file mode 100644 index 0000000..052d721 --- /dev/null +++ b/frontend/src/views/garden/LeafView.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/frontend/src/views/garden/TreeView.vue b/frontend/src/views/garden/TreeView.vue new file mode 100644 index 0000000..2c6c1c4 --- /dev/null +++ b/frontend/src/views/garden/TreeView.vue @@ -0,0 +1,295 @@ + + + + + diff --git a/提示词.md b/提示词.md index 557bae0..8b19a10 100644 --- a/提示词.md +++ b/提示词.md @@ -22,4 +22,10 @@ 再做中优先 -最后做低优先 \ No newline at end of file +最后做低优先 + +如何使其他人也可以用青叶? + +我现在有一台服务器,可以用上吗? + +我现在想要一些特色的亮点,是其他社交软件没有的 \ No newline at end of file