refactor: 前端构建按需化与 Mock 懒加载

移除全量 Element Plus 与全局 VChart 注册,改为按需引入样式与组件内局部图表;Mock 适配器改为按 VITE_ENABLE_MOCK 环境变量懒加载,生产默认不拦截请求;ECharts 精简为看板所需图表,各视图与 stores 同步适配。
This commit is contained in:
caoxiaozhu
2026-07-16 11:03:54 +08:00
parent ab9e87f948
commit 4173b53b1b
18 changed files with 137 additions and 92 deletions

View File

@@ -26,6 +26,15 @@ npm run dev
后端 API 默认通过 Vite 代理转发到 `http://localhost:7861`(见 `vite.config.ts`)。
开发环境默认启用前端 Mock。如需联调真实后端使用
```bash
VITE_ENABLE_MOCK=false npm run dev
```
生产构建默认不包含 Mock仅在演示构建中可显式设置
`VITE_ENABLE_MOCK=true`
## 构建
```bash

View File

@@ -117,7 +117,8 @@ assert.match(
assert.match(source, /import \{ getSystemInfo \} from '@\/api\/modules\/system'/, '训练概览必须复用系统 GPU 监控数据源')
assert.match(source, /Promise\.all\(\[datasetPromise, loadLog\(currentTask\), loadGpuStatus\(\)\]\)/, 'GPU 状态必须和训练日志一起刷新')
assert.match(source, /if \(refreshInFlight\) return/, '轮询刷新必须阻止并发重叠')
assert.match(source, /onUnmounted\([\s\S]*?clearInterval\(timer\)/, '组件卸载时必须清理轮询定时器')
assert.match(source, /usePolling/, '训练日志必须使用统一轮询机制')
assert.match(source, /stopPolling\(\)/, '训练结束后必须停止轮询')
const overview = findElements(
templateAst,

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
// 根组件,仅承载路由出口
import zhCn from 'element-plus/es/locale/lang/zh-cn'
</script>
<template>
<el-config-provider :locale="zhCn">
<router-view />
</el-config-provider>
</template>

View File

@@ -17,10 +17,6 @@ const service: AxiosInstance = axios.create({
timeout: 30000,
})
// 安装 mock adapter拦截所有 axios 请求返回 mock 数据,方便前端独立开发调试)
import { installMockAdapter } from '@/mock/adapter'
installMockAdapter(service)
// 请求拦截器
service.interceptors.request.use(
(config) => config,

View File

@@ -1,5 +1,14 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** 是否启用前端 Mock开发环境默认开启生产环境默认关闭。 */
readonly VITE_ENABLE_MOCK?: 'true' | 'false'
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import '@/assets/font-awesome/css/font-awesome.min.css'
import AppSidebar from '@/components/AppSidebar.vue'
import AppHeader from '@/components/AppHeader.vue'
import { useSystemStore } from '@/stores/system'

View File

@@ -1,17 +1,13 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
// Font Awesome 图标
import '@/assets/font-awesome/css/font-awesome.min.css'
// ECharts按需引入+ vue-echarts 组件
import '@/plugins/echarts'
import VChart from 'vue-echarts'
// 这三个能力通过函数或指令使用,无法由模板组件扫描自动补充样式。
import 'element-plus/es/components/message/style/css'
import 'element-plus/es/components/message-box/style/css'
import 'element-plus/es/components/loading/style/css'
import App from './App.vue'
import service from '@/api/request'
import router from './router'
import './styles/index.scss'
@@ -19,7 +15,19 @@ const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.component('VChart', VChart)
app.mount('#app')
async function bootstrap() {
// 开发环境默认使用 Mock生产环境只有显式开启时才加载整套 Mock 数据。
// 这样真实部署不会被前端适配器截断请求,也不会把 Mock 数据打进首屏包。
const shouldEnableMock = import.meta.env.VITE_ENABLE_MOCK === 'true'
|| (import.meta.env.DEV && import.meta.env.VITE_ENABLE_MOCK !== 'false')
if (shouldEnableMock) {
const { installMockAdapter } = await import('@/mock/adapter')
installMockAdapter(service)
}
app.mount('#app')
}
void bootstrap()

View File

@@ -1,26 +1,20 @@
/**
* ECharts 按需引入
* 仅注册训练曲线所需的模块,避免引入全量包以减小体积
* 服务看板图表按需注册。
*/
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { BarChart, LineChart, PieChart } from 'echarts/charts'
import { BarChart, PieChart } from 'echarts/charts'
import {
GridComponent,
TooltipComponent,
LegendComponent,
DataZoomComponent,
MarkLineComponent,
} from 'echarts/components'
use([
CanvasRenderer,
BarChart,
LineChart,
PieChart,
GridComponent,
TooltipComponent,
LegendComponent,
DataZoomComponent,
MarkLineComponent,
])

View File

@@ -10,15 +10,24 @@ import type { ModelItem } from '@/types'
export const useModelsStore = defineStore('models', () => {
const list = ref<ModelItem[]>([])
const loaded = ref(false)
let pendingLoad: Promise<void> | null = null
async function load(force = false) {
if (loaded.value && !force) return
if (pendingLoad && !force) return pendingLoad
pendingLoad = (async () => {
try {
list.value = (await getModelList()) || []
loaded.value = true
} catch {
list.value = []
} finally {
pendingLoad = null
}
})()
return pendingLoad
}
/** 根据 id 获取模型名 */

View File

@@ -10,19 +10,29 @@ import type { HealthMetrics } from '@/types'
export const useSystemStore = defineStore('system', () => {
const metrics = ref<HealthMetrics>({})
let timer: ReturnType<typeof setInterval> | null = null
let fetching = false
async function fetchMetrics() {
if (fetching || document.hidden) return
fetching = true
try {
metrics.value = await getHealth()
} catch {
// 静默失败,顶部栏非关键
} finally {
fetching = false
}
}
function handleVisibilityChange() {
if (!document.hidden) void fetchMetrics()
}
function start() {
if (timer) return
fetchMetrics()
void fetchMetrics()
timer = setInterval(fetchMetrics, 30000)
document.addEventListener('visibilitychange', handleVisibilityChange)
}
function stop() {
@@ -30,6 +40,7 @@ export const useSystemStore = defineStore('system', () => {
clearInterval(timer)
timer = null
}
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
return { metrics, fetchMetrics, start, stop }

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import VChart from 'vue-echarts'
import '@/plugins/echarts'
import type { EChartsOption } from 'echarts'
type ServiceState = 'normal' | 'busy' | 'error'
@@ -253,7 +255,7 @@ const loginDurationStats: LoginDurationStat[] = [
const loginDurationChartOption = computed<EChartsOption>(() => ({
animationDuration: 500,
grid: { top: 8, right: 60, bottom: 6, left: 8, containLabel: true },
grid: { top: 8, right: 12, bottom: 6, left: 8, containLabel: true },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
@@ -284,7 +286,7 @@ const loginDurationChartOption = computed<EChartsOption>(() => ({
barMaxWidth: 18,
barCategoryGap: '34%',
itemStyle: { color: '#4f46e5', borderRadius: [0, 4, 4, 0] },
label: { show: true, position: 'right', distance: 6, color: '#64748b', fontSize: 11, formatter: '{c} 小时' },
label: { show: true, position: 'insideRight', distance: 6, color: '#ffffff', fontSize: 11, formatter: '{c} 小时' },
},
],
}))

View File

@@ -1,11 +1,16 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { MdEditor } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import { computed, defineAsyncComponent, watch } from 'vue'
import { EVAL_METHODS, EVAL_METHOD_PROMPTS } from '@/constants/dimension'
import { DIMENSION_TYPE_MAP } from '@/constants'
import type { DimensionType, ModelItem } from '@/types'
// 编辑器体积较大,仅在用户进入包含 Prompt 的指标配置时加载。
const MdEditor = defineAsyncComponent(async () => {
await import('md-editor-v3/lib/style.css')
const module = await import('md-editor-v3')
return module.MdEditor
})
export interface DimensionFormDraft {
type: DimensionType | ''
description: string

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import DataTablePage from '@/components/DataTablePage.vue'
import ModelStatusTag from '@/components/ModelStatusTag.vue'
import { usePolling } from '@/composables/usePolling'
import { useModelsStore } from '@/stores/models'
import {
getFineTuneList,
@@ -44,14 +45,10 @@ const filteredList = computed(() => {
})
})
let progressTimer: ReturnType<typeof setInterval> | null = null
async function loadData() {
loading.value = true
try {
dataList.value = (await getFineTuneList()) || []
// 列表加载完成后,立即获取一次运行中任务的进度
refreshProgress()
} catch {
// 拦截器已提示
} finally {
@@ -101,14 +98,13 @@ function formatDateTime(value?: string) {
return new Date(value).toLocaleString('zh-CN', { hour12: false })
}
onMounted(() => {
modelsStore.load()
loadData()
progressTimer = setInterval(refreshProgress, 5000)
})
const { start: startProgressPolling } = usePolling(refreshProgress, 5000, { immediate: false })
onUnmounted(() => {
if (progressTimer) clearInterval(progressTimer)
onMounted(async () => {
void modelsStore.load()
await loadData()
await refreshProgress()
startProgressPolling()
})
</script>

View File

@@ -136,7 +136,7 @@ async function handleLogin() {
position: relative;
color: #fff;
background-color: #121127;
background-image: url('@/assets/login-hero-flow.png');
background-image: url('@/assets/login-hero-flow.jpg');
background-size: cover;
background-position: center;
overflow: hidden;

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import VChart from 'vue-echarts'
import PageCard from '@/components/PageCard.vue'
import '@/plugins/echarts-hardware'
import { getSystemInfo } from '@/api/modules/system'
import type { GpuInfo, SystemInfo } from '@/types'

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import PageCard from '@/components/PageCard.vue'
import { useCountdown } from '@/composables/useCountdown'
import { usePolling } from '@/composables/usePolling'
import {
getLogFiles,
getLogContent,
@@ -30,29 +31,26 @@ const fullContent = ref('')
// 自动刷新
const refreshInterval = ref(10)
const { remaining, start: startCountdown, stop: stopCountdown } = useCountdown(10)
let refreshTimer: ReturnType<typeof setInterval> | null = null
const filteredContent = computed(() => {
if (!keyword.value.trim()) return fullContent.value
const filteredLog = computed(() => {
if (!keyword.value.trim()) return { content: fullContent.value, count: 0 }
const kw = keyword.value.toLowerCase().trim()
return fullContent.value
const lines = fullContent.value
.split('\n')
.filter((line) => line.toLowerCase().includes(kw))
.join('\n')
return { content: lines.join('\n'), count: lines.length }
})
const matchCount = computed(() => {
if (!keyword.value.trim()) return 0
const kw = keyword.value.toLowerCase().trim()
return fullContent.value.split('\n').filter((line) => line.toLowerCase().includes(kw)).length
})
const filteredContent = computed(() => filteredLog.value.content)
const matchCount = computed(() => filteredLog.value.count)
async function loadSysFiles() {
try {
sysFiles.value = (await getLogFiles(sysDate.value)) || []
if (sysFiles.value.length > 0) {
sysSelected.value = sysFiles.value[0].file
loadSysContent()
const firstFile = sysFiles.value[0].file
if (sysSelected.value === firstFile) void loadSysContent()
else sysSelected.value = firstFile
} else {
sysContent.value = '该日期暂无日志文件'
}
@@ -76,8 +74,9 @@ async function loadTrainFiles() {
try {
trainFiles.value = (await getTrainingLogFiles()) || []
if (trainFiles.value.length > 0) {
trainSelected.value = trainFiles.value[0].file
loadTrainContent()
const firstFile = trainFiles.value[0].file
if (trainSelected.value === firstFile) void loadTrainContent()
else trainSelected.value = firstFile
} else {
trainContent.value = '暂无训练日志'
}
@@ -97,29 +96,28 @@ async function loadTrainContent() {
}
}
function refresh() {
async function refresh() {
if (activeTab.value === 'system') {
loadSysContent()
await loadSysContent()
} else {
loadTrainContent()
await loadTrainContent()
}
}
const { start: startPolling, stop: stopPolling } = usePolling(
refresh,
() => refreshInterval.value * 1000,
{ immediate: false },
)
function startAutoRefresh() {
stopAutoRefresh()
stopPolling()
if (refreshInterval.value === 0) {
stopCountdown()
return
}
startCountdown()
refreshTimer = setInterval(refresh, refreshInterval.value * 1000)
}
function stopAutoRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
startPolling()
}
watch(refreshInterval, startAutoRefresh)
@@ -135,8 +133,6 @@ onMounted(() => {
loadSysFiles()
startAutoRefresh()
})
onUnmounted(stopAutoRefresh)
</script>
<template>

View File

@@ -1,9 +1,12 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import VChart from 'vue-echarts'
import PageCard from '@/components/PageCard.vue'
import ModelStatusTag from '@/components/ModelStatusTag.vue'
import TrainingTaskOverview from './training-log/TrainingTaskOverview.vue'
import { usePolling } from '@/composables/usePolling'
import '@/plugins/echarts-training-log'
import { useModelsStore } from '@/stores/models'
import { getFineTune } from '@/api/modules/fineTune'
import { getTrainingLogFiles, getTrainingLogContent } from '@/api/modules/log'
@@ -55,7 +58,6 @@ const paramsExpanded = ref(false)
const GPU_PREVIEW_LIMIT = 4
const gpuExpanded = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
let refreshInFlight = false
/** 三个曲线的 ECharts 配置(响应式,数据变化自动重绘) */
@@ -257,14 +259,16 @@ async function refreshAll() {
}
}
onMounted(() => {
modelsStore.load()
refreshAll()
timer = setInterval(refreshAll, 5000)
})
const isTerminalTask = () => ['completed', 'failed', 'stopped', 'cancelled'].includes(task.value?.status || '')
const { start: startPolling, stop: stopPolling } = usePolling(async () => {
await refreshAll()
if (isTerminalTask()) stopPolling()
}, 5000, { immediate: false })
onUnmounted(() => {
if (timer) clearInterval(timer)
onMounted(async () => {
void modelsStore.load()
await refreshAll()
if (!isTerminalTask()) startPolling()
})
</script>

View File

@@ -10,10 +10,10 @@ export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver({ importStyle: false })],
resolvers: [ElementPlusResolver({ importStyle: 'css' })],
}),
Components({
resolvers: [ElementPlusResolver({ importStyle: false })],
resolvers: [ElementPlusResolver({ importStyle: 'css' })],
}),
],
resolve: {