Explorar o código

feat: 新增动态表单组件

zhupei hai 1 ano
pai
achega
fa4249c1b9

+ 73 - 0
src/modules/btn-iframe-chujuan/BtnConfigDynamic.vue

@@ -0,0 +1,73 @@
+<template>
+  <a-button @click="handleClick" class="chujuan-btn">
+    <img class="chujuan-logo-img" :src="logo" /> {{ $t('wendangchujuan.agent_title') }}
+  </a-button>
+
+  <a-modal
+    v-model:visible="visible"
+    @ok="handleOk"
+    @cancel="handleCancel"
+    modal-class="chujuan-config-container-modal"
+    body-class="chujuan-config-container-body"
+  >
+    <template #title> <img class="chujuan-logo-img" :src="logo" /> {{ $t('wendangchujuan.agent_title') }} </template>
+
+    <DynamicFormWraper ref="confForm" />
+  </a-modal>
+</template>
+
+<script setup>
+import { ref } from 'vue';
+
+import logo from '../../assets/1.png';
+
+import DynamicFormWraper from '../form-dynamic/DynamicFormWraper.vue';
+
+const emits = defineEmits(['confirm']);
+
+const confForm = ref(null);
+const visible = ref(false);
+
+const handleClick = () => {
+  visible.value = true;
+};
+const handleOk = () => {
+  const target = confForm.value;
+  if (target && typeof target.getValues === 'function') {
+    const data = target.getValues();
+
+    emits('confirm', data);
+  }
+
+  visible.value = false;
+};
+const handleCancel = () => {
+  visible.value = false;
+};
+</script>
+
+<style scoped>
+.chujuan-btn {
+  border-radius: 30px;
+  color: #353535;
+}
+.chujuan-logo-img {
+  width: 22px;
+  display: inline-block;
+  margin-right: 5px;
+}
+</style>
+
+<style>
+.chujuan-config-container-modal.arco-modal {
+  width: 50%;
+}
+.chujuan-config-container-body.arco-modal-body {
+  /* width: 50%; */
+  padding: 30px;
+}
+.chujuan-config-container-body.arco-modal-body .chujuan-iframe {
+  width: 100%;
+  height: 80vh;
+}
+</style>

+ 2 - 0
src/modules/conversation-chat/DialogInputTop.vue

@@ -5,6 +5,7 @@
       <!-- <BtnIframeChujuan /> -->
 
       <BtnConfigChujuan @confirm="handleChujuanConfirm" />
+      <BtnConfigDynamic @confirm="handleChujuanConfirm" />
     </template>
 
     <template v-if="menu_id === 9">
@@ -27,6 +28,7 @@ import { ref, onMounted, onUnmounted } from 'vue';
 
 import BtnIframeChujuan from '../btn-iframe-chujuan/BtnIframeChujuan.vue';
 import BtnConfigChujuan from '../btn-iframe-chujuan/BtnConfigChujuan.vue';
+import BtnConfigDynamic from '../btn-iframe-chujuan/BtnConfigDynamic.vue';
 
 /**
  * 此组件用以处理对话的附加参数

+ 19 - 0
src/modules/form-dynamic/BtnFileUpload.vue

@@ -0,0 +1,19 @@
+<template>
+  <a-tooltip :content="popmessage">
+    <a-button class="upload-button">
+      <template #icon>
+        <icon-upload />
+      </template>
+
+      <template #default>点击上传</template>
+    </a-button>
+  </a-tooltip>
+</template>
+
+<script setup>
+import { ref, reactive, computed, onMounted } from 'vue';
+
+const props = defineProps({
+  popmessage: String
+});
+</script>

+ 377 - 0
src/modules/form-dynamic/DynamicFormBasic.vue

@@ -0,0 +1,377 @@
+<template>
+  <div>
+    <a-form
+      :model="form_values"
+      :style="{ width: '100%' }"
+      :rules="form_rules"
+      class="dynamic-form-container-jfsdjaklfjsfjsdkalfjskaljsk"
+    >
+      <template v-for="(item, index) of form_fields">
+        <!-- 单行文本:数字 -->
+        <a-form-item v-if="item.type === FIELD_TYPE.NUMBER" :key="index" :field="item.variable" :label="item.label">
+          <a-input v-model="form_values[item.variable]" :placeholder="item.label" type="number" />
+        </a-form-item>
+
+        <!-- 单行文本:文字 -->
+        <a-form-item v-if="item.type === FIELD_TYPE.TEXT_INPUT" :key="index" :field="item.variable" :label="item.label">
+          <a-input v-model="form_values[item.variable]" :placeholder="item.label" />
+        </a-form-item>
+
+        <!-- 段落:多行文本 -->
+        <a-form-item v-if="item.type === FIELD_TYPE.PARAGRAPH" :key="index" :field="item.variable" :label="item.label">
+          <a-textarea v-model="form_values[item.variable]" :placeholder="item.label" :auto-size="item.autosize" />
+        </a-form-item>
+
+        <!-- 多选 -->
+        <a-form-item v-if="item.type === FIELD_TYPE.SELECT" :key="index" :field="item.variable" :label="item.label">
+          <a-select v-model="form_values[item.variable]" :placeholder="item.label" allow-clear>
+            <a-option v-for="(opt, idx) of item.options" :value="opt" :key="opt + '_' + idx">{{ opt }}</a-option>
+          </a-select>
+        </a-form-item>
+
+        <!-- 单文件上传 -->
+        <a-form-item
+          v-if="item.type === FIELD_TYPE.FILE"
+          :key="index"
+          :field="item.variable"
+          :label="item.label"
+          :required="item.required"
+          :rules="[]"
+          :validate-trigger="[]"
+        >
+          <a-upload
+            v-model:file-list="form_values[item.variable]"
+            :multiple="false"
+            :limit="1"
+            :accept="item.accept"
+            :auto-upload="false"
+            @change="handleFileChange(item, $event)"
+          >
+            <template #upload-button>
+              <BtnFileUpload :popmessage="item.popmessage" />
+            </template>
+
+            <template #file-name="{ fileItem }">
+              <a-tooltip :content="fileItem.name">
+                <span>{{ fileItem.name }}</span>
+              </a-tooltip>
+            </template>
+          </a-upload>
+        </a-form-item>
+
+        <!-- 多文件上传 -->
+        <a-form-item
+          v-if="item.type === FIELD_TYPE.FILE_LIST"
+          :key="index"
+          :field="item.variable"
+          :label="item.label"
+          :required="item.required"
+          :rules="[]"
+          :validate-trigger="[]"
+        >
+          <a-upload
+            v-model:file-list="form_values[item.variable]"
+            :multiple="true"
+            :auto-upload="false"
+            :limit="item.limit"
+            :accept="item.accept"
+            @change="handleFileChange(item, $event)"
+          >
+            <template #upload-button>
+              <BtnFileUpload :popmessage="item.popmessage" />
+            </template>
+
+            <template #file-name="{ fileItem }">
+              <a-tooltip :content="fileItem.name">
+                <span>{{ fileItem.name }}</span>
+              </a-tooltip>
+            </template>
+          </a-upload>
+        </a-form-item>
+      </template>
+    </a-form>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, computed, onMounted } from 'vue';
+import getFieldFileUplaodAcceptString from './file-accept';
+import getFieldFileUplaodMessageString from './file-message';
+
+import BtnFileUpload from './BtnFileUpload.vue';
+
+/**
+ * 表单类型
+ */
+const FIELD_TYPE = {
+  // 输入数字
+  NUMBER: 'number',
+  // 输入单行文本
+  TEXT_INPUT: 'text-input',
+  // 输入段落:多行文本
+  PARAGRAPH: 'paragraph',
+  // 下拉选项
+  SELECT: 'select',
+  // 单文件上传
+  // 允许的文件扩展名
+  FILE: 'file',
+  // 多文件上传
+  // 允许的文件扩展名
+  // 最大可上传的文件数量
+  FILE_LIST: 'file-list'
+};
+
+const props = defineProps({
+  /**
+   * 配置项
+   */
+  form_params: Array
+});
+
+/**
+ * 表单的所有值
+ */
+const form_values = reactive(initFormValues());
+
+/**
+ * 文件数据
+ */
+const file_values = ref({});
+
+/**
+ * 表单的各字段参数
+ */
+const form_fields = computed(() => {
+  const forms_conf = props.form_params;
+
+  const conf_list = [];
+
+  const len = forms_conf.length;
+
+  for (let i = 0; i < len; i++) {
+    const item = forms_conf[i];
+
+    const itm_tp = item.type;
+
+    // 默认值
+    const conf = {
+      type: itm_tp,
+      label: item.label,
+      variable: item.variable,
+      options: item.options,
+      popmessage: item.label
+    };
+
+    if (itm_tp === FIELD_TYPE.PARAGRAPH) {
+      // 段落
+      conf.autosize = {
+        minRows: 2,
+        maxRows: 5
+      };
+    }
+
+    // 文档 < 15.00MB, 图片 < 10.00MB, 音频 < 50.00MB, 视频 < 100.00MB
+    if (itm_tp === FIELD_TYPE.FILE) {
+      // 单文件上传
+      conf.required = item.required;
+      conf.accept = getFieldFileUplaodAcceptString(item.allowed_file_types, item.allowed_file_extensions);
+      conf.popmessage = getFieldFileUplaodMessageString(item.allowed_file_types, 1);
+    }
+
+    if (itm_tp === FIELD_TYPE.FILE_LIST) {
+      // 多文件上传: 最多允许上传的数量
+      conf.limit = item.max_length;
+      conf.required = item.required;
+      conf.accept = getFieldFileUplaodAcceptString(item.allowed_file_types, item.allowed_file_extensions);
+      conf.popmessage = getFieldFileUplaodMessageString(item.allowed_file_types, item.max_length);
+    }
+
+    conf_list.push(conf);
+  }
+
+  return conf_list;
+});
+
+/**
+ * 初始化表单的所有数据项
+ */
+function initFormValues() {
+  const form_info = props.form_params;
+
+  const obj = {};
+  const len = form_info.length;
+
+  for (let i = 0; i < len; i++) {
+    const item = form_info[i];
+
+    const field_tp = item.type;
+    const field_name = item.variable;
+    const is_required = item.required;
+
+    if (field_tp === FIELD_TYPE.FILE || field_tp === FIELD_TYPE.FILE_LIST) {
+      obj[field_name] = [];
+    } else {
+      obj[field_name] = '';
+    }
+  }
+
+  return obj;
+}
+
+/**
+ * 表单校验规则
+ */
+const form_rules = computed(() => {
+  const form_info = props.form_params;
+
+  const obj = {};
+  const len = form_info.length;
+
+  for (let i = 0; i < len; i++) {
+    const item = form_info[i];
+
+    const field_tp = item.type;
+    const field_name = item.variable;
+    const is_required = item.required;
+
+    if (field_tp === FIELD_TYPE.FILE || field_tp === FIELD_TYPE.FILE_LIST) {
+      // 文件上传: 文件上传的验证不通过表单,通过自定义验证方式进行验证
+      // if (is_required) {
+      //   item_rule.push({
+      //     required: is_required,
+      //     message: ''
+      //   });
+      // }
+    } else {
+      const item_rule = [];
+
+      // 是否是必填项
+      if (is_required) {
+        item_rule.push({
+          required: is_required,
+          message: `${item.label}是必填项`
+        });
+      } else {
+        // 不是必填项
+      }
+
+      if (field_tp === FIELD_TYPE.TEXT_INPUT || field_tp === FIELD_TYPE.PARAGRAPH) {
+        if (item.max_length > 0) {
+          // 配置最大值验证
+          item_rule.push({
+            maxLength: item.max_length,
+            message: `最大长度为${item.max_length}`
+          });
+        }
+      }
+
+      obj[field_name] = item_rule;
+    }
+  }
+
+  return obj;
+});
+
+/**
+ * 处理文件响应
+ * @param item
+ * @param event
+ */
+const handleFileChange = (item, event) => {
+  const field_key = item.variable;
+
+  // 使用file_values保存文件
+  file_values.value[field_key] = event;
+
+  // // 保存文件的数据信息
+  // form_values[field_key] = event;
+};
+
+const handleSubmit = (data) => {
+  console.log(data);
+};
+
+/**
+ * 获取表单里的所有数据
+ */
+const getFormFiledValues = () => {
+  const res_opts = {};
+
+  for (let key in form_values) {
+    const cur_val = form_values[key];
+
+    if (cur_val === undefined || cur_val === null || cur_val === '') {
+      continue;
+    } else {
+      res_opts[key] = form_values[key];
+    }
+  }
+
+  const files = file_values.value;
+
+  const res_files = {};
+  for (let key in files) {
+    res_files[key] = files[key];
+  }
+
+  return {
+    // 键值对数据
+    options: res_opts,
+    // 文件数据
+    files: res_files
+  };
+};
+
+/**
+ * 清空表单所有数据
+ */
+const clearFormFiledValues = () => {
+  // 初始化数据
+  const init_values = initFormValues();
+
+  for (let key in form_values) {
+    form_values[key] = init_values[key];
+  }
+
+  // TODO: 清除文件数据
+};
+
+defineExpose({
+  /**
+   * 父组件通过ref,调用getValues方法,可以获取到表单里的所有数据
+   */
+  getValues: getFormFiledValues,
+  /**
+   * 父组件通过ref,调用clear方法,可以将表单里数据清空
+   */
+  clear: clearFormFiledValues
+});
+</script>
+
+<style scoped></style>
+
+<style>
+.dynamic-form-container-jfsdjaklfjsfjsdkalfjskaljsk .arco-upload-icon.arco-upload-icon-start {
+  display: none;
+}
+
+.dynamic-form-container-jfsdjaklfjsfjsdkalfjskaljsk .arco-upload-progress {
+  display: none;
+}
+
+.dynamic-form-container-jfsdjaklfjsfjsdkalfjskaljsk .arco-upload-list.arco-upload-list-type-text,
+.dynamic-form-container-jfsdjaklfjsfjsdkalfjskaljsk .arco-upload-list.arco-upload-list-type-picture {
+  width: 100%;
+  max-height: 400px;
+  overflow-y: auto;
+  padding-right: 5px;
+}
+
+.dynamic-form-container-jfsdjaklfjsfjsdkalfjskaljsk .arco-upload-list.arco-upload-list-type-text,
+.dynamic-form-container-jfsdjaklfjsfjsdkalfjskaljsk .arco-upload-list.arco-upload-list-type-picture {
+  width: 100%;
+  max-height: 300px;
+  overflow-y: auto;
+  padding-right: 5px;
+}
+</style>

+ 103 - 0
src/modules/form-dynamic/DynamicFormWraper.vue

@@ -0,0 +1,103 @@
+<template>
+  <template v-if="api_status === 1">
+    <a-spin :size="32" />
+  </template>
+
+  <template v-if="api_status === 2">
+    <a-spin :size="32" />
+  </template>
+
+  <template v-if="api_status === 3">
+    <DynamicFormBasic :form_params="formConfig" ref="formRefference" />
+  </template>
+
+  <template v-if="api_status === 4">
+    <a-result status="warning" :title="api_message"> </a-result>
+  </template>
+
+  <template v-if="api_status === 5">
+    <a-result status="error" :title="api_message"> </a-result>
+  </template>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue';
+
+import DynamicFormBasic from './DynamicFormBasic.vue';
+
+import { getDynamicFormConfig } from './api-dynimic-form';
+
+const formRefference = ref(null);
+
+/**
+ * 获取表单的数据项目
+ */
+const getFormValues = () => {
+  const target = formRefference.value;
+  if (target && typeof target.getValues === 'function') {
+    const data = target.getValues();
+
+    return data;
+  }
+
+  return null;
+};
+
+/**
+ * 清空表单所有数据
+ */
+const clearFormFields = () => {
+  const target = formRefference.value;
+  if (target && typeof target.clear === 'function') {
+    target.clear();
+  }
+};
+
+// api请求的状态
+const api_status = ref(1); // api状态 1 请求未启动 2 请求进心中 3 请求已成功 4 请求失败(网络成功,但是code不等于200) 5 请求出错
+const api_message = ref(''); // 请求消息 与 api_status 对应的 message 消息内容
+const formConfig = ref([]);
+
+/**
+ * 调用api请求
+ */
+const handleApiRequest = () => {
+  // 处理查询参数
+  const opts = {};
+
+  // 进入加载状态
+  api_status.value = 2;
+  api_message.value = '';
+  getDynamicFormConfig(opts)
+    .then((res) => {
+      if (res.code / 1 === 200) {
+        // 成功
+        formConfig.value = res.data;
+
+        // api请求成功
+        api_status.value = 3;
+        api_message.value = 'success';
+      } else {
+        // api请求成功,但是业务失败
+        api_status.value = 4;
+        api_message.value = res.msg;
+      }
+    })
+    .catch((err) => {
+      // api请求出错
+      api_status.value = 5;
+      api_message.value = err.message;
+    });
+};
+
+onMounted(() => {
+  handleApiRequest();
+});
+
+defineExpose({
+  getValues: getFormValues,
+  clear: clearFormFields
+});
+</script>
+
+<style scoped></style>

+ 149 - 0
src/modules/form-dynamic/api-dynimic-form.js

@@ -0,0 +1,149 @@
+import ajax from '../../base/ajax';
+
+/**
+ * 上传文件
+ * @param {string} agent_id
+ * @param {string} chat_id
+ * @param {FormData} file
+ * @returns
+ */
+export const uploadFiles = (agent_id, chat_id, file) => {
+  /**
+   * 响应数据示例
+   * 单文件
+   */
+  const res_example1 = {
+    code: 200,
+    msg: '',
+    data: {
+      id: 'faf0d7f5-ba4d-4dcc-81bd-be826accfeb9',
+      name: 'juejin.pdf',
+      size: 1432958,
+      extension: 'pdf',
+      mime_type: 'application/pdf',
+      created_by: '714172d6-5be3-4891-b413-2eb5a5047d02',
+      created_at: 1734487728
+    }
+  };
+
+  return ajax.post(`/api/files/upload/${agent_id}?chat_id=${chat_id}`, file, {
+    headers: {
+      'Content-Type': 'multipart/form-data'
+    }
+  });
+};
+
+/**
+ * 获取动态表单内容
+ * @param {FormData} file
+ * @returns
+ */
+export const getDynamicFormConfig = (file) => {
+  /**
+   * 响应数据示例
+   * 单文件
+   */
+  const res_example1 = {
+    code: 200,
+    msg: '',
+    data: [
+      {
+        variable: 'product_name',
+        label: '\u5546\u54c1\u540d',
+        type: 'text-input',
+        max_length: 10,
+        required: true,
+        options: []
+      },
+      {
+        variable: 'product_introduce',
+        label: '\u4ea7\u54c1\u4ecb\u7ecd',
+        type: 'paragraph',
+        max_length: 560,
+        required: true,
+        options: []
+      },
+      {
+        variable: 'product_address',
+        label: '\u6240\u5728\u5730',
+        type: 'text-input',
+        max_length: 128,
+        required: false,
+        options: []
+      },
+      {
+        variable: 'product_code',
+        label: '\u4ea7\u54c1\u7f16\u7801',
+        type: 'number',
+        max_length: 48,
+        required: true,
+        options: []
+      },
+      {
+        variable: 'product_level',
+        label: '\u4ea7\u54c1\u7ea7\u522b',
+        type: 'select',
+        max_length: 48,
+        required: true,
+        options: ['\u91d1', '\u94f6', '\u94dc', '\u94c1']
+      },
+      {
+        variable: 'product_image',
+        label: '\u56fe\u7247\u4ecb\u7ecd',
+        type: 'file',
+        max_length: 48,
+        required: true,
+        options: [],
+        allowed_file_upload_methods: ['local_file'],
+        allowed_file_types: ['image'],
+        allowed_file_extensions: []
+      },
+      {
+        variable: 'product_doc',
+        label: '\u8bf4\u660e\u6587\u6863',
+        type: 'file',
+        max_length: 5,
+        required: false,
+        options: [],
+        allowed_file_upload_methods: ['local_file'],
+        allowed_file_types: ['document'],
+        allowed_file_extensions: []
+      },
+      {
+        variable: 'product_video',
+        label: '\u5ba3\u4f20\u89c6\u9891',
+        type: 'file',
+        max_length: 48,
+        required: false,
+        options: [],
+        allowed_file_upload_methods: ['local_file'],
+        allowed_file_types: ['video'],
+        allowed_file_extensions: []
+      },
+      {
+        variable: 'product_assets',
+        label: '\u9644\u52a0\u6587\u4ef6',
+        type: 'file-list',
+        max_length: 20,
+        required: true,
+        options: [],
+        allowed_file_upload_methods: ['local_file'],
+        allowed_file_types: ['document', 'image', 'audio'],
+        allowed_file_extensions: []
+      }
+    ]
+  };
+
+  // const {resolve, reject} = Promise.withResolver();
+  return new Promise((resolve, reject) => {
+    setTimeout(() => {
+      resolve(res_example1);
+    }, 300);
+  });
+
+  // return ajax.post(`/api/document/excel/upload`, file, {
+  //   headers: {
+  //     'Content-Type': 'multipart/form-data'
+  //   }
+  // });
+};

+ 54 - 0
src/modules/form-dynamic/file-accept.js

@@ -0,0 +1,54 @@
+/**
+ * 动态表单,文件上传,可被允许的文件类型
+ */
+
+/**
+ * 此处的值,来自于 dify 的工作流编排中,针对开始节点,添加变量的弹窗里,配置输入字段时,对于单文件和文件列表中的支持的文件类型处
+ * 文件类型下方的文字内容提示信息
+ */
+const types_map = {
+  // 文档
+  document: '.txt,.md,.markdown,.pdf,.html,.xlsx,.xls,.docx,.csv,.eml,.msg,.pptx,.ppt,.xml,.epub',
+  // 图片
+  image: '.jpg,.jpeg,.png,.gif,.webp,.svg',
+  // 音频
+  audio: '.mp3,.m4a,.wav,.webm,.amr,.mpga',
+  // 视频
+  video: '.mp4,.mov,.mpeg,.mpga'
+};
+
+/**
+ * 根据表单 文件 的参数,获取到 accept 值
+ * @param {object[]} file_types - 例如:['document', 'image', 'audio', 'video']
+ * @param {string[]} file_extensions - 例如:[".jdx", ".jsx", ".py"]
+ * @returns
+ */
+export default function getFieldFileUplaodAcceptString(file_types, file_extensions) {
+  let str_list = [];
+
+  // 处理预设类型
+  if (file_types && Array.isArray(file_types)) {
+    file_types.forEach((item) => {
+      if (item in types_map) {
+        const accept = types_map[item];
+
+        str_list.push(accept);
+      }
+    });
+  }
+
+  // 处理自定义类型
+  if (file_extensions && Array.isArray(file_extensions)) {
+    const accept = file_extensions.join(',');
+
+    str_list.push(accept);
+  }
+
+  const str_value = str_list.join(',');
+
+  if (str_value.length === 0) {
+    return '*';
+  } else {
+    return str_value;
+  }
+}

+ 43 - 0
src/modules/form-dynamic/file-message.js

@@ -0,0 +1,43 @@
+const types_map = {
+  // 文档
+  document: '文档 < 15.00MB',
+  // 图片
+  image: '图片 < 10.00MB',
+  // 音频
+  audio: '音频 < 50.00MB',
+  // 视频
+  video: '视频 < 100.00MB'
+};
+
+/**
+ * 根据表单 文件 的参数,获取到 提示信息
+ * @param {object[]} file_types - 例如:['document', 'image', 'audio', 'video']
+ * @param {number} max_length - 最多上传的文件数量,例如:10
+ * @returns
+ */
+export default function getFieldFileUplaodMessageString(file_types, max_length) {
+  let str_list = [];
+
+  // 处理预设类型
+  if (file_types && Array.isArray(file_types)) {
+    file_types.forEach((item) => {
+      if (item in types_map) {
+        const accept = types_map[item];
+
+        str_list.push(accept);
+      }
+    });
+  }
+
+  if (max_length && max_length > 0) {
+    str_list.push(`最多上传${max_length}个文件`);
+  }
+
+  const str_value = str_list.join(',');
+
+  if (str_value.length === 0) {
+    return '';
+  } else {
+    return str_value;
+  }
+}