import { useEffect, useState } from "react"; import { App, Button, Checkbox, Input, Modal, Popconfirm, Select, Space } from "antd"; import type { ColumnsType } from "antd/es/table"; import { Plus, RefreshCw, Search, Trash2 } from "lucide-react"; import { PaginationBar } from "@/pages/admin/components/admin-ui"; import { ModelIcon } from "@/components/model-picker"; import { modelProtocolDefinition, modelProtocolLabel, type ModelProtocol } from "@/lib/model-protocols"; import { fetchPluginProviderCatalog } from "@/services/api/plugin-catalog"; import { deleteAdminChannelModel, deleteAdminChannelModels, fetchAdminChannelModels, importAdminChannelModels, listAdminChannelModels, type ChannelModel, type ChannelModelPriceTier } from "@/services/api/wallet"; import type { ModelChannel } from "@/stores/use-config-store"; import { ChannelModelEditor } from "./channel-model-editor"; import { AdminPageFrame } from "./admin-shell"; import { AdminBatchBar, AdminDataTable, AdminFilterChip, AdminStatusBadge } from "./admin-ui"; import { ChannelOrderDialog } from "./channel-order-dialog"; export function ChannelModelManager({ channel, onClose, onChanged }: { channel: ModelChannel; onClose: () => void; onChanged: () => void | Promise }) { const { message, modal } = App.useApp(); const [items, setItems] = useState([]); const [selectedModelIds, setSelectedModelIds] = useState([]); const [deletingSelected, setDeletingSelected] = useState(false); const [editing, setEditing] = useState(null); const [loading, setLoading] = useState(false); const [fetching, setFetching] = useState(false); const [fetchPreviewOpen, setFetchPreviewOpen] = useState(false); const [fetchPreviewModels, setFetchPreviewModels] = useState([]); const [selectedFetchModels, setSelectedFetchModels] = useState([]); const [importing, setImporting] = useState(false); const [editorOpen, setEditorOpen] = useState(false); const [keyword, setKeyword] = useState(""); const [capability, setCapability] = useState("all"); const [status, setStatus] = useState<"all" | "enabled" | "disabled">("all"); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [availableProtocols, setAvailableProtocols] = useState([]); const [protocolLoading, setProtocolLoading] = useState(true); const [protocolError, setProtocolError] = useState(""); const loadProtocols = async () => { setProtocolLoading(true); setProtocolError(""); try { setAvailableProtocols(await fetchPluginProviderCatalog("admin.system-channel")); } catch (error) { setAvailableProtocols([]); setProtocolError(error instanceof Error ? error.message : "无法读取协议目录"); } finally { setProtocolLoading(false); } }; const reload = async () => { if (!channel) return; setLoading(true); try { const models = (await listAdminChannelModels(channel.id)).models; const availableIDs = new Set(models.map((item) => item.id)); setItems(models); setSelectedModelIds((current) => current.filter((id) => availableIDs.has(id))); } catch (error) { message.error(error instanceof Error ? error.message : "读取渠道模型失败"); } finally { setLoading(false); } }; useEffect(() => { void reload(); void loadProtocols(); setEditing(null); setEditorOpen(false); setSelectedModelIds([]); resetFetchPreview(); setKeyword(""); setCapability("all"); setStatus("all"); setPage(1); }, [channel.id]); const fetchModels = async () => { setFetching(true); try { const result = await fetchAdminChannelModels(channel.id); if (result.models.length === 0) { message.warning("上游没有返回可用模型"); return; } setFetchPreviewModels(result.models); setSelectedFetchModels(result.models); setFetchPreviewOpen(true); } catch (error) { message.error(error instanceof Error ? error.message : "拉取模型失败"); } finally { setFetching(false); } }; const closeFetchPreview = () => { if (importing) return; resetFetchPreview(); }; const resetFetchPreview = () => { setFetchPreviewOpen(false); setFetchPreviewModels([]); setSelectedFetchModels([]); }; const importSelectedModels = async () => { if (!selectedFetchModels.length) return; if (!selectedNewFetchModels.length) { message.info("当前勾选的模型均已存在,没有需要新增的模型"); resetFetchPreview(); return; } setImporting(true); try { const result = await importAdminChannelModels(channel.id, selectedFetchModels); await reload(); await onChanged(); resetFetchPreview(); if (result.added > 0) message.success(`已导入 ${result.added} 个模型,新增模型仍需配置价格后启用`); else message.info("所选模型均已存在,没有新增模型"); } catch (error) { message.error(error instanceof Error ? error.message : "导入模型失败"); } finally { setImporting(false); } }; const startCreate = () => { setEditing(null); setEditorOpen(true); }; const startEdit = (item: ChannelModel) => { setEditing(item); setEditorOpen(true); }; const remove = async (item: ChannelModel) => { try { await deleteAdminChannelModel(channel.id, item.id); await reload(); await onChanged(); message.success("模型已删除"); } catch (error) { message.error(error instanceof Error ? error.message : "删除模型失败"); } }; const confirmBatchRemove = () => { if (!selectedModelIds.length) return; const count = selectedModelIds.length; modal.confirm({ title: `删除已选的 ${count} 个模型?`, content: "删除后模型不再显示,且不能在页面恢复。只要其中任一模型仍被前台供应线路或进行中任务使用,本次就不会删除任何模型。", okText: "批量删除", cancelText: "取消", okButtonProps: { danger: true }, onOk: async () => { setDeletingSelected(true); try { const result = await deleteAdminChannelModels(channel.id, selectedModelIds); setSelectedModelIds([]); setPage(1); await reload(); await onChanged(); message.success(`已删除 ${result.deleted} 个模型`); } catch (error) { message.error(error instanceof Error ? error.message : "批量删除模型失败"); } finally { setDeletingSelected(false); } }, }); }; const columns: ColumnsType = [ { title: "模型", render: (_, item) => (
{item.displayName || item.modelKey}
{item.modelKey}
{item.channelLabel ?
渠道展示名:{item.channelLabel}
: null} {item.providerModelKey && item.providerModelKey !== item.modelKey ?
上游:{item.providerModelKey}
: null}
), }, { title: "能力", dataIndex: "capability", width: 90, render: capabilityLabel }, { title: "请求协议", dataIndex: "protocol", width: 230, render: (value: ModelProtocol) => value ? (
{modelProtocolLabel(value, availableProtocols)}
{modelProtocolDefinition(value, availableProtocols)?.create}
) : ( ), }, { title: "规格价格", width: 280, render: (_, item) => (item.priceConfigured ? billingSummary(item) : ) }, { title: "版本", dataIndex: "priceVersion", width: 75, render: (value) => `v${value}` }, { title: "状态", dataIndex: "enabled", width: 85, render: (enabled) => }, { title: "操作", width: 180, render: (_, item) => ( void remove(item)}> } > } value={keyword} placeholder="搜索模型标识或显示名称" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} /> } toolbarActiveFilters={ <> {keyword ? ( { setKeyword(""); setPage(1); }} /> ) : null} {capability !== "all" ? ( { setCapability("all"); setPage(1); }} /> ) : null} {status !== "all" ? ( { setStatus("all"); setPage(1); }} /> ) : null} } toolbarActive={Boolean(keyword || capability !== "all" || status !== "all")} toolbarFilters={ <> { setStatus(value); setPage(1); }} options={[ { label: "全部状态", value: "all" }, { label: "已启用", value: "enabled" }, { label: "已停用", value: "disabled" }, ]} /> } onReset={() => { setKeyword(""); setCapability("all"); setStatus("all"); setPage(1); }} batchActions={ setSelectedModelIds([])}> } table={{ className: "app-data-table", rowKey: "id", size: "small", loading, rowSelection: { selectedRowKeys: selectedModelIds, preserveSelectedRowKeys: true, onChange: (keys) => { const next = keys.map(String); if (next.length > 100) message.warning("单次最多选择 100 个模型"); setSelectedModelIds(next.slice(0, 100)); }, }, columns, dataSource: pagedItems, pagination: false, scroll: { x: 1150 }, }} footer={ { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} /> } /> 取消 , , ]} >

