refactor(modules): update classes, course-plans, diagnostic, questions, settings, student, layout

- Update classes data-access (invitations, main) for invitation management

- Update course-plans actions, data-access, and types

- Update diagnostic data-access for report queries

- Update questions data-access for question bank queries

- Update settings actions, ai-provider-settings-card, data-access, and types

- Update student course-filters, student-courses-view, student-schedule-filters, student-schedule-view

- Update layout app-sidebar, site-header, and navigation config
This commit is contained in:
SpecialX
2026-06-24 12:03:35 +08:00
parent c9e46f9f80
commit 8c2fe14c20
18 changed files with 712 additions and 189 deletions

View File

@@ -5,7 +5,11 @@ import { revalidatePath } from "next/cache"
import { createId } from "@paralleldrive/cuid2"
import type { ActionState } from "@/shared/types/action-state"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import {
requirePermission,
PermissionDeniedError,
getAuthContext,
} from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { encryptAiApiKey, getAiErrorMessage, testAiProviderById, testAiProviderConfig } from "@/shared/lib/ai"
@@ -15,13 +19,15 @@ import {
deleteAiProvider as deleteAiProviderRecord,
getAiProviderForUpdate,
getAiProviderSummaries as fetchAiProviderSummaries,
getAiProviderSummariesForUser,
updateAiProvider,
} from "./data-access"
import type { AiProviderSummary } from "./types"
import type { AiProviderSummary, AiProviderVisibility } from "./types"
export type { AiProviderSummary } from "./types"
const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom"])
const VisibilitySchema = z.enum(["public", "private"])
const AiProviderFormSchema = z.object({
id: z.string().optional(),
@@ -30,6 +36,7 @@ const AiProviderFormSchema = z.object({
model: z.string().min(1),
apiKey: z.string().min(1).optional(),
isDefault: z.boolean().optional(),
visibility: VisibilitySchema.optional(),
})
const AiProviderTestSchema = AiProviderFormSchema.extend({
@@ -44,9 +51,15 @@ const AiProviderTestSchema = AiProviderFormSchema.extend({
}
})
const ensureUser = async (): Promise<{ id: string }> => {
const ctx = await requirePermission(Permissions.AI_CONFIGURE)
return { id: ctx.userId }
/**
* 校验当前用户身份,返回 { id, isAdmin }
*
* - 所有 AI_CHAT 用户均可访问(用于管理自己的 private provider
* - isAdmin 标识是否拥有 AI_CONFIGURE 权限(可管理 public provider 与他人 private
*/
const ensureUser = async (): Promise<{ id: string; isAdmin: boolean }> => {
const ctx = await requirePermission(Permissions.AI_CHAT)
return { id: ctx.userId, isAdmin: ctx.permissions.includes(Permissions.AI_CONFIGURE) }
}
const normalizeBaseUrl = (value: string | undefined): string | null => {
@@ -58,10 +71,18 @@ const normalizeBaseUrl = (value: string | undefined): string | null => {
.replace(/\/chat\/completions$/i, "")
}
/**
* 获取当前用户可见的 AI Provider 列表
*
* - 管理员:返回所有 public + private 记录
* - 普通用户:返回 public + 自己创建的 private 记录
*/
export async function getAiProviderSummaries(): Promise<ActionState<AiProviderSummary[]>> {
try {
await ensureUser()
const data = await fetchAiProviderSummaries()
const user = await ensureUser()
const data = user.isAdmin
? await fetchAiProviderSummaries()
: await getAiProviderSummariesForUser(user.id)
return { success: true, data }
} catch (error) {
if (error instanceof PermissionDeniedError) return { success: false, message: error.message }
@@ -85,10 +106,17 @@ export async function upsertAiProviderAction(
return { success: false, message: "Base URL is required for this provider" }
}
// 可见性规则:
// - 管理员可创建/更新 public 或 private
// - 普通用户只能创建/更新 private
const requestedVisibility: AiProviderVisibility = payload.visibility ?? "private"
const visibility: AiProviderVisibility =
user.isAdmin ? requestedVisibility : "private"
// Parallelize default-count and existing-provider queries
const [defaultCount, existing] = await Promise.all([
countDefaultAiProviders(),
payload.id ? getAiProviderForUpdate(payload.id) : Promise.resolve(null),
payload.id ? getAiProviderForUpdate(payload.id, user.id) : Promise.resolve(null),
])
const hasDefault = defaultCount > 0
@@ -114,11 +142,13 @@ export async function upsertAiProviderAction(
apiKeyEncrypted: encrypted,
apiKeyLast4: last4,
isDefault: isNextDefault,
visibility,
updatedBy: user.id,
},
payload.isDefault === true
)
revalidatePath("/admin/ai-settings")
revalidatePath("/settings")
return { success: true, message: "AI provider updated", data: id }
}
@@ -141,16 +171,19 @@ export async function upsertAiProviderAction(
apiKeyEncrypted: encrypted,
apiKeyLast4: last4,
isDefault: shouldMakeDefault,
visibility,
createdBy: user.id,
updatedBy: user.id,
},
shouldMakeDefault
)
revalidatePath("/admin/ai-settings")
revalidatePath("/settings")
return { success: true, message: "AI provider created", data: id }
} catch (error) {
if (error instanceof PermissionDeniedError) return { success: false, message: error.message }
console.error("[upsertAiProviderAction] Failed to save AI provider:", error)
return { success: false, message: "Failed to save AI provider" }
}
}
@@ -190,18 +223,26 @@ const DeleteAiProviderSchema = z.object({
/**
* 删除 AI Provider
*
* 权限规则:
* - 管理员AI_CONFIGURE可删除任意 Provider
* - 普通用户AI_CHAT仅可删除自己创建的 Provider
*
* 如果删除的是默认 Provider自动将最新的一条记录设为默认若存在
*/
export async function deleteAiProviderAction(
input: z.infer<typeof DeleteAiProviderSchema>
): Promise<ActionState<null>> {
try {
await ensureUser()
const user = await ensureUser()
const parsed = DeleteAiProviderSchema.safeParse(input)
if (!parsed.success) {
return { success: false, message: "Invalid provider id" }
}
await deleteAiProviderRecord(parsed.data.id)
// 管理员不传 userId可删除任意普通用户传 userId 做所有权校验
await deleteAiProviderRecord(
parsed.data.id,
user.isAdmin ? undefined : user.id
)
revalidatePath("/admin/ai-settings")
revalidatePath("/settings")
return { success: true, message: "AI provider deleted", data: null }
@@ -210,3 +251,18 @@ export async function deleteAiProviderAction(
return { success: false, message: "Failed to delete AI provider" }
}
}
/**
* 检查当前用户是否拥有 AI_CONFIGURE 权限(管理员)
*
* 供 UI 层决定是否显示 public 可见性选项。
*/
export async function canConfigurePublicAiProvider(): Promise<ActionState<boolean>> {
try {
const ctx = await getAuthContext()
return { success: true, data: ctx.permissions.includes(Permissions.AI_CONFIGURE) }
} catch (error) {
if (error instanceof PermissionDeniedError) return { success: false, message: error.message, data: false }
return { success: false, message: "Failed to check permission", data: false }
}
}

View File

@@ -1,6 +1,6 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"
import { useCallback, useEffect, useMemo, useRef, useState, useTransition, type ReactElement } from "react"
import { useTranslations } from "next-intl"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
@@ -26,6 +26,7 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@@ -39,9 +40,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"
const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom"])
const VisibilitySchema = z.enum(["public", "private"])
const AiProviderFormSchema = z.object({
id: z.string().optional(),
@@ -50,19 +53,26 @@ const AiProviderFormSchema = z.object({
model: z.string().min(1, "Model is required"),
apiKey: z.string().optional(),
isDefault: z.boolean().optional(),
visibility: VisibilitySchema.optional(),
})
type AiProviderFormValues = z.infer<typeof AiProviderFormSchema>
const NEW_PROVIDER_VALUE = "__new__"
type AiProviderSettingsCardProps = {
onProvidersChanged?: (rows: AiProviderSummary[]) => void
initialMode?: "new" | "first"
isAdmin?: boolean
currentUserId?: string
}
export function AiProviderSettingsCard({
onProvidersChanged,
initialMode = "first",
}: {
onProvidersChanged?: (rows: AiProviderSummary[]) => void
initialMode?: "new" | "first"
}) {
isAdmin = false,
currentUserId,
}: AiProviderSettingsCardProps) {
const t = useTranslations("settings.ai.providers")
const [isPending, startTransition] = useTransition()
const [providers, setProviders] = useState<AiProviderSummary[]>([])
@@ -80,6 +90,7 @@ export function AiProviderSettingsCard({
model: "",
apiKey: "",
isDefault: false,
visibility: "private",
},
})
@@ -108,6 +119,7 @@ export function AiProviderSettingsCard({
model: "",
apiKey: "",
isDefault: false,
visibility: "private",
})
}, [form])
@@ -138,6 +150,7 @@ export function AiProviderSettingsCard({
model: next.model,
apiKey: "",
isDefault: next.isDefault,
visibility: next.visibility,
})
}
} catch {
@@ -163,6 +176,7 @@ export function AiProviderSettingsCard({
model: next.model,
apiKey: "",
isDefault: next.isDefault,
visibility: next.visibility,
})
}
@@ -193,6 +207,7 @@ export function AiProviderSettingsCard({
model: values.model.trim(),
apiKey: apiKey || undefined,
isDefault: values.isDefault ?? false,
visibility: values.visibility,
}
const result = await testAiProviderAction(payload)
if (result.success) {
@@ -220,6 +235,7 @@ export function AiProviderSettingsCard({
model: values.model.trim(),
apiKey: values.apiKey?.trim() || undefined,
isDefault: values.isDefault ?? false,
visibility: values.visibility,
}
const result = await upsertAiProviderAction(payload)
if (result.success) {
@@ -245,6 +261,7 @@ export function AiProviderSettingsCard({
model: next.model,
apiKey: "",
isDefault: next.isDefault,
visibility: next.visibility,
})
}
} else {
@@ -278,6 +295,7 @@ export function AiProviderSettingsCard({
model: next.model,
apiKey: "",
isDefault: next.isDefault,
visibility: next.visibility,
})
} else {
resetToNew()
@@ -291,6 +309,34 @@ 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>
@@ -312,7 +358,7 @@ export function AiProviderSettingsCard({
<SelectItem value={NEW_PROVIDER_VALUE}>{t("createNew")}</SelectItem>
{providers.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.provider} · {item.model}
{renderProviderLabel(item)}
</SelectItem>
))}
</SelectContent>
@@ -320,10 +366,13 @@ export function AiProviderSettingsCard({
</div>
<div className="space-y-2">
<Label>{t("keyStatus")}</Label>
<div className="rounded-md border px-3 py-2 text-sm text-muted-foreground">
{selectedProvider?.apiKeyLast4
? `${t("stored")} • ****${selectedProvider.apiKeyLast4}`
: t("noKey")}
<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>
@@ -374,6 +423,36 @@ export function AiProviderSettingsCard({
/>
</div>
<FormField
control={form.control}
name="visibility"
render={({ field }) => (
<FormItem>
<FormLabel>{t("visibility")}</FormLabel>
<FormControl>
<Select
value={field.value ?? "private"}
onValueChange={field.onChange}
disabled={!isAdmin}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="private">{t("visibilityPrivateLabel")}</SelectItem>
{isAdmin ? (
<SelectItem value="public">{t("visibilityPublicLabel")}</SelectItem>
) : null}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
{isAdmin ? t("visibilityDesc") : t("visibilityReadOnly")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="isDefault"

View File

@@ -1,14 +1,24 @@
import "server-only"
import { count, desc, eq } from "drizzle-orm"
import { count, desc, eq, or } from "drizzle-orm"
import { db } from "@/shared/db"
import { aiProviders, passwordSecurity, users } from "@/shared/db/schema"
import type { AiProviderExisting, AiProviderName, AiProviderSummary } from "./types"
import type {
AiProviderExisting,
AiProviderName,
AiProviderSummary,
AiProviderVisibility,
} from "./types"
// --- AI Provider operations ---
/**
* 获取所有 AI Provider管理员视图
*
* 返回所有 public 与 private 记录,供管理员在 /admin/ai-settings 中管理。
*/
export async function getAiProviderSummaries(): Promise<AiProviderSummary[]> {
const rows = await db
.select({
@@ -18,6 +28,8 @@ export async function getAiProviderSummaries(): Promise<AiProviderSummary[]> {
model: aiProviders.model,
apiKeyLast4: aiProviders.apiKeyLast4,
isDefault: aiProviders.isDefault,
visibility: aiProviders.visibility,
createdBy: aiProviders.createdBy,
updatedAt: aiProviders.updatedAt,
})
.from(aiProviders)
@@ -25,6 +37,41 @@ export async function getAiProviderSummaries(): Promise<AiProviderSummary[]> {
return rows
}
/**
* 获取当前用户可见的 AI Provider用户视图
*
* 规则:
* - public Provider全员可见
* - private Provider仅创建者可见
*
* @param userId 当前用户 ID
*/
export async function getAiProviderSummariesForUser(
userId: string
): Promise<AiProviderSummary[]> {
const rows = await db
.select({
id: aiProviders.id,
provider: aiProviders.provider,
baseUrl: aiProviders.baseUrl,
model: aiProviders.model,
apiKeyLast4: aiProviders.apiKeyLast4,
isDefault: aiProviders.isDefault,
visibility: aiProviders.visibility,
createdBy: aiProviders.createdBy,
updatedAt: aiProviders.updatedAt,
})
.from(aiProviders)
.where(
or(
eq(aiProviders.visibility, "public"),
eq(aiProviders.createdBy, userId)
)
)
.orderBy(desc(aiProviders.updatedAt))
return rows
}
export async function countDefaultAiProviders(): Promise<number> {
const [row] = await db
.select({ value: count() })
@@ -33,18 +80,35 @@ export async function countDefaultAiProviders(): Promise<number> {
return Number(row?.value ?? 0)
}
export async function getAiProviderForUpdate(id: string): Promise<AiProviderExisting | null> {
/**
* 获取 Provider 用于更新(管理员或创建者视图)
*
* 管理员可访问任意 Provider非管理员仅能访问自己创建的 private Provider。
*/
export async function getAiProviderForUpdate(
id: string,
userId?: string
): Promise<AiProviderExisting | null> {
const [row] = await db
.select({
id: aiProviders.id,
apiKeyEncrypted: aiProviders.apiKeyEncrypted,
apiKeyLast4: aiProviders.apiKeyLast4,
isDefault: aiProviders.isDefault,
visibility: aiProviders.visibility,
createdBy: aiProviders.createdBy,
})
.from(aiProviders)
.where(eq(aiProviders.id, id))
.limit(1)
return row ?? null
if (!row) return null
// 未传 userId 视为管理员视图(向后兼容)
if (userId === undefined) return row
// 非管理员只能更新自己创建的 Provider
if (row.createdBy !== userId) return null
return row
}
export async function updateAiProvider(
@@ -56,6 +120,7 @@ export async function updateAiProvider(
apiKeyEncrypted: string
apiKeyLast4: string | null
isDefault: boolean
visibility: AiProviderVisibility
updatedBy: string
},
resetOtherDefaults: boolean
@@ -73,6 +138,7 @@ export async function updateAiProvider(
apiKeyEncrypted: data.apiKeyEncrypted,
apiKeyLast4: data.apiKeyLast4,
isDefault: data.isDefault,
visibility: data.visibility,
updatedBy: data.updatedBy,
})
.where(eq(aiProviders.id, id))
@@ -88,6 +154,7 @@ export async function createAiProvider(
apiKeyEncrypted: string
apiKeyLast4: string | null
isDefault: boolean
visibility: AiProviderVisibility
createdBy: string
updatedBy: string
},
@@ -105,6 +172,7 @@ export async function createAiProvider(
apiKeyEncrypted: data.apiKeyEncrypted,
apiKeyLast4: data.apiKeyLast4,
isDefault: data.isDefault,
visibility: data.visibility,
createdBy: data.createdBy,
updatedBy: data.updatedBy,
})
@@ -115,11 +183,20 @@ export async function createAiProvider(
* 删除 AI Provider
*
* 如果删除的是默认 Provider自动将最新的一条记录设为默认若存在
*
* @param id Provider ID
* @param userId 当前用户 ID用于所有权校验未传则不校验仅管理员路径使用
*/
export async function deleteAiProvider(id: string): Promise<{ wasDefault: boolean }> {
export async function deleteAiProvider(
id: string,
userId?: string
): Promise<{ wasDefault: boolean }> {
return await db.transaction(async (tx) => {
const [existing] = await tx
.select({ isDefault: aiProviders.isDefault })
.select({
isDefault: aiProviders.isDefault,
createdBy: aiProviders.createdBy,
})
.from(aiProviders)
.where(eq(aiProviders.id, id))
.limit(1)
@@ -128,6 +205,11 @@ export async function deleteAiProvider(id: string): Promise<{ wasDefault: boolea
return { wasDefault: false }
}
// 所有权校验:非创建者不能删除(管理员路径不传 userId
if (userId !== undefined && existing.createdBy !== userId) {
return { wasDefault: false }
}
await tx.delete(aiProviders).where(eq(aiProviders.id, id))
// 如果删除的是默认 Provider自动选一条最新的设为默认

View File

@@ -7,6 +7,13 @@ import type {
export type AiProviderName = "zhipu" | "openai" | "gemini" | "custom"
/**
* AI 服务商可见性
* - public: 管理员发布,全员可用
* - private: 仅创建者可见
*/
export type AiProviderVisibility = "public" | "private"
export interface AiProviderSummary {
id: string
provider: AiProviderName
@@ -14,6 +21,8 @@ export interface AiProviderSummary {
model: string
apiKeyLast4: string | null
isDefault: boolean
visibility: AiProviderVisibility
createdBy: string | null
updatedAt: Date
}
@@ -22,6 +31,8 @@ export interface AiProviderExisting {
apiKeyEncrypted: string
apiKeyLast4: string | null
isDefault: boolean
visibility: AiProviderVisibility
createdBy: string | null
}
/**