8 Commits

Author SHA1 Message Date
caoxiaozhu
7d5b345779 style: 登录时长图表轴上限提取与标签外置
横轴上限提取为 loginDurationAxisMax 常量,标签位置统一回到柱体右侧灰色展示,回归断言同步。
2026-07-16 11:20:30 +08:00
caoxiaozhu
4173b53b1b refactor: 前端构建按需化与 Mock 懒加载
移除全量 Element Plus 与全局 VChart 注册,改为按需引入样式与组件内局部图表;Mock 适配器改为按 VITE_ENABLE_MOCK 环境变量懒加载,生产默认不拦截请求;ECharts 精简为看板所需图表,各视图与 stores 同步适配。
2026-07-16 11:03:54 +08:00
caoxiaozhu
ab9e87f948 refactor: 推理对比超时与打字机展示改进
抽取 withTimeout 替代 Promise.race 超时控制,对比结果新增打字机逐字渲染与清理,推理聊天参数与列表类型同步收敛。
2026-07-16 11:03:25 +08:00
caoxiaozhu
5a040366da refactor: usePolling 改为串行轮询
上一轮完成后才安排下一轮避免慢请求重叠,页面不可见时自动暂停、恢复后立即刷新,支持动态间隔与错误回调。
2026-07-16 11:03:15 +08:00
caoxiaozhu
3cb20a4a28 style: 登录时长图表标签回到柱条右侧
标签位置由 insideRight 改回 right、颜色改回灰色,避免短柱条内文字溢出。
2026-07-16 09:38:23 +08:00
caoxiaozhu
a6085a2612 chore: 页面表面回归与数据处理文案适配
页面表面回归选择器更新为 task-overview,任务概览补充表面背景变量,数据处理结果步骤标题文案精简。
2026-07-16 09:36:39 +08:00
caoxiaozhu
4899bc8779 style: Dashboard 看板表格行高与图表微调
训练任务表格行高加大提升可读性,登录时长图表留白与标签位置调整避免溢出,回归断言同步。
2026-07-16 09:36:39 +08:00
caoxiaozhu
e70538e64d feat: 顶部栏新增使用文档入口
AppHeader 增加使用文档按钮,新窗口打开 /guide 路由,补充响应式窄屏适配。
2026-07-16 09:36:39 +08:00
29 changed files with 377 additions and 154 deletions

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

