新增:价格管理(计价配置管理)页面与 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:
2026-09-01 11:47:37 +08:00
co-authored by Claude Code
parent c2244f50b1
commit bdbf989b5e
7 changed files with 2091 additions and 0 deletions
+35
View File
@@ -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_periodperiod 固定 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>
+449
View File
@@ -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>
+321
View File
@@ -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 },
],
},
],
};