chore: 移除 frontend/ 和图标资源目录;彻底清理遗留脚手架

This commit is contained in:
DXC
2026-06-08 12:13:37 +08:00
parent 1cbd38a8e0
commit d5dd2ba1da
70 changed files with 5 additions and 843 deletions

8
.gitignore vendored
View File

@ -187,9 +187,11 @@ data/sub/waterindex*.xlsx
data/sub/png/watermask.png data/sub/png/watermask.png
# 图标文件(仅需保留 vector/svg删除像素图标压缩包副本 # 图标文件(仅需保留 vector/svg删除像素图标压缩包副本
data/icons-1/*.ico data/icons-1/
data/icons/*.png data/icons/
data/icons/word/*.png
# 旧版脚手架(遗留实验代码) # 旧版脚手架(遗留实验代码)
new/ new/
# 前端脚手架(未集成的独立 Vue 项目)
frontend/

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 950 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 978 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 884 KiB

View File

@ -1,2 +0,0 @@
# 联调期指向本地 FastAPI dev 服务
VITE_API_BASE_URL=http://127.0.0.1:9090

7
frontend/.gitignore vendored
View File

@ -1,7 +0,0 @@
node_modules
dist
dist-ssr
.vite
*.local
.DS_Store
*.log

15
frontend/env.d.ts vendored
View File

@ -1,15 +0,0 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WQ_GUI · 水质反演联调控制台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -1,25 +0,0 @@
{
"name": "wq-gui-frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"vue": "^3.4.27",
"element-plus": "^2.7.5",
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.2"
},
"devDependencies": {
"@types/node": "^20.12.12",
"@vitejs/plugin-vue": "^5.0.4",
"typescript": "^5.4.5",
"vite": "^5.2.11",
"vue-tsc": "^2.0.19"
}
}

View File

@ -1,225 +0,0 @@
<template>
<div class="dashboard-container">
<h1 class="title">高光谱水质反演控制台</h1>
<el-row :gutter="20">
<el-col :span="12">
<el-card class="box-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="header-title">🚀 模型训练 (Train)</span>
</div>
</template>
<el-form label-position="top">
<el-form-item label="算法选择 (Model Type)">
<el-select v-model="trainForm.model_type" placeholder="请选择算法" class="w-full">
<el-option label="随机森林 (RF)" value="RF" />
<el-option label="支持向量回归 (SVR)" value="SVR" />
<el-option label="线性回归 (LinearRegression)" value="LinearRegression" />
<el-option label="K近邻 (KNN)" value="KNN" />
<el-option label="偏最小二乘 (PLS)" value="PLS" />
</el-select>
</el-form-item>
<el-form-item label="目标参数 (Target)">
<el-input v-model="trainForm.target" placeholder="如 Chl-a" />
</el-form-item>
<el-form-item label="训练数据路径 (CSV 绝对路径)">
<el-input v-model="trainForm.train_data_path" placeholder="如 D:\111\data.csv" />
</el-form-item>
<el-form-item label="特征起始列 (如 4, 或列名)">
<el-input v-model="trainForm.feature_start" placeholder="填写数字或列名" />
</el-form-item>
<el-button type="primary" @click="handleTrain" :loading="trainPoller?.isPolling?.value" class="w-full">
开始训练
</el-button>
</el-form>
<div v-if="trainTaskId" class="status-board">
<p><strong>任务 ID:</strong> <el-tag size="small" type="info">{{ trainTaskId }}</el-tag></p>
<p><strong>当前状态:</strong>
<el-tag :type="getStatusType(trainPoller?.status?.value || 'PENDING')" style="margin-left:10px">
{{ trainPoller?.status?.value || 'PENDING' }}
</el-tag>
</p>
<el-progress
v-if="trainPoller?.isPolling?.value || trainPoller?.status?.value === 'SUCCESS'"
:percentage="trainPoller?.status?.value === 'SUCCESS' ? 100 : 60"
:status="trainPoller?.status?.value === 'SUCCESS' ? 'success' : (trainPoller?.status?.value === 'FAILED' ? 'exception' : '')"
:indeterminate="trainPoller?.isPolling?.value"
/>
<div v-if="trainPoller?.error?.value" class="error-msg">
<el-alert :title="trainPoller.error.value" type="error" :closable="false" show-icon />
</div>
<div v-if="trainPoller?.result?.value?.model_id" class="result-msg">
<el-descriptions border :column="1" size="small" title="训练指标">
<el-descriptions-item label="Model ID">{{ trainPoller.result.value.model_id }}</el-descriptions-item>
<el-descriptions-item label="Test R²">{{ Number(trainPoller.result.value.test_r2).toFixed(4) }}</el-descriptions-item>
<el-descriptions-item label="Test RMSE">{{ Number(trainPoller.result.value.test_rmse).toFixed(4) }}</el-descriptions-item>
</el-descriptions>
</div>
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card class="box-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="header-title">🎯 模型推断 (Predict)</span>
</div>
</template>
<el-form label-position="top">
<el-form-item label="已训练模型 ID (Model ID)">
<el-input v-model="predictForm.model_id" placeholder="将自动填入左侧训练好的 ID" />
</el-form-item>
<el-form-item label="待推断影像路径 (Zarr 绝对路径)">
<el-input v-model="predictForm.input_zarr_path" placeholder="如 D:\111\image.zarr" />
</el-form-item>
<el-button type="success" @click="handlePredict" :loading="predictPoller?.isPolling?.value" class="w-full">
开始大图反演推断
</el-button>
</el-form>
<div v-if="predictTaskId" class="status-board">
<p><strong>任务 ID:</strong> <el-tag size="small" type="info">{{ predictTaskId }}</el-tag></p>
<p><strong>当前状态:</strong>
<el-tag :type="getStatusType(predictPoller?.status?.value || 'PENDING')" style="margin-left:10px">
{{ predictPoller?.status?.value || 'PENDING' }}
</el-tag>
</p>
<el-progress
v-if="predictPoller?.isPolling?.value || predictPoller?.status?.value === 'SUCCESS'"
:percentage="predictPoller?.status?.value === 'SUCCESS' ? 100 : 50"
:status="predictPoller?.status?.value === 'SUCCESS' ? 'success' : (predictPoller?.status?.value === 'FAILED' ? 'exception' : '')"
:indeterminate="predictPoller?.isPolling?.value"
/>
<div v-if="predictPoller?.error?.value" class="error-msg">
<el-alert :title="predictPoller.error.value" type="error" :closable="false" show-icon />
</div>
<div v-if="predictPoller?.result?.value?.output_zarr_path" class="result-msg">
<el-alert :title="'推断成功!结果已落盘至: ' + predictPoller.result.value.output_zarr_path" type="success" :closable="false" show-icon />
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, watch, reactive } from 'vue'
import { submitTrain, submitPredict } from '@/api/tasks'
import { useTaskPoller } from '@/composables/useTaskPoller'
// 训练表单状态
const trainForm = reactive({
model_type: 'RF',
target: 'Chl-a',
train_data_path: '',
feature_start: '4'
})
const trainTaskId = ref<string | null>(null)
const trainPoller = useTaskPoller(trainTaskId)
// 推断表单状态
const predictForm = reactive({
model_id: '',
input_zarr_path: ''
})
const predictTaskId = ref<string | null>(null)
const predictPoller = useTaskPoller(predictTaskId)
// 自动填入联动
watch(() => trainPoller?.result?.value?.model_id, (newId) => {
if (newId) predictForm.model_id = newId as string
})
// 提交训练
const handleTrain = async () => {
try {
const res = await submitTrain({
model_type: trainForm.model_type,
target: trainForm.target,
train_data_path: trainForm.train_data_path,
feature_start: trainForm.feature_start,
params: {}
})
trainTaskId.value = res.task_id
} catch (e: any) {
console.error('训练接口调用失败', e)
alert('提交失败,请检查后端是否在 9090 端口启动,或按 F12 查看控制台跨域报错')
}
}
// 提交推断
const handlePredict = async () => {
try {
const res = await submitPredict({
model_id: predictForm.model_id,
input_zarr_path: predictForm.input_zarr_path
})
predictTaskId.value = res.task_id
} catch (e: any) {
console.error('推断接口调用失败', e)
}
}
// 样式辅助
const getStatusType = (status: string) => {
if (status === 'SUCCESS') return 'success'
if (status === 'FAILED') return 'danger'
if (status === 'PROCESSING') return 'warning'
return 'info'
}
</script>
<style>
/* 去除全局默认边距 */
body {
margin: 0;
padding: 0;
}
</style>
<style scoped>
.dashboard-container {
padding: 40px;
min-height: 100vh;
background-color: #1e1e2d; /* 科技深色底 */
}
.title {
text-align: center;
margin-bottom: 40px;
color: #ffffff;
font-weight: 300;
letter-spacing: 2px;
}
.header-title {
font-weight: bold;
font-size: 16px;
}
.box-card {
margin-bottom: 20px;
background-color: rgba(255, 255, 255, 0.95);
}
.w-full {
width: 100%;
}
.status-board {
margin-top: 25px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e4e7ed;
}
.error-msg, .result-msg {
margin-top: 20px;
}
</style>

