7 Commits

Author SHA1 Message Date
86a7ff1573 Merge remote-tracking branch 'origin/feature/workflow'
# Conflicts:
#	.gitea/workflows/deploy.yml
2026-05-23 16:29:03 +08:00
cb7e36ad03 封版 2026-05-23 16:28:44 +08:00
7c60e34de0 模型配置字段相关修改 2026-05-23 15:04:59 +08:00
38166cb0b8 feat: 添加删除选中元素功能与保存工作流对话框组件
- 新增删除选中元素的按钮,支持对节点的删除操作,并处理下级节点引用的清理。
- 将保存工作流对话框重构为独立组件,提升代码可读性与复用性。
- 优化了预览功能的代码结构,确保视频展示的样式一致性。
2026-05-23 10:22:25 +08:00
ce70f86000 feat: 增强预览功能与文件下载逻辑
- 更新预览弹窗,支持图片、视频和音频格式的展示。
- 添加 fileType 字段以支持不同文件类型的处理。
- 优化下载逻辑,根据文件类型自动设置默认扩展名,并在下载成功提示中显示文件类型。
2026-05-22 18:44:34 +08:00
e357f93779 Merge branch 'feature/workflow' of http://116.204.74.41:3000/red-future/admin-ui into feature/workflow 2026-05-22 18:20:42 +08:00
010db1e7bc refactor: 优化创作页面文件上传逻辑与样式
- 调整了文件上传按钮的样式和结构,提升了可读性。
- 规范化了文件上传状态管理,确保用户体验一致性。
- 更新了动态表单值处理逻辑,支持更灵活的字段扩展管理。
- 清理了动态表单值,确保在节点切换时正确重置相关字段。
2026-05-22 18:20:39 +08:00
7 changed files with 438 additions and 99 deletions

View File

@@ -7,28 +7,47 @@ jobs:
deploy:
runs-on: ubuntu-latest
env:
# 从组织级Secrets读取不用在仓库重复配置
K3S_HOST: ${{ secrets.K3S_HOST }}
K3S_HOST: 121.37.117.181
APP_NAME: ${{ gitea.repo_name }}
REGISTRY: 你的镜像仓库地址 # 比如 docker.io/你的用户名
steps:
- name: 拉取代码
uses: actions/checkout@v4
- uses: gitea/actions/checkout@v4
# 1. 初始化 Docker Buildx
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# 2. 登录镜像仓库(按需)
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PWD }}
# 3. 构建+推送,启用缓存
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ env.REGISTRY }}/${{ env.APP_NAME }}:${{ gitea.sha }}
# 缓存配置:推送到镜像仓库
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.APP_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.APP_NAME }}:buildcache,mode=max
# 4. 核心修改先上传deploy.yaml到K3s服务器再执行kubectl
- name: SSH部署K3s
run: |
run:
mkdir -p ~/.ssh
# 写入组织配置的SSH私钥
echo "${{ secrets.K3S_SSH_KEY }}" > k3s.pem
echo "${{ secrets.K3S_PEM_KEY }}" > k3s.pem
chmod 600 k3s.pem
# 调试:验证私钥是否正确写入
echo "私钥文件权限:"
ls -l k3s.pem
echo "私钥头部仅前5行"
head -5 k3s.pem
# 测试连接会输出服务器主机名和kubectl版本
ssh -i k3s.pem -o StrictHostKeyChecking=no -o ConnectTimeout=10 root@${K3S_HOST} "hostname && kubectl version --client"
# 正式执行部署命令
# 关键1把Gitea仓库里的deploy.yaml上传到K3s服务器临时目录/tmp
# 注意如果你的deploy.yaml不在仓库根目录要修改./deploy.yaml为实际路径
scp -i k3s.pem -o StrictHostKeyChecking=no ./deploy.yaml root@${K3S_HOST}:/tmp/
# 关键2执行kubectl时指向临时目录的文件而非不存在的/k8s/
ssh -i k3s.pem -o StrictHostKeyChecking=no root@${K3S_HOST} << CMD
kubectl apply -f /k8s/deploy.yaml
kubectl rollout restart deployment ${APP_NAME}
kubectl apply -f /tmp/deploy.yaml
kubectl rollout restart deployment ${APP_NAME} -n default
# 可选:部署完成后删除临时文件,清理服务器
rm -f /tmp/deploy.yaml
CMD

View File

