Browse Source

继续完善报告撰写界面

Kevin Jiang 3 years ago
parent
commit
78230233dc

+ 73 - 1
package-lock.json

@@ -9,12 +9,16 @@
       "version": "0.0.0",
       "dependencies": {
         "@antv/g6": "^4.7.16",
+        "@tinymce/tinymce-vue": "^5.0.0",
         "@types/lodash": "^4.14.188",
         "ant-design-vue": "^3.2.13",
         "axios": "^1.1.3",
+        "nzh": "^1.0.8",
         "pinia": "^2.0.21",
+        "tinymce": "^6.3.1",
         "vue": "^3.2.38",
-        "vue-router": "^4.1.5"
+        "vue-router": "^4.1.5",
+        "vuedraggable": "^4.1.0"
       },
       "devDependencies": {
         "@rushstack/eslint-patch": "^1.1.4",
@@ -1186,6 +1190,17 @@
         "nanopop": "^2.1.0"
       }
     },
+    "node_modules/@tinymce/tinymce-vue": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/@tinymce/tinymce-vue/-/tinymce-vue-5.0.0.tgz",
+      "integrity": "sha512-1HRCNa2eGdztOKShYAiHIry50LTU6YJG//qSP9AJElrXhb3BIBN0Bef6E56nZObVgwCgSmI4cnX35VU9D49aow==",
+      "dependencies": {
+        "tinymce": "^6.0.0 || ^5.5.1"
+      },
+      "peerDependencies": {
+        "vue": "^3.0.0"
+      }
+    },
     "node_modules/@tootallnate/once": {
       "version": "2.0.0",
       "resolved": "https://registry.npmmirror.com/@tootallnate/once/-/once-2.0.0.tgz",
@@ -6350,6 +6365,11 @@
       "integrity": "sha512-90yv+6538zuvUMnN+zCr8LuV6bPFdq50304114vJYJ8RDyK8D5O9Phpbd6SZWgI7PwzmmfN1upeOJlvybDSgCw==",
       "dev": true
     },
+    "node_modules/nzh": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmmirror.com/nzh/-/nzh-1.0.8.tgz",
+      "integrity": "sha512-fm9qlo0aBJxbi3dt4ix+iqRa95tAWAptwmSNTfUEleCitbTz8D3i7KPu0O2mecnvVn+b/JMb4uJqHOj2yJf3hQ=="
+    },
     "node_modules/object-inspect": {
       "version": "1.12.2",
       "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.12.2.tgz",
@@ -7314,6 +7334,11 @@
       "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
       "dev": true
     },
+    "node_modules/sortablejs": {
+      "version": "1.14.0",
+      "resolved": "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.14.0.tgz",
+      "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w=="
+    },
     "node_modules/source-map": {
       "version": "0.6.1",
       "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz",
@@ -7721,6 +7746,11 @@
         "node": "*"
       }
     },
+    "node_modules/tinymce": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmmirror.com/tinymce/-/tinymce-6.3.1.tgz",
+      "integrity": "sha512-+oCwXuTxAdJXVJ0130OxQz0JDNsqg3deuzgeUo8X5Vb27EzCJgXwO5eWvCxvkxpQo4oiHMVlM4tUIpTUHufHGQ=="
+    },
     "node_modules/tinypool": {
       "version": "0.3.0",
       "resolved": "https://registry.npmmirror.com/tinypool/-/tinypool-0.3.0.tgz",
@@ -8206,6 +8236,17 @@
         "vue": "^3.0.0"
       }
     },
+    "node_modules/vuedraggable": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/vuedraggable/-/vuedraggable-4.1.0.tgz",
+      "integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
+      "dependencies": {
+        "sortablejs": "1.14.0"
+      },
+      "peerDependencies": {
+        "vue": "^3.0.1"
+      }
+    },
     "node_modules/w3c-xmlserializer": {
       "version": "3.0.0",
       "resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-3.0.0.tgz",
@@ -9491,6 +9532,14 @@
         "nanopop": "^2.1.0"
       }
     },
