From 9d567fa8bc20d523ea9b26fb3df5ba8d96fc70f6 Mon Sep 17 00:00:00 2001 From: Shibo-Zhu Date: Mon, 17 Aug 2026 00:32:36 +0800 Subject: [PATCH 1/4] feat(bilibili): add parallel notes for episode ranges --- .../pages/HomePage/components/NoteForm.tsx | 201 +++++++++++++- BillNote_frontend/src/services/note.ts | 38 ++- .../src/store/taskStore/index.ts | 57 +++- .../app/downloaders/bilibili_downloader.py | 246 ++++++++++++----- backend/app/routers/note.py | 261 ++++++++++++++---- backend/app/services/note.py | 9 +- backend/app/services/task_serial_executor.py | 10 +- backend/app/utils/url_parser.py | 15 + backend/tests/test_note_batch.py | 101 +++++++ backend/tests/test_task_serial_executor.py | 18 +- backend/tests/test_video_url_support.py | 17 ++ 11 files changed, 827 insertions(+), 146 deletions(-) create mode 100644 backend/tests/test_note_batch.py diff --git a/BillNote_frontend/src/pages/HomePage/components/NoteForm.tsx b/BillNote_frontend/src/pages/HomePage/components/NoteForm.tsx index 9e9cbfa5..7d7fd861 100644 --- a/BillNote_frontend/src/pages/HomePage/components/NoteForm.tsx +++ b/BillNote_frontend/src/pages/HomePage/components/NoteForm.tsx @@ -8,13 +8,13 @@ import { FormMessage, } from '@/components/ui/form.tsx' import { useEffect,useState } from 'react' -import { useForm, useWatch } from 'react-hook-form' +import { type FieldErrors, useForm, useWatch } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { Info, Loader2, Plus } from 'lucide-react' import { Alert, AlertDescription } from '@/components/ui/alert.tsx' -import { generateNote } from '@/services/note.ts' +import { generateNote, generateNoteBatch, type GenerateNotePayload } from '@/services/note.ts' import { uploadFile } from '@/services/upload.ts' import { useTaskStore } from '@/store/taskStore' import { useModelStore } from '@/store/modelStore' @@ -59,8 +59,11 @@ const formSchema = z .tuple([z.coerce.number().min(1).max(10), z.coerce.number().min(1).max(10)]) .default([2, 2]) .optional(), + batch_enabled: z.boolean().default(false), + p_start: z.coerce.number().int().min(1).max(9999).default(1), + p_end: z.coerce.number().int().min(1).max(9999).default(1), }) - .superRefine(({ video_url, platform }, ctx) => { + .superRefine(({ video_url, platform, batch_enabled, p_start, p_end }, ctx) => { if (platform === 'local') { if (!video_url) { ctx.addIssue({ code: 'custom', message: '本地视频路径不能为空', path: ['video_url'] }) @@ -81,6 +84,29 @@ const formSchema = z } } } + if (batch_enabled) { + if (platform !== 'bilibili') { + ctx.addIssue({ + code: 'custom', + message: '批量分集目前仅支持哔哩哔哩', + path: ['batch_enabled'], + }) + } + if (p_end < p_start) { + ctx.addIssue({ + code: 'custom', + message: '结束 P 不能小于起始 P', + path: ['p_end'], + }) + } + if (p_end - p_start + 1 > 100) { + ctx.addIssue({ + code: 'custom', + message: '单次最多生成 100 集', + path: ['p_end'], + }) + } + } }) export type NoteFormValues = z.infer @@ -133,7 +159,7 @@ const NoteForm = () => { const [isUploading, setIsUploading] = useState(false) const [uploadSuccess, setUploadSuccess] = useState(false) /* ---- 全局状态 ---- */ - const { addPendingTask, currentTaskId, setCurrentTask, getCurrentTask, retryTask } = + const { addPendingTask, addPendingTasks, currentTaskId, setCurrentTask, getCurrentTask, retryTask } = useTaskStore() const { loadEnabledModels, modelList, showFeatureHint, setShowFeatureHint } = useModelStore() @@ -148,13 +174,25 @@ const NoteForm = () => { video_interval: 6, grid_size: [2, 2], format: [], + batch_enabled: false, + p_start: 1, + p_end: 1, }, }) const currentTask = getCurrentTask() /* ---- 派生状态(只 watch 一次,提高性能) ---- */ const platform = useWatch({ control: form.control, name: 'platform' }) as string + const selectedModel = useWatch({ control: form.control, name: 'model_name' }) as string const videoUnderstandingEnabled = useWatch({ control: form.control, name: 'video_understanding' }) + const batchEnabled = useWatch({ control: form.control, name: 'batch_enabled' }) + const videoUrl = useWatch({ control: form.control, name: 'video_url' }) + const pStart = useWatch({ control: form.control, name: 'p_start' }) || 1 + const pEnd = useWatch({ control: form.control, name: 'p_end' }) || 1 + const selectedModelConfig = modelList.find(m => m.model_name === selectedModel) + const deepSeekSelected = + selectedModel?.toLowerCase().startsWith('deepseek-') + || selectedModelConfig?.provider_id?.toLowerCase() === 'deepseek' const editing = currentTask && currentTask.id const goModelAdd = () => { @@ -169,22 +207,30 @@ const NoteForm = () => { useEffect(() => { if (!currentTask) return const { formData } = currentTask + const savedModelIsAvailable = modelList.some(m => m.model_name === formData.model_name) + const restoredModel = savedModelIsAvailable + ? formData.model_name + : modelList[0]?.model_name || formData.model_name || '' + const restoredIsDeepSeek = restoredModel.toLowerCase().startsWith('deepseek-') console.log('currentTask.formData.platform:', formData.platform) form.reset({ platform: formData.platform || 'bilibili', video_url: formData.video_url || '', - model_name: formData.model_name || modelList[0]?.model_name || '', + model_name: restoredModel, style: formData.style || 'minimal', quality: formData.quality || 'medium', extras: formData.extras || '', screenshot: formData.screenshot ?? false, link: formData.link ?? false, - video_understanding: formData.video_understanding ?? false, + video_understanding: restoredIsDeepSeek ? false : formData.video_understanding ?? false, video_interval: formData.video_interval ?? 6, grid_size: formData.grid_size ?? [2, 2], format: formData.format ?? [], + batch_enabled: false, + p_start: 1, + p_end: 1, }) }, [ // 当下面任意一个变了,就重新 reset @@ -194,6 +240,25 @@ const NoteForm = () => { // 还要加上 formData 的各字段,或者直接 currentTask currentTask?.formData, ]) + useEffect(() => { + if (!deepSeekSelected || !videoUnderstandingEnabled) return + form.setValue('video_understanding', false) + const formats = form.getValues('format') || [] + if (formats.includes('screenshot')) { + form.setValue('format', formats.filter(item => item !== 'screenshot')) + } + }, [deepSeekSelected, videoUnderstandingEnabled]) + useEffect(() => { + if (!batchEnabled || platform !== 'bilibili' || !videoUrl) return + try { + const page = Number(new URL(videoUrl).searchParams.get('p')) + if (!Number.isInteger(page) || page < 1) return + form.setValue('p_start', page) + if ((form.getValues('p_end') || 1) < page) form.setValue('p_end', page) + } catch { + // 链接尚未输入完整时不打扰用户,提交时会由 schema 给出提示。 + } + }, [batchEnabled, platform, videoUrl]) /* ---- 帮助函数 ---- */ const isGenerating = () => !['SUCCESS', 'FAILED', undefined].includes(getCurrentTask()?.status) @@ -219,10 +284,22 @@ const NoteForm = () => { const onSubmit = async (values: NoteFormValues) => { console.log('Not even go here') - const payload: NoteFormValues = { - ...values, - provider_id: modelList.find(m => m.model_name === values.model_name)!.provider_id, - task_id: currentTaskId || '', + const selected = modelList.find(m => m.model_name === values.model_name) + if (!selected) { + toast.error('当前模型已不可用,请重新选择模型') + return + } + if (values.video_understanding && ( + values.model_name.toLowerCase().startsWith('deepseek-') + || selected?.provider_id?.toLowerCase() === 'deepseek' + )) { + toast.error('DeepSeek API 不支持图片输入,请关闭「视频理解」后重试') + return + } + const { batch_enabled, p_start, p_end, ...noteValues } = values + const payload: GenerateNotePayload = { + ...noteValues, + provider_id: selected!.provider_id, } if (currentTaskId) { retryTask(currentTaskId, payload) @@ -231,6 +308,28 @@ const NoteForm = () => { // message.success('已提交任务') try { + if (batch_enabled) { + const batch = await generateNoteBatch({ + ...payload, + p_start, + p_end, + }) + addPendingTasks(batch.tasks.map(item => ({ + taskId: item.task_id, + platform: values.platform, + title: item.title, + formData: { + ...payload, + video_url: item.video_url, + batch_id: batch.batch_id, + batch_page: item.p, + }, + }))) + toast.success( + `已创建 ${batch.total} 集笔记任务,最多并行处理 ${batch.max_parallel} 集`, + ) + return + } const data = await generateNote(payload) addPendingTask(data.task_id, values.platform, payload) } catch (e: any) { @@ -246,6 +345,10 @@ const NoteForm = () => { if (!downloading) navigate('/settings/transcriber') return } + if (e?.data?.reason === 'vision_model_required') { + toast.error('当前模型不支持视频理解,请关闭该功能或更换多模态模型') + return + } // 其余错误:axios 拦截器已经弹过 toast,这里只兜底不让 promise 变成未处理 rejection console.error('提交任务失败:', e) } @@ -260,7 +363,14 @@ const NoteForm = () => { setCurrentTask(null) } const FormButton = () => { - const label = generating ? '正在生成…' : editing ? '重新生成' : '生成笔记' + const batchCount = Math.max(0, pEnd - pStart + 1) + const label = generating + ? '正在生成…' + : editing + ? '重新生成' + : batchEnabled + ? `生成 ${batchCount} 集笔记` + : '生成笔记' return (
@@ -346,6 +456,61 @@ const NoteForm = () => { />
+ {platform === 'bilibili' && ( +
+ ( + +
+ field.onChange(Boolean(value))} + /> + 批量生成多 P 分集 +
+ +
+ )} + /> + + {batchEnabled && ( + <> +
+ ( + + 起始 P + + + + )} + /> + ( + + 结束 P + + + + )} + /> +
+

+ 将为 P{pStart}–P{pEnd} 创建 {Math.max(0, pEnd - pStart + 1)} 个独立笔记任务; + 后端默认最多同时处理 3 集,其余任务自动排队。 +

+ + )} +
+ )} + { 启用 form.setValue('video_understanding', v)} + disabled={deepSeekSelected} + onCheckedChange={v => { + if (v && deepSeekSelected) { + toast.error('DeepSeek API 不支持图片输入,无法开启视频理解') + return + } + form.setValue('video_understanding', Boolean(v)) + }} /> @@ -533,7 +705,10 @@ const NoteForm = () => { - 提示:视频理解功能必须使用多模态模型。 + 提示: + {deepSeekSelected + ? 'DeepSeek API 当前不支持图片输入,已禁用视频理解。' + : '视频理解功能必须使用多模态模型。'} diff --git a/BillNote_frontend/src/services/note.ts b/BillNote_frontend/src/services/note.ts index 722bd92b..ed739aba 100644 --- a/BillNote_frontend/src/services/note.ts +++ b/BillNote_frontend/src/services/note.ts @@ -1,7 +1,7 @@ import request from '@/utils/request' import toast from 'react-hot-toast' -export const generateNote = async (data: { +export interface GenerateNotePayload { video_url: string platform: string quality: string @@ -11,10 +11,30 @@ export const generateNote = async (data: { format: Array style: string extras?: string - video_understand?: boolean + screenshot?: boolean + link?: boolean + video_understanding?: boolean video_interval?: number grid_size: Array -}) => { + prefetched_transcript?: Record +} + +export interface BatchTaskItem { + task_id: string + p: number + video_url: string + title: string +} + +export interface GenerateNoteBatchResult { + batch_id: string + total: number + max_parallel: number + series_title: string + tasks: BatchTaskItem[] +} + +export const generateNote = async (data: GenerateNotePayload) => { try { console.log('generateNote', data) const response = await request.post('/generate_note', data) @@ -41,6 +61,18 @@ export const generateNote = async (data: { } } +export const generateNoteBatch = async ( + data: GenerateNotePayload & { p_start: number; p_end: number } +): Promise => { + try { + // 后端会先请求一次 B 站分集清单并校验范围,代理较慢时可能超过全局 10 秒。 + return await request.post('/generate_note_batch', data, { timeout: 30000 }) + } catch (e: any) { + console.error('❌ 批量任务提交失败', e) + throw e + } +} + export const delete_task = async ({ video_id, platform }) => { try { const data = { diff --git a/BillNote_frontend/src/store/taskStore/index.ts b/BillNote_frontend/src/store/taskStore/index.ts index f511f78f..1df661fc 100644 --- a/BillNote_frontend/src/store/taskStore/index.ts +++ b/BillNote_frontend/src/store/taskStore/index.ts @@ -6,7 +6,16 @@ import toast from 'react-hot-toast' import { get, set, del } from 'idb-keyval' -export type TaskStatus = 'PENDING' | 'RUNNING' | 'SUCCESS' | 'FAILD' +export type TaskStatus = + | 'PENDING' + | 'PARSING' + | 'DOWNLOADING' + | 'TRANSCRIBING' + | 'SUMMARIZING' + | 'FORMATTING' + | 'SAVING' + | 'SUCCESS' + | 'FAILED' export interface AudioMeta { cover_url: string @@ -40,6 +49,7 @@ export interface Markdown { export interface Task { id: string + platform: string markdown: string|Markdown [] //为了兼容之前的笔记 transcript: Transcript status: TaskStatus @@ -59,7 +69,13 @@ export interface Task { interface TaskStore { tasks: Task[] currentTaskId: string | null - addPendingTask: (taskId: string, platform: string) => void + addPendingTask: (taskId: string, platform: string, formData: any, title?: string) => void + addPendingTasks: (items: Array<{ + taskId: string + platform: string + formData: any + title?: string + }>) => void updateTaskContent: (id: string, data: Partial>) => void removeTask: (id: string) => void clearTasks: () => void @@ -74,7 +90,7 @@ export const useTaskStore = create()( tasks: [], currentTaskId: null, - addPendingTask: (taskId: string, platform: string, formData: any) => + addPendingTask: (taskId: string, platform: string, formData: any, title = '') => set(state => ({ tasks: [ @@ -97,7 +113,7 @@ export const useTaskStore = create()( file_path: '', platform: '', raw_info: null, - title: '', + title, video_id: '', }, }, @@ -106,6 +122,39 @@ export const useTaskStore = create()( currentTaskId: taskId, // 默认设置为当前任务 })), + addPendingTasks: items => + set(state => { + if (items.length === 0) return state + const createdAt = new Date().toISOString() + const pendingTasks: Task[] = items.map(item => ({ + formData: item.formData, + id: item.taskId, + status: 'PENDING', + markdown: '', + platform: item.platform, + transcript: { + full_text: '', + language: '', + raw: null, + segments: [], + }, + createdAt, + audioMeta: { + cover_url: '', + duration: 0, + file_path: '', + platform: item.platform, + raw_info: null, + title: item.title || '', + video_id: '', + }, + })) + return { + tasks: [...pendingTasks, ...state.tasks], + currentTaskId: pendingTasks[0].id, + } + }), + updateTaskContent: (id, data) => set(state => ({ tasks: state.tasks.map(task => { diff --git a/backend/app/downloaders/bilibili_downloader.py b/backend/app/downloaders/bilibili_downloader.py index 95f8e5f9..18c8e5c0 100644 --- a/backend/app/downloaders/bilibili_downloader.py +++ b/backend/app/downloaders/bilibili_downloader.py @@ -1,10 +1,12 @@ import os import json import logging +import subprocess import tempfile from abc import ABC from typing import Union, Optional, List +import requests import yt_dlp from app.downloaders.base import Downloader, DownloadQuality, QUALITY_MAP @@ -13,11 +15,16 @@ from app.models.notes_model import AudioDownloadResult from app.models.transcriber_model import TranscriptResult, TranscriptSegment from app.utils.path_helper import get_data_dir -from app.utils.url_parser import extract_video_id +from app.utils.url_parser import extract_video_id, extract_bilibili_p_number from app.services.cookie_manager import CookieConfigManager logger = logging.getLogger(__name__) +BILIBILI_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" +) + # Inject the dm_img_* / web_location risk-control params Bilibili's wbi/playurl # gateway now requires; without them the API path returns HTTP 412. See # app/downloaders/bilibili_dm_patch.py for details. @@ -31,6 +38,159 @@ def __init__(self): self._cookie = self._cookie_mgr.get('bilibili') self._cookiefile = self._write_netscape_cookie_file() + def _headers(self) -> dict: + headers = { + 'User-Agent': BILIBILI_UA, + 'Referer': 'https://www.bilibili.com/', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + } + if self._cookie: + headers['Cookie'] = self._cookie + return headers + + def _direct_info(self, video_url: str) -> dict: + """通过 Bilibili 官方 API 获取分集元数据,避开易触发 412 的网页。""" + bvid = extract_video_id(video_url, "bilibili") + if not bvid: + raise ValueError(f"无法从 Bilibili 链接提取 BV 号: {video_url}") + p = extract_bilibili_p_number(video_url) + response = requests.get( + "https://api.bilibili.com/x/web-interface/view", + params={"bvid": bvid}, + headers=self._headers(), + timeout=15, + ) + response.raise_for_status() + payload = response.json() + if payload.get("code") != 0: + raise RuntimeError( + f"Bilibili view API 失败: {payload.get('message') or payload.get('code')}" + ) + + data = payload.get("data") or {} + pages = data.get("pages") or [] + page_number = p or 1 + if pages: + if page_number < 1 or page_number > len(pages): + raise ValueError(f"分集 p={page_number} 超出范围(共 {len(pages)} 集)") + page = pages[page_number - 1] + else: + page = data + + cid = page.get("cid") or data.get("cid") + if not cid: + raise RuntimeError("Bilibili view API 未返回 cid") + + title = data.get("title") or bvid + part_title = page.get("part") + if len(pages) > 1 and part_title: + title = f"{title} - P{page_number} {part_title}" + return { + "id": f"{bvid}_p{page_number}" if p else bvid, + "bvid": bvid, + "cid": int(cid), + "p": page_number, + "title": title, + "duration": float(page.get("duration") or data.get("duration") or 0), + "thumbnail": data.get("pic"), + "raw_view": data, + } + + def get_series_info(self, video_url: str) -> dict: + """返回多 P 视频的分集清单,供批量生成入口做一次性范围校验。""" + info = self._direct_info(video_url) + data = info["raw_view"] + raw_pages = data.get("pages") or [data] + pages = [] + for index, page in enumerate(raw_pages, start=1): + pages.append({ + "p": index, + "title": page.get("part") or f"P{index}", + "duration": float(page.get("duration") or 0), + }) + return { + "bvid": info["bvid"], + "title": data.get("title") or info["bvid"], + "total": len(pages), + "pages": pages, + } + + def _direct_media_urls(self, info: dict) -> List[str]: + """获取带音轨的渐进式 MP4 地址;匿名访问通常可获得 720p。""" + response = requests.get( + "https://api.bilibili.com/x/player/playurl", + params={ + "bvid": info["bvid"], + "cid": info["cid"], + "qn": 64, + "fnval": 1, + "platform": "html5", + "high_quality": 1, + }, + headers=self._headers(), + timeout=15, + ) + response.raise_for_status() + payload = response.json() + if payload.get("code") != 0: + raise RuntimeError( + f"Bilibili playurl API 失败: {payload.get('message') or payload.get('code')}" + ) + durl = (payload.get("data") or {}).get("durl") or [] + urls: List[str] = [] + for item in durl: + if item.get("url"): + urls.append(item["url"]) + urls.extend(item.get("backup_url") or []) + if not urls: + raise RuntimeError("Bilibili playurl API 未返回可下载的 MP4 地址") + return urls + + def _direct_download_video(self, video_url: str, output_dir: str) -> tuple[str, dict]: + info = self._direct_info(video_url) + video_path = os.path.join(output_dir, f"{info['id']}.mp4") + if os.path.exists(video_path) and os.path.getsize(video_path) > 0: + return video_path, info + + part_path = video_path + ".part" + last_error: Optional[Exception] = None + for media_url in self._direct_media_urls(info): + try: + with requests.get( + media_url, + headers=self._headers(), + stream=True, + timeout=(15, 60), + ) as response: + response.raise_for_status() + with open(part_path, "wb") as output: + for chunk in response.iter_content(chunk_size=1024 * 1024): + if chunk: + output.write(chunk) + if os.path.getsize(part_path) == 0: + raise RuntimeError("Bilibili CDN 返回了空文件") + os.replace(part_path, video_path) + logger.info("Bilibili 官方 API 下载完成: %s", video_path) + return video_path, info + except Exception as exc: + last_error = exc + logger.warning("Bilibili CDN 地址下载失败,尝试备用地址: %s", exc) + if os.path.exists(part_path): + os.unlink(part_path) + raise RuntimeError(f"Bilibili 官方 API 下载失败: {last_error}") + + @staticmethod + def _extract_audio(video_path: str, audio_path: str, bitrate: str) -> None: + subprocess.run( + [ + "ffmpeg", "-y", "-i", video_path, "-vn", + "-acodec", "libmp3lame", "-b:a", f"{bitrate}k", audio_path, + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + def _write_netscape_cookie_file(self) -> Optional[str]: """将 Cookie 写入 Netscape 格式临时文件,返回文件路径(供 yt-dlp cookiefile 使用)""" if not self._cookie: @@ -52,7 +212,8 @@ def download( video_url: str, output_dir: Union[str, None] = None, quality: DownloadQuality = "fast", - need_video:Optional[bool]=False + need_video:Optional[bool]=False, + skip_download: bool = False, ) -> AudioDownloadResult: if output_dir is None: output_dir = get_data_dir() @@ -60,42 +221,24 @@ def download( output_dir=self.cache_data os.makedirs(output_dir, exist_ok=True) - output_path = os.path.join(output_dir, "%(id)s.%(ext)s") - - ydl_opts = { - 'format': 'bestaudio[ext=m4a]/bestaudio/best', - 'outtmpl': output_path, - 'http_headers': {'Referer': 'https://www.bilibili.com'}, - 'postprocessors': [ - { - 'key': 'FFmpegExtractAudio', - 'preferredcodec': 'mp3', - 'preferredquality': '64', - } - ], - 'noplaylist': True, - 'quiet': False, - } - if self._cookiefile: - ydl_opts['cookiefile'] = self._cookiefile - - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(video_url, download=True) - video_id = info.get("id") - title = info.get("title") - duration = info.get("duration", 0) - cover_url = info.get("thumbnail") - audio_path = os.path.join(output_dir, f"{video_id}.mp3") + # Bilibili 网页端会按 IP/指纹返回 HTTP 412。官方 view/playurl API + # 不依赖网页解析,也能准确保留多 P 视频的 p 参数,优先使用该路径。 + direct_info = self._direct_info(video_url) + audio_path = os.path.join(output_dir, f"{direct_info['id']}.mp3") + video_path = os.path.join(output_dir, f"{direct_info['id']}.mp4") + if not skip_download and not os.path.exists(audio_path): + video_path, direct_info = self._direct_download_video(video_url, output_dir) + self._extract_audio(video_path, audio_path, QUALITY_MAP.get(quality, "64")) return AudioDownloadResult( - file_path=audio_path, - title=title, - duration=duration, - cover_url=cover_url, + file_path=audio_path if not skip_download else "", + title=direct_info["title"], + duration=direct_info["duration"], + cover_url=direct_info["thumbnail"], platform="bilibili", - video_id=video_id, - raw_info=info, - video_path=None # ❗音频下载不包含视频路径 + video_id=direct_info["id"], + raw_info=direct_info, + video_path=video_path if need_video and os.path.exists(video_path) else None, ) def download_video( @@ -110,36 +253,7 @@ def download_video( if output_dir is None: output_dir = get_data_dir() os.makedirs(output_dir, exist_ok=True) - print("video_url",video_url) - video_id=extract_video_id(video_url, "bilibili") - video_path = os.path.join(output_dir, f"{video_id}.mp4") - if os.path.exists(video_path): - return video_path - - # 检查是否已经存在 - - - output_path = os.path.join(output_dir, "%(id)s.%(ext)s") - - ydl_opts = { - 'format': 'bv*[ext=mp4]/bestvideo+bestaudio/best', - 'outtmpl': output_path, - 'http_headers': {'Referer': 'https://www.bilibili.com'}, - 'noplaylist': True, - 'quiet': False, - 'merge_output_format': 'mp4', # 确保合并成 mp4 - } - if self._cookiefile: - ydl_opts['cookiefile'] = self._cookiefile - - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(video_url, download=True) - video_id = info.get("id") - video_path = os.path.join(output_dir, f"{video_id}.mp4") - - if not os.path.exists(video_path): - raise FileNotFoundError(f"视频文件未找到: {video_path}") - + video_path, _ = self._direct_download_video(video_url, output_dir) return video_path def delete_video(self, video_path: str) -> str: @@ -346,4 +460,4 @@ def _parse_json3_subtitle(self, subtitle_file: str, language: str) -> Optional[T except Exception as e: logger.warning(f"解析字幕文件失败: {e}") - return None \ No newline at end of file + return None diff --git a/backend/app/routers/note.py b/backend/app/routers/note.py index 80033c87..24fbb1fe 100644 --- a/backend/app/routers/note.py +++ b/backend/app/routers/note.py @@ -2,12 +2,12 @@ import json import os import uuid -from pathlib import Path +from concurrent.futures import as_completed from typing import Optional from urllib.parse import urlparse from fastapi import APIRouter, HTTPException, BackgroundTasks, UploadFile, File -from pydantic import BaseModel, validator, field_validator +from pydantic import BaseModel, Field, field_validator from dataclasses import asdict from app.db.video_task_dao import get_task_by_video @@ -15,9 +15,10 @@ from app.enmus.note_enums import DownloadQuality from app.exceptions.note import NoteError from app.services.note import NoteGenerator, logger +from app.services.constant import SUPPORT_PLATFORM_MAP from app.services.task_serial_executor import task_serial_executor from app.utils.response import ResponseWrapper as R -from app.utils.url_parser import extract_video_id +from app.utils.url_parser import extract_video_id, build_bilibili_page_url from app.validators.video_url_validator import is_supported_video_url from fastapi import APIRouter, Request, HTTPException from fastapi.responses import StreamingResponse @@ -68,8 +69,15 @@ def validate_supported_url(cls, v): return v +class BatchVideoRequest(VideoRequest): + """B 站多 P 批量生成请求。每一集仍会创建独立任务和独立笔记。""" + p_start: int = Field(ge=1) + p_end: int = Field(ge=1) + + NOTE_OUTPUT_DIR = os.getenv("NOTE_OUTPUT_DIR", "note_results") UPLOAD_DIR = "uploads" +BATCH_MAX_EPISODES = int(os.getenv("BATCH_MAX_EPISODES", "100")) def save_note_to_file(task_id: str, note): @@ -112,35 +120,31 @@ def _persist_prefetched_transcript(task_id: str, transcript: dict) -> None: logger.info(f"已写入客户端预取字幕缓存: {target} ({len(cleaned_segments)} 段)") -def run_note_task(task_id: str, video_url: str, platform: str, quality: DownloadQuality, - link: bool = False, screenshot: bool = False, model_name: str = None, provider_id: str = None, - _format: list = None, style: str = None, extras: str = None, video_understanding: bool = False, - video_interval=0, grid_size=[] - ): - +def execute_note_task(task_id: str, video_url: str, platform: str, quality: DownloadQuality, + link: bool = False, screenshot: bool = False, model_name: str = None, + provider_id: str = None, _format: list = None, style: str = None, + extras: str = None, video_understanding: bool = False, + video_interval: int = 0, grid_size: Optional[list] = None): + """实际执行一条笔记任务;单任务和批量任务共用这一实现。""" if not model_name or not provider_id: raise HTTPException(status_code=400, detail="请选择模型和提供者") - def _execute_note_task(): - return NoteGenerator().generate( - video_url=video_url, - platform=platform, - quality=quality, - task_id=task_id, - model_name=model_name, - provider_id=provider_id, - link=link, - _format=_format, - style=style, - extras=extras, - screenshot=screenshot, - video_understanding=video_understanding, - video_interval=video_interval, - grid_size=grid_size, - ) - - logger.info(f"任务进入执行队列 (task_id={task_id})") - note = task_serial_executor.run(_execute_note_task) + note = NoteGenerator().generate( + video_url=video_url, + platform=platform, + quality=quality, + task_id=task_id, + model_name=model_name, + provider_id=provider_id, + link=link, + _format=_format, + style=style, + extras=extras, + screenshot=screenshot, + video_understanding=video_understanding, + video_interval=video_interval, + grid_size=grid_size or [], + ) logger.info(f"Note generated: {task_id}") if not note or not note.markdown: logger.warning(f"任务 {task_id} 执行失败,跳过保存") @@ -155,6 +159,68 @@ def _execute_note_task(): logger.warning(f"向量索引失败(不影响笔记): {e}") +def run_note_task(task_id: str, video_url: str, platform: str, quality: DownloadQuality, + link: bool = False, screenshot: bool = False, model_name: str = None, + provider_id: str = None, _format: list = None, style: str = None, + extras: str = None, video_understanding: bool = False, + video_interval: int = 0, grid_size: Optional[list] = None): + logger.info(f"任务进入执行队列 (task_id={task_id})") + return task_serial_executor.run( + execute_note_task, task_id, video_url, platform, quality, link, screenshot, + model_name, provider_id, _format, style, extras, video_understanding, + video_interval, grid_size, + ) + + +def run_note_batch(task_specs: list[dict]) -> None: + """把整批任务提交到全局线程池,和普通单任务共享并发上限。""" + futures = { + task_serial_executor.submit(execute_note_task, **spec): spec["task_id"] + for spec in task_specs + } + logger.info( + "批量任务已进入执行队列: total=%s, max_workers=%s", + len(futures), task_serial_executor.max_workers, + ) + for future in as_completed(futures): + task_id = futures[future] + try: + future.result() + except Exception as exc: + logger.error("批量子任务异常 (task_id=%s): %s", task_id, exc, exc_info=True) + NoteGenerator()._update_status(task_id, TaskStatus.FAILED, message=str(exc)) + + +def _submission_gate(data: VideoRequest): + """单任务与批量任务共用的模型能力和转写模型就绪检查。""" + if data.video_understanding and ( + str(data.provider_id).lower() == "deepseek" + or str(data.model_name).lower().startswith("deepseek-") + ): + return R.error( + msg="DeepSeek API 当前不支持图片输入,请关闭「视频理解」,或改用支持视觉的多模态模型", + code=300103, + data={"reason": "vision_model_required"}, + ) + + if not data.prefetched_transcript: + from app.services.transcriber_config_manager import TranscriberConfigManager + readiness = TranscriberConfigManager().is_model_ready() + if not readiness["ready"]: + logger.warning(f"拒绝笔记任务:{readiness['reason']}") + return R.error( + msg=readiness["reason"], + code=300102, + data={ + "reason": "transcriber_model_not_ready", + "transcriber_type": readiness["transcriber_type"], + "model_size": readiness["model_size"], + "downloading": readiness["downloading"], + }, + ) + return None + + @router.post('/delete_task') def delete_task(data: RecordRequest): try: @@ -180,26 +246,10 @@ async def upload(file: UploadFile = File(...)): @router.post("/generate_note") def generate_note(data: VideoRequest, background_tasks: BackgroundTasks): try: - # 就绪门禁:本地转写引擎(fast-whisper / mlx-whisper)必须等模型下载完才能跑视频, - # 否则任务会卡在首次下载(慢 / OOM / 截断),用户只看到一个静默失败的任务。 - # 客户端已抓好字幕(prefetched_transcript)则不需要转写,跳过检查。 - if not data.prefetched_transcript: - from app.services.transcriber_config_manager import TranscriberConfigManager - readiness = TranscriberConfigManager().is_model_ready() - if not readiness["ready"]: - logger.warning(f"拒绝 generate_note:{readiness['reason']}") - return R.error( - msg=readiness["reason"], - code=300102, - data={ - "reason": "transcriber_model_not_ready", - "transcriber_type": readiness["transcriber_type"], - "model_size": readiness["model_size"], - "downloading": readiness["downloading"], - }, - ) - - video_id = extract_video_id(data.video_url, data.platform) + gate_error = _submission_gate(data) + if gate_error: + return gate_error + # if not video_id: # raise HTTPException(status_code=400, detail="无法提取视频 ID") # existing = get_task_by_video(video_id, data.platform) @@ -234,6 +284,119 @@ def generate_note(data: VideoRequest, background_tasks: BackgroundTasks): raise HTTPException(status_code=500, detail=str(e)) +@router.post("/generate_note_batch") +def generate_note_batch(data: BatchVideoRequest, background_tasks: BackgroundTasks): + """按 B 站分 P 范围创建独立笔记任务,并受控并发执行。""" + try: + if data.platform != "bilibili": + return R.error( + msg="批量分集生成目前仅支持哔哩哔哩多 P 视频", + code=300104, + data={"reason": "batch_platform_not_supported"}, + ) + if data.task_id: + return R.error( + msg="批量任务不支持复用单个 task_id,请新建批量任务", + code=300104, + data={"reason": "batch_retry_not_supported"}, + ) + if data.prefetched_transcript: + return R.error( + msg="批量任务不能为所有分集共用同一份预取字幕", + code=300104, + data={"reason": "batch_transcript_not_supported"}, + ) + if data.p_end < data.p_start: + return R.error( + msg="结束 P 必须大于或等于起始 P", + code=300104, + data={"reason": "invalid_page_range"}, + ) + + count = data.p_end - data.p_start + 1 + if count > BATCH_MAX_EPISODES: + return R.error( + msg=f"单次最多生成 {BATCH_MAX_EPISODES} 集笔记", + code=300104, + data={"reason": "batch_too_large", "limit": BATCH_MAX_EPISODES}, + ) + + gate_error = _submission_gate(data) + if gate_error: + return gate_error + + downloader = SUPPORT_PLATFORM_MAP["bilibili"] + try: + series = downloader.get_series_info(data.video_url) + except Exception as exc: + logger.warning("解析 B 站分集列表失败: %s", exc) + return R.error( + msg=f"无法读取 B 站分集列表:{exc}", + code=300105, + data={"reason": "series_parse_failed"}, + ) + + if data.p_end > series["total"]: + return R.error( + msg=f"分集范围超出课程总集数(共 {series['total']} 集)", + code=300104, + data={ + "reason": "page_range_exceeded", + "total": series["total"], + }, + ) + + batch_id = str(uuid.uuid4()) + task_specs = [] + response_tasks = [] + status_writer = NoteGenerator() + for page_number in range(data.p_start, data.p_end + 1): + task_id = str(uuid.uuid4()) + page = series["pages"][page_number - 1] + page_url = build_bilibili_page_url(data.video_url, page_number) + display_title = f"{series['title']} - P{page_number} {page['title']}" + + status_writer._update_status(task_id, TaskStatus.PENDING) + task_specs.append({ + "task_id": task_id, + "video_url": page_url, + "platform": data.platform, + "quality": data.quality, + "link": bool(data.link), + "screenshot": bool(data.screenshot), + "model_name": data.model_name, + "provider_id": data.provider_id, + "_format": list(data.format or []), + "style": data.style, + "extras": data.extras, + "video_understanding": bool(data.video_understanding), + "video_interval": data.video_interval or 0, + "grid_size": list(data.grid_size or []), + }) + response_tasks.append({ + "task_id": task_id, + "p": page_number, + "video_url": page_url, + "title": display_title, + }) + + background_tasks.add_task(run_note_batch, task_specs) + logger.info( + "创建批量笔记任务 batch_id=%s, range=P%s-P%s, total=%s", + batch_id, data.p_start, data.p_end, len(task_specs), + ) + return R.success({ + "batch_id": batch_id, + "total": len(response_tasks), + "max_parallel": task_serial_executor.max_workers, + "series_title": series["title"], + "tasks": response_tasks, + }) + except Exception as exc: + logger.error("创建批量笔记任务失败: %s", exc, exc_info=True) + raise HTTPException(status_code=500, detail=str(exc)) + + @router.get("/task_status/{task_id}") def get_task_status(task_id: str): status_path = os.path.join(NOTE_OUTPUT_DIR, f"{task_id}.status.json") diff --git a/backend/app/services/note.py b/backend/app/services/note.py index ebbe83a6..4d820354 100644 --- a/backend/app/services/note.py +++ b/backend/app/services/note.py @@ -443,6 +443,10 @@ def _download_media( unit_width=960, unit_height=540, save_quality=80, + # 并行任务必须使用各自目录。VideoReader.run 会清空目录, + # 如果共享默认目录,会互相删除对方刚截取的帧。 + frame_dir=str(NOTE_OUTPUT_DIR / f"{task_id}_frames"), + grid_dir=str(NOTE_OUTPUT_DIR / f"{task_id}_grids"), ).run() else: logger.info("未指定 grid_size,跳过缩略图生成") @@ -592,7 +596,10 @@ def _summarize_text( :param extras: GPT 额外参数 :return: 生成的 Markdown 字符串 """ - task_id = markdown_cache_file.stem + # markdown 文件名是 _markdown.md;此前直接使用 stem 会把 + # 总结状态写到 _markdown.status.json,前端轮询的原任务状态 + # 因而一直停在 TRANSCRIBING。这里还原真正的任务 ID。 + task_id = markdown_cache_file.stem.removesuffix("_markdown") self._update_status(task_id, TaskStatus.SUMMARIZING) source = GPTSource( diff --git a/backend/app/services/task_serial_executor.py b/backend/app/services/task_serial_executor.py index f4017f92..e42d76b4 100644 --- a/backend/app/services/task_serial_executor.py +++ b/backend/app/services/task_serial_executor.py @@ -11,9 +11,17 @@ def __init__(self, max_workers: int | None = None): self._pool = ThreadPoolExecutor(max_workers=self._max_workers) def run(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: - future: Future = self._pool.submit(fn, *args, **kwargs) + future = self.submit(fn, *args, **kwargs) return future.result() + def submit(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Future: + """提交任务但不阻塞,供批量任务共享同一个全局并发上限。""" + return self._pool.submit(fn, *args, **kwargs) + + @property + def max_workers(self) -> int: + return self._max_workers + def shutdown(self, wait: bool = True): self._pool.shutdown(wait=wait) diff --git a/backend/app/utils/url_parser.py b/backend/app/utils/url_parser.py index 8722fe01..ab35f562 100644 --- a/backend/app/utils/url_parser.py +++ b/backend/app/utils/url_parser.py @@ -81,3 +81,18 @@ def extract_bilibili_p_number(url: str) -> Optional[int]: return p_val return None + + +def build_bilibili_page_url(url: str, page_number: int) -> str: + """把任意 B 站视频链接规范化为指定分 P 的稳定链接。 + + 批量任务不保留 ``spm_id_from``、``vd_source`` 等跟踪参数,避免相同分集 + 因查询参数不同被当成不同资源。短链接会先解析到 BV 号。 + """ + if page_number < 1: + raise ValueError("B 站分集序号必须大于等于 1") + + bvid = extract_video_id(url, "bilibili") + if not bvid: + raise ValueError(f"无法从 Bilibili 链接提取 BV 号: {url}") + return f"https://www.bilibili.com/video/{bvid}?p={page_number}" diff --git a/backend/tests/test_note_batch.py b/backend/tests/test_note_batch.py new file mode 100644 index 00000000..99f70c0d --- /dev/null +++ b/backend/tests/test_note_batch.py @@ -0,0 +1,101 @@ +import json +import pathlib +import sys +import unittest +from unittest.mock import Mock, patch + +from fastapi import BackgroundTasks + +# 部分轻量单元测试会向 sys.modules 注入没有 __path__ 的 app 桩模块。 +# 清掉这些桩,确保本测试无论在单独运行还是 discover 全量运行时都能导入真实路由。 +BACKEND_ROOT = pathlib.Path(__file__).resolve().parents[1] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) +for module_name in list(sys.modules): + if module_name == "app" or module_name.startswith("app."): + del sys.modules[module_name] + +from app.routers import note as note_router + + +class TestGenerateNoteBatch(unittest.TestCase): + def _request(self, p_start=2, p_end=34): + return note_router.BatchVideoRequest( + video_url=( + "https://www.bilibili.com/video/BV1YY4y1i7SN" + "?spm_id_from=333.788&vd_source=test&p=2" + ), + platform="bilibili", + quality="medium", + model_name="test-model", + provider_id="test-provider", + format=[], + style="minimal", + grid_size=[2, 2], + p_start=p_start, + p_end=p_end, + ) + + @staticmethod + def _series(total=40): + return { + "bvid": "BV1YY4y1i7SN", + "title": "计算机组成与设计:RISC-V", + "total": total, + "pages": [ + {"p": p, "title": f"第 {p} 讲", "duration": 600} + for p in range(1, total + 1) + ], + } + + def test_p2_to_p34_creates_33_independent_tasks_without_running_them(self): + downloader = Mock() + downloader.get_series_info.return_value = self._series() + background = BackgroundTasks() + + with ( + patch.object(note_router, "_submission_gate", return_value=None), + patch.dict(note_router.SUPPORT_PLATFORM_MAP, {"bilibili": downloader}), + patch.object(note_router, "NoteGenerator") as generator, + ): + response = note_router.generate_note_batch(self._request(), background) + + payload = json.loads(response.body) + self.assertEqual(payload["code"], 0) + data = payload["data"] + self.assertEqual(data["total"], 33) + self.assertEqual(data["tasks"][0]["p"], 2) + self.assertEqual(data["tasks"][-1]["p"], 34) + self.assertEqual( + data["tasks"][0]["video_url"], + "https://www.bilibili.com/video/BV1YY4y1i7SN?p=2", + ) + self.assertEqual( + data["tasks"][-1]["video_url"], + "https://www.bilibili.com/video/BV1YY4y1i7SN?p=34", + ) + self.assertEqual(len({item["task_id"] for item in data["tasks"]}), 33) + self.assertEqual(generator.return_value._update_status.call_count, 33) + self.assertEqual(len(background.tasks), 1) + + def test_rejects_range_beyond_series_without_creating_tasks(self): + downloader = Mock() + downloader.get_series_info.return_value = self._series(total=30) + background = BackgroundTasks() + + with ( + patch.object(note_router, "_submission_gate", return_value=None), + patch.dict(note_router.SUPPORT_PLATFORM_MAP, {"bilibili": downloader}), + patch.object(note_router, "NoteGenerator") as generator, + ): + response = note_router.generate_note_batch(self._request(), background) + + payload = json.loads(response.body) + self.assertEqual(payload["code"], 300104) + self.assertEqual(payload["data"]["reason"], "page_range_exceeded") + generator.assert_not_called() + self.assertEqual(len(background.tasks), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_task_serial_executor.py b/backend/tests/test_task_serial_executor.py index 14e0e238..b1ee01f0 100644 --- a/backend/tests/test_task_serial_executor.py +++ b/backend/tests/test_task_serial_executor.py @@ -12,12 +12,12 @@ raise ImportError("task_serial_executor module spec not found") task_serial_executor = importlib.util.module_from_spec(spec) spec.loader.exec_module(task_serial_executor) -SerialTaskExecutor = task_serial_executor.SerialTaskExecutor +ConcurrentTaskExecutor = task_serial_executor.ConcurrentTaskExecutor class TestTaskSerialExecutor(unittest.TestCase): - def test_executor_runs_tasks_one_by_one(self): - executor = SerialTaskExecutor() + def test_executor_runs_tasks_in_parallel_with_a_hard_limit(self): + executor = ConcurrentTaskExecutor(max_workers=2) state_lock = threading.Lock() state = {"active": 0, "peak_active": 0} @@ -29,13 +29,13 @@ def critical_work(): with state_lock: state["active"] -= 1 - threads = [threading.Thread(target=lambda: executor.run(critical_work)) for _ in range(2)] - for t in threads: - t.start() - for t in threads: - t.join() + futures = [executor.submit(critical_work) for _ in range(4)] + for future in futures: + future.result() - self.assertEqual(state["peak_active"], 1) + self.assertEqual(state["peak_active"], 2) + self.assertEqual(executor.max_workers, 2) + executor.shutdown() if __name__ == "__main__": diff --git a/backend/tests/test_video_url_support.py b/backend/tests/test_video_url_support.py index b4021278..97f37ac8 100644 --- a/backend/tests/test_video_url_support.py +++ b/backend/tests/test_video_url_support.py @@ -45,6 +45,23 @@ def test_accepts_youtube_shorts_url(self): self.assertTrue(video_url_validator.is_supported_video_url(url)) + def test_build_bilibili_page_url_removes_tracking_parameters(self): + source = ( + "https://www.bilibili.com/video/BV1YY4y1i7SN" + "?spm_id_from=333.788&vd_source=test&p=2" + ) + + self.assertEqual( + url_parser.build_bilibili_page_url(source, 34), + "https://www.bilibili.com/video/BV1YY4y1i7SN?p=34", + ) + + def test_build_bilibili_page_url_rejects_zero(self): + with self.assertRaises(ValueError): + url_parser.build_bilibili_page_url( + "https://www.bilibili.com/video/BV1YY4y1i7SN", 0 + ) + if __name__ == "__main__": unittest.main() From 8453a507ff4c10f6bd62ca97d77639d2eaf92476 Mon Sep 17 00:00:00 2001 From: Shibo-Zhu Date: Mon, 17 Aug 2026 00:32:52 +0800 Subject: [PATCH 2/4] feat(frontend): add projects and history sorting --- .../src/pages/HomePage/components/History.tsx | 2 +- .../pages/HomePage/components/NoteHistory.tsx | 474 ++++++++++++------ .../components/ProjectManagerDialog.tsx | 174 +++++++ .../src/store/taskStore/index.ts | 229 +++++++-- 4 files changed, 687 insertions(+), 192 deletions(-) create mode 100644 BillNote_frontend/src/pages/HomePage/components/ProjectManagerDialog.tsx diff --git a/BillNote_frontend/src/pages/HomePage/components/History.tsx b/BillNote_frontend/src/pages/HomePage/components/History.tsx index 3f34ae86..9996bc6d 100644 --- a/BillNote_frontend/src/pages/HomePage/components/History.tsx +++ b/BillNote_frontend/src/pages/HomePage/components/History.tsx @@ -1,6 +1,6 @@ import NoteHistory from '@/pages/HomePage/components/NoteHistory.tsx' import { useTaskStore } from '@/store/taskStore' -import { Info, Clock, Loader2 } from 'lucide-react' +import { Clock } from 'lucide-react' import { ScrollArea } from '@/components/ui/scroll-area.tsx' const History = () => { const currentTaskId = useTaskStore(state => state.currentTaskId) diff --git a/BillNote_frontend/src/pages/HomePage/components/NoteHistory.tsx b/BillNote_frontend/src/pages/HomePage/components/NoteHistory.tsx index d11e8523..f9181d02 100644 --- a/BillNote_frontend/src/pages/HomePage/components/NoteHistory.tsx +++ b/BillNote_frontend/src/pages/HomePage/components/NoteHistory.tsx @@ -1,184 +1,374 @@ -import { useTaskStore } from '@/store/taskStore' -import { ScrollArea } from '@/components/ui/scroll-area.tsx' -import { Badge } from '@/components/ui/badge.tsx' -import { cn } from '@/lib/utils.ts' -import { Trash } from 'lucide-react' -import { Button } from '@/components/ui/button.tsx' -import PinyinMatch from 'pinyin-match' +import { type FC, useEffect, useMemo, useState } from 'react' import Fuse from 'fuse.js' +import { CheckSquare, Folder, FolderCog, ListChecks, Search, Square, Trash2 } from 'lucide-react' +import toast from 'react-hot-toast' +import LazyImage from '@/components/LazyImage' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, -} from '@/components/ui/tooltip.tsx' -import LazyImage from "@/components/LazyImage.tsx"; -import {FC, useState, useEffect, useMemo} from 'react' +} from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import ProjectManagerDialog from '@/pages/HomePage/components/ProjectManagerDialog' +import { type HistorySort, useTaskStore } from '@/store/taskStore' interface NoteHistoryProps { onSelect: (taskId: string) => void selectedId: string | null } +const UNFILED = 'unfiled' +const titleCollator = new Intl.Collator('zh-CN', { + numeric: true, + sensitivity: 'base', +}) + +const formatTime = (value?: string) => { + if (!value) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '' + return date.toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} + const NoteHistory: FC = ({ onSelect, selectedId }) => { const tasks = useTaskStore(state => state.tasks) + const projects = useTaskStore(state => state.projects) + const selectedProjectId = useTaskStore(state => state.selectedProjectId) + const historySort = useTaskStore(state => state.historySort) const removeTask = useTaskStore(state => state.removeTask) - // 确保baseURL没有尾部斜杠 - const baseURL = (String(import.meta.env.VITE_API_BASE_URL || 'api')).replace(/\/$/, '') + const moveTaskToProject = useTaskStore(state => state.moveTaskToProject) + const moveTasksToProject = useTaskStore(state => state.moveTasksToProject) + const setSelectedProject = useTaskStore(state => state.setSelectedProject) + const setHistorySort = useTaskStore(state => state.setHistorySort) const [rawSearch, setRawSearch] = useState('') const [search, setSearch] = useState('') + const [managerOpen, setManagerOpen] = useState(false) + const [selectionMode, setSelectionMode] = useState(false) + const [selectedIds, setSelectedIds] = useState>(new Set()) + + const baseURL = (String(import.meta.env.VITE_API_BASE_URL || 'api')).replace(/\/$/, '') + const projectById = useMemo( + () => new Map(projects.map(project => [project.id, project])), + [projects] + ) const fuse = useMemo(() => new Fuse(tasks, { keys: ['audioMeta.title'], - threshold: 0.4 // 匹配精度(越低越严格) + threshold: 0.4, }), [tasks]) - useEffect(() => { - const timer = setTimeout(() => { - if (rawSearch === '') return - setSearch(rawSearch) - }, 300) // 300ms 防抖 - return () => clearTimeout(timer) + useEffect(() => { + const timer = window.setTimeout(() => setSearch(rawSearch.trim()), 250) + return () => window.clearTimeout(timer) }, [rawSearch]) - const filteredTasks = search.trim() - ? fuse.search(search).map(result => result.item) - : tasks - if (filteredTasks.length === 0) { - return ( - <> -
- setSearch(e.target.value)} - /> -
-
-

暂无记录

-
- - ) + useEffect(() => { + const existingIds = new Set(tasks.map(task => task.id)) + setSelectedIds(previous => { + const next = new Set([...previous].filter(id => existingIds.has(id))) + return next.size === previous.size ? previous : next + }) + }, [tasks]) + + const filteredTasks = useMemo(() => { + const matchedIds = search + ? new Set(fuse.search(search).map(result => result.item.id)) + : null + const result = tasks.filter(task => { + if (matchedIds && !matchedIds.has(task.id)) return false + if (selectedProjectId === 'all') return true + if (selectedProjectId === UNFILED) return !task.projectId + return task.projectId === selectedProjectId + }) + + return [...result].sort((left, right) => { + if (historySort === 'title') { + const titleOrder = titleCollator.compare( + left.audioMeta.title || '未命名笔记', + right.audioMeta.title || '未命名笔记' + ) + if (titleOrder !== 0) return titleOrder + } + if (historySort === 'earliest') { + return new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime() + } + return new Date(right.lastGeneratedAt || right.createdAt).getTime() + - new Date(left.lastGeneratedAt || left.createdAt).getTime() + }) + }, [fuse, historySort, search, selectedProjectId, tasks]) + + const toggleSelected = (taskId: string) => { + setSelectedIds(previous => { + const next = new Set(previous) + if (next.has(taskId)) next.delete(taskId) + else next.add(taskId) + return next + }) + } + + const allVisibleSelected = filteredTasks.length > 0 + && filteredTasks.every(task => selectedIds.has(task.id)) + + const toggleAllVisible = () => { + setSelectedIds(previous => { + const next = new Set(previous) + if (allVisibleSelected) filteredTasks.forEach(task => next.delete(task.id)) + else filteredTasks.forEach(task => next.add(task.id)) + return next + }) + } + + const handleBulkMove = (value: string) => { + if (selectedIds.size === 0) return + const projectId = value === UNFILED ? null : value + moveTasksToProject([...selectedIds], projectId) + const targetName = projectId ? projectById.get(projectId)?.name : '未归类' + toast.success(`已将 ${selectedIds.size} 条笔记移动到“${targetName}”`) + setSelectedIds(new Set()) } + const stopSelectionMode = () => { + setSelectionMode(false) + setSelectedIds(new Set()) + } return ( <> -
- +
+ + setSearch(e.target.value)} - /> -
-
- {filteredTasks.map(task => ( -
onSelect(task.id)} - className={cn( - 'flex cursor-pointer flex-col rounded-md border border-neutral-200 p-3', - selectedId === task.id && 'border-primary bg-primary-light' - )} + className="h-8 pl-8 text-sm" + value={rawSearch} + onChange={event => setRawSearch(event.target.value)} + /> +
+ +
+ + + +
+ +
+ + +
+ + {selectionMode && ( +
+
+ + 已选 {selectedIds.size} 条
-
-
- {task.status === 'SUCCESS' && ( -
- 已完成 -
+ +
+ )} +
+ + {filteredTasks.length === 0 ? ( +
+

+ {search ? '没有匹配的笔记' : '当前项目暂无笔记'} +

+
+ ) : ( +
+ {filteredTasks.map(task => { + const taskSelected = selectedIds.has(task.id) + const project = task.projectId ? projectById.get(task.projectId) : null + return ( +
selectionMode ? toggleSelected(task.id) : onSelect(task.id)} + className={cn( + 'flex cursor-pointer flex-col rounded-md border border-neutral-200 p-3', + selectedId === task.id && !selectionMode && 'border-primary bg-primary-light', + taskSelected && 'border-blue-500 bg-blue-50' )} - {task.status !== 'SUCCESS' && task.status !== 'FAILED' ? ( -
- 等待中 + > +
+ {selectionMode && ( + + )} + + {task.platform === 'local' ? ( + 封面 + ) : ( + + )} + + + + +
+ {task.audioMeta.title || '未命名笔记'} +
+
+ +

{task.audioMeta.title || '未命名笔记'}

+
+
+
+
+ +
+
+ {task.status === 'SUCCESS' ? ( + 已完成 + ) : task.status === 'FAILED' ? ( + 失败 + ) : ( + 进行中 + )} + + {formatTime(task.lastGeneratedAt || task.createdAt)} +
- ) : ( - <> - )} - {task.status === 'FAILED' && ( -
失败
- )} -
+
-
- - - - +
+ )}
- {/*
*/} - {/* {task.status === 'SUCCESS' && 已完成}*/} - {/* {task.status !== 'SUCCESS' && task.status === 'FAILED' && (*/} - {/* 等待中*/} - {/* )}*/} - {/* {task.status === 'FAILED' && 失败}*/} - {/*
*/} -
-
- ))} -
+ ) + })} +
+ )} + + ) } diff --git a/BillNote_frontend/src/pages/HomePage/components/ProjectManagerDialog.tsx b/BillNote_frontend/src/pages/HomePage/components/ProjectManagerDialog.tsx new file mode 100644 index 00000000..0e41f481 --- /dev/null +++ b/BillNote_frontend/src/pages/HomePage/components/ProjectManagerDialog.tsx @@ -0,0 +1,174 @@ +import { useState } from 'react' +import { Folder, Pencil, Save, Trash2, X } from 'lucide-react' +import toast from 'react-hot-toast' + +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { useTaskStore } from '@/store/taskStore' + +interface ProjectManagerDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +const ProjectManagerDialog = ({ open, onOpenChange }: ProjectManagerDialogProps) => { + const projects = useTaskStore(state => state.projects) + const tasks = useTaskStore(state => state.tasks) + const createProject = useTaskStore(state => state.createProject) + const renameProject = useTaskStore(state => state.renameProject) + const deleteProject = useTaskStore(state => state.deleteProject) + const [newName, setNewName] = useState('') + const [editingId, setEditingId] = useState(null) + const [editingName, setEditingName] = useState('') + + const handleCreate = () => { + const project = createProject(newName) + if (!project) { + toast.error(newName.trim() ? '项目名称已存在' : '请输入项目名称') + return + } + setNewName('') + toast.success(`已创建项目“${project.name}”`) + } + + const handleRename = (id: string) => { + if (!renameProject(id, editingName)) { + toast.error(editingName.trim() ? '项目名称已存在' : '项目名称不能为空') + return + } + setEditingId(null) + setEditingName('') + } + + const handleDelete = (id: string, name: string) => { + const noteCount = tasks.filter(task => task.projectId === id).length + const message = noteCount > 0 + ? `删除项目“${name}”?其中 ${noteCount} 条笔记会移到“未归类”,笔记不会被删除。` + : `删除空项目“${name}”?` + if (!window.confirm(message)) return + deleteProject(id) + if (editingId === id) setEditingId(null) + toast.success('项目已删除,笔记内容已保留') + } + + return ( + + + + 管理笔记项目 + + 项目相当于文件夹。删除项目只会解除归类,不会删除其中的笔记。 + + + +
+ setNewName(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') handleCreate() + }} + /> + +
+ +
+ {projects.length === 0 ? ( +
+ 暂无项目,请先创建一个文件夹 +
+ ) : projects.map(project => { + const noteCount = tasks.filter(task => task.projectId === project.id).length + const editing = editingId === project.id + return ( +
+ + {editing ? ( + setEditingName(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') handleRename(project.id) + if (event.key === 'Escape') setEditingId(null) + }} + /> + ) : ( +
+
{project.name}
+
{noteCount} 条笔记
+
+ )} + + {editing ? ( + <> + + + + ) : ( + + )} + +
+ ) + })} +
+
+
+ ) +} + +export default ProjectManagerDialog diff --git a/BillNote_frontend/src/store/taskStore/index.ts b/BillNote_frontend/src/store/taskStore/index.ts index 1df661fc..2db28de1 100644 --- a/BillNote_frontend/src/store/taskStore/index.ts +++ b/BillNote_frontend/src/store/taskStore/index.ts @@ -1,6 +1,6 @@ import { create } from 'zustand' import { persist, createJSONStorage } from 'zustand/middleware' -import { delete_task, generateNote } from '@/services/note.ts' +import { delete_task, generateNote, type GenerateNotePayload } from '@/services/note.ts' import { v4 as uuidv4 } from 'uuid' import toast from 'react-hot-toast' import { get, set, del } from 'idb-keyval' @@ -17,12 +17,21 @@ export type TaskStatus = | 'SUCCESS' | 'FAILED' +export type HistorySort = 'latest' | 'earliest' | 'title' + +export interface NoteProject { + id: string + name: string + createdAt: string + updatedAt: string +} + export interface AudioMeta { cover_url: string duration: number file_path: string platform: string - raw_info: any + raw_info: unknown title: string video_id: string } @@ -36,7 +45,7 @@ export interface Segment { export interface Transcript { full_text: string language: string - raw: any + raw: unknown segments: Segment[] } export interface Markdown { @@ -47,33 +56,35 @@ export interface Markdown { created_at: string } +export interface TaskFormData extends GenerateNotePayload { + batch_id?: string + batch_page?: number +} + export interface Task { id: string platform: string + projectId?: string | null markdown: string|Markdown [] //为了兼容之前的笔记 transcript: Transcript status: TaskStatus audioMeta: AudioMeta createdAt: string - formData: { - video_url: string - link: undefined | boolean - screenshot: undefined | boolean - platform: string - quality: string - model_name: string - provider_id: string - } + lastGeneratedAt?: string + formData: TaskFormData } interface TaskStore { tasks: Task[] + projects: NoteProject[] currentTaskId: string | null - addPendingTask: (taskId: string, platform: string, formData: any, title?: string) => void + selectedProjectId: string + historySort: HistorySort + addPendingTask: (taskId: string, platform: string, formData: TaskFormData, title?: string) => void addPendingTasks: (items: Array<{ taskId: string platform: string - formData: any + formData: TaskFormData title?: string }>) => void updateTaskContent: (id: string, data: Partial>) => void @@ -81,46 +92,61 @@ interface TaskStore { clearTasks: () => void setCurrentTask: (taskId: string | null) => void getCurrentTask: () => Task | null - retryTask: (id: string) => void + retryTask: (id: string, payload?: TaskFormData) => void + createProject: (name: string) => NoteProject | null + renameProject: (id: string, name: string) => boolean + deleteProject: (id: string) => void + moveTaskToProject: (taskId: string, projectId: string | null) => void + moveTasksToProject: (taskIds: string[], projectId: string | null) => void + setSelectedProject: (projectId: string) => void + setHistorySort: (sort: HistorySort) => void } export const useTaskStore = create()( persist( (set, get) => ({ tasks: [], + projects: [], currentTaskId: null, + selectedProjectId: 'all', + historySort: 'latest', - addPendingTask: (taskId: string, platform: string, formData: any, title = '') => + addPendingTask: (taskId: string, platform: string, formData: TaskFormData, title = '') => - set(state => ({ - tasks: [ - { - formData: formData, - id: taskId, - status: 'PENDING', - markdown: '', - platform: platform, - transcript: { - full_text: '', - language: '', - raw: null, - segments: [], - }, - createdAt: new Date().toISOString(), - audioMeta: { - cover_url: '', - duration: 0, - file_path: '', - platform: '', - raw_info: null, - title, - video_id: '', + set(state => { + const createdAt = new Date().toISOString() + return { + tasks: [ + { + formData, + id: taskId, + status: 'PENDING', + markdown: '', + platform, + projectId: null, + transcript: { + full_text: '', + language: '', + raw: null, + segments: [], + }, + createdAt, + lastGeneratedAt: createdAt, + audioMeta: { + cover_url: '', + duration: 0, + file_path: '', + platform: '', + raw_info: null, + title, + video_id: '', + }, }, - }, - ...state.tasks, - ], - currentTaskId: taskId, // 默认设置为当前任务 - })), + ...state.tasks, + ], + currentTaskId: taskId, // 默认设置为当前任务 + } + }), addPendingTasks: items => set(state => { @@ -132,6 +158,7 @@ export const useTaskStore = create()( status: 'PENDING', markdown: '', platform: item.platform, + projectId: null, transcript: { full_text: '', language: '', @@ -139,6 +166,7 @@ export const useTaskStore = create()( segments: [], }, createdAt, + lastGeneratedAt: createdAt, audioMeta: { cover_url: '', duration: 0, @@ -161,6 +189,10 @@ export const useTaskStore = create()( if (task.id !== id) return task if (task.status === 'SUCCESS' && data.status === 'SUCCESS') return task + const generationUpdate = + data.status === 'SUCCESS' + ? { lastGeneratedAt: new Date().toISOString() } + : {} // 如果是 markdown 字符串,封装为版本 if (typeof data.markdown === 'string') { @@ -194,11 +226,12 @@ export const useTaskStore = create()( return { ...task, ...data, + ...generationUpdate, markdown: updatedMarkdown, } } - return { ...task, ...data } + return { ...task, ...data, ...generationUpdate } }), })), @@ -207,7 +240,7 @@ export const useTaskStore = create()( const currentTaskId = get().currentTaskId return get().tasks.find(task => task.id === currentTaskId) || null }, - retryTask: async (id: string, payload?: any) => { + retryTask: async (id: string, payload?: TaskFormData) => { if (!id){ toast.error('任务不存在') @@ -223,12 +256,13 @@ export const useTaskStore = create()( ...newFormData, task_id: id, }) - } catch (e: any) { + } catch (e: unknown) { + const error = e as { data?: { reason?: string; downloading?: boolean } } // 就绪门禁:转写模型未下载好。不要把任务标成 PENDING(会一直转), // 给提示让用户先去下载。 - if (e?.data?.reason === 'transcriber_model_not_ready') { + if (error.data?.reason === 'transcriber_model_not_ready') { toast.error( - e?.data?.downloading + error.data.downloading ? '转写模型正在下载中,请稍候再重试' : '转写模型尚未下载,请先去「设置 → 音频转写配置」页下载', ) @@ -270,12 +304,109 @@ export const useTaskStore = create()( } }, + createProject: name => { + const cleanName = name.trim().slice(0, 50) + if (!cleanName) return null + const duplicate = get().projects.some( + project => project.name.toLocaleLowerCase() === cleanName.toLocaleLowerCase() + ) + if (duplicate) return null + + const now = new Date().toISOString() + const project: NoteProject = { + id: uuidv4(), + name: cleanName, + createdAt: now, + updatedAt: now, + } + set(state => ({ projects: [...state.projects, project] })) + return project + }, + + renameProject: (id, name) => { + const cleanName = name.trim().slice(0, 50) + if (!cleanName) return false + const duplicate = get().projects.some( + project => + project.id !== id + && project.name.toLocaleLowerCase() === cleanName.toLocaleLowerCase() + ) + if (duplicate || !get().projects.some(project => project.id === id)) return false + + set(state => ({ + projects: state.projects.map(project => + project.id === id + ? { ...project, name: cleanName, updatedAt: new Date().toISOString() } + : project + ), + })) + return true + }, + + deleteProject: id => + set(state => ({ + projects: state.projects.filter(project => project.id !== id), + tasks: state.tasks.map(task => + task.projectId === id ? { ...task, projectId: null } : task + ), + selectedProjectId: state.selectedProjectId === id ? 'all' : state.selectedProjectId, + })), + + moveTaskToProject: (taskId, projectId) => { + const target = projectId && get().projects.some(project => project.id === projectId) + ? projectId + : null + set(state => ({ + tasks: state.tasks.map(task => + task.id === taskId ? { ...task, projectId: target } : task + ), + })) + }, + + moveTasksToProject: (taskIds, projectId) => { + const ids = new Set(taskIds) + const target = projectId && get().projects.some(project => project.id === projectId) + ? projectId + : null + set(state => ({ + tasks: state.tasks.map(task => + ids.has(task.id) ? { ...task, projectId: target } : task + ), + })) + }, + + setSelectedProject: selectedProjectId => set({ selectedProjectId }), + setHistorySort: historySort => set({ historySort }), + clearTasks: () => set({ tasks: [], currentTaskId: null }), setCurrentTask: taskId => set({ currentTaskId: taskId }), }), { name: 'task-storage', + version: 2, + migrate: persistedState => { + const state = (persistedState || {}) as Partial + const projects = Array.isArray(state.projects) ? state.projects : [] + const projectIds = new Set(projects.map(project => project.id)) + const selectedProjectId = + state.selectedProjectId === 'unfiled' + || state.selectedProjectId === 'all' + || projectIds.has(state.selectedProjectId || '') + ? state.selectedProjectId || 'all' + : 'all' + return { + ...state, + projects, + selectedProjectId, + historySort: state.historySort || 'latest', + tasks: (state.tasks || []).map(task => ({ + ...task, + projectId: task.projectId && projectIds.has(task.projectId) ? task.projectId : null, + lastGeneratedAt: task.lastGeneratedAt || task.createdAt, + })), + } + }, storage: createJSONStorage(() => ({ getItem: async (name: string): Promise => { const value = await get(name) From e259b4063baa758c86cc1a765c5bdd09a3ccf4af Mon Sep 17 00:00:00 2001 From: Shibo-Zhu Date: Mon, 17 Aug 2026 00:36:18 +0800 Subject: [PATCH 3/4] feat(frontend): add markdown editing and image management --- .../HomePage/components/MarkdownEditor.tsx | 486 ++++++++++++++++++ .../HomePage/components/MarkdownHeader.tsx | 31 +- .../HomePage/components/MarkdownViewer.tsx | 119 ++--- BillNote_frontend/src/services/note.ts | 34 ++ .../src/store/taskStore/index.ts | 35 ++ backend/app/routers/note.py | 66 ++- backend/app/services/note_image.py | 166 ++++++ backend/tests/test_note_image.py | 43 ++ 8 files changed, 909 insertions(+), 71 deletions(-) create mode 100644 BillNote_frontend/src/pages/HomePage/components/MarkdownEditor.tsx create mode 100644 backend/app/services/note_image.py create mode 100644 backend/tests/test_note_image.py diff --git a/BillNote_frontend/src/pages/HomePage/components/MarkdownEditor.tsx b/BillNote_frontend/src/pages/HomePage/components/MarkdownEditor.tsx new file mode 100644 index 00000000..49c77c1e --- /dev/null +++ b/BillNote_frontend/src/pages/HomePage/components/MarkdownEditor.tsx @@ -0,0 +1,486 @@ +import { + Bold, + ClipboardPaste, + Code2, + Eye, + Heading2, + ImagePlus, + Images, + Italic, + Link, + List, + ListOrdered, + Quote, + Save, + Trash2, + X, +} from 'lucide-react' +import { + type ClipboardEvent, + type ReactNode, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import toast from 'react-hot-toast' + +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { ScrollArea } from '@/components/ui/scroll-area' +import { deleteNoteImage, getNoteImageConfig, uploadNoteImage } from '@/services/note' + +interface MarkdownEditorProps { + value: string + onSave: (content: string) => boolean | Promise + onCancel: () => void + renderPreview: (content: string) => ReactNode +} + +interface MarkdownImage { + alt: string + url: string + raw: string + start: number + imageId?: string +} + +const IMAGE_DIRECTORY_KEY = 'bilinote-editor-image-directory' +const MANAGED_IMAGE_PATTERN = /\/api\/note_images\/([0-9a-f]{32})(?:[?#][^\s)]*)?$/ + +const parseMarkdownImages = (content: string): MarkdownImage[] => { + const pattern = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g + const images: MarkdownImage[] = [] + let match: RegExpExecArray | null + while ((match = pattern.exec(content)) !== null) { + images.push({ + alt: match[1], + url: match[2], + raw: match[0], + start: match.index, + imageId: match[2].match(MANAGED_IMAGE_PATTERN)?.[1], + }) + } + return images +} + +const MarkdownEditor = ({ value, onSave, onCancel, renderPreview }: MarkdownEditorProps) => { + const [draft, setDraft] = useState(value) + const [showPreview, setShowPreview] = useState(true) + const [imageDialogOpen, setImageDialogOpen] = useState(false) + const [managerOpen, setManagerOpen] = useState(false) + const [defaultDirectory, setDefaultDirectory] = useState('BiliNote/backend/data/note_images') + const [useCustomDirectory, setUseCustomDirectory] = useState(false) + const [customDirectory, setCustomDirectory] = useState('') + const [imageFile, setImageFile] = useState(null) + const [imageAlt, setImageAlt] = useState('') + const [uploading, setUploading] = useState(false) + const [pasteUploadCount, setPasteUploadCount] = useState(0) + const [saving, setSaving] = useState(false) + const [pendingFileDeletes, setPendingFileDeletes] = useState>(new Set()) + const newlyUploadedIds = useRef>(new Set()) + const preserveUploadedFiles = useRef(false) + const editorActive = useRef(true) + const textareaRef = useRef(null) + + const images = useMemo(() => parseMarkdownImages(draft), [draft]) + const isDirty = draft !== value + + useEffect(() => { + const savedDirectory = localStorage.getItem(IMAGE_DIRECTORY_KEY) || '' + if (savedDirectory) { + setCustomDirectory(savedDirectory) + setUseCustomDirectory(true) + } + getNoteImageConfig() + .then(config => setDefaultDirectory(config.default_directory)) + .catch(() => undefined) + }, []) + + useEffect(() => { + const warnBeforeUnload = (event: BeforeUnloadEvent) => { + if (!isDirty) return + event.preventDefault() + } + window.addEventListener('beforeunload', warnBeforeUnload) + return () => window.removeEventListener('beforeunload', warnBeforeUnload) + }, [isDirty]) + + useEffect(() => { + // React StrictMode 会在开发环境执行一次 setup → cleanup → setup。 + // 每次 setup 都恢复 active,避免把随后完成的粘贴上传误判为组件已卸载。 + const uploadedImageIds = newlyUploadedIds + editorActive.current = true + return () => { + editorActive.current = false + if (!preserveUploadedFiles.current) { + void Promise.allSettled([...uploadedImageIds.current].map(deleteNoteImage)) + } + } + }, []) + + const focusSelection = (start: number, end: number) => { + requestAnimationFrame(() => { + textareaRef.current?.focus() + textareaRef.current?.setSelectionRange(start, end) + }) + } + + const replaceSelection = (before: string, after: string, placeholder: string) => { + const textarea = textareaRef.current + const start = textarea?.selectionStart ?? draft.length + const end = textarea?.selectionEnd ?? draft.length + const selected = draft.slice(start, end) || placeholder + const next = `${draft.slice(0, start)}${before}${selected}${after}${draft.slice(end)}` + setDraft(next) + focusSelection(start + before.length, start + before.length + selected.length) + } + + const insertAtCursor = (text: string) => { + const textarea = textareaRef.current + const start = textarea?.selectionStart ?? draft.length + const end = textarea?.selectionEnd ?? start + setDraft(`${draft.slice(0, start)}${text}${draft.slice(end)}`) + focusSelection(start + text.length, start + text.length) + } + + const uploadPastedImages = ( + files: File[], + selectionStart: number, + selectionEnd: number, + ) => { + const directory = useCustomDirectory ? customDirectory.trim() : '' + if (useCustomDirectory && !directory) { + toast.error('请先填写自定义图片保存目录') + return + } + + const uploads = files.map((file, index) => { + const token = crypto.randomUUID() + return { + file, + placeholder: ``, + alt: file.name && !/^image\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(file.name) + ? file.name.replace(/\.[^.]+$/, '').replaceAll('[', '').replaceAll(']', '') + : `粘贴图片${files.length > 1 ? ` ${index + 1}` : ''}`, + } + }) + const placeholderText = uploads.map(item => item.placeholder).join('\n') + setDraft(current => ( + `${current.slice(0, selectionStart)}${placeholderText}${current.slice(selectionEnd)}` + )) + focusSelection( + selectionStart + placeholderText.length, + selectionStart + placeholderText.length, + ) + setPasteUploadCount(count => count + uploads.length) + + void Promise.allSettled(uploads.map(async item => { + try { + const uploaded = await uploadNoteImage(item.file, directory) + if (!editorActive.current) { + await deleteNoteImage(uploaded.id) + return + } + newlyUploadedIds.current.add(uploaded.id) + if (directory) localStorage.setItem(IMAGE_DIRECTORY_KEY, directory) + setDraft(current => current.replace( + item.placeholder, + `![${item.alt}](${uploaded.url})`, + )) + } catch { + if (editorActive.current) { + setDraft(current => current.replace(item.placeholder, '')) + } + throw new Error('paste image upload failed') + } finally { + if (editorActive.current) setPasteUploadCount(count => Math.max(0, count - 1)) + } + })).then(results => { + if (!editorActive.current) return + const succeeded = results.filter(result => result.status === 'fulfilled').length + if (succeeded > 0) toast.success(`已粘贴 ${succeeded} 张图片`) + }) + } + + const handlePaste = (event: ClipboardEvent) => { + const imageFiles = Array.from(event.clipboardData.items) + .filter(item => item.kind === 'file' && item.type.startsWith('image/')) + .map(item => item.getAsFile()) + .filter((file): file is File => file !== null) + + if (imageFiles.length === 0) return + + event.preventDefault() + uploadPastedImages( + imageFiles, + event.currentTarget.selectionStart, + event.currentTarget.selectionEnd, + ) + } + + const prefixLines = (prefix: string) => { + const textarea = textareaRef.current + const start = textarea?.selectionStart ?? draft.length + const end = textarea?.selectionEnd ?? draft.length + const lineStart = draft.lastIndexOf('\n', Math.max(0, start - 1)) + 1 + const selected = draft.slice(lineStart, end) || '内容' + const replacement = selected.split('\n').map(line => `${prefix}${line}`).join('\n') + setDraft(`${draft.slice(0, lineStart)}${replacement}${draft.slice(end)}`) + focusSelection(lineStart, lineStart + replacement.length) + } + + const insertUploadedImage = async () => { + if (!imageFile) { + toast.error('请先选择图片') + return + } + const directory = useCustomDirectory ? customDirectory.trim() : '' + if (useCustomDirectory && !directory) { + toast.error('请输入自定义保存目录') + return + } + + setUploading(true) + try { + const uploaded = await uploadNoteImage(imageFile, directory) + newlyUploadedIds.current.add(uploaded.id) + if (directory) localStorage.setItem(IMAGE_DIRECTORY_KEY, directory) + else localStorage.removeItem(IMAGE_DIRECTORY_KEY) + + const alt = imageAlt.trim() || imageFile.name.replace(/\.[^.]+$/, '') || '图片' + insertAtCursor(`![${alt}](${uploaded.url})`) + setImageDialogOpen(false) + setImageFile(null) + setImageAlt('') + toast.success(`图片已保存到 ${uploaded.directory}`) + } catch { + // 请求层已经展示具体错误。 + } finally { + setUploading(false) + } + } + + const removeImageReference = (image: MarkdownImage, deleteFile: boolean) => { + if (deleteFile && !window.confirm('确定同时删除这张本地图片文件吗?该操作保存后不可恢复。')) { + return + } + setDraft(current => { + const before = current.slice(0, image.start) + const target = current.slice(image.start, image.start + image.raw.length) + if (target !== image.raw) return current.replace(image.raw, '') + return before + current.slice(image.start + image.raw.length) + }) + if (deleteFile && image.imageId) { + setPendingFileDeletes(current => new Set(current).add(image.imageId!)) + } + } + + const handleCancel = () => { + if (isDirty && !window.confirm('放弃尚未保存的修改吗?')) return + const imagesToDiscard = [...newlyUploadedIds.current] + newlyUploadedIds.current.clear() + preserveUploadedFiles.current = true + onCancel() + void Promise.allSettled(imagesToDiscard.map(deleteNoteImage)) + } + + const handleSave = async () => { + setSaving(true) + try { + preserveUploadedFiles.current = true + const saved = await onSave(draft) + if (!saved) { + preserveUploadedFiles.current = false + toast.error('笔记保存失败') + return + } + await Promise.allSettled([...pendingFileDeletes].map(deleteNoteImage)) + newlyUploadedIds.current.clear() + setPendingFileDeletes(new Set()) + toast.success('笔记已保存') + } catch { + preserveUploadedFiles.current = false + toast.error('笔记保存失败') + } finally { + setSaving(false) + } + } + + return ( +
+
+ + + + + + + + +
+ + + + + {pasteUploadCount > 0 ? `正在上传 ${pasteUploadCount} 张…` : '可直接 Ctrl+V 粘贴图片'} + +
+ + + +
+
+ +
+