Compare commits
2
Commits
bdbf989b5e
...
3f9b5e59a8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f9b5e59a8 | ||
|
|
fbe2bcfee6 |
@@ -17,6 +17,15 @@ export function getPricingConfig(params: { subjectType: string; subjectId: strin
|
||||
});
|
||||
}
|
||||
|
||||
/** 计价对象可选计费方式(workflow/business 使用;model 计价方式由配置规则决定,不调用) */
|
||||
export function getChargeModes(params: { subjectType: string; subjectId: string }) {
|
||||
return request({
|
||||
url: '/shop-user-trade/pricing/controller/subjects/charge-modes',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/** 新增/更新计价配置(id > 0 更新,0 新增;rules 传费率对象;id 兼容雪花字符串避免精度丢失) */
|
||||
export function savePricingConfig(data: {
|
||||
id: string | number;
|
||||
|
||||
@@ -203,11 +203,12 @@ export default defineComponent({
|
||||
}
|
||||
},
|
||||
})
|
||||
.then(async () => {
|
||||
.then(() => {
|
||||
// 手动退出登录也只清理登录态缓存,保留主题、语言等本地配置。
|
||||
Session.clearAuth();
|
||||
// 显式回到登录页,避免保留之前受保护页面的重定向参数
|
||||
await router.replace('/login');
|
||||
// 整页跳转登录页(刷新重建路由实例):SPA 内 router.replace 不会重置已注册的动态路由,
|
||||
// 重新登录时 setAddRoute 的 hasRoute 判断会跳过新菜单注册,出现"菜单显示但点击 404"(如价格管理页)
|
||||
window.location.href = import.meta.env.BASE_URL + '#/login';
|
||||
})
|
||||
.catch(() => {});
|
||||
} else if (path === 'wareHouse') {
|
||||
|
||||
@@ -33,6 +33,12 @@
|
||||
|
||||
<div v-show="!collapsed" class="workflow-form-scroll">
|
||||
<el-form label-position="top" class="workflow-form">
|
||||
<!-- 计费方式:必选、不设置默认值;可选项来自计价对象 charge-modes 接口(workflow) -->
|
||||
<el-form-item label="计费方式" required>
|
||||
<el-select v-model="chargeMode" :disabled="isDisabled" placeholder="请选择计费方式" class="w100">
|
||||
<el-option v-for="opt in chargeModes" :key="opt.mode" :label="opt.name" :value="opt.mode" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<template v-if="detail?.nodeInputParams">
|
||||
<template v-for="node in detail.nodeInputParams" :key="node.id || node.nodeCode">
|
||||
<template v-if="hasFormConfig(node)">
|
||||
@@ -188,6 +194,7 @@ import { ElMessage } from 'element-plus';
|
||||
import { ArrowDown, CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue';
|
||||
import PatchTemplateEditor from '/@/components/patchTemplate/PatchTemplateEditor.vue';
|
||||
import { uploadFile } from '/@/api/common/upload';
|
||||
import { getChargeModes } from '/@/api/trade/pricing';
|
||||
import { collectHomeFormFields } from '../utils/flowDsl';
|
||||
import type { WorkflowNodeStep } from '../utils/wsMessage';
|
||||
|
||||
@@ -209,7 +216,7 @@ interface Props {
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', payload: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates: any[] }): void;
|
||||
(e: 'submit', payload: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates: any[]; chargeMode: string }): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -229,6 +236,33 @@ const formFileNames = reactive<Record<string, string | string[]>>({});
|
||||
const uploadingFields = reactive<Record<string, boolean>>({});
|
||||
const templates = ref<any[]>([]);
|
||||
|
||||
// ===== 计费方式:必选、不设置默认值(新建为空强制选择;回显重跑恢复上次选择) =====
|
||||
interface ChargeModeOption {
|
||||
mode: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const chargeMode = ref('');
|
||||
const chargeModes = ref<ChargeModeOption[]>([]);
|
||||
/** 计费方式可选项缓存:workflow 计价对象固定(subjectType/subjectId 均 workflow),模块级共享避免每张卡片重复请求 */
|
||||
let workflowChargeModesCache: ChargeModeOption[] | null = null;
|
||||
|
||||
const loadChargeModes = async () => {
|
||||
if (workflowChargeModesCache) {
|
||||
chargeModes.value = workflowChargeModesCache;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res: any = await getChargeModes({ subjectType: 'workflow', subjectId: 'workflow' });
|
||||
const list: ChargeModeOption[] = res?.data?.chargeModes || [];
|
||||
chargeModes.value = list;
|
||||
workflowChargeModesCache = list;
|
||||
} catch {
|
||||
// 计费方式拉取失败:全局拦截器已提示,下拉留空(必填校验会拦截提交)
|
||||
chargeModes.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
// 状态派生:失败 = 携带 formError;禁用编辑 = 定格(done/failed)卡片默认只读,
|
||||
// 但最后一张已执行卡片(editable)允许改参;执行中(submitting)一律禁用
|
||||
const isFailed = computed(() => !!props.formError);
|
||||
@@ -435,6 +469,11 @@ const hasFormFields = computed(() => {
|
||||
});
|
||||
|
||||
const validateFormFields = (): boolean => {
|
||||
// 计费方式必选、无默认值:未选择时阻止提交(含无表单参数的工作流)
|
||||
if (!chargeMode.value) {
|
||||
ElMessage.warning('请选择计费方式');
|
||||
return false;
|
||||
}
|
||||
if (!props.detail?.nodeInputParams) return true;
|
||||
for (const node of props.detail.nodeInputParams as any[]) {
|
||||
if (String(node?.nodeCode || '').toLowerCase() !== '__start__') continue;
|
||||
@@ -462,6 +501,10 @@ watch(
|
||||
Object.keys(fieldFiles).forEach((key) => delete fieldFiles[key]);
|
||||
Object.keys(formFileNames).forEach((key) => delete formFileNames[key]);
|
||||
Object.keys(uploadingFields).forEach((key) => delete uploadingFields[key]);
|
||||
// 计费方式:新建(handleWorkflowSelect 已清空后端自带 chargeMode)为空强制选择;
|
||||
// 回显重跑卡片从 flowContent.chargeMode 恢复上次选择
|
||||
chargeMode.value = detail?.flowContent?.chargeMode || '';
|
||||
loadChargeModes();
|
||||
// 尝试从执行详情恢复贴片模板
|
||||
// 提交路径:templates 写入开启贴片布局的节点内部(patchLayout === true 的 node.templates),顶层已删除;
|
||||
// 回显时 detail.nodeInputParams 即提交时的 nodes,从该处优先恢复;兼容旧数据(extension / detail / flowContent 顶层)
|
||||
@@ -543,6 +586,7 @@ const handleSubmit = () => {
|
||||
formValues: JSON.parse(JSON.stringify(formValues)),
|
||||
formFileNames: JSON.parse(JSON.stringify(formFileNames)),
|
||||
templates: JSON.parse(JSON.stringify(templates.value || [])),
|
||||
chargeMode: chargeMode.value,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -690,6 +690,8 @@ const handleWorkflowSelect = async (workflowId: string | null, isTemplate?: bool
|
||||
removeDraftFormCard();
|
||||
const res = await getWorkflowDetail(workflowId);
|
||||
selectedWorkflowDetail.value = res.data || null;
|
||||
// 新建表单不沿用后端工作流详情自带的 chargeMode(如缺省 per_item):计费方式必选且无默认值,强制用户选择
|
||||
if (res.data?.flowContent) delete res.data.flowContent.chargeMode;
|
||||
// 对话卡片化:选中工作流即推送一张可填表单卡片进入消息流
|
||||
if (res.data) pushFormCard(res.data);
|
||||
} catch {
|
||||
@@ -1042,7 +1044,7 @@ const runWorkflow = async (
|
||||
sid: string,
|
||||
sessionId: string,
|
||||
detail: any,
|
||||
opts: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates?: any[] },
|
||||
opts: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates?: any[]; chargeMode?: string },
|
||||
formMsg: ChatMessage
|
||||
) => {
|
||||
const curSession = historyList.value.find((h) => h.id === sid);
|
||||
@@ -1119,6 +1121,8 @@ const runWorkflow = async (
|
||||
// 构建 flowContent:templates 不再放最外层(删除 detail.flowContent 可能遗留的顶层 templates,统一走节点)
|
||||
const updatedFlowContent: any = { ...detail.flowContent, nodes: nodeInputParams };
|
||||
delete updatedFlowContent.templates;
|
||||
// 计费方式写入 flowContent(与 version 平级),后端按此计费;表单必填已拦截未选,此处仅兜底
|
||||
if (opts.chargeMode) updatedFlowContent.chargeMode = opts.chargeMode;
|
||||
|
||||
// 3. 工作流模型由后端按 flow 配置决定,无需前端取模型 id(普通对话取模型见 runChat)
|
||||
|
||||
@@ -1247,7 +1251,7 @@ const snapshotTemplatesToMsg = (msg: ChatMessage, templates: any[]) => {
|
||||
|
||||
const startWorkflowFromCard = async (
|
||||
msg: ChatMessage,
|
||||
payload: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates?: any[] }
|
||||
payload: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates?: any[]; chargeMode?: string }
|
||||
) => {
|
||||
// 定位卡片所在会话
|
||||
let sid = '';
|
||||
@@ -1269,6 +1273,12 @@ const startWorkflowFromCard = async (
|
||||
if (Array.isArray(payload.templates)) {
|
||||
snapshotTemplatesToMsg(msg, payload.templates);
|
||||
}
|
||||
// 计费方式写回消息对象 flowContent(快照):会话认领(virtual→UUID 触发 MainContent 重建)/历史回显重挂载时,
|
||||
// WorkflowFormCard 从 flowContent.chargeMode 恢复本次选择,避免重挂载后计费方式被清空
|
||||
if (payload.chargeMode) {
|
||||
if (!msg.form.flowContent || typeof msg.form.flowContent !== 'object') msg.form.flowContent = {};
|
||||
msg.form.flowContent.chargeMode = payload.chargeMode;
|
||||
}
|
||||
|
||||
// 标记本轮发送中:工作流执行期间 InputBar 显示停止按钮(isGenerating 依赖 sendingSessions)
|
||||
sendingSessions[sid] = true;
|
||||
|
||||
@@ -43,37 +43,36 @@
|
||||
<div class="rc-sec match">命中条件 match</div>
|
||||
<el-form label-position="top" size="small">
|
||||
<div class="rc-grid">
|
||||
<el-form-item label="媒体类型">
|
||||
<el-select v-model="r.mediaType" class="rc-control">
|
||||
<el-option v-for="o in MEDIA_TYPE_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<template v-if="subject.modelType === 'reason'">
|
||||
<el-form-item label="thinking">
|
||||
<el-form-item label="思考模式">
|
||||
<el-select v-model="r.thinking" class="rc-control">
|
||||
<el-option v-for="o in BOOL_TRIPLE_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
||||
<el-option v-for="o in THINKING_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="输入 token 下界">
|
||||
<el-form-item label="输入长度下界">
|
||||
<el-input-number v-model="r.inLenMin" :min="0" :precision="0" :controls="false" class="rc-control" />
|
||||
</el-form-item>
|
||||
<el-form-item label="输入 token 上界">
|
||||
<el-form-item label="输入长度上界">
|
||||
<el-input-number v-model="r.inLenMax" :min="0" :precision="0" :controls="false" class="rc-control" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template v-if="subject.modelType === 'video'">
|
||||
<el-form-item label="outputAudio">
|
||||
<el-form-item label="输出音频">
|
||||
<el-select v-model="r.outputAudio" class="rc-control">
|
||||
<el-option v-for="o in BOOL_TRIPLE_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
||||
<el-option v-for="o in OUTPUT_AUDIO_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="outputResolution">
|
||||
<el-input v-model="r.outputResolution" placeholder="480p/720p/1080p/2k/4k" class="rc-control" />
|
||||
<el-form-item label="输出分辨率">
|
||||
<el-select v-model="r.outputResolution" class="rc-control">
|
||||
<el-option v-for="o in RESOLUTION_OPTIONS.video" :key="o.value" :label="o.label" :value="o.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template v-if="subject.modelType === 'image'">
|
||||
<el-form-item label="outputResolution">
|
||||
<el-input v-model="r.outputResolution" placeholder="512x512/1024x1024/2048x2048" class="rc-control" />
|
||||
<el-form-item label="输出分辨率">
|
||||
<el-select v-model="r.outputResolution" class="rc-control">
|
||||
<el-option v-for="o in RESOLUTION_OPTIONS.image" :key="o.value" :label="o.label" :value="o.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</div>
|
||||
@@ -100,6 +99,22 @@
|
||||
</template>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<!-- 媒体价格模板(仅 token 单位):媒体命中条件已移除,改为对特定媒体类型设独立覆盖价 mediaPrices -->
|
||||
<div v-if="isTokenUnit" class="media-price-section">
|
||||
<div class="rc-sec price">媒体价格模板 mediaPrices(可选)</div>
|
||||
<div v-for="(mp, mIdx) in r.mediaPrices" :key="mIdx" class="media-price-row">
|
||||
<el-select v-model="mp.mediaType" class="mp-media" placeholder="媒体类型">
|
||||
<el-option v-for="o in getAvailableMediaOptions(r, mIdx)" :key="o.value" :label="o.label" :value="o.value" />
|
||||
</el-select>
|
||||
<el-input-number v-model="mp.input" :min="0" :precision="6" :step="0.0001" :controls="false" class="mp-num" placeholder="input" />
|
||||
<el-input-number v-model="mp.output" :min="0" :precision="6" :step="0.0001" :controls="false" class="mp-num" placeholder="output" />
|
||||
<el-input-number v-model="mp.cacheHit" :min="0" :precision="6" :step="0.0001" :controls="false" class="mp-num" placeholder="cacheHit" />
|
||||
<el-button type="danger" text size="small" @click="removeMediaPrice(r, mIdx)">删除</el-button>
|
||||
</div>
|
||||
<el-button size="small" class="media-add-btn" @click="addMediaPrice(r)">+ 添加媒体价格</el-button>
|
||||
<div class="mp-tip">基础价(input/output/cacheHit)为默认价;媒体模板仅覆盖指定媒体类型的价,未选媒体类型的行保存时丢弃。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button size="small" class="add-btn" @click="addRule">+ 添加规则</el-button>
|
||||
@@ -147,14 +162,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import {
|
||||
BOOL_TRIPLE_OPTIONS,
|
||||
EXAMPLES,
|
||||
MEDIA_TYPE_OPTIONS,
|
||||
MEDIA_PRICE_OPTIONS,
|
||||
OUTPUT_AUDIO_OPTIONS,
|
||||
RESOLUTION_OPTIONS,
|
||||
THINKING_OPTIONS,
|
||||
UNIT_OPTIONS,
|
||||
type BuiltModelRule,
|
||||
type BuiltModelRuleMatch,
|
||||
type Currency,
|
||||
type EditorInitConfig,
|
||||
type MediaPriceRow,
|
||||
type ModelRule,
|
||||
type ModelRules,
|
||||
type Subject,
|
||||
@@ -193,7 +211,6 @@ const priceNote = computed(() =>
|
||||
function emptyRule(): ModelRule {
|
||||
return {
|
||||
name: '',
|
||||
mediaType: '',
|
||||
thinking: '',
|
||||
outputAudio: '',
|
||||
outputResolution: '',
|
||||
@@ -203,6 +220,7 @@ function emptyRule(): ModelRule {
|
||||
output: 0,
|
||||
cacheHit: 0,
|
||||
unitPrice: 0,
|
||||
mediaPrices: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -216,7 +234,6 @@ function loadExample(index: number) {
|
||||
if (ex.minBalance != null) minBalance.value = ex.minBalance;
|
||||
modelRules.value = ex.rules.map((r) => ({
|
||||
name: r.name,
|
||||
mediaType: (r.mediaType as ModelRule['mediaType']) || '',
|
||||
thinking: (r.thinking == null ? '' : String(r.thinking)) as ModelRule['thinking'],
|
||||
outputAudio: (r.outputAudio == null ? '' : String(r.outputAudio)) as ModelRule['outputAudio'],
|
||||
outputResolution: r.outputResolution || '',
|
||||
@@ -226,6 +243,7 @@ function loadExample(index: number) {
|
||||
output: r.output ?? 0,
|
||||
cacheHit: r.cacheHit ?? 0,
|
||||
unitPrice: r.unitPrice ?? 0,
|
||||
mediaPrices: mediaPricesToRows(r.mediaPrices),
|
||||
}));
|
||||
onUnitChange();
|
||||
}
|
||||
@@ -248,7 +266,6 @@ function fromBuiltRule(r: BuiltModelRule): ModelRule {
|
||||
const m = r.match;
|
||||
return {
|
||||
name: r.name,
|
||||
mediaType: (m?.mediaType as ModelRule['mediaType']) || '',
|
||||
thinking: (m?.thinking == null ? '' : String(m.thinking)) as ModelRule['thinking'],
|
||||
outputAudio: (m?.outputAudio == null ? '' : String(m.outputAudio)) as ModelRule['outputAudio'],
|
||||
outputResolution: m?.outputResolution || '',
|
||||
@@ -258,6 +275,7 @@ function fromBuiltRule(r: BuiltModelRule): ModelRule {
|
||||
output: r.price.output ?? 0,
|
||||
cacheHit: r.price.cacheHit ?? 0,
|
||||
unitPrice: r.price.unitPrice ?? 0,
|
||||
mediaPrices: mediaPricesToRows(r.mediaPrices),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -277,15 +295,16 @@ function applyInit(cfg: EditorInitConfig) {
|
||||
/** 过滤空白规则:规则名或任一命中/价格字段非空才保留 */
|
||||
function keepRule(r: ModelRule): boolean {
|
||||
if (r.name.trim()) return true;
|
||||
if (r.mediaType || r.thinking || r.outputAudio || r.outputResolution) return true;
|
||||
if (r.thinking || r.outputAudio || r.outputResolution) return true;
|
||||
if (r.inLenMin != null || r.inLenMax != null) return true;
|
||||
return r.input > 0 || r.output > 0 || r.cacheHit > 0 || r.unitPrice > 0;
|
||||
if (r.input > 0 || r.output > 0 || r.cacheHit > 0 || r.unitPrice > 0) return true;
|
||||
// 仅填写了媒体价格模板也算有效规则
|
||||
return !!buildMediaPrices(r.mediaPrices);
|
||||
}
|
||||
|
||||
/** 编辑器行态 → 构建态规则(match 只收录非空字段) */
|
||||
function buildBuiltRule(r: ModelRule): BuiltModelRule {
|
||||
const match: BuiltModelRuleMatch = {};
|
||||
if (r.mediaType) match.mediaType = r.mediaType;
|
||||
if (r.thinking === 'true' || r.thinking === 'false') match.thinking = r.thinking === 'true';
|
||||
if (r.outputAudio === 'true' || r.outputAudio === 'false') match.outputAudio = r.outputAudio === 'true';
|
||||
if (r.outputResolution) match.outputResolution = r.outputResolution;
|
||||
@@ -298,9 +317,54 @@ function buildBuiltRule(r: ModelRule): BuiltModelRule {
|
||||
name: r.name,
|
||||
match: Object.keys(match).length ? match : undefined,
|
||||
price,
|
||||
mediaPrices: buildMediaPrices(r.mediaPrices),
|
||||
};
|
||||
}
|
||||
|
||||
/** 媒体价格行 → mediaPrices 对象(仅保留填写了价格的媒体;无覆盖返回 undefined 不写入) */
|
||||
function buildMediaPrices(rows: MediaPriceRow[]): Record<string, Record<string, number>> | undefined {
|
||||
if (!rows?.length) return undefined;
|
||||
const map: Record<string, Record<string, number>> = {};
|
||||
rows.forEach((row) => {
|
||||
if (!row.mediaType) return;
|
||||
const prices: Record<string, number> = {};
|
||||
if (row.input > 0) prices.input = row.input;
|
||||
if (row.output > 0) prices.output = row.output;
|
||||
if (row.cacheHit > 0) prices.cacheHit = row.cacheHit;
|
||||
if (Object.keys(prices).length) map[row.mediaType] = prices;
|
||||
});
|
||||
return Object.keys(map).length ? map : undefined;
|
||||
}
|
||||
|
||||
/** mediaPrices 对象 → 编辑态媒体价格行(兼容后端两种结构:按媒体分组的内层对象,或空/缺失) */
|
||||
function mediaPricesToRows(mediaPrices?: unknown): MediaPriceRow[] {
|
||||
if (!mediaPrices || typeof mediaPrices !== 'object') return [];
|
||||
return Object.entries(mediaPrices as Record<string, Record<string, number | undefined>>)
|
||||
.filter(([, prices]) => prices && typeof prices === 'object')
|
||||
.map(([mediaType, prices]) => ({
|
||||
mediaType,
|
||||
input: prices.input ?? 0,
|
||||
output: prices.output ?? 0,
|
||||
cacheHit: prices.cacheHit ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 添加一条媒体价格行 */
|
||||
function addMediaPrice(r: ModelRule) {
|
||||
r.mediaPrices.push({ mediaType: '', input: 0, output: 0, cacheHit: 0 });
|
||||
}
|
||||
|
||||
/** 删除一条媒体价格行 */
|
||||
function removeMediaPrice(r: ModelRule, index: number) {
|
||||
r.mediaPrices.splice(index, 1);
|
||||
}
|
||||
|
||||
/** 媒体价格行的可选媒体:排除本规则其他行已选中的媒体,避免同一条规则重复选择同一媒体 */
|
||||
function getAvailableMediaOptions(r: ModelRule, curIndex: number) {
|
||||
const selected = new Set(r.mediaPrices.filter((_, i) => i !== curIndex).map((row) => row.mediaType));
|
||||
return MEDIA_PRICE_OPTIONS.filter((opt) => !selected.has(opt.value) || r.mediaPrices[curIndex]?.mediaType === opt.value);
|
||||
}
|
||||
|
||||
/** 构建模型费率 JSON(对齐后端结构,由父组件 stringify 提交) */
|
||||
function buildRules(): ModelRules {
|
||||
return {
|
||||
@@ -467,6 +531,41 @@ defineExpose({ save });
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 媒体价格模板区(token 单位):基础价下方的媒体覆盖价动态行 */
|
||||
.media-price-section {
|
||||
margin-top: 12px;
|
||||
border-top: 1px dashed #e4e7ed;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.media-price-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.mp-media {
|
||||
width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mp-num {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.media-add-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mp-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.7;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.note {
|
||||
border: 1px solid #e6a23c;
|
||||
background: #fdf6ec;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 说明:
|
||||
* - 计价对象枚举来自后端 GET .../subjects(见 src/api/trade/pricing/index.ts),
|
||||
* 本文件提供后端项 → 页面 Subject 的映射(中文 modelType/modelTypeCode → 英文枚举等)
|
||||
* - 计价配置读取后端的 rules JSON 字符串(JSON.parse),保存时 JSON.stringify 提交
|
||||
* - 计价配置读取/提交后端的 rules 均为对象(config/get 返回对象,save 也传对象)
|
||||
* - 模型类型编码:reason=100推理 / image=200图片 / audio=300音频 / video=600视频
|
||||
* - EXAMPLES 仅为编辑器本地"示例填充" UX,不参与后端数据
|
||||
*/
|
||||
@@ -37,6 +37,8 @@ export interface ApiSubjectItem {
|
||||
modelType?: string;
|
||||
/** 仅 model:模型类型编码(100推理/200图片/300音频/600视频) */
|
||||
modelTypeCode?: number;
|
||||
/** 该对象支持的计费模式(workflow/business 由后端 subjects.chargeModes 下发;model 无此字段) */
|
||||
chargeModes?: string[];
|
||||
}
|
||||
|
||||
/** modelTypeCode → 英文模型类型 */
|
||||
@@ -47,12 +49,6 @@ export const MODEL_TYPE_CODE_TO_EN: Record<number, ModelType> = {
|
||||
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') {
|
||||
@@ -71,7 +67,7 @@ export function toSubject(item: ApiSubjectItem): Subject {
|
||||
type: item.subjectType,
|
||||
id: item.subjectId,
|
||||
name: item.name,
|
||||
modes: SUBJECT_MODE_MAP[item.subjectType] ?? [],
|
||||
modes: item.chargeModes ?? [],
|
||||
hasConfig: false,
|
||||
};
|
||||
}
|
||||
@@ -90,7 +86,7 @@ export interface Subject {
|
||||
modelTypeCode?: number;
|
||||
/** 仅 model:模型中文类型(推理/视频/音频/图片) */
|
||||
typeLabel?: string;
|
||||
/** workflow/business:支持的计费模式(本地硬编码) */
|
||||
/** workflow/business:支持的计费模式(后端 subjects.chargeModes 下发) */
|
||||
modes?: string[];
|
||||
/** 是否已配置计价(选中后由 config/get 判断) */
|
||||
hasConfig: boolean;
|
||||
@@ -122,10 +118,21 @@ export interface WorkflowRules {
|
||||
|
||||
/* ================= 模型费率 JSON ================= */
|
||||
|
||||
/** 编辑器内部规则行态(thinking/outputAudio 用字符串承载"未设置") */
|
||||
/** 媒体价格模板行(token 单位):对特定媒体类型设置独立覆盖价(mediaPrices) */
|
||||
export interface MediaPriceRow {
|
||||
/** 媒体类型:text / audio / video / image */
|
||||
mediaType: string;
|
||||
/** 输入单价 */
|
||||
input: number;
|
||||
/** 输出单价 */
|
||||
output: number;
|
||||
/** 缓存命中输入单价 */
|
||||
cacheHit: number;
|
||||
}
|
||||
|
||||
/** 编辑器内部规则行态(thinking/outputAudio 用字符串承载"未设置";媒体命中条件已移除,媒体改走价格模板) */
|
||||
export interface ModelRule {
|
||||
name: string;
|
||||
mediaType: '' | 'text' | 'audio' | 'video' | 'image';
|
||||
thinking: '' | 'true' | 'false';
|
||||
outputAudio: '' | 'true' | 'false';
|
||||
outputResolution: string;
|
||||
@@ -139,12 +146,13 @@ export interface ModelRule {
|
||||
cacheHit: number;
|
||||
/** 非 token 单位:每单位单价 */
|
||||
unitPrice: number;
|
||||
/** 媒体价格模板行(token 单位;空数组=无媒体覆盖价) */
|
||||
mediaPrices: MediaPriceRow[];
|
||||
}
|
||||
|
||||
/** 示例数据源规则(thinking/outputAudio 为布尔,由 loadExample 转换) */
|
||||
export interface ExampleRule {
|
||||
name: string;
|
||||
mediaType?: string;
|
||||
thinking?: boolean;
|
||||
outputAudio?: boolean;
|
||||
outputResolution?: string;
|
||||
@@ -154,11 +162,12 @@ export interface ExampleRule {
|
||||
output?: number;
|
||||
cacheHit?: number;
|
||||
unitPrice?: number;
|
||||
/** 媒体价格模板示例(可选):媒体类型 → 覆盖价 */
|
||||
mediaPrices?: Record<string, { input?: number; output?: number; cacheHit?: number }>;
|
||||
}
|
||||
|
||||
/** 构建态:规则命中条件 */
|
||||
/** 构建态:规则命中条件(媒体类型已改为价格模板,不再作为命中条件) */
|
||||
export interface BuiltModelRuleMatch {
|
||||
mediaType?: string;
|
||||
thinking?: boolean;
|
||||
outputAudio?: boolean;
|
||||
outputResolution?: string;
|
||||
@@ -171,6 +180,8 @@ export interface BuiltModelRule {
|
||||
name: string;
|
||||
match?: BuiltModelRuleMatch;
|
||||
price: Record<string, number>;
|
||||
/** 媒体价格模板(token 单位):媒体类型 → 该媒体独立覆盖价;无覆盖时省略 */
|
||||
mediaPrices?: Record<string, Record<string, number>>;
|
||||
}
|
||||
|
||||
/** 模型费率 JSON 结构(对齐后端,含内嵌币种) */
|
||||
@@ -238,30 +249,56 @@ export const UNIT_OPTIONS: { value: Unit; label: string }[] = [
|
||||
{ value: 'per_char', label: 'per_char(每字)' },
|
||||
];
|
||||
|
||||
export const MEDIA_TYPE_OPTIONS: { value: ModelRule['mediaType']; label: string }[] = [
|
||||
{ value: '', label: '不限制' },
|
||||
/** 媒体价格模板可选媒体类型(媒体命中条件已移除,媒体改按模板定价) */
|
||||
export const MEDIA_PRICE_OPTIONS: { value: 'text' | 'audio' | 'video' | 'image'; label: string }[] = [
|
||||
{ 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 }[] = [
|
||||
/** outputAudio 命中条件(video):给用户展示中文(有声/无声) */
|
||||
export const OUTPUT_AUDIO_OPTIONS: { value: '' | 'true' | 'false'; label: string }[] = [
|
||||
{ value: '', label: '不限制' },
|
||||
{ value: 'true', label: '有声' },
|
||||
{ value: 'false', label: '无声' },
|
||||
];
|
||||
|
||||
/** thinking 命中条件(reason):给用户展示中文(是/否) */
|
||||
export const THINKING_OPTIONS: { value: '' | 'true' | 'false'; label: string }[] = [
|
||||
{ value: '', label: '不限制' },
|
||||
{ value: 'true', label: '是' },
|
||||
{ value: 'false', label: '否' },
|
||||
];
|
||||
|
||||
/** 输出分辨率命中条件下拉选项(按模型类型区分:video 横版 / image 方形) */
|
||||
export const RESOLUTION_OPTIONS: Record<'video' | 'image', { value: string; label: string }[]> = {
|
||||
video: [
|
||||
{ value: '', label: '不限制' },
|
||||
{ value: '480p', label: '480p' },
|
||||
{ value: '720p', label: '720p' },
|
||||
{ value: '1080p', label: '1080p' },
|
||||
{ value: '2k', label: '2k' },
|
||||
{ value: '4k', label: '4k' },
|
||||
],
|
||||
image: [
|
||||
{ value: '', label: '不限制' },
|
||||
{ value: '512x512', label: '512x512' },
|
||||
{ value: '1024x1024', label: '1024x1024' },
|
||||
{ value: '2048x2048', label: '2048x2048' },
|
||||
],
|
||||
};
|
||||
|
||||
/** 模型示例(本地填充 UX,逐字对应原型定价配置) */
|
||||
export const EXAMPLES: ModelExampleMap = {
|
||||
video: [
|
||||
{
|
||||
label: '视频·按秒(分辨率×有声/无声×输入媒体)',
|
||||
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 },
|
||||
{ name: '无声-720p', outputAudio: false, outputResolution: '720p', unitPrice: 0.8 },
|
||||
{ name: '有声-1080p', outputAudio: true, outputResolution: '1080p', unitPrice: 1.5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -269,20 +306,28 @@ export const EXAMPLES: ModelExampleMap = {
|
||||
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 },
|
||||
{ name: '无声-1080p', outputAudio: false, outputResolution: '1080p', input: 0.6, output: 3.6, cacheHit: 0.12 },
|
||||
{ name: '有声-1080p', outputAudio: true, outputResolution: '1080p', input: 9, output: 27, cacheHit: 1.8 },
|
||||
],
|
||||
},
|
||||
],
|
||||
reason: [
|
||||
{
|
||||
label: '推理·阶梯(思考×输入token档×输入媒体)',
|
||||
label: '推理·阶梯(思考×输入长度档×媒体模板)',
|
||||
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: '思考-输入长度[0,32k]', thinking: true, inLenMax: 32000, input: 0.6, output: 3.6, cacheHit: 0.12 },
|
||||
{
|
||||
name: '思考-输入长度(32k,128k]',
|
||||
thinking: true,
|
||||
inLenMin: 32001,
|
||||
inLenMax: 128000,
|
||||
input: 0.9,
|
||||
output: 5.4,
|
||||
cacheHit: 0.18,
|
||||
mediaPrices: { audio: { input: 9, output: 3.6, cacheHit: 1.8 } },
|
||||
},
|
||||
{ name: '非思考-统一价', thinking: false, input: 0.3, output: 1.2, cacheHit: 0.06 },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user