2318 lines
118 KiB
TypeScript
2318 lines
118 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react'
|
||
import { useTranslation } from 'react-i18next'
|
||
import { toast } from 'sonner'
|
||
import { Book, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square, Pencil, Check, X, Loader2, Zap, Settings2, PanelLeftClose, PanelLeftOpen, Wand2, Volume2, Bot, Flame } from 'lucide-react'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Textarea } from '@/components/ui/textarea'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Progress } from '@/components/ui/progress'
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||
import { Navbar } from '@/components/Navbar'
|
||
import { AudioPlayer } from '@/components/AudioPlayer'
|
||
import { audiobookApi, type AudiobookProject, type AudiobookProjectDetail, type AudiobookCharacter, type AudiobookSegment, type ScriptGenerationRequest, type NsfwScriptGenerationRequest } from '@/lib/api/audiobook'
|
||
import { RotateCcw } from 'lucide-react'
|
||
import apiClient, { formatApiError, adminApi, authApi } from '@/lib/api'
|
||
import { useAuth } from '@/contexts/AuthContext'
|
||
|
||
function LazyAudioPlayer({ audioUrl, jobId }: { audioUrl: string; jobId: number }) {
|
||
const [visible, setVisible] = useState(false)
|
||
const ref = useRef<HTMLDivElement>(null)
|
||
useEffect(() => {
|
||
const el = ref.current
|
||
if (!el) return
|
||
const observer = new IntersectionObserver(
|
||
([entry]) => { if (entry.isIntersecting) { setVisible(true); observer.disconnect() } },
|
||
{ rootMargin: '120px' }
|
||
)
|
||
observer.observe(el)
|
||
return () => observer.disconnect()
|
||
}, [])
|
||
return <div ref={ref}>{visible && <AudioPlayer audioUrl={audioUrl} jobId={jobId} />}</div>
|
||
}
|
||
|
||
const STATUS_COLORS: Record<string, string> = {
|
||
pending: 'secondary',
|
||
analyzing: 'default',
|
||
characters_ready: 'default',
|
||
parsing: 'default',
|
||
processing: 'default',
|
||
ready: 'default',
|
||
generating: 'default',
|
||
done: 'outline',
|
||
error: 'destructive',
|
||
}
|
||
|
||
const STEP_HINT_STATUSES = ['pending', 'analyzing', 'characters_ready', 'ready', 'generating']
|
||
|
||
function SequentialPlayer({
|
||
segments,
|
||
projectId,
|
||
onPlayingChange,
|
||
}: {
|
||
segments: AudiobookSegment[]
|
||
projectId: number
|
||
onPlayingChange: (segmentId: number | null) => void
|
||
}) {
|
||
const { t } = useTranslation('audiobook')
|
||
const [displayIndex, setDisplayIndex] = useState<number | null>(null)
|
||
const [isLoading, setIsLoading] = useState(false)
|
||
const audioRef = useRef<HTMLAudioElement>(new Audio())
|
||
const blobUrlsRef = useRef<Record<number, string>>({})
|
||
const currentIndexRef = useRef<number | null>(null)
|
||
const doneSegments = segments.filter(s => s.status === 'done')
|
||
|
||
useEffect(() => {
|
||
const audio = audioRef.current
|
||
return () => {
|
||
audio.pause()
|
||
audio.src = ''
|
||
Object.values(blobUrlsRef.current).forEach(url => URL.revokeObjectURL(url))
|
||
}
|
||
}, [])
|
||
|
||
const stop = useCallback(() => {
|
||
audioRef.current.pause()
|
||
audioRef.current.src = ''
|
||
currentIndexRef.current = null
|
||
setDisplayIndex(null)
|
||
setIsLoading(false)
|
||
onPlayingChange(null)
|
||
}, [onPlayingChange])
|
||
|
||
const playSegment = useCallback(async (index: number) => {
|
||
if (index >= doneSegments.length) {
|
||
currentIndexRef.current = null
|
||
setDisplayIndex(null)
|
||
onPlayingChange(null)
|
||
return
|
||
}
|
||
const seg = doneSegments[index]
|
||
currentIndexRef.current = index
|
||
setDisplayIndex(index)
|
||
onPlayingChange(seg.id)
|
||
setIsLoading(true)
|
||
|
||
try {
|
||
if (!blobUrlsRef.current[seg.id]) {
|
||
const response = await apiClient.get(
|
||
audiobookApi.getSegmentAudioUrl(projectId, seg.id),
|
||
{ responseType: 'blob' }
|
||
)
|
||
blobUrlsRef.current[seg.id] = URL.createObjectURL(response.data)
|
||
}
|
||
const audio = audioRef.current
|
||
audio.src = blobUrlsRef.current[seg.id]
|
||
await audio.play()
|
||
} catch {
|
||
playSegment(index + 1)
|
||
} finally {
|
||
setIsLoading(false)
|
||
}
|
||
}, [doneSegments, projectId, onPlayingChange])
|
||
|
||
useEffect(() => {
|
||
const audio = audioRef.current
|
||
const handleEnded = () => {
|
||
if (currentIndexRef.current !== null) {
|
||
playSegment(currentIndexRef.current + 1)
|
||
}
|
||
}
|
||
audio.addEventListener('ended', handleEnded)
|
||
return () => audio.removeEventListener('ended', handleEnded)
|
||
}, [playSegment])
|
||
|
||
if (doneSegments.length === 0) return null
|
||
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
{displayIndex !== null ? (
|
||
<>
|
||
<Button size="sm" variant="outline" onClick={stop}>
|
||
<Square className="h-3 w-3 mr-1 fill-current" />{t('projectCard.sequential.stop')}
|
||
</Button>
|
||
<span className="text-xs text-muted-foreground">
|
||
{isLoading
|
||
? t('projectCard.sequential.loading')
|
||
: t('projectCard.sequential.progress', { current: displayIndex + 1, total: doneSegments.length })}
|
||
</span>
|
||
</>
|
||
) : (
|
||
<Button size="sm" variant="outline" onClick={() => playSegment(0)}>
|
||
<Play className="h-3 w-3 mr-1" />{t('projectCard.sequential.play', { count: doneSegments.length })}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function LogStream({ projectId, chapterId, active }: { projectId: number; chapterId?: number; active: boolean }) {
|
||
const [lines, setLines] = useState<string[]>([])
|
||
const [done, setDone] = useState(false)
|
||
const containerRef = useRef<HTMLDivElement>(null)
|
||
|
||
useEffect(() => {
|
||
if (!active) return
|
||
setLines([])
|
||
setDone(false)
|
||
|
||
const token = localStorage.getItem('token')
|
||
const apiBase = (import.meta.env.VITE_API_URL as string) || ''
|
||
const controller = new AbortController()
|
||
|
||
const chapterParam = chapterId !== undefined ? `?chapter_id=${chapterId}` : ''
|
||
fetch(`${apiBase}/audiobook/projects/${projectId}/logs${chapterParam}`, {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
signal: controller.signal,
|
||
}).then(async res => {
|
||
const reader = res.body?.getReader()
|
||
if (!reader) return
|
||
const decoder = new TextDecoder()
|
||
let buffer = ''
|
||
while (true) {
|
||
const { done: streamDone, value } = await reader.read()
|
||
if (streamDone) break
|
||
buffer += decoder.decode(value, { stream: true })
|
||
const parts = buffer.split('\n\n')
|
||
buffer = parts.pop() ?? ''
|
||
for (const part of parts) {
|
||
const line = part.trim()
|
||
if (!line.startsWith('data: ')) continue
|
||
try {
|
||
const msg = JSON.parse(line.slice(6))
|
||
if (msg.done) {
|
||
setDone(true)
|
||
} else if (typeof msg.index === 'number') {
|
||
setLines(prev => {
|
||
const next = [...prev]
|
||
next[msg.index] = msg.line
|
||
return next
|
||
})
|
||
}
|
||
} catch {}
|
||
}
|
||
}
|
||
}).catch(() => {})
|
||
|
||
return () => controller.abort()
|
||
}, [projectId, chapterId, active])
|
||
|
||
useEffect(() => {
|
||
const el = containerRef.current
|
||
if (el) el.scrollTop = el.scrollHeight
|
||
}, [lines])
|
||
|
||
if (lines.length === 0) return null
|
||
|
||
return (
|
||
<div ref={containerRef} className="rounded border border-green-900/40 bg-black/90 text-green-400 font-mono text-xs p-3 max-h-52 overflow-y-auto leading-relaxed">
|
||
{lines.map((line, i) => (
|
||
<div key={i} className="whitespace-pre-wrap">{line}</div>
|
||
))}
|
||
{!done && (
|
||
<span className="inline-block w-2 h-3 bg-green-400 animate-pulse ml-0.5 align-middle" />
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function LLMConfigDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||
const { t } = useTranslation('audiobook')
|
||
const [baseUrl, setBaseUrl] = useState('')
|
||
const [apiKey, setApiKey] = useState('')
|
||
const [model, setModel] = useState('')
|
||
const [loading, setLoading] = useState(false)
|
||
const [existing, setExisting] = useState<{ base_url?: string; model?: string; has_key: boolean } | null>(null)
|
||
|
||
useEffect(() => {
|
||
if (open) adminApi.getLLMConfig().then(setExisting).catch(() => {})
|
||
}, [open])
|
||
|
||
const handleSave = async () => {
|
||
if (!baseUrl || !apiKey || !model) {
|
||
toast.error(t('llmConfigPanel.incompleteError'))
|
||
return
|
||
}
|
||
setLoading(true)
|
||
try {
|
||
await adminApi.setLLMConfig({ base_url: baseUrl, api_key: apiKey, model })
|
||
toast.success(t('llmConfigPanel.savedSuccess'))
|
||
setApiKey('')
|
||
const updated = await adminApi.getLLMConfig()
|
||
setExisting(updated)
|
||
onClose()
|
||
} catch (e: any) {
|
||
toast.error(formatApiError(e))
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={v => { if (!v) onClose() }}>
|
||
<DialogContent className="sm:max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>{t('llmConfigPanel.title')}</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-3 pt-1">
|
||
{existing && (
|
||
<div className="text-xs text-muted-foreground">
|
||
{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>
|
||
)}
|
||
<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="Model (e.g. gpt-4o)" value={model} onChange={e => setModel(e.target.value)} />
|
||
<div className="flex justify-end gap-2 pt-1">
|
||
<Button size="sm" variant="outline" onClick={onClose} disabled={loading}>
|
||
{t('cancel', { defaultValue: '取消' })}
|
||
</Button>
|
||
<Button size="sm" onClick={handleSave} disabled={loading}>
|
||
{loading ? t('llmConfigPanel.saving') : t('llmConfigPanel.save')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
function CreateProjectDialog({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: () => void }) {
|
||
const { t } = useTranslation('audiobook')
|
||
const [title, setTitle] = useState('')
|
||
const [sourceType, setSourceType] = useState<'text' | 'epub'>('text')
|
||
const [text, setText] = useState('')
|
||
const [epubFile, setEpubFile] = useState<File | null>(null)
|
||
const [loading, setLoading] = useState(false)
|
||
|
||
const reset = () => { setTitle(''); setText(''); setEpubFile(null); setSourceType('text') }
|
||
|
||
const handleCreate = async () => {
|
||
if (!title) { toast.error(t('createPanel.titleRequired')); return }
|
||
if (sourceType === 'text' && !text) { toast.error(t('createPanel.textRequired')); return }
|
||
if (sourceType === 'epub' && !epubFile) { toast.error(t('createPanel.epubRequired')); return }
|
||
setLoading(true)
|
||
try {
|
||
if (sourceType === 'text') {
|
||
await audiobookApi.createProject({ title, source_type: 'text', source_text: text })
|
||
} else {
|
||
await audiobookApi.uploadEpub(title, epubFile!)
|
||
}
|
||
toast.success(t('createPanel.createdSuccess'))
|
||
reset()
|
||
onCreated()
|
||
onClose()
|
||
} catch (e: any) {
|
||
toast.error(formatApiError(e))
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={v => { if (!v) { reset(); onClose() } }}>
|
||
<DialogContent className="sm:max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle>{t('createPanel.title')}</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-3 pt-1">
|
||
<Input placeholder={t('createPanel.titlePlaceholder')} value={title} onChange={e => setTitle(e.target.value)} />
|
||
<div className="flex gap-2">
|
||
<Button size="sm" variant={sourceType === 'text' ? 'default' : 'outline'} onClick={() => setSourceType('text')}>
|
||
{t('createPanel.pasteText')}
|
||
</Button>
|
||
<Button size="sm" variant={sourceType === 'epub' ? 'default' : 'outline'} onClick={() => setSourceType('epub')}>
|
||
{t('createPanel.uploadEpub')}
|
||
</Button>
|
||
</div>
|
||
{sourceType === 'text' && (
|
||
<Textarea placeholder={t('createPanel.textPlaceholder')} rows={6} value={text} onChange={e => setText(e.target.value)} />
|
||
)}
|
||
{sourceType === 'epub' && (
|
||
<Input type="file" accept=".epub" onChange={e => {
|
||
const file = e.target.files?.[0] || null
|
||
setEpubFile(file)
|
||
if (file && !title) setTitle(file.name.replace(/\.epub$/i, ''))
|
||
}} />
|
||
)}
|
||
<div className="flex justify-end gap-2 pt-1">
|
||
<Button size="sm" variant="outline" onClick={() => { reset(); onClose() }} disabled={loading}>
|
||
{t('cancel', { defaultValue: '取消' })}
|
||
</Button>
|
||
<Button size="sm" onClick={handleCreate} disabled={loading}>
|
||
{loading ? t('createPanel.creating') : t('createPanel.create')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
interface SubgenreConfig {
|
||
protagonistTypes: string[]
|
||
tones: string[]
|
||
conflictScales: string[]
|
||
}
|
||
|
||
interface GenreGroup {
|
||
label: string
|
||
subgenres: Record<string, SubgenreConfig>
|
||
}
|
||
|
||
const GENRE_CONFIGS: Record<string, GenreGroup> = {
|
||
'玄幻': {
|
||
label: '玄幻',
|
||
subgenres: {
|
||
'高武玄幻': { protagonistTypes: ['热血少年', '落魄天才', '废材逆袭', '穿越者', '系统宿主', '天生废体'], tones: ['热血激情', '升级爽文', '黑暗血腥', '轻松搞笑', '史诗宏大'], conflictScales: ['门派争斗', '宗门战争', '诸天争霸', '个人成长', '复仇雪耻'] },
|
||
'修仙升级': { protagonistTypes: ['散修弟子', '资质平庸者', '天才修炼者', '转世神魂', '炼丹师', '剑修'], tones: ['清修飘逸', '热血激战', '奇幻神秘', '恩怨情仇', '历练成长'], conflictScales: ['渡劫飞升', '门派恩怨', '天道之争', '仙魔对决', '灵脉争夺'] },
|
||
'洪荒流': { protagonistTypes: ['洪荒神兽', '人族菜鸡', '太古妖族', '上古大能', '仙界强者', '混沌生灵'], tones: ['宏大史诗', '爽文热血', '阴谋算计', '轻松恶搞', '诸圣博弈'], conflictScales: ['洪荒争霸', '诸圣博弈', '天道争夺', '人族崛起', '封神大战'] },
|
||
'诸天万界': { protagonistTypes: ['穿梭者', '任务系统宿主', '时空旅行者', '多界强者', '轮回者'], tones: ['热血爽文', '智谋流', '搞笑无厘头', '严肃史诗', '全能碾压'], conflictScales: ['完成任务', '诸界争霸', '宇宙级威胁', '推翻天道', '救世传承'] },
|
||
'异世大陆': { protagonistTypes: ['穿越者', '异世贵族', '平民逆袭', '战神转世', '魔法天才', '剑神传人'], tones: ['热血冒险', '宫廷权谋', '温馨治愈', '黑暗复仇', '轻松奇幻'], conflictScales: ['王国争霸', '异族入侵', '魔兽泛滥', '政治阴谋', '个人成长'] },
|
||
},
|
||
},
|
||
'武侠': {
|
||
label: '武侠',
|
||
subgenres: {
|
||
'传统武侠': { protagonistTypes: ['江湖侠客', '名门弟子', '落魄世家子', '武学天才', '复仇者', '隐世高人'], tones: ['豪情壮志', '快意恩仇', '爱恨情仇', '正邪对决', '侠义热血'], conflictScales: ['门派之争', '江湖恩怨', '家仇国恨', '武林正邪', '武功秘籍争夺'] },
|
||
'奇幻武侠': { protagonistTypes: ['习得神功者', '寻宝冒险者', '侠义少年', '隐世高手', '机缘得道者'], tones: ['奇幻冒险', '武侠浪漫', '热血激战', '神秘探索', '奇遇成长'], conflictScales: ['神兵寻觅', '秘籍争夺', '奇遇成长', '救世任务', '阵法宝物'] },
|
||
'现代武侠': { protagonistTypes: ['隐居高手', '都市侠客', '特种兵出身者', '传承者', '武道研究者'], tones: ['现代都市感', '热血写实', '轻松幽默', '动作爽快', '家国情怀'], conflictScales: ['维护正义', '黑道对抗', '传承保护', '功夫切磋', '古武势力'] },
|
||
},
|
||
},
|
||
'仙侠': {
|
||
label: '仙侠',
|
||
subgenres: {
|
||
'东方修真': { protagonistTypes: ['凡人弟子', '仙门弟子', '落魄仙人', '天骄后裔', '妖族转化', '古神血脉'], tones: ['清逸飘渺', '热血激战', '唯美浪漫', '奇幻神秘', '历练成长'], conflictScales: ['渡劫飞升', '仙魔大战', '道侣情缘', '天机命运', '宗门争霸'] },
|
||
'剑仙传说': { protagonistTypes: ['剑道天才', '剑修散仙', '古剑传人', '剑灵融合者', '剑心通明者'], tones: ['飘逸洒脱', '剑意凛冽', '纵横江湖', '诗意浪漫', '独孤求败'], conflictScales: ['剑道证道', '至宝争夺', '仙凡之别', '剑与心境', '天下第一'] },
|
||
'斩妖除魔': { protagonistTypes: ['除魔使者', '天师传人', '驱魔术士', '正道大侠', '神选之人'], tones: ['热血正义', '悬疑恐怖', '神话奇幻', '黑暗压抑', '守护信念'], conflictScales: ['妖魔肆虐', '鬼怪作乱', '邪道崛起', '地狱封印', '天地失衡'] },
|
||
},
|
||
},
|
||
'现代言情': {
|
||
label: '现代言情',
|
||
subgenres: {
|
||
'甜宠': { protagonistTypes: ['普通女孩', '天真少女', '职场新人', '邻家女孩', '意外相遇者'], tones: ['甜蜜温馨', '欢笑爱情', '轻松浪漫', '治愈温暖', '撒糖日常'], conflictScales: ['误会化解', '追爱历程', '霸总求爱', '青涩初恋', '双向暗恋'] },
|
||
'虐恋': { protagonistTypes: ['坚强女主', '深情错付者', '误解中的恋人', '身份悬殊者', '宿命相遇者'], tones: ['虐心煎熬', '深情执着', '痛苦成长', '悲剧唯美', '破镜重圆'], conflictScales: ['身世误解', '爱而不得', '命运阻隔', '家族反对', '情感纠葛'] },
|
||
'婚姻生活': { protagonistTypes: ['已婚夫妻', '闪婚陌生人', '失婚重来者', '中年危机者', '再婚家庭成员'], tones: ['温馨现实', '矛盾磨合', '生活感悟', '烟火气息', '相濡以沫'], conflictScales: ['婚姻危机', '重建感情', '第三者风波', '家庭矛盾', '育儿分歧'] },
|
||
'青春校园': { protagonistTypes: ['高中生', '大学生', '校园社团成员', '学霸', '运动员'], tones: ['青涩懵懂', '阳光活力', '笑泪交织', '追梦热血', '暗恋悸动'], conflictScales: ['学习压力', '暗恋表白', '友情裂痕', '家庭变故', '比赛竞争'] },
|
||
},
|
||
},
|
||
'都市': {
|
||
label: '都市',
|
||
subgenres: {
|
||
'系统流': { protagonistTypes: ['普通小人物', '穿越重生者', '选中之人', '落魄天才', '被系统选中者'], tones: ['爽文升级', '轻松幽默', '热血励志', '全能碾压', '种田发展'], conflictScales: ['系统任务', '快速崛起', '实力碾压', '逆袭人生', '征服巅峰'] },
|
||
'商战': { protagonistTypes: ['商界精英', '白手起家者', '家族继承人', '职场新人', '商业奇才'], tones: ['紧张激烈', '智谋对抗', '励志奋斗', '尔虞我诈', '商业传奇'], conflictScales: ['商业竞争', '家族权斗', '职场博弈', '资本角力', '企业并购'] },
|
||
'重生逆袭': { protagonistTypes: ['重生者', '回到过去者', '带着记忆重来者', '复仇归来者', '先知者'], tones: ['爽文复仇', '轻松改变命运', '深情弥补', '步步为营', '大杀四方'], conflictScales: ['前世遗恨', '命运改写', '天才崛起', '恩仇清算', '守护挚爱'] },
|
||
'官场': { protagonistTypes: ['基层公务员', '仕途奋斗者', '清官强者', '改革者', '下乡干部'], tones: ['权谋斗争', '写实主义', '正义坚守', '仕途险恶', '为民请命'], conflictScales: ['官场倾轧', '腐败反贪', '仕途升迁', '民生为先', '政治博弈'] },
|
||
},
|
||
},
|
||
'悬疑': {
|
||
label: '悬疑',
|
||
subgenres: {
|
||
'推理侦探': { protagonistTypes: ['警探', '法医', '业余侦探', '记者', '侦探助手', '神探'], tones: ['烧脑推理', '扣人心弦', '悬念丛生', '冷静理性', '层层剥茧'], conflictScales: ['连环命案', '密室谜题', '真相揭露', '罪犯追踪', '系列悬案'] },
|
||
'犯罪惊悚': { protagonistTypes: ['警察', '罪犯视角', '受害者幸存者', '卧底', '特工'], tones: ['黑暗紧张', '写实血腥', '心理较量', '暗流涌动', '生死边缘'], conflictScales: ['犯罪追击', '身份揭露', '背叛与救赎', '生死博弈', '组织渗透'] },
|
||
'灵异恐怖': { protagonistTypes: ['普通人', '灵异体质者', '道士法师', '鬼怪', '民俗学者'], tones: ['恐怖诡异', '悬疑惊悚', '志怪神秘', '民俗风情', '冷汗直冒'], conflictScales: ['恶灵纠缠', '冤情揭露', '鬼怪作祟', '阴阳两界', '封印破解'] },
|
||
'心理悬疑': { protagonistTypes: ['心理咨询师', '精神科医生', '犯罪侧写师', '创伤幸存者', '不可靠叙述者'], tones: ['心理压迫', '黑暗深沉', '哲思反思', '人性剖析', '迷雾重重'], conflictScales: ['心魔挣脱', '人格分裂', '记忆迷失', '操控与反制', '真相解谜'] },
|
||
},
|
||
},
|
||
'科幻': {
|
||
label: '科幻',
|
||
subgenres: {
|
||
'星际战争': { protagonistTypes: ['星际舰长', '星际士兵', '精英飞行员', '星际难民', '外星协调者'], tones: ['史诗宏大', '战争残酷', '科技未来', '人性探索', '军事硬派'], conflictScales: ['星际战争', '殖民冲突', '物种灭绝威胁', '宇宙联邦争霸', '文明碰撞'] },
|
||
'末世废土': { protagonistTypes: ['末世幸存者', '变异人类', '反抗组织领袖', '科学家', '拾荒者'], tones: ['黑暗压抑', '生存挣扎', '希望微光', '残酷现实', '人性测试'], conflictScales: ['末世求生', '物资争夺', '反抗统治', '文明重建', '变异扩散'] },
|
||
'赛博朋克': { protagonistTypes: ['黑客', '赛博战士', '企业异见者', '增强人类', '数字游民'], tones: ['科技反乌托邦', '阴暗迷幻', '反体制', '霓虹暗夜', '数字哲学'], conflictScales: ['数据战争', '企业阴谋', '人机界限', '身份认同', '系统颠覆'] },
|
||
'时间旅行': { protagonistTypes: ['时间旅行者', '时间管理局成员', '意外穿越者', '历史修正者', '时间悖论受害者'], tones: ['烧脑悬疑', '蝴蝶效应', '时间悖论', '浪漫跨时代', '命运游戏'], conflictScales: ['历史修正', '时间线保护', '因果悖论', '多重未来', '时间战争'] },
|
||
'人工智能': { protagonistTypes: ['AI研究员', '觉醒AI', '人机协作者', '反AI组织成员', '数字意识体'], tones: ['哲学反思', '科技惊悚', '情感探索', '伦理困境', '奇点到来'], conflictScales: ['AI觉醒', '人机战争', '意识复制', '伦理边界', '数字文明'] },
|
||
},
|
||
},
|
||
'历史': {
|
||
label: '历史',
|
||
subgenres: {
|
||
'架空历史': { protagonistTypes: ['穿越者', '改变历史者', '默默无闻小人物', '穿越官员', '现代人'], tones: ['宏大史诗', '权谋斗争', '家国情怀', '轻松穿越', '历史重构'], conflictScales: ['朝代更迭', '权力争夺', '外族入侵', '历史改变', '改革维新'] },
|
||
'宫廷斗争': { protagonistTypes: ['妃嫔', '皇子', '太监谋士', '宫女逆袭者', '皇后'], tones: ['心机深沉', '权谋博弈', '悲情凄美', '步步惊心', '宫廷浮沉'], conflictScales: ['后宫争宠', '皇位争夺', '废立之争', '家族联姻', '宫廷政变'] },
|
||
'战争史诗': { protagonistTypes: ['将领', '士兵', '谋士', '王侯', '战地医者'], tones: ['悲壮雄浑', '铁血热血', '英雄主义', '战争残酷', '家国大义'], conflictScales: ['国家存亡', '统一战争', '抗击外敌', '乱世争雄', '以少胜多'] },
|
||
'历史正剧': { protagonistTypes: ['历史名人', '草根智者', '政治家', '民间义士', '文人墨客'], tones: ['厚重写实', '人文感悟', '历史还原', '时代悲歌', '英雄气节'], conflictScales: ['时代变革', '个人命运', '历史洪流', '民族大义', '文化传承'] },
|
||
},
|
||
},
|
||
'恐怖': {
|
||
label: '恐怖',
|
||
subgenres: {
|
||
'都市灵异': { protagonistTypes: ['普通市民', '灵异体质者', '鬼怪猎人', '民俗研究者', '意外卷入者'], tones: ['惊悚诡异', '都市传说', '黑暗压抑', '悬疑恐惧', '无处可逃'], conflictScales: ['都市怪谈', '恶灵作祟', '冤魂纠缠', '超自然事件', '禁忌揭秘'] },
|
||
'克苏鲁': { protagonistTypes: ['学者研究者', '猎奇者', '受选者', '意志坚强者', '古神信徒'], tones: ['宇宙级恐惧', '理智崩溃', '末日预感', '深渊凝视', '存在主义恐怖'], conflictScales: ['禁忌召唤', '理性崩溃', '古神苏醒', '深渊凝视', '人类渺小'] },
|
||
'鬼屋探险': { protagonistTypes: ['探险者', '灵媒', '记者', '大胆学生', '真相追寻者'], tones: ['极度紧张', '恐怖刺激', '生死挣扎', '诡异氛围', '密室求生'], conflictScales: ['怨灵诅咒', '脱离险境', '真相揭露', '诡异机关', '黑暗守护者'] },
|
||
'丧尸末日': { protagonistTypes: ['末日幸存者', '军人', '科学家', '普通家庭', '幸运儿'], tones: ['生存惊悚', '人性黑暗', '绝望与希望', '末日互助', '道德崩塌'], conflictScales: ['丧尸追击', '幸存者冲突', '疫苗寻找', '安全区争夺', '文明坚守'] },
|
||
},
|
||
},
|
||
'Fantasy': {
|
||
label: 'Fantasy',
|
||
subgenres: {
|
||
'High Fantasy': { protagonistTypes: ['Chosen One', 'Magic User', 'Knight/Warrior', 'Royal Heir', 'Common Hero', 'Prophesied One'], tones: ['Epic', 'Heroic', 'Noble', 'Mythic', 'Adventure'], conflictScales: ['Personal Quest', 'Kingdom-wide', 'World-saving', 'Good vs Evil', 'Political Intrigue'] },
|
||
'Dark Fantasy': { protagonistTypes: ['Antihero', 'Cursed Individual', 'Monster Hunter', 'Corrupted Noble', 'Reluctant Chosen One', 'Morally Gray Wizard'], tones: ['Grim', 'Morally Ambiguous', 'Horror-tinged', 'Psychological', 'Brutal'], conflictScales: ['Personal Survival', 'Moral Choice', 'Power Corruption', 'Existential Horror', 'Societal Decay'] },
|
||
'Urban Fantasy': { protagonistTypes: ['Magical Detective', 'Hidden World Guardian', 'Modern Witch/Wizard', 'Supernatural Creature', 'Normal Person Discovered', 'Magic Shop Owner'], tones: ['Modern', 'Mysterious', 'Action-packed', 'Detective Noir', 'Secret World'], conflictScales: ['Personal', 'City-wide', 'Hidden Society', 'Supernatural Politics', 'World-threatening'] },
|
||
'Sword and Sorcery': { protagonistTypes: ['Warrior', 'Rogue', 'Barbarian', 'Mercenary', 'Wandering Wizard', 'Treasure Hunter'], tones: ['Adventurous', 'Gritty', 'Action-focused', 'Pulp', 'Personal'], conflictScales: ['Personal Gain', 'Adventure', 'Survival', 'Quest', 'Local Threat'] },
|
||
'Mythic Fantasy': { protagonistTypes: ['Demigod', 'Legendary Hero', 'Oracle', 'Divine Champion', 'Monster Slayer', 'Mythical Creature'], tones: ['Legendary', 'Epic', 'Mythological', 'Divine', 'Larger than Life'], conflictScales: ['Divine Politics', 'Legendary Quests', 'Fate of Gods', 'Mythic Prophecy', 'World-shaping'] },
|
||
'Fairy Tale': { protagonistTypes: ['Innocent Hero', 'Clever Trickster', 'Transformed Being', 'Royal Figure', 'Magical Helper', 'Common Person'], tones: ['Whimsical', 'Moral', 'Magical', 'Traditional', 'Transformative'], conflictScales: ['Personal Journey', 'Moral Test', 'Magical Challenge', 'Kingdom Fate', 'Breaking Curses'] },
|
||
},
|
||
},
|
||
'Sci-Fi': {
|
||
label: 'Sci-Fi',
|
||
subgenres: {
|
||
'Space Opera': { protagonistTypes: ['Military Officer', 'Merchant Captain', 'Explorer', 'Diplomat', 'Rebel Leader', 'Imperial Noble'], tones: ['Optimistic', 'Dark', 'Political', 'Adventure-focused', 'Character-driven'], conflictScales: ['Personal', 'Planetary', 'Interstellar', 'Galactic', 'Species Survival'] },
|
||
'Cyberpunk': { protagonistTypes: ['Hacker', 'Street Mercenary', 'Corporate Defector', 'AI Researcher', 'Underground Activist', 'Augmented Human'], tones: ['Noir', 'Gritty', 'Anti-establishment', 'Dystopian', 'Tech-noir'], conflictScales: ['Personal', 'Street Level', 'Corporate', 'Systemic', 'Digital'] },
|
||
'Post-Apocalyptic': { protagonistTypes: ['Survivor', 'Wasteland Warrior', 'Community Leader', 'Scavenger', 'Medic', 'Former Military'], tones: ['Grim', 'Survival-focused', 'Hope in Darkness', 'Brutal', 'Revolutionary'], conflictScales: ['Personal Survival', 'Group Survival', 'Resource Control', 'Territory', 'Rebuilding Society'] },
|
||
'Hard Sci-Fi': { protagonistTypes: ['Scientist', 'Engineer', 'Astronaut', 'Research Team Leader', 'AI Researcher', 'Technical Specialist'], tones: ['Technical', 'Philosophical', 'Discovery-focused', 'Methodical', 'Realistic'], conflictScales: ['Personal', 'Technical', 'Scientific Discovery', 'Environmental', 'Existential'] },
|
||
'Biopunk': { protagonistTypes: ['Genetic Engineer', 'Modified Human', 'Underground Scientist', 'Corporate Whistleblower', 'Bio-hacker', 'Test Subject'], tones: ['Body Horror', 'Ethical Drama', 'Scientific', 'Anti-corporate', 'Transformative'], conflictScales: ['Personal', 'Medical', 'Ethical', 'Corporate', 'Species-wide'] },
|
||
'Time Travel': { protagonistTypes: ['Time Agent', 'Accidental Traveler', 'Historical Researcher', 'Timeline Guardian', 'Temporal Engineer'], tones: ['Complex', 'Mysterious', 'Philosophical', 'Adventure', 'Causality-focused'], conflictScales: ['Personal', 'Historical', 'Timeline Preservation', 'Paradox Prevention', 'Multi-temporal'] },
|
||
},
|
||
},
|
||
'Mystery': {
|
||
label: 'Mystery',
|
||
subgenres: {
|
||
'Cozy Mystery': { protagonistTypes: ['Amateur Detective', 'Librarian', 'Shop Owner', 'Retired Professional', 'Local Resident', 'Hobby Enthusiast'], tones: ['Gentle', 'Puzzle-focused', 'Community-centered', 'Cozy', 'Character-driven'], conflictScales: ['Personal Mystery', 'Community Secret', 'Local Crime', 'Family Mystery', 'Historical Puzzle'] },
|
||
'Police Procedural': { protagonistTypes: ['Police Detective', 'Forensic Specialist', 'Police Captain', 'Crime Scene Investigator', 'FBI Agent'], tones: ['Realistic', 'Procedural', 'Professional', 'Methodical', 'Team-focused'], conflictScales: ['Individual Cases', 'Serial Crimes', 'Organized Crime', 'Corruption', 'Major Investigations'] },
|
||
'Hard-boiled': { protagonistTypes: ['Private Detective', 'Ex-Cop', 'Cynical Investigator', 'Tough Guy', 'Street-smart Detective', 'Noir Hero'], tones: ['Noir', 'Cynical', 'Gritty', 'Dark', 'Atmospheric'], conflictScales: ['Personal Cases', 'Corruption', 'Urban Crime', 'Moral Choices', 'Survival'] },
|
||
'Psychological Mystery': { protagonistTypes: ['Psychologist', 'Troubled Detective', 'Mental Health Professional', 'Unreliable Narrator', 'Psychological Profiler'], tones: ['Psychological', 'Introspective', 'Mind-bending', 'Complex', 'Character-focused'], conflictScales: ['Mental Mysteries', 'Psychological Crimes', 'Identity Issues', 'Memory Problems', 'Perception Puzzles'] },
|
||
'Historical Mystery': { protagonistTypes: ['Period Detective', 'Historical Figure', 'Scholar', 'Period Professional', 'Aristocrat', 'Common Person'], tones: ['Historical', 'Authentic', 'Period-appropriate', 'Cultural', 'Educational'], conflictScales: ['Personal Mysteries', 'Historical Events', 'Period Crimes', 'Social Issues', 'Political Intrigue'] },
|
||
},
|
||
},
|
||
'Horror': {
|
||
label: 'Horror',
|
||
subgenres: {
|
||
'Gothic Horror': { protagonistTypes: ['Haunted Individual', 'Investigator', 'Innocent Victim', 'Cursed Person', 'Gothic Hero', 'Tormented Soul'], tones: ['Atmospheric', 'Psychological', 'Brooding', 'Mysterious', 'Melancholic'], conflictScales: ['Personal Haunting', 'Family Curse', 'Supernatural Threat', 'Psychological Terror', 'Ancient Evil'] },
|
||
'Cosmic Horror': { protagonistTypes: ['Academic Researcher', 'Occult Investigator', 'Unwitting Scholar', 'Cosmic Witness', 'Doomed Explorer', 'Sanity-threatened Individual'], tones: ['Existential', 'Unknowable', 'Cosmic', 'Dread-filled', 'Mind-breaking'], conflictScales: ['Cosmic Revelation', 'Sanity Destruction', 'Reality Breakdown', 'Ancient Awakening', 'Existential Horror'] },
|
||
'Psychological Horror': { protagonistTypes: ['Unreliable Narrator', 'Mentally Unstable', 'Paranoid Individual', 'Trauma Victim', 'Isolated Person'], tones: ['Psychological', 'Disturbing', 'Mind-bending', 'Paranoid', 'Introspective'], conflictScales: ['Mental Breakdown', 'Reality Distortion', 'Psychological Manipulation', 'Internal Terror', 'Sanity Loss'] },
|
||
'Supernatural Horror': { protagonistTypes: ['Paranormal Investigator', 'Haunted Individual', 'Psychic Medium', 'Skeptical Researcher', 'Spiritual Warrior', 'Innocent Victim'], tones: ['Supernatural', 'Eerie', 'Spiritual', 'Otherworldly', 'Paranormal'], conflictScales: ['Ghostly Haunting', 'Demonic Possession', 'Spiritual Warfare', 'Paranormal Investigation', 'Supernatural Threat'] },
|
||
'Slasher Horror': { protagonistTypes: ['Final Girl', 'Survivor', 'Potential Victim', 'Group Member', 'Resourceful Fighter'], tones: ['Suspenseful', 'Violent', 'Survival-focused', 'Intense', 'Action-horror'], conflictScales: ['Survival', 'Killer Hunt', 'Group Elimination', 'Escape Attempt', 'Final Confrontation'] },
|
||
},
|
||
},
|
||
'Thriller': {
|
||
label: 'Thriller',
|
||
subgenres: {
|
||
'Espionage Thriller': { protagonistTypes: ['Secret Agent', 'Intelligence Officer', 'Double Agent', 'Spy Handler', 'Undercover Operative', 'Government Analyst'], tones: ['Sophisticated', 'International', 'High-stakes', 'Political', 'Covert'], conflictScales: ['International Conspiracy', 'Government Secrets', 'Spy Networks', 'National Security', 'Global Politics'] },
|
||
'Psychological Thriller': { protagonistTypes: ['Psychologist', 'Mentally Unstable', 'Manipulation Victim', 'Paranoid Individual', 'Mind Game Player', 'Psychological Profiler'], tones: ['Psychological', 'Mind-bending', 'Paranoid', 'Manipulative', 'Disturbing'], conflictScales: ['Mental Manipulation', 'Psychological Torture', 'Mind Control', 'Paranoid Delusions', 'Reality Breakdown'] },
|
||
'Action Thriller': { protagonistTypes: ['Action Hero', 'Special Forces', 'Mercenary', 'Bodyguard', 'Martial Artist'], tones: ['High-energy', 'Action-packed', 'Adrenaline-fueled', 'Physical', 'Fast-paced'], conflictScales: ['Physical Confrontation', 'High-speed Chases', 'Combat Situations', 'Rescue Missions', 'Survival Scenarios'] },
|
||
'Legal Thriller': { protagonistTypes: ['Lawyer', 'Judge', 'Legal Investigator', 'Prosecutor', 'Defense Attorney', 'Legal Whistleblower'], tones: ['Legal', 'Procedural', 'Justice-focused', 'Institutional', 'Courtroom-driven'], conflictScales: ['Legal Conspiracy', 'Courtroom Drama', 'Justice Corruption', 'Legal Cover-up', 'Judicial Manipulation'] },
|
||
'Techno-Thriller': { protagonistTypes: ['Computer Expert', 'Cyber Security Specialist', 'Tech Entrepreneur', 'Hacker', 'Scientist', 'Digital Investigator'], tones: ['High-tech', 'Fast-paced', 'Technical', 'Futuristic', 'Digital'], conflictScales: ['Cyber Attacks', 'Technological Threats', 'Digital Warfare', 'Scientific Disasters', 'Tech Conspiracies'] },
|
||
'Political Thriller': { protagonistTypes: ['Politician', 'Journalist', 'Government Insider', 'Whistleblower', 'Political Aide', 'Investigative Reporter'], tones: ['Political', 'Investigative', 'Institutional', 'Conspiratorial', 'Power-focused'], conflictScales: ['Political Corruption', 'Government Conspiracy', 'Electoral Manipulation', 'Institutional Cover-up', 'Power Abuse'] },
|
||
},
|
||
},
|
||
}
|
||
|
||
function Chip({ label, selected, onClick }: { label: string; selected: boolean; onClick: () => void }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className={`px-2.5 py-1 rounded-full text-xs border transition-colors ${
|
||
selected
|
||
? 'bg-primary text-primary-foreground border-primary'
|
||
: 'bg-background text-muted-foreground border-border hover:border-primary/50 hover:text-foreground'
|
||
}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function AIScriptDialog({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: () => void }) {
|
||
const [title, setTitle] = useState('')
|
||
const [genre, setGenre] = useState('')
|
||
const [subgenre, setSubgenre] = useState('')
|
||
const [protagonistType, setProtagonistType] = useState('')
|
||
const [tone, setTone] = useState('')
|
||
const [conflictScale, setConflictScale] = useState('')
|
||
const [numCharacters, setNumCharacters] = useState(5)
|
||
const [numChapters, setNumChapters] = useState(8)
|
||
const [synopsis, setSynopsis] = useState('')
|
||
const [generatingSynopsis, setGeneratingSynopsis] = useState(false)
|
||
const [loading, setLoading] = useState(false)
|
||
|
||
const genreKeys = Object.keys(GENRE_CONFIGS)
|
||
const subgenreKeys = genre ? Object.keys(GENRE_CONFIGS[genre]?.subgenres ?? {}) : []
|
||
const subgenreConfig = (genre && subgenre) ? GENRE_CONFIGS[genre]?.subgenres[subgenre] : null
|
||
|
||
const reset = () => {
|
||
setTitle(''); setGenre(''); setSubgenre(''); setProtagonistType(''); setTone('')
|
||
setConflictScale(''); setNumCharacters(5); setNumChapters(8); setSynopsis('')
|
||
}
|
||
|
||
const handleGenreSelect = (g: string) => {
|
||
setGenre(g); setSubgenre(''); setProtagonistType(''); setTone(''); setConflictScale(''); setSynopsis('')
|
||
}
|
||
const handleSubgenreSelect = (s: string) => {
|
||
setSubgenre(s); setProtagonistType(''); setTone(''); setConflictScale(''); setSynopsis('')
|
||
}
|
||
|
||
const handleGenerateSynopsis = async () => {
|
||
if (!genre) { toast.error('请选择故事类型'); return }
|
||
setGeneratingSynopsis(true)
|
||
try {
|
||
const result = await audiobookApi.generateSynopsis({
|
||
genre: subgenre ? `${genre} - ${subgenre}` : genre,
|
||
subgenre,
|
||
protagonist_type: protagonistType,
|
||
tone,
|
||
conflict_scale: conflictScale,
|
||
num_characters: numCharacters,
|
||
num_chapters: numChapters,
|
||
})
|
||
setSynopsis(result)
|
||
} catch (e: any) {
|
||
toast.error(formatApiError(e))
|
||
} finally {
|
||
setGeneratingSynopsis(false)
|
||
}
|
||
}
|
||
|
||
const handleCreate = async () => {
|
||
if (!title) { toast.error('请输入作品标题'); return }
|
||
if (!synopsis) { toast.error('请先生成故事简介'); return }
|
||
setLoading(true)
|
||
try {
|
||
await audiobookApi.createAIScript({
|
||
title,
|
||
genre: subgenre ? `${genre} - ${subgenre}` : genre,
|
||
subgenre,
|
||
premise: synopsis,
|
||
style: tone,
|
||
num_characters: numCharacters,
|
||
num_chapters: numChapters,
|
||
} as ScriptGenerationRequest)
|
||
toast.success('AI剧本生成任务已创建')
|
||
reset()
|
||
onCreated()
|
||
onClose()
|
||
} catch (e: any) {
|
||
toast.error(formatApiError(e))
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={v => { if (!v) { reset(); onClose() } }}>
|
||
<DialogContent className="sm:max-w-2xl max-h-[90vh] flex flex-col">
|
||
<DialogHeader className="shrink-0">
|
||
<DialogTitle>AI 生成剧本</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="flex-1 overflow-y-auto space-y-4 pr-1">
|
||
<div className="space-y-1">
|
||
<p className="text-xs text-muted-foreground">作品标题</p>
|
||
<Input placeholder="输入作品标题" value={title} onChange={e => setTitle(e.target.value)} />
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<p className="text-xs text-muted-foreground">故事类型</p>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{genreKeys.map(g => (
|
||
<Chip key={g} label={GENRE_CONFIGS[g].label} selected={genre === g} onClick={() => handleGenreSelect(g)} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{genre && subgenreKeys.length > 0 && (
|
||
<div className="space-y-2">
|
||
<p className="text-xs text-muted-foreground">子类型</p>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{subgenreKeys.map(s => (
|
||
<Chip key={s} label={s} selected={subgenre === s} onClick={() => handleSubgenreSelect(s)} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{subgenreConfig && (
|
||
<>
|
||
<div className="space-y-2">
|
||
<p className="text-xs text-muted-foreground">主角类型</p>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{subgenreConfig.protagonistTypes.map(p => (
|
||
<Chip key={p} label={p} selected={protagonistType === p} onClick={() => setProtagonistType(protagonistType === p ? '' : p)} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<p className="text-xs text-muted-foreground">故事基调</p>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{subgenreConfig.tones.map(t => (
|
||
<Chip key={t} label={t} selected={tone === t} onClick={() => setTone(tone === t ? '' : t)} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<p className="text-xs text-muted-foreground">冲突规模</p>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{subgenreConfig.conflictScales.map(c => (
|
||
<Chip key={c} label={c} selected={conflictScale === c} onClick={() => setConflictScale(conflictScale === c ? '' : c)} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<div className="flex gap-3">
|
||
<label className="flex-1 flex flex-col gap-1 text-sm">
|
||
<span className="text-muted-foreground text-xs">角色数量(2-10)</span>
|
||
<Input type="number" min={2} max={10} value={numCharacters} onChange={e => setNumCharacters(Math.min(10, Math.max(2, Number(e.target.value))))} />
|
||
</label>
|
||
<label className="flex-1 flex flex-col gap-1 text-sm">
|
||
<span className="text-muted-foreground text-xs">章节数量(2-30)</span>
|
||
<Input type="number" min={2} max={30} value={numChapters} onChange={e => setNumChapters(Math.min(30, Math.max(2, Number(e.target.value))))} />
|
||
</label>
|
||
</div>
|
||
|
||
<div className="flex justify-end">
|
||
<Button size="sm" variant="outline" onClick={handleGenerateSynopsis} disabled={!genre || generatingSynopsis}>
|
||
{generatingSynopsis ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
|
||
{generatingSynopsis ? '生成中...' : '生成故事简介'}
|
||
</Button>
|
||
</div>
|
||
|
||
{synopsis && (
|
||
<div className="space-y-2">
|
||
<p className="text-xs text-muted-foreground">故事简介(可编辑)</p>
|
||
<Textarea rows={6} value={synopsis} onChange={e => setSynopsis(e.target.value)} className="text-sm" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex justify-between gap-2 pt-3 shrink-0 border-t">
|
||
<Button size="sm" variant="ghost" onClick={() => { reset(); onClose() }} disabled={loading}>取消</Button>
|
||
<div className="flex gap-2">
|
||
{synopsis && (
|
||
<Button size="sm" variant="outline" onClick={handleGenerateSynopsis} disabled={generatingSynopsis}>
|
||
<RotateCcw className="h-3 w-3 mr-1" />
|
||
重新生成
|
||
</Button>
|
||
)}
|
||
<Button size="sm" onClick={handleCreate} disabled={loading || !synopsis || !title}>
|
||
{loading ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
|
||
{loading ? '创建中...' : '生成剧本'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
const NSFW_GENRE_CONFIGS: Record<string, { label: string; subgenres: Record<string, { protagonistTypes: string[]; tones: string[]; conflictScales: string[] }> }> = {
|
||
'都市成人': {
|
||
label: '都市成人',
|
||
subgenres: {
|
||
'办公室禁忌': { protagonistTypes: ['职场白领', '高管下属', '同事关系', '猎头顾问'], tones: ['压抑克制', '暧昧升温', '禁忌诱惑', '情欲失控'], conflictScales: ['办公室恩怨', '权力游戏', '职场暗战', '情感纠葛'] },
|
||
'豪门虐恋': { protagonistTypes: ['普通女孩', '豪门公子', '隐婚妻子', '商业联姻'], tones: ['痴缠虐恋', '甜蜜救赎', '冷酷霸总', '深情告白'], conflictScales: ['门第悬殊', '家族势力', '商业角力', '情感博弈'] },
|
||
'合约婚姻': { protagonistTypes: ['合约双方', '假戏真做', '情感觉醒', '身份反转'], tones: ['冷漠开局', '渐入佳境', '情不自禁', '破镜重圆'], conflictScales: ['合约终止', '真心暴露', '外敌干扰', '家族干涉'] },
|
||
'姐弟恋': { protagonistTypes: ['成熟女性', '年轻男性', '职场前辈', '学生社会人'], tones: ['成熟风情', '反差萌甜', '年龄压制', '互相依赖'], conflictScales: ['年龄差距', '外界眼光', '情感成熟度', '未来规划'] },
|
||
'高干文': { protagonistTypes: ['官员子弟', '平民女主', '政商联姻', '秘密保护'], tones: ['权力压迫', '低调奢华', '隐秘情感', '强势占有'], conflictScales: ['权力博弈', '政治婚姻', '身份暴露', '家族对决'] },
|
||
},
|
||
},
|
||
'修仙双修': {
|
||
label: '修仙双修',
|
||
subgenres: {
|
||
'双修秘法': { protagonistTypes: ['天才弟子', '神秘师尊', '炉鼎逆袭', '道侣缘定'], tones: ['禁忌师徒', '双修悟道', '情欲修炼', '天道孽缘'], conflictScales: ['门派规矩', '天道考验', '魔道诱惑', '飞升之争'] },
|
||
'仙尊宠妻': { protagonistTypes: ['无名小修', '万年仙尊', '女弟子', '神女转世'], tones: ['宠溺无边', '万年等待', '强强联手', '情深似海'], conflictScales: ['外敌窥伺', '仙界规则', '劫数将临', '情缘注定'] },
|
||
'妖族后宫': { protagonistTypes: ['妖王', '人族女修', '混血妖神', '后宫妃嫔'], tones: ['妖冶风情', '强势占有', '蛊惑人心', '情孽难断'], conflictScales: ['人妖对立', '族群战争', '后宫争宠', '妖皇之位'] },
|
||
'天骄配天娇': { protagonistTypes: ['第一天骄', '绝世女主', '双强对决', '势均力敌'], tones: ['互相较劲', '惺惺相惜', '强强碰撞', '双向暗恋'], conflictScales: ['修炼竞争', '争夺机缘', '外敌来犯', '道路抉择'] },
|
||
},
|
||
},
|
||
'奇幻异世': {
|
||
label: '奇幻异世',
|
||
subgenres: {
|
||
'兽人联姻': { protagonistTypes: ['穿越女主', '兽人王者', '人兽混血', '部落首领'], tones: ['野性粗犷', '温柔呵护', '征服臣服', '异族情缘'], conflictScales: ['种族融合', '部落战争', '血脉觉醒', '异世生存'] },
|
||
'魔君圣女': { protagonistTypes: ['圣女祭司', '堕落魔君', '神魔对立', '光暗守护者'], tones: ['圣洁污染', '光暗交融', '禁忌之恋', '救赎与沉沦'], conflictScales: ['神魔大战', '命运抗争', '信仰崩塌', '世界毁灭'] },
|
||
'穿越迷情': { protagonistTypes: ['现代女性', '古代君王', '异世界人物', '时空旅者'], tones: ['时空错位', '身份迷失', '古今碰撞', '情不知所起'], conflictScales: ['回归困境', '历史改变', '时空悖论', '情感牵绊'] },
|
||
'契约恋人': { protagonistTypes: ['契约双方', '灵魂契约', '主仆关系', '命运绑定'], tones: ['被迫相处', '情感萌生', '契约升温', '破除枷锁'], conflictScales: ['契约限制', '灵魂危机', '外界阻碍', '真心表达'] },
|
||
},
|
||
},
|
||
'历史宫廷': {
|
||
label: '历史宫廷',
|
||
subgenres: {
|
||
'帝王宠妃': { protagonistTypes: ['寒门女子', '九五之尊', '独宠专房', '贵妃娘娘'], tones: ['专宠霸道', '龙恩浩荡', '后宫独步', '帝心难测'], conflictScales: ['后宫争宠', '前朝牵制', '子嗣之争', '外戚干政'] },
|
||
'宫廷秘史': { protagonistTypes: ['宫廷暗卫', '妃嫔密探', '皇室隐秘', '历史见证者'], tones: ['权谋算计', '忠义两难', '隐秘情愫', '乱世真情'], conflictScales: ['宫廷政变', '皇位继承', '异国入侵', '家族倾轧'] },
|
||
'宅斗宫斗': { protagonistTypes: ['嫡女庶女', '正妻侍妾', '宫女妃嫔', '世家主母'], tones: ['心机算尽', '步步为营', '白莲花黑化', '大女主觉醒'], conflictScales: ['争夺家产', '嫁妆之战', '子嗣竞争', '家族延续'] },
|
||
'后宫权谋': { protagonistTypes: ['权倾后宫者', '新晋宠妃', '太后皇后', '谋士幕僚'], tones: ['深宫寂寞', '权力欲望', '情与利的博弈', '最终胜者独孤'], conflictScales: ['废立皇后', '皇储之争', '派系对立', '外朝联动'] },
|
||
},
|
||
},
|
||
'现代激情': {
|
||
label: '现代激情',
|
||
subgenres: {
|
||
'一夜情缘': { protagonistTypes: ['陌生男女', '意外邂逅', '酒后乱性', '重逢旧恋'], tones: ['激情一夜', '难以忘怀', '意外怀孕', '命运纠缠'], conflictScales: ['身份悬殊', '情感纠缠', '意外结果', '关系定义'] },
|
||
'前任回归': { protagonistTypes: ['前男友女友', '新欢旧爱', '情感疗愈者', '纠缠不清'], tones: ['旧情复燃', '爱恨交织', '放不下的执念', '破镜能否重圆'], conflictScales: ['感情空白期', '第三者介入', '过去伤害', '信任重建'] },
|
||
'欲望都市': { protagonistTypes: ['都市白领', '成功商人', '独立女性', '富二代'], tones: ['纸醉金迷', '欲望横流', '都市孤独', '情欲游戏'], conflictScales: ['金钱利益', '情感空虚', '道德底线', '真爱追寻'] },
|
||
'豪门宠婚': { protagonistTypes: ['灰姑娘女主', '豪门单身汉', '闪婚闪恋', '宠妻狂魔'], tones: ['宠溺日常', '霸道甜蜜', '嫉妒吃醋', '甜到发腻'], conflictScales: ['豪门规则', '家族考验', '外来破坏', '证明真爱'] },
|
||
},
|
||
},
|
||
}
|
||
|
||
function NSFWScriptDialog({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: () => void }) {
|
||
const [title, setTitle] = useState('')
|
||
const [genre, setGenre] = useState('')
|
||
const [subgenre, setSubgenre] = useState('')
|
||
const [protagonistType, setProtagonistType] = useState('')
|
||
const [tone, setTone] = useState('')
|
||
const [conflictScale, setConflictScale] = useState('')
|
||
const [numCharacters, setNumCharacters] = useState(5)
|
||
const [numChapters, setNumChapters] = useState(8)
|
||
const [synopsis, setSynopsis] = useState('')
|
||
const [generatingSynopsis, setGeneratingSynopsis] = useState(false)
|
||
const [loading, setLoading] = useState(false)
|
||
|
||
const genreKeys = Object.keys(NSFW_GENRE_CONFIGS)
|
||
const subgenreKeys = genre ? Object.keys(NSFW_GENRE_CONFIGS[genre]?.subgenres ?? {}) : []
|
||
const subgenreConfig = (genre && subgenre) ? NSFW_GENRE_CONFIGS[genre]?.subgenres[subgenre] : null
|
||
|
||
const reset = () => {
|
||
setTitle(''); setGenre(''); setSubgenre(''); setProtagonistType(''); setTone('')
|
||
setConflictScale(''); setNumCharacters(5); setNumChapters(8); setSynopsis('')
|
||
}
|
||
|
||
const handleGenreSelect = (g: string) => {
|
||
setGenre(g); setSubgenre(''); setProtagonistType(''); setTone(''); setConflictScale(''); setSynopsis('')
|
||
}
|
||
const handleSubgenreSelect = (s: string) => {
|
||
setSubgenre(s); setProtagonistType(''); setTone(''); setConflictScale(''); setSynopsis('')
|
||
}
|
||
|
||
const handleGenerateSynopsis = async () => {
|
||
if (!genre) { toast.error('请选择故事类型'); return }
|
||
setGeneratingSynopsis(true)
|
||
try {
|
||
const result = await audiobookApi.generateNsfwSynopsis({
|
||
genre: subgenre ? `${genre} - ${subgenre}` : genre,
|
||
subgenre,
|
||
protagonist_type: protagonistType,
|
||
tone,
|
||
conflict_scale: conflictScale,
|
||
num_characters: numCharacters,
|
||
num_chapters: numChapters,
|
||
})
|
||
setSynopsis(result)
|
||
} catch (e: any) {
|
||
toast.error(formatApiError(e))
|
||
} finally {
|
||
setGeneratingSynopsis(false)
|
||
}
|
||
}
|
||
|
||
const handleCreate = async () => {
|
||
if (!title) { toast.error('请输入作品标题'); return }
|
||
if (!synopsis) { toast.error('请先生成故事简介'); return }
|
||
setLoading(true)
|
||
try {
|
||
await audiobookApi.createNsfwScript({
|
||
title,
|
||
genre: subgenre ? `${genre} - ${subgenre}` : genre,
|
||
subgenre,
|
||
premise: synopsis,
|
||
style: tone,
|
||
num_characters: numCharacters,
|
||
num_chapters: numChapters,
|
||
} as NsfwScriptGenerationRequest)
|
||
toast.success('NSFW剧本生成任务已创建')
|
||
reset()
|
||
onCreated()
|
||
onClose()
|
||
} catch (e: any) {
|
||
toast.error(formatApiError(e))
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={v => { if (!v) { reset(); onClose() } }}>
|
||
<DialogContent className="sm:max-w-2xl max-h-[90vh] flex flex-col">
|
||
<DialogHeader className="shrink-0">
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<Flame className="h-4 w-4 text-orange-500" />
|
||
NSFW 生成剧本
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="flex-1 overflow-y-auto space-y-4 pr-1">
|
||
<div className="space-y-1">
|
||
<p className="text-xs text-muted-foreground">作品标题</p>
|
||
<Input placeholder="输入作品标题" value={title} onChange={e => setTitle(e.target.value)} />
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<p className="text-xs text-muted-foreground">故事类型</p>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{genreKeys.map(g => (
|
||
<Chip key={g} label={NSFW_GENRE_CONFIGS[g].label} selected={genre === g} onClick={() => handleGenreSelect(g)} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{genre && subgenreKeys.length > 0 && (
|
||
<div className="space-y-2">
|
||
<p className="text-xs text-muted-foreground">子类型</p>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{subgenreKeys.map(s => (
|
||
<Chip key={s} label={s} selected={subgenre === s} onClick={() => handleSubgenreSelect(s)} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{subgenreConfig && (
|
||
<>
|
||
<div className="space-y-2">
|
||
<p className="text-xs text-muted-foreground">主角类型</p>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{subgenreConfig.protagonistTypes.map(p => (
|
||
<Chip key={p} label={p} selected={protagonistType === p} onClick={() => setProtagonistType(protagonistType === p ? '' : p)} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<p className="text-xs text-muted-foreground">故事基调</p>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{subgenreConfig.tones.map(t => (
|
||
<Chip key={t} label={t} selected={tone === t} onClick={() => setTone(tone === t ? '' : t)} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<p className="text-xs text-muted-foreground">冲突规模</p>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{subgenreConfig.conflictScales.map(c => (
|
||
<Chip key={c} label={c} selected={conflictScale === c} onClick={() => setConflictScale(conflictScale === c ? '' : c)} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<div className="flex gap-3">
|
||
<label className="flex-1 flex flex-col gap-1 text-sm">
|
||
<span className="text-muted-foreground text-xs">角色数量(2-10)</span>
|
||
<Input type="number" min={2} max={10} value={numCharacters} onChange={e => setNumCharacters(Math.min(10, Math.max(2, Number(e.target.value))))} />
|
||
</label>
|
||
<label className="flex-1 flex flex-col gap-1 text-sm">
|
||
<span className="text-muted-foreground text-xs">章节数量(2-30)</span>
|
||
<Input type="number" min={2} max={30} value={numChapters} onChange={e => setNumChapters(Math.min(30, Math.max(2, Number(e.target.value))))} />
|
||
</label>
|
||
</div>
|
||
|
||
<div className="flex justify-end">
|
||
<Button size="sm" variant="outline" onClick={handleGenerateSynopsis} disabled={!genre || generatingSynopsis}>
|
||
{generatingSynopsis ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
|
||
{generatingSynopsis ? '生成中...' : '生成故事简介'}
|
||
</Button>
|
||
</div>
|
||
|
||
{synopsis && (
|
||
<div className="space-y-2">
|
||
<p className="text-xs text-muted-foreground">故事简介(可编辑)</p>
|
||
<Textarea rows={6} value={synopsis} onChange={e => setSynopsis(e.target.value)} className="text-sm" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex justify-between gap-2 pt-3 shrink-0 border-t">
|
||
<Button size="sm" variant="ghost" onClick={() => { reset(); onClose() }} disabled={loading}>取消</Button>
|
||
<div className="flex gap-2">
|
||
{synopsis && (
|
||
<Button size="sm" variant="outline" onClick={handleGenerateSynopsis} disabled={generatingSynopsis}>
|
||
<RotateCcw className="h-3 w-3 mr-1" />
|
||
重新生成
|
||
</Button>
|
||
)}
|
||
<Button size="sm" onClick={handleCreate} disabled={loading || !synopsis || !title}>
|
||
{loading ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
|
||
{loading ? '创建中...' : '生成剧本'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
function ContinueScriptDialog({ open, onClose, onConfirm }: { open: boolean; onClose: () => void; onConfirm: (n: number) => Promise<void> }) {
|
||
const { t } = useTranslation('audiobook')
|
||
const [count, setCount] = useState(4)
|
||
const [loading, setLoading] = useState(false)
|
||
|
||
const handleSubmit = async () => {
|
||
setLoading(true)
|
||
try {
|
||
await onConfirm(count)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={v => { if (!v) onClose() }}>
|
||
<DialogContent className="sm:max-w-sm">
|
||
<DialogHeader>
|
||
<DialogTitle>{t('projectCard.continueScriptDialog.title')}</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-3 py-2">
|
||
<label className="flex flex-col gap-1 text-sm">
|
||
<span className="text-muted-foreground text-xs">{t('projectCard.continueScriptDialog.label')}</span>
|
||
<Input
|
||
type="number" min={1} max={20} value={count}
|
||
onChange={e => setCount(Math.min(20, Math.max(1, Number(e.target.value))))}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<div className="flex justify-end gap-2 pt-2">
|
||
<Button size="sm" variant="ghost" onClick={onClose} disabled={loading}>{t('projectCard.segments.cancel')}</Button>
|
||
<Button size="sm" onClick={handleSubmit} disabled={loading}>
|
||
{loading ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
|
||
{loading ? t('projectCard.continueScriptDialog.starting') : t('projectCard.continueScriptDialog.start')}
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
function ProjectListSidebar({
|
||
projects,
|
||
selectedId,
|
||
onSelect,
|
||
onNew,
|
||
onAIScript,
|
||
onNSFWScript,
|
||
onLLM,
|
||
loading,
|
||
collapsed,
|
||
onToggle,
|
||
isSuperuser,
|
||
hasNsfwAccess,
|
||
}: {
|
||
projects: AudiobookProject[]
|
||
selectedId: number | null
|
||
onSelect: (id: number) => void
|
||
onNew: () => void
|
||
onAIScript: () => void
|
||
onNSFWScript: () => void
|
||
onLLM: () => void
|
||
loading: boolean
|
||
collapsed: boolean
|
||
onToggle: () => void
|
||
isSuperuser?: boolean
|
||
hasNsfwAccess?: boolean
|
||
}) {
|
||
const { t } = useTranslation('audiobook')
|
||
return (
|
||
<div className={`${collapsed ? 'w-10' : 'w-64'} shrink-0 flex flex-col bg-muted/30 overflow-hidden transition-all duration-200`}>
|
||
<div className="h-16 flex items-center shrink-0 px-2 gap-1">
|
||
{!collapsed && (
|
||
<div className="flex items-center gap-2 flex-1 min-w-0 ml-1">
|
||
<img src="/qwen.svg" alt="Qwen" className="h-5 w-5 shrink-0" />
|
||
<span className="text-sm font-semibold truncate">{t('title')}</span>
|
||
</div>
|
||
)}
|
||
<Button size="icon" variant="ghost" className="h-8 w-8 shrink-0" onClick={onToggle}>
|
||
{collapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
|
||
</Button>
|
||
</div>
|
||
{!collapsed && (
|
||
<>
|
||
<div className="flex items-center justify-end px-2 pb-1 gap-0.5 shrink-0">
|
||
{isSuperuser && (
|
||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onLLM} title={t('llmConfig')}>
|
||
<Settings2 className="h-4 w-4" />
|
||
</Button>
|
||
)}
|
||
{hasNsfwAccess && (
|
||
<Button size="icon" variant="ghost" className="h-7 w-7 text-orange-500 hover:text-orange-600" onClick={onNSFWScript} title="NSFW 生成剧本">
|
||
<Flame className="h-4 w-4" />
|
||
</Button>
|
||
)}
|
||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onAIScript} title="AI 生成剧本">
|
||
<Zap className="h-4 w-4" />
|
||
</Button>
|
||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onNew} title={t('newProject')}>
|
||
<Plus className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
<div className="flex-1 overflow-y-auto px-2 py-1 space-y-1.5">
|
||
{loading ? (
|
||
<div className="px-3 py-4 text-xs text-muted-foreground">{t('loading')}</div>
|
||
) : projects.length === 0 ? (
|
||
<div className="px-3 py-4 text-xs text-muted-foreground">{t('noProjects')}</div>
|
||
) : (
|
||
projects.map(p => (
|
||
<button
|
||
key={p.id}
|
||
onClick={() => onSelect(p.id)}
|
||
className={`w-full text-left rounded-lg border p-3 flex flex-col gap-1.5 transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 ${
|
||
selectedId === p.id
|
||
? 'bg-background border-border shadow-sm'
|
||
: 'bg-background/50 border-border/40'
|
||
}`}
|
||
>
|
||
<div className="flex items-start gap-2">
|
||
<Book className="h-3.5 w-3.5 shrink-0 mt-0.5 text-muted-foreground" />
|
||
<span className="text-sm font-medium leading-snug break-words min-w-0 flex-1">{p.title}</span>
|
||
</div>
|
||
<Badge variant={(STATUS_COLORS[p.status] || 'secondary') as any} className="text-[10px] h-4 px-1 self-start ml-5">
|
||
{t(`status.${p.status}`, { defaultValue: p.status })}
|
||
</Badge>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function EmptyState() {
|
||
const { t } = useTranslation('audiobook')
|
||
return (
|
||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||
<div className="text-center">
|
||
<Book className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||
<p>{t('noProjects')}</p>
|
||
<p className="text-sm mt-1">{t('noProjectsHint')}</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function CharactersPanel({
|
||
project,
|
||
detail,
|
||
loadingAction,
|
||
onConfirm,
|
||
onAnalyze,
|
||
onFetchDetail,
|
||
collapsed,
|
||
onToggle,
|
||
onScrollToChapter,
|
||
}: {
|
||
project: AudiobookProject
|
||
detail: AudiobookProjectDetail | null
|
||
loadingAction: boolean
|
||
onConfirm: () => void
|
||
onAnalyze: () => void
|
||
onFetchDetail: () => Promise<void>
|
||
collapsed: boolean
|
||
onToggle: () => void
|
||
onScrollToChapter: (chapterId: number) => void
|
||
}) {
|
||
const { t } = useTranslation('audiobook')
|
||
const [editingCharId, setEditingCharId] = useState<number | null>(null)
|
||
const [editFields, setEditFields] = useState({ name: '', gender: '', description: '', instruct: '', use_indextts2: false })
|
||
const [regeneratingVoices, setRegeneratingVoices] = useState<Set<number>>(new Set())
|
||
const [voiceKeys, setVoiceKeys] = useState<Record<number, number>>({})
|
||
const status = project.status
|
||
const isActive = ['analyzing', 'generating'].includes(status)
|
||
|
||
useEffect(() => {
|
||
if (status !== 'characters_ready') setEditingCharId(null)
|
||
}, [status])
|
||
|
||
const startEditChar = (char: AudiobookCharacter) => {
|
||
setEditingCharId(char.id)
|
||
setEditFields({ name: char.name, gender: char.gender || '', description: char.description || '', instruct: char.instruct || '', use_indextts2: char.use_indextts2 ?? false })
|
||
}
|
||
|
||
const saveEditChar = async (char: AudiobookCharacter) => {
|
||
try {
|
||
await audiobookApi.updateCharacter(project.id, char.id, {
|
||
name: editFields.name || char.name,
|
||
gender: editFields.gender || undefined,
|
||
description: editFields.description,
|
||
instruct: editFields.instruct,
|
||
use_indextts2: editFields.use_indextts2,
|
||
})
|
||
setEditingCharId(null)
|
||
await onFetchDetail()
|
||
toast.success(t('projectCard.characters.savedSuccess'))
|
||
} catch (e: any) {
|
||
toast.error(formatApiError(e))
|
||
}
|
||
}
|
||
|
||
const handleRegeneratePreview = async (charId: number) => {
|
||
setRegeneratingVoices(prev => new Set(prev).add(charId))
|
||
try {
|
||
await audiobookApi.regenerateCharacterPreview(project.id, charId)
|
||
toast.success(t('projectCard.characters.savedSuccess'))
|
||
setVoiceKeys(prev => ({ ...prev, [charId]: (prev[charId] || 0) + 1 }))
|
||
} catch (e: any) {
|
||
toast.error(formatApiError(e))
|
||
} finally {
|
||
setRegeneratingVoices(prev => { const n = new Set(prev); n.delete(charId); return n })
|
||
}
|
||
}
|
||
|
||
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 charCount = detail?.characters.length ?? 0
|
||
const hasChaptersOutline = (detail?.chapters.length ?? 0) > 0
|
||
|
||
if (collapsed && hasChaptersOutline) {
|
||
return (
|
||
<div className="w-44 shrink-0 flex flex-col border-r border-emerald-500/20 bg-emerald-500/5 overflow-hidden">
|
||
<div className="flex items-center justify-between px-2 py-2 shrink-0 border-b border-emerald-500/20">
|
||
<span className="text-xs font-medium text-emerald-400/80 truncate">
|
||
{t('projectCard.chapters.title', { count: detail!.chapters.length })}
|
||
</span>
|
||
<Button size="icon" variant="ghost" className="h-6 w-6 shrink-0 ml-1" onClick={onToggle}>
|
||
<PanelLeftOpen className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
<div className="flex-1 overflow-y-auto">
|
||
{detail!.chapters.map(ch => {
|
||
const chTitle = ch.title || t('projectCard.chapters.defaultTitle', { index: ch.chapter_index + 1 })
|
||
const dotClass = ch.status === 'done' ? 'bg-green-400' : ch.status === 'error' ? 'bg-destructive' : ch.status === 'parsing' || ch.status === 'generating' ? 'bg-amber-400' : ch.status === 'ready' ? 'bg-blue-400' : 'bg-muted-foreground/50'
|
||
return (
|
||
<button
|
||
key={ch.id}
|
||
className="w-full flex items-center gap-1.5 px-2 py-1.5 text-left hover:bg-muted/60 border-b border-border/30 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||
onClick={() => onScrollToChapter(ch.id)}
|
||
>
|
||
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${dotClass}`} />
|
||
<span className="text-xs truncate">{chTitle}</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="w-72 shrink-0 flex flex-col border-r border-blue-500/20 bg-blue-500/5 overflow-hidden">
|
||
<div className="flex items-center justify-between px-3 py-2 shrink-0">
|
||
<span className="text-xs font-medium text-blue-400/80">
|
||
{t('projectCard.characters.title', { count: charCount })}
|
||
</span>
|
||
<div className="flex items-center gap-1">
|
||
{!isActive && status !== 'pending' && charCount > 0 && (
|
||
<Button size="xs" variant="ghost" className="text-muted-foreground" onClick={onAnalyze} disabled={loadingAction}>
|
||
{t('projectCard.reanalyze')}
|
||
</Button>
|
||
)}
|
||
{hasChaptersOutline && (
|
||
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={onToggle}>
|
||
<PanelLeftClose className="h-3.5 w-3.5" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{(!detail || charCount === 0) ? (
|
||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground px-3 text-center">
|
||
{status === 'pending' ? t('stepHints.pending') : t('projectCard.characters.title', { count: 0 })}
|
||
</div>
|
||
) : (
|
||
<div className="flex-1 overflow-y-auto px-2 py-2 space-y-1.5">
|
||
{detail.characters.map(char => (
|
||
<div key={char.id} className="border rounded px-3 py-2">
|
||
{editingCharId === char.id ? (
|
||
<div className="space-y-2">
|
||
<Input
|
||
value={editFields.name}
|
||
onChange={e => setEditFields(f => ({ ...f, name: e.target.value }))}
|
||
placeholder={t('projectCard.characters.namePlaceholder')}
|
||
/>
|
||
<select
|
||
className="w-full h-9 rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||
value={editFields.gender}
|
||
onChange={e => setEditFields(f => ({ ...f, gender: e.target.value }))}
|
||
>
|
||
<option value="">{t('projectCard.characters.genderPlaceholder')}</option>
|
||
<option value="男">{t('projectCard.characters.genderMale')}</option>
|
||
<option value="女">{t('projectCard.characters.genderFemale')}</option>
|
||
<option value="未知">{t('projectCard.characters.genderUnknown')}</option>
|
||
</select>
|
||
<Input
|
||
value={editFields.instruct}
|
||
onChange={e => setEditFields(f => ({ ...f, instruct: e.target.value }))}
|
||
placeholder={t('projectCard.characters.instructPlaceholder')}
|
||
/>
|
||
<Input
|
||
value={editFields.description}
|
||
onChange={e => setEditFields(f => ({ ...f, description: e.target.value }))}
|
||
placeholder={t('projectCard.characters.descPlaceholder')}
|
||
/>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
|
||
<input
|
||
type="checkbox"
|
||
checked={editFields.use_indextts2}
|
||
onChange={e => setEditFields(f => ({ ...f, use_indextts2: e.target.checked }))}
|
||
className="w-4 h-4"
|
||
/>
|
||
<Zap className="h-3 w-3 text-amber-400" />
|
||
<span>使用 IndexTTS2(需要音色克隆参考音频)</span>
|
||
</label>
|
||
<div className="flex gap-2">
|
||
<Button size="sm" onClick={() => saveEditChar(char)}>
|
||
<Check className="h-3 w-3 mr-1" />{t('common:save')}
|
||
</Button>
|
||
<Button size="sm" variant="ghost" onClick={() => setEditingCharId(null)}>
|
||
<X className="h-3 w-3 mr-1" />{t('common:cancel')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col gap-1 text-sm">
|
||
<div className="flex items-center justify-between gap-1">
|
||
<div className="flex items-center gap-1.5 min-w-0">
|
||
<span className="font-medium truncate">{char.name}</span>
|
||
{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'}`}>
|
||
{genderLabel(char.gender)}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-1 shrink-0">
|
||
{char.use_indextts2 && (
|
||
<Badge variant="outline" className="text-xs border-amber-400/50 text-amber-400">
|
||
<Zap className="h-2.5 w-2.5 mr-0.5" />IndexTTS2
|
||
</Badge>
|
||
)}
|
||
{status === 'characters_ready' && (
|
||
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => startEditChar(char)}>
|
||
<Pencil className="h-3 w-3" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{char.instruct && <span className="text-xs text-muted-foreground truncate">{char.instruct}</span>}
|
||
<div className="flex items-center gap-1">
|
||
{char.voice_design_id
|
||
? <Badge variant="outline" className="text-xs">{t('projectCard.characters.voiceDesign', { id: char.voice_design_id })}</Badge>
|
||
: <Badge variant="secondary" className="text-xs">{t('projectCard.characters.noVoice')}</Badge>
|
||
}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{!editingCharId && char.voice_design_id && (
|
||
<div className="mt-2 flex items-center justify-between gap-2 bg-muted/30 rounded-md p-1.5 border border-muted/50">
|
||
<div className="flex-1 min-w-0">
|
||
<LazyAudioPlayer
|
||
key={`audio-${char.id}-${voiceKeys[char.id] || 0}`}
|
||
audioUrl={`${audiobookApi.getCharacterAudioUrl(project.id, char.id)}?t=${voiceKeys[char.id] || 0}`}
|
||
jobId={char.id}
|
||
/>
|
||
</div>
|
||
{status === 'characters_ready' && (
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground shrink-0"
|
||
onClick={() => handleRegeneratePreview(char.id)}
|
||
disabled={regeneratingVoices.has(char.id)}
|
||
>
|
||
{regeneratingVoices.has(char.id)
|
||
? <><Loader2 className="h-3 w-3 mr-1 animate-spin" />{t('projectCard.characters.regeneratingPreview')}</>
|
||
: <><RefreshCw className="h-3 w-3 mr-1" />{t('projectCard.characters.regeneratePreview')}</>
|
||
}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{status === 'characters_ready' && (
|
||
<div className="px-2 py-2 border-t shrink-0">
|
||
<Button
|
||
className="w-full"
|
||
onClick={onConfirm}
|
||
disabled={loadingAction || editingCharId !== null}
|
||
>
|
||
{loadingAction
|
||
? t('projectCard.confirm.loading')
|
||
: project.source_type === 'ai_generated'
|
||
? t('projectCard.confirm.generateScript', '确认角色并生成剧本')
|
||
: t('projectCard.confirm.button')}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const EMOTION_OPTIONS = ['开心', '愤怒', '悲伤', '恐惧', '厌恶', '低沉', '惊讶', '中性']
|
||
const EMOTION_ALPHA_DEFAULTS: Record<string, number> = {
|
||
开心: 0.6, 愤怒: 0.15, 悲伤: 0.4, 恐惧: 0.4, 厌恶: 0.6, 低沉: 0.6, 惊讶: 0.3, 中性: 0.5,
|
||
}
|
||
|
||
function ChaptersPanel({
|
||
project,
|
||
detail,
|
||
segments,
|
||
loadingAction,
|
||
generatingChapterIndices,
|
||
sequentialPlayingId,
|
||
onParseChapter,
|
||
onGenerate,
|
||
onParseAll,
|
||
onGenerateAll,
|
||
onProcessAll,
|
||
onContinueScript,
|
||
onDownload,
|
||
onSequentialPlayingChange,
|
||
onUpdateSegment,
|
||
onRegenerateSegment,
|
||
scrollToChapterId,
|
||
onScrollToChapterDone,
|
||
}: {
|
||
project: AudiobookProject
|
||
detail: AudiobookProjectDetail | null
|
||
segments: AudiobookSegment[]
|
||
loadingAction: boolean
|
||
generatingChapterIndices: Set<number>
|
||
sequentialPlayingId: number | null
|
||
onParseChapter: (chapterId: number, title?: string) => void
|
||
onGenerate: (chapterIndex?: number, force?: boolean) => void
|
||
onParseAll: () => void
|
||
onGenerateAll: () => void
|
||
onProcessAll: () => void
|
||
onContinueScript?: () => void
|
||
onDownload: (chapterIndex?: number) => void
|
||
onSequentialPlayingChange: (id: number | null) => void
|
||
onUpdateSegment: (segmentId: number, data: { text: string; emo_text?: string | null; emo_alpha?: number | null }) => Promise<void>
|
||
onRegenerateSegment: (segmentId: number) => Promise<void>
|
||
scrollToChapterId: number | null
|
||
onScrollToChapterDone: () => void
|
||
}) {
|
||
const { t } = useTranslation('audiobook')
|
||
const [expandedChapters, setExpandedChapters] = useState<Set<number>>(new Set())
|
||
const [editingSegId, setEditingSegId] = useState<number | null>(null)
|
||
const [editText, setEditText] = useState('')
|
||
const [editEmoText, setEditEmoText] = useState('')
|
||
const [editEmoAlpha, setEditEmoAlpha] = useState(0.5)
|
||
const [savingSegId, setSavingSegId] = useState<number | null>(null)
|
||
const [regeneratingSegs, setRegeneratingSegs] = useState<Set<number>>(new Set())
|
||
const [audioVersions, setAudioVersions] = useState<Record<number, number>>({})
|
||
const prevSegStatusRef = useRef<Record<number, string>>({})
|
||
const initialExpandDoneRef = useRef(false)
|
||
|
||
useEffect(() => {
|
||
if (!scrollToChapterId) return
|
||
setExpandedChapters(prev => { const n = new Set(prev); n.add(scrollToChapterId); return n })
|
||
setTimeout(() => {
|
||
document.getElementById(`ch-${scrollToChapterId}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
}, 50)
|
||
onScrollToChapterDone()
|
||
}, [scrollToChapterId, onScrollToChapterDone])
|
||
|
||
useEffect(() => {
|
||
const bumps: Record<number, number> = {}
|
||
segments.forEach(seg => {
|
||
if (prevSegStatusRef.current[seg.id] === 'generating' && seg.status === 'done') {
|
||
bumps[seg.id] = (audioVersions[seg.id] ?? 0) + 1
|
||
}
|
||
})
|
||
if (Object.keys(bumps).length > 0) {
|
||
setAudioVersions(prev => ({ ...prev, ...bumps }))
|
||
}
|
||
const next: Record<number, string> = {}
|
||
segments.forEach(seg => { next[seg.id] = seg.status })
|
||
prevSegStatusRef.current = next
|
||
}, [segments])
|
||
|
||
const startEdit = (seg: AudiobookSegment) => {
|
||
setEditingSegId(seg.id)
|
||
setEditText(seg.text)
|
||
setEditEmoText(seg.emo_text || '')
|
||
setEditEmoAlpha(seg.emo_alpha ?? 0.5)
|
||
}
|
||
|
||
const cancelEdit = () => setEditingSegId(null)
|
||
|
||
const saveEdit = async (segId: number) => {
|
||
setSavingSegId(segId)
|
||
try {
|
||
await onUpdateSegment(segId, {
|
||
text: editText,
|
||
emo_text: editEmoText || null,
|
||
emo_alpha: editEmoText ? editEmoAlpha : null,
|
||
})
|
||
setEditingSegId(null)
|
||
} finally {
|
||
setSavingSegId(null)
|
||
}
|
||
}
|
||
|
||
const handleRegenerate = async (segId: number) => {
|
||
setRegeneratingSegs(prev => new Set([...prev, segId]))
|
||
try {
|
||
await onRegenerateSegment(segId)
|
||
} finally {
|
||
setRegeneratingSegs(prev => { const n = new Set(prev); n.delete(segId); return n })
|
||
}
|
||
}
|
||
|
||
const status = project.status
|
||
const doneCount = segments.filter(s => s.status === 'done').length
|
||
|
||
useEffect(() => {
|
||
if (!detail || segments.length === 0) return
|
||
const generatingChapterIds = detail.chapters
|
||
.filter(ch => segments.some(s => s.chapter_index === ch.chapter_index && s.status === 'generating'))
|
||
.map(ch => ch.id)
|
||
if (generatingChapterIds.length > 0) {
|
||
setExpandedChapters(prev => {
|
||
const next = new Set(prev)
|
||
generatingChapterIds.forEach(id => next.add(id))
|
||
return next.size === prev.size ? prev : next
|
||
})
|
||
return
|
||
}
|
||
if (!initialExpandDoneRef.current) {
|
||
initialExpandDoneRef.current = true
|
||
const chapterIdsWithSegs = detail.chapters
|
||
.filter(ch => segments.some(s => s.chapter_index === ch.chapter_index))
|
||
.map(ch => ch.id)
|
||
if (chapterIdsWithSegs.length > 0) {
|
||
setExpandedChapters(new Set(chapterIdsWithSegs))
|
||
}
|
||
}
|
||
}, [segments, detail])
|
||
|
||
const isAIMode = project.source_type === 'ai_generated'
|
||
const hasChapters = detail && detail.chapters.length > 0 && ['ready', 'generating', 'done'].includes(status)
|
||
|
||
return (
|
||
<div className="flex-1 flex flex-col bg-emerald-500/5 overflow-hidden">
|
||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between px-3 py-2 shrink-0">
|
||
<span className="text-xs font-medium text-emerald-400/80">
|
||
{t('projectCard.chapters.title', { count: detail?.chapters.length ?? 0 })}
|
||
</span>
|
||
{hasChapters && (
|
||
<div className="flex items-center gap-1 flex-wrap">
|
||
{detail!.chapters.some(c => ['pending', 'error', 'ready'].includes(c.status)) && (
|
||
<Button size="xs" variant="outline" disabled={loadingAction} onClick={onParseAll}>
|
||
{isAIMode ? <Bot className="h-3 w-3 mr-1" /> : <Wand2 className="h-3 w-3 mr-1" />}
|
||
{t(isAIMode ? 'projectCard.chapters.parseAllAI' : 'projectCard.chapters.parseAll')}
|
||
</Button>
|
||
)}
|
||
{detail!.chapters.some(c => c.status === 'ready') && (
|
||
<Button size="xs" variant="outline" disabled={loadingAction} onClick={onGenerateAll}>
|
||
<Volume2 className="h-3 w-3 mr-1" />
|
||
{t('projectCard.chapters.generateAll')}
|
||
</Button>
|
||
)}
|
||
{detail!.chapters.some(c => ['pending', 'error'].includes(c.status)) && detail!.chapters.some(c => c.status === 'ready') && (
|
||
<Button size="xs" disabled={loadingAction} onClick={onProcessAll}>
|
||
{loadingAction ? <Loader2 className="h-3 w-3 animate-spin" /> : t('projectCard.chapters.processAll')}
|
||
</Button>
|
||
)}
|
||
{isAIMode && onContinueScript && (
|
||
<Button size="xs" variant="outline" disabled={loadingAction} onClick={onContinueScript}>
|
||
<Plus className="h-3 w-3 mr-1" />
|
||
{t('projectCard.chapters.continueScript')}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{!hasChapters ? (
|
||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground" />
|
||
) : (
|
||
<div className="flex-1 overflow-y-auto">
|
||
{detail!.chapters.map(ch => {
|
||
const chSegs = segments.filter(s => s.chapter_index === ch.chapter_index)
|
||
const chDone = chSegs.filter(s => s.status === 'done').length
|
||
const chTotal = chSegs.length
|
||
const chGenerating = chSegs.some(s => s.status === 'generating')
|
||
const chAllDone = chTotal > 0 && chDone === chTotal
|
||
const chTitle = ch.title || t('projectCard.chapters.defaultTitle', { index: ch.chapter_index + 1 })
|
||
const chExpanded = expandedChapters.has(ch.id)
|
||
const toggleChExpand = () => setExpandedChapters(prev => {
|
||
const next = new Set(prev)
|
||
if (next.has(ch.id)) next.delete(ch.id)
|
||
else next.add(ch.id)
|
||
return next
|
||
})
|
||
return (
|
||
<div key={ch.id} id={`ch-${ch.id}`}>
|
||
{/* Chapter header — flat, full-width, click to expand */}
|
||
<button
|
||
className="w-full flex items-center gap-2 px-3 py-2.5 bg-muted/40 hover:bg-muted/70 border-b text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||
onClick={toggleChExpand}
|
||
>
|
||
<span className="shrink-0 text-muted-foreground">
|
||
{chExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronUp className="h-3.5 w-3.5" style={{ transform: 'rotate(180deg)' }} />}
|
||
</span>
|
||
<span className="text-xs font-medium flex-1 truncate">{chTitle}</span>
|
||
<span className="shrink-0 flex items-center gap-1.5" onClick={e => e.stopPropagation()}>
|
||
{ch.status === 'pending' && (
|
||
<Button size="xs" variant="outline" onClick={() => onParseChapter(ch.id, ch.title)}>
|
||
{isAIMode ? <Bot className="h-3 w-3 mr-1" /> : <Wand2 className="h-3 w-3 mr-1" />}
|
||
{t(isAIMode ? 'projectCard.chapters.parseAI' : 'projectCard.chapters.parse')}
|
||
</Button>
|
||
)}
|
||
{ch.status === 'parsing' && (
|
||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||
<Loader2 className="h-3 w-3 animate-spin" />{t('projectCard.chapters.parsing')}
|
||
</span>
|
||
)}
|
||
{ch.status === 'ready' && !chGenerating && !chAllDone && !generatingChapterIndices.has(ch.chapter_index) && (
|
||
<>
|
||
<Button size="xs" variant="outline" disabled={loadingAction} onClick={() => {
|
||
setExpandedChapters(prev => { const n = new Set(prev); n.add(ch.id); return n })
|
||
onGenerate(ch.chapter_index)
|
||
}}>
|
||
<Volume2 className="h-3 w-3 mr-1" />
|
||
{t('projectCard.chapters.generate')}
|
||
</Button>
|
||
<Button size="xs" variant="outline" disabled={loadingAction} onClick={() => onParseChapter(ch.id, ch.title)}>
|
||
{isAIMode ? <Bot className="h-3 w-3 mr-1" /> : <Wand2 className="h-3 w-3 mr-1" />}
|
||
{t(isAIMode ? 'projectCard.chapters.reparseAI' : 'projectCard.chapters.reparse')}
|
||
</Button>
|
||
</>
|
||
)}
|
||
{ch.status === 'ready' && (chGenerating || generatingChapterIndices.has(ch.chapter_index)) && (
|
||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||
<Loader2 className="h-3 w-3 animate-spin" />{t('projectCard.chapters.segmentProgress', { done: chDone, total: chTotal })}
|
||
</span>
|
||
)}
|
||
{(ch.status === 'done' || (ch.status === 'ready' && chAllDone)) && (
|
||
<>
|
||
<span className="text-[11px] text-muted-foreground">{t('projectCard.chapters.doneBadge', { count: chDone })}</span>
|
||
<Button size="xs" variant="outline" disabled={loadingAction} onClick={() => {
|
||
setExpandedChapters(prev => { const n = new Set(prev); n.add(ch.id); return n })
|
||
onGenerate(ch.chapter_index, true)
|
||
}}>
|
||
<RefreshCw className="h-3 w-3 mr-0.5" /><Volume2 className="h-3 w-3 mr-1" />{t('projectCard.chapters.generate')}
|
||
</Button>
|
||
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => onDownload(ch.chapter_index)} title={t('projectCard.downloadAll')}>
|
||
<Download className="h-3 w-3" />
|
||
</Button>
|
||
</>
|
||
)}
|
||
{ch.status === 'error' && (
|
||
<Button size="xs" variant="outline" className="text-destructive border-destructive/40" onClick={() => onParseChapter(ch.id, ch.title)}>
|
||
{isAIMode ? <Bot className="h-3 w-3 mr-1" /> : <Wand2 className="h-3 w-3 mr-1" />}
|
||
{t(isAIMode ? 'projectCard.chapters.reparseAI' : 'projectCard.chapters.reparse')}
|
||
</Button>
|
||
)}
|
||
</span>
|
||
</button>
|
||
|
||
{ch.status === 'parsing' && (
|
||
<div className="px-3 py-1 border-b">
|
||
<LogStream projectId={project.id} chapterId={ch.id} active={ch.status === 'parsing'} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Segments — flat list, each is its own card */}
|
||
{chExpanded && chSegs.length > 0 && (
|
||
<div className="px-3 py-2 space-y-2 border-b">
|
||
{chSegs.map(seg => {
|
||
const isEditing = editingSegId === seg.id
|
||
const isRegenerating = regeneratingSegs.has(seg.id) || seg.status === 'generating'
|
||
const isSaving = savingSegId === seg.id
|
||
const audioV = audioVersions[seg.id] ?? 0
|
||
return (
|
||
<div
|
||
key={seg.id}
|
||
className={`rounded-lg border overflow-hidden ${sequentialPlayingId === seg.id ? 'border-primary/40 bg-primary/5' : 'bg-card'}`}
|
||
>
|
||
{/* Card header */}
|
||
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border/50">
|
||
<Badge variant="outline" className="text-xs shrink-0 font-normal">
|
||
{seg.character_name || t('projectCard.segments.unknownCharacter')}
|
||
</Badge>
|
||
{!isEditing && seg.emo_text && (
|
||
<span className="text-[11px] text-muted-foreground shrink-0">
|
||
{seg.emo_text}
|
||
{seg.emo_alpha != null && (
|
||
<span className="opacity-60 ml-0.5">{seg.emo_alpha.toFixed(2)}</span>
|
||
)}
|
||
</span>
|
||
)}
|
||
{isRegenerating && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
||
{!isRegenerating && seg.status === 'error' && (
|
||
<Badge variant="destructive" className="text-xs">{t('projectCard.segments.errorBadge')}</Badge>
|
||
)}
|
||
<div className="ml-auto flex items-center gap-0.5 shrink-0">
|
||
{!isEditing ? (
|
||
<>
|
||
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => startEdit(seg)} disabled={isRegenerating} title={t('projectCard.segments.edit')}>
|
||
<Pencil className="h-3 w-3" />
|
||
</Button>
|
||
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => handleRegenerate(seg.id)} disabled={isRegenerating} title={t('projectCard.segments.regenerate')}>
|
||
<RefreshCw className="h-3 w-3" />
|
||
</Button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Button size="icon" variant="ghost" className="h-6 w-6 text-destructive" onClick={cancelEdit} title={t('projectCard.segments.cancel')}>
|
||
<X className="h-3 w-3" />
|
||
</Button>
|
||
<Button size="icon" variant="ghost" className="h-6 w-6 text-primary" onClick={() => saveEdit(seg.id)} disabled={isSaving} title={t('projectCard.segments.save')}>
|
||
{isSaving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
||
</Button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Card body */}
|
||
<div className="px-3 py-2.5">
|
||
{isEditing ? (
|
||
<div className="space-y-2">
|
||
<Textarea
|
||
value={editText}
|
||
onChange={e => setEditText(e.target.value)}
|
||
className="text-sm min-h-[60px] resize-y"
|
||
rows={3}
|
||
/>
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-xs text-muted-foreground shrink-0">{t('projectCard.segments.emotion')}:</span>
|
||
<select
|
||
value={editEmoText}
|
||
onChange={e => {
|
||
const v = e.target.value
|
||
setEditEmoText(v)
|
||
if (v && EMOTION_ALPHA_DEFAULTS[v]) setEditEmoAlpha(EMOTION_ALPHA_DEFAULTS[v])
|
||
}}
|
||
className="text-xs h-6 rounded border border-input bg-background px-1 focus:outline-none"
|
||
>
|
||
<option value="">{t('projectCard.segments.noEmotion')}</option>
|
||
{EMOTION_OPTIONS.map(e => <option key={e} value={e}>{e}</option>)}
|
||
</select>
|
||
{editEmoText && (
|
||
<div className="flex items-center gap-1.5 flex-1 min-w-[120px]">
|
||
<span className="text-xs text-muted-foreground shrink-0">{t('projectCard.segments.intensity')}:</span>
|
||
<input
|
||
type="range"
|
||
min={0.05}
|
||
max={0.9}
|
||
step={0.05}
|
||
value={editEmoAlpha}
|
||
onChange={e => setEditEmoAlpha(Number(e.target.value))}
|
||
className="flex-1 h-1.5 accent-primary"
|
||
/>
|
||
<span className="text-xs text-muted-foreground w-8 text-right">{editEmoAlpha.toFixed(2)}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-foreground/80 break-words leading-relaxed">{seg.text}</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Audio — integrated at bottom of card */}
|
||
{!isEditing && seg.status === 'done' && (
|
||
<div className="px-3 pb-2">
|
||
<LazyAudioPlayer
|
||
key={`seg-audio-${seg.id}-${audioV}`}
|
||
audioUrl={`${audiobookApi.getSegmentAudioUrl(project.id, seg.id)}?v=${audioV}`}
|
||
jobId={seg.id}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{doneCount > 0 && (
|
||
<div className="px-3 py-2 border-t shrink-0">
|
||
<SequentialPlayer segments={segments} projectId={project.id} onPlayingChange={onSequentialPlayingChange} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function Audiobook() {
|
||
const { t } = useTranslation('audiobook')
|
||
const { user } = useAuth()
|
||
const [projects, setProjects] = useState<AudiobookProject[]>([])
|
||
const [selectedProjectId, setSelectedProjectId] = useState<number | null>(null)
|
||
const [detail, setDetail] = useState<AudiobookProjectDetail | null>(null)
|
||
const [segments, setSegments] = useState<AudiobookSegment[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [loadingAction, setLoadingAction] = useState(false)
|
||
const [isPolling, setIsPolling] = useState(false)
|
||
const [generatingChapterIndices, setGeneratingChapterIndices] = useState<Set<number>>(new Set())
|
||
const [sequentialPlayingId, setSequentialPlayingId] = useState<number | null>(null)
|
||
const [showCreate, setShowCreate] = useState(false)
|
||
const [showAIScript, setShowAIScript] = useState(false)
|
||
const [showNSFWScript, setShowNSFWScript] = useState(false)
|
||
const [showContinueScript, setShowContinueScript] = useState(false)
|
||
const [showLLM, setShowLLM] = useState(false)
|
||
const [hasNsfwAccess, setHasNsfwAccess] = useState(false)
|
||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||
const [charactersCollapsed, setCharactersCollapsed] = useState(false)
|
||
const [scrollToChapterId, setScrollToChapterId] = useState<number | null>(null)
|
||
const prevStatusRef = useRef<string>('')
|
||
const autoExpandedRef = useRef(new Set<string>())
|
||
|
||
const selectedProject = projects.find(p => p.id === selectedProjectId) ?? null
|
||
|
||
const fetchProjects = useCallback(async () => {
|
||
try {
|
||
const list = await audiobookApi.listProjects()
|
||
setProjects(list)
|
||
} catch (e: any) {
|
||
toast.error(formatApiError(e))
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [])
|
||
|
||
const fetchDetail = useCallback(async () => {
|
||
if (!selectedProjectId) return
|
||
try { setDetail(await audiobookApi.getProject(selectedProjectId)) } catch {}
|
||
}, [selectedProjectId])
|
||
|
||
const fetchSegments = useCallback(async () => {
|
||
if (!selectedProjectId) return
|
||
try { setSegments(await audiobookApi.getSegments(selectedProjectId)) } catch {}
|
||
}, [selectedProjectId])
|
||
|
||
useEffect(() => {
|
||
fetchProjects()
|
||
authApi.getNsfwAccess().then(r => setHasNsfwAccess(r.has_access)).catch(() => {})
|
||
}, [fetchProjects])
|
||
|
||
useEffect(() => {
|
||
if (!selectedProjectId) {
|
||
setDetail(null)
|
||
setSegments([])
|
||
return
|
||
}
|
||
setDetail(null)
|
||
setSegments([])
|
||
setGeneratingChapterIndices(new Set())
|
||
setIsPolling(false)
|
||
autoExpandedRef.current.clear()
|
||
prevStatusRef.current = ''
|
||
fetchDetail()
|
||
fetchSegments()
|
||
}, [selectedProjectId, fetchDetail, fetchSegments])
|
||
|
||
const status = selectedProject?.status ?? ''
|
||
|
||
useEffect(() => {
|
||
if (!selectedProject) return
|
||
if (['done', 'error'].includes(status)) setIsPolling(false)
|
||
if (['characters_ready', 'ready', 'generating'].includes(status) && !autoExpandedRef.current.has(status)) {
|
||
autoExpandedRef.current.add(status)
|
||
fetchDetail()
|
||
fetchSegments()
|
||
}
|
||
}, [status, selectedProject, fetchDetail, fetchSegments])
|
||
|
||
useEffect(() => {
|
||
if (!selectedProject) return
|
||
if (prevStatusRef.current === 'generating' && status === 'done') {
|
||
toast.success(t('projectCard.allDoneToast', { title: selectedProject.title }))
|
||
}
|
||
prevStatusRef.current = status
|
||
}, [status, selectedProject, t])
|
||
|
||
const hasParsingChapter = detail?.chapters.some(c => c.status === 'parsing') ?? false
|
||
|
||
useEffect(() => {
|
||
if (!isPolling) return
|
||
if (['analyzing', 'generating'].includes(status)) return
|
||
if (hasParsingChapter) return
|
||
if (!segments.some(s => s.status === 'generating')) setIsPolling(false)
|
||
}, [isPolling, status, segments, hasParsingChapter])
|
||
|
||
useEffect(() => {
|
||
if (generatingChapterIndices.size === 0) return
|
||
const done: number[] = []
|
||
generatingChapterIndices.forEach(chIdx => {
|
||
const chSegs = segments.filter(s => s.chapter_index === chIdx)
|
||
if (chSegs.length > 0 && chSegs.every(s => s.status === 'done' || s.status === 'error')) {
|
||
done.push(chIdx)
|
||
}
|
||
})
|
||
if (done.length > 0) {
|
||
setGeneratingChapterIndices(prev => {
|
||
const n = new Set(prev)
|
||
done.forEach(i => n.delete(i))
|
||
return n
|
||
})
|
||
}
|
||
}, [segments, generatingChapterIndices])
|
||
|
||
const shouldPoll = isPolling || ['analyzing', 'generating'].includes(status) || hasParsingChapter || generatingChapterIndices.size > 0 || segments.some(s => s.status === 'generating')
|
||
|
||
useEffect(() => {
|
||
if (!shouldPoll || !selectedProjectId) return
|
||
const id = setInterval(() => { fetchProjects(); fetchSegments(); fetchDetail() }, 1500)
|
||
return () => clearInterval(id)
|
||
}, [shouldPoll, selectedProjectId, fetchProjects, fetchSegments, fetchDetail])
|
||
|
||
const handleAnalyze = async () => {
|
||
if (!selectedProject) return
|
||
if (['characters_ready', 'ready', 'done'].includes(status)) {
|
||
if (!confirm(t('projectCard.reanalyzeConfirm'))) return
|
||
}
|
||
autoExpandedRef.current.clear()
|
||
setLoadingAction(true)
|
||
setIsPolling(true)
|
||
try {
|
||
await audiobookApi.analyze(selectedProject.id, { turbo: true })
|
||
toast.success(t('projectCard.analyzeStarted'))
|
||
fetchProjects()
|
||
} catch (e: any) {
|
||
setIsPolling(false)
|
||
toast.error(formatApiError(e))
|
||
} finally {
|
||
setLoadingAction(false)
|
||
}
|
||
}
|
||
|
||
const handleConfirm = async () => {
|
||
if (!selectedProject) return
|
||
setLoadingAction(true)
|
||
try {
|
||
await audiobookApi.confirmCharacters(selectedProject.id)
|
||
toast.success(t('projectCard.confirm.chaptersRecognized'))
|
||
fetchProjects()
|
||
fetchDetail()
|
||
} catch (e: any) {
|
||
toast.error(formatApiError(e))
|
||
} finally {
|
||
setLoadingAction(false)
|
||
}
|
||
}
|
||
|
||
const handleParseChapter = async (chapterId: number, title?: string) => {
|
||
if (!selectedProject) return
|
||
try {
|
||
await audiobookApi.parseChapter(selectedProject.id, chapterId)
|
||
toast.success(title
|
||
? t('projectCard.chapters.parseStarted', { title })
|
||
: t('projectCard.chapters.parseStartedDefault'))
|
||
fetchDetail()
|
||
} catch (e: any) {
|
||
toast.error(formatApiError(e))
|
||
}
|
||
}
|
||
|
||
const handleGenerate = async (chapterIndex?: number, force?: boolean) => {
|
||
if (!selectedProject) return
|
||
setLoadingAction(true)
|
||
if (chapterIndex !== undefined) {
|
||
setGeneratingChapterIndices(prev => new Set([...prev, chapterIndex]))
|
||
} else {
|
||
setIsPolling(true)
|
||
}
|
||
try {
|
||
await audiobookApi.generate(selectedProject.id, chapterIndex, force)
|
||
toast.success(chapterIndex !== undefined
|
||
? t('projectCard.chapters.generateStarted', { index: chapterIndex + 1 })
|
||
: t('projectCard.chapters.generateAllStarted'))
|
||
fetchProjects()
|
||
fetchSegments()
|
||
} catch (e: any) {
|
||
if (chapterIndex !== undefined) {
|
||
setGeneratingChapterIndices(prev => { const n = new Set(prev); n.delete(chapterIndex); return n })
|
||
} else {
|
||
setIsPolling(false)
|
||
}
|
||
toast.error(formatApiError(e))
|
||
} finally {
|
||
setLoadingAction(false)
|
||
}
|
||
}
|
||
|
||
const handleParseAll = async () => {
|
||
if (!selectedProject) return
|
||
setLoadingAction(true)
|
||
setIsPolling(true)
|
||
try {
|
||
await audiobookApi.parseAllChapters(selectedProject.id)
|
||
toast.success(t('projectCard.chapters.parseAllStarted'))
|
||
fetchProjects()
|
||
fetchDetail()
|
||
} catch (e: any) {
|
||
setIsPolling(false)
|
||
toast.error(formatApiError(e))
|
||
} finally {
|
||
setLoadingAction(false)
|
||
}
|
||
}
|
||
|
||
const handleRetryFailed = async () => {
|
||
if (!selectedProject) return
|
||
setIsPolling(true)
|
||
try {
|
||
await audiobookApi.parseAllChapters(selectedProject.id, true)
|
||
toast.success(t('projectCard.chapters.parseAllStarted'))
|
||
fetchProjects()
|
||
fetchDetail()
|
||
} catch (e: any) {
|
||
setIsPolling(false)
|
||
toast.error(formatApiError(e))
|
||
}
|
||
}
|
||
|
||
const handleGenerateAll = async () => {
|
||
if (!selectedProject || !detail) return
|
||
setLoadingAction(true)
|
||
const ready = detail.chapters.filter(c => c.status === 'ready')
|
||
if (ready.length > 0) {
|
||
setGeneratingChapterIndices(prev => new Set([...prev, ...ready.map(c => c.chapter_index)]))
|
||
}
|
||
setIsPolling(true)
|
||
try {
|
||
await audiobookApi.generate(selectedProject.id)
|
||
toast.success(t('projectCard.chapters.generateAllStarted'))
|
||
fetchProjects()
|
||
fetchSegments()
|
||
} catch (e: any) {
|
||
setIsPolling(false)
|
||
toast.error(formatApiError(e))
|
||
} finally {
|
||
setLoadingAction(false)
|
||
}
|
||
}
|
||
|
||
const handleProcessAll = async () => {
|
||
if (!selectedProject || !detail) return
|
||
setLoadingAction(true)
|
||
const ready = detail.chapters.filter(c => c.status === 'ready')
|
||
if (ready.length > 0) {
|
||
setGeneratingChapterIndices(prev => new Set([...prev, ...ready.map(c => c.chapter_index)]))
|
||
}
|
||
setIsPolling(true)
|
||
try {
|
||
await audiobookApi.processAll(selectedProject.id)
|
||
toast.success(t('projectCard.chapters.processAllStarted'))
|
||
fetchProjects()
|
||
fetchDetail()
|
||
fetchSegments()
|
||
} catch (e: any) {
|
||
setIsPolling(false)
|
||
toast.error(formatApiError(e))
|
||
} finally {
|
||
setLoadingAction(false)
|
||
}
|
||
}
|
||
|
||
const handleContinueScript = async (additionalChapters: number) => {
|
||
if (!selectedProject) return
|
||
setLoadingAction(true)
|
||
setIsPolling(true)
|
||
try {
|
||
await audiobookApi.continueScript(selectedProject.id, additionalChapters)
|
||
toast.success(t('projectCard.chapters.continueScriptStarted'))
|
||
setShowContinueScript(false)
|
||
fetchProjects()
|
||
fetchDetail()
|
||
} catch (e: any) {
|
||
setIsPolling(false)
|
||
toast.error(formatApiError(e))
|
||
} finally {
|
||
setLoadingAction(false)
|
||
}
|
||
}
|
||
|
||
const handleCancelBatch = async () => {
|
||
if (!selectedProject) return
|
||
try {
|
||
await audiobookApi.cancelBatch(selectedProject.id)
|
||
toast.success(t('projectCard.cancelledToast'))
|
||
setIsPolling(false)
|
||
setGeneratingChapterIndices(new Set())
|
||
setTimeout(() => { fetchProjects(); fetchDetail(); fetchSegments() }, 1000)
|
||
} catch (e: any) {
|
||
toast.error(formatApiError(e))
|
||
}
|
||
}
|
||
|
||
const handleUpdateSegment = async (segmentId: number, data: { text: string; emo_text?: string | null; emo_alpha?: number | null }) => {
|
||
if (!selectedProject) return
|
||
try {
|
||
await audiobookApi.updateSegment(selectedProject.id, segmentId, data)
|
||
toast.success(t('projectCard.segments.savedSuccess'))
|
||
fetchSegments()
|
||
} catch (e: any) {
|
||
toast.error(formatApiError(e))
|
||
throw e
|
||
}
|
||
}
|
||
|
||
const handleRegenerateSegment = async (segmentId: number) => {
|
||
if (!selectedProject) return
|
||
try {
|
||
await audiobookApi.regenerateSegment(selectedProject.id, segmentId)
|
||
fetchSegments()
|
||
} catch (e: any) {
|
||
toast.error(formatApiError(e))
|
||
throw e
|
||
}
|
||
}
|
||
|
||
const handleDownload = async (chapterIndex?: number) => {
|
||
if (!selectedProject) return
|
||
setLoadingAction(true)
|
||
try {
|
||
const response = await apiClient.get(`/audiobook/projects/${selectedProject.id}/download`, {
|
||
responseType: 'blob',
|
||
params: chapterIndex !== undefined ? { chapter: chapterIndex } : {},
|
||
})
|
||
const url = URL.createObjectURL(response.data)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = chapterIndex !== undefined
|
||
? `${selectedProject.title}_ch${chapterIndex + 1}.mp3`
|
||
: `${selectedProject.title}.mp3`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
} catch (e: any) {
|
||
toast.error(formatApiError(e))
|
||
} finally {
|
||
setLoadingAction(false)
|
||
}
|
||
}
|
||
|
||
const handleDelete = async () => {
|
||
if (!selectedProject) return
|
||
if (!confirm(t('projectCard.deleteConfirm', { title: selectedProject.title }))) return
|
||
try {
|
||
await audiobookApi.deleteProject(selectedProject.id)
|
||
toast.success(t('projectCard.deleteSuccess'))
|
||
setSelectedProjectId(null)
|
||
setDetail(null)
|
||
setSegments([])
|
||
fetchProjects()
|
||
} catch (e: any) {
|
||
toast.error(formatApiError(e))
|
||
}
|
||
}
|
||
|
||
const doneCount = segments.filter(s => s.status === 'done').length
|
||
const totalCount = segments.length
|
||
const progress = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0
|
||
const chaptersParsed = detail?.chapters.filter(c => ['ready', 'done'].includes(c.status) || (segments.some(s => s.chapter_index === c.chapter_index))).length ?? 0
|
||
const chaptersParsing = detail?.chapters.filter(c => c.status === 'parsing').length ?? 0
|
||
const chaptersError = detail?.chapters.filter(c => c.status === 'error').length ?? 0
|
||
const chaptersTotal = detail?.chapters.length ?? 0
|
||
const chapterProgress = chaptersTotal > 0 ? Math.round((chaptersParsed / chaptersTotal) * 100) : 0
|
||
const hasGenerating = segments.some(s => s.status === 'generating')
|
||
|
||
const displayStatus = (() => {
|
||
if (!selectedProject) return ''
|
||
const s = selectedProject.status
|
||
if (['ready', 'generating', 'done'].includes(s)) {
|
||
if (chaptersParsing > 0 && hasGenerating) return 'processing'
|
||
if (chaptersParsing > 0) return 'parsing'
|
||
if (hasGenerating) return 'generating'
|
||
if (totalCount > 0 && doneCount === totalCount && chaptersTotal > 0 && chaptersParsing === 0 && chaptersError === 0) return 'done'
|
||
if (s === 'done' && (chaptersError > 0 || (chaptersTotal > 0 && chaptersParsed < chaptersTotal))) return 'ready'
|
||
}
|
||
return s
|
||
})()
|
||
|
||
const isTurboMode = ['analyzing', 'parsing', 'processing'].includes(displayStatus)
|
||
|
||
return (
|
||
<div className="h-screen overflow-hidden flex bg-background">
|
||
<ProjectListSidebar
|
||
projects={projects}
|
||
selectedId={selectedProjectId}
|
||
onSelect={(id) => {
|
||
if (id !== selectedProjectId) {
|
||
setSelectedProjectId(id)
|
||
setIsPolling(false)
|
||
setGeneratingChapterIndices(new Set())
|
||
}
|
||
}}
|
||
onNew={() => { setShowCreate(v => !v); setShowLLM(false); setShowAIScript(false); setShowNSFWScript(false) }}
|
||
onAIScript={() => { setShowAIScript(v => !v); setShowCreate(false); setShowLLM(false); setShowNSFWScript(false) }}
|
||
onNSFWScript={() => { setShowNSFWScript(v => !v); setShowCreate(false); setShowLLM(false); setShowAIScript(false) }}
|
||
onLLM={() => { setShowLLM(v => !v); setShowCreate(false); setShowAIScript(false); setShowNSFWScript(false) }}
|
||
loading={loading}
|
||
collapsed={!sidebarOpen}
|
||
onToggle={() => setSidebarOpen(v => !v)}
|
||
isSuperuser={user?.is_superuser}
|
||
hasNsfwAccess={hasNsfwAccess}
|
||
/>
|
||
<div className="flex-1 flex flex-col overflow-hidden bg-muted/30">
|
||
<Navbar />
|
||
<div className="flex-1 flex flex-col overflow-hidden bg-background rounded-tl-2xl">
|
||
<LLMConfigDialog open={showLLM} onClose={() => setShowLLM(false)} />
|
||
<CreateProjectDialog open={showCreate} onClose={() => setShowCreate(false)} onCreated={fetchProjects} />
|
||
<AIScriptDialog open={showAIScript} onClose={() => setShowAIScript(false)} onCreated={() => { fetchProjects(); setShowAIScript(false) }} />
|
||
<NSFWScriptDialog open={showNSFWScript} onClose={() => setShowNSFWScript(false)} onCreated={() => { fetchProjects(); setShowNSFWScript(false) }} />
|
||
<ContinueScriptDialog open={showContinueScript} onClose={() => setShowContinueScript(false)} onConfirm={handleContinueScript} />
|
||
{!selectedProject ? (
|
||
<EmptyState />
|
||
) : (
|
||
<>
|
||
<div className="shrink-0 border-b px-4 py-2 flex items-start justify-between gap-2">
|
||
<div className="flex items-start gap-2 min-w-0 flex-1">
|
||
<Book className="h-4 w-4 shrink-0 text-muted-foreground mt-0.5" />
|
||
<span className="font-medium break-words">{selectedProject.title}</span>
|
||
</div>
|
||
<div className="flex items-center gap-1 shrink-0 flex-wrap justify-end">
|
||
{isTurboMode && (
|
||
<Badge variant="secondary" className="bg-amber-500/10 text-amber-500 hover:bg-amber-500/20 shadow-sm border-amber-500/20">
|
||
{t('status.turboActive')}
|
||
</Badge>
|
||
)}
|
||
<Badge variant={(STATUS_COLORS[displayStatus] || 'secondary') as any}>
|
||
{t(`status.${displayStatus}`, { defaultValue: displayStatus })}
|
||
</Badge>
|
||
{status === 'pending' && (
|
||
<Button size="sm" onClick={handleAnalyze} disabled={loadingAction}>
|
||
{t('projectCard.analyze')}
|
||
</Button>
|
||
)}
|
||
{status === 'ready' && (
|
||
<Button size="sm" onClick={handleProcessAll} disabled={loadingAction}>
|
||
{loadingAction ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
|
||
{t('projectCard.processAll')}
|
||
</Button>
|
||
)}
|
||
{status === 'done' && (
|
||
<Button size="sm" variant="outline" onClick={() => handleDownload()} disabled={loadingAction}>
|
||
<Download className="h-3 w-3" />{t('projectCard.downloadAll')}
|
||
</Button>
|
||
)}
|
||
<Button size="icon" variant="ghost" className="h-8 w-8 shrink-0" onClick={handleDelete}>
|
||
<Trash2 className="h-4 w-4 text-destructive" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{STEP_HINT_STATUSES.includes(status) && (
|
||
<div className="shrink-0 mx-4 mt-2 text-xs text-muted-foreground bg-muted/50 rounded px-3 py-2 border-l-2 border-primary/40">
|
||
{t(`stepHints.${status}`)}
|
||
</div>
|
||
)}
|
||
|
||
{(status === 'analyzing' || (status === 'generating' && selectedProject.source_type === 'ai_generated')) && (
|
||
<div className="shrink-0 mx-4 mt-2">
|
||
<LogStream projectId={selectedProject.id} active={true} />
|
||
</div>
|
||
)}
|
||
|
||
{selectedProject.error_message && (
|
||
<div className="shrink-0 mx-4 mt-2 text-xs text-destructive bg-destructive/10 rounded p-2">
|
||
{selectedProject.error_message}
|
||
</div>
|
||
)}
|
||
|
||
{chaptersTotal > 0 && ['ready', 'generating', 'done'].includes(status) && (chaptersParsing > 0 || chaptersError > 0 || (totalCount > 0 && doneCount > 0)) && (
|
||
<div className="shrink-0 mx-4 mt-2 space-y-2">
|
||
{(chaptersParsing > 0 || chaptersError > 0 || chaptersParsed < chaptersTotal) && (
|
||
<div className="space-y-1">
|
||
<div className="text-xs text-muted-foreground flex items-center justify-between">
|
||
<div className="flex items-center gap-1 flex-wrap">
|
||
<span>📝</span>
|
||
<span>{t('projectCard.chaptersProgress', { parsed: chaptersParsed, total: chaptersTotal })}</span>
|
||
{chaptersParsing > 0 && (
|
||
<span className="text-primary">({t('projectCard.chaptersParsing', { count: chaptersParsing })})</span>
|
||
)}
|
||
{chaptersError > 0 && (
|
||
<>
|
||
<span className="text-destructive">({t('projectCard.chaptersError', { count: chaptersError })})</span>
|
||
<Button size="xs" variant="outline" className="text-destructive border-destructive/40" onClick={handleRetryFailed}>
|
||
{t('projectCard.retryFailed')}
|
||
</Button>
|
||
</>
|
||
)}
|
||
</div>
|
||
{chaptersParsing > 0 && totalCount > 0 && (
|
||
<Button size="xs" variant="ghost" className="text-destructive" onClick={handleCancelBatch}>
|
||
{t('projectCard.cancelParsing')}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
<Progress value={chapterProgress} />
|
||
</div>
|
||
)}
|
||
{totalCount > 0 && doneCount > 0 && (
|
||
<div className="space-y-1">
|
||
<div className="text-xs text-muted-foreground flex items-center justify-between">
|
||
<div className="flex items-center gap-1">
|
||
<span>🎵</span>
|
||
<span>{t('projectCard.segmentsProgress', { done: doneCount, total: totalCount })}</span>
|
||
</div>
|
||
{!chaptersParsing && hasGenerating && (
|
||
<Button size="xs" variant="ghost" className="text-destructive" onClick={handleCancelBatch}>
|
||
{t('projectCard.cancelGenerating')}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
<Progress value={progress} />
|
||
</div>
|
||
)}
|
||
{chaptersParsing > 0 && !totalCount && (
|
||
<div className="flex justify-end">
|
||
<Button size="xs" variant="ghost" className="text-destructive" onClick={handleCancelBatch}>
|
||
{t('projectCard.cancelParsing')}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex-1 flex overflow-hidden mt-2">
|
||
<CharactersPanel
|
||
project={selectedProject}
|
||
detail={detail}
|
||
loadingAction={loadingAction}
|
||
onConfirm={handleConfirm}
|
||
onAnalyze={handleAnalyze}
|
||
onFetchDetail={fetchDetail}
|
||
collapsed={charactersCollapsed}
|
||
onToggle={() => setCharactersCollapsed(v => !v)}
|
||
onScrollToChapter={(id) => setScrollToChapterId(id)}
|
||
/>
|
||
<ChaptersPanel
|
||
key={selectedProject.id}
|
||
project={selectedProject}
|
||
detail={detail}
|
||
segments={segments}
|
||
loadingAction={loadingAction}
|
||
generatingChapterIndices={generatingChapterIndices}
|
||
sequentialPlayingId={sequentialPlayingId}
|
||
onParseChapter={handleParseChapter}
|
||
onGenerate={handleGenerate}
|
||
onParseAll={handleParseAll}
|
||
onGenerateAll={handleGenerateAll}
|
||
onProcessAll={handleProcessAll}
|
||
onContinueScript={selectedProject.source_type === 'ai_generated' ? () => setShowContinueScript(true) : undefined}
|
||
onDownload={handleDownload}
|
||
onSequentialPlayingChange={setSequentialPlayingId}
|
||
onUpdateSegment={handleUpdateSegment}
|
||
onRegenerateSegment={handleRegenerateSegment}
|
||
scrollToChapterId={scrollToChapterId}
|
||
onScrollToChapterDone={() => setScrollToChapterId(null)}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|