上游共返回 {fetchPreviewModels.length} 个模型。默认已全选,可批量全选或取消全选;已存在的模型不会重复导入。

已选择 {selectedFetchModels.length} / {fetchPreviewModels.length} 个模型
setSelectedFetchModels(values as string[])} />
{selectedNewFetchModels.length > 0 ? `将导入 ${selectedNewFetchModels.length} 个新模型` : "当前勾选的模型均已存在"} {selectedExistingFetchCount > 0 ? `,另有 ${selectedExistingFetchCount} 个已存在模型已勾选` : ""}
{editorOpen && ( void loadProtocols()} onClose={() => setEditorOpen(false)} onSaved={async () => { await reload(); await onChanged(); }} /> )} ); } function capabilityLabel(value: ChannelModel["capability"]) { return { text: "文本", image: "图片", video: "视频", audio: "音频", "": "待配置" }[value]; } function billingSummary(item: ChannelModel) { const tiers = item.priceTiers?.filter((tier) => tier.enabled && tier.priceConfigured) || []; if (!tiers.length) return ; return (
{tiers.slice(0, 3).map((tier) => (
{priceTierLabel(tier, item.capability)}
))} {tiers.length > 3 ?
另有 {tiers.length - 3} 个规格价格档
: null}
); } function priceTierLabel(tier: ChannelModelPriceTier, capability: ChannelModel["capability"]) { const selector = tier.selector || {}; const specParts = [ selector.operation && selector.operation !== "*" ? operationLabel(selector.operation) : "任意生成方式", selector.quality && selector.quality !== "*" ? selector.quality.toUpperCase() : "", selector.size && selector.size !== "*" ? selector.size : "", tier.resolution === "*" ? "" : tier.resolution.toUpperCase(), tier.videoSeconds ? `${tier.videoSeconds} 秒` : "", selector.imageCount && selector.imageCount !== "*" ? `${selector.imageCount} 张参考图` : "", selector.videoGenerateAudio === "true" ? "有声" : selector.videoGenerateAudio === "false" ? "无声" : "", ].filter(Boolean); const spec = specParts.length ? specParts.join(" / ") : "默认规格"; if (tier.billingMode === "token") return `${spec} · ${formatCredits(tier.outputTokenPriceMicrocredits)} 积分 / 百万${capability === "video" ? "视频 " : " "}Token`; return `${spec} · ${formatCredits(tier.unitPriceMicrocredits)} 积分 / ${tier.billingMode === "per_second" ? "秒" : "次"}`; } function operationLabel(operation: string) { return ({ text_to_image: "文生图", image_to_image: "图生图", text_to_video: "文生视频", image_to_video: "图生视频", video_to_video: "视频生视频", text_generation: "文本生成" } as Record)[operation] || operation; } function formatCredits(value: number) { return (value / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 }); } function normalizeFetchModelKey(value: string) { return value .trim() .replace(/^models\//, "") .toLowerCase(); }