diff --git a/web/frontend/src/api/client.js b/web/frontend/src/api/client.js index 0e379a0..a7a2a9c 100644 --- a/web/frontend/src/api/client.js +++ b/web/frontend/src/api/client.js @@ -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`), diff --git a/web/frontend/src/pages/ProjectPage.jsx b/web/frontend/src/pages/ProjectPage.jsx index 5c06bef..912fa39 100644 --- a/web/frontend/src/pages/ProjectPage.jsx +++ b/web/frontend/src/pages/ProjectPage.jsx @@ -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() { ← К списку