diff --git a/BillNote_frontend/src/components/FavoriteBatchDialog.tsx b/BillNote_frontend/src/components/FavoriteBatchDialog.tsx new file mode 100644 index 000000000..9620bdaaf --- /dev/null +++ b/BillNote_frontend/src/components/FavoriteBatchDialog.tsx @@ -0,0 +1,525 @@ +import { useEffect, useRef, useState } from 'react' +import { AlertCircle, Check, Loader2, ListVideo, Pause, Play, RefreshCw } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { ScrollArea } from '@/components/ui/scroll-area' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { getBatchTaskStatus, getBilibiliFavoritePage, type FavoriteVideo } from '@/services/favorites' +import { generateNote, type GenerateNotePayload } from '@/services/note' +import { useTaskStore, type FavoriteFolderInfo, type FavoriteVideoInfo } from '@/store/taskStore' + +const STORAGE_KEY = 'bilinote-favorite-batch-v1' +type BatchState = 'idle' | 'running' | 'paused' | 'done' +type GenerationConfig = Omit + +interface SavedBatch { + favoriteUrl: string + favoriteId: string + favoriteTitle: string + videos: FavoriteVideo[] + selectedBvids: string[] + page: number + total: number + hasMore: boolean + config: GenerationConfig | null + nextIndex: number + state: BatchState +} + +interface BatchError { + code?: number + message?: string + msg?: string + data?: { + reason?: string + downloading?: boolean + } +} + +interface FavoriteBatchDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + getGenerationConfig: () => GenerationConfig | null +} + +const delay = (milliseconds: number) => new Promise(resolve => setTimeout(resolve, milliseconds)) + +const getVideoKey = (video: FavoriteVideo) => { + if (video.video_key) return video.video_key + if ((video.page_count || 1) > 1) return `${video.bvid}_p${video.page_number || 1}` + return video.bvid +} + +const FavoriteBatchDialog = ({ open, onOpenChange, getGenerationConfig }: FavoriteBatchDialogProps) => { + const addPendingTask = useTaskStore(state => state.addPendingTask) + const [hydrated, setHydrated] = useState(false) + const [favoriteUrl, setFavoriteUrl] = useState('') + const [favoriteId, setFavoriteId] = useState('') + const [favoriteTitle, setFavoriteTitle] = useState('') + const [videos, setVideos] = useState([]) + const [selectedBvids, setSelectedBvids] = useState([]) + const [page, setPage] = useState(0) + const [total, setTotal] = useState(0) + const [hasMore, setHasMore] = useState(false) + const [loading, setLoading] = useState(false) + const [loadingAll, setLoadingAll] = useState(false) + const [batchState, setBatchState] = useState('idle') + const [pauseRequested, setPauseRequested] = useState(false) + const [batchConfig, setBatchConfig] = useState(null) + const [nextIndex, setNextIndex] = useState(0) + const [error, setError] = useState('') + const stopAfterCurrent = useRef(false) + const hasMoreRef = useRef(false) + const pageRef = useRef(0) + const videosRef = useRef([]) + + useEffect(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY) + if (saved) { + const batch = JSON.parse(saved) as SavedBatch + setFavoriteUrl(batch.favoriteUrl || '') + setFavoriteId(batch.favoriteId || '') + setFavoriteTitle(batch.favoriteTitle || '') + setVideos(batch.videos || []) + setSelectedBvids(batch.selectedBvids || []) + setPage(batch.page || 0) + setTotal(batch.total || 0) + setHasMore(Boolean(batch.hasMore)) + setBatchConfig(batch.config || null) + setNextIndex(batch.nextIndex || 0) + setBatchState(batch.state === 'running' ? 'paused' : batch.state || 'idle') + } + } catch { + localStorage.removeItem(STORAGE_KEY) + } finally { + setHydrated(true) + } + }, []) + + useEffect(() => { + pageRef.current = page + hasMoreRef.current = hasMore + videosRef.current = videos + }, [page, hasMore, selectedBvids, videos]) + + useEffect(() => { + if (!hydrated) return + const saved: SavedBatch = { + favoriteUrl, + favoriteId, + favoriteTitle, + videos, + selectedBvids, + page, + total, + hasMore, + config: batchConfig, + nextIndex, + state: batchState, + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(saved)) + }, [hydrated, favoriteUrl, favoriteId, favoriteTitle, videos, selectedBvids, page, total, hasMore, batchConfig, nextIndex, batchState]) + + const fetchPage = async (pageNumber: number, replace = false) => { + const result = await getBilibiliFavoritePage(favoriteUrl, pageNumber) + setFavoriteId(result.favorite_id) + pageRef.current = result.page + hasMoreRef.current = result.has_more + setFavoriteTitle(result.title) + setTotal(result.media_count) + setPage(result.page) + setHasMore(result.has_more) + setVideos(current => { + const existing = new Set(replace ? [] : current.map(getVideoKey)) + const incoming = result.videos.filter(video => !existing.has(getVideoKey(video))) + return replace ? result.videos : [...current, ...incoming] + }) + setSelectedBvids(current => { + const base = replace ? [] : current + return [...new Set([...base, ...result.videos.map(getVideoKey)])] + }) + } + + const previewFavorite = async () => { + if (!favoriteUrl.trim()) { + setError('请粘贴 Bilibili 收藏夹链接') + return + } + setLoading(true) + setError('') + setVideos([]) + setSelectedBvids([]) + setFavoriteId('') + setPage(0) + setBatchState('idle') + setBatchConfig(null) + setNextIndex(0) + try { + await fetchPage(1, true) + } catch (cause: unknown) { + const failure = cause as BatchError + setError(failure?.msg || failure?.message || '无法读取收藏夹,请检查 Cookie 和链接') + } finally { + setLoading(false) + } + } + + const loadAllPages = async () => { + setLoadingAll(true) + setError('') + try { + let nextPage = pageRef.current + 1 + while (hasMoreRef.current) { + await delay(200) + await fetchPage(nextPage) + nextPage += 1 + } + } catch (cause: unknown) { + const failure = cause as BatchError + setError(failure?.msg || failure?.message || '加载收藏夹后续视频失败') + } finally { + setLoadingAll(false) + } + } + + const updateVideo = (videoKey: string, update: Partial) => { + setVideos(current => current.map(video => getVideoKey(video) === videoKey ? { ...video, ...update } : video)) + } + + const runBatch = async ( + items: FavoriteVideo[], + config: GenerationConfig, + startIndex: number, + favoriteFolder: FavoriteFolderInfo, + ) => { + stopAfterCurrent.current = false + setPauseRequested(false) + setBatchState('running') + setError('') + + for (let index = startIndex; index < items.length; index += 1) { + if (stopAfterCurrent.current) { + setNextIndex(index) + setBatchState('paused') + setPauseRequested(false) + return + } + + const video = videosRef.current.find(item => getVideoKey(item) === getVideoKey(items[index])) || items[index] + if (video.status === 'success' || video.status === 'failed') { + setNextIndex(index + 1) + continue + } + + updateVideo(getVideoKey(video), { status: 'running', error: undefined }) + let taskId = video.task_id + if (!taskId) { + try { + const payload: GenerateNotePayload = { + ...config, + style: config.style || 'minimal', + video_url: video.video_url, + platform: 'bilibili', + } + const response = await generateNote(payload, { silent: true }) + taskId = response?.task_id + if (!taskId) throw new Error('后端没有返回任务编号') + const favoriteVideo: FavoriteVideoInfo = { + bvid: video.bvid, + title: video.title, + page_number: video.page_number, + page_count: video.page_count, + } + addPendingTask(taskId, 'bilibili', payload, favoriteFolder, favoriteVideo) + updateVideo(getVideoKey(video), { task_id: taskId }) + } catch (cause: unknown) { + const failure = cause as BatchError + if (failure?.data?.reason === 'transcriber_model_not_ready') { + updateVideo(getVideoKey(video), { status: undefined, error: undefined }) + setNextIndex(index) + setBatchState('paused') + setPauseRequested(false) + setError(failure.data.downloading + ? '转写模型正在下载,等模型准备好后点击继续。' + : '当前转写模型尚未下载,请先到「设置 → 音频转写配置」下载模型。') + return + } + updateVideo(getVideoKey(video), { + status: 'failed', + error: failure?.msg || failure?.message || '提交任务失败', + }) + setNextIndex(index + 1) + continue + } + } + + while (true) { + try { + const status = await getBatchTaskStatus(taskId) + if (status.status === 'SUCCESS') { + updateVideo(getVideoKey(video), { status: 'success', error: undefined }) + setNextIndex(index + 1) + break + } + if (status.status === 'FAILED') { + updateVideo(getVideoKey(video), { + status: 'failed', + error: status.message || '笔记生成失败', + }) + setNextIndex(index + 1) + break + } + await delay(3000) + } catch (cause: unknown) { + const failure = cause as BatchError + if (failure?.code === 500) { + updateVideo(getVideoKey(video), { + status: 'failed', + error: failure?.msg || '笔记生成失败', + }) + setNextIndex(index + 1) + break + } + setError(`无法确认「${video.title}」的任务状态,可点击继续重试查询。`) + setNextIndex(index) + setBatchState('paused') + setPauseRequested(false) + return + } + } + } + + setNextIndex(items.length) + setBatchState('done') + setPauseRequested(false) + } + + const startBatch = () => { + const items = videos.filter(video => selectedBvids.includes(getVideoKey(video))) + if (hasMore) { + setError('请先加载完整个收藏夹,再开始批量生成') + return + } + if (items.length === 0) { + setError('请至少选择一个视频') + return + } + const config = getGenerationConfig() + if (!config) return + setBatchConfig(config) + setNextIndex(0) + void runBatch(items, config, 0, { + id: favoriteId || favoriteUrl.trim(), + title: favoriteTitle || 'Bilibili 收藏夹', + url: favoriteUrl.trim(), + }) + } + + const resumeBatch = () => { + if (!batchConfig) return + const items = videos.filter(video => selectedBvids.includes(getVideoKey(video))) + if (items.length === 0) { + setError('没有可继续的已选视频') + return + } + void runBatch(items, batchConfig, nextIndex, { + id: favoriteId || favoriteUrl.trim(), + title: favoriteTitle || 'Bilibili 收藏夹', + url: favoriteUrl.trim(), + }) + } + + const retryFailed = () => { + const selectedVideos = videos.filter(video => selectedBvids.includes(getVideoKey(video))) + const firstFailedIndex = selectedVideos.findIndex(video => video.status === 'failed') + if (firstFailedIndex < 0) return + + const resetVideos = videos.map(video => selectedBvids.includes(getVideoKey(video)) && video.status === 'failed' + ? { ...video, status: undefined, task_id: undefined, error: undefined } + : video) + setVideos(resetVideos) + videosRef.current = resetVideos + setNextIndex(firstFailedIndex) + setBatchState('paused') + setError('') + } + + const pauseBatch = () => { + if (batchState !== 'running' || pauseRequested) return + stopAfterCurrent.current = true + setPauseRequested(true) + } + + const clearBatch = () => { + if (batchState === 'running') return + setVideos([]) + setSelectedBvids([]) + setFavoriteId('') + setFavoriteTitle('') + setPage(0) + setTotal(0) + setHasMore(false) + setBatchConfig(null) + setNextIndex(0) + setBatchState('idle') + setError('') + localStorage.removeItem(STORAGE_KEY) + } + + const toggleVideo = (videoKey: string) => { + setSelectedBvids(current => current.includes(videoKey) + ? current.filter(item => item !== videoKey) + : [...current, videoKey]) + } + + const toggleAll = () => { + setSelectedBvids(current => current.length === videos.length ? [] : videos.map(getVideoKey)) + } + + const finishedCount = videos.filter(video => + selectedBvids.includes(getVideoKey(video)) && (video.status === 'success' || video.status === 'failed') + ).length + const selectedCount = videos.filter(video => selectedBvids.includes(getVideoKey(video))).length + + return ( + + + + + + Bilibili 收藏夹批量生成 + + + 使用设置中保存的 Bilibili Cookie 读取私有收藏夹;多 P 视频会展开为独立任务,批量任务复用当前笔记配置并逐个执行。 + + + +
+ setFavoriteUrl(event.target.value)} + placeholder="粘贴收藏夹链接,例如 bilibili.com/list/ml..." + disabled={batchState === 'running'} + /> + +
+ + {error && ( +
+ + {error} +
+ )} + + {videos.length > 0 && ( + <> +
+
+ {favoriteTitle} + + 已载入 {videos.length} 个视频/分P · 收藏项 {total} · 已选 {selectedCount} + +
+
+ + {batchState !== 'running' && ( + + )} +
+
+ + +
+ {videos.map(video => ( +
+ ) +} + +export default FavoriteBatchDialog diff --git a/BillNote_frontend/src/layouts/HomeLayout.tsx b/BillNote_frontend/src/layouts/HomeLayout.tsx index ecba5aae3..469426be2 100644 --- a/BillNote_frontend/src/layouts/HomeLayout.tsx +++ b/BillNote_frontend/src/layouts/HomeLayout.tsx @@ -116,7 +116,7 @@ const HomeLayout: FC = ({ NoteForm, Preview, History }) => { onCollapse={() => setIsMiddleCollapsed(true)} onExpand={() => setIsMiddleCollapsed(false)} > -