Merge branch 'feature/home-workflow' of https://gitea.redpowerfuture.com/red-future/admin-ui into feature/home-workflow

This commit is contained in:
2026-09-07 10:45:37 +08:00
19 changed files with 2646 additions and 242 deletions
-213
View File
@@ -1,213 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
/* ========== 全局重置 ========== */
/* 去掉所有元素的自带边距和内边距,让布局从零开始 */
* { margin: 0; padding: 0; box-sizing: border-box; }
/* ========== 画布(视频输出尺寸) ========== */
/* 这是最终视频的分辨率:宽1080px × 高1920px(竖屏 9:16,手机全屏) */
/* 如果你想改输出尺寸,改这里的 width 和 height 就行 */
body {
width: 1080px; /* 画布宽度 ← 改成 720 就是 720×1280 */
height: 1920px; /* 画布高度 ← 改成 1280 就是 720p */
overflow: hidden; /* 超出画布的部分裁掉,不显示滚动条 */
background: #000; /* 画布背景色(黑色),视频没铺满时露出的颜色 */
}
/* ========== 所有元素的定位基础 ========== */
/* 给所有带 clip 类的元素开启绝对定位,可以用 left/top 精确控制位置 */
.clip {
position: absolute; /* 绝对定位,相对于父容器(body)定位 */
}
/* ========== 文字元素的通用样式 ========== */
.text-element {
font-family: Arial, Helvetica, sans-serif; /* 字体:首选Arial,不行就Helvetica,再不行用系统无衬线体 */
text-align: center; /* 文字水平居中 */
display: flex; /* 启用弹性盒子布局 */
align-items: center; /* 垂直居中(配合flex使用) */
justify-content: center; /* 水平居中(配合flex使用) */
white-space: pre-wrap; /* 保留换行,文字超出宽度自动换行 */
word-break: break-word; /* 长单词或URL撑破容器时强制换行 */
}
/* ========== 入场动画定义 ========== */
/* 想改动画时长,改后面的秒数(如 0.5s → 1s 就是慢一倍) */
/* 淡入:从完全透明 → 完全可见 */
@keyframes fadeIn {
from { opacity: 0; } /* 开始:透明(完全看不见) */
to { opacity: 1; } /* 结束:不透明(完全可见) */
}
/* 上滑:从下方40px处边向上移动边显现 */
@keyframes slideUp {
from { opacity: 0; transform: translateY(40px); } /* 开始:透明 + 在下方40px */
to { opacity: 1; transform: translateY(0); } /* 结束:可见 + 回到原位 */
}
/* 左滑:从右侧40px处边向左移动边显现(视觉上像从右边滑入) */
@keyframes slideLeft {
from { opacity: 0; transform: translateX(40px); } /* 开始:透明 + 在右边40px */
to { opacity: 1; transform: translateX(0); } /* 结束:可见 + 回到原位 */
}
/* 放大:从缩小到80%边放大边显现 */
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.8); } /* 开始:透明 + 缩到80%大小 */
to { opacity: 1; transform: scale(1); } /* 结束:可见 + 100%正常大小 */
}
/* 脉冲:一直循环缩放,像呼吸一样忽大忽小(一般用于强调/吸引注意) */
@keyframes pulse {
0% { transform: scale(1); } /* 开始:正常大小 */
50% { transform: scale(1.05); } /* 中间:放大到1.05倍(105%*/
100% { transform: scale(1); } /* 结束:回到正常大小 */
}
/* ========== 动画类名 ========== */
/* 给元素加上 class="anim-xxx" 就能用上面的动画,用法看下面的HTML元素 */
/* 淡入 0.5秒,先快后慢 */
.anim-fadeIn { animation: fadeIn 0.5s ease-out; }
/* 上滑 0.6秒,先快后慢 */
.anim-slideUp { animation: slideUp 0.6s ease-out; }
/* 左滑 0.6秒,先快后慢 */
.anim-slideLeft { animation: slideLeft 0.6s ease-out; }
/* 放大入场 0.5秒,先快后慢 */
.anim-scaleIn { animation: scaleIn 0.5s ease-out; }
/* 脉冲 1.5秒一个循环,一直重复(infinite) */
.anim-pulse { animation: pulse 1.5s ease-in-out infinite; }
/* ── 动画参数怎么改? ── */
/* • 0.5s → 改成 1s 动画就慢一倍,改 0.2s 就快一倍 */
/* • ease-out → 开始快结尾慢,换成 linear 就是匀速,换成 ease-in 就是开始慢结尾快 */
/* • infinite → 一直重复,去掉就不重复只播一次 */
</style>
<!-- GSAP:专业动画引擎,用于精确控制时间轴(当前脚本只用了初始化,没做复杂动画) -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
</head>
<body>
<!-- ====================================================================== -->
<!-- 舞台容器:所有元素都放在这里面 -->
<!-- data-composition-id 是内部标识,不要改 -->
<!-- data-width / data-height 需要和上面的 body 宽高一致 -->
<!-- ====================================================================== -->
<div id="stage" data-composition-id="main" data-start="0" data-width="1080" data-height="1920">
<!-- ================================================================== -->
<!-- data-key 属性:这个元素的内容会被程序自动替换 -->
<!-- 比如 data-key="title",程序就会用"标题"对应的文字替换掉这个元素的内容 -->
<!-- 所以里面的"标题文字"只是占位,实际显示什么由数据决定 -->
<!-- ================================================================== -->
<!-- ======================================================== -->
<!-- 标题:红色大标题,靠上居中 -->
<!-- ======================================================== -->
<!--
data-key="title" → 程序会用标题数据替换此元素内容
data-start="0" → 第0秒开始显示(单位:秒)
data-duration="58.000" → 持续显示58秒
class="clip" → 绝对定位,可以用 left/top 控制位置
class="text-element" → 文字居中、自动换行
class="anim-slideUp" → 入场动画:从下方滑入,0.6秒
-->
<div data-key="title" data-start="0" data-duration="58.000" data-track-index="1"
class="clip text-element anim-slideUp"
style="
left: 50%; /* 水平居中定位(配合 translateX 使用) */
top: 70px; /* 距离顶部70px ← 改这个数字上下移动 */
transform: translateX(-50%); /* 把元素向左拉回自身宽度的一半,实现真居中 */
width: 972px; /* 文字区域宽度 ← 改窄一点文字就缩短 */
max-width: 972px; /* 最大宽度,防止被撑破 */
font-size: 48px; /* 字号 ← 改大/改小 */
color: #D82828; /* 文字颜色(红色)← 改成 #FFFFFF 就是白色 */
">标题文字</div>
<!-- ======================================================== -->
<!-- 产品图片:左上角位置,图片链接由程序自动替换 -->
<!-- ======================================================== -->
<!--
这是 <img> 标签,src 属性会被程序替换成真实的图片 URL
data-key="product_image" → 程序用产品图片的URL替换 src
-->
<img data-key="product_image" data-start="0" data-duration="58.000" data-track-index="2"
class="clip anim-fadeIn"
style="
width: 200px; /* 图片宽度 ← 改这个调大小 */
height: 200px; /* 图片高度 ← 改这个调大小 */
left: 0px; /* 距离左边0px(贴左边缘)← 改大往右移 */
top: 200px; /* 距离顶部200px ← 改大往下移 */
"
src="占位图片.jpg" />
<!-- ======================================================== -->
<!-- 产品卖点描述:居中偏下位置 -->
<!-- ======================================================== -->
<div data-key="product_desc" data-start="0" data-duration="58.000" data-track-index="3"
class="clip text-element"
style="
left: 50%; /* 水平居中 */
top: 830px; /* 距离顶部830px ← 改大就往下移 */
transform: translateX(-50%); /* 水平居中补偿 */
width: 972px; /* 文本宽度 */
max-width: 972px; /* 最大宽度 */
font-size: 34px; /* 字号 ← 改大/改小 */
color: #333333; /* 文字颜色(深灰色)← 改成 #000 就是黑色 */
">产品卖点描述</div>
<!-- ======================================================== -->
<!-- 底部免责声明:背景半透明黑底白字,圆角,贴底部 -->
<!-- ======================================================== -->
<div data-key="disclaimer" data-start="0" data-duration="58.000" data-track-index="5"
class="clip text-element"
style="
left: 50%; /* 水平居中 */
top: calc(100% - 45px); /* 距离底部45px(100%是父容器高度)← 改45调距离 */
transform: translateX(-50%); /* 水平居中补偿 */
width: 972px; /* 文本宽度 */
max-width: 972px;
font-size: 20px; /* 字号 */
color: #FFFFFF; /* 文字颜色(白色) */
">
<!--
内部 span:负责背景和间距
background: rgba(0,0,0,0.70) → 黑色半透明背景,70%不透明度
← 改最后一个数字(0~1)调透明度:0=全透,1=全黑
padding: 12px 24px → 内边距:上下12px,左右24px
← 改这个让背景范围变大/变小
border-radius: 8px → 圆角大小
← 改大更圆(如 20px),改 0 就是直角
-->
<span style="background:rgba(0,0,0,0.70); padding:12px 24px; border-radius:8px;">
免责声明文字
</span>
</div>
</div>
<!-- ================================================================ -->
<!-- GSAP 时间轴初始化脚本 -->
<!-- 当前只初始化了一个空时间轴,给程序框架用,一般不需要改 -->
<!-- ================================================================ -->
<script>
(function() {
var tl = gsap.timeline({ paused: true }); // 建一个暂停的时间轴
tl.to('#stage', { duration: 0, opacity: 1 }, 0); // 舞台立刻显示(第0秒)
window.__timelines = window.__timelines || {}; // 存到全局,供程序调用
window.__timelines['main'] = tl; // 时间轴名字叫 'main'
})();
</script>
</body>
</html>
+2
View File
@@ -24,6 +24,8 @@ export interface VOSessionInfoResult {
totalTokens: number;
totalFee: number;
errorMsg: string;
// 失败详细原因(工作流节点失败详情,如「执行工作流失败: ...节点:生成视频, 失败原因:...」),比 errorMsg 更具体
error?: string;
createdAt: string;
}
+16
View File
@@ -70,6 +70,22 @@ export function deleteUser(ids: number[]) {
});
}
export function recharge(data: object) {
return request({
url: '/shop-user-trade/account/controller/recharge',
method: 'post',
data: data,
});
}
export function getWalletLogs(userId: number, pageNum: number, pageSize: number) {
return request({
url: '/shop-user-trade/account/controller/logs',
method: 'get',
params: { userId, pageNum, pageSize },
});
}
export function checkIsSuperAdmin() {
return request({
url: '/admin-go/api/v1/system/user/checkIsSuperAdmin',
+44
View File
@@ -0,0 +1,44 @@
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,
});
}
/** 计价对象可选计费方式(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;
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,
});
}
@@ -166,7 +166,7 @@ function nextImageKey(template: PatchTemplate): string | null {
return null;
}
const DEFAULT_TEMPLATE_URL = 'https://cdn.redpowerfuture.com/tenantid-1/template_example1.html';
const DEFAULT_TEMPLATE_URL = 'https://cdn.redpowerfuture.com/tenantid-1/vertical_template_example.html';
const props = defineProps<{
modelValue: PatchTemplate[];
@@ -290,14 +290,24 @@ const handleImageUpload = async (templateIndex: number, imgIdx: number, file: an
}
};
// 下载参考模板文件
const handleDownload = (_e?: Event) => {
const a = document.createElement('a');
a.href = '/template_example.html';
a.download = 'template_example.html';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// 下载参考模板文件:统一走 CDN,转 blob 触发下载(避免跨域 <a download> 直接打开页面)
const handleDownload = async (_e?: Event) => {
try {
const res = await fetch(DEFAULT_TEMPLATE_URL);
if (!res.ok) throw new Error('参考模板获取失败');
const blob = await res.blob();
const fileName = decodeURIComponent(DEFAULT_TEMPLATE_URL.split('/').pop() || 'vertical_template_example.html');
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
} catch (error: any) {
ElMessage.error(error?.message || '参考模板下载失败');
}
};
</script>
+4 -3
View File
@@ -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') {
+8
View File
@@ -31,6 +31,10 @@
color: #f56c6c !important;
}
.op-btn-recharge {
color: #67c23a !important; // 充值 绿色
}
[data-theme='dark'] {
// textarea - css vars
--w-e-textarea-bg-color: var(--el-color-white) !important;
@@ -49,6 +53,10 @@
color: #f56c6c !important; // 删除 红色
}
.op-btn-recharge {
color: #67c23a !important; // 充值 绿色
}
// toolbar - css vars
--w-e-toolbar-color: var(--el-text-color-primary) !important;
--w-e-toolbar-bg-color: var(--el-color-white) !important;
+89 -7
View File
@@ -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)">
@@ -165,19 +171,26 @@
<!-- 底部操作区:editing 提示 / running 执行中 / done·failed 状态标签(执行统一由 InputBar 发送按钮触发) -->
<div class="workflow-form-footer">
<div v-if="isFailed" class="form-error-text">{{ formError }}</div>
<!-- 执行中:节点文本进度推进(后端 node_start/node_complete 事件),无进度时兜底「执行中...」;
停止统一由 InputBar 发送按钮(变停止)触发,卡片内不再提供取消入口 -->
<div v-if="submitting" class="executing-progress">
<span class="exec-spinner" />
<span class="exec-text">{{ progressText }}</span>
</div>
<!-- 已执行卡片(done/failed,含可编辑的最后一张):显示状态标签,参数是否可编辑由 editable 决定 -->
<el-tag v-else-if="formStatus === 'done' || formStatus === 'failed'" :type="isFailed ? 'danger' : 'success'" size="small" effect="light">
{{ isFailed ? '执行失败' : '执行完成' }}
</el-tag>
<!-- 已执行卡片(done/failed,含可编辑的最后一张):状态标签 + 失败原因按钮(默认收起,点击手动展开) -->
<div v-else-if="formStatus === 'done' || formStatus === 'failed'" class="form-status-row">
<el-tag :type="isFailed ? 'danger' : 'success'" size="small" effect="light">
{{ isFailed ? '执行失败' : '执行完成' }}
</el-tag>
<el-button v-if="isFailed && formError" text size="small" class="form-error-toggle" @click="showError = !showError">
{{ showError ? '收起失败原因' : '查看失败原因' }}
<el-icon class="error-arrow" :class="{ 'is-expanded': showError }"><ArrowDown /></el-icon>
</el-button>
</div>
<!-- 编辑态:无执行按钮,提示通过下方发送按钮执行 -->
<span v-else class="form-edit-tip">填写参数后,点击下方发送按钮执行工作流</span>
<!-- 失败原因详情:默认收起,点「查看失败原因」展开(error 字段可能含多行详细原因) -->
<div v-if="isFailed && showError && formError" class="form-error-text">{{ formError }}</div>
</div>
</div>
</template>
@@ -188,6 +201,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 +223,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,11 +243,47 @@ 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);
const isDisabled = computed(() => (props.readonly && !props.editable) || props.submitting);
// 失败原因默认收起:新失败时重置为不展示,用户点「查看失败原因」才展开
const showError = ref(false);
watch(
() => props.formError,
() => {
showError.value = false;
}
);
// 表单收起/展开:editing(待提交/回显)默认展开;提交(running)自动收起;
// done/failed 保持提交时状态(默认收起,用户可手动展开查看已填值)
const collapsed = ref(false);
@@ -435,6 +485,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 +517,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 +602,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,
});
};
@@ -754,19 +814,41 @@ watch(
/* 底部操作区 */
.workflow-form-footer {
display: flex;
flex-wrap: wrap; /* 失败原因详情换行到状态行下方 */
align-items: center;
gap: 12px;
padding: 12px 20px;
border-top: 1px solid #f1f5f9;
background: #fafbfc;
flex-shrink: 0;
.form-error-text {
.form-status-row {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.form-error-toggle {
padding: 0;
height: auto;
font-size: 12px;
color: #ef4444;
.error-arrow {
transition: transform 0.2s;
&.is-expanded { transform: rotate(180deg); }
}
}
.form-error-text {
flex-basis: 100%; /* 占整行,显示在状态行下方 */
font-size: 12px;
color: #ef4444;
line-height: 1.5;
white-space: pre-wrap; /* 失败详情可能含换行(error 字段多行原因),允许换行展示 */
word-break: break-word;
background: #fef2f2;
border: 1px solid #fee2e2;
border-radius: 6px;
padding: 8px 12px;
}
.form-edit-tip {
flex: 1;
+35 -8
View File
@@ -105,7 +105,7 @@ import { applyHomeFormValues, buildOutputsFromUrls } from './utils/flowDsl';
import type { WorkflowOutput } from './utils/flowDsl';
import { getChatModel, listModelManage } from '/@/api/settings/modelConfigV2';
import { connectSessionSocket, sendAgentStart, sendWorkflowStart, sendCancel } from './utils/wsExecute';
import { parseWsMessage, getDelta, getAnswer, getRecordId, getErrorText, getNodeEvent, getToolCallName, getToolResultText, type WorkflowNodeProgress } from './utils/wsMessage';
import { parseWsMessage, getDelta, getAnswer, getRecordId, getErrorText, getErrorDetail, getNodeEvent, getToolCallName, getToolResultText, type WorkflowNodeProgress } from './utils/wsMessage';
import { getApiErrorMessage } from '/@/utils/request';
import {
getWorkflowDetail,
@@ -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 (
// 构建 flowContenttemplates 不再放最外层(删除 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)
@@ -1136,13 +1140,14 @@ const runWorkflow = async (
formMsg.recordType = 'workflow';
}
} else if (msg.type === 'error') {
// 失败:执行中的节点标记为失败,执行过程区展示中断位置
// 失败:执行中的节点标记为失败,执行过程区展示中断位置
// 失败原因绑定 error 字段(节点失败详情),供卡片「查看失败原因」手动展开
if (formMsg.formProgress) {
formMsg.formProgress.nodes.forEach((n) => {
if (n.status === 'running') n.status = 'failed';
});
}
finishExec(false, getErrorText(msg));
finishExec(false, getErrorDetail(msg));
} else if (msg.type === 'flow_complete') {
// flow_complete 为后端新增的工作流完成信号,事件自带产出文件 URL 列表 → 直接渲染产出卡片;
// 以 round_start 首帧填充的 roundRecordId 作 backendId,保证实时产出可删除(与回显路径一致)
@@ -1234,9 +1239,20 @@ const snapshotFormValuesToMsg = (msg: ChatMessage, formValues: Record<string, an
});
};
// 提交时把用户填的贴片模板写回消息对象 patchLayout 节点(与表单值快照同理):
// 会话认领(virtual→UUID 触发 MainContent 重建)/ 历史回显重挂载时,WorkflowFormCard 从 msg.form
// 恢复贴片模板能取到本次填写值,避免新建会话首次执行后展开贴片模板回显空白
const snapshotTemplatesToMsg = (msg: ChatMessage, templates: any[]) => {
const nodes = msg.form?.nodeInputParams;
if (!Array.isArray(nodes)) return;
nodes.forEach((n: any) => {
if (n.patchLayout === true) n.templates = templates;
});
};
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 = '';
@@ -1254,6 +1270,16 @@ const startWorkflowFromCard = async (
// 会话认领(virtual→UUID 触发 MainContent 重建)/ 历史回显重挂载时能从消息对象恢复已填值,
// 避免执行后表单被清空
snapshotFormValuesToMsg(msg, payload.formValues);
// 贴片模板同样写回消息对象 patchLayout 节点(保持与后端执行副本一致),重建/重挂载后能恢复回显
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;
@@ -1632,8 +1658,8 @@ const loadWorkflowSessionMessages = async (sid: string, results: VOSessionInfoRe
if (q) msgs.push({ id: 'rq-' + r.id, content: String(q), time: r.createdAt || '', isUser: true, recordId: String(r.id), recordType: r.type });
if (r.resultContent) msgs.push({ id: 'ra-' + r.id, content: String(r.resultContent), time: r.createdAt || '', isUser: false, recordId: String(r.id), recordType: r.type });
} else if (r.type === 'workflow') {
// 失败判断: errorMsg 为准(部分失败记录 status 仍是 1,仅凭 status 会误判为成功)
const failed = !!r.errorMsg;
// 失败判断:error / errorMsg 任一有值即失败(部分失败记录 status 仍是 1,仅凭 status 会误判为成功)
const failed = !!(r.errorMsg || r.error);
// 每次执行都渲染带值表单卡片(requestParams.nodes 即该次提交的表单值快照);
// form 补 id=flowId,供失败卡片「重新编辑并执行」时作为启动 flowId
if (Array.isArray(r.requestParams?.nodes)) {
@@ -1650,7 +1676,8 @@ const loadWorkflowSessionMessages = async (sid: string, results: VOSessionInfoRe
flowContent: r.requestParams,
},
formStatus: failed ? 'failed' : 'done',
formError: failed ? r.errorMsg || '执行失败' : '',
// 失败原因绑定 error 字段(节点失败详情);errorMsg(如「用户已终止执行」)仅作兜底
formError: failed ? r.error || r.errorMsg || '执行失败' : '',
});
}
// 结果消息:成功记录直接带产出(session/get 记录自带的 resultFileUrl),不再调 execution/get
+13
View File
@@ -169,3 +169,16 @@ export function getErrorText(msg: WsStreamMessage): string {
}
return '执行失败,请重试';
}
/** error 详细原因(error 字段优先展示失败详情;message 仅在 error 缺失时兜底)——供工作流失败卡片「查看失败原因」使用 */
export function getErrorDetail(msg: WsStreamMessage): string {
if (typeof msg?.error === 'string' && msg.error) return msg.error;
if (typeof msg?.message === 'string' && msg.message) return msg.message;
const d = msg?.data;
if (typeof d === 'string') return d;
if (d && typeof d === 'object') {
const nested = d.message ?? d.error ?? d.msg;
if (typeof nested === 'string' && nested) return nested;
}
return '执行失败,请重试';
}
@@ -0,0 +1,213 @@
<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">
<div class="cfg-row">
<div class="cfg-item">
<div class="cfg-label">门禁金额</div>
<el-input-number v-model="minBalance" :min="0" :precision="5" :step="0.01" :controls="false" class="cfg-min-balance" />
</div>
<div class="cfg-item">
<div class="cfg-label">币种</div>
<el-select v-model="currency" class="cfg-currency">
<el-option label="人民币" value="CNY" />
<el-option label="美元" value="USD" />
</el-select>
</div>
<div class="cfg-item">
<div class="cfg-label">启用</div>
<el-switch v-model="enabled" />
</div>
</div>
</div>
</div>
</div>
<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="周期">
<el-select v-model="period" class="ctrl-select">
<el-option label="按年" value="year" />
</el-select>
</el-form-item>
<el-form-item label="每周期价格(元)">
<el-input-number
v-model="price"
:min="0"
:precision="5"
:step="0.01"
:controls="false"
class="ctrl-input"
/>
</el-form-item>
<div class="form-hint">本期支持按年订阅订阅购买 / 续费流程接入另期</div>
</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: block;
}
.editor-col {
width: 100%;
min-width: 0;
}
.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;
}
.cfg-row {
display: flex;
align-items: flex-end;
gap: 40px;
flex-wrap: wrap;
}
.cfg-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.cfg-label {
font-size: 13px;
color: #606266;
}
.cfg-min-balance {
width: 200px;
}
.cfg-currency {
width: 200px;
}
.ctrl-input {
width: 220px;
}
.ctrl-select {
width: 220px;
}
</style>
@@ -0,0 +1,616 @@
<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">
<div class="cfg-row">
<div class="cfg-item">
<div class="cfg-label">门禁金额</div>
<el-input-number v-model="minBalance" :min="0" :precision="5" :step="0.01" :controls="false" class="cfg-min-balance" />
</div>
<div class="cfg-item">
<div class="cfg-label">币种</div>
<el-select v-model="currency" class="cfg-currency">
<el-option label="人民币" value="CNY" />
<el-option label="美元" value="USD" />
</el-select>
</div>
<div class="cfg-item">
<div class="cfg-label">启用</div>
<el-switch v-model="enabled" />
</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="计费单位">
<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="阶梯分档">
<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">命中条件</div>
<el-form label-position="top" size="small">
<div class="rc-grid">
<template v-if="subject.modelType === 'reason'">
<el-form-item label="思考模式">
<el-select v-model="r.thinking" class="rc-control">
<el-option v-for="o in THINKING_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<el-form-item label="输入长度下界">
<el-input-number v-model="r.inLenMin" :min="0" :precision="0" :controls="false" class="rc-control" />
</el-form-item>
<el-form-item label="输入长度上界">
<el-input-number v-model="r.inLenMax" :min="0" :precision="0" :controls="false" class="rc-control" />
</el-form-item>
</template>
<template v-if="subject.modelType === 'video'">
<el-form-item label="输出音频">
<el-select v-model="r.outputAudio" class="rc-control">
<el-option v-for="o in OUTPUT_AUDIO_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<el-form-item label="输出分辨率">
<el-select v-model="r.outputResolution" class="rc-control">
<el-option v-for="o in RESOLUTION_OPTIONS.video" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
</template>
<template v-if="subject.modelType === 'image'">
<el-form-item label="输出分辨率">
<el-select v-model="r.outputResolution" class="rc-control">
<el-option v-for="o in RESOLUTION_OPTIONS.image" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
</template>
</div>
</el-form>
<div class="rc-sec price">计价项</div>
<el-form label-position="top" size="small">
<div class="rc-grid">
<template v-if="isTokenUnit">
<el-form-item label="输入单价">
<el-input-number v-model="r.input" :min="0" :precision="5" :step="0.0001" :controls="false" class="rc-control" />
</el-form-item>
<el-form-item label="输出单价">
<el-input-number v-model="r.output" :min="0" :precision="5" :step="0.0001" :controls="false" class="rc-control" />
</el-form-item>
<el-form-item label="缓存命中">
<el-input-number v-model="r.cacheHit" :min="0" :precision="5" :step="0.0001" :controls="false" class="rc-control" />
</el-form-item>
</template>
<template v-else>
<el-form-item label="每单位单价">
<el-input-number v-model="r.unitPrice" :min="0" :precision="5" :step="0.0001" :controls="false" class="rc-control" />
</el-form-item>
</template>
</div>
</el-form>
<!-- 媒体价格模板 token 单位媒体命中条件已移除改为对特定媒体类型设独立覆盖价 mediaPrices -->
<div v-if="isTokenUnit" class="media-price-section">
<div class="rc-sec price">媒体价格模板可选</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>
<div class="mp-field">
<span class="mp-label">输入价</span>
<el-input-number v-model="mp.input" :min="0" :precision="5" :step="0.0001" :controls="false" class="mp-num" />
</div>
<div class="mp-field">
<span class="mp-label">输出价</span>
<el-input-number v-model="mp.output" :min="0" :precision="5" :step="0.0001" :controls="false" class="mp-num" />
</div>
<div class="mp-field">
<span class="mp-label">缓存命中价</span>
<el-input-number v-model="mp.cacheHit" :min="0" :precision="5" :step="0.0001" :controls="false" class="mp-num" />
</div>
<el-button type="danger" text size="small" @click="removeMediaPrice(r, mIdx)">删除</el-button>
</div>
<el-button size="small" class="media-add-btn" @click="addMediaPrice(r)">+ 添加媒体价格</el-button>
<div class="mp-tip">基础价input/output/cacheHit为默认价媒体模板仅覆盖指定媒体类型的价未选媒体类型的行保存时丢弃</div>
</div>
</div>
<el-button size="small" class="add-btn" @click="addRule">+ 添加规则</el-button>
<div class="note mt10">
结算入参ChargeUsage 扩展本期备用<code>{promptTokens, completionTokens, cachedTokens, charCount, imageCount, mediaType, thinking, outputAudio, outputResolution}</code>匹配 = 全部条件满足的<b>首条规则</b>命中无命中报错
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import {
EXAMPLES,
MEDIA_PRICE_OPTIONS,
OUTPUT_AUDIO_OPTIONS,
RESOLUTION_OPTIONS,
THINKING_OPTIONS,
UNIT_OPTIONS,
type BuiltModelRule,
type BuiltModelRuleMatch,
type Currency,
type EditorInitConfig,
type MediaPriceRow,
type ModelRule,
type ModelRules,
type Subject,
type Unit,
} from '../mock';
/**
* 模型计价编辑面板(按模型类型自适应)。
* 计费单位决定计价项(token 单位 input/output/cacheHit,非 token 单位 unitPrice);
* 命中条件 match 字段随模型类型变化(reason/video/audio/image)。
* initConfig 传入后端已保存配置(parse 后)时初始化表单;无配置时自动加载本地示例。
*/
const props = defineProps<{
subject: Subject;
initConfig: EditorInitConfig | null;
}>();
const examples = computed(() => EXAMPLES[props.subject.modelType!] ?? []);
const unit = ref<Unit>('per_second');
const tiered = ref(false);
const currency = ref<'CNY' | 'USD'>('CNY');
const enabled = ref(true);
const minBalance = ref(0);
const modelRules = ref<ModelRule[]>([]);
const exampleIndex = ref(0);
const isTokenUnit = computed(() => unit.value === 'per_1K' || unit.value === 'per_1M');
const priceNote = computed(() =>
isTokenUnit.value
? 'token 单位价格按 元/基准(per_1M=每百万 token)计:input=输入单价、output=输出单价、cacheHit=缓存命中输入单价。'
: '非 token 单位价格按 元/单位 计:unitPrice=每单位单价(秒/分钟/小时/字/张)。',
);
function emptyRule(): ModelRule {
return {
name: '',
thinking: '',
outputAudio: '',
outputResolution: '',
inLenMin: undefined,
inLenMax: undefined,
input: 0,
output: 0,
cacheHit: 0,
unitPrice: 0,
mediaPrices: [],
};
}
function loadExample(index: number) {
const ex = examples.value[index];
if (!ex) return;
exampleIndex.value = index;
unit.value = ex.unit;
tiered.value = !!ex.tiered;
if (ex.currency) currency.value = ex.currency;
if (ex.minBalance != null) minBalance.value = ex.minBalance;
modelRules.value = ex.rules.map((r) => ({
name: r.name,
thinking: (r.thinking == null ? '' : String(r.thinking)) as ModelRule['thinking'],
outputAudio: (r.outputAudio == null ? '' : String(r.outputAudio)) as ModelRule['outputAudio'],
outputResolution: r.outputResolution || '',
inLenMin: r.inLenMin == null ? undefined : r.inLenMin,
inLenMax: r.inLenMax == null ? undefined : r.inLenMax,
input: r.input ?? 0,
output: r.output ?? 0,
cacheHit: r.cacheHit ?? 0,
unitPrice: r.unitPrice ?? 0,
mediaPrices: mediaPricesToRows(r.mediaPrices),
}));
onUnitChange();
}
function onUnitChange() {
// 非 token 单位强制关闭阶梯
if (!isTokenUnit.value) tiered.value = false;
}
function addRule() {
modelRules.value.push(emptyRule());
}
function removeRule(index: number) {
modelRules.value.splice(index, 1);
}
/** 构建态规则 → 编辑器行态(parse 反向映射) */
function fromBuiltRule(r: BuiltModelRule): ModelRule {
const m = r.match;
return {
name: r.name,
thinking: (m?.thinking == null ? '' : String(m.thinking)) as ModelRule['thinking'],
outputAudio: (m?.outputAudio == null ? '' : String(m.outputAudio)) as ModelRule['outputAudio'],
outputResolution: m?.outputResolution || '',
inLenMin: m?.inputLengthMin,
inLenMax: m?.inputLengthMax,
input: r.price.input ?? 0,
output: r.price.output ?? 0,
cacheHit: r.price.cacheHit ?? 0,
unitPrice: r.price.unitPrice ?? 0,
mediaPrices: mediaPricesToRows(r.mediaPrices),
};
}
/** 用后端已保存配置初始化表单 */
function applyInit(cfg: EditorInitConfig) {
const r = cfg.rules as ModelRules;
unit.value = r.unit;
tiered.value = !!r.tiered;
currency.value = r.currency || cfg.currency;
enabled.value = cfg.enabled;
minBalance.value = cfg.minBalance;
modelRules.value = (r.rules || []).map(fromBuiltRule);
exampleIndex.value = -1;
if (!isTokenUnit.value) tiered.value = false;
}
/** 过滤空白规则:规则名或任一命中/价格字段非空才保留 */
function keepRule(r: ModelRule): boolean {
if (r.name.trim()) return true;
if (r.thinking || r.outputAudio || r.outputResolution) return true;
if (r.inLenMin != null || r.inLenMax != null) return true;
if (r.input > 0 || r.output > 0 || r.cacheHit > 0 || r.unitPrice > 0) return true;
// 仅填写了媒体价格模板也算有效规则
return !!buildMediaPrices(r.mediaPrices);
}
/** 编辑器行态 → 构建态规则(match 只收录非空字段) */
function buildBuiltRule(r: ModelRule): BuiltModelRule {
const match: BuiltModelRuleMatch = {};
if (r.thinking === 'true' || r.thinking === 'false') match.thinking = r.thinking === 'true';
if (r.outputAudio === 'true' || r.outputAudio === 'false') match.outputAudio = r.outputAudio === 'true';
if (r.outputResolution) match.outputResolution = r.outputResolution;
if (r.inLenMin != null) match.inputLengthMin = r.inLenMin;
if (r.inLenMax != null) match.inputLengthMax = r.inLenMax;
const price: Record<string, number> = isTokenUnit.value
? { input: r.input ?? 0, output: r.output ?? 0, cacheHit: r.cacheHit ?? 0 }
: { unitPrice: r.unitPrice ?? 0 };
return {
name: r.name,
match: Object.keys(match).length ? match : undefined,
price,
mediaPrices: buildMediaPrices(r.mediaPrices),
};
}
/** 媒体价格行 → mediaPrices 对象(仅保留填写了价格的媒体;无覆盖返回 undefined 不写入) */
function buildMediaPrices(rows: MediaPriceRow[]): Record<string, Record<string, number>> | undefined {
if (!rows?.length) return undefined;
const map: Record<string, Record<string, number>> = {};
rows.forEach((row) => {
if (!row.mediaType) return;
const prices: Record<string, number> = {};
if (row.input > 0) prices.input = row.input;
if (row.output > 0) prices.output = row.output;
if (row.cacheHit > 0) prices.cacheHit = row.cacheHit;
if (Object.keys(prices).length) map[row.mediaType] = prices;
});
return Object.keys(map).length ? map : undefined;
}
/** mediaPrices 对象 → 编辑态媒体价格行(兼容后端两种结构:按媒体分组的内层对象,或空/缺失) */
function mediaPricesToRows(mediaPrices?: unknown): MediaPriceRow[] {
if (!mediaPrices || typeof mediaPrices !== 'object') return [];
return Object.entries(mediaPrices as Record<string, Record<string, number | undefined>>)
.filter(([, prices]) => prices && typeof prices === 'object')
.map(([mediaType, prices]) => ({
mediaType,
input: prices.input ?? 0,
output: prices.output ?? 0,
cacheHit: prices.cacheHit ?? 0,
}));
}
/** 添加一条媒体价格行 */
function addMediaPrice(r: ModelRule) {
r.mediaPrices.push({ mediaType: '', input: 0, output: 0, cacheHit: 0 });
}
/** 删除一条媒体价格行 */
function removeMediaPrice(r: ModelRule, index: number) {
r.mediaPrices.splice(index, 1);
}
/** 媒体价格行的可选媒体:排除本规则其他行已选中的媒体,避免同一条规则重复选择同一媒体 */
function getAvailableMediaOptions(r: ModelRule, curIndex: number) {
const selected = new Set(r.mediaPrices.filter((_, i) => i !== curIndex).map((row) => row.mediaType));
return MEDIA_PRICE_OPTIONS.filter((opt) => !selected.has(opt.value) || r.mediaPrices[curIndex]?.mediaType === opt.value);
}
/** 构建模型费率 JSON(对齐后端结构,由父组件 stringify 提交) */
function buildRules(): ModelRules {
return {
unit: unit.value,
tiered: tiered.value,
currency: currency.value,
rules: modelRules.value.filter(keepRule).map(buildBuiltRule),
};
}
function save(): { rules: ModelRules; minBalance: number; currency: Currency; enabled: boolean } {
return {
rules: buildRules(),
minBalance: minBalance.value,
currency: currency.value,
enabled: enabled.value,
};
}
watch(
() => props.initConfig,
(cfg) => {
if (cfg && (cfg.rules as ModelRules).unit) {
// 有后端已保存配置 → 用它初始化
applyInit(cfg);
} else if (examples.value.length) {
// 无配置 → 自动加载该类型第一个示例(对齐原型行为)
loadExample(0);
} else {
modelRules.value = [emptyRule()];
}
},
{ immediate: true },
);
defineExpose({ save });
</script>
<style scoped lang="scss">
.editor-body {
display: block;
}
.editor-col {
width: 100%;
min-width: 0;
}
.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;
}
.rule-card {
border: 1px solid #e4e7ed;
border-radius: 8px;
margin-bottom: 12px;
padding: 12px;
.rule-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.rule-index {
color: #909399;
font-size: 12px;
flex-shrink: 0;
}
.rule-name {
flex: 1;
}
}
}
.rc-sec {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #909399;
margin: 10px 0 6px;
&::before {
content: '';
width: 3px;
height: 12px;
border-radius: 2px;
}
&.match::before {
background: #e6a23c;
}
&.price::before {
background: #409eff;
}
}
.rc-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0 12px;
.rc-control {
width: 100%;
}
}
.add-btn {
width: 100%;
}
/* 媒体价格模板区(token 单位):基础价下方的媒体覆盖价动态行 */
.media-price-section {
margin-top: 12px;
border-top: 1px dashed #e4e7ed;
padding-top: 10px;
}
.media-price-row {
display: flex;
align-items: flex-end;
gap: 8px;
margin-bottom: 8px;
.mp-media {
width: 120px;
flex-shrink: 0;
}
.mp-field {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
.mp-label {
font-size: 12px;
color: #606266;
}
.mp-num {
width: 100%;
}
}
}
.media-add-btn {
width: 100%;
}
.mp-tip {
font-size: 12px;
color: #909399;
line-height: 1.7;
margin-top: 8px;
}
.note {
border: 1px solid #e6a23c;
background: #fdf6ec;
color: #b88230;
border-radius: 6px;
padding: 10px 14px;
font-size: 12.5px;
line-height: 1.7;
}
.cfg-row {
display: flex;
align-items: flex-end;
gap: 40px;
flex-wrap: wrap;
}
.cfg-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.cfg-label {
font-size: 13px;
color: #606266;
}
.cfg-min-balance {
width: 200px;
}
.cfg-currency {
width: 200px;
}
.ctrl-select {
width: 220px;
}
</style>
@@ -0,0 +1,211 @@
<template>
<div class="subject-list">
<div v-if="loading" class="subject-loading">加载计价对象</div>
<template v-else>
<!-- 工作流 -->
<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
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">{{ modelSubjects.length }}</span>
</div>
<div
v-for="s in modelSubjects"
: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="!modelSubjects.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
class="status-dot"
:class="s.hasConfig ? 'on' : 'off'"
:title="s.hasConfig ? '已配置' : '未配置'"
/>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { 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'));
</script>
<style scoped lang="scss">
.subject-list {
.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;
}
}
.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,343 @@
<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">
<div class="cfg-row">
<div class="cfg-item">
<div class="cfg-label">门禁金额</div>
<el-input-number v-model="minBalance" :min="0" :precision="5" :step="0.01" :controls="false" class="cfg-min-balance" />
</div>
<div class="cfg-item">
<div class="cfg-label">币种</div>
<el-select v-model="currency" class="cfg-currency">
<el-option label="人民币" value="CNY" />
<el-option label="美元" value="USD" />
</el-select>
</div>
<div class="cfg-item">
<div class="cfg-label">启用</div>
<el-switch v-model="enabled" />
</div>
</div>
</div>
</div>
</div>
<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>
<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="档位列表(上不封顶,命中首个档位上限 ≥ 时长的档)">
<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="档位上限(秒)"
class="tier-input"
/>
<el-input-number
v-model="tier.price"
:min="0"
:precision="5"
: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="5"
: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>
<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="5"
: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>
<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>
</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: block;
}
.editor-col {
width: 100%;
min-width: 0;
}
.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-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;
}
.cfg-row {
display: flex;
align-items: flex-end;
gap: 40px;
flex-wrap: wrap;
}
.cfg-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.cfg-label {
font-size: 13px;
color: #606266;
}
.cfg-min-balance {
width: 200px;
}
.cfg-currency {
width: 200px;
}
</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 `工作流对象 · 标识:${s.id}`;
if (s.type === 'model') return `模型对象(${s.typeLabel})· 标识:${s.id}`;
return `业务模块对象 · 标识:${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>
+366
View File
@@ -0,0 +1,366 @@
/**
* 价格管理(计价配置管理)—— 类型定义、接口映射与本地示例数据
*
* 说明:
* - 计价对象枚举来自后端 GET .../subjects(见 src/api/trade/pricing/index.ts),
* 本文件提供后端项 → 页面 Subject 的映射(中文 modelType/modelTypeCode → 英文枚举等)
* - 计价配置读取/提交后端的 rules 均为对象(config/get 返回对象,save 也传对象)
* - 模型类型编码: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;
/** 该对象支持的计费模式(workflow/business 由后端 subjects.chargeModes 下发;model 无此字段) */
chargeModes?: string[];
}
/** modelTypeCode → 英文模型类型 */
export const MODEL_TYPE_CODE_TO_EN: Record<number, ModelType> = {
100: 'reason',
200: 'image',
300: 'audio',
600: 'video',
};
/** 后端枚举项 → 页面 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: item.chargeModes ?? [],
hasConfig: false,
};
}
/* ================= 计价对象枚举(页面态) ================= */
export interface Subject {
type: SubjectType;
/** subjectId */
id: string;
/** 显示名称 */
name: string;
/** 仅 model:模型类型(英文枚举,由 modelTypeCode 映射) */
modelType?: ModelType;
/** 仅 model:模型类型编码 */
modelTypeCode?: number;
/** 仅 model:模型中文类型(推理/视频/音频/图片) */
typeLabel?: string;
/** workflow/business:支持的计费模式(后端 subjects.chargeModes 下发) */
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 ================= */
/** 媒体价格模板行(token 单位):对特定媒体类型设置独立覆盖价(mediaPrices */
export interface MediaPriceRow {
/** 媒体类型:text / audio / video / image */
mediaType: string;
/** 输入单价 */
input: number;
/** 输出单价 */
output: number;
/** 缓存命中输入单价 */
cacheHit: number;
}
/** 编辑器内部规则行态(thinking/outputAudio 用字符串承载"未设置";媒体命中条件已移除,媒体改走价格模板) */
export interface ModelRule {
name: string;
thinking: '' | 'true' | 'false';
outputAudio: '' | 'true' | 'false';
outputResolution: string;
inLenMin?: number;
inLenMax?: number;
/** token 单位:输入单价 */
input: number;
/** token 单位:输出单价 */
output: number;
/** token 单位:缓存命中输入单价 */
cacheHit: number;
/** 非 token 单位:每单位单价 */
unitPrice: number;
/** 媒体价格模板行(token 单位;空数组=无媒体覆盖价) */
mediaPrices: MediaPriceRow[];
}
/** 示例数据源规则(thinking/outputAudio 为布尔,由 loadExample 转换) */
export interface ExampleRule {
name: string;
thinking?: boolean;
outputAudio?: boolean;
outputResolution?: string;
inLenMin?: number;
inLenMax?: number;
input?: number;
output?: number;
cacheHit?: number;
unitPrice?: number;
/** 媒体价格模板示例(可选):媒体类型 → 覆盖价 */
mediaPrices?: Record<string, { input?: number; output?: number; cacheHit?: number }>;
}
/** 构建态:规则命中条件(媒体类型已改为价格模板,不再作为命中条件) */
export interface BuiltModelRuleMatch {
thinking?: boolean;
outputAudio?: boolean;
outputResolution?: string;
inputLengthMin?: number;
inputLengthMax?: number;
}
/** 构建态:单条规则 */
export interface BuiltModelRule {
name: string;
match?: BuiltModelRuleMatch;
price: Record<string, number>;
/** 媒体价格模板(token 单位):媒体类型 → 该媒体独立覆盖价;无覆盖时省略 */
mediaPrices?: Record<string, 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: '每百万 Token' },
{ value: 'per_1K', label: '每千 Token' },
{ value: 'per_1', label: '每个 / 每张图' },
{ value: 'per_second', label: '每秒' },
{ value: 'per_minute', label: '每分钟' },
{ value: 'per_hour', label: '每小时' },
{ value: 'per_char', label: '每字' },
];
/** 媒体价格模板可选媒体类型(媒体命中条件已移除,媒体改按模板定价) */
export const MEDIA_PRICE_OPTIONS: { value: 'text' | 'audio' | 'video' | 'image'; label: string }[] = [
{ value: 'text', label: '文本' },
{ value: 'audio', label: '音频' },
{ value: 'video', label: '视频' },
{ value: 'image', label: '图片' },
];
/** 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: '视频·按秒(分辨率×有声/无声)',
unit: 'per_second',
tiered: false,
rules: [
{ name: '无声-720p', outputAudio: false, outputResolution: '720p', unitPrice: 0.8 },
{ name: '有声-1080p', outputAudio: true, outputResolution: '1080p', unitPrice: 1.5 },
],
},
{
label: '视频·按token(百万)',
unit: 'per_1M',
tiered: false,
rules: [
{ 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: '推理·阶梯(思考×输入长度档×媒体模板)',
unit: 'per_1M',
tiered: true,
rules: [
{ 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 },
],
},
],
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 },
],
},
],
};
@@ -0,0 +1,111 @@
<template>
<div class="system-recharge-user-container">
<el-dialog title="用户充值" v-model="isShowDialog" width="520px">
<el-form ref="formRef" :model="ruleForm" :rules="rules" size="default" label-width="110px">
<el-form-item label="账户名称" prop="userName">
<el-input v-model="ruleForm.userName" disabled placeholder="请输入账户名称"></el-input>
</el-form-item>
<el-form-item label="充值金额" prop="amount">
<el-input v-model="ruleForm.amount" placeholder="请输入充值金额(单位:元)" clearable></el-input>
</el-form-item>
<el-form-item label="外部订单号" prop="orderNo">
<el-input v-model="ruleForm.orderNo" placeholder="请输入外部订单号(选填)" clearable></el-input>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="ruleForm.description" type="textarea" placeholder="请输入描述(选填)" maxlength="150"></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="onCancel" size="default"> </el-button>
<el-button type="primary" @click="onSubmit" size="default"> </el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script lang="ts">
import { reactive, toRefs, defineComponent, ref, unref } from 'vue';
import { ElMessage } from 'element-plus';
import { recharge } from '/@/api/system/user';
export default defineComponent({
name: 'systemRechargeUser',
setup() {
const formRef = ref<HTMLElement | null>(null);
const state = reactive({
isShowDialog: false,
ruleForm: {
userId: 0,
userName: '',
amount: '',
orderNo: '',
description: '',
},
//表单校验
rules: {
amount: [
{ required: true, message: '充值金额不能为空', trigger: 'blur' },
{
validator: (_rule: any, value: any, callback: any) => {
if (!/^\d+(\.\d{1,2})?$/.test(value) || Number(value) <= 0) {
callback(new Error('请输入大于 0 的金额(单位:元,最多两位小数)'));
} else {
callback();
}
},
trigger: 'blur',
},
],
},
});
// 打开弹窗
const openDialog = (row: any) => {
state.ruleForm = {
userId: row.id,
userName: row.userName,
amount: '',
orderNo: '',
description: '',
};
state.isShowDialog = true;
};
// 关闭弹窗
const closeDialog = () => {
state.isShowDialog = false;
};
// 取消
const onCancel = () => {
closeDialog();
};
// 提交
const onSubmit = () => {
const formWrap = unref(formRef) as any;
if (!formWrap) return;
formWrap.validate((valid: boolean) => {
if (valid) {
recharge({
userId: state.ruleForm.userId,
userName: state.ruleForm.userName,
amount: Number(state.ruleForm.amount),
orderNo: state.ruleForm.orderNo,
description: state.ruleForm.description,
}).then(() => {
ElMessage.success('充值成功');
closeDialog();
});
}
});
};
return {
openDialog,
closeDialog,
onCancel,
onSubmit,
formRef,
...toRefs(state),
};
},
});
</script>
@@ -0,0 +1,68 @@
<template>
<div class="system-wallet-log-container">
<el-dialog title="钱包流水信息" v-model="isShowDialog" width="1000px">
<el-table :data="logs" style="width: 100%" :max-height="420">
<el-table-column prop="orderNo" label="业务单据号" show-overflow-tooltip></el-table-column>
<el-table-column prop="transactionNo" label="账务流水号" show-overflow-tooltip></el-table-column>
<el-table-column prop="type" label="类型" width="80" align="center">
<template #default="scope">
<el-tag :type="scope.row.type === 'income' ? 'success' : 'danger'">{{ scope.row.type === 'income' ? '充值' : '扣费' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="amount" label="变动金额(元)" width="120" align="right"></el-table-column>
<el-table-column prop="balanceBefore" label="变动前余额(元)" width="130" align="right"></el-table-column>
<el-table-column prop="balanceAfter" label="变动后余额(元)" width="130" align="right"></el-table-column>
<el-table-column prop="currency" label="货币" width="80" align="center"></el-table-column>
<el-table-column prop="description" label="描述" show-overflow-tooltip></el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="170"></el-table-column>
</el-table>
<div class="system-wallet-log-pagination">
<pagination :total="total" v-model:page="pageNum" v-model:limit="pageSize" @pagination="handlePagination" />
</div>
</el-dialog>
</div>
</template>
<script lang="ts">
import { reactive, toRefs, defineComponent } from 'vue';
import { getWalletLogs } from '/@/api/system/user';
export default defineComponent({
name: 'systemWalletLog',
setup() {
const state = reactive({
isShowDialog: false,
userId: 0,
logs: [] as any[],
total: 0,
pageNum: 1,
pageSize: 10,
});
// 请求流水数据(后端分页)
const getLogs = () => {
getWalletLogs(state.userId, state.pageNum, state.pageSize).then((res: any) => {
state.logs = res.data.logs ?? [];
state.total = res.data.total ?? 0;
});
};
// 打开弹窗并加载流水
const openDialog = (userId: number) => {
state.userId = userId;
state.logs = [];
state.total = 0;
state.pageNum = 1;
state.isShowDialog = true;
getLogs();
};
// 分页事件(pageNum/pageSize 已由 v-model 更新,重新请求当前页)
const handlePagination = () => {
getLogs();
};
return {
openDialog,
handlePagination,
...toRefs(state),
};
},
});
</script>
+39 -2
View File
@@ -117,7 +117,7 @@
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" show-overflow-tooltip></el-table-column>
<el-table-column label="操作" width="200">
<el-table-column label="操作" width="320">
<template #default="scope">
<el-button
size="small"
@@ -140,6 +140,25 @@
>删除</el-button
>
<el-button size="small" text type="primary" v-debounce @click="handleResetPwd(scope.row)">重置</el-button>
<el-button
size="small"
text
type="primary"
class="op-btn-recharge"
:disabled="!scope.row.isOperation"
v-debounce
@click="onOpenRecharge(scope.row)"
>充值</el-button
>
<el-button
size="small"
text
type="primary"
:disabled="!scope.row.isOperation"
v-debounce
@click="onOpenWalletLog(scope.row)"
>流水信息</el-button
>
</template>
</el-table-column>
</el-table>
@@ -154,6 +173,8 @@
</el-col>
</el-row>
<EditUser ref="editUserRef" :dept-data="deptData" :gender-data="sys_user_sex" @getUserList="userList" />
<RechargeUser ref="rechargeUserRef" />
<WalletLog ref="walletLogRef" />
</div>
</template>
@@ -162,6 +183,8 @@ import { toRefs, reactive, onMounted, ref, defineComponent, watch, getCurrentIns
import { ElMessageBox, ElMessage, ElTree, FormInstance } from 'element-plus';
import { Search } from '@element-plus/icons-vue';
import EditUser from '/@/views/system/user/component/editUser.vue';
import RechargeUser from '/@/views/system/user/component/rechargeUser.vue';
import WalletLog from '/@/views/system/user/component/walletLog.vue';
import { getUserList, getDeptTree, resetUserPwd, changeUserStatus, deleteUser } from '/@/api/system/user/index';
interface TableDataState {
@@ -186,11 +209,13 @@ interface TableDataState {
export default defineComponent({
name: 'systemUser',
components: { EditUser },
components: { EditUser, RechargeUser, WalletLog },
setup() {
const { proxy } = <any>getCurrentInstance();
const { sys_user_sex } = proxy.useDict('sys_user_sex');
const editUserRef = ref();
const rechargeUserRef = ref();
const walletLogRef = ref();
const queryRef = ref();
const filterText = ref('');
const treeRef = ref<InstanceType<typeof ElTree>>();
@@ -259,6 +284,14 @@ export default defineComponent({
const onOpenEditUser = (row: any) => {
editUserRef.value.openDialog(row);
};
// 打开充值弹窗
const onOpenRecharge = (row: any) => {
rechargeUserRef.value.openDialog(row);
};
// 打开流水信息弹窗
const onOpenWalletLog = (row: any) => {
walletLogRef.value.openDialog(row.id);
};
// 删除用户
const onRowDel = (row: any) => {
let msg = '你确定要删除所选用户?';
@@ -358,8 +391,12 @@ export default defineComponent({
return {
queryRef,
editUserRef,
rechargeUserRef,
walletLogRef,
onOpenAddUser,
onOpenEditUser,
onOpenRecharge,
onOpenWalletLog,
onRowDel,
onHandleSizeChange,
onHandleCurrentChange,