@@ -26,6 +26,6 @@ COPY ngnix.conf /etc/nginx/conf.d/default.conf
# 复制SSL证书
COPY ssl/* /etc/nginx/ssl/
EXPOSE 80 443
EXPOSE 443
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -112,6 +112,7 @@ export interface CreateModelParams {
modelName: string;
/** 与 listType 返回的类型 id 一致,可能为数字或字符串 */
modelType: number | string;
operatorName?: string;
baseUrl: string;
httpMethod?: string;
headMsg?: string;
@@ -122,6 +123,10 @@ export interface CreateModelParams {
form: ModelFormEntry[];
requestMapping?: Record<string, unknown>;
responseMapping?: Record<string, unknown>;
responseBody?: Record<string, unknown>;
extendMapping?: Record<string, unknown>;
responseTokenField?: string;
tokenConfig?: Record<string, unknown>;
maxConcurrency?: number;
queueLimit?: number;
timeoutSeconds: number;

View File

@@ -92,7 +92,14 @@
import { ref, reactive, watch, onMounted } from 'vue';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { Search, CircleCheck } from '@element-plus/icons-vue';
import { getModelModuleList, addModelModule, getModelTypeList, normalizeModelTypeOptions } from '/@/api/settings/modelConfig/modelModule';
import {
getModelModuleList,
addModelModule,
getModelTypeList,
normalizeModelTypeOptions,
type CreateModelParams,
type ModelFormEntry,
} from '/@/api/settings/modelConfig/modelModule';
import { checkIsSuperAdmin } from '/@/api/system/user/index';
import { getApiErrorMessage } from '/@/utils/request';
import EditModule from '/@/views/settings/modelConfig/modelModule/component/editModule.vue';
@@ -101,7 +108,7 @@ interface ModelItem {
id: string;
tenantId?: number;
modelName: string;
modelType: number;
modelType: number | string;
baseUrl: string;
route: string;
httpMethod: string;
@@ -113,9 +120,10 @@ interface ModelItem {
operatorName?: string;
responseBody?: Record<string, unknown>;
tokenConfig?: Record<string, unknown> | string;
form?: any;
requestMapping?: any;
responseMapping?: any;
extendMapping?: Record<string, unknown> | string;
form?: ModelFormEntry[] | Record<string, unknown>;
requestMapping?: Record<string, unknown>;
responseMapping?: Record<string, unknown>;
maxConcurrency?: number;
queueLimit?: number;
timeoutSeconds?: number;
@@ -206,13 +214,30 @@ watch(
}
);
const getModelTypeName = (type: number) => {
const getModelTypeName = (type: number | string) => {
const typeMap: Record<number, string> = {
1: '推理模型',
2: '图片模型',
3: '音频模型',
};
return typeMap[type] || '未知类型';
return typeMap[Number(type)] || '未知类型';
};
const parseJsonObjectField = (raw: unknown): Record<string, unknown> => {
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
return raw as Record<string, unknown>;
}
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw || '{}');
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
return {};
}
}
return {};
};
const fetchModelList = async () => {
@@ -270,7 +295,13 @@ const handleCreatePrivateModel = async () => {
creatingModel.value = true;
const builtInModel = builtInModelToClone.value;
const createParams = {
const formList: ModelFormEntry[] = Array.isArray(builtInModel.form)
? (builtInModel.form as ModelFormEntry[])
: Object.entries((builtInModel.form as Record<string, unknown>) || {}).map(([key, value]) => ({
key: String(key),
value: String(value ?? ''),
}));
const createParams: CreateModelParams = {
modelName: apiKeyForm.modelName,
modelType: builtInModel.modelType,
operatorName: builtInModel.operatorName || '',
@@ -281,9 +312,9 @@ const handleCreatePrivateModel = async () => {
enabled: builtInModel.enabled ?? 1,
isChatModel: builtInModel.isChatModel || 0,
apiKey: apiKeyForm.apiKey,
form: builtInModel.form || {},
requestMapping: builtInModel.requestMapping || {},
responseMapping: builtInModel.responseMapping || {},
form: formList,
requestMapping: (builtInModel.requestMapping as Record<string, unknown>) || {},
responseMapping: (builtInModel.responseMapping as Record<string, unknown>) || {},
responseBody: builtInModel.responseBody || {},
maxConcurrency: builtInModel.maxConcurrency || 10,
queueLimit: builtInModel.queueLimit || 100,
@@ -293,8 +324,9 @@ const handleCreatePrivateModel = async () => {
retryQueueMaxSeconds: builtInModel.retryQueueMaxSeconds || 60,
autoCleanSeconds: builtInModel.autoCleanSeconds || 300,
remark: builtInModel.remark || '',
tokenMapping: builtInModel.tokenMapping || '',
tokenConfig: builtInModel.tokenConfig || {},
extendMapping: parseJsonObjectField(builtInModel.extendMapping),
tokenConfig: parseJsonObjectField(builtInModel.tokenConfig),
};
const res: any = await addModelModule(createParams);

View File

@@ -0,0 +1,40 @@
<template>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" :close-on-click-modal="false">
<el-form :model="saveForm" label-position="top">
<el-form-item label="工作流名称" required>
<el-input v-model="saveForm.flowName" placeholder="请输入工作流名称" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="工作流描述">
<el-input v-model="saveForm.description" type="textarea" :rows="4" placeholder="请输入工作流描述(选填)" maxlength="200" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="emit('confirm')">{{ confirmText }}</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{
modelValue: boolean;
saveForm: { flowName: string; description: string };
currentEditingWorkflowId: string | null;
saving: boolean;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
(e: 'confirm'): void;
}>();
const dialogVisible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
});
const dialogTitle = computed(() => (props.currentEditingWorkflowId ? '编辑工作流' : '保存工作流'));
const confirmText = computed(() => (props.currentEditingWorkflowId ? '确定更新' : '确定保存'));
</script>

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="creation-page" :class="{ 'creation-mode': isCreationMode }">
<!-- 左侧面板工作空间/当前选中元素 Tab切换 -->
<div class="panel left">
@@ -352,7 +352,13 @@
:accept="getCreationFileAccept(field)"
:on-change="(file: any) => handleCreationFieldUpload(node, field, file)"
>
<el-button size="small" type="primary" :loading="isCreationFieldUploading(node, field)" :disabled="isFromWorkspace || isCreationFieldUploading(node, field)">{{ isCreationFieldUploading(node, field) ? '上传中...' : '选择文件' }}</el-button>
<el-button
size="small"
type="primary"
:loading="isCreationFieldUploading(node, field)"
:disabled="isFromWorkspace || isCreationFieldUploading(node, field)"
>{{ isCreationFieldUploading(node, field) ? '上传中...' : '选择文件' }}</el-button
>
</el-upload>
</div>
<div class="creation-upload-tags">
@@ -360,7 +366,11 @@
<span class="creation-upload-tag count">已上传 {{ getCreationFileCountText(node, field) }}</span>
</div>
<div v-if="getCreationFieldFiles(node, field).length > 0" class="uploaded-files-list creation-upload-list">
<div v-for="(uploadedFile, fileIdx) in getCreationFieldFiles(node, field)" :key="fileIdx" class="uploaded-file-item creation-upload-item">
<div
v-for="(uploadedFile, fileIdx) in getCreationFieldFiles(node, field)"
:key="fileIdx"
class="uploaded-file-item creation-upload-item"
>
<span class="file-name">{{ uploadedFile.name }}</span>
<el-button
type="danger"
@@ -439,6 +449,18 @@
</div>
<div class="meta-actions">
<el-button size="small" @click="resetFlow">清空画布</el-button>
<el-button
size="small"
type="danger"
:disabled="
!selectedElement ||
(selectedElement.kind === 'node' &&
(selectedElement.properties?.nodeCode === START_NODE_CODE || selectedElement.text === START_NODE_TEXT))
"
@click="deleteSelectedElement"
>
删除选中
</el-button>
<el-button type="primary" size="small" @click="saveWorkflowAction" :loading="saving">保存工作流</el-button>
</div>
</div>
@@ -554,25 +576,13 @@
</div>
<!-- 保存工作流对话框 -->
<el-dialog
<SaveWorkflowDialog
v-model="saveDialogVisible"
:title="currentEditingWorkflowId ? '编辑工作流' : '保存工作流'"
width="500px"
:close-on-click-modal="false"
>
<el-form :model="saveForm" label-position="top">
<el-form-item label="工作流名称" required>
<el-input v-model="saveForm.flowName" placeholder="请输入工作流名称" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="工作流描述">
<el-input v-model="saveForm.description" type="textarea" :rows="4" placeholder="请输入工作流描述选填" maxlength="200" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="saveDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmSaveWorkflow" :loading="saving">{{ currentEditingWorkflowId ? '确定更新' : '确定保存' }}</el-button>
</template>
</el-dialog>
:save-form="saveForm"
:current-editing-workflow-id="currentEditingWorkflowId"
:saving="saving"
@confirm="confirmSaveWorkflow"
/>
<!-- 技能选择器 -->
<SkillSelector v-model="showSkillSelector" :default-skill="selectedSkill" @confirm="handleSkillConfirm" />
@@ -759,7 +769,15 @@
<!-- 预览弹窗 -->
<el-dialog v-model="previewDialogVisible" title="预览" width="95%" top="2vh" :close-on-click-modal="false" destroy-on-close>
<div class="preview-container">
<iframe v-if="previewUrl" :src="previewUrl" class="preview-iframe" frameborder="0"></iframe>
<el-image v-if="previewUrl && previewMode === 'image'" :src="previewUrl" fit="contain" style="width: 100%; height: 100%" />
<video
v-else-if="previewUrl && previewMode === 'video'"
:src="previewUrl"
controls
style="width: 100%; height: 100%; background: #000"
></video>
<audio v-else-if="previewUrl && previewMode === 'audio'" :src="previewUrl" controls style="width: 100%"></audio>
<iframe v-else-if="previewUrl" :src="previewUrl" class="preview-iframe" frameborder="0"></iframe>
<el-empty v-else description="无法加载预览内容" />
</div>
</el-dialog>
@@ -776,6 +794,7 @@ import '@logicflow/core/dist/index.css';
import '@logicflow/extension/lib/style/index.css';
import SkillSelector from '/@/components/skill/NodeSkillSelector.vue';
import ModelSelector from '/@/components/model/ModelSelector.vue';
import SaveWorkflowDialog from './component/SaveWorkflowDialog.vue';
import type { SkillItem } from '/@/api/settings/skill';
import {
downloadToFile,
@@ -810,6 +829,7 @@ interface TreeNode {
children?: TreeNode[];
fileUrl?: string;
workflowId?: number | string;
fileType?: string;
sessionId?: string;
}
interface SelectedState {
@@ -881,6 +901,7 @@ const isDraggingMiddleSplitter = ref(false);
// 预览相关状态
const previewDialogVisible = ref(false);
const previewUrl = ref('');
const previewMode = ref<'iframe' | 'image' | 'video' | 'audio'>('iframe');
// 模型选择器相关状态
const showModelSelector = ref(false);
const selectedModelData = ref<any>(null);
@@ -1252,6 +1273,7 @@ const buildTreeNodes = (tree: ExecutionTreeItem[]): TreeNode[] =>
label: item.label || `作品${ii + 1}`,
nodeType: 'title',
fileUrl: item.content,
fileType: item.type,
workflowId: f.Id,
sessionId: f.sessionId,
})),
@@ -1799,7 +1821,7 @@ const handleCreationFieldUpload = async (node: any, field: any, file: any) => {
creationFieldFiles[key].push({ name: file.name, url: fileUrl });
creationFormValues[key] = creationFieldFiles[key].map((f) => f.url);
ElMessage.success('文件上传成功');
} catch (error: any) {
} catch (error: any) {
ElMessage.error(error?.message || '文件上传失败');
} finally {
creationFieldUploading[key] = false;
@@ -1945,7 +1967,9 @@ const sendMessage = async () => {
value:
bodyItem.fieldType === 'fileUpload'
? Array.isArray(userVal !== undefined ? userVal : bodyItem.value)
? (userVal !== undefined ? userVal : bodyItem.value)
? userVal !== undefined
? userVal
: bodyItem.value
: userVal !== undefined
? [userVal]
: bodyItem.value
@@ -2130,13 +2154,13 @@ const handleTreeNodeClick = async (data: TreeNode) => {
creationFormValues[fieldKey] = Boolean(field.value);
} else {
creationFormValues[fieldKey] =
field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload'
? Array.isArray(field.value)
? field.value
: field.value
? [field.value]
: []
: field.value || '';
field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload'
? Array.isArray(field.value)
? field.value
: field.value
? [field.value]
: []
: field.value || '';
}
});
}
@@ -2214,13 +2238,13 @@ const handleTreeNodeClick = async (data: TreeNode) => {
creationFormValues[fieldKey] = Boolean(field.value);
} else {
creationFormValues[fieldKey] =
field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload'
? Array.isArray(field.value)
? field.value
: field.value
? [field.value]
: []
: field.value || '';
field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload'
? Array.isArray(field.value)
? field.value
: field.value
? [field.value]
: []
: field.value || '';
}
});
}
@@ -2248,9 +2272,21 @@ const handleTreeNodeClick = async (data: TreeNode) => {
};
// 预览节点
const previewNode = (d: TreeNode) => {
if (d.nodeType !== 'html' && d.nodeType !== 'image' && d.nodeType !== 'title') return;
if (!d.fileUrl) return ElMessage.warning('当前节点没有可用预览地址');
const url = buildAssetUrl(d.fileUrl);
if (!url) return ElMessage.warning('当前节点没有可用预览地址');
const type = String(d.fileType || '').toLowerCase();
if (type === 'image') {
previewMode.value = 'image';
} else if (type === 'video') {
previewMode.value = 'video';
} else if (type === 'audio') {
previewMode.value = 'audio';
} else {
previewMode.value = 'iframe';
}
previewUrl.value = url;
previewDialogVisible.value = true;
};
@@ -2264,7 +2300,9 @@ const downloadNode = async (d: TreeNode) => {
const blob = r instanceof Blob ? r : r?.data;
if (!(blob instanceof Blob)) throw new Error('invalid blob');
const fileName = d.fileUrl.split('/').pop() || '';
const fileExt = fileName.split('.').pop()?.toLowerCase() || 'html';
const type = String(d.fileType || '').toLowerCase();
const defaultExt = type === 'video' ? 'mp4' : type === 'audio' ? 'mp3' : 'html';
const fileExt = fileName.split('.').pop()?.toLowerCase() || defaultExt;
const name = decodeURIComponent(fileName || `${d.label}.${fileExt}`);
const u = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -2274,7 +2312,7 @@ const downloadNode = async (d: TreeNode) => {
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(u);
ElMessage.success('下载成功');
ElMessage.success(`下载成功${type ? `${type}` : ''}`);
} catch {
// 下载失败由 request 全局提示后端 message
}
@@ -2406,12 +2444,18 @@ const isHttpExpandTriggerField = (fieldItem: any) => {
};
const openHttpExpandDialog = (fieldItem: any) => {
if (!Array.isArray(fieldItem?.expand)) return;
const nodeId = selectedElement.value?.id;
if (!nodeId) return;
currentHttpExpandFields.value = fieldItem.expand;
Object.keys(httpExpandFormValues).forEach((k) => delete httpExpandFormValues[k]);
Object.keys(httpExpandKeyValuePairs).forEach((k) => delete httpExpandKeyValuePairs[k]);
fieldItem.expand.forEach((f: any) => {
httpExpandFormValues[f.field] = dynamicFormValues[f.field] !== undefined ? dynamicFormValues[f.field] : (f.default ?? '');
const expandKey = `${nodeId}_responseType_expand_${f.field}`;
httpExpandFormValues[f.field] = dynamicFormValues[expandKey] !== undefined ? dynamicFormValues[expandKey] : '';
});
showHttpExpandDialog.value = true;
};
const getHttpExpandKeyValuePairs = (field: string) => {
@@ -2454,12 +2498,28 @@ const removeHttpExpandKeyValuePair = (field: string, index: number) => {
updateHttpExpandKeyValueField(field);
};
const confirmHttpExpandDialog = () => {
const nodeId = selectedElement.value?.id;
if (!nodeId) return;
currentHttpExpandFields.value.forEach((f: any) => {
dynamicFormValues[f.field] = httpExpandFormValues[f.field];
const expandKey = `${nodeId}_responseType_expand_${f.field}`;
dynamicFormValues[expandKey] = httpExpandFormValues[f.field];
});
showHttpExpandDialog.value = false;
ElMessage.success('主动拉取参数已保存');
};
const buildHttpResponseTypeExpandData = (responseTypeField: any, nodeId: string) => {
if (!Array.isArray(responseTypeField?.expand) || responseTypeField.expand.length === 0) return [];
return responseTypeField.expand.map((f: any) => {
const expandKey = `${nodeId}_responseType_expand_${f.field}`;
return {
...f,
value: dynamicFormValues[expandKey] !== undefined ? dynamicFormValues[expandKey] : '',
};
});
};
// HTTP请求体配置相关函数
// 打开HTTP请求体配置弹窗
@@ -3182,12 +3242,14 @@ watch(
currentHttpBodyField.value = '';
showHttpBodyDialog.value = false;
// 重置 dynamicFormValues不删除键,保持响应式
// 重置 dynamicFormValues不删除固定字段键,动态 expand 键按节点切换清理
for (const key in dynamicFormValues) {
if (key.includes('_responseType_expand_')) {
delete dynamicFormValues[key];
continue;
}
dynamicFormValues[key] = '';
}
// 获取当前节点的基础表单字段(直接从 nodeSchemaMap 获取,避免响应式延迟)
const currentNodeCode = formState.nodeCode;
const baseFormFields = nodeSchemaMap.value[currentNodeCode] || [];
const baseFieldNames = new Set(baseFormFields.map((f) => f.field));
@@ -3208,6 +3270,16 @@ watch(
// 其他类型:保持原值
dynamicFormValues[fieldConfig.field] = fieldConfig.value;
}
if (
String(e.properties.nodeCode || '').toLowerCase() === 'http' &&
fieldConfig.field === 'responseType' &&
Array.isArray(fieldConfig.expand)
) {
fieldConfig.expand.forEach((expandField: any) => {
const expandKey = `${e.id}_responseType_expand_${expandField.field}`;
dynamicFormValues[expandKey] = expandField.value !== undefined ? expandField.value : '';
});
}
} else {
// 自定义字段:加载到 customFields
const customType = fieldConfig.type === 'upload' ? 'uploadMultiple' : fieldConfig.type || 'input';
@@ -3460,6 +3532,11 @@ const applySelected = () => {
label: fieldItem.label,
value: normalizedValue,
required: fieldItem.required || false,
...(String(formState.nodeCode || '').toLowerCase() === 'http' && fieldItem.field === 'responseType' && normalizedValue === 'pull'
? {
expand: buildHttpResponseTypeExpandData(fieldItem, cur.id),
}
: {}),
});
});
@@ -3708,7 +3785,100 @@ const resetFlow = () => {
selectedElement.value = null;
syncDsl();
};
// 从后端 DSL 恢复工作流
const cleanupReferencesToNode = (deletedNodeId: string) => {
const lf = logicFlowInstance.value;
if (!lf) return 0;
const graphData = lf.getGraphData() as { nodes?: Item[] };
const nodes = graphData.nodes || [];
let affectedCount = 0;
nodes.forEach((node: any) => {
if (node.id === deletedNodeId) return;
const props = node.properties || {};
const inputSource = Array.isArray(props.inputSource) ? props.inputSource : [];
const nextInputSource = inputSource.filter((item: any) => item?.nodeId !== deletedNodeId);
if (nextInputSource.length === inputSource.length) return;
affectedCount += 1;
const normalizedInputSource = nextInputSource.length > 0 ? nextInputSource : null;
lf.setProperties(node.id, {
...props,
inputSource: normalizedInputSource,
});
if (selectedElement.value?.id === node.id) {
selectedElement.value.properties = {
...props,
inputSource: normalizedInputSource,
};
}
});
return affectedCount;
};
const getAffectedDownstreamNodeNames = (deletedNodeId: string) => {
const lf = logicFlowInstance.value;
if (!lf) return [] as string[];
const graphData = lf.getGraphData() as { nodes?: Item[] };
const nodes = graphData.nodes || [];
const names: string[] = [];
nodes.forEach((node: any) => {
if (node.id === deletedNodeId) return;
const props = node.properties || {};
const inputSource = Array.isArray(props.inputSource) ? props.inputSource : [];
const referenced = inputSource.some((item: any) => item?.nodeId === deletedNodeId);
if (!referenced) return;
const nodeName = typeof node.text === 'string' ? node.text : node.text?.value || node.id;
names.push(String(nodeName));
});
return names;
};
const deleteSelectedElement = async () => {
const lf = logicFlowInstance.value;
const cur = selectedElement.value;
if (!lf || !cur) return;
if (cur.kind === 'node' && (cur.properties?.nodeCode === START_NODE_CODE || cur.text === START_NODE_TEXT)) {
ElMessage.warning('开始节点不能删除');
return;
}
try {
let affectedCount = 0;
if (cur.kind === 'node') {
const affectedNodeNames = getAffectedDownstreamNodeNames(cur.id);
if (affectedNodeNames.length > 0) {
const previewNames = affectedNodeNames.slice(0, 8);
const overflowText = affectedNodeNames.length > 8 ? `\n...等 ${affectedNodeNames.length} 个节点` : '';
await ElMessageBox.confirm(
`删除该节点将清理以下下级节点中的引用:\n${previewNames.join('、')}${overflowText}`,
'删除确认',
{
confirmButtonText: '继续删除',
cancelButtonText: '取消',
type: 'warning',
}
);
}
affectedCount = cleanupReferencesToNode(cur.id);
lf.deleteNode(cur.id);
} else {
lf.deleteEdge(cur.id);
}
selectedElement.value = null;
ElMessage.success(affectedCount > 0 ? `删除成功,已清理 ${affectedCount} 个下级节点引用` : '删除成功');
} catch (error) {
if (error === 'cancel') return;
ElMessage.error('删除失败');
}
};// 从后端 DSL 恢复工作流
const loadWorkflowFromDsl = (dsl: any) => {
const lf = logicFlowInstance.value;
if (!lf || !dsl) return;
@@ -5456,3 +5626,7 @@ onBeforeUnmount(() => {
justify-content: center;
}
</style>

View File

@@ -152,9 +152,15 @@
</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="Token映射" prop="tokenMapping">
<el-input v-model="state.ruleForm.tokenMapping" placeholder="请输入Token映射" clearable></el-input>
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
<el-form-item label="附加映射" prop="extendMapping">
<el-input
v-model="state.ruleForm.extendMapping"
type="textarea"
:rows="4"
placeholder='请输入 JSON 对象,例如:{"\"foo\": \"bar\"}'
clearable
></el-input>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
@@ -163,7 +169,7 @@
v-model="state.ruleForm.tokenConfig"
type="textarea"
:rows="4"
placeholder="请输入 JSON 对象,例如:{promptRate: 1, completionRate: 1}"
placeholder='请输入 JSON 对象,例如:{\"promptRate\": 1, \"completionRate\": 1}'
clearable
></el-input>
</el-form-item>
@@ -240,9 +246,18 @@
<el-dialog v-model="showResponseMappingDialog" title="配置响应映射" width="700px" :close-on-click-modal="false">
<div class="mapping-config-container">
<div v-for="(field, index) in state.responseMappingFields" :key="index" class="mapping-field-item">
<el-input v-model="field.key" placeholder="请输入字段名 (Key)" style="width: 30%" clearable></el-input>
<el-input
v-model="field.key"
placeholder="请输入字段名 (Key)"
style="width: 30%"
clearable
@input="syncTokenFieldOnKeyChange(index)"
></el-input>
<span class="separator">=</span>
<el-input v-model="field.value" placeholder="请输入字段值 (Value)" style="width: 30%" clearable></el-input>
<el-button :type="field.isTokenField ? 'warning' : 'primary'" :plain="!field.isTokenField" @click="setTokenField(index)" size="small">
{{ field.isTokenField ? '✓ 计费字段' : '设置计费字段' }}
</el-button>
<el-button :type="field.isMainBody ? 'success' : 'primary'" :plain="!field.isMainBody" @click="setMainBody(index)" size="small">
{{ field.isMainBody ? '✓ 返回主体' : '设置返回主体' }}
</el-button>
@@ -271,6 +286,7 @@ import {
getModelModuleDetail,
getOperatorList,
type ModelFormEntry,
type CreateModelParams,
} from '/@/api/settings/modelConfig/modelModule/index';
export type ModelTypeOption = { id: number | string; label: string };
@@ -334,7 +350,8 @@ const state = reactive({
retryQueueMaxSeconds: 60,
autoCleanSeconds: 300,
remark: '',
tokenMapping: '',
extendMapping: '{}',
responseTokenField: '',
tokenConfig: '{}',
},
rules: {
@@ -401,6 +418,27 @@ const state = reactive({
trigger: 'blur',
},
],
extendMapping: [
{
validator: (_rule: unknown, value: unknown, callback: (e?: Error) => void) => {
if (value === undefined || value === null || String(value).trim() === '') {
callback();
return;
}
try {
const parsed = JSON.parse(String(value));
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
callback();
return;
}
callback(new Error('附加映射必须为 JSON 对象'));
} catch {
callback(new Error('附加映射 JSON 格式不正确'));
}
},
trigger: 'blur',
},
],
},
dialog: {
isShowDialog: false,
@@ -414,7 +452,7 @@ const state = reactive({
headers: [] as Array<{ key: string; value: string }>,
formFields: [] as Array<{ key: string; value: string }>,
requestMappingFields: [] as Array<{ key: string; value: string }>,
responseMappingFields: [] as Array<{ key: string; value: string; isMainBody?: boolean }>,
responseMappingFields: [] as Array<{ key: string; value: string; isMainBody?: boolean; isTokenField?: boolean }>,
mainBodyIndex: -1, // 记录哪一行被设置为返回主体
});
@@ -549,11 +587,28 @@ const confirmRequestMappingFields = () => {
// 响应映射字段操作
const addResponseMappingField = () => {
state.responseMappingFields.push({ key: '', value: '', isMainBody: false });
state.responseMappingFields.push({ key: '', value: '', isMainBody: false, isTokenField: false });
};
const removeResponseMappingField = (index: number) => {
const removed = state.responseMappingFields[index];
state.responseMappingFields.splice(index, 1);
if (removed?.isTokenField) {
state.ruleForm.responseTokenField = '';
}
};
const setTokenField = (index: number) => {
state.responseMappingFields.forEach((field, i) => {
field.isTokenField = i === index;
});
state.ruleForm.responseTokenField = state.responseMappingFields[index]?.key?.trim?.() || '';
};
const syncTokenFieldOnKeyChange = (index: number) => {
const row = state.responseMappingFields[index];
if (!row?.isTokenField) return;
state.ruleForm.responseTokenField = row.key?.trim?.() || '';
};
// 设置返回主体(单选)
@@ -571,9 +626,9 @@ const confirmResponseMappingFields = () => {
const ensureKeyValueRows = (rows: Array<{ key: string; value: string }>) => (rows.length ? rows : [{ key: '', value: '' }]);
const ensureResponseMappingRows = (rows: Array<{ key: string; value: string; isMainBody?: boolean }>) => {
if (!rows.length) return [{ key: '', value: '', isMainBody: false }];
return rows.map((row) => ({ ...row, isMainBody: row.isMainBody || false }));
const ensureResponseMappingRows = (rows: Array<{ key: string; value: string; isMainBody?: boolean; isTokenField?: boolean }>) => {
if (!rows.length) return [{ key: '', value: '', isMainBody: false, isTokenField: false }];
return rows.map((row) => ({ ...row, isMainBody: row.isMainBody || false, isTokenField: row.isTokenField || false }));
};
/** 从 getModel 返回的 data 中取出单条模型对象 */
@@ -623,11 +678,10 @@ const fillFormFromDetailRow = (row: Record<string, unknown>) => {
retryQueueMaxSeconds: Number(row.retryQueueMaxSeconds ?? 60),
autoCleanSeconds: Number(row.autoCleanSeconds ?? 300),
remark: String(row.remark || ''),
tokenMapping: String(row.tokenMapping || ''),
tokenConfig:
typeof row.tokenConfig === 'string'
? row.tokenConfig
: JSON.stringify((row.tokenConfig as Record<string, unknown>) || {}, null, 2),
extendMapping:
typeof row.extendMapping === 'string' ? row.extendMapping : JSON.stringify((row.extendMapping as Record<string, unknown>) || {}, null, 2),
responseTokenField: String(row.responseTokenField || ''),
tokenConfig: typeof row.tokenConfig === 'string' ? row.tokenConfig : JSON.stringify((row.tokenConfig as Record<string, unknown>) || {}, null, 2),
};
state.headers = ensureKeyValueRows(parseHeaders(String(row.headMsg || '')));
state.formFields = ensureKeyValueRows(parseFormFields(row.form));
@@ -635,6 +689,15 @@ const fillFormFromDetailRow = (row: Record<string, unknown>) => {
state.requestMappingFields = ensureKeyValueRows(parseRequestMappingFields(row.requestMapping));
state.responseMappingFields = ensureResponseMappingRows(parseResponseMappingFields(row.responseMapping));
// 根据 responseTokenField 字段设置计费字段标记(单选)
const tokenFieldKey = String(row.responseTokenField || '').trim();
if (tokenFieldKey) {
const tokenFieldIndex = state.responseMappingFields.findIndex((f) => String(f.key || '').trim() === tokenFieldKey);
if (tokenFieldIndex !== -1) {
state.responseMappingFields[tokenFieldIndex].isTokenField = true;
}
}
// 根据 responseBody 字段设置返回主体标记 (responseBody 是对象 {key: value})
if (row.responseBody && typeof row.responseBody === 'object') {
const responseBodyKey = Object.keys(row.responseBody)[0];
@@ -705,13 +768,14 @@ const openDialog = async (type: string, row?: Record<string, unknown>) => {
retryQueueMaxSeconds: 60,
autoCleanSeconds: 300,
remark: '',
tokenMapping: '',
extendMapping: '{}',
responseTokenField: '',
tokenConfig: '{}',
};
state.headers = [{ key: '', value: '' }];
state.formFields = [{ key: '', value: '' }];
state.requestMappingFields = [{ key: '', value: '' }];
state.responseMappingFields = [{ key: '', value: '', isMainBody: false }];
state.responseMappingFields = [{ key: '', value: '', isMainBody: false, isTokenField: false }];
state.dialog.title = '新增模型配置';
state.dialog.submitTxt = '新 增';
}
@@ -742,7 +806,9 @@ const onSubmit = () => {
// 获取被设置为返回主体的字段 {key: value}
const responseBodyField = state.responseMappingFields.find((f) => f.isMainBody);
const responseBody = responseBodyField ? { [responseBodyField.key.trim()]: responseBodyField.value } : {};
const submitData = {
const responseTokenField =
state.responseMappingFields.find((f) => f.isTokenField)?.key?.trim() || String(state.ruleForm.responseTokenField || '').trim();
const submitData: CreateModelParams = {
modelName: state.ruleForm.modelName,
modelType: state.ruleForm.modelType as number | string,
operatorName: state.ruleForm.operatorName,
@@ -753,7 +819,9 @@ const onSubmit = () => {
enabled: state.ruleForm.enabled,
isChatModel: state.ruleForm.isChatModel,
apiKey: state.ruleForm.isPrivate === 1 ? String(state.ruleForm.apiKey ?? '').trim() : '',
form: fieldsToObject(state.formFields),
form: state.formFields
.filter((f) => String(f.key || '').trim() !== '')
.map((f) => ({ key: String(f.key).trim(), value: String(f.value ?? '') })),
requestMapping,
responseMapping,
responseBody,
@@ -765,7 +833,8 @@ const onSubmit = () => {
retryQueueMaxSeconds: state.ruleForm.retryQueueMaxSeconds,
autoCleanSeconds: state.ruleForm.autoCleanSeconds,
remark: state.ruleForm.remark || '',
tokenMapping: state.ruleForm.tokenMapping || '',
extendMapping: parseJsonObjectField(state.ruleForm.extendMapping || '{}', {}),
responseTokenField,
tokenConfig: parseJsonObjectField(state.ruleForm.tokenConfig || '{}', {}),
};