refactor: 前端构建按需化与 Mock 懒加载
移除全量 Element Plus 与全局 VChart 注册,改为按需引入样式与组件内局部图表;Mock 适配器改为按 VITE_ENABLE_MOCK 环境变量懒加载,生产默认不拦截请求;ECharts 精简为看板所需图表,各视图与 stores 同步适配。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
9
frontend/src/env.d.ts
vendored
9
frontend/src/env.d.ts
vendored
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
@@ -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 获取模型名 */
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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} 小时' },
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user