feat(*): 系统渠道与画布 - 支持多规格批量调价及 Agent 入口拖动

This commit is contained in:
ddCat committed 2026-09-20 11:18:19 +08:00
1 parent 9afe061d79
commit 8d94bde1f4
26 files changed
+1761 -44

No files matched your search

+174
View File
@@ -0,0 +1,174 @@
import { expect, test } from "bun:test";
import { runInNewContext } from "node:vm";
import { clampAgentLauncherPosition, moveAgentLauncher, restoreAgentLauncherPosition } from "../src/lib/canvas/agent-launcher-position";
const viewport = { width: 1280, height: 800 };
const start = { right: 20, bottom: 20 };
test("launcher restores a valid position and defaults safely for corrupt preferences", () => {
expect(restoreAgentLauncherPosition('{"right":240,"bottom":180}', viewport)).toEqual({ right: 240, bottom: 180 });
for (const raw of [null, "invalid", "null", "{}", '{"right":"20","bottom":20}', '{"right":1e999,"bottom":20}']) {
expect(restoreAgentLauncherPosition(raw, viewport)).toEqual(start);
}
});
test("launcher stays within all four edges and clamps after a viewport shrink", () => {
expect(clampAgentLauncherPosition({ right: -500, bottom: -500 }, viewport)).toEqual(start);
expect(clampAgentLauncherPosition({ right: 5000, bottom: 5000 }, viewport)).toEqual({ right: 1184, bottom: 704 });
expect(clampAgentLauncherPosition({ right: 1000, bottom: 500 }, { width: 320, height: 300 })).toEqual({ right: 224, bottom: 204 });
expect(clampAgentLauncherPosition(start, { width: 90, height: 90 })).toEqual({ right: 7, bottom: 7 });
});
test("small pointer jitter remains a click, but dragging back to the origin remains a drag", () => {
const gesture = { start, x: 500, y: 500, dragged: false };
expect(moveAgentLauncher(gesture, 502, 503, viewport)).toEqual({ dragged: false, position: start });
expect(moveAgentLauncher(gesture, 400, 450, viewport)).toEqual({ dragged: true, position: { right: 120, bottom: 70 } });
expect(moveAgentLauncher({ ...gesture, dragged: true }, 500, 500, viewport)).toEqual({ dragged: true, position: start });
});
// Execute the production hook with a tiny hook scheduler; only React and browser
// side effects are substituted, not the pointer/click handlers being tested.
const build = await Bun.build({
entrypoints: [new URL("../src/components/canvas/use-agent-launcher-position.ts", import.meta.url).pathname],
target: "browser",
format: "cjs",
plugins: [
{
name: "hook-scheduler",
setup(builder) {
builder.onResolve({ filter: /^react$/ }, () => ({ path: "react", namespace: "test" }));
builder.onLoad({ filter: /.*/, namespace: "test" }, () => ({ contents: "export const { useState, useRef, useEffect } = globalThis.hooks;", loader: "js" }));
},
},
],
});
expect(build.success).toBe(true);
const source = await build.outputs[0].text();
function harness() {
const cells = [];
let cursor = 0;
let opens = 0;
const stored = new Map();
const listeners = new Map();
const window = { innerWidth: 1280, innerHeight: 800, addEventListener: (name, fn) => listeners.set(name, fn), removeEventListener: (name) => listeners.delete(name) };
const module = { exports: {} };
const hooks = {
useState(initial) {
const i = cursor++;
if (!(i in cells)) cells[i] = typeof initial === "function" ? initial() : initial;
return [
cells[i],
(next) => {
cells[i] = typeof next === "function" ? next(cells[i]) : next;
},
];
},
useRef(initial) {
const i = cursor++;
return (cells[i] ||= { current: initial });
},
useEffect(fn) {
const i = cursor++;
if (!cells[i]) {
cells[i] = true;
fn();
}
},
};
runInNewContext(source, { module, exports: module.exports, hooks, window, localStorage: { getItem: (key) => stored.get(key) ?? null, setItem: (key, value) => stored.set(key, value) }, console });
const render = () => {
cursor = 0;
return module.exports.useAgentLauncherPosition(() => opens++);
};
let current = render();
let captured = null;
const target = {
setPointerCapture: (id) => {
captured = id;
},
hasPointerCapture: (id) => captured === id,
releasePointerCapture: () => {
captured = null;
},
};
return {
fire(name, overrides = {}) {
const event = { currentTarget: target, pointerId: 1, isPrimary: true, button: 0, clientX: 500, clientY: 500, detail: 1, preventDefault() {}, stopPropagation() {}, ...overrides };
current.handlers[name](event);
current = render();
},
resize(width, height) {
window.innerWidth = width;
window.innerHeight = height;
listeners.get("resize")();
current = render();
},
get state() {
return current;
},
get opens() {
return opens;
},
get saved() {
return [...stored.values()].map(JSON.parse);
},
get captured() {
return captured;
},
};
}
test("production handlers preserve click and suppress drag-release activation", () => {
const h = harness();
h.fire("onPointerDown");
h.fire("onPointerMove", { clientX: 502 });
h.fire("onPointerUp", { clientX: 502 });
h.fire("onClick");
expect(h.opens).toBe(1);
h.fire("onPointerDown");
h.fire("onPointerMove", { clientX: 400, clientY: 450 });
expect(h.state.dragging).toBe(true);
h.fire("onPointerUp", { clientX: 400, clientY: 450 });
h.fire("onClick");
expect(h.opens).toBe(1);
expect(h.state.position).toEqual({ right: 120, bottom: 70 });
expect(h.saved).toEqual([{ right: 120, bottom: 70 }]);
expect(h.captured).toBe(null);
h.fire("onClick", { detail: 0 });
expect(h.opens).toBe(2);
h.fire("onPointerDown");
h.fire("onPointerUp");
h.fire("onClick");
expect(h.opens).toBe(3);
});
for (const ending of ["onPointerCancel", "onLostPointerCapture"]) {
test(`production handlers stop safely on ${ending} and ignore other pointers`, () => {
const h = harness();
h.fire("onPointerDown", { button: 2 });
expect(h.captured).toBe(null);
h.fire("onPointerDown", { pointerType: "touch" });
h.fire("onPointerMove", { pointerId: 2, clientX: 100 });
expect(h.state.position).toEqual(start);
h.fire("onPointerMove", { clientX: 300 });
h.fire(ending);
h.fire("onPointerMove", { clientX: 100 });
h.fire("onClick");
expect(h.state.position).toEqual({ right: 220, bottom: 20 });
expect(h.state.dragging).toBe(false);
expect(h.captured).toBe(null);
expect(h.opens).toBe(0);
});
}
test("production handlers support keyboard movement and viewport resizing", () => {
const h = harness();
h.fire("onKeyDown", { key: "ArrowLeft" });
h.fire("onKeyDown", { key: "ArrowUp", shiftKey: true });
expect(h.state.position).toEqual({ right: 30, bottom: 60 });
h.resize(100, 100);
expect(h.state.position).toEqual({ right: 12, bottom: 12 });
h.fire("onClick", { detail: 0 });
expect(h.opens).toBe(1);
});
+25 -15
View File
@@ -45,24 +45,26 @@ const item: ChannelModel = {
};
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", () => {
test("fixed and per-second specifications show cost, sale and margin", () => {
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 积分 / 秒");
expect(fixed).toContain("1.25 / 99");
expect(fixed).toContain("积分 / 次 · 利润率 98.74%");
expect(render([{ ...tier, billingMode: "per_second" }])).toContain("积分 / 秒");
});
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("输入 2 / 88");
expect(html).toContain("输出 3 / 77");
expect(html).toContain("缓存 0.5 / 66");
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).toContain("视频 3 / 77");
expect(html).toContain("积分 / 百万视频 Token");
expect(html).not.toContain("输入");
});
@@ -70,21 +72,23 @@ 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("积分 /");
expect(html).toContain("未配置成本 / 99");
expect(html).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).toContain("0 / 未配置售价");
expect(html).toContain("利润率 —");
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 积分");
expect(html).toContain("— / 99");
expect(html).toContain("利润率 —");
}
});
@@ -93,17 +97,23 @@ test("specification labels prefer canonical selectors and additional tiers remai
expect(html).toContain("图生视频 / 1080P / 10 秒");
expect(html).not.toContain("720P");
expect(html).toContain("<details");
expect(html).toContain("其余 1 个规格成本");
expect(html).toContain("其余 1 个规格价格");
});
test("disabled tiers are excluded and empty costs are explicit", () => {
expect(render([{ ...tier, enabled: false }])).toContain("未配置成本");
expect(render([])).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('title: "规格成本价 / 销售价 / 利润率"');
expect(source).toContain("<ChannelModelCostSummary item={item} />");
expect(source).not.toContain("billingSummary(item)");
});
test("zero revenue is undefined, free costs have 100% margin and losses stay negative", () => {
expect(render([{ ...tier, unitPriceMicrocredits: 0 }])).toContain("利润率 —");
expect(render([{ ...tier, unitPriceMicrocredits: 1_000_000 }])).toContain("利润率 -25%");
expect(render([{ ...tier, costPricing: { ...tier.costPricing!, unitPriceMicrocredits: 0 } }])).toContain("利润率 100%");
});
+125
View File
@@ -0,0 +1,125 @@
import { expect, test } from "bun:test";
import { formatModelMargin, previewModelRepricing, modelRepriceRows, modelRepricePayload, parseSaleInput, saleInputValue } from "../src/pages/admin/components/channel-model-pricing";
import type { ChannelModel } from "../src/services/api/wallet";
const item = {
id: "m",
displayName: "Model",
capability: "text",
priceTiers: [
{
id: "t",
selectorKey: "{}",
enabled: true,
priceConfigured: true,
billingMode: "fixed_request",
unitPriceMicrocredits: 9,
inputTokenPriceMicrocredits: 0,
outputTokenPriceMicrocredits: 0,
cachedTokenPriceMicrocredits: 0,
costPricing: { configured: true, unitPriceMicrocredits: 1_000_000, inputTokenPriceMicrocredits: 2_000_000, outputTokenPriceMicrocredits: 4_000_000, cachedTokenPriceMicrocredits: 0 },
},
],
} as ChannelModel;
test("20% margin uses cost / 0.8, not markup, and leaves inputs unchanged", () => {
const result = previewModelRepricing([item], 20);
expect(result[0]!.priceTiers[0]!.unitPriceMicrocredits).toBe(1_250_000);
expect(item.priceTiers[0]!.unitPriceMicrocredits).toBe(9);
expect(result[0]!.priceTiers[0]!.costPricing).toEqual(item.priceTiers[0]!.costPricing);
});
test("token prices are independent and zero costs remain free", () => {
const token = { ...item, priceTiers: [{ ...item.priceTiers[0]!, billingMode: "token" as const }] };
const tier = previewModelRepricing([token], 20)[0]!.priceTiers[0]!;
expect(tier.inputTokenPriceMicrocredits).toBe(2_500_000);
expect(tier.outputTokenPriceMicrocredits).toBe(5_000_000);
expect(tier.cachedTokenPriceMicrocredits).toBe(0);
expect(formatModelMargin(0, 0)).toBe("—");
});
test("video token adjusts only output; per-second adjusts unit price", () => {
const video = { ...item, capability: "video" as const, priceTiers: [{ ...item.priceTiers[0]!, billingMode: "token" as const }] };
const tier = previewModelRepricing([video], 20)[0]!.priceTiers[0]!;
expect(tier.outputTokenPriceMicrocredits).toBe(5_000_000);
expect(tier.inputTokenPriceMicrocredits).toBe(0);
video.priceTiers[0]!.billingMode = "per_second";
expect(previewModelRepricing([video], 20)[0]!.priceTiers[0]!.unitPriceMicrocredits).toBe(1_250_000);
});
test("half-up rounding at microcredit precision and disabled tiers stay untouched", () => {
const tiny = {
...item,
priceTiers: [
{ ...item.priceTiers[0]!, costPricing: { ...item.priceTiers[0]!.costPricing!, unitPriceMicrocredits: 1 } },
{ ...item.priceTiers[0]!, id: "disabled", enabled: false },
],
};
const result = previewModelRepricing([tiny], 20, 6)[0]!;
expect(result.priceTiers[0]!.unitPriceMicrocredits).toBe(1);
expect(result.priceTiers[1]).toBe(tiny.priceTiers[1]);
expect(previewModelRepricing([tiny], 0, 6)[0]!.priceTiers[0]!.unitPriceMicrocredits).toBe(1);
});
test("selected decimals round half up exactly, including binary floating point ties", () => {
for (const [cost, places, expected] of [
[1_005_000, 2, 1_010_000],
[1_004_999, 2, 1_000_000],
[1_500_000, 0, 2_000_000],
[1_234_500, 3, 1_235_000],
[5_000, 2, 10_000],
[4_999, 2, 0],
]) {
const input = { ...item, priceTiers: [{ ...item.priceTiers[0]!, costPricing: { ...item.priceTiers[0]!.costPricing!, unitPriceMicrocredits: cost! } }] };
expect(previewModelRepricing([input], 0, places)[0]!.priceTiers[0]!.unitPriceMicrocredits).toBe(expected);
}
for (const places of [-1, 7, 1.5]) expect(() => previewModelRepricing([item], 20, places)).toThrow();
});
test("rows are present before a percentage is set and final manual prices survive payload creation", () => {
const input = { ...item, priceVersion: 4, priceTiers: [{ ...item.priceTiers[0]!, priceVersion: 8 }] };
const initial = modelRepriceRows([input]);
expect(initial).toHaveLength(1);
expect(initial[0]!.sale).toBe("0.000009");
const recalculated = modelRepriceRows(previewModelRepricing([input], 20));
expect(recalculated[0]!.sale).toBe("1.25");
recalculated[0]!.sale = "1.37";
recalculated[0]!.manual = true;
expect(modelRepricePayload([input], recalculated)).toEqual([{ modelId: "m", priceVersion: 4, priceTiers: [{ id: "t", priceVersion: 8, prices: { unitPriceMicrocredits: 1_370_000 } }] }]);
expect(modelRepriceRows(previewModelRepricing([input], 50, 2))[0]!.sale).toBe("2");
expect(modelRepriceRows(previewModelRepricing([input], 20, 0))[0]!.sale).toBe("1");
});
test("manual token edits affect only their own field and invalid input never silently becomes zero", () => {
const token = { ...item, priceTiers: [{ ...item.priceTiers[0]!, billingMode: "token" as const }] };
const rows = modelRepriceRows(previewModelRepricing([token], 20));
rows[1]!.sale = "7.123456";
const prices = modelRepricePayload([token], rows)[0]!.priceTiers[0]!.prices;
expect(prices).toEqual({ inputTokenPriceMicrocredits: 2_500_000, outputTokenPriceMicrocredits: 7_123_456, cachedTokenPriceMicrocredits: 0 });
for (const invalid of [null, "", "-1", "1.1234567", "1e3", "NaN", "9007199254.740992"]) expect(() => parseSaleInput(invalid)).toThrow();
for (const value of [0, 1, 10_000_000, Number.MAX_SAFE_INTEGER]) expect(parseSaleInput(saleInputValue(value))).toBe(value);
rows[1]!.sale = null;
expect(() => modelRepricePayload([token], rows)).toThrow();
});
test("reject invalid rates, incomplete costs, invalid amounts and overflow", () => {
for (const margin of [null, NaN, Infinity, -1, 100, 20.001]) expect(() => previewModelRepricing([item], margin)).toThrow();
for (const cost of [undefined, NaN, -1, 1_000_000_000_001]) {
const invalid = { ...item, priceTiers: [{ ...item.priceTiers[0]!, costPricing: { ...item.priceTiers[0]!.costPricing!, unitPriceMicrocredits: cost as number } }] };
expect(() => previewModelRepricing([item, invalid], 20)).toThrow();
}
expect(() => previewModelRepricing([{ ...item, priceTiers: [] }], 20)).toThrow();
expect(() => previewModelRepricing([{ ...item, priceTiers: [{ ...item.priceTiers[0]!, costPricing: undefined }] }], 20)).toThrow();
expect(() => previewModelRepricing([{ ...item, priceTiers: [{ ...item.priceTiers[0]!, priceConfigured: false }] }], 20)).toThrow();
const huge = { ...item, priceTiers: [{ ...item.priceTiers[0]!, costPricing: { ...item.priceTiers[0]!.costPricing!, unitPriceMicrocredits: 1_000_000_000_000 } }] };
expect(() => previewModelRepricing([huge], 99.99)).toThrow();
});
test("manager preserves cross-page selection and API uses one atomic request", async () => {
const source = await Bun.file(new URL("../src/pages/admin/components/channel-model-manager.tsx", import.meta.url)).text();
const api = await Bun.file(new URL("../src/services/api/wallet.ts", import.meta.url)).text();
expect(source).toContain("preserveSelectedRowKeys: true");
expect(source).toContain("items.filter((item) => selectedModelIds.includes(item.id))");
expect(source).toContain("统一调价");
expect(api).toContain("/models/batch-reprice");
});
@@ -0,0 +1,81 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { ChannelModelRepriceTable } from "../src/pages/admin/components/channel-model-reprice-dialog";
import { modelRepricePayload, modelRepriceRows, previewModelRepricing } from "../src/pages/admin/components/channel-model-pricing";
import type { ChannelModel } from "../src/services/api/wallet";
const model = {
id: "image",
modelKey: "image-model",
displayName: "GPT Image 2.5",
channelLabel: "特惠渠道",
providerModelKey: "gpt-image-2.5",
capability: "image",
priceVersion: 9,
priceTiers: ["1K", "2K", "4K"].map((resolution, index) => ({
id: `tier-${index}`,
selector: { vquality: resolution },
selectorKey: resolution,
resolution,
enabled: true,
priceConfigured: true,
priceVersion: index + 1,
billingMode: "fixed_request",
unitPriceMicrocredits: 4_000 + index * 1_000,
costPricing: { configured: true, unitPriceMicrocredits: 3_000 },
})),
} as ChannelModel;
function render(items = [model], rows = modelRepriceRows(items)) {
return renderToStaticMarkup(<ChannelModelRepriceTable items={items} rows={rows} channelName="所属渠道" decimalPlaces={3} disabled={false} onSaleChange={() => {}} />);
}
test("multi-spec models render once with aligned specifications and separate sale inputs", () => {
const html = render();
expect(html.match(/data-row-key="image"/g)).toHaveLength(1);
expect(html.match(/class="admin-model-reprice-spec-row"/g)).toHaveLength(3);
expect(html.match(/role="spinbutton"/g)).toHaveLength(3);
expect(html).toContain('colSpan="3"');
expect(html).toContain("渠道展示名(上游模型ID)");
expect(html.match(/>GPT Image 2.5</g)).toHaveLength(1);
expect(html.match(/>特惠渠道</g)).toHaveLength(1);
expect(html).toContain("gpt-image-2.5");
for (const value of ["0.004", "0.005", "0.006"]) expect(html).toContain(`value="${value}"`);
for (const spec of ["1K", "2K", "4K"]) expect(html).toContain(`>${spec}<`);
});
test("recalculation resets all specifications; manual edit changes only its final submitted price", () => {
const rows = modelRepriceRows(previewModelRepricing([model], 25, 3));
expect(rows.map((row) => row.sale)).toEqual(["0.004", "0.004", "0.004"]);
rows[1]!.sale = "0.0055";
rows[1]!.manual = true;
expect(modelRepricePayload([model], rows)[0]!.priceTiers.map((tier) => tier.prices.unitPriceMicrocredits)).toEqual([4_000, 5_500, 4_000]);
const html = render([model], rows);
expect(html).toContain('value="0.0055"');
expect(html).toContain("手动修改");
const reset = modelRepriceRows(previewModelRepricing([model], 25, 3));
expect(reset.map((row) => row.sale)).toEqual(["0.004", "0.004", "0.004"]);
expect(reset.every((row) => !row.manual)).toBe(true);
});
test("token fields stay inside a single model row and tier upstream overrides stay visible", () => {
const token = {
...model,
capability: "text" as const,
channelLabel: "",
priceTiers: [{ ...model.priceTiers[0]!, billingMode: "token" as const, providerModelKey: "spec-upstream", inputTokenPriceMicrocredits: 10, outputTokenPriceMicrocredits: 20, cachedTokenPriceMicrocredits: 30 }],
};
const html = render([token]);
expect(html.match(/data-row-key="image"/g)).toHaveLength(1);
expect(html.match(/role="spinbutton"/g)).toHaveLength(3);
expect(html).toContain("所属渠道");
expect(html).toContain("spec-upstream");
for (const label of ["输入", "输出", "缓存"]) expect(html).toContain(`>${label}<`);
});
test("recalculate button reruns the current margin and precision even when they are unchanged", async () => {
const source = await Bun.file(new URL("../src/pages/admin/components/channel-model-reprice-dialog.tsx", import.meta.url)).text();
expect(source).toContain("onClick={() => recalculate(margin, decimalPlaces)}");
expect(source).toContain("重新计算");
expect(source).toContain("previewModelRepricing(items, nextMargin, places)");
});