Upload all selected dataset files with progress

This commit is contained in:
2026-07-01 17:21:32 +08:00
parent 3690e64b18
commit b87d4f5d0b
2 changed files with 370 additions and 36 deletions

View File

@@ -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<UploadKind, UploadKindProgress>;
};
type GpuPayload = {
available: boolean;
gpus: Array<{
@@ -405,6 +424,39 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
return res.json();
}
function uploadDatasetKind(
datasetName: string,
kind: UploadKind,
files: File[],
onProgress: (loaded: number, total: number, status: "uploading" | "processing") => void
): Promise<DatasetUploadResponse> {
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<Catalog | null>(null);
@@ -573,6 +637,16 @@ function useData() {
updatedAt: cachedDatasetState?.savedAt ?? null
});
const refreshSeqRef = useRef(0);
const datasetsRef = useRef<UploadedDataset[]>(cachedDatasetState?.datasets ?? []);
const datasetValidationsRef = useRef<Record<string, DatasetValidation>>(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<UploadedDataset[]>("/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<UploadStepStatus, string> = {
idle: "未选择",
queued: "等待",
uploading: "上传中",
processing: "保存中",
done: "完成",
failed: "失败"
};
return (
<div className={`uploadProgressPanel ${progress.active ? "active" : ""}`}>
<div className="uploadProgressHead">
<strong>{progress.phase}</strong>
<span>{Math.max(0, Math.min(100, progress.overall))}%</span>
</div>
<div className="uploadProgressBar" aria-label="上传总进度">
<span style={{ width: `${Math.max(0, Math.min(100, progress.overall))}%` }} />
</div>
<div className="uploadProgressRows">
{visibleKinds.map((kind) => {
const item = progress.kinds[kind];
return (
<div key={kind} className={`uploadProgressRow ${item.status}`}>
<div>
<strong>{kind}</strong>
<small>{item.files} {typeof item.saved === "number" ? ` · 已保存 ${item.saved}` : ""}</small>
</div>
<span>{labels[item.status]} · {item.percent}%</span>
</div>
);
})}
</div>
</div>
);
}
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<PageId>(pageFromHash);
@@ -727,8 +839,9 @@ function App() {
const [resultQuery, setResultQuery] = useState("");
const [selectedGpuIds, setSelectedGpuIds] = useState<number[]>([]);
const [selectedCondaEnv, setSelectedCondaEnv] = useState("auto");
const [uploadKind, setUploadKind] = useState<"images" | "masks">("images");
const [uploadFilesByKind, setUploadFilesByKind] = useState<Record<"images" | "masks", FileList | null>>({ images: null, masks: null });
const [uploadKind, setUploadKind] = useState<UploadKind>("images");
const [uploadFilesByKind, setUploadFilesByKind] = useState<Record<UploadKind, FileList | null>>({ images: null, masks: null });
const [uploadProgress, setUploadProgress] = useState<UploadProgressState>(() => emptyUploadProgress());
const [agentValidation, setAgentValidation] = useState<ValidationAgentPayload | null>(null);
const [weightVerification, setWeightVerification] = useState<WeightVerifyPayload | null>(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() {
<p className="eyebrow">Dataset Bench</p>
<h2></h2>
</div>
<button className="iconButton addDatasetButton" type="button" onClick={() => setUploadModalOpen(true)} title="新建或上传数据集">
<button
className="iconButton addDatasetButton"
type="button"
onClick={() => {
setUploadProgress(emptyUploadProgress());
setUploadModalOpen(true);
}}
title="新建或上传数据集"
>
<Plus size={19} />
</button>
</div>
@@ -1623,23 +1813,33 @@ function App() {
<p className="eyebrow">Upload</p>
<h2></h2>
</div>
<button className="iconButton" type="button" onClick={() => setUploadModalOpen(false)} title="关闭">
<button
className="iconButton"
type="button"
disabled={uploadProgress.active}
onClick={() => {
setUploadModalOpen(false);
setUploadProgress(emptyUploadProgress());
}}
title="关闭"
>
<X size={18} />
</button>
</div>
<div className="datasetForm">
<label className="field compact">
<span></span>
<input value={datasetName} onChange={(event) => setDatasetName(event.target.value)} />
<input disabled={uploadProgress.active} value={datasetName} onChange={(event) => setDatasetName(event.target.value)} />
</label>
<label className="field compact">
<span></span>
<input value={datasetDescription} onChange={(event) => setDatasetDescription(event.target.value)} />
<input disabled={uploadProgress.active} value={datasetDescription} onChange={(event) => setDatasetDescription(event.target.value)} />
</label>
{datasetNameExists && <small className="formWarn"> {uploadKind}</small>}
{datasetNameExists && <small className="formWarn">{uploadSelectionLabel}</small>}
{!datasetNameExists && totalUploadFiles > 0 && <small className="formHint">{uploadSelectionLabel}</small>}
<div className="segmented">
{(["images", "masks"] as const).map((kind) => (
<button key={kind} type="button" className={uploadKind === kind ? "active" : ""} onClick={() => setUploadKind(kind)}>
{DATASET_UPLOAD_KINDS.map((kind) => (
<button key={kind} type="button" disabled={uploadProgress.active} className={uploadKind === kind ? "active" : ""} onClick={() => setUploadKind(kind)}>
{kind}
</button>
))}
@@ -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 }))}
/>
</label>
<UploadProgressPanel progress={uploadProgress} />
<div className="modalActions">
<button className="primary" disabled={busy || !activeUploadFiles?.length} onClick={uploadDatasetFiles}>
<UploadCloud size={17} />{datasetNameExists ? "追加文件" : "创建并上传"}
<button className="primary" disabled={busy || totalUploadFiles === 0} onClick={uploadDatasetFiles}>
<UploadCloud size={17} />{uploadProgress.active ? "上传中..." : datasetNameExists ? "追加已选择文件" : "创建并上传全部"}
</button>
<button className="primary ghost" disabled={busy || datasetNameExists} onClick={createDataset}>
<button className="primary ghost" disabled={busy || datasetNameExists || uploadProgress.active} onClick={createDataset}>
<Boxes size={17} />
</button>
</div>

View File

@@ -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;