feat(admin): 系统模型 - 合并渠道与模型管理并展示规格成本价
This commit is contained in:
@@ -5,6 +5,16 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
|
||||
# 待测试
|
||||
|
||||
## 系统模型同页渠道与模型管理
|
||||
|
||||
- [x] 样式细化:渠道列表改为留白分组与中性选中描边,数量与启停状态分别呈现;模型头部说明成本口径,规格与成本分行,操作列固定在右侧,超过三个规格可展开查看。
|
||||
- [x] “规格价格”改为“规格成本价”,仅读取管理员接口已有的 `priceTiers[].costPricing`,不再显示用户售价。支持按次、按秒、文本输入/输出/缓存 Token 和视频 Token;未配置成本明确提示,显式零成本正常显示,异常或缺失数值显示 `—`。不修改后端、计费或用户售价。
|
||||
- [x] 本轮 49 项专项与相邻回归测试、前端类型检查通过;新增成本摘要真实组件渲染测试,覆盖成本/售价隔离、零成本、缺失/异常值、Token 单位、规格选择器与展开内容。样式细化仍需在登录后的真实页面检查明暗主题、长名称、多规格行高、横向滚动与固定操作列。
|
||||
- [x] 系统渠道入口改为同页工作区:桌面左侧约 20% 展示渠道搜索、状态筛选、新增和排序,右侧约 80% 展示当前渠道信息与模型管理;移除模型管理的二级页面和返回入口,窄屏改为上下布局。
|
||||
- [x] 保留渠道编辑、复制、启停、删除及模型拉取、选择导入、编辑、排序和批量删除。新增或复制后自动选中渠道;切换渠道时隔离模型列表、筛选、勾选和编辑状态,渠道与模型加载失败显示重试入口。服务端仍使用原分页接口,左栏分批读取全部渠道,避免默认仅展示前 20 个。
|
||||
- [x] 40 项专项及相邻回归测试通过,覆盖渠道筛选与选择回退、更新后选中数据、真实模型管理组件静态渲染、同页结构,以及既有模型导入、批量删除、排序和编辑规则;前端类型检查通过。
|
||||
- [ ] 本地真实 `/admin/channels` 路由跳转登录页,未完成登录后的浏览器验收。需检查明暗主题、桌面 20/80 比例、窄屏上下布局、左右滚动、搜索及切换、新增/编辑弹窗;确认新增/复制后选中、删除当前渠道后的回退、多页渠道完整加载和快速切换不串模型。静态渲染不等同于浏览器或真实写入链路通过。
|
||||
|
||||
## 工作台菜单表面与素材显示密度
|
||||
|
||||
- [x] 根据焦点样式反馈,内容库筛选下拉取消鼠标 / 触屏点击后的粗外圈,聚焦和展开改用柔和底色;工具栏区分指针与键盘输入,键盘聚焦保留 1px 细轮廓,不主动移除焦点。离开工具栏后恢复键盘默认状态,相关复用筛选入口同步生效。本轮 12 项专项测试及类型检查通过;指针 / 键盘切换仍需真实页面验收。
|
||||
|
||||
13
web/src/pages/admin/channels/channel-workspace-state.ts
Normal file
13
web/src/pages/admin/channels/channel-workspace-state.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ModelChannel } from "@/stores/use-config-store";
|
||||
|
||||
export function selectChannelWorkspace(channels: ModelChannel[], keyword: string, status: "all" | "enabled" | "disabled", selectedChannelId: string | null) {
|
||||
const query = keyword.trim().toLowerCase();
|
||||
const visibleChannels = channels.filter((channel) => {
|
||||
const matchesKeyword = `${channel.name} ${channel.baseUrl}`.toLowerCase().includes(query);
|
||||
return matchesKeyword && (status === "all" || (channel.enabled !== false) === (status === "enabled"));
|
||||
});
|
||||
return {
|
||||
visibleChannels,
|
||||
selectedChannel: visibleChannels.find((channel) => channel.id === selectedChannelId) || visibleChannels[0],
|
||||
};
|
||||
}
|
||||
306
web/src/pages/admin/channels/channels-page.css
Normal file
306
web/src/pages/admin/channels/channels-page.css
Normal file
@@ -0,0 +1,306 @@
|
||||
.admin-channel-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 4fr);
|
||||
flex: 1;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.admin-channel-sidebar,
|
||||
.admin-channel-detail {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: var(--admin-radius-lg);
|
||||
background: var(--admin-surface);
|
||||
}
|
||||
|
||||
.admin-channel-sidebar-header {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--admin-divider);
|
||||
}
|
||||
|
||||
.admin-channel-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.admin-channel-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
margin-bottom: 4px;
|
||||
border: 0;
|
||||
border-radius: var(--admin-radius);
|
||||
background: transparent;
|
||||
color: var(--admin-text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-channel-item:hover {
|
||||
background: var(--admin-row-hover);
|
||||
}
|
||||
|
||||
.admin-channel-item[aria-pressed="true"] {
|
||||
background: var(--admin-nav-active);
|
||||
box-shadow: inset 0 0 0 1px var(--admin-border-strong);
|
||||
}
|
||||
|
||||
.admin-channel-item:focus-visible {
|
||||
outline: 2px solid var(--admin-focus);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
.admin-channel-item-name {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-channel-item-topline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-channel-item-arrow {
|
||||
flex: none;
|
||||
color: var(--admin-text-muted);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.admin-channel-item[aria-pressed="true"] .admin-channel-item-arrow {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.admin-channel-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
padding: 2px 7px;
|
||||
border-radius: var(--admin-radius-sm);
|
||||
background: var(--admin-surface-3);
|
||||
color: var(--admin-text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.admin-channel-item-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-channel-item-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.admin-channel-item-status::before {
|
||||
content: "";
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--admin-text-placeholder);
|
||||
}
|
||||
|
||||
.admin-channel-item-status[data-enabled="true"]::before {
|
||||
background: var(--admin-status-success);
|
||||
}
|
||||
|
||||
.admin-channel-sidebar-footer {
|
||||
flex: none;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--admin-divider);
|
||||
}
|
||||
|
||||
.admin-channel-sidebar-footer > button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-channel-detail-header {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--admin-divider);
|
||||
}
|
||||
|
||||
.admin-channel-detail-title {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-channel-detail-url {
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
color: var(--admin-text-muted);
|
||||
font-family: var(--admin-font-mono);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-channel-model-manager {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.admin-channel-model-toolbar {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-channel-model-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-channel-model-hint {
|
||||
margin-top: 4px;
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-channel-model-icon {
|
||||
display: grid;
|
||||
flex: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
place-items: center;
|
||||
border-radius: var(--admin-radius);
|
||||
background: var(--admin-surface-2);
|
||||
}
|
||||
|
||||
.admin-model-cost-summary {
|
||||
padding: 4px 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.admin-model-cost-tier + .admin-model-cost-tier {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--admin-divider);
|
||||
}
|
||||
|
||||
.admin-model-cost-spec {
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 11px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-model-cost-value {
|
||||
color: var(--admin-text);
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.admin-model-cost-unit {
|
||||
display: block;
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.admin-model-cost-missing {
|
||||
color: var(--admin-status-warning);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.admin-model-cost-more {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.admin-model-cost-more > summary {
|
||||
width: fit-content;
|
||||
color: var(--admin-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-model-cost-more > summary:focus-visible {
|
||||
outline: 2px solid var(--admin-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.admin-model-cost-more[open] > summary {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.admin-channel-load-error {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
color: var(--admin-status-error);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-channel-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.admin-channel-workspace {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto minmax(480px, 1fr);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.admin-channel-sidebar {
|
||||
max-height: 360px;
|
||||
}
|
||||
|
||||
.admin-channel-list {
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
.admin-channel-sidebar-header,
|
||||
.admin-channel-detail-header,
|
||||
.admin-channel-model-manager {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,19 @@
|
||||
import { App, Button, Form, Input, InputNumber, Modal, Select, Switch } from "antd";
|
||||
import type { FormInstance } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { Copy, Pencil, Plus, Power, Search, Trash2 } from "lucide-react";
|
||||
import { App, Button, Form, Input, InputNumber, Modal, Select, Spin, Switch } from "antd";
|
||||
import { ChevronRight, Copy, Pencil, Plus, Power, Search, Trash2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
|
||||
import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor";
|
||||
import { PaginationBar } from "@/pages/admin/components/admin-ui";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { refreshSystemChannels } from "@/lib/user-session";
|
||||
import { createAdminChannel, deleteAdminChannel, duplicateAdminChannel, listAdminChannels, updateAdminChannel } from "@/services/api/auth";
|
||||
import { type ChannelHeader, type ModelChannel } from "@/stores/use-config-store";
|
||||
import { useAdminContext } from "../admin-context";
|
||||
import { AdminPageFrame } from "../components/admin-shell";
|
||||
import { AdminDataTable, AdminRowActions, AdminStatusBadge, AdminTableEmpty, configuredSecretText } from "../components/admin-ui";
|
||||
import { AdminRowActions, AdminStatusBadge, AdminTableEmpty, configuredSecretText } from "../components/admin-ui";
|
||||
import { ChannelModelManager } from "../components/channel-model-manager";
|
||||
import { ChannelOrderDialog } from "../components/channel-order-dialog";
|
||||
import { selectChannelWorkspace } from "./channel-workspace-state";
|
||||
import "./channels-page.css";
|
||||
|
||||
type ChannelFormValues = {
|
||||
name: string;
|
||||
@@ -47,43 +45,54 @@ export default function ChannelsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const keyword = searchParams.get("filter") || "";
|
||||
const status = normalizeStatus(searchParams.get("status"));
|
||||
const page = positiveInt(searchParams.get("page"), 1);
|
||||
const pageSize = normalizePageSize(searchParams.get("pageSize"));
|
||||
const debouncedKeyword = useDebouncedValue(keyword);
|
||||
const selectedChannelId = searchParams.get("channel");
|
||||
const [channels, setChannels] = useState<ModelChannel[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [editingChannel, setEditingChannel] = useState<ModelChannel | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [duplicatingChannelId, setDuplicatingChannelId] = useState<string | null>(null);
|
||||
const [managingChannel, setManagingChannel] = useState<ModelChannel | null>(null);
|
||||
const requestSequence = useRef(0);
|
||||
const [form] = Form.useForm<ChannelFormValues>();
|
||||
const useGlobalConcurrency = Form.useWatch("useGlobalConcurrency", form) !== false;
|
||||
const hasFilters = Boolean(keyword || status !== "all");
|
||||
const { visibleChannels, selectedChannel } = selectChannelWorkspace(channels, keyword, status, selectedChannelId);
|
||||
|
||||
const updateUrl = (patch: Record<string, string | number>, replace = false) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
Object.entries(patch).forEach(([key, value]) => {
|
||||
const isDefault = (key === "filter" && value === "") || (key === "status" && value === "all") || (key === "page" && value === 1) || (key === "pageSize" && value === 20);
|
||||
if (isDefault) next.delete(key);
|
||||
else next.set(key, String(value));
|
||||
});
|
||||
setSearchParams(next, { replace });
|
||||
setSearchParams(
|
||||
(current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
next.delete("page");
|
||||
next.delete("pageSize");
|
||||
Object.entries(patch).forEach(([key, value]) => {
|
||||
if (value === "" || (key === "status" && value === "all")) next.delete(key);
|
||||
else next.set(key, String(value));
|
||||
});
|
||||
return next;
|
||||
},
|
||||
{ replace },
|
||||
);
|
||||
};
|
||||
|
||||
const reload = async () => {
|
||||
const sequence = ++requestSequence.current;
|
||||
setLoading(true);
|
||||
setLoadError("");
|
||||
try {
|
||||
const result = await listAdminChannels({ keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status, page, pageSize });
|
||||
// The navigation needs every channel, not just the API's default first 20.
|
||||
const result = await listAdminChannels({ page: 1, pageSize: 100 });
|
||||
const nextChannels = [...result.channels];
|
||||
for (let page = 2; nextChannels.length < result.total; page += 1) {
|
||||
if (sequence !== requestSequence.current) return;
|
||||
const next = await listAdminChannels({ page, pageSize: 100 });
|
||||
if (!next.channels.length) throw new Error("渠道列表发生变化,请重新加载");
|
||||
nextChannels.push(...next.channels);
|
||||
}
|
||||
if (sequence !== requestSequence.current) return;
|
||||
setChannels(result.channels);
|
||||
setTotal(result.total);
|
||||
if (result.total > 0 && result.channels.length === 0 && page > 1) updateUrl({ page: 1 }, true);
|
||||
setChannels(Array.from(new Map(nextChannels.map((channel) => [channel.id, channel])).values()));
|
||||
} catch (error) {
|
||||
if (sequence === requestSequence.current) message.error(error instanceof Error ? error.message : "读取渠道列表失败");
|
||||
if (sequence === requestSequence.current) setLoadError(error instanceof Error ? error.message : "读取渠道列表失败");
|
||||
} finally {
|
||||
if (sequence === requestSequence.current) setLoading(false);
|
||||
}
|
||||
@@ -91,7 +100,10 @@ export default function ChannelsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [debouncedKeyword, status, page, pageSize]);
|
||||
return () => {
|
||||
requestSequence.current += 1;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const syncChannels = async () => {
|
||||
await reloadReferences();
|
||||
@@ -145,7 +157,9 @@ export default function ChannelsPage() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = adminChannelSavePayload(values);
|
||||
await (editingChannel ? updateAdminChannel(editingChannel.id, payload) : createAdminChannel(payload));
|
||||
const { channel } = await (editingChannel ? updateAdminChannel(editingChannel.id, payload) : createAdminChannel(payload));
|
||||
setChannels((current) => (editingChannel ? current.map((item) => (item.id === channel.id ? channel : item)) : [...current, channel]));
|
||||
updateUrl({ channel: channel.id, filter: "", status: "all" }, true);
|
||||
await syncChannels();
|
||||
setDrawerOpen(false);
|
||||
form.resetFields();
|
||||
@@ -172,7 +186,9 @@ export default function ChannelsPage() {
|
||||
const duplicateChannel = async (channel: ModelChannel) => {
|
||||
setDuplicatingChannelId(channel.id);
|
||||
try {
|
||||
await duplicateAdminChannel(channel.id);
|
||||
const { channel: duplicated } = await duplicateAdminChannel(channel.id);
|
||||
setChannels((current) => [...current, duplicated]);
|
||||
updateUrl({ channel: duplicated.id, filter: "", status: "all" }, true);
|
||||
await syncChannels();
|
||||
await reload();
|
||||
message.success("系统渠道已复制");
|
||||
@@ -194,145 +210,147 @@ export default function ChannelsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ModelChannel> = [
|
||||
{
|
||||
title: "渠道",
|
||||
dataIndex: "name",
|
||||
render: (_, channel) => (
|
||||
<div>
|
||||
<div className="font-medium">{channel.name}</div>
|
||||
<div className="admin-monospace max-w-lg truncate text-foreground/45">{channel.baseUrl}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: "模型", dataIndex: "models", width: 100, align: "center", render: (models: string[]) => `${models?.length || 0} 个` },
|
||||
{ title: "最大并发", dataIndex: "concurrencyLimit", width: 120, align: "center", render: (value: number) => (value > 0 ? value : <span className="text-foreground/45">跟随系统</span>) },
|
||||
{ title: "凭证", width: 130, align: "center", render: (_, channel) => <AdminStatusBadge label={channel.hasApiKey ? (channel.hasSecretKey ? "AK/SK 已配置" : "API Key 已配置") : "未配置"} tone={channel.hasApiKey ? "success" : "neutral"} /> },
|
||||
{ title: "状态", dataIndex: "enabled", width: 100, align: "center", render: (enabled) => <AdminStatusBadge label={enabled !== false ? "已启用" : "已停用"} tone={enabled !== false ? "success" : "neutral"} /> },
|
||||
{
|
||||
title: "操作",
|
||||
width: 250,
|
||||
align: "center",
|
||||
render: (_, channel) => (
|
||||
<AdminRowActions
|
||||
primary={{ label: "模型管理", onClick: () => setManagingChannel(channel) }}
|
||||
actions={[
|
||||
{ key: "edit", label: "编辑", icon: <Pencil className="size-3.5" />, onClick: () => openDrawer(channel) },
|
||||
{ key: "duplicate", label: "复制渠道", icon: <Copy className="size-3.5" />, disabled: Boolean(duplicatingChannelId), onClick: () => duplicateChannel(channel) },
|
||||
{
|
||||
key: "toggle",
|
||||
label: channel.enabled !== false ? "停用渠道" : "启用渠道",
|
||||
icon: <Power className="size-3.5" />,
|
||||
danger: channel.enabled !== false,
|
||||
confirm: {
|
||||
title: channel.enabled !== false ? "停用这个系统渠道?" : "启用这个系统渠道?",
|
||||
description: channel.enabled !== false ? "停用后新任务不会再使用该渠道,但仍会保留在列表中,可随时重新启用。" : "启用后,配置完整的模型会重新进入系统可用模型集合。",
|
||||
okText: channel.enabled !== false ? "确认停用" : "确认启用",
|
||||
},
|
||||
onClick: () => toggleChannel(channel),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除渠道",
|
||||
icon: <Trash2 className="size-3.5" />,
|
||||
danger: true,
|
||||
confirm: { title: "删除这个系统渠道?", description: "删除后渠道及所属模型将不再显示,API Key 会被清除,历史账单和调用记录继续保留。该操作不能在页面恢复。", okText: "确认删除" },
|
||||
onClick: () => removeChannel(channel),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (managingChannel) {
|
||||
return (
|
||||
<ChannelModelManager
|
||||
channel={managingChannel}
|
||||
onClose={() => setManagingChannel(null)}
|
||||
onChanged={async () => {
|
||||
await syncChannels();
|
||||
await reload();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const channelActions = (channel: ModelChannel) => (
|
||||
<AdminRowActions
|
||||
primary={{ label: "编辑渠道", icon: <Pencil className="size-3.5" />, onClick: () => openDrawer(channel) }}
|
||||
actions={[
|
||||
{ key: "duplicate", label: "复制渠道", icon: <Copy className="size-3.5" />, disabled: Boolean(duplicatingChannelId), onClick: () => duplicateChannel(channel) },
|
||||
{
|
||||
key: "toggle",
|
||||
label: channel.enabled !== false ? "停用渠道" : "启用渠道",
|
||||
icon: <Power className="size-3.5" />,
|
||||
danger: channel.enabled !== false,
|
||||
confirm: {
|
||||
title: channel.enabled !== false ? "停用这个系统渠道?" : "启用这个系统渠道?",
|
||||
description: channel.enabled !== false ? "停用后新任务不会再使用该渠道,但仍会保留在列表中,可随时重新启用。" : "启用后,配置完整的模型会重新进入系统可用模型集合。",
|
||||
okText: channel.enabled !== false ? "确认停用" : "确认启用",
|
||||
},
|
||||
onClick: () => toggleChannel(channel),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除渠道",
|
||||
icon: <Trash2 className="size-3.5" />,
|
||||
danger: true,
|
||||
confirm: { title: "删除这个系统渠道?", description: "删除后渠道及所属模型将不再显示,API Key 会被清除,历史账单和调用记录继续保留。该操作不能在页面恢复。", okText: "确认删除" },
|
||||
onClick: () => removeChannel(channel),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminPageFrame
|
||||
title="系统渠道"
|
||||
description="列表顺序就是用户端展示顺序,点击“设置排序”即可调整。"
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<ChannelOrderDialog
|
||||
onSaved={async () => {
|
||||
await syncChannels();
|
||||
await reload();
|
||||
}}
|
||||
/>
|
||||
<Button type="primary" icon={<Plus className="size-4" />} onClick={() => openDrawer()}>
|
||||
新增系统渠道
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AdminDataTable
|
||||
toolbar={
|
||||
<Input
|
||||
id="admin-channel-search"
|
||||
aria-label="搜索系统渠道"
|
||||
autoComplete="off"
|
||||
allowClear
|
||||
className="app-list-search"
|
||||
prefix={<Search className="size-4 text-foreground/40" />}
|
||||
value={keyword}
|
||||
placeholder="搜索渠道名称、别名或地址"
|
||||
onChange={(event) => updateUrl({ filter: event.target.value, page: 1 }, true)}
|
||||
/>
|
||||
}
|
||||
toolbarFilters={
|
||||
<Select
|
||||
aria-label="筛选渠道状态"
|
||||
className="w-32"
|
||||
value={status}
|
||||
onChange={(value) => updateUrl({ status: value, page: 1 })}
|
||||
options={[
|
||||
{ label: "全部状态", value: "all" },
|
||||
{ label: "已启用", value: "enabled" },
|
||||
{ label: "已停用", value: "disabled" },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
toolbarActive={hasFilters}
|
||||
onReset={() => updateUrl({ filter: "", status: "all", page: 1 })}
|
||||
skeletonColumns={7}
|
||||
table={{
|
||||
className: "app-data-table",
|
||||
size: "small",
|
||||
rowKey: "id",
|
||||
loading,
|
||||
columns,
|
||||
dataSource: channels,
|
||||
locale: {
|
||||
emptyText: (
|
||||
<AdminTableEmpty
|
||||
filtered={hasFilters}
|
||||
title={hasFilters ? undefined : "还没有系统渠道"}
|
||||
action={
|
||||
hasFilters ? undefined : (
|
||||
<Button type="primary" icon={<Plus className="size-4" />} onClick={() => openDrawer()}>
|
||||
新增系统渠道
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<AdminPageFrame title="系统模型" description="左侧选择渠道,右侧直接管理模型、协议与售价。">
|
||||
<div className="admin-channel-workspace">
|
||||
<aside className="admin-channel-sidebar" aria-label="系统渠道">
|
||||
<div className="admin-channel-sidebar-header">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="font-semibold">渠道</h2>
|
||||
<span className="admin-channel-count">{hasFilters ? `${visibleChannels.length} / ${channels.length}` : channels.length}</span>
|
||||
</div>
|
||||
<Button block type="primary" icon={<Plus className="size-4" />} onClick={() => openDrawer()}>
|
||||
新增渠道
|
||||
</Button>
|
||||
<Input
|
||||
id="admin-channel-search"
|
||||
aria-label="搜索系统渠道"
|
||||
autoComplete="off"
|
||||
allowClear
|
||||
prefix={<Search className="size-4 text-foreground/40" />}
|
||||
value={keyword}
|
||||
placeholder="搜索渠道名称或地址"
|
||||
onChange={(event) => updateUrl({ filter: event.target.value }, true)}
|
||||
/>
|
||||
<Select
|
||||
aria-label="筛选渠道状态"
|
||||
value={status}
|
||||
onChange={(value) => updateUrl({ status: value })}
|
||||
options={[
|
||||
{ label: "全部状态", value: "all" },
|
||||
{ label: "已启用", value: "enabled" },
|
||||
{ label: "已停用", value: "disabled" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-channel-list" aria-busy={loading}>
|
||||
{loadError ? (
|
||||
<div className="admin-channel-load-error" role="alert">
|
||||
<p>{loadError}</p>
|
||||
<Button size="small" onClick={() => void reload()}>
|
||||
重新加载渠道
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{loading ? (
|
||||
<div className="admin-channel-loading" role="status" aria-label="正在加载渠道">
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
) : null}
|
||||
{visibleChannels.map((channel) => (
|
||||
<button type="button" key={channel.id} className="admin-channel-item" aria-pressed={selectedChannel?.id === channel.id} aria-controls="admin-channel-models" onClick={() => updateUrl({ channel: channel.id })}>
|
||||
<span className="admin-channel-item-topline">
|
||||
<span className="admin-channel-item-name" title={channel.name}>
|
||||
{channel.name}
|
||||
</span>
|
||||
<ChevronRight className="admin-channel-item-arrow size-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="admin-channel-item-meta">
|
||||
<span>{channel.models?.length || 0} 个模型</span>
|
||||
<span className="admin-channel-item-status" data-enabled={channel.enabled !== false}>
|
||||
{channel.enabled !== false ? "已启用" : "已停用"}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{!loading && !loadError && !visibleChannels.length ? (
|
||||
<AdminTableEmpty filtered={hasFilters} title={hasFilters ? undefined : "还没有系统渠道"} action={hasFilters ? <Button onClick={() => updateUrl({ filter: "", status: "all" })}>清除筛选</Button> : undefined} />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="admin-channel-sidebar-footer">
|
||||
<ChannelOrderDialog
|
||||
onSaved={async () => {
|
||||
await syncChannels();
|
||||
await reload();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
<section id="admin-channel-models" className="admin-channel-detail" aria-label={selectedChannel ? `${selectedChannel.name}的模型管理` : "渠道模型管理"}>
|
||||
{selectedChannel ? (
|
||||
<>
|
||||
<header className="admin-channel-detail-header">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="admin-channel-detail-title">{selectedChannel.name}</h2>
|
||||
<AdminStatusBadge label={selectedChannel.enabled !== false ? "已启用" : "已停用"} tone={selectedChannel.enabled !== false ? "success" : "neutral"} />
|
||||
</div>
|
||||
<p className="admin-channel-detail-url" title={selectedChannel.baseUrl}>
|
||||
{selectedChannel.baseUrl}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-foreground/50">
|
||||
<span>{selectedChannel.hasApiKey ? (selectedChannel.hasSecretKey ? "AK/SK 已配置" : "API Key 已配置") : "凭证未配置"}</span>
|
||||
<span>最大并发:{selectedChannel.concurrencyLimit || "跟随系统"}</span>
|
||||
</div>
|
||||
</div>
|
||||
{channelActions(selectedChannel)}
|
||||
</header>
|
||||
<ChannelModelManager
|
||||
key={selectedChannel.id}
|
||||
channel={selectedChannel}
|
||||
onChanged={async () => {
|
||||
await syncChannels();
|
||||
await reload();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
pagination: false,
|
||||
scroll: { x: 1150 },
|
||||
}}
|
||||
footer={<PaginationBar alwaysShow current={page} pageSize={pageSize} total={total} onChange={(nextPage, nextSize) => updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize })} />}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<AdminTableEmpty
|
||||
title={loading ? "正在加载渠道" : loadError ? "暂时无法读取渠道" : hasFilters ? "没有匹配的渠道" : "添加渠道,开始管理模型"}
|
||||
description={loading ? "加载完成后将在此显示渠道模型。" : loadError ? "请在左侧重试加载。" : hasFilters ? "调整左侧搜索或状态筛选。" : "在左侧新增渠道,配置连接信息后即可拉取或新增模型。"}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<Modal
|
||||
className="workspace-modal workspace-modal-wide admin-channel-modal"
|
||||
rootClassName="admin-channel-modal-root"
|
||||
@@ -401,14 +419,6 @@ export default function ChannelsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function positiveInt(value: string | null, fallback: number) {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
function normalizePageSize(value: string | null) {
|
||||
const parsed = positiveInt(value, 20);
|
||||
return [20, 50, 100].includes(parsed) ? parsed : 20;
|
||||
}
|
||||
function normalizeStatus(value: string | null): "all" | "enabled" | "disabled" {
|
||||
return value === "enabled" || value === "disabled" ? value : "all";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ChannelModel, ChannelModelPriceTier } from "@/services/api/wallet";
|
||||
import { AdminStatusBadge } from "./admin-ui";
|
||||
|
||||
export function ChannelModelCostSummary({ item }: { item: ChannelModel }) {
|
||||
// Cost configuration is independent of the user-facing sale price.
|
||||
const tiers = item.priceTiers?.filter((tier) => tier.enabled) || [];
|
||||
if (!tiers.length) return <AdminStatusBadge label="未配置成本" tone="warning" />;
|
||||
const renderTier = (tier: ChannelModelPriceTier) => (
|
||||
<div className="admin-model-cost-tier" key={tier.id}>
|
||||
<div className="admin-model-cost-spec">{specificationLabel(tier)}</div>
|
||||
<div className="admin-model-cost-value">{costLabel(tier, item.capability)}</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="admin-model-cost-summary">
|
||||
{tiers.slice(0, 3).map(renderTier)}
|
||||
{tiers.length > 3 ? (
|
||||
<details className="admin-model-cost-more">
|
||||
<summary>其余 {tiers.length - 3} 个规格成本</summary>
|
||||
{tiers.slice(3).map(renderTier)}
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function costLabel(tier: ChannelModelPriceTier, capability: ChannelModel["capability"]) {
|
||||
const cost = tier.costPricing;
|
||||
if (!cost?.configured) return <span className="admin-model-cost-missing">未配置成本</span>;
|
||||
const format = (value: number) => (Number.isFinite(value) && value >= 0 ? (value / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 }) : "—");
|
||||
if (tier.billingMode === "token") {
|
||||
if (capability === "video") return `${format(cost.outputTokenPriceMicrocredits)} 积分 / 百万视频 Token`;
|
||||
return (
|
||||
<>
|
||||
<span>
|
||||
输入 {format(cost.inputTokenPriceMicrocredits)} · 输出 {format(cost.outputTokenPriceMicrocredits)} · 缓存 {format(cost.cachedTokenPriceMicrocredits)}
|
||||
</span>
|
||||
<span className="admin-model-cost-unit">积分 / 百万 Token</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return `${format(cost.unitPriceMicrocredits)} 积分 / ${tier.billingMode === "per_second" ? "秒" : "次"}`;
|
||||
}
|
||||
|
||||
function specificationLabel(tier: ChannelModelPriceTier) {
|
||||
const selector = tier.selector || {};
|
||||
const specific = (value?: string) => (value && value !== "*" ? value : "");
|
||||
const operation = specific(selector.operation);
|
||||
const resolution = specific(selector.vquality) || specific(tier.resolution);
|
||||
const duration = specific(selector.videoSeconds) || (tier.videoSeconds ? String(tier.videoSeconds) : "");
|
||||
const operations: Record<string, string> = { text_to_image: "文生图", image_to_image: "图生图", text_to_video: "文生视频", image_to_video: "图生视频", video_to_video: "视频生视频", text_generation: "文本生成" };
|
||||
return (
|
||||
[
|
||||
operation ? operations[operation] || operation : "",
|
||||
specific(selector.quality).toUpperCase(),
|
||||
specific(selector.size),
|
||||
resolution.toUpperCase(),
|
||||
duration ? `${duration} 秒` : "",
|
||||
specific(selector.imageCount) ? `${selector.imageCount} 张参考图` : "",
|
||||
selector.videoGenerateAudio === "true" ? "有声" : selector.videoGenerateAudio === "false" ? "无声" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" / ") || "默认规格"
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,27 @@
|
||||
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 { Pencil, 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 { deleteAdminChannelModel, deleteAdminChannelModels, fetchAdminChannelModels, importAdminChannelModels, listAdminChannelModels, type ChannelModel } 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 { AdminBatchBar, AdminDataTable, AdminFilterChip, AdminStatusBadge, AdminTableEmpty } from "./admin-ui";
|
||||
import { ChannelOrderDialog } from "./channel-order-dialog";
|
||||
import { ChannelModelCostSummary } from "./channel-model-cost-summary";
|
||||
|
||||
export function ChannelModelManager({ channel, onClose, onChanged }: { channel: ModelChannel; onClose: () => void; onChanged: () => void | Promise<void> }) {
|
||||
export function ChannelModelManager({ channel, onChanged }: { channel: ModelChannel; onChanged: () => void | Promise<void> }) {
|
||||
const { message, modal } = App.useApp();
|
||||
const [items, setItems] = useState<ChannelModel[]>([]);
|
||||
const [selectedModelIds, setSelectedModelIds] = useState<string[]>([]);
|
||||
const [deletingSelected, setDeletingSelected] = useState(false);
|
||||
const [editing, setEditing] = useState<ChannelModel | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [fetchPreviewOpen, setFetchPreviewOpen] = useState(false);
|
||||
const [fetchPreviewModels, setFetchPreviewModels] = useState<string[]>([]);
|
||||
@@ -50,13 +51,14 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
const reload = async () => {
|
||||
if (!channel) return;
|
||||
setLoading(true);
|
||||
setLoadError("");
|
||||
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 : "读取渠道模型失败");
|
||||
setLoadError(error instanceof Error ? error.message : "读取渠道模型失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -175,13 +177,16 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
const columns: ColumnsType<ChannelModel> = [
|
||||
{
|
||||
title: "模型",
|
||||
width: 240,
|
||||
render: (_, item) => (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md border border-border/70 bg-muted/35">
|
||||
<span className="admin-channel-model-icon">
|
||||
<ModelIcon model={item.modelKey} icon={item.icon} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{item.displayName || item.modelKey}</div>
|
||||
<div className="truncate font-medium" title={item.displayName || item.modelKey}>
|
||||
{item.displayName || item.modelKey}
|
||||
</div>
|
||||
<div className="admin-monospace truncate text-xs text-foreground/45">{item.modelKey}</div>
|
||||
{item.channelLabel ? <div className="truncate text-xs text-foreground/60">渠道展示名:{item.channelLabel}</div> : null}
|
||||
{item.providerModelKey && item.providerModelKey !== item.modelKey ? <div className="admin-monospace truncate text-xs text-foreground/35">上游:{item.providerModelKey}</div> : null}
|
||||
@@ -193,7 +198,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
{
|
||||
title: "请求协议",
|
||||
dataIndex: "protocol",
|
||||
width: 230,
|
||||
width: 190,
|
||||
render: (value: ModelProtocol) =>
|
||||
value ? (
|
||||
<div>
|
||||
@@ -204,15 +209,16 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
<AdminStatusBadge label="待配置" tone="warning" />
|
||||
),
|
||||
},
|
||||
{ title: "规格价格", width: 280, render: (_, item) => (item.priceConfigured ? billingSummary(item) : <AdminStatusBadge label="未配置价格" tone="warning" />) },
|
||||
{ title: "规格成本价", width: 290, render: (_, item) => <ChannelModelCostSummary item={item} /> },
|
||||
{ title: "版本", dataIndex: "priceVersion", width: 75, render: (value) => `v${value}` },
|
||||
{ title: "状态", dataIndex: "enabled", width: 85, render: (enabled) => <AdminStatusBadge label={enabled ? "启用" : "停用"} tone={enabled ? "success" : "neutral"} /> },
|
||||
{
|
||||
title: "操作",
|
||||
width: 180,
|
||||
width: 120,
|
||||
fixed: "right",
|
||||
render: (_, item) => (
|
||||
<Space>
|
||||
<Button size="small" disabled={deletingSelected} onClick={() => startEdit(item)}>
|
||||
<Button size="small" icon={<Pencil className="size-3.5" />} disabled={deletingSelected} onClick={() => startEdit(item)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="删除模型" description="已被前台供应线路或进行中任务使用的模型不能删除;删除后模型不再显示,且不能在页面恢复。" okText="删除" cancelText="取消" onConfirm={() => void remove(item)}>
|
||||
@@ -252,11 +258,14 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
});
|
||||
|
||||
return (
|
||||
<AdminPageFrame
|
||||
title={`${channel.name} / 模型管理`}
|
||||
description="模型按用户端展示顺序排列,点击“设置排序”即可调整。"
|
||||
back={{ label: "返回系统渠道", onClick: onClose }}
|
||||
actions={
|
||||
<div className="admin-channel-model-manager">
|
||||
<div className="admin-channel-model-toolbar">
|
||||
<div>
|
||||
<h3 className="admin-channel-model-heading">
|
||||
模型管理 <span className="admin-channel-count">{items.length}</span>
|
||||
</h3>
|
||||
<p className="admin-channel-model-hint">规格展示上游成本,不影响用户售价</p>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<ChannelOrderDialog
|
||||
channelId={channel.id}
|
||||
@@ -272,9 +281,22 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
新增模型
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
</div>
|
||||
{loadError ? (
|
||||
<div className="admin-channel-load-error" role="alert">
|
||||
<span>{loadError}</span>
|
||||
<Button size="small" onClick={() => void reload()}>
|
||||
重新加载模型
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<AdminDataTable
|
||||
empty={
|
||||
<AdminTableEmpty
|
||||
title={loadError ? "模型列表加载失败" : items.length ? "没有匹配的模型" : "这个渠道还没有模型"}
|
||||
description={loadError ? "请重试加载,暂时无法确认模型列表。" : items.length ? "调整搜索或筛选条件后重试。" : "点击上方“拉取模型”从上游导入,或手动新增模型。"}
|
||||
/>
|
||||
}
|
||||
toolbar={
|
||||
<Input
|
||||
allowClear
|
||||
@@ -382,7 +404,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
columns,
|
||||
dataSource: pagedItems,
|
||||
pagination: false,
|
||||
scroll: { x: 1150 },
|
||||
scroll: { x: 1090 },
|
||||
}}
|
||||
footer={
|
||||
<PaginationBar
|
||||
@@ -460,7 +482,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AdminPageFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -468,43 +490,6 @@ 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 <AdminStatusBadge label="未配置价格" tone="warning" />;
|
||||
return (
|
||||
<div className="space-y-1 text-xs leading-5">
|
||||
{tiers.slice(0, 3).map((tier) => (
|
||||
<div key={tier.id}>{priceTierLabel(tier, item.capability)}</div>
|
||||
))}
|
||||
{tiers.length > 3 ? <div className="text-foreground/45">另有 {tiers.length - 3} 个规格价格档</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, string>)[operation] || operation;
|
||||
}
|
||||
|
||||
function formatCredits(value: number) {
|
||||
return (value / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 });
|
||||
}
|
||||
|
||||
function normalizeFetchModelKey(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
|
||||
69
web/test/admin-channel-workspace.test.tsx
Normal file
69
web/test/admin-channel-workspace.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { App } from "antd";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { selectChannelWorkspace } from "../src/pages/admin/channels/channel-workspace-state";
|
||||
import { ChannelModelManager } from "../src/pages/admin/components/channel-model-manager";
|
||||
import type { ModelChannel } from "../src/stores/use-config-store";
|
||||
|
||||
const channels: ModelChannel[] = [
|
||||
{ id: "first", name: "主渠道", baseUrl: "https://first.example/v1", apiKey: "", apiFormat: "openai", models: ["model-a"], enabled: true },
|
||||
{ id: "second", name: "备用渠道", baseUrl: "https://second.example/v1", apiKey: "", apiFormat: "openai", models: [], enabled: false },
|
||||
];
|
||||
|
||||
test("channel workspace defaults to the first channel and restores a selected channel", () => {
|
||||
expect(selectChannelWorkspace(channels, "", "all", null).selectedChannel).toBe(channels[0]);
|
||||
expect(selectChannelWorkspace(channels, "", "all", "second").selectedChannel).toBe(channels[1]);
|
||||
});
|
||||
|
||||
test("search and status keep selection inside the visible channel list", () => {
|
||||
const byAddress = selectChannelWorkspace(channels, " SECOND.EXAMPLE ", "all", "first");
|
||||
expect(byAddress.visibleChannels).toEqual([channels[1]!]);
|
||||
expect(byAddress.selectedChannel).toBe(channels[1]);
|
||||
expect(selectChannelWorkspace(channels, "主渠道", "all", "second").selectedChannel).toBe(channels[0]);
|
||||
expect(selectChannelWorkspace(channels, "", "enabled", "second").selectedChannel).toBe(channels[0]);
|
||||
expect(selectChannelWorkspace(channels, "", "disabled", "first").selectedChannel).toBe(channels[1]);
|
||||
});
|
||||
|
||||
test("deleted, missing and filtered-out channels do not leave a stale model pane", () => {
|
||||
expect(selectChannelWorkspace(channels.slice(1), "", "all", "first").selectedChannel).toBe(channels[1]);
|
||||
expect(selectChannelWorkspace(channels, "", "all", "missing").selectedChannel).toBe(channels[0]);
|
||||
expect(selectChannelWorkspace([], "", "all", "first").selectedChannel).toBeUndefined();
|
||||
expect(selectChannelWorkspace(channels, "not-found", "all", "first").selectedChannel).toBeUndefined();
|
||||
});
|
||||
|
||||
test("selection uses refreshed channel data and can select a newly created channel", () => {
|
||||
const updated = { ...channels[0]!, name: "更新后的渠道", models: ["model-a", "model-b"] };
|
||||
expect(selectChannelWorkspace([updated], "", "all", "first").selectedChannel).toBe(updated);
|
||||
const created = { ...channels[0]!, id: "created" };
|
||||
expect(selectChannelWorkspace([...channels, created], "", "all", "created").selectedChannel).toBe(created);
|
||||
});
|
||||
|
||||
test("real model manager renders inline without a second page or back navigation", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<App>
|
||||
<ChannelModelManager channel={channels[0]!} onChanged={() => {}} />
|
||||
</App>,
|
||||
);
|
||||
expect(html).toContain("模型管理");
|
||||
expect(html).toContain("拉取模型");
|
||||
expect(html).toContain("新增模型");
|
||||
expect(html).toContain("正在加载表格");
|
||||
expect(html).not.toContain("admin-page-root");
|
||||
expect(html).not.toContain("返回系统渠道");
|
||||
});
|
||||
|
||||
test("channel navigation is persistent, keyed and responsive with all server pages loaded", async () => {
|
||||
const page = await Bun.file(new URL("../src/pages/admin/channels/channels-page.tsx", import.meta.url)).text();
|
||||
const css = await Bun.file(new URL("../src/pages/admin/channels/channels-page.css", import.meta.url)).text();
|
||||
expect(page).toContain('aria-label="系统渠道"');
|
||||
expect(page).toContain("新增渠道");
|
||||
expect(page).toContain("key={selectedChannel.id}");
|
||||
expect(page).toContain("aria-pressed={selectedChannel?.id === channel.id}");
|
||||
expect(page).toContain("listAdminChannels({ page: 1, pageSize: 100 })");
|
||||
expect(page).toContain("listAdminChannels({ page, pageSize: 100 })");
|
||||
expect(page).not.toContain("setManagingChannel");
|
||||
expect(css).toContain("grid-template-columns: minmax(0, 1fr) minmax(0, 4fr)");
|
||||
expect(css).toContain("@media (max-width: 900px)");
|
||||
expect(css).toContain(".admin-channel-item:focus-visible");
|
||||
});
|
||||
109
web/test/channel-model-cost-summary.test.tsx
Normal file
109
web/test/channel-model-cost-summary.test.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { ChannelModelCostSummary } from "../src/pages/admin/components/channel-model-cost-summary";
|
||||
import type { ChannelModel, ChannelModelPriceTier } from "../src/services/api/wallet";
|
||||
|
||||
const tier: ChannelModelPriceTier = {
|
||||
id: "tier",
|
||||
channelModelId: "model",
|
||||
selector: {},
|
||||
selectorKey: "{}",
|
||||
resolution: "*",
|
||||
videoSeconds: 0,
|
||||
providerModelKey: "",
|
||||
billingMode: "fixed_request",
|
||||
unitPriceMicrocredits: 99000000,
|
||||
inputTokenPriceMicrocredits: 88000000,
|
||||
outputTokenPriceMicrocredits: 77000000,
|
||||
cachedTokenPriceMicrocredits: 66000000,
|
||||
priceConfigured: true,
|
||||
enabled: true,
|
||||
priceVersion: 1,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
costPricing: { configured: true, unitPriceMicrocredits: 1250000, inputTokenPriceMicrocredits: 2000000, outputTokenPriceMicrocredits: 3000000, cachedTokenPriceMicrocredits: 500000 },
|
||||
};
|
||||
const item: ChannelModel = {
|
||||
id: "model",
|
||||
channelId: "channel",
|
||||
modelKey: "model",
|
||||
displayName: "model",
|
||||
providerModelKey: "",
|
||||
icon: "",
|
||||
capability: "image",
|
||||
billingMode: "fixed_request",
|
||||
unitPriceMicrocredits: 99000000,
|
||||
inputTokenPriceMicrocredits: 88000000,
|
||||
outputTokenPriceMicrocredits: 77000000,
|
||||
cachedTokenPriceMicrocredits: 66000000,
|
||||
priceConfigured: true,
|
||||
enabled: true,
|
||||
priceVersion: 1,
|
||||
priceTiers: [tier],
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
const render = (tiers: ChannelModelPriceTier[], capability: ChannelModel["capability"] = "image") => renderToStaticMarkup(<ChannelModelCostSummary item={{ ...item, capability, priceTiers: tiers }} />);
|
||||
|
||||
test("fixed and per-second costs use costPricing, not the sale price", () => {
|
||||
const fixed = render([tier]);
|
||||
expect(fixed).toContain("默认规格");
|
||||
expect(fixed).toContain("1.25 积分 / 次");
|
||||
expect(fixed).not.toContain("99 积分");
|
||||
expect(render([{ ...tier, billingMode: "per_second" }])).toContain("1.25 积分 / 秒");
|
||||
});
|
||||
|
||||
test("text token costs include input, output and cached tokens", () => {
|
||||
const html = render([{ ...tier, billingMode: "token" }], "text");
|
||||
expect(html).toContain("输入 2 · 输出 3 · 缓存 0.5");
|
||||
expect(html).toContain("积分 / 百万 Token");
|
||||
expect(html).not.toContain("77");
|
||||
});
|
||||
|
||||
test("video token costs use only output video tokens", () => {
|
||||
const html = render([{ ...tier, billingMode: "token" }], "video");
|
||||
expect(html).toContain("3 积分 / 百万视频 Token");
|
||||
expect(html).not.toContain("输入");
|
||||
});
|
||||
|
||||
test("missing and unconfigured costs never fall back to sale prices or zero", () => {
|
||||
for (const costPricing of [undefined, { ...tier.costPricing!, configured: false }]) {
|
||||
const html = render([{ ...tier, costPricing }]);
|
||||
expect(html).toContain("未配置成本");
|
||||
expect(html).not.toContain("积分 /");
|
||||
}
|
||||
});
|
||||
|
||||
test("explicit free costs remain zero even if sale pricing is unconfigured", () => {
|
||||
const html = renderToStaticMarkup(<ChannelModelCostSummary item={{ ...item, priceConfigured: false, priceTiers: [{ ...tier, priceConfigured: false, costPricing: { ...tier.costPricing!, unitPriceMicrocredits: 0 } }] }} />);
|
||||
expect(html).toContain("0 积分 / 次");
|
||||
expect(html).not.toContain("未配置成本");
|
||||
});
|
||||
|
||||
test("invalid or missing numeric costs are not silently displayed as free", () => {
|
||||
for (const unitPriceMicrocredits of [NaN, Infinity, -1, undefined]) {
|
||||
const html = render([{ ...tier, costPricing: { ...tier.costPricing!, unitPriceMicrocredits: unitPriceMicrocredits as number } }]);
|
||||
expect(html).toContain("— 积分 / 次");
|
||||
expect(html).not.toContain("0 积分");
|
||||
}
|
||||
});
|
||||
|
||||
test("specification labels prefer canonical selectors and additional tiers remain expandable", () => {
|
||||
const html = render(Array.from({ length: 4 }, (_, i) => ({ ...tier, id: String(i), selector: { operation: "image_to_video", vquality: "1080p", videoSeconds: "10" }, resolution: "720p", videoSeconds: 5 })));
|
||||
expect(html).toContain("图生视频 / 1080P / 10 秒");
|
||||
expect(html).not.toContain("720P");
|
||||
expect(html).toContain("<details");
|
||||
expect(html).toContain("其余 1 个规格成本");
|
||||
});
|
||||
|
||||
test("disabled tiers are excluded and empty costs are explicit", () => {
|
||||
expect(render([{ ...tier, enabled: false }])).toContain("未配置成本");
|
||||
expect(render([])).toContain("未配置成本");
|
||||
});
|
||||
|
||||
test("model list uses the cost component without gating it on user prices", async () => {
|
||||
const source = await Bun.file(new URL("../src/pages/admin/components/channel-model-manager.tsx", import.meta.url)).text();
|
||||
expect(source).toContain('title: "规格成本价"');
|
||||
expect(source).toContain("<ChannelModelCostSummary item={item} />");
|
||||
expect(source).not.toContain("billingSummary(item)");
|
||||
});
|
||||
Reference in New Issue
Block a user