View File

@ -1,94 +0,0 @@
/**
* Axios 单例 + 响应拦截器
* --------------------------------
* 1. baseURL 默认指向本地 FastAPI dev 服务。
* 通过 Vite 环境变量 VITE_API_BASE_URL 可覆盖, 例如:
* .env.development: VITE_API_BASE_URL=http://127.0.0.1:8000
* .env.production: VITE_API_BASE_URL=https://api.example.com
*
* 2. 响应拦截器统一 unwrap response.data, 调用方拿到的是真正的业务对象,
* 而不是 AxiosResponse 包装。失败时统一抛 Error, message 优先取
* FastAPI 的 detail 字段。
*
* 3. 类型增强: cast 成 UnwrappedAxiosInstance, 让 request.get<T>(url)
* 的返回类型直接是 T, 而不是 AxiosResponse<T>, 调用方无需二次解包。
*/
import axios, {
type AxiosInstance,
type AxiosRequestConfig,
} from 'axios'
// 在 Vite 下用 import.meta.env; 其它环境 (webpack/直接 ts-node) 兜底到 process.env
type ViteEnv = { env?: Record<string, string | undefined> }
const viteEnv: ViteEnv | undefined =
typeof import.meta !== 'undefined' ? ((import.meta as unknown) as ViteEnv) : undefined
const baseURL: string =
viteEnv?.env?.VITE_API_BASE_URL ??
(typeof process !== 'undefined' && process.env?.VITE_API_BASE_URL) ??
'http://127.0.0.1:9090'
const _instance: AxiosInstance = axios.create({
baseURL,
timeout: 15000,
headers: {
'Content-Type': 'application/json',
},
// FastAPI 开发期 CORS 是 allow_origins=["*"], 不需要带 cookie
withCredentials: false,
})
// ----- 请求拦截器: 预留 token / 日志位 -----
_instance.interceptors.request.use(
(config) => {
// const token = localStorage.getItem('token')
// if (token) config.headers.Authorization = `Bearer ${token}`
return config
},
(error) => Promise.reject(error),
)
// ----- 响应拦截器: unwrap data + 统一错误 message -----
_instance.interceptors.response.use(
(response) => response.data,
(error) => {
const detail = error?.response?.data?.detail
const message =
(typeof detail === 'string' ? detail : detail?.msg) ??
error?.response?.data?.message ??
error?.message ??
'请求失败'
return Promise.reject(new Error(message))
},
)
// ----- 类型增强: 把响应拦截器 unwrap 的事实在类型上表达出来 -----
type UnwrappedAxiosInstance = Omit<
AxiosInstance,
'get' | 'delete' | 'head' | 'options' | 'post' | 'put' | 'patch'
> & {
get<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T>
delete<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T>
head<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T>
options<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T>
post<T = unknown, D = unknown>(
url: string,
data?: D,
config?: AxiosRequestConfig,
): Promise<T>
put<T = unknown, D = unknown>(
url: string,
data?: D,
config?: AxiosRequestConfig,
): Promise<T>
patch<T = unknown, D = unknown>(
url: string,
data?: D,
config?: AxiosRequestConfig,
): Promise<T>
}
const request = _instance as UnwrappedAxiosInstance
export default request
export { baseURL }

