added file screen

This commit is contained in:
2026-09-02 13:20:29 +03:00
parent f83b7efcf1
commit dfc71c0130
3 changed files with 190 additions and 9 deletions
+61 -2
View File
@@ -8,6 +8,17 @@ export function setToken(token) {
localStorage.setItem(TOKEN_KEY, token || "");
}
function parseErrorBody(xhr) {
let detail = xhr.statusText || "Request failed";
try {
const data = JSON.parse(xhr.responseText);
detail = data.detail || JSON.stringify(data);
} catch (_) {
/* ignore */
}
return typeof detail === "string" ? detail : JSON.stringify(detail);
}
async function request(path, options = {}) {
const headers = new Headers(options.headers || {});
const token = getToken();
@@ -42,11 +53,59 @@ export const api = {
getProject: (id) => request(`/api/projects/${id}`),
updateProject: (id, data) => request(`/api/projects/${id}`, { method: "PATCH", json: data }),
deleteProject: (id) => request(`/api/projects/${id}`, { method: "DELETE" }),
uploadZip: async (id, file, method = "POST", keepTables = true) => {
uploadZip: (id, file, method = "POST", keepTables = true, onProgress) => {
const fd = new FormData();
fd.append("file", file);
const q = method === "PUT" ? `?keep_tables=${keepTables}` : "";
return request(`/api/projects/${id}/upload${q}`, { method, body: fd });
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, `/api/projects/${id}/upload${q}`);
const token = getToken();
if (token) xhr.setRequestHeader("Authorization", `Bearer ${token}`);
const report = (phase, loaded, total) => {
if (!onProgress) return;
const safeTotal = total || file.size || loaded;
const percent =
safeTotal > 0 ? Math.min(100, Math.round((loaded / safeTotal) * 100)) : 0;
onProgress({
phase,
loaded,
total: safeTotal,
percent,
fileName: file.name,
fileSize: file.size,
});
};
xhr.upload.onprogress = (e) => {
report("upload", e.loaded, e.lengthComputable ? e.total : file.size);
};
xhr.upload.onload = () => {
report("processing", file.size, file.size);
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText));
} catch {
reject(new Error("Некорректный ответ сервера"));
}
return;
}
reject(new Error(parseErrorBody(xhr)));
};
xhr.onerror = () => reject(new Error("Ошибка сети при загрузке файла"));
xhr.onabort = () => reject(new Error("Загрузка отменена"));
xhr.ontimeout = () => reject(new Error("Превышено время ожидания загрузки"));
report("upload", 0, file.size);
xhr.send(fd);
});
},
getComponents: (id) => request(`/api/projects/${id}/components`),
getParams: (id) => request(`/api/projects/${id}/params`),
+99 -7
View File
@@ -72,6 +72,25 @@ const FRAME_FIELDS = [
[1002, "Название документа (спец.)"],
];
const STATUS_LABELS = {
created: "создан",
parsing: "парсинг",
ready: "готов",
error: "ошибка",
};
function formatBytes(bytes) {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let i = 0;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i += 1;
}
return `${value.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
}
export default function ProjectPage() {
const { id } = useParams();
const projectId = Number(id);
@@ -87,6 +106,9 @@ export default function ProjectPage() {
const [chat, setChat] = useState([]);
const [pendingEdits, setPendingEdits] = useState(null);
const [tab, setTab] = useState("tables");
const [uploadState, setUploadState] = useState(null);
const uploading = uploadState && uploadState.phase !== "done";
const loadProject = useCallback(async () => {
const [p, par, pcbInfo, insc] = await Promise.all([
@@ -125,11 +147,30 @@ export default function ProjectPage() {
if (!file) return;
setBusy(true);
setError("");
setUploadState({
phase: "upload",
percent: 0,
loaded: 0,
total: file.size,
fileName: file.name,
fileSize: file.size,
});
try {
await api.uploadZip(projectId, file, method, true);
await loadProject();
const updated = await api.uploadZip(projectId, file, method, true, setUploadState);
setProject(updated);
await Promise.all([loadProject(), loadTable()]);
setUploadState({
phase: "done",
percent: 100,
fileName: file.name,
fileSize: file.size,
loaded: file.size,
total: file.size,
});
window.setTimeout(() => setUploadState(null), 4000);
} catch (err) {
setError(err.message);
setUploadState(null);
} finally {
setBusy(false);
e.target.value = "";
@@ -224,22 +265,73 @@ export default function ProjectPage() {
<Link to="/"> К списку</Link>
<h2 style={{ margin: "0.4rem 0 0" }}>{project.name}</h2>
<div className="muted">
Статус: <span className={`status ${project.status}`}>{project.status}</span>
Статус:{" "}
<span className={`status ${project.status}`}>
{STATUS_LABELS[project.status] || project.status}
</span>
{project.error_message ? `${project.error_message}` : ""}
</div>
</div>
<div className="row">
<label className="secondary" style={{ background: "#4a5a53", color: "#fff", padding: "0.45rem 0.85rem", borderRadius: 6, cursor: "pointer" }}>
<label
className="secondary"
style={{
background: uploading ? "#8a9690" : "#4a5a53",
color: "#fff",
padding: "0.45rem 0.85rem",
borderRadius: 6,
cursor: uploading ? "not-allowed" : "pointer",
}}
>
Загрузить ZIP
<input type="file" accept=".zip" hidden onChange={(e) => onUpload(e, "POST")} />
<input type="file" accept=".zip" hidden disabled={uploading} onChange={(e) => onUpload(e, "POST")} />
</label>
<label className="secondary" style={{ background: "#4a5a53", color: "#fff", padding: "0.45rem 0.85rem", borderRadius: 6, cursor: "pointer" }}>
<label
className="secondary"
style={{
background: uploading ? "#8a9690" : "#4a5a53",
color: "#fff",
padding: "0.45rem 0.85rem",
borderRadius: 6,
cursor: uploading ? "not-allowed" : "pointer",
}}
>
Обновить ZIP
<input type="file" accept=".zip" hidden onChange={(e) => onUpload(e, "PUT")} />
<input type="file" accept=".zip" hidden disabled={uploading} onChange={(e) => onUpload(e, "PUT")} />
</label>
</div>
</div>
{uploadState && (
<div
className={`upload-panel ${uploadState.phase === "processing" ? "processing" : ""} ${
uploadState.phase === "done" ? "done" : ""
}`}
>
<strong>
{uploadState.phase === "upload" && "Загрузка архива на сервер…"}
{uploadState.phase === "processing" && "Файл загружен, идёт распаковка и парсинг…"}
{uploadState.phase === "done" && "Проект успешно загружен"}
</strong>
<div className="muted">
{uploadState.fileName} ({formatBytes(uploadState.fileSize)})
{uploadState.phase === "upload" &&
`${formatBytes(uploadState.loaded)} / ${formatBytes(uploadState.total)}`}
</div>
{uploadState.phase !== "done" ? (
<div className={`progress ${uploadState.phase === "processing" ? "indeterminate" : ""}`}>
<span style={{ width: `${uploadState.percent || 0}%` }} />
</div>
) : null}
{uploadState.phase === "upload" && (
<div className="muted">{uploadState.percent ?? 0}%</div>
)}
{uploadState.phase === "processing" && (
<div className="muted">Это может занять несколько минут для больших архивов.</div>
)}
</div>
)}
<div className="row">
<label>Вариант</label>
<select
+30
View File
@@ -88,7 +88,37 @@ table.data tr.header-row td { font-weight: 700; text-decoration: underline; }
background: #e7e0d2; font-size: 0.75rem;
}
.status.ready { background: #d7efe4; }
.status.parsing { background: #dbeafe; }
.status.error { background: #f5d5d5; }
.upload-panel {
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.75rem 0.9rem;
background: #fff;
}
.upload-panel.processing { border-color: #7eb8d4; background: #f0f8fc; }
.upload-panel.done { border-color: var(--ok); background: #eef8f3; }
.progress {
height: 10px;
border-radius: 999px;
background: #e7e0d2;
overflow: hidden;
margin: 0.5rem 0;
}
.progress > span {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--accent), #3a9b82);
transition: width 0.15s ease;
}
.progress.indeterminate > span {
width: 35% !important;
animation: progress-slide 1.2s ease-in-out infinite;
}
@keyframes progress-slide {
0% { transform: translateX(-120%); }
100% { transform: translateX(320%); }
}
.error { color: var(--danger); }
@media (max-width: 960px) {
.grid.two { grid-template-columns: 1fr; }