titusgiap's picture
Claude Opus 4.8
perf: queue theo loại + code-split FE + streaming upload cho nhiều user
3370310
Raw History Blame Contribute Delete
8 kB
import { useRef, useState } from "react";
import { IcUpload, IcInfo } from "./Icons";
export function Toggle({ on, onChange }) {
return (
<div className={"toggle" + (on ? " on" : "")} onClick={() => onChange(!on)} role="switch" aria-checked={on}>
<div className="knob" />
</div>
);
}
export function ProcessRail({ title = "Quy trình xử lý", steps, tip }) {
return (
<div className="rail">
<div className="rail-title">{title}</div>
{steps.map((s, i) => (
<div className="rail-step" key={i}>
<div className="num">{i + 1}</div>
<div className="body">
<div className="t">{s.t}</div>
<div className="d">{s.d}</div>
</div>
</div>
))}
{tip && <div className="tip"><b>Mẹo:</b> {tip}</div>}
</div>
);
}
// Duyệt ĐỆ QUY một thư mục được kéo-thả (DataTransferItem -> FileSystemEntry).
// Trình duyệt KHÔNG đưa file con của thư mục vào dataTransfer.files, nên phải tự
// đọc qua webkitGetAsEntry để lấy hết file trong (các) thư mục.
async function readDropEntries(dataTransfer) {
const items = Array.from(dataTransfer.items || []);
const entries = items
.map((it) => (it.webkitGetAsEntry ? it.webkitGetAsEntry() : null))
.filter(Boolean);
if (!entries.length) return Array.from(dataTransfer.files || []);
const out = [];
const walk = (entry) =>
new Promise((resolve) => {
if (entry.isFile) {
entry.file((f) => { out.push(f); resolve(); }, () => resolve());
} else if (entry.isDirectory) {
const reader = entry.createReader();
const readBatch = () =>
reader.readEntries(async (batch) => {
if (!batch.length) return resolve();
await Promise.all(batch.map(walk));
readBatch(); // readEntries trả theo lô -> đọc tới khi hết
}, () => resolve());
readBatch();
} else resolve();
});
await Promise.all(entries.map(walk));
return out;
}
export function FileDrop({ accept = "video/*", multiple = true, onFiles, title, hint, compact, folder = false }) {
const ref = useRef();
const folderRef = useRef();
const [drag, setDrag] = useState(false);
const pick = (list) => list && list.length && onFiles(Array.from(list));
const onDrop = async (e) => {
e.preventDefault();
setDrag(false);
// Hỗ trợ kéo CẢ THƯ MỤC: nếu có entries thì duyệt đệ quy, lọc theo accept.
let files = await readDropEntries(e.dataTransfer);
if (accept && accept !== "*" && files.length) {
const base = accept.replace("/*", ""); // "video" | "audio"
const filtered = files.filter((f) => (f.type || "").startsWith(base));
if (filtered.length) files = filtered; // chỉ lọc khi còn file hợp lệ
}
pick(files);
};
return (
<div
className={"dropzone" + (drag ? " drag" : "")}
style={compact ? { padding: "22px 16px" } : null}
onDragOver={(e) => { e.preventDefault(); setDrag(true); }}
onDragLeave={() => setDrag(false)}
onDrop={onDrop}
onClick={() => ref.current.click()}
>
<div className="chip"><IcUpload size={24} /></div>
<div className="big">{title || "Kéo & thả file vào đây"}</div>
<div className="hint">{hint || "Hỗ trợ MP4, MOV, AVI, MKV"}</div>
{folder && (
<button className="btn sm mt12" type="button"
onClick={(e) => { e.stopPropagation(); folderRef.current.click(); }}>
Chọn cả thư mục
</button>
)}
<input ref={ref} type="file" accept={accept} multiple={multiple} hidden
onChange={(e) => pick(e.target.files)} />
{folder && (
<input ref={folderRef} type="file" hidden webkitdirectory="" directory=""
multiple onChange={(e) => pick(e.target.files)} />
)}
</div>
);
}
// Hiển thị tiến độ + log + danh sách item của một job đang chạy.
export function JobView({ job }) {
if (!job) return null;
const pct = Math.round((job.progress || 0) * 100);
return (
<div className="card mt16">
<div className="flex between center mt8" style={{ marginTop: 0 }}>
<b className="small">Tiến độ xử lý</b>
<span className="small muted">{pct}% · {job.status}</span>
</div>
<div className="progress mt8"><i style={{ width: pct + "%" }} /></div>
{job.items?.length > 0 && (
<table className="list mt16">
<tbody>
{job.items.map((it) => (
<tr key={it.id}>
<td>{it.label}</td>
<td style={{ width: 120 }}>
<span className={"badge " + (it.status || "")}>
<span className="dot" />
{{ queued: "chờ", running: "đang xử lý", done: "xong", error: "lỗi" }[it.status] || it.status}
</span>
</td>
<td className="muted small">{it.detail || it.error || ""}</td>
</tr>
))}
</tbody>
</table>
)}
{job.logs?.length > 0 && (
<div className="logbox mt12">
{job.logs.map((l, i) => (
<div key={i} className={l.level === "error" ? "err" : ""}>{l.msg}</div>
))}
</div>
)}
</div>
);
}
// Lớp phủ "ĐANG XỬ LÝ": hiện NGAY khi busy=true (kể cả trước khi có job), phủ kín
// màn hình -> chặn mọi thao tác cho tới khi xong. Có spinner + tiến độ + bước hiện tại.
export function ProcessingOverlay({ busy, job, title = "Đang xử lý…" }) {
if (!busy) return null;
const pct = Math.round((job?.progress || 0) * 100);
const items = job?.items || [];
const running = items.find((i) => i.status === "running");
const doneCount = items.filter((i) => i.status === "done").length;
const lastLog = job?.logs?.length ? job.logs[job.logs.length - 1].msg : "";
// Đang xếp hàng chờ tài nguyên máy chủ (nhiều người dùng cùng lúc).
const queued = job?.status === "queued" && (job?.queue_total || 0) > 0;
const queueMsg = queued
? `Đang xếp hàng — vị trí ${(job.queue_pos || 0) + 1}/${job.queue_total}. Máy chủ đang bận, job của bạn sẽ tự chạy khi tới lượt.`
: "";
const sub = queueMsg || running?.detail || lastLog || "Đang xử lý trên máy chủ, vui lòng đợi…";
return (
<div className="proc-overlay" role="alertdialog" aria-busy="true" aria-label={title}>
<div className="proc-box">
<div className="proc-spinner" />
<div className="proc-title">{queued ? "Đang chờ tới lượt…" : title}</div>
<div className="proc-sub">{sub}</div>
{job && (
<>
<div className="progress mt12"><i style={{ width: pct + "%" }} /></div>
<div className="proc-meta">
{pct}%{items.length ? ` · ${doneCount}/${items.length} mục` : ""} · {job.status}
</div>
</>
)}
<div className="proc-hint">Thao tác bị khoá tới khi xử lý xong — đừng đóng tab.</div>
</div>
</div>
);
}
// Thông báo cho user biết dữ liệu được giữ tạm theo phiên (cache), không lưu lâu dài.
export function CacheNote({ onClose }) {
return (
<div className="cache-note">
<span className="ic"><IcInfo size={16} /></span>
<span>
Dữ liệu được lưu tạm theo <b>phiên làm việc</b> (cache trên trình duyệt + vùng tạm trên máy chủ),
giúp bạn chuyển qua lại giữa các tool mà không mất tiến trình. Sau khi xử lý xong, hãy
<b> tải kết quả về máy</b> — dữ liệu tạm sẽ tự xoá, không lưu trữ lâu dài.
</span>
{onClose && <button className="x" onClick={onClose} title="Đóng">×</button>}
</div>
);
}