Merge pull request #48 from JefferyHcool/dev

feat(frontend): 重构首页布局并添加生成历史组件
This commit is contained in:
Jianwu Huang
2025-04-27 17:00:43 +08:00
committed by GitHub
15 changed files with 190 additions and 127 deletions

View File

@@ -2,6 +2,30 @@
@import 'tw-animate-css'; @import 'tw-animate-css';
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
html,body{
height: 100%;
width: 100%;
}
/* 修改滚动条轨道颜色 */
::-webkit-scrollbar {
width: 8px; /* 控制滚动条的宽度 */
}
/* 修改滚动条的轨道颜色 */
::-webkit-scrollbar-track {
background-color: #f1f1f1; /* 轨道的背景颜色 */
}
/* 修改滚动条的滑块颜色 */
::-webkit-scrollbar-thumb {
background-color: #888; /* 滑块的颜色 */
border-radius: 4px; /* 圆角 */
}
/* 当鼠标悬停时,修改滑块颜色 */
::-webkit-scrollbar-thumb:hover {
background-color: #555; /* 悬停时的颜色 */
}
:root { :root {
--radius: 0.625rem; --radius: 0.625rem;
@@ -21,7 +45,7 @@
--accent: oklch(0.97 0 0); --accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: #e6f7ff; --border: var( --color-neutral-200);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--ring: #096dd9; --ring: #096dd9;
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.646 0.222 41.116);

View File

@@ -13,15 +13,16 @@ import { Link } from 'react-router-dom'
interface IProps { interface IProps {
NoteForm: React.ReactNode NoteForm: React.ReactNode
Preview: React.ReactNode Preview: React.ReactNode
History: React.ReactNode
} }
const HomeLayout: FC<IProps> = ({ NoteForm, Preview }) => { const HomeLayout: FC<IProps> = ({ NoteForm, Preview, History }) => {
const [, setShowSettings] = useState(false) const [, setShowSettings] = useState(false)
return ( return (
<div className="flex min-h-screen flex-col bg-white"> <div className="flex h-screen flex-col overflow-hidden bg-white">
<div className="flex flex-1"> <div className="flex flex-1">
{/* 左侧部分Header + 表单 */} {/* 左侧部分Header + 表单 */}
<aside className="flex w-[400px] flex-col border-r border-neutral-200 bg-white"> <aside className="flex w-[340px] flex-col border-r border-neutral-200 bg-white">
{/* Header */} {/* Header */}
<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">
@@ -52,6 +53,13 @@ const HomeLayout: FC<IProps> = ({ NoteForm, Preview }) => {
{NoteForm} {NoteForm}
</div> </div>
</aside> </aside>
<aside className="flex h-full w-[300px] flex-col border-r border-neutral-200 bg-white">
{/* Header */}
{/* 表单内容 */}
{/*<NoteForm />*/}
{History}
</aside>
{/* 右侧预览区域 */} {/* 右侧预览区域 */}
<main className="h-screen flex-1 overflow-hidden bg-white p-6"> <main className="h-screen flex-1 overflow-hidden bg-white p-6">

View File

@@ -3,6 +3,7 @@ import HomeLayout from '@/layouts/HomeLayout.tsx'
import NoteForm from '@/pages/HomePage/components/NoteForm.tsx' import NoteForm from '@/pages/HomePage/components/NoteForm.tsx'
import MarkdownViewer from '@/pages/HomePage/components/MarkdownViewer.tsx' import MarkdownViewer from '@/pages/HomePage/components/MarkdownViewer.tsx'
import { useTaskStore } from '@/store/taskStore' import { useTaskStore } from '@/store/taskStore'
import History from '@/pages/HomePage/components/History.tsx'
type ViewStatus = 'idle' | 'loading' | 'success' | 'failed' type ViewStatus = 'idle' | 'loading' | 'success' | 'failed'
export const HomePage: FC = () => { export const HomePage: FC = () => {
const tasks = useTaskStore(state => state.tasks) const tasks = useTaskStore(state => state.tasks)
@@ -36,6 +37,7 @@ export const HomePage: FC = () => {
<HomeLayout <HomeLayout
NoteForm={<NoteForm />} NoteForm={<NoteForm />}
Preview={<MarkdownViewer status={status} content={content} />} Preview={<MarkdownViewer status={status} content={content} />}
History={<History />}
/> />
) )
} }

View File

@@ -0,0 +1,26 @@
import NoteHistory from '@/pages/HomePage/components/NoteHistory.tsx'
import { useTaskStore } from '@/store/taskStore'
import { Info, Clock, Loader2 } from 'lucide-react'
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
const History = () => {
const currentTaskId = useTaskStore(state => state.currentTaskId)
const setCurrentTask = useTaskStore(state => state.setCurrentTask)
return (
<>
<div className={'flex h-full w-full flex-col gap-4 px-2.5 py-1.5'}>
{/*生成历史 */}
<div className="my-4 flex h-[40px] items-center gap-2">
<Clock className="h-4 w-4 text-neutral-500" />
<h2 className="text-base font-medium text-neutral-900"></h2>
</div>
<ScrollArea className="h-[800px] w-full">
{/*<div className="w-full flex-1 overflow-y-auto">*/}
<NoteHistory onSelect={setCurrentTask} selectedId={currentTaskId} />
{/*</div>*/}
</ScrollArea>
</div>
</>
)
}
export default History

View File

@@ -346,7 +346,7 @@ const NoteForm = () => {
</div> </div>
<FormControl> <FormControl>
<div className="flex space-x-1.5"> <div className="flex flex-wrap space-x-1.5">
{noteFormats.map(item => ( {noteFormats.map(item => (
<label key={item.value} className="flex items-center space-x-2"> <label key={item.value} className="flex items-center space-x-2">
<Checkbox <Checkbox
@@ -383,7 +383,7 @@ const NoteForm = () => {
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
</div> </div>
<Textarea placeholder={'笔记需要罗列出 xxx 关键点'} /> <Textarea placeholder={'笔记需要罗列出 xxx 关键点'} {...field} />
{/*<FormDescription className="text-xs text-neutral-500">*/} {/*<FormDescription className="text-xs text-neutral-500">*/}
{/* 质量越高,下载体积越大,速度越慢*/} {/* 质量越高,下载体积越大,速度越慢*/}
@@ -408,44 +408,8 @@ const NoteForm = () => {
</form> </form>
</Form> </Form>
{/*生成历史 */}
<div className="my-4 flex items-center gap-2">
<Clock className="h-4 w-4 text-neutral-500" />
<h2 className="text-base font-medium text-neutral-900"></h2>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<NoteHistory onSelect={setCurrentTask} selectedId={currentTaskId} />
</div>
{/* 添加一些额外的说明或功能介绍 */} {/* 添加一些额外的说明或功能介绍 */}
{showFeatureHint && (
<Alert
message="功能介绍 v2.0.0"
description={
<ul className="space-y-2 text-sm text-neutral-600">
<li className="flex items-start gap-2">
<span className="text-primary font-bold"></span>
<span></span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary font-bold"></span>
<span>YouTube等</span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary font-bold"></span>
<span>Markdown格式</span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary font-bold"></span>
<span></span>
</li>
</ul>
}
type="info"
onClose={onClose}
closable
/>
)}
{/*<div className="bg-primary-light mt-6 rounded-lg p-4"></div>*/} {/*<div className="bg-primary-light mt-6 rounded-lg p-4"></div>*/}
</div> </div>
) )

View File

@@ -30,76 +30,102 @@ const NoteHistory: FC<NoteHistoryProps> = ({ onSelect, selectedId }) => {
} }
return ( return (
<ScrollArea className="h-auto max-h-[20vh] sm:max-h-[10vh]"> <>
<div className="flex flex-col space-y-2"> <div className="flex flex-col gap-2">
{tasks.map(task => ( {tasks.map(task => (
<div <div
key={task.id}
className={cn( className={cn(
'flex cursor-pointer items-center gap-4 rounded-md border p-3 transition hover:bg-neutral-50', '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'
)} )}
onClick={() => onSelect(task.id)}
> >
{/* 封面图 */} <div
<img key={task.id}
src={ className={cn('flex items-center gap-4')}
task.audioMeta.cover_url onClick={() => onSelect(task.id)}
? `/api/image_proxy?url=${encodeURIComponent(task.audioMeta.cover_url)}` >
: '/placeholder.png' {/* 封面图 */}
} <img
alt="封面" src={
className="h-10 w-16 rounded-md object-cover" task.audioMeta.cover_url
/> ? `/api/image_proxy?url=${encodeURIComponent(task.audioMeta.cover_url)}`
: '/placeholder.png'
}
alt="封面"
className="h-10 w-12 rounded-md object-cover"
/>
{/* 标题 + 状态 */} {/* 标题 + 状态 */}
<div className="flex w-full min-w-0 items-center justify-between gap-2"> <div className="flex w-full items-center justify-between gap-2">
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="max-w-[120px] flex-1 truncate font-medium"> <div className="line-clamp-2 max-w-[180px] flex-1 overflow-hidden text-sm text-ellipsis">
{task.audioMeta.title || '未命名笔记'} {task.audioMeta.title || '未命名笔记'}
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{task.audioMeta.title || '未命名笔记'}</p> <p>{task.audioMeta.title || '未命名笔记'}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
<div className="shrink-0">
{task.status === 'SUCCESS' && <Badge variant="default"></Badge>}
{task.status === 'PENDING' && <Badge variant="outline"></Badge>}
{task.status === 'FAILED' && <Badge variant="destructive"></Badge>}
</div> </div>
</div> </div>
<div className={'mt-2 flex items-center justify-between text-[10px]'}>
<div className="shrink-0">
{task.status === 'SUCCESS' && (
<div className={'bg-primary w-10 rounded p-0.5 text-center text-white'}>
</div>
)}
{task.status !== 'SUCCESS' && task.status !== 'FAILED' ? (
<div className={'w-10 rounded bg-green-500 p-0.5 text-center text-white'}>
</div>
) : (
<></>
)}
{task.status === 'FAILED' && (
<div className={'w-10 rounded bg-red-500 p-0.5 text-center text-white'}></div>
)}
</div>
{/* 删除按钮 */} <div>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
type="button" type="button"
size="icon" size="small"
variant="ghost" variant="ghost"
onClick={e => { onClick={e => {
e.stopPropagation() e.stopPropagation()
removeTask(task.id) removeTask(task.id)
}} }}
className="shrink-0" className="shrink-0"
> >
<Trash className="text-muted-foreground h-4 w-4" /> <Trash className="text-muted-foreground h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p></p> <p></p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
</div>
{/*<div className="shrink-0">*/}
{/* {task.status === 'SUCCESS' && <Badge variant="default">已完成</Badge>}*/}
{/* {task.status !== 'SUCCESS' && task.status === 'FAILED' && (*/}
{/* <Badge variant="outline">等待中</Badge>*/}
{/* )}*/}
{/* {task.status === 'FAILED' && <Badge variant="destructive">失败</Badge>}*/}
{/*</div>*/}
</div>
</div> </div>
))} ))}
</div> </div>
</ScrollArea> </>
) )
} }

View File

@@ -41,9 +41,8 @@ const StepBar: FC<StepBarProps> = ({ steps, currentStep }) => {
<div className="mt-4 text-center text-xs text-gray-700">{step.label}</div> <div className="mt-4 text-center text-xs text-gray-700">{step.label}</div>
{/* 连接线 */} {/* 连接线 */}
{!isLast && (
<div className={`h-1 w-full ${isActive ? 'bg-primary' : 'bg-gray-300'}`}></div> <div className={`h-1 w-full ${isActive ? 'bg-primary' : 'bg-gray-300'}`}></div>
)}
</div> </div>
) )
})} })}

View File

@@ -3,7 +3,7 @@
<p align="center"> <p align="center">
<img src="./doc/icon.svg" alt="BiliNote Banner" width="50" height="50" /> <img src="./doc/icon.svg" alt="BiliNote Banner" width="50" height="50" />
</p> </p>
<h1 align="center" > BiliNote v1.1.0</h1> <h1 align="center" > BiliNote v1.1.1</h1>
</div> </div>
<p align="center"><i>AI 视频笔记生成工具 让 AI 为你的视频做笔记</i></p> <p align="center"><i>AI 视频笔记生成工具 让 AI 为你的视频做笔记</i></p>
@@ -46,6 +46,8 @@ BiliNote 是一个开源的 AI 视频笔记助手支持通过哔哩哔哩、Y
![screenshot](./doc/image1.png) ![screenshot](./doc/image1.png)
![screenshot](./doc/image2.png) ![screenshot](./doc/image2.png)
![screenshot](./doc/image3.png) ![screenshot](./doc/image3.png)
![screenshot](./doc/image.png)
![screenshot](./doc/image4.png)
## 🚀 快速开始 ## 🚀 快速开始
@@ -133,19 +135,19 @@ DEEP_SEEK_API_KEY=xxx
QWEN_API_KEY=xxx QWEN_API_KEY=xxx
``` ```
## Changelog ## Changelog
### v1.1.0 ### v1.1.0
- #### Added - #### Added
- 新增 AI 笔记风格选择 - 新增 AI 笔记风格选择
- 新增 AI 笔记返回格式选择 - 新增 AI 笔记返回格式选择
- 添加 AI 自定义笔记备注 Prompt - 添加 AI 自定义笔记备注 Prompt
- 添加任务失败重试 - 添加任务失败重试
- 添加全局设置页,可在设置页进行模型设置 - 添加全局设置页,可在设置页进行模型设置
- #### Optimize - #### Optimize
- 优化前端样式,优化用户体验 - 优化前端样式,优化用户体验
- 增加生成中间产物,可用于失败后加快生成速度 - 增加生成中间产物,可用于失败后加快生成速度
- #### Fix - #### Fix
- 修复视频截图视频过早删除错误 - 修复视频截图视频过早删除错误
## 🧠 TODO ## 🧠 TODO

View File

@@ -42,7 +42,9 @@ class YoutubeDownloader(Downloader, ABC):
title = info.get("title") title = info.get("title")
duration = info.get("duration", 0) duration = info.get("duration", 0)
cover_url = info.get("thumbnail") cover_url = info.get("thumbnail")
audio_path = os.path.join(output_dir, f"{video_id}.m4a") ext = info.get("ext", "m4a") # 兜底用 m4a
audio_path = os.path.join(output_dir, f"{video_id}.{ext}")
print('os.path.join(output_dir, f"{video_id}.{ext}")',os.path.join(output_dir, f"{video_id}.{ext}"))
return AudioDownloadResult( return AudioDownloadResult(
file_path=audio_path, file_path=audio_path,

View File

@@ -40,7 +40,7 @@ def generate_base_prompt(title, segment_text, tags, _format=None, style=None, ex
# 添加额外内容 # 添加额外内容
if extras: if extras:
prompt += f"\n{extras}" prompt += f"\n{extras}"
print(prompt)
return prompt return prompt

View File

@@ -41,8 +41,10 @@ from events import transcription_finished
logger = get_logger(__name__) logger = get_logger(__name__)
load_dotenv() load_dotenv()
BACKEND_BASE_URL = os.getenv("API_BASE_URL", "http://localhost:8000") api_path = os.getenv("API_BASE_URL", "http://localhost")
BACKEND_PORT= os.getenv("BACKEND_PORT", 8000)
BACKEND_BASE_URL = f"{api_path}:{BACKEND_PORT}"
output_dir = os.getenv('OUT_DIR') output_dir = os.getenv('OUT_DIR')
image_base_url = os.getenv('IMAGE_BASE_URL') image_base_url = os.getenv('IMAGE_BASE_URL')
logger.info("starting up") logger.info("starting up")
@@ -129,6 +131,7 @@ class NoteGenerator:
""" """
matches = self.extract_screenshot_timestamps(markdown) matches = self.extract_screenshot_timestamps(markdown)
new_markdown = markdown new_markdown = markdown
print(f"匹配到的截图:{matches}")
logger.info(f"开始为笔记生成截图") logger.info(f"开始为笔记生成截图")
try: try:
for idx, (marker, ts) in enumerate(matches): for idx, (marker, ts) in enumerate(matches):
@@ -137,6 +140,7 @@ class NoteGenerator:
image_url = f"{BACKEND_BASE_URL.rstrip('/')}/{image_relative_path.lstrip('/')}" image_url = f"{BACKEND_BASE_URL.rstrip('/')}/{image_relative_path.lstrip('/')}"
replacement = f"![]({image_url})" replacement = f"![]({image_url})"
new_markdown = new_markdown.replace(marker, replacement, 1) new_markdown = new_markdown.replace(marker, replacement, 1)
print(f"替换后的 markdown{new_markdown}")
return new_markdown return new_markdown
except Exception as e: except Exception as e:
@@ -214,7 +218,7 @@ class NoteGenerator:
) )
_path=audio.raw_info.get('path') _path=audio.raw_info.get('path')
with open(audio_cache_path, "w", encoding="utf-8") as f: with open(audio_cache_path, "w", encoding="utf-8") as f:
json.dump(audio.__dict__, f, ensure_ascii=False, indent=2) json.dump(asdict(audio), f, ensure_ascii=False, indent=2)
logger.info(f"音频下载并缓存成功task_id={task_id}") logger.info(f"音频下载并缓存成功task_id={task_id}")
except Exception as e: except Exception as e:
logger.error(f"❌ 下载音频失败task_id={task_id},错误信息:{e}") logger.error(f"❌ 下载音频失败task_id={task_id},错误信息:{e}")
@@ -226,13 +230,19 @@ class NoteGenerator:
self.update_task_status(task_id, TaskStatus.TRANSCRIBING) self.update_task_status(task_id, TaskStatus.TRANSCRIBING)
if os.path.exists(transcript_cache_path): if os.path.exists(transcript_cache_path):
logger.info(f"检测到已有转写缓存直接读取task_id={task_id}") logger.info(f"检测到已有转写缓存直接读取task_id={task_id}")
with open(transcript_cache_path, "r", encoding="utf-8") as f: try:
transcript_data = json.load(f) with open(transcript_cache_path, "r", encoding="utf-8") as f:
transcript = TranscriptResult( transcript_data = json.load(f)
language=transcript_data["language"], transcript = TranscriptResult(
full_text=transcript_data["full_text"], language=transcript_data["language"],
segments=[TranscriptSegment(**seg) for seg in transcript_data["segments"]] full_text=transcript_data["full_text"],
) segments=[TranscriptSegment(**seg) for seg in transcript_data["segments"]]
)
except (json.JSONDecodeError, KeyError) as e:
logger.warning(f"⚠️ 读取转录缓存失败重新转录task_id={task_id},错误信息:{e}")
transcript: TranscriptResult = self.transcriber.transcript(file_path=audio.file_path)
with open(transcript_cache_path, "w", encoding="utf-8") as f:
json.dump(asdict(transcript), f, ensure_ascii=False, indent=2)
else: else:
transcript: TranscriptResult = self.transcriber.transcript(file_path=audio.file_path) transcript: TranscriptResult = self.transcriber.transcript(file_path=audio.file_path)
with open(transcript_cache_path, "w", encoding="utf-8") as f: with open(transcript_cache_path, "w", encoding="utf-8") as f:

View File

@@ -46,4 +46,4 @@ if __name__ == "__main__":
port = int(os.getenv("BACKEND_PORT", 8000)) port = int(os.getenv("BACKEND_PORT", 8000))
host = os.getenv("BACKEND_HOST", "0.0.0.0") host = os.getenv("BACKEND_HOST", "0.0.0.0")
logger.info(f"Starting server on {host}:{port}") logger.info(f"Starting server on {host}:{port}")
uvicorn.run("main:app", host=host, port=port, reload=True) uvicorn.run("main:app", host=host, port=port, reload=False)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 883 KiB

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 879 KiB

After

Width:  |  Height:  |  Size: 142 KiB