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:
@@ -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 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user