feat(*): 系统渠道与画布 - 支持多规格批量调价及 Agent 入口拖动
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
99
web/src/components/canvas/use-agent-launcher-position.ts
Normal file
99
web/src/components/canvas/use-agent-launcher-position.ts
Normal 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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
38
web/src/lib/canvas/agent-launcher-position.ts
Normal file
38
web/src/lib/canvas/agent-launcher-position.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
120
web/src/pages/admin/components/channel-model-pricing.ts
Normal file
120
web/src/pages/admin/components/channel-model-pricing.ts
Normal 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 };
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
224
web/src/pages/admin/components/channel-model-reprice-dialog.tsx
Normal file
224
web/src/pages/admin/components/channel-model-reprice-dialog.tsx
Normal 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 }) },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 = {}) {
|
||||
|
||||
174
web/test/agent-launcher-position.test.mjs
Normal file
174
web/test/agent-launcher-position.test.mjs
Normal 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);
|
||||
});
|
||||
@@ -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
web/test/channel-model-pricing.test.ts
Normal file
125
web/test/channel-model-pricing.test.ts
Normal 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");
|
||||
});
|
||||
81
web/test/channel-model-reprice-dialog.test.tsx
Normal file
81
web/test/channel-model-reprice-dialog.test.tsx
Normal 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)");
|
||||
});
|
||||
Reference in New Issue
Block a user