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:
SpecialX
2026-07-03 10:26:00 +08:00
parent 138b6f1b00
commit f3c223d914
77 changed files with 5397 additions and 2864 deletions

View File

@@ -1,12 +1,11 @@
"use server"
import { unlink } from "fs/promises"
import path from "path"
import { revalidatePath } from "next/cache"
import type { ActionState } from "@/shared/types/action-state"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { storageProvider } from "@/shared/lib/storage-provider"
import { getUserProfile, updateUserAvatar } from "@/modules/users/data-access"
import {
deleteFileAttachment,
@@ -16,6 +15,9 @@ import {
/**
* 清理旧头像文件(磁盘 + DB 记录)
* 静默失败,不影响主流程
*
* P1-4磁盘删除统一走 storageProvider 抽象,
* 不再直接 import fs/promises。
*/
async function cleanupOldAvatarFile(oldImageUrl: string | null): Promise<void> {
if (!oldImageUrl) return
@@ -23,17 +25,8 @@ async function cleanupOldAvatarFile(oldImageUrl: string | null): Promise<void> {
const fileRecord = await getFileByUrl(oldImageUrl)
if (!fileRecord) return
// 删除磁盘文件
const absolutePath = path.join(
process.cwd(),
"public",
fileRecord.storagePath,
)
try {
await unlink(absolutePath)
} catch {
// 文件可能已不存在,忽略错误
}
// 删除磁盘文件(通过 storageProvider 抽象)
await storageProvider.delete(fileRecord.storagePath)
// 删除 DB 记录
await deleteFileAttachment(fileRecord.id)
@@ -45,7 +38,8 @@ async function cleanupOldAvatarFile(oldImageUrl: string | null): Promise<void> {
/**
* 更新用户头像 URL
*
* 实际文件上传通过 /api/upload 路由完成,此 action 仅更新 users.image 字段。
* 实际文件上传通过 /api/upload 路由完成targetType="user_avatar"
* 此 action 仅更新 users.image 字段。
* 更新成功后会清理旧头像文件(磁盘 + DB 记录)。
*/
export async function updateUserAvatarAction(

View File

@@ -0,0 +1,81 @@
"use server"
import { z } from "zod"
import { revalidatePath } from "next/cache"
import type { ActionState } from "@/shared/types/action-state"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getSession } from "@/shared/lib/session"
import { getBrandConfig, saveBrandConfig } from "./data-access-brand"
import type { BrandConfig } from "./brand-config"
const BrandConfigSchema = z.object({
schoolName: z.string().min(1).max(255),
logoUrl: z.string().url().or(z.literal("")).default(""),
testimonialQuote: z.string().min(1).max(500),
testimonialAuthor: z.string().min(1).max(100),
})
/**
* 获取品牌配置 Server Actionaudit-P2-6 新增)
*/
export async function getBrandConfigAction(): Promise<ActionState<BrandConfig>> {
try {
await requirePermission(Permissions.SCHOOL_MANAGE)
const config = await getBrandConfig()
return { success: true, data: config }
} catch (e) {
return {
success: false,
message: e instanceof Error ? e.message : "Failed to get brand config",
}
}
}
/**
* 保存品牌配置 Server Actionaudit-P2-6 新增)
*/
export async function saveBrandConfigAction(
prevState: ActionState<BrandConfig>,
formData: FormData,
): Promise<ActionState<BrandConfig>> {
try {
await requirePermission(Permissions.SCHOOL_MANAGE)
const session = await getSession()
const parsed = BrandConfigSchema.safeParse({
schoolName: formData.get("schoolName"),
logoUrl: formData.get("logoUrl"),
testimonialQuote: formData.get("testimonialQuote"),
testimonialAuthor: formData.get("testimonialAuthor"),
})
if (!parsed.success) {
return {
success: false,
message: parsed.error.issues[0]?.message ?? "Invalid brand config",
}
}
const config: BrandConfig = {
schoolName: parsed.data.schoolName,
logoUrl: parsed.data.logoUrl || null,
testimonialQuote: parsed.data.testimonialQuote,
testimonialAuthor: parsed.data.testimonialAuthor,
}
await saveBrandConfig(config, session?.user?.id)
revalidatePath("/admin/settings")
revalidatePath("/(auth)/login")
revalidatePath("/(auth)/register")
return { success: true, data: config, message: "Brand configuration saved" }
} catch (e) {
return {
success: false,
message: e instanceof Error ? e.message : "Failed to save brand config",
}
}
}

View File

@@ -10,6 +10,7 @@ import { Permissions } from "@/shared/types/permissions"
import { validatePassword } from "@/shared/lib/password-policy"
import { rateLimit, rateLimitKey, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit"
import { normalizeBcryptHash } from "@/shared/lib/bcrypt-utils"
import { checkBreachedPassword } from "@/shared/lib/breached-password"
import {
getPasswordSecurityByUserId,
@@ -38,7 +39,7 @@ export async function changePasswordAction(
const userId = ctx.userId
const limitKey = rateLimitKey("pwd-change", userId)
const limit = rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.PASSWORD_CHANGE })
const limit = await rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.PASSWORD_CHANGE })
if (!limit.success) {
return { success: false, message: "Too many attempts. Please try again later." }
}
@@ -68,6 +69,16 @@ export async function changePasswordAction(
return { success: false, message: validation.errors[0] ?? "Password does not meet requirements" }
}
// audit-P2-4: Breached password 检测HIBP k-anonymity API
// fail-openAPI 不可用时跳过检查,避免外部依赖阻断改密流程
const breachCheck = await checkBreachedPassword(newPassword)
if (breachCheck.isBreached) {
return {
success: false,
message: "This password has appeared in a known data breach. Please choose a different password.",
}
}
// Parallelize user and passwordSecurity queries
const [userRecord, existingSecurity] = await Promise.all([
getUserPasswordHash(userId),

View File

@@ -7,8 +7,9 @@ import type { ActionState } from "@/shared/types/action-state"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { db } from "@/shared/db"
import { loginLogs, sessions } from "@/shared/db/schema"
import { loginLogs } from "@/shared/db/schema"
import { logLoginEvent } from "@/shared/lib/login-logger"
import { trackAuthEvent } from "@/shared/lib/track-event"
import { getUserProfile } from "@/modules/users/data-access"
import {
@@ -239,6 +240,11 @@ export async function verifyTwoFactorAction(
const status = await getTwoFactorStatus(ctx.userId)
// audit-P1-92FA 启用埋点(用于 2FA 启用率统计)
await trackAuthEvent("auth.2fa_enabled", {
userId: ctx.userId,
})
return {
success: true,
data: { backupCodes, status },
@@ -298,6 +304,12 @@ export async function disableTwoFactorAction(
revalidatePath("/settings")
const status = await getTwoFactorStatus(ctx.userId)
// audit-P1-92FA 禁用埋点(用于 2FA 禁用率告警,可能表明账户安全降级)
await trackAuthEvent("auth.2fa_disabled", {
userId: ctx.userId,
})
return { success: true, data: status }
} catch (error) {
const message =
@@ -369,27 +381,20 @@ export async function revokeAllOtherSessionsAction(): Promise<
return { success: false, message: "User not found" }
}
// 删除 sessions 表中该用户的所有记录
const result = await db
.delete(sessions)
.where(eq(sessions.userId, ctx.userId))
// audit-P1-9JWT 策略下 sessions 表无数据,此 Action 为 no-op。
// 真正的"远程登出其他设备"需要 JWT 黑名单或短期 token + refresh token
// 当前架构不支持。保留此 Action 是为了:
// 1. 前端 UI 不会因 Action 缺失而报错
// 2. 记录用户"主动登出其他设备"的意图(用于安全审计)
const revokedCount = 0
// MySqlRawQueryResult 是 [rows, fields] 元组rows 可能含 affectedRows
const rows = Array.isArray(result) ? result[0] : result
const revokedCount =
typeof rows === "object" && rows !== null && "affectedRows" in rows
? Number((rows as { affectedRows: unknown }).affectedRows)
: 0
// 记录一条安全处置日志
await logLoginEvent({
userId: ctx.userId,
userEmail: profile.email,
action: "signout",
status: "success",
errorMessage: revokedCount > 0
? `Remote logout: revoked ${revokedCount} session(s)`
: "Remote logout: no active DB sessions (JWT-based)",
errorMessage:
"Remote logout requested (JWT-based, no active DB sessions to revoke)",
})
revalidatePath("/settings")

View File

@@ -10,9 +10,8 @@ import { Permissions } from "@/shared/types/permissions"
import {
getAllSystemSettings,
upsertSystemSettings,
type SystemSettingCategory,
type SystemSettingValueType,
} from "./data-access-system-settings"
import { toSettingItem } from "./lib/system-settings-utils"
// --- Schemas ---
@@ -53,27 +52,6 @@ const AdminSettingsFormSchema = z.object({
type AdminSettingsFormValues = z.infer<typeof AdminSettingsFormSchema>
// --- Helpers ---
function toSettingItem(
category: SystemSettingCategory,
key: string,
value: unknown,
valueType: SystemSettingValueType
): { category: SystemSettingCategory; key: string; value: string; valueType: SystemSettingValueType } {
let strValue: string
if (valueType === "json") {
strValue = JSON.stringify(value)
} else if (valueType === "boolean") {
strValue = value ? "true" : "false"
} else if (valueType === "number") {
strValue = String(value)
} else {
strValue = String(value ?? "")
}
return { category, key, value: strValue, valueType }
}
// --- Actions ---
/**

View File

@@ -26,7 +26,7 @@ import type { AiProviderSummary, AiProviderVisibility } from "./types"
export type { AiProviderSummary } from "./types"
const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom"])
const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom", "ollama"])
const VisibilitySchema = z.enum(["public", "private"])
const AiProviderFormSchema = z.object({
@@ -42,6 +42,8 @@ const AiProviderFormSchema = z.object({
const AiProviderTestSchema = AiProviderFormSchema.extend({
apiKey: z.string().optional(),
}).superRefine((data, ctx) => {
// Ollama 本地部署无需 API Key
if (data.provider === "ollama") return
if (!data.apiKey?.trim() && !data.id?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -51,6 +53,15 @@ const AiProviderTestSchema = AiProviderFormSchema.extend({
}
})
/** Ollama 默认 baseUrlOpenAI 兼容端点) */
const OLLAMA_DEFAULT_BASE_URL = "http://localhost:11434/v1"
/** Ollama 本地部署无需 API Key使用占位符满足 NOT NULL 约束 */
const OLLAMA_PLACEHOLDER_API_KEY = "ollama"
/** 判断是否为不需要 API Key 的本地 Provider */
const isLocalProvider = (provider: string): boolean => provider === "ollama"
/**
* 校验当前用户身份,返回 { id, isAdmin }
*
@@ -71,6 +82,22 @@ const normalizeBaseUrl = (value: string | undefined): string | null => {
.replace(/\/chat\/completions$/i, "")
}
/**
* 解析 Provider 的 baseUrl应用默认值
*
* - Ollama未提供时使用默认本地地址 http://localhost:11434/v1
* - 其他 Provider未提供时返回 null由调用方校验
*/
const resolveBaseUrl = (
provider: string,
raw: string | undefined
): string | null => {
const normalized = normalizeBaseUrl(raw)
if (normalized) return normalized
if (isLocalProvider(provider)) return OLLAMA_DEFAULT_BASE_URL
return null
}
/**
* 获取当前用户可见的 AI Provider 列表
*
@@ -101,8 +128,8 @@ export async function upsertAiProviderAction(
}
const payload = parsed.data
const baseUrl = normalizeBaseUrl(payload.baseUrl)
if (payload.provider !== "openai" && !baseUrl) {
const baseUrl = resolveBaseUrl(payload.provider, payload.baseUrl)
if (!isLocalProvider(payload.provider) && !baseUrl) {
return { success: false, message: "Base URL is required for this provider" }
}
@@ -124,9 +151,15 @@ export async function upsertAiProviderAction(
const id = payload.id
if (!existing) return { success: false, message: "AI provider not found" }
// Ollama 无需 API Key未提供时使用占位符仅新建时更新时保留原值
const nextKey = payload.apiKey?.trim()
const encrypted = nextKey ? encryptAiApiKey(nextKey) : existing.apiKeyEncrypted
const last4 = nextKey ? nextKey.slice(-4) : existing.apiKeyLast4
const effectiveKey = nextKey
? nextKey
: isLocalProvider(payload.provider) && !existing.apiKeyEncrypted
? OLLAMA_PLACEHOLDER_API_KEY
: null
const encrypted = effectiveKey ? encryptAiApiKey(effectiveKey) : existing.apiKeyEncrypted
const last4 = effectiveKey ? effectiveKey.slice(-4) : existing.apiKeyLast4
const isNextDefault =
payload.isDefault === false && existing.isDefault && defaultCount <= 1
@@ -153,13 +186,16 @@ export async function upsertAiProviderAction(
return { success: true, message: "AI provider updated", data: id }
}
if (!payload.apiKey) {
// 新建 ProviderOllama 允许无 API Key使用占位符
const rawApiKey = payload.apiKey?.trim()
if (!rawApiKey && !isLocalProvider(payload.provider)) {
return { success: false, message: "API key is required" }
}
const effectiveApiKey = rawApiKey ?? OLLAMA_PLACEHOLDER_API_KEY
const id = createId()
const encrypted = encryptAiApiKey(payload.apiKey.trim())
const last4 = payload.apiKey.trim().slice(-4)
const encrypted = encryptAiApiKey(effectiveApiKey)
const last4 = effectiveApiKey.slice(-4)
const shouldMakeDefault = payload.isDefault ?? !hasDefault
await createAiProvider(
@@ -198,14 +234,16 @@ export async function testAiProviderAction(
return { success: false, message: "Invalid form data" }
}
const payload = parsed.data
const baseUrl = normalizeBaseUrl(payload.baseUrl)
if (payload.provider !== "openai" && !baseUrl) {
const baseUrl = resolveBaseUrl(payload.provider, payload.baseUrl)
if (!isLocalProvider(payload.provider) && !baseUrl) {
return { success: false, message: "Base URL is required for this provider" }
}
const model = payload.model.trim()
const apiKey = payload.apiKey?.trim()
if (apiKey) {
await testAiProviderConfig({ apiKey, baseUrl: baseUrl ?? undefined, model })
// Ollama 无 API Key 时使用占位符进行测试
const effectiveApiKey = apiKey ?? (isLocalProvider(payload.provider) ? OLLAMA_PLACEHOLDER_API_KEY : undefined)
if (effectiveApiKey) {
await testAiProviderConfig({ apiKey: effectiveApiKey, baseUrl: baseUrl ?? undefined, model })
} else if (payload.id) {
await testAiProviderById(payload.id, { baseUrl: baseUrl ?? undefined, model })
}

View File

@@ -0,0 +1,27 @@
/**
* 品牌配置类型与默认值audit-P2-6 新增)
*
* 纯类型 + 常量,无 `server-only`,可被 Server / Client Component 安全导入。
* 数据访问函数在 `data-access-brand.ts`server-only
*/
/** 品牌配置 */
export interface BrandConfig {
/** 学校/品牌名称(显示在 AuthLayout 左上角) */
schoolName: string
/** Logo URL可选未设置时使用默认 GraduationCap 图标) */
logoUrl: string | null
/** 标语/引用语(显示在 AuthLayout 左下角 blockquote */
testimonialQuote: string
/** 标语作者 */
testimonialAuthor: string
}
/** 默认品牌配置(数据库未配置时使用) */
export const DEFAULT_BRAND_CONFIG: BrandConfig = {
schoolName: "Next_Edu",
logoUrl: null,
testimonialQuote:
"This platform has completely transformed how we deliver education to our students. The attention to detail and performance is unmatched.",
testimonialAuthor: "Sofia Davis",
}

View File

@@ -0,0 +1,66 @@
"use client"
import { useTranslations } from "next-intl"
import { Database } from "lucide-react"
import { type ReactElement } from "react"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
export interface FileUploadValues {
maxFileSize: number
allowedTypes: string
}
interface FileUploadCardProps {
values: FileUploadValues
onChange: (key: keyof FileUploadValues, value: number | string) => void
}
/**
* 管理员系统设置 - 文件上传卡片
*/
export function FileUploadCard({ values, onChange }: FileUploadCardProps): ReactElement {
const t = useTranslations("settings.admin.fileUpload")
return (
<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("title")}</CardTitle>
<CardDescription>{t("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("maxFileSize")}</Label>
<Input
id="max-file-size"
name="maxFileSize"
type="number"
min={1}
max={100}
value={values.maxFileSize}
onChange={(e) => onChange("maxFileSize", Number(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="allowed-types">{t("allowedTypes")}</Label>
<Input
id="allowed-types"
name="allowedTypes"
placeholder={t("allowedTypesPlaceholder")}
value={values.allowedTypes}
onChange={(e) => onChange("allowedTypes", e.target.value)}
/>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,79 @@
"use client"
import { useTranslations } from "next-intl"
import { Bell } from "lucide-react"
import { type ReactElement } from "react"
import { Label } from "@/shared/components/ui/label"
import { Switch } from "@/shared/components/ui/switch"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
export interface NotificationConfigValues {
notifyNewUser: boolean
notifyScheduleChange: boolean
notifyAnnouncement: boolean
}
interface NotificationConfigCardProps {
values: NotificationConfigValues
onChange: (key: keyof NotificationConfigValues, value: boolean) => void
}
/**
* 管理员系统设置 - 通知配置卡片
*/
export function NotificationConfigCard({ values, onChange }: NotificationConfigCardProps): ReactElement {
const t = useTranslations("settings.admin.notificationConfig")
return (
<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("title")}</CardTitle>
<CardDescription>{t("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("notifyNewUser")}</Label>
<p className="text-sm text-muted-foreground">{t("notifyNewUserDesc")}</p>
</div>
<Switch
id="notify-new-user"
name="notifyNewUser"
checked={values.notifyNewUser}
onCheckedChange={(v) => onChange("notifyNewUser", v)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-schedule-change">{t("notifyScheduleChange")}</Label>
<p className="text-sm text-muted-foreground">{t("notifyScheduleChangeDesc")}</p>
</div>
<Switch
id="notify-schedule-change"
name="notifyScheduleChange"
checked={values.notifyScheduleChange}
onCheckedChange={(v) => onChange("notifyScheduleChange", v)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-announcement">{t("notifyAnnouncement")}</Label>
<p className="text-sm text-muted-foreground">{t("notifyAnnouncementDesc")}</p>
</div>
<Switch
id="notify-announcement"
name="notifyAnnouncement"
checked={values.notifyAnnouncement}
onCheckedChange={(v) => onChange("notifyAnnouncement", v)}
/>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,113 @@
"use client"
import { useTranslations } from "next-intl"
import { School } from "lucide-react"
import { type ReactElement } from "react"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
export interface SchoolInfoValues {
schoolName: string
schoolCode: string
schoolPhone: string
schoolEmail: string
schoolAddress: string
schoolDescription: string
}
interface SchoolInfoCardProps {
values: SchoolInfoValues
onChange: (key: keyof SchoolInfoValues, value: string) => void
}
/**
* 管理员系统设置 - 学校信息卡片
*/
export function SchoolInfoCard({ values, onChange }: SchoolInfoCardProps): ReactElement {
const t = useTranslations("settings.admin.schoolInfo")
return (
<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("title")}</CardTitle>
<CardDescription>{t("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("name")}</Label>
<Input
id="school-name"
name="schoolName"
placeholder={t("namePlaceholder")}
value={values.schoolName}
onChange={(e) => onChange("schoolName", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="school-code">{t("code")}</Label>
<Input
id="school-code"
name="schoolCode"
placeholder={t("codePlaceholder")}
value={values.schoolCode}
onChange={(e) => onChange("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("phone")}</Label>
<Input
id="school-phone"
name="schoolPhone"
placeholder={t("phonePlaceholder")}
value={values.schoolPhone}
onChange={(e) => onChange("schoolPhone", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="school-email">{t("email")}</Label>
<Input
id="school-email"
name="schoolEmail"
type="email"
placeholder={t("emailPlaceholder")}
value={values.schoolEmail}
onChange={(e) => onChange("schoolEmail", e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="school-address">{t("address")}</Label>
<Input
id="school-address"
name="schoolAddress"
placeholder={t("addressPlaceholder")}
value={values.schoolAddress}
onChange={(e) => onChange("schoolAddress", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="school-desc">{t("description2")}</Label>
<Textarea
id="school-desc"
name="schoolDescription"
placeholder={t("descriptionPlaceholder")}
rows={3}
value={values.schoolDescription}
onChange={(e) => onChange("schoolDescription", e.target.value)}
/>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,110 @@
"use client"
import { useTranslations } from "next-intl"
import { Shield } from "lucide-react"
import { type ReactElement } from "react"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
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"
export interface SecurityPolicyValues {
passwordMinLength: number
sessionTimeout: number
requireSpecialChar: boolean
requireUppercase: boolean
forcePasswordChange: boolean
}
interface SecurityPolicyCardProps {
values: SecurityPolicyValues
onChange: (key: keyof SecurityPolicyValues, value: number | boolean) => void
}
/**
* 管理员系统设置 - 安全策略卡片
*/
export function SecurityPolicyCard({ values, onChange }: SecurityPolicyCardProps): ReactElement {
const t = useTranslations("settings.admin.securityPolicy")
return (
<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("title")}</CardTitle>
<CardDescription>{t("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("passwordMinLength")}</Label>
<Input
id="password-min-length"
name="passwordMinLength"
type="number"
min={6}
max={32}
value={values.passwordMinLength}
onChange={(e) => onChange("passwordMinLength", Number(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="session-timeout">{t("sessionTimeout")}</Label>
<Input
id="session-timeout"
name="sessionTimeout"
type="number"
min={5}
max={1440}
value={values.sessionTimeout}
onChange={(e) => onChange("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("requireSpecialChar")}</Label>
<p className="text-sm text-muted-foreground">{t("requireSpecialCharDesc")}</p>
</div>
<Switch
id="require-special-char"
name="requireSpecialChar"
checked={values.requireSpecialChar}
onCheckedChange={(v) => onChange("requireSpecialChar", v)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="require-uppercase">{t("requireUppercase")}</Label>
<p className="text-sm text-muted-foreground">{t("requireUppercaseDesc")}</p>
</div>
<Switch
id="require-uppercase"
name="requireUppercase"
checked={values.requireUppercase}
onCheckedChange={(v) => onChange("requireUppercase", v)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="force-password-change">{t("forcePasswordChange")}</Label>
<p className="text-sm text-muted-foreground">{t("forcePasswordChangeDesc")}</p>
</div>
<Switch
id="force-password-change"
name="forcePasswordChange"
checked={values.forcePasswordChange}
onCheckedChange={(v) => onChange("forcePasswordChange", v)}
/>
</div>
</CardContent>
</Card>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,68 @@
"use client"
import { useTranslations } from "next-intl"
import { Loader2, Trash2 } from "lucide-react"
import { type ReactElement } from "react"
import { Button } from "@/shared/components/ui/button"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/components/ui/alert-dialog"
interface AiProviderDeleteDialogProps {
/** 是否禁用删除按钮(无选中项或正在执行其他操作时) */
disabled: boolean
/** 是否正在执行删除操作 */
isPending: boolean
/** 确认删除回调 */
onConfirm: () => void
}
/**
* AI 服务商删除确认对话框
*
* 仅负责删除确认交互,具体删除逻辑由父组件通过 onConfirm 回调注入。
*/
export function AiProviderDeleteDialog({
disabled,
isPending,
onConfirm,
}: AiProviderDeleteDialogProps): ReactElement {
const t = useTranslations("settings.ai.providers")
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="destructive"
disabled={disabled}
>
<Trash2 className="mr-2 h-4 w-4" />
{t("delete")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>{t("deleteConfirmDescription")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("deleteCancel")}</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>
{isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t("deleteConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,108 @@
"use client"
import { useTranslations } from "next-intl"
import { type ReactElement } from "react"
import { type AiProviderSummary } from "@/modules/settings/actions"
import { Badge } from "@/shared/components/ui/badge"
import { Label } from "@/shared/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
const NEW_PROVIDER_VALUE = "__new__"
interface AiProviderSelectorProps {
/** 已有服务商列表 */
providers: AiProviderSummary[]
/** 当前选中的服务商 ID空字符串表示新建 */
selectedId: string
/** 当前用户 ID用于判断是否为创建者 */
currentUserId?: string
/** 选择变更回调 */
onSelectChange: (value: string) => void
}
/**
* AI 服务商选择器
*
* 负责:
* - 渲染已有服务商下拉选择(含"新建"选项)
* - 展示当前选中服务商的密钥状态与可见性徽章
*
* 不包含任何业务逻辑,仅做展示与事件转发。
*/
export function AiProviderSelector({
providers,
selectedId,
currentUserId,
onSelectChange,
}: AiProviderSelectorProps): ReactElement {
const t = useTranslations("settings.ai.providers")
const selectedProvider = providers.find((item) => item.id === selectedId) ?? null
const renderProviderLabel = (item: AiProviderSummary): string => {
const parts = [item.provider, "·", item.model]
if (item.isDefault) parts.push(`(${t("setDefault")})`)
return parts.join(" ")
}
const renderVisibilityBadge = (item: AiProviderSummary): ReactElement => {
const isOwner = currentUserId !== undefined && item.createdBy === currentUserId
return (
<span className="ml-2 inline-flex gap-1">
{item.visibility === "public" ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4">
{t("badgePublic")}
</Badge>
) : (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4">
{t("badgePrivate")}
</Badge>
)}
{isOwner ? (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 text-muted-foreground">
{t("badgeOwner")}
</Badge>
) : null}
</span>
)
}
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t("existing")}</Label>
<Select value={selectedId || NEW_PROVIDER_VALUE} onValueChange={onSelectChange}>
<SelectTrigger>
<SelectValue placeholder={t("selectPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NEW_PROVIDER_VALUE}>{t("createNew")}</SelectItem>
{providers.map((item) => (
<SelectItem key={item.id} value={item.id}>
{renderProviderLabel(item)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t("keyStatus")}</Label>
<div className="flex items-center rounded-md border px-3 py-2 text-sm text-muted-foreground">
{selectedProvider ? renderVisibilityBadge(selectedProvider) : null}
<span className="ml-auto">
{selectedProvider?.apiKeyLast4
? `${t("stored")} • ****${selectedProvider.apiKeyLast4}`
: t("noKey")}
</span>
</div>
</div>
</div>
)
}

View File

@@ -1,28 +1,16 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState, useTransition, type ReactElement } from "react"
import { useCallback, useEffect, useRef, useState, useTransition, type ReactElement } from "react"
import { useTranslations } from "next-intl"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { Loader2, Save, Sparkles, Trash2 } from "lucide-react"
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 { Checkbox } from "@/shared/components/ui/checkbox"
import { Label } from "@/shared/components/ui/label"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/components/ui/alert-dialog"
import {
Form,
FormControl,
@@ -40,10 +28,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Badge } from "@/shared/components/ui/badge"
import { deleteAiProviderAction, getAiProviderSummaries, testAiProviderAction, upsertAiProviderAction, type AiProviderSummary } from "@/modules/settings/actions"
import { AiProviderSelector } from "@/modules/settings/components/ai-provider-selector"
import { AiProviderDeleteDialog } from "@/modules/settings/components/ai-provider-delete-dialog"
const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom"])
const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom", "ollama"])
const VisibilitySchema = z.enum(["public", "private"])
const AiProviderFormSchema = z.object({
@@ -58,8 +47,6 @@ const AiProviderFormSchema = z.object({
type AiProviderFormValues = z.infer<typeof AiProviderFormSchema>
const NEW_PROVIDER_VALUE = "__new__"
type AiProviderSettingsCardProps = {
onProvidersChanged?: (rows: AiProviderSummary[]) => void
initialMode?: "new" | "first"
@@ -72,7 +59,7 @@ export function AiProviderSettingsCard({
initialMode = "first",
isAdmin = false,
currentUserId,
}: AiProviderSettingsCardProps) {
}: AiProviderSettingsCardProps): ReactElement {
const t = useTranslations("settings.ai.providers")
const [isPending, startTransition] = useTransition()
const [providers, setProviders] = useState<AiProviderSummary[]>([])
@@ -94,11 +81,6 @@ export function AiProviderSettingsCard({
},
})
const selectedProvider = useMemo(
() => providers.find((item) => item.id === selectedId) ?? null,
[providers, selectedId]
)
const buildSignature = useCallback((values: AiProviderFormValues) => {
return JSON.stringify({
provider: values.provider,
@@ -160,7 +142,7 @@ export function AiProviderSettingsCard({
}, [form, selectedId, onProvidersChanged, initialMode, resetToNew, t])
const handleSelectChange = (value: string) => {
if (value === NEW_PROVIDER_VALUE) {
if (value === "__new__") {
resetToNew()
return
}
@@ -194,7 +176,8 @@ export function AiProviderSettingsCard({
const handleTest = () => {
const values = form.getValues()
const apiKey = values.apiKey?.trim()
if (!apiKey && !values.id?.trim()) {
const isLocalProvider = values.provider === "ollama"
if (!apiKey && !values.id?.trim() && !isLocalProvider) {
toast.error(t("needKey"))
return
}
@@ -309,34 +292,6 @@ export function AiProviderSettingsCard({
})
}
const renderProviderLabel = (item: AiProviderSummary): string => {
const parts = [item.provider, "·", item.model]
if (item.isDefault) parts.push(`(${t("setDefault")})`)
return parts.join(" ")
}
const renderVisibilityBadge = (item: AiProviderSummary): ReactElement => {
const isOwner = currentUserId !== undefined && item.createdBy === currentUserId
return (
<span className="ml-2 inline-flex gap-1">
{item.visibility === "public" ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4">
{t("badgePublic")}
</Badge>
) : (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4">
{t("badgePrivate")}
</Badge>
)}
{isOwner ? (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 text-muted-foreground">
{t("badgeOwner")}
</Badge>
) : null}
</span>
)
}
return (
<Card>
<CardHeader>
@@ -347,35 +302,12 @@ export function AiProviderSettingsCard({
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t("existing")}</Label>
<Select value={selectedId || NEW_PROVIDER_VALUE} onValueChange={handleSelectChange}>
<SelectTrigger>
<SelectValue placeholder={t("selectPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NEW_PROVIDER_VALUE}>{t("createNew")}</SelectItem>
{providers.map((item) => (
<SelectItem key={item.id} value={item.id}>
{renderProviderLabel(item)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t("keyStatus")}</Label>
<div className="flex items-center rounded-md border px-3 py-2 text-sm text-muted-foreground">
{selectedProvider ? renderVisibilityBadge(selectedProvider) : null}
<span className="ml-auto">
{selectedProvider?.apiKeyLast4
? `${t("stored")} • ****${selectedProvider.apiKeyLast4}`
: t("noKey")}
</span>
</div>
</div>
</div>
<AiProviderSelector
providers={providers}
selectedId={selectedId}
currentUserId={currentUserId}
onSelectChange={handleSelectChange}
/>
<Form {...form}>
<div className="grid gap-6">
@@ -396,6 +328,7 @@ export function AiProviderSettingsCard({
{ value: "zhipu", label: "Zhipu" },
{ value: "openai", label: "OpenAI" },
{ value: "gemini", label: "Gemini" },
{ value: "ollama", label: "Ollama (Local)" },
{ value: "custom", label: "Custom" },
]}
/>
@@ -403,22 +336,22 @@ export function AiProviderSettingsCard({
control={form.control}
name="baseUrl"
label={t("baseUrl")}
placeholder={t("baseUrlPlaceholder")}
description={t("baseUrlDesc")}
placeholder={form.watch("provider") === "ollama" ? "http://localhost:11434/v1" : t("baseUrlPlaceholder")}
description={form.watch("provider") === "ollama" ? t("baseUrlDescOllama") : t("baseUrlDesc")}
/>
<TextField
control={form.control}
name="model"
label={t("model")}
placeholder={t("modelPlaceholder")}
placeholder={form.watch("provider") === "ollama" ? "llama3.2" : t("modelPlaceholder")}
/>
<TextField
control={form.control}
name="apiKey"
label={t("apiKey")}
type="password"
placeholder={t("apiKeyPlaceholder")}
description={t("apiKeyDesc")}
placeholder={form.watch("provider") === "ollama" ? t("apiKeyPlaceholderOllama") : t("apiKeyPlaceholder")}
description={form.watch("provider") === "ollama" ? t("apiKeyDescOllama") : t("apiKeyDesc")}
itemClassName="sm:col-span-2"
/>
</div>
@@ -467,28 +400,11 @@ export function AiProviderSettingsCard({
/>
<CardFooter className="flex justify-between border-t px-0 pt-4">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="destructive"
disabled={isPending || !form.getValues("id")?.trim()}
>
<Trash2 className="mr-2 h-4 w-4" />
{t("delete")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>{t("deleteConfirmDescription")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("deleteCancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>{t("deleteConfirm")}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AiProviderDeleteDialog
disabled={isPending || !form.getValues("id")?.trim()}
isPending={isPending}
onConfirm={handleDelete}
/>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={handleTest} disabled={isPending || testStatus === "testing"}>
{testStatus === "testing" ? (

View File

@@ -29,7 +29,8 @@ const MAX_FILENAME_LENGTH = 255
* 头像上传组件
*
* 支持上传新头像、预览、删除。
* 文件通过 /api/upload 上传,成功后调用 Server Action 更新 users.image。
* 文件通过 /api/upload 上传targetType="user_avatar" 已注册到 FileTargetType 枚举),
* 成功后调用 Server Action 更新 users.image。
*/
export function AvatarUpload({
currentImage,
@@ -72,7 +73,7 @@ export function AvatarUpload({
setUploading(true)
try {
// 上传文件到 /api/upload
// 上传文件到 /api/uploadtargetType="user_avatar" 已注册到 FileTargetType 枚举
const formData = new FormData()
formData.append("file", file)
formData.append("targetType", "user_avatar")

View File

@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Loader2, Save } from "lucide-react"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
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 { getBrandConfigAction, saveBrandConfigAction } from "@/modules/settings/actions-brand"
import type { BrandConfig } from "@/modules/settings/brand-config"
import { DEFAULT_BRAND_CONFIG } from "@/modules/settings/brand-config"
/**
* 品牌配置卡片audit-P2-6 新增)
*
* 管理员可配置学校品牌信息(名称/Logo/标语),显示在认证页面 AuthLayout。
* 独立组件,不嵌入 AdminSettingsView 的统一表单状态,自行管理加载与保存。
*/
export function BrandConfigCard(): React.ReactElement {
const t = useTranslations("settings.brand")
const [config, setConfig] = React.useState<BrandConfig>(DEFAULT_BRAND_CONFIG)
const [isLoading, setIsLoading] = React.useState(true)
const [isSaving, setIsSaving] = React.useState(false)
React.useEffect(() => {
let cancelled = false
async function loadConfig(): Promise<void> {
setIsLoading(true)
try {
const res = await getBrandConfigAction()
if (!cancelled && res.success && res.data) {
setConfig(res.data)
}
} catch {
if (!cancelled) toast.error(t("loadFailed"))
} finally {
if (!cancelled) setIsLoading(false)
}
}
void loadConfig()
return () => {
cancelled = true
}
}, [t])
async function handleSave(e: React.FormEvent): Promise<void> {
e.preventDefault()
setIsSaving(true)
try {
const formData = new FormData()
formData.set("schoolName", config.schoolName)
formData.set("logoUrl", config.logoUrl ?? "")
formData.set("testimonialQuote", config.testimonialQuote)
formData.set("testimonialAuthor", config.testimonialAuthor)
const res = await saveBrandConfigAction({ success: false }, formData)
if (res.success) {
toast.success(t("saveSuccess"))
} else {
toast.error(res.message ?? t("saveFailed"))
}
} catch {
toast.error(t("saveFailed"))
} finally {
setIsSaving(false)
}
}
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle>{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSave} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="brand-schoolName">{t("schoolName")}</Label>
<Input
id="brand-schoolName"
value={config.schoolName}
onChange={(e) => setConfig({ ...config, schoolName: e.target.value })}
maxLength={255}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="brand-logoUrl">{t("logoUrl")}</Label>
<Input
id="brand-logoUrl"
type="url"
value={config.logoUrl ?? ""}
onChange={(e) => setConfig({ ...config, logoUrl: e.target.value || null })}
placeholder="https://..."
/>
<p className="text-xs text-muted-foreground">{t("logoUrlDescription")}</p>
</div>
<div className="space-y-2">
<Label htmlFor="brand-quote">{t("testimonialQuote")}</Label>
<Textarea
id="brand-quote"
value={config.testimonialQuote}
onChange={(e) => setConfig({ ...config, testimonialQuote: e.target.value })}
maxLength={500}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="brand-author">{t("testimonialAuthor")}</Label>
<Input
id="brand-author"
value={config.testimonialAuthor}
onChange={(e) => setConfig({ ...config, testimonialAuthor: e.target.value })}
maxLength={100}
required
/>
</div>
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{t("save")}
</Button>
</form>
</CardContent>
</Card>
)
}

View File

@@ -5,8 +5,7 @@ import { StudentGradesCard } from "@/modules/dashboard/components/student-dashbo
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 { getStudentProfileOverviewData } from "@/modules/settings/data-access-profile-overview"
import { buildStudentOverviewData } from "@/modules/settings/lib/student-overview-data"
import { Separator } from "@/shared/components/ui/separator"
@@ -18,23 +17,21 @@ interface ProfileStudentOverviewProps {
* 学生概览区块Server Component
*
* 独立获取学生数据并渲染,可被 Suspense + ErrorBoundary 包裹实现流式渲染与局部容错。
* 数据获取通过 settings 模块自身的 data-access-profile-overview 层调用,
* 不直接 import classes/homework 的 data-access。
*/
export async function ProfileStudentOverview({
userId,
}: ProfileStudentOverviewProps): Promise<ReactElement> {
const t = await getTranslations("settings.profilePage.studentOverview")
const t = await getTranslations("settings.profile.studentOverview")
const [classes, schedule, assignmentsAll, grades] = await Promise.all([
getStudentClasses(userId),
getStudentSchedule(userId),
getStudentHomeworkAssignments(userId),
getStudentDashboardGrades(userId),
])
const { classes, schedule, assignments, grades } =
await getStudentProfileOverviewData(userId)
const data = buildStudentOverviewData({
classes,
schedule,
assignments: assignmentsAll,
assignments,
grades,
})
@@ -90,4 +87,3 @@ export function ProfileStudentOverviewSkeleton(): ReactElement {
</div>
)
}

View File

@@ -3,7 +3,7 @@ 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 { getTeacherProfileOverviewData } from "@/modules/settings/data-access-profile-overview"
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"
@@ -18,16 +18,15 @@ interface ProfileTeacherOverviewProps {
* 教师概览区块Server Component
*
* 独立获取教师数据并渲染,可被 Suspense + ErrorBoundary 包裹实现流式渲染与局部容错。
* 数据获取通过 settings 模块自身的 data-access-profile-overview 层调用,
* 不直接 import classes 的 data-access。
*/
export async function ProfileTeacherOverview(
_props: ProfileTeacherOverviewProps = {}
): Promise<ReactElement> {
const t = await getTranslations("settings.profilePage.teacherOverview")
const t = await getTranslations("settings.profile.teacherOverview")
const [subjects, classes] = await Promise.all([
getTeacherTeachingSubjects(),
getTeacherClasses(),
])
const { subjects, classes } = await getTeacherProfileOverviewData()
return (
<div className="space-y-6">

View File

@@ -1,42 +1,14 @@
"use client"
import * as React from "react"
import { useLocale, useTranslations } from "next-intl"
import { toast } from "sonner"
import {
ShieldCheck,
Smartphone,
Loader2,
LogIn,
LogOut,
UserPlus,
AlertCircle,
LogOutIcon,
KeyRound,
Copy,
Check,
RefreshCw,
} from "lucide-react"
import { useTranslations } from "next-intl"
import { ShieldCheck } from "lucide-react"
import {
disableTwoFactorAction,
getSecurityCenterAction,
regenerateBackupCodesAction,
revokeAllOtherSessionsAction,
setupTwoFactorAction,
verifyTwoFactorAction,
type LoginHistoryItem,
type TwoFactorSetupData,
type TwoFactorStatus,
} from "@/modules/settings/actions-security"
import {
formatRelativeTime,
parseUserAgent,
} from "@/modules/settings/lib/security-utils"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import {
Card,
CardContent,
@@ -44,226 +16,63 @@ import {
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { SecurityTwoFactorSection } from "@/modules/settings/components/security-two-factor-section"
import { SecurityRecentLoginsSection } from "@/modules/settings/components/security-recent-logins-section"
interface SecurityCenterCardProps {
/** 当前会话的 user agent用于标记当前会话 */
currentDeviceLabel?: string
}
const ACTION_ICON_MAP: Record<LoginHistoryItem["action"], React.ReactNode> = {
signin: <LogIn className="h-4 w-4" />,
signout: <LogOut className="h-4 w-4" />,
signup: <UserPlus className="h-4 w-4" />,
}
type SetupStep = "idle" | "qr" | "backup"
/**
* 安全中心卡片
*
* 提供
* - 2FA TOTP 完整流程(启用 / 关闭 / 重新生成备份码)
* - 最近登录历史(最近 10 条,来自 login_logs 表)
* - 远程登出其他会话
* 作为容器组件负责
* - 加载 2FA 状态与最近登录记录
* - 编排 TwoFactor / RecentLogins 两个子区块
*
* 具体交互逻辑封装在子组件中,本组件仅做数据加载与状态分发。
*/
export function SecurityCenterCard({
currentDeviceLabel,
}: SecurityCenterCardProps): React.ReactElement {
const t = useTranslations("settings.security.center")
const locale = useLocale()
const [twoFactor, setTwoFactor] = React.useState<TwoFactorStatus | null>(null)
const [recentLogins, setRecentLogins] = React.useState<LoginHistoryItem[]>([])
const [loading, setLoading] = React.useState(true)
const [revoking, setRevoking] = React.useState(false)
// 启用 2FA Dialog 状态
const [enableDialogOpen, setEnableDialogOpen] = React.useState(false)
const [setupStep, setSetupStep] = React.useState<SetupStep>("idle")
const [setupData, setSetupData] = React.useState<TwoFactorSetupData | null>(null)
const [verifyCode, setVerifyCode] = React.useState("")
const [backupCodes, setBackupCodes] = React.useState<string[]>([])
const [setupLoading, setSetupLoading] = React.useState(false)
const [copied, setCopied] = React.useState(false)
// 关闭 2FA Dialog 状态
const [disableDialogOpen, setDisableDialogOpen] = React.useState(false)
const [disableCode, setDisableCode] = React.useState("")
const [disableLoading, setDisableLoading] = React.useState(false)
// 重新生成备份码 Dialog 状态
const [regenDialogOpen, setRegenDialogOpen] = React.useState(false)
const [regenCode, setRegenCode] = React.useState("")
const [regenLoading, setRegenLoading] = React.useState(false)
const [regenBackupCodes, setRegenBackupCodes] = React.useState<string[]>([])
const loadData = React.useCallback(async (): Promise<void> => {
try {
const result = await getSecurityCenterAction()
if (result.success && result.data) {
setTwoFactor(result.data.twoFactor)
setRecentLogins(result.data.recentLogins)
}
} catch {
// 加载失败时静默处理,子组件会展示空状态
} finally {
setLoading(false)
}
}, [])
React.useEffect(() => {
let cancelled = false
async function load(): Promise<void> {
try {
const result = await getSecurityCenterAction()
if (!cancelled && result.success && result.data) {
setTwoFactor(result.data.twoFactor)
setRecentLogins(result.data.recentLogins)
}
} catch {
// 加载失败时静默处理
} finally {
if (!cancelled) setLoading(false)
}
if (!cancelled) await loadData()
}
void load()
return () => {
cancelled = true
}
}, [])
}, [loadData])
// --- 启用 2FA 流程 ---
const handleEnable2FA = async (): Promise<void> => {
setEnableDialogOpen(true)
setSetupStep("idle")
setSetupData(null)
setVerifyCode("")
setBackupCodes([])
setSetupLoading(true)
try {
const result = await setupTwoFactorAction()
if (result.success && result.data) {
setSetupData(result.data)
setSetupStep("qr")
} else {
toast.error(result.message || t("twoFactor.setupFailure"))
setEnableDialogOpen(false)
}
} catch {
toast.error(t("twoFactor.setupFailure"))
setEnableDialogOpen(false)
} finally {
setSetupLoading(false)
}
const handleTwoFactorChange = (status: TwoFactorStatus): void => {
setTwoFactor(status)
}
const handleVerifySetup = async (): Promise<void> => {
if (!verifyCode.trim()) return
setSetupLoading(true)
try {
const result = await verifyTwoFactorAction(verifyCode.trim())
if (result.success && result.data) {
setBackupCodes(result.data.backupCodes)
setTwoFactor(result.data.status)
setSetupStep("backup")
toast.success(t("twoFactor.enableSuccess"))
} else {
toast.error(result.message || t("twoFactor.invalidCode"))
}
} catch {
toast.error(t("twoFactor.verifyFailure"))
} finally {
setSetupLoading(false)
}
}
const handleCopyBackupCodes = async (): Promise<void> => {
try {
await navigator.clipboard.writeText(backupCodes.join("\n"))
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// 剪贴板不可用时静默
}
}
const handleCloseEnableDialog = (): void => {
setEnableDialogOpen(false)
setSetupStep("idle")
setSetupData(null)
setVerifyCode("")
setBackupCodes([])
}
// --- 关闭 2FA 流程 ---
const handleDisable2FA = async (): Promise<void> => {
if (!disableCode.trim()) return
setDisableLoading(true)
try {
const result = await disableTwoFactorAction(disableCode.trim())
if (result.success && result.data) {
setTwoFactor(result.data)
setDisableDialogOpen(false)
setDisableCode("")
toast.success(t("twoFactor.disableSuccess"))
} else {
toast.error(result.message || t("twoFactor.invalidCode"))
}
} catch {
toast.error(t("twoFactor.disableFailure"))
} finally {
setDisableLoading(false)
}
}
// --- 重新生成备份码 ---
const handleRegenerateBackupCodes = async (): Promise<void> => {
if (!regenCode.trim()) return
setRegenLoading(true)
try {
const result = await regenerateBackupCodesAction(regenCode.trim())
if (result.success && result.data) {
setRegenBackupCodes(result.data.backupCodes)
setTwoFactor(result.data.status)
setRegenCode("")
toast.success(t("twoFactor.regenerateSuccess"))
} else {
toast.error(result.message || t("twoFactor.invalidCode"))
}
} catch {
toast.error(t("twoFactor.regenerateFailure"))
} finally {
setRegenLoading(false)
}
}
const handleRegenDialogOpen = (): void => {
setRegenDialogOpen(true)
setRegenCode("")
setRegenBackupCodes([])
}
// --- 远程登出 ---
const handleRevokeAllSessions = async (): Promise<void> => {
setRevoking(true)
try {
const result = await revokeAllOtherSessionsAction()
if (result.success && result.data) {
if (result.data.revokedCount > 0) {
toast.success(t("recentLogins.revokeSuccess", { count: result.data.revokedCount }))
} else {
toast.info(t("recentLogins.revokeSuccessEmpty"))
}
const refreshed = await getSecurityCenterAction()
if (refreshed.success && refreshed.data) {
setRecentLogins(refreshed.data.recentLogins)
}
} else {
toast.error(result.message || t("recentLogins.revokeFailure"))
}
} catch {
toast.error(t("recentLogins.revokeFailure"))
} finally {
setRevoking(false)
}
const handleRevoked = async (): Promise<void> => {
await loadData()
}
return (
@@ -278,368 +87,18 @@ export function SecurityCenterCard({
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* 2FA 区域 */}
<div className="space-y-3">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex items-start gap-3">
<Smartphone className="mt-0.5 h-5 w-5 text-muted-foreground" />
<div className="space-y-0.5">
<div className="text-sm font-medium">{t("twoFactor.title")}</div>
<p className="text-sm text-muted-foreground">
{t("twoFactor.description")}
</p>
{twoFactor?.enabled ? (
<div className="mt-1 flex items-center gap-2">
<Badge variant="secondary">{t("twoFactor.enabled")}</Badge>
<span className="text-xs text-muted-foreground">
{t("twoFactor.backupRemaining", { count: twoFactor.backupCodesRemaining })}
</span>
</div>
) : null}
</div>
</div>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : twoFactor?.enabled ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setDisableDialogOpen(true)}
>
{t("twoFactor.disable")}
</Button>
) : (
<Button
type="button"
size="sm"
onClick={handleEnable2FA}
>
{t("twoFactor.enable")}
</Button>
)}
</div>
{twoFactor?.enabled ? (
<div className="flex items-center justify-between rounded-lg border border-dashed p-3">
<div className="flex items-start gap-2">
<KeyRound className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div>
<div className="text-xs font-medium">{t("twoFactor.backupCodes")}</div>
<p className="text-xs text-muted-foreground">
{t("twoFactor.backupHint")}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleRegenDialogOpen}
className="h-7 gap-1.5 text-xs"
>
<RefreshCw className="h-3.5 w-3.5" />
{t("twoFactor.regenerate")}
</Button>
</div>
) : (
<p className="flex items-start gap-1.5 text-xs text-muted-foreground">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
{t("twoFactor.hint")}
</p>
)}
</div>
{/* 最近登录历史 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">{t("recentLogins.title")}</h4>
<div className="flex items-center gap-2">
{recentLogins.length > 0 ? (
<span className="text-xs text-muted-foreground">
{t("recentLogins.showingLatest", { count: recentLogins.length })}
</span>
) : null}
{!loading && recentLogins.length > 0 ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRevokeAllSessions}
disabled={revoking}
className="h-7 gap-1.5 text-xs"
>
{revoking ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<LogOutIcon className="h-3.5 w-3.5" />
)}
{revoking ? t("recentLogins.revoking") : t("recentLogins.revokeAll")}
</Button>
) : null}
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : recentLogins.length === 0 ? (
<div className="rounded-md border border-dashed py-6 text-center text-sm text-muted-foreground">
{t("recentLogins.empty")}
</div>
) : (
<ul className="divide-y rounded-md border">
{recentLogins.map((item) => {
const { device, browser } = parseUserAgent(item.userAgent)
const isCurrent = currentDeviceLabel
? item.userAgent?.includes(currentDeviceLabel)
: false
return (
<li
key={item.id}
className="flex items-center gap-3 px-3 py-2.5 text-sm"
>
<span
className={
item.status === "success"
? "text-green-600"
: "text-red-600"
}
>
{ACTION_ICON_MAP[item.action]}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">
{t(`recentLogins.actions.${item.action}`)}
</span>
{item.status === "failure" ? (
<Badge variant="destructive" className="text-xs">
{t("recentLogins.failed")}
</Badge>
) : null}
{isCurrent ? (
<Badge variant="outline" className="text-xs">
{t("recentLogins.current")}
</Badge>
) : null}
</div>
<div className="text-xs text-muted-foreground truncate">
{device} · {browser}
{item.ipAddress ? ` · ${item.ipAddress}` : ""}
</div>
</div>
<time className="text-xs text-muted-foreground whitespace-nowrap">
{formatRelativeTime(item.createdAt, locale)}
</time>
</li>
)
})}
</ul>
)}
</div>
<SecurityTwoFactorSection
twoFactor={twoFactor}
loading={loading}
onStatusChange={handleTwoFactorChange}
/>
<SecurityRecentLoginsSection
recentLogins={recentLogins}
loading={loading}
currentDeviceLabel={currentDeviceLabel}
onRevoked={handleRevoked}
/>
</CardContent>
{/* 启用 2FA Dialog */}
<Dialog open={enableDialogOpen} onOpenChange={(o) => { if (!o) handleCloseEnableDialog() }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("twoFactor.title")}</DialogTitle>
<DialogDescription>{t("twoFactor.description")}</DialogDescription>
</DialogHeader>
{setupStep === "qr" && setupData ? (
<div className="space-y-4">
<div className="flex flex-col items-center gap-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={setupData.qrCodeDataUrl}
alt="2FA QR Code"
className="rounded-md border"
width={240}
height={240}
/>
<p className="text-center text-sm text-muted-foreground">
{t("twoFactor.scanQr")}
</p>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("twoFactor.manualEntry")}</Label>
<code className="block rounded-md bg-muted p-2 text-xs break-all">
{setupData.secret}
</code>
</div>
<div className="space-y-2">
<Label htmlFor="verifyCode">{t("twoFactor.enterCode")}</Label>
<Input
id="verifyCode"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
maxLength={6}
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value)}
disabled={setupLoading}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCloseEnableDialog} disabled={setupLoading}>
{t("twoFactor.cancel")}
</Button>
<Button onClick={handleVerifySetup} disabled={setupLoading || !verifyCode.trim()}>
{setupLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t("twoFactor.verify")}
</Button>
</DialogFooter>
</div>
) : null}
{setupStep === "backup" ? (
<div className="space-y-4">
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
<p className="flex items-start gap-1.5 text-xs text-amber-800 dark:text-amber-200">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
{t("twoFactor.backupWarning")}
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>{t("twoFactor.backupCodes")}</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleCopyBackupCodes}
className="h-7 gap-1.5 text-xs"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? t("twoFactor.copied") : t("twoFactor.copy")}
</Button>
</div>
<div className="grid grid-cols-2 gap-2 rounded-md border p-3">
{backupCodes.map((code, i) => (
<code key={i} className="text-sm font-mono">
{code}
</code>
))}
</div>
</div>
<DialogFooter>
<Button onClick={handleCloseEnableDialog}>
{t("twoFactor.done")}
</Button>
</DialogFooter>
</div>
) : null}
{setupStep === "idle" ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : null}
</DialogContent>
</Dialog>
{/* 关闭 2FA Dialog */}
<Dialog open={disableDialogOpen} onOpenChange={setDisableDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("twoFactor.disableTitle")}</DialogTitle>
<DialogDescription>{t("twoFactor.disableDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="disableCode">{t("twoFactor.enterCodeDisable")}</Label>
<Input
id="disableCode"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
maxLength={8}
value={disableCode}
onChange={(e) => setDisableCode(e.target.value)}
disabled={disableLoading}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDisableDialogOpen(false)} disabled={disableLoading}>
{t("twoFactor.cancel")}
</Button>
<Button
variant="destructive"
onClick={handleDisable2FA}
disabled={disableLoading || !disableCode.trim()}
>
{disableLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t("twoFactor.disable")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 重新生成备份码 Dialog */}
<Dialog open={regenDialogOpen} onOpenChange={setRegenDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("twoFactor.regenerateTitle")}</DialogTitle>
<DialogDescription>{t("twoFactor.regenerateDescription")}</DialogDescription>
</DialogHeader>
{regenBackupCodes.length === 0 ? (
<>
<div className="space-y-2">
<Label htmlFor="regenCode">{t("twoFactor.enterCodeRegen")}</Label>
<Input
id="regenCode"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
maxLength={6}
value={regenCode}
onChange={(e) => setRegenCode(e.target.value)}
disabled={regenLoading}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRegenDialogOpen(false)} disabled={regenLoading}>
{t("twoFactor.cancel")}
</Button>
<Button
onClick={handleRegenerateBackupCodes}
disabled={regenLoading || !regenCode.trim()}
>
{regenLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t("twoFactor.regenerate")}
</Button>
</DialogFooter>
</>
) : (
<div className="space-y-4">
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
<p className="flex items-start gap-1.5 text-xs text-amber-800 dark:text-amber-200">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
{t("twoFactor.backupWarning")}
</p>
</div>
<div className="grid grid-cols-2 gap-2 rounded-md border p-3">
{regenBackupCodes.map((code, i) => (
<code key={i} className="text-sm font-mono">
{code}
</code>
))}
</div>
<DialogFooter>
<Button onClick={() => setRegenDialogOpen(false)}>
{t("twoFactor.done")}
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
</Card>
)
}

View File

@@ -0,0 +1,172 @@
"use client"
import * as React from "react"
import { useLocale, useTranslations } from "next-intl"
import { toast } from "sonner"
import {
LogIn,
LogOut,
UserPlus,
Loader2,
LogOutIcon,
} from "lucide-react"
import {
revokeAllOtherSessionsAction,
type LoginHistoryItem,
} from "@/modules/settings/actions-security"
import {
formatRelativeTime,
parseUserAgent,
} from "@/modules/settings/lib/security-utils"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
interface SecurityRecentLoginsSectionProps {
/** 最近登录记录列表 */
recentLogins: LoginHistoryItem[]
/** 是否正在加载初始数据 */
loading: boolean
/** 当前会话的 user agent用于标记当前会话 */
currentDeviceLabel?: string
/** 远程登出后通知父组件刷新数据 */
onRevoked: () => Promise<void> | void
}
const ACTION_ICON_MAP: Record<LoginHistoryItem["action"], React.ReactNode> = {
signin: <LogIn className="h-4 w-4" />,
signout: <LogOut className="h-4 w-4" />,
signup: <UserPlus className="h-4 w-4" />,
}
/**
* 安全中心 - 最近登录记录区块
*
* 负责:
* - 展示最近登录历史(最近 N 条)
* - 标记当前会话与失败记录
* - 远程登出其他会话
*
* 数据获取由父组件统一管理,本组件仅负责展示与登出操作。
*/
export function SecurityRecentLoginsSection({
recentLogins,
loading,
currentDeviceLabel,
onRevoked,
}: SecurityRecentLoginsSectionProps): React.ReactElement {
const t = useTranslations("settings.security.center")
const locale = useLocale()
const [revoking, setRevoking] = React.useState(false)
const handleRevokeAllSessions = async (): Promise<void> => {
setRevoking(true)
try {
const result = await revokeAllOtherSessionsAction()
if (result.success && result.data) {
if (result.data.revokedCount > 0) {
toast.success(t("recentLogins.revokeSuccess", { count: result.data.revokedCount }))
} else {
toast.info(t("recentLogins.revokeSuccessEmpty"))
}
await onRevoked()
} else {
toast.error(result.message || t("recentLogins.revokeFailure"))
}
} catch {
toast.error(t("recentLogins.revokeFailure"))
} finally {
setRevoking(false)
}
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">{t("recentLogins.title")}</h4>
<div className="flex items-center gap-2">
{recentLogins.length > 0 ? (
<span className="text-xs text-muted-foreground">
{t("recentLogins.showingLatest", { count: recentLogins.length })}
</span>
) : null}
{!loading && recentLogins.length > 0 ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRevokeAllSessions}
disabled={revoking}
className="h-7 gap-1.5 text-xs"
>
{revoking ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<LogOutIcon className="h-3.5 w-3.5" />
)}
{revoking ? t("recentLogins.revoking") : t("recentLogins.revokeAll")}
</Button>
) : null}
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : recentLogins.length === 0 ? (
<div className="rounded-md border border-dashed py-6 text-center text-sm text-muted-foreground">
{t("recentLogins.empty")}
</div>
) : (
<ul className="divide-y rounded-md border">
{recentLogins.map((item) => {
const { device, browser } = parseUserAgent(item.userAgent)
const isCurrent = currentDeviceLabel
? item.userAgent?.includes(currentDeviceLabel)
: false
return (
<li
key={item.id}
className="flex items-center gap-3 px-3 py-2.5 text-sm"
>
<span
className={
item.status === "success"
? "text-green-600"
: "text-red-600"
}
>
{ACTION_ICON_MAP[item.action]}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">
{t(`recentLogins.actions.${item.action}`)}
</span>
{item.status === "failure" ? (
<Badge variant="destructive" className="text-xs">
{t("recentLogins.failed")}
</Badge>
) : null}
{isCurrent ? (
<Badge variant="outline" className="text-xs">
{t("recentLogins.current")}
</Badge>
) : null}
</div>
<div className="text-xs text-muted-foreground truncate">
{device} · {browser}
{item.ipAddress ? ` · ${item.ipAddress}` : ""}
</div>
</div>
<time className="text-xs text-muted-foreground whitespace-nowrap">
{formatRelativeTime(item.createdAt, locale)}
</time>
</li>
)
})}
</ul>
)}
</div>
)
}

View File

@@ -0,0 +1,473 @@
"use client"
import * as React from "react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import {
Smartphone,
Loader2,
AlertCircle,
KeyRound,
Copy,
Check,
RefreshCw,
} from "lucide-react"
import {
disableTwoFactorAction,
regenerateBackupCodesAction,
setupTwoFactorAction,
verifyTwoFactorAction,
type TwoFactorSetupData,
type TwoFactorStatus,
} from "@/modules/settings/actions-security"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
interface SecurityTwoFactorSectionProps {
/** 当前 2FA 状态null 表示尚未加载 */
twoFactor: TwoFactorStatus | null
/** 是否正在加载初始数据 */
loading: boolean
/** 2FA 状态变更后通知父组件刷新 */
onStatusChange: (status: TwoFactorStatus) => void
}
type SetupStep = "idle" | "qr" | "backup"
/**
* 安全中心 - 两步验证区块
*
* 负责:
* - 2FA 启用流程(二维码 + 验证 + 备份码展示)
* - 2FA 关闭流程
* - 备份码重新生成
*
* 所有 Server Action 调用均封装在本组件内部,
* 父组件仅需提供初始状态与状态变更回调。
*/
export function SecurityTwoFactorSection({
twoFactor,
loading,
onStatusChange,
}: SecurityTwoFactorSectionProps): React.ReactElement {
const t = useTranslations("settings.security.center")
// 启用 2FA Dialog 状态
const [enableDialogOpen, setEnableDialogOpen] = React.useState(false)
const [setupStep, setSetupStep] = React.useState<SetupStep>("idle")
const [setupData, setSetupData] = React.useState<TwoFactorSetupData | null>(null)
const [verifyCode, setVerifyCode] = React.useState("")
const [backupCodes, setBackupCodes] = React.useState<string[]>([])
const [setupLoading, setSetupLoading] = React.useState(false)
const [copied, setCopied] = React.useState(false)
// 关闭 2FA Dialog 状态
const [disableDialogOpen, setDisableDialogOpen] = React.useState(false)
const [disableCode, setDisableCode] = React.useState("")
const [disableLoading, setDisableLoading] = React.useState(false)
// 重新生成备份码 Dialog 状态
const [regenDialogOpen, setRegenDialogOpen] = React.useState(false)
const [regenCode, setRegenCode] = React.useState("")
const [regenLoading, setRegenLoading] = React.useState(false)
const [regenBackupCodes, setRegenBackupCodes] = React.useState<string[]>([])
// --- 启用 2FA 流程 ---
const handleEnable2FA = async (): Promise<void> => {
setEnableDialogOpen(true)
setSetupStep("idle")
setSetupData(null)
setVerifyCode("")
setBackupCodes([])
setSetupLoading(true)
try {
const result = await setupTwoFactorAction()
if (result.success && result.data) {
setSetupData(result.data)
setSetupStep("qr")
} else {
toast.error(result.message || t("twoFactor.setupFailure"))
setEnableDialogOpen(false)
}
} catch {
toast.error(t("twoFactor.setupFailure"))
setEnableDialogOpen(false)
} finally {
setSetupLoading(false)
}
}
const handleVerifySetup = async (): Promise<void> => {
if (!verifyCode.trim()) return
setSetupLoading(true)
try {
const result = await verifyTwoFactorAction(verifyCode.trim())
if (result.success && result.data) {
setBackupCodes(result.data.backupCodes)
onStatusChange(result.data.status)
setSetupStep("backup")
toast.success(t("twoFactor.enableSuccess"))
} else {
toast.error(result.message || t("twoFactor.invalidCode"))
}
} catch {
toast.error(t("twoFactor.verifyFailure"))
} finally {
setSetupLoading(false)
}
}
const handleCopyBackupCodes = async (): Promise<void> => {
try {
await navigator.clipboard.writeText(backupCodes.join("\n"))
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// 剪贴板不可用时静默
}
}
const handleCloseEnableDialog = (): void => {
setEnableDialogOpen(false)
setSetupStep("idle")
setSetupData(null)
setVerifyCode("")
setBackupCodes([])
}
// --- 关闭 2FA 流程 ---
const handleDisable2FA = async (): Promise<void> => {
if (!disableCode.trim()) return
setDisableLoading(true)
try {
const result = await disableTwoFactorAction(disableCode.trim())
if (result.success && result.data) {
onStatusChange(result.data)
setDisableDialogOpen(false)
setDisableCode("")
toast.success(t("twoFactor.disableSuccess"))
} else {
toast.error(result.message || t("twoFactor.invalidCode"))
}
} catch {
toast.error(t("twoFactor.disableFailure"))
} finally {
setDisableLoading(false)
}
}
// --- 重新生成备份码 ---
const handleRegenerateBackupCodes = async (): Promise<void> => {
if (!regenCode.trim()) return
setRegenLoading(true)
try {
const result = await regenerateBackupCodesAction(regenCode.trim())
if (result.success && result.data) {
setRegenBackupCodes(result.data.backupCodes)
onStatusChange(result.data.status)
setRegenCode("")
toast.success(t("twoFactor.regenerateSuccess"))
} else {
toast.error(result.message || t("twoFactor.invalidCode"))
}
} catch {
toast.error(t("twoFactor.regenerateFailure"))
} finally {
setRegenLoading(false)
}
}
const handleRegenDialogOpen = (): void => {
setRegenDialogOpen(true)
setRegenCode("")
setRegenBackupCodes([])
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex items-start gap-3">
<Smartphone className="mt-0.5 h-5 w-5 text-muted-foreground" />
<div className="space-y-0.5">
<div className="text-sm font-medium">{t("twoFactor.title")}</div>
<p className="text-sm text-muted-foreground">
{t("twoFactor.description")}
</p>
{twoFactor?.enabled ? (
<div className="mt-1 flex items-center gap-2">
<Badge variant="secondary">{t("twoFactor.enabled")}</Badge>
<span className="text-xs text-muted-foreground">
{t("twoFactor.backupRemaining", { count: twoFactor.backupCodesRemaining })}
</span>
</div>
) : null}
</div>
</div>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : twoFactor?.enabled ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setDisableDialogOpen(true)}
>
{t("twoFactor.disable")}
</Button>
) : (
<Button
type="button"
size="sm"
onClick={handleEnable2FA}
>
{t("twoFactor.enable")}
</Button>
)}
</div>
{twoFactor?.enabled ? (
<div className="flex items-center justify-between rounded-lg border border-dashed p-3">
<div className="flex items-start gap-2">
<KeyRound className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div>
<div className="text-xs font-medium">{t("twoFactor.backupCodes")}</div>
<p className="text-xs text-muted-foreground">
{t("twoFactor.backupHint")}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleRegenDialogOpen}
className="h-7 gap-1.5 text-xs"
>
<RefreshCw className="h-3.5 w-3.5" />
{t("twoFactor.regenerate")}
</Button>
</div>
) : (
<p className="flex items-start gap-1.5 text-xs text-muted-foreground">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
{t("twoFactor.hint")}
</p>
)}
{/* 启用 2FA Dialog */}
<Dialog open={enableDialogOpen} onOpenChange={(o) => { if (!o) handleCloseEnableDialog() }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("twoFactor.title")}</DialogTitle>
<DialogDescription>{t("twoFactor.description")}</DialogDescription>
</DialogHeader>
{setupStep === "qr" && setupData ? (
<div className="space-y-4">
<div className="flex flex-col items-center gap-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={setupData.qrCodeDataUrl}
alt="2FA QR Code"
className="rounded-md border"
width={240}
height={240}
/>
<p className="text-center text-sm text-muted-foreground">
{t("twoFactor.scanQr")}
</p>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("twoFactor.manualEntry")}</Label>
<code className="block rounded-md bg-muted p-2 text-xs break-all">
{setupData.secret}
</code>
</div>
<div className="space-y-2">
<Label htmlFor="verifyCode">{t("twoFactor.enterCode")}</Label>
<Input
id="verifyCode"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
maxLength={6}
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value)}
disabled={setupLoading}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCloseEnableDialog} disabled={setupLoading}>
{t("twoFactor.cancel")}
</Button>
<Button onClick={handleVerifySetup} disabled={setupLoading || !verifyCode.trim()}>
{setupLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t("twoFactor.verify")}
</Button>
</DialogFooter>
</div>
) : null}
{setupStep === "backup" ? (
<div className="space-y-4">
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
<p className="flex items-start gap-1.5 text-xs text-amber-800 dark:text-amber-200">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
{t("twoFactor.backupWarning")}
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>{t("twoFactor.backupCodes")}</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleCopyBackupCodes}
className="h-7 gap-1.5 text-xs"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? t("twoFactor.copied") : t("twoFactor.copy")}
</Button>
</div>
<div className="grid grid-cols-2 gap-2 rounded-md border p-3">
{backupCodes.map((code, i) => (
<code key={i} className="text-sm font-mono">
{code}
</code>
))}
</div>
</div>
<DialogFooter>
<Button onClick={handleCloseEnableDialog}>
{t("twoFactor.done")}
</Button>
</DialogFooter>
</div>
) : null}
{setupStep === "idle" ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : null}
</DialogContent>
</Dialog>
{/* 关闭 2FA Dialog */}
<Dialog open={disableDialogOpen} onOpenChange={setDisableDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("twoFactor.disableTitle")}</DialogTitle>
<DialogDescription>{t("twoFactor.disableDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="disableCode">{t("twoFactor.enterCodeDisable")}</Label>
<Input
id="disableCode"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
maxLength={8}
value={disableCode}
onChange={(e) => setDisableCode(e.target.value)}
disabled={disableLoading}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDisableDialogOpen(false)} disabled={disableLoading}>
{t("twoFactor.cancel")}
</Button>
<Button
variant="destructive"
onClick={handleDisable2FA}
disabled={disableLoading || !disableCode.trim()}
>
{disableLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t("twoFactor.disable")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 重新生成备份码 Dialog */}
<Dialog open={regenDialogOpen} onOpenChange={setRegenDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("twoFactor.regenerateTitle")}</DialogTitle>
<DialogDescription>{t("twoFactor.regenerateDescription")}</DialogDescription>
</DialogHeader>
{regenBackupCodes.length === 0 ? (
<>
<div className="space-y-2">
<Label htmlFor="regenCode">{t("twoFactor.enterCodeRegen")}</Label>
<Input
id="regenCode"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
maxLength={6}
value={regenCode}
onChange={(e) => setRegenCode(e.target.value)}
disabled={regenLoading}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRegenDialogOpen(false)} disabled={regenLoading}>
{t("twoFactor.cancel")}
</Button>
<Button
onClick={handleRegenerateBackupCodes}
disabled={regenLoading || !regenCode.trim()}
>
{regenLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t("twoFactor.regenerate")}
</Button>
</DialogFooter>
</>
) : (
<div className="space-y-4">
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
<p className="flex items-start gap-1.5 text-xs text-amber-800 dark:text-amber-200">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
{t("twoFactor.backupWarning")}
</p>
</div>
<div className="grid grid-cols-2 gap-2 rounded-md border p-3">
{regenBackupCodes.map((code, i) => (
<code key={i} className="text-sm font-mono">
{code}
</code>
))}
</div>
<DialogFooter>
<Button onClick={() => setRegenDialogOpen(false)}>
{t("twoFactor.done")}
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,64 +1,25 @@
"use client"
import { Component, type ReactNode } from "react"
import { AlertCircle } from "lucide-react"
/**
* 设置页分区 Error Boundary
*
* 薄包装:委托给共享 SectionErrorBoundary使用 common 命名空间。
* 保留同名导出以兼容现有 import。
*/
import { EmptyState } from "@/shared/components/ui/empty-state"
import { useTranslations } from "next-intl"
import type { ReactNode } from "react"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
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")
export function SettingsSectionErrorBoundary({
children,
}: SettingsSectionErrorBoundaryProps): ReactNode {
return (
<EmptyState
icon={AlertCircle}
title={t("sectionLoadFailed")}
description={t("sectionLoadFailedDesc")}
action={{
label: t("retry"),
onClick: onRetry,
}}
className="border-none shadow-none h-auto"
/>
<SectionErrorBoundary namespace="common">
{children}
</SectionErrorBoundary>
)
}

View File

@@ -0,0 +1,26 @@
import type { Role } from "@/shared/types/permissions"
/**
* Profile 概览类型
*
* 通过配置驱动角色 → 概览区块的映射,新增角色只需在此添加条目。
* 避免在页面层使用 roles.includes("xxx") 硬编码。
*/
export type ProfileOverviewType = "student" | "teacher" | "none"
const PROFILE_OVERVIEW_MAP: Partial<Record<Role, ProfileOverviewType>> = {
student: "student",
teacher: "teacher",
}
/**
* 根据角色列表解析首选概览类型。
* 优先级student > teacher > none
*/
export function resolveProfileOverviewType(roles: Role[]): ProfileOverviewType {
for (const role of roles) {
const overviewType = PROFILE_OVERVIEW_MAP[role]
if (overviewType) return overviewType
}
return "none"
}

View File

@@ -0,0 +1,78 @@
import "server-only"
import {
getSystemSetting,
upsertSystemSetting,
} from "@/modules/settings/data-access-system-settings"
import { DEFAULT_BRAND_CONFIG, type BrandConfig } from "@/modules/settings/brand-config"
export type { BrandConfig }
export { DEFAULT_BRAND_CONFIG }
/** 品牌配置键名 */
const BRAND_KEYS = {
schoolName: "schoolName",
logoUrl: "logoUrl",
testimonialQuote: "testimonialQuote",
testimonialAuthor: "testimonialAuthor",
} as const
/**
* 获取品牌配置audit-P2-6 新增)
*
* 从 system_settings 表 brand 分类读取,未配置项使用默认值。
*/
export async function getBrandConfig(): Promise<BrandConfig> {
const [schoolNameRow, logoUrlRow, quoteRow, authorRow] = await Promise.all([
getSystemSetting("brand", BRAND_KEYS.schoolName),
getSystemSetting("brand", BRAND_KEYS.logoUrl),
getSystemSetting("brand", BRAND_KEYS.testimonialQuote),
getSystemSetting("brand", BRAND_KEYS.testimonialAuthor),
])
return {
schoolName: schoolNameRow?.value || DEFAULT_BRAND_CONFIG.schoolName,
logoUrl: logoUrlRow?.value || null,
testimonialQuote: quoteRow?.value || DEFAULT_BRAND_CONFIG.testimonialQuote,
testimonialAuthor: authorRow?.value || DEFAULT_BRAND_CONFIG.testimonialAuthor,
}
}
/**
* 保存品牌配置audit-P2-6 新增)
*/
export async function saveBrandConfig(
config: BrandConfig,
updatedBy?: string,
): Promise<void> {
await Promise.all([
upsertSystemSetting({
category: "brand",
key: BRAND_KEYS.schoolName,
value: config.schoolName,
valueType: "string",
updatedBy,
}),
upsertSystemSetting({
category: "brand",
key: BRAND_KEYS.logoUrl,
value: config.logoUrl ?? "",
valueType: "string",
updatedBy,
}),
upsertSystemSetting({
category: "brand",
key: BRAND_KEYS.testimonialQuote,
value: config.testimonialQuote,
valueType: "string",
updatedBy,
}),
upsertSystemSetting({
category: "brand",
key: BRAND_KEYS.testimonialAuthor,
value: config.testimonialAuthor,
valueType: "string",
updatedBy,
}),
])
}

View File

@@ -0,0 +1,46 @@
import "server-only"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
import { getStudentHomeworkAssignments } from "@/modules/homework/data-access-student"
import { getStudentDashboardGrades } from "@/modules/homework/stats-service"
import { getTeacherClasses, getTeacherTeachingSubjects } from "@/modules/classes/data-access"
/**
* Profile 概览数据访问层
*
* 将 classes/homework 模块的 data-access 调用封装在 settings 模块内部,
* 避免 settings 组件层直接 import 其他业务模块的 data-access。
* 模块间通过 data-access 通信是允许的,组件层直接 import 则违反解耦原则。
*/
/**
* 获取学生概览所需的所有数据(并行查询)
*/
export async function getStudentProfileOverviewData(userId: string): Promise<{
classes: Awaited<ReturnType<typeof getStudentClasses>>
schedule: Awaited<ReturnType<typeof getStudentSchedule>>
assignments: Awaited<ReturnType<typeof getStudentHomeworkAssignments>>
grades: Awaited<ReturnType<typeof getStudentDashboardGrades>>
}> {
const [classes, schedule, assignments, grades] = await Promise.all([
getStudentClasses(userId),
getStudentSchedule(userId),
getStudentHomeworkAssignments(userId),
getStudentDashboardGrades(userId),
])
return { classes, schedule, assignments, grades }
}
/**
* 获取教师概览所需的所有数据(并行查询)
*/
export async function getTeacherProfileOverviewData(): Promise<{
subjects: Awaited<ReturnType<typeof getTeacherTeachingSubjects>>
classes: Awaited<ReturnType<typeof getTeacherClasses>>
}> {
const [subjects, classes] = await Promise.all([
getTeacherTeachingSubjects(),
getTeacherClasses(),
])
return { subjects, classes }
}

View File

@@ -15,6 +15,8 @@ export type SystemSettingCategory =
| "security_policy"
| "file_upload"
| "notification_config"
| "audit_retention"
| "brand"
/**
* 系统设置值类型

View File

@@ -0,0 +1,141 @@
import { describe, it, expect } from "vitest"
import { toSettingItem } from "./system-settings-utils"
describe("toSettingItem", () => {
describe("string 类型", () => {
it("将普通字符串转换为设置项", () => {
const item = toSettingItem("school_info", "schoolName", "实验中学", "string")
expect(item).toEqual({
category: "school_info",
key: "schoolName",
value: "实验中学",
valueType: "string",
})
})
it("将 null 转换为空字符串", () => {
const item = toSettingItem("school_info", "schoolCode", null, "string")
expect(item.value).toBe("")
})
it("将 undefined 转换为空字符串", () => {
const item = toSettingItem("school_info", "schoolCode", undefined, "string")
expect(item.value).toBe("")
})
it("将数字转换为字符串", () => {
const item = toSettingItem("school_info", "schoolCode", 12345, "string")
expect(item.value).toBe("12345")
})
})
describe("number 类型", () => {
it("将数字转换为字符串", () => {
const item = toSettingItem("security_policy", "passwordMinLength", 8, "number")
expect(item.value).toBe("8")
})
it("将大数字转换为字符串", () => {
const item = toSettingItem("security_policy", "sessionTimeout", 1440, "number")
expect(item.value).toBe("1440")
})
it("将零转换为字符串", () => {
const item = toSettingItem("security_policy", "sessionTimeout", 0, "number")
expect(item.value).toBe("0")
})
})
describe("boolean 类型", () => {
it("将 true 转换为 'true'", () => {
const item = toSettingItem("security_policy", "requireSpecialChar", true, "boolean")
expect(item.value).toBe("true")
})
it("将 false 转换为 'false'", () => {
const item = toSettingItem("security_policy", "requireUppercase", false, "boolean")
expect(item.value).toBe("false")
})
it("将 truthy 值转换为 'true'", () => {
const item = toSettingItem("security_policy", "forcePasswordChange", 1, "boolean")
expect(item.value).toBe("true")
})
it("将 falsy 值转换为 'false'", () => {
const item = toSettingItem("security_policy", "forcePasswordChange", 0, "boolean")
expect(item.value).toBe("false")
})
})
describe("json 类型", () => {
it("将对象序列化为 JSON 字符串", () => {
const item = toSettingItem("file_upload", "allowedTypes", ["jpg", "png"], "json")
expect(item.value).toBe(JSON.stringify(["jpg", "png"]))
})
it("将嵌套对象序列化", () => {
const data = { types: ["jpg", "png"], maxSize: 10 }
const item = toSettingItem("file_upload", "config", data, "json")
expect(item.value).toBe(JSON.stringify(data))
})
it("将 null 序列化为 'null'", () => {
const item = toSettingItem("file_upload", "config", null, "json")
expect(item.value).toBe("null")
})
it("将数组序列化", () => {
const item = toSettingItem("notification_config", "channels", ["email", "sms"], "json")
expect(item.value).toBe(JSON.stringify(["email", "sms"]))
})
})
describe("返回结构", () => {
it("返回包含所有字段的设置项", () => {
const item = toSettingItem("school_info", "name", "测试", "string")
expect(item).toHaveProperty("category")
expect(item).toHaveProperty("key")
expect(item).toHaveProperty("value")
expect(item).toHaveProperty("valueType")
})
it("保留传入的 category 和 key", () => {
const item = toSettingItem("notification_config", "notifyNewUser", true, "boolean")
expect(item.category).toBe("notification_config")
expect(item.key).toBe("notifyNewUser")
})
it("保留传入的 valueType", () => {
const item = toSettingItem("security_policy", "min", 8, "number")
expect(item.valueType).toBe("number")
})
})
describe("边界情况", () => {
it("处理空字符串", () => {
const item = toSettingItem("school_info", "name", "", "string")
expect(item.value).toBe("")
})
it("处理负数", () => {
const item = toSettingItem("security_policy", "timeout", -1, "number")
expect(item.value).toBe("-1")
})
it("处理浮点数", () => {
const item = toSettingItem("file_upload", "maxSize", 10.5, "number")
expect(item.value).toBe("10.5")
})
it("处理空数组 JSON", () => {
const item = toSettingItem("file_upload", "types", [], "json")
expect(item.value).toBe("[]")
})
it("处理空对象 JSON", () => {
const item = toSettingItem("file_upload", "config", {}, "json")
expect(item.value).toBe("{}")
})
})
})

View File

@@ -0,0 +1,44 @@
import type {
SystemSettingCategory,
SystemSettingValueType,
} from "@/modules/settings/data-access-system-settings"
/**
* 系统设置项(用于 upsertSystemSettings 批量写入)
*/
export interface SettingItem {
category: SystemSettingCategory
key: string
value: string
valueType: SystemSettingValueType
}
/**
* 将表单值转换为可写入数据库的设置项。
*
* 根据 valueType 将原始值序列化为字符串:
* - json: JSON.stringify
* - boolean: "true" / "false"
* - number: String(value)
* - string: String(value ?? "")null/undefined 转为空串)
*
* 该函数为纯函数,便于单元测试。
*/
export function toSettingItem(
category: SystemSettingCategory,
key: string,
value: unknown,
valueType: SystemSettingValueType,
): SettingItem {
let strValue: string
if (valueType === "json") {
strValue = JSON.stringify(value)
} else if (valueType === "boolean") {
strValue = value ? "true" : "false"
} else if (valueType === "number") {
strValue = String(value)
} else {
strValue = String(value ?? "")
}
return { category, key, value: strValue, valueType }
}

View File

@@ -5,7 +5,7 @@ import type {
UpdateNotificationPreferencesInput,
} from "@/modules/notifications/types"
export type AiProviderName = "zhipu" | "openai" | "gemini" | "custom"
export type AiProviderName = "zhipu" | "openai" | "gemini" | "custom" | "ollama"
/**
* AI 服务商可见性