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

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

View File

@@ -32,6 +32,7 @@ import { AgentChatComposer, AgentChatMessage, AgentPlanBar, AgentQuestionBar, Ag
import { CanvasAgentSkillLibraryModal } from "./canvas-agent-skill-library-modal";
import { CanvasCloudAgentSettings, agentPermissionLabel, agentPermissionMenuItems, agentPermissionVisual, type AgentContextKey } from "./canvas-cloud-agent-settings";
import { useAgentPanelLayout } from "./use-agent-panel-layout";
import { useAgentLauncherPosition } from "./use-agent-launcher-position";
import { AgentWelcome } from "./canvas-agent-welcome";
import "./canvas-cloud-agent.css";
@@ -797,17 +798,18 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
}
function AgentLauncher({ theme, statusColor, approvalPending, reducedMotion, onOpen }: { theme: CanvasTheme; statusColor: string; approvalPending: boolean; reducedMotion: boolean; onOpen: () => void }) {
const { position, dragging, handlers } = useAgentLauncherPosition(onOpen);
return (
<motion.button
type="button"
aria-label="打开云端 Agent"
title={approvalPending ? "Agent 等待你的审批" : "打开 Agent 助手"}
className="canvas-agent-launcher fixed bottom-5 right-5 z-[var(--z-modal-overlay)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/35"
style={{ color: theme.node.text, "--canvas-agent-launcher-shadow": theme.spatial.shadow } as CSSProperties}
title={`${approvalPending ? "Agent 等待你的审批" : "打开 Agent 助手"} · 拖动可调整位置,聚焦后可用方向键移动`}
className={cn("canvas-agent-launcher fixed z-[var(--z-modal-overlay)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/35", dragging && "is-dragging")}
style={{ ...position, color: theme.node.text, "--canvas-agent-launcher-shadow": theme.spatial.shadow } as CSSProperties}
data-canvas-no-zoom
onClick={onOpen}
whileHover={reducedMotion ? undefined : { y: -3, scale: 1.035 }}
whileTap={reducedMotion ? undefined : { scale: 0.96 }}
{...handlers}
whileHover={reducedMotion || dragging ? undefined : { scale: 1.035 }}
whileTap={reducedMotion || dragging ? undefined : { scale: 0.96 }}
transition={{ duration: reducedMotion ? 0 : 0.18 }}
>
<FluidOrb size={62} color="#7164f6" />

View File

@@ -522,10 +522,16 @@
background: transparent;
padding: 7px;
box-shadow: 0 16px 38px color-mix(in srgb, var(--canvas-agent-launcher-shadow) 64%, transparent);
cursor: pointer;
cursor: grab;
touch-action: none;
user-select: none;
isolation: isolate;
}
.canvas-agent-launcher.is-dragging {
cursor: grabbing;
}
.canvas-agent-launcher::before {
position: absolute;
inset: 3px;

View File

@@ -0,0 +1,99 @@
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type PointerEvent } from "react";
import { AGENT_LAUNCHER_POSITION_KEY, clampAgentLauncherPosition, moveAgentLauncher, restoreAgentLauncherPosition, type AgentLauncherGesture, type AgentLauncherPosition } from "@/lib/canvas/agent-launcher-position";
const viewport = () => ({ width: window.innerWidth, height: window.innerHeight });
export function useAgentLauncherPosition(onOpen: () => void) {
const [position, setPosition] = useState(() => {
try {
return restoreAgentLauncherPosition(localStorage.getItem(AGENT_LAUNCHER_POSITION_KEY), viewport());
} catch (error) {
console.warn("Agent 入口位置无法读取", error);
return restoreAgentLauncherPosition(null, viewport());
}
});
const [dragging, setDragging] = useState(false);
const gestureRef = useRef<(AgentLauncherGesture & { pointerId: number }) | null>(null);
const suppressClick = useRef(false);
const save = (next: AgentLauncherPosition) => {
try {
localStorage.setItem(AGENT_LAUNCHER_POSITION_KEY, JSON.stringify(next));
} catch (error) {
console.warn("Agent 入口位置无法保存", error);
}
};
useEffect(() => {
const resize = () => setPosition((current) => clampAgentLauncherPosition(current, viewport()));
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, []);
const onPointerDown = (event: PointerEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (event.button !== 0 || !event.isPrimary || gestureRef.current) return;
suppressClick.current = false;
gestureRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, start: position, dragged: false };
event.currentTarget.setPointerCapture(event.pointerId);
};
const onPointerMove = (event: PointerEvent<HTMLButtonElement>) => {
event.stopPropagation();
const gesture = gestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
const next = moveAgentLauncher(gesture, event.clientX, event.clientY, viewport());
gesture.dragged = next.dragged;
if (!next.dragged) return;
event.preventDefault();
suppressClick.current = true;
setDragging(true);
setPosition(next.position);
};
const finish = (event: PointerEvent<HTMLButtonElement>, cancelled: boolean) => {
event.stopPropagation();
const gesture = gestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
gestureRef.current = null;
const next = cancelled ? { dragged: gesture.dragged, position: clampAgentLauncherPosition(position, viewport()) } : moveAgentLauncher(gesture, event.clientX, event.clientY, viewport());
suppressClick.current = cancelled || next.dragged;
setDragging(false);
setPosition(next.position);
if (next.dragged) save(next.position);
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
};
const onClick = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
// Keyboard/assistive activation remains available after a drag or cancellation.
if (event.detail !== 0 && suppressClick.current) {
event.preventDefault();
return;
}
onOpen();
};
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
event.stopPropagation();
const delta = event.shiftKey ? 40 : 10;
const offsets: Record<string, [number, number]> = { ArrowLeft: [delta, 0], ArrowRight: [-delta, 0], ArrowUp: [0, delta], ArrowDown: [0, -delta] };
const offset = offsets[event.key];
if (!offset) return;
event.preventDefault();
const next = clampAgentLauncherPosition({ right: position.right + offset[0], bottom: position.bottom + offset[1] }, viewport());
setPosition(next);
save(next);
};
return {
position,
dragging,
handlers: {
onPointerDown,
onPointerMove,
onPointerUp: (event: PointerEvent<HTMLButtonElement>) => finish(event, false),
onPointerCancel: (event: PointerEvent<HTMLButtonElement>) => finish(event, true),
onLostPointerCapture: (event: PointerEvent<HTMLButtonElement>) => finish(event, true),
onClick,
onKeyDown,
onDoubleClick: (event: MouseEvent<HTMLButtonElement>) => event.stopPropagation(),
},
};
}

