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:
@@ -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(
|
||||
|
||||
81
src/modules/settings/actions-brand.ts
Normal file
81
src/modules/settings/actions-brand.ts
Normal 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 Action(audit-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 Action(audit-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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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-open:API 不可用时跳过检查,避免外部依赖阻断改密流程
|
||||
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),
|
||||
|
||||
@@ -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-9:2FA 启用埋点(用于 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-9:2FA 禁用埋点(用于 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-9:JWT 策略下 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")
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 默认 baseUrl(OpenAI 兼容端点) */
|
||||
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) {
|
||||
// 新建 Provider:Ollama 允许无 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 })
|
||||
}
|
||||
|
||||
27
src/modules/settings/brand-config.ts
Normal file
27
src/modules/settings/brand-config.ts
Normal 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",
|
||||
}
|
||||
66
src/modules/settings/components/admin-file-upload-card.tsx
Normal file
66
src/modules/settings/components/admin-file-upload-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
113
src/modules/settings/components/admin-school-info-card.tsx
Normal file
113
src/modules/settings/components/admin-school-info-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
110
src/modules/settings/components/admin-security-policy-card.tsx
Normal file
110
src/modules/settings/components/admin-security-policy-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
108
src/modules/settings/components/ai-provider-selector.tsx
Normal file
108
src/modules/settings/components/ai-provider-selector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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/upload,targetType="user_avatar" 已注册到 FileTargetType 枚举
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
formData.append("targetType", "user_avatar")
|
||||
|
||||
160
src/modules/settings/components/brand-config-card.tsx
Normal file
160
src/modules/settings/components/brand-config-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
473
src/modules/settings/components/security-two-factor-section.tsx
Normal file
473
src/modules/settings/components/security-two-factor-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
26
src/modules/settings/config/profile-overview-config.ts
Normal file
26
src/modules/settings/config/profile-overview-config.ts
Normal 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"
|
||||
}
|
||||
78
src/modules/settings/data-access-brand.ts
Normal file
78
src/modules/settings/data-access-brand.ts
Normal 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,
|
||||
}),
|
||||
])
|
||||
}
|
||||
46
src/modules/settings/data-access-profile-overview.ts
Normal file
46
src/modules/settings/data-access-profile-overview.ts
Normal 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 }
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export type SystemSettingCategory =
|
||||
| "security_policy"
|
||||
| "file_upload"
|
||||
| "notification_config"
|
||||
| "audit_retention"
|
||||
| "brand"
|
||||
|
||||
/**
|
||||
* 系统设置值类型
|
||||
|
||||
141
src/modules/settings/lib/system-settings-utils.test.ts
Normal file
141
src/modules/settings/lib/system-settings-utils.test.ts
Normal 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("{}")
|
||||
})
|
||||
})
|
||||
})
|
||||
44
src/modules/settings/lib/system-settings-utils.ts
Normal file
44
src/modules/settings/lib/system-settings-utils.ts
Normal 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 }
|
||||
}
|
||||
@@ -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 服务商可见性
|
||||
|
||||
Reference in New Issue
Block a user