diff --git a/BillNote_frontend/src/pages/HomePage/components/NoteForm.tsx b/BillNote_frontend/src/pages/HomePage/components/NoteForm.tsx index 6fffa1c..a1f5dbf 100644 --- a/BillNote_frontend/src/pages/HomePage/components/NoteForm.tsx +++ b/BillNote_frontend/src/pages/HomePage/components/NoteForm.tsx @@ -1,542 +1,410 @@ +/* NoteForm.tsx ---------------------------------------------------- */ import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, + Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form.tsx' import { useEffect } from 'react' -import { Input } from '@/components/ui/input.tsx' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select.tsx' -import { Button } from '@/components/ui/button.tsx' -import { Checkbox } from '@/components/ui/checkbox.tsx' -import { useForm } from 'react-hook-form' -import { z } from 'zod' +import { useForm, useWatch } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' -import { Info, Clock, Loader2 } from 'lucide-react' -import { message } from 'antd' -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip.tsx' +import { z } from 'zod' + +import { Info, Loader2 } from 'lucide-react' +import { message, Alert } from 'antd' + import { generateNote } from '@/services/note.ts' -import { useTaskStore } from '@/store/taskStore' -import NoteHistory from '@/pages/HomePage/components/NoteHistory.tsx' -import { useModelStore } from '@/store/modelStore' -import { Alert } from 'antd' -import { Textarea } from '@/components/ui/textarea.tsx' -import { ScrollArea } from '@/components/ui/scroll-area.tsx' import { uploadFile } from '@/services/upload.ts' -// ✅ 定义表单 schema -const formSchema = z - .object({ - video_url: z.string(), - platform: z.string().nonempty('请选择平台'), - quality: z.enum(['fast', 'medium', 'slow'], { - required_error: '请选择音频质量', - }), - screenshot: z.boolean().optional(), - link: z.boolean().optional(), - model_name: z.string().nonempty('请选择模型'), - format: z.array(z.string()).default([]), - style: z.string().nonempty('请选择笔记生成风格'), - extras: z.string().optional(), - }) - .superRefine((data, ctx) => { - const { video_url, platform } = data +import { useTaskStore } from '@/store/taskStore' +import { useModelStore } from '@/store/modelStore' +import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip.tsx"; +import {Checkbox} from "@/components/ui/checkbox.tsx"; +import {ScrollArea} from "@/components/ui/scroll-area.tsx"; +import {Button} from "@/components/ui/button.tsx"; +import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select.tsx"; +import {Input} from "@/components/ui/input.tsx"; +import {Textarea} from "@/components/ui/textarea.tsx"; - if (platform === 'local' || platform === 'douyin') { - if (!video_url || typeof video_url !== 'string') { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: '本地视频路径不能为空', - path: ['video_url'], - }) - } - } else { - try { - const url = new URL(video_url) - if (!(url.protocol === 'http:' || url.protocol === 'https:')) { - throw new Error() - } - } catch { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: '请输入正确的视频链接', - path: ['video_url'], - }) - } - } - }) - -type NoteFormValues = z.infer +/* -------------------- 常量 -------------------- */ const noteFormats = [ - { - label: '目录', - value: 'toc', - }, + { label: '目录', value: 'toc' }, { label: '原片跳转', value: 'link' }, { label: '原片截图', value: 'screenshot' }, { label: 'AI总结', value: 'summary' }, -] -const noteStyles = [ - { - label: '精简', - value: 'minimal', // 简洁、快速呈现要点 - }, - { - label: '详细', - value: 'detailed', // 详细记录,包含时间戳、关键点 - }, - { - label: '教程', - value: 'tutorial', // 详细记录,包含时间戳、关键点 - }, - { - label: '学术', - value: 'academic', // 适合学术报告,正式且结构化 - }, - { - label: '小红书', - value: 'xiaohongshu', // 适合社交平台分享,亲切、口语化 - }, - { - label: '生活向', - value: 'life_journal', // 记录个人生活感悟,情感化表达 - }, - { - label: '任务导向', - value: 'task_oriented', // 强调任务、目标,适合工作和待办事项 - }, - { - label: '商业风格', - value: 'business', // 适合商业报告、会议纪要,正式且精准 - }, - { - label: '会议纪要', - value: 'meeting_minutes', // 适合商业报告、会议纪要,正式且精准 - }, -] +] as const +const noteStyles = [ + { label: '精简', value: 'minimal' }, + { label: '详细', value: 'detailed' }, + { label: '教程', value: 'tutorial' }, + { label: '学术', value: 'academic' }, + { label: '小红书', value: 'xiaohongshu' }, + { label: '生活向', value: 'life_journal' }, + { label: '任务导向', value: 'task_oriented' }, + { label: '商业风格', value: 'business' }, + { label: '会议纪要', value: 'meeting_minutes' }, +] as const + +/* -------------------- 校验 Schema -------------------- */ +const formSchema = z.object({ + video_url: z.string(), + platform: z.string().nonempty('请选择平台'), + quality: z.enum(['fast', 'medium', 'slow']), + screenshot: z.boolean().optional(), + link: z.boolean().optional(), + model_name: z.string().nonempty('请选择模型'), + format: z.array(z.string()).default([]), + style: z.string().nonempty('请选择笔记生成风格'), + extras: z.string().optional(), + video_understanding: z.boolean().optional(), + video_interval: z.coerce.number().min(1).max(30).default(4).optional(), + grid_size: z.tuple([ + z.coerce.number().min(1).max(10), + z.coerce.number().min(1).max(10), + ]).default([3, 3]).optional(), +}).superRefine(({ video_url, platform }, ctx) => { + if (platform === 'local' || platform === 'douyin') { + if (!video_url) { + ctx.addIssue({ code: 'custom', message: '本地视频路径不能为空', path: ['video_url'] }) + } + } else { + try { + const url = new URL(video_url) + if (!['http:', 'https:'].includes(url.protocol)) throw new Error() + } catch { + ctx.addIssue({ code: 'custom', message: '请输入正确的视频链接', path: ['video_url'] }) + } + } +}) + +type NoteFormValues = z.infer + +/* -------------------- 可复用子组件 -------------------- */ +const SectionHeader = ({ title, tip }: { title: string; tip?: string }) => ( +
+

