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:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user