@@ -48,13 +48,14 @@ assert.match(dashboardSource, /grid-template-columns:\s*minmax\(0,\s*1\.9fr\)\s*
assert.match(dashboardSource, /\.training-chart\s*\{[\s\S]*?height:\s*300px;[\s\S]*?min-height:\s*300px;/, '训练统计图应保持舒展、稳定的展示高度') assert.match(dashboardSource, /\.training-chart\s*\{[\s\S]*?height:\s*300px;[\s\S]*?min-height:\s*300px;/, '训练统计图应保持舒展、稳定的展示高度')
assert.match(dashboardSource, /\.service-table\s*\{[\s\S]*?grid-template-rows:[^;]*repeat\(4,\s*minmax\(48px,\s*1fr\)\)/, '服务状态行应随中间区域同步拉伸') assert.match(dashboardSource, /\.service-table\s*\{[\s\S]*?grid-template-rows:[^;]*repeat\(4,\s*minmax\(48px,\s*1fr\)\)/, '服务状态行应随中间区域同步拉伸')
assert.match(dashboardSource, /\.tasks-heading\s*\{[\s\S]*?min-height:\s*32px;[\s\S]*?padding:\s*0 14px 10px;/, '训练任务标题区应适当加高') assert.match(dashboardSource, /\.tasks-heading\s*\{[\s\S]*?min-height:\s*32px;[\s\S]*?padding:\s*0 14px 10px;/, '训练任务标题区应适当加高')
assert.match(dashboardSource, /\.tasks-table\s*\{[\s\S]*?th,\s*td\s*\{[\s\S]*?height:\s*42px;[\s\S]*?th\s*\{[\s\S]*?height:\s*36px;/, '训练任务表格行应适当加高') assert.match(dashboardSource, /\.tasks-table\s*\{[\s\S]*?th,\s*td\s*\{[\s\S]*?height:\s*50px;[\s\S]*?th\s*\{[\s\S]*?height:\s*38px;/, '训练任务表格正文行应保持舒展')
assert.match(dashboardSource, /@media\s*\(max-height:\s*900px\)[\s\S]*?\.tasks-heading\s*\{[\s\S]*?min-height:\s*28px;[\s\S]*?padding:\s*0 12px 8px;[\s\S]*?\.tasks-table\s*\{[\s\S]*?height:\s*36px;[\s\S]*?th\s*\{[\s\S]*?height:\s*32px;/, '低高度桌面视口也应提高训练任务占比') assert.match(dashboardSource, /@media\s*\(max-height:\s*900px\)[\s\S]*?\.tasks-heading\s*\{[\s\S]*?min-height:\s*28px;[\s\S]*?padding:\s*0 12px 8px;[\s\S]*?\.tasks-table\s*\{[\s\S]*?height:\s*42px;[\s\S]*?th\s*\{[\s\S]*?height:\s*34px;/, '低高度桌面视口也应保留可读的训练任务行高')
assert.match(dashboardSource, /class=["']user-stats-row["'][\s\S]*?用户操作分布[\s\S]*?登录时长排行[\s\S]*?最近登录用户/, '服务看板应展示三块用户统计卡片') assert.match(dashboardSource, /class=["']user-stats-row["'][\s\S]*?用户操作分布[\s\S]*?登录时长排行[\s\S]*?最近登录用户/, '服务看板应展示三块用户统计卡片')
assert.match(dashboardSource, /class=["']duration-chart["'][\s\S]*?loginDurationChartOption/, '登录时长应使用 ECharts 图表展示') assert.match(dashboardSource, /class=["']duration-chart["'][\s\S]*?loginDurationChartOption/, '登录时长应使用 ECharts 图表展示')
assert.match(dashboardSource, /const loginDurationChartOption[\s\S]*?type:\s*['"]bar['"][\s\S]*?formatter:\s*['"]\{c\} 小时['"]/, '登录时长应以横向柱状图展示具体小时数') assert.match(dashboardSource, /const loginDurationChartOption[\s\S]*?type:\s*['"]bar['"][\s\S]*?formatter:\s*['"]\{c\} 小时['"]/, '登录时长应以横向柱状图展示具体小时数')
assert.match(dashboardSource, /const operationChartOption[\s\S]*?position:\s*['"]outside['"][\s\S]*?labelLine:\s*\{[\s\S]*?show:\s*true/, '饼图应以外侧引导线标注操作名称') assert.match(dashboardSource, /const operationChartOption[\s\S]*?position:\s*['"]outside['"][\s\S]*?labelLine:\s*\{[\s\S]*?show:\s*true/, '饼图应以外侧引导线标注操作名称')
assert.match(dashboardSource, /const loginDurationChartOption[\s\S]*?grid:\s*\{\s*top:\s*8,\s*right:\s*12,\s*bottom:\s*6,\s*left:\s*8,[\s\S]*?max:\s*Math\.ceil\(Math\.max[\s\S]*?position:\s*['"]insideRight['"]/, '登录时长图应收紧左右边距、按数据范围拉伸,并将数值置于柱内') assert.match(dashboardSource, /const loginDurationAxisMax\s*=\s*Math\.ceil\([\s\S]*?Math\.max/, '登录时长图应按数据范围计算横轴上限')
assert.match(dashboardSource, /const loginDurationChartOption[\s\S]*?position:\s*['"]right['"][\s\S]*?formatter:\s*['"]\{c\} 小时['"]/, '登录时长标签应统一显示在柱体外侧')
assert.match(dashboardSource, /\.user-stats-row\s*\{[\s\S]*?repeat\(3,\s*minmax\(0,\s*1fr\)\)/, '宽屏用户统计卡片应保持三列') assert.match(dashboardSource, /\.user-stats-row\s*\{[\s\S]*?repeat\(3,\s*minmax\(0,\s*1fr\)\)/, '宽屏用户统计卡片应保持三列')
assert.match(dashboardSource, /@media\s*\(max-width:\s*1180px\)[\s\S]*?\.user-stats-row\s*\{[\s\S]*?repeat\(2,\s*minmax\(0,\s*1fr\)\)/, '中等宽度应将用户统计卡片降为两列') assert.match(dashboardSource, /@media\s*\(max-width:\s*1180px\)[\s\S]*?\.user-stats-row\s*\{[\s\S]*?repeat\(2,\s*minmax\(0,\s*1fr\)\)/, '中等宽度应将用户统计卡片降为两列')
assert.match(dashboardSource, /@media\s*\(max-width:\s*720px\)[\s\S]*?\.user-stats-row\s*\{[\s\S]*?grid-template-columns:\s*1fr;/, '窄屏应将用户统计卡片降为单列') assert.match(dashboardSource, /@media\s*\(max-width:\s*720px\)[\s\S]*?\.user-stats-row\s*\{[\s\S]*?grid-template-columns:\s*1fr;/, '窄屏应将用户统计卡片降为单列')

View File

@@ -181,7 +181,7 @@ assert.match(rootPageCardBlock, /box-shadow:\s*none\s*!important;/, '页面根
const trainingLogStyle = [trainingLogSource, trainingOverviewSource] const trainingLogStyle = [trainingLogSource, trainingOverviewSource]
.flatMap((source) => parseSfc(source).descriptor.styles.map((item) => item.content)) .flatMap((source) => parseSfc(source).descriptor.styles.map((item) => item.content))
.join('\n') .join('\n')
const businessSurfaceBlock = extractCssBlock(trainingLogStyle, '.profile-section,\n.runtime-panel') const businessSurfaceBlock = extractCssBlock(trainingLogStyle, '.task-overview')
assert.match( assert.match(
businessSurfaceBlock, businessSurfaceBlock,
/background:\s*var\(--app-surface-bg\);/, /background:\s*var\(--app-surface-bg\);/,

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>
<router-view /> <el-config-provider :locale="zhCn">
<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

@@ -27,6 +27,11 @@ function updateTime() {
currentDate.value = now.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).replace(/\//g, '-') currentDate.value = now.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).replace(/\//g, '-')
} }
function openGuide() {
const guideUrl = router.resolve({ name: 'guide' }).href
window.open(guideUrl, '_blank', 'noopener,noreferrer')
}
const serverIp = ref(window.location.hostname) const serverIp = ref(window.location.hostname)
onMounted(() => { onMounted(() => {
@@ -103,6 +108,19 @@ onUnmounted(() => {
<span>{{ currentTime }}</span> <span>{{ currentTime }}</span>
</div> </div>
</div> </div>
<div class="divider document-divider"></div>
<el-tooltip content="使用文档" placement="bottom">
<button
type="button"
class="documentation-btn"
aria-label="打开使用文档"
@click="openGuide"
>
<i class="fa fa-book" aria-hidden="true" />
</button>
</el-tooltip>
</div> </div>
</header> </header>
</template> </template>
@@ -261,4 +279,50 @@ onUnmounted(() => {
} }
} }
} }
.documentation-btn {
width: 40px;
height: 40px;
padding: 0;
border: 1px solid #e2e8f0;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
background: #fff;
color: #64748b;
cursor: pointer;
font-size: 17px;
transition: color 180ms ease, border-color 180ms ease, background-color 180ms ease, box-shadow 180ms ease;
&:hover {
border-color: rgba(79, 70, 229, 0.28);
background: #eef2ff;
color: var(--primary-color);
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.1);
}
&:focus-visible {
outline: 3px solid rgba(79, 70, 229, 0.2);
outline-offset: 2px;
}
}
@media (max-width: 1280px) {
.header-right {
gap: 12px;
}
.system-info .info-item:first-child {
display: none;
}
}
@media (max-width: 1040px) {
.system-info,
.header-right > .divider:not(.document-divider) {
display: none;
}
}
</style> </style>

View File

@@ -1,20 +1,82 @@
import { useIntervalFn } from '@vueuse/core' import { onBeforeUnmount, onMounted, ref } from 'vue'
interface PollingOptions {
/** 启动后是否立即执行一次,默认立即执行。 */
immediate?: boolean
/** 页面不可见时是否暂停,默认暂停。 */
pauseWhenHidden?: boolean
/** 轮询函数抛错时的可选回调。 */
onError?: (error: unknown) => void
}
/** /**
* 轮询 composable * 轮询 composable
* 封装 useIntervalFn自动在组件卸载时清理 * 串行轮询:上一轮完成后才安排下一轮,避免慢请求重叠和旧响应覆盖。
* 页面进入后台时自动暂停,恢复可见后立即刷新;组件卸载时自动清理。
*/ */
export function usePolling(fn: () => void | Promise<void>, interval = 5000, immediate = true) { export function usePolling(
const { pause, resume } = useIntervalFn(fn, interval, { immediate }) fn: () => void | Promise<void>,
interval: number | (() => number) = 5000,
options: PollingOptions = {},
) {
const { immediate = true, pauseWhenHidden = true, onError } = options
const isActive = ref(false)
const isRunning = ref(false)
let timer: ReturnType<typeof setTimeout> | null = null
function clearTimer() {
if (!timer) return
clearTimeout(timer)
timer = null
}
function schedule() {
clearTimer()
if (!isActive.value) return
if (pauseWhenHidden && document.hidden) return
const delay = typeof interval === 'function' ? interval() : interval
timer = setTimeout(() => void run(), Math.max(0, delay))
}
async function run() {
clearTimer()
if (!isActive.value || isRunning.value) return
if (pauseWhenHidden && document.hidden) return
isRunning.value = true
try {
await fn()
} catch (error) {
onError?.(error)
} finally {
isRunning.value = false
schedule()
}
}
function stop() { function stop() {
pause() isActive.value = false
clearTimer()
} }
function start() { function start() {
if (immediate) fn() if (isActive.value) return
resume() isActive.value = true
if (immediate) void run()
else schedule()
} }
return { start, stop, pause, resume } function handleVisibilityChange() {
if (!pauseWhenHidden || !isActive.value) return
if (document.hidden) clearTimer()
else void run()
}
onMounted(() => document.addEventListener('visibilitychange', handleVisibilityChange))
onBeforeUnmount(() => {
stop()
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
return { start, stop, run, isActive, isRunning }
} }

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

@@ -9,6 +9,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/login/LoginView.vue'), component: () => import('@/views/login/LoginView.vue'),
meta: { title: '登录', public: true }, meta: { title: '登录', public: true },
}, },
{
path: '/guide',
name: 'guide',
component: () => import('@/views/guide/GuideView.vue'),
meta: { title: '使用文档', skipPermission: true },
},
{ {
path: '/', path: '/',
component: () => import('@/layouts/MainLayout.vue'), component: () => import('@/layouts/MainLayout.vue'),

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
try { if (pendingLoad && !force) return pendingLoad
list.value = (await getModelList()) || []
loaded.value = true pendingLoad = (async () => {
} catch { try {
list.value = [] list.value = (await getModelList()) || []
} loaded.value = true
} catch {
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,8 +1,9 @@
<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, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import PageCard from '@/components/PageCard.vue' import PageCard from '@/components/PageCard.vue'
import { usePolling } from '@/composables/usePolling'
import { getCompare } from '@/api/modules/compare' import { getCompare } from '@/api/modules/compare'
import type { CompareTask, LoadedModel } from '@/types' import type { CompareTask, LoadedModel } from '@/types'
@@ -11,7 +12,6 @@ const router = useRouter()
const taskId = route.params.id as string const taskId = route.params.id as string
const task = ref<CompareTask | null>(null) const task = ref<CompareTask | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
const form = reactive({ const form = reactive({
systemPrompt: '', systemPrompt: '',
@@ -73,13 +73,11 @@ function handleSubmit() {
window.open(url, '_blank') window.open(url, '_blank')
} }
onMounted(() => { const { start: startPolling } = usePolling(loadTask, 5000, { immediate: false })
loadTask()
pollTimer = setInterval(loadTask, 5000)
})
onUnmounted(() => { onMounted(async () => {
if (pollTimer) clearInterval(pollTimer) await loadTask()
startPolling()
}) })
</script> </script>

View File

@@ -1,14 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue' import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import MarkdownView from '@/components/MarkdownView.vue' import MarkdownView from '@/components/MarkdownView.vue'
import { import { getCompare, chatWithPort } from '@/api/modules/compare'
getCompare, import type { LoadedModel } from '@/types'
chatWithPort,
batchChat,
} from '@/api/modules/compare'
import { getModelByName } from '@/api/modules/model'
import type { CompareTask, LoadedModel } from '@/types'
const route = useRoute() const route = useRoute()
const taskId = route.query.taskId as string const taskId = route.query.taskId as string
@@ -25,6 +20,7 @@ interface ModelResult {
name: string name: string
content: string content: string
displayContent: string displayContent: string
isTyping: boolean
status: 'loading' | 'done' | 'error' status: 'loading' | 'done' | 'error'
stats?: { charsPerSec?: number; totalTime?: number } stats?: { charsPerSec?: number; totalTime?: number }
} }
@@ -33,6 +29,7 @@ const results = ref<ModelResult[]>([])
const started = ref(false) const started = ref(false)
const loadedModels = ref<LoadedModel[]>([]) const loadedModels = ref<LoadedModel[]>([])
const typewriterTimers = new Set<ReturnType<typeof setInterval>>()
async function init() { async function init() {
if (started.value) return if (started.value) return
@@ -50,6 +47,7 @@ async function init() {
name: m.model_name || '模型', name: m.model_name || '模型',
content: '', content: '',
displayContent: '', displayContent: '',
isTyping: false,
status: 'loading', status: 'loading',
})) }))
@@ -65,7 +63,7 @@ async function inferOne(model: LoadedModel, idx: number) {
const startTime = Date.now() const startTime = Date.now()
try { try {
// 尝试通过端口代理调用 // 尝试通过端口代理调用
const res: any = await Promise.race([ const res: any = await withTimeout(
chatWithPort({ chatWithPort({
port: model.port, port: model.port,
model_name: model.model_name, model_name: model.model_name,
@@ -78,8 +76,8 @@ async function inferOne(model: LoadedModel, idx: number) {
top_k: topK, top_k: topK,
max_tokens: maxTokens, max_tokens: maxTokens,
}), }),
new Promise((_, reject) => setTimeout(() => reject(new Error('推理超时')), 300000)), 300000,
]) )
const content = res?.response || res?.content || res?.data || JSON.stringify(res) const content = res?.response || res?.content || res?.data || JSON.stringify(res)
const totalTime = (Date.now() - startTime) / 1000 const totalTime = (Date.now() - startTime) / 1000
@@ -87,7 +85,7 @@ async function inferOne(model: LoadedModel, idx: number) {
results.value[idx].status = 'done' results.value[idx].status = 'done'
results.value[idx].stats = { results.value[idx].stats = {
totalTime, totalTime,
charsPerSec: totalTime > 0 ? (content.length / totalTime).toFixed(1) as unknown as number : 0, charsPerSec: totalTime > 0 ? Number((content.length / totalTime).toFixed(1)) : 0,
} }
// 模拟打字机效果 // 模拟打字机效果
typewriterDisplay(idx, content) typewriterDisplay(idx, content)
@@ -97,22 +95,46 @@ async function inferOne(model: LoadedModel, idx: number) {
} }
} }
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | null = null
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error('推理超时')), timeoutMs)
}),
])
} finally {
if (timeoutId) clearTimeout(timeoutId)
}
}
/** 打字机效果逐字展示 */ /** 打字机效果逐字展示 */
function typewriterDisplay(idx: number, content: string) { function typewriterDisplay(idx: number, content: string) {
let pos = 0 let pos = 0
results.value[idx].isTyping = true
// 将更新次数控制在约 30 次,避免长回答逐字触发 Markdown 全文解析。
const step = Math.max(2, Math.ceil(content.length / 30))
const interval = setInterval(() => { const interval = setInterval(() => {
pos += 2 pos += step
results.value[idx].displayContent = content.slice(0, pos) results.value[idx].displayContent = content.slice(0, pos)
if (pos >= content.length) { if (pos >= content.length) {
clearInterval(interval) clearInterval(interval)
typewriterTimers.delete(interval)
results.value[idx].displayContent = content results.value[idx].displayContent = content
results.value[idx].isTyping = false
} }
}, 20) }, 50)
typewriterTimers.add(interval)
} }
const allDone = computed(() => results.value.length > 0 && results.value.every((r) => r.status === 'done' || r.status === 'error')) const allDone = computed(() => results.value.length > 0 && results.value.every((r) => r.status === 'done' || r.status === 'error'))
onMounted(init) onMounted(init)
onBeforeUnmount(() => {
typewriterTimers.forEach(clearInterval)
typewriterTimers.clear()
})
</script> </script>
<template> <template>
@@ -144,6 +166,7 @@ onMounted(init)
</template> </template>
<div v-if="r.status === 'error'" class="error-text">{{ r.content }}</div> <div v-if="r.status === 'error'" class="error-text">{{ r.content }}</div>
<div v-else-if="r.isTyping" class="streaming-text">{{ r.displayContent }}</div>
<MarkdownView v-else-if="r.displayContent" :content="r.displayContent" /> <MarkdownView v-else-if="r.displayContent" :content="r.displayContent" />
<div v-else class="loading-text"> <div v-else class="loading-text">
<i class="fa fa-spinner fa-spin" /> 正在生成回答... <i class="fa fa-spinner fa-spin" /> 正在生成回答...
@@ -212,6 +235,13 @@ onMounted(init)
color: #f56c6c; color: #f56c6c;
} }
.streaming-text {
min-height: 80px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
.result-stats { .result-stats {
display: flex; display: flex;
gap: 16px; gap: 16px;

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'
@@ -250,10 +252,13 @@ const loginDurationStats: LoginDurationStat[] = [
{ id: 3, username: 'lisi', duration: 42 }, { id: 3, username: 'lisi', duration: 42 },
{ id: 4, username: 'wangwu', duration: 18 }, { id: 4, username: 'wangwu', duration: 18 },
] ]
const loginDurationAxisMax = Math.ceil(
Math.max(...loginDurationStats.map((user) => user.duration)) * 1.15 / 10,
) * 10
const loginDurationChartOption = computed<EChartsOption>(() => ({ const loginDurationChartOption = computed<EChartsOption>(() => ({
animationDuration: 500, animationDuration: 500,
grid: { top: 8, right: 40, 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' },
@@ -261,7 +266,7 @@ const loginDurationChartOption = computed<EChartsOption>(() => ({
}, },
xAxis: { xAxis: {
type: 'value', type: 'value',
max: Math.ceil(Math.max(...loginDurationStats.map((user) => user.duration)) / 10) * 10, max: loginDurationAxisMax,
splitNumber: 4, splitNumber: 4,
axisLabel: { color: '#94a3b8', fontSize: 11, formatter: '{value}h' }, axisLabel: { color: '#94a3b8', fontSize: 11, formatter: '{value}h' },
axisLine: { show: false }, axisLine: { show: false },
@@ -284,7 +289,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: 8, color: '#64748b', fontSize: 11, formatter: '{c} 小时' }, label: { show: true, position: 'right', distance: 6, color: '#475569', fontSize: 11, formatter: '{c} 小时' },
}, },
], ],
})) }))
@@ -798,8 +803,8 @@ function viewTask(task: DashboardTask) {
th, th,
td { td {
height: 42px; height: 50px;
padding: 6px 10px; padding: 8px 10px;
border-top: 1px solid #eef2f7; border-top: 1px solid #eef2f7;
text-align: left; text-align: left;
vertical-align: middle; vertical-align: middle;
@@ -807,7 +812,7 @@ function viewTask(task: DashboardTask) {
} }
th { th {
height: 36px; height: 38px;
background: #f8fafc; background: #f8fafc;
color: #475569; color: #475569;
font-weight: 600; font-weight: 600;
@@ -925,12 +930,12 @@ function viewTask(task: DashboardTask) {
.tasks-table { .tasks-table {
th, th,
td { td {
height: 36px; height: 42px;
padding: 4px 8px; padding: 6px 8px;
} }
th { th {
height: 32px; height: 34px;
} }
} }
} }

View File

@@ -47,7 +47,7 @@ const WIZARD_STEPS = [
{ id: 'upload', title: '上传文件', desc: '上传或接入待处理的源数据' }, { id: 'upload', title: '上传文件', desc: '上传或接入待处理的源数据' },
{ id: 'preview', title: '数据预览', desc: '核对源文件与预览内容' }, { id: 'preview', title: '数据预览', desc: '核对源文件与预览内容' },
{ id: 'generate', title: '开始生成', desc: '确认摘要并启动处理' }, { id: 'generate', title: '开始生成', desc: '确认摘要并启动处理' },
{ id: 'results', title: '编辑与保存', desc: '检查、修改并保存结果' }, { id: 'results', title: '结果编辑与保存', desc: '检查、修改并保存结果' },
] as const satisfies ReadonlyArray<{ id: StepId; title: string; desc: string }> ] as const satisfies ReadonlyArray<{ id: StepId; title: string; desc: string }>
const currentStep = ref(0) const currentStep = ref(0)
const currentStepId = computed<StepId>(() => WIZARD_STEPS[currentStep.value]?.id ?? 'create') const currentStepId = computed<StepId>(() => WIZARD_STEPS[currentStep.value]?.id ?? 'create')

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

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, nextTick, onMounted } from 'vue' import { ref, reactive, nextTick, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import MarkdownView from '@/components/MarkdownView.vue' import MarkdownView from '@/components/MarkdownView.vue'
@@ -34,6 +34,7 @@ const temperature = ref(0.7)
const top_p = ref(0.95) const top_p = ref(0.95)
const maxTokens = ref(2048) const maxTokens = ref(2048)
const contentRef = ref<HTMLElement>() const contentRef = ref<HTMLElement>()
let activeAssistant: ChatMessage | null = null
/** 设置面板抽屉 */ /** 设置面板抽屉 */
const showSettings = ref(false) const showSettings = ref(false)
@@ -98,8 +99,8 @@ async function handleSend() {
return return
} }
// 监听流式 message 变化,同步到 assistantMsg // 流式状态变化时只同步当前回复,避免固定定时器空转。
const watchStop = watchMessage(assistantMsg) activeAssistant = assistantMsg
await send({ await send({
port: target.port, port: target.port,
@@ -118,7 +119,7 @@ async function handleSend() {
assistantMsg.isThinking = false assistantMsg.isThinking = false
assistantMsg.isStreaming = false assistantMsg.isStreaming = false
assistantMsg.done = true assistantMsg.done = true
watchStop() activeAssistant = null
reset() reset()
await nextTick() await nextTick()
scrollToBottom() scrollToBottom()
@@ -149,17 +150,24 @@ async function mockReply(assistantMsg: ChatMessage, question: string) {
scrollToBottom() scrollToBottom()
} }
/** 轮询同步流式状态到展示消息 */ watch(
function watchMessage(assistantMsg: ChatMessage) { () => [
const timer = setInterval(() => { message.value.displayContent,
assistantMsg.content = message.value.displayContent message.value.thinkContent,
assistantMsg.think = message.value.thinkContent message.value.isThinking,
assistantMsg.isThinking = message.value.isThinking message.value.done,
if (message.value.done) clearInterval(timer) ] as const,
async ([content, think, isThinking, done]) => {
if (!activeAssistant) return
activeAssistant.content = content
activeAssistant.think = think
activeAssistant.isThinking = isThinking
activeAssistant.isStreaming = !done
await nextTick()
scrollToBottom() scrollToBottom()
}, 80) },
return () => clearInterval(timer) { flush: 'post' },
} )
function scrollToBottom() { function scrollToBottom() {
if (contentRef.value) { if (contentRef.value) {
@@ -168,6 +176,7 @@ function scrollToBottom() {
} }
function handleNewChat() { function handleNewChat() {
activeAssistant = null
messages.value = [] messages.value = []
reset() reset()
} }

View File

@@ -3,6 +3,7 @@ import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import DataTablePage from '@/components/DataTablePage.vue' import DataTablePage from '@/components/DataTablePage.vue'
import { usePolling } from '@/composables/usePolling'
import { import {
getCompareList, getCompareList,
deleteCompare, deleteCompare,
@@ -17,7 +18,7 @@ const router = useRouter()
const loading = ref(false) const loading = ref(false)
const dataList = ref<CompareTask[]>([]) const dataList = ref<CompareTask[]>([])
let refreshTimer: ReturnType<typeof setInterval> | null = null let delayedRefreshTimer: ReturnType<typeof setTimeout> | null = null
async function loadData(silent = false) { async function loadData(silent = false) {
if (!silent) { if (!silent) {
@@ -80,7 +81,8 @@ function parseModelNames(row: any): string[] {
async function handleLoad(row: any) { async function handleLoad(row: any) {
await loadCompare(row.id) await loadCompare(row.id)
ElMessage.info('正在加载模型,请稍候...') ElMessage.info('正在加载模型,请稍候...')
setTimeout(loadData, 1000) if (delayedRefreshTimer) clearTimeout(delayedRefreshTimer)
delayedRefreshTimer = setTimeout(loadData, 1000)
} }
/** 卸载推理任务 */ /** 卸载推理任务 */
@@ -112,13 +114,15 @@ function startChat(row: any) {
router.push(`/model-inference/chat/${row.id}`) router.push(`/model-inference/chat/${row.id}`)
} }
onMounted(() => { const { start: startPolling } = usePolling(() => loadData(true), 3000, { immediate: false })
loadData()
refreshTimer = setInterval(() => loadData(true), 3000) onMounted(async () => {
await loadData()
startPolling()
}) })
onUnmounted(() => { onUnmounted(() => {
if (refreshTimer) clearInterval(refreshTimer) if (delayedRefreshTimer) clearTimeout(delayedRefreshTimer)
}) })
</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

@@ -61,6 +61,7 @@ function formatDateTime(value?: string) {
margin-bottom: 0; margin-bottom: 0;
border: 1px solid #e4e7ed !important; border: 1px solid #e4e7ed !important;
border-radius: 8px !important; border-radius: 8px !important;
background: var(--app-surface-bg);
box-shadow: none !important; box-shadow: none !important;
} }
.overview-layout { width: 100%; } .overview-layout { width: 100%; }

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: {