feat: 完成短剧项目空间与结构化资产创作闭环 (#38)

* feat(canvas): 短剧项目空间 - 建立项目章节与画布关联 MVP

* feat(canvas): 短剧项目空间 - 完善项目资产、镜头与流程闭环

Refs #30

* fix(deploy): 本地部署 - 修复前端构建并忽略本地 PostgreSQL 配置

* feat(*): 短剧项目空间 - 完成项目、章节、资产、角色与画布创作闭环

Refs #30

---------

Co-authored-by: ddCat <ddcat666@88.com>
This commit is contained in:
ddcat666
2026-07-27 10:45:11 +08:00
committed by GitHub
parent 431b9e3294
commit d2fbc413b3
208 changed files with 22655 additions and 4234 deletions

View File

@@ -0,0 +1,64 @@
import { InputNumber, Segmented } from "antd";
import { modelOptionName, type ModelChannel } from "@/stores/use-config-store";
type ModelCost = NonNullable<ModelChannel["modelCosts"]>[number];
export function ChannelVideoPricing({ channel, onChange }: { channel: ModelChannel; onChange: (costs: ModelCost[]) => void }) {
if (!isVideoChannel(channel) || !channel.models.length) return null;
const updateCost = (model: string, patch: Partial<ModelCost>) => {
const current = channel.modelCosts?.find((item) => item.model === model) || {
model,
capability: "video" as const,
billingMode: "fixed_request" as const,
unitPriceMicrocredits: 0,
};
const next = [...(channel.modelCosts || []).filter((item) => item.model !== model), { ...current, ...patch, model, capability: "video" as const }];
onChange(next.filter((item) => channel.models.includes(item.model)));
};
return (
<div className="mt-3 border-t border-border/70 pt-3">
<div className="mb-2 flex items-center justify-between gap-3">
<div><div className="text-xs font-medium">视频模型价格</div><div className="mt-0.5 text-[10px] text-foreground/42">仅用于记录自定义渠道成本,不扣平台积分</div></div>
<span className="text-[10px] text-foreground/35">{channel.models.length} 个模型</span>
</div>
<div className="divide-y divide-border/60 rounded-md border border-border/70">
{channel.models.map((rawModel) => {
const model = modelOptionName(rawModel);
const cost = channel.modelCosts?.find((item) => item.model === model);
const billingMode = cost?.billingMode || "fixed_request";
return (
<div key={model} className="grid gap-2 px-2.5 py-2 sm:grid-cols-[minmax(0,1fr)_176px_160px] sm:items-center">
<span className="truncate text-xs font-medium" title={model}>{model}</span>
<Segmented
size="small"
block
value={billingMode}
options={[{ label: "按次", value: "fixed_request" }, { label: "按秒", value: "per_second" }]}
onChange={(value) => updateCost(model, { billingMode: value as ModelCost["billingMode"] })}
/>
<InputNumber
size="small"
min={0}
max={1_000_000}
precision={6}
step={0.1}
className="w-full"
placeholder={billingMode === "per_second" ? "每秒价格" : "每次价格"}
addonAfter={`积分/${billingMode === "per_second" ? "秒" : "次"}`}
value={cost ? cost.unitPriceMicrocredits / 1_000_000 : null}
onChange={(value) => updateCost(model, { unitPriceMicrocredits: Math.round(Number(value || 0) * 1_000_000) })}
/>
</div>
);
})}
</div>
</div>
);
}
function isVideoChannel(channel: ModelChannel) {
return channel.interfaceType === "newapi" || channel.interfaceType === "newapi-channel-1" || channel.interfaceType === "newapi-channel-2" || channel.interfaceType === "xai-video";
}

View File

@@ -0,0 +1,640 @@
import { App, Button, Form, Input, InputNumber, Popconfirm, Select, Tag, Tooltip } from "antd";
import { ArrowLeft, Boxes, CircleCheck, Cloud, Info, Plus, RadioTower, RefreshCw, SlidersHorizontal, Trash2 } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { UserOSSSettingsForm } from "@/components/layout/user-oss-settings-form";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal-icon";
import { refreshSystemChannels } from "@/lib/user-session";
import { fetchChannelModels } from "@/services/api/image";
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
import {
createModelChannel,
defaultBaseUrlForApiFormat,
defaultBaseUrlForChannelInterface,
defaultConfig,
filterModelsByCapability,
modelOptionsFromChannels,
useConfigStore,
type AiConfig,
type ChannelInterfaceType,
type ModelChannel,
} from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
import { ChannelVideoPricing } from "./channel-video-pricing";
import { ModelDefaultGrid } from "./model-default-grid";
type ConfigSectionKey = "channels" | "models" | "preferences" | "storage";
const configSections: Array<{ key: ConfigSectionKey; label: string; description: string; icon: ReactNode }> = [
{ key: "channels", label: "自定义渠道", description: "连接你自己的模型服务", icon: <RadioTower className="size-4" /> },
{ key: "models", label: "模型选择", description: "按领域选择默认模型", icon: <Boxes className="size-4" /> },
{ key: "preferences", label: "生成偏好", description: "画布、音频与系统提示词", icon: <SlidersHorizontal className="size-4" /> },
{ key: "storage", label: "我的 OSS", description: "管理个人媒体存储", icon: <Cloud className="size-4" /> },
];
type UserChannelProtocol = ChannelInterfaceType | "auto" | "gemini";
const channelProtocolOptions = [
{ label: "OpenAI 自动兼容", value: "auto" },
{ label: "Gemini 原生", value: "gemini" },
{
label: "文本",
options: [
{ label: "Chat Completions", value: "chat-completion" },
{ label: "OpenAI Responses", value: "openai-response" },
],
},
{ label: "图片", options: [{ label: "OpenAI Images", value: "openai-image" }] },
{
label: "视频",
options: [
{ label: "NewAPI 视频", value: "newapi" },
{ label: "NewAPI 渠道 1", value: "newapi-channel-1" },
{ label: "NewAPI 渠道 2", value: "newapi-channel-2" },
{ label: "xAI / Sub2API 视频", value: "xai-video" },
],
},
];
function isConfigSection(value: string | null): value is ConfigSectionKey {
return configSections.some((section) => section.key === value);
}
export default function SettingsPage() {
const { message } = App.useApp();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const requestedSection = searchParams.get("section");
const [activeTab, setActiveTab] = useState<ConfigSectionKey>(isConfigSection(requestedSection) ? requestedSection : "channels");
const [loadingChannelIds, setLoadingChannelIds] = useState<string[]>([]);
const config = useConfigStore((state) => state.config);
const updateConfig = useConfigStore((state) => state.updateConfig);
const replaceConfig = useConfigStore((state) => state.replaceConfig);
const shouldPromptContinue = searchParams.get("continue") === "1";
const userId = useUserStore((state) => state.user?.id);
const userChannels = config.channels.filter((channel) => channel.scope !== "system");
useEffect(() => {
if (isConfigSection(requestedSection)) setActiveTab(requestedSection);
}, [requestedSection]);
useEffect(() => {
if (!userId) return;
let cancelled = false;
void refreshSystemChannels().catch((error) => {
if (!cancelled) message.warning(error instanceof Error ? `系统模型刷新失败:${error.message}` : "系统模型刷新失败,继续使用本地缓存");
});
return () => {
cancelled = true;
};
}, [message, userId]);
const selectSection = (section: ConfigSectionKey) => {
setActiveTab(section);
const next = new URLSearchParams(searchParams);
next.set("section", section);
setSearchParams(next, { replace: true });
};
const finishConfig = () => {
const invalidChannel = userChannels.find((channel) => channelValidationError(channel));
if (invalidChannel) {
selectSection("channels");
message.warning(`${invalidChannel.name || "未命名渠道"}:${channelValidationError(invalidChannel)}`);
focusInvalidChannelField(invalidChannel);
return;
}
const ready = config.channels.some(isChannelReady);
if (!ready) {
selectSection("channels");
message.error(shouldPromptContinue ? "请先完成至少一个渠道的 Base URL、API Key 和模型配置" : "当前没有可用渠道,请先完成连接信息和模型配置");
return;
}
message.success("配置已保存,正在返回创作页面");
navigate(-1);
};
const updateChannels = (channels: ModelChannel[], baseConfig = config) => {
replaceConfig(withChannels(baseConfig, channels));
};
const updateChannel = (id: string, patch: Partial<ModelChannel>) => {
updateChannels(config.channels.map((channel) => {
if (channel.id !== id) return channel;
const models = patch.models ? uniqueModels(patch.models) : channel.models;
return {
...channel,
...patch,
models,
modelCosts: patch.modelCosts !== undefined ? patch.modelCosts : (patch.models ? channel.modelCosts?.filter((item) => models.includes(item.model)) : channel.modelCosts),
};
}));
};
const updateChannelProtocol = (channel: ModelChannel, protocol: UserChannelProtocol) => {
const apiFormat = protocol === "gemini" ? "gemini" : "openai";
const interfaceType = protocol === "auto" || protocol === "gemini" ? undefined : protocol;
const defaultBaseUrl = protocol === "gemini" ? defaultBaseUrlForApiFormat("gemini") : defaultBaseUrlForChannelInterface(interfaceType);
const baseUrl = isKnownDefaultBaseUrl(channel.baseUrl) ? defaultBaseUrl : channel.baseUrl;
const videoProtocol = interfaceType === "newapi" || interfaceType === "newapi-channel-1" || interfaceType === "newapi-channel-2" || interfaceType === "xai-video";
updateChannel(channel.id, { apiFormat, interfaceType, baseUrl, modelCosts: videoProtocol ? channel.modelCosts : [] });
};
const addChannel = () => {
const channel = createModelChannel({ name: `渠道 ${userChannels.length + 1}` });
updateChannels([...config.channels, channel]);
requestAnimationFrame(() => document.getElementById(`channel-${channel.id}-name`)?.focus());
};
const deleteChannel = (id: string) => {
const channel = config.channels.find((item) => item.id === id);
if (channel?.scope === "system") {
message.warning("系统渠道由管理员维护");
return;
}
updateChannels(config.channels.filter((item) => item.id !== id));
};
const setChannelLoading = (id: string, loading: boolean) => {
setLoadingChannelIds((items) => (loading ? Array.from(new Set([...items, id])) : items.filter((item) => item !== id)));
};
const refreshChannelModels = async (channel: ModelChannel) => {
const connectionError = channelConnectionError(channel);
if (connectionError) {
message.error(`${channel.name || "当前渠道"}:${connectionError}`);
return;
}
setChannelLoading(channel.id, true);
try {
const models = await fetchChannelModels(channel);
if (!models.length) {
message.warning(`${channel.name || "当前渠道"}未返回模型,已保留现有手工模型`);
return;
}
const latestConfig = useConfigStore.getState().config;
const latestChannel = latestConfig.channels.find((item) => item.id === channel.id);
if (!latestChannel) return;
if (channelConnectionSignature(latestChannel) !== channelConnectionSignature(channel)) {
message.warning(`${latestChannel.name || "当前渠道"}的连接配置已改变,已忽略旧的拉取结果`);
return;
}
updateChannels(
latestConfig.channels.map((item) => (item.id === channel.id ? { ...item, models } : item)),
latestConfig,
);
message.success(`${latestChannel.name || "当前渠道"}模型列表已更新`);
} catch (error) {
message.error(error instanceof Error ? `${error.message};也可以直接在模型列表中手动输入模型名` : "读取模型失败,可直接手动输入模型名");
} finally {
setChannelLoading(channel.id, false);
}
};
const refreshAllModels = async () => {
const runnable = userChannels.filter((channel) => !channelConnectionError(channel));
const skipped = userChannels.filter((channel) => channelConnectionError(channel));
if (!runnable.length) {
const detail = skipped.map((channel) => `${channel.name || "未命名渠道"}:${channelConnectionError(channel)}`).join(";");
message.error(detail || "没有可拉取的自定义渠道,请先填写有效 Base URL 和 API Key");
return;
}
setChannelLoading("all", true);
try {
const results = await Promise.all(
runnable.map(async (channel) => {
try {
const models = await fetchChannelModels(channel);
return { channel, models, error: "" };
} catch (error) {
return { channel, models: [] as string[], error: error instanceof Error ? error.message : "读取失败" };
}
}),
);
const latestConfig = useConfigStore.getState().config;
const successful = results.filter((item) => {
const latestChannel = latestConfig.channels.find((channel) => channel.id === item.channel.id);
return Boolean(item.models.length && latestChannel && channelConnectionSignature(latestChannel) === channelConnectionSignature(item.channel));
});
const stale = results.filter((item) => {
const latestChannel = latestConfig.channels.find((channel) => channel.id === item.channel.id);
return Boolean(item.models.length && (!latestChannel || channelConnectionSignature(latestChannel) !== channelConnectionSignature(item.channel)));
});
const failed = results.filter((item) => !item.models.length);
if (successful.length) {
const modelMap = new Map(successful.map((item) => [item.channel.id, item.models] as const));
updateChannels(
latestConfig.channels.map((channel) => (modelMap.has(channel.id) ? { ...channel, models: modelMap.get(channel.id) || channel.models } : channel)),
latestConfig,
);
message.success(`已更新 ${successful.length} 个渠道的模型`);
}
const warnings = [
...failed.map((item) => `${item.channel.name || "未命名渠道"}:${item.error || "未返回模型"}`),
...stale.map((item) => `${item.channel.name || "未命名渠道"}:连接配置已改变,已忽略旧结果`),
...skipped.map((channel) => `${channel.name || "未命名渠道"}:${channelConnectionError(channel)}`),
];
if (warnings.length) {
message.warning(`${warnings.join(";")}。未更新的渠道已保留原有模型列表`);
}
} catch (error) {
message.error(error instanceof Error ? error.message : "批量读取模型失败,原有模型列表未改动");
} finally {
setChannelLoading("all", false);
}
};
return (
<main className="flex h-full min-h-0 flex-col bg-background text-foreground">
<header className="flex min-h-16 shrink-0 items-center justify-between gap-4 border-b border-border/70 px-4 py-3 sm:px-5">
<div className="flex min-w-0 items-center gap-3">
{shouldPromptContinue ? (
<button type="button" className="app-workspace-icon-button shrink-0" onClick={() => navigate(-1)} aria-label="返回创作页面" title="返回创作页面"><ArrowLeft className="size-4" /></button>
) : null}
<WorkspaceSignalIcon variant="settings" />
<div className="min-w-0">
<h1 className="truncate text-base font-semibold">配置与用户偏好</h1>
<p className="mt-0.5 truncate text-xs text-foreground/50">模型连接、生成默认值与个人媒体存储</p>
</div>
</div>
{shouldPromptContinue ? <Button type="primary" onClick={finishConfig}>保存并返回创作</Button> : null}
</header>
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
<aside className="w-full shrink-0 border-b border-border/70 bg-muted/[.12] p-2 md:w-[224px] md:border-b-0 md:border-r md:p-3">
<nav className="thin-scrollbar flex gap-1 overflow-x-auto md:block md:space-y-1" aria-label="配置分类">
{configSections.map((item) => {
const selected = item.key === activeTab;
return (
<button
key={item.key}
type="button"
className={`flex h-9 shrink-0 items-center gap-2 rounded-md px-3 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:h-auto md:w-full md:items-start md:gap-3 md:py-2.5 ${selected ? "bg-[var(--workspace-accent-soft)] text-foreground" : "text-foreground/58 hover:bg-muted/55 hover:text-foreground"}`}
onClick={() => selectSection(item.key)}
aria-current={selected ? "page" : undefined}
>
<span className={`shrink-0 md:mt-0.5 ${selected ? "text-[var(--workspace-accent)]" : ""}`}>{item.icon}</span>
<span className="min-w-0"><span className="block whitespace-nowrap text-sm font-medium">{item.label}</span><span className="mt-1 hidden text-[11px] leading-4 text-current opacity-65 md:block">{item.description}</span></span>
</button>
);
})}
</nav>
</aside>
<section className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 md:px-6 md:py-5">
<div className="mx-auto w-full max-w-[1180px]">
{([
{
key: "channels",
label: "渠道",
children: (
<SettingsPane>
<Form layout="vertical" requiredMark={false}>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
<div className="min-w-0 flex-1">
<div className="flex w-fit max-w-full flex-wrap items-center gap-1.5 text-xs text-foreground/65">
<Info className="size-3.5 shrink-0" />
<span>模型会按渠道协议和后台能力自动归类,拉取完成后可直接选择默认模型。</span>
<Button type="link" size="small" className="h-auto p-0 text-xs font-semibold" onClick={() => selectSection("models")}>
打开模型选择
</Button>
</div>
</div>
<div className="flex w-full gap-2 sm:w-auto sm:shrink-0">
<Button
className="h-10 flex-1 sm:h-8 sm:flex-none"
icon={<RefreshCw className="size-4" />}
loading={loadingChannelIds.includes("all")}
disabled={loadingChannelIds.some((id) => id !== "all")}
onClick={() => void refreshAllModels()}
>
拉取全部
</Button>
<Button className="h-10 flex-1 sm:h-8 sm:flex-none" type="primary" icon={<Plus className="size-4" />} onClick={addChannel}>
新增渠道
</Button>
</div>
</div>
{userChannels.length ? (
<div className="space-y-3">
{userChannels.map((channel) => (
<section key={channel.id} aria-labelledby={`channel-${channel.id}-title`} className="rounded-md border border-border bg-background p-3">
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1 basis-52">
<h3 id={`channel-${channel.id}-title`} className="truncate text-sm font-semibold">
{channel.name || "未命名渠道"}
</h3>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-foreground/55">
{channelProtocolLabel(channel)} · 已保存 {channel.models.length} 个模型
<ChannelStatus channel={channel} />
</div>
</div>
<div className="flex w-full justify-end gap-2 sm:w-auto sm:shrink-0">
<Button
className="h-10 sm:h-8"
size="small"
icon={<RefreshCw className="size-3.5" />}
loading={loadingChannelIds.includes(channel.id)}
disabled={loadingChannelIds.includes("all")}
onClick={() => void refreshChannelModels(channel)}
>
拉取模型
</Button>
<Popconfirm title="删除自定义渠道?" description="该渠道关联的模型选择会同时移除。" okText="删除" cancelText="取消" okButtonProps={{ danger: true }} onConfirm={() => deleteChannel(channel.id)}>
<Tooltip title="删除渠道">
<Button
className="size-10 p-0 sm:size-8"
aria-label={`删除渠道 ${channel.name || "未命名渠道"}`}
size="small"
danger
disabled={loadingChannelIds.includes(channel.id) || loadingChannelIds.includes("all")}
icon={<Trash2 className="size-3.5" />}
/>
</Tooltip>
</Popconfirm>
</div>
</div>
<div className="grid gap-x-3 gap-y-2 lg:grid-cols-12">
<Form.Item label="渠道名称" htmlFor={`channel-${channel.id}-name`} className="mb-0 lg:col-span-3">
<Input
id={`channel-${channel.id}-name`}
value={channel.name}
placeholder="例如:我的 NewAPI"
onChange={(event) => updateChannel(channel.id, { name: event.target.value })}
onBlur={(event) => updateChannel(channel.id, { name: event.target.value.trim() || "未命名渠道" })}
/>
</Form.Item>
<Form.Item label="接口协议" htmlFor={`channel-${channel.id}-protocol`} className="mb-0 lg:col-span-3">
<Select<UserChannelProtocol>
id={`channel-${channel.id}-protocol`}
value={channelProtocolValue(channel)}
options={channelProtocolOptions}
onChange={(value) => updateChannelProtocol(channel, value)}
/>
</Form.Item>
<Form.Item label="Base URL" htmlFor={`channel-${channel.id}-base-url`} className="mb-0 lg:col-span-6">
<Input
id={`channel-${channel.id}-base-url`}
inputMode="url"
value={channel.baseUrl}
placeholder="填写渠道 Base URL"
onChange={(event) => updateChannel(channel.id, { baseUrl: event.target.value })}
onBlur={(event) => updateChannel(channel.id, { baseUrl: event.target.value.trim().replace(/\/+$/, "") })}
/>
</Form.Item>
<Form.Item label="API Key" htmlFor={`channel-${channel.id}-api-key`} className="mb-0 lg:col-span-5">
<Input.Password
id={`channel-${channel.id}-api-key`}
autoComplete="new-password"
value={channel.apiKey}
placeholder={channel.apiFormat === "gemini" ? "填写 Gemini API Key" : "填写当前渠道 API Key"}
onChange={(event) => updateChannel(channel.id, { apiKey: event.target.value })}
onBlur={(event) => updateChannel(channel.id, { apiKey: event.target.value.trim() })}
/>
</Form.Item>
<Form.Item label="模型列表" htmlFor={`channel-${channel.id}-models`} className="mb-0 lg:col-span-7">
<Select
id={`channel-${channel.id}-models`}
mode="tags"
showSearch
allowClear
maxTagCount="responsive"
tokenSeparators={[",", "\n"]}
placeholder="输入模型名,或点击拉取模型"
value={channel.models}
onChange={(models) => updateChannel(channel.id, { models: uniqueModels(models) })}
/>
</Form.Item>
</div>
<ChannelVideoPricing channel={channel} onChange={(modelCosts) => updateChannel(channel.id, { modelCosts })} />
</section>
))}
</div>
) : (
<WorkspaceState
icon="settings"
compact
title="当前没有自定义渠道"
description="管理员配置的系统渠道会出现在模型选择中;也可以添加自己的模型服务。"
action={<Button icon={<Plus className="size-4" />} onClick={addChannel}>新增自定义渠道</Button>}
/>
)}
</Form>
</SettingsPane>
),
},
{
key: "models",
label: "模型",
children: (
<SettingsPane>
<ModelDefaultGrid config={config} onChange={(key, model) => updateConfig(key, model)} />
</SettingsPane>
),
},
{
key: "preferences",
label: "生成偏好",
children: (
<SettingsPane>
<Form layout="vertical" requiredMark={false}>
<section className="border-b border-border pb-6">
<div className="mb-4"><h3 className="text-sm font-semibold">画布生成</h3><p className="mt-1 text-xs text-foreground/55">设置新建生成任务时使用的初始值,节点内仍可单独覆盖。</p></div>
<Form.Item label="默认生图张数" className="mb-0 max-w-xs">
<InputNumber
min={1}
max={15}
precision={0}
className="w-full"
value={Number(config.canvasImageCount)}
onChange={(value) => updateConfig("canvasImageCount", normalizeImageCount(String(value ?? defaultConfig.canvasImageCount)))}
/>
</Form.Item>
</section>
<section className="border-b border-border py-6">
<div className="mb-4"><h3 className="text-sm font-semibold">音频默认值</h3><p className="mt-1 text-xs text-foreground/55">用于新建音频节点和未单独设置参数的生成任务。</p></div>
<div className="grid gap-4 md:grid-cols-3">
<Form.Item label="默认声音" className="mb-0">
<Select value={config.audioVoice} options={audioVoiceOptions} onChange={(value) => updateConfig("audioVoice", value)} />
</Form.Item>
<Form.Item label="文件格式" className="mb-0">
<Select value={config.audioFormat} options={audioFormatOptions} onChange={(value) => updateConfig("audioFormat", value)} />
</Form.Item>
<Form.Item label="语速" className="mb-0">
<InputNumber
min={0.25}
max={4}
step={0.05}
precision={2}
className="w-full"
value={Number(config.audioSpeed)}
onChange={(value) => updateConfig("audioSpeed", normalizeAudioSpeedValue(String(value ?? defaultConfig.audioSpeed)))}
/>
</Form.Item>
</div>
</section>
<section className="pt-6">
<div className="mb-4"><h3 className="text-sm font-semibold">默认指令</h3><p className="mt-1 text-xs text-foreground/55">在未单独填写时附加到对应生成请求。</p></div>
<div className="grid gap-5 lg:grid-cols-2">
<Form.Item label="音频指令" className="mb-0">
<Input.TextArea rows={5} value={config.audioInstructions} placeholder="例如:自然、温暖、适合旁白。" onChange={(event) => updateConfig("audioInstructions", event.target.value)} />
</Form.Item>
<Form.Item label="系统提示词" className="mb-0">
<Input.TextArea rows={5} value={config.systemPrompt} placeholder="例如:你是一位擅长电影感写实摄影的视觉导演。" onChange={(event) => updateConfig("systemPrompt", event.target.value)} />
</Form.Item>
</div>
</section>
</Form>
</SettingsPane>
),
},
{
key: "storage",
label: (
<span className="inline-flex items-center gap-2">
<Cloud className="size-4" />
我的 OSS
</span>
),
children: (
<SettingsPane>
<UserOSSSettingsForm />
</SettingsPane>
),
},
] as Array<{ key: ConfigSectionKey; label: ReactNode; children: ReactNode }>).find((item) => item.key === activeTab)?.children}
</div>
</div>
</section>
</div>
</main>
);
}
function SettingsPane({ children }: { children: ReactNode }) {
return <div>{children}</div>;
}
function ChannelStatus({ channel }: { channel: ModelChannel }) {
const error = channelValidationError(channel);
return error ? (
<Tag variant="filled" color="warning" className="m-0">
{error}
</Tag>
) : (
<Tag variant="filled" color="success" icon={<CircleCheck className="size-3" />} className="m-0">
可用
</Tag>
);
}
function withChannels(config: AiConfig, channels: ModelChannel[]): AiConfig {
const models = modelOptionsFromChannels(channels);
const imageModels = filterModelsByCapability(models, "image", channels);
const videoModels = filterModelsByCapability(models, "video", channels);
const textModels = filterModelsByCapability(models, "text", channels);
const audioModels = filterModelsByCapability(models, "audio", channels);
return {
...config,
channels,
models,
baseUrl: channels[0]?.baseUrl || config.baseUrl,
apiKey: channels[0]?.apiKey || config.apiKey,
apiFormat: channels[0]?.apiFormat || config.apiFormat,
imageModels,
videoModels,
textModels,
audioModels,
imageModel: normalizeDefaultModel(config.imageModel, imageModels),
videoModel: normalizeDefaultModel(config.videoModel, videoModels),
textModel: normalizeDefaultModel(config.textModel, textModels),
audioModel: normalizeDefaultModel(config.audioModel, audioModels),
};
}
function normalizeDefaultModel(value: string, options: string[]) {
if (options.includes(value)) return value;
return options[0] || "";
}
function normalizeImageCount(value: string) {
return String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || Number(defaultConfig.canvasImageCount)))));
}
function uniqueModels(models: string[]) {
return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean)));
}
function channelProtocolValue(channel: ModelChannel): UserChannelProtocol {
if (channel.apiFormat === "gemini") return "gemini";
return channel.interfaceType || "auto";
}
function channelConnectionError(channel: ModelChannel) {
const baseUrl = channel.baseUrl.trim();
if (!baseUrl) return "请填写 Base URL";
try {
const parsed = new URL(baseUrl);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "Base URL 只支持 HTTP 或 HTTPS";
} catch {
return "Base URL 格式不正确";
}
if (!channel.apiKey.trim()) return "请填写 API Key";
return "";
}
function channelConnectionSignature(channel: ModelChannel) {
return [channel.baseUrl.trim(), channel.apiKey.trim(), channel.apiFormat, channel.interfaceType || "auto"].join("\n");
}
function channelValidationError(channel: ModelChannel) {
return channelConnectionError(channel) || (!channel.models.length ? "请添加至少一个模型" : "");
}
function isChannelReady(channel: ModelChannel) {
return !channelValidationError(channel);
}
function focusInvalidChannelField(channel: ModelChannel) {
const baseUrlError = channelConnectionError({ ...channel, apiKey: "valid" });
const field = baseUrlError ? "base-url" : !channel.apiKey.trim() ? "api-key" : "models";
requestAnimationFrame(() => {
const element = document.getElementById(`channel-${channel.id}-${field}`);
element?.scrollIntoView({ behavior: "smooth", block: "center" });
element?.focus({ preventScroll: true });
});
}
function channelProtocolLabel(channel: ModelChannel) {
const protocol = channelProtocolValue(channel);
switch (protocol) {
case "gemini":
return "Gemini 原生";
case "chat-completion":
return "Chat Completions";
case "openai-response":
return "OpenAI Responses";
case "openai-image":
return "OpenAI Images";
case "newapi":
return "NewAPI 视频";
case "newapi-channel-1":
return "NewAPI 渠道 1";
case "newapi-channel-2":
return "NewAPI 渠道 2";
case "xai-video":
return "xAI / Sub2API 视频";
default:
return "OpenAI 自动兼容";
}
}
function isKnownDefaultBaseUrl(value: string) {
const normalized = value.trim().replace(/\/+$/, "");
if (!normalized) return true;
return [defaultBaseUrlForApiFormat("openai"), defaultBaseUrlForApiFormat("gemini")].some((candidate) => candidate.replace(/\/+$/, "") === normalized);
}