{title}

+ {tip && ( + + + + + + {tip} + + + )} +
+) + +const CheckboxGroup = ({ + value = [], onChange, disabledMap, + }: { + value?: string[] + onChange: (v: string[]) => void + disabledMap: Record +}) => ( +
+ {noteFormats.map(({ label, value: v }) => ( + + ))} +
+) + +/* -------------------- 主组件 -------------------- */ const NoteForm = () => { - useTaskStore(state => state.tasks) - const setCurrentTask = useTaskStore(state => state.setCurrentTask) - const currentTaskId = useTaskStore(state => state.currentTaskId) - const getCurrentTask = useTaskStore(state => state.getCurrentTask) - const loadEnabledModels = useModelStore(state => state.loadEnabledModels) - const modelList = useModelStore(state => state.modelList) - const showFeatureHint = useModelStore(state => state.showFeatureHint) - const setShowFeatureHint = useModelStore(state => state.setShowFeatureHint) + /* ---- 全局状态 ---- */ + const { addPendingTask, currentTaskId, getCurrentTask } = useTaskStore() + const { loadEnabledModels, modelList, showFeatureHint, setShowFeatureHint } = useModelStore() + + /* ---- 表单 ---- */ const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { - video_url: '', platform: 'bilibili', - quality: 'medium', // 默认中等质量 - screenshot: false, - model_name: modelList[0]?.model_name || '', // 确保有值 - format: [], // 初始化为空数组 - style: 'minimal', // 默认选择精简风格 - extras: '', // 初始化为空字符串 + quality: 'medium', + model_name: modelList[0]?.model_name || '', + style: 'minimal', + video_interval: 4, + grid_size: [3, 3], + format: [], }, }) - const platform = form.watch('platform') - const onClose = () => { - setShowFeatureHint(false) - } - const isGenerating = () => { - console.log('🚀 isGenerating', getCurrentTask()?.status) - return ( - getCurrentTask()?.status != 'SUCCESS' && - getCurrentTask()?.status != 'FAILED' && - getCurrentTask()?.status != undefined - ) - } - const handleFileUpload = async (file: File, onSuccess: (url: string) => void) => { - if (!file) return - const formData = new FormData() - formData.append('file', file) + /* ---- 派生状态(只 watch 一次,提高性能) ---- */ + const platform = useWatch({ control: form.control, name: 'platform' }) as string + const videoUnderstandingEnabled = useWatch({ control: form.control, name: 'video_understanding' }) - try { - const res = await uploadFile(formData) - if (res.data.code === 0) { - const uploadedUrl = res.data.data.url - console.log('✅ 上传成功', uploadedUrl) - - onSuccess(uploadedUrl) - } - } catch (error) { - console.error('上传失败', error) - // 可以弹个 toast 或者提示上传失败 - } - } - // TODO 修复选择其他视频平台以后再选择本地视频还可以选择 Link 的问题 - const onSubmit = async (data: NoteFormValues) => { - console.log('🎯 提交内容:', data) - message.success('提交任务') - - const payload = { - video_url: data.video_url, - platform: data.platform, - quality: data.quality, - model_name: data.model_name, - provider_id: modelList.find(model => model.model_name === data.model_name).provider_id, - format: data.format, - style: data.style, - extras: data.extras, - } - const res = await generateNote(payload) - const taskId = res.data.task_id - useTaskStore.getState().addPendingTask(taskId, data.platform, payload) - } + /* ---- 副作用 ---- */ useEffect(() => { loadEnabledModels() - }, []) + return}, []) + + /* ---- 帮助函数 ---- */ + const isGenerating = () => + !['SUCCESS', 'FAILED', undefined].includes(getCurrentTask()?.status) + + const handleFileUpload = async (file: File, cb: (url: string) => void) => { + const formData = new FormData() + formData.append('file', file) + try { + const { data } = await uploadFile(formData) + if (data.code === 0) cb(data.data.url) + } catch (err) { + console.error('上传失败:', err) + message.error('上传失败,请重试') + } + } + + const onSubmit = async (values: NoteFormValues) => { + const payload:NoteFormValues = { + ...values, + provider_id: modelList.find(m => m.model_name === values.model_name)!.provider_id, + } + message.success('已提交任务') + const { data } = await generateNote(payload) + addPendingTask(data.task_id, values.platform, payload) + } + + /* -------------------- 渲染 -------------------- */ return ( - <>
- -
- -
-
-
-

视频链接

- - - - - - -

输入视频链接,支持哔哩哔哩、YouTube等平台

-
-
-
-
+ + {/* 顶部按钮 */} + -
- {/* 平台选择 */} - +
+ {/* 平台选择 */} + ( + + + + + )} + /> + {/* 链接输入 / 上传框 */} + ( + + {platform === 'local' ? ( + <> + +
{ e.preventDefault(); e.stopPropagation() }} + onDrop={e => { + e.preventDefault() + const file = e.dataTransfer.files?.[0] + if (file) handleFileUpload(file, field.onChange) + }} + onClick={() => { + const input = document.createElement('input') + input.type = 'file' + input.accept = 'video/*' + input.onchange = e => { + const file = (e.target as HTMLInputElement).files?.[0] + if (file) handleFileUpload(file, field.onChange) + } + input.click() + }} + > +

+ 拖拽文件到这里上传
+ 或点击选择文件 +

+
+ + ) : ( + + )} + +
+ )} + /> +
+ + {/* 模型选择 */} + ( + - )} - /> - - {/* 视频地址 */} - ( - - - {form.watch('platform') === 'local' ? ( -
- {/* 第一行:本地路径输入框 */} - -
- ) : ( - - )} -
- -
- )} - /> -
- - ( - - - {form.watch('platform') === 'local' ? ( -
{ - e.preventDefault() - e.stopPropagation() - }} - onDrop={e => { - e.preventDefault() - e.stopPropagation() - const file = e.dataTransfer.files?.[0] - if (file) { - handleFileUpload(file, uploadedUrl => { - field.onChange(uploadedUrl) - }) - } - }} - onClick={() => { - const input = document.createElement('input') - input.type = 'file' - input.accept = 'video/*' - input.onchange = (e: any) => { - const file = e.target.files?.[0] - if (file) { - handleFileUpload(file, uploadedUrl => { - field.onChange(uploadedUrl) - }) - } - } - input.click() - }} - > -
-

拖拽文件到这里上传

-

或点击选择文件

-
-
- ) : ( - <> - )} -
- {/* ❗可以不要FormMessage,不然重复两次报错提示 */} -
)} - /> - {/*

*/} - {/* 支持哔哩哔哩视频链接,例如:*/} - {/* https://www.bilibili.com/video/BV1vc25YQE9X/*/} - {/*

*/} - {/* (*/} - {/* */} - {/*
*/} - {/*

音频质量

*/} - {/* */} - {/* */} - {/* */} - {/* */} - {/* */} - {/* */} - {/*

*/} - {/* 质量越高,下载体积越大,速度越慢*/} - {/*

*/} - {/*
*/} - {/*
*/} - {/*
*/} - {/*
*/} - {/* */} - {/* /!**!/*/} - {/* /!* 质量越高,下载体积越大,速度越慢*!/*/} - {/* /!**!/*/} - {/* */} - {/*
*/} - {/* )}*/} - {/*/>*/} - - ( - -
-

模型选择

- - - - - - -

- 不同模型返回质量不同,可自行测试 -

-
-
-
-
- - {/**/} - {/* 质量越高,下载体积越大,速度越慢*/} - {/**/} - -
- )} - /> -
- - ( - -
-

笔记风格

- - - - - - -

选择你希望生成的笔记风格

-
-
-
-
- - - - -
- )} /> - ( - -
-

笔记格式

- - - - - - -

- 选择要包含的笔记元素,比如时间戳、截图提示或总结 -

-
-
-
-
+ {/* 视频理解 */} + +
- -
- {noteFormats.map(item => ( - - ))} -
-
- ( + ( -
-

备注

- - - - - - -

会把这段加入到Prompt最后 可自行测试

-
-
-
+
+ 启用 + form.setValue('video_understanding', v)} + />
-