+    "@tinymce/tinymce-vue": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/@tinymce/tinymce-vue/-/tinymce-vue-5.0.0.tgz",
+      "integrity": "sha512-1HRCNa2eGdztOKShYAiHIry50LTU6YJG//qSP9AJElrXhb3BIBN0Bef6E56nZObVgwCgSmI4cnX35VU9D49aow==",
+      "requires": {
+        "tinymce": "^6.0.0 || ^5.5.1"
+      }
+    },
     "@tootallnate/once": {
       "version": "2.0.0",
       "resolved": "https://registry.npmmirror.com/@tootallnate/once/-/once-2.0.0.tgz",
@@ -13531,6 +13580,11 @@
       "integrity": "sha512-90yv+6538zuvUMnN+zCr8LuV6bPFdq50304114vJYJ8RDyK8D5O9Phpbd6SZWgI7PwzmmfN1upeOJlvybDSgCw==",
       "dev": true
     },
+    "nzh": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmmirror.com/nzh/-/nzh-1.0.8.tgz",
+      "integrity": "sha512-fm9qlo0aBJxbi3dt4ix+iqRa95tAWAptwmSNTfUEleCitbTz8D3i7KPu0O2mecnvVn+b/JMb4uJqHOj2yJf3hQ=="
+    },
     "object-inspect": {
       "version": "1.12.2",
       "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.12.2.tgz",
@@ -14295,6 +14349,11 @@
         }
       }
     },
+    "sortablejs": {
+      "version": "1.14.0",
+      "resolved": "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.14.0.tgz",
+      "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w=="
+    },
     "source-map": {
       "version": "0.6.1",
       "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz",
@@ -14628,6 +14687,11 @@
       "resolved": "https://registry.npmmirror.com/tinycolor2/-/tinycolor2-1.4.2.tgz",
       "integrity": "sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA=="
     },
+    "tinymce": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmmirror.com/tinymce/-/tinymce-6.3.1.tgz",
+      "integrity": "sha512-+oCwXuTxAdJXVJ0130OxQz0JDNsqg3deuzgeUo8X5Vb27EzCJgXwO5eWvCxvkxpQo4oiHMVlM4tUIpTUHufHGQ=="
+    },
     "tinypool": {
       "version": "0.3.0",
       "resolved": "https://registry.npmmirror.com/tinypool/-/tinypool-0.3.0.tgz",
@@ -14958,6 +15022,14 @@
         "is-plain-object": "3.0.1"
       }
     },