View File

@@ -0,0 +1,38 @@
export type AgentLauncherPosition = { right: number; bottom: number };
export type AgentLauncherViewport = { width: number; height: number };
export type AgentLauncherGesture = { x: number; y: number; start: AgentLauncherPosition; dragged: boolean };
export const AGENT_LAUNCHER_POSITION_KEY = "canvas:agent-launcher-position:v1";
export const AGENT_LAUNCHER_SIZE = 76;
const MARGIN = 20; // Includes the label below the orb.
export function clampAgentLauncherPosition(position: AgentLauncherPosition, viewport: AgentLauncherViewport): AgentLauncherPosition {
const clamp = (value: number, extent: number) => {
const available = Math.max(0, extent - AGENT_LAUNCHER_SIZE);
const margin = Math.min(MARGIN, available / 2);
return Math.min(Math.max(value, margin), available - margin);
};
return { right: clamp(position.right, viewport.width), bottom: clamp(position.bottom, viewport.height) };
}
export function restoreAgentLauncherPosition(raw: string | null, viewport: AgentLauncherViewport): AgentLauncherPosition {
if (raw) {
try {
const value = JSON.parse(raw);
if (value && Number.isFinite(value.right) && Number.isFinite(value.bottom)) return clampAgentLauncherPosition(value, viewport);
} catch {
// Damaged UI preferences must not hide the launcher.
}
}
return clampAgentLauncherPosition({ right: MARGIN, bottom: MARGIN }, viewport);
}
export function moveAgentLauncher(gesture: AgentLauncherGesture, x: number, y: number, viewport: AgentLauncherViewport) {
const dx = x - gesture.x;
const dy = y - gesture.y;
const dragged = gesture.dragged || Math.hypot(dx, dy) >= 6;
return {
dragged,
position: clampAgentLauncherPosition(dragged ? { right: gesture.start.right - dx, bottom: gesture.start.bottom - dy } : gesture.start, viewport),
};
}

