Compare commits
54
Commits
dev
..
ef4976ee03
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef4976ee03 | ||
|
|
cf6a17387d | ||
|
|
dd4e719f55 | ||
|
|
95dc89e097 | ||
|
|
dbd41cc045 | ||
|
|
d48875bd7a | ||
|
|
490cf4bb93 | ||
|
|
b140bff5f1 | ||
|
|
0232dc6e14 | ||
|
|
c51d0dc2ca | ||
|
|
918ae96f71 | ||
|
|
cb6792cb29 | ||
|
|
a95fe2d73f | ||
|
|
73036038fd | ||
|
|
e596c9b5ae | ||
|
|
c441ccec94 | ||
|
|
c432eb431a | ||
|
|
4d74dd2d63 | ||
|
|
daf9f5147b | ||
|
|
de2e1d8902 | ||
|
|
28cc58bca0 | ||
|
|
9f94f4a9ab | ||
|
|
03697b9747 | ||
|
|
ee5120c6ed | ||
|
|
ba5ff9b87d | ||
|
|
e2a5480bf9 | ||
|
|
abc5b47f72 | ||
|
|
63ac39cd4a | ||
|
|
20bab0997e | ||
|
|
3cddff6df2 | ||
|
|
c1658380a7 | ||
|
|
998e13e6be | ||
|
|
68850c40fe | ||
|
|
d2143809de | ||
|
|
68bdc63231 | ||
|
|
b6208b37e4 | ||
|
|
f7b3531392 | ||
|
|
c8f457dada | ||
|
|
0421adb6ac | ||
|
|
c962cdd830 | ||
|
|
13a2aeee9f | ||
|
|
bb15c053e6 | ||
|
|
dc20a6a829 | ||
|
|
b8d29e31df | ||
|
|
41655fa825 | ||
|
|
3b71f144c4 | ||
|
|
c4c2625714 | ||
|
|
7e7a3cbf2d | ||
|
|
a65b898680 | ||
|
|
315d05c3ea | ||
|
|
362cb4332f | ||
|
|
4ec05b507a | ||
|
|
c088894546 | ||
|
|
8e7948cff5 |
+1
-1
@@ -5,4 +5,4 @@ ENV = 'development'
|
|||||||
# 开发环境走本地代理,避免 CORS
|
# 开发环境走本地代理,避免 CORS
|
||||||
VITE_API_URL = 'http://116.204.74.41:8000'
|
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.30:8000'
|
||||||
# VITE_API_URL = 'http://192.168.3.135:8000'
|
# VITE_API_URL = 'http://192.168.3.135:8000'
|
||||||
|
|||||||
+29
@@ -6,6 +6,12 @@ map $http_user_agent $badbot {
|
|||||||
~*(GPTBot|ChatGPT-User|Claude-Web|ClaudeBot|Bytespider|CCBot|Google-Extended) 1;
|
~*(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 内容)
|
# 主站 HTTP(SSL 已在外层 nginx 终结,此处只需 serve 内容)
|
||||||
server {
|
server {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
@@ -59,6 +65,9 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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_connect_timeout 75s;
|
||||||
proxy_send_timeout 86400s;
|
proxy_send_timeout 86400s;
|
||||||
proxy_read_timeout 86400s;
|
proxy_read_timeout 86400s;
|
||||||
@@ -146,6 +155,23 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
# observer HTTP(转发到 18080)
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name observer.redpowerfuture.com;
|
||||||
|
location / {
|
||||||
|
proxy_pass http://__API_HOST__:18080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
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 反向代理) ====================
|
# ==================== 主站 HTTPS(静态文件 + API 反向代理) ====================
|
||||||
server {
|
server {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
@@ -203,6 +229,9 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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_connect_timeout 75s;
|
||||||
proxy_send_timeout 86400s;
|
proxy_send_timeout 86400s;
|
||||||
proxy_read_timeout 86400s;
|
proxy_read_timeout 86400s;
|
||||||
|
|||||||
Generated
+50
@@ -23,17 +23,21 @@
|
|||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.1",
|
||||||
"countup.js": "^2.8.0",
|
"countup.js": "^2.8.0",
|
||||||
"cropperjs": "^1.6.0",
|
"cropperjs": "^1.6.0",
|
||||||
|
"dompurify": "^3.4.13",
|
||||||
"echarts": "^5.5.0",
|
"echarts": "^5.5.0",
|
||||||
"echarts-gl": "^2.0.9",
|
"echarts-gl": "^2.0.9",
|
||||||
"echarts-wordcloud": "^2.1.0",
|
"echarts-wordcloud": "^2.1.0",
|
||||||
"element-china-area-data": "^6.1.0",
|
"element-china-area-data": "^6.1.0",
|
||||||
"element-plus": "^2.10.7",
|
"element-plus": "^2.10.7",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
|
"highlight.js": "^11.12.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"json-schema-editor": "github:wusij/json-schema#8827d0d6a360c37bc32a8945fb979260bd4a4c85",
|
"json-schema-editor": "github:wusij/json-schema#8827d0d6a360c37bc32a8945fb979260bd4a4c85",
|
||||||
"jsplumb": "^2.15.6",
|
"jsplumb": "^2.15.6",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
"marked": "^18.0.9",
|
||||||
|
"marked-highlight": "^2.2.4",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
@@ -1945,6 +1949,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/web-bluetooth": {
|
||||||
"version": "0.0.16",
|
"version": "0.0.16",
|
||||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz",
|
||||||
@@ -3248,6 +3259,15 @@
|
|||||||
"ssr-window": "^3.0.0-alpha.1"
|
"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": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.6.1",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
@@ -4170,6 +4190,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/hoist-non-react-statics": {
|
||||||
"version": "2.5.5",
|
"version": "2.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz",
|
||||||
@@ -4576,6 +4605,27 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|||||||
@@ -28,17 +28,21 @@
|
|||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.1",
|
||||||
"countup.js": "^2.8.0",
|
"countup.js": "^2.8.0",
|
||||||
"cropperjs": "^1.6.0",
|
"cropperjs": "^1.6.0",
|
||||||
|
"dompurify": "^3.4.13",
|
||||||
"echarts": "^5.5.0",
|
"echarts": "^5.5.0",
|
||||||
"echarts-gl": "^2.0.9",
|
"echarts-gl": "^2.0.9",
|
||||||
"echarts-wordcloud": "^2.1.0",
|
"echarts-wordcloud": "^2.1.0",
|
||||||
"element-china-area-data": "^6.1.0",
|
"element-china-area-data": "^6.1.0",
|
||||||
"element-plus": "^2.10.7",
|
"element-plus": "^2.10.7",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
|
"highlight.js": "^11.12.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"json-schema-editor": "github:wusij/json-schema#8827d0d6a360c37bc32a8945fb979260bd4a4c85",
|
"json-schema-editor": "github:wusij/json-schema#8827d0d6a360c37bc32a8945fb979260bd4a4c85",
|
||||||
"jsplumb": "^2.15.6",
|
"jsplumb": "^2.15.6",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
"marked": "^18.0.9",
|
||||||
|
"marked-highlight": "^2.2.4",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface AsyncTaskMapping {
|
|||||||
url: string;
|
url: string;
|
||||||
httpMethod: string;
|
httpMethod: string;
|
||||||
requestHeadMapping: Record<string, unknown>;
|
requestHeadMapping: Record<string, unknown>;
|
||||||
|
requestBodyMapping?: Record<string, unknown>;
|
||||||
responseMapping: Record<string, unknown>;
|
responseMapping: Record<string, unknown>;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
taskStatus: string;
|
taskStatus: string;
|
||||||
@@ -40,21 +41,26 @@ export interface ModelManageItem {
|
|||||||
updater: string;
|
updater: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
deletedAt: null | string;
|
deletedAt: null | string;
|
||||||
modelSupplier: string;
|
modelSupplier: number;
|
||||||
modelName: string;
|
modelName: string;
|
||||||
modelType: number;
|
modelType: number;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
responseType: number;
|
responseType: number;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
ChatModel: boolean;
|
chatModel?: boolean;
|
||||||
maxConcurrency: number;
|
maxConcurrency: number;
|
||||||
maxTokens: number;
|
maxTokens: number;
|
||||||
tokenPredictPrice: number;
|
tokenPredictPrice: number;
|
||||||
requestHeadMapping: Record<string, unknown>;
|
requestHeadMapping: Record<string, unknown>;
|
||||||
requestBodyMapping: Record<string, unknown>;
|
requestBodyMapping: Record<string, unknown>;
|
||||||
|
requestBusinessFieldMapping?: Record<string, string>;
|
||||||
|
error_message_mapping?: Record<string, unknown>;
|
||||||
responseMapping: Record<string, unknown>;
|
responseMapping: Record<string, unknown>;
|
||||||
responseBodyMapping: Record<string, string>;
|
responseBodyMapping: Record<string, string>;
|
||||||
|
responseBusinessFieldMapping?: Record<string, string>;
|
||||||
|
minDuration?: number;
|
||||||
|
priceConfig?: unknown;
|
||||||
tokenMapping?: TokenMapping;
|
tokenMapping?: TokenMapping;
|
||||||
asyncTaskMapping?: AsyncTaskMapping;
|
asyncTaskMapping?: AsyncTaskMapping;
|
||||||
lastFrame?: string;
|
lastFrame?: string;
|
||||||
@@ -166,8 +172,13 @@ export interface CreateModelManageParams {
|
|||||||
tokenPredictPrice?: number;
|
tokenPredictPrice?: number;
|
||||||
requestHeadMapping?: Record<string, unknown>;
|
requestHeadMapping?: Record<string, unknown>;
|
||||||
requestBodyMapping?: Record<string, unknown>;
|
requestBodyMapping?: Record<string, unknown>;
|
||||||
|
requestBusinessFieldMapping?: Record<string, string>;
|
||||||
|
error_message_mapping?: Record<string, unknown>;
|
||||||
responseMapping?: Record<string, unknown>;
|
responseMapping?: Record<string, unknown>;
|
||||||
responseBodyMapping?: Record<string, string>;
|
responseBodyMapping?: Record<string, string>;
|
||||||
|
responseBusinessFieldMapping?: Record<string, string>;
|
||||||
|
minDuration?: number;
|
||||||
|
priceConfig?: unknown;
|
||||||
tokenMapping?: TokenMapping;
|
tokenMapping?: TokenMapping;
|
||||||
asyncTaskMapping?: AsyncTaskMapping;
|
asyncTaskMapping?: AsyncTaskMapping;
|
||||||
lastFrame?: string;
|
lastFrame?: string;
|
||||||
@@ -219,3 +230,14 @@ export function updateModelManage(data: UpdateModelManageParams) {
|
|||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前会话模型(普通对话执行时使用的模型 id)
|
||||||
|
* 返回兼容 v2 惯例:data.modelManage.id(亦兼容 data.id / data.modelId)
|
||||||
|
*/
|
||||||
|
export function getChatModel() {
|
||||||
|
return request({
|
||||||
|
url: '/model-gateway/model/manage/getChatModel',
|
||||||
|
method: 'get',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import request from '/@/utils/request';
|
import request from '/@/utils/request';
|
||||||
|
import type { RequestOptions } from '/@/utils/request';
|
||||||
|
|
||||||
// ===== 节点库(V2 结构,workflow 专用;creation 继续使用 creation/index.ts 的旧结构)=====
|
// ===== 节点库(V2 结构,workflow 专用;creation 继续使用 creation/index.ts 的旧结构)=====
|
||||||
export interface NodeLibraryPresetOption {
|
export interface NodeLibraryPresetOption {
|
||||||
@@ -12,6 +13,10 @@ export interface NodeLibraryPresetOption {
|
|||||||
value: string;
|
value: string;
|
||||||
config?: NodeLibraryPresetOption[] | null;
|
config?: NodeLibraryPresetOption[] | null;
|
||||||
}> | null;
|
}> | null;
|
||||||
|
// 运行时已在用的扩展字段(补齐声明消除 TS 报错)
|
||||||
|
isFormField?: boolean;
|
||||||
|
constraint?: any;
|
||||||
|
defaultValue?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeLibraryItem {
|
export interface NodeLibraryItem {
|
||||||
@@ -21,15 +26,22 @@ export interface NodeLibraryItem {
|
|||||||
sort: number;
|
sort: number;
|
||||||
desc?: string;
|
desc?: string;
|
||||||
batchExecOption: boolean;
|
batchExecOption: boolean;
|
||||||
preToolOption: boolean;
|
// 是否允许该节点内「引用上级节点输出」多选(true 时所有引用下拉可多选;节点库返回,仅 model 节点 true)
|
||||||
postToolOption: boolean;
|
isMultiParameter: boolean;
|
||||||
|
// 前置/后置方法配置定义(结构同 presetOption,值为数组;仅 model 节点提供)
|
||||||
|
preToolOption: NodeLibraryPresetOption[] | null;
|
||||||
|
postToolOption: NodeLibraryPresetOption[] | null;
|
||||||
skillOption: boolean;
|
skillOption: boolean;
|
||||||
promptOption: boolean;
|
promptOption: boolean;
|
||||||
negativePromptOption: boolean;
|
negativePromptOption: boolean;
|
||||||
isSaveFileOption: boolean;
|
isSaveFileOption: boolean;
|
||||||
formConfigOption: boolean;
|
formConfigOption: boolean;
|
||||||
modelConfigOption: boolean;
|
modelConfigOption: boolean;
|
||||||
|
// 节点自带输出项([{ field, label }]):空/null 表示无输出;非空时该节点可作为下游节点的引用来源
|
||||||
|
outputField?: { field: string; label: string }[] | null;
|
||||||
presetOption: NodeLibraryPresetOption[] | null;
|
presetOption: NodeLibraryPresetOption[] | null;
|
||||||
|
// 是否支持贴片布局(节点库返回,仅 model 节点 true):控制节点配置面板「贴片布局」开关显示
|
||||||
|
patchLayout?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeLibraryGroup {
|
export interface NodeLibraryGroup {
|
||||||
@@ -52,12 +64,20 @@ export interface WorkflowModelItem {
|
|||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
enabled?: number | boolean;
|
enabled?: number | boolean;
|
||||||
isOwner?: number;
|
isOwner?: number;
|
||||||
|
// true = 系统内置模型(选它需填 API Key 转用户模型);false/缺省 = 用户模型
|
||||||
|
systemModel?: boolean;
|
||||||
// 模型请求参数模板(选择模型后渲染为递归表单,与 DSL modelRequestParams 结构一致)
|
// 模型请求参数模板(选择模型后渲染为递归表单,与 DSL modelRequestParams 结构一致)
|
||||||
requestBodyMapping?: Record<string, any>;
|
requestBodyMapping?: Record<string, any>;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWorkflowModelList(params?: { pageNum: number; pageSize: number; modelName?: string }) {
|
export function getWorkflowModelList(params?: {
|
||||||
|
pageNum: number;
|
||||||
|
pageSize: number;
|
||||||
|
modelName?: string;
|
||||||
|
isSameType?: boolean;
|
||||||
|
modelType?: string | number;
|
||||||
|
}) {
|
||||||
return request({
|
return request({
|
||||||
url: '/model-gateway/model/manage/listModelManage',
|
url: '/model-gateway/model/manage/listModelManage',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
@@ -81,3 +101,69 @@ export function getWorkflowSkillList(params?: { pageNum: number; pageSize: numbe
|
|||||||
params,
|
params,
|
||||||
}) as Promise<{ code: number; message: string; data: { list: WorkflowSkillItem[]; total: number } }>;
|
}) 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -65,7 +65,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="node.outputFields.length === 0" class="fcd-ref-node-empty">该节点无输出字段</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)">
|
<div v-for="of in node.outputFields" :key="of.field" class="fcd-ref-field" @click="setValueSource(node, of)">
|
||||||
{{ of.label || of.field }}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -206,7 +208,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="node.outputFields.length === 0" class="fcd-ref-node-empty">该节点无输出字段</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)">
|
<div v-for="of in node.outputFields" :key="of.field" class="fcd-ref-field" @click="setValueSource(node, of)">
|
||||||
{{ of.label || of.field }}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -305,7 +309,7 @@ interface UpstreamNodeInfo {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
nodeCode: string;
|
nodeCode: string;
|
||||||
outputFields: { field: string; label: string }[];
|
outputFields: { field: string; label: string; fieldType?: 'scalar' | 'object' | 'array' }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FieldFormData {
|
interface FieldFormData {
|
||||||
@@ -548,6 +552,9 @@ function handleClosed() { resetForm(); }
|
|||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.fcd-ref-popover {
|
.fcd-ref-popover {
|
||||||
.fcd-ref-panel {
|
.fcd-ref-panel {
|
||||||
|
// 面板超高时内部滚动,避免超出浏览器视口被截掉显示不全
|
||||||
|
max-height: 40vh;
|
||||||
|
overflow-y: auto;
|
||||||
.fcd-ref-empty {
|
.fcd-ref-empty {
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -581,6 +588,9 @@ function handleClosed() { resetForm(); }
|
|||||||
padding: 2px 0 2px 8px;
|
padding: 2px 0 2px 8px;
|
||||||
}
|
}
|
||||||
.fcd-ref-field {
|
.fcd-ref-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
@@ -590,6 +600,24 @@ function handleClosed() { resetForm(); }
|
|||||||
background: #eff6ff;
|
background: #eff6ff;
|
||||||
color: #3b82f6;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,82 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="chat-list">
|
<div ref="chatListRef" class="chat-list" @scroll.passive="handleScroll">
|
||||||
<div v-for="msg in messages" :key="msg.id" class="message-row" :class="{ 'is-user': msg.isUser }">
|
<!-- 顶部:有更多历史时上滑加载更早记录 -->
|
||||||
|
<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="bubble-wrap">
|
<div class="bubble-wrap">
|
||||||
<div class="bubble">{{ msg.content }}</div>
|
<!-- 工作流表单卡片:不套气泡,独立卡片渲染 -->
|
||||||
|
<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="time">{{ msg.time }}</div>
|
<div class="time">{{ msg.time }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -10,28 +84,266 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 {
|
interface ChatMessage {
|
||||||
id: string;
|
id: string;
|
||||||
content: string;
|
content: string;
|
||||||
time: string;
|
time: string;
|
||||||
isUser: boolean;
|
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 {
|
interface Props {
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
|
hasMore?: boolean;
|
||||||
|
loadingMore?: boolean;
|
||||||
|
// 产出相对路径 → 完整 URL 解析函数(由首页传入 buildAssetUrl),内嵌预览媒体需完整地址
|
||||||
|
resolveUrl?: (url: string) => string;
|
||||||
|
// InputBar 发送按钮触发执行:父组件定位目标卡片后,经此信号调用卡片内部提交
|
||||||
|
// (卡片参数在子组件内部,父组件无法直接取值,故由卡片暴露方法、这里中转调用)
|
||||||
|
executeRequest?: { msgId: string; action: 'submit'; nonce: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>();
|
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;
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.chat-list {
|
.chat-list {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 16px;
|
padding: 8px 0 16px;
|
||||||
padding: 28px 0 10px;
|
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); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-row {
|
.message-row {
|
||||||
@@ -45,37 +357,399 @@ defineProps<Props>();
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 节奏化消息间距: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 {
|
.bubble-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 5px;
|
gap: 4px;
|
||||||
max-width: min(84%, 1120px);
|
max-width: 100%;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
.message-row.is-user & {
|
||||||
|
align-items: flex-end;
|
||||||
|
/* 明确宽度基准:打断 fit-content 与百分比 max-width 的循环,避免气泡被压得过窄导致文字竖排 */
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble {
|
.bubble {
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
line-height: 1.75;
|
line-height: 1.7;
|
||||||
padding: 14px 16px;
|
padding: 0;
|
||||||
border-radius: 16px;
|
border-radius: 0;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
color: #1f2937;
|
color: #1f2328;
|
||||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.96) 0%, rgba(248, 250, 255, 0.92) 100%);
|
background: transparent;
|
||||||
border: 1px solid rgba(226, 233, 244, 0.95);
|
border: none;
|
||||||
backdrop-filter: blur(8px);
|
box-shadow: none;
|
||||||
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.05);
|
|
||||||
|
/* AI 消息:无背景卡片,纯文本左对齐(DeepSeek 式) */
|
||||||
|
.message-row:not(.is-user) & {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 880px;
|
||||||
|
}
|
||||||
|
|
||||||
.message-row.is-user & {
|
.message-row.is-user & {
|
||||||
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
|
width: fit-content;
|
||||||
border-color: rgba(59, 130, 246, 0.4);
|
max-width: 72%;
|
||||||
color: #fff;
|
padding: 10px 16px;
|
||||||
box-shadow: 0 8px 24px rgba(37, 99, 235, 0.35);
|
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;
|
||||||
|
word-break: break-word;
|
||||||
|
border-top: 1px solid #eef0f3;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes thinking-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
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 {
|
.time {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #8f9aae;
|
color: #8f9aae;
|
||||||
padding: 4px 6px;
|
padding: 4px 2px;
|
||||||
|
|
||||||
.message-row.is-user & {
|
.message-row.is-user & {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
<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,20 +2,23 @@
|
|||||||
<div class="input-shell">
|
<div class="input-shell">
|
||||||
<div class="input-card" :class="{ 'is-focused': isFocused }">
|
<div class="input-card" :class="{ 'is-focused': isFocused }">
|
||||||
<!-- 已选中状态标签栏 -->
|
<!-- 已选中状态标签栏 -->
|
||||||
<div v-if="selectedWorkflowId !== null" class="selected-tags">
|
<Transition name="capsule-slide" @enter="onCapsuleEnter" @after-enter="onCapsuleAfterEnter" @leave="onCapsuleLeave" @after-leave="onCapsuleAfterLeave">
|
||||||
<div class="selected-tag workflow-tag">
|
<div v-if="selectedWorkflowId !== null" class="selected-tags">
|
||||||
<el-icon><Promotion /></el-icon>
|
<div class="selected-tag workflow-tag">
|
||||||
<span>{{ commonWorkflows.find((w) => w.id === selectedWorkflowId)?.name }}</span>
|
<el-icon><Promotion /></el-icon>
|
||||||
<el-icon v-if="!workflowLocked" class="tag-close" @click="clearWorkflow"><Close /></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>
|
</div>
|
||||||
</div>
|
</Transition>
|
||||||
|
|
||||||
<!-- 文本输入区 -->
|
<!-- 文本输入区 -->
|
||||||
<el-input
|
<el-input
|
||||||
v-model="message"
|
v-model="message"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:autosize="{ minRows: 1, maxRows: 6 }"
|
:autosize="{ minRows: 1, maxRows: 6 }"
|
||||||
placeholder="有什么想聊的?按 Enter 发送,Shift+Enter 换行"
|
:disabled="generating || isWorkflowRunning || selectedWorkflowId !== null || isWorkflowSession"
|
||||||
|
:placeholder="inputPlaceholder"
|
||||||
class="message-input"
|
class="message-input"
|
||||||
@focus="isFocused = true"
|
@focus="isFocused = true"
|
||||||
@blur="isFocused = false"
|
@blur="isFocused = false"
|
||||||
@@ -26,89 +29,165 @@
|
|||||||
<!-- 底部工具栏 -->
|
<!-- 底部工具栏 -->
|
||||||
<div class="input-toolbar">
|
<div class="input-toolbar">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
<button class="tool-icon-btn" title="上传文件" @click="handleAttachment">
|
|
||||||
<el-icon><Paperclip /></el-icon>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- 技能暂已禁用 -->
|
<!-- 切换会话模型入口 -->
|
||||||
<button class="tool-icon-btn" title="技能暂不可用" disabled>
|
<button class="tool-icon-btn" :class="{ active: !!currentModelName }" title="切换会话模型" @click="emit('select-model')">
|
||||||
<el-icon><MagicStick /></el-icon>
|
<el-icon><Cpu /></el-icon>
|
||||||
<span class="tool-label">技能</span>
|
<span class="tool-label">{{ currentModelName || '设置模型' }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toolbar-right">
|
<div class="toolbar-right">
|
||||||
<span class="hint-text">Shift+Enter 换行</span>
|
<span class="hint-text">Shift+Enter 换行</span>
|
||||||
<button class="send-btn" :disabled="selectedWorkflowId === null || sendDisabled" @click="handleSend">
|
<button
|
||||||
<el-icon><Top /></el-icon>
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 快捷工作流胶囊(输入框下方) -->
|
<!-- 快捷工作流胶囊(输入框下方) -->
|
||||||
<div v-if="visibleWorkflows.length > 0" class="workflow-shortcuts">
|
<Transition name="capsule-slide" @enter="onCapsuleEnter" @after-enter="onCapsuleAfterEnter" @leave="onCapsuleLeave" @after-leave="onCapsuleAfterLeave">
|
||||||
<button
|
<div v-if="visibleWorkflows.length > 0 && !hideShortcuts" class="workflow-shortcuts">
|
||||||
v-for="wf in visibleWorkflows"
|
<el-tooltip
|
||||||
:key="wf.id"
|
v-for="wf in visibleWorkflows"
|
||||||
class="shortcut-pill"
|
:key="wf.id"
|
||||||
:class="{ active: selectedWorkflowId === wf.id }"
|
:content="wf.name"
|
||||||
@click="toggleWorkflow(wf.id)"
|
placement="top"
|
||||||
>
|
:show-after="150"
|
||||||
<el-icon><Promotion /></el-icon>
|
>
|
||||||
{{ wf.name }}
|
<button
|
||||||
</button>
|
class="shortcut-pill"
|
||||||
</div>
|
: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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { Top, MagicStick, Promotion, Close, Paperclip } from '@element-plus/icons-vue';
|
import { useRouter } from 'vue-router';
|
||||||
import { ElMessage } from 'element-plus';
|
import { Top, Promotion, Close, VideoPause, Cpu, More } from '@element-plus/icons-vue';
|
||||||
import { getWorkflowList } from '/@/api/settings/creation';
|
import { getWorkflowList } from '/@/api/settings/creation';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sendDisabled?: boolean;
|
sendDisabled?: boolean;
|
||||||
workflowLocked?: boolean;
|
hideShortcuts?: boolean;
|
||||||
|
generating?: boolean;
|
||||||
|
// 当前会话模型名称,用于输入栏切换入口展示
|
||||||
|
currentModelName?: string;
|
||||||
|
// 工作流正在执行中:输入框禁用,发送按钮变停止(与普通对话生成共用同一开关)
|
||||||
|
isWorkflowRunning?: boolean;
|
||||||
|
// 本会话为工作流会话(已执行过工作流):文本输入永久禁止,发送按钮仅执行工作流
|
||||||
|
isWorkflowSession?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'send', message: string): void;
|
(e: 'send', message: string): void;
|
||||||
(e: 'workflow-select', workflowId: string | null): void;
|
// 工作流场景点击发送:父组件定位目标工作流卡片执行(编辑态提交 / 定格态重跑)
|
||||||
|
(e: 'execute-workflow'): void;
|
||||||
|
(e: 'workflow-select', workflowId: string | null, isTemplate?: boolean): void;
|
||||||
|
(e: 'stop'): void;
|
||||||
|
(e: 'select-model'): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Workflow {
|
interface Workflow {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
|
isTemplate: boolean;
|
||||||
|
raw?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
sendDisabled: false,
|
sendDisabled: false,
|
||||||
workflowLocked: false,
|
hideShortcuts: false,
|
||||||
|
generating: false,
|
||||||
|
currentModelName: '',
|
||||||
|
isWorkflowRunning: false,
|
||||||
|
isWorkflowSession: false,
|
||||||
});
|
});
|
||||||
const emit = defineEmits<Emits>();
|
const emit = defineEmits<Emits>();
|
||||||
|
const router = useRouter();
|
||||||
|
// 首页展示工作流上限:用户工作流优先,不足用系统模板补足,最多 9 个
|
||||||
|
const MAX_WORKFLOWS = 9;
|
||||||
|
|
||||||
|
// 「更多」入口:工作流数量超出展示上限时跳转工作流管理页查看/选择全部
|
||||||
|
const handleOpenWorkflowManage = () => {
|
||||||
|
router.push('/settings/workflow');
|
||||||
|
};
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
const isFocused = ref(false);
|
const isFocused = ref(false);
|
||||||
const selectedWorkflowId = ref<string | null>(null);
|
const selectedWorkflowId = ref<string | null>(null);
|
||||||
const commonWorkflows = ref<Workflow[]>([]);
|
const commonWorkflows = ref<Workflow[]>([]);
|
||||||
|
// 取消工作流前的选择:父组件确认「继续编辑」时回滚,避免 InputBar 先置空、父侧保留草稿造成状态脱钩
|
||||||
|
const previousSelectedWorkflowId = ref<string | null>(null);
|
||||||
|
|
||||||
const visibleWorkflows = computed(() => {
|
// 工作流胶囊最多展示 MAX_WORKFLOWS 个:commonWorkflows 用户工作流在前、模板在后,
|
||||||
if (props.workflowLocked && selectedWorkflowId.value) {
|
// 截断前 MAX_WORKFLOWS 个即「用户优先、不足模板补足」;完整列表仍保留供父组件匹配
|
||||||
return commonWorkflows.value.filter((w) => w.id === selectedWorkflowId.value);
|
const visibleWorkflows = computed(() => commonWorkflows.value.slice(0, MAX_WORKFLOWS));
|
||||||
}
|
|
||||||
return commonWorkflows.value;
|
// 输入框整体锁定:正处于生成/工作流执行中(执行中禁止切换工作流)
|
||||||
|
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 fetchWorkflows = async () => {
|
const fetchWorkflows = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getWorkflowList();
|
const res = await getWorkflowList();
|
||||||
const workflows = res.data?.listFlowUserRes?.list || [];
|
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 })),
|
||||||
|
];
|
||||||
commonWorkflows.value = workflows.map((w) => ({
|
commonWorkflows.value = workflows.map((w) => ({
|
||||||
id: String(w.id),
|
id: String(w.id),
|
||||||
name: w.flowName || '未命名',
|
name: w.flowName || w.flowTemplateName || '未命名',
|
||||||
prefix: '[工作流] ' + (w.flowName || '') + ':\n',
|
prefix: '[工作流] ' + (w.flowName || w.flowTemplateName || '') + ':\n',
|
||||||
|
isTemplate: !!w.isTemplate,
|
||||||
|
raw: w,
|
||||||
}));
|
}));
|
||||||
} catch {
|
} catch {
|
||||||
commonWorkflows.value = [];
|
commonWorkflows.value = [];
|
||||||
@@ -116,22 +195,32 @@ const fetchWorkflows = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
if (selectedWorkflowId.value === null || props.sendDisabled) return;
|
// 生成中 / 工作流执行中:发送按钮即「停止」(普通对话与工作流共用同一开关)
|
||||||
|
if (props.generating || props.isWorkflowRunning) {
|
||||||
|
emit('stop');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 工作流场景(已选工作流 / 本会话为工作流会话):发送按钮即「执行工作流」
|
||||||
|
if (selectedWorkflowId.value !== null || props.isWorkflowSession) {
|
||||||
|
emit('execute-workflow');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 普通对话:发送文本
|
||||||
const msg = message.value.trim();
|
const msg = message.value.trim();
|
||||||
emit('send', msg || '');
|
if (!msg || props.sendDisabled) return;
|
||||||
|
emit('send', msg);
|
||||||
message.value = '';
|
message.value = '';
|
||||||
// 不清除工作流选择,保持表单可见
|
// 不清除工作流选择,保持表单可见
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAttachment = () => {
|
|
||||||
ElMessage.info('附件上传功能开发中...');
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleWorkflow = (id: string) => {
|
const toggleWorkflow = (id: string) => {
|
||||||
if (props.workflowLocked) return;
|
if (lockAll.value) return;
|
||||||
|
const item = commonWorkflows.value.find((w) => w.id === id);
|
||||||
const newId = selectedWorkflowId.value === id ? null : id;
|
const newId = selectedWorkflowId.value === id ? null : id;
|
||||||
|
// 取消(newId 为 null)前记录当前选择,供父组件确认「继续编辑」时回滚
|
||||||
|
if (newId === null) previousSelectedWorkflowId.value = selectedWorkflowId.value;
|
||||||
selectedWorkflowId.value = newId;
|
selectedWorkflowId.value = newId;
|
||||||
emit('workflow-select', newId);
|
emit('workflow-select', newId, item?.isTemplate || false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetAll = () => {
|
const resetAll = () => {
|
||||||
@@ -141,30 +230,81 @@ const resetAll = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearWorkflow = () => {
|
const clearWorkflow = () => {
|
||||||
if (props.workflowLocked) return;
|
if (lockAll.value) return;
|
||||||
|
// 清空(取消)前记录当前选择,供父组件确认「继续编辑」时回滚
|
||||||
|
previousSelectedWorkflowId.value = selectedWorkflowId.value;
|
||||||
selectedWorkflowId.value = null;
|
selectedWorkflowId.value = null;
|
||||||
emit('workflow-select', 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(() => {
|
onMounted(() => {
|
||||||
fetchWorkflows();
|
fetchWorkflows();
|
||||||
});
|
});
|
||||||
|
|
||||||
defineExpose({ resetAll, clearWorkflow, selectedWorkflowId, commonWorkflows });
|
// —— 胶囊高度动画: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 });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<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 {
|
.input-shell {
|
||||||
padding: 0 24px 24px;
|
padding: 0 20px 20px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-card {
|
.input-card {
|
||||||
max-width: 860px;
|
max-width: 880px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1.5px solid #e2e8f0;
|
border: 1.5px solid #e5e8ee;
|
||||||
border-radius: 16px;
|
border-radius: 20px;
|
||||||
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.07);
|
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.07);
|
||||||
transition:
|
transition:
|
||||||
border-color 0.2s,
|
border-color 0.2s,
|
||||||
@@ -221,7 +361,7 @@ onMounted(() => {
|
|||||||
border: none;
|
border: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
resize: none;
|
resize: none;
|
||||||
padding: 14px 16px 8px;
|
padding: 16px 18px 10px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
@@ -240,7 +380,7 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 6px 10px 10px 12px;
|
padding: 8px 12px 12px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-left,
|
.toolbar-left,
|
||||||
@@ -254,10 +394,10 @@ onMounted(() => {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
height: 30px;
|
height: 34px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -298,10 +438,10 @@ onMounted(() => {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 32px;
|
width: 36px;
|
||||||
height: 32px;
|
height: 36px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
background: #2563eb;
|
background: #2563eb;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -326,12 +466,22 @@ onMounted(() => {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 生成中:变停止按钮 */
|
||||||
|
&.is-stop {
|
||||||
|
background: #f43f5e;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e11d48;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 快捷工作流胶囊 */
|
/* 快捷工作流胶囊 */
|
||||||
.workflow-shortcuts {
|
.workflow-shortcuts {
|
||||||
max-width: 860px;
|
max-width: 880px;
|
||||||
margin: 10px auto 0;
|
margin: 8px auto 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -341,13 +491,13 @@ onMounted(() => {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
height: 28px;
|
height: 32px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e5e8ee;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(255, 255, 255, 0.7);
|
background: rgba(255, 255, 255, 0.7);
|
||||||
color: #475569;
|
color: #475569;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
@@ -355,7 +505,7 @@ onMounted(() => {
|
|||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
.el-icon {
|
.el-icon {
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -371,4 +521,43 @@ onMounted(() => {
|
|||||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,456 +1,157 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
<!-- 工作流动态表单 -->
|
<Transition name="content-fade" mode="out-in">
|
||||||
<div v-if="workflowDetail" class="content-body workflow-form-body">
|
<!-- 对话页 — 有消息或会话结果时展示消息流(工作流表单卡片/结果/产出统一在此)。
|
||||||
<div class="workflow-form-card">
|
key 绑定会话 id:切换会话时重建内容区,触发 Transition 过渡动画(固定 key 会复用 DOM 不触发) -->
|
||||||
<div class="workflow-form-header">
|
<div v-if="hasMessages || hasResults" :key="activeHistoryId || 'chat'" class="content-body chat-body">
|
||||||
<div>
|
<ChatList
|
||||||
<h3>{{ workflowDetail.flowName || '工作流表单' }}</h3>
|
:messages="messages"
|
||||||
<div class="workflow-form-sub">{{ workflowDetail.description || '请填写以下参数' }}</div>
|
:has-more="hasMore"
|
||||||
</div>
|
:loading-more="loadingMore"
|
||||||
</div>
|
:execute-request="executeRequest"
|
||||||
<div class="workflow-form-scroll">
|
class="chat-list-scroll"
|
||||||
<el-form label-position="top" class="workflow-form">
|
@delete="emit('delete', $event)"
|
||||||
<template v-if="workflowDetail.nodeInputParams">
|
@form-submit="(msg: any, payload: any) => emit('form-submit', msg, payload)"
|
||||||
<template v-for="node in workflowDetail.nodeInputParams" :key="node.id || node.nodeCode">
|
@output-preview="(msg: any, output: any) => emit('output-preview', msg, output)"
|
||||||
<template v-if="node.nodeCode !== '__start__' && hasFormConfig(node)">
|
@output-download="(msg: any, output: any) => emit('output-download', msg, output)"
|
||||||
|
@output-delete="(msg: any, output: any) => emit('output-delete', msg, output)"
|
||||||
<el-form-item
|
@load-more="emit('load-more', activeHistoryId)"
|
||||||
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>
|
||||||
|
|
||||||
<!-- 默认占位 — 无工作流时显示引导 -->
|
<!-- 默认占位 — 无工作流时显示引导 -->
|
||||||
<div v-else class="content-body placeholder-body">
|
<div v-else :key="'placeholder'" class="content-body placeholder-body">
|
||||||
<div class="placeholder-content">
|
<div class="placeholder-content">
|
||||||
<div class="placeholder-icon">
|
<h2 class="placeholder-title">你好,我能帮你解决什么问题?</h2>
|
||||||
<div class="icon-ring ring-outer">
|
<p class="placeholder-desc">选择一个工作流,填写参数后一键生成</p>
|
||||||
<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>
|
|
||||||
|
|
||||||
<h2 class="placeholder-title">请选择下方工作流进行创作</h2>
|
<div v-if="placeWorkflows.length" class="workflow-cards">
|
||||||
<p class="placeholder-desc">点击工作流胶囊快速切换,填写参数后一键生成内容</p>
|
<el-tooltip
|
||||||
|
v-for="wf in placeWorkflows"
|
||||||
<div class="placeholder-steps">
|
:key="wf.id"
|
||||||
<div class="step-item">
|
:content="wf.name"
|
||||||
<div class="step-num">1</div>
|
placement="top"
|
||||||
<div class="step-text">
|
:show-after="150"
|
||||||
<span class="step-label">选择工作流</span>
|
>
|
||||||
<span class="step-hint">点击胶囊切换不同创作模式</span>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
</div>
|
class="workflow-card"
|
||||||
<div class="step-arrow">
|
@click="handlePlaceSelect(wf)"
|
||||||
<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>
|
<el-icon class="wf-card-icon"><Promotion /></el-icon>
|
||||||
<div class="step-item">
|
<span class="wf-card-name">{{ wf.name }}</span>
|
||||||
<div class="step-num">2</div>
|
<span v-if="wf.isTemplate" class="wf-card-tag">模板</span>
|
||||||
<div class="step-text">
|
</button>
|
||||||
<span class="step-label">填写参数</span>
|
</el-tooltip>
|
||||||
<span class="step-hint">按需配置内容与样式选项</span>
|
<!-- 「更多」常驻入口:点击跳转工作流管理页查看/选择全部,不依赖工作流数量超出上限 -->
|
||||||
</div>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
<div class="step-arrow">
|
class="workflow-card wf-card-more"
|
||||||
<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>
|
title="查看全部工作流"
|
||||||
</div>
|
@click="goWorkflowManage"
|
||||||
<div class="step-item">
|
>
|
||||||
<div class="step-num">3</div>
|
<el-icon class="wf-card-icon"><More /></el-icon>
|
||||||
<div class="step-text">
|
<span class="wf-card-name">更多工作流</span>
|
||||||
<span class="step-label">一键生成</span>
|
</button>
|
||||||
<span class="step-hint">AI 自动产出,结果同步到工作空间</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="placeholder-footer">
|
|
||||||
<span class="footer-hint">还可从左侧「对话记录」查看历史或「工作空间」管理作品</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="placeholder-empty">暂无工作流,可在工作流管理中创建</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, ref, watch } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { ElMessage } from 'element-plus';
|
import { useRouter } from 'vue-router';
|
||||||
import PatchTemplateEditor from '/@/components/patchTemplate/PatchTemplateEditor.vue';
|
import { getWorkflowList } from '/@/api/settings/creation';
|
||||||
import { uploadFile } from '/@/api/common/upload';
|
import { Promotion, More } from '@element-plus/icons-vue';
|
||||||
|
import ChatList from './ChatList.vue';
|
||||||
|
import type { VOSessionInfoResult } from '/@/api/settings/workflow/session';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
activeMenu: string;
|
activeMenu: string;
|
||||||
workflowDetail: any;
|
|
||||||
activeHistoryId?: string | null;
|
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>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
activeHistoryId: null,
|
activeHistoryId: null,
|
||||||
|
messages: () => [],
|
||||||
|
results: () => [],
|
||||||
|
hasMore: false,
|
||||||
|
loadingMore: false,
|
||||||
|
executeRequest: null,
|
||||||
});
|
});
|
||||||
const formValues = reactive<Record<string, any>>({});
|
const emit = defineEmits<Emits>();
|
||||||
const fieldFiles = reactive<Record<string, { name: string; url: string }[]>>({});
|
const router = useRouter();
|
||||||
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 getVisibleFields = (node: any): any[] => {
|
const hasMessages = computed(() => Array.isArray(props.messages) && props.messages.length > 0);
|
||||||
const fields = Array.isArray(node?.formConfig) ? node.formConfig : [];
|
const hasResults = computed(() => Array.isArray(props.results) && props.results.length > 0);
|
||||||
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 {
|
try {
|
||||||
const uploadRes = await uploadFile(raw, { timeout: 0 });
|
const res: any = await getWorkflowList();
|
||||||
if (!uploadRes?.data?.fileURL) throw new Error('上传失败:未返回文件URL');
|
const userList = res.data?.listFlowUserRes?.list || [];
|
||||||
|
const tplList = res.data?.listFlowTemplateRes?.list || [];
|
||||||
const fileUrl = uploadRes.data.fileAddressPrefix
|
placeWorkflows.value = [
|
||||||
? `${uploadRes.data.fileAddressPrefix}${uploadRes.data.fileURL}`
|
...userList.map((w: any) => ({ id: String(w.id), name: w.flowName || '未命名', isTemplate: false })),
|
||||||
: uploadRes.data.fileURL;
|
...tplList.map((w: any) => ({ id: String(w.id), name: w.flowTemplateName || '未命名', isTemplate: true })),
|
||||||
|
].slice(0, MAX_PLACE_WORKFLOWS);
|
||||||
if (!fieldFiles[key]) fieldFiles[key] = [];
|
} catch {
|
||||||
fieldFiles[key].push({ name: raw.name, url: fileUrl });
|
placeWorkflows.value = [];
|
||||||
|
|
||||||
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) => {
|
||||||
const removeFieldFile = (node: any, field: any, fileIdx: number) => {
|
emit('workflow-select', wf.id, wf.isTemplate);
|
||||||
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 currentWorkflowHasPatchLayout = computed(() => {
|
const goWorkflowManage = () => {
|
||||||
const nodes = props.workflowDetail?.nodeInputParams || [];
|
router.push('/settings/workflow');
|
||||||
return nodes.some((node: any) => node.patchLayout === true);
|
};
|
||||||
|
onMounted(() => {
|
||||||
|
loadPlaceWorkflows();
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasFormConfig = (node: any): boolean => {
|
defineExpose({ loadPlaceWorkflows });
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">.main-content {
|
<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 {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -465,208 +166,119 @@ defineExpose({ formValues, fieldFiles, templates, validateFormFields });
|
|||||||
&::-webkit-scrollbar { display: none; }
|
&::-webkit-scrollbar { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 工作流表单 ===== */
|
/* ===== 对话页 ===== */
|
||||||
.workflow-form-body {
|
.chat-body {
|
||||||
width: min(820px, 100%);
|
width: min(880px, calc(100% - 40px));
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 16px 20px;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
overflow: hidden; /* 结果列表固定高度、消息流内部滚动,避免整体滚动与内部冲突 */
|
||||||
|
scrollbar-width: none;
|
||||||
|
&::-webkit-scrollbar { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-form-card {
|
/* 消息流填充剩余高度并在内部滚动 */
|
||||||
background: #fff;
|
.chat-body :deep(.chat-list-scroll) {
|
||||||
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;
|
flex: 1;
|
||||||
overflow-y: auto;
|
min-height: 0;
|
||||||
padding: 20px;
|
height: auto;
|
||||||
&::-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-body { display: flex; align-items: center; justify-content: center; }
|
||||||
.placeholder-content { text-align: center; padding: 20px; max-width: 480px; }
|
.placeholder-content { text-align: center; padding: 24px 20px; width: 100%; max-width: 740px; }
|
||||||
|
|
||||||
/* ===== 图标:层叠圆环 + 星形 ===== */
|
|
||||||
.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: 18px; font-weight: 700; color: #0f172a; margin: 0 0 6px; letter-spacing: -0.2px; line-height: 1.4; }
|
.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: 14px; color: #94a3b8; margin: 0 0 32px; line-height: 1.5; }
|
.placeholder-desc { font-size: 15px; color: #94a3b8; margin: 0 0 24px; line-height: 1.5; }
|
||||||
|
|
||||||
/* ===== 步骤引导 ===== */
|
/* ===== 工作流功能卡片(DeepSeek 式引导) ===== */
|
||||||
.placeholder-steps {
|
.workflow-cards {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: center;
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
justify-content: center;
|
gap: 12px;
|
||||||
gap: 0;
|
text-align: left;
|
||||||
margin-bottom: 28px;
|
|
||||||
}
|
}
|
||||||
.step-item {
|
.workflow-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
text-align: left;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.step-num {
|
.wf-card-icon {
|
||||||
width: 28px; height: 28px;
|
width: 40px;
|
||||||
border-radius: 50%;
|
height: 40px;
|
||||||
background: linear-gradient(135deg, #eff6ff 0%, #f0f0ff 100%);
|
border-radius: 12px;
|
||||||
border: 1.5px solid #bfdbfe;
|
background: #eaf1ff;
|
||||||
color: #3b82f6;
|
color: #2563eb;
|
||||||
font-size: 13px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
display: inline-flex;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: all 0.2s;
|
}
|
||||||
.step-item:hover & {
|
.wf-card-name {
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
|
flex: 1;
|
||||||
color: #fff;
|
min-width: 0;
|
||||||
border-color: transparent;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.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;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin: 0 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== 底部辅助提示 ===== */
|
.placeholder-empty {
|
||||||
.placeholder-footer { margin-top: 4px; }
|
font-size: 13px;
|
||||||
.footer-hint { font-size: 12px; color: #cbd5e1; line-height: 1.5; }
|
color: #cbd5e1;
|
||||||
</style>
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,207 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
<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>
|
<el-icon class="brand-icon"><ChatDotRound /></el-icon>
|
||||||
<span class="brand-name">AI 助手</span>
|
<span class="brand-name">AI 助手</span>
|
||||||
</div>
|
</div>
|
||||||
<el-tooltip content="新建对话" placement="right">
|
<el-tooltip content="新建会话" placement="right">
|
||||||
<button class="new-chat-icon-btn" @click="handleNewChat">
|
<button class="new-chat-icon-btn" @click="handleNewChat">
|
||||||
<el-icon><EditPen /></el-icon>
|
<el-icon><EditPen /></el-icon>
|
||||||
</button>
|
</button>
|
||||||
@@ -15,11 +15,11 @@
|
|||||||
|
|
||||||
<!-- Tab 切换 -->
|
<!-- Tab 切换 -->
|
||||||
<div class="sidebar-tabs">
|
<div class="sidebar-tabs">
|
||||||
<button class="tab-btn" :class="{ active: activeTab === 'history' }" @click="activeTab = 'history'">
|
<button class="tab-btn" :class="{ active: activeTab === 'history' }" @click="handleTabChange('history')">
|
||||||
<el-icon><ChatLineRound /></el-icon>
|
<el-icon><ChatLineRound /></el-icon>
|
||||||
对话记录
|
会话记录
|
||||||
</button>
|
</button>
|
||||||
<button class="tab-btn" :class="{ active: activeTab === 'workspace' }" @click="activeTab = 'workspace'">
|
<button class="tab-btn" :class="{ active: activeTab === 'workspace' }" @click="handleTabChange('workspace')">
|
||||||
<el-icon><FolderOpened /></el-icon>
|
<el-icon><FolderOpened /></el-icon>
|
||||||
工作空间
|
工作空间
|
||||||
</button>
|
</button>
|
||||||
@@ -45,14 +45,28 @@
|
|||||||
<el-icon><Delete /></el-icon>
|
<el-icon><Delete /></el-icon>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
<div v-else class="panel-empty">暂无对话记录</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>
|
</div>
|
||||||
|
|
||||||
<!-- 工作空间面板 -->
|
<!-- 工作空间面板 -->
|
||||||
<div v-show="activeTab === 'workspace'" class="panel" v-loading="treeLoading">
|
<div v-show="activeTab === 'workspace'" class="panel" v-loading="treeLoading">
|
||||||
<el-empty v-if="!treeLoading && treeNodes.length === 0" :image-size="48" description="暂无内容" />
|
<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="暂无内容" />
|
||||||
<div v-else class="panel-list">
|
<div v-else class="panel-list">
|
||||||
<div v-for="dateNode in treeNodes" :key="dateNode.id" class="tree-date-group">
|
<div v-for="dateNode in treeNodes" :key="dateNode.id" class="tree-date-group">
|
||||||
<div class="tree-date-label">{{ dateNode.label }}</div>
|
<div class="tree-date-label">{{ dateNode.label }}</div>
|
||||||
@@ -63,7 +77,13 @@
|
|||||||
<span class="file-name">{{ fileNode.label }}</span>
|
<span class="file-name">{{ fileNode.label }}</span>
|
||||||
<div v-if="fileNode.fileUrl" class="file-actions">
|
<div v-if="fileNode.fileUrl" class="file-actions">
|
||||||
<button class="file-action-btn" @click.stop="handlePreviewNode(fileNode)">预览</button>
|
<button class="file-action-btn" @click.stop="handlePreviewNode(fileNode)">预览</button>
|
||||||
<button class="file-action-btn" @click.stop="handleDownloadNode(fileNode)">下载</button>
|
<button
|
||||||
|
class="file-action-btn"
|
||||||
|
:disabled="downloadingFileId === fileNode.id"
|
||||||
|
@click.stop="handleDownloadNode(fileNode)"
|
||||||
|
>
|
||||||
|
{{ downloadingFileId === fileNode.id ? '下载中…' : '下载' }}
|
||||||
|
</button>
|
||||||
<button class="file-action-btn file-action-btn--del" @click.stop="handleDeleteNode(fileNode)">删除</button>
|
<button class="file-action-btn file-action-btn--del" @click.stop="handleDeleteNode(fileNode)">删除</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,6 +118,16 @@ interface Props {
|
|||||||
activeMenu: string;
|
activeMenu: string;
|
||||||
activeHistoryId: string | null;
|
activeHistoryId: string | null;
|
||||||
historyList: HistoryItem[];
|
historyList: HistoryItem[];
|
||||||
|
// 会话记录 / 工作空间加载失败标记:失败时显示「加载失败 + 重试」错误态
|
||||||
|
historyFailed?: boolean;
|
||||||
|
// 历史会话分页:是否还有更多可加载、加载更多请求中
|
||||||
|
historyHasMore?: boolean;
|
||||||
|
historyLoadingMore?: boolean;
|
||||||
|
workspaceFailed?: boolean;
|
||||||
|
// 工作空间是否已成功加载:懒加载成功后才不再触发;失败保持 false → 切回 tab 自动重试
|
||||||
|
workspaceLoaded?: boolean;
|
||||||
|
// 工作空间正在下载的文件 id:对应「下载」按钮显示下载中并禁用(按钮级感知,不影响其他功能)
|
||||||
|
downloadingFileId?: string | null;
|
||||||
treeNodes: TreeNode[];
|
treeNodes: TreeNode[];
|
||||||
treeLoading: boolean;
|
treeLoading: boolean;
|
||||||
}
|
}
|
||||||
@@ -110,12 +140,31 @@ interface Emits {
|
|||||||
(e: 'preview-node', data: TreeNode): void;
|
(e: 'preview-node', data: TreeNode): void;
|
||||||
(e: 'download-node', data: TreeNode): void;
|
(e: 'download-node', data: TreeNode): void;
|
||||||
(e: 'delete-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 = defineProps<Props>();
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
historyFailed: false,
|
||||||
|
historyHasMore: false,
|
||||||
|
historyLoadingMore: false,
|
||||||
|
workspaceFailed: false,
|
||||||
|
workspaceLoaded: false,
|
||||||
|
downloadingFileId: null,
|
||||||
|
});
|
||||||
const emit = defineEmits<Emits>();
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
const activeTab = ref<'history' | 'workspace'>('history');
|
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) => {
|
const getFileIcon = (fileType?: string) => {
|
||||||
@@ -139,8 +188,8 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
|||||||
.sidebar {
|
.sidebar {
|
||||||
width: 240px;
|
width: 240px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: #f8fafc;
|
background: #f7f8fa;
|
||||||
border-right: 1px solid #e2e8f0;
|
border-right: 1px solid #e5e8ee;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -152,20 +201,27 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 16px 12px 12px;
|
padding: 16px 14px 14px;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-icon {
|
.brand-icon {
|
||||||
font-size: 20px;
|
width: 30px;
|
||||||
color: #2563eb;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-name {
|
.brand-name {
|
||||||
@@ -194,18 +250,17 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #e2e8f0;
|
background: #e8ebf0;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab 切换 */
|
/* Tab 切换(圆角胶囊) */
|
||||||
.sidebar-tabs {
|
.sidebar-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 8px 8px 0;
|
padding: 12px 12px 8px;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-btn {
|
.tab-btn {
|
||||||
@@ -213,26 +268,24 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 5px;
|
gap: 6px;
|
||||||
height: 34px;
|
height: 36px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px 8px 0 0;
|
border-radius: 10px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
outline: none;
|
outline: none;
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
margin-bottom: -1px;
|
|
||||||
|
|
||||||
.el-icon {
|
.el-icon {
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover:not(.active) {
|
&:hover:not(.active) {
|
||||||
background: #f1f5f9;
|
background: #eef0f3;
|
||||||
color: #334155;
|
color: #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +293,7 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
|||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-bottom-color: #2563eb;
|
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.06);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +301,7 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
|||||||
.panel {
|
.panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 8px 8px 12px;
|
padding: 4px 8px 12px;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
@@ -268,19 +321,47 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
|||||||
padding: 32px 0;
|
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 {
|
.history-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px;
|
padding: 10px;
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
margin-bottom: 1px;
|
margin-bottom: 2px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #f1f5f9;
|
background: #eef0f3;
|
||||||
|
|
||||||
.item-del-btn {
|
.item-del-btn {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -288,7 +369,7 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: #eff6ff;
|
background: #e6f0ff;
|
||||||
|
|
||||||
.item-icon {
|
.item-icon {
|
||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
@@ -325,17 +406,17 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
|||||||
.item-time {
|
.item-time {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
margin-top: 1px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-del-btn {
|
.item-del-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 22px;
|
width: 24px;
|
||||||
height: 22px;
|
height: 24px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 5px;
|
border-radius: 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -365,7 +446,7 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
padding: 6px 4px 2px;
|
padding: 8px 6px 4px;
|
||||||
letter-spacing: 0.4px;
|
letter-spacing: 0.4px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
@@ -374,13 +455,13 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 5px 8px 5px 24px;
|
padding: 7px 10px 7px 24px;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #f1f5f9;
|
background: #eef0f3;
|
||||||
|
|
||||||
.file-actions {
|
.file-actions {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -412,14 +493,6 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
|||||||
}
|
}
|
||||||
|
|
||||||
.file-action-btn {
|
.file-action-btn {
|
||||||
&--del {
|
|
||||||
color: #ef4444;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #3b82f6;
|
color: #3b82f6;
|
||||||
@@ -433,7 +506,26 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
|||||||
&:hover {
|
&:hover {
|
||||||
background: #dbeafe;
|
background: #dbeafe;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--del {
|
||||||
|
color: #ef4444;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge {
|
.status-badge {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 1px 8px;
|
padding: 1px 8px;
|
||||||
@@ -449,7 +541,34 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
|||||||
animation: pulse 1.5s ease-in-out infinite;
|
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 {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
|
|||||||
@@ -0,0 +1,500 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,806 @@
|
|||||||
|
<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">
|
||||||
|
<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 { 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[] }): 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[]>([]);
|
||||||
|
|
||||||
|
// 状态派生:失败 = 携带 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 (!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]);
|
||||||
|
// 尝试从执行详情恢复贴片模板
|
||||||
|
// 提交路径: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 || [])),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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>
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
<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>
|
||||||
+1519
-261
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,226 @@
|
|||||||
|
// ===== 首页执行工作流管理 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// ===== 首页回答区 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
// ===== 首页 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' }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// ===== 首页 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,21 +113,28 @@
|
|||||||
<KeyValueEditor v-model="formData.requestHeadMapping" />
|
<KeyValueEditor v-model="formData.requestHeadMapping" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6" class="mb20">
|
||||||
<el-form-item label="请求体映射">
|
<el-form-item label="请求体映射">
|
||||||
<el-button @click="openMappingEditor('requestBodyMapping')" style="width: 100%">
|
<el-button @click="openMappingEditor('requestBodyMapping')" style="width: 100%">
|
||||||
配置 ({{ Object.keys(formData.requestBodyMapping).length }})
|
配置 ({{ Object.keys(formData.requestBodyMapping).length }})
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6" class="mb20">
|
||||||
<el-form-item label="响应映射">
|
<el-form-item label="响应映射">
|
||||||
<el-button @click="openMappingEditor('responseMapping')" style="width: 100%">
|
<el-button @click="openMappingEditor('responseMapping')" style="width: 100%">
|
||||||
配置 ({{ Object.keys(formData.responseMapping).length }})
|
配置 ({{ Object.keys(formData.responseMapping).length }})
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20" v-if="formData.responseType !== 2">
|
<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-form-item label="响应主体映射">
|
<el-form-item label="响应主体映射">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="formData.responseBodyMapping"
|
v-model="formData.responseBodyMapping"
|
||||||
@@ -152,48 +159,64 @@
|
|||||||
<template v-if="formData.responseType === 2">
|
<template v-if="formData.responseType === 2">
|
||||||
<el-divider content-position="left">异步映射配置</el-divider>
|
<el-divider content-position="left">异步映射配置</el-divider>
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
||||||
<el-form-item label="回调响应映射">
|
<el-form-item label="异步响应映射">
|
||||||
<el-button @click="openMappingEditor('asyncResponseMapping')" style="width: 100%">
|
<el-button @click="openMappingEditor('asyncResponseMapping')" style="width: 100%">
|
||||||
配置 ({{ Object.keys(formData.asyncTaskMapping.responseMapping).length }})
|
配置 ({{ Object.keys((formData.asyncTaskMapping.responseMapping || {})).length }})
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
<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-form-item label="回调 URL">
|
<el-form-item label="回调 URL">
|
||||||
<el-input v-model="formData.asyncTaskMapping.url" placeholder="请输入异步回调地址" clearable />
|
<el-input v-model="formData.asyncTaskMapping.url" placeholder="请输入异步回调地址" clearable />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
|
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||||
<el-form-item label="回调请求头映射">
|
<el-form-item label="请求体映射">
|
||||||
<KeyValueEditor v-model="formData.asyncTaskMapping.requestHeadMapping" />
|
<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-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
|
||||||
<el-form-item label="响应主体映射">
|
<el-form-item label="异步请求头映射">
|
||||||
<el-select
|
<KeyValueEditor v-model="formData.asyncTaskMapping.requestHeadMapping" />
|
||||||
v-model="formData.responseBodyMapping"
|
</el-form-item>
|
||||||
multiple
|
</el-col>
|
||||||
: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>
|
||||||
|
|
||||||
<el-divider content-position="left" class="sub-divider">任务状态字段路径</el-divider>
|
<el-divider content-position="left" class="sub-divider">任务状态字段路径</el-divider>
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||||
@@ -375,6 +398,7 @@ const defaultAsyncTaskMapping: AsyncTaskMapping = {
|
|||||||
url: '',
|
url: '',
|
||||||
httpMethod: 'POST',
|
httpMethod: 'POST',
|
||||||
requestHeadMapping: {},
|
requestHeadMapping: {},
|
||||||
|
requestBodyMapping: {},
|
||||||
responseMapping: {},
|
responseMapping: {},
|
||||||
taskId: '',
|
taskId: '',
|
||||||
taskStatus: '',
|
taskStatus: '',
|
||||||
@@ -401,6 +425,7 @@ const formData = reactive({
|
|||||||
// 映射
|
// 映射
|
||||||
requestHeadMapping: {} as Record<string, unknown>,
|
requestHeadMapping: {} as Record<string, unknown>,
|
||||||
requestBodyMapping: {} as Record<string, unknown>,
|
requestBodyMapping: {} as Record<string, unknown>,
|
||||||
|
error_message_mapping: {} as Record<string, unknown>,
|
||||||
responseMapping: {} as Record<string, unknown>,
|
responseMapping: {} as Record<string, unknown>,
|
||||||
responseBodyMapping: [] as string[],
|
responseBodyMapping: [] as string[],
|
||||||
// Token 映射
|
// Token 映射
|
||||||
@@ -529,6 +554,7 @@ const hasTaskStatusConfig = computed(() => taskStatusOptions.value.length > 0);
|
|||||||
/** 用户手动切换返回类型时清空 token 映射(编程赋值不触发) */
|
/** 用户手动切换返回类型时清空 token 映射(编程赋值不触发) */
|
||||||
function handleResponseTypeChange() {
|
function handleResponseTypeChange() {
|
||||||
formData.tokenMapping = { ...defaultTokenMapping };
|
formData.tokenMapping = { ...defaultTokenMapping };
|
||||||
|
formData.responseBodyMapping = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -542,7 +568,9 @@ const mappingFieldLabels: Record<string, string> = {
|
|||||||
requestHeadMapping: '请求头映射',
|
requestHeadMapping: '请求头映射',
|
||||||
requestBodyMapping: '请求体映射',
|
requestBodyMapping: '请求体映射',
|
||||||
responseMapping: '响应映射',
|
responseMapping: '响应映射',
|
||||||
|
error_message_mapping: '错误消息映射',
|
||||||
asyncResponseMapping: '异步-响应映射',
|
asyncResponseMapping: '异步-响应映射',
|
||||||
|
asyncRequestBodyMapping: '异步-请求体映射',
|
||||||
};
|
};
|
||||||
|
|
||||||
const jsonEditorTitle = computed(() => {
|
const jsonEditorTitle = computed(() => {
|
||||||
@@ -553,6 +581,7 @@ const jsonEditorTitle = computed(() => {
|
|||||||
/** 获取映射字段的引用 */
|
/** 获取映射字段的引用 */
|
||||||
function getMappingValue(key: string): Record<string, unknown> {
|
function getMappingValue(key: string): Record<string, unknown> {
|
||||||
if (key === 'asyncResponseMapping') return formData.asyncTaskMapping.responseMapping;
|
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>;
|
return (formData as Record<string, unknown>)[key] as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,6 +589,8 @@ function getMappingValue(key: string): Record<string, unknown> {
|
|||||||
function setMappingValue(key: string, value: Record<string, unknown>) {
|
function setMappingValue(key: string, value: Record<string, unknown>) {
|
||||||
if (key === 'asyncResponseMapping') {
|
if (key === 'asyncResponseMapping') {
|
||||||
formData.asyncTaskMapping.responseMapping = value;
|
formData.asyncTaskMapping.responseMapping = value;
|
||||||
|
} else if (key === 'asyncRequestBodyMapping') {
|
||||||
|
formData.asyncTaskMapping.requestBodyMapping = value;
|
||||||
} else {
|
} else {
|
||||||
(formData as Record<string, unknown>)[key] = value;
|
(formData as Record<string, unknown>)[key] = value;
|
||||||
}
|
}
|
||||||
@@ -571,8 +602,53 @@ const openMappingEditor = (key: string) => {
|
|||||||
jsonEditorVisible.value = true;
|
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 confirmJsonEditor = () => {
|
||||||
setMappingValue(editingMappingKey.value, { ...jsonEditorData.value });
|
const cleaned = resetSchemaValues(JSON.parse(JSON.stringify(jsonEditorData.value)));
|
||||||
|
setMappingValue(editingMappingKey.value, cleaned);
|
||||||
jsonEditorVisible.value = false;
|
jsonEditorVisible.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -634,6 +710,7 @@ const resetForm = () => {
|
|||||||
formData.tokenPredictPrice = 0;
|
formData.tokenPredictPrice = 0;
|
||||||
formData.requestHeadMapping = {};
|
formData.requestHeadMapping = {};
|
||||||
formData.requestBodyMapping = {};
|
formData.requestBodyMapping = {};
|
||||||
|
formData.error_message_mapping = {};
|
||||||
formData.responseMapping = {};
|
formData.responseMapping = {};
|
||||||
formData.responseBodyMapping = [];
|
formData.responseBodyMapping = [];
|
||||||
formData.tokenMapping = { ...defaultTokenMapping };
|
formData.tokenMapping = { ...defaultTokenMapping };
|
||||||
@@ -661,18 +738,27 @@ const openDialog = async (type: 'add' | 'edit', id?: string) => {
|
|||||||
formData.apiKey = row.apiKey || '';
|
formData.apiKey = row.apiKey || '';
|
||||||
formData.responseType = row.responseType ?? row.invokeType;
|
formData.responseType = row.responseType ?? row.invokeType;
|
||||||
formData.enabled = row.enabled ?? true;
|
formData.enabled = row.enabled ?? true;
|
||||||
formData.chatModel = row.ChatModel ?? false;
|
formData.chatModel = row.chatModel ?? row.ChatModel ?? false;
|
||||||
formData.maxConcurrency = row.maxConcurrency || 10;
|
formData.maxConcurrency = row.maxConcurrency || 10;
|
||||||
formData.maxTokens = row.maxTokens || 4096;
|
formData.maxTokens = row.maxTokens || 4096;
|
||||||
formData.tokenPredictPrice = row.tokenPredictPrice ?? 0;
|
formData.tokenPredictPrice = row.tokenPredictPrice ?? 0;
|
||||||
formData.requestHeadMapping = row.requestHeadMapping || row.requestMapping || {};
|
formData.requestHeadMapping = row.requestHeadMapping || row.requestMapping || {};
|
||||||
formData.requestBodyMapping = row.requestBodyMapping || {};
|
formData.requestBodyMapping = row.requestBodyMapping || {};
|
||||||
|
formData.error_message_mapping = row.error_message_mapping || {};
|
||||||
formData.responseMapping = row.responseMapping || {};
|
formData.responseMapping = row.responseMapping || {};
|
||||||
formData.responseBodyMapping = row.responseBodyMapping
|
formData.responseBodyMapping = row.responseBodyMapping
|
||||||
? (Array.isArray(row.responseBodyMapping) ? row.responseBodyMapping : Object.keys(row.responseBodyMapping))
|
? (Array.isArray(row.responseBodyMapping) ? row.responseBodyMapping : Object.keys(row.responseBodyMapping))
|
||||||
: [];
|
: [];
|
||||||
formData.tokenMapping = row.tokenMapping ? { ...defaultTokenMapping, ...row.tokenMapping } : { ...defaultTokenMapping };
|
formData.tokenMapping = row.tokenMapping ? { ...defaultTokenMapping, ...row.tokenMapping } : { ...defaultTokenMapping };
|
||||||
formData.asyncTaskMapping = row.asyncTaskMapping ? { ...defaultAsyncTaskMapping, ...row.asyncTaskMapping } : { ...defaultAsyncTaskMapping };
|
formData.asyncTaskMapping = row.asyncTaskMapping
|
||||||
|
? {
|
||||||
|
...defaultAsyncTaskMapping,
|
||||||
|
...row.asyncTaskMapping,
|
||||||
|
requestHeadMapping: row.asyncTaskMapping.requestHeadMapping || {},
|
||||||
|
requestBodyMapping: row.asyncTaskMapping.requestBodyMapping || {},
|
||||||
|
responseMapping: row.asyncTaskMapping.responseMapping || {},
|
||||||
|
}
|
||||||
|
: { ...defaultAsyncTaskMapping };
|
||||||
formData.lastFrame = row.lastFrame || '';
|
formData.lastFrame = row.lastFrame || '';
|
||||||
formData.maxDuration = row.maxDuration ?? 0;
|
formData.maxDuration = row.maxDuration ?? 0;
|
||||||
formData.tokenPredictPriceUnit = row.tokenPredictPriceUnit || '';
|
formData.tokenPredictPriceUnit = row.tokenPredictPriceUnit || '';
|
||||||
@@ -695,6 +781,10 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
try {
|
try {
|
||||||
|
// 响应主体映射的 value 直接填对应字段的中文 label(无 label 时兜底为路径本身),不再用空串占位
|
||||||
|
const responseBodyLabelMap = new Map<string, string>(
|
||||||
|
tokenMappingOptions.value.map((o) => [o.path, o.label || o.path])
|
||||||
|
);
|
||||||
const baseParams = {
|
const baseParams = {
|
||||||
modelName: formData.modelName,
|
modelName: formData.modelName,
|
||||||
modelType: Array.isArray(formData.modelType) ? formData.modelType[formData.modelType.length - 1] : formData.modelType,
|
modelType: Array.isArray(formData.modelType) ? formData.modelType[formData.modelType.length - 1] : formData.modelType,
|
||||||
@@ -709,9 +799,10 @@ const handleSubmit = async () => {
|
|||||||
tokenPredictPrice: formData.tokenPredictPrice,
|
tokenPredictPrice: formData.tokenPredictPrice,
|
||||||
requestHeadMapping: Object.keys(formData.requestHeadMapping).length > 0 ? formData.requestHeadMapping : undefined,
|
requestHeadMapping: Object.keys(formData.requestHeadMapping).length > 0 ? formData.requestHeadMapping : undefined,
|
||||||
requestBodyMapping: Object.keys(formData.requestBodyMapping).length > 0 ? formData.requestBodyMapping : 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,
|
responseMapping: Object.keys(formData.responseMapping).length > 0 ? formData.responseMapping : undefined,
|
||||||
responseBodyMapping: formData.responseBodyMapping.length > 0
|
responseBodyMapping: formData.responseBodyMapping.length > 0
|
||||||
? Object.fromEntries(formData.responseBodyMapping.map(k => [k, '']))
|
? Object.fromEntries(formData.responseBodyMapping.map((k) => [k, responseBodyLabelMap.get(k) || k]))
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -726,11 +817,15 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
if (formData.responseType === 2) {
|
if (formData.responseType === 2) {
|
||||||
const atm = formData.asyncTaskMapping;
|
const atm = formData.asyncTaskMapping;
|
||||||
const hasAsyncConfig = atm.url || Object.keys(atm.requestHeadMapping).length > 0 || atm.taskId;
|
const hasAsyncConfig =
|
||||||
|
atm.url ||
|
||||||
|
Object.keys(atm.requestHeadMapping).length > 0 ||
|
||||||
|
Object.keys(atm.requestBodyMapping || {}).length > 0 ||
|
||||||
|
atm.taskId;
|
||||||
asyncTaskMapping = hasAsyncConfig ? ({ ...atm } as AsyncTaskMapping) : undefined;
|
asyncTaskMapping = hasAsyncConfig ? ({ ...atm } as AsyncTaskMapping) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.lastFrame) {
|
if (isVideoModel.value && formData.lastFrame) {
|
||||||
lastFrame = formData.lastFrame;
|
lastFrame = formData.lastFrame;
|
||||||
}
|
}
|
||||||
let maxDuration: number | undefined;
|
let maxDuration: number | undefined;
|
||||||
|
|||||||
@@ -43,9 +43,9 @@
|
|||||||
<el-tag :type="scope.row.systemModel ? 'warning' : 'success'">{{ scope.row.systemModel ? '系统' : '用户' }}</el-tag>
|
<el-tag :type="scope.row.systemModel ? 'warning' : 'success'">{{ scope.row.systemModel ? '系统' : '用户' }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="访问类型">
|
<el-table-column label="供应商" show-overflow-tooltip>
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-tag :type="scope.row.PrivateModel ? 'info' : 'primary'">{{ scope.row.PrivateModel ? '服务商模型' : '本地模型' }}</el-tag>
|
{{ resolveSupplierLabel(scope.row.modelSupplier) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态">
|
<el-table-column label="状态">
|
||||||
@@ -85,7 +85,7 @@ import { ref, reactive, onMounted } from 'vue';
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import { Search, Plus } from '@element-plus/icons-vue';
|
import { Search, Plus } from '@element-plus/icons-vue';
|
||||||
import EditModule from './component/editModule.vue';
|
import EditModule from './component/editModule.vue';
|
||||||
import { listModelManage, getModelManageTypes, deleteModelManage, type ModelManageItem, type ModelTypeTreeNode } from '/@/api/settings/modelConfigV2';
|
import { listModelManage, getModelManageTypes, deleteModelManage, getModelSupplierList, type ModelManageItem, type ModelTypeTreeNode, type ModelSupplierItem } from '/@/api/settings/modelConfigV2';
|
||||||
|
|
||||||
const searchParams = reactive({
|
const searchParams = reactive({
|
||||||
modelName: '',
|
modelName: '',
|
||||||
@@ -101,6 +101,7 @@ const pagination = reactive({
|
|||||||
const modelList = ref<ModelManageItem[]>([]);
|
const modelList = ref<ModelManageItem[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const modelTypes = ref<ModelTypeTreeNode[]>([]);
|
const modelTypes = ref<ModelTypeTreeNode[]>([]);
|
||||||
|
const supplierList = ref<ModelSupplierItem[]>([]);
|
||||||
const editModuleRef = ref();
|
const editModuleRef = ref();
|
||||||
|
|
||||||
/** 级联选择器的选中路径(例:[200, 201]) */
|
/** 级联选择器的选中路径(例:[200, 201]) */
|
||||||
@@ -139,6 +140,22 @@ 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 () => {
|
const fetchModelList = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -204,6 +221,7 @@ const handleDelete = (row: ModelManageItem) => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadModelTypes();
|
await loadModelTypes();
|
||||||
|
loadSupplierList();
|
||||||
fetchModelList();
|
fetchModelList();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -33,6 +33,9 @@
|
|||||||
import { ref, watch, nextTick } from 'vue';
|
import { ref, watch, nextTick } from 'vue';
|
||||||
|
|
||||||
export interface FormField {
|
export interface FormField {
|
||||||
|
// 不可变字段标识(保存/下游引用的 key);label 仅为展示名,重命名不影响已建立的引用。
|
||||||
|
// 旧数据缺省时退化用 label,保存一次后 field 固化不再随 label 变化
|
||||||
|
field?: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: string; // input | number | textarea | switch | uploadMultiple
|
type: string; // input | number | textarea | switch | uploadMultiple
|
||||||
value: any; // 默认值(uploadMultiple 时存文件 URL 数组或空)
|
value: any; // 默认值(uploadMultiple 时存文件 URL 数组或空)
|
||||||
@@ -67,8 +70,12 @@ const sync = () => {
|
|||||||
emit('update:modelValue', clone(fields.value));
|
emit('update:modelValue', clone(fields.value));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 字段唯一标识生成:时间戳 + 递增序号,避免同节点内字段 field 冲突
|
||||||
|
let fieldSeq = 0;
|
||||||
|
const genFieldKey = () => `f_${Date.now().toString(36)}_${++fieldSeq}`;
|
||||||
|
|
||||||
const addField = () => {
|
const addField = () => {
|
||||||
fields.value.push({ label: '', type: 'input', value: '', required: false });
|
fields.value.push({ field: genFieldKey(), label: '', type: 'input', value: '', required: false });
|
||||||
sync();
|
sync();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<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,6 +16,7 @@
|
|||||||
:field-def="childDef"
|
:field-def="childDef"
|
||||||
:path="path ? `${path}.attrs.${childKey}` : childKey"
|
:path="path ? `${path}.attrs.${childKey}` : childKey"
|
||||||
:upstream-nodes="upstreamNodes"
|
:upstream-nodes="upstreamNodes"
|
||||||
|
:multi="multi"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,39 +33,24 @@
|
|||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div class="mf-array-flat">
|
<div class="mf-array-flat">
|
||||||
<!-- 联合数组(enumValues 多模板):全部模板平铺,固定条数,不可增删 -->
|
<!-- 数组元素直接遍历模板(enumValues/attrs):值与勾选均落在模板上,
|
||||||
<template v-if="isVariantArray">
|
保存只留 enumValues 一份即可完整回显,不依赖 value 实例 -->
|
||||||
<div v-for="(item, idx) in getVariantItems()" :key="idx" class="mf-variant">
|
<template v-for="(item, idx) in renderedArrayItems()" :key="idx">
|
||||||
<div class="mf-variant-head">
|
<template v-if="isObjectDef(item)">
|
||||||
|
<div v-if="isVariantArray" class="mf-variant-head">
|
||||||
<span class="mf-variant-name">{{ variantLabel(idx) }}</span>
|
<span class="mf-variant-name">{{ variantLabel(idx) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="isObjectDef(item)">
|
<div v-else-if="renderedArrayItems().length > 1" class="mf-array-flat-index">#{{ idx + 1 }}</div>
|
||||||
<ModelField
|
<ModelField
|
||||||
v-for="(subDef, subKey) in item.attrs"
|
v-for="(subDef, subKey) in item.attrs"
|
||||||
:key="subKey"
|
:key="subKey"
|
||||||
:field-def="subDef"
|
:field-def="subDef"
|
||||||
:path="path ? `${path}.value[${idx}].attrs.${subKey}` : String(subKey)"
|
:path="path ? `${path}.enumValues[${idx}].attrs.${subKey}` : String(subKey)"
|
||||||
:upstream-nodes="upstreamNodes"
|
:upstream-nodes="upstreamNodes"
|
||||||
/>
|
:multi="multi"
|
||||||
</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>
|
</template>
|
||||||
|
<el-input v-else :model-value="getTemplatePrimitive(item)" @input="(v: any) => setTemplatePrimitive(item, v)" size="small" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,52 +73,24 @@
|
|||||||
class="mf-leaf-expose"
|
class="mf-leaf-expose"
|
||||||
>表单展示</el-checkbox>
|
>表单展示</el-checkbox>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
<el-popover
|
<!-- 引用上级输出与「表单展示」勾选同级:所有叶子参数(含数组元素内字段)都可引用;multi 时支持多选 -->
|
||||||
v-if="canValueSource"
|
<MfReferenceButton :field-def="def" :upstream-nodes="upstreamNodes" :multi="multi" />
|
||||||
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>
|
||||||
<div v-if="hasValueSource" class="mf-ref-bound" :class="{ 'is-invalid': valueSourceInvalid }">
|
<div v-if="hasValueSource" class="mf-ref-bound" :class="{ 'is-invalid': valueSourceInvalid }">
|
||||||
<span class="mf-ref-bound-text">已引用:{{ valueSourceLabel }}</span>
|
<div class="mf-ref-tags">
|
||||||
<el-button size="small" text type="danger" @click="clearValueSource">清除</el-button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!isUpload" class="mf-leaf-ctrl">
|
<div v-if="!hasValueSource && !isUpload" class="mf-leaf-ctrl">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-if="isNumber"
|
v-if="isNumber"
|
||||||
:model-value="numberValue"
|
:model-value="numberValue"
|
||||||
@@ -176,8 +134,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, watch } from 'vue';
|
import { computed, watch } from 'vue';
|
||||||
import { FolderOpened, List, QuestionFilled, Link } from '@element-plus/icons-vue';
|
import { FolderOpened, List, QuestionFilled } from '@element-plus/icons-vue';
|
||||||
import { deepClone, normalizeArrayItem } from './modelParamUtils';
|
import MfReferenceButton from './MfReferenceButton.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'ModelField' });
|
defineOptions({ name: 'ModelField' });
|
||||||
|
|
||||||
@@ -186,10 +144,10 @@ interface UpstreamNodeInfo {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
nodeCode: string;
|
nodeCode: string;
|
||||||
outputFields: { field: string; label: string }[];
|
outputFields: { field: string; label: string; fieldType?: 'scalar' | 'object' | 'array' }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{ fieldDef: any; path?: string; upstreamNodes?: UpstreamNodeInfo[] }>();
|
const props = defineProps<{ fieldDef: any; path?: string; upstreamNodes?: UpstreamNodeInfo[]; multi?: boolean }>();
|
||||||
|
|
||||||
// 用 computed 实时取当前 props,避免 internalSync 替换对象树后仍持有旧引用
|
// 用 computed 实时取当前 props,避免 internalSync 替换对象树后仍持有旧引用
|
||||||
const def = computed(() => props.fieldDef);
|
const def = computed(() => props.fieldDef);
|
||||||
@@ -220,46 +178,57 @@ const isTextarea = computed(() => isLeaf.value && fieldType.value === 'textarea'
|
|||||||
const isUpload = computed(() => isLeaf.value && fieldType.value === 'upload');
|
const isUpload = computed(() => isLeaf.value && fieldType.value === 'upload');
|
||||||
|
|
||||||
// ===== 引用上级节点输出 =====
|
// ===== 引用上级节点输出 =====
|
||||||
// 可引用:叶子 + 非开关 + 非固定选项下拉(开关/下拉值来自模型;上传类型同样支持引用上级输出)
|
// 引用按钮与候选面板在 MfReferenceButton 中统一渲染(叶子与对象/数组整体均可引用)
|
||||||
const canValueSource = computed(() => isLeaf.value && !isSwitch.value && !isSelect.value);
|
// valueSource 契约统一数组 [{ nodeId, field }];multi(节点 isMultiParameter)时支持多选
|
||||||
const hasUpstream = computed(() => (props.upstreamNodes?.length ?? 0) > 0);
|
const valueSourceList = computed(() => {
|
||||||
const hasValueSource = computed(() => !!def.value?.valueSource);
|
const vs = def.value?.valueSource;
|
||||||
// 已引用时的展示文案:{节点label}.{字段label}
|
// 兼容旧 DSL 单对象 { nodeId, field }:统一按数组读取
|
||||||
const valueSourceLabel = computed(() => {
|
if (!vs || typeof vs !== 'object') return [];
|
||||||
const ref = def.value?.valueSource;
|
const arr = Array.isArray(vs) ? vs : [vs];
|
||||||
if (!ref) return '';
|
return arr.map((r: any) => {
|
||||||
const node = props.upstreamNodes?.find((n) => n.id === ref.nodeId);
|
const node = props.upstreamNodes?.find((n) => n.id === r?.nodeId);
|
||||||
const nodeName = node?.label || ref.nodeId;
|
// 引用标签只展示字段中文名(不带「节点名.」前缀)
|
||||||
const fieldLabel = node?.outputFields.find((f) => f.field === ref.field)?.label || ref.field;
|
const fieldLabel = node?.outputFields.find((f) => f.field === r?.field)?.label || r?.field || '';
|
||||||
return `${nodeName}.${fieldLabel}`;
|
const invalid = !node || !node.outputFields.some((f) => f.field === r?.field);
|
||||||
|
return { nodeId: r?.nodeId, field: r?.field, label: fieldLabel, invalid };
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
const hasValueSource = computed(() => valueSourceList.value.length > 0);
|
||||||
|
// 任一引用失效即整体标红(失效项逐条展示,由失效清理 watch 自动移除)
|
||||||
|
const valueSourceInvalid = computed(() => valueSourceList.value.some((r) => r.invalid));
|
||||||
|
|
||||||
// 引用是否已失效:上游节点被删除,或其输出字段已不存在(换模型/换配置导致 schema 变化)
|
// 失效引用自动清理,避免保存脏数据(过滤失效项;全部失效则删除整个 valueSource,复用深 watch 上传链路)
|
||||||
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(
|
watch(
|
||||||
valueSourceInvalid,
|
() => valueSourceList.value,
|
||||||
(invalid) => {
|
(list) => {
|
||||||
if (invalid && def.value) {
|
if (!def.value) return;
|
||||||
delete def.value.valueSource;
|
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;
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
// 选中前驱节点输出字段:就地写 def.valueSource,复用深 watch 上传链路
|
// 删除单个引用(标签 closable)
|
||||||
const setValueSource = (node: UpstreamNodeInfo, of: { field: string; label: string }) => {
|
const removeValueSource = (ref: any) => {
|
||||||
if (!def.value) return;
|
if (!def.value) return;
|
||||||
def.value.valueSource = { nodeId: node.id, field: of.field };
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 清空全部引用
|
||||||
const clearValueSource = () => {
|
const clearValueSource = () => {
|
||||||
if (!def.value) return;
|
if (!def.value) return;
|
||||||
delete def.value.valueSource;
|
delete def.value.valueSource;
|
||||||
@@ -312,22 +281,26 @@ const arrayTemplates = computed<any[]>(() => {
|
|||||||
return [];
|
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';
|
const isObjectDef = (item: any) => !!item && typeof item === 'object' && item.type === 'object' && item.attrs && typeof item.attrs === 'object';
|
||||||
|
|
||||||
const setArrayPrimitive = (idx: number, val: any) => {
|
// 数组渲染统一以模板(enumValues/attrs)为数据源:值与勾选均落在模板上,
|
||||||
getArrayValue()[idx] = val ?? '';
|
// 不受 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 ?? '';
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== array:联合数组(enumValues 多模板)→ 全部模板平铺,固定条数,不可增删 =====
|
// ===== array:联合数组(enumValues 多模板)→ 全部模板平铺,固定条数,不可增删 =====
|
||||||
@@ -366,25 +339,6 @@ 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);
|
const variantLabel = (idx: number): string => templateLabel(def.value?.enumValues?.[idx], idx);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -523,20 +477,26 @@ const variantLabel = (idx: number): string => templateLabel(def.value?.enumValue
|
|||||||
|
|
||||||
.mf-ref-bound {
|
.mf-ref-bound {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #3b82f6;
|
color: #3b82f6;
|
||||||
background: #eff6ff;
|
background: #eff6ff;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 4px 8px;
|
padding: 6px 8px;
|
||||||
|
|
||||||
.mf-ref-bound-text {
|
.mf-ref-tags {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
display: flex;
|
||||||
text-overflow: ellipsis;
|
flex-wrap: wrap;
|
||||||
white-space: nowrap;
|
gap: 4px;
|
||||||
|
|
||||||
|
.mf-ref-tag {
|
||||||
|
:deep(.el-tag__content) {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-invalid {
|
&.is-invalid {
|
||||||
@@ -557,6 +517,9 @@ const variantLabel = (idx: number): string => templateLabel(def.value?.enumValue
|
|||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.mf-ref-popover {
|
.mf-ref-popover {
|
||||||
.mf-ref-panel {
|
.mf-ref-panel {
|
||||||
|
// 面板超高时内部滚动,避免超出浏览器视口被截掉显示不全
|
||||||
|
max-height: 40vh;
|
||||||
|
overflow-y: auto;
|
||||||
.mf-ref-empty {
|
.mf-ref-empty {
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
:field-def="def"
|
:field-def="def"
|
||||||
:path="key"
|
:path="key"
|
||||||
:upstream-nodes="upstreamNodes"
|
:upstream-nodes="upstreamNodes"
|
||||||
|
:multi="multi"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,7 +34,7 @@ interface UpstreamNodeInfo {
|
|||||||
outputFields: { field: string; label: string }[];
|
outputFields: { field: string; label: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{ modelRequestParams: Record<string, any> | null; upstreamNodes?: UpstreamNodeInfo[] }>();
|
const props = defineProps<{ modelRequestParams: Record<string, any> | null; upstreamNodes?: UpstreamNodeInfo[]; multi?: boolean }>();
|
||||||
const emit = defineEmits<{ 'update:modelRequestParams': [Record<string, any> | null] }>();
|
const emit = defineEmits<{ 'update:modelRequestParams': [Record<string, any> | null] }>();
|
||||||
|
|
||||||
const localParams = ref<Record<string, any>>({});
|
const localParams = ref<Record<string, any>>({});
|
||||||
|
|||||||
@@ -22,7 +22,11 @@
|
|||||||
@click="handleSelectModel(model)"
|
@click="handleSelectModel(model)"
|
||||||
>
|
>
|
||||||
<div class="model-card-header">
|
<div class="model-card-header">
|
||||||
<div class="model-type">{{ getModelTypeName(model.modelType) }}</div>
|
<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>
|
||||||
<el-icon v-if="selectedModel?.id === model.id" class="check-icon" color="#67c23a">
|
<el-icon v-if="selectedModel?.id === model.id" class="check-icon" color="#67c23a">
|
||||||
<CircleCheck />
|
<CircleCheck />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
@@ -56,16 +60,56 @@
|
|||||||
<el-button type="primary" @click="handleConfirm" :disabled="!selectedModel">确定</el-button>
|
<el-button type="primary" @click="handleConfirm" :disabled="!selectedModel">确定</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, watch } from 'vue';
|
import { ref, reactive, watch } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
import { Search, CircleCheck } from '@element-plus/icons-vue';
|
import { Search, CircleCheck } from '@element-plus/icons-vue';
|
||||||
import { getWorkflowModelList, type WorkflowModelItem } from '/@/api/settings/workflow';
|
import { getWorkflowModelList, type WorkflowModelItem } from '/@/api/settings/workflow';
|
||||||
|
import { updateModelManage } from '/@/api/settings/modelConfigV2';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: boolean;
|
modelValue: boolean;
|
||||||
defaultModel?: WorkflowModelItem | null;
|
defaultModel?: WorkflowModelItem | null;
|
||||||
|
// 非空时仅列出同类型模型(首页「重新选择模型」场景);工作流管理不传,行为不变
|
||||||
|
sameTypeModelType?: string | number | null;
|
||||||
|
// 系统模型是否必须填写 API Key(默认 true)。
|
||||||
|
// 超级管理员绘制/编辑模板工作流时传 false:选系统模型直接选中,不再弹 API Key。
|
||||||
|
systemModelRequireKey?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
@@ -76,6 +120,8 @@ interface Emits {
|
|||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
modelValue: false,
|
modelValue: false,
|
||||||
defaultModel: null,
|
defaultModel: null,
|
||||||
|
sameTypeModelType: null,
|
||||||
|
systemModelRequireKey: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<Emits>();
|
const emit = defineEmits<Emits>();
|
||||||
@@ -86,6 +132,11 @@ const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
|
|||||||
const modelList = ref<WorkflowModelItem[]>([]);
|
const modelList = ref<WorkflowModelItem[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const selectedModel = ref<WorkflowModelItem | null>(null);
|
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(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
@@ -143,6 +194,9 @@ const fetchModelList = async () => {
|
|||||||
pageNum: pagination.pageNum,
|
pageNum: pagination.pageNum,
|
||||||
pageSize: pagination.pageSize,
|
pageSize: pagination.pageSize,
|
||||||
modelName: searchParams.modelName || undefined,
|
modelName: searchParams.modelName || undefined,
|
||||||
|
...(props.sameTypeModelType !== undefined && props.sameTypeModelType !== null && props.sameTypeModelType !== ''
|
||||||
|
? { isSameType: true, modelType: props.sameTypeModelType }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
const res = await getWorkflowModelList(params);
|
const res = await getWorkflowModelList(params);
|
||||||
modelList.value = res.data?.list || [];
|
modelList.value = res.data?.list || [];
|
||||||
@@ -170,6 +224,14 @@ const handlePageChange = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectModel = (model: WorkflowModelItem) => {
|
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;
|
selectedModel.value = model;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -180,6 +242,68 @@ 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 = () => {
|
const handleClose = () => {
|
||||||
visible.value = false;
|
visible.value = false;
|
||||||
selectedModel.value = null;
|
selectedModel.value = null;
|
||||||
@@ -232,13 +356,19 @@ const handleClose = () => {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
.model-type {
|
.model-type {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
background: #eff6ff;
|
background: #eff6ff;
|
||||||
color: #3b82f6;
|
color: #3b82f6;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
||||||
|
.model-owner-tag {
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.check-icon {
|
.check-icon {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
@@ -272,4 +402,12 @@ const handleClose = () => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.api-key-alert {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-form {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,8 +13,39 @@
|
|||||||
@update:model-value="updateNodeDesc"
|
@update:model-value="updateNodeDesc"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="节点类型">
|
|
||||||
<el-tag>{{ selectedNode.data?.nodeCode }}</el-tag>
|
<!-- 前置方法 / 后置方法(节点库 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>
|
</el-form-item>
|
||||||
|
|
||||||
<!-- 开始节点:运行表单字段摘要(来自各模型节点勾选的「表单展示」字段) -->
|
<!-- 开始节点:运行表单字段摘要(来自各模型节点勾选的「表单展示」字段) -->
|
||||||
@@ -46,7 +77,7 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<!-- 贴片布局开关 -->
|
<!-- 贴片布局开关 -->
|
||||||
<el-form-item v-if="isVideoModelSelected" label="贴片布局">
|
<el-form-item v-if="nodeConfig?.patchLayout" label="贴片布局">
|
||||||
<el-switch
|
<el-switch
|
||||||
:model-value="selectedNode.data?.patchLayout ?? false"
|
:model-value="selectedNode.data?.patchLayout ?? false"
|
||||||
@update:model-value="emit('update:patchLayout', $event)"
|
@update:model-value="emit('update:patchLayout', $event)"
|
||||||
@@ -85,62 +116,86 @@
|
|||||||
<template v-if="nodeConfig?.formConfig && nodeConfig.formConfig.length > 0">
|
<template v-if="nodeConfig?.formConfig && nodeConfig.formConfig.length > 0">
|
||||||
<el-divider content-position="left">节点参数</el-divider>
|
<el-divider content-position="left">节点参数</el-divider>
|
||||||
<template v-for="field in nodeConfig.formConfig" :key="field.field">
|
<template v-for="field in nodeConfig.formConfig" :key="field.field">
|
||||||
<el-form-item :label="field.label" :required="field.required">
|
<el-form-item :required="field.required">
|
||||||
<el-input
|
<template #label>
|
||||||
v-if="field.type === 'input' || field.type === 'string'"
|
<span class="dyn-field-label">{{ field.label }}</span>
|
||||||
:model-value="getFieldValue(field.field)"
|
<!-- 脚本转写节点的视频总时长:支持引用上级节点输出(引用后隐藏输入、显示引用来源) -->
|
||||||
@update:model-value="updateFieldValue(field.field, $event)"
|
<MfReferenceButton v-if="isTotalDurationField(field)" :field-def="fieldRefDef(field)" :upstream-nodes="upstreamNodes" :multi="false" />
|
||||||
:placeholder="field.required ? '必填' : '选填'"
|
</template>
|
||||||
/>
|
<!-- 脚本转写节点的视频总时长:引用上级输出后隐藏输入控件,显示引用来源标签 -->
|
||||||
<el-input-number
|
<template v-if="isTotalDurationField(field) && hasFieldRef(field.field)">
|
||||||
v-else-if="field.type === 'number' || field.type === 'inputNumber'"
|
<div class="mf-ref-bound">
|
||||||
:model-value="getFieldValue(field.field)"
|
<div class="mf-ref-tags">
|
||||||
@update:model-value="updateFieldValue(field.field, $event)"
|
<el-tag
|
||||||
class="w100"
|
v-for="(ref, i) in fieldRefList(field.field)"
|
||||||
/>
|
:key="`${ref.nodeId}-${ref.field}-${i}`"
|
||||||
<el-input
|
size="small"
|
||||||
v-else-if="field.type === 'textarea'"
|
class="mf-ref-tag"
|
||||||
:model-value="getFieldValue(field.field)"
|
:type="ref.invalid ? 'danger' : 'primary'"
|
||||||
@update:model-value="updateFieldValue(field.field, $event)"
|
closable
|
||||||
type="textarea"
|
@close="removeFieldRef(field, ref)"
|
||||||
:rows="3"
|
>{{ ref.label }}</el-tag>
|
||||||
:placeholder="field.required ? '必填' : '选填'"
|
</div>
|
||||||
/>
|
<el-button size="small" text type="danger" @click="clearFieldRef(field)">清空</el-button>
|
||||||
<el-switch
|
</div>
|
||||||
v-else-if="field.type === 'switch'"
|
</template>
|
||||||
:model-value="getFieldValue(field.field)"
|
<template v-else>
|
||||||
@update:model-value="updateFieldValue(field.field, $event)"
|
<el-input
|
||||||
/>
|
v-if="field.type === 'input' || field.type === 'string'"
|
||||||
<el-select
|
:model-value="getFieldValue(field.field)"
|
||||||
v-else-if="field.type === 'select' && field.options"
|
@update:model-value="updateFieldValue(field.field, $event)"
|
||||||
:model-value="getFieldValue(field.field)"
|
:placeholder="field.required ? '必填' : '选填'"
|
||||||
@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>
|
<el-input-number
|
||||||
<KeyValueEditor
|
v-else-if="field.type === 'number' || field.type === 'inputNumber'"
|
||||||
v-else-if="field.type === 'keyValue'"
|
:model-value="getFieldValue(field.field)"
|
||||||
:model-value="getFieldValue(field.field)"
|
@update:model-value="updateFieldValue(field.field, $event)"
|
||||||
@update:model-value="(v: any) => updateFieldValue(field.field, v)"
|
class="w100"
|
||||||
/>
|
/>
|
||||||
<el-button
|
<el-input
|
||||||
v-else-if="field.type === 'schemaJson'"
|
v-else-if="field.type === 'textarea'"
|
||||||
@click="openSchemaEditor(field.field)"
|
:model-value="getFieldValue(field.field)"
|
||||||
style="width: 100%"
|
@update:model-value="updateFieldValue(field.field, $event)"
|
||||||
>配置</el-button>
|
type="textarea"
|
||||||
<el-input
|
:rows="3"
|
||||||
v-else
|
:placeholder="field.required ? '必填' : '选填'"
|
||||||
:model-value="getFieldValue(field.field)"
|
/>
|
||||||
@update:model-value="updateFieldValue(field.field, $event)"
|
<el-switch
|
||||||
:placeholder="field.required ? '必填' : '选填'"
|
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-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<!-- 下拉选中选项的嵌套配置(responseType 级联) -->
|
<!-- 下拉选中选项的嵌套配置(responseType 级联) -->
|
||||||
@@ -184,7 +239,7 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
<KeyValueEditor
|
<KeyValueEditor
|
||||||
v-else-if="sub.type === 'keyValue'"
|
v-else-if="sub.type === 'keyValue'"
|
||||||
:model-value="getExpandValue(field.field, sub.field)"
|
:model-value="getExpandKeyValueValue(field.field, sub.field)"
|
||||||
@update:model-value="(v: any) => updateExpandValue(field.field, sub.field, v)"
|
@update:model-value="(v: any) => updateExpandValue(field.field, sub.field, v)"
|
||||||
/>
|
/>
|
||||||
<el-button
|
<el-button
|
||||||
@@ -202,6 +257,28 @@
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</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 节点) -->
|
<!-- 自定义表单字段(formConfigOption,如 form 节点) -->
|
||||||
<template v-if="nodeConfig?.formConfigOption">
|
<template v-if="nodeConfig?.formConfigOption">
|
||||||
<el-divider content-position="left">自定义字段</el-divider>
|
<el-divider content-position="left">自定义字段</el-divider>
|
||||||
@@ -224,6 +301,7 @@
|
|||||||
<ModelParamsForm
|
<ModelParamsForm
|
||||||
:model-request-params="selectedNode.data.modelConfig.modelRequestParams"
|
:model-request-params="selectedNode.data.modelConfig.modelRequestParams"
|
||||||
:upstream-nodes="upstreamNodes"
|
:upstream-nodes="upstreamNodes"
|
||||||
|
:multi="!!nodeConfig?.isMultiParameter"
|
||||||
@update:model-request-params="updateModelRequestParams"
|
@update:model-request-params="updateModelRequestParams"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -249,12 +327,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
import type { Node } from '@vue-flow/core';
|
import type { Node } from '@vue-flow/core';
|
||||||
import KeyValueEditor from './KeyValueEditor.vue';
|
import KeyValueEditor from './KeyValueEditor.vue';
|
||||||
|
import MfReferenceButton from './MfReferenceButton.vue';
|
||||||
import FormFieldsEditor, { type FormField } from './FormFieldsEditor.vue';
|
import FormFieldsEditor, { type FormField } from './FormFieldsEditor.vue';
|
||||||
import ModelParamsForm from './ModelParamsForm.vue';
|
import ModelParamsForm from './ModelParamsForm.vue';
|
||||||
import PromptEditor from './PromptEditor.vue';
|
import PromptEditor from './PromptEditor.vue';
|
||||||
|
import SubFlowParams from './SubFlowParams.vue';
|
||||||
|
import type { SubFlowField } from './subFlowTypes';
|
||||||
import { JsonEditor } from '/@/components/json-schema-editor';
|
import { JsonEditor } from '/@/components/json-schema-editor';
|
||||||
|
|
||||||
interface NodeData {
|
interface NodeData {
|
||||||
@@ -268,17 +349,25 @@ interface NodeData {
|
|||||||
negativePrompt?: string;
|
negativePrompt?: string;
|
||||||
patchLayout?: boolean;
|
patchLayout?: boolean;
|
||||||
isSaveFile?: boolean;
|
isSaveFile?: boolean;
|
||||||
|
preTool?: string | null;
|
||||||
|
postTool?: string | null;
|
||||||
runFormFields?: any[];
|
runFormFields?: any[];
|
||||||
|
// 子流程节点(sub_flow):引入的工作流配置(workflowId + 引入参数)
|
||||||
|
subFlowConfig?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NodeConfig {
|
interface NodeConfig {
|
||||||
formConfig: any[];
|
formConfig: any[];
|
||||||
|
preToolOption: any[];
|
||||||
|
postToolOption: any[];
|
||||||
|
isMultiParameter: boolean;
|
||||||
modelConfigOption: boolean;
|
modelConfigOption: boolean;
|
||||||
formConfigOption: boolean;
|
formConfigOption: boolean;
|
||||||
skillOption: boolean;
|
skillOption: boolean;
|
||||||
promptOption: boolean;
|
promptOption: boolean;
|
||||||
negativePromptOption: boolean;
|
negativePromptOption: boolean;
|
||||||
isSaveFileOption: boolean;
|
isSaveFileOption: boolean;
|
||||||
|
patchLayout: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 前驱节点的可引用输出字段(供模型参数「引用上级节点输出」)
|
// 前驱节点的可引用输出字段(供模型参数「引用上级节点输出」)
|
||||||
@@ -295,17 +384,17 @@ const props = defineProps<{
|
|||||||
upstreamNodes?: UpstreamNodeInfo[];
|
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__');
|
const isStartNode = computed(() => props.selectedNode?.data?.nodeCode === '__start__');
|
||||||
|
|
||||||
// 开始节点运行表单字段摘要(数据来自 index.vue 聚合写回)
|
// 开始节点运行表单字段摘要(数据来自 index.vue 聚合写回)
|
||||||
const runFormFields = computed<any[]>(() => props.selectedNode?.data?.runFormFields || []);
|
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 可读标签
|
// fieldType 可读标签
|
||||||
const fieldTypeLabel = (ft: string) => {
|
const fieldTypeLabel = (ft: string) => {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
@@ -330,6 +419,8 @@ const emit = defineEmits<{
|
|||||||
(e: 'openSkillSelector'): void;
|
(e: 'openSkillSelector'): void;
|
||||||
(e: 'removeSkill'): void;
|
(e: 'removeSkill'): void;
|
||||||
(e: 'update:patchLayout', value: boolean): void;
|
(e: 'update:patchLayout', value: boolean): void;
|
||||||
|
(e: 'openWorkflowSelector'): void;
|
||||||
|
(e: 'removeWorkflow'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Schema 编辑器弹窗
|
// Schema 编辑器弹窗
|
||||||
@@ -433,12 +524,71 @@ const updateModelRequestParams = (params: Record<string, any> | null) => {
|
|||||||
emit('update:selectedNode', updatedNode);
|
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) => {
|
const getFieldValue = (fieldName: string) => {
|
||||||
if (!props.selectedNode?.data?.formConfig) return '';
|
if (!props.selectedNode?.data?.formConfig) return '';
|
||||||
const field = props.selectedNode.data.formConfig.find((f: any) => f.field === fieldName);
|
const field = props.selectedNode.data.formConfig.find((f: any) => f.field === fieldName);
|
||||||
return field?.value ?? '';
|
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) => {
|
const updateFieldValue = (fieldName: string, value: any) => {
|
||||||
if (!props.selectedNode?.data) return;
|
if (!props.selectedNode?.data) return;
|
||||||
|
|
||||||
@@ -474,6 +624,108 @@ const getFormConfigEntry = (fieldName: string) => {
|
|||||||
return props.selectedNode.data.formConfig.find((f: any) => f.field === fieldName);
|
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)
|
// 当前选中下拉选项的嵌套 config 定义(来自节点库 presetOption)
|
||||||
const getSelectedOptionConfig = (fieldDef: any) => {
|
const getSelectedOptionConfig = (fieldDef: any) => {
|
||||||
if (fieldDef?.type !== 'select' || !Array.isArray(fieldDef.options)) return [];
|
if (fieldDef?.type !== 'select' || !Array.isArray(fieldDef.options)) return [];
|
||||||
@@ -489,6 +741,12 @@ const getExpandValue = (hostField: string, subField: string) => {
|
|||||||
return sub?.value ?? '';
|
return sub?.value ?? '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 嵌套 keyValue 类型字段对象化(同 getKeyValueValue,供下拉选项嵌套配置使用)
|
||||||
|
const getExpandKeyValueValue = (hostField: string, subField: string) => {
|
||||||
|
const v = getExpandValue(hostField, subField);
|
||||||
|
return v && typeof v === 'object' ? v : {};
|
||||||
|
};
|
||||||
|
|
||||||
// 写嵌套 expand 字段值(单次 emit)
|
// 写嵌套 expand 字段值(单次 emit)
|
||||||
const updateExpandValue = (hostField: string, subField: string, value: any) => {
|
const updateExpandValue = (hostField: string, subField: string, value: any) => {
|
||||||
if (!props.selectedNode?.data) return;
|
if (!props.selectedNode?.data) return;
|
||||||
@@ -549,6 +807,35 @@ const updateSelectFieldValue = (fieldDef: any, value: any) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<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 {
|
.config-panel {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
<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,6 +102,15 @@ export function stripReadonlyFields(params: any): any {
|
|||||||
}
|
}
|
||||||
return true;
|
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 (isEnumValues) def.enumValues = keptTemplates;
|
||||||
if (isAttrsArray) def.attrs = keptTemplates;
|
if (isAttrsArray) def.attrs = keptTemplates;
|
||||||
if (keptTemplates.length > 0) hasEditable = true;
|
if (keptTemplates.length > 0) hasEditable = true;
|
||||||
@@ -128,9 +137,47 @@ export function stripReadonlyFields(params: any): any {
|
|||||||
return params;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 暴露清单:在工作流表单中展示的勾选字段 =====
|
// ===== 暴露清单:在工作流表单中展示的勾选字段 =====
|
||||||
|
|
||||||
// 单个暴露叶子字段(路径带实例索引,如 "messages.value[0].attrs.content")
|
// 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")
|
||||||
export interface ExposedField {
|
export interface ExposedField {
|
||||||
path: string;
|
path: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -139,13 +186,46 @@ export interface ExposedField {
|
|||||||
required: boolean;
|
required: boolean;
|
||||||
options?: any[];
|
options?: any[];
|
||||||
value?: any;
|
value?: any;
|
||||||
|
defaultValue?: any; // 默认值:首页初始化/回显用
|
||||||
|
fieldConstraint?: any; // 字段约束:上传格式/大小/数量、数字 min/max 等
|
||||||
|
valueSource?: any; // 引用上游节点输出([{ nodeId, field }] 数组,支持多选);有则首页只读展示
|
||||||
|
multiple?: boolean; // 多文件上传标记
|
||||||
refNodeId?: string; // 预留:引用其他节点功能(后续启用)
|
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 的叶子字段,生成暴露清单
|
// 收集 runtimeShow === true 的叶子字段,生成暴露清单
|
||||||
// 路径文法(与 restoreRuntimeShow 共用):
|
// 路径文法(与 restoreRuntimeShow 共用):
|
||||||
// object 子字段 → 父路径 + ".attrs." + 子key
|
// object 子字段 → 父路径 + ".attrs." + 子key
|
||||||
// array 实例元素 → 父路径 + ".value[i]"
|
// array 模板元素 → 父路径 + ".enumValues[i]"
|
||||||
// 原始值数组元素(非 object 包装)不可勾选,跳过
|
// 原始值数组元素(非 object 包装)不可勾选,跳过
|
||||||
export function collectExposedFields(params: any, prefix = ''): ExposedField[] {
|
export function collectExposedFields(params: any, prefix = ''): ExposedField[] {
|
||||||
if (!params || typeof params !== 'object') return [];
|
if (!params || typeof params !== 'object') return [];
|
||||||
@@ -161,14 +241,17 @@ export function collectExposedFields(params: any, prefix = ''): ExposedField[] {
|
|||||||
result.push(...collectExposedFields(attrs, `${path}.attrs`));
|
result.push(...collectExposedFields(attrs, `${path}.attrs`));
|
||||||
}
|
}
|
||||||
} else if (t === 'array') {
|
} else if (t === 'array') {
|
||||||
// 仅遍历实例数组;元素为 { type:'object', attrs } 包装时才递归
|
// 勾选跟随模板:渲染时 value 元素与模板共享引用,runtimeShow 落在模板上,
|
||||||
if (Array.isArray(def.value)) {
|
// 故遍历模板(enumValues 优先)收集,path 用 enumValues[i] 定位模板
|
||||||
def.value.forEach((item: any, i: number) => {
|
const templates = arrayTemplatesOf(def);
|
||||||
if (item && typeof item === 'object' && item.attrs && typeof item.attrs === 'object' && !Array.isArray(item.attrs)) {
|
templates.forEach((tpl: any, i: number) => {
|
||||||
result.push(...collectExposedFields(item.attrs, `${path}.value[${i}].attrs`));
|
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`));
|
||||||
|
}
|
||||||
|
});
|
||||||
} else if (def.runtimeShow === true) {
|
} else if (def.runtimeShow === true) {
|
||||||
// 叶子且已勾选
|
// 叶子且已勾选
|
||||||
result.push({
|
result.push({
|
||||||
@@ -179,20 +262,157 @@ export function collectExposedFields(params: any, prefix = ''): ExposedField[] {
|
|||||||
required: !!def.required,
|
required: !!def.required,
|
||||||
options: Array.isArray(def.options) ? def.options : undefined,
|
options: Array.isArray(def.options) ? def.options : undefined,
|
||||||
value: def.value,
|
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;
|
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(加载时还原勾选)
|
// 按暴露清单的 path 反解,把对应叶子 def 的 runtimeShow 置 true(加载时还原勾选)
|
||||||
export function restoreRuntimeShow(params: any, fields: ExposedField[] | null | undefined): void {
|
// startNodeId:勾选「表单展示」的字段保存时带 valueSource 指向开始节点(后端契约);
|
||||||
|
// 回显时该 valueSource 无编辑器引用语义,清除避免 ModelField 误判引用/误删
|
||||||
|
export function restoreRuntimeShow(params: any, fields: ExposedField[] | null | undefined, startNodeId = ''): void {
|
||||||
if (!params || typeof params !== 'object' || !Array.isArray(fields) || fields.length === 0) return;
|
if (!params || typeof params !== 'object' || !Array.isArray(fields) || fields.length === 0) return;
|
||||||
for (const f of fields) {
|
for (const f of fields) {
|
||||||
if (!f || typeof f.path !== 'string') continue;
|
if (!f || typeof f.path !== 'string') continue;
|
||||||
const cur = resolvePath(params, f.path);
|
const cur = resolvePath(params, f.path);
|
||||||
if (cur && typeof cur === 'object' && !Array.isArray(cur)) {
|
if (cur && typeof cur === 'object' && !Array.isArray(cur)) {
|
||||||
cur.runtimeShow = true;
|
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 }];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,14 +428,26 @@ function resolvePath(params: any, path: string): any {
|
|||||||
cur = cur.attrs;
|
cur = cur.attrs;
|
||||||
if (!cur || typeof cur !== 'object') return undefined;
|
if (!cur || typeof cur !== 'object') return undefined;
|
||||||
} else {
|
} else {
|
||||||
const m = seg.match(/^value\[(\d+)\]$/);
|
const mv = seg.match(/^value\[(\d+)\]$/);
|
||||||
if (m) {
|
if (mv) {
|
||||||
const idx = Number(m[1]);
|
const idx = Number(mv[1]);
|
||||||
if (!Array.isArray(cur.value) || idx >= cur.value.length) return undefined;
|
if (!Array.isArray(cur.value) || idx >= cur.value.length) return undefined;
|
||||||
cur = cur.value[idx];
|
cur = cur.value[idx];
|
||||||
} else {
|
continue;
|
||||||
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;
|
return cur;
|
||||||
@@ -225,3 +457,53 @@ function resolvePath(params: any, path: string): any {
|
|||||||
export function isEqual(a: any, b: any): boolean {
|
export function isEqual(a: any, b: any): boolean {
|
||||||
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// 子流程节点(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
Reference in New Issue
Block a user