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:
@@ -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>
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
93
src/modules/settings/components/profile-student-overview.tsx
Normal file
93
src/modules/settings/components/profile-student-overview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
115
src/modules/settings/components/profile-teacher-overview.tsx
Normal file
115
src/modules/settings/components/profile-teacher-overview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
src/modules/settings/components/quick-links-card.tsx
Normal file
46
src/modules/settings/components/quick-links-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
39
src/modules/settings/components/settings-service-context.tsx
Normal file
39
src/modules/settings/components/settings-service-context.tsx
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
84
src/modules/settings/config/role-settings-config.tsx
Normal file
84
src/modules/settings/config/role-settings-config.tsx
Normal 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
|
||||
}
|
||||
150
src/modules/settings/lib/student-overview-data.ts
Normal file
150
src/modules/settings/lib/student-overview-data.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user