1.7
This commit is contained in:
+5
-1
@@ -64,7 +64,7 @@ app.add_middleware(
|
||||
app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads")
|
||||
|
||||
# 注册路由
|
||||
from app.routers import auth, users, conversations, messages, friends, admin, uploads, moments, leaves, trees, capsules
|
||||
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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
@@ -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])
|
||||
@@ -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])
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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 []
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<n-message-provider>
|
||||
<n-dialog-provider>
|
||||
<router-view />
|
||||
<EchoLayer />
|
||||
<FlashLayer />
|
||||
</n-dialog-provider>
|
||||
</n-message-provider>
|
||||
</n-notification-provider>
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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`),
|
||||
}
|
||||
@@ -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/'),
|
||||
}
|
||||
@@ -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'),
|
||||
}
|
||||
@@ -7,4 +7,6 @@ export const leavesApi = {
|
||||
api.put(`/leaves/${leafId}`, data),
|
||||
|
||||
getCollection: () => api.get('/leaves/collection'),
|
||||
|
||||
getGrove: () => api.get('/leaves/grove'),
|
||||
}
|
||||
|
||||
@@ -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}`),
|
||||
}
|
||||
@@ -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`),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div class="echo-layer">
|
||||
<!-- 飘入的回音叶 -->
|
||||
<transition name="echo">
|
||||
<div v-if="active" class="echo-leaf" :key="active.id">
|
||||
<svg viewBox="0 0 100 120" class="leaf-svg">
|
||||
<defs>
|
||||
<radialGradient :id="`eg-${active.id}`">
|
||||
<stop offset="0%" :stop-color="leafColor(style, 20)" />
|
||||
<stop offset="100%" :stop-color="leafColor(style)" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<path :d="leafPath(style.shapeVariant)" :fill="`url(#eg-${active.id})`" />
|
||||
<g stroke="rgba(255,255,255,0.5)" stroke-width="0.8" fill="none">
|
||||
<path v-for="(vp, i) in veinPaths(style)" :key="i" :d="vp" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- 提示文字 -->
|
||||
<transition name="echo-text">
|
||||
<div v-if="active" class="echo-text-bubble">
|
||||
<span class="echo-name">{{ active.from_nickname || active.from_username }}</span>
|
||||
<span class="echo-hint">在想你 🌿</span>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- 发送成功反馈 -->
|
||||
<transition name="echo-toast">
|
||||
<div v-if="sentToast" class="echo-toast">
|
||||
🍃 回音已寄出{{ sentToast.online ? ',TA 感受到了' : ',将在 TA 的花园降落' }}
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { generateLeafStyle, leafColor, leafPath, veinPaths } from '@/utils/leafGenerator'
|
||||
|
||||
const active = ref<any>(null)
|
||||
const sentToast = ref<any>(null)
|
||||
let hideTimer: ReturnType<typeof setTimeout>
|
||||
|
||||
const style = computed(() => active.value
|
||||
? generateLeafStyle(active.value.leaf_seed)
|
||||
: generateLeafStyle('0000000000000000'))
|
||||
|
||||
function onEchoReceived(e: Event) {
|
||||
const data = (e as CustomEvent).detail
|
||||
active.value = data
|
||||
clearTimeout(hideTimer)
|
||||
hideTimer = setTimeout(() => (active.value = null), 3500)
|
||||
}
|
||||
|
||||
function onEchoSent(e: Event) {
|
||||
const data = (e as CustomEvent).detail
|
||||
sentToast.value = data
|
||||
setTimeout(() => (sentToast.value = null), 2500)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('qingye:echo-received', onEchoReceived)
|
||||
window.addEventListener('qingye:echo-sent', onEchoSent)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('qingye:echo-received', onEchoReceived)
|
||||
window.removeEventListener('qingye:echo-sent', onEchoSent)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.echo-layer {
|
||||
position: fixed; inset: 0; pointer-events: none; z-index: 1500; overflow: hidden;
|
||||
}
|
||||
|
||||
/* 飘入的叶子 */
|
||||
.echo-leaf {
|
||||
position: fixed; top: 50%; left: 50%;
|
||||
width: 120px; height: 144px;
|
||||
filter: drop-shadow(0 0 24px rgba(0,200,150,0.7));
|
||||
}
|
||||
.leaf-svg { width: 100%; height: 100%; animation: leaf-float 3.5s ease-out; }
|
||||
@keyframes leaf-float {
|
||||
0% { transform: translate(-50%, -300%) scale(0.3) rotate(-180deg); opacity: 0; }
|
||||
20% { opacity: 1; }
|
||||
50% { transform: translate(-50%, -50%) scale(1.1) rotate(0deg); }
|
||||
80% { transform: translate(-50%, -50%) scale(1) rotate(8deg); opacity: 1; }
|
||||
100% { transform: translate(-50%, 200%) scale(0.6) rotate(20deg); opacity: 0; }
|
||||
}
|
||||
|
||||
/* 提示文字 */
|
||||
.echo-text-bubble {
|
||||
position: fixed; top: 62%; left: 50%; transform: translateX(-50%);
|
||||
background: var(--color-surface); padding: 12px 24px; border-radius: 24px;
|
||||
box-shadow: 0 8px 24px rgba(0,150,136,0.25); display: flex; flex-direction: column;
|
||||
align-items: center; gap: 2px; animation: text-pop 3.5s ease;
|
||||
}
|
||||
@keyframes text-pop {
|
||||
0% { opacity: 0; transform: translateX(-50%) translateY(10px); }
|
||||
15%, 75% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
100% { opacity: 0; transform: translateX(-50%) translateY(-10px); }
|
||||
}
|
||||
.echo-name { font-weight: 600; font-size: 15px; color: var(--color-primary-darker); }
|
||||
.echo-hint { font-size: 13px; color: var(--color-text-secondary); }
|
||||
|
||||
/* 发送反馈 toast */
|
||||
.echo-toast {
|
||||
position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%);
|
||||
background: var(--color-primary); color: white; padding: 10px 20px;
|
||||
border-radius: 20px; box-shadow: 0 4px 16px rgba(0,150,136,0.3);
|
||||
font-size: 13px; animation: toast-up 2.5s ease;
|
||||
}
|
||||
@keyframes toast-up {
|
||||
0% { opacity: 0; transform: translateX(-50%) translateY(20px); }
|
||||
15%, 80% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
100% { opacity: 0; transform: translateX(-50%) translateY(-10px); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<div class="flash-layer">
|
||||
<!-- 萤火虫粒子背景 -->
|
||||
<transition name="flash-fade">
|
||||
<div v-if="active" class="flash-overlay">
|
||||
<span v-for="i in 18" :key="i" class="firefly" :style="fireflyStyle(i)"></span>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- 中央集气面板 -->
|
||||
<transition name="flash-pop">
|
||||
<div v-if="active" class="flash-panel">
|
||||
<span class="flash-close" @click.stop="dismiss">✕</span>
|
||||
<div class="flash-tap-area" @click="onTap">
|
||||
<div class="flash-title">✨ 萤火虫降临</div>
|
||||
<div class="flash-progress-ring">
|
||||
<svg viewBox="0 0 100 100" class="ring-svg">
|
||||
<circle cx="50" cy="50" r="44" class="ring-bg" />
|
||||
<circle cx="50" cy="50" r="44" class="ring-fill"
|
||||
:style="{ strokeDashoffset: ringOffset }" />
|
||||
</svg>
|
||||
<div class="ring-center">
|
||||
<div class="ring-count">{{ progress.total }}</div>
|
||||
<div class="ring-target">/ {{ active.target_clicks }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flash-hint">点击集气 · 集满全服掉限定叶 🌟</div>
|
||||
<div v-if="myClicks > 0" class="my-clicks">你贡献了 {{ myClicks }} 次</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- 达标庆祝 -->
|
||||
<transition name="flash-pop">
|
||||
<div v-if="reward" class="reward-modal" @click.self="reward = null">
|
||||
<div class="reward-card">
|
||||
<div class="reward-glow"></div>
|
||||
<div class="reward-leaf">
|
||||
<svg viewBox="0 0 100 120" class="reward-svg">
|
||||
<defs>
|
||||
<radialGradient id="flash-grad">
|
||||
<stop offset="0%" :stop-color="leafColor(style, 25)" />
|
||||
<stop offset="100%" :stop-color="leafColor(style)" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<path :d="leafPath(style.shapeVariant)" fill="url(#flash-grad)" />
|
||||
<g stroke="rgba(255,255,255,0.6)" stroke-width="0.8" fill="none">
|
||||
<path v-for="(vp, i) in veinPaths(style)" :key="i" :d="vp" />
|
||||
</g>
|
||||
<!-- 萤火虫光点 -->
|
||||
<circle v-for="i in 4" :key="'g'+i" :cx="30 + i*12" :cy="40 + (i%2)*30" r="2" fill="rgba(255,235,100,0.9)" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>限定纪念叶到手!</h3>
|
||||
<p class="reward-variant">{{ reward.leaf_variant }}</p>
|
||||
<p class="reward-desc">永不复刻 · 已收入图鉴</p>
|
||||
<n-button type="primary" round @click="claimReward">收入图鉴</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { flashApi } from '@/api/flash'
|
||||
import { generateLeafStyle, leafColor, leafPath, veinPaths } from '@/utils/leafGenerator'
|
||||
|
||||
const active = ref<any>(null)
|
||||
const reward = ref<any>(null)
|
||||
const progress = ref<any>({ total: 0 })
|
||||
const myClicks = ref(0)
|
||||
const dismissedIds = ref<Set<string>>(new Set()) // 用户已忽略的事件,不再弹回
|
||||
let pollTimer: ReturnType<typeof setInterval>
|
||||
let claimTimer: ReturnType<typeof setTimeout>
|
||||
|
||||
const style = computed(() => reward.value
|
||||
? generateLeafStyle(reward.value.leaf_seed)
|
||||
: generateLeafStyle('0000000000000000'))
|
||||
|
||||
const ringOffset = computed(() => {
|
||||
const p = progress.value.progress || 0
|
||||
return 276 - 276 * p // 276 = 2π*44
|
||||
})
|
||||
|
||||
function fireflyStyle(i: number): Record<string, string> {
|
||||
const left = (i * 53) % 100
|
||||
const top = (i * 37) % 100
|
||||
const delay = (i % 6) * 0.3
|
||||
const dur = 2.5 + (i % 4)
|
||||
return {
|
||||
left: left + '%',
|
||||
top: top + '%',
|
||||
animationDelay: delay + 's',
|
||||
animationDuration: dur + 's',
|
||||
}
|
||||
}
|
||||
|
||||
function onFlash(e: Event) {
|
||||
const data = (e as CustomEvent).detail
|
||||
if (data._phase === 'flash.spawn') {
|
||||
if (dismissedIds.value.has(data.id)) return // 已忽略的不再弹
|
||||
active.value = data
|
||||
progress.value = { total: data.total_clicks || 0, progress: data.progress || 0, target: data.target_clicks }
|
||||
} else if (data._phase === 'flash.progress') {
|
||||
progress.value = data
|
||||
} else if (data._phase === 'flash.result' && data.reached) {
|
||||
// 达标:显示奖励并关闭集气面板
|
||||
reward.value = { ...data, event_id: data.event_id }
|
||||
active.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function onTap() {
|
||||
if (!active.value) return
|
||||
try {
|
||||
const { data } = await flashApi.click(active.value.id)
|
||||
progress.value = data
|
||||
myClicks.value = data.my_clicks
|
||||
// 本地达标也立即关闭面板,触发奖励
|
||||
if (data.reached && !reward.value) {
|
||||
reward.value = { event_id: active.value.id, leaf_seed: active.value.leaf_seed, leaf_variant: active.value.leaf_variant }
|
||||
active.value = null
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (active.value) dismissedIds.value.add(active.value.id)
|
||||
active.value = null
|
||||
}
|
||||
|
||||
async function claimReward() {
|
||||
if (!reward.value) return
|
||||
try {
|
||||
await flashApi.claim(reward.value.event_id)
|
||||
reward.value = null
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 轮询检查是否有进行中的事件(弥补未在线时错过 spawn 的情况)
|
||||
async function pollActive() {
|
||||
// 若当前已有面板或奖励弹窗,先检查是否到期
|
||||
if (active.value) {
|
||||
if (new Date(active.value.end_at).getTime() <= Date.now()) {
|
||||
active.value = null // 事件到期,自动关闭
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
const { data } = await flashApi.getActive()
|
||||
if (data.event && !reward.value && !dismissedIds.value.has(data.event.id)) {
|
||||
active.value = data.event
|
||||
progress.value = {
|
||||
total: data.event.total_clicks,
|
||||
progress: data.event.progress,
|
||||
target: data.event.target_clicks,
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('qingye:flash', onFlash)
|
||||
pollActive()
|
||||
pollTimer = setInterval(pollActive, 20000) // 每 20 秒检查一次(更轻量)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('qingye:flash', onFlash)
|
||||
clearInterval(pollTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.flash-layer { position: fixed; inset: 0; pointer-events: none; z-index: 1400; }
|
||||
|
||||
/* 萤火虫粒子 */
|
||||
.flash-overlay { position: fixed; inset: 0; pointer-events: none; }
|
||||
.firefly {
|
||||
position: absolute; width: 6px; height: 6px; border-radius: 50%;
|
||||
background: radial-gradient(circle, #FFEB3B 0%, rgba(255,200,0,0.4) 60%, transparent 100%);
|
||||
box-shadow: 0 0 8px #FFEB3B;
|
||||
animation: firefly-float ease-in-out infinite;
|
||||
}
|
||||
@keyframes firefly-float {
|
||||
0%, 100% { transform: translate(0, 0); opacity: 0.3; }
|
||||
50% { transform: translate(20px, -30px); opacity: 1; }
|
||||
}
|
||||
|
||||
/* 中央面板 */
|
||||
.flash-panel {
|
||||
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||
background: rgba(20,30,28,0.92); color: white; padding: 24px 32px; border-radius: 24px;
|
||||
text-align: center; pointer-events: auto;
|
||||
box-shadow: 0 0 60px rgba(255,235,59,0.4); backdrop-filter: blur(8px);
|
||||
}
|
||||
.flash-close {
|
||||
position: absolute; top: 10px; right: 14px; cursor: pointer;
|
||||
font-size: 18px; color: rgba(255,255,255,0.6); line-height: 1; z-index: 2;
|
||||
}
|
||||
.flash-close:hover { color: white; }
|
||||
.flash-tap-area { cursor: pointer; }
|
||||
.flash-title { font-size: 16px; font-weight: 700; margin-bottom: 12px; color: #FFEB3B; }
|
||||
.flash-progress-ring { position: relative; width: 140px; height: 140px; margin: 0 auto 12px; }
|
||||
.ring-svg { width: 100%; height: 100%; transform: rotate(-90deg); }
|
||||
.ring-bg { fill: none; stroke: rgba(255,255,255,0.15); stroke-width: 6; }
|
||||
.ring-fill { fill: none; stroke: #FFEB3B; stroke-width: 6; stroke-linecap: round; stroke-dasharray: 276; transition: stroke-dashoffset 0.3s; filter: drop-shadow(0 0 4px #FFEB3B); }
|
||||
.ring-center { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
||||
.ring-count { font-size: 32px; font-weight: 800; color: white; }
|
||||
.ring-target { font-size: 12px; color: rgba(255,255,255,0.6); }
|
||||
.flash-hint { font-size: 12px; color: rgba(255,255,255,0.7); }
|
||||
.my-clicks { font-size: 11px; color: #FFEB3B; margin-top: 6px; }
|
||||
|
||||
/* 达标奖励 */
|
||||
.reward-modal {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.7); pointer-events: auto;
|
||||
display: flex; align-items: center; justify-content: center; z-index: 1600;
|
||||
}
|
||||
.reward-card {
|
||||
background: var(--color-surface); padding: 32px; border-radius: 24px; text-align: center;
|
||||
position: relative; overflow: hidden; max-width: 320px;
|
||||
}
|
||||
.reward-glow {
|
||||
position: absolute; inset: 0; background: radial-gradient(circle at 50% 30%, rgba(255,235,59,0.3), transparent 60%);
|
||||
animation: glow 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes glow { 0%,100% { opacity: 0.6; } 50% { opacity: 1; } }
|
||||
.reward-leaf { position: relative; margin: 0 auto 16px; width: 120px; height: 144px; filter: drop-shadow(0 0 20px rgba(255,200,0,0.6)); }
|
||||
.reward-svg { width: 100%; height: 100%; animation: reward-bloom 0.8s ease; }
|
||||
@keyframes reward-bloom { 0% { transform: scale(0.3) rotate(-180deg); opacity: 0; } 100% { transform: scale(1) rotate(0); opacity: 1; } }
|
||||
.reward-card h3 { margin: 0 0 4px; font-size: 18px; color: var(--color-primary-darker); position: relative; }
|
||||
.reward-variant { font-size: 12px; color: var(--color-text-hint); margin: 4px 0; font-family: monospace; position: relative; }
|
||||
.reward-desc { font-size: 13px; color: var(--color-text-secondary); margin: 4px 0 16px; position: relative; }
|
||||
|
||||
/* transitions */
|
||||
.flash-fade-enter-active, .flash-fade-leave-active { transition: opacity 0.5s; }
|
||||
.flash-fade-enter-from, .flash-fade-leave-to { opacity: 0; }
|
||||
.flash-pop-enter-active { animation: pop-in 0.4s ease; }
|
||||
.flash-pop-leave-active { transition: opacity 0.3s; }
|
||||
.flash-pop-leave-to { opacity: 0; }
|
||||
@keyframes pop-in { 0% { transform: translate(-50%, -50%) scale(0.7); opacity: 0; } 100% { transform: translate(-50%, -50%) scale(1); opacity: 1; } }
|
||||
</style>
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
<span v-else-if="convDetail?.type === 'group'" class="member-count">{{ convDetail.members?.length || 0 }} 位成员</span>
|
||||
<span v-else class="member-count" :class="{ online: isOtherOnline }">{{ isOtherOnline ? '在线' : '离线' }}</span>
|
||||
</div>
|
||||
<span v-if="climate" class="climate-badge" @click="showCalendar = !showCalendar" :title="climateDesc">
|
||||
{{ climate.emoji }} {{ climate.temperature }}°
|
||||
</span>
|
||||
<n-button v-if="convDetail?.type === 'group'" quaternary size="small" @click="showGroupInfo = !showGroupInfo">群信息 ▾</n-button>
|
||||
<n-button quaternary size="small" @click="toggleSearch" title="搜索">🔍</n-button>
|
||||
</div>
|
||||
@@ -24,6 +27,20 @@
|
||||
<n-button size="tiny" quaternary @click="showSearch = false; searchResults = []">✕</n-button>
|
||||
</div>
|
||||
|
||||
<!-- 气候日历 -->
|
||||
<div v-if="showCalendar" class="calendar-bar">
|
||||
<div class="calendar-header">
|
||||
<span>{{ climateDesc }}</span>
|
||||
<span class="close-btn" @click="showCalendar = false">✕</span>
|
||||
</div>
|
||||
<div class="calendar-grid">
|
||||
<div v-for="(day, i) in calendar" :key="i" class="cal-cell" :title="day.date">
|
||||
<span class="cal-emoji">{{ day.emoji }}</span>
|
||||
</div>
|
||||
<div v-if="calendar.length === 0" class="cal-empty">还没有气候记录,多聊聊就有了</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<div v-if="searchResults.length > 0" class="search-results">
|
||||
<div class="search-results-header">
|
||||
@@ -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<string | null>(null)
|
||||
const forwardContent = ref('')
|
||||
const forwardSender = ref('')
|
||||
const climate = ref<any>(null)
|
||||
const calendar = ref<any[]>([])
|
||||
const showCalendar = ref(false)
|
||||
const emojis = EMOJIS
|
||||
|
||||
const SEASON_LABEL: Record<string, string> = {
|
||||
spring: '春', summer: '夏', autumn: '秋', winter: '冬',
|
||||
}
|
||||
const WEATHER_LABEL: Record<string, string> = {
|
||||
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; }
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
<!-- 操作按钮 -->
|
||||
<div class="card-actions">
|
||||
<n-button type="primary" block @click="startChat">发消息</n-button>
|
||||
<n-button quaternary type="primary" block @click="sendEcho" :loading="sendingEcho">
|
||||
🍃 想念 TA(寄一片回音叶)
|
||||
</n-button>
|
||||
<n-button type="error" ghost block @click="handleRemove">删除好友</n-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div class="firefly-view">
|
||||
<div class="ff-header">
|
||||
<h2>✨ 萤火虫时刻</h2>
|
||||
<p class="subtitle">全服随机降临,一起集气点亮,掉落永不复刻的限定叶</p>
|
||||
</div>
|
||||
|
||||
<!-- 状态卡 -->
|
||||
<div class="status-card">
|
||||
<div v-if="active" class="active-event">
|
||||
<div class="event-title">🟢 萤火虫正在降临!</div>
|
||||
<div class="event-progress">
|
||||
<div class="prog-bar"><div class="prog-fill" :style="{ width: (active.progress * 100) + '%' }"></div></div>
|
||||
<span>{{ active.total_clicks }} / {{ active.target_clicks }}</span>
|
||||
</div>
|
||||
<div class="countdown">⏱️ 剩余 {{ countdown }}</div>
|
||||
<p class="hint">点击屏幕中央的萤火虫面板参与集气</p>
|
||||
</div>
|
||||
<div v-else class="no-event">
|
||||
<div class="no-icon">🌌</div>
|
||||
<p>此刻森林很安静</p>
|
||||
<p class="sub-hint">萤火虫会在不经意时降临,留意闪烁的光点</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图鉴 -->
|
||||
<div class="album-section">
|
||||
<h3>🌟 我的限定叶图鉴</h3>
|
||||
<div v-if="album.length === 0" class="album-empty">
|
||||
还没有收集到限定叶,下次萤火虫降临别忘了参与
|
||||
</div>
|
||||
<div v-else class="album-grid">
|
||||
<div v-for="(item, i) in album" :key="i" class="album-leaf">
|
||||
<svg viewBox="0 0 100 120" class="album-svg">
|
||||
<path :d="leafPath(generateLeafStyle(item.leaf_seed).shapeVariant)"
|
||||
:fill="leafColor(generateLeafStyle(item.leaf_seed))" />
|
||||
<circle v-for="j in 3" :key="j" :cx="30 + j*18" :cy="45 + (j%2)*25" r="2" fill="rgba(255,235,100,0.85)" />
|
||||
</svg>
|
||||
<span class="album-variant">{{ item.leaf_variant }}</span>
|
||||
<span class="album-clicks">贡献 {{ item.my_clicks }} 击</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { flashApi } from '@/api/flash'
|
||||
import { generateLeafStyle, leafColor, leafPath } from '@/utils/leafGenerator'
|
||||
|
||||
const active = ref<any>(null)
|
||||
const album = ref<any[]>([])
|
||||
const now = ref(Date.now())
|
||||
let pollTimer: ReturnType<typeof setInterval>
|
||||
let tickTimer: ReturnType<typeof setInterval>
|
||||
|
||||
const countdown = computed(() => {
|
||||
if (!active.value) return ''
|
||||
const left = Math.max(0, Math.floor((new Date(active.value.end_at).getTime() - now.value) / 1000))
|
||||
const m = Math.floor(left / 60)
|
||||
const s = left % 60
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
})
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const { data } = await flashApi.getActive()
|
||||
active.value = data.event
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await poll()
|
||||
await loadAlbum()
|
||||
pollTimer = setInterval(poll, 5000)
|
||||
tickTimer = setInterval(() => (now.value = Date.now()), 1000)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
clearInterval(pollTimer)
|
||||
clearInterval(tickTimer)
|
||||
})
|
||||
|
||||
async function loadAlbum() {
|
||||
try {
|
||||
const { data } = await flashApi.getAlbum()
|
||||
album.value = data
|
||||
} catch {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.firefly-view {
|
||||
flex: 1; overflow-y: auto; padding: 24px;
|
||||
background: linear-gradient(180deg, var(--color-bg) 0%, #1a2e2a 200%);
|
||||
}
|
||||
.ff-header { text-align: center; margin-bottom: 20px; }
|
||||
.ff-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; }
|
||||
|
||||
.status-card {
|
||||
max-width: 480px; margin: 0 auto 24px; padding: 24px; border-radius: 16px;
|
||||
background: var(--color-surface); border: 1px solid var(--color-border); text-align: center;
|
||||
}
|
||||
.active-event .event-title { font-size: 16px; color: var(--color-success); font-weight: 700; margin-bottom: 12px; }
|
||||
.event-progress { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||
.prog-bar { flex: 1; height: 10px; background: var(--color-bg); border-radius: 5px; overflow: hidden; }
|
||||
.prog-fill { height: 100%; background: linear-gradient(90deg, #FFC107, #FFEB3B); transition: width 0.4s; }
|
||||
.countdown { font-size: 20px; font-weight: 700; color: #FF9800; margin: 8px 0; }
|
||||
.hint { font-size: 12px; color: var(--color-text-hint); }
|
||||
|
||||
.no-event .no-icon { font-size: 56px; margin-bottom: 8px; }
|
||||
.no-event p { margin: 4px 0; color: var(--color-text-secondary); }
|
||||
.sub-hint { font-size: 12px; color: var(--color-text-hint) !important; }
|
||||
|
||||
.album-section { max-width: 560px; margin: 0 auto; }
|
||||
.album-section h3 { font-size: 16px; color: var(--color-text-primary); margin-bottom: 12px; }
|
||||
.album-empty { text-align: center; padding: 30px; color: var(--color-text-hint); font-size: 13px; background: var(--color-surface); border-radius: 12px; }
|
||||
.album-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 12px; }
|
||||
.album-leaf {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||
padding: 12px 8px; background: var(--color-surface); border-radius: 12px;
|
||||
border: 1px solid var(--color-border); position: relative;
|
||||
}
|
||||
.album-svg { width: 64px; height: 77px; filter: drop-shadow(0 2px 6px rgba(255,200,0,0.3)); }
|
||||
.album-variant { font-size: 10px; color: var(--color-text-hint); font-family: monospace; }
|
||||
.album-clicks { font-size: 11px; color: var(--color-primary); }
|
||||
</style>
|
||||
@@ -24,6 +24,22 @@
|
||||
<span class="menu-icon">⏳</span>
|
||||
<span class="menu-label">时光胶囊</span>
|
||||
</router-link>
|
||||
<router-link to="/garden/sync" class="menu-item" active-class="active">
|
||||
<span class="menu-icon">🌱</span>
|
||||
<span class="menu-label">默契种子</span>
|
||||
</router-link>
|
||||
<router-link to="/garden/grove" class="menu-item" active-class="active">
|
||||
<span class="menu-icon">🌲</span>
|
||||
<span class="menu-label">情绪共鸣林</span>
|
||||
</router-link>
|
||||
<router-link to="/garden/heartbeat" class="menu-item" active-class="active">
|
||||
<span class="menu-icon">💓</span>
|
||||
<span class="menu-label">心跳同步</span>
|
||||
</router-link>
|
||||
<router-link to="/garden/firefly" class="menu-item" active-class="active">
|
||||
<span class="menu-icon">✨</span>
|
||||
<span class="menu-label">萤火虫时刻</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -32,6 +32,51 @@
|
||||
</div>
|
||||
<span class="card-arrow">›</span>
|
||||
</div>
|
||||
|
||||
<div class="feature-card echo-card" @click="$router.push('/contacts')">
|
||||
<div class="card-icon">🍃</div>
|
||||
<div class="card-body">
|
||||
<h3>念念回音</h3>
|
||||
<p>在好友资料卡寄一片"我在想你"的回音叶</p>
|
||||
</div>
|
||||
<span class="card-arrow">›</span>
|
||||
</div>
|
||||
|
||||
<div class="feature-card sync-card" @click="$router.push('/garden/sync')">
|
||||
<div class="card-icon">🌱</div>
|
||||
<div class="card-body">
|
||||
<h3>默契种子</h3>
|
||||
<p>和好友盲答一题,默契会长出独特的叶子</p>
|
||||
</div>
|
||||
<span class="card-arrow">›</span>
|
||||
</div>
|
||||
|
||||
<div class="feature-card grove-card" @click="$router.push('/garden/grove')">
|
||||
<div class="card-icon">🌲</div>
|
||||
<div class="card-body">
|
||||
<h3>情绪共鸣林</h3>
|
||||
<p>俯瞰朋友圈今日的情绪天气,你并不孤单</p>
|
||||
</div>
|
||||
<span class="card-arrow">›</span>
|
||||
</div>
|
||||
|
||||
<div class="feature-card heartbeat-card" @click="$router.push('/garden/heartbeat')">
|
||||
<div class="card-icon">💓</div>
|
||||
<div class="card-body">
|
||||
<h3>心跳同步森林</h3>
|
||||
<p>你们的叶子在同一节拍上呼吸</p>
|
||||
</div>
|
||||
<span class="card-arrow">›</span>
|
||||
</div>
|
||||
|
||||
<div class="feature-card firefly-card" @click="$router.push('/garden/firefly')">
|
||||
<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">
|
||||
@@ -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; }
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div class="grove-view">
|
||||
<div class="grove-header">
|
||||
<h2>🌲 情绪共鸣林</h2>
|
||||
<p class="subtitle">{{ weatherText }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grove-stage" :class="weatherClass">
|
||||
<!-- 天气遮罩层 -->
|
||||
<div class="weather-overlay" :class="weatherClass"></div>
|
||||
|
||||
<!-- 雾气(低落多时) -->
|
||||
<div v-if="gloomyRatio > 0.5" class="mist"></div>
|
||||
|
||||
<!-- 暖光(开心多时) -->
|
||||
<div v-if="happyRatio > 0.5" class="warm-glow"></div>
|
||||
|
||||
<!-- 叶子们 -->
|
||||
<div v-if="grove && grove.total > 0" class="forest">
|
||||
<div v-for="(leaf, i) in grove.leaves" :key="i" class="grove-leaf"
|
||||
:class="{ self: leaf.is_self }"
|
||||
:style="leafPosition(leaf, i)">
|
||||
<svg viewBox="0 0 100 120" class="mini-leaf-svg" :class="{ sway: !leaf.is_self }">
|
||||
<path :d="leafPath(generateLeafStyle(leaf.leaf_seed).shapeVariant)"
|
||||
:fill="leaf.is_self ? leafColor(generateLeafStyle(leaf.leaf_seed), 15) : leafColor(generateLeafStyle(leaf.leaf_seed))"
|
||||
:opacity="leaf.is_self ? 1 : 0.85" />
|
||||
</svg>
|
||||
<span v-if="leaf.mood" class="leaf-mood-emoji">{{ moodEmoji(leaf.mood) }}</span>
|
||||
<div v-if="leaf.is_self" class="self-glow"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-forest">
|
||||
<div style="font-size: 56px">🌳</div>
|
||||
<p>今天还是一片空林</p>
|
||||
<p class="empty-hint">领取你的今日心情叶,森林就会亮起来</p>
|
||||
<n-button type="primary" round size="small" @click="$router.push('/garden/leaf')">去领今日叶</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 情绪统计 -->
|
||||
<div v-if="grove && grove.total > 0" class="mood-stats">
|
||||
<span class="stat-label">今日朋友圈情绪</span>
|
||||
<div class="mood-bars">
|
||||
<div v-for="m in moodStats" :key="m.key" class="mood-bar">
|
||||
<span class="mood-emoji">{{ m.emoji }}</span>
|
||||
<div class="bar-track">
|
||||
<div class="bar-fill" :style="{ width: m.percent + '%', background: m.color }"></div>
|
||||
</div>
|
||||
<span class="mood-count">{{ m.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { leavesApi } from '@/api/leaves'
|
||||
import { generateLeafStyle, leafColor, leafPath } from '@/utils/leafGenerator'
|
||||
|
||||
const grove = ref<any>(null)
|
||||
|
||||
const MOOD_META: Record<string, { emoji: string; color: string; happy: number }> = {
|
||||
happy: { emoji: '😊', color: '#FFD54F', happy: 1 },
|
||||
energetic: { emoji: '⚡', color: '#66BB6A', happy: 1 },
|
||||
grateful: { emoji: '🙏', color: '#26A69A', happy: 1 },
|
||||
calm: { emoji: '🌙', color: '#90A4AE', happy: 0.5 },
|
||||
thoughtful: { emoji: '🤔', color: '#7E57C2', happy: 0.3 },
|
||||
lazy: { emoji: '😴', color: '#78909C', happy: 0.2 },
|
||||
unknown: { emoji: '🍃', color: '#A5D6A7', happy: 0.5 },
|
||||
}
|
||||
|
||||
const happyRatio = computed(() => {
|
||||
if (!grove.value) return 0
|
||||
const counts = grove.value.mood_counts
|
||||
let happy = 0, total = 0
|
||||
for (const [k, v] of Object.entries(counts)) {
|
||||
total += v as number
|
||||
if ((MOOD_META[k]?.happy || 0.5) >= 1) happy += v as number
|
||||
}
|
||||
return total ? happy / total : 0
|
||||
})
|
||||
|
||||
const gloomyRatio = computed(() => {
|
||||
if (!grove.value) return 0
|
||||
const counts = grove.value.mood_counts
|
||||
let gloomy = 0, total = 0
|
||||
for (const [k, v] of Object.entries(counts)) {
|
||||
total += v as number
|
||||
if ((MOOD_META[k]?.happy || 0.5) <= 0.3) gloomy += v as number
|
||||
}
|
||||
return total ? gloomy / total : 0
|
||||
})
|
||||
|
||||
const weatherClass = computed(() => {
|
||||
if (happyRatio.value > 0.5) return 'sunny'
|
||||
if (gloomyRatio.value > 0.5) return 'misty'
|
||||
return 'balanced'
|
||||
})
|
||||
|
||||
const weatherText = computed(() => {
|
||||
if (!grove.value || grove.value.total === 0) return '俯瞰整片森林的情绪天气'
|
||||
if (happyRatio.value > 0.5) return '☀️ 今天朋友圈阳光正好,暖意融融'
|
||||
if (gloomyRatio.value > 0.5) return '🌫️ 今天林子有些低沉,给朋友一个拥抱吧'
|
||||
return '🌤️ 森林平静地呼吸着'
|
||||
})
|
||||
|
||||
const moodStats = computed(() => {
|
||||
if (!grove.value) return []
|
||||
const counts = grove.value.mood_counts
|
||||
const total = grove.value.total
|
||||
return Object.entries(counts).map(([k, v]) => ({
|
||||
key: k,
|
||||
emoji: MOOD_META[k]?.emoji || '🍃',
|
||||
color: MOOD_META[k]?.color || '#A5D6A7',
|
||||
count: v as number,
|
||||
percent: Math.round(((v as number) / total) * 100),
|
||||
})).sort((a, b) => b.count - a.count)
|
||||
})
|
||||
|
||||
function leafPosition(leaf: any, i: number): Record<string, string> {
|
||||
if (leaf.is_self) {
|
||||
return { left: '50%', top: '50%', transform: 'translate(-50%, -50%)' }
|
||||
}
|
||||
// 环形排布
|
||||
const angle = leaf.angle
|
||||
const radius = 38 + (i % 2) * 6 // %
|
||||
const x = 50 + Math.cos(angle) * radius
|
||||
const y = 50 + Math.sin(angle) * radius * 0.7
|
||||
return { left: x + '%', top: y + '%', transform: 'translate(-50%, -50%)' }
|
||||
}
|
||||
|
||||
function moodEmoji(mood: string): string {
|
||||
return MOOD_META[mood]?.emoji || ''
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await leavesApi.getGrove()
|
||||
grove.value = data
|
||||
} catch {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grove-view {
|
||||
flex: 1; overflow-y: auto; padding: 24px;
|
||||
}
|
||||
.grove-header { text-align: center; margin-bottom: 12px; }
|
||||
.grove-header h2 { margin: 0; font-size: 22px; color: var(--color-primary-darker); }
|
||||
.subtitle { font-size: 14px; color: var(--color-text-secondary); margin: 6px 0 0; }
|
||||
|
||||
.grove-stage {
|
||||
position: relative; height: 420px; max-width: 560px; margin: 0 auto 20px;
|
||||
border-radius: 20px; overflow: hidden;
|
||||
background: radial-gradient(ellipse at center, #E8F5E9 0%, #C8E6C9 100%);
|
||||
transition: background 1s;
|
||||
}
|
||||
.grove-stage.misty { background: radial-gradient(ellipse at center, #CFD8DC 0%, #B0BEC5 100%); }
|
||||
.grove-stage.sunny { background: radial-gradient(ellipse at center, #FFF8E1 0%, #FFE082 100%); }
|
||||
|
||||
.weather-overlay { position: absolute; inset: 0; pointer-events: none; }
|
||||
|
||||
.mist {
|
||||
position: absolute; inset: 0; pointer-events: none;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0) 0%, rgba(200,210,215,0.5) 100%);
|
||||
backdrop-filter: blur(1px); animation: mist-drift 8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes mist-drift { 0%,100% { opacity: 0.6; } 50% { opacity: 0.9; } }
|
||||
|
||||
.warm-glow {
|
||||
position: absolute; inset: 0; pointer-events: none;
|
||||
background: radial-gradient(circle at 50% 40%, rgba(255,213,79,0.35) 0%, transparent 60%);
|
||||
animation: glow-pulse 4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes glow-pulse { 0%,100% { opacity: 0.7; } 50% { opacity: 1; } }
|
||||
|
||||
.forest { position: absolute; inset: 0; }
|
||||
.grove-leaf { position: absolute; transition: all 0.6s ease; }
|
||||
.grove-leaf.self { z-index: 10; }
|
||||
.mini-leaf-svg { width: 48px; height: 58px; filter: drop-shadow(0 3px 4px rgba(0,0,0,0.15)); }
|
||||
.grove-leaf.self .mini-leaf-svg { width: 72px; height: 86px; }
|
||||
.mini-leaf-svg.sway { animation: grove-sway 3s ease-in-out infinite; transform-origin: 50% 90%; }
|
||||
.mini-leaf-svg.sway:nth-child(odd) { animation-delay: -1.5s; }
|
||||
@keyframes grove-sway { 0%,100% { transform: rotate(-2deg); } 50% { transform: rotate(2deg); } }
|
||||
.leaf-mood-emoji { position: absolute; top: -6px; right: -6px; font-size: 16px; }
|
||||
.self-glow {
|
||||
position: absolute; inset: -16px; border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(0,200,150,0.4) 0%, transparent 70%);
|
||||
animation: self-pulse 2s ease-in-out infinite; pointer-events: none;
|
||||
}
|
||||
@keyframes self-pulse { 0%,100% { transform: scale(1); opacity: 0.6; } 50% { transform: scale(1.2); opacity: 1; } }
|
||||
|
||||
.empty-forest {
|
||||
position: absolute; inset: 0; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: 8px; color: var(--color-text-hint);
|
||||
}
|
||||
.empty-hint { font-size: 12px; }
|
||||
|
||||
.mood-stats {
|
||||
max-width: 560px; margin: 0 auto; background: var(--color-surface);
|
||||
border-radius: 14px; padding: 16px 20px; border: 1px solid var(--color-border);
|
||||
}
|
||||
.stat-label { font-size: 13px; color: var(--color-text-secondary); font-weight: 500; display: block; margin-bottom: 12px; }
|
||||
.mood-bars { display: flex; flex-direction: column; gap: 8px; }
|
||||
.mood-bar { display: flex; align-items: center; gap: 8px; }
|
||||
.mood-emoji { font-size: 16px; width: 24px; text-align: center; }
|
||||
.bar-track { flex: 1; height: 10px; background: var(--color-bg); border-radius: 5px; overflow: hidden; }
|
||||
.bar-fill { height: 100%; border-radius: 5px; transition: width 0.8s; }
|
||||
.mood-count { font-size: 12px; color: var(--color-text-hint); width: 20px; text-align: right; }
|
||||
</style>
|
||||
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div class="heartbeat-view">
|
||||
<div class="hb-header">
|
||||
<h2>💓 心跳同步森林</h2>
|
||||
<p class="subtitle">你们的呼吸,在同一节拍上</p>
|
||||
</div>
|
||||
|
||||
<div v-if="trees.length === 0 && !loading" class="empty">
|
||||
<div style="font-size: 56px">🌱</div>
|
||||
<p>加个好友,开始同步心跳</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 好友选择 -->
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 心跳舞台 -->
|
||||
<div v-if="hb" class="hb-stage">
|
||||
<!-- 两片叶子 -->
|
||||
<div class="leaves-pair">
|
||||
<!-- 我的叶子 -->
|
||||
<div class="leaf-side">
|
||||
<svg viewBox="0 0 100 120" class="hb-leaf mine" :style="myLeafStyle">
|
||||
<path :d="leafPath(myStyle.shapeVariant)" :fill="leafColor(myStyle)" />
|
||||
<g stroke="rgba(255,255,255,0.4)" stroke-width="0.8" fill="none">
|
||||
<path v-for="(vp, i) in veinPaths(myStyle)" :key="i" :d="vp" />
|
||||
</g>
|
||||
</svg>
|
||||
<span class="leaf-label">我</span>
|
||||
</div>
|
||||
<!-- 连接线(心跳波) -->
|
||||
<div class="hb-link" :style="{ animationDuration: cycleMs + 'ms' }">
|
||||
<span class="pulse-dot" :style="{ animationDuration: cycleMs + 'ms' }"></span>
|
||||
</div>
|
||||
<!-- 好友的叶子 -->
|
||||
<div class="leaf-side" :class="{ offline: !hb.is_online }">
|
||||
<svg viewBox="0 0 100 120" class="hb-leaf friend" :style="friendLeafStyle">
|
||||
<path :d="leafPath(friendStyle.shapeVariant)" :fill="leafColor(friendStyle)" />
|
||||
<g stroke="rgba(255,255,255,0.4)" stroke-width="0.8" fill="none">
|
||||
<path v-for="(vp, i) in veinPaths(friendStyle)" :key="i" :d="vp" />
|
||||
</g>
|
||||
</svg>
|
||||
<span class="leaf-label">{{ hb.friend_name }}</span>
|
||||
<span v-if="!hb.is_online" class="offline-tag">已下线</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BPM 信息 -->
|
||||
<div class="bpm-info">
|
||||
<span class="bpm-num">{{ hb.bpm }}</span>
|
||||
<span class="bpm-unit">BPM</span>
|
||||
<span class="bpm-desc">{{ bpmDesc }}</span>
|
||||
</div>
|
||||
<div class="hb-stats">近 7 天 {{ hb.msg_7d }} 条消息 · {{ hb.is_online ? '在线' : '离线' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { generateLeafStyle, leafColor, leafPath, veinPaths } from '@/utils/leafGenerator'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const trees = ref<any[]>([])
|
||||
const selectedFriend = ref<string | null>(null)
|
||||
const hb = ref<any>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const myStyle = computed(() => {
|
||||
const seed = (auth.user?.id || '0').padEnd(16, '0').slice(0, 16)
|
||||
return generateLeafStyle(seed)
|
||||
})
|
||||
const friendStyle = computed(() =>
|
||||
hb.value ? generateLeafStyle(hb.value.friend_leaf_seed) : generateLeafStyle('0000000000000000')
|
||||
)
|
||||
|
||||
// 一次呼吸的毫秒数(BPM → 周期)。心跳周期 = 60s/bpm
|
||||
const cycleMs = computed(() => Math.round(60000 / (hb.value?.bpm || 60)))
|
||||
|
||||
// 关键同步巧思:基于墙钟时间算相位,双方天然同步
|
||||
// animation-delay 让动画的"起点"对齐到全局时间,即使双方分别加载也对齐
|
||||
const phaseDelay = computed(() => {
|
||||
const now = Date.now()
|
||||
const cycle = cycleMs.value
|
||||
return -(now % cycle) + 'ms'
|
||||
})
|
||||
|
||||
const myLeafStyle = computed(() => ({
|
||||
animation: `heartbeat-breathe ${cycleMs.value}ms ease-in-out infinite`,
|
||||
animationDelay: phaseDelay.value,
|
||||
}))
|
||||
const friendLeafStyle = computed(() => ({
|
||||
animation: `heartbeat-breathe ${cycleMs.value}ms ease-in-out infinite`,
|
||||
animationDelay: phaseDelay.value,
|
||||
filter: hb.value?.is_online ? 'none' : 'grayscale(0.6) opacity(0.5)',
|
||||
}))
|
||||
|
||||
const bpmDesc = computed(() => {
|
||||
const bpm = hb.value?.bpm || 0
|
||||
if (bpm < 50) return '沉睡中,静静陪伴'
|
||||
if (bpm < 60) return '平静的呼吸'
|
||||
if (bpm < 75) return '温暖的同频'
|
||||
if (bpm < 85) return '活跃的心跳'
|
||||
return '热烈的共振'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await treesApi.getAll()
|
||||
trees.value = data
|
||||
if (data.length > 0) await selectFriend(data[0].friend_id)
|
||||
} catch {} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function selectFriend(friendId: string) {
|
||||
selectedFriend.value = friendId
|
||||
try {
|
||||
const { data } = await treesApi.heartbeat(friendId)
|
||||
hb.value = data
|
||||
} catch {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.heartbeat-view {
|
||||
flex: 1; overflow-y: auto; padding: 24px;
|
||||
background: linear-gradient(180deg, var(--color-bg) 0%, #E8F5E9 100%);
|
||||
}
|
||||
.hb-header { text-align: center; margin-bottom: 16px; }
|
||||
.hb-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; color: var(--color-text-hint); }
|
||||
|
||||
.friend-selector { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-bottom: 24px; }
|
||||
.friend-chip { cursor: pointer; padding: 3px; border-radius: 50%; border: 2px solid transparent; }
|
||||
.friend-chip:hover { border-color: var(--color-primary-lighter); }
|
||||
.friend-chip.active { border-color: var(--color-primary); }
|
||||
|
||||
.hb-stage { max-width: 480px; margin: 0 auto; text-align: center; }
|
||||
.leaves-pair { display: flex; align-items: center; justify-content: center; gap: 12px; height: 240px; }
|
||||
.leaf-side { display: flex; flex-direction: column; align-items: center; gap: 6px; }
|
||||
.leaf-side.offline .hb-leaf { animation: leaf-fall 2s ease forwards; }
|
||||
@keyframes leaf-fall {
|
||||
0% { transform: rotate(0); }
|
||||
100% { transform: rotate(30deg) translateY(20px); opacity: 0.4; }
|
||||
}
|
||||
.hb-leaf { width: 110px; height: 132px; filter: drop-shadow(0 6px 12px rgba(0,150,136,0.2)); transform-origin: 50% 100%; }
|
||||
@keyframes heartbeat-breathe {
|
||||
0%, 100% { transform: scale(0.92); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
.leaf-label { font-size: 12px; color: var(--color-text-secondary); }
|
||||
.offline-tag { font-size: 10px; color: var(--color-text-hint); }
|
||||
|
||||
.hb-link { flex: 0 0 60px; height: 4px; background: var(--color-border); border-radius: 2px; position: relative; overflow: hidden; }
|
||||
.pulse-dot {
|
||||
position: absolute; top: -3px; left: 0; width: 10px; height: 10px;
|
||||
border-radius: 50%; background: var(--color-primary);
|
||||
animation: pulse-travel linear infinite;
|
||||
box-shadow: 0 0 8px var(--color-primary);
|
||||
}
|
||||
@keyframes pulse-travel {
|
||||
0% { left: -10px; }
|
||||
100% { left: 60px; }
|
||||
}
|
||||
|
||||
.bpm-info { margin-top: 20px; }
|
||||
.bpm-num { font-size: 48px; font-weight: 800; color: var(--color-primary); }
|
||||
.bpm-unit { font-size: 16px; color: var(--color-text-hint); margin-left: 4px; }
|
||||
.bpm-desc { display: block; font-size: 14px; color: var(--color-text-secondary); margin-top: 4px; }
|
||||
.hb-stats { font-size: 12px; color: var(--color-text-hint); margin-top: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div class="sync-view">
|
||||
<div class="sync-header">
|
||||
<h2>🌱 默契种子</h2>
|
||||
<p class="subtitle">和好友盲答同一题,看看默契长出什么样的叶子</p>
|
||||
</div>
|
||||
|
||||
<div class="sync-main">
|
||||
<!-- 今日题目 -->
|
||||
<div v-if="question" class="question-card">
|
||||
<div class="q-label">今日默契题</div>
|
||||
<div class="q-content">{{ question.content }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 选好友 + 作答 -->
|
||||
<div v-if="question" class="answer-section">
|
||||
<div class="form-row">
|
||||
<span class="form-label">和谁默契?</span>
|
||||
<n-select v-model:value="selectedFriend" :options="friendOptions" placeholder="选择好友" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<span class="form-label">你的答案</span>
|
||||
<n-input v-model:value="myAnswer" type="textarea" :rows="3"
|
||||
placeholder="凭直觉写,越真诚越默契..." maxlength="500" show-count />
|
||||
</div>
|
||||
<n-button type="primary" round block :loading="submitting"
|
||||
:disabled="!selectedFriend || !myAnswer.trim()" @click="submit">
|
||||
种下默契种子
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<!-- 结果区 -->
|
||||
<div v-if="result" class="result-card" :class="{ revealed: result.score != null }">
|
||||
<template v-if="result.score != null">
|
||||
<!-- 揭晓 -->
|
||||
<div class="leaf-stage">
|
||||
<svg viewBox="0 0 100 120" class="result-leaf" :class="{ bloom: true }">
|
||||
<defs>
|
||||
<linearGradient id="sync-grad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" :stop-color="leafColor(style, 15)" />
|
||||
<stop offset="100%" :stop-color="leafColor(style, -10)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path :d="leafPath(style.shapeVariant)" fill="url(#sync-grad)" />
|
||||
<g stroke="rgba(255,255,255,0.4)" stroke-width="0.8" fill="none">
|
||||
<path v-for="(vp, i) in veinPaths(style)" :key="i" :d="vp" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="score-display">
|
||||
<span class="score-num">{{ result.score }}</span>
|
||||
<span class="score-unit">默契度</span>
|
||||
</div>
|
||||
<div class="score-comment">{{ scoreComment(result.score) }}</div>
|
||||
<div class="answers-compare">
|
||||
<div class="answer-box mine">
|
||||
<span class="ans-label">你</span>
|
||||
<span class="ans-text">{{ result.my_answer }}</span>
|
||||
</div>
|
||||
<div class="answer-box partner">
|
||||
<span class="ans-label">TA</span>
|
||||
<span class="ans-text">{{ result.partner_answer }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- 等待对方 -->
|
||||
<div class="waiting">
|
||||
<div class="seed-sprout">🌱</div>
|
||||
<p>种子已种下,等 TA 来回答</p>
|
||||
<p class="waiting-hint">你们都答完,默契叶就会发芽</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { syncApi } from '@/api/sync'
|
||||
import { friendsApi } from '@/api/friends'
|
||||
import { generateLeafStyle, leafColor, leafPath, veinPaths } from '@/utils/leafGenerator'
|
||||
|
||||
const message = useMessage()
|
||||
const question = ref<any>(null)
|
||||
const friends = ref<any[]>([])
|
||||
const selectedFriend = ref<string | null>(null)
|
||||
const myAnswer = ref('')
|
||||
const submitting = ref(false)
|
||||
const result = ref<any>(null)
|
||||
|
||||
const friendOptions = computed(() =>
|
||||
friends.value.map((f) => ({
|
||||
label: f.remark || f.nickname || f.username,
|
||||
value: f.friend_user_id,
|
||||
}))
|
||||
)
|
||||
|
||||
const style = computed(() =>
|
||||
result.value?.leaf_seed
|
||||
? generateLeafStyle(result.value.leaf_seed)
|
||||
: generateLeafStyle('0000000000000000')
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await syncApi.getQuestion()
|
||||
question.value = data
|
||||
} catch {
|
||||
message.error('加载题目失败')
|
||||
}
|
||||
try {
|
||||
const { data } = await friendsApi.getFriends()
|
||||
friends.value = data
|
||||
} catch {}
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (!selectedFriend.value || !myAnswer.value.trim()) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const { data } = await syncApi.submitAnswer(question.value.id, selectedFriend.value, myAnswer.value.trim())
|
||||
result.value = { ...data, my_answer: myAnswer.value.trim() }
|
||||
if (data.score != null) {
|
||||
message.success('默契叶发芽了!')
|
||||
} else {
|
||||
message.success('种子已种下,等 TA 回答')
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e.response?.data?.detail || '提交失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function scoreComment(score: number): string {
|
||||
if (score >= 80) return '心有灵犀!这片叶子饱满得像要发光 ✨'
|
||||
if (score >= 60) return '相当默契,叶子长得很精神 🌿'
|
||||
if (score >= 40) return '有点默契,叶子还在成长 🌱'
|
||||
if (score >= 20) return '需要多聊聊,叶子还嫩着呢'
|
||||
return '可能想到了不同的方向,但这也很可爱'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sync-view {
|
||||
flex: 1; overflow-y: auto; padding: 24px;
|
||||
background: linear-gradient(180deg, var(--color-bg) 0%, var(--color-primary-lightest) 100%);
|
||||
}
|
||||
.sync-header { text-align: center; margin-bottom: 20px; }
|
||||
.sync-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; }
|
||||
|
||||
.sync-main { max-width: 480px; margin: 0 auto; }
|
||||
|
||||
.question-card {
|
||||
background: var(--color-surface); border-radius: 14px; padding: 20px;
|
||||
margin-bottom: 16px; border-left: 4px solid var(--color-primary);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
.q-label { font-size: 12px; color: var(--color-primary); font-weight: 600; margin-bottom: 6px; }
|
||||
.q-content { font-size: 17px; font-weight: 600; color: var(--color-text-primary); line-height: 1.5; }
|
||||
|
||||
.answer-section { background: var(--color-surface); border-radius: 14px; padding: 20px; margin-bottom: 16px; }
|
||||
.form-row { margin-bottom: 16px; }
|
||||
.form-label { display: block; font-size: 13px; color: var(--color-text-secondary); margin-bottom: 6px; font-weight: 500; }
|
||||
|
||||
.result-card {
|
||||
background: var(--color-surface); border-radius: 14px; padding: 24px; text-align: center;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.result-card.revealed { border-color: var(--color-primary); box-shadow: 0 4px 16px rgba(0,150,136,0.15); }
|
||||
|
||||
.leaf-stage { display: flex; justify-content: center; margin-bottom: 12px; filter: drop-shadow(0 6px 12px rgba(0,150,136,0.2)); }
|
||||
.result-leaf { width: 120px; height: 144px; }
|
||||
.result-leaf.bloom { animation: bloom 0.8s ease; }
|
||||
@keyframes bloom { 0% { transform: scale(0.3); opacity: 0; } 60% { transform: scale(1.15); } 100% { transform: scale(1); opacity: 1; } }
|
||||
|
||||
.score-display { margin-bottom: 4px; }
|
||||
.score-num { font-size: 48px; font-weight: 800; color: var(--color-primary); }
|
||||
.score-unit { font-size: 14px; color: var(--color-text-hint); margin-left: 4px; }
|
||||
.score-comment { font-size: 14px; color: var(--color-text-secondary); margin-bottom: 20px; }
|
||||
|
||||
.answers-compare { display: flex; gap: 12px; text-align: left; }
|
||||
.answer-box { flex: 1; padding: 12px; border-radius: 10px; background: var(--color-bg); }
|
||||
.answer-box.mine { border-left: 3px solid var(--color-primary); }
|
||||
.answer-box.partner { border-left: 3px solid var(--color-primary-lighter); }
|
||||
.ans-label { display: block; font-size: 11px; font-weight: 700; color: var(--color-primary); margin-bottom: 4px; }
|
||||
.ans-text { font-size: 13px; color: var(--color-text-primary); line-height: 1.5; }
|
||||
|
||||
.waiting { padding: 30px 0; }
|
||||
.seed-sprout { font-size: 64px; animation: sprout 2s ease-in-out infinite; }
|
||||
@keyframes sprout { 0%,100% { transform: scale(1); } 50% { transform: scale(1.1); } }
|
||||
.waiting p { margin: 8px 0; color: var(--color-text-secondary); }
|
||||
.waiting-hint { font-size: 12px; color: var(--color-text-hint) !important; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user