dev #4

Merged
caoxiaozhu merged 3 commits from dev into main 2026-07-16 11:04:35 +08:00
18 changed files with 137 additions and 92 deletions
Showing only changes of commit 4173b53b1b - Show all commits

View File

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

View File

@@ -117,7 +117,8 @@ assert.match(
assert.match(source, /import \{ getSystemInfo \} from '@\/api\/modules\/system'/, '训练概览必须复用系统 GPU 监控数据源') assert.match(source, /import \{ getSystemInfo \} from '@\/api\/modules\/system'/, '训练概览必须复用系统 GPU 监控数据源')
assert.match(source, /Promise\.all\(\[datasetPromise, loadLog\(currentTask\), loadGpuStatus\(\)\]\)/, 'GPU 状态必须和训练日志一起刷新') assert.match(source, /Promise\.all\(\[datasetPromise, loadLog\(currentTask\), loadGpuStatus\(\)\]\)/, 'GPU 状态必须和训练日志一起刷新')
assert.match(source, /if \(refreshInFlight\) return/, '轮询刷新必须阻止并发重叠') 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( const overview = findElements(
templateAst, templateAst,

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,13 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' 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' import 'element-plus/es/components/message/style/css'
import 'element-plus/es/components/message-box/style/css'
// ECharts按需引入+ vue-echarts 组件 import 'element-plus/es/components/loading/style/css'
import '@/plugins/echarts'
import VChart from 'vue-echarts'
import App from './App.vue' import App from './App.vue'
import service from '@/api/request'
import router from './router' import router from './router'
import './styles/index.scss' import './styles/index.scss'
@@ -19,7 +15,19 @@ const app = createApp(App)
app.use(createPinia()) app.use(createPinia())
app.use(router) 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 { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers' import { CanvasRenderer } from 'echarts/renderers'
import { BarChart, LineChart, PieChart } from 'echarts/charts' import { BarChart, PieChart } from 'echarts/charts'
import { import {
GridComponent, GridComponent,
TooltipComponent, TooltipComponent,
LegendComponent, LegendComponent,
DataZoomComponent,
MarkLineComponent,
} from 'echarts/components' } from 'echarts/components'
use([ use([
CanvasRenderer, CanvasRenderer,
BarChart, BarChart,
LineChart,
PieChart, PieChart,
GridComponent, GridComponent,
TooltipComponent, TooltipComponent,
LegendComponent, LegendComponent,
DataZoomComponent,
MarkLineComponent,
]) ])

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import VChart from 'vue-echarts'
import '@/plugins/echarts'
import type { EChartsOption } from 'echarts' import type { EChartsOption } from 'echarts'
type ServiceState = 'normal' | 'busy' | 'error' type ServiceState = 'normal' | 'busy' | 'error'
@@ -253,7 +255,7 @@ const loginDurationStats: LoginDurationStat[] = [
const loginDurationChartOption = computed<EChartsOption>(() => ({ const loginDurationChartOption = computed<EChartsOption>(() => ({
animationDuration: 500, 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: { tooltip: {
trigger: 'axis', trigger: 'axis',
axisPointer: { type: 'shadow' }, axisPointer: { type: 'shadow' },
@@ -284,7 +286,7 @@ const loginDurationChartOption = computed<EChartsOption>(() => ({
barMaxWidth: 18, barMaxWidth: 18,
barCategoryGap: '34%', barCategoryGap: '34%',
itemStyle: { color: '#4f46e5', borderRadius: [0, 4, 4, 0] }, 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"> <script setup lang="ts">
import { computed, watch } from 'vue' import { computed, defineAsyncComponent, watch } from 'vue'
import { MdEditor } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import { EVAL_METHODS, EVAL_METHOD_PROMPTS } from '@/constants/dimension' import { EVAL_METHODS, EVAL_METHOD_PROMPTS } from '@/constants/dimension'
import { DIMENSION_TYPE_MAP } from '@/constants' import { DIMENSION_TYPE_MAP } from '@/constants'
import type { DimensionType, ModelItem } from '@/types' 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 { export interface DimensionFormDraft {
type: DimensionType | '' type: DimensionType | ''
description: string description: string

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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