From df482b2416a6311c5a9fe5c1424c87b4812ec1bd Mon Sep 17 00:00:00 2001 From: muuushroom <16791719+muuushroom@user.noreply.gitee.com> Date: Tue, 20 Oct 2026 21:39:18 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(bilibili):=20=E6=94=B6=E8=97=8F?= =?UTF-8?q?=E5=A4=B9=E7=AC=94=E8=AE=B0=E4=B8=B2=E8=A1=8C=E7=94=9F=E6=88=90?= =?UTF-8?q?=E4=B8=8E=E5=8E=86=E5=8F=B2=E6=89=B9=E9=87=8F=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/FavoriteBatchDialog.tsx | 513 ++++++++++++++++++ BillNote_frontend/src/layouts/HomeLayout.tsx | 6 +- .../src/pages/HomePage/components/History.tsx | 6 +- .../HomePage/components/MarkdownHeader.tsx | 13 +- .../HomePage/components/MarkdownViewer.tsx | 17 +- .../pages/HomePage/components/NoteForm.tsx | 65 ++- .../pages/HomePage/components/NoteHistory.tsx | 441 ++++++++++----- BillNote_frontend/src/services/favorites.ts | 38 ++ BillNote_frontend/src/services/note.ts | 21 +- .../src/store/taskStore/index.ts | 33 +- README.md | 8 + backend/app/__init__.py | 3 +- backend/app/routers/favorites.py | 115 ++++ backend/tests/test_favorites.py | 76 +++ 14 files changed, 1169 insertions(+), 186 deletions(-) create mode 100644 BillNote_frontend/src/components/FavoriteBatchDialog.tsx create mode 100644 BillNote_frontend/src/services/favorites.ts create mode 100644 backend/app/routers/favorites.py create mode 100644 backend/tests/test_favorites.py diff --git a/BillNote_frontend/src/components/FavoriteBatchDialog.tsx b/BillNote_frontend/src/components/FavoriteBatchDialog.tsx new file mode 100644 index 000000000..55394e332 --- /dev/null +++ b/BillNote_frontend/src/components/FavoriteBatchDialog.tsx @@ -0,0 +1,513 @@ +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 } 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 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(video => video.bvid)) + const incoming = result.videos.filter(video => !existing.has(video.bvid)) + return replace ? result.videos : [...current, ...incoming] + }) + setSelectedBvids(current => { + const base = replace ? [] : current + return [...new Set([...base, ...result.videos.map(video => video.bvid)])] + }) + } + + 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 = (bvid: string, update: Partial) => { + setVideos(current => current.map(video => video.bvid === bvid ? { ...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 => item.bvid === items[index].bvid) || items[index] + if (video.status === 'success' || video.status === 'failed') { + setNextIndex(index + 1) + continue + } + + updateVideo(video.bvid, { 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('后端没有返回任务编号') + addPendingTask(taskId, 'bilibili', payload, favoriteFolder) + updateVideo(video.bvid, { task_id: taskId }) + } catch (cause: unknown) { + const failure = cause as BatchError + if (failure?.data?.reason === 'transcriber_model_not_ready') { + updateVideo(video.bvid, { status: undefined, error: undefined }) + setNextIndex(index) + setBatchState('paused') + setPauseRequested(false) + setError(failure.data.downloading + ? '转写模型正在下载,等模型准备好后点击继续。' + : '当前转写模型尚未下载,请先到「设置 → 音频转写配置」下载模型。') + return + } + updateVideo(video.bvid, { + status: 'failed', + error: failure?.msg || failure?.message || '提交任务失败', + }) + setNextIndex(index + 1) + continue + } + } + + while (true) { + try { + const status = await getBatchTaskStatus(taskId) + if (status.status === 'SUCCESS') { + updateVideo(video.bvid, { status: 'success', error: undefined }) + setNextIndex(index + 1) + break + } + if (status.status === 'FAILED') { + updateVideo(video.bvid, { + 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(video.bvid, { + 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(video.bvid)) + 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(video.bvid)) + 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(video.bvid)) + const firstFailedIndex = selectedVideos.findIndex(video => video.status === 'failed') + if (firstFailedIndex < 0) return + + const resetVideos = videos.map(video => selectedBvids.includes(video.bvid) && 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 = (bvid: string) => { + setSelectedBvids(current => current.includes(bvid) + ? current.filter(item => item !== bvid) + : [...current, bvid]) + } + + const toggleAll = () => { + setSelectedBvids(current => current.length === videos.length ? [] : videos.map(video => video.bvid)) + } + + const finishedCount = videos.filter(video => + selectedBvids.includes(video.bvid) && (video.status === 'success' || video.status === 'failed') + ).length + const selectedCount = videos.filter(video => selectedBvids.includes(video.bvid)).length + + return ( + + + + + + Bilibili 收藏夹批量生成 + + + 使用设置中保存的 Bilibili Cookie 读取私有收藏夹;批量任务复用当前笔记配置并逐个执行。 + + + +
+ setFavoriteUrl(event.target.value)} + placeholder="粘贴收藏夹链接,例如 bilibili.com/list/ml..." + disabled={batchState === 'running'} + /> + +
+ + {error && ( +
+ + {error} +
+ )} + + {videos.length > 0 && ( + <> +
+
+ {favoriteTitle} + + 已载入 {videos.length} / {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)} > -