新增:模型计价编辑器适配新计费结构(mediaPrices媒体价格模板、thinking/outputAudio/outputResolution中文展示与下拉)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-01 15:09:15 +08:00
co-authored by Claude Code
parent bdbf989b5e
commit fbe2bcfee6
2 changed files with 193 additions and 49 deletions
+122 -23
View File
@@ -43,37 +43,36 @@
<div class="rc-sec match">命中条件 match</div>
<el-form label-position="top" size="small">
<div class="rc-grid">
<el-form-item label="媒体类型">
<el-select v-model="r.mediaType" class="rc-control">
<el-option v-for="o in MEDIA_TYPE_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<template v-if="subject.modelType === 'reason'">
<el-form-item label="thinking">
<el-form-item label="思考模式">
<el-select v-model="r.thinking" class="rc-control">
<el-option v-for="o in BOOL_TRIPLE_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
<el-option v-for="o in THINKING_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<el-form-item label="输入 token 下界">
<el-form-item label="输入长度下界">
<el-input-number v-model="r.inLenMin" :min="0" :precision="0" :controls="false" class="rc-control" />
</el-form-item>
<el-form-item label="输入 token 上界">
<el-form-item label="输入长度上界">
<el-input-number v-model="r.inLenMax" :min="0" :precision="0" :controls="false" class="rc-control" />
</el-form-item>
</template>
<template v-if="subject.modelType === 'video'">
<el-form-item label="outputAudio">
<el-form-item label="输出音频">
<el-select v-model="r.outputAudio" class="rc-control">
<el-option v-for="o in BOOL_TRIPLE_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
<el-option v-for="o in OUTPUT_AUDIO_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<el-form-item label="outputResolution">
<el-input v-model="r.outputResolution" placeholder="480p/720p/1080p/2k/4k" class="rc-control" />
<el-form-item label="输出分辨率">
<el-select v-model="r.outputResolution" class="rc-control">
<el-option v-for="o in RESOLUTION_OPTIONS.video" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
</template>
<template v-if="subject.modelType === 'image'">
<el-form-item label="outputResolution">
<el-input v-model="r.outputResolution" placeholder="512x512/1024x1024/2048x2048" class="rc-control" />
<el-form-item label="输出分辨率">
<el-select v-model="r.outputResolution" class="rc-control">
<el-option v-for="o in RESOLUTION_OPTIONS.image" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
</template>
</div>
@@ -100,6 +99,22 @@
</template>
</div>
</el-form>
<!-- 媒体价格模板 token 单位媒体命中条件已移除改为对特定媒体类型设独立覆盖价 mediaPrices -->
<div v-if="isTokenUnit" class="media-price-section">
<div class="rc-sec price">媒体价格模板 mediaPrices可选</div>
<div v-for="(mp, mIdx) in r.mediaPrices" :key="mIdx" class="media-price-row">
<el-select v-model="mp.mediaType" class="mp-media" placeholder="媒体类型">
<el-option v-for="o in getAvailableMediaOptions(r, mIdx)" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
<el-input-number v-model="mp.input" :min="0" :precision="6" :step="0.0001" :controls="false" class="mp-num" placeholder="input" />
<el-input-number v-model="mp.output" :min="0" :precision="6" :step="0.0001" :controls="false" class="mp-num" placeholder="output" />
<el-input-number v-model="mp.cacheHit" :min="0" :precision="6" :step="0.0001" :controls="false" class="mp-num" placeholder="cacheHit" />
<el-button type="danger" text size="small" @click="removeMediaPrice(r, mIdx)">删除</el-button>
</div>
<el-button size="small" class="media-add-btn" @click="addMediaPrice(r)">+ 添加媒体价格</el-button>
<div class="mp-tip">基础价input/output/cacheHit为默认价媒体模板仅覆盖指定媒体类型的价未选媒体类型的行保存时丢弃</div>
</div>
</div>
<el-button size="small" class="add-btn" @click="addRule">+ 添加规则</el-button>
@@ -147,14 +162,17 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import {
BOOL_TRIPLE_OPTIONS,
EXAMPLES,
MEDIA_TYPE_OPTIONS,
MEDIA_PRICE_OPTIONS,
OUTPUT_AUDIO_OPTIONS,
RESOLUTION_OPTIONS,
THINKING_OPTIONS,
UNIT_OPTIONS,
type BuiltModelRule,
type BuiltModelRuleMatch,
type Currency,
type EditorInitConfig,
type MediaPriceRow,
type ModelRule,
type ModelRules,
type Subject,
@@ -193,7 +211,6 @@ const priceNote = computed(() =>
function emptyRule(): ModelRule {
return {
name: '',
mediaType: '',
thinking: '',
outputAudio: '',
outputResolution: '',
@@ -203,6 +220,7 @@ function emptyRule(): ModelRule {
output: 0,
cacheHit: 0,
unitPrice: 0,
mediaPrices: [],
};
}
@@ -216,7 +234,6 @@ function loadExample(index: number) {
if (ex.minBalance != null) minBalance.value = ex.minBalance;
modelRules.value = ex.rules.map((r) => ({
name: r.name,
mediaType: (r.mediaType as ModelRule['mediaType']) || '',
thinking: (r.thinking == null ? '' : String(r.thinking)) as ModelRule['thinking'],
outputAudio: (r.outputAudio == null ? '' : String(r.outputAudio)) as ModelRule['outputAudio'],
outputResolution: r.outputResolution || '',
@@ -226,6 +243,7 @@ function loadExample(index: number) {
output: r.output ?? 0,
cacheHit: r.cacheHit ?? 0,
unitPrice: r.unitPrice ?? 0,
mediaPrices: mediaPricesToRows(r.mediaPrices),
}));
onUnitChange();
}
@@ -248,7 +266,6 @@ function fromBuiltRule(r: BuiltModelRule): ModelRule {
const m = r.match;
return {
name: r.name,
mediaType: (m?.mediaType as ModelRule['mediaType']) || '',
thinking: (m?.thinking == null ? '' : String(m.thinking)) as ModelRule['thinking'],
outputAudio: (m?.outputAudio == null ? '' : String(m.outputAudio)) as ModelRule['outputAudio'],
outputResolution: m?.outputResolution || '',
@@ -258,6 +275,7 @@ function fromBuiltRule(r: BuiltModelRule): ModelRule {
output: r.price.output ?? 0,
cacheHit: r.price.cacheHit ?? 0,
unitPrice: r.price.unitPrice ?? 0,
mediaPrices: mediaPricesToRows(r.mediaPrices),
};
}
@@ -277,15 +295,16 @@ function applyInit(cfg: EditorInitConfig) {
/** 过滤空白规则:规则名或任一命中/价格字段非空才保留 */
function keepRule(r: ModelRule): boolean {
if (r.name.trim()) return true;
if (r.mediaType || r.thinking || r.outputAudio || r.outputResolution) return true;
if (r.thinking || r.outputAudio || r.outputResolution) return true;
if (r.inLenMin != null || r.inLenMax != null) return true;
return r.input > 0 || r.output > 0 || r.cacheHit > 0 || r.unitPrice > 0;
if (r.input > 0 || r.output > 0 || r.cacheHit > 0 || r.unitPrice > 0) return true;
// 仅填写了媒体价格模板也算有效规则
return !!buildMediaPrices(r.mediaPrices);
}
/** 编辑器行态 → 构建态规则(match 只收录非空字段) */
function buildBuiltRule(r: ModelRule): BuiltModelRule {
const match: BuiltModelRuleMatch = {};
if (r.mediaType) match.mediaType = r.mediaType;
if (r.thinking === 'true' || r.thinking === 'false') match.thinking = r.thinking === 'true';
if (r.outputAudio === 'true' || r.outputAudio === 'false') match.outputAudio = r.outputAudio === 'true';
if (r.outputResolution) match.outputResolution = r.outputResolution;
@@ -298,9 +317,54 @@ function buildBuiltRule(r: ModelRule): BuiltModelRule {
name: r.name,
match: Object.keys(match).length ? match : undefined,
price,
mediaPrices: buildMediaPrices(r.mediaPrices),
};
}
/** 媒体价格行 → mediaPrices 对象(仅保留填写了价格的媒体;无覆盖返回 undefined 不写入) */
function buildMediaPrices(rows: MediaPriceRow[]): Record<string, Record<string, number>> | undefined {
if (!rows?.length) return undefined;
const map: Record<string, Record<string, number>> = {};
rows.forEach((row) => {
if (!row.mediaType) return;
const prices: Record<string, number> = {};
if (row.input > 0) prices.input = row.input;
if (row.output > 0) prices.output = row.output;
if (row.cacheHit > 0) prices.cacheHit = row.cacheHit;
if (Object.keys(prices).length) map[row.mediaType] = prices;
});
return Object.keys(map).length ? map : undefined;
}
/** mediaPrices 对象 → 编辑态媒体价格行(兼容后端两种结构:按媒体分组的内层对象,或空/缺失) */
function mediaPricesToRows(mediaPrices?: unknown): MediaPriceRow[] {
if (!mediaPrices || typeof mediaPrices !== 'object') return [];
return Object.entries(mediaPrices as Record<string, Record<string, number | undefined>>)
.filter(([, prices]) => prices && typeof prices === 'object')
.map(([mediaType, prices]) => ({
mediaType,
input: prices.input ?? 0,
output: prices.output ?? 0,
cacheHit: prices.cacheHit ?? 0,
}));
}
/** 添加一条媒体价格行 */
function addMediaPrice(r: ModelRule) {
r.mediaPrices.push({ mediaType: '', input: 0, output: 0, cacheHit: 0 });
}
/** 删除一条媒体价格行 */
function removeMediaPrice(r: ModelRule, index: number) {
r.mediaPrices.splice(index, 1);
}
/** 媒体价格行的可选媒体:排除本规则其他行已选中的媒体,避免同一条规则重复选择同一媒体 */
function getAvailableMediaOptions(r: ModelRule, curIndex: number) {
const selected = new Set(r.mediaPrices.filter((_, i) => i !== curIndex).map((row) => row.mediaType));
return MEDIA_PRICE_OPTIONS.filter((opt) => !selected.has(opt.value) || r.mediaPrices[curIndex]?.mediaType === opt.value);
}
/** 构建模型费率 JSON(对齐后端结构,由父组件 stringify 提交) */
function buildRules(): ModelRules {
return {
@@ -467,6 +531,41 @@ defineExpose({ save });
width: 100%;
}
/* 媒体价格模板区(token 单位):基础价下方的媒体覆盖价动态行 */
.media-price-section {
margin-top: 12px;
border-top: 1px dashed #e4e7ed;
padding-top: 10px;
}
.media-price-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.mp-media {
width: 120px;
flex-shrink: 0;
}
.mp-num {
flex: 1;
min-width: 0;
}
}
.media-add-btn {
width: 100%;
}
.mp-tip {
font-size: 12px;
color: #909399;
line-height: 1.7;
margin-top: 8px;
}
.note {
border: 1px solid #e6a23c;
background: #fdf6ec;