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,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" ? (
|
||||
|
||||
Reference in New Issue
Block a user