579 lines
18 KiB
Vue
579 lines
18 KiB
Vue
<template>
|
||
<div class="editor-body">
|
||
<div class="editor-col">
|
||
<!-- 基础计费 -->
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<span class="card-title">基础计费</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<el-form label-position="top" size="default">
|
||
<el-form-item label="计费单位 unit">
|
||
<el-select v-model="unit" class="ctrl-select" @change="onUnitChange">
|
||
<el-option v-for="o in UNIT_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="阶梯分档 tiered">
|
||
<el-switch v-model="tiered" :disabled="!isTokenUnit" />
|
||
</el-form-item>
|
||
<div class="form-hint">阶梯=按输入 token 分档,<b>命中哪档整单按哪档价(不累加)</b>。档位区间相邻不重叠(下档 max+1 = 上档 min)。仅 token 单位(per_1K / per_1M)可用。</div>
|
||
<div class="money-note">{{ priceNote }}</div>
|
||
</el-form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 计费规则 -->
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<span class="card-title">计费规则</span>
|
||
<span class="head-select">
|
||
<el-select :model-value="exampleIndex" size="small" placeholder="选择示例填充" @change="loadExample">
|
||
<el-option v-for="(e, i) in examples" :key="i" :label="e.label" :value="i" />
|
||
</el-select>
|
||
</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<div v-for="(r, i) in modelRules" :key="i" class="rule-card">
|
||
<div class="rule-head">
|
||
<span class="rule-index">#{{ i + 1 }}</span>
|
||
<el-input v-model="r.name" placeholder="规则名(如:有声-1080p-输入含视频)" class="rule-name" />
|
||
<el-button size="small" type="danger" text @click="removeRule(i)">删除</el-button>
|
||
</div>
|
||
|
||
<div class="rc-sec match">命中条件 match</div>
|
||
<el-form label-position="top" size="small">
|
||
<div class="rc-grid">
|
||
<template v-if="subject.modelType === 'reason'">
|
||
<el-form-item label="思考模式">
|
||
<el-select v-model="r.thinking" class="rc-control">
|
||
<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="输入长度下界">
|
||
<el-input-number v-model="r.inLenMin" :min="0" :precision="0" :controls="false" class="rc-control" />
|
||
</el-form-item>
|
||
<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="输出音频">
|
||
<el-select v-model="r.outputAudio" class="rc-control">
|
||
<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="输出分辨率">
|
||
<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="输出分辨率">
|
||
<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>
|
||
</el-form>
|
||
|
||
<div class="rc-sec price">计价项 price</div>
|
||
<el-form label-position="top" size="small">
|
||
<div class="rc-grid">
|
||
<template v-if="isTokenUnit">
|
||
<el-form-item label="输入单价 input">
|
||
<el-input-number v-model="r.input" :min="0" :precision="6" :step="0.0001" :controls="false" class="rc-control" />
|
||
</el-form-item>
|
||
<el-form-item label="输出单价 output">
|
||
<el-input-number v-model="r.output" :min="0" :precision="6" :step="0.0001" :controls="false" class="rc-control" />
|
||
</el-form-item>
|
||
<el-form-item label="缓存命中 cacheHit">
|
||
<el-input-number v-model="r.cacheHit" :min="0" :precision="6" :step="0.0001" :controls="false" class="rc-control" />
|
||
</el-form-item>
|
||
</template>
|
||
<template v-else>
|
||
<el-form-item label="每单位单价 unitPrice">
|
||
<el-input-number v-model="r.unitPrice" :min="0" :precision="6" :step="0.0001" :controls="false" class="rc-control" />
|
||
</el-form-item>
|
||
</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>
|
||
<div class="note mt10">
|
||
结算入参(ChargeUsage 扩展,本期备用):<code>{promptTokens, completionTokens, cachedTokens, charCount, imageCount, mediaType, thinking, outputAudio, outputResolution}</code>。匹配 = 全部条件满足的<b>首条规则</b>命中,无命中报错。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="editor-col">
|
||
<!-- 通用配置 -->
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<span class="card-title">通用配置</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<el-form label-position="top" size="default">
|
||
<el-form-item label="门禁 min_balance(元)">
|
||
<el-input-number
|
||
v-model="minBalance"
|
||
:min="0"
|
||
:precision="2"
|
||
:step="0.01"
|
||
:controls="false"
|
||
class="ctrl-input"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="币种">
|
||
<el-select v-model="currency" class="ctrl-select">
|
||
<el-option label="人民币 (CNY)" value="CNY" />
|
||
<el-option label="美元 (USD)" value="USD" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="启用">
|
||
<el-switch v-model="enabled" />
|
||
</el-form-item>
|
||
</el-form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref, watch } from 'vue';
|
||
import {
|
||
EXAMPLES,
|
||
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,
|
||
type Unit,
|
||
} from '../mock';
|
||
|
||
/**
|
||
* 模型计价编辑面板(按模型类型自适应)。
|
||
* 计费单位决定计价项(token 单位 input/output/cacheHit,非 token 单位 unitPrice);
|
||
* 命中条件 match 字段随模型类型变化(reason/video/audio/image)。
|
||
* initConfig 传入后端已保存配置(parse 后)时初始化表单;无配置时自动加载本地示例。
|
||
*/
|
||
const props = defineProps<{
|
||
subject: Subject;
|
||
initConfig: EditorInitConfig | null;
|
||
}>();
|
||
|
||
const examples = computed(() => EXAMPLES[props.subject.modelType!] ?? []);
|
||
|
||
const unit = ref<Unit>('per_second');
|
||
const tiered = ref(false);
|
||
const currency = ref<'CNY' | 'USD'>('CNY');
|
||
const enabled = ref(true);
|
||
const minBalance = ref(0);
|
||
const modelRules = ref<ModelRule[]>([]);
|
||
const exampleIndex = ref(0);
|
||
|
||
const isTokenUnit = computed(() => unit.value === 'per_1K' || unit.value === 'per_1M');
|
||
|
||
const priceNote = computed(() =>
|
||
isTokenUnit.value
|
||
? 'token 单位价格按 元/基准(per_1M=每百万 token)计:input=输入单价、output=输出单价、cacheHit=缓存命中输入单价。'
|
||
: '非 token 单位价格按 元/单位 计:unitPrice=每单位单价(秒/分钟/小时/字/张)。',
|
||
);
|
||
|
||
function emptyRule(): ModelRule {
|
||
return {
|
||
name: '',
|
||
thinking: '',
|
||
outputAudio: '',
|
||
outputResolution: '',
|
||
inLenMin: undefined,
|
||
inLenMax: undefined,
|
||
input: 0,
|
||
output: 0,
|
||
cacheHit: 0,
|
||
unitPrice: 0,
|
||
mediaPrices: [],
|
||
};
|
||
}
|
||
|
||
function loadExample(index: number) {
|
||
const ex = examples.value[index];
|
||
if (!ex) return;
|
||
exampleIndex.value = index;
|
||
unit.value = ex.unit;
|
||
tiered.value = !!ex.tiered;
|
||
if (ex.currency) currency.value = ex.currency;
|
||
if (ex.minBalance != null) minBalance.value = ex.minBalance;
|
||
modelRules.value = ex.rules.map((r) => ({
|
||
name: r.name,
|
||
thinking: (r.thinking == null ? '' : String(r.thinking)) as ModelRule['thinking'],
|
||
outputAudio: (r.outputAudio == null ? '' : String(r.outputAudio)) as ModelRule['outputAudio'],
|
||
outputResolution: r.outputResolution || '',
|
||
inLenMin: r.inLenMin == null ? undefined : r.inLenMin,
|
||
inLenMax: r.inLenMax == null ? undefined : r.inLenMax,
|
||
input: r.input ?? 0,
|
||
output: r.output ?? 0,
|
||
cacheHit: r.cacheHit ?? 0,
|
||
unitPrice: r.unitPrice ?? 0,
|
||
mediaPrices: mediaPricesToRows(r.mediaPrices),
|
||
}));
|
||
onUnitChange();
|
||
}
|
||
|
||
function onUnitChange() {
|
||
// 非 token 单位强制关闭阶梯
|
||
if (!isTokenUnit.value) tiered.value = false;
|
||
}
|
||
|
||
function addRule() {
|
||
modelRules.value.push(emptyRule());
|
||
}
|
||
|
||
function removeRule(index: number) {
|
||
modelRules.value.splice(index, 1);
|
||
}
|
||
|
||
/** 构建态规则 → 编辑器行态(parse 反向映射) */
|
||
function fromBuiltRule(r: BuiltModelRule): ModelRule {
|
||
const m = r.match;
|
||
return {
|
||
name: r.name,
|
||
thinking: (m?.thinking == null ? '' : String(m.thinking)) as ModelRule['thinking'],
|
||
outputAudio: (m?.outputAudio == null ? '' : String(m.outputAudio)) as ModelRule['outputAudio'],
|
||
outputResolution: m?.outputResolution || '',
|
||
inLenMin: m?.inputLengthMin,
|
||
inLenMax: m?.inputLengthMax,
|
||
input: r.price.input ?? 0,
|
||
output: r.price.output ?? 0,
|
||
cacheHit: r.price.cacheHit ?? 0,
|
||
unitPrice: r.price.unitPrice ?? 0,
|
||
mediaPrices: mediaPricesToRows(r.mediaPrices),
|
||
};
|
||
}
|
||
|
||
/** 用后端已保存配置初始化表单 */
|
||
function applyInit(cfg: EditorInitConfig) {
|
||
const r = cfg.rules as ModelRules;
|
||
unit.value = r.unit;
|
||
tiered.value = !!r.tiered;
|
||
currency.value = r.currency || cfg.currency;
|
||
enabled.value = cfg.enabled;
|
||
minBalance.value = cfg.minBalance;
|
||
modelRules.value = (r.rules || []).map(fromBuiltRule);
|
||
exampleIndex.value = -1;
|
||
if (!isTokenUnit.value) tiered.value = false;
|
||
}
|
||
|
||
/** 过滤空白规则:规则名或任一命中/价格字段非空才保留 */
|
||
function keepRule(r: ModelRule): boolean {
|
||
if (r.name.trim()) return true;
|
||
if (r.thinking || r.outputAudio || r.outputResolution) return true;
|
||
if (r.inLenMin != null || r.inLenMax != null) return true;
|
||
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.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;
|
||
if (r.inLenMin != null) match.inputLengthMin = r.inLenMin;
|
||
if (r.inLenMax != null) match.inputLengthMax = r.inLenMax;
|
||
const price: Record<string, number> = isTokenUnit.value
|
||
? { input: r.input ?? 0, output: r.output ?? 0, cacheHit: r.cacheHit ?? 0 }
|
||
: { unitPrice: r.unitPrice ?? 0 };
|
||
return {
|
||
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 {
|
||
unit: unit.value,
|
||
tiered: tiered.value,
|
||
currency: currency.value,
|
||
rules: modelRules.value.filter(keepRule).map(buildBuiltRule),
|
||
};
|
||
}
|
||
|
||
function save(): { rules: ModelRules; minBalance: number; currency: Currency; enabled: boolean } {
|
||
return {
|
||
rules: buildRules(),
|
||
minBalance: minBalance.value,
|
||
currency: currency.value,
|
||
enabled: enabled.value,
|
||
};
|
||
}
|
||
|
||
watch(
|
||
() => props.initConfig,
|
||
(cfg) => {
|
||
if (cfg && (cfg.rules as ModelRules).unit) {
|
||
// 有后端已保存配置 → 用它初始化
|
||
applyInit(cfg);
|
||
} else if (examples.value.length) {
|
||
// 无配置 → 自动加载该类型第一个示例(对齐原型行为)
|
||
loadExample(0);
|
||
} else {
|
||
modelRules.value = [emptyRule()];
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
defineExpose({ save });
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.editor-body {
|
||
display: flex;
|
||
gap: 20px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.editor-col {
|
||
flex: 1;
|
||
min-width: 380px;
|
||
}
|
||
|
||
.card {
|
||
border: 1px solid #e4e7ed;
|
||
border-radius: 8px;
|
||
margin-bottom: 16px;
|
||
overflow: hidden;
|
||
|
||
.card-head {
|
||
background: #fafafa;
|
||
padding: 10px 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
|
||
.card-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
}
|
||
|
||
.head-select {
|
||
flex-shrink: 0;
|
||
}
|
||
}
|
||
|
||
.card-body {
|
||
padding: 14px;
|
||
}
|
||
}
|
||
|
||
.form-hint {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.money-note {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.mt10 {
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.ctrl-input {
|
||
width: 220px;
|
||
}
|
||
|
||
.ctrl-select {
|
||
width: 220px;
|
||
}
|
||
|
||
.rule-card {
|
||
border: 1px solid #e4e7ed;
|
||
border-radius: 8px;
|
||
margin-bottom: 12px;
|
||
padding: 12px;
|
||
|
||
.rule-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 8px;
|
||
|
||
.rule-index {
|
||
color: #909399;
|
||
font-size: 12px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.rule-name {
|
||
flex: 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
.rc-sec {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
color: #909399;
|
||
margin: 10px 0 6px;
|
||
|
||
&::before {
|
||
content: '';
|
||
width: 3px;
|
||
height: 12px;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
&.match::before {
|
||
background: #e6a23c;
|
||
}
|
||
|
||
&.price::before {
|
||
background: #409eff;
|
||
}
|
||
}
|
||
|
||
.rc-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||
gap: 0 12px;
|
||
|
||
.rc-control {
|
||
width: 100%;
|
||
}
|
||
}
|
||
|
||
.add-btn {
|
||
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;
|
||
color: #b88230;
|
||
border-radius: 6px;
|
||
padding: 10px 14px;
|
||
font-size: 12.5px;
|
||
line-height: 1.7;
|
||
}
|
||
</style>
|