feat(settings,questions,school,textbooks): add brand config, question components, school dialogs, textbooks hooks
settings: - Add actions-brand, brand-config, data-access-brand for brand management - Add admin-file-upload-card, admin-notification-config-card, admin-school-info-card, admin-security-policy-card - Add ai-provider-delete-dialog, ai-provider-selector, brand-config-card - Add security-recent-logins-section, security-two-factor-section - Add config/profile-overview-config, data-access-profile-overview, lib/system-settings-utils questions: - Add batch-operations, import-export-buttons, knowledge-point-selector, options-editor - Add question-bank-results-client, question-cascade-filter, question-content-renderer, utils school: - Add grade-delete-dialog, grade-form-dialog, grade-list-toolbar, grade-overview-cards - Add use-grade-data hook textbooks: - Add textbook-form-fields component - Add use-kp-create, use-kp-delete, use-kp-update hooks
This commit is contained in:
@@ -3,45 +3,24 @@
|
||||
import * as React from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { School, Shield, Database, Bell, Loader2 } from "lucide-react"
|
||||
import { 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"
|
||||
import { SchoolInfoCard, type SchoolInfoValues } from "@/modules/settings/components/admin-school-info-card"
|
||||
import { SecurityPolicyCard, type SecurityPolicyValues } from "@/modules/settings/components/admin-security-policy-card"
|
||||
import { FileUploadCard, type FileUploadValues } from "@/modules/settings/components/admin-file-upload-card"
|
||||
import { NotificationConfigCard, type NotificationConfigValues } from "@/modules/settings/components/admin-notification-config-card"
|
||||
import { BrandConfigCard } from "@/modules/settings/components/brand-config-card"
|
||||
|
||||
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
|
||||
}
|
||||
schoolInfo: SchoolInfoValues
|
||||
securityPolicy: SecurityPolicyValues
|
||||
fileUpload: FileUploadValues
|
||||
notificationConfig: NotificationConfigValues
|
||||
}
|
||||
|
||||
const DEFAULT_VALUES: AdminSettingsFormValues = {
|
||||
@@ -135,26 +114,26 @@ export function AdminSettingsView(): React.ReactElement {
|
||||
setValues(loadedValues)
|
||||
}
|
||||
|
||||
const updateSchoolInfo = (key: keyof AdminSettingsFormValues["schoolInfo"], value: string): void => {
|
||||
const updateSchoolInfo = (key: keyof SchoolInfoValues, value: string): void => {
|
||||
setValues((prev) => ({ ...prev, schoolInfo: { ...prev.schoolInfo, [key]: value } }))
|
||||
}
|
||||
|
||||
const updateSecurityPolicy = (
|
||||
key: keyof AdminSettingsFormValues["securityPolicy"],
|
||||
key: keyof SecurityPolicyValues,
|
||||
value: number | boolean
|
||||
): void => {
|
||||
setValues((prev) => ({ ...prev, securityPolicy: { ...prev.securityPolicy, [key]: value } }))
|
||||
}
|
||||
|
||||
const updateFileUpload = (
|
||||
key: keyof AdminSettingsFormValues["fileUpload"],
|
||||
key: keyof FileUploadValues,
|
||||
value: number | string
|
||||
): void => {
|
||||
setValues((prev) => ({ ...prev, fileUpload: { ...prev.fileUpload, [key]: value } }))
|
||||
}
|
||||
|
||||
const updateNotificationConfig = (
|
||||
key: keyof AdminSettingsFormValues["notificationConfig"],
|
||||
key: keyof NotificationConfigValues,
|
||||
value: boolean
|
||||
): void => {
|
||||
setValues((prev) => ({ ...prev, notificationConfig: { ...prev.notificationConfig, [key]: value } }))
|
||||
@@ -176,254 +155,22 @@ export function AdminSettingsView(): React.ReactElement {
|
||||
</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>
|
||||
<SchoolInfoCard
|
||||
values={values.schoolInfo}
|
||||
onChange={updateSchoolInfo}
|
||||
/>
|
||||
<SecurityPolicyCard
|
||||
values={values.securityPolicy}
|
||||
onChange={updateSecurityPolicy}
|
||||
/>
|
||||
<FileUploadCard
|
||||
values={values.fileUpload}
|
||||
onChange={updateFileUpload}
|
||||
/>
|
||||
<NotificationConfigCard
|
||||
values={values.notificationConfig}
|
||||
onChange={updateNotificationConfig}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
@@ -439,6 +186,9 @@ export function AdminSettingsView(): React.ReactElement {
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* audit-P2-6: 品牌配置(独立表单 + 独立保存,不嵌入主表单以防嵌套 form) */}
|
||||
<BrandConfigCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user