Vue业务组件封装(二)Form表单

news2025/1/30 15:57:57

前言

这个系列主要是分享自己在工作中常用到的业务组件,以及如何对这些组件进行有效的封装和封装的思路。注:都是基于element ui进行二次封装。

封装组件的基本方法就是通过props和emit进行父子组件的传值和通信。利用插槽、组件等增加组件的可扩展性和复用性。

Form组件介绍

Form表单包含 输入框, 单选框, 下拉选择, 多选框 等用户输入的组件。使用表单,可以收集、验证和提交数据。

表单常用的地方是在搜索、信息提交、内容编辑以及新增。

搜索表单

搜索表单

编辑表单

编辑表单

Form组件封装思路

了解element Form组件代码

这里以最基本的Form代码为例进行分析:

<template>
  <el-form label-width="120px" ref="ruleFormRef" :model="ruleForm" :rules="rules">
    <el-form-item label="Activity name">
      <el-input v-model="form.name" />
    </el-form-item>
    <el-form-item label="Activity zone">
      <el-select v-model="form.region" placeholder="please select your zone">
        <el-option label="Zone one" value="shanghai" />
        <el-option label="Zone two" value="beijing" />
      </el-select>
    </el-form-item>
    <el-form-item>
      <el-button type="primary" @click="onSubmit">Create</el-button>
      <el-button>Cancel</el-button>
    </el-form-item>
  </el-form>
</template>
const form = reactive({
  name: '',
  region: ''
}) 

基本表单

基本表单

根据基本的Form代码,我们可以知道:

  1. 整个Form表单数据绑定在el-form上::model=“form”,form就是表单的数据对象。
  2. 表单里面的每一项是放在el-form-item标签里面,放入我们想渲染出来的组件,如输入框,单选等。
  3. 每个el-form-item中可以绑定了prop、label、rules等属性,我们可以在配置文件中配置对应属性的值进行绑定。

Form组件如何去封装

通过分析Form代码我们可以通过一个配置文件去遍历得到el-form-item,然后在el-form-item上面绑定我们需要的属性就可以得到我们想要的表单。

代码实现

配置文件

我们可以在页面文件夹下面新建一个文件夹config用于存放页面需要的各种配置文件,在里面新建我们表单的配置文件formConfig.ts:

import { IForm } from '@/components/Form/types'
import { rules } from '@/utils/validator'
export const modalConfig: IForm = {
  formItems: [
    {
      field: 'name',
      label: '用户名',
      placeholder: '请输入用户名',
      type: 'input',
      rule: [{ required: true, message: 'Please input name', trigger: 'blur' }]
    },
    {
      field: 'realname',
      type: 'input',
      label: '真实姓名',
      placeholder: '请输入真实姓名',
      rule: [
        { required: true, message: 'Please input realname', trigger: 'blur' }
      ]
    },
    {
      field: 'password',
      type: 'password',
      label: '用户密码',
      placeholder: '请输入密码',
      isHidden: false,
      rule: [
        { required: true, message: 'Please input password', trigger: 'blur' }
      ]
    },
    {
      field: 'cellphone',
      type: 'input',
      label: '电话号码',
      placeholder: '请输入电话号码',
      rule: [
        {
          required: true,
          message: '请输入正确手机号码',
          validator: (rule: any, value: any) => /^1\d{10}$/.test(value)
        }
      ]
    },
    {
      field: 'departmentId',
      type: 'select',
      label: '部门',
      placeholder: '请选择部门',
      options: [],
      rule: [
        {
          required: true,
          message: 'Please input departmentId',
          trigger: 'change'
        }
      ]
    },
    {
      field: 'roleId',
      type: 'select',
      label: '角色',
      placeholder: '请选择角色',
      options: [],
      rule: [
        { required: true, message: 'Please input roleId', trigger: 'change' }
      ]
    }
  ],
  labelWidth: '80px',
  colLayout: {
    xl: 5,
    lg: 8,
    md: 12,
    sm: 24,
    xs: 24
  }
} 

