'd'
This commit is contained in:
485
app/[locale]/messages/page.tsx
Normal file
485
app/[locale]/messages/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
app/[locale]/settings/notifications/page.tsx
Normal file
196
app/[locale]/settings/notifications/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" })}
|
||||||
|
|||||||
169
app/components/NotificationBell.tsx
Normal file
169
app/components/NotificationBell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: "🌐" },
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
440
backend/main.py
440
backend/main.py
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "中国数字游民社区",
|
||||||
|
|||||||
Reference in New Issue
Block a user