Files
NextEdu/src/modules/settings/components/notification-preferences-form.tsx
SpecialX 1fcef5c3aa feat(settings): add security center, 2FA/TOTP, avatar upload, system settings
- Add TOTP implementation and two-factor data-access for 2FA enrollment

- Add security center card with password policy and session management

- Add avatar upload action and component

- Add system settings actions and data-access (actions-system-settings, data-access-system-settings)

- Add notification preferences and service actions

- Add security-utils and student-overview-data with tests

- Update existing settings views, data-access, and types for new features
2026-06-23 17:37:06 +08:00

336 lines
13 KiB
TypeScript

"use client"
import * as React from "react"
import { useTransition } from "react"
import { useTranslations } from "next-intl"
import { Loader2, Save, Bell, Mail, MessageSquare, Megaphone, GraduationCap, BookOpen, CalendarCheck, Moon, Send } from "lucide-react"
import { toast } from "sonner"
import { sendTestNotificationAction } from "@/modules/settings/actions-notifications"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Input } from "@/shared/components/ui/input"
import { Switch } from "@/shared/components/ui/switch"
import { Label } from "@/shared/components/ui/label"
import { Separator } from "@/shared/components/ui/separator"
import { cn } from "@/shared/lib/utils"
import { useSettingsService } from "@/modules/settings/components/settings-service-context"
import type { NotificationPreferences } from "@/modules/notifications/types"
interface NotificationPreferencesFormProps {
preferences: NotificationPreferences
}
interface ChannelItem {
key: keyof Pick<
NotificationPreferences,
"emailEnabled" | "smsEnabled" | "pushEnabled"
>
labelKey: string
descKey: string
icon: React.ComponentType<{ className?: string }>
}
interface CategoryItem {
key: keyof Pick<
NotificationPreferences,
| "homeworkNotifications"
| "gradeNotifications"
| "announcementNotifications"
| "messageNotifications"
| "attendanceNotifications"
>
labelKey: string
descKey: string
icon: React.ComponentType<{ className?: string }>
}
const CHANNELS: ChannelItem[] = [
{ key: "pushEnabled", labelKey: "channels.push", descKey: "channels.pushDesc", icon: Bell },
{ key: "emailEnabled", labelKey: "channels.email", descKey: "channels.emailDesc", icon: Mail },
{ key: "smsEnabled", labelKey: "channels.sms", descKey: "channels.smsDesc", icon: MessageSquare },
]
const CATEGORIES: CategoryItem[] = [
{ key: "messageNotifications", labelKey: "categories.messages", descKey: "categories.messagesDesc", icon: MessageSquare },
{ key: "announcementNotifications", labelKey: "categories.announcements", descKey: "categories.announcementsDesc", icon: Megaphone },
{ key: "homeworkNotifications", labelKey: "categories.homework", descKey: "categories.homeworkDesc", icon: BookOpen },
{ key: "gradeNotifications", labelKey: "categories.grades", descKey: "categories.gradesDesc", icon: GraduationCap },
{ key: "attendanceNotifications", labelKey: "categories.attendance", descKey: "categories.attendanceDesc", icon: CalendarCheck },
]
export function NotificationPreferencesForm({ preferences }: NotificationPreferencesFormProps) {
const t = useTranslations("settings.notifications")
const { notifications } = useSettingsService()
const [isPending, startTransition] = useTransition()
const [testingChannel, setTestingChannel] = React.useState<string | null>(null)
const [channels, setChannels] = React.useState({
emailEnabled: preferences.emailEnabled,
smsEnabled: preferences.smsEnabled,
pushEnabled: preferences.pushEnabled,
})
const [categories, setCategories] = React.useState({
homeworkNotifications: preferences.homeworkNotifications,
gradeNotifications: preferences.gradeNotifications,
announcementNotifications: preferences.announcementNotifications,
messageNotifications: preferences.messageNotifications,
attendanceNotifications: preferences.attendanceNotifications,
})
const [quietHours, setQuietHours] = React.useState({
quietHoursEnabled: preferences.quietHoursEnabled,
quietHoursStart: preferences.quietHoursStart ?? "",
quietHoursEnd: preferences.quietHoursEnd ?? "",
})
// 记录初始状态,用于 dirty 检测
const initialSnapshot = React.useMemo(
() => JSON.stringify({
channels: {
emailEnabled: preferences.emailEnabled,
smsEnabled: preferences.smsEnabled,
pushEnabled: preferences.pushEnabled,
},
categories: {
homeworkNotifications: preferences.homeworkNotifications,
gradeNotifications: preferences.gradeNotifications,
announcementNotifications: preferences.announcementNotifications,
messageNotifications: preferences.messageNotifications,
attendanceNotifications: preferences.attendanceNotifications,
},
quietHours: {
quietHoursEnabled: preferences.quietHoursEnabled,
quietHoursStart: preferences.quietHoursStart ?? "",
quietHoursEnd: preferences.quietHoursEnd ?? "",
},
}),
[preferences],
)
const isDirty = React.useMemo(() => {
const currentSnapshot = JSON.stringify({ channels, categories, quietHours })
return currentSnapshot !== initialSnapshot
}, [channels, categories, quietHours, initialSnapshot])
const toggleChannel = (key: keyof typeof channels) => {
setChannels((prev) => ({ ...prev, [key]: !prev[key] }))
}
const toggleCategory = (key: keyof typeof categories) => {
setCategories((prev) => ({ ...prev, [key]: !prev[key] }))
}
const toggleQuietHours = () => {
setQuietHours((prev) => ({ ...prev, quietHoursEnabled: !prev.quietHoursEnabled }))
}
function onSubmit() {
if (!isDirty) return
startTransition(async () => {
try {
const result = await notifications.updatePreferences({
...channels,
...categories,
quietHoursEnabled: quietHours.quietHoursEnabled,
quietHoursStart: quietHours.quietHoursStart || null,
quietHoursEnd: quietHours.quietHoursEnd || null,
})
if (result.success) {
toast.success(t("success"))
} else {
toast.error(result.message || t("failure"))
}
} catch {
toast.error(t("failure"))
}
})
}
async function handleTestNotification(channel: "push" | "email" | "sms"): Promise<void> {
setTestingChannel(channel)
try {
const result = await sendTestNotificationAction({ channel })
if (result.success) {
toast.success(t("testSuccess"))
} else {
toast.error(result.message || t("testFailure"))
}
} catch {
toast.error(t("testFailure"))
} finally {
setTestingChannel(null)
}
}
return (
<Card>
<CardHeader>
<CardTitle>{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Delivery channels */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium">{t("channels.title")}</h4>
<p className="text-xs text-muted-foreground">{t("channels.subtitle")}</p>
</div>
{CHANNELS.map((item) => {
const Icon = item.icon
const checked = channels[item.key]
const channelName = item.key === "emailEnabled" ? "email" : item.key === "smsEnabled" ? "sms" : "push"
return (
<div key={item.key} className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="space-y-0.5">
<Label htmlFor={item.key} className="text-sm font-medium cursor-pointer">
{t(item.labelKey)}
</Label>
<p className="text-xs text-muted-foreground">{t(item.descKey)}</p>
</div>
</div>
<div className="flex items-center gap-2">
{checked ? (
<Button
type="button"
variant="ghost"
size="sm"
className="gap-1.5 text-xs"
disabled={testingChannel === channelName}
onClick={() => handleTestNotification(channelName as "push" | "email" | "sms")}
>
{testingChannel === channelName ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Send className="h-3.5 w-3.5" />
)}
{t("test")}
</Button>
) : null}
<Switch
id={item.key}
checked={checked}
onCheckedChange={() => toggleChannel(item.key)}
aria-label={t(item.labelKey)}
/>
</div>
</div>
)
})}
</div>
<Separator />
{/* Notification categories */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium">{t("categories.title")}</h4>
<p className="text-xs text-muted-foreground">{t("categories.subtitle")}</p>
</div>
{CATEGORIES.map((item) => {
const Icon = item.icon
const checked = categories[item.key]
return (
<div key={item.key} className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="space-y-0.5">
<Label htmlFor={item.key} className="text-sm font-medium cursor-pointer">
{t(item.labelKey)}
</Label>
<p className="text-xs text-muted-foreground">{t(item.descKey)}</p>
</div>
</div>
<Switch
id={item.key}
checked={checked}
onCheckedChange={() => toggleCategory(item.key)}
aria-label={t(item.labelKey)}
/>
</div>
)
})}
</div>
<Separator />
{/* Quiet hours */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium">{t("quietHours.title")}</h4>
<p className="text-xs text-muted-foreground">{t("quietHours.subtitle")}</p>
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
<Moon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="space-y-0.5">
<Label htmlFor="quietHoursEnabled" className="text-sm font-medium cursor-pointer">
{t("quietHours.enable")}
</Label>
<p className="text-xs text-muted-foreground">{t("quietHours.enableDesc")}</p>
</div>
</div>
<Switch
id="quietHoursEnabled"
checked={quietHours.quietHoursEnabled}
onCheckedChange={toggleQuietHours}
aria-label={t("quietHours.enable")}
/>
</div>
<div className={cn(
"grid gap-4 sm:grid-cols-2 transition-opacity",
!quietHours.quietHoursEnabled && "pointer-events-none opacity-50"
)}>
<div className="space-y-2">
<Label htmlFor="quietHoursStart" className="text-xs font-medium">
{t("quietHours.start")}
</Label>
<Input
id="quietHoursStart"
type="time"
value={quietHours.quietHoursStart}
onChange={(e) => setQuietHours((prev) => ({ ...prev, quietHoursStart: e.target.value }))}
disabled={!quietHours.quietHoursEnabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="quietHoursEnd" className="text-xs font-medium">
{t("quietHours.end")}
</Label>
<Input
id="quietHoursEnd"
type="time"
value={quietHours.quietHoursEnd}
onChange={(e) => setQuietHours((prev) => ({ ...prev, quietHoursEnd: e.target.value }))}
disabled={!quietHours.quietHoursEnabled}
/>
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-end border-t px-6 py-4">
<Button type="button" onClick={onSubmit} disabled={isPending || !isDirty}>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("saving")}
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{t("save")}
</>
)}
</Button>
</CardFooter>
</Card>
)
}