formItems里面每一项就对应表单里的每一个el-form-item,里面的属性绑定到el-form-item上。

  • field:必填,表示的是我们提交时的key,要与接口提供的字段名一致。
  • type:必填,表示我们显示表单的种类。
  • label:表单的标签文本。
  • placeholder:输入框显示提示文案。
  • options:选项,如select里面的options。
  • rule:表单的校验规则,如果不是必填就不用写。
  • labelWidth:表单label的宽度。
  • colLayout:表单的布局,这里做了一个响应式设置。

还可以设置一些其他属性,具体根据实际业务需求。

新建LForm组件

我们在components文件夹下新建一个LForm表示我们封装的Form组件。基于El-Form组件的基本代码,我们写下LTable下代码内容:

<template>
<div class="form-container">
  <el-form
    :label-width="labelWidth"
    class="form-content"
    :size="size"
    ref="ruleFormRef"
    :model="modelValue"
  >
    <el-row>
      <template v-for="item in formItems" :key="item.label">
        <el-col v-bind="colLayout">
          <el-form-item
            v-bind='item'
          >
            <!-- 输入框 -->
            <template v-if="item.type === 'input'">
              <el-input
                :placeholder="item.placeholder"
                clearable
                :model-value="modelValue[`${item.field}`]"
                @update:modelValue="handleValueChange($event, item.field)"
              />
            </template>
            <!-- 密码输入框 -->
            <template v-if="item.type === 'password'">
              <el-input
                type="password"
                show-password
                :placeholder="item.placeholder"
                :model-value="modelValue[`${item.field}`]"
                @update:modelValue="handleValueChange($event, item.field)"
              />
            </template>
            <!-- 日期范围 -->
            <template v-if="item.type === 'dateRange'">
              <el-date-picker
                range-separator="To"
                v-bind="item.otherOptions"
                :model-value="modelValue[`${item.field}`]"
                @update:modelValue="handleValueChange($event, item.field)"
              />
            </template>
            <!-- 日期时间
            <template v-if="item.type === 'date'">
              <el-date-picker
                v-bind="item.otherOptions"
                :model-value="modelValue[`${item.field}`]"
                @update:modelValue="handleValueChange($event, item.field)"
              />
            </template> -->
            <!-- 下拉框 -->
            <template v-if="item.type === 'select'">
              <el-select
                clearable
                :placeholder="item.placeholder"
                :model-value="modelValue[`${item.field}`]"
                @update:modelValue="handleValueChange($event, item.field)"
                v-bind="item.otherOptions"
              >
                <el-option
                  v-for="optionItem in item.options"
                  :label="optionItem.label"
                  :value="optionItem.value"
                  :key="optionItem.label"
                />
              </el-select>
            </template>
            <!-- 切换 -->
            <template v-if="item.type === 'switch'">
              <el-switch
                :model-value="modelValue[`${item.field}`]"
                @update:modelValue="handleValueChange($event, item.field)"
              />
            </template>
            <!-- 多选 -->
            <template v-if="item.type === 'checkbox'">
              <el-checkbox
                v-if="item.otherOptions && item.otherOptions.showAll"
                v-model="checkAll"
                :indeterminate="isIndeterminate"
                @change="
                  (val) => handleCheckAllChange(val, item.field, item.options)
                "
                >Check all</el-checkbox
              >
              <el-checkbox-group
                :model-value="modelValue[`${item.field}`]"
                @update:modelValue="handleValueChange($event, item.field)"
                @change="(val) => handleCheckedChange(val, item.options)"
              >
                <el-checkbox
                  v-for="optionItem in item.options"
                  :key="optionItem.label"
                  :label="optionItem.value"
                  name="type"
                  >{{ optionItem.label }}
                </el-checkbox>
              </el-checkbox-group>
            </template>
            <!-- 自定义多选 -->
            <template v-if="item.type === 'customCheckBox'">
              <div class="customCheckBox">
                <div
                  class="customCheckBox-group"
                  v-for="_item in item.options"
                  :key="_item.label"
                >
                  <div style="text-align: left">{{ _item.label }}</div>
                  <el-checkbox-group
                    :model-value="modelValue[`${item.field}`]"
                    @update:modelValue="handleValueChange($event, item.field)"
                  >
                    <el-checkbox
                      :label="optionItem.value"
                      name="type"
                      v-for="optionItem in _item.itemOptions"
                      :key="optionItem.label"
                      >{{ optionItem.label }}
                    </el-checkbox>
                  </el-checkbox-group>
                </div>
              </div>
            </template>
            <!-- 单选 -->
            <template v-if="item.type === 'radio'">
              <el-radio-group
                :model-value="modelValue[`${item.field}`]"
                @update:modelValue="handleValueChange($event, item.field)"
              >
                <el-radio
                  :label="optionItem.value"
                  v-for="optionItem in item.options"
                  :key="optionItem.label"
                >
                  {{ optionItem.label }}
                </el-radio>
              </el-radio-group>
            </template>
            <!-- 文本框 -->
            <template v-if="item.type === 'textarea'">
              <el-input
                type="textarea"
                :model-value="modelValue[`${item.field}`]"
                @update:modelValue="handleValueChange($event, item.field)"
              />
            </template>
            <!-- 图片上传 -->
            <template v-if="item.type === 'uploadImg'">
              <el-upload
                class="avatar-uploader"
                action="https://jsonplaceholder.typicode.com/posts/"
                :show-file-list="false"
                :on-success="handleAvatarSuccess"
                :before-upload="
                  (rawFile) => beforeAvatarUpload(rawFile, item.otherOptions)
                "
              >
                <template #tip>
                  <div class="el-upload__tip" v-if="item.otherOptions.tip">
                    {{ item.otherOptions.tip }}
                  </div>
                </template>
                <img v-if="imageUrl" :src="imageUrl" class="avatar" />
                <el-icon v-else class="avatar-uploader-icon"
                  ><Plus
                /></el-icon>
              </el-upload>
            </template>
            <!-- 文件上传 -->
            <template v-if="item.type === 'uploadFile'">
              <el-upload
                ref="uploadRef"
                class="file-uploader"
                action="https://jsonplaceholder.typicode.com/posts/"
                :limit="1"
                :on-remove="handleRemove"
                :on-success="handleFileSuccess"
                :on-exceed="handleExceed"
                :before-upload="beforeFileUpload"
              >
                <el-button>选择上传文件</el-button>
                <template #tip>
                  <div class="el-upload__tip" v-if="item.otherOptions.tip">
                    {{ item.otherOptions.tip }}
                  </div>
                </template>
              </el-upload>
            </template>
          </el-form-item>
        </el-col>
      </template>
    </el-row>
  </el-form>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { FormInstance } from 'element-plus'
