feat: 实现基础设施层

axios 请求封装及七个业务模块 API,Pinia 状态管理(auth/system/models/tools),Mock 适配器与数据,以及流式对话、轮询、倒计时组合式函数。
This commit is contained in:
caoxiaozhu
2026-07-10 16:45:06 +08:00
parent 8aa67003c8
commit ca9e05aa91
17 changed files with 1197 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
import { get, post, del } from '../request'
import type { CompareTask, CompareModelRef } from '@/types'
/** 推理/对比任务列表 */
export const getCompareList = () => get<CompareTask[]>('/model-compare')
/** 任务详情 */
export const getCompare = (id: string | number) => get<CompareTask>(`/model-compare/${id}`)
/** 创建任务 */
export const createCompare = (data: Partial<CompareTask>) =>
post<{ id: string | number }>('/model-compare', data)
/** 删除任务 */
export const deleteCompare = (id: string | number) => del(`/model-compare/${id}`)
/** 更新任务加载状态 */
export const updateLoadStatus = (id: string | number, load_status: any) =>
post(`/model-compare/${id}/load-status`, { load_status })
/** 查询任务加载状态 */
export const getLoadStatus = (id: string | number) =>
get<{ all_ready: boolean; loaded_models: any[] }>(`/model-compare/${id}/load-status`)
/** 停止所有旧模型服务 */
export const stopAllModels = () => post('/model-compare/all/stop-all')
/** 启动单个模型服务 */
export const startModel = (id: string | number, data: Partial<CompareModelRef>) =>
post<{ pid: number; port: number }>(`/model-compare/${id}/start-model`, data)
/** 按 PID 停止模型进程 */
export const stopModelByPid = (pid: number) =>
post('/model-compare/stop-by-pid', { pid })
/** 加载任务 */
export const loadCompare = (id: string | number) => post(`/model-compare/${id}/load`)
/** 卸载任务 */
export const unloadCompare = (id: string | number) => post(`/model-compare/${id}/unload`)
/** 流式对话mock 模式下返回非流式响应,调用方需兼容) */
export const streamChat = async (data: any): Promise<any> => {
// Mock 环境:返回非流式响应对象,调用方检测 content-type 决定如何处理
const resp = await post<{ response: string }>('/model-compare/stream-chat', data)
return {
ok: true,
body: {
getReader: () => {
// 模拟流式读取:把整个响应切成小块逐个返回
const text = resp?.response || ''
const encoder = new TextEncoder()
const chunks = [text.slice(0, text.length / 3), text.slice(text.length / 3, 2 * text.length / 3), text.slice(2 * text.length / 3)]
let i = 0
return {
read: async () => {
if (i >= chunks.length) return { done: true, value: undefined }
const value = encoder.encode(chunks[i++])
return { done: false, value }
},
}
},
},
}
}
/** 非流式对话(按端口代理) */
export const chatWithPort = (data: any) => post('/model-compare/chat-with-port', data)
/** 批量对话API 类型模型) */
export const batchChat = (data: any) => post('/model-chat/batch', data)
/** 本地 transformers 模型对话 */
export const localChat = (data: any) => post('/model-chat/local/chat', data)
/** 预加载本地模型 */
export const preloadLocalModel = (data: any) => post('/model-chat/local/preload', data)
/** 预加载已训练模型 */
export const preloadTrainedModel = (data: any) => post('/model-chat/trained/preload', data)

View File

