diff --git a/backend/app/main.py b/backend/app/main.py index eef4fa7..4e43585 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, leaves, trees, capsules +from app.routers import auth, users, conversations, messages, friends, admin, uploads, moments, leaves, trees, capsules, echoes, sync, climates, flash app.include_router(auth.router, prefix="/api/v1/auth", tags=["认证"]) app.include_router(users.router, prefix="/api/v1/users", tags=["用户"]) @@ -77,6 +77,10 @@ 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=["时光胶囊"]) +app.include_router(echoes.router, prefix="/api/v1/echoes", tags=["念念回音"]) +app.include_router(sync.router, prefix="/api/v1/sync", tags=["默契种子"]) +app.include_router(climates.router, prefix="/api/v1/climates", tags=["聊天气候"]) +app.include_router(flash.router, prefix="/api/v1/flash", tags=["萤火虫时刻"]) # WebSocket from app.websocket.router import websocket_router diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8d4a1ca..a43905a 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -11,6 +11,10 @@ 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 +from app.models.miss_echo import MissEcho +from app.models.sync_seed import SyncQuestion, SyncSeed +from app.models.chat_climate import ChatClimate +from app.models.flash_event import FlashEvent, FlashParticipation __all__ = [ "User", @@ -26,4 +30,10 @@ __all__ = [ "DailyMoodLeaf", "FriendshipTree", "TimeCapsule", + "MissEcho", + "SyncQuestion", + "SyncSeed", + "ChatClimate", + "FlashEvent", + "FlashParticipation", ] diff --git a/backend/app/models/chat_climate.py b/backend/app/models/chat_climate.py new file mode 100644 index 0000000..b9438af --- /dev/null +++ b/backend/app/models/chat_climate.py @@ -0,0 +1,24 @@ +"""聊天气候模型""" + +from datetime import datetime + +from sqlalchemy import String, Integer, DateTime, Text, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class ChatClimate(Base): + __tablename__ = "chat_climates" + + conversation_id: Mapped[str] = mapped_column(String(36), ForeignKey("conversations.id", ondelete="CASCADE"), primary_key=True) + season: Mapped[str] = mapped_column(String(10), nullable=False) # spring/summer/autumn/winter + temperature: Mapped[int] = mapped_column(Integer, nullable=False) # -10 ~ 40 + weather: Mapped[str] = mapped_column(String(20), nullable=False) # sunny/cloudy/rainy/windy/snowy + emoji: Mapped[str] = mapped_column(String(10), nullable=False) + daily_history: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON: [{date, season, temp, emoji}] + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=lambda: datetime.utcnow(), onupdate=lambda: datetime.utcnow() + ) + + conversation = relationship("Conversation") diff --git a/backend/app/models/flash_event.py b/backend/app/models/flash_event.py new file mode 100644 index 0000000..4065e66 --- /dev/null +++ b/backend/app/models/flash_event.py @@ -0,0 +1,41 @@ +"""萤火虫时刻模型""" + +from datetime import datetime + +from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class FlashEvent(Base): + """全服随机萤火虫事件""" + __tablename__ = "flash_events" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + type: Mapped[str] = mapped_column(String(20), default="firefly") # firefly / meteor + start_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + end_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + target_clicks: Mapped[int] = mapped_column(Integer, default=100) # 全服目标点击数 + total_clicks: Mapped[int] = mapped_column(Integer, default=0) + reached: Mapped[bool] = mapped_column(Boolean, default=False) + leaf_seed: Mapped[str] = mapped_column(String(16), nullable=False) # 限定纪念叶种子 + leaf_variant: Mapped[str] = mapped_column(String(40), nullable=False) # 永不复刻的变体标识 + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow()) + + +class FlashParticipation(Base): + """用户参与记录(集气点击 + 获得的叶子)""" + __tablename__ = "flash_participations" + __table_args__ = ( + UniqueConstraint("event_id", "user_id", name="uq_flash_participation"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + event_id: Mapped[str] = mapped_column(String(36), ForeignKey("flash_events.id", ondelete="CASCADE")) + user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE")) + clicks: Mapped[int] = mapped_column(Integer, default=0) + earned: Mapped[bool] = mapped_column(Boolean, default=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/miss_echo.py b/backend/app/models/miss_echo.py new file mode 100644 index 0000000..1144291 --- /dev/null +++ b/backend/app/models/miss_echo.py @@ -0,0 +1,24 @@ +"""念念回音模型""" + +from datetime import datetime + +from sqlalchemy import String, Boolean, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class MissEcho(Base): + __tablename__ = "miss_echoes" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + from_user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE")) + to_user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE")) + leaf_seed: Mapped[str] = mapped_column(String(32), nullable=False) # 程序化叶子种子 + message: Mapped[str | None] = mapped_column(String(50), nullable=True) # 可选附言 + delivered_online: Mapped[bool] = mapped_column(Boolean, default=False) # 是否在线送达 + read_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow()) + + from_user = relationship("User", foreign_keys=[from_user_id]) + to_user = relationship("User", foreign_keys=[to_user_id]) diff --git a/backend/app/models/sync_seed.py b/backend/app/models/sync_seed.py new file mode 100644 index 0000000..cf3c6bb --- /dev/null +++ b/backend/app/models/sync_seed.py @@ -0,0 +1,37 @@ +"""默契种子模型""" + +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 SyncQuestion(Base): + __tablename__ = "sync_questions" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + content: Mapped[str] = mapped_column(String(200), nullable=False) + active_date: Mapped[str | None] = mapped_column(String(10), nullable=True) # YYYY-MM-DD + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow()) + + +class SyncSeed(Base): + """默契种子:两人对同一题的作答""" + __tablename__ = "sync_seeds" + __table_args__ = ( + UniqueConstraint("question_id", "user_a", "user_b", name="uq_sync_seed_pair"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + question_id: Mapped[str] = mapped_column(String(36), ForeignKey("sync_questions.id", ondelete="CASCADE")) + user_a: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE")) + user_b: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE")) + answer_a: Mapped[str | None] = mapped_column(String(500), nullable=True) + answer_b: Mapped[str | None] = mapped_column(String(500), nullable=True) + score: Mapped[int | None] = mapped_column(Integer, nullable=True) # 0-100 + leaf_seed: Mapped[str | None] = mapped_column(String(16), nullable=True) # 默契叶种子 + status: Mapped[str] = mapped_column(String(20), default="draft") # draft/revealed + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow()) + revealed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/backend/app/routers/climates.py b/backend/app/routers/climates.py new file mode 100644 index 0000000..84a7734 --- /dev/null +++ b/backend/app/routers/climates.py @@ -0,0 +1,38 @@ +"""聊天气候路由""" + +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.climate_service import ClimateService + +router = APIRouter() + + +@router.get("/{conversation_id}") +async def get_climate( + conversation_id: str, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取/计算会话气候""" + service = ClimateService(db) + try: + return await service.compute(conversation_id, user.id) + except ValueError as e: + raise HTTPException(status_code=403, detail=str(e)) + + +@router.get("/{conversation_id}/calendar") +async def get_calendar( + conversation_id: str, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取 30 天气候日历""" + service = ClimateService(db) + try: + return await service.get_calendar(conversation_id, user.id) + except ValueError as e: + raise HTTPException(status_code=403, detail=str(e)) diff --git a/backend/app/routers/echoes.py b/backend/app/routers/echoes.py new file mode 100644 index 0000000..7b791ca --- /dev/null +++ b/backend/app/routers/echoes.py @@ -0,0 +1,40 @@ +"""念念回音路由""" + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies import get_db, get_current_user +from app.models.user import User +from app.services.echo_service import EchoService + +router = APIRouter() + + +class EchoCreate(BaseModel): + to_user_id: str + message: str | None = Field(None, max_length=50) + + +@router.post("/") +async def send_echo( + req: EchoCreate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """发送一片回音叶""" + service = EchoService(db) + try: + return await service.send_echo(user.id, req.to_user_id, req.message) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/") +async def list_echoes( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取回音列表""" + service = EchoService(db) + return await service.get_echoes(user.id) diff --git a/backend/app/routers/flash.py b/backend/app/routers/flash.py new file mode 100644 index 0000000..6fa2764 --- /dev/null +++ b/backend/app/routers/flash.py @@ -0,0 +1,68 @@ +"""萤火虫时刻路由""" + +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.flash_service import FlashService + +router = APIRouter() + + +@router.get("/active") +async def get_active( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取当前萤火虫事件。若没有,随机概率尝试触发一个(让功能可被体验到)""" + service = FlashService(db) + event = await service.get_active_event() + if event: + return {"event": event} + # 概率触发:每次请求 ~4% 概率尝试生成(受单日 1 次限制),让萤火虫保持稀有 + import hashlib + seed = hashlib.md5(f"{user.id}:{__import__('datetime').datetime.utcnow().strftime('%H%M')}".encode()).hexdigest() + if int(seed[:2], 16) < 10: # ~4% + spawned = await service.try_spawn() + if spawned: + return {"event": spawned} + return {"event": None} + + +@router.post("/{event_id}/click") +async def click( + event_id: str, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """集气点击""" + service = FlashService(db) + try: + return await service.click(user.id, event_id) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/{event_id}/claim") +async def claim( + event_id: str, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """领取限定纪念叶""" + service = FlashService(db) + try: + return await service.claim_reward(user.id, event_id) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/album") +async def get_album( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """我的萤火虫图鉴""" + service = FlashService(db) + return await service.get_my_album(user.id) diff --git a/backend/app/routers/leaves.py b/backend/app/routers/leaves.py index 711a767..1a2940a 100644 --- a/backend/app/routers/leaves.py +++ b/backend/app/routers/leaves.py @@ -44,3 +44,13 @@ async def get_collection( """获取叶子收藏""" service = LeafService(db) return await service.get_collection(user.id) + + +@router.get("/grove") +async def get_grove( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """情绪共鸣林:好友圈今日心情叶聚合""" + service = LeafService(db) + return await service.get_grove(user.id) diff --git a/backend/app/routers/sync.py b/backend/app/routers/sync.py new file mode 100644 index 0000000..2970fa3 --- /dev/null +++ b/backend/app/routers/sync.py @@ -0,0 +1,58 @@ +"""默契种子路由""" + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies import get_db, get_current_user +from app.models.user import User +from app.services.sync_service import SyncService + +router = APIRouter() + + +class AnswerSubmit(BaseModel): + question_id: str + friend_id: str + answer: str = Field(..., min_length=1, max_length=500) + + +@router.get("/question") +async def get_today_question( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取今日默契题""" + service = SyncService(db) + try: + return await service.get_today_question() + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/answer") +async def submit_answer( + req: AnswerSubmit, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """提交答案""" + service = SyncService(db) + try: + return await service.submit_answer(user.id, req.question_id, req.friend_id, req.answer) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/{seed_id}") +async def get_seed( + seed_id: str, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """查看默契种子结果""" + service = SyncService(db) + try: + return await service.get_seed(user.id, seed_id) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/app/routers/trees.py b/backend/app/routers/trees.py index 82e9a2e..83421c5 100644 --- a/backend/app/routers/trees.py +++ b/backend/app/routers/trees.py @@ -43,3 +43,14 @@ async def water_tree( return await service.water(user.id, friend_id) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/{friend_id}/heartbeat") +async def get_heartbeat( + friend_id: str, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取心跳同步数据""" + service = TreeService(db) + return await service.get_heartbeat(user.id, friend_id) diff --git a/backend/app/services/climate_service.py b/backend/app/services/climate_service.py new file mode 100644 index 0000000..afc11dc --- /dev/null +++ b/backend/app/services/climate_service.py @@ -0,0 +1,161 @@ +"""聊天气候服务:把对话节奏翻译成季节/温度/天气""" + +import json +from datetime import datetime, timedelta + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.chat_climate import ChatClimate +from app.models.message import Message +from app.models.conversation_member import ConversationMember + + +SEASON_EMOJI = { + "spring": "🌸", "summer": "☀️", "autumn": "🍁", "winter": "❄️", +} +WEATHER_EMOJI = { + "sunny": "晴", "cloudy": "多云", "rainy": "雨", "windy": "风", "snowy": "雪", +} + + +class ClimateService: + def __init__(self, db: AsyncSession): + self.db = db + + async def _verify_member(self, conversation_id: str, user_id: str) -> bool: + result = await self.db.execute( + select(ConversationMember).where( + ConversationMember.conversation_id == conversation_id, + ConversationMember.user_id == user_id, + ) + ) + return result.scalars().first() is not None + + async def compute(self, conversation_id: str, user_id: str) -> dict: + """计算/更新会话气候""" + if not await self._verify_member(conversation_id, user_id): + raise ValueError("无权访问该会话") + + # 取近 14 天消息 + since = datetime.utcnow() - timedelta(days=14) + result = await self.db.execute( + select(Message).where( + Message.conversation_id == conversation_id, + Message.is_deleted == False, + Message.created_at >= since, + ).order_by(Message.created_at.asc()) + ) + msgs = result.scalars().all() + + season, temperature, weather = self._analyze(msgs) + emoji = SEASON_EMOJI[season] + + # 更新气候记录 + cc_result = await self.db.execute( + select(ChatClimate).where(ChatClimate.conversation_id == conversation_id) + ) + climate = cc_result.scalars().first() + + # 日历历史 + history = [] + if climate and climate.daily_history: + try: + history = json.loads(climate.daily_history) + except Exception: + history = [] + + today = datetime.utcnow().strftime("%Y-%m-%d") + history = [h for h in history if h.get("date") != today] + history.append({"date": today, "season": season, "temp": temperature, "emoji": emoji}) + history = history[-30:] # 保留 30 天 + + if climate: + climate.season = season + climate.temperature = temperature + climate.weather = weather + climate.emoji = emoji + climate.daily_history = json.dumps(history, ensure_ascii=False) + else: + climate = ChatClimate( + conversation_id=conversation_id, + season=season, temperature=temperature, weather=weather, + emoji=emoji, daily_history=json.dumps(history, ensure_ascii=False), + ) + self.db.add(climate) + await self.db.flush() + + return { + "conversation_id": conversation_id, + "season": season, + "temperature": temperature, + "weather": weather, + "emoji": emoji, + "weather_label": WEATHER_EMOJI[weather], + "message_count_14d": len(msgs), + } + + def _analyze(self, msgs: list[Message]) -> tuple[str, int, str]: + """根据消息列表分析季节/温度/天气""" + n = len(msgs) + if n == 0: + return "winter", -8, "snowy" + + # 14 天内的消息分布 → 温度(活跃度) + # 温度 = 消息密度映射到 -10..40 + density = min(n / 14, 1) # 平均每天消息数(封顶 1 表示满) + temperature = round(-8 + density * 46) # -8 .. 38 + + # 季节:按温度分段 + if temperature >= 28: + season = "summer" + elif temperature >= 15: + season = "spring" + elif temperature >= 5: + season = "autumn" + else: + season = "winter" + + # 天气:根据回复间隔/连续性 + 平均字数 + if n >= 2: + gaps = [] + for i in range(1, n): + gap = (msgs[i].created_at - msgs[i - 1].created_at).total_seconds() + gaps.append(gap) + avg_gap = sum(gaps) / len(gaps) + avg_len = sum(len(m.content or "") for m in msgs) / n + + # 连续性(gap 小 = 连续) + if avg_gap < 120: # 2 分钟内,热烈 + weather = "sunny" + elif avg_gap < 1800: # 半小时内,正常 + weather = "cloudy" + elif avg_gap < 21600: # 6 小时内,稀疏 + weather = "rainy" + elif avg_gap < 86400: # 一天内 + weather = "windy" + else: + weather = "snowy" + + # 字数长 = 有深度对话,偏向"雨"(绵绵) + if avg_len > 50 and weather in ("sunny", "cloudy"): + weather = "rainy" + else: + weather = "cloudy" + + return season, temperature, weather + + async def get_calendar(self, conversation_id: str, user_id: str) -> list[dict]: + """获取 30 天气候日历""" + if not await self._verify_member(conversation_id, user_id): + raise ValueError("无权访问该会话") + result = await self.db.execute( + select(ChatClimate).where(ChatClimate.conversation_id == conversation_id) + ) + climate = result.scalars().first() + if not climate or not climate.daily_history: + return [] + try: + return json.loads(climate.daily_history) + except Exception: + return [] diff --git a/backend/app/services/echo_service.py b/backend/app/services/echo_service.py new file mode 100644 index 0000000..c22598b --- /dev/null +++ b/backend/app/services/echo_service.py @@ -0,0 +1,97 @@ +"""念念回音服务""" + +import hashlib +import uuid +from datetime import datetime + +from sqlalchemy import select, or_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.miss_echo import MissEcho +from app.models.user import User +from app.websocket.events import EventType +from app.websocket.manager import manager + + +class EchoService: + def __init__(self, db: AsyncSession): + self.db = db + + async def send_echo(self, from_user_id: str, to_user_id: str, + message: str | None = None) -> dict: + """发送一片回音叶""" + if from_user_id == to_user_id: + raise ValueError("不能给自己回音") + + # 校验目标用户存在 + target_result = await self.db.execute(select(User).where(User.id == to_user_id)) + if not target_result.scalars().first(): + raise ValueError("目标用户不存在") + + # 派生叶子种子 + today = datetime.utcnow().strftime("%Y%m%d") + seed = hashlib.md5(f"echo:{from_user_id}:{to_user_id}:{today}".encode()).hexdigest()[:16] + + is_online = manager.is_online(to_user_id) + + echo = MissEcho( + id=str(uuid.uuid4()), + from_user_id=from_user_id, + to_user_id=to_user_id, + leaf_seed=seed, + message=message, + delivered_online=is_online, + ) + self.db.add(echo) + await self.db.flush() + + # 发送者信息 + from_result = await self.db.execute(select(User).where(User.id == from_user_id)) + from_user = from_result.scalars().first() + + payload = { + "id": echo.id, + "from_user_id": from_user_id, + "from_username": from_user.username if from_user else "未知", + "from_nickname": from_user.nickname if from_user else None, + "from_avatar": from_user.avatar_url if from_user else None, + "leaf_seed": seed, + "message": message, + "delivered_online": is_online, + "created_at": echo.created_at.isoformat(), + } + + # 在线则实时推送,离线则在用户下次打开时收到(落入花园) + if is_online: + await manager.send_to_user(to_user_id, EventType.ECHO_SEND, payload) + + return payload + + async def get_echoes(self, user_id: str, limit: int = 30) -> list[dict]: + """获取我收到的回音(花园里飘落的叶子)""" + result = await self.db.execute( + select(MissEcho).where( + or_( + MissEcho.to_user_id == user_id, + MissEcho.from_user_id == user_id, + ) + ).order_by(MissEcho.created_at.desc()).limit(limit) + ) + echoes = [] + for e in result.scalars().all(): + from_result = await self.db.execute(select(User).where(User.id == e.from_user_id)) + fu = from_result.scalars().first() + echoes.append({ + "id": e.id, + "from_user_id": e.from_user_id, + "to_user_id": e.to_user_id, + "from_username": fu.username if fu else "未知", + "from_nickname": fu.nickname if fu else None, + "from_avatar": fu.avatar_url if fu else None, + "leaf_seed": e.leaf_seed, + "message": e.message, + "delivered_online": e.delivered_online, + "is_received": e.to_user_id == user_id, + "created_at": e.created_at.isoformat(), + }) + return echoes diff --git a/backend/app/services/flash_service.py b/backend/app/services/flash_service.py new file mode 100644 index 0000000..9cf1266 --- /dev/null +++ b/backend/app/services/flash_service.py @@ -0,0 +1,213 @@ +"""萤火虫时刻服务:全服协作型随机掉落""" + +import hashlib +import uuid +from datetime import datetime, timedelta + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.flash_event import FlashEvent, FlashParticipation +from app.websocket.events import EventType +from app.websocket.manager import manager + +# Redis(用于原子计数 + 单日限频) +import redis.asyncio as aioredis +from app.config import settings + + +class FlashService: + def __init__(self, db: AsyncSession): + self.db = db + self._redis = None + + async def _get_redis(self): + if self._redis is None: + self._redis = aioredis.from_url(settings.REDIS_URL, decode_responses=True) + return self._redis + + async def get_active_event(self) -> dict | None: + """获取当前进行中且尚未达标的萤火虫事件""" + now = datetime.utcnow() + result = await self.db.execute( + select(FlashEvent).where( + FlashEvent.start_at <= now, + FlashEvent.end_at > now, + FlashEvent.reached == False, # 已达标的不再算作可参与 + ).order_by(FlashEvent.start_at.desc()) + ) + event = result.scalars().first() + if not event: + return None + return self._event_to_dict(event) + + async def try_spawn(self) -> dict | None: + """尝试触发新事件(单日限频,由前端定时调用或后台触发)""" + r = await self._get_redis() + today = datetime.utcnow().strftime("%Y%m%d") + spawn_key = f"flash:spawned:{today}" + + # 单日最多 1 次(用 SETNX 抢占),让萤火虫保持稀有惊喜 + count = await r.get(spawn_key) + if count and int(count) >= 1: + return None + + now = datetime.utcnow() + # 事件持续 60-90 秒 + duration = 60 + (hashlib.md5(today.encode()).hexdigest()[:2] is not None) * 0 + duration = 75 # 固定 75 秒便于体验 + end_at = now + timedelta(seconds=duration) + + variant = f"firefly-{today}-{count or '0'}" + seed = hashlib.md5(variant.encode()).hexdigest()[:16] + target = 30 # 目标点击数(小规模便于达标) + + event = FlashEvent( + id=str(uuid.uuid4()), + type="firefly", + start_at=now, + end_at=end_at, + target_clicks=target, + leaf_seed=seed, + leaf_variant=variant, + ) + self.db.add(event) + await self.db.flush() + + await r.incr(spawn_key) + await r.expire(spawn_key, 86400) + + payload = self._event_to_dict(event) + # 全服广播 + await manager.broadcast(EventType.FLASH_SPAWN, payload) + return payload + + async def click(self, user_id: str, event_id: str) -> dict: + """用户点击集气""" + r = await self._get_redis() + result = await self.db.execute( + select(FlashEvent).where(FlashEvent.id == event_id) + ) + event = result.scalars().first() + if not event: + raise ValueError("事件不存在") + now = datetime.utcnow() + if now < event.start_at or now > event.end_at: + raise ValueError("事件已结束") + + # 原子计数:全服总点击 + 个人点击 + total_key = f"flash:total:{event_id}" + user_key = f"flash:user:{event_id}:{user_id}" + new_total = await r.incr(total_key) + await r.expire(total_key, 300) + user_clicks = await r.incr(user_key) + await r.expire(user_key, 300) + + # 同步到 DB(用于持久化和查询) + event.total_clicks = new_total + await self.db.flush() + + reached = new_total >= event.target_clicks + if reached and not event.reached: + event.reached = True + # 广播达标 + await manager.broadcast(EventType.FLASH_RESULT, { + "event_id": event_id, "reached": True, + "leaf_seed": event.leaf_seed, "leaf_variant": event.leaf_variant, + "message": "萤火虫被点亮了!限定纪念叶降临 🌟", + }) + + # 广播进度(节流:每 5 次或达标时) + if new_total % 5 == 0 or reached: + await manager.broadcast(EventType.FLASH_PROGRESS, { + "event_id": event_id, + "total_clicks": new_total, + "target": event.target_clicks, + "progress": min(1, new_total / event.target_clicks), + }) + + return { + "event_id": event_id, + "total_clicks": new_total, + "target": event.target_clicks, + "progress": min(1, new_total / event.target_clicks), + "my_clicks": user_clicks, + "reached": reached, + } + + async def claim_reward(self, user_id: str, event_id: str) -> dict: + """达标后领取限定纪念叶""" + result = await self.db.execute( + select(FlashEvent).where(FlashEvent.id == event_id) + ) + event = result.scalars().first() + if not event: + raise ValueError("事件不存在") + if not event.reached: + raise ValueError("尚未达标") + + # 记录参与 + p_result = await self.db.execute( + select(FlashParticipation).where( + FlashParticipation.event_id == event_id, + FlashParticipation.user_id == user_id, + ) + ) + p = p_result.scalars().first() + r = await self._get_redis() + user_clicks = int(await r.get(f"flash:user:{event_id}:{user_id}") or 0) + if p: + p.clicks = user_clicks + p.earned = True + else: + p = FlashParticipation( + id=str(uuid.uuid4()), + event_id=event_id, user_id=user_id, + clicks=user_clicks, earned=True, + ) + self.db.add(p) + await self.db.flush() + + return { + "earned": True, + "leaf_seed": event.leaf_seed, + "leaf_variant": event.leaf_variant, + "type": event.type, + "my_clicks": user_clicks, + } + + async def get_my_album(self, user_id: str) -> list[dict]: + """获取我获得的限定纪念叶图鉴""" + result = await self.db.execute( + select(FlashParticipation).where( + FlashParticipation.user_id == user_id, + FlashParticipation.earned == True, + ).order_by(FlashParticipation.created_at.desc()) + ) + album = [] + for p in result.scalars().all(): + ev = await self.db.execute(select(FlashEvent).where(FlashEvent.id == p.event_id)) + e = ev.scalars().first() + if e: + album.append({ + "leaf_seed": e.leaf_seed, + "leaf_variant": e.leaf_variant, + "type": e.type, + "my_clicks": p.clicks, + "earned_at": p.created_at.isoformat(), + }) + return album + + def _event_to_dict(self, e: FlashEvent) -> dict: + return { + "id": e.id, + "type": e.type, + "start_at": e.start_at.isoformat(), + "end_at": e.end_at.isoformat(), + "target_clicks": e.target_clicks, + "total_clicks": e.total_clicks, + "reached": e.reached, + "leaf_seed": e.leaf_seed, + "leaf_variant": e.leaf_variant, + "progress": min(1, (e.total_clicks or 0) / e.target_clicks) if e.target_clicks else 0, + } diff --git a/backend/app/services/leaf_service.py b/backend/app/services/leaf_service.py index c2fe1e4..aeb7783 100644 --- a/backend/app/services/leaf_service.py +++ b/backend/app/services/leaf_service.py @@ -64,6 +64,58 @@ class LeafService: ) return [self._leaf_to_dict(l) for l in result.scalars().all()] + async def get_grove(self, user_id: str) -> dict: + """获取情绪共鸣林:自己 + 好友今日的心情叶,聚合成俯瞰森林""" + from app.models.friend import Friend + today = date.today() + + # 自己 + 好友 ID + friends_result = await self.db.execute( + select(Friend.friend_user_id).where(Friend.user_id == user_id) + ) + visible_ids = [user_id] + [r[0] for r in friends_result.all()] + + # 查今日所有人的叶子 + result = await self.db.execute( + select(DailyMoodLeaf).where( + DailyMoodLeaf.user_id.in_(visible_ids), + DailyMoodLeaf.leaf_date == today, + ) + ) + leaves = result.scalars().all() + + # 排布:自己在中心(position 0),好友按确定性环绕 + import math + positioned = [] + others = [l for l in leaves if l.user_id != user_id] + my_leaf = next((l for l in leaves if l.user_id == user_id), None) + if my_leaf: + positioned.append({ + "is_self": True, "user_id": my_leaf.user_id, + "mood": my_leaf.mood, "leaf_seed": my_leaf.leaf_seed, + "angle": 0, "radius": 0, + }) + n = len(others) + for i, l in enumerate(others): + angle = (2 * math.pi * i / n) if n > 0 else 0 + positioned.append({ + "is_self": False, "user_id": l.user_id, + "mood": l.mood, "leaf_seed": l.leaf_seed, + "angle": angle, "radius": 1, + }) + + # 聚合情绪天气 + mood_counts: dict[str, int] = {} + for l in leaves: + mood_counts[l.mood or "unknown"] = mood_counts.get(l.mood or "unknown", 0) + 1 + + return { + "leaves": positioned, + "total": len(leaves), + "mood_counts": mood_counts, + "date": today.isoformat(), + } + def _leaf_to_dict(self, leaf: DailyMoodLeaf) -> dict: return { "id": leaf.id, diff --git a/backend/app/services/sync_service.py b/backend/app/services/sync_service.py new file mode 100644 index 0000000..97ada8d --- /dev/null +++ b/backend/app/services/sync_service.py @@ -0,0 +1,191 @@ +"""默契种子服务""" + +import hashlib +import re +import uuid +from datetime import datetime + +from sqlalchemy import select, or_, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.sync_seed import SyncQuestion, SyncSeed +from app.models.friend import Friend +from app.models.user import User +from app.websocket.events import EventType +from app.websocket.manager import manager + + +# 预置题目池 +DEFAULT_QUESTIONS = [ + "用一个词形容你理想中的周末", + "深夜最想吃的一道食物", + "如果有一整天完全自由,你会做什么", + "最近让你开心的瞬间是什么", + "你最向往的旅行目的地", + "用一种颜色形容你现在的状态", + "你的快乐源泉是什么", + "如果会一种超能力,你想要什么", +] + + +def _tokenize(text: str) -> set[str]: + """中文分词:按 2-gram + 标点切分;英文按单词""" + if not text: + return set() + tokens = set() + # 英文单词 + for w in re.findall(r'[a-zA-Z]+', text.lower()): + if len(w) >= 2: + tokens.add(w) + # 中文 2-gram + cn = re.sub(r'[^一-鿿]', '', text) + for i in range(len(cn) - 1): + tokens.add(cn[i:i + 2]) + # 单字 + for ch in cn: + tokens.add(ch) + return tokens + + +def _jaccard(a: set[str], b: set[str]) -> float: + if not a or not b: + return 0.0 + inter = len(a & b) + union = len(a | b) + return inter / union if union else 0.0 + + +def _score_answers(a: str, b: str) -> int: + """0-100 默契分:Jaccard 为主 + 句长相似 + emoji/标点加分""" + if not a or not b: + return 0 + ta, tb = _tokenize(a), _tokenize(b) + j = _jaccard(ta, tb) + # 句长相似度 + len_diff = abs(len(a) - len(b)) / max(len(a), len(b), 1) + len_sim = 1 - len_diff + # emoji / 相同标点 + emoji_a = set(re.findall(r'[\U0001F300-\U0001FAFF]', a)) + emoji_b = set(re.findall(r'[\U0001F300-\U0001FAFF]', b)) + emoji_bonus = 0.1 if emoji_a and emoji_a & emoji_b else 0 + raw = j * 0.7 + len_sim * 0.2 + emoji_bonus + # 放大曲线:让中等重合也能有可见分数 + score = round(min(100, raw * 130 + (10 if j > 0 else 0))) + return max(0, min(100, score)) + + +class SyncService: + def __init__(self, db: AsyncSession): + self.db = db + + async def _ensure_questions(self): + """确保题目池存在""" + result = await self.db.execute(select(func.count(SyncQuestion.id))) + if (result.scalar() or 0) == 0: + for q in DEFAULT_QUESTIONS: + self.db.add(SyncQuestion(id=str(uuid.uuid4()), content=q)) + await self.db.flush() + + async def get_today_question(self) -> dict: + """获取今日题目(按日期确定性选取)""" + await self._ensure_questions() + result = await self.db.execute(select(SyncQuestion)) + questions = result.scalars().all() + if not questions: + raise ValueError("没有题目") + today = datetime.utcnow().strftime("%Y%m%d") + idx = int(hashlib.md5(today.encode()).hexdigest(), 16) % len(questions) + q = questions[idx] + return {"id": q.id, "content": q.content} + + async def _get_or_create_seed(self, question_id: str, user_a: str, user_b: str) -> SyncSeed: + a, b = (user_a, user_b) if user_a < user_b else (user_b, user_a) + result = await self.db.execute( + select(SyncSeed).where( + SyncSeed.question_id == question_id, + SyncSeed.user_a == a, + SyncSeed.user_b == b, + ) + ) + seed = result.scalars().first() + if not seed: + seed = SyncSeed( + id=str(uuid.uuid4()), + question_id=question_id, + user_a=a, + user_b=b, + ) + self.db.add(seed) + await self.db.flush() + return seed + + async def submit_answer(self, user_id: str, question_id: str, friend_id: str, + answer: str) -> dict: + """提交自己的答案""" + # 校验是好友 + fr = await self.db.execute( + select(Friend).where(Friend.user_id == user_id, Friend.friend_user_id == friend_id) + ) + if not fr.scalars().first(): + raise ValueError("只能和好友默契") + + seed = await self._get_or_create_seed(question_id, user_id, friend_id) + # 写入自己的答案(a 或 b) + if seed.user_a == user_id: + seed.answer_a = answer + else: + seed.answer_b = answer + + result: dict = { + "id": seed.id, + "question_id": question_id, + "my_answer": answer, + "status": seed.status, + } + + # 双方都答了 → 揭晓 + if seed.answer_a and seed.answer_b and seed.status == "draft": + score = _score_answers(seed.answer_a, seed.answer_b) + seed.score = score + leaf = hashlib.md5( + f"sync:{question_id}:{seed.user_a}:{seed.user_b}".encode() + ).hexdigest()[:16] + seed.leaf_seed = leaf + seed.status = "revealed" + seed.revealed_at = datetime.utcnow() + result["score"] = score + result["leaf_seed"] = leaf + result["partner_answer"] = seed.answer_b if seed.user_a == user_id else seed.answer_a + # 推送给搭档 + await manager.send_to_user(friend_id, "sync.revealed", { + "seed_id": seed.id, + "question_id": question_id, + "score": score, + "leaf_seed": leaf, + }) + + await self.db.flush() + return result + + async def get_seed(self, user_id: str, seed_id: str) -> dict: + result = await self.db.execute(select(SyncSeed).where(SyncSeed.id == seed_id)) + seed = result.scalars().first() + if not seed: + raise ValueError("种子不存在") + if user_id not in (seed.user_a, seed.user_b): + raise ValueError("无权查看") + + my_answer = seed.answer_a if seed.user_a == user_id else seed.answer_b + partner_answer = None + if seed.status == "revealed": + partner_answer = seed.answer_b if seed.user_a == user_id else seed.answer_a + + return { + "id": seed.id, + "question_id": seed.question_id, + "my_answer": my_answer, + "partner_answer": partner_answer, + "score": seed.score, + "leaf_seed": seed.leaf_seed, + "status": seed.status, + } diff --git a/backend/app/services/tree_service.py b/backend/app/services/tree_service.py index 61e78ad..a56b07c 100644 --- a/backend/app/services/tree_service.py +++ b/backend/app/services/tree_service.py @@ -12,6 +12,7 @@ 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 +from app.websocket.manager import manager # 阶段定义:分数 -> (阶段索引, 名称, emoji) @@ -89,6 +90,72 @@ class TreeService: ) return count_result.scalar() or 0 + async def _count_messages_in_days(self, user_id: str, friend_id: str, days: int = 7) -> int: + """统计近 N 天两人私聊消息数(心跳 BPM 用)""" + 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 + from datetime import datetime, timedelta + since = datetime.utcnow() - timedelta(days=days) + count_result = await self.db.execute( + select(func.count(Message.id)).where( + Message.conversation_id == conv_id, + Message.is_deleted == False, + Message.created_at >= since, + ) + ) + return count_result.scalar() or 0 + + async def get_heartbeat(self, user_id: str, friend_id: str) -> dict: + """获取心跳同步数据:BPM 由近7天消息数决定""" + from app.models.user import User + msg_7d = await self._count_messages_in_days(user_id, friend_id, 7) + + # BPM 映射 + if msg_7d == 0: + bpm = 42 # 沉睡 + elif msg_7d < 30: + bpm = 54 # 平静 + elif msg_7d < 100: + bpm = 66 # 正常 + elif msg_7d < 300: + bpm = 78 # 活跃 + else: + bpm = 90 # 热烈 + + # 对方信息 + friend_result = await self.db.execute(select(User).where(User.id == friend_id)) + friend = friend_result.scalars().first() + is_online = manager.is_online(friend_id) if friend else False + + return { + "friend_id": friend_id, + "friend_name": friend.nickname or friend.username if friend else "未知", + "friend_avatar": friend.avatar_url if friend else None, + "bpm": bpm, + "msg_7d": msg_7d, + "is_online": is_online, + # leaf_seed 用于渲染对方的迷你叶(确定性) + "friend_leaf_seed": (friend_id or "0")[:16].ljust(16, '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) diff --git a/backend/app/websocket/events.py b/backend/app/websocket/events.py index 8bd1c1d..c98368d 100644 --- a/backend/app/websocket/events.py +++ b/backend/app/websocket/events.py @@ -24,6 +24,11 @@ class EventType(str, Enum): FRIEND_ACCEPTED = "friend.accepted" PRESENCE_ONLINE = "presence.online" PRESENCE_OFFLINE = "presence.offline" + ECHO_SEND = "echo.send" + HEARTBEAT_SYNC = "heartbeat.sync" + FLASH_SPAWN = "flash.spawn" + FLASH_PROGRESS = "flash.progress" + FLASH_RESULT = "flash.result" ERROR = "error" diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d49a389..161e145 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -4,6 +4,8 @@ + + @@ -14,6 +16,8 @@ import { darkTheme } from 'naive-ui' import type { GlobalThemeOverrides } from 'naive-ui' import { useUiStore } from '@/stores/ui' +import EchoLayer from '@/components/EchoLayer.vue' +import FlashLayer from '@/components/FlashLayer.vue' const uiStore = useUiStore() diff --git a/frontend/src/api/climates.ts b/frontend/src/api/climates.ts new file mode 100644 index 0000000..d924bac --- /dev/null +++ b/frontend/src/api/climates.ts @@ -0,0 +1,7 @@ +import api from './client' + +export const climatesApi = { + get: (conversationId: string) => api.get(`/climates/${conversationId}`), + + getCalendar: (conversationId: string) => api.get(`/climates/${conversationId}/calendar`), +} diff --git a/frontend/src/api/echoes.ts b/frontend/src/api/echoes.ts new file mode 100644 index 0000000..3d406ae --- /dev/null +++ b/frontend/src/api/echoes.ts @@ -0,0 +1,8 @@ +import api from './client' + +export const echoesApi = { + send: (toUserId: string, message?: string) => + api.post('/echoes/', { to_user_id: toUserId, message }), + + getAll: () => api.get('/echoes/'), +} diff --git a/frontend/src/api/flash.ts b/frontend/src/api/flash.ts new file mode 100644 index 0000000..b35424f --- /dev/null +++ b/frontend/src/api/flash.ts @@ -0,0 +1,11 @@ +import api from './client' + +export const flashApi = { + getActive: () => api.get('/flash/active'), + + click: (eventId: string) => api.post(`/flash/${eventId}/click`), + + claim: (eventId: string) => api.post(`/flash/${eventId}/claim`), + + getAlbum: () => api.get('/flash/album'), +} diff --git a/frontend/src/api/leaves.ts b/frontend/src/api/leaves.ts index 08674cc..71539d2 100644 --- a/frontend/src/api/leaves.ts +++ b/frontend/src/api/leaves.ts @@ -7,4 +7,6 @@ export const leavesApi = { api.put(`/leaves/${leafId}`, data), getCollection: () => api.get('/leaves/collection'), + + getGrove: () => api.get('/leaves/grove'), } diff --git a/frontend/src/api/sync.ts b/frontend/src/api/sync.ts new file mode 100644 index 0000000..da8d5e1 --- /dev/null +++ b/frontend/src/api/sync.ts @@ -0,0 +1,10 @@ +import api from './client' + +export const syncApi = { + getQuestion: () => api.get('/sync/question'), + + submitAnswer: (questionId: string, friendId: string, answer: string) => + api.post('/sync/answer', { question_id: questionId, friend_id: friendId, answer }), + + getSeed: (seedId: string) => api.get(`/sync/${seedId}`), +} diff --git a/frontend/src/api/trees.ts b/frontend/src/api/trees.ts index c273786..9887d72 100644 --- a/frontend/src/api/trees.ts +++ b/frontend/src/api/trees.ts @@ -6,4 +6,6 @@ export const treesApi = { getTree: (friendId: string) => api.get(`/trees/${friendId}`), water: (friendId: string) => api.post(`/trees/${friendId}/water`), + + heartbeat: (friendId: string) => api.get(`/trees/${friendId}/heartbeat`), } diff --git a/frontend/src/components/EchoLayer.vue b/frontend/src/components/EchoLayer.vue new file mode 100644 index 0000000..a682fac --- /dev/null +++ b/frontend/src/components/EchoLayer.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/frontend/src/components/FlashLayer.vue b/frontend/src/components/FlashLayer.vue new file mode 100644 index 0000000..5b2852e --- /dev/null +++ b/frontend/src/components/FlashLayer.vue @@ -0,0 +1,242 @@ + + + + + diff --git a/frontend/src/composables/useWebSocket.ts b/frontend/src/composables/useWebSocket.ts index d711541..d8a7636 100644 --- a/frontend/src/composables/useWebSocket.ts +++ b/frontend/src/composables/useWebSocket.ts @@ -112,6 +112,18 @@ export function useWebSocket() { window.dispatchEvent(new CustomEvent('qingye:member-removed', { detail: event.data })) chatStore.fetchConversations() break + case 'echo.send': + // 收到一片回音叶:有人在想你 + window.dispatchEvent(new CustomEvent('qingye:echo-received', { detail: event.data })) + break + case 'heartbeat.sync': + window.dispatchEvent(new CustomEvent('qingye:heartbeat', { detail: event.data })) + break + case 'flash.spawn': + case 'flash.progress': + case 'flash.result': + window.dispatchEvent(new CustomEvent('qingye:flash', { detail: { ...event.data, _phase: event.type } })) + break case 'error': console.error('服务端错误:', event.data.message) break diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 79ac232..4c5fb4b 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -130,6 +130,42 @@ const routes: RouteRecordRaw[] = [ default: () => import('@/views/garden/CapsuleView.vue'), }, }, + { + path: 'sync', + name: 'GardenSync', + meta: { hideSecondary: true }, + components: { + secondary: () => import('@/views/garden/GardenSidebar.vue'), + default: () => import('@/views/garden/SyncView.vue'), + }, + }, + { + path: 'grove', + name: 'GardenGrove', + meta: { hideSecondary: true }, + components: { + secondary: () => import('@/views/garden/GardenSidebar.vue'), + default: () => import('@/views/garden/GroveView.vue'), + }, + }, + { + path: 'heartbeat', + name: 'GardenHeartbeat', + meta: { hideSecondary: true }, + components: { + secondary: () => import('@/views/garden/GardenSidebar.vue'), + default: () => import('@/views/garden/HeartbeatView.vue'), + }, + }, + { + path: 'firefly', + name: 'GardenFirefly', + meta: { hideSecondary: true }, + components: { + secondary: () => import('@/views/garden/GardenSidebar.vue'), + default: () => import('@/views/garden/FireflyView.vue'), + }, + }, ], }, diff --git a/frontend/src/views/chat/ChatRoomView.vue b/frontend/src/views/chat/ChatRoomView.vue index de98c2d..9c737bb 100644 --- a/frontend/src/views/chat/ChatRoomView.vue +++ b/frontend/src/views/chat/ChatRoomView.vue @@ -12,6 +12,9 @@ {{ convDetail.members?.length || 0 }} 位成员 {{ isOtherOnline ? '在线' : '离线' }} + + {{ climate.emoji }} {{ climate.temperature }}° + 群信息 ▾ 🔍 @@ -24,6 +27,20 @@ + +
+
+ {{ climateDesc }} + +
+
+
+ {{ day.emoji }} +
+
还没有气候记录,多聊聊就有了
+
+
+
@@ -166,6 +183,7 @@ import { useAuthStore } from '@/stores/auth' import { useUiStore } from '@/stores/ui' import { useWebSocket } from '@/composables/useWebSocket' import { chatApi } from '@/api/chat' +import { climatesApi } from '@/api/climates' import api from '@/api/client' import GroupInfoPanel from './GroupInfoPanel.vue' import dayjs from 'dayjs' @@ -197,8 +215,24 @@ const showForwardModal = ref(false) const forwardTarget = ref(null) const forwardContent = ref('') const forwardSender = ref('') +const climate = ref(null) +const calendar = ref([]) +const showCalendar = ref(false) const emojis = EMOJIS +const SEASON_LABEL: Record = { + spring: '春', summer: '夏', autumn: '秋', winter: '冬', +} +const WEATHER_LABEL: Record = { + sunny: '晴朗', cloudy: '多云', rainy: '绵绵细雨', windy: '微风', snowy: '飘雪', +} +const climateDesc = computed(() => { + if (!climate.value) return '' + const s = SEASON_LABEL[climate.value.season] || '' + const w = WEATHER_LABEL[climate.value.weather] || '' + return `你们的对话正处于「${s}天 · ${w}」,${climate.value.temperature}°C` +}) + // Typing indicator: get users typing in current conversation const currentTypingUsers = computed(() => { const convId = route.params.id as string @@ -233,6 +267,7 @@ onMounted(async () => { await nextTick() scrollToBottom() markRead() + loadClimate(id) } document.addEventListener('click', closeCtxMenu) }) @@ -258,6 +293,15 @@ async function loadDetail() { } catch {} } +async function loadClimate(id: string) { + try { + const { data } = await climatesApi.get(id) + climate.value = data + const { data: cal } = await climatesApi.getCalendar(id) + calendar.value = cal + } catch {} +} + function markRead() { const msgs = chatStore.currentMessages if (msgs.length > 0 && chatStore.activeConversation) { @@ -500,6 +544,29 @@ function formatTimeDivider(time: string) { .typing-indicator { font-size: 12px; color: var(--color-primary); font-style: italic; animation: typing-fade 1.5s ease infinite; } @keyframes typing-fade { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } +/* 气候徽章 */ +.climate-badge { + font-size: 13px; padding: 3px 10px; border-radius: 12px; + background: var(--color-primary-lightest); color: var(--color-primary); + cursor: pointer; transition: all 0.2s; white-space: nowrap; +} +.climate-badge:hover { background: var(--color-primary-lighter); color: white; } + +/* 气候日历 */ +.calendar-bar { + background: var(--color-surface); border-bottom: 1px solid var(--color-border); padding: 12px 20px; +} +.calendar-header { + display: flex; justify-content: space-between; align-items: center; + font-size: 13px; color: var(--color-text-secondary); margin-bottom: 10px; +} +.calendar-header .close-btn { cursor: pointer; color: var(--color-text-hint); } +.calendar-grid { + display: grid; grid-template-columns: repeat(15, 1fr); gap: 4px; +} +.cal-cell { display: flex; align-items: center; justify-content: center; font-size: 16px; padding: 2px; } +.cal-empty { grid-column: 1 / -1; text-align: center; font-size: 12px; color: var(--color-text-hint); padding: 12px; } + .chat-body { flex: 1; display: flex; overflow: hidden; position: relative; } .message-list { flex: 1; overflow-y: auto; padding: 16px 20px; } diff --git a/frontend/src/views/contacts/FriendProfileCard.vue b/frontend/src/views/contacts/FriendProfileCard.vue index 3aa5418..7e986a0 100644 --- a/frontend/src/views/contacts/FriendProfileCard.vue +++ b/frontend/src/views/contacts/FriendProfileCard.vue @@ -34,6 +34,9 @@
发消息 + + 🍃 想念 TA(寄一片回音叶) + 删除好友
@@ -47,6 +50,7 @@ import { useRouter } from 'vue-router' import { useMessage } from 'naive-ui' import { chatApi } from '@/api/chat' import { friendsApi } from '@/api/friends' +import { echoesApi } from '@/api/echoes' const props = defineProps<{ visible: boolean; friend: any }>() const emit = defineEmits<{ close: []; updated: [] }>() @@ -54,6 +58,7 @@ const emit = defineEmits<{ close: []; updated: [] }>() const router = useRouter() const message = useMessage() const remark = ref('') +const sendingEcho = ref(false) watch(() => props.friend, (f) => { remark.value = f?.remark || '' @@ -69,6 +74,20 @@ async function startChat() { } } +async function sendEcho() { + sendingEcho.value = true + try { + const { data } = await echoesApi.send(props.friend.friend_user_id) + // 触发全局发送反馈 + window.dispatchEvent(new CustomEvent('qingye:echo-sent', { detail: { online: data.delivered_online } })) + emit('close') + } catch (e: any) { + message.error(e.response?.data?.detail || '寄送失败') + } finally { + sendingEcho.value = false + } +} + async function saveRemark() { try { await friendsApi.updateRemark(props.friend.friend_user_id, remark.value || null) diff --git a/frontend/src/views/garden/FireflyView.vue b/frontend/src/views/garden/FireflyView.vue new file mode 100644 index 0000000..cf49f52 --- /dev/null +++ b/frontend/src/views/garden/FireflyView.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/frontend/src/views/garden/GardenSidebar.vue b/frontend/src/views/garden/GardenSidebar.vue index 24c5858..a9e2236 100644 --- a/frontend/src/views/garden/GardenSidebar.vue +++ b/frontend/src/views/garden/GardenSidebar.vue @@ -24,6 +24,22 @@ 时光胶囊 + + 🌱 + 默契种子 + + + 🌲 + 情绪共鸣林 + + + 💓 + 心跳同步 + + + + 萤火虫时刻 +
diff --git a/frontend/src/views/garden/GardenView.vue b/frontend/src/views/garden/GardenView.vue index 9f3951d..27da2a4 100644 --- a/frontend/src/views/garden/GardenView.vue +++ b/frontend/src/views/garden/GardenView.vue @@ -32,6 +32,51 @@ + +
+
🍃
+
+

念念回音

+

在好友资料卡寄一片"我在想你"的回音叶

+
+ +
+ +
+
🌱
+
+

默契种子

+

和好友盲答一题,默契会长出独特的叶子

+
+ +
+ +
+
🌲
+
+

情绪共鸣林

+

俯瞰朋友圈今日的情绪天气,你并不孤单

+
+ +
+ +
+
💓
+
+

心跳同步森林

+

你们的叶子在同一节拍上呼吸

+
+ +
+ +
+
+
+

萤火虫时刻

+

全服随机降临,集气点亮掉限定叶

+
+ +
@@ -73,6 +118,11 @@ .leaf-card { border-left: 4px solid #66BB6A; } .tree-card { border-left: 4px solid #8D6E63; } .capsule-card { border-left: 4px solid #FFB74D; } +.echo-card { border-left: 4px solid #26A69A; } +.sync-card { border-left: 4px solid #9CCC65; } +.grove-card { border-left: 4px solid #7CB342; } +.heartbeat-card { border-left: 4px solid #EC407A; } +.firefly-card { border-left: 4px solid #FFC107; } .garden-quote { text-align: center; margin-top: 48px; } .garden-quote p { font-size: 13px; color: var(--color-text-hint); font-style: italic; margin: 0; } diff --git a/frontend/src/views/garden/GroveView.vue b/frontend/src/views/garden/GroveView.vue new file mode 100644 index 0000000..93acad7 --- /dev/null +++ b/frontend/src/views/garden/GroveView.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/frontend/src/views/garden/HeartbeatView.vue b/frontend/src/views/garden/HeartbeatView.vue new file mode 100644 index 0000000..b19a386 --- /dev/null +++ b/frontend/src/views/garden/HeartbeatView.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/frontend/src/views/garden/SyncView.vue b/frontend/src/views/garden/SyncView.vue new file mode 100644 index 0000000..b15fe7d --- /dev/null +++ b/frontend/src/views/garden/SyncView.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/提示词.md b/提示词.md index 8b19a10..d7d988b 100644 --- a/提示词.md +++ b/提示词.md @@ -28,4 +28,12 @@ 我现在有一台服务器,可以用上吗? -我现在想要一些特色的亮点,是其他社交软件没有的 \ No newline at end of file +我现在想要一些特色的亮点,是其他社交软件没有的 + +我要丰富的功能,最好是很有创意的、 + +不用写新的创意了,先把第一波的做完 + +这个窗口一直不关上 + +可以 \ No newline at end of file