feat(settings): 设置与个人信息模块审计重构 — i18n + 服务注入解耦 + Error Boundary + 流式渲染

- 新增 SettingsService 接口 + Context 注入,组件层不再直接 import users/messaging actions

- 新增 resolveRoleSettingsConfig 配置驱动角色路由,删除 parent/student/teacher-settings-view 冗余文件

- 新增 SettingsSectionErrorBoundary,每个 TabsContent + profile 角色概览区块均包裹

- 新增 ProfileStudentOverview/ProfileTeacherOverview 异步 Server Component + 骨架屏,支持流式渲染

- 抽取 buildStudentOverviewData 等纯函数到 lib/student-overview-data.ts,便于单元测试

- 新增 settings.json 翻译文件(zh-CN + en),所有组件改用 useTranslations/getTranslations

- 重构 profile/page.tsx:i18n 适配 + Suspense 分区加载 + 业务逻辑抽离

- 同步更新架构图 004/005
This commit is contained in:
SpecialX
2026-06-22 16:15:36 +08:00
parent 21c7e65fee
commit 5d42495480
29 changed files with 2445 additions and 1094 deletions

View File

@@ -1,8 +1,8 @@
"use client"
import * as React from "react"
import { useActionState } from "react"
import { useFormStatus } from "react-dom"
import { useTransition } from "react"
import { useTranslations } from "next-intl"
import { Loader2, Save, Bell, Mail, MessageSquare, Megaphone, GraduationCap, BookOpen, CalendarCheck, Moon } from "lucide-react"
import { toast } from "sonner"
@@ -13,7 +13,7 @@ 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 { updateNotificationPreferencesAction } from "@/modules/messaging/actions"
import { useSettingsService } from "@/modules/settings/components/settings-service-context"
import type { NotificationPreferences } from "@/modules/notifications/types"
interface NotificationPreferencesFormProps {
@@ -25,8 +25,8 @@ interface ChannelItem {
NotificationPreferences,
"emailEnabled" | "smsEnabled" | "pushEnabled"
>
label: string
description: string
labelKey: string
descKey: string
icon: React.ComponentType<{ className?: string }>
}
@@ -39,88 +39,30 @@ interface CategoryItem {
| "messageNotifications"
| "attendanceNotifications"
>
label: string
description: string
labelKey: string
descKey: string
icon: React.ComponentType<{ className?: string }>
}
const CHANNELS: ChannelItem[] = [
{
key: "pushEnabled",
label: "Push Notifications",
description: "Receive in-app and browser push notifications.",
icon: Bell,
},
{
key: "emailEnabled",
label: "Email",
description: "Send notifications to my registered email address.",
icon: Mail,
},
{
key: "smsEnabled",
label: "SMS",
description: "Send critical notifications via SMS (charges may apply).",
icon: MessageSquare,
},
{ 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",
label: "Messages",
description: "New direct messages and replies.",
icon: MessageSquare,
},
{
key: "announcementNotifications",
label: "Announcements",
description: "School, grade, and class announcements.",
icon: Megaphone,
},
{
key: "homeworkNotifications",
label: "Homework",
description: "New assignments and submission reminders.",
icon: BookOpen,
},
{
key: "gradeNotifications",
label: "Grades",
description: "Exam and assignment grade releases.",
icon: GraduationCap,
},
{
key: "attendanceNotifications",
label: "Attendance",
description: "Attendance records and absence alerts.",
icon: CalendarCheck,
},
{ 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 },
]
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save Preferences
</>
)}
</Button>
)
}
export function NotificationPreferencesForm({ preferences }: NotificationPreferencesFormProps) {
const [state, formAction] = useActionState(updateNotificationPreferencesAction, null)
const t = useTranslations("settings.notifications")
const { notifications } = useSettingsService()
const [isPending, startTransition] = useTransition()
// Local state for immediate Switch toggle feedback
const [channels, setChannels] = React.useState({
emailEnabled: preferences.emailEnabled,
smsEnabled: preferences.smsEnabled,
@@ -139,14 +81,6 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
quietHoursEnd: preferences.quietHoursEnd ?? "",
})
React.useEffect(() => {
if (state?.success) {
toast.success(state.message ?? "Preferences updated")
} else if (state?.success === false && state.message) {
toast.error(state.message)
}
}, [state])
const toggleChannel = (key: keyof typeof channels) => {
setChannels((prev) => ({ ...prev, [key]: !prev[key] }))
}
@@ -159,187 +93,175 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
setQuietHours((prev) => ({ ...prev, quietHoursEnabled: !prev.quietHoursEnabled }))
}
function onSubmit() {
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"))
}
})
}
return (
<Card>
<CardHeader>
<CardTitle>Notification Preferences</CardTitle>
<CardDescription>
Choose how and when you want to be notified.
</CardDescription>
<CardTitle>{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<form action={formAction}>
<CardContent className="space-y-6">
{/* Delivery channels */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium">Delivery Channels</h4>
<p className="text-xs text-muted-foreground">
Select the channels through which you want to receive notifications.
</p>
</div>
{CHANNELS.map((item) => {
const Icon = item.icon
const checked = channels[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">
{item.label}
</Label>
<p className="text-xs text-muted-foreground">{item.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Hidden checkbox for form submission */}
<input
type="checkbox"
name={item.key}
checked={checked}
onChange={() => toggleChannel(item.key)}
className="sr-only"
tabIndex={-1}
/>
<Switch
id={item.key}
checked={checked}
onCheckedChange={() => toggleChannel(item.key)}
aria-label={item.label}
/>
</div>
</div>
)
})}
<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>
<Separator />
{/* Notification categories */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium">Notification Categories</h4>
<p className="text-xs text-muted-foreground">
Choose which types of events should trigger notifications.
</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">
{item.label}
</Label>
<p className="text-xs text-muted-foreground">{item.description}</p>
</div>
{CHANNELS.map((item) => {
const Icon = item.icon
const checked = channels[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="flex items-center gap-2">
<input
type="checkbox"
name={item.key}
checked={checked}
onChange={() => toggleCategory(item.key)}
className="sr-only"
tabIndex={-1}
/>
<Switch
id={item.key}
checked={checked}
onCheckedChange={() => toggleCategory(item.key)}
aria-label={item.label}
/>
<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>
<Separator />
{/* 免打扰时段 */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium">Quiet Hours</h4>
<p className="text-xs text-muted-foreground">
Suppress non-urgent notifications during a specified time period each day.
</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">
Enable Quiet Hours
</Label>
<p className="text-xs text-muted-foreground">
When enabled, only urgent notifications will be delivered during the specified hours.
</p>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
name="quietHoursEnabled"
checked={quietHours.quietHoursEnabled}
onChange={toggleQuietHours}
className="sr-only"
tabIndex={-1}
/>
<Switch
id="quietHoursEnabled"
checked={quietHours.quietHoursEnabled}
onCheckedChange={toggleQuietHours}
aria-label="Enable Quiet Hours"
id={item.key}
checked={checked}
onCheckedChange={() => toggleChannel(item.key)}
aria-label={t(item.labelKey)}
/>
</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>
<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">
Start Time
</Label>
<Input
id="quietHoursStart"
name="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">
End Time
</Label>
<Input
id="quietHoursEnd"
name="quietHoursEnd"
type="time"
value={quietHours.quietHoursEnd}
onChange={(e) => setQuietHours((prev) => ({ ...prev, quietHoursEnd: e.target.value }))}
disabled={!quietHours.quietHoursEnabled}
/>
</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>
</CardContent>
<CardFooter className="flex justify-end border-t px-6 py-4">
<SubmitButton />
</CardFooter>
</form>
</div>
</CardContent>
<CardFooter className="flex justify-end border-t px-6 py-4">
<Button type="button" onClick={onSubmit} disabled={isPending}>
{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>
)
}