2026/9/14 21:36:24

Vue2信创升级Vue3

Vue2信创升级Vue3 背景Vue2信创升级Vue3过程记录其实感觉就是直接重写换一种版本语法再写一遍一、结论先行你的直觉是对的这几十个页面不建议做整套 配置驱动封装。推荐方案是——模板各写各的页面组件完全独立 逻辑层抽一个useTablecomposable 接口收敛到 api 模块。页面模板不抽象避免配置地狱、slot 满天飞只把分页/查询/重置/loading这一层完全相同的东西抽出来复用每页能省 40~60 行重复逻辑两全其美。下面是两种路线的对比示意帮你看清为什么各写各的更适合你们这种字段全不同的场景html stylemargin:0;padding:0; div stylebackground-color:transparent;box-sizing:border-box;--accent:var(--m-02);font-family:Roboto,PingFang SC,Segoe UI,Arial,sans-serif;font-size:13.5px;line-height:1.6;color:#1A1B1C; div stylemargin:0 0 8px;font-size:15px;font-weight:600;color:#1A1B1C;30 个维护页两种实现路线对比示意/div div styledisplay:flex;gap:16px;flex-wrap:wrap;align-items:stretch;box-sizing:border-box; !-- 方案A -- div styleflex:1 1 280px;min-width:0;background:rgba(0,0,0,0.025);border:1px solid rgba(0,0,0,0.08);border-radius:14px;padding:14px 16px;box-sizing:border-box; div stylefont-weight:600;font-size:14px;方案A · 配置驱动封装/div div stylefont-size:12px;color:#6B7280;margin:2px 0 10px;通用 CrudPage 每页一份字段配置/div div styledisplay:flex;gap:6px;align-items:baseline;margin:4px 0;span stylecolor:#52C41A;font-weight:600;✓/spanspan新页面只写配置代码量最小/span/div div styledisplay:flex;gap:6px;align-items:baseline;margin:4px 0;span stylecolor:#52C41A;font-weight:600;✓/spanspan分页/查询/表格逻辑天然复用/span/div div styledisplay:flex;gap:6px;align-items:baseline;margin:4px 0;span stylecolor:#52C41A;font-weight:600;✓/spanspan30 个页面风格绝对统一/span/div div styledisplay:flex;gap:6px;align-items:baseline;margin:4px 0;span stylecolor:#EA6668;font-weight:600;✕/spanspan字段/校验/操作差异大 → 配置膨胀、slot 满天飞/span/div div styledisplay:flex;gap:6px;align-items:baseline;margin:4px 0;span stylecolor:#EA6668;font-weight:600;✕/spanspan特殊业务要开逃逸口越逃越乱/span/div div styledisplay:flex;gap:6px;align-items:baseline;margin:4px 0;span stylecolor:#EA6668;font-weight:600;✕/spanspan配置是对象无模板/类型提示调试弱/span/div div styledisplay:flex;gap:6px;align-items:baseline;margin:4px 0;span stylecolor:#EA6668;font-weight:600;✕/spanspan改一处通用逻辑 → 30 个页面一起冒风险/span/div /div !-- 方案B -- div styleflex:1 1 280px;min-width:0;background:linear-gradient(135deg, rgba(163,213,232,0.14), rgba(163,213,232,0.3));border:1px solid rgba(0,0,0,0.08);border-radius:14px;padding:14px 16px;box-sizing:border-box; div stylefont-weight:600;font-size:14px;方案B · 各写各的 逻辑复用推荐/div div stylefont-size:12px;color:#6B7280;margin:2px 0 10px;模板每页直写公共逻辑抽 useTable/div div styledisplay:flex;gap:6px;align-items:baseline;margin:4px 0;span stylecolor:#52C41A;font-weight:600;✓/spanspan字段再杂也所见即所得无抽象负担/span/div div styledisplay:flex;gap:6px;align-items:baseline;margin:4px 0;span stylecolor:#52C41A;font-weight:600;✓/spanspan模板 TS 类型提示完整新人零上手成本/span/div div styledisplay:flex;gap:6px;align-items:baseline;margin:4px 0;span stylecolor:#52C41A;font-weight:600;✓/spanspan单页出问题只影响自己改动互不牵连/span/div div styledisplay:flex;gap:6px;align-items:baseline;margin:4px 0;span stylecolor:#52C41A;font-weight:600;✓/spanspan分页/查询/重置逻辑经 useTable 仍可复用/span/div div styledisplay:flex;gap:6px;align-items:baseline;margin:4px 0;span stylecolor:#EA6668;font-weight:600;✕/spanspan模板有重复 → 用页面模板文件复制改字段缓解/span/div div styledisplay:flex;gap:6px;align-items:baseline;margin:4px 0;span stylecolor:#EA6668;font-weight:600;✕/spanspan需要先沉淀 useTable 等 2~3 个公共件/span/div /div /div !-- 推荐条 -- div stylemargin-top:16px;padding:12px 16px;background:linear-gradient(135deg, rgba(163,213,232,0.18), rgba(163,213,232,0.34));border-radius:14px;box-sizing:border-box; span stylefont-weight:600;color:#1A1B1C;结论/span模板各写各的页面组件独立仅把分页/查询/重置逻辑抽成 span stylefont-weight:600;useTable/span接口收敛到 span stylefont-weight:600;api 模块/span。若后续发现 60% 以上页面字段高度同构再局部升级为配置驱动不必现在一次性抽象。 /div !-- 落地路径 -- div stylemargin-top:12px;display:flex;gap:12px;flex-wrap:wrap;box-sizing:border-box; div styleflex:1 1 180px;min-width:0;padding:10px 12px;background:#fff;border:1px solid rgba(0,0,0,0.08);border-radius:12px;box-sizing:border-box; div stylefont-weight:600;color:#3A6E8A;font-size:13px;第 1 步/div div stylefont-size:12.5px;color:#1A1B1C;margin-top:2px;写 useTable 公共件附件已附/div /div div styleflex:1 1 180px;min-width:0;padding:10px 12px;background:#fff;border:1px solid rgba(0,0,0,0.08);border-radius:12px;box-sizing:border-box; div stylefont-weight:600;color:#3A6E8A;font-size:13px;第 2 步/div div stylefont-size:12.5px;color:#1A1B1C;margin-top:2px;每页复制本页模板按字段/列/按钮直改/div /div div styleflex:1 1 180px;min-width:0;padding:10px 12px;background:#fff;border:1px solid rgba(0,0,0,0.08);border-radius:12px;box-sizing:border-box; div stylefont-weight:600;color:#3A6E8A;font-size:13px;第 3 步/div div stylefont-size:12.5px;color:#1A1B1C;margin-top:2px;接口按业务收进 api/car/xxx.ts路径/枚举统一维护/div /div /div /div /html二、分析要点简短版你们的差异点不在结构、在字段查询项、表格列、弹窗、状态枚举、操作按钮各不相同这正是配置驱动最难受的地方——每个差异都要设计一套 config 字段 slot 逃逸口最终封装比直写还难维护且丢失模板/TS 的类型提示。完全相同的只有逻辑层分页状态、loading、查询重置、参数拼装。把这50 行抽成一个useTablecomposable每页 3~5 行接入既不牺牲可读性又消掉 80% 重复。渐进式兜底哪天发现某类页面如全是字典维护字段高度同构再对那一类做局部配置化不要现在拍板全局抽象。三、完整代码1. 页面组件src/views/car/brandName/index.vue还原了图1的完整结构9 项查询条件pp ID/名称/代码 4 组时间区间 审核状态 cwsjbs、查询/重置/新增按钮、7 列表格操作列含查看zc/cw数据开关切换带二次确认、共 N 条 每页条数 页码跳转分页另补了新增弹窗和查看详情弹窗。!-- * clppmcwh页面内容 * 对应公司 Vue3 框架路由菜单cxsjwh - clppmcwh * 依赖Vue3 script setup langts Element Plus * * 接入时按公司框架微调 4 处已用注释标注 * 1. /utils/request 换成公司封装的请求实例 * 2. 接口路径 /car/xxxName/xxx 换成后端真实路径 * 3. 返回结构默认取 res.data.records / res.data.total见 fetchData * 4. sh状态、cwsjbs的枚举值按后端字典调整 -- template div classbrand-name-page !-- 查询区 -- el-form :inlinetrue :modelqueryParams classquery-form el-form-item labelppmc ID el-input v-modelqueryParams.brandId placeholder请输入ppmc ID clearable stylewidth: 180px / /el-form-item el-form-item labelppmc el-input v-modelqueryParams.brandName placeholder请输入ppmc clearable stylewidth: 180px / /el-form-item el-form-item labelppmcdm el-input v-modelqueryParams.brandCode placeholder请输入ppmcdm clearable stylewidth: 200px / /el-form-item el-form-item label创建时间 el-date-picker v-modelqueryParams.createTime typedatetimerange range-separator至 start-placeholder选择开始时间 end-placeholder选择结束时间 value-formatYYYY-MM-DD HH:mm:ss / /el-form-item el-form-item label修改时间 el-date-picker v-modelqueryParams.updateTime typedatetimerange range-separator至 start-placeholder选择开始时间 end-placeholder选择结束时间 value-formatYYYY-MM-DD HH:mm:ss / /el-form-item el-form-item labelcs时间 el-date-picker v-modelqueryParams.firstAuditTime typedatetimerange range-separator至 start-placeholder选择开始时间 end-placeholder选择结束时间 value-formatYYYY-MM-DD HH:mm:ss / /el-form-item el-form-item labelfs时间 el-date-picker v-modelqueryParams.reAuditTime typedatetimerange range-separator至 start-placeholder选择开始时间 end-placeholder选择结束时间 value-formatYYYY-MM-DD HH:mm:ss / /el-form-item el-form-item labelsh状态 el-select v-modelqueryParams.auditStatus clearable stylewidth: 140px el-option label全部 value / el-option label待审核 value0 / el-option label审核通过 value1 / el-option label审核不通过 value2 / /el-select /el-form-item el-form-item labelcwsjbs el-select v-modelqueryParams.errorFlag placeholder请选择 clearable stylewidth: 140px el-option labelzc数据 value0 / el-option labelcw数据 value1 / /el-select /el-form-item el-form-item el-button typeprimary :loadingloading clickhandleQuery查询/el-button el-button clickhandleReset重置/el-button el-button typeprimary plain clickopenAdd新增/el-button /el-form-item /el-form !-- 表格区 -- div classtable-card el-table v-loadingloading :datatableData border stripe el-table-column propxxxId labelppmc ID min-width110 show-overflow-tooltip / el-table-column propxxxName labelppmc min-width160 show-overflow-tooltip / el-table-column propxxxCode labelppmcdm min-width150 show-overflow-tooltip / el-table-column propupdateBy labelgxry min-width110 show-overflow-tooltip / el-table-column propupdateTime labelgxsj min-width180 show-overflow-tooltip / el-table-column labelsh状态 width110 aligncenter template #default{ row } el-tag :typeauditTagType(row.auditStatus){{ auditStatusText(row.auditStatus) }}/el-tag /template /el-table-column el-table-column label操作 width230 aligncenter template #default{ row } el-button link typeprimary clickopenDetail(row)查看/el-button el-switch v-modelrow.errorFlag active-textzc数据 inactive-textcw数据 :before-change() handleErrorFlagChange(row) / /template /el-table-column template #empty el-empty description暂无数据 / /template /el-table !-- 分页区 -- div classpagination-wrap el-pagination v-model:current-pagepageNum v-model:page-sizepageSize :totaltotal :page-sizes[10, 20, 50, 100] layouttotal, sizes, prev, pager, next, jumper background size-changehandleQuery current-changefetchData / /div /div !-- 新增弹窗 -- el-dialog v-modeladdVisible title新增ppmc width480px destroy-on-close el-form refaddFormRef :modeladdForm :rulesaddRules label-width100px el-form-item labelppmc propbrandName el-input v-modeladdForm.brandName placeholder请输入ppmc maxlength50 / /el-form-item el-form-item labelppmcdm propbrandCode el-input v-modeladdForm.brandCode placeholder请输入ppmcdm maxlength30 / /el-form-item /el-form template #footer el-button clickaddVisible false取消/el-button el-button typeprimary :loadingaddLoading clickhandleAdd确定/el-button /template /el-dialog !-- 查看弹窗 -- el-dialog v-modeldetailVisible titleppmcxq width560px destroy-on-close el-descriptions :column2 border el-descriptions-item labelppmc ID{{ detailRow.xxxId }}/el-descriptions-item el-descriptions-item labelppmc{{ detailRow.xxxName }}/el-descriptions-item el-descriptions-item labelppmcdm{{ detailRow.xxxCode }}/el-descriptions-item el-descriptions-item labelgxry{{ detailRow.updateBy }}/el-descriptions-item el-descriptions-item labelgxsj{{ detailRow.updateTime }}/el-descriptions-item el-descriptions-item labelsh状态{{ auditStatusText(detailRow.auditStatus) }}/el-descriptions-item /el-descriptions /el-dialog /div /template script setup langts import { reactive, ref, onMounted } from vue import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from element-plus import { pagexxxName, createxxxName, updatexxxNameErrorFlag } from /api/car/xxxName // 若框架用 keep-alive 按组件名缓存可保留否则可删 defineOptions({ name: BrandNameMaintenance }) // ---------- 查询参数 ---------- interface QueryParams { xxxId: string xxxName: string xxxCode: string createTime: [string, string] | null updateTime: [string, string] | null firstAuditTime: [string, string] | null reAuditTime: [string, string] | null auditStatus: string errorFlag: string } const queryParams reactiveQueryParams({ xxxId: , xxxName: , xxxCode: , createTime: null, updateTime: null, firstAuditTime: null, reAuditTime: null, auditStatus: , errorFlag: }) // ---------- 列表与分页 ---------- interface TableRow { xxxId: number | string xxxName: string xxxCode: string updateBy: string updateTime: string auditStatus: string errorFlag: boolean } const loading ref(false) const tableData refTableRow[]([]) const total ref(0) const pageNum ref(1) const pageSize ref(10) // 时间区间拆成 start/end 传给后端 function buildParams() { const params: Recordstring, unknown { xxxId: queryParams.brandId || undefined, xxxName: queryParams.brandName || undefined, xxxCode: queryParams.brandCode || undefined, auditStatus: queryParams.auditStatus || undefined, errorFlag: queryParams.errorFlag || undefined, pageNum: pageNum.value, pageSize: pageSize.value } const rangeMap: Array[keyof QueryParams, string, string] [ [createTime, createTimeStart, createTimeEnd], [updateTime, updateTimeStart, updateTimeEnd], [firstAuditTime, firstAuditTimeStart, firstAuditTimeEnd], [reAuditTime, reAuditTimeStart, reAuditTimeEnd] ] rangeMap.forEach(([key, startKey, endKey]) { const range queryParams[key] as [string, string] | null if (range range.length 2) { params[startKey] range[0] params[endKey] range[1] } }) return params } async function fetchData() { loading.value true try { const res: any await pagexxxName(buildParams()) // 按公司后端返回结构调整默认兼容 { data: { records, total } } 或 { records, total } tableData.value res?.data?.records ?? res?.records ?? [] total.value res?.data?.total ?? res?.total ?? 0 } catch (e: any) { ElMessage.error(e?.message || 查询失败) } finally { loading.value false } } function handleQuery() { pageNum.value 1 fetchData() } function handleReset() { queryParams.xxxId queryParams.xxxName queryParams.xxxCode queryParams.createTime null queryParams.updateTime null queryParams.firstAuditTime null queryParams.reAuditTime null queryParams.auditStatus queryParams.errorFlag handleQuery() } // ---------- sh状态展示枚举值按后端字典调整 ---------- const AUDIT_STATUS: Recordstring, { text: string; tag: success | warning | danger | info } { 0: { text: 待审核, tag: warning }, 1: { text: 审核通过, tag: success }, 2: { text: 审核不通过, tag: danger } } function auditStatusText(status: string) { return AUDIT_STATUS[status]?.text ?? status ?? - } function auditTagType(status: string) { return AUDIT_STATUS[status]?.tag ?? info } // ---------- 新增 ---------- const addVisible ref(false) const addLoading ref(false) const addFormRef refFormInstance() const addForm reactive({ brandName: , brandCode: }) const addRules: FormRules { xxxName: [{ required: true, message: 请输入ppmc, trigger: blur }], xxxCode: [{ required: true, message: 请输入ppmcdm, trigger: blur }] } function openAdd() { addForm.xxxName addForm.xxxCode addVisible.value true } async function handleAdd() { if (!addFormRef.value) return await addFormRef.value.validate() addLoading.value true try { await createBrandName({ ...addForm }) ElMessage.success(新增成功) addVisible.value false fetchData() } catch (e: any) { ElMessage.error(e?.message || 新增失败) } finally { addLoading.value false } } // ---------- 查看 ---------- const detailVisible ref(false) const detailRow refPartialTableRow({}) function openDetail(row: TableRow) { detailRow.value row detailVisible.value true } // ---------- 正常/错误sjbs切换 ---------- function handleErrorFlagChange(row: TableRow) { // before-change 阶段 row.errorFlag 还是旧值 const target row.errorFlag ? 错误数据 : 正常数据 return new Promiseboolean((resolve) { ElMessageBox.confirm(确定将该条sjbs为「${target}」吗, 提示, { type: warning, confirmButtonText: 确定, cancelButtonText: 取消 }) .then(async () { try { await updatexxxNameErrorFlag(row.brandId, !row.errorFlag) ElMessage.success(操作成功) resolve(true) } catch { resolve(false) } }) .catch(() resolve(false)) }) } onMounted(() { fetchData() }) /script style scoped .xxx-name-page { padding: 16px; } /* 如公司框架外层已包卡片容器可去掉下方背景与圆角 */ .query-form { padding: 16px 16px 0; background: #fff; border-radius: 8px; margin-bottom: 16px; } .table-card { background: #fff; border-radius: 8px; } .pagination-wrap { display: flex; justify-content: flex-end; padding: 16px; } /style2. 接口模块src/api/car/brandName.ts/** * clppmcwh - 接口模块 * 接入时/utils/request 换成公司封装的请求实例接口路径换成后端真实路径 */ import request from /utils/request export interface BrandNameQuery { xxxId?: string xxxName?: string xxxCode?: string createTimeStart?: string createTimeEnd?: string updateTimeStart?: string updateTimeEnd?: string firstAuditTimeStart?: string firstAuditTimeEnd?: string reAuditTimeStart?: string reAuditTimeEnd?: string auditStatus?: string errorFlag?: string pageNum: number pageSize: number } export interface BrandNameRow { xxxId: number | string xxxName: string xxxCode: string updateBy: string updateTime: string auditStatus: string errorFlag: boolean } /** 分页查询 */ export function pagexxxName(params: BrandNameQuery) { return request({ url: /car/xxxName/page, method: get, params }) } /** 新增ppmc */ export function createxxxName(data: { brandName: string; brandCode: string }) { return request({ url: /car/xxxName, method: post, data }) } /** 切换正常/错误sjbs */ export function updatexxxNameErrorFlag(brandId: number | string, errorFlag: boolean) { return request({ url: /car/xxxName/${xxxId}/errorFlag, method: put, params: { errorFlag } }) }3. 公共逻辑src/composables/useTable.ts后续页面选用接入后每个页面的 script 会从 ~100 行缩到 ~10 行模板照旧直写。第一页我给了自包含版本保证直接能跑后续页面建议用它。/** * useTable - 列表维护页通用逻辑分页 查询 重置 加载态 * * 适用公司几十个「查询表单 表格 分页」维护类页面。 * 页面模板各写各的只有这一段逻辑完全相同抽到这里复用。 * 用法const { loading, list, total, pageNum, pageSize, queryParams, fetchData, handleQuery, handleReset } useTable({...}) */ import { reactive, ref } from vue interface UseTableOptionsT, P extends Recordstring, any { /** 分页查询接口入参会自动带上 queryParams pageNum pageSize */ fetchApi: (params: any) Promiseany /** 查询参数工厂必须返回新对象供初始化与重置使用 */ defaultQuery: () P pageSize?: number /** 从响应取列表默认 res?.data?.records ?? res?.records ?? [] */ listKey?: (res: any) any[] /** 从响应取总数默认 res?.data?.total ?? res?.total ?? 0 */ totalKey?: (res: any) number /** 查询失败回调默认不处理由页面自行 catch */ onError?: (err: any) void } export function useTableT any, P extends Recordstring, any Recordstring, any( options: UseTableOptionsT, P ) { const loading ref(false) const list refT[]([]) const total ref(0) const pageNum ref(1) const pageSize ref(options.pageSize ?? 10) const queryParams reactiveP(options.defaultQuery()) const pickList (res: any) options.listKey ? options.listKey(res) : res?.data?.records ?? res?.records ?? [] const pickTotal (res: any) options.totalKey ? options.totalKey(res) : res?.data?.total ?? res?.total ?? 0 async function fetchData() { loading.value true try { const res: any await options.fetchApi({ ...queryParams, pageNum: pageNum.value, pageSize: pageSize.value }) list.value pickList(res) total.value pickTotal(res) } catch (e) { options.onError?.(e) } finally { loading.value false } } /** 查询回到第一页再拉数据 */ function handleQuery() { pageNum.value 1 fetchData() } /** 重置用工厂方法重建查询参数再触发查询 */ function handleReset() { const fresh options.defaultQuery() Object.keys(fresh).forEach((key) { ;(queryParams as any)[key] fresh[key] }) handleQuery() } return { loading, list, total, pageNum, pageSize, queryParams, fetchData, handleQuery, handleReset } }四、接入清单按公司框架微调 4 处即可放目录index.vue放到路由对应的src/views/car/brandName/图2的菜单/面包屑/标题由框架路由配置不用动请求实例/utils/request换成公司封装的 axios 实例接口与返回结构/car/xxxName/*换成后端真实路径若返回结构不是data.records/data.total改fetchData里那两行已注释标注枚举值审核状态0/1/2和错误数据标识0/1按后端字典调整顺便改el-select的 option。验证情况index.vue已通过 vue/compiler-sfc 模板脚本编译校验两个 TS 文件已通过 esbuild 转译校验未做真实接口联调无后端地址接口路径/返回结构需按你们后端微调。三个文件已全部交付上方的下载链接。如果后续需要我可以再帮你做这些事把「新增弹窗」改成你们后端实际要的字段、按你的框架返回结构把fetchData写死适配、或者直接按useTable的方式把第二个页面也写出来给你当对照模板。