文件预览

TranscriptionSettings.tsx

查看 Local Audio2SRT 技能包中的文件内容。

文件内容

references/src/components/TranscriptionSettings.tsx

import React, { useState, useRef, useCallback } from 'react';
import {
  Box,
  Typography,
  TextField,
  Select,
  MenuItem,
  FormControl,
  InputLabel,
  Slider,
  Collapse,
  IconButton,
  Tooltip,
  Divider,
  Chip,
} from '@mui/material';
import SettingsIcon from '@mui/icons-material/Tune';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import CloudDoneIcon from '@mui/icons-material/CloudDone';
import CloudOffIcon from '@mui/icons-material/CloudOff';
import { useQueueStore } from '../store/queueStore';
import { LANGUAGES } from '../utils/helpers';
import type { AppSettings } from '../types';

/**
 * 转录参数设置面板 — 可折叠,包含语言、模型、分段等设置。
 */
const TranscriptionSettings: React.FC = () => {
  const [expanded, setExpanded] = useState(true);
  const settings = useQueueStore((s) => s.settings);
  const updateSettings = useQueueStore((s) => s.updateSettings);
  const backendConnected = useQueueStore((s) => s.backendConnected);
  const checkBackend = useQueueStore((s) => s.checkBackend);
  const defaultWhisperModelName = useQueueStore((s) => s.defaultWhisperModelName);
  const defaultTranslateModelName = useQueueStore((s) => s.defaultTranslateModelName);

  /** 隐藏的目录选择 input(输出目录) */
  const outputDirInputRef = useRef<HTMLInputElement>(null);
  /** 隐藏的目录选择 input(Whisper 模型路径) */
  const modelDirInputRef = useRef<HTMLInputElement>(null);
  /** 隐藏的目录选择 input(翻译模型路径) */
  const translateModelDirInputRef = useRef<HTMLInputElement>(null);

  const handleChange = (field: keyof AppSettings, value: string | number) => {
    updateSettings({ [field]: value } as Partial<AppSettings>);
  };

  /** 处理输出目录选择 */
  const handleOutputDirPick = useCallback(async () => {
    if ('showDirectoryPicker' in window) {
      try {
        const dirHandle = await (window as any).showDirectoryPicker({ mode: 'read' });
        if (dirHandle) {
          const currentDir = settings.outputDir;
          const homePrefix = currentDir.startsWith('~/') ? '~/' : '';
          const newPath = homePrefix ? `~/${dirHandle.name}` : dirHandle.name;
          updateSettings({ outputDir: newPath });
          return;
        }
      } catch (e: any) {
        if (e?.name === 'AbortError') return;
      }
    }
    outputDirInputRef.current?.click();
  }, [settings.outputDir, updateSettings]);

  const handleOutputDirInputChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const files = e.target.files;
      if (files && files.length > 0) {
        const relPath = files[0].webkitRelativePath;
        const dirName = relPath.split('/')[0];
        const currentDir = settings.outputDir;
        const homePrefix = currentDir.startsWith('~/') ? '~/' : '';
        updateSettings({ outputDir: homePrefix ? `~/${dirName}` : dirName });
      }
      e.target.value = '';
    },
    [settings.outputDir, updateSettings],
  );

  const handleModelDirPick = useCallback(async () => {
    if ('showDirectoryPicker' in window) {
      try {
        const dirHandle = await (window as any).showDirectoryPicker({ mode: 'read' });
        if (dirHandle) {
          updateSettings({ modelPath: dirHandle.name });
          return;
        }
      } catch (e: any) {
        if (e?.name === 'AbortError') return;
      }
    }
    modelDirInputRef.current?.click();
  }, [updateSettings]);

  const handleModelDirInputChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const files = e.target.files;
      if (files && files.length > 0) {
        const relPath = files[0].webkitRelativePath;
        const dirName = relPath.split('/')[0];
        updateSettings({ modelPath: dirName });
      }
      e.target.value = '';
    },
    [updateSettings],
  );

  /** 翻译模型目录选择 */
  const handleTranslateModelDirPick = useCallback(async () => {
    if ('showDirectoryPicker' in window) {
      try {
        const dirHandle = await (window as any).showDirectoryPicker({ mode: 'read' });
        if (dirHandle) {
          updateSettings({ translateModel: dirHandle.name });
          return;
        }
      } catch (e: any) {
        if (e?.name === 'AbortError') return;
      }
    }
    translateModelDirInputRef.current?.click();
  }, [updateSettings]);

  const handleTranslateModelDirInputChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const files = e.target.files;
      if (files && files.length > 0) {
        const relPath = files[0].webkitRelativePath;
        const dirName = relPath.split('/')[0];
        updateSettings({ translateModel: dirName });
      }
      e.target.value = '';
    },
    [updateSettings],
  );

  return (
    <Box
      sx={{
        bgcolor: 'rgba(30, 34, 54, 0.6)',
        borderRadius: 2,
        border: '1px solid rgba(124, 77, 255, 0.1)',
        overflow: 'hidden',
      }}
    >
      {/* 折叠头部 */}
      <div
        className="flex items-center justify-between px-3 py-2 cursor-pointer"
        onClick={() => setExpanded(!expanded)}
        role="button"
        tabIndex={0}
        onKeyDown={(e) => {
          if (e.key === 'Enter' || e.key === ' ') setExpanded(!expanded);
        }}
      >
        <div className="flex items-center gap-2">
          <SettingsIcon sx={{ fontSize: 20, color: '#7C4DFF' }} />
          <Typography variant="body2" sx={{ color: '#B388FF', fontWeight: 600 }}>
            转录参数设置
          </Typography>
        </div>
        <div className="flex items-center gap-2">
          {/* 后端连接状态 */}
          <Chip
            icon={backendConnected ? <CloudDoneIcon /> : <CloudOffIcon />}
            label={backendConnected ? '已连接' : '未连接'}
            size="small"
            onClick={(e) => {
              e.stopPropagation();
              checkBackend();
            }}
            sx={{
              height: 22,
              fontSize: '0.7rem',
              bgcolor: backendConnected ? 'rgba(76,175,80,0.15)' : 'rgba(244,67,54,0.15)',
              color: backendConnected ? '#4CAF50' : '#F44336',
              border: `1px solid ${backendConnected ? 'rgba(76,175,80,0.3)' : 'rgba(244,67,54,0.3)'}`,
              '& .MuiChip-icon': { color: backendConnected ? '#4CAF50' : '#F44336', fontSize: 14 },
            }}
          />
          <IconButton size="small" sx={{ color: '#5A6180' }}>
            {expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
          </IconButton>
        </div>
      </div>

      {/* 折叠内容 */}
      <Collapse in={expanded}>
        <Divider sx={{ borderColor: 'rgba(124, 77, 255, 0.1)' }} />
        <Box sx={{ p: 2.5, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
          {/* 后端 API 地址 */}
          <TextField
            size="small"
            label="后端 API 地址"
            value={settings.apiBaseUrl}
            onChange={(e) => handleChange('apiBaseUrl', e.target.value)}
            sx={{
              '& .MuiOutlinedInput-root': {
                color: '#E8EAF0',
                bgcolor: 'rgba(42, 47, 74, 0.6)',
                borderRadius: 1.5,
                fontSize: '0.85rem',
                '& fieldset': { borderColor: 'rgba(124, 77, 255, 0.2)' },
                '&:hover fieldset': { borderColor: 'rgba(124, 77, 255, 0.4)' },
                '&.Mui-focused fieldset': { borderColor: '#7C4DFF' },
              },
              '& .MuiInputLabel-root': {
                color: '#9EA3B8',
                '&.Mui-focused': { color: '#7C4DFF' },
              },
            }}
          />

          {/* 第一行:语言 + 模型路径 */}
          <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
            {/* 语言选择 */}
            <FormControl size="small" fullWidth>
              <InputLabel sx={{ color: '#9EA3B8' }}>默认语言</InputLabel>
              <Select
                value={settings.defaultLanguage}
                label="默认语言"
                onChange={(e) => handleChange('defaultLanguage', e.target.value)}
                sx={{
                  color: '#E8EAF0',
                  bgcolor: 'rgba(42, 47, 74, 0.6)',
                  borderRadius: 1.5,
                  '& .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(124, 77, 255, 0.2)' },
                  '&:hover .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(124, 77, 255, 0.4)' },
                  '&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: '#7C4DFF' },
                  '& .MuiSelect-icon': { color: '#9EA3B8' },
                }}
              >
                {LANGUAGES.map((lang) => (
                  <MenuItem key={lang.code} value={lang.code}>
                    <span className="flex items-center gap-2">
                      <span className="font-semibold">{lang.label}</span>
                      {lang.code !== 'auto' && (
                        <span className="text-gray-400 text-xs">({lang.code})</span>
                      )}
                    </span>
                  </MenuItem>
                ))}
              </Select>
            </FormControl>

            {/* 模型路径 */}
            <TextField
              size="small"
              label="Whisper 模型路径"
              placeholder={defaultWhisperModelName ? `默认: ${defaultWhisperModelName}` : '留空使用服务器默认'}
              value={settings.modelPath}
              onChange={(e) => handleChange('modelPath', e.target.value)}
              InputProps={{
                endAdornment: (
                  <Tooltip title="浏览模型目录">
                    <IconButton size="small" sx={{ color: '#7C4DFF' }} onClick={handleModelDirPick}>
                      <FolderOpenIcon fontSize="small" />
                    </IconButton>
                  </Tooltip>
                ),
              }}
              sx={{
                '& .MuiOutlinedInput-root': {
                  color: '#E8EAF0',
                  bgcolor: 'rgba(42, 47, 74, 0.6)',
                  borderRadius: 1.5,
                  '& fieldset': { borderColor: 'rgba(124, 77, 255, 0.2)' },
                  '&:hover fieldset': { borderColor: 'rgba(124, 77, 255, 0.4)' },
                  '&.Mui-focused fieldset': { borderColor: '#7C4DFF' },
                },
                '& .MuiInputLabel-root': {
                  color: '#9EA3B8',
                  '&.Mui-focused': { color: '#7C4DFF' },
                },
              }}
            />
          </div>

          {/* 第二行:Chunk 秒数 + 重叠秒数 */}
          <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
            <Box>
              <div className="flex items-center justify-between mb-1">
                <Typography variant="caption" sx={{ color: '#9EA3B8' }}>分段秒数</Typography>
                <TextField
                  size="small"
                  type="number"
                  value={settings.defaultChunkSec}
                  onChange={(e) => {
                    const val = parseInt(e.target.value, 10);
                    if (!isNaN(val) && val >= 60 && val <= 1800) handleChange('defaultChunkSec', val);
                  }}
                  inputProps={{ min: 60, max: 1800, step: 30, style: { textAlign: 'center', padding: '2px 6px' } }}
                  sx={{
                    width: 80,
                    '& .MuiOutlinedInput-root': {
                      color: '#E8EAF0', bgcolor: 'rgba(42, 47, 74, 0.6)', fontSize: '0.8rem',
                      '& fieldset': { borderColor: 'rgba(124, 77, 255, 0.2)' },
                    },
                  }}
                />
              </div>
              <Slider
                value={settings.defaultChunkSec}
                onChange={(_, val) => handleChange('defaultChunkSec', val as number)}
                min={60} max={1800} step={30}
                valueLabelDisplay="auto" valueLabelFormat={(v) => `${v}s`}
                sx={{
                  color: '#7C4DFF',
                  '& .MuiSlider-thumb': { '&:hover': { boxShadow: '0 0 0 8px rgba(124, 77, 255, 0.16)' } },
                  '& .MuiSlider-valueLabel': { bgcolor: '#7C4DFF' },
                }}
              />
              <div className="flex justify-between">
                <Typography variant="caption" sx={{ color: '#5A6180' }}>60s</Typography>
                <Typography variant="caption" sx={{ color: '#5A6180' }}>1800s</Typography>
              </div>
            </Box>

            <Box>
              <div className="flex items-center justify-between mb-1">
                <Typography variant="caption" sx={{ color: '#9EA3B8' }}>重叠秒数</Typography>
                <TextField
                  size="small"
                  type="number"
                  value={settings.defaultOverlapSec}
                  onChange={(e) => {
                    const val = parseInt(e.target.value, 10);
                    if (!isNaN(val) && val >= 0 && val <= 120) handleChange('defaultOverlapSec', val);
                  }}
                  inputProps={{ min: 0, max: 120, step: 5, style: { textAlign: 'center', padding: '2px 6px' } }}
                  sx={{
                    width: 80,
                    '& .MuiOutlinedInput-root': {
                      color: '#E8EAF0', bgcolor: 'rgba(42, 47, 74, 0.6)', fontSize: '0.8rem',
                      '& fieldset': { borderColor: 'rgba(124, 77, 255, 0.2)' },
                    },
                  }}
                />
              </div>
              <Slider
                value={settings.defaultOverlapSec}
                onChange={(_, val) => handleChange('defaultOverlapSec', val as number)}
                min={0} max={120} step={5}
                valueLabelDisplay="auto" valueLabelFormat={(v) => `${v}s`}
                sx={{
                  color: '#FF9800',
                  '& .MuiSlider-thumb': { '&:hover': { boxShadow: '0 0 0 8px rgba(255, 152, 0, 0.16)' } },
                  '& .MuiSlider-valueLabel': { bgcolor: '#FF9800' },
                }}
              />
              <div className="flex justify-between">
                <Typography variant="caption" sx={{ color: '#5A6180' }}>0s</Typography>
                <Typography variant="caption" sx={{ color: '#5A6180' }}>120s</Typography>
              </div>
            </Box>
          </div>

          {/* 第三行:输出目录 + 翻译模型 */}
          <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
            <TextField
              size="small"
              label="输出目录"
              value={settings.outputDir}
              onChange={(e) => handleChange('outputDir', e.target.value)}
              InputProps={{
                endAdornment: (
                  <Tooltip title="选择输出目录">
                    <IconButton size="small" sx={{ color: '#7C4DFF' }} onClick={handleOutputDirPick}>
                      <FolderOpenIcon fontSize="small" />
                    </IconButton>
                  </Tooltip>
                ),
              }}
              sx={{
                '& .MuiOutlinedInput-root': {
                  color: '#E8EAF0', bgcolor: 'rgba(42, 47, 74, 0.6)', borderRadius: 1.5,
                  '& fieldset': { borderColor: 'rgba(124, 77, 255, 0.2)' },
                  '&:hover fieldset': { borderColor: 'rgba(124, 77, 255, 0.4)' },
                  '&.Mui-focused fieldset': { borderColor: '#7C4DFF' },
                },
                '& .MuiInputLabel-root': { color: '#9EA3B8', '&.Mui-focused': { color: '#7C4DFF' } },
              }}
            />

            <TextField
              size="small"
              label="翻译模型"
              placeholder={defaultTranslateModelName ? `默认: ${defaultTranslateModelName}` : '留空使用服务器默认'}
              value={settings.translateModel}
              onChange={(e) => handleChange('translateModel', e.target.value)}
              InputProps={{
                endAdornment: (
                  <Tooltip title="浏览翻译模型目录">
                    <IconButton size="small" sx={{ color: '#7C4DFF' }} onClick={handleTranslateModelDirPick}>
                      <FolderOpenIcon fontSize="small" />
                    </IconButton>
                  </Tooltip>
                ),
              }}
              sx={{
                '& .MuiOutlinedInput-root': {
                  color: '#E8EAF0', bgcolor: 'rgba(42, 47, 74, 0.6)', borderRadius: 1.5,
                  '& fieldset': { borderColor: 'rgba(124, 77, 255, 0.2)' },
                  '&:hover fieldset': { borderColor: 'rgba(124, 77, 255, 0.4)' },
                  '&.Mui-focused fieldset': { borderColor: '#7C4DFF' },
                },
                '& .MuiInputLabel-root': { color: '#9EA3B8', '&.Mui-focused': { color: '#7C4DFF' } },
              }}
            />
          </div>
        </Box>
      </Collapse>

      {/* 隐藏的目录选择 input(回退) */}
      <input ref={outputDirInputRef} type="file" style={{ display: 'none' }} {...{ webkitdirectory: '', directory: '' }} onChange={handleOutputDirInputChange} />
      <input ref={modelDirInputRef} type="file" style={{ display: 'none' }} {...{ webkitdirectory: '', directory: '' }} onChange={handleModelDirInputChange} />
      <input ref={translateModelDirInputRef} type="file" style={{ display: 'none' }} {...{ webkitdirectory: '', directory: '' }} onChange={handleTranslateModelDirInputChange} />
    </Box>
  );
};

export default TranscriptionSettings;