This commit is contained in:
2026-06-14 09:25:59 +08:00
parent a0f441d8ae
commit 6fbf610277
39 changed files with 2492 additions and 2 deletions
+5 -1
View File
@@ -64,7 +64,7 @@ app.add_middleware(
app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads")
# 注册路由
from app.routers import auth, users, conversations, messages, friends, admin, uploads, moments, 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
+10
View File
@@ -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",
]
+24
View File
@@ -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")
+41
View File
@@ -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])
+24
View File
@@ -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])
+37
View File
@@ -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)
+38
View File
@@ -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))
+40
View File
@@ -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)
+68
View File
@@ -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)
+10
View File
@@ -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)
+58
View File
@@ -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))
+11
View File
@@ -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)
+161
View File
@@ -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 []
+97
View File
@@ -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
+213
View File
@@ -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,
}
+52
View File
@@ -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,
+191
View File
@@ -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,
}
+67
View File
@@ -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)
+5
View File
@@ -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
View File
@@ -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()
+7
View File
@@ -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`),
}
+8
View File
@@ -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/'),
}
+11
View File
@@ -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'),
}
+2
View File
@@ -7,4 +7,6 @@ export const leavesApi = {
api.put(`/leaves/${leafId}`, data),
getCollection: () => api.get('/leaves/collection'),
getGrove: () => api.get('/leaves/grove'),
}
+10
View File
@@ -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}`),
}
+2
View File
@@ -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`),
}
+120
View File
@@ -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>
+242
View File
@@ -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>
+12
View File
@@ -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
+36
View File
@@ -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'),
},
},
],
},
+67
View File
@@ -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)
+128
View File
@@ -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>
+50
View File
@@ -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; }
+212
View File
@@ -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>
+187
View File
@@ -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>
+198
View File
@@ -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>
+9 -1
View File
@@ -28,4 +28,12 @@
我现在有一台服务器,可以用上吗?
我现在想要一些特色的亮点,是其他社交软件没有的
我现在想要一些特色的亮点,是其他社交软件没有的
我要丰富的功能,最好是很有创意的、
不用写新的创意了,先把第一波的做完
这个窗口一直不关上
可以