diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index fc481b5d7..fcc8eef12 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -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 项专项测试及类型检查通过;指针 / 键盘切换仍需真实页面验收。 diff --git a/web/src/pages/admin/channels/channel-workspace-state.ts b/web/src/pages/admin/channels/channel-workspace-state.ts new file mode 100644 index 000000000..c229fd3cc --- /dev/null +++ b/web/src/pages/admin/channels/channel-workspace-state.ts @@ -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], + }; +} diff --git a/web/src/pages/admin/channels/channels-page.css b/web/src/pages/admin/channels/channels-page.css new file mode 100644 index 000000000..d59e83db1 --- /dev/null +++ b/web/src/pages/admin/channels/channels-page.css @@ -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; + } +} diff --git a/web/src/pages/admin/channels/channels-page.tsx b/web/src/pages/admin/channels/channels-page.tsx index e7cae1bcb..e6a41cdc4 100644 --- a/web/src/pages/admin/channels/channels-page.tsx +++ b/web/src/pages/admin/channels/channels-page.tsx @@ -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([]); - const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(""); const [drawerOpen, setDrawerOpen] = useState(false); const [editingChannel, setEditingChannel] = useState(null); const [saving, setSaving] = useState(false); const [duplicatingChannelId, setDuplicatingChannelId] = useState(null); - const [managingChannel, setManagingChannel] = useState(null); const requestSequence = useRef(0); const [form] = Form.useForm(); 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, 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 = [ - { - title: "渠道", - dataIndex: "name", - render: (_, channel) => ( -
-
{channel.name}
-
{channel.baseUrl}
-
- ), - }, - { 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 : 跟随系统) }, - { title: "凭证", width: 130, align: "center", render: (_, channel) => }, - { title: "状态", dataIndex: "enabled", width: 100, align: "center", render: (enabled) => }, - { - title: "操作", - width: 250, - align: "center", - render: (_, channel) => ( - setManagingChannel(channel) }} - actions={[ - { key: "edit", label: "编辑", icon: , onClick: () => openDrawer(channel) }, - { key: "duplicate", label: "复制渠道", icon: , disabled: Boolean(duplicatingChannelId), onClick: () => duplicateChannel(channel) }, - { - key: "toggle", - label: channel.enabled !== false ? "停用渠道" : "启用渠道", - icon: , - danger: channel.enabled !== false, - confirm: { - title: channel.enabled !== false ? "停用这个系统渠道?" : "启用这个系统渠道?", - description: channel.enabled !== false ? "停用后新任务不会再使用该渠道,但仍会保留在列表中,可随时重新启用。" : "启用后,配置完整的模型会重新进入系统可用模型集合。", - okText: channel.enabled !== false ? "确认停用" : "确认启用", - }, - onClick: () => toggleChannel(channel), - }, - { - key: "delete", - label: "删除渠道", - icon: , - danger: true, - confirm: { title: "删除这个系统渠道?", description: "删除后渠道及所属模型将不再显示,API Key 会被清除,历史账单和调用记录继续保留。该操作不能在页面恢复。", okText: "确认删除" }, - onClick: () => removeChannel(channel), - }, - ]} - /> - ), - }, - ]; - - if (managingChannel) { - return ( - setManagingChannel(null)} - onChanged={async () => { - await syncChannels(); - await reload(); - }} - /> - ); - } + const channelActions = (channel: ModelChannel) => ( + , onClick: () => openDrawer(channel) }} + actions={[ + { key: "duplicate", label: "复制渠道", icon: , disabled: Boolean(duplicatingChannelId), onClick: () => duplicateChannel(channel) }, + { + key: "toggle", + label: channel.enabled !== false ? "停用渠道" : "启用渠道", + icon: , + danger: channel.enabled !== false, + confirm: { + title: channel.enabled !== false ? "停用这个系统渠道?" : "启用这个系统渠道?", + description: channel.enabled !== false ? "停用后新任务不会再使用该渠道,但仍会保留在列表中,可随时重新启用。" : "启用后,配置完整的模型会重新进入系统可用模型集合。", + okText: channel.enabled !== false ? "确认停用" : "确认启用", + }, + onClick: () => toggleChannel(channel), + }, + { + key: "delete", + label: "删除渠道", + icon: , + danger: true, + confirm: { title: "删除这个系统渠道?", description: "删除后渠道及所属模型将不再显示,API Key 会被清除,历史账单和调用记录继续保留。该操作不能在页面恢复。", okText: "确认删除" }, + onClick: () => removeChannel(channel), + }, + ]} + /> + ); return ( - - { - await syncChannels(); - await reload(); - }} - /> - - - } - > - } - value={keyword} - placeholder="搜索渠道名称、别名或地址" - onChange={(event) => updateUrl({ filter: event.target.value, page: 1 }, true)} - /> - } - toolbarFilters={ - } + value={keyword} + placeholder="搜索渠道名称或地址" + onChange={(event) => updateUrl({ filter: event.target.value }, true)} + /> + )} - + ); } @@ -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 ; - 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() diff --git a/web/test/admin-channel-workspace.test.tsx b/web/test/admin-channel-workspace.test.tsx new file mode 100644 index 000000000..51813cbd9 --- /dev/null +++ b/web/test/admin-channel-workspace.test.tsx @@ -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( + + {}} /> + , + ); + 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"); +}); diff --git a/web/test/channel-model-cost-summary.test.tsx b/web/test/channel-model-cost-summary.test.tsx new file mode 100644 index 000000000..f078aba3c --- /dev/null +++ b/web/test/channel-model-cost-summary.test.tsx @@ -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(); + +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(); + 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(" { + 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(""); + expect(source).not.toContain("billingSummary(item)"); +});