fix: 统一上传响应与表单校验处理

- 上传组件统一解析后端响应并暴露错误事件

- AI 资源、模型提供商和工作流表单补齐程序化字段校验同步

- 修正 MinIO 对外访问域名配置
This commit is contained in:
2026-03-24 18:38:42 +08:00
parent 799174406e
commit da536ea742
9 changed files with 201 additions and 46 deletions

View File

@@ -116,7 +116,8 @@ dromara:
secret-key: easyflowadmin123 secret-key: easyflowadmin123
end-point: http://127.0.0.1:39000 end-point: http://127.0.0.1:39000
bucket-name: easyflow bucket-name: easyflow
domain: http://127.0.0.1:9000/easyflow/ # minio 对象对外访问链接
domain: http://127.0.0.1:39000/easyflow/
base-path: attachment base-path: attachment
# 自定义节点相关配置 # 自定义节点相关配置

View File

@@ -7,7 +7,9 @@ import { useAppConfig } from '@easyflow/hooks';
import { useAccessStore } from '@easyflow/stores'; import { useAccessStore } from '@easyflow/stores';
import { UploadFilled } from '@element-plus/icons-vue'; import { UploadFilled } from '@element-plus/icons-vue';
import { ElIcon, ElUpload } from 'element-plus'; import { ElIcon, ElMessage, ElUpload } from 'element-plus';
import { normalizeUploadError, resolveUploadPath } from '#/utils/upload-response';
const props = defineProps({ const props = defineProps({
action: { action: {
@@ -20,7 +22,7 @@ const props = defineProps({
}, },
}); });
const emit = defineEmits(['success', 'onChange']); const emit = defineEmits(['success', 'error', 'onChange']);
const accessStore = useAccessStore(); const accessStore = useAccessStore();
const headers = ref({ const headers = ref({
'easyflow-token': accessStore.accessToken, 'easyflow-token': accessStore.accessToken,
@@ -32,7 +34,18 @@ const uploadRef = ref<InstanceType<typeof ElUpload>>();
// 上传成功回调 // 上传成功回调
const handleSuccess: UploadProps['onSuccess'] = (response) => { const handleSuccess: UploadProps['onSuccess'] = (response) => {
emit('success', response.data.path); try {
emit('success', resolveUploadPath(response));
} catch (error) {
const normalizedError = normalizeUploadError(error);
ElMessage.error(normalizedError.message);
emit('error', normalizedError);
}
};
const handleError: UploadProps['onError'] = (error) => {
const normalizedError = normalizeUploadError(error);
ElMessage.error(normalizedError.message);
emit('error', normalizedError);
}; };
// 文件状态变化回调 // 文件状态变化回调
@@ -66,6 +79,7 @@ defineExpose({
:headers="headers" :headers="headers"
:action="`${apiURL}${props.action}`" :action="`${apiURL}${props.action}`"
:on-success="handleSuccess" :on-success="handleSuccess"
:on-error="handleError"
:on-change="handleChange" :on-change="handleChange"
multiple multiple
:style="{ display: props.visible ? 'block' : 'none' }" :style="{ display: props.visible ? 'block' : 'none' }"

View File

@@ -6,8 +6,9 @@ import { ref } from 'vue';
import { useAppConfig } from '@easyflow/hooks'; import { useAppConfig } from '@easyflow/hooks';
import { useAccessStore } from '@easyflow/stores'; import { useAccessStore } from '@easyflow/stores';
import { ElButton, ElUpload } from 'element-plus'; import { ElButton, ElMessage, ElUpload } from 'element-plus';
import { normalizeUploadError, resolveUploadPath } from '#/utils/upload-response';
import { $t } from '#/locales'; import { $t } from '#/locales';
const props = defineProps({ const props = defineProps({
@@ -31,6 +32,7 @@ const props = defineProps({
const emit = defineEmits([ const emit = defineEmits([
'success', // 文件上传成功 'success', // 文件上传成功
'error',
'handleDelete', 'handleDelete',
'handlePreview', 'handlePreview',
'beforeUpload', 'beforeUpload',
@@ -51,7 +53,18 @@ const handleRemove: UploadProps['onRemove'] = (file, uploadFiles) => {
emit('handleDelete', file, uploadFiles); emit('handleDelete', file, uploadFiles);
}; };
const handleSuccess: UploadProps['onSuccess'] = (response) => { const handleSuccess: UploadProps['onSuccess'] = (response) => {
emit('success', response.data.path); try {
emit('success', resolveUploadPath(response));
} catch (error) {
const normalizedError = normalizeUploadError(error);
ElMessage.error(normalizedError.message);
emit('error', normalizedError);
}
};
const handleError: UploadProps['onError'] = (error) => {
const normalizedError = normalizeUploadError(error);
ElMessage.error(normalizedError.message);
emit('error', normalizedError);
}; };
</script> </script>
@@ -66,6 +79,7 @@ const handleSuccess: UploadProps['onSuccess'] = (response) => {
:on-remove="handleRemove" :on-remove="handleRemove"
:limit="props.limit" :limit="props.limit"
:on-success="handleSuccess" :on-success="handleSuccess"
:on-error="handleError"
> >
<ElButton type="primary">{{ $t('button.upload') }}</ElButton> <ElButton type="primary">{{ $t('button.upload') }}</ElButton>
</ElUpload> </ElUpload>

View File

@@ -10,6 +10,7 @@ import { Plus } from '@element-plus/icons-vue';
import { ElIcon, ElImage, ElMessage, ElUpload } from 'element-plus'; import { ElIcon, ElImage, ElMessage, ElUpload } from 'element-plus';
import { $t } from '#/locales'; import { $t } from '#/locales';
import { normalizeUploadError, resolveUploadPath } from '#/utils/upload-response';
const props = defineProps({ const props = defineProps({
action: { action: {
@@ -39,7 +40,7 @@ const props = defineProps({
}, },
}); });
const emit = defineEmits(['success', 'update:modelValue']); const emit = defineEmits(['success', 'error', 'update:modelValue']);
const accessStore = useAccessStore(); const accessStore = useAccessStore();
const headers = ref({ const headers = ref({
'easyflow-token': accessStore.accessToken, 'easyflow-token': accessStore.accessToken,
@@ -48,12 +49,24 @@ const headers = ref({
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD); const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
const localImageUrl = ref(props.modelValue); const localImageUrl = ref(props.modelValue);
const handleAvatarSuccess: UploadProps['onSuccess'] = ( const handleAvatarSuccess: UploadProps['onSuccess'] = (
_response, response,
uploadFile, uploadFile,
) => { ) => {
try {
const imageUrl = resolveUploadPath(response);
localImageUrl.value = URL.createObjectURL(uploadFile.raw!); localImageUrl.value = URL.createObjectURL(uploadFile.raw!);
emit('success', _response.data.path); emit('success', imageUrl);
emit('update:modelValue', _response.data.path); emit('update:modelValue', imageUrl);
} catch (error) {
const normalizedError = normalizeUploadError(error);
ElMessage.error(normalizedError.message);
emit('error', normalizedError);
}
};
const handleAvatarError: UploadProps['onError'] = (error) => {
const normalizedError = normalizeUploadError(error);
ElMessage.error(normalizedError.message);
emit('error', normalizedError);
}; };
watch( watch(
@@ -98,6 +111,7 @@ const beforeAvatarUpload: UploadProps['beforeUpload'] = (rawFile) => {
:headers="headers" :headers="headers"
:show-file-list="false" :show-file-list="false"
:on-success="handleAvatarSuccess" :on-success="handleAvatarSuccess"
:on-error="handleAvatarError"
:before-upload="beforeAvatarUpload" :before-upload="beforeAvatarUpload"
> >
<ElImage <ElImage

View File

@@ -0,0 +1,25 @@
import type { FormInstance, FormItemProp } from 'element-plus';
import type { Ref } from 'vue';
import { nextTick } from 'vue';
type FormRef = Ref<FormInstance | null | undefined>;
export async function syncProgrammaticFieldValidation(
formRef: FormRef,
fields?: Array<FormItemProp>,
) {
await nextTick();
if (!formRef.value) {
return;
}
if (!fields || fields.length === 0) {
formRef.value.clearValidate();
return;
}
formRef.value.clearValidate(fields);
}

View File

@@ -0,0 +1,59 @@
import { $t } from '#/locales';
interface UploadResponseData {
path?: string;
}
interface UploadResponseBody {
data?: UploadResponseData;
errorCode?: number;
message?: string;
}
function getMessageFromUnknown(error: unknown) {
if (typeof error === 'string' && error) {
return error;
}
if (!error || typeof error !== 'object') {
return '';
}
const raw = error as {
message?: unknown;
response?: unknown;
};
if (typeof raw.message === 'string' && raw.message) {
return raw.message;
}
if (typeof raw.response === 'string' && raw.response) {
try {
const parsed = JSON.parse(raw.response) as UploadResponseBody;
return parsed.message || '';
} catch {
return raw.response;
}
}
return '';
}
export function normalizeUploadError(error: unknown) {
const message = getMessageFromUnknown(error);
return new Error(message || $t('cropper.message.uploadFailed'));
}
export function resolveUploadPath(response: UploadResponseBody) {
if (!response || response.errorCode !== 0) {
throw new Error(response?.message || $t('cropper.message.uploadFailed'));
}
const path = response.data?.path;
if (!path) {
throw new Error($t('cropper.message.notUrl'));
}
return path;
}

View File

@@ -9,6 +9,7 @@ import {ElForm, ElFormItem, ElIcon, ElInput, ElMessage, ElOption, ElSelect,} fro
import {api} from '#/api/request'; import {api} from '#/api/request';
import UploadAvatar from '#/components/upload/UploadAvatar.vue'; import UploadAvatar from '#/components/upload/UploadAvatar.vue';
import {$t} from '#/locales'; import {$t} from '#/locales';
import {syncProgrammaticFieldValidation} from '#/utils/form-validation';
import ModelProviderBadge from '#/views/ai/model/ModelProviderBadge.vue'; import ModelProviderBadge from '#/views/ai/model/ModelProviderBadge.vue';
import {getProviderPresetByValue, providerPresets,} from '#/views/ai/model/modelUtils/defaultIcon'; import {getProviderPresetByValue, providerPresets,} from '#/views/ai/model/modelUtils/defaultIcon';
@@ -132,6 +133,14 @@ const applyPreset = (value: string) => {
formData.chatPath = preset.options.chatPath || ''; formData.chatPath = preset.options.chatPath || '';
formData.embedPath = preset.options.embedPath || ''; formData.embedPath = preset.options.embedPath || '';
formData.rerankPath = preset.options.rerankPath || ''; formData.rerankPath = preset.options.rerankPath || '';
syncProgrammaticFieldValidation(formDataRef, [
'providerType',
'providerName',
'endpoint',
'chatPath',
'embedPath',
'rerankPath',
]);
}; };
const save = async () => { const save = async () => {

View File

@@ -12,6 +12,7 @@ import { api } from '#/api/request';
import DictSelect from '#/components/dict/DictSelect.vue'; import DictSelect from '#/components/dict/DictSelect.vue';
import Upload from '#/components/upload/Upload.vue'; import Upload from '#/components/upload/Upload.vue';
import { $t } from '#/locales'; import { $t } from '#/locales';
import { syncProgrammaticFieldValidation } from '#/utils/form-validation';
const emit = defineEmits(['reload']); const emit = defineEmits(['reload']);
// vue // vue
@@ -20,50 +21,50 @@ defineExpose({
openDialog, openDialog,
}); });
const saveForm = ref<FormInstance>(); const saveForm = ref<FormInstance>();
const createDefaultEntity = () => ({
categoryId: '',
deptId: '',
fileSize: '',
options: '',
origin: '',
resourceName: '',
resourceType: '',
resourceUrl: '',
status: 0,
suffix: '',
});
// variables // variables
const dialogVisible = ref(false); const dialogVisible = ref(false);
const isAdd = ref(true); const isAdd = ref(true);
const entity = ref<any>({ const entity = ref<any>(createDefaultEntity());
deptId: '',
resourceType: '',
resourceName: '',
suffix: '',
resourceUrl: '',
origin: '',
status: '',
options: '',
fileSize: '',
});
const btnLoading = ref(false); const btnLoading = ref(false);
const rules = ref({ const rules = ref({
deptId: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
resourceType: [ resourceType: [
{ required: true, message: $t('message.required'), trigger: 'blur' }, { required: true, message: $t('message.required'), trigger: 'change' },
], ],
resourceName: [ resourceName: [
{ required: true, message: $t('message.required'), trigger: 'blur' }, {
], required: true,
suffix: [ message: $t('message.required'),
{ required: true, message: $t('message.required'), trigger: 'blur' }, trigger: ['blur', 'change'],
},
], ],
resourceUrl: [ resourceUrl: [
{ required: true, message: $t('message.required'), trigger: 'blur' }, { required: true, message: $t('message.required'), trigger: 'change' },
], ],
origin: [ origin: [
{ required: true, message: $t('message.required'), trigger: 'blur' }, { required: true, message: $t('message.required'), trigger: 'change' },
],
status: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
], ],
}); });
// functions // functions
function openDialog(row: any) { function openDialog(row: any) {
if (row.id) { isAdd.value = !row?.id;
isAdd.value = false; entity.value = {
} ...createDefaultEntity(),
entity.value = row; ...row,
status: row?.status ?? 0,
};
dialogVisible.value = true; dialogVisible.value = true;
} }
function save() { function save() {
@@ -92,20 +93,33 @@ function save() {
function closeDialog() { function closeDialog() {
saveForm.value?.resetFields(); saveForm.value?.resetFields();
isAdd.value = true; isAdd.value = true;
entity.value = {}; entity.value = createDefaultEntity();
dialogVisible.value = false; dialogVisible.value = false;
} }
function beforeUpload(f: any) { function beforeUpload(f: any) {
const fName = f?.name?.split('.')[0]; const fileName = f?.name || '';
const fExt = f?.name?.split('.')[1]; const fileNameParts = fileName.split('.');
const fExt = fileNameParts.length > 1 ? fileNameParts.at(-1) || '' : '';
const fName =
fileNameParts.length > 1 ? fileNameParts.slice(0, -1).join('.') : fileName;
entity.value.resourceType = getResourceType(fExt); entity.value.resourceType = getResourceType(fExt);
entity.value.resourceName = fName; entity.value.resourceName = fName;
entity.value.suffix = fExt; entity.value.suffix = fExt;
entity.value.fileSize = f.size; entity.value.fileSize = f.size;
entity.value.origin = 0; entity.value.origin = 0;
syncProgrammaticFieldValidation(saveForm, [
'origin',
'resourceName',
'resourceType',
]);
} }
function uploadSuccess(res: any) { function uploadSuccess(res: any) {
entity.value.resourceUrl = res; entity.value.resourceUrl = res;
syncProgrammaticFieldValidation(saveForm, ['resourceUrl']);
}
function uploadError() {
entity.value.resourceUrl = '';
syncProgrammaticFieldValidation(saveForm, ['resourceUrl']);
} }
</script> </script>
@@ -129,7 +143,11 @@ function uploadSuccess(res: any) {
class="easyflow-modal-form easyflow-modal-form--compact" class="easyflow-modal-form easyflow-modal-form--compact"
> >
<ElFormItem prop="resourceUrl" :label="$t('aiResource.resourceUrl')"> <ElFormItem prop="resourceUrl" :label="$t('aiResource.resourceUrl')">
<Upload @before-upload="beforeUpload" @success="uploadSuccess" /> <Upload
@before-upload="beforeUpload"
@success="uploadSuccess"
@error="uploadError"
/>
</ElFormItem> </ElFormItem>
<ElFormItem prop="origin" :label="$t('aiResource.origin')"> <ElFormItem prop="origin" :label="$t('aiResource.origin')">
<DictSelect v-model="entity.origin" dict-code="resourceOriginType" /> <DictSelect v-model="entity.origin" dict-code="resourceOriginType" />

View File

@@ -12,6 +12,7 @@ import DictSelect from '#/components/dict/DictSelect.vue';
// import Cropper from '#/components/upload/Cropper.vue'; // import Cropper from '#/components/upload/Cropper.vue';
import UploadAvatar from '#/components/upload/UploadAvatar.vue'; import UploadAvatar from '#/components/upload/UploadAvatar.vue';
import { $t } from '#/locales'; import { $t } from '#/locales';
import { syncProgrammaticFieldValidation } from '#/utils/form-validation';
const emit = defineEmits(['reload']); const emit = defineEmits(['reload']);
// vue // vue
@@ -66,18 +67,18 @@ function openDialog(row: any, importMode = false) {
const beforeUpload: UploadProps['beforeUpload'] = (file) => { const beforeUpload: UploadProps['beforeUpload'] = (file) => {
jsonFile.value = file; jsonFile.value = file;
uploadFileList.value = [file]; uploadFileList.value = [file];
saveForm.value?.clearValidate('jsonFile'); syncProgrammaticFieldValidation(saveForm, ['jsonFile']);
return false; return false;
}; };
const handleChange: UploadProps['onChange'] = (file, fileList) => { const handleChange: UploadProps['onChange'] = (file, fileList) => {
jsonFile.value = file.raw; jsonFile.value = file.raw;
uploadFileList.value = fileList.slice(-1); uploadFileList.value = fileList.slice(-1);
saveForm.value?.clearValidate('jsonFile'); syncProgrammaticFieldValidation(saveForm, ['jsonFile']);
}; };
const handleRemove: UploadProps['onRemove'] = () => { const handleRemove: UploadProps['onRemove'] = () => {
jsonFile.value = null; jsonFile.value = null;
uploadFileList.value = []; uploadFileList.value = [];
saveForm.value?.clearValidate('jsonFile'); syncProgrammaticFieldValidation(saveForm, ['jsonFile']);
}; };
function save() { function save() {
saveForm.value?.validate((valid) => { saveForm.value?.validate((valid) => {