mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-05-06 20:42:52 +08:00
- 新增关于页面组件,介绍项目背景、功能和使用方法 - 重构笔记生成逻辑,支持多版本笔记 - 新增笔记版本选择、复制和导出功能 -优化笔记界面布局和交互 - 调整部分组件样式,提升用户体验
480 lines
19 KiB
TypeScript
480 lines
19 KiB
TypeScript
/* NoteForm.tsx ---------------------------------------------------- */
|
||
import {
|
||
Form, FormControl, FormField, FormItem, FormLabel, FormMessage,
|
||
} from '@/components/ui/form.tsx'
|
||
import { useEffect } from 'react'
|
||
import { useForm, useWatch } from 'react-hook-form'
|
||
import { zodResolver } from '@hookform/resolvers/zod'
|
||
import { z } from 'zod'
|
||
|
||
import { Info, Loader2 ,Plus} from 'lucide-react'
|
||
import { message, Alert } from 'antd'
|
||
import { generateNote } from '@/services/note.ts'
|
||
import { uploadFile } from '@/services/upload.ts'
|
||
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";
|
||
import {noteStyles,noteFormats} from "@/constant/note.ts";
|
||
|
||
|
||
/* -------------------- 校验 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<typeof formSchema>
|
||
|
||
/* -------------------- 可复用子组件 -------------------- */
|
||
const SectionHeader = ({ title, tip }: { title: string; tip?: string }) => (
|
||
<div className="my-3 flex items-center justify-between">
|
||
<h2 className="block">{title}</h2>
|
||
{tip && (
|
||
<TooltipProvider>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Info className="hover:text-primary h-4 w-4 cursor-pointer text-neutral-400" />
|
||
</TooltipTrigger>
|
||
<TooltipContent className="text-xs">{tip}</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
)}
|
||
</div>
|
||
)
|
||
|
||
const CheckboxGroup = ({
|
||
value = [], onChange, disabledMap,
|
||
}: {
|
||
value?: string[]
|
||
onChange: (v: string[]) => void
|
||
disabledMap: Record<string, boolean>
|
||
}) => (
|
||
<div className="flex flex-wrap space-x-1.5">
|
||
{noteFormats.map(({ label, value: v }) => (
|
||
<label key={v} className="flex items-center space-x-2">
|
||
<Checkbox
|
||
checked={value.includes(v)}
|
||
disabled={disabledMap[v]}
|
||
onCheckedChange={checked =>
|
||
onChange(checked ? [...value, v] : value.filter(x => x !== v))}
|
||
/>
|
||
<span>{label}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
)
|
||
|
||
/* -------------------- 主组件 -------------------- */
|
||
const NoteForm = () => {
|
||
/* ---- 全局状态 ---- */
|
||
const { addPendingTask, currentTaskId, setCurrentTask,getCurrentTask ,retryTask} = useTaskStore()
|
||
const { loadEnabledModels, modelList, showFeatureHint, setShowFeatureHint } = useModelStore()
|
||
|
||
|
||
/* ---- 表单 ---- */
|
||
const form = useForm<NoteFormValues>({
|
||
resolver: zodResolver(formSchema),
|
||
defaultValues: {
|
||
platform: 'bilibili',
|
||
quality: 'medium',
|
||
model_name: modelList[0]?.model_name || '',
|
||
style: 'minimal',
|
||
video_interval: 4,
|
||
grid_size: [3, 3],
|
||
format: [],
|
||
},
|
||
})
|
||
const currentTask = getCurrentTask()
|
||
|
||
/* ---- 派生状态(只 watch 一次,提高性能) ---- */
|
||
const platform = useWatch({ control: form.control, name: 'platform' }) as string
|
||
const videoUnderstandingEnabled = useWatch({ control: form.control, name: 'video_understanding' })
|
||
const editing = currentTask && currentTask.id
|
||
|
||
/* ---- 副作用 ---- */
|
||
useEffect(() => {
|
||
loadEnabledModels()
|
||
|
||
return}, [])
|
||
useEffect(() => {
|
||
const currentTask = getCurrentTask()
|
||
const { formData } = currentTask || {}
|
||
if (!currentTask) return
|
||
form.reset(
|
||
{
|
||
...formData,
|
||
extras: formData?.extras || '',
|
||
}
|
||
|
||
)
|
||
}, [currentTaskId])
|
||
|
||
/* ---- 帮助函数 ---- */
|
||
const isGenerating = () =>
|
||
!['SUCCESS', 'FAILED', undefined].includes(getCurrentTask()?.status)
|
||
const generating = isGenerating()
|
||
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,
|
||
task_id: currentTaskId || '',
|
||
}
|
||
if (currentTaskId){
|
||
retryTask(currentTaskId,payload)
|
||
return
|
||
}
|
||
|
||
message.success('已提交任务')
|
||
const { data } = await generateNote(payload)
|
||
addPendingTask(data.task_id, values.platform, payload)
|
||
}
|
||
const handleCreateNew = () => {
|
||
// 🔁 这里清空当前任务状态
|
||
// 比如调用 resetCurrentTask() 或者 navigate 到一个新页面
|
||
setCurrentTask(null)
|
||
|
||
}
|
||
const FormButton = () => {
|
||
|
||
|
||
const label = generating
|
||
? '正在生成…'
|
||
: editing
|
||
? '重新生成'
|
||
: '生成笔记'
|
||
|
||
|
||
|
||
return (
|
||
<div className="flex gap-2">
|
||
<Button type="submit" className={!editing?'w-full':'w-2/3'+" bg-primary"} disabled={generating}>
|
||
{generating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
{label}
|
||
</Button>
|
||
|
||
{editing && (
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
className="w-1/3"
|
||
onClick={handleCreateNew}
|
||
>
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
新建笔记
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* -------------------- 渲染 -------------------- */
|
||
return (
|
||
<div className="h-full w-full">
|
||
<Form {...form}>
|
||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||
{/* 顶部按钮 */}
|
||
<FormButton></FormButton>
|
||
|
||
{/* 视频链接 & 平台 */}
|
||
<SectionHeader title="视频链接" tip="支持 B 站、YouTube 等平台" />
|
||
<div className="flex gap-2">
|
||
{/* 平台选择 */}
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="platform"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<Select disabled={!!editing} value={field.value} onValueChange={field.onChange} defaultValue={field.value}>
|
||
<FormControl>
|
||
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
|
||
</FormControl>
|
||
<SelectContent >
|
||
<SelectItem value="bilibili">哔哩哔哩</SelectItem>
|
||
<SelectItem value="youtube">YouTube</SelectItem>
|
||
<SelectItem value="douyin">抖音</SelectItem>
|
||
<SelectItem value="local">本地视频</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
{/* 链接输入 / 上传框 */}
|
||
<FormField
|
||
control={form.control}
|
||
name="video_url"
|
||
render={({ field }) => (
|
||
<FormItem className="flex-1">
|
||
{platform === 'local' ? (
|
||
<>
|
||
<Input disabled={!!editing} placeholder="请输入本地视频路径" {...field} />
|
||
|
||
</>
|
||
) : (
|
||
<Input disabled={!!editing} placeholder="请输入视频网站链接" {...field} />
|
||
)}
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
</div>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="video_url"
|
||
render={({ field }) => (
|
||
<FormItem className="flex-1">
|
||
{platform === 'local' && (
|
||
<>
|
||
<div
|
||
className="hover:border-primary mt-2 flex h-40 cursor-pointer items-center justify-center rounded-md border-2 border-dashed border-gray-300 transition-colors"
|
||
onDragOver={e => { 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()
|
||
}}
|
||
>
|
||
<p className="text-center text-sm text-gray-500">
|
||
拖拽文件到这里上传 <br />
|
||
<span className="text-xs text-gray-400">或点击选择文件</span>
|
||
</p>
|
||
</div>
|
||
|
||
</>
|
||
)}
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
|
||
{/* 模型选择 */}
|
||
<FormField
|
||
className="w-full"
|
||
control={form.control}
|
||
name="model_name"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<SectionHeader title="模型选择" tip="不同模型效果不同,建议自行测试" />
|
||
<Select value={field.value} onValueChange={field.onChange} defaultValue={field.value}>
|
||
<FormControl>
|
||
<SelectTrigger className="w-full min-w-0 truncate" ><SelectValue /></SelectTrigger>
|
||
</FormControl>
|
||
<SelectContent>
|
||
{modelList.map(m => (
|
||
<SelectItem key={m.id} value={m.model_name}>
|
||
{m.model_name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
{/* 笔记风格 */}
|
||
<FormField
|
||
className="w-full"
|
||
control={form.control}
|
||
name="style"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<SectionHeader title="笔记风格" tip="选择生成笔记的呈现风格" />
|
||
<Select value={field.value} onValueChange={field.onChange} defaultValue={field.value}>
|
||
<FormControl>
|
||
<SelectTrigger className="w-full min-w-0 truncate" ><SelectValue /></SelectTrigger>
|
||
</FormControl>
|
||
<SelectContent>
|
||
{noteStyles.map(({ label, value }) => (
|
||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</div>
|
||
{/* 视频理解 */}
|
||
<SectionHeader title="视频理解" tip="将视频截图发给多模态模型辅助分析" />
|
||
<div className="flex flex-col gap-2">
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="video_understanding"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<div className="flex items-center gap-2">
|
||
<FormLabel>启用</FormLabel>
|
||
<Checkbox
|
||
checked={videoUnderstandingEnabled}
|
||
onCheckedChange={v => form.setValue('video_understanding', v)}
|
||
/>
|
||
</div>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
{/* 采样间隔 */}
|
||
<FormField
|
||
control={form.control}
|
||
name="video_interval"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>采样间隔(秒)</FormLabel>
|
||
<Input
|
||
disabled={!videoUnderstandingEnabled}
|
||
type="number"
|
||
{...field}
|
||
/>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
{/* 拼图大小 */}
|
||
<FormField
|
||
control={form.control}
|
||
name="grid_size"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>拼图尺寸(列 × 行)</FormLabel>
|
||
<div className="flex items-center space-x-2">
|
||
<Input
|
||
disabled={!videoUnderstandingEnabled}
|
||
type="number"
|
||
value={field.value?.[0] || 3}
|
||
onChange={e =>
|
||
field.onChange([+e.target.value, field.value?.[1] || 3])}
|
||
className="w-16"
|
||
/>
|
||
<span>x</span>
|
||
<Input
|
||
disabled={!videoUnderstandingEnabled}
|
||
type="number"
|
||
value={field.value?.[1] || 3}
|
||
onChange={e =>
|
||
field.onChange([field.value?.[0] || 3, +e.target.value])}
|
||
className="w-16"
|
||
/>
|
||
</div>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</div>
|
||
<Alert
|
||
closable
|
||
type="error"
|
||
message={
|
||
<div>
|
||
<strong>提示:</strong>
|
||
<p>视频理解功能必须使用多模态模型。</p>
|
||
|
||
</div>
|
||
}
|
||
className="text-sm"
|
||
/>
|
||
</div>
|
||
|
||
|
||
|
||
{/* 笔记格式 */}
|
||
<FormField
|
||
control={form.control}
|
||
name="format"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<SectionHeader title="笔记格式" tip="选择要包含的笔记元素" />
|
||
<CheckboxGroup
|
||
value={field.value}
|
||
onChange={field.onChange}
|
||
disabledMap={{
|
||
link: platform === 'local',
|
||
screenshot: !videoUnderstandingEnabled,
|
||
}}
|
||
/>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
{/* 备注 */}
|
||
<FormField
|
||
control={form.control}
|
||
name="extras"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<SectionHeader title="备注" tip="可在 Prompt 结尾附加自定义说明" />
|
||
<Textarea placeholder="笔记需要罗列出 xxx 关键点…" {...field} />
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</form>
|
||
</Form>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default NoteForm
|