@@ -0,0 +1,40 @@
import { get, post, put, del } from '../request'
import type { DatasetItem } from '@/types'
/** 数据集列表 */
export const getDatasetList = () => get<DatasetItem[]>('/dataset-manage')
/** 数据集详情 */
export const getDataset = (id: string | number) => get<DatasetItem>(`/dataset-manage/${id}`)
/** 创建数据集 */
export const createDataset = (data: Partial<DatasetItem>) =>
post<{ id: string | number }>('/dataset-manage', data)
/** 更新数据集 */
export const updateDataset = (id: string | number, data: Partial<DatasetItem>) =>
put(`/dataset-manage/${id}`, data)
/** 删除数据集 */
export const deleteDataset = (id: string | number) => del(`/dataset-manage/${id}`)
/** 上传数据集文件multipart字段名 files */
export const uploadDatasetFiles = (datasetId: string | number, files: File[]) => {
const formData = new FormData()
files.forEach((f) => formData.append('files', f))
return post(`/dataset-manage/upload/${datasetId}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
}
/** 预览数据集文件内容 */
export const previewDatasetFile = (fileId: string | number) =>
get<{ content: string }>(`/dataset-manage/preview/${fileId}`)
/** 下载文件 URL */
export const downloadFileUrl = (datasetId: string | number, fileId: string | number) =>
`/api/dataset-manage/download/${datasetId}/${fileId}`
/** 打包下载数据集 URL */
export const downloadDatasetUrl = (datasetId: string | number) =>
`/api/dataset-manage/download/${datasetId}`

View File

@@ -0,0 +1,27 @@
import { get, post, put, del } from '../request'
import type { EvalTask, Dimension } from '@/types'
/** 评测任务列表 */
export const getEvalList = () => get<EvalTask[]>('/model-eval')
/** 删除评测任务 */
export const deleteEval = (id: string | number) => del(`/model-eval/${id}`)
/** 启动评测 */
export const startEval = (data: any) => post('/model-eval/start', data)
/** 评测维度列表 */
export const getDimensionList = () => get<Dimension[]>('/dimension')
/** 评测维度详情 */
export const getDimension = (id: string | number) => get<Dimension>(`/dimension/${id}`)
/** 创建维度 */
export const createDimension = (data: Partial<Dimension>) => post('/dimension', data)
/** 编辑维度 */
export const updateDimension = (id: string | number, data: Partial<Dimension>) =>
put(`/dimension/${id}`, data)
/** 删除维度 */
export const deleteDimension = (id: string | number) => del(`/dimension/${id}`)

View File

@@ -0,0 +1,40 @@
import { get, post, put, del } from '../request'
import type { FineTuneTask, TrainingProgress } from '@/types'
/** 训练任务列表 */
export const getFineTuneList = () => get<FineTuneTask[]>('/fine-tune')
/** 训练任务详情 */
export const getFineTune = (id: string | number) => get<FineTuneTask>(`/fine-tune/${id}`)
/** 任务名查重 */
export const checkFineTuneName = (name: string) =>
get<{ exists: boolean }>('/fine-tune/check-name', { name })
/** 创建训练任务记录(第一步) */
export const createFineTune = (data: Partial<FineTuneTask>) =>
post<{ id: string | number }>('/fine-tune', data)
/** 启动训练(第二步) */
export const startFineTune = (data: any) => post('/fine-tune/start', data)
/** 更新训练任务 */
export const updateFineTune = (id: string | number, data: Partial<FineTuneTask>) =>
put(`/fine-tune/${id}`, data)
/** 停止训练任务 */
export const stopFineTune = (id: string | number) => post(`/fine-tune/stop/${id}`)
/** 删除训练任务 */
export const deleteFineTune = (id: string | number) => del(`/fine-tune/${id}`)
/** 获取训练进度 */
export const getFineTuneProgress = (id: string | number) =>
get<TrainingProgress>(`/fine-tune/progress/${id}`)
/** 启动 TensorBoard */
export const startTensorboard = () => post('/fine-tune/tensorboard/start')
/** 提交 Web 日志 */
export const sendWebLog = (level: string, message: string, page: string) =>
post('/web-log', { level, message, page, timestamp: new Date().toISOString() })

View File

@@ -0,0 +1,18 @@
import { get } from '../request'
import type { LogFile, LogContent, TrainingLogFile } from '@/types'
/** 按日期获取系统日志文件列表 */
export const getLogFiles = (date: string) =>
get<LogFile[]>('/log-files', { date })
/** 获取系统日志内容 */
export const getLogContent = (file: string) =>
get<LogContent>('/log-content', { file })
/** 训练日志文件列表 */
export const getTrainingLogFiles = () =>
get<TrainingLogFile[]>('/training-log-files')
/** 训练日志内容 */
export const getTrainingLogContent = (file: string) =>
get<LogContent>('/training-log-content', { file })

View File

@@ -0,0 +1,48 @@
import { get, post, put, del } from '../request'
import type { ModelItem, ModelForm, TrainedModel } from '@/types'
/** 模型列表 */
export const getModelList = () => get<ModelItem[]>('/model-manage')
/** 模型详情 */
export const getModel = (id: string | number) => get<ModelItem>(`/model-manage/${id}`)
/** 按名称查模型 */
export const getModelByName = (name: string) => get<ModelItem>(`/model-manage/name/${name}`)
/** 本地模型路径列表 */
export const getLocalModels = () =>
get<{ models: { path: string; name: string }[] }>('/model-manage/local-models')
/** 已训练模型列表 */
export const getTrainedModels = () =>
get<{ models: TrainedModel[] }>('/model-manage/trained-models')
/** 创建模型 */
export const createModel = (data: ModelForm) => post('/model-manage', data)
/** 编辑模型 */
export const updateModel = (id: string | number, data: ModelForm) =>
put(`/model-manage/${id}`, data)
/** 删除模型 */
export const deleteModel = (id: string | number) => del(`/model-manage/${id}`)
/** 删除已训练模型(合并权重) */
export const deleteTrainedModel = (id: string | number, type: 'merged' | 'lora' = 'merged') =>
del(`/model-manage/trained-models/${id}`, { type })
/** 更新模型用途 */
export const updateModelPurpose = (id: string | number, purpose: string) =>
put(`/model-manage/${id}/purpose`, { purpose })
/** 合并 LoRA 权重 */
export const mergeModel = (data: {
model_name: string
train_method: string
base_model_path: string
}) => post('/model-manage/merge', data)
/** 导出已训练模型权重 */
export const exportModelUrl = (modelName: string) =>
`/api/model-manage/trained-models/${encodeURIComponent(modelName)}/export`

View File

@@ -0,0 +1,12 @@
import { get, post } from '../request'
import type { SystemInfo, HealthMetrics } from '@/types'
/** 系统信息CPU/内存/磁盘/GPU/网络/系统) */
export const getSystemInfo = () => get<SystemInfo>('/system-info')
/** 健康指标(顶部栏轻量指标) */
export const getHealth = () => get<HealthMetrics>('/health')
/** 登录 */
export const login = (username: string, password: string) =>
post('/login', { username, password })

View File

@@ -0,0 +1,73 @@
import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'
import { ElMessage } from 'element-plus'
/**
* 后端统一响应格式
* code === 0 表示成功data 为业务数据
*/
export interface ApiResult<T = any> {
code: number
message?: string
data: T
}
const service: AxiosInstance = axios.create({
// 统一走相对路径,由 Vite 代理转发到 http://localhost:7861
baseURL: '/api',
timeout: 30000,
})
// 安装 mock adapter拦截所有 axios 请求返回 mock 数据,方便前端独立开发调试)
import { installMockAdapter } from '@/mock/adapter'
installMockAdapter(service)
// 请求拦截器
service.interceptors.request.use(
(config) => config,
(error) => Promise.reject(error),
)
// 响应拦截器:统一解包 { code, data, message }
service.interceptors.response.use(
(response) => {
const res = response.data as ApiResult
// 二进制流等非 JSON 响应直接返回
if (response.config.responseType === 'blob' || response.config.responseType === 'arraybuffer') {
return response
}
if (res.code === 0) {
return res.data
}
// 业务错误
const message = res.message || '请求失败'
ElMessage.error(message)
return Promise.reject(new Error(message))
},
(error) => {
const message = error.response?.data?.message || error.message || '网络异常'
ElMessage.error(message)
return Promise.reject(error)
},
)
/** GET 请求,返回已解包的 data */
export function get<T = any>(url: string, params?: any, config?: AxiosRequestConfig): Promise<T> {
return service.get(url, { params, ...config }) as unknown as Promise<T>
}
/** POST 请求 */
export function post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.post(url, data, config) as unknown as Promise<T>
}
/** PUT 请求 */
export function put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.put(url, data, config) as unknown as Promise<T>
}
/** DELETE 请求 */
export function del<T = any>(url: string, params?: any, config?: AxiosRequestConfig): Promise<T> {
return service.delete(url, { params, ...config }) as unknown as Promise<T>
}
export default service

View File

@@ -0,0 +1,35 @@
import { ref, onUnmounted } from 'vue'
/**
* 倒计时 composable日志自动刷新倒计时用
*/
export function useCountdown(total: number) {
const remaining = ref(total)
let timer: ReturnType<typeof setInterval> | null = null
function start() {
stop()
remaining.value = total
timer = setInterval(() => {
remaining.value--
if (remaining.value <= 0) {
remaining.value = total
}
}, 1000)
}
function reset() {
remaining.value = total
}
function stop() {
if (timer) {
clearInterval(timer)
timer = null
}
}
onUnmounted(stop)
return { remaining, start, stop, reset }
}

View File

@@ -0,0 +1,20 @@
import { useIntervalFn } from '@vueuse/core'
/**
* 轮询 composable
* 封装 useIntervalFn自动在组件卸载时清理
*/
export function usePolling(fn: () => void | Promise<void>, interval = 5000, immediate = true) {
const { pause, resume } = useIntervalFn(fn, interval, { immediate })
function stop() {
pause()
}
function start() {
if (immediate) fn()
resume()
}
return { start, stop, pause, resume }
}

View File

@@ -0,0 +1,143 @@
import { ref } from 'vue'
import { streamChat } from '@/api/modules/compare'
export interface StreamMessage {
/** 用户问题 */
question: string
/** 完整回答(含 think 标签原始内容) */
fullContent: string
/** 去除 think 标签后的展示内容 */
displayContent: string
/** 思考过程内容 */
thinkContent: string
/** 是否正在思考think 标签未闭合) */
isThinking: boolean
/** 是否流式中 */
isStreaming: boolean
/** 是否已完成 */
done: boolean
/** 错误信息 */
error?: string
}
/**
* 流式对话 composable
* 移植自原 model-chat.html
* - fetch + body.getReader() + TextDecoder
* - <think>...</think> 标签解析(思考过程可折叠)
* - 50ms 节流更新
*/
export function useStreamChat() {
const message = ref<StreamMessage>({
question: '',
fullContent: '',
displayContent: '',
thinkContent: '',
isThinking: false,
isStreaming: false,
done: false,
})
const loading = ref(false)
/** 从内容中解析 think 标签 */
function parseContent(content: string) {
const thinkRegex = /<think>([\s\S]*?)(<\/think>)?/g
let think = ''
let display = content
let isThinking = false
let match
// 检查是否有未闭合的 think 标签
const openTags = (content.match(/<think>/g) || []).length
const closeTags = (content.match(/<\/think>/g) || []).length
isThinking = openTags > closeTags
// 提取所有 think 内容
while ((match = thinkRegex.exec(content)) !== null) {
think += match[1]
}
// 去除 think 标签得到展示内容
display = content.replace(/<think>[\s\S]*?(<\/think>|$)/g, '').trim()
return { think: think.trim(), display, isThinking }
}
/**
* 发起流式对话
* @param payload 后端请求体 { port, model_name, model_path, system_prompt, user_question, ... }
*/
async function send(payload: any) {
loading.value = true
message.value = {
question: payload.user_question || '',
fullContent: '',
displayContent: '',
thinkContent: '',
isThinking: false,
isStreaming: true,
done: false,
}
let lastUpdate = 0
const UPDATE_INTERVAL = 50 // 50ms 节流
try {
const response = await streamChat(payload)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const reader = response.body?.getReader()
if (!reader) throw new Error('无法读取响应流')
const decoder = new TextDecoder('utf-8')
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
message.value.fullContent = buffer
const now = Date.now()
if (now - lastUpdate >= UPDATE_INTERVAL) {
lastUpdate = now
const parsed = parseContent(buffer)
message.value.thinkContent = parsed.think
message.value.displayContent = parsed.display
message.value.isThinking = parsed.isThinking
}
}
// 最终更新
const parsed = parseContent(buffer)
message.value.thinkContent = parsed.think
message.value.displayContent = parsed.display
message.value.isThinking = false
message.value.isStreaming = false
message.value.done = true
} catch (e: any) {
message.value.isStreaming = false
message.value.done = true
message.value.error = e.message || '流式请求失败'
message.value.displayContent = message.value.fullContent || message.value.error || '请求失败'
} finally {
loading.value = false
}
}
function reset() {
message.value = {
question: '',
fullContent: '',
displayContent: '',
thinkContent: '',
isThinking: false,
isStreaming: false,
done: false,
}
}
return { message, loading, send, reset }
}

View File

@@ -0,0 +1,268 @@
/**
* Axios Mock Adapter
* 拦截所有 API 请求并返回 mock 数据
* 通过 URL + method 路由到对应的 mock 响应
*/
import type { AxiosInstance, AxiosRequestConfig } from 'axios'
import {
mockLoginOk,
mockHealth,
mockSystemInfo,
mockModels,
mockTrainedModels,
mockLocalModels,
mockDatasets,
mockFineTuneList,
mockCompareList,
mockEvalList,
mockDimensions,
mockLogFiles,
mockTrainingLogFiles,
mockLogContent,
} from './data'
/** 模拟网络延迟 */
function delay(ms = 200): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/** 构造 Axios 风格的成功响应 */
function ok(data: any, config: AxiosRequestConfig = {}) {
return {
data: { code: 0, message: 'ok', data },
status: 200,
statusText: 'OK',
headers: {},
config,
}
}
/** 构造失败响应 */
function fail(message: string, code = 500, config: AxiosRequestConfig = {}) {
return {
data: { code, message },
status: code,
statusText: message,
headers: {},
config,
}
}
/** 简易 URL 解析(去掉 baseURL 前缀) */
function stripBaseURL(url: string): string {
// url 可能带查询参数
const [path] = url.split('?')
return path
}
/** 通过路径 + method 匹配 mock 响应 */
async function handleMock(config: AxiosRequestConfig) {
await delay(150) // 模拟网络延迟
const url = stripBaseURL(config.url || '')
const method = (config.method || 'get').toLowerCase()
const params = config.params || {}
const body = typeof config.data === 'string' ? safeJSON(config.data) : config.data || {}
// ==================== 认证 ====================
if (url === '/login' && method === 'post') {
if (body.username === 'admin' && body.password === 'admin') {
return ok(mockLoginOk.data)
}
return fail('账号或密码错误', 401)
}
if (url === '/web-log' && method === 'post') {
return ok({ received: true })
}
// ==================== 系统监控 ====================
if (url === '/health' && method === 'get') return ok(mockHealth)
if (url === '/system-info' && method === 'get') return ok(mockSystemInfo)
// ==================== 模型管理 ====================
if (url === '/model-manage' && method === 'get') return ok(mockModels)
if (url === '/model-manage/local-models' && method === 'get') return ok(mockLocalModels)
if (url === '/model-manage/trained-models' && method === 'get') return ok(mockTrainedModels)
if (url === '/model-manage/merge' && method === 'post') {
return ok({ merged: true, path: '/data/saves/' + body.model_name + '-merged' })
}
// 模型详情 / 编辑 / 删除 / 用途更新
let m = url.match(/^\/model-manage\/([^/]+)$/)
if (m && method === 'get') {
const id = m[1]
const found = mockModels.find((x) => String(x.id) === id || x.name === id)
return found ? ok(found) : fail('模型不存在', 404)
}
m = url.match(/^\/model-manage\/([^/]+)$/)
if (m && (method === 'put' || method === 'delete')) {
return ok({ id: m[1], ...body })
}
m = url.match(/^\/model-manage\/name\/([^/]+)$/)
if (m && method === 'get') {
const name = decodeURIComponent(m[1])
const found = mockModels.find((x) => x.name === name)
return found ? ok(found) : fail('模型不存在', 404)
}
m = url.match(/^\/model-manage\/trained-models\/([^/]+)$/)
if (m && method === 'delete') {
return ok({ deleted: m[1] })
}
m = url.match(/^\/model-manage\/([^/]+)\/purpose$/)
if (m && method === 'put') {
return ok({ id: m[1], purpose: body.purpose })
}
// ==================== 数据集 ====================
if (url === '/dataset-manage' && method === 'get') return ok(mockDatasets)
if (url === '/dataset-manage' && method === 'post') {
const newId = Math.max(...mockDatasets.map((d) => Number(d.id))) + 1
return ok({ id: newId })
}
m = url.match(/^\/dataset-manage\/([^/]+)$/)
if (m && method === 'get') {
const found = mockDatasets.find((x) => String(x.id) === m[1])
return found ? ok(found) : fail('数据集不存在', 404)
}
if (m && (method === 'put' || method === 'delete')) {
return ok({ id: m[1] })
}
m = url.match(/^\/dataset-manage\/upload\/([^/]+)$/)
if (m && method === 'post') return ok({ uploaded: true })
m = url.match(/^\/dataset-manage\/preview\/([^/]+)$/)
if (m && method === 'get') return ok(mockLogContent)
// ==================== 训练任务 ====================
if (url === '/fine-tune' && method === 'get') return ok(mockFineTuneList)
if (url === '/fine-tune' && method === 'post') {
return ok({ id: Math.floor(Math.random() * 10000) + 100 })
}
if (url === '/fine-tune/start' && method === 'post') {
return ok({ started: true })
}
if (url === '/fine-tune/check-name' && method === 'get') {
return ok({ exists: false })
}
m = url.match(/^\/fine-tune\/progress\/([^/]+)$/)
if (m && method === 'get') {
const task = mockFineTuneList.find((t) => String(t.id) === m[1])
if (!task) return fail('任务不存在', 404)
if (task.status === 'running') {
return ok({
status: task.status,
progress: task.progress ?? 0,
step: `${Math.floor((task.progress ?? 0) * 10)}/1000`,
speed: '1.23s/it',
eta: '00:23:45',
})
}
return ok({ status: task.status, progress: task.progress ?? 0 })
}
m = url.match(/^\/fine-tune\/([^/]+)$/)
if (m && method === 'get') {
const found = mockFineTuneList.find((x) => String(x.id) === m[1])
return found ? ok(found) : fail('任务不存在', 404)
}
m = url.match(/^\/fine-tune\/stop\/([^/]+)$/)
if (m && method === 'post') return ok({ stopped: true })
m = url.match(/^\/fine-tune\/([^/]+)$/)
if (m && (method === 'put' || method === 'delete')) {
return ok({ id: m[1] })
}
if (url === '/fine-tune/tensorboard/start' && method === 'post') {
return ok({ url: 'http://10.10.10.77:6006' })
}
// ==================== 模型推理/对比 ====================
if (url === '/model-compare' && method === 'get') return ok(mockCompareList)
if (url === '/model-compare' && method === 'post') {
return ok({ id: Math.floor(Math.random() * 10000) + 100 })
}
m = url.match(/^\/model-compare\/([^/]+)$/)
if (m && method === 'get') {
const found = mockCompareList.find((x) => String(x.id) === m[1])
return found ? ok(found) : fail('任务不存在', 404)
}
if (m && method === 'delete') return ok({ deleted: m[1] })
m = url.match(/^\/model-compare\/([^/]+)\/load-status$/)
if (m && method === 'get') {
return ok({ all_ready: true, loaded_models: [] })
}
if (m && method === 'post') return ok({ updated: true })
m = url.match(/^\/model-compare\/([^/]+)\/load$/)
if (m && method === 'post') return ok({ loading: true })
m = url.match(/^\/model-compare\/([^/]+)\/unload$/)
if (m && method === 'post') return ok({ stopped: true })
m = url.match(/^\/model-compare\/([^/]+)\/start-model$/)
if (m && method === 'post') {
return ok({ pid: 12345 + Math.floor(Math.random() * 100), port: 18000 + Math.floor(Math.random() * 1000) })
}
if (url === '/model-compare/all/stop-all' && method === 'post') return ok({ stopped: true })
if (url === '/model-compare/stop-by-pid' && method === 'post') return ok({ stopped: true })
if (url === '/model-compare/chat-with-port' && method === 'post') {
// 模拟推理回答(用于对比结果页)
const userQ = body?.messages?.find((m: any) => m.role === 'user')?.content || '你的问题'
const answer = `这是一个针对「${userQ.slice(0, 30)}」的模拟回答。\n\n在真实环境中对比结果页会通过端口代理调用对应模型服务返回该模型的真实推理结果。\n\n模型参数量、温度、Top-p 等参数都会影响输出内容。\n\n- 模型:${body?.model_name || '未知'}\n- 端口:${body?.port || '-'}\n- 参数temperature=${body?.temperature ?? 0.7}, max_tokens=${body?.max_tokens ?? 2048}`
return ok({ response: answer, content: answer })
}
if (url === '/model-compare/stream-chat' && method === 'post') {
// 模拟流式对话(前端 useStreamChat 会按块读取)
const userQ = body?.user_question || ''
const sysPrompt = body?.system_prompt || ''
const answer = `${sysPrompt ? '【系统提示】' + sysPrompt.slice(0, 50) + '\n\n' : ''}关于「${userQ}」的回答:\n\n这是一段**模拟流式输出**。在真实部署中,后端会通过 SSE/WebSocket 逐字推送 token。\n\n## 模型参数\n- 温度temperature${body?.temperature ?? 0.7}\n- 最大长度max_tokens${body?.max_tokens ?? 2048}\n\n## 思考过程\n让我先分析这个问题...\n- 识别用户意图\n- 检索相关知识\n- 生成回答\n\n回答已生成。以上为前端 Mock 演示内容。`
return ok({ response: answer })
}
if (url === '/model-compare/test-stream' && method === 'post') {
return ok({ response: '测试流式输出' })
}
if (url === '/model-chat/batch' && method === 'post') return ok({ responses: [] })
if (url === '/model-chat/local/chat' && method === 'post') return ok({ response: '本地模型回复' })
if (url === '/model-chat/local/preload' && method === 'post') return ok({ loaded: true })
if (url === '/model-chat/trained/preload' && method === 'post') return ok({ loaded: true })
// ==================== 模型评测 ====================
if (url === '/model-eval' && method === 'get') return ok(mockEvalList)
if (url === '/model-eval' && method === 'delete') return ok({ deleted: true })
if (url === '/model-eval/start' && method === 'post') return ok({ task_id: Math.floor(Math.random() * 1000) + 1 })
if (url === '/dimension' && method === 'get') return ok(mockDimensions)
if (url === '/dimension' && method === 'post') {
return ok({ id: Math.floor(Math.random() * 1000) + 100 })
}
m = url.match(/^\/dimension\/([^/]+)$/)
if (m && method === 'get') {
const found = mockDimensions.find((x) => String(x.id) === m[1])
return found ? ok(found) : fail('维度不存在', 404)
}
if (m && (method === 'put' || method === 'delete')) {
return ok({ id: m[1] })
}
// ==================== 日志 ====================
if (url === '/log-files' && method === 'get') return ok(mockLogFiles)
if (url === '/log-content' && method === 'get') return ok(mockLogContent)
if (url === '/training-log-files' && method === 'get') return ok(mockTrainingLogFiles)
if (url === '/training-log-content' && method === 'get') return ok(mockLogContent)
// 未匹配的请求 → 兜底返回空成功(避免阻断 UI
console.warn('[Mock] 未匹配路由:', method.toUpperCase(), url, params)
return ok({ mocked: true, url, method, params, body })
}
function safeJSON(str: string) {
try {
return JSON.parse(str)
} catch {
return {}
}
}
/** 给 axios instance 安装 mock adapter */
export function installMockAdapter(instance: AxiosInstance) {
instance.defaults.adapter = async (config: AxiosRequestConfig) => {
try {
const response = await handleMock(config)
return response
} catch (e: any) {
return fail(e.message || 'Mock 错误', 500, config)
}
}
}

