Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57cb3ffd74 |
+1
-1
@@ -5,4 +5,4 @@ ENV = 'development'
|
||||
# 开发环境走本地代理,避免 CORS
|
||||
VITE_API_URL = 'http://116.204.74.41:8000'
|
||||
# VITE_API_URL = 'http://192.168.3.30:8000'
|
||||
# VITE_API_URL = 'http://192.168.3.135:8000'
|
||||
# VITE_API_URL = 'http://192.168.3.135:8000'
|
||||
+1
-25
@@ -6,12 +6,6 @@ map $http_user_agent $badbot {
|
||||
~*(GPTBot|ChatGPT-User|Claude-Web|ClaudeBot|Bytespider|CCBot|Google-Extended) 1;
|
||||
}
|
||||
|
||||
# WebSocket 升级头透传:客户端带 Upgrade 头(WS 握手)时 Connection 置 upgrade,普通 REST 请求保持 close
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
# 主站 HTTP(SSL 已在外层 nginx 终结,此处只需 serve 内容)
|
||||
server {
|
||||
root /usr/share/nginx/html;
|
||||
@@ -65,9 +59,6 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# WebSocket 升级头透传(首页会话长连接 /ai-agent/session/wsExecute 依赖握手成功)
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_connect_timeout 75s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_read_timeout 86400s;
|
||||
@@ -78,7 +69,6 @@ server {
|
||||
server {
|
||||
listen 80;
|
||||
server_name gitea.redpowerfuture.com;
|
||||
client_max_body_size 500M;
|
||||
if ($badbot) {
|
||||
return 444;
|
||||
}
|
||||
@@ -89,7 +79,6 @@ 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;
|
||||
@@ -108,7 +97,6 @@ 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;
|
||||
@@ -123,7 +111,6 @@ 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;
|
||||
@@ -137,7 +124,6 @@ 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;
|
||||
@@ -151,7 +137,6 @@ 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;
|
||||
@@ -161,11 +146,10 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
# observer HTTP(转发到 18080)
|
||||
# observer HTTP(仅 HTTP)
|
||||
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;
|
||||
@@ -173,10 +157,6 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# WebSocket 升级头透传
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
}
|
||||
# ==================== 主站 HTTPS(静态文件 + API 反向代理) ====================
|
||||
@@ -236,9 +216,6 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# WebSocket 升级头透传(首页会话长连接 /ai-agent/session/wsExecute 依赖握手成功)
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_connect_timeout 75s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_read_timeout 86400s;
|
||||
@@ -249,7 +226,6 @@ 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;
|
||||
|
||||
Generated
-50
@@ -23,21 +23,17 @@
|
||||
"codemirror": "^6.0.1",
|
||||
"countup.js": "^2.8.0",
|
||||
"cropperjs": "^1.6.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"echarts": "^5.5.0",
|
||||
"echarts-gl": "^2.0.9",
|
||||
"echarts-wordcloud": "^2.1.0",
|
||||
"element-china-area-data": "^6.1.0",
|
||||
"element-plus": "^2.10.7",
|
||||
"file-saver": "^2.0.5",
|
||||
"highlight.js": "^11.12.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"json-schema-editor": "github:wusij/json-schema#8827d0d6a360c37bc32a8945fb979260bd4a4c85",
|
||||
"jsplumb": "^2.15.6",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^18.0.9",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"mitt": "^3.0.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^2.1.7",
|
||||
@@ -1949,13 +1945,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz",
|
||||
@@ -3259,15 +3248,6 @@
|
||||
"ssr-window": "^3.0.0-alpha.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.13.tgz",
|
||||
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
@@ -4190,15 +4170,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.12.0",
|
||||
"resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.12.0.tgz",
|
||||
"integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hoist-non-react-statics": {
|
||||
"version": "2.5.5",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz",
|
||||
@@ -4605,27 +4576,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.9",
|
||||
"resolved": "https://registry.npmmirror.com/marked/-/marked-18.0.9.tgz",
|
||||
"integrity": "sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/marked-highlight": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/marked-highlight/-/marked-highlight-2.2.4.tgz",
|
||||
"integrity": "sha512-PZxisNMJDduSjc0q6uvjsnqqHCXc9s0eyzxDO9sB1eNGJnd/H1/Fu+z6g/liC1dfJdFW4SftMwMlLvsBhUPrqQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"marked": ">=4 <19"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
||||
@@ -28,21 +28,17 @@
|
||||
"codemirror": "^6.0.1",
|
||||
"countup.js": "^2.8.0",
|
||||
"cropperjs": "^1.6.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"echarts": "^5.5.0",
|
||||
"echarts-gl": "^2.0.9",
|
||||
"echarts-wordcloud": "^2.1.0",
|
||||
"element-china-area-data": "^6.1.0",
|
||||
"element-plus": "^2.10.7",
|
||||
"file-saver": "^2.0.5",
|
||||
"highlight.js": "^11.12.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"json-schema-editor": "github:wusij/json-schema#8827d0d6a360c37bc32a8945fb979260bd4a4c85",
|
||||
"jsplumb": "^2.15.6",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^18.0.9",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"mitt": "^3.0.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^2.1.7",
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
<!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>
|
||||
@@ -12,7 +12,6 @@ export interface AsyncTaskMapping {
|
||||
url: string;
|
||||
httpMethod: string;
|
||||
requestHeadMapping: Record<string, unknown>;
|
||||
requestBodyMapping?: Record<string, unknown>;
|
||||
responseMapping: Record<string, unknown>;
|
||||
taskId: string;
|
||||
taskStatus: string;
|
||||
@@ -41,26 +40,21 @@ export interface ModelManageItem {
|
||||
updater: string;
|
||||
updatedAt: string;
|
||||
deletedAt: null | string;
|
||||
modelSupplier: number;
|
||||
modelSupplier: string;
|
||||
modelName: string;
|
||||
modelType: number;
|
||||
baseUrl: string;
|
||||
responseType: number;
|
||||
apiKey: string;
|
||||
enabled: boolean;
|
||||
chatModel?: boolean;
|
||||
ChatModel: boolean;
|
||||
maxConcurrency: number;
|
||||
maxTokens: number;
|
||||
tokenPredictPrice: number;
|
||||
requestHeadMapping: Record<string, unknown>;
|
||||
requestBodyMapping: Record<string, unknown>;
|
||||
requestBusinessFieldMapping?: Record<string, string>;
|
||||
error_message_mapping?: Record<string, unknown>;
|
||||
responseMapping: Record<string, unknown>;
|
||||
responseBodyMapping: Record<string, string>;
|
||||
responseBusinessFieldMapping?: Record<string, string>;
|
||||
minDuration?: number;
|
||||
priceConfig?: unknown;
|
||||
tokenMapping?: TokenMapping;
|
||||
asyncTaskMapping?: AsyncTaskMapping;
|
||||
lastFrame?: string;
|
||||
@@ -172,13 +166,8 @@ export interface CreateModelManageParams {
|
||||
tokenPredictPrice?: number;
|
||||
requestHeadMapping?: Record<string, unknown>;
|
||||
requestBodyMapping?: Record<string, unknown>;
|
||||
requestBusinessFieldMapping?: Record<string, string>;
|
||||
error_message_mapping?: Record<string, unknown>;
|
||||
responseMapping?: Record<string, unknown>;
|
||||
responseBodyMapping?: Record<string, string>;
|
||||
responseBusinessFieldMapping?: Record<string, string>;
|
||||
minDuration?: number;
|
||||
priceConfig?: unknown;
|
||||
tokenMapping?: TokenMapping;
|
||||
asyncTaskMapping?: AsyncTaskMapping;
|
||||
lastFrame?: string;
|
||||
@@ -230,14 +219,3 @@ export function updateModelManage(data: UpdateModelManageParams) {
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前会话模型(普通对话执行时使用的模型 id)
|
||||
* 返回兼容 v2 惯例:data.modelManage.id(亦兼容 data.id / data.modelId)
|
||||
*/
|
||||
export function getChatModel() {
|
||||
return request({
|
||||
url: '/model-gateway/model/manage/getChatModel',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import request from '/@/utils/request';
|
||||
import type { RequestOptions } from '/@/utils/request';
|
||||
|
||||
// ===== 节点库(V2 结构,workflow 专用;creation 继续使用 creation/index.ts 的旧结构)=====
|
||||
export interface NodeLibraryPresetOption {
|
||||
@@ -13,10 +12,6 @@ export interface NodeLibraryPresetOption {
|
||||
value: string;
|
||||
config?: NodeLibraryPresetOption[] | null;
|
||||
}> | null;
|
||||
// 运行时已在用的扩展字段(补齐声明消除 TS 报错)
|
||||
isFormField?: boolean;
|
||||
constraint?: any;
|
||||
defaultValue?: any;
|
||||
}
|
||||
|
||||
export interface NodeLibraryItem {
|
||||
@@ -26,22 +21,15 @@ export interface NodeLibraryItem {
|
||||
sort: number;
|
||||
desc?: string;
|
||||
batchExecOption: boolean;
|
||||
// 是否允许该节点内「引用上级节点输出」多选(true 时所有引用下拉可多选;节点库返回,仅 model 节点 true)
|
||||
isMultiParameter: boolean;
|
||||
// 前置/后置方法配置定义(结构同 presetOption,值为数组;仅 model 节点提供)
|
||||
preToolOption: NodeLibraryPresetOption[] | null;
|
||||
postToolOption: NodeLibraryPresetOption[] | null;
|
||||
preToolOption: boolean;
|
||||
postToolOption: boolean;
|
||||
skillOption: boolean;
|
||||
promptOption: boolean;
|
||||
negativePromptOption: boolean;
|
||||
isSaveFileOption: boolean;
|
||||
formConfigOption: boolean;
|
||||
modelConfigOption: boolean;
|
||||
// 节点自带输出项([{ field, label }]):空/null 表示无输出;非空时该节点可作为下游节点的引用来源
|
||||
outputField?: { field: string; label: string }[] | null;
|
||||
presetOption: NodeLibraryPresetOption[] | null;
|
||||
// 是否支持贴片布局(节点库返回,仅 model 节点 true):控制节点配置面板「贴片布局」开关显示
|
||||
patchLayout?: boolean;
|
||||
}
|
||||
|
||||
export interface NodeLibraryGroup {
|
||||
@@ -64,20 +52,12 @@ export interface WorkflowModelItem {
|
||||
baseUrl?: string;
|
||||
enabled?: number | boolean;
|
||||
isOwner?: number;
|
||||
// true = 系统内置模型(选它需填 API Key 转用户模型);false/缺省 = 用户模型
|
||||
systemModel?: boolean;
|
||||
// 模型请求参数模板(选择模型后渲染为递归表单,与 DSL modelRequestParams 结构一致)
|
||||
requestBodyMapping?: Record<string, any>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export function getWorkflowModelList(params?: {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
modelName?: string;
|
||||
isSameType?: boolean;
|
||||
modelType?: string | number;
|
||||
}) {
|
||||
export function getWorkflowModelList(params?: { pageNum: number; pageSize: number; modelName?: string }) {
|
||||
return request({
|
||||
url: '/model-gateway/model/manage/listModelManage',
|
||||
method: 'get',
|
||||
@@ -101,69 +81,3 @@ export function getWorkflowSkillList(params?: { pageNum: number; pageSize: numbe
|
||||
params,
|
||||
}) as Promise<{ code: number; message: string; data: { list: WorkflowSkillItem[]; total: number } }>;
|
||||
}
|
||||
|
||||
// ===== 子流程:引入已有工作流(独立实现,不依赖 creation)=====
|
||||
export interface SubFlowWorkflowItem {
|
||||
id: string;
|
||||
// 后端列表接口返回 flowName(name 仅为前端兜底)
|
||||
name?: string;
|
||||
flowName?: string;
|
||||
description?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 获取"我的工作流"列表(IsOwn=true 只取本人创建,供 sub_flow 节点选择引入)
|
||||
// 响应结构多形态容错在调用方处理(扁平 list 优先,否则按 isAdmin 分支读取 listFlowTemplateRes / listFlowUserRes)
|
||||
export function getSubFlowWorkflowList(params?: {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
keyword?: string;
|
||||
IsOwn?: boolean;
|
||||
}) {
|
||||
return request({
|
||||
url: '/ai-agent/flow/user/list',
|
||||
method: 'get',
|
||||
params,
|
||||
}) as Promise<{ code: number; message: string; data: any }>;
|
||||
}
|
||||
|
||||
// 获取工作流详情(取开始节点 outputConfig 构建 sub_flow 引入参数)
|
||||
export function getSubFlowWorkflowDetail(id: string) {
|
||||
return request({
|
||||
url: '/ai-agent/flow/user/get',
|
||||
method: 'get',
|
||||
params: { id },
|
||||
}) as Promise<{ code: number; message: string; data: any }>;
|
||||
}
|
||||
|
||||
// 工作空间文件下载(首页工作流/工作空间专用):与 creation 的 downloadToFile 同一后端接口,
|
||||
// 但工作空间文件可能较大(视频/音频),下载超时放宽到 5 分钟;不改动 creation 共享 API 的超时
|
||||
export function downloadWorkspaceFile(data: { fileURL: string }, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/oss/file/downloadToBrowser',
|
||||
method: 'post',
|
||||
data,
|
||||
responseType: 'blob',
|
||||
timeout: 5 * 60 * 1000,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import request, { type RequestOptions } from '/@/utils/request';
|
||||
|
||||
// ===== 会话管理(/ai-agent/session/*)=====
|
||||
// 后端结构:
|
||||
// ListSessionReq { page } → ListSessionRes { list: VOSession[], total }
|
||||
// GetSessionInfoReq { page, sessionId } → GetSessionInfoRes { list: VOSessionInfoResult[], total }
|
||||
// DeleteSessionReq { sessionId }(POST,级联删除普通对话结果,工作流结果保留)
|
||||
// 说明:会话为「会话 → 会话内结果(工作流/普通对话混排,按时间倒序)」模型。
|
||||
|
||||
export interface VOSession {
|
||||
id: string;
|
||||
sessionName: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface VOSessionInfoResult {
|
||||
id: string;
|
||||
type: 'workflow' | 'chat';
|
||||
status: number; // 1-运行中, 2-成功, 3-失败
|
||||
flowId: string;
|
||||
requestParams: Record<string, any>;
|
||||
resultFileUrl: string; // 产出文件地址(单文件;工作流执行成功时后端填写,空串表示无产出)
|
||||
resultContent: string; // 回复文本内容(非文件地址)
|
||||
totalTokens: number;
|
||||
totalFee: number;
|
||||
errorMsg: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function getSessionListV2(params?: Record<string, any>, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/ai-agent/session/list',
|
||||
method: 'get',
|
||||
params,
|
||||
requestOptions,
|
||||
}) as Promise<{ code: number; message: string; data: { list: VOSession[]; total: number } }>;
|
||||
}
|
||||
|
||||
export function getSessionResults(sessionId: string, params?: Record<string, any>, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/ai-agent/session/get',
|
||||
method: 'get',
|
||||
params: { sessionId, ...params },
|
||||
requestOptions,
|
||||
}) as Promise<{ code: number; message: string; data: { list: VOSessionInfoResult[]; total: number } }>;
|
||||
}
|
||||
|
||||
export function deleteSessionV2(sessionId: string, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/ai-agent/session/delete',
|
||||
method: 'delete',
|
||||
data: { sessionId },
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除会话内的对话记录(AI 回复 / 用户问题单条或多条),传 Ids 数组,每项为 { id, type }
|
||||
// 注意:记录 id 为 19 位雪花大整数,超出 JS Number 安全范围,必须用 string 形式传递,否则精度丢失
|
||||
export function deleteSessionRecord(ids: { id: string; type: string }[], requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/ai-agent/session/deleteRecord',
|
||||
method: 'post',
|
||||
data: { ids },
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 工作空间结果列表(/ai-agent/session/resultList)=====
|
||||
// 与内容创作 execution/list 同结构:data.tree(按 createDate 分组)+ data.imgAddressPrefix
|
||||
export interface ExecutionItem {
|
||||
// 兼容旧平铺结构;resultList 新结构下 items 无独立 id,删除按流程记录 flow.Id
|
||||
id?: string;
|
||||
timestamp?: string;
|
||||
content: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
// resultList 新两级结构:createDate → flows(流程分组)→ items(文件)
|
||||
export interface ExecutionFlow {
|
||||
flowName: string;
|
||||
Id: string; // 流程结果记录 id(雪花)
|
||||
sessionId: string;
|
||||
items: ExecutionItem[];
|
||||
}
|
||||
|
||||
export interface ExecutionTreeItem {
|
||||
createDate: string;
|
||||
// 新结构:日期下按流程分组;兼容旧平铺结构(无 flows 时回落 items)
|
||||
flows?: ExecutionFlow[];
|
||||
items?: ExecutionItem[];
|
||||
}
|
||||
|
||||
export interface ExecutionListData {
|
||||
tree: ExecutionTreeItem[];
|
||||
imgAddressPrefix: string;
|
||||
}
|
||||
|
||||
export interface ExecutionListResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: ExecutionListData;
|
||||
}
|
||||
|
||||
/** 获取工作空间结果列表(结果树) */
|
||||
export function getWorkspaceResultList(params?: Record<string, any>, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/ai-agent/session/resultList',
|
||||
method: 'get',
|
||||
params,
|
||||
requestOptions,
|
||||
}) as Promise<ExecutionListResponse>;
|
||||
}
|
||||
|
||||
/** 删除工作空间结果(单个文件)——工作流域独立接口,不依赖内容创作域 */
|
||||
export function deleteWorkspaceResult(data: { id: string; content: string }, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/ai-agent/session/resultDelete',
|
||||
method: 'delete',
|
||||
data,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
@@ -70,22 +70,6 @@ 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',
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -65,9 +65,7 @@
|
||||
</div>
|
||||
<div v-if="node.outputFields.length === 0" class="fcd-ref-node-empty">该节点无输出字段</div>
|
||||
<div v-for="of in node.outputFields" :key="of.field" class="fcd-ref-field" @click="setValueSource(node, of)">
|
||||
<span v-if="of.fieldType === 'object'" class="fcd-ref-type" title="引用对象整体">{} 对象</span>
|
||||
<span v-else-if="of.fieldType === 'array'" class="fcd-ref-type" title="引用数组整体">[] 数组</span>
|
||||
<span class="fcd-ref-field-text">{{ of.label || of.field }}</span>
|
||||
{{ of.label || of.field }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -208,9 +206,7 @@
|
||||
</div>
|
||||
<div v-if="node.outputFields.length === 0" class="fcd-ref-node-empty">该节点无输出字段</div>
|
||||
<div v-for="of in node.outputFields" :key="of.field" class="fcd-ref-field" @click="setValueSource(node, of)">
|
||||
<span v-if="of.fieldType === 'object'" class="fcd-ref-type" title="引用对象整体">{} 对象</span>
|
||||
<span v-else-if="of.fieldType === 'array'" class="fcd-ref-type" title="引用数组整体">[] 数组</span>
|
||||
<span class="fcd-ref-field-text">{{ of.label || of.field }}</span>
|
||||
{{ of.label || of.field }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -309,7 +305,7 @@ interface UpstreamNodeInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
nodeCode: string;
|
||||
outputFields: { field: string; label: string; fieldType?: 'scalar' | 'object' | 'array' }[];
|
||||
outputFields: { field: string; label: string }[];
|
||||
}
|
||||
|
||||
interface FieldFormData {
|
||||
@@ -552,9 +548,6 @@ function handleClosed() { resetForm(); }
|
||||
<style lang="scss">
|
||||
.fcd-ref-popover {
|
||||
.fcd-ref-panel {
|
||||
// 面板超高时内部滚动,避免超出浏览器视口被截掉显示不全
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
.fcd-ref-empty {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
@@ -588,9 +581,6 @@ function handleClosed() { resetForm(); }
|
||||
padding: 2px 0 2px 8px;
|
||||
}
|
||||
.fcd-ref-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
padding: 4px 8px;
|
||||
@@ -600,24 +590,6 @@ function handleClosed() { resetForm(); }
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.fcd-ref-type {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: #64748b;
|
||||
background: #f1f5f9;
|
||||
border-radius: 3px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.fcd-ref-field-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ function nextImageKey(template: PatchTemplate): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const DEFAULT_TEMPLATE_URL = 'https://cdn.redpowerfuture.com/tenantid-1/vertical_template_example.html';
|
||||
const DEFAULT_TEMPLATE_URL = 'https://cdn.redpowerfuture.com/tenantid-1/template_example1.html';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: PatchTemplate[];
|
||||
@@ -290,24 +290,14 @@ const handleImageUpload = async (templateIndex: number, imgIdx: number, file: an
|
||||
}
|
||||
};
|
||||
|
||||
// 下载参考模板文件:统一走 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 || '参考模板下载失败');
|
||||
}
|
||||
// 下载参考模板文件
|
||||
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);
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -203,12 +203,11 @@ export default defineComponent({
|
||||
}
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
.then(async () => {
|
||||
// 手动退出登录也只清理登录态缓存,保留主题、语言等本地配置。
|
||||
Session.clearAuth();
|
||||
// 整页跳转登录页(刷新重建路由实例):SPA 内 router.replace 不会重置已注册的动态路由,
|
||||
// 重新登录时 setAddRoute 的 hasRoute 判断会跳过新菜单注册,出现"菜单显示但点击 404"(如价格管理页)
|
||||
window.location.href = import.meta.env.BASE_URL + '#/login';
|
||||
// 显式回到登录页,避免保留之前受保护页面的重定向参数
|
||||
await router.replace('/login');
|
||||
})
|
||||
.catch(() => {});
|
||||
} else if (path === 'wareHouse') {
|
||||
|
||||
@@ -31,10 +31,6 @@
|
||||
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;
|
||||
@@ -53,10 +49,6 @@
|
||||
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;
|
||||
|
||||
@@ -1,82 +1,8 @@
|
||||
<template>
|
||||
<div ref="chatListRef" class="chat-list" @scroll.passive="handleScroll">
|
||||
<!-- 顶部:有更多历史时上滑加载更早记录 -->
|
||||
<div v-if="hasMore || loadingMore" class="load-more-bar">
|
||||
<span v-if="loadingMore" class="load-more-spinner"></span>
|
||||
{{ loadingMore ? '加载中…' : '继续上滑,加载更早的记录' }}
|
||||
</div>
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
class="message-row"
|
||||
:class="{ 'is-user': msg.isUser, 'is-form': msg.type === 'form', 'is-error': !msg.isUser && isErrorMsg(msg) }"
|
||||
>
|
||||
<div class="chat-list">
|
||||
<div v-for="msg in messages" :key="msg.id" class="message-row" :class="{ 'is-user': msg.isUser }">
|
||||
<div class="bubble-wrap">
|
||||
<!-- 工作流表单卡片:不套气泡,独立卡片渲染 -->
|
||||
<WorkflowFormCard
|
||||
v-if="msg.type === 'form'"
|
||||
:execute-signal="executeRequest?.msgId === msg.id ? executeRequest : null"
|
||||
:detail="msg.form"
|
||||
:readonly="msg.formStatus === 'done' || msg.formStatus === 'failed'"
|
||||
:editable="lastFormMsg ? msg.id === lastFormMsg.id : false"
|
||||
:submitting="msg.formStatus === 'running'"
|
||||
:form-error="msg.formError"
|
||||
:progress="msg.formProgress"
|
||||
@submit="emit('form-submit', msg, $event)"
|
||||
/>
|
||||
<template v-else>
|
||||
<div class="bubble">
|
||||
<template v-if="msg.isUser">{{ msg.content }}</template>
|
||||
<template v-else>
|
||||
<!-- 初始加载动画 -->
|
||||
<template v-if="msg.loading && !msg.thinking && !msg.content">
|
||||
<span class="loading-text">思考中</span><span class="loading-dots"><i></i><i></i><i></i></span>
|
||||
</template>
|
||||
<!-- 思考区:DeepSeek 风格折叠块 -->
|
||||
<div v-if="msg.thinking" class="thinking-block">
|
||||
<button class="thinking-toggle" type="button" @click="toggleThinking(msg)">
|
||||
<span class="thinking-icon">🧠</span>
|
||||
<span class="thinking-title">{{ thinkingTitle(msg) }}</span>
|
||||
<span class="thinking-arrow" :class="{ collapsed: !isThinkingExpanded(msg) }">▾</span>
|
||||
</button>
|
||||
<div v-if="isThinkingExpanded(msg)" class="thinking-content">
|
||||
<div class="thinking-text">{{ msg.thinking }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 回答区:Markdown 渲染 -->
|
||||
<div
|
||||
v-if="msg.content"
|
||||
class="answer-block"
|
||||
v-html="renderAnswer(msg.content)"
|
||||
@click="handleAnswerClick"
|
||||
></div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- 工作流执行产出文件卡片(挂在结果消息上,预览/下载/删除) -->
|
||||
<WorkflowOutputCard
|
||||
v-if="msg.outputs && msg.outputs.length"
|
||||
:outputs="msg.outputs"
|
||||
:resolve-url="props.resolveUrl"
|
||||
@preview="emit('output-preview', msg, $event)"
|
||||
@download="emit('output-download', msg, $event)"
|
||||
@delete="emit('output-delete', msg, $event)"
|
||||
/>
|
||||
<!-- AI 消息操作栏(hover 显示) -->
|
||||
<div v-if="!msg.isUser && msg.content" class="msg-actions">
|
||||
<button class="msg-action" title="复制回答" @click="copyText(msg.content)">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
</button>
|
||||
<button v-if="msg.recordId" class="msg-action is-danger" title="删除对话" @click="emit('delete', msg)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 用户消息操作栏(hover 显示:重新生成 + 删除) -->
|
||||
<div v-if="msg.isUser" class="msg-actions">
|
||||
<button v-if="msg.recordId" class="msg-action is-danger" title="删除对话" @click="emit('delete', msg)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="bubble">{{ msg.content }}</div>
|
||||
<div class="time">{{ msg.time }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,266 +10,28 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Delete, DocumentCopy } from '@element-plus/icons-vue';
|
||||
import 'highlight.js/styles/github-dark.css';
|
||||
import { renderMarkdown } from '../utils/markdown';
|
||||
import type { WorkflowOutput } from '../utils/flowDsl';
|
||||
import type { WorkflowNodeProgress } from '../utils/wsMessage';
|
||||
import WorkflowFormCard from './WorkflowFormCard.vue';
|
||||
import WorkflowOutputCard from './WorkflowOutputCard.vue';
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
time: string;
|
||||
isUser: boolean;
|
||||
loading?: boolean;
|
||||
thinking?: string;
|
||||
thinkingSeconds?: number;
|
||||
// 后端记录 id / 类型(来自 session/get 的每条结果),存在时才显示删除按钮
|
||||
recordId?: string;
|
||||
recordType?: string;
|
||||
// 工作流表单卡片消息(type==='form' 时渲染 WorkflowFormCard)
|
||||
type?: 'form';
|
||||
form?: any;
|
||||
formStatus?: 'editing' | 'running' | 'done' | 'failed';
|
||||
formError?: string;
|
||||
// 工作流执行节点进度(node_start/node_complete 事件驱动的节点步骤列表,渲染执行过程区)
|
||||
formProgress?: WorkflowNodeProgress;
|
||||
// 工作流执行产出文件列表(挂在 workflow 结果消息上,渲染产出卡片)
|
||||
outputs?: WorkflowOutput[];
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'delete', msg: ChatMessage): void;
|
||||
(e: 'form-submit', msg: ChatMessage, payload: any): void;
|
||||
(e: 'output-preview', msg: ChatMessage, output: WorkflowOutput): void;
|
||||
(e: 'output-download', msg: ChatMessage, output: WorkflowOutput): void;
|
||||
(e: 'output-delete', msg: ChatMessage, output: WorkflowOutput): void;
|
||||
(e: 'load-more'): void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
messages: ChatMessage[];
|
||||
hasMore?: boolean;
|
||||
loadingMore?: boolean;
|
||||
// 产出相对路径 → 完整 URL 解析函数(由首页传入 buildAssetUrl),内嵌预览媒体需完整地址
|
||||
resolveUrl?: (url: string) => string;
|
||||
// InputBar 发送按钮触发执行:父组件定位目标卡片后,经此信号调用卡片内部提交
|
||||
// (卡片参数在子组件内部,父组件无法直接取值,故由卡片暴露方法、这里中转调用)
|
||||
executeRequest?: { msgId: string; action: 'submit'; nonce: number } | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
hasMore: false,
|
||||
loadingMore: false,
|
||||
resolveUrl: undefined,
|
||||
executeRequest: null,
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
const chatListRef = ref<HTMLElement | null>(null);
|
||||
|
||||
// 会话中最后一张已执行的工作流卡片(done/failed):参数保持可编辑,便于改参后经发送按钮重新执行
|
||||
const lastFormMsg = computed(() =>
|
||||
[...props.messages].reverse().find((m) => m.type === 'form' && !!m.formStatus && m.formStatus !== 'editing')
|
||||
);
|
||||
|
||||
const isErrorMsg = (msg: ChatMessage): boolean => !msg.isUser && String(msg.content || '').startsWith('❌');
|
||||
|
||||
// 思考区折叠:未手动操作时,流式中展开、完成(出现答案)后自动收起(DeepSeek 形态)
|
||||
const thinkingExpanded = reactive<Record<string, boolean>>({});
|
||||
|
||||
const isThinkingExpanded = (msg: ChatMessage): boolean =>
|
||||
msg.id in thinkingExpanded ? thinkingExpanded[msg.id] : !msg.content;
|
||||
|
||||
const toggleThinking = (msg: ChatMessage) => {
|
||||
thinkingExpanded[msg.id] = !isThinkingExpanded(msg);
|
||||
};
|
||||
|
||||
// Markdown 渲染缓存(content 在作答完成后固定,避免重复解析)
|
||||
// 设上限:防止长会话渲染结果无限累积(超限清空重建,量级可控)
|
||||
const mdCache = new Map<string, string>();
|
||||
const MD_CACHE_MAX = 300;
|
||||
const renderAnswer = (content: string): string => {
|
||||
if (mdCache.size >= MD_CACHE_MAX) mdCache.clear();
|
||||
if (!mdCache.has(content)) mdCache.set(content, renderMarkdown(content));
|
||||
return mdCache.get(content)!;
|
||||
};
|
||||
|
||||
// 复制文本(剪贴板 API + 兼容回退)
|
||||
const copyText = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
ElMessage.success('已复制');
|
||||
};
|
||||
|
||||
// 代码块复制(事件委托:点击渲染出的 .code-copy-btn)
|
||||
const handleAnswerClick = (e: MouseEvent) => {
|
||||
const el = e.target as HTMLElement;
|
||||
const btn = el.closest('.code-copy-btn');
|
||||
if (!btn) return;
|
||||
const code = btn.closest('.code-wrapper')?.querySelector('code');
|
||||
if (code) copyText(code.textContent || '');
|
||||
};
|
||||
|
||||
// 思考区折叠标题:完成(有答案)显示"已深度思考(用时 X 秒)",流式中显示"思考中…"
|
||||
const thinkingTitle = (msg: ChatMessage): string => {
|
||||
if (!msg.content) return '思考中…';
|
||||
return msg.thinkingSeconds != null ? `已深度思考(用时 ${msg.thinkingSeconds} 秒)` : '已深度思考';
|
||||
};
|
||||
|
||||
// 距底部小于该阈值(px)视为"正在查看最新":新消息到达自动滚底;用户上滑阅读历史(超过阈值)则不强制拉回
|
||||
const NEAR_BOTTOM_THRESHOLD = 120;
|
||||
// 是否处于"查看最新"状态:初始为真(挂载即定位最新),由用户真实滚动(isTrusted)更新
|
||||
let isAtBottom = true;
|
||||
// autoLoading 标记本次 load-more 是否由"自动续载"触发:自动续载不保持位置(落在最新),用户上滑保持视口
|
||||
let autoLoading = false;
|
||||
// 自动续载累计次数上限:连续自动加载超过上限即停,防止异常数据(如 hasMore 恒 true 且视口不满)
|
||||
// 导致 load-more 无限循环占满主线程。用户主动上滑加载不受此上限约束(见 handleScroll)。
|
||||
const MAX_AUTO_LOAD = 5;
|
||||
let autoLoadCount = 0;
|
||||
const canAutoLoad = (): boolean => autoLoadCount < MAX_AUTO_LOAD;
|
||||
|
||||
// 消息新增或内容变化时自动滚动到底部;顶部加载更多期间不滚底。
|
||||
// immediate:切换会话时 ChatList 会重建,挂载即定位到最新消息,避免停留在顶部旧消息。
|
||||
// 首屏不满视口时自动续载更早记录,直到填满视口或没有更多(保证"上滑加载"入口始终可见)
|
||||
watch(
|
||||
() => props.messages.map((m) => `${m.id}|${m.content}|${m.thinking || ''}|${m.loading ? 1 : 0}`).join('\n'),
|
||||
() => {
|
||||
if (props.loadingMore) return;
|
||||
nextTick(() => {
|
||||
const el = chatListRef.value;
|
||||
if (!el) return;
|
||||
// 仅当用户贴近底部(isAtBottom)时才滚底;上滑阅读历史期间新消息不打断阅读位置
|
||||
if (isAtBottom) el.scrollTop = el.scrollHeight;
|
||||
if (props.messages.length > 0 && props.hasMore && el.scrollHeight <= el.clientHeight) {
|
||||
if (canAutoLoad()) {
|
||||
autoLoading = true;
|
||||
autoLoadCount++;
|
||||
emit('load-more');
|
||||
} else {
|
||||
// 达到自动续载上限:停止自动加载,避免无限循环;用户可主动上滑继续加载更早记录
|
||||
autoLoading = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 顶部加载更多:开始加载时记录容器高度,完成后把滚动位置补回新增高度,保持视口内消息不跳动。
|
||||
// 该 watch 注册在 messages watch 之后,其 nextTick 回调后执行,可覆盖同批的"滚到底"。
|
||||
const prevScrollHeight = ref(0);
|
||||
watch(
|
||||
() => props.loadingMore,
|
||||
(loading, prev) => {
|
||||
if (loading && !prev) {
|
||||
const el = chatListRef.value;
|
||||
prevScrollHeight.value = autoLoading ? 0 : el && el.scrollHeight > el.clientHeight ? el.scrollHeight : 0;
|
||||
} else if (!loading && prev) {
|
||||
nextTick(() => {
|
||||
const el = chatListRef.value;
|
||||
if (!el) return;
|
||||
if (prevScrollHeight.value) {
|
||||
// 用户主动上滑加载更早:滚动位置补回新增高度,保持视口内消息不跳动
|
||||
el.scrollTop = el.scrollHeight - prevScrollHeight.value;
|
||||
prevScrollHeight.value = 0;
|
||||
} else if (autoLoading) {
|
||||
// 自动续载:加载期间滚底 watch 被 loadingMore 拦截(同批 loadingMore 仍为 true 先 flush),
|
||||
// 恢复时须把 scrollTop 设回底部定位到最新消息;若仍不满屏则继续自动续载
|
||||
el.scrollTop = el.scrollHeight;
|
||||
if (props.messages.length > 0 && props.hasMore && el.scrollHeight <= el.clientHeight) {
|
||||
if (canAutoLoad()) {
|
||||
autoLoadCount++;
|
||||
emit('load-more');
|
||||
} else {
|
||||
// 达到自动续载上限:停止自动加载,避免无限循环
|
||||
autoLoading = false;
|
||||
}
|
||||
} else {
|
||||
autoLoading = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 滚动到顶部时触发加载更早记录(防重入由父组件 sessionLoadingMore 保证)
|
||||
const handleScroll = (e: Event) => {
|
||||
// 程序设置 scrollTop 触发的 scroll 事件(isTrusted=false)不参与交互判断:
|
||||
// 自动续载/滚底时 scrollTop 不满屏被钳制为 0,会被误判为"用户上滑"而清空 autoLoading、破坏自动定位到最新
|
||||
if (!e.isTrusted) return;
|
||||
const el = chatListRef.value;
|
||||
if (!el) return;
|
||||
// 感知用户阅读位置:贴近底部视为"正在查看最新"(新消息自动滚底),上滑阅读历史则不再强制拉回
|
||||
isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_THRESHOLD;
|
||||
if (!props.hasMore || props.loadingMore) return;
|
||||
if (el.scrollTop <= 4) {
|
||||
// 用户主动上滑加载更早:重置自动续载计数,使用户操作不受自动续载上限约束
|
||||
autoLoading = false;
|
||||
autoLoadCount = 0;
|
||||
emit('load-more');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const el = chatListRef.value;
|
||||
// 挂载即定位到最新消息。immediate watch 的 nextTick 在 Transition 期间 DOM 未插入时执行(el 为 null)会失效,
|
||||
// 这里在 DOM 已插入、内容已渲染(sh 已确定)后滚底;requestAnimationFrame 确保过渡与布局完成
|
||||
requestAnimationFrame(() => {
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
});
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-list {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
padding: 8px 0 16px;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
/* 顶部:加载更早记录提示条 */
|
||||
.load-more-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: #cbd5e1;
|
||||
padding: 4px 0 10px;
|
||||
}
|
||||
|
||||
.load-more-spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-top-color: #94a3b8;
|
||||
border-radius: 50%;
|
||||
animation: load-more-rotate 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes load-more-rotate {
|
||||
to { transform: rotate(360deg); }
|
||||
gap: 16px;
|
||||
padding: 28px 0 10px;
|
||||
}
|
||||
|
||||
.message-row {
|
||||
@@ -357,399 +45,37 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 节奏化消息间距:AI↔AI 24,用户↔AI 16 */
|
||||
.message-row + .message-row {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.message-row:not(.is-user) + .message-row:not(.is-user) {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.bubble-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
align-items: flex-start;
|
||||
|
||||
.message-row.is-user & {
|
||||
align-items: flex-end;
|
||||
/* 明确宽度基准:打断 fit-content 与百分比 max-width 的循环,避免气泡被压得过窄导致文字竖排 */
|
||||
width: 100%;
|
||||
}
|
||||
gap: 5px;
|
||||
max-width: min(84%, 1120px);
|
||||
}
|
||||
|
||||
.bubble {
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
word-break: break-word;
|
||||
color: #1f2328;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
|
||||
/* AI 消息:无背景卡片,纯文本左对齐(DeepSeek 式) */
|
||||
.message-row:not(.is-user) & {
|
||||
width: 100%;
|
||||
max-width: 880px;
|
||||
}
|
||||
|
||||
.message-row.is-user & {
|
||||
width: fit-content;
|
||||
max-width: 72%;
|
||||
padding: 10px 16px;
|
||||
border-radius: 18px 18px 6px 18px;
|
||||
background: #e8ecff;
|
||||
color: #1f2328;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.message-row.is-error & {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
/* 工作流表单卡片消息:左对齐,卡片自带背景不套气泡 */
|
||||
.message-row.is-form {
|
||||
justify-content: flex-start;
|
||||
.bubble-wrap {
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
.time { padding-left: 2px; }
|
||||
}
|
||||
}
|
||||
|
||||
/* AI 消息操作栏(hover 显示) */
|
||||
.msg-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
.message-row:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
/* 用户消息在右侧,操作栏右对齐 */
|
||||
.message-row.is-user & {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #eef0f3;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
&.is-danger:hover {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 思考区:DeepSeek 风格折叠块 */
|
||||
.thinking-block {
|
||||
margin: 0 0 12px;
|
||||
background: #f5f6f7;
|
||||
border: 1px solid #eef0f3;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
max-width: 880px;
|
||||
}
|
||||
|
||||
.thinking-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 9px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #8b8b98;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
user-select: none;
|
||||
transition: color 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.thinking-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.thinking-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.thinking-arrow {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.thinking-content {
|
||||
padding: 0 14px 14px;
|
||||
animation: thinking-fade-in 0.2s ease;
|
||||
}
|
||||
|
||||
.thinking-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #9a9aab;
|
||||
font-style: italic;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.75;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
word-break: break-word;
|
||||
border-top: 1px solid #eef0f3;
|
||||
padding-top: 10px;
|
||||
}
|
||||
color: #1f2937;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.96) 0%, rgba(248, 250, 255, 0.92) 100%);
|
||||
border: 1px solid rgba(226, 233, 244, 0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.05);
|
||||
|
||||
@keyframes thinking-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
.message-row.is-user & {
|
||||
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
|
||||
border-color: rgba(59, 130, 246, 0.4);
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 24px rgba(37, 99, 235, 0.35);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 回答区:Markdown 渲染(DeepSeek 风格排版) */
|
||||
.answer-block {
|
||||
white-space: normal;
|
||||
line-height: 1.7;
|
||||
font-size: 15px;
|
||||
word-break: break-word;
|
||||
max-width: 880px;
|
||||
|
||||
& > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
& > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(p) {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
:deep(h1),
|
||||
:deep(h2),
|
||||
:deep(h3),
|
||||
:deep(h4),
|
||||
:deep(h5),
|
||||
:deep(h6) {
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin: 20px 0 10px;
|
||||
color: #1f2328;
|
||||
}
|
||||
|
||||
:deep(h1) {
|
||||
font-size: 20px;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(h2) {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
:deep(h3) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
:deep(h4),
|
||||
:deep(h5),
|
||||
:deep(h6) {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
:deep(ul),
|
||||
:deep(ol) {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
:deep(li) {
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
:deep(li > ul),
|
||||
:deep(li > ol) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(blockquote) {
|
||||
margin: 0 0 12px;
|
||||
padding: 6px 14px;
|
||||
border-left: 4px solid #e0e0e0;
|
||||
color: #6a737d;
|
||||
background: #fafafa;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
:deep(code) {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 13px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: #f2f3f5;
|
||||
color: #c7254e;
|
||||
}
|
||||
|
||||
:deep(.code-wrapper) {
|
||||
position: relative;
|
||||
margin: 0 0 14px;
|
||||
|
||||
&:hover .code-copy-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.code-copy-btn) {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #8b949e;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
color: #c9d1d9;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(pre) {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: #0d1117;
|
||||
overflow-x: auto;
|
||||
line-height: 1.6;
|
||||
|
||||
code {
|
||||
display: block;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(table) {
|
||||
width: 100%;
|
||||
margin: 0 0 14px;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 6px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f5f7fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(a) {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid #eaecef;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 执行中占位 */
|
||||
.loading-text {
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-left: 6px;
|
||||
|
||||
i {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
animation: dot-blink 1.2s infinite ease-in-out both;
|
||||
|
||||
&:nth-child(1) { animation-delay: 0s; }
|
||||
&:nth-child(2) { animation-delay: 0.15s; }
|
||||
&:nth-child(3) { animation-delay: 0.3s; }
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dot-blink {
|
||||
0%, 80%, 100% { opacity: 0.2; }
|
||||
40% { opacity: 1; }
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 11px;
|
||||
color: #8f9aae;
|
||||
padding: 4px 2px;
|
||||
padding: 4px 6px;
|
||||
|
||||
.message-row.is-user & {
|
||||
text-align: right;
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div class="command-list">
|
||||
<div v-for="command in commandList" :key="command.id" class="command-card" @click="handleCommandClick(command)">
|
||||
<div class="command-icon">
|
||||
<el-icon :size="32" color="#3b82f6">
|
||||
<Lightning />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="command-content">
|
||||
<h3 class="command-name">{{ command.name }}</h3>
|
||||
<p class="command-desc">{{ command.description }}</p>
|
||||
<div class="command-trigger">
|
||||
<el-tag size="small" type="info">{{ command.trigger }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="command-actions">
|
||||
<el-button text size="small" @click.stop="handleEdit(command)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
</el-button>
|
||||
<el-button text size="small" type="danger" @click.stop="handleDelete(command)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Lightning, Edit, Delete } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
interface Command {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
trigger: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
const commandList = ref<Command[]>([
|
||||
{
|
||||
id: 1,
|
||||
name: '快速创建组件',
|
||||
description: '快速创建一个 Vue 组件模板,包含基础结构和样式',
|
||||
trigger: '/component',
|
||||
action: 'create_component',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '生成 API 接口',
|
||||
description: '根据接口文档快速生成 API 请求函数',
|
||||
trigger: '/api',
|
||||
action: 'generate_api',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '代码格式化',
|
||||
description: '格式化当前文件的代码,统一代码风格',
|
||||
trigger: '/format',
|
||||
action: 'format_code',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '添加类型定义',
|
||||
description: '为 JavaScript 代码添加 TypeScript 类型定义',
|
||||
trigger: '/types',
|
||||
action: 'add_types',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: '生成测试用例',
|
||||
description: '为当前组件或函数生成单元测试代码',
|
||||
trigger: '/test',
|
||||
action: 'generate_test',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: '优化性能',
|
||||
description: '分析代码并提供性能优化建议',
|
||||
trigger: '/optimize',
|
||||
action: 'optimize_code',
|
||||
},
|
||||
]);
|
||||
|
||||
const handleCommandClick = (command: Command) => {
|
||||
ElMessage.success(`执行指令: ${command.name}`);
|
||||
};
|
||||
|
||||
const handleEdit = (command: Command) => {
|
||||
ElMessage.info(`编辑指令: ${command.name}`);
|
||||
};
|
||||
|
||||
const handleDelete = (command: Command) => {
|
||||
ElMessage.warning(`删除指令: ${command.name}`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.command-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.command-card {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e5e7eb;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-2px);
|
||||
border-color: #3b82f6;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f0f9ff 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.command-icon {
|
||||
flex-shrink: 0;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.command-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.command-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.command-desc {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.command-trigger {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.command-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div class="diary-list">
|
||||
<div v-for="diary in diaryList" :key="diary.id" class="diary-card" @click="handleDiaryClick(diary)">
|
||||
<div class="diary-header">
|
||||
<div class="diary-date">
|
||||
<el-icon><Calendar /></el-icon>
|
||||
{{ diary.date }}
|
||||
</div>
|
||||
<el-tag :type="diary.mood === 'happy' ? 'success' : diary.mood === 'sad' ? 'danger' : 'warning'" size="small">
|
||||
{{ getMoodText(diary.mood) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<h3 class="diary-title">{{ diary.title }}</h3>
|
||||
<p class="diary-content">{{ diary.content }}</p>
|
||||
<div class="diary-footer">
|
||||
<div class="diary-tags">
|
||||
<el-tag v-for="tag in diary.tags" :key="tag" size="small" effect="plain">{{ tag }}</el-tag>
|
||||
</div>
|
||||
<div class="diary-actions">
|
||||
<el-button text size="small" @click.stop="handleEdit(diary)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
</el-button>
|
||||
<el-button text size="small" type="danger" @click.stop="handleDelete(diary)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Calendar, Edit, Delete } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
interface Diary {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
date: string;
|
||||
mood: 'happy' | 'sad' | 'normal';
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const diaryList = ref<Diary[]>([
|
||||
{
|
||||
id: 1,
|
||||
title: '今天学习了 Vue 3 新特性',
|
||||
content: '今天深入学习了 Vue 3 的 Composition API,感觉比 Options API 更加灵活和强大。特别是 setup 函数和响应式系统的改进,让代码组织更加清晰...',
|
||||
date: '2026-05-26',
|
||||
mood: 'happy',
|
||||
tags: ['学习', 'Vue3', '前端'],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '项目进度顺利',
|
||||
content: '今天完成了首页重构的基础布局,整体效果不错。明天继续完善功能模块和样式细节...',
|
||||
date: '2026-05-25',
|
||||
mood: 'happy',
|
||||
tags: ['工作', '项目'],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '遇到了一些技术难题',
|
||||
content: '在处理复杂组件通信时遇到了一些问题,花了不少时间调试。最后通过重新设计数据流解决了...',
|
||||
date: '2026-05-24',
|
||||
mood: 'normal',
|
||||
tags: ['技术', '调试'],
|
||||
},
|
||||
]);
|
||||
|
||||
const getMoodText = (mood: string) => {
|
||||
const moodMap: Record<string, string> = {
|
||||
happy: '😊 开心',
|
||||
sad: '😢 难过',
|
||||
normal: '😐 平静',
|
||||
};
|
||||
return moodMap[mood] || '平静';
|
||||
};
|
||||
|
||||
const handleDiaryClick = (diary: Diary) => {
|
||||
ElMessage.info(`查看日记: ${diary.title}`);
|
||||
};
|
||||
|
||||
const handleEdit = (diary: Diary) => {
|
||||
ElMessage.info(`编辑日记: ${diary.title}`);
|
||||
};
|
||||
|
||||
const handleDelete = (diary: Diary) => {
|
||||
ElMessage.warning(`删除日记: ${diary.title}`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.diary-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.diary-card {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e5e7eb;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-2px);
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
.diary-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.diary-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.diary-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 12px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.diary-content {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 16px 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.diary-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.diary-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.diary-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div class="file-list">
|
||||
<div v-for="file in fileList" :key="file.id" class="file-card" @click="handleFileClick(file)">
|
||||
<div class="file-icon">
|
||||
<el-icon :size="40" :color="getFileColor(file.type)">
|
||||
<component :is="getFileIcon(file.type)" />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="file-info">
|
||||
<h3 class="file-name">{{ file.name }}</h3>
|
||||
<div class="file-meta">
|
||||
<span class="file-size">{{ file.size }}</span>
|
||||
<span class="file-date">{{ file.date }}</span>
|
||||
</div>
|
||||
<div class="file-tags">
|
||||
<el-tag v-for="tag in file.tags" :key="tag" size="small" effect="plain">{{ tag }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-actions">
|
||||
<el-button text size="small" @click.stop="handleDownload(file)">
|
||||
<el-icon><Download /></el-icon>
|
||||
</el-button>
|
||||
<el-button text size="small" type="danger" @click.stop="handleDelete(file)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Document, Picture, VideoPlay, Folder, Download, Delete } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
interface FileItem {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'document' | 'image' | 'video' | 'folder';
|
||||
size: string;
|
||||
date: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const fileList = ref<FileItem[]>([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Vue3 学习笔记.pdf',
|
||||
type: 'document',
|
||||
size: '2.5 MB',
|
||||
date: '2026-05-26',
|
||||
tags: ['学习', '文档'],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '项目截图.png',
|
||||
type: 'image',
|
||||
size: '856 KB',
|
||||
date: '2026-05-25',
|
||||
tags: ['截图', '项目'],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '演示视频.mp4',
|
||||
type: 'video',
|
||||
size: '15.3 MB',
|
||||
date: '2026-05-24',
|
||||
tags: ['视频', '演示'],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '项目文档',
|
||||
type: 'folder',
|
||||
size: '12 个文件',
|
||||
date: '2026-05-23',
|
||||
tags: ['文件夹'],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'API 接口文档.docx',
|
||||
type: 'document',
|
||||
size: '1.2 MB',
|
||||
date: '2026-05-22',
|
||||
tags: ['文档', 'API'],
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'UI 设计稿.sketch',
|
||||
type: 'image',
|
||||
size: '4.8 MB',
|
||||
date: '2026-05-21',
|
||||
tags: ['设计', 'UI'],
|
||||
},
|
||||
]);
|
||||
|
||||
const getFileIcon = (type: string) => {
|
||||
const iconMap: Record<string, any> = {
|
||||
document: Document,
|
||||
image: Picture,
|
||||
video: VideoPlay,
|
||||
folder: Folder,
|
||||
};
|
||||
return iconMap[type] || Document;
|
||||
};
|
||||
|
||||
const getFileColor = (type: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
document: '#3b82f6',
|
||||
image: '#10b981',
|
||||
video: '#f59e0b',
|
||||
folder: '#8b5cf6',
|
||||
};
|
||||
return colorMap[type] || '#6b7280';
|
||||
};
|
||||
|
||||
const handleFileClick = (file: FileItem) => {
|
||||
ElMessage.info(`打开文件: ${file.name}`);
|
||||
};
|
||||
|
||||
const handleDownload = (file: FileItem) => {
|
||||
ElMessage.success(`下载文件: ${file.name}`);
|
||||
};
|
||||
|
||||
const handleDelete = (file: FileItem) => {
|
||||
ElMessage.warning(`删除文件: ${file.name}`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.file-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.file-card {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e5e7eb;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-2px);
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
flex-shrink: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f3f4f6;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 8px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.file-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -2,23 +2,20 @@
|
||||
<div class="input-shell">
|
||||
<div class="input-card" :class="{ 'is-focused': isFocused }">
|
||||
<!-- 已选中状态标签栏 -->
|
||||
<Transition name="capsule-slide" @enter="onCapsuleEnter" @after-enter="onCapsuleAfterEnter" @leave="onCapsuleLeave" @after-leave="onCapsuleAfterLeave">
|
||||
<div v-if="selectedWorkflowId !== null" class="selected-tags">
|
||||
<div class="selected-tag workflow-tag">
|
||||
<el-icon><Promotion /></el-icon>
|
||||
<span>{{ commonWorkflows.find((w) => w.id === selectedWorkflowId)?.name }}</span>
|
||||
<el-icon v-if="!lockAll" class="tag-close" @click="clearWorkflow"><Close /></el-icon>
|
||||
</div>
|
||||
<div v-if="selectedWorkflowId !== null" class="selected-tags">
|
||||
<div class="selected-tag workflow-tag">
|
||||
<el-icon><Promotion /></el-icon>
|
||||
<span>{{ commonWorkflows.find((w) => w.id === selectedWorkflowId)?.name }}</span>
|
||||
<el-icon v-if="!workflowLocked" class="tag-close" @click="clearWorkflow"><Close /></el-icon>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- 文本输入区 -->
|
||||
<el-input
|
||||
v-model="message"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 1, maxRows: 6 }"
|
||||
:disabled="generating || isWorkflowRunning || selectedWorkflowId !== null || isWorkflowSession"
|
||||
:placeholder="inputPlaceholder"
|
||||
placeholder="有什么想聊的?按 Enter 发送,Shift+Enter 换行"
|
||||
class="message-input"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
@@ -29,165 +26,89 @@
|
||||
<!-- 底部工具栏 -->
|
||||
<div class="input-toolbar">
|
||||
<div class="toolbar-left">
|
||||
|
||||
<!-- 切换会话模型入口 -->
|
||||
<button class="tool-icon-btn" :class="{ active: !!currentModelName }" title="切换会话模型" @click="emit('select-model')">
|
||||
<el-icon><Cpu /></el-icon>
|
||||
<span class="tool-label">{{ currentModelName || '设置模型' }}</span>
|
||||
<button class="tool-icon-btn" title="上传文件" @click="handleAttachment">
|
||||
<el-icon><Paperclip /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 技能暂已禁用 -->
|
||||
<button class="tool-icon-btn" title="技能暂不可用" disabled>
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
<span class="tool-label">技能</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<span class="hint-text">Shift+Enter 换行</span>
|
||||
<button
|
||||
class="send-btn"
|
||||
:class="{ 'is-stop': isStopping }"
|
||||
:disabled="sendBtnDisabled"
|
||||
@click="handleSend"
|
||||
>
|
||||
<el-icon v-if="isStopping"><VideoPause /></el-icon>
|
||||
<el-icon v-else><Top /></el-icon>
|
||||
<button class="send-btn" :disabled="selectedWorkflowId === null || sendDisabled" @click="handleSend">
|
||||
<el-icon><Top /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快捷工作流胶囊(输入框下方) -->
|
||||
<Transition name="capsule-slide" @enter="onCapsuleEnter" @after-enter="onCapsuleAfterEnter" @leave="onCapsuleLeave" @after-leave="onCapsuleAfterLeave">
|
||||
<div v-if="visibleWorkflows.length > 0 && !hideShortcuts" class="workflow-shortcuts">
|
||||
<el-tooltip
|
||||
v-for="wf in visibleWorkflows"
|
||||
:key="wf.id"
|
||||
:content="wf.name"
|
||||
placement="top"
|
||||
:show-after="150"
|
||||
>
|
||||
<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"
|
||||
title="查看全部工作流"
|
||||
@click="handleOpenWorkflowManage"
|
||||
>
|
||||
<el-icon><More /></el-icon>
|
||||
更多
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
<div v-if="visibleWorkflows.length > 0" class="workflow-shortcuts">
|
||||
<button
|
||||
v-for="wf in visibleWorkflows"
|
||||
:key="wf.id"
|
||||
class="shortcut-pill"
|
||||
:class="{ active: selectedWorkflowId === wf.id }"
|
||||
@click="toggleWorkflow(wf.id)"
|
||||
>
|
||||
<el-icon><Promotion /></el-icon>
|
||||
{{ wf.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Top, Promotion, Close, VideoPause, Cpu, More } from '@element-plus/icons-vue';
|
||||
import { Top, MagicStick, Promotion, Close, Paperclip } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { getWorkflowList } from '/@/api/settings/creation';
|
||||
|
||||
interface Props {
|
||||
sendDisabled?: boolean;
|
||||
hideShortcuts?: boolean;
|
||||
generating?: boolean;
|
||||
// 当前会话模型名称,用于输入栏切换入口展示
|
||||
currentModelName?: string;
|
||||
// 工作流正在执行中:输入框禁用,发送按钮变停止(与普通对话生成共用同一开关)
|
||||
isWorkflowRunning?: boolean;
|
||||
// 本会话为工作流会话(已执行过工作流):文本输入永久禁止,发送按钮仅执行工作流
|
||||
isWorkflowSession?: boolean;
|
||||
workflowLocked?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'send', message: string): void;
|
||||
// 工作流场景点击发送:父组件定位目标工作流卡片执行(编辑态提交 / 定格态重跑)
|
||||
(e: 'execute-workflow'): void;
|
||||
(e: 'workflow-select', workflowId: string | null, isTemplate?: boolean): void;
|
||||
(e: 'stop'): void;
|
||||
(e: 'select-model'): void;
|
||||
(e: 'workflow-select', workflowId: string | null): void;
|
||||
}
|
||||
|
||||
interface Workflow {
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
isTemplate: boolean;
|
||||
raw?: any;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
sendDisabled: false,
|
||||
hideShortcuts: false,
|
||||
generating: false,
|
||||
currentModelName: '',
|
||||
isWorkflowRunning: false,
|
||||
isWorkflowSession: false,
|
||||
workflowLocked: false,
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
const router = useRouter();
|
||||
// 首页展示工作流上限:用户工作流优先,不足用系统模板补足,最多 9 个
|
||||
const MAX_WORKFLOWS = 9;
|
||||
|
||||
// 「更多」入口:工作流数量超出展示上限时跳转工作流管理页查看/选择全部
|
||||
const handleOpenWorkflowManage = () => {
|
||||
router.push('/settings/workflow');
|
||||
};
|
||||
const message = ref('');
|
||||
const isFocused = ref(false);
|
||||
const selectedWorkflowId = ref<string | null>(null);
|
||||
const commonWorkflows = ref<Workflow[]>([]);
|
||||
// 取消工作流前的选择:父组件确认「继续编辑」时回滚,避免 InputBar 先置空、父侧保留草稿造成状态脱钩
|
||||
const previousSelectedWorkflowId = ref<string | null>(null);
|
||||
|
||||
// 工作流胶囊最多展示 MAX_WORKFLOWS 个:commonWorkflows 用户工作流在前、模板在后,
|
||||
// 截断前 MAX_WORKFLOWS 个即「用户优先、不足模板补足」;完整列表仍保留供父组件匹配
|
||||
const visibleWorkflows = computed(() => commonWorkflows.value.slice(0, MAX_WORKFLOWS));
|
||||
|
||||
// 输入框整体锁定:正处于生成/工作流执行中(执行中禁止切换工作流)
|
||||
const lockAll = computed(() => props.isWorkflowRunning || props.generating);
|
||||
|
||||
// 输入框占位符:工作流执行中 / 选中工作流待填 / 工作流会话永久禁输入时给出明确提示
|
||||
const inputPlaceholder = computed(() =>
|
||||
props.isWorkflowRunning
|
||||
? '工作流执行中,请稍候...'
|
||||
: selectedWorkflowId.value !== null
|
||||
? '请填写上方工作流表单,提交后开始执行'
|
||||
: props.isWorkflowSession
|
||||
? '本会话为工作流对话,仅可执行工作流'
|
||||
: '有什么想聊的?按 Enter 发送,Shift+Enter 换行',
|
||||
);
|
||||
|
||||
// 生成中 / 工作流执行中:发送按钮变「停止」(普通对话与工作流共用同一开关)
|
||||
const isStopping = computed(() => props.generating || props.isWorkflowRunning);
|
||||
// 发送按钮可用性:执行中可点(停止);工作流场景可点(执行);普通对话需有文本且未禁用
|
||||
const sendBtnDisabled = computed(() => {
|
||||
if (props.generating || props.isWorkflowRunning) return false;
|
||||
if (selectedWorkflowId.value !== null || props.isWorkflowSession) return false;
|
||||
return props.sendDisabled || !message.value.trim();
|
||||
const visibleWorkflows = computed(() => {
|
||||
if (props.workflowLocked && selectedWorkflowId.value) {
|
||||
return commonWorkflows.value.filter((w) => w.id === selectedWorkflowId.value);
|
||||
}
|
||||
return commonWorkflows.value;
|
||||
});
|
||||
|
||||
const fetchWorkflows = async () => {
|
||||
try {
|
||||
const res = await getWorkflowList();
|
||||
const userList = res.data?.listFlowUserRes?.list || [];
|
||||
const tplList = res.data?.listFlowTemplateRes?.list || [];
|
||||
// commonWorkflows 保留完整列表:父组件按 id 匹配工作流名称/预选历史会话,不能被展示截断影响
|
||||
const workflows = [
|
||||
...userList.map((w) => ({ ...w, isTemplate: false })),
|
||||
...tplList.map((w) => ({ ...w, isTemplate: true })),
|
||||
];
|
||||
const workflows = res.data?.listFlowUserRes?.list || [];
|
||||
commonWorkflows.value = workflows.map((w) => ({
|
||||
id: String(w.id),
|
||||
name: w.flowName || w.flowTemplateName || '未命名',
|
||||
prefix: '[工作流] ' + (w.flowName || w.flowTemplateName || '') + ':\n',
|
||||
isTemplate: !!w.isTemplate,
|
||||
raw: w,
|
||||
name: w.flowName || '未命名',
|
||||
prefix: '[工作流] ' + (w.flowName || '') + ':\n',
|
||||
}));
|
||||
} catch {
|
||||
commonWorkflows.value = [];
|
||||
@@ -195,32 +116,22 @@ const fetchWorkflows = async () => {
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
// 生成中 / 工作流执行中:发送按钮即「停止」(普通对话与工作流共用同一开关)
|
||||
if (props.generating || props.isWorkflowRunning) {
|
||||
emit('stop');
|
||||
return;
|
||||
}
|
||||
// 工作流场景(已选工作流 / 本会话为工作流会话):发送按钮即「执行工作流」
|
||||
if (selectedWorkflowId.value !== null || props.isWorkflowSession) {
|
||||
emit('execute-workflow');
|
||||
return;
|
||||
}
|
||||
// 普通对话:发送文本
|
||||
if (selectedWorkflowId.value === null || props.sendDisabled) return;
|
||||
const msg = message.value.trim();
|
||||
if (!msg || props.sendDisabled) return;
|
||||
emit('send', msg);
|
||||
emit('send', msg || '');
|
||||
message.value = '';
|
||||
// 不清除工作流选择,保持表单可见
|
||||
};
|
||||
|
||||
const handleAttachment = () => {
|
||||
ElMessage.info('附件上传功能开发中...');
|
||||
};
|
||||
|
||||
const toggleWorkflow = (id: string) => {
|
||||
if (lockAll.value) return;
|
||||
const item = commonWorkflows.value.find((w) => w.id === id);
|
||||
if (props.workflowLocked) return;
|
||||
const newId = selectedWorkflowId.value === id ? null : id;
|
||||
// 取消(newId 为 null)前记录当前选择,供父组件确认「继续编辑」时回滚
|
||||
if (newId === null) previousSelectedWorkflowId.value = selectedWorkflowId.value;
|
||||
selectedWorkflowId.value = newId;
|
||||
emit('workflow-select', newId, item?.isTemplate || false);
|
||||
emit('workflow-select', newId);
|
||||
};
|
||||
|
||||
const resetAll = () => {
|
||||
@@ -230,81 +141,30 @@ const resetAll = () => {
|
||||
};
|
||||
|
||||
const clearWorkflow = () => {
|
||||
if (lockAll.value) return;
|
||||
// 清空(取消)前记录当前选择,供父组件确认「继续编辑」时回滚
|
||||
previousSelectedWorkflowId.value = selectedWorkflowId.value;
|
||||
if (props.workflowLocked) return;
|
||||
selectedWorkflowId.value = null;
|
||||
emit('workflow-select', null);
|
||||
};
|
||||
|
||||
const selectWorkflow = (id: string | null) => {
|
||||
if (lockAll.value) return;
|
||||
selectedWorkflowId.value = id;
|
||||
emit('workflow-select', id, false);
|
||||
};
|
||||
|
||||
// 父组件确认「放弃未提交表单」失败时回滚选择:恢复取消前的工作流(标签与禁用态归位),
|
||||
// 不发 emit(父侧草稿与 selectedWorkflowDetail 保留,状态由父组件维持)
|
||||
const restoreWorkflowSelection = () => {
|
||||
if (previousSelectedWorkflowId.value !== null) {
|
||||
selectedWorkflowId.value = previousSelectedWorkflowId.value;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchWorkflows();
|
||||
});
|
||||
|
||||
// —— 胶囊高度动画:JS 测量实际高度,让 max-height 与内容高度精确匹配,
|
||||
// 进入时从 0 展开、离开时收缩到 0,配合 CSS transition 消除底部高度突变 ——
|
||||
const onCapsuleEnter = (el: Element) => {
|
||||
const e = el as HTMLElement;
|
||||
e.style.maxHeight = '0px';
|
||||
requestAnimationFrame(() => {
|
||||
e.style.maxHeight = e.scrollHeight + 'px';
|
||||
});
|
||||
};
|
||||
const onCapsuleAfterEnter = (el: Element) => {
|
||||
(el as HTMLElement).style.maxHeight = '';
|
||||
};
|
||||
const onCapsuleLeave = (el: Element) => {
|
||||
const e = el as HTMLElement;
|
||||
e.style.maxHeight = e.scrollHeight + 'px';
|
||||
requestAnimationFrame(() => {
|
||||
e.style.maxHeight = '0px';
|
||||
});
|
||||
};
|
||||
const onCapsuleAfterLeave = (el: Element) => {
|
||||
(el as HTMLElement).style.maxHeight = '';
|
||||
};
|
||||
|
||||
defineExpose({ resetAll, clearWorkflow, selectWorkflow, restoreWorkflowSelection, fetchWorkflows, selectedWorkflowId, commonWorkflows });
|
||||
defineExpose({ resetAll, clearWorkflow, selectedWorkflowId, commonWorkflows });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* ===== 胶囊显示/隐藏过渡:淡入淡出 + 轻位移 + 高度展开/收缩 ===== */
|
||||
.capsule-slide-enter-active,
|
||||
.capsule-slide-leave-active {
|
||||
transition: opacity 0.25s ease, transform 0.25s ease, max-height 0.25s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.capsule-slide-enter-from,
|
||||
.capsule-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
|
||||
.input-shell {
|
||||
padding: 0 20px 20px;
|
||||
padding: 0 24px 24px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.input-card {
|
||||
max-width: 880px;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
border: 1.5px solid #e5e8ee;
|
||||
border-radius: 20px;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.07);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
@@ -361,7 +221,7 @@ const onCapsuleAfterLeave = (el: Element) => {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
resize: none;
|
||||
padding: 16px 18px 10px;
|
||||
padding: 14px 16px 8px;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: #0f172a;
|
||||
@@ -380,7 +240,7 @@ const onCapsuleAfterLeave = (el: Element) => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px 12px 14px;
|
||||
padding: 6px 10px 10px 12px;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
@@ -394,10 +254,10 @@ const onCapsuleAfterLeave = (el: Element) => {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 34px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
@@ -438,10 +298,10 @@ const onCapsuleAfterLeave = (el: Element) => {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
@@ -466,22 +326,12 @@ const onCapsuleAfterLeave = (el: Element) => {
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 生成中:变停止按钮 */
|
||||
&.is-stop {
|
||||
background: #f43f5e;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: #e11d48;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 快捷工作流胶囊 */
|
||||
.workflow-shortcuts {
|
||||
max-width: 880px;
|
||||
margin: 8px auto 0;
|
||||
max-width: 860px;
|
||||
margin: 10px auto 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
@@ -491,13 +341,13 @@ const onCapsuleAfterLeave = (el: Element) => {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 32px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #e5e8ee;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(4px);
|
||||
@@ -505,7 +355,7 @@ const onCapsuleAfterLeave = (el: Element) => {
|
||||
outline: none;
|
||||
|
||||
.el-icon {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@@ -521,43 +371,4 @@ const onCapsuleAfterLeave = (el: Element) => {
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* 胶囊内工作流名称:单行省略显示(不撑宽胶囊),悬停经 el-tooltip 展示完整名称 */
|
||||
.pill-name {
|
||||
max-width: 140px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 「更多」入口:与普通胶囊区分,虚线边框 + 更轻的视觉重量,提示可进入管理页查看全部 */
|
||||
.shortcut-more {
|
||||
border-style: dashed;
|
||||
color: #94a3b8;
|
||||
|
||||
&:hover {
|
||||
border-style: dashed;
|
||||
border-color: #93c5fd;
|
||||
color: #2563eb;
|
||||
background: rgba(239, 246, 255, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.pill-tag {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
background: #fff7e6;
|
||||
color: #d97706;
|
||||
border: 1px solid #fde68a;
|
||||
white-space: nowrap;
|
||||
|
||||
.shortcut-pill.active & {
|
||||
background: rgba(255, 247, 230, 0.2);
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,157 +1,456 @@
|
||||
<template>
|
||||
<div class="main-content">
|
||||
<Transition name="content-fade" mode="out-in">
|
||||
<!-- 对话页 — 有消息或会话结果时展示消息流(工作流表单卡片/结果/产出统一在此)。
|
||||
key 绑定会话 id:切换会话时重建内容区,触发 Transition 过渡动画(固定 key 会复用 DOM 不触发) -->
|
||||
<div v-if="hasMessages || hasResults" :key="activeHistoryId || 'chat'" class="content-body chat-body">
|
||||
<ChatList
|
||||
:messages="messages"
|
||||
:has-more="hasMore"
|
||||
:loading-more="loadingMore"
|
||||
:execute-request="executeRequest"
|
||||
class="chat-list-scroll"
|
||||
@delete="emit('delete', $event)"
|
||||
@form-submit="(msg: any, payload: any) => emit('form-submit', msg, payload)"
|
||||
@output-preview="(msg: any, output: any) => emit('output-preview', msg, output)"
|
||||
@output-download="(msg: any, output: any) => emit('output-download', msg, output)"
|
||||
@output-delete="(msg: any, output: any) => emit('output-delete', msg, output)"
|
||||
@load-more="emit('load-more', activeHistoryId)"
|
||||
/>
|
||||
<!-- 工作流动态表单 -->
|
||||
<div v-if="workflowDetail" class="content-body workflow-form-body">
|
||||
<div class="workflow-form-card">
|
||||
<div class="workflow-form-header">
|
||||
<div>
|
||||
<h3>{{ workflowDetail.flowName || '工作流表单' }}</h3>
|
||||
<div class="workflow-form-sub">{{ workflowDetail.description || '请填写以下参数' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="workflow-form-scroll">
|
||||
<el-form label-position="top" class="workflow-form">
|
||||
<template v-if="workflowDetail.nodeInputParams">
|
||||
<template v-for="node in workflowDetail.nodeInputParams" :key="node.id || node.nodeCode">
|
||||
<template v-if="node.nodeCode !== '__start__' && hasFormConfig(node)">
|
||||
|
||||
<el-form-item
|
||||
v-for="field in getVisibleFields(node)"
|
||||
:key="getFieldKey(node, field)"
|
||||
:label="field.label"
|
||||
:required="field.required"
|
||||
>
|
||||
<!-- 文本输入 -->
|
||||
<el-input
|
||||
v-if="field.type === 'input' || field.type === 'string'"
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<!-- 数字输入 -->
|
||||
<el-input-number
|
||||
v-else-if="field.type === 'number' || field.type === 'inputNumber'"
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
class="w100"
|
||||
:controls="true"
|
||||
:min="field.fieldConstraint?.minValue ?? undefined"
|
||||
:max="field.fieldConstraint?.maxValue ?? undefined"
|
||||
/>
|
||||
|
||||
<!-- 多行文本 -->
|
||||
<el-input
|
||||
v-else-if="field.type === 'textarea'"
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
show-word-limit
|
||||
:maxlength="500"
|
||||
/>
|
||||
|
||||
<!-- 开关 -->
|
||||
<el-switch
|
||||
v-else-if="field.type === 'switch'"
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
active-text="开启"
|
||||
inactive-text="关闭"
|
||||
/>
|
||||
|
||||
<!-- 下拉选择 -->
|
||||
<el-select
|
||||
v-else-if="field.type === 'select' && field.options"
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
class="w100"
|
||||
>
|
||||
<el-option v-for="opt in field.options" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
|
||||
<!-- 文件上传 -->
|
||||
<div v-else-if="isFileField(field)" class="field-upload-wrapper">
|
||||
<el-upload
|
||||
:key="`upload-${getFieldKey(node, field)}`"
|
||||
:auto-upload="false"
|
||||
:multiple="field.type === 'uploadMultiple'"
|
||||
:show-file-list="false"
|
||||
:accept="getFileAccept(field)"
|
||||
:on-change="(file: any) => handleFileUpload(node, field, file)"
|
||||
>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="uploadingFields[getFieldKey(node, field)]"
|
||||
:disabled="uploadingFields[getFieldKey(node, field)]"
|
||||
>
|
||||
{{ uploadingFields[getFieldKey(node, field)] ? '上传中...' : '选择文件' }}
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<div class="upload-rules">
|
||||
<span>{{ getFileRuleText(field) }}</span>
|
||||
<span v-if="getFieldFileList(node, field).length > 0" class="upload-count">
|
||||
已上传 {{ getFieldFileCountText(node, field) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="getFieldFileList(node, field).length > 0" class="uploaded-files-list">
|
||||
<div
|
||||
v-for="(f, fileIdx) in getFieldFileList(node, field)"
|
||||
:key="fileIdx"
|
||||
class="uploaded-file-item"
|
||||
>
|
||||
<span class="file-name">{{ f.name }}</span>
|
||||
<el-button type="danger" link size="small" @click="removeFieldFile(node, field, fileIdx)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 兜底文本输入 -->
|
||||
<el-input
|
||||
v-else
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</el-form>
|
||||
|
||||
<!-- 贴片模板编辑器 -->
|
||||
<div v-if="currentWorkflowHasPatchLayout" class="patch-template-section">
|
||||
<PatchTemplateEditor v-model="templates" />
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!hasFormFields && !currentWorkflowHasPatchLayout" description="该工作流无需填写参数" :image-size="60" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 默认占位 — 无工作流时显示引导 -->
|
||||
<div v-else :key="'placeholder'" class="content-body placeholder-body">
|
||||
<div v-else class="content-body placeholder-body">
|
||||
<div class="placeholder-content">
|
||||
<h2 class="placeholder-title">你好,我能帮你解决什么问题?</h2>
|
||||
<p class="placeholder-desc">选择一个工作流,填写参数后一键生成</p>
|
||||
<div class="placeholder-icon">
|
||||
<div class="icon-ring ring-outer">
|
||||
<div class="icon-ring ring-inner">
|
||||
<div class="icon-ring ring-core">
|
||||
<svg class="icon-sparkle" width="28" height="28" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2l1.09 6.26L18 4.5l-3.41 4.74L20 10l-5.41.76L18 15.5l-4.91-2.76L12 19l-1.09-6.26L6 15.5l3.41-4.74L4 10l5.41-.76L6 4.5l4.91 2.74L12 2z" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="placeWorkflows.length" class="workflow-cards">
|
||||
<el-tooltip
|
||||
v-for="wf in placeWorkflows"
|
||||
:key="wf.id"
|
||||
:content="wf.name"
|
||||
placement="top"
|
||||
:show-after="150"
|
||||
>
|
||||
<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"
|
||||
class="workflow-card wf-card-more"
|
||||
title="查看全部工作流"
|
||||
@click="goWorkflowManage"
|
||||
>
|
||||
<el-icon class="wf-card-icon"><More /></el-icon>
|
||||
<span class="wf-card-name">更多工作流</span>
|
||||
</button>
|
||||
<h2 class="placeholder-title">请选择下方工作流进行创作</h2>
|
||||
<p class="placeholder-desc">点击工作流胶囊快速切换,填写参数后一键生成内容</p>
|
||||
|
||||
<div class="placeholder-steps">
|
||||
<div class="step-item">
|
||||
<div class="step-num">1</div>
|
||||
<div class="step-text">
|
||||
<span class="step-label">选择工作流</span>
|
||||
<span class="step-hint">点击胶囊切换不同创作模式</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-arrow">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>
|
||||
</div>
|
||||
<div class="step-item">
|
||||
<div class="step-num">2</div>
|
||||
<div class="step-text">
|
||||
<span class="step-label">填写参数</span>
|
||||
<span class="step-hint">按需配置内容与样式选项</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-arrow">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>
|
||||
</div>
|
||||
<div class="step-item">
|
||||
<div class="step-num">3</div>
|
||||
<div class="step-text">
|
||||
<span class="step-label">一键生成</span>
|
||||
<span class="step-hint">AI 自动产出,结果同步到工作空间</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="placeholder-footer">
|
||||
<span class="footer-hint">还可从左侧「对话记录」查看历史或「工作空间」管理作品</span>
|
||||
</div>
|
||||
<div v-else class="placeholder-empty">暂无工作流,可在工作流管理中创建</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { getWorkflowList } from '/@/api/settings/creation';
|
||||
import { Promotion, More } from '@element-plus/icons-vue';
|
||||
import ChatList from './ChatList.vue';
|
||||
import type { VOSessionInfoResult } from '/@/api/settings/workflow/session';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import PatchTemplateEditor from '/@/components/patchTemplate/PatchTemplateEditor.vue';
|
||||
import { uploadFile } from '/@/api/common/upload';
|
||||
|
||||
interface Props {
|
||||
activeMenu: string;
|
||||
workflowDetail: any;
|
||||
activeHistoryId?: string | null;
|
||||
messages?: any[];
|
||||
// 会话内结果(session/get):仅用于判断「对话页 vs 占位页」,结果渲染统一走消息流
|
||||
results?: VOSessionInfoResult[];
|
||||
hasMore?: boolean;
|
||||
loadingMore?: boolean;
|
||||
// InputBar 发送按钮触发执行信号(透传给 ChatList,定位工作流卡片执行)
|
||||
executeRequest?: { msgId: string; action: 'submit'; nonce: number } | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'workflow-select', id: string, isTemplate?: boolean): void;
|
||||
(e: 'delete', msg: any): void;
|
||||
(e: 'form-submit', msg: any, payload: any): void;
|
||||
(e: 'output-preview', msg: any, output: any): void;
|
||||
(e: 'output-download', msg: any, output: any): void;
|
||||
(e: 'output-delete', msg: any, output: any): void;
|
||||
(e: 'load-more', sid: string | null | undefined): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
activeHistoryId: null,
|
||||
messages: () => [],
|
||||
results: () => [],
|
||||
hasMore: false,
|
||||
loadingMore: false,
|
||||
executeRequest: null,
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
const router = useRouter();
|
||||
const formValues = reactive<Record<string, any>>({});
|
||||
const fieldFiles = reactive<Record<string, { name: string; url: string }[]>>({});
|
||||
const uploadingFields = reactive<Record<string, boolean>>({});
|
||||
const templates = ref<any[]>([]);
|
||||
const getFieldKey = (node: any, field: any): string => {
|
||||
if (field?.__isHttpBodyChild) {
|
||||
return `${node.id}_body_${field.bodyKey}`;
|
||||
}
|
||||
return (node.id || node.nodeCode) + '_' + (field.field || field.label);
|
||||
};
|
||||
|
||||
const hasMessages = computed(() => Array.isArray(props.messages) && props.messages.length > 0);
|
||||
const hasResults = computed(() => Array.isArray(props.results) && props.results.length > 0);
|
||||
const getVisibleFields = (node: any): any[] => {
|
||||
const fields = Array.isArray(node?.formConfig) ? node.formConfig : [];
|
||||
const result: any[] = [];
|
||||
fields.forEach((field: any) => {
|
||||
if (!field) return;
|
||||
if (field.expand && typeof field.expand === 'object' && field.expand.editable === false) return;
|
||||
|
||||
if (String(node?.nodeCode || '').toLowerCase() === 'http') {
|
||||
if (field.field !== 'body') return;
|
||||
const bodyVal = field.value;
|
||||
if (!bodyVal || typeof bodyVal !== 'object' || Array.isArray(bodyVal)) return;
|
||||
Object.entries(bodyVal).forEach(([bodyKey, bodyItem]: [string, any]) => {
|
||||
if (!bodyItem || bodyItem.showInForm !== true) return;
|
||||
result.push({
|
||||
__isHttpBodyChild: true,
|
||||
bodyKey,
|
||||
field: `body.${bodyKey}`,
|
||||
label: bodyItem.key || bodyKey,
|
||||
required: false,
|
||||
type: bodyItem.fieldType || 'input',
|
||||
fieldType: bodyItem.fieldType || 'string',
|
||||
fieldConstraint: bodyItem.fieldConstraint || {},
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
result.push(field);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const isFileField = (field: any): boolean => {
|
||||
return field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload';
|
||||
};
|
||||
|
||||
const getFileAccept = (field: any): string => {
|
||||
return field.fieldConstraint?.fileTypes ? '.' + field.fieldConstraint.fileTypes.replace(/,/g, ',.') : '*';
|
||||
};
|
||||
|
||||
const getFileRuleText = (field: any): string => {
|
||||
const rules: string[] = [];
|
||||
if (field.fieldConstraint?.fileTypes) rules.push('格式: ' + field.fieldConstraint.fileTypes);
|
||||
if (field.fieldConstraint?.maxFileSize) rules.push('最大: ' + field.fieldConstraint.maxFileSize + 'MB');
|
||||
if (field.fieldConstraint?.maxFileCount) rules.push('最多: ' + field.fieldConstraint.maxFileCount + '个');
|
||||
return rules.join(' | ');
|
||||
};
|
||||
|
||||
const getFieldFileList = (node: any, field: any): { name: string; url: string }[] => {
|
||||
return fieldFiles[getFieldKey(node, field)] || [];
|
||||
};
|
||||
|
||||
const getFieldConstraint = (field: any): any => {
|
||||
return field?.fieldConstraint && typeof field.fieldConstraint === 'object' ? field.fieldConstraint : {};
|
||||
};
|
||||
|
||||
const getFieldFileCountText = (node: any, field: any): string => {
|
||||
const current = getFieldFileList(node, field).length;
|
||||
const max = Number(getFieldConstraint(field).maxFileCount);
|
||||
if (!Number.isNaN(max) && max > 0) {
|
||||
return `${current} / ${max}`;
|
||||
}
|
||||
return `${current}`;
|
||||
};
|
||||
|
||||
const handleFileUpload = async (node: any, field: any, file: any) => {
|
||||
const raw = file.raw;
|
||||
if (!raw) return;
|
||||
|
||||
const key = getFieldKey(node, field);
|
||||
const fc = getFieldConstraint(field);
|
||||
|
||||
const typeRules = String(fc.fileTypes || '')
|
||||
.split(',')
|
||||
.map((t: string) => t.trim().toLowerCase().replace(/^\./, ''))
|
||||
.filter(Boolean);
|
||||
if (typeRules.length > 0) {
|
||||
const ext = (raw.name?.split('.').pop() || '').toLowerCase();
|
||||
if (!typeRules.includes(ext)) {
|
||||
ElMessage.warning(`文件格式不符合要求,仅支持:${typeRules.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const maxFileSize = Number(fc.maxFileSize);
|
||||
if (!Number.isNaN(maxFileSize) && maxFileSize > 0) {
|
||||
const sizeMB = raw.size / 1024 / 1024;
|
||||
if (sizeMB > maxFileSize) {
|
||||
ElMessage.warning(`文件大小超限,最大 ${maxFileSize}MB`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const maxFileCount = Number(fc.maxFileCount);
|
||||
if (!Number.isNaN(maxFileCount) && maxFileCount > 0) {
|
||||
const currentCount = fieldFiles[key]?.length || 0;
|
||||
if (currentCount >= maxFileCount) {
|
||||
ElMessage.warning(`文件数量超限,最多 ${maxFileCount} 个`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
uploadingFields[key] = true;
|
||||
|
||||
// 占位页工作流卡片(DeepSeek 式引导:居中标题 + 工作流卡片)
|
||||
// 展示上限与 InputBar 胶囊保持一致:用户工作流优先,不足用模板补足,最多 9 个
|
||||
const MAX_PLACE_WORKFLOWS = 9;
|
||||
const placeWorkflows = ref<any[]>([]);
|
||||
const loadPlaceWorkflows = async () => {
|
||||
try {
|
||||
const res: any = await getWorkflowList();
|
||||
const userList = res.data?.listFlowUserRes?.list || [];
|
||||
const tplList = res.data?.listFlowTemplateRes?.list || [];
|
||||
placeWorkflows.value = [
|
||||
...userList.map((w: any) => ({ id: String(w.id), name: w.flowName || '未命名', isTemplate: false })),
|
||||
...tplList.map((w: any) => ({ id: String(w.id), name: w.flowTemplateName || '未命名', isTemplate: true })),
|
||||
].slice(0, MAX_PLACE_WORKFLOWS);
|
||||
} catch {
|
||||
placeWorkflows.value = [];
|
||||
const uploadRes = await uploadFile(raw, { timeout: 0 });
|
||||
if (!uploadRes?.data?.fileURL) throw new Error('上传失败:未返回文件URL');
|
||||
|
||||
const fileUrl = uploadRes.data.fileAddressPrefix
|
||||
? `${uploadRes.data.fileAddressPrefix}${uploadRes.data.fileURL}`
|
||||
: uploadRes.data.fileURL;
|
||||
|
||||
if (!fieldFiles[key]) fieldFiles[key] = [];
|
||||
fieldFiles[key].push({ name: raw.name, url: fileUrl });
|
||||
|
||||
if (field.type === 'upload') {
|
||||
formValues[key] = fileUrl;
|
||||
} else {
|
||||
formValues[key] = fieldFiles[key].map((f: any) => f.url);
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '文件上传失败');
|
||||
} finally {
|
||||
uploadingFields[key] = false;
|
||||
}
|
||||
};
|
||||
const handlePlaceSelect = (wf: any) => {
|
||||
emit('workflow-select', wf.id, wf.isTemplate);
|
||||
|
||||
const removeFieldFile = (node: any, field: any, fileIdx: number) => {
|
||||
const key = getFieldKey(node, field);
|
||||
if (!fieldFiles[key]) return;
|
||||
fieldFiles[key].splice(fileIdx, 1);
|
||||
if (field.type === 'upload') {
|
||||
formValues[key] = '';
|
||||
} else {
|
||||
formValues[key] = fieldFiles[key].map((f) => f.url);
|
||||
}
|
||||
};
|
||||
// 「更多」入口:工作流数量超出展示上限时跳转工作流管理页查看/选择全部
|
||||
const goWorkflowManage = () => {
|
||||
router.push('/settings/workflow');
|
||||
};
|
||||
onMounted(() => {
|
||||
loadPlaceWorkflows();
|
||||
|
||||
const currentWorkflowHasPatchLayout = computed(() => {
|
||||
const nodes = props.workflowDetail?.nodeInputParams || [];
|
||||
return nodes.some((node: any) => node.patchLayout === true);
|
||||
});
|
||||
|
||||
defineExpose({ loadPlaceWorkflows });
|
||||
const hasFormConfig = (node: any): boolean => {
|
||||
return node.nodeCode !== '__start__' && node.formConfig && node.formConfig.length > 0;
|
||||
};
|
||||
|
||||
const hasFormFields = computed(() => {
|
||||
if (!props.workflowDetail?.nodeInputParams) return false;
|
||||
return props.workflowDetail.nodeInputParams.some(
|
||||
(node: any) => node.nodeCode !== '__start__' && node.formConfig?.length > 0
|
||||
);
|
||||
});
|
||||
|
||||
const validateFormFields = (): boolean => {
|
||||
if (!props.workflowDetail?.nodeInputParams) return true;
|
||||
for (const node of props.workflowDetail.nodeInputParams as any[]) {
|
||||
if (node.nodeCode === '__start__') continue;
|
||||
const fields = getVisibleFields(node);
|
||||
for (const field of fields) {
|
||||
if (!field.required) continue;
|
||||
const key = getFieldKey(node, field);
|
||||
const value = formValues[key];
|
||||
if (value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0)) {
|
||||
ElMessage.warning(`请填写必填项:${field.label}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 初始化或重置表单
|
||||
watch(
|
||||
() => props.workflowDetail,
|
||||
(detail) => {
|
||||
Object.keys(formValues).forEach((key) => delete formValues[key]);
|
||||
Object.keys(fieldFiles).forEach((key) => delete fieldFiles[key]);
|
||||
Object.keys(uploadingFields).forEach((key) => delete uploadingFields[key]);
|
||||
// 尝试从执行详情恢复贴片模板
|
||||
const ext = (detail as any)?.extension;
|
||||
const restoredTemplates = ext?.templates || (detail as any)?.templates || detail?.flowContent?.templates || [];
|
||||
templates.value = Array.isArray(restoredTemplates) ? restoredTemplates : [];
|
||||
|
||||
if (!detail?.nodeInputParams) return;
|
||||
detail.nodeInputParams.forEach((node: any) => {
|
||||
if (!node.formConfig) return;
|
||||
node.formConfig.forEach((field: any) => {
|
||||
if (String(node.nodeCode || '').toLowerCase() === 'http' && field.field === 'body' && field.value && typeof field.value === 'object') {
|
||||
Object.entries(field.value).forEach(([bodyKey, bodyItem]: [string, any]) => {
|
||||
if (!bodyItem || bodyItem.showInForm !== true) return;
|
||||
const bodyFieldKey = `${node.id}_body_${bodyKey}`;
|
||||
if (bodyItem.fieldType === 'number') {
|
||||
formValues[bodyFieldKey] = (bodyItem.value !== undefined && bodyItem.value !== null && bodyItem.value !== '')
|
||||
? Number(bodyItem.value) : null;
|
||||
} else if (bodyItem.fieldType === 'fileUpload') {
|
||||
formValues[bodyFieldKey] = Array.isArray(bodyItem.value) ? bodyItem.value
|
||||
: bodyItem.value ? [bodyItem.value] : [];
|
||||
} else {
|
||||
formValues[bodyFieldKey] = bodyItem.value || '';
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const key = getFieldKey(node, field);
|
||||
const hasValue = field.value !== undefined && field.value !== null;
|
||||
if (field.type === 'number' || field.type === 'inputNumber') {
|
||||
formValues[key] = hasValue ? Number(field.value) : (field.default ?? null);
|
||||
} else if (field.type === 'switch') {
|
||||
formValues[key] = hasValue ? Boolean(field.value) : (field.default ?? false);
|
||||
} else if (field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload') {
|
||||
formValues[key] = hasValue ? field.value : (field.default ?? (field.type === 'upload' ? '' : []));
|
||||
} else {
|
||||
formValues[key] = hasValue ? field.value : (field.default ?? '');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
detail.nodeInputParams.forEach((node: any) => {
|
||||
if (!node.formConfig) return;
|
||||
node.formConfig.forEach((field: any) => {
|
||||
if (!isFileField(field)) return;
|
||||
const key = getFieldKey(node, field);
|
||||
const rawValue = formValues[key];
|
||||
const urls = Array.isArray(rawValue) ? rawValue : rawValue ? [rawValue] : [];
|
||||
if (urls.length === 0) return;
|
||||
fieldFiles[key] = urls.map((url: string) => ({
|
||||
name: String(url || '').split('/').pop() || 'file-' + Math.random().toString(36).slice(2, 8),
|
||||
url,
|
||||
}));
|
||||
});
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
defineExpose({ formValues, fieldFiles, templates, validateFormFields });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* ===== 页面切换过渡:淡入淡出 + 轻微上移 ===== */
|
||||
.content-fade-enter-active,
|
||||
.content-fade-leave-active {
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
.content-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
.content-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
<style scoped lang="scss">.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -166,119 +465,208 @@ defineExpose({ loadPlaceWorkflows });
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
/* ===== 对话页 ===== */
|
||||
.chat-body {
|
||||
width: min(880px, calc(100% - 40px));
|
||||
/* ===== 工作流表单 ===== */
|
||||
.workflow-form-body {
|
||||
width: min(820px, 100%);
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
padding: 16px 20px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden; /* 结果列表固定高度、消息流内部滚动,避免整体滚动与内部冲突 */
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
/* 消息流填充剩余高度并在内部滚动 */
|
||||
.chat-body :deep(.chat-list-scroll) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
.workflow-form-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.workflow-form-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
flex-shrink: 0;
|
||||
h3 { margin: 0; font-size: 14px; font-weight: 600; color: #0f172a; }
|
||||
.workflow-form-sub { font-size: 12px; color: #94a3b8; margin-top: 2px; }
|
||||
.el-button { flex-shrink: 0; }
|
||||
}
|
||||
|
||||
.workflow-form-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
&::-webkit-scrollbar { width: 4px; }
|
||||
&::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
|
||||
&::-webkit-scrollbar-track { background: transparent; }
|
||||
}
|
||||
|
||||
.form-node-group {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 16px;
|
||||
&:last-child { margin-bottom: 0; }
|
||||
}
|
||||
|
||||
.form-node-group-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 14px;
|
||||
&:last-child { margin-bottom: 0; }
|
||||
.el-form-item__label { font-size: 13px; font-weight: 500; color: #334155; padding-bottom: 4px; line-height: 1.5; }
|
||||
.el-form-item__label::before { color: #ef4444; margin-right: 4px; }
|
||||
.el-input-number { width: 100%; }
|
||||
.el-select { width: 100%; }
|
||||
.el-textarea__inner { font-size: 13px; }
|
||||
}
|
||||
|
||||
:deep(.el-divider) {
|
||||
border-top-color: #e2e8f0;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 12px;
|
||||
.el-divider__text { font-weight: 600; font-size: 12px; color: #64748b; background: transparent; padding-left: 0; }
|
||||
}
|
||||
|
||||
:deep(.upload-area) {
|
||||
width: 100%;
|
||||
.el-upload-dragger {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 100%; height: auto; padding: 20px;
|
||||
border: 2px dashed #e2e8f0; border-radius: 10px;
|
||||
background: #fff; cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
&:hover { border-color: #93c5fd; background: #f8faff; }
|
||||
}
|
||||
.upload-trigger { display: flex; align-items: center; gap: 12px; }
|
||||
.upload-icon { font-size: 28px; color: #93c5fd; }
|
||||
.upload-text { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: #475569; text-align: left; }
|
||||
.upload-rule { font-size: 11px; color: #94a3b8; }
|
||||
&.is-filled .el-upload-dragger { border-color: #bfdbfe; background: #eff6ff; }
|
||||
}
|
||||
|
||||
.uploaded-files-list { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
.uploaded-file-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 12px; background: #fff;
|
||||
border: 1px solid #e2e8f0; border-radius: 8px;
|
||||
transition: background 0.15s;
|
||||
&:hover { background: #f1f5f9; }
|
||||
.file-type-icon { font-size: 16px; color: #3b82f6; flex-shrink: 0; }
|
||||
.file-name { flex: 1; font-size: 12px; color: #475569; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.el-button { flex-shrink: 0; }
|
||||
}
|
||||
|
||||
.patch-template-section {
|
||||
margin-top: 16px;
|
||||
:deep(.patch-template-editor) {
|
||||
> .el-divider { margin-top: 4px; margin-bottom: 12px; border-top-color: #e2e8f0;
|
||||
.el-divider__text { font-weight: 600; font-size: 13px; color: #475569; background: transparent; padding-left: 0; }
|
||||
}
|
||||
.template-card { border: 1px solid #e2e8f0; border-radius: 10px; margin-bottom: 10px; }
|
||||
}
|
||||
}
|
||||
|
||||
.w100 { width: 100%; }
|
||||
.chat-container { width: min(1060px, 82%); margin: 0 auto; padding: 20px 0 130px; }
|
||||
|
||||
.placeholder-body { display: flex; align-items: center; justify-content: center; }
|
||||
.placeholder-content { text-align: center; padding: 24px 20px; width: 100%; max-width: 740px; }
|
||||
.placeholder-content { text-align: center; padding: 20px; max-width: 480px; }
|
||||
|
||||
/* ===== 图标:层叠圆环 + 星形 ===== */
|
||||
.placeholder-icon { margin-bottom: 28px; display: flex; justify-content: center; }
|
||||
.icon-ring {
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.ring-outer {
|
||||
width: 88px; height: 88px;
|
||||
background: radial-gradient(circle at 35% 30%, rgba(59, 130, 246, 0.10) 0%, rgba(139, 92, 246, 0.06) 100%);
|
||||
border: 1.5px solid rgba(59, 130, 246, 0.15);
|
||||
&:hover { transform: scale(1.05); }
|
||||
}
|
||||
.ring-inner {
|
||||
width: 62px; height: 62px;
|
||||
background: radial-gradient(circle at 35% 30%, rgba(59, 130, 246, 0.14) 0%, rgba(139, 92, 246, 0.08) 100%);
|
||||
border: 1.5px solid rgba(59, 130, 246, 0.20);
|
||||
}
|
||||
.ring-core {
|
||||
width: 42px; height: 42px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
|
||||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
.icon-sparkle { color: #fff; }
|
||||
|
||||
/* ===== 标题与描述 ===== */
|
||||
.placeholder-title { font-size: 26px; font-weight: 700; color: #0f172a; margin: 0 0 8px; letter-spacing: -0.2px; line-height: 1.4; }
|
||||
.placeholder-desc { font-size: 15px; color: #94a3b8; margin: 0 0 24px; line-height: 1.5; }
|
||||
.placeholder-title { font-size: 18px; font-weight: 700; color: #0f172a; margin: 0 0 6px; letter-spacing: -0.2px; line-height: 1.4; }
|
||||
.placeholder-desc { font-size: 14px; color: #94a3b8; margin: 0 0 32px; line-height: 1.5; }
|
||||
|
||||
/* ===== 工作流功能卡片(DeepSeek 式引导) ===== */
|
||||
.workflow-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
text-align: left;
|
||||
/* ===== 步骤引导 ===== */
|
||||
.placeholder-steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.workflow-card {
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid #e5e8ee;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
outline: none;
|
||||
&:hover {
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
text-align: left;
|
||||
}
|
||||
.wf-card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: #eaf1ff;
|
||||
color: #2563eb;
|
||||
font-size: 20px;
|
||||
display: inline-flex;
|
||||
.step-num {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #f0f0ff 100%);
|
||||
border: 1.5px solid #bfdbfe;
|
||||
color: #3b82f6;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wf-card-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.wf-card-tag {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: #fff7e6;
|
||||
color: #d97706;
|
||||
border: 1px solid #fde68a;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 「更多」入口:与普通工作流卡片区分,虚线边框 + 更轻的视觉重量,提示可进入管理页查看全部 */
|
||||
.wf-card-more {
|
||||
border-style: dashed;
|
||||
justify-content: center;
|
||||
|
||||
.wf-card-icon {
|
||||
background: #f8fafc;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.wf-card-name {
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
}
|
||||
&:hover {
|
||||
border-style: dashed;
|
||||
border-color: #93c5fd;
|
||||
.wf-card-icon {
|
||||
background: #eaf1ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
.wf-card-name {
|
||||
color: #2563eb;
|
||||
}
|
||||
transition: all 0.2s;
|
||||
.step-item:hover & {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder-empty {
|
||||
font-size: 13px;
|
||||
.step-text { display: flex; flex-direction: column; gap: 1px; }
|
||||
.step-label { font-size: 13px; font-weight: 600; color: #334155; line-height: 1.3; white-space: nowrap; }
|
||||
.step-hint { font-size: 11px; color: #94a3b8; line-height: 1.3; white-space: nowrap; }
|
||||
.step-arrow {
|
||||
color: #cbd5e1;
|
||||
padding: 16px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
/* ===== 底部辅助提示 ===== */
|
||||
.placeholder-footer { margin-top: 4px; }
|
||||
.footer-hint { font-size: 12px; color: #cbd5e1; line-height: 1.5; }
|
||||
</style>
|
||||
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div class="reply-list">
|
||||
<div v-for="reply in replyList" :key="reply.id" class="reply-card" @click="handleReplyClick(reply)">
|
||||
<div class="reply-header">
|
||||
<div class="reply-category">
|
||||
<el-tag :type="getCategoryType(reply.category)" size="small">{{ reply.category }}</el-tag>
|
||||
</div>
|
||||
<div class="reply-usage">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
<span>{{ reply.usageCount }} 次使用</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="reply-title">{{ reply.title }}</h3>
|
||||
<p class="reply-content">{{ reply.content }}</p>
|
||||
<div class="reply-footer">
|
||||
<div class="reply-tags">
|
||||
<el-tag v-for="tag in reply.tags" :key="tag" size="small" effect="plain">{{ tag }}</el-tag>
|
||||
</div>
|
||||
<div class="reply-actions">
|
||||
<el-button text size="small" @click.stop="handleCopy(reply)">
|
||||
<el-icon><CopyDocument /></el-icon>
|
||||
</el-button>
|
||||
<el-button text size="small" @click.stop="handleEdit(reply)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
</el-button>
|
||||
<el-button text size="small" type="danger" @click.stop="handleDelete(reply)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ChatDotRound, CopyDocument, Edit, Delete } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
interface Reply {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
usageCount: number;
|
||||
}
|
||||
|
||||
const replyList = ref<Reply[]>([
|
||||
{
|
||||
id: 1,
|
||||
title: '欢迎语',
|
||||
content: '您好!很高兴为您服务。请问有什么可以帮助您的吗?',
|
||||
category: '问候',
|
||||
tags: ['欢迎', '问候'],
|
||||
usageCount: 156,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '感谢回复',
|
||||
content: '非常感谢您的反馈!我们会继续努力为您提供更好的服务。',
|
||||
category: '感谢',
|
||||
tags: ['感谢', '反馈'],
|
||||
usageCount: 89,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '技术支持',
|
||||
content: '我已经收到您的问题,正在为您查询相关信息。请稍等片刻,我会尽快回复您。',
|
||||
category: '支持',
|
||||
tags: ['技术', '支持'],
|
||||
usageCount: 234,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '道歉回复',
|
||||
content: '非常抱歉给您带来不便。我们会立即处理这个问题,并尽快给您一个满意的答复。',
|
||||
category: '道歉',
|
||||
tags: ['道歉', '问题'],
|
||||
usageCount: 45,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: '结束语',
|
||||
content: '如果您还有其他问题,随时欢迎联系我们。祝您生活愉快!',
|
||||
category: '结束',
|
||||
tags: ['结束', '祝福'],
|
||||
usageCount: 178,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: '产品介绍',
|
||||
content: '我们的产品采用最新技术,为您提供高效、稳定、安全的服务体验。',
|
||||
category: '介绍',
|
||||
tags: ['产品', '介绍'],
|
||||
usageCount: 67,
|
||||
},
|
||||
]);
|
||||
|
||||
const getCategoryType = (category: string) => {
|
||||
const typeMap: Record<string, any> = {
|
||||
问候: 'success',
|
||||
感谢: 'primary',
|
||||
支持: 'warning',
|
||||
道歉: 'danger',
|
||||
结束: 'info',
|
||||
介绍: '',
|
||||
};
|
||||
return typeMap[category] || '';
|
||||
};
|
||||
|
||||
const handleReplyClick = (reply: Reply) => {
|
||||
ElMessage.success(`使用快捷回复: ${reply.title}`);
|
||||
};
|
||||
|
||||
const handleCopy = (reply: Reply) => {
|
||||
navigator.clipboard.writeText(reply.content);
|
||||
ElMessage.success('已复制到剪贴板');
|
||||
};
|
||||
|
||||
const handleEdit = (reply: Reply) => {
|
||||
ElMessage.info(`编辑回复: ${reply.title}`);
|
||||
};
|
||||
|
||||
const handleDelete = (reply: Reply) => {
|
||||
ElMessage.warning(`删除回复: ${reply.title}`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.reply-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.reply-card {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e5e7eb;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-2px);
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
.reply-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.reply-category {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reply-usage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.reply-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.reply-content {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 16px 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.reply-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.reply-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.reply-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,482 +0,0 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="切换会话模型"
|
||||
width="860px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<!-- 当前会话模型提示 -->
|
||||
<el-alert
|
||||
v-if="props.currentChatModelName"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="current-model-alert"
|
||||
>
|
||||
<template #title>当前会话模型:{{ props.currentChatModelName }}</template>
|
||||
</el-alert>
|
||||
|
||||
<div class="setter-header">
|
||||
<div class="search-bar">
|
||||
<el-input v-model="searchKeyword" placeholder="搜索模型名称" clearable @clear="handleSearch">
|
||||
<template #prefix
|
||||
><el-icon> <Search /> </el-icon
|
||||
></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="model-list" v-loading="loading">
|
||||
<el-empty v-if="!loading && pagedModels.length === 0" description="暂无推理模型" :image-size="100" />
|
||||
<div v-else class="model-grid">
|
||||
<div
|
||||
v-for="model in pagedModels"
|
||||
:key="model.id"
|
||||
class="model-card"
|
||||
:class="{ selected: selectedModel?.id === model.id, active: String(model.id) === String(props.currentChatModelId) }"
|
||||
@click="handleSelectModel(model)"
|
||||
>
|
||||
<div class="model-card-header">
|
||||
<div class="model-name-row">
|
||||
<span class="model-name">{{ model.modelName }}</span>
|
||||
<el-tag v-if="model.systemModel" size="small" type="warning">内置</el-tag>
|
||||
<el-tag v-else size="small" type="success">我的</el-tag>
|
||||
</div>
|
||||
<div class="model-icons">
|
||||
<el-icon v-if="String(model.id) === String(props.currentChatModelId)" class="current-icon" color="#2563eb">
|
||||
<CircleCheck />
|
||||
</el-icon>
|
||||
<el-icon v-if="selectedModel?.id === model.id" class="check-icon" color="#67c23a">
|
||||
<CircleCheck />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="model-card-body">
|
||||
<p class="model-url">{{ model.baseUrl }}</p>
|
||||
<div class="model-status">
|
||||
<el-tag :type="isModelEnabled(model) ? 'success' : 'info'" size="small">
|
||||
{{ isModelEnabled(model) ? '已启用' : '已禁用' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
:current-page="pagination.pageNum"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 30, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :disabled="!selectedModel" :loading="saving" @click="handleConfirm">设为会话模型</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 系统内置模型:填写 API Key,经修改接口转换为用户模型并设为会话模型 -->
|
||||
<el-dialog
|
||||
v-model="apiKeyDialogVisible"
|
||||
title="填写 API Key"
|
||||
width="480px"
|
||||
append-to-body
|
||||
:close-on-click-modal="false"
|
||||
@close="handleApiKeyClose"
|
||||
>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="api-key-alert"
|
||||
title="该模型为系统内置模型,填写你的 API Key 后将自动转换为你的用户模型,并设为会话模型。"
|
||||
/>
|
||||
<el-form label-position="top" class="api-key-form">
|
||||
<el-form-item label="API Key" required>
|
||||
<el-input
|
||||
v-model="apiKey"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入你的 API Key"
|
||||
@keyup.enter="handleApiKeyConfirm"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleApiKeyClose">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleApiKeyConfirm">确认并设置</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Search, CircleCheck } from '@element-plus/icons-vue';
|
||||
import { listModelManage, getModelManageTypes, updateModelManage, type ModelManageItem, type ModelTypeTreeNode, type UpdateModelManageParams } from '/@/api/settings/modelConfigV2';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
// 当前会话模型 id,用于高亮展示
|
||||
currentChatModelId?: string | number | null;
|
||||
// 当前会话模型名称,用于顶部提示
|
||||
currentChatModelName?: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'saved', model: { id: string; modelName: string }): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
currentChatModelId: null,
|
||||
currentChatModelName: '',
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = ref(false);
|
||||
const searchKeyword = ref('');
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const modelList = ref<ModelManageItem[]>([]);
|
||||
const reasoningTypes = ref<Set<number>>(new Set());
|
||||
const selectedModel = ref<ModelManageItem | null>(null);
|
||||
|
||||
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
|
||||
|
||||
// 系统内置模型:填 API Key → updateModelManage 转用户模型
|
||||
const apiKeyDialogVisible = ref(false);
|
||||
const pendingSystemModel = ref<ModelManageItem | null>(null);
|
||||
const apiKey = ref('');
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
visible.value = val;
|
||||
if (val) {
|
||||
selectedModel.value = null;
|
||||
searchKeyword.value = '';
|
||||
pagination.pageNum = 1;
|
||||
fetchData();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(visible, (val) => {
|
||||
if (!val) emit('update:modelValue', false);
|
||||
});
|
||||
|
||||
// 兼容 enabled 数字(1/0) 与布尔(true/false)
|
||||
const isModelEnabled = (model: ModelManageItem) => model.enabled === true || (model as any).enabled === 1;
|
||||
|
||||
// 将节点及其整棵子树的所有 value 加入集合
|
||||
const addSubtreeValues = (node: ModelTypeTreeNode, set: Set<number>) => {
|
||||
set.add(Number(node.value));
|
||||
for (const child of node.children || []) {
|
||||
addSubtreeValues(child, set);
|
||||
}
|
||||
};
|
||||
|
||||
// 递归遍历类型树,收集"推理"类节点的整棵子树 value 作为推理类型集合。
|
||||
// 例如 推理模型(100) 下的 101 对话/补全、102 思维链、103 函数调用 都属于推理类型,
|
||||
// 与 editModule 中"父路径标签含推理即算推理模型"的判断语义一致。
|
||||
const collectReasoningTypes = (nodes: ModelTypeTreeNode[], set: Set<number>) => {
|
||||
for (const n of nodes || []) {
|
||||
const label = String(n.label || '');
|
||||
if (label.includes('推理') || label.toLowerCase().includes('reasoning')) {
|
||||
addSubtreeValues(n, set);
|
||||
continue;
|
||||
}
|
||||
if (n.children) collectReasoningTypes(n.children, set);
|
||||
}
|
||||
};
|
||||
|
||||
// 拉取全部模型(分页合并),避免因分页截断导致前端过滤不完整
|
||||
const fetchAllModels = async () => {
|
||||
const all: ModelManageItem[] = [];
|
||||
const pageSize = 100;
|
||||
const first: any = await listModelManage({ pageNum: 1, pageSize });
|
||||
all.push(...(first?.data?.list || []));
|
||||
const total = first?.data?.total || 0;
|
||||
const pages = Math.ceil(total / pageSize);
|
||||
for (let p = 2; p <= pages; p++) {
|
||||
const r: any = await listModelManage({ pageNum: p, pageSize });
|
||||
all.push(...(r?.data?.list || []));
|
||||
}
|
||||
return all;
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// 类型树
|
||||
const typeRes: any = await getModelManageTypes();
|
||||
const set = new Set<number>();
|
||||
collectReasoningTypes(typeRes?.data?.list || [], set);
|
||||
reasoningTypes.value = set;
|
||||
// 全量模型
|
||||
const all = await fetchAllModels();
|
||||
modelList.value = all.filter((m) => {
|
||||
// 推理类型集合非空时按类型过滤;集合为空(接口异常)则退回显示全部启用模型
|
||||
if (reasoningTypes.value.size > 0 && !reasoningTypes.value.has(Number(m.modelType))) return false;
|
||||
return isModelEnabled(m);
|
||||
});
|
||||
} catch {
|
||||
modelList.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredModels = computed(() => {
|
||||
const kw = searchKeyword.value.trim();
|
||||
if (!kw) return modelList.value;
|
||||
return modelList.value.filter((m) => (m.modelName || '').includes(kw));
|
||||
});
|
||||
|
||||
// 本地分页切片
|
||||
const pagedModels = computed(() => {
|
||||
const start = (pagination.pageNum - 1) * pagination.pageSize;
|
||||
return filteredModels.value.slice(start, start + pagination.pageSize);
|
||||
});
|
||||
|
||||
watch(
|
||||
filteredModels,
|
||||
() => {
|
||||
pagination.total = filteredModels.value.length;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.pageNum = 1;
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.pageNum = page;
|
||||
};
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
pagination.pageSize = size;
|
||||
pagination.pageNum = 1;
|
||||
};
|
||||
|
||||
const handleSelectModel = (model: ModelManageItem) => {
|
||||
// 系统内置模型:先填 API Key,经修改接口转成用户模型并设为会话模型
|
||||
if (model.systemModel) {
|
||||
pendingSystemModel.value = model;
|
||||
apiKey.value = '';
|
||||
apiKeyDialogVisible.value = true;
|
||||
return;
|
||||
}
|
||||
selectedModel.value = model;
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!selectedModel.value) return;
|
||||
await doUpdate(selectedModel.value);
|
||||
};
|
||||
|
||||
const handleApiKeyConfirm = async () => {
|
||||
if (!apiKey.value.trim()) {
|
||||
ElMessage.warning('请输入 API Key');
|
||||
return;
|
||||
}
|
||||
if (!pendingSystemModel.value) return;
|
||||
await doUpdate(pendingSystemModel.value, apiKey.value.trim());
|
||||
};
|
||||
|
||||
// 系统模型转用户模型:需把模型完整配置传给后端,由后端复制为当前用户的模型副本
|
||||
const buildFullPayload = (model: ModelManageItem, apiKeyVal: string): UpdateModelManageParams => {
|
||||
const safeObj = (v: unknown): Record<string, unknown> | undefined =>
|
||||
v && typeof v === 'object' ? (v as Record<string, unknown>) : undefined;
|
||||
return {
|
||||
id: model.id,
|
||||
modelSupplier: Number(model.modelSupplier),
|
||||
modelName: model.modelName,
|
||||
modelType: Number(model.modelType),
|
||||
baseUrl: model.baseUrl,
|
||||
responseType: Number(model.responseType),
|
||||
apiKey: apiKeyVal,
|
||||
enabled: model.enabled,
|
||||
chatModel: true,
|
||||
maxConcurrency: model.maxConcurrency,
|
||||
maxTokens: model.maxTokens,
|
||||
tokenPredictPrice: model.tokenPredictPrice,
|
||||
requestHeadMapping: safeObj(model.requestHeadMapping),
|
||||
requestBodyMapping: safeObj(model.requestBodyMapping),
|
||||
requestBusinessFieldMapping: model.requestBusinessFieldMapping,
|
||||
responseMapping: safeObj(model.responseMapping),
|
||||
responseBodyMapping: (safeObj(model.responseBodyMapping) as Record<string, string>) || undefined,
|
||||
responseBusinessFieldMapping: model.responseBusinessFieldMapping,
|
||||
tokenMapping: model.tokenMapping,
|
||||
asyncTaskMapping: model.asyncTaskMapping,
|
||||
lastFrame: model.lastFrame || '',
|
||||
maxDuration: model.maxDuration,
|
||||
minDuration: model.minDuration,
|
||||
tokenPredictPriceUnit: model.tokenPredictPriceUnit || '',
|
||||
priceConfig: model.priceConfig,
|
||||
};
|
||||
};
|
||||
|
||||
const doUpdate = async (model: ModelManageItem, apiKeyVal?: string) => {
|
||||
saving.value = true;
|
||||
try {
|
||||
// 系统模型(填 API Key)转用户模型时传完整模型数据;用户自己的模型仅传 id + chatModel
|
||||
const payload: UpdateModelManageParams = apiKeyVal ? buildFullPayload(model, apiKeyVal) : { id: model.id, chatModel: true };
|
||||
const res: any = await updateModelManage(payload);
|
||||
// 系统模型转用户模型:后端返回新的 modelManage(含新 id)
|
||||
const newId = res?.data?.modelManage?.id || res?.data?.id || model.id;
|
||||
emit('saved', { id: String(newId), modelName: model.modelName });
|
||||
apiKeyDialogVisible.value = false;
|
||||
pendingSystemModel.value = null;
|
||||
selectedModel.value = null;
|
||||
visible.value = false;
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '设置会话模型失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiKeyClose = () => {
|
||||
apiKeyDialogVisible.value = false;
|
||||
pendingSystemModel.value = null;
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false;
|
||||
selectedModel.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.current-model-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.setter-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.model-list {
|
||||
min-height: 300px;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.model-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.model-card {
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: #67c23a;
|
||||
background: #f0f9ff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
}
|
||||
|
||||
.model-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.model-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.model-icons {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.current-icon,
|
||||
.check-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.model-card-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.model-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-url {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin: 0 0 8px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.api-key-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.api-key-form {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -6,7 +6,7 @@
|
||||
<el-icon class="brand-icon"><ChatDotRound /></el-icon>
|
||||
<span class="brand-name">AI 助手</span>
|
||||
</div>
|
||||
<el-tooltip content="新建会话" placement="right">
|
||||
<el-tooltip content="新建对话" placement="right">
|
||||
<button class="new-chat-icon-btn" @click="handleNewChat">
|
||||
<el-icon><EditPen /></el-icon>
|
||||
</button>
|
||||
@@ -15,11 +15,11 @@
|
||||
|
||||
<!-- Tab 切换 -->
|
||||
<div class="sidebar-tabs">
|
||||
<button class="tab-btn" :class="{ active: activeTab === 'history' }" @click="handleTabChange('history')">
|
||||
<button class="tab-btn" :class="{ active: activeTab === 'history' }" @click="activeTab = 'history'">
|
||||
<el-icon><ChatLineRound /></el-icon>
|
||||
会话记录
|
||||
对话记录
|
||||
</button>
|
||||
<button class="tab-btn" :class="{ active: activeTab === 'workspace' }" @click="handleTabChange('workspace')">
|
||||
<button class="tab-btn" :class="{ active: activeTab === 'workspace' }" @click="activeTab = 'workspace'">
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
工作空间
|
||||
</button>
|
||||
@@ -45,28 +45,14 @@
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 历史会话分页「加载更多」:还有更多会话时展示,点击加载下一页 -->
|
||||
<div v-if="historyHasMore" class="history-load-more">
|
||||
<button class="load-more-btn" :disabled="historyLoadingMore" @click="emit('load-more-history')">
|
||||
{{ historyLoadingMore ? '加载中...' : '加载更多' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div v-else-if="historyFailed" class="panel-error">
|
||||
<span class="panel-error-text">加载失败</span>
|
||||
<button class="panel-retry-btn" @click="emit('retry-history')">重试</button>
|
||||
</div>
|
||||
<div v-else class="panel-empty">暂无会话记录</div>
|
||||
<div v-else class="panel-empty">暂无对话记录</div>
|
||||
</div>
|
||||
|
||||
<!-- 工作空间面板 -->
|
||||
<div v-show="activeTab === 'workspace'" class="panel" v-loading="treeLoading">
|
||||
<div v-if="!treeLoading && treeNodes.length === 0 && workspaceFailed" class="panel-error">
|
||||
<span class="panel-error-text">加载失败</span>
|
||||
<button class="panel-retry-btn" @click="emit('retry-workspace')">重试</button>
|
||||
</div>
|
||||
<el-empty v-else-if="!treeLoading && treeNodes.length === 0" :image-size="48" description="暂无内容" />
|
||||
<el-empty v-if="!treeLoading && treeNodes.length === 0" :image-size="48" description="暂无内容" />
|
||||
<div v-else class="panel-list">
|
||||
<div v-for="dateNode in treeNodes" :key="dateNode.id" class="tree-date-group">
|
||||
<div class="tree-date-label">{{ dateNode.label }}</div>
|
||||
@@ -77,13 +63,7 @@
|
||||
<span class="file-name">{{ fileNode.label }}</span>
|
||||
<div v-if="fileNode.fileUrl" class="file-actions">
|
||||
<button class="file-action-btn" @click.stop="handlePreviewNode(fileNode)">预览</button>
|
||||
<button
|
||||
class="file-action-btn"
|
||||
:disabled="downloadingFileId === fileNode.id"
|
||||
@click.stop="handleDownloadNode(fileNode)"
|
||||
>
|
||||
{{ downloadingFileId === fileNode.id ? '下载中…' : '下载' }}
|
||||
</button>
|
||||
<button class="file-action-btn" @click.stop="handleDownloadNode(fileNode)">下载</button>
|
||||
<button class="file-action-btn file-action-btn--del" @click.stop="handleDeleteNode(fileNode)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,16 +98,6 @@ interface Props {
|
||||
activeMenu: string;
|
||||
activeHistoryId: string | null;
|
||||
historyList: HistoryItem[];
|
||||
// 会话记录 / 工作空间加载失败标记:失败时显示「加载失败 + 重试」错误态
|
||||
historyFailed?: boolean;
|
||||
// 历史会话分页:是否还有更多可加载、加载更多请求中
|
||||
historyHasMore?: boolean;
|
||||
historyLoadingMore?: boolean;
|
||||
workspaceFailed?: boolean;
|
||||
// 工作空间是否已成功加载:懒加载成功后才不再触发;失败保持 false → 切回 tab 自动重试
|
||||
workspaceLoaded?: boolean;
|
||||
// 工作空间正在下载的文件 id:对应「下载」按钮显示下载中并禁用(按钮级感知,不影响其他功能)
|
||||
downloadingFileId?: string | null;
|
||||
treeNodes: TreeNode[];
|
||||
treeLoading: boolean;
|
||||
}
|
||||
@@ -140,31 +110,12 @@ interface Emits {
|
||||
(e: 'preview-node', data: TreeNode): void;
|
||||
(e: 'download-node', data: TreeNode): void;
|
||||
(e: 'delete-node', data: TreeNode): void;
|
||||
(e: 'load-workspace'): void;
|
||||
(e: 'retry-history'): void;
|
||||
(e: 'load-more-history'): void;
|
||||
(e: 'retry-workspace'): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
historyFailed: false,
|
||||
historyHasMore: false,
|
||||
historyLoadingMore: false,
|
||||
workspaceFailed: false,
|
||||
workspaceLoaded: false,
|
||||
downloadingFileId: null,
|
||||
});
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const activeTab = ref<'history' | 'workspace'>('history');
|
||||
const handleTabChange = (tab: 'history' | 'workspace') => {
|
||||
activeTab.value = tab;
|
||||
// 懒加载:仅在「工作空间」tab 且尚未成功加载过时通知父组件拉取结果树,避免进入页面即请求;
|
||||
// 失败时父组件不置 workspaceLoaded,切回 tab 自动重试
|
||||
if (tab === 'workspace' && !props.workspaceLoaded) {
|
||||
emit('load-workspace');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const getFileIcon = (fileType?: string) => {
|
||||
@@ -188,8 +139,8 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
background: #f7f8fa;
|
||||
border-right: 1px solid #e5e8ee;
|
||||
background: #f8fafc;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
@@ -201,27 +152,20 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 14px 14px;
|
||||
padding: 16px 12px 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #2563eb, #4f8bff);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.25);
|
||||
font-size: 20px;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
@@ -250,17 +194,18 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #e8ebf0;
|
||||
background: #e2e8f0;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tab 切换(圆角胶囊) */
|
||||
/* Tab 切换 */
|
||||
.sidebar-tabs {
|
||||
display: flex;
|
||||
padding: 12px 12px 8px;
|
||||
padding: 8px 8px 0;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
@@ -268,24 +213,26 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
gap: 5px;
|
||||
height: 34px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
outline: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
|
||||
.el-icon {
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&:hover:not(.active) {
|
||||
background: #eef0f3;
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
@@ -293,7 +240,7 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
color: #2563eb;
|
||||
font-weight: 600;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.06);
|
||||
border-bottom-color: #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,7 +248,7 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
.panel {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px 12px;
|
||||
padding: 8px 8px 12px;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
@@ -321,47 +268,19 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
/* 加载失败错误态:可区分「失败」与「空数据」,并提供重试入口 */
|
||||
.panel-error {
|
||||
padding: 32px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.panel-error-text {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.panel-retry-btn {
|
||||
border: 1px solid #dbeafe;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
font-size: 12px;
|
||||
padding: 4px 14px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
}
|
||||
|
||||
/* 历史记录条目 */
|
||||
.history-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
margin-bottom: 2px;
|
||||
margin-bottom: 1px;
|
||||
|
||||
&:hover {
|
||||
background: #eef0f3;
|
||||
background: #f1f5f9;
|
||||
|
||||
.item-del-btn {
|
||||
opacity: 1;
|
||||
@@ -369,7 +288,7 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #e6f0ff;
|
||||
background: #eff6ff;
|
||||
|
||||
.item-icon {
|
||||
color: #2563eb;
|
||||
@@ -406,17 +325,17 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
.item-time {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.item-del-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
@@ -446,7 +365,7 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
padding: 8px 6px 4px;
|
||||
padding: 6px 4px 2px;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -455,13 +374,13 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 10px 7px 24px;
|
||||
border-radius: 8px;
|
||||
padding: 5px 8px 5px 24px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #eef0f3;
|
||||
background: #f1f5f9;
|
||||
|
||||
.file-actions {
|
||||
opacity: 1;
|
||||
@@ -493,6 +412,14 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
}
|
||||
|
||||
.file-action-btn {
|
||||
&--del {
|
||||
color: #ef4444;
|
||||
|
||||
&:hover {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #3b82f6;
|
||||
@@ -506,26 +433,7 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
&:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&--del {
|
||||
color: #ef4444;
|
||||
|
||||
&:hover {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
@@ -541,34 +449,7 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 历史会话分页「加载更多」按钮 */
|
||||
.history-load-more {
|
||||
padding: 10px 0 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
border: 1px solid #e5e8ee;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
padding: 5px 20px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
outline: none;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #93c5fd;
|
||||
color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
|
||||
@@ -1,500 +0,0 @@
|
||||
<template>
|
||||
<div class="template-complete-dialog">
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="完善系统工作流"
|
||||
width="760px"
|
||||
top="6vh"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div v-loading="loading" class="tc-body">
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="系统模板缺少运行所需配置,补全后将保存为你的个人工作流,之后可直接复用。"
|
||||
class="tc-alert"
|
||||
/>
|
||||
|
||||
<template v-if="!loading">
|
||||
<el-form label-position="top" class="tc-form">
|
||||
<el-form-item label="工作流名称">
|
||||
<el-input v-model="flowName" placeholder="请输入工作流名称" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-empty v-if="flowWorkflowGroups.length === 0" description="该模板无需补全模型参数" :image-size="60" />
|
||||
|
||||
<!-- 主模板 / 子工作流 分组:递归展开整棵工作流树,每层模型节点单独补全 -->
|
||||
<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 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>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="!canSave" @click="handleSave">保存并使用</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 内嵌模型选择器(自身 el-dialog 会 teleport 到 body,层级高于本弹窗) -->
|
||||
<ModelSelector
|
||||
v-model="modelSelectorVisible"
|
||||
:default-model="selectorDefaultModel"
|
||||
:same-type-model-type="selectorTargetRef?.node?.modelConfig?.modelType"
|
||||
@confirm="handleModelConfirm"
|
||||
/>
|
||||
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 } from '/@/api/settings/creation';
|
||||
import { updateWorkflowFromTemplate, getSubFlowWorkflowDetail } from '/@/api/settings/workflow';
|
||||
import { getModelManage, updateModelManage } from '/@/api/settings/modelConfigV2';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
template?: any; // { id, flowName, raw }
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(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; // 开始节点 id(buildModelRequestParamsPath 契约需要)
|
||||
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 flowTree = ref<FlowWorkflowNode | null>(null);
|
||||
const modelRefs = ref<ModelNodeRef[]>([]);
|
||||
// 防环:已展开过的工作流 id(子工作流可能互相引用 / 引用主模板)
|
||||
const visitedFlowIds = new Set<string>();
|
||||
|
||||
const modelSelectorVisible = ref(false);
|
||||
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 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 (modelRefs.value.some((m) => !m.node.modelConfig?.modelId)) return false;
|
||||
// 系统模型节点必须已填写 API Key(未补全不可保存)
|
||||
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) => {
|
||||
if (!val) return;
|
||||
loading.value = true;
|
||||
saving.value = false;
|
||||
flowName.value = '我的-' + (props.template?.flowName || '系统模板');
|
||||
flowTree.value = null;
|
||||
modelRefs.value = [];
|
||||
visitedFlowIds.clear();
|
||||
apiKeyInputs.value = {};
|
||||
systemModelNodeIds.value = {};
|
||||
try {
|
||||
await buildMainTree();
|
||||
if (flowTree.value) await expandSubFlows(flowTree.value);
|
||||
} finally {
|
||||
// 先判断各已绑定模型节点是否需要补 API Key(系统模型),完成后再结束 loading,避免竞态
|
||||
await checkModelApiKey();
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
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 m = selectorTargetRef.value;
|
||||
if (m) {
|
||||
const node = m.node;
|
||||
node.modelConfig = node.modelConfig || {};
|
||||
node.modelConfig.modelId = model.id || '';
|
||||
node.modelConfig.modelName = model.modelName;
|
||||
node.modelConfig.modelType = model.modelType;
|
||||
// 深拷贝模型 requestBodyMapping 作为参数模板,剔除只读字段(strip 会给被压缩的数组模板记 __originIndex)
|
||||
const params = stripReadonlyFields(deepClone(model.requestBodyMapping ?? null));
|
||||
node.modelConfig.modelRequestParams = params;
|
||||
// 后端契约:基于编辑器参数树(含 __originIndex 原始索引)生成全部叶子字段扁平清单,与工作流管理保存一致
|
||||
node.modelConfig.modelRequestParamsPath = buildModelRequestParamsPath(params, getWfStartNodeId(m.wfKey));
|
||||
// 用模型 responseMapping 的中文描述填充 value,供工作流管理「引用上级节点输出」展示中文
|
||||
node.modelConfig.modelResponseBodyMapping = enrichResponseBodyMapping(model.responseBodyMapping, model.responseMapping);
|
||||
// 选中为用户模型,无需再补 API Key
|
||||
systemModelNodeIds.value[m.nodeKey] = false;
|
||||
apiKeyInputs.value[m.nodeKey] = '';
|
||||
}
|
||||
modelSelectorVisible.value = false;
|
||||
selectorTargetRef.value = null;
|
||||
};
|
||||
|
||||
// 打开弹窗后:判断每个已绑定模型节点是否为系统模型(需补 API Key)
|
||||
const checkModelApiKey = async () => {
|
||||
const targets = modelRefs.value.filter((m) => m.node.modelConfig?.modelId);
|
||||
await Promise.all(
|
||||
targets.map(async (m) => {
|
||||
try {
|
||||
const res = await getModelManage(m.node.modelConfig.modelId);
|
||||
const model = res?.data?.modelManage || res?.data;
|
||||
systemModelNodeIds.value[m.nodeKey] = model?.systemModel === true;
|
||||
if (!systemModelNodeIds.value[m.nodeKey]) apiKeyInputs.value[m.nodeKey] = '';
|
||||
} catch {
|
||||
// 详情获取失败:不强制补 Key,避免阻塞保存
|
||||
systemModelNodeIds.value[m.nodeKey] = false;
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// 将系统模型克隆为用户模型并绑定到节点(保存时统一调用)
|
||||
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('模型详情获取失败');
|
||||
const upRes = await updateModelManage({
|
||||
id: src.id,
|
||||
// 传递系统模型全部配置,供后端克隆用户模型时继承
|
||||
modelName: src.modelName,
|
||||
modelType: src.modelType,
|
||||
modelSupplier: src.modelSupplier,
|
||||
baseUrl: src.baseUrl,
|
||||
responseType: src.responseType ?? src.invokeType,
|
||||
apiKey: (apiKeyInputs.value[m.nodeKey] || '').trim(),
|
||||
enabled: src.enabled,
|
||||
chatModel: src.ChatModel ?? src.chatModel,
|
||||
maxConcurrency: src.maxConcurrency,
|
||||
maxTokens: src.maxTokens,
|
||||
tokenPredictPrice: src.tokenPredictPrice,
|
||||
requestHeadMapping: src.requestHeadMapping ?? src.requestMapping,
|
||||
requestBodyMapping: src.requestBodyMapping,
|
||||
requestBusinessFieldMapping: src.requestBusinessFieldMapping,
|
||||
responseMapping: src.responseMapping,
|
||||
responseBodyMapping: src.responseBodyMapping,
|
||||
responseBusinessFieldMapping: src.responseBusinessFieldMapping,
|
||||
minDuration: src.minDuration,
|
||||
priceConfig: src.priceConfig,
|
||||
...(src.tokenMapping ? { tokenMapping: src.tokenMapping } : {}),
|
||||
...(src.asyncTaskMapping ? { asyncTaskMapping: src.asyncTaskMapping } : {}),
|
||||
...(src.lastFrame ? { lastFrame: src.lastFrame } : {}),
|
||||
...(src.maxDuration ? { maxDuration: src.maxDuration } : {}),
|
||||
...(src.tokenPredictPriceUnit ? { tokenPredictPriceUnit: src.tokenPredictPriceUnit } : {}),
|
||||
} as any);
|
||||
const newModelManage = upRes?.data?.modelManage || upRes?.data;
|
||||
if (!newModelManage?.id) throw new Error('接口未返回新的用户模型');
|
||||
// 更新节点绑定为新用户模型,不再需要补 Key
|
||||
node.modelConfig.modelId = newModelManage.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 = modelRefs.value.find((m) => !m.node.modelConfig?.modelId);
|
||||
if (missing) {
|
||||
ElMessage.warning(`节点「${missing.node.name || missing.node.nodeCode}」未选择模型,请先选择`);
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
// 保存前统一转换:整棵树的系统模型节点先克隆为用户模型并绑定
|
||||
for (const m of modelRefs.value.filter((mm) => systemModelNodeIds.value[mm.nodeKey])) {
|
||||
await convertSystemModel(m);
|
||||
}
|
||||
// 旧模板缺 modelRequestParamsPath 的兜底生成(主模板 + 嵌套子工作流)
|
||||
if (flowTree.value) patchRequestParamsPath(flowTree.value);
|
||||
const flowContent = {
|
||||
...(flowTree.value?.flowContent || {}),
|
||||
nodes: flowTree.value?.nodes || [],
|
||||
};
|
||||
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);
|
||||
if (!newId) throw new Error('保存失败:未返回工作流ID');
|
||||
ElMessage.success('已保存为用户工作流');
|
||||
emit('saved', String(newId), name);
|
||||
emit('update:modelValue', false);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '保存失败,请重试');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('update:modelValue', false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tc-body {
|
||||
min-height: 120px;
|
||||
max-height: 62vh;
|
||||
overflow-y: auto;
|
||||
padding: 2px 4px 4px 0;
|
||||
}
|
||||
|
||||
.tc-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tc-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 主模板 / 子工作流 分组:子工作流组带标题条,与主模板节点区分 */
|
||||
.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;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
background: #fbfcfe;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tc-node-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.tc-node-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.tc-node-model {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tc-api-key-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.el-input {
|
||||
max-width: 340px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,850 +0,0 @@
|
||||
<template>
|
||||
<div class="workflow-form-card" :class="{ readonly, submitting, failed: isFailed }">
|
||||
<div class="workflow-form-header" @click="toggleCollapse">
|
||||
<div class="header-left">
|
||||
<h3>{{ detail?.flowName || '工作流表单' }}</h3>
|
||||
<div class="workflow-form-sub">{{ detail?.description || '请填写以下参数' }}</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-tag v-if="submitting" size="small" type="primary">执行中</el-tag>
|
||||
<el-tag v-else-if="isFailed" size="small" type="danger">执行失败</el-tag>
|
||||
<el-tag v-else-if="formStatus === 'done'" size="small" type="success">执行完成</el-tag>
|
||||
<!-- 折叠切换:明确按钮提示用户可展开/收起已填参数(点击标题行同样可切换) -->
|
||||
<button type="button" class="collapse-toggle" @click.stop="toggleCollapse">
|
||||
<el-icon class="collapse-arrow" :class="{ collapsed }"><ArrowDown /></el-icon>
|
||||
<span>{{ collapsed ? '展开' : '收起' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 节点执行过程区(仿豆包步骤流):有节点事件才显示,始终可见、不受参数收起影响 -->
|
||||
<div v-if="progress?.nodes?.length" class="node-progress-list">
|
||||
<div v-for="node in progress.nodes" :key="node.nodeId" class="node-step" :class="'is-' + node.status">
|
||||
<span class="node-step-icon">
|
||||
<span v-if="node.status === 'running'" class="node-spinner" />
|
||||
<el-icon v-else-if="node.status === 'done'" class="node-icon-done"><CircleCheckFilled /></el-icon>
|
||||
<el-icon v-else-if="node.status === 'failed'" class="node-icon-failed"><CircleCloseFilled /></el-icon>
|
||||
<span v-else class="node-dot" />
|
||||
</span>
|
||||
<span class="node-step-name">{{ node.nodeName }}</span>
|
||||
<span class="node-step-status">{{ nodeStatusText(node) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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)">
|
||||
<el-form-item
|
||||
v-for="field in getVisibleFields(node)"
|
||||
:key="getFieldKey(node, field)"
|
||||
:label="field.label"
|
||||
:required="field.required"
|
||||
>
|
||||
<!-- 引用其他节点输出:只读展示,值由上游节点运行时提供 -->
|
||||
<div v-if="field.valueSource" class="field-source-readonly">
|
||||
<el-tag type="info" size="small" class="source-tag">引用</el-tag>
|
||||
<span class="source-text">由「{{ getSourceDisplay(field) }}」自动提供</span>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 文本输入 -->
|
||||
<el-input
|
||||
v-if="field.type === 'input' || field.type === 'string'"
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
:disabled="isDisabled"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<!-- 数字输入 -->
|
||||
<el-input-number
|
||||
v-else-if="field.type === 'number' || field.type === 'inputNumber'"
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
class="w100"
|
||||
:controls="true"
|
||||
:disabled="isDisabled"
|
||||
:min="field.fieldConstraint?.minValue ?? undefined"
|
||||
:max="field.fieldConstraint?.maxValue ?? undefined"
|
||||
/>
|
||||
|
||||
<!-- 多行文本 -->
|
||||
<el-input
|
||||
v-else-if="field.type === 'textarea'"
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:disabled="isDisabled"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
show-word-limit
|
||||
:maxlength="500"
|
||||
/>
|
||||
|
||||
<!-- 开关 -->
|
||||
<el-switch
|
||||
v-else-if="field.type === 'switch'"
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
:disabled="isDisabled"
|
||||
active-text="开启"
|
||||
inactive-text="关闭"
|
||||
/>
|
||||
|
||||
<!-- 下拉选择 -->
|
||||
<el-select
|
||||
v-else-if="field.type === 'select' && field.options"
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
:disabled="isDisabled"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
class="w100"
|
||||
>
|
||||
<el-option v-for="opt in field.options" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
|
||||
<!-- 文件上传 -->
|
||||
<div v-else-if="isFileField(field)" class="field-upload-wrapper">
|
||||
<el-upload
|
||||
:key="`upload-${getFieldKey(node, field)}`"
|
||||
:auto-upload="false"
|
||||
:multiple="field.type === 'uploadMultiple'"
|
||||
:show-file-list="false"
|
||||
:accept="getFileAccept(field)"
|
||||
:disabled="isDisabled"
|
||||
:on-change="(file: any) => handleFileUpload(node, field, file)"
|
||||
>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="uploadingFields[getFieldKey(node, field)]"
|
||||
:disabled="isDisabled || uploadingFields[getFieldKey(node, field)]"
|
||||
>
|
||||
{{ uploadingFields[getFieldKey(node, field)] ? '上传中...' : '选择文件' }}
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<div class="upload-rules">
|
||||
<span>{{ getFileRuleText(field) }}</span>
|
||||
<span v-if="getFieldFileList(node, field).length > 0" class="upload-count">
|
||||
已上传 {{ getFieldFileCountText(node, field) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="getFieldFileList(node, field).length > 0" class="uploaded-files-list">
|
||||
<div
|
||||
v-for="(f, fileIdx) in getFieldFileList(node, field)"
|
||||
:key="fileIdx"
|
||||
class="uploaded-file-item"
|
||||
>
|
||||
<span class="file-name">{{ f.name }}</span>
|
||||
<el-button v-if="!isDisabled" type="danger" link size="small" @click="removeFieldFile(node, field, fileIdx)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 兜底文本输入 -->
|
||||
<el-input
|
||||
v-else
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
:disabled="isDisabled"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
/>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</el-form>
|
||||
|
||||
<!-- 贴片模板编辑器 -->
|
||||
<div v-if="currentWorkflowHasPatchLayout" class="patch-template-section">
|
||||
<PatchTemplateEditor v-model="templates" />
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!hasFormFields && !currentWorkflowHasPatchLayout" description="该工作流无需填写参数" :image-size="60" />
|
||||
</div>
|
||||
|
||||
<!-- 底部操作区: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>
|
||||
<!-- 编辑态:无执行按钮,提示通过下方发送按钮执行 -->
|
||||
<span v-else class="form-edit-tip">填写参数后,点击下方发送按钮执行工作流</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
// 工作流详情(含 nodeInputParams / flowContent)
|
||||
detail: any;
|
||||
// 定格只读(done/failed 态):禁用表单与提交
|
||||
readonly?: boolean;
|
||||
// 执行中:禁用表单,底部显示加载中
|
||||
submitting?: boolean;
|
||||
// 执行失败信息(有值即 failed 态)
|
||||
formError?: string;
|
||||
// 执行节点进度(node_start/node_complete 事件驱动的步骤列表,渲染执行过程区)
|
||||
progress?: { nodes: WorkflowNodeStep[] };
|
||||
// InputBar 发送按钮触发执行信号:仅当本卡片为目标时非 null(统一走提交,含必填校验)
|
||||
executeSignal?: { action: 'submit'; nonce: number } | null;
|
||||
// 已执行卡片(done/failed)参数可编辑(会话中最后一张执行过的工作流):改参后经发送按钮重新执行
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', payload: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates: any[]; chargeMode: string }): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
readonly: false,
|
||||
submitting: false,
|
||||
formError: '',
|
||||
progress: () => ({ nodes: [] }),
|
||||
executeSignal: null,
|
||||
editable: false,
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const formValues = reactive<Record<string, any>>({});
|
||||
const fieldFiles = reactive<Record<string, { name: string; url: string }[]>>({});
|
||||
// 上传字段的文件名(与 formValues 平行;单文件为字符串、多文件为数组),执行时随 value 一并写回开始节点传给后端
|
||||
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);
|
||||
|
||||
// 表单收起/展开:editing(待提交/回显)默认展开;提交(running)自动收起;
|
||||
// done/failed 保持提交时状态(默认收起,用户可手动展开查看已填值)
|
||||
const collapsed = ref(false);
|
||||
const formStatus = computed(() => {
|
||||
if (props.submitting) return 'running';
|
||||
if (props.formError) return 'failed';
|
||||
if (props.readonly) return 'done';
|
||||
return 'editing';
|
||||
});
|
||||
watch(
|
||||
formStatus,
|
||||
(st, prev) => {
|
||||
if (st === 'editing') {
|
||||
collapsed.value = false; // 回显/重新编辑:默认展开
|
||||
} else if (prev === undefined) {
|
||||
// 初始即非编辑态(执行中/完成/失败):一律收起(执行后默认收起,不因可编辑展开),
|
||||
// 用户可手动展开查看已填值/改参
|
||||
collapsed.value = true;
|
||||
} else if (st === 'running') {
|
||||
collapsed.value = true; // 提交瞬间自动收起
|
||||
}
|
||||
// done/failed 到达时保持当前(提交时已收起,用户手动展开则保留)
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
const toggleCollapse = () => {
|
||||
collapsed.value = !collapsed.value;
|
||||
};
|
||||
|
||||
// 执行进度文本:有进行中节点显示「正在执行:节点名」,否则兜底「执行中...」
|
||||
const progressText = computed(() => {
|
||||
const running = (props.progress?.nodes || []).find((n) => n.status === 'running');
|
||||
return running ? `正在执行:${running.nodeName}` : '执行中...';
|
||||
});
|
||||
|
||||
// 节点步骤状态文案(执行过程区)
|
||||
const nodeStatusText = (node: WorkflowNodeStep): string => {
|
||||
switch (node.status) {
|
||||
case 'running':
|
||||
return '执行中';
|
||||
case 'done':
|
||||
return '完成';
|
||||
case 'failed':
|
||||
return '失败';
|
||||
default:
|
||||
return '待执行';
|
||||
}
|
||||
};
|
||||
|
||||
const getFieldKey = (node: any, field: any): string => {
|
||||
const id = node.id || node.nodeCode;
|
||||
return `${id}|${field.path || field.field || field.label}`;
|
||||
};
|
||||
|
||||
const getVisibleFields = (node: any): any[] => {
|
||||
return collectHomeFormFields(node);
|
||||
};
|
||||
|
||||
// 引用来源展示:valueSource 统一数组 [{ nodeId, field }](兼容旧 DSL 单对象),逐项定位上游节点名 + 字段
|
||||
const getSourceDisplay = (field: any): string => {
|
||||
const vs = field?.valueSource;
|
||||
if (!vs || typeof vs !== 'object') return '';
|
||||
const arr = Array.isArray(vs) ? vs : [vs];
|
||||
const nodes = props.detail?.nodeInputParams || [];
|
||||
const parts = arr
|
||||
.filter((r: any) => r && typeof r === 'object')
|
||||
.map((r: any) => {
|
||||
const srcNode = nodes.find((n: any) => String(n?.id) === String(r.nodeId));
|
||||
const nodeName = srcNode?.name || srcNode?.nodeName || (r.nodeId ? `节点 ${r.nodeId}` : '上游节点');
|
||||
return r.field ? `${nodeName} · ${r.field}` : nodeName;
|
||||
});
|
||||
return parts.join(',');
|
||||
};
|
||||
|
||||
const isFileField = (field: any): boolean => {
|
||||
return field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload';
|
||||
};
|
||||
|
||||
const getFileAccept = (field: any): string => {
|
||||
return field.fieldConstraint?.fileTypes ? '.' + field.fieldConstraint.fileTypes.replace(/,/g, ',.') : '*';
|
||||
};
|
||||
|
||||
const getFileRuleText = (field: any): string => {
|
||||
const rules: string[] = [];
|
||||
if (field.fieldConstraint?.fileTypes) rules.push('格式: ' + field.fieldConstraint.fileTypes);
|
||||
if (field.fieldConstraint?.maxFileSize) rules.push('最大: ' + field.fieldConstraint.maxFileSize + 'MB');
|
||||
if (field.fieldConstraint?.maxFileCount) rules.push('最多: ' + field.fieldConstraint.maxFileCount + '个');
|
||||
return rules.join(' | ');
|
||||
};
|
||||
|
||||
const getFieldFileList = (node: any, field: any): { name: string; url: string }[] => {
|
||||
return fieldFiles[getFieldKey(node, field)] || [];
|
||||
};
|
||||
|
||||
const getFieldConstraint = (field: any): any => {
|
||||
return field?.fieldConstraint && typeof field.fieldConstraint === 'object' ? field.fieldConstraint : {};
|
||||
};
|
||||
|
||||
const getFieldFileCountText = (node: any, field: any): string => {
|
||||
const current = getFieldFileList(node, field).length;
|
||||
const max = Number(getFieldConstraint(field).maxFileCount);
|
||||
if (!Number.isNaN(max) && max > 0) {
|
||||
return `${current} / ${max}`;
|
||||
}
|
||||
return `${current}`;
|
||||
};
|
||||
|
||||
const handleFileUpload = async (node: any, field: any, file: any) => {
|
||||
const raw = file.raw;
|
||||
if (!raw) return;
|
||||
|
||||
const key = getFieldKey(node, field);
|
||||
const fc = getFieldConstraint(field);
|
||||
|
||||
const typeRules = String(fc.fileTypes || '')
|
||||
.split(',')
|
||||
.map((t: string) => t.trim().toLowerCase().replace(/^\./, ''))
|
||||
.filter(Boolean);
|
||||
if (typeRules.length > 0) {
|
||||
const ext = (raw.name?.split('.').pop() || '').toLowerCase();
|
||||
if (!typeRules.includes(ext)) {
|
||||
ElMessage.warning(`文件格式不符合要求,仅支持:${typeRules.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const maxFileSize = Number(fc.maxFileSize);
|
||||
if (!Number.isNaN(maxFileSize) && maxFileSize > 0) {
|
||||
const sizeMB = raw.size / 1024 / 1024;
|
||||
if (sizeMB > maxFileSize) {
|
||||
ElMessage.warning(`文件大小超限,最大 ${maxFileSize}MB`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const maxFileCount = Number(fc.maxFileCount);
|
||||
if (!Number.isNaN(maxFileCount) && maxFileCount > 0) {
|
||||
const currentCount = fieldFiles[key]?.length || 0;
|
||||
if (currentCount >= maxFileCount) {
|
||||
ElMessage.warning(`文件数量超限,最多 ${maxFileCount} 个`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
uploadingFields[key] = true;
|
||||
|
||||
try {
|
||||
const uploadRes = await uploadFile(raw, { timeout: 0 });
|
||||
if (!uploadRes?.data?.fileURL) throw new Error('上传失败:未返回文件URL');
|
||||
|
||||
const fileUrl = uploadRes.data.fileAddressPrefix
|
||||
? `${uploadRes.data.fileAddressPrefix}${uploadRes.data.fileURL}`
|
||||
: uploadRes.data.fileURL;
|
||||
|
||||
if (!fieldFiles[key]) fieldFiles[key] = [];
|
||||
// 文件名取服务器返回 fileName(OSS 存储名,已确认),随 value 一并写回开始节点传给后端
|
||||
fieldFiles[key].push({ name: uploadRes.data.fileName || raw.name, url: fileUrl });
|
||||
|
||||
if (field.type === 'upload') {
|
||||
formValues[key] = fileUrl;
|
||||
formFileNames[key] = fieldFiles[key][0]?.name;
|
||||
} else {
|
||||
formValues[key] = fieldFiles[key].map((f: any) => f.url);
|
||||
formFileNames[key] = fieldFiles[key].map((f: any) => f.name);
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '文件上传失败');
|
||||
} finally {
|
||||
uploadingFields[key] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const removeFieldFile = (node: any, field: any, fileIdx: number) => {
|
||||
const key = getFieldKey(node, field);
|
||||
if (!fieldFiles[key]) return;
|
||||
fieldFiles[key].splice(fileIdx, 1);
|
||||
if (field.type === 'upload') {
|
||||
formValues[key] = '';
|
||||
formFileNames[key] = '';
|
||||
} else {
|
||||
formValues[key] = fieldFiles[key].map((f) => f.url);
|
||||
formFileNames[key] = fieldFiles[key].map((f) => f.name);
|
||||
}
|
||||
};
|
||||
|
||||
const currentWorkflowHasPatchLayout = computed(() => {
|
||||
const nodes = props.detail?.nodeInputParams || [];
|
||||
return nodes.some((node: any) => node.patchLayout === true);
|
||||
});
|
||||
|
||||
const hasFormConfig = (node: any): boolean => {
|
||||
// 开始节点为唯一表单源(runFormFields 汇总各 model 勾选字段 + form 自定义字段)
|
||||
return String(node?.nodeCode || '').toLowerCase() === '__start__' && collectHomeFormFields(node).length > 0;
|
||||
};
|
||||
|
||||
const hasFormFields = computed(() => {
|
||||
if (!props.detail?.nodeInputParams) return false;
|
||||
return props.detail.nodeInputParams.some((node: any) => hasFormConfig(node));
|
||||
});
|
||||
|
||||
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;
|
||||
const fields = getVisibleFields(node);
|
||||
for (const field of fields) {
|
||||
if (!field.required) continue;
|
||||
// 引用其他节点输出的字段:值由上游节点运行时提供,非用户填写项,跳过必填校验
|
||||
if (field.valueSource) continue;
|
||||
const key = getFieldKey(node, field);
|
||||
const value = formValues[key];
|
||||
if (value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0)) {
|
||||
ElMessage.warning(`请填写必填项:${field.label}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 初始化或重置表单(与旧整页表单一致:含默认值 + 文件回填 + 贴片模板恢复)
|
||||
watch(
|
||||
() => props.detail,
|
||||
(detail) => {
|
||||
Object.keys(formValues).forEach((key) => delete formValues[key]);
|
||||
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 顶层)
|
||||
const patchNodes = Array.isArray(detail?.nodeInputParams)
|
||||
? (detail.nodeInputParams as any[]).filter((n: any) => n.patchLayout === true && Array.isArray(n.templates))
|
||||
: [];
|
||||
const nodeTemplates = patchNodes[0]?.templates;
|
||||
const ext = (detail as any)?.extension;
|
||||
const restoredTemplates =
|
||||
nodeTemplates || ext?.templates || (detail as any)?.templates || detail?.flowContent?.templates || [];
|
||||
templates.value = Array.isArray(restoredTemplates) ? restoredTemplates : [];
|
||||
|
||||
if (!detail?.nodeInputParams) return;
|
||||
const nodes = detail.nodeInputParams as any[];
|
||||
|
||||
// 1) 初始化表单值(model → modelRequestParams runtimeShow;form → outputConfig;旧数据 → formConfig)
|
||||
nodes.forEach((node) => {
|
||||
collectHomeFormFields(node).forEach((field) => {
|
||||
const key = getFieldKey(node, field);
|
||||
const hasValue = field.value !== undefined && field.value !== null && field.value !== '';
|
||||
if (field.type === 'number' || field.type === 'inputNumber') {
|
||||
formValues[key] = hasValue ? Number(field.value) : (field.default ?? null);
|
||||
} else if (field.type === 'switch') {
|
||||
formValues[key] = hasValue ? Boolean(field.value) : (field.default ?? false);
|
||||
} else if (field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload') {
|
||||
if (field.type === 'fileUpload') {
|
||||
formValues[key] = hasValue
|
||||
? Array.isArray(field.value)
|
||||
? field.value
|
||||
: field.value
|
||||
? [field.value]
|
||||
: []
|
||||
: Array.isArray(field.default)
|
||||
? field.default
|
||||
: field.default
|
||||
? [field.default]
|
||||
: [];
|
||||
} else {
|
||||
formValues[key] = hasValue ? field.value : (field.default ?? (field.type === 'upload' ? '' : []));
|
||||
}
|
||||
} else {
|
||||
formValues[key] = hasValue ? field.value : (field.default ?? '');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 2) 文件字段回填已上传文件列表
|
||||
nodes.forEach((node) => {
|
||||
collectHomeFormFields(node).forEach((field) => {
|
||||
if (!isFileField(field)) return;
|
||||
const key = getFieldKey(node, field);
|
||||
const rawValue = formValues[key];
|
||||
const urls = Array.isArray(rawValue) ? rawValue : rawValue ? [rawValue] : [];
|
||||
if (urls.length === 0) return;
|
||||
// 文件名优先取运行字段携带的服务器名(新数据),旧数据从 url 提取兜底
|
||||
const rawFn = (field as any).fileName;
|
||||
fieldFiles[key] = urls.map((url: string, i: number) => ({
|
||||
name:
|
||||
(Array.isArray(rawFn) ? rawFn[i] : i === 0 ? rawFn : undefined) ||
|
||||
String(url || '').split('/').pop() ||
|
||||
'file-' + Math.random().toString(36).slice(2, 8),
|
||||
url,
|
||||
}));
|
||||
formFileNames[key] =
|
||||
field.type === 'upload' && fieldFiles[key].length === 1
|
||||
? fieldFiles[key][0].name
|
||||
: fieldFiles[key].map((f) => f.name);
|
||||
});
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleSubmit = () => {
|
||||
// 定格卡片默认只读不可提交;最后一张已执行卡片(editable)允许改参后重新提交
|
||||
if ((props.readonly && !props.editable) || props.submitting) return;
|
||||
if (!validateFormFields()) return;
|
||||
emit('submit', {
|
||||
formValues: JSON.parse(JSON.stringify(formValues)),
|
||||
formFileNames: JSON.parse(JSON.stringify(formFileNames)),
|
||||
templates: JSON.parse(JSON.stringify(templates.value || [])),
|
||||
chargeMode: chargeMode.value,
|
||||
});
|
||||
};
|
||||
|
||||
// InputBar 发送按钮触发执行:父组件把执行信号下发给目标卡片(本卡片为目标时 executeSignal 非 null),
|
||||
// 统一调用提交(含必填校验);卡片参数在组件内部,父组件经信号间接触发
|
||||
watch(
|
||||
() => props.executeSignal,
|
||||
(sig) => {
|
||||
if (!sig) return;
|
||||
handleSubmit();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* ===== 表单卡片(对话消息流内) ===== */
|
||||
.workflow-form-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e8ee;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.06);
|
||||
width: min(640px, calc(100% - 4px));
|
||||
max-width: 100%;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&.readonly {
|
||||
box-shadow: none;
|
||||
}
|
||||
&.submitting {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.workflow-form-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.15s;
|
||||
&:hover { background: #f8fafc; }
|
||||
h3 { margin: 0; font-size: 14px; font-weight: 600; color: #0f172a; }
|
||||
.workflow-form-sub { font-size: 12px; color: #94a3b8; margin-top: 2px; }
|
||||
}
|
||||
.header-left { min-width: 0; }
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* 折叠切换按钮:文字 + 箭头,明确提示可展开/收起参数区 */
|
||||
.collapse-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 3px 9px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
&:hover {
|
||||
border-color: #93c5fd;
|
||||
color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
.collapse-arrow {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: transform 0.2s;
|
||||
&.collapsed { transform: rotate(-180deg); }
|
||||
}
|
||||
}
|
||||
|
||||
/* 节点执行过程区(豆包式步骤流) */
|
||||
.node-progress-list {
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.node-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
|
||||
.node-step-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.node-step-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #1e293b;
|
||||
}
|
||||
.node-step-status {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
&.is-running .node-step-status { color: #2563eb; }
|
||||
&.is-done .node-step-status { color: #22c55e; }
|
||||
&.is-failed .node-step-status { color: #ef4444; }
|
||||
}
|
||||
.node-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid #dbeafe;
|
||||
border-top-color: #2563eb;
|
||||
border-radius: 50%;
|
||||
animation: node-spin 0.8s linear infinite;
|
||||
}
|
||||
.node-icon-done { color: #22c55e; font-size: 15px; }
|
||||
.node-icon-failed { color: #ef4444; font-size: 15px; }
|
||||
.node-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #cbd5e1;
|
||||
}
|
||||
@keyframes node-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.workflow-form-scroll {
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
&::-webkit-scrollbar { width: 4px; }
|
||||
&::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
|
||||
&::-webkit-scrollbar-track { background: transparent; }
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 14px;
|
||||
&:last-child { margin-bottom: 0; }
|
||||
.el-form-item__label { font-size: 13px; font-weight: 500; color: #334155; padding-bottom: 4px; line-height: 1.5; }
|
||||
.el-form-item__label::before { color: #ef4444; margin-right: 4px; }
|
||||
.el-input-number { width: 100%; }
|
||||
.el-select { width: 100%; }
|
||||
.el-textarea__inner { font-size: 13px; }
|
||||
}
|
||||
|
||||
/* 引用字段只读展示 */
|
||||
.field-source-readonly {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
.source-tag { flex-shrink: 0; }
|
||||
.source-text { line-height: 1.5; }
|
||||
}
|
||||
|
||||
.uploaded-files-list { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
.uploaded-file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
.file-name {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.patch-template-section {
|
||||
margin-top: 16px;
|
||||
:deep(.patch-template-editor) {
|
||||
> .el-divider { margin-top: 4px; margin-bottom: 12px; border-top-color: #e2e8f0;
|
||||
.el-divider__text { font-weight: 600; font-size: 13px; color: #475569; background: transparent; padding-left: 0; }
|
||||
}
|
||||
.template-card { border: 1px solid #e2e8f0; border-radius: 10px; margin-bottom: 10px; }
|
||||
}
|
||||
}
|
||||
|
||||
/* 底部操作区 */
|
||||
.workflow-form-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
background: #fafbfc;
|
||||
flex-shrink: 0;
|
||||
.form-error-text {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: #ef4444;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap; /* 失败详情可能含换行(error 字段多行原因),允许换行展示 */
|
||||
word-break: break-word;
|
||||
}
|
||||
.form-edit-tip {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
/* 执行进度:spinner + 文本,节点事件推进 */
|
||||
.executing-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
.exec-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid #bfdbfe;
|
||||
border-top-color: #2563eb;
|
||||
border-radius: 50%;
|
||||
animation: exec-spin 0.8s linear infinite;
|
||||
}
|
||||
.exec-text {
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
@keyframes exec-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.w100 { width: 100%; }
|
||||
</style>
|
||||
@@ -1,263 +0,0 @@
|
||||
<template>
|
||||
<div class="workflow-output-card">
|
||||
<template v-for="(out, i) in outputs" :key="i">
|
||||
<!-- 图片:常驻内嵌缩略图,点击放大看大图(走父级预览弹窗) -->
|
||||
<div v-if="out.type === 'image'" class="output-media is-image" @click="emit('preview', out)">
|
||||
<img :src="resolveUrl ? resolveUrl(out.url) : out.url" :alt="out.name" loading="lazy" />
|
||||
<span class="media-name">{{ out.name }}</span>
|
||||
<span class="media-actions" @click.stop>
|
||||
<button type="button" class="output-action" title="下载" @click="emit('download', out)">
|
||||
<el-icon><Download /></el-icon>
|
||||
</button>
|
||||
<button type="button" class="output-action is-danger" title="删除" @click="emit('delete', out)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 视频:内嵌播放器(底部不叠加文件名,避免遮挡原生控制条) -->
|
||||
<div v-else-if="out.type === 'video'" class="output-media is-video">
|
||||
<video :src="resolveUrl ? resolveUrl(out.url) : out.url" controls preload="metadata" :title="out.name"></video>
|
||||
<span class="media-actions" @click.stop>
|
||||
<button type="button" class="output-action" title="下载" @click="emit('download', out)">
|
||||
<el-icon><Download /></el-icon>
|
||||
</button>
|
||||
<button type="button" class="output-action is-danger" title="删除" @click="emit('delete', out)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 音频:内嵌播放器(底部不叠加文件名,避免遮挡原生控制条) -->
|
||||
<div v-else-if="out.type === 'audio'" class="output-media is-audio">
|
||||
<audio :src="resolveUrl ? resolveUrl(out.url) : out.url" controls preload="metadata" :title="out.name"></audio>
|
||||
<span class="media-actions" @click.stop>
|
||||
<button type="button" class="output-action" title="下载" @click="emit('download', out)">
|
||||
<el-icon><Download /></el-icon>
|
||||
</button>
|
||||
<button type="button" class="output-action is-danger" title="删除" @click="emit('delete', out)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 文本 / 其他类型:文件行 + 预览/下载/删除 -->
|
||||
<div v-else class="output-item">
|
||||
<el-icon class="output-icon" :class="`is-${out.type || 'file'}`">
|
||||
<component :is="getIcon(out.type)" />
|
||||
</el-icon>
|
||||
<span class="output-name" :title="out.name">{{ out.name }}</span>
|
||||
<span class="output-actions">
|
||||
<button v-if="out.type !== 'text'" type="button" class="output-action" title="预览" @click="emit('preview', out)">
|
||||
<el-icon><View /></el-icon>
|
||||
</button>
|
||||
<button type="button" class="output-action" title="下载" @click="emit('download', out)">
|
||||
<el-icon><Download /></el-icon>
|
||||
</button>
|
||||
<button type="button" class="output-action is-danger" title="删除" @click="emit('delete', out)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Picture, VideoPlay, Headset, Document, View, Download, Delete } from '@element-plus/icons-vue';
|
||||
|
||||
// 产出文件:url 文件地址(相对/绝对)、name 展示名、type 预览类型(image/video/audio/text/file)、backendId 执行记录 id
|
||||
interface WorkflowOutput {
|
||||
url: string;
|
||||
name: string;
|
||||
type: string;
|
||||
backendId: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
outputs: WorkflowOutput[];
|
||||
// 相对路径 → 完整 URL 解析函数(由父级传入 buildAssetUrl),缺省时直接用原 url(绝对地址场景)
|
||||
resolveUrl?: (url: string) => string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'preview', output: WorkflowOutput): void;
|
||||
(e: 'download', output: WorkflowOutput): void;
|
||||
(e: 'delete', output: WorkflowOutput): void;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
// 按文件类型选图标(与工作空间侧边栏一致)
|
||||
const getIcon = (type: string) => {
|
||||
const t = String(type || '').toLowerCase();
|
||||
if (t === 'image') return Picture;
|
||||
if (t === 'video') return VideoPlay;
|
||||
if (t === 'audio') return Headset;
|
||||
return Document;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.workflow-output-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
// 媒体产出(图片/视频/音频):常驻内嵌展示,hover 浮出下载/删除操作
|
||||
.output-media {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
background: #0f172a;
|
||||
|
||||
img,
|
||||
video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 320px;
|
||||
object-fit: contain;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
audio {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: #0f172a;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.media-name {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
color: #e2e8f0;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.6));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.media-actions {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.media-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 非媒体产出:文件行 + 操作按钮
|
||||
.output-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #f1f5f9;
|
||||
border-radius: 10px;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #eef2f7;
|
||||
.output-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.output-icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.is-image {
|
||||
color: #2563eb;
|
||||
}
|
||||
&.is-video {
|
||||
color: #7c3aed;
|
||||
}
|
||||
&.is-audio {
|
||||
color: #d97706;
|
||||
}
|
||||
&.is-file,
|
||||
&.is-text {
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.output-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.output-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.output-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
&.is-danger:hover {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
// 媒体产出内的操作按钮:深色背景上使用浅色图标
|
||||
.output-media {
|
||||
.output-action {
|
||||
color: #cbd5e1;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
&.is-danger:hover {
|
||||
background: rgba(220, 38, 38, 0.35);
|
||||
color: #fecaca;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+249
-1532
File diff suppressed because it is too large
Load Diff
@@ -1,226 +0,0 @@
|
||||
// ===== 首页执行工作流管理 DSL 的解析与写回工具 =====
|
||||
// 独立实现,不依赖 settings/workflow 或 settings/creation 的旧结构。
|
||||
// 字段路径文法与工作流管理 modelParamUtils 一致:key / .attrs. / .enumValues[i]
|
||||
|
||||
export interface HomeFormField {
|
||||
path: string;
|
||||
label: string;
|
||||
type: string;
|
||||
fieldType: string;
|
||||
required: boolean;
|
||||
value?: any;
|
||||
default?: any;
|
||||
options?: any[];
|
||||
fieldConstraint?: any;
|
||||
// 引用上游节点输出({ nodeId, field });有则首页只读展示引用,值由上游节点提供
|
||||
valueSource?: any;
|
||||
// 来源节点 id(未配置 valueSource 时默认引用开始节点,用于引用展示)
|
||||
nodeId?: string;
|
||||
// 上传字段的文件名(单文件为字符串,多文件为数组),执行时随 value 一并写回传给后端
|
||||
fileName?: string | string[];
|
||||
// 旧兼容:creation 时代 http body 中 showInForm 的子字段
|
||||
__isHttpBodyChild?: boolean;
|
||||
bodyKey?: string;
|
||||
}
|
||||
|
||||
export function deepClone<T>(val: T): T {
|
||||
return JSON.parse(JSON.stringify(val ?? null)) as T;
|
||||
}
|
||||
|
||||
// 开始节点运行字段(runFormFields)→ MainContent 控件 type
|
||||
function mapRunFieldType(f: any): string {
|
||||
const ft = String(f?.fieldType || '');
|
||||
const t = String(f?.type || 'string');
|
||||
if (ft === 'number' || t === 'number') return 'number';
|
||||
if (ft === 'boolean' || t === 'boolean' || ft === 'switch') return 'switch';
|
||||
if (ft === 'select' && Array.isArray(f?.options)) return 'select';
|
||||
if (ft === 'textarea') return 'textarea';
|
||||
if (ft === 'uploadMultiple' || t === 'uploadMultiple' || f?.multiple) return 'uploadMultiple';
|
||||
if (ft === 'upload' || ft === 'file' || ft === 'fileUpload' || t === 'upload' || t === 'file') return 'upload';
|
||||
return 'input';
|
||||
}
|
||||
|
||||
// 选项归一化:兼容 {label,value} / {key,value} / value.options 嵌套三种来源
|
||||
function normalizeOptions(def: any): any[] | undefined {
|
||||
let raw: any[] | undefined;
|
||||
if (Array.isArray(def.options) && def.options.length > 0) raw = def.options;
|
||||
else if (def.value && typeof def.value === 'object' && !Array.isArray(def.value) && Array.isArray(def.value.options))
|
||||
raw = def.value.options;
|
||||
else if (Array.isArray(def.enumValues) && def.enumValues.length > 0) raw = def.enumValues;
|
||||
if (!raw) return undefined;
|
||||
return raw.map((o: any) => {
|
||||
if (o && typeof o === 'object' && !Array.isArray(o)) {
|
||||
const val = o.value !== undefined ? o.value : o.key;
|
||||
const label = o.label !== undefined ? o.label : o.key;
|
||||
return { label: label === undefined ? String(val) : label, value: val };
|
||||
}
|
||||
return { label: String(o), value: o };
|
||||
});
|
||||
}
|
||||
|
||||
// 开始节点(__start__):读取 outputConfig 中保存的 runFormFields(工作流管理汇总的
|
||||
// model 勾选字段 + form 自定义字段),转首页扁平表单字段。条目已有
|
||||
// field/label/type/fieldType/required/value/defaultValue/fieldConstraint/valueSource/multiple,
|
||||
// 直接归一化映射,无需再走 model/form 各自结构。
|
||||
function collectStartNodeFields(node: any): HomeFormField[] {
|
||||
const out = Array.isArray(node?.outputConfig) ? node.outputConfig : [];
|
||||
return out
|
||||
.filter((o: any) => o && typeof o === 'object' && (o.field !== undefined || o.path !== undefined))
|
||||
.map((o: any) => {
|
||||
const field = o.field !== undefined ? o.field : o.path;
|
||||
return {
|
||||
path: field,
|
||||
label: o.label || String(field),
|
||||
type: mapRunFieldType(o),
|
||||
fieldType: o.fieldType || o.type || 'string',
|
||||
required: Boolean(o.required),
|
||||
value: o.value !== undefined ? o.value : o.defaultValue,
|
||||
default: o.defaultValue,
|
||||
options: normalizeOptions(o),
|
||||
fieldConstraint: o.fieldConstraint && typeof o.fieldConstraint === 'object' ? o.fieldConstraint : undefined,
|
||||
valueSource: o.valueSource && typeof o.valueSource === 'object' ? o.valueSource : undefined,
|
||||
nodeId: o.nodeId,
|
||||
fileName: o.fileName,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 主入口:DSL 节点 → 首页扁平表单字段。开始节点为唯一表单源
|
||||
// (runFormFields 汇总了各 model 节点勾选字段 + form 节点自定义字段),其余节点无运行时字段。
|
||||
export function collectHomeFormFields(node: any): HomeFormField[] {
|
||||
if (String(node?.nodeCode || '').toLowerCase() !== '__start__') return [];
|
||||
return collectStartNodeFields(node);
|
||||
}
|
||||
|
||||
// 上传类型空值:未选文件(空字符串 / 空数组 / null)
|
||||
const isEmptyUploadValue = (val: any): boolean => val === '' || val === null || (Array.isArray(val) && val.length === 0);
|
||||
|
||||
// 首页表单值写回开始节点(唯一表单源)outputConfig 对应条目 value:
|
||||
// 1. 引用其他节点输出(valueSource)的字段只读展示,值由上游节点运行时提供,
|
||||
// 清空保存时的值快照(避免后端误用过期值)
|
||||
// 2. 上传类型字段未选文件 → 从开始节点运行字段移除,不向后端提交空字段
|
||||
export function applyHomeFormValues(nodes: any[], formValues: Record<string, any>, formFileNames?: Record<string, any>): void {
|
||||
if (!Array.isArray(nodes)) return;
|
||||
const startNode = nodes.find((n: any) => String(n?.nodeCode || '').toLowerCase() === '__start__');
|
||||
if (!startNode || !Array.isArray(startNode.outputConfig)) return;
|
||||
for (let i = startNode.outputConfig.length - 1; i >= 0; i--) {
|
||||
const f = startNode.outputConfig[i];
|
||||
if (!f || typeof f !== 'object') continue;
|
||||
if (f.valueSource && typeof f.valueSource === 'object') {
|
||||
// 引用其他节点输出的字段:值由上游节点运行时提供,清空保存时的快照,避免后端误用过期值
|
||||
if ('value' in f) delete f.value;
|
||||
continue;
|
||||
}
|
||||
const field = f.field !== undefined ? f.field : f.path;
|
||||
if (field === undefined || field === '') continue;
|
||||
const key = `${startNode.id || startNode.nodeCode}|${field}`;
|
||||
const val = formValues[key];
|
||||
// 仅跳过未初始化的 key;null(如数字清空)也要写回
|
||||
if (val === undefined) continue;
|
||||
// 上传类型字段未选文件 → 不提交,从开始节点运行字段移除
|
||||
const ft = String(f.fieldType || f.type || '');
|
||||
const isUpload = ft === 'upload' || ft === 'uploadMultiple' || ft === 'fileUpload' || ft === 'file';
|
||||
if (isUpload && isEmptyUploadValue(val)) {
|
||||
startNode.outputConfig.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
f.value = val;
|
||||
// 上传字段附带文件名:与 value 类型对齐(单文件字符串/多文件数组),随开始节点一并传给后端
|
||||
if (formFileNames && formFileNames[key] !== undefined) {
|
||||
f.fileName = formFileNames[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 模板完整性校验:返回缺模型等缺失项,供补全弹窗提示
|
||||
export function checkTemplateMissing(flowContent: any): { nodeId: string; nodeName: string; reason: string }[] {
|
||||
const nodes = Array.isArray(flowContent?.nodes) ? flowContent.nodes : [];
|
||||
const missing: { nodeId: string; nodeName: string; reason: string }[] = [];
|
||||
for (const n of nodes) {
|
||||
if (String(n?.nodeCode || '').toLowerCase() === 'model' && !n?.modelConfig?.modelId) {
|
||||
missing.push({ nodeId: n?.id || '', nodeName: n?.name || n?.nodeCode || '模型节点', reason: '未选择模型' });
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
// ===== 执行产出提取 =====
|
||||
// 会话内展示工作流执行产出(文件卡片):来自 getExecutionDetail 返回的 WorkflowItem,
|
||||
// 合并 fileUrls / outputParams 各 value / resultUrl,去重后归一为 { url, name, type, backendId }。
|
||||
// type 用于预览模式判定(image/video/audio/text/file),backendId 为执行记录 id(删除产出用)。
|
||||
|
||||
export interface WorkflowOutput {
|
||||
url: string;
|
||||
name: string;
|
||||
type: string;
|
||||
backendId: string;
|
||||
}
|
||||
|
||||
// 按文件扩展名推断预览类型
|
||||
function guessFileType(url: string): string {
|
||||
const clean = String(url).split('?')[0] || '';
|
||||
const ext = (clean.split('.').pop() || '').toLowerCase();
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'ico'].includes(ext)) return 'image';
|
||||
if (['mp4', 'webm', 'mov', 'avi', 'mkv'].includes(ext)) return 'video';
|
||||
if (['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac'].includes(ext)) return 'audio';
|
||||
if (['txt', 'md', 'json', 'log', 'csv'].includes(ext)) return 'text';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
export function extractWorkflowOutputs(data: any, backendId?: string): WorkflowOutput[] {
|
||||
if (!data) return [];
|
||||
const seen = new Set<string>();
|
||||
const outputs: WorkflowOutput[] = [];
|
||||
const push = (rawUrl: any) => {
|
||||
if (typeof rawUrl !== 'string') return;
|
||||
const url = rawUrl.trim();
|
||||
if (!url || seen.has(url)) return;
|
||||
seen.add(url);
|
||||
const name = String(url).split('?')[0].split('/').pop() || '产出文件';
|
||||
outputs.push({
|
||||
url,
|
||||
name,
|
||||
type: guessFileType(url),
|
||||
backendId: backendId || data.id || '',
|
||||
});
|
||||
};
|
||||
if (Array.isArray(data.fileUrls)) data.fileUrls.forEach(push);
|
||||
if (Array.isArray(data.outputParams)) {
|
||||
data.outputParams.forEach((o: any) => {
|
||||
if (!o || typeof o !== 'object') return;
|
||||
Object.values(o).forEach((v) => push(v));
|
||||
});
|
||||
}
|
||||
if (data.resultUrl) push(data.resultUrl);
|
||||
return outputs;
|
||||
}
|
||||
|
||||
// 从事件携带的文件 URL 数组(flow_complete 的 data.resultFileUrls)直接构建产出列表:
|
||||
// 不依赖执行详情的异步查询,type 同样按扩展名推断,去重后归一为 { url, name, type, backendId }。
|
||||
// 用于完成事件即时渲染产出卡片,删除时 backendId 为空则跳过删除接口(安全降级)。
|
||||
export function buildOutputsFromUrls(urls: any, backendId?: string): WorkflowOutput[] {
|
||||
// 兼容两种入参:字符串(逗号拼接多 URL)或数组(元素本身可能是逗号拼接串);
|
||||
// 合法 URL 不含裸逗号,统一按逗号拆分后逐条归一化,避免把整串误当单个产出
|
||||
const arr: string[] = typeof urls === 'string' ? [urls] : Array.isArray(urls) ? urls : [];
|
||||
const seen = new Set<string>();
|
||||
const outputs: WorkflowOutput[] = [];
|
||||
for (const u of arr) {
|
||||
if (typeof u !== 'string') continue;
|
||||
const parts = u
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
for (const url of parts) {
|
||||
if (seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
const name = String(url).split('?')[0].split('/').pop() || '产出文件';
|
||||
outputs.push({
|
||||
url,
|
||||
name,
|
||||
type: guessFileType(url),
|
||||
backendId: backendId || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
return outputs;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// ===== 首页回答区 Markdown 渲染(DeepSeek 风格)=====
|
||||
// marked 负责 Markdown 语法,自定义 renderer.code 用 highlight.js 做代码语法高亮,
|
||||
// 并为代码块包一层带"复制"按钮的结构;DOMPurify 消毒避免 v-html 注入 XSS。
|
||||
import { marked } from 'marked';
|
||||
import hljs from 'highlight.js/lib/common';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
const renderer = new marked.Renderer();
|
||||
renderer.code = ({ text, lang }) => {
|
||||
const language = lang && hljs.getLanguage(lang) ? lang : 'plaintext';
|
||||
const highlighted = hljs.highlight(text, { language }).value;
|
||||
return `<div class="code-wrapper"><button type="button" class="code-copy-btn" data-copy>复制</button><pre><code class="hljs language-${language}">${highlighted}</code></pre></div>`;
|
||||
};
|
||||
marked.use({ renderer });
|
||||
|
||||
// gfm:GitHub 风格(表格、任务列表等);breaks:单换行转 <br>,贴近对话输出
|
||||
marked.setOptions({ gfm: true, breaks: true });
|
||||
|
||||
/** 将 Markdown 渲染为安全 HTML(含代码高亮与复制按钮,已消毒) */
|
||||
export function renderMarkdown(content: string): string {
|
||||
return DOMPurify.sanitize(marked.parse(content) as string);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
// ===== 首页 WebSocket 会话级长连接(/ai-agent/session/wsExecute)=====
|
||||
// 约定:握手 URL query 只带 sessionId/token(+可选 modelId/flowId),
|
||||
// 建连后不发任何帧;每轮提问/工作流执行通过 send* 系列函数发送启动帧,
|
||||
// 一轮推完由服务端推送完成事件(answer/error)判定结束,连接保持不关闭。
|
||||
// 服务端推送消息格式见 wsMessage.ts,本工具只做连接/发送,消息解析交给调用方。
|
||||
|
||||
import { Session } from '/@/utils/storage';
|
||||
|
||||
// VITE_API_URL(http(s)://host)→ ws(s)://host
|
||||
const getWsBase = (): string => {
|
||||
const raw = import.meta.env.VITE_API_URL || '';
|
||||
return raw.replace(/^http/, 'ws');
|
||||
};
|
||||
|
||||
export interface SessionSocket {
|
||||
ws: WebSocket;
|
||||
// onopen 时 resolve(ws);打开前 close/error 时 reject,调用方据此判定建连失败
|
||||
ready: Promise<WebSocket>;
|
||||
}
|
||||
|
||||
export interface ConnectSessionSocketOptions {
|
||||
// 可选:已认领会话(后端正式号)传回复用;未传(空串)则后端自动创建,从首帧 ack 认领
|
||||
sessionId?: string;
|
||||
modelId?: string | number;
|
||||
flowId?: string | number;
|
||||
onMessage: (raw: any) => void;
|
||||
// 未传 sessionId 时,后端创建会话后首帧 ack 返回的正式会话号(UUID),调用方据此认领本地会话
|
||||
onClaim?: (sessionId: string) => void;
|
||||
onClose: (ev: CloseEvent) => void;
|
||||
onError: (ev: Event) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立会话级 WebSocket 长连接。
|
||||
* 握手 query 只带 sessionId/token(+可选 modelId/flowId),不做任何自动发送;
|
||||
* 后续每轮提问由 sendAgentStart / sendWorkflowStart 发送启动帧。
|
||||
* 返回 SessionSocket;URL 构造失败返回 null。
|
||||
*/
|
||||
export function connectSessionSocket(opts: ConnectSessionSocketOptions): SessionSocket | null {
|
||||
const base = getWsBase();
|
||||
const params: Record<string, string> = {};
|
||||
// 已认领会话传号复用;未传号则由后端自动创建会话,首帧 ack 返回正式号
|
||||
const sidForConnect = opts.sessionId;
|
||||
if (sidForConnect) params.sessionId = sidForConnect;
|
||||
if (opts.flowId != null) params.flowId = String(opts.flowId);
|
||||
if (opts.modelId != null) params.modelId = String(opts.modelId);
|
||||
const token = Session.get('token');
|
||||
if (token) params.token = String(token);
|
||||
|
||||
const query = Object.keys(params)
|
||||
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`)
|
||||
.join('&');
|
||||
const url = `${base}/ai-agent/session/wsExecute?${query}`;
|
||||
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(url);
|
||||
} catch (e) {
|
||||
console.error('[wsExecute] WebSocket 创建失败', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
let readyResolve!: (w: WebSocket) => void;
|
||||
let readyReject!: (e: unknown) => void;
|
||||
const ready = new Promise<WebSocket>((resolve, reject) => {
|
||||
readyResolve = resolve;
|
||||
readyReject = reject;
|
||||
});
|
||||
|
||||
// 未传会话号:等后端首帧 ack(data.sessionId)认领后才算就绪,避免业务帧先于会话号发出
|
||||
const wantsClaim = !opts.sessionId;
|
||||
let claimed = false;
|
||||
// 打开前是否已就绪:用于区分「未连上就关闭」与「正常关闭」
|
||||
let opened = false;
|
||||
// 兜底:ack 迟迟未到(异常)时超时放行,降级用原本地 id 继续,避免永久卡在建连
|
||||
let claimTimer: number | null = null;
|
||||
|
||||
ws.onopen = () => {
|
||||
// 长连接:握手成功仅标记就绪,不发送任何启动帧
|
||||
opened = true;
|
||||
if (!wantsClaim) {
|
||||
readyResolve(ws);
|
||||
} else {
|
||||
claimTimer = window.setTimeout(() => {
|
||||
claimed = true;
|
||||
readyResolve(ws);
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
// 首次建连:首帧 ack 携带后端正式会话号 → 认领后放行业务帧
|
||||
if (wantsClaim && !claimed) {
|
||||
const ackId = parseAckSessionId(ev.data);
|
||||
if (ackId) {
|
||||
claimed = true;
|
||||
if (claimTimer != null) window.clearTimeout(claimTimer);
|
||||
opts.onClaim?.(ackId);
|
||||
readyResolve(ws);
|
||||
return; // ack 帧仅用于认领,不交给业务 handler
|
||||
}
|
||||
}
|
||||
opts.onMessage(ev.data);
|
||||
};
|
||||
ws.onclose = (ev) => {
|
||||
if (claimTimer != null) window.clearTimeout(claimTimer);
|
||||
if (!opened) readyReject(ev);
|
||||
opts.onClose(ev);
|
||||
};
|
||||
ws.onerror = (ev) => opts.onError(ev);
|
||||
|
||||
return { ws, ready };
|
||||
}
|
||||
|
||||
// 解析建连 ack 帧:{ type:'ack', data:{ sessionId } } → 返回 sessionId;非 ack / 无号返回空串
|
||||
function parseAckSessionId(raw: any): string {
|
||||
let data: any = raw;
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
if (!data || typeof data !== 'object') return '';
|
||||
if (data.type !== 'ack') return '';
|
||||
const sid = data?.data?.sessionId;
|
||||
return typeof sid === 'string' && sid ? sid : '';
|
||||
}
|
||||
|
||||
// ===== 启动帧 =====
|
||||
|
||||
/** 普通对话:发送提问(type=agent),后端据此开始推流 */
|
||||
export function sendAgentStart(ws: WebSocket, p: { modelId?: string | number; question: string }): void {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'agent',
|
||||
payload: { modelId: p.modelId != null ? String(p.modelId) : '', question: p.question || '' },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** 工作流执行:发送 { type, payload:{ flowId, flowContent } },对齐后端 socket 传参结构 */
|
||||
export function sendWorkflowStart(ws: WebSocket, p: { flowId?: string | number; flowContent: any }): void {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'workflow',
|
||||
payload: {
|
||||
flowId: p.flowId != null ? String(p.flowId) : '',
|
||||
flowContent: p.flowContent,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** 取消当前轮(不关闭连接,长连接保持复用) */
|
||||
export function sendCancel(ws: WebSocket, isWorkflow: boolean): void {
|
||||
ws.send(JSON.stringify({ type: isWorkflow ? 'workflow_cancel' : 'agent_cancel' }));
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
// ===== 首页 WebSocket 流式消息解析工具 =====
|
||||
// 服务端推送统一为 JSON 文本帧(后端 ReAct 事件协议):
|
||||
// {"type":"model_call","message":"模型思考","data":{"step":1,"maxStep":15}} 模型思考(step 开始)
|
||||
// {"type":"tool_call","message":"调用工具","data":{"tool":"web_search","arguments":{}}} 模型请求调用工具
|
||||
// {"type":"tool_result","message":"工具返回","data":{"result":"..."}} 工具返回结果
|
||||
// {"type":"reasoning_chunk","message":"思考中","data":{"delta":"你"}} 思考内容增量(逐 chunk)
|
||||
// {"type":"answer_chunk","message":"回答中","data":{"delta":"你"}} 回答内容增量(逐 chunk)
|
||||
// {"type":"answer","message":"作答完成","data":{"answer":"..."}} 最终回答
|
||||
// {"type":"node_start","message":"开始执行(2/4): xxx","data":{"nodeCount":4,"nodeId":"..","nodeIndex":2,"nodeName":".."}} 工作流节点开始(进度推进)
|
||||
// {"type":"node_complete","message":"执行完成(2/4): xxx","data":同上} 工作流节点完成(进度推进)
|
||||
// {"type":"error","message":"...","error":"节点失败详情"} 执行失败(error 字段含详细原因)
|
||||
// 完成/失败判定均以 type 为准,不再用正则猜测文本。
|
||||
|
||||
export interface WsStreamMessage {
|
||||
type: string;
|
||||
message?: string;
|
||||
data?: any;
|
||||
payload?: any;
|
||||
// 失败详情:error 事件携带的详细原因(含节点名与具体失败信息),比 message 更具体
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** 后端 ReAct WebSocket 事件类型常量 */
|
||||
export const WsEventType = {
|
||||
ModelCall: 'model_call', // 模型思考(step 开始)
|
||||
ToolCall: 'tool_call', // 模型请求调用工具
|
||||
ToolResult: 'tool_result', // 工具返回结果
|
||||
Answer: 'answer', // 最终回答
|
||||
AnswerChunk: 'answer_chunk', // 回答内容增量(逐 chunk)
|
||||
RoundStart: 'round_start', // 本轮问答开始(后端收到提问后第一帧,data.recordId 为本次问答记录 id)
|
||||
ReasoningChunk: 'reasoning_chunk', // 思考内容增量(逐 chunk)
|
||||
NodeStart: 'node_start', // 工作流节点开始(进度推进)
|
||||
NodeComplete: 'node_complete', // 工作流节点完成(进度推进)
|
||||
FlowComplete: 'flow_complete', // 工作流执行完成(后端完成信号,日志末尾事件)
|
||||
Error: 'error', // 出错
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 解析服务端推送帧。
|
||||
* 非 JSON、无 type 的帧返回 null(调用方忽略)。
|
||||
*/
|
||||
export function parseWsMessage(raw: any): WsStreamMessage | null {
|
||||
let data: any = raw;
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!data || typeof data !== 'object' || typeof data.type !== 'string') return null;
|
||||
return data;
|
||||
}
|
||||
|
||||
/** answer_chunk / reasoning_chunk → data.delta 流式增量 */
|
||||
export function getDelta(msg: WsStreamMessage): string {
|
||||
return typeof msg?.data?.delta === 'string' ? msg.data.delta : '';
|
||||
}
|
||||
|
||||
/** answer → data.answer 最终答案 */
|
||||
export function getAnswer(msg: WsStreamMessage): string {
|
||||
return typeof msg?.data?.answer === 'string' ? msg.data.answer : '';
|
||||
}
|
||||
|
||||
/** round_start → data.recordId 本次问答记录 id(19 位雪花大整数,契约要求字符串形式,兼容数字类型防御性转 string) */
|
||||
export function getRecordId(msg: WsStreamMessage): string {
|
||||
const rid = msg?.data?.recordId;
|
||||
if (typeof rid === 'string' || typeof rid === 'number') return String(rid);
|
||||
return '';
|
||||
}
|
||||
|
||||
/** model_call → { step, maxStep },缺失时 maxStep 为 0 */
|
||||
export function getStepInfo(msg: WsStreamMessage): { step: number; maxStep: number } | null {
|
||||
const step = msg?.data?.step;
|
||||
if (typeof step !== 'number') return null;
|
||||
const maxStep = msg?.data?.maxStep;
|
||||
return { step, maxStep: typeof maxStep === 'number' ? maxStep : 0 };
|
||||
}
|
||||
|
||||
/** 工作流节点进度:node_start / node_complete → { current, total, nodeName },非节点事件返回 null */
|
||||
export function getNodeProgress(msg: WsStreamMessage): { current: number; total: number; nodeName: string } | null {
|
||||
const t = msg?.type;
|
||||
if (t !== WsEventType.NodeStart && t !== WsEventType.NodeComplete) return null;
|
||||
const d = msg?.data;
|
||||
const nodeIndex = d?.nodeIndex;
|
||||
const nodeCount = d?.nodeCount;
|
||||
if (typeof nodeIndex !== 'number' || typeof nodeCount !== 'number') return null;
|
||||
return {
|
||||
current: nodeIndex,
|
||||
total: nodeCount,
|
||||
nodeName: typeof d?.nodeName === 'string' ? d.nodeName : '',
|
||||
};
|
||||
}
|
||||
|
||||
/** 工作流执行节点步骤(node_start/node_complete/error 事件驱动,渲染表单卡片执行过程区) */
|
||||
export interface WorkflowNodeStep {
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
nodeIndex: number;
|
||||
status: 'pending' | 'running' | 'done' | 'failed';
|
||||
}
|
||||
|
||||
/** 工作流执行节点进度(挂在表单卡片消息 formProgress 上) */
|
||||
export interface WorkflowNodeProgress {
|
||||
nodes: WorkflowNodeStep[];
|
||||
}
|
||||
|
||||
/** 工作流节点事件解析结果:node_start / node_complete → { nodeId, nodeName, nodeIndex, phase } */
|
||||
export interface WorkflowNodeEvent {
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
nodeIndex: number;
|
||||
phase: 'start' | 'complete';
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析工作流节点事件(node_start / node_complete)。
|
||||
* 返回完整节点信息(nodeId/nodeName/nodeIndex/phase),供执行过程区步骤列表驱动;非节点事件返回 null。
|
||||
*/
|
||||
export function getNodeEvent(msg: WsStreamMessage): WorkflowNodeEvent | null {
|
||||
const t = msg?.type;
|
||||
if (t !== WsEventType.NodeStart && t !== WsEventType.NodeComplete) return null;
|
||||
const d = msg?.data;
|
||||
const nodeIndex = d?.nodeIndex;
|
||||
if (typeof nodeIndex !== 'number') return null;
|
||||
return {
|
||||
nodeId: typeof d?.nodeId === 'string' ? d.nodeId : '',
|
||||
nodeName: typeof d?.nodeName === 'string' ? d.nodeName : '',
|
||||
nodeIndex,
|
||||
phase: t === WsEventType.NodeStart ? 'start' : 'complete',
|
||||
};
|
||||
}
|
||||
|
||||
/** tool_call → 工具名称(tool / toolName / name 依次尝试,缺失返回空串) */
|
||||
export function getToolCallName(msg: WsStreamMessage): string {
|
||||
const d = msg?.data;
|
||||
if (!d || typeof d !== 'object') return '';
|
||||
const name = d.tool ?? d.toolName ?? d.name;
|
||||
return typeof name === 'string' ? name : '';
|
||||
}
|
||||
|
||||
/** tool_result → 工具返回文本(result / output / content / message 依次尝试,对象则序列化) */
|
||||
export function getToolResultText(msg: WsStreamMessage): string {
|
||||
const d = msg?.data;
|
||||
if (typeof d === 'string') return d;
|
||||
if (!d || typeof d !== 'object') return '';
|
||||
const raw = d.result ?? d.output ?? d.content ?? d.message;
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (raw != null) {
|
||||
try {
|
||||
return JSON.stringify(raw);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** error → 错误文案(message 优先展示简洁业务提示;error 详细原因仅在 message 缺失时兜底) */
|
||||
export function getErrorText(msg: WsStreamMessage): string {
|
||||
// 只显示 message 的消息(用户确认),error 详细原因不再作为界面展示内容
|
||||
if (typeof msg?.message === 'string' && msg.message) return msg.message;
|
||||
if (typeof msg?.error === 'string' && msg.error) return msg.error;
|
||||
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 '执行失败,请重试';
|
||||
}
|
||||
@@ -113,28 +113,21 @@
|
||||
<KeyValueEditor v-model="formData.requestHeadMapping" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6" class="mb20">
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
||||
<el-form-item label="请求体映射">
|
||||
<el-button @click="openMappingEditor('requestBodyMapping')" style="width: 100%">
|
||||
配置 ({{ Object.keys(formData.requestBodyMapping).length }})
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6" class="mb20">
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
||||
<el-form-item label="响应映射">
|
||||
<el-button @click="openMappingEditor('responseMapping')" style="width: 100%">
|
||||
配置 ({{ Object.keys(formData.responseMapping).length }})
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6" class="mb20">
|
||||
<el-form-item label="错误消息映射">
|
||||
<el-button @click="openMappingEditor('error_message_mapping')" style="width: 100%">
|
||||
配置 ({{ Object.keys(formData.error_message_mapping || {}).length }})
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6" class="mb20" v-if="formData.responseType !== 2">
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20" v-if="formData.responseType !== 2">
|
||||
<el-form-item label="响应主体映射">
|
||||
<el-select
|
||||
v-model="formData.responseBodyMapping"
|
||||
@@ -159,64 +152,48 @@
|
||||
<template v-if="formData.responseType === 2">
|
||||
<el-divider content-position="left">异步映射配置</el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
||||
<el-form-item label="异步响应映射">
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="回调响应映射">
|
||||
<el-button @click="openMappingEditor('asyncResponseMapping')" style="width: 100%">
|
||||
配置 ({{ Object.keys((formData.asyncTaskMapping.responseMapping || {})).length }})
|
||||
配置 ({{ Object.keys(formData.asyncTaskMapping.responseMapping).length }})
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
||||
<el-form-item label="请求方法">
|
||||
<el-select v-model="formData.asyncTaskMapping.httpMethod" style="width: 100%">
|
||||
<el-option label="POST" value="POST" />
|
||||
<el-option label="GET" value="GET" />
|
||||
<el-option label="PUT" value="PUT" />
|
||||
<el-option label="DELETE" value="DELETE" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="回调 URL">
|
||||
<el-input v-model="formData.asyncTaskMapping.url" placeholder="请输入异步回调地址" clearable />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="请求体映射">
|
||||
<el-button @click="openMappingEditor('asyncRequestBodyMapping')" style="width: 100%">
|
||||
配置 ({{ Object.keys((formData.asyncTaskMapping.requestBodyMapping || {})).length }})
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="响应主体映射">
|
||||
<el-select
|
||||
v-model="formData.responseBodyMapping"
|
||||
multiple
|
||||
:disabled="!hasMappingConfig"
|
||||
:placeholder="mappingPlaceholder"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in tokenMappingOptions"
|
||||
:key="item.path"
|
||||
:label="`${item.path} — ${item.label}`"
|
||||
:value="item.path"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
|
||||
<el-form-item label="异步请求头映射">
|
||||
<el-form-item label="回调请求头映射">
|
||||
<KeyValueEditor v-model="formData.asyncTaskMapping.requestHeadMapping" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
||||
<el-form-item label="响应主体映射">
|
||||
<el-select
|
||||
v-model="formData.responseBodyMapping"
|
||||
multiple
|
||||
:disabled="!hasMappingConfig"
|
||||
:placeholder="mappingPlaceholder"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in tokenMappingOptions"
|
||||
:key="item.path"
|
||||
:label="`${item.path} — ${item.label}`"
|
||||
:value="item.path"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-divider content-position="left" class="sub-divider">任务状态字段路径</el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
@@ -398,7 +375,6 @@ const defaultAsyncTaskMapping: AsyncTaskMapping = {
|
||||
url: '',
|
||||
httpMethod: 'POST',
|
||||
requestHeadMapping: {},
|
||||
requestBodyMapping: {},
|
||||
responseMapping: {},
|
||||
taskId: '',
|
||||
taskStatus: '',
|
||||
@@ -425,7 +401,6 @@ const formData = reactive({
|
||||
// 映射
|
||||
requestHeadMapping: {} as Record<string, unknown>,
|
||||
requestBodyMapping: {} as Record<string, unknown>,
|
||||
error_message_mapping: {} as Record<string, unknown>,
|
||||
responseMapping: {} as Record<string, unknown>,
|
||||
responseBodyMapping: [] as string[],
|
||||
// Token 映射
|
||||
@@ -554,7 +529,6 @@ const hasTaskStatusConfig = computed(() => taskStatusOptions.value.length > 0);
|
||||
/** 用户手动切换返回类型时清空 token 映射(编程赋值不触发) */
|
||||
function handleResponseTypeChange() {
|
||||
formData.tokenMapping = { ...defaultTokenMapping };
|
||||
formData.responseBodyMapping = [];
|
||||
}
|
||||
|
||||
|
||||
@@ -568,9 +542,7 @@ const mappingFieldLabels: Record<string, string> = {
|
||||
requestHeadMapping: '请求头映射',
|
||||
requestBodyMapping: '请求体映射',
|
||||
responseMapping: '响应映射',
|
||||
error_message_mapping: '错误消息映射',
|
||||
asyncResponseMapping: '异步-响应映射',
|
||||
asyncRequestBodyMapping: '异步-请求体映射',
|
||||
};
|
||||
|
||||
const jsonEditorTitle = computed(() => {
|
||||
@@ -581,7 +553,6 @@ const jsonEditorTitle = computed(() => {
|
||||
/** 获取映射字段的引用 */
|
||||
function getMappingValue(key: string): Record<string, unknown> {
|
||||
if (key === 'asyncResponseMapping') return formData.asyncTaskMapping.responseMapping;
|
||||
if (key === 'asyncRequestBodyMapping') return formData.asyncTaskMapping.requestBodyMapping || {};
|
||||
return (formData as Record<string, unknown>)[key] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -589,8 +560,6 @@ function getMappingValue(key: string): Record<string, unknown> {
|
||||
function setMappingValue(key: string, value: Record<string, unknown>) {
|
||||
if (key === 'asyncResponseMapping') {
|
||||
formData.asyncTaskMapping.responseMapping = value;
|
||||
} else if (key === 'asyncRequestBodyMapping') {
|
||||
formData.asyncTaskMapping.requestBodyMapping = value;
|
||||
} else {
|
||||
(formData as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
@@ -602,53 +571,8 @@ const openMappingEditor = (key: string) => {
|
||||
jsonEditorVisible.value = true;
|
||||
};
|
||||
|
||||
/** 递归将 schema 中所有标量字段的 value 重置为 null。
|
||||
* 模型配置保存的是「结构模板」:保留 value 键与结构、defaultValue 等配置,但数据值不预设。
|
||||
* defaultValue 已有专门字段承载默认值,value 不再携带任何值,统一置 null。
|
||||
* 根节点经 nodeToValue 输出时不带 type/attrs 包装(普通对象),故需对无 type 节点也递归子键。
|
||||
*/
|
||||
function resetSchemaValues(schema: any): any {
|
||||
if (schema === null || schema === undefined) return schema;
|
||||
if (Array.isArray(schema)) {
|
||||
schema.forEach((item, i) => {
|
||||
schema[i] = resetSchemaValues(item);
|
||||
});
|
||||
return schema;
|
||||
}
|
||||
if (typeof schema !== 'object') return schema;
|
||||
if (typeof schema.type === 'string') {
|
||||
switch (schema.type) {
|
||||
case 'object':
|
||||
if (schema.attrs && typeof schema.attrs === 'object') {
|
||||
Object.keys(schema.attrs).forEach((k) => {
|
||||
schema.attrs[k] = resetSchemaValues(schema.attrs[k]);
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'array':
|
||||
if (Array.isArray(schema.attrs)) {
|
||||
schema.attrs = schema.attrs.map((item: any) => resetSchemaValues(item));
|
||||
}
|
||||
break;
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'boolean':
|
||||
case 'null':
|
||||
schema.value = null;
|
||||
break;
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
// 普通对象(如根 object 或未包装节点):递归子键
|
||||
Object.keys(schema).forEach((k) => {
|
||||
schema[k] = resetSchemaValues(schema[k]);
|
||||
});
|
||||
return schema;
|
||||
}
|
||||
|
||||
const confirmJsonEditor = () => {
|
||||
const cleaned = resetSchemaValues(JSON.parse(JSON.stringify(jsonEditorData.value)));
|
||||
setMappingValue(editingMappingKey.value, cleaned);
|
||||
setMappingValue(editingMappingKey.value, { ...jsonEditorData.value });
|
||||
jsonEditorVisible.value = false;
|
||||
};
|
||||
|
||||
@@ -710,7 +634,6 @@ const resetForm = () => {
|
||||
formData.tokenPredictPrice = 0;
|
||||
formData.requestHeadMapping = {};
|
||||
formData.requestBodyMapping = {};
|
||||
formData.error_message_mapping = {};
|
||||
formData.responseMapping = {};
|
||||
formData.responseBodyMapping = [];
|
||||
formData.tokenMapping = { ...defaultTokenMapping };
|
||||
@@ -738,27 +661,18 @@ const openDialog = async (type: 'add' | 'edit', id?: string) => {
|
||||
formData.apiKey = row.apiKey || '';
|
||||
formData.responseType = row.responseType ?? row.invokeType;
|
||||
formData.enabled = row.enabled ?? true;
|
||||
formData.chatModel = row.chatModel ?? row.ChatModel ?? false;
|
||||
formData.chatModel = row.ChatModel ?? false;
|
||||
formData.maxConcurrency = row.maxConcurrency || 10;
|
||||
formData.maxTokens = row.maxTokens || 4096;
|
||||
formData.tokenPredictPrice = row.tokenPredictPrice ?? 0;
|
||||
formData.requestHeadMapping = row.requestHeadMapping || row.requestMapping || {};
|
||||
formData.requestBodyMapping = row.requestBodyMapping || {};
|
||||
formData.error_message_mapping = row.error_message_mapping || {};
|
||||
formData.responseMapping = row.responseMapping || {};
|
||||
formData.responseBodyMapping = row.responseBodyMapping
|
||||
? (Array.isArray(row.responseBodyMapping) ? row.responseBodyMapping : Object.keys(row.responseBodyMapping))
|
||||
: [];
|
||||
formData.tokenMapping = row.tokenMapping ? { ...defaultTokenMapping, ...row.tokenMapping } : { ...defaultTokenMapping };
|
||||
formData.asyncTaskMapping = row.asyncTaskMapping
|
||||
? {
|
||||
...defaultAsyncTaskMapping,
|
||||
...row.asyncTaskMapping,
|
||||
requestHeadMapping: row.asyncTaskMapping.requestHeadMapping || {},
|
||||
requestBodyMapping: row.asyncTaskMapping.requestBodyMapping || {},
|
||||
responseMapping: row.asyncTaskMapping.responseMapping || {},
|
||||
}
|
||||
: { ...defaultAsyncTaskMapping };
|
||||
formData.asyncTaskMapping = row.asyncTaskMapping ? { ...defaultAsyncTaskMapping, ...row.asyncTaskMapping } : { ...defaultAsyncTaskMapping };
|
||||
formData.lastFrame = row.lastFrame || '';
|
||||
formData.maxDuration = row.maxDuration ?? 0;
|
||||
formData.tokenPredictPriceUnit = row.tokenPredictPriceUnit || '';
|
||||
@@ -781,10 +695,6 @@ const handleSubmit = async () => {
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
// 响应主体映射的 value 直接填对应字段的中文 label(无 label 时兜底为路径本身),不再用空串占位
|
||||
const responseBodyLabelMap = new Map<string, string>(
|
||||
tokenMappingOptions.value.map((o) => [o.path, o.label || o.path])
|
||||
);
|
||||
const baseParams = {
|
||||
modelName: formData.modelName,
|
||||
modelType: Array.isArray(formData.modelType) ? formData.modelType[formData.modelType.length - 1] : formData.modelType,
|
||||
@@ -799,10 +709,9 @@ const handleSubmit = async () => {
|
||||
tokenPredictPrice: formData.tokenPredictPrice,
|
||||
requestHeadMapping: Object.keys(formData.requestHeadMapping).length > 0 ? formData.requestHeadMapping : undefined,
|
||||
requestBodyMapping: Object.keys(formData.requestBodyMapping).length > 0 ? formData.requestBodyMapping : undefined,
|
||||
error_message_mapping: Object.keys(formData.error_message_mapping).length > 0 ? formData.error_message_mapping : undefined,
|
||||
responseMapping: Object.keys(formData.responseMapping).length > 0 ? formData.responseMapping : undefined,
|
||||
responseBodyMapping: formData.responseBodyMapping.length > 0
|
||||
? Object.fromEntries(formData.responseBodyMapping.map((k) => [k, responseBodyLabelMap.get(k) || k]))
|
||||
? Object.fromEntries(formData.responseBodyMapping.map(k => [k, '']))
|
||||
: undefined,
|
||||
};
|
||||
|
||||
@@ -817,15 +726,11 @@ const handleSubmit = async () => {
|
||||
|
||||
if (formData.responseType === 2) {
|
||||
const atm = formData.asyncTaskMapping;
|
||||
const hasAsyncConfig =
|
||||
atm.url ||
|
||||
Object.keys(atm.requestHeadMapping).length > 0 ||
|
||||
Object.keys(atm.requestBodyMapping || {}).length > 0 ||
|
||||
atm.taskId;
|
||||
const hasAsyncConfig = atm.url || Object.keys(atm.requestHeadMapping).length > 0 || atm.taskId;
|
||||
asyncTaskMapping = hasAsyncConfig ? ({ ...atm } as AsyncTaskMapping) : undefined;
|
||||
}
|
||||
|
||||
if (isVideoModel.value && formData.lastFrame) {
|
||||
if (formData.lastFrame) {
|
||||
lastFrame = formData.lastFrame;
|
||||
}
|
||||
let maxDuration: number | undefined;
|
||||
|
||||
@@ -43,9 +43,9 @@
|
||||
<el-tag :type="scope.row.systemModel ? 'warning' : 'success'">{{ scope.row.systemModel ? '系统' : '用户' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="供应商" show-overflow-tooltip>
|
||||
<el-table-column label="访问类型">
|
||||
<template #default="scope">
|
||||
{{ resolveSupplierLabel(scope.row.modelSupplier) }}
|
||||
<el-tag :type="scope.row.PrivateModel ? 'info' : 'primary'">{{ scope.row.PrivateModel ? '服务商模型' : '本地模型' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态">
|
||||
@@ -85,7 +85,7 @@ import { ref, reactive, onMounted } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { Search, Plus } from '@element-plus/icons-vue';
|
||||
import EditModule from './component/editModule.vue';
|
||||
import { listModelManage, getModelManageTypes, deleteModelManage, getModelSupplierList, type ModelManageItem, type ModelTypeTreeNode, type ModelSupplierItem } from '/@/api/settings/modelConfigV2';
|
||||
import { listModelManage, getModelManageTypes, deleteModelManage, type ModelManageItem, type ModelTypeTreeNode } from '/@/api/settings/modelConfigV2';
|
||||
|
||||
const searchParams = reactive({
|
||||
modelName: '',
|
||||
@@ -101,7 +101,6 @@ const pagination = reactive({
|
||||
const modelList = ref<ModelManageItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const modelTypes = ref<ModelTypeTreeNode[]>([]);
|
||||
const supplierList = ref<ModelSupplierItem[]>([]);
|
||||
const editModuleRef = ref();
|
||||
|
||||
/** 级联选择器的选中路径(例:[200, 201]) */
|
||||
@@ -140,22 +139,6 @@ const loadModelTypes = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
/** 加载模型供应商列表 */
|
||||
const loadSupplierList = async () => {
|
||||
try {
|
||||
const res = await getModelSupplierList();
|
||||
supplierList.value = res.data?.list || [];
|
||||
} catch {
|
||||
supplierList.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
/** 根据供应商值显示名称 */
|
||||
const resolveSupplierLabel = (supplier: number | undefined | null): string => {
|
||||
if (supplier === undefined || supplier === null) return '—';
|
||||
return supplierList.value.find((s) => s.value === supplier)?.label ?? String(supplier);
|
||||
};
|
||||
|
||||
/** 获取模型列表 */
|
||||
const fetchModelList = async () => {
|
||||
loading.value = true;
|
||||
@@ -221,7 +204,6 @@ const handleDelete = (row: ModelManageItem) => {
|
||||
|
||||
onMounted(async () => {
|
||||
await loadModelTypes();
|
||||
loadSupplierList();
|
||||
fetchModelList();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -33,9 +33,6 @@
|
||||
import { ref, watch, nextTick } from 'vue';
|
||||
|
||||
export interface FormField {
|
||||
// 不可变字段标识(保存/下游引用的 key);label 仅为展示名,重命名不影响已建立的引用。
|
||||
// 旧数据缺省时退化用 label,保存一次后 field 固化不再随 label 变化
|
||||
field?: string;
|
||||
label: string;
|
||||
type: string; // input | number | textarea | switch | uploadMultiple
|
||||
value: any; // 默认值(uploadMultiple 时存文件 URL 数组或空)
|
||||
@@ -70,12 +67,8 @@ const sync = () => {
|
||||
emit('update:modelValue', clone(fields.value));
|
||||
};
|
||||
|
||||
// 字段唯一标识生成:时间戳 + 递增序号,避免同节点内字段 field 冲突
|
||||
let fieldSeq = 0;
|
||||
const genFieldKey = () => `f_${Date.now().toString(36)}_${++fieldSeq}`;
|
||||
|
||||
const addField = () => {
|
||||
fields.value.push({ field: genFieldKey(), label: '', type: 'input', value: '', required: false });
|
||||
fields.value.push({ label: '', type: 'input', value: '', required: false });
|
||||
sync();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
<template>
|
||||
<!-- 引用上级节点输出:按钮 + 候选面板(叶子字段 + 对象/数组整体);multi 时支持多选 -->
|
||||
<el-popover
|
||||
v-if="canValueSource"
|
||||
placement="bottom"
|
||||
:width="260"
|
||||
trigger="click"
|
||||
popper-class="mf-ref-popover"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
type="primary"
|
||||
class="mf-ref-btn"
|
||||
title="引用上级节点输出"
|
||||
:disabled="!!fieldDef?.runtimeShow || !hasUpstream"
|
||||
>
|
||||
<el-icon><Link /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
<div class="mf-ref-panel">
|
||||
<div v-if="!hasUpstream" class="mf-ref-empty">无可引用的上级节点</div>
|
||||
<template v-else>
|
||||
<div v-for="node in upstreamNodes" :key="node.id" class="mf-ref-node">
|
||||
<div class="mf-ref-node-head">
|
||||
<span class="mf-ref-node-name">{{ node.label }}</span>
|
||||
<el-tag size="small" type="info">{{ node.nodeCode }}</el-tag>
|
||||
</div>
|
||||
<div v-if="node.outputFields.length === 0" class="mf-ref-node-empty">该节点无输出字段</div>
|
||||
<div
|
||||
v-for="of in node.outputFields"
|
||||
:key="`${of.field}-${of.fieldType || 'scalar'}`"
|
||||
class="mf-ref-field"
|
||||
:class="{ 'is-selected': isSelected(node, of) }"
|
||||
@click="toggleValueSource(node, of)"
|
||||
>
|
||||
<span class="mf-ref-check">
|
||||
<el-icon v-if="isSelected(node, of)"><Check /></el-icon>
|
||||
</span>
|
||||
<span v-if="of.fieldType === 'object'" class="mf-ref-type" title="引用对象整体">{} 对象</span>
|
||||
<span v-else-if="of.fieldType === 'array'" class="mf-ref-type" title="引用数组整体">[] 数组</span>
|
||||
<span class="mf-ref-field-text">{{ of.label || of.field }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Check, Link } from '@element-plus/icons-vue';
|
||||
|
||||
defineOptions({ name: 'MfReferenceButton' });
|
||||
|
||||
// 前驱节点的可引用输出字段(fieldType:scalar 叶子 / object、array 整体)
|
||||
interface UpstreamField {
|
||||
field: string;
|
||||
label: string;
|
||||
fieldType?: 'scalar' | 'object' | 'array';
|
||||
}
|
||||
interface UpstreamNodeInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
nodeCode: string;
|
||||
outputFields: UpstreamField[];
|
||||
}
|
||||
|
||||
const props = defineProps<{ fieldDef: any; upstreamNodes?: UpstreamNodeInfo[]; multi?: boolean }>();
|
||||
|
||||
// 可引用:非开关、非固定选项下拉(开关/下拉值来自模型);叶子与对象/数组整体均可引用
|
||||
const canValueSource = computed(() => {
|
||||
const ft = props.fieldDef?.fieldType || 'string';
|
||||
const dt = props.fieldDef?.type || 'string';
|
||||
return ft !== 'switch' && ft !== 'select' && dt !== 'switch';
|
||||
});
|
||||
const hasUpstream = computed(() => (props.upstreamNodes?.length ?? 0) > 0);
|
||||
|
||||
// 当前引用集合(契约统一数组 [{ nodeId, field }];兼容旧 DSL 单对象)
|
||||
const valueSource = computed<any[]>(() => {
|
||||
const vs = props.fieldDef?.valueSource;
|
||||
if (Array.isArray(vs)) return vs;
|
||||
return vs && typeof vs === 'object' && !Array.isArray(vs) ? [vs] : [];
|
||||
});
|
||||
|
||||
const isSelected = (node: UpstreamNodeInfo, of: UpstreamField): boolean =>
|
||||
valueSource.value.some((r) => r && r.nodeId === node.id && r.field === of.field);
|
||||
|
||||
// 点选切换:就地写 fieldDef.valueSource 数组,复用父组件深 watch 上传链路
|
||||
const toggleValueSource = (node: UpstreamNodeInfo, of: UpstreamField) => {
|
||||
if (!props.fieldDef) return;
|
||||
const cur = valueSource.value;
|
||||
if (props.multi) {
|
||||
// 多选:勾选/取消切换,同字段去重
|
||||
if (isSelected(node, of)) {
|
||||
const keep = cur.filter((r) => !(r && r.nodeId === node.id && r.field === of.field));
|
||||
if (keep.length === 0) delete props.fieldDef.valueSource;
|
||||
else props.fieldDef.valueSource = keep;
|
||||
} else {
|
||||
props.fieldDef.valueSource = [...cur, { nodeId: node.id, field: of.field }];
|
||||
}
|
||||
} else {
|
||||
// 单选:点已选字段取消,否则替换为单元素数组
|
||||
if (cur.length === 1 && isSelected(node, of)) {
|
||||
delete props.fieldDef.valueSource;
|
||||
} else {
|
||||
props.fieldDef.valueSource = [{ nodeId: node.id, field: of.field }];
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mf-ref-btn {
|
||||
flex-shrink: 0;
|
||||
margin-left: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- popover teleport 到 body,面板样式需脱离 scoped -->
|
||||
<style lang="scss">
|
||||
.mf-ref-popover {
|
||||
.mf-ref-panel {
|
||||
// 面板超高时内部滚动,避免超出浏览器视口被截掉显示不全
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
.mf-ref-empty {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
padding: 6px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mf-ref-node {
|
||||
& + .mf-ref-node {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
.mf-ref-node-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.mf-ref-node-name {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.mf-ref-node-empty {
|
||||
font-size: 12px;
|
||||
color: #cbd5e1;
|
||||
padding: 2px 0 2px 8px;
|
||||
}
|
||||
|
||||
.mf-ref-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
.mf-ref-check {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
font-size: 12px;
|
||||
color: #3b82f6;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.mf-ref-type {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: #64748b;
|
||||
background: #f1f5f9;
|
||||
border-radius: 3px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.mf-ref-field-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -16,7 +16,6 @@
|
||||
:field-def="childDef"
|
||||
:path="path ? `${path}.attrs.${childKey}` : childKey"
|
||||
:upstream-nodes="upstreamNodes"
|
||||
:multi="multi"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -33,24 +32,39 @@
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="mf-array-flat">
|
||||
<!-- 数组元素直接遍历模板(enumValues/attrs):值与勾选均落在模板上,
|
||||
保存只留 enumValues 一份即可完整回显,不依赖 value 实例 -->
|
||||
<template v-for="(item, idx) in renderedArrayItems()" :key="idx">
|
||||
<template v-if="isObjectDef(item)">
|
||||
<div v-if="isVariantArray" class="mf-variant-head">
|
||||
<!-- 联合数组(enumValues 多模板):全部模板平铺,固定条数,不可增删 -->
|
||||
<template v-if="isVariantArray">
|
||||
<div v-for="(item, idx) in getVariantItems()" :key="idx" class="mf-variant">
|
||||
<div class="mf-variant-head">
|
||||
<span class="mf-variant-name">{{ variantLabel(idx) }}</span>
|
||||
</div>
|
||||
<div v-else-if="renderedArrayItems().length > 1" class="mf-array-flat-index">#{{ idx + 1 }}</div>
|
||||
<ModelField
|
||||
v-for="(subDef, subKey) in item.attrs"
|
||||
:key="subKey"
|
||||
:field-def="subDef"
|
||||
:path="path ? `${path}.enumValues[${idx}].attrs.${subKey}` : String(subKey)"
|
||||
:upstream-nodes="upstreamNodes"
|
||||
:multi="multi"
|
||||
/>
|
||||
<template v-if="isObjectDef(item)">
|
||||
<ModelField
|
||||
v-for="(subDef, subKey) in item.attrs"
|
||||
:key="subKey"
|
||||
:field-def="subDef"
|
||||
:path="path ? `${path}.value[${idx}].attrs.${subKey}` : String(subKey)"
|
||||
:upstream-nodes="upstreamNodes"
|
||||
/>
|
||||
</template>
|
||||
<el-input v-else :model-value="item" @input="(v: any) => setArrayPrimitive(idx, v)" size="small" />
|
||||
</div>
|
||||
</template>
|
||||
<!-- 同构数组:保持现有平铺逻辑 -->
|
||||
<template v-else>
|
||||
<template v-for="(item, idx) in getArrayValue()" :key="idx">
|
||||
<template v-if="isObjectDef(item)">
|
||||
<div v-if="getArrayValue().length > 1" class="mf-array-flat-index">#{{ idx + 1 }}</div>
|
||||
<ModelField
|
||||
v-for="(subDef, subKey) in item.attrs"
|
||||
:key="subKey"
|
||||
:field-def="subDef"
|
||||
:path="path ? `${path}.value[${idx}].attrs.${subKey}` : String(subKey)"
|
||||
:upstream-nodes="upstreamNodes"
|
||||
/>
|
||||
</template>
|
||||
<el-input v-else :model-value="item" @input="(v: any) => setArrayPrimitive(idx, v)" size="small" />
|
||||
</template>
|
||||
<el-input v-else :model-value="getTemplatePrimitive(item)" @input="(v: any) => setTemplatePrimitive(item, v)" size="small" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,24 +87,52 @@
|
||||
class="mf-leaf-expose"
|
||||
>表单展示</el-checkbox>
|
||||
</el-tooltip>
|
||||
<!-- 引用上级输出与「表单展示」勾选同级:所有叶子参数(含数组元素内字段)都可引用;multi 时支持多选 -->
|
||||
<MfReferenceButton :field-def="def" :upstream-nodes="upstreamNodes" :multi="multi" />
|
||||
<el-popover
|
||||
v-if="canValueSource"
|
||||
placement="bottom"
|
||||
:width="260"
|
||||
trigger="click"
|
||||
popper-class="mf-ref-popover"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
type="primary"
|
||||
class="mf-ref-btn"
|
||||
title="引用上级节点输出"
|
||||
:disabled="!!def.runtimeShow || !hasUpstream"
|
||||
>
|
||||
<el-icon><Link /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
<div class="mf-ref-panel">
|
||||
<div v-if="!hasUpstream" class="mf-ref-empty">无可引用的上级节点</div>
|
||||
<template v-else>
|
||||
<div v-for="node in upstreamNodes" :key="node.id" class="mf-ref-node">
|
||||
<div class="mf-ref-node-head">
|
||||
<span class="mf-ref-node-name">{{ node.label }}</span>
|
||||
<el-tag size="small" type="info">{{ node.nodeCode }}</el-tag>
|
||||
</div>
|
||||
<div v-if="node.outputFields.length === 0" class="mf-ref-node-empty">该节点无输出字段</div>
|
||||
<div
|
||||
v-for="of in node.outputFields"
|
||||
:key="of.field"
|
||||
class="mf-ref-field"
|
||||
@click="setValueSource(node, of)"
|
||||
>
|
||||
{{ of.label || of.field }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-popover>
|
||||
</div>
|
||||
<div v-if="hasValueSource" class="mf-ref-bound" :class="{ 'is-invalid': valueSourceInvalid }">
|
||||
<div class="mf-ref-tags">
|
||||
<el-tag
|
||||
v-for="(ref, i) in valueSourceList"
|
||||
:key="`${ref.nodeId}-${ref.field}-${i}`"
|
||||
size="small"
|
||||
class="mf-ref-tag"
|
||||
:type="ref.invalid ? 'danger' : 'primary'"
|
||||
closable
|
||||
@close="removeValueSource(ref)"
|
||||
>{{ ref.label }}</el-tag>
|
||||
</div>
|
||||
<el-button size="small" text type="danger" @click="clearValueSource">清空</el-button>
|
||||
<span class="mf-ref-bound-text">已引用:{{ valueSourceLabel }}</span>
|
||||
<el-button size="small" text type="danger" @click="clearValueSource">清除</el-button>
|
||||
</div>
|
||||
<div v-if="!hasValueSource && !isUpload" class="mf-leaf-ctrl">
|
||||
<div v-else-if="!isUpload" class="mf-leaf-ctrl">
|
||||
<el-input-number
|
||||
v-if="isNumber"
|
||||
:model-value="numberValue"
|
||||
@@ -134,8 +176,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue';
|
||||
import { FolderOpened, List, QuestionFilled } from '@element-plus/icons-vue';
|
||||
import MfReferenceButton from './MfReferenceButton.vue';
|
||||
import { FolderOpened, List, QuestionFilled, Link } from '@element-plus/icons-vue';
|
||||
import { deepClone, normalizeArrayItem } from './modelParamUtils';
|
||||
|
||||
defineOptions({ name: 'ModelField' });
|
||||
|
||||
@@ -144,10 +186,10 @@ interface UpstreamNodeInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
nodeCode: string;
|
||||
outputFields: { field: string; label: string; fieldType?: 'scalar' | 'object' | 'array' }[];
|
||||
outputFields: { field: string; label: string }[];
|
||||
}
|
||||
|
||||
const props = defineProps<{ fieldDef: any; path?: string; upstreamNodes?: UpstreamNodeInfo[]; multi?: boolean }>();
|
||||
const props = defineProps<{ fieldDef: any; path?: string; upstreamNodes?: UpstreamNodeInfo[] }>();
|
||||
|
||||
// 用 computed 实时取当前 props,避免 internalSync 替换对象树后仍持有旧引用
|
||||
const def = computed(() => props.fieldDef);
|
||||
@@ -178,57 +220,46 @@ const isTextarea = computed(() => isLeaf.value && fieldType.value === 'textarea'
|
||||
const isUpload = computed(() => isLeaf.value && fieldType.value === 'upload');
|
||||
|
||||
// ===== 引用上级节点输出 =====
|
||||
// 引用按钮与候选面板在 MfReferenceButton 中统一渲染(叶子与对象/数组整体均可引用)
|
||||
// valueSource 契约统一数组 [{ nodeId, field }];multi(节点 isMultiParameter)时支持多选
|
||||
const valueSourceList = computed(() => {
|
||||
const vs = def.value?.valueSource;
|
||||
// 兼容旧 DSL 单对象 { nodeId, field }:统一按数组读取
|
||||
if (!vs || typeof vs !== 'object') return [];
|
||||
const arr = Array.isArray(vs) ? vs : [vs];
|
||||
return arr.map((r: any) => {
|
||||
const node = props.upstreamNodes?.find((n) => n.id === r?.nodeId);
|
||||
// 引用标签只展示字段中文名(不带「节点名.」前缀)
|
||||
const fieldLabel = node?.outputFields.find((f) => f.field === r?.field)?.label || r?.field || '';
|
||||
const invalid = !node || !node.outputFields.some((f) => f.field === r?.field);
|
||||
return { nodeId: r?.nodeId, field: r?.field, label: fieldLabel, invalid };
|
||||
});
|
||||
// 可引用:叶子 + 非开关 + 非固定选项下拉(开关/下拉值来自模型;上传类型同样支持引用上级输出)
|
||||
const canValueSource = computed(() => isLeaf.value && !isSwitch.value && !isSelect.value);
|
||||
const hasUpstream = computed(() => (props.upstreamNodes?.length ?? 0) > 0);
|
||||
const hasValueSource = computed(() => !!def.value?.valueSource);
|
||||
// 已引用时的展示文案:{节点label}.{字段label}
|
||||
const valueSourceLabel = computed(() => {
|
||||
const ref = def.value?.valueSource;
|
||||
if (!ref) return '';
|
||||
const node = props.upstreamNodes?.find((n) => n.id === ref.nodeId);
|
||||
const nodeName = node?.label || ref.nodeId;
|
||||
const fieldLabel = node?.outputFields.find((f) => f.field === ref.field)?.label || ref.field;
|
||||
return `${nodeName}.${fieldLabel}`;
|
||||
});
|
||||
const hasValueSource = computed(() => valueSourceList.value.length > 0);
|
||||
// 任一引用失效即整体标红(失效项逐条展示,由失效清理 watch 自动移除)
|
||||
const valueSourceInvalid = computed(() => valueSourceList.value.some((r) => r.invalid));
|
||||
|
||||
// 失效引用自动清理,避免保存脏数据(过滤失效项;全部失效则删除整个 valueSource,复用深 watch 上传链路)
|
||||
// 引用是否已失效:上游节点被删除,或其输出字段已不存在(换模型/换配置导致 schema 变化)
|
||||
const valueSourceInvalid = computed(() => {
|
||||
const ref = def.value?.valueSource;
|
||||
if (!ref) return false;
|
||||
const node = props.upstreamNodes?.find((n) => n.id === ref.nodeId);
|
||||
if (!node) return true;
|
||||
return !node.outputFields.some((f) => f.field === ref.field);
|
||||
});
|
||||
|
||||
// 失效引用自动清理,避免保存脏数据(就地删除 valueSource,复用深 watch 上传链路)
|
||||
watch(
|
||||
() => valueSourceList.value,
|
||||
(list) => {
|
||||
if (!def.value) return;
|
||||
const vs = def.value.valueSource;
|
||||
if (!vs || typeof vs !== 'object') return;
|
||||
// 兼容旧 DSL 单对象:按数组处理,清理后统一写回数组
|
||||
const arr = Array.isArray(vs) ? vs : [vs];
|
||||
if (!list.some((r) => r.invalid)) return;
|
||||
const keep = arr.filter((r: any) => {
|
||||
const node = props.upstreamNodes?.find((n) => n.id === r?.nodeId);
|
||||
return node && node.outputFields.some((f) => f.field === r?.field);
|
||||
});
|
||||
if (keep.length === 0) delete def.value.valueSource;
|
||||
else def.value.valueSource = keep;
|
||||
valueSourceInvalid,
|
||||
(invalid) => {
|
||||
if (invalid && def.value) {
|
||||
delete def.value.valueSource;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 删除单个引用(标签 closable)
|
||||
const removeValueSource = (ref: any) => {
|
||||
// 选中前驱节点输出字段:就地写 def.valueSource,复用深 watch 上传链路
|
||||
const setValueSource = (node: UpstreamNodeInfo, of: { field: string; label: string }) => {
|
||||
if (!def.value) return;
|
||||
const vs = def.value.valueSource;
|
||||
if (!vs || typeof vs !== 'object') return;
|
||||
const arr = Array.isArray(vs) ? vs : [vs];
|
||||
const keep = arr.filter((r: any) => !(r?.nodeId === ref.nodeId && r?.field === ref.field));
|
||||
if (keep.length === 0) delete def.value.valueSource;
|
||||
else def.value.valueSource = keep;
|
||||
def.value.valueSource = { nodeId: node.id, field: of.field };
|
||||
};
|
||||
|
||||
// 清空全部引用
|
||||
const clearValueSource = () => {
|
||||
if (!def.value) return;
|
||||
delete def.value.valueSource;
|
||||
@@ -281,26 +312,22 @@ const arrayTemplates = computed<any[]>(() => {
|
||||
return [];
|
||||
});
|
||||
|
||||
const getArrayValue = (): any[] => {
|
||||
if (!def.value) return [];
|
||||
if (!Array.isArray(def.value.value)) {
|
||||
def.value.value = [];
|
||||
}
|
||||
// value 为空时(后端仅定义结构未给内容)用模板补一条空结构,保证字段可展示、可填写
|
||||
if (def.value.value.length === 0 && arrayTemplates.value.length > 0) {
|
||||
def.value.value.push(normalizeArrayItem(deepClone(arrayTemplates.value[0])));
|
||||
}
|
||||
return def.value.value;
|
||||
};
|
||||
|
||||
const isObjectDef = (item: any) => !!item && typeof item === 'object' && item.type === 'object' && item.attrs && typeof item.attrs === 'object';
|
||||
|
||||
// 数组渲染统一以模板(enumValues/attrs)为数据源:值与勾选均落在模板上,
|
||||
// 不受 props 同步重建 localParams(深拷贝)影响,保存只留 enumValues 一份即可完整回显
|
||||
const renderedArrayItems = (): any[] => {
|
||||
const templates = arrayTemplates.value;
|
||||
// 多模板(variant):懒填 type/role 标识值到模板(幂等),保证展示与保存有标识值
|
||||
if (templates.length > 1) templates.forEach(autoFillVariant);
|
||||
return templates;
|
||||
};
|
||||
|
||||
// 原始值数组元素:值直接读写模板原始值对象上的 value
|
||||
const getTemplatePrimitive = (item: any): any => {
|
||||
if (!item || typeof item !== 'object') return '';
|
||||
return item.value !== undefined ? item.value : item.defaultValue ?? '';
|
||||
};
|
||||
|
||||
const setTemplatePrimitive = (item: any, v: any): void => {
|
||||
if (!item || typeof item !== 'object') return;
|
||||
item.value = v ?? '';
|
||||
const setArrayPrimitive = (idx: number, val: any) => {
|
||||
getArrayValue()[idx] = val ?? '';
|
||||
};
|
||||
|
||||
// ===== array:联合数组(enumValues 多模板)→ 全部模板平铺,固定条数,不可增删 =====
|
||||
@@ -339,6 +366,25 @@ const autoFillVariant = (tpl: any): void => {
|
||||
}
|
||||
};
|
||||
|
||||
// 联合数组渲染条目:value 与 enumValues 模板按索引一一对应(懒补模板),保证固定条数
|
||||
const getVariantItems = (): any[] => {
|
||||
if (!isVariantArray.value || !def.value) return [];
|
||||
const templates = Array.isArray(def.value.enumValues) ? def.value.enumValues : [];
|
||||
if (!Array.isArray(def.value.value)) def.value.value = [];
|
||||
templates.forEach((tpl: any, i: number) => {
|
||||
const existing = def.value.value[i];
|
||||
if (!existing || typeof existing !== 'object') {
|
||||
const item = normalizeArrayItem(deepClone(tpl));
|
||||
autoFillVariant(item);
|
||||
def.value.value[i] = item;
|
||||
}
|
||||
});
|
||||
if (def.value.value.length > templates.length) {
|
||||
def.value.value = def.value.value.slice(0, templates.length);
|
||||
}
|
||||
return def.value.value;
|
||||
};
|
||||
|
||||
const variantLabel = (idx: number): string => templateLabel(def.value?.enumValues?.[idx], idx);
|
||||
</script>
|
||||
|
||||
@@ -477,26 +523,20 @@ const variantLabel = (idx: number): string => templateLabel(def.value?.enumValue
|
||||
|
||||
.mf-ref-bound {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
padding: 4px 8px;
|
||||
|
||||
.mf-ref-tags {
|
||||
.mf-ref-bound-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
|
||||
.mf-ref-tag {
|
||||
:deep(.el-tag__content) {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.is-invalid {
|
||||
@@ -517,9 +557,6 @@ const variantLabel = (idx: number): string => templateLabel(def.value?.enumValue
|
||||
<style lang="scss">
|
||||
.mf-ref-popover {
|
||||
.mf-ref-panel {
|
||||
// 面板超高时内部滚动,避免超出浏览器视口被截掉显示不全
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
.mf-ref-empty {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
:field-def="def"
|
||||
:path="key"
|
||||
:upstream-nodes="upstreamNodes"
|
||||
:multi="multi"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,7 +33,7 @@ interface UpstreamNodeInfo {
|
||||
outputFields: { field: string; label: string }[];
|
||||
}
|
||||
|
||||
const props = defineProps<{ modelRequestParams: Record<string, any> | null; upstreamNodes?: UpstreamNodeInfo[]; multi?: boolean }>();
|
||||
const props = defineProps<{ modelRequestParams: Record<string, any> | null; upstreamNodes?: UpstreamNodeInfo[] }>();
|
||||
const emit = defineEmits<{ 'update:modelRequestParams': [Record<string, any> | null] }>();
|
||||
|
||||
const localParams = ref<Record<string, any>>({});
|
||||
|
||||
@@ -22,11 +22,7 @@
|
||||
@click="handleSelectModel(model)"
|
||||
>
|
||||
<div class="model-card-header">
|
||||
<div class="model-type">
|
||||
{{ getModelTypeName(model.modelType) }}
|
||||
<el-tag v-if="model.systemModel" size="small" type="warning" class="model-owner-tag">内置</el-tag>
|
||||
<el-tag v-else size="small" type="success" class="model-owner-tag">我的</el-tag>
|
||||
</div>
|
||||
<div class="model-type">{{ getModelTypeName(model.modelType) }}</div>
|
||||
<el-icon v-if="selectedModel?.id === model.id" class="check-icon" color="#67c23a">
|
||||
<CircleCheck />
|
||||
</el-icon>
|
||||
@@ -60,56 +56,16 @@
|
||||
<el-button type="primary" @click="handleConfirm" :disabled="!selectedModel">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 系统内置模型:填写 API Key,经修改接口转换为用户模型后自动绑定 -->
|
||||
<el-dialog
|
||||
v-model="apiKeyDialogVisible"
|
||||
title="填写 API Key"
|
||||
width="480px"
|
||||
append-to-body
|
||||
:close-on-click-modal="false"
|
||||
@close="handleApiKeyClose"
|
||||
>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="该模型为系统内置模型,填写你的 API Key 后将创建一条用户模型并自动绑定到当前节点。"
|
||||
class="api-key-alert"
|
||||
/>
|
||||
<el-form label-position="top" class="api-key-form">
|
||||
<el-form-item label="API Key" required>
|
||||
<el-input
|
||||
v-model="apiKeyForm.apiKey"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入你的 API Key"
|
||||
@keyup.enter="handleApiKeyConfirm"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleApiKeyClose">取消</el-button>
|
||||
<el-button type="primary" :loading="converting" @click="handleApiKeyConfirm">确认并转换</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Search, CircleCheck } from '@element-plus/icons-vue';
|
||||
import { getWorkflowModelList, type WorkflowModelItem } from '/@/api/settings/workflow';
|
||||
import { updateModelManage } from '/@/api/settings/modelConfigV2';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
defaultModel?: WorkflowModelItem | null;
|
||||
// 非空时仅列出同类型模型(首页「重新选择模型」场景);工作流管理不传,行为不变
|
||||
sameTypeModelType?: string | number | null;
|
||||
// 系统模型是否必须填写 API Key(默认 true)。
|
||||
// 超级管理员绘制/编辑模板工作流时传 false:选系统模型直接选中,不再弹 API Key。
|
||||
systemModelRequireKey?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -120,8 +76,6 @@ interface Emits {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
defaultModel: null,
|
||||
sameTypeModelType: null,
|
||||
systemModelRequireKey: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
@@ -132,11 +86,6 @@ const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
|
||||
const modelList = ref<WorkflowModelItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const selectedModel = ref<WorkflowModelItem | null>(null);
|
||||
// 系统内置模型(systemModel === true)转用户模型:填 API Key → 调修改接口,后端自动克隆出新用户模型
|
||||
const apiKeyDialogVisible = ref(false);
|
||||
const converting = ref(false);
|
||||
const pendingCloneModel = ref<WorkflowModelItem | null>(null);
|
||||
const apiKeyForm = reactive({ apiKey: '' });
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
@@ -194,9 +143,6 @@ const fetchModelList = async () => {
|
||||
pageNum: pagination.pageNum,
|
||||
pageSize: pagination.pageSize,
|
||||
modelName: searchParams.modelName || undefined,
|
||||
...(props.sameTypeModelType !== undefined && props.sameTypeModelType !== null && props.sameTypeModelType !== ''
|
||||
? { isSameType: true, modelType: props.sameTypeModelType }
|
||||
: {}),
|
||||
};
|
||||
const res = await getWorkflowModelList(params);
|
||||
modelList.value = res.data?.list || [];
|
||||
@@ -224,14 +170,6 @@ const handlePageChange = () => {
|
||||
};
|
||||
|
||||
const handleSelectModel = (model: WorkflowModelItem) => {
|
||||
// 系统内置模型:默认需填写 API Key,经修改接口转成用户模型后再绑定;
|
||||
// 超级管理员绘制模板(systemModelRequireKey=false)时直接选中,模板保留系统模型供用户使用时再补 Key
|
||||
if (model.systemModel && props.systemModelRequireKey) {
|
||||
pendingCloneModel.value = model;
|
||||
apiKeyForm.apiKey = '';
|
||||
apiKeyDialogVisible.value = true;
|
||||
return;
|
||||
}
|
||||
selectedModel.value = model;
|
||||
};
|
||||
|
||||
@@ -242,68 +180,6 @@ const handleConfirm = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 确认 API Key:调用修改接口,后端克隆出用户模型并直接返回,前端立即绑定
|
||||
const handleApiKeyConfirm = async () => {
|
||||
if (!apiKeyForm.apiKey.trim()) {
|
||||
ElMessage.warning('请输入 API Key');
|
||||
return;
|
||||
}
|
||||
if (!pendingCloneModel.value?.id) return;
|
||||
converting.value = true;
|
||||
try {
|
||||
const src = pendingCloneModel.value;
|
||||
const res = await updateModelManage({
|
||||
id: src.id,
|
||||
// 传递系统模型全部配置,供后端克隆用户模型时继承
|
||||
modelName: src.modelName,
|
||||
modelType: src.modelType,
|
||||
modelSupplier: src.modelSupplier,
|
||||
baseUrl: src.baseUrl,
|
||||
responseType: src.responseType ?? src.invokeType,
|
||||
apiKey: apiKeyForm.apiKey.trim(),
|
||||
enabled: src.enabled,
|
||||
chatModel: src.ChatModel ?? src.chatModel,
|
||||
maxConcurrency: src.maxConcurrency,
|
||||
maxTokens: src.maxTokens,
|
||||
tokenPredictPrice: src.tokenPredictPrice,
|
||||
requestHeadMapping: src.requestHeadMapping ?? src.requestMapping,
|
||||
requestBodyMapping: src.requestBodyMapping,
|
||||
responseMapping: src.responseMapping,
|
||||
responseBodyMapping: src.responseBodyMapping,
|
||||
...(src.tokenMapping ? { tokenMapping: src.tokenMapping } : {}),
|
||||
...(src.asyncTaskMapping ? { asyncTaskMapping: src.asyncTaskMapping } : {}),
|
||||
...(src.lastFrame ? { lastFrame: src.lastFrame } : {}),
|
||||
...(src.maxDuration ? { maxDuration: src.maxDuration } : {}),
|
||||
...(src.tokenPredictPriceUnit ? { tokenPredictPriceUnit: src.tokenPredictPriceUnit } : {}),
|
||||
} as any);
|
||||
// 返回结构为 { data: { modelManage: {...} } },新用户模型 id 在 modelManage 中
|
||||
const newModelManage = res?.data?.modelManage || res?.data;
|
||||
if (!newModelManage?.id) throw new Error('接口未返回新的用户模型');
|
||||
// 后端返回的 modelManage 仅带新 id/apiKey,modelName/requestBodyMapping 等为空。
|
||||
// 用原系统模型完整配置 + 新 id 合并,保证绑定后模型名、参数表单、下游引用、isSameType 过滤都正常。
|
||||
const newModel: WorkflowModelItem = {
|
||||
...(pendingCloneModel.value || {}),
|
||||
id: newModelManage.id,
|
||||
systemModel: false,
|
||||
apiKey: apiKeyForm.apiKey.trim(),
|
||||
};
|
||||
apiKeyDialogVisible.value = false;
|
||||
pendingCloneModel.value = null;
|
||||
emit('confirm', newModel);
|
||||
handleClose();
|
||||
ElMessage.success('已创建用户模型并绑定');
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '模型转换失败,请重试');
|
||||
} finally {
|
||||
converting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiKeyClose = () => {
|
||||
apiKeyDialogVisible.value = false;
|
||||
pendingCloneModel.value = null;
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false;
|
||||
selectedModel.value = null;
|
||||
@@ -356,19 +232,13 @@ const handleClose = () => {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.model-type {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
.model-owner-tag {
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
.check-icon {
|
||||
font-size: 20px;
|
||||
@@ -402,12 +272,4 @@ const handleClose = () => {
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.api-key-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.api-key-form {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,39 +13,8 @@
|
||||
@update:model-value="updateNodeDesc"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 前置方法 / 后置方法(节点库 preToolOption / postToolOption 配置定义,值保存到节点顶层 preTool / postTool,与 outputConfig 平级) -->
|
||||
<el-form-item v-if="preToolDef" :label="preToolDef.label || '前置方法'">
|
||||
<el-select
|
||||
:model-value="selectedNode.data?.preTool ?? ''"
|
||||
@update:model-value="updatePreTool"
|
||||
clearable
|
||||
class="w100"
|
||||
:placeholder="preToolDef.required ? '必选' : '选填'"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in preToolDef.options || []"
|
||||
:key="opt.key || opt.value"
|
||||
:label="opt.value ?? opt.key"
|
||||
:value="opt.key ?? opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="postToolDef" :label="postToolDef.label || '后置方法'">
|
||||
<el-select
|
||||
:model-value="selectedNode.data?.postTool ?? ''"
|
||||
@update:model-value="updatePostTool"
|
||||
clearable
|
||||
class="w100"
|
||||
:placeholder="postToolDef.required ? '必选' : '选填'"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in postToolDef.options || []"
|
||||
:key="opt.key || opt.value"
|
||||
:label="opt.value ?? opt.key"
|
||||
:value="opt.key ?? opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-form-item label="节点类型">
|
||||
<el-tag>{{ selectedNode.data?.nodeCode }}</el-tag>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 开始节点:运行表单字段摘要(来自各模型节点勾选的「表单展示」字段) -->
|
||||
@@ -77,7 +46,7 @@
|
||||
</el-form-item>
|
||||
|
||||
<!-- 贴片布局开关 -->
|
||||
<el-form-item v-if="nodeConfig?.patchLayout" label="贴片布局">
|
||||
<el-form-item v-if="isVideoModelSelected" label="贴片布局">
|
||||
<el-switch
|
||||
:model-value="selectedNode.data?.patchLayout ?? false"
|
||||
@update:model-value="emit('update:patchLayout', $event)"
|
||||
@@ -116,86 +85,62 @@
|
||||
<template v-if="nodeConfig?.formConfig && nodeConfig.formConfig.length > 0">
|
||||
<el-divider content-position="left">节点参数</el-divider>
|
||||
<template v-for="field in nodeConfig.formConfig" :key="field.field">
|
||||
<el-form-item :required="field.required">
|
||||
<template #label>
|
||||
<span class="dyn-field-label">{{ field.label }}</span>
|
||||
<!-- 脚本转写节点的视频总时长:支持引用上级节点输出(引用后隐藏输入、显示引用来源) -->
|
||||
<MfReferenceButton v-if="isTotalDurationField(field)" :field-def="fieldRefDef(field)" :upstream-nodes="upstreamNodes" :multi="false" />
|
||||
</template>
|
||||
<!-- 脚本转写节点的视频总时长:引用上级输出后隐藏输入控件,显示引用来源标签 -->
|
||||
<template v-if="isTotalDurationField(field) && hasFieldRef(field.field)">
|
||||
<div class="mf-ref-bound">
|
||||
<div class="mf-ref-tags">
|
||||
<el-tag
|
||||
v-for="(ref, i) in fieldRefList(field.field)"
|
||||
:key="`${ref.nodeId}-${ref.field}-${i}`"
|
||||
size="small"
|
||||
class="mf-ref-tag"
|
||||
:type="ref.invalid ? 'danger' : 'primary'"
|
||||
closable
|
||||
@close="removeFieldRef(field, ref)"
|
||||
>{{ ref.label }}</el-tag>
|
||||
</div>
|
||||
<el-button size="small" text type="danger" @click="clearFieldRef(field)">清空</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-input
|
||||
v-if="field.type === 'input' || field.type === 'string'"
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="updateFieldValue(field.field, $event)"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
<el-form-item :label="field.label" :required="field.required">
|
||||
<el-input
|
||||
v-if="field.type === 'input' || field.type === 'string'"
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="updateFieldValue(field.field, $event)"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
/>
|
||||
<el-input-number
|
||||
v-else-if="field.type === 'number' || field.type === 'inputNumber'"
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="updateFieldValue(field.field, $event)"
|
||||
class="w100"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="field.type === 'textarea'"
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="updateFieldValue(field.field, $event)"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
/>
|
||||
<el-switch
|
||||
v-else-if="field.type === 'switch'"
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="updateFieldValue(field.field, $event)"
|
||||
/>
|
||||
<el-select
|
||||
v-else-if="field.type === 'select' && field.options"
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="updateSelectFieldValue(field, $event)"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
class="w100"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in field.options"
|
||||
:key="opt.key || opt.value"
|
||||
:label="opt.value ?? opt.key"
|
||||
:value="opt.key ?? opt.value"
|
||||
/>
|
||||
<el-input-number
|
||||
v-else-if="field.type === 'number' || field.type === 'inputNumber'"
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="updateFieldValue(field.field, $event)"
|
||||
class="w100"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="field.type === 'textarea'"
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="updateFieldValue(field.field, $event)"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
/>
|
||||
<el-switch
|
||||
v-else-if="field.type === 'switch'"
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="updateFieldValue(field.field, $event)"
|
||||
/>
|
||||
<el-select
|
||||
v-else-if="field.type === 'select' && field.options"
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="updateSelectFieldValue(field, $event)"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
class="w100"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in field.options"
|
||||
:key="opt.key || opt.value"
|
||||
:label="opt.value ?? opt.key"
|
||||
:value="opt.key ?? opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<KeyValueEditor
|
||||
v-else-if="field.type === 'keyValue'"
|
||||
:model-value="getKeyValueValue(field.field)"
|
||||
@update:model-value="(v: any) => updateFieldValue(field.field, v)"
|
||||
/>
|
||||
<el-button
|
||||
v-else-if="field.type === 'schemaJson'"
|
||||
@click="openSchemaEditor(field.field)"
|
||||
style="width: 100%"
|
||||
>配置</el-button>
|
||||
<el-input
|
||||
v-else
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="updateFieldValue(field.field, $event)"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
/>
|
||||
</template>
|
||||
</el-select>
|
||||
<KeyValueEditor
|
||||
v-else-if="field.type === 'keyValue'"
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="(v: any) => updateFieldValue(field.field, v)"
|
||||
/>
|
||||
<el-button
|
||||
v-else-if="field.type === 'schemaJson'"
|
||||
@click="openSchemaEditor(field.field)"
|
||||
style="width: 100%"
|
||||
>配置</el-button>
|
||||
<el-input
|
||||
v-else
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="updateFieldValue(field.field, $event)"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 下拉选中选项的嵌套配置(responseType 级联) -->
|
||||
@@ -239,7 +184,7 @@
|
||||
</el-select>
|
||||
<KeyValueEditor
|
||||
v-else-if="sub.type === 'keyValue'"
|
||||
:model-value="getExpandKeyValueValue(field.field, sub.field)"
|
||||
:model-value="getExpandValue(field.field, sub.field)"
|
||||
@update:model-value="(v: any) => updateExpandValue(field.field, sub.field, v)"
|
||||
/>
|
||||
<el-button
|
||||
@@ -257,28 +202,6 @@
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 子流程配置(仅 sub_flow 节点):选择已有工作流引入其开始参数 -->
|
||||
<template v-if="isSubFlowNode">
|
||||
<el-divider content-position="left">子流程配置</el-divider>
|
||||
<el-form-item label="引入工作流">
|
||||
<el-button type="primary" plain @click="emit('openWorkflowSelector')" style="width: 100%">选择工作流</el-button>
|
||||
<div v-if="subFlowConfig" class="selected-tag">
|
||||
<el-tag type="success" size="large" closable @close="emit('removeWorkflow')">
|
||||
{{ subFlowConfig.workflowName || subFlowConfig.workflowId }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<template v-if="subFlowConfig">
|
||||
<el-divider content-position="left">引入参数</el-divider>
|
||||
<SubFlowParams
|
||||
:fields="subFlowConfig.fields"
|
||||
:upstream-nodes="upstreamNodes"
|
||||
:multi="!!nodeConfig?.isMultiParameter"
|
||||
@update:fields="updateSubFlowFields"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 自定义表单字段(formConfigOption,如 form 节点) -->
|
||||
<template v-if="nodeConfig?.formConfigOption">
|
||||
<el-divider content-position="left">自定义字段</el-divider>
|
||||
@@ -301,7 +224,6 @@
|
||||
<ModelParamsForm
|
||||
:model-request-params="selectedNode.data.modelConfig.modelRequestParams"
|
||||
:upstream-nodes="upstreamNodes"
|
||||
:multi="!!nodeConfig?.isMultiParameter"
|
||||
@update:model-request-params="updateModelRequestParams"
|
||||
/>
|
||||
</template>
|
||||
@@ -327,15 +249,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import type { Node } from '@vue-flow/core';
|
||||
import KeyValueEditor from './KeyValueEditor.vue';
|
||||
import MfReferenceButton from './MfReferenceButton.vue';
|
||||
import FormFieldsEditor, { type FormField } from './FormFieldsEditor.vue';
|
||||
import ModelParamsForm from './ModelParamsForm.vue';
|
||||
import PromptEditor from './PromptEditor.vue';
|
||||
import SubFlowParams from './SubFlowParams.vue';
|
||||
import type { SubFlowField } from './subFlowTypes';
|
||||
import { JsonEditor } from '/@/components/json-schema-editor';
|
||||
|
||||
interface NodeData {
|
||||
@@ -349,25 +268,17 @@ interface NodeData {
|
||||
negativePrompt?: string;
|
||||
patchLayout?: boolean;
|
||||
isSaveFile?: boolean;
|
||||
preTool?: string | null;
|
||||
postTool?: string | null;
|
||||
runFormFields?: any[];
|
||||
// 子流程节点(sub_flow):引入的工作流配置(workflowId + 引入参数)
|
||||
subFlowConfig?: any;
|
||||
}
|
||||
|
||||
interface NodeConfig {
|
||||
formConfig: any[];
|
||||
preToolOption: any[];
|
||||
postToolOption: any[];
|
||||
isMultiParameter: boolean;
|
||||
modelConfigOption: boolean;
|
||||
formConfigOption: boolean;
|
||||
skillOption: boolean;
|
||||
promptOption: boolean;
|
||||
negativePromptOption: boolean;
|
||||
isSaveFileOption: boolean;
|
||||
patchLayout: boolean;
|
||||
}
|
||||
|
||||
// 前驱节点的可引用输出字段(供模型参数「引用上级节点输出」)
|
||||
@@ -384,17 +295,17 @@ const props = defineProps<{
|
||||
upstreamNodes?: UpstreamNodeInfo[];
|
||||
}>();
|
||||
|
||||
const isVideoModelSelected = computed(() => {
|
||||
const t = Number(props.selectedNode?.data?.modelConfig?.modelType);
|
||||
return t >= 600 && t <= 605;
|
||||
});
|
||||
|
||||
// 开始节点不展示节点描述
|
||||
const isStartNode = computed(() => props.selectedNode?.data?.nodeCode === '__start__');
|
||||
|
||||
// 开始节点运行表单字段摘要(数据来自 index.vue 聚合写回)
|
||||
const runFormFields = computed<any[]>(() => props.selectedNode?.data?.runFormFields || []);
|
||||
|
||||
// 子流程节点(sub_flow):是否当前选中节点为子流程
|
||||
const isSubFlowNode = computed(() => props.selectedNode?.data?.nodeCode === 'sub_flow');
|
||||
// 子流程引入配置(编辑器态 subFlowConfig;未引入则为 null)
|
||||
const subFlowConfig = computed<any>(() => props.selectedNode?.data?.subFlowConfig || null);
|
||||
|
||||
// fieldType 可读标签
|
||||
const fieldTypeLabel = (ft: string) => {
|
||||
const map: Record<string, string> = {
|
||||
@@ -419,8 +330,6 @@ const emit = defineEmits<{
|
||||
(e: 'openSkillSelector'): void;
|
||||
(e: 'removeSkill'): void;
|
||||
(e: 'update:patchLayout', value: boolean): void;
|
||||
(e: 'openWorkflowSelector'): void;
|
||||
(e: 'removeWorkflow'): void;
|
||||
}>();
|
||||
|
||||
// Schema 编辑器弹窗
|
||||
@@ -524,71 +433,12 @@ const updateModelRequestParams = (params: Record<string, any> | null) => {
|
||||
emit('update:selectedNode', updatedNode);
|
||||
};
|
||||
|
||||
// 前置/后置方法配置定义(节点库 preToolOption / postToolOption,仅 model 节点提供;取首个配置项渲染)
|
||||
const preToolDef = computed(() => {
|
||||
const defs = props.nodeConfig?.preToolOption;
|
||||
return Array.isArray(defs) && defs.length > 0 ? defs[0] : null;
|
||||
});
|
||||
const postToolDef = computed(() => {
|
||||
const defs = props.nodeConfig?.postToolOption;
|
||||
return Array.isArray(defs) && defs.length > 0 ? defs[0] : null;
|
||||
});
|
||||
|
||||
// 前置方法值写节点顶层 preTool(与 outputConfig 平级)
|
||||
const updatePreTool = (value: string) => {
|
||||
if (!props.selectedNode?.data) return;
|
||||
const updatedNode = {
|
||||
...props.selectedNode,
|
||||
data: {
|
||||
...props.selectedNode.data,
|
||||
preTool: value,
|
||||
},
|
||||
};
|
||||
emit('update:selectedNode', updatedNode);
|
||||
};
|
||||
|
||||
// 后置方法值写节点顶层 postTool(与 outputConfig 平级)
|
||||
const updatePostTool = (value: string) => {
|
||||
if (!props.selectedNode?.data) return;
|
||||
const updatedNode = {
|
||||
...props.selectedNode,
|
||||
data: {
|
||||
...props.selectedNode.data,
|
||||
postTool: value,
|
||||
},
|
||||
};
|
||||
emit('update:selectedNode', updatedNode);
|
||||
};
|
||||
|
||||
// 子流程引入参数就地变更(ModelField 已就地写共享引用):
|
||||
// 仅刷新节点引用同步 VueFlow / 父组件状态;fields 为同一数组引用,避免与 SubFlowParams 深 watch 形成循环
|
||||
const updateSubFlowFields = (fields: SubFlowField[]) => {
|
||||
if (!props.selectedNode?.data || !props.selectedNode.data.subFlowConfig) return;
|
||||
const updatedNode = {
|
||||
...props.selectedNode,
|
||||
data: {
|
||||
...props.selectedNode.data,
|
||||
subFlowConfig: {
|
||||
...props.selectedNode.data.subFlowConfig,
|
||||
fields,
|
||||
},
|
||||
},
|
||||
};
|
||||
emit('update:selectedNode', updatedNode);
|
||||
};
|
||||
|
||||
const getFieldValue = (fieldName: string) => {
|
||||
if (!props.selectedNode?.data?.formConfig) return '';
|
||||
const field = props.selectedNode.data.formConfig.find((f: any) => f.field === fieldName);
|
||||
return field?.value ?? '';
|
||||
};
|
||||
|
||||
// keyValue 类型字段的值以对象形式传给编辑器:空值兜底空对象,避免 prop 类型告警(组件期望 Record<string, unknown>)
|
||||
const getKeyValueValue = (fieldName: string) => {
|
||||
const v = getFieldValue(fieldName);
|
||||
return v && typeof v === 'object' ? v : {};
|
||||
};
|
||||
|
||||
const updateFieldValue = (fieldName: string, value: any) => {
|
||||
if (!props.selectedNode?.data) return;
|
||||
|
||||
@@ -624,108 +474,6 @@ const getFormConfigEntry = (fieldName: string) => {
|
||||
return props.selectedNode.data.formConfig.find((f: any) => f.field === fieldName);
|
||||
};
|
||||
|
||||
// ===== 脚本转写节点「视频总时长」字段级引用:支持引用上级节点输出 / 手动输入 =====
|
||||
// valueSource 契约统一数组 [{ nodeId, field }],存到 node.data.formConfig 对应条目;
|
||||
// 仅脚本转写节点的 totalDuration 字段启用引用能力(值可由用户手动输入或引用上游输出)
|
||||
const isTotalDurationField = (field: any) => !!field && field.field === 'totalDuration' && field.isFormField !== true;
|
||||
|
||||
// 读字段引用(统一数组;兼容旧 DSL 单对象)
|
||||
const getFieldValueSource = (fieldName: string) => {
|
||||
const entry = getFormConfigEntry(fieldName);
|
||||
const vs = entry?.valueSource;
|
||||
if (Array.isArray(vs)) return vs;
|
||||
if (vs && typeof vs === 'object') return [vs];
|
||||
return [];
|
||||
};
|
||||
|
||||
// 写字段引用:无条目先创建(复用字段定义);引用清空时移除 valueSource 键
|
||||
const setFieldValueSource = (field: any, valueSource: any) => {
|
||||
if (!props.selectedNode?.data) return;
|
||||
const formConfig = props.selectedNode.data.formConfig || [];
|
||||
const idx = formConfig.findIndex((f: any) => f && f.field === field.field);
|
||||
const arr = Array.isArray(valueSource) ? valueSource : [];
|
||||
let updatedFormConfig;
|
||||
if (idx >= 0) {
|
||||
updatedFormConfig = [...formConfig];
|
||||
const { valueSource: _vs, ...rest } = updatedFormConfig[idx];
|
||||
updatedFormConfig[idx] = arr.length > 0 ? { ...rest, valueSource: arr } : rest;
|
||||
} else {
|
||||
updatedFormConfig = [...formConfig, { ...field, value: '', ...(arr.length > 0 ? { valueSource: arr } : {}) }];
|
||||
}
|
||||
const updatedNode = {
|
||||
...props.selectedNode,
|
||||
data: { ...props.selectedNode.data, formConfig: updatedFormConfig },
|
||||
};
|
||||
emit('update:selectedNode', updatedNode);
|
||||
};
|
||||
|
||||
const hasFieldRef = (fieldName: string) => getFieldValueSource(fieldName).length > 0;
|
||||
|
||||
// 引用展示列表:label 用字段中文名(不带「节点名.」前缀);引用来源失效标红
|
||||
const fieldRefList = (fieldName: string) =>
|
||||
getFieldValueSource(fieldName).map((r: any) => {
|
||||
const node = props.upstreamNodes?.find((n) => n.id === r?.nodeId);
|
||||
const fieldLabel = node?.outputFields.find((f: any) => f.field === r?.field)?.label || r?.field || '';
|
||||
const invalid = !node || !node.outputFields.some((f: any) => f.field === r?.field);
|
||||
return { nodeId: r?.nodeId, field: r?.field, label: fieldLabel, invalid };
|
||||
});
|
||||
|
||||
const removeFieldRef = (field: any, ref: any) => {
|
||||
const keep = getFieldValueSource(field.field).filter((r: any) => !(r?.nodeId === ref.nodeId && r?.field === ref.field));
|
||||
setFieldValueSource(field, keep);
|
||||
};
|
||||
|
||||
const clearFieldRef = (field: any) => setFieldValueSource(field, []);
|
||||
|
||||
// 字段引用可写代理:供 MfReferenceButton 就地读写 valueSource(set/deleteProperty 拦截到 formConfig 条目)
|
||||
const fieldRefDef = (field: any) =>
|
||||
new Proxy(
|
||||
{ ...field },
|
||||
{
|
||||
get(t, key) {
|
||||
if (key === 'valueSource') return getFieldValueSource(field.field);
|
||||
return Reflect.get(t, key);
|
||||
},
|
||||
set(t, key, value) {
|
||||
if (key === 'valueSource') {
|
||||
setFieldValueSource(field, value);
|
||||
return true;
|
||||
}
|
||||
return Reflect.set(t, key, value);
|
||||
},
|
||||
deleteProperty() {
|
||||
setFieldValueSource(field, undefined);
|
||||
return true;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// 失效引用自动清理:引用来源节点/字段被移除时过滤脏数据,避免保存时写入失效引用
|
||||
watch(
|
||||
() => {
|
||||
if (!props.selectedNode?.data?.nodeCode) return null;
|
||||
const entry = getFormConfigEntry('totalDuration');
|
||||
// 依赖 upstreamNodes 引用,保证删除上游节点/字段时触发清理
|
||||
return { vs: entry?.valueSource, upstream: props.upstreamNodes };
|
||||
},
|
||||
(state: any) => {
|
||||
const vs = state?.vs;
|
||||
if (!vs || typeof vs !== 'object') return;
|
||||
const arr = Array.isArray(vs) ? vs : [vs];
|
||||
const anyInvalid = arr.some((r: any) => {
|
||||
const node = props.upstreamNodes?.find((n) => n.id === r?.nodeId);
|
||||
return !node || !node.outputFields.some((f: any) => f.field === r?.field);
|
||||
});
|
||||
if (!anyInvalid) return;
|
||||
const keep = arr.filter((r: any) => {
|
||||
const node = props.upstreamNodes?.find((n) => n.id === r?.nodeId);
|
||||
return node && node.outputFields.some((f: any) => f.field === r?.field);
|
||||
});
|
||||
setFieldValueSource({ field: 'totalDuration' }, keep);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 当前选中下拉选项的嵌套 config 定义(来自节点库 presetOption)
|
||||
const getSelectedOptionConfig = (fieldDef: any) => {
|
||||
if (fieldDef?.type !== 'select' || !Array.isArray(fieldDef.options)) return [];
|
||||
@@ -741,12 +489,6 @@ const getExpandValue = (hostField: string, subField: string) => {
|
||||
return sub?.value ?? '';
|
||||
};
|
||||
|
||||
// 嵌套 keyValue 类型字段对象化(同 getKeyValueValue,供下拉选项嵌套配置使用)
|
||||
const getExpandKeyValueValue = (hostField: string, subField: string) => {
|
||||
const v = getExpandValue(hostField, subField);
|
||||
return v && typeof v === 'object' ? v : {};
|
||||
};
|
||||
|
||||
// 写嵌套 expand 字段值(单次 emit)
|
||||
const updateExpandValue = (hostField: string, subField: string, value: any) => {
|
||||
if (!props.selectedNode?.data) return;
|
||||
@@ -807,35 +549,6 @@ const updateSelectFieldValue = (fieldDef: any, value: any) => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 动态字段 label 行:字段名与引用按钮并排 */
|
||||
.dyn-field-label {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
/* 字段引用来源标签区(脚本转写节点「视频总时长」引用上级输出后展示) */
|
||||
.mf-ref-bound {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
|
||||
.mf-ref-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mf-ref-tag {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.config-panel {
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
<template>
|
||||
<div class="sub-flow-params">
|
||||
<template v-if="fields?.length">
|
||||
<!-- 薄封装:直接复用 ModelField 渲染引入参数。
|
||||
ModelField 就地写 def.value / def.valueSource / def.runtimeShow,
|
||||
fields 元素为同一引用,父组件读取同步无需额外同步。 -->
|
||||
<ModelField
|
||||
v-for="f in fields"
|
||||
:key="f.field"
|
||||
:field-def="f"
|
||||
:path="f.field"
|
||||
:upstream-nodes="upstreamNodes"
|
||||
:multi="multi"
|
||||
/>
|
||||
</template>
|
||||
<el-empty v-else description="该工作流无可引入的开始参数" :image-size="60" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue';
|
||||
import ModelField from './ModelField.vue';
|
||||
import type { SubFlowField } from './subFlowTypes';
|
||||
|
||||
defineOptions({ name: 'SubFlowParams' });
|
||||
|
||||
interface Props {
|
||||
fields: SubFlowField[];
|
||||
upstreamNodes?: any[];
|
||||
multi?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:fields', fields: SubFlowField[]): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
// ModelField 就地修改 def(value/valueSource/runtimeShow),deep watch 通知父级刷新节点引用
|
||||
// (fields 元素为共享引用,父级无需复制数组,仅重新 emit update:selectedNode 即可)
|
||||
watch(
|
||||
() => props.fields,
|
||||
(fields) => {
|
||||
emit('update:fields', fields);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sub-flow-params {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,235 +0,0 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="选择要引入的工作流" width="900px" :close-on-click-modal="false" @close="handleClose">
|
||||
<div class="search-bar">
|
||||
<el-input v-model="searchParams.keyword" placeholder="搜索工作流名称或描述" clearable @clear="handleSearch">
|
||||
<template #prefix
|
||||
><el-icon><Search /></el-icon
|
||||
></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
<div class="workflow-list" v-loading="loading">
|
||||
<el-empty v-if="!loading && workflowList.length === 0" description="暂无工作流数据" :image-size="100" />
|
||||
<div v-else class="workflow-grid">
|
||||
<div
|
||||
v-for="workflow in workflowList"
|
||||
:key="workflow.id"
|
||||
class="workflow-card"
|
||||
:class="{ selected: selectedWorkflow?.id === workflow.id }"
|
||||
@click="handleSelectWorkflow(workflow)"
|
||||
>
|
||||
<div class="workflow-card-header">
|
||||
<span class="workflow-badge">我的工作流</span>
|
||||
<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.flowTemplateName || workflow.name || '未命名工作流' }}</h3>
|
||||
<p class="workflow-desc">{{ workflow.description || '暂无描述' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pagination.total > 0" class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.pageNum"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
layout="total, prev, pager, next"
|
||||
small
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleConfirm" :disabled="!selectedWorkflow">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue';
|
||||
import { Search, CircleCheck } from '@element-plus/icons-vue';
|
||||
import { getSubFlowWorkflowList, type SubFlowWorkflowItem } from '/@/api/settings/workflow';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
defaultWorkflow?: SubFlowWorkflowItem | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'confirm', workflow: SubFlowWorkflowItem): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
defaultWorkflow: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = ref(false);
|
||||
const searchParams = reactive({ keyword: '' });
|
||||
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
|
||||
const workflowList = ref<SubFlowWorkflowItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const selectedWorkflow = ref<SubFlowWorkflowItem | null>(null);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
visible.value = val;
|
||||
if (val) {
|
||||
selectedWorkflow.value = props.defaultWorkflow || null;
|
||||
pagination.pageNum = 1;
|
||||
fetchWorkflowList();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(visible, (val) => {
|
||||
if (!val) {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
});
|
||||
|
||||
const fetchWorkflowList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
pageNum: pagination.pageNum,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchParams.keyword || undefined,
|
||||
IsOwn: true,
|
||||
};
|
||||
const res = await getSubFlowWorkflowList(params);
|
||||
const data = res.data || {};
|
||||
// 按登录角色分支读取: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 ?? source?.total ?? workflowList.value.length;
|
||||
// 预选项仅为部分信息(切换节点后无 id)时,按名称匹配当前页以高亮
|
||||
// 名称字段兼容 flowName / flowTemplateName / name 三形态
|
||||
if (selectedWorkflow.value && !selectedWorkflow.value.id) {
|
||||
const selName = selectedWorkflow.value.flowName || selectedWorkflow.value.flowTemplateName || selectedWorkflow.value.name;
|
||||
if (selName) {
|
||||
const matched = workflowList.value.find((w) => (w.flowName || w.flowTemplateName || w.name) === selName);
|
||||
if (matched) selectedWorkflow.value = matched;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
workflowList.value = [];
|
||||
pagination.total = 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.pageNum = 1;
|
||||
fetchWorkflowList();
|
||||
};
|
||||
|
||||
const handlePageChange = () => {
|
||||
fetchWorkflowList();
|
||||
};
|
||||
|
||||
const handleSelectWorkflow = (workflow: SubFlowWorkflowItem) => {
|
||||
selectedWorkflow.value = workflow;
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedWorkflow.value) {
|
||||
emit('confirm', selectedWorkflow.value);
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false;
|
||||
selectedWorkflow.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.workflow-list {
|
||||
min-height: 300px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.workflow-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.workflow-card {
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
.workflow-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.workflow-card.selected {
|
||||
border-color: #67c23a;
|
||||
background: #f0f9ff;
|
||||
}
|
||||
.workflow-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.workflow-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.check-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
.workflow-card-body {
|
||||
flex: 1;
|
||||
}
|
||||
.workflow-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 8px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.workflow-desc {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: 40px;
|
||||
}
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -102,15 +102,6 @@ export function stripReadonlyFields(params: any): any {
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// 有模板被剔除(子字段全只读)→ 数组索引被压缩;给保留的模板补记原始位置 __originIndex,
|
||||
// 供 buildModelRequestParamsPath 生成后端契约的 enumValues 原始索引(幂等:已带则不覆盖)
|
||||
if (keptTemplates.length !== templates.length) {
|
||||
templates.forEach((t: any, originIdx: number) => {
|
||||
if (t && typeof t === 'object' && keptTemplates.includes(t) && t.__originIndex === undefined) {
|
||||
t.__originIndex = originIdx;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (isEnumValues) def.enumValues = keptTemplates;
|
||||
if (isAttrsArray) def.attrs = keptTemplates;
|
||||
if (keptTemplates.length > 0) hasEditable = true;
|
||||
@@ -137,47 +128,9 @@ export function stripReadonlyFields(params: any): any {
|
||||
return params;
|
||||
}
|
||||
|
||||
// 剔除 array 类型字段的实例值(value):后端模型定义中 array 仅保存结构/模板
|
||||
// (enumValues / attrs),前端渲染时从模板复制并填充的 value 实例是冗余数据,
|
||||
// 保存工作流时不提交。递归处理嵌套结构(array 字段可嵌套于 enumValues 模板中,
|
||||
// 如 messages.enumValues[0].attrs.content 仍是 array,同样需删除 value)。
|
||||
export function removeArrayValueInstances(params: any): any {
|
||||
if (!params || typeof params !== 'object' || Array.isArray(params)) return params;
|
||||
// 单个字段定义(带 type):array 删实例值并递归模板;object 递归 attrs
|
||||
if (typeof params.type === 'string') {
|
||||
if (params.type === 'array') {
|
||||
delete params.value;
|
||||
if (params.attrs && typeof params.attrs === 'object') {
|
||||
if (Array.isArray(params.attrs)) params.attrs.forEach((it: any) => removeArrayValueInstances(it));
|
||||
else removeArrayValueInstances(params.attrs);
|
||||
}
|
||||
if (Array.isArray(params.enumValues)) params.enumValues.forEach((tpl: any) => removeArrayValueInstances(tpl));
|
||||
} else if (params.type === 'object' && params.attrs && typeof params.attrs === 'object' && !Array.isArray(params.attrs)) {
|
||||
removeArrayValueInstances(params.attrs);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
// 字段定义容器 { key: fieldDef }:逐个字段递归
|
||||
for (const key of Object.keys(params)) {
|
||||
removeArrayValueInstances(params[key]);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
// ===== 暴露清单:在工作流表单中展示的勾选字段 =====
|
||||
|
||||
// array 的模板元素列表(与 ModelField.arrayTemplates 优先级一致):
|
||||
// enumValues 数组 → attrs 数组 → 单个 attrs 对象。勾选/值都跟随模板,故 path 用
|
||||
// enumValues[i] 定位模板,collect 与 resolve 共用本函数保持文法一致。
|
||||
function arrayTemplatesOf(def: any): any[] {
|
||||
if (!def || typeof def !== 'object') return [];
|
||||
if (Array.isArray(def.enumValues) && def.enumValues.length) return def.enumValues;
|
||||
if (Array.isArray(def.attrs) && def.attrs.length) return def.attrs;
|
||||
if (def.attrs && typeof def.attrs === 'object' && !Array.isArray(def.attrs)) return [{ type: 'object', attrs: def.attrs }];
|
||||
return [];
|
||||
}
|
||||
|
||||
// 单个暴露叶子字段(路径带模板索引,如 "messages.enumValues[0].attrs.content")
|
||||
// 单个暴露叶子字段(路径带实例索引,如 "messages.value[0].attrs.content")
|
||||
export interface ExposedField {
|
||||
path: string;
|
||||
label: string;
|
||||
@@ -186,46 +139,13 @@ export interface ExposedField {
|
||||
required: boolean;
|
||||
options?: any[];
|
||||
value?: any;
|
||||
defaultValue?: any; // 默认值:首页初始化/回显用
|
||||
fieldConstraint?: any; // 字段约束:上传格式/大小/数量、数字 min/max 等
|
||||
valueSource?: any; // 引用上游节点输出([{ nodeId, field }] 数组,支持多选);有则首页只读展示
|
||||
multiple?: boolean; // 多文件上传标记
|
||||
refNodeId?: string; // 预留:引用其他节点功能(后续启用)
|
||||
}
|
||||
|
||||
// 将模型模板字段级约束(constraint,JsonEditor 风格:min/max/uploadRules/accept/maxSize/maxCount 等)
|
||||
// 归一化为首页运行表单消费的 fieldConstraint(minValue/maxValue/fileTypes/maxFileSize/maxFileCount)。
|
||||
// 模板字段约束实际挂在 constraint 键下(如 video_url.url 的 uploadRules、duration 的 min/numberType),
|
||||
// 与 collectExposedFields 原读取的 fieldConstraint 键不一致,需在此转换后随 modelFormFields 透传首页。
|
||||
function toFieldConstraint(constraint: any): any {
|
||||
if (!constraint || typeof constraint !== 'object') return undefined;
|
||||
const out: Record<string, any> = {};
|
||||
// 数字 min/max → 首页 el-input-number 的 minValue/maxValue
|
||||
if (constraint.min !== undefined && constraint.min !== null) out.minValue = constraint.min;
|
||||
if (constraint.max !== undefined && constraint.max !== null) out.maxValue = constraint.max;
|
||||
// 上传约束:uploadRules 数组取第一条;兼容顶层 accept/maxSize/maxCount 单值形式
|
||||
const rules = Array.isArray(constraint.uploadRules) ? constraint.uploadRules.filter((r: any) => r && typeof r === 'object') : [];
|
||||
const rule = rules[0] || null;
|
||||
const fileTypes = rule?.format ?? constraint.accept;
|
||||
if (fileTypes !== undefined && fileTypes !== null && fileTypes !== '') {
|
||||
// 模板 format 可能用中文顿号/空白分隔(如 wan 模型 "wav、mp3"),
|
||||
// 统一转英文逗号,保证首页 split(',') 与扩展名匹配正确
|
||||
out.fileTypes = String(fileTypes)
|
||||
.replace(/[、\s]+/g, ',')
|
||||
.replace(/,+/g, ',')
|
||||
.replace(/^,|,$/g, '');
|
||||
}
|
||||
const maxSize = rule?.maxSize ?? constraint.maxSize;
|
||||
if (maxSize !== undefined && maxSize !== null) out.maxFileSize = maxSize;
|
||||
const maxCount = rule?.maxCount ?? constraint.maxCount;
|
||||
if (maxCount !== undefined && maxCount !== null) out.maxFileCount = maxCount;
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
// 收集 runtimeShow === true 的叶子字段,生成暴露清单
|
||||
// 路径文法(与 restoreRuntimeShow 共用):
|
||||
// object 子字段 → 父路径 + ".attrs." + 子key
|
||||
// array 模板元素 → 父路径 + ".enumValues[i]"
|
||||
// array 实例元素 → 父路径 + ".value[i]"
|
||||
// 原始值数组元素(非 object 包装)不可勾选,跳过
|
||||
export function collectExposedFields(params: any, prefix = ''): ExposedField[] {
|
||||
if (!params || typeof params !== 'object') return [];
|
||||
@@ -241,17 +161,14 @@ export function collectExposedFields(params: any, prefix = ''): ExposedField[] {
|
||||
result.push(...collectExposedFields(attrs, `${path}.attrs`));
|
||||
}
|
||||
} else if (t === 'array') {
|
||||
// 勾选跟随模板:渲染时 value 元素与模板共享引用,runtimeShow 落在模板上,
|
||||
// 故遍历模板(enumValues 优先)收集,path 用 enumValues[i] 定位模板
|
||||
const templates = arrayTemplatesOf(def);
|
||||
templates.forEach((tpl: any, i: number) => {
|
||||
if (tpl && typeof tpl === 'object' && tpl.attrs && typeof tpl.attrs === 'object' && !Array.isArray(tpl.attrs)) {
|
||||
// 索引与 collectAllLeafFields 一致:优先用 strip 时记录的原始模板索引 __originIndex,
|
||||
// 保证 modelFormFields 与 modelRequestParamsPath 的 path 索引对齐后端模型完整定义
|
||||
const originIdx = typeof tpl.__originIndex === 'number' ? tpl.__originIndex : i;
|
||||
result.push(...collectExposedFields(tpl.attrs, `${path}.enumValues[${originIdx}].attrs`));
|
||||
}
|
||||
});
|
||||
// 仅遍历实例数组;元素为 { type:'object', attrs } 包装时才递归
|
||||
if (Array.isArray(def.value)) {
|
||||
def.value.forEach((item: any, i: number) => {
|
||||
if (item && typeof item === 'object' && item.attrs && typeof item.attrs === 'object' && !Array.isArray(item.attrs)) {
|
||||
result.push(...collectExposedFields(item.attrs, `${path}.value[${i}].attrs`));
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (def.runtimeShow === true) {
|
||||
// 叶子且已勾选
|
||||
result.push({
|
||||
@@ -262,157 +179,20 @@ export function collectExposedFields(params: any, prefix = ''): ExposedField[] {
|
||||
required: !!def.required,
|
||||
options: Array.isArray(def.options) ? def.options : undefined,
|
||||
value: def.value,
|
||||
defaultValue: def.defaultValue,
|
||||
// 优先透传已有的 fieldConstraint(首页格式);模板字段约束在 constraint 键时归一化补全
|
||||
fieldConstraint:
|
||||
(def.fieldConstraint && typeof def.fieldConstraint === 'object' ? def.fieldConstraint : undefined) ??
|
||||
toFieldConstraint(def.constraint),
|
||||
valueSource: (() => {
|
||||
const vs = def.valueSource;
|
||||
// 契约统一数组;兼容旧 DSL 单对象 { nodeId, field }
|
||||
return Array.isArray(vs) ? vs : vs && typeof vs === 'object' ? [vs] : undefined;
|
||||
})(),
|
||||
multiple: def.multiple || def.fieldType === 'uploadMultiple' || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== 模型请求参数扁平清单(modelRequestParamsPath)=====
|
||||
// 后端契约:保存时与 modelRequestParams 平级新增 modelRequestParamsPath,
|
||||
// 为嵌套模型参数树的全部叶子字段扁平清单(path 文法与 collectExposedFields 一致),
|
||||
// 后端按 path 重组请求参数。value 保留当前值;valueSource 规则(统一数组 [{ nodeId, field }]):
|
||||
// - 已有引用配置(用户在设计器配置「引用上游」)→ 原样保留数组
|
||||
// - 无引用但勾选「表单展示」(runtimeShow=true)→ 补 [{ nodeId: 开始节点id, field: path }]
|
||||
// - 其余 → 不带 valueSource
|
||||
export interface ModelRequestParamsPathItem {
|
||||
path: string;
|
||||
// 叶子字段中文名(回显/展示用;无 label 时兜底 path 最后一段字段名)
|
||||
label: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
value: any;
|
||||
valueSource?: any;
|
||||
}
|
||||
|
||||
// 收集嵌套树的全部叶子字段(含数组模板元素,不过滤 runtimeShow),返回 { path, def }
|
||||
function collectAllLeafFields(params: any, prefix = ''): { path: string; def: any }[] {
|
||||
if (!params || typeof params !== 'object' || Array.isArray(params)) return [];
|
||||
const result: { path: string; def: any }[] = [];
|
||||
for (const key of Object.keys(params)) {
|
||||
const def = params[key];
|
||||
if (!def || typeof def !== 'object') continue;
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
const t = def.type;
|
||||
if (t === 'object') {
|
||||
const attrs = def.attrs;
|
||||
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) {
|
||||
result.push(...collectAllLeafFields(attrs, `${path}.attrs`));
|
||||
}
|
||||
} else if (t === 'array') {
|
||||
// 数组字段遍历模板(enumValues 优先,其次 attrs 数组),path 用 enumValues[i] 定位模板,
|
||||
// 与 ModelField 渲染 / collectExposedFields 文法一致(值/勾选均落在模板上,不依赖 value 实例)。
|
||||
// 模板带 __originIndex(strip 时记录的原始位置)时用原始索引,保证与后端模型完整定义的 enumValues 编号一致
|
||||
const templates = arrayTemplatesOf(def);
|
||||
templates.forEach((tpl: any, i: number) => {
|
||||
if (tpl && typeof tpl === 'object' && tpl.attrs && typeof tpl.attrs === 'object' && !Array.isArray(tpl.attrs)) {
|
||||
const originIdx = typeof tpl.__originIndex === 'number' ? tpl.__originIndex : i;
|
||||
result.push(...collectAllLeafFields(tpl.attrs, `${path}.enumValues[${originIdx}].attrs`));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
result.push({ path, def });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 生成 modelRequestParamsPath 扁平清单
|
||||
// 所有叶子字段(含对象/数组内部)都支持「引用上游」,valueSource 原样保留;
|
||||
// 与编辑器「引用上级输出与表单展示同级」语义一致
|
||||
export function buildModelRequestParamsPath(
|
||||
params: any,
|
||||
startNodeId = '',
|
||||
// 节点输出索引(id → 节点名 + 字段 label),用于补全 valueSource 的引用来源 label
|
||||
outputIndex?: Map<string, { name: string; fields: Map<string, string> }>
|
||||
): ModelRequestParamsPathItem[] {
|
||||
const fields = collectAllLeafFields(params);
|
||||
// 引用来源 label:字段中文名(不带「节点名.」前缀;无索引/未命中时兜底 field)
|
||||
const resolveRefLabel = (nodeId: string, field: string): string => {
|
||||
const info = outputIndex?.get(nodeId);
|
||||
if (!info) return field;
|
||||
return info.fields.get(field) || field;
|
||||
};
|
||||
return fields.map(({ path, def }) => {
|
||||
let valueSource: any;
|
||||
if (Array.isArray(def.valueSource)) {
|
||||
// 引用上游:前端编辑态 [{ nodeId, field }] 数组,逐项补 label
|
||||
valueSource = def.valueSource.map((r: any) =>
|
||||
r && r.nodeId && r.field
|
||||
? { nodeId: r.nodeId, field: r.field, label: resolveRefLabel(r.nodeId, r.field) }
|
||||
: r
|
||||
);
|
||||
} else if (def.valueSource && typeof def.valueSource === 'object') {
|
||||
// 兼容旧 DSL 单对象 { nodeId, field }
|
||||
const r = def.valueSource as any;
|
||||
valueSource = [
|
||||
r && r.nodeId && r.field
|
||||
? { nodeId: r.nodeId, field: r.field, label: resolveRefLabel(r.nodeId, r.field) }
|
||||
: r,
|
||||
];
|
||||
} else if (def.runtimeShow === true && startNodeId) {
|
||||
// 勾选「表单展示」:补后端契约数组(指向开始节点,label 取开始节点字段名)
|
||||
valueSource = [{ nodeId: startNodeId, field: path, label: resolveRefLabel(startNodeId, path) }];
|
||||
}
|
||||
return {
|
||||
path,
|
||||
label: def.label || path.split('.').pop() || '',
|
||||
type: def.type || 'string',
|
||||
required: Boolean(def.required),
|
||||
value: def.value ?? '',
|
||||
...(valueSource ? { valueSource } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 按暴露清单的 path 反解,把对应叶子 def 的 runtimeShow 置 true(加载时还原勾选)
|
||||
// startNodeId:勾选「表单展示」的字段保存时带 valueSource 指向开始节点(后端契约);
|
||||
// 回显时该 valueSource 无编辑器引用语义,清除避免 ModelField 误判引用/误删
|
||||
export function restoreRuntimeShow(params: any, fields: ExposedField[] | null | undefined, startNodeId = ''): void {
|
||||
export function restoreRuntimeShow(params: any, fields: ExposedField[] | null | undefined): void {
|
||||
if (!params || typeof params !== 'object' || !Array.isArray(fields) || fields.length === 0) return;
|
||||
for (const f of fields) {
|
||||
if (!f || typeof f.path !== 'string') continue;
|
||||
const cur = resolvePath(params, f.path);
|
||||
if (cur && typeof cur === 'object' && !Array.isArray(cur)) {
|
||||
cur.runtimeShow = true;
|
||||
if (startNodeId && cur.valueSource && typeof cur.valueSource === 'object') {
|
||||
// 移除指向开始节点(表单展示标记)的引用;兼容旧 DSL 单对象(统一数组化),全部移除后删除整个 valueSource
|
||||
const arr = Array.isArray(cur.valueSource) ? cur.valueSource : [cur.valueSource];
|
||||
const keep = arr.filter((r: any) => !(r && r.nodeId === startNodeId));
|
||||
if (keep.length === 0) delete cur.valueSource;
|
||||
else cur.valueSource = keep;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 勾选「表单展示」的字段:保存前按后端契约补 valueSource={nodeId:开始节点id, field:path},
|
||||
// 标记字段值来自主工作流开始节点(首页表单)。startNodeId 为空(无开始节点)时跳过。
|
||||
export function attachFormFieldValueSources(params: any, fields: ExposedField[] | null | undefined, startNodeId = ''): void {
|
||||
if (!params || typeof params !== 'object' || !Array.isArray(fields) || fields.length === 0 || !startNodeId) return;
|
||||
for (const f of fields) {
|
||||
if (!f || typeof f.path !== 'string') continue;
|
||||
const cur = resolvePath(params, f.path);
|
||||
if (cur && typeof cur === 'object' && !Array.isArray(cur)) {
|
||||
// 已有指向上游节点的真实引用时保持互斥:不覆盖为开始节点标记,避免引用丢失
|
||||
const vs = cur.valueSource;
|
||||
const hasRealRef = Array.isArray(vs)
|
||||
? vs.some((r: any) => r && r.nodeId && r.nodeId !== startNodeId)
|
||||
: !!vs && typeof vs === 'object' && vs.nodeId && vs.nodeId !== startNodeId;
|
||||
if (!hasRealRef) {
|
||||
cur.valueSource = [{ nodeId: startNodeId, field: f.path }];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -428,26 +208,14 @@ function resolvePath(params: any, path: string): any {
|
||||
cur = cur.attrs;
|
||||
if (!cur || typeof cur !== 'object') return undefined;
|
||||
} else {
|
||||
const mv = seg.match(/^value\[(\d+)\]$/);
|
||||
if (mv) {
|
||||
const idx = Number(mv[1]);
|
||||
const m = seg.match(/^value\[(\d+)\]$/);
|
||||
if (m) {
|
||||
const idx = Number(m[1]);
|
||||
if (!Array.isArray(cur.value) || idx >= cur.value.length) return undefined;
|
||||
cur = cur.value[idx];
|
||||
continue;
|
||||
} else {
|
||||
cur = cur[seg];
|
||||
}
|
||||
const me = seg.match(/^enumValues\[(\d+)\]$/);
|
||||
if (me) {
|
||||
const idx = Number(me[1]);
|
||||
const templates = arrayTemplatesOf(cur);
|
||||
// 路径索引可能为 strip 压缩前记录的原始索引(__originIndex),优先按原始索引匹配;
|
||||
// 未带 __originIndex 时退化为按数组位置匹配(未压缩场景)
|
||||
const byOrigin = templates.find((t: any) => t && typeof t.__originIndex === 'number' && t.__originIndex === idx);
|
||||
if (byOrigin !== undefined) cur = byOrigin;
|
||||
else if (idx < templates.length) cur = templates[idx];
|
||||
else return undefined;
|
||||
continue;
|
||||
}
|
||||
cur = cur[seg];
|
||||
}
|
||||
}
|
||||
return cur;
|
||||
@@ -457,53 +225,3 @@ function resolvePath(params: any, path: string): any {
|
||||
export function isEqual(a: any, b: any): boolean {
|
||||
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||
}
|
||||
|
||||
// ===== 模型返回参数中文 label(引用展示用)=====
|
||||
// 模型 responseMapping(响应映射:路径→中文)支持两种结构:普通对象 {路径: 中文} 与
|
||||
// { type, attrs/value, label } 包裹格式。独立实现平铺(不依赖 settings/modelConfigV2),
|
||||
// 供保存 modelResponseBodyMapping 时把中文描述填入 value,引用上级节点输出展示中文而非英文 key。
|
||||
function flattenResponseLabelMap(mapping: any, basePath = '', out: Record<string, string>): void {
|
||||
if (!mapping || typeof mapping !== 'object') return;
|
||||
// { type, attrs/value } 包裹格式:叶子节点带 label
|
||||
if (typeof mapping.type === 'string' && ['string', 'number', 'boolean', 'null', 'object', 'array'].includes(mapping.type)) {
|
||||
const jtype = mapping.type;
|
||||
const dataKey = jtype === 'object' || jtype === 'array' ? 'attrs' : 'value';
|
||||
if (jtype === 'object' && dataKey in mapping && mapping[dataKey] && typeof mapping[dataKey] === 'object' && !Array.isArray(mapping[dataKey])) {
|
||||
for (const [key, val] of Object.entries(mapping[dataKey])) {
|
||||
flattenResponseLabelMap(val, basePath ? `${basePath}.attrs.${key}` : key, out);
|
||||
}
|
||||
} else if (jtype === 'array' && dataKey in mapping && Array.isArray(mapping[dataKey])) {
|
||||
mapping[dataKey].forEach((item: any, idx: number) => {
|
||||
flattenResponseLabelMap(item, `${basePath}.attrs[${idx}]`, out);
|
||||
});
|
||||
} else if (['string', 'number', 'boolean', 'null'].includes(jtype)) {
|
||||
if (mapping.label) out[basePath] = String(mapping.label);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 普通对象(非包裹格式)
|
||||
if (!Array.isArray(mapping)) {
|
||||
for (const [key, val] of Object.entries(mapping)) {
|
||||
const childPath = basePath ? `${basePath}.${key}` : key;
|
||||
if (val && typeof val === 'object' && !Array.isArray(val)) {
|
||||
flattenResponseLabelMap(val, childPath, out);
|
||||
} else if (typeof val === 'string' && val) {
|
||||
out[childPath] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 用模型 responseMapping 的中文描述填充 modelResponseBodyMapping 的 value:
|
||||
// { key: '' } → { key: '中文' }。结构不变(对象 key→string),无对应中文时保留原 value(兜底 key)。
|
||||
export function enrichResponseBodyMapping(body: any, mapping: any): any {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) return body ?? null;
|
||||
const labelMap: Record<string, string> = {};
|
||||
flattenResponseLabelMap(mapping, '', labelMap);
|
||||
const out: Record<string, string> = {};
|
||||
for (const k of Object.keys(body)) {
|
||||
const raw = typeof body[k] === 'string' ? body[k] : '';
|
||||
out[k] = labelMap[k] || raw || k;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
// 子流程节点(sub_flow)引入工作流的类型定义(workflow 独立实现,不依赖内容创作)
|
||||
|
||||
// ===== 编辑器态(node.data.subFlowConfig)=====
|
||||
export interface SubFlowField {
|
||||
// = 目标工作流开始节点 runFormFields 的 field(后端匹配键,不可改)
|
||||
field: string;
|
||||
label: string;
|
||||
// 控件类型:input/number/textarea/switch/select/upload/uploadMultiple
|
||||
type: string;
|
||||
fieldType: string;
|
||||
required: boolean;
|
||||
// 未引用且未勾选表单展示时的静态值(编辑器可填)
|
||||
value?: any;
|
||||
defaultValue?: any;
|
||||
// upload {fileTypes,maxFileSize,maxFileCount} / number {minValue,maxValue}
|
||||
fieldConstraint?: any;
|
||||
// select 选项
|
||||
options?: any[];
|
||||
// 多文件上传标记
|
||||
multiple?: boolean;
|
||||
// 引用上级节点输出(主工作流上游);有值则编辑器内只读引用展示
|
||||
valueSource?: { nodeId: string; field: string }[] | null;
|
||||
// 编辑器内「表单展示」勾选(ModelField 用 runtimeShow)
|
||||
runtimeShow?: boolean;
|
||||
}
|
||||
|
||||
export interface SubFlowConfig {
|
||||
// 目标工作流 id(后端执行 sub_flow 时引入)
|
||||
workflowId: string;
|
||||
workflowName: string;
|
||||
// 生成次数(sub_flow 执行并发数;未填为 0)
|
||||
maxConcurrency?: number;
|
||||
fields: SubFlowField[];
|
||||
}
|
||||
|
||||
// ===== DSL 保存态(node.subConfig,runtimeShow → isFormField)=====
|
||||
export interface SubFlowDslField {
|
||||
field: string;
|
||||
label: string;
|
||||
type: string;
|
||||
fieldType: string;
|
||||
required: boolean;
|
||||
value?: any;
|
||||
defaultValue?: any;
|
||||
fieldConstraint?: any;
|
||||
options?: any[] | null;
|
||||
multiple?: boolean;
|
||||
valueSource?: { nodeId: string; field: string }[] | null;
|
||||
// 勾选表单展示 → 聚合进主工作流开始节点(首页表单可填)
|
||||
isFormField?: boolean;
|
||||
}
|
||||
|
||||
export interface SubFlowConfigDsl {
|
||||
workflowId: string;
|
||||
workflowName: string;
|
||||
maxConcurrency?: number;
|
||||
fields: SubFlowDslField[];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,192 +0,0 @@
|
||||
<template>
|
||||
<div class="editor-body">
|
||||
<div class="editor-col">
|
||||
<!-- 周期订阅 -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">周期订阅</span>
|
||||
<span class="head-hint">按周期一次性收取,与用量无关</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<el-form label-position="top" size="default">
|
||||
<el-form-item label="周期 period">
|
||||
<el-select v-model="period" class="ctrl-select">
|
||||
<el-option label="year(按年)" value="year" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="每周期价格(元)">
|
||||
<el-input-number
|
||||
v-model="price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.01"
|
||||
:controls="false"
|
||||
class="ctrl-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
<div class="form-hint">本期支持按年订阅(period=year);订阅购买 / 续费流程接入另期。</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-col">
|
||||
<!-- 通用配置 -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">通用配置</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<el-form label-position="top" size="default">
|
||||
<el-form-item label="门禁 min_balance(元)">
|
||||
<el-input-number
|
||||
v-model="minBalance"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.01"
|
||||
:controls="false"
|
||||
class="ctrl-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="币种">
|
||||
<el-select v-model="currency" class="ctrl-select">
|
||||
<el-option label="人民币 (CNY)" value="CNY" />
|
||||
<el-option label="美元 (USD)" value="USD" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="enabled" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import {
|
||||
type BusinessRules,
|
||||
type Currency,
|
||||
type EditorInitConfig,
|
||||
type Subject,
|
||||
} from '../mock';
|
||||
|
||||
/**
|
||||
* 业务模块计价编辑面板。
|
||||
* 周期订阅 per_period:period 固定 year,价格 + 公共配置。
|
||||
* initConfig 传入后端已保存配置(parse 后)时初始化表单;无配置时用默认值。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
subject: Subject;
|
||||
initConfig: EditorInitConfig | null;
|
||||
}>();
|
||||
|
||||
const period = ref<'year'>('year');
|
||||
const price = ref(1999);
|
||||
const minBalance = ref(0);
|
||||
const currency = ref<Currency>('CNY');
|
||||
const enabled = ref(true);
|
||||
|
||||
/** 用后端已保存配置初始化表单 */
|
||||
function applyConfig(cfg: EditorInitConfig) {
|
||||
const r = cfg.rules as BusinessRules;
|
||||
period.value = r.period;
|
||||
price.value = r.price;
|
||||
minBalance.value = cfg.minBalance;
|
||||
currency.value = cfg.currency;
|
||||
enabled.value = cfg.enabled;
|
||||
}
|
||||
|
||||
/** 重置为默认(无配置) */
|
||||
function resetDefault() {
|
||||
period.value = 'year';
|
||||
price.value = 1999;
|
||||
minBalance.value = 0;
|
||||
currency.value = 'CNY';
|
||||
enabled.value = true;
|
||||
}
|
||||
|
||||
function save(): { rules: BusinessRules; minBalance: number; currency: Currency; enabled: boolean } {
|
||||
return {
|
||||
rules: { period: period.value, price: price.value },
|
||||
minBalance: minBalance.value,
|
||||
currency: currency.value,
|
||||
enabled: enabled.value,
|
||||
};
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.initConfig,
|
||||
(cfg) => {
|
||||
if (cfg && cfg.rules && (cfg.rules as BusinessRules).period) {
|
||||
applyConfig(cfg);
|
||||
} else {
|
||||
resetDefault();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
defineExpose({ save });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.editor-body {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editor-col {
|
||||
flex: 1;
|
||||
min-width: 380px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
|
||||
.card-head {
|
||||
background: #fafafa;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.head-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.ctrl-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.ctrl-select {
|
||||
width: 220px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,578 +0,0 @@
|
||||
<template>
|
||||
<div class="editor-body">
|
||||
<div class="editor-col">
|
||||
<!-- 基础计费 -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">基础计费</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<el-form label-position="top" size="default">
|
||||
<el-form-item label="计费单位 unit">
|
||||
<el-select v-model="unit" class="ctrl-select" @change="onUnitChange">
|
||||
<el-option v-for="o in UNIT_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="阶梯分档 tiered">
|
||||
<el-switch v-model="tiered" :disabled="!isTokenUnit" />
|
||||
</el-form-item>
|
||||
<div class="form-hint">阶梯=按输入 token 分档,<b>命中哪档整单按哪档价(不累加)</b>。档位区间相邻不重叠(下档 max+1 = 上档 min)。仅 token 单位(per_1K / per_1M)可用。</div>
|
||||
<div class="money-note">{{ priceNote }}</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 计费规则 -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">计费规则</span>
|
||||
<span class="head-select">
|
||||
<el-select :model-value="exampleIndex" size="small" placeholder="选择示例填充" @change="loadExample">
|
||||
<el-option v-for="(e, i) in examples" :key="i" :label="e.label" :value="i" />
|
||||
</el-select>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-for="(r, i) in modelRules" :key="i" class="rule-card">
|
||||
<div class="rule-head">
|
||||
<span class="rule-index">#{{ i + 1 }}</span>
|
||||
<el-input v-model="r.name" placeholder="规则名(如:有声-1080p-输入含视频)" class="rule-name" />
|
||||
<el-button size="small" type="danger" text @click="removeRule(i)">删除</el-button>
|
||||
</div>
|
||||
|
||||
<div class="rc-sec match">命中条件 match</div>
|
||||
<el-form label-position="top" size="small">
|
||||
<div class="rc-grid">
|
||||
<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">计价项 price</div>
|
||||
<el-form label-position="top" size="small">
|
||||
<div class="rc-grid">
|
||||
<template v-if="isTokenUnit">
|
||||
<el-form-item label="输入单价 input">
|
||||
<el-input-number v-model="r.input" :min="0" :precision="6" :step="0.0001" :controls="false" class="rc-control" />
|
||||
</el-form-item>
|
||||
<el-form-item label="输出单价 output">
|
||||
<el-input-number v-model="r.output" :min="0" :precision="6" :step="0.0001" :controls="false" class="rc-control" />
|
||||
</el-form-item>
|
||||
<el-form-item label="缓存命中 cacheHit">
|
||||
<el-input-number v-model="r.cacheHit" :min="0" :precision="6" :step="0.0001" :controls="false" class="rc-control" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-form-item label="每单位单价 unitPrice">
|
||||
<el-input-number v-model="r.unitPrice" :min="0" :precision="6" :step="0.0001" :controls="false" class="rc-control" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<!-- 媒体价格模板(仅 token 单位):媒体命中条件已移除,改为对特定媒体类型设独立覆盖价 mediaPrices -->
|
||||
<div v-if="isTokenUnit" class="media-price-section">
|
||||
<div class="rc-sec price">媒体价格模板 mediaPrices(可选)</div>
|
||||
<div v-for="(mp, mIdx) in r.mediaPrices" :key="mIdx" class="media-price-row">
|
||||
<el-select v-model="mp.mediaType" class="mp-media" placeholder="媒体类型">
|
||||
<el-option v-for="o in getAvailableMediaOptions(r, mIdx)" :key="o.value" :label="o.label" :value="o.value" />
|
||||
</el-select>
|
||||
<el-input-number v-model="mp.input" :min="0" :precision="6" :step="0.0001" :controls="false" class="mp-num" placeholder="input" />
|
||||
<el-input-number v-model="mp.output" :min="0" :precision="6" :step="0.0001" :controls="false" class="mp-num" placeholder="output" />
|
||||
<el-input-number v-model="mp.cacheHit" :min="0" :precision="6" :step="0.0001" :controls="false" class="mp-num" placeholder="cacheHit" />
|
||||
<el-button type="danger" text size="small" @click="removeMediaPrice(r, mIdx)">删除</el-button>
|
||||
</div>
|
||||
<el-button size="small" class="media-add-btn" @click="addMediaPrice(r)">+ 添加媒体价格</el-button>
|
||||
<div class="mp-tip">基础价(input/output/cacheHit)为默认价;媒体模板仅覆盖指定媒体类型的价,未选媒体类型的行保存时丢弃。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button size="small" class="add-btn" @click="addRule">+ 添加规则</el-button>
|
||||
<div class="note mt10">
|
||||
结算入参(ChargeUsage 扩展,本期备用):<code>{promptTokens, completionTokens, cachedTokens, charCount, imageCount, mediaType, thinking, outputAudio, outputResolution}</code>。匹配 = 全部条件满足的<b>首条规则</b>命中,无命中报错。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-col">
|
||||
<!-- 通用配置 -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">通用配置</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<el-form label-position="top" size="default">
|
||||
<el-form-item label="门禁 min_balance(元)">
|
||||
<el-input-number
|
||||
v-model="minBalance"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.01"
|
||||
:controls="false"
|
||||
class="ctrl-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="币种">
|
||||
<el-select v-model="currency" class="ctrl-select">
|
||||
<el-option label="人民币 (CNY)" value="CNY" />
|
||||
<el-option label="美元 (USD)" value="USD" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="enabled" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import {
|
||||
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: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editor-col {
|
||||
flex: 1;
|
||||
min-width: 380px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
|
||||
.card-head {
|
||||
background: #fafafa;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.head-select {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.money-note {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.mt10 {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ctrl-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.ctrl-select {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.rule-card {
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding: 12px;
|
||||
|
||||
.rule-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.rule-index {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rule-name {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rc-sec {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin: 10px 0 6px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&.match::before {
|
||||
background: #e6a23c;
|
||||
}
|
||||
|
||||
&.price::before {
|
||||
background: #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
.rc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 0 12px;
|
||||
|
||||
.rc-control {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 媒体价格模板区(token 单位):基础价下方的媒体覆盖价动态行 */
|
||||
.media-price-section {
|
||||
margin-top: 12px;
|
||||
border-top: 1px dashed #e4e7ed;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.media-price-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.mp-media {
|
||||
width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mp-num {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.media-add-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mp-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.7;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.note {
|
||||
border: 1px solid #e6a23c;
|
||||
background: #fdf6ec;
|
||||
color: #b88230;
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
@@ -1,281 +0,0 @@
|
||||
<template>
|
||||
<div class="subject-list">
|
||||
<div v-if="loading" class="subject-loading">加载计价对象…</div>
|
||||
<template v-else>
|
||||
<!-- 模型类型筛选 -->
|
||||
<div v-if="hasModel" class="filter-bar">
|
||||
<button
|
||||
v-for="f in FILTER_OPTIONS"
|
||||
:key="f.value"
|
||||
class="filter-chip"
|
||||
:class="{ active: modelFilter === f.value }"
|
||||
@click="modelFilter = f.value"
|
||||
>
|
||||
{{ f.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 工作流 -->
|
||||
<div class="grp-head">
|
||||
<span class="grp-name">工作流</span>
|
||||
<span class="grp-count">{{ workflowSubjects.length }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="s in workflowSubjects"
|
||||
:key="`${s.type}:${s.id}`"
|
||||
class="subj-item"
|
||||
:class="{ active: `${s.type}:${s.id}` === currentKey }"
|
||||
@click="emit('select', s)"
|
||||
>
|
||||
<span class="type-badge t-workflow">工作流</span>
|
||||
<span class="subj-name" :title="s.name">{{ s.name }}</span>
|
||||
<span v-for="m in s.modes" :key="m" class="mode-chip">{{ m }}</span>
|
||||
<span
|
||||
class="status-dot"
|
||||
:class="s.hasConfig ? 'on' : 'off'"
|
||||
:title="s.hasConfig ? '已配置' : '未配置'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 模型(支持类型筛选) -->
|
||||
<div class="grp-head">
|
||||
<span class="grp-name">模型</span>
|
||||
<span class="grp-count">{{ filteredModels.length }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="s in filteredModels"
|
||||
:key="`${s.type}:${s.id}`"
|
||||
class="subj-item"
|
||||
:class="{ active: `${s.type}:${s.id}` === currentKey }"
|
||||
@click="emit('select', s)"
|
||||
>
|
||||
<span class="type-badge" :class="`t-${s.modelType}`">{{ s.typeLabel }}</span>
|
||||
<span class="subj-name" :title="s.name">{{ s.name }}</span>
|
||||
<span
|
||||
class="status-dot"
|
||||
:class="s.hasConfig ? 'on' : 'off'"
|
||||
:title="s.hasConfig ? '已配置' : '未配置'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!filteredModels.length" class="empty-tip">该类型暂无模型</div>
|
||||
|
||||
<!-- 业务模块 -->
|
||||
<div class="grp-head">
|
||||
<span class="grp-name">业务模块</span>
|
||||
<span class="grp-count">{{ businessSubjects.length }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="s in businessSubjects"
|
||||
:key="`${s.type}:${s.id}`"
|
||||
class="subj-item"
|
||||
:class="{ active: `${s.type}:${s.id}` === currentKey }"
|
||||
@click="emit('select', s)"
|
||||
>
|
||||
<span class="type-badge t-business">业务</span>
|
||||
<span class="subj-name" :title="s.name">{{ s.name }}</span>
|
||||
<span v-for="m in s.modes" :key="m" class="mode-chip">{{ m }}</span>
|
||||
<span
|
||||
class="status-dot"
|
||||
:class="s.hasConfig ? 'on' : 'off'"
|
||||
:title="s.hasConfig ? '已配置' : '未配置'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import type { ModelType, Subject } from '../mock';
|
||||
|
||||
/**
|
||||
* 左侧计价对象枚举列表。
|
||||
* 分三组渲染(工作流 / 模型 / 业务模块),模型组支持类型筛选;
|
||||
* 配置状态以实心(已配置)/ 空心(未配置)圆点呈现。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
subjects: Subject[];
|
||||
currentKey: string;
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ select: [subject: Subject] }>();
|
||||
|
||||
const workflowSubjects = computed(() => props.subjects.filter((s) => s.type === 'workflow'));
|
||||
const modelSubjects = computed(() => props.subjects.filter((s) => s.type === 'model'));
|
||||
const businessSubjects = computed(() => props.subjects.filter((s) => s.type === 'business'));
|
||||
|
||||
const hasModel = computed(() => modelSubjects.value.length > 0);
|
||||
|
||||
/** 模型类型筛选 */
|
||||
const FILTER_OPTIONS: { value: ModelType | 'all'; label: string }[] = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'reason', label: '推理' },
|
||||
{ value: 'video', label: '视频' },
|
||||
{ value: 'audio', label: '音频' },
|
||||
{ value: 'image', label: '图片' },
|
||||
];
|
||||
const modelFilter = ref<ModelType | 'all'>('all');
|
||||
const filteredModels = computed(() =>
|
||||
modelFilter.value === 'all'
|
||||
? modelSubjects.value
|
||||
: modelSubjects.value.filter((s) => s.modelType === modelFilter.value),
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.subject-list {
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 10px 16px 2px;
|
||||
|
||||
.filter-chip {
|
||||
border: 1px solid #dcdfe6;
|
||||
background: #fff;
|
||||
color: #606266;
|
||||
border-radius: 14px;
|
||||
padding: 3px 12px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
line-height: 1.4;
|
||||
|
||||
&:hover {
|
||||
color: #409eff;
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #ecf5ff;
|
||||
border-color: #409eff;
|
||||
color: #409eff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.grp-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 14px 16px 6px;
|
||||
|
||||
.grp-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #909399;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.grp-count {
|
||||
font-size: 11px;
|
||||
background: #f4f4f5;
|
||||
color: #909399;
|
||||
border-radius: 8px;
|
||||
padding: 0 6px;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.subject-loading {
|
||||
padding: 20px 16px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 10px 16px;
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subj-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 16px;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #ecf5ff;
|
||||
border-left-color: #409eff;
|
||||
}
|
||||
|
||||
.subj-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
font-size: 11px;
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.t-workflow {
|
||||
background: #8e44ad;
|
||||
}
|
||||
|
||||
&.t-reason {
|
||||
background: #e6a23c;
|
||||
}
|
||||
|
||||
&.t-video {
|
||||
background: #909399;
|
||||
}
|
||||
|
||||
&.t-audio {
|
||||
background: #67c23a;
|
||||
}
|
||||
|
||||
&.t-image {
|
||||
background: #00bcd4;
|
||||
}
|
||||
|
||||
&.t-business {
|
||||
background: #e74c3c;
|
||||
}
|
||||
}
|
||||
|
||||
.mode-chip {
|
||||
font-size: 11px;
|
||||
background: #f4f4f5;
|
||||
color: #606266;
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.on {
|
||||
background: #67c23a;
|
||||
box-shadow: 0 0 0 2px rgba(103, 194, 58, 0.2);
|
||||
}
|
||||
|
||||
&.off {
|
||||
background: #fff;
|
||||
border: 1px solid #c0c4cc;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,334 +0,0 @@
|
||||
<template>
|
||||
<div class="editor-body">
|
||||
<div class="editor-col">
|
||||
<!-- 计费模式 -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">计费模式</span>
|
||||
<span class="head-hint">可同时开启多套计费</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- 按条计费 per_item -->
|
||||
<div class="mode-item">
|
||||
<div class="mode-item-head">
|
||||
<span class="mode-name">按条计费 <span class="mode-code">per_item</span></span>
|
||||
<el-switch v-model="modes.per_item" />
|
||||
</div>
|
||||
<div v-if="modes.per_item" class="mode-item-body">
|
||||
<el-form label-position="top" size="default">
|
||||
<el-form-item label="档位列表(上不封顶,命中首个 maxSec ≥ 时长 的档)">
|
||||
<div class="tier-list">
|
||||
<div v-for="(tier, i) in tiers" :key="i" class="tier-row">
|
||||
<el-input-number
|
||||
v-model="tier.maxSec"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
:controls="false"
|
||||
placeholder="maxSec"
|
||||
class="tier-input"
|
||||
/>
|
||||
<el-input-number
|
||||
v-model="tier.price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
placeholder="价格(元)"
|
||||
class="tier-input"
|
||||
/>
|
||||
<el-button size="small" type="danger" text @click="removeTier(i)">删除</el-button>
|
||||
</div>
|
||||
<el-button size="small" class="add-btn" @click="addTier">+ 添加档位</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="超出最大档单价(元/秒)">
|
||||
<el-input-number
|
||||
v-model="overflowUnitPrice"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.01"
|
||||
:controls="false"
|
||||
class="ctrl-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按秒计费 per_second -->
|
||||
<div class="mode-item">
|
||||
<div class="mode-item-head">
|
||||
<span class="mode-name">按秒计费 <span class="mode-code">per_second</span></span>
|
||||
<el-switch v-model="modes.per_second" />
|
||||
</div>
|
||||
<div v-if="modes.per_second" class="mode-item-body">
|
||||
<el-form label-position="top" size="default">
|
||||
<el-form-item label="单价(元/秒,不满 1 秒向上取整)">
|
||||
<el-input-number
|
||||
v-model="perSecondUnitPrice"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.01"
|
||||
:controls="false"
|
||||
class="ctrl-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按 token 计费 per_token -->
|
||||
<div class="mode-item">
|
||||
<div class="mode-item-head">
|
||||
<span class="mode-name">按 Token 计费 <span class="mode-code">per_token</span></span>
|
||||
<el-switch v-model="modes.per_token" />
|
||||
</div>
|
||||
<div v-if="modes.per_token" class="mode-item-body">
|
||||
<div class="form-hint">
|
||||
价格<b>取自各模型自己的计价配置</b>(模型列表里配的那份),不在此重复配置。未配价的模型按 0
|
||||
计。⚠️ per_token 费率不冻结在快照,结算时按模型实时价。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-col">
|
||||
<!-- 通用配置 -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">通用配置</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<el-form label-position="top" size="default">
|
||||
<el-form-item label="门禁 min_balance(元)">
|
||||
<el-input-number
|
||||
v-model="minBalance"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.01"
|
||||
:controls="false"
|
||||
class="ctrl-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="币种">
|
||||
<el-select v-model="currency" class="ctrl-select">
|
||||
<el-option label="人民币 (CNY)" value="CNY" />
|
||||
<el-option label="美元 (USD)" value="USD" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="enabled" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import {
|
||||
WORKFLOW_DEFAULT_TIERS,
|
||||
type Currency,
|
||||
type EditorInitConfig,
|
||||
type Subject,
|
||||
type WorkflowRules,
|
||||
type WorkflowTier,
|
||||
} from '../mock';
|
||||
|
||||
/**
|
||||
* 工作流计价编辑面板。
|
||||
* 三套计费模式开关(per_item 档位表 / per_second 单价 / per_token 占位)。
|
||||
* initConfig 传入后端已保存配置(parse 后)时初始化表单;无配置时用默认档位。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
subject: Subject;
|
||||
initConfig: EditorInitConfig | null;
|
||||
}>();
|
||||
|
||||
const modes = reactive({ per_item: true, per_second: false, per_token: false });
|
||||
const tiers = ref<WorkflowTier[]>(WORKFLOW_DEFAULT_TIERS.map((t) => ({ ...t })));
|
||||
const overflowUnitPrice = ref(0.9);
|
||||
const perSecondUnitPrice = ref(0.9);
|
||||
const minBalance = ref(0);
|
||||
const currency = ref<Currency>('CNY');
|
||||
const enabled = ref(true);
|
||||
|
||||
function addTier() {
|
||||
tiers.value.push({ maxSec: 60, price: 89 });
|
||||
}
|
||||
|
||||
function removeTier(index: number) {
|
||||
tiers.value.splice(index, 1);
|
||||
}
|
||||
|
||||
/** 用后端已保存配置初始化表单 */
|
||||
function applyConfig(cfg: EditorInitConfig) {
|
||||
const r = cfg.rules as WorkflowRules;
|
||||
modes.per_item = !!r.per_item;
|
||||
modes.per_second = !!r.per_second;
|
||||
modes.per_token = !!r.per_token;
|
||||
tiers.value = r.per_item?.tiers?.map((t) => ({ ...t })) ?? WORKFLOW_DEFAULT_TIERS.map((t) => ({ ...t }));
|
||||
overflowUnitPrice.value = r.per_item?.overflowUnitPrice ?? 0.9;
|
||||
perSecondUnitPrice.value = r.per_second?.unitPrice ?? 0.9;
|
||||
minBalance.value = cfg.minBalance;
|
||||
currency.value = cfg.currency;
|
||||
enabled.value = cfg.enabled;
|
||||
}
|
||||
|
||||
/** 重置为默认(无配置) */
|
||||
function resetDefault() {
|
||||
modes.per_item = true;
|
||||
modes.per_second = false;
|
||||
modes.per_token = false;
|
||||
tiers.value = WORKFLOW_DEFAULT_TIERS.map((t) => ({ ...t }));
|
||||
overflowUnitPrice.value = 0.9;
|
||||
perSecondUnitPrice.value = 0.9;
|
||||
minBalance.value = 0;
|
||||
currency.value = 'CNY';
|
||||
enabled.value = true;
|
||||
}
|
||||
|
||||
function save(): { rules: WorkflowRules; minBalance: number; currency: Currency; enabled: boolean } {
|
||||
const rules: WorkflowRules = {};
|
||||
// 只把开启的模式写入费率 JSON
|
||||
if (modes.per_item) {
|
||||
rules.per_item = { tiers: tiers.value.filter((t) => t.maxSec > 0), overflowUnitPrice: overflowUnitPrice.value };
|
||||
}
|
||||
if (modes.per_second) rules.per_second = { unitPrice: perSecondUnitPrice.value };
|
||||
if (modes.per_token) rules.per_token = {};
|
||||
return {
|
||||
rules,
|
||||
minBalance: minBalance.value,
|
||||
currency: currency.value,
|
||||
enabled: enabled.value,
|
||||
};
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.initConfig,
|
||||
(cfg) => {
|
||||
if (cfg && cfg.rules && !('unit' in (cfg.rules as Record<string, unknown>))) {
|
||||
applyConfig(cfg);
|
||||
} else {
|
||||
resetDefault();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
defineExpose({ save });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.editor-body {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editor-col {
|
||||
flex: 1;
|
||||
min-width: 380px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
|
||||
.card-head {
|
||||
background: #fafafa;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.head-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.mode-item {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.mode-item-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px 6px 0 0;
|
||||
|
||||
.mode-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
||||
.mode-code {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #909399;
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mode-item-body {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.tier-list {
|
||||
.tier-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.tier-input {
|
||||
width: 130px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.ctrl-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.ctrl-select {
|
||||
width: 220px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,449 +0,0 @@
|
||||
<template>
|
||||
<div class="system-price-container">
|
||||
<el-card shadow="hover" class="price-card">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="page-toolbar">
|
||||
<div class="page-toolbar-left">
|
||||
<div class="page-title">计价配置管理</div>
|
||||
<div class="page-subtitle">统一管理工作流 / 模型 / 业务模块的计费规则与门禁</div>
|
||||
</div>
|
||||
<div class="page-toolbar-actions">
|
||||
<el-button :loading="loading" :disabled="!subjects.length" @click="handleRefresh">刷新</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="!current" @click="handleSave">保存配置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="price-layout">
|
||||
<!-- 左侧:计价对象枚举 -->
|
||||
<div class="subject-panel">
|
||||
<div class="subject-head">
|
||||
<span class="subject-title">计价对象</span>
|
||||
<span class="subject-count">{{ subjects.length }} 项</span>
|
||||
</div>
|
||||
<SubjectList :subjects="subjects" :current-key="currentKey" :loading="loading" @select="onSelect" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧:配置编辑 -->
|
||||
<div class="editor-panel">
|
||||
<div class="editor-overview">
|
||||
<div class="overview-main">
|
||||
<div class="overview-title">
|
||||
{{ current?.name ?? '计价对象' }}
|
||||
<span v-if="current?.type === 'model'" class="type-tag" :class="`t-${current.modelType}`">
|
||||
{{ current.typeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="overview-meta">
|
||||
<span v-if="current" class="conf-status" :class="current.hasConfig ? 'ok' : 'no'">
|
||||
{{ current.hasConfig ? '已配置' : '未配置' }}
|
||||
</span>
|
||||
<span v-if="current && lastUpdated" class="update-time">更新于 {{ lastUpdated }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-sub">{{ headerSub }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 面板切换:v-if 每次切换重建,initConfig 驱动回显后端已保存配置 -->
|
||||
<template v-if="current">
|
||||
<WorkflowEditor
|
||||
v-if="current.type === 'workflow'"
|
||||
ref="workflowRef"
|
||||
:subject="current"
|
||||
:init-config="initConfig"
|
||||
/>
|
||||
<ModelEditor
|
||||
v-else-if="current.type === 'model'"
|
||||
ref="modelRef"
|
||||
:key="currentKey"
|
||||
:subject="current"
|
||||
:init-config="initConfig"
|
||||
/>
|
||||
<BusinessEditor
|
||||
v-else-if="current.type === 'business'"
|
||||
ref="businessRef"
|
||||
:subject="current"
|
||||
:init-config="initConfig"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="editor-empty">请选择左侧计价对象</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import SubjectList from './component/subjectList.vue';
|
||||
import WorkflowEditor from './component/workflowEditor.vue';
|
||||
import ModelEditor from './component/modelEditor.vue';
|
||||
import BusinessEditor from './component/businessEditor.vue';
|
||||
import { getPricingConfig, getSubjects, savePricingConfig } from '/@/api/trade/pricing';
|
||||
import {
|
||||
toSubject,
|
||||
type ApiSubjectItem,
|
||||
type BusinessRules,
|
||||
type Currency,
|
||||
type EditorInitConfig,
|
||||
type ModelRules,
|
||||
type Subject,
|
||||
type WorkflowRules,
|
||||
} from './mock';
|
||||
|
||||
/**
|
||||
* 价格管理(计价配置管理)页面。
|
||||
* 左侧计价对象枚举来自后端 subjects 接口,右侧按对象加载 config/get 详情并编辑;
|
||||
* 保存时把编辑器构建的费率对象 stringify 为 rules 字符串提交 config/save。
|
||||
*/
|
||||
|
||||
/** 后端 config/get 返回的配置记录(rules 为费率对象;兼容历史字符串形态) */
|
||||
interface PricingDetailRaw {
|
||||
/** 配置记录 id(雪花 id 超 JS 安全整数,用字符串承载避免精度丢失) */
|
||||
id: string | number;
|
||||
subjectType: string;
|
||||
subjectId: string;
|
||||
/** rules 为费率对象;旧接口可能返回 JSON 字符串,解析时兼容 */
|
||||
rules: string | Record<string, unknown>;
|
||||
minBalance: number;
|
||||
currency: string;
|
||||
enabled: number;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/** 编辑器 save() 的返回结构(父组件组装提交) */
|
||||
type SaveResult = {
|
||||
rules: WorkflowRules | ModelRules | BusinessRules;
|
||||
minBalance: number;
|
||||
currency: Currency;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
const subjects = ref<Subject[]>([]);
|
||||
const currentKey = ref('');
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
/** 当前配置记录 id(>0 更新,0 新增;雪花 id 用字符串承载,避免精度丢失) */
|
||||
const currentConfigId = ref<string | number>(0);
|
||||
/** 当前对象已保存配置(parse 后),传给编辑器回显;null 表示无配置 */
|
||||
const initConfig = ref<EditorInitConfig | null>(null);
|
||||
/** 当前配置最近更新时间(展示用) */
|
||||
const lastUpdated = ref('');
|
||||
|
||||
const workflowRef = ref<InstanceType<typeof WorkflowEditor>>();
|
||||
const modelRef = ref<InstanceType<typeof ModelEditor>>();
|
||||
const businessRef = ref<InstanceType<typeof BusinessEditor>>();
|
||||
|
||||
const current = computed<Subject | undefined>(() =>
|
||||
subjects.value.find((s) => `${s.type}:${s.id}` === currentKey.value),
|
||||
);
|
||||
|
||||
const headerSub = computed(() => {
|
||||
const s = current.value;
|
||||
if (!s) return '加载计价对象…';
|
||||
if (s.type === 'workflow') return `subjectType=workflow · subjectId=${s.id}`;
|
||||
if (s.type === 'model') return `subjectType=model · subjectId=${s.id}(${s.typeLabel},modelType=${s.modelType})`;
|
||||
return `subjectType=business · subjectId=${s.id}`;
|
||||
});
|
||||
|
||||
/** 详情请求序号,防止快速切换时旧响应覆盖当前对象 */
|
||||
let detailSeq = 0;
|
||||
|
||||
function onSelect(subject: Subject) {
|
||||
// 先清空 initConfig,避免 v-if 重建的编辑器拿到上一个对象的配置
|
||||
initConfig.value = null;
|
||||
currentConfigId.value = 0;
|
||||
currentKey.value = `${subject.type}:${subject.id}`;
|
||||
loadDetail(subject);
|
||||
}
|
||||
|
||||
/** 拉取某对象已保存的计价配置,填充编辑器回显并刷新"已配置"状态 */
|
||||
async function loadDetail(subject: Subject) {
|
||||
const seq = ++detailSeq;
|
||||
try {
|
||||
const res = await getPricingConfig({ subjectType: subject.type, subjectId: subject.id });
|
||||
const data = res?.data as PricingDetailRaw | undefined;
|
||||
if (data && data.rules) {
|
||||
let parsed: EditorInitConfig['rules'];
|
||||
try {
|
||||
// 后端 config/get 的 rules 为费率对象;兼容旧接口返回的 JSON 字符串形态
|
||||
parsed =
|
||||
typeof data.rules === 'string'
|
||||
? JSON.parse(data.rules)
|
||||
: (data.rules as unknown as EditorInitConfig['rules']);
|
||||
} catch {
|
||||
parsed = undefined as unknown as EditorInitConfig['rules'];
|
||||
}
|
||||
if (parsed) {
|
||||
if (seq !== detailSeq) return;
|
||||
subject.hasConfig = true;
|
||||
currentConfigId.value = data.id;
|
||||
lastUpdated.value = formatTime(data.updatedAt);
|
||||
initConfig.value = {
|
||||
id: data.id,
|
||||
minBalance: data.minBalance ?? 0,
|
||||
currency: (data.currency as Currency) || 'CNY',
|
||||
enabled: data.enabled === 1,
|
||||
rules: parsed,
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (seq !== detailSeq) return;
|
||||
subject.hasConfig = false;
|
||||
currentConfigId.value = 0;
|
||||
lastUpdated.value = '';
|
||||
initConfig.value = null;
|
||||
} catch {
|
||||
// 详情拉取失败按无配置处理(错误提示由全局拦截器负责)
|
||||
if (seq !== detailSeq) return;
|
||||
subject.hasConfig = false;
|
||||
currentConfigId.value = 0;
|
||||
lastUpdated.value = '';
|
||||
initConfig.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubjects() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getSubjects();
|
||||
const list: ApiSubjectItem[] = res?.data?.subjects ?? [];
|
||||
subjects.value = list.map(toSubject);
|
||||
// 默认选中第一个模型(对齐原型默认打开模型),无模型则第一个对象
|
||||
const first = subjects.value.find((s) => s.type === 'model') ?? subjects.value[0];
|
||||
if (first) onSelect(first);
|
||||
} catch {
|
||||
// 枚举拉取失败:全局拦截器已提示,留空列表
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 刷新枚举:保留当前选中(若对象仍存在),否则回退到第一个模型 */
|
||||
async function handleRefresh() {
|
||||
const keepKey = currentKey.value;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getSubjects();
|
||||
const list: ApiSubjectItem[] = res?.data?.subjects ?? [];
|
||||
subjects.value = list.map(toSubject);
|
||||
const kept = subjects.value.find((s) => `${s.type}:${s.id}` === keepKey);
|
||||
if (kept) {
|
||||
initConfig.value = null;
|
||||
currentConfigId.value = 0;
|
||||
lastUpdated.value = '';
|
||||
await loadDetail(kept);
|
||||
} else {
|
||||
const first = subjects.value.find((s) => s.type === 'model') ?? subjects.value[0];
|
||||
if (first) onSelect(first);
|
||||
}
|
||||
} catch {
|
||||
// 枚举拉取失败:全局拦截器已提示
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const s = current.value;
|
||||
if (!s) return;
|
||||
// v-if 保证当前对象对应编辑器已挂载,ref 非空
|
||||
let result: SaveResult;
|
||||
if (s.type === 'workflow') result = workflowRef.value!.save();
|
||||
else if (s.type === 'model') result = modelRef.value!.save();
|
||||
else result = businessRef.value!.save();
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await savePricingConfig({
|
||||
id: currentConfigId.value,
|
||||
subjectType: s.type,
|
||||
subjectId: s.id,
|
||||
rules: result.rules,
|
||||
minBalance: result.minBalance,
|
||||
currency: result.currency,
|
||||
enabled: result.enabled ? 1 : 0,
|
||||
});
|
||||
ElMessage.success('计价配置保存成功');
|
||||
s.hasConfig = true;
|
||||
// 保存后重拉详情,刷新配置 id 与回显(新增场景 id 以后端返回为准)
|
||||
await loadDetail(s);
|
||||
} catch {
|
||||
// 保存失败:错误提示由全局拦截器负责
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 时间格式化:YYYY-MM-DD HH:mm */
|
||||
function formatTime(t?: string): string {
|
||||
if (!t) return '';
|
||||
const d = new Date(t);
|
||||
if (Number.isNaN(d.getTime())) return t;
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
onMounted(loadSubjects);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.system-price-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.price-card {
|
||||
:deep(.el-card__body) {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 顶部操作栏 */
|
||||
.page-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.page-toolbar-left {
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-toolbar-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.price-layout {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.subject-panel {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid #ebeef5;
|
||||
|
||||
.subject-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 16px 12px;
|
||||
|
||||
.subject-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subject-count {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.editor-overview {
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.overview-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
.overview-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.type-tag {
|
||||
font-size: 11px;
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
color: #fff;
|
||||
font-weight: 400;
|
||||
|
||||
&.t-reason {
|
||||
background: #e6a23c;
|
||||
}
|
||||
|
||||
&.t-video {
|
||||
background: #909399;
|
||||
}
|
||||
|
||||
&.t-audio {
|
||||
background: #67c23a;
|
||||
}
|
||||
|
||||
&.t-image {
|
||||
background: #00bcd4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.overview-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.conf-status {
|
||||
font-size: 12px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
|
||||
&.ok {
|
||||
background: #f0f9eb;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
&.no {
|
||||
background: #f4f4f5;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.update-time {
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.overview-sub {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editor-empty {
|
||||
padding: 60px 0;
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,366 +0,0 @@
|
||||
/**
|
||||
* 价格管理(计价配置管理)—— 类型定义、接口映射与本地示例数据
|
||||
*
|
||||
* 说明:
|
||||
* - 计价对象枚举来自后端 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: 'per_1M(每百万token)' },
|
||||
{ value: 'per_1K', label: 'per_1K(每千token)' },
|
||||
{ value: 'per_1', label: 'per_1(每个/每张图)' },
|
||||
{ value: 'per_second', label: 'per_second(每秒)' },
|
||||
{ value: 'per_minute', label: 'per_minute(每分钟)' },
|
||||
{ value: 'per_hour', label: 'per_hour(每小时)' },
|
||||
{ value: 'per_char', label: 'per_char(每字)' },
|
||||
];
|
||||
|
||||
/** 媒体价格模板可选媒体类型(媒体命中条件已移除,媒体改按模板定价) */
|
||||
export const MEDIA_PRICE_OPTIONS: { value: 'text' | 'audio' | 'video' | 'image'; label: string }[] = [
|
||||
{ value: 'text', label: '文本 text' },
|
||||
{ value: 'audio', label: '音频 audio' },
|
||||
{ value: 'video', label: '视频 video' },
|
||||
{ value: 'image', label: '图片 image' },
|
||||
];
|
||||
|
||||
/** 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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,111 +0,0 @@
|
||||
<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>
|
||||
@@ -1,68 +0,0 @@
|
||||
<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>
|
||||
@@ -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="320">
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
size="small"
|
||||
@@ -140,25 +140,6 @@
|
||||
>删除</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>
|
||||
@@ -173,8 +154,6 @@
|
||||
</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>
|
||||
|
||||
@@ -183,8 +162,6 @@ 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 {
|
||||
@@ -209,13 +186,11 @@ interface TableDataState {
|
||||
|
||||
export default defineComponent({
|
||||
name: 'systemUser',
|
||||
components: { EditUser, RechargeUser, WalletLog },
|
||||
components: { EditUser },
|
||||
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>>();
|
||||
@@ -284,14 +259,6 @@ 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 = '你确定要删除所选用户?';
|
||||
@@ -391,12 +358,8 @@ export default defineComponent({
|
||||
return {
|
||||
queryRef,
|
||||
editUserRef,
|
||||
rechargeUserRef,
|
||||
walletLogRef,
|
||||
onOpenAddUser,
|
||||
onOpenEditUser,
|
||||
onOpenRecharge,
|
||||
onOpenWalletLog,
|
||||
onRowDel,
|
||||
onHandleSizeChange,
|
||||
onHandleCurrentChange,
|
||||
|
||||
Reference in New Issue
Block a user