refactor(core): 模型路由与画布 - 收敛渠道模型映射并优化交互

This commit is contained in:
ddCat
2026-08-19 20:27:26 +08:00
parent 52424543ea
commit d47975f9bc
34 changed files with 601 additions and 901 deletions

View File

@@ -22,7 +22,6 @@ type CanvasNodeProps = {
isRelated: boolean;
isFocusRelated: boolean;
isConnectionTarget: boolean;
isConnecting: boolean;
forceInputVisible?: boolean;
showImageInfo: boolean;
reduceMediaEffects?: boolean;
@@ -364,9 +363,9 @@ export const CanvasNode = React.memo(function CanvasNode({
/>
</div>
{hasReplaceAction ? (
{hasReplaceAction && (!hasMediaContent || isSelected || hovered) ? (
<div
className={`absolute right-3 top-3 z-[var(--node-z-overlay)] motion-safe:transition motion-safe:duration-200 ${!hasMediaContent || isSelected || hovered ? "opacity-100" : "pointer-events-none opacity-0"}`}
className="absolute right-3 top-3 z-[var(--node-z-overlay)]"
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
@@ -436,7 +435,7 @@ export const CanvasNode = React.memo(function CanvasNode({
</div>
) : null}
{!readOnly && !data.metadata?.locked ? <>
{!readOnly && !data.metadata?.locked && (isSelected || hovered) ? <>
<ResizeHandle corner="top-left" onMouseDown={handleResizeMouseDown} />
<ResizeHandle corner="top-right" onMouseDown={handleResizeMouseDown} />
<ResizeHandle corner="bottom-left" onMouseDown={handleResizeMouseDown} />
@@ -444,12 +443,61 @@ export const CanvasNode = React.memo(function CanvasNode({
</> : null}
</div>
{!readOnly && data.type !== CanvasNodeType.Script ? <ConnectionSideRail side="left" scale={scale} visible={hovered || forceInputVisible} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null}
{!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config ? <ConnectionSideRail side="right" scale={scale} visible={hovered} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null}
{!readOnly && data.type !== CanvasNodeType.Script && (hovered || forceInputVisible) ? <ConnectionSideRail side="left" scale={scale} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null}
{!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config && hovered ? <ConnectionSideRail side="right" scale={scale} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null}
</div>
);
});
}, areCanvasNodePropsEqual);
function areCanvasNodePropsEqual(previous: CanvasNodeProps, next: CanvasNodeProps) {
return (
previous.data === next.data &&
previous.dragOffset?.x === next.dragOffset?.x &&
previous.dragOffset?.y === next.dragOffset?.y &&
previous.scale === next.scale &&
previous.isSelected === next.isSelected &&
previous.isRelated === next.isRelated &&
previous.isFocusRelated === next.isFocusRelated &&
previous.isConnectionTarget === next.isConnectionTarget &&
previous.forceInputVisible === next.forceInputVisible &&
previous.showImageInfo === next.showImageInfo &&
previous.reduceMediaEffects === next.reduceMediaEffects &&
previous.readOnly === next.readOnly &&
previous.resourceLabel === next.resourceLabel &&
previous.mentionReferences === next.mentionReferences &&
previous.renderNodeContent === next.renderNodeContent &&
previous.drawingProjectId === next.drawingProjectId &&
previous.batchCount === next.batchCount &&
previous.batchExpanded === next.batchExpanded &&
previous.batchClosing === next.batchClosing &&
previous.batchOpening === next.batchOpening &&
previous.batchRecovering === next.batchRecovering &&
previous.batchPrimary === next.batchPrimary &&
previous.batchMotion?.x === next.batchMotion?.x &&
previous.batchMotion?.y === next.batchMotion?.y &&
previous.batchMotion?.index === next.batchMotion?.index &&
previous.onMouseDown === next.onMouseDown &&
previous.onHoverStart === next.onHoverStart &&
previous.onHoverEnd === next.onHoverEnd &&
previous.onConnectStart === next.onConnectStart &&
previous.onResize === next.onResize &&
previous.onTitleChange === next.onTitleChange &&
previous.onContentChange === next.onContentChange &&
previous.onToggleBatch === next.onToggleBatch &&
previous.onSetBatchPrimary === next.onSetBatchPrimary &&
previous.onRetry === next.onRetry &&
previous.onCancelTask === next.onCancelTask &&
previous.onOpenTaskDetails === next.onOpenTaskDetails &&
previous.onOpenVersions === next.onOpenVersions &&
previous.onViewImage === next.onViewImage &&
previous.onReplaceMedia === next.onReplaceMedia &&
previous.onOpenTextEditor === next.onOpenTextEditor &&
previous.onOpenDirector === next.onOpenDirector &&
previous.onOpenDrawing === next.onOpenDrawing &&
previous.onContextMenu === next.onContextMenu
);
}
function ResourceLabelBadge({ reference, theme }: { reference: CanvasResourceReference; theme: CanvasTheme }) {
return (
@@ -624,7 +672,7 @@ function NodeStatusBadge({ status }: { status: "loading" | "success" | "error" }
);
}
function ConnectionSideRail({ side, scale, visible, theme, onPointerDown }: { side: "left" | "right"; scale: number; visible: boolean; theme: CanvasTheme; onPointerDown: (event: React.PointerEvent, anchorRatio: number) => void }) {
function ConnectionSideRail({ side, scale, theme, onPointerDown }: { side: "left" | "right"; scale: number; theme: CanvasTheme; onPointerDown: (event: React.PointerEvent, anchorRatio: number) => void }) {
const handleRef = useRef<HTMLSpanElement>(null);
const anchorRatioRef = useRef(0.5);
const inverseScale = 1 / Math.max(scale, 0.05);
@@ -634,10 +682,6 @@ function ConnectionSideRail({ side, scale, visible, theme, onPointerDown }: { si
if (handleRef.current) handleRef.current.style.top = "50%";
}, []);
useEffect(() => {
if (!visible) resetAnchor();
}, [resetAnchor, visible]);
const updateAnchor = (event: React.PointerEvent<HTMLButtonElement>) => {
const railBounds = event.currentTarget.getBoundingClientRect();
const nodeBounds = event.currentTarget.parentElement?.getBoundingClientRect() || railBounds;
@@ -651,7 +695,7 @@ function ConnectionSideRail({ side, scale, visible, theme, onPointerDown }: { si
return (
<button
type="button"
className={`group absolute top-1/2 z-[var(--node-z-overlay)] touch-none -translate-y-1/2 outline-none transition-opacity duration-150 ${visible ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`}
className="group pointer-events-auto absolute top-1/2 z-[var(--node-z-overlay)] touch-none -translate-y-1/2 opacity-100 outline-none transition-opacity duration-150"
style={{ width: 56 * inverseScale, height: `min(100%, ${72 * inverseScale}px)`, ...(side === "left" ? { right: "100%" } : { left: "100%" }) }}
onPointerEnter={updateAnchor}
onPointerMove={updateAnchor}

View File

@@ -65,11 +65,12 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
onPointerDown={() => preloadWorkspaceRoute(tool.slug)}
onPointerEnter={() => preloadWorkspaceRoute(tool.slug)}
className={cn(
"app-workspace-nav-link app-workspace-rail-tile grid size-10 place-items-center rounded-md text-[var(--fs-tiny)] transition-colors",
"app-workspace-nav-link app-workspace-rail-tile flex min-h-12 w-full flex-col items-center justify-center gap-1 rounded-md px-1 py-1 text-[var(--fs-tiny)] leading-none transition-colors",
active ? "is-active font-medium" : "",
)}
>
<Icon className="app-workspace-nav-icon shrink-0" strokeWidth={1.8} />
<span className="truncate">{tool.label.slice(0, 2)}</span>
</Link>
);
};
@@ -121,8 +122,9 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
{/* 桌面:64px 电影胶片条式轨道 */}
<div className="app-workspace-rail hidden min-h-0 flex-col overflow-y-auto lg:flex">
<div className="app-workspace-rail-header flex h-16 shrink-0 items-center justify-center">
<Link to="/home" className="grid size-10 shrink-0 place-items-center rounded-md text-foreground/55 transition-colors hover:bg-surface-hover hover:text-foreground" title="工作台首页">
<Link to="/home" className="app-workspace-rail-tile flex min-h-12 w-10 shrink-0 flex-col items-center justify-center gap-1 rounded-md px-1 py-1 text-[var(--fs-tiny)] leading-none text-foreground/55 transition-colors hover:bg-surface-hover hover:text-foreground" title="工作台首页">
<Home className="app-workspace-nav-icon" strokeWidth={1.8} />
<span>首页</span>
</Link>
</div>
<div className="app-workspace-rail-middle flex min-h-0 w-full flex-1 flex-col items-center justify-start overflow-y-auto px-3 pt-3">

View File

@@ -1,7 +1,7 @@
import { Input, InputNumber, Segmented, Select, Switch } from "antd";
import type { ReactNode } from "react";
import { defaultImageCapabilityConfig, defaultModelCapabilityConfig, type ImageCapabilityConfig, type ModelCapabilityConfig, type TextCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import { defaultImageCapabilityConfig, defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type ImageCapabilityConfig, type ModelCapabilityConfig, type TextCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import type { ModelProtocol } from "@/lib/model-protocols";
import { VIDEO_RESOLUTION_CAPABILITY_OPTIONS } from "@/lib/video-generation-options";
@@ -33,7 +33,7 @@ export function ModelCapabilityEditor({ value, onChange, protocol, capability =
if (capability === "image") {
return <ImageCapabilityEditor value={value} onChange={onChange} protocol={protocol} model={model} disabled={disabled} />;
}
const profile = value?.video || defaultModelCapabilityConfig(protocol).video!;
const profile = normalizeModelCapabilityConfig(value || defaultModelCapabilityConfig(protocol)).video!;
const update = (patch: Partial<VideoCapabilityConfig>) => onChange?.({ version: 1, video: { ...profile, ...patch } });
const updateReferences = (patch: Partial<VideoCapabilityConfig["references"]>) => update({ references: { ...profile.references, ...patch } });
const updateDuration = (patch: Partial<VideoCapabilityConfig["duration"]>) => update({ duration: { ...profile.duration, ...patch } });
@@ -125,7 +125,7 @@ function TextCapabilityEditor({ value, onChange, protocol, disabled }: Pick<Prop
}
function ImageCapabilityEditor({ value, onChange, protocol, model, disabled }: Required<Pick<Props, "model" | "disabled">> & Pick<Props, "value" | "onChange" | "protocol">) {
const profile = value?.image || defaultImageCapabilityConfig(protocol, model);
const profile = normalizeModelCapabilityConfig(value || { version: 1, image: defaultImageCapabilityConfig(protocol, model) }).image!;
const update = (patch: Partial<ImageCapabilityConfig>) => onChange?.({ version: 1, image: { ...profile, ...patch } });
const updateReferences = (patch: Partial<ImageCapabilityConfig["references"]>) => update({ references: { ...profile.references, ...patch } });
const updateSize = (patch: Partial<ImageCapabilityConfig["size"]>) => update({ size: { ...profile.size, ...patch } });

View File

@@ -89,6 +89,47 @@ export const STANDARD_IMAGE_SIZE_VALUES = [
"1024x1536",
] as const;
export function normalizeCapabilityString(value: string) {
const normalized = value.trim();
return normalized.startsWith("string:") ? normalized.slice("string:".length) : normalized;
}
function normalizeCapabilityStrings(values: string[]) {
return Array.from(new Set(values.map(normalizeCapabilityString)));
}
export function normalizeModelCapabilityConfig(config: ModelCapabilityConfig): ModelCapabilityConfig {
return {
...config,
image: config.image
? {
...config.image,
size: {
...config.image.size,
values: normalizeCapabilityStrings(config.image.size.values),
default: normalizeCapabilityString(config.image.size.default),
},
quality: {
...config.image.quality,
values: normalizeCapabilityStrings(config.image.quality.values),
default: normalizeCapabilityString(config.image.quality.default),
},
}
: undefined,
video: config.video
? {
...config.video,
ratios: normalizeCapabilityStrings(config.video.ratios),
defaultRatio: normalizeCapabilityString(config.video.defaultRatio),
resolutions: normalizeCapabilityStrings(config.video.resolutions),
defaultResolution: normalizeCapabilityString(config.video.defaultResolution),
operations: normalizeCapabilityStrings(config.video.operations),
defaultOperation: normalizeCapabilityString(config.video.defaultOperation),
}
: undefined,
};
}
// Keep explicit pixel presets for each resolution tier so the settings panel can
// switch between 1K, 2K and 4K without silently converting the requested ratio.
const defaultImageSizes = [
@@ -271,17 +312,18 @@ export function modelCapabilityConfigFor(config: { channels: Array<{ id: string;
const cost = channel?.modelCosts?.find((item) => item.model === modelName);
const fallback = defaultModelCapabilityConfig(cost?.protocol, modelName);
if (!cost?.capabilityConfig) return fallback;
const text = cost.capabilityConfig.text ? { ...fallback.text!, ...cost.capabilityConfig.text, references: { ...fallback.text!.references, ...cost.capabilityConfig.text.references } } : fallback.text;
const video = cost.capabilityConfig.video ? { ...fallback.video!, ...cost.capabilityConfig.video, references: { ...fallback.video!.references, ...cost.capabilityConfig.video.references } } : fallback.video;
const configuredImage = cost.capabilityConfig.image;
const capabilityConfig = normalizeModelCapabilityConfig(cost.capabilityConfig);
const text = capabilityConfig.text ? { ...fallback.text!, ...capabilityConfig.text, references: { ...fallback.text!.references, ...capabilityConfig.text.references } } : fallback.text;
const video = capabilityConfig.video ? { ...fallback.video!, ...capabilityConfig.video, references: { ...fallback.video!.references, ...capabilityConfig.video.references } } : fallback.video;
const configuredImage = capabilityConfig.image;
const image = configuredImage
? (() => {
const configuredSize = configuredImage.size;
const configuredValues = configuredSize?.values;
const configuredValues = configuredSize?.values?.map(normalizeCapabilityString);
const allowCustom = Boolean(configuredSize?.allowCustom || configuredValues?.includes("*"));
const concreteValues = configuredValues?.filter((value) => value !== "*") || [];
const values = !configuredValues ? fallback.image!.size.values : concreteValues.length || !allowCustom ? concreteValues : [...STANDARD_IMAGE_SIZE_VALUES];
const configuredDefault = configuredSize?.default?.trim();
const configuredDefault = configuredSize?.default ? normalizeCapabilityString(configuredSize.default) : undefined;
const defaultValue = configuredDefault && configuredDefault !== "*" && values.includes(configuredDefault) ? configuredDefault : values.find((value) => value !== "*") || fallback.image!.size.default;
return {
...fallback.image!,
@@ -296,7 +338,7 @@ export function modelCapabilityConfigFor(config: { channels: Array<{ id: string;
};
})()
: fallback.image;
return { ...fallback, ...cost.capabilityConfig, text, image, video };
return { ...fallback, ...capabilityConfig, text, image, video };
}
export function normalizeImageValue(profile: ImageCapabilityConfig, value: { size?: string; quality?: string; count?: string; transparentBackground?: string }) {

View File

@@ -29,13 +29,13 @@ export function CreditOperationsPage() {
}
export function AccessSettingsPage() {
return <AdminPageFrame title="登录与注册" description="注册策略与 Linux.do"><Suspense fallback={<PageFallback label="登录与注册配置" />}><AccessSettingsPanel /></Suspense></AdminPageFrame>;
return <AdminPageFrame title="登录与注册" description="注册策略与 Linux.do" scroll><Suspense fallback={<PageFallback label="登录与注册配置" />}><AccessSettingsPanel /></Suspense></AdminPageFrame>;
}
export function EmailSettingsPage() {
return <AdminPageFrame title="邮件服务" description="注册验证码 SMTP"><div className="pt-4"><Suspense fallback={<PageFallback label="邮件配置" />}><EmailSettingsPanel /></Suspense></div></AdminPageFrame>;
return <AdminPageFrame title="邮件服务" description="注册验证码 SMTP" scroll><div className="pt-4"><Suspense fallback={<PageFallback label="邮件配置" />}><EmailSettingsPanel /></Suspense></div></AdminPageFrame>;
}
export function FeatureAvailabilityPage() {
return <AdminPageFrame title="功能开放" description="控制用户工作台入口、渠道与计费模式"><Suspense fallback={<PageFallback label="功能开放配置" />}><FeatureAvailabilityPanel /></Suspense></AdminPageFrame>;
return <AdminPageFrame title="功能开放" description="控制用户工作台入口、渠道与计费模式" scroll><Suspense fallback={<PageFallback label="功能开放配置" />}><FeatureAvailabilityPanel /></Suspense></AdminPageFrame>;
}

View File

@@ -136,10 +136,10 @@ export function AdminShell() {
);
}
export function AdminPageFrame({ title, actions, back, children }: { title: string; description?: string; actions?: ReactNode; back?: { label: string; onClick: () => void }; children: ReactNode }) {
export function AdminPageFrame({ title, actions, back, scroll = false, children }: { title: string; description?: string; actions?: ReactNode; back?: { label: string; onClick: () => void }; scroll?: boolean; children: ReactNode }) {
return (
<WorkspacePage scroll={false} fluid className="admin-page-root">
<div className="admin-page-frame">
<WorkspacePage scroll={scroll} fluid className={cn("admin-page-root", scroll && "admin-page-root-scrollable")}>
<div className={cn("admin-page-frame", scroll && "admin-page-frame-scrollable")}>
<header className="admin-page-header flex min-h-12 shrink-0 flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-2.5">
{back ? (

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { App, Button, Drawer, Form, Input, InputNumber, Popconfirm, Segmented, Select, Space, Switch } from "antd";
import type { ColumnsType } from "antd/es/table";
import { FlaskConical, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
@@ -7,12 +7,10 @@ import { PaginationBar } from "@/components/layout/workspace-page";
import { ModelIcon } from "@/components/model-picker";
import { ModelCapabilityEditor } from "@/components/model-capability-editor";
import { CapabilityCardPicker, ProtocolCardPicker, type ModelCapabilityChoice } from "@/components/model-protocol-picker";
import { defaultModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
import { defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
import { MODEL_PROTOCOLS, modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols";
import { createAdminPhysicalVariant, listAdminPhysicalVariants, updateAdminPhysicalVariant, type AdminPhysicalVariant } from "@/services/api/logical-models";
import { createAdminChannelModel, deleteAdminChannelModel, fetchAdminChannelModels, listAdminChannelModels, testAdminChannelModel, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet";
import type { ModelChannel } from "@/stores/use-config-store";
import { CapabilitySummary, capabilitySpecFromChannelModel } from "../logical-models/model-routing-capabilities";
import { AdminPageFrame } from "./admin-shell";
import { AdminDataTable, AdminFilterChip, AdminStatusBadge } from "./admin-ui";
@@ -32,11 +30,6 @@ type FormValues = {
capabilityConfig?: ModelCapabilityConfig;
};
type ConfigFormValues = {
name: string;
enabled: boolean;
};
export function ChannelModelManager({ channel, onClose, onChanged }: { channel: ModelChannel; onClose: () => void; onChanged: () => void | Promise<void> }) {
const { message } = App.useApp();
const [items, setItems] = useState<ChannelModel[]>([]);
@@ -46,24 +39,16 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const [configDrawerOpen, setConfigDrawerOpen] = useState(false);
const [config, setConfig] = useState<AdminPhysicalVariant | null>(null);
const [configLoading, setConfigLoading] = useState(false);
const [configSaving, setConfigSaving] = useState(false);
const [keyword, setKeyword] = useState("");
const [capability, setCapability] = useState<ChannelModel["capability"] | "all">("all");
const [status, setStatus] = useState<"all" | "enabled" | "disabled">("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [form] = Form.useForm<FormValues>();
const [configForm] = Form.useForm<ConfigFormValues>();
const configRequestRef = useRef(0);
const billingMode = Form.useWatch("billingMode", form) || "fixed_request";
const modelCapability = Form.useWatch("capability", form);
const modelProtocol = Form.useWatch("protocol", form);
const modelKey = Form.useWatch("modelKey", form) || "";
const editingCapabilitySpec = capabilitySpecFromChannelModel(editing || undefined);
const currentConfig = editing && config?.channelModelId === editing.id ? config : null;
const reload = async () => {
if (!channel) return;
@@ -77,69 +62,9 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
}
};
const reloadConfig = async (channelModelID: string, defaultName = "") => {
const requestID = ++configRequestRef.current;
setConfigLoading(true);
try {
const variants = (await listAdminPhysicalVariants(channelModelID)).variants;
if (requestID !== configRequestRef.current) return;
const nextConfig = variants.find((item) => item.channelModelId === channelModelID) || null;
setConfig(nextConfig);
if (nextConfig) {
configForm.setFieldsValue({ name: nextConfig.name, enabled: nextConfig.enabled });
} else {
configForm.setFieldsValue({ name: defaultName, enabled: true });
}
} catch (error) {
if (requestID === configRequestRef.current) message.error(error instanceof Error ? error.message : "读取可用配置失败");
} finally {
if (requestID === configRequestRef.current) setConfigLoading(false);
}
};
const saveConfig = async () => {
if (!editing) {
message.info("请先保存渠道模型,再配置唯一可用配置");
return;
}
const values = await configForm.validateFields();
if (values.enabled && editing.capability !== "audio" && !editingCapabilitySpec) {
message.error("请先完成渠道模型的供应能力配置");
return;
}
if (values.enabled && !editing.enabled) {
message.error("渠道模型已停用,不能启用可用配置");
return;
}
if (config && !currentConfig) {
message.error("可用配置与当前渠道模型不匹配,请重新打开后再试");
return;
}
setConfigSaving(true);
try {
// 能力范围只维护在渠道模型能力参数中;可用配置只是路由开关和显示名称。
// 后端会从渠道模型能力生成 variant 快照,避免同一尺寸/比例被填写两次。
const payload = { channelModelId: editing.id, name: values.name.trim(), enabled: values.enabled };
if (currentConfig) await updateAdminPhysicalVariant(currentConfig.id, payload);
else await createAdminPhysicalVariant(payload);
await reloadConfig(editing.id, editing.displayName || editing.modelKey);
await onChanged();
message.success(currentConfig ? "可用配置已更新" : "已创建唯一可用配置");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存可用配置失败");
} finally {
setConfigSaving(false);
}
};
useEffect(() => {
configRequestRef.current += 1;
void reload();
setEditing(null);
setConfig(null);
setConfigLoading(false);
setConfigDrawerOpen(false);
configForm.resetFields();
setEditorOpen(false);
setKeyword("");
setCapability("all");
@@ -165,12 +90,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
};
const startCreate = () => {
configRequestRef.current += 1;
setEditing(null);
setConfig(null);
setConfigLoading(false);
setConfigDrawerOpen(false);
configForm.resetFields();
form.setFieldsValue({
modelKey: "",
displayName: "",
@@ -189,10 +109,6 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
const startEdit = (item: ChannelModel) => {
setEditing(item);
setConfig(null);
setConfigDrawerOpen(false);
configForm.setFieldsValue({ name: item.displayName || item.modelKey, enabled: true });
void reloadConfig(item.id, item.displayName || item.modelKey);
form.setFieldsValue({
modelKey: item.modelKey,
displayName: item.displayName,
@@ -204,7 +120,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
outputTokenPrice: item.outputTokenPriceMicrocredits / 1_000_000,
cachedTokenPrice: item.cachedTokenPriceMicrocredits / 1_000_000,
enabled: item.enabled,
capabilityConfig: item.capability === "text" || item.capability === "image" || item.capability === "video" ? item.capabilityConfig || defaultModelCapabilityConfig(item.protocol, item.modelKey) : undefined,
capabilityConfig: item.capability === "text" || item.capability === "image" || item.capability === "video" ? normalizeModelCapabilityConfig(item.capabilityConfig || defaultModelCapabilityConfig(item.protocol, item.modelKey)) : undefined,
});
setEditorOpen(true);
};
@@ -225,7 +141,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
cachedTokenPriceMicrocredits: Math.round((values.cachedTokenPrice || 0) * 1_000_000),
priceConfigured: true,
enabled: values.enabled !== false,
capabilityConfig: values.capability === "text" || values.capability === "image" || values.capability === "video" ? values.capabilityConfig : undefined,
capabilityConfig: values.capability === "text" || values.capability === "image" || values.capability === "video" ? normalizeModelCapabilityConfig(values.capabilityConfig!) : undefined,
};
if (editing) await updateAdminChannelModel(channel.id, editing.id, payload);
else await createAdminChannelModel(channel.id, payload);
@@ -249,7 +165,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
modelKey: values.modelKey.trim(),
capability: values.capability,
protocol: values.protocol,
capabilityConfig: values.capabilityConfig,
capabilityConfig: values.capabilityConfig ? normalizeModelCapabilityConfig(values.capabilityConfig) : undefined,
});
message.success(`模型测试通过,耗时 ${(result.durationMs / 1000).toFixed(2)} 秒`);
} catch (error) {
@@ -330,7 +246,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
<Button size="small" onClick={() => startEdit(item)}>
编辑
</Button>
<Popconfirm title="删除模型" description="删除后模型不再显示,历史账单仍会保留。该操作不能在页面恢复。" okText="删除" cancelText="取消" onConfirm={() => void remove(item)}>
<Popconfirm title="删除模型" description="已被前台供应线路或进行中任务使用的模型不能删除;删除后模型不再显示,且不能在页面恢复。" okText="删除" cancelText="取消" onConfirm={() => void remove(item)}>
<Button size="small" danger title="删除模型" aria-label="删除模型" icon={<Trash2 className="size-3.5" />} />
</Popconfirm>
</Space>
@@ -421,7 +337,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
title={editing ? `编辑模型 / ${editing.displayName || editing.modelKey}` : "新增模型"}
open={editorOpen}
size="min(1080px, 100vw)"
onClose={() => !configSaving && setEditorOpen(false)}
onClose={() => !saving && setEditorOpen(false)}
rootClassName="admin-drawer"
footer={
<div className="flex items-center justify-between gap-3">
@@ -481,34 +397,6 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
) : null}
</section>
<section className="admin-form-section">
<div className="mb-4">
<h2 className="text-sm font-semibold">可用配置</h2>
</div>
{!editing ? (
<div className="rounded-md bg-muted/20 px-4 py-4 text-sm text-foreground/60">请先保存模型基础信息,保存后即可配置唯一的路由入口。</div>
) : (
<div className="flex items-center justify-between gap-4 rounded-lg bg-muted/15 p-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{currentConfig?.name || "尚未创建路由配置"}</span>
{currentConfig ? <AdminStatusBadge label={currentConfig.enabled ? "启用" : "停用"} tone={currentConfig.enabled ? "success" : "neutral"} /> : null}
</div>
<div className="mt-1 text-xs text-foreground/45">{currentConfig?.usageCount ? `已被 ${currentConfig.usageCount} 条前台供应配置引用` : "尚未被前台供应配置引用"}</div>
</div>
<Button
loading={configLoading}
onClick={() => {
setConfigDrawerOpen(true);
void reloadConfig(editing.id, editing.displayName || editing.modelKey);
}}
>
编辑配置
</Button>
</div>
)}
</section>
<section className="admin-form-section">
<div className="mb-4">
<h2 className="text-sm font-semibold">计费</h2>
@@ -558,32 +446,6 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
</section>
</Form>
</Drawer>
<Drawer
title="可用配置"
open={configDrawerOpen}
size="min(560px, 100vw)"
onClose={() => !configSaving && setConfigDrawerOpen(false)}
rootClassName="admin-secondary-drawer"
footer={<div className="flex justify-end gap-2"><Button onClick={() => setConfigDrawerOpen(false)}>取消</Button><Button type="primary" loading={configSaving} onClick={() => void saveConfig()}>保存路由配置</Button></div>}
>
<Form form={configForm} layout="vertical" requiredMark={false}>
<div className="mb-5 rounded-lg bg-muted/15 p-4">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">唯一可用配置</span>
<AdminStatusBadge label={currentConfig ? (currentConfig.enabled ? "启用" : "停用") : "未创建"} tone={currentConfig?.enabled ? "success" : "neutral"} />
</div>
<span className="text-xs text-foreground/45">{currentConfig?.usageCount ? `引用 ${currentConfig.usageCount}` : "未引用"}</span>
</div>
</div>
<Form.Item name="name" label="配置名称" rules={[{ required: true, message: "请填写配置名称" }]}><Input placeholder="例如:标准能力、高清能力、多参考图" /></Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item>
<div className="rounded-lg bg-muted/10 px-4 py-4">
<div className="mb-2 text-xs font-semibold text-foreground/65">继承的模型能力</div>
{editingCapabilitySpec ? <CapabilitySummary spec={editingCapabilitySpec} /> : <span className="text-xs text-foreground/45">请先在“能力与协议”中完成模型能力参数。</span>}
</div>
</Form>
</Drawer>
</AdminPageFrame>
);
}

View File

@@ -6,18 +6,18 @@ import { useDeferredValue, useEffect, useMemo, useState, type ReactNode } from "
import { PaginationBar } from "@/components/layout/workspace-page";
import { ModelIconPicker, ModelLogo } from "@/components/model-logo";
import { CapabilityCardPicker } from "@/components/model-protocol-picker";
import { formatCredits } from "@/constant/credits";
import { AdminPageFrame } from "@/pages/admin/components/admin-shell";
import { AdminDataTable, AdminFilterChip, AdminRowActions, AdminStatusBadge, AdminTableEmpty } from "@/pages/admin/components/admin-ui";
import { listAdminChannels } from "@/services/api/auth";
import { listAdminChannelModels, type ChannelModel } from "@/services/api/wallet";
import {
createAdminLogicalModel,
listAdminLogicalModels,
listAdminPhysicalVariants,
simulateAdminLogicalModel,
updateAdminLogicalModel,
type AdminLogicalModel,
type AdminPhysicalVariant,
type CapabilitySpec,
type LogicalModelMutation,
type ModelRequestIntent,
@@ -29,6 +29,7 @@ import {
CapabilitySummary,
DefaultOptionsEditor,
capabilityLabel,
capabilitySpecFromChannelModel,
capabilitySourceError,
emptyCapabilitySpec,
mergeCapabilitySpecs,
@@ -38,7 +39,7 @@ import {
type CapabilityKind,
} from "./model-routing-capabilities";
type RouteRuleRow = { physicalVariantId: string; enabled: boolean; priority: number; weight: number };
type RouteRuleRow = { channelModelId: string; enabled: boolean; priority: number; weight: number };
type LogicalModelFormValues = {
code: string;
name: string;
@@ -57,13 +58,6 @@ type LogicalModelFormValues = {
defaultOptions: Record<string, unknown>;
routes: RouteRuleRow[];
};
const capabilityOptions = [
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
{ label: "视频", value: "video" },
{ label: "音频", value: "audio" },
];
export default function LogicalModelsPage() {
const { message } = App.useApp();
const [keyword, setKeyword] = useState("");
@@ -72,7 +66,7 @@ export default function LogicalModelsPage() {
const deferredKeyword = useDeferredValue(keyword.trim().toLowerCase());
const [loading, setLoading] = useState(true);
const [models, setModels] = useState<AdminLogicalModel[]>([]);
const [variants, setVariants] = useState<AdminPhysicalVariant[]>([]);
const [channelModels, setChannelModels] = useState<ChannelModel[]>([]);
const [channelNames, setChannelNames] = useState<Record<string, string>>({});
const [channelEnabled, setChannelEnabled] = useState<Record<string, boolean>>({});
const [editingModel, setEditingModel] = useState<AdminLogicalModel | null | undefined>();
@@ -89,11 +83,16 @@ export default function LogicalModelsPage() {
const reload = async () => {
setLoading(true);
try {
const [modelResult, variantResult, channelResult] = await Promise.all([listAdminLogicalModels(), listAdminPhysicalVariants(), listAdminChannels({ page: 1, limit: 100 })]);
const [modelResult, firstChannelPage] = await Promise.all([listAdminLogicalModels(), listAdminChannels({ page: 1, limit: 100 })]);
const remainingChannelPages = await Promise.all(
Array.from({ length: Math.max(0, Math.ceil(firstChannelPage.total / firstChannelPage.limit) - 1) }, (_, index) => listAdminChannels({ page: index + 2, limit: firstChannelPage.limit })),
);
const channels = [firstChannelPage, ...remainingChannelPages].flatMap((result) => result.channels);
const channelModelResults = await Promise.all(channels.map((channel) => listAdminChannelModels(channel.id)));
setModels(modelResult.models);
setVariants(variantResult.variants);
setChannelNames(Object.fromEntries(channelResult.channels.map((channel) => [channel.id, channel.name])));
setChannelEnabled(Object.fromEntries(channelResult.channels.map((channel) => [channel.id, channel.enabled !== false])));
setChannelModels(channelModelResults.flatMap((result) => result.models));
setChannelNames(Object.fromEntries(channels.map((channel) => [channel.id, channel.name])));
setChannelEnabled(Object.fromEntries(channels.map((channel) => [channel.id, channel.enabled !== false])));
} catch (error) {
message.error(error instanceof Error ? error.message : "读取前台模型配置失败");
} finally {
@@ -107,14 +106,15 @@ export default function LogicalModelsPage() {
const filteredModels = useMemo(() => models.filter((item) => !deferredKeyword || [item.name, item.code, item.capability].some((value) => value.toLowerCase().includes(deferredKeyword))), [models, deferredKeyword]);
const paginatedModels = useMemo(() => filteredModels.slice((page - 1) * pageSize, page * pageSize), [filteredModels, page, pageSize]);
const modelVariants = useMemo(() => variants.filter((item) => item.capability === modelCapability), [variants, modelCapability]);
const modelChannelModels = useMemo(() => channelModels.filter((item) => item.capability === modelCapability), [channelModels, modelCapability]);
const modelSourceSpecs = useMemo(
() =>
modelRoutes
.filter((route) => route.enabled && route.weight > 0)
.map((route) => variants.find((item) => item.id === route.physicalVariantId && item.enabled && item.modelEnabled && channelEnabled[item.channelId] !== false)?.capabilitySpec)
.map((route) => channelModels.find((item) => item.id === route.channelModelId && item.enabled && channelEnabled[item.channelId] !== false))
.map((item) => (item ? capabilitySpecFromChannelModel(item) : undefined))
.filter((item): item is CapabilitySpec => Boolean(item)),
[channelEnabled, modelRoutes, variants],
[channelEnabled, channelModels, modelRoutes],
);
const openModel = (item?: AdminLogicalModel) => {
@@ -148,7 +148,7 @@ export default function LogicalModelsPage() {
const saveModel = async () => {
const values = await modelForm.validateFields();
if (values.enabled && !values.routes.length) {
message.error("请至少添加一条供应配置");
message.error("请至少添加一条供应线路");
return;
}
const sourceError = capabilitySourceError(values.capability, modelSourceSpecs, values.capabilitySpec);
@@ -218,9 +218,9 @@ export default function LogicalModelsPage() {
),
},
{ title: "类型", dataIndex: "capability", width: 90, render: (value: CapabilityKind) => capabilityLabel(value) },
{ title: "创作端能力", render: (_, item) => <CapabilitySummary spec={item.capabilitySpec} /> },
{ title: "创作端能力", width: 360, render: (_, item) => <CapabilitySummary spec={item.capabilitySpec} /> },
{
title: "供应配置",
title: "供应线路",
width: 110,
render: (_, item) => (
<div className="text-xs">
@@ -239,7 +239,7 @@ export default function LogicalModelsPage() {
<AdminRowActions
primary={{ label: "编辑", icon: <Pencil className="size-3.5" />, onClick: () => openModel(item) }}
actions={[
{ key: "simulate", label: "模拟供应配置匹配", icon: <FlaskConical className="size-3.5" />, onClick: () => openSimulation(item) },
{ key: "simulate", label: "模拟供应线路匹配", icon: <FlaskConical className="size-3.5" />, onClick: () => openSimulation(item) },
{ key: "toggle", label: item.enabled ? "停用" : "启用", onClick: () => void toggleModel(item) },
]}
/>
@@ -276,13 +276,28 @@ export default function LogicalModelsPage() {
rootClassName="admin-drawer"
footer={<div className="flex justify-end gap-2"><Button disabled={saving} onClick={() => setEditingModel(undefined)}>取消</Button><Button type="primary" loading={saving} onClick={() => void saveModel()}>保存</Button></div>}
>
<Form form={modelForm} layout="vertical" requiredMark={false} className="space-y-3">
<Form
form={modelForm}
layout="vertical"
requiredMark={false}
className="space-y-3"
onValuesChange={(changedValues: Partial<LogicalModelFormValues>) => {
const capability = changedValues.capability;
if (!capability) return;
modelForm.setFieldsValue({
routes: [],
capabilitySpec: emptyCapabilitySpec(capability),
defaultOptions: {},
billingMode: capability === "text" ? "token" : capability === "video" ? "per_second" : "fixed_request",
});
}}
>
{editingModel?.configurationError || editingModel?.availabilityError ? (
<Alert
className="mb-4"
type="warning"
showIcon
message={editingModel.configurationError ? "当前供应配置无法覆盖全部创作端能力" : "当前供应配置暂不可结算"}
message={editingModel.configurationError ? "当前供应线路无法覆盖全部创作端能力" : "当前供应线路暂不可结算"}
description={editingModel.configurationError || editingModel.availabilityError}
/>
) : null}
@@ -301,15 +316,10 @@ export default function LogicalModelsPage() {
<Form.Item name="description" label="简短说明">
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="说明适合的创作场景,不描述供应渠道。" />
</Form.Item>
<div className="grid gap-3 sm:grid-cols-3">
<Form.Item name="capability" label="类型">
<Select
options={capabilityOptions}
onChange={(capability: CapabilityKind) =>
modelForm.setFieldsValue({ routes: [], capabilitySpec: emptyCapabilitySpec(capability), defaultOptions: {}, billingMode: capability === "text" ? "token" : capability === "video" ? "per_second" : "fixed_request" })
}
/>
</Form.Item>
<Form.Item name="capability" label="类型">
<CapabilityCardPicker density="compact" />
</Form.Item>
<div className="grid gap-3 sm:grid-cols-2">
<Form.Item name="sortOrder" label="前台排序">
<InputNumber className="w-full" precision={0} />
</Form.Item>
@@ -318,8 +328,8 @@ export default function LogicalModelsPage() {
</Form.Item>
</div>
</DrawerSection>
<DrawerSection icon={<GitBranch className="size-4" />} title="供应配置">
<RouteFields variants={modelVariants} channelNames={channelNames} channelEnabled={channelEnabled} form={modelForm} capability={modelCapability} />
<DrawerSection icon={<GitBranch className="size-4" />} title="供应线路">
<RouteFields channelModels={modelChannelModels} channelNames={channelNames} channelEnabled={channelEnabled} form={modelForm} capability={modelCapability} />
</DrawerSection>
<DrawerSection title="创作端可选能力">
<Form.Item name="capabilitySpec" noStyle>
@@ -338,7 +348,7 @@ export default function LogicalModelsPage() {
</Drawer>
<Modal
title={simulatingModel ? `供应配置匹配模拟 - ${simulatingModel.name}` : "供应配置匹配模拟"}
title={simulatingModel ? `供应线路匹配模拟 - ${simulatingModel.name}` : "供应线路匹配模拟"}
open={Boolean(simulatingModel)}
className="workspace-modal workspace-modal-wide admin-simulation-modal"
rootClassName="admin-modal-root"
@@ -382,7 +392,7 @@ export default function LogicalModelsPage() {
</Tag>
</div>
{simulationResult.productMatch.reasons?.length ? <p className="mb-4 text-sm text-error">{simulationResult.productMatch.reasons.join(";")}</p> : null}
<Table size="small" pagination={false} rowKey="routeId" dataSource={simulationResult.candidates} columns={simulationColumns(simulatingModel)} />
<Table size="small" pagination={false} rowKey="routeId" dataSource={simulationResult.candidates} columns={simulationColumns()} />
</section>
) : null}
</div>
@@ -408,74 +418,75 @@ function DrawerSection({ icon, title, description, children }: { icon?: ReactNod
}
function RouteFields({
variants,
channelModels,
channelNames,
channelEnabled,
form,
capability,
}: {
variants: AdminPhysicalVariant[];
channelModels: ChannelModel[];
channelNames: Record<string, string>;
channelEnabled: Record<string, boolean>;
form: FormInstance<LogicalModelFormValues>;
capability: CapabilityKind;
}) {
const selectOptions = variants.map((item) => {
const unavailableReason = channelEnabled[item.channelId] === false ? "渠道已停用" : !item.modelEnabled ? "渠道模型已停用" : !item.enabled ? "规格已停用" : "";
const selectOptions = channelModels.map((item) => {
const unavailableReason = channelEnabled[item.channelId] === false ? "渠道已停用" : !item.enabled ? "渠道模型已停用" : "";
return {
value: item.id,
label: `${item.name} · ${channelNames[item.channelId] || item.channelId} / ${item.modelName || item.modelKey}${unavailableReason ? `(${unavailableReason})` : ""}`,
label: `${channelNames[item.channelId]} / ${item.displayName || item.modelKey}${unavailableReason ? `(${unavailableReason})` : ""}`,
disabled: Boolean(unavailableReason),
};
});
const availableVariantCount = selectOptions.filter((item) => !item.disabled).length;
const availableChannelModelCount = selectOptions.filter((item) => !item.disabled).length;
return (
<Form.List name="routes">
{(fields, { add, remove }) => {
const currentRoutes = (form.getFieldValue("routes") || []) as RouteRuleRow[];
const selectedVariantCount = new Set(currentRoutes.map((route) => route?.physicalVariantId).filter(Boolean)).size;
const canAdd = selectedVariantCount < availableVariantCount;
const selectedChannelModelCount = new Set(currentRoutes.map((route) => route?.channelModelId).filter(Boolean)).size;
const canAdd = selectedChannelModelCount < availableChannelModelCount;
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs text-foreground/50">共 {fields.length} 条供应配置</span>
<Button size="small" icon={<Plus className="size-3.5" />} disabled={!canAdd} onClick={() => add({ physicalVariantId: "", enabled: true, priority: 100, weight: 100 })}>
添加供应配置
<span className="text-xs text-foreground/50">共 {fields.length} 条供应线路</span>
<Button size="small" icon={<Plus className="size-3.5" />} disabled={!canAdd} onClick={() => add({ channelModelId: "", enabled: true, priority: 100, weight: 100 })}>
添加供应线路
</Button>
</div>
{fields.length ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{fields.map((field) => {
const routes = (form.getFieldValue("routes") || []) as RouteRuleRow[];
const selectedByOthers = new Set(routes.map((route, index) => (index === field.name ? "" : route?.physicalVariantId)).filter(Boolean));
const selectedByOthers = new Set(routes.map((route, index) => (index === field.name ? "" : route?.channelModelId)).filter(Boolean));
const options = selectOptions.map((option) => ({ ...option, disabled: option.disabled || selectedByOthers.has(option.value) }));
const selected = variants.find((item) => item.id === routes[field.name]?.physicalVariantId);
const selected = channelModels.find((item) => item.id === routes[field.name]?.channelModelId);
return (
<div key={field.key} className="rounded-lg border border-border bg-muted/5 p-4">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-xs font-semibold">供应配置 {fields.indexOf(field) + 1}</div>
<div className="text-xs font-semibold">供应线路 {fields.indexOf(field) + 1}</div>
<div className="mt-0.5 truncate text-xs text-foreground/50">
{selected ? `${selected.name} · ${channelNames[selected.channelId] || selected.channelId} / ${selected.modelName || selected.modelKey}` : "选择一个可承接请求的配置"}
{selected ? `${channelNames[selected.channelId]} / ${selected.displayName || selected.modelKey}` : "选择一个可承接请求的渠道模型"}
</div>
</div>
<Button type="text" size="small" danger onClick={() => remove(field.name)}>
移除
</Button>
</div>
<Form.Item name={[field.name, "physicalVariantId"]} rules={[{ required: true, message: "请选择供应配置" }]} className="mb-3">
<Form.Item name={[field.name, "channelModelId"]} rules={[{ required: true, message: "请选择渠道模型" }]} className="mb-3">
<Select
aria-label={`供应配置 ${fields.indexOf(field) + 1}`}
aria-label={`供应线路 ${fields.indexOf(field) + 1}`}
showSearch
optionFilterProp="label"
placeholder="选择供应配置"
placeholder="选择渠道模型"
options={options}
onChange={(physicalVariantId) => {
onChange={(channelModelId) => {
const nextRoutes = [...(form.getFieldValue("routes") || [])];
nextRoutes[field.name] = { ...nextRoutes[field.name], physicalVariantId };
nextRoutes[field.name] = { ...nextRoutes[field.name], channelModelId };
const specs = nextRoutes
.filter((route) => route.enabled && route.weight > 0)
.map((route) => variants.find((item) => item.id === route.physicalVariantId && item.enabled && item.modelEnabled && channelEnabled[item.channelId] !== false)?.capabilitySpec)
.map((route) => channelModels.find((item) => item.id === route.channelModelId && item.enabled && channelEnabled[item.channelId] !== false))
.map((item) => (item ? capabilitySpecFromChannelModel(item) : undefined))
.filter((item): item is CapabilitySpec => Boolean(item));
form.setFieldValue("routes", nextRoutes);
if (!hasCapabilityRules(form.getFieldValue("capabilitySpec"))) form.setFieldValue("capabilitySpec", mergeCapabilitySpecs(capability, specs));
@@ -498,7 +509,7 @@ function RouteFields({
})}
</div>
) : null}
{!fields.length ? <div className="rounded-md bg-muted/20 px-3 py-4 text-center text-xs text-foreground/50">尚未添加供应配置</div> : null}
{!fields.length ? <div className="rounded-md bg-muted/20 px-3 py-4 text-center text-xs text-foreground/50">尚未添加供应线路</div> : null}
</div>
);
}}
@@ -569,7 +580,7 @@ function PricingFields() {
) : null}
</>
) : (
<div className="rounded-md bg-muted/20 px-3 py-3 text-xs leading-5 text-foreground/55">用户费用按实际命中的供应配置价格计算。故障切换到更高价格配置时会重新校验余额。</div>
<div className="rounded-md bg-muted/20 px-3 py-3 text-xs leading-5 text-foreground/55">用户费用按实际命中的供应线路价格计算。故障切换到更高价格线路时会重新校验余额。</div>
)}
</>
);
@@ -613,7 +624,7 @@ function logicalModelToForm(item: AdminLogicalModel): LogicalModelFormValues {
cachedPriceMicrocredits: item.cachedPriceMicrocredits,
capabilitySpec: item.capabilitySpec,
defaultOptions: item.defaultOptions,
routes: item.routes.map((route) => ({ physicalVariantId: route.physicalVariantId, enabled: route.enabled, priority: route.priority, weight: route.weight })),
routes: item.routes.map((route) => ({ channelModelId: route.channelModelId, enabled: route.enabled, priority: route.priority, weight: route.weight })),
};
}
@@ -643,9 +654,12 @@ function hasCapabilityRules(spec?: CapabilitySpec) {
return Boolean(spec && ((spec.operations?.length || 0) > 0 || Object.keys(spec.inputs || {}).length > 0 || Object.keys(spec.options || {}).length > 0));
}
function simulationColumns(model: AdminLogicalModel): ColumnsType<RouteSimulationResult["candidates"][number]> {
function simulationColumns(): ColumnsType<RouteSimulationResult["candidates"][number]> {
return [
{ title: "供应配置", render: (_, candidate) => model.routes.find((route) => route.id === candidate.routeId)?.physicalVariantName || "未知配置" },
{
title: "供应线路",
render: (_, candidate) => `${candidate.channelModelName}(${candidate.channelModelKey})`,
},
{ title: "优先级", dataIndex: "priority", width: 80 },
{ title: "权重", dataIndex: "weight", width: 70 },
{

View File

@@ -66,7 +66,7 @@ export function emptyCapabilitySpec(capability: CapabilityKind): CapabilitySpec
}
function imageSizeOptionValues(values: string[], allowCustom: boolean) {
const concreteValues = Array.from(new Set(values.map((value) => value.trim()).filter((value) => value && value !== "*")));
const concreteValues = Array.from(new Set(values.map((value) => String(normalizeScalar(value)).trim()).filter((value) => value && value !== "*")));
const result = concreteValues.length || !allowCustom ? concreteValues : [...STANDARD_IMAGE_SIZE_VALUES];
if (allowCustom) result.push("*");
return result;
@@ -437,11 +437,13 @@ export function CapabilitySummary({ spec }: { spec: CapabilitySpec }) {
if (spec.operations?.length) labels.unshift(spec.operations.map(operationLabel).join("/"));
if (!labels.length) return <span className="text-xs text-foreground/45">基础能力</span>;
return (
<div className="flex max-w-lg flex-wrap gap-1">
<div className="flex min-w-0 max-w-full flex-wrap gap-1">
{labels.slice(0, 4).map((label) => (
<Tag key={label}>{label}</Tag>
<Tag key={label} className="max-w-full whitespace-normal break-all text-left leading-5">
{label}
</Tag>
))}
{labels.length > 4 ? <Tag>+{labels.length - 4}</Tag> : null}
{labels.length > 4 ? <Tag className="shrink-0">+{labels.length - 4}</Tag> : null}
</div>
);
}

View File

@@ -54,7 +54,7 @@ export default function DrawingEngineSettingsPage() {
const dirty = engine !== current.defaultEngine || tldrawLicenseKey !== (current.tldrawLicenseKey || "");
return (
<AdminPageFrame title="绘图工具" description="设置新建绘图节点使用的默认编辑器">
<AdminPageFrame title="绘图工具" description="设置新建绘图节点使用的默认编辑器" scroll>
<div className="pt-4">
<SettingsSectionCard
icon={<Brush className="size-4" />}

View File

@@ -83,7 +83,7 @@ export default function LibTVSettingsPage() {
};
return (
<AdminPageFrame title="第三方参数配置" description="集中维护外部平台的服务端凭证和连接状态,后续平台可在此继续扩展。">
<AdminPageFrame title="第三方参数配置" description="集中维护外部平台的服务端凭证和连接状态,后续平台可在此继续扩展。" scroll>
<div className="pt-4">
<SettingsSectionCard
icon={<KeyRound className="size-4" />}

View File

@@ -59,7 +59,7 @@ export default function ResponseInterceptionSettingsPage() {
};
return (
<AdminPageFrame title="模型响应拦截">
<AdminPageFrame title="模型响应拦截" scroll>
<div className="pt-4">
<SettingsSectionCard
layout="stacked"

View File

@@ -162,6 +162,7 @@ export default function RuntimePolicySettingsPage() {
<AdminPageFrame
title="资源与策略"
description="账号配额、任务调度与请求安全策略"
scroll
actions={<div className="flex items-center gap-2"><Button icon={<RotateCcw className="size-4" />} disabled={loading || saving} onClick={reset}>重置</Button><Button icon={<InfinityIcon className="size-4" />} disabled={loading || saving} onClick={() => void useSelfMode()}>自用模式</Button></div>}
>
<Form form={form} layout="vertical" requiredMark={false} disabled={loading} onValuesChange={() => setDirty(true)}>

View File

@@ -83,7 +83,7 @@ export default function StorageSettingsPage() {
};
return (
<AdminPageFrame title="存储服务" description="配置新增资源的默认存储位置">
<AdminPageFrame title="存储服务" description="配置新增资源的默认存储位置" scroll>
<div className="space-y-4 pt-4">
<SettingsSectionCard
layout="stacked"

View File

@@ -195,12 +195,20 @@ export function CanvasTopBar({
<div className="canvas-topbar-cluster pointer-events-auto flex items-center gap-1.5">
<Button type="text" className="!hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Search className="size-4" />} onClick={onOpenSearch} aria-label="搜索画布节点" title="搜索画布节点" />
<Button type="text" className="canvas-topbar-import-button !h-10 !rounded-xl !px-2.5 !font-medium" style={{ color: theme.node.text }} icon={<CopyPlus className="size-4" />} onClick={onImportLibTV} aria-label="一键导入 LibTV 画布" title="一键导入 LibTV 画布">
<span className="hidden xl:inline">导入 LibTV</span>
</Button>
<Button type="text" className="canvas-topbar-import-button !h-10 !rounded-xl !px-2.5 !font-medium" style={{ color: theme.node.text }} icon={<CloudDownload className="size-4" />} onClick={onImportTapNow} aria-label="导入 TapNow 画布" title="导入 TapNow 画布">
<span className="hidden xl:inline">导入 TapNow</span>
</Button>
<Dropdown
trigger={["click"]}
placement="bottomRight"
menu={{
items: [
{ key: "libtv", icon: <CopyPlus className="size-4" />, label: "导入 LibTV 画布", onClick: onImportLibTV },
{ key: "tapnow", icon: <CloudDownload className="size-4" />, label: "导入 TapNow 画布", onClick: onImportTapNow },
],
}}
>
<Button type="text" className="canvas-topbar-import-button !h-10 !rounded-xl !px-2.5 !font-medium" style={{ color: theme.node.text }} icon={<CloudDownload className="size-4" />} aria-label="导入第三方画布" title="导入第三方画布">
<span className="hidden lg:inline">导入第三方画布</span>
</Button>
</Dropdown>
<Dropdown
trigger={["click"]}
menu={{

View File

@@ -1,4 +1,4 @@
import type { CSSProperties, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, ReactNode, RefObject } from "react";
import { memo, type CSSProperties, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject } from "react";
import { Link2 } from "lucide-react";
import { ConnectionPath } from "@/components/canvas/canvas-connections";
@@ -75,7 +75,7 @@ type CanvasProjectWorldLayersProps = {
const EMPTY_RESOURCE_REFERENCES: CanvasResourceReference[] = [];
const EMPTY_CANVAS_NODES: CanvasNodeData[] = [];
export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
const { viewportScale } = props;
return (
<>
@@ -126,7 +126,6 @@ export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
isRelated={props.relatedNodeIds.has(node.id)}
isFocusRelated={props.activeNodeId === node.id}
isConnectionTarget={props.connectionTargetNodeId === node.id || props.batchConnectionPreview?.targetNodeId === node.id}
isConnecting={Boolean(props.connectingParams || props.batchConnectionPreview)}
forceInputVisible={Boolean(props.batchConnectionPreview)}
batchCount={props.batchChildCountById.get(node.id) || 0}
batchExpanded={Boolean(node.metadata?.imageBatchExpanded)}
@@ -136,7 +135,7 @@ export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
batchPrimary={Boolean(node.metadata?.batchRootId && props.nodeById.get(node.metadata.batchRootId)?.metadata?.primaryImageId === node.id)}
batchMotion={props.batchMotionById.get(node.id)}
showImageInfo={props.showImageInfo}
reduceMediaEffects={props.reduceMediaEffects || props.isNodeDragging || props.mediaEffectsDisabledNodeId === node.id}
reduceMediaEffects={props.reduceMediaEffects || props.mediaEffectsDisabledNodeId === node.id}
resourceLabel={props.resourceReferenceByNodeId.get(node.id)}
mentionReferences={props.mentionReferencesByNodeId.get(node.id) || EMPTY_RESOURCE_REFERENCES}
renderNodeContent={props.renderCanvasNodeContent}
@@ -187,7 +186,7 @@ export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
) : null}
</>
);
}
});
function BatchConnectionHandle({ scale, count, active, onPointerDown }: { scale: number; count: number; active: boolean; onPointerDown: (event: ReactPointerEvent) => void }) {
const inverseScale = 1 / Math.max(scale, 0.05);

View File

@@ -560,6 +560,7 @@ function InfiniteCanvasPage() {
setContextMenu,
setDialogNodeId,
});
const replaceCanvasNodeMedia = useCallback((node: CanvasNodeData) => handleUploadRequest(node.id), [handleUploadRequest]);
useEffect(() => {
if (!projectLoaded || !assetsHydrated || searchParams.get("mode") !== "handoff") return;
@@ -1840,7 +1841,7 @@ function InfiniteCanvasPage() {
onOpenTaskDetails={openCanvasNodeTaskDetails}
onOpenVersions={openCanvasNodeVersions}
onViewImage={viewCanvasNodeImage}
onReplaceMedia={(node) => handleUploadRequest(node.id)}
onReplaceMedia={replaceCanvasNodeMedia}
onOpenTextEditor={openTextNodeEditor}
onOpenDirector={editCanvasDirector}
onOpenDrawing={openDrawingNode}

View File

@@ -244,7 +244,7 @@ export default function SharedCanvasPage() {
const dragged = [node, ...(frameChildrenById.get(nodeId) || [])];
dragRef.current = { primaryId: nodeId, nodeIds: dragged.map((item) => item.id), startX: event.clientX, startY: event.clientY, origins: new Map(dragged.map((item) => [item.id, item.position])), moved: false };
document.body.style.cursor = "grabbing";
}} onResize={() => undefined} onToggleCollapsed={toggleFrame} onTitleChange={unauthorized} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} /> : <CanvasNode key={node.id} data={node} dragOffset={dragRef.current?.nodeIds.includes(node.id) && dragOffset ? dragOffset : undefined} scale={viewport.k} isSelected={selectedNodeId === node.id} isRelated={false} isFocusRelated={false} isConnectionTarget={false} isConnecting={false} showImageInfo={false} readOnly renderNodeContent={renderSharedNode} onMouseDown={(event, nodeId) => {
}} onResize={() => undefined} onToggleCollapsed={toggleFrame} onTitleChange={unauthorized} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} /> : <CanvasNode key={node.id} data={node} dragOffset={dragRef.current?.nodeIds.includes(node.id) && dragOffset ? dragOffset : undefined} scale={viewport.k} isSelected={selectedNodeId === node.id} isRelated={false} isFocusRelated={false} isConnectionTarget={false} showImageInfo={false} readOnly renderNodeContent={renderSharedNode} onMouseDown={(event, nodeId) => {
event.stopPropagation();
if (event.button !== 0) return;
const target = nodes.find((item) => item.id === nodeId);

View File

@@ -110,7 +110,7 @@ export function useCanvasRenderModel({
return { left, top, width: Math.max(2, right - left), height: Math.max(2, bottom - top) };
}, [nodes]);
const renderBounds = useMemo(() => {
const padding = (reduceMediaEffects ? Math.max(240, Math.max(viewportSize.width, viewportSize.height) * 0.4) : Math.max(800, Math.max(viewportSize.width, viewportSize.height) * 1.5)) / viewport.k;
const padding = (reduceMediaEffects ? 240 : 360) / viewport.k;
const viewLeft = -viewport.x / viewport.k - padding;
const viewTop = -viewport.y / viewport.k - padding;
const viewRight = viewLeft + viewportSize.width / viewport.k + padding * 2;
@@ -121,11 +121,13 @@ export function useCanvasRenderModel({
const frames: CanvasNodeData[] = [];
const regular: CanvasNodeData[] = [];
nodes.forEach((node) => {
if (renderHiddenNodeIds.has(node.id) || node.position.x + node.width <= renderBounds.left || node.position.x >= renderBounds.right || node.position.y + node.height <= renderBounds.top || node.position.y >= renderBounds.bottom) return;
if (renderHiddenNodeIds.has(node.id)) return;
const retained = selectedNodeIds.has(node.id) || Boolean(dragPreview?.nodeIds.has(node.id));
if (!retained && (node.position.x + node.width <= renderBounds.left || node.position.x >= renderBounds.right || node.position.y + node.height <= renderBounds.top || node.position.y >= renderBounds.bottom)) return;
(isFrameNode(node) ? frames : regular).push(node);
});
return [...frames, ...regular];
}, [nodes, renderBounds, renderHiddenNodeIds]);
}, [dragPreview, nodes, renderBounds, renderHiddenNodeIds, selectedNodeIds]);
const imageAssets = useMemo(() => assets.filter((asset): asset is ImageAsset => asset.kind === "image"), [assets]);
const canvasImageNodes = useMemo(() => nodes.filter((node) => node.type === CanvasNodeType.Image && Boolean(node.metadata?.content) && !collapsedBatchChildIds.has(node.id) && !(node.parentId && nodeById.get(node.parentId)?.metadata?.frame?.collapsed)), [collapsedBatchChildIds, nodeById, nodes]);

View File

@@ -39,12 +39,10 @@ export type PublicLogicalModel = {
export type AdminLogicalRoute = {
id: string;
physicalVariantId: string;
physicalVariantName: string;
channelModelId: string;
channelId: string;
physicalModelKey: string;
physicalModelName: string;
channelModelKey: string;
channelModelName: string;
enabled: boolean;
priority: number;
weight: number;
@@ -61,29 +59,6 @@ export type AdminLogicalModel = PublicLogicalModel & {
routes: AdminLogicalRoute[];
};
export type AdminPhysicalVariant = {
id: string;
channelModelId: string;
channelId: string;
modelKey: string;
modelName: string;
name: string;
capability: CapabilitySpec["capability"];
protocol: string;
enabled: boolean;
modelEnabled: boolean;
usageCount: number;
capabilitySpec: CapabilitySpec;
};
export type PhysicalVariantMutation = {
channelModelId: string;
name: string;
enabled: boolean;
/** 能力范围由渠道模型能力参数自动生成;保留可选字段兼容旧调用方。 */
capabilitySpec?: CapabilitySpec;
};
export type LogicalModelMutation = {
code: string;
name: string;
@@ -100,12 +75,12 @@ export type LogicalModelMutation = {
cachedPriceMicrocredits: number;
capabilitySpec: CapabilitySpec;
defaultOptions: Record<string, unknown>;
routes: Array<{ physicalVariantId: string; enabled: boolean; priority: number; weight: number }>;
routes: Array<{ channelModelId: string; enabled: boolean; priority: number; weight: number }>;
};
export type RouteSimulationResult = {
productMatch: { matched: boolean; reasons?: string[] };
candidates: Array<{ routeId: string; variantId: string; channelModelId: string; priority: number; weight: number; enabled: boolean; matched: boolean; blocked: boolean; inPool: boolean; reasons?: string[] }>;
candidates: Array<{ routeId: string; channelModelId: string; channelModelKey: string; channelModelName: string; priority: number; weight: number; enabled: boolean; matched: boolean; blocked: boolean; inPool: boolean; reasons?: string[] }>;
};
export function listLogicalModels() {
@@ -128,18 +103,6 @@ export function updateAdminLogicalModel(id: string, input: LogicalModelMutation)
return request<{ model: AdminLogicalModel }>(apiClient.patch(`/admin/logical-models/${encodeURIComponent(id)}`, input));
}
export function listAdminPhysicalVariants(channelModelId?: string) {
return request<{ variants: AdminPhysicalVariant[] }>(apiClient.get("/admin/logical-models/physical-variants", { params: channelModelId ? { channelModelId } : undefined }));
}
export function createAdminPhysicalVariant(input: PhysicalVariantMutation) {
return request<{ variant: AdminPhysicalVariant }>(apiClient.post("/admin/logical-models/physical-variants", input));
}
export function updateAdminPhysicalVariant(id: string, input: PhysicalVariantMutation) {
return request<{ variant: AdminPhysicalVariant }>(apiClient.patch(`/admin/logical-models/physical-variants/${encodeURIComponent(id)}`, input));
}
export function simulateAdminLogicalModel(id: string, intent: ModelRequestIntent) {
return request<RouteSimulationResult>(apiClient.post(`/admin/logical-models/${encodeURIComponent(id)}/simulate`, intent));
}

View File

@@ -1390,7 +1390,8 @@
.app-workspace-shell.is-spatial .app-workspace-rail .app-workspace-rail-tile {
width: 40px !important;
height: 40px !important;
height: auto !important;
min-height: 40px;
border-radius: var(--r-md);
margin-bottom: 0 !important;
}
@@ -7725,6 +7726,11 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
height: 100%;
}
.admin-page-root.admin-page-root-scrollable > div {
height: auto;
min-height: 100%;
}
.admin-page-frame {
display: flex;
height: 100%;
@@ -7733,6 +7739,11 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
padding: 12px 16px 0;
}
.admin-page-frame-scrollable {
height: auto;
min-height: 100%;
}
.admin-page-header {
min-height: 44px;
}