diff --git a/backend/app/main.py b/backend/app/main.py index 4e43585..6a33967 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -64,7 +64,7 @@ app.add_middleware( app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads") # 注册路由 -from app.routers import auth, users, conversations, messages, friends, admin, uploads, moments, leaves, trees, capsules, echoes, sync, climates, flash +from app.routers import auth, users, conversations, messages, friends, admin, uploads, moments, leaves, trees, capsules, echoes, sync, climates, flash, garden app.include_router(auth.router, prefix="/api/v1/auth", tags=["认证"]) app.include_router(users.router, prefix="/api/v1/users", tags=["用户"]) @@ -81,6 +81,7 @@ 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=["萤火虫时刻"]) +app.include_router(garden.router, prefix="/api/v1/garden", tags=["花园状态"]) # WebSocket from app.websocket.router import websocket_router diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index a43905a..42a7a2b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -15,6 +15,7 @@ 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 +from app.models.user_streak import UserStreak __all__ = [ "User", @@ -36,4 +37,5 @@ __all__ = [ "ChatClimate", "FlashEvent", "FlashParticipation", + "UserStreak", ] diff --git a/backend/app/models/user_streak.py b/backend/app/models/user_streak.py new file mode 100644 index 0000000..6122d30 --- /dev/null +++ b/backend/app/models/user_streak.py @@ -0,0 +1,22 @@ +"""用户每日签到/连续天数模型""" + +from datetime import date, datetime + +from sqlalchemy import String, Integer, Date, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class UserStreak(Base): + __tablename__ = "user_streaks" + + user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True) + streak: Mapped[int] = mapped_column(Integer, default=0) # 连续打开天数 + last_open_date: Mapped[date | None] = mapped_column(Date, nullable=True) + total_days: Mapped[int] = mapped_column(Integer, default=0) # 累计打开天数 + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=lambda: datetime.utcnow(), onupdate=lambda: datetime.utcnow() + ) + + user = relationship("User", foreign_keys=[user_id]) diff --git a/backend/app/routers/garden.py b/backend/app/routers/garden.py new file mode 100644 index 0000000..9c3f93d --- /dev/null +++ b/backend/app/routers/garden.py @@ -0,0 +1,20 @@ +"""花园综合状态路由""" + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies import get_db, get_current_user +from app.models.user import User +from app.services.daily_service import DailyService + +router = APIRouter() + + +@router.get("/daily-status") +async def daily_status( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """今日花园状态:连续天数 + 口渴的树""" + service = DailyService(db) + return await service.get_daily_status(user.id) diff --git a/backend/app/services/daily_service.py b/backend/app/services/daily_service.py new file mode 100644 index 0000000..5ef5abf --- /dev/null +++ b/backend/app/services/daily_service.py @@ -0,0 +1,80 @@ +"""每日花园状态服务:连续天数 + 树渴了提醒""" + +from datetime import date, datetime, timedelta + +from sqlalchemy import select, or_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.user_streak import UserStreak +from app.models.friendship_tree import FriendshipTree +from app.models.friend import Friend +from app.models.user import User + + +class DailyService: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_daily_status(self, user_id: str) -> dict: + """获取/更新今日花园状态""" + today = date.today() + result = await self.db.execute( + select(UserStreak).where(UserStreak.user_id == user_id) + ) + streak_row = result.scalars().first() + + is_new_day_open = False + if not streak_row: + streak_row = UserStreak( + user_id=user_id, streak=1, last_open_date=today, total_days=1, + ) + self.db.add(streak_row) + is_new_day_open = True + elif streak_row.last_open_date != today: + # 计算连续天数 + if streak_row.last_open_date == today - timedelta(days=1): + streak_row.streak += 1 # 连续 + else: + streak_row.streak = 1 # 断了,重新计数 + streak_row.last_open_date = today + streak_row.total_days += 1 + is_new_day_open = True + await self.db.flush() + + # 找口渴的树(24 小时没浇水的) + thirsty_trees = await self._get_thirsty_trees(user_id) + + return { + "streak": streak_row.streak, + "total_days": streak_row.total_days, + "is_new_day_open": is_new_day_open, + "thirsty_count": len(thirsty_trees), + "thirsty_trees": thirsty_trees[:3], # 最多提示 3 棵 + } + + async def _get_thirsty_trees(self, user_id: str) -> list[dict]: + """获取口渴的好友之树(24h 未浇水)""" + cutoff = datetime.utcnow() - timedelta(hours=24) + # 查该用户参与的所有树 + result = await self.db.execute( + select(FriendshipTree).where( + or_( + FriendshipTree.user_a_id == user_id, + FriendshipTree.user_b_id == user_id, + ) + ) + ) + thirsty = [] + for tree in result.scalars().all(): + if tree.last_watered_at is None or tree.last_watered_at < cutoff: + friend_id = tree.user_b_id if tree.user_a_id == user_id else tree.user_a_id + # 取好友信息 + fr = await self.db.execute(select(User).where(User.id == friend_id)) + friend = fr.scalars().first() + if friend: + thirsty.append({ + "friend_id": friend_id, + "friend_name": friend.nickname or friend.username, + "hours_since": int((datetime.utcnow() - (tree.last_watered_at or tree.created_at)).total_seconds() / 3600), + }) + return thirsty diff --git a/frontend/src/api/garden.ts b/frontend/src/api/garden.ts new file mode 100644 index 0000000..dc3c7c9 --- /dev/null +++ b/frontend/src/api/garden.ts @@ -0,0 +1,5 @@ +import api from './client' + +export const gardenApi = { + getDailyStatus: () => api.get('/garden/daily-status'), +} diff --git a/frontend/src/utils/shareCard.ts b/frontend/src/utils/shareCard.ts new file mode 100644 index 0000000..a0c79a2 --- /dev/null +++ b/frontend/src/utils/shareCard.ts @@ -0,0 +1,140 @@ +/** + * 生成好友之树分享图(Canvas 绘制,可下载发到微信朋友圈) + */ + +export interface ShareTreeData { + friendName: string + myName: string + stageIndex: number + stageName: string + stageEmoji: string + messageCount: number + waterCount: number + totalScore: number + hue: number +} + +function hsl(h: number, s: number, l: number): string { + return `hsl(${h}, ${s}%, ${l}%)` +} + +/** 把树画到 canvas 上 */ +function drawTree(ctx: CanvasRenderingContext2D, cx: number, baseY: number, stageIndex: number, hue: number) { + if (stageIndex === 0) { + // 种子 + ctx.fillStyle = hsl(25, 45, 40) + ctx.beginPath() + ctx.ellipse(cx, baseY, 14, 10, 0, 0, Math.PI * 2) + ctx.fill() + return + } + const trunkW = 4 + stageIndex * 2.2 + const canopyR = 22 + stageIndex * 9 + const canopyN = 3 + stageIndex + + // 树干 + ctx.fillStyle = hsl(25, 45, 35 + stageIndex * 2) + ctx.fillRect(cx - trunkW / 2, baseY - 90, trunkW, 90) + + // 树冠(多个圆叠加) + ctx.fillStyle = hsl(hue, 55, 42) + ctx.beginPath() + ctx.arc(cx, baseY - 100, canopyR, 0, Math.PI * 2) + ctx.fill() + for (let i = 1; i < canopyN; i++) { + const offset = i % 2 === 1 ? 1 : -1 + const layer = Math.ceil(i / 2) + ctx.fillStyle = hsl(hue, 55, 42 + (i % 2) * 6) + ctx.beginPath() + ctx.arc( + cx + offset * canopyR * 0.6 * layer, + baseY - 100 + layer * canopyR * 0.3, + canopyR * (0.75 - layer * 0.1), + 0, Math.PI * 2 + ) + ctx.fill() + } + // 高光 + ctx.fillStyle = hsl(hue, 60, 60) + ctx.globalAlpha = 0.3 + ctx.beginPath() + ctx.arc(cx - canopyR * 0.3, baseY - 100 - canopyR * 0.3, canopyR * 0.3, 0, Math.PI * 2) + ctx.fill() + ctx.globalAlpha = 1 +} + +/** 生成完整分享卡片,返回 dataURL */ +export function generateTreeShareCard(data: ShareTreeData): string { + const W = 600, H = 800 + const canvas = document.createElement('canvas') + canvas.width = W + canvas.height = H + const ctx = canvas.getContext('2d')! + + // 背景渐变 + const bg = ctx.createLinearGradient(0, 0, 0, H) + bg.addColorStop(0, '#E8F5E9') + bg.addColorStop(0.5, '#C8E6C9') + bg.addColorStop(1, '#A5D6A7') + ctx.fillStyle = bg + ctx.fillRect(0, 0, W, H) + + // 顶部光晕 + const glow = ctx.createRadialGradient(W / 2, 200, 0, W / 2, 200, 300) + glow.addColorStop(0, 'rgba(255,255,255,0.5)') + glow.addColorStop(1, 'rgba(255,255,255,0)') + ctx.fillStyle = glow + ctx.fillRect(0, 0, W, 400) + + // logo + 标题 + ctx.fillStyle = '#004D40' + ctx.font = 'bold 28px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif' + ctx.textAlign = 'center' + ctx.fillText('🌿 我们在青叶种了一棵树', W / 2, 70) + + // 名字 + ctx.font = '20px -apple-system, "PingFang SC", sans-serif' + ctx.fillStyle = '#00695C' + ctx.fillText(`${data.myName} × ${data.friendName}`, W / 2, 105) + + // 地面 + ctx.fillStyle = 'rgba(139,90,43,0.15)' + ctx.beginPath() + ctx.ellipse(W / 2, 560, 140, 16, 0, 0, Math.PI * 2) + ctx.fill() + + // 画树 + drawTree(ctx, W / 2, 560, data.stageIndex, data.hue) + + // 阶段标签 + ctx.font = 'bold 22px sans-serif' + ctx.fillStyle = '#00796B' + ctx.fillText(`${data.stageEmoji} ${data.stageName} Lv.${data.stageIndex + 1}`, W / 2, 620) + + // 统计三栏 + const stats = [ + { label: '条消息', value: data.messageCount }, + { label: '次浇水', value: data.waterCount }, + { label: '成长值', value: data.totalScore }, + ] + const statY = 670 + stats.forEach((s, i) => { + const x = W / 4 + (W / 4) * i - 30 + ctx.fillStyle = '#009688' + ctx.font = 'bold 26px sans-serif' + ctx.fillText(String(s.value), x, statY) + ctx.fillStyle = '#5F7A74' + ctx.font = '13px sans-serif' + ctx.fillText(s.label, x, statY + 22) + }) + + // 底部邀请 + ctx.fillStyle = '#004D40' + ctx.font = '15px sans-serif' + ctx.fillText('来青叶,和你在意的人一起种树', W / 2, 745) + ctx.font = '12px sans-serif' + ctx.fillStyle = '#5F7A74' + ctx.fillText('微信给不了你的,是一片会生长的关系', W / 2, 770) + + return canvas.toDataURL('image/png') +} diff --git a/frontend/src/views/chat/ChatListView.vue b/frontend/src/views/chat/ChatListView.vue index 7f2e69f..88b6f5b 100644 --- a/frontend/src/views/chat/ChatListView.vue +++ b/frontend/src/views/chat/ChatListView.vue @@ -1,7 +1,61 @@