Move dataset upload into modal wall view

This commit is contained in:
2026-07-01 13:01:56 +08:00
parent b1c0c982b8
commit bb4c7ffa5e
2 changed files with 332 additions and 87 deletions

View File

@@ -14,6 +14,7 @@ import {
Gauge,
HardDrive,
Layers3,
Plus,
Play,
RefreshCcw,
ShieldCheck,
@@ -22,6 +23,7 @@ import {
Trash2,
UploadCloud,
PackageOpen,
X,
Wand2,
Zap
} from "lucide-react";
@@ -616,6 +618,7 @@ function App() {
const [log, setLog] = useState("");
const [busy, setBusy] = useState(false);
const [actionError, setActionError] = useState("");
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [datasetName, setDatasetName] = useState("demo_dataset");
const [datasetDescription, setDatasetDescription] = useState("");
const [copyDatasetName, setCopyDatasetName] = useState("");
@@ -964,6 +967,7 @@ function App() {
body: JSON.stringify({ name: datasetName, description: datasetDescription })
});
setSelectedDatasetName(datasetName);
setUploadModalOpen(false);
await refresh();
} catch (err) {
setActionError(String(err));
@@ -989,6 +993,9 @@ function App() {
throw new Error(`上传失败,无法连接后端 ${API_BASE}${err instanceof Error ? err.message : String(err)}`);
}
if (!res.ok) throw new Error(await res.text());
setSelectedDatasetName(datasetName);
setUploadModalOpen(false);
setUploadFiles(null);
await refresh();
} catch (err) {
setActionError(String(err));
@@ -1394,37 +1401,11 @@ function App() {
<div className="panelHead">
<div>
<p className="eyebrow">Dataset Bench</p>
<h2></h2>
<h2></h2>
</div>
<Database size={22} />
</div>
<div className="datasetForm">
<label className="field compact">
<span></span>
<input value={datasetName} onChange={(event) => setDatasetName(event.target.value)} />
</label>
<label className="field compact">
<span></span>
<input value={datasetDescription} onChange={(event) => setDatasetDescription(event.target.value)} />
</label>
{datasetNameExists && <small className="formWarn"></small>}
<div className="segmented">
{(["images", "masks"] as const).map((kind) => (
<button key={kind} className={uploadKind === kind ? "active" : ""} onClick={() => setUploadKind(kind)}>
{kind}
<button className="iconButton addDatasetButton" type="button" onClick={() => setUploadModalOpen(true)} title="新建或上传数据集">
<Plus size={19} />
</button>
))}
</div>
<label className="drop">
{uploadFiles?.length ? <PackageOpen size={24} /> : <UploadCloud size={24} />}
<span>{uploadFiles?.length ? `${uploadFiles.length} 个文件待上传` : `选择 ${uploadKind} 图片、视频或压缩包`}</span>
<small>.png/.jpg/.tif/.mp4/.zip/.tar.gz </small>
<input multiple type="file" accept={uploadAccept} onChange={(event) => setUploadFiles(event.target.files)} />
</label>
<div className="buttonRow">
<button className="primary" disabled={busy || datasetNameExists} onClick={createDataset}><Boxes size={17} /></button>
<button className="primary secondary" disabled={busy || !uploadFiles?.length} onClick={uploadDatasetFiles}><UploadCloud size={17} /></button>
</div>
</div>
<div className="datasetManager">
@@ -1521,8 +1502,6 @@ function App() {
{selectedDataset ? (
<>
<DatasetDetail dataset={selectedDataset} validation={selectedValidation} />
{selectedValidation && <DatasetQuality validation={selectedValidation} />}
<DatasetYoloOutputs dataset={selectedDataset} outputs={selectedYoloOutputs} />
</>
) : (
<p className="muted">masks</p>
@@ -1530,6 +1509,50 @@ function App() {
</div>
</section>
{uploadModalOpen && (
<div className="modalOverlay" data-page-section="datasets" role="dialog" aria-modal="true" aria-label="上传数据集">
<div className="datasetUploadModal">
<div className="modalHead">
<div>
<p className="eyebrow">Upload</p>
<h2></h2>
</div>
<button className="iconButton" type="button" onClick={() => setUploadModalOpen(false)} title="关闭">
<X size={18} />
</button>
</div>
<div className="datasetForm">
<label className="field compact">
<span></span>
<input value={datasetName} onChange={(event) => setDatasetName(event.target.value)} />
</label>
<label className="field compact">
<span></span>
<input value={datasetDescription} onChange={(event) => setDatasetDescription(event.target.value)} />
</label>
{datasetNameExists && <small className="formWarn"></small>}
<div className="segmented">
{(["images", "masks"] as const).map((kind) => (
<button key={kind} type="button" className={uploadKind === kind ? "active" : ""} onClick={() => setUploadKind(kind)}>
{kind}
</button>
))}
</div>
<label className="drop">
{uploadFiles?.length ? <PackageOpen size={24} /> : <UploadCloud size={24} />}
<span>{uploadFiles?.length ? `${uploadFiles.length} 个文件待上传` : `选择 ${uploadKind} 图片、视频或压缩包`}</span>
<small>.png/.jpg/.tif/.mp4/.zip/.tar.gz </small>
<input multiple type="file" accept={uploadAccept} onChange={(event) => setUploadFiles(event.target.files)} />
</label>
<div className="modalActions">
<button className="primary" disabled={busy || datasetNameExists} onClick={createDataset}><Boxes size={17} /></button>
<button className="primary secondary" disabled={busy || !uploadFiles?.length} onClick={uploadDatasetFiles}><UploadCloud size={17} /></button>
</div>
</div>
</div>
</div>
)}
<section className="grid two" id="inference" data-page-section="inference">
<div className="panel">
<div className="panelHead">
@@ -2159,73 +2182,70 @@ function AcceptanceArtifactLinks({ artifacts }: { artifacts?: AcceptancePayload[
function DatasetDetail({ dataset, validation }: { dataset: UploadedDataset; validation?: DatasetValidation }) {
const rows = datasetPairRows(dataset);
const images = datasetFiles(dataset, "images");
const masks = datasetFiles(dataset, "masks");
const problemRows = rows.filter((row) => row.status !== "paired");
const shapeErrors = validation?.checks.find((check) => check.name === "mask_shapes_match")?.errors ?? [];
const ready = validation?.ready.mask;
return (
<div className="datasetDetailStack">
<div className="datasetSummaryStrip">
<div><span>images / video</span><strong>{dataset.counts.images}</strong></div>
<div><span>masks</span><strong>{dataset.counts.masks}</strong></div>
<div><span>paired</span><strong>{validation?.pairs.image_mask ?? rows.filter((row) => row.status === "paired").length}</strong></div>
<div><span>status</span><strong>{ready ? "MASK READY" : "CHECK"}</strong></div>
<div className="datasetWallStats">
<span>{dataset.counts.images} images / video</span>
<span>{dataset.counts.masks} masks</span>
<span>{validation?.pairs.image_mask ?? rows.filter((row) => row.status === "paired").length} paired</span>
<strong>{ready ? "MASK READY" : "CHECK"}</strong>
</div>
<div className="datasetPreviewBand">
{[...images.slice(0, 4), ...masks.slice(0, 4)].slice(0, 8).map((item) => (
<a key={item.relative_path} href={artifactUrl(item.relative_path)} target="_blank" rel="noreferrer">
{item.previewable ? <img src={artifactUrl(item.relative_path)} alt={item.name} /> : <FileImage size={22} />}
<span>{item.name}</span>
</a>
))}
</div>
<div className="fileTable">
<div className="fileTableHead">
<span></span>
<span>images / video</span>
<span></span>
<span>masks</span>
<span></span>
<span></span>
</div>
<div className="fileTableBody">
<div className="imageWall">
{rows.length ? rows.slice(0, 500).map((row) => (
<div key={row.key} className={`fileTableRow ${row.status}`}>
<span>{row.stem}</span>
<a href={row.image ? artifactUrl(row.image.relative_path) : undefined} target="_blank" rel="noreferrer">
{row.image?.name ?? "-"}
</a>
<span>{row.image ? formatBytes(row.image.size) : "-"}</span>
<a href={row.mask ? artifactUrl(row.mask.relative_path) : undefined} target="_blank" rel="noreferrer">
{row.mask?.name ?? "-"}
</a>
<span>{row.mask ? formatBytes(row.mask.size) : "-"}</span>
<strong>{row.status === "paired" ? "已配对" : row.status === "missing_mask" ? "缺少 mask" : "多余 mask"}</strong>
<article key={row.key} className={`wallCard ${row.status}`}>
<span className="wallBadge">{row.status === "paired" ? "已配对" : row.status === "missing_mask" ? "缺少 mask" : "多余 mask"}</span>
<div className="wallMedia">
<DatasetWallPane item={row.image} label="image" emptyText={row.status === "extra_mask" ? "无原图" : "无图片"} />
<DatasetWallPane item={row.mask} label="mask" emptyText={row.status === "missing_mask" ? "缺少 mask" : "无 mask"} />
</div>
<div className="wallCaption">
<strong>{row.stem}</strong>
<span>{row.image?.name ?? "-"} / {row.mask?.name ?? "-"}</span>
<small>{row.image ? formatBytes(row.image.size) : "-"} · {row.mask ? formatBytes(row.mask.size) : "-"}</small>
</div>
</article>
)) : (
<p className="muted"></p>
)}
</div>
</div>
<div className="issueGrid">
<div>
<strong></strong>
{problemRows.length ? problemRows.slice(0, 80).map((row) => (
<small key={row.key}>{row.status === "missing_mask" ? row.image?.name : row.mask?.name}</small>
)) : <small></small>}
{(problemRows.length > 0 || shapeErrors.length > 0) && (
<div className="wallIssueRail">
{problemRows.slice(0, 80).map((row) => (
<span key={row.key}>{row.status === "missing_mask" ? `缺少 mask: ${row.image?.name}` : `多余 mask: ${row.mask?.name}`}</span>
))}
{shapeErrors.slice(0, 20).map((item, index) => (
<span key={`shape-${index}`}>: {JSON.stringify(item)}</span>
))}
</div>
<div>
<strong></strong>
{shapeErrors.length ? shapeErrors.slice(0, 20).map((item, index) => (
<small key={index}>{JSON.stringify(item)}</small>
)) : <small> image/mask </small>}
)}
</div>
);
}
function DatasetWallPane({ item, label, emptyText }: { item?: DatasetFileItem; label: string; emptyText: string }) {
if (!item) {
return (
<div className="wallPane emptyPane">
<FileImage size={24} />
<span>{emptyText}</span>
</div>
);
}
return (
<a className="wallPane" href={artifactUrl(item.relative_path)} target="_blank" rel="noreferrer">
{item.previewable ? <img src={artifactUrl(item.relative_path)} alt={item.name} /> : (
<div className="mediaPlaceholder">
<FileImage size={26} />
<span>{item.media_type ?? "file"}</span>
</div>
)}
<i>{label}</i>
</a>
);
}

View File

@@ -740,6 +740,12 @@ textarea {
gap: 16px;
}
.addDatasetButton {
color: #0b0d0b;
background: var(--green);
border-color: transparent;
}
.datasetManager,
.preprocessBox,
.datasetDetailStack {
@@ -759,6 +765,44 @@ textarea {
line-height: 1.35;
}
.modalOverlay {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 28px;
background: rgba(4, 7, 5, 0.72);
backdrop-filter: blur(10px);
}
.datasetUploadModal {
width: min(620px, 100%);
max-height: calc(100vh - 56px);
overflow: auto;
padding: 18px;
border: 1px solid rgba(157, 226, 111, 0.32);
border-radius: 8px;
background:
linear-gradient(135deg, rgba(157, 226, 111, 0.06), transparent 42%),
#151815;
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.55);
}
.modalHead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.modalActions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.segmented {
display: grid;
grid-template-columns: repeat(2, 1fr);
@@ -960,6 +1004,177 @@ textarea {
font-size: 20px;
}
.datasetWallStats {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.datasetWallStats span,
.datasetWallStats strong {
padding: 7px 9px;
border: 1px solid var(--line);
border-radius: 999px;
background: #101310;
color: var(--muted);
font-size: 12px;
}
.datasetWallStats strong {
color: var(--green);
border-color: rgba(157, 226, 111, 0.35);
}
.imageWall {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 12px;
align-items: start;
}
.wallCard {
position: relative;
min-width: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
background: #101310;
}
.wallCard.paired {
border-color: rgba(157, 226, 111, 0.28);
}
.wallCard.missing_mask,
.wallCard.extra_mask {
border-color: rgba(211, 179, 91, 0.52);
}
.wallBadge {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
max-width: calc(100% - 16px);
padding: 4px 7px;
border-radius: 999px;
background: rgba(8, 10, 8, 0.78);
color: var(--green);
border: 1px solid rgba(157, 226, 111, 0.38);
font-size: 11px;
font-weight: 760;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wallCard.missing_mask .wallBadge,
.wallCard.extra_mask .wallBadge {
color: var(--amber);
border-color: rgba(211, 179, 91, 0.55);
}
.wallMedia {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
background: var(--line);
}
.wallPane {
position: relative;
min-width: 0;
display: grid;
place-items: center;
aspect-ratio: 1 / 0.82;
background: #070907;
color: var(--muted);
text-decoration: none;
overflow: hidden;
}
.wallPane img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.wallPane i {
position: absolute;
left: 7px;
bottom: 7px;
padding: 3px 5px;
border-radius: 4px;
background: rgba(8, 10, 8, 0.75);
color: var(--green);
font-style: normal;
font-size: 10px;
text-transform: uppercase;
}
.emptyPane {
gap: 6px;
border: 1px dashed rgba(211, 179, 91, 0.45);
color: var(--amber);
}
.emptyPane span {
font-size: 12px;
}
.mediaPlaceholder {
display: grid;
place-items: center;
gap: 5px;
color: var(--muted);
}
.mediaPlaceholder span {
font-size: 11px;
}
.wallCaption {
display: grid;
gap: 3px;
padding: 9px;
}
.wallCaption strong,
.wallCaption span,
.wallCaption small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wallCaption span,
.wallCaption small {
color: var(--muted);
font-size: 11px;
}
.wallIssueRail {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.wallIssueRail span {
max-width: 280px;
padding: 6px 8px;
border: 1px solid rgba(211, 179, 91, 0.45);
border-radius: 999px;
background: rgba(211, 179, 91, 0.08);
color: var(--amber);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.datasetPreviewBand {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
@@ -2189,11 +2404,20 @@ meter {
.inferencePreview,
.datasetSummaryStrip,
.datasetPreviewBand,
.imageWall,
.issueGrid,
.userAgentDataset {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.modalActions {
justify-content: stretch;
}
.modalActions .primary {
flex: 1 1 0;
}
.fileTableHead,
.fileTableRow {
grid-template-columns: minmax(80px, 0.8fr) minmax(120px, 1.3fr) 74px minmax(120px, 1.3fr) 74px 82px;
@@ -2254,6 +2478,7 @@ meter {
.opGrid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.datasetWorkbench { grid-template-columns: 1fr; }
.datasetPreviewBand { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.imageWall { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.coverageGrid,
.taskCheckList { grid-template-columns: 1fr; }
.grid.three { grid-template-columns: 1fr; }