Add audiobook localization support for Korean, Simplified Chinese, and Traditional Chinese
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { useRef, useState, useEffect, useCallback, memo } from 'react'
|
import { useRef, useState, useEffect, useCallback, memo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useTheme } from '@/contexts/ThemeContext'
|
||||||
import WaveformPlayer from '@arraypress/waveform-player'
|
import WaveformPlayer from '@arraypress/waveform-player'
|
||||||
import '@arraypress/waveform-player/dist/waveform-player.css'
|
import '@arraypress/waveform-player/dist/waveform-player.css'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -15,6 +16,7 @@ interface AudioPlayerProps {
|
|||||||
|
|
||||||
const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => {
|
const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => {
|
||||||
const { t } = useTranslation('common')
|
const { t } = useTranslation('common')
|
||||||
|
const { theme } = useTheme()
|
||||||
const [blobUrl, setBlobUrl] = useState<string>('')
|
const [blobUrl, setBlobUrl] = useState<string>('')
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
@@ -71,6 +73,9 @@ const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current || !blobUrl) return
|
if (!containerRef.current || !blobUrl) return
|
||||||
|
|
||||||
|
const waveformColor = theme === 'dark' ? '#4b5563' : '#d1d5db'
|
||||||
|
const progressColor = theme === 'dark' ? '#a78bfa' : '#7c3aed'
|
||||||
|
|
||||||
const player = new WaveformPlayer(containerRef.current, {
|
const player = new WaveformPlayer(containerRef.current, {
|
||||||
url: blobUrl,
|
url: blobUrl,
|
||||||
waveformStyle: 'mirror',
|
waveformStyle: 'mirror',
|
||||||
@@ -78,6 +83,8 @@ const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => {
|
|||||||
barWidth: 3,
|
barWidth: 3,
|
||||||
barSpacing: 1,
|
barSpacing: 1,
|
||||||
samples: 200,
|
samples: 200,
|
||||||
|
waveformColor,
|
||||||
|
progressColor,
|
||||||
showTime: true,
|
showTime: true,
|
||||||
showPlaybackSpeed: false,
|
showPlaybackSpeed: false,
|
||||||
autoplay: false,
|
autoplay: false,
|
||||||
@@ -103,7 +110,7 @@ const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => {
|
|||||||
playerInstanceRef.current = null
|
playerInstanceRef.current = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [blobUrl])
|
}, [blobUrl, theme])
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
const handleDownload = useCallback(() => {
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
|
|||||||
115
qwen3-tts-frontend/src/locales/en-US/audiobook.json
Normal file
115
qwen3-tts-frontend/src/locales/en-US/audiobook.json
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
{
|
||||||
|
"title": "Audiobook Generation",
|
||||||
|
"llmConfig": "LLM Config",
|
||||||
|
"newProject": "New Project",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"noProjects": "No audiobook projects yet",
|
||||||
|
"noProjectsHint": "Click \"New Project\" to get started",
|
||||||
|
|
||||||
|
"status": {
|
||||||
|
"pending": "Pending",
|
||||||
|
"analyzing": "Analyzing",
|
||||||
|
"characters_ready": "Awaiting Character Review",
|
||||||
|
"parsing": "Parsing Chapters",
|
||||||
|
"ready": "Ready",
|
||||||
|
"generating": "Generating",
|
||||||
|
"done": "Done",
|
||||||
|
"error": "Error"
|
||||||
|
},
|
||||||
|
|
||||||
|
"stepHints": {
|
||||||
|
"pending": "Step 1: Click \"Analyze\" — the LLM will automatically extract the character list",
|
||||||
|
"analyzing": "Step 1: LLM is extracting characters, please wait...",
|
||||||
|
"characters_ready": "Step 2: Review character info, then click \"Confirm Characters · Identify Chapters\"",
|
||||||
|
"ready": "Step 3: Parse chapters one by one (LLM); parsed chapters can generate audio immediately",
|
||||||
|
"generating": "Step 4: Synthesizing audio — completed segments can be played immediately"
|
||||||
|
},
|
||||||
|
|
||||||
|
"llmConfigPanel": {
|
||||||
|
"title": "LLM Configuration",
|
||||||
|
"current": "Current: {{baseUrl}} / {{model}} / {{keyStatus}}",
|
||||||
|
"hasKey": "API key set",
|
||||||
|
"noKey": "No API key",
|
||||||
|
"notSet": "Not set",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"save": "Save Config",
|
||||||
|
"savedSuccess": "LLM config saved",
|
||||||
|
"incompleteError": "Please fill in all LLM configuration fields"
|
||||||
|
},
|
||||||
|
|
||||||
|
"createPanel": {
|
||||||
|
"title": "New Audiobook Project",
|
||||||
|
"titlePlaceholder": "Title",
|
||||||
|
"pasteText": "Paste Text",
|
||||||
|
"uploadEpub": "Upload EPUB",
|
||||||
|
"textPlaceholder": "Paste novel text here...",
|
||||||
|
"creating": "Creating...",
|
||||||
|
"create": "Create Project",
|
||||||
|
"createdSuccess": "Project created",
|
||||||
|
"titleRequired": "Please enter a title",
|
||||||
|
"textRequired": "Please enter text content",
|
||||||
|
"epubRequired": "Please select an EPUB file"
|
||||||
|
},
|
||||||
|
|
||||||
|
"projectCard": {
|
||||||
|
"analyze": "Analyze",
|
||||||
|
"reanalyze": "Re-analyze",
|
||||||
|
"reanalyzeConfirm": "Re-analyzing will clear all character and chapter data. Continue?",
|
||||||
|
"analyzeStarted": "Analysis started",
|
||||||
|
"generateAll": "Generate Full Book",
|
||||||
|
"downloadAll": "Download Full Book",
|
||||||
|
"deleteConfirm": "Delete project \"{{title}}\" and all its audio?",
|
||||||
|
"deleteSuccess": "Project deleted",
|
||||||
|
"allDoneToast": "\"{{title}}\" — all audio generation complete!",
|
||||||
|
"segmentsProgress": "{{done}} / {{total}} segments done",
|
||||||
|
|
||||||
|
"characters": {
|
||||||
|
"title": "Characters ({{count}})",
|
||||||
|
"namePlaceholder": "Character name",
|
||||||
|
"genderPlaceholder": "Gender (not set)",
|
||||||
|
"genderMale": "Male",
|
||||||
|
"genderFemale": "Female",
|
||||||
|
"genderUnknown": "Unknown",
|
||||||
|
"instructPlaceholder": "Voice description (for TTS)",
|
||||||
|
"descPlaceholder": "Character description",
|
||||||
|
"voiceDesign": "Voice #{{id}}",
|
||||||
|
"noVoice": "Unassigned",
|
||||||
|
"savedSuccess": "Character saved"
|
||||||
|
},
|
||||||
|
|
||||||
|
"confirm": {
|
||||||
|
"button": "Confirm Characters · Identify Chapters",
|
||||||
|
"loading": "Identifying...",
|
||||||
|
"chaptersRecognized": "Chapters identified"
|
||||||
|
},
|
||||||
|
|
||||||
|
"chapters": {
|
||||||
|
"title": "Chapters ({{count}} total)",
|
||||||
|
"processAll": "Process All",
|
||||||
|
"defaultTitle": "Chapter {{index}}",
|
||||||
|
"parse": "Parse Chapter",
|
||||||
|
"parsing": "Parsing",
|
||||||
|
"parseStarted": "Parsing \"{{title}}\" started",
|
||||||
|
"parseStartedDefault": "Chapter parsing started",
|
||||||
|
"reparse": "Re-parse",
|
||||||
|
"generate": "Generate Chapter",
|
||||||
|
"generateStarted": "Chapter {{index}} generation started",
|
||||||
|
"generateAllStarted": "Full book generation started",
|
||||||
|
"processAllStarted": "All tasks triggered",
|
||||||
|
"doneBadge": "{{count}} segments done",
|
||||||
|
"segmentProgress": "{{done}}/{{total}} segments"
|
||||||
|
},
|
||||||
|
|
||||||
|
"segments": {
|
||||||
|
"errorBadge": "Error",
|
||||||
|
"unknownCharacter": "?"
|
||||||
|
},
|
||||||
|
|
||||||
|
"sequential": {
|
||||||
|
"play": "Play In Order ({{count}} segments)",
|
||||||
|
"stop": "Stop",
|
||||||
|
"progress": "{{current}} / {{total}}",
|
||||||
|
"loading": "Loading..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import user from './user.json'
|
|||||||
import errors from './errors.json'
|
import errors from './errors.json'
|
||||||
import constants from './constants.json'
|
import constants from './constants.json'
|
||||||
import onboarding from './onboarding.json'
|
import onboarding from './onboarding.json'
|
||||||
|
import audiobook from './audiobook.json'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
common,
|
common,
|
||||||
@@ -22,4 +23,5 @@ export default {
|
|||||||
errors,
|
errors,
|
||||||
constants,
|
constants,
|
||||||
onboarding,
|
onboarding,
|
||||||
|
audiobook,
|
||||||
}
|
}
|
||||||
|
|||||||
115
qwen3-tts-frontend/src/locales/ja-JP/audiobook.json
Normal file
115
qwen3-tts-frontend/src/locales/ja-JP/audiobook.json
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
{
|
||||||
|
"title": "オーディオブック生成",
|
||||||
|
"llmConfig": "LLM 設定",
|
||||||
|
"newProject": "新規プロジェクト",
|
||||||
|
"loading": "読み込み中...",
|
||||||
|
"noProjects": "オーディオブックプロジェクトがありません",
|
||||||
|
"noProjectsHint": "「新規プロジェクト」をクリックして作成を開始",
|
||||||
|
|
||||||
|
"status": {
|
||||||
|
"pending": "未分析",
|
||||||
|
"analyzing": "分析中",
|
||||||
|
"characters_ready": "キャラクター確認待ち",
|
||||||
|
"parsing": "章を解析中",
|
||||||
|
"ready": "生成待ち",
|
||||||
|
"generating": "生成中",
|
||||||
|
"done": "完了",
|
||||||
|
"error": "エラー"
|
||||||
|
},
|
||||||
|
|
||||||
|
"stepHints": {
|
||||||
|
"pending": "ステップ 1:「分析」をクリック — LLM がキャラクターリストを自動抽出します",
|
||||||
|
"analyzing": "ステップ 1:LLM がキャラクターを抽出中です。少々お待ちください...",
|
||||||
|
"characters_ready": "ステップ 2:キャラクター情報を確認し、「キャラクター確認 · 章を識別」をクリック",
|
||||||
|
"ready": "ステップ 3:章ごとに解析(LLM)— 解析済みの章はすぐに音声生成できます",
|
||||||
|
"generating": "ステップ 4:音声合成中 — 完成したセグメントはすぐに再生できます"
|
||||||
|
},
|
||||||
|
|
||||||
|
"llmConfigPanel": {
|
||||||
|
"title": "LLM 設定",
|
||||||
|
"current": "現在:{{baseUrl}} / {{model}} / {{keyStatus}}",
|
||||||
|
"hasKey": "APIキー設定済み",
|
||||||
|
"noKey": "APIキー未設定",
|
||||||
|
"notSet": "未設定",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"save": "設定を保存",
|
||||||
|
"savedSuccess": "LLM 設定を保存しました",
|
||||||
|
"incompleteError": "LLM 設定をすべて入力してください"
|
||||||
|
},
|
||||||
|
|
||||||
|
"createPanel": {
|
||||||
|
"title": "新規オーディオブックプロジェクト",
|
||||||
|
"titlePlaceholder": "タイトル",
|
||||||
|
"pasteText": "テキストを貼り付け",
|
||||||
|
"uploadEpub": "EPUB アップロード",
|
||||||
|
"textPlaceholder": "小説のテキストを貼り付け...",
|
||||||
|
"creating": "作成中...",
|
||||||
|
"create": "プロジェクト作成",
|
||||||
|
"createdSuccess": "プロジェクトを作成しました",
|
||||||
|
"titleRequired": "タイトルを入力してください",
|
||||||
|
"textRequired": "テキスト内容を入力してください",
|
||||||
|
"epubRequired": "EPUB ファイルを選択してください"
|
||||||
|
},
|
||||||
|
|
||||||
|
"projectCard": {
|
||||||
|
"analyze": "分析",
|
||||||
|
"reanalyze": "再分析",
|
||||||
|
"reanalyzeConfirm": "再分析するとすべてのキャラクターと章のデータが削除されます。続けますか?",
|
||||||
|
"analyzeStarted": "分析を開始しました",
|
||||||
|
"generateAll": "全冊生成",
|
||||||
|
"downloadAll": "全冊ダウンロード",
|
||||||
|
"deleteConfirm": "プロジェクト「{{title}}」とすべての音声を削除しますか?",
|
||||||
|
"deleteSuccess": "プロジェクトを削除しました",
|
||||||
|
"allDoneToast": "「{{title}}」の音声生成がすべて完了しました!",
|
||||||
|
"segmentsProgress": "{{done}} / {{total}} セグメント完了",
|
||||||
|
|
||||||
|
"characters": {
|
||||||
|
"title": "キャラクター({{count}} 人)",
|
||||||
|
"namePlaceholder": "キャラクター名",
|
||||||
|
"genderPlaceholder": "性別(未設定)",
|
||||||
|
"genderMale": "男性",
|
||||||
|
"genderFemale": "女性",
|
||||||
|
"genderUnknown": "不明",
|
||||||
|
"instructPlaceholder": "音声説明(TTS 用)",
|
||||||
|
"descPlaceholder": "キャラクター説明",
|
||||||
|
"voiceDesign": "音声 #{{id}}",
|
||||||
|
"noVoice": "未割り当て",
|
||||||
|
"savedSuccess": "キャラクターを保存しました"
|
||||||
|
},
|
||||||
|
|
||||||
|
"confirm": {
|
||||||
|
"button": "キャラクター確認 · 章を識別",
|
||||||
|
"loading": "識別中...",
|
||||||
|
"chaptersRecognized": "章を識別しました"
|
||||||
|
},
|
||||||
|
|
||||||
|
"chapters": {
|
||||||
|
"title": "章一覧(全 {{count}} 章)",
|
||||||
|
"processAll": "すべて処理",
|
||||||
|
"defaultTitle": "第 {{index}} 章",
|
||||||
|
"parse": "この章を解析",
|
||||||
|
"parsing": "解析中",
|
||||||
|
"parseStarted": "「{{title}}」の解析を開始しました",
|
||||||
|
"parseStartedDefault": "章の解析を開始しました",
|
||||||
|
"reparse": "再解析",
|
||||||
|
"generate": "この章を生成",
|
||||||
|
"generateStarted": "第 {{index}} 章の生成を開始しました",
|
||||||
|
"generateAllStarted": "全冊生成を開始しました",
|
||||||
|
"processAllStarted": "すべてのタスクを開始しました",
|
||||||
|
"doneBadge": "{{count}} セグメント完了",
|
||||||
|
"segmentProgress": "{{done}}/{{total}} セグメント"
|
||||||
|
},
|
||||||
|
|
||||||
|
"segments": {
|
||||||
|
"errorBadge": "エラー",
|
||||||
|
"unknownCharacter": "?"
|
||||||
|
},
|
||||||
|
|
||||||
|
"sequential": {
|
||||||
|
"play": "順番に再生({{count}} セグメント)",
|
||||||
|
"stop": "停止",
|
||||||
|
"progress": "{{current}} / {{total}}",
|
||||||
|
"loading": "読み込み中..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import user from './user.json'
|
|||||||
import errors from './errors.json'
|
import errors from './errors.json'
|
||||||
import constants from './constants.json'
|
import constants from './constants.json'
|
||||||
import onboarding from './onboarding.json'
|
import onboarding from './onboarding.json'
|
||||||
|
import audiobook from './audiobook.json'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
common,
|
common,
|
||||||
@@ -22,4 +23,5 @@ export default {
|
|||||||
errors,
|
errors,
|
||||||
constants,
|
constants,
|
||||||
onboarding,
|
onboarding,
|
||||||
|
audiobook,
|
||||||
}
|
}
|
||||||
|
|||||||
115
qwen3-tts-frontend/src/locales/ko-KR/audiobook.json
Normal file
115
qwen3-tts-frontend/src/locales/ko-KR/audiobook.json
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
{
|
||||||
|
"title": "오디오북 생성",
|
||||||
|
"llmConfig": "LLM 설정",
|
||||||
|
"newProject": "새 프로젝트",
|
||||||
|
"loading": "로딩 중...",
|
||||||
|
"noProjects": "오디오북 프로젝트가 없습니다",
|
||||||
|
"noProjectsHint": "「새 프로젝트」를 클릭하여 시작하세요",
|
||||||
|
|
||||||
|
"status": {
|
||||||
|
"pending": "분석 대기",
|
||||||
|
"analyzing": "분석 중",
|
||||||
|
"characters_ready": "캐릭터 확인 대기",
|
||||||
|
"parsing": "챕터 파싱 중",
|
||||||
|
"ready": "생성 대기",
|
||||||
|
"generating": "생성 중",
|
||||||
|
"done": "완료",
|
||||||
|
"error": "오류"
|
||||||
|
},
|
||||||
|
|
||||||
|
"stepHints": {
|
||||||
|
"pending": "1단계: 「분석」을 클릭하면 LLM이 캐릭터 목록을 자동으로 추출합니다",
|
||||||
|
"analyzing": "1단계: LLM이 캐릭터를 추출 중입니다. 잠시 기다려 주세요...",
|
||||||
|
"characters_ready": "2단계: 캐릭터 정보를 확인한 후 「캐릭터 확인 · 챕터 식별」을 클릭하세요",
|
||||||
|
"ready": "3단계: 챕터별로 대본을 파싱합니다(LLM). 파싱된 챕터는 즉시 음성 생성이 가능합니다",
|
||||||
|
"generating": "4단계: 음성 합성 중 — 완료된 세그먼트는 즉시 재생할 수 있습니다"
|
||||||
|
},
|
||||||
|
|
||||||
|
"llmConfigPanel": {
|
||||||
|
"title": "LLM 설정",
|
||||||
|
"current": "현재: {{baseUrl}} / {{model}} / {{keyStatus}}",
|
||||||
|
"hasKey": "API 키 설정됨",
|
||||||
|
"noKey": "API 키 없음",
|
||||||
|
"notSet": "미설정",
|
||||||
|
"saving": "저장 중...",
|
||||||
|
"save": "설정 저장",
|
||||||
|
"savedSuccess": "LLM 설정이 저장되었습니다",
|
||||||
|
"incompleteError": "LLM 설정을 모두 입력해 주세요"
|
||||||
|
},
|
||||||
|
|
||||||
|
"createPanel": {
|
||||||
|
"title": "새 오디오북 프로젝트",
|
||||||
|
"titlePlaceholder": "제목",
|
||||||
|
"pasteText": "텍스트 붙여넣기",
|
||||||
|
"uploadEpub": "EPUB 업로드",
|
||||||
|
"textPlaceholder": "소설 텍스트를 붙여넣으세요...",
|
||||||
|
"creating": "생성 중...",
|
||||||
|
"create": "프로젝트 생성",
|
||||||
|
"createdSuccess": "프로젝트가 생성되었습니다",
|
||||||
|
"titleRequired": "제목을 입력해 주세요",
|
||||||
|
"textRequired": "텍스트 내용을 입력해 주세요",
|
||||||
|
"epubRequired": "EPUB 파일을 선택해 주세요"
|
||||||
|
},
|
||||||
|
|
||||||
|
"projectCard": {
|
||||||
|
"analyze": "분석",
|
||||||
|
"reanalyze": "재분석",
|
||||||
|
"reanalyzeConfirm": "재분석하면 모든 캐릭터와 챕터 데이터가 삭제됩니다. 계속하시겠습니까?",
|
||||||
|
"analyzeStarted": "분석이 시작되었습니다",
|
||||||
|
"generateAll": "전체 책 생성",
|
||||||
|
"downloadAll": "전체 책 다운로드",
|
||||||
|
"deleteConfirm": "프로젝트 「{{title}}」와 모든 음성을 삭제하시겠습니까?",
|
||||||
|
"deleteSuccess": "프로젝트가 삭제되었습니다",
|
||||||
|
"allDoneToast": "「{{title}}」 음성 생성이 모두 완료되었습니다!",
|
||||||
|
"segmentsProgress": "{{done}} / {{total}} 세그먼트 완료",
|
||||||
|
|
||||||
|
"characters": {
|
||||||
|
"title": "캐릭터 목록 ({{count}}명)",
|
||||||
|
"namePlaceholder": "캐릭터 이름",
|
||||||
|
"genderPlaceholder": "성별 (미설정)",
|
||||||
|
"genderMale": "남성",
|
||||||
|
"genderFemale": "여성",
|
||||||
|
"genderUnknown": "알 수 없음",
|
||||||
|
"instructPlaceholder": "음성 설명 (TTS용)",
|
||||||
|
"descPlaceholder": "캐릭터 설명",
|
||||||
|
"voiceDesign": "음성 #{{id}}",
|
||||||
|
"noVoice": "미할당",
|
||||||
|
"savedSuccess": "캐릭터가 저장되었습니다"
|
||||||
|
},
|
||||||
|
|
||||||
|
"confirm": {
|
||||||
|
"button": "캐릭터 확인 · 챕터 식별",
|
||||||
|
"loading": "식별 중...",
|
||||||
|
"chaptersRecognized": "챕터가 식별되었습니다"
|
||||||
|
},
|
||||||
|
|
||||||
|
"chapters": {
|
||||||
|
"title": "챕터 목록 (총 {{count}}챕터)",
|
||||||
|
"processAll": "전체 처리",
|
||||||
|
"defaultTitle": "제 {{index}} 장",
|
||||||
|
"parse": "이 챕터 파싱",
|
||||||
|
"parsing": "파싱 중",
|
||||||
|
"parseStarted": "「{{title}}」 파싱이 시작되었습니다",
|
||||||
|
"parseStartedDefault": "챕터 파싱이 시작되었습니다",
|
||||||
|
"reparse": "재파싱",
|
||||||
|
"generate": "이 챕터 생성",
|
||||||
|
"generateStarted": "제 {{index}} 장 생성이 시작되었습니다",
|
||||||
|
"generateAllStarted": "전체 책 생성이 시작되었습니다",
|
||||||
|
"processAllStarted": "모든 작업이 시작되었습니다",
|
||||||
|
"doneBadge": "{{count}}개 세그먼트 완료",
|
||||||
|
"segmentProgress": "{{done}}/{{total}} 세그먼트"
|
||||||
|
},
|
||||||
|
|
||||||
|
"segments": {
|
||||||
|
"errorBadge": "오류",
|
||||||
|
"unknownCharacter": "?"
|
||||||
|
},
|
||||||
|
|
||||||
|
"sequential": {
|
||||||
|
"play": "순차 재생 ({{count}}개 세그먼트)",
|
||||||
|
"stop": "정지",
|
||||||
|
"progress": "{{current}} / {{total}}",
|
||||||
|
"loading": "로딩 중..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import user from './user.json'
|
|||||||
import errors from './errors.json'
|
import errors from './errors.json'
|
||||||
import constants from './constants.json'
|
import constants from './constants.json'
|
||||||
import onboarding from './onboarding.json'
|
import onboarding from './onboarding.json'
|
||||||
|
import audiobook from './audiobook.json'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
common,
|
common,
|
||||||
@@ -22,4 +23,5 @@ export default {
|
|||||||
errors,
|
errors,
|
||||||
constants,
|
constants,
|
||||||
onboarding,
|
onboarding,
|
||||||
|
audiobook,
|
||||||
}
|
}
|
||||||
|
|||||||
115
qwen3-tts-frontend/src/locales/zh-CN/audiobook.json
Normal file
115
qwen3-tts-frontend/src/locales/zh-CN/audiobook.json
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
{
|
||||||
|
"title": "有声书生成",
|
||||||
|
"llmConfig": "LLM 配置",
|
||||||
|
"newProject": "新建项目",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"noProjects": "暂无有声书项目",
|
||||||
|
"noProjectsHint": "点击「新建项目」开始创建",
|
||||||
|
|
||||||
|
"status": {
|
||||||
|
"pending": "待分析",
|
||||||
|
"analyzing": "分析中",
|
||||||
|
"characters_ready": "角色待确认",
|
||||||
|
"parsing": "解析章节",
|
||||||
|
"ready": "待生成",
|
||||||
|
"generating": "生成中",
|
||||||
|
"done": "已完成",
|
||||||
|
"error": "出错"
|
||||||
|
},
|
||||||
|
|
||||||
|
"stepHints": {
|
||||||
|
"pending": "第 1 步:点击「分析」,LLM 将自动提取角色列表",
|
||||||
|
"analyzing": "第 1 步:LLM 正在提取角色,请稍候...",
|
||||||
|
"characters_ready": "第 2 步:确认角色信息,可编辑后点击「确认角色 · 识别章节」",
|
||||||
|
"ready": "第 3 步:逐章解析剧本(LLM),解析完的章节可立即生成音频",
|
||||||
|
"generating": "第 4 步:正在合成音频,已完成片段可立即播放"
|
||||||
|
},
|
||||||
|
|
||||||
|
"llmConfigPanel": {
|
||||||
|
"title": "LLM 配置",
|
||||||
|
"current": "当前:{{baseUrl}} / {{model}} / {{keyStatus}}",
|
||||||
|
"hasKey": "已配置密钥",
|
||||||
|
"noKey": "未配置密钥",
|
||||||
|
"notSet": "未设置",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"save": "保存配置",
|
||||||
|
"savedSuccess": "LLM 配置已保存",
|
||||||
|
"incompleteError": "请填写完整的 LLM 配置"
|
||||||
|
},
|
||||||
|
|
||||||
|
"createPanel": {
|
||||||
|
"title": "新建有声书项目",
|
||||||
|
"titlePlaceholder": "书名",
|
||||||
|
"pasteText": "粘贴文本",
|
||||||
|
"uploadEpub": "上传 epub",
|
||||||
|
"textPlaceholder": "粘贴小说文本...",
|
||||||
|
"creating": "创建中...",
|
||||||
|
"create": "创建项目",
|
||||||
|
"createdSuccess": "项目已创建",
|
||||||
|
"titleRequired": "请输入书名",
|
||||||
|
"textRequired": "请输入文本内容",
|
||||||
|
"epubRequired": "请选择 epub 文件"
|
||||||
|
},
|
||||||
|
|
||||||
|
"projectCard": {
|
||||||
|
"analyze": "分析",
|
||||||
|
"reanalyze": "重新分析",
|
||||||
|
"reanalyzeConfirm": "重新分析将清除所有角色和章节数据,确定继续?",
|
||||||
|
"analyzeStarted": "分析已开始",
|
||||||
|
"generateAll": "生成全书",
|
||||||
|
"downloadAll": "下载全书",
|
||||||
|
"deleteConfirm": "确认删除项目「{{title}}」及所有音频?",
|
||||||
|
"deleteSuccess": "项目已删除",
|
||||||
|
"allDoneToast": "「{{title}}」音频全部生成完成!",
|
||||||
|
"segmentsProgress": "{{done}} / {{total}} 片段完成",
|
||||||
|
|
||||||
|
"characters": {
|
||||||
|
"title": "角色列表({{count}} 个)",
|
||||||
|
"namePlaceholder": "角色名",
|
||||||
|
"genderPlaceholder": "性别(未设置)",
|
||||||
|
"genderMale": "男",
|
||||||
|
"genderFemale": "女",
|
||||||
|
"genderUnknown": "未知",
|
||||||
|
"instructPlaceholder": "音色描述(用于 TTS)",
|
||||||
|
"descPlaceholder": "角色描述",
|
||||||
|
"voiceDesign": "音色 #{{id}}",
|
||||||
|
"noVoice": "未分配",
|
||||||
|
"savedSuccess": "角色已保存"
|
||||||
|
},
|
||||||
|
|
||||||
|
"confirm": {
|
||||||
|
"button": "确认角色 · 识别章节",
|
||||||
|
"loading": "识别中...",
|
||||||
|
"chaptersRecognized": "章节已识别"
|
||||||
|
},
|
||||||
|
|
||||||
|
"chapters": {
|
||||||
|
"title": "章节列表(共 {{count}} 章)",
|
||||||
|
"processAll": "一键全部处理",
|
||||||
|
"defaultTitle": "第 {{index}} 章",
|
||||||
|
"parse": "解析此章",
|
||||||
|
"parsing": "解析中",
|
||||||
|
"parseStarted": "「{{title}}」解析已开始",
|
||||||
|
"parseStartedDefault": "章节解析已开始",
|
||||||
|
"reparse": "重新解析",
|
||||||
|
"generate": "生成此章",
|
||||||
|
"generateStarted": "第 {{index}} 章生成已开始",
|
||||||
|
"generateAllStarted": "全书生成已开始",
|
||||||
|
"processAllStarted": "全部任务已触发",
|
||||||
|
"doneBadge": "已完成 {{count}} 段",
|
||||||
|
"segmentProgress": "{{done}}/{{total}} 段"
|
||||||
|
},
|
||||||
|
|
||||||
|
"segments": {
|
||||||
|
"errorBadge": "出错",
|
||||||
|
"unknownCharacter": "?"
|
||||||
|
},
|
||||||
|
|
||||||
|
"sequential": {
|
||||||
|
"play": "顺序播放({{count}} 段)",
|
||||||
|
"stop": "停止",
|
||||||
|
"progress": "第 {{current}} / {{total}} 段",
|
||||||
|
"loading": "加载中..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import user from './user.json'
|
|||||||
import errors from './errors.json'
|
import errors from './errors.json'
|
||||||
import constants from './constants.json'
|
import constants from './constants.json'
|
||||||
import onboarding from './onboarding.json'
|
import onboarding from './onboarding.json'
|
||||||
|
import audiobook from './audiobook.json'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
common,
|
common,
|
||||||
@@ -22,4 +23,5 @@ export default {
|
|||||||
errors,
|
errors,
|
||||||
constants,
|
constants,
|
||||||
onboarding,
|
onboarding,
|
||||||
|
audiobook,
|
||||||
}
|
}
|
||||||
|
|||||||
115
qwen3-tts-frontend/src/locales/zh-TW/audiobook.json
Normal file
115
qwen3-tts-frontend/src/locales/zh-TW/audiobook.json
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
{
|
||||||
|
"title": "有聲書生成",
|
||||||
|
"llmConfig": "LLM 配置",
|
||||||
|
"newProject": "新建專案",
|
||||||
|
"loading": "載入中...",
|
||||||
|
"noProjects": "暫無有聲書專案",
|
||||||
|
"noProjectsHint": "點擊「新建專案」開始建立",
|
||||||
|
|
||||||
|
"status": {
|
||||||
|
"pending": "待分析",
|
||||||
|
"analyzing": "分析中",
|
||||||
|
"characters_ready": "角色待確認",
|
||||||
|
"parsing": "解析章節",
|
||||||
|
"ready": "待生成",
|
||||||
|
"generating": "生成中",
|
||||||
|
"done": "已完成",
|
||||||
|
"error": "出錯"
|
||||||
|
},
|
||||||
|
|
||||||
|
"stepHints": {
|
||||||
|
"pending": "第 1 步:點擊「分析」,LLM 將自動提取角色列表",
|
||||||
|
"analyzing": "第 1 步:LLM 正在提取角色,請稍候...",
|
||||||
|
"characters_ready": "第 2 步:確認角色資訊,可編輯後點擊「確認角色 · 識別章節」",
|
||||||
|
"ready": "第 3 步:逐章解析劇本(LLM),解析完的章節可立即生成音訊",
|
||||||
|
"generating": "第 4 步:正在合成音訊,已完成片段可立即播放"
|
||||||
|
},
|
||||||
|
|
||||||
|
"llmConfigPanel": {
|
||||||
|
"title": "LLM 配置",
|
||||||
|
"current": "當前:{{baseUrl}} / {{model}} / {{keyStatus}}",
|
||||||
|
"hasKey": "已配置金鑰",
|
||||||
|
"noKey": "未配置金鑰",
|
||||||
|
"notSet": "未設置",
|
||||||
|
"saving": "儲存中...",
|
||||||
|
"save": "儲存配置",
|
||||||
|
"savedSuccess": "LLM 配置已儲存",
|
||||||
|
"incompleteError": "請填寫完整的 LLM 配置"
|
||||||
|
},
|
||||||
|
|
||||||
|
"createPanel": {
|
||||||
|
"title": "新建有聲書專案",
|
||||||
|
"titlePlaceholder": "書名",
|
||||||
|
"pasteText": "貼上文字",
|
||||||
|
"uploadEpub": "上傳 epub",
|
||||||
|
"textPlaceholder": "貼上小說文字...",
|
||||||
|
"creating": "建立中...",
|
||||||
|
"create": "建立專案",
|
||||||
|
"createdSuccess": "專案已建立",
|
||||||
|
"titleRequired": "請輸入書名",
|
||||||
|
"textRequired": "請輸入文字內容",
|
||||||
|
"epubRequired": "請選擇 epub 檔案"
|
||||||
|
},
|
||||||
|
|
||||||
|
"projectCard": {
|
||||||
|
"analyze": "分析",
|
||||||
|
"reanalyze": "重新分析",
|
||||||
|
"reanalyzeConfirm": "重新分析將清除所有角色和章節資料,確定繼續?",
|
||||||
|
"analyzeStarted": "分析已開始",
|
||||||
|
"generateAll": "生成全書",
|
||||||
|
"downloadAll": "下載全書",
|
||||||
|
"deleteConfirm": "確認刪除專案「{{title}}」及所有音訊?",
|
||||||
|
"deleteSuccess": "專案已刪除",
|
||||||
|
"allDoneToast": "「{{title}}」音訊全部生成完成!",
|
||||||
|
"segmentsProgress": "{{done}} / {{total}} 片段完成",
|
||||||
|
|
||||||
|
"characters": {
|
||||||
|
"title": "角色列表({{count}} 個)",
|
||||||
|
"namePlaceholder": "角色名",
|
||||||
|
"genderPlaceholder": "性別(未設置)",
|
||||||
|
"genderMale": "男",
|
||||||
|
"genderFemale": "女",
|
||||||
|
"genderUnknown": "未知",
|
||||||
|
"instructPlaceholder": "音色描述(用於 TTS)",
|
||||||
|
"descPlaceholder": "角色描述",
|
||||||
|
"voiceDesign": "音色 #{{id}}",
|
||||||
|
"noVoice": "未分配",
|
||||||
|
"savedSuccess": "角色已儲存"
|
||||||
|
},
|
||||||
|
|
||||||
|
"confirm": {
|
||||||
|
"button": "確認角色 · 識別章節",
|
||||||
|
"loading": "識別中...",
|
||||||
|
"chaptersRecognized": "章節已識別"
|
||||||
|
},
|
||||||
|
|
||||||
|
"chapters": {
|
||||||
|
"title": "章節列表(共 {{count}} 章)",
|
||||||
|
"processAll": "一鍵全部處理",
|
||||||
|
"defaultTitle": "第 {{index}} 章",
|
||||||
|
"parse": "解析此章",
|
||||||
|
"parsing": "解析中",
|
||||||
|
"parseStarted": "「{{title}}」解析已開始",
|
||||||
|
"parseStartedDefault": "章節解析已開始",
|
||||||
|
"reparse": "重新解析",
|
||||||
|
"generate": "生成此章",
|
||||||
|
"generateStarted": "第 {{index}} 章生成已開始",
|
||||||
|
"generateAllStarted": "全書生成已開始",
|
||||||
|
"processAllStarted": "全部任務已觸發",
|
||||||
|
"doneBadge": "已完成 {{count}} 段",
|
||||||
|
"segmentProgress": "{{done}}/{{total}} 段"
|
||||||
|
},
|
||||||
|
|
||||||
|
"segments": {
|
||||||
|
"errorBadge": "出錯",
|
||||||
|
"unknownCharacter": "?"
|
||||||
|
},
|
||||||
|
|
||||||
|
"sequential": {
|
||||||
|
"play": "順序播放({{count}} 段)",
|
||||||
|
"stop": "停止",
|
||||||
|
"progress": "第 {{current}} / {{total}} 段",
|
||||||
|
"loading": "載入中..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import user from './user.json'
|
|||||||
import errors from './errors.json'
|
import errors from './errors.json'
|
||||||
import constants from './constants.json'
|
import constants from './constants.json'
|
||||||
import onboarding from './onboarding.json'
|
import onboarding from './onboarding.json'
|
||||||
|
import audiobook from './audiobook.json'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
common,
|
common,
|
||||||
@@ -22,4 +23,5 @@ export default {
|
|||||||
errors,
|
errors,
|
||||||
constants,
|
constants,
|
||||||
onboarding,
|
onboarding,
|
||||||
|
audiobook,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Book, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square, Pencil, Check, X, Loader2 } from 'lucide-react'
|
import { Book, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square, Pencil, Check, X, Loader2 } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -27,17 +28,6 @@ function LazyAudioPlayer({ audioUrl, jobId }: { audioUrl: string; jobId: number
|
|||||||
return <div ref={ref}>{visible && <AudioPlayer audioUrl={audioUrl} jobId={jobId} />}</div>
|
return <div ref={ref}>{visible && <AudioPlayer audioUrl={audioUrl} jobId={jobId} />}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
|
||||||
pending: '待分析',
|
|
||||||
analyzing: '分析中',
|
|
||||||
characters_ready: '角色待确认',
|
|
||||||
parsing: '解析章节',
|
|
||||||
ready: '待生成',
|
|
||||||
generating: '生成中',
|
|
||||||
done: '已完成',
|
|
||||||
error: '出错',
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
pending: 'secondary',
|
pending: 'secondary',
|
||||||
analyzing: 'default',
|
analyzing: 'default',
|
||||||
@@ -49,13 +39,7 @@ const STATUS_COLORS: Record<string, string> = {
|
|||||||
error: 'destructive',
|
error: 'destructive',
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEP_HINTS: Record<string, string> = {
|
const STEP_HINT_STATUSES = ['pending', 'analyzing', 'characters_ready', 'ready', 'generating']
|
||||||
pending: '第 1 步:点击「分析」,LLM 将自动提取角色列表',
|
|
||||||
analyzing: '第 1 步:LLM 正在提取角色,请稍候...',
|
|
||||||
characters_ready: '第 2 步:确认角色信息,可编辑后点击「确认角色 · 识别章节」',
|
|
||||||
ready: '第 3 步:逐章解析剧本(LLM),解析完的章节可立即生成音频',
|
|
||||||
generating: '第 4 步:正在合成音频,已完成片段可立即播放',
|
|
||||||
}
|
|
||||||
|
|
||||||
function SequentialPlayer({
|
function SequentialPlayer({
|
||||||
segments,
|
segments,
|
||||||
@@ -66,6 +50,7 @@ function SequentialPlayer({
|
|||||||
projectId: number
|
projectId: number
|
||||||
onPlayingChange: (segmentId: number | null) => void
|
onPlayingChange: (segmentId: number | null) => void
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation('audiobook')
|
||||||
const [displayIndex, setDisplayIndex] = useState<number | null>(null)
|
const [displayIndex, setDisplayIndex] = useState<number | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const audioRef = useRef<HTMLAudioElement>(new Audio())
|
const audioRef = useRef<HTMLAudioElement>(new Audio())
|
||||||
@@ -140,15 +125,17 @@ function SequentialPlayer({
|
|||||||
{displayIndex !== null ? (
|
{displayIndex !== null ? (
|
||||||
<>
|
<>
|
||||||
<Button size="sm" variant="outline" onClick={stop}>
|
<Button size="sm" variant="outline" onClick={stop}>
|
||||||
<Square className="h-3 w-3 mr-1 fill-current" />停止
|
<Square className="h-3 w-3 mr-1 fill-current" />{t('projectCard.sequential.stop')}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{isLoading ? '加载中...' : `第 ${displayIndex + 1} / ${doneSegments.length} 段`}
|
{isLoading
|
||||||
|
? t('projectCard.sequential.loading')
|
||||||
|
: t('projectCard.sequential.progress', { current: displayIndex + 1, total: doneSegments.length })}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button size="sm" variant="outline" onClick={() => playSegment(0)}>
|
<Button size="sm" variant="outline" onClick={() => playSegment(0)}>
|
||||||
<Play className="h-3 w-3 mr-1" />顺序播放({doneSegments.length} 段)
|
<Play className="h-3 w-3 mr-1" />{t('projectCard.sequential.play', { count: doneSegments.length })}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -228,6 +215,7 @@ function LogStream({ projectId, chapterId, active }: { projectId: number; chapte
|
|||||||
}
|
}
|
||||||
|
|
||||||
function LLMConfigPanel({ onSaved }: { onSaved?: () => void }) {
|
function LLMConfigPanel({ onSaved }: { onSaved?: () => void }) {
|
||||||
|
const { t } = useTranslation('audiobook')
|
||||||
const [baseUrl, setBaseUrl] = useState('')
|
const [baseUrl, setBaseUrl] = useState('')
|
||||||
const [apiKey, setApiKey] = useState('')
|
const [apiKey, setApiKey] = useState('')
|
||||||
const [model, setModel] = useState('')
|
const [model, setModel] = useState('')
|
||||||
@@ -240,13 +228,13 @@ function LLMConfigPanel({ onSaved }: { onSaved?: () => void }) {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!baseUrl || !apiKey || !model) {
|
if (!baseUrl || !apiKey || !model) {
|
||||||
toast.error('请填写完整的 LLM 配置')
|
toast.error(t('llmConfigPanel.incompleteError'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
await audiobookApi.setLLMConfig({ base_url: baseUrl, api_key: apiKey, model })
|
await audiobookApi.setLLMConfig({ base_url: baseUrl, api_key: apiKey, model })
|
||||||
toast.success('LLM 配置已保存')
|
toast.success(t('llmConfigPanel.savedSuccess'))
|
||||||
setApiKey('')
|
setApiKey('')
|
||||||
const updated = await audiobookApi.getLLMConfig()
|
const updated = await audiobookApi.getLLMConfig()
|
||||||
setExisting(updated)
|
setExisting(updated)
|
||||||
@@ -260,21 +248,28 @@ function LLMConfigPanel({ onSaved }: { onSaved?: () => void }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border rounded-lg p-4 space-y-3">
|
<div className="border rounded-lg p-4 space-y-3">
|
||||||
<div className="font-medium text-sm">LLM 配置</div>
|
<div className="font-medium text-sm">{t('llmConfigPanel.title')}</div>
|
||||||
{existing && (
|
{existing && (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
当前: {existing.base_url || '未设置'} / {existing.model || '未设置'} / {existing.has_key ? '已配置密钥' : '未配置密钥'}
|
{t('llmConfigPanel.current', {
|
||||||
|
baseUrl: existing.base_url || t('llmConfigPanel.notSet'),
|
||||||
|
model: existing.model || t('llmConfigPanel.notSet'),
|
||||||
|
keyStatus: existing.has_key ? t('llmConfigPanel.hasKey') : t('llmConfigPanel.noKey'),
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Input placeholder="Base URL (e.g. https://api.openai.com/v1)" value={baseUrl} onChange={e => setBaseUrl(e.target.value)} />
|
<Input placeholder="Base URL (e.g. https://api.openai.com/v1)" value={baseUrl} onChange={e => setBaseUrl(e.target.value)} />
|
||||||
<Input placeholder="API Key" type="password" value={apiKey} onChange={e => setApiKey(e.target.value)} />
|
<Input placeholder="API Key" type="password" value={apiKey} onChange={e => setApiKey(e.target.value)} />
|
||||||
<Input placeholder="Model (e.g. gpt-4o)" value={model} onChange={e => setModel(e.target.value)} />
|
<Input placeholder="Model (e.g. gpt-4o)" value={model} onChange={e => setModel(e.target.value)} />
|
||||||
<Button size="sm" onClick={handleSave} disabled={loading}>{loading ? '保存中...' : '保存配置'}</Button>
|
<Button size="sm" onClick={handleSave} disabled={loading}>
|
||||||
|
{loading ? t('llmConfigPanel.saving') : t('llmConfigPanel.save')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateProjectPanel({ onCreated }: { onCreated: () => void }) {
|
function CreateProjectPanel({ onCreated }: { onCreated: () => void }) {
|
||||||
|
const { t } = useTranslation('audiobook')
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [sourceType, setSourceType] = useState<'text' | 'epub'>('text')
|
const [sourceType, setSourceType] = useState<'text' | 'epub'>('text')
|
||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
@@ -282,9 +277,9 @@ function CreateProjectPanel({ onCreated }: { onCreated: () => void }) {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!title) { toast.error('请输入书名'); return }
|
if (!title) { toast.error(t('createPanel.titleRequired')); return }
|
||||||
if (sourceType === 'text' && !text) { toast.error('请输入文本内容'); return }
|
if (sourceType === 'text' && !text) { toast.error(t('createPanel.textRequired')); return }
|
||||||
if (sourceType === 'epub' && !epubFile) { toast.error('请选择 epub 文件'); return }
|
if (sourceType === 'epub' && !epubFile) { toast.error(t('createPanel.epubRequired')); return }
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
if (sourceType === 'text') {
|
if (sourceType === 'text') {
|
||||||
@@ -292,7 +287,7 @@ function CreateProjectPanel({ onCreated }: { onCreated: () => void }) {
|
|||||||
} else {
|
} else {
|
||||||
await audiobookApi.uploadEpub(title, epubFile!)
|
await audiobookApi.uploadEpub(title, epubFile!)
|
||||||
}
|
}
|
||||||
toast.success('项目已创建')
|
toast.success(t('createPanel.createdSuccess'))
|
||||||
setTitle(''); setText(''); setEpubFile(null)
|
setTitle(''); setText(''); setEpubFile(null)
|
||||||
onCreated()
|
onCreated()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -304,14 +299,18 @@ function CreateProjectPanel({ onCreated }: { onCreated: () => void }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border rounded-lg p-4 space-y-3">
|
<div className="border rounded-lg p-4 space-y-3">
|
||||||
<div className="font-medium text-sm">新建有声书项目</div>
|
<div className="font-medium text-sm">{t('createPanel.title')}</div>
|
||||||
<Input placeholder="书名" value={title} onChange={e => setTitle(e.target.value)} />
|
<Input placeholder={t('createPanel.titlePlaceholder')} value={title} onChange={e => setTitle(e.target.value)} />
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" variant={sourceType === 'text' ? 'default' : 'outline'} onClick={() => setSourceType('text')}>粘贴文本</Button>
|
<Button size="sm" variant={sourceType === 'text' ? 'default' : 'outline'} onClick={() => setSourceType('text')}>
|
||||||
<Button size="sm" variant={sourceType === 'epub' ? 'default' : 'outline'} onClick={() => setSourceType('epub')}>上传 epub</Button>
|
{t('createPanel.pasteText')}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant={sourceType === 'epub' ? 'default' : 'outline'} onClick={() => setSourceType('epub')}>
|
||||||
|
{t('createPanel.uploadEpub')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{sourceType === 'text' && (
|
{sourceType === 'text' && (
|
||||||
<Textarea placeholder="粘贴小说文本..." rows={6} value={text} onChange={e => setText(e.target.value)} />
|
<Textarea placeholder={t('createPanel.textPlaceholder')} rows={6} value={text} onChange={e => setText(e.target.value)} />
|
||||||
)}
|
)}
|
||||||
{sourceType === 'epub' && (
|
{sourceType === 'epub' && (
|
||||||
<Input type="file" accept=".epub" onChange={e => {
|
<Input type="file" accept=".epub" onChange={e => {
|
||||||
@@ -322,12 +321,15 @@ function CreateProjectPanel({ onCreated }: { onCreated: () => void }) {
|
|||||||
}
|
}
|
||||||
}} />
|
}} />
|
||||||
)}
|
)}
|
||||||
<Button size="sm" onClick={handleCreate} disabled={loading}>{loading ? '创建中...' : '创建项目'}</Button>
|
<Button size="sm" onClick={handleCreate} disabled={loading}>
|
||||||
|
{loading ? t('createPanel.creating') : t('createPanel.create')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefresh: () => void }) {
|
function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefresh: () => void }) {
|
||||||
|
const { t } = useTranslation('audiobook')
|
||||||
const [detail, setDetail] = useState<AudiobookProjectDetail | null>(null)
|
const [detail, setDetail] = useState<AudiobookProjectDetail | null>(null)
|
||||||
const [segments, setSegments] = useState<AudiobookSegment[]>([])
|
const [segments, setSegments] = useState<AudiobookSegment[]>([])
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
@@ -368,10 +370,10 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevStatusRef.current === 'generating' && project.status === 'done') {
|
if (prevStatusRef.current === 'generating' && project.status === 'done') {
|
||||||
toast.success(`「${project.title}」音频全部生成完成!`)
|
toast.success(t('projectCard.allDoneToast', { title: project.title }))
|
||||||
}
|
}
|
||||||
prevStatusRef.current = project.status
|
prevStatusRef.current = project.status
|
||||||
}, [project.status, project.title])
|
}, [project.status, project.title, t])
|
||||||
|
|
||||||
const hasParsingChapter = detail?.chapters.some(c => c.status === 'parsing') ?? false
|
const hasParsingChapter = detail?.chapters.some(c => c.status === 'parsing') ?? false
|
||||||
|
|
||||||
@@ -423,7 +425,7 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
const handleAnalyze = async () => {
|
const handleAnalyze = async () => {
|
||||||
const s = project.status
|
const s = project.status
|
||||||
if (['characters_ready', 'ready', 'done'].includes(s)) {
|
if (['characters_ready', 'ready', 'done'].includes(s)) {
|
||||||
if (!confirm('重新分析将清除所有角色和章节数据,确定继续?')) return
|
if (!confirm(t('projectCard.reanalyzeConfirm'))) return
|
||||||
}
|
}
|
||||||
autoExpandedRef.current.clear()
|
autoExpandedRef.current.clear()
|
||||||
setEditingCharId(null)
|
setEditingCharId(null)
|
||||||
@@ -431,7 +433,7 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
setIsPolling(true)
|
setIsPolling(true)
|
||||||
try {
|
try {
|
||||||
await audiobookApi.analyze(project.id, {})
|
await audiobookApi.analyze(project.id, {})
|
||||||
toast.success('分析已开始')
|
toast.success(t('projectCard.analyzeStarted'))
|
||||||
onRefresh()
|
onRefresh()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setIsPolling(false)
|
setIsPolling(false)
|
||||||
@@ -445,7 +447,7 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
setLoadingAction(true)
|
setLoadingAction(true)
|
||||||
try {
|
try {
|
||||||
await audiobookApi.confirmCharacters(project.id)
|
await audiobookApi.confirmCharacters(project.id)
|
||||||
toast.success('章节已识别')
|
toast.success(t('projectCard.confirm.chaptersRecognized'))
|
||||||
onRefresh()
|
onRefresh()
|
||||||
fetchDetail()
|
fetchDetail()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -458,7 +460,9 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
const handleParseChapter = async (chapterId: number, title?: string) => {
|
const handleParseChapter = async (chapterId: number, title?: string) => {
|
||||||
try {
|
try {
|
||||||
await audiobookApi.parseChapter(project.id, chapterId)
|
await audiobookApi.parseChapter(project.id, chapterId)
|
||||||
toast.success(title ? `「${title}」解析已开始` : '章节解析已开始')
|
toast.success(title
|
||||||
|
? t('projectCard.chapters.parseStarted', { title })
|
||||||
|
: t('projectCard.chapters.parseStartedDefault'))
|
||||||
fetchDetail()
|
fetchDetail()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(formatApiError(e))
|
toast.error(formatApiError(e))
|
||||||
@@ -474,7 +478,9 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await audiobookApi.generate(project.id, chapterIndex)
|
await audiobookApi.generate(project.id, chapterIndex)
|
||||||
toast.success(chapterIndex !== undefined ? `第 ${chapterIndex + 1} 章生成已开始` : '全书生成已开始')
|
toast.success(chapterIndex !== undefined
|
||||||
|
? t('projectCard.chapters.generateStarted', { index: chapterIndex + 1 })
|
||||||
|
: t('projectCard.chapters.generateAllStarted'))
|
||||||
onRefresh()
|
onRefresh()
|
||||||
fetchSegments()
|
fetchSegments()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -503,7 +509,7 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
...pending.map(c => audiobookApi.parseChapter(project.id, c.id)),
|
...pending.map(c => audiobookApi.parseChapter(project.id, c.id)),
|
||||||
...ready.map(c => audiobookApi.generate(project.id, c.chapter_index)),
|
...ready.map(c => audiobookApi.generate(project.id, c.chapter_index)),
|
||||||
])
|
])
|
||||||
toast.success('全部任务已触发')
|
toast.success(t('projectCard.chapters.processAllStarted'))
|
||||||
onRefresh()
|
onRefresh()
|
||||||
fetchDetail()
|
fetchDetail()
|
||||||
fetchSegments()
|
fetchSegments()
|
||||||
@@ -538,10 +544,10 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!confirm(`确认删除项目「${project.title}」及所有音频?`)) return
|
if (!confirm(t('projectCard.deleteConfirm', { title: project.title }))) return
|
||||||
try {
|
try {
|
||||||
await audiobookApi.deleteProject(project.id)
|
await audiobookApi.deleteProject(project.id)
|
||||||
toast.success('项目已删除')
|
toast.success(t('projectCard.deleteSuccess'))
|
||||||
onRefresh()
|
onRefresh()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(formatApiError(e))
|
toast.error(formatApiError(e))
|
||||||
@@ -563,12 +569,19 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
})
|
})
|
||||||
setEditingCharId(null)
|
setEditingCharId(null)
|
||||||
await fetchDetail()
|
await fetchDetail()
|
||||||
toast.success('角色已保存')
|
toast.success(t('projectCard.characters.savedSuccess'))
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(formatApiError(e))
|
toast.error(formatApiError(e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const genderLabel = (gender: string) => {
|
||||||
|
if (gender === '男') return t('projectCard.characters.genderMale')
|
||||||
|
if (gender === '女') return t('projectCard.characters.genderFemale')
|
||||||
|
if (gender === '未知') return t('projectCard.characters.genderUnknown')
|
||||||
|
return gender
|
||||||
|
}
|
||||||
|
|
||||||
const status = project.status
|
const status = project.status
|
||||||
const isActive = ['analyzing', 'generating'].includes(status)
|
const isActive = ['analyzing', 'generating'].includes(status)
|
||||||
const doneCount = segments.filter(s => s.status === 'done').length
|
const doneCount = segments.filter(s => s.status === 'done').length
|
||||||
@@ -584,7 +597,7 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
<Badge variant={(STATUS_COLORS[status] || 'secondary') as any}>
|
<Badge variant={(STATUS_COLORS[status] || 'secondary') as any}>
|
||||||
{STATUS_LABELS[status] || status}
|
{t(`status.${status}`, { defaultValue: status })}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => setExpanded(!expanded)}>
|
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => setExpanded(!expanded)}>
|
||||||
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
@@ -592,9 +605,9 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{STEP_HINTS[status] && (
|
{STEP_HINT_STATUSES.includes(status) && (
|
||||||
<div className="text-xs text-muted-foreground bg-muted/50 rounded px-3 py-2 border-l-2 border-primary/40">
|
<div className="text-xs text-muted-foreground bg-muted/50 rounded px-3 py-2 border-l-2 border-primary/40">
|
||||||
{STEP_HINTS[status]}
|
{t(`stepHints.${status}`)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -608,7 +621,9 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
|
|
||||||
{totalCount > 0 && doneCount > 0 && (
|
{totalCount > 0 && doneCount > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-xs text-muted-foreground">{doneCount} / {totalCount} 片段完成</div>
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t('projectCard.segmentsProgress', { done: doneCount, total: totalCount })}
|
||||||
|
</div>
|
||||||
<Progress value={progress} />
|
<Progress value={progress} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -623,17 +638,17 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
onClick={handleAnalyze}
|
onClick={handleAnalyze}
|
||||||
disabled={loadingAction}
|
disabled={loadingAction}
|
||||||
>
|
>
|
||||||
{status === 'pending' ? '分析' : '重新分析'}
|
{status === 'pending' ? t('projectCard.analyze') : t('projectCard.reanalyze')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{status === 'ready' && (
|
{status === 'ready' && (
|
||||||
<Button size="sm" className="h-7 text-xs px-2" onClick={() => handleGenerate()} disabled={loadingAction}>
|
<Button size="sm" className="h-7 text-xs px-2" onClick={() => handleGenerate()} disabled={loadingAction}>
|
||||||
生成全书
|
{t('projectCard.generateAll')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{status === 'done' && (
|
{status === 'done' && (
|
||||||
<Button size="sm" variant="outline" className="h-7 text-xs px-2" onClick={() => handleDownload()} disabled={loadingAction}>
|
<Button size="sm" variant="outline" className="h-7 text-xs px-2" onClick={() => handleDownload()} disabled={loadingAction}>
|
||||||
<Download className="h-3 w-3 mr-1" />下载全书
|
<Download className="h-3 w-3 mr-1" />{t('projectCard.downloadAll')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -651,7 +666,7 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
onClick={() => setCharsCollapsed(v => !v)}
|
onClick={() => setCharsCollapsed(v => !v)}
|
||||||
>
|
>
|
||||||
{charsCollapsed ? <ChevronDown className="h-3 w-3" /> : <ChevronUp className="h-3 w-3" />}
|
{charsCollapsed ? <ChevronDown className="h-3 w-3" /> : <ChevronUp className="h-3 w-3" />}
|
||||||
角色列表({detail.characters.length} 个)
|
{t('projectCard.characters.title', { count: detail.characters.length })}
|
||||||
</button>
|
</button>
|
||||||
{!charsCollapsed && <div className={`space-y-1.5 pr-1 ${editingCharId ? '' : 'max-h-72 overflow-y-auto'}`}>
|
{!charsCollapsed && <div className={`space-y-1.5 pr-1 ${editingCharId ? '' : 'max-h-72 overflow-y-auto'}`}>
|
||||||
{detail.characters.map(char => (
|
{detail.characters.map(char => (
|
||||||
@@ -661,34 +676,34 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
<Input
|
<Input
|
||||||
value={editFields.name}
|
value={editFields.name}
|
||||||
onChange={e => setEditFields(f => ({ ...f, name: e.target.value }))}
|
onChange={e => setEditFields(f => ({ ...f, name: e.target.value }))}
|
||||||
placeholder="角色名"
|
placeholder={t('projectCard.characters.namePlaceholder')}
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
className="w-full h-9 rounded-md border border-input bg-background px-3 py-1 text-sm"
|
className="w-full h-9 rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||||||
value={editFields.gender}
|
value={editFields.gender}
|
||||||
onChange={e => setEditFields(f => ({ ...f, gender: e.target.value }))}
|
onChange={e => setEditFields(f => ({ ...f, gender: e.target.value }))}
|
||||||
>
|
>
|
||||||
<option value="">性别(未设置)</option>
|
<option value="">{t('projectCard.characters.genderPlaceholder')}</option>
|
||||||
<option value="男">男</option>
|
<option value="男">{t('projectCard.characters.genderMale')}</option>
|
||||||
<option value="女">女</option>
|
<option value="女">{t('projectCard.characters.genderFemale')}</option>
|
||||||
<option value="未知">未知</option>
|
<option value="未知">{t('projectCard.characters.genderUnknown')}</option>
|
||||||
</select>
|
</select>
|
||||||
<Input
|
<Input
|
||||||
value={editFields.instruct}
|
value={editFields.instruct}
|
||||||
onChange={e => setEditFields(f => ({ ...f, instruct: e.target.value }))}
|
onChange={e => setEditFields(f => ({ ...f, instruct: e.target.value }))}
|
||||||
placeholder="音色描述(用于 TTS)"
|
placeholder={t('projectCard.characters.instructPlaceholder')}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={editFields.description}
|
value={editFields.description}
|
||||||
onChange={e => setEditFields(f => ({ ...f, description: e.target.value }))}
|
onChange={e => setEditFields(f => ({ ...f, description: e.target.value }))}
|
||||||
placeholder="角色描述"
|
placeholder={t('projectCard.characters.descPlaceholder')}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" onClick={() => saveEditChar(char)}>
|
<Button size="sm" onClick={() => saveEditChar(char)}>
|
||||||
<Check className="h-3 w-3 mr-1" />保存
|
<Check className="h-3 w-3 mr-1" />{t('common:save')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={() => setEditingCharId(null)}>
|
<Button size="sm" variant="ghost" onClick={() => setEditingCharId(null)}>
|
||||||
<X className="h-3 w-3 mr-1" />取消
|
<X className="h-3 w-3 mr-1" />{t('common:cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -698,15 +713,15 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
<span className="font-medium truncate">{char.name}</span>
|
<span className="font-medium truncate">{char.name}</span>
|
||||||
{char.gender && (
|
{char.gender && (
|
||||||
<Badge variant="outline" className={`text-xs shrink-0 ${char.gender === '男' ? 'border-blue-400/50 text-blue-400' : char.gender === '女' ? 'border-pink-400/50 text-pink-400' : 'border-muted-foreground/40 text-muted-foreground'}`}>
|
<Badge variant="outline" className={`text-xs shrink-0 ${char.gender === '男' ? 'border-blue-400/50 text-blue-400' : char.gender === '女' ? 'border-pink-400/50 text-pink-400' : 'border-muted-foreground/40 text-muted-foreground'}`}>
|
||||||
{char.gender}
|
{genderLabel(char.gender)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground truncate sm:mx-2 sm:flex-1">{char.instruct}</span>
|
<span className="text-xs text-muted-foreground truncate sm:mx-2 sm:flex-1">{char.instruct}</span>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{char.voice_design_id
|
{char.voice_design_id
|
||||||
? <Badge variant="outline" className="text-xs">音色 #{char.voice_design_id}</Badge>
|
? <Badge variant="outline" className="text-xs">{t('projectCard.characters.voiceDesign', { id: char.voice_design_id })}</Badge>
|
||||||
: <Badge variant="secondary" className="text-xs">未分配</Badge>
|
: <Badge variant="secondary" className="text-xs">{t('projectCard.characters.noVoice')}</Badge>
|
||||||
}
|
}
|
||||||
{status === 'characters_ready' && (
|
{status === 'characters_ready' && (
|
||||||
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => startEditChar(char)}>
|
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => startEditChar(char)}>
|
||||||
@@ -725,7 +740,7 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={loadingAction || editingCharId !== null}
|
disabled={loadingAction || editingCharId !== null}
|
||||||
>
|
>
|
||||||
{loadingAction ? '识别中...' : '确认角色 · 识别章节'}
|
{loadingAction ? t('projectCard.confirm.loading') : t('projectCard.confirm.button')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -739,7 +754,7 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
onClick={() => setChaptersCollapsed(v => !v)}
|
onClick={() => setChaptersCollapsed(v => !v)}
|
||||||
>
|
>
|
||||||
{chaptersCollapsed ? <ChevronDown className="h-3 w-3" /> : <ChevronUp className="h-3 w-3" />}
|
{chaptersCollapsed ? <ChevronDown className="h-3 w-3" /> : <ChevronUp className="h-3 w-3" />}
|
||||||
章节列表(共 {detail.chapters.length} 章)
|
{t('projectCard.chapters.title', { count: detail.chapters.length })}
|
||||||
</button>
|
</button>
|
||||||
{detail.chapters.some(c => ['pending', 'error', 'ready'].includes(c.status)) && (
|
{detail.chapters.some(c => ['pending', 'error', 'ready'].includes(c.status)) && (
|
||||||
<Button
|
<Button
|
||||||
@@ -748,7 +763,7 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
disabled={loadingAction}
|
disabled={loadingAction}
|
||||||
onClick={handleProcessAll}
|
onClick={handleProcessAll}
|
||||||
>
|
>
|
||||||
{loadingAction ? <Loader2 className="h-3 w-3 animate-spin" /> : '一键全部处理'}
|
{loadingAction ? <Loader2 className="h-3 w-3 animate-spin" /> : t('projectCard.chapters.processAll')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -759,7 +774,7 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
const chTotal = chSegs.length
|
const chTotal = chSegs.length
|
||||||
const chGenerating = chSegs.some(s => s.status === 'generating')
|
const chGenerating = chSegs.some(s => s.status === 'generating')
|
||||||
const chAllDone = chTotal > 0 && chDone === chTotal
|
const chAllDone = chTotal > 0 && chDone === chTotal
|
||||||
const chTitle = ch.title || `第 ${ch.chapter_index + 1} 章`
|
const chTitle = ch.title || t('projectCard.chapters.defaultTitle', { index: ch.chapter_index + 1 })
|
||||||
const chExpanded = expandedChapters.has(ch.id)
|
const chExpanded = expandedChapters.has(ch.id)
|
||||||
const toggleChExpand = () => setExpandedChapters(prev => {
|
const toggleChExpand = () => setExpandedChapters(prev => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
@@ -780,13 +795,13 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
<div className="flex items-center gap-1 flex-wrap">
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
{ch.status === 'pending' && (
|
{ch.status === 'pending' && (
|
||||||
<Button size="sm" variant="outline" className="h-6 text-xs px-2" onClick={() => handleParseChapter(ch.id, ch.title)}>
|
<Button size="sm" variant="outline" className="h-6 text-xs px-2" onClick={() => handleParseChapter(ch.id, ch.title)}>
|
||||||
解析此章
|
{t('projectCard.chapters.parse')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{ch.status === 'parsing' && (
|
{ch.status === 'parsing' && (
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
<span>解析中</span>
|
<span>{t('projectCard.chapters.parsing')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{ch.status === 'ready' && !chGenerating && !chAllDone && !generatingChapterIndices.has(ch.chapter_index) && (
|
{ch.status === 'ready' && !chGenerating && !chAllDone && !generatingChapterIndices.has(ch.chapter_index) && (
|
||||||
@@ -794,26 +809,28 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
setExpandedChapters(prev => { const n = new Set(prev); n.add(ch.id); return n })
|
setExpandedChapters(prev => { const n = new Set(prev); n.add(ch.id); return n })
|
||||||
handleGenerate(ch.chapter_index)
|
handleGenerate(ch.chapter_index)
|
||||||
}}>
|
}}>
|
||||||
生成此章
|
{t('projectCard.chapters.generate')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{ch.status === 'ready' && (chGenerating || generatingChapterIndices.has(ch.chapter_index)) && (
|
{ch.status === 'ready' && (chGenerating || generatingChapterIndices.has(ch.chapter_index)) && (
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
<span>{chDone}/{chTotal} 段</span>
|
<span>{t('projectCard.chapters.segmentProgress', { done: chDone, total: chTotal })}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{ch.status === 'ready' && chAllDone && (
|
{ch.status === 'ready' && chAllDone && (
|
||||||
<>
|
<>
|
||||||
<Badge variant="outline" className="text-xs">已完成 {chDone} 段</Badge>
|
<Badge variant="outline" className="text-xs">
|
||||||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={() => handleDownload(ch.chapter_index)} title="下载此章">
|
{t('projectCard.chapters.doneBadge', { count: chDone })}
|
||||||
|
</Badge>
|
||||||
|
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={() => handleDownload(ch.chapter_index)} title={t('projectCard.downloadAll')}>
|
||||||
<Download className="h-3 w-3" />
|
<Download className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{ch.status === 'error' && (
|
{ch.status === 'error' && (
|
||||||
<Button size="sm" variant="outline" className="h-6 text-xs px-2 text-destructive border-destructive/40" onClick={() => handleParseChapter(ch.id, ch.title)}>
|
<Button size="sm" variant="outline" className="h-6 text-xs px-2 text-destructive border-destructive/40" onClick={() => handleParseChapter(ch.id, ch.title)}>
|
||||||
重新解析
|
{t('projectCard.chapters.reparse')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -825,9 +842,11 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
{chSegs.map(seg => (
|
{chSegs.map(seg => (
|
||||||
<div key={seg.id} className={`py-2 space-y-1.5 ${sequentialPlayingId === seg.id ? 'bg-primary/5 px-1 rounded' : ''}`}>
|
<div key={seg.id} className={`py-2 space-y-1.5 ${sequentialPlayingId === seg.id ? 'bg-primary/5 px-1 rounded' : ''}`}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline" className="text-xs shrink-0">{seg.character_name || '?'}</Badge>
|
<Badge variant="outline" className="text-xs shrink-0">
|
||||||
|
{seg.character_name || t('projectCard.segments.unknownCharacter')}
|
||||||
|
</Badge>
|
||||||
{seg.status === 'generating' && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
{seg.status === 'generating' && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
||||||
{seg.status === 'error' && <Badge variant="destructive" className="text-xs">出错</Badge>}
|
{seg.status === 'error' && <Badge variant="destructive" className="text-xs">{t('projectCard.segments.errorBadge')}</Badge>}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground break-words leading-relaxed">{seg.text}</p>
|
<p className="text-xs text-muted-foreground break-words leading-relaxed">{seg.text}</p>
|
||||||
{seg.status === 'done' && (
|
{seg.status === 'done' && (
|
||||||
@@ -856,6 +875,7 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Audiobook() {
|
export default function Audiobook() {
|
||||||
|
const { t } = useTranslation('audiobook')
|
||||||
const [projects, setProjects] = useState<AudiobookProject[]>([])
|
const [projects, setProjects] = useState<AudiobookProject[]>([])
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
const [showLLM, setShowLLM] = useState(false)
|
const [showLLM, setShowLLM] = useState(false)
|
||||||
@@ -881,11 +901,11 @@ export default function Audiobook() {
|
|||||||
<Navbar />
|
<Navbar />
|
||||||
<main className="flex-1 container max-w-3xl mx-auto px-4 py-6 space-y-4">
|
<main className="flex-1 container max-w-3xl mx-auto px-4 py-6 space-y-4">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h1 className="text-xl sm:text-2xl font-bold">有声书生成</h1>
|
<h1 className="text-xl sm:text-2xl font-bold">{t('title')}</h1>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<Button size="sm" variant="outline" onClick={() => setShowLLM(!showLLM)}>LLM 配置</Button>
|
<Button size="sm" variant="outline" onClick={() => setShowLLM(!showLLM)}>{t('llmConfig')}</Button>
|
||||||
<Button size="sm" onClick={() => setShowCreate(!showCreate)}>
|
<Button size="sm" onClick={() => setShowCreate(!showCreate)}>
|
||||||
<Plus className="h-4 w-4 mr-1" />新建项目
|
<Plus className="h-4 w-4 mr-1" />{t('newProject')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="icon" variant="ghost" onClick={fetchProjects}>
|
<Button size="icon" variant="ghost" onClick={fetchProjects}>
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
@@ -897,12 +917,12 @@ export default function Audiobook() {
|
|||||||
{showCreate && <CreateProjectPanel onCreated={() => { setShowCreate(false); fetchProjects() }} />}
|
{showCreate && <CreateProjectPanel onCreated={() => { setShowCreate(false); fetchProjects() }} />}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center text-muted-foreground py-12">加载中...</div>
|
<div className="text-center text-muted-foreground py-12">{t('loading')}</div>
|
||||||
) : projects.length === 0 ? (
|
) : projects.length === 0 ? (
|
||||||
<div className="text-center text-muted-foreground py-12">
|
<div className="text-center text-muted-foreground py-12">
|
||||||
<Book className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
<Book className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||||
<p>暂无有声书项目</p>
|
<p>{t('noProjects')}</p>
|
||||||
<p className="text-sm mt-1">点击「新建项目」开始创建</p>
|
<p className="text-sm mt-1">{t('noProjectsHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user