feat: implement AI gateway phase one runtime

This commit is contained in:
2026-05-09 21:18:32 +08:00
parent a5e66e79cd
commit fdcdcd477b
46 changed files with 5678 additions and 768 deletions
@@ -0,0 +1,62 @@
import type { ReactNode } from 'react';
import { Calculator } from 'lucide-react';
export function PricingInlineField(props: {
label: string;
children: ReactNode;
}) {
return (
<div className="pricingInlineField">
<span>{props.label}</span>
{props.children}
</div>
);
}
export function PricingReadonlyField(props: {
label: string;
value: string;
}) {
return (
<div className="pricingInlineField pricingInlineReadonly">
<span>{props.label}</span>
<strong>{props.value}</strong>
</div>
);
}
export function unitLabel(unit: string | undefined) {
const labels: Record<string, string> = {
image: '每张图片',
'5s': '每 5 秒',
second: '每秒',
item: '每次',
'1k_tokens': '每 1K Token',
character_1k: '每 1K 字符',
};
return labels[unit ?? ''] ?? unit ?? '-';
}
export function calculatorLabel(calculatorType: string | undefined) {
const labels: Record<string, string> = {
token_usage: '按 Token 用量',
unit_weight: '按数量和参数',
duration_weight: '按时长和参数',
formula: '按公式',
};
return labels[calculatorType ?? ''] ?? calculatorType ?? '-';
}
export function PricingFormulaNote(props: {
children: ReactNode;
}) {
return (
<div className="pricingFormulaBox">
<Calculator size={16} />
<div>
<strong></strong>
<p>{props.children}</p>
</div>
</div>
);
}
@@ -1,11 +1,19 @@
import { useMemo, useState } from 'react';
import { Calculator, Plus, Trash2 } from 'lucide-react';
import { Plus, Trash2 } from 'lucide-react';
import type { PricingRuleInput } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Input, Label, Select } from '../../components/ui';
import { cn } from '../../lib/utils';
import { calculatorLabel, PricingFormulaNote, PricingInlineField, PricingReadonlyField, unitLabel } from './PricingEditorControls';
type RecordValue = Record<string, unknown>;
type ModeKey = 'text' | 'image' | 'video' | 'digitalHuman' | 'speech' | 'music';
type ModeKey = 'text' | 'image' | 'video';
type ParameterGroup = {
key: string;
title: string;
defaults: RecordValue;
labels?: Record<string, string>;
locked?: boolean;
};
type ModeDefinition = {
key: ModeKey;
@@ -13,88 +21,52 @@ type ModeDefinition = {
formula: string;
match: (rule: PricingRuleInput) => boolean;
templates: (currency: string) => PricingRuleInput[];
weightGroups: Array<{ key: string; title: string; defaults: RecordValue }>;
parameterGroups: ParameterGroup[];
};
const currencies = ['resource', 'credit', 'cny', 'usd'];
const calculators = ['token_usage', 'unit_weight', 'duration_weight', 'formula'];
const statuses = ['active', 'deprecated', 'hidden'];
const modeDefinitions: ModeDefinition[] = [
{
key: 'text',
label: '大语言模型',
formula: '扣费 = 输入 Token 单价 × 输入 Token 数 / 1000 +出 Token 单价 × 输出 Token / 1000。',
label: '文本',
formula: '一次对话扣费 = 输入 Token / 1000 ×入扣费单价 + 输出 Token / 1000 × 输出扣费单价。',
match: (rule) => rule.resourceType.startsWith('text_'),
templates: (currency) => [
createRule('text_input_tokens', '文本输入 Token', 'text_input', '1k_tokens', 0.01, currency, 'token_usage', 'ceil(input_tokens / 1000) * base_price', { meter: 'input_tokens' }, {}, { metrics: ['input_tokens'], unitScale: 1000 }),
createRule('text_output_tokens', '文本输出 Token', 'text_output', '1k_tokens', 0.03, currency, 'token_usage', 'ceil(output_tokens / 1000) * base_price', { meter: 'output_tokens' }, {}, { metrics: ['output_tokens'], unitScale: 1000 }),
],
weightGroups: [],
templates: (currency) => [createTextRule(currency, 0.01, 0.03)],
parameterGroups: [],
},
{
key: 'image',
label: '图像生成',
formula: '扣费 = 基础单价 × 基础权重 × 动态参数权重,例如 2K 或高清质量会按对应倍数计费。',
label: '图像',
formula: '扣费 = 基础单价 × 数量 × 分辨率参数 × 图像质量参数。图像质量按 OpenAI GPT Image 的 low / medium / high 三档配置。',
match: (rule) => rule.resourceType === 'image' || rule.resourceType === 'image_edit',
templates: (currency) => [
createRule('image_generation', '图像生成', 'image', 'image', 10, currency, 'unit_weight', 'count * base_price * resolution_weight * quality_weight * mode_weight', { meter: 'count', base: 1 }, {
resolutionWeights: { '512x512': 0.5, '1024x1024': 1, '2K': 1.5, '4K': 2 },
qualityWeights: { standard: 1, hd: 1.5 },
modeWeights: { generation: 1 },
}, { dimensions: ['count', 'resolution', 'quality', 'mode'], defaults: { count: 1, resolution: '1024x1024', quality: 'standard', mode: 'generation' } }),
],
weightGroups: [
{ key: 'resolutionWeights', title: '分辨率', defaults: { '512x512': 0.5, '1024x1024': 1, '2K': 1.5, '4K': 2 } },
{ key: 'qualityWeights', title: '图像质量', defaults: { standard: 1, hd: 1.5 } },
{ key: 'referenceImageWeights', title: '参考图数量', defaults: { single: 1, multiple: 1.3 } },
templates: (currency) => [createRule('image', '图像', 'image', 'image', 10, currency, 'unit_weight', 'count * base_price * resolution_factor * quality_factor', {
resolutionFactors: { '1K': 1, '2K': 1.5, '3K': 1.75, '4K': 2, '8K': 4 },
qualityFactors: { low: 0.5, medium: 1, high: 1.5 },
}, { dimensions: ['count', 'resolution', 'quality'], defaults: { count: 1, resolution: '1K', quality: 'medium' } })],
parameterGroups: [
{ key: 'resolutionFactors', title: '分辨率', defaults: { '1K': 1, '2K': 1.5, '3K': 1.75, '4K': 2, '8K': 4 }, locked: true },
{ key: 'qualityFactors', title: '图像质量', defaults: { low: 0.5, medium: 1, high: 1.5 }, labels: { low: '低质量 (low)', medium: '标准质量 (medium)', high: '高质量 (high)' }, locked: true },
],
},
{
key: 'video',
label: '视频生成',
formula: '扣费 = 基础单价 × 基础权重 × 动态参数权重。例如生成 1080p 且带音频时,会叠加分辨率音频权重。',
label: '视频',
formula: '扣费 = 基础单价 × 生成时长单位 × 数量 × 分辨率音频、参考视频、音色等计费参数。',
match: (rule) => rule.resourceType === 'video',
templates: (currency) => [
createRule('video_generation', '视频生成', 'video', '5s', 100, currency, 'duration_weight', 'count * ceil(duration_seconds / 5) * base_price * resolution_weight * audio_weight * reference_video_weight * voice_specified_weight', { meter: 'duration', unitSeconds: 5, base: 1 }, {
createRule('video', '视频', 'video', '5s', 100, currency, 'duration_weight', 'count * ceil(duration_seconds / 5) * base_price * resolution_factor * audio_factor * reference_video_factor * voice_specified_factor', {
resolutionWeights: { '480p': 0.75, '720p': 1, '1080p': 1.5, '2160p': 2 },
audioWeights: { true: 2, false: 1 },
referenceVideoWeights: { true: 1.5, false: 1 },
voiceSpecifiedWeights: { true: 1.2, false: 1 },
}, { dimensions: ['count', 'duration_seconds', 'resolution', 'audio', 'reference_video', 'voice_specified'], defaults: { count: 1, duration_seconds: 5, resolution: '720p', audio: false, reference_video: false, voice_specified: false } }),
],
weightGroups: [
{ key: 'resolutionWeights', title: '分辨率', defaults: { '480p': 0.75, '720p': 1, '1080p': 1.5, '2160p': 2 } },
{ key: 'audioWeights', title: '是否生成音频', defaults: { true: 2, false: 1 } },
{ key: 'referenceVideoWeights', title: '是否含有参考视频', defaults: { true: 1.5, false: 1 } },
{ key: 'voiceSpecifiedWeights', title: '是否指定音色', defaults: { true: 1.2, false: 1 } },
],
},
{
key: 'digitalHuman',
label: '数字人生成',
formula: '扣费 = 基础单价 × 生成时长 × 分辨率、音频驱动等动态参数权重。',
match: (rule) => rule.resourceType === 'digital_human',
templates: (currency) => [createRule('digital_human_generation', '数字人生成', 'digital_human', 'second', 2, currency, 'duration_weight', 'duration_seconds * base_price * resolution_weight', { meter: 'duration_seconds', base: 1 }, { resolutionWeights: { '720p': 1, '1080p': 1.5 } }, { dimensions: ['duration_seconds', 'resolution'], defaults: { duration_seconds: 10, resolution: '720p' } })],
weightGroups: [{ key: 'resolutionWeights', title: '分辨率', defaults: { '720p': 1, '1080p': 1.5 } }],
},
{
key: 'speech',
label: '语音合成',
formula: '扣费 = 基础单价 × 字符数 / 1000 × 声音或质量权重。',
match: (rule) => rule.resourceType === 'audio',
templates: (currency) => [createRule('speech_generation', '语音合成', 'audio', 'character_1k', 1, currency, 'unit_weight', 'ceil(characters / 1000) * base_price * voice_weight', { meter: 'characters', base: 1 }, { voiceWeights: { standard: 1, premium: 1.5 } }, { dimensions: ['characters', 'voice'], defaults: { voice: 'standard' } })],
weightGroups: [{ key: 'voiceWeights', title: '音色', defaults: { standard: 1, premium: 1.5 } }],
},
{
key: 'music',
label: '音乐生成',
formula: '扣费 = 基础单价 × 生成数量 × 时长或质量权重。',
match: (rule) => rule.resourceType === 'music',
templates: (currency) => [createRule('music_generation', '音乐生成', 'music', 'item', 20, currency, 'unit_weight', 'count * base_price * duration_weight * quality_weight', { meter: 'count', base: 1 }, { durationWeights: { short: 1, long: 1.8 }, qualityWeights: { standard: 1, high: 1.5 } }, { dimensions: ['count', 'duration', 'quality'], defaults: { count: 1, duration: 'short', quality: 'standard' } })],
weightGroups: [
{ key: 'durationWeights', title: '时长', defaults: { short: 1, long: 1.8 } },
{ key: 'qualityWeights', title: '质量', defaults: { standard: 1, high: 1.5 } },
parameterGroups: [
{ key: 'resolutionWeights', title: '分辨率', defaults: { '480p': 0.75, '720p': 1, '1080p': 1.5, '2160p': 2 }, locked: true },
{ key: 'audioWeights', title: '生成声音', defaults: { true: 2, false: 1 }, labels: { true: '生成声音', false: '不生成声音' }, locked: true },
{ key: 'referenceVideoWeights', title: '参考视频', defaults: { true: 1.5, false: 1 }, labels: { true: '有参考视频', false: '无参考视频' }, locked: true },
{ key: 'voiceSpecifiedWeights', title: '指定音色', defaults: { true: 1.2, false: 1 }, labels: { true: '指定音色', false: '不指定音色' }, locked: true },
],
},
];
@@ -104,27 +76,26 @@ export function PricingRuleVisualEditor(props: {
rules: PricingRuleInput[];
onChange: (rules: PricingRuleInput[]) => void;
}) {
const [activeMode, setActiveMode] = useState<ModeKey>('video');
const [activeMode, setActiveMode] = useState<ModeKey>('text');
const mode = modeDefinitions.find((item) => item.key === activeMode) ?? modeDefinitions[0];
const activeRules = useMemo(() => props.rules.filter(mode.match), [mode, props.rules]);
const rawActiveRules = useMemo(() => props.rules.filter(mode.match), [mode, props.rules]);
const activeRules = useMemo(() => {
if (mode.key === 'text' && rawActiveRules.length) return [completeRule(mergeTextRules(rawActiveRules, props.currency), mode)];
if (mode.key === 'image' && rawActiveRules.length) return [completeRule(mergeTemplateRules(mode.templates(props.currency), rawActiveRules)[0], mode)];
return rawActiveRules.map((rule) => completeRule(rule, mode));
}, [mode.key, props.currency, rawActiveRules]);
function replaceModeRules(nextRules: PricingRuleInput[]) {
props.onChange([...props.rules.filter((rule) => !mode.match(rule)), ...nextRules]);
}
function completeMissing() {
const existing = activeRules.length ? activeRules : mode.templates(props.currency);
replaceModeRules(existing.map((rule) => completeRule(rule, mode)));
}
return (
<section className="pricingModeEditor spanTwo">
<div className="pricingModeHeader">
<div>
<strong></strong>
<span></span>
<span></span>
</div>
<Button type="button" variant="outline" size="sm" onClick={completeMissing}></Button>
</div>
<div className="pricingModeTabs">
{modeDefinitions.map((item) => (
@@ -152,6 +123,10 @@ export function PricingRuleVisualEditor(props: {
);
}
export function createDefaultPricingRules(currency: string): PricingRuleInput[] {
return modeDefinitions.flatMap((mode) => mode.templates(currency).map((rule) => completeRule(rule, mode)));
}
function ModeRuleEditor(props: {
mode: ModeDefinition;
rule: PricingRuleInput;
@@ -159,19 +134,17 @@ function ModeRuleEditor(props: {
onDelete: () => void;
}) {
const rule = props.rule;
const baseWeight = Number((rule.baseWeight ?? {}).base ?? 1);
const formula = String((rule.formulaConfig ?? {}).formula ?? props.mode.formula);
function patch(patchValue: Partial<PricingRuleInput>) {
props.onChange({ ...rule, ...patchValue });
props.onChange(completeRule({ ...rule, ...patchValue }, props.mode));
}
function patchDynamicWeight(key: string, value: RecordValue) {
patch({ dynamicWeight: { ...(rule.dynamicWeight ?? {}), [key]: value } });
}
function addCustomGroup() {
patchDynamicWeight(nextKey(rule.dynamicWeight ?? {}, 'customWeights'), {});
if (props.mode.key === 'text') {
return <TextRuleEditor formula={props.mode.formula} rule={rule} onChange={props.onChange} onDelete={props.onDelete} />;
}
return (
@@ -185,40 +158,33 @@ function ModeRuleEditor(props: {
<Button type="button" variant="ghost" size="sm" onClick={props.onDelete}><Trash2 size={14} /></Button>
</header>
<div className="pricingModeBaseRows">
<Label> Key<Input value={rule.ruleKey ?? ''} onChange={(event) => patch({ ruleKey: event.target.value })} /></Label>
<div className="pricingModeBaseRows compact">
<Label><Input value={rule.displayName ?? ''} onChange={(event) => patch({ displayName: event.target.value })} /></Label>
<Label><Input value={rule.resourceType} onChange={(event) => patch({ resourceType: event.target.value })} /></Label>
<Label><Select value={rule.calculatorType ?? 'unit_weight'} onChange={(event) => patch({ calculatorType: event.target.value })}>{calculators.map((item) => <option key={item} value={item}>{item}</option>)}</Select></Label>
<Label><Select value={rule.currency ?? 'resource'} onChange={(event) => patch({ currency: event.target.value })}>{currencies.map((item) => <option key={item} value={item}>{item}</option>)}</Select></Label>
<Label><Select value={rule.status ?? 'active'} onChange={(event) => patch({ status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select></Label>
</div>
<div className="pricingModeInlineRows">
<div className="pricingModeInlineInput"><span>/{rule.unit}</span><Input min="0" step="0.0001" type="number" value={rule.basePrice} onChange={(event) => patch({ basePrice: Number(event.target.value) })} /></div>
<div className="pricingModeInlineInput"><span></span><Input min="0" step="0.01" type="number" value={baseWeight} onChange={(event) => patch({ baseWeight: { ...(rule.baseWeight ?? {}), base: Number(event.target.value) } })} /></div>
<div className="pricingModeInlineInput"><span></span><Input value={rule.unit} onChange={(event) => patch({ unit: event.target.value })} /></div>
<PricingInlineField label={`基础单价/${unitLabel(rule.unit)}`}>
<Input min="0" step="0.0001" type="number" value={rule.basePrice} onChange={(event) => patch({ basePrice: Number(event.target.value) })} />
</PricingInlineField>
<PricingReadonlyField label="计价单位" value={unitLabel(rule.unit)} />
<PricingReadonlyField label="计算方式" value={calculatorLabel(rule.calculatorType)} />
</div>
<div className="pricingFormulaBox">
<Calculator size={16} />
<div>
<strong></strong>
<Input value={formula} onChange={(event) => patch({ formulaConfig: { ...(rule.formulaConfig ?? {}), formula: event.target.value } })} />
<p>{props.mode.formula}</p>
</div>
</div>
<PricingFormulaNote>{props.mode.formula}</PricingFormulaNote>
<div className="pricingWeightStack">
{props.mode.weightGroups.map((group) => (
{props.mode.parameterGroups.map((group) => (
<WeightTable
key={group.key}
title={group.title}
value={readGroup(rule.dynamicWeight, group)}
labels={group.labels}
locked={group.locked}
onChange={(value) => patchDynamicWeight(group.key, value)}
/>
))}
{Object.entries(rule.dynamicWeight ?? {}).filter(([key]) => !props.mode.weightGroups.some((group) => group.key === key)).map(([key, value]) => (
{Object.entries(rule.dynamicWeight ?? {}).filter(([key]) => !props.mode.parameterGroups.some((group) => group.key === key)).map(([key, value]) => (
<WeightTable key={key} editableTitle title={key} value={isPlainObject(value) ? value as RecordValue : { value }} onChange={(nextValue, nextTitle) => {
const dynamicWeight = { ...(rule.dynamicWeight ?? {}) };
delete dynamicWeight[key];
@@ -227,14 +193,74 @@ function ModeRuleEditor(props: {
}} />
))}
</div>
</article>
);
}
<Button className="pricingAddWeightButton" type="button" variant="outline" onClick={addCustomGroup}><Plus size={15} /></Button>
function TextRuleEditor(props: {
formula: string;
rule: PricingRuleInput;
onChange: (rule: PricingRuleInput) => void;
onDelete: () => void;
}) {
const prices = textPrices(props.rule);
function updatePrices(inputTokenPrice: number, outputTokenPrice: number) {
props.onChange({
...props.rule,
basePrice: inputTokenPrice,
displayName: props.rule.displayName || '文本',
resourceType: 'text_total',
ruleKey: 'text',
unit: '1k_tokens',
formulaConfig: {
...(props.rule.formulaConfig ?? {}),
formula: textFormula,
inputTokenPrice,
outputTokenPrice,
},
dimensionSchema: {
...(props.rule.dimensionSchema ?? {}),
metrics: ['input_tokens', 'output_tokens'],
unitScale: 1000,
},
});
}
return (
<article className="pricingModeRule pricingTextRule">
<header>
<div>
<Badge variant="secondary">text_total</Badge>
<strong>{props.rule.displayName || '文本'}</strong>
<span>text</span>
</div>
<Button type="button" variant="ghost" size="sm" onClick={props.onDelete}><Trash2 size={14} /></Button>
</header>
<div className="pricingModeBaseRows compact">
<Label><Input value={props.rule.displayName ?? '文本'} onChange={(event) => props.onChange({ ...props.rule, displayName: event.target.value, ruleKey: 'text' })} /></Label>
<Label><Select value={props.rule.status ?? 'active'} onChange={(event) => props.onChange({ ...props.rule, status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select></Label>
</div>
<div className="pricingTextPriceGrid">
<PricingInlineField label="输入单价/1K tokens">
<Input min="0" step="0.0001" type="number" value={prices.inputTokenPrice} onChange={(event) => updatePrices(Number(event.target.value), prices.outputTokenPrice)} />
</PricingInlineField>
<PricingInlineField label="输出单价/1K tokens">
<Input min="0" step="0.0001" type="number" value={prices.outputTokenPrice} onChange={(event) => updatePrices(prices.inputTokenPrice, Number(event.target.value))} />
</PricingInlineField>
</div>
<PricingFormulaNote>{props.formula}</PricingFormulaNote>
</article>
);
}
function WeightTable(props: {
editableTitle?: boolean;
labels?: Record<string, string>;
locked?: boolean;
title: string;
value: RecordValue;
onChange: (value: RecordValue, title?: string) => void;
@@ -256,13 +282,17 @@ function WeightTable(props: {
</header>
<div className="pricingWeightRows">
{rows.map(([key, value], index) => (
<div className="pricingWeightRow" key={`${key}-${index}`}>
<Input value={key} onChange={(event) => updateRows(rows.map((row, rowIndex) => (rowIndex === index ? [event.target.value, value] : row)))} />
<div className={cn('pricingWeightRow', props.locked && 'locked')} key={`${key}-${index}`}>
{props.locked ? (
<span className="pricingFactorLabel">{props.labels?.[key] ?? key}</span>
) : (
<Input value={key} onChange={(event) => updateRows(rows.map((row, rowIndex) => (rowIndex === index ? [event.target.value, value] : row)))} />
)}
<Input value={scalarToString(value)} onChange={(event) => updateRows(rows.map((row, rowIndex) => (rowIndex === index ? [key, parseScalar(event.target.value)] : row)))} />
<Button type="button" variant="ghost" size="icon" onClick={() => updateRows(rows.filter((_, rowIndex) => rowIndex !== index))}><Trash2 size={14} /></Button>
{!props.locked && <Button type="button" variant="ghost" size="icon" onClick={() => updateRows(rows.filter((_, rowIndex) => rowIndex !== index))}><Trash2 size={14} /></Button>}
</div>
))}
<Button className="pricingInlineAdd" type="button" variant="outline" size="sm" onClick={() => updateRows([...rows, [nextKey(props.value, 'option'), 1]])}><Plus size={14} /></Button>
{!props.locked && <Button className="pricingInlineAdd" type="button" variant="outline" size="sm" onClick={() => updateRows([...rows, [nextKey(props.value, 'option'), 1]])}><Plus size={14} /></Button>}
</div>
</section>
);
@@ -283,20 +313,130 @@ export function KeyValueEditor(props: {
}
function completeRule(rule: PricingRuleInput, mode: ModeDefinition): PricingRuleInput {
const template = mode.templates(rule.currency ?? 'resource')[0] ?? rule;
return {
...template,
...rule,
dynamicWeight: {
...Object.fromEntries(mode.weightGroups.map((group) => [group.key, { ...group.defaults, ...(isPlainObject(rule.dynamicWeight?.[group.key]) ? rule.dynamicWeight?.[group.key] as RecordValue : {}) }])),
...(rule.dynamicWeight ?? {}),
ruleKey: mode.key,
displayName: normalizeDisplayName(rule.displayName || template.displayName, mode),
resourceType: template.resourceType,
dimensionSchema: template.dimensionSchema,
formulaConfig: {
...(rule.formulaConfig ?? {}),
formula: template.formulaConfig?.formula,
},
dynamicWeight: normalizeDynamicWeight(rule.dynamicWeight, mode),
};
}
function readGroup(value: RecordValue | undefined, group: ModeDefinition['weightGroups'][number]) {
function normalizeDisplayName(value: string | undefined, mode: ModeDefinition) {
const legacyNames: Record<ModeKey, string[]> = {
text: ['文本输入 Token', '文本输出 Token', '文本生成 Token', '文本生成'],
image: ['图像生成', '图像编辑'],
video: ['视频生成'],
};
if (!value || legacyNames[mode.key].includes(value)) return mode.label;
return value;
}
function normalizeDynamicWeight(value: PricingRuleInput['dynamicWeight'], mode: ModeDefinition): RecordValue {
const source = isPlainObject(value) ? { ...(value as RecordValue) } : {};
if (mode.key === 'image') {
if (!isPlainObject(source.resolutionFactors) && isPlainObject(source.resolutionWeights)) {
source.resolutionFactors = source.resolutionWeights;
}
if (!isPlainObject(source.qualityFactors) && isPlainObject(source.qualityWeights)) {
source.qualityFactors = source.qualityWeights;
}
delete source.modeFactors;
delete source.modeWeights;
delete source.referenceImageWeights;
delete source.resolutionWeights;
delete source.qualityWeights;
}
const groupKeys = new Set(mode.parameterGroups.map((group) => group.key));
const defaults = Object.fromEntries(mode.parameterGroups.map((group) => [
group.key,
mergeGroupDefaults(group, isPlainObject(source[group.key]) ? source[group.key] as RecordValue : {}),
]));
const custom = Object.fromEntries(Object.entries(source).filter(([key]) => !groupKeys.has(key)));
return { ...defaults, ...custom };
}
function mergeGroupDefaults(group: ParameterGroup, source: RecordValue): RecordValue {
if (!group.locked) return { ...group.defaults, ...source };
return Object.fromEntries(Object.entries(group.defaults).map(([key, defaultValue]) => [
key,
key in source ? source[key] : defaultValue,
]));
}
function mergeTemplateRules(templates: PricingRuleInput[], rules: PricingRuleInput[]) {
if (templates.length === 1) return rules.length ? [{ ...templates[0], ...rules[0] }] : templates;
const byKey = new Map(templates.map((rule) => [rule.ruleKey ?? rule.resourceType, rule]));
for (const rule of rules) {
byKey.set(rule.ruleKey ?? rule.resourceType, rule);
}
return Array.from(byKey.values());
}
function mergeTextRules(rules: PricingRuleInput[], currency: string): PricingRuleInput {
const totalRule = rules.find((rule) => rule.resourceType === 'text_total');
const inputRule = rules.find((rule) => rule.resourceType === 'text_input');
const outputRule = rules.find((rule) => rule.resourceType === 'text_output');
const source = totalRule ?? inputRule ?? outputRule ?? createTextRule(currency, 0.01, 0.03);
return createTextRule(
source.currency ?? currency,
Number(source.formulaConfig?.inputTokenPrice ?? inputRule?.basePrice ?? source.basePrice ?? 0.01),
Number(source.formulaConfig?.outputTokenPrice ?? outputRule?.basePrice ?? 0.03),
source,
);
}
function readGroup(value: RecordValue | undefined, group: ModeDefinition['parameterGroups'][number]) {
return isPlainObject(value?.[group.key]) ? value?.[group.key] as RecordValue : group.defaults;
}
function createRule(ruleKey: string, displayName: string, resourceType: string, unit: string, basePrice: number, currency: string, calculatorType: string, formula: string, baseWeight: RecordValue, dynamicWeight: RecordValue, dimensionSchema: RecordValue): PricingRuleInput {
const textFormula = 'input_tokens / 1000 * input_token_price + output_tokens / 1000 * output_token_price';
function createTextRule(currency: string, inputTokenPrice: number, outputTokenPrice: number, source?: PricingRuleInput): PricingRuleInput {
return {
...(source ?? {}),
ruleKey: 'text',
displayName: normalizeDisplayName(source?.displayName, modeDefinitions[0]),
resourceType: 'text_total',
unit: '1k_tokens',
basePrice: inputTokenPrice,
currency,
calculatorType: 'token_usage',
baseWeight: {},
dynamicWeight: {},
dimensionSchema: {
metrics: ['input_tokens', 'output_tokens'],
unitScale: 1000,
...(source?.dimensionSchema ?? {}),
},
formulaConfig: {
...(source?.formulaConfig ?? {}),
formula: textFormula,
inputTokenPrice,
outputTokenPrice,
},
priority: source?.priority ?? 100,
status: source?.status ?? 'active',
metadata: source?.metadata ?? {},
};
}
function textPrices(rule: PricingRuleInput) {
return {
inputTokenPrice: Number(rule.formulaConfig?.inputTokenPrice ?? rule.basePrice ?? 0.01),
outputTokenPrice: Number(rule.formulaConfig?.outputTokenPrice ?? 0.03),
};
}
function createRule(ruleKey: string, displayName: string, resourceType: string, unit: string, basePrice: number, currency: string, calculatorType: string, formula: string, dynamicWeight: RecordValue, dimensionSchema: RecordValue): PricingRuleInput {
return {
ruleKey,
displayName,
@@ -305,7 +445,7 @@ function createRule(ruleKey: string, displayName: string, resourceType: string,
basePrice,
currency,
calculatorType,
baseWeight,
baseWeight: {},
dynamicWeight,
dimensionSchema,
formulaConfig: { formula },
+76 -22
View File
@@ -1,9 +1,10 @@
import { useMemo, useState, type FormEvent } from 'react';
import { Pencil, Plus, RotateCcw, Trash2 } from 'lucide-react';
import { FileText, Image, Pencil, Plus, RotateCcw, Trash2, Video } from 'lucide-react';
import type { PricingRule, PricingRuleInput, PricingRuleSet, PricingRuleSetUpsertRequest } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, FormDialog, Input, Label, Select } from '../../components/ui';
import type { LoadState } from '../../types';
import { KeyValueEditor, PricingRuleVisualEditor } from './PricingRuleVisualEditor';
import { unitLabel } from './PricingEditorControls';
import { createDefaultPricingRules, KeyValueEditor, PricingRuleVisualEditor } from './PricingRuleVisualEditor';
type PricingForm = {
ruleSetKey: string;
@@ -16,17 +17,6 @@ type PricingForm = {
rules: PricingRuleInput[];
};
const emptyForm: PricingForm = {
ruleSetKey: '',
name: '',
description: '',
category: 'custom',
currency: 'resource',
status: 'active',
metadata: {},
rules: [],
};
const categories = ['default', 'custom', 'provider', 'model'];
const statuses = ['active', 'deprecated', 'hidden'];
const currencies = ['resource', 'credit', 'cny', 'usd'];
@@ -40,7 +30,7 @@ export function PricingRulesPanel(props: {
}) {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState('');
const [form, setForm] = useState<PricingForm>(emptyForm);
const [form, setForm] = useState<PricingForm>(() => createEmptyForm());
const [localError, setLocalError] = useState('');
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
@@ -62,7 +52,7 @@ export function PricingRulesPanel(props: {
function openCreateDialog() {
setEditingId('');
setForm(emptyForm);
setForm(createEmptyForm());
setLocalError('');
setDialogOpen(true);
}
@@ -76,11 +66,15 @@ export function PricingRulesPanel(props: {
function closeDialog() {
setEditingId('');
setForm(emptyForm);
setForm(createEmptyForm());
setLocalError('');
setDialogOpen(false);
}
function updateCurrency(currency: string) {
setForm({ ...form, currency, rules: ensureRules(form.rules, currency) });
}
async function deleteRuleSet(ruleSet: PricingRuleSet) {
const confirmed = window.confirm(`确认删除定价规则 ${ruleSet.name}?已绑定的平台或模型会清空规则绑定。`);
if (!confirmed) return;
@@ -125,11 +119,14 @@ export function PricingRulesPanel(props: {
<div className="providerCatalogMeta">
<span>{ruleSet.category}</span>
<span>{ruleSet.currency}</span>
<span>{ruleSet.rules?.length ?? 0} </span>
<span>{pricingRuleSummaries(ruleSet).length} </span>
</div>
<div className="pricingRuleItems">
{(ruleSet.rules ?? []).slice(0, 5).map((rule) => (
<span key={rule.id || rule.ruleKey}>{rule.displayName || rule.resourceType}: {rule.basePrice}/{rule.unit}</span>
{pricingRuleSummaries(ruleSet).map((item) => (
<span key={item.label}>
<strong>{pricingRuleSummaryIcon(item.kind)}{item.label}</strong>
<em>{item.value}</em>
</span>
))}
</div>
<div className="providerCatalogActions">
@@ -163,7 +160,7 @@ export function PricingRulesPanel(props: {
<Label><Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Label>
<Label className="spanTwo"><Input value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Label>
<Label><Select value={form.category} onChange={(event) => setForm({ ...form, category: event.target.value })}>{categories.map((item) => <option value={item} key={item}>{item}</option>)}</Select></Label>
<Label><Select value={form.currency} onChange={(event) => setForm({ ...form, currency: event.target.value })}>{currencies.map((item) => <option value={item} key={item}>{item}</option>)}</Select></Label>
<Label><Select value={form.currency} onChange={(event) => updateCurrency(event.target.value)}>{currencies.map((item) => <option value={item} key={item}>{item}</option>)}</Select></Label>
<Label><Select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>{statuses.map((item) => <option value={item} key={item}>{item}</option>)}</Select></Label>
<PricingRuleVisualEditor currency={form.currency} rules={form.rules} onChange={(rules) => setForm({ ...form, rules })} />
<KeyValueEditor className="spanTwo" title="规则集元数据" value={form.metadata} onChange={(metadata) => setForm({ ...form, metadata })} />
@@ -172,6 +169,19 @@ export function PricingRulesPanel(props: {
);
}
function createEmptyForm(currency = 'resource'): PricingForm {
return {
ruleSetKey: '',
name: '',
description: '',
category: 'custom',
currency,
status: 'active',
metadata: {},
rules: createDefaultPricingRules(currency),
};
}
function ruleSetToForm(ruleSet: PricingRuleSet): PricingForm {
return {
ruleSetKey: ruleSet.ruleSetKey,
@@ -205,13 +215,12 @@ function ruleToInput(rule: PricingRule): PricingRuleInput {
}
function formToPayload(form: PricingForm): PricingRuleSetUpsertRequest {
const rules = form.rules.map((rule, index) => ({
const rules = ensureRules(form.rules, form.currency).map((rule, index) => ({
...rule,
displayName: rule.displayName?.trim() || `${rule.resourceType} 计价`,
priority: rule.priority ?? (index + 1) * 10,
ruleKey: rule.ruleKey?.trim() || `${rule.resourceType}_${index + 1}`,
}));
if (!rules.length) throw new Error('计价规则至少需要一条');
return {
ruleSetKey: form.ruleSetKey.trim(),
name: form.name.trim(),
@@ -223,3 +232,48 @@ function formToPayload(form: PricingForm): PricingRuleSetUpsertRequest {
rules,
};
}
type PricingRuleSummary = {
kind: 'text' | 'image' | 'video' | 'custom';
label: string;
value: string;
};
function pricingRuleSummaries(ruleSet: PricingRuleSet): PricingRuleSummary[] {
const rules = ruleSet.rules ?? [];
const textTotal = rules.find((rule) => rule.resourceType === 'text_total' || rule.ruleKey === 'text');
const textInput = rules.find((rule) => rule.resourceType === 'text_input');
const textOutput = rules.find((rule) => rule.resourceType === 'text_output');
const image = rules.find((rule) => rule.resourceType === 'image');
const video = rules.find((rule) => rule.resourceType === 'video');
const items: PricingRuleSummary[] = [];
if (textTotal || textInput || textOutput) {
const inputPrice = Number(textTotal?.formulaConfig?.inputTokenPrice ?? textInput?.basePrice ?? textTotal?.basePrice ?? 0);
const outputPrice = Number(textTotal?.formulaConfig?.outputTokenPrice ?? textOutput?.basePrice ?? 0);
items.push({ kind: 'text', label: '文本', value: `输入 ${formatPrice(inputPrice)} / 输出 ${formatPrice(outputPrice)} / 1K Token` });
}
if (image) items.push({ kind: 'image', label: '图像', value: `${formatPrice(image.basePrice)} / ${unitLabel(image.unit)}` });
if (video) items.push({ kind: 'video', label: '视频', value: `${formatPrice(video.basePrice)} / ${unitLabel(video.unit)}` });
if (items.length) return items;
return rules.slice(0, 3).map((rule) => ({
kind: 'custom',
label: rule.displayName || rule.resourceType,
value: `${formatPrice(rule.basePrice)} / ${unitLabel(rule.unit)}`,
}));
}
function pricingRuleSummaryIcon(kind: PricingRuleSummary['kind']) {
if (kind === 'image') return <Image size={14} />;
if (kind === 'video') return <Video size={14} />;
return <FileText size={14} />;
}
function formatPrice(value: number) {
if (!Number.isFinite(value)) return '0';
return Number(value.toFixed(4)).toString();
}
function ensureRules(rules: PricingRuleInput[], currency: string): PricingRuleInput[] {
const sourceRules = rules.length ? rules : createDefaultPricingRules(currency);
return sourceRules.map((rule) => ({ ...rule, currency }));
}
+37 -36
View File
@@ -1,22 +1,24 @@
@import './styles/ui.css';
@import './styles/pages.css';
@import './styles/pricing.css';
@import './styles/api-docs.css';
@import './styles/landing.css';
:root {
--background: #f6f7f9;
--foreground: #111827;
--background: #f7f8fa;
--foreground: #101318;
--card: #ffffff;
--card-foreground: #111827;
--muted: #eef2f6;
--muted-foreground: #667085;
--border: #dde3ea;
--input: #cfd8e3;
--primary: #145388;
--card-foreground: #101318;
--muted: #f2f4f7;
--muted-foreground: #6b7280;
--border: #e4e7ec;
--input: #d8dee6;
--primary: #111827;
--primary-foreground: #ffffff;
--secondary: #edf2f7;
--secondary-foreground: #1f2937;
--secondary: #f4f6f8;
--secondary-foreground: #202734;
--destructive: #b42318;
--ring: #2f80c1;
--ring: #64748b;
color: var(--foreground);
background: var(--background);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
@@ -90,8 +92,8 @@ h1 {
width: 38px;
height: 38px;
place-items: center;
border-radius: 8px;
background: #145388;
border-radius: 10px;
background: #111827;
color: #fff;
font-size: 13px;
font-weight: 900;
@@ -153,10 +155,9 @@ h1 {
align-items: center;
gap: 2px;
padding: 3px;
border: 1px solid var(--border);
border: 1px solid #e7ebf0;
border-radius: 999px;
background: #f5f7fa;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
background: rgba(255, 255, 255, 0.72);
}
.topNavItem {
@@ -168,7 +169,7 @@ h1 {
border: 0;
border-radius: 999px;
background: transparent;
color: #475467;
color: #5d6675;
font-size: 14px;
font-weight: 800;
white-space: nowrap;
@@ -177,8 +178,8 @@ h1 {
.topNavItem:hover,
.topNavItem[data-active="true"] {
background: #ffffff;
color: #145388;
box-shadow: 0 1px 4px rgba(16, 24, 40, 0.12);
color: #111827;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.08);
}
.workspaceShell {
@@ -196,8 +197,8 @@ h1 {
gap: 16px;
padding: 0 28px;
border-bottom: 1px solid var(--border);
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 1px 10px rgba(16, 24, 40, 0.04);
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 1px 0 rgba(16, 24, 40, 0.03);
backdrop-filter: blur(12px);
}
@@ -262,10 +263,10 @@ h1 {
.notice,
.inlineNotice {
padding: 12px 14px;
border: 1px solid #f0b8b8;
border: 1px solid #f0d4d4;
border-radius: 8px;
background: #fff1f1;
color: #9b2c2c;
background: #fff7f7;
color: #9f2f2f;
font-size: 14px;
}
@@ -301,17 +302,16 @@ h1 {
min-height: 92px;
padding: 15px;
border: 1px solid var(--border);
border-top: 3px solid #64748b;
border-radius: 8px;
border-radius: 10px;
background: var(--card);
}
.statCard[data-tone="blue"] { border-top-color: #145388; }
.statCard[data-tone="green"] { border-top-color: #14805e; }
.statCard[data-tone="violet"] { border-top-color: #7048b8; }
.statCard[data-tone="amber"] { border-top-color: #b7791f; }
.statCard[data-tone="cyan"] { border-top-color: #087f8c; }
.statCard[data-tone="rose"] { border-top-color: #b8325f; }
.statCard[data-tone="blue"] { border-color: #d8e0ea; }
.statCard[data-tone="green"] { border-color: #d7e7df; }
.statCard[data-tone="violet"] { border-color: #e0dce9; }
.statCard[data-tone="amber"] { border-color: #ebe0cc; }
.statCard[data-tone="cyan"] { border-color: #d5e4e8; }
.statCard[data-tone="rose"] { border-color: #ead8df; }
.statCard span {
color: var(--muted-foreground);
@@ -336,9 +336,10 @@ h1 {
width: 38px;
height: 38px;
place-items: center;
border-radius: 8px;
background: #eaf2f8;
color: #145388;
border: 1px solid var(--border);
border-radius: 10px;
background: #fff;
color: #111827;
}
.infoItem,
@@ -348,7 +349,7 @@ h1 {
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fbfcfe;
background: #fafbfc;
}
@media (max-width: 980px) {
+188
View File
@@ -0,0 +1,188 @@
.apiDocsShell {
display: grid;
min-height: calc(100vh - 120px);
grid-template-columns: 280px minmax(420px, 1fr) 360px;
gap: 0;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 12px;
background: #fff;
}
.docsSidebar,
.docsArticle,
.docsRunner {
min-height: 0;
overflow: auto;
}
.docsSidebar {
padding: 18px;
border-right: 1px solid var(--border);
}
.docsBrand,
.docsSearch {
display: flex;
align-items: center;
gap: 10px;
}
.docsBrand {
margin-bottom: 18px;
font-size: 17px;
}
.docsSearch {
min-height: 40px;
padding: 0 12px;
border: 1px solid var(--border);
border-radius: 8px;
color: var(--muted-foreground);
}
.docsSearch input {
width: 100%;
border: 0;
outline: 0;
}
.docsGroup {
display: grid;
gap: 6px;
margin-top: 22px;
}
.docsGroup h3 {
margin-bottom: 4px;
color: var(--muted-foreground);
font-size: 13px;
}
.docsGroup button {
display: flex;
min-height: 34px;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 10px;
border: 0;
border-radius: 7px;
background: transparent;
color: #344054;
text-align: left;
}
.docsGroup button[data-active="true"] {
background: #f5f6f8;
color: #111827;
}
.docsArticle {
padding: 28px 34px 60px;
background: #fff;
}
.docsArticle h1 {
margin-bottom: 20px;
}
.endpointBar,
.runnerEndpoint {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
border: 1px solid var(--border);
border-radius: 10px;
background: #fff;
}
.docsLead {
margin: 18px 0 24px;
color: #475467;
line-height: 1.7;
}
.paramCard {
margin-top: 18px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 12px;
background: #fff;
}
.paramCard header,
.docsRunner header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.paramRow {
display: grid;
grid-template-columns: 150px 110px minmax(0, 1fr) 50px;
gap: 12px;
padding: 13px 16px;
border-bottom: 1px solid #f0f2f5;
color: #475467;
font-size: 13px;
}
.paramRow em {
color: #a16207;
font-style: normal;
}
.docsRunner {
border-left: 1px solid var(--border);
background: #fff;
}
.docsRunner form {
display: grid;
gap: 14px;
padding: 0 16px 16px;
}
.runnerResult {
display: grid;
gap: 12px;
padding: 16px;
border-top: 1px solid var(--border);
}
.runnerResult pre {
overflow: auto;
max-height: 300px;
margin: 0;
padding: 12px;
border-radius: 8px;
background: #f7f8fa;
font-size: 12px;
}
@media (max-width: 1180px) {
.apiDocsShell {
grid-template-columns: 240px minmax(0, 1fr);
}
.docsRunner {
grid-column: 1 / -1;
border-top: 1px solid var(--border);
border-left: 0;
}
}
@media (max-width: 860px) {
.apiDocsShell {
grid-template-columns: 1fr;
}
.paramRow {
grid-template-columns: 1fr;
}
}
+14 -16
View File
@@ -9,10 +9,10 @@
align-items: center;
gap: 12px;
padding: 10px 16px;
border: 1px solid #d7eadf;
border-radius: 10px;
background: #f1fbf5;
color: #155e3d;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
color: #202734;
}
.releaseNotice strong {
@@ -20,7 +20,7 @@
}
.releaseNotice span {
color: #43735b;
color: var(--muted-foreground);
font-size: 13px;
}
@@ -32,10 +32,8 @@
align-items: center;
padding: 44px;
border: 1px solid var(--border);
border-radius: 12px;
background:
linear-gradient(120deg, rgba(245, 250, 255, 0.96), rgba(255, 255, 255, 0.9)),
#ffffff;
border-radius: 14px;
background: #ffffff;
}
.landingCopy {
@@ -66,10 +64,10 @@
display: grid;
gap: 16px;
padding: 18px;
border: 1px solid #dbe5ef;
border-radius: 10px;
background: #f8fafc;
box-shadow: 0 18px 45px rgba(16, 24, 40, 0.10);
border: 1px solid var(--border);
border-radius: 12px;
background: #fafbfc;
box-shadow: 0 20px 60px rgba(16, 24, 40, 0.08);
}
.previewHeader,
@@ -98,7 +96,7 @@
min-height: 92px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 8px;
border-radius: 10px;
background: #fff;
}
@@ -114,8 +112,8 @@
.previewFlow {
padding: 14px;
border-radius: 8px;
background: #102033;
border-radius: 10px;
background: #111827;
color: #fff;
font-size: 12px;
font-weight: 800;
+26 -379
View File
@@ -15,7 +15,9 @@
display: grid;
gap: 6px;
padding: 8px;
background: #fff;
border: 1px solid var(--border);
border-radius: 10px;
background: rgba(255, 255, 255, 0.72);
}
.sideTabs .shTab {
@@ -58,7 +60,7 @@
min-height: 28px;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 6px;
border-radius: 999px;
background: #fff;
color: #344054;
font-size: 12px;
@@ -66,8 +68,8 @@
}
.filterChip[data-active="true"] {
border-color: #2f6fe4;
background: #2f6fe4;
border-color: #111827;
background: #111827;
color: #fff;
}
@@ -109,9 +111,9 @@
align-items: center;
gap: 14px;
padding: 18px;
border: 1px solid #ebe5fb;
border-radius: 8px;
background: #f6f1ff;
border: 1px solid var(--border);
border-radius: 10px;
background: #fff;
}
.providerHero strong {
@@ -130,7 +132,7 @@
width: 42px;
height: 42px;
place-items: center;
border-radius: 8px;
border-radius: 10px;
background: #fff;
color: #111827;
font-weight: 900;
@@ -193,9 +195,10 @@
.modelTags span {
padding: 4px 7px;
border-radius: 5px;
background: #fff5e7;
color: #9a5b12;
border: 1px solid #eceff3;
border-radius: 999px;
background: #fafbfc;
color: #5d6675;
font-size: 12px;
}
@@ -208,7 +211,7 @@
}
.modelCardFooter a {
color: #2563eb;
color: #111827;
font-size: 12px;
font-weight: 800;
text-decoration: none;
@@ -247,7 +250,7 @@
align-items: center;
padding: 16px;
border: 1px solid var(--border);
border-radius: 8px;
border-radius: 10px;
background: #fff;
}
@@ -257,9 +260,9 @@
height: 54px;
place-items: center;
overflow: hidden;
border: 1px solid #edf1f5;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #eef1f4;
border-radius: 10px;
background: #fafbfc;
color: #111827;
font-weight: 900;
}
@@ -323,7 +326,7 @@
min-height: 184px;
padding: 16px;
border: 1px solid var(--border);
border-radius: 8px;
border-radius: 10px;
background: #fff;
}
@@ -359,9 +362,10 @@
.modelAbilityChips span {
padding: 4px 7px;
border-radius: 5px;
background: #eef7ff;
color: #0f5d8f;
border: 1px solid #e8ecf1;
border-radius: 999px;
background: #fafbfc;
color: #4b5563;
font-size: 12px;
font-weight: 800;
}
@@ -376,368 +380,17 @@
font-size: 12px;
}
.pricingRuleGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.pricingRuleCard {
display: grid;
gap: 12px;
padding: 16px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
}
.pricingRuleCard header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.pricingRuleCard strong,
.pricingRuleCard span {
display: block;
}
.pricingRuleCard header span,
.pricingRuleCard p {
margin: 0;
color: var(--muted-foreground);
font-size: 12px;
}
.pricingRuleItems {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pricingRuleItems span {
padding: 5px 7px;
border-radius: 5px;
background: #f1f5f9;
color: #334155;
font-size: 12px;
font-weight: 800;
}
.pricingRuleDialog {
width: min(1120px, 100%);
}
.pricingRuleFormBody {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.pricingRuleEditor {
display: grid;
gap: 12px;
}
.pricingRuleEditorHeader,
.pricingRuleEditorCard header,
.pricingRuleSubHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.pricingRuleEditorHeader strong,
.pricingRuleEditorCard strong,
.pricingRuleSubHeader strong {
display: block;
font-size: 14px;
}
.pricingRuleEditorHeader span,
.pricingRuleEditorCard header span,
.pricingRuleSubHeader span,
.pricingRuleEmpty span,
.mutedLine {
color: var(--muted-foreground);
font-size: 12px;
}
.pricingRuleQuickActions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pricingRuleEditorCard,
.pricingRuleSubPanel,
.pricingRuleEmpty {
display: grid;
gap: 12px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fbfcfe;
}
.pricingRuleEditorCard {
background: #fff;
}
.pricingRuleEditorCard header > div {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.pricingRuleEditorCard header span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pricingRuleFields {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.dimensionChips {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.dimensionChips button {
min-height: 30px;
padding: 0 9px;
border: 1px solid var(--border);
border-radius: 999px;
background: #fff;
color: #475467;
font-size: 12px;
font-weight: 800;
}
.dimensionChips button.active {
border-color: #145388;
background: #eaf3fb;
color: #145388;
}
.weightGroup {
display: grid;
gap: 10px;
padding: 10px;
border: 1px dashed var(--border);
border-radius: 8px;
background: #fff;
}
.keyValueRows {
display: grid;
gap: 8px;
}
.keyValueRow {
display: grid;
grid-template-columns: minmax(120px, 0.55fr) minmax(140px, 1fr) 34px;
gap: 8px;
align-items: center;
}
.apiDocsShell {
display: grid;
min-height: calc(100vh - 120px);
grid-template-columns: 280px minmax(420px, 1fr) 360px;
gap: 0;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 10px;
background: #fff;
}
.docsSidebar,
.docsArticle,
.docsRunner {
min-height: 0;
overflow: auto;
}
.docsSidebar {
padding: 18px;
border-right: 1px solid var(--border);
}
.docsBrand,
.docsSearch {
display: flex;
align-items: center;
gap: 10px;
}
.docsBrand {
margin-bottom: 18px;
font-size: 17px;
}
.docsSearch {
min-height: 40px;
padding: 0 12px;
border: 1px solid var(--border);
border-radius: 8px;
color: var(--muted-foreground);
}
.docsSearch input {
width: 100%;
border: 0;
outline: 0;
}
.docsGroup {
display: grid;
gap: 6px;
margin-top: 22px;
}
.docsGroup h3 {
margin-bottom: 4px;
color: var(--muted-foreground);
font-size: 13px;
}
.docsGroup button {
display: flex;
min-height: 34px;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 10px;
border: 0;
border-radius: 7px;
background: transparent;
color: #344054;
text-align: left;
}
.docsGroup button[data-active="true"] {
background: #f0e9ff;
color: #7048e8;
}
.docsArticle {
padding: 28px 34px 60px;
background: linear-gradient(90deg, #fff 0%, #fbf8ff 100%);
}
.docsArticle h1 {
margin-bottom: 20px;
}
.endpointBar,
.runnerEndpoint {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
}
.docsLead {
margin: 18px 0 24px;
color: #475467;
line-height: 1.7;
}
.paramCard {
margin-top: 18px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 10px;
background: #fff;
}
.paramCard header,
.docsRunner header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.paramRow {
display: grid;
grid-template-columns: 150px 110px minmax(0, 1fr) 50px;
gap: 12px;
padding: 13px 16px;
border-bottom: 1px solid #f0f2f5;
color: #475467;
font-size: 13px;
}
.paramRow em {
color: #f97316;
font-style: normal;
}
.docsRunner {
border-left: 1px solid var(--border);
background: #fff;
}
.docsRunner form {
display: grid;
gap: 14px;
padding: 0 16px 16px;
}
.runnerResult {
display: grid;
gap: 12px;
padding: 16px;
border-top: 1px solid var(--border);
}
.runnerResult pre {
overflow: auto;
max-height: 300px;
margin: 0;
padding: 12px;
border-radius: 8px;
background: #f8fafc;
font-size: 12px;
}
@media (max-width: 1180px) {
.modelCards,
.providerCatalogGrid,
.baseModelGrid,
.pricingRuleGrid {
.baseModelGrid {
grid-template-columns: repeat(2, minmax(240px, 1fr));
}
.apiDocsShell {
grid-template-columns: 240px minmax(0, 1fr);
}
.docsRunner {
grid-column: 1 / -1;
border-top: 1px solid var(--border);
border-left: 0;
}
}
@media (max-width: 860px) {
.subPageLayout,
.modelsPage,
.apiDocsShell {
.modelsPage {
grid-template-columns: 1fr;
}
@@ -749,9 +402,6 @@
.modelCards,
.providerCatalogGrid,
.baseModelGrid,
.pricingRuleGrid,
.pricingRuleFields,
.pricingRuleFormBody,
.modelCatalogFilters {
grid-template-columns: 1fr;
}
@@ -770,7 +420,4 @@
justify-content: flex-start;
}
.paramRow {
grid-template-columns: 1fr;
}
}
+302
View File
@@ -0,0 +1,302 @@
.pricingRuleGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.pricingRuleCard {
display: grid;
gap: 12px;
padding: 16px;
border: 1px solid var(--border);
border-radius: 10px;
background: #fff;
}
.pricingRuleCard header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.pricingRuleCard strong,
.pricingRuleCard span {
display: block;
}
.pricingRuleCard header span,
.pricingRuleCard p {
margin: 0;
color: var(--muted-foreground);
font-size: 12px;
}
.pricingRuleItems {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 8px;
}
.pricingRuleItems span {
display: grid;
gap: 4px;
min-height: 56px;
padding: 10px;
border-radius: 8px;
border: 1px solid #e8ecf1;
background: #fafbfc;
color: #4b5563;
font-size: 12px;
}
.pricingRuleItems strong {
display: flex;
align-items: center;
gap: 6px;
color: #111827;
font-size: 13px;
}
.pricingRuleItems svg {
color: #6b7280;
}
.pricingRuleItems em {
color: var(--muted-foreground);
font-style: normal;
line-height: 1.35;
}
.pricingRuleDialog {
width: min(1120px, 100%);
}
.pricingRuleFormBody {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.pricingModeEditor,
.pricingModeRule,
.pricingWeightStack {
display: grid;
gap: 14px;
}
.pricingModeHeader,
.pricingModeRule header,
.pricingWeightTable header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.pricingModeHeader strong,
.pricingModeRule strong,
.pricingWeightTable strong {
display: block;
font-size: 14px;
}
.pricingModeHeader span,
.pricingModeRule header span {
color: var(--muted-foreground);
font-size: 12px;
}
.pricingModeTabs {
display: flex;
flex-wrap: wrap;
gap: 18px;
border-bottom: 1px solid var(--border);
}
.pricingModeTabs button {
min-height: 40px;
padding: 0 2px;
border: 0;
border-bottom: 3px solid transparent;
background: transparent;
color: #475467;
font-size: 14px;
font-weight: 800;
}
.pricingModeTabs button.active {
border-bottom-color: #111827;
color: #111827;
}
.pricingModeRule,
.pricingModeEmpty {
display: grid;
gap: 12px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: #fff;
}
.pricingModeEmpty {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
}
.pricingModeRule header > div {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.pricingModeRule header span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pricingModeBaseRows {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.pricingModeBaseRows.compact {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.pricingModeInlineRows {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.pricingTextPriceGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.pricingModeInlineInput,
.pricingInlineField {
display: grid;
grid-template-columns: 140px minmax(0, 1fr);
overflow: hidden;
border: 1px solid var(--border);
border-radius: 10px;
background: #fff;
}
.pricingModeInlineInput span,
.pricingInlineField span {
display: flex;
align-items: center;
padding: 0 10px;
border-right: 1px solid var(--border);
background: #fafbfc;
color: #344054;
font-size: 12px;
font-weight: 700;
line-height: 1.35;
}
.pricingModeInlineInput input,
.pricingModeInlineInput select,
.pricingInlineField input,
.pricingInlineField select {
border: 0;
border-radius: 0;
}
.pricingInlineReadonly strong {
display: flex;
min-height: 40px;
align-items: center;
padding: 0 12px;
color: #111827;
font-size: 13px;
}
.pricingFormulaBox {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
gap: 10px;
padding: 12px;
border-radius: 10px;
background: #f6f7f9;
color: #344054;
}
.pricingFormulaBox p {
margin-top: 8px;
color: var(--muted-foreground);
font-size: 13px;
font-weight: 700;
}
.pricingFormulaBox input {
margin-top: 8px;
}
.pricingWeightTable {
display: grid;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 10px;
background: #fff;
}
.pricingWeightTable header {
min-height: 36px;
padding: 0 12px;
background: #f6f7f9;
}
.pricingWeightRows {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 9px;
}
.pricingWeightRow {
display: grid;
grid-template-columns: minmax(100px, 150px) minmax(82px, 120px) 32px;
gap: 6px;
align-items: center;
}
.pricingWeightRow.locked {
grid-template-columns: minmax(112px, 150px) minmax(82px, 120px);
}
.pricingFactorLabel {
display: flex;
min-height: 34px;
align-items: center;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fafbfc;
color: #344054;
font-size: 13px;
font-weight: 700;
}
.pricingInlineAdd {
border-style: dashed;
}
@media (max-width: 860px) {
.pricingRuleGrid,
.pricingModeBaseRows,
.pricingRuleFormBody,
.pricingModeInlineRows,
.pricingModeInlineInput,
.pricingInlineField,
.pricingTextPriceGrid {
grid-template-columns: 1fr;
}
}
+35 -20
View File
@@ -1,7 +1,7 @@
.shCard {
overflow: hidden;
border: 1px solid var(--border);
border-radius: 8px;
border-radius: 10px;
background: var(--card);
color: var(--card-foreground);
}
@@ -18,7 +18,7 @@
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid #edf1f5;
border-bottom: 1px solid #eef1f4;
}
.shCardTitle {
@@ -39,9 +39,10 @@
justify-content: center;
gap: 8px;
border: 1px solid transparent;
border-radius: 8px;
border-radius: 7px;
font-weight: 800;
line-height: 1;
transition: background 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, color 0.16s ease;
}
.shButtonDefaultSize {
@@ -65,6 +66,10 @@
color: var(--primary-foreground);
}
.shButtonDefault:hover {
background: #202734;
}
.shButtonSecondary {
background: var(--secondary);
color: var(--secondary-foreground);
@@ -76,11 +81,21 @@
color: #344054;
}
.shButtonOutline:hover,
.shButtonSecondary:hover {
border-color: #d5dbe3;
background: #f9fafb;
}
.shButtonGhost {
background: transparent;
color: #344054;
}
.shButtonGhost:hover {
background: #f5f6f8;
}
.shButtonDestructive {
background: var(--destructive);
color: #fff;
@@ -93,7 +108,7 @@
display: grid;
place-items: center;
padding: 24px;
background: rgba(15, 23, 42, 0.42);
background: rgba(16, 19, 24, 0.36);
}
.formDialog {
@@ -103,9 +118,9 @@
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
border: 1px solid var(--border);
border-radius: 10px;
border-radius: 12px;
background: #fff;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.22);
box-shadow: 0 22px 60px rgba(16, 24, 40, 0.16);
}
.formDialogHeader {
@@ -154,14 +169,14 @@
padding: 14px 18px;
border-top: 1px solid var(--border);
background: #fff;
box-shadow: 0 -10px 24px rgba(15, 23, 42, 0.06);
box-shadow: 0 -1px 0 rgba(16, 24, 40, 0.02);
}
.shInput,
.shTextarea {
width: 100%;
border: 1px solid var(--input);
border-radius: 8px;
border-radius: 7px;
background: #fff;
color: var(--foreground);
outline: none;
@@ -182,7 +197,7 @@
.shTextarea:focus,
.tokenInline input:focus {
border-color: var(--ring);
box-shadow: 0 0 0 3px rgba(47, 128, 193, 0.16);
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.14);
}
.shLabel,
@@ -212,7 +227,7 @@
padding: 4px;
border: 1px solid var(--border);
border-radius: 8px;
background: #f8fafc;
background: #f6f7f9;
}
.shTab {
@@ -230,8 +245,8 @@
.shTab[data-active="true"] {
background: #fff;
color: #145388;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.08);
color: #111827;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.06);
}
.shBadge {
@@ -246,12 +261,12 @@
font-weight: 900;
}
.shBadgeDefault { background: #eaf2f8; color: #145388; }
.shBadgeSecondary { background: #eef2f6; color: #475467; }
.shBadgeDefault { background: #eef1f5; color: #202734; }
.shBadgeSecondary { background: #f3f5f7; color: #5d6675; }
.shBadgeOutline { border-color: var(--border); background: #fff; color: #344054; }
.shBadgeSuccess { background: #e8f5ef; color: #14805e; }
.shBadgeWarning { background: #fff6df; color: #98690c; }
.shBadgeDestructive { background: #fff1f1; color: #b42318; }
.shBadgeSuccess { background: #edf7f1; color: #157a57; }
.shBadgeWarning { background: #fbf4e8; color: #8a6116; }
.shBadgeDestructive { background: #fff3f3; color: #b42318; }
.shTable {
overflow: auto;
@@ -261,11 +276,11 @@
display: grid;
grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
min-width: 520px;
border-bottom: 1px solid #edf1f5;
border-bottom: 1px solid #eef1f4;
}
.shTableHeader {
background: #f8fafc;
background: #f7f8fa;
}
.shTableHead,
@@ -305,7 +320,7 @@
overflow: auto;
border: 1px solid var(--border);
border-radius: 8px;
background: #f8fafc;
background: #f7f8fa;
color: #1f2937;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 12px;