feat(settings,questions,school,textbooks): add brand config, question components, school dialogs, textbooks hooks

settings:

- Add actions-brand, brand-config, data-access-brand for brand management

- Add admin-file-upload-card, admin-notification-config-card, admin-school-info-card, admin-security-policy-card

- Add ai-provider-delete-dialog, ai-provider-selector, brand-config-card

- Add security-recent-logins-section, security-two-factor-section

- Add config/profile-overview-config, data-access-profile-overview, lib/system-settings-utils

questions:

- Add batch-operations, import-export-buttons, knowledge-point-selector, options-editor

- Add question-bank-results-client, question-cascade-filter, question-content-renderer, utils

school:

- Add grade-delete-dialog, grade-form-dialog, grade-list-toolbar, grade-overview-cards

- Add use-grade-data hook

textbooks:

- Add textbook-form-fields component

- Add use-kp-create, use-kp-delete, use-kp-update hooks
This commit is contained in:
SpecialX
2026-07-03 10:26:00 +08:00
parent 138b6f1b00
commit f3c223d914
77 changed files with 5397 additions and 2864 deletions

View File

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