fix(*): 模型目录 - 修复前台开关、系统模型价格与访问控制

This commit is contained in:
ddCat
2026-08-22 20:57:31 +08:00
parent 5d9986f6c7
commit 1bb5d1423a
19 changed files with 989 additions and 84 deletions

View File

@@ -7,12 +7,13 @@ import { WorkspaceErrorState, WorkspaceLoadingState, WorkspaceState } from "@/co
import { refreshFeatureAvailability } from "@/lib/user-session";
import { useUserStore } from "@/stores/use-user-store";
type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled";
type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "frontendModelsEnabled";
const featureNames: Record<FeatureKey, string> = {
shortDramaEnabled: "短剧创作",
taskCenterEnabled: "任务中心",
creditsEnabled: "积分中心",
frontendModelsEnabled: "前台模型",
};
let featureAvailabilityCheckedOnce = false;
@@ -43,9 +44,14 @@ export function RequireFeature({ feature, children }: { feature: FeatureKey; chi
if (checking) return <WorkspacePage><WorkspaceLoadingState label="正在确认功能状态" detail={featureNames[feature]} rows={3} /></WorkspacePage>;
if (error) return <WorkspacePage><WorkspaceErrorState title="无法确认功能状态" description={error} actionLabel="返回创作台" onRetry={() => navigate("/create", { replace: true })} /></WorkspacePage>;
if (!features[feature]) {
// 管理员页面返回到管理后台首页,用户页面返回到创作台
const isAdminFeature = feature === "frontendModelsEnabled";
const backPath = isAdminFeature ? "/admin" : "/create";
const backLabel = isAdminFeature ? "返回管理后台" : "返回创作台";
return (
<WorkspacePage>
<WorkspaceState icon="empty" title={`${featureNames[feature]}暂未开放`} description="当前功能已由平台管理员关闭。" action={<Button type="primary" onClick={() => navigate("/create", { replace: true })}>返回创作台</Button>} />
<WorkspaceState icon="empty" title={`${featureNames[feature]}暂未开放`} description="当前功能已由平台管理员关闭。" action={<Button type="primary" onClick={() => navigate(backPath, { replace: true })}>{backLabel}</Button>} />
</WorkspacePage>
);
}

View File

@@ -1,11 +1,11 @@
import { getFeatureAvailability, type AuthSessionPayload } from "@/services/api/auth";
import { listLogicalModels, type CapabilitySpec, type OptionConstraint, type PublicLogicalModel } from "@/services/api/logical-models";
import { getModelCatalog, listLogicalModels, type CapabilitySpec, type ModelCatalogResponse, type OptionConstraint, type PublicChannelCatalog, type PublicLogicalModel } from "@/services/api/logical-models";
import { localForageStorage } from "@/lib/localforage-storage";
import { appQueryClient } from "@/lib/query-client";
import { scopedLocalStorage, setActiveUserScope } from "@/lib/user-scope";
import { CANVAS_STORE_KEY, flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store";
import { CONFIG_STORE_KEY, PUBLIC_MODEL_CATALOG_ID, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelChannel } from "@/stores/use-config-store";
import { CONFIG_STORE_KEY, PUBLIC_MODEL_CATALOG_ID, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
import { defaultModelCapabilityConfig, STANDARD_IMAGE_SIZE_VALUES, type ModelCapabilityConfig } from "@/lib/model-capabilities";
import { useUserStore } from "@/stores/use-user-store";
import { installRemoteUserDataAutoSync, resetRemoteUserDataSync, syncRemoteUserData, withRemoteUserDataSyncPaused } from "@/services/user-data-sync";
@@ -41,9 +41,17 @@ export async function applyUserSession(payload: AuthSessionPayload) {
if (!persistedAssets) useAssetStore.setState({ assets: [] });
if (!persistedConfig) {
// 只有首次配置缺失时才生成能力推荐;已有配置中的空数组代表用户明确清空。
// 使用统一模型目录接口
const catalog = await getModelCatalog();
let channels: ModelChannel[] = [];
if (catalog.source === "frontend" && catalog.models) {
channels = managedModelChannels(catalog.models);
} else if (catalog.source === "system" && catalog.channels) {
channels = systemChannelModelChannels(catalog.channels);
}
const initialSystemConfig = {
...defaultConfig,
channels: managedModelChannels(payload.logicalModels || []),
channels,
imageModels: undefined,
videoModels: undefined,
textModels: undefined,
@@ -51,7 +59,13 @@ export async function applyUserSession(payload: AuthSessionPayload) {
};
useConfigStore.getState().replaceConfig(normalizeConfigSnapshot({ config: initialSystemConfig }).config);
} else {
useConfigStore.getState().mergeSystemChannels(managedModelChannels(payload.logicalModels || []));
// 已有配置时也需要合并最新的系统渠道
const catalog = await getModelCatalog();
if (catalog.source === "frontend" && catalog.models) {
useConfigStore.getState().mergeSystemChannels(managedModelChannels(catalog.models));
} else if (catalog.source === "system" && catalog.channels) {
useConfigStore.getState().mergeSystemChannels(systemChannelModelChannels(catalog.channels));
}
}
installRemoteUserDataAutoSync();
if (payload.user?.id) {
@@ -64,9 +78,16 @@ export async function applyUserSession(payload: AuthSessionPayload) {
}
export async function refreshSystemChannels() {
// 创作端只刷新公开前台模型;供应渠道目录仅管理员页面可见。
const logicalPayload = await listLogicalModels();
useConfigStore.getState().mergeSystemChannels(managedModelChannels(logicalPayload.models || []));
// 使用统一模型目录接口,根据 frontendModelsEnabled 自动返回前台模型或系统渠道模型
const catalog = await getModelCatalog();
if (catalog.source === "frontend" && catalog.models) {
// 前台模型模式
useConfigStore.getState().mergeSystemChannels(managedModelChannels(catalog.models));
} else if (catalog.source === "system" && catalog.channels) {
// 系统渠道模式
useConfigStore.getState().mergeSystemChannels(systemChannelModelChannels(catalog.channels));
}
}
function managedModelChannels(models: PublicLogicalModel[]) {
@@ -107,6 +128,62 @@ function managedModelChannels(models: PublicLogicalModel[]) {
return [managed];
}
// 系统渠道模型转换为前端配置格式
function systemChannelModelChannels(channels: PublicChannelCatalog[]): ModelChannel[] {
return channels.map((channel) => {
const availableModels = channel.models.filter((m) => m.available);
return {
id: channel.id,
name: channel.displayName,
baseUrl: "/api",
apiKey: "system",
apiFormat: "openai",
scope: "system" as const,
enabled: true,
models: availableModels.map((m) => m.modelKey),
modelAliases: {},
modelCosts: availableModels.map((model) => {
// 标量价格仅用于旧配置兼容;创作端按完整 SKU 档位展示和匹配价格。
const firstTier = model.priceTiers?.[0];
const unitPrice = firstTier?.unitPriceMicrocredits || 0;
const inputPrice = firstTier?.inputTokenPriceMicrocredits || 0;
const outputPrice = firstTier?.outputTokenPriceMicrocredits || 0;
const cachedPrice = firstTier?.cachedTokenPriceMicrocredits || 0;
const billingMode = firstTier?.billingMode || "fixed_request";
const logicalPriceTiers = (model.priceTiers || []).map((tier) => ({
selector: tier.selector || {},
resolution: tier.resolution || "*",
videoSeconds: tier.videoSeconds || 0,
billingMode: tier.billingMode as "fixed_request" | "per_second" | "token",
unitPriceMicrocredits: tier.unitPriceMicrocredits || 0,
inputTokenPriceMicrocredits: tier.inputTokenPriceMicrocredits || 0,
outputTokenPriceMicrocredits: tier.outputTokenPriceMicrocredits || 0,
cachedTokenPriceMicrocredits: tier.cachedTokenPriceMicrocredits || 0,
}));
return {
model: model.modelKey,
displayName: model.displayName,
description: "",
icon: "",
capability: model.capability as ModelCapability,
pricePolicy: "channel" as const,
billingMode: billingMode as any,
unitPriceMicrocredits: unitPrice,
inputTokenPriceMicrocredits: inputPrice,
outputTokenPriceMicrocredits: outputPrice,
cachedTokenPriceMicrocredits: cachedPrice,
capabilityConfig: (model.capabilityConfig as ModelCapabilityConfig | undefined) || defaultModelCapabilityConfig(),
channelModelId: model.id,
channelId: channel.id,
modelKey: model.modelKey,
logicalPriceTiers,
};
}),
};
});
}
function projectLogicalCapability(spec: CapabilitySpec, defaults: Record<string, unknown>): ModelCapabilityConfig {
const projected = defaultModelCapabilityConfig();
if (spec.capability === "image" && projected.image) {

View File

@@ -32,12 +32,14 @@ import { WORKSPACE_SIDEBAR_STORAGE_KEY } from "@/components/layout/workspace-sid
import { getAdminAntThemeConfig } from "@/lib/app-theme";
import { cn } from "@/lib/utils";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
type AdminNavigationItem = {
path: string;
label: string;
description: string;
icon: ReactNode;
requireFeature?: "frontendModelsEnabled";
};
const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> = [
@@ -50,7 +52,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
items: [
{ path: "/admin/users", label: "用户管理", description: "账号、角色与状态", icon: <UsersRound className="size-4" /> },
{ path: "/admin/channels", label: "系统渠道", description: "渠道、模型与售价", icon: <RadioTower className="size-4" /> },
{ path: "/admin/models", label: "前台模型", description: "展示、线路与用户价格", icon: <Layers3 className="size-4" /> },
{ path: "/admin/models", label: "前台模型", description: "展示、线路与用户价格", icon: <Layers3 className="size-4" />, requireFeature: "frontendModelsEnabled" },
{ path: "/admin/prompt-templates", label: "提示词模板", description: "平台创作策略版本", icon: <MessageSquareText className="size-4" /> },
],
},
@@ -160,62 +162,70 @@ export function AdminPageFrame({ title, actions, back, scroll = false, children
}
function MobileAdminNavigation() {
const features = useUserStore((state) => state.features);
const visibleItems = adminNavigation.flatMap((group) => group.items).filter((item) => !item.requireFeature || features[item.requireFeature]);
return (
<nav className="app-workspace-navigation hide-scrollbar flex shrink-0 gap-1 overflow-x-auto border-b border-border/70 px-3 py-2 lg:hidden" aria-label="管理后台分区">
{adminNavigation
.flatMap((group) => group.items)
.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === "/admin"}
className={({ isActive }) =>
cn("app-workspace-nav-link flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-xs transition-colors", isActive ? "is-active font-medium" : "text-foreground/60 hover:bg-surface-hover hover:text-foreground")
}
>
{item.icon}
<span>{item.label}</span>
</NavLink>
))}
{visibleItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === "/admin"}
className={({ isActive }) =>
cn("app-workspace-nav-link flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-xs transition-colors", isActive ? "is-active font-medium" : "text-foreground/60 hover:bg-surface-hover hover:text-foreground")
}
>
{item.icon}
<span>{item.label}</span>
</NavLink>
))}
<AppChangelogButton className="grid size-8 shrink-0 place-items-center rounded-md text-foreground/55 transition-colors hover:bg-surface-hover hover:text-foreground [&_svg]:size-4" />
</nav>
);
}
function AdminNavigation({ collapsed }: { collapsed: boolean }) {
const features = useUserStore((state) => state.features);
return (
<nav className="thin-scrollbar flex-1 overflow-y-auto px-2 py-2" aria-label="管理后台菜单">
{adminNavigation.map((group) => (
<div key={group.label} className="mb-3">
{!collapsed ? (
<div className="admin-nav-group-label mb-1 px-2.5 text-[var(--fs-tiny)] font-medium text-foreground/38">
<span>{group.label}</span>
{adminNavigation.map((group) => {
const visibleItems = group.items.filter((item) => !item.requireFeature || features[item.requireFeature]);
if (visibleItems.length === 0) return null;
return (
<div key={group.label} className="mb-3">
{!collapsed ? (
<div className="admin-nav-group-label mb-1 px-2.5 text-[var(--fs-tiny)] font-medium text-foreground/38">
<span>{group.label}</span>
</div>
) : (
<div className="mx-auto mb-1.5 h-px w-7 bg-border/80" />
)}
<div className="space-y-0.5">
{visibleItems.map((item) => (
<Tooltip key={item.path} mouseEnterDelay={0.1} title={collapsed ? item.label : undefined} placement="right">
<NavLink
to={item.path}
end={item.path === "/admin"}
className={({ isActive }) =>
cn(
"app-workspace-nav-link flex h-8 items-center rounded-md text-[var(--fs-body)] transition-colors",
collapsed ? "justify-center px-0" : "gap-2.5 px-2.5",
isActive ? "is-active font-medium" : "text-foreground/62 hover:bg-surface-hover hover:text-foreground",
)
}
>
{item.icon}
{!collapsed ? <span className="truncate">{item.label}</span> : null}
</NavLink>
</Tooltip>
))}
</div>
) : (
<div className="mx-auto mb-1.5 h-px w-7 bg-border/80" />
)}
<div className="space-y-0.5">
{group.items.map((item) => (
<Tooltip key={item.path} mouseEnterDelay={0.1} title={collapsed ? item.label : undefined} placement="right">
<NavLink
to={item.path}
end={item.path === "/admin"}
className={({ isActive }) =>
cn(
"app-workspace-nav-link flex h-8 items-center rounded-md text-[var(--fs-body)] transition-colors",
collapsed ? "justify-center px-0" : "gap-2.5 px-2.5",
isActive ? "is-active font-medium" : "text-foreground/62 hover:bg-surface-hover hover:text-foreground",
)
}
>
{item.icon}
{!collapsed ? <span className="truncate">{item.label}</span> : null}
</NavLink>
</Tooltip>
))}
</div>
</div>
))}
);
})}
</nav>
);
}

View File

@@ -1,20 +1,24 @@
import { useEffect, useState, type ReactNode } from "react";
import { App, Switch } from "antd";
import { Clapperboard, Coins, ListChecks, RadioTower, ToggleLeft } from "lucide-react";
import { Clapperboard, Coins, ListChecks, RadioTower, ToggleLeft, Sparkles, Settings } from "lucide-react";
import { getAdminFeatureAvailability, updateAdminFeatureAvailability } from "@/services/api/auth";
import { useUserStore, type FeatureAvailability } from "@/stores/use-user-store";
import { SettingsSectionCard } from "./admin-ui";
type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled";
type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "frontendModelsEnabled";
const featureRows: Array<{ key: FeatureKey; title: string; menu: string; description: string; icon: ReactNode }> = [
const userFeatureRows: Array<{ key: FeatureKey; title: string; menu: string; description: string; icon: ReactNode }> = [
{ key: "shortDramaEnabled", title: "短剧创作", menu: "/projects", description: "关闭后隐藏短剧入口,并拦截项目列表、详情和项目 API。已有项目数据不会删除。", icon: <Clapperboard className="size-4" /> },
{ key: "taskCenterEnabled", title: "任务", menu: "/tasks", description: "关闭后仅隐藏并拦截任务中心页面;生成任务仍会创建、执行、记录和恢复。", icon: <ListChecks className="size-4" /> },
{ key: "creditsEnabled", title: "积分中心", menu: "/wallet", description: "关闭后隐藏用户积分入口,新创建的任务和系统渠道请求不再冻结或消费积分。", icon: <Coins className="size-4" /> },
{ key: "customChannelsEnabled", title: "自定义渠道", menu: "/settings?section=channels", description: "关闭后隐藏用户自定义渠道入口,并拦截模型目录拉取、渠道中转和新的生成任务。已有渠道配置不会删除。", icon: <RadioTower className="size-4" /> },
];
const adminFeatureRows: Array<{ key: FeatureKey; title: string; menu: string; description: string; icon: ReactNode }> = [
{ key: "frontendModelsEnabled", title: "前台模型", menu: "/admin/models", description: "关闭后用户直接使用系统渠道中的模型;后台仍保留前台模型配置入口,方便重新开启。已有配置不会删除。", icon: <Sparkles className="size-4" /> },
];
export default function FeatureAvailabilityPanel() {
const { message, modal } = App.useApp();
const setGlobalFeatures = useUserStore((state) => state.setFeatures);
@@ -43,10 +47,11 @@ export default function FeatureAvailabilityPanel() {
taskCenterEnabled: next.taskCenterEnabled,
creditsEnabled: next.creditsEnabled,
customChannelsEnabled: next.customChannelsEnabled,
frontendModelsEnabled: next.frontendModelsEnabled,
});
setFeatures(result.features);
setGlobalFeatures(result.features);
message.success(`${featureRows.find((item) => item.key === key)?.title || "功能"}已${enabled ? "开放" : "关闭"}`);
message.success(`${allFeatureRows.find((item) => item.key === key)?.title || "功能"}已${enabled ? "开放" : "关闭"}`);
} catch (error) {
message.error(error instanceof Error ? error.message : "保存功能开放配置失败");
} finally {
@@ -55,30 +60,67 @@ export default function FeatureAvailabilityPanel() {
};
const toggle = (key: FeatureKey, enabled: boolean) => {
if (key !== "creditsEnabled" || enabled) {
void save(key, enabled);
// 积分关闭需要二次确认
if (key === "creditsEnabled" && !enabled) {
modal.confirm({
title: "关闭用户积分功能?",
content: "保存后新创建的任务和系统渠道请求将不再扣减积分;已经冻结的计费订单仍按原规则结算,已有余额和流水继续保留。",
okText: "确认关闭",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: () => save(key, false),
});
return;
}
modal.confirm({
title: "关闭用户积分功能?",
content: "保存后新创建的任务和系统渠道请求将不再扣减积分;已经冻结的计费订单仍按原规则结算,已有余额和流水继续保留。",
okText: "确认关闭",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: () => save(key, false),
});
// 前台模型关闭需要二次确认
if (key === "frontendModelsEnabled" && !enabled) {
modal.confirm({
title: "关闭前台模型功能?",
content: "关闭后用户将直接使用系统渠道中配置的模型;管理后台仍保留前台模型配置入口,重新开启后即可继续使用。",
okText: "确认关闭",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: () => save(key, false),
});
return;
}
void save(key, enabled);
};
const enabledCount = features ? featureRows.filter((item) => features[item.key]).length : 0;
const allFeatureRows = [...userFeatureRows, ...adminFeatureRows];
const userEnabledCount = features ? userFeatureRows.filter((item) => features[item.key]).length : 0;
const adminEnabledCount = features ? adminFeatureRows.filter((item) => features[item.key]).length : 0;
return (
<div className="pt-4">
<div className="space-y-6 pt-4">
<SettingsSectionCard
icon={<ToggleLeft className="size-4" />}
title="用户功能开放"
status={{ label: features ? `${enabledCount}/${featureRows.length} 已开放` : "读取中", color: enabledCount === featureRows.length ? "success" : "default" }}
status={{ label: features ? `${userEnabledCount}/${userFeatureRows.length} 已开放` : "读取中", color: userEnabledCount === userFeatureRows.length ? "success" : "default" }}
>
<div className="divide-y divide-border/75">
{featureRows.map((item) => (
{userFeatureRows.map((item) => (
<div key={item.key} className="flex min-h-16 items-center gap-3 px-4 py-3">
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted/35 text-foreground/65">{item.icon}</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-medium">{item.title}</h3>
<span className="rounded border border-border/70 px-1.5 py-0.5 text-[var(--fs-micro)] text-foreground/45">{item.menu}</span>
</div>
</div>
<Switch checked={features?.[item.key] === true} loading={!features || saving === item.key} disabled={Boolean(saving && saving !== item.key)} onChange={(checked) => toggle(item.key, checked)} aria-label={`开放${item.title}`} />
</div>
))}
</div>
</SettingsSectionCard>
<SettingsSectionCard
icon={<Settings className="size-4" />}
title="管理后台功能"
status={{ label: features ? `${adminEnabledCount}/${adminFeatureRows.length} 已开放` : "读取中", color: adminEnabledCount === adminFeatureRows.length ? "success" : "default" }}
>
<div className="divide-y divide-border/75">
{adminFeatureRows.map((item) => (
<div key={item.key} className="flex min-h-16 items-center gap-3 px-4 py-3">
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted/35 text-foreground/65">{item.icon}</span>
<div className="min-w-0 flex-1">

View File

@@ -5,6 +5,7 @@ import { useNavigate, useSearchParams } from "react-router";
import { applyUserSession } from "@/lib/user-session";
import { getAuthSession, getAuthSettings, linuxDOLoginURL, login } from "@/services/api/auth";
import { useUserStore } from "@/stores/use-user-store";
import { LinuxDOIcon } from "./auth-scene";
export default function LoginPage() {
@@ -16,6 +17,15 @@ export default function LoginPage() {
const [submitting, setSubmitting] = useState(false);
const [linuxdoEnabled, setLinuxdoEnabled] = useState(false);
const next = safeNext(params.get("next"));
const user = useUserStore((state) => state.user);
const hydrated = useUserStore((state) => state.hydrated);
// 如果已登录,直接跳转
useEffect(() => {
if (hydrated && user) {
navigate(next, { replace: true });
}
}, [hydrated, user, next, navigate]);
useEffect(() => {
void getAuthSettings().then((settings) => setLinuxdoEnabled(settings.linuxdoEnabled)).catch(() => undefined);

View File

@@ -142,7 +142,7 @@ export const router = createBrowserRouter([
{ index: true, element: deferred(<AnalyticsPage />) },
{ path: "users", element: deferred(<UsersPage />) },
{ path: "channels", element: deferred(<ChannelsPage />) },
{ path: "models", element: deferred(<LogicalModelsPage />) },
{ path: "models", element: <RequireFeature feature="frontendModelsEnabled">{deferred(<LogicalModelsPage />)}</RequireFeature> },
{ path: "prompt-templates", element: deferred(<StoryboardPromptsPage />) },
{ path: "storyboard-prompts", element: <Navigate to="/admin/prompt-templates" replace /> },
{ path: "announcements", element: deferred(<AnnouncementsPage />) },

View File

@@ -370,7 +370,7 @@ export function getAdminFeatureAvailability() {
return request<{ features: FeatureAvailability }>(api.get("/admin/settings/features"));
}
export function updateAdminFeatureAvailability(features: Pick<FeatureAvailability, "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled">) {
export function updateAdminFeatureAvailability(features: Pick<FeatureAvailability, "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "frontendModelsEnabled">) {
return request<{ features: FeatureAvailability }>(api.patch("/admin/settings/features", features));
}

View File

@@ -121,6 +121,56 @@ export type LogicalModelQuote = {
estimated: boolean;
};
export type ModelCatalogSource = "frontend" | "system";
export type PublicChannelCatalog = {
id: string;
name: string;
displayName: string;
models: PublicChannelModel[];
};
export type PublicChannelModel = {
id: string;
modelKey: string;
displayName: string;
capability: string;
capabilityConfig?: Record<string, any>;
priceTiers: PublicChannelModelPriceTier[];
pricingMode: string;
displayPrice?: number;
priceLabel: string;
available: boolean;
};
export type PublicChannelModelPriceTier = {
id: string;
selector?: Record<string, string>;
resolution: string;
videoSeconds: number;
billingMode: string;
unitPriceMicrocredits: number;
inputTokenPriceMicrocredits: number;
outputTokenPriceMicrocredits: number;
cachedTokenPriceMicrocredits: number;
};
export type ModelCatalogResponse = {
source: ModelCatalogSource;
models?: PublicLogicalModel[];
channels?: PublicChannelCatalog[];
};
// 统一模型目录接口 - 根据 frontendModelsEnabled 开关返回前台模型或系统渠道模型
export function getModelCatalog() {
return request<ModelCatalogResponse>(apiClient.get("/model-catalog"));
}
export function getAvailableModelCatalog(intent: ModelRequestIntent) {
return request<ModelCatalogResponse>(apiClient.post("/model-catalog/available", intent));
}
// 旧接口,保持兼容
export function listLogicalModels() {
return request<{ models: PublicLogicalModel[] }>(apiClient.get("/models"));
}

View File

@@ -475,7 +475,20 @@ export function modelOptionsFromChannels(channels: ModelChannel[]) {
export function hasSystemModelPrice(channel: ModelChannel, model: string) {
if (channel.scope !== "system") return true;
return channel.modelCosts?.some((item) => item.model === model && Number.isFinite(item.unitPriceMicrocredits) && item.unitPriceMicrocredits >= 0) === true;
const positive = (value: number | undefined) => typeof value === "number" && Number.isFinite(value) && value > 0;
return channel.modelCosts?.some((item) => {
if (item.model !== model) return false;
const tiers = item.logicalPriceTiers || [];
if (tiers.length) {
return tiers.some((tier) => tier.billingMode === "token"
? [tier.inputTokenPriceMicrocredits, tier.outputTokenPriceMicrocredits, tier.cachedTokenPriceMicrocredits].some(positive)
: positive(tier.unitPriceMicrocredits));
}
if (item.billingMode === "token") {
return [item.inputTokenPriceMicrocredits, item.outputTokenPriceMicrocredits, item.cachedTokenPriceMicrocredits].some(positive);
}
return positive(item.unitPriceMicrocredits);
}) === true;
}
export function normalizeModelOptionValue(value: unknown, channels: ModelChannel[]) {

View File

@@ -29,6 +29,7 @@ export type FeatureAvailability = {
taskCenterEnabled: boolean;
creditsEnabled: boolean;
customChannelsEnabled: boolean;
frontendModelsEnabled: boolean;
desktopLocalChannelsEnabled: boolean;
configured?: boolean;
updatedBy?: string;
@@ -40,6 +41,7 @@ export const defaultFeatureAvailability: FeatureAvailability = {
taskCenterEnabled: true,
creditsEnabled: true,
customChannelsEnabled: true,
frontendModelsEnabled: true,
desktopLocalChannelsEnabled: false,
};