feat(download): 添加快手下载器并优化下载配置功能

- 新增快手下载器,支持快手视频下载
- 添加下载配置页面,可设置各平台Cookies
- 优化后端接口,增加获取和更新Cookies的功能
- 前端新增Downloader组件和相关表单组件
- 更新路由配置,增加下载配置相关路由
This commit is contained in:
黄建武
2025-05-08 18:15:59 +08:00
parent 321d22271a
commit 21c9d47495
21 changed files with 1106 additions and 413 deletions

View File

@@ -1,108 +1,128 @@
/* 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 { useForm, useWatch } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Info, Loader2 ,Plus} from 'lucide-react'
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";
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, videoPlatforms } 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'] })
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'] })
}
}
} 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>
<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 = [],
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>
<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 { addPendingTask, currentTaskId, setCurrentTask, getCurrentTask, retryTask } =
useTaskStore()
const { loadEnabledModels, modelList, showFeatureHint, setShowFeatureHint } = useModelStore()
/* ---- 表单 ---- */
const form = useForm<NoteFormValues>({
resolver: zodResolver(formSchema),
@@ -116,35 +136,32 @@ const NoteForm = () => {
format: [],
},
})
const currentTask = getCurrentTask()
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
const editing = currentTask && currentTask.id
/* ---- 副作用 ---- */
/* ---- 副作用 ---- */
useEffect(() => {
loadEnabledModels()
return}, [])
useEffect(() => {
const currentTask = getCurrentTask()
const { formData } = currentTask || {}
if (!currentTask) return
form.reset(
{
...formData,
extras: formData?.extras || '',
}
)
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 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)
@@ -158,321 +175,324 @@ const NoteForm = () => {
}
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
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 handleCreateNew = () => {
// 🔁 这里清空当前任务状态
// 比如调用 resetCurrentTask() 或者 navigate 到一个新页面
setCurrentTask(null)
}
const FormButton = () => {
const label = generating ? '正在生成…' : editing ? '重新生成' : '生成笔记'
}
const FormButton = () => {
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>
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>
)
}
{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>
<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">
{/* 平台选择 */}
{/* 视频链接 & 平台 */}
<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"
/>
<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>
{videoPlatforms?.map(p => (
<SelectItem key={p.value} value={p.value}>
<div className="flex items-center justify-center gap-2">
<div className="h-4 w-4">{p.logo()}</div>
<span>{p.label}</span>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
<Alert
closable
type="error"
message={
<div>
<strong></strong>
<p>使</p>
</div>
}
className="text-sm"
/>
</div>
{/* 笔记格式 */}
</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="format"
name="video_interval"
render={({ field }) => (
<FormItem>
<SectionHeader title="笔记格式" tip="选择要包含的笔记元素" />
<CheckboxGroup
value={field.value}
onChange={field.onChange}
disabledMap={{
link: platform === 'local',
screenshot: !videoUnderstandingEnabled,
}}
<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"
/>
<FormMessage />
</FormItem>
<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="extras"
render={({ field }) => (
<FormItem>
<SectionHeader title="备注" tip="可在 Prompt 结尾附加自定义说明" />
<Textarea placeholder="笔记需要罗列出 xxx 关键点…" {...field} />
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</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>
)
}

View File

@@ -0,0 +1,16 @@
import Provider from '@/components/Form/modelForm/Provider.tsx'
import { Outlet } from 'react-router-dom'
import Options from '@/components/Form/DownloaderForm/Options.tsx'
const Downloader = () => {
return (
<div className={'flex h-full bg-white'}>
<div className={'flex-1/5 border-r border-neutral-200 p-2'}>
<Options></Options>
</div>
<div className={'flex-4/5'}>
<Outlet />
</div>
</div>
)
}
export default Downloader

View File

@@ -24,12 +24,12 @@ const Menu = () => {
// path: '/settings/transcriber',
// },
// //下载配置
// {
// id: 'download',
// name: '下载配置',
// icon: <HardDriveDownload />,
// path: '/settings/download',
// },
{
id: 'download',
name: '下载配置',
icon: <HardDriveDownload />,
path: '/settings/download',
},
// //其他配置
// {
// id: 'prompt',