230
frontend/src/mock/data.ts Normal file
View File

@@ -0,0 +1,230 @@
/**
* 全量 Mock 数据
* 为前端开发提供不依赖后端的模拟数据
*/
import type {
FineTuneTask,
ModelItem,
TrainedModel,
DatasetItem,
CompareTask,
EvalTask,
Dimension,
SystemInfo,
HealthMetrics,
LogFile,
TrainingLogFile,
LogContent,
} from '@/types'
// ============ 认证 ============
export const mockLoginOk = { code: 0, message: 'ok', data: { token: 'mock-token' } }
// ============ 系统监控 ============
export const mockHealth: HealthMetrics = {
cpu_percent: 32,
memory_percent: 58,
disk_percent: 45,
}
export const mockSystemInfo: SystemInfo = {
cpu: {
percent: 32,
cores: 8,
percents: [25, 38, 42, 30, 28, 36, 40, 18],
},
memory: {
used_gb: 9.2,
total_gb: 16,
percent: 58,
available_gb: 6.8,
cached_gb: 2.3,
},
disk: {
used_gb: 230,
total_gb: 512,
percent: 45,
},
gpu: [
{ name: 'NVIDIA A800', gpu_percent: 0, memory_used_gb: 0.0, memory_total_gb: 80, temperature: 32, power_w: 38, fan_speed: 0, clock_mhz: 1410, driver_version: '535.86.10' },
{ name: 'NVIDIA A800', gpu_percent: 28, memory_used_gb: 22.5, memory_total_gb: 80, temperature: 52, power_w: 165, fan_speed: 32, clock_mhz: 1410, driver_version: '535.86.10' },
{ name: 'NVIDIA A800', gpu_percent: 73, memory_used_gb: 58.7, memory_total_gb: 80, temperature: 68, power_w: 320, fan_speed: 58, clock_mhz: 1410, driver_version: '535.86.10' },
{ name: 'NVIDIA A800', gpu_percent: 0, memory_used_gb: 0.0, memory_total_gb: 80, temperature: 34, power_w: 42, fan_speed: 0, clock_mhz: 1410, driver_version: '535.86.10' },
{ name: 'NVIDIA A800', gpu_percent: 46, memory_used_gb: 36.8, memory_total_gb: 80, temperature: 60, power_w: 210, fan_speed: 42, clock_mhz: 1410, driver_version: '535.86.10' },
{ name: 'NVIDIA A800', gpu_percent: 0, memory_used_gb: 0.0, memory_total_gb: 80, temperature: 33, power_w: 40, fan_speed: 0, clock_mhz: 1410, driver_version: '535.86.10' },
{ name: 'NVIDIA A800', gpu_percent: 85, memory_used_gb: 68.2, memory_total_gb: 80, temperature: 73, power_w: 365, fan_speed: 62, clock_mhz: 1410, driver_version: '535.86.10' },
{ name: 'NVIDIA A800', gpu_percent: 15, memory_used_gb: 12.0, memory_total_gb: 80, temperature: 45, power_w: 95, fan_speed: 22, clock_mhz: 1410, driver_version: '535.86.10' },
],
network: {
download_mb: 1024,
upload_mb: 256,
},
system: {
uptime_seconds: 86400 * 3 + 3600 * 7 + 1800,
process_count: 256,
os: 'Ubuntu 22.04 LTS',
},
}
// ============ 模型管理 ============
export const mockModels: ModelItem[] = [
{ id: 1, name: 'Qwen2.5-7B-Instruct', type: 'LLM', purpose: 'training', model_source: 'local', description: 'Qwen2.5 7B 指令微调基座', path: '/data/models/qwen2.5-7b', create_time: '2025-12-10T08:30:00Z' },
{ id: 2, name: 'Qwen2.5-14B-Instruct', type: 'LLM', purpose: 'training', model_source: 'local', description: 'Qwen2.5 14B 指令微调基座', path: '/data/models/qwen2.5-14b', create_time: '2025-12-12T10:15:00Z' },
{ id: 3, name: 'Llama3-8B-Instruct', type: 'LLM', purpose: 'inference', model_source: 'local', description: 'Llama3 8B 推理模型', path: '/data/models/llama3-8b', create_time: '2025-12-15T14:20:00Z' },
{ id: 4, name: 'DeepSeek-V2-Lite', type: 'LLM', purpose: 'inference', model_source: 'local', description: 'DeepSeek V2 Lite', path: '/data/models/deepseek-v2-lite', create_time: '2026-01-05T09:00:00Z' },
{ id: 5, name: 'GPT-4o', type: 'LLM', purpose: 'evaluation', model_source: 'api', description: 'OpenAI GPT-4o 在线模型', api_url: 'https://api.openai.com/v1', api_key: 'sk-***', online_model_name: 'gpt-4o', create_time: '2026-01-08T11:30:00Z' },
{ id: 6, name: 'Claude-3.5-Sonnet', type: 'LLM', purpose: 'evaluation', model_source: 'api', description: 'Anthropic Claude 3.5 Sonnet', api_url: 'https://api.anthropic.com', api_key: 'sk-***', online_model_name: 'claude-3-5-sonnet-20241022', create_time: '2026-01-10T16:45:00Z' },
{ id: 7, name: 'BGE-large-zh', type: 'Embedding', purpose: 'inference', model_source: 'local', description: '中文 embedding 模型', path: '/data/models/bge-large-zh', create_time: '2026-01-12T13:00:00Z' },
]
export const mockTrainedModels: { models: TrainedModel[] } = {
models: [
{ id: 1, name: 'qwen-ft-finance-001', train_methods: [{ name: 'lora' }], base_model_path: '/data/models/qwen2.5-7b', merged: true, merging: false, merged_path: '/data/saves/qwen-ft-finance-001-merged', create_time: '2026-01-15T10:30:00Z' },
{ id: 2, name: 'qwen-ft-legal-002', train_methods: [{ name: 'lora' }], base_model_path: '/data/models/qwen2.5-7b', merged: false, merging: true, create_time: '2026-01-18T14:20:00Z' },
{ id: 3, name: 'llama3-ft-customer-service', train_methods: [{ name: 'qlora' }], base_model_path: '/data/models/llama3-8b', merged: true, merging: false, merged_path: '/data/saves/llama3-ft-customer-service-merged', create_time: '2026-01-22T09:45:00Z' },
{ id: 4, name: 'qwen-ft-medical-003', train_methods: [{ name: 'lora' }], base_model_path: '/data/models/qwen2.5-14b', merged: false, merging: false, create_time: '2026-02-01T16:10:00Z' },
],
}
export const mockLocalModels = {
models: [
{ path: '/data/models/qwen2.5-7b', name: 'Qwen2.5-7B-Instruct' },
{ path: '/data/models/qwen2.5-14b', name: 'Qwen2.5-14B-Instruct' },
{ path: '/data/models/llama3-8b', name: 'Llama3-8B-Instruct' },
{ path: '/data/models/deepseek-v2-lite', name: 'DeepSeek-V2-Lite' },
{ path: '/data/models/bge-large-zh', name: 'BGE-large-zh' },
],
}
// ============ 数据集 ============
export const mockDatasets: DatasetItem[] = [
{ id: 1, name: '金融问答-训练集', type: 'train', storage_type: 'local', size: '128 MB', count: 8560, description: '金融领域问答对', create_time: '2025-12-20T08:00:00Z' },
{ id: 2, name: '法律文书-训练集', type: 'train', storage_type: 'local', size: '256 MB', count: 15230, description: '法律文书数据集', create_time: '2025-12-25T10:30:00Z' },
{ id: 3, name: '客服对话-训练集', type: 'train', storage_type: 'minio', size: '512 MB', count: 24500, description: '客服对话记录', create_time: '2026-01-05T14:20:00Z' },
{ id: 4, name: '金融评测集', type: 'eval', storage_type: 'local', size: '32 MB', count: 1200, description: '金融领域评测', create_time: '2026-01-10T09:15:00Z' },
{ id: 5, name: '通用能力评测', type: 'eval', storage_type: 'local', size: '64 MB', count: 3500, description: '通用能力评测数据集', create_time: '2026-01-12T11:30:00Z' },
{ id: 6, name: '医疗问答-训练集', type: 'train', storage_type: 'local', size: '180 MB', count: 9800, description: '医疗问答对', create_time: '2026-02-01T15:00:00Z' },
]
// ============ 训练任务 ============
export const mockFineTuneList: FineTuneTask[] = [
{ id: 1, name: 'finance-sft-001', description: '金融领域 SFT 训练', status: 'completed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 1, gpus: [0], progress: 100, train_duration: '2小时18分钟', create_time: '2026-01-15T08:00:00Z' },
{ id: 2, name: 'legal-sft-002', description: '法律文书 SFT', status: 'completed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 2, gpus: [1], progress: 100, train_duration: '1小时46分钟', create_time: '2026-01-18T10:00:00Z' },
{ id: 3, name: 'medical-cpt-001', description: '医疗领域继续预训练', status: 'running', train_type: 'CPT', train_method: 'lora', template: 'qwen2_5', base_model: 2, train_dataset_id: 6, gpus: [0, 1], progress: 64, train_duration: '36分钟', create_time: '2026-02-05T09:00:00Z' },
{ id: 4, name: 'service-dpo-001', description: '客服对话偏好训练', status: 'pending', train_type: 'DPO', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 3, gpus: [2], progress: 0, train_duration: '-', create_time: '2026-02-08T14:00:00Z' },
{ id: 5, name: 'finance-sft-002', description: '金融领域二轮微调', status: 'failed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 1, gpus: [3], progress: 32, train_duration: '18分钟', create_time: '2026-02-10T11:00:00Z' },
{ id: 6, name: 'general-sft-001', description: '通用能力微调', status: 'completed', train_type: 'SFT', train_method: 'full', template: 'llama3', base_model: 3, train_dataset_id: 3, gpus: [0, 2], progress: 100, train_duration: '3小时05分钟', create_time: '2026-02-12T13:00:00Z' },
]
// ============ 模型推理/对比 ============
export const mockCompareList: CompareTask[] = [
{
id: 1,
name: '金融问答对比',
model_name: '金融问答对比',
description: '对比基座模型与微调模型',
status: 'loaded',
models: JSON.stringify([
{ model_id: 1, model_name: 'Qwen2.5-7B-Instruct', model_path: '/data/models/qwen2.5-7b', gpu_id: 0, source: 'database', port: 18001 },
{ model_id: 101, model_name: 'qwen-ft-finance-001', model_path: '/data/saves/qwen-ft-finance-001-merged', gpu_id: 1, source: 'trained', port: 18002 },
]),
load_status: JSON.stringify({
loaded_models: [
{ model_id: 1, model_name: 'Qwen2.5-7B-Instruct', status: 'ready', pid: 12345, port: 18001 },
{ model_id: 101, model_name: 'qwen-ft-finance-001', status: 'ready', pid: 12346, port: 18002 },
],
}),
create_time: '2026-02-15T10:00:00Z',
},
{
id: 2,
name: '客服场景推理',
model_name: '客服场景推理',
description: '客服对话推理测试',
status: 'pending',
models: JSON.stringify([{ model_id: 3, model_name: 'Llama3-8B-Instruct', model_path: '/data/models/llama3-8b', gpu_id: 2, source: 'database' }]),
load_status: JSON.stringify({ loaded_models: [] }),
create_time: '2026-02-18T11:00:00Z',
},
{
id: 3,
name: '法律文书推理',
model_name: '法律文书推理',
description: '法律文书推理测试',
status: 'loaded',
models: JSON.stringify([{ model_id: 102, model_name: 'qwen-ft-legal-002', model_path: '/data/saves/qwen-ft-legal-002-merged', gpu_id: 3, source: 'trained' }]),
load_status: JSON.stringify({
loaded_models: [
{ model_id: 102, model_name: 'qwen-ft-legal-002', status: 'ready', pid: 12350, port: 18003 },
],
}),
create_time: '2026-02-20T14:00:00Z',
},
]
// ============ 模型评测 ============
export const mockEvalList: EvalTask[] = [
{ id: 1, eval_task_name: '金融模型评测-v1', model_name: 'qwen-ft-finance-001', dataset: '金融评测集', metric: 'accuracy', score: 87.5, status: 'completed', create_time: '2026-02-01T10:00:00Z' },
{ id: 2, eval_task_name: '客服模型评测-v1', model_name: 'llama3-ft-customer-service', dataset: '通用能力评测', metric: 'rouge-1', score: 0.82, status: 'completed', create_time: '2026-02-05T11:00:00Z' },
{ id: 3, eval_task_name: '基线对比评测', model_name: 'Qwen2.5-7B-Instruct', dataset: '金融评测集', metric: 'accuracy', score: 72.3, status: 'running', create_time: '2026-02-10T09:00:00Z' },
]
export const mockDimensions: Dimension[] = [
{ id: 1, name: '回答准确性', type: 'classification', description: '评估模型回答是否准确', eval_model: 'GPT-4o', eval_method: 'standard', eval_prompt: '# 角色\n你是专业的评估专家...', is_active: true, is_default: true, create_time: '2025-12-01T08:00:00Z' },
{ id: 2, name: '综合评分', type: 'metric', description: '0-5 分综合评分', eval_model: 'Claude-3.5-Sonnet', eval_method: 'metric_standard', eval_prompt: '# 角色\n你是专业评分专家...', is_active: true, is_default: false, score_min: 0, score_max: 5, pass_threshold: 3.5, create_time: '2025-12-05T09:00:00Z' },
{ id: 3, name: '语义相似度', type: 'metric', description: '生成文本与参考答案的语义相似度', eval_model: 'GPT-4o', eval_method: 'semantic', eval_prompt: '# 角色\n你是语义相似度评估专家...', is_active: true, is_default: false, score_min: 0, score_max: 1, pass_threshold: 0.7, create_time: '2025-12-08T10:00:00Z' },
{ id: 4, name: 'BLEU-4 相似度', type: 'text_similarity', description: '使用 BLEU-4 评估文本相似度', is_active: true, is_default: false, eval_method: ['bleu_4'], bleu_n: 4, output_precision: 3, create_time: '2025-12-10T11:00:00Z' },
{ id: 5, name: 'ROUGE 多指标', type: 'text_similarity', description: 'ROUGE-1/2/4 多指标评估', is_active: false, is_default: false, eval_method: ['rouge_1', 'rouge_2', 'rouge_4'], bleu_n: 1, output_precision: 3, create_time: '2025-12-12T13:00:00Z' },
]
// ============ 日志 ============
export const mockLogFiles: LogFile[] = [
{ file: 'system-2026-02-15.log', name: '系统日志-2026-02-15', size: '2.3 MB' },
{ file: 'error-2026-02-15.log', name: '错误日志-2026-02-15', size: '156 KB' },
{ file: 'system-2026-02-14.log', name: '系统日志-2026-02-14', size: '3.1 MB' },
]
export const mockTrainingLogFiles: TrainingLogFile[] = [
{ file: 'qwen-ft-finance-001_pid12345.log', name: 'finance-sft-001', size: '4.5 MB', pid: 12345, date: '2026-02-15' },
{ file: 'llama3-ft-customer-service_pid12346.log', name: 'service-dpo-001', size: '2.1 MB', pid: 12346, date: '2026-02-18' },
{ file: 'qwen-ft-legal-002_pid12350.log', name: 'legal-sft-002', size: '5.8 MB', pid: 12350, date: '2026-02-20' },
]
const fakeLogLines = [
"[2026-02-15 08:30:12] INFO: Loading model from /data/models/qwen2.5-7b",
"[2026-02-15 08:30:13] INFO: Loading dataset finance-train-001 (8560 samples)",
"[2026-02-15 08:30:15] INFO: Training started with batch_size=8, learning_rate=2e-5",
"[2026-02-15 08:32:45] INFO: {'loss': 2.341, 'grad_norm': 1.234, 'learning_rate': 1.95e-05, 'epoch': 0.05}",
"[2026-02-15 08:34:15] INFO: {'loss': 1.892, 'grad_norm': 0.987, 'learning_rate': 1.88e-05, 'epoch': 0.10}",
"[2026-02-15 08:35:42] INFO: {'loss': 1.543, 'grad_norm': 0.876, 'learning_rate': 1.79e-05, 'epoch': 0.15}",
"[2026-02-15 08:37:10] INFO: {'loss': 1.287, 'grad_norm': 0.765, 'learning_rate': 1.70e-05, 'epoch': 0.20}",
"[2026-02-15 08:38:55] INFO: {'loss': 1.056, 'grad_norm': 0.654, 'learning_rate': 1.60e-05, 'epoch': 0.25}",
"[2026-02-15 08:40:30] WARN: Gradient norm exceeds threshold (0.654 > 0.5)",
"[2026-02-15 08:42:00] INFO: {'loss': 0.892, 'grad_norm': 0.543, 'learning_rate': 1.50e-05, 'epoch': 0.30}",
"[2026-02-15 08:45:15] INFO: Saved checkpoint to /data/checkpoints/finance-sft-001-step-100",
"[2026-02-15 08:46:30] INFO: {'loss': 0.754, 'grad_norm': 0.432, 'learning_rate': 1.40e-05, 'epoch': 0.35}",
"[2026-02-15 08:48:00] INFO: {'loss': 0.623, 'grad_norm': 0.398, 'learning_rate': 1.30e-05, 'epoch': 0.40}",
"[2026-02-15 08:50:15] INFO: {'loss': 0.512, 'grad_norm': 0.345, 'learning_rate': 1.20e-05, 'epoch': 0.45}",
"[2026-02-15 08:52:30] ERROR: Failed to save model: No space left on device",
"[2026-02-15 08:52:31] INFO: Retrying save with compressed format...",
"[2026-02-15 08:53:00] INFO: Model saved successfully (size: 14.2 GB)",
"[2026-02-15 08:55:00] INFO: {'loss': 0.421, 'grad_norm': 0.298, 'learning_rate': 1.10e-05, 'epoch': 0.50}",
"[2026-02-15 08:57:30] INFO: {'loss': 0.356, 'grad_norm': 0.256, 'learning_rate': 1.00e-05, 'epoch': 0.55}",
"[2026-02-15 09:00:00] INFO: Training completed successfully",
"",
"***** train metrics *****",
" epoch = 1.0",
" total_flos = 1234567890",
" train_loss = 0.342",
" train_runtime = 1785.2",
" train_samples_per_second = 4.79",
" train_steps_per_second = 0.60",
"***** train metrics end *****",
]
export const mockLogContent: LogContent = {
file: 'system-2026-02-15.log',
size: '2.3 MB',
content: fakeLogLines.join('\n'),
}

View File

@@ -0,0 +1,45 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { login as loginApi } from '@/api/modules/system'
import { SESSION_TIMEOUT } from '@/constants'
/**
* 认证 store
* 沿用原项目 localStorage 的登录时间戳 + 5 分钟会话超时机制
*/
export const useAuthStore = defineStore('auth', () => {
const username = ref<string>(localStorage.getItem('username') || '')
const loginTime = ref<number>(parseInt(localStorage.getItem('loginTime') || '0', 10) || 0)
const isLoggedIn = computed(() => {
if (!loginTime.value) return false
return Date.now() - loginTime.value < SESSION_TIMEOUT
})
/** 登录 */
async function login(user: string, password: string) {
await loginApi(user, password)
username.value = user
loginTime.value = Date.now()
localStorage.setItem('username', user)
localStorage.setItem('loginTime', String(loginTime.value))
}
/** 续期会话(活跃时刷新) */
function refresh() {
if (isLoggedIn.value) {
loginTime.value = Date.now()
localStorage.setItem('loginTime', String(loginTime.value))
}
}
/** 退出 */
function logout() {
username.value = ''
loginTime.value = 0
localStorage.removeItem('username')
localStorage.removeItem('loginTime')
}
return { username, loginTime, isLoggedIn, login, refresh, logout }
})

View File

@@ -0,0 +1,34 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getModelList } from '@/api/modules/model'
import type { ModelItem } from '@/types'
/**
* 模型列表缓存 store
* 列表页根据 base_model id 渲染模型名时使用
*/
export const useModelsStore = defineStore('models', () => {
const list = ref<ModelItem[]>([])
const loaded = ref(false)
async function load(force = false) {
if (loaded.value && !force) return
try {
list.value = (await getModelList()) || []
loaded.value = true
} catch {
list.value = []
}
}
/** 根据 id 获取模型名 */
function getModelName(modelId: string | number): string {
if (!modelId) return '-'
const model = list.value.find(
(m) => m.id == modelId || m.id === String(modelId) || m.id === Number(modelId),
)
return model ? model.name : `模型${modelId}`
}
return { list, loaded, load, getModelName }
})

View File

@@ -0,0 +1,36 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getHealth } from '@/api/modules/system'
import type { HealthMetrics } from '@/types'
/**
* 顶部栏系统监控 store
* 30s 轮询 CPU/内存/磁盘使用率
*/
export const useSystemStore = defineStore('system', () => {
const metrics = ref<HealthMetrics>({})
let timer: ReturnType<typeof setInterval> | null = null
async function fetchMetrics() {
try {
metrics.value = await getHealth()
} catch {
// 静默失败,顶部栏非关键
}
}
function start() {
if (timer) return
fetchMetrics()
timer = setInterval(fetchMetrics, 30000)
}
function stop() {
if (timer) {
clearInterval(timer)
timer = null
}
}
return { metrics, fetchMetrics, start, stop }
})

View File

@@ -0,0 +1,48 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import type { CustomTool } from '@/types'
/**
* 自定义工具 storelocalStorage 持久化)
* 原 web 项目 customTools 仅存本地,无后端
*/
export const useToolsStore = defineStore('tools', () => {
const STORAGE_KEY = 'customTools'
const tools = ref<CustomTool[]>(loadFromStorage())
function loadFromStorage(): CustomTool[] {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]')
} catch {
return []
}
}
// 持久化
watch(
tools,
(val) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(val))
},
{ deep: true },
)
function addTool(tool: CustomTool) {
tools.value.push(tool)
}
function updateTool(id: string, data: Partial<CustomTool>) {
const idx = tools.value.findIndex((t) => t.id === id)
if (idx !== -1) tools.value[idx] = { ...tools.value[idx], ...data }
}
function removeTool(id: string) {
tools.value = tools.value.filter((t) => t.id !== id)
}
function getTool(id: string): CustomTool | undefined {
return tools.value.find((t) => t.id === id)
}
return { tools, addTool, updateTool, removeTool, getTool }
})