- Add TOTP implementation and two-factor data-access for 2FA enrollment - Add security center card with password policy and session management - Add avatar upload action and component - Add system settings actions and data-access (actions-system-settings, data-access-system-settings) - Add notification preferences and service actions - Add security-utils and student-overview-data with tests - Update existing settings views, data-access, and types for new features
445 lines
16 KiB
TypeScript
445 lines
16 KiB
TypeScript
"use client"
|
||
|
||
import * as React from "react"
|
||
import { useTranslations } from "next-intl"
|
||
import { toast } from "sonner"
|
||
import { School, Shield, Database, Bell, Loader2 } from "lucide-react"
|
||
|
||
import {
|
||
getAdminSystemSettingsAction,
|
||
saveAdminSystemSettingsAction,
|
||
} from "@/modules/settings/actions-system-settings"
|
||
import { Button } from "@/shared/components/ui/button"
|
||
import { Input } from "@/shared/components/ui/input"
|
||
import { Label } from "@/shared/components/ui/label"
|
||
import { Textarea } from "@/shared/components/ui/textarea"
|
||
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"
|
||
|
||
interface AdminSettingsFormValues {
|
||
schoolInfo: {
|
||
schoolName: string
|
||
schoolCode: string
|
||
schoolPhone: string
|
||
schoolEmail: string
|
||
schoolAddress: string
|
||
schoolDescription: string
|
||
}
|
||
securityPolicy: {
|
||
passwordMinLength: number
|
||
sessionTimeout: number
|
||
requireSpecialChar: boolean
|
||
requireUppercase: boolean
|
||
forcePasswordChange: boolean
|
||
}
|
||
fileUpload: {
|
||
maxFileSize: number
|
||
allowedTypes: string
|
||
}
|
||
notificationConfig: {
|
||
notifyNewUser: boolean
|
||
notifyScheduleChange: boolean
|
||
notifyAnnouncement: boolean
|
||
}
|
||
}
|
||
|
||
const DEFAULT_VALUES: AdminSettingsFormValues = {
|
||
schoolInfo: {
|
||
schoolName: "",
|
||
schoolCode: "",
|
||
schoolPhone: "",
|
||
schoolEmail: "",
|
||
schoolAddress: "",
|
||
schoolDescription: "",
|
||
},
|
||
securityPolicy: {
|
||
passwordMinLength: 8,
|
||
sessionTimeout: 60,
|
||
requireSpecialChar: true,
|
||
requireUppercase: false,
|
||
forcePasswordChange: true,
|
||
},
|
||
fileUpload: {
|
||
maxFileSize: 10,
|
||
allowedTypes: "jpg,png,pdf,docx,xlsx,pptx",
|
||
},
|
||
notificationConfig: {
|
||
notifyNewUser: true,
|
||
notifyScheduleChange: true,
|
||
notifyAnnouncement: false,
|
||
},
|
||
}
|
||
|
||
/**
|
||
* 管理员系统设置视图
|
||
*
|
||
* 通过 Server Actions 加载和保存系统设置,数据持久化到 system_settings 表。
|
||
* 4 个 Card:学校信息 / 安全策略 / 文件上传 / 通知配置。
|
||
* 所有文本通过 settings.admin.* i18n 键获取。
|
||
*/
|
||
export function AdminSettingsView(): React.ReactElement {
|
||
const t = useTranslations("settings.admin")
|
||
const [values, setValues] = React.useState<AdminSettingsFormValues>(DEFAULT_VALUES)
|
||
const [loadedValues, setLoadedValues] = React.useState<AdminSettingsFormValues>(DEFAULT_VALUES)
|
||
const [loading, setLoading] = React.useState(true)
|
||
const [saving, setSaving] = React.useState(false)
|
||
|
||
React.useEffect(() => {
|
||
let cancelled = false
|
||
async function load(): Promise<void> {
|
||
try {
|
||
const result = await getAdminSystemSettingsAction()
|
||
if (!cancelled && result.success && result.data) {
|
||
setValues(result.data)
|
||
setLoadedValues(result.data)
|
||
}
|
||
} catch {
|
||
// 加载失败时使用默认值
|
||
} finally {
|
||
if (!cancelled) setLoading(false)
|
||
}
|
||
}
|
||
void load()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [])
|
||
|
||
// dirty 检测:当前值与加载值不一致时为 dirty
|
||
const isDirty = React.useMemo(
|
||
() => JSON.stringify(values) !== JSON.stringify(loadedValues),
|
||
[values, loadedValues],
|
||
)
|
||
|
||
const handleSave = async (e: React.FormEvent): Promise<void> => {
|
||
e.preventDefault()
|
||
if (!isDirty) return
|
||
setSaving(true)
|
||
try {
|
||
const result = await saveAdminSystemSettingsAction(values)
|
||
if (result.success) {
|
||
toast.success(t("saveSuccess"))
|
||
setLoadedValues(values)
|
||
} else {
|
||
toast.error(result.message || t("saveFailure"))
|
||
}
|
||
} catch {
|
||
toast.error(t("saveFailure"))
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const handleReset = (): void => {
|
||
setValues(loadedValues)
|
||
}
|
||
|
||
const updateSchoolInfo = (key: keyof AdminSettingsFormValues["schoolInfo"], value: string): void => {
|
||
setValues((prev) => ({ ...prev, schoolInfo: { ...prev.schoolInfo, [key]: value } }))
|
||
}
|
||
|
||
const updateSecurityPolicy = (
|
||
key: keyof AdminSettingsFormValues["securityPolicy"],
|
||
value: number | boolean
|
||
): void => {
|
||
setValues((prev) => ({ ...prev, securityPolicy: { ...prev.securityPolicy, [key]: value } }))
|
||
}
|
||
|
||
const updateFileUpload = (
|
||
key: keyof AdminSettingsFormValues["fileUpload"],
|
||
value: number | string
|
||
): void => {
|
||
setValues((prev) => ({ ...prev, fileUpload: { ...prev.fileUpload, [key]: value } }))
|
||
}
|
||
|
||
const updateNotificationConfig = (
|
||
key: keyof AdminSettingsFormValues["notificationConfig"],
|
||
value: boolean
|
||
): void => {
|
||
setValues((prev) => ({ ...prev, notificationConfig: { ...prev.notificationConfig, [key]: value } }))
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex h-full items-center justify-center">
|
||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="flex h-full flex-col space-y-6">
|
||
<div>
|
||
<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">
|
||
{/* 学校信息 */}
|
||
<Card className="shadow-none">
|
||
<CardHeader>
|
||
<div className="flex items-center gap-2">
|
||
<School className="h-5 w-5 text-primary" />
|
||
<div>
|
||
<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">{t("schoolInfo.name")}</Label>
|
||
<Input
|
||
id="school-name"
|
||
name="schoolName"
|
||
placeholder={t("schoolInfo.namePlaceholder")}
|
||
value={values.schoolInfo.schoolName}
|
||
onChange={(e) => updateSchoolInfo("schoolName", e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="school-code">{t("schoolInfo.code")}</Label>
|
||
<Input
|
||
id="school-code"
|
||
name="schoolCode"
|
||
placeholder={t("schoolInfo.codePlaceholder")}
|
||
value={values.schoolInfo.schoolCode}
|
||
onChange={(e) => updateSchoolInfo("schoolCode", e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="school-phone">{t("schoolInfo.phone")}</Label>
|
||
<Input
|
||
id="school-phone"
|
||
name="schoolPhone"
|
||
placeholder={t("schoolInfo.phonePlaceholder")}
|
||
value={values.schoolInfo.schoolPhone}
|
||
onChange={(e) => updateSchoolInfo("schoolPhone", e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="school-email">{t("schoolInfo.email")}</Label>
|
||
<Input
|
||
id="school-email"
|
||
name="schoolEmail"
|
||
type="email"
|
||
placeholder={t("schoolInfo.emailPlaceholder")}
|
||
value={values.schoolInfo.schoolEmail}
|
||
onChange={(e) => updateSchoolInfo("schoolEmail", e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="school-address">{t("schoolInfo.address")}</Label>
|
||
<Input
|
||
id="school-address"
|
||
name="schoolAddress"
|
||
placeholder={t("schoolInfo.addressPlaceholder")}
|
||
value={values.schoolInfo.schoolAddress}
|
||
onChange={(e) => updateSchoolInfo("schoolAddress", e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="school-desc">{t("schoolInfo.description2")}</Label>
|
||
<Textarea
|
||
id="school-desc"
|
||
name="schoolDescription"
|
||
placeholder={t("schoolInfo.descriptionPlaceholder")}
|
||
rows={3}
|
||
value={values.schoolInfo.schoolDescription}
|
||
onChange={(e) => updateSchoolInfo("schoolDescription", e.target.value)}
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 安全策略 */}
|
||
<Card className="shadow-none">
|
||
<CardHeader>
|
||
<div className="flex items-center gap-2">
|
||
<Shield className="h-5 w-5 text-primary" />
|
||
<div>
|
||
<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">{t("securityPolicy.passwordMinLength")}</Label>
|
||
<Input
|
||
id="password-min-length"
|
||
name="passwordMinLength"
|
||
type="number"
|
||
min={6}
|
||
max={32}
|
||
value={values.securityPolicy.passwordMinLength}
|
||
onChange={(e) => updateSecurityPolicy("passwordMinLength", Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="session-timeout">{t("securityPolicy.sessionTimeout")}</Label>
|
||
<Input
|
||
id="session-timeout"
|
||
name="sessionTimeout"
|
||
type="number"
|
||
min={5}
|
||
max={1440}
|
||
value={values.securityPolicy.sessionTimeout}
|
||
onChange={(e) => updateSecurityPolicy("sessionTimeout", Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<Separator />
|
||
<div className="flex items-center justify-between">
|
||
<div className="space-y-0.5">
|
||
<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"
|
||
name="requireSpecialChar"
|
||
checked={values.securityPolicy.requireSpecialChar}
|
||
onCheckedChange={(v) => updateSecurityPolicy("requireSpecialChar", v)}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<div className="space-y-0.5">
|
||
<Label htmlFor="require-uppercase">{t("securityPolicy.requireUppercase")}</Label>
|
||
<p className="text-sm text-muted-foreground">{t("securityPolicy.requireUppercaseDesc")}</p>
|
||
</div>
|
||
<Switch
|
||
id="require-uppercase"
|
||
name="requireUppercase"
|
||
checked={values.securityPolicy.requireUppercase}
|
||
onCheckedChange={(v) => updateSecurityPolicy("requireUppercase", v)}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<div className="space-y-0.5">
|
||
<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"
|
||
name="forcePasswordChange"
|
||
checked={values.securityPolicy.forcePasswordChange}
|
||
onCheckedChange={(v) => updateSecurityPolicy("forcePasswordChange", v)}
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 文件上传 */}
|
||
<Card className="shadow-none">
|
||
<CardHeader>
|
||
<div className="flex items-center gap-2">
|
||
<Database className="h-5 w-5 text-primary" />
|
||
<div>
|
||
<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">{t("fileUpload.maxFileSize")}</Label>
|
||
<Input
|
||
id="max-file-size"
|
||
name="maxFileSize"
|
||
type="number"
|
||
min={1}
|
||
max={100}
|
||
value={values.fileUpload.maxFileSize}
|
||
onChange={(e) => updateFileUpload("maxFileSize", Number(e.target.value))}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="allowed-types">{t("fileUpload.allowedTypes")}</Label>
|
||
<Input
|
||
id="allowed-types"
|
||
name="allowedTypes"
|
||
placeholder={t("fileUpload.allowedTypesPlaceholder")}
|
||
value={values.fileUpload.allowedTypes}
|
||
onChange={(e) => updateFileUpload("allowedTypes", e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 通知配置 */}
|
||
<Card className="shadow-none">
|
||
<CardHeader>
|
||
<div className="flex items-center gap-2">
|
||
<Bell className="h-5 w-5 text-primary" />
|
||
<div>
|
||
<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">{t("notificationConfig.notifyNewUser")}</Label>
|
||
<p className="text-sm text-muted-foreground">{t("notificationConfig.notifyNewUserDesc")}</p>
|
||
</div>
|
||
<Switch
|
||
id="notify-new-user"
|
||
name="notifyNewUser"
|
||
checked={values.notificationConfig.notifyNewUser}
|
||
onCheckedChange={(v) => updateNotificationConfig("notifyNewUser", v)}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<div className="space-y-0.5">
|
||
<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"
|
||
name="notifyScheduleChange"
|
||
checked={values.notificationConfig.notifyScheduleChange}
|
||
onCheckedChange={(v) => updateNotificationConfig("notifyScheduleChange", v)}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<div className="space-y-0.5">
|
||
<Label htmlFor="notify-announcement">{t("notificationConfig.notifyAnnouncement")}</Label>
|
||
<p className="text-sm text-muted-foreground">{t("notificationConfig.notifyAnnouncementDesc")}</p>
|
||
</div>
|
||
<Switch
|
||
id="notify-announcement"
|
||
name="notifyAnnouncement"
|
||
checked={values.notificationConfig.notifyAnnouncement}
|
||
onCheckedChange={(v) => updateNotificationConfig("notifyAnnouncement", v)}
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div className="flex justify-end gap-3">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={handleReset}
|
||
disabled={!isDirty || saving}
|
||
>
|
||
{t("reset")}
|
||
</Button>
|
||
<Button type="submit" disabled={saving || !isDirty}>
|
||
{saving ? t("saving") : t("save")}
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
)
|
||
}
|