- 重构 capture.rs:添加 start_recording/stop_recording 支持真正的开始/停止控制 - 更新 AppState:引入 RecordingHandle 字段管理录音状态 - 重构 commands.rs:start_recording 立即返回,stop_recording 停止并保存文件 - 新增 recognize_file/recognize_last_recording 命令 - 前端 RecordPage:调用真实后端命令,监听 recording-stopped 事件自动识别 - 前端 FileConvertPage:连接 recognize_file 命令,支持导出识别结果 - 前端 SettingsPage:通过 save_config 持久化配置,支持配置加载 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
404 lines
13 KiB
TypeScript
404 lines
13 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
|
||
interface Settings {
|
||
model: string
|
||
language: string
|
||
sampleRate: number
|
||
microphone: string
|
||
theme: string
|
||
autoStart: boolean
|
||
autoCheckUpdate: boolean
|
||
autoCopy: boolean
|
||
historyKeepDays: number
|
||
hotkeyRecord: string
|
||
hotkeyCopy: string
|
||
hotkeyToggle: string
|
||
modelPath?: string
|
||
}
|
||
|
||
interface SettingsPageProps {
|
||
theme: 'light' | 'dark' | 'system'
|
||
onThemeChange: (theme: 'light' | 'dark' | 'system') => void
|
||
}
|
||
|
||
interface TauriAPI {
|
||
invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>
|
||
}
|
||
|
||
declare const window: Window & {
|
||
__TAURI__?: TauriAPI
|
||
}
|
||
|
||
const isTauri = () => typeof window !== 'undefined' && !!window.__TAURI__
|
||
|
||
const defaultSettings: Settings = {
|
||
model: 'sensevoice-small',
|
||
language: 'zh',
|
||
sampleRate: 16000,
|
||
microphone: '默认设备',
|
||
theme: 'dark',
|
||
autoStart: false,
|
||
autoCheckUpdate: true,
|
||
autoCopy: false,
|
||
historyKeepDays: 30,
|
||
hotkeyRecord: 'Ctrl+Shift+R',
|
||
hotkeyCopy: 'Ctrl+Shift+C',
|
||
hotkeyToggle: 'Ctrl+Shift+H'
|
||
}
|
||
|
||
export default function SettingsPage({ theme, onThemeChange }: SettingsPageProps) {
|
||
const [settings, setSettings] = useState<Settings>(defaultSettings)
|
||
const [modified, setModified] = useState(false)
|
||
const [saving, setSaving] = useState(false)
|
||
const [saveMessage, setSaveMessage] = useState<string | null>(null)
|
||
|
||
// 加载保存的配置
|
||
useEffect(() => {
|
||
if (!isTauri()) return
|
||
|
||
const loadConfig = async () => {
|
||
try {
|
||
const config = await window.__TAURI__!.invoke('get_config_cmd') as Record<string, unknown>
|
||
if (config) {
|
||
setSettings(prev => ({
|
||
...prev,
|
||
model: (config.asr as any)?.model || prev.model,
|
||
language: (config.asr as any)?.language || prev.language,
|
||
sampleRate: (config.audio as any)?.sample_rate || prev.sampleRate,
|
||
microphone: (config.audio as any)?.device || prev.microphone,
|
||
modelPath: (config.asr as any)?.model_path || prev.modelPath,
|
||
}))
|
||
}
|
||
} catch (e) {
|
||
console.error('加载配置失败:', e)
|
||
}
|
||
}
|
||
|
||
loadConfig()
|
||
}, [])
|
||
|
||
const handleChange = (key: keyof Settings, value: Settings[typeof key]) => {
|
||
setSettings(prev => ({ ...prev, [key]: value }))
|
||
setModified(true)
|
||
setSaveMessage(null)
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
setSaving(true)
|
||
setSaveMessage(null)
|
||
|
||
try {
|
||
if (isTauri()) {
|
||
// 构建后端期望的配置格式
|
||
const configPayload = {
|
||
asr: {
|
||
model: settings.model,
|
||
language: settings.language,
|
||
model_path: settings.modelPath || null,
|
||
},
|
||
audio: {
|
||
sample_rate: settings.sampleRate,
|
||
channels: 1,
|
||
device: settings.microphone,
|
||
},
|
||
ui: {
|
||
theme: settings.theme,
|
||
auto_start: settings.autoStart,
|
||
auto_check_update: settings.autoCheckUpdate,
|
||
auto_copy: settings.autoCopy,
|
||
history_keep_days: settings.historyKeepDays,
|
||
},
|
||
hotkeys: {
|
||
record: settings.hotkeyRecord,
|
||
copy: settings.hotkeyCopy,
|
||
toggle: settings.hotkeyToggle,
|
||
}
|
||
}
|
||
|
||
await window.__TAURI__!.invoke('save_config', { settings: configPayload })
|
||
setSaveMessage('✓ 设置已保存')
|
||
} else {
|
||
// 开发环境:仅日志
|
||
console.log('保存设置 (开发环境):', settings)
|
||
setSaveMessage('✓ 设置已记录到控制台 (开发环境)')
|
||
}
|
||
setModified(false)
|
||
} catch (e) {
|
||
setSaveMessage(`✗ 保存失败: ${e instanceof Error ? e.message : String(e)}`)
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const handleReset = () => {
|
||
setSettings({ ...defaultSettings })
|
||
setModified(true)
|
||
setSaveMessage(null)
|
||
}
|
||
|
||
const handleSelectModel = async () => {
|
||
try {
|
||
if (!isTauri()) {
|
||
alert('仅在 Tauri 环境中可用')
|
||
return
|
||
}
|
||
const modelPath = await window.__TAURI__!.invoke('select_model_file')
|
||
if (modelPath) {
|
||
const path = modelPath as string
|
||
setSettings(prev => ({ ...prev, modelPath: path }))
|
||
setModified(true)
|
||
}
|
||
} catch (error) {
|
||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||
setSaveMessage(`✗ 选择模型失败: ${errorMessage}`)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="settings-page">
|
||
<h1 className="page-title">设置</h1>
|
||
|
||
{saveMessage && (
|
||
<div className={`save-message ${saveMessage.startsWith('✓') ? 'success' : 'error'}`}>
|
||
{saveMessage}
|
||
</div>
|
||
)}
|
||
|
||
<div className="card">
|
||
{/* 识别模型 */}
|
||
<div className="setting-section">
|
||
<h2 className="setting-section-title">识别模型</h2>
|
||
|
||
<div className="setting-item">
|
||
<div>
|
||
<div className="setting-label">模型选择</div>
|
||
<div className="setting-description">选择用于语音识别的 ONNX 模型</div>
|
||
</div>
|
||
<select
|
||
className="select"
|
||
value={settings.model}
|
||
onChange={(e) => handleChange('model', e.target.value)}
|
||
>
|
||
<option value="sensevoice-small">SenseVoice Small (推荐)</option>
|
||
<option value="sensevoice-base">SenseVoice Base</option>
|
||
<option value="paraformer">FunASR Paraformer</option>
|
||
<option value="whisper-small">Whisper Small</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div className="setting-item">
|
||
<div>
|
||
<div className="setting-label">模型路径</div>
|
||
<div className="setting-description">自定义模型文件路径(可选,留空使用内置模型)</div>
|
||
</div>
|
||
<div className="model-path-selector">
|
||
<input
|
||
type="text"
|
||
className="input"
|
||
style={{ flex: 1, marginRight: '8px' }}
|
||
value={settings.modelPath || ''}
|
||
placeholder="选择自定义模型文件..."
|
||
readOnly
|
||
/>
|
||
<button className="btn btn-secondary" onClick={handleSelectModel}>
|
||
选择文件
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="setting-item">
|
||
<div>
|
||
<div className="setting-label">识别语言</div>
|
||
<div className="setting-description">主要识别的语言</div>
|
||
</div>
|
||
<select
|
||
className="select"
|
||
value={settings.language}
|
||
onChange={(e) => handleChange('language', e.target.value)}
|
||
>
|
||
<option value="zh">中文普通话</option>
|
||
<option value="en">英语</option>
|
||
<option value="ja">日语</option>
|
||
<option value="ko">韩语</option>
|
||
<option value="yue">粤语</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 音频输入 */}
|
||
<div className="setting-section">
|
||
<h2 className="setting-section-title">音频输入</h2>
|
||
|
||
<div className="setting-item">
|
||
<div>
|
||
<div className="setting-label">麦克风</div>
|
||
<div className="setting-description">选择录音用的麦克风设备</div>
|
||
</div>
|
||
<select
|
||
className="select"
|
||
value={settings.microphone}
|
||
onChange={(e) => handleChange('microphone', e.target.value)}
|
||
>
|
||
<option value="default">默认设备</option>
|
||
<option value="builtin">Built-in Microphone</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div className="setting-item">
|
||
<div>
|
||
<div className="setting-label">采样率</div>
|
||
<div className="setting-description">音频录制采样率</div>
|
||
</div>
|
||
<select
|
||
className="select"
|
||
value={settings.sampleRate}
|
||
onChange={(e) => handleChange('sampleRate', Number(e.target.value))}
|
||
>
|
||
<option value={16000}>16000 Hz</option>
|
||
<option value={44100}>44100 Hz</option>
|
||
<option value={48000}>48000 Hz</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 快捷键 */}
|
||
<div className="setting-section">
|
||
<h2 className="setting-section-title">快捷键</h2>
|
||
|
||
<div className="setting-item">
|
||
<div className="setting-label">开始/停止录音</div>
|
||
<input
|
||
type="text"
|
||
className="input"
|
||
style={{ width: '150px' }}
|
||
value={settings.hotkeyRecord}
|
||
onChange={(e) => handleChange('hotkeyRecord', e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="setting-item">
|
||
<div className="setting-label">快速复制结果</div>
|
||
<input
|
||
type="text"
|
||
className="input"
|
||
style={{ width: '150px' }}
|
||
value={settings.hotkeyCopy}
|
||
onChange={(e) => handleChange('hotkeyCopy', e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="setting-item">
|
||
<div className="setting-label">显示/隐藏窗口</div>
|
||
<input
|
||
type="text"
|
||
className="input"
|
||
style={{ width: '150px' }}
|
||
value={settings.hotkeyToggle}
|
||
onChange={(e) => handleChange('hotkeyToggle', e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 外观 */}
|
||
<div className="setting-section">
|
||
<h2 className="setting-section-title">外观</h2>
|
||
|
||
<div className="setting-item">
|
||
<div className="setting-label">主题</div>
|
||
<div className="theme-selector">
|
||
<button
|
||
className={`theme-btn ${theme === 'light' ? 'active' : ''}`}
|
||
onClick={() => onThemeChange('light')}
|
||
>
|
||
浅色
|
||
</button>
|
||
<button
|
||
className={`theme-btn ${theme === 'dark' ? 'active' : ''}`}
|
||
onClick={() => onThemeChange('dark')}
|
||
>
|
||
深色
|
||
</button>
|
||
<button
|
||
className={`theme-btn ${theme === 'system' ? 'active' : ''}`}
|
||
onClick={() => onThemeChange('system')}
|
||
>
|
||
跟随系统
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 其他 */}
|
||
<div className="setting-section">
|
||
<h2 className="setting-section-title">其他</h2>
|
||
|
||
<div className="setting-item">
|
||
<div className="setting-label">开机自启</div>
|
||
<label className="switch">
|
||
<input
|
||
type="checkbox"
|
||
checked={settings.autoStart}
|
||
onChange={(e) => handleChange('autoStart', e.target.checked)}
|
||
/>
|
||
<span className="switch-slider"></span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="setting-item">
|
||
<div className="setting-label">自动检查更新</div>
|
||
<label className="switch">
|
||
<input
|
||
type="checkbox"
|
||
checked={settings.autoCheckUpdate}
|
||
onChange={(e) => handleChange('autoCheckUpdate', e.target.checked)}
|
||
/>
|
||
<span className="switch-slider"></span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="setting-item">
|
||
<div className="setting-label">自动复制识别结果</div>
|
||
<label className="switch">
|
||
<input
|
||
type="checkbox"
|
||
checked={settings.autoCopy}
|
||
onChange={(e) => handleChange('autoCopy', e.target.checked)}
|
||
/>
|
||
<span className="switch-slider"></span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="setting-item">
|
||
<div>
|
||
<div className="setting-label">历史记录保留天数</div>
|
||
<div className="setting-description">超过此天数的记录将被自动清除</div>
|
||
</div>
|
||
<input
|
||
type="number"
|
||
className="input"
|
||
style={{ width: '80px' }}
|
||
value={settings.historyKeepDays}
|
||
onChange={(e) => handleChange('historyKeepDays', Number(e.target.value))}
|
||
min={1}
|
||
max={365}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-actions">
|
||
<button className="btn btn-secondary" onClick={handleReset}>
|
||
重置默认
|
||
</button>
|
||
<button
|
||
className="btn btn-primary"
|
||
onClick={handleSave}
|
||
disabled={!modified || saving}
|
||
>
|
||
{saving ? '保存中...' : '保存设置'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|