View File

@@ -0,0 +1,103 @@
import { Tag } from "antd";
import { AudioLines, Check, Film, Image, MessageSquareText } from "lucide-react";
import { ModelIcon } from "@/components/model-picker";
import { cn } from "@/lib/utils";
import {
filterModelsByCapability,
modelDisplayName,
modelOptionName,
resolveModelChannel,
type AiConfig,
type ModelCapability,
} from "@/stores/use-config-store";
type DefaultModelKey = "imageModel" | "videoModel" | "textModel" | "audioModel";
const groups: Array<{
capability: ModelCapability;
modelKey: DefaultModelKey;
title: string;
description: string;
icon: typeof Image;
}> = [
{ capability: "image", modelKey: "imageModel", title: "默认生图模型", description: "图片生成、图像编辑与视觉探索", icon: Image },
{ capability: "video", modelKey: "videoModel", title: "默认视频模型", description: "文生视频、图生视频与镜头延展", icon: Film },
{ capability: "text", modelKey: "textModel", title: "默认文本模型", description: "提示词改写、脚本与结构化文本", icon: MessageSquareText },
{ capability: "audio", modelKey: "audioModel", title: "默认音频模型", description: "语音、音效与音乐生成", icon: AudioLines },
];
export function ModelDefaultGrid({ config, onChange }: { config: AiConfig; onChange: (key: DefaultModelKey, model: string) => void }) {
return (
<div className="divide-y divide-border/70">
{groups.map((group) => {
const models = filterModelsByCapability(config.models, group.capability, config.channels);
const Icon = group.icon;
return (
<section key={group.capability} className="py-5 first:pt-0 last:pb-0" aria-labelledby={`default-${group.capability}-title`}>
<div className="mb-3 flex items-start gap-3">
<span className="grid size-8 shrink-0 place-items-center rounded-md border border-border bg-muted/35 text-foreground/65"><Icon className="size-4" /></span>
<div className="min-w-0">
<h3 id={`default-${group.capability}-title`} className="text-sm font-semibold">{group.title}</h3>
<p className="mt-0.5 text-xs text-foreground/48">{group.description}</p>
</div>
<span className="ml-auto shrink-0 text-xs tabular-nums text-foreground/38">{models.length} 个可用</span>
</div>
{models.length ? (
<div role="radiogroup" aria-label={group.title} className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{models.map((model) => {
const channel = resolveModelChannel(config, model);
const selected = config[group.modelKey] === model;
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(model));
return (
<button
key={model}
type="button"
role="radio"
aria-checked={selected}
className={cn(
"group relative min-h-[76px] overflow-hidden rounded-md border px-3 py-2.5 text-left transition-[border-color,background-color,box-shadow] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
selected
? "border-[var(--workspace-accent)] bg-[var(--workspace-accent-soft)] shadow-[inset_0_1px_0_color-mix(in_srgb,var(--workspace-accent)_28%,transparent)]"
: "border-border/85 bg-background hover:border-foreground/25 hover:bg-muted/25",
)}
onClick={() => onChange(group.modelKey, model)}
>
<span className="flex min-w-0 items-start gap-2.5">
<span className={cn("grid size-8 shrink-0 place-items-center rounded-md border", selected ? "border-[color-mix(in_srgb,var(--workspace-accent)_28%,transparent)] bg-background/70" : "border-border/70 bg-muted/35")}>
<ModelIcon model={model} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold">{modelDisplayName(config, model)}</span>
<span className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-[10px] text-foreground/45">
<span className="max-w-full truncate font-mono">{modelOptionName(model)}</span>
<span className="max-w-full truncate">{channel.name || "未命名渠道"}</span>
<span className="rounded border border-border/70 px-1 py-px text-[9px]">{channel.scope === "system" ? "系统" : "自定义"}</span>
{cost ? <Tag color="gold" className="!m-0 !px-1.5 !text-[9px] !font-medium !leading-[18px] tabular-nums">{formatPrice(cost.unitPriceMicrocredits)} 积分/{cost.billingMode === "per_second" ? "秒" : "次"}</Tag> : null}
</span>
</span>
<span className={cn("grid size-5 shrink-0 place-items-center rounded-full border", selected ? "border-[var(--workspace-accent)] bg-[var(--workspace-accent)] text-white" : "border-border text-transparent group-hover:border-foreground/30")}>
<Check className="size-3" strokeWidth={2.5} />
</span>
</span>
</button>
);
})}
</div>
) : (
<div className="rounded-md border border-dashed border-border px-4 py-6 text-center text-xs text-foreground/45">当前渠道中没有可用的{capabilityLabel(group.capability)}模型</div>
)}
</section>
);
})}
</div>
);
}
function capabilityLabel(capability: ModelCapability) {
return { image: "图片", video: "视频", text: "文本", audio: "音频" }[capability];
}
function formatPrice(microcredits: number) {
return (microcredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 });
}