diff --git a/app/[locale]/messages/page.tsx b/app/[locale]/messages/page.tsx new file mode 100644 index 0000000..2d916ad --- /dev/null +++ b/app/[locale]/messages/page.tsx @@ -0,0 +1,485 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Link } from "@/i18n/navigation"; +import Footer from "@/app/components/Footer"; +import { apiFetch } from "@/app/lib/api-client"; + +interface NotificationItem { + id: string; + title: string; + body: string; + category: string; + priority: "low" | "normal" | "high" | string; + actionLabel?: string; + actionUrl?: string; + entityType?: string; + entityId?: string; + icon?: string; + created?: string; + read: boolean; + archived: boolean; + pinned: boolean; + muted?: boolean; + metadata?: Record; +} + +interface PreferenceRecord { + channels: Record; + categories: Record; + quietHoursStart: string; + quietHoursEnd: string; + digest: string; + allowMarketing: boolean; +} + +const CATEGORY_META: Record = { + all: { label: "全部", icon: "📬", color: "bg-gray-900 text-white" }, + system: { label: "系统", icon: "🔔", color: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-200" }, + meetup: { label: "活动", icon: "🍹", color: "bg-orange-100 text-orange-700 dark:bg-orange-950/40 dark:text-orange-200" }, + gig: { label: "赏金", icon: "💰", color: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200" }, + match: { label: "匹配", icon: "💚", color: "bg-pink-100 text-pink-700 dark:bg-pink-950/40 dark:text-pink-200" }, + service: { label: "服务", icon: "🧭", color: "bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-200" }, + community: { label: "社区", icon: "🔥", color: "bg-violet-100 text-violet-700 dark:bg-violet-950/40 dark:text-violet-200" }, + city: { label: "城市", icon: "🏙️", color: "bg-cyan-100 text-cyan-700 dark:bg-cyan-950/40 dark:text-cyan-200" }, +}; + +const STATUS_TABS = [ + { key: "active", label: "收件箱" }, + { key: "unread", label: "未读" }, + { key: "read", label: "已读" }, + { key: "archived", label: "归档" }, +]; + +const DEFAULT_PREFERENCES: PreferenceRecord = { + channels: { in_app: true, email: false, wechat: false }, + categories: { + system: true, + meetup: true, + gig: true, + match: true, + service: true, + community: true, + city: true, + }, + quietHoursStart: "22:00", + quietHoursEnd: "08:00", + digest: "daily", + allowMarketing: false, +}; + +function formatTime(value?: string): string { + if (!value) return "刚刚"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "刚刚"; + const diff = Date.now() - date.getTime(); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return "刚刚"; + if (minutes < 60) return `${minutes} 分钟前`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} 小时前`; + return `${date.getMonth() + 1}月${date.getDate()}日`; +} + +function priorityLabel(priority: string): string { + if (priority === "high") return "重要"; + if (priority === "low") return "低优先级"; + return "普通"; +} + +export default function MessagesPage() { + const [items, setItems] = useState([]); + const [unread, setUnread] = useState(0); + const [categories, setCategories] = useState>({}); + const [mutedCount, setMutedCount] = useState(0); + const [category, setCategory] = useState("all"); + const [status, setStatus] = useState("active"); + const [query, setQuery] = useState(""); + const [loading, setLoading] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES); + const [notice, setNotice] = useState(""); + + const selected = useMemo( + () => items.find((item) => item.id === selectedId) || items[0], + [items, selectedId] + ); + + const loadNotifications = () => { + setLoading(true); + const params = new URLSearchParams({ category, status, q: query }); + apiFetch<{ items: NotificationItem[]; unread: number; categories: Record; muted: number }>(`/api/notifications?${params.toString()}`) + .then((data) => { + setItems(data.items || []); + setUnread(Number(data.unread || 0)); + setCategories(data.categories || {}); + setMutedCount(Number(data.muted || 0)); + if (data.items?.length && !data.items.some((item) => item.id === selectedId)) { + setSelectedId(data.items[0].id); + } + }) + .catch(() => { + setItems([]); + setUnread(0); + setCategories({}); + setMutedCount(0); + }) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + loadNotifications(); + apiFetch<{ record: PreferenceRecord }>("/api/notifications/preferences") + .then((data) => setPreferences({ ...DEFAULT_PREFERENCES, ...(data.record || {}) })) + .catch(() => setPreferences(DEFAULT_PREFERENCES)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const timer = window.setTimeout(loadNotifications, 250); + return () => window.clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [category, status, query]); + + const mutateNotification = async (id: string, action: "read" | "unread" | "archive" | "restore" | "pin") => { + await apiFetch(`/api/notifications/${id}/${action}`, { method: "POST" }); + loadNotifications(); + }; + + const markAllRead = async () => { + const data = await apiFetch<{ count: number }>("/api/notifications/mark-all-read", { method: "POST" }); + setNotice(`已标记 ${data.count} 条消息为已读`); + loadNotifications(); + }; + + const savePreferences = async () => { + await apiFetch("/api/notifications/preferences", { + method: "PUT", + body: JSON.stringify(preferences), + }); + setNotice("通知偏好已保存"); + loadNotifications(); + }; + + return ( +
+
+
+
+

Message Center

+

+ 站内消息中心 +

+

+ 活动、任务、匹配、服务咨询、城市更新和系统公告统一收口,所有动作都和 FastAPI / PocketBase 数据联动。 +

+
+
+ + + 偏好设置 + +
+
+ + {notice && ( +
+ {notice} +
+ )} + +
+
+

未读消息

+

{unread}

+
+
+

当前列表

+

{items.length}

+
+
+

静音消息

+

{mutedCount}

+
+
+

当前策略

+

{preferences.digest === "instant" ? "实时提醒" : "每日摘要"}

+

{preferences.quietHoursStart} - {preferences.quietHoursEnd} 免打扰

+
+
+ +
+ + +
+
+
+

消息流

+

{loading ? "正在同步 PocketBase" : "最新消息在前,置顶优先"}

+
+ + {items.length} 条 + +
+ +
+ {items.map((item) => { + const meta = CATEGORY_META[item.category] || CATEGORY_META.system; + const active = selected?.id === item.id; + return ( + + ); + })} +
+ + {items.length === 0 && ( +
+
📭
+

没有符合条件的消息

+

换一个分类、状态或搜索词试试。

+
+ )} +
+ + +
+
+
+
+ ); +} diff --git a/app/[locale]/settings/notifications/page.tsx b/app/[locale]/settings/notifications/page.tsx new file mode 100644 index 0000000..de77a5c --- /dev/null +++ b/app/[locale]/settings/notifications/page.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Link } from "@/i18n/navigation"; +import Footer from "@/app/components/Footer"; +import { apiFetch } from "@/app/lib/api-client"; + +interface PreferenceRecord { + channels: Record; + categories: Record; + quietHoursStart: string; + quietHoursEnd: string; + digest: string; + allowMarketing: boolean; +} + +const DEFAULT_PREFERENCES: PreferenceRecord = { + channels: { in_app: true, email: false, wechat: false }, + categories: { + system: true, + meetup: true, + gig: true, + match: true, + service: true, + community: true, + city: true, + }, + quietHoursStart: "22:00", + quietHoursEnd: "08:00", + digest: "daily", + allowMarketing: false, +}; + +const CHANNELS = [ + ["in_app", "站内消息", "始终建议开启,所有业务通知都会进入消息中心。"], + ["email", "邮件摘要", "适合离线时收到每日/每周汇总。"], + ["wechat", "微信提醒", "后续可接企业微信、公众号或机器人。"], +]; + +const CATEGORIES = [ + ["system", "系统公告"], + ["meetup", "活动提醒"], + ["gig", "赏金任务"], + ["match", "社交匹配"], + ["service", "服务咨询"], + ["community", "社区讨论"], + ["city", "城市更新"], +]; + +export default function NotificationSettingsPage() { + const [prefs, setPrefs] = useState(DEFAULT_PREFERENCES); + const [saved, setSaved] = useState(""); + + useEffect(() => { + apiFetch<{ record: PreferenceRecord }>("/api/notifications/preferences") + .then((data) => setPrefs({ ...DEFAULT_PREFERENCES, ...(data.record || {}) })) + .catch(() => setPrefs(DEFAULT_PREFERENCES)); + }, []); + + const save = async () => { + await apiFetch("/api/notifications/preferences", { + method: "PUT", + body: JSON.stringify(prefs), + }); + setSaved("通知偏好已保存"); + }; + + return ( +
+
+ + ← 返回消息中心 + +
+

Notification Settings

+

通知偏好设置

+

+ 控制接收渠道、消息类型、免打扰时间和摘要频率。设置会保存到 PocketBase。 +

+
+ + {saved && ( +
+ {saved} +
+ )} + +
+
+
+

接收渠道

+
+ {CHANNELS.map(([key, title, desc]) => ( + + ))} +
+
+ +
+

消息类型

+
+ {CATEGORIES.map(([key, label]) => ( + + ))} +
+
+
+ + +
+
+
+
+ ); +} diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index b8cd6f9..db408a1 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -9,6 +9,7 @@ import AuthModal from "./AuthModal"; import ThemeToggle from "./ThemeToggle"; import SiteMenu from "./SiteMenu"; import UserAvatar from "./UserAvatar"; +import NotificationBell from "./NotificationBell"; export default function NavbarComponent() { const { t } = useTranslation("common"); @@ -153,6 +154,7 @@ export default function NavbarComponent() { ) : null} + + + {open && ( +
+
+
+

站内消息

+

{unread} 条未读

+
+ +
+ +
+ {items.map((item) => ( + { + markRead(item.id); + setOpen(false); + }} + className="block border-b border-gray-100 px-4 py-3 transition-colors hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-gray-800" + > +
+
+ {item.icon || "🔔"} +
+
+
+

+ {item.title} +

+ {!item.read && } +
+

{item.body}

+

+ {CATEGORY_LABELS[item.category] || item.category} · {formatTime(item.created)} +

+
+
+ + ))} +
+ + {items.length === 0 && ( +
+
📭
+

暂时没有新消息

+
+ )} + + setOpen(false)} + className="block bg-gray-50 px-4 py-3 text-center text-sm font-semibold text-gray-800 hover:bg-gray-100 dark:bg-gray-950 dark:text-gray-200 dark:hover:bg-gray-800" + > + 打开消息中心 → + +
+ )} + + ); +} diff --git a/app/components/SiteMenu.tsx b/app/components/SiteMenu.tsx index 3ba3339..629c3b0 100644 --- a/app/components/SiteMenu.tsx +++ b/app/components/SiteMenu.tsx @@ -56,6 +56,7 @@ export default memo(function SiteMenu({ { titleKey: "community", items: [ + { labelKey: "messages", href: "/messages", icon: "🔔", new: true }, { labelKey: "chat", href: "/join", icon: "💬" }, { labelKey: "hostMeetup", href: "/meetups/host", icon: "🎤", new: true }, { labelKey: "membersMap", href: "/map", icon: "🌐" }, diff --git a/backend/init_db.py b/backend/init_db.py index 86f6361..e37a13b 100644 --- a/backend/init_db.py +++ b/backend/init_db.py @@ -16,6 +16,58 @@ from .seed_data import ( from .enhanced_seed import ENHANCED_DISCUSSIONS, ENHANCED_GIGS, ENHANCED_MEETUPS, ENHANCED_PROFILES, ROUTES, SERVICES +NOTIFICATION_SEEDS: list[dict[str, Any]] = [ + { + "title": "欢迎来到游牧中国消息中心", + "body": "这里会集中显示活动提醒、赏金任务、服务咨询、匹配动态、城市更新和系统公告。你可以筛选、已读、归档,也可以从消息直接跳转到对应功能。", + "category": "system", + "priority": "high", + "targetUserId": "", + "audience": "all", + "actionLabel": "查看仪表盘", + "actionUrl": "/dashboard", + "entityType": "onboarding", + "entityId": "message-center", + "icon": "🔔", + "status": "published", + "channels": ["in_app"], + "metadata": {"source": "seed", "tone": "welcome"}, + }, + { + "title": "本周同城活动已更新", + "body": "大理、成都、深圳等城市新增了线下聚会,你可以在活动页查看时间、地点和报名人数。", + "category": "meetup", + "priority": "normal", + "targetUserId": "", + "audience": "all", + "actionLabel": "查看活动", + "actionUrl": "/meetups", + "entityType": "meetup", + "entityId": "weekly", + "icon": "🍹", + "status": "published", + "channels": ["in_app"], + "metadata": {"source": "seed"}, + }, + { + "title": "赏金墙有新任务可接", + "body": "城市资料补充、共享办公调研、活动摄影等任务已经开放,接单后会生成站内通知记录。", + "category": "gig", + "priority": "normal", + "targetUserId": "", + "audience": "all", + "actionLabel": "去接任务", + "actionUrl": "/gigs", + "entityType": "gig", + "entityId": "open-board", + "icon": "💰", + "status": "published", + "channels": ["in_app"], + "metadata": {"source": "seed"}, + }, +] + + def text(name: str, *, required: bool = False, max: int = 0) -> dict[str, Any]: return {"name": name, "type": "text", "required": required, "max": max} @@ -205,6 +257,41 @@ COLLECTIONS: dict[str, list[dict[str, Any]]] = { json_field("tags"), json_field("resultSlugs"), ], + "notifications": [ + text("title", required=True, max=255), + text("body", max=2000), + text("category", max=80), + text("priority", max=40), + text("targetUserId", max=80), + text("audience", max=40), + text("actionLabel", max=120), + text("actionUrl", max=500), + text("entityType", max=80), + text("entityId", max=120), + text("icon", max=16), + text("status", max=40), + json_field("channels"), + json_field("metadata"), + date("expiresAt"), + ], + "notification_receipts": [ + text("notificationId", required=True, max=80), + text("userId", required=True, max=80), + date("readAt"), + date("archivedAt"), + date("dismissedAt"), + boolean("pinned"), + ], + "notification_preferences": [ + text("userId", required=True, max=80), + text("email", max=255), + json_field("channels"), + json_field("categories"), + text("quietHoursStart", max=10), + text("quietHoursEnd", max=10), + text("digest", max=40), + boolean("allowMarketing"), + ], "feedback": [ text("type", max=40), text("email", max=255), @@ -319,6 +406,7 @@ async def main() -> None: await upsert_seed("city_details", "citySlug", CITY_DETAILS) await upsert_seed("services", "slug", SERVICES) await upsert_seed("routes", "slug", ROUTES) + await upsert_seed("notifications", "title", NOTIFICATION_SEEDS) if __name__ == "__main__": diff --git a/backend/main.py b/backend/main.py index 06329fc..4e350f5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,7 +11,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse from fastapi.responses import Response as FastAPIResponse from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel +from pydantic import BaseModel, Field from .pb_client import PocketBaseError, pb, pb_quote from .settings import get_settings @@ -88,7 +88,7 @@ class DiscussionPayload(BaseModel): title: str author: str = "NomadCNA" city: str = "" - tags: list[str] = [] + tags: list[str] = Field(default_factory=list) excerpt: str = "" @@ -106,11 +106,36 @@ class ServiceLeadPayload(BaseModel): note: str = "" +class NotificationCreatePayload(BaseModel): + title: str + body: str = "" + category: str = "system" + priority: str = "normal" + targetUserId: str = "" + audience: str = "all" + actionLabel: str = "" + actionUrl: str = "" + entityType: str = "" + entityId: str = "" + icon: str = "🔔" + channels: list[str] = Field(default_factory=lambda: ["in_app"]) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class NotificationPreferencePayload(BaseModel): + channels: dict[str, bool] = Field(default_factory=dict) + categories: dict[str, bool] = Field(default_factory=dict) + quietHoursStart: str = "22:00" + quietHoursEnd: str = "08:00" + digest: str = "daily" + allowMarketing: bool = False + + class RecommendationPayload(BaseModel): budget: int = 6000 internet: int = 50 climate: str = "any" - tags: list[str] = [] + tags: list[str] = Field(default_factory=list) limit: int = 12 @@ -218,6 +243,147 @@ def public_record(record: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in record.items() if not k.startswith("@")} +def utc_now() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.000Z") + + +def default_notification_preferences(user_id: str, email: str = "") -> dict[str, Any]: + return { + "userId": user_id, + "email": email, + "channels": {"in_app": True, "email": False, "wechat": False}, + "categories": { + "system": True, + "meetup": True, + "gig": True, + "match": True, + "service": True, + "community": True, + "city": True, + }, + "quietHoursStart": "22:00", + "quietHoursEnd": "08:00", + "digest": "daily", + "allowMarketing": False, + } + + +def normalize_notification_preferences(record: dict[str, Any] | None, user_id: str, email: str = "") -> dict[str, Any]: + defaults = default_notification_preferences(user_id, email) + if not record: + return defaults + public = public_record(record) + return { + **defaults, + **public, + "channels": {**defaults["channels"], **(public.get("channels") or {})}, + "categories": {**defaults["categories"], **(public.get("categories") or {})}, + } + + +async def notification_preferences_for_user(user_id: str, user: dict[str, Any] | None = None) -> dict[str, Any]: + record = await pb.first_record("notification_preferences", filter=f'userId={pb_quote(user_id)}') + return normalize_notification_preferences(record, user_id, user.get("email", "") if user else "") + + +def notification_is_muted(item: dict[str, Any], preferences: dict[str, Any]) -> bool: + category = item.get("category") or "system" + categories = preferences.get("categories") or {} + channels = preferences.get("channels") or {} + item_channels = item.get("channels") or ["in_app"] + category_enabled = bool(categories.get(category, True)) + channel_enabled = any(bool(channels.get(channel, False)) for channel in item_channels) + return not category_enabled or not channel_enabled + + +async def create_notification( + *, + title: str, + body: str = "", + category: str = "system", + priority: str = "normal", + target_user_id: str = "", + audience: str = "all", + action_label: str = "", + action_url: str = "", + entity_type: str = "", + entity_id: str = "", + icon: str = "🔔", + channels: list[str] | None = None, + metadata: dict[str, Any] | None = None, +) -> dict[str, Any] | None: + try: + return await pb.create_record( + "notifications", + { + "title": title, + "body": body, + "category": category, + "priority": priority, + "targetUserId": target_user_id, + "audience": audience, + "actionLabel": action_label, + "actionUrl": action_url, + "entityType": entity_type, + "entityId": entity_id, + "icon": icon, + "status": "published", + "channels": channels or ["in_app"], + "metadata": metadata or {}, + }, + ) + except PocketBaseError: + return None + + +async def receipt_for(notification_id: str, user_id: str) -> dict[str, Any] | None: + if not user_id: + return None + return await pb.first_record( + "notification_receipts", + filter=f'notificationId={pb_quote(notification_id)} && userId={pb_quote(user_id)}', + ) + + +async def upsert_receipt(notification_id: str, user_id: str, payload: dict[str, Any]) -> dict[str, Any]: + existing = await receipt_for(notification_id, user_id) + base = {"notificationId": notification_id, "userId": user_id, "pinned": False} + if existing: + return await pb.update_record("notification_receipts", existing["id"], payload) + return await pb.create_record("notification_receipts", {**base, **payload}) + + +async def decorated_notifications_for_user( + user_id: str, + preferences: dict[str, Any] | None = None, +) -> list[dict[str, Any]]: + preferences = preferences or await notification_preferences_for_user(user_id) + data = await pb.list_records("notifications", filter='status="published"', per_page=100) + notifications = [ + public_record(item) + for item in data.get("items", []) + if item.get("audience", "all") == "all" or item.get("targetUserId") == user_id + ] + receipts = (await pb.list_records("notification_receipts", filter=f'userId={pb_quote(user_id)}', per_page=200)).get("items", []) + receipt_by_notification = {receipt.get("notificationId"): public_record(receipt) for receipt in receipts} + decorated = [] + for item in notifications: + receipt = receipt_by_notification.get(item["id"], {}) + archived = bool(receipt.get("archivedAt")) + decorated.append( + { + **item, + "receipt": receipt, + "read": bool(receipt.get("readAt")), + "archived": archived, + "pinned": bool(receipt.get("pinned")), + "muted": notification_is_muted(item, preferences), + } + ) + decorated.sort(key=lambda item: (item.get("pinned", False), item.get("created", "")), reverse=True) + return decorated + + @app.get("/api/health") async def health() -> dict[str, Any]: return {"ok": True, "service": "NomadCNA FastAPI", "pb": settings.pb_url} @@ -408,6 +574,19 @@ async def join(payload: JoinPayload, request: Request) -> dict[str, Any]: await pb.update_record("profiles", profile["id"], profile_payload) else: await pb.create_record("profiles", profile_payload) + await create_notification( + title="成员资料已更新", + body="你的加入资料已经保存,后续匹配、活动推荐和城市建议会基于这份资料联动。", + category="system", + priority="normal", + target_user_id=user["id"], + audience="user", + action_label="查看资料", + action_url="/join", + entity_type="member_application", + entity_id=record["id"], + icon="👤", + ) return {"ok": True, "record": record, "user_id": user["id"]} @@ -555,6 +734,171 @@ async def content_detail(slug: str) -> dict[str, Any]: return {"ok": True, "item": public_record(item), "cities": cities} +@app.get("/api/notifications") +async def list_notifications( + request: Request, + category: str = "all", + status: str = "active", + q: str = "", + muted: str = "include", +) -> dict[str, Any]: + _, user = await current_user(request) + user_id = user["id"] if user else "anonymous" + preferences = await notification_preferences_for_user(user_id, user) + all_items = await decorated_notifications_for_user(user_id, preferences) + items = all_items + if muted == "exclude": + items = [item for item in items if not item.get("muted")] + elif muted == "only": + items = [item for item in items if item.get("muted")] + if category != "all": + items = [item for item in items if item.get("category") == category] + if status == "unread": + items = [item for item in items if not item.get("read") and not item.get("archived") and not item.get("muted")] + elif status == "read": + items = [item for item in items if item.get("read") and not item.get("archived")] + elif status == "archived": + items = [item for item in items if item.get("archived")] + else: + items = [item for item in items if not item.get("archived")] + if q.strip(): + needle = q.strip().lower() + items = [ + item + for item in items + if needle in str(item.get("title", "")).lower() or needle in str(item.get("body", "")).lower() + ] + unread = sum(1 for item in all_items if not item.get("read") and not item.get("archived") and not item.get("muted")) + categories: dict[str, int] = {} + muted_count = 0 + for item in all_items: + if not item.get("archived"): + categories[item.get("category", "system")] = categories.get(item.get("category", "system"), 0) + 1 + if item.get("muted"): + muted_count += 1 + return { + "ok": True, + "items": items, + "unread": unread, + "categories": categories, + "muted": muted_count, + "preferences": preferences, + } + + +@app.get("/api/notifications/unread-count") +async def notification_unread_count(request: Request) -> dict[str, Any]: + _, user = await current_user(request) + user_id = user["id"] if user else "anonymous" + preferences = await notification_preferences_for_user(user_id, user) + items = await decorated_notifications_for_user(user_id, preferences) + return {"ok": True, "count": sum(1 for item in items if not item.get("read") and not item.get("archived") and not item.get("muted"))} + + +@app.post("/api/notifications") +async def create_notification_endpoint(payload: NotificationCreatePayload, request: Request) -> dict[str, Any]: + _, user = await current_user(request) + target_user_id = payload.targetUserId or (user["id"] if user and payload.audience == "user" else "") + record = await create_notification( + title=payload.title, + body=payload.body, + category=payload.category, + priority=payload.priority, + target_user_id=target_user_id, + audience=payload.audience, + action_label=payload.actionLabel, + action_url=payload.actionUrl, + entity_type=payload.entityType, + entity_id=payload.entityId, + icon=payload.icon, + channels=payload.channels, + metadata=payload.metadata, + ) + if not record: + raise HTTPException(status_code=500, detail="通知创建失败") + return {"ok": True, "record": public_record(record)} + + +@app.post("/api/notifications/{notification_id}/read") +async def mark_notification_read(notification_id: str, request: Request) -> dict[str, Any]: + _, user = await current_user(request) + user_id = user["id"] if user else "anonymous" + receipt = await upsert_receipt(notification_id, user_id, {"readAt": utc_now()}) + return {"ok": True, "receipt": public_record(receipt)} + + +@app.post("/api/notifications/{notification_id}/unread") +async def mark_notification_unread(notification_id: str, request: Request) -> dict[str, Any]: + _, user = await current_user(request) + user_id = user["id"] if user else "anonymous" + receipt = await upsert_receipt(notification_id, user_id, {"readAt": None, "archivedAt": None}) + return {"ok": True, "receipt": public_record(receipt)} + + +@app.post("/api/notifications/{notification_id}/archive") +async def archive_notification(notification_id: str, request: Request) -> dict[str, Any]: + _, user = await current_user(request) + user_id = user["id"] if user else "anonymous" + receipt = await upsert_receipt(notification_id, user_id, {"archivedAt": utc_now(), "readAt": utc_now()}) + return {"ok": True, "receipt": public_record(receipt)} + + +@app.post("/api/notifications/{notification_id}/restore") +async def restore_notification(notification_id: str, request: Request) -> dict[str, Any]: + _, user = await current_user(request) + user_id = user["id"] if user else "anonymous" + receipt = await upsert_receipt(notification_id, user_id, {"archivedAt": None, "dismissedAt": None}) + return {"ok": True, "receipt": public_record(receipt)} + + +@app.post("/api/notifications/{notification_id}/pin") +async def pin_notification(notification_id: str, request: Request) -> dict[str, Any]: + _, user = await current_user(request) + user_id = user["id"] if user else "anonymous" + existing = await receipt_for(notification_id, user_id) + pinned = not bool(existing and existing.get("pinned")) + receipt = await upsert_receipt(notification_id, user_id, {"pinned": pinned}) + return {"ok": True, "receipt": public_record(receipt), "pinned": pinned} + + +@app.post("/api/notifications/mark-all-read") +async def mark_all_notifications_read(request: Request) -> dict[str, Any]: + _, user = await current_user(request) + user_id = user["id"] if user else "anonymous" + preferences = await notification_preferences_for_user(user_id, user) + items = await decorated_notifications_for_user(user_id, preferences) + count = 0 + for item in items: + if not item.get("read") and not item.get("archived") and not item.get("muted"): + await upsert_receipt(item["id"], user_id, {"readAt": utc_now()}) + count += 1 + return {"ok": True, "count": count} + + +@app.get("/api/notifications/preferences") +async def notification_preferences(request: Request) -> dict[str, Any]: + _, user = await current_user(request) + user_id = user["id"] if user else "anonymous" + return {"ok": True, "record": await notification_preferences_for_user(user_id, user)} + + +@app.put("/api/notifications/preferences") +async def update_notification_preferences(payload: NotificationPreferencePayload, request: Request) -> dict[str, Any]: + _, user = await current_user(request) + user_id = user["id"] if user else "anonymous" + record_payload = { + "userId": user_id, + "email": user.get("email", "") if user else "", + **payload.model_dump(), + } + existing = await pb.first_record("notification_preferences", filter=f'userId={pb_quote(user_id)}') + if existing: + record = await pb.update_record("notification_preferences", existing["id"], record_payload) + else: + record = await pb.create_record("notification_preferences", record_payload) + return {"ok": True, "record": public_record(record)} + + def city_recommendation_score(city: dict[str, Any], payload: RecommendationPayload) -> tuple[float, list[str]]: score = float(city.get("overallScore", 0)) * 20 reasons: list[str] = [] @@ -663,6 +1007,19 @@ async def create_service_lead(payload: ServiceLeadPayload, request: Request) -> "status": "new", }, ) + await create_notification( + title=f"服务咨询已提交:{service.get('title', payload.serviceSlug)}", + body="我们已经记录你的服务咨询,后续可以按城市、预算、路线和会员状态继续跟进。", + category="service", + priority="normal", + target_user_id=user["id"] if user else "", + audience="user" if user else "all", + action_label="查看服务", + action_url="/services", + entity_type="service_lead", + entity_id=record["id"], + icon=service.get("icon") or "🧭", + ) return {"ok": True, "record": public_record(record), "service": public_record(service)} @@ -709,6 +1066,18 @@ async def create_meetup(payload: dict[str, Any]) -> dict[str, Any]: payload.setdefault("rsvpCount", 0) payload.setdefault("attendees", []) record = await pb.create_record("meetups", payload) + await create_notification( + title=f"新活动已提交:{payload.get('city', '同城')} · {payload.get('venue', '活动地点')}", + body=str(payload.get("description") or "新的同城活动已经进入活动列表,审核后可对外展示。"), + category="meetup", + priority="normal", + audience="all", + action_label="查看活动", + action_url="/meetups", + entity_type="meetup", + entity_id=record["id"], + icon=payload.get("emoji") or "🍹", + ) return {"ok": True, "record": record} @@ -730,6 +1099,19 @@ async def create_discussion(payload: DiscussionPayload) -> dict[str, Any]: "views": 0, }, ) + await create_notification( + title=f"新讨论:{payload.title}", + body=payload.excerpt or f"{payload.author} 在 {payload.city or '社区'} 发起了一个新话题。", + category="community", + priority="normal", + audience="all", + action_label="参与讨论", + action_url="/discuss", + entity_type="discussion", + entity_id=record["id"], + icon="🔥", + metadata={"city": payload.city, "tags": payload.tags}, + ) return {"ok": True, "record": public_record(record)} @@ -751,6 +1133,18 @@ async def create_gig(payload: GigPayload, request: Request) -> dict[str, Any]: "status": "open", }, ) + await create_notification( + title=f"赏金墙新任务:{payload.title}", + body=f"{payload.location} · {payload.category} · ¥{payload.budget}。{payload.description}", + category="gig", + priority="high" if payload.budget >= 1000 else "normal", + audience="all", + action_label="查看任务", + action_url="/gigs", + entity_type="gig", + entity_id=record["id"], + icon="💰", + ) return {"ok": True, "record": public_record(record), "user": public_record(user) if user else None} @@ -772,6 +1166,19 @@ async def apply_gig(gig_id: str, request: Request) -> dict[str, Any]: }, ) updated = await pb.update_record("gigs", gig_id, {"status": "in_progress"}) + await create_notification( + title=f"任务已被接单:{gig.get('title', '赏金任务')}", + body=f"{applicant} 已接下这个任务,任务状态已进入进行中。", + category="gig", + priority="high", + target_user_id=user["id"] if user else "", + audience="user" if user else "all", + action_label="查看赏金墙", + action_url="/gigs", + entity_type="gig_application", + entity_id=application["id"], + icon="✅", + ) return { "ok": True, "message": "已接单", @@ -792,12 +1199,39 @@ async def save_swipe(payload: SwipePayload, request: Request) -> dict[str, Any]: _, user = await current_user(request) user_id = user["id"] if user else "anonymous" record = await pb.create_record("swipes", {"userId": user_id, "profileId": payload.profileId, "action": payload.action}) + if payload.action in {"like", "right", "superlike"}: + profile = await pb.first_record("profiles", filter=f'id={pb_quote(payload.profileId)}') + await create_notification( + title=f"已喜欢 {profile.get('name', '一位成员') if profile else '一位成员'}", + body="这条社交动作已经记录,后续可以扩展成互相喜欢、私信和同城约见提醒。", + category="match", + priority="normal", + target_user_id=user_id if user_id != "anonymous" else "", + audience="user" if user_id != "anonymous" else "all", + action_label="继续匹配", + action_url="/dating", + entity_type="swipe", + entity_id=record["id"], + icon="💚", + ) return {"ok": True, "record": record} @app.post("/api/feedback") async def feedback(payload: FeedbackPayload) -> dict[str, Any]: record = await pb.create_record("feedback", {**payload.model_dump(), "status": "new"}) + await create_notification( + title=f"反馈已收到:{payload.title}", + body=payload.content[:220], + category="system", + priority="normal", + audience="all", + action_label="查看反馈页", + action_url="/feedback", + entity_type="feedback", + entity_id=record["id"], + icon="💡", + ) return {"ok": True, "record": record} diff --git a/messages/en.json b/messages/en.json index 88da2b3..3081258 100644 --- a/messages/en.json +++ b/messages/en.json @@ -48,7 +48,8 @@ "changelog": "Changelog", "remoteJobs": "Remote Jobs", "coworking": "Coworking", - "digitalNomadGuide": "Digital Nomad Guide" + "digitalNomadGuide": "Digital Nomad Guide", + "messages": "Messages" }, "nav": { "cities": "Cities", @@ -61,7 +62,8 @@ "tools": "Tools", "pricing": "Pricing", "gigs": "Gigs", - "report": "Report" + "report": "Report", + "messages": "Messages" }, "hero": { "badge": "Chinese Digital Nomad Community", diff --git a/messages/zh.json b/messages/zh.json index 7f0e1f5..556acbf 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -48,7 +48,8 @@ "changelog": "更新日志", "remoteJobs": "远程工作", "coworking": "共享办公", - "digitalNomadGuide": "数字游民指南" + "digitalNomadGuide": "数字游民指南", + "messages": "站内消息" }, "nav": { "cities": "城市", @@ -61,7 +62,8 @@ "tools": "工具箱", "pricing": "会员", "gigs": "赏金", - "report": "年报" + "report": "年报", + "messages": "站内消息" }, "hero": { "badge": "中国数字游民社区",