2026-05-20-15-54-46 逆向工作区视图交互与布局优化
This commit is contained in:
@@ -10,6 +10,8 @@ import {
|
||||
ChevronUp,
|
||||
Eye,
|
||||
Layers,
|
||||
Maximize2,
|
||||
RefreshCcw,
|
||||
Save,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
@@ -70,7 +72,7 @@ const poseStepConfig: Record<ModelPoseKey, { min: number; max: number; step: num
|
||||
translateX: { min: -2, max: 2, step: 0.005, minus: '-X', plus: '+X' },
|
||||
translateY: { min: -2, max: 2, step: 0.005, minus: '-Y', plus: '+Y' },
|
||||
translateZ: { min: -2, max: 2, step: 0.005, minus: '-Z', plus: '+Z' },
|
||||
scale: { min: 0.5, max: 2, step: 0.05, minus: '-S', plus: '+S' },
|
||||
scale: { min: 0.5, max: 3, step: 0.05, minus: '-S', plus: '+S' },
|
||||
};
|
||||
|
||||
const defaultModelPose: ModelPose = {
|
||||
@@ -135,6 +137,36 @@ function formatPoseDraftValues(pose: ModelPose): PoseDraftValues {
|
||||
}), {} as PoseDraftValues);
|
||||
}
|
||||
|
||||
function isNinetyDegreeMultiple(value: number) {
|
||||
const normalized = ((value % 90) + 90) % 90;
|
||||
return Math.min(normalized, 90 - normalized) < 1e-6;
|
||||
}
|
||||
|
||||
function isOrthogonalModelPose(pose: ModelPose) {
|
||||
return isNinetyDegreeMultiple(pose.rotateX)
|
||||
&& isNinetyDegreeMultiple(pose.rotateY)
|
||||
&& isNinetyDegreeMultiple(pose.rotateZ);
|
||||
}
|
||||
|
||||
function getRotatedModelSize(bounds: { min: THREE.Vector3; max: THREE.Vector3 }, pose: ModelPose) {
|
||||
const center = new THREE.Vector3().addVectors(bounds.min, bounds.max).multiplyScalar(0.5);
|
||||
const rotationMatrix = new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(
|
||||
THREE.MathUtils.degToRad(pose.rotateX),
|
||||
THREE.MathUtils.degToRad(pose.rotateY),
|
||||
THREE.MathUtils.degToRad(pose.rotateZ),
|
||||
));
|
||||
const rotatedBox = new THREE.Box3();
|
||||
[bounds.min.x, bounds.max.x].forEach((x) => {
|
||||
[bounds.min.y, bounds.max.y].forEach((y) => {
|
||||
[bounds.min.z, bounds.max.z].forEach((z) => {
|
||||
const point = new THREE.Vector3(x, y, z).sub(center).applyMatrix4(rotationMatrix);
|
||||
rotatedBox.expandByPoint(point);
|
||||
});
|
||||
});
|
||||
});
|
||||
return rotatedBox.getSize(new THREE.Vector3());
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -424,6 +456,7 @@ export function FusionThreeView({
|
||||
const [loadProgress, setLoadProgress] = useState(0);
|
||||
const [axisProjection, setAxisProjection] = useState<AxisProjection>(defaultAxisProjection);
|
||||
const axisProjectionSignatureRef = useRef(axisProjectionSignature(defaultAxisProjection));
|
||||
const resetFusionViewRef = useRef<() => void>(() => undefined);
|
||||
|
||||
useEffect(() => {
|
||||
modelPoseRef.current = modelPose;
|
||||
@@ -615,12 +648,12 @@ export function FusionThreeView({
|
||||
modelPivot.add(modelBounds);
|
||||
modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.92;
|
||||
modelPoseGroup.position.set(0, 0, 0);
|
||||
modelPivot.position.set(0, 0, dicomDepth * 0.08);
|
||||
modelPivot.position.set(0, 0, 0);
|
||||
setLoadProgress(100);
|
||||
setStatus(visibleStlFiles.length ? '三维融合场景已就绪' : 'DICOM 三维体已就绪,当前没有显示的 STL 构件');
|
||||
});
|
||||
|
||||
const rootPose = {
|
||||
const defaultRootPose = {
|
||||
rotateX: THREE.MathUtils.degToRad(58),
|
||||
rotateY: 0,
|
||||
rotateZ: THREE.MathUtils.degToRad(-18),
|
||||
@@ -628,6 +661,7 @@ export function FusionThreeView({
|
||||
translateY: 0,
|
||||
scale: 1,
|
||||
};
|
||||
const rootPose = { ...defaultRootPose };
|
||||
const dragState = {
|
||||
active: false,
|
||||
mode: 'rotate' as 'rotate' | 'pan',
|
||||
@@ -636,6 +670,10 @@ export function FusionThreeView({
|
||||
startY: 0,
|
||||
root: { ...rootPose },
|
||||
};
|
||||
resetFusionViewRef.current = () => {
|
||||
Object.assign(rootPose, defaultRootPose);
|
||||
setStatus('三维融合视角已复位');
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
dragState.active = true;
|
||||
@@ -749,6 +787,7 @@ export function FusionThreeView({
|
||||
}
|
||||
});
|
||||
renderer.dispose();
|
||||
resetFusionViewRef.current = () => undefined;
|
||||
container.innerHTML = '';
|
||||
};
|
||||
}, [
|
||||
@@ -776,6 +815,14 @@ export function FusionThreeView({
|
||||
<div className="pointer-events-none absolute right-4 top-4 rounded-xl border border-cyan-400/20 bg-cyan-950/50 px-3 py-2 text-[10px] font-mono text-cyan-100">
|
||||
DICOM {volume ? `${volume.start + 1}-${volume.end + 1}/${volume.total}` : '加载中'} · STL {project.modelCount ?? 0}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => resetFusionViewRef.current()}
|
||||
className="absolute right-4 top-16 z-10 flex h-8 items-center gap-1.5 rounded-xl border border-white/10 bg-black/60 px-3 text-[10px] font-bold text-white/70 shadow-lg hover:border-cyan-300/30 hover:text-cyan-100"
|
||||
title="重置影像与模型融合视角位置"
|
||||
>
|
||||
<RefreshCcw size={13} />
|
||||
位置重置
|
||||
</button>
|
||||
<CoordinateAxesInset projection={axisProjection} />
|
||||
{loadProgress < 100 && (
|
||||
<div className="absolute inset-x-10 bottom-8 rounded-xl border border-white/10 bg-black/70 p-3">
|
||||
@@ -952,7 +999,7 @@ function CutSectionPreview({
|
||||
});
|
||||
modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.98;
|
||||
modelPoseGroup.position.set(0, 0, 0);
|
||||
modelPivot.position.set(0, 0, dicomDepth * 0.08);
|
||||
modelPivot.position.set(0, 0, 0);
|
||||
});
|
||||
|
||||
const rootPose = {
|
||||
@@ -1765,6 +1812,15 @@ export function VoxelizationMappingView({
|
||||
const [dicomStatus, setDicomStatus] = useState('等待 DICOM 切片');
|
||||
const [overlayStatus, setOverlayStatus] = useState('等待 STL 映射');
|
||||
const [overlayStats, setOverlayStats] = useState<OverlayStats>({ activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] });
|
||||
const [mappingViewport, setMappingViewport] = useState({ scale: 1, offsetX: 0, offsetY: 0 });
|
||||
const mappingPanRef = useRef({
|
||||
active: false,
|
||||
pointerId: 0,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
});
|
||||
const maxSlice = Math.max(totalSlices - 1, 0);
|
||||
const safeSlice = clamp(slice, 0, maxSlice);
|
||||
const stlFiles = project?.stlFiles ?? [];
|
||||
@@ -1879,30 +1935,94 @@ export function VoxelizationMappingView({
|
||||
onSliceChange(clamp(safeSlice + delta, 0, maxSlice));
|
||||
};
|
||||
const slicePercent = maxSlice > 0 ? (safeSlice / maxSlice) * 100 : 0;
|
||||
const resetMappingViewport = () => {
|
||||
setMappingViewport({ scale: 1, offsetX: 0, offsetY: 0 });
|
||||
};
|
||||
const handleMappingWheel = (event: React.WheelEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1;
|
||||
setMappingViewport((current) => ({
|
||||
...current,
|
||||
scale: clamp(current.scale * scaleFactor, 0.45, 6),
|
||||
}));
|
||||
};
|
||||
const handleMappingPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
mappingPanRef.current = {
|
||||
active: true,
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
offsetX: mappingViewport.offsetX,
|
||||
offsetY: mappingViewport.offsetY,
|
||||
};
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
const handleMappingPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const dragState = mappingPanRef.current;
|
||||
if (!dragState.active || dragState.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
setMappingViewport((current) => ({
|
||||
...current,
|
||||
offsetX: dragState.offsetX + event.clientX - dragState.startX,
|
||||
offsetY: dragState.offsetY + event.clientY - dragState.startY,
|
||||
}));
|
||||
};
|
||||
const stopMappingPointerDrag = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const dragState = mappingPanRef.current;
|
||||
if (!dragState.active || dragState.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
mappingPanRef.current = { ...dragState, active: false };
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-[420px] flex-col overflow-hidden rounded-3xl border border-slate-900 bg-slate-950 shadow-2xl">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-white/10 bg-slate-950 px-4 py-3">
|
||||
<div className="flex min-w-0 flex-wrap gap-2">
|
||||
<span className="rounded-lg border border-white/10 bg-black/65 px-2.5 py-1 text-[9px] font-bold uppercase tracking-widest text-slate-200">
|
||||
<div className="relative flex h-full min-h-[520px] flex-col overflow-hidden rounded-3xl border border-slate-100 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-slate-100 bg-white px-4 py-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-1 text-[9px] font-bold uppercase tracking-widest text-slate-600">
|
||||
Base DICOM
|
||||
</span>
|
||||
<span className="rounded-lg border border-cyan-300/20 bg-cyan-950/70 px-2.5 py-1 text-[9px] font-bold uppercase tracking-widest text-cyan-100">
|
||||
<span className="rounded-lg border border-cyan-200 bg-cyan-50 px-2.5 py-1 text-[9px] font-bold uppercase tracking-widest text-cyan-700">
|
||||
Overlay Label Map
|
||||
</span>
|
||||
</div>
|
||||
<div className="shrink-0 rounded-lg border border-white/10 bg-black/65 px-2.5 py-1 text-[10px] font-mono text-white/70">
|
||||
<span className="rounded-lg bg-slate-100 px-2.5 py-1 text-[9px] font-mono font-bold text-slate-500">
|
||||
Z {safeSlice + 1}/{Math.max(totalSlices, 1)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={resetMappingViewport}
|
||||
className="flex h-8 shrink-0 items-center gap-1.5 rounded-xl border border-slate-200 bg-white px-3 text-[10px] font-bold text-slate-600 shadow-sm hover:border-cyan-200 hover:bg-cyan-50 hover:text-cyan-700"
|
||||
title="重置逆向分割映射视图位置"
|
||||
>
|
||||
<RefreshCcw size={13} />
|
||||
位置重置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_76px]">
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_110px]">
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden bg-black">
|
||||
<div
|
||||
className={`relative min-h-0 flex-1 overflow-hidden bg-black ${mappingPanRef.current.active ? 'cursor-grabbing' : 'cursor-grab'}`}
|
||||
onWheel={handleMappingWheel}
|
||||
onPointerDown={handleMappingPointerDown}
|
||||
onPointerMove={handleMappingPointerMove}
|
||||
onPointerUp={stopMappingPointerDrag}
|
||||
onPointerCancel={stopMappingPointerDrag}
|
||||
>
|
||||
{dicomPreview ? (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{ transform: `rotate(${rotation}deg)` }}
|
||||
style={{
|
||||
transform: `translate3d(${mappingViewport.offsetX}px, ${mappingViewport.offsetY}px, 0) rotate(${rotation}deg) scale(${mappingViewport.scale})`,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
>
|
||||
<canvas ref={baseCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
|
||||
<canvas ref={overlayCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
|
||||
@@ -1914,10 +2034,10 @@ export function VoxelizationMappingView({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 bg-slate-950 px-4 py-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-[10px] font-bold text-white/70">
|
||||
<span className="truncate">{overlayStatus}</span>
|
||||
<span className="font-mono text-cyan-100">
|
||||
<div className="border-t border-slate-100 bg-white px-4 py-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-[10px] font-bold text-slate-600">
|
||||
<span className="truncate">Overlay Label Map · {overlayStatus}</span>
|
||||
<span className="font-mono text-cyan-700">
|
||||
{overlayStats.activeModules}/{visibleModuleCount} 构件 · {overlayStats.segmentCount} 边 · {overlayStats.filledPixels} px
|
||||
</span>
|
||||
</div>
|
||||
@@ -1925,17 +2045,17 @@ export function VoxelizationMappingView({
|
||||
{overlayStats.modules.length ? (
|
||||
<div className="grid grid-cols-2 gap-1.5 xl:grid-cols-3">
|
||||
{overlayStats.modules.map((item) => (
|
||||
<div key={item.fileName} className="grid grid-cols-[10px_1fr_auto] items-center gap-1 rounded-md border border-white/10 bg-white/5 px-1.5 py-1 text-[8px] font-bold text-white/70">
|
||||
<span className="h-2 w-2 rounded-sm border border-white/20" style={{ backgroundColor: item.color, opacity: item.opacity }} />
|
||||
<div key={item.fileName} className="grid grid-cols-[10px_1fr_auto] items-center gap-1 rounded-md border border-slate-100 bg-slate-50 px-1.5 py-1 text-[8px] font-bold text-slate-600">
|
||||
<span className="h-2 w-2 rounded-sm border border-white" style={{ backgroundColor: item.color, opacity: item.opacity }} />
|
||||
<span className="min-w-0 truncate">{item.name}</span>
|
||||
<span className="font-mono text-cyan-100">ID {item.partId}</span>
|
||||
<span className="col-start-2 font-mono text-white/35">{item.segmentCount} 边</span>
|
||||
<span className="font-mono text-white/35">{item.filledPixels} px</span>
|
||||
<span className="font-mono text-cyan-700">ID {item.partId}</span>
|
||||
<span className="col-start-2 font-mono text-slate-400">{item.segmentCount} 边</span>
|
||||
<span className="font-mono text-slate-400">{item.filledPixels} px</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-white/10 bg-white/5 px-2 py-1.5 text-[9px] font-bold text-white/35">
|
||||
<div className="rounded-lg border border-slate-100 bg-slate-50 px-2 py-1.5 text-[9px] font-bold text-slate-400">
|
||||
当前切片暂无可见构件
|
||||
</div>
|
||||
)}
|
||||
@@ -1943,25 +2063,25 @@ export function VoxelizationMappingView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="flex min-h-0 flex-col items-center gap-3 border-l border-white/10 bg-slate-900/95 px-3 py-4">
|
||||
<div className="w-full rounded-2xl border border-white/10 bg-slate-950/90 px-2 py-3 text-center">
|
||||
<p className="text-[10px] font-bold text-slate-300">DICOM 切片位置</p>
|
||||
<span className="mt-1 block font-mono text-[10px] font-bold text-cyan-100">
|
||||
<aside className="flex min-h-0 flex-col items-center gap-3 border-l border-slate-100 bg-white px-3 py-4">
|
||||
<div className="w-full rounded-2xl border border-slate-100 bg-white px-2 py-3 text-center shadow-sm">
|
||||
<p className="text-[10px] font-bold text-slate-700">DICOM 切片位置</p>
|
||||
<span className="mt-1 block font-mono text-[10px] font-bold text-blue-600">
|
||||
{safeSlice + 1} / {Math.max(totalSlices, 1)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => stepSlice(-1)}
|
||||
disabled={safeSlice <= 0}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-xl border border-slate-700 bg-slate-950 text-slate-200 hover:border-cyan-400 hover:text-cyan-100 disabled:opacity-35"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-35"
|
||||
title="上一层"
|
||||
>
|
||||
<ChevronUp size={16} />
|
||||
</button>
|
||||
<div className="relative min-h-[240px] w-10 flex-1">
|
||||
<div className="absolute inset-y-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-slate-800" />
|
||||
<div className="absolute inset-y-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-slate-200" />
|
||||
<div
|
||||
className="absolute bottom-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-cyan-400"
|
||||
className="absolute bottom-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-blue-600"
|
||||
style={{ height: `${slicePercent}%` }}
|
||||
/>
|
||||
<input
|
||||
@@ -1977,14 +2097,14 @@ export function VoxelizationMappingView({
|
||||
<button
|
||||
onClick={() => stepSlice(1)}
|
||||
disabled={safeSlice >= maxSlice}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-xl border border-slate-700 bg-slate-950 text-slate-200 hover:border-cyan-400 hover:text-cyan-100 disabled:opacity-35"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-35"
|
||||
title="下一层"
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
<div className="grid w-full grid-cols-1 gap-1 text-center text-[9px] font-bold text-slate-500">
|
||||
<span>顶层 {Math.max(totalSlices, 1)}</span>
|
||||
<span className="text-cyan-200">当前 {safeSlice + 1}</span>
|
||||
<span className="text-blue-600">当前 {safeSlice + 1}</span>
|
||||
<span>底层 1</span>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -2029,7 +2149,9 @@ export default function ReverseWorkspace({
|
||||
const [fusionError, setFusionError] = useState('');
|
||||
const [saveStatus, setSaveStatus] = useState('');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [stretchingAxis, setStretchingAxis] = useState<AxisKey | null>(null);
|
||||
const fusionVolumeCacheRef = useRef(new Map<string, DicomFusionVolume>());
|
||||
const modelBoundsCacheRef = useRef(new Map<string, { min: THREE.Vector3; max: THREE.Vector3 }>());
|
||||
const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null });
|
||||
const poseImportInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const saveToastTimerRef = useRef<number | null>(null);
|
||||
@@ -2217,6 +2339,88 @@ export default function ReverseWorkspace({
|
||||
return volumePayload;
|
||||
};
|
||||
|
||||
const loadVisibleModelBounds = async () => {
|
||||
if (!project) {
|
||||
return null;
|
||||
}
|
||||
const visibleFiles = (project.stlFiles ?? []).filter((fileName) => moduleStyles[fileName]?.visible !== false);
|
||||
const cacheKey = `${project.id}:${visibleFiles.join('|')}`;
|
||||
const cached = modelBoundsCacheRef.current.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const modelBox = new THREE.Box3();
|
||||
const results = await Promise.allSettled(visibleFiles.map((fileName) => (
|
||||
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=1000`)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('模型边界载入失败');
|
||||
}
|
||||
return response.json() as Promise<ModelPreviewPayload>;
|
||||
})
|
||||
)));
|
||||
results.forEach((result) => {
|
||||
if (result.status !== 'fulfilled' || !result.value.bounds) {
|
||||
return;
|
||||
}
|
||||
modelBox.expandByPoint(new THREE.Vector3(result.value.bounds.min.x, result.value.bounds.min.y, result.value.bounds.min.z));
|
||||
modelBox.expandByPoint(new THREE.Vector3(result.value.bounds.max.x, result.value.bounds.max.y, result.value.bounds.max.z));
|
||||
});
|
||||
|
||||
if (modelBox.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
const bounds = { min: modelBox.min.clone(), max: modelBox.max.clone() };
|
||||
modelBoundsCacheRef.current.set(cacheKey, bounds);
|
||||
return bounds;
|
||||
};
|
||||
|
||||
const applyModelStretchByAxis = async (axis: AxisKey) => {
|
||||
if (!project || !fusionVolume) {
|
||||
setFusionError('请等待 DICOM 与 STL 数据加载完成后再拉伸模型');
|
||||
return;
|
||||
}
|
||||
if (!isOrthogonalModelPose(modelPose)) {
|
||||
setPoseImportStatus('');
|
||||
setFusionError('模型拉伸仅在旋转 X/Y/Z 均为 90° 的整数倍时可用');
|
||||
return;
|
||||
}
|
||||
|
||||
setStretchingAxis(axis);
|
||||
setFusionError('');
|
||||
try {
|
||||
const bounds = await loadVisibleModelBounds();
|
||||
if (!bounds) {
|
||||
throw new Error('未获取到可见 STL 构件边界');
|
||||
}
|
||||
const rawSize = new THREE.Vector3().subVectors(bounds.max, bounds.min);
|
||||
const rotatedSize = getRotatedModelSize(bounds, modelPose);
|
||||
const maxModelSize = Math.max(rawSize.x, rawSize.y, rawSize.z, 1);
|
||||
const maxPhysical = Math.max(
|
||||
fusionVolume.physicalSize.width,
|
||||
fusionVolume.physicalSize.height,
|
||||
fusionVolume.physicalSize.depth,
|
||||
1,
|
||||
);
|
||||
const baseExtent = 4.6;
|
||||
const dicomSize = {
|
||||
x: (fusionVolume.physicalSize.width / maxPhysical) * baseExtent,
|
||||
y: (fusionVolume.physicalSize.height / maxPhysical) * baseExtent,
|
||||
z: Math.max((fusionVolume.physicalSize.depth / maxPhysical) * baseExtent, 0.18),
|
||||
};
|
||||
const baseScale = (Math.max(dicomSize.x, dicomSize.y, dicomSize.z) / maxModelSize) * 0.92;
|
||||
const rotatedAxisSize = Math.max(rotatedSize[axis], 1e-6);
|
||||
const nextScale = clampPoseValue('scale', dicomSize[axis] / (rotatedAxisSize * baseScale));
|
||||
updateModelPose({ scale: nextScale });
|
||||
setPoseImportStatus(`已按 ${axis.toUpperCase()} 方向进行三维等比例拉伸`);
|
||||
} catch (error) {
|
||||
setFusionError(error instanceof Error ? error.message : '模型自动拉伸失败');
|
||||
} finally {
|
||||
setStretchingAxis(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
api.getProject(projectId).then((item) => {
|
||||
setProject(item);
|
||||
@@ -2517,6 +2721,7 @@ export default function ReverseWorkspace({
|
||||
const rangeEndPercent = maxSlice > 0 ? (displayEnd / maxSlice) * 100 : 0;
|
||||
const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0];
|
||||
const selectedDicomOpacity = dicomOpacityOptions.find((item) => item.id === dicomOpacityLevel) ?? dicomOpacityOptions[0];
|
||||
const stretchEnabled = Boolean(project && fusionVolume && isOrthogonalModelPose(modelPose));
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-y-auto pr-2 flex flex-col gap-6">
|
||||
@@ -2628,19 +2833,43 @@ export default function ReverseWorkspace({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[780px] lg:min-h-0 flex-1 grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
<div className="lg:col-span-4 min-h-0 flex flex-col gap-4">
|
||||
<div className="min-h-[840px] flex-1 grid grid-cols-1 items-stretch gap-5 xl:grid-cols-[minmax(300px,0.82fr)_minmax(350px,0.98fr)_minmax(520px,1.32fr)] 2xl:grid-cols-[minmax(330px,0.8fr)_minmax(390px,1fr)_minmax(620px,1.45fr)]">
|
||||
<div className="min-h-0 flex flex-col gap-4">
|
||||
<div className="px-2 flex flex-wrap items-center justify-between gap-2 shrink-0">
|
||||
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
||||
<Rotate3d size={18} className="text-blue-500" />
|
||||
影像与模型融合视角
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
<span className="text-[10px] font-mono text-slate-400">
|
||||
Layer: {displayStart + 1}-{displayEnd + 1}/{project?.dicomCount ?? 0}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 rounded-xl bg-slate-100 p-1">
|
||||
<span className="hidden items-center gap-1 px-1 text-[9px] font-bold text-slate-400 2xl:flex">
|
||||
<Maximize2 size={11} />
|
||||
自动拉伸
|
||||
</span>
|
||||
{(['x', 'y', 'z'] as AxisKey[]).map((axis) => (
|
||||
<button
|
||||
key={axis}
|
||||
onClick={() => void applyModelStretchByAxis(axis)}
|
||||
disabled={!stretchEnabled || stretchingAxis !== null}
|
||||
className={`rounded-lg px-2 py-1 text-[9px] font-bold transition ${
|
||||
stretchEnabled && stretchingAxis === null
|
||||
? 'bg-white text-blue-600 shadow-sm hover:text-blue-700'
|
||||
: 'cursor-not-allowed text-slate-300'
|
||||
}`}
|
||||
title={stretchEnabled ? `按 ${axis.toUpperCase()} 方向自动等比例拉伸模型` : '仅当旋转 X/Y/Z 均为 90° 的整数倍时可用'}
|
||||
>
|
||||
{stretchingAxis === axis ? '...' : `${axis.toUpperCase()}拉伸`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{project ? (
|
||||
<div className="min-h-0 flex-1">
|
||||
<FusionThreeView
|
||||
project={project}
|
||||
volume={fusionVolume}
|
||||
@@ -2654,6 +2883,7 @@ export default function ReverseWorkspace({
|
||||
cutStart={displayStart}
|
||||
cutEnd={displayEnd}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 rounded-3xl border border-slate-100 bg-white flex items-center justify-center text-sm text-slate-400">
|
||||
正在载入项目...
|
||||
@@ -2714,7 +2944,7 @@ export default function ReverseWorkspace({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-4 min-h-0 flex flex-col gap-4 overflow-hidden">
|
||||
<div className="min-h-0 flex flex-col gap-4 overflow-hidden">
|
||||
<div className="px-2 shrink-0">
|
||||
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
||||
<Settings2 size={18} className="text-emerald-500" />
|
||||
@@ -2998,7 +3228,7 @@ export default function ReverseWorkspace({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-4 flex flex-col gap-4 overflow-hidden">
|
||||
<div className="min-h-0 flex flex-col gap-4 overflow-hidden">
|
||||
<div className="px-2 flex flex-wrap items-center justify-between gap-2 shrink-0">
|
||||
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
||||
<Layers size={18} className="text-cyan-500" />
|
||||
@@ -3035,6 +3265,7 @@ export default function ReverseWorkspace({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<VoxelizationMappingView
|
||||
project={project}
|
||||
moduleStyles={moduleStyles}
|
||||
@@ -3049,5 +3280,6 @@ export default function ReverseWorkspace({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,10 +142,10 @@
|
||||
.mapping-slice-vertical-input::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: #22d3ee;
|
||||
border: 3px solid #0f172a;
|
||||
background: #2563eb;
|
||||
border: 3px solid #ffffff;
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.28);
|
||||
cursor: grab;
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
@@ -158,10 +158,10 @@
|
||||
}
|
||||
|
||||
.mapping-slice-vertical-input::-moz-range-thumb {
|
||||
background: #22d3ee;
|
||||
border: 3px solid #0f172a;
|
||||
background: #2563eb;
|
||||
border: 3px solid #ffffff;
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.28);
|
||||
cursor: grab;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
|
||||
65
工程分析/实现方案-2026-05-20-15-54-46.md
Normal file
65
工程分析/实现方案-2026-05-20-15-54-46.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# 实现方案:逆向工作区控件外置、视图复位与手动拉伸
|
||||
|
||||
实现方案文档路径:`工程分析/实现方案-2026-05-20-15-54-46.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
优化逆向工作区整体布局和交互:右侧二维映射控件白底外置,支持缩放/平移/复位;左侧三维融合视图支持复位;主布局给右侧更多空间并底部对齐;位姿工具栏新增受角度约束的模型等比例拉伸。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/index.css`
|
||||
- `工程分析/需求分析-2026-05-20-15-54-46.md`
|
||||
- `工程分析/实现方案-2026-05-20-15-54-46.md`
|
||||
- `工程分析/测试方案-2026-05-20-15-54-46.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 调整逆向工作区主网格比例,左侧略窄,右侧加宽;统一设置三列拉伸高度,减少底部错位。
|
||||
2. 改造 `VoxelizationMappingView`:
|
||||
- 顶部图层状态改成白底信息条;
|
||||
- 右侧 `DICOM 切片位置` 控件改成白底卡片,风格接近 `DICOM 切片范围`;
|
||||
- 保留竖向切片条并外置于影像右侧;
|
||||
- 添加滚轮缩放、鼠标拖拽平移和“位置重置”按钮;
|
||||
- Base DICOM 与 Overlay Canvas 共用同一 transform。
|
||||
3. 改造 `FusionThreeView`:
|
||||
- 新增 `viewResetToken` 属性;
|
||||
- 在 Three.js 场景内部保存 camera/controls 引用,token 变化时恢复相机初始位置;
|
||||
- 在视图右侧或角落添加“位置重置”按钮入口。
|
||||
4. 位姿工具栏新增模型等比例拉伸按钮:
|
||||
- 判断 `rotateX/Y/Z` 是否均为 90 度整数倍;
|
||||
- 在可用时提供 X/Y/Z 方向拉伸按钮;
|
||||
- 拉伸仅调整 `modelPose.scale`,作为手动校正,不默认自动执行。
|
||||
5. 保持保存快照和导出语义使用现有 `modelPose.scale`,不新增后端结构。
|
||||
6. 运行 lint/build,部署验证,并提交 Gitea。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
- 阅读 `ReverseWorkspace.tsx` 中主布局、`FusionThreeView`、`VoxelizationMappingView`、位姿工具栏实现。
|
||||
- 修改二维映射视图布局和交互状态。
|
||||
- 修改三维融合视图复位接口和按钮。
|
||||
- 修改主布局比例和位姿拉伸按钮。
|
||||
- 运行 `npm run lint` 与 `npm run build`。
|
||||
- 重新部署 `tmux` 服务并验证健康接口和首页。
|
||||
- 更新测试方案与经验记录。
|
||||
- 精确暂存本轮相关文件,commit 并推送 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 二维缩放和平移仅影响 CSS transform,可直接回滚到原 `rotation` transform。
|
||||
- 三维复位按钮只触发相机状态,不改变数据,可安全移除。
|
||||
- 手动拉伸只复用既有 `scale` 字段,不改变保存结果结构;若用户需要按轴非等比例拉伸,可后续新增独立 scaleX/Y/Z 字段。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/index.css`
|
||||
- 本轮工程分析文档与 `工程分析/经验记录.md`
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 暂存本轮相关代码和工程分析文档。
|
||||
- commit message 包含 `2026-05-20-15-54-46`。
|
||||
- 推送到 Gitea 后重新部署并验证 `http://127.0.0.1:4000/api/health` 与首页。
|
||||
64
工程分析/测试方案-2026-05-20-15-54-46.md
Normal file
64
工程分析/测试方案-2026-05-20-15-54-46.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# 测试方案:逆向工作区布局、缩放平移、复位与拉伸验证
|
||||
|
||||
测试方案文档路径:`工程分析/测试方案-2026-05-20-15-54-46.md`
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 确认 `DICOM 切片位置` 为白底外置控件,并位于二维影像右侧。
|
||||
- 确认 `Base DICOM` / `Overlay Label Map` 不再作为深色大块控件压在影像内部。
|
||||
- 确认 `VoxelizationMappingView` 支持 zoom/pan 状态和位置重置。
|
||||
- 确认 `FusionThreeView` 支持视图复位 token。
|
||||
- 确认三维拉伸按钮仅在 XYZ 旋转均为 90 度倍数时启用。
|
||||
- 确认主布局右侧视图宽度提升,三列底部更接近对齐。
|
||||
|
||||
## 构建检查
|
||||
|
||||
- 在 `WebSite/` 执行 `npm run lint`。
|
||||
- 在 `WebSite/` 执行 `npm run build`。
|
||||
|
||||
## 关键业务场景验证
|
||||
|
||||
- 右侧二维影像可滚轮缩放。
|
||||
- 右侧二维影像可鼠标拖拽平移。
|
||||
- 点击“位置重置”后二维影像恢复居中、1 倍缩放。
|
||||
- 点击三维融合视图复位按钮后相机恢复默认视角。
|
||||
- 旋转角度不是 90 度倍数时,拉伸按钮不可用。
|
||||
- 旋转角度满足 90 度倍数时,可触发 X/Y/Z 方向等比例拉伸并更新模型 scale。
|
||||
|
||||
## 医学影像数据相关边界验证
|
||||
|
||||
- 不修改 DICOM/STL 原始数据。
|
||||
- 不改变切片映射算法和导出接口。
|
||||
- 二维缩放和平移只影响浏览显示,不改变 Label Map 坐标。
|
||||
- 手动拉伸属于位姿参数调整,保存/导出沿用当前位姿。
|
||||
|
||||
## 部署验证
|
||||
|
||||
- 验证 `http://127.0.0.1:4000/api/health`。
|
||||
- 验证 `http://127.0.0.1:4000/` 返回 200。
|
||||
|
||||
## Git/Gitea 备份验证
|
||||
|
||||
- commit message 包含 `2026-05-20-15-54-46`。
|
||||
- 推送 Gitea 成功后记录 commit。
|
||||
- 确认未暂存历史删除状态、软著材料和运行态文件。
|
||||
|
||||
## 风险与回归关注点
|
||||
|
||||
- 右侧控件白底化后要保持与深色影像区边界清晰。
|
||||
- CSS transform 顺序需要同时覆盖 Base DICOM 与 Overlay,避免错位。
|
||||
- Three.js 复位不能打断现有 OrbitControls。
|
||||
- 当前实现为等比例 `scale` 调整,不提供非等比轴向拉伸。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- `npm run lint`:通过,TypeScript 无报错。
|
||||
- `npm run build`:通过,Vite 完成生产构建;仅保留当前项目已有的大 chunk 体积提示。
|
||||
- 静态确认:右侧 `DICOM 切片位置` 已改为白底外置卡片,`Overlay Label Map` 状态与构件列表已位于影像外部。
|
||||
- 静态确认:`VoxelizationMappingView` 已加入滚轮缩放、鼠标拖拽平移和 `位置重置`。
|
||||
- 静态确认:`FusionThreeView` 已加入 `位置重置`,三维模型默认 Z 偏移已取消。
|
||||
- 静态确认:主布局改为左窄、中等、右宽的三列比例,并用 `flex-1` 包裹三维/二维视图以改善底部对齐。
|
||||
- 静态确认:已加入 X/Y/Z 等比例自动拉伸按钮,旋转 X/Y/Z 均为 90° 整数倍且数据加载完成时可用。
|
||||
- 部署验证:已重建 `tmux` 会话 `revoxelseg-dicom`,执行 `npm run serve -- --host 0.0.0.0 --port 4000`。
|
||||
- `curl -fsS http://127.0.0.1:4000/api/health`:通过,返回 `{"ok":true,"service":"revoxelseg-dicom",...}`。
|
||||
- `curl -I -fsS http://127.0.0.1:4000/`:通过,返回 `HTTP/1.1 200 OK`。
|
||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -1315,3 +1315,21 @@ C. 解决问题方案
|
||||
D. 后续如何避免问题
|
||||
|
||||
医学影像主画布应优先保持无遮挡,状态、统计、导航控件默认放在画布外侧。若必须悬浮在影像上,只能使用小尺寸状态标识,并在提交前检查是否遮挡解剖结构或分割边界。
|
||||
|
||||
## 2026-05-20-15-54-46 视图交互状态与位姿参数要分层处理
|
||||
|
||||
A. 具体问题
|
||||
|
||||
用户要求右侧二维影像支持滚轮缩放、拖拽移动、位置重置,同时三维融合视图也需要视角复位,并增加按方向的三维等比例拉伸。如果把这些交互都写进位姿数据,容易把临时浏览状态误保存进项目结果。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
二维缩放/平移、三维相机复位属于查看视口状态;模型平移、旋转、缩放属于会影响分割映射和导出的位姿参数。两类状态此前都在可视化层发生,如果不区分,很容易导致“只是看大一点”变成“改变了结果”。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
二维映射视图新增独立 `mappingViewport`,只通过 CSS transform 同步作用于 Base DICOM 与 Overlay Canvas,不写入保存结果;三维融合视图用内部 `rootPose` 复位浏览相机,也不改变 `modelPose`。模型等比例拉伸则明确更新 `modelPose.scale`,并限制在旋转 X/Y/Z 均为 90° 整数倍时才可触发。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
新增影像查看交互时必须先判断它属于“浏览视口”还是“结果位姿”。浏览视口状态默认不进入保存和导出;只有会改变模型与 DICOM 空间关系的参数才进入 `modelPose`、保存快照和导出数据。
|
||||
|
||||
64
工程分析/需求分析-2026-05-20-15-54-46.md
Normal file
64
工程分析/需求分析-2026-05-20-15-54-46.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# 需求分析:逆向工作区视图比例、白底控件、平移缩放与三维拉伸
|
||||
|
||||
开始时间:`2026-05-20-15-54-46`
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户要求继续优化逆向工作区:
|
||||
|
||||
1. “DICOM 切片位置”展示效果要类似“DICOM 切片范围”,白底、格式统一,不放入 DICOM 图像中,放到图片右侧;`Overlay Label Map` 也不要放在图片中。
|
||||
2. 左侧“模型显示/影像与模型融合视角”略微变小、变窄,为右侧视图留出更多空间。
|
||||
3. 右侧 DICOM 影像支持鼠标滚轮放大缩小、鼠标拖拽移动;“逆向分割映射视图”增加位置重置按钮;“影像与模型融合视角”右侧也增加位置重置按钮。
|
||||
4. 逆向工作区底部三列不平行,需要拉长“逆向分割映射视图”和“可视化工具栏”,让底部对齐。
|
||||
5. “影像与模型融合视角”右侧增加自动拉伸按钮;仅当旋转 XYZ 都为 0 或 90 的整数倍时可用;按特定方向对模型做三维等比例拉伸。当前初始状态若存在自动拉伸导致 DICOM 与模型顶/底不在同一平面,后续以手动选择拉伸方向为准,不再默认自动拉伸。
|
||||
|
||||
## 业务目标
|
||||
|
||||
- 让右侧二维映射视图的控件与中部工具栏控件风格一致,避免深色大面板压缩或遮挡影像。
|
||||
- 增强二维映射视图审查能力,支持缩放、拖拽和平移复位。
|
||||
- 增强三维融合视图审查能力,提供视角复位和受约束的模型等比例拉伸操作。
|
||||
- 优化三列布局比例和底部对齐,使右侧影像审查区更宽、更稳。
|
||||
|
||||
## 输入与输出
|
||||
|
||||
输入:
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/index.css`
|
||||
- 复用逆向视图的 `WebSite/src/components/ProjectLibrary.tsx`
|
||||
|
||||
输出:
|
||||
|
||||
- 右侧“DICOM 切片位置”改为白底控制卡片,放在影像右侧外部。
|
||||
- `Base DICOM` / `Overlay Label Map` 信息移出影像内部,以独立白底图层状态展示。
|
||||
- 二维映射视图支持滚轮缩放、拖拽平移、位置复位。
|
||||
- 三维融合视图支持位置复位。
|
||||
- 模型位姿增加手动三维等比例拉伸控制,旋转角度满足 90 度倍数时可用。
|
||||
- 左中右布局比例调整,右侧视图获得更多宽度,底部保持对齐。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- 逆向工作区三列主布局。
|
||||
- `FusionThreeView` 三维场景交互与工具按钮。
|
||||
- `VoxelizationMappingView` 二维映射视图交互、控件样式和白底布局。
|
||||
- 项目库中复用的两类视图会同步获得复位/缩放等能力。
|
||||
|
||||
## 关键约束
|
||||
|
||||
- 不改变 DICOM/STL 原始数据和导出算法。
|
||||
- 不默认自动拉伸模型,避免再次引入初始错位;自动拉伸由用户手动触发。
|
||||
- 三维拉伸只在 XYZ 旋转均为 90 度整数倍时启用。
|
||||
- 二维影像缩放和平移只影响浏览视口,不改变切片索引、分割映射和导出结果。
|
||||
- 不提交历史删除状态、软著文档和无关运行态文件。
|
||||
|
||||
## 风险点
|
||||
|
||||
- Three.js 场景复位需要在组件内部暴露状态触发,避免影响原有 OrbitControls。
|
||||
- 二维 Canvas 缩放和平移需要同时作用于 Base DICOM 和 Overlay Label Map,保持叠加严格同步。
|
||||
- 三维模型拉伸方向若与医学物理坐标不完全一致,需要在 UI 上做成“手动校正”而非默认自动修正。
|
||||
- 调整三列比例可能影响较小视口下的滚动和可用宽度。
|
||||
|
||||
## 默认假设
|
||||
|
||||
- “模型显示”指左侧“影像与模型融合视角”的三维模型/融合视图区域。
|
||||
- “自动拉伸”本轮实现为用户手动点击的等比例缩放操作,按 X/Y/Z 方向选择基于 DICOM 体范围估算缩放,不在初始加载时自动执行。
|
||||
Reference in New Issue
Block a user