import { IFormItem, IOptions } from '../types'
import { useUploadImg } from '../hooks/use-uploadImg'
import { useUploadFile } from '../hooks/use-uploadFile'
const ruleFormRef = ref<FormInstance>()
type propsType = {
  modelValue: Record<string, any>
  formItems: IFormItem[]
  labelWidth?: string
  colLayout?: Record<string, any>
  formItemStyle?: Record<string, any>
  size?: string
}
const props = withDefaults(defineProps<propsType>(), {
  labelWidth: '80px',
  colLayout: () => ({
    xl: 6,
    lg: 8,
    md: 12,
    sm: 24,
    xs: 24
  }),
  formItemStyle: () => ({ padding: '20px 40px', marginBottom: 0 }),
  size: 'default'
})
const emit = defineEmits(['update:modelValue'])

// 表单内容变化回调
const handleValueChange = (value: any, field: any) => {
  console.log('=============', value)
  emit('update:modelValue', { ...props.modelValue, [field]: value })
}
// 上传图片逻辑
const [imageUrl, beforeAvatarUpload, handleAvatarSuccess] = useUploadImg(
  props,
  handleValueChange
)
// 上传文件逻辑
const [
  uploadRef,
  handleRemove,
  beforeFileUpload,
  handleFileSuccess,
  handleExceed
] = useUploadFile(props, handleValueChange)
// 表格提交:编辑/新增
const submitForm = async () => {
  await ruleFormRef.value?.validate((valid) => valid)
}
defineExpose({
  submitForm
})
const checkAll = ref(false)
const isIndeterminate = ref(true)
const handleCheckAllChange = (
  val: boolean,
  field: string,
  options: IOptions[]
) => {
  isIndeterminate.value = false
  const checkList = val ? options.map((item) => item.value) : []
  handleValueChange(checkList, field)
}
const handleCheckedChange = (value: string[], options: IOptions[]) => {
  const checkedCount = value.length
  checkAll.value = checkedCount === options.length
  isIndeterminate.value = checkedCount > 0 && checkedCount < options.length
}
</script> 

