Author SHA1 Message Date
admin 7c286f639e admin-ui ng转发域名增加 2026-09-11 00:13:41 +08:00
admin 56131a4662 Merge branch 'feature/home-workflow' of https://gitea.redpowerfuture.com/red-future/admin-ui into feature/home-workflow 2026-09-07 10:45:37 +08:00
admin 7209cd3ba2 admin-ui ng转发域名增加 2026-09-07 10:42:49 +08:00
2910410219andClaude Code a5262941ea 价格管理:媒体价格模板三个输入框增加上方 label
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-01 17:20:16 +08:00
2910410219andClaude Code 490abd60e9 价格管理:label 中文化、金额保留5位小数、通用配置置顶重排、取消模型类型筛选、去除列表计费方式标签
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-01 17:15:55 +08:00
2910410219andClaude Code aa7c8856fb 新增:首页工作流失败卡片提供「查看失败原因」按钮(默认收起、点击展开,绑定 error 字段);实时与历史回显均展示详细失败原因
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-01 15:39:21 +08:00
2910410219andClaude Code 3f9b5e59a8 新增:首页工作流执行必选计费方式(重跑回显上次选择);修复:退出登录整页刷新解决重新登录后动态菜单404
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-01 15:09:24 +08:00
2910410219andClaude Code fbe2bcfee6 新增:模型计价编辑器适配新计费结构(mediaPrices媒体价格模板、thinking/outputAudio/outputResolution中文展示与下拉)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-01 15:09:15 +08:00
2910410219andClaude Code bdbf989b5e 新增:价格管理(计价配置管理)页面与 API
- 左侧计价对象枚举列表(工作流 / 模型 / 业务模块,模型支持类型筛选与配置状态圆点)
- 右侧按对象类型切换工作流 / 模型 / 业务编辑器,费率规则编辑与后端配置回显
- 新增 src/api/trade/pricing 接口(subjects / config/get / config/save)
- rules 兼容对象与历史字符串两种形态;配置 id 用字符串承载避免雪花 ID 精度丢失

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-01 11:47:37 +08:00
2910410219andClaude c2244f50b1 新增:用户管理增加充值功能与钱包流水查询
- 操作列新增"充值""流水信息"按钮,权限与修改一致(isOperation 控制)
- 新增充值弹窗 rechargeUser.vue:展示账户名称(只读),金额单位元(校验大于0、最多两位小数),提交 userId/userName/amount/orderNo/description
- 新增流水弹窗 walletLog.vue:展示充值与扣费明细(income/expense),金额元,后端分页(pageNum/pageSize)
- 新增 recharge、getWalletLogs 接口,路径 shop-user-trade/account/controller/*
- other.scss 补充 op-btn-recharge 固定绿色样式(含 dark 主题)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 15:28:03 +08:00
2910410219 43d4392873 Merge branch 'feature/home-workflow' of http://116.204.74.41:3000/red-future/admin-ui into feature/home-workflow 2026-08-27 11:55:00 +08:00
2910410219andClaude 0ec961e210 修复:贴片模板统一走CDN参考(默认模板与下载、移除本地模板文件)、首页新建会话首次执行后贴片模板回显空白
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 11:54:50 +08:00
admin 3b0369850f admin-ui ng转发域名增加 2026-08-27 09:23:22 +08:00
admin 42b6e75b77 Merge remote-tracking branch 'origin/feature/home-workflow' into feature/home-workflow 2026-08-25 17:07:27 +08:00
admin d62f79a407 admin-ui ng转发域名增加 2026-08-25 17:07:12 +08:00
2910410219 ef4976ee03 Merge branch 'feature/home-workflow' of http://116.204.74.41:3000/red-future/admin-ui into feature/home-workflow 2026-08-24 18:25:46 +08:00
2910410219andClaude cf6a17387d 修复:子例程引入便签名称兼容模板字段、模板创建成功后刷新首页占位工作流列表
- 工作流管理引入子例程:存名/提示补 flowTemplateName,管理员引入模板时便签不再显示数字 id
- 首页模板补全创建成功:同步刷新占位页工作流列表(MainContent 暴露 loadPlaceWorkflows),点叉取消后可见新建工作流

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 18:25:32 +08:00
2910410219andClaude dd4e719f55 工作流功能完善:子例程弹窗角色分支与模板名称修复、模板子工作流递归补全、首页工作流名称省略+tooltip
- 引入子例程弹窗:按 isAdmin 分支读取 listFlowTemplateRes/listFlowUserRes,名称兼容 flowTemplateName
- 模板补全弹窗:递归展开子工作流模型节点,一次 PUT 更新整棵树(updateWorkflowFromTemplate)
- 首页工作流胶囊/占位卡片:名称单行省略 + el-tooltip 悬停显示全名

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 18:09:34 +08:00
31 changed files with 3058 additions and 457 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
# ==================== 第一阶段:构建前端 ====================
FROM docker.m.daocloud.io/node:18-alpine AS builder
FROM node:18-alpine AS builder
WORKDIR /app
# 配置Alpine国内镜像源
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
@@ -13,7 +13,7 @@ COPY . .
RUN npm run build
# ==================== 第二阶段:部署到Nginx ====================
FROM docker.m.daocloud.io/nginx:alpine
FROM nginx:alpine
# 复制构建产物
COPY --from=builder /app/dist/ /usr/share/nginx/html/
+8
View File
@@ -78,6 +78,7 @@ server {
server {
listen 80;
server_name gitea.redpowerfuture.com;
client_max_body_size 500M;
if ($badbot) {
return 444;
}
@@ -88,6 +89,7 @@ server {
server {
listen 80;
server_name minio.redpowerfuture.com;
client_max_body_size 500M;
location / {
proxy_pass http://__API_HOST__:9001;
proxy_http_version 1.1;
@@ -106,6 +108,7 @@ server {
server {
listen 80;
server_name consul.redpowerfuture.com;
client_max_body_size 500M;
location / {
proxy_pass http://__API_HOST__:8500;
proxy_http_version 1.1;
@@ -120,6 +123,7 @@ server {
server {
listen 80;
server_name meilisearch.redpowerfuture.com;
client_max_body_size 500M;
location / {
proxy_pass http://__API_HOST__:7700;
proxy_http_version 1.1;
@@ -133,6 +137,7 @@ server {
server {
listen 80;
server_name pm.redpowerfuture.com;
client_max_body_size 500M;
location / {
proxy_pass http://__API_HOST__:8080;
proxy_http_version 1.1;
@@ -146,6 +151,7 @@ server {
server {
listen 80;
server_name jaeger.redpowerfuture.com;
client_max_body_size 500M;
location / {
proxy_pass http://__API_HOST__:16686;
proxy_http_version 1.1;
@@ -159,6 +165,7 @@ server {
server {
listen 80;
server_name observer.redpowerfuture.com;
client_max_body_size 500M;
location / {
proxy_pass http://__API_HOST__:18080;
proxy_http_version 1.1;
@@ -242,6 +249,7 @@ server {
server {
listen 443 ssl;
server_name gitea.redpowerfuture.com;
client_max_body_size 500M;
ssl_certificate /etc/nginx/ssl/gitea.redpowerfuture.com.pem;
ssl_certificate_key /etc/nginx/ssl/gitea.redpowerfuture.com.key;
-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>
+20 -1
View File
@@ -113,7 +113,7 @@ export interface SubFlowWorkflowItem {
}
// 获取"我的工作流"列表(IsOwn=true 只取本人创建,供 sub_flow 节点选择引入)
// 响应结构多形态容错在调用方处理(data.list ?? data.listFlowUserRes.list
// 响应结构多形态容错在调用方处理(扁平 list 优先,否则按 isAdmin 分支读取 listFlowTemplateRes / listFlowUserRes
export function getSubFlowWorkflowList(params?: {
pageNum: number;
pageSize: number;
@@ -148,3 +148,22 @@ export function downloadWorkspaceFile(data: { fileURL: string }, requestOptions?
requestOptions,
});
}
// 与后端 UpdateFlowUserReq 对齐的更新树:id/flowName/description/flowContent/subFlows
// subFlows 递归嵌套,一次 PUT 更新整棵「主流程 + 嵌套子流程」树);独立实现,不动内容创作的 API
export interface UpdateFlowUserTree {
id: string;
flowName: string;
description: string;
flowContent: any;
subFlows?: UpdateFlowUserTree[];
}
export function updateWorkflowFromTemplate(data: UpdateFlowUserTree, requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/flow/user/update',
method: 'put',
data,
requestOptions,
});
}
+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;
+23 -8
View File
@@ -55,17 +55,23 @@
<!-- 快捷工作流胶囊输入框下方 -->
<Transition name="capsule-slide" @enter="onCapsuleEnter" @after-enter="onCapsuleAfterEnter" @leave="onCapsuleLeave" @after-leave="onCapsuleAfterLeave">
<div v-if="visibleWorkflows.length > 0 && !hideShortcuts" class="workflow-shortcuts">
<button
<el-tooltip
v-for="wf in visibleWorkflows"
:key="wf.id"
class="shortcut-pill"
:class="{ active: selectedWorkflowId === wf.id }"
@click="toggleWorkflow(wf.id)"
:content="wf.name"
placement="top"
:show-after="150"
>
<el-icon><Promotion /></el-icon>
{{ wf.name }}
<span v-if="wf.isTemplate" class="pill-tag">模板</span>
</button>
<button
class="shortcut-pill"
:class="{ active: selectedWorkflowId === wf.id }"
@click="toggleWorkflow(wf.id)"
>
<el-icon><Promotion /></el-icon>
<span class="pill-name">{{ wf.name }}</span>
<span v-if="wf.isTemplate" class="pill-tag">模板</span>
</button>
</el-tooltip>
<!-- 更多常驻入口点击跳转工作流管理页查看/选择全部不依赖工作流数量超出上限 -->
<button
class="shortcut-pill shortcut-more"
@@ -516,6 +522,15 @@ const onCapsuleAfterLeave = (el: Element) => {
}
}
/* 胶囊内工作流名称:单行省略显示(不撑宽胶囊),悬停经 el-tooltip 展示完整名称 */
.pill-name {
max-width: 140px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 「更多」入口:与普通胶囊区分,虚线边框 + 更轻的视觉重量,提示可进入管理页查看全部 */
.shortcut-more {
border-style: dashed;
+19 -9
View File
@@ -26,17 +26,23 @@
<p class="placeholder-desc">选择一个工作流,填写参数后一键生成</p>
<div v-if="placeWorkflows.length" class="workflow-cards">
<button
<el-tooltip
v-for="wf in placeWorkflows"
:key="wf.id"
type="button"
class="workflow-card"
@click="handlePlaceSelect(wf)"
:content="wf.name"
placement="top"
:show-after="150"
>
<el-icon class="wf-card-icon"><Promotion /></el-icon>
<span class="wf-card-name">{{ wf.name }}</span>
<span v-if="wf.isTemplate" class="wf-card-tag">模板</span>
</button>
<button
type="button"
class="workflow-card"
@click="handlePlaceSelect(wf)"
>
<el-icon class="wf-card-icon"><Promotion /></el-icon>
<span class="wf-card-name">{{ wf.name }}</span>
<span v-if="wf.isTemplate" class="wf-card-tag">模板</span>
</button>
</el-tooltip>
<!-- 「更多」常驻入口:点击跳转工作流管理页查看/选择全部,不依赖工作流数量超出上限 -->
<button
type="button"
@@ -126,6 +132,8 @@ const goWorkflowManage = () => {
onMounted(() => {
loadPlaceWorkflows();
});
defineExpose({ loadPlaceWorkflows });
</script>
<style scoped lang="scss">
@@ -229,7 +237,9 @@ onMounted(() => {
font-weight: 600;
color: #1e293b;
line-height: 1.4;
word-break: break-word;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wf-card-tag {
font-size: 10px;
@@ -25,31 +25,36 @@
</el-form-item>
</el-form>
<el-empty v-if="modelNodes.length === 0" description="该模板无需补全模型参数" :image-size="60" />
<el-empty v-if="flowWorkflowGroups.length === 0" description="该模板无需补全模型参数" :image-size="60" />
<div v-for="node in modelNodes" :key="node.id" class="tc-node">
<div class="tc-node-title">
<span class="tc-node-name">{{ node.name || node.nodeCode }}</span>
<el-tag v-if="!node.modelConfig?.modelId" type="warning" size="small">未选择模型</el-tag>
<el-tag v-else-if="node.modelConfig?.modelName" type="success" size="small">{{ node.modelConfig.modelName }}</el-tag>
</div>
<!-- 主模板 / 子工作流 分组递归展开整棵工作流树每层模型节点单独补全 -->
<div v-for="group in flowWorkflowGroups" :key="group.wf.key" class="tc-group">
<div v-if="group.wf.key !== 'main'" class="tc-group-title">{{ group.wf.pathLabel }}</div>
<div class="tc-node-model">
<template v-if="node.modelConfig?.modelId">
<!-- 系统模型直接内联显示 API Key 输入框保存时统一转换 -->
<div v-if="systemModelNodeIds[node.id]" class="tc-api-key-inline">
<el-input
v-model="apiKeyInputs[node.id]"
type="password"
show-password
placeholder="该模型为系统内置模型,请输入你的 API Key"
size="small"
clearable
/>
</div>
<el-button type="primary" plain size="small" @click="openModelSelector(node)">重新选择模型</el-button>
</template>
<el-button v-else type="primary" plain size="small" @click="openModelSelector(node)">选择模型</el-button>
<div v-for="m in group.models" :key="m.nodeKey" class="tc-node">
<div class="tc-node-title">
<span class="tc-node-name">{{ m.node.name || m.node.nodeCode }}</span>
<el-tag v-if="!m.node.modelConfig?.modelId" type="warning" size="small">未选择模型</el-tag>
<el-tag v-else-if="m.node.modelConfig?.modelName" type="success" size="small">{{ m.node.modelConfig.modelName }}</el-tag>
</div>
<div class="tc-node-model">
<template v-if="m.node.modelConfig?.modelId">
<!-- 系统模型直接内联显示 API Key 输入框保存时统一转换 -->
<div v-if="systemModelNodeIds[m.nodeKey]" class="tc-api-key-inline">
<el-input
v-model="apiKeyInputs[m.nodeKey]"
type="password"
show-password
placeholder="该模型为系统内置模型,请输入你的 API Key"
size="small"
clearable
/>
</div>
<el-button type="primary" plain size="small" @click="openModelSelector(m)">重新选择模型</el-button>
</template>
<el-button v-else type="primary" plain size="small" @click="openModelSelector(m)">选择模型</el-button>
</div>
</div>
</div>
</template>
@@ -64,7 +69,7 @@
<ModelSelector
v-model="modelSelectorVisible"
:default-model="selectorDefaultModel"
:same-type-model-type="selectorTargetNode?.modelConfig?.modelType"
:same-type-model-type="selectorTargetRef?.node?.modelConfig?.modelType"
@confirm="handleModelConfirm"
/>
@@ -77,7 +82,8 @@ import { ref, computed, watch } from 'vue';
import { ElMessage } from 'element-plus';
import ModelSelector from '/@/views/settings/workflow/component/ModelSelector.vue';
import { stripReadonlyFields, deepClone, buildModelRequestParamsPath, enrichResponseBodyMapping } from '/@/views/settings/workflow/component/modelParamUtils';
import { getWorkflowDetail, saveWorkflow } from '/@/api/settings/creation';
import { getWorkflowDetail } from '/@/api/settings/creation';
import { updateWorkflowFromTemplate, getSubFlowWorkflowDetail } from '/@/api/settings/workflow';
import { getModelManage, updateModelManage } from '/@/api/settings/modelConfigV2';
interface Props {
@@ -90,41 +96,129 @@ interface Emits {
(e: 'saved', newId: string, flowName: string): void;
}
// 工作流树节点:主模板与嵌套子工作流统一建模,递归展开 sub_flow 引用
interface FlowWorkflowNode {
key: string; // 唯一 key:主模板 'main',子工作流 'sub:{workflowId}'
id: string; // 工作流 id(保存时定位;主模板即来源模板 id)
flowName: string; // 工作流原名(子工作流保存时沿用原名,不可改)
flowContent: any; // 该工作流完整 flowContent(保存时随 subFlows 提交)
nodes: any[]; // flowContent.nodes 引用
startNodeId: string; // 开始节点 idbuildModelRequestParamsPath 契约需要)
pathLabel: string; // 展示:'主模板' / '子工作流『xxx』'
hasModel: boolean; // 该层是否存在 model 节点(决定子树是否放入 subFlows 提交)
children: FlowWorkflowNode[];
}
// 模型节点引用:携带全局唯一 nodeKey(避免不同工作流内节点 id 冲突)与所属工作流 key
interface ModelNodeRef {
wfKey: string;
nodeKey: string;
node: any;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const loading = ref(false);
const saving = ref(false);
const flowName = ref('');
const nodes = ref<any[]>([]);
const templateFlowContent = ref<any>(null);
const flowTree = ref<FlowWorkflowNode | null>(null);
const modelRefs = ref<ModelNodeRef[]>([]);
// 防环:已展开过的工作流 id(子工作流可能互相引用 / 引用主模板)
const visitedFlowIds = new Set<string>();
const modelSelectorVisible = ref(false);
const selectorTargetNode = ref<any>(null);
const selectorTargetRef = ref<ModelNodeRef | null>(null);
const selectorDefaultModel = ref<any>(null);
// 补全 API Key:内联输入框暂存 Key,保存时统一转换为用户模型
const apiKeyInputs = ref<Record<string, string>>({});
const systemModelNodeIds = ref<Record<string, boolean>>({});
const modelNodes = computed(() => nodes.value.filter((n) => String(n?.nodeCode || '').toLowerCase() === 'model'));
// 开始节点 id:模板 DSL 与工作流管理 DSL 结构一致(nodeCode === '__start__'),
// 供 buildModelRequestParamsPath 为「表单展示」字段补 valueSource 指向开始节点
const getStartNodeId = () => {
const sn = nodes.value.find((n) => String(n?.nodeCode || '').toLowerCase() === '__start__');
return sn?.id || '';
};
// 按工作流分组(主模板在前,子工作流按树序),供模板分组渲染
const flowWorkflowGroups = computed(() => {
const groups: { wf: FlowWorkflowNode; models: ModelNodeRef[] }[] = [];
const walk = (wf: FlowWorkflowNode) => {
groups.push({ wf, models: modelRefs.value.filter((m) => m.wfKey === wf.key) });
for (const c of wf.children) walk(c);
};
if (flowTree.value) walk(flowTree.value);
return groups;
});
const canSave = computed(() => {
if (!flowName.value.trim()) return false;
// 所有模型节点都必须已绑定模型
if (modelNodes.value.some((n) => !n.modelConfig?.modelId)) return false;
// 整棵树所有模型节点都必须已绑定模型
if (modelRefs.value.some((m) => !m.node.modelConfig?.modelId)) return false;
// 系统模型节点必须已填写 API Key(未补全不可保存)
if (modelNodes.value.some((n) => systemModelNodeIds.value[n.id] && !(apiKeyInputs.value[n.id] || '').trim())) return false;
if (modelRefs.value.some((m) => systemModelNodeIds.value[m.nodeKey] && !(apiKeyInputs.value[m.nodeKey] || '').trim())) return false;
return true;
});
// 递归展开:遍历当前工作流 nodes 收集 model 节点;遇 sub_flow 递归拉取子工作流详情
const expandSubFlows = async (wf: FlowWorkflowNode) => {
for (const n of wf.nodes) {
const code = String(n?.nodeCode || '').toLowerCase();
if (code === 'model') {
wf.hasModel = true;
modelRefs.value.push({ wfKey: wf.key, nodeKey: `${wf.key}|${n.id}`, node: n });
} else if (code === 'sub_flow') {
const subWfId = String(n?.subConfig?.workflowId || '');
if (!subWfId || visitedFlowIds.has(subWfId)) continue;
visitedFlowIds.add(subWfId);
try {
const detail = (await getSubFlowWorkflowDetail(subWfId))?.data;
const fc = detail?.flowContent || {};
const childNodes = Array.isArray(fc.nodes) ? fc.nodes : [];
const child: FlowWorkflowNode = {
key: 'sub:' + subWfId,
id: subWfId,
flowName: detail?.flowName || '',
flowContent: fc,
nodes: childNodes,
startNodeId: childNodes.find((x: any) => String(x?.nodeCode || '').toLowerCase() === '__start__')?.id || '',
pathLabel: '子工作流『' + (detail?.flowName || subWfId) + '』',
hasModel: false,
children: [],
};
wf.children.push(child);
await expandSubFlows(child);
} catch {
// 子工作流详情拉取失败:跳过该子流程,不阻塞主模板补全
}
}
}
};
// 构建主模板树节点:优先拉取模板完整 DSL(get 接口),失败则回退列表项 flowContent
const buildMainTree = async () => {
let fc: any = null;
let mainNodes: any[] = [];
let detailFlowName = '';
try {
const detail = (await getWorkflowDetail(props.template?.id))?.data;
fc = detail?.flowContent || null;
mainNodes = JSON.parse(JSON.stringify(fc?.nodes || detail?.nodeInputParams || []));
detailFlowName = detail?.flowName || '';
} catch {
fc = props.template?.raw?.flowContent || props.template?.flowContent;
mainNodes = JSON.parse(JSON.stringify(fc?.nodes || []));
}
flowTree.value = {
key: 'main',
id: String(props.template?.id || ''),
flowName: detailFlowName || props.template?.flowName || '',
flowContent: fc,
nodes: mainNodes,
startNodeId: mainNodes.find((n: any) => String(n?.nodeCode || '').toLowerCase() === '__start__')?.id || '',
pathLabel: '主模板',
hasModel: false,
children: [],
};
visitedFlowIds.add(flowTree.value.id);
if (detailFlowName) flowName.value = '我的-' + detailFlowName;
};
watch(
() => props.modelValue,
async (val) => {
@@ -132,21 +226,14 @@ watch(
loading.value = true;
saving.value = false;
flowName.value = '我的-' + (props.template?.flowName || '系统模板');
nodes.value = [];
templateFlowContent.value = null;
flowTree.value = null;
modelRefs.value = [];
visitedFlowIds.clear();
apiKeyInputs.value = {};
systemModelNodeIds.value = {};
try {
// 优先拉取模板完整 DSL(get 接口),失败则回退列表项 flowContent
const res = await getWorkflowDetail(props.template?.id);
const detail = res.data;
templateFlowContent.value = detail?.flowContent || null;
nodes.value = JSON.parse(JSON.stringify(detail?.flowContent?.nodes || detail?.nodeInputParams || []));
if (detail?.flowName) flowName.value = '我的-' + detail.flowName;
} catch {
const fc = props.template?.raw?.flowContent || props.template?.flowContent;
templateFlowContent.value = fc || null;
nodes.value = JSON.parse(JSON.stringify(fc?.nodes || []));
await buildMainTree();
if (flowTree.value) await expandSubFlows(flowTree.value);
} finally {
// 先判断各已绑定模型节点是否需要补 API Key(系统模型),完成后再结束 loading,避免竞态
await checkModelApiKey();
@@ -155,16 +242,31 @@ watch(
}
);
const openModelSelector = (node: any) => {
selectorTargetNode.value = node;
const openModelSelector = (m: ModelNodeRef) => {
selectorTargetRef.value = m;
selectorDefaultModel.value = null;
modelSelectorVisible.value = true;
};
// 取指定工作流(按 key)的开始节点 id,用于契约字段生成
const getWfStartNodeId = (wfKey: string): string => {
const walk = (wf: FlowWorkflowNode | null): string => {
if (!wf) return '';
if (wf.key === wfKey) return wf.startNodeId;
for (const c of wf.children) {
const r = walk(c);
if (r) return r;
}
return '';
};
return walk(flowTree.value);
};
// 选模型:复刻工作流管理 handleModelConfirm 的初始化逻辑
const handleModelConfirm = (model: any) => {
const node = selectorTargetNode.value;
if (node) {
const m = selectorTargetRef.value;
if (m) {
const node = m.node;
node.modelConfig = node.modelConfig || {};
node.modelConfig.modelId = model.id || '';
node.modelConfig.modelName = model.modelName;
@@ -173,41 +275,38 @@ const handleModelConfirm = (model: any) => {
const params = stripReadonlyFields(deepClone(model.requestBodyMapping ?? null));
node.modelConfig.modelRequestParams = params;
// 后端契约:基于编辑器参数树(含 __originIndex 原始索引)生成全部叶子字段扁平清单,与工作流管理保存一致
node.modelConfig.modelRequestParamsPath = buildModelRequestParamsPath(params, getStartNodeId());
node.modelConfig.modelRequestParamsPath = buildModelRequestParamsPath(params, getWfStartNodeId(m.wfKey));
// 用模型 responseMapping 的中文描述填充 value,供工作流管理「引用上级节点输出」展示中文
node.modelConfig.modelResponseBodyMapping = enrichResponseBodyMapping(model.responseBodyMapping, model.responseMapping);
// 选中为用户模型,无需再补 API Key
systemModelNodeIds.value[node.id] = false;
apiKeyInputs.value[node.id] = '';
systemModelNodeIds.value[m.nodeKey] = false;
apiKeyInputs.value[m.nodeKey] = '';
}
modelSelectorVisible.value = false;
selectorTargetNode.value = null;
selectorTargetRef.value = null;
};
// 打开弹窗后:判断每个已绑定模型节点是否为系统模型(需补 API Key)
const checkModelApiKey = async () => {
const targets = modelNodes.value.filter((n) => n.modelConfig?.modelId);
const targets = modelRefs.value.filter((m) => m.node.modelConfig?.modelId);
await Promise.all(
targets.map(async (node) => {
targets.map(async (m) => {
try {
const res = await getModelManage(node.modelConfig.modelId);
const res = await getModelManage(m.node.modelConfig.modelId);
const model = res?.data?.modelManage || res?.data;
if (model?.systemModel === true) {
systemModelNodeIds.value[node.id] = true;
} else {
systemModelNodeIds.value[node.id] = false;
apiKeyInputs.value[node.id] = '';
}
systemModelNodeIds.value[m.nodeKey] = model?.systemModel === true;
if (!systemModelNodeIds.value[m.nodeKey]) apiKeyInputs.value[m.nodeKey] = '';
} catch {
// 详情获取失败:不强制补 Key,避免阻塞保存
systemModelNodeIds.value[node.id] = false;
systemModelNodeIds.value[m.nodeKey] = false;
}
})
);
};
// 将系统模型克隆为用户模型并绑定到节点(保存时统一调用)
const convertSystemModel = async (node: any) => {
const convertSystemModel = async (m: ModelNodeRef) => {
const node = m.node;
const res = await getModelManage(node.modelConfig.modelId);
const src = res?.data?.modelManage || res?.data;
if (!src?.id) throw new Error('模型详情获取失败');
@@ -219,7 +318,7 @@ const convertSystemModel = async (node: any) => {
modelSupplier: src.modelSupplier,
baseUrl: src.baseUrl,
responseType: src.responseType ?? src.invokeType,
apiKey: (apiKeyInputs.value[node.id] || '').trim(),
apiKey: (apiKeyInputs.value[m.nodeKey] || '').trim(),
enabled: src.enabled,
chatModel: src.ChatModel ?? src.chatModel,
maxConcurrency: src.maxConcurrency,
@@ -243,43 +342,66 @@ const convertSystemModel = async (node: any) => {
if (!newModelManage?.id) throw new Error('接口未返回新的用户模型');
// 更新节点绑定为新用户模型,不再需要补 Key
node.modelConfig.modelId = newModelManage.id;
systemModelNodeIds.value[node.id] = false;
apiKeyInputs.value[node.id] = '';
systemModelNodeIds.value[m.nodeKey] = false;
apiKeyInputs.value[m.nodeKey] = '';
};
// 各工作流 DSL 缺 modelRequestParamsPath 时兜底生成(含嵌套子工作流)
const patchRequestParamsPath = (wf: FlowWorkflowNode) => {
for (const n of wf.nodes) {
if (String(n?.nodeCode || '').toLowerCase() !== 'model') continue;
const mc = n?.modelConfig;
if (mc?.modelRequestParams && !mc.modelRequestParamsPath) {
mc.modelRequestParamsPath = buildModelRequestParamsPath(mc.modelRequestParams, wf.startNodeId);
}
}
for (const c of wf.children) patchRequestParamsPath(c);
};
// 子树内是否含 model 节点(自身或其子孙):含模型的子树才提交更新,保证嵌套写回链路完整
const nodeHasModelInSubtree = (wf: FlowWorkflowNode): boolean => wf.hasModel || wf.children.some(nodeHasModelInSubtree);
// 组装嵌套 subFlows:仅含模型的工作流子树才提交(无模型的子流程无需更新)
const buildSubFlows = (wf: FlowWorkflowNode): any[] =>
wf.children
.filter((c) => nodeHasModelInSubtree(c))
.map((c) => ({
id: c.id,
flowName: c.flowName,
description: '',
flowContent: c.flowContent,
subFlows: buildSubFlows(c),
}));
const handleSave = async () => {
const name = flowName.value.trim();
if (!name) {
ElMessage.warning('请输入工作流名称');
return;
}
const missing = modelNodes.value.find((n) => !n.modelConfig?.modelId);
const missing = modelRefs.value.find((m) => !m.node.modelConfig?.modelId);
if (missing) {
ElMessage.warning(`节点「${missing.name || missing.nodeCode}」未选择模型,请先选择`);
ElMessage.warning(`节点「${missing.node.name || missing.node.nodeCode}」未选择模型,请先选择`);
return;
}
saving.value = true;
try {
// 保存前统一转换:系统模型节点先克隆为用户模型并绑定
for (const node of modelNodes.value.filter((n) => systemModelNodeIds.value[n.id])) {
await convertSystemModel(node);
}
// 模板 DSL 若缺 modelRequestParamsPath(旧版模板),用现有参数树兜底生成,保证后端拿到契约字段
const startNodeId = getStartNodeId();
for (const node of modelNodes.value) {
const mc = node?.modelConfig;
if (mc?.modelRequestParams && !mc.modelRequestParamsPath) {
mc.modelRequestParamsPath = buildModelRequestParamsPath(mc.modelRequestParams, startNodeId);
}
// 保存前统一转换:整棵树的系统模型节点先克隆为用户模型并绑定
for (const m of modelRefs.value.filter((mm) => systemModelNodeIds.value[mm.nodeKey])) {
await convertSystemModel(m);
}
// 旧模板缺 modelRequestParamsPath 的兜底生成(主模板 + 嵌套子工作流)
if (flowTree.value) patchRequestParamsPath(flowTree.value);
const flowContent = {
...(templateFlowContent.value || {}),
nodes: nodes.value,
...(flowTree.value?.flowContent || {}),
nodes: flowTree.value?.nodes || [],
};
const res = await saveWorkflow({
const res = await updateWorkflowFromTemplate({
id: flowTree.value?.id || String(props.template?.id || ''),
flowName: name,
description: '',
flowContent,
subFlows: flowTree.value ? buildSubFlows(flowTree.value) : [],
});
const data = res?.data;
const newId = data?.id ?? (typeof data === 'string' || typeof data === 'number' ? data : null);
@@ -317,6 +439,21 @@ const handleClose = () => {
}
}
/* 主模板 / 子工作流 分组:子工作流组带标题条,与主模板节点区分 */
.tc-group {
margin-bottom: 16px;
}
.tc-group-title {
font-size: 12px;
font-weight: 600;
color: #2563eb;
background: #eff6ff;
border-radius: 6px;
padding: 6px 10px;
margin-bottom: 10px;
}
.tc-node {
border: 1px solid #e2e8f0;
border-radius: 10px;
+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;
+40 -8
View File
@@ -26,6 +26,7 @@
/>
<div class="main-wrapper">
<MainContent
ref="mainContentRef"
:active-menu="activeMenu"
:active-history-id="activeHistoryId"
:messages="currentMessages"
@@ -104,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,
@@ -519,6 +520,7 @@ const isWorkflowSession = computed(() => {
return !!list && list.some((m) => m.type === 'form' && !!m.formStatus && m.formStatus !== 'editing');
});
const inputBarRef = ref<any>(null);
const mainContentRef = ref<any>(null);
const templateDialogVisible = ref(false);
const pendingTemplate = ref<any>(null);
@@ -688,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 {
@@ -748,6 +752,9 @@ const handleTemplateSaved = async (newId: string) => {
pendingTemplate.value = null;
const ib = inputBarRef.value as any;
if (ib?.fetchWorkflows) await ib.fetchWorkflows();
// 同步刷新占位页工作流列表:创建成功后回到占位页能立即看到新工作流
const mc = mainContentRef.value as any;
if (mc?.loadPlaceWorkflows) await mc.loadPlaceWorkflows();
ib?.selectWorkflow?.(newId);
};
@@ -1037,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);
@@ -1114,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)
@@ -1131,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,保证实时产出可删除(与回显路径一致)
@@ -1229,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 = '';
@@ -1249,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;
@@ -1627,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)) {
@@ -1645,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 '执行失败,请重试';
}
@@ -23,7 +23,7 @@
<el-icon v-if="selectedWorkflow?.id === workflow.id" class="check-icon" color="#67c23a"><CircleCheck /></el-icon>
</div>
<div class="workflow-card-body">
<h3 class="workflow-name">{{ workflow.flowName || workflow.name || '未命名工作流' }}</h3>
<h3 class="workflow-name">{{ workflow.flowName || workflow.flowTemplateName || workflow.name || '未命名工作流' }}</h3>
<p class="workflow-desc">{{ workflow.description || '暂无描述' }}</p>
</div>
</div>
@@ -104,16 +104,19 @@ const fetchWorkflowList = async () => {
};
const res = await getSubFlowWorkflowList(params);
const data = res.data || {};
// 响应结构多形态容错:扁平 list 或 listFlowUserRes 嵌套
const list = data.list ?? data.listFlowUserRes?.list ?? [];
// 按登录角色分支读取:isAdmin=true(管理员)工作流在 listFlowTemplateRes,普通用户在 listFlowUserRes
// 兼容扁平 list 结构
const isAdmin = !!data.isAdmin;
const source = isAdmin ? data.listFlowTemplateRes : data.listFlowUserRes;
const list = data.list ?? source?.list ?? [];
workflowList.value = list || [];
pagination.total = data.total ?? data.listFlowUserRes?.total ?? workflowList.value.length;
pagination.total = data.total ?? source?.total ?? workflowList.value.length;
// 预选项仅为部分信息(切换节点后无 id)时,按名称匹配当前页以高亮
// 名称字段兼容 flowName / name 形态
// 名称字段兼容 flowName / flowTemplateName / name 形态
if (selectedWorkflow.value && !selectedWorkflow.value.id) {
const selName = selectedWorkflow.value.flowName || selectedWorkflow.value.name;
const selName = selectedWorkflow.value.flowName || selectedWorkflow.value.flowTemplateName || selectedWorkflow.value.name;
if (selName) {
const matched = workflowList.value.find((w) => (w.flowName || w.name) === selName);
const matched = workflowList.value.find((w) => (w.flowName || w.flowTemplateName || w.name) === selName);
if (matched) selectedWorkflow.value = matched;
}
}
+2 -2
View File
@@ -594,7 +594,7 @@ const handleWorkflowConfirm = async (workflow: any) => {
const subFlowConfig: SubFlowConfig = {
workflowId: workflow.id || '',
workflowName: workflow.flowName || workflow.name || '',
workflowName: workflow.flowName || workflow.flowTemplateName || workflow.name || '',
fields,
};
@@ -616,7 +616,7 @@ const handleWorkflowConfirm = async (workflow: any) => {
nodes.value[index] = updatedNode;
}
ElMessage.success(`已引入工作流:${workflow.flowName || workflow.name || workflow.id}`);
ElMessage.success(`已引入工作流:${workflow.flowName || workflow.flowTemplateName || workflow.name || workflow.id}`);
};
// 移除子流程引入的工作流
@@ -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,
+25 -25
View File
@@ -1,27 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
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
-----END RSA PRIVATE KEY-----
+23 -23
View File
@@ -1,17 +1,17 @@
-----BEGIN CERTIFICATE-----
MIIGJTCCBQ2gAwIBAgIQCXpUlFT9Cue3+TsPpxvnWjANBgkqhkiG9w0BAQsFADBu
MIIGJTCCBQ2gAwIBAgIQA0m9UBf/1I217Y9jcPp0vTANBgkqhkiG9w0BAQsFADBu
MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
d3cuZGlnaWNlcnQuY29tMS0wKwYDVQQDEyRFbmNyeXB0aW9uIEV2ZXJ5d2hlcmUg
RFYgVExTIENBIC0gRzIwHhcNMjYwNjEwMDAwMDAwWhcNMjYwOTA3MjM1OTU5WjAj
RFYgVExTIENBIC0gRzIwHhcNMjYwOTA3MDAwMDAwWhcNMjYxMjA1MjM1OTU5WjAj
MSEwHwYDVQQDExhnaXRlYS5yZWRwb3dlcmZ1dHVyZS5jb20wggEiMA0GCSqGSIb3
DQEBAQUAA4IBDwAwggEKAoIBAQCqVsfwTHPJbcPzuCUOCNLPoigpaZXWJmHfcMhi
YMXNtoap0FmBbJbhNZsGf2G5UOuCVKGN4gSMB18rcFwcq/j/GH5eMaPOn8bK8qSC
AsYHYi1zdarDGXICGWbu2BTRuZAY+7/4Kci+ayzuUpbH1vkv15hT5OhTev8Y4/iO
4apxRgcvKFZkOPLZ+M7km7j9bRTU5xouGjVJqmf0QVcUiMnlFuKI4XxBWjwTPcRt
X8SxCjBoi+k6iotPUYYFF3qzP8vVMyWLDpQ9wZ35NNoANOHg/FKSVixequjS+y1+
3SSK4ASVqHcfDddkw2JF8ColW+Fzz+ld0DqQfgW5kp/EAXwLAgMBAAGjggMIMIID
BDAfBgNVHSMEGDAWgBR435GQX+7erPbFdevVTFVT7yRKtjAdBgNVHQ4EFgQUOiq0
OCRofHA6BrxoM9do+AWnNgowQQYDVR0RBDowOIIYZ2l0ZWEucmVkcG93ZXJmdXR1
DQEBAQUAA4IBDwAwggEKAoIBAQCUvl+uz6tWWTAea9IYnFT35vJzbfPF2wutwVBL
l4QVTNBUPK7Ptpmpvc1AzJNGgZwuxRhEfJBZkx2x7O7XES6a80v0THUGqHf9SUoM
QFUX/PJWtaGF9RQvl53GWJY2vDCYexfUUbL4xpVXIHzNXvXnrIZy8KrtB8A01Pqq
2W2KiCo7qswpaBP/2F0Bn4U9BonaEUeyDPbRNaAqRLZWRnW2asDHAgjpoUEvKqSS
Y6cMWwjCtbzOQaKctWmq8jqjIceA71UqD5MxgtWyykrCage5IQJFBVvbRvWOk+J+
yybGg9jGRjz1objhWMYO5YNTXW1tTvc9oSdltFnT5zBJAJcVAgMBAAGjggMIMIID
BDAfBgNVHSMEGDAWgBR435GQX+7erPbFdevVTFVT7yRKtjAdBgNVHQ4EFgQUYPN9
3He9hDMC/uFm78xCWWEF4/owQQYDVR0RBDowOIIYZ2l0ZWEucmVkcG93ZXJmdXR1
cmUuY29tghx3d3cuZ2l0ZWEucmVkcG93ZXJmdXR1cmUuY29tMD4GA1UdIAQ3MDUw
MwYGZ4EMAQIBMCkwJwYIKwYBBQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNlcnQuY29t
L0NQUzAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUF
@@ -19,19 +19,19 @@ BwMCMIGABggrBgEFBQcBAQR0MHIwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRp
Z2ljZXJ0LmNvbTBKBggrBgEFBQcwAoY+aHR0cDovL2NhY2VydHMuZGlnaWNlcnQu
Y29tL0VuY3J5cHRpb25FdmVyeXdoZXJlRFZUTFNDQS1HMi5jcnQwDAYDVR0TAQH/
BAIwADCCAX0GCisGAQQB1nkCBAIEggFtBIIBaQFnAHUAwjF+V0UZo0XufzjespBB
68fCIVoiv3/Vta12mtkOUs0AAAGesEYy8QAABAMARjBEAiBD7255l5PRI6mZXWxU
iC7txvWxkLlBgVFzAkRrMW7RXQIgZyupslAslfGDqDMk9H948AFZGe0JwB6YiuXK
lx1sox0AdgDYCVU7lE96/8gWGW+UT4WrsPj8XodVJg8V0S5yu0VLFAAAAZ6wRjNI
AAAEAwBHMEUCIBqfIfl4sL0ME60NI0KG6JIHImcJjR5qkLGDjCp3sEhrAiEA4cvy
yFaz3BSjjQa+UspsqgfKxeTjCLJq5pLbDcgBgGQAdgCUTkOH+uzB74HzGSQmqBhl
AcfTXzgCAT9yZ31VNy4Z2AAAAZ6wRjL3AAAEAwBHMEUCIQDIIa8mLpO6BmhgCecw
pI+QNznn4lhIxmtJQyHmZo7qRAIgFPq9xlLLqqe5/+WgcNwoawggpi8skhg8t/yT
JJ5TmV0wDQYJKoZIhvcNAQELBQADggEBAOeiZrRwpNh/bNIuKe+1vWBrLHu2Zj70
cKAVUhT08ZAl8scEYusbuqtSAq+oz9sm595YyPgmrB0wSZEMTd/+pUy5GrrJAXal
kOx1a3TBxCvpK9F4T7KmBaBWA5HuljHfw9833jXi7v9jLYShDjyNRzSWeKl41kdt
Hq4CbDLXp9XVzVPBlOongGZV/6cDrvy4hoJcBq8UANU/QWw0PaJQWxPwHcULekBw
AefTX7JwKbHcZ3gGV4EzJU/eoycBOu9VsYgJu9U+p1nNjYHwS8RQjij+cgs8r5Wx
r4+wYkdN2767iK2+NRRdQD/ZqBMTgSZ6AXCM7gS8oP/ylLwnzyq7ikc=
68fCIVoiv3/Vta12mtkOUs0AAAGgebrk6AAABAMARjBEAiAgFMcnwhg3c0h/LxiP
nZ+YWMLYgXC9ePXRZ+F0CFotEAIgR5H6BlW0mUMwOr5n82ukGvdlme/gkKINeQA2
r33cbtQAdgDYCVU7lE96/8gWGW+UT4WrsPj8XodVJg8V0S5yu0VLFAAAAaB5uuUP
AAAEAwBHMEUCIHMpgTZWPL/owVG92G0tYuZEiCPkElXskLLFygZOqbKkAiEA7RGC
gJ+Gd+XWH5n1kvVyofTnRccp9PVwc7gYUzfNQU0AdgCUTkOH+uzB74HzGSQmqBhl
AcfTXzgCAT9yZ31VNy4Z2AAAAaB5uuT1AAAEAwBHMEUCIAzth4PgMSzYLoUFxNAu
ErCqHKZNRACffuKdr1sKodLJAiEAh67qPx8Pfr87kSR2bnMYnpBcXjJ38S4cz2RL
v6dFDaowDQYJKoZIhvcNAQELBQADggEBABPRvwySa7ou11Mlf5265pizxyR6qhG8
ywlCy0IImyFBZ/mu4YzXrLbBVB9c/KNGiLxCAUBg+rEZsbVmPW//cfo31uUtzenb
DhDOXqVlULG1nwDkhcCw90jMGUOl5X6zKZ2KSXd3Uq++EVTjuqXC3fbheocXLNZr
22DWhQ7wfFvogw6bKCkS15y4WGjWa2ut4Avso9pzCA+HDzJCTMLgJStOZNArRkZQ
u3OhuLjnbG5kWQiwVXU/ixxOaL4JCwZWKueMhumux20Ww6bvdj+wx+l4M5Dg+qXp
D3bLSWOxFnv2SqxFf1AA/7hf5rqomO8XhsFGSBV/ESGoKvAQyo5gnRw=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEqjCCA5KgAwIBAgIQDeD/te5iy2EQn2CMnO1e0zANBgkqhkiG9w0BAQsFADBh
+25 -25
View File
@@ -1,27 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
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
-----END RSA PRIVATE KEY-----
+24 -24
View File
@@ -1,37 +1,37 @@
-----BEGIN CERTIFICATE-----
MIIGJTCCBQ2gAwIBAgIQAWng8l3t5rBYcCmLpCDdJTANBgkqhkiG9w0BAQsFADBu
MIIGJDCCBQygAwIBAgIQCG68erA4eBvEiFO3I/+fkDANBgkqhkiG9w0BAQsFADBu
MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
d3cuZGlnaWNlcnQuY29tMS0wKwYDVQQDEyRFbmNyeXB0aW9uIEV2ZXJ5d2hlcmUg
RFYgVExTIENBIC0gRzIwHhcNMjYwNjEyMDAwMDAwWhcNMjYwOTA5MjM1OTU5WjAj
RFYgVExTIENBIC0gRzIwHhcNMjYwOTA3MDAwMDAwWhcNMjYxMjA1MjM1OTU5WjAj
MSEwHwYDVQQDExhtaW5pby5yZWRwb3dlcmZ1dHVyZS5jb20wggEiMA0GCSqGSIb3
DQEBAQUAA4IBDwAwggEKAoIBAQC+gxSvGFHa9gL+3IZmWmWQA2m2YV+pyCWd34RO
TWmkT+0zKsg/EEu+M3UEhxsEWQe2G6/9S391KC1LSFsjnZnVswAJsnlJ08tMFnM2
leAdZdQd/9I6xAHyzuCwJ6RxWXLdN/V6axfFzJ5dDu7z9ms0QAoZnBPfGQC+217T
U2ol5ogx11jWj+euVWGBXoK8O0IAUtFW/DannNz9V6Nz8wsRdXiX2DdUg4N9whsu
xzNSaxP3zM4s7DCBWywJBx2cDXZPVfdTixfhTQ+KfQx7FPkvz8rHekdtqOlAr4gP
RMOmY/hAp/lCQAcnEzunLi7UQc0GjeeSvFY099lNOeFu+jCPAgMBAAGjggMIMIID
BDAfBgNVHSMEGDAWgBR435GQX+7erPbFdevVTFVT7yRKtjAdBgNVHQ4EFgQUm+W1
VEi2C0CdxtuncyBFtX9cS5UwQQYDVR0RBDowOIIYbWluaW8ucmVkcG93ZXJmdXR1
DQEBAQUAA4IBDwAwggEKAoIBAQCkmgtAQ/KZv8wrvYOiih8xozGCaoTWiW5ye/Hf
tnX07Sxt1IaAfFUMHtaoEzsVBLwuTALYINWsCTrolvrgy6i/WvpsTGbZn7mkLjmK
T8db1ToBueW/eePdHXg2Q1Pgi/Cy5/cwkp29WXvxRsbiINP4jqsma+4vuqPuXV6W
o6HfXL3xddPl/ap0ScjCe+aZKB/hWwR3O4QNSmQFLY6jb7tgH3hqwRJzjOnqkQHA
H2Tcdx/gUYYUErPqyyqjhFJW5uDvh1Ha5jcbU2XphOiBgI3wElCkmfAqt4WZGU0z
kIluYkwZDxe3nN7iSZl9SbmgwTmlO1g04rC3T5WRQlk08dVdAgMBAAGjggMHMIID
AzAfBgNVHSMEGDAWgBR435GQX+7erPbFdevVTFVT7yRKtjAdBgNVHQ4EFgQUbMUu
74Ronh+u0yO+sQpoSQ4TbFMwQQYDVR0RBDowOIIYbWluaW8ucmVkcG93ZXJmdXR1
cmUuY29tghx3d3cubWluaW8ucmVkcG93ZXJmdXR1cmUuY29tMD4GA1UdIAQ3MDUw
MwYGZ4EMAQIBMCkwJwYIKwYBBQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNlcnQuY29t
L0NQUzAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUF
BwMCMIGABggrBgEFBQcBAQR0MHIwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRp
Z2ljZXJ0LmNvbTBKBggrBgEFBQcwAoY+aHR0cDovL2NhY2VydHMuZGlnaWNlcnQu
Y29tL0VuY3J5cHRpb25FdmVyeXdoZXJlRFZUTFNDQS1HMi5jcnQwDAYDVR0TAQH/
BAIwADCCAX0GCisGAQQB1nkCBAIEggFtBIIBaQFnAHUAwjF+V0UZo0XufzjespBB
68fCIVoiv3/Vta12mtkOUs0AAAGeucefAgAABAMARjBEAiBdD793ndAhFw/xk4gS
Pycc/U4IGSbSFNg1wXMMBkma9gIgSK8LNW9lqHn4wEYFRQrBfmYMUkJhUR51JmOb
rUESBu8AdgDXbX0Q0af1d8LH6V/XAL/5gskzWmXh0LMBcxfAyMVpdwAAAZ65x57r
AAAEAwBHMEUCIALrNS+sHrfHVl72YGHkqSb4quYzH6xZ0JNCTLakfjK4AiEA0jbR
ORUL7BLzYbw5geZfMb8NJmD+7CbgXdHNPWmowdYAdgCUTkOH+uzB74HzGSQmqBhl
AcfTXzgCAT9yZ31VNy4Z2AAAAZ65x58NAAAEAwBHMEUCIQCn753LSIK5ODnAw8za
2BUf7PZQhtwhe1I0fEfrFjwcAAIgPZtnV83cNV8UK9zGQhWso4QX/oK+ziXQMuP5
+lApPpcwDQYJKoZIhvcNAQELBQADggEBAKceFp1JWPgaR5TGrMXQNY0qgRPhY0ZC
MSVLqtbgWPeODl3p+BTpLJ/taGR1v1mlL/2cM+EU/RQVDobYUu91Td0Tibp1T8vv
KKgnGTj8fVTzimTR5YsFHKc0XFqHpbneArZFb9tbMnRLheDjAqJ3Xs5H5t/XNDSi
vasqxdvAYZPP+7JZPRrFqq/fPZR79NRz7kl45YiQX199d/8oPRVFRrq1TGtp41Nk
7rP31tq0YZDpBHXXmpYXmOZalWBHoASnZJDq0+H5bSBzNVHR25eOZe0dt5lWMzrf
bzFMatWnPKI/aLhc3GGZAxyy7PCRA5pJGB+qnaJAakODvB02VvJey/Y=
BAIwADCCAXwGCisGAQQB1nkCBAIEggFsBIIBaAFmAHUAwjF+V0UZo0XufzjespBB
68fCIVoiv3/Vta12mtkOUs0AAAGgebrkkgAABAMARjBEAiA3QXzJe/8VFDnVdr/6
BRerjq1qVsc61eFPJfMdmgbeCAIgTTMUJI0RnOIX7O0umzAWJa6T4lljD2+2ybyw
FNTGoaQAdgDYCVU7lE96/8gWGW+UT4WrsPj8XodVJg8V0S5yu0VLFAAAAaB5uuSb
AAAEAwBHMEUCIDb3e4Fb75I9JRnvfM5q8/iPLgs4u/NPdH4brm//kO9KAiEAujgF
VeUmRxadsYgF1pVRytH2vURymXUvcTtZfWLbk/YAdQCUTkOH+uzB74HzGSQmqBhl
AcfTXzgCAT9yZ31VNy4Z2AAAAaB5uuSbAAAEAwBGMEQCIGyiorlBMD7UARZvFGRz
VsPQH+oqh3cEtHq9t/EdDFH7AiAb7dllHHDE1zJrf41OuXOHQZZrA5VBNPpBu1Lj
avU6kzANBgkqhkiG9w0BAQsFAAOCAQEADDNB+zpouXcPD43XUYYE8dOpqFCHXGpe
MOn44OGPpfU/DvsQgsMZl//bdIcSlmuyHIr/Z5CojN/sCiyCmqnM+vSRMjcjoqxU
y7Ly0df9moxXprB20/XiWhCMSy0+bK051Kk4Lh3O0jWKGe2edEUEzwb1vvyhDylV
skd347/BUVGEf+PGT4NqI2ImWoUBc73C8oZ6eHe/bHAiESOI1RnUefOUw3G8El2o
t5DUflbkT1+Gzi/NgxvnSwbuvsf77mA9DOg8F6wwsPk4je+ekGycrzoVj/hnzy+h
4BT6OiBxzBkRa7TLMblQAPiDTXkbP41ZzLNYnCN8XwpXRvrt29Hf6g==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEqjCCA5KgAwIBAgIQDeD/te5iy2EQn2CMnO1e0zANBgkqhkiG9w0BAQsFADBh