mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-08-28 19:46:57 +08:00
@@ -2,7 +2,7 @@ import './App.css'
|
|||||||
import { HomePage } from './pages/HomePage/Home.tsx'
|
import { HomePage } from './pages/HomePage/Home.tsx'
|
||||||
import { useTaskPolling } from '@/hooks/useTaskPolling.ts'
|
import { useTaskPolling } from '@/hooks/useTaskPolling.ts'
|
||||||
import SettingPage from './pages/SettingPage/index.tsx'
|
import SettingPage from './pages/SettingPage/index.tsx'
|
||||||
import { BrowserRouter, HashRouter, Navigate, Routes } from 'react-router-dom'
|
import { BrowserRouter, Navigate, Routes } from 'react-router-dom'
|
||||||
import { Route } from 'react-router-dom'
|
import { Route } from 'react-router-dom'
|
||||||
import Index from '@/pages/Index.tsx'
|
import Index from '@/pages/Index.tsx'
|
||||||
import NotFoundPage from '@/pages/NotFoundPage'
|
import NotFoundPage from '@/pages/NotFoundPage'
|
||||||
@@ -43,7 +43,7 @@ function App() {
|
|||||||
// 后端已初始化,渲染主应用
|
// 后端已初始化,渲染主应用
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HashRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Index />}>
|
<Route path="/" element={<Index />}>
|
||||||
<Route index element={<HomePage />} />
|
<Route index element={<HomePage />} />
|
||||||
@@ -62,7 +62,7 @@ function App() {
|
|||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</HashRouter>
|
</BrowserRouter>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||||
import { CheckIcon } from 'lucide-react'
|
import { CheckIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
function Checkbox({ className, checked, onChange, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
const [isChecked, setIsChecked] = useState(checked || false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (checked !== undefined) {
|
||||||
|
setIsChecked(checked);
|
||||||
|
}
|
||||||
|
}, [checked]);
|
||||||
|
|
||||||
|
const handleCheckChange = (newChecked: boolean) => {
|
||||||
|
setIsChecked(newChecked);
|
||||||
|
if (onChange) {
|
||||||
|
onChange({} as React.FormEvent<HTMLButtonElement>);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
data-slot="checkbox"
|
data-slot="checkbox"
|
||||||
@@ -12,6 +28,8 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
|
|||||||
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
checked={isChecked}
|
||||||
|
onCheckedChange={handleCheckChange}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<CheckboxPrimitive.Indicator
|
<CheckboxPrimitive.Indicator
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as React from 'react'
|
|||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
function Input({ className, type, value, onChange, ...props }: React.ComponentProps<'input'>) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
@@ -13,6 +13,8 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
|||||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={onChange}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const HomeLayout: FC<IProps> = ({ NoteForm, Preview, History }) => {
|
|||||||
<div className="flex h-screen flex-col overflow-hidden">
|
<div className="flex h-screen flex-col overflow-hidden">
|
||||||
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
|
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
|
||||||
{/* 左边表单 */}
|
{/* 左边表单 */}
|
||||||
<ResizablePanel defaultSize={18} minSize={10} maxSize={35}>
|
<ResizablePanel defaultSize={23} minSize={10} maxSize={35}>
|
||||||
<aside className="flex h-full flex-col overflow-hidden border-r border-neutral-200 bg-white">
|
<aside className="flex h-full flex-col overflow-hidden border-r border-neutral-200 bg-white">
|
||||||
<header className="flex h-16 items-center justify-between px-6">
|
<header className="flex h-16 items-center justify-between px-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -68,7 +68,7 @@ const HomeLayout: FC<IProps> = ({ NoteForm, Preview, History }) => {
|
|||||||
<ResizableHandle />
|
<ResizableHandle />
|
||||||
|
|
||||||
{/* 右边预览 */}
|
{/* 右边预览 */}
|
||||||
<ResizablePanel defaultSize={55} minSize={30}>
|
<ResizablePanel defaultSize={61} minSize={30}>
|
||||||
<main className="flex h-full flex-col overflow-hidden bg-white p-6">{Preview}</main>
|
<main className="flex h-full flex-col overflow-hidden bg-white p-6">{Preview}</main>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
/* -------------------- 校验 Schema -------------------- */
|
/* -------------------- 校验 Schema -------------------- */
|
||||||
const formSchema = z
|
const formSchema = z
|
||||||
.object({
|
.object({
|
||||||
video_url: z.string(),
|
video_url: z.string().optional(),
|
||||||
platform: z.string().nonempty('请选择平台'),
|
platform: z.string().nonempty('请选择平台'),
|
||||||
quality: z.enum(['fast', 'medium', 'slow']),
|
quality: z.enum(['fast', 'medium', 'slow']),
|
||||||
screenshot: z.boolean().optional(),
|
screenshot: z.boolean().optional(),
|
||||||
@@ -60,10 +60,10 @@ const formSchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
.superRefine(({ video_url, platform }, ctx) => {
|
.superRefine(({ video_url, platform }, ctx) => {
|
||||||
if (platform === 'local' || platform === 'douyin') {
|
if (platform === 'local' && !video_url) {
|
||||||
if (!video_url) {
|
ctx.addIssue({ code: 'custom', message: '本地视频路径不能为空', path: ['video_url'] })
|
||||||
ctx.addIssue({ code: 'custom', message: '本地视频路径不能为空', path: ['video_url'] })
|
} else if (!video_url) {
|
||||||
}
|
ctx.addIssue({ code: 'custom', message: '视频链接不能为空', path: ['video_url'] })
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const url = new URL(video_url)
|
const url = new URL(video_url)
|
||||||
@@ -202,7 +202,7 @@ const NoteForm = () => {
|
|||||||
setUploadSuccess(true)
|
setUploadSuccess(true)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('上传失败:', err)
|
console.error('上传失败:', err)
|
||||||
message.error('上传失败,请重试')
|
// message.error('上传失败,请重试')
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false)
|
setIsUploading(false)
|
||||||
}
|
}
|
||||||
@@ -220,13 +220,13 @@ const NoteForm = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
message.success('已提交任务')
|
// message.success('已提交任务')
|
||||||
const data = await generateNote(payload)
|
const data = await generateNote(payload)
|
||||||
addPendingTask(data.task_id, values.platform, payload)
|
addPendingTask(data.task_id, values.platform, payload)
|
||||||
}
|
}
|
||||||
const onInvalid = (errors: FieldErrors<NoteFormValues>) => {
|
const onInvalid = (errors: FieldErrors<NoteFormValues>) => {
|
||||||
console.warn('表单校验失败:', errors)
|
console.warn('表单校验失败:', errors)
|
||||||
message.error('请完善所有必填项后再提交')
|
// message.error('请完善所有必填项后再提交')
|
||||||
}
|
}
|
||||||
const handleCreateNew = () => {
|
const handleCreateNew = () => {
|
||||||
// 🔁 这里清空当前任务状态
|
// 🔁 这里清空当前任务状态
|
||||||
@@ -297,7 +297,7 @@ const NoteForm = () => {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormMessage />
|
<FormMessage style={{ display: 'none' }} />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -314,7 +314,7 @@ const NoteForm = () => {
|
|||||||
) : (
|
) : (
|
||||||
<Input disabled={!!editing} placeholder="请输入视频网站链接" {...field} />
|
<Input disabled={!!editing} placeholder="请输入视频网站链接" {...field} />
|
||||||
)}
|
)}
|
||||||
<FormMessage />
|
<FormMessage style={{ display: 'none' }} />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -77,16 +77,15 @@ const NoteHistory: FC<NoteHistoryProps> = ({ onSelect, selectedId }) => {
|
|||||||
<div className="flex flex-col gap-2 overflow-hidden">
|
<div className="flex flex-col gap-2 overflow-hidden">
|
||||||
{filteredTasks.map(task => (
|
{filteredTasks.map(task => (
|
||||||
<div
|
<div
|
||||||
onClick={() => onSelect(task.id)}
|
key={task.id}
|
||||||
|
onClick={() => onSelect(task.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex cursor-pointer flex-col rounded-md border border-neutral-200 p-3',
|
'flex cursor-pointer flex-col rounded-md border border-neutral-200 p-3',
|
||||||
selectedId === task.id && 'border-primary bg-primary-light'
|
selectedId === task.id && 'border-primary bg-primary-light'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
key={task.id}
|
|
||||||
className={cn('flex items-center gap-4')}
|
className={cn('flex items-center gap-4')}
|
||||||
|
|
||||||
>
|
>
|
||||||
{/* 封面图 */}
|
{/* 封面图 */}
|
||||||
{task.platform === 'local' ? (
|
{task.platform === 'local' ? (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import re
|
import re
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
def extract_video_id(url: str, platform: str) -> Optional[str]:
|
def extract_video_id(url: str, platform: str) -> Optional[str]:
|
||||||
@@ -11,6 +12,12 @@ def extract_video_id(url: str, platform: str) -> Optional[str]:
|
|||||||
:return: 提取到的视频 ID 或 None
|
:return: 提取到的视频 ID 或 None
|
||||||
"""
|
"""
|
||||||
if platform == "bilibili":
|
if platform == "bilibili":
|
||||||
|
# 如果是短链接,则解析真实链接
|
||||||
|
if "b23.tv" in url:
|
||||||
|
resolved_url = resolve_bilibili_short_url(url)
|
||||||
|
if resolved_url:
|
||||||
|
url = resolved_url
|
||||||
|
|
||||||
# 匹配 BV号(如 BV1vc411b7Wa)
|
# 匹配 BV号(如 BV1vc411b7Wa)
|
||||||
match = re.search(r"BV([0-9A-Za-z]+)", url)
|
match = re.search(r"BV([0-9A-Za-z]+)", url)
|
||||||
return f"BV{match.group(1)}" if match else None
|
return f"BV{match.group(1)}" if match else None
|
||||||
@@ -26,3 +33,18 @@ def extract_video_id(url: str, platform: str) -> Optional[str]:
|
|||||||
return match.group(1) if match else None
|
return match.group(1) if match else None
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_bilibili_short_url(short_url: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
解析哔哩哔哩短链接以获取真实视频链接
|
||||||
|
|
||||||
|
:param short_url: Bilibili短链接(如"https://b23.tv/xxxxxx")
|
||||||
|
:return: 真实的视频链接或None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = requests.head(short_url, allow_redirects=True)
|
||||||
|
return response.url
|
||||||
|
except requests.RequestException as e:
|
||||||
|
print(f"Error resolving short URL: {e}")
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from pydantic import AnyUrl, validator, BaseModel, field_validator
|
from pydantic import AnyUrl, validator, BaseModel, field_validator
|
||||||
import re
|
import re
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
SUPPORTED_PLATFORMS = {
|
SUPPORTED_PLATFORMS = {
|
||||||
"bilibili": r"(https?://)?(www\.)?bilibili\.com/video/[a-zA-Z0-9]+",
|
"bilibili": r"(https?://)?(www\.)?bilibili\.com/video/[a-zA-Z0-9]+",
|
||||||
@@ -10,6 +11,12 @@ SUPPORTED_PLATFORMS = {
|
|||||||
|
|
||||||
|
|
||||||
def is_supported_video_url(url: str) -> bool:
|
def is_supported_video_url(url: str) -> bool:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
|
||||||
|
# 检查是否为Bilibili的短链接
|
||||||
|
if parsed.netloc == "b23.tv":
|
||||||
|
return True
|
||||||
|
|
||||||
for name, pattern in SUPPORTED_PLATFORMS.items():
|
for name, pattern in SUPPORTED_PLATFORMS.items():
|
||||||
if pattern in ["douyin", "kuaishou"]:
|
if pattern in ["douyin", "kuaishou"]:
|
||||||
if pattern in url:
|
if pattern in url:
|
||||||
|
|||||||
Reference in New Issue
Block a user