Files
gitlab-instance-0a899031_cn…/app/[locale]/messages/page.tsx
2026-06-06 07:49:47 -05:00

486 lines
24 KiB
TypeScript
Raw 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">
<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>
);
}