新增:价格管理(计价配置管理)页面与 API
- 左侧计价对象枚举列表(工作流 / 模型 / 业务模块,模型支持类型筛选与配置状态圆点) - 右侧按对象类型切换工作流 / 模型 / 业务编辑器,费率规则编辑与后端配置回显 - 新增 src/api/trade/pricing 接口(subjects / config/get / config/save) - rules 兼容对象与历史字符串两种形态;配置 id 用字符串承载避免雪花 ID 精度丢失 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import request from '/@/utils/request';
|
||||
|
||||
/** 计价对象枚举 */
|
||||
export function getSubjects() {
|
||||
return request({
|
||||
url: '/shop-user-trade/pricing/controller/subjects',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/** 计价配置详情 */
|
||||
export function getPricingConfig(params: { subjectType: string; subjectId: string }) {
|
||||
return request({
|
||||
url: '/shop-user-trade/pricing/controller/config/get',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/** 新增/更新计价配置(id > 0 更新,0 新增;rules 传费率对象;id 兼容雪花字符串避免精度丢失) */
|
||||
export function savePricingConfig(data: {
|
||||
id: string | number;
|
||||
subjectType: string;
|
||||
subjectId: string;
|
||||
rules: object;
|
||||
minBalance?: number;
|
||||
currency?: string;
|
||||
enabled?: number;
|
||||
}) {
|
||||
return request({
|
||||
url: '/shop-user-trade/pricing/controller/config/save',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div class="editor-body">
|
||||
<div class="editor-col">
|
||||
<!-- 周期订阅 -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">周期订阅</span>
|
||||
<span class="head-hint">按周期一次性收取,与用量无关</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<el-form label-position="top" size="default">
|
||||
<el-form-item label="周期 period">
|
||||
<el-select v-model="period" class="ctrl-select">
|
||||
<el-option label="year(按年)" value="year" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="每周期价格(元)">
|
||||
<el-input-number
|
||||
v-model="price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.01"
|
||||
:controls="false"
|
||||
class="ctrl-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
<div class="form-hint">本期支持按年订阅(period=year);订阅购买 / 续费流程接入另期。</div>
|
||||
</el-form>
|
||||
</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 { ref, watch } from 'vue';
|
||||
import {
|
||||
type BusinessRules,
|
||||
type Currency,
|
||||
type EditorInitConfig,
|
||||
type Subject,
|
||||
} from '../mock';
|
||||
|
||||
/**
|
||||
* 业务模块计价编辑面板。
|
||||
* 周期订阅 per_period:period 固定 year,价格 + 公共配置。
|
||||
* initConfig 传入后端已保存配置(parse 后)时初始化表单;无配置时用默认值。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
subject: Subject;
|
||||
initConfig: EditorInitConfig | null;
|
||||
}>();
|
||||
|
||||
const period = ref<'year'>('year');
|
||||
const price = ref(1999);
|
||||
const minBalance = ref(0);
|
||||
const currency = ref<Currency>('CNY');
|
||||
const enabled = ref(true);
|
||||
|
||||
/** 用后端已保存配置初始化表单 */
|
||||
function applyConfig(cfg: EditorInitConfig) {
|
||||
const r = cfg.rules as BusinessRules;
|
||||
period.value = r.period;
|
||||
price.value = r.price;
|
||||
minBalance.value = cfg.minBalance;
|
||||
currency.value = cfg.currency;
|
||||
enabled.value = cfg.enabled;
|
||||
}
|
||||
|
||||
/** 重置为默认(无配置) */
|
||||
function resetDefault() {
|
||||
period.value = 'year';
|
||||
price.value = 1999;
|
||||
minBalance.value = 0;
|
||||
currency.value = 'CNY';
|
||||
enabled.value = true;
|
||||
}
|
||||
|
||||
function save(): { rules: BusinessRules; minBalance: number; currency: Currency; enabled: boolean } {
|
||||
return {
|
||||
rules: { period: period.value, price: price.value },
|
||||
minBalance: minBalance.value,
|
||||
currency: currency.value,
|
||||
enabled: enabled.value,
|
||||
};
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.initConfig,
|
||||
(cfg) => {
|
||||
if (cfg && cfg.rules && (cfg.rules as BusinessRules).period) {
|
||||
applyConfig(cfg);
|
||||
} else {
|
||||
resetDefault();
|
||||
}
|
||||
},
|
||||
{ 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-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.ctrl-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.ctrl-select {
|
||||
width: 220px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,479 @@
|
||||
<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">
|
||||
<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-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-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="输入 token 下界">
|
||||
<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-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-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-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>
|
||||
</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>
|
||||
</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>
|
||||
</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 {
|
||||
BOOL_TRIPLE_OPTIONS,
|
||||
EXAMPLES,
|
||||
MEDIA_TYPE_OPTIONS,
|
||||
UNIT_OPTIONS,
|
||||
type BuiltModelRule,
|
||||
type BuiltModelRuleMatch,
|
||||
type Currency,
|
||||
type EditorInitConfig,
|
||||
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: '',
|
||||
mediaType: '',
|
||||
thinking: '',
|
||||
outputAudio: '',
|
||||
outputResolution: '',
|
||||
inLenMin: undefined,
|
||||
inLenMax: undefined,
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheHit: 0,
|
||||
unitPrice: 0,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
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 || '',
|
||||
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,
|
||||
}));
|
||||
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,
|
||||
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 || '',
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** 用后端已保存配置初始化表单 */
|
||||
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.mediaType || 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;
|
||||
}
|
||||
|
||||
/** 编辑器行态 → 构建态规则(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;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** 构建模型费率 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%;
|
||||
}
|
||||
|
||||
.note {
|
||||
border: 1px solid #e6a23c;
|
||||
background: #fdf6ec;
|
||||
color: #b88230;
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<div class="subject-list">
|
||||
<div v-if="loading" class="subject-loading">加载计价对象…</div>
|
||||
<template v-else>
|
||||
<!-- 模型类型筛选 -->
|
||||
<div v-if="hasModel" class="filter-bar">
|
||||
<button
|
||||
v-for="f in FILTER_OPTIONS"
|
||||
:key="f.value"
|
||||
class="filter-chip"
|
||||
:class="{ active: modelFilter === f.value }"
|
||||
@click="modelFilter = f.value"
|
||||
>
|
||||
{{ f.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 工作流 -->
|
||||
<div class="grp-head">
|
||||
<span class="grp-name">工作流</span>
|
||||
<span class="grp-count">{{ workflowSubjects.length }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="s in workflowSubjects"
|
||||
:key="`${s.type}:${s.id}`"
|
||||
class="subj-item"
|
||||
:class="{ active: `${s.type}:${s.id}` === currentKey }"
|
||||
@click="emit('select', s)"
|
||||
>
|
||||
<span class="type-badge t-workflow">工作流</span>
|
||||
<span class="subj-name" :title="s.name">{{ s.name }}</span>
|
||||
<span v-for="m in s.modes" :key="m" class="mode-chip">{{ m }}</span>
|
||||
<span
|
||||
class="status-dot"
|
||||
:class="s.hasConfig ? 'on' : 'off'"
|
||||
:title="s.hasConfig ? '已配置' : '未配置'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 模型(支持类型筛选) -->
|
||||
<div class="grp-head">
|
||||
<span class="grp-name">模型</span>
|
||||
<span class="grp-count">{{ filteredModels.length }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="s in filteredModels"
|
||||
:key="`${s.type}:${s.id}`"
|
||||
class="subj-item"
|
||||
:class="{ active: `${s.type}:${s.id}` === currentKey }"
|
||||
@click="emit('select', s)"
|
||||
>
|
||||
<span class="type-badge" :class="`t-${s.modelType}`">{{ s.typeLabel }}</span>
|
||||
<span class="subj-name" :title="s.name">{{ s.name }}</span>
|
||||
<span
|
||||
class="status-dot"
|
||||
:class="s.hasConfig ? 'on' : 'off'"
|
||||
:title="s.hasConfig ? '已配置' : '未配置'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!filteredModels.length" class="empty-tip">该类型暂无模型</div>
|
||||
|
||||
<!-- 业务模块 -->
|
||||
<div class="grp-head">
|
||||
<span class="grp-name">业务模块</span>
|
||||
<span class="grp-count">{{ businessSubjects.length }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="s in businessSubjects"
|
||||
:key="`${s.type}:${s.id}`"
|
||||
class="subj-item"
|
||||
:class="{ active: `${s.type}:${s.id}` === currentKey }"
|
||||
@click="emit('select', s)"
|
||||
>
|
||||
<span class="type-badge t-business">业务</span>
|
||||
<span class="subj-name" :title="s.name">{{ s.name }}</span>
|
||||
<span v-for="m in s.modes" :key="m" class="mode-chip">{{ m }}</span>
|
||||
<span
|
||||
class="status-dot"
|
||||
:class="s.hasConfig ? 'on' : 'off'"
|
||||
:title="s.hasConfig ? '已配置' : '未配置'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import type { ModelType, Subject } from '../mock';
|
||||
|
||||
/**
|
||||
* 左侧计价对象枚举列表。
|
||||
* 分三组渲染(工作流 / 模型 / 业务模块),模型组支持类型筛选;
|
||||
* 配置状态以实心(已配置)/ 空心(未配置)圆点呈现。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
subjects: Subject[];
|
||||
currentKey: string;
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ select: [subject: Subject] }>();
|
||||
|
||||
const workflowSubjects = computed(() => props.subjects.filter((s) => s.type === 'workflow'));
|
||||
const modelSubjects = computed(() => props.subjects.filter((s) => s.type === 'model'));
|
||||
const businessSubjects = computed(() => props.subjects.filter((s) => s.type === 'business'));
|
||||
|
||||
const hasModel = computed(() => modelSubjects.value.length > 0);
|
||||
|
||||
/** 模型类型筛选 */
|
||||
const FILTER_OPTIONS: { value: ModelType | 'all'; label: string }[] = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'reason', label: '推理' },
|
||||
{ value: 'video', label: '视频' },
|
||||
{ value: 'audio', label: '音频' },
|
||||
{ value: 'image', label: '图片' },
|
||||
];
|
||||
const modelFilter = ref<ModelType | 'all'>('all');
|
||||
const filteredModels = computed(() =>
|
||||
modelFilter.value === 'all'
|
||||
? modelSubjects.value
|
||||
: modelSubjects.value.filter((s) => s.modelType === modelFilter.value),
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.subject-list {
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 10px 16px 2px;
|
||||
|
||||
.filter-chip {
|
||||
border: 1px solid #dcdfe6;
|
||||
background: #fff;
|
||||
color: #606266;
|
||||
border-radius: 14px;
|
||||
padding: 3px 12px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
line-height: 1.4;
|
||||
|
||||
&:hover {
|
||||
color: #409eff;
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #ecf5ff;
|
||||
border-color: #409eff;
|
||||
color: #409eff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.grp-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 14px 16px 6px;
|
||||
|
||||
.grp-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #909399;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.grp-count {
|
||||
font-size: 11px;
|
||||
background: #f4f4f5;
|
||||
color: #909399;
|
||||
border-radius: 8px;
|
||||
padding: 0 6px;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.subject-loading {
|
||||
padding: 20px 16px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 10px 16px;
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subj-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 16px;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #ecf5ff;
|
||||
border-left-color: #409eff;
|
||||
}
|
||||
|
||||
.subj-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
font-size: 11px;
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.t-workflow {
|
||||
background: #8e44ad;
|
||||
}
|
||||
|
||||
&.t-reason {
|
||||
background: #e6a23c;
|
||||
}
|
||||
|
||||
&.t-video {
|
||||
background: #909399;
|
||||
}
|
||||
|
||||
&.t-audio {
|
||||
background: #67c23a;
|
||||
}
|
||||
|
||||
&.t-image {
|
||||
background: #00bcd4;
|
||||
}
|
||||
|
||||
&.t-business {
|
||||
background: #e74c3c;
|
||||
}
|
||||
}
|
||||
|
||||
.mode-chip {
|
||||
font-size: 11px;
|
||||
background: #f4f4f5;
|
||||
color: #606266;
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.on {
|
||||
background: #67c23a;
|
||||
box-shadow: 0 0 0 2px rgba(103, 194, 58, 0.2);
|
||||
}
|
||||
|
||||
&.off {
|
||||
background: #fff;
|
||||
border: 1px solid #c0c4cc;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,334 @@
|
||||
<template>
|
||||
<div class="editor-body">
|
||||
<div class="editor-col">
|
||||
<!-- 计费模式 -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">计费模式</span>
|
||||
<span class="head-hint">可同时开启多套计费</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- 按条计费 per_item -->
|
||||
<div class="mode-item">
|
||||
<div class="mode-item-head">
|
||||
<span class="mode-name">按条计费 <span class="mode-code">per_item</span></span>
|
||||
<el-switch v-model="modes.per_item" />
|
||||
</div>
|
||||
<div v-if="modes.per_item" class="mode-item-body">
|
||||
<el-form label-position="top" size="default">
|
||||
<el-form-item label="档位列表(上不封顶,命中首个 maxSec ≥ 时长 的档)">
|
||||
<div class="tier-list">
|
||||
<div v-for="(tier, i) in tiers" :key="i" class="tier-row">
|
||||
<el-input-number
|
||||
v-model="tier.maxSec"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
:controls="false"
|
||||
placeholder="maxSec"
|
||||
class="tier-input"
|
||||
/>
|
||||
<el-input-number
|
||||
v-model="tier.price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
placeholder="价格(元)"
|
||||
class="tier-input"
|
||||
/>
|
||||
<el-button size="small" type="danger" text @click="removeTier(i)">删除</el-button>
|
||||
</div>
|
||||
<el-button size="small" class="add-btn" @click="addTier">+ 添加档位</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="超出最大档单价(元/秒)">
|
||||
<el-input-number
|
||||
v-model="overflowUnitPrice"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.01"
|
||||
:controls="false"
|
||||
class="ctrl-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按秒计费 per_second -->
|
||||
<div class="mode-item">
|
||||
<div class="mode-item-head">
|
||||
<span class="mode-name">按秒计费 <span class="mode-code">per_second</span></span>
|
||||
<el-switch v-model="modes.per_second" />
|
||||
</div>
|
||||
<div v-if="modes.per_second" class="mode-item-body">
|
||||
<el-form label-position="top" size="default">
|
||||
<el-form-item label="单价(元/秒,不满 1 秒向上取整)">
|
||||
<el-input-number
|
||||
v-model="perSecondUnitPrice"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.01"
|
||||
:controls="false"
|
||||
class="ctrl-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按 token 计费 per_token -->
|
||||
<div class="mode-item">
|
||||
<div class="mode-item-head">
|
||||
<span class="mode-name">按 Token 计费 <span class="mode-code">per_token</span></span>
|
||||
<el-switch v-model="modes.per_token" />
|
||||
</div>
|
||||
<div v-if="modes.per_token" class="mode-item-body">
|
||||
<div class="form-hint">
|
||||
价格<b>取自各模型自己的计价配置</b>(模型列表里配的那份),不在此重复配置。未配价的模型按 0
|
||||
计。⚠️ per_token 费率不冻结在快照,结算时按模型实时价。
|
||||
</div>
|
||||
</div>
|
||||
</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 { reactive, ref, watch } from 'vue';
|
||||
import {
|
||||
WORKFLOW_DEFAULT_TIERS,
|
||||
type Currency,
|
||||
type EditorInitConfig,
|
||||
type Subject,
|
||||
type WorkflowRules,
|
||||
type WorkflowTier,
|
||||
} from '../mock';
|
||||
|
||||
/**
|
||||
* 工作流计价编辑面板。
|
||||
* 三套计费模式开关(per_item 档位表 / per_second 单价 / per_token 占位)。
|
||||
* initConfig 传入后端已保存配置(parse 后)时初始化表单;无配置时用默认档位。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
subject: Subject;
|
||||
initConfig: EditorInitConfig | null;
|
||||
}>();
|
||||
|
||||
const modes = reactive({ per_item: true, per_second: false, per_token: false });
|
||||
const tiers = ref<WorkflowTier[]>(WORKFLOW_DEFAULT_TIERS.map((t) => ({ ...t })));
|
||||
const overflowUnitPrice = ref(0.9);
|
||||
const perSecondUnitPrice = ref(0.9);
|
||||
const minBalance = ref(0);
|
||||
const currency = ref<Currency>('CNY');
|
||||
const enabled = ref(true);
|
||||
|
||||
function addTier() {
|
||||
tiers.value.push({ maxSec: 60, price: 89 });
|
||||
}
|
||||
|
||||
function removeTier(index: number) {
|
||||
tiers.value.splice(index, 1);
|
||||
}
|
||||
|
||||
/** 用后端已保存配置初始化表单 */
|
||||
function applyConfig(cfg: EditorInitConfig) {
|
||||
const r = cfg.rules as WorkflowRules;
|
||||
modes.per_item = !!r.per_item;
|
||||
modes.per_second = !!r.per_second;
|
||||
modes.per_token = !!r.per_token;
|
||||
tiers.value = r.per_item?.tiers?.map((t) => ({ ...t })) ?? WORKFLOW_DEFAULT_TIERS.map((t) => ({ ...t }));
|
||||
overflowUnitPrice.value = r.per_item?.overflowUnitPrice ?? 0.9;
|
||||
perSecondUnitPrice.value = r.per_second?.unitPrice ?? 0.9;
|
||||
minBalance.value = cfg.minBalance;
|
||||
currency.value = cfg.currency;
|
||||
enabled.value = cfg.enabled;
|
||||
}
|
||||
|
||||
/** 重置为默认(无配置) */
|
||||
function resetDefault() {
|
||||
modes.per_item = true;
|
||||
modes.per_second = false;
|
||||
modes.per_token = false;
|
||||
tiers.value = WORKFLOW_DEFAULT_TIERS.map((t) => ({ ...t }));
|
||||
overflowUnitPrice.value = 0.9;
|
||||
perSecondUnitPrice.value = 0.9;
|
||||
minBalance.value = 0;
|
||||
currency.value = 'CNY';
|
||||
enabled.value = true;
|
||||
}
|
||||
|
||||
function save(): { rules: WorkflowRules; minBalance: number; currency: Currency; enabled: boolean } {
|
||||
const rules: WorkflowRules = {};
|
||||
// 只把开启的模式写入费率 JSON
|
||||
if (modes.per_item) {
|
||||
rules.per_item = { tiers: tiers.value.filter((t) => t.maxSec > 0), overflowUnitPrice: overflowUnitPrice.value };
|
||||
}
|
||||
if (modes.per_second) rules.per_second = { unitPrice: perSecondUnitPrice.value };
|
||||
if (modes.per_token) rules.per_token = {};
|
||||
return {
|
||||
rules,
|
||||
minBalance: minBalance.value,
|
||||
currency: currency.value,
|
||||
enabled: enabled.value,
|
||||
};
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.initConfig,
|
||||
(cfg) => {
|
||||
if (cfg && cfg.rules && !('unit' in (cfg.rules as Record<string, unknown>))) {
|
||||
applyConfig(cfg);
|
||||
} else {
|
||||
resetDefault();
|
||||
}
|
||||
},
|
||||
{ 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-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.mode-item {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.mode-item-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px 6px 0 0;
|
||||
|
||||
.mode-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
||||
.mode-code {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #909399;
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mode-item-body {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.tier-list {
|
||||
.tier-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.tier-input {
|
||||
width: 130px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.ctrl-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.ctrl-select {
|
||||
width: 220px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,449 @@
|
||||
<template>
|
||||
<div class="system-price-container">
|
||||
<el-card shadow="hover" class="price-card">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="page-toolbar">
|
||||
<div class="page-toolbar-left">
|
||||
<div class="page-title">计价配置管理</div>
|
||||
<div class="page-subtitle">统一管理工作流 / 模型 / 业务模块的计费规则与门禁</div>
|
||||
</div>
|
||||
<div class="page-toolbar-actions">
|
||||
<el-button :loading="loading" :disabled="!subjects.length" @click="handleRefresh">刷新</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="!current" @click="handleSave">保存配置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="price-layout">
|
||||
<!-- 左侧:计价对象枚举 -->
|
||||
<div class="subject-panel">
|
||||
<div class="subject-head">
|
||||
<span class="subject-title">计价对象</span>
|
||||
<span class="subject-count">{{ subjects.length }} 项</span>
|
||||
</div>
|
||||
<SubjectList :subjects="subjects" :current-key="currentKey" :loading="loading" @select="onSelect" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧:配置编辑 -->
|
||||
<div class="editor-panel">
|
||||
<div class="editor-overview">
|
||||
<div class="overview-main">
|
||||
<div class="overview-title">
|
||||
{{ current?.name ?? '计价对象' }}
|
||||
<span v-if="current?.type === 'model'" class="type-tag" :class="`t-${current.modelType}`">
|
||||
{{ current.typeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="overview-meta">
|
||||
<span v-if="current" class="conf-status" :class="current.hasConfig ? 'ok' : 'no'">
|
||||
{{ current.hasConfig ? '已配置' : '未配置' }}
|
||||
</span>
|
||||
<span v-if="current && lastUpdated" class="update-time">更新于 {{ lastUpdated }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-sub">{{ headerSub }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 面板切换:v-if 每次切换重建,initConfig 驱动回显后端已保存配置 -->
|
||||
<template v-if="current">
|
||||
<WorkflowEditor
|
||||
v-if="current.type === 'workflow'"
|
||||
ref="workflowRef"
|
||||
:subject="current"
|
||||
:init-config="initConfig"
|
||||
/>
|
||||
<ModelEditor
|
||||
v-else-if="current.type === 'model'"
|
||||
ref="modelRef"
|
||||
:key="currentKey"
|
||||
:subject="current"
|
||||
:init-config="initConfig"
|
||||
/>
|
||||
<BusinessEditor
|
||||
v-else-if="current.type === 'business'"
|
||||
ref="businessRef"
|
||||
:subject="current"
|
||||
:init-config="initConfig"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="editor-empty">请选择左侧计价对象</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import SubjectList from './component/subjectList.vue';
|
||||
import WorkflowEditor from './component/workflowEditor.vue';
|
||||
import ModelEditor from './component/modelEditor.vue';
|
||||
import BusinessEditor from './component/businessEditor.vue';
|
||||
import { getPricingConfig, getSubjects, savePricingConfig } from '/@/api/trade/pricing';
|
||||
import {
|
||||
toSubject,
|
||||
type ApiSubjectItem,
|
||||
type BusinessRules,
|
||||
type Currency,
|
||||
type EditorInitConfig,
|
||||
type ModelRules,
|
||||
type Subject,
|
||||
type WorkflowRules,
|
||||
} from './mock';
|
||||
|
||||
/**
|
||||
* 价格管理(计价配置管理)页面。
|
||||
* 左侧计价对象枚举来自后端 subjects 接口,右侧按对象加载 config/get 详情并编辑;
|
||||
* 保存时把编辑器构建的费率对象 stringify 为 rules 字符串提交 config/save。
|
||||
*/
|
||||
|
||||
/** 后端 config/get 返回的配置记录(rules 为费率对象;兼容历史字符串形态) */
|
||||
interface PricingDetailRaw {
|
||||
/** 配置记录 id(雪花 id 超 JS 安全整数,用字符串承载避免精度丢失) */
|
||||
id: string | number;
|
||||
subjectType: string;
|
||||
subjectId: string;
|
||||
/** rules 为费率对象;旧接口可能返回 JSON 字符串,解析时兼容 */
|
||||
rules: string | Record<string, unknown>;
|
||||
minBalance: number;
|
||||
currency: string;
|
||||
enabled: number;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/** 编辑器 save() 的返回结构(父组件组装提交) */
|
||||
type SaveResult = {
|
||||
rules: WorkflowRules | ModelRules | BusinessRules;
|
||||
minBalance: number;
|
||||
currency: Currency;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
const subjects = ref<Subject[]>([]);
|
||||
const currentKey = ref('');
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
/** 当前配置记录 id(>0 更新,0 新增;雪花 id 用字符串承载,避免精度丢失) */
|
||||
const currentConfigId = ref<string | number>(0);
|
||||
/** 当前对象已保存配置(parse 后),传给编辑器回显;null 表示无配置 */
|
||||
const initConfig = ref<EditorInitConfig | null>(null);
|
||||
/** 当前配置最近更新时间(展示用) */
|
||||
const lastUpdated = ref('');
|
||||
|
||||
const workflowRef = ref<InstanceType<typeof WorkflowEditor>>();
|
||||
const modelRef = ref<InstanceType<typeof ModelEditor>>();
|
||||
const businessRef = ref<InstanceType<typeof BusinessEditor>>();
|
||||
|
||||
const current = computed<Subject | undefined>(() =>
|
||||
subjects.value.find((s) => `${s.type}:${s.id}` === currentKey.value),
|
||||
);
|
||||
|
||||
const headerSub = computed(() => {
|
||||
const s = current.value;
|
||||
if (!s) return '加载计价对象…';
|
||||
if (s.type === 'workflow') return `subjectType=workflow · subjectId=${s.id}`;
|
||||
if (s.type === 'model') return `subjectType=model · subjectId=${s.id}(${s.typeLabel},modelType=${s.modelType})`;
|
||||
return `subjectType=business · subjectId=${s.id}`;
|
||||
});
|
||||
|
||||
/** 详情请求序号,防止快速切换时旧响应覆盖当前对象 */
|
||||
let detailSeq = 0;
|
||||
|
||||
function onSelect(subject: Subject) {
|
||||
// 先清空 initConfig,避免 v-if 重建的编辑器拿到上一个对象的配置
|
||||
initConfig.value = null;
|
||||
currentConfigId.value = 0;
|
||||
currentKey.value = `${subject.type}:${subject.id}`;
|
||||
loadDetail(subject);
|
||||
}
|
||||
|
||||
/** 拉取某对象已保存的计价配置,填充编辑器回显并刷新"已配置"状态 */
|
||||
async function loadDetail(subject: Subject) {
|
||||
const seq = ++detailSeq;
|
||||
try {
|
||||
const res = await getPricingConfig({ subjectType: subject.type, subjectId: subject.id });
|
||||
const data = res?.data as PricingDetailRaw | undefined;
|
||||
if (data && data.rules) {
|
||||
let parsed: EditorInitConfig['rules'];
|
||||
try {
|
||||
// 后端 config/get 的 rules 为费率对象;兼容旧接口返回的 JSON 字符串形态
|
||||
parsed =
|
||||
typeof data.rules === 'string'
|
||||
? JSON.parse(data.rules)
|
||||
: (data.rules as unknown as EditorInitConfig['rules']);
|
||||
} catch {
|
||||
parsed = undefined as unknown as EditorInitConfig['rules'];
|
||||
}
|
||||
if (parsed) {
|
||||
if (seq !== detailSeq) return;
|
||||
subject.hasConfig = true;
|
||||
currentConfigId.value = data.id;
|
||||
lastUpdated.value = formatTime(data.updatedAt);
|
||||
initConfig.value = {
|
||||
id: data.id,
|
||||
minBalance: data.minBalance ?? 0,
|
||||
currency: (data.currency as Currency) || 'CNY',
|
||||
enabled: data.enabled === 1,
|
||||
rules: parsed,
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (seq !== detailSeq) return;
|
||||
subject.hasConfig = false;
|
||||
currentConfigId.value = 0;
|
||||
lastUpdated.value = '';
|
||||
initConfig.value = null;
|
||||
} catch {
|
||||
// 详情拉取失败按无配置处理(错误提示由全局拦截器负责)
|
||||
if (seq !== detailSeq) return;
|
||||
subject.hasConfig = false;
|
||||
currentConfigId.value = 0;
|
||||
lastUpdated.value = '';
|
||||
initConfig.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubjects() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getSubjects();
|
||||
const list: ApiSubjectItem[] = res?.data?.subjects ?? [];
|
||||
subjects.value = list.map(toSubject);
|
||||
// 默认选中第一个模型(对齐原型默认打开模型),无模型则第一个对象
|
||||
const first = subjects.value.find((s) => s.type === 'model') ?? subjects.value[0];
|
||||
if (first) onSelect(first);
|
||||
} catch {
|
||||
// 枚举拉取失败:全局拦截器已提示,留空列表
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 刷新枚举:保留当前选中(若对象仍存在),否则回退到第一个模型 */
|
||||
async function handleRefresh() {
|
||||
const keepKey = currentKey.value;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getSubjects();
|
||||
const list: ApiSubjectItem[] = res?.data?.subjects ?? [];
|
||||
subjects.value = list.map(toSubject);
|
||||
const kept = subjects.value.find((s) => `${s.type}:${s.id}` === keepKey);
|
||||
if (kept) {
|
||||
initConfig.value = null;
|
||||
currentConfigId.value = 0;
|
||||
lastUpdated.value = '';
|
||||
await loadDetail(kept);
|
||||
} else {
|
||||
const first = subjects.value.find((s) => s.type === 'model') ?? subjects.value[0];
|
||||
if (first) onSelect(first);
|
||||
}
|
||||
} catch {
|
||||
// 枚举拉取失败:全局拦截器已提示
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const s = current.value;
|
||||
if (!s) return;
|
||||
// v-if 保证当前对象对应编辑器已挂载,ref 非空
|
||||
let result: SaveResult;
|
||||
if (s.type === 'workflow') result = workflowRef.value!.save();
|
||||
else if (s.type === 'model') result = modelRef.value!.save();
|
||||
else result = businessRef.value!.save();
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await savePricingConfig({
|
||||
id: currentConfigId.value,
|
||||
subjectType: s.type,
|
||||
subjectId: s.id,
|
||||
rules: result.rules,
|
||||
minBalance: result.minBalance,
|
||||
currency: result.currency,
|
||||
enabled: result.enabled ? 1 : 0,
|
||||
});
|
||||
ElMessage.success('计价配置保存成功');
|
||||
s.hasConfig = true;
|
||||
// 保存后重拉详情,刷新配置 id 与回显(新增场景 id 以后端返回为准)
|
||||
await loadDetail(s);
|
||||
} catch {
|
||||
// 保存失败:错误提示由全局拦截器负责
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 时间格式化:YYYY-MM-DD HH:mm */
|
||||
function formatTime(t?: string): string {
|
||||
if (!t) return '';
|
||||
const d = new Date(t);
|
||||
if (Number.isNaN(d.getTime())) return t;
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
onMounted(loadSubjects);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.system-price-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.price-card {
|
||||
:deep(.el-card__body) {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 顶部操作栏 */
|
||||
.page-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.page-toolbar-left {
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-toolbar-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.price-layout {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.subject-panel {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid #ebeef5;
|
||||
|
||||
.subject-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 16px 12px;
|
||||
|
||||
.subject-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subject-count {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.editor-overview {
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.overview-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
.overview-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.type-tag {
|
||||
font-size: 11px;
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
color: #fff;
|
||||
font-weight: 400;
|
||||
|
||||
&.t-reason {
|
||||
background: #e6a23c;
|
||||
}
|
||||
|
||||
&.t-video {
|
||||
background: #909399;
|
||||
}
|
||||
|
||||
&.t-audio {
|
||||
background: #67c23a;
|
||||
}
|
||||
|
||||
&.t-image {
|
||||
background: #00bcd4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.overview-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.conf-status {
|
||||
font-size: 12px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
|
||||
&.ok {
|
||||
background: #f0f9eb;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
&.no {
|
||||
background: #f4f4f5;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.update-time {
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.overview-sub {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editor-empty {
|
||||
padding: 60px 0;
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* 价格管理(计价配置管理)—— 类型定义、接口映射与本地示例数据
|
||||
*
|
||||
* 说明:
|
||||
* - 计价对象枚举来自后端 GET .../subjects(见 src/api/trade/pricing/index.ts),
|
||||
* 本文件提供后端项 → 页面 Subject 的映射(中文 modelType/modelTypeCode → 英文枚举等)
|
||||
* - 计价配置读取后端的 rules JSON 字符串(JSON.parse),保存时 JSON.stringify 提交
|
||||
* - 模型类型编码:reason=100推理 / image=200图片 / audio=300音频 / video=600视频
|
||||
* - EXAMPLES 仅为编辑器本地"示例填充" UX,不参与后端数据
|
||||
*/
|
||||
|
||||
/* ================= 枚举类型 ================= */
|
||||
|
||||
export type SubjectType = 'workflow' | 'model' | 'business';
|
||||
|
||||
export type ModelType = 'reason' | 'video' | 'audio' | 'image';
|
||||
|
||||
export type Unit =
|
||||
| 'per_1M'
|
||||
| 'per_1K'
|
||||
| 'per_1'
|
||||
| 'per_second'
|
||||
| 'per_minute'
|
||||
| 'per_hour'
|
||||
| 'per_char';
|
||||
|
||||
export type Currency = 'CNY' | 'USD';
|
||||
|
||||
/* ================= 后端枚举接口 ================= */
|
||||
|
||||
/** 后端 GET .../subjects 返回的计价对象项 */
|
||||
export interface ApiSubjectItem {
|
||||
subjectType: SubjectType;
|
||||
subjectId: string;
|
||||
name: string;
|
||||
/** 仅 model:中文类型(推理/视频/音频/图片) */
|
||||
modelType?: string;
|
||||
/** 仅 model:模型类型编码(100推理/200图片/300音频/600视频) */
|
||||
modelTypeCode?: number;
|
||||
}
|
||||
|
||||
/** modelTypeCode → 英文模型类型 */
|
||||
export const MODEL_TYPE_CODE_TO_EN: Record<number, ModelType> = {
|
||||
100: 'reason',
|
||||
200: 'image',
|
||||
300: 'audio',
|
||||
600: 'video',
|
||||
};
|
||||
|
||||
/** 各 subjectType 固定的计费模式(后端不下发 modes,按类型硬编码展示) */
|
||||
export const SUBJECT_MODE_MAP: Partial<Record<SubjectType, string[]>> = {
|
||||
workflow: ['per_item', 'per_second', 'per_token'],
|
||||
business: ['per_period'],
|
||||
};
|
||||
|
||||
/** 后端枚举项 → 页面 Subject */
|
||||
export function toSubject(item: ApiSubjectItem): Subject {
|
||||
if (item.subjectType === 'model') {
|
||||
const modelType = MODEL_TYPE_CODE_TO_EN[item.modelTypeCode ?? -1] ?? 'reason';
|
||||
return {
|
||||
type: 'model',
|
||||
id: item.subjectId,
|
||||
name: item.name,
|
||||
modelType,
|
||||
modelTypeCode: item.modelTypeCode,
|
||||
typeLabel: item.modelType || modelType,
|
||||
hasConfig: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: item.subjectType,
|
||||
id: item.subjectId,
|
||||
name: item.name,
|
||||
modes: SUBJECT_MODE_MAP[item.subjectType] ?? [],
|
||||
hasConfig: false,
|
||||
};
|
||||
}
|
||||
|
||||
/* ================= 计价对象枚举(页面态) ================= */
|
||||
|
||||
export interface Subject {
|
||||
type: SubjectType;
|
||||
/** subjectId */
|
||||
id: string;
|
||||
/** 显示名称 */
|
||||
name: string;
|
||||
/** 仅 model:模型类型(英文枚举,由 modelTypeCode 映射) */
|
||||
modelType?: ModelType;
|
||||
/** 仅 model:模型类型编码 */
|
||||
modelTypeCode?: number;
|
||||
/** 仅 model:模型中文类型(推理/视频/音频/图片) */
|
||||
typeLabel?: string;
|
||||
/** workflow/business:支持的计费模式(本地硬编码) */
|
||||
modes?: string[];
|
||||
/** 是否已配置计价(选中后由 config/get 判断) */
|
||||
hasConfig: boolean;
|
||||
}
|
||||
|
||||
/* ================= 工作流费率 JSON ================= */
|
||||
|
||||
export interface WorkflowTier {
|
||||
/** 档位最大秒数 */
|
||||
maxSec: number;
|
||||
/** 档位价格(元) */
|
||||
price: number;
|
||||
}
|
||||
|
||||
export interface WorkflowRules {
|
||||
/** 按条计费 */
|
||||
per_item?: {
|
||||
tiers: WorkflowTier[];
|
||||
/** 超出最大档单价(元/秒) */
|
||||
overflowUnitPrice: number;
|
||||
};
|
||||
/** 按秒计费 */
|
||||
per_second?: {
|
||||
unitPrice: number;
|
||||
};
|
||||
/** 按 token 计费(价格取自各模型,仅占位空对象) */
|
||||
per_token?: Record<string, never>;
|
||||
}
|
||||
|
||||
/* ================= 模型费率 JSON ================= */
|
||||
|
||||
/** 编辑器内部规则行态(thinking/outputAudio 用字符串承载"未设置") */
|
||||
export interface ModelRule {
|
||||
name: string;
|
||||
mediaType: '' | 'text' | 'audio' | 'video' | 'image';
|
||||
thinking: '' | 'true' | 'false';
|
||||
outputAudio: '' | 'true' | 'false';
|
||||
outputResolution: string;
|
||||
inLenMin?: number;
|
||||
inLenMax?: number;
|
||||
/** token 单位:输入单价 */
|
||||
input: number;
|
||||
/** token 单位:输出单价 */
|
||||
output: number;
|
||||
/** token 单位:缓存命中输入单价 */
|
||||
cacheHit: number;
|
||||
/** 非 token 单位:每单位单价 */
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
/** 示例数据源规则(thinking/outputAudio 为布尔,由 loadExample 转换) */
|
||||
export interface ExampleRule {
|
||||
name: string;
|
||||
mediaType?: string;
|
||||
thinking?: boolean;
|
||||
outputAudio?: boolean;
|
||||
outputResolution?: string;
|
||||
inLenMin?: number;
|
||||
inLenMax?: number;
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheHit?: number;
|
||||
unitPrice?: number;
|
||||
}
|
||||
|
||||
/** 构建态:规则命中条件 */
|
||||
export interface BuiltModelRuleMatch {
|
||||
mediaType?: string;
|
||||
thinking?: boolean;
|
||||
outputAudio?: boolean;
|
||||
outputResolution?: string;
|
||||
inputLengthMin?: number;
|
||||
inputLengthMax?: number;
|
||||
}
|
||||
|
||||
/** 构建态:单条规则 */
|
||||
export interface BuiltModelRule {
|
||||
name: string;
|
||||
match?: BuiltModelRuleMatch;
|
||||
price: Record<string, number>;
|
||||
}
|
||||
|
||||
/** 模型费率 JSON 结构(对齐后端,含内嵌币种) */
|
||||
export interface ModelRules {
|
||||
unit: Unit;
|
||||
tiered: boolean;
|
||||
rules: BuiltModelRule[];
|
||||
currency: Currency;
|
||||
}
|
||||
|
||||
/** 模型示例 */
|
||||
export interface ModelExample {
|
||||
label: string;
|
||||
unit: Unit;
|
||||
tiered: boolean;
|
||||
currency?: Currency;
|
||||
minBalance?: number;
|
||||
rules: ExampleRule[];
|
||||
}
|
||||
|
||||
export type ModelExampleMap = Record<ModelType, ModelExample[]>;
|
||||
|
||||
/* ================= 业务模块费率 JSON ================= */
|
||||
|
||||
export interface BusinessRules {
|
||||
period: 'year';
|
||||
price: number;
|
||||
}
|
||||
|
||||
/* ================= 编辑器初始化配置 ================= */
|
||||
|
||||
/** 后端 config/get 解析后的编辑器初始化数据(公共字段 + 已 parse 的费率 JSON) */
|
||||
export interface EditorInitConfig {
|
||||
/** 配置记录 id(>0 更新,0 新增;雪花 id 超安全整数,用字符串承载避免精度丢失) */
|
||||
id: string | number;
|
||||
minBalance: number;
|
||||
currency: Currency;
|
||||
enabled: boolean;
|
||||
/** 已 parse 的费率 JSON(编辑器按 subjectType 断言具体结构) */
|
||||
rules: WorkflowRules | ModelRules | BusinessRules;
|
||||
}
|
||||
|
||||
/* ================= 常量 ================= */
|
||||
|
||||
export const WORKFLOW_DEFAULT_TIERS: WorkflowTier[] = [
|
||||
{ maxSec: 15, price: 29 },
|
||||
{ maxSec: 30, price: 49 },
|
||||
{ maxSec: 60, price: 89 },
|
||||
];
|
||||
|
||||
export const MODEL_TYPE_CODES: Record<ModelType, number> = {
|
||||
reason: 100,
|
||||
image: 200,
|
||||
audio: 300,
|
||||
video: 600,
|
||||
};
|
||||
|
||||
export const UNIT_OPTIONS: { value: Unit; label: string }[] = [
|
||||
{ value: 'per_1M', label: 'per_1M(每百万token)' },
|
||||
{ value: 'per_1K', label: 'per_1K(每千token)' },
|
||||
{ value: 'per_1', label: 'per_1(每个/每张图)' },
|
||||
{ value: 'per_second', label: 'per_second(每秒)' },
|
||||
{ value: 'per_minute', label: 'per_minute(每分钟)' },
|
||||
{ value: 'per_hour', label: 'per_hour(每小时)' },
|
||||
{ value: 'per_char', label: 'per_char(每字)' },
|
||||
];
|
||||
|
||||
export const MEDIA_TYPE_OPTIONS: { value: ModelRule['mediaType']; label: string }[] = [
|
||||
{ value: '', label: '不限制' },
|
||||
{ value: 'text', label: '文本 text' },
|
||||
{ value: 'audio', label: '音频 audio' },
|
||||
{ value: 'video', label: '视频 video' },
|
||||
{ value: 'image', label: '图片 image' },
|
||||
];
|
||||
|
||||
export const BOOL_TRIPLE_OPTIONS: { value: '' | 'true' | 'false'; label: string }[] = [
|
||||
{ value: '', label: '不限制' },
|
||||
{ value: 'true', label: '是' },
|
||||
{ value: 'false', label: '否' },
|
||||
];
|
||||
|
||||
/** 模型示例(本地填充 UX,逐字对应原型定价配置) */
|
||||
export const EXAMPLES: ModelExampleMap = {
|
||||
video: [
|
||||
{
|
||||
label: '视频·按秒(分辨率×有声/无声×输入媒体)',
|
||||
unit: 'per_second',
|
||||
tiered: false,
|
||||
rules: [
|
||||
{ name: '无声-720p-输入不含视频', mediaType: 'text', outputAudio: false, outputResolution: '720p', unitPrice: 0.8 },
|
||||
{ name: '有声-1080p-输入含视频', mediaType: 'video', outputAudio: true, outputResolution: '1080p', unitPrice: 1.5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '视频·按token(百万)',
|
||||
unit: 'per_1M',
|
||||
tiered: false,
|
||||
rules: [
|
||||
{ name: '文本输入-无声-1080p', mediaType: 'text', outputAudio: false, outputResolution: '1080p', input: 0.6, output: 3.6, cacheHit: 0.12 },
|
||||
{ name: '视频输入-有声-1080p', mediaType: 'video', outputAudio: true, outputResolution: '1080p', input: 9, output: 27, cacheHit: 1.8 },
|
||||
],
|
||||
},
|
||||
],
|
||||
reason: [
|
||||
{
|
||||
label: '推理·阶梯(思考×输入token档×输入媒体)',
|
||||
unit: 'per_1M',
|
||||
tiered: true,
|
||||
rules: [
|
||||
{ name: '思考-输入≤32000-文本', mediaType: 'text', thinking: true, inLenMax: 32000, input: 0.6, output: 3.6, cacheHit: 0.12 },
|
||||
{ name: '思考-输入32001~128000-文本', mediaType: 'text', thinking: true, inLenMin: 32001, inLenMax: 128000, input: 0.9, output: 5.4, cacheHit: 0.18 },
|
||||
{ name: '思考-输入含音频', mediaType: 'audio', thinking: true, input: 9, output: 3.6, cacheHit: 1.8 },
|
||||
{ name: '非思考-统一价', thinking: false, input: 0.3, output: 1.2, cacheHit: 0.06 },
|
||||
],
|
||||
},
|
||||
],
|
||||
audio: [
|
||||
{
|
||||
label: '音频·按字数',
|
||||
unit: 'per_char',
|
||||
tiered: false,
|
||||
rules: [{ name: '语音-统一价(万字符5元)', unitPrice: 0.0005 }],
|
||||
},
|
||||
{
|
||||
label: '音频·按分钟',
|
||||
unit: 'per_minute',
|
||||
tiered: false,
|
||||
rules: [{ name: '语音-统一价', unitPrice: 2 }],
|
||||
},
|
||||
{
|
||||
label: '音频·按token(百万)',
|
||||
unit: 'per_1M',
|
||||
tiered: false,
|
||||
rules: [{ name: '统一价', input: 500, output: 500, cacheHit: 0 }],
|
||||
},
|
||||
],
|
||||
image: [
|
||||
{
|
||||
label: '图片·按张数×分辨率',
|
||||
unit: 'per_1',
|
||||
tiered: false,
|
||||
rules: [
|
||||
{ name: '512x512', outputResolution: '512x512', unitPrice: 0.2 },
|
||||
{ name: '1024x1024', outputResolution: '1024x1024', unitPrice: 0.5 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user