View File

@ -1,155 +0,0 @@
/**
* 与 FastAPI 后端对接的 API 函数
* --------------------------------
* 全部用 request 单例, 调用方拿到的就是业务对象 (response 拦截器已 unwrap)。
*
* 后端路由:
* GET /api/algorithms
* POST /api/process/deglint
* POST /api/modeling/train
* POST /api/modeling/predict (额外, 与 train 配套)
* GET /api/tasks/{task_id}
*/
import request from './request'
// ============================================================
// 通用类型
// ============================================================
/** 后端任务状态机 (与 app.core.task_store.TASK_STORE 保持一致) */
export type TaskStatus = 'PENDING' | 'PROCESSING' | 'SUCCESS' | 'FAILED'
/** 任务类型, 区分去耀斑 / 训练 / 推断 */
export type TaskKind = 'deglint' | 'train' | 'predict'
/** 提交后端后立即返回的最小任务凭证 */
export interface TaskAcceptedResponse {
task_id: string
status: TaskStatus
kind: TaskKind
}
/**
* 任务详情 (与后端 TASK_STORE 里记录的字段对齐, 通用 + 各 kind 增量字段)
* 用 [key: string]: unknown 兜底, 兼容未来后端新增字段
*/
export interface TaskRecord {
task_id: string
kind: TaskKind
status: TaskStatus
// 去耀斑
algorithm?: string
input_zarr_path?: string
output_zarr_path?: string | null
// 训练
model_type?: string
target?: string
train_data_path?: string
feature_start?: number | string
params?: Record<string, unknown>
model_id?: string | null
model_path?: string | null
test_r2?: number | null
test_rmse?: number | null
test_mae?: number | null
n_features?: number | null
n_samples?: number | null
// 推断
// (model_id / input_zarr_path / output_zarr_path 已在上方)
// 失败
error?: string | null
traceback?: string | null
// 元
created_at?: string
updated_at?: string
[key: string]: unknown
}
// ============================================================
// 1) 算法列表 GET /api/algorithms
// ============================================================
export interface AlgorithmInfo {
name: string
doc?: string
}
export interface AlgorithmListResponse {
algorithms: AlgorithmInfo[]
count: number
}
export function getAlgorithms(): Promise<AlgorithmListResponse> {
return request.get<AlgorithmListResponse>('/api/algorithms')
}
// ============================================================
// 2) 提交去耀斑 POST /api/process/deglint
// ============================================================
export interface DeglintParams {
input_zarr_path: string
output_zarr_path?: string
/** 算法自定义参数 (D_max / band 选择等) */
[key: string]: unknown
}
export function submitDeglint(
method: string,
params: DeglintParams,
): Promise<TaskAcceptedResponse> {
return request.post<TaskAcceptedResponse, { method: string; params: DeglintParams }>(
'/api/process/deglint',
{ method, params },
)
}
// ============================================================
// 3) 提交训练 POST /api/modeling/train
// ============================================================
export interface TrainRequest {
model_type: string
target: string
train_data_path: string
/** 特征起始列, int 索引或 str 列名, 默认 4 */
feature_start?: number | string
/** sklearn 估计器超参 */
params?: Record<string, unknown>
}
export function submitTrain(payload: TrainRequest): Promise<TaskAcceptedResponse> {
return request.post<TaskAcceptedResponse, TrainRequest>(
'/api/modeling/train',
payload,
)
}
// ============================================================
// 4) 提交推断 POST /api/modeling/predict (配套, 训练后才能用)
// ============================================================
export interface PredictRequest {
model_id: string
input_zarr_path: string
output_zarr_path?: string
}
export function submitPredict(
payload: PredictRequest,
): Promise<TaskAcceptedResponse> {
return request.post<TaskAcceptedResponse, PredictRequest>(
'/api/modeling/predict',
payload,
)
}
// ============================================================
// 5) 查询任务状态 GET /api/tasks/{task_id}
// ============================================================
export function getTaskStatus(task_id: string): Promise<TaskRecord> {
return request.get<TaskRecord>(
`/api/tasks/${encodeURIComponent(task_id)}`,
)
}

