This commit is contained in:
eric
2026-06-06 07:49:47 -05:00
parent 41493c35ca
commit f5c5819bdf
9 changed files with 1398 additions and 7 deletions

View File

@@ -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<string, unknown>;
}
interface PreferenceRecord {
channels: Record<string, boolean>;
categories: Record<string, boolean>;
quietHoursStart: string;
quietHoursEnd: string;
digest: string;
allowMarketing: boolean;
}
const CATEGORY_META: Record<string, { label: string; icon: string; color: string }> = {
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<NotificationItem[]>([]);
const [unread, setUnread] = useState(0);
const [categories, setCategories] = useState<Record<string, number>>({});
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<string | null>(null);
const [preferences, setPreferences] = useState<PreferenceRecord>(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<string, number>; 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 (
<div className="min-h-screen bg-[#f6f7f9] dark:bg-gray-950">
<main className="mx-auto max-w-[1420px] px-4 py-8 sm:px-6 sm:py-10">
<header className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-sm font-semibold text-[#ff4d4f]">Message Center</p>
<h1 className="mt-2 text-3xl font-bold text-gray-950 dark:text-gray-50 sm:text-4xl">
站内消息中心
</h1>
<p className="mt-2 max-w-2xl text-sm text-gray-500 dark:text-gray-400">
活动、任务、匹配、服务咨询、城市更新和系统公告统一收口,所有动作都和 FastAPI / PocketBase 数据联动。
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={markAllRead}
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
全部已读
</button>
<Link
href="/settings/notifications"
className="rounded-xl bg-gray-950 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-950"
>
偏好设置
</Link>
</div>
</header>
{notice && (
<div className="mb-5 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-200">
{notice}
</div>
)}
<section className="mb-6 grid gap-4 md:grid-cols-4">
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
<p className="text-sm text-gray-500 dark:text-gray-400">未读消息</p>
<p className="mt-2 text-3xl font-bold text-gray-950 dark:text-gray-50">{unread}</p>
</div>
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
<p className="text-sm text-gray-500 dark:text-gray-400">当前列表</p>
<p className="mt-2 text-3xl font-bold text-gray-950 dark:text-gray-50">{items.length}</p>
</div>
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
<p className="text-sm text-gray-500 dark:text-gray-400">静音消息</p>
<p className="mt-2 text-3xl font-bold text-gray-950 dark:text-gray-50">{mutedCount}</p>
</div>
<div className="rounded-2xl bg-gradient-to-br from-[#ff4d4f] to-[#ff7a45] p-5 text-white shadow-sm">
<p className="text-sm text-white/75">当前策略</p>
<p className="mt-2 text-xl font-bold">{preferences.digest === "instant" ? "实时提醒" : "每日摘要"}</p>
<p className="mt-1 text-xs text-white/75">{preferences.quietHoursStart} - {preferences.quietHoursEnd} 免打扰</p>
</div>
</section>
<div className="grid gap-5 lg:grid-cols-[320px_minmax(0,1fr)_320px]">
<aside className="space-y-4">
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
<label className="text-xs font-semibold uppercase tracking-wide text-gray-400">搜索消息</label>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="标题、正文、城市、任务..."
className="mt-2 w-full rounded-xl border border-gray-200 bg-gray-50 px-4 py-2.5 text-sm text-gray-900 outline-none focus:border-[#ff4d4f] focus:bg-white dark:border-gray-800 dark:bg-gray-950 dark:text-gray-100"
/>
</div>
<div className="rounded-2xl bg-white p-3 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
<div className="mb-2 px-2 text-xs font-semibold uppercase tracking-wide text-gray-400">分类</div>
{["all", "system", "meetup", "gig", "match", "service", "community", "city"].map((key) => {
const meta = CATEGORY_META[key] || CATEGORY_META.system;
const active = category === key;
return (
<button
key={key}
type="button"
onClick={() => setCategory(key)}
className={`mb-1 flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors ${
active
? "bg-gray-950 text-white dark:bg-gray-100 dark:text-gray-950"
: "text-gray-600 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800"
}`}
>
<span>{meta.icon}</span>
<span className="flex-1 font-medium">{meta.label}</span>
<span className={`rounded-full px-2 py-0.5 text-xs ${active ? "bg-white/20" : "bg-gray-100 dark:bg-gray-800"}`}>
{key === "all" ? Object.values(categories).reduce((sum, value) => sum + value, 0) : categories[key] || 0}
</span>
</button>
);
})}
</div>
<div className="rounded-2xl bg-white p-3 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
<div className="mb-2 px-2 text-xs font-semibold uppercase tracking-wide text-gray-400">状态</div>
<div className="grid grid-cols-2 gap-2">
{STATUS_TABS.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setStatus(tab.key)}
className={`rounded-xl px-3 py-2 text-sm font-medium transition-colors ${
status === tab.key
? "bg-[#ff4d4f] text-white"
: "bg-gray-50 text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300"
}`}
>
{tab.label}
</button>
))}
</div>
</div>
</aside>
<section className="min-h-[660px] overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
<div className="flex items-center justify-between border-b border-gray-100 px-5 py-4 dark:border-gray-800">
<div>
<h2 className="font-semibold text-gray-950 dark:text-gray-50">消息流</h2>
<p className="text-xs text-gray-400">{loading ? "正在同步 PocketBase" : "最新消息在前,置顶优先"}</p>
</div>
<span className="rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-500 dark:bg-gray-800 dark:text-gray-400">
{items.length} 条
</span>
</div>
<div className="divide-y divide-gray-100 dark:divide-gray-800">
{items.map((item) => {
const meta = CATEGORY_META[item.category] || CATEGORY_META.system;
const active = selected?.id === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => setSelectedId(item.id)}
className={`block w-full px-5 py-4 text-left transition-colors ${
active ? "bg-[#fff3f3] dark:bg-[#ff4d4f]/10" : "hover:bg-gray-50 dark:hover:bg-gray-800/60"
}`}
>
<div className="flex items-start gap-3">
<div className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl ${meta.color}`}>
<span className="text-lg">{item.icon || meta.icon}</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<p className={`line-clamp-1 text-sm font-semibold ${item.read ? "text-gray-600 dark:text-gray-300" : "text-gray-950 dark:text-gray-50"}`}>
{item.pinned && "📌 "}{item.title}
</p>
{!item.read && <span className="mt-1 h-2 w-2 shrink-0 rounded-full bg-[#ff4d4f]" />}
</div>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-gray-500 dark:text-gray-400">{item.body}</p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-gray-400">
<span>{formatTime(item.created)}</span>
<span>·</span>
<span>{meta.label}</span>
<span>·</span>
<span>{priorityLabel(item.priority)}</span>
{item.muted && (
<>
<span>·</span>
<span className="text-amber-600 dark:text-amber-300">已静音</span>
</>
)}
{item.archived && (
<>
<span>·</span>
<span>已归档</span>
</>
)}
</div>
</div>
</div>
</button>
);
})}
</div>
{items.length === 0 && (
<div className="flex min-h-[420px] flex-col items-center justify-center px-6 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-3xl bg-gray-100 text-3xl dark:bg-gray-800">📭</div>
<h3 className="mt-4 font-semibold text-gray-900 dark:text-gray-100">没有符合条件的消息</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">换一个分类、状态或搜索词试试。</p>
</div>
)}
</section>
<aside className="space-y-4">
<section className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
{selected ? (
<>
<div className="flex items-start justify-between gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#ff4d4f]/10 text-2xl">
{selected.icon || CATEGORY_META[selected.category]?.icon || "🔔"}
</div>
<span className={`rounded-full px-2 py-1 text-[11px] font-medium ${selected.priority === "high" ? "bg-[#ff4d4f] text-white" : "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-300"}`}>
{priorityLabel(selected.priority)}
</span>
</div>
<h2 className="mt-4 text-xl font-bold text-gray-950 dark:text-gray-50">{selected.title}</h2>
<p className="mt-3 text-sm leading-7 text-gray-600 dark:text-gray-300">{selected.body}</p>
<div className="mt-4 rounded-2xl bg-gray-50 p-3 text-xs text-gray-500 dark:bg-gray-950 dark:text-gray-400">
<p>类型:{CATEGORY_META[selected.category]?.label || selected.category}</p>
<p className="mt-1">时间:{formatTime(selected.created)}</p>
<p className="mt-1">状态:{selected.archived ? "已归档" : selected.read ? "已读" : "未读"}{selected.muted ? " · 已静音" : ""}</p>
{selected.entityType && <p className="mt-1">关联:{selected.entityType} / {selected.entityId}</p>}
</div>
<div className="mt-5 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => mutateNotification(selected.id, selected.read ? "unread" : "read")}
className="rounded-xl bg-gray-950 px-3 py-2 text-sm font-medium text-white hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-950"
>
{selected.read ? "标为未读" : "标为已读"}
</button>
<button
type="button"
onClick={() => mutateNotification(selected.id, "pin")}
className="rounded-xl border border-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-800 dark:text-gray-200 dark:hover:bg-gray-800"
>
{selected.pinned ? "取消置顶" : "置顶"}
</button>
<button
type="button"
onClick={() => mutateNotification(selected.id, selected.archived ? "restore" : "archive")}
className="rounded-xl border border-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-800 dark:text-gray-200 dark:hover:bg-gray-800"
>
{selected.archived ? "恢复" : "归档"}
</button>
{selected.actionUrl ? (
<Link
href={selected.actionUrl}
onClick={() => mutateNotification(selected.id, "read")}
className="rounded-xl bg-[#ff4d4f] px-3 py-2 text-center text-sm font-medium text-white hover:bg-[#ff3333]"
>
{selected.actionLabel || "打开"}
</Link>
) : (
<span className="rounded-xl bg-gray-100 px-3 py-2 text-center text-sm text-gray-400 dark:bg-gray-800">无动作</span>
)}
</div>
</>
) : (
<p className="text-sm text-gray-500">选择一条消息查看详情。</p>
)}
</section>
<section className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
<h2 className="font-semibold text-gray-950 dark:text-gray-50">通知偏好</h2>
<div className="mt-4 space-y-3">
{[
["in_app", "站内"],
["email", "邮件"],
["wechat", "微信"],
].map(([key, label]) => (
<label key={key} className="flex items-center justify-between rounded-xl bg-gray-50 px-3 py-2 dark:bg-gray-800">
<span className="text-sm text-gray-700 dark:text-gray-200">{label}</span>
<input
type="checkbox"
checked={!!preferences.channels[key]}
onChange={(event) =>
setPreferences((prev) => ({
...prev,
channels: { ...prev.channels, [key]: event.target.checked },
}))
}
className="h-4 w-4 accent-[#ff4d4f]"
/>
</label>
))}
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
{["system", "meetup", "gig", "match", "service", "community", "city"].map((key) => (
<label key={key} className="flex items-center justify-between rounded-xl bg-gray-50 px-3 py-2 dark:bg-gray-800">
<span className="text-sm text-gray-700 dark:text-gray-200">{CATEGORY_META[key]?.label || key}</span>
<input
type="checkbox"
checked={!!preferences.categories[key]}
onChange={(event) =>
setPreferences((prev) => ({
...prev,
categories: { ...prev.categories, [key]: event.target.checked },
}))
}
className="h-4 w-4 accent-[#ff4d4f]"
/>
</label>
))}
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
<label className="block">
<span className="text-xs text-gray-400">开始</span>
<input
value={preferences.quietHoursStart}
onChange={(event) => setPreferences((prev) => ({ ...prev, quietHoursStart: event.target.value }))}
className="mt-1 w-full rounded-xl border border-gray-200 px-3 py-2 text-sm dark:border-gray-800 dark:bg-gray-950 dark:text-gray-100"
/>
</label>
<label className="block">
<span className="text-xs text-gray-400">结束</span>
<input
value={preferences.quietHoursEnd}
onChange={(event) => setPreferences((prev) => ({ ...prev, quietHoursEnd: event.target.value }))}
className="mt-1 w-full rounded-xl border border-gray-200 px-3 py-2 text-sm dark:border-gray-800 dark:bg-gray-950 dark:text-gray-100"
/>
</label>
</div>
<button
type="button"
onClick={savePreferences}
className="mt-4 w-full rounded-xl bg-[#ff4d4f] px-4 py-2.5 text-sm font-medium text-white hover:bg-[#ff3333]"
>
保存偏好
</button>
</section>
</aside>
</div>
</main>
<Footer />
</div>
);
}

View File

@@ -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<string, boolean>;
categories: Record<string, boolean>;
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<PreferenceRecord>(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 (
<div className="min-h-screen bg-[#f6f7f9] dark:bg-gray-950">
<main className="mx-auto max-w-5xl px-4 py-10 sm:px-6">
<Link href="/messages" className="text-sm font-medium text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100">
← 返回消息中心
</Link>
<header className="mt-5 mb-8">
<p className="text-sm font-semibold text-[#ff4d4f]">Notification Settings</p>
<h1 className="mt-2 text-3xl font-bold text-gray-950 dark:text-gray-50">通知偏好设置</h1>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
控制接收渠道、消息类型、免打扰时间和摘要频率。设置会保存到 PocketBase。
</p>
</header>
{saved && (
<div className="mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-200">
{saved}
</div>
)}
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
<section className="space-y-6">
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
<h2 className="font-semibold text-gray-950 dark:text-gray-50">接收渠道</h2>
<div className="mt-4 space-y-3">
{CHANNELS.map(([key, title, desc]) => (
<label key={key} className="flex items-start justify-between gap-4 rounded-2xl border border-gray-100 p-4 dark:border-gray-800">
<span>
<span className="block font-medium text-gray-900 dark:text-gray-100">{title}</span>
<span className="mt-1 block text-sm text-gray-500 dark:text-gray-400">{desc}</span>
</span>
<input
type="checkbox"
checked={!!prefs.channels[key]}
onChange={(event) =>
setPrefs((prev) => ({
...prev,
channels: { ...prev.channels, [key]: event.target.checked },
}))
}
className="mt-1 h-5 w-5 accent-[#ff4d4f]"
/>
</label>
))}
</div>
</div>
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
<h2 className="font-semibold text-gray-950 dark:text-gray-50">消息类型</h2>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{CATEGORIES.map(([key, label]) => (
<label key={key} className="flex items-center justify-between rounded-xl bg-gray-50 px-4 py-3 dark:bg-gray-800">
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">{label}</span>
<input
type="checkbox"
checked={!!prefs.categories[key]}
onChange={(event) =>
setPrefs((prev) => ({
...prev,
categories: { ...prev.categories, [key]: event.target.checked },
}))
}
className="h-4 w-4 accent-[#ff4d4f]"
/>
</label>
))}
</div>
</div>
</section>
<aside className="space-y-6">
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
<h2 className="font-semibold text-gray-950 dark:text-gray-50">免打扰</h2>
<div className="mt-4 grid grid-cols-2 gap-3">
<label>
<span className="text-xs text-gray-400">开始</span>
<input
value={prefs.quietHoursStart}
onChange={(event) => setPrefs((prev) => ({ ...prev, quietHoursStart: event.target.value }))}
className="mt-1 w-full rounded-xl border border-gray-200 px-3 py-2 text-sm dark:border-gray-800 dark:bg-gray-950 dark:text-gray-100"
/>
</label>
<label>
<span className="text-xs text-gray-400">结束</span>
<input
value={prefs.quietHoursEnd}
onChange={(event) => setPrefs((prev) => ({ ...prev, quietHoursEnd: event.target.value }))}
className="mt-1 w-full rounded-xl border border-gray-200 px-3 py-2 text-sm dark:border-gray-800 dark:bg-gray-950 dark:text-gray-100"
/>
</label>
</div>
</div>
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">
<h2 className="font-semibold text-gray-950 dark:text-gray-50">摘要频率</h2>
<select
value={prefs.digest}
onChange={(event) => setPrefs((prev) => ({ ...prev, digest: event.target.value }))}
className="mt-4 w-full rounded-xl border border-gray-200 px-3 py-2 text-sm dark:border-gray-800 dark:bg-gray-950 dark:text-gray-100"
>
<option value="instant">实时</option>
<option value="daily">每日摘要</option>
<option value="weekly">每周摘要</option>
</select>
<label className="mt-4 flex items-center justify-between rounded-xl bg-gray-50 px-4 py-3 dark:bg-gray-800">
<span className="text-sm text-gray-700 dark:text-gray-200">允许产品和服务推荐</span>
<input
type="checkbox"
checked={prefs.allowMarketing}
onChange={(event) => setPrefs((prev) => ({ ...prev, allowMarketing: event.target.checked }))}
className="h-4 w-4 accent-[#ff4d4f]"
/>
</label>
</div>
<button
type="button"
onClick={save}
className="w-full rounded-2xl bg-[#ff4d4f] px-5 py-3 font-semibold text-white shadow-sm hover:bg-[#ff3333]"
>
保存设置
</button>
</aside>
</div>
</main>
<Footer />
</div>
);
}

View File

@@ -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() {
<UserAvatar email={userEmail} isVip={userVip} onLogout={handleLogout} />
</div>
) : null}
<NotificationBell />
<button
type="button"
onClick={() => router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" })}
@@ -204,6 +206,18 @@ export default function NavbarComponent() {
<span>{tNav(link.labelKey)}</span>
</Link>
))}
<Link
href="/messages"
className={`flex items-center gap-2 px-4 py-3 rounded-xl text-sm font-medium ${
isActive("/messages")
? "text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-800"
: "text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
onClick={() => setMobileOpen(false)}
>
<span className="text-lg">🔔</span>
<span>{tNav("messages")}</span>
</Link>
<button
type="button"
onClick={() => router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" })}

View File

@@ -0,0 +1,169 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Link } from "@/i18n/navigation";
import { apiFetch } from "@/app/lib/api-client";
interface NotificationPreview {
id: string;
title: string;
body: string;
category: string;
icon?: string;
read: boolean;
muted?: boolean;
created?: string;
actionUrl?: string;
}
const CATEGORY_LABELS: Record<string, string> = {
system: "系统",
meetup: "活动",
gig: "赏金",
match: "匹配",
service: "服务",
community: "社区",
city: "城市",
};
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()}日`;
}
export default function NotificationBell() {
const [open, setOpen] = useState(false);
const [unread, setUnread] = useState(0);
const [items, setItems] = useState<NotificationPreview[]>([]);
const ref = useRef<HTMLDivElement>(null);
const load = () => {
apiFetch<{ items: NotificationPreview[]; unread: number }>("/api/notifications?status=active&muted=exclude")
.then((data) => {
setItems((data.items || []).slice(0, 5));
setUnread(Number(data.unread || 0));
})
.catch(() => {
setItems([]);
setUnread(0);
});
};
useEffect(() => {
load();
const timer = window.setInterval(load, 30000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
if (!open) return;
const onClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, [open]);
const markAllRead = async () => {
await apiFetch("/api/notifications/mark-all-read", { method: "POST" });
load();
};
const markRead = (id: string) => {
apiFetch(`/api/notifications/${id}/read`, { method: "POST" })
.then(load)
.catch(() => undefined);
};
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className="relative inline-flex h-10 w-10 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
aria-label="站内消息"
title="站内消息"
>
<span className="text-lg">🔔</span>
{unread > 0 && (
<span className="absolute right-1.5 top-1.5 flex min-w-5 items-center justify-center rounded-full bg-[#ff4d4f] px-1 text-[10px] font-bold leading-5 text-white shadow-sm">
{unread > 99 ? "99+" : unread}
</span>
)}
</button>
{open && (
<div className="absolute right-0 top-12 z-50 w-[360px] overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-gray-800 dark:bg-gray-900 sm:w-[420px]">
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3 dark:border-gray-800">
<div>
<p className="font-semibold text-gray-950 dark:text-gray-50">站内消息</p>
<p className="text-xs text-gray-400">{unread} 条未读</p>
</div>
<button
type="button"
onClick={markAllRead}
className="rounded-lg px-2.5 py-1.5 text-xs font-medium text-[#ff4d4f] hover:bg-[#ff4d4f]/10"
>
全部已读
</button>
</div>
<div className="max-h-[420px] overflow-y-auto">
{items.map((item) => (
<Link
key={item.id}
href={item.actionUrl || "/messages"}
onClick={() => {
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"
>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-[#ff4d4f]/10 text-lg">
{item.icon || "🔔"}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className={`line-clamp-1 text-sm font-semibold ${item.read ? "text-gray-600 dark:text-gray-300" : "text-gray-950 dark:text-gray-50"}`}>
{item.title}
</p>
{!item.read && <span className="h-2 w-2 shrink-0 rounded-full bg-[#ff4d4f]" />}
</div>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-gray-500 dark:text-gray-400">{item.body}</p>
<p className="mt-1 text-[11px] text-gray-400">
{CATEGORY_LABELS[item.category] || item.category} · {formatTime(item.created)}
</p>
</div>
</div>
</Link>
))}
</div>
{items.length === 0 && (
<div className="px-4 py-10 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-gray-100 text-2xl dark:bg-gray-800">📭</div>
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">暂时没有新消息</p>
</div>
)}
<Link
href="/messages"
onClick={() => 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"
>
打开消息中心 →
</Link>
</div>
)}
</div>
);
}

View File

@@ -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: "🌐" },

View File

@@ -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__":

View File

@@ -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}

View File

@@ -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",

View File

@@ -48,7 +48,8 @@
"changelog": "更新日志",
"remoteJobs": "远程工作",
"coworking": "共享办公",
"digitalNomadGuide": "数字游民指南"
"digitalNomadGuide": "数字游民指南",
"messages": "站内消息"
},
"nav": {
"cities": "城市",
@@ -61,7 +62,8 @@
"tools": "工具箱",
"pricing": "会员",
"gigs": "赏金",
"report": "年报"
"report": "年报",
"messages": "站内消息"
},
"hero": {
"badge": "中国数字游民社区",