'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 SiteMenu from "./SiteMenu";
|
||||
import UserAvatar from "./UserAvatar";
|
||||
import NotificationBell from "./NotificationBell";
|
||||
|
||||
export default function NavbarComponent() {
|
||||
const { t } = useTranslation("common");
|
||||
@@ -153,6 +154,7 @@ export default function NavbarComponent() {
|
||||
<UserAvatar email={userEmail} isVip={userVip} onLogout={handleLogout} />
|
||||
</div>
|
||||
) : null}
|
||||
<NotificationBell />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" })}
|
||||
@@ -204,6 +206,18 @@ export default function NavbarComponent() {
|
||||
<span>{tNav(link.labelKey)}</span>
|
||||
</Link>
|
||||
))}
|
||||
<Link
|
||||
href="/messages"
|
||||
className={`flex items-center gap-2 px-4 py-3 rounded-xl text-sm font-medium ${
|
||||
isActive("/messages")
|
||||
? "text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-800"
|
||||
: "text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
<span className="text-lg">🔔</span>
|
||||
<span>{tNav("messages")}</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" })}
|
||||
|
||||
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",
|
||||
items: [
|
||||
{ labelKey: "messages", href: "/messages", icon: "🔔", new: true },
|
||||
{ labelKey: "chat", href: "/join", icon: "💬" },
|
||||
{ labelKey: "hostMeetup", href: "/meetups/host", icon: "🎤", new: true },
|
||||
{ labelKey: "membersMap", href: "/map", icon: "🌐" },
|
||||
|
||||
Reference in New Issue
Block a user