View File

@ -1,238 +0,0 @@
/**
* 任务轮询 Composable (Vue 3 + TypeScript)
* -----------------------------------------
* 用法 1 — 静态 task_id, 立即开始轮询:
* const { status, result, error, waitForCompletion } = useTaskPoller(taskId)
*
* 用法 2 — 响应式 task_id (异步拿到后赋值, 自动开始):
* const taskId = ref<string | null>(null)
* const poller = useTaskPoller(taskId)
* ;(async () => { taskId.value = (await submitTrain({...})).task_id })()
* await poller.waitForCompletion()
*
* 用法 3 — 手动控制:
* const poller = useTaskPoller()
* poller.start(taskId) // 开始
* poller.stop() // 停止
* poller.reset() // 清空状态
*
* 设计要点:
* - 终态 (SUCCESS/FAILED) 自动停止轮询
* - 组件卸载自动清理 (onUnmounted)
* - 网络错误不立刻终止, 计入 error.value 但继续轮询 (兼容临时抖动)
* - waitForCompletion 是单次承诺: SUCCESS resolve(record), FAILED reject(error)
* 外部 stop() 也会 reject
*/
import {
onUnmounted,
ref,
watch,
type MaybeRefOrGetter,
type Ref,
} from 'vue'
import { toValue } from 'vue'
import {
getTaskStatus,
type TaskRecord,
type TaskStatus,
} from '../api/tasks'
// 显式包含 'idle', 用于未开始轮询的初始态
export type PollerStatus = TaskStatus | 'idle'
export interface UseTaskPollerOptions {
/** 轮询间隔 ms, 默认 2000 */
intervalMs?: number
/** task_id 变 null 时是否自动停止, 默认 true */
autoStopOnNull?: boolean
}
export interface UseTaskPollerReturn {
/** 当前任务状态, 初始 'idle' */
status: Ref<PollerStatus>
/** SUCCESS 时的完整任务记录 (含 output_zarr_path / model_id 等) */
result: Ref<TaskRecord | null>
/** FAILED 时的错误描述, 或轮询过程中网络异常的消息 */
error: Ref<string | null>
/** 最新一次拉取到的任务记录 (含 PENDING/PROCESSING 占位字段) */
record: Ref<TaskRecord | null>
/** 是否正在轮询中 */
isPolling: Ref<boolean>
/** 当前轮询的 task_id (可能为 null) */
taskId: Ref<string | null>
/** 开始轮询某 task, 已轮询同一 id 时是 no-op */
start: (taskId: string) => void
/** 主动停止 (会 reject 未完成的 waitForCompletion) */
stop: () => void
/** 清空所有状态回 'idle' */
reset: () => void
/**
* 等到 SUCCESS/FAILED。
* - SUCCESS: resolve(record)
* - FAILED : reject(Error)
* - stop() : reject(Error('Polling stopped'))
* - 组件卸载: reject(Error('Component unmounted'))
* 已处于终态时立刻 resolve/reject, 不重复等待。
*/
waitForCompletion: () => Promise<TaskRecord>
}
export function useTaskPoller(
taskIdSource?: MaybeRefOrGetter<string | null>,
options: UseTaskPollerOptions = {},
): UseTaskPollerReturn {
const { intervalMs = 2000, autoStopOnNull = true } = options
const status = ref<PollerStatus>('idle')
const result = ref<TaskRecord | null>(null)
const error = ref<string | null>(null)
const record = ref<TaskRecord | null>(null)
const isPolling = ref(false)
const taskId = ref<string | null>(null)
let timerId: ReturnType<typeof setInterval> | null = null
let inFlightTick = false
let resolveWait: ((rec: TaskRecord) => void) | null = null
let rejectWait: ((err: Error) => void) | null = null
function clearTimer() {
if (timerId !== null) {
clearInterval(timerId)
timerId = null
}
}
function resolveOrRejectWait(rec: TaskRecord | null, err: Error | null) {
const r = resolveWait
const rj = rejectWait
resolveWait = null
rejectWait = null
if (rec && r) r(rec)
else if (err && rj) rj(err)
}
function applyTerminalRecord(rec: TaskRecord) {
record.value = rec
status.value = rec.status
if (rec.status === 'SUCCESS') {
result.value = rec
error.value = null
resolveOrRejectWait(rec, null)
} else if (rec.status === 'FAILED') {
result.value = null
error.value = rec.error ?? '任务失败 (无具体错误信息)'
resolveOrRejectWait(
null,
new Error(error.value ?? '任务失败'),
)
}
}
async function tick() {
const currentId = taskId.value
if (!currentId || inFlightTick) return
inFlightTick = true
try {
const rec = await getTaskStatus(currentId)
// 防止 await 期间用户 stop() / start() 了别的 task
if (taskId.value !== currentId) return
if (rec.status === 'SUCCESS' || rec.status === 'FAILED') {
applyTerminalRecord(rec)
stop()
} else {
// PENDING / PROCESSING 阶段, 更新 record 与 status 供 UI 展示
record.value = rec
status.value = rec.status
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
// 单次失败不立刻终止, 写入 error 但保持轮询
error.value = `轮询异常: ${msg}`
} finally {
inFlightTick = false
}
}
function start(nextId: string) {
if (!nextId) return
// 已在轮询同一 id, 幂等
if (taskId.value === nextId && isPolling.value) return
clearTimer()
taskId.value = nextId
status.value = 'idle'
result.value = null
error.value = null
record.value = null
isPolling.value = true
// 立刻拉一次, 避免 2s 空窗
void tick()
timerId = setInterval(() => void tick(), intervalMs)
}
function stop() {
const wasActive = isPolling.value
clearTimer()
isPolling.value = false
if (wasActive) {
resolveOrRejectWait(null, new Error('Polling stopped'))
}
}
function reset() {
stop()
taskId.value = null
status.value = 'idle'
result.value = null
error.value = null
record.value = null
}
function waitForCompletion(): Promise<TaskRecord> {
const r = record.value
if (r && r.status === 'SUCCESS') return Promise.resolve(r)
if (r && r.status === 'FAILED') {
return Promise.reject(
new Error(r.error ?? '任务失败 (无具体错误信息)'),
)
}
return new Promise<TaskRecord>((resolve, reject) => {
resolveWait = resolve
rejectWait = reject
})
}
// 自动模式: 监听外部 taskIdSource
if (taskIdSource !== undefined) {
const stopWatch = watch(
() => toValue(taskIdSource),
(newId, oldId) => {
if (newId && newId !== oldId) start(newId)
else if (!newId && autoStopOnNull) stop()
},
{ immediate: true },
)
onUnmounted(() => {
stopWatch()
reset()
resolveOrRejectWait(null, new Error('Component unmounted'))
})
} else {
onUnmounted(() => {
reset()
resolveOrRejectWait(null, new Error('Component unmounted'))
})
}
return {
status,
result,
error,
record,
isPolling,
taskId,
start,
stop,
reset,
waitForCompletion,
}
}

View File

@ -1,9 +0,0 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
const app = createApp(App)
app.use(ElementPlus)
app.mount('#app')

View File

@ -1,24 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "env.d.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -1,12 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}

View File

@ -1,21 +0,0 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
// Vite 配置:
// - @ -> frontend/src
// - dev server 监听 0.0.0.0:5173, 允许局域网内真机调试
// - VITE_API_BASE_URL 通过 .env.development 注入, 缺省走 src/api/request.ts 内的兜底 (http://127.0.0.1:8000)
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
host: '0.0.0.0',
port: 5173,
strictPort: false,
},
})