
背景:
第一篇中已经列出了迁移步骤、效果和验收的方式。
本次聚焦如何与 AI 协作:明确角色、边界、规则,产出高质量的迁移脚本与验收工具;记录踩坑与复盘,形成可复用的迁移流水线。
第一阶段:准备工作
学习文档列出风险,并学习了解代码现状。
问:
目前angular和 @angular/material版本是18,ngx-bootstrap的版本是 8。使用mat-button替换 bootstrap button会有哪些需要注意的坑?请仔细阅读两个框架对应版本的文档和GitHub issues列表,一步一步思考。
答:
1: 颜色:Bootstrap 的 btn-primary, btn-secondary, btn-danger 是固定色值。Material的配色来源于主题。
2: Bootstrap 常将 <a class="btn"> 作为按钮使用;Material 使用 <button mat-raised-button> 或含 href 的 <a mat-button>。图标按钮需用 mat-icon-button。
3: Bootstrap 用 btn-lg、btn-sm 及 btn-block 管理尺寸与宽度;Material 没有类映射,需要通过布局 CSS 或自定义样式控制。
4: Material 按钮默认带 ripple 效果,如需关闭 ripple,添加 [disableRipple]="true" 并在样式中移除 ripple CSS 变量,例如 --mat-text-button-state-layer-color: none;。
问:
学习app文件下所有html文件中的<button>,给出总结和替换清单,一步一步思考。
答:





我们查看这一阶段工作中AI的回答,风险和替换清单都比较全面。有两点值得注意。
1: 替换清单中的规则部分,在 Material 3 (M3) 主题体系中,<button color="..."> 已经 不再支持,这个属性只对旧的 M2 主题生效。从 Angular Material v19 (M3) 起,组件文档里明确指出:
"This API is supported in M2 themes only, it has no effect in M3 themes."。

2: 事件绑定保持不变,但是可能会和之前的行为不同。
第二阶段:生成方案
处于将来升级的考虑,我们使用M3主题,更多请参阅:主题
问:
基于你的替换清单(迁移策略)中的规则,生成迁移脚本,注意,我已经有的主题是M3,请不要使用color,可以用class。只使用mat-icon-button和mat-raised-button。保留 <i class="fa ...">
答:


我们查看脚本之后,需要调整语义映射。因为根据M3规则,我可以制定不同主题局部应用给button。所以让AI调整脚本后:

第三阶段:实施方案
跑完替换脚本后,有几个问题
1: 做了一些多余的工作。



2: 错误如下,这说明AI没有理解Material组件之间的使用规则。

那么我们需要调整prompt,角色设定与目标清晰化:
你现在是一个前端高级工程师,精通 Angular、Bootstrap、Material。请生成一个迁移脚本,遵守以下规则:
- 只修改 <button>,不修改任何 <a>;
- 如果按钮已使用 mat-raised-button 或 mat-icon-button,不更改;
- M3 主题,不要使用 color 属性,改用类名映射(如 btn-primary → mat-btn-primary);
- 识别纯图标按钮使用 mat-icon-button,其余用 mat-raised-button;
- 保留 <i class="fa ...">;
- 避免与 mat-menu-item 等组件的非法组合;
经验:把规则写成机器可判断的条件,AI 更容易生成可执行脚本。
前后对比,这就靠谱了许多。

增加主题色之后的效果:

第四阶段:验证
问:
作为高级测试工程师,我已经替换完成,请问200+文件,要怎么高效率验收?请一步一步分析给出方案
答:

