系列文章第 7 篇收官之作上一篇《迁移不等于结束我们是如何验证生成代码的正确性的》一、引子为什么迁移平台也需要看得见我们花了 6 篇文章构建了一套相当硬核的迁移系统IR 作为语义中间层RuleEngine 以 90% 的覆盖率处理确定性代码LLM 安全地兜底复杂逻辑Validator 用四层验证筑起正确性防火墙SQL Diff 基于 Logical Plan 确保语义等价但有一个问题一直没解决整个系统跑起来用户看到的是什么一个命令行进度条一个 JSON 日志文件还是什么都不显示等几个小时再来看结果没有人愿意用一个盲盒系统不管它有多准确。所以我们需要一个驾驶舱Dashboard——让迁移过程中的每一个细节都看得见、摸得着、管得住。二、驾驶舱的核心需求在设计前端之前我们先明确三个核心诉求1. 实时性迁移是一个长时操作几十秒到几分钟。用户不能等全部完成后才看到结果必须实时感知进度。✅ UserEntity → rule (12ms) ✅ UserRepository → rule (8ms) UserService → llm (3.2s) ⚠️ UserController → failed (验证失败) OrderEntity → pending (等待中)2. 可诊断性当迁移失败时用户需要一眼定位问题哪个文件失败了失败原因是什么编译错误 / SQL Diff 失败 / 规范校验失败有没有 LLM 生成的有问题的代码可以查看3. 可决策性用户需要关键统计数据来评估迁移质量规则引擎命中率是多少LLM 使用了多少次Token 消耗多少SQL 等价率是多少有多少文件需要人工介入三、技术选型维度选择理由框架Vue 3 (Composition API)响应式系统天然适合实时数据UI 库Element Plus与 Vue 3 深度集成表单/表格开箱即用图表ECharts 5支持渐变色、动画、SVG 渲染通信WebSocket (STOMP)后端主动推送进度前端实时更新状态管理PiniaVue 3 官方推荐TypeScript 友好构建工具Vite秒级 HMR开发体验极佳四、整体布局设计┌──────────────────────────────────────────────────────────────┐ │ Crystal Migration Dashboard [v1.0.0] 运行中 │ ├──────────────────────────────────────────────────────────────┤ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │ │ │ 总文件 │ │ 成功率 │ │ 规则命中 │ │ LLM │ │ │ │ 23 │ │ 95.6% │ │ 92.3% │ │ 8% │ │ │ └──────────┘ └──────────┘ └──────────┘ └────────┘ │ ├──────────────────────────────────────────────────────────────┤ │ ┌──────────────────────────┐ ┌──────────────────────┐ │ │ │ 迁移进度条 (实时) │ │ 来源分布饼图 │ │ │ │ █████████████░░░ 80% │ │ ┌──────────────┐ │ │ │ │ │ │ │ 缓存 2 │ │ │ │ │ │ │ │ 规则 15 │ │ │ │ │ │ │ │ LLM 4 │ │ │ │ │ │ │ │ 失败 2 │ │ │ │ └──────────────────────────┘ │ └──────────────┘ │ │ │ ┌──────────────────────────┐ │ ┌──────────────────────┐ │ │ │ 文件列表实时更新 │ │ │ SQL 等价率 │ │ │ │ │ ✅ UserEntity rule │ │ │ ┌────────────┐ │ │ │ │ │ ✅ UserRepo rule │ │ │ │ 98.5% │ │ │ │ │ │ UserService llm │ │ │ │ ████████░ | │ │ │ │ │ ❌ UserCtrl failed │ │ │ └────────────┘ │ │ │ │ │ OrderEntity pending │ │ └──────────────────────┘ │ │ │ └──────────────────────────┘ └─────────────────────────────┘ │ ├──────────────────────────────────────────────────────────────┤ │ 开始迁移 │ ⏸️ 暂停 │ ⏭ 取消 │ 导出报告 │ └──────────────────────────────────────────────────────────────┘五、后端 WebSocket 推送1. WebSocket 配置Spring Boot// WebSocketConfig.java Configuration EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { Override public void configureMessageBroker(MessageBrokerRegistry config) { config.enableSimpleBroker(/topic); // 前端订阅前缀 config.setApplicationDestinationPrefixes(/app); // 前端发送前缀 } Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint(/ws/migration) .setAllowedOriginPatterns(*) // 生产环境要限制域名 .withSockJS(); // 降级支持 } }2. 进度推送器ProgressBroadcaster// ProgressBroadcaster.java Component RequiredArgsConstructor public class ProgressBroadcaster { private final SimpMessagingTemplate template; /** * 推送单个文件完成事件 */ public void broadcastFileCompleted(String projectName, FileMigrationResult result) { MigrationEvent event new MigrationEvent( projectName, FILE_COMPLETED, ProgressPayload.from(result) ); template.convertAndSend(/topic/migration/ projectName, event); } /** * 推送项目完成事件 */ public void broadcastProjectCompleted(String projectName, MigrationResult result) { MigrationEvent event new MigrationEvent( projectName, PROJECT_COMPLETED, result ); template.convertAndSend(/topic/migration/ projectName, event); } /** * 推送进度更新 */ public void broadcastProgress(String projectName, ProgressDto progress) { MigrationEvent event new MigrationEvent( projectName, PROGRESS_UPDATE, progress ); template.convertAndSend(/topic/migration/ projectName /progress, event); } public record MigrationEvent(String projectName, String type, Object payload) {} }3. 前端订阅Vue 3// useMigrationWebSocket.js (Composable) import { ref, onMounted, onUnmounted } from vue import SockJS from sockjs-client import Stomp from stompjs export function useMigrationWebSocket(projectName) { const messages ref([]) const progress ref(null) const isConnected ref(false) let stompClient null const connect () { const socket new SockJS(/ws/migration) stompClient Stomp.over(socket) stompClient.connect({}, (frame) { isConnected.value true // 订阅文件完成事件 stompClient.subscribe(/topic/migration/${projectName}, (msg) { const event JSON.parse(msg.body) handleEvent(event) }) // 订阅进度更新 stompClient.subscribe(/topic/migration/${projectName}/progress, (msg) { progress.value JSON.parse(msg.body) }) }) } const handleEvent (event) { switch (event.type) { case FILE_COMPLETED: messages.value.push(event.payload) break case PROJECT_COMPLETED: // 显示完成通知 ElNotification.success({ title: 迁移完成, message: 项目 ${event.projectName} 迁移完成 }) break } } const disconnect () { if (stompClient) { stompClient.disconnect() isConnected.value false } } onUnmounted(() disconnect()) return { messages, progress, isConnected, connect, disconnect } }六、核心组件实现1. 统计卡片StatsCards.vuetemplate div classstats-container el-card v-forcard in cards :keycard.title :class[stat-card, card.colorClass] shadowhover div classstat-value{{ card.value }}/div div classstat-title{{ card.title }}/div div v-ifcard.subtitle classstat-subtitle{{ card.subtitle }}/div /el-card /div /template script setup import { computed } from vue import { useMigrationStore } from /stores/migration const store useMigrationStore() const cards computed(() [ { title: 总文件, value: store.totalFiles, colorClass: blue, subtitle: 已完成 ${store.completedFiles} }, { title: 成功率, value: store.successRate %, colorClass: store.successRate 95 ? green : orange, subtitle: 成功 ${store.successCount} / 失败 ${store.failedCount} }, { title: 规则命中率, value: store.ruleHitRate %, colorClass: green, subtitle: LLM 仅使用 ${store.llmUsedCount} 次 }, { title: SQL 等价率, value: store.sqlEquivalenceRate %, colorClass: store.sqlEquivalenceRate 98 ? green : red, subtitle: Logical Plan 比较 } ]) /script style scoped .stats-container { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; } .stat-card { text-align: center; transition: transform 0.2s; } .stat-card:hover { transform: translateY(-2px); } .stat-value { font-size: 32px; font-weight: bold; line-height: 1.2; } .stat-title { font-size: 14px; color: #909399; margin-top: 8px; } .stat-subtitle { font-size: 12px; color: #c0c4cc; margin-top: 4px; } .blue .stat-value { color: #409eff; } .green .stat-value { color: #67c23a; } .orange .stat-value { color: #e6a23c; } .red .stat-value { color: #f56c6c; } /style2. 进度条ProgressBar.vuetemplate div classprogress-container div classprogress-header span classproject-name{{ projectName }}/span span classprogress-percent{{ percent }}%/span /div el-progress :percentagepercent :colorprogressColor :stroke-width20 :text-insidetrue striped striped-flow / div classprogress-details span{{ completed }} / {{ total }} 文件已完成/span span classelapsed-time{{ elapsedTime }}/span /div /div /template script setup import { computed } from vue const props defineProps({ projectName: String, total: Number, completed: Number, startTime: Number }) const percent computed(() props.total 0 ? 0 : Math.round((props.completed / props.total) * 100) ) const progressColor computed(() { if (percent.value 100) return #67c23a if (percent.value 70) return #409eff return #e6a23c }) const elapsedTime computed(() { if (!props.startTime) return 00:00 const seconds Math.floor((Date.now() - props.startTime) / 1000) const mins Math.floor(seconds / 60) const secs seconds % 60 return ${String(mins).padStart(2, 0)}:${String(secs).padStart(2, 0)} }) /script3. 来源分布饼图SourcePieChart.vuetemplate div classchart-container div refchartRef stylewidth: 100%; height: 280px;/div /div /template script setup import { ref, onMounted, watch } from vue import * as echarts from echarts import { useMigrationStore } from /stores/migration const chartRef ref(null) const store useMigrationStore() let chart null const initChart () { chart echarts.init(chartRef.value, light) const option { title: { text: 迁移来源分布, left: center, textStyle: { fontSize: 14, color: #303133 } }, tooltip: { trigger: item, formatter: {b}: {c} 个 ({d}%) }, legend: { orient: vertical, right: 10, top: center, textStyle: { fontSize: 12 } }, series: [{ type: pie, radius: [40%, 70%], center: [40%, 55%], avoidLabelOverlap: true, itemStyle: { borderRadius: 6, borderColor: #fff, borderWidth: 2 }, label: { show: false }, emphasis: { label: { show: true, fontSize: 14, fontWeight: bold } }, data: [ { value: store.cacheHitCount, name: 缓存命中, itemStyle: { color: #909399 }}, { value: store.ruleOnlyCount, name: 仅规则, itemStyle: { color: #409eff }}, { value: store.llmUsedCount, name: LLM 兜底, itemStyle: { color: #e6a23c }}, { value: store.failedCount, name: 迁移失败, itemStyle: { color: #f56c6c }} ] }] } chart.setOption(option) } watch( () [store.cacheHitCount, store.ruleOnlyCount, store.llmUsedCount, store.failedCount], () { if (chart) initChart() } ) onMounted(() initChart()) /script4. 文件列表FileList.vuetemplate div classfile-list el-table :datasortedFiles stylewidth: 100% stripe sizesmall el-table-column propfileName label文件名 min-width240 template #default{ row } span :class[file-icon, row.source] {{ row.fileName }} /span /template /el-table-column el-table-column propsource label来源 width100 aligncenter template #default{ row } el-tag :typesourceTagType(row.source) sizesmall {{ sourceLabel(row.source) }} /el-tag /template /el-table-column el-table-column propcostMs label耗时 width100 alignright template #default{ row } span :class{ slow: row.costMs 3000 } {{ formatTime(row.costMs) }} /span /template /el-table-column el-table-column label操作 width120 aligncenter template #default{ row } el-button v-ifrow.failed typedanger sizesmall click$emit(show-error, row) 查看错误 /el-button el-button v-ifrow.source llm typewarning sizesmall click$emit(show-code, row) 查看代码 /el-button /template /el-table-column /el-table /div /template script setup const props defineProps({ files: { type: Array, required: true } }) const sortedFiles computed(() [...props.files].sort((a, b) { // 失败排最前然后是 pending然后是成功的 const order { failed: 0, pending: 1, llm: 2, rule: 3, cache: 4 } return (order[a.source] ?? 9) - (order[b.source] ?? 9) }) ) const sourceTagType (source) { return { cache: info, rule: , llm: warning, failed: danger }[source] ?? info } const sourceLabel (source) { return { cache: 缓存, rule: 规则, llm: LLM, failed: 失败 }[source] ?? source } const formatTime (ms) { if (ms 1000) return ms ms return (ms / 1000).toFixed(1) s } /script style scoped .file-icon.cache::before { content: ; } .file-icon.rule::before { content: ✅ ; } .file-icon.llm::before { content: ; } .file-icon.failed::before { content: ❌ ; } .file-icon.pending::before { content: ; } .slow { color: #f56c6c; font-weight: bold; } /style七、主页面集成Dashboard.vuetemplate div classdashboard header classdashboard-header h1 Crystal Migration Dashboard/h1 div classheader-actions el-button typeprimary clickstartMigration :loadingisRunning 开始迁移 /el-button el-button clickexportReport :disabled!store.isComplete 导出报告 /el-button /div /header main classdashboard-main !-- 统计卡片 -- StatsCards / div classdashboard-grid !-- 进度条 -- div classgrid-item progress-item ProgressBar :projectNamecurrentProject :totalstore.totalFiles :completedstore.completedFiles :startTimestore.startTime / /div !-- 来源分布饼图 -- div classgrid-item chart-item SourcePieChart / /div !-- SQL 等价率仪表盘 -- div classgrid-item gauge-item SqlEquivalenceGauge / /div /div !-- 文件列表 -- div classfile-list-section h3文件迁移详情/h3 FileList :filesstore.fileResults show-errorshowErrorDialog show-codeshowCodeDialog / /div /main !-- 错误详情对话框 -- el-dialog v-modelerrorDialogVisible title迁移错误详情 width700px pre classerror-detail{{ selectedError }}/pre /el-dialog /div /template script setup import { ref } from vue import { useMigrationStore } from /stores/migration import StatsCards from /components/StatsCards.vue import ProgressBar from /components/ProgressBar.vue import SourcePieChart from /components/SourcePieChart.vue import FileList from /components/FileList.vue const store useMigrationStore() const currentProject ref(UserManagement) const errorDialogVisible ref(false) const selectedError ref() const startMigration async () { await store.startMigration(currentProject.value) } const exportReport () { const report store.generateReport() const blob new Blob([JSON.stringify(report, null, 2)], { type: application/json }) const url URL.createObjectURL(blob) const a document.createElement(a) a.href url a.download migration-report-${Date.now()}.json a.click() URL.revokeObjectURL(url) } const showErrorDialog (file) { selectedError.value file.errorMessage || 未知错误 errorDialogVisible.value true } /script八、Pinia 状态管理import { defineStore } from pinia import { ref, computed } from vue import axios from axios export const useMigrationStore defineStore(migration, () { const totalFiles ref(0) const completedFiles ref(0) const successCount ref(0) const failedCount ref(0) const cacheHitCount ref(0) const ruleOnlyCount ref(0) const llmUsedCount ref(0) const startTime ref(null) const fileResults ref([]) const isRunning ref(false) const successRate computed(() totalFiles.value 0 ? 0 : Math.round((successCount.value / totalFiles.value) * 100) ) const ruleHitRate computed(() { const total ruleOnlyCount.value llmUsedCount.value return total 0 ? 0 : Math.round((ruleOnlyCount.value / total) * 100) }) const startMigration async (projectPath) { isRunning.value true startTime.value Date.now() fileResults.value [] try { const response await axios.post(/api/migration/run, { projectPath }) // WebSocket 会推送详细进度这里只需要等待最终完成 Object.assign(response.data, { totalFiles, completedFiles, successCount, failedCount, cacheHitCount, ruleOnlyCount, llmUsedCount }) } catch (error) { ElNotification.error({ title: 迁移失败, message: error.message }) } finally { isRunning.value false } } const addFileResult (result) { fileResults.value.push(result) completedFiles.value if (result.success) successCount.value else failedCount.value switch (result.source) { case cache: cacheHitCount.value; break case rule: ruleOnlyCount.value; break case llm: llmUsedCount.value; break } } return { totalFiles, completedFiles, successCount, failedCount, cacheHitCount, ruleOnlyCount, llmUsedCount, startTime, fileResults, isRunning, successRate, ruleHitRate, startMigration, addFileResult } })九、最终效果完整的驾驶舱让迁移过程变得透明、可控、可诊断启动迁移 → 进度条实时推进文件列表逐行更新规则命中 → 绿色卡片跳动饼图实时刷新LLM 兜底 → 黄色标记高亮点击可查看生成的代码迁移失败 → 红色高亮点击查看详细错误堆栈全部完成 → 弹出通知可一键导出 JSON 报告十、系列总结6 篇文章我们从无到有构建了一套完整的 C# → Java 代码迁移系统篇目核心思想第 1 篇总览规则优先 LLM 兜底 验证闭环 的架构哲学第 2 篇IR 设计用语言无关的中间表示解耦语法差异第 3 篇RuleEngine确定性规则覆盖 90% 的迁移工作第 4 篇LLM 兜底把 LLM关进笼子安全使用 AI第 5 篇JOIN 迁移用Logical Plan 解决 SqlSugar → MPJ 的语义鸿沟第 6 篇验证闭环四层验证筑起正确性防火墙第 7 篇驾驶舱本文用Vue3 ECharts 让迁移过程透明可控核心启示代码迁移的本质不是翻译文本而是搬运语义。IR 是语义的容器规则是搬运的工具LLM 是特种兵验证是安全的底线而驾驶舱是让这一切被人信任的窗口。系列目录从 C# 到 Java我们是如何把老系统无痛迁到 Crystal Framework 的代码迁移的中间语言为什么我们要设计一套 IRRuleEngine让 90% 的代码迁移不再依赖大模型LLM 兜底如何安全地使用大模型处理复杂逻辑SqlSugar 的 JOIN 如何优雅地迁移到 MyBatis-Plus-Join迁移不等于结束我们是如何验证生成代码的正确性的给迁移平台做一个驾驶舱Vue3 ECharts 实战本文如果你正在做类似的工程化项目或者对这个迁移系统的任何细节有疑问欢迎在评论区交流。
给迁移平台做一个“驾驶舱“:Vue3 + ECharts 实战
系列文章第 7 篇收官之作上一篇《迁移不等于结束我们是如何验证生成代码的正确性的》一、引子为什么迁移平台也需要看得见我们花了 6 篇文章构建了一套相当硬核的迁移系统IR 作为语义中间层RuleEngine 以 90% 的覆盖率处理确定性代码LLM 安全地兜底复杂逻辑Validator 用四层验证筑起正确性防火墙SQL Diff 基于 Logical Plan 确保语义等价但有一个问题一直没解决整个系统跑起来用户看到的是什么一个命令行进度条一个 JSON 日志文件还是什么都不显示等几个小时再来看结果没有人愿意用一个盲盒系统不管它有多准确。所以我们需要一个驾驶舱Dashboard——让迁移过程中的每一个细节都看得见、摸得着、管得住。二、驾驶舱的核心需求在设计前端之前我们先明确三个核心诉求1. 实时性迁移是一个长时操作几十秒到几分钟。用户不能等全部完成后才看到结果必须实时感知进度。✅ UserEntity → rule (12ms) ✅ UserRepository → rule (8ms) UserService → llm (3.2s) ⚠️ UserController → failed (验证失败) OrderEntity → pending (等待中)2. 可诊断性当迁移失败时用户需要一眼定位问题哪个文件失败了失败原因是什么编译错误 / SQL Diff 失败 / 规范校验失败有没有 LLM 生成的有问题的代码可以查看3. 可决策性用户需要关键统计数据来评估迁移质量规则引擎命中率是多少LLM 使用了多少次Token 消耗多少SQL 等价率是多少有多少文件需要人工介入三、技术选型维度选择理由框架Vue 3 (Composition API)响应式系统天然适合实时数据UI 库Element Plus与 Vue 3 深度集成表单/表格开箱即用图表ECharts 5支持渐变色、动画、SVG 渲染通信WebSocket (STOMP)后端主动推送进度前端实时更新状态管理PiniaVue 3 官方推荐TypeScript 友好构建工具Vite秒级 HMR开发体验极佳四、整体布局设计┌──────────────────────────────────────────────────────────────┐ │ Crystal Migration Dashboard [v1.0.0] 运行中 │ ├──────────────────────────────────────────────────────────────┤ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │ │ │ 总文件 │ │ 成功率 │ │ 规则命中 │ │ LLM │ │ │ │ 23 │ │ 95.6% │ │ 92.3% │ │ 8% │ │ │ └──────────┘ └──────────┘ └──────────┘ └────────┘ │ ├──────────────────────────────────────────────────────────────┤ │ ┌──────────────────────────┐ ┌──────────────────────┐ │ │ │ 迁移进度条 (实时) │ │ 来源分布饼图 │ │ │ │ █████████████░░░ 80% │ │ ┌──────────────┐ │ │ │ │ │ │ │ 缓存 2 │ │ │ │ │ │ │ │ 规则 15 │ │ │ │ │ │ │ │ LLM 4 │ │ │ │ │ │ │ │ 失败 2 │ │ │ │ └──────────────────────────┘ │ └──────────────┘ │ │ │ ┌──────────────────────────┐ │ ┌──────────────────────┐ │ │ │ 文件列表实时更新 │ │ │ SQL 等价率 │ │ │ │ │ ✅ UserEntity rule │ │ │ ┌────────────┐ │ │ │ │ │ ✅ UserRepo rule │ │ │ │ 98.5% │ │ │ │ │ │ UserService llm │ │ │ │ ████████░ | │ │ │ │ │ ❌ UserCtrl failed │ │ │ └────────────┘ │ │ │ │ │ OrderEntity pending │ │ └──────────────────────┘ │ │ │ └──────────────────────────┘ └─────────────────────────────┘ │ ├──────────────────────────────────────────────────────────────┤ │ 开始迁移 │ ⏸️ 暂停 │ ⏭ 取消 │ 导出报告 │ └──────────────────────────────────────────────────────────────┘五、后端 WebSocket 推送1. WebSocket 配置Spring Boot// WebSocketConfig.java Configuration EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { Override public void configureMessageBroker(MessageBrokerRegistry config) { config.enableSimpleBroker(/topic); // 前端订阅前缀 config.setApplicationDestinationPrefixes(/app); // 前端发送前缀 } Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint(/ws/migration) .setAllowedOriginPatterns(*) // 生产环境要限制域名 .withSockJS(); // 降级支持 } }2. 进度推送器ProgressBroadcaster// ProgressBroadcaster.java Component RequiredArgsConstructor public class ProgressBroadcaster { private final SimpMessagingTemplate template; /** * 推送单个文件完成事件 */ public void broadcastFileCompleted(String projectName, FileMigrationResult result) { MigrationEvent event new MigrationEvent( projectName, FILE_COMPLETED, ProgressPayload.from(result) ); template.convertAndSend(/topic/migration/ projectName, event); } /** * 推送项目完成事件 */ public void broadcastProjectCompleted(String projectName, MigrationResult result) { MigrationEvent event new MigrationEvent( projectName, PROJECT_COMPLETED, result ); template.convertAndSend(/topic/migration/ projectName, event); } /** * 推送进度更新 */ public void broadcastProgress(String projectName, ProgressDto progress) { MigrationEvent event new MigrationEvent( projectName, PROGRESS_UPDATE, progress ); template.convertAndSend(/topic/migration/ projectName /progress, event); } public record MigrationEvent(String projectName, String type, Object payload) {} }3. 前端订阅Vue 3// useMigrationWebSocket.js (Composable) import { ref, onMounted, onUnmounted } from vue import SockJS from sockjs-client import Stomp from stompjs export function useMigrationWebSocket(projectName) { const messages ref([]) const progress ref(null) const isConnected ref(false) let stompClient null const connect () { const socket new SockJS(/ws/migration) stompClient Stomp.over(socket) stompClient.connect({}, (frame) { isConnected.value true // 订阅文件完成事件 stompClient.subscribe(/topic/migration/${projectName}, (msg) { const event JSON.parse(msg.body) handleEvent(event) }) // 订阅进度更新 stompClient.subscribe(/topic/migration/${projectName}/progress, (msg) { progress.value JSON.parse(msg.body) }) }) } const handleEvent (event) { switch (event.type) { case FILE_COMPLETED: messages.value.push(event.payload) break case PROJECT_COMPLETED: // 显示完成通知 ElNotification.success({ title: 迁移完成, message: 项目 ${event.projectName} 迁移完成 }) break } } const disconnect () { if (stompClient) { stompClient.disconnect() isConnected.value false } } onUnmounted(() disconnect()) return { messages, progress, isConnected, connect, disconnect } }六、核心组件实现1. 统计卡片StatsCards.vuetemplate div classstats-container el-card v-forcard in cards :keycard.title :class[stat-card, card.colorClass] shadowhover div classstat-value{{ card.value }}/div div classstat-title{{ card.title }}/div div v-ifcard.subtitle classstat-subtitle{{ card.subtitle }}/div /el-card /div /template script setup import { computed } from vue import { useMigrationStore } from /stores/migration const store useMigrationStore() const cards computed(() [ { title: 总文件, value: store.totalFiles, colorClass: blue, subtitle: 已完成 ${store.completedFiles} }, { title: 成功率, value: store.successRate %, colorClass: store.successRate 95 ? green : orange, subtitle: 成功 ${store.successCount} / 失败 ${store.failedCount} }, { title: 规则命中率, value: store.ruleHitRate %, colorClass: green, subtitle: LLM 仅使用 ${store.llmUsedCount} 次 }, { title: SQL 等价率, value: store.sqlEquivalenceRate %, colorClass: store.sqlEquivalenceRate 98 ? green : red, subtitle: Logical Plan 比较 } ]) /script style scoped .stats-container { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; } .stat-card { text-align: center; transition: transform 0.2s; } .stat-card:hover { transform: translateY(-2px); } .stat-value { font-size: 32px; font-weight: bold; line-height: 1.2; } .stat-title { font-size: 14px; color: #909399; margin-top: 8px; } .stat-subtitle { font-size: 12px; color: #c0c4cc; margin-top: 4px; } .blue .stat-value { color: #409eff; } .green .stat-value { color: #67c23a; } .orange .stat-value { color: #e6a23c; } .red .stat-value { color: #f56c6c; } /style2. 进度条ProgressBar.vuetemplate div classprogress-container div classprogress-header span classproject-name{{ projectName }}/span span classprogress-percent{{ percent }}%/span /div el-progress :percentagepercent :colorprogressColor :stroke-width20 :text-insidetrue striped striped-flow / div classprogress-details span{{ completed }} / {{ total }} 文件已完成/span span classelapsed-time{{ elapsedTime }}/span /div /div /template script setup import { computed } from vue const props defineProps({ projectName: String, total: Number, completed: Number, startTime: Number }) const percent computed(() props.total 0 ? 0 : Math.round((props.completed / props.total) * 100) ) const progressColor computed(() { if (percent.value 100) return #67c23a if (percent.value 70) return #409eff return #e6a23c }) const elapsedTime computed(() { if (!props.startTime) return 00:00 const seconds Math.floor((Date.now() - props.startTime) / 1000) const mins Math.floor(seconds / 60) const secs seconds % 60 return ${String(mins).padStart(2, 0)}:${String(secs).padStart(2, 0)} }) /script3. 来源分布饼图SourcePieChart.vuetemplate div classchart-container div refchartRef stylewidth: 100%; height: 280px;/div /div /template script setup import { ref, onMounted, watch } from vue import * as echarts from echarts import { useMigrationStore } from /stores/migration const chartRef ref(null) const store useMigrationStore() let chart null const initChart () { chart echarts.init(chartRef.value, light) const option { title: { text: 迁移来源分布, left: center, textStyle: { fontSize: 14, color: #303133 } }, tooltip: { trigger: item, formatter: {b}: {c} 个 ({d}%) }, legend: { orient: vertical, right: 10, top: center, textStyle: { fontSize: 12 } }, series: [{ type: pie, radius: [40%, 70%], center: [40%, 55%], avoidLabelOverlap: true, itemStyle: { borderRadius: 6, borderColor: #fff, borderWidth: 2 }, label: { show: false }, emphasis: { label: { show: true, fontSize: 14, fontWeight: bold } }, data: [ { value: store.cacheHitCount, name: 缓存命中, itemStyle: { color: #909399 }}, { value: store.ruleOnlyCount, name: 仅规则, itemStyle: { color: #409eff }}, { value: store.llmUsedCount, name: LLM 兜底, itemStyle: { color: #e6a23c }}, { value: store.failedCount, name: 迁移失败, itemStyle: { color: #f56c6c }} ] }] } chart.setOption(option) } watch( () [store.cacheHitCount, store.ruleOnlyCount, store.llmUsedCount, store.failedCount], () { if (chart) initChart() } ) onMounted(() initChart()) /script4. 文件列表FileList.vuetemplate div classfile-list el-table :datasortedFiles stylewidth: 100% stripe sizesmall el-table-column propfileName label文件名 min-width240 template #default{ row } span :class[file-icon, row.source] {{ row.fileName }} /span /template /el-table-column el-table-column propsource label来源 width100 aligncenter template #default{ row } el-tag :typesourceTagType(row.source) sizesmall {{ sourceLabel(row.source) }} /el-tag /template /el-table-column el-table-column propcostMs label耗时 width100 alignright template #default{ row } span :class{ slow: row.costMs 3000 } {{ formatTime(row.costMs) }} /span /template /el-table-column el-table-column label操作 width120 aligncenter template #default{ row } el-button v-ifrow.failed typedanger sizesmall click$emit(show-error, row) 查看错误 /el-button el-button v-ifrow.source llm typewarning sizesmall click$emit(show-code, row) 查看代码 /el-button /template /el-table-column /el-table /div /template script setup const props defineProps({ files: { type: Array, required: true } }) const sortedFiles computed(() [...props.files].sort((a, b) { // 失败排最前然后是 pending然后是成功的 const order { failed: 0, pending: 1, llm: 2, rule: 3, cache: 4 } return (order[a.source] ?? 9) - (order[b.source] ?? 9) }) ) const sourceTagType (source) { return { cache: info, rule: , llm: warning, failed: danger }[source] ?? info } const sourceLabel (source) { return { cache: 缓存, rule: 规则, llm: LLM, failed: 失败 }[source] ?? source } const formatTime (ms) { if (ms 1000) return ms ms return (ms / 1000).toFixed(1) s } /script style scoped .file-icon.cache::before { content: ; } .file-icon.rule::before { content: ✅ ; } .file-icon.llm::before { content: ; } .file-icon.failed::before { content: ❌ ; } .file-icon.pending::before { content: ; } .slow { color: #f56c6c; font-weight: bold; } /style七、主页面集成Dashboard.vuetemplate div classdashboard header classdashboard-header h1 Crystal Migration Dashboard/h1 div classheader-actions el-button typeprimary clickstartMigration :loadingisRunning 开始迁移 /el-button el-button clickexportReport :disabled!store.isComplete 导出报告 /el-button /div /header main classdashboard-main !-- 统计卡片 -- StatsCards / div classdashboard-grid !-- 进度条 -- div classgrid-item progress-item ProgressBar :projectNamecurrentProject :totalstore.totalFiles :completedstore.completedFiles :startTimestore.startTime / /div !-- 来源分布饼图 -- div classgrid-item chart-item SourcePieChart / /div !-- SQL 等价率仪表盘 -- div classgrid-item gauge-item SqlEquivalenceGauge / /div /div !-- 文件列表 -- div classfile-list-section h3文件迁移详情/h3 FileList :filesstore.fileResults show-errorshowErrorDialog show-codeshowCodeDialog / /div /main !-- 错误详情对话框 -- el-dialog v-modelerrorDialogVisible title迁移错误详情 width700px pre classerror-detail{{ selectedError }}/pre /el-dialog /div /template script setup import { ref } from vue import { useMigrationStore } from /stores/migration import StatsCards from /components/StatsCards.vue import ProgressBar from /components/ProgressBar.vue import SourcePieChart from /components/SourcePieChart.vue import FileList from /components/FileList.vue const store useMigrationStore() const currentProject ref(UserManagement) const errorDialogVisible ref(false) const selectedError ref() const startMigration async () { await store.startMigration(currentProject.value) } const exportReport () { const report store.generateReport() const blob new Blob([JSON.stringify(report, null, 2)], { type: application/json }) const url URL.createObjectURL(blob) const a document.createElement(a) a.href url a.download migration-report-${Date.now()}.json a.click() URL.revokeObjectURL(url) } const showErrorDialog (file) { selectedError.value file.errorMessage || 未知错误 errorDialogVisible.value true } /script八、Pinia 状态管理import { defineStore } from pinia import { ref, computed } from vue import axios from axios export const useMigrationStore defineStore(migration, () { const totalFiles ref(0) const completedFiles ref(0) const successCount ref(0) const failedCount ref(0) const cacheHitCount ref(0) const ruleOnlyCount ref(0) const llmUsedCount ref(0) const startTime ref(null) const fileResults ref([]) const isRunning ref(false) const successRate computed(() totalFiles.value 0 ? 0 : Math.round((successCount.value / totalFiles.value) * 100) ) const ruleHitRate computed(() { const total ruleOnlyCount.value llmUsedCount.value return total 0 ? 0 : Math.round((ruleOnlyCount.value / total) * 100) }) const startMigration async (projectPath) { isRunning.value true startTime.value Date.now() fileResults.value [] try { const response await axios.post(/api/migration/run, { projectPath }) // WebSocket 会推送详细进度这里只需要等待最终完成 Object.assign(response.data, { totalFiles, completedFiles, successCount, failedCount, cacheHitCount, ruleOnlyCount, llmUsedCount }) } catch (error) { ElNotification.error({ title: 迁移失败, message: error.message }) } finally { isRunning.value false } } const addFileResult (result) { fileResults.value.push(result) completedFiles.value if (result.success) successCount.value else failedCount.value switch (result.source) { case cache: cacheHitCount.value; break case rule: ruleOnlyCount.value; break case llm: llmUsedCount.value; break } } return { totalFiles, completedFiles, successCount, failedCount, cacheHitCount, ruleOnlyCount, llmUsedCount, startTime, fileResults, isRunning, successRate, ruleHitRate, startMigration, addFileResult } })九、最终效果完整的驾驶舱让迁移过程变得透明、可控、可诊断启动迁移 → 进度条实时推进文件列表逐行更新规则命中 → 绿色卡片跳动饼图实时刷新LLM 兜底 → 黄色标记高亮点击可查看生成的代码迁移失败 → 红色高亮点击查看详细错误堆栈全部完成 → 弹出通知可一键导出 JSON 报告十、系列总结6 篇文章我们从无到有构建了一套完整的 C# → Java 代码迁移系统篇目核心思想第 1 篇总览规则优先 LLM 兜底 验证闭环 的架构哲学第 2 篇IR 设计用语言无关的中间表示解耦语法差异第 3 篇RuleEngine确定性规则覆盖 90% 的迁移工作第 4 篇LLM 兜底把 LLM关进笼子安全使用 AI第 5 篇JOIN 迁移用Logical Plan 解决 SqlSugar → MPJ 的语义鸿沟第 6 篇验证闭环四层验证筑起正确性防火墙第 7 篇驾驶舱本文用Vue3 ECharts 让迁移过程透明可控核心启示代码迁移的本质不是翻译文本而是搬运语义。IR 是语义的容器规则是搬运的工具LLM 是特种兵验证是安全的底线而驾驶舱是让这一切被人信任的窗口。系列目录从 C# 到 Java我们是如何把老系统无痛迁到 Crystal Framework 的代码迁移的中间语言为什么我们要设计一套 IRRuleEngine让 90% 的代码迁移不再依赖大模型LLM 兜底如何安全地使用大模型处理复杂逻辑SqlSugar 的 JOIN 如何优雅地迁移到 MyBatis-Plus-Join迁移不等于结束我们是如何验证生成代码的正确性的给迁移平台做一个驾驶舱Vue3 ECharts 实战本文如果你正在做类似的工程化项目或者对这个迁移系统的任何细节有疑问欢迎在评论区交流。