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,18 +1,20 @@
"use client"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function ProfileError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
const t = useTranslations("settings.errors")
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title="页面加载失败"
description="抱歉,个人资料页面加载时发生了意外错误。请稍后重试。"
title={t("loadFailed")}
description={t("loadFailedDesc")}
action={{
label: "重试",
label: t("retry"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"

View File

@@ -1,138 +1,58 @@
import Link from "next/link"
import { redirect } from "next/navigation"
import { Suspense, type ReactElement } from "react"
import { getTranslations } from "next-intl/server"
import { User, Mail, Phone, MapPin, Calendar, Clock, Shield } from "lucide-react"
import { requireAuth } from "@/shared/lib/auth-guard"
import { getStudentClasses, getStudentSchedule, getTeacherClasses, getTeacherTeachingSubjects } from "@/modules/classes/data-access"
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 { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
import { getUserProfile } from "@/modules/users/data-access"
import { ProfileStudentOverview, ProfileStudentOverviewSkeleton } from "@/modules/settings/components/profile-student-overview"
import { ProfileTeacherOverview, ProfileTeacherOverviewSkeleton } from "@/modules/settings/components/profile-teacher-overview"
import { SettingsSectionErrorBoundary } from "@/modules/settings/components/settings-section-error-boundary"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
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 { PageHeader } from "@/shared/components/ui/page-header"
import { Separator } from "@/shared/components/ui/separator"
import { formatDate } from "@/shared/lib/utils"
import { User, Mail, Phone, MapPin, Calendar, Clock, Shield } from "lucide-react"
export const dynamic = "force-dynamic"
export const metadata = {
title: "Profile",
export async function generateMetadata() {
const t = await getTranslations("settings.profilePage")
return { title: t("title") }
}
const WEEKDAY_MAP = [7, 1, 2, 3, 4, 5, 6] as const
type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7
const 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
}
export default async function ProfilePage() {
export default async function ProfilePage(): Promise<ReactElement> {
const ctx = await requireAuth()
const userId = ctx.userId
const userProfile = await getUserProfile(userId)
if (!userProfile) {
redirect("/login")
redirect("/login")
}
const roles = ctx.roles
const isStudent = roles.includes("student")
const isTeacher = roles.includes("teacher")
const studentData =
isStudent
? await (async () => {
const [classes, schedule, assignmentsAll, grades] = await Promise.all([
getStudentClasses(userId),
getStudentSchedule(userId),
getStudentHomeworkAssignments(userId),
getStudentDashboardGrades(userId),
])
const now = new Date()
const in7Days = new Date(now)
in7Days.setDate(in7Days.getDate() + 7)
const dueSoonCount = assignmentsAll.filter((a) => {
if (!a.dueAt) return false
const due = new Date(a.dueAt)
return due >= now && due <= in7Days && a.progressStatus !== "graded"
}).length
const overdueCount = assignmentsAll.filter((a) => {
if (!a.dueAt) return false
const due = new Date(a.dueAt)
return due < now && a.progressStatus !== "graded"
}).length
const gradedCount = assignmentsAll.filter((a) => a.progressStatus === "graded").length
const upcomingAssignments = [...assignmentsAll]
.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, 8)
const todayWeekday = toWeekday(now)
const todayScheduleItems = 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,
}))
return {
enrolledClassCount: classes.length,
dueSoonCount,
overdueCount,
gradedCount,
todayScheduleItems,
upcomingAssignments,
grades,
}
})()
: null
const teacherData =
isTeacher
? await (async () => {
const [subjects, classes] = await Promise.all([getTeacherTeachingSubjects(), getTeacherClasses()])
return { subjects, classes }
})()
: null
const t = await getTranslations("settings.profilePage")
return (
<div className="flex h-full flex-col gap-8 p-8">
<PageHeader
title="Profile"
description="Manage your personal and account information."
title={t("title")}
description={t("description")}
actions={
<Button asChild variant="outline">
<Link href="/settings">Edit Profile</Link>
<Link href="/settings">{t("editProfile")}</Link>
</Button>
}
/>
<div className="flex items-center gap-4">
<Avatar className="h-20 w-20">
{userProfile.image ? <AvatarImage src={userProfile.image} alt={userProfile.name ?? "User avatar"} /> : null}
{userProfile.image ? <AvatarImage src={userProfile.image} alt={userProfile.name ?? t("title")} /> : null}
<AvatarFallback className="text-xl font-semibold">
{(userProfile.name ?? userProfile.email).slice(0, 2).toUpperCase()}
</AvatarFallback>
@@ -148,36 +68,36 @@ export default async function ProfilePage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Personal Information
{t("personalInfo.title")}
</CardTitle>
<CardDescription>Basic personal details.</CardDescription>
<CardDescription>{t("personalInfo.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">Full Name</div>
<div className="text-sm font-medium text-muted-foreground">{t("personalInfo.fullName")}</div>
<div className="text-sm font-medium">{userProfile.name ?? "-"}</div>
</div>
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">Gender</div>
<div className="text-sm font-medium text-muted-foreground">{t("personalInfo.gender")}</div>
<div className="text-sm capitalize">{userProfile.gender ?? "-"}</div>
</div>
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">Age</div>
<div className="text-sm font-medium text-muted-foreground">{t("personalInfo.age")}</div>
<div className="text-sm">{userProfile.age ?? "-"}</div>
</div>
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">Phone</div>
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">{t("personalInfo.phone")}</div>
<div className="flex items-center gap-2 text-sm">
{userProfile.phone ? <Phone className="h-3 w-3 text-muted-foreground" /> : null}
{userProfile.phone ?? "-"}
{userProfile.phone ? <Phone className="h-3 w-3 text-muted-foreground" /> : null}
{userProfile.phone ?? "-"}
</div>
</div>
<div className="col-span-1 sm:col-span-2 space-y-1">
<div className="text-sm font-medium text-muted-foreground">Address</div>
<div className="text-sm font-medium text-muted-foreground">{t("personalInfo.address")}</div>
<div className="flex items-center gap-2 text-sm">
{userProfile.address ? <MapPin className="h-3 w-3 text-muted-foreground" /> : null}
{userProfile.address ?? "-"}
{userProfile.address ? <MapPin className="h-3 w-3 text-muted-foreground" /> : null}
{userProfile.address ?? "-"}
</div>
</div>
</div>
@@ -188,37 +108,37 @@ export default async function ProfilePage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Account Information
{t("accountInfo.title")}
</CardTitle>
<CardDescription>System account details.</CardDescription>
<CardDescription>{t("accountInfo.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="col-span-1 sm:col-span-2 space-y-1">
<div className="text-sm font-medium text-muted-foreground">Email</div>
<div className="col-span-1 sm:col-span-2 space-y-1">
<div className="text-sm font-medium text-muted-foreground">{t("accountInfo.email")}</div>
<div className="flex items-center gap-2 text-sm">
<Mail className="h-3 w-3 text-muted-foreground" />
{userProfile.email}
<Mail className="h-3 w-3 text-muted-foreground" />
{userProfile.email}
</div>
</div>
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">Role</div>
<div className="text-sm font-medium text-muted-foreground">{t("accountInfo.role")}</div>
<Badge variant="secondary" className="capitalize">
{userProfile.role}
</Badge>
</div>
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">Member Since</div>
<div className="text-sm font-medium text-muted-foreground">{t("accountInfo.memberSince")}</div>
<div className="flex items-center gap-2 text-sm">
<Calendar className="h-3 w-3 text-muted-foreground" />
{formatDate(userProfile.createdAt)}
<Calendar className="h-3 w-3 text-muted-foreground" />
{formatDate(userProfile.createdAt)}
</div>
</div>
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">Onboarded At</div>
<div className="flex items-center gap-2 text-sm">
<Clock className="h-3 w-3 text-muted-foreground" />
{userProfile.onboardedAt ? formatDate(userProfile.onboardedAt) : "-"}
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">{t("accountInfo.onboardedAt")}</div>
<div className="flex items-center gap-2 text-sm">
<Clock className="h-3 w-3 text-muted-foreground" />
{userProfile.onboardedAt ? formatDate(userProfile.onboardedAt) : "-"}
</div>
</div>
</div>
@@ -226,91 +146,20 @@ export default async function ProfilePage() {
</Card>
</div>
{studentData ? (
<div className="space-y-6">
<Separator />
<div className="space-y-1">
<h2 className="text-xl font-semibold tracking-tight">Student Overview</h2>
<div className="text-sm text-muted-foreground">Your academic performance and schedule.</div>
</div>
<StudentStatsGrid
enrolledClassCount={studentData.enrolledClassCount}
dueSoonCount={studentData.dueSoonCount}
overdueCount={studentData.overdueCount}
gradedCount={studentData.gradedCount}
ranking={studentData.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={studentData.upcomingAssignments} />
<StudentGradesCard grades={studentData.grades} />
</div>
<div className="space-y-6">
<StudentTodayScheduleCard items={studentData.todayScheduleItems} />
</div>
</div>
</div>
{isStudent ? (
<SettingsSectionErrorBoundary>
<Suspense fallback={<ProfileStudentOverviewSkeleton />}>
<ProfileStudentOverview userId={userId} />
</Suspense>
</SettingsSectionErrorBoundary>
) : null}
{teacherData ? (
<div className="space-y-6">
<Separator />
<div className="space-y-1">
<h2 className="text-xl font-semibold tracking-tight">Teacher Overview</h2>
<div className="text-sm text-muted-foreground">Your teaching subjects and classes.</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Teaching Subjects</CardTitle>
<CardDescription>Subjects you are currently assigned to teach.</CardDescription>
</CardHeader>
<CardContent>
{teacherData.subjects.length === 0 ? (
<div className="text-sm text-muted-foreground">No subjects assigned yet.</div>
) : (
<div className="flex flex-wrap gap-2">
{teacherData.subjects.map((subject) => (
<Badge key={subject} variant="secondary">
{subject}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Teaching Classes</CardTitle>
<CardDescription>Classes you are currently managing.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{teacherData.classes.length === 0 ? (
<div className="text-sm text-muted-foreground">No classes assigned yet.</div>
) : (
teacherData.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)}`}>View</Link>
</Button>
</div>
))
)}
</CardContent>
</Card>
</div>
</div>
{isTeacher ? (
<SettingsSectionErrorBoundary>
<Suspense fallback={<ProfileTeacherOverviewSkeleton />}>
<ProfileTeacherOverview />
</Suspense>
</SettingsSectionErrorBoundary>
) : null}
</div>
)

View File

@@ -1,18 +1,20 @@
"use client"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function SettingsError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
const t = useTranslations("settings.errors")
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title="页面加载失败"
description="抱歉,设置页面加载时发生了意外错误。请稍后重试。"
title={t("loadFailed")}
description={t("loadFailedDesc")}
action={{
label: "重试",
label: t("retry"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"

View File

@@ -1,12 +1,16 @@
import { redirect } from "next/navigation"
import { getTranslations } from "next-intl/server"
import { requireAuth } from "@/shared/lib/auth-guard"
import { SettingsView } from "@/modules/settings/components/settings-view"
import { StudentSettingsView } from "@/modules/settings/components/student-settings-view"
import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view"
import { ParentSettingsView } from "@/modules/settings/components/parent-settings-view"
import { SettingsServiceProvider } from "@/modules/settings/components/settings-service-context"
import { resolveRoleSettingsConfig } from "@/modules/settings/config/role-settings-config"
import type { SettingsService } from "@/modules/settings/types"
import { getUserProfile } from "@/modules/users/data-access"
import { updateUserProfile } from "@/modules/users/actions"
import { getNotificationPreferences } from "@/modules/notifications/preferences"
import { updateNotificationPreferencesAction } from "@/modules/messaging/actions"
import type { UpdateNotificationPreferencesInput } from "@/modules/notifications/types"
export const dynamic = "force-dynamic"
@@ -14,6 +18,32 @@ export const metadata = {
title: "Settings",
}
/**
* 将通知偏好输入对象转换为 FormData适配 updateNotificationPreferencesAction 的签名。
* Action 内部通过 formData.get(key) === "on" 解析布尔值。
*/
function buildNotificationFormData(input: UpdateNotificationPreferencesInput): FormData {
const formData = new FormData()
const booleanFields: Array<keyof UpdateNotificationPreferencesInput> = [
"emailEnabled",
"smsEnabled",
"pushEnabled",
"homeworkNotifications",
"gradeNotifications",
"announcementNotifications",
"messageNotifications",
"attendanceNotifications",
"quietHoursEnabled",
]
for (const field of booleanFields) {
const value = input[field]
if (value === true) formData.set(field, "on")
}
if (input.quietHoursStart) formData.set("quietHoursStart", input.quietHoursStart)
if (input.quietHoursEnd) formData.set("quietHoursEnd", input.quietHoursEnd)
return formData
}
export default async function SettingsPage() {
const ctx = await requireAuth()
@@ -24,22 +54,36 @@ export default async function SettingsPage() {
const roles = ctx.roles
const notificationPrefs = await getNotificationPreferences(userId)
const t = await getTranslations("settings")
if (roles.includes("admin")) {
return (
const config = resolveRoleSettingsConfig(roles)
const description = t(config?.descriptionKey ?? "title")
const backHref = config?.backHref ?? "/dashboard"
const generalExtra = config?.generalExtra
// 构建 SettingsService 实现,注入到 SettingsServiceProvider
// 组件层通过 useSettingsService() 消费,不直接 import users/messaging actions
const service: SettingsService = {
profile: {
getProfile: async () => getUserProfile(userId),
updateProfile: async (input) => updateUserProfile(input),
},
notifications: {
getPreferences: async () => getNotificationPreferences(userId),
updatePreferences: async (input) =>
updateNotificationPreferencesAction(null, buildNotificationFormData(input)),
},
}
return (
<SettingsServiceProvider service={service}>
<SettingsView
description="Manage your admin preferences and account access."
backHref="/admin/dashboard"
description={description}
backHref={backHref}
user={userProfile}
notificationPreferences={notificationPrefs}
generalExtra={generalExtra}
/>
)
}
if (roles.includes("student")) {
return <StudentSettingsView user={userProfile} notificationPreferences={notificationPrefs} />
}
if (roles.includes("parent")) {
return <ParentSettingsView user={userProfile} notificationPreferences={notificationPrefs} />
}
return <TeacherSettingsView user={userProfile} notificationPreferences={notificationPrefs} />
</SettingsServiceProvider>
)
}

View File

@@ -1,18 +1,20 @@
"use client"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function SecuritySettingsError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
const t = useTranslations("settings.errors")
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title="页面加载失败"
description="抱歉,安全设置页面加载时发生了意外错误。请稍后重试。"
title={t("loadFailed")}
description={t("loadFailedDesc")}
action={{
label: "重试",
label: t("retry"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"

View File

@@ -1,4 +1,5 @@
import { Lock } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { requireAuth } from "@/shared/lib/auth-guard"
import { PasswordChangeForm } from "@/modules/settings/components/password-change-form"
@@ -13,12 +14,13 @@ export const metadata = {
export default async function SecuritySettingsPage() {
await requireAuth()
const t = await getTranslations("settings")
return (
<div className="flex h-full flex-col gap-8 p-8">
<PageHeader
title="Security"
description="Manage your password and account security settings."
title={t("tabs.security")}
description={t("security.changePassword.description")}
icon={Lock}
/>
@@ -27,15 +29,15 @@ export default async function SecuritySettingsPage() {
<Card>
<CardHeader>
<CardTitle>Security Tips</CardTitle>
<CardDescription>Best practices to keep your account safe.</CardDescription>
<CardTitle>{t("security.tips.title")}</CardTitle>
<CardDescription>{t("security.tips.description")}</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>Use a unique password that you don&apos;t reuse across other sites.</li>
<li>Avoid common words, names, or sequential patterns.</li>
<li>Change your password periodically.</li>
<li>Your account will be temporarily locked after multiple failed login attempts.</li>
<li>{t("security.tips.tip1")}</li>
<li>{t("security.tips.tip2")}</li>
<li>{t("security.tips.tip3")}</li>
<li>{t("security.tips.tip4")}</li>
</ul>
</CardContent>
</Card>

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
}

View File

@@ -0,0 +1,280 @@
{
"title": "Settings",
"backToDashboard": "Back to dashboard",
"tabs": {
"general": "General",
"notifications": "Notifications",
"appearance": "Appearance",
"security": "Security",
"ai": "AI"
},
"profile": {
"title": "Profile Information",
"description": "Update your personal information.",
"fields": {
"name": "Full Name",
"namePlaceholder": "Your name",
"email": "Email",
"emailDisabled": "Email cannot be changed.",
"phone": "Phone",
"phonePlaceholder": "+1 234 567 890",
"address": "Address",
"addressPlaceholder": "123 Main St, City, Country",
"gender": "Gender",
"genderPlaceholder": "Select gender",
"age": "Age",
"role": "Role"
},
"save": "Save Changes",
"saving": "Saving...",
"success": "Profile updated successfully",
"failure": "Failed to update profile"
},
"notifications": {
"title": "Notification Preferences",
"description": "Choose how and when you want to be notified.",
"channels": {
"title": "Delivery Channels",
"subtitle": "Select the channels through which you want to receive notifications.",
"push": "Push Notifications",
"pushDesc": "Receive in-app and browser push notifications.",
"email": "Email",
"emailDesc": "Send notifications to my registered email address.",
"sms": "SMS",
"smsDesc": "Send critical notifications via SMS (charges may apply)."
},
"categories": {
"title": "Notification Categories",
"subtitle": "Choose which types of events should trigger notifications.",
"messages": "Messages",
"messagesDesc": "New direct messages and replies.",
"announcements": "Announcements",
"announcementsDesc": "School, grade, and class announcements.",
"homework": "Homework",
"homeworkDesc": "New assignments and submission reminders.",
"grades": "Grades",
"gradesDesc": "Exam and assignment grade releases.",
"attendance": "Attendance",
"attendanceDesc": "Attendance records and absence alerts."
},
"quietHours": {
"title": "Quiet Hours",
"subtitle": "Suppress non-urgent notifications during a specified time period each day.",
"enable": "Enable Quiet Hours",
"enableDesc": "When enabled, only urgent notifications will be delivered during the specified hours.",
"start": "Start Time",
"end": "End Time"
},
"save": "Save Preferences",
"saving": "Saving...",
"success": "Preferences updated",
"failure": "Failed to update preferences"
},
"appearance": {
"theme": {
"title": "Theme",
"description": "Choose how the interface looks on this device.",
"label": "Color theme",
"system": "System",
"light": "Light",
"dark": "Dark"
},
"language": {
"title": "Language",
"description": "Choose the interface language.",
"label": "Interface language"
}
},
"security": {
"changePassword": {
"title": "Change Password",
"description": "Choose a strong password to keep your account secure.",
"current": "Current Password",
"currentPlaceholder": "Enter current password",
"new": "New Password",
"newPlaceholder": "Enter new password",
"confirm": "Confirm New Password",
"confirmPlaceholder": "Re-enter new password",
"strength": "Password strength",
"strengthWeak": "Weak",
"strengthMedium": "Medium",
"strengthStrong": "Strong",
"requirements": "Password requirements:",
"submit": "Update Password",
"updating": "Updating..."
},
"session": {
"title": "Session",
"description": "Account access and session controls.",
"signOut": "Log out",
"signOutDesc": "Return to the login screen.",
"confirmTitle": "Confirm sign out",
"confirmDesc": "Are you sure you want to sign out? You will be returned to the login screen.",
"cancel": "Cancel",
"confirm": "Sign out"
},
"tips": {
"title": "Security Tips",
"description": "Best practices to keep your account safe.",
"tip1": "Use a unique password that you don't reuse across other sites.",
"tip2": "Avoid common words, names, or sequential patterns.",
"tip3": "Change your password periodically.",
"tip4": "Your account will be temporarily locked after multiple failed login attempts."
}
},
"ai": {
"providers": {
"title": "AI Providers",
"description": "Manage AI vendors and default model configuration.",
"existing": "Existing Providers",
"selectPlaceholder": "Create new or select existing",
"createNew": "Create new",
"keyStatus": "Key Status",
"stored": "Stored",
"noKey": "No key stored",
"id": "ID",
"idDesc": "Auto-generated for each provider.",
"provider": "Provider",
"providerPlaceholder": "Select provider",
"baseUrl": "API URL",
"baseUrlPlaceholder": "https://open.bigmodel.cn/api/paas/v4",
"baseUrlDesc": "Enter base URL without /chat/completions suffix.",
"model": "Model",
"modelPlaceholder": "gpt-4o-mini",
"apiKey": "API Key",
"apiKeyPlaceholder": "Paste new key to replace",
"apiKeyDesc": "Existing key won't be displayed. Leave blank to keep current.",
"setDefault": "Set as default",
"test": "Test",
"testing": "Testing...",
"save": "Save Changes",
"saving": "Saving...",
"testSuccess": "Test passed",
"testFailure": "Test failed",
"saveSuccess": "Saved",
"saveFailure": "Failed to save",
"loadFailure": "Failed to load AI providers",
"needKey": "Please enter API key to test",
"needTest": "Please test the configuration before saving"
}
},
"quickLinks": {
"title": "Quick links",
"description": "Common places you may want to visit.",
"profile": "Profile",
"dashboard": "Dashboard",
"children": "Children",
"grades": "Grades",
"attendance": "Attendance",
"assignments": "Assignments",
"schedule": "Schedule",
"textbooks": "Textbooks",
"exams": "Exams",
"homework": "Homework"
},
"roleDescriptions": {
"admin": "Manage your account and system configuration.",
"teacher": "Manage your profile, notifications, and teaching preferences.",
"student": "Manage your profile, notifications, and learning preferences.",
"parent": "Manage your profile, notifications, and child follow-up preferences."
},
"errors": {
"loadFailed": "Page load failed",
"loadFailedDesc": "Sorry, an unexpected error occurred while loading the page. Please try again later.",
"retry": "Retry",
"sectionLoadFailed": "This section failed to load",
"sectionLoadFailedDesc": "Please try again later."
},
"admin": {
"title": "System Settings",
"description": "Manage system basics and runtime parameters.",
"schoolInfo": {
"title": "School Information",
"description": "Basic school information displayed throughout the system.",
"name": "School Name",
"namePlaceholder": "Enter school name",
"code": "School Code",
"codePlaceholder": "Enter school code",
"phone": "Contact Phone",
"phonePlaceholder": "Enter contact phone",
"email": "Contact Email",
"emailPlaceholder": "Enter contact email",
"address": "School Address",
"addressPlaceholder": "Enter school address",
"description2": "School Description",
"descriptionPlaceholder": "Enter school description"
},
"securityPolicy": {
"title": "Security Policy",
"description": "Password policy and session management.",
"passwordMinLength": "Minimum Password Length",
"sessionTimeout": "Session Timeout (minutes)",
"requireSpecialChar": "Require special characters in passwords",
"requireSpecialCharDesc": "Require at least one special character in user passwords",
"requireUppercase": "Require uppercase letters in passwords",
"requireUppercaseDesc": "Require at least one uppercase letter in user passwords",
"forcePasswordChange": "Force password change on first login",
"forcePasswordChangeDesc": "New users or after password reset must change password on first login"
},
"fileUpload": {
"title": "File Upload",
"description": "File upload limits and storage configuration.",
"maxFileSize": "Max File Size (MB)",
"allowedTypes": "Allowed File Types",
"allowedTypesPlaceholder": "e.g. jpg,png,pdf,docx"
},
"notificationConfig": {
"title": "Notification Configuration",
"description": "How and when system notifications are sent.",
"notifyNewUser": "Notify admins on new user registration",
"notifyNewUserDesc": "Send notification to admins when a new user registers",
"notifyScheduleChange": "Notify teachers on schedule changes",
"notifyScheduleChangeDesc": "Notify relevant teachers when schedule change is approved",
"notifyAnnouncement": "Notify target users on announcement publish",
"notifyAnnouncementDesc": "Push notification to target users when announcement is published"
},
"save": "Save Settings",
"saving": "Saving...",
"reset": "Reset",
"saveSuccess": "Settings saved",
"saveFailure": "Failed to save settings",
"loadFailure": "Failed to load system settings"
},
"profilePage": {
"title": "Profile",
"description": "Manage your personal and account information.",
"editProfile": "Edit Profile",
"personalInfo": {
"title": "Personal Information",
"description": "Basic personal details.",
"fullName": "Full Name",
"gender": "Gender",
"age": "Age",
"phone": "Phone",
"address": "Address"
},
"accountInfo": {
"title": "Account Information",
"description": "System account details.",
"email": "Email",
"role": "Role",
"memberSince": "Member Since",
"onboardedAt": "Onboarded At"
},
"studentOverview": {
"title": "Student Overview",
"description": "Your academic performance and schedule."
},
"teacherOverview": {
"title": "Teacher Overview",
"description": "Your teaching subjects and classes.",
"teachingSubjects": "Teaching Subjects",
"teachingSubjectsDesc": "Subjects you are currently assigned to teach.",
"noSubjects": "No subjects assigned yet.",
"teachingClasses": "Teaching Classes",
"teachingClassesDesc": "Classes you are currently managing.",
"noClasses": "No classes assigned yet.",
"view": "View"
}
}
}

View File

@@ -0,0 +1,280 @@
{
"title": "设置",
"backToDashboard": "返回仪表盘",
"tabs": {
"general": "通用",
"notifications": "通知",
"appearance": "外观",
"security": "安全",
"ai": "AI"
},
"profile": {
"title": "个人信息",
"description": "更新您的个人资料。",
"fields": {
"name": "姓名",
"namePlaceholder": "您的姓名",
"email": "邮箱",
"emailDisabled": "邮箱不可修改。",
"phone": "电话",
"phonePlaceholder": "+86 138 0000 0000",
"address": "地址",
"addressPlaceholder": "省市区街道",
"gender": "性别",
"genderPlaceholder": "选择性别",
"age": "年龄",
"role": "角色"
},
"save": "保存修改",
"saving": "保存中...",
"success": "个人资料更新成功",
"failure": "个人资料更新失败"
},
"notifications": {
"title": "通知偏好",
"description": "选择您希望接收通知的方式和时间。",
"channels": {
"title": "通知渠道",
"subtitle": "选择您希望接收通知的渠道。",
"push": "推送通知",
"pushDesc": "接收应用内和浏览器推送通知。",
"email": "邮件",
"emailDesc": "将通知发送到我的注册邮箱。",
"sms": "短信",
"smsDesc": "通过短信发送重要通知(可能产生费用)。"
},
"categories": {
"title": "通知类别",
"subtitle": "选择哪些事件应触发通知。",
"messages": "消息",
"messagesDesc": "新的私信和回复。",
"announcements": "公告",
"announcementsDesc": "学校、年级和班级公告。",
"homework": "作业",
"homeworkDesc": "新作业和提交提醒。",
"grades": "成绩",
"gradesDesc": "考试和作业成绩发布。",
"attendance": "考勤",
"attendanceDesc": "考勤记录和缺勤提醒。"
},
"quietHours": {
"title": "免打扰时段",
"subtitle": "每天在指定时段内暂停非紧急通知。",
"enable": "启用免打扰时段",
"enableDesc": "启用后,仅在指定时段内发送紧急通知。",
"start": "开始时间",
"end": "结束时间"
},
"save": "保存偏好",
"saving": "保存中...",
"success": "通知偏好已更新",
"failure": "通知偏好更新失败"
},
"appearance": {
"theme": {
"title": "主题",
"description": "选择此设备上的界面外观。",
"label": "配色主题",
"system": "跟随系统",
"light": "浅色",
"dark": "深色"
},
"language": {
"title": "语言",
"description": "选择界面语言。",
"label": "界面语言"
}
},
"security": {
"changePassword": {
"title": "修改密码",
"description": "使用强密码保护您的账户安全。",
"current": "当前密码",
"currentPlaceholder": "输入当前密码",
"new": "新密码",
"newPlaceholder": "输入新密码",
"confirm": "确认新密码",
"confirmPlaceholder": "再次输入新密码",
"strength": "密码强度",
"strengthWeak": "弱",
"strengthMedium": "中",
"strengthStrong": "强",
"requirements": "密码要求:",
"submit": "更新密码",
"updating": "更新中..."
},
"session": {
"title": "会话",
"description": "账户访问与会话管理。",
"signOut": "退出登录",
"signOutDesc": "返回登录页面。",
"confirmTitle": "确认退出",
"confirmDesc": "确定要退出登录吗?您将返回登录页面。",
"cancel": "取消",
"confirm": "确认退出"
},
"tips": {
"title": "安全提示",
"description": "保护账户安全的最佳实践。",
"tip1": "使用不与其他网站重复的独立密码。",
"tip2": "避免使用常见词汇、姓名或连续模式。",
"tip3": "定期更换密码。",
"tip4": "多次登录失败后账户将被临时锁定。"
}
},
"ai": {
"providers": {
"title": "AI 服务商",
"description": "管理 AI 供应商和默认模型配置。",
"existing": "已有服务商",
"selectPlaceholder": "新建或选择已有",
"createNew": "新建",
"keyStatus": "密钥状态",
"stored": "已存储",
"noKey": "未存储密钥",
"id": "ID",
"idDesc": "每个服务商自动生成。",
"provider": "服务商",
"providerPlaceholder": "选择服务商",
"baseUrl": "API 地址",
"baseUrlPlaceholder": "https://open.bigmodel.cn/api/paas/v4",
"baseUrlDesc": "输入基础地址,无需 /chat/completions 后缀。",
"model": "模型",
"modelPlaceholder": "gpt-4o-mini",
"apiKey": "API 密钥",
"apiKeyPlaceholder": "粘贴新密钥以替换",
"apiKeyDesc": "已有密钥不会显示。留空则保留当前密钥。",
"setDefault": "设为默认",
"test": "测试",
"testing": "测试中...",
"save": "保存修改",
"saving": "保存中...",
"testSuccess": "测试通过",
"testFailure": "测试失败",
"saveSuccess": "保存成功",
"saveFailure": "保存失败",
"loadFailure": "加载 AI 服务商失败",
"needKey": "请输入 API 密钥进行测试",
"needTest": "保存前请先测试配置"
}
},
"quickLinks": {
"title": "快捷链接",
"description": "您可能想访问的常用页面。",
"profile": "个人资料",
"dashboard": "仪表盘",
"children": "孩子",
"grades": "成绩",
"attendance": "考勤",
"assignments": "作业",
"schedule": "课表",
"textbooks": "教材",
"exams": "考试",
"homework": "作业"
},
"roleDescriptions": {
"admin": "管理您的账户和系统配置。",
"teacher": "管理您的个人信息、通知和教学偏好。",
"student": "管理您的个人信息、通知和学习偏好。",
"parent": "管理您的个人信息、通知和孩子关注偏好。"
},
"errors": {
"loadFailed": "页面加载失败",
"loadFailedDesc": "抱歉,页面加载时发生了意外错误。请稍后重试。",
"retry": "重试",
"sectionLoadFailed": "该区块加载失败",
"sectionLoadFailedDesc": "请稍后重试。"
},
"admin": {
"title": "系统设置",
"description": "管理系统基础信息与运行参数。",
"schoolInfo": {
"title": "学校信息",
"description": "学校的基础信息,将显示在系统各处。",
"name": "学校名称",
"namePlaceholder": "请输入学校名称",
"code": "学校代码",
"codePlaceholder": "请输入学校代码",
"phone": "联系电话",
"phonePlaceholder": "请输入联系电话",
"email": "联系邮箱",
"emailPlaceholder": "请输入联系邮箱",
"address": "学校地址",
"addressPlaceholder": "请输入学校地址",
"description2": "学校简介",
"descriptionPlaceholder": "请输入学校简介"
},
"securityPolicy": {
"title": "安全策略",
"description": "密码策略与会话管理。",
"passwordMinLength": "密码最小长度",
"sessionTimeout": "会话超时(分钟)",
"requireSpecialChar": "密码必须包含特殊字符",
"requireSpecialCharDesc": "要求用户密码中包含至少一个特殊字符",
"requireUppercase": "密码必须包含大写字母",
"requireUppercaseDesc": "要求用户密码中包含至少一个大写字母",
"forcePasswordChange": "首次登录强制修改密码",
"forcePasswordChangeDesc": "新用户或重置密码后首次登录时必须修改密码"
},
"fileUpload": {
"title": "文件上传",
"description": "文件上传限制与存储配置。",
"maxFileSize": "单文件最大大小MB",
"allowedTypes": "允许的文件类型",
"allowedTypesPlaceholder": "如jpg,png,pdf,docx"
},
"notificationConfig": {
"title": "通知配置",
"description": "系统通知的发送方式与触发条件。",
"notifyNewUser": "新用户注册通知管理员",
"notifyNewUserDesc": "有新用户注册时向管理员发送通知",
"notifyScheduleChange": "课表变更通知教师",
"notifyScheduleChangeDesc": "课表变更审批通过后通知相关教师",
"notifyAnnouncement": "公告发布通知目标用户",
"notifyAnnouncementDesc": "公告发布时向目标用户推送通知"
},
"save": "保存设置",
"saving": "保存中...",
"reset": "重置",
"saveSuccess": "设置已保存",
"saveFailure": "设置保存失败",
"loadFailure": "加载系统设置失败"
},
"profilePage": {
"title": "个人资料",
"description": "管理您的个人和账户信息。",
"editProfile": "编辑资料",
"personalInfo": {
"title": "个人信息",
"description": "基本个人资料。",
"fullName": "姓名",
"gender": "性别",
"age": "年龄",
"phone": "电话",
"address": "地址"
},
"accountInfo": {
"title": "账户信息",
"description": "系统账户详情。",
"email": "邮箱",
"role": "角色",
"memberSince": "注册时间",
"onboardedAt": "入职时间"
},
"studentOverview": {
"title": "学生概览",
"description": "您的学业表现和课表。"
},
"teacherOverview": {
"title": "教师概览",
"description": "您任教的科目和班级。",
"teachingSubjects": "任教科目",
"teachingSubjectsDesc": "您当前被分配教授的科目。",
"noSubjects": "暂无分配科目。",
"teachingClasses": "任教班级",
"teachingClassesDesc": "您当前管理的班级。",
"noClasses": "暂无分配班级。",
"view": "查看"
}
}
}