View File

@@ -218,6 +218,145 @@
line-height: 1.6;
}
.admin-model-reprice {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
gap: 16px;
padding-block: 8px;
}
.admin-model-reprice > :not(.admin-model-reprice-table) {
flex-shrink: 0;
}
.admin-model-reprice-controls {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: 24px;
padding: 24px;
border-radius: var(--admin-radius);
background: var(--admin-surface-2);
}
.admin-model-reprice-field {
display: flex;
flex-direction: column;
gap: 10px;
width: 190px;
max-width: 100%;
}
.admin-model-reprice-field > .ant-input-number {
width: 100%;
}
.admin-model-reprice-formula,
.admin-model-reprice-identity {
display: flex;
flex-direction: column;
gap: 2px;
overflow-wrap: anywhere;
}
.admin-model-reprice-sale {
display: flex;
align-items: center;
gap: 8px;
}
.admin-model-reprice-sale > .ant-input-number {
flex: 0 0 120px;
width: 120px;
}
.admin-model-reprice-sale > small {
white-space: nowrap;
}
.admin-model-reprice-formula {
padding-block: 4px;
gap: 8px;
}
.admin-model-reprice-note,
.admin-model-reprice-formula span,
.admin-model-reprice-identity small,
.admin-model-reprice-sale small,
.admin-model-reprice-list-heading > span {
color: var(--admin-text-muted);
font-size: 12px;
line-height: 1.7;
}
.admin-model-reprice-note {
margin: 0;
}
.admin-model-reprice-list-heading {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.admin-model-reprice-table {
flex: 1;
min-height: 160px;
overflow: auto;
}
.admin-model-reprice-table .ant-table-tbody > tr > td {
padding: 6px 16px;
vertical-align: middle;
overflow-wrap: anywhere;
}
.admin-model-reprice-table .ant-table-tbody > tr > td.admin-model-reprice-spec-cell {
padding: 0;
}
.admin-model-reprice-spec-row {
display: grid;
grid-template-columns: minmax(0, 140fr) minmax(0, 160fr) minmax(0, 340fr);
align-items: center;
}
.admin-model-reprice-spec-row > div {
min-width: 0;
padding: 6px 16px;
}
.admin-model-reprice-spec-row + .admin-model-reprice-spec-row {
border-top: 1px solid var(--admin-divider);
}
.admin-model-reprice-no-spec {
padding: 6px 16px;
color: var(--admin-text-muted);
}
.admin-model-reprice-modal .ant-modal-container {
display: flex;
flex-direction: column;
height: min(900px, calc(100dvh - 48px));
}
.admin-model-reprice-modal .ant-modal-header,
.admin-model-reprice-modal .ant-modal-footer {
flex-shrink: 0;
}
.admin-model-reprice-modal .ant-modal-body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.admin-model-cost-tier + .admin-model-cost-tier {
margin-top: 8px;
padding-top: 8px;

View File

@@ -1,14 +1,14 @@
import type { ChannelModel, ChannelModelPriceTier } from "@/services/api/wallet";
import { AdminStatusBadge } from "./admin-ui";
import { formatModelMargin, formatModelPrice, modelPriceFields } from "./channel-model-pricing";
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" />;
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 className="admin-model-cost-value">{priceLabel(tier, item.capability)}</div>
</div>
);
return (
@@ -16,7 +16,7 @@ export function ChannelModelCostSummary({ item }: { item: ChannelModel }) {
{tiers.slice(0, 3).map(renderTier)}
{tiers.length > 3 ? (
<details className="admin-model-cost-more">
<summary>其余 {tiers.length - 3} 个规格成本</summary>
<summary>其余 {tiers.length - 3} 个规格价格</summary>
{tiers.slice(3).map(renderTier)}
</details>
) : null}
@@ -24,25 +24,20 @@ export function ChannelModelCostSummary({ item }: { item: ChannelModel }) {
);
}
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`;
function priceLabel(tier: ChannelModelPriceTier, capability: ChannelModel["capability"]) {
return modelPriceFields(tier, capability).map(({ key, label, unit }) => {
const cost = tier.costPricing?.configured ? tier.costPricing[key] : undefined;
const sale = tier.priceConfigured ? tier[key] : undefined;
return (
<>
<span>
输入 {format(cost.inputTokenPriceMicrocredits)} · 输出 {format(cost.outputTokenPriceMicrocredits)} · 缓存 {format(cost.cachedTokenPriceMicrocredits)}
</span>
<span className="admin-model-cost-unit">积分 / 百万 Token</span>
</>
<div key={key}>
<span>{label ? `${label} ` : ""}{tier.costPricing?.configured ? formatModelPrice(cost) : "未配置成本"} / {tier.priceConfigured ? formatModelPrice(sale) : "未配置售价"}</span>
<span className="admin-model-cost-unit">积分 / {unit} · 利润率 {formatModelMargin(cost, sale)}</span>
</div>
);
}
return `${format(cost.unitPriceMicrocredits)} 积分 / ${tier.billingMode === "per_second" ? "秒" : "次"}`;
});
}
function specificationLabel(tier: ChannelModelPriceTier) {
export function specificationLabel(tier: ChannelModelPriceTier) {
const selector = tier.selector || {};
const specific = (value?: string) => (value && value !== "*" ? value : "");
const operation = specific(selector.operation);

View File

@@ -13,12 +13,14 @@ import { ChannelModelEditor } from "./channel-model-editor";
import { AdminBatchBar, AdminDataTable, AdminFilterChip, AdminStatusBadge, AdminTableEmpty } from "./admin-ui";
import { ChannelOrderDialog } from "./channel-order-dialog";
import { ChannelModelCostSummary } from "./channel-model-cost-summary";
import { ChannelModelRepriceDialog } from "./channel-model-reprice-dialog";
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 [repriceItems, setRepriceItems] = useState<ChannelModel[]>([]);
const [editing, setEditing] = useState<ChannelModel | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
@@ -70,6 +72,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
setEditing(null);
setEditorOpen(false);
setSelectedModelIds([]);
setRepriceItems([]);
resetFetchPreview();
setKeyword("");
setCapability("all");
@@ -209,7 +212,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
<AdminStatusBadge label="待配置" tone="warning" />
),
},
{ title: "规格成本价", width: 290, render: (_, item) => <ChannelModelCostSummary item={item} /> },
{ title: "规格成本价 / 销售价 / 利润率", width: 310, 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"} /> },
{
@@ -264,7 +267,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
<h3 className="admin-channel-model-heading">
模型管理 <span className="admin-channel-count">{items.length}</span>
</h3>
<p className="admin-channel-model-hint">规格展示上游成本,不影响用户售价</p>
<p className="admin-channel-model-hint">规格展示成本价 / 销售价及利润率,勾选模型可统一调价</p>
</div>
<Space wrap>
<ChannelOrderDialog
@@ -382,6 +385,9 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
}}
batchActions={
<AdminBatchBar count={selectedModelIds.length} onClear={() => setSelectedModelIds([])}>
<Button size="small" disabled={deletingSelected || loading || Boolean(loadError)} onClick={() => setRepriceItems(items.filter((item) => selectedModelIds.includes(item.id)))}>
统一调价
</Button>
<Button danger size="small" icon={<Trash2 className="size-3.5" />} loading={deletingSelected} onClick={confirmBatchRemove}>
批量删除
</Button>
@@ -419,6 +425,11 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
/>
}
/>
{repriceItems.length > 0 && <ChannelModelRepriceDialog channelId={channel.id} channelName={channel.name} items={repriceItems} onClose={() => setRepriceItems([])} onSaved={async () => {
setSelectedModelIds([]);
await reload();
await onChanged();
}} />}
<Modal
title="选择要导入的模型"
open={fetchPreviewOpen}

View File

@@ -0,0 +1,120 @@
import type { ChannelModel, ChannelModelPriceTier, ChannelModelRepriceInput } from "@/services/api/wallet";
const validPrice = (value: number | undefined): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
export function formatModelPrice(value: number | undefined) {
return validPrice(value) ? (value / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 }) : "—";
}
export function formatModelMargin(cost: number | undefined, sale: number | undefined) {
if (!validPrice(cost) || !validPrice(sale) || sale === 0) return "—";
return `${(((sale - cost) * 100) / sale).toLocaleString("zh-CN", { maximumFractionDigits: 2 })}%`;
}
export function modelPriceFields(tier: ChannelModelPriceTier, capability: ChannelModel["capability"]) {
if (tier.billingMode !== "token") return [{ key: "unitPriceMicrocredits" as const, label: "", unit: tier.billingMode === "per_second" ? "秒" : "次" }];
if (capability === "video") return [{ key: "outputTokenPriceMicrocredits" as const, label: "视频", unit: "百万视频 Token" }];
return [
{ key: "inputTokenPriceMicrocredits" as const, label: "输入", unit: "百万 Token" },
{ key: "outputTokenPriceMicrocredits" as const, label: "输出", unit: "百万 Token" },
{ key: "cachedTokenPriceMicrocredits" as const, label: "缓存", unit: "百万 Token" },
];
}
export function previewModelRepricing(items: ChannelModel[], marginPercent: number | null, decimalPlaces = 2): ChannelModel[] {
if (marginPercent === null || !Number.isFinite(marginPercent) || marginPercent < 0 || marginPercent > 99.99 || Math.abs(marginPercent * 100 - Math.round(marginPercent * 100)) > 1e-8) {
throw new Error("请输入 0% 到 99.99% 的目标利润率,最多两位小数");
}
if (!items.length || items.length > 100) throw new Error("请选择 1 到 100 个模型");
if (!Number.isInteger(decimalPlaces) || decimalPlaces < 0 || decimalPlaces > 6) throw new Error("请选择保留 0 到 6 位小数");
const denominator = BigInt(10_000 - Math.round(marginPercent * 100));
const quantum = BigInt(10 ** (6 - decimalPlaces));
return items.map((item) => {
if (!item.priceTiers.some((tier) => tier.enabled)) throw new Error(`${item.displayName || item.modelKey} 没有已启用规格`);
return {
...item,
priceTiers: item.priceTiers.map((tier) => {
if (!tier.enabled) return tier;
const name = `${item.displayName || item.modelKey}(${tier.selectorKey})`;
if (!tier.id || !tier.priceConfigured || !tier.costPricing?.configured) throw new Error(`${name}:请先保存完整的规格成本价和销售价配置`);
const next = { ...tier };
for (const { key } of modelPriceFields(tier, item.capability)) {
const cost = tier.costPricing[key];
if (!validPrice(cost) || cost > 1_000_000_000_000) throw new Error(`${name}:成本价无效`);
// Round once at the selected credit precision, with exact half-up ties.
const numerator = BigInt(cost) * BigInt(10_000);
const divisor = denominator * quantum;
const sale = Number(((numerator * BigInt(2) + divisor) / (divisor * BigInt(2))) * quantum);
if (!validPrice(sale) || (tier.billingMode === "token" && sale > 1_000_000_000_000)) throw new Error(`${name}:调整后售价超过上限`);
next[key] = sale;
}
return next;
}),
};
});
}
export function saleInputValue(microcredits: number) {
if (!validPrice(microcredits)) return null;
const value = BigInt(microcredits);
return `${value / BigInt(1_000_000)}.${String(value % BigInt(1_000_000)).padStart(6, "0")}`.replace(/\.?0+$/, "");
}
export function parseSaleInput(value: string | null) {
if (value === null || !/^\d+(?:\.\d{0,6})?$/.test(value)) throw new Error("销售价须为非负数,最多保留 6 位小数");
const [integer, fraction = ""] = value.split(".");
const amount = Number(BigInt(integer) * BigInt(1_000_000) + BigInt(fraction.padEnd(6, "0")));
if (!validPrice(amount)) throw new Error("销售价超出有效范围");
return amount;
}
export type ModelRepriceRow = {
key: string;
model: ChannelModel;
tier: ChannelModelPriceTier;
field: ReturnType<typeof modelPriceFields>[number];
sale: string | null;
manual: boolean;
};
export function modelRepriceRows(items: ChannelModel[]): ModelRepriceRow[] {
return items.flatMap((model) =>
model.priceTiers
.filter((tier) => tier.enabled)
.flatMap((tier) =>
modelPriceFields(tier, model.capability).map((field) => ({
key: `${tier.id}:${field.key}`,
model,
tier,
field,
sale: tier.priceConfigured ? saleInputValue(tier[field.key]) : null,
manual: false,
})),
),
);
}
export function modelRepricePayload(items: ChannelModel[], rows: ModelRepriceRow[]): ChannelModelRepriceInput[] {
if (!items.length || items.length > 100) throw new Error("请选择 1 到 100 个模型");
const byKey = new Map(rows.map((row) => [row.key, row]));
return items.map((item) => {
const tiers = item.priceTiers.filter((tier) => tier.enabled);
if (!tiers.length) throw new Error(`${item.displayName || item.modelKey} 没有已启用规格`);
return {
modelId: item.id,
priceVersion: item.priceVersion,
priceTiers: tiers.map((tier) => {
if (!tier.id || !tier.priceConfigured || !tier.costPricing?.configured) throw new Error(`${item.displayName || item.modelKey}:请先配置规格成本价和销售价`);
const prices: ChannelModelRepriceInput["priceTiers"][number]["prices"] = {};
for (const { key } of modelPriceFields(tier, item.capability)) {
const cost = tier.costPricing[key];
if (!validPrice(cost) || cost > 1_000_000_000_000) throw new Error(`${item.displayName || item.modelKey}:成本价无效`);
const sale = parseSaleInput(byKey.get(`${tier.id}:${key}`)?.sale ?? null);
if (tier.billingMode === "token" && sale > 1_000_000_000_000) throw new Error("Token 售价不能超过 1,000,000 积分");
prices[key] = sale;
}
return { id: tier.id, priceVersion: tier.priceVersion, prices };
}),
};
});
}

View File

@@ -0,0 +1,224 @@
import { useState } from "react";
import { Alert, App, Button, InputNumber, Select, Table } from "antd";
import { AdminModal } from "@/pages/admin/ui/overlays";
import { repriceAdminChannelModels, type ChannelModel } from "@/services/api/wallet";
import { specificationLabel } from "./channel-model-cost-summary";
import { formatModelMargin, formatModelPrice, modelRepricePayload, modelRepriceRows, parseSaleInput, previewModelRepricing, type ModelRepriceRow } from "./channel-model-pricing";
export function ChannelModelRepriceDialog({ channelId, channelName, items, onClose, onSaved }: { channelId: string; channelName: string; items: ChannelModel[]; onClose: () => void; onSaved: () => Promise<void> }) {
const { message } = App.useApp();
const [margin, setMargin] = useState<number | null>(null);
const [decimalPlaces, setDecimalPlaces] = useState(2);
const [rows, setRows] = useState(() => modelRepriceRows(items));
const [calculationError, setCalculationError] = useState("");
const [saving, setSaving] = useState(false);
let validationError = "";
try {
modelRepricePayload(items, rows);
} catch (cause) {
validationError = cause instanceof Error ? cause.message : "销售价无效";
}
const original = new Map(modelRepriceRows(items).map((row) => [row.key, row.sale]));
const changed = rows.filter((row) => {
try {
return parseSaleInput(row.sale) !== parseSaleInput(original.get(row.key) ?? null);
} catch {
return false;
}
}).length;
const recalculate = (nextMargin: number | null, places: number) => {
setMargin(nextMargin);
setDecimalPlaces(places);
if (nextMargin === null) {
setCalculationError("");
return;
}
try {
setRows(modelRepriceRows(previewModelRepricing(items, nextMargin, places)));
setCalculationError("");
} catch (cause) {
setCalculationError(cause instanceof Error ? cause.message : "无法计算销售价");
}
};
const save = async () => {
if (validationError || calculationError || !changed || saving) return;
setSaving(true);
try {
const result = await repriceAdminChannelModels(channelId, modelRepricePayload(items, rows));
message.success(`已保存 ${result.updated} 个模型的销售价`);
} catch (cause) {
message.error(cause instanceof Error ? cause.message : "统一调价失败");
setSaving(false);
return;
}
try {
await onSaved();
} catch {
message.warning("价格已保存,但列表刷新失败,请手动刷新");
} finally {
setSaving(false);
onClose();
}
};
return (
<AdminModal
title={`统一调价 · ${items.length} 个模型`}
open
centered
width={1180}
rootClassName="admin-model-reprice-modal"
onCancel={onClose}
closable={!saving}
mask={{ closable: !saving }}
keyboard={!saving}
confirmLoading={saving}
cancelButtonProps={{ disabled: saving }}
okButtonProps={{ disabled: Boolean(validationError || calculationError) || !changed }}
okText={`保存销售价${changed ? `(${changed} 项)` : ""}`}
onOk={() => void save()}
>
<div className="admin-model-reprice">
<div className="admin-model-reprice-controls">
<div className="admin-model-reprice-field">
<label htmlFor="reprice-margin">目标利润率</label>
<InputNumber id="reprice-margin" aria-label="目标利润率" size="large" value={margin} onChange={(value) => recalculate(value, decimalPlaces)} min={0} max={99.99} step={1} suffix="%" placeholder="输入百分比" disabled={saving} />
</div>
<div className="admin-model-reprice-field">
<label htmlFor="reprice-decimals">保留小数位</label>
<Select
id="reprice-decimals"
aria-label="保留小数位"
size="large"
value={decimalPlaces}
disabled={saving}
onChange={(value) => recalculate(margin, value)}
options={Array.from({ length: 7 }, (_, value) => ({ value, label: `${value} 位小数` }))}
/>
</div>
<Button size="large" disabled={saving || margin === null} onClick={() => recalculate(margin, decimalPlaces)}>
重新计算
</Button>
<div className="admin-model-reprice-formula">
<strong>按成本计算,四舍五入</strong>
<span>新售价 = 成本价 ÷(1 − 利润率)</span>
</div>
</div>
<p className="admin-model-reprice-note">修改利润率、小数位或点击「重新计算」会按成本重算全部规格售价,覆盖手动修改。计算后可继续单独编辑,保存以输入框的最终值为准。</p>
<div className="admin-model-reprice-list-heading">
<strong>模型价格</strong>
<span>
{rows.length} 个计费项 · 已修改 {changed} 项
</span>
</div>
<ChannelModelRepriceTable
items={items}
rows={rows}
channelName={channelName}
decimalPlaces={decimalPlaces}
disabled={saving}
onSaleChange={(key, value) => setRows((current) => current.map((entry) => (entry.key === key ? { ...entry, sale: value, manual: true } : entry)))}
/>
{calculationError || validationError ? <Alert type="warning" showIcon title={calculationError || validationError} /> : null}
<p className="admin-model-reprice-note">仅修改已启用规格的售价,不改变成本或启停状态。四舍五入及手动修改可能使实际利润率偏离目标;价格配置发生变化时整批拒绝保存,请刷新后重试。</p>
</div>
</AdminModal>
);
}
export function ChannelModelRepriceTable({
items,
rows,
channelName,
decimalPlaces,
disabled,
onSaleChange,
}: {
items: ChannelModel[];
rows: ModelRepriceRow[];
channelName: string;
decimalPlaces: number;
disabled: boolean;
onSaleChange: (key: string, value: string | null) => void;
}) {
const byModel = new Map<string, ModelRepriceRow[]>();
for (const row of rows) {
const group = byModel.get(row.model.id) ?? [];
group.push(row);
byModel.set(row.model.id, group);
}
return (
<Table<ChannelModel>
className="admin-model-reprice-table"
rowKey="id"
dataSource={items}
pagination={false}
tableLayout="fixed"
scroll={{ x: 1040 }}
locale={{ emptyText: "暂无所选模型" }}
columns={[
{ title: "模型展示名", width: 180, render: (_, model) => <strong>{model.displayName || model.modelKey}</strong> },
{
title: "渠道展示名(上游模型ID)",
width: 220,
render: (_, model) => (
<div className="admin-model-reprice-identity">
<span>{model.channelLabel || channelName}</span>
<small className="admin-monospace">{model.providerModelKey || model.modelKey}</small>
</div>
),
},
// One model per table row; the shared grid keeps each specification and its prices aligned even when labels wrap.
{
title: "规格",
width: 140,
onCell: () => ({ colSpan: 3, className: "admin-model-reprice-spec-cell" }),
render: (_, model) => (
<div className="admin-model-reprice-specs">
{(byModel.get(model.id) ?? []).map((row) => {
let sale: number | undefined;
try {
sale = parseSaleInput(row.sale);
} catch {
/* Incomplete input remains visible and blocks saving. */
}
const cost = row.tier.costPricing?.configured ? row.tier.costPricing[row.field.key] : undefined;
return (
<div key={row.key} className="admin-model-reprice-spec-row">
<div className="admin-model-reprice-identity">
<span>{specificationLabel(row.tier)}</span>
{row.field.label && <small>{row.field.label}</small>}
{row.tier.providerModelKey && row.tier.providerModelKey !== (model.providerModelKey || model.modelKey) && <small className="admin-monospace">上游:{row.tier.providerModelKey}</small>}
</div>
<div className="admin-model-reprice-identity">
<span>{cost === undefined ? "未配置成本" : formatModelPrice(cost)}</span>
<small>积分 / {row.field.unit}</small>
</div>
<div className="admin-model-reprice-sale">
<InputNumber<string>
stringMode
aria-label={`${model.displayName || model.modelKey} ${specificationLabel(row.tier)} ${row.field.label} 销售价`}
size="middle"
value={row.sale}
min="0"
step={String(10 ** -decimalPlaces)}
disabled={disabled}
status={sale === undefined ? "error" : undefined}
onChange={(value) => onSaleChange(row.key, value)}
/>
<small>
{row.manual ? "手动修改 · " : ""}利润率 {formatModelMargin(cost, sale)}
</small>
</div>
</div>
);
})}
{!byModel.get(model.id)?.length && <div className="admin-model-reprice-no-spec">暂无已启用规格</div>}
</div>
),
},
{ title: "成本价", width: 160, onCell: () => ({ colSpan: 0 }) },
{ title: "销售价(积分)", width: 340, onCell: () => ({ colSpan: 0 }) },
]}
/>
);
}

View File

@@ -320,6 +320,16 @@ export function deleteAdminChannelModels(channelId: string, modelIds: string[])
return http.post<{ deleted: number }>(`/admin/channels/${encodeURIComponent(channelId)}/models/batch-delete`, { modelIds });
}
export type ChannelModelRepriceInput = {
modelId: string;
priceVersion: number;
priceTiers: { id: string; priceVersion: number; prices: Partial<Record<"unitPriceMicrocredits" | "inputTokenPriceMicrocredits" | "outputTokenPriceMicrocredits" | "cachedTokenPriceMicrocredits", number>> }[];
};
export function repriceAdminChannelModels(channelId: string, models: ChannelModelRepriceInput[]) {
return http.post<{ updated: number }>(`/admin/channels/${encodeURIComponent(channelId)}/models/batch-reprice`, { models });
}
export type AdminFinanceListParams = { keyword?: string; status?: string; validity?: string; page?: number; pageSize?: number };
export function listAdminRedeemBatches(params: AdminFinanceListParams = {}) {

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);
});

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%");
});

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");
});

View File

@@ -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)");
});