feat(admin): 系统模型 - 合并渠道与模型管理并展示规格成本价
This commit is contained in:
1 parent
473d535ec1
commit
d7682963a4
8 files changed
+797
-230
No files matched your search
@@ -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");
|
||||
});
|
||||
@@ -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