modelValue为双向绑定数据对象,通过modelValue[${item.field}]进行数据双向绑定。表单改变时调用handleValueChange方法更新数据到父组件,然后在父组件进行提交。

上传组件逻辑相对麻烦,这里将他们分别用hook进行了抽离:

use-uploadFile.ts:

import { ref } from 'vue'
import type { UploadProps, UploadRawFile, UploadInstance } from 'element-plus'
import { ElMessage, genFileId } from 'element-plus'
type fn = (value: any, field: string) => void

export const useUploadFile = (props: any, handleValueChange: fn) => {
  const uploadRef = ref<UploadInstance>()
  // 文件移除
  const handleRemove: UploadProps['onRemove'] = (file, uploadFiles) => {
    handleValueChange('', 'file')
  }
  // 在 before-upload 钩子中限制用户上传文件的格式和大小
  const beforeFileUpload: UploadProps['beforeUpload'] = (
    rawFile: UploadRawFile
  ) => {
    if (props.type && !props.type.includes(rawFile.type as any)) {
      const formatStr = props.type.join(',')
      ElMessage.error(`File must be ${formatStr} format`)
      return false
    } else if (props.size && rawFile.size / 1024 / 1024 > props.size) {
      ElMessage.error(`File size can not exceed ${props.size}MB!`)
      return false
    }
    return true
  }
  // 文件上传成功时的钩子
  const handleFileSuccess: UploadProps['onSuccess'] = (
    response,
    uploadFile
  ) => {
    handleValueChange(uploadFile.raw, 'file')
  }
  // 文件替换
  const handleExceed: UploadProps['onExceed'] = (files: File[]) => {
    console.log(uploadRef.value, 'upload.value')
    uploadRef.value && uploadRef.value.clearFiles()
    const file = files[0] as UploadRawFile
    file.uid = genFileId()
    uploadRef.value && uploadRef.value.handleStart(file)
  }
  return [
    uploadRef,
    handleRemove,
    beforeFileUpload,
    handleFileSuccess,
    handleExceed
  ]
} 

use-uploadImg.ts:

import { ref, toRefs } from 'vue'
import type { UploadProps, UploadRawFile, UploadFile } from 'element-plus'
import { ElMessage } from 'element-plus'
type fn = (value: any, field: string) => void
export const useUploadImg = (props: any, handleValueChange: fn) => {
  const { modelValue } = toRefs(props)
  const imageUrl = ref(modelValue.value.img)
  // 图片上传
  // 在 before-upload 钩子中限制用户上传文件的格式和大小
  const beforeAvatarUpload = (rawFile: UploadRawFile, otherOptions: any) => {
    if (otherOptions.type && !otherOptions.type.includes(rawFile.type as any)) {
      const formatStr = otherOptions.type.join(',')
      ElMessage.error(`Avatar picture must be ${formatStr} format`)
      return false
    } else if (props.size && rawFile.size / 1024 / 1024 > otherOptions.size) {
      ElMessage.error(`Avatar picture size can not exceed ${props.size}MB!`)
      return false
    }
    return true
  }
  // 上传成功时的钩子
  const handleAvatarSuccess: UploadProps['onSuccess'] = (
    response,
    uploadFile: UploadFile
  ) => {
    handleValueChange(uploadFile.raw, 'img')
    imageUrl.value = URL.createObjectURL(uploadFile.raw as any)
  }
  return [imageUrl, beforeAvatarUpload, handleAvatarSuccess]
} 

hooks文件将我们组件需要用到的方法和属性进行返回。

效果

效果

总结

Form组件的封装思路就是通过配置文件生成一个基本的表单,然后配合数据的双向绑定得到我们提交的数据。

