added file screen
This commit is contained in:
@@ -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`),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user