+    "vuedraggable": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/vuedraggable/-/vuedraggable-4.1.0.tgz",
+      "integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
+      "requires": {
+        "sortablejs": "1.14.0"
+      }
+    },
     "w3c-xmlserializer": {
       "version": "3.0.0",
       "resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-3.0.0.tgz",

+ 5 - 1
package.json

@@ -14,12 +14,16 @@
   },
   "dependencies": {
     "@antv/g6": "^4.7.16",
+    "@tinymce/tinymce-vue": "^5.0.0",
     "@types/lodash": "^4.14.188",
     "ant-design-vue": "^3.2.13",
     "axios": "^1.1.3",
+    "nzh": "^1.0.8",
     "pinia": "^2.0.21",
+    "tinymce": "^6.3.1",
     "vue": "^3.2.38",
-    "vue-router": "^4.1.5"
+    "vue-router": "^4.1.5",
+    "vuedraggable": "^4.1.0"
   },
   "devDependencies": {
     "@rushstack/eslint-patch": "^1.1.4",

+ 2 - 0
src/assets/base.scss

@@ -76,6 +76,8 @@ body {
 $border-color-primary: #f0f0f0;
 $border-color-secondary: #e0e0e0;
 
+$desc-color: #4f4f4f;
+
 h1.title {
   font-size: 1.75em;
 }

+ 40 - 0
src/models/report.model.ts

@@ -0,0 +1,40 @@
+import { ChapterTypeLabels, type ChapterType } from "@/types/report.types";
+import Nzh from "nzh";
+
+export interface Chapter {
+  title: string;
+  fullTitle?: string;
+  content: string;
+}
+
+/**
+ * 章节管理器
+ */
+export class ChapterManager {
+  private chapters: Chapter[];
+
+  constructor() {
+    this.chapters = [];
+  }
+
+  public addChapter(chapterType: ChapterType) {
+    const item = ChapterTypeLabels.find((item) => item.key == chapterType);
+    if (item) {
+      this.chapters.push({
+        title: item.label,
+        content: '',
+      });
+    }
+  }
+
+  public getChapter(index: number) {
+    return this.chapters[index];
+  }
+
+  public getChapters(): Chapter[] {
+    return this.chapters.map((chapter, index) => {
+      const fullTitle = Nzh.cn.encodeS(index+1) + '、' + chapter.title;
+      return { ...chapter, fullTitle };
+    });
+  }
+}

+ 39 - 0
src/types/report.types.ts

@@ -0,0 +1,39 @@
+/**
+ * 章节类型
+ */
+export enum ChapterType {
+  BACKGROUND = 'BACKGROUND',
+  RESEARCH_STATUS = 'RESEARCH_STATUS',
+  RESEARCH = 'RESEARCH',
+  INNOVATION = 'INNOVATION',
+  SOLUTION = 'SOLUTION',
+  USER_DEFINED = 'USER_DEFINED',
+  UNKNOWN = 'UNKNOWN',
+}
+
+export const ChapterTypeLabels = [
+  {
+    key: ChapterType.BACKGROUND,
+    label: '立项背景与意义'
+  },
+  {
+    key: ChapterType.RESEARCH_STATUS,
+    label: '国内外研究现状与发展趋势'
+  },
+  {
+    key: ChapterType.RESEARCH,
+    label: '项目主要研究内容/项目实施主要内容'
+  },
+  {
+    key: ChapterType.INNOVATION,
+    label: '关键技术与创新点/拟解决的关键问题'
+  },
+  {
+    key: ChapterType.SOLUTION,
+    label: '项目实施方案'
+  },
+  {
+    key: ChapterType.USER_DEFINED,
+    label: '自定义章节'
+  },
+]

+ 42 - 8
src/views/report/ReportCreateView.vue

@@ -6,17 +6,25 @@ import RelationCompanyInput from './components/report-create/RelationCompanyInpu
 import ReportCategory from './components/report-create/ReportCategory.vue';
 import DynamicMeta from './components/dynamic-meta/DynamicMeta.vue';
 import ChapterTypeSelect from "./components/ChapterTypeSelect.vue";
+import LogoComponent from "./components/LogoComponent.vue";
+import type { ChapterType } from "@/types/report.types";
+import { ChapterManager } from "@/models/report.model";
+import ContentEditor from "./components/ContentEditor.vue";
 
 const metadata: Ref<any[]> = ref([]);
 
 function onMetaChange(data: any[]) {
   metadata.value = data;
+  console.log('metadata', metadata.value);
 }
 
 const chapterTypeSelectVisible = ref(false);
 
-function onChapterTypeSelected(type: string) {
-  console.log('selected chapter type is', type);
+const chapterManager = ref(new ChapterManager());
+
+function onChapterTypeSelected(type: ChapterType) {
+  chapterManager.value.addChapter(type);
+  console.log('chapterManager', chapterManager);
 }
 </script>
 
@@ -31,14 +39,32 @@ function onChapterTypeSelected(type: string) {
       </div>
     </a-col>
     <a-col :span="12">
-      Logo
+      <LogoComponent />
     </a-col>
   </a-row>
-  <a-divider></a-divider>
-  <a-button @click="() => chapterTypeSelectVisible = true">
-    <plus-outlined />
-    添加章节
-  </a-button>
+  <a-divider />
+  <div class="chapter-wrap">
+    <div v-for="(item, index) in chapterManager.getChapters()" :key="index">
+      <h1>{{ item.fullTitle }}</h1>
+      <p>
+        <ContentEditor />
+      </p>
+    </div>
+  </div>
+  <div class="add-chapter-wrap">
+    <a-button @click="() => chapterTypeSelectVisible = true">
+      <plus-outlined />
+      添加章节
+    </a-button>
+  </div>
+  <div class="bottom-button-group">
+    <a-space>
+      <a-button type="primary">保存</a-button>
+      <a-button>预览</a-button>
+      <a-button>导出</a-button>
+      <a-button>保存模板</a-button>
+    </a-space>
+  </div>
   <ChapterTypeSelect @ok="onChapterTypeSelected" v-model:visible="chapterTypeSelectVisible" />
 </template>
 
@@ -48,4 +74,12 @@ function onChapterTypeSelected(type: string) {
     margin-top: 0.5em;
   }
 }
+
+.add-chapter-wrap {
+  margin-top: 3em;
+}
+
+.bottom-button-group {
+  margin-top: 5em;
+}
 </style>

+ 14 - 18
src/views/report/components/ChapterTypeSelect.vue

@@ -1,5 +1,6 @@
 <script setup lang="ts">
-import { ref } from "vue";
+import { ChapterType, ChapterTypeLabels } from "@/types/report.types";
+import { ref, type Ref } from "vue";
 
 defineProps({
   visible: {
@@ -11,19 +12,10 @@ defineProps({
 
 const emits = defineEmits<{
   (e: "update:visible", visible: boolean): void;
-  (e: "ok", vaule: string): void;
+  (e: "ok", vaule: ChapterType): void;
 }>();
 
-const categories = ref([
-  '立项背景与意义',
-  '国内外研究现状与发展趋势',
-  '项目主要研究内容/项目实施主要内容',
-  '关键技术与创新点/拟解决的关键问题',
-  '项目实施方案',
-  '自定义章节',
-]);
-
-const category = ref('');
+const category: Ref<ChapterType> = ref(ChapterType.UNKNOWN);
 
 function onOk() {
   emits("ok", category.value);
@@ -37,14 +29,18 @@ function onCancel() {
 </script>
 
 <template>
-  <a-modal :visible="visible" title="选择章节类型" @ok="onOk" @cancel="onCancel">
+  <a-modal
+    :visible="visible"
+    title="选择章节类型"
+    :ok-button-props="{ disabled: category == ChapterType.UNKNOWN }"
+    @ok="onOk"
+    @cancel="onCancel"
+  >
     <a-radio-group v-model:value="category">
-      <a-radio class="radio" :value="item" v-for="(item, index) in categories" :key="index">{{ item }}</a-radio>
+      <a-radio class="radio" :value="item.key" v-for="(item, index) in ChapterTypeLabels" :key="index">
+        {{ item.label }}
+      </a-radio>
     </a-radio-group>
-    <a-space>
-      <div v-for="(item, index) in categories" :key="index">
-      </div>
-    </a-space>
   </a-modal>
 </template>
 

+ 51 - 0
src/views/report/components/ContentEditor.vue

@@ -0,0 +1,51 @@
+<script setup lang="ts">
+import { ref } from "vue";
+import Editor from '@tinymce/tinymce-vue'
+
+const toolbar = ref([
+  'undo redo',
+  // 'blocks',
+  'bold italic',
+  'alignleft aligncenter alignright alignjustify',
+  'indent outdent',
+  'table',
+  'image',
+  'fullscreen'
+]);
+
+function filePickerCallback(callback: any, value: any, meta: any) {
+  const input = document.createElement('input');
+  input.setAttribute('type', 'file');
+  input.setAttribute('accept', 'image/*');
+
+  input.addEventListener('change', (e: any) => {
+    const file = e.target.files[0];
+
+    const reader = new FileReader();
+    reader.addEventListener('load', () => {
+      callback(reader.result, { title: file.name });
+    });
+    reader.readAsDataURL(file);
+  });
+
+  input.click();
+}
+</script>
+
+<template>
+  <Editor
+    api-key="ybn5lke39bgjhdehsamztepaxlch6nja25ekokjjav5nqdlq"
+    :init="{
+      language: 'zh_CN',
+      skin: 'small',
+      icons: 'small',
+      plugins: 'lists link image table code help wordcount fullscreen',
+      toolbar: toolbar.join(' | '),
+      menubar: false,
+      branding: false,
+      resize: false,
+      file_picker_types: 'image',
+      file_picker_callback: filePickerCallback,
+    }"
+  />
+</template>

+ 87 - 0
src/views/report/components/LogoComponent.vue

@@ -0,0 +1,87 @@
+<script setup lang="ts">
+import { PlusOutlined, LoadingOutlined } from '@ant-design/icons-vue';
+import { message } from 'ant-design-vue';
+import { ref } from 'vue';
+import type { UploadChangeParam } from 'ant-design-vue';
+
+function getBase64(img: Blob, callback: (base64Url: string) => void) {
+  const reader = new FileReader();
+  reader.addEventListener('load', () => callback(reader.result as string));
+  reader.readAsDataURL(img);
+}
+
+const fileList = ref([]);
+const loading = ref<boolean>(false);
+const imageUrl = ref<string>('');
+
+const handleChange = (info: UploadChangeParam) => {
+  if (info.file.status === 'uploading') {
+    loading.value = true;
+    return;
+  }
+  if (info.file.status === 'done') {
+    // Get this url from response in real world.
+    getBase64(info.file.originFileObj as Blob, (base64Url: string) => {
+      imageUrl.value = base64Url;
+      loading.value = false;
+    });
+  }
+  if (info.file.status === 'error') {
+    loading.value = false;
+    message.error('upload error');
+  }
+};
+
+const beforeUpload = (file: File) => {
+  console.log('file', file);
+  const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
+  if (!isJpgOrPng) {
+    message.error('You can only upload JPG file!');
+  }
+  const isLt2M = file.size / 1024 / 1024 < 2;
+  if (!isLt2M) {
+    message.error('Image must smaller than 2MB!');
+  }
+  return isJpgOrPng && isLt2M;
+};
+</script>
+
+<template>
+  <a-row>
+    <a-col>
+      <a-upload
+        v-model:file-list="fileList"
+        name="avatar"
+        list-type="picture-card"
+        class="logo-uploader"
+        :show-upload-list="false"
+        action="https://www.mocky.io/v2/5cc8019d300000980a055e76"
+        :before-upload="beforeUpload"
+        @change="handleChange"
+      >
+        <img width="104" v-if="imageUrl" :src="imageUrl" alt="企业Logo" />
+        <div v-else>
+          <loading-outlined v-if="loading"></loading-outlined>
+          <plus-outlined v-else></plus-outlined>
+          <div class="ant-upload-text">上传</div>
+        </div>
+      </a-upload>
+    </a-col>
+    <a-col class="description">
+      请按需上传企业logo<br />仅支持 jpg.  png.  格式图片
+    </a-col>
+  </a-row>
+</template>
+
+<style scoped lang="scss">
+@import "@/assets/base.scss";
+
+.logo-uploader {
+  margin-left: 20px;
+}
+
+.description {
+  font-size: 0.85em;
+  color: $desc-color;
+}
+</style>

+ 75 - 41
src/views/report/components/dynamic-meta/DynamicMeta.vue

@@ -1,76 +1,110 @@
 <!-- 报告元数据,封面上的动态字段 -->
 <script setup lang="ts">
 import { defineAsyncComponent, ref, markRaw, type Ref } from "vue";
-import { PlusOutlined } from '@ant-design/icons-vue';
+import { PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue';
+import draggable from 'vuedraggable'
+
 import type ReportMetaTextInput from './ReportMetaTextInput.vue';
 
 type ComponentType = typeof ReportMetaTextInput;
 
-const components: Ref<ComponentType[]> = ref([]);
-const props: Ref<any[]> = ref([]);
+interface DynamicComponentProp {
+  name: string;
+  value: string;
+}
+
+interface DynamicComponent {
+  id: number;
+  component: ComponentType;
+  prop: DynamicComponentProp;
+}
+
+const dynamicComponents: Ref<DynamicComponent[]> = ref([]);
 
 const emits = defineEmits<{
   (e: "change", data: any[]): void;
 }>();
 
-function addTextComponent() {
-  const comp = markRaw(defineAsyncComponent(() => import('./ReportMetaTextInput.vue') ));
-  components.value.push(comp);
-  props.value.push({
-    name: '',
-    value: ''
-  });
-}
-
-function addDateComponent() {
-  const comp = markRaw(defineAsyncComponent(() => import('./ReportMetaDateInput.vue') ));
-  components.value.push(comp);
-  props.value.push({
-    name: '',
-    value: ''
+function addComponent(comp: ComponentType, index: number) {
+  dynamicComponents.value.push({
+    id: index,
+    component: comp,
+    prop: {
+      name: '',
+      value: ''
+    }
   });
 }
 
 function handleMenuClick(e: {key: string}) {
+  let comp: ComponentType | null = null;
   if (e.key == 'text') {
-    addTextComponent();
+    comp = markRaw(defineAsyncComponent(() => import('./ReportMetaTextInput.vue') ));
   } else if (e.key === 'date') {
-    addDateComponent();
+    comp = markRaw(defineAsyncComponent(() => import('./ReportMetaDateInput.vue') ));
   }
+  if (comp) {
+    addComponent(comp, dynamicComponents.value.length+1);
+  }
+}
+
+function deleteComponent(index: number) {
+  dynamicComponents.value.splice(index, 1);
 }
 
 function onMetaCompChange() {
-  emits("change", props.value);
+  const props = dynamicComponents.value.map((item) => item.prop);
+  emits("change", props);
+}
+
+function onDragEnd() {
+  const props = dynamicComponents.value.map((item) => item.prop);
+  emits("change", props);
 }
 </script>
 
 <template>
   <div class="dynamic-meta-wrap">
-    <component
-      :is="comp"
-      v-for="(comp, index) in components"
-      :key="index"
-      v-model:data="props[index]"
-      @update:data="onMetaCompChange"
-    />
-    <a-dropdown>
-    <template #overlay>
-      <a-menu @click="handleMenuClick">
-        <a-menu-item key="text">文本</a-menu-item>
-        <a-menu-item key="date">日期</a-menu-item>
-      </a-menu>
-    </template>
-    <a-button>
-      添加新项
-      <plus-outlined />
-    </a-button>
-  </a-dropdown>
+    <draggable v-model="dynamicComponents" item-key="id" group="components" @end="onDragEnd">
+      <template #item="{element, index}">
+        <a-row type="flex" class="dynamic-meta-item-row">
+          <a-col flex="1">
+            <component
+              :is="element.component"
+              v-model:data="element.prop"
+              @update:data="onMetaCompChange"
+            />
+          </a-col>
+          <a-col>
+            <a-button type="text" @click="deleteComponent(index)">
+              <template #icon><delete-outlined /></template>
+            </a-button>
+          </a-col>
+        </a-row>
+      </template>
+    </draggable>
+    <a-dropdown class="add-item-dropdown">
+      <template #overlay>
+        <a-menu @click="handleMenuClick">
+          <a-menu-item key="text">文本项</a-menu-item>
+          <a-menu-item key="date">日期项</a-menu-item>
+        </a-menu>
+      </template>
+      <a-button>
+        <plus-outlined />
+        添加新项
+      </a-button>
+    </a-dropdown>
   </div>
 </template>
 
 <style scoped lang="scss">
 .dynamic-meta-wrap {
-  > * {
+  .dynamic-meta-item-row {
+    margin-top: 0.5em;
+  }
+
+  .add-item-dropdown {
     margin-top: 0.5em;
   }
 }