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 ThemeToggle from "./ThemeToggle";
import SiteMenu from "./SiteMenu"; import SiteMenu from "./SiteMenu";
import UserAvatar from "./UserAvatar"; import UserAvatar from "./UserAvatar";
import NotificationBell from "./NotificationBell";
export default function NavbarComponent() { export default function NavbarComponent() {
const { t } = useTranslation("common"); const { t } = useTranslation("common");
@@ -153,6 +154,7 @@ export default function NavbarComponent() {
<UserAvatar email={userEmail} isVip={userVip} onLogout={handleLogout} /> <UserAvatar email={userEmail} isVip={userVip} onLogout={handleLogout} />
</div> </div>
) : null} ) : null}
<NotificationBell />
<button <button
type="button" type="button"
onClick={() => router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" })} onClick={() => router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" })}
@@ -204,6 +206,18 @@ export default function NavbarComponent() {
<span>{tNav(link.labelKey)}</span> <span>{tNav(link.labelKey)}</span>
</Link> </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 <button
type="button" type="button"
onClick={() => router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" })} 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", titleKey: "community",
items: [ items: [
{ labelKey: "messages", href: "/messages", icon: "🔔", new: true },
{ labelKey: "chat", href: "/join", icon: "💬" }, { labelKey: "chat", href: "/join", icon: "💬" },
{ labelKey: "hostMeetup", href: "/meetups/host", icon: "🎤", new: true }, { labelKey: "hostMeetup", href: "/meetups/host", icon: "🎤", new: true },
{ labelKey: "membersMap", href: "/map", icon: "🌐" }, { 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 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]: def text(name: str, *, required: bool = False, max: int = 0) -> dict[str, Any]:
return {"name": name, "type": "text", "required": required, "max": max} 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("tags"),
json_field("resultSlugs"), 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": [ "feedback": [
text("type", max=40), text("type", max=40),
text("email", max=255), text("email", max=255),
@@ -319,6 +406,7 @@ async def main() -> None:
await upsert_seed("city_details", "citySlug", CITY_DETAILS) await upsert_seed("city_details", "citySlug", CITY_DETAILS)
await upsert_seed("services", "slug", SERVICES) await upsert_seed("services", "slug", SERVICES)
await upsert_seed("routes", "slug", ROUTES) await upsert_seed("routes", "slug", ROUTES)
await upsert_seed("notifications", "title", NOTIFICATION_SEEDS)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -11,7 +11,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.responses import Response as FastAPIResponse from fastapi.responses import Response as FastAPIResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel, Field
from .pb_client import PocketBaseError, pb, pb_quote from .pb_client import PocketBaseError, pb, pb_quote
from .settings import get_settings from .settings import get_settings
@@ -88,7 +88,7 @@ class DiscussionPayload(BaseModel):
title: str title: str
author: str = "NomadCNA" author: str = "NomadCNA"
city: str = "" city: str = ""
tags: list[str] = [] tags: list[str] = Field(default_factory=list)
excerpt: str = "" excerpt: str = ""
@@ -106,11 +106,36 @@ class ServiceLeadPayload(BaseModel):
note: str = "" 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): class RecommendationPayload(BaseModel):
budget: int = 6000 budget: int = 6000
internet: int = 50 internet: int = 50
climate: str = "any" climate: str = "any"
tags: list[str] = [] tags: list[str] = Field(default_factory=list)
limit: int = 12 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("@")} 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") @app.get("/api/health")
async def health() -> dict[str, Any]: async def health() -> dict[str, Any]:
return {"ok": True, "service": "NomadCNA FastAPI", "pb": settings.pb_url} 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) await pb.update_record("profiles", profile["id"], profile_payload)
else: else:
await pb.create_record("profiles", profile_payload) 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"]} 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} 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]]: def city_recommendation_score(city: dict[str, Any], payload: RecommendationPayload) -> tuple[float, list[str]]:
score = float(city.get("overallScore", 0)) * 20 score = float(city.get("overallScore", 0)) * 20
reasons: list[str] = [] reasons: list[str] = []
@@ -663,6 +1007,19 @@ async def create_service_lead(payload: ServiceLeadPayload, request: Request) ->
"status": "new", "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)} 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("rsvpCount", 0)
payload.setdefault("attendees", []) payload.setdefault("attendees", [])
record = await pb.create_record("meetups", payload) 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} return {"ok": True, "record": record}
@@ -730,6 +1099,19 @@ async def create_discussion(payload: DiscussionPayload) -> dict[str, Any]:
"views": 0, "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)} return {"ok": True, "record": public_record(record)}
@@ -751,6 +1133,18 @@ async def create_gig(payload: GigPayload, request: Request) -> dict[str, Any]:
"status": "open", "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} 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"}) 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 { return {
"ok": True, "ok": True,
"message": "已接单", "message": "已接单",
@@ -792,12 +1199,39 @@ async def save_swipe(payload: SwipePayload, request: Request) -> dict[str, Any]:
_, user = await current_user(request) _, user = await current_user(request)
user_id = user["id"] if user else "anonymous" user_id = user["id"] if user else "anonymous"
record = await pb.create_record("swipes", {"userId": user_id, "profileId": payload.profileId, "action": payload.action}) 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} return {"ok": True, "record": record}
@app.post("/api/feedback") @app.post("/api/feedback")
async def feedback(payload: FeedbackPayload) -> dict[str, Any]: async def feedback(payload: FeedbackPayload) -> dict[str, Any]:
record = await pb.create_record("feedback", {**payload.model_dump(), "status": "new"}) 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} return {"ok": True, "record": record}

View File

@@ -48,7 +48,8 @@
"changelog": "Changelog", "changelog": "Changelog",
"remoteJobs": "Remote Jobs", "remoteJobs": "Remote Jobs",
"coworking": "Coworking", "coworking": "Coworking",
"digitalNomadGuide": "Digital Nomad Guide" "digitalNomadGuide": "Digital Nomad Guide",
"messages": "Messages"
}, },
"nav": { "nav": {
"cities": "Cities", "cities": "Cities",
@@ -61,7 +62,8 @@
"tools": "Tools", "tools": "Tools",
"pricing": "Pricing", "pricing": "Pricing",
"gigs": "Gigs", "gigs": "Gigs",
"report": "Report" "report": "Report",
"messages": "Messages"
}, },
"hero": { "hero": {
"badge": "Chinese Digital Nomad Community", "badge": "Chinese Digital Nomad Community",

View File

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