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,6 +1,7 @@
"use client"
import * as React from "react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { School, Shield, Database, Bell } from "lucide-react"
@@ -12,23 +13,31 @@ import { Switch } from "@/shared/components/ui/switch"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Separator } from "@/shared/components/ui/separator"
/**
* 管理员系统设置视图
*
* TODO: 当前为 mock 实现setTimeout 模拟保存),未接入真实数据层。
* 后续需新增 system_settings 表 + data-access + actions替换 mock 逻辑。
* 当前已适配 i18n文本均通过 settings.admin.* 翻译键获取。
*/
export function AdminSettingsView() {
const t = useTranslations("settings.admin")
const [saving, setSaving] = React.useState(false)
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
// 模拟保存
await new Promise((r) => setTimeout(r, 800))
toast.success("设置已保存")
// TODO: 替换为真实 Server Action 调用
await new Promise<void>((resolve) => setTimeout(resolve, 800))
toast.success(t("saveSuccess"))
setSaving(false)
}
return (
<div className="flex h-full flex-col space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
<h2 className="text-2xl font-bold tracking-tight">{t("title")}</h2>
<p className="text-muted-foreground">{t("description")}</p>
</div>
<form onSubmit={handleSave} className="space-y-6">
@@ -38,39 +47,39 @@ export function AdminSettingsView() {
<div className="flex items-center gap-2">
<School className="h-5 w-5 text-primary" />
<div>
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
<CardTitle className="text-base">{t("schoolInfo.title")}</CardTitle>
<CardDescription>{t("schoolInfo.description")}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="school-name"></Label>
<Input id="school-name" placeholder="请输入学校名称" defaultValue="Next_Edu 实验学校" />
<Label htmlFor="school-name">{t("schoolInfo.name")}</Label>
<Input id="school-name" name="schoolName" placeholder={t("schoolInfo.namePlaceholder")} />
</div>
<div className="space-y-2">
<Label htmlFor="school-code"></Label>
<Input id="school-code" placeholder="请输入学校代码" />
<Label htmlFor="school-code">{t("schoolInfo.code")}</Label>
<Input id="school-code" name="schoolCode" placeholder={t("schoolInfo.codePlaceholder")} />
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="school-phone"></Label>
<Input id="school-phone" placeholder="请输入联系电话" />
<Label htmlFor="school-phone">{t("schoolInfo.phone")}</Label>
<Input id="school-phone" name="schoolPhone" placeholder={t("schoolInfo.phonePlaceholder")} />
</div>
<div className="space-y-2">
<Label htmlFor="school-email"></Label>
<Input id="school-email" type="email" placeholder="请输入联系邮箱" />
<Label htmlFor="school-email">{t("schoolInfo.email")}</Label>
<Input id="school-email" name="schoolEmail" type="email" placeholder={t("schoolInfo.emailPlaceholder")} />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="school-address"></Label>
<Input id="school-address" placeholder="请输入学校地址" />
<Label htmlFor="school-address">{t("schoolInfo.address")}</Label>
<Input id="school-address" name="schoolAddress" placeholder={t("schoolInfo.addressPlaceholder")} />
</div>
<div className="space-y-2">
<Label htmlFor="school-desc"></Label>
<Textarea id="school-desc" placeholder="请输入学校简介" rows={3} />
<Label htmlFor="school-desc">{t("schoolInfo.description2")}</Label>
<Textarea id="school-desc" name="schoolDescription" placeholder={t("schoolInfo.descriptionPlaceholder")} rows={3} />
</div>
</CardContent>
</Card>
@@ -81,43 +90,43 @@ export function AdminSettingsView() {
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
<div>
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
<CardTitle className="text-base">{t("securityPolicy.title")}</CardTitle>
<CardDescription>{t("securityPolicy.description")}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="password-min-length"></Label>
<Input id="password-min-length" type="number" min={6} max={32} defaultValue={8} />
<Label htmlFor="password-min-length">{t("securityPolicy.passwordMinLength")}</Label>
<Input id="password-min-length" name="passwordMinLength" type="number" min={6} max={32} defaultValue={8} />
</div>
<div className="space-y-2">
<Label htmlFor="session-timeout"></Label>
<Input id="session-timeout" type="number" min={5} max={1440} defaultValue={60} />
<Label htmlFor="session-timeout">{t("securityPolicy.sessionTimeout")}</Label>
<Input id="session-timeout" name="sessionTimeout" type="number" min={5} max={1440} defaultValue={60} />
</div>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="require-special-char"></Label>
<p className="text-sm text-muted-foreground"></p>
<Label htmlFor="require-special-char">{t("securityPolicy.requireSpecialChar")}</Label>
<p className="text-sm text-muted-foreground">{t("securityPolicy.requireSpecialCharDesc")}</p>
</div>
<Switch id="require-special-char" defaultChecked />
<Switch id="require-special-char" name="requireSpecialChar" defaultChecked />
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="require-uppercase"></Label>
<p className="text-sm text-muted-foreground"></p>
<Label htmlFor="require-uppercase">{t("securityPolicy.requireUppercase")}</Label>
<p className="text-sm text-muted-foreground">{t("securityPolicy.requireUppercaseDesc")}</p>
</div>
<Switch id="require-uppercase" />
<Switch id="require-uppercase" name="requireUppercase" />
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="force-password-change"></Label>
<p className="text-sm text-muted-foreground"></p>
<Label htmlFor="force-password-change">{t("securityPolicy.forcePasswordChange")}</Label>
<p className="text-sm text-muted-foreground">{t("securityPolicy.forcePasswordChangeDesc")}</p>
</div>
<Switch id="force-password-change" defaultChecked />
<Switch id="force-password-change" name="forcePasswordChange" defaultChecked />
</div>
</CardContent>
</Card>
@@ -128,20 +137,20 @@ export function AdminSettingsView() {
<div className="flex items-center gap-2">
<Database className="h-5 w-5 text-primary" />
<div>
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
<CardTitle className="text-base">{t("fileUpload.title")}</CardTitle>
<CardDescription>{t("fileUpload.description")}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="max-file-size">MB</Label>
<Input id="max-file-size" type="number" min={1} max={100} defaultValue={10} />
<Label htmlFor="max-file-size">{t("fileUpload.maxFileSize")}</Label>
<Input id="max-file-size" name="maxFileSize" type="number" min={1} max={100} defaultValue={10} />
</div>
<div className="space-y-2">
<Label htmlFor="allowed-types"></Label>
<Input id="allowed-types" placeholder="如jpg,png,pdf,docx" defaultValue="jpg,png,pdf,docx,xlsx,pptx" />
<Label htmlFor="allowed-types">{t("fileUpload.allowedTypes")}</Label>
<Input id="allowed-types" name="allowedTypes" placeholder={t("fileUpload.allowedTypesPlaceholder")} defaultValue="jpg,png,pdf,docx,xlsx,pptx" />
</div>
</div>
</CardContent>
@@ -153,40 +162,40 @@ export function AdminSettingsView() {
<div className="flex items-center gap-2">
<Bell className="h-5 w-5 text-primary" />
<div>
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
<CardTitle className="text-base">{t("notificationConfig.title")}</CardTitle>
<CardDescription>{t("notificationConfig.description")}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-new-user"></Label>
<p className="text-sm text-muted-foreground"></p>
<Label htmlFor="notify-new-user">{t("notificationConfig.notifyNewUser")}</Label>
<p className="text-sm text-muted-foreground">{t("notificationConfig.notifyNewUserDesc")}</p>
</div>
<Switch id="notify-new-user" defaultChecked />
<Switch id="notify-new-user" name="notifyNewUser" defaultChecked />
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-schedule-change"></Label>
<p className="text-sm text-muted-foreground"></p>
<Label htmlFor="notify-schedule-change">{t("notificationConfig.notifyScheduleChange")}</Label>
<p className="text-sm text-muted-foreground">{t("notificationConfig.notifyScheduleChangeDesc")}</p>
</div>
<Switch id="notify-schedule-change" defaultChecked />
<Switch id="notify-schedule-change" name="notifyScheduleChange" defaultChecked />
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-announcement"></Label>
<p className="text-sm text-muted-foreground"></p>
<Label htmlFor="notify-announcement">{t("notificationConfig.notifyAnnouncement")}</Label>
<p className="text-sm text-muted-foreground">{t("notificationConfig.notifyAnnouncementDesc")}</p>
</div>
<Switch id="notify-announcement" />
<Switch id="notify-announcement" name="notifyAnnouncement" />
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline"></Button>
<Button type="button" variant="outline">{t("reset")}</Button>
<Button type="submit" disabled={saving}>
{saving ? "保存中..." : "保存设置"}
{saving ? t("saving") : t("save")}
</Button>
</div>
</form>

View File

@@ -1,6 +1,7 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"
import { useTranslations } from "next-intl"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
@@ -9,17 +10,16 @@ import { Loader2, Save, Sparkles } from "lucide-react"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Checkbox } from "@/shared/components/ui/checkbox"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/components/ui/form"
import { TextField } from "@/shared/components/form-fields/text-field"
import { SelectField } from "@/shared/components/form-fields/select-field"
import {
Select,
SelectContent,
@@ -42,13 +42,6 @@ const AiProviderFormSchema = z.object({
type AiProviderFormValues = z.infer<typeof AiProviderFormSchema>
const providerLabels: Record<z.infer<typeof ProviderSchema>, string> = {
zhipu: "Zhipu",
openai: "OpenAI",
gemini: "Gemini",
custom: "Custom",
}
const NEW_PROVIDER_VALUE = "__new__"
export function AiProviderSettingsCard({
@@ -58,6 +51,7 @@ export function AiProviderSettingsCard({
onProvidersChanged?: (rows: AiProviderSummary[]) => void
initialMode?: "new" | "first"
}) {
const t = useTranslations("settings.ai.providers")
const [isPending, startTransition] = useTransition()
const [providers, setProviders] = useState<AiProviderSummary[]>([])
const [selectedId, setSelectedId] = useState<string>("")
@@ -112,7 +106,7 @@ export function AiProviderSettingsCard({
try {
const result = await getAiProviderSummaries()
if (!result.success || !result.data) {
toast.error(result.message ?? "Failed to load AI providers")
toast.error(result.message ?? t("loadFailure"))
return
}
const rows = result.data
@@ -135,10 +129,10 @@ export function AiProviderSettingsCard({
})
}
} catch {
toast.error("Failed to load AI providers")
toast.error(t("loadFailure"))
}
})
}, [form, selectedId, onProvidersChanged, initialMode, resetToNew])
}, [form, selectedId, onProvidersChanged, initialMode, resetToNew, t])
const handleSelectChange = (value: string) => {
if (value === NEW_PROVIDER_VALUE) {
@@ -175,7 +169,7 @@ export function AiProviderSettingsCard({
const values = form.getValues()
const apiKey = values.apiKey?.trim()
if (!apiKey && !values.id?.trim()) {
toast.error("Please enter API key to test")
toast.error(t("needKey"))
return
}
setTestStatus("testing")
@@ -192,10 +186,10 @@ export function AiProviderSettingsCard({
if (result.success) {
setTestStatus("passed")
setLastTestedSignature(buildSignature(values))
toast.success(result.message ?? "Test passed")
toast.success(result.message ?? t("testSuccess"))
} else {
setTestStatus("failed")
toast.error(result.message ?? "Test failed")
toast.error(result.message ?? t("testFailure"))
}
})
}
@@ -203,7 +197,7 @@ export function AiProviderSettingsCard({
const onSubmit = (values: AiProviderFormValues) => {
const signature = buildSignature(values)
if (testStatus !== "passed" || signature !== lastTestedSignature) {
toast.error("Please test the configuration before saving")
toast.error(t("needTest"))
return
}
startTransition(async () => {
@@ -217,12 +211,12 @@ export function AiProviderSettingsCard({
}
const result = await upsertAiProviderAction(payload)
if (result.success) {
toast.success(result.message ?? "Saved")
toast.success(result.message ?? t("saveSuccess"))
setTestStatus("idle")
setLastTestedSignature("")
const summariesResult = await getAiProviderSummaries()
if (!summariesResult.success || !summariesResult.data) {
toast.error(summariesResult.message ?? "Failed to load AI providers")
toast.error(summariesResult.message ?? t("loadFailure"))
return
}
const rows = summariesResult.data
@@ -242,7 +236,7 @@ export function AiProviderSettingsCard({
})
}
} else {
toast.error(result.message ?? "Failed to save")
toast.error(result.message ?? t("saveFailure"))
}
})
}
@@ -252,34 +246,34 @@ export function AiProviderSettingsCard({
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-purple-500" />
AI Providers
{t("title")}
</CardTitle>
<CardDescription>Manage AI vendors and default model configuration.</CardDescription>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<FormLabel>Existing Providers</FormLabel>
<FormLabel>{t("existing")}</FormLabel>
<Select value={selectedId || NEW_PROVIDER_VALUE} onValueChange={handleSelectChange}>
<SelectTrigger>
<SelectValue placeholder="Create new or select existing" />
<SelectValue placeholder={t("selectPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NEW_PROVIDER_VALUE}>Create new</SelectItem>
<SelectItem value={NEW_PROVIDER_VALUE}>{t("createNew")}</SelectItem>
{providers.map((item) => (
<SelectItem key={item.id} value={item.id}>
{providerLabels[item.provider]} · {item.model}
{item.provider} · {item.model}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<FormLabel>Key Status</FormLabel>
<FormLabel>{t("keyStatus")}</FormLabel>
<div className="rounded-md border px-3 py-2 text-sm text-muted-foreground">
{selectedProvider?.apiKeyLast4
? `Stored • ****${selectedProvider.apiKeyLast4}`
: "No key stored"}
? `${t("stored")} • ****${selectedProvider.apiKeyLast4}`
: t("noKey")}
</div>
</div>
</div>
@@ -287,82 +281,46 @@ export function AiProviderSettingsCard({
<Form {...form}>
<div className="grid gap-6">
<div className="grid gap-4 sm:grid-cols-2">
<FormField
<TextField
control={form.control}
name="id"
render={({ field }) => (
<FormItem>
<FormLabel>ID</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ""} disabled />
</FormControl>
<FormDescription>Auto-generated for each provider.</FormDescription>
</FormItem>
)}
label={t("id")}
disabled
description={t("idDesc")}
/>
<FormField
<SelectField
control={form.control}
name="provider"
render={({ field }) => (
<FormItem>
<FormLabel>Provider</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="zhipu">Zhipu</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="gemini">Gemini</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
label={t("provider")}
placeholder={t("providerPlaceholder")}
options={[
{ value: "zhipu", label: "Zhipu" },
{ value: "openai", label: "OpenAI" },
{ value: "gemini", label: "Gemini" },
{ value: "custom", label: "Custom" },
]}
/>
<FormField
<TextField
control={form.control}
name="baseUrl"
render={({ field }) => (
<FormItem>
<FormLabel>API URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://open.bigmodel.cn/api/paas/v4" />
</FormControl>
<FormDescription>Enter base URL without /chat/completions suffix.</FormDescription>
<FormMessage />
</FormItem>
)}
label={t("baseUrl")}
placeholder={t("baseUrlPlaceholder")}
description={t("baseUrlDesc")}
/>
<FormField
<TextField
control={form.control}
name="model"
render={({ field }) => (
<FormItem>
<FormLabel>Model</FormLabel>
<FormControl>
<Input {...field} placeholder="gpt-4o-mini" />
</FormControl>
<FormMessage />
</FormItem>
)}
label={t("model")}
placeholder={t("modelPlaceholder")}
/>
<FormField
<TextField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem className="sm:col-span-2">
<FormLabel>API Key</FormLabel>
<FormControl>
<Input type="password" {...field} placeholder="Paste new key to replace" />
</FormControl>
<FormDescription>Existing key won&apos;t be displayed. Leave blank to keep current.</FormDescription>
<FormMessage />
</FormItem>
)}
label={t("apiKey")}
type="password"
placeholder={t("apiKeyPlaceholder")}
description={t("apiKeyDesc")}
itemClassName="sm:col-span-2"
/>
</div>
@@ -374,7 +332,7 @@ export function AiProviderSettingsCard({
<FormControl>
<Checkbox checked={!!field.value} onCheckedChange={(value) => field.onChange(value === true)} />
</FormControl>
<FormLabel>Set as default</FormLabel>
<FormLabel>{t("setDefault")}</FormLabel>
</FormItem>
)}
/>
@@ -384,12 +342,12 @@ export function AiProviderSettingsCard({
{testStatus === "testing" ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Testing...
{t("testing")}
</>
) : (
<>
<Sparkles className="mr-2 h-4 w-4" />
Test
{t("test")}
</>
)}
</Button>
@@ -397,12 +355,12 @@ export function AiProviderSettingsCard({
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
{t("saving")}
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save Changes
{t("save")}
</>
)}
</Button>

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>
)
}

View File

@@ -1,65 +0,0 @@
"use client"
import Link from "next/link"
import { LayoutDashboard, GraduationCap, CalendarDays, ClipboardList } from "lucide-react"
import { SettingsView } from "@/modules/settings/components/settings-view"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { UserProfile } from "@/modules/users/data-access"
import type { NotificationPreferences } from "@/modules/notifications/types"
interface ParentSettingsViewProps {
user: UserProfile
notificationPreferences: NotificationPreferences
}
export function ParentSettingsView({ user, notificationPreferences }: ParentSettingsViewProps) {
const generalExtra = (
<Card>
<CardHeader>
<CardTitle>Quick links</CardTitle>
<CardDescription>Common places you may want to visit.</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Button asChild variant="outline">
<Link href="/profile">Profile</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/parent/dashboard">
<LayoutDashboard className="h-4 w-4" />
Dashboard
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/parent/children">
<GraduationCap className="h-4 w-4" />
Children
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/parent/grades">
<ClipboardList className="h-4 w-4" />
Grades
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/parent/attendance">
<CalendarDays className="h-4 w-4" />
Attendance
</Link>
</Button>
</CardContent>
</Card>
)
return (
<SettingsView
description="Manage your preferences and family account access."
backHref="/parent/dashboard"
user={user}
notificationPreferences={notificationPreferences}
generalExtra={generalExtra}
/>
)
}

View File

@@ -2,6 +2,7 @@
import { useActionState, useEffect, useMemo, useRef, useState } from "react"
import { useFormStatus } from "react-dom"
import { useTranslations } from "next-intl"
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react"
import { toast } from "sonner"
@@ -18,25 +19,26 @@ import {
} from "@/shared/lib/password-policy"
import type { ActionState } from "@/shared/types/action-state"
const STRENGTH_META: Record<PasswordStrength, { value: number; label: string; barClassName: string; indicatorClassName: string }> = {
weak: { value: 33, label: "Weak", barClassName: "h-2", indicatorClassName: "bg-red-500" },
medium: { value: 66, label: "Medium", barClassName: "h-2", indicatorClassName: "bg-yellow-500" },
strong: { value: 100, label: "Strong", barClassName: "h-2", indicatorClassName: "bg-green-500" },
const STRENGTH_META: Record<PasswordStrength, { value: number; labelKey: string; barClassName: string; indicatorClassName: string }> = {
weak: { value: 33, labelKey: "security.changePassword.strengthWeak", barClassName: "h-2", indicatorClassName: "bg-red-500" },
medium: { value: 66, labelKey: "security.changePassword.strengthMedium", barClassName: "h-2", indicatorClassName: "bg-yellow-500" },
strong: { value: 100, labelKey: "security.changePassword.strengthStrong", barClassName: "h-2", indicatorClassName: "bg-green-500" },
}
function SubmitButton() {
const { pending } = useFormStatus()
const t = useTranslations("settings.security.changePassword")
return (
<Button type="submit" disabled={pending}>
{pending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Updating...
{t("updating")}
</>
) : (
<>
<KeyRound className="mr-2 h-4 w-4" />
Update Password
{t("submit")}
</>
)}
</Button>
@@ -44,6 +46,7 @@ function SubmitButton() {
}
export function PasswordChangeForm() {
const t = useTranslations("settings.security.changePassword")
const [state, formAction] = useActionState<ActionState<null>, FormData>(
changePasswordAction,
{ success: false, data: null }
@@ -59,31 +62,29 @@ export function PasswordChangeForm() {
useEffect(() => {
if (state?.success) {
toast.success(state.message ?? "Password changed successfully")
toast.success(state.message ?? t("success"))
formRef.current?.reset()
} else if (state?.message) {
toast.error(state.message)
}
}, [state])
}, [state, t])
return (
<Card>
<CardHeader>
<CardTitle>Change Password</CardTitle>
<CardDescription>
Choose a strong password to keep your account secure.
</CardDescription>
<CardTitle>{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<form ref={formRef} id="password-change-form" action={formAction} onReset={() => setNewPassword("")}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="currentPassword">Current Password</Label>
<Label htmlFor="currentPassword">{t("current")}</Label>
<div className="relative">
<Input
id="currentPassword"
name="currentPassword"
type={showCurrent ? "text" : "password"}
placeholder="Enter current password"
placeholder={t("currentPlaceholder")}
required
autoComplete="current-password"
/>
@@ -93,7 +94,7 @@ export function PasswordChangeForm() {
size="icon"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => setShowCurrent((v) => !v)}
tabIndex={-1}
aria-label={showCurrent ? "Hide password" : "Show password"}
>
{showCurrent ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
@@ -101,13 +102,13 @@ export function PasswordChangeForm() {
</div>
<div className="space-y-2">
<Label htmlFor="newPassword">New Password</Label>
<Label htmlFor="newPassword">{t("new")}</Label>
<div className="relative">
<Input
id="newPassword"
name="newPassword"
type={showNew ? "text" : "password"}
placeholder="Enter new password"
placeholder={t("newPlaceholder")}
required
autoComplete="new-password"
value={newPassword}
@@ -119,7 +120,7 @@ export function PasswordChangeForm() {
size="icon"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => setShowNew((v) => !v)}
tabIndex={-1}
aria-label={showNew ? "Hide password" : "Show password"}
>
{showNew ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
@@ -127,8 +128,8 @@ export function PasswordChangeForm() {
{newPassword.length > 0 && (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Password strength</span>
<span className="font-medium">{meta.label}</span>
<span className="text-muted-foreground">{t("strength")}</span>
<span className="font-medium">{t(meta.labelKey)}</span>
</div>
<Progress value={meta.value} className={meta.barClassName} indicatorClassName={meta.indicatorClassName} />
</div>
@@ -136,13 +137,13 @@ export function PasswordChangeForm() {
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm New Password</Label>
<Label htmlFor="confirmPassword">{t("confirm")}</Label>
<div className="relative">
<Input
id="confirmPassword"
name="confirmPassword"
type={showConfirm ? "text" : "password"}
placeholder="Re-enter new password"
placeholder={t("confirmPlaceholder")}
required
autoComplete="new-password"
/>
@@ -152,7 +153,7 @@ export function PasswordChangeForm() {
size="icon"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => setShowConfirm((v) => !v)}
tabIndex={-1}
aria-label={showConfirm ? "Hide password" : "Show password"}
>
{showConfirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
@@ -160,7 +161,7 @@ export function PasswordChangeForm() {
</div>
<div className="rounded-md border bg-muted/30 p-3">
<div className="text-xs font-medium text-muted-foreground">Password requirements:</div>
<div className="text-xs font-medium text-muted-foreground">{t("requirements")}</div>
<ul className="mt-1.5 grid gap-1 text-xs text-muted-foreground">
{PASSWORD_REQUIREMENT_HINTS.map((hint) => (
<li key={hint} className="flex items-center gap-1.5">

View File

@@ -1,40 +1,47 @@
"use client"
import { useTransition } from "react"
import { useTransition, type ReactElement } from "react"
import { useTranslations } from "next-intl"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm, type Resolver } from "react-hook-form"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { Loader2, Save } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/shared/components/ui/card"
import { Input } from "@/shared/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/shared/components/ui/form"
import { UserProfile } from "@/modules/users/data-access"
import { updateUserProfile } from "@/modules/users/actions"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Form } from "@/shared/components/ui/form"
import { TextField } from "@/shared/components/form-fields/text-field"
import { SelectField } from "@/shared/components/form-fields/select-field"
import type { UserProfile } from "@/modules/users/data-access"
import { useSettingsService } from "@/modules/settings/components/settings-service-context"
const profileFormSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters."),
email: z.string().email().optional(), // Read only
role: z.string().optional(), // Read only
email: z.string().email().optional(),
role: z.string().optional(),
phone: z.string().optional(),
address: z.string().optional(),
gender: z.string().optional(),
age: z.preprocess(
(v) => (v === "" || v === null || v === undefined ? undefined : Number(v)),
z.number().min(0).optional()
),
age: z.string().optional(),
})
type ProfileFormValues = z.infer<typeof profileFormSchema>
export function ProfileSettingsForm({ user }: { user: UserProfile }) {
const GENDER_OPTIONS = [
{ value: "male", label: "Male" },
{ value: "female", label: "Female" },
{ value: "other", label: "Other" },
{ value: "prefer_not_to_say", label: "Prefer not to say" },
]
export function ProfileSettingsForm({ user }: { user: UserProfile }): ReactElement {
const t = useTranslations("settings.profile")
const { profile } = useSettingsService()
const [isPending, startTransition] = useTransition()
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileFormSchema) as Resolver<ProfileFormValues>,
resolver: zodResolver(profileFormSchema),
defaultValues: {
name: user.name ?? "",
email: user.email ?? "",
@@ -42,27 +49,28 @@ export function ProfileSettingsForm({ user }: { user: UserProfile }) {
phone: user.phone ?? "",
address: user.address ?? "",
gender: user.gender ?? "",
age: user.age ?? undefined,
age: user.age !== undefined && user.age !== null ? String(user.age) : "",
},
})
function onSubmit(data: ProfileFormValues) {
function onSubmit(data: ProfileFormValues): void {
startTransition(async () => {
try {
const result = await updateUserProfile({
const ageNum = data.age ? Number(data.age) : undefined
const result = await profile.updateProfile({
name: data.name,
phone: data.phone || undefined,
address: data.address || undefined,
gender: data.gender || undefined,
age: data.age || undefined,
age: ageNum !== undefined && !Number.isNaN(ageNum) ? ageNum : undefined,
})
if (result.success) {
toast.success("Profile updated successfully")
toast.success(t("success"))
} else {
toast.error(result.message || "Failed to update profile")
toast.error(result.message || t("failure"))
}
} catch {
toast.error("Failed to update profile")
toast.error(t("failure"))
}
})
}
@@ -70,114 +78,59 @@ export function ProfileSettingsForm({ user }: { user: UserProfile }) {
return (
<Card>
<CardHeader>
<CardTitle>Profile Information</CardTitle>
<CardDescription>Update your personal information.</CardDescription>
<CardTitle>{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
<TextField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input placeholder="Your name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
label={t("fields.name")}
placeholder={t("fields.namePlaceholder")}
/>
<FormField
<TextField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} disabled />
</FormControl>
<FormDescription>Email cannot be changed.</FormDescription>
<FormMessage />
</FormItem>
)}
label={t("fields.email")}
disabled
description={t("fields.emailDisabled")}
/>
<FormField
<TextField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Phone</FormLabel>
<FormControl>
<Input placeholder="+1 234 567 890" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
label={t("fields.phone")}
placeholder={t("fields.phonePlaceholder")}
/>
<FormField
<SelectField
control={form.control}
name="gender"
render={({ field }) => (
<FormItem>
<FormLabel>Gender</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select gender" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="male">Male</SelectItem>
<SelectItem value="female">Female</SelectItem>
<SelectItem value="other">Other</SelectItem>
<SelectItem value="prefer_not_to_say">Prefer not to say</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
label={t("fields.gender")}
placeholder={t("fields.genderPlaceholder")}
options={GENDER_OPTIONS}
/>
<FormField
<TextField
control={form.control}
name="age"
render={({ field }) => (
<FormItem>
<FormLabel>Age</FormLabel>
<FormControl>
<Input type="number" placeholder="Age" {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
label={t("fields.age")}
type="number"
placeholder={t("fields.age")}
/>
<FormField
<TextField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<FormControl>
<Input {...field} disabled className="capitalize" />
</FormControl>
<FormMessage />
</FormItem>
)}
label={t("fields.role")}
disabled
inputClassName="capitalize"
/>
<FormField
<TextField
control={form.control}
name="address"
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Address</FormLabel>
<FormControl>
<Input placeholder="123 Main St, City, Country" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
label={t("fields.address")}
placeholder={t("fields.addressPlaceholder")}
itemClassName="col-span-1 sm:col-span-2"
/>
</div>
</CardContent>
@@ -186,12 +139,12 @@ export function ProfileSettingsForm({ user }: { user: UserProfile }) {
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
{t("saving")}
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save Changes
{t("save")}
</>
)}
</Button>

View File

@@ -0,0 +1,93 @@
import type { ReactElement } from "react"
import { getTranslations } from "next-intl/server"
import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card"
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card"
import { StudentUpcomingAssignmentsCard } from "@/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
import { buildStudentOverviewData } from "@/modules/settings/lib/student-overview-data"
import { Separator } from "@/shared/components/ui/separator"
interface ProfileStudentOverviewProps {
userId: string
}
/**
* 学生概览区块Server Component
*
* 独立获取学生数据并渲染,可被 Suspense + ErrorBoundary 包裹实现流式渲染与局部容错。
*/
export async function ProfileStudentOverview({
userId,
}: ProfileStudentOverviewProps): Promise<ReactElement> {
const t = await getTranslations("settings.profilePage.studentOverview")
const [classes, schedule, assignmentsAll, grades] = await Promise.all([
getStudentClasses(userId),
getStudentSchedule(userId),
getStudentHomeworkAssignments(userId),
getStudentDashboardGrades(userId),
])
const data = buildStudentOverviewData({
classes,
schedule,
assignments: assignmentsAll,
grades,
})
return (
<div className="space-y-6">
<Separator />
<div className="space-y-1">
<h2 className="text-xl font-semibold tracking-tight">{t("title")}</h2>
<div className="text-sm text-muted-foreground">{t("description")}</div>
</div>
<StudentStatsGrid
enrolledClassCount={data.enrolledClassCount}
dueSoonCount={data.dueSoonCount}
overdueCount={data.overdueCount}
gradedCount={data.gradedCount}
ranking={data.grades.ranking}
/>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<StudentUpcomingAssignmentsCard upcomingAssignments={data.upcomingAssignments} />
<StudentGradesCard grades={data.grades} />
</div>
<div className="space-y-6">
<StudentTodayScheduleCard items={data.todayScheduleItems} />
</div>
</div>
</div>
)
}
/**
* 学生概览骨架屏
*/
export function ProfileStudentOverviewSkeleton(): ReactElement {
return (
<div className="space-y-6">
<Separator />
<div className="space-y-2">
<div className="h-6 w-40 animate-pulse rounded bg-muted" />
<div className="h-4 w-64 animate-pulse rounded bg-muted" />
</div>
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-24 animate-pulse rounded-lg bg-muted" />
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 h-64 animate-pulse rounded-lg bg-muted" />
<div className="h-64 animate-pulse rounded-lg bg-muted" />
</div>
</div>
)
}

View File

@@ -0,0 +1,115 @@
import type { ReactElement } from "react"
import Link from "next/link"
import { getTranslations } from "next-intl/server"
import { Calendar, GraduationCap } from "lucide-react"
import { getTeacherClasses, getTeacherTeachingSubjects } from "@/modules/classes/data-access"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Separator } from "@/shared/components/ui/separator"
interface ProfileTeacherOverviewProps {
/** 传入空字符串表示使用当前会话教师 */
teacherId?: string
}
/**
* 教师概览区块Server Component
*
* 独立获取教师数据并渲染,可被 Suspense + ErrorBoundary 包裹实现流式渲染与局部容错。
*/
export async function ProfileTeacherOverview(
_props: ProfileTeacherOverviewProps = {}
): Promise<ReactElement> {
const t = await getTranslations("settings.profilePage.teacherOverview")
const [subjects, classes] = await Promise.all([
getTeacherTeachingSubjects(),
getTeacherClasses(),
])
return (
<div className="space-y-6">
<Separator />
<div className="space-y-1">
<h2 className="text-xl font-semibold tracking-tight">{t("title")}</h2>
<div className="text-sm text-muted-foreground">{t("description")}</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<GraduationCap className="h-5 w-5" />
{t("teachingSubjects")}
</CardTitle>
<CardDescription>{t("teachingSubjectsDesc")}</CardDescription>
</CardHeader>
<CardContent>
{subjects.length === 0 ? (
<div className="text-sm text-muted-foreground">{t("noSubjects")}</div>
) : (
<div className="flex flex-wrap gap-2">
{subjects.map((subject) => (
<Badge key={subject} variant="secondary">
{subject}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
{t("teachingClasses")}
</CardTitle>
<CardDescription>{t("teachingClassesDesc")}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{classes.length === 0 ? (
<div className="text-sm text-muted-foreground">{t("noClasses")}</div>
) : (
classes.map((cls) => (
<div key={cls.id} className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{cls.name}</div>
<div className="text-xs text-muted-foreground">
{cls.grade}
{cls.homeroom ? `${cls.homeroom}` : ""}
</div>
</div>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/classes/my/${encodeURIComponent(cls.id)}`}>{t("view")}</Link>
</Button>
</div>
))
)}
</CardContent>
</Card>
</div>
</div>
)
}
/**
* 教师概览骨架屏
*/
export function ProfileTeacherOverviewSkeleton(): ReactElement {
return (
<div className="space-y-6">
<Separator />
<div className="space-y-2">
<div className="h-6 w-40 animate-pulse rounded bg-muted" />
<div className="h-4 w-64 animate-pulse rounded bg-muted" />
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="h-48 animate-pulse rounded-lg bg-muted" />
<div className="h-48 animate-pulse rounded-lg bg-muted" />
</div>
</div>
)
}

View File

@@ -0,0 +1,46 @@
"use client"
import Link from "next/link"
import { useTranslations } from "next-intl"
import type { ReactNode } from "react"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
export interface QuickLinkItem {
href: string
/** settings.quickLinks 命名空间下的 i18n 键 */
labelKey: string
icon?: ReactNode
}
/**
* 快捷链接卡片
*
* 在设置页 General 标签页底部展示角色专属的常用入口。
* 文本通过 settings.quickLinks 命名空间国际化。
*/
export function QuickLinksCard({ links }: { links: QuickLinkItem[] }): ReactNode {
const t = useTranslations("settings.quickLinks")
return (
<Card>
<CardHeader>
<CardTitle>{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Button asChild variant="outline">
<Link href="/profile">{t("profile")}</Link>
</Button>
{links.map((link) => (
<Button key={link.href} asChild variant="outline" className="gap-2">
<Link href={link.href}>
{link.icon}
{t(link.labelKey)}
</Link>
</Button>
))}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,64 @@
"use client"
import { Component, type ReactNode } from "react"
import { AlertCircle } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { useTranslations } from "next-intl"
interface SettingsSectionErrorBoundaryProps {
children: ReactNode
}
interface SettingsSectionErrorBoundaryState {
hasError: boolean
}
/**
* 设置页分区 Error Boundary
*
* 包裹每个 TabsContent 内部组件,避免单个区块崩溃导致整页不可用。
*/
export class SettingsSectionErrorBoundary extends Component<
SettingsSectionErrorBoundaryProps,
SettingsSectionErrorBoundaryState
> {
state: SettingsSectionErrorBoundaryState = { hasError: false }
static getDerivedStateFromError(): SettingsSectionErrorBoundaryState {
return { hasError: true }
}
handleRetry = (): void => {
this.setState({ hasError: false })
}
render(): ReactNode {
if (this.state.hasError) {
return (
<SettingsSectionErrorFallback onRetry={this.handleRetry} />
)
}
return this.props.children
}
}
function SettingsSectionErrorFallback({
onRetry,
}: {
onRetry: () => void
}): ReactNode {
const t = useTranslations("settings.errors")
return (
<EmptyState
icon={AlertCircle}
title={t("sectionLoadFailed")}
description={t("sectionLoadFailedDesc")}
action={{
label: t("retry"),
onClick: onRetry,
}}
className="border-none shadow-none h-auto"
/>
)
}

View File

@@ -0,0 +1,39 @@
"use client"
import { createContext, useContext, type ReactNode } from "react"
import type { SettingsService } from "@/modules/settings/types"
/**
* SettingsService React Context
*
* 通过页面层注入 SettingsService 实现,组件层使用 useSettingsService() 消费,
* 避免直接 import 其他业务模块的 actions/data-access。
*/
const SettingsServiceContext = createContext<SettingsService | null>(null)
interface SettingsServiceProviderProps {
service: SettingsService
children: ReactNode
}
export function SettingsServiceProvider({
service,
children,
}: SettingsServiceProviderProps): ReactNode {
return (
<SettingsServiceContext.Provider value={service}>
{children}
</SettingsServiceContext.Provider>
)
}
export function useSettingsService(): SettingsService {
const ctx = useContext(SettingsServiceContext)
if (!ctx) {
throw new Error(
"useSettingsService must be used within a SettingsServiceProvider"
)
}
return ctx
}

View File

@@ -3,6 +3,7 @@
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, type ReactNode } from "react"
import { useTranslations } from "next-intl"
import { User, Palette, Lock, Bell, Sparkles } from "lucide-react"
import { signOut } from "next-auth/react"
@@ -11,8 +12,10 @@ import { ProfileSettingsForm } from "@/modules/settings/components/profile-setti
import { PasswordChangeForm } from "@/modules/settings/components/password-change-form"
import { NotificationPreferencesForm } from "@/modules/settings/components/notification-preferences-form"
import { AiProviderSettingsCard } from "@/modules/settings/components/ai-provider-settings-card"
import { SettingsSectionErrorBoundary } from "@/modules/settings/components/settings-section-error-boundary"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import {
AlertDialog,
@@ -25,13 +28,13 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/components/ui/alert-dialog"
import { UserProfile } from "@/modules/users/data-access"
import type { UserProfile } from "@/modules/users/data-access"
import type { NotificationPreferences } from "@/modules/notifications/types"
import { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions"
interface SettingsViewProps {
/** 页面副标题描述 */
/** 页面副标题描述i18n 键) */
description: string
/** 返回仪表盘的链接 */
backHref: string
@@ -39,7 +42,7 @@ interface SettingsViewProps {
user: UserProfile
/** 通知偏好 */
notificationPreferences: NotificationPreferences
/** General 标签页中 ProfileSettingsForm 下方的内容(角色专属快捷链接/组织信息等) */
/** General 标签页中 ProfileSettingsForm 下方的内容(角色专属快捷链接等) */
generalExtra?: ReactNode
}
@@ -50,6 +53,21 @@ function isTabValue(value: string | null): value is TabValue {
return value !== null && (VALID_TABS as readonly string[]).includes(value)
}
function SettingsSectionSkeleton(): ReactNode {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
)
}
/**
* 统一设置页视图
*
@@ -61,6 +79,7 @@ function isTabValue(value: string | null): value is TabValue {
*
* 角色差异通过 `description`、`backHref` 和 `generalExtra` 三个 props 注入。
* 当前激活的标签页通过 URL `?tab=` 参数持久化。
* 每个标签页内容用 Error Boundary + Suspense 包裹,局部失败不影响整页。
*/
function SettingsViewInner({
description,
@@ -69,6 +88,7 @@ function SettingsViewInner({
notificationPreferences,
generalExtra,
}: SettingsViewProps) {
const t = useTranslations("settings")
const router = useRouter()
const searchParams = useSearchParams()
const { hasPermission } = usePermission()
@@ -93,12 +113,12 @@ function SettingsViewInner({
<div className="flex h-full flex-col gap-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
<div className="text-sm text-muted-foreground">{description}</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href={backHref}>Back to dashboard</Link>
<Link href={backHref}>{t("backToDashboard")}</Link>
</Button>
</div>
</div>
@@ -107,79 +127,99 @@ function SettingsViewInner({
<TabsList className="w-full justify-start">
<TabsTrigger value="general" className="gap-2">
<User className="h-4 w-4" />
General
{t("tabs.general")}
</TabsTrigger>
<TabsTrigger value="notifications" className="gap-2">
<Bell className="h-4 w-4" />
Notifications
{t("tabs.notifications")}
</TabsTrigger>
<TabsTrigger value="appearance" className="gap-2">
<Palette className="h-4 w-4" />
Appearance
{t("tabs.appearance")}
</TabsTrigger>
<TabsTrigger value="security" className="gap-2">
<Lock className="h-4 w-4" />
Security
{t("tabs.security")}
</TabsTrigger>
{canConfigureAi ? (
<TabsTrigger value="ai" className="gap-2">
<Sparkles className="h-4 w-4" />
AI
{t("tabs.ai")}
</TabsTrigger>
) : null}
</TabsList>
<TabsContent value="general" className="mt-6 space-y-6">
<ProfileSettingsForm user={user} />
{generalExtra}
<SettingsSectionErrorBoundary>
<Suspense fallback={<SettingsSectionSkeleton />}>
<ProfileSettingsForm user={user} />
{generalExtra}
</Suspense>
</SettingsSectionErrorBoundary>
</TabsContent>
<TabsContent value="notifications" className="mt-6 space-y-6">
<NotificationPreferencesForm preferences={notificationPreferences} />
<SettingsSectionErrorBoundary>
<Suspense fallback={<SettingsSectionSkeleton />}>
<NotificationPreferencesForm preferences={notificationPreferences} />
</Suspense>
</SettingsSectionErrorBoundary>
</TabsContent>
<TabsContent value="appearance" className="mt-6 space-y-6">
<ThemePreferencesCard />
<SettingsSectionErrorBoundary>
<Suspense fallback={<SettingsSectionSkeleton />}>
<ThemePreferencesCard />
</Suspense>
</SettingsSectionErrorBoundary>
</TabsContent>
<TabsContent value="security" className="mt-6 space-y-6">
<PasswordChangeForm />
<Card>
<CardHeader>
<CardTitle>Session</CardTitle>
<CardDescription>Account access and session controls.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">Sign out</div>
<div className="text-sm text-muted-foreground">Return to the login screen.</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline">Log out</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm sign out</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to sign out? You will be returned to the login screen.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => signOut({ callbackUrl: "/login" })}>
Sign out
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
<SettingsSectionErrorBoundary>
<Suspense fallback={<SettingsSectionSkeleton />}>
<PasswordChangeForm />
<Card>
<CardHeader>
<CardTitle>{t("security.session.title")}</CardTitle>
<CardDescription>{t("security.session.description")}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">{t("security.session.signOut")}</div>
<div className="text-sm text-muted-foreground">{t("security.session.signOutDesc")}</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline">{t("security.session.signOut")}</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("security.session.confirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("security.session.confirmDesc")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("security.session.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => signOut({ callbackUrl: "/login" })}>
{t("security.session.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
</Suspense>
</SettingsSectionErrorBoundary>
</TabsContent>
{canConfigureAi ? (
<TabsContent value="ai" className="mt-6 space-y-6">
<AiProviderSettingsCard />
<SettingsSectionErrorBoundary>
<Suspense fallback={<SettingsSectionSkeleton />}>
<AiProviderSettingsCard />
</Suspense>
</SettingsSectionErrorBoundary>
</TabsContent>
) : null}
</Tabs>

View File

@@ -1,59 +0,0 @@
"use client"
import Link from "next/link"
import { LayoutDashboard, PenTool, CalendarDays } from "lucide-react"
import { SettingsView } from "@/modules/settings/components/settings-view"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { UserProfile } from "@/modules/users/data-access"
import type { NotificationPreferences } from "@/modules/notifications/types"
interface StudentSettingsViewProps {
user: UserProfile
notificationPreferences: NotificationPreferences
}
export function StudentSettingsView({ user, notificationPreferences }: StudentSettingsViewProps) {
const generalExtra = (
<Card>
<CardHeader>
<CardTitle>Quick links</CardTitle>
<CardDescription>Common places you may want to visit.</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Button asChild variant="outline">
<Link href="/profile">Profile</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/student/dashboard">
<LayoutDashboard className="h-4 w-4" />
Dashboard
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/student/learning/assignments">
<PenTool className="h-4 w-4" />
Assignments
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/student/schedule">
<CalendarDays className="h-4 w-4" />
Schedule
</Link>
</Button>
</CardContent>
</Card>
)
return (
<SettingsView
description="Manage your preferences and account access."
backHref="/student/dashboard"
user={user}
notificationPreferences={notificationPreferences}
generalExtra={generalExtra}
/>
)
}

View File

@@ -1,71 +0,0 @@
"use client"
import Link from "next/link"
import { LayoutDashboard, PenTool, CalendarDays, Library, FileQuestion } from "lucide-react"
import { SettingsView } from "@/modules/settings/components/settings-view"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { UserProfile } from "@/modules/users/data-access"
import type { NotificationPreferences } from "@/modules/notifications/types"
interface TeacherSettingsViewProps {
user: UserProfile
notificationPreferences: NotificationPreferences
}
export function TeacherSettingsView({ user, notificationPreferences }: TeacherSettingsViewProps) {
const generalExtra = (
<Card>
<CardHeader>
<CardTitle>Quick links</CardTitle>
<CardDescription>Jump to common teacher areas.</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Button asChild variant="outline">
<Link href="/profile">Profile</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/teacher/dashboard">
<LayoutDashboard className="h-4 w-4" />
Dashboard
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/teacher/textbooks">
<Library className="h-4 w-4" />
Textbooks
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/teacher/exams/all">
<FileQuestion className="h-4 w-4" />
Exams
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/teacher/homework/assignments">
<PenTool className="h-4 w-4" />
Homework
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/teacher/classes/schedule">
<CalendarDays className="h-4 w-4" />
Schedule
</Link>
</Button>
</CardContent>
</Card>
)
return (
<SettingsView
description="Manage your preferences and teaching workspace."
backHref="/teacher/dashboard"
user={user}
notificationPreferences={notificationPreferences}
generalExtra={generalExtra}
/>
)
}

View File

@@ -1,6 +1,7 @@
"use client"
import { Monitor, Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { useTranslations } from "next-intl"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Label } from "@/shared/components/ui/label"
@@ -15,6 +16,7 @@ import {
type ThemeChoice = "system" | "light" | "dark"
export function ThemePreferencesCard() {
const t = useTranslations("settings.appearance.theme")
const { theme, setTheme } = useTheme()
const value: ThemeChoice = theme === "light" || theme === "dark" || theme === "system" ? theme : "system"
@@ -22,33 +24,33 @@ export function ThemePreferencesCard() {
return (
<Card>
<CardHeader>
<CardTitle>Theme</CardTitle>
<CardDescription>Choose how the admin console looks on this device.</CardDescription>
<CardTitle>{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 sm:max-w-md">
<div className="space-y-2">
<Label htmlFor="theme">Color theme</Label>
<Label htmlFor="theme">{t("label")}</Label>
<Select value={value} onValueChange={(v) => setTheme(v)}>
<SelectTrigger id="theme" suppressHydrationWarning>
<SelectValue placeholder="Select theme" />
<SelectValue placeholder={t("title")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">
<div className="flex items-center gap-2">
<Monitor className="h-4 w-4 text-muted-foreground" />
System
{t("system")}
</div>
</SelectItem>
<SelectItem value="light">
<div className="flex items-center gap-2">
<Sun className="h-4 w-4 text-muted-foreground" />
Light
{t("light")}
</div>
</SelectItem>
<SelectItem value="dark">
<div className="flex items-center gap-2">
<Moon className="h-4 w-4 text-muted-foreground" />
Dark
{t("dark")}
</div>
</SelectItem>
</SelectContent>

View File

@@ -0,0 +1,84 @@
import type { ReactNode } from "react"
import {
LayoutDashboard,
GraduationCap,
CalendarDays,
ClipboardList,
PenTool,
Library,
FileQuestion,
} from "lucide-react"
import { QuickLinksCard, type QuickLinkItem } from "@/modules/settings/components/quick-links-card"
import type { Role } from "@/shared/types/permissions"
/**
* 角色设置页配置
*
* 通过配置驱动角色 → 设置视图的映射,新增角色只需在此添加条目。
* description/backHref 使用 i18n 键,由消费方翻译。
* 快捷链接的 label 通过 settings.quickLinks 命名空间国际化。
*/
export interface RoleSettingsConfig {
/** i18n 键settings.<role>.description */
descriptionKey: string
backHref: string
/** 角色专属快捷链接区块General 标签页底部) */
generalExtra?: ReactNode
}
const STUDENT_LINKS: QuickLinkItem[] = [
{ href: "/student/dashboard", labelKey: "dashboard", icon: <LayoutDashboard className="h-4 w-4" /> },
{ href: "/student/learning/assignments", labelKey: "assignments", icon: <PenTool className="h-4 w-4" /> },
{ href: "/student/schedule", labelKey: "schedule", icon: <CalendarDays className="h-4 w-4" /> },
]
const PARENT_LINKS: QuickLinkItem[] = [
{ href: "/parent/dashboard", labelKey: "dashboard", icon: <LayoutDashboard className="h-4 w-4" /> },
{ href: "/parent/children", labelKey: "children", icon: <GraduationCap className="h-4 w-4" /> },
{ href: "/parent/grades", labelKey: "grades", icon: <ClipboardList className="h-4 w-4" /> },
{ href: "/parent/attendance", labelKey: "attendance", icon: <CalendarDays className="h-4 w-4" /> },
]
const TEACHER_LINKS: QuickLinkItem[] = [
{ href: "/teacher/dashboard", labelKey: "dashboard", icon: <LayoutDashboard className="h-4 w-4" /> },
{ href: "/teacher/textbooks", labelKey: "textbooks", icon: <Library className="h-4 w-4" /> },
{ href: "/teacher/exams/all", labelKey: "exams", icon: <FileQuestion className="h-4 w-4" /> },
{ href: "/teacher/homework/assignments", labelKey: "homework", icon: <PenTool className="h-4 w-4" /> },
{ href: "/teacher/classes/schedule", labelKey: "schedule", icon: <CalendarDays className="h-4 w-4" /> },
]
export const ROLE_SETTINGS_CONFIG: Partial<Record<Role, RoleSettingsConfig>> = {
admin: {
descriptionKey: "settings.roleDescriptions.admin",
backHref: "/admin/dashboard",
},
teacher: {
descriptionKey: "settings.roleDescriptions.teacher",
backHref: "/teacher/dashboard",
generalExtra: <QuickLinksCard links={TEACHER_LINKS} />,
},
student: {
descriptionKey: "settings.roleDescriptions.student",
backHref: "/student/dashboard",
generalExtra: <QuickLinksCard links={STUDENT_LINKS} />,
},
parent: {
descriptionKey: "settings.roleDescriptions.parent",
backHref: "/parent/dashboard",
generalExtra: <QuickLinksCard links={PARENT_LINKS} />,
},
}
/**
* 根据角色列表解析首选设置配置。
* 优先级admin > teacher > student > parent
*/
export function resolveRoleSettingsConfig(roles: Role[]): RoleSettingsConfig | null {
for (const role of roles) {
const config = ROLE_SETTINGS_CONFIG[role]
if (config) return config
}
return null
}

View File

@@ -0,0 +1,150 @@
import "server-only"
import type { StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
/**
* 学生概览纯数据计算
*
* 将数据获取与计算逻辑从页面组件中抽出,便于单元测试。
* 输入为已获取的原始数据,输出为 UI 直接消费的视图模型。
*/
export interface StudentScheduleItem {
id: string
classId: string
className: string
course: string
startTime: string
endTime: string
location: string | null
}
export interface StudentOverviewData<TGrades = unknown> {
enrolledClassCount: number
dueSoonCount: number
overdueCount: number
gradedCount: number
todayScheduleItems: StudentScheduleItem[]
upcomingAssignments: StudentHomeworkAssignmentListItem[]
grades: TGrades
}
const WEEKDAY_MAP = [7, 1, 2, 3, 4, 5, 6] as const
type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7
export function toWeekday(d: Date): Weekday {
const day = d.getDay()
const result = WEEKDAY_MAP[day]
if (result < 1 || result > 7) throw new Error("Invalid weekday")
return result
}
interface RawScheduleItem {
id: string
classId: string
className: string
course: string
startTime: string
endTime: string
location?: string | null
weekday: number
}
interface RawAssignment {
id: string
dueAt: string | null
progressStatus: string
}
/**
* 从原始作业列表计算学生概览统计
*/
export function computeStudentStats(
assignments: ReadonlyArray<RawAssignment>,
now: Date = new Date()
): { dueSoonCount: number; overdueCount: number; gradedCount: number } {
const in7Days = new Date(now)
in7Days.setDate(in7Days.getDate() + 7)
const dueSoonCount = assignments.filter((a) => {
if (!a.dueAt) return false
const due = new Date(a.dueAt)
return due >= now && due <= in7Days && a.progressStatus !== "graded"
}).length
const overdueCount = assignments.filter((a) => {
if (!a.dueAt) return false
const due = new Date(a.dueAt)
return due < now && a.progressStatus !== "graded"
}).length
const gradedCount = assignments.filter((a) => a.progressStatus === "graded").length
return { dueSoonCount, overdueCount, gradedCount }
}
/**
* 排序并截取即将到期的作业列表
*/
export function sortUpcomingAssignments<T extends RawAssignment>(
assignments: ReadonlyArray<T>,
limit: number = 8
): T[] {
return [...assignments]
.sort((a, b) => {
const aTime = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY
const bTime = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY
if (aTime !== bTime) return aTime - bTime
return a.id.localeCompare(b.id)
})
.slice(0, limit)
}
/**
* 过滤今日课表
*/
export function filterTodaySchedule<T extends RawScheduleItem>(
schedule: ReadonlyArray<T>,
now: Date = new Date()
): StudentScheduleItem[] {
const todayWeekday = toWeekday(now)
return schedule
.filter((s) => s.weekday === todayWeekday)
.map((s) => ({
id: s.id,
classId: s.classId,
className: s.className,
course: s.course,
startTime: s.startTime,
endTime: s.endTime,
location: s.location ?? null,
}))
}
/**
* 组装完整的学生概览视图模型
*/
export function buildStudentOverviewData<TGrades>(
params: {
classes: ReadonlyArray<unknown>
schedule: ReadonlyArray<RawScheduleItem>
assignments: ReadonlyArray<StudentHomeworkAssignmentListItem>
grades: TGrades
now?: Date
}
): StudentOverviewData<TGrades> {
const { classes, schedule, assignments, grades, now = new Date() } = params
const stats = computeStudentStats(assignments, now)
const upcomingAssignments = sortUpcomingAssignments(assignments)
const todayScheduleItems = filterTodaySchedule(schedule, now)
return {
enrolledClassCount: classes.length,
dueSoonCount: stats.dueSoonCount,
overdueCount: stats.overdueCount,
gradedCount: stats.gradedCount,
todayScheduleItems,
upcomingAssignments,
grades,
}
}

View File

@@ -1,3 +1,10 @@
import type { ActionState } from "@/shared/types/action-state"
import type { UpdateUserProfileInput, UserProfile } from "@/modules/users/data-access"
import type {
NotificationPreferences,
UpdateNotificationPreferencesInput,
} from "@/modules/notifications/types"
export type AiProviderName = "zhipu" | "openai" | "gemini" | "custom"
export interface AiProviderSummary {
@@ -16,3 +23,38 @@ export interface AiProviderExisting {
apiKeyLast4: string | null
isDefault: boolean
}
/**
* 个人资料服务接口(解耦 settings 组件对 users/actions 的直接依赖)
*
* 由页面层注入实现,组件层通过 useSettingsService().profile 消费。
*/
export interface ProfileService {
getProfile: () => Promise<UserProfile | null>
updateProfile: (input: UpdateUserProfileInput) => Promise<ActionState<void>>
}
/**
* 通知偏好服务接口(解耦 settings 组件对 messaging/actions 的直接依赖)
*
* 由页面层注入实现,组件层通过 useSettingsService().notifications 消费。
*/
export interface NotificationPreferenceService {
getPreferences: () => Promise<NotificationPreferences>
updatePreferences: (
input: UpdateNotificationPreferencesInput
) => Promise<ActionState<NotificationPreferences>>
}
/**
* 设置模块统一服务接口
*
* 通过 React Context 注入,组件层不直接 import 其他业务模块的 actions。
* 不同角色或测试场景可注入不同实现。
*/
export interface SettingsService {
profile: ProfileService
notifications: NotificationPreferenceService
/** 预留埋点接口 */
trackEvent?: (event: string, payload?: Record<string, unknown>) => void
}