show zip packaging progress on download
This commit is contained in:
@@ -47,7 +47,7 @@ type Page = 'overview' | 'library' | 'workspace' | 'users';
|
|||||||
|
|
||||||
type BackendJob = {
|
type BackendJob = {
|
||||||
id: string;
|
id: string;
|
||||||
kind: 'deformation' | 'video';
|
kind: 'deformation' | 'video' | 'zip';
|
||||||
owner?: string;
|
owner?: string;
|
||||||
status: 'running' | 'completed' | 'failed';
|
status: 'running' | 'completed' | 'failed';
|
||||||
message: string;
|
message: string;
|
||||||
@@ -56,6 +56,8 @@ type BackendJob = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ZipJobsByTarget = Record<string, BackendJob>;
|
||||||
|
|
||||||
type LibraryItem = {
|
type LibraryItem = {
|
||||||
id: string;
|
id: string;
|
||||||
patientId: string;
|
patientId: string;
|
||||||
@@ -246,6 +248,7 @@ export default function App() {
|
|||||||
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
||||||
const [deformationJob, setDeformationJob] = useState<BackendJob | null>(restoredDeformationJob?.job || null);
|
const [deformationJob, setDeformationJob] = useState<BackendJob | null>(restoredDeformationJob?.job || null);
|
||||||
const [videoJob, setVideoJob] = useState<BackendJob | null>(null);
|
const [videoJob, setVideoJob] = useState<BackendJob | null>(null);
|
||||||
|
const [zipJobs, setZipJobs] = useState<ZipJobsByTarget>({});
|
||||||
const [videoMaxAngle, setVideoMaxAngle] = useState(20);
|
const [videoMaxAngle, setVideoMaxAngle] = useState(20);
|
||||||
const [videoDuration, setVideoDuration] = useState(6);
|
const [videoDuration, setVideoDuration] = useState(6);
|
||||||
|
|
||||||
@@ -295,14 +298,22 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fileUrl = (path?: string) => path ? `${API_BASE}/api/file?path=${encodeURIComponent(path)}` : '';
|
const fileUrl = (path?: string) => path ? `${API_BASE}/api/file?path=${encodeURIComponent(path)}` : '';
|
||||||
const deformationDownloadUrl = (target: string) => deformationJob?.id
|
|
||||||
? `${API_BASE}/api/deformation/download?job=${encodeURIComponent(deformationJob.id)}&target=${encodeURIComponent(target)}`
|
const triggerDownload = (path?: string, name?: string) => {
|
||||||
: '';
|
if (!path) return;
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = fileUrl(path);
|
||||||
|
if (name) link.download = name;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
};
|
||||||
|
|
||||||
const clearDeformationTask = () => {
|
const clearDeformationTask = () => {
|
||||||
setDeformationJob(null);
|
setDeformationJob(null);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
setIsSimulating(false);
|
setIsSimulating(false);
|
||||||
|
setZipJobs({});
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.localStorage.removeItem(DEFORMATION_JOB_STORAGE_KEY);
|
window.localStorage.removeItem(DEFORMATION_JOB_STORAGE_KEY);
|
||||||
}
|
}
|
||||||
@@ -314,6 +325,34 @@ export default function App() {
|
|||||||
setIsSimulating(job.status === 'running');
|
setIsSimulating(job.status === 'running');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePackageDownload = async (target: string) => {
|
||||||
|
if (!deformationJob?.id || zipJobs[target]?.status === 'running') return;
|
||||||
|
try {
|
||||||
|
const job = await apiRequest('/api/deformation/package', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: currentUser?.username || 'anonymous',
|
||||||
|
jobId: deformationJob.id,
|
||||||
|
target
|
||||||
|
})
|
||||||
|
}) as BackendJob;
|
||||||
|
setZipJobs(current => ({ ...current, [target]: job }));
|
||||||
|
showToast(target === 'all' ? '四状态 ZIP 开始打包' : '本状态 ZIP 开始打包');
|
||||||
|
} catch (error) {
|
||||||
|
setZipJobs(current => ({
|
||||||
|
...current,
|
||||||
|
[target]: {
|
||||||
|
id: `failed_${Date.now()}`,
|
||||||
|
kind: 'zip',
|
||||||
|
status: 'failed',
|
||||||
|
message: '打包失败。',
|
||||||
|
error: (error as Error).message,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
showToast((error as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadLibrary = async () => {
|
const loadLibrary = async () => {
|
||||||
const data = await apiRequest('/api/library');
|
const data = await apiRequest('/api/library');
|
||||||
const items = data.items || [];
|
const items = data.items || [];
|
||||||
@@ -427,6 +466,52 @@ export default function App() {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [videoJob?.id, videoJob?.status]);
|
}, [videoJob?.id, videoJob?.status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const runningEntries = (Object.entries(zipJobs) as [string, BackendJob][])
|
||||||
|
.filter(([, job]) => job.status === 'running');
|
||||||
|
if (!runningEntries.length) return;
|
||||||
|
|
||||||
|
let isActive = true;
|
||||||
|
const pollZipJobs = async () => {
|
||||||
|
await Promise.all(runningEntries.map(async ([target, currentJob]) => {
|
||||||
|
try {
|
||||||
|
const job = await apiRequest(`/api/job?id=${currentJob.id}`) as BackendJob;
|
||||||
|
if (!isActive) return;
|
||||||
|
const displayJob = job.status === 'running'
|
||||||
|
? {
|
||||||
|
...job,
|
||||||
|
progress: Math.min(
|
||||||
|
95,
|
||||||
|
Math.max(progressFromJob(currentJob, 10) + 8, progressFromJob(job, 10))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
: job;
|
||||||
|
setZipJobs(current => ({ ...current, [target]: displayJob }));
|
||||||
|
if (job.status === 'completed') {
|
||||||
|
triggerDownload(job.result?.file?.path, job.result?.file?.name);
|
||||||
|
showToast('ZIP 打包完成,已开始下载');
|
||||||
|
}
|
||||||
|
if (job.status === 'failed') {
|
||||||
|
showToast(job.error || 'ZIP 打包失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!isActive) return;
|
||||||
|
setZipJobs(current => ({
|
||||||
|
...current,
|
||||||
|
[target]: { ...currentJob, status: 'failed', error: (error as Error).message }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
pollZipJobs();
|
||||||
|
const timer = setInterval(pollZipJobs, 1500);
|
||||||
|
return () => {
|
||||||
|
isActive = false;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [zipJobs]);
|
||||||
|
|
||||||
const handleLogin = (e: React.FormEvent) => {
|
const handleLogin = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const user = users.find(u => u.username === loginUsername && u.password === loginPassword);
|
const user = users.find(u => u.username === loginUsername && u.password === loginPassword);
|
||||||
@@ -833,14 +918,18 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
{deformationJob?.error && <p className="text-[10px] text-red-500 font-bold mt-2 break-all">{deformationJob.error}</p>}
|
{deformationJob?.error && <p className="text-[10px] text-red-500 font-bold mt-2 break-all">{deformationJob.error}</p>}
|
||||||
{deformationJob?.status === 'completed' && deformationJob.result?.outputs && (
|
{deformationJob?.status === 'completed' && deformationJob.result?.outputs && (
|
||||||
<a
|
<button
|
||||||
href={deformationDownloadUrl('all')}
|
onClick={() => handlePackageDownload('all')}
|
||||||
download
|
disabled={zipJobs.all?.status === 'running'}
|
||||||
className="mt-3 w-full py-3 bg-green-600 text-white rounded-xl text-xs font-bold hover:bg-green-700 transition-all flex items-center justify-center gap-2"
|
className="mt-3 w-full py-3 bg-green-600 text-white rounded-xl text-xs font-bold hover:bg-green-700 transition-all flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-wait"
|
||||||
>
|
>
|
||||||
<Download size={14} /> 下载四状态 ZIP
|
<Download size={14} />
|
||||||
</a>
|
{zipJobs.all?.status === 'running'
|
||||||
|
? `四状态 ZIP 打包中 ${progressFromJob(zipJobs.all, 0)}%`
|
||||||
|
: '下载四状态 ZIP'}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{zipJobs.all?.status === 'failed' && <p className="text-[10px] text-red-500 font-bold mt-2 break-all">{zipJobs.all.error || '四状态 ZIP 打包失败'}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -920,6 +1009,7 @@ export default function App() {
|
|||||||
].map(t => {
|
].map(t => {
|
||||||
const screenshotDir = deformationJob?.result?.previews?.screenshots;
|
const screenshotDir = deformationJob?.result?.previews?.screenshots;
|
||||||
const imagePath = screenshotDir ? `${screenshotDir}/${t.key}.png` : '';
|
const imagePath = screenshotDir ? `${screenshotDir}/${t.key}.png` : '';
|
||||||
|
const zipJob = zipJobs[t.key];
|
||||||
return (
|
return (
|
||||||
<div key={t.key} className="bg-white p-4 rounded-2xl border flex flex-col hover:border-blue-200 transition-colors shadow-sm group min-h-[285px]">
|
<div key={t.key} className="bg-white p-4 rounded-2xl border flex flex-col hover:border-blue-200 transition-colors shadow-sm group min-h-[285px]">
|
||||||
<div className="flex justify-between items-center mb-3">
|
<div className="flex justify-between items-center mb-3">
|
||||||
@@ -937,14 +1027,18 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{deformationJob?.status === 'completed' && deformationJob.result?.outputs?.[t.key] && (
|
{deformationJob?.status === 'completed' && deformationJob.result?.outputs?.[t.key] && (
|
||||||
<a
|
<button
|
||||||
href={deformationDownloadUrl(t.key)}
|
onClick={() => handlePackageDownload(t.key)}
|
||||||
download
|
disabled={zipJob?.status === 'running'}
|
||||||
className="mt-3 py-2.5 bg-slate-100 text-slate-600 rounded-xl text-[11px] font-black hover:bg-green-600 hover:text-white transition-all flex items-center justify-center gap-2"
|
className="mt-3 py-2.5 bg-slate-100 text-slate-600 rounded-xl text-[11px] font-black hover:bg-green-600 hover:text-white transition-all flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-wait"
|
||||||
>
|
>
|
||||||
<Download size={13} /> 下载本状态 DICOM ZIP
|
<Download size={13} />
|
||||||
</a>
|
{zipJob?.status === 'running'
|
||||||
|
? `打包中 ${progressFromJob(zipJob, 0)}%`
|
||||||
|
: '下载本状态 DICOM ZIP'}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{zipJob?.status === 'failed' && <p className="text-[10px] text-red-500 font-bold mt-2 break-all">{zipJob.error || '打包失败'}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -508,7 +508,7 @@ def get_job(job_id):
|
|||||||
return dict(job) if job else None
|
return dict(job) if job else None
|
||||||
|
|
||||||
|
|
||||||
def start_job(kind, worker, owner=None, params=None):
|
def start_job(kind, worker, owner=None, params=None, remember_user_task=True):
|
||||||
job_id = uuid.uuid4().hex[:12]
|
job_id = uuid.uuid4().hex[:12]
|
||||||
owner = normalized_username(owner)
|
owner = normalized_username(owner)
|
||||||
with JOBS_LOCK:
|
with JOBS_LOCK:
|
||||||
@@ -526,7 +526,8 @@ def start_job(kind, worker, owner=None, params=None):
|
|||||||
"updatedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
|
"updatedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
}
|
}
|
||||||
persist_jobs_locked()
|
persist_jobs_locked()
|
||||||
set_user_task(owner, kind, job_id)
|
if remember_user_task:
|
||||||
|
set_user_task(owner, kind, job_id)
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
try:
|
try:
|
||||||
@@ -573,6 +574,15 @@ def serialize_outputs(output_paths, preview_paths):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def file_metadata(file_path):
|
||||||
|
file_path = Path(file_path).resolve()
|
||||||
|
return {
|
||||||
|
"path": str(file_path),
|
||||||
|
"name": file_path.name,
|
||||||
|
"size": file_path.stat().st_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def prepare_deformation_zip(job_id, target):
|
def prepare_deformation_zip(job_id, target):
|
||||||
job = get_job(job_id)
|
job = get_job(job_id)
|
||||||
if not job:
|
if not job:
|
||||||
@@ -712,6 +722,33 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json(make_preview(body["inputDir"], body.get("angleDegrees", 12)))
|
self.send_json(make_preview(body["inputDir"], body.get("angleDegrees", 12)))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/deformation/package":
|
||||||
|
source_job_id = body["jobId"]
|
||||||
|
target = body.get("target", "all")
|
||||||
|
username = normalized_username(body.get("username"))
|
||||||
|
|
||||||
|
def worker(job_id):
|
||||||
|
label = "四状态总 ZIP" if target == "all" else "本状态 DICOM ZIP"
|
||||||
|
set_job(job_id, message=f"正在打包{label}...", progress=10)
|
||||||
|
zip_path = prepare_deformation_zip(source_job_id, target)
|
||||||
|
set_job(job_id, message="打包完成,准备下载...", progress=95)
|
||||||
|
return {"file": file_metadata(zip_path)}
|
||||||
|
|
||||||
|
self.send_json(
|
||||||
|
start_job(
|
||||||
|
"zip",
|
||||||
|
worker,
|
||||||
|
owner=username,
|
||||||
|
params={
|
||||||
|
"sourceJobId": source_job_id,
|
||||||
|
"target": target,
|
||||||
|
},
|
||||||
|
remember_user_task=False,
|
||||||
|
),
|
||||||
|
status=202,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if parsed.path == "/api/deformation":
|
if parsed.path == "/api/deformation":
|
||||||
input_dir = body["inputDir"]
|
input_dir = body["inputDir"]
|
||||||
angle_degrees = float(body.get("angleDegrees", 12))
|
angle_degrees = float(body.get("angleDegrees", 12))
|
||||||
|
|||||||
Reference in New Issue
Block a user