根据AI答案的具体验收步骤:
1: 正则批量扫描
AI 根据规则生成 grep 格式;你只需运行并查看异常列表。用 grep 或脚本检查关键点:
js
// 冲突检查,应无输出
grep -R --line-number "<button[^>]*mat-menu-item[^>]*mat-raised-button" client/app/components
grep -R --line-number "<button[^>]*mat-menu-item[^>]*mat-icon-button" client/app/components
// 非 <button> 修改检查,应无输出
grep -R --line-number "<a[^>]*mat-raised-button" client/app/components
// 类名映射检查,应无输出
grep -R --line-number "btn-" client/app/components
2: 使用脚本辅助验证
validate-buttons.mjs是AI通过迁移脚本和验收目标的规则生成的,逐行判断是否存在违规组合或误改。
运行之后很容易发现哪些文件存在问题:
js
// scripts/validate-buttons.mjs
import { promises as fs } from 'node:fs';
import path from 'node:path';
/**
* 用法:
* node scripts/validate-buttons.mjs --dir=client/app/components --ext=.html --json=report.json
*
* 参数:
* --dir 扫描根目录(默认:client/app/components)
* --ext 文件扩展名(默认:.html)
* --json 输出 JSON 报告到指定文件(可选)
*/
const args = new Map(
process.argv.slice(2).flatMap(a => {
const m = a.match(/^--([^=]+)(?:=(.+))?$/);
return m ? [[m[1], m[2] ?? true]] : [];
}),
);
const ROOT_DIR = path.resolve(process.cwd(), args.get('dir') || 'client/app/components');
const EXT = String(args.get('ext') || '.html');
const JSON_OUT = args.get('json');
const ALLOWED_SUFFIX = new Set([
'primary','secondary','success','info','danger','warning','light','dark','link',
]);
// 会导致组件冲突或不在允许外观范围内的按钮属性
const MATERIAL_BUTTON_ATTRS = [
'mat-raised-button',
'mat-icon-button',
'mat-button',
'mat-flat-button',
'mat-stroked-button',
];
// 不允许与 MatButton 共存的"组件型指令"
const CONFLICT_COMPONENT_ATTRS = [
'mat-menu-item', // 菜单项
'mat-fab', // 浮动按钮
'mat-mini-fab',
];
// Bootstrap 基类(应移除)
const BOOTSTRAP_BASE = new Set(['btn','btn-sm','btn-lg']);
/* ------------------------ 工具函数 ------------------------ */
// 递归列出所有指定扩展名的文件
async function listFiles(dir, ext, acc = []) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
await listFiles(full, ext, acc);
} else if (e.isFile() && full.endsWith(ext)) {
acc.push(full);
}
}
return acc;
}
// 估算某个匹配在文本中的行号(1-based)
function lineOf(content, index) {
let line = 1;
for (let i = 0; i < index; i++) {
if (content.charCodeAt(i) === 10) line++;
}
return line;
}
// 判断按钮内部是否仅图标(<i class="fa ..."> 或 <mat-icon>),且无可见文本
function isIconOnly(innerHtml) {
const cleaned = innerHtml
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/\s+/g, '')
.replace(/ /g, '');
const onlyFA = /^<i[^>]*class=["'][^"']*\bfa\b[^"']*["'][^>]*><\/i>$/.test(cleaned)
|| /^<i[^>]*class=["'][^"']*\bfa\b[^"']*["'][^>]*\/>$/.test(cleaned);
const onlyMatI = /^<mat-icon\b[^>]*>[\s\S]*<\/mat-icon>$/.test(cleaned);
return onlyFA || onlyMatI;
}
// 从 openTag 提取 class 列表(数组)
function parseClassList(openTag) {
const m = openTag.match(/\bclass\s*=\s*(['"])(.*?)\1/i);
if (!m) return [];
return m[2].trim().split(/\s+/);
}
// 是否含任一属性
function hasAnyAttr(openTag, attrList) {
return attrList.some(a => new RegExp(`\\b${a}\\b`).test(openTag));
}
// 统计包含的按钮外观属性数量
function countButtonAppearances(openTag) {
return MATERIAL_BUTTON_ATTRS.reduce((n, a) => n + (new RegExp(`\\b${a}\\b`).test(openTag) ? 1 : 0), 0);
}
/* ------------------------ 规则检查 ------------------------ */
function validateButton(openTag, innerHtml) {
const issues = [];
const hasColorAttr = /\bcolor\s*=\s*(['"]).*?\1/i.test(openTag);
const classes = parseClassList(openTag);
const hasTargetAppearance =
/\bmat-raised-button\b/.test(openTag) || /\bmat-icon-button\b/.test(openTag);
const isMenuItem = /\bmat-menu-item\b/.test(openTag);
const isFab = /\bmat-fab\b/.test(openTag) || /\bmat-mini-fab\b/.test(openTag);
const btnAppearanceCount = countButtonAppearances(openTag);
// R1: 组件冲突检查(menu-item/fab 与 MatButton 同时存在)
if (isMenuItem || isFab) {
if (MATERIAL_BUTTON_ATTRS.some(a => new RegExp(`\\b${a}\\b`).test(openTag))) {
issues.push({rule: 'R1', msg: '禁止在 mat-menu-item / mat-fab 上同时使用任何 MatButton 外观属性'});
}
}
// R2: 外观属性合法性(只允许 mat-raised-button 或 mat-icon-button;且最多一个)
if (!isMenuItem && !isFab) {
if (btnAppearanceCount === 0) {
issues.push({rule: 'R2', msg: '缺少 MatButton 外观属性(期望 mat-raised-button 或 mat-icon-button)'});
} else if (btnAppearanceCount > 1) {
issues.push({rule: 'R2', msg: '存在多个 MatButton 外观属性(最多只能有一个)'});
} else {
// 存在且只有一个,但如果是 mat-button / mat-flat / mat-stroked 也视为违规(只允许 raised/icon)
if (/\bmat-button\b|\bmat-flat-button\b|\bmat-stroked-button\b/.test(openTag)) {
issues.push({rule: 'R2', msg: '仅允许 mat-raised-button 或 mat-icon-button,其它外观不允许'});
}
}
}
// R3: 禁止使用 color="..."(M3 下无效)
if (hasColorAttr) {
issues.push({rule: 'R3', msg: 'M3 下不允许使用 color="...",请改用类(mat-btn-*)'});
}
// R4: 类名映射(不允许出现任何 Bootstrap 按钮类)
const hasBootstrapBase = classes.some(c => BOOTSTRAP_BASE.has(c));
const hasBootstrapVar = classes.some(c => /^btn(-|$)/.test(c));
if (hasBootstrapBase || hasBootstrapVar) {
issues.push({rule: 'R4', msg: '检测到 Bootstrap 按钮类(btn/btn-xx),应全部迁移为 mat-btn-*'});
}
// R5: mat-btn-* 后缀合法性(后缀保持一致,但需在允许集合中)
const matBtnClasses = classes.filter(c => /^mat-btn-/.test(c));
matBtnClasses.forEach(c => {
const suffix = c.replace(/^mat-btn-/, '');
if (!ALLOWED_SUFFIX.has(suffix)) {
issues.push({rule: 'R5', msg: `mat-btn-* 后缀不在允许集合:${c}(允许:${[...ALLOWED_SUFFIX].join(', ')})`});
}
});
// R6: 图标按钮外观合理性(仅图标内容 -> 应为 mat-icon-button)
if (!isMenuItem && !isFab) {
const iconOnly = isIconOnly(innerHtml);
if (iconOnly) {
if (!/\bmat-icon-button\b/.test(openTag)) {
issues.push({rule: 'R6', msg: '仅图标内容的按钮应使用 mat-icon-button'});
}
} else {
// 非纯图标:若使用 mat-icon-button(而且内部存在可见文本),给出提示
if (/\bmat-icon-button\b/.test(openTag)) {
issues.push({rule: 'R6', msg: '包含可见文本的按钮不建议使用 mat-icon-button(请使用 mat-raised-button)'});
}
}
}
// R7: 已有目标外观的按钮不改动 ------ 校验阶段仅提示违规,不强制"应该被改"为另一外观
// (已通过 R2/R6 给出合理性提示)
return issues;
}
/* ------------------------ 主流程 ------------------------ */
async function validateFile(filePath) {
const content = await fs.readFile(filePath, 'utf8');
const buttons = [...content.matchAll(/<button\b[^>]*>[\s\S]*?<\/button>/gi)];
const fileIssues = [];
let buttonCount = 0;
let okCount = 0;
for (const m of buttons) {
buttonCount++;
const fullTag = m[0];
const startIdx = m.index ?? content.indexOf(fullTag);
const openTagMatch = fullTag.match(/^<\s*button\b[^>]*>/i);
if (!openTagMatch) continue;
const openTag = openTagMatch[0];
const inner = fullTag.slice(openTag.length, fullTag.lastIndexOf('</')).trim();
const issues = validateButton(openTag, inner);
if (issues.length) {
const line = lineOf(content, startIdx);
fileIssues.push({
line,
snippet: openTag.trim().slice(0, 240) + (openTag.length > 240 ? ' ...' : ''),
issues,
});
} else {
okCount++;
}
}
return {
file: filePath,
totalButtons: buttonCount,
passed: okCount,
failed: fileIssues.length,
details: fileIssues,
};
}
async function main() {
console.log(`Scanning: ${ROOT_DIR} (ext: ${EXT})`);
const files = await listFiles(ROOT_DIR, EXT);
const report = {
root: ROOT_DIR,
ext: EXT,
scannedFiles: files.length,
summary: {
totalButtons: 0,
passed: 0,
failedButtons: 0,
filesWithIssues: 0,
},
files: [],
};
for (const f of files) {
const res = await validateFile(f);
report.summary.totalButtons += res.totalButtons;
report.summary.passed += res.passed;
report.summary.failedButtons += res.failed;
if (res.failed > 0) report.summary.filesWithIssues += 1;
report.files.push(res);
// 控制台简报(只输出有问题的行)
if (res.failed > 0) {
console.log(`\n❌ ${path.relative(process.cwd(), f)} (${res.failed}/${res.totalButtons} buttons failed)`);
for (const row of res.details) {
console.log(` - line ${row.line}: ${row.snippet}`);
for (const i of row.issues) {
console.log(` [${i.rule}] ${i.msg}`);
}
}
} else {
console.log(`✓ ${path.relative(process.cwd(), f)} (${res.totalButtons} buttons OK)`);
}
}
console.log('\n========== Summary ==========');
console.log(`Files scanned : ${report.scannedFiles}`);
console.log(`Buttons scanned : ${report.summary.totalButtons}`);
console.log(`Buttons passed : ${report.summary.passed}`);
console.log(`Buttons failed : ${report.summary.failedButtons}`);
console.log(`Files with issues : ${report.summary.filesWithIssues}`);
if (JSON_OUT) {
await fs.writeFile(JSON_OUT, JSON.stringify(report, null, 2), 'utf8');
console.log(`\nJSON report saved: ${JSON_OUT}`);
}
}
main().catch(err => {
console.error(err);
process.exit(1);
});

3: 视觉回归和人工抽样检查
聚焦关键页面与交互态(hover/focus/disabled,涟漪与阴影)。
复盘: AI 是放大器,不是魔法棒
回溯整个过程
1. 先要"学文档":
要求 AI 总结版本差异与风险(例如:M3 不支持 color 属性);并让它给出代码现状报告(扫描 <button> 的分布与模式)。
2. 再要"可执行方案":
让 AI 输出脚本(例如 Node 的迁移脚本 + 验证脚本),并逐步收敛规则:
发现误改(例如改动 <a> 或与 mat-menu-item 冲突),就把规则加进 Prompt,并让 AI 只改脚本,不改其他元素。
3. 最后"验收与复盘":
扫描脚本(grep + validate),视觉回归。
记录问题与解决方案,某些细节可能会被AI忽略。(如 Tooltip 对禁用态不触发,material样式无效)。
部分文件未注册,导致material样式无效。还有上边提到的matTooltip不显示问题。

• 收益:约 70% 的重复性替换自动完成;验证脚本显著降低人工成本。
• 风险:规则疏漏会导致误改;版本差异(M3 色彩、组件搭配)必须前置明确。
• 改进:把边界、规则、目标变成 Prompt 的"硬约束";每次误改都写入 Prompt 作为新规则,逐轮收敛。
• 总结:把踩坑记录沉淀为工程手册(含脚本与检查项),下一次迁移可复用。
PS: 可复用指令:
1:迁移时
角色:前端高级工程师(Angular/Bootstrap/Material)
目标:将 Bootstrap 按钮迁移到 Angular Material(M3),只用 mat-raised-button / mat-icon-button
约束:
- 仅处理 <button>;不修改 <a>
- 已使用 Material 指令的按钮不改
- M3 主题,禁止使用 color 属性;改用类名映射(btn-* → mat-btn-*)
- 纯图标按钮用 mat-icon-button,其他用 mat-raised-button
- 保留 <i class="fa ..."> 图标
- 禁止与 mat-menu-item 等组件非法组合
产出: - 迁移脚本(Node 或你项目可执行的形式)
- 验证脚本(扫描违规组合)
- 风险清单与已知问题处理建议(如 Tooltip 与 disabled)
2:验收时
角色:高级测试工程师
背景:已完成 200 +文件的替换。
目标:在 1 个工作日内完成高效、可追溯的验收。
请按如下步骤执行并输出结果:
- 完整性校验:统计数量是否大于 200,并生成文件清单(含路径、大小、修改时间、哈希)。
- 一致性校验:对照基线(或模板/旧版)进行结构/格式/字段差异比对,输出差异摘要报告。
- 内容规则校验:按校验规则(编码、行结尾、命名规范、必填字段、禁止项)批量扫描并产出违规清单。
- 业务验收抽测:按风险分层抽样 ≥10% 进行深度检查(含边界用例),记录样本与结果。
- 自动化复核:执行自动化脚本或单元测试/校验器,输出通过率与失败明细。
- 报告与结论:汇总上述产物,给出通过/不通过结论、剩余问题与修复建议、复验计划。
- 交付物:file_inventory.csv、diff_report.md、rule_violations.csv、sample_checks.md、automation_test_report.json、final_acceptance.md。