fix(#1196): 修复试听播放失败状态不一致 + 音频切换竞态 + 列表key统一为voice_id
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled

This commit is contained in:
2026-07-30 14:27:59 +08:00
parent 1aa50c9c65
commit c73e213e35
@@ -1,203 +1,227 @@
import { useState, useCallback, useEffect, useRef, type ChangeEvent } from "react"
import { message } from "antd"
import type { TtsConfig, TtsMode } from "../../../types"
import { DEFAULT_TTS_CONFIG } from "../../../types"
import { previewTts } from "@/api/tts"
import { fetchVoices, type UnifiedVoiceItem } from "@/api/voices"
import {
useState,
useCallback,
useEffect,
useRef,
type ChangeEvent,
} from "react";
import { message } from "antd";
import type { TtsConfig, TtsMode } from "../../../types";
import { DEFAULT_TTS_CONFIG } from "../../../types";
import { previewTts } from "@/api/tts";
import { fetchVoices, type UnifiedVoiceItem } from "@/api/voices";
interface UseTtsPanelOptions {
open: boolean
config: TtsConfig
onChange: (config: TtsConfig) => void
onClose: () => void
open: boolean;
config: TtsConfig;
onChange: (config: TtsConfig) => void;
onClose: () => void;
}
export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOptions) => {
export const useTtsPanel = ({
open,
config,
onChange,
onClose,
}: UseTtsPanelOptions) => {
/* ── 音色列表(统一音色:预设 + 克隆) ── */
const [presetVoices, setPresetVoices] = useState<UnifiedVoiceItem[]>([])
const [cloneVoices, setCloneVoices] = useState<UnifiedVoiceItem[]>([])
const [voicesLoading, setVoicesLoading] = useState(false)
const [presetVoices, setPresetVoices] = useState<UnifiedVoiceItem[]>([]);
const [cloneVoices, setCloneVoices] = useState<UnifiedVoiceItem[]>([]);
const [voicesLoading, setVoicesLoading] = useState(false);
/* ── 试听状态 ── */
const [previewLoading, setPreviewLoading] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null)
const mountedRef = useRef(true)
const [previewLoading, setPreviewLoading] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
const mountedRef = useRef(true);
/* ── 加载音色列表 + 重置挂载状态 ── */
useEffect(() => {
if (!open) return
mountedRef.current = true
let active = true
setVoicesLoading(true)
if (!open) return;
mountedRef.current = true;
let active = true;
setVoicesLoading(true);
fetchVoices({ status: "ready", limit: 100 })
.then((res) => {
if (!active) return
const presets = res.items.filter((v) => v.type === "preset")
const clones = res.items.filter((v) => v.type === "clone")
setPresetVoices(presets)
setCloneVoices(clones)
if (!active) return;
const presets = res.items.filter((v) => v.type === "preset");
const clones = res.items.filter((v) => v.type === "clone");
setPresetVoices(presets);
setCloneVoices(clones);
// 如果当前没有选中音色,默认选第一个预设
if (!config.voice_id && presets.length > 0) {
onChange({ ...config, voice_id: presets[0].voice_id })
onChange({ ...config, voice_id: presets[0].voice_id });
}
})
.catch(() => {
if (active) message.error("加载音色列表失败")
if (active) message.error("加载音色列表失败");
})
.finally(() => {
if (active) setVoicesLoading(false)
})
if (active) setVoicesLoading(false);
});
return () => {
active = false
}
active = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
}, [open]);
/* ── 组件卸载时清理音频资源 ── */
useEffect(() => {
return () => {
audioRef.current?.pause()
audioRef.current = null
mountedRef.current = false
}
}, [])
audioRef.current?.pause();
audioRef.current = null;
mountedRef.current = false;
};
}, []);
/* ── 切换配音模式 ── */
const handleModeChange = useCallback(
(mode: TtsMode) => {
onChange({ ...config, mode })
onChange({ ...config, mode });
},
[config, onChange],
)
);
/* ── 文本输入 ── */
const handleTextChange = useCallback(
(e: ChangeEvent<HTMLTextAreaElement>) => {
const text = e.target.value.slice(0, 5000)
onChange({ ...config, text })
const text = e.target.value.slice(0, 5000);
onChange({ ...config, text });
},
[config, onChange],
)
);
/* ── 选择音色 ── */
const handleVoiceSelect = useCallback(
(voiceId: string) => {
onChange({ ...config, voice_id: voiceId })
onChange({ ...config, voice_id: voiceId });
},
[config, onChange],
)
);
/* ── 语速 ── */
const handleSpeedChange = useCallback(
(speed: number) => {
onChange({ ...config, speed })
onChange({ ...config, speed });
},
[config, onChange],
)
);
/* ── 语调 ── */
const handlePitchChange = useCallback(
(pitch: number) => {
onChange({ ...config, pitch })
onChange({ ...config, pitch });
},
[config, onChange],
)
);
/* ── 音量 ── */
const handleVolumeChange = useCallback(
(volume: number) => {
onChange({ ...config, volume })
onChange({ ...config, volume });
},
[config, onChange],
)
);
/* ── 字幕联动 ── */
const handleSubtitleSyncToggle = useCallback(() => {
onChange({ ...config, subtitle_sync: !config.subtitle_sync })
}, [config, onChange])
onChange({ ...config, subtitle_sync: !config.subtitle_sync });
}, [config, onChange]);
/* ── 试听 ── */
const handlePreview = useCallback(async () => {
if (!config.text.trim()) {
message.warning("请先输入合成文本")
return
message.warning("请先输入合成文本");
return;
}
if (!config.voice_id) {
message.warning("请先选择音色")
return
message.warning("请先选择音色");
return;
}
setPreviewLoading(true)
setPreviewLoading(true);
try {
const res = await previewTts({
text: config.text.slice(0, 200),
voice_id: config.voice_id,
speed: config.speed,
pitch: config.pitch,
})
if (!mountedRef.current) return
audioRef.current?.pause()
const audio = new Audio(res.audio_url)
audioRef.current = audio
});
if (!mountedRef.current) return;
audioRef.current?.pause();
const audio = new Audio(res.audio_url);
audioRef.current = audio;
audio
.play()
.then(() => {
if (mountedRef.current) message.success("试听播放中")
if (mountedRef.current) message.success("试听播放中");
})
.catch(() => {
if (mountedRef.current) message.error("播放失败")
})
if (mountedRef.current) message.error("播放失败");
});
audio.onended = () => {
if (mountedRef.current) audioRef.current = null
}
if (mountedRef.current) audioRef.current = null;
};
} catch {
if (mountedRef.current) message.error("试听生成失败")
if (mountedRef.current) message.error("试听生成失败");
} finally {
if (mountedRef.current) setPreviewLoading(false)
if (mountedRef.current) setPreviewLoading(false);
}
}, [config])
}, [config]);
/* ── 单音色示例试听 ── */
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null)
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
const handlePlayVoiceSample = useCallback(
(voiceId: string, previewUrl: string | null) => {
if (playingVoiceId === voiceId) {
audioRef.current?.pause()
audioRef.current = null
setPlayingVoiceId(null)
return
audioRef.current?.pause();
audioRef.current = null;
setPlayingVoiceId(null);
return;
}
// 暂停上一个音频并清除事件监听,避免竞态
if (audioRef.current) {
audioRef.current.onended = null;
audioRef.current.pause();
}
audioRef.current?.pause()
if (!previewUrl) {
message.warning("该音色暂无示例音频")
return
message.warning("该音色暂无示例音频");
return;
}
const audio = new Audio(previewUrl)
audioRef.current = audio
audio.play().catch(() => {
message.error("播放失败")
})
const audio = new Audio(previewUrl);
audioRef.current = audio;
audio
.play()
.then(() => {
if (mountedRef.current) setPlayingVoiceId(voiceId);
})
.catch(() => {
if (mountedRef.current) {
message.error("播放失败");
audioRef.current = null;
}
});
audio.onended = () => {
setPlayingVoiceId(null)
audioRef.current = null
}
setPlayingVoiceId(voiceId)
if (mountedRef.current) {
setPlayingVoiceId(null);
audioRef.current = null;
}
};
},
[playingVoiceId],
)
);
/* ── 重置 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_TTS_CONFIG })
}, [onChange])
onChange({ ...DEFAULT_TTS_CONFIG });
}, [onChange]);
/* ── 关闭时停止音频 ── */
const handleClose = useCallback(() => {
mountedRef.current = false
audioRef.current?.pause()
audioRef.current = null
setPlayingVoiceId(null)
onClose()
}, [onClose])
mountedRef.current = false;
audioRef.current?.pause();
audioRef.current = null;
setPlayingVoiceId(null);
onClose();
}, [onClose]);
return {
presetVoices,
@@ -216,5 +240,5 @@ export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOpti
handlePreview,
handleReset,
handleClose,
}
}
};
};