feat: update admin navigation and configuration for local deployment, enhance user experience with direct API connections

This commit is contained in:
HouYunFei
2026-06-16 12:07:35 +08:00
parent 0d90465339
commit 8a66524ea7
83 changed files with 649 additions and 7173 deletions
+49 -79
View File
@@ -9,7 +9,7 @@ import { fetchImageModels } from "@/services/api/image";
import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync";
import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync";
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
import { filterModelsByCapability, useConfigStore, useEffectiveConfig, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
import { filterModelsByCapability, useConfigStore, type ModelCapability } from "@/stores/use-config-store";
type ModelGroup = {
capability: ModelCapability;
@@ -67,26 +67,19 @@ export function AppConfigModal() {
const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue);
const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen);
const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue);
const publicSettings = useConfigStore((state) => state.publicSettings);
const effectiveConfig = useEffectiveConfig();
const modelChannel = publicSettings?.modelChannel;
const allowCustomChannel = modelChannel?.allowCustomChannel === true;
const effectiveMode = allowCustomChannel ? config.channelMode : "remote";
const modelConfig = effectiveMode === "remote" ? effectiveConfig : config;
const modelOptions = config.models.map((model) => ({ label: model, value: model }));
const webdavReady = Boolean(webdav.url.trim());
const finishConfig = () => {
setConfigDialogOpen(false);
if (effectiveMode === "local" && (!config.baseUrl.trim() || !config.apiKey.trim())) return;
if (!modelConfig.imageModel.trim() || !modelConfig.videoModel.trim() || !modelConfig.textModel.trim()) return;
if (!allowCustomChannel && config.channelMode !== "remote") updateConfig("channelMode", "remote");
if (!config.baseUrl.trim() || !config.apiKey.trim()) return;
if (!config.imageModel.trim() || !config.videoModel.trim() || !config.textModel.trim()) return;
if (config.channelMode !== "local") updateConfig("channelMode", "local");
message.success(shouldPromptContinue ? "配置已保存,请继续刚才的请求" : "配置已保存");
clearPromptContinue();
};
const refreshModels = async () => {
if (effectiveMode === "remote") return;
if (!config.baseUrl.trim() || !config.apiKey.trim()) {
message.error("请先填写 Base URL 和 API Key");
return;
@@ -197,74 +190,53 @@ export function AppConfigModal() {
>
<div className="pt-1">
<Form layout="vertical" requiredMark={false}>
{allowCustomChannel ? (
<Form.Item label="渠道模式" className="mb-5">
<Segmented
block
size="middle"
value={effectiveMode}
onChange={(value) => updateConfig("channelMode", value as AiConfig["channelMode"])}
options={[
{ label: "本地直连", value: "local" },
{ label: "云端渠道", value: "remote" },
]}
/>
<div className="mb-5 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="text-sm font-semibold"></div>
<div className="mt-1 text-xs leading-5 text-stone-500">AI OpenAI API Key </div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Form.Item label="Base URL" className="mb-4">
<Input value={config.baseUrl} onChange={(event) => updateConfig("baseUrl", event.target.value)} />
</Form.Item>
) : null}
{effectiveMode === "local" ? (
<>
<div className="grid gap-4 md:grid-cols-2">
<Form.Item label="Base URL" className="mb-4">
<Input value={config.baseUrl} onChange={(event) => updateConfig("baseUrl", event.target.value)} />
</Form.Item>
<Form.Item label="API Key" className="mb-4">
<Input.Password value={config.apiKey} onChange={(event) => updateConfig("apiKey", event.target.value)} />
</Form.Item>
</div>
<div className="mb-5 flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-3 py-2 dark:border-stone-800">
<div className="min-w-0">
<div className="text-sm font-medium"></div>
<div className="mt-1 text-xs text-stone-500"> {config.models.length} </div>
</div>
<Button size="small" loading={loadingModels} onClick={() => void refreshModels()}>
</Button>
</div>
</>
) : (
<div className="mb-5 rounded-lg border border-stone-200 p-3 text-sm text-stone-500 dark:border-stone-800">
<div className="font-medium text-stone-900 dark:text-stone-100"></div>
<div className="mt-1"> {modelChannel?.availableModels.length || 0} </div>
<Form.Item label="API Key" className="mb-4">
<Input.Password value={config.apiKey} onChange={(event) => updateConfig("apiKey", event.target.value)} />
</Form.Item>
</div>
<div className="mb-5 flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-3 py-2 dark:border-stone-800">
<div className="min-w-0">
<div className="text-sm font-medium"></div>
<div className="mt-1 text-xs text-stone-500"> {config.models.length} </div>
</div>
)}
{effectiveMode === "local" ? (
<section className="mb-5 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="mb-3">
<div className="text-sm font-semibold"></div>
<div className="mt-1 text-xs text-stone-500"></div>
</div>
<div className="grid gap-4 md:grid-cols-2">
{modelGroups.map((group) => (
<Form.Item key={group.modelsKey} label={group.optionsLabel} className="mb-0">
<Select
mode="multiple"
showSearch
allowClear
maxTagCount="responsive"
placeholder={config.models.length ? `请选择${group.optionsLabel}` : "请先拉取模型列表"}
value={config[group.modelsKey]}
options={modelOptions}
onChange={(models) => updateCapabilityModels(group, models)}
/>
</Form.Item>
))}
</div>
</section>
) : null}
<Button size="small" loading={loadingModels} onClick={() => void refreshModels()}>
</Button>
</div>
<section className="mb-5 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="mb-3">
<div className="text-sm font-semibold"></div>
<div className="mt-1 text-xs text-stone-500"></div>
</div>
<div className="grid gap-4 md:grid-cols-2">
{modelGroups.map((group) => (
<Form.Item key={group.modelsKey} label={group.optionsLabel} className="mb-0">
<Select
mode="tags"
showSearch
allowClear
maxTagCount="responsive"
placeholder={config.models.length ? `请选择或输入${group.optionsLabel}` : "输入模型名,或先拉取模型列表"}
value={config[group.modelsKey]}
options={modelOptions}
onChange={(models) => updateCapabilityModels(group, models)}
/>
</Form.Item>
))}
</div>
</section>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{modelGroups.map((group) => (
<Form.Item key={group.modelKey} label={group.defaultLabel} className="mb-4">
<ModelPicker config={modelConfig} value={modelConfig[group.modelKey]} onChange={(model) => updateConfig(group.modelKey, model)} capability={group.capability} fullWidth />
<ModelPicker config={config} value={config[group.modelKey]} onChange={(model) => updateConfig(group.modelKey, model)} capability={group.capability} fullWidth />
</Form.Item>
))}
</div>
@@ -300,11 +272,9 @@ export function AppConfigModal() {
<Form.Item label="默认音频指令" className="mb-4">
<Input.TextArea rows={2} value={config.audioInstructions} placeholder="例如:自然、温暖、适合旁白。" onChange={(event) => updateConfig("audioInstructions", event.target.value)} />
</Form.Item>
{effectiveMode === "local" ? (
<Form.Item label="系统提示词" className="mb-0">
<Input.TextArea rows={3} value={config.systemPrompt} placeholder="例如:你是一位擅长电影感写实摄影的视觉导演。" onChange={(event) => updateConfig("systemPrompt", event.target.value)} />
</Form.Item>
) : null}
<Form.Item label="系统提示词" className="mb-0">
<Input.TextArea rows={3} value={config.systemPrompt} placeholder="例如:你是一位擅长电影感写实摄影的视觉导演。" onChange={(event) => updateConfig("systemPrompt", event.target.value)} />
</Form.Item>
<section className="mt-5 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
<div>
+2 -17
View File
@@ -2,54 +2,39 @@
import type { ReactNode } from "react";
import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
import { App } from "antd";
import { useConfigStore } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
export function ClientRootInit({ children }: { children: ReactNode }) {
const { message } = App.useApp();
const handledConfigParams = useRef(false);
const pathname = usePathname();
const hydrateUser = useUserStore((state) => state.hydrateUser);
const loadPublicSettings = useConfigStore((state) => state.loadPublicSettings);
const publicSettings = useConfigStore((state) => state.publicSettings);
const updateConfig = useConfigStore((state) => state.updateConfig);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const isLoginPage = pathname === "/login" || pathname === "/admin/login";
useEffect(() => {
void loadPublicSettings();
}, [loadPublicSettings]);
useEffect(() => {
if (!isLoginPage) void hydrateUser();
}, [hydrateUser, isLoginPage]);
useEffect(() => {
if (handledConfigParams.current) return;
const searchParams = new URLSearchParams(window.location.search);
const baseUrl = searchParams.get("baseUrl") || searchParams.get("baseurl");
const apiKey = searchParams.get("apiKey") || searchParams.get("apikey");
if (!baseUrl && !apiKey) return;
if (!publicSettings) return;
handledConfigParams.current = true;
searchParams.delete("baseUrl");
searchParams.delete("baseurl");
searchParams.delete("apiKey");
searchParams.delete("apikey");
window.history.replaceState(null, "", `${window.location.pathname}${searchParams.size ? `?${searchParams}` : ""}${window.location.hash}`);
if (!publicSettings.modelChannel.allowCustomChannel) {
openConfigDialog(false);
message.error("后台未允许用户自定义渠道,请联系管理员进行配置");
return;
}
updateConfig("channelMode", "local");
if (baseUrl) updateConfig("baseUrl", baseUrl);
if (apiKey) updateConfig("apiKey", apiKey);
openConfigDialog(false);
}, [message, openConfigDialog, publicSettings, updateConfig]);
message.success("已导入本地直连配置");
}, [message, openConfigDialog, updateConfig]);
return <>{children}</>;
}
@@ -1,15 +1,13 @@
"use client";
import type { CSSProperties, RefObject } from "react";
import { Avatar, Dropdown, Tooltip } from "antd";
import { BookOpen, Keyboard, LogOut, Settings2, Shield } from "lucide-react";
import { Avatar, Dropdown } from "antd";
import { BookOpen, Keyboard, LogOut, Settings2 } from "lucide-react";
import type { ItemType } from "antd/es/menu/interface";
import Link from "next/link";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import { GitHubLink } from "@/components/layout/github-link";
import { VersionReleaseModal } from "@/components/layout/version-release-modal";
import { CreditSymbol } from "@/constant/credits";
import { DOCS_URL } from "@/constant/env";
import { cn } from "@/lib/utils";
import { canvasThemes } from "@/lib/canvas-theme";
@@ -35,7 +33,6 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const canvasTheme = canvasThemes[theme];
const userName = user?.displayName || user?.username || "";
const credits = user?.credits ?? 0;
const avatarUrl = user?.avatarUrl?.trim();
const avatarText = (userName.trim()[0] || "U").toUpperCase();
const naturalIconClass = "inline-flex size-7 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white [&_svg]:size-4";
@@ -46,10 +43,9 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
const avatarStyle: CSSProperties | undefined = variant === "canvas" ? { borderColor: canvasTheme.toolbar.border, color: canvasTheme.node.text, background: "transparent" } : undefined;
const menuItems: ItemType[] = [
{ key: "user", disabled: true, label: <span className="font-medium text-current">{userName}</span> },
...(user?.role === "admin" ? [{ key: "admin", icon: <Shield className="size-4" />, label: <Link href="/admin"></Link> }] : []),
...(onOpenShortcuts ? [{ key: "shortcuts", icon: <Keyboard className="size-4" />, label: "快捷键", onClick: onOpenShortcuts }] : []),
{ type: "divider" },
{ key: "logout", icon: <LogOut className="size-4" />, label: "退出登录", onClick: logout },
{ key: "logout", icon: <LogOut className="size-4" />, label: "清除本地账户", onClick: logout },
];
return (
@@ -65,24 +61,11 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
<AnimatedThemeToggler theme={theme} onThemeChange={setTheme} className={naturalIconClass} style={iconStyle} aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} />
<VersionReleaseModal style={versionStyle} />
<GitHubLink className={cn("bg-transparent hover:bg-transparent dark:hover:bg-transparent", gitHubClassName)} style={gitHubStyle} />
{variant === "canvas" && user ? (
<Tooltip title="当前算力点余额" placement="bottom">
<div className="flex h-8 shrink-0 items-center gap-1.5 px-1.5 text-xs font-medium tabular-nums opacity-75 transition hover:opacity-100" style={{ color: canvasTheme.node.text }}>
<CreditSymbol className="text-sm leading-none" />
<span>{credits.toLocaleString()}</span>
</div>
</Tooltip>
) : null}
{!user && onOpenShortcuts ? (
<button type="button" className={naturalIconClass} style={iconStyle} onClick={onOpenShortcuts} aria-label="快捷键" title="快捷键">
<Keyboard className="size-4" />
</button>
) : null}
{!user ? (
<Link href="/login" className="px-1.5 text-sm font-medium text-stone-600 underline-offset-4 transition hover:text-stone-950 hover:underline dark:text-stone-300 dark:hover:text-stone-100" style={iconStyle}>
</Link>
) : null}
{user ? (
<div ref={accountRef}>
<Dropdown open={accountOpen} onOpenChange={onAccountOpenChange} trigger={["click"]} placement="bottomRight" getPopupContainer={getPopupContainer} styles={{ root: { minWidth: 150 } }} menu={{ items: menuItems }}>
-1
View File
@@ -85,7 +85,6 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
const label = capability === "image" ? "生图" : capability === "video" ? "视频" : capability === "text" ? "文本" : capability === "audio" ? "音频" : "";
if (config.channelMode === "remote") return `暂无可用${label}模型`;
if (capability && config.models.length) return "请先在上方配置可选模型";
return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里拉取模型列表";
}