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 = {}) {