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