diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 887cd74..f21e3f6 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -32,6 +32,7 @@ import "./styles.css"; const DEFAULT_API_BASE = typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8010` : "http://127.0.0.1:8010"; const API_BASE = (import.meta.env.VITE_API_BASE ?? DEFAULT_API_BASE).replace(/\/$/, ""); +const DATASET_UPLOAD_KINDS: UploadKind[] = ["images", "masks"]; type JobProgress = { percent: number | null; @@ -225,6 +226,24 @@ type DataLoadingState = { updatedAt?: number | null; }; +type UploadKind = "images" | "masks"; +type UploadStepStatus = "idle" | "queued" | "uploading" | "processing" | "done" | "failed"; + +type UploadKindProgress = { + status: UploadStepStatus; + percent: number; + files: number; + saved?: number; +}; + +type UploadProgressState = { + active: boolean; + phase: string; + overall: number; + currentKind?: UploadKind; + kinds: Record; +}; + type GpuPayload = { available: boolean; gpus: Array<{ @@ -405,6 +424,39 @@ async function api(path: string, init?: RequestInit): Promise { return res.json(); } +function uploadDatasetKind( + datasetName: string, + kind: UploadKind, + files: File[], + onProgress: (loaded: number, total: number, status: "uploading" | "processing") => void +): Promise { + return new Promise((resolve, reject) => { + const body = new FormData(); + files.forEach((file) => body.append("files", file)); + const xhr = new XMLHttpRequest(); + xhr.open("POST", `${API_BASE}/api/datasets/${encodeURIComponent(datasetName)}/upload/${kind}`); + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + onProgress(event.loaded, event.total, "uploading"); + } + }; + xhr.upload.onload = () => onProgress(1, 1, "processing"); + xhr.onerror = () => reject(new Error(`上传失败,无法连接后端 ${API_BASE}`)); + xhr.onload = () => { + if (xhr.status < 200 || xhr.status >= 300) { + reject(new Error(xhr.responseText || `上传失败:HTTP ${xhr.status}`)); + return; + } + try { + resolve(JSON.parse(xhr.responseText) as DatasetUploadResponse); + } catch (err) { + reject(new Error(`上传结果解析失败:${err instanceof Error ? err.message : String(err)}`)); + } + }; + xhr.send(body); + }); +} + function artifactUrl(path: string) { return `${API_BASE}/api/artifacts/${path.split("/").map(encodeURIComponent).join("/")}`; } @@ -544,6 +596,18 @@ function writeDatasetCache(payload: DatasetCachePayload) { } } +function emptyUploadProgress(): UploadProgressState { + return { + active: false, + phase: "", + overall: 0, + kinds: { + images: { status: "idle", percent: 0, files: 0 }, + masks: { status: "idle", percent: 0, files: 0 } + } + }; +} + function useData() { const cachedDatasetState = useMemo(() => readDatasetCache(), []); const [catalog, setCatalog] = useState(null); @@ -573,6 +637,16 @@ function useData() { updatedAt: cachedDatasetState?.savedAt ?? null }); const refreshSeqRef = useRef(0); + const datasetsRef = useRef(cachedDatasetState?.datasets ?? []); + const datasetValidationsRef = useRef>(cachedDatasetState?.datasetValidations ?? {}); + + useEffect(() => { + datasetsRef.current = datasets; + }, [datasets]); + + useEffect(() => { + datasetValidationsRef.current = datasetValidations; + }, [datasetValidations]); async function refresh() { const seq = refreshSeqRef.current + 1; @@ -582,11 +656,11 @@ function useData() { setDataLoading((current) => ({ ...current, ...patch })); }; try { - updateLoading({ refreshing: true, phase: "读取数据集", progress: 8, cached: datasets.length > 0 }); + updateLoading({ refreshing: true, phase: "读取数据集", progress: 8, cached: datasetsRef.current.length > 0 }); const datasetsNext = await api("/api/datasets"); if (refreshSeqRef.current !== seq) return; setDatasets(datasetsNext); - writeDatasetCache({ datasets: datasetsNext, datasetValidations, savedAt: Date.now() }); + writeDatasetCache({ datasets: datasetsNext, datasetValidations: datasetValidationsRef.current, savedAt: Date.now() }); updateLoading({ phase: "校验图片和 masks", progress: 32 }); const validationResults = await Promise.allSettled( @@ -646,9 +720,9 @@ function useData() { setError(String(err)); updateLoading({ refreshing: false, - phase: datasets.length ? "同步失败,继续显示缓存" : "同步失败", - progress: datasets.length ? 100 : 0, - cached: datasets.length > 0 + phase: datasetsRef.current.length ? "同步失败,继续显示缓存" : "同步失败", + progress: datasetsRef.current.length ? 100 : 0, + cached: datasetsRef.current.length > 0 }); } } @@ -700,6 +774,44 @@ function DataSyncStrip({ loading }: { loading: DataLoadingState }) { ); } +function UploadProgressPanel({ progress }: { progress: UploadProgressState }) { + if (!progress.phase) return null; + const visibleKinds = DATASET_UPLOAD_KINDS.filter((kind) => progress.kinds[kind].files > 0 || progress.kinds[kind].status !== "idle"); + const labels: Record = { + idle: "未选择", + queued: "等待", + uploading: "上传中", + processing: "保存中", + done: "完成", + failed: "失败" + }; + return ( +
+
+ {progress.phase} + {Math.max(0, Math.min(100, progress.overall))}% +
+
+ +
+
+ {visibleKinds.map((kind) => { + const item = progress.kinds[kind]; + return ( +
+
+ {kind} + {item.files} 个文件{typeof item.saved === "number" ? ` · 已保存 ${item.saved}` : ""} +
+ {labels[item.status]} · {item.percent}% +
+ ); + })} +
+
+ ); +} + function App() { const { catalog, gpus, condaEnvs, runtimeReadiness, capabilities, agentEvaluation, userAgent, jobs, results, curves, weightManifest, datasets, datasetValidations, coverage, acceptance, realAcceptance, realTrainAcceptance, deepAcceptance, error, dataLoading, refresh } = useData(); const [page, setPage] = useState(pageFromHash); @@ -727,8 +839,9 @@ function App() { const [resultQuery, setResultQuery] = useState(""); const [selectedGpuIds, setSelectedGpuIds] = useState([]); const [selectedCondaEnv, setSelectedCondaEnv] = useState("auto"); - const [uploadKind, setUploadKind] = useState<"images" | "masks">("images"); - const [uploadFilesByKind, setUploadFilesByKind] = useState>({ images: null, masks: null }); + const [uploadKind, setUploadKind] = useState("images"); + const [uploadFilesByKind, setUploadFilesByKind] = useState>({ images: null, masks: null }); + const [uploadProgress, setUploadProgress] = useState(() => emptyUploadProgress()); const [agentValidation, setAgentValidation] = useState(null); const [weightVerification, setWeightVerification] = useState(null); const [agentBusy, setAgentBusy] = useState(false); @@ -853,6 +966,11 @@ function App() { ? "image/*,video/*,.zip,.tar,.tar.gz,.tgz" : "image/*,.txt,.json,.yaml,.yml,.zip,.tar,.tar.gz,.tgz"; const activeUploadFiles = uploadFilesByKind[uploadKind]; + const uploadKindsWithFiles = DATASET_UPLOAD_KINDS.filter((kind) => (uploadFilesByKind[kind]?.length ?? 0) > 0); + const totalUploadFiles = uploadKindsWithFiles.reduce((total, kind) => total + (uploadFilesByKind[kind]?.length ?? 0), 0); + const uploadSelectionLabel = uploadKindsWithFiles.length + ? uploadKindsWithFiles.map((kind) => `${kind} ${uploadFilesByKind[kind]?.length ?? 0}`).join(" / ") + : `当前 ${uploadKind}`; const inferenceOutputs = useMemo(() => { const predictions = results.filter((item) => item.role === "segmentation" && item.previewable).slice(0, 8); const heatmaps = results.filter((item) => item.role === "heatmap" && item.previewable).slice(0, 8); @@ -1072,31 +1190,95 @@ function App() { } async function uploadDatasetFiles() { - const files = uploadFilesByKind[uploadKind]; - if (!files || files.length === 0) return; + const pendingUploads = DATASET_UPLOAD_KINDS + .map((kind) => ({ kind, files: Array.from(uploadFilesByKind[kind] ?? []) })) + .filter((item) => item.files.length > 0); + if (!pendingUploads.length) return; setBusy(true); + let runningKind: UploadKind | undefined; try { setActionError(""); - const body = new FormData(); - Array.from(files).forEach((file) => body.append("files", file)); - let res: Response; - try { - res = await fetch(`${API_BASE}/api/datasets/${encodeURIComponent(datasetName)}/upload/${uploadKind}`, { - method: "POST", - body + setUploadProgress({ + active: true, + phase: "准备上传", + overall: 0, + kinds: { + images: { + status: uploadFilesByKind.images?.length ? "queued" : "idle", + percent: 0, + files: uploadFilesByKind.images?.length ?? 0 + }, + masks: { + status: uploadFilesByKind.masks?.length ? "queued" : "idle", + percent: 0, + files: uploadFilesByKind.masks?.length ?? 0 + } + } + }); + let targetDatasetName = datasetName; + let latestDatasetName = datasetName; + for (let index = 0; index < pendingUploads.length; index += 1) { + const { kind, files } = pendingUploads[index]; + runningKind = kind; + setUploadProgress((current) => ({ + ...current, + currentKind: kind, + phase: `上传 ${kind}`, + kinds: { + ...current.kinds, + [kind]: { ...current.kinds[kind], status: "uploading", percent: 0 } + } + })); + const uploadResult = await uploadDatasetKind(targetDatasetName, kind, files, (loaded, total, status) => { + const rawPercent = total > 0 ? Math.round((loaded / total) * 100) : 0; + const percent = status === "processing" ? Math.max(rawPercent, 96) : rawPercent; + const overall = Math.round(((index + percent / 100) / pendingUploads.length) * 100); + setUploadProgress((current) => ({ + ...current, + currentKind: kind, + phase: status === "processing" ? `保存 ${kind}` : `上传 ${kind}`, + overall, + kinds: { + ...current.kinds, + [kind]: { ...current.kinds[kind], status, percent } + } + })); }); - } catch (err) { - throw new Error(`上传失败,无法连接后端 ${API_BASE}:${err instanceof Error ? err.message : String(err)}`); + latestDatasetName = uploadResult.dataset.name; + targetDatasetName = uploadResult.dataset.name; + setUploadProgress((current) => ({ + ...current, + overall: Math.round(((index + 1) / pendingUploads.length) * 100), + kinds: { + ...current.kinds, + [kind]: { ...current.kinds[kind], status: "done", percent: 100, saved: uploadResult.saved.length } + } + })); } - if (!res.ok) throw new Error(await res.text()); - const uploadResult = await res.json() as DatasetUploadResponse; - setDatasetName(uploadResult.dataset.name); - setSelectedDatasetName(uploadResult.dataset.name); - setUploadModalOpen(false); - setUploadFilesByKind((current) => ({ ...current, [uploadKind]: null })); + setDatasetName(latestDatasetName); + setSelectedDatasetName(latestDatasetName); + setUploadProgress((current) => ({ ...current, active: true, phase: "刷新图片墙", overall: 100 })); + setUploadFilesByKind((current) => { + const next = { ...current }; + for (const { kind } of pendingUploads) next[kind] = null; + return next; + }); await refresh(); + setUploadModalOpen(false); + window.setTimeout(() => setUploadProgress(emptyUploadProgress()), 500); } catch (err) { setActionError(String(err)); + setUploadProgress((current) => ({ + ...current, + active: false, + phase: "上传失败", + kinds: runningKind + ? { + ...current.kinds, + [runningKind]: { ...current.kinds[runningKind], status: "failed" } + } + : current.kinds + })); } finally { setBusy(false); } @@ -1521,7 +1703,15 @@ function App() {

Dataset Bench

数据集管理

- @@ -1623,23 +1813,33 @@ function App() {

Upload

新建或追加数据集

-
- {datasetNameExists && 将追加到现有数据集的 {uploadKind},不会新建同名数据集。} + {datasetNameExists && 将追加所有已选择文件到现有数据集:{uploadSelectionLabel}。} + {!datasetNameExists && totalUploadFiles > 0 && 将创建数据集并上传:{uploadSelectionLabel}。}
- {(["images", "masks"] as const).map((kind) => ( - ))} @@ -1656,15 +1856,17 @@ function App() { key={uploadKind} multiple type="file" + disabled={uploadProgress.active} accept={uploadAccept} onChange={(event) => setUploadFilesByKind((current) => ({ ...current, [uploadKind]: event.target.files }))} /> +
- -
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index ee7b8cf..4389df0 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -652,6 +652,12 @@ textarea { background: var(--field); } +.field input:disabled, +.field select:disabled { + opacity: 0.7; + cursor: not-allowed; +} + .pathStack, .inferenceForm, .modelList, @@ -831,12 +837,20 @@ textarea { border-top: 1px solid var(--line); } -.formWarn { +.formWarn, +.formHint { display: block; - color: var(--amber); line-height: 1.35; } +.formWarn { + color: var(--amber); +} + +.formHint { + color: var(--cyan); +} + .modalOverlay { position: fixed; inset: 0; @@ -913,6 +927,11 @@ textarea { font-weight: 760; } +.segmented button:disabled { + opacity: 0.62; + cursor: not-allowed; +} + .uploadKindCounts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -966,6 +985,119 @@ textarea { cursor: pointer; } +.drop input:disabled { + cursor: not-allowed; +} + +.uploadProgressPanel { + display: grid; + gap: 9px; + padding: 11px; + border: 1px solid rgba(115, 210, 222, 0.32); + border-radius: 8px; + background: rgba(8, 12, 12, 0.74); +} + +.uploadProgressPanel.active { + border-color: rgba(115, 210, 222, 0.58); +} + +.uploadProgressHead { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; +} + +.uploadProgressHead strong, +.uploadProgressHead span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.uploadProgressHead span { + color: var(--cyan); + font-size: 12px; + font-weight: 760; +} + +.uploadProgressBar { + height: 8px; + overflow: hidden; + border-radius: 999px; + background: #080a08; + border: 1px solid rgba(238, 242, 232, 0.12); +} + +.uploadProgressBar span { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--green), var(--cyan)); + transition: width 180ms ease; +} + +.uploadProgressRows { + display: grid; + gap: 6px; +} + +.uploadProgressRow { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 7px 8px; + border: 1px solid var(--line); + border-radius: 6px; + background: #101310; +} + +.uploadProgressRow > div { + min-width: 0; + display: grid; + gap: 2px; +} + +.uploadProgressRow strong, +.uploadProgressRow small, +.uploadProgressRow > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.uploadProgressRow small, +.uploadProgressRow > span { + color: var(--muted); + font-size: 11px; +} + +.uploadProgressRow.uploading, +.uploadProgressRow.processing { + border-color: rgba(115, 210, 222, 0.45); +} + +.uploadProgressRow.done { + border-color: rgba(157, 226, 111, 0.42); +} + +.uploadProgressRow.failed { + border-color: rgba(240, 113, 103, 0.6); +} + +.uploadProgressRow.done > span { + color: var(--green); +} + +.uploadProgressRow.failed > span { + color: var(--red); +} + .buttonRow { display: flex; gap: 10px;