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
This commit is contained in:
SpecialX
2026-06-23 17:37:06 +08:00
parent 242a770cc9
commit 1fcef5c3aa
22 changed files with 3091 additions and 52 deletions

View File

@@ -3,9 +3,10 @@
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 } from "lucide-react"
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"
@@ -62,6 +63,7 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
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,
@@ -81,6 +83,35 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
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] }))
}
@@ -94,6 +125,7 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
}
function onSubmit() {
if (!isDirty) return
startTransition(async () => {
try {
const result = await notifications.updatePreferences({
@@ -114,6 +146,22 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
})
}
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>
@@ -130,6 +178,7 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
{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">
@@ -143,12 +192,31 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
<p className="text-xs text-muted-foreground">{t(item.descKey)}</p>
</div>
</div>
<Switch
id={item.key}
checked={checked}
onCheckedChange={() => toggleChannel(item.key)}
aria-label={t(item.labelKey)}
/>
<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>
)
})}
@@ -248,7 +316,7 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
</div>
</CardContent>
<CardFooter className="flex justify-end border-t px-6 py-4">
<Button type="button" onClick={onSubmit} disabled={isPending}>
<Button type="button" onClick={onSubmit} disabled={isPending || !isDirty}>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />