| | |
| | | <div class="add-group"> |
| | | <div v-if="title">*</div> |
| | | <span v-if="title">{{ title }}</span> |
| | | <el-button |
| | | @click="showAddDialog = true" |
| | | class="el-icon-plus" |
| | | type="primary" |
| | | > |
| | | 添加组件</el-button |
| | | > |
| | | <el-button v-if="editable" @click="showAddDialog = true" class="el-icon-plus" type="primary"> |
| | | 添加组件</el-button> |
| | | </div> |
| | | |
| | | <!-- 选择组件弹窗 --> |
| | | <AddComponentDialog |
| | | :visible="showAddDialog" |
| | | @confirm="addComponent" |
| | | @close="showAddDialog = false" |
| | | /> |
| | | <AddComponentDialog v-if="editable" :visible="showAddDialog" @confirm="addComponent" |
| | | @close="showAddDialog = false" /> |
| | | |
| | | <!-- 动态渲染组件 --> |
| | | <div |
| | | v-for="(item, idx) in components" |
| | | :key="item.id" |
| | | class="dynamic-component" |
| | | > |
| | | <div v-for="(item, idx) in components" :key="item.id" class="dynamic-component"> |
| | | <!-- 富文本 --> |
| | | <div v-if="item.type == 'richText'"> |
| | | <AiEditor |
| | | :ref="`editor_${item.id}`" |
| | | v-model="item.data.content" |
| | | height="200px" |
| | | placeholder="请输入内容..." |
| | | /> |
| | | <AiEditor :ref="`editor_${item.id}`" :value="item.data.content" height="200px" :readOnly="!editable" placeholder="请输入内容..." |
| | | :disabled="!editable" /> |
| | | </div> |
| | | <!-- 自定义表格 --> |
| | | <div v-else-if="item.type == 'customTable'" style="flex: 1"> |
| | | <div class="table-actions"> |
| | | <el-button size="mini" @click="showTableHeaderDialog(idx)" |
| | | >添加表头</el-button |
| | | > |
| | | <el-button |
| | | size="mini" |
| | | type="primary" |
| | | @click="showAddRowDialog(idx, item.data.headers)" |
| | | >添加数据</el-button |
| | | > |
| | | <div v-if="editable" class="table-actions"> |
| | | <el-button size="mini" @click="showTableHeaderDialog(idx)">添加表头</el-button> |
| | | <el-button size="mini" type="primary" @click="showAddRowDialog(idx, item.data.headers)">添加数据</el-button> |
| | | </div> |
| | | <Table |
| | | :data="item.data.rows" |
| | | :total="null" |
| | | :height="null" |
| | | class="groupTable" |
| | | > |
| | | <!-- <el-table-column |
| | | type="index" |
| | | label="序号" |
| | | width="80" |
| | | ></el-table-column> --> |
| | | |
| | | <el-table-column |
| | | v-for="(header, hidx) in item.data.headers" |
| | | :key="hidx" |
| | | :label="header.name" |
| | | :prop="header.name" |
| | | /> |
| | | <el-table-column |
| | | label="更新时间" |
| | | prop="updateTime" |
| | | min-width="180" |
| | | ></el-table-column> |
| | | <el-table-column label="操作" min-width="200"> |
| | | <Table :data="item.data.rows" :total="null" :height="null" class="groupTable"> |
| | | <el-table-column v-for="(header, hidx) in item.data.headers" :key="hidx" :label="header.name" |
| | | :prop="header.name" /> |
| | | <el-table-column label="更新时间" prop="updateTime" min-width="180"></el-table-column> |
| | | <el-table-column label="操作" min-width="200" v-if="dialogCanEdit"> |
| | | <template slot-scope="scope"> |
| | | <el-button type="text" @click="handleEditRow(idx, scope.$index)" |
| | | >编辑</el-button |
| | | > |
| | | <el-button |
| | | type="text" |
| | | @click="handleDeleteRow(idx, scope.$index)" |
| | | >删除</el-button |
| | | > |
| | | <el-button type="text" @click="handleEditRow(idx, scope.$index)" >编辑</el-button> |
| | | <el-button type="text" v-if="editable" @click="handleDeleteRow(idx, scope.$index)">删除</el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </Table> |
| | | </div> |
| | | <!-- 文件上传 --> |
| | | <div v-else-if="item.type == 'fileUpload'"> |
| | | <el-upload |
| | | action="#" |
| | | :file-list="item.data.fileList" |
| | | :on-change="(file, fileList) => handleFileChange(idx, fileList)" |
| | | list-type="text" |
| | | > |
| | | <el-upload v-if="editable" action="#" :file-list="item.data.fileList" |
| | | :on-change="(file, fileList) => handleFileChange(idx, fileList)" list-type="text"> |
| | | <el-button size="small" icon="el-icon-upload2">点击上传</el-button> |
| | | </el-upload> |
| | | <div v-else> |
| | | <div v-for="file in item.data.fileList" :key="file.uid" class="file-list-item"> |
| | | {{ file.name }} |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <!-- 图片上传 --> |
| | | <div v-else-if="item.type == 'imageUpload'"> |
| | | <el-upload |
| | | action="#" |
| | | :file-list="item.data.imageList" |
| | | :on-change="(file, fileList) => handleImageChange(idx, fileList)" |
| | | :on-success=" |
| | | (res, file, fileList) => |
| | | handleImageSuccess(res, file, fileList, idx) |
| | | " |
| | | list-type="picture-card" |
| | | > |
| | | <i class="el-icon-plus"></i> |
| | | <div class="upload-text">上传图片</div> |
| | | </el-upload> |
| | | <div class="uploaf-notice">支持.jpg .png格式</div> |
| | | <div class="image-upload-container"> |
| | | <el-upload v-if="editable" action="#" :file-list="item.data.imageList" |
| | | :on-change="(file, fileList) => handleImageChange(idx, fileList)" |
| | | :on-success="(res, file, fileList) => handleImageSuccess(res, file, fileList, idx)" :auto-upload="true" |
| | | :http-request="() => { }" :before-upload="beforeImageUpload" list-type="picture-card" |
| | | class="image-uploader"> |
| | | <i class="el-icon-plus"></i> |
| | | <div class="upload-text">上传图片</div> |
| | | </el-upload> |
| | | <div v-else class="image-preview"> |
| | | <img v-for="image in item.data.imageList" :key="image.uid" :src="image.url" class="preview-image" /> |
| | | </div> |
| | | <div class="uploaf-notice">支持.jpg .png格式</div> |
| | | </div> |
| | | </div> |
| | | <img |
| | | src="@/assets/public/delete.png" |
| | | @click="removeComponent(idx)" |
| | | alt="删除" |
| | | class="delete-icon" |
| | | /> |
| | | <img v-if="editable" src="@/assets/public/delete.png" @click="removeComponent(idx)" alt="删除" |
| | | class="delete-icon" /> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 添加表头弹窗 --> |
| | | <el-dialog |
| | | :visible.sync="tableHeaderDialog.visible" |
| | | title="添加表头" |
| | | width="300px" |
| | | > |
| | | <el-input |
| | | v-model="tableHeaderDialog.header" |
| | | placeholder="请输入表头" |
| | | ></el-input> |
| | | <span slot="footer" class="dialog-footer"> |
| | | <el-button @click="tableHeaderDialog.visible = false">取消</el-button> |
| | | <el-button type="primary" @click="confirmAddHeader">确定</el-button> |
| | | </span> |
| | | </el-dialog> |
| | | |
| | | <addTableHeader |
| | | :visible.sync="tableHeaderDialog.visible" |
| | | @confirm="confirmAddHeader" |
| | | ></addTableHeader> |
| | | <addTableData |
| | | :visible.sync="rowDialog.visible" |
| | | :headerList="rowDialog.headers" |
| | | :editData="rowDialog.form" |
| | | :isEdit="rowDialog.isEdit" |
| | | @success="confirmAddRow" |
| | | > |
| | | <addTableHeader :visible.sync="tableHeaderDialog.visible" :participants="participants" |
| | | @confirm="confirmAddHeader"></addTableHeader> |
| | | <addTableData :visible.sync="rowDialog.visible" :headerList="rowDialog.headers" |
| | | :editData="rowDialog.form" :isEdit="rowDialog.isEdit" @success="confirmAddRow"> |
| | | </addTableData> |
| | | </div> |
| | | </template> |
| | |
| | | type: String, |
| | | default: "", |
| | | }, |
| | | participants: { |
| | | type: Array, |
| | | default: () => [] |
| | | }, |
| | | dataSource: { |
| | | type: Array, |
| | | default: () => [] |
| | | }, |
| | | editable: { |
| | | type: Boolean, |
| | | default: true |
| | | }, |
| | | dialogCanEdit: { |
| | | type: Boolean, |
| | | default: true |
| | | } |
| | | }, |
| | | data() { |
| | | return { |
| | |
| | | headers: [], |
| | | form: {}, |
| | | }, |
| | | headerList: [], //编辑的表头列表 |
| | | headerList: [], |
| | | }; |
| | | }, |
| | | watch: { |
| | | dataSource: { |
| | | handler(newVal) { |
| | | if (newVal) { |
| | | newVal = newVal.map(component => { |
| | | let componentData = null; |
| | | |
| | | switch (component.type) { |
| | | case 'richText': |
| | | componentData = { content: component.data } |
| | | break; |
| | | case 'customTable': |
| | | componentData = { |
| | | headers: component.data.headers, |
| | | rows: component.data.rows |
| | | }; |
| | | break; |
| | | case 'fileUpload': |
| | | componentData = { fileList: component.data }; |
| | | break; |
| | | case 'imageUpload': |
| | | componentData = { imageList: component.data }; |
| | | break; |
| | | } |
| | | return { |
| | | type: component.type, |
| | | id: component.id || Math.random().toString(36).substr(2, 9), |
| | | data: componentData |
| | | } |
| | | }) |
| | | } |
| | | this.components = newVal ? [...newVal] : []; |
| | | }, |
| | | immediate: true, |
| | | deep: true |
| | | } |
| | | }, |
| | | methods: { |
| | | addComponent(type) { |
| | | if (!this.editable) return; |
| | | |
| | | if (type === "customTable") { |
| | | if (!this.participants || this.participants.length === 0) { |
| | | this.$message.warning('请先选择实验调度'); |
| | | this.showAddDialog = false; |
| | | return; |
| | | } |
| | | } |
| | | |
| | | this.showAddDialog = false; |
| | | const id = Date.now() + Math.random(); |
| | | let data = {}; |
| | |
| | | if (type === "customTable") data = { headers: [], rows: [] }; |
| | | if (type === "fileUpload") data = { fileList: [] }; |
| | | if (type === "imageUpload") data = { imageList: [] }; |
| | | console.log(type, "111111111111111", this.components); |
| | | |
| | | this.components.push({ id, type, data }); |
| | | this.emitUpdate(); |
| | | }, |
| | | submit() { |
| | | const data = this.components.map(component => { |
| | | let componentData = null; |
| | | |
| | | switch (component.type) { |
| | | case 'richText': |
| | | const editorRef = this.$refs[`editor_${component.id}`]; |
| | | const editor = Array.isArray(editorRef) ? editorRef[0] : editorRef; |
| | | const content = editor ? editor.getContent() : ''; |
| | | componentData = content && content !== '<p></p>' ? content : ''; |
| | | break; |
| | | case 'customTable': |
| | | componentData = { |
| | | headers: component.data.headers, |
| | | rows: component.data.rows |
| | | }; |
| | | break; |
| | | case 'fileUpload': |
| | | componentData = component.data.fileList; |
| | | break; |
| | | case 'imageUpload': |
| | | componentData = component.data.imageList; |
| | | break; |
| | | } |
| | | |
| | | return { |
| | | type: component.type, |
| | | data: componentData |
| | | }; |
| | | }); |
| | | |
| | | this.$emit('submit', data); |
| | | }, |
| | | confirmAddRow(formData) { |
| | | if (!this.editable) return; |
| | | |
| | | const { idx, rowIndex, isEdit } = this.rowDialog; |
| | | if (isEdit) { |
| | | this.components[idx].data.rows[rowIndex] = { |
| | | ...formData, |
| | | updateTime: new Date().toLocaleString() |
| | | }; |
| | | } else { |
| | | this.components[idx].data.rows.push({ |
| | | ...formData, |
| | | updateTime: new Date().toLocaleString() |
| | | }); |
| | | } |
| | | this.rowDialog.visible = false; |
| | | this.rowDialog.form = {}; |
| | | this.emitUpdate(); |
| | | }, |
| | | removeComponent(idx) { |
| | | if (!this.editable) return; |
| | | |
| | | this.components.splice(idx, 1); |
| | | this.emitUpdate(); |
| | | }, |
| | | showTableHeaderDialog(idx) { |
| | | this.tableHeaderDialog.visible = true; |
| | |
| | | this.tableHeaderDialog.header = ""; |
| | | }, |
| | | confirmAddHeader(data) { |
| | | console.log("data", data); |
| | | if (!this.editable) return; |
| | | |
| | | const { idx } = this.tableHeaderDialog; |
| | | // 添加新表头 |
| | | this.components[idx].data.headers.push({ ...data }); |
| | | |
| | | // 为已有行数据添加新表头对应的默认值 |
| | | |
| | | this.components[idx].data.rows.forEach(row => { |
| | | // 如果行数据是对象,直接添加新属性 |
| | | if (typeof row === 'object' && row !== null) { |
| | | if (data.type === 'user') { |
| | | this.$set(row, data.name, []); |
| | |
| | | this.$set(row, data.name, ''); |
| | | } |
| | | } else { |
| | | // 如果行数据不是对象,转换为对象 |
| | | const newRow = {}; |
| | | this.components[idx].data.headers.forEach(header => { |
| | | if (header.name === data.name) { |
| | |
| | | newRow[header.name] = row[header.name] || ''; |
| | | } |
| | | }); |
| | | // 替换原行数据 |
| | | const rowIndex = this.components[idx].data.rows.indexOf(row); |
| | | this.components[idx].data.rows.splice(rowIndex, 1, newRow); |
| | | } |
| | | }); |
| | | |
| | | // 关闭弹窗并重置数据 |
| | | this.tableHeaderDialog.visible = false; |
| | | this.tableHeaderDialog = { |
| | | visible: false, |
| | | idx: null, |
| | | header: "", |
| | | }; |
| | | this.emitUpdate(); |
| | | }, |
| | | showAddRowDialog(idx, headerList) { |
| | | this.headerList = headerList; |
| | |
| | | this.rowDialog.isEdit = false; |
| | | this.rowDialog.headers = this.components[idx].data.headers; |
| | | this.rowDialog.form = {}; |
| | | // 初始化表单数据 |
| | | this.rowDialog.headers.forEach((header) => { |
| | | if (header.type === "user") { |
| | | this.rowDialog.form[header.name] = []; |
| | |
| | | this.rowDialog.rowIndex = rowIndex; |
| | | this.rowDialog.isEdit = true; |
| | | this.rowDialog.headers = this.components[idx].data.headers; |
| | | // 深拷贝行数据,避免直接修改原数据 |
| | | this.rowDialog.form = JSON.parse( |
| | | JSON.stringify(this.components[idx].data.rows[rowIndex]) |
| | | ); |
| | | }, |
| | | handleDeleteRow(idx, rowIndex) { |
| | | if (!this.editable) return; |
| | | |
| | | this.$confirm("确认删除该行数据吗?", "提示", { |
| | | confirmButtonText: "确定", |
| | | cancelButtonText: "取消", |
| | |
| | | .then(() => { |
| | | this.components[idx].data.rows.splice(rowIndex, 1); |
| | | this.$message.success("删除成功"); |
| | | this.emitUpdate(); |
| | | }) |
| | | .catch(() => {}); |
| | | }, |
| | | confirmAddRow(formData) { |
| | | const { idx, rowIndex, isEdit } = this.rowDialog; |
| | | if (isEdit) { |
| | | // 编辑模式:替换原有行数据 |
| | | this.components[idx].data.rows.splice(rowIndex, 1, formData); |
| | | } else { |
| | | // 新增模式:添加新行数据 |
| | | this.components[idx].data.rows.push(formData); |
| | | } |
| | | this.rowDialog.visible = false; |
| | | // 重置对话框数据 |
| | | this.rowDialog = { |
| | | visible: false, |
| | | idx: null, |
| | | rowIndex: null, |
| | | isEdit: false, |
| | | headers: [], |
| | | form: {}, |
| | | }; |
| | | .catch(() => { }); |
| | | }, |
| | | handleFileChange(idx, fileList) { |
| | | if (!this.editable) return; |
| | | |
| | | fileList = fileList.map(file => { |
| | | if (!file.url) { |
| | | file.url = 'https://picsum.photos/200/200'; |
| | | } |
| | | if (!file.name) { |
| | | file.name = '默认文件.txt'; |
| | | } |
| | | return file; |
| | | }); |
| | | this.components[idx].data.fileList = fileList; |
| | | this.emitUpdate(); |
| | | }, |
| | | handleImageChange(idx, fileList) { |
| | | if (!this.editable) return; |
| | | |
| | | fileList = fileList.map(file => { |
| | | if (!file.url) { |
| | | file.url = 'https://picsum.photos/200/200'; |
| | | } |
| | | return file; |
| | | }); |
| | | this.components[idx].data.imageList = fileList; |
| | | this.emitUpdate(); |
| | | }, |
| | | handleImageSuccess(res, file, fileList, idx) { |
| | | // 假设后端返回的图片地址在 res.url |
| | | file.url = res.url; |
| | | file.url = 'https://picsum.photos/200/200'; |
| | | this.components[idx].data.imageList = fileList; |
| | | }, |
| | | // 获取所有组件数据 |
| | | getComponentsData() { |
| | | // 整理数据,图片只保留url |
| | | const submitData = this.components.map((item) => { |
| | | if (item.type === "richText") { |
| | | // 获取富文本编辑器的内容 |
| | | const editorRef = this.$refs[`editor_${item.id}`]; |
| | | return { |
| | | ...item, |
| | | data: { |
| | | content: editorRef ? editorRef.getContent() : item.data.content |
| | | } |
| | | }; |
| | | } |
| | | if (item.type === "imageUpload") { |
| | | return { |
| | | ...item, |
| | | data: { |
| | | imageList: item.data.imageList.map((img) => ({ url: img.url })), |
| | | }, |
| | | }; |
| | | } |
| | | return item; |
| | | }); |
| | | return submitData; |
| | | }, |
| | | // 验证所有组件数据 |
| | | validateComponents() { |
| | | // 验证富文本编辑器 |
| | | const richTextValid = this.components.every(item => { |
| | | if (item.type === 'richText') { |
| | | const editorRef = this.$refs[`editor_${item.id}`]; |
| | | return editorRef && editorRef.getContent().trim() !== ''; |
| | | } |
| | | return true; |
| | | }); |
| | | beforeImageUpload(file) { |
| | | const isJPG = file.type === 'image/jpeg'; |
| | | const isPNG = file.type === 'image/png'; |
| | | const isLt2M = file.size / 1024 / 1024 < 2; |
| | | |
| | | if (!richTextValid) { |
| | | this.$message.error('请填写所有富文本内容'); |
| | | if (!isJPG && !isPNG) { |
| | | this.$message.error('上传图片只能是 JPG 或 PNG 格式!'); |
| | | return false; |
| | | } |
| | | |
| | | // 验证表格数据 |
| | | const tableValid = this.components.every(item => { |
| | | if (item.type === 'customTable') { |
| | | return item.data.rows.length > 0; |
| | | } |
| | | return true; |
| | | }); |
| | | |
| | | if (!tableValid) { |
| | | this.$message.error('请至少添加一行表格数据'); |
| | | if (!isLt2M) { |
| | | this.$message.error('上传图片大小不能超过 2MB!'); |
| | | return false; |
| | | } |
| | | |
| | | return true; |
| | | }, |
| | | // 提交数据 |
| | | submit() { |
| | | if (this.validateComponents()) { |
| | | const data = this.getComponentsData(); |
| | | this.$emit('submit', data); |
| | | } |
| | | } |
| | | emitUpdate() { |
| | | this.$emit('update:dataSource', this.components); |
| | | }, |
| | | }, |
| | | }; |
| | | </script> |
| | |
| | | padding: 20px; |
| | | margin-top: 37px; |
| | | } |
| | | .has-title{ |
| | | |
| | | .has-title { |
| | | margin-top: 0px !important; |
| | | } |
| | | |
| | | .add-group { |
| | | display: flex; |
| | | align-items: center; |
| | |
| | | margin: 0 32px 0 8px; |
| | | } |
| | | } |
| | | |
| | | .dynamic-component { |
| | | background: #ffffff; |
| | | padding: 15px 20px; |
| | | display: flex; |
| | | justify-content: space-between; |
| | | margin-bottom: 20px; |
| | | |
| | | .delete-icon { |
| | | width: 16px; |
| | | height: 16px; |
| | | cursor: pointer; |
| | | } |
| | | } |
| | | |
| | | .image-uploader { |
| | | display: flex; |
| | | |
| | | ::v-deep .el-upload-list__item { |
| | | width: 104px !important; |
| | | height: 104px !important; |
| | | } |
| | | } |
| | | |
| | | .image-upload-container { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: flex-start; |
| | | gap: 10px; |
| | | } |
| | | |
| | | ::v-deep .image-uploader { |
| | | .el-upload--picture-card { |
| | | margin: 0; |
| | | } |
| | | |
| | | .el-upload-list--picture-card .el-upload-list__item { |
| | | margin: 0 10px 10px 0; |
| | | } |
| | | } |
| | | |
| | | ::v-deep .el-upload { |
| | | width: 104px; |
| | | height: 104px; |
| | |
| | | align-items: center; |
| | | justify-content: center; |
| | | flex-direction: column; |
| | | |
| | | .upload-text { |
| | | font-weight: 400; |
| | | font-size: 14px; |
| | |
| | | margin-top: 13px; |
| | | } |
| | | } |
| | | |
| | | .uploaf-notice { |
| | | font-weight: 400; |
| | | font-size: 14px; |
| | |
| | | line-height: 22px; |
| | | margin-top: 8px; |
| | | } |
| | | |
| | | .table-actions { |
| | | margin-bottom: 10px; |
| | | display: flex; |
| | | gap: 10px; |
| | | } |
| | | |
| | | .groupTable { |
| | | width: 100%; |
| | | margin-top: 10px; |
| | | |
| | | ::v-deep .el-input__inner { |
| | | width: unset !important; |
| | | } |
| | | } |
| | | |
| | | .file-list-item { |
| | | padding: 8px 0; |
| | | border-bottom: 1px solid #eee; |
| | | } |
| | | |
| | | .image-preview { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 10px; |
| | | } |
| | | |
| | | .preview-image { |
| | | width: 104px; |
| | | height: 104px; |
| | | object-fit: cover; |
| | | border-radius: 8px; |
| | | } |
| | | </style> |