select contents for four-state zip package
This commit is contained in:
@@ -60,6 +60,31 @@ type BackendJob = {
|
|||||||
|
|
||||||
type ZipJobsByTarget = Record<string, BackendJob>;
|
type ZipJobsByTarget = Record<string, BackendJob>;
|
||||||
|
|
||||||
|
type PackageOptions = {
|
||||||
|
dicom: string[];
|
||||||
|
images: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const DICOM_PACKAGE_OPTIONS = [
|
||||||
|
{ key: 'original', label: '原始序列 DICOM' },
|
||||||
|
{ key: 'hard_boundary', label: '硬边界 DICOM' },
|
||||||
|
{ key: 'gaussian_smooth', label: '高斯平滑 DICOM' },
|
||||||
|
{ key: 'soft_transition', label: '软过渡重建 DICOM' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const IMAGE_PACKAGE_OPTIONS = [
|
||||||
|
{ key: 'comparison', label: '原始图片四状态过程对比图' },
|
||||||
|
{ key: 'original', label: '原始序列' },
|
||||||
|
{ key: 'hard_boundary', label: '硬边界' },
|
||||||
|
{ key: 'gaussian_smooth', label: '高斯平滑' },
|
||||||
|
{ key: 'soft_transition', label: '软过渡重建' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_PACKAGE_OPTIONS: PackageOptions = {
|
||||||
|
dicom: DICOM_PACKAGE_OPTIONS.map(option => option.key),
|
||||||
|
images: IMAGE_PACKAGE_OPTIONS.map(option => option.key),
|
||||||
|
};
|
||||||
|
|
||||||
type LibraryItem = {
|
type LibraryItem = {
|
||||||
id: string;
|
id: string;
|
||||||
patientId: string;
|
patientId: string;
|
||||||
@@ -263,6 +288,8 @@ export default function App() {
|
|||||||
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 [zipJobs, setZipJobs] = useState<ZipJobsByTarget>({});
|
||||||
|
const [isPackageDialogOpen, setIsPackageDialogOpen] = useState(false);
|
||||||
|
const [packageOptions, setPackageOptions] = useState<PackageOptions>(DEFAULT_PACKAGE_OPTIONS);
|
||||||
const [videoMaxAngle, setVideoMaxAngle] = useState(20);
|
const [videoMaxAngle, setVideoMaxAngle] = useState(20);
|
||||||
const [videoDuration, setVideoDuration] = useState(6);
|
const [videoDuration, setVideoDuration] = useState(6);
|
||||||
|
|
||||||
@@ -339,7 +366,7 @@ export default function App() {
|
|||||||
setIsSimulating(job.status === 'running');
|
setIsSimulating(job.status === 'running');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePackageDownload = async (target: string) => {
|
const handlePackageDownload = async (target: string, selectedPackageOptions?: PackageOptions) => {
|
||||||
if (!deformationJob?.id || zipJobs[target]?.status === 'running') return;
|
if (!deformationJob?.id || zipJobs[target]?.status === 'running') return;
|
||||||
try {
|
try {
|
||||||
const job = await apiRequest('/api/deformation/package', {
|
const job = await apiRequest('/api/deformation/package', {
|
||||||
@@ -347,7 +374,8 @@ export default function App() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username: currentUser?.username || 'anonymous',
|
username: currentUser?.username || 'anonymous',
|
||||||
jobId: deformationJob.id,
|
jobId: deformationJob.id,
|
||||||
target
|
target,
|
||||||
|
packageOptions: selectedPackageOptions
|
||||||
})
|
})
|
||||||
}) as BackendJob;
|
}) as BackendJob;
|
||||||
setZipJobs(current => ({ ...current, [target]: job }));
|
setZipJobs(current => ({ ...current, [target]: job }));
|
||||||
@@ -367,6 +395,38 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const togglePackageOption = (group: keyof PackageOptions, key: string) => {
|
||||||
|
setPackageOptions(current => {
|
||||||
|
const exists = current[group].includes(key);
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
[group]: exists
|
||||||
|
? current[group].filter(value => value !== key)
|
||||||
|
: [...current[group], key],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPackageGroupSelection = (group: keyof PackageOptions, keys: string[]) => {
|
||||||
|
setPackageOptions(current => ({ ...current, [group]: keys }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const invertPackageGroupSelection = (group: keyof PackageOptions, allKeys: string[]) => {
|
||||||
|
setPackageOptions(current => ({
|
||||||
|
...current,
|
||||||
|
[group]: allKeys.filter(key => !current[group].includes(key)),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmPackageDownload = () => {
|
||||||
|
if (!packageOptions.dicom.length && !packageOptions.images.length) {
|
||||||
|
showToast('请至少选择一个打包内容');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsPackageDialogOpen(false);
|
||||||
|
handlePackageDownload('all', packageOptions);
|
||||||
|
};
|
||||||
|
|
||||||
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 || [];
|
||||||
@@ -947,7 +1007,10 @@ export default function App() {
|
|||||||
{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 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handlePackageDownload('all')}
|
onClick={() => {
|
||||||
|
setPackageOptions(DEFAULT_PACKAGE_OPTIONS);
|
||||||
|
setIsPackageDialogOpen(true);
|
||||||
|
}}
|
||||||
disabled={zipJobs.all?.status === 'running'}
|
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 disabled:opacity-70 disabled:cursor-wait"
|
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"
|
||||||
>
|
>
|
||||||
@@ -1394,6 +1457,105 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{isPackageDialogOpen && (
|
||||||
|
<div className="fixed inset-0 bg-slate-950/45 backdrop-blur-sm z-40 flex items-center justify-center p-6">
|
||||||
|
<div className="w-full max-w-3xl bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden">
|
||||||
|
<div className="px-7 py-5 border-b flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">四状态 ZIP 内容选择</p>
|
||||||
|
<h3 className="text-xl font-black text-slate-800 mt-1">选择要打包下载的内容</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsPackageDialogOpen(false)}
|
||||||
|
className="w-10 h-10 rounded-xl bg-slate-50 text-slate-400 hover:bg-red-50 hover:text-red-500 flex items-center justify-center transition-all"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-7 space-y-6">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
key: 'dicom' as keyof PackageOptions,
|
||||||
|
title: 'DICOM 类',
|
||||||
|
options: DICOM_PACKAGE_OPTIONS,
|
||||||
|
allKeys: DICOM_PACKAGE_OPTIONS.map(option => option.key),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'images' as keyof PackageOptions,
|
||||||
|
title: '图片类',
|
||||||
|
options: IMAGE_PACKAGE_OPTIONS,
|
||||||
|
allKeys: IMAGE_PACKAGE_OPTIONS.map(option => option.key),
|
||||||
|
},
|
||||||
|
].map(group => (
|
||||||
|
<div key={group.key} className="border border-slate-100 rounded-2xl p-5 bg-slate-50/50">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h4 className="text-sm font-black text-slate-800">{group.title}</h4>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPackageGroupSelection(group.key, group.allKeys)}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-white border border-slate-200 text-[10px] font-black text-slate-500 hover:text-blue-600 hover:border-blue-200 transition-all"
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => invertPackageGroupSelection(group.key, group.allKeys)}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-white border border-slate-200 text-[10px] font-black text-slate-500 hover:text-blue-600 hover:border-blue-200 transition-all"
|
||||||
|
>
|
||||||
|
反选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{group.options.map(option => {
|
||||||
|
const checked = packageOptions[group.key].includes(option.key);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${group.key}-${option.key}`}
|
||||||
|
onClick={() => togglePackageOption(group.key, option.key)}
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-xl border text-left transition-all ${
|
||||||
|
checked
|
||||||
|
? 'bg-blue-50 border-blue-200 text-blue-700'
|
||||||
|
: 'bg-white border-slate-100 text-slate-500 hover:border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`w-5 h-5 rounded-md border flex items-center justify-center shrink-0 ${
|
||||||
|
checked ? 'bg-blue-600 border-blue-600 text-white' : 'border-slate-200'
|
||||||
|
}`}>
|
||||||
|
{checked && <Check size={13} />}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-black">{option.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<p className="text-[10px] font-bold text-slate-400">
|
||||||
|
已选择 {packageOptions.dicom.length + packageOptions.images.length} 项内容
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsPackageDialogOpen(false)}
|
||||||
|
className="px-5 py-3 rounded-xl bg-slate-100 text-slate-500 text-xs font-black hover:bg-slate-200 transition-all"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={confirmPackageDownload}
|
||||||
|
className="px-5 py-3 rounded-xl bg-green-600 text-white text-xs font-black hover:bg-green-700 transition-all flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Download size={14} /> 开始打包下载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{(libraryInfo || isLibraryInfoLoading) && (
|
{(libraryInfo || isLibraryInfoLoading) && (
|
||||||
<div className="fixed inset-0 bg-slate-950/45 backdrop-blur-sm z-40 flex items-center justify-center p-6">
|
<div className="fixed inset-0 bg-slate-950/45 backdrop-blur-sm z-40 flex items-center justify-center p-6">
|
||||||
<div className="w-full max-w-4xl max-h-[85vh] bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden">
|
<div className="w-full max-w-4xl max-h-[85vh] bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden">
|
||||||
|
|||||||
@@ -588,7 +588,60 @@ def file_metadata(file_path):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def prepare_deformation_zip(job_id, target):
|
def zip_selected_deformation_outputs(job_root, outputs, previews, package_options):
|
||||||
|
dicom_keys = package_options.get("dicom") or []
|
||||||
|
image_keys = package_options.get("images") or []
|
||||||
|
if not dicom_keys and not image_keys:
|
||||||
|
raise RuntimeError("请至少选择一个要打包的内容。")
|
||||||
|
|
||||||
|
zip_path = Path(job_root) / f"head_ct_morph_selected_{Path(job_root).name}.zip"
|
||||||
|
if zip_path.exists():
|
||||||
|
zip_path.unlink()
|
||||||
|
|
||||||
|
dicom_names = {
|
||||||
|
"original": "dicom/ct_original",
|
||||||
|
"hard_boundary": "dicom/ct_hard_boundary",
|
||||||
|
"gaussian_smooth": "dicom/ct_gaussian_smooth",
|
||||||
|
"soft_transition": "dicom/ct_soft_transition",
|
||||||
|
}
|
||||||
|
image_files = {
|
||||||
|
"comparison": Path(previews.get("comparison", "")),
|
||||||
|
"original": Path(previews.get("screenshots", "")) / "original.png",
|
||||||
|
"hard_boundary": Path(previews.get("screenshots", "")) / "hard_boundary.png",
|
||||||
|
"gaussian_smooth": Path(previews.get("screenshots", "")) / "gaussian_smooth.png",
|
||||||
|
"soft_transition": Path(previews.get("screenshots", "")) / "soft_transition.png",
|
||||||
|
}
|
||||||
|
image_names = {
|
||||||
|
"comparison": "images/process_comparison_4states.png",
|
||||||
|
"original": "images/original.png",
|
||||||
|
"hard_boundary": "images/hard_boundary.png",
|
||||||
|
"gaussian_smooth": "images/gaussian_smooth.png",
|
||||||
|
"soft_transition": "images/soft_transition.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
for key in dicom_keys:
|
||||||
|
if key not in dicom_names:
|
||||||
|
continue
|
||||||
|
source_dir = Path(outputs.get(key, ""))
|
||||||
|
if not source_dir.exists():
|
||||||
|
raise RuntimeError(f"{key} 的 DICOM 输出目录不存在。")
|
||||||
|
for file_path in source_dir.rglob("*"):
|
||||||
|
if file_path.is_file():
|
||||||
|
archive.write(file_path, Path(dicom_names[key]) / file_path.relative_to(source_dir))
|
||||||
|
|
||||||
|
for key in image_keys:
|
||||||
|
if key not in image_files:
|
||||||
|
continue
|
||||||
|
source_file = image_files[key]
|
||||||
|
if not source_file.exists():
|
||||||
|
raise RuntimeError(f"{key} 的图片输出不存在。")
|
||||||
|
archive.write(source_file, image_names[key])
|
||||||
|
|
||||||
|
return zip_path
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_deformation_zip(job_id, target, package_options=None):
|
||||||
job = get_job(job_id)
|
job = get_job(job_id)
|
||||||
if not job:
|
if not job:
|
||||||
raise RuntimeError("任务不存在。")
|
raise RuntimeError("任务不存在。")
|
||||||
@@ -599,8 +652,11 @@ def prepare_deformation_zip(job_id, target):
|
|||||||
|
|
||||||
result = job.get("result") or {}
|
result = job.get("result") or {}
|
||||||
outputs = result.get("outputs") or {}
|
outputs = result.get("outputs") or {}
|
||||||
|
previews = result.get("previews") or {}
|
||||||
job_root = RESULT_DIR / job_id
|
job_root = RESULT_DIR / job_id
|
||||||
if target == "all":
|
if target == "all":
|
||||||
|
if package_options:
|
||||||
|
return zip_selected_deformation_outputs(job_root, outputs, previews, package_options)
|
||||||
output_dir = job_root / "four_state_output"
|
output_dir = job_root / "four_state_output"
|
||||||
if not output_dir.exists():
|
if not output_dir.exists():
|
||||||
raise RuntimeError("四状态输出目录不存在。")
|
raise RuntimeError("四状态输出目录不存在。")
|
||||||
@@ -736,12 +792,13 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if parsed.path == "/api/deformation/package":
|
if parsed.path == "/api/deformation/package":
|
||||||
source_job_id = body["jobId"]
|
source_job_id = body["jobId"]
|
||||||
target = body.get("target", "all")
|
target = body.get("target", "all")
|
||||||
|
package_options = body.get("packageOptions")
|
||||||
username = normalized_username(body.get("username"))
|
username = normalized_username(body.get("username"))
|
||||||
|
|
||||||
def worker(job_id):
|
def worker(job_id):
|
||||||
label = "四状态总 ZIP" if target == "all" else "本状态 DICOM ZIP"
|
label = "四状态总 ZIP" if target == "all" else "本状态 DICOM ZIP"
|
||||||
set_job(job_id, message=f"正在打包{label}...", progress=10)
|
set_job(job_id, message=f"正在打包{label}...", progress=10)
|
||||||
zip_path = prepare_deformation_zip(source_job_id, target)
|
zip_path = prepare_deformation_zip(source_job_id, target, package_options)
|
||||||
set_job(job_id, message="打包完成,准备下载...", progress=95)
|
set_job(job_id, message="打包完成,准备下载...", progress=95)
|
||||||
return {"file": file_metadata(zip_path)}
|
return {"file": file_metadata(zip_path)}
|
||||||
|
|
||||||
@@ -753,6 +810,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
params={
|
params={
|
||||||
"sourceJobId": source_job_id,
|
"sourceJobId": source_job_id,
|
||||||
"target": target,
|
"target": target,
|
||||||
|
"packageOptions": package_options,
|
||||||
},
|
},
|
||||||
remember_user_task=False,
|
remember_user_task=False,
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user