Files
2026-06-08 03:06:12 -05:00

492 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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">
<Link
href="/chat"
className="rounded-xl border border-[#ff4d4f]/30 bg-[#fff5f5] px-4 py-2 text-sm font-medium text-[#ff4d4f] hover:bg-[#ffecec] dark:border-[#ff4d4f]/40 dark:bg-[#ff4d4f]/10 dark:hover:bg-[#ff4d4f]/20"
>
私信聊天
</Link>
<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>
);
}