|
|
@@ -0,0 +1,425 @@
|
|
|
+import React, { useEffect, useCallback, useRef } from 'react';
|
|
|
+import { Layout, Button, Typography, Select, message, Spin } from 'antd';
|
|
|
+import { ArrowLeftOutlined } from '@ant-design/icons';
|
|
|
+import { useDispatch } from 'react-redux';
|
|
|
+import { useAppSelector } from '@/states/store';
|
|
|
+import { switchToAdvancedProcessingPanel } from '../../../states/panelSwitchSliceForView';
|
|
|
+import {
|
|
|
+ loadImageProcessingParams,
|
|
|
+ saveProcessingParams,
|
|
|
+ updateParameter,
|
|
|
+ applyPreset,
|
|
|
+ resetToPreset,
|
|
|
+ setAlgorithm,
|
|
|
+ setLUT,
|
|
|
+ selectParameters,
|
|
|
+ selectSelectedStyle,
|
|
|
+ selectSelectedAlgorithm,
|
|
|
+ selectSelectedLUT,
|
|
|
+ selectIsLoading,
|
|
|
+ selectIsSaving,
|
|
|
+ selectIsInitialLoad,
|
|
|
+ selectError,
|
|
|
+ selectCurrentImageId,
|
|
|
+} from '../../../states/view/sliderAdjustmentPanelSlice';
|
|
|
+import { PARAMETER_RANGES, ALGORITHM_OPTIONS } from '../../../domain/processingPresets';
|
|
|
+import { LUT_OPTIONS } from '../../../domain/lutConfig';
|
|
|
+import type { ProcessingStyle, LUTType, FullProcessingParams } from '../../../types/imageProcessing';
|
|
|
+import ParameterSlider from './ParameterSlider';
|
|
|
+
|
|
|
+const { Header, Content } = Layout;
|
|
|
+const { Title } = Typography;
|
|
|
+const { Option } = Select;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 滑动参数调节面板
|
|
|
+ * 三级面板,用于调整图像处理参数
|
|
|
+ */
|
|
|
+const SliderAdjustmentPanel = () => {
|
|
|
+ const dispatch = useDispatch();
|
|
|
+
|
|
|
+ // 从 Redux 获取状态
|
|
|
+ const parameters = useAppSelector(selectParameters);
|
|
|
+ const selectedStyle = useAppSelector(selectSelectedStyle);
|
|
|
+ const selectedAlgorithm = useAppSelector(selectSelectedAlgorithm);
|
|
|
+ const selectedLUT = useAppSelector(selectSelectedLUT);
|
|
|
+ const isLoading = useAppSelector(selectIsLoading);
|
|
|
+ const isSaving = useAppSelector(selectIsSaving);
|
|
|
+ const isInitialLoad = useAppSelector(selectIsInitialLoad);
|
|
|
+ const error = useAppSelector(selectError);
|
|
|
+ const currentImageId = useAppSelector(selectCurrentImageId);
|
|
|
+
|
|
|
+ // 防抖定时器
|
|
|
+ const saveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
+
|
|
|
+ // 组件挂载时加载参数
|
|
|
+ useEffect(() => {
|
|
|
+ // TODO: 从当前选中的图像获取 sopInstanceUid
|
|
|
+ // 这里需要从 ViewerContainer 或其他地方获取当前图像ID
|
|
|
+ const sopInstanceUid = ''; // 临时占位
|
|
|
+
|
|
|
+ if (sopInstanceUid) {
|
|
|
+ dispatch(loadImageProcessingParams(sopInstanceUid) as any);
|
|
|
+ }
|
|
|
+ }, [dispatch]);
|
|
|
+
|
|
|
+ // 监听错误
|
|
|
+ useEffect(() => {
|
|
|
+ if (error) {
|
|
|
+ message.error(error);
|
|
|
+ }
|
|
|
+ }, [error]);
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 返回上级面板
|
|
|
+ */
|
|
|
+ const handleReturn = () => {
|
|
|
+ dispatch(switchToAdvancedProcessingPanel());
|
|
|
+ };
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 防抖保存参数
|
|
|
+ */
|
|
|
+ const debouncedSave = useCallback(
|
|
|
+ (params: FullProcessingParams) => {
|
|
|
+ // 如果是初始加载,不触发保存
|
|
|
+ if (isInitialLoad) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 清除之前的定时器
|
|
|
+ if (saveTimerRef.current) {
|
|
|
+ clearTimeout(saveTimerRef.current);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 设置新的定时器
|
|
|
+ saveTimerRef.current = setTimeout(() => {
|
|
|
+ if (currentImageId) {
|
|
|
+ dispatch(
|
|
|
+ saveProcessingParams({
|
|
|
+ sopInstanceUid: currentImageId,
|
|
|
+ params: {
|
|
|
+ contrast: params.contrast,
|
|
|
+ detail: params.detail,
|
|
|
+ latitude: params.latitude,
|
|
|
+ noise: params.noise,
|
|
|
+ },
|
|
|
+ }) as any
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }, 500); // 500ms 防抖延迟
|
|
|
+ },
|
|
|
+ [dispatch, currentImageId, isInitialLoad]
|
|
|
+ );
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 参数变化处理
|
|
|
+ */
|
|
|
+ const handleParameterChange = (name: keyof FullProcessingParams, value: number) => {
|
|
|
+ dispatch(updateParameter({ name, value }));
|
|
|
+ // 触发防抖保存
|
|
|
+ const newParams = { ...parameters, [name]: value };
|
|
|
+ debouncedSave(newParams);
|
|
|
+ };
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 风格变化处理
|
|
|
+ */
|
|
|
+ const handleStyleChange = (value: ProcessingStyle) => {
|
|
|
+ dispatch(applyPreset(value));
|
|
|
+ // 风格切换后立即保存
|
|
|
+ if (currentImageId) {
|
|
|
+ const preset = parameters; // 应用风格后的参数会在 Redux 中更新
|
|
|
+ setTimeout(() => {
|
|
|
+ dispatch(
|
|
|
+ saveProcessingParams({
|
|
|
+ sopInstanceUid: currentImageId,
|
|
|
+ params: {
|
|
|
+ contrast: preset.contrast,
|
|
|
+ detail: preset.detail,
|
|
|
+ latitude: preset.latitude,
|
|
|
+ noise: preset.noise,
|
|
|
+ },
|
|
|
+ }) as any
|
|
|
+ );
|
|
|
+ }, 100);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 算法变化处理
|
|
|
+ */
|
|
|
+ const handleAlgorithmChange = (value: string) => {
|
|
|
+ dispatch(setAlgorithm(value));
|
|
|
+ };
|
|
|
+
|
|
|
+ /**
|
|
|
+ * LUT 变化处理
|
|
|
+ */
|
|
|
+ const handleLUTChange = (value: LUTType) => {
|
|
|
+ dispatch(setLUT(value));
|
|
|
+ };
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 重置参数
|
|
|
+ */
|
|
|
+ const handleReset = () => {
|
|
|
+ dispatch(resetToPreset());
|
|
|
+ message.success('已重置为当前风格的默认参数');
|
|
|
+
|
|
|
+ // 重置后保存
|
|
|
+ if (currentImageId) {
|
|
|
+ setTimeout(() => {
|
|
|
+ dispatch(
|
|
|
+ saveProcessingParams({
|
|
|
+ sopInstanceUid: currentImageId,
|
|
|
+ params: {
|
|
|
+ contrast: parameters.contrast,
|
|
|
+ detail: parameters.detail,
|
|
|
+ latitude: parameters.latitude,
|
|
|
+ noise: parameters.noise,
|
|
|
+ },
|
|
|
+ }) as any
|
|
|
+ );
|
|
|
+ }, 100);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 手动保存参数
|
|
|
+ */
|
|
|
+ const handleSave = () => {
|
|
|
+ if (currentImageId) {
|
|
|
+ dispatch(
|
|
|
+ saveProcessingParams({
|
|
|
+ sopInstanceUid: currentImageId,
|
|
|
+ params: {
|
|
|
+ contrast: parameters.contrast,
|
|
|
+ detail: parameters.detail,
|
|
|
+ latitude: parameters.latitude,
|
|
|
+ noise: parameters.noise,
|
|
|
+ },
|
|
|
+ }) as any
|
|
|
+ ).then(() => {
|
|
|
+ message.success('参数保存成功');
|
|
|
+ });
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 清理定时器
|
|
|
+ useEffect(() => {
|
|
|
+ return () => {
|
|
|
+ if (saveTimerRef.current) {
|
|
|
+ clearTimeout(saveTimerRef.current);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <Layout className="h-full">
|
|
|
+ {/* 顶部导航栏 */}
|
|
|
+ <Header
|
|
|
+ style={{
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ padding: '0 16px',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Button
|
|
|
+ type="text"
|
|
|
+ icon={<ArrowLeftOutlined />}
|
|
|
+ onClick={handleReturn}
|
|
|
+ />
|
|
|
+ <Title level={5} style={{ margin: 0, lineHeight: '48px' }}>
|
|
|
+ 滑动参数调节
|
|
|
+ </Title>
|
|
|
+ </Header>
|
|
|
+
|
|
|
+ {/* 主体内容 */}
|
|
|
+ <Content
|
|
|
+ style={{
|
|
|
+ padding: '16px',
|
|
|
+ maxHeight: '100%',
|
|
|
+ overflowY: 'auto',
|
|
|
+ position: 'relative',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {isLoading && (
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: '50%',
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translate(-50%, -50%)',
|
|
|
+ zIndex: 1000,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Spin size="large" tip="加载参数中..." />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <div style={{ opacity: isLoading ? 0.5 : 1 }}>
|
|
|
+ {/* 增益滑块 */}
|
|
|
+ <ParameterSlider
|
|
|
+ label="增益"
|
|
|
+ value={parameters.contrast}
|
|
|
+ min={PARAMETER_RANGES.gain.min}
|
|
|
+ max={PARAMETER_RANGES.gain.max}
|
|
|
+ step={PARAMETER_RANGES.gain.step}
|
|
|
+ range={`${PARAMETER_RANGES.gain.min}~${PARAMETER_RANGES.gain.max}`}
|
|
|
+ onChange={(value) => handleParameterChange('contrast', value)}
|
|
|
+ disabled={isLoading}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 细节滑块 */}
|
|
|
+ <ParameterSlider
|
|
|
+ label="细节"
|
|
|
+ value={parameters.detail}
|
|
|
+ min={PARAMETER_RANGES.detail.min}
|
|
|
+ max={PARAMETER_RANGES.detail.max}
|
|
|
+ step={PARAMETER_RANGES.detail.step}
|
|
|
+ range={`${PARAMETER_RANGES.detail.min}~${PARAMETER_RANGES.detail.max}`}
|
|
|
+ onChange={(value) => handleParameterChange('detail', value)}
|
|
|
+ disabled={isLoading}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 动态范围滑块 */}
|
|
|
+ <ParameterSlider
|
|
|
+ label="动态范围"
|
|
|
+ value={parameters.latitude}
|
|
|
+ min={PARAMETER_RANGES.latitude.min}
|
|
|
+ max={PARAMETER_RANGES.latitude.max}
|
|
|
+ step={PARAMETER_RANGES.latitude.step}
|
|
|
+ range={`${PARAMETER_RANGES.latitude.min}~${PARAMETER_RANGES.latitude.max}`}
|
|
|
+ onChange={(value) => handleParameterChange('latitude', value)}
|
|
|
+ disabled={isLoading}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 噪声模式滑块 */}
|
|
|
+ <ParameterSlider
|
|
|
+ label="噪声模式"
|
|
|
+ value={parameters.noise}
|
|
|
+ min={PARAMETER_RANGES.noise.min}
|
|
|
+ max={PARAMETER_RANGES.noise.max}
|
|
|
+ step={PARAMETER_RANGES.noise.step}
|
|
|
+ range={`${PARAMETER_RANGES.noise.min}~${PARAMETER_RANGES.noise.max}`}
|
|
|
+ onChange={(value) => handleParameterChange('noise', value)}
|
|
|
+ disabled={isLoading}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 对比度滑块(前端维护) */}
|
|
|
+ <ParameterSlider
|
|
|
+ label="对比度"
|
|
|
+ value={parameters.brightness}
|
|
|
+ min={PARAMETER_RANGES.brightness.min}
|
|
|
+ max={PARAMETER_RANGES.brightness.max}
|
|
|
+ step={PARAMETER_RANGES.brightness.step}
|
|
|
+ range={`${PARAMETER_RANGES.brightness.min}~${PARAMETER_RANGES.brightness.max}`}
|
|
|
+ onChange={(value) => handleParameterChange('brightness', value)}
|
|
|
+ disabled={isLoading}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 亮度滑块(前端维护) */}
|
|
|
+ <ParameterSlider
|
|
|
+ label="亮度"
|
|
|
+ value={parameters.sharpness}
|
|
|
+ min={PARAMETER_RANGES.sharpness.min}
|
|
|
+ max={PARAMETER_RANGES.sharpness.max}
|
|
|
+ step={PARAMETER_RANGES.sharpness.step}
|
|
|
+ range={`${PARAMETER_RANGES.sharpness.min}~${PARAMETER_RANGES.sharpness.max}`}
|
|
|
+ onChange={(value) => handleParameterChange('sharpness', value)}
|
|
|
+ disabled={isLoading}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 算法选择 */}
|
|
|
+ <div style={{ marginBottom: '16px' }}>
|
|
|
+ <Typography.Text strong style={{ display: 'block', marginBottom: '8px' }}>
|
|
|
+ 算法
|
|
|
+ </Typography.Text>
|
|
|
+ <Select
|
|
|
+ value={selectedAlgorithm}
|
|
|
+ onChange={handleAlgorithmChange}
|
|
|
+ style={{ width: '100%' }}
|
|
|
+ disabled={isLoading}
|
|
|
+ >
|
|
|
+ {ALGORITHM_OPTIONS.map((algo) => (
|
|
|
+ <Option key={algo} value={algo}>
|
|
|
+ {algo}
|
|
|
+ </Option>
|
|
|
+ ))}
|
|
|
+ </Select>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* LUT 选择 */}
|
|
|
+ <div style={{ marginBottom: '16px' }}>
|
|
|
+ <Typography.Text strong style={{ display: 'block', marginBottom: '8px' }}>
|
|
|
+ LUT
|
|
|
+ </Typography.Text>
|
|
|
+ <Select
|
|
|
+ value={selectedLUT}
|
|
|
+ onChange={handleLUTChange}
|
|
|
+ style={{ width: '100%' }}
|
|
|
+ disabled={isLoading}
|
|
|
+ >
|
|
|
+ {LUT_OPTIONS.map((lut) => (
|
|
|
+ <Option key={lut} value={lut}>
|
|
|
+ {lut}
|
|
|
+ </Option>
|
|
|
+ ))}
|
|
|
+ </Select>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 风格选择 */}
|
|
|
+ <div style={{ marginBottom: '16px' }}>
|
|
|
+ <Typography.Text strong style={{ display: 'block', marginBottom: '8px' }}>
|
|
|
+ 风格
|
|
|
+ </Typography.Text>
|
|
|
+ <Select
|
|
|
+ value={selectedStyle}
|
|
|
+ onChange={handleStyleChange}
|
|
|
+ style={{ width: '100%' }}
|
|
|
+ disabled={isLoading}
|
|
|
+ >
|
|
|
+ <Option value="均衡">均衡</Option>
|
|
|
+ <Option value="高对比">高对比</Option>
|
|
|
+ <Option value="锐组织">锐组织</Option>
|
|
|
+ <Option value="骨骼">骨骼</Option>
|
|
|
+ </Select>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 底部按钮 */}
|
|
|
+ <div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
|
|
|
+ <Button
|
|
|
+ onClick={handleReset}
|
|
|
+ disabled={isLoading || isSaving}
|
|
|
+ style={{
|
|
|
+ flex: 1,
|
|
|
+ height: '40px',
|
|
|
+ fontSize: '14px',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ 重置参数
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ type="primary"
|
|
|
+ onClick={handleSave}
|
|
|
+ loading={isSaving}
|
|
|
+ disabled={isLoading}
|
|
|
+ style={{
|
|
|
+ flex: 1,
|
|
|
+ height: '40px',
|
|
|
+ fontSize: '14px',
|
|
|
+ backgroundColor: '#13c2c2',
|
|
|
+ borderColor: '#13c2c2',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ 保存参数
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </Content>
|
|
|
+ </Layout>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+export default SliderAdjustmentPanel;
|