feat(settings): add security center, 2FA/TOTP, avatar upload, system settings
- 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
This commit is contained in:
@@ -3,8 +3,12 @@
|
||||
import * as React from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { School, Shield, Database, Bell } from "lucide-react"
|
||||
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"
|
||||
@@ -13,24 +17,155 @@ 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,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员系统设置视图
|
||||
*
|
||||
* TODO: 当前为 mock 实现(setTimeout 模拟保存),未接入真实数据层。
|
||||
* 后续需新增 system_settings 表 + data-access + actions,替换 mock 逻辑。
|
||||
* 当前已适配 i18n,文本均通过 settings.admin.* 翻译键获取。
|
||||
* 通过 Server Actions 加载和保存系统设置,数据持久化到 system_settings 表。
|
||||
* 4 个 Card:学校信息 / 安全策略 / 文件上传 / 通知配置。
|
||||
* 所有文本通过 settings.admin.* i18n 键获取。
|
||||
*/
|
||||
export function AdminSettingsView() {
|
||||
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)
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
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)
|
||||
// TODO: 替换为真实 Server Action 调用
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 800))
|
||||
toast.success(t("saveSuccess"))
|
||||
setSaving(false)
|
||||
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 (
|
||||
@@ -56,30 +191,68 @@ export function AdminSettingsView() {
|
||||
<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")} />
|
||||
<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")} />
|
||||
<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")} />
|
||||
<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")} />
|
||||
<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")} />
|
||||
<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} />
|
||||
<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>
|
||||
@@ -99,11 +272,27 @@ export function AdminSettingsView() {
|
||||
<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} defaultValue={8} />
|
||||
<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} defaultValue={60} />
|
||||
<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 />
|
||||
@@ -112,21 +301,36 @@ export function AdminSettingsView() {
|
||||
<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" defaultChecked />
|
||||
<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" />
|
||||
<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" defaultChecked />
|
||||
<Switch
|
||||
id="force-password-change"
|
||||
name="forcePasswordChange"
|
||||
checked={values.securityPolicy.forcePasswordChange}
|
||||
onCheckedChange={(v) => updateSecurityPolicy("forcePasswordChange", v)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -146,11 +350,25 @@ export function AdminSettingsView() {
|
||||
<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} defaultValue={10} />
|
||||
<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")} defaultValue="jpg,png,pdf,docx,xlsx,pptx" />
|
||||
<Input
|
||||
id="allowed-types"
|
||||
name="allowedTypes"
|
||||
placeholder={t("fileUpload.allowedTypesPlaceholder")}
|
||||
value={values.fileUpload.allowedTypes}
|
||||
onChange={(e) => updateFileUpload("allowedTypes", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -173,28 +391,50 @@ export function AdminSettingsView() {
|
||||
<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" defaultChecked />
|
||||
<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" defaultChecked />
|
||||
<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" />
|
||||
<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">{t("reset")}</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user