From 7cea21f76539ba8ed2f3b800c1333a9f8cfc983d Mon Sep 17 00:00:00 2001 From: chengcheng Date: Mon, 20 Jul 2026 23:22:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E5=A2=9E=E5=8A=A0=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E5=8D=B3=E6=97=B6=E4=BC=B0=E4=BB=B7=E4=B8=8E=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=E5=85=8D=E8=B4=B9=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 估价请求统一使用 350ms 防抖、AbortController 和请求序号,区分免费、计算中、价格不可用与失败状态,并展示候选冻结上限。价格未就绪时阻止生产提交。\n\n计价规则编辑器新增显式免费开关和 9 位价格步进。\n\n验证:Web 88 项测试和生产构建通过。 --- apps/web/src/api.ts | 5 +- .../src/pages/PlaygroundPage.pricing.test.ts | 49 ++++++++ apps/web/src/pages/PlaygroundPage.tsx | 116 +++++++++++++++--- .../pages/admin/PricingRuleVisualEditor.tsx | 41 ++++++- .../web/src/pages/admin/PricingRulesPanel.tsx | 3 + 5 files changed, 193 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/pages/PlaygroundPage.pricing.test.ts diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 0946295..f367ae3 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -896,10 +896,12 @@ export async function uploadFileToStorage( export async function estimatePricing( token: string, input: Record, + signal?: AbortSignal, ): Promise { return request('/api/v1/pricing/estimate', { body: input, method: 'POST', + signal, token, }); } @@ -1180,7 +1182,7 @@ export async function deleteFileStorageChannel(token: string, channelId: string) async function request( path: string, - options: { token?: string; auth?: boolean; method?: string; body?: unknown; headers?: Record } = {}, + options: { token?: string; auth?: boolean; method?: string; body?: unknown; headers?: Record; signal?: AbortSignal } = {}, ): Promise { const headers: Record = { ...(options.headers ?? {}) }; if (options.auth !== false && options.token && options.token !== OIDC_BROWSER_SESSION_CREDENTIAL) { @@ -1194,6 +1196,7 @@ async function request( headers, body: options.body === undefined ? undefined : JSON.stringify(options.body), credentials: 'include', + signal: options.signal, }); if (!response.ok) { const body = await response.text(); diff --git a/apps/web/src/pages/PlaygroundPage.pricing.test.ts b/apps/web/src/pages/PlaygroundPage.pricing.test.ts new file mode 100644 index 0000000..abacfb2 --- /dev/null +++ b/apps/web/src/pages/PlaygroundPage.pricing.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import type { GatewayPricingEstimate } from '@easyai-ai-gateway/contracts'; +import { + MEDIA_ESTIMATE_DEBOUNCE_MS, + billingEstimateSignature, + mediaEstimateFromResponse, +} from './PlaygroundPage'; + +describe('playground pricing estimate', () => { + it('uses the specified 350ms debounce interval', () => { + expect(MEDIA_ESTIMATE_DEBOUNCE_MS).toBe(350); + }); + + it('only changes its signature for billing-relevant parameters', () => { + const first = billingEstimateSignature({ + kind: 'images.generations', model: 'image-model', prompt: 'first', + count: 1, quality: 'high', resolution: '2K', + }); + const promptOnly = billingEstimateSignature({ + kind: 'images.generations', model: 'image-model', prompt: 'second', + count: 1, quality: 'high', resolution: '2K', + }); + const changedCount = billingEstimateSignature({ + kind: 'images.generations', model: 'image-model', prompt: 'second', + count: 2, quality: 'high', resolution: '2K', + }); + expect(promptOnly).toBe(first); + expect(changedCount).not.toBe(first); + }); + + it('distinguishes explicit free and exposes the reservation ceiling', () => { + const response: GatewayPricingEstimate = { + candidateCount: 2, + currency: 'resource', + items: [{ amount: 0, currency: 'resource', isFree: true }], + pricingVersion: 'effective-pricing-v2', + requestFingerprint: 'fingerprint', + reservationAmount: 0, + resolver: 'effective-pricing-v2', + totalAmount: 0, + }; + expect(mediaEstimateFromResponse(response)).toMatchObject({ + amount: 0, + candidateCount: 2, + reservationAmount: 0, + status: 'free', + }); + }); +}); diff --git a/apps/web/src/pages/PlaygroundPage.tsx b/apps/web/src/pages/PlaygroundPage.tsx index a9b8af1..048bc6c 100644 --- a/apps/web/src/pages/PlaygroundPage.tsx +++ b/apps/web/src/pages/PlaygroundPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import type { GatewayApiKey, GatewayPricingEstimate, GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts'; import { ArrowUp, ChevronDown, MessageSquarePlus, Settings2, Sparkles } from 'lucide-react'; import { Badge, Button, FormDialog, Select, Textarea } from '../components/ui'; -import { createImageEditTask, createImageGenerationTask, createVideoGenerationTask, estimatePricing, pollTaskUntilSettled, resolveApiAssetUrl, taskIsPending } from '../api'; +import { GatewayApiError, createImageEditTask, createImageGenerationTask, createVideoGenerationTask, estimatePricing, pollTaskUntilSettled, resolveApiAssetUrl, taskIsPending } from '../api'; import type { PlaygroundMode } from '../types'; import { PlaygroundPromptMentionInput, @@ -56,13 +56,17 @@ import { const MEDIA_RUNS_STORAGE_KEY = 'easyai:playground:media-runs:v1'; const MEDIA_RUNS_STORAGE_LIMIT = 50; +export const MEDIA_ESTIMATE_DEBOUNCE_MS = 350; type MediaEstimateState = { amount?: number; + reservationAmount?: number; + candidateCount?: number; currency?: string; error?: string; + pricingVersion?: string; resolver?: string; - status: 'idle' | 'loading' | 'ready' | 'error'; + status: 'idle' | 'loading' | 'ready' | 'free' | 'unavailable' | 'error'; }; const publicWorks = [ @@ -107,6 +111,7 @@ export function PlaygroundPage(props: { const [settingsOpen, setSettingsOpen] = useState(false); const isMountedRef = useRef(false); const pendingMediaModelRef = useRef(''); + const mediaEstimateSequenceRef = useRef(0); const resumedTaskIdsRef = useRef(new Set()); const activeMode = useMemo(() => modeOptions.find((item) => item.value === props.mode) ?? modeOptions[0], [props.mode]); const mediaUploadAcceptValue = sharedMediaUploadAcceptForMode(props.mode, videoMode); @@ -136,6 +141,10 @@ export function PlaygroundPage(props: { supportsQualityControl: mediaCapabilities?.supportsQualityControl, }); }, [mediaCapabilities, mediaSettings, mediaUploads, prompt, props.mode, selectedModel, videoMode]); + const mediaEstimateSignature = useMemo( + () => billingEstimateSignature(mediaEstimatePayload), + [mediaEstimatePayload], + ); useEffect(() => { setSelectedModel((current) => { @@ -176,30 +185,34 @@ export function PlaygroundPage(props: { useEffect(() => { const credential = activeApiKeySecret || props.token; if (props.mode === 'chat' || !credential || !mediaEstimatePayload) { + mediaEstimateSequenceRef.current += 1; setMediaEstimate({ status: 'idle' }); return; } - let cancelled = false; + const sequence = mediaEstimateSequenceRef.current + 1; + mediaEstimateSequenceRef.current = sequence; + const controller = new AbortController(); setMediaEstimate((current) => ({ ...current, error: '', status: 'loading' })); const timer = window.setTimeout(() => { - estimatePricing(credential, mediaEstimatePayload) + estimatePricing(credential, mediaEstimatePayload, controller.signal) .then((estimate) => { - if (cancelled) return; + if (controller.signal.aborted || sequence !== mediaEstimateSequenceRef.current) return; setMediaEstimate(mediaEstimateFromResponse(estimate)); }) .catch((err) => { - if (cancelled) return; + if (controller.signal.aborted || sequence !== mediaEstimateSequenceRef.current) return; + const unavailable = err instanceof GatewayApiError && err.details.code === 'pricing_unavailable'; setMediaEstimate({ - error: err instanceof Error ? err.message : '预计扣费计算失败', - status: 'error', + error: err instanceof Error ? err.message : unavailable ? '价格不可用' : '预计扣费计算失败', + status: unavailable ? 'unavailable' : 'error', }); }); - }, 260); + }, MEDIA_ESTIMATE_DEBOUNCE_MS); return () => { - cancelled = true; window.clearTimeout(timer); + controller.abort(); }; - }, [activeApiKeySecret, mediaEstimatePayload, props.mode, props.token]); + }, [activeApiKeySecret, mediaEstimateSignature, props.mode, props.token]); useEffect(() => { if (props.mode === 'image') { @@ -313,6 +326,12 @@ export function PlaygroundPage(props: { setMediaMessage(runMode === 'video' ? '请输入视频提示词。' : '请输入图片提示词。'); return; } + if (!overrides && !mediaEstimateAllowsProduction(mediaEstimate)) { + setMediaMessage(mediaEstimate.status === 'unavailable' + ? '当前参数没有可用价格,已阻止生产生成。' + : '请等待当前参数的预计费用计算完成后再生成。'); + return; + } const localId = newLocalId(); const runUploads = sanitizeMediaRunUploads(overrides?.uploads ?? mediaUploads); @@ -456,6 +475,7 @@ export function PlaygroundPage(props: { imageHasReference={effectiveImageHasReference} mediaSettings={mediaSettings} mediaEstimate={mediaEstimate} + submitDisabled={!mediaEstimateAllowsProduction(mediaEstimate)} mediaCapabilities={mediaCapabilities} uploadAccept={mediaUploadAcceptValue} uploadMessage={mediaUploadMessage} @@ -684,6 +704,7 @@ function Composer(props: { imageHasReference?: boolean; mediaCapabilities?: MediaModelCapabilities; mediaEstimate?: MediaEstimateState; + submitDisabled?: boolean; mediaSettings?: MediaGenerationSettings; mode: PlaygroundMode; modelOptions: ModelOption[]; @@ -791,7 +812,7 @@ function Composer(props: { {mediaEstimateText(props.mediaEstimate)} )} - @@ -828,12 +849,17 @@ function buildMediaEstimatePayload( }; } -function mediaEstimateFromResponse(response: GatewayPricingEstimate): MediaEstimateState { +export function mediaEstimateFromResponse(response: GatewayPricingEstimate): MediaEstimateState { + const amount = numericFromUnknown(response.totalAmount) ?? estimateItemsTotal(response.items); + const explicitFree = amount === 0 && response.items.length > 0 && response.items.every((item) => item.isFree === true); return { - amount: numericFromUnknown(response.totalAmount) ?? estimateItemsTotal(response.items), + amount, + reservationAmount: numericFromUnknown(response.reservationAmount), + candidateCount: response.candidateCount, currency: response.currency || estimateItemsCurrency(response.items), + pricingVersion: response.pricingVersion, resolver: response.resolver, - status: 'ready', + status: explicitFree ? 'free' : 'ready', }; } @@ -847,21 +873,79 @@ function estimateItemsCurrency(items: GatewayPricingEstimate['items']) { } function mediaEstimateText(estimate: MediaEstimateState) { + if (estimate.status === 'loading') return '计算中'; + if (estimate.status === 'unavailable') return '价格不可用'; + if (estimate.status === 'error') return '估价失败'; + if (estimate.status === 'free') return '免费'; if (estimate.amount === undefined) return '--'; - return formatEstimateAmount(estimate.amount); + const amount = formatEstimateAmount(estimate.amount); + const reservation = estimate.reservationAmount; + if (reservation !== undefined && reservation > estimate.amount) { + return `预计 ${amount} · 冻结上限 ${formatEstimateAmount(reservation)}`; + } + return `预计 ${amount}`; } function mediaEstimateHint(estimate: MediaEstimateState) { + if (estimate.status === 'unavailable' && estimate.error) return `价格不可用:${estimate.error}`; if (estimate.status === 'error' && estimate.error) return `预计扣费计算失败:${estimate.error}`; + if (estimate.status === 'free') return '当前计价规则已显式标记为免费'; return '扣费为预计,实际扣费以账单为准'; } function mediaEstimateAriaLabel(estimate: MediaEstimateState) { if (estimate.status === 'error') return estimate.error ? `预计扣费计算失败:${estimate.error}` : '预计扣费计算失败'; + if (estimate.status === 'unavailable') return estimate.error ? `价格不可用:${estimate.error}` : '价格不可用'; + if (estimate.status === 'free') return '当前请求免费'; if (estimate.amount === undefined) return '预计扣费估算中'; return `预计扣费 ${formatEstimateAmount(estimate.amount)} ${estimate.currency || 'resource'}`; } +function mediaEstimateAllowsProduction(estimate: MediaEstimateState) { + return estimate.status === 'ready' || estimate.status === 'free'; +} + +export function billingEstimateSignature(payload: Record | null) { + if (!payload) return ''; + const content = Array.isArray(payload.content) + ? payload.content.map((item) => { + const record = item && typeof item === 'object' ? item as Record : {}; + return { + duration: record.duration, + role: record.role, + type: record.type, + hasAudio: Boolean(record.audio_url), + hasImage: Boolean(record.image_url), + hasVideo: Boolean(record.video_url), + }; + }) + : undefined; + const signature = { + kind: payload.kind, + model: payload.model, + n: payload.n ?? payload.count ?? payload.batch_size ?? payload.batchSize, + quality: payload.quality, + size: payload.size, + resolution: payload.resolution, + duration: payload.duration, + audio: payload.audio, + maxCompletionTokens: payload.max_completion_tokens, + maxOutputTokens: payload.max_output_tokens, + maxTokens: payload.max_tokens, + content, + imageCount: pricingReferenceCount(payload, ['image', 'images', 'image_url', 'image_urls', 'referenceImage', 'reference_image']), + }; + return JSON.stringify(signature); +} + +function pricingReferenceCount(payload: Record, keys: string[]) { + return keys.reduce((count, key) => { + const value = payload[key]; + if (Array.isArray(value)) return count + value.length; + return count + (value ? 1 : 0); + }, 0); +} + function formatEstimateAmount(value: number) { if (!Number.isFinite(value)) return '--'; const digits = Math.abs(value) > 0 && Math.abs(value) < 0.01 ? 6 : 2; diff --git a/apps/web/src/pages/admin/PricingRuleVisualEditor.tsx b/apps/web/src/pages/admin/PricingRuleVisualEditor.tsx index 336d47b..2e45e1a 100644 --- a/apps/web/src/pages/admin/PricingRuleVisualEditor.tsx +++ b/apps/web/src/pages/admin/PricingRuleVisualEditor.tsx @@ -221,7 +221,17 @@ function ModeRuleEditor(props: { - patch({ basePrice: Number(event.target.value) })} /> + patch({ basePrice: Number(event.target.value) })} /> + + + @@ -311,11 +321,32 @@ function TextRuleEditor(props: { + + + @@ -329,9 +360,10 @@ function TextRuleEditor(props: { @@ -345,9 +377,10 @@ function TextRuleEditor(props: { diff --git a/apps/web/src/pages/admin/PricingRulesPanel.tsx b/apps/web/src/pages/admin/PricingRulesPanel.tsx index 7bf44f1..d696ac3 100644 --- a/apps/web/src/pages/admin/PricingRulesPanel.tsx +++ b/apps/web/src/pages/admin/PricingRulesPanel.tsx @@ -225,6 +225,7 @@ function ruleToInput(rule: PricingRule): PricingRuleInput { resourceType: rule.resourceType, unit: rule.unit, basePrice: rule.basePrice, + isFree: rule.isFree, currency: rule.currency, baseWeight: rule.baseWeight ?? {}, dynamicWeight: rule.dynamicWeight ?? {}, @@ -234,6 +235,8 @@ function ruleToInput(rule: PricingRule): PricingRuleInput { priority: rule.priority, status: rule.status, metadata: rule.metadata ?? {}, + effectiveFrom: rule.effectiveFrom, + effectiveTo: rule.effectiveTo, }; }