exceed ${props.size}MB!`)
return false
}
return true
}
// 上传成功时的钩子
const handleAvatarSuccess: UploadProps[‘onSuccess’] = (
response,
uploadFile: UploadFile
) => {
handleValueChange(uploadFile.raw, ‘img’)
imageUrl.value = URL.createObjectURL(uploadFile.raw as any)
}
return [imageUrl, beforeAvatarUpload, handleAvatarSuccess]
}


hooks文件将我们组件需要用到的方法和属性进行返回。

[外链图片转存中...(img-jmfo1oPW-1656318443637)]

效果

### 总结

Form组件的封装思路就是通过配置文件生成一个基本的表单,然后配合数据的双向绑定得到我们提交的数据。

  

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/407672.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

JavaScript实现留言板

目录 1.案例说明&#xff1a; 2.html部分 3.css部分 4.js代码 5.全部代码 6.效果图&#xff1a; 1.案例说明&#xff1a; 利用JavaScript、css以及html制作一个简易的留言板 要求在页面文本框中输入一些文字之后&#xff0c;点击“提交”按钮&#xff0c;就可以让输入的…

React+Mobx|综合项目实践(附项目源码、地址)

欢迎来到我的博客 📔博主是一名大学在读本科生,主要学习方向是前端。 🍭目前已经更新了【Vue】、【React–从基础到实战】、【TypeScript】等等系列专栏 🛠目前正在学习的是🔥 R e a c t 框架 React框架 Reac

【学姐面试宝典】前端基础篇Ⅳ(JavaScript)

前言 博主主页&#x1f449;&#x1f3fb;蜡笔雏田学代码 专栏链接&#x1f449;&#x1f3fb;【前端面试专栏】 今天继续学习前端面试题相关的知识&#xff01; 感兴趣的小伙伴一起来看看吧~&#x1f91e; 文章目录webpack 的作用什么是按需加载如何理解前端模块化讲讲 JS 的语…

npm i 报错及解决方案

目录报错案例1报错案例2报错案例3报错案例4报错案例5报错案例1 npm ERR! Cannot read properties of null (reading pickAlgorithm)解决方案&#xff1a;清理缓存后再次安装 npm cache clear --force报错案例2 npm ERR! gyp info it worked if it ends with ok ... npm ERR!…

前端使用lottie-web,使用AE导出的JSON动画贴心教程

Lottie简介 官方介绍&#xff1a;Lottie是一个库&#xff0c;可以解析使用AE制作的动画&#xff08;需要用bodymovie导出为json格式&#xff09;,支持web、ios、android、flutter和react native。 在web端&#xff0c;lottie-web库可以解析导出的动画json文件&#xff0c;并将其…

【博主推荐】html好看的图片轮播多种风格(源码)

html好看的图片轮播多种风格所有轮播图动态效果展示1.普通自带按钮切换轮播图1.1 效果展示1.2 源码2.自动切换图片2.1 效果展示2.2 源码3.鼠标拖动切换图片3.1 效果展示4.数字按钮拖动切换图片4.1 效果展示5.图片带缩略图5.1 效果展示6.上下拖动切换图片6.1 效果展示7. 3D切换图…

X-Frame-Options简介

最近安全检查&#xff0c;发现没有保障和避免自己的网页嵌入到别人的站点里面&#xff0c;于是需要设置X-Frame-Options增加安全性。 网上查了查资料&#xff0c;这里记录一下。 可以使用下面工具进行验证&#xff1a;Clickjacking Tool | Test | UI Redressing 1、X-Frame-Op…

3.js中判断数组中是否存在某个对象/值,判断数组里的对象是否存在某个值 的五种方法 及应用场景|判断数组里有没有某对象,有不添加,没有则添加到数组

3.js中判断数组中是否存在某个对象/值&#xff0c;判断数组里的对象是否存在某个值 的五种方法 及应用场景 一、当数组中的数据是简单类型时&#xff1a; 应用js中的indexof方法&#xff1a;存在则返回当前项索引&#xff0c;不存在则返回 -1。 var arr[1,2,3,4]var str2// …

ECharts设置双x轴

下面给大家分享一下ECharts的几种功能&#xff0c;循序渐进地实现一个复杂的曲线图。 V1.0&#xff1a; 代码&#xff1a; let option {title: { text: V1.0 },legend: { data:[销量] },// x轴的数据xAxis: {data: ["王","胡歌","曾小贤",&q…

Vue3使用axios的配置教程详解

1.安装 npm install --save axios vue-axios2.在src根目录创建service文件夹。然后创建axios.js 2.1在axios.js添加拦截器,请求拦截:initAxios.interceptors.request;响应拦截:initAxios.interceptors.response import axios from "axios";const initAxios axios.…

用idea创建vue项目

目录 一、安装node.js &#xff08;1&#xff09;下载安装包 &#xff08;2&#xff09;测试node.js是否安装成功 &#xff08;3&#xff09;安装vue和全局vue-cli 二、idea安装vue.js插件 三、创建vue项目 四、修改配置文件 五、配置idea运行的环境 一、安装node.js …

ERROR: npm v9.4.1 is known not to run on Node.js v8.13.0.

前面全是废话&#xff0c;大家可以直接看序号8下面的nvm的命令以及序号11之后的问题解决&#xff0c;希望能帮助到你们&#xff01;是个什么问题呢&#xff1f;昨天领导给了个前后端分离的项目&#xff0c;让不才我搭建一下环境&#xff0c;我兴高采烈的拿着项目搭建手册按照文…

微信小程序开发 app.json全局配置

JSON 是一种数据格式&#xff0c;在实际开发中&#xff0c;JSON 总是以配置文件的形式出现。app.json 是当前小程序的全局配置&#xff0c;可以通过app.json对小程序项目进行设置所有页面路径、窗口外观、界面表现、底部 tab 等。{"pages": ["pages/index/index…

vue中实现文件批量打包压缩下载(以及下载跨域问题分析)

上次做了一个选择多个数据生成多个二维码并下载&#xff0c;当时项目催的紧&#xff0c;就简单写了个循环生成二维码下载&#xff0c;一次性会下载很多文件&#xff0c;特别难整理&#xff1b; 刚好这次项目又遇到类似这种功能&#xff0c;需要一次性批量下载多个文件&#xf…

浅谈uniapp的flex布局

文章目录1 flex布局1.1 flex-direction1.2 flex-wrap1.3 justify-content1.4 align-items1.5 align-content属性1.6 其他项目属性1.6.1 order属性1.6.2 flex-grow属性1.6.3 flex-shrink属性1.6.4 flex属性1 flex布局 ​ flex是Flexible Box的缩写&#xff0c;意为”弹性布局”…

TypeError: this.$message is not a function报错情况解决

在最近负责的一个前端项目中&#xff0c;使用this.$message报错了&#xff0c;之前也没注意&#xff0c;然后这次抽空看了一下问题 报错原因是因为我用了这种提示写法&#xff1a; 首先&#xff0c;我最开始是用基础写法&#xff1a; 但是这种写法有个弊端&#xff0c;就是如…

【JS 构造|原型|原型链|继承(圣杯模式)|ES6类语法】上篇

⌚️⌚️⌚️个人格言&#xff1a;时间是亳不留情的&#xff0c;它真使人在自己制造的镜子里照见自己的真相! &#x1f4d6;Git专栏&#xff1a;&#x1f4d1;Git篇&#x1f525;&#x1f525;&#x1f525; &#x1f4d6;JavaScript专栏&#xff1a;&#x1f4d1;js实用技巧篇…

腾讯地图api使用——地图选点自动定位到当前位置

WebService API | 腾讯位置服务 用户在使用腾讯地图api时&#xff0c;需先申请腾讯位置服务API Key&#xff0c;该key在调用时用于唯一标识开发者身份。 1.自动获取当前位置 引入以下js文件 https://mapapi.qq.com/web/mapComponents/geoLocation/v/geolocation.min.js //获…

FAST_LIO_SAM 融入后端优化的FASTLIO SLAM 系统 前端:FAST_LIO2 后端:LIO_SAM

FAST_LIO_SAM Front_end : fastlio2 Back_end : lio_sam Videos : FAST-LIO-SAM Bilibili_link Source code : FAST_LIO_SAM Related worked 1.FAST-LIO2为紧耦合的lio slam系统&#xff0c;因其缺乏前端&#xff0c;所以缺少全局一致性&#xff0c;参考lio_sam的后端部分&…

Vue3 reactive丢失响应式问题

问题描述&#xff1a;使用 reactive 定义的对象&#xff0c;重新赋值后失去了响应式&#xff0c;改变值视图不会发生变化。 测试代码&#xff1a; <template><div><p>{{ title }}</p><ul><li v-for"(item, index) in tableData" …