feat: 实现基础设施层
axios 请求封装及七个业务模块 API,Pinia 状态管理(auth/system/models/tools),Mock 适配器与数据,以及流式对话、轮询、倒计时组合式函数。
This commit is contained in:
80
frontend/src/api/modules/compare.ts
Normal file
80
frontend/src/api/modules/compare.ts
Normal 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)
|
||||||
40
frontend/src/api/modules/dataset.ts
Normal file
40
frontend/src/api/modules/dataset.ts
Normal 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}`
|
||||||
27
frontend/src/api/modules/eval.ts
Normal file
27
frontend/src/api/modules/eval.ts
Normal 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}`)
|
||||||
40
frontend/src/api/modules/fineTune.ts
Normal file
40
frontend/src/api/modules/fineTune.ts
Normal 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() })
|
||||||
18
frontend/src/api/modules/log.ts
Normal file
18
frontend/src/api/modules/log.ts
Normal 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 })
|
||||||
48
frontend/src/api/modules/model.ts
Normal file
48
frontend/src/api/modules/model.ts
Normal 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`
|
||||||
12
frontend/src/api/modules/system.ts
Normal file
12
frontend/src/api/modules/system.ts
Normal 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 })
|
||||||
73
frontend/src/api/request.ts
Normal file
73
frontend/src/api/request.ts
Normal 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
|
||||||
35
frontend/src/composables/useCountdown.ts
Normal file
35
frontend/src/composables/useCountdown.ts
Normal 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 }
|
||||||
|
}
|
||||||
20
frontend/src/composables/usePolling.ts
Normal file
20
frontend/src/composables/usePolling.ts
Normal 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 }
|
||||||
|
}
|
||||||
143
frontend/src/composables/useStreamChat.ts
Normal file
143
frontend/src/composables/useStreamChat.ts
Normal 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 }
|
||||||
|
}
|
||||||
268
frontend/src/mock/adapter.ts
Normal file
268
frontend/src/mock/adapter.ts
Normal 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
230
frontend/src/mock/data.ts
Normal 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'),
|
||||||
|
}
|
||||||
45
frontend/src/stores/auth.ts
Normal file
45
frontend/src/stores/auth.ts
Normal 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 }
|
||||||
|
})
|
||||||
34
frontend/src/stores/models.ts
Normal file
34
frontend/src/stores/models.ts
Normal 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 }
|
||||||
|
})
|
||||||
36
frontend/src/stores/system.ts
Normal file
36
frontend/src/stores/system.ts
Normal 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 }
|
||||||
|
})
|
||||||
48
frontend/src/stores/tools.ts
Normal file
48
frontend/src/stores/tools.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import type { CustomTool } from '@/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义工具 store(localStorage 持久化)
|
||||||
|
* 原 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 }
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user