Zimo 1 неделя назад
Родитель
Сommit
f415b6a561
3 измененных файлов с 491 добавлено и 94 удалено
  1. 29 2
      src/api/pms/maotu/index.ts
  2. 89 4
      src/views/maotu/edit.vue
  3. 373 88
      src/views/maotu/index.vue

+ 29 - 2
src/api/pms/maotu/index.ts

@@ -3,9 +3,11 @@ import request from '@/config/axios'
 export type WebtopoProjectVO = {
   id: number
   projectName: string
+  deptId?: number
   thumbnail?: string
+  createTime?: number | string
+  linkedDevices?: number[]
   remark?: string
-  createTime?: string
   updateTime?: string
 }
 
@@ -14,11 +16,24 @@ export type WebtopoProjectDetailVO = WebtopoProjectVO & {
 }
 
 export type WebtopoProjectPageReqVO = {
+  deptId?: number
   pageNo: number
   pageSize: number
   projectName?: string
 }
 
+export type WebtopoProjectCreateReqVO = {
+  projectName: string
+  linkedDevices?: number[]
+  remark?: string
+}
+
+export type WebtopoProjectUpdateReqVO = WebtopoProjectCreateReqVO & {
+  id: number
+  thumbnail?: string
+  dataModel?: string
+}
+
 type ApiResult<T> = {
   code?: number
   data?: T
@@ -46,8 +61,20 @@ export const parseWebtopoDataModel = (dataModel: unknown) => {
 }
 
 export const WebtopoProjectApi = {
+  createWebtopoProject: (data: WebtopoProjectCreateReqVO) => {
+    return request.post<number | ApiResult<number>>({
+      url: '/pms/iot-webtopo-project/create',
+      data
+    })
+  },
+  updateWebtopoProject: (data: WebtopoProjectUpdateReqVO) => {
+    return request.put({
+      url: '/pms/iot-webtopo-project/update',
+      data
+    })
+  },
   getWebtopoProjectPage: (params: WebtopoProjectPageReqVO) => {
-    return request.get<{ list: WebtopoProjectVO[]; total: number }>({
+    return request.get<WebtopoProjectVO[] | { list: WebtopoProjectVO[]; total: number }>({
       url: '/pms/iot-webtopo-project/page',
       params
     })

+ 89 - 4
src/views/maotu/edit.vue

@@ -1,14 +1,22 @@
 <script setup lang="ts">
-import { WebtopoProjectApi } from '@/api/pms/maotu'
+import {
+  WebtopoProjectApi,
+  type WebtopoProjectDetailVO,
+  type WebtopoProjectUpdateReqVO
+} from '@/api/pms/maotu'
 import type { IExportJson } from '@/components/mt-edit/components/types'
 import { useGenThumbnail } from '@/components/mt-edit/composables/thumbnail'
 import { MtEdit } from '@/export'
+import { Canvg } from 'canvg'
+import html2canvas from 'html2canvas'
 import { ElMessage } from 'element-plus'
 import { nextTick, onMounted, ref } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 const route = useRoute()
 const router = useRouter()
 const MtEditRef = ref<InstanceType<typeof MtEdit>>()
+const projectDetail = ref<WebtopoProjectDetailVO>()
+const saveLoading = ref(false)
 
 const getProjectId = () => {
   const id = Number(route.params.id)
@@ -23,6 +31,7 @@ const loadProject = async () => {
 
   try {
     const data = await WebtopoProjectApi.getWebtopoProject(id)
+    projectDetail.value = data
 
     if (data?.dataModel) {
       await nextTick()
@@ -33,6 +42,55 @@ const loadProject = async () => {
   }
 }
 
+const genThumbnailDataUrl = async (canvasId = 'mtCanvasArea') => {
+  const el = document.querySelector<HTMLElement>(`#${canvasId}`)
+  if (!el) {
+    ElMessage.error('没有找到canvas元素,请检查!')
+    return projectDetail.value?.thumbnail
+  }
+
+  const shouldRemoveSvgNodes: HTMLCanvasElement[] = []
+  const svgElements = document.body.querySelectorAll<HTMLElement>(`#${canvasId} .mt-line-render`)
+
+  for (const item of svgElements) {
+    const svg = item.outerHTML.trim()
+    const canvas = document.createElement('canvas')
+    canvas.width = item.getBoundingClientRect().width
+    canvas.height = item.getBoundingClientRect().height
+    const ctx = canvas.getContext('2d')
+    const v = Canvg.fromString(ctx!, svg)
+    await v.render()
+
+    if (item.style.position) {
+      canvas.style.position += item.style.position
+      canvas.style.left += item.style.left
+      canvas.style.top += item.style.top
+    }
+
+    item.parentNode!.appendChild(canvas)
+    shouldRemoveSvgNodes.push(canvas)
+  }
+
+  try {
+    const width = el.offsetWidth
+    const height = el.offsetHeight
+    const canvas = await html2canvas(el, {
+      useCORS: true,
+      scale: 2,
+      width,
+      height,
+      allowTaint: true,
+      windowHeight: height,
+      logging: false,
+      ignoreElements: (element) => element.classList.contains('mt-line-render')
+    })
+
+    return canvas.toDataURL('image/png')
+  } finally {
+    shouldRemoveSvgNodes.forEach((item) => item.remove())
+  }
+}
+
 const onPreviewClick = (exportJson: IExportJson) => {
   sessionStorage.setItem('exportJson', JSON.stringify(exportJson))
   const routeUrl = router.resolve({
@@ -40,8 +98,35 @@ const onPreviewClick = (exportJson: IExportJson) => {
   })
   window.open(routeUrl.href, '_blank')
 }
-const onSaveClick = (e: IExportJson) => {
-  console.log(e, '这是要保存的数据')
+const onSaveClick = async (dataModel: IExportJson) => {
+  const detail = projectDetail.value
+  if (!detail?.id) {
+    ElMessage.error('项目信息不存在,无法保存')
+    return
+  }
+
+  try {
+    saveLoading.value = true
+    const thumbnail = await genThumbnailDataUrl()
+    const data: WebtopoProjectUpdateReqVO = {
+      id: detail.id,
+      projectName: detail.projectName,
+      linkedDevices: detail.linkedDevices || [],
+      remark: detail.remark,
+      thumbnail,
+      dataModel: JSON.stringify(dataModel)
+    }
+
+    await WebtopoProjectApi.updateWebtopoProject(data)
+    projectDetail.value = {
+      ...detail,
+      thumbnail,
+      dataModel: JSON.stringify(dataModel)
+    }
+    ElMessage.success('更新成功')
+  } finally {
+    saveLoading.value = false
+  }
 }
 const onReturnClick = () => {
   router.go(-1)
@@ -56,7 +141,7 @@ onMounted(() => {
 </script>
 
 <template>
-  <div class="w-1/1 h-100vh">
+  <div v-loading="saveLoading" class="w-1/1 h-100vh">
     <mt-edit
       ref="MtEditRef"
       :use-thumbnail="true"

+ 373 - 88
src/views/maotu/index.vue

@@ -1,80 +1,265 @@
 <script lang="ts" setup>
 import {
   WebtopoProjectApi,
+  type WebtopoProjectCreateReqVO,
   type WebtopoProjectPageReqVO,
+  type WebtopoProjectUpdateReqVO,
   type WebtopoProjectVO
 } from '@/api/pms/maotu'
+import { IotDeviceApi } from '@/api/pms/device'
+import { useUserStore } from '@/store/modules/user'
 import { useDebounceFn } from '@vueuse/core'
-import { ElMessage } from 'element-plus'
+import dayjs from 'dayjs'
+import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
 
 defineOptions({ name: 'MaotuList' })
 
 const router = useRouter()
 
+const id = useUserStore().getUser.deptId
+const deptId = id
+
 const loading = ref(false)
-const initialized = ref(false)
 const list = ref<WebtopoProjectVO[]>([])
 const total = ref(0)
-const queryParams = reactive<WebtopoProjectPageReqVO>({
+
+const initQuery: WebtopoProjectPageReqVO = {
   pageNo: 1,
-  pageSize: 10,
+  pageSize: 12,
+  deptId: id,
   projectName: ''
+}
+
+const query = ref<WebtopoProjectPageReqVO>({ ...initQuery })
+
+type FormMode = 'create' | 'config'
+type ProjectForm = Partial<WebtopoProjectUpdateReqVO>
+
+interface DeviceOption {
+  label: string
+  value: number
+}
+
+const createVisible = ref(false)
+const createLoading = ref(false)
+const deviceLoading = ref(false)
+const formRef = ref<FormInstance>()
+const formMode = ref<FormMode>('create')
+const deviceOptions = ref<DeviceOption[]>([])
+const createForm = ref<ProjectForm>({
+  projectName: '',
+  linkedDevices: [],
+  remark: ''
 })
 
+const rules: FormRules = {
+  projectName: [{ required: true, message: '请输入项目名称', trigger: ['blur', 'change'] }]
+}
+
 const loadList = useDebounceFn(async () => {
   loading.value = true
   try {
-    const data = await WebtopoProjectApi.getWebtopoProjectPage(queryParams)
-    list.value = data.list || []
-    total.value = data.total || 0
+    const data = await WebtopoProjectApi.getWebtopoProjectPage(query.value)
+    if (Array.isArray(data)) {
+      list.value = data
+      total.value = data.length
+      return
+    }
+
+    list.value = data?.list || []
+    total.value = data?.total || 0
   } finally {
     loading.value = false
-    initialized.value = true
   }
 }, 200)
 
-const handleQuery = () => {
-  queryParams.pageNo = 1
+function getChineseName(value?: string) {
+  return (value || '').split('~~')[0]
+}
+
+function formatDeviceLabel(item: any) {
+  const code = item.deviceCode || ''
+  const name = getChineseName(item.deviceName || item.label)
+  return [code, name].filter(Boolean).join(' - ')
+}
+
+async function loadDeviceOptions(deptId?: number) {
+  if (!deptId) {
+    deviceOptions.value = []
+    return
+  }
+
+  deviceLoading.value = true
+  try {
+    const data = await IotDeviceApi.simpleDevices({ deptId })
+    deviceOptions.value = (Array.isArray(data) ? data : []).map((item: any) => ({
+      label: formatDeviceLabel(item),
+      value: item.id
+    }))
+  } finally {
+    deviceLoading.value = false
+  }
+}
+
+function handleSizeChange(val: number) {
+  query.value.pageSize = val
+  handleQuery()
+}
+
+function handleCurrentChange(val: number) {
+  query.value.pageNo = val
   loadList()
 }
 
-const resetQuery = () => {
-  queryParams.projectName = ''
+function handleQuery(setPage = true) {
+  if (setPage) {
+    query.value.pageNo = 1
+  }
+  loadList()
+}
+
+function resetQuery() {
+  query.value = { ...initQuery }
   handleQuery()
 }
 
-const handleEdit = (row: WebtopoProjectVO) => {
+async function openCreate() {
+  formMode.value = 'create'
+  createForm.value = {
+    id: undefined,
+    projectName: '',
+    linkedDevices: [],
+    remark: ''
+  }
+  deviceOptions.value = []
+  createVisible.value = true
+  await loadDeviceOptions(deptId)
+  await nextTick()
+  formRef.value?.clearValidate()
+}
+
+async function openConfig(row: WebtopoProjectVO) {
+  formMode.value = 'config'
+  createForm.value = {
+    id: row.id,
+    projectName: row.projectName,
+    linkedDevices: row.linkedDevices || [],
+    remark: row.remark
+  }
+  createVisible.value = true
+  await loadDeviceOptions(deptId)
+  await nextTick()
+  formRef.value?.clearValidate()
+}
+
+function handleCreateClose() {
+  createVisible.value = false
+}
+
+async function submitCreate() {
+  if (!formRef.value) return
+
+  try {
+    createLoading.value = true
+    await formRef.value.validate()
+    const data: WebtopoProjectCreateReqVO = {
+      projectName: createForm.value.projectName!,
+      linkedDevices: createForm.value.linkedDevices || [],
+      remark: createForm.value.remark
+    }
+    const res = await WebtopoProjectApi.createWebtopoProject(data)
+    const projectId = typeof res === 'number' ? res : res?.data
+
+    if (!projectId) {
+      ElMessage.error('新增成功,但未返回项目 ID')
+      return
+    }
+
+    ElMessage.success('新增成功')
+    handleCreateClose()
+    router.push(`/maotu/edit/${projectId}`)
+  } finally {
+    createLoading.value = false
+  }
+}
+
+async function submitConfig() {
+  if (!formRef.value || !createForm.value.id) return
+
+  try {
+    createLoading.value = true
+    await formRef.value.validate()
+    const data = { ...createForm.value } as WebtopoProjectUpdateReqVO
+    await WebtopoProjectApi.updateWebtopoProject(data)
+    ElMessage.success('更新成功')
+    handleCreateClose()
+    loadList()
+  } finally {
+    createLoading.value = false
+  }
+}
+
+function submitForm() {
+  if (formMode.value === 'config') {
+    submitConfig()
+    return
+  }
+
+  submitCreate()
+}
+
+function formatCreateTime(value?: number | string) {
+  if (!value) return '-'
+  const time = typeof value === 'number' && value.toString().length === 10 ? value * 1000 : value
+  return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
+}
+
+function getLinkedDeviceCount(row: WebtopoProjectVO) {
+  return row.linkedDevices?.length || 0
+}
+
+function handleEdit(row: WebtopoProjectVO) {
   router.push(`/maotu/edit/${row.id}`)
 }
 
-const handlePreview = (row: WebtopoProjectVO) => {
+function handlePreview(row: WebtopoProjectVO) {
   router.push(`/maotu/preview/${row.id}`)
 }
 
-const handleConfig = (row: WebtopoProjectVO) => {
-  ElMessage.info(`配置功能待接入:${row.projectName}`)
+function handleConfig(row: WebtopoProjectVO) {
+  openConfig(row)
 }
 
-onMounted(() => {
-  loadList()
-})
+watch(
+  [() => query.value.deptId, () => query.value.projectName],
+  () => {
+    handleQuery()
+  },
+  { immediate: true }
+)
 </script>
 
 <template>
   <div
-    class="grid grid-rows-[48px_1fr] gap-y-3 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]">
+    class="grid grid-cols-[auto_1fr] grid-rows-[62px_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]">
+    <DeptTreeSelect
+      :top-id="156"
+      :deptId="deptId"
+      v-model="query.deptId"
+      :show-title="false"
+      class="row-span-2" />
+
     <el-form
       size="default"
-      :model="queryParams"
-      class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-6 gap-8 flex items-center justify-between">
+      class="filter-form bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-8 gap-8 flex items-center justify-between">
       <div class="flex items-center gap-8">
         <el-form-item label="组态名称">
           <el-input
-            v-model="queryParams.projectName"
+            v-model="query.projectName"
             clearable
-            class="!w-180px"
+            class="!w-240px"
             placeholder="请输入组态名称"
-            @keyup.enter="handleQuery" />
+            @keyup.enter="handleQuery()" />
         </el-form-item>
       </div>
       <el-form-item>
@@ -82,86 +267,186 @@ onMounted(() => {
           <Icon icon="ep:search" class="mr-5px" /> 搜索
         </el-button>
         <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
+        <el-button type="primary" plain @click="openCreate">
+          <Icon icon="ep:plus" class="mr-5px" /> 新建
+        </el-button>
       </el-form-item>
     </el-form>
 
-    <div
-      class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow p-3 overflow-hidden flex flex-col min-h-0">
-      <div
-        v-loading="loading"
-        class="flex-1 min-h-0 border border-gray-100 dark:border-gray-700 rounded-lg overflow-hidden">
-        <div
-          v-if="initialized && !loading && !list.length"
-          class="h-full flex items-center justify-center">
-          <el-empty description="暂无组态项目" :image-size="100" />
-        </div>
-        <div
-          v-else
-          class="h-full overflow-y-auto p-3 grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] auto-rows-max content-start gap-3">
-          <div
-            v-for="item in list"
-            :key="item.id"
-            class="group bg-white dark:bg-[#1d1e1f] rounded-lg shadow-sm hover:shadow-md border border-gray-100 dark:border-gray-700 overflow-hidden transition-all duration-300">
-            <div class="h-150px bg-[var(--el-fill-color-light)]">
-              <el-image
-                v-if="item.thumbnail"
-                :src="item.thumbnail"
-                fit="cover"
-                class="w-full h-full"
-                lazy>
-                <template #error>
-                  <div
-                    class="h-full flex items-center justify-center text-12px text-[var(--el-text-color-secondary)]">
-                    缩略图加载失败
-                  </div>
-                </template>
-              </el-image>
+    <div class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow flex flex-col min-h-0">
+      <div class="flex-1 relative">
+        <el-auto-resizer class="absolute">
+          <template #default="{ height }">
+            <el-scrollbar v-loading="loading" :height="height" view-class="p-4 min-h-full">
               <div
-                v-else
-                class="h-full flex items-center justify-center text-12px text-[var(--el-text-color-secondary)]">
-                暂无缩略图
+                v-if="!loading && !list.length"
+                class="h-full min-h-[360px] flex flex-col items-center justify-center text-gray-400">
+                <div class="i-lucide-inbox text-5xl mb-4 op-50"></div>
+                <p class="text-sm font-medium">暂无组态项目</p>
+                <p class="text-xs mt-1 op-60">尝试调整组织或搜索条件</p>
               </div>
-            </div>
 
-            <div class="p-3">
               <div
-                class="text-15px font-600 text-gray-800 dark:text-gray-100 truncate"
-                :title="item.projectName">
-                {{ item.projectName }}
-              </div>
-              <div
-                class="mt-2 h-36px text-12px leading-18px text-gray-400 dark:text-gray-500 line-clamp-2">
-                {{ item.remark || '暂无备注' }}
-              </div>
-              <div class="mt-3 flex items-center justify-end gap-2">
-                <el-button size="small" type="primary" link @click="handleEdit(item)">
-                  <Icon icon="ep:edit" class="mr-4px" /> 编辑
-                </el-button>
-                <el-button size="small" type="primary" link @click="handlePreview(item)">
-                  <Icon icon="ep:view" class="mr-4px" /> 预览
-                </el-button>
-                <el-button size="small" type="primary" link @click="handleConfig(item)">
-                  <Icon icon="ep:setting" class="mr-4px" /> 配置
-                </el-button>
+                v-else
+                class="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] auto-rows-max content-start gap-4">
+                <div
+                  v-for="item in list"
+                  :key="item.id"
+                  class="group bg-white dark:bg-[#262727] rounded-lg shadow-sm border border-gray-100 dark:border-gray-700 hover:shadow-[0_10px_24px_rgba(15,23,42,0.12)] hover:-translate-y-1 transition-all duration-300 overflow-hidden">
+                  <div class="relative h-170px bg-[var(--el-fill-color-light)] overflow-hidden">
+                    <el-image
+                      v-if="item.thumbnail"
+                      :src="item.thumbnail"
+                      fit="cover"
+                      class="w-full h-full transition-transform duration-500 group-hover:scale-105"
+                      lazy>
+                      <template #error>
+                        <div
+                          class="h-full flex flex-col items-center justify-center text-12px text-[var(--el-text-color-secondary)]">
+                          <Icon icon="ep:picture" class="text-28px mb-2 op-60" />
+                          缩略图加载失败
+                        </div>
+                      </template>
+                    </el-image>
+                    <div
+                      v-else
+                      class="h-full flex flex-col items-center justify-center text-12px text-[var(--el-text-color-secondary)] bg-gradient-to-br from-gray-50 to-gray-100 dark:from-[#252728] dark:to-[#1d1e1f]">
+                      <Icon icon="ep:picture" class="text-32px mb-2 op-60" />
+                      暂无缩略图
+                    </div>
+
+                    <div
+                      class="absolute left-3 top-3 flex items-center gap-1.5 rounded-full bg-black/45 backdrop-blur px-2.5 py-1 text-xs text-white">
+                      <Icon icon="ep:cpu" />
+                      {{ getLinkedDeviceCount(item) }} 台设备
+                    </div>
+                  </div>
+
+                  <div class="p-4">
+                    <div class="flex items-start justify-between gap-3">
+                      <el-tooltip effect="dark" :content="item.projectName" placement="top-start">
+                        <div
+                          class="text-16px font-600 text-gray-800 dark:text-gray-100 truncate"
+                          :title="item.projectName">
+                          {{ item.projectName || '-' }}
+                        </div>
+                      </el-tooltip>
+                      <!-- <el-tag size="small" effect="light" type="info" class="shrink-0">
+                        ID {{ item.id }}
+                      </el-tag> -->
+                    </div>
+
+                    <div class="mt-3 grid gap-2 text-12px text-gray-500 dark:text-gray-400">
+                      <div class="flex items-center justify-between gap-3">
+                        <span class="flex items-center gap-1.5 text-gray-400">
+                          <Icon icon="ep:clock" /> 创建时间
+                        </span>
+                        <span class="font-medium text-gray-600 dark:text-gray-300">
+                          {{ formatCreateTime(item.createTime) }}
+                        </span>
+                      </div>
+                    </div>
+
+                    <div
+                      class="mt-3 text-12px leading-20px text-gray-400 dark:text-gray-500 line-clamp-2"
+                      :title="item.remark">
+                      {{ item.remark || '暂无备注' }}
+                    </div>
+                  </div>
+
+                  <div
+                    class="px-4 py-3 bg-gray-50/80 dark:bg-[#1d1e1f] flex items-center justify-end gap-3 group-hover:bg-blue-50/40 dark:group-hover:bg-blue-900/10 transition-colors">
+                    <el-button size="small" type="primary" link @click="handleEdit(item)">
+                      <Icon icon="ep:edit" class="mr-4px" /> 编辑
+                    </el-button>
+                    <el-button size="small" type="primary" link @click="handlePreview(item)">
+                      <Icon icon="ep:view" class="mr-4px" /> 预览
+                    </el-button>
+                    <el-button size="small" type="primary" link @click="handleConfig(item)">
+                      <Icon icon="ep:setting" class="mr-4px" /> 配置
+                    </el-button>
+                  </div>
+                </div>
               </div>
-            </div>
-          </div>
-        </div>
+            </el-scrollbar>
+          </template>
+        </el-auto-resizer>
       </div>
 
-      <div class="pt-3 flex justify-end shrink-0">
-        <Pagination
-          v-model:limit="queryParams.pageSize"
-          v-model:page="queryParams.pageNo"
+      <div class="h-10 mt-4 mb-4 px-4 flex items-center justify-end shrink-0">
+        <el-pagination
+          size="default"
+          v-show="total > 0"
+          v-model:current-page="query.pageNo"
+          v-model:page-size="query.pageSize"
+          :background="true"
+          :page-sizes="[12, 20, 30, 50, 100]"
           :total="total"
-          @pagination="loadList" />
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange" />
       </div>
     </div>
   </div>
+
+  <el-dialog
+    v-model="createVisible"
+    :title="formMode === 'config' ? '配置关联设备' : '新建组态项目'"
+    width="560px"
+    :close-on-click-modal="false"
+    destroy-on-close>
+    <el-form
+      ref="formRef"
+      :model="createForm"
+      :rules="rules"
+      label-position="top"
+      require-asterisk-position="right"
+      size="default">
+      <el-form-item label="项目名称" prop="projectName">
+        <el-input v-model="createForm.projectName" clearable placeholder="请输入项目名称" />
+      </el-form-item>
+
+      <el-form-item label="绑定设备" prop="linkedDevices">
+        <el-select
+          v-model="createForm.linkedDevices"
+          multiple
+          filterable
+          clearable
+          collapse-tags
+          collapse-tags-tooltip
+          :max-collapse-tags="3"
+          :loading="deviceLoading"
+          class="w-full"
+          placeholder="请选择绑定设备">
+          <el-option
+            v-for="item in deviceOptions"
+            :key="item.value"
+            :label="item.label"
+            :value="item.value" />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item label="备注" prop="remark">
+        <el-input
+          v-model="createForm.remark"
+          type="textarea"
+          :autosize="{ minRows: 3, maxRows: 6 }"
+          show-word-limit
+          :maxlength="500"
+          resize="none"
+          placeholder="请输入备注" />
+      </el-form-item>
+    </el-form>
+
+    <template #footer>
+      <el-button @click="handleCreateClose">取消</el-button>
+      <el-button type="primary" :loading="createLoading" @click="submitForm">保存</el-button>
+    </template>
+  </el-dialog>
 </template>
 
 <style scoped>
-:deep(.el-form-item) {
+.filter-form :deep(.el-form-item) {
   margin-bottom: 0;
 }