关键词:Vue2、组件化、AI 辅助编程、代码重构
一、背景:一段没人敢动的报表代码
我们系统里有一张资金报表表格,需要同时展示银行A / 银行B 两家对手方的数据,而且每个对手方又分左面板 / 右面板。功能一直正常,但代码没人敢动------改一个样式要同步改四个地方,漏一个就出 bug。
直到前两天我用 AI 把它重构了一遍。虽然总行数没少多少,但结构清爽了,同事终于敢在上面改需求了。
下面把完整过程和 Prompt 公开,供大家参考。
二、重构前:问题到底在哪
先看一下重构前单个单元格的模板(已脱敏,仅展示其中一处):
vue
<!-- 重构前:四套几乎一样的 :class 内联在模板里,这里只贴一处 -->
<div class="data-cell"
v-for="(cell, cellIndex) in row"
:key="cellIndex"
:class="{
'hiddle-cell': [7, 8].includes(cellIndex),
'no-right-border': cellIndex === 6,
'fixed-width': cellIndex === 0,
'link-style link-style--secondary': cellIndex === 1 && !isSumArr.includes(row[0]),
'link-style link-style--black-underline': cellIndex === 0 && (cell == 'Fund Transfer' || cell == 'TR Loan' || cell == 'Margin Call' || cell == 'IA' || cell == 'Client' || cell == 'Others'),
'data-cell--gery-bg': row[0] == 'Closing Balance' && cellIndex != 0,
'text-width': cellIndex != 0,
'italic-text': cellIndex == 0 && getCellCalculateFlag(cell, 'A', 'left'),
'cash-all-gray-cell': isOpeningBalanceRow(row) && cellIndex !== 0 && isALL,
'bg-totalMovement': cell === 'Total Movement' && cellIndex === 0,
'bg-closingBalance': leftAmountArr.includes(cell) && cellIndex === 0,
'bg-other-hightLight': cell && cellIndex === 8,
'bg-mc-hightLight': row[0] === 'Margin Call' && row[8] && cellIndex !== 0,
'bg-zero-hightLight': row[0] === 'Opening Balance' && !row[1] && cellIndex !== 0,
'copy-amount': isSumArr.includes(row[0]) && cellIndex > 0,
}"
>
<ui-tooltip :content="cell" placement="top" :disabled="cell?.length < 18 || cellIndex == 0">
<span :class="{ 'font-700': isSumArr.includes(row[0]) && cellIndex > 0 }"
@click="showLeftDialog(row, cellIndex, rowIndex, data)">{{ cell }}</span>
</ui-tooltip>
</div>
三个真实痛点
- 同一段
:class对象复制粘贴了 4 遍(银行A左、银行A右、银行B左、银行B右),每套 14 个条件,只差几个字符。 - 左/右、银行A/银行B 结构完全镜像 ,整个块在
v-for里又重复一遍,零抽象。 - 行内逻辑爆炸:编辑态、tooltip、权限指令全堆在模板里,单文件已无法维护。
三、我写给 AI 的 Prompt(已脱敏)
下面是一段 Vue2 报表表格模板,银行A/银行B、左右两面板共四套几乎一样的
:class内联在模板里,难维护。请帮我重构:
- 把重复 class 计算抽成方法;
- 把左右/银行结构抽成可复用组件;
- 保持原有样式类和数据逻辑不变。 注意:对手方名称属公司信息,请脱敏为银行A/银行B;内部 UI 组件库前缀统一替换为
ui-。
四、AI 给的初版:抽成 getCellClass 方法
AI 第一版很聪明地把重复的 class 计算抽成了方法,模板从 14 行 :class 变成 1 行:
html
<div class="data-cell"
v-for="(cell, ci) in row"
:key="ci"
:class="getCellClass(cell, ci, row, 'A', 'left')"
:data-amount="sumArr.includes(row[0]) && ci > 0 ? String(cell).replace(/,/g,'') : undefined"
>
js
methods: {
getCellClass(cell, ci, row, bankType, side) {
const isSum = this.sumArr.includes(row[0])
const isOpening = this.isOpeningBalanceRow(row)
const linkRows = ['Fund Transfer','TR Loan','Margin Call','IA','Client','Others','Closing调整明细']
const cls = {
'fixed-width': ci === 0,
'link-style link-style--secondary': ci === 1 && !isSum,
'link-style link-style--black-underline': ci === 0 && linkRows.includes(cell),
'data-cell--gery-bg': row[0] === 'Closing Balance' && ci !== 0,
'italic-text': ci === 0 && this.getCellCalculateFlag(cell, bankType, side),
'bg-totalMovement': cell === 'Total Movement' && ci === 0,
'bg-closingBalance': this.leftAmountArr.includes(cell) && ci === 0,
'bg-other-hightLight': !!cell && ci === 8,
'bg-mc-hightLight': row[0] === 'Margin Call' && row[8] && ci !== 0,
'bg-zero-hightLight': row[0] === 'Opening Balance' && !row[1] && ci !== 0,
'copy-amount': isSum && ci > 0,
}
if (side === 'left') {
cls['no-right-border'] = ci === 6
cls['hiddle-cell'] = [7, 8].includes(ci)
cls['text-width'] = ci !== 0
cls['cash-all-gray-cell'] = isOpening && ci !== 0 && this.isALL
} else {
cls['text-right-width'] = ci !== 0
cls['hiddle-cell'] = ci === 7
cls['cash-all-gray-cell'] = ci !== 0 && this.isALL
}
return cls
}
}
五、我的调整:为什么还要抽组件
AI 第一版只做了"抽方法",但左右面板、银行A/银行B 的表格结构块还是重复的------它没进一步抽象。
我的判断是:左右只是 side 不同、银行只是 bankType / boxColor 不同,本质是一个组件。于是我推了 AI 一步,抽出 ReportTable.vue,父组件用 v-for + props 把四个块收敛成 4 次 <report-table> 调用。
ReportTable.vue(自包含组件)
vue
<template>
<div class="left-content">
<div class="scb-box" :style="boxStyle">{{ bankLabel }}</div>
<div class="table-content">
<div class="header-row">
<div v-for="(title, i) in titles" :key="'h'+i"
class="header-cell" :class="headerClass(title, i)">{{ title }}</div>
</div>
<div v-for="(row, ri) in rows" :key="'r'+ri" class="data-row">
<div v-for="(cell, ci) in row" :key="'c'+ci"
class="data-cell" :class="cellClass(cell, ci, row)"
:data-amount="isSumRow(row) && ci>0 ? String(cell).replace(/,/g,'') : undefined">
<slot name="cell" :cell="cell" :ci="ci" :row="row" :ri="ri">
<ui-tooltip :content="cell" placement="top"
:disabled="cell?.length < 18 || ci === 0">
<span :class="{ 'font-700': isSumRow(row) && ci>0 }"
@click="$emit('cell-click', { cell, ci, ri, row })">{{ cell }}</span>
</ui-tooltip>
</slot>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ReportTable',
props: {
titles: { type: Array, required: true },
rows: { type: Array, required: true },
bankType: { type: String, default: 'A' }, // 'A' | 'B'
side: { type: String, default: 'left' }, // 'left' | 'right'
bankLabel: { type: String, default: '' },
boxColor: { type: String, default: '' },
sumArr: { type: Array, default: () => [] },
leftAmountArr: { type: Array, default: () => [] },
isAll: { type: Boolean, default: false },
},
computed: {
boxStyle() { return this.boxColor ? { backgroundColor: this.boxColor } : {} },
},
methods: {
isSumRow(row) { return this.sumArr.includes(row[0]) },
isOpeningBalanceRow(row) { return row[0] === 'Opening Balance' },
// 原项目逻辑:按 bankType/side 判断计算字段是否斜体,接入你真实的数据源即可
getCellCalculateFlag(cell, bankType, side) {
return this.calcFlagMap[`${bankType}-${side}`]?.includes(cell) || false
},
headerClass(title, i) {
return {
'bg-gray': i === 0, 'bg-green': i === 1, 'fixed-width': i === 0,
'text-width': i !== 0 && this.side === 'left',
'text-right-width': i !== 0 && this.side === 'right',
'no-right-border': i === 6 && this.side === 'left',
'hiddle-cell':
(this.side === 'left' && ['TR','Others'].includes(title)) ||
(this.side === 'right' && title === 'TR'),
}
},
cellClass(cell, ci, row) { return this.getCellClass(cell, ci, row, this.bankType, this.side) },
getCellClass(cell, ci, row, bankType, side) {
const isSum = this.isSumRow(row)
const isOpening = this.isOpeningBalanceRow(row)
const linkRows = ['Fund Transfer','TR Loan','Margin Call','IA','Client','Others','Closing调整明细']
const cls = {
'fixed-width': ci === 0,
'link-style link-style--secondary': ci === 1 && !isSum,
'link-style link-style--black-underline': ci === 0 && linkRows.includes(cell),
'data-cell--gery-bg': row[0] === 'Closing Balance' && ci !== 0,
'italic-text': ci === 0 && this.getCellCalculateFlag(cell, bankType, side),
'bg-totalMovement': cell === 'Total Movement' && ci === 0,
'bg-closingBalance': this.leftAmountArr.includes(cell) && ci === 0,
'bg-other-hightLight': !!cell && ci === 8,
'bg-mc-hightLight': row[0] === 'Margin Call' && row[8] && ci !== 0,
'bg-zero-hightLight': row[0] === 'Opening Balance' && !row[1] && ci !== 0,
'copy-amount': isSum && ci > 0,
}
if (side === 'left') {
cls['no-right-border'] = ci === 6
cls['hiddle-cell'] = [7, 8].includes(ci)
cls['text-width'] = ci !== 0
cls['cash-all-gray-cell'] = isOpening && ci !== 0 && this.isAll
} else {
cls['text-right-width'] = ci !== 0
cls['hiddle-cell'] = ci === 7
cls['cash-all-gray-cell'] = ci !== 0 && this.isAll
}
return cls
},
},
}
</script>
父组件调用(左/右、银行A/银行B 全收敛)
vue
<template>
<!-- 银行A -->
<div class="table-box">
<div style="flex:1">
<report-table :titles="bankALeftTitles" :rows="bankAReport.leftData"
bank-type="A" side="left" bank-label="银行A"
:sum-arr="sumArr" :left-amount-arr="leftAmountArr" :is-all="isAll"
@cell-click="onCellClick" />
</div>
<div style="flex:1" class="ml10">
<report-table :titles="bankARightTitles" :rows="bankAReport.rightData"
bank-type="A" side="right" bank-label="银行A"
:sum-arr="sumArr" :left-amount-arr="leftAmountArr" :is-all="isAll"
@cell-click="onCellClick" />
</div>
</div>
<!-- 银行B(多账户循环) -->
<div v-for="report in bankBReportList" :key="report.bankAccountId" class="report-container">
<div class="table-box">
<div style="flex:1">
<report-table :titles="bankBLeftTitles" :rows="report.leftData"
bank-type="B" side="left" bank-label="银行B" box-color="#5a9d8f"
:sum-arr="sumArr" :left-amount-arr="leftAmountArr" :is-all="isAll"
@cell-click="(e) => onCellClick(e, report.bankAccountId)" />
</div>
<div style="flex:1" class="ml10">
<report-table :titles="bankBRightTitles" :rows="report.rightData"
bank-type="B" side="right" bank-label="银行B" box-color="#5a9d8f"
:sum-arr="sumArr" :left-amount-arr="leftAmountArr" :is-all="isAll"
@cell-click="(e) => onCellClick(e, report.bankAccountId)" />
</div>
</div>
</div>
</template>
提示:编辑态(期初余额编辑)通过父组件的
#cell插槽或cell-click事件接回原逻辑,原有ui-input-number编辑 UI 不受影响。
六、重构前后对比
| 指标 | 重构前 | 重构后 |
|---|---|---|
重复 :class 对象 |
4 套 × 14 行 | 1 个方法 |
| 表格结构块 | 银行A×2 + 银行B×N×2 | <report-table> × N |
| 改一处样式的成本 | 改 4 处 | 改 1 处 |
| 单文件可维护性 | 不敢动 | 敢改 |
七、一点思考
AI 能很快帮你把"重复代码"抽成方法,但"这段代码到底是不是一个组件"这种抽象判断,目前还得靠人。我的经验是:先让 AI 做低风险的抽取(抽方法、抽变量),再自己决定是否值得进一步组件化------别一上来就让 AI 大改结构,容易改出回归。
八、互动
你们项目里有这种"能跑不敢动"的代码吗?是怎么处理的?欢迎评论区聊聊 👋
如果觉得有用,点赞 + 收藏 不迷路,后续我会写更多「AI 辅助前端重构」的实战。