mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-05-07 08:43:30 +08:00
Compare commits
278 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cd43770eb | ||
|
|
e3134f2078 | ||
|
|
118b7357c5 | ||
|
|
c9ab763f1b | ||
|
|
c5e08e1ec6 | ||
|
|
20fcf2c29c | ||
|
|
8fa3101f0f | ||
|
|
499366da02 | ||
|
|
1f52185539 | ||
|
|
cb5c11d41a | ||
|
|
c9ee3e6957 | ||
|
|
8e2f74c0f5 | ||
|
|
274e5d25a8 | ||
|
|
c0f978bd77 | ||
|
|
e7db5124ea | ||
|
|
4bff57c774 | ||
|
|
198f01d079 | ||
|
|
341d3ded06 | ||
|
|
7da4f9587b | ||
|
|
0e10a3d906 | ||
|
|
6d5d1ad373 | ||
|
|
3582e65dc5 | ||
|
|
3010690d2e | ||
|
|
a40bb19743 | ||
|
|
6090982261 | ||
|
|
90aeb22853 | ||
|
|
f6a3438079 | ||
|
|
c553fd898f | ||
|
|
f4801d5be7 | ||
|
|
5861ef4168 | ||
|
|
27758f95dd | ||
|
|
a2ab457f75 | ||
|
|
795615f0f7 | ||
|
|
c46c971e64 | ||
|
|
55cc3bcd63 | ||
|
|
05877a2197 | ||
|
|
3e9f908d7b | ||
|
|
8a8e448e22 | ||
|
|
a92c779dd6 | ||
|
|
ef1dec1e47 | ||
|
|
fea376d1cb | ||
|
|
b18277a3a0 | ||
|
|
d8fbceaadf | ||
|
|
dea393e713 | ||
|
|
ae2bfe4d0a | ||
|
|
2f2eb646a4 | ||
|
|
fdc888512a | ||
|
|
3cd4c749c1 | ||
|
|
efadbc267d | ||
|
|
63b8ac7e2b | ||
|
|
c105342ded | ||
|
|
1cd8c33983 | ||
|
|
dd73e56c30 | ||
|
|
4a53b6aa32 | ||
|
|
15d851f0d0 | ||
|
|
8172e64510 | ||
|
|
7969d9a75c | ||
|
|
7fb4fcba77 | ||
|
|
d9a7b89e7d | ||
|
|
8cd8c6f7b4 | ||
|
|
769aca10db | ||
|
|
7b45db2f59 | ||
|
|
a5f0211fcb | ||
|
|
658d29e72f | ||
|
|
2b3f850478 | ||
|
|
caa4619aab | ||
|
|
85b24dee40 | ||
|
|
844e1a102a | ||
|
|
10311c1438 | ||
|
|
3a0f86e74e | ||
|
|
208aed41a1 | ||
|
|
6e385b8d75 | ||
|
|
9ba895fa8d | ||
|
|
df72fa9366 | ||
|
|
6d077a4ed3 | ||
|
|
b1b0e87d85 | ||
|
|
7d325517b3 | ||
|
|
dc29319a3e | ||
|
|
880f745718 | ||
|
|
1ce8b41bde | ||
|
|
f667e9460b | ||
|
|
5f346f1b04 | ||
|
|
b813d83246 | ||
|
|
564eee2682 | ||
|
|
8fecf293bb | ||
|
|
ce76b78b34 | ||
|
|
a8c10d3961 | ||
|
|
c009afaa6c | ||
|
|
5ff88ac765 | ||
|
|
fabf4b7cd5 | ||
|
|
d8768d5d5b | ||
|
|
f05ae6a27f | ||
|
|
e487b5382b | ||
|
|
b20725cb00 | ||
|
|
e40c97b3fd | ||
|
|
ebb6d174ad | ||
|
|
ef4e67eda6 | ||
|
|
d5c3fe472a | ||
|
|
50bf467341 | ||
|
|
caad44414f | ||
|
|
f23ed6ec6c | ||
|
|
0919e65ad7 | ||
|
|
7f8d4faa44 | ||
|
|
7687e898bc | ||
|
|
467deefd28 | ||
|
|
d3f42f967b | ||
|
|
601bd7c4e3 | ||
|
|
6d06cb662d | ||
|
|
63e0345812 | ||
|
|
c24fcc6d7d | ||
|
|
2b0b1d2a85 | ||
|
|
29372bab6b | ||
|
|
03cb670bfa | ||
|
|
8ad1ea6d38 | ||
|
|
cdcbfc89bc | ||
|
|
2a510e8059 | ||
|
|
f8808737a3 | ||
|
|
0aaec4a53f | ||
|
|
1ab91965f3 | ||
|
|
689a6d99b0 | ||
|
|
2a164828a2 | ||
|
|
abbda6848a | ||
|
|
3afc0c1166 | ||
|
|
e9ac6e499f | ||
|
|
02d2b6d983 | ||
|
|
2683569a0b | ||
|
|
5876b88a8a | ||
|
|
58648399a2 | ||
|
|
4981e09ede | ||
|
|
29c4926306 | ||
|
|
7d9d47d7b7 | ||
|
|
3b3e6b86f3 | ||
|
|
d92cc4a977 | ||
|
|
4a0f483224 | ||
|
|
5e63630033 | ||
|
|
80f1b6b48b | ||
|
|
032446d5eb | ||
|
|
35ef60b956 | ||
|
|
cf512e226f | ||
|
|
2dfc1c068f | ||
|
|
2b0fb8f4ad | ||
|
|
f1cc79aab4 | ||
|
|
fff4fdc9c9 | ||
|
|
1945586b55 | ||
|
|
8b1bc54f2d | ||
|
|
707241bf6b | ||
|
|
b965020491 | ||
|
|
df5c0f771a | ||
|
|
31f42aa26e | ||
|
|
be3db5faaf | ||
|
|
9b298d3094 | ||
|
|
ee9f6ed80c | ||
|
|
0a43bca0c0 | ||
|
|
9063026d45 | ||
|
|
32c57b61b5 | ||
|
|
3d91a4f29e | ||
|
|
44203c5382 | ||
|
|
44d89e3b73 | ||
|
|
539d9f3868 | ||
|
|
d6e7a6e394 | ||
|
|
d4f18feaf9 | ||
|
|
f365dfe5de | ||
|
|
8c4d59918e | ||
|
|
53be1f341c | ||
|
|
aeae3410a0 | ||
|
|
41b067305e | ||
|
|
3862c657b7 | ||
|
|
1c848c727f | ||
|
|
4a0bff9919 | ||
|
|
a5a523f918 | ||
|
|
1b78fb6417 | ||
|
|
c1ef98f6d9 | ||
|
|
fbb292d0e3 | ||
|
|
6ff8b4d90f | ||
|
|
490ee11a85 | ||
|
|
591f0d5ddd | ||
|
|
b2034c0865 | ||
|
|
3239675e69 | ||
|
|
137cf81d29 | ||
|
|
c6aa6603ef | ||
|
|
235a044a1a | ||
|
|
1888849270 | ||
|
|
8c0f637ab1 | ||
|
|
00ca7e891c | ||
|
|
5e7c381e07 | ||
|
|
da645291a2 | ||
|
|
c7656e5609 | ||
|
|
faad0fd4d4 | ||
|
|
048a3b70df | ||
|
|
bab8e3af65 | ||
|
|
b75caaea0e | ||
|
|
140c9b1d88 | ||
|
|
668785ebe5 | ||
|
|
883b112fc2 | ||
|
|
8e917ee15e | ||
|
|
5298e6adb3 | ||
|
|
17216534cb | ||
|
|
1071da2bed | ||
|
|
2dfcb600ae | ||
|
|
274d7b9677 | ||
|
|
0a5196a475 | ||
|
|
892ccd9ee4 | ||
|
|
732ea0ba2b | ||
|
|
8ed50ba662 | ||
|
|
d4d5e063d0 | ||
|
|
8e1ab5373f | ||
|
|
61ca6d2fe6 | ||
|
|
21c9d47495 | ||
|
|
321d22271a | ||
|
|
1af6cde68f | ||
|
|
5fe78c2a68 | ||
|
|
17f5bad16d | ||
|
|
51fb59e3e1 | ||
|
|
3d9cb1aaa9 | ||
|
|
ae92ec190a | ||
|
|
832c0fe437 | ||
|
|
894e34b28d | ||
|
|
b31588e00d | ||
|
|
d1f108041b | ||
|
|
e2757a18b9 | ||
|
|
051a099d5f | ||
|
|
be4c3313d4 | ||
|
|
bab61d8462 | ||
|
|
41a79d60a5 | ||
|
|
0bedd7ff6f | ||
|
|
be2a749905 | ||
|
|
c1b1439510 | ||
|
|
03c950eb63 | ||
|
|
3d8981f970 | ||
|
|
cbc94fafce | ||
|
|
d8cec22f54 | ||
|
|
0f40a99f70 | ||
|
|
b9b0e581e7 | ||
|
|
d6b50773b9 | ||
|
|
97f153646f | ||
|
|
6ea9023558 | ||
|
|
c0746aab57 | ||
|
|
c492f0780b | ||
|
|
bf9098db3c | ||
|
|
0e055b34ca | ||
|
|
5fbf84fc36 | ||
|
|
bb64936a38 | ||
|
|
749635e156 | ||
|
|
6e084f720d | ||
|
|
23e7104f5a | ||
|
|
bbba401637 | ||
|
|
72daeda465 | ||
|
|
fd3b105821 | ||
|
|
94220c8b97 | ||
|
|
e4c1c0f7d1 | ||
|
|
58402c6554 | ||
|
|
244bf73260 | ||
|
|
f766966802 | ||
|
|
6496fd097b | ||
|
|
04dad3b72a | ||
|
|
7066b4288a | ||
|
|
1e2a2d33a8 | ||
|
|
d04c7f50ef | ||
|
|
0a13a22d1d | ||
|
|
f3839951bd | ||
|
|
b66c366a08 | ||
|
|
f3c8deb367 | ||
|
|
44e991a9d0 | ||
|
|
ad730bd52d | ||
|
|
1309a592df | ||
|
|
eea22fb1c5 | ||
|
|
c65de4654f | ||
|
|
eb0a46183d | ||
|
|
75541f3d34 | ||
|
|
c037c4b385 | ||
|
|
06e0eb2ce3 | ||
|
|
02688f1600 | ||
|
|
bd68ba35b9 | ||
|
|
885083e8e6 | ||
|
|
e24979f6f4 | ||
|
|
6fcffb635e | ||
|
|
246e8a1406 | ||
|
|
508a0efd92 |
35
.dockerignore
Normal file
35
.dockerignore
Normal file
@@ -0,0 +1,35 @@
|
||||
# Git 和 IDE
|
||||
.git
|
||||
.github
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
|
||||
# Tauri 构建产物(非常大)
|
||||
BillNote_frontend/src-tauri/target
|
||||
BillNote_frontend/src-tauri/bin
|
||||
|
||||
# 运行时数据
|
||||
backend/data
|
||||
backend/static
|
||||
backend/models
|
||||
backend/logs
|
||||
backend/uploads
|
||||
backend/*.db
|
||||
backend/note_results
|
||||
backend/bin/
|
||||
|
||||
# 依赖和构建缓存
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
dist/
|
||||
build/
|
||||
*.tar
|
||||
*.egg-info/
|
||||
|
||||
# 环境文件
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
!.env.example
|
||||
27
.env.example
27
.env.example
@@ -1,29 +1,24 @@
|
||||
###
|
||||
# @Author: 思诺特 jefferyhcool@gmail.com
|
||||
# @Date: 2025-04-14 08:49:59
|
||||
# @LastEditors: 思诺特 jefferyhcool@gmail.com
|
||||
# @LastEditTime: 2025-04-26 19:56:50
|
||||
# @FilePath: \BiliNote\.env.example
|
||||
# @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
|
||||
###
|
||||
# 通用端口配置
|
||||
BACKEND_PORT=8001
|
||||
BACKEND_PORT=8483 # 后端端口
|
||||
FRONTEND_PORT=3015
|
||||
BACKEND_HOST=0.0.0.0 # 默认为 0.0.0.0,表示监听所有 IP 地址 不建议动
|
||||
|
||||
# 前端访问后端用(生产环境建议写公网或宿主机 IP)
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8001
|
||||
VITE_SCREENSHOT_BASE_URL=http://127.0.0.1:8001/static/screenshots
|
||||
|
||||
APP_PORT= 3015 # docker 部署时用
|
||||
# 前端访问后端用 (开发环境使用)
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8000
|
||||
VITE_SCREENSHOT_BASE_URL=http://127.0.0.1:8483/static/screenshots
|
||||
VITE_FRONTEND_PORT=3015
|
||||
# 生产环境配置
|
||||
ENV=production
|
||||
STATIC=/static
|
||||
OUT_DIR=./static/screenshots
|
||||
NOTE_OUTPUT_DIR=note_results
|
||||
IMAGE_BASE_URL=/static/screenshots
|
||||
DATA_DIR=data
|
||||
# FFMPEG 配置
|
||||
FFMPEG_BIN_PATH=
|
||||
|
||||
# transcriber 相关配置
|
||||
TRANSCRIBER_TYPE=fast-whisper # fast-whisper/bcut/kuaishou/mlx-whisper(仅Apple平台)
|
||||
WHISPER_MODEL_SIZE=base
|
||||
TRANSCRIBER_TYPE=fast-whisper # fast-whisper/bcut/kuaishou/mlx-whisper(仅Apple平台)/groq
|
||||
WHISPER_MODEL_SIZE=medium
|
||||
|
||||
GROQ_TRANSCRIBER_MODEL=whisper-large-v3-turbo # groq提供的faster-whisper 默认为 whisper-large-v3-turbo
|
||||
|
||||
49
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
49
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: 上报一些bug
|
||||
title: "[BUG]"
|
||||
labels: bug
|
||||
assignees: JefferyHcool
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
name: 🐛 Bug 反馈
|
||||
about: 提交一个 Bug 报告,帮助我们改进
|
||||
title: "[Bug] "
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**版本说明**
|
||||
|
||||
请说明的你的版本号
|
||||
|
||||
**部署方式**
|
||||
|
||||
使用的是什么方式部署(代码环境部署,docker部署,桌面端,在线预览)
|
||||
|
||||
**描述问题**
|
||||
清晰、简明地描述你遇到的问题是什么。
|
||||
|
||||
**复现步骤**
|
||||
复现该问题的步骤:
|
||||
|
||||
1. 进入页面 '...'
|
||||
2. 点击 '...'
|
||||
3. 滚动到 '...'
|
||||
4. 出现错误
|
||||
|
||||
**预期行为**
|
||||
清晰、简明地描述你本来预期发生的行为。
|
||||
|
||||
**截图**
|
||||
如果适用,请添加截图以帮助说明问题。
|
||||
|
||||
**桌面端(请补充以下信息)**
|
||||
|
||||
- 操作系统:例如 Windows / macOS / Ubuntu
|
||||
- 浏览器:例如 Chrome、Safari
|
||||
|
||||
**其他补充信息**
|
||||
请补充任何其他相关信息。
|
||||
29
.github/ISSUE_TEMPLATE/新增功能建议.md
vendored
Normal file
29
.github/ISSUE_TEMPLATE/新增功能建议.md
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: 新增功能建议
|
||||
about: 一些新的功能建议
|
||||
title: "[FEATHURE]"
|
||||
labels: enhancement
|
||||
assignees: JefferyHcool
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
name: ✨ 功能请求
|
||||
about: 提出一个新的功能建议
|
||||
title: "[Feature] "
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**这个功能请求是否与某个问题相关?请描述**
|
||||
清晰简要地描述问题是什么。例如:每次遇到 [...] 都让我感到很沮丧。
|
||||
|
||||
**描述你希望实现的解决方案**
|
||||
清晰简要地描述你希望发生的事情。
|
||||
|
||||
**描述你考虑过的备选方案**
|
||||
清晰简要地描述你考虑过的其他解决方案或功能。
|
||||
|
||||
**其他补充信息**
|
||||
请在此添加关于功能请求的其他上下文或截图。
|
||||
73
.github/workflows/docker-build.yml
vendored
Normal file
73
.github/workflows/docker-build.yml
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix=
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.complete
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Generate Usage Instructions
|
||||
run: |
|
||||
echo "=========================================="
|
||||
echo "Docker Image Published!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Pull the image:"
|
||||
echo " docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
|
||||
echo ""
|
||||
echo "Run the container:"
|
||||
echo " docker run -d -p 80:80 \\"
|
||||
echo " -v bilinote-data:/app/backend/data \\"
|
||||
echo " --name bilinote \\"
|
||||
echo " ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
|
||||
echo ""
|
||||
echo "Access the application at: http://localhost"
|
||||
echo "=========================================="
|
||||
160
.github/workflows/main.yml
vendored
Normal file
160
.github/workflows/main.yml
vendored
Normal file
@@ -0,0 +1,160 @@
|
||||
name: Build & Release Desktop App
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: macos-latest
|
||||
target: universal-apple-darwin
|
||||
- platform: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- platform: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Linux 系统依赖(Tauri 需要)
|
||||
- name: Install Linux Dependencies
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
# 设置 Python 环境(带 pip 缓存)
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: backend/requirements.txt
|
||||
|
||||
# 安装 Python 依赖并执行构建
|
||||
- name: Install Python dependencies & Build backend
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||
backend\\build.bat
|
||||
else
|
||||
chmod +x backend/build.sh
|
||||
./backend/build.sh
|
||||
fi
|
||||
|
||||
# 设置 pnpm
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 'latest'
|
||||
|
||||
# 设置 Node 环境
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: BillNote_frontend
|
||||
run: pnpm install
|
||||
|
||||
# 设置 Rust 环境
|
||||
- name: Set up Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
# Cargo 缓存
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
BillNote_frontend/src-tauri/target/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('BillNote_frontend/src-tauri/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-
|
||||
|
||||
# 打包 Tauri 应用
|
||||
- name: Build Tauri App
|
||||
working-directory: BillNote_frontend
|
||||
run: pnpm tauri build
|
||||
|
||||
# 收集产物到统一目录
|
||||
- name: Collect release artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p release-artifacts
|
||||
BUNDLE_DIR="BillNote_frontend/src-tauri/target/release/bundle"
|
||||
|
||||
# macOS: .dmg
|
||||
find "$BUNDLE_DIR" -name "*.dmg" -exec cp {} release-artifacts/ \; 2>/dev/null || true
|
||||
# Windows: .msi, .exe (NSIS)
|
||||
find "$BUNDLE_DIR" -name "*.msi" -exec cp {} release-artifacts/ \; 2>/dev/null || true
|
||||
find "$BUNDLE_DIR/nsis" -name "*.exe" -exec cp {} release-artifacts/ \; 2>/dev/null || true
|
||||
# Linux: .deb, .AppImage
|
||||
find "$BUNDLE_DIR" -name "*.deb" -exec cp {} release-artifacts/ \; 2>/dev/null || true
|
||||
find "$BUNDLE_DIR" -name "*.AppImage" -exec cp {} release-artifacts/ \; 2>/dev/null || true
|
||||
|
||||
echo "=== Collected artifacts ==="
|
||||
ls -lh release-artifacts/
|
||||
|
||||
# 生成 SHA256 校验和
|
||||
- name: Generate checksums
|
||||
shell: bash
|
||||
run: |
|
||||
cd release-artifacts
|
||||
sha256sum * > SHA256SUMS.txt 2>/dev/null || shasum -a 256 * > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
# 上传产物(供 release job 使用)
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: artifacts-${{ matrix.platform }}
|
||||
path: release-artifacts/
|
||||
|
||||
# 创建 GitHub Release 并上传所有产物
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 下载所有平台的构建产物
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: all-artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: List all artifacts
|
||||
run: |
|
||||
echo "=== All release artifacts ==="
|
||||
ls -lhR all-artifacts/
|
||||
|
||||
# 创建 Release 并上传产物
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: BiliNote ${{ github.ref_name }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
files: all-artifacts/*
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -190,7 +190,7 @@ cover/
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
.idea/
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
@@ -316,4 +316,14 @@ cython_debug/
|
||||
/backend/note_results
|
||||
/backend/models
|
||||
/backend/.idea/*
|
||||
/backend/bili_note.db
|
||||
/backend/bili_note.db
|
||||
/backend/uploads/*
|
||||
/backend/.idea/*
|
||||
/backend/config/*
|
||||
/backend/vector_db/
|
||||
/BiliNote_frontend/.idea/*
|
||||
/BiliNote_frontend/src-tauri/bin/
|
||||
|
||||
# FFmpeg 构建文件(不应该提交到仓库)
|
||||
ffmpeg*/
|
||||
ffmpg*/
|
||||
1
.vscode/settings.json
vendored
Normal file
1
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
2
BillNote_frontend/.env.tauri
Normal file
2
BillNote_frontend/.env.tauri
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8483/api
|
||||
VITE_PLATFORM=tauri
|
||||
2
BillNote_frontend/.gitignore
vendored
2
BillNote_frontend/.gitignore
vendored
@@ -22,4 +22,4 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
/pnpm-lock.yaml
|
||||
/src-tauri/bin/
|
||||
|
||||
@@ -1,32 +1,23 @@
|
||||
# === 前端构建阶段 ===
|
||||
FROM node:18-alpine AS build
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
# 安装 pnpm
|
||||
RUN npm install -g pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 拷贝前端源码
|
||||
COPY ./BillNote_frontend /app
|
||||
# 先复制 lockfile 利用依赖层缓存
|
||||
COPY ./BillNote_frontend/package.json ./BillNote_frontend/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# 安装依赖并构建
|
||||
RUN pnpm install && pnpm run build
|
||||
# 再复制源代码并构建
|
||||
COPY ./BillNote_frontend/ ./
|
||||
RUN pnpm run build
|
||||
|
||||
# === nginx 运行阶段 ===
|
||||
FROM nginx:alpine
|
||||
# --- 阶段2:使用 nginx 作为静态服务器 ---
|
||||
FROM nginx:1.25-alpine
|
||||
|
||||
# 拷贝模板配置
|
||||
COPY ./BillNote_frontend/deploy/default.conf.template /etc/nginx/templates/default.conf.template
|
||||
RUN rm -rf /etc/nginx/conf.d/default.conf
|
||||
COPY ./BillNote_frontend/deploy/default.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# 拷贝构建产物
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# 拷贝启动脚本
|
||||
COPY ./BillNote_frontend/deploy/start.sh /start.sh
|
||||
RUN chmod +x /start.sh
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# 使用启动脚本启动容器
|
||||
CMD ["/start.sh"]
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
11
BillNote_frontend/deploy/default.conf
Normal file
11
BillNote_frontend/deploy/default.conf
Normal file
@@ -0,0 +1,11 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
18705
BillNote_frontend/package-lock.json
generated
Normal file
18705
BillNote_frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/x": "^2.4.0",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@lobehub/icons": "^1.97.1",
|
||||
"@lobehub/icons-static-svg": "^1.45.0",
|
||||
@@ -21,37 +22,54 @@
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.2.2",
|
||||
"@radix-ui/react-tabs": "^1.1.4",
|
||||
"@radix-ui/react-tabs": "^1.1.9",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@tauri-apps/plugin-shell": "~2.2.2",
|
||||
"@uiw/react-markdown-preview": "^5.1.3",
|
||||
"antd": "^5.24.8",
|
||||
"axios": "^1.8.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"katex": "^0.16.21",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"jszip": "^3.10.1",
|
||||
"katex": "^0.16.22",
|
||||
"lottie-react": "^2.4.1",
|
||||
"lucide-react": "^0.487.0",
|
||||
"markdown-navbar": "^1.4.3",
|
||||
"markmap-common": "^0.18.9",
|
||||
"markmap-lib": "^0.18.11",
|
||||
"markmap-toolbar": "^0.18.10",
|
||||
"markmap-view": "^0.18.10",
|
||||
"next-themes": "^0.4.6",
|
||||
"pinyin-match": "^1.2.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.55.0",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-medium-image-zoom": "^5.2.14",
|
||||
"react-resizable-panels": "^2.1.8",
|
||||
"react-router-dom": "^7.5.1",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"remark-gfm": "1.0.0",
|
||||
"rehype-katex": "^6.0.2",
|
||||
"remark-gfm": "3.0.1",
|
||||
"remark-math": "^5.1.1",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "^3.1.0",
|
||||
"tailwindcss": "^4.1.3",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.24.2",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@tailwindcss/postcss": "^4.1.3",
|
||||
"@tauri-apps/cli": "^2.5.0",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
|
||||
BIN
BillNote_frontend/public/preview_1.png
Normal file
BIN
BillNote_frontend/public/preview_1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
4
BillNote_frontend/src-tauri/.gitignore
vendored
Normal file
4
BillNote_frontend/src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
5027
BillNote_frontend/src-tauri/Cargo.lock
generated
Normal file
5027
BillNote_frontend/src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
BillNote_frontend/src-tauri/Cargo.toml
Normal file
31
BillNote_frontend/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.2.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
tauri = { version = "2.5.0", features = ["devtools"] }
|
||||
tauri-plugin-log = "2.0.0-rc"
|
||||
tauri-plugin-shell = "2"
|
||||
|
||||
[package.metadata.tauri.bundle.macOS]
|
||||
frameworks = ["bin/BiliNoteBackend/_internal/"]
|
||||
|
||||
|
||||
3
BillNote_frontend/src-tauri/build.rs
Normal file
3
BillNote_frontend/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
19
BillNote_frontend/src-tauri/capabilities/default.json
Normal file
19
BillNote_frontend/src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
{
|
||||
"identifier": "shell:allow-execute",
|
||||
"allow": [
|
||||
{
|
||||
"name": "BiliNoteBackend",
|
||||
"sidecar": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"shell:allow-open"
|
||||
]
|
||||
}
|
||||
BIN
BillNote_frontend/src-tauri/icons/icon.ico
Normal file
BIN
BillNote_frontend/src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
BIN
BillNote_frontend/src-tauri/icons/icon.png
Normal file
BIN
BillNote_frontend/src-tauri/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
285
BillNote_frontend/src-tauri/src/lib.rs
Normal file
285
BillNote_frontend/src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,285 @@
|
||||
use tauri::{Manager, Emitter};
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use tauri_plugin_shell::process::CommandEvent;
|
||||
use std::env;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.setup(|app| {
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)?;
|
||||
}
|
||||
|
||||
let exe_path = env::current_exe().expect("无法获取当前可执行文件路径");
|
||||
let sidecar_dir = exe_path.parent().expect("无法获取可执行文件的父目录");
|
||||
|
||||
// 收集所有系统环境变量
|
||||
let mut all_env_vars = HashMap::new();
|
||||
for (key, value) in env::vars() {
|
||||
all_env_vars.insert(key, value);
|
||||
}
|
||||
|
||||
// 增强 PATH 环境变量,添加常见的二进制路径
|
||||
let current_path = all_env_vars.get("PATH").cloned().unwrap_or_default();
|
||||
let additional_paths = get_additional_binary_paths();
|
||||
let enhanced_path = enhance_path_variable(¤t_path, &additional_paths);
|
||||
all_env_vars.insert("PATH".to_string(), enhanced_path);
|
||||
|
||||
// 打印一些关键环境变量用于调试
|
||||
println!("Enhanced PATH: {}", all_env_vars.get("PATH").unwrap_or(&"Not found".to_string()));
|
||||
println!("Total environment variables: {}", all_env_vars.len());
|
||||
|
||||
// 检查 ffmpeg 是否在 PATH 中可用
|
||||
check_ffmpeg_availability();
|
||||
|
||||
// 启动 Python 后端侧车
|
||||
let mut sidecar_command = app.shell().sidecar("BiliNoteBackend").unwrap();
|
||||
|
||||
// 设置所有环境变量到 sidecar
|
||||
for (key, value) in &all_env_vars {
|
||||
sidecar_command = sidecar_command.env(key, value);
|
||||
}
|
||||
|
||||
let (mut rx, _child) = sidecar_command
|
||||
.current_dir(sidecar_dir)
|
||||
.spawn()
|
||||
.expect("Failed to spawn sidecar");
|
||||
|
||||
// 获取主窗口句柄用于发送事件
|
||||
let window = app.get_webview_window("main").unwrap();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// 读取诸如 stdout 之类的事件
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
CommandEvent::Stdout(line) => {
|
||||
let output = String::from_utf8_lossy(&line);
|
||||
println!("Backend stdout: {}", output);
|
||||
|
||||
// 发送到前端
|
||||
window
|
||||
.emit("backend-message", Some(format!("'{}'", output)))
|
||||
.expect("failed to emit event");
|
||||
}
|
||||
CommandEvent::Stderr(line) => {
|
||||
let error = String::from_utf8_lossy(&line);
|
||||
eprintln!("Backend stderr: {}", error);
|
||||
|
||||
window
|
||||
.emit("backend-error", Some(format!("'{}'", error)))
|
||||
.expect("failed to emit event");
|
||||
}
|
||||
CommandEvent::Terminated(payload) => {
|
||||
println!("Backend terminated with code: {:?}", payload.code);
|
||||
window
|
||||
.emit("backend-terminated", Some(payload.code))
|
||||
.expect("failed to emit event");
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
println!("Backend event: {:?}", event);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_system_env_vars,
|
||||
find_executable_path,
|
||||
run_command_with_env,
|
||||
test_ffmpeg_access
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
// 获取额外的二进制路径
|
||||
fn get_additional_binary_paths() -> Vec<String> {
|
||||
if cfg!(target_os = "windows") {
|
||||
vec![
|
||||
"C:\\ffmpeg\\bin".to_string(),
|
||||
"C:\\Program Files\\ffmpeg\\bin".to_string(),
|
||||
"C:\\Program Files (x86)\\ffmpeg\\bin".to_string(),
|
||||
"C:\\tools\\ffmpeg\\bin".to_string(),
|
||||
"C:\\ProgramData\\chocolatey\\bin".to_string(),
|
||||
]
|
||||
} else if cfg!(target_os = "macos") {
|
||||
vec![
|
||||
"/usr/local/bin".to_string(),
|
||||
"/opt/homebrew/bin".to_string(),
|
||||
"/usr/bin".to_string(),
|
||||
"/bin".to_string(),
|
||||
"/opt/local/bin".to_string(), // MacPorts
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
"/usr/local/bin".to_string(),
|
||||
"/usr/bin".to_string(),
|
||||
"/bin".to_string(),
|
||||
"/snap/bin".to_string(),
|
||||
"/opt/bin".to_string(),
|
||||
"/usr/local/sbin".to_string(),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 增强 PATH 环境变量
|
||||
fn enhance_path_variable(current_path: &str, additional_paths: &[String]) -> String {
|
||||
let path_separator = if cfg!(target_os = "windows") { ";" } else { ":" };
|
||||
|
||||
let mut paths: Vec<String> = additional_paths.to_vec();
|
||||
|
||||
// 添加当前 PATH
|
||||
if !current_path.is_empty() {
|
||||
paths.push(current_path.to_string());
|
||||
}
|
||||
|
||||
paths.join(path_separator)
|
||||
}
|
||||
|
||||
// 检查 ffmpeg 可用性
|
||||
fn check_ffmpeg_availability() {
|
||||
use std::process::Command;
|
||||
|
||||
match Command::new("ffmpeg").arg("-version").output() {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
println!("✓ FFmpeg is available in PATH");
|
||||
let version_info = String::from_utf8_lossy(&output.stdout);
|
||||
let first_line = version_info.lines().next().unwrap_or("Unknown version");
|
||||
println!("FFmpeg version: {}", first_line);
|
||||
} else {
|
||||
println!("✗ FFmpeg found but returned error");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("✗ FFmpeg not found in PATH: {}", e);
|
||||
|
||||
// 尝试在常见路径中查找
|
||||
let common_paths = get_additional_binary_paths();
|
||||
for path in common_paths {
|
||||
let ffmpeg_path = if cfg!(target_os = "windows") {
|
||||
format!("{}\\ffmpeg.exe", path)
|
||||
} else {
|
||||
format!("{}/ffmpeg", path)
|
||||
};
|
||||
|
||||
if std::path::Path::new(&ffmpeg_path).exists() {
|
||||
println!("✓ Found FFmpeg at: {}", ffmpeg_path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
println!("✗ FFmpeg not found in common installation paths");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tauri 命令:获取系统环境变量
|
||||
#[tauri::command]
|
||||
fn get_system_env_vars() -> HashMap<String, String> {
|
||||
env::vars().collect()
|
||||
}
|
||||
|
||||
// Tauri 命令:查找可执行文件路径
|
||||
#[tauri::command]
|
||||
fn find_executable_path(executable_name: String) -> Option<String> {
|
||||
use std::process::Command;
|
||||
|
||||
// 首先尝试直接执行
|
||||
if Command::new(&executable_name).arg("--version").output().is_ok() {
|
||||
return Some(executable_name);
|
||||
}
|
||||
|
||||
// 使用 which/where 命令查找
|
||||
let which_cmd = if cfg!(target_os = "windows") { "where" } else { "which" };
|
||||
|
||||
if let Ok(output) = Command::new(which_cmd).arg(&executable_name).output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 在常见路径中搜索
|
||||
let common_paths = get_additional_binary_paths();
|
||||
for base_path in common_paths {
|
||||
let executable_path = if cfg!(target_os = "windows") {
|
||||
format!("{}\\{}.exe", base_path, executable_name)
|
||||
} else {
|
||||
format!("{}/{}", base_path, executable_name)
|
||||
};
|
||||
|
||||
if std::path::Path::new(&executable_path).exists() {
|
||||
return Some(executable_path);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// Tauri 命令:使用完整环境变量运行命令
|
||||
#[tauri::command]
|
||||
async fn run_command_with_env(
|
||||
program: String,
|
||||
args: Vec<String>
|
||||
) -> Result<String, String> {
|
||||
use std::process::Command;
|
||||
|
||||
let mut cmd = Command::new(&program);
|
||||
cmd.args(&args);
|
||||
|
||||
// 设置所有环境变量
|
||||
for (key, value) in env::vars() {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
|
||||
// 增强 PATH
|
||||
let current_path = env::var("PATH").unwrap_or_default();
|
||||
let additional_paths = get_additional_binary_paths();
|
||||
let enhanced_path = enhance_path_variable(¤t_path, &additional_paths);
|
||||
cmd.env("PATH", enhanced_path);
|
||||
|
||||
match cmd.output() {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&output.stderr).to_string())
|
||||
}
|
||||
}
|
||||
Err(e) => Err(format!("Failed to execute {}: {}", program, e))
|
||||
}
|
||||
}
|
||||
|
||||
// Tauri 命令:测试 ffmpeg 访问
|
||||
#[tauri::command]
|
||||
async fn test_ffmpeg_access() -> Result<String, String> {
|
||||
run_command_with_env("ffmpeg".to_string(), vec!["-version".to_string()]).await
|
||||
}
|
||||
|
||||
// 可选:添加一个函数来动态更新 sidecar 的环境变量
|
||||
#[tauri::command]
|
||||
async fn update_sidecar_environment(
|
||||
app_handle: tauri::AppHandle,
|
||||
additional_env_vars: HashMap<String, String>
|
||||
) -> Result<(), String> {
|
||||
// 这个函数可以用来在运行时更新环境变量
|
||||
// 注意:这需要重启 sidecar 才能生效
|
||||
|
||||
for (key, value) in additional_env_vars {
|
||||
env::set_var(key, value);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
6
BillNote_frontend/src-tauri/src/main.rs
Normal file
6
BillNote_frontend/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
app_lib::run();
|
||||
}
|
||||
46
BillNote_frontend/src-tauri/tauri.conf.json
Normal file
46
BillNote_frontend/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "BiliNote",
|
||||
"version": "2.0.0",
|
||||
"identifier": "com.jefferyhuang.bilinote",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:3015",
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"beforeBuildCommand": "pnpm build --mode tauri "
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "BiliNote",
|
||||
"width": 1600,
|
||||
"height": 1000,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"devtools": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"externalBin": [
|
||||
"bin/BiliNoteBackend/BiliNoteBackend"
|
||||
],
|
||||
"resources": {
|
||||
"bin/BiliNoteBackend/_internal":"_internal"
|
||||
},
|
||||
"macOS":{
|
||||
"files": {
|
||||
"Frameworks": "bin/BiliNoteBackend/_internal"
|
||||
}
|
||||
},
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/icon.ico",
|
||||
"icons/icon.png"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,70 @@
|
||||
import './App.css'
|
||||
import { HomePage } from './pages/HomePage/Home.tsx'
|
||||
import { lazy, Suspense, useEffect } from 'react'
|
||||
import { BrowserRouter, Navigate, Routes, Route } from 'react-router-dom'
|
||||
import { useTaskPolling } from '@/hooks/useTaskPolling.ts'
|
||||
import SettingPage from './pages/SettingPage/index.tsx'
|
||||
import { BrowserRouter, Navigate, Routes } from 'react-router-dom'
|
||||
import { Route } from 'react-router-dom'
|
||||
import { useCheckBackend } from '@/hooks/useCheckBackend.ts'
|
||||
import { systemCheck } from '@/services/system.ts'
|
||||
import BackendInitDialog from '@/components/BackendInitDialog'
|
||||
import Index from '@/pages/Index.tsx'
|
||||
import NotFoundPage from '@/pages/NotFoundPage' //
|
||||
import Model from '@/pages/SettingPage/Model.tsx'
|
||||
import Transcriber from '@/pages/SettingPage/transcriber.tsx'
|
||||
import ProviderForm from '@/components/Form/modelForm/Form.tsx'
|
||||
import StepBar from '@/pages/HomePage/components/StepBar.tsx'
|
||||
import Downloading from '@/components/Lottie/download.tsx'
|
||||
import { HomePage } from './pages/HomePage/Home.tsx'
|
||||
|
||||
// 非首屏页面使用 React.lazy 按需加载
|
||||
const SettingPage = lazy(() => import('./pages/SettingPage/index.tsx'))
|
||||
const Model = lazy(() => import('@/pages/SettingPage/Model.tsx'))
|
||||
const ProviderForm = lazy(() => import('@/components/Form/modelForm/Form.tsx'))
|
||||
const AboutPage = lazy(() => import('@/pages/SettingPage/about.tsx'))
|
||||
const Monitor = lazy(() => import('@/pages/SettingPage/Monitor.tsx'))
|
||||
const Downloader = lazy(() => import('@/pages/SettingPage/Downloader.tsx'))
|
||||
const DownloaderForm = lazy(() => import('@/components/Form/DownloaderForm/Form.tsx'))
|
||||
const TranscriberPage = lazy(() => import('@/pages/SettingPage/transcriber.tsx'))
|
||||
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
|
||||
|
||||
function App() {
|
||||
useTaskPolling(3000) // 每 3 秒轮询一次
|
||||
const steps = [
|
||||
{ label: '解析链接', key: 'PARSING', icon: <Downloading /> },
|
||||
{ label: '下载音频', key: 'DOWNLOADING' },
|
||||
{ label: '转写文字', key: 'TRANSCRIBING' },
|
||||
{ label: '总结内容', key: 'SUMMARIZING' },
|
||||
{ label: '保存完成', key: 'SUCCESS' },
|
||||
]
|
||||
const { loading, initialized } = useCheckBackend()
|
||||
|
||||
// 在后端初始化完成后执行系统检查
|
||||
useEffect(() => {
|
||||
if (initialized) {
|
||||
systemCheck()
|
||||
}
|
||||
}, [initialized])
|
||||
|
||||
// 如果后端还未初始化,显示初始化对话框
|
||||
if (!initialized) {
|
||||
return (
|
||||
<>
|
||||
<BackendInitDialog open={loading} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 后端已初始化,渲染主应用
|
||||
return (
|
||||
<>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Index />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="settings" element={<SettingPage />}>
|
||||
<Route index element={<Navigate to="model" replace />} />
|
||||
<Route path="model" element={<Model />}>
|
||||
<Route path="new" element={<ProviderForm isCreate />} />
|
||||
{/*<Route index element={<Navigate to="openai" replace />} />*/}
|
||||
<Route path=":id" element={<ProviderForm />} />
|
||||
<Suspense fallback={<div className="flex h-screen items-center justify-center">加载中…</div>}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Index />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="settings" element={<SettingPage />}>
|
||||
<Route index element={<Navigate to="model" replace />} />
|
||||
<Route path="model" element={<Model />}>
|
||||
<Route path="new" element={<ProviderForm isCreate />} />
|
||||
<Route path=":id" element={<ProviderForm />} />
|
||||
</Route>
|
||||
<Route path="download" element={<Downloader />}>
|
||||
<Route path=":id" element={<DownloaderForm />} />
|
||||
</Route>
|
||||
<Route path="transcriber" element={<TranscriberPage />} />
|
||||
<Route path="monitor" element={<Monitor />}></Route>
|
||||
<Route path="about" element={<AboutPage />}></Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
<Route path="transcriber" elment={<Transcriber />}></Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</>
|
||||
)
|
||||
|
||||
12
BillNote_frontend/src/assets/icon.svg
Normal file
12
BillNote_frontend/src/assets/icon.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg width="415" height="412" viewBox="0 0 415 412" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 28C0 12.536 12.536 0 28 0H387C402.464 0 415 12.536 415 28V384C415 399.464 402.464 412 387 412H28C12.536 412 0 399.464 0 384V28Z" fill="#3C77FB"/>
|
||||
<rect x="60" y="64" width="296" height="283" rx="37" fill="white"/>
|
||||
<path d="M268.422 175.657C276.308 180.298 276.308 191.702 268.422 196.343L186.335 244.641C178.336 249.348 168.25 243.58 168.25 234.298V137.702C168.25 128.42 178.336 122.652 186.335 127.359L268.422 175.657Z" fill="#3C77FB"/>
|
||||
<path d="M17 282C17 270.954 25.9543 262 37 262H83C94.0457 262 103 270.954 103 282V282C103 293.046 94.0457 302 83 302H37C25.9543 302 17 293.046 17 282V282Z" fill="#3C77FB"/>
|
||||
<path d="M38 281.5C38 274.044 44.0442 268 51.5 268H82.5C89.9558 268 96 274.044 96 281.5V281.5C96 288.956 89.9558 295 82.5 295H51.5C44.0442 295 38 288.956 38 281.5V281.5Z" fill="white"/>
|
||||
<path d="M17 206C17 194.954 25.9543 186 37 186H83C94.0457 186 103 194.954 103 206V206C103 217.046 94.0457 226 83 226H37C25.9543 226 17 217.046 17 206V206Z" fill="#3C77FB"/>
|
||||
<path d="M38 205.5C38 198.044 44.0442 192 51.5 192H82.5C89.9558 192 96 198.044 96 205.5V205.5C96 212.956 89.9558 219 82.5 219H51.5C44.0442 219 38 212.956 38 205.5V205.5Z" fill="white"/>
|
||||
<path d="M17 130C17 118.954 25.9543 110 37 110H83C94.0457 110 103 118.954 103 130V130C103 141.046 94.0457 150 83 150H37C25.9543 150 17 141.046 17 130V130Z" fill="#3C77FB"/>
|
||||
<path d="M38 129.5C38 122.044 44.0442 116 51.5 116H82.5C89.9558 116 96 122.044 96 129.5V129.5C96 136.956 89.9558 143 82.5 143H51.5C44.0442 143 38 136.956 38 129.5V129.5Z" fill="white"/>
|
||||
<path d="M145 290C145 285.582 148.582 282 153 282H284C288.418 282 292 285.582 292 290V299C292 303.418 288.418 307 284 307H153C148.582 307 145 303.418 145 299V290Z" fill="#3C77FB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
23
BillNote_frontend/src/components/BackendInitDialog.tsx
Normal file
23
BillNote_frontend/src/components/BackendInitDialog.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
}
|
||||
|
||||
function BackendInitDialog({ open }: Props) {
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogContent className="text-center">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="animate-spin w-5 h-5" />
|
||||
后端正在初始化中…
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-muted-foreground mt-2">请稍候,系统正在启动后端服务,出现报错属于正常现象</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
export default BackendInitDialog
|
||||
@@ -0,0 +1,95 @@
|
||||
// 下载器 Cookie 设置表单(最简化版)
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { getDownloaderCookie, updateDownloaderCookie } from '@/services/downloader' // 你自定义的请求
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { videoPlatforms } from '@/constant/note.ts'
|
||||
|
||||
const CookieSchema = z.object({
|
||||
cookie: z.string().min(10, '请填写有效 Cookie'),
|
||||
})
|
||||
|
||||
const DownloaderForm = () => {
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CookieSchema),
|
||||
defaultValues: { cookie: '' },
|
||||
})
|
||||
const { id } = useParams()
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const loadCookie = async () => {
|
||||
setLoading(true) // 🔁 切换平台时显示 loading
|
||||
try {
|
||||
const res = await getDownloaderCookie(id)
|
||||
const cookie = res?.cookie || ''
|
||||
form.reset({ cookie }) // ✅ 正确重置表单值
|
||||
} catch (e) {
|
||||
toast.error('加载 Cookie 失败: ' + e)
|
||||
form.reset({ cookie: '' }) // ❗失败时也要清空旧值
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (id) loadCookie()
|
||||
}, [id]) // 🔁 每当 id 变化时触发
|
||||
|
||||
const onSubmit = async values => {
|
||||
try {
|
||||
await updateDownloaderCookie({
|
||||
platform: id,
|
||||
cookie: String(values.cookie),
|
||||
})
|
||||
toast.success('保存成功')
|
||||
} catch (e) {
|
||||
toast.error('保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-4">加载中...</div>
|
||||
|
||||
return (
|
||||
<div className="max-w-xl p-4">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||
<div className="text-lg font-bold">
|
||||
设置{videoPlatforms.find(item => item.value === id)?.label}下载器 Cookie
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cookie"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col gap-2">
|
||||
<FormLabel>Cookie</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="输入 Cookie" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit">保存</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DownloaderForm
|
||||
@@ -0,0 +1,34 @@
|
||||
import ProviderCard from '@/components/Form/DownloaderForm/providerCard.tsx'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import { useProviderStore } from '@/store/providerStore'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { DouyinLogo, KuaishouLogo } from '@/components/Icons/platform.tsx'
|
||||
import { videoPlatforms } from '@/constant/note.ts'
|
||||
|
||||
const Provider = () => {
|
||||
const navigate = useNavigate()
|
||||
const handleClick = () => {
|
||||
navigate(`/settings/model/new`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-sm font-light">下载器配置</div>
|
||||
<div>
|
||||
{videoPlatforms &&
|
||||
videoPlatforms.map((provider, index) => {
|
||||
if (provider.value !== 'local')
|
||||
return (
|
||||
<ProviderCard
|
||||
key={index}
|
||||
providerName={provider.label}
|
||||
Icon={provider?.logo}
|
||||
id={provider.value}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default Provider
|
||||
@@ -0,0 +1,6 @@
|
||||
.card {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.card:hover {
|
||||
background-color: #f7f7f7;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Switch } from '@/components/ui/switch.tsx'
|
||||
import { FC } from 'react'
|
||||
import styles from './index.module.css'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import AILogo from '@/components/Form/modelForm/Icons'
|
||||
import { useProviderStore } from '@/store/providerStore'
|
||||
export interface IProviderCardProps {
|
||||
id: string
|
||||
providerName: string
|
||||
Icon: any
|
||||
}
|
||||
const ProviderCard: FC<IProviderCardProps> = ({ providerName, Icon, id }: IProviderCardProps) => {
|
||||
const navigate = useNavigate()
|
||||
const updateProvider = useProviderStore(state => state.updateProvider)
|
||||
const handleClick = () => {
|
||||
navigate(`/settings/download/${id}`)
|
||||
}
|
||||
|
||||
const rawId = useParams()
|
||||
console.log('rawId', rawId)
|
||||
// @ts-ignore
|
||||
const { id: currentId } = useParams()
|
||||
const isActive = currentId === id
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
handleClick()
|
||||
}}
|
||||
className={
|
||||
styles.card +
|
||||
' flex h-14 items-center justify-between rounded border border-[#f3f3f3] p-2' +
|
||||
(isActive ? ' bg-[#F0F0F0] font-semibold text-blue-600' : '')
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-lg">
|
||||
<div className="flex h-6 w-6 items-center">{<Icon></Icon>}</div>
|
||||
<div className="font-semibold">{providerName}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default ProviderCard
|
||||
@@ -16,7 +16,7 @@ import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useProviderStore } from '@/store/providerStore'
|
||||
import { useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { testConnection, fetchModels } from '@/services/model.ts'
|
||||
import { testConnection, fetchModels, deleteModelById } from '@/services/model.ts'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -26,6 +26,9 @@ import {
|
||||
} from '@/components/ui/select.tsx' // ⚡新增 fetchModels
|
||||
import { ModelSelector } from '@/components/Form/modelForm/ModelSelector.tsx'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert.tsx'
|
||||
import { Tags } from 'lucide-react'
|
||||
import { X } from 'lucide-react'
|
||||
import { useModelStore } from '@/store/modelStore'
|
||||
|
||||
// ✅ Provider表单schema
|
||||
const ProviderSchema = z.object({
|
||||
@@ -52,7 +55,7 @@ interface IModel {
|
||||
root: string
|
||||
}
|
||||
const ProviderForm = ({ isCreate = false }: { isCreate?: boolean }) => {
|
||||
const { id } = useParams()
|
||||
let { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const isEditMode = !isCreate
|
||||
|
||||
@@ -60,12 +63,16 @@ const ProviderForm = ({ isCreate = false }: { isCreate?: boolean }) => {
|
||||
const loadProviderById = useProviderStore(state => state.loadProviderById)
|
||||
const updateProvider = useProviderStore(state => state.updateProvider)
|
||||
const addNewProvider = useProviderStore(state => state.addNewProvider)
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [isBuiltIn, setIsBuiltIn] = useState(false)
|
||||
const loadModelsById= useModelStore(state => state.loadModelsById)
|
||||
const [modelOptions, setModelOptions] = useState<IModel[]>([]) // ⚡新增,保存模型列表
|
||||
const [models, setModels]= useState([])
|
||||
const [modelLoading, setModelLoading] = useState(false)
|
||||
const randomColor = ()=>{
|
||||
return '#' + Math.floor(Math.random() * 16777215).toString(16)
|
||||
}
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const providerForm = useForm<ProviderFormValues>({
|
||||
@@ -91,8 +98,10 @@ const ProviderForm = ({ isCreate = false }: { isCreate?: boolean }) => {
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const load = async () => {
|
||||
if (isEditMode) {
|
||||
|
||||
const data = await loadProviderById(id!)
|
||||
providerForm.reset(data)
|
||||
setIsBuiltIn(data.type === 'built-in')
|
||||
@@ -105,11 +114,29 @@ const ProviderForm = ({ isCreate = false }: { isCreate?: boolean }) => {
|
||||
})
|
||||
setIsBuiltIn(false)
|
||||
}
|
||||
const models = await loadModelsById(id!)
|
||||
if(models){
|
||||
console.log('🔧 模型列表:', models)
|
||||
setModels(models)
|
||||
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
load()
|
||||
}, [id])
|
||||
const handelDelete=async (modelId)=>{
|
||||
if (!window.confirm('确定要删除这个模型吗?')) return
|
||||
|
||||
try {
|
||||
const res = await deleteModelById(modelId)
|
||||
console.log('🔧 删除结果:', res)
|
||||
|
||||
toast.success('删除成功')
|
||||
|
||||
} catch (e) {
|
||||
toast.error('删除异常')
|
||||
}
|
||||
}
|
||||
// 测试连通性
|
||||
const handleTest = async () => {
|
||||
const values = providerForm.getValues()
|
||||
@@ -118,18 +145,21 @@ const ProviderForm = ({ isCreate = false }: { isCreate?: boolean }) => {
|
||||
return
|
||||
}
|
||||
try {
|
||||
setTesting(true)
|
||||
const data = await testConnection({
|
||||
api_key: values.apiKey,
|
||||
base_url: values.baseUrl,
|
||||
})
|
||||
if (data.data.code === 0) {
|
||||
toast.success('测试连通性成功 🎉')
|
||||
} else {
|
||||
toast.error(`连接失败: ${data.data.msg || '未知错误'}`)
|
||||
if (!id){
|
||||
toast.error('请先保存供应商信息')
|
||||
return
|
||||
}
|
||||
setTesting(true)
|
||||
await testConnection({
|
||||
id
|
||||
})
|
||||
|
||||
toast.success('测试连通性成功 🎉')
|
||||
|
||||
} catch (error) {
|
||||
toast.error('测试连通性异常')
|
||||
|
||||
toast.error(`连接失败: ${data.data.msg || '未知错误'}`)
|
||||
// toast.error('测试连通性异常')
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
@@ -162,18 +192,21 @@ const ProviderForm = ({ isCreate = false }: { isCreate?: boolean }) => {
|
||||
// 保存Provider信息
|
||||
const onProviderSubmit = async (values: ProviderFormValues) => {
|
||||
if (isEditMode) {
|
||||
updateProvider({ ...values, id: id! })
|
||||
await updateProvider({ ...values, id: id! })
|
||||
toast.success('更新供应商成功')
|
||||
} else {
|
||||
addNewProvider({ ...values })
|
||||
id = await addNewProvider({ ...values })
|
||||
|
||||
toast.success('新增供应商成功')
|
||||
}
|
||||
// 刷新页面
|
||||
|
||||
}
|
||||
|
||||
// 保存Model信息
|
||||
const onModelSubmit = async (values: ModelFormValues) => {
|
||||
console.log('🔧 选择的模型:', values.modelName)
|
||||
toast.success(`保存模型: ${values.modelName}`)
|
||||
await loadModelsById(id!)
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-4">加载中...</div>
|
||||
@@ -267,6 +300,32 @@ const ProviderForm = ({ isCreate = false }: { isCreate?: boolean }) => {
|
||||
</div>
|
||||
<ModelSelector providerId={id!} />
|
||||
|
||||
{/*<datalist id="model-options">*/}
|
||||
{/* {modelOptions.map(model => (*/}
|
||||
{/* <option key={model.id + '1'} value={model.id} />*/}
|
||||
{/* ))}*/}
|
||||
{/*</datalist>*/}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="font-bold">已启用模型</span>
|
||||
<div className={'flex flex-wrap gap-2 rounded p-2.5'}>
|
||||
{
|
||||
models && models.map(model => {
|
||||
return (
|
||||
<span key={model.id} className="inline-flex items-center gap-1 rounded-md bg-blue-100 px-2 py-0.5 text-sm text-blue-700">
|
||||
{model.model_name}
|
||||
<button type="button" onClick={() => handelDelete(model.id)} className="hover:text-blue-900">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
</div>
|
||||
{/*<ModelSelector providerId={id!} />*/}
|
||||
|
||||
{/*<datalist id="model-options">*/}
|
||||
{/* {modelOptions.map(model => (*/}
|
||||
{/* <option key={model.id + '1'} value={model.id} />*/}
|
||||
|
||||
@@ -76,8 +76,8 @@ export function ModelSelector({ providerId }: ModelSelectorProps) {
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
{filteredModels.map(model => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{filteredModels.map((model, index) => (
|
||||
<SelectItem key={`${model.id}-${index}`} value={model.id}>
|
||||
{model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
@@ -4,47 +4,51 @@ import styles from './index.module.css'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import AILogo from '@/components/Form/modelForm/Icons'
|
||||
import { useProviderStore } from '@/store/providerStore'
|
||||
|
||||
export interface IProviderCardProps {
|
||||
id: string
|
||||
providerName: string
|
||||
Icon: string
|
||||
enable: number
|
||||
}
|
||||
|
||||
const ProviderCard: FC<IProviderCardProps> = ({
|
||||
providerName,
|
||||
Icon,
|
||||
id,
|
||||
enable,
|
||||
}: IProviderCardProps) => {
|
||||
const navigate = useNavigate()
|
||||
const updateProvider = useProviderStore(state => state.updateProvider)
|
||||
const handleClick = () => {
|
||||
navigate(`/settings/model/${id}`)
|
||||
}
|
||||
const handleEnable = () => {
|
||||
console.log('enable', enable)
|
||||
const enabled = useProviderStore(state => state.provider.find(p => p.id === id)?.enabled)
|
||||
|
||||
const isChecked = enabled === 1
|
||||
|
||||
const handleToggle = (checked: boolean) => {
|
||||
const allProviders = useProviderStore.getState().provider
|
||||
const provider = allProviders.find(p => p.id === id)
|
||||
if (!provider) return
|
||||
updateProvider({
|
||||
id,
|
||||
enabled: enable == 1 ? 0 : 1,
|
||||
...provider,
|
||||
enabled: checked ? 1 : 0,
|
||||
})
|
||||
}
|
||||
const rawId = useParams()
|
||||
console.log('rawId', rawId)
|
||||
|
||||
// @ts-ignore
|
||||
const { id: currentId } = useParams()
|
||||
const isActive = currentId === id
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
handleClick()
|
||||
}}
|
||||
className={
|
||||
styles.card +
|
||||
' flex h-14 items-center justify-between rounded border border-[#f3f3f3] p-2' +
|
||||
(isActive ? ' bg-[#F0F0F0] font-semibold text-blue-600' : '')
|
||||
}
|
||||
>
|
||||
<div className="flex items-center text-lg">
|
||||
<div
|
||||
className="flex items-center text-lg"
|
||||
onClick={() => navigate(`/settings/model/${id}`)}
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center">
|
||||
<AILogo name={Icon} />
|
||||
</div>
|
||||
@@ -53,11 +57,8 @@ const ProviderCard: FC<IProviderCardProps> = ({
|
||||
|
||||
<div>
|
||||
<Switch
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
handleEnable()
|
||||
}}
|
||||
checked={enable == 1}
|
||||
checked={isChecked}
|
||||
onCheckedChange={handleToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
168
BillNote_frontend/src/components/Icons/platform.tsx
Normal file
168
BillNote_frontend/src/components/Icons/platform.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
export const KuaishouLogo = () => {
|
||||
return (
|
||||
<svg
|
||||
t="1746695310517"
|
||||
className="icon"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="1680"
|
||||
width="200"
|
||||
height="200"
|
||||
>
|
||||
<path
|
||||
d="M299.27936 624.43008v87.48544c0 14.64832 10.70592 21.24288 23.78752 14.65856l83.49696-42.01984v-32.76288L323.072 609.7664c-13.08672-6.58432-23.79264 0.01536-23.79264 14.66368zM654.42304 436.03456c36.72064 0 66.59584-29.87008 66.59584-66.59072s-29.8752-66.59584-66.59584-66.59584c-36.71552 0-66.5856 29.8752-66.5856 66.59584s29.87008 66.59072 66.5856 66.59072zM443.56096 435.65056c47.73376 0 86.56384-38.8352 86.56384-86.56896s-38.83008-86.56896-86.56384-86.56896-86.56896 38.8352-86.56896 86.56896 38.8352 86.56896 86.56896 86.56896z"
|
||||
fill="#FF4A08"
|
||||
p-id="1681"
|
||||
></path>
|
||||
<path
|
||||
d="M849.92 51.2H174.08c-67.8656 0-122.88 55.0144-122.88 122.88v675.84c0 67.8656 55.0144 122.88 122.88 122.88h675.84c67.8656 0 122.88-55.0144 122.88-122.88V174.08c0-67.8656-55.0144-122.88-122.88-122.88zM443.56096 204.8c54.05184 0 101.22752 29.89056 125.93664 73.99936 22.24128-20.85376 52.11136-33.664 84.93056-33.664 68.54656 0 124.30848 55.76704 124.30848 124.30848s-55.76704 124.30336-124.30848 124.30336c-41.40544 0-78.12608-20.37248-100.73088-51.60448-26.48576 31.29856-66.01728 51.22048-110.13632 51.22048-79.55968 0-144.2816-64.72704-144.2816-144.2816S364.00128 204.8 443.56096 204.8z m336.65536 505.63584c0 59.97568-48.78848 108.76416-108.76416 108.76416H515.328c-47.05792 0-87.22432-30.04416-102.34368-71.96672l-87.81824 42.40384c-9.43616 4.5568-18.97984 6.8608-28.37504 6.8608h-0.00512c-30.70976 0-53.00224-24.3712-53.00224-57.9328v-140.5696c0-33.57696 22.29248-57.94304 53.00736-57.94304 9.3952 0 18.93888 2.30912 28.36992 6.86592l87.59808 42.29632c14.93504-42.26048 55.26528-72.63232 102.56896-72.63232h156.11904c59.97568 0 108.76416 48.7936 108.76416 108.76928v85.08416z"
|
||||
fill="#FF4A08"
|
||||
p-id="1682"
|
||||
></path>
|
||||
<path
|
||||
d="M671.45216 574.28992H515.328c-28.14976 0-51.05664 22.90688-51.05664 51.05664v85.08928c0 28.14976 22.90688 51.05664 51.05664 51.05664h156.11904c28.14976 0 51.05664-22.90688 51.05664-51.05664v-85.08928c0-28.14976-22.90176-51.05664-51.05152-51.05664z"
|
||||
fill="#FF4A08"
|
||||
p-id="1683"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export const DouyinLogo = () => {
|
||||
return (
|
||||
<svg
|
||||
t="1746695428425"
|
||||
className="icon"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="2731"
|
||||
width="200"
|
||||
height="200"
|
||||
>
|
||||
<path
|
||||
d="M0 0m184.32 0l655.36 0q184.32 0 184.32 184.32l0 655.36q0 184.32-184.32 184.32l-655.36 0q-184.32 0-184.32-184.32l0-655.36q0-184.32 184.32-184.32Z"
|
||||
fill="#111111"
|
||||
p-id="2732"
|
||||
></path>
|
||||
<path
|
||||
d="M204.27776 670.59712a246.25152 246.25152 0 0 1 245.97504-245.97504v147.57888a98.49856 98.49856 0 0 0-98.38592 98.38592c0 48.34304 26.14272 100.352 83.54816 100.352 3.81952 0 93.55264-0.88064 93.55264-77.19936V134.35904h157.26592a133.31456 133.31456 0 0 0 133.12 132.99712l-0.13312 147.31264a273.152 273.152 0 0 1-142.62272-38.912l-0.06144 317.98272c0 146.00192-124.24192 224.77824-241.14176 224.77824-131.74784 0.03072-231.1168-106.56768-231.1168-247.92064z"
|
||||
fill="#FF4040"
|
||||
p-id="2733"
|
||||
></path>
|
||||
<path
|
||||
d="M164.92544 631.23456a246.25152 246.25152 0 0 1 245.97504-245.97504v147.57888a98.49856 98.49856 0 0 0-98.38592 98.38592c0 48.34304 26.14272 100.352 83.54816 100.352 3.81952 0 93.55264-0.88064 93.55264-77.19936V94.99648h157.26592a133.31456 133.31456 0 0 0 133.12 132.99712l-0.13312 147.31264a273.152 273.152 0 0 1-142.62272-38.912l-0.06144 317.98272c0 146.00192-124.24192 224.77824-241.14176 224.77824-131.74784 0.03072-231.1168-106.56768-231.1168-247.92064z"
|
||||
fill="#00F5FF"
|
||||
p-id="2734"
|
||||
></path>
|
||||
<path
|
||||
d="M410.91072 427.58144c-158.8224 20.15232-284.44672 222.72-154.112 405.00224 120.40192 98.47808 373.68832 41.20576 380.70272-171.85792l-0.17408-324.1472a280.7296 280.7296 0 0 0 142.88896 38.62528V261.2224a144.98816 144.98816 0 0 1-72.8064-54.82496 135.23968 135.23968 0 0 1-54.70208-72.45824h-123.66848l-0.08192 561.41824c-0.11264 78.46912-130.9696 106.41408-164.18816 30.2592-83.18976-39.77216-64.37888-190.9248 46.31552-192.57344z"
|
||||
fill="#FFFFFF"
|
||||
p-id="2735"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const BiliBiliLogo = () => {
|
||||
return (
|
||||
<svg
|
||||
t="1746696526393"
|
||||
className="icon"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="3757"
|
||||
width="200"
|
||||
height="200"
|
||||
>
|
||||
<path
|
||||
d="M0 0m184.32 0l655.36 0q184.32 0 184.32 184.32l0 655.36q0 184.32-184.32 184.32l-655.36 0q-184.32 0-184.32-184.32l0-655.36q0-184.32 184.32-184.32Z"
|
||||
fill="#EC5D85"
|
||||
p-id="3758"
|
||||
></path>
|
||||
<path
|
||||
d="M512 241.96096h52.224l65.06496-96.31744c49.63328-50.31936 89.64096 0.43008 63.85664 45.71136l-34.31424 51.5072c257.64864 5.02784 257.64864 43.008 257.64864 325.03808 0 325.94944 0 336.46592-404.48 336.46592S107.52 893.8496 107.52 567.90016c0-277.69856 0-318.80192 253.14304-324.95616l-39.43424-58.368c-31.26272-54.90688 37.33504-90.40896 64.68608-42.37312l60.416 99.80928c18.18624-0.0512 41.18528-0.0512 65.66912-0.0512z"
|
||||
fill="#EF85A7"
|
||||
p-id="3759"
|
||||
></path>
|
||||
<path
|
||||
d="M512 338.5856c332.8 0 332.8 0 332.8 240.64s0 248.39168-332.8 248.39168-332.8-7.75168-332.8-248.39168 0-240.64 332.8-240.64z"
|
||||
fill="#EC5D85"
|
||||
p-id="3760"
|
||||
></path>
|
||||
<path
|
||||
d="M281.6 558.08a30.72 30.72 0 0 1-27.47392-16.97792 30.72 30.72 0 0 1 13.73184-41.216l122.88-61.44a30.72 30.72 0 0 1 41.216 13.74208 30.72 30.72 0 0 1-13.74208 41.216l-122.88 61.44a30.59712 30.59712 0 0 1-13.73184 3.23584zM752.64 558.08a30.60736 30.60736 0 0 1-12.8512-2.83648l-133.12-61.44a30.72 30.72 0 0 1-15.04256-40.7552 30.72 30.72 0 0 1 40.76544-15.02208l133.12 61.44A30.72 30.72 0 0 1 752.64 558.08zM454.656 666.88a15.36 15.36 0 0 1-12.288-6.1952 15.36 15.36 0 0 1 3.072-21.49376l68.5056-50.91328 50.35008 52.62336a15.36 15.36 0 0 1-22.20032 21.23776l-31.5904-33.024-46.71488 34.72384a15.28832 15.28832 0 0 1-9.13408 3.04128z"
|
||||
fill="#EF85A7"
|
||||
p-id="3761"
|
||||
></path>
|
||||
<path
|
||||
d="M65.536 369.31584c15.03232 101.90848 32.84992 147.17952 44.544 355.328 14.63296 2.18112 177.70496 10.04544 204.05248-74.62912a16.14848 16.14848 0 0 0 1.64864-10.87488c-30.60736-80.3328-169.216-60.416-169.216-60.416s-10.36288-146.50368-11.49952-238.83776zM362.25024 383.03744l34.816 303.17568h34.64192L405.23776 381.1328zM309.52448 536.28928h45.48608l16.09728 158.6176-31.82592 1.85344zM446.86336 542.98624h45.80352V705.3312h-33.87392zM296.6016 457.97376h21.39136l5.2736 58.99264-18.91328 2.26304zM326.99392 457.97376h21.39136l2.53952 55.808-17.408 1.61792zM470.62016 459.88864h19.456v62.27968h-19.456zM440.23808 459.88864h22.20032v62.27968h-16.62976z"
|
||||
fill="#FFFFFF"
|
||||
p-id="3762"
|
||||
></path>
|
||||
<path
|
||||
d="M243.56864 645.51936a275.456 275.456 0 0 1-28.4672 23.74656 242.688 242.688 0 0 1-29.53216 17.52064 2.70336 2.70336 0 0 1-4.4032-1.95584 258.60096 258.60096 0 0 1-5.12-29.57312c-1.41312-12.1856-1.95584-25.68192-2.16064-36.36224 0-0.3072 0-2.5088 3.01056-1.90464a245.92384 245.92384 0 0 1 34.22208 9.5744 257.024 257.024 0 0 1 32.3584 15.17568c0.52224 0.256 2.51904 1.4848 0.09216 3.77856z"
|
||||
fill="#EB5480"
|
||||
p-id="3763"
|
||||
></path>
|
||||
<path
|
||||
d="M513.29024 369.31584c15.03232 101.90848 32.84992 147.17952 44.544 355.328 14.63296 2.18112 177.70496 10.04544 204.05248-74.62912a16.14848 16.14848 0 0 0 1.64864-10.87488c-30.60736-80.3328-169.216-60.416-169.216-60.416s-10.36288-146.50368-11.49952-238.83776zM810.00448 383.03744l34.816 303.17568h34.64192L852.992 381.1328zM757.27872 536.28928h45.48608l16.09728 158.6176-31.82592 1.85344zM894.6176 542.98624h45.80352V705.3312H906.5472zM744.35584 457.97376h21.39136l5.2736 58.99264-18.91328 2.26304zM774.74816 457.97376h21.39136l2.53952 55.808-17.408 1.61792zM918.3744 459.88864h19.456v62.27968h-19.456zM887.99232 459.88864h22.20032v62.27968h-16.62976z"
|
||||
fill="#FFFFFF"
|
||||
p-id="3764"
|
||||
></path>
|
||||
<path
|
||||
d="M691.32288 645.51936a275.456 275.456 0 0 1-28.4672 23.74656 242.688 242.688 0 0 1-29.53216 17.52064 2.70336 2.70336 0 0 1-4.4032-1.95584 258.60096 258.60096 0 0 1-5.12-29.57312c-1.41312-12.1856-1.95584-25.68192-2.16064-36.36224 0-0.3072 0-2.5088 3.01056-1.90464a245.92384 245.92384 0 0 1 34.22208 9.5744 257.024 257.024 0 0 1 32.3584 15.17568c0.52224 0.256 2.51904 1.4848 0.09216 3.77856z"
|
||||
fill="#EB5480"
|
||||
p-id="3765"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const YoutubeLogo = () => {
|
||||
return (
|
||||
<svg
|
||||
t="1746696577253"
|
||||
className="icon"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="4785"
|
||||
width="200"
|
||||
height="200"
|
||||
>
|
||||
<path
|
||||
d="M426.666667 682.666667V384l256 149.845333L426.666667 682.666667z m587.093333-355.541334s-10.026667-71.04-40.704-102.357333c-38.954667-41.088-82.602667-41.258667-102.613333-43.648C727.168 170.666667 512.213333 170.666667 512.213333 170.666667h-0.426666s-214.954667 0-358.229334 10.453333c-20.053333 2.389333-63.658667 2.56-102.656 43.648-30.677333 31.317333-40.661333 102.4-40.661333 102.4S0 410.538667 0 493.952v78.293333c0 83.456 10.24 166.912 10.24 166.912s9.984 71.04 40.661333 102.357334c38.997333 41.088 90.154667 39.765333 112.938667 44.074666C245.76 893.568 512 896 512 896s215.168-0.341333 358.442667-10.752c20.053333-2.432 63.658667-2.602667 102.613333-43.690667 30.72-31.317333 40.704-102.4 40.704-102.4s10.24-83.413333 10.24-166.869333v-78.250667c0-83.456-10.24-166.912-10.24-166.912z"
|
||||
fill="#FF0000"
|
||||
p-id="4786"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const LocalLogo = () => {
|
||||
return (
|
||||
<svg
|
||||
t="1746696617516"
|
||||
className="icon"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="5795"
|
||||
width="200"
|
||||
height="200"
|
||||
>
|
||||
<path
|
||||
d="M948.736 144.384H461.568l-56.576-83.456c-6.144-7.168-15.36-10.752-24.576-9.728H79.872c-17.152-0.512-34.048 5.632-46.592 17.408-12.544 11.776-19.968 28.16-20.48 45.312v222.464c0-18.944 7.424-37.12 20.992-50.432 13.312-13.312 31.488-20.992 50.432-20.992h855.808c18.944 0 37.12 7.424 50.432 20.992 13.312 13.312 20.992 31.488 20.992 50.432V213.248c1.28-36.096-26.624-66.816-62.72-68.864z m0 0"
|
||||
fill="#FFD569"
|
||||
p-id="5796"
|
||||
></path>
|
||||
<path
|
||||
d="M939.776 265.216H84.224C44.8 265.216 12.8 297.216 12.8 336.64v570.368c0 18.944 7.424 37.12 20.992 50.432 13.312 13.312 31.488 20.992 50.432 20.992h855.808c18.944 0 37.12-7.424 50.432-20.992 13.312-13.312 20.992-31.488 20.992-50.432V336.64c0-18.944-7.424-37.12-20.992-50.432-13.568-13.312-31.744-20.992-50.688-20.992z m-213.76 467.968c0.256 6.4-3.328 12.288-9.216 14.848-1.792 0.256-3.84 0.256-5.632 0-4.096 0-7.936-1.792-10.752-4.864l-54.784-59.136v77.056c0.256 8.704-6.4 15.872-14.848 16.384h-317.44c-7.936-0.512-14.336-6.912-14.848-14.848V495.616c-0.256-8.704 6.4-15.872 14.848-16.384h317.44c8.704 0.512 15.616 7.68 15.36 16.384v76.544l54.784-57.344c3.84-4.864 10.496-6.144 16.128-3.584 5.632 2.816 9.472 8.704 9.216 14.848v207.104z m0 0"
|
||||
fill="#FFC225"
|
||||
p-id="5797"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
34
BillNote_frontend/src/components/LazyImage.tsx
Normal file
34
BillNote_frontend/src/components/LazyImage.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
// components/LazyImage.tsx
|
||||
import { useInView } from 'react-intersection-observer'
|
||||
import { FC, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface LazyImageProps {
|
||||
src: string
|
||||
alt?: string
|
||||
className?: string
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const LazyImage: FC<LazyImageProps> = ({ src, alt, className, placeholder = '.src/assets/placeholder.png' }) => {
|
||||
const { ref, inView } = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
return (
|
||||
<div ref={ref} className={clsx('overflow-hidden', className)}>
|
||||
{inView ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
onLoad={() => setLoaded(true)}
|
||||
className={clsx('transition-opacity duration-300', loaded ? 'opacity-100' : 'opacity-0') + ' h-10 w-14 rounded-md object-cover'}
|
||||
/>
|
||||
) : (
|
||||
<img src={placeholder} alt="loading" className="opacity-30" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LazyImage
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FC } from 'react'
|
||||
import Lottie from 'lottie-react'
|
||||
import error from '@/assets/Lottie/error.json'
|
||||
import error from '@/assets/Lottie/Error.json'
|
||||
|
||||
const Error: FC = () => {
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import * as React from 'react'
|
||||
import { useState, useEffect } from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||
import { CheckIcon } from 'lucide-react'
|
||||
|
||||
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 (
|
||||
<CheckboxPrimitive.Root
|
||||
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',
|
||||
className
|
||||
)}
|
||||
checked={isChecked}
|
||||
onCheckedChange={handleCheckChange}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
|
||||
141
BillNote_frontend/src/components/ui/dialog.tsx
Normal file
141
BillNote_frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
function Input({ className, type, value, onChange, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<input
|
||||
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',
|
||||
className
|
||||
)}
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
54
BillNote_frontend/src/components/ui/resizable.tsx
Normal file
54
BillNote_frontend/src/components/ui/resizable.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import * as React from "react"
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
)
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
64
BillNote_frontend/src/components/ui/tabs.tsx
Normal file
64
BillNote_frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
35
BillNote_frontend/src/constant/note.ts
Normal file
35
BillNote_frontend/src/constant/note.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/* -------------------- 常量 -------------------- */
|
||||
import {
|
||||
BiliBiliLogo,
|
||||
DouyinLogo,
|
||||
KuaishouLogo,
|
||||
LocalLogo,
|
||||
YoutubeLogo,
|
||||
} from '@/components/Icons/platform.tsx'
|
||||
|
||||
export const noteFormats = [
|
||||
{ label: '目录', value: 'toc' },
|
||||
{ label: '原片跳转', value: 'link' },
|
||||
{ label: '原片截图', value: 'screenshot' },
|
||||
{ label: 'AI总结', value: 'summary' },
|
||||
] as const
|
||||
|
||||
export 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
|
||||
|
||||
export const videoPlatforms = [
|
||||
{ label: '哔哩哔哩', value: 'bilibili', logo: BiliBiliLogo },
|
||||
{ label: 'YouTube', value: 'youtube', logo: YoutubeLogo },
|
||||
{ label: '抖音', value: 'douyin', logo: DouyinLogo },
|
||||
{ label: '快手', value: 'kuaishou', logo: KuaishouLogo },
|
||||
{ label: '本地视频', value: 'local', logo: LocalLogo },
|
||||
] as const
|
||||
52
BillNote_frontend/src/hooks/useCheckBackend.ts
Normal file
52
BillNote_frontend/src/hooks/useCheckBackend.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const MAX_RETRIES = 3
|
||||
const RETRY_INTERVAL = 10000 // 10秒
|
||||
|
||||
export const useCheckBackend = () => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let retries = 0
|
||||
|
||||
const check = async () => {
|
||||
try {
|
||||
await request.get('/sys_check')
|
||||
setInitialized(true)
|
||||
setLoading(false)
|
||||
} catch {
|
||||
if (retries === 0) {
|
||||
// 第一次失败时开始显示加载状态
|
||||
setLoading(true)
|
||||
}
|
||||
|
||||
if (retries < MAX_RETRIES) {
|
||||
retries++
|
||||
setTimeout(check, RETRY_INTERVAL)
|
||||
} else {
|
||||
// 达到重试上限,继续轮询直到后端就绪
|
||||
waitUntilBackendReady()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const waitUntilBackendReady = async () => {
|
||||
while (true) {
|
||||
try {
|
||||
await request.get('/sys_health')
|
||||
setInitialized(true)
|
||||
setLoading(false)
|
||||
break
|
||||
} catch {
|
||||
await new Promise(res => setTimeout(res, RETRY_INTERVAL))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
check()
|
||||
}, [])
|
||||
|
||||
return { loading, initialized }
|
||||
}
|
||||
@@ -22,15 +22,17 @@ export const useTaskPolling = (interval = 3000) => {
|
||||
task => task.status != 'SUCCESS' && task.status != 'FAILED'
|
||||
)
|
||||
|
||||
// 无活跃任务时跳过轮询
|
||||
if (pendingTasks.length === 0) return
|
||||
|
||||
for (const task of pendingTasks) {
|
||||
try {
|
||||
console.log('🔄 正在轮询任务:', task.id)
|
||||
const res = await get_task_status(task.id)
|
||||
const { status } = res.data
|
||||
const { status } = res
|
||||
|
||||
if (status && status !== task.status) {
|
||||
if (status === 'SUCCESS') {
|
||||
const { markdown, transcript, audio_meta } = res.data.result
|
||||
const { markdown, transcript, audio_meta } = res.result
|
||||
toast.success('笔记生成成功')
|
||||
updateTaskContent(task.id, {
|
||||
status,
|
||||
@@ -47,9 +49,7 @@ export const useTaskPolling = (interval = 3000) => {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('❌ 任务轮询失败:', e)
|
||||
toast.error(`生成失败 ${e.message || e}`)
|
||||
updateTaskContent(task.id, { status: 'FAILED' })
|
||||
// removeTask(task.id)
|
||||
}
|
||||
}
|
||||
}, interval)
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
html,body{
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 修改滚动条轨道颜色 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px; /* 控制滚动条的宽度 */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { FC } from 'react'
|
||||
import { SlidersHorizontal } from 'lucide-react'
|
||||
import React, { FC, useRef, useState } from 'react'
|
||||
import { SlidersHorizontal, PanelLeftClose, PanelLeftOpen, History as HistoryIcon } from 'lucide-react'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -7,71 +7,166 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip.tsx'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable'
|
||||
import { ScrollArea } from "@/components/ui/scroll-area.tsx"
|
||||
import type { ImperativePanelHandle } from 'react-resizable-panels'
|
||||
import logo from '@/assets/icon.svg'
|
||||
|
||||
interface IProps {
|
||||
NoteForm: React.ReactNode
|
||||
Preview: React.ReactNode
|
||||
History: React.ReactNode
|
||||
}
|
||||
|
||||
const HomeLayout: FC<IProps> = ({ NoteForm, Preview, History }) => {
|
||||
const [, setShowSettings] = useState(false)
|
||||
const [isLeftCollapsed, setIsLeftCollapsed] = useState(false)
|
||||
const [isMiddleCollapsed, setIsMiddleCollapsed] = useState(false)
|
||||
const leftPanelRef = useRef<ImperativePanelHandle>(null)
|
||||
const middlePanelRef = useRef<ImperativePanelHandle>(null)
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-white">
|
||||
<div className="flex flex-1">
|
||||
{/* 左侧部分:Header + 表单 */}
|
||||
<aside className="flex w-[340px] flex-col border-r border-neutral-200 bg-white">
|
||||
{/* Header */}
|
||||
<header className="flex h-16 items-center justify-between px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-2xl">
|
||||
<img src="/icon.svg" alt="logo" className="h-full w-full object-contain" />
|
||||
<div className="flex h-screen flex-col overflow-hidden">
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
|
||||
{/* 左边表单 */}
|
||||
<ResizablePanel
|
||||
ref={leftPanelRef}
|
||||
defaultSize={23}
|
||||
minSize={10}
|
||||
maxSize={35}
|
||||
collapsible
|
||||
collapsedSize={0}
|
||||
onCollapse={() => setIsLeftCollapsed(true)}
|
||||
onExpand={() => setIsLeftCollapsed(false)}
|
||||
>
|
||||
<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">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-2xl">
|
||||
<img src={logo} alt="logo" className="h-full w-full object-contain" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-800">BiliNote</div>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-800">BiliNote</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => leftPanelRef.current?.collapse()}
|
||||
className="text-muted-foreground hover:text-primary cursor-pointer rounded p-1 hover:bg-neutral-100"
|
||||
>
|
||||
<PanelLeftClose className="h-5 w-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span>收起工作区</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger onClick={() => setShowSettings(true)}>
|
||||
<Link to={'/settings'}>
|
||||
<SlidersHorizontal className="text-muted-foreground hover:text-primary cursor-pointer" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span>全局配置</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</header>
|
||||
<ScrollArea className="flex-1 overflow-auto">
|
||||
<div className="p-4">{NoteForm}</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle />
|
||||
|
||||
{/* 左面板折叠时的展开按钮 */}
|
||||
{isLeftCollapsed && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => leftPanelRef.current?.expand()}
|
||||
className="flex h-full w-8 shrink-0 items-center justify-center border-r border-neutral-200 bg-white hover:bg-neutral-50"
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<span>展开工作区</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* 中间历史 */}
|
||||
<ResizablePanel
|
||||
ref={middlePanelRef}
|
||||
defaultSize={16}
|
||||
minSize={10}
|
||||
maxSize={30}
|
||||
collapsible
|
||||
collapsedSize={0}
|
||||
onCollapse={() => setIsMiddleCollapsed(true)}
|
||||
onExpand={() => setIsMiddleCollapsed(false)}
|
||||
>
|
||||
<aside className="flex h-full flex-col overflow-hidden border-r border-neutral-200 bg-white">
|
||||
<header className="flex h-10 shrink-0 items-center justify-between border-b border-neutral-100 px-3">
|
||||
<span className="text-sm font-medium text-gray-600">生成历史</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger onClick={() => setShowSettings(true)}>
|
||||
<Link to={'/settings'}>
|
||||
<SlidersHorizontal className="text-muted-foreground hover:text-primary cursor-pointer" />
|
||||
</Link>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => middlePanelRef.current?.collapse()}
|
||||
className="text-muted-foreground hover:text-primary cursor-pointer rounded p-1 hover:bg-neutral-100"
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span>全局配置</span>
|
||||
<span>收起历史</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
||||
<ScrollArea className="flex-1 overflow-auto">
|
||||
<div>{History}</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
</ResizablePanel>
|
||||
|
||||
{/* 表单内容 */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{/*<NoteForm />*/}
|
||||
{NoteForm}
|
||||
</div>
|
||||
</aside>
|
||||
<aside className="flex h-full w-[300px] flex-col border-r border-neutral-200 bg-white">
|
||||
{/* Header */}
|
||||
<ResizableHandle />
|
||||
|
||||
{/* 表单内容 */}
|
||||
{/*<NoteForm />*/}
|
||||
{History}
|
||||
</aside>
|
||||
{/* 中间面板折叠时的展开按钮 */}
|
||||
{isMiddleCollapsed && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => middlePanelRef.current?.expand()}
|
||||
className="flex h-full w-8 shrink-0 items-center justify-center border-r border-neutral-200 bg-white hover:bg-neutral-50"
|
||||
>
|
||||
<HistoryIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<span>展开历史</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* 右侧预览区域 */}
|
||||
<main className="h-screen flex-1 overflow-hidden bg-white p-6">
|
||||
{/*<Outlet />*/}
|
||||
{Preview}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* 页脚 */}
|
||||
{/*<footer className="h-12 bg-white shadow-inner flex items-center justify-center text-sm text-neutral-600">*/}
|
||||
{/* © 2025 BiliNote. All rights reserved.*/}
|
||||
{/*</footer>*/}
|
||||
{/* 右边预览 */}
|
||||
<ResizablePanel defaultSize={61} minSize={30}>
|
||||
<main className="flex h-full flex-col overflow-hidden bg-white p-6">{Preview}</main>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
import { Link, Outlet } from 'react-router-dom'
|
||||
import { SlidersHorizontal } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import logo from '@/assets/icon.svg'
|
||||
|
||||
interface ISettingLayoutProps {
|
||||
Menu: React.ReactNode
|
||||
}
|
||||
@@ -25,7 +27,7 @@ const SettingLayout = ({ Menu }: ISettingLayoutProps) => {
|
||||
<header className="flex h-16 items-center justify-between px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-2xl">
|
||||
<img src="/icon.svg" alt="logo" className="h-full w-full object-contain" />
|
||||
<img src={logo} alt="logo" className="h-full w-full object-contain" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-800">BiliNote</div>
|
||||
</div>
|
||||
|
||||
8
BillNote_frontend/src/lib/markmap.ts
Normal file
8
BillNote_frontend/src/lib/markmap.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { loadCSS, loadJS } from 'markmap-common'
|
||||
import { Transformer } from 'markmap-lib'
|
||||
import * as markmap from 'markmap-view'
|
||||
|
||||
export const transformer = new Transformer()
|
||||
const { scripts, styles } = transformer.getAssets()
|
||||
loadCSS(styles)
|
||||
loadJS(scripts, { getMarkmap: () => markmap })
|
||||
@@ -3,7 +3,6 @@ import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import RootLayout from './layouts/RootLayout.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<RootLayout>
|
||||
|
||||
@@ -18,14 +18,15 @@ export const HomePage: FC = () => {
|
||||
useEffect(() => {
|
||||
if (!currentTask) {
|
||||
setStatus('idle')
|
||||
} else if (currentTask.status === 'PENDING') {
|
||||
setStatus('loading')
|
||||
} else if (currentTask.status === 'SUCCESS') {
|
||||
setStatus('success')
|
||||
} else if (currentTask.status === 'FAILED') {
|
||||
setStatus('failed')
|
||||
} else {
|
||||
// PENDING、PARSING、DOWNLOADING、TRANSCRIBING、SUMMARIZING 等所有进行中状态
|
||||
setStatus('loading')
|
||||
}
|
||||
}, [currentTask])
|
||||
}, [currentTask, currentTask?.status])
|
||||
|
||||
// useEffect( () => {
|
||||
// get_task_status('d4e87938-c066-48a0-bbd5-9bec40d53354').then(res=>{
|
||||
@@ -36,7 +37,7 @@ export const HomePage: FC = () => {
|
||||
return (
|
||||
<HomeLayout
|
||||
NoteForm={<NoteForm />}
|
||||
Preview={<MarkdownViewer status={status} content={content} />}
|
||||
Preview={<MarkdownViewer status={status} />}
|
||||
History={<History />}
|
||||
/>
|
||||
)
|
||||
|
||||
294
BillNote_frontend/src/pages/HomePage/components/ChatPanel.tsx
Normal file
294
BillNote_frontend/src/pages/HomePage/components/ChatPanel.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Bubble, Sender } from '@ant-design/x'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Loader2, Trash2, ChevronDown, ChevronUp, BookOpen, UserRound, Bot, Maximize2, Minimize2 } from 'lucide-react'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { useChatStore } from '@/store/chatStore'
|
||||
import { useTaskStore } from '@/store/taskStore'
|
||||
import { askQuestion, getChatStatus, indexTask, type ChatSource, type IndexStatus } from '@/services/chat'
|
||||
|
||||
type ChatMode = 'half' | 'full'
|
||||
|
||||
interface ChatPanelProps {
|
||||
taskId: string
|
||||
mode: ChatMode
|
||||
onModeChange: (mode: ChatMode) => void
|
||||
}
|
||||
|
||||
function SourceBadges({ sources }: { sources: ChatSource[] }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
if (!sources || sources.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mt-1.5">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-1 text-xs text-neutral-400 hover:text-neutral-600"
|
||||
>
|
||||
<BookOpen className="h-3 w-3" />
|
||||
<span>引用来源 ({sources.length})</span>
|
||||
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{sources.map((s, i) => (
|
||||
<Badge key={i} variant="outline" className="text-xs font-normal">
|
||||
{s.source_type === 'markdown'
|
||||
? s.section_title || '笔记'
|
||||
: `${(s.start_time ?? 0).toFixed(0)}s ~ ${(s.end_time ?? 0).toFixed(0)}s`}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ChatPanel({ taskId, mode, onModeChange }: ChatPanelProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [indexStatus, setIndexStatus] = useState<IndexStatus | null>(null)
|
||||
|
||||
const messages = useChatStore(state => state.chatHistory[taskId]) ?? []
|
||||
const addMessage = useChatStore(state => state.addMessage)
|
||||
const clearChat = useChatStore(state => state.clearChat)
|
||||
|
||||
const currentTaskId = useTaskStore(state => state.currentTaskId)
|
||||
const tasks = useTaskStore(state => state.tasks)
|
||||
const currentTask = useMemo(
|
||||
() => tasks.find(t => t.id === currentTaskId) ?? null,
|
||||
[tasks, currentTaskId],
|
||||
)
|
||||
|
||||
// 检查索引状态,未索引时自动触发,indexing 时轮询
|
||||
useEffect(() => {
|
||||
if (!taskId) return
|
||||
let cancelled = false
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const res = await getChatStatus(taskId)
|
||||
if (cancelled) return
|
||||
setIndexStatus(res.status)
|
||||
|
||||
if (res.status === 'idle') {
|
||||
// 未索引,触发后台索引
|
||||
await indexTask(taskId)
|
||||
if (!cancelled) setIndexStatus('indexing')
|
||||
}
|
||||
|
||||
// indexing 状态持续轮询
|
||||
if (res.status === 'indexing' || res.status === 'idle') {
|
||||
timer = setTimeout(poll, 2000)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setIndexStatus('failed')
|
||||
}
|
||||
}
|
||||
|
||||
poll()
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}, [taskId])
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (value: string) => {
|
||||
const question = value.trim()
|
||||
if (!question || loading) return
|
||||
|
||||
const providerId = currentTask?.formData?.provider_id
|
||||
const modelName = currentTask?.formData?.model_name
|
||||
if (!providerId || !modelName) {
|
||||
toast.error('无法获取模型配置,请确认任务已完成')
|
||||
return
|
||||
}
|
||||
|
||||
addMessage(taskId, { role: 'user', content: question })
|
||||
setInput('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const history = messages.map(m => ({ role: m.role, content: m.content }))
|
||||
const res = await askQuestion({
|
||||
task_id: taskId,
|
||||
question,
|
||||
history,
|
||||
provider_id: providerId,
|
||||
model_name: modelName,
|
||||
})
|
||||
addMessage(taskId, {
|
||||
role: 'assistant',
|
||||
content: res.answer,
|
||||
sources: res.sources,
|
||||
})
|
||||
} catch {
|
||||
toast.error('问答请求失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[loading, taskId, currentTask, messages, addMessage],
|
||||
)
|
||||
|
||||
// 转换为 Bubble.List 的数据格式
|
||||
const bubbleItems = useMemo(() => {
|
||||
const items = messages.map((msg, i) => ({
|
||||
key: `msg-${i}`,
|
||||
role: msg.role === 'user' ? ('user' as const) : ('ai' as const),
|
||||
content: msg.content,
|
||||
footer:
|
||||
msg.role === 'assistant' && msg.sources ? (
|
||||
<SourceBadges sources={msg.sources} />
|
||||
) : undefined,
|
||||
}))
|
||||
|
||||
if (loading) {
|
||||
items.push({
|
||||
key: 'loading',
|
||||
role: 'ai' as const,
|
||||
content: '思考中...',
|
||||
loading: true,
|
||||
} as any)
|
||||
}
|
||||
|
||||
return items
|
||||
}, [messages, loading])
|
||||
|
||||
// Bubble 角色配置
|
||||
const roles = useMemo(
|
||||
() => ({
|
||||
user: {
|
||||
placement: 'end' as const,
|
||||
avatar: (
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-blue-500 text-white">
|
||||
<UserRound className="h-4 w-4" />
|
||||
</div>
|
||||
),
|
||||
variant: 'filled' as const,
|
||||
styles: { content: { background: '#3b82f6', color: '#fff' } },
|
||||
},
|
||||
ai: {
|
||||
placement: 'start' as const,
|
||||
avatar: (
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-neutral-500 text-white">
|
||||
<Bot className="h-4 w-4" />
|
||||
</div>
|
||||
),
|
||||
variant: 'outlined' as const,
|
||||
contentRender: (content: any) => (
|
||||
<div className="markdown-body prose prose-sm max-w-none prose-p:my-1 prose-li:my-0.5 prose-headings:my-2">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{typeof content === 'string' ? content : String(content)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
if (indexStatus === null || indexStatus === 'indexing' || indexStatus === 'idle') {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-neutral-400">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">正在索引笔记内容...</p>
|
||||
<p className="mt-1 text-xs">首次使用需下载 Embedding 模型(约 80MB),请耐心等待</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (indexStatus === 'failed') {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-neutral-400">
|
||||
<span className="text-sm">索引失败,请重试</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
setIndexStatus('indexing')
|
||||
try {
|
||||
await indexTask(taskId)
|
||||
} catch {
|
||||
toast.error('索引请求失败')
|
||||
setIndexStatus('failed')
|
||||
}
|
||||
}}
|
||||
>
|
||||
重新索引
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col border-l">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<span className="text-sm font-medium">AI 问答</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-neutral-400 hover:text-neutral-600"
|
||||
onClick={() => onModeChange(mode === 'half' ? 'full' : 'half')}
|
||||
title={mode === 'half' ? '全屏' : '半屏'}
|
||||
>
|
||||
{mode === 'half' ? (
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Minimize2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
{messages.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-neutral-400 hover:text-red-500"
|
||||
onClick={() => clearChat(taskId)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{messages.length === 0 && !loading ? (
|
||||
<div className="flex h-full items-center justify-center text-center text-sm text-neutral-400">
|
||||
<div>
|
||||
<p>针对笔记内容提问</p>
|
||||
<p className="mt-1 text-xs">例如:这个视频的核心观点是什么?</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Bubble.List
|
||||
items={bubbleItems}
|
||||
role={roles}
|
||||
style={{ height: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 输入区域 */}
|
||||
<div className="border-t px-3 py-2">
|
||||
<Sender
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={handleSend}
|
||||
loading={loading}
|
||||
placeholder="输入你的问题..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,7 +13,7 @@ const History = () => {
|
||||
<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">
|
||||
<ScrollArea className="w-full sm:h-[480px] md:h-[720px] lg:h-[92%]">
|
||||
{/*<div className="w-full flex-1 overflow-y-auto">*/}
|
||||
<NoteHistory onSelect={setCurrentTask} selectedId={currentTaskId} />
|
||||
{/*</div>*/}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Copy, Download, BrainCircuit, MessageSquare } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
interface VersionNote {
|
||||
ver_id: string
|
||||
model_name?: string
|
||||
style?: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
interface NoteHeaderProps {
|
||||
currentTask?: {
|
||||
markdown: VersionNote[] | string
|
||||
}
|
||||
isMultiVersion: boolean
|
||||
currentVerId: string
|
||||
setCurrentVerId: (id: string) => void
|
||||
modelName: string
|
||||
style: string
|
||||
noteStyles: { value: string; label: string }[]
|
||||
onCopy: () => void
|
||||
onDownload: () => void
|
||||
createAt?: string | Date
|
||||
setShowTranscribe: (show: boolean) => void
|
||||
showChat?: false | 'half' | 'full'
|
||||
setShowChat?: (mode: false | 'half' | 'full') => void
|
||||
}
|
||||
|
||||
export function MarkdownHeader({
|
||||
currentTask,
|
||||
isMultiVersion,
|
||||
currentVerId,
|
||||
setCurrentVerId,
|
||||
modelName,
|
||||
style,
|
||||
noteStyles,
|
||||
onCopy,
|
||||
onDownload,
|
||||
createAt,
|
||||
showTranscribe,
|
||||
setShowTranscribe,
|
||||
showChat,
|
||||
setShowChat,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
}: NoteHeaderProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout
|
||||
if (copied) {
|
||||
timer = setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
return () => clearTimeout(timer)
|
||||
}, [copied])
|
||||
|
||||
const handleCopy = () => {
|
||||
onCopy()
|
||||
setCopied(true)
|
||||
}
|
||||
|
||||
const styleName = noteStyles.find(v => v.value === style)?.label || style
|
||||
|
||||
const reversedMarkdown: VersionNote[] = Array.isArray(currentTask?.markdown)
|
||||
? [...currentTask!.markdown].reverse()
|
||||
: []
|
||||
|
||||
const formatDate = (date: string | Date | undefined) => {
|
||||
if (!date) return ''
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
if (isNaN(d.getTime())) return ''
|
||||
return d
|
||||
.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
.replace(/\//g, '-')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-3 border-b bg-white/95 px-4 py-2 backdrop-blur-sm">
|
||||
{/* 左侧区域:版本 + 标签 + 创建时间 */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{isMultiVersion && (
|
||||
<Select value={currentVerId} onValueChange={setCurrentVerId}>
|
||||
<SelectTrigger className="h-8 w-[160px] text-sm">
|
||||
<div className="flex items-center">
|
||||
{(() => {
|
||||
const idx = currentTask?.markdown.findIndex(v => v.ver_id === currentVerId)
|
||||
return idx !== -1 ? `版本(${currentVerId.slice(-6)})` : ''
|
||||
})()}
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{(currentTask?.markdown || []).map((v, idx) => {
|
||||
const shortId = v.ver_id.slice(-6)
|
||||
return (
|
||||
<SelectItem key={v.ver_id} value={v.ver_id}>
|
||||
{`版本(${shortId})`}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Badge variant="secondary" className="bg-pink-100 text-pink-700 hover:bg-pink-200">
|
||||
{modelName}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="bg-cyan-100 text-cyan-700 hover:bg-cyan-200">
|
||||
{styleName}
|
||||
</Badge>
|
||||
|
||||
{createAt && (
|
||||
<div className="text-muted-foreground text-sm">创建时间: {formatDate(createAt)}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧操作按钮 */}
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setViewMode(viewMode == 'preview' ? 'map' : 'preview')
|
||||
}}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<BrainCircuit className="mr-1.5 h-4 w-4" />
|
||||
<span className="text-sm">{viewMode == 'preview' ? '思维导图' : 'markdown'}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>思维导图</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={handleCopy} variant="ghost" size="sm" className="h-8 px-2">
|
||||
<Copy className="mr-1.5 h-4 w-4" />
|
||||
<span className="text-sm">{copied ? '已复制' : '复制'}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>复制内容</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={onDownload} variant="ghost" size="sm" className="h-8 px-2">
|
||||
<Download className="mr-1.5 h-4 w-4" />
|
||||
<span className="text-sm">导出 Markdown</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>下载为 Markdown 文件</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowTranscribe(!showTranscribe)
|
||||
}}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
>
|
||||
{/*<Download className="mr-1.5 h-4 w-4" />*/}
|
||||
<span className="text-sm">原文参照</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>原文参照</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{setShowChat && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => setShowChat(showChat ? false : 'half')}
|
||||
variant={showChat ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<MessageSquare className="mr-1.5 h-4 w-4" />
|
||||
<span className="text-sm">AI 问答</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>基于笔记内容的 AI 问答</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,40 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useRef, useMemo, memo, FC } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import { Copy, Download, FileText, ArrowRight } from 'lucide-react'
|
||||
import { toast } from 'sonner' // 你可以换成自己的通知组件
|
||||
import { Copy, Download, ArrowRight, Play, ExternalLink } from 'lucide-react'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import Error from '@/components/Lottie/error.tsx'
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import { solarizedlight as codeStyle } from 'react-syntax-highlighter/dist/cjs/styles/prism'
|
||||
import 'github-markdown-css/github-markdown-light.css'
|
||||
import { FC } from 'react'
|
||||
import Loading from '@/components/Lottie/Loading.tsx'
|
||||
import Idle from '@/components/Lottie/Idle.tsx'
|
||||
import { useTaskStore } from '@/store/taskStore'
|
||||
import StepBar from '@/pages/HomePage/components/StepBar.tsx'
|
||||
interface MarkdownViewerProps {
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import { atomDark as codeStyle } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
||||
import Zoom from 'react-medium-image-zoom'
|
||||
import 'react-medium-image-zoom/dist/styles.css'
|
||||
import gfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import 'github-markdown-css/github-markdown-light.css'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
|
||||
import { useTaskStore } from '@/store/taskStore'
|
||||
import { noteStyles } from '@/constant/note.ts'
|
||||
import { MarkdownHeader } from '@/pages/HomePage/components/MarkdownHeader.tsx'
|
||||
import TranscriptViewer from '@/pages/HomePage/components/transcriptViewer.tsx'
|
||||
import MarkmapEditor from '@/pages/HomePage/components/MarkmapComponent.tsx'
|
||||
import ChatPanel from '@/pages/HomePage/components/ChatPanel.tsx'
|
||||
import VideoBanner from '@/pages/HomePage/components/VideoBanner.tsx'
|
||||
|
||||
interface VersionNote {
|
||||
ver_id: string
|
||||
content: string
|
||||
style: string
|
||||
model_name: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
interface MarkdownViewerProps {
|
||||
content: string | VersionNote[]
|
||||
status: 'idle' | 'loading' | 'success' | 'failed'
|
||||
}
|
||||
|
||||
@@ -25,36 +46,329 @@ const steps = [
|
||||
{ label: '保存完成', key: 'SUCCESS' },
|
||||
]
|
||||
|
||||
const MarkdownViewer: FC<MarkdownViewerProps> = ({ content, status }) => {
|
||||
const remarkPlugins = [gfm, remarkMath]
|
||||
const rehypePlugins = [rehypeKatex]
|
||||
|
||||
/**
|
||||
* 构建 ReactMarkdown components 对象,baseURL 用于修正图片路径。
|
||||
* 使用函数 + useMemo 避免每次渲染都创建新的函数实例。
|
||||
*/
|
||||
function createMarkdownComponents(baseURL: string) {
|
||||
return {
|
||||
h1: ({ children, ...props }: any) => (
|
||||
<h1
|
||||
className="text-primary my-6 scroll-m-20 text-3xl font-extrabold tracking-tight lg:text-4xl"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children, ...props }: any) => (
|
||||
<h2
|
||||
className="text-primary mt-10 mb-4 scroll-m-20 border-b pb-2 text-2xl font-semibold tracking-tight first:mt-0"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children, ...props }: any) => (
|
||||
<h3
|
||||
className="text-primary mt-8 mb-4 scroll-m-20 text-xl font-semibold tracking-tight"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children, ...props }: any) => (
|
||||
<h4
|
||||
className="text-primary mt-6 mb-2 scroll-m-20 text-lg font-semibold tracking-tight"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
p: ({ children, ...props }: any) => (
|
||||
<p className="leading-7 [&:not(:first-child)]:mt-6" {...props}>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
a: ({ href, children, ...props }: any) => {
|
||||
const isOriginLink =
|
||||
typeof children[0] === 'string' &&
|
||||
(children[0] as string).startsWith('原片 @')
|
||||
|
||||
if (isOriginLink) {
|
||||
const timeMatch = (children[0] as string).match(/原片 @ (\d{2}:\d{2})/)
|
||||
const timeText = timeMatch ? timeMatch[1] : '原片'
|
||||
|
||||
return (
|
||||
<span className="origin-link my-2 inline-flex">
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-3 py-1 text-sm font-medium text-blue-700 transition-colors hover:bg-blue-100"
|
||||
{...props}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
<span>原片({timeText})</span>
|
||||
</a>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:text-primary/80 inline-flex items-center gap-0.5 font-medium underline underline-offset-4"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{href?.startsWith('http') && (
|
||||
<ExternalLink className="ml-0.5 inline-block h-3 w-3" />
|
||||
)}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
img: ({ node, ...props }: any) => {
|
||||
let src = props.src
|
||||
if (src.startsWith('/')) {
|
||||
src = baseURL + src
|
||||
}
|
||||
props.src = src
|
||||
|
||||
return (
|
||||
<div className="my-8 flex justify-center">
|
||||
<Zoom>
|
||||
<img
|
||||
{...props}
|
||||
className="max-w-full cursor-zoom-in rounded-lg object-cover shadow-md transition-all hover:shadow-lg"
|
||||
style={{ maxHeight: '500px' }}
|
||||
/>
|
||||
</Zoom>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
strong: ({ children, ...props }: any) => (
|
||||
<strong className="text-primary font-bold" {...props}>
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
li: ({ children, ...props }: any) => {
|
||||
const rawText = String(children)
|
||||
const isFakeHeading = /^(\*\*.+\*\*)$/.test(rawText.trim())
|
||||
|
||||
if (isFakeHeading) {
|
||||
return (
|
||||
<div className="text-primary my-4 text-lg font-bold">{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="my-1" {...props}>
|
||||
{children}
|
||||
</li>
|
||||
)
|
||||
},
|
||||
ul: ({ children, ...props }: any) => (
|
||||
<ul className="my-6 ml-6 list-disc [&>li]:mt-2" {...props}>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children, ...props }: any) => (
|
||||
<ol className="my-6 ml-6 list-decimal [&>li]:mt-2" {...props}>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
blockquote: ({ children, ...props }: any) => (
|
||||
<blockquote
|
||||
className="border-primary/20 text-muted-foreground mt-6 border-l-4 pl-4 italic"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
code: ({ inline, className, children, ...props }: any) => {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const codeContent = String(children).replace(/\n$/, '')
|
||||
|
||||
if (!inline && match) {
|
||||
return (
|
||||
<div className="group bg-muted relative my-6 overflow-hidden rounded-lg border shadow-sm">
|
||||
<div className="bg-muted text-muted-foreground flex items-center justify-between px-4 py-1.5 text-sm font-medium">
|
||||
<div>{match[1].toUpperCase()}</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(codeContent)
|
||||
toast.success('代码已复制')
|
||||
}}
|
||||
className="bg-background/80 hover:bg-background flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
style={codeStyle}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
className="!bg-muted !m-0 !p-0"
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
background: 'transparent',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{codeContent}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<code
|
||||
className="bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
table: ({ children, ...props }: any) => (
|
||||
<div className="my-6 w-full overflow-y-auto">
|
||||
<table className="w-full border-collapse text-sm" {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children, ...props }: any) => (
|
||||
<th
|
||||
className="border-muted-foreground/20 border px-4 py-2 text-left font-medium [&[align=center]]:text-center [&[align=right]]:text-right"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children, ...props }: any) => (
|
||||
<td
|
||||
className="border-muted-foreground/20 border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
hr: ({ ...props }: any) => (
|
||||
<hr className="border-muted-foreground/20 my-8" {...props} />
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const MarkdownViewer: FC<MarkdownViewerProps> = memo(({ status }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [currentVerId, setCurrentVerId] = useState<string>('')
|
||||
const [selectedContent, setSelectedContent] = useState<string>('')
|
||||
const [modelName, setModelName] = useState<string>('')
|
||||
const [style, setStyle] = useState<string>('')
|
||||
const [createTime, setCreateTime] = useState<string>('')
|
||||
// 确保baseURL没有尾部斜杠
|
||||
const baseURL = (String(import.meta.env.VITE_API_BASE_URL || '').replace('/api','') || '').replace(/\/$/, '')
|
||||
const getCurrentTask = useTaskStore.getState().getCurrentTask
|
||||
const currentTask = useTaskStore(state => state.getCurrentTask())
|
||||
const taskStatus = currentTask?.status || 'PENDING'
|
||||
const retryTask = useTaskStore.getState().retryTask
|
||||
const isMultiVersion = Array.isArray(currentTask?.markdown)
|
||||
const [showTranscribe, setShowTranscribe] = useState(false)
|
||||
const [showChat, setShowChat] = useState<false | 'half' | 'full'>(false)
|
||||
const [viewMode, setViewMode] = useState<'map' | 'preview'>('preview')
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
|
||||
// 缓存 ReactMarkdown components,仅在 baseURL 变化时重建
|
||||
const markdownComponents = useMemo(() => createMarkdownComponents(baseURL), [baseURL])
|
||||
|
||||
// 多版本内容处理
|
||||
useEffect(() => {
|
||||
if (!currentTask) return
|
||||
|
||||
if (!isMultiVersion) {
|
||||
setCurrentVerId('') // 清空旧版本 ID
|
||||
setModelName(currentTask.formData.model_name)
|
||||
setStyle(currentTask.formData.style)
|
||||
setCreateTime(currentTask.createdAt)
|
||||
setSelectedContent(currentTask?.markdown)
|
||||
} else {
|
||||
const latestVersion = [...currentTask.markdown].sort(
|
||||
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
)[0]
|
||||
|
||||
if (latestVersion) {
|
||||
setCurrentVerId(latestVersion.ver_id)
|
||||
}
|
||||
}
|
||||
}, [currentTask?.id, taskStatus])
|
||||
useEffect(() => {
|
||||
if (!currentTask || !isMultiVersion) return
|
||||
|
||||
const currentVer = currentTask.markdown.find(v => v.ver_id === currentVerId)
|
||||
if (currentVer) {
|
||||
setModelName(currentVer.model_name)
|
||||
setStyle(currentVer.style)
|
||||
setCreateTime(currentVer.created_at || '')
|
||||
setSelectedContent(currentVer.content)
|
||||
}
|
||||
}, [currentVerId, currentTask?.id])
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content)
|
||||
await navigator.clipboard.writeText(selectedContent)
|
||||
setCopied(true)
|
||||
toast.success('已复制到剪贴板')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (e) {
|
||||
toast.error(`复制失败${e}`)
|
||||
toast.error('复制失败', e)
|
||||
toast.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
const alertButton = {
|
||||
id: 'alert',
|
||||
title: '测试警告',
|
||||
content: '⚠️',
|
||||
onClick: () => alert('你点击了自定义按钮!'),
|
||||
}
|
||||
const exportButton = {
|
||||
id: 'export',
|
||||
title: '导出思维导图',
|
||||
content: '⤓',
|
||||
onClick: () => {
|
||||
const svgEl = svgRef.current
|
||||
if (!svgEl) return
|
||||
// 同上面的序列化逻辑
|
||||
const serializer = new XMLSerializer()
|
||||
const source = serializer.serializeToString(svgEl)
|
||||
const blob = new Blob(['<?xml version="1.0" encoding="UTF-8"?>', source], {
|
||||
type: 'image/svg+xml;charset=utf-8',
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'mindmap.svg'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
},
|
||||
}
|
||||
const handleDownload = () => {
|
||||
const currentTask = getCurrentTask()
|
||||
const currentTaskName = currentTask?.audioMeta.title
|
||||
|
||||
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' })
|
||||
const task = getCurrentTask()
|
||||
const name = task?.audioMeta.title || 'note'
|
||||
const blob = new Blob([selectedContent], { type: 'text/markdown;charset=utf-8' })
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = `${currentTaskName}.md`
|
||||
link.download = `${name}.md`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col items-center justify-center space-y-4 text-neutral-500">
|
||||
@@ -66,30 +380,29 @@ const MarkdownViewer: FC<MarkdownViewerProps> = ({ content, status }) => {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else if (status === 'idle') {
|
||||
}
|
||||
|
||||
if (status === 'idle') {
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col items-center justify-center space-y-3 text-neutral-500">
|
||||
<Idle></Idle>
|
||||
|
||||
<Idle />
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-bold">输入视频链接并点击“生成笔记”</p>
|
||||
<p className="mt-2 text-xs text-neutral-500">支持哔哩哔哩、YouTube 等视频平台</p>
|
||||
<p className="text-lg font-bold">输入视频链接并点击"生成笔记"</p>
|
||||
<p className="mt-2 text-xs text-neutral-500">支持哔哩哔哩、YouTube 、抖音等视频平台</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else if (status === 'failed') {
|
||||
}
|
||||
|
||||
if (status === 'failed' && !isMultiVersion) {
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col items-center justify-center gap-4 space-y-3">
|
||||
<Error /> {/* 你可以换成 Failed 动画 */}
|
||||
<Error />
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-bold text-red-500">笔记生成失败</p>
|
||||
<p className="mt-2 mb-2 text-xs text-red-400">请检查后台或稍后再试</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
retryTask(currentTask.id)
|
||||
}}
|
||||
size="lg"
|
||||
>
|
||||
|
||||
<Button onClick={() => retryTask(currentTask.id)} size="lg">
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
@@ -98,100 +411,95 @@ const MarkdownViewer: FC<MarkdownViewerProps> = ({ content, status }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
{/* 顶部操作栏 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-xl font-semibold text-neutral-900">
|
||||
<FileText className="text-primary h-5 w-5" />
|
||||
笔记内容
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handleCopy} variant="outline" size="sm">
|
||||
<Copy className="mr-1 h-4 w-4" />
|
||||
{copied ? '已复制' : '复制'}
|
||||
</Button>
|
||||
<Button onClick={handleDownload} variant="outline" size="sm">
|
||||
<Download className="mr-1 h-4 w-4" />
|
||||
导出 Markdown
|
||||
</Button>
|
||||
<div className="flex h-screen w-full flex-col overflow-hidden">
|
||||
<MarkdownHeader
|
||||
currentTask={currentTask}
|
||||
isMultiVersion={isMultiVersion}
|
||||
currentVerId={currentVerId}
|
||||
setCurrentVerId={setCurrentVerId}
|
||||
modelName={modelName}
|
||||
style={style}
|
||||
noteStyles={noteStyles}
|
||||
onCopy={handleCopy}
|
||||
onDownload={handleDownload}
|
||||
createAt={createTime}
|
||||
showTranscribe={showTranscribe}
|
||||
setShowTranscribe={setShowTranscribe}
|
||||
showChat={showChat}
|
||||
setShowChat={setShowChat}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
/>
|
||||
|
||||
{viewMode === 'map' ? (
|
||||
<div className="flex w-full flex-1 overflow-hidden bg-white">
|
||||
<div className={'w-full'}>
|
||||
<MarkmapEditor
|
||||
value={selectedContent}
|
||||
onChange={() => {}}
|
||||
height="100%" // 根据需求可以设定百分比或固定高度
|
||||
title={currentTask?.audioMeta?.title || '思维导图'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 滚动容器 */}
|
||||
|
||||
<div className="overflow-y-auto">
|
||||
{(content && content != 'loading') || content != 'empty' ? (
|
||||
<div className="markdown-body flex-1 bg-white">
|
||||
{' '}
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const codeContent = String(children).replace(/\n$/, '')
|
||||
|
||||
if (!inline && match) {
|
||||
return (
|
||||
<div className="group relative">
|
||||
<SyntaxHighlighter
|
||||
style={codeStyle}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
{...props}
|
||||
>
|
||||
{codeContent}
|
||||
</SyntaxHighlighter>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(codeContent)
|
||||
toast.success('代码已复制')
|
||||
}}
|
||||
className="absolute top-2 right-2 hidden items-center gap-1 rounded border border-gray-300 bg-white/70 px-2 py-1 text-xs shadow-sm transition group-hover:flex hover:bg-white"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<code className="rounded bg-gray-100 px-1 py-0.5 text-sm" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-screen w-full items-center justify-center">
|
||||
<div className="w-[300px] flex-col justify-items-center">
|
||||
<div className="bg-primary-light mb-4 flex h-16 w-16 items-center justify-center rounded-full">
|
||||
<ArrowRight className="text-primary h-8 w-8" />
|
||||
) : (
|
||||
<div className="flex flex-1 overflow-hidden bg-white py-2">
|
||||
{selectedContent && selectedContent !== 'loading' && selectedContent !== 'empty' ? (
|
||||
<>
|
||||
{showChat === 'full' && currentTask ? (
|
||||
<div className="h-full w-full">
|
||||
<ChatPanel taskId={currentTask.id} mode="full" onModeChange={setShowChat} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ScrollArea className="min-w-0 flex-1">
|
||||
<div className="px-2">
|
||||
<VideoBanner
|
||||
audioMeta={currentTask?.audioMeta}
|
||||
videoUrl={currentTask?.formData?.video_url}
|
||||
/>
|
||||
</div>
|
||||
<div className={'markdown-body w-full px-2'}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{selectedContent.replace(/^>\s*来源链接:[^\n]*\n*/m, '')}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{showTranscribe && (
|
||||
<div className={'ml-2 w-2/4'}>
|
||||
<TranscriptViewer />
|
||||
</div>
|
||||
)}
|
||||
{/* 侧边问答模式:markdown + ChatPanel 各占一半 */}
|
||||
{showChat === 'half' && currentTask && (
|
||||
<div className="ml-2 h-full w-1/2 shrink-0">
|
||||
<ChatPanel taskId={currentTask.id} mode="half" onModeChange={setShowChat} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="w-[300px] flex-col justify-items-center">
|
||||
<div className="bg-primary-light mb-4 flex h-16 w-16 items-center justify-center rounded-full">
|
||||
<ArrowRight className="text-primary h-8 w-8" />
|
||||
</div>
|
||||
<p className="mb-2 text-neutral-600">输入视频链接并点击"生成笔记"按钮</p>
|
||||
<p className="text-xs text-neutral-500">支持哔哩哔哩、YouTube等视频网站</p>
|
||||
</div>
|
||||
<p className="mb-2 text-neutral-600">输入视频链接并点击"生成笔记"按钮</p>
|
||||
<p className="text-xs text-neutral-500">支持哔哩哔哩、YouTube等视频网站</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/*<div className="markdown-body flex-1 overflow-y-auto bg-white">*/}
|
||||
{/* {content ? (*/}
|
||||
{/* */}
|
||||
{/* ) : (*/}
|
||||
{/* <>*/}
|
||||
{/* <div className="w-16 h-16 bg-primary-light rounded-full flex items-center justify-center mb-4">*/}
|
||||
{/* <ArrowRight className="h-8 w-8 text-primary" />*/}
|
||||
{/* </div>*/}
|
||||
{/* <p className="text-neutral-600 mb-2">输入视频链接并点击"生成笔记"按钮</p>*/}
|
||||
{/* <p className="text-xs text-neutral-500">支持哔哩哔哩、YouTube、腾讯视频和爱奇艺</p>*/}
|
||||
{/* </>*/}
|
||||
{/* )}*/}
|
||||
{/*</div>*/}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
MarkdownViewer.displayName = 'MarkdownViewer'
|
||||
|
||||
export default MarkdownViewer
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Markmap } from 'markmap-view'
|
||||
import { transformer } from '@/lib/markmap.ts'
|
||||
import { Toolbar } from 'markmap-toolbar'
|
||||
import 'markmap-toolbar/dist/style.css'
|
||||
import JSZip from 'jszip'
|
||||
|
||||
export interface MarkmapEditorProps {
|
||||
/** 要渲染的 Markdown 文本 */
|
||||
value: string
|
||||
/** 内容变化时的回调 */
|
||||
onChange: (value: string) => void
|
||||
/** Toolbar 上要展示的 item id 列表,默认使用 Toolbar.defaultItems */
|
||||
toolbarItems?: string[]
|
||||
/** 自定义按钮列表,会依次注册 */
|
||||
customButtons?: any[]
|
||||
/** 容器 SVG 的高度,默认为 600px */
|
||||
height?: string
|
||||
/** 文档标题,用于导出HTML时的文件名 */
|
||||
title?: string
|
||||
}
|
||||
|
||||
export default function MarkmapEditor({
|
||||
value,
|
||||
onChange,
|
||||
toolbarItems,
|
||||
customButtons = [],
|
||||
height = '600px',
|
||||
title = 'mindmap',
|
||||
}: MarkmapEditorProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const mmRef = useRef<Markmap | undefined>()
|
||||
const toolbarRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 用于跟踪是否处于全屏状态
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
|
||||
// 监听全屏状态变化
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement)
|
||||
}
|
||||
document.addEventListener('fullscreenchange', handler)
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', handler)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 进入全屏
|
||||
const enterFullscreen = () => {
|
||||
const el = svgRef.current?.parentElement
|
||||
if (el && el.requestFullscreen) {
|
||||
el.requestFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
// 退出全屏
|
||||
const exitFullscreen = () => {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
// 导出HTML思维导图
|
||||
const exportHtml = () => {
|
||||
try {
|
||||
const { root } = transformer.transform(value)
|
||||
const data = JSON.stringify(root)
|
||||
|
||||
// 创建HTML内容
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${title || 'BiliNote思维导图'}</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
#mindmap {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/markmap-view@0.18.10"></script>
|
||||
</head>
|
||||
<body>
|
||||
<svg id="mindmap"></svg>
|
||||
<script>
|
||||
(async () => {
|
||||
const { markmap } = window;
|
||||
const { Markmap } = markmap;
|
||||
const mm = Markmap.create(document.getElementById('mindmap'));
|
||||
mm.setData(${data});
|
||||
mm.fit();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${title || 'mindmap'}.html`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('导出HTML失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出SVG思维导图(矢量图)
|
||||
const exportSvg = async () => {
|
||||
try {
|
||||
if (!svgRef.current || !mmRef.current) return;
|
||||
|
||||
const svgEl = svgRef.current;
|
||||
const mm = mmRef.current;
|
||||
|
||||
// 先调用fit()确保显示完整的思维导图内容
|
||||
await mm.fit();
|
||||
// 等待渲染完成
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// 克隆SVG以避免修改原始SVG
|
||||
const clonedSvg = svgEl.cloneNode(true) as SVGSVGElement;
|
||||
|
||||
// 获取SVG内容的实际边界框
|
||||
const gElement = svgEl.querySelector('g');
|
||||
if (gElement) {
|
||||
const bbox = gElement.getBBox();
|
||||
// 添加一些边距
|
||||
const padding = 50;
|
||||
const viewBoxX = bbox.x - padding;
|
||||
const viewBoxY = bbox.y - padding;
|
||||
const viewBoxWidth = bbox.width + padding * 2;
|
||||
const viewBoxHeight = bbox.height + padding * 2;
|
||||
|
||||
// 设置viewBox以确保SVG可以无限缩放
|
||||
clonedSvg.setAttribute('viewBox', `${viewBoxX} ${viewBoxY} ${viewBoxWidth} ${viewBoxHeight}`);
|
||||
// 移除固定尺寸,让SVG根据viewBox自适应
|
||||
clonedSvg.removeAttribute('width');
|
||||
clonedSvg.removeAttribute('height');
|
||||
// 设置默认尺寸为100%,可以在任何容器中自适应
|
||||
clonedSvg.setAttribute('width', '100%');
|
||||
clonedSvg.setAttribute('height', '100%');
|
||||
// 保持宽高比
|
||||
clonedSvg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||||
}
|
||||
|
||||
// 设置SVG的背景为白色
|
||||
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
||||
style.textContent = 'svg { background-color: white; }';
|
||||
clonedSvg.insertBefore(style, clonedSvg.firstChild);
|
||||
|
||||
// 添加白色背景矩形(确保背景在所有查看器中都是白色)
|
||||
const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
const viewBox = clonedSvg.getAttribute('viewBox')?.split(' ').map(Number) || [0, 0, 800, 600];
|
||||
bgRect.setAttribute('x', viewBox[0].toString());
|
||||
bgRect.setAttribute('y', viewBox[1].toString());
|
||||
bgRect.setAttribute('width', viewBox[2].toString());
|
||||
bgRect.setAttribute('height', viewBox[3].toString());
|
||||
bgRect.setAttribute('fill', 'white');
|
||||
// 插入到最前面作为背景
|
||||
const firstG = clonedSvg.querySelector('g');
|
||||
if (firstG) {
|
||||
clonedSvg.insertBefore(bgRect, firstG);
|
||||
} else {
|
||||
clonedSvg.insertBefore(bgRect, clonedSvg.firstChild);
|
||||
}
|
||||
|
||||
// 确保SVG有正确的命名空间
|
||||
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
clonedSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
|
||||
|
||||
// 序列化SVG
|
||||
const svgData = new XMLSerializer().serializeToString(clonedSvg);
|
||||
|
||||
// 创建下载
|
||||
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${title || 'mindmap'}.svg`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('导出SVG失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出XMind格式思维导图
|
||||
const exportXMind = async () => {
|
||||
try {
|
||||
const { root } = transformer.transform(value);
|
||||
|
||||
// 生成唯一ID
|
||||
const generateId = () => Math.random().toString(36).substring(2, 15);
|
||||
|
||||
// 解码HTML实体(如 实 -> 实,〹 -> 对应字符)
|
||||
const decodeHtmlEntities = (text: string): string => {
|
||||
if (!text) return text;
|
||||
|
||||
// 首先手动处理十六进制数字实体 &#xHHHH;
|
||||
let decoded = text.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => {
|
||||
return String.fromCodePoint(parseInt(hex, 16));
|
||||
});
|
||||
|
||||
// 处理十进制数字实体 &#DDDD;
|
||||
decoded = decoded.replace(/&#(\d+);/g, (_, dec) => {
|
||||
return String.fromCodePoint(parseInt(dec, 10));
|
||||
});
|
||||
|
||||
// 使用textarea处理命名实体(如 & < > 等)
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.innerHTML = decoded;
|
||||
return textarea.value;
|
||||
};
|
||||
|
||||
// 清理HTML标签,只保留纯文本
|
||||
const stripHtml = (html: string): string => {
|
||||
if (!html) return html;
|
||||
// 先解码HTML实体
|
||||
let text = decodeHtmlEntities(html);
|
||||
// 移除HTML标签
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = text;
|
||||
return div.textContent || div.innerText || text;
|
||||
};
|
||||
|
||||
// 将 markmap 节点转换为 XMind 节点格式
|
||||
const convertToXMindNode = (node: any, isRoot = false): any => {
|
||||
const rawTitle = node.content || node.payload?.content || '未命名';
|
||||
const xmindNode: any = {
|
||||
id: generateId(),
|
||||
class: isRoot ? 'topic' : 'topic',
|
||||
title: stripHtml(rawTitle),
|
||||
};
|
||||
|
||||
if (node.children && node.children.length > 0) {
|
||||
xmindNode.children = {
|
||||
attached: node.children.map((child: any) => convertToXMindNode(child, false))
|
||||
};
|
||||
}
|
||||
|
||||
return xmindNode;
|
||||
};
|
||||
|
||||
const rootTopic = convertToXMindNode(root, true);
|
||||
const sheetId = generateId();
|
||||
|
||||
// XMind content.json 结构
|
||||
const content = [{
|
||||
id: sheetId,
|
||||
class: 'sheet',
|
||||
title: stripHtml(title) || '思维导图',
|
||||
rootTopic: rootTopic,
|
||||
topicPositioning: 'fixed'
|
||||
}];
|
||||
|
||||
// XMind metadata.json
|
||||
const metadata = {
|
||||
creator: {
|
||||
name: 'BiliNote',
|
||||
version: '1.0.0'
|
||||
}
|
||||
};
|
||||
|
||||
// XMind manifest.json
|
||||
const manifest = {
|
||||
'file-entries': {
|
||||
'content.json': {},
|
||||
'metadata.json': {}
|
||||
}
|
||||
};
|
||||
|
||||
// 使用 JSZip 创建 .xmind 文件
|
||||
// 直接传入字符串,JSZip会自动处理UTF-8编码
|
||||
const zip = new JSZip();
|
||||
zip.file('content.json', JSON.stringify(content, null, 2));
|
||||
zip.file('metadata.json', JSON.stringify(metadata, null, 2));
|
||||
zip.file('manifest.json', JSON.stringify(manifest, null, 2));
|
||||
|
||||
// 生成 ZIP 并下载
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${title || 'mindmap'}.xmind`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('导出XMind失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出PNG思维导图
|
||||
const exportPng = async () => {
|
||||
try {
|
||||
if (!svgRef.current || !mmRef.current) return;
|
||||
|
||||
const svgEl = svgRef.current;
|
||||
const mm = mmRef.current;
|
||||
|
||||
// 先调用fit()确保显示完整的思维导图内容
|
||||
await mm.fit();
|
||||
// 等待渲染完成
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// 获取SVG实际尺寸
|
||||
const svgWidth = svgEl.width.baseVal.value || svgEl.clientWidth || 800;
|
||||
const svgHeight = svgEl.height.baseVal.value || svgEl.clientHeight || 600;
|
||||
|
||||
// 设置足够大的缩放比例以确保高清输出
|
||||
const scale = 3;
|
||||
|
||||
// 克隆SVG以避免修改原始SVG
|
||||
const clonedSvg = svgEl.cloneNode(true) as SVGSVGElement;
|
||||
|
||||
// 设置SVG的背景为白色
|
||||
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
||||
style.textContent = 'svg { background-color: white; }';
|
||||
clonedSvg.insertBefore(style, clonedSvg.firstChild);
|
||||
|
||||
// 确保SVG有正确的命名空间
|
||||
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
clonedSvg.setAttribute('width', svgWidth.toString());
|
||||
clonedSvg.setAttribute('height', svgHeight.toString());
|
||||
|
||||
// 将SVG转换为Data URI (避免使用Blob URL来解决跨域问题)
|
||||
const svgData = new XMLSerializer().serializeToString(clonedSvg);
|
||||
const svgBase64 = btoa(unescape(encodeURIComponent(svgData)));
|
||||
const dataUri = `data:image/svg+xml;base64,${svgBase64}`;
|
||||
|
||||
// 创建Canvas
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = svgWidth * scale;
|
||||
canvas.height = svgHeight * scale;
|
||||
|
||||
// 获取上下文并设置白色背景
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('无法获取Canvas上下文');
|
||||
}
|
||||
|
||||
// 设置白色背景
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 创建Image对象
|
||||
const img = new Image();
|
||||
|
||||
// 当图片加载完成后,在Canvas上绘制并导出
|
||||
img.onload = () => {
|
||||
try {
|
||||
// 应用缩放
|
||||
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
||||
|
||||
// 绘制SVG
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// 重置变换
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
|
||||
// 将Canvas转换为PNG Blob
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
// 创建下载链接
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${title || 'mindmap'}.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
console.error('无法创建Blob对象');
|
||||
}
|
||||
}, 'image/png');
|
||||
} catch (err) {
|
||||
console.error('Canvas处理失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 设置图片加载错误处理
|
||||
img.onerror = (error) => {
|
||||
console.error('导出PNG失败(图片加载错误):', error);
|
||||
};
|
||||
|
||||
// 开始加载SVG图像 (使用Data URI而不是Blob URL)
|
||||
img.src = dataUri;
|
||||
|
||||
} catch (error) {
|
||||
console.error('导出PNG失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化 Markmap 实例 + Toolbar
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || mmRef.current) return
|
||||
const mm = Markmap.create(svgRef.current)
|
||||
mmRef.current = mm
|
||||
|
||||
if (toolbarRef.current) {
|
||||
toolbarRef.current.innerHTML = ''
|
||||
const toolbar = new Toolbar()
|
||||
toolbar.attach(mm)
|
||||
customButtons.forEach(btn => toolbar.register(btn))
|
||||
toolbar.setItems(toolbarItems ?? Toolbar.defaultItems)
|
||||
toolbarRef.current.appendChild(toolbar.render())
|
||||
}
|
||||
}, [customButtons, toolbarItems])
|
||||
|
||||
// 当 value 变化时,重新渲染数据
|
||||
useEffect(() => {
|
||||
const mm = mmRef.current
|
||||
if (!mm) return
|
||||
const { root } = transformer.transform(value)
|
||||
mm.setData(root).then(() => mm.fit())
|
||||
}, [value])
|
||||
|
||||
// 文本输入变化回调(如果你自行添加 textarea 编辑区)
|
||||
// const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
// onChange(e.target.value)
|
||||
// }
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full flex-col bg-white">
|
||||
{/* 全屏/退出全屏 按钮 */}
|
||||
<div className="absolute top-2 right-2 z-20 flex space-x-2">
|
||||
<button
|
||||
onClick={exportXMind}
|
||||
className="rounded p-1 hover:bg-gray-200"
|
||||
title="导出XMind格式"
|
||||
>
|
||||
🧠
|
||||
</button>
|
||||
<button
|
||||
onClick={exportSvg}
|
||||
className="rounded p-1 hover:bg-gray-200"
|
||||
title="导出SVG矢量图(可无限放大)"
|
||||
>
|
||||
📐
|
||||
</button>
|
||||
<button
|
||||
onClick={exportPng}
|
||||
className="rounded p-1 hover:bg-gray-200"
|
||||
title="导出PNG图片"
|
||||
>
|
||||
🖼️
|
||||
</button>
|
||||
<button
|
||||
onClick={exportHtml}
|
||||
className="rounded p-1 hover:bg-gray-200"
|
||||
title="导出HTML(可交互)"
|
||||
>
|
||||
💾
|
||||
</button>
|
||||
{isFullscreen ? (
|
||||
<button
|
||||
onClick={exitFullscreen}
|
||||
className="rounded p-1 hover:bg-gray-200"
|
||||
title="退出全屏"
|
||||
>
|
||||
🗗
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={enterFullscreen} className="rounded p-1 hover:bg-gray-200" title="全屏">
|
||||
🗖
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 如果需要编辑区,就自己加一个 <textarea> 并把 handleChange 绑上 */}
|
||||
{/* <textarea value={value} onChange={handleChange} className="mb-2 p-2 border rounded" /> */}
|
||||
|
||||
{/* 思维导图区 */}
|
||||
<svg ref={svgRef} className="w-full flex-1" style={{ height, overflow: 'auto' }} />
|
||||
|
||||
{/* 如果你还想保留 markmap-toolbar */}
|
||||
{/* <div ref={toolbarRef} className="absolute right-2 bottom-2 z-10" /> */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
/* NoteForm.tsx ---------------------------------------------------- */
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -6,8 +7,26 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form.tsx'
|
||||
import { useEffect } from 'react'
|
||||
import { Input } from '@/components/ui/input.tsx'
|
||||
import { useEffect,useState } 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 { Alert, AlertDescription } from '@/components/ui/alert.tsx'
|
||||
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,
|
||||
@@ -15,273 +34,295 @@ import {
|
||||
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 { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Info, Clock, Loader2 } from 'lucide-react'
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip.tsx'
|
||||
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 { Input } from '@/components/ui/input.tsx'
|
||||
import { Textarea } from '@/components/ui/textarea.tsx'
|
||||
// ✅ 定义表单 schema
|
||||
const formSchema = z.object({
|
||||
video_url: z.string().url('请输入正确的视频链接'),
|
||||
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(),
|
||||
})
|
||||
import { noteStyles, noteFormats, videoPlatforms } from '@/constant/note.ts'
|
||||
import { fetchModels } from '@/services/model.ts'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
type NoteFormValues = z.infer<typeof formSchema>
|
||||
const noteFormats = [
|
||||
{
|
||||
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', // 适合商业报告、会议纪要,正式且精准
|
||||
},
|
||||
]
|
||||
/* -------------------- 校验 Schema -------------------- */
|
||||
const formSchema = z
|
||||
.object({
|
||||
video_url: z.string().optional(),
|
||||
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(6).optional(),
|
||||
grid_size: z
|
||||
.tuple([z.coerce.number().min(1).max(10), z.coerce.number().min(1).max(10)])
|
||||
.default([2, 2])
|
||||
.optional(),
|
||||
})
|
||||
.superRefine(({ video_url, platform }, ctx) => {
|
||||
if (platform === 'local') {
|
||||
if (!video_url) {
|
||||
ctx.addIssue({ code: 'custom', message: '本地视频路径不能为空', path: ['video_url'] })
|
||||
}
|
||||
}
|
||||
else {
|
||||
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'] })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export 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 = () => {
|
||||
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 navigate = useNavigate();
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [uploadSuccess, setUploadSuccess] = useState(false)
|
||||
/* ---- 全局状态 ---- */
|
||||
const { addPendingTask, currentTaskId, setCurrentTask, getCurrentTask, retryTask } =
|
||||
useTaskStore()
|
||||
const { loadEnabledModels, modelList, showFeatureHint, setShowFeatureHint } = useModelStore()
|
||||
|
||||
/* ---- 表单 ---- */
|
||||
const form = useForm<NoteFormValues>({
|
||||
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: 6,
|
||||
grid_size: [2, 2],
|
||||
format: [],
|
||||
},
|
||||
})
|
||||
const currentTask = getCurrentTask()
|
||||
|
||||
const onClose = () => {
|
||||
setShowFeatureHint(false)
|
||||
}
|
||||
const isGenerating = () => {
|
||||
console.log('🚀 isGenerating', getCurrentTask()?.status)
|
||||
return getCurrentTask()?.status === 'PENDING'
|
||||
}
|
||||
/* ---- 派生状态(只 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 onSubmit = async (data: NoteFormValues) => {
|
||||
console.log('🎯 提交内容:', data)
|
||||
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)
|
||||
}
|
||||
const goModelAdd = () => {
|
||||
navigate("/settings/model");
|
||||
};
|
||||
/* ---- 副作用 ---- */
|
||||
useEffect(() => {
|
||||
loadEnabledModels()
|
||||
|
||||
return
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (!currentTask) return
|
||||
const { formData } = currentTask
|
||||
|
||||
console.log('currentTask.formData.platform:', formData.platform)
|
||||
|
||||
form.reset({
|
||||
platform: formData.platform || 'bilibili',
|
||||
video_url: formData.video_url || '',
|
||||
model_name: formData.model_name || modelList[0]?.model_name || '',
|
||||
style: formData.style || 'minimal',
|
||||
quality: formData.quality || 'medium',
|
||||
extras: formData.extras || '',
|
||||
screenshot: formData.screenshot ?? false,
|
||||
link: formData.link ?? false,
|
||||
video_understanding: formData.video_understanding ?? false,
|
||||
video_interval: formData.video_interval ?? 6,
|
||||
grid_size: formData.grid_size ?? [2, 2],
|
||||
format: formData.format ?? [],
|
||||
})
|
||||
}, [
|
||||
// 当下面任意一个变了,就重新 reset
|
||||
currentTaskId,
|
||||
// modelList 用来兜底 model_name
|
||||
modelList.length,
|
||||
// 还要加上 formData 的各字段,或者直接 currentTask
|
||||
currentTask?.formData,
|
||||
])
|
||||
|
||||
/* ---- 帮助函数 ---- */
|
||||
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)
|
||||
setIsUploading(true)
|
||||
setUploadSuccess(false)
|
||||
|
||||
try {
|
||||
|
||||
const data = await uploadFile(formData)
|
||||
cb(data.url)
|
||||
setUploadSuccess(true)
|
||||
} catch (err) {
|
||||
console.error('上传失败:', err)
|
||||
// message.error('上传失败,请重试')
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async (values: NoteFormValues) => {
|
||||
console.log('Not even go here')
|
||||
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 onInvalid = (errors: FieldErrors<NoteFormValues>) => {
|
||||
console.warn('表单校验失败:', errors)
|
||||
// message.error('请完善所有必填项后再提交')
|
||||
}
|
||||
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="flex h-full flex-col">
|
||||
<div className="h-full w-full">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="my-3 flex items-center justify-between">
|
||||
<h2 className="block">视频链接</h2>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="hover:text-primary h-4 w-4 cursor-pointer text-neutral-400" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">输入视频链接,支持哔哩哔哩、YouTube等平台</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<form onSubmit={form.handleSubmit(onSubmit, onInvalid)} className="space-y-4">
|
||||
{/* 顶部按钮 */}
|
||||
<FormButton></FormButton>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{/* 平台选择 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="platform"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="选择平台" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="bilibili">哔哩哔哩</SelectItem>
|
||||
<SelectItem value="youtube">Youtube</SelectItem>
|
||||
{/*<SelectItem value="local">本地视频</SelectItem>*/}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* 视频链接 & 平台 */}
|
||||
<SectionHeader title="视频链接" tip="支持 B 站、YouTube 等平台" />
|
||||
<div className="flex gap-2">
|
||||
{/* 平台选择 */}
|
||||
|
||||
{/* 视频地址 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="video_url"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input placeholder="视频链接" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/*<p className="text-xs text-neutral-500">*/}
|
||||
{/* 支持哔哩哔哩视频链接,例如:*/}
|
||||
{/* https://www.bilibili.com/video/BV1vc25YQE9X/*/}
|
||||
{/*</p>*/}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="quality"
|
||||
name="platform"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="my-3 flex items-center justify-between">
|
||||
<h2 className="block">音频质量</h2>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="hover:text-primary h-4 w-4 cursor-pointer text-neutral-400" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-[200px] text-xs">质量越高,下载体积越大,速度越慢</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<Select
|
||||
disabled={!!editing}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="选择质量" />
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="fast">快速(压缩)</SelectItem>
|
||||
<SelectItem value="medium">中等(推荐)</SelectItem>
|
||||
<SelectItem value="slow">高质量(清晰)</SelectItem>
|
||||
{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>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/*<FormDescription className="text-xs text-neutral-500">*/}
|
||||
{/* 质量越高,下载体积越大,速度越慢*/}
|
||||
{/*</FormDescription>*/}
|
||||
<FormMessage />
|
||||
<FormMessage style={{ display: 'none' }} />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 链接输入 / 上传框 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model_name"
|
||||
name="video_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="my-3 flex items-center justify-between">
|
||||
<h2 className="block">模型选择</h2>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="hover:text-primary h-4 w-4 cursor-pointer text-neutral-400" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-[200px] text-xs">不同模型返回质量不同,可自行测试</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="选择配置好的模型" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{modelList.map(item => {
|
||||
return <SelectItem value={item.model_name}>{item.model_name}</SelectItem>
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/*<FormDescription className="text-xs text-neutral-500">*/}
|
||||
{/* 质量越高,下载体积越大,速度越慢*/}
|
||||
{/*</FormDescription>*/}
|
||||
<FormMessage />
|
||||
<FormItem className="flex-1">
|
||||
{platform === 'local' ? (
|
||||
<>
|
||||
<Input disabled={!!editing} placeholder="请输入本地视频路径" {...field} />
|
||||
</>
|
||||
) : (
|
||||
<Input disabled={!!editing} placeholder="请输入视频网站链接" {...field} />
|
||||
)}
|
||||
<FormMessage style={{ display: 'none' }} />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -289,128 +330,230 @@ const NoteForm = () => {
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="style"
|
||||
name="video_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="my-3 flex items-center justify-between">
|
||||
<h2 className="block">笔记风格</h2>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="hover:text-primary h-4 w-4 cursor-pointer text-neutral-400" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-[200px] text-xs">选择你希望生成的笔记风格</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="选择笔记风格" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{noteStyles.map(item => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<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()
|
||||
}}
|
||||
>
|
||||
{isUploading ? (
|
||||
<p className="text-center text-sm text-blue-500">上传中,请稍候…</p>
|
||||
) : uploadSuccess ? (
|
||||
<p className="text-center text-sm text-green-500">上传成功!</p>
|
||||
) : (
|
||||
<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">
|
||||
{/* 模型选择 */}
|
||||
{
|
||||
|
||||
modelList.length>0?( <FormField
|
||||
className="w-full"
|
||||
control={form.control}
|
||||
name="model_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<SectionHeader title="模型选择" tip="不同模型效果不同,建议自行测试" />
|
||||
<Select
|
||||
onOpenChange={()=>{
|
||||
loadEnabledModels()
|
||||
}}
|
||||
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>
|
||||
)}
|
||||
/>): (
|
||||
<FormItem>
|
||||
<SectionHeader title="模型选择" tip="不同模型效果不同,建议自行测试" />
|
||||
<Button type={'button'} variant={
|
||||
'outline'
|
||||
} onClick={()=>{goModelAdd()}}>请先添加模型</Button>
|
||||
<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 variant="warning" className="text-sm">
|
||||
<AlertDescription>
|
||||
<strong>提示:</strong>视频理解功能必须使用多模态模型。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
{/* 笔记格式 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="format"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="my-3 flex items-center justify-between">
|
||||
<h2 className="block">笔记格式</h2>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="hover:text-primary h-4 w-4 cursor-pointer text-neutral-400" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">选择要包含的笔记元素,比如时间戳、截图提示或总结</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<FormControl>
|
||||
<div className="flex flex-wrap space-x-1.5">
|
||||
{noteFormats.map(item => (
|
||||
<label key={item.value} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={field.value?.includes(item.value)}
|
||||
onCheckedChange={checked => {
|
||||
const currentValue = field.value ?? [] // ✨ 保底是数组
|
||||
if (checked) {
|
||||
field.onChange([...currentValue, item.value])
|
||||
} else {
|
||||
field.onChange(currentValue.filter(v => v !== item.value))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="extras"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="my-3 flex items-center justify-between">
|
||||
<h2 className="block">备注</h2>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="hover:text-primary h-4 w-4 cursor-pointer text-neutral-400" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">会把这段加入到Prompt最后 可自行测试</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Textarea placeholder={'笔记需要罗列出 xxx 关键点'} {...field} />
|
||||
|
||||
{/*<FormDescription className="text-xs text-neutral-500">*/}
|
||||
{/* 质量越高,下载体积越大,速度越慢*/}
|
||||
{/*</FormDescription>*/}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
<SectionHeader title="笔记格式" tip="选择要包含的笔记元素" />
|
||||
<CheckboxGroup
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
disabledMap={{
|
||||
link: platform === 'local',
|
||||
screenshot: !videoUnderstandingEnabled,
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className={'flex w-full items-center gap-2 py-1.5'}>
|
||||
{/* 提交按钮 */}
|
||||
<Button type="submit" className="bg-primary w-full" disabled={isGenerating()}>
|
||||
{isGenerating() && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{isGenerating() ? '正在生成…' : '生成笔记'}
|
||||
</Button>
|
||||
</div>
|
||||
{/* 备注 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="extras"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<SectionHeader title="备注" tip="可在 Prompt 结尾附加自定义说明" />
|
||||
<Textarea placeholder="笔记需要罗列出 xxx 关键点…" {...field} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{/* 添加一些额外的说明或功能介绍 */}
|
||||
|
||||
{/*<div className="bg-primary-light mt-6 rounded-lg p-4"></div>*/}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { useTaskStore } from '@/store/taskStore'
|
||||
import { FC } from 'react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
|
||||
import { Badge } from '@/components/ui/badge.tsx'
|
||||
import { cn } from '@/lib/utils.ts'
|
||||
import { Trash } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import PinyinMatch from 'pinyin-match'
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip.tsx'
|
||||
import LazyImage from "@/components/LazyImage.tsx";
|
||||
import {FC, useState, useEffect, useMemo} from 'react'
|
||||
|
||||
interface NoteHistoryProps {
|
||||
onSelect: (taskId: string) => void
|
||||
@@ -20,40 +24,90 @@ interface NoteHistoryProps {
|
||||
const NoteHistory: FC<NoteHistoryProps> = ({ onSelect, selectedId }) => {
|
||||
const tasks = useTaskStore(state => state.tasks)
|
||||
const removeTask = useTaskStore(state => state.removeTask)
|
||||
// 确保baseURL没有尾部斜杠
|
||||
const baseURL = (String(import.meta.env.VITE_API_BASE_URL || 'api')).replace(/\/$/, '')
|
||||
const [rawSearch, setRawSearch] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const fuse = useMemo(() => new Fuse(tasks, {
|
||||
keys: ['audioMeta.title'],
|
||||
threshold: 0.4 // 匹配精度(越低越严格)
|
||||
}), [tasks])
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (rawSearch === '') return
|
||||
setSearch(rawSearch)
|
||||
}, 300) // 300ms 防抖
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return () => clearTimeout(timer)
|
||||
}, [rawSearch])
|
||||
const filteredTasks = search.trim()
|
||||
? fuse.search(search).map(result => result.item)
|
||||
: tasks
|
||||
if (filteredTasks.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-neutral-200 bg-neutral-50 py-6 text-center">
|
||||
<p className="text-sm text-neutral-500">暂无历史记录</p>
|
||||
</div>
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索笔记标题..."
|
||||
className="w-full rounded border border-neutral-300 px-3 py-1 text-sm outline-none focus:border-primary"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-md border border-neutral-200 bg-neutral-50 py-6 text-center">
|
||||
<p className="text-sm text-neutral-500">暂无记录</p>
|
||||
</div>
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{tasks.map(task => (
|
||||
<div className="mb-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索笔记标题..."
|
||||
className="w-full rounded border border-neutral-300 px-3 py-1 text-sm outline-none focus:border-primary"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 overflow-hidden">
|
||||
{filteredTasks.map(task => (
|
||||
<div
|
||||
key={task.id}
|
||||
onClick={() => onSelect(task.id)}
|
||||
className={cn(
|
||||
'flex cursor-pointer flex-col rounded-md border border-neutral-200 p-3',
|
||||
selectedId === task.id && 'border-primary bg-primary-light'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
key={task.id}
|
||||
className={cn('flex items-center gap-4')}
|
||||
onClick={() => onSelect(task.id)}
|
||||
>
|
||||
{/* 封面图 */}
|
||||
<img
|
||||
src={
|
||||
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"
|
||||
/>
|
||||
{task.platform === 'local' ? (
|
||||
<img
|
||||
src={
|
||||
task.audioMeta.cover_url ? `${task.audioMeta.cover_url}` : '/placeholder.png'
|
||||
}
|
||||
alt="封面"
|
||||
className="h-10 w-12 rounded-md object-cover"
|
||||
/>
|
||||
) : (
|
||||
<LazyImage
|
||||
|
||||
src={
|
||||
task.audioMeta.cover_url
|
||||
? `${baseURL}/image_proxy?url=${encodeURIComponent(task.audioMeta.cover_url)}`
|
||||
: '/placeholder.png'
|
||||
}
|
||||
alt="封面"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 标题 + 状态 */}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import type { AudioMeta } from '@/store/taskStore'
|
||||
|
||||
interface VideoBannerProps {
|
||||
audioMeta?: AudioMeta
|
||||
videoUrl?: string
|
||||
}
|
||||
|
||||
/** 平台 label 映射 */
|
||||
const platformLabel: Record<string, string> = {
|
||||
bilibili: '哔哩哔哩',
|
||||
youtube: 'YouTube',
|
||||
douyin: '抖音',
|
||||
xiaohongshu: '小红书',
|
||||
}
|
||||
|
||||
export default function VideoBanner({ audioMeta, videoUrl }: VideoBannerProps) {
|
||||
if (!audioMeta) return null
|
||||
|
||||
const rawCover = audioMeta.cover_url
|
||||
// 通过后端代理加载封面,避免跨域/Referrer 限制
|
||||
const apiBase = String(import.meta.env.VITE_API_BASE_URL || 'api').replace(/\/$/, '')
|
||||
const coverUrl = rawCover
|
||||
? `${apiBase}/image_proxy?url=${encodeURIComponent(rawCover)}`
|
||||
: ''
|
||||
const title = audioMeta.title
|
||||
const uploader = audioMeta.raw_info?.uploader || ''
|
||||
const platform = platformLabel[audioMeta.platform] || audioMeta.platform || ''
|
||||
const originalUrl = videoUrl || audioMeta.raw_info?.webpage_url || ''
|
||||
|
||||
return (
|
||||
<div className="relative mb-4 overflow-hidden rounded-lg">
|
||||
{/* 模糊背景封面 */}
|
||||
<div className="absolute inset-0">
|
||||
{coverUrl ? (
|
||||
<img
|
||||
src={coverUrl}
|
||||
alt=""
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full object-cover blur-md brightness-[0.4] scale-110"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full bg-gradient-to-r from-blue-600 to-indigo-700" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 内容层 */}
|
||||
<div className="relative flex items-center gap-4 px-5 py-4">
|
||||
{/* 封面缩略图 */}
|
||||
{coverUrl && (
|
||||
<img
|
||||
src={coverUrl}
|
||||
alt={title}
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-16 w-28 shrink-0 rounded-md object-cover shadow-md"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 文字信息 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate text-base font-bold text-white" title={title}>
|
||||
{title}
|
||||
</h2>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-white/70">
|
||||
{uploader && <span>{uploader}</span>}
|
||||
{uploader && platform && <span className="text-white/40">·</span>}
|
||||
{platform && <span>{platform}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 跳转原视频 */}
|
||||
{originalUrl && (
|
||||
<a
|
||||
href={originalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-full bg-white/15 px-3 py-1.5 text-xs font-medium text-white backdrop-blur-sm transition-colors hover:bg-white/25"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
<span>原视频</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client"
|
||||
|
||||
import { useTaskStore } from "@/store/taskStore"
|
||||
import { useEffect, useState, useRef } from "react"
|
||||
import { Play } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {ScrollArea} from "@/components/ui/scroll-area.tsx";
|
||||
|
||||
interface Segment {
|
||||
start: number
|
||||
end: number
|
||||
text: string
|
||||
|
||||
}
|
||||
|
||||
interface Task {
|
||||
transcript?: {
|
||||
segments?: Segment[]
|
||||
}
|
||||
}
|
||||
|
||||
const TranscriptViewer = () => {
|
||||
const getCurrentTask = useTaskStore((state) => state.getCurrentTask)
|
||||
const currentTaskId = useTaskStore((state) => state.currentTaskId)
|
||||
const [task, setTask] = useState<Task | null>(null)
|
||||
const [activeSegment, setActiveSegment] = useState<number | null>(null)
|
||||
const segmentRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setTask(getCurrentTask())
|
||||
}, [currentTaskId, getCurrentTask])
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
const handleSegmentClick = (index: number) => {
|
||||
setActiveSegment(index)
|
||||
// Here you could add functionality to play the audio from this segment
|
||||
}
|
||||
|
||||
const scrollToSegment = (index: number) => {
|
||||
segmentRefs.current[index]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="transcript-viewer flex h-full w-full flex-col rounded-md border bg-white p-4 shadow-sm">
|
||||
<h2 className="mb-4 text-lg font-medium">转写结果</h2>
|
||||
{!task?.transcript?.segments?.length ? (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">暂无转写内容</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
|
||||
<div className="mb-3 grid grid-cols-[80px_1fr] gap-2 border-b pb-2 text-xs font-medium text-muted-foreground">
|
||||
<div>时间</div>
|
||||
<div>内容</div>
|
||||
</div>
|
||||
<ScrollArea className="w-full overflow-y-auto">
|
||||
|
||||
<div className="space-y-1">
|
||||
{task.transcript.segments.map((segment, index) => (
|
||||
<div
|
||||
key={index}
|
||||
ref={(el) => (segmentRefs.current[index] = el)}
|
||||
className={cn(
|
||||
"group grid grid-cols-[80px_1fr] gap-2 rounded-md p-2 transition-colors hover:bg-slate-50",
|
||||
activeSegment === index && "bg-slate-100",
|
||||
)}
|
||||
onClick={() => handleSegmentClick(index)}
|
||||
>
|
||||
<div className="flex items-center gap-1 text-xs text-slate-500">
|
||||
<button
|
||||
className="invisible rounded-full p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-700 group-hover:visible"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// Add play functionality here
|
||||
}}
|
||||
>
|
||||
{/*<Play className="h-3 w-3" />*/}
|
||||
</button>
|
||||
<span>{formatTime(segment.start)}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm leading-relaxed text-slate-700">
|
||||
{segment.speaker && (
|
||||
<span className="mr-2 rounded bg-slate-200 px-1.5 py-0.5 text-xs font-medium text-slate-700">
|
||||
{segment.speaker}
|
||||
</span>
|
||||
)}
|
||||
{segment.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
{task?.transcript?.segments?.length > 0 && (
|
||||
<div className="mt-4 flex justify-between border-t pt-3 text-xs text-slate-500">
|
||||
<span>共 {task.transcript.segments.length} 条片段</span>
|
||||
<span>总时长: {formatTime(task.transcript.segments[task.transcript.segments.length - 1]?.end || 0)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TranscriptViewer
|
||||
16
BillNote_frontend/src/pages/SettingPage/Downloader.tsx
Normal file
16
BillNote_frontend/src/pages/SettingPage/Downloader.tsx
Normal 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
|
||||
@@ -1,4 +1,10 @@
|
||||
import { BotMessageSquare, Captions, HardDriveDownload, Wrench } from 'lucide-react'
|
||||
import {
|
||||
BotMessageSquare,
|
||||
Captions,
|
||||
HardDriveDownload,
|
||||
Info,
|
||||
Activity,
|
||||
} from 'lucide-react'
|
||||
import MenuBar, { IMenuProps } from '@/pages/SettingPage/components/menuBar.tsx'
|
||||
|
||||
const Menu = () => {
|
||||
@@ -9,22 +15,38 @@ const Menu = () => {
|
||||
icon: <BotMessageSquare />,
|
||||
path: '/settings/model',
|
||||
},
|
||||
// TODO :下一版本升级优化
|
||||
// {
|
||||
// id: ' transcriber',
|
||||
// name: '音频转译配置',
|
||||
// icon: <Captions />,
|
||||
// path: '/settings/transcriber',
|
||||
// },
|
||||
// //下载配置
|
||||
// {
|
||||
// id: 'download',
|
||||
// name: '下载配置',
|
||||
// icon: <HardDriveDownload />,
|
||||
// path: '/settings/download',
|
||||
// },
|
||||
{
|
||||
id: 'transcriber',
|
||||
name: '音频转写配置',
|
||||
icon: <Captions />,
|
||||
path: '/settings/transcriber',
|
||||
},
|
||||
{
|
||||
id: 'download',
|
||||
name: '下载配置',
|
||||
icon: <HardDriveDownload />,
|
||||
path: '/settings/download',
|
||||
},
|
||||
// //其他配置
|
||||
// {
|
||||
// id: 'prompt',
|
||||
// name: '提示词设置',
|
||||
// icon: <SquareChevronRight />,
|
||||
// path: '/settings/prompt',
|
||||
// },
|
||||
{
|
||||
id: 'monitor',
|
||||
name: '部署监控',
|
||||
icon: <Activity />,
|
||||
path: '/settings/monitor',
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
name: '关于',
|
||||
icon: <Info />,
|
||||
path: '/settings/about',
|
||||
},
|
||||
// {
|
||||
// id: 'other',
|
||||
// name: '其他配置',
|
||||
// icon: <Wrench />,
|
||||
|
||||
241
BillNote_frontend/src/pages/SettingPage/Monitor.tsx
Normal file
241
BillNote_frontend/src/pages/SettingPage/Monitor.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Server,
|
||||
Cpu,
|
||||
AudioLines,
|
||||
Film,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { getDeployStatus, DeployStatus } from '@/services/system'
|
||||
|
||||
export default function Monitor() {
|
||||
const [status, setStatus] = useState<DeployStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await getDeployStatus()
|
||||
setStatus(data)
|
||||
setLastUpdated(new Date())
|
||||
} catch (err) {
|
||||
setError('无法连接到后端服务')
|
||||
setStatus(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
// 自动刷新(每 30 秒)
|
||||
const interval = setInterval(fetchStatus, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchStatus])
|
||||
|
||||
const StatusBadge = ({ ok, label }: { ok: boolean; label?: string }) => (
|
||||
<Badge
|
||||
variant={ok ? 'default' : 'destructive'}
|
||||
className={ok ? 'bg-green-500 hover:bg-green-600' : ''}
|
||||
>
|
||||
{ok ? (
|
||||
<><CheckCircle2 className="mr-1 h-3 w-3" />{label || '正常'}</>
|
||||
) : (
|
||||
<><XCircle className="mr-1 h-3 w-3" />{label || '异常'}</>
|
||||
)}
|
||||
</Badge>
|
||||
)
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full overflow-y-auto bg-white">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">部署监控</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
实时监控系统各组件运行状态
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{lastUpdated && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
最后更新: {lastUpdated.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchStatus}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 p-4 text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Cards */}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* Backend FastAPI */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-lg font-medium">
|
||||
<Server className="mr-2 inline h-5 w-5 text-blue-500" />
|
||||
后端 FastAPI
|
||||
</CardTitle>
|
||||
{status && <StatusBadge ok={status.backend.status === 'running'} label="运行中" />}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && !status ? (
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
加载中...
|
||||
</div>
|
||||
) : status ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">状态:</span>
|
||||
<span className={status.backend.status === 'running' ? 'font-medium text-green-600' : 'font-medium text-red-600'}>
|
||||
{status.backend.status === 'running' ? '运行中' : status.backend.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">端口:</span>
|
||||
<span className="font-mono">{status.backend.port}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* CUDA GPU */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-lg font-medium">
|
||||
<Cpu className="mr-2 inline h-5 w-5 text-green-500" />
|
||||
CUDA GPU
|
||||
</CardTitle>
|
||||
{status && <StatusBadge ok={status.cuda.available} label={status.cuda.available ? '已启用' : '未启用'} />}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && !status ? (
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
加载中...
|
||||
</div>
|
||||
) : status ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
{status.cuda.available ? (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">GPU:</span>
|
||||
<span className="font-medium">{status.cuda.gpu_name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">CUDA 版本:</span>
|
||||
<span className="font-mono">{status.cuda.version}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
CUDA 不可用,将使用 CPU 模式
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Whisper Model */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-lg font-medium">
|
||||
<AudioLines className="mr-2 inline h-5 w-5 text-purple-500" />
|
||||
Whisper 模型
|
||||
</CardTitle>
|
||||
{status && <StatusBadge ok={true} label="已配置" />}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && !status ? (
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
加载中...
|
||||
</div>
|
||||
) : status ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">模型大小:</span>
|
||||
<span className="font-medium">{status.whisper.model_size}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">转写引擎:</span>
|
||||
<span className="font-mono">{status.whisper.transcriber_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* FFmpeg */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-lg font-medium">
|
||||
<Film className="mr-2 inline h-5 w-5 text-orange-500" />
|
||||
FFmpeg
|
||||
</CardTitle>
|
||||
{status && <StatusBadge ok={status.ffmpeg.available} label={status.ffmpeg.available ? '可用' : '不可用'} />}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && !status ? (
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
加载中...
|
||||
</div>
|
||||
) : status ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">状态:</span>
|
||||
<span className={status.ffmpeg.available ? 'font-medium text-green-600' : 'font-medium text-red-600'}>
|
||||
{status.ffmpeg.available ? '已安装' : '未安装'}
|
||||
</span>
|
||||
</div>
|
||||
{!status.ffmpeg.available && (
|
||||
<div className="text-xs text-red-500">
|
||||
请安装 FFmpeg 并添加到系统 PATH
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="mt-8 text-center text-xs text-gray-400">
|
||||
状态每 30 秒自动刷新
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
4
BillNote_frontend/src/pages/SettingPage/Prompt.tsx
Normal file
4
BillNote_frontend/src/pages/SettingPage/Prompt.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
const Prompt = () => {
|
||||
return <div className={'flex h-full w-full bg-white'}>prompt</div>
|
||||
}
|
||||
export default Prompt
|
||||
227
BillNote_frontend/src/pages/SettingPage/about.tsx
Normal file
227
BillNote_frontend/src/pages/SettingPage/about.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Github, Star, ExternalLink, Download } from 'lucide-react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
|
||||
import logo from '@/assets/icon.svg'
|
||||
|
||||
export default function AboutPage() {
|
||||
const images = [
|
||||
'https://common-1304618721.cos.ap-chengdu.myqcloud.com/20250504102850.png',
|
||||
'https://common-1304618721.cos.ap-chengdu.myqcloud.com/20250504103028.png',
|
||||
'https://common-1304618721.cos.ap-chengdu.myqcloud.com/20250504103304.png',
|
||||
'https://common-1304618721.cos.ap-chengdu.myqcloud.com/20250504103625.png',
|
||||
]
|
||||
return (
|
||||
<ScrollArea className={'h-full overflow-y-auto bg-white'}>
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
{/* Hero Section */}
|
||||
<div className="mb-16 flex flex-col items-center justify-center text-center">
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
<img
|
||||
src={logo}
|
||||
alt="BiliNote Logo"
|
||||
width={50}
|
||||
height={50}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
<h1 className="text-4xl font-bold">BiliNote v2.0.0</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-6 text-xl italic">
|
||||
AI 视频笔记生成工具 让 AI 为你的视频做笔记
|
||||
</p>
|
||||
|
||||
<div className="mb-8 flex flex-wrap justify-center gap-2">
|
||||
<Badge variant="secondary">MIT License</Badge>
|
||||
<Badge variant="secondary">React</Badge>
|
||||
<Badge variant="secondary">FastAPI</Badge>
|
||||
<Badge variant="secondary">Docker Compose</Badge>
|
||||
<Badge variant="secondary">Active</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<Button asChild>
|
||||
<a href="https://www.bilinote.app" target="_blank">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
体验 BiliNote
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="https://github.com/JefferyHcool/BiliNote" target="_blank">
|
||||
<Github className="mr-2 h-4 w-4" />
|
||||
GitHub 仓库
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="https://github.com/JefferyHcool/BiliNote/releases" target="_blank">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
下载桌面版
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Introduction */}
|
||||
<section className="mb-16">
|
||||
<h2 className="mb-6 text-center text-3xl font-bold">✨ 项目简介</h2>
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<p className="text-lg">
|
||||
BiliNote 是一个开源的 AI 视频笔记助手,支持通过哔哩哔哩、YouTube、抖音等视频链接,
|
||||
自动提取内容并生成结构清晰、重点明确的 Markdown
|
||||
格式笔记。支持插入截图、原片跳转等功能。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="mb-16">
|
||||
<h2 className="mb-8 text-center text-3xl font-bold">🔧 功能特性</h2>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[
|
||||
{ title: '多平台支持', desc: '支持 Bilibili、YouTube、本地视频、抖音等多个平台' },
|
||||
{ title: '笔记格式选择', desc: '支持返回多种笔记格式,满足不同需求' },
|
||||
{ title: '笔记风格选择', desc: '支持多种笔记风格,个性化定制' },
|
||||
{ title: '多模态视频理解', desc: '结合视觉和音频内容,全面理解视频' },
|
||||
{ title: '自定义 GPT 配置', desc: '支持自行配置 GPT 大模型' },
|
||||
{ title: '本地音频转写', desc: '支持 Fast-Whisper 等本地模型音频转写' },
|
||||
{ title: '结构化笔记', desc: '自动生成结构化 Markdown 笔记' },
|
||||
{ title: '智能截图', desc: '可选插入自动截取的关键画面' },
|
||||
{ title: '内容跳转', desc: '支持关联原视频的内容跳转链接' },
|
||||
].map((feature, index) => (
|
||||
<Card key={index} className="h-full">
|
||||
<CardContent className="pt-2">
|
||||
<h3 className="mb-2 text-xl font-semibold">{feature.title}</h3>
|
||||
<p className="text-muted-foreground">{feature.desc}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Screenshots Section */}
|
||||
<section className="mb-16">
|
||||
<h2 className="mb-8 text-center text-3xl font-bold">📸 截图预览</h2>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{images.map(num => (
|
||||
<div key={num} className="overflow-hidden rounded-lg border shadow-sm">
|
||||
<img
|
||||
src={num}
|
||||
alt={`BiliNote Screenshot ${num}`}
|
||||
width={600}
|
||||
height={400}
|
||||
className="w-full object-cover transition-transform hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quick Start Section */}
|
||||
<section className="mb-16">
|
||||
<h2 className="mb-8 text-center text-3xl font-bold">🚀 快速开始</h2>
|
||||
<Tabs defaultValue="manual" className="mx-auto max-w-3xl">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="manual">手动安装</TabsTrigger>
|
||||
<TabsTrigger value="docker">Docker 部署</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="manual" className="mt-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-3 text-xl font-semibold">1. 克隆仓库</h3>
|
||||
<div className="bg-muted rounded-md p-4 font-mono text-sm">
|
||||
git clone https://github.com/JefferyHcool/BiliNote.git
|
||||
<br />
|
||||
cd BiliNote
|
||||
<br />
|
||||
mv .env.example .env
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-3 text-xl font-semibold">2. 启动后端(FastAPI)</h3>
|
||||
<div className="bg-muted rounded-md p-4 font-mono text-sm">
|
||||
cd backend
|
||||
<br />
|
||||
pip install -r requirements.txt
|
||||
<br />
|
||||
python main.py
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-3 text-xl font-semibold">3. 启动前端(Vite + React)</h3>
|
||||
<div className="bg-muted rounded-md p-4 font-mono text-sm">
|
||||
cd BiliNote_frontend
|
||||
<br />
|
||||
pnpm install
|
||||
<br />
|
||||
pnpm dev
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
访问:<code className="bg-muted rounded px-2 py-1">http://localhost:5173</code>
|
||||
</p>
|
||||
</TabsContent>
|
||||
<TabsContent value="docker" className="mt-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-3 text-xl font-semibold">1. 克隆仓库</h3>
|
||||
<div className="bg-muted rounded-md p-4 font-mono text-sm">
|
||||
git clone https://github.com/JefferyHcool/BiliNote.git
|
||||
<br />
|
||||
cd BiliNote
|
||||
<br />
|
||||
mv .env.example .env
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-3 text-xl font-semibold">2. 启动 Docker Compose</h3>
|
||||
<div className="bg-muted rounded-md p-4 font-mono text-sm">
|
||||
docker compose up --build
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
默认端口:
|
||||
<br />
|
||||
前端:http://localhost:${'{FRONTEND_PORT}'}
|
||||
<br />
|
||||
后端:http://localhost:${'{BACKEND_PORT}'}
|
||||
<br />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
.env 文件中可自定义端口与环境配置
|
||||
</span>
|
||||
</p>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
|
||||
{/* Community Section */}
|
||||
<section className="mb-16">
|
||||
<h2 className="mb-8 text-center text-3xl font-bold">联系和加入社区</h2>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="flex flex-col items-center justify-center gap-8">
|
||||
<div className="text-center">
|
||||
<h3 className="mb-3 text-xl font-semibold">BiliNote 交流 QQ 群</h3>
|
||||
<p className="text-lg font-medium">785367111</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="mb-3 text-xl font-semibold">BiliNote 交流微信群</h3>
|
||||
<div className="bg-muted mx-auto flex h-52 w-52 items-center justify-center rounded-md">
|
||||
<img src={'https://common-1304618721.cos.ap-chengdu.myqcloud.com/wechat.png'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* License Section */}
|
||||
<section className="mb-8 text-center">
|
||||
<h2 className="mb-4 text-3xl font-bold">📜 License</h2>
|
||||
<p>MIT License</p>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t pt-8 text-center">
|
||||
<p className="mb-4">💬 你的支持与反馈是我持续优化的动力!欢迎 PR、提 issue、Star ⭐️</p>
|
||||
</footer>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
@@ -13,10 +13,10 @@ interface IMenuItem {
|
||||
menuItem: IMenuProps
|
||||
}
|
||||
|
||||
const MenuBar: FC<IMenuItem> = ({ menuItem }) => {
|
||||
const MenuBar: ({ menuItem }: { menuItem: any }) => JSX.Element = ({ menuItem }) => {
|
||||
const location = useLocation()
|
||||
const isActive = location.pathname.startsWith(menuItem.path + '/')
|
||||
|| location.pathname === menuItem.path
|
||||
const isActive =
|
||||
location.pathname.startsWith(menuItem.path + '/') || location.pathname === menuItem.path
|
||||
|
||||
return (
|
||||
<Link to={menuItem.path} className="w-full">
|
||||
|
||||
@@ -1,8 +1,255 @@
|
||||
const Transcriber = () => {
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { AudioLines, AlertTriangle, CheckCircle2, Download, Loader2, Save, XCircle } from 'lucide-react'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import {
|
||||
getTranscriberConfig,
|
||||
updateTranscriberConfig,
|
||||
getModelsStatus,
|
||||
downloadModel,
|
||||
TranscriberConfig,
|
||||
ModelStatus,
|
||||
} from '@/services/transcriber'
|
||||
|
||||
const isWhisperType = (type: string) =>
|
||||
type === 'fast-whisper' || type === 'mlx-whisper'
|
||||
|
||||
export default function Transcriber() {
|
||||
const [config, setConfig] = useState<TranscriberConfig | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [selectedType, setSelectedType] = useState('')
|
||||
const [selectedModelSize, setSelectedModelSize] = useState('')
|
||||
const [modelStatuses, setModelStatuses] = useState<ModelStatus[]>([])
|
||||
const [mlxModelStatuses, setMlxModelStatuses] = useState<ModelStatus[]>([])
|
||||
const [mlxAvailable, setMlxAvailable] = useState(false)
|
||||
|
||||
const fetchModelsStatus = useCallback(async () => {
|
||||
try {
|
||||
const data = await getModelsStatus()
|
||||
setModelStatuses(data.whisper)
|
||||
setMlxModelStatuses(data.mlx_whisper)
|
||||
setMlxAvailable(data.mlx_available)
|
||||
} catch {
|
||||
// 静默失败,不阻塞主流程
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await getTranscriberConfig()
|
||||
setConfig(data)
|
||||
setSelectedType(data.transcriber_type)
|
||||
setSelectedModelSize(data.whisper_model_size)
|
||||
} catch {
|
||||
toast.error('获取转写器配置失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
fetchModelsStatus()
|
||||
}, [fetchModelsStatus])
|
||||
|
||||
// 有下载中的模型时自动轮询状态
|
||||
useEffect(() => {
|
||||
const hasDownloading =
|
||||
modelStatuses.some(m => m.downloading) || mlxModelStatuses.some(m => m.downloading)
|
||||
if (!hasDownloading) return
|
||||
|
||||
const timer = setInterval(fetchModelsStatus, 3000)
|
||||
return () => clearInterval(timer)
|
||||
}, [modelStatuses, mlxModelStatuses, fetchModelsStatus])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload: { transcriber_type: string; whisper_model_size?: string } = {
|
||||
transcriber_type: selectedType,
|
||||
}
|
||||
if (isWhisperType(selectedType)) {
|
||||
payload.whisper_model_size = selectedModelSize
|
||||
}
|
||||
await updateTranscriberConfig(payload)
|
||||
toast.success('转写器配置已保存')
|
||||
} catch {
|
||||
toast.error('保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = async (modelSize: string, transcriberType: string) => {
|
||||
try {
|
||||
await downloadModel({ model_size: modelSize, transcriber_type: transcriberType })
|
||||
toast.success(`模型 ${modelSize} 开始下载`)
|
||||
// 立即刷新状态
|
||||
setTimeout(fetchModelsStatus, 1000)
|
||||
} catch {
|
||||
toast.error('下载请求失败')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-neutral-400" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return <div className="p-6 text-center text-neutral-500">无法加载配置</div>
|
||||
}
|
||||
|
||||
const currentModels = selectedType === 'mlx-whisper' ? mlxModelStatuses : modelStatuses
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col items-center justify-center">
|
||||
<h1 className="text-center text-4xl font-bold">Transcriber is under development</h1>
|
||||
<div className="space-y-6 p-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">音频转写配置</h2>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
选择视频音频转写为文字所使用的引擎,保存后对新任务立即生效
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 转写引擎选择 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<AudioLines className="h-5 w-5" />
|
||||
转写引擎
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">转写器类型</label>
|
||||
<Select value={selectedType} onValueChange={setSelectedType}>
|
||||
<SelectTrigger className="w-full max-w-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{config.available_types.map(t => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isWhisperType(selectedType) && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Whisper 模型大小</label>
|
||||
<Select value={selectedModelSize} onValueChange={setSelectedModelSize}>
|
||||
<SelectTrigger className="w-full max-w-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{config.whisper_model_sizes.map(size => {
|
||||
const status = currentModels.find(m => m.model_size === size)
|
||||
return (
|
||||
<SelectItem key={size} value={size}>
|
||||
<span className="flex items-center gap-2">
|
||||
{size}
|
||||
{status?.downloaded && (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-neutral-400">
|
||||
模型越大精度越高,但速度更慢、占用更多显存
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedType === 'mlx-whisper' && !config.mlx_whisper_available && (
|
||||
<Alert variant="warning" className="text-sm">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
MLX Whisper 当前不可用。需要 macOS 平台并安装{' '}
|
||||
<code className="rounded bg-neutral-100 px-1">pip install mlx_whisper</code>,
|
||||
安装后重启后端生效。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSave} disabled={saving || (selectedType === 'mlx-whisper' && !config.mlx_whisper_available)} className="mt-2">
|
||||
{saving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
保存配置
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Whisper 模型管理 */}
|
||||
{isWhisperType(selectedType) && currentModels.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Download className="h-5 w-5" />
|
||||
模型管理
|
||||
<span className="text-sm font-normal text-neutral-400">
|
||||
{selectedType === 'mlx-whisper' ? 'MLX Whisper' : 'Faster Whisper'}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{currentModels.map(model => (
|
||||
<div
|
||||
key={model.model_size}
|
||||
className="flex items-center justify-between rounded-md border px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium">{model.model_size}</span>
|
||||
{model.downloaded ? (
|
||||
<Badge variant="default" className="bg-green-500 hover:bg-green-600">
|
||||
已下载
|
||||
</Badge>
|
||||
) : model.downloading ? (
|
||||
<Badge variant="secondary" className="flex items-center gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
下载中
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">未下载</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!model.downloaded && !model.downloading && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDownload(model.model_size, selectedType)}
|
||||
>
|
||||
<Download className="mr-1 h-4 w-4" />
|
||||
下载
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default Transcriber
|
||||
|
||||
44
BillNote_frontend/src/services/chat.ts
Normal file
44
BillNote_frontend/src/services/chat.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface ChatSource {
|
||||
text: string
|
||||
source_type: 'markdown' | 'transcript'
|
||||
section_title?: string
|
||||
start_time?: number
|
||||
end_time?: number
|
||||
}
|
||||
|
||||
export interface AskResponse {
|
||||
answer: string
|
||||
sources: ChatSource[]
|
||||
}
|
||||
|
||||
export type IndexStatus = 'idle' | 'indexing' | 'indexed' | 'failed'
|
||||
|
||||
export interface ChatStatusResponse {
|
||||
indexed: boolean
|
||||
status: IndexStatus
|
||||
}
|
||||
|
||||
export const indexTask = async (taskId: string): Promise<void> => {
|
||||
return await request.post('/chat/index', { task_id: taskId })
|
||||
}
|
||||
|
||||
export const askQuestion = async (data: {
|
||||
task_id: string
|
||||
question: string
|
||||
history: ChatMessage[]
|
||||
provider_id: string
|
||||
model_name: string
|
||||
}): Promise<AskResponse> => {
|
||||
return await request.post('/chat/ask', data, { timeout: 60000 })
|
||||
}
|
||||
|
||||
export const getChatStatus = async (taskId: string): Promise<ChatStatusResponse> => {
|
||||
return await request.get(`/chat/status?task_id=${taskId}`)
|
||||
}
|
||||
9
BillNote_frontend/src/services/downloader.ts
Normal file
9
BillNote_frontend/src/services/downloader.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import request from '@/utils/request.ts'
|
||||
|
||||
export const getDownloaderCookie = async id => {
|
||||
return await request.get('/get_downloader_cookie/' + id)
|
||||
}
|
||||
|
||||
export const updateDownloaderCookie = async (data: { cookie: string; platform: any }) => {
|
||||
return await request.post('/update_downloader_cookie', data)
|
||||
}
|
||||
@@ -18,10 +18,14 @@ export const testConnection = async (data: any) => {
|
||||
return await request.post('/connect_test', data)
|
||||
}
|
||||
|
||||
export const fetchModels = async (providerId: any) => {
|
||||
export const fetchModels = async (providerId: string) => {
|
||||
return await request.get('/model_list/' + providerId)
|
||||
}
|
||||
|
||||
export const fetchEnableModelById = async (id: string) => {
|
||||
return await request.get('/model_enable/' + id)
|
||||
}
|
||||
|
||||
export async function addModel(data: { provider_id: string; model_name: string }) {
|
||||
return request.post('/models', data)
|
||||
}
|
||||
@@ -29,3 +33,7 @@ export async function addModel(data: { provider_id: string; model_name: string }
|
||||
export const fetchEnableModels = async () => {
|
||||
return await request.get('/model_list')
|
||||
}
|
||||
|
||||
export const deleteModelById = async (modelId: number) => {
|
||||
return await request.get(`/models/delete/${modelId}`)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import request from '@/utils/request'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useTaskStore } from '@/store/taskStore'
|
||||
import request from '@/utils/request'
|
||||
|
||||
export const generateNote = async (data: {
|
||||
video_url: string
|
||||
platform: string
|
||||
@@ -12,11 +11,15 @@ export const generateNote = async (data: {
|
||||
format: Array<string>
|
||||
style: string
|
||||
extras?: string
|
||||
video_understand?: boolean
|
||||
video_interval?: number
|
||||
grid_size: Array<number>
|
||||
}) => {
|
||||
try {
|
||||
console.log('generateNote', data)
|
||||
const response = await request.post('/generate_note', data)
|
||||
|
||||
if (response.data.code != 0) {
|
||||
if (!response) {
|
||||
if (response.data.msg) {
|
||||
toast.error(response.data.msg)
|
||||
}
|
||||
@@ -27,12 +30,12 @@ export const generateNote = async (data: {
|
||||
console.log('res', response)
|
||||
// 成功提示
|
||||
|
||||
return response.data
|
||||
return response
|
||||
} catch (e: any) {
|
||||
console.error('❌ 请求出错', e)
|
||||
|
||||
// 错误提示
|
||||
toast.error('笔记生成失败,请稍后重试')
|
||||
// toast.error('笔记生成失败,请稍后重试')
|
||||
|
||||
throw e // 抛出错误以便调用方处理
|
||||
}
|
||||
@@ -46,13 +49,9 @@ export const delete_task = async ({ video_id, platform }) => {
|
||||
}
|
||||
const res = await request.post('/delete_task', data)
|
||||
|
||||
if (res.data.code === 0) {
|
||||
|
||||
toast.success('任务已成功删除')
|
||||
return res.data
|
||||
} else {
|
||||
toast.error(res.data.message || '删除失败')
|
||||
throw new Error(res.data.message || '删除失败')
|
||||
}
|
||||
return res
|
||||
} catch (e) {
|
||||
toast.error('请求异常,删除任务失败')
|
||||
console.error('❌ 删除任务失败:', e)
|
||||
@@ -62,15 +61,9 @@ export const delete_task = async ({ video_id, platform }) => {
|
||||
|
||||
export const get_task_status = async (task_id: string) => {
|
||||
try {
|
||||
const response = await request.get('/task_status/' + task_id)
|
||||
|
||||
if (response.data.code == 0 && response.data.status == 'SUCCESS') {
|
||||
// toast.success("笔记生成成功")
|
||||
}
|
||||
console.log('res', response)
|
||||
// 成功提示
|
||||
|
||||
return response.data
|
||||
return await request.get('/task_status/' + task_id)
|
||||
} catch (e) {
|
||||
console.error('❌ 请求出错', e)
|
||||
|
||||
|
||||
29
BillNote_frontend/src/services/system.ts
Normal file
29
BillNote_frontend/src/services/system.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export const systemCheck = async () => {
|
||||
return await request.get('/sys_health')
|
||||
}
|
||||
|
||||
export interface DeployStatus {
|
||||
backend: {
|
||||
status: string
|
||||
port: number
|
||||
}
|
||||
cuda: {
|
||||
available: boolean
|
||||
version: string | null
|
||||
gpu_name: string | null
|
||||
}
|
||||
whisper: {
|
||||
model_size: string
|
||||
transcriber_type: string
|
||||
}
|
||||
ffmpeg: {
|
||||
available: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export const getDeployStatus = async (): Promise<DeployStatus> => {
|
||||
return await request.get('/deploy_status')
|
||||
}
|
||||
|
||||
43
BillNote_frontend/src/services/transcriber.ts
Normal file
43
BillNote_frontend/src/services/transcriber.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface TranscriberConfig {
|
||||
transcriber_type: string
|
||||
whisper_model_size: string
|
||||
available_types: { value: string; label: string }[]
|
||||
whisper_model_sizes: string[]
|
||||
mlx_whisper_available: boolean
|
||||
}
|
||||
|
||||
export interface ModelStatus {
|
||||
model_size: string
|
||||
downloaded: boolean
|
||||
downloading: boolean
|
||||
}
|
||||
|
||||
export interface ModelsStatusResponse {
|
||||
whisper: ModelStatus[]
|
||||
mlx_whisper: ModelStatus[]
|
||||
mlx_available: boolean
|
||||
}
|
||||
|
||||
export const getTranscriberConfig = async (): Promise<TranscriberConfig> => {
|
||||
return await request.get('/transcriber_config')
|
||||
}
|
||||
|
||||
export const updateTranscriberConfig = async (data: {
|
||||
transcriber_type: string
|
||||
whisper_model_size?: string
|
||||
}) => {
|
||||
return await request.post('/transcriber_config', data)
|
||||
}
|
||||
|
||||
export const getModelsStatus = async (): Promise<ModelsStatusResponse> => {
|
||||
return await request.get('/transcriber_models_status')
|
||||
}
|
||||
|
||||
export const downloadModel = async (data: {
|
||||
model_size: string
|
||||
transcriber_type?: string
|
||||
}) => {
|
||||
return await request.post('/transcriber_download', data)
|
||||
}
|
||||
9
BillNote_frontend/src/services/upload.ts
Normal file
9
BillNote_frontend/src/services/upload.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import request from '@/utils/request' // 你项目里封装好的axios或者fetch
|
||||
|
||||
export const uploadFile = (formData: FormData) => {
|
||||
return request.post('/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
}
|
||||
43
BillNote_frontend/src/store/chatStore/index.ts
Normal file
43
BillNote_frontend/src/store/chatStore/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { ChatSource } from '@/services/chat'
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
sources?: ChatSource[]
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
chatHistory: Record<string, ChatMessage[]>
|
||||
addMessage: (taskId: string, msg: ChatMessage) => void
|
||||
clearChat: (taskId: string) => void
|
||||
getMessages: (taskId: string) => ChatMessage[]
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
chatHistory: {},
|
||||
|
||||
addMessage: (taskId, msg) =>
|
||||
set(state => ({
|
||||
chatHistory: {
|
||||
...state.chatHistory,
|
||||
[taskId]: [...(state.chatHistory[taskId] || []), msg],
|
||||
},
|
||||
})),
|
||||
|
||||
clearChat: (taskId) =>
|
||||
set(state => {
|
||||
const { [taskId]: _, ...rest } = state.chatHistory
|
||||
return { chatHistory: rest }
|
||||
}),
|
||||
|
||||
getMessages: (taskId) => get().chatHistory[taskId] || [],
|
||||
}),
|
||||
{
|
||||
name: 'bilinote-chat-storage',
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -1,6 +1,12 @@
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import { fetchModels, addModel, fetchEnableModels } from '@/services/model.ts'
|
||||
import {
|
||||
fetchModels,
|
||||
addModel,
|
||||
fetchEnableModels,
|
||||
fetchEnableModelById,
|
||||
deleteModelById
|
||||
} from '@/services/model'
|
||||
|
||||
interface IModel {
|
||||
id: string
|
||||
@@ -11,67 +17,93 @@ interface IModel {
|
||||
root: string
|
||||
}
|
||||
|
||||
interface IModelListItem {
|
||||
id: string
|
||||
provider_id: string
|
||||
model_name: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
interface ModelStore {
|
||||
models: IModel[]
|
||||
modelList: []
|
||||
modelList: IModelListItem[]
|
||||
loading: boolean
|
||||
selectedModel: string
|
||||
|
||||
loadModels: (providerId: string) => Promise<void>
|
||||
loadModelsById: (providerId: string) => Promise<IModelListItem[]>
|
||||
loadEnabledModels: () => Promise<void>
|
||||
addNewModel: (providerId: string, modelId: string) => Promise<void>
|
||||
deleteModel: (modelId: number) => Promise<void>
|
||||
setSelectedModel: (modelId: string) => void
|
||||
clearModels: () => void
|
||||
}
|
||||
|
||||
export const useModelStore = create<ModelStore>()(
|
||||
devtools(set => ({
|
||||
devtools((set) => ({
|
||||
models: [],
|
||||
modelList: [],
|
||||
loading: false,
|
||||
selectedModel: '',
|
||||
modelList: [],
|
||||
|
||||
// 获取所有可用模型 (全局可用模型列表)
|
||||
loadEnabledModels: async () => {
|
||||
try {
|
||||
set({ loading: true })
|
||||
const res = await fetchEnableModels()
|
||||
if (res.data.code === 0 && res.data.data.length > 0) {
|
||||
set({ modelList: res.data.data })
|
||||
} else {
|
||||
set({ modelList: [] })
|
||||
console.error('模型列表加载失败')
|
||||
}
|
||||
const list = await fetchEnableModels()
|
||||
set({ modelList: list })
|
||||
} catch (error) {
|
||||
set({ modelList: [] })
|
||||
console.error('加载模型出错', error)
|
||||
}
|
||||
},
|
||||
// 加载模型列表
|
||||
loadModels: async (providerId: string) => {
|
||||
try {
|
||||
set({ loading: true })
|
||||
const res = await fetchModels(providerId)
|
||||
if (res.data.code === 0 && res.data.data.models.data.length > 0) {
|
||||
set({ models: res.data.data.models.data })
|
||||
} else {
|
||||
set({ models: [] })
|
||||
console.error('模型列表加载失败')
|
||||
}
|
||||
} catch (error) {
|
||||
set({ models: [] })
|
||||
console.error('加载模型出错', error)
|
||||
console.error('加载可用模型失败', error)
|
||||
} finally {
|
||||
set({ loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
// 新增模型
|
||||
// 通过 provider 获取该供应商的模型列表
|
||||
loadModels: async (providerId: string) => {
|
||||
try {
|
||||
set({ loading: true })
|
||||
const res = await fetchModels(providerId)
|
||||
|
||||
let models: IModel[] = []
|
||||
|
||||
// 兼容 SyncPage 分页对象与普通数组两种格式
|
||||
if (Array.isArray(res.models)) {
|
||||
models = res.models
|
||||
} else if (res.models?.data && Array.isArray(res.models.data)) {
|
||||
models = res.models.data
|
||||
}
|
||||
|
||||
set({ models })
|
||||
} catch (error) {
|
||||
set({ models: [] })
|
||||
console.error('加载模型列表失败', error)
|
||||
} finally {
|
||||
set({ loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
// 单独获取某个供应商下已启用模型
|
||||
loadModelsById: async (providerId: string) => {
|
||||
try {
|
||||
const models = await fetchEnableModelById(providerId)
|
||||
console.log('获取供应商模型成功:', models)
|
||||
return models
|
||||
} catch (error) {
|
||||
console.error('加载供应商模型失败', error)
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
// 新增模型逻辑
|
||||
addNewModel: async (providerId: string, modelId: string) => {
|
||||
try {
|
||||
const res = await addModel({ provider_id: providerId, model_name: modelId })
|
||||
|
||||
if (res.code === 0) {
|
||||
console.log('新增模型成功:', modelId)
|
||||
// ✅ 新增成功以后,前端直接追加一条到 models 列表
|
||||
set(state => ({
|
||||
set((state) => ({
|
||||
models: [
|
||||
...state.models,
|
||||
{
|
||||
@@ -85,17 +117,30 @@ export const useModelStore = create<ModelStore>()(
|
||||
],
|
||||
}))
|
||||
} else {
|
||||
console.error('新增模型失败')
|
||||
console.error('新增模型失败', res.msg)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('添加模型出错', error)
|
||||
}
|
||||
},
|
||||
|
||||
// 设置选中的模型
|
||||
setSelectedModel: modelId => set({ selectedModel: modelId }),
|
||||
// 删除模型
|
||||
deleteModel: async (modelId: number) => {
|
||||
try {
|
||||
await deleteModelById(modelId)
|
||||
// 删除后更新本地状态(可选)
|
||||
set((state) => ({
|
||||
models: state.models.filter((model) => model.id !== modelId.toString())
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('删除模型失败', error)
|
||||
}
|
||||
},
|
||||
|
||||
// 清空
|
||||
clearModels: () => set({ models: [], selectedModel: '' }),
|
||||
// 切换选中模型
|
||||
setSelectedModel: (modelId: string) => set({ selectedModel: modelId }),
|
||||
|
||||
// 清空
|
||||
clearModels: () => set({ models: [], selectedModel: '', modelList: [] }),
|
||||
}))
|
||||
)
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand'
|
||||
import { IProvider } from '@/types'
|
||||
import { IProvider, IResponse } from '@/types'
|
||||
import {
|
||||
addProvider,
|
||||
getProviderById,
|
||||
@@ -38,10 +38,9 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
|
||||
// 设置整个 provider 列表
|
||||
setAllProviders: providers => set({ provider: providers }),
|
||||
loadProviderById: async (id: string) => {
|
||||
const res = await getProviderById(id)
|
||||
if (res.data.code === 0) {
|
||||
const item = res.data.data
|
||||
console.log('Provider ', item)
|
||||
const res:IResponse<IProvider> = await getProviderById(id)
|
||||
|
||||
const item = res
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
@@ -51,9 +50,7 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
|
||||
type: item.type,
|
||||
enabled: item.enabled,
|
||||
}
|
||||
} else {
|
||||
console.log('Provider not found')
|
||||
}
|
||||
|
||||
},
|
||||
addNewProvider: async (provider: IProvider) => {
|
||||
const payload = {
|
||||
@@ -66,7 +63,9 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
|
||||
if (res.data.code === 0) {
|
||||
const item = res.data.data
|
||||
console.log('Provider ', item)
|
||||
|
||||
await get().fetchProviderList()
|
||||
return item
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching provider:', error)
|
||||
@@ -76,34 +75,36 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
|
||||
getProviderById: id => get().provider.find(p => p.id === id),
|
||||
updateProvider: async (provider: IProvider) => {
|
||||
try {
|
||||
const existing = get().provider.find(p => p.id === provider.id)
|
||||
const merged = { ...existing, ...provider }
|
||||
|
||||
const data = {
|
||||
...provider,
|
||||
api_key: provider.apiKey,
|
||||
base_url: provider.baseUrl,
|
||||
}
|
||||
const res = await updateProviderById(data)
|
||||
if (res.data.code === 0) {
|
||||
const item = res.data.data
|
||||
console.log('Provider ', item)
|
||||
await get().fetchProviderList()
|
||||
...merged,
|
||||
api_key: merged.apiKey,
|
||||
base_url: merged.baseUrl,
|
||||
}
|
||||
// 拦截器已解包:成功时直接返回 data 部分
|
||||
await updateProviderById(data)
|
||||
await get().fetchProviderList()
|
||||
} catch (error) {
|
||||
console.error('Error fetching provider:', error)
|
||||
console.error('Error updating provider:', error)
|
||||
}
|
||||
},
|
||||
getProviderList: () => get().provider,
|
||||
fetchProviderList: async () => {
|
||||
try {
|
||||
const res = await getProviderList()
|
||||
if (res.data.code === 0) {
|
||||
const res = await getProviderList()
|
||||
|
||||
set({
|
||||
provider: res.data.data.map(
|
||||
provider: res.map(
|
||||
(item: {
|
||||
id: string
|
||||
name: string
|
||||
logo: string
|
||||
api_key: string
|
||||
base_url: string
|
||||
type: string
|
||||
enabled: number
|
||||
}) => {
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -117,7 +118,6 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
|
||||
}
|
||||
),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching provider list:', error)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import { persist, createJSONStorage } from 'zustand/middleware'
|
||||
import { delete_task, generateNote } from '@/services/note.ts'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import toast from 'react-hot-toast'
|
||||
import { get, set, del } from 'idb-keyval'
|
||||
|
||||
|
||||
export type TaskStatus = 'PENDING' | 'RUNNING' | 'SUCCESS' | 'FAILD'
|
||||
|
||||
@@ -26,10 +30,17 @@ export interface Transcript {
|
||||
raw: any
|
||||
segments: Segment[]
|
||||
}
|
||||
export interface Markdown {
|
||||
ver_id: string
|
||||
content: string
|
||||
style: string
|
||||
model_name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string
|
||||
markdown: string
|
||||
markdown: string|Markdown [] //为了兼容之前的笔记
|
||||
transcript: Transcript
|
||||
status: TaskStatus
|
||||
audioMeta: AudioMeta
|
||||
@@ -64,6 +75,7 @@ export const useTaskStore = create<TaskStore>()(
|
||||
currentTaskId: null,
|
||||
|
||||
addPendingTask: (taskId: string, platform: string, formData: any) =>
|
||||
|
||||
set(state => ({
|
||||
tasks: [
|
||||
{
|
||||
@@ -95,24 +107,87 @@ export const useTaskStore = create<TaskStore>()(
|
||||
})),
|
||||
|
||||
updateTaskContent: (id, data) =>
|
||||
set(state => ({
|
||||
tasks: state.tasks.map(task => (task.id === id ? { ...task, ...data } : task)),
|
||||
})),
|
||||
set(state => ({
|
||||
tasks: state.tasks.map(task => {
|
||||
if (task.id !== id) return task
|
||||
|
||||
if (task.status === 'SUCCESS' && data.status === 'SUCCESS') return task
|
||||
|
||||
// 如果是 markdown 字符串,封装为版本
|
||||
if (typeof data.markdown === 'string') {
|
||||
const prev = task.markdown
|
||||
const newVersion: Markdown = {
|
||||
ver_id: `${task.id}-${uuidv4()}`,
|
||||
content: data.markdown,
|
||||
style: task.formData.style || '',
|
||||
model_name: task.formData.model_name || '',
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
|
||||
let updatedMarkdown: Markdown[]
|
||||
if (Array.isArray(prev)) {
|
||||
updatedMarkdown = [newVersion, ...prev]
|
||||
} else {
|
||||
updatedMarkdown = [
|
||||
newVersion,
|
||||
...(typeof prev === 'string' && prev
|
||||
? [{
|
||||
ver_id: `${task.id}-${uuidv4()}`,
|
||||
content: prev,
|
||||
style: task.formData.style || '',
|
||||
model_name: task.formData.model_name || '',
|
||||
created_at: new Date().toISOString(),
|
||||
}]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
...task,
|
||||
...data,
|
||||
markdown: updatedMarkdown,
|
||||
}
|
||||
}
|
||||
|
||||
return { ...task, ...data }
|
||||
}),
|
||||
})),
|
||||
|
||||
|
||||
getCurrentTask: () => {
|
||||
const currentTaskId = get().currentTaskId
|
||||
return get().tasks.find(task => task.id === currentTaskId) || null
|
||||
},
|
||||
retryTask: async (id: string) => {
|
||||
const task = get().tasks.find(task => task.id === id).formData
|
||||
retryTask: async (id: string, payload?: any) => {
|
||||
|
||||
if (!id){
|
||||
toast.error('任务不存在')
|
||||
return
|
||||
}
|
||||
const task = get().tasks.find(task => task.id === id)
|
||||
console.log('retry',task)
|
||||
if (!task) return
|
||||
|
||||
const newFormData = payload || task.formData
|
||||
await generateNote({
|
||||
...newFormData,
|
||||
task_id: id,
|
||||
...task,
|
||||
})
|
||||
|
||||
set(state => ({
|
||||
tasks: state.tasks.map(task => (task.id === id ? { ...task, status: 'PENDING' } : task)),
|
||||
tasks: state.tasks.map(t =>
|
||||
t.id === id
|
||||
? {
|
||||
...t,
|
||||
formData: newFormData, // ✅ 显式更新 formData
|
||||
status: 'PENDING',
|
||||
}
|
||||
: t
|
||||
),
|
||||
}))
|
||||
},
|
||||
|
||||
|
||||
removeTask: async id => {
|
||||
const task = get().tasks.find(t => t.id === id)
|
||||
|
||||
@@ -137,6 +212,18 @@ export const useTaskStore = create<TaskStore>()(
|
||||
}),
|
||||
{
|
||||
name: 'task-storage',
|
||||
storage: createJSONStorage(() => ({
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
const value = await get(name)
|
||||
return value ?? null
|
||||
},
|
||||
setItem: async (name: string, value: string): Promise<void> => {
|
||||
await set(name, value)
|
||||
},
|
||||
removeItem: async (name: string): Promise<void> => {
|
||||
await del(name)
|
||||
},
|
||||
})),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
5
BillNote_frontend/src/types/index.d.ts
vendored
5
BillNote_frontend/src/types/index.d.ts
vendored
@@ -7,3 +7,8 @@ export interface IProvider {
|
||||
baseUrl: string
|
||||
enabled: number
|
||||
}
|
||||
export interface IResponse<T> {
|
||||
code: number
|
||||
data:T
|
||||
msg: string
|
||||
}
|
||||
@@ -1,8 +1,59 @@
|
||||
import axios from 'axios'
|
||||
import axios, { AxiosInstance, AxiosResponse } from 'axios';
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/api', // 默认请求路径前缀
|
||||
// 统一响应类型
|
||||
export interface IResponse<T = any> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// 模拟一个消息提示函数 (实际项目中会使用UI库的组件,如 Ant Design 的 message 或 Element UI 的 ElMessage)
|
||||
// This function simulates a message display (in real projects, you'd use a UI library's component)
|
||||
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
// 创建实例
|
||||
const request: AxiosInstance = axios.create({
|
||||
baseURL: baseURL || '/api',
|
||||
timeout: 10000,
|
||||
})
|
||||
});
|
||||
|
||||
// 响应拦截器
|
||||
request.interceptors.response.use(
|
||||
(response: AxiosResponse<IResponse>) => {
|
||||
const res = response.data;
|
||||
if (res.code === 0) {
|
||||
// 业务成功,可以根据需要显示成功消息,或者不显示(如果操作本身就是可见的)
|
||||
// showMessage('success', res.msg || '操作成功'); // 如果需要显示成功消息
|
||||
return res.data; // 返回data部分,简化后续业务代码
|
||||
} else {
|
||||
// 业务错误,统一显示后端返回的错误消息
|
||||
// Business error, uniformly display the error message returned from the backend
|
||||
toast.error(res.msg || '操作失败,请稍后再试');
|
||||
return Promise.reject(res); // 拒绝Promise,让业务代码可以捕获并处理
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
// 网络/服务器错误
|
||||
const res = error?.response?.data as IResponse | undefined;
|
||||
if (res) {
|
||||
// 如果后端有返回错误信息,则显示后端信息
|
||||
// If the backend returns an error message, display it
|
||||
|
||||
toast.error(res.msg || '服务器错误,请稍后再试');
|
||||
return Promise.reject(res);
|
||||
} else {
|
||||
// 没有响应数据(如网络中断),显示通用网络错误
|
||||
// No response data (e.g., network disconnected), display generic network error
|
||||
toast.error( '请求失败,请检查网络连接或稍后再试')
|
||||
return Promise.reject({
|
||||
code: -1,
|
||||
msg: '请求失败,请检查网络连接',
|
||||
data: null
|
||||
} as IResponse);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default request
|
||||
|
||||
@@ -1,30 +1,54 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd() + '/../')
|
||||
// 在 Docker 环境中,父目录可能没有 .env 文件,使用当前目录
|
||||
const envDir = process.env.DOCKER_BUILD ? __dirname : path.resolve(__dirname, '../')
|
||||
const env = loadEnv(mode, envDir)
|
||||
|
||||
const apiBaseUrl = env.VITE_API_BASE_URL
|
||||
const apiBaseUrl = env.VITE_API_BASE_URL || 'http://127.0.0.1:8483'
|
||||
const port = parseInt(env.VITE_FRONTEND_PORT || '3015', 10)
|
||||
|
||||
return {
|
||||
base: './',
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
markdown: ['react-markdown', 'react-syntax-highlighter', 'remark-gfm', 'remark-math', 'rehype-katex'],
|
||||
markmap: ['markmap-lib', 'markmap-view', 'markmap-toolbar', 'markmap-common'],
|
||||
vendor: ['react', 'react-dom', 'react-router-dom'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
port: port,
|
||||
allowedHosts: true, // 允许任意域名访问
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: apiBaseUrl,
|
||||
changeOrigin: true,
|
||||
rewrite: path => path.replace(/^\/api/, '/api'),
|
||||
},
|
||||
'/static': {
|
||||
target: apiBaseUrl,
|
||||
changeOrigin: true,
|
||||
rewrite: path => path.replace(/^\/static/, '/static'),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
74
CLAUDE.md
Normal file
74
CLAUDE.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
BiliNote is an AI video note generation tool. It extracts content from video links (Bilibili, YouTube, Douyin, Kuaishou, local files) and generates structured Markdown notes using LLM models. Full-stack app with a FastAPI backend, React frontend, and optional Tauri desktop packaging.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Backend (Python 3.11 + FastAPI)
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
python main.py # Starts on 0.0.0.0:8483
|
||||
```
|
||||
|
||||
### Frontend (React 19 + Vite + TypeScript)
|
||||
```bash
|
||||
cd BillNote_frontend
|
||||
pnpm install
|
||||
pnpm dev # Dev server on port 3015, proxies /api to backend
|
||||
pnpm build # Production build
|
||||
pnpm lint # ESLint
|
||||
```
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
docker-compose up # Web stack (backend + frontend + nginx)
|
||||
docker-compose -f docker-compose.gpu.yml up # GPU variant
|
||||
```
|
||||
|
||||
### Desktop (Tauri)
|
||||
```bash
|
||||
cd backend && ./build.sh # Build PyInstaller backend binary
|
||||
cd BillNote_frontend && pnpm tauri build
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
**Backend** (`backend/`) — FastAPI app, entry point `main.py`:
|
||||
- `app/routers/` — API routes: `note.py` (generation), `provider.py`, `model.py`, `config.py`
|
||||
- `app/services/` — Business logic: `note.py` (NoteGenerator orchestrates the full pipeline), `task_serial_executor.py` (task queue)
|
||||
- `app/downloaders/` — Platform adapters (bilibili, youtube, douyin, kuaishou, local) with shared `base.py` interface
|
||||
- `app/transcriber/` — Speech-to-text engines (fast-whisper, groq, bcut, kuaishou, mlx-whisper) with factory in `transcriber_provider.py`
|
||||
- `app/gpt/` — LLM integration with factory pattern (`gpt_factory.py`), prompt templates (`prompt.py`, `prompt_builder.py`), and `request_chunker.py` for long transcripts
|
||||
- `app/db/` — SQLite + SQLAlchemy: DAO pattern (`provider_dao.py`, `model_dao.py`, `video_task_dao.py`), models in `models/`
|
||||
- `app/utils/` — `response.py` (ResponseWrapper for consistent JSON), `video_helper.py` (screenshots via FFmpeg), `export.py` (PDF/DOCX)
|
||||
- `events/` (root level) — Blinker signal system for post-processing (e.g., temp file cleanup after transcription)
|
||||
|
||||
**Frontend** (`BillNote_frontend/src/`) — React 19 + Vite + Tailwind + shadcn/ui:
|
||||
- `pages/HomePage/` — Main note generation UI: `NoteForm.tsx` (input), `MarkdownViewer.tsx` (preview), `MarkmapComponent.tsx` (mind map)
|
||||
- `pages/SettingPage/` — LLM provider management, system monitoring, transcriber config
|
||||
- `store/` — Zustand stores: `taskStore`, `modelStore`, `configStore`, `providerStore`
|
||||
- `services/` — Axios API clients matching backend routes
|
||||
- `hooks/useTaskPolling.ts` — Polls task status every 3 seconds
|
||||
- `components/ui/` — shadcn/ui (Radix-based) components
|
||||
- Path alias: `@` → `./src`
|
||||
|
||||
**Core Workflow**: User submits URL → task queued → download video → extract audio (FFmpeg) → transcribe (Whisper/Groq/etc) → generate notes (LLM) → frontend polls for completion → display Markdown + mind map.
|
||||
|
||||
## Key Configuration
|
||||
|
||||
- **Ports**: Backend 8483, Frontend dev 3015, Docker maps 3015→80
|
||||
- **Environment**: Root `.env` (copy from `.env.example`). LLM API keys are configured through the UI, not env vars.
|
||||
- **Database**: SQLite at `backend/app/db/bili_note.db`, auto-initialized on first run
|
||||
- **FFmpeg**: Required system dependency for video/audio processing
|
||||
- **Vite proxy**: Dev server proxies `/api` and `/static` to backend (configured in `vite.config.ts`, reads env from parent dir)
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Frontend**: ESLint + Prettier (2 spaces, single quotes, 100 char width, Tailwind plugin). TypeScript strict mode.
|
||||
- **Backend**: Python with type hints. No configured linter. Uses Pydantic models for validation.
|
||||
- **Note**: The frontend directory is named `BillNote_frontend` (not "Bili").
|
||||
112
Dockerfile.complete
Normal file
112
Dockerfile.complete
Normal file
@@ -0,0 +1,112 @@
|
||||
# === 阶段1:构建 Backend ===
|
||||
FROM python:3.11-slim AS backend-builder
|
||||
|
||||
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
|
||||
ARG PIP_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
RUN set -ex && \
|
||||
rm -f /etc/apt/sources.list && \
|
||||
rm -rf /etc/apt/sources.list.d/* && \
|
||||
echo "deb http://${APT_MIRROR}/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
|
||||
echo "deb http://${APT_MIRROR}/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
echo "deb http://${APT_MIRROR}/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends ffmpeg && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/usr/bin:${PATH}"
|
||||
ENV HF_ENDPOINT=https://hf-mirror.com
|
||||
|
||||
WORKDIR /tmp/backend
|
||||
|
||||
# 先复制 requirements.txt 利用层缓存
|
||||
COPY ./backend/requirements.txt /tmp/backend/requirements.txt
|
||||
RUN pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt
|
||||
|
||||
COPY ./backend /tmp/backend
|
||||
|
||||
# === 阶段2:构建 Frontend ===
|
||||
FROM node:18-alpine AS frontend-builder
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
WORKDIR /tmp/frontend
|
||||
|
||||
# 先复制 package.json 利用依赖层缓存
|
||||
COPY ./BillNote_frontend/package.json ./
|
||||
RUN pnpm install
|
||||
|
||||
COPY ./BillNote_frontend /tmp/frontend
|
||||
|
||||
# 设置环境变量,告诉 vite.config.ts 这是 Docker 构建
|
||||
ENV DOCKER_BUILD=1
|
||||
RUN pnpm run build
|
||||
|
||||
# === 阶段3:完整应用镜像 ===
|
||||
FROM python:3.11-slim
|
||||
|
||||
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
|
||||
|
||||
# 安装必要的运行时依赖
|
||||
RUN set -ex && \
|
||||
rm -f /etc/apt/sources.list && \
|
||||
rm -rf /etc/apt/sources.list.d/* && \
|
||||
echo "deb http://${APT_MIRROR}/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
|
||||
echo "deb http://${APT_MIRROR}/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
echo "deb http://${APT_MIRROR}/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends ffmpeg nginx supervisor procps && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/usr/bin:${PATH}"
|
||||
ENV HF_ENDPOINT=https://hf-mirror.com
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# 复制 Python 依赖
|
||||
COPY --from=backend-builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
||||
COPY --from=backend-builder /usr/local/bin /usr/local/bin
|
||||
|
||||
# 复制 backend 代码
|
||||
COPY ./backend /app/backend
|
||||
WORKDIR /app/backend
|
||||
|
||||
# 复制前端静态文件到 nginx
|
||||
COPY --from=frontend-builder /tmp/frontend/dist /usr/share/nginx/html
|
||||
|
||||
# 配置 nginx
|
||||
RUN rm -rf /etc/nginx/conf.d/default.conf
|
||||
COPY ./nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# 创建 supervisor 配置
|
||||
RUN mkdir -p /var/log/supervisor
|
||||
COPY <<EOF /etc/supervisor/conf.d/supervisord.conf
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=root
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[program:nginx]
|
||||
command=nginx -g "daemon off;"
|
||||
stdout_logfile=/var/log/supervisor/nginx.log
|
||||
stderr_logfile=/var/log/supervisor/nginx.log
|
||||
autorestart=true
|
||||
priority=10
|
||||
|
||||
[program:backend]
|
||||
command=python main.py
|
||||
directory=/app/backend
|
||||
stdout_logfile=/var/log/supervisor/backend.log
|
||||
stderr_logfile=/var/log/supervisor/backend.log
|
||||
autorestart=true
|
||||
priority=20
|
||||
environment=BACKEND_PORT="8483",BACKEND_HOST="0.0.0.0"
|
||||
EOF
|
||||
|
||||
# 修改 nginx 配置以使用本地 backend
|
||||
RUN sed -i 's/proxy_pass http:\/\/backend:8483/proxy_pass http:\/\/127.0.0.1:8483/g' /etc/nginx/conf.d/default.conf && \
|
||||
sed -i 's/proxy_pass http:\/\/frontend:80/proxy_pass http:\/\/127.0.0.1:8080/g' /etc/nginx/conf.d/default.conf
|
||||
|
||||
# 启动 supervisor
|
||||
EXPOSE 80
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||
180
README.md
180
README.md
@@ -3,17 +3,17 @@
|
||||
<p align="center">
|
||||
<img src="./doc/icon.svg" alt="BiliNote Banner" width="50" height="50" />
|
||||
</p>
|
||||
<h1 align="center" > BiliNote v1.1.1</h1>
|
||||
<h1 align="center" > BiliNote v2.0.0</h1>
|
||||
</div>
|
||||
|
||||
<p align="center"><i>AI 视频笔记生成工具 让 AI 为你的视频做笔记</i></p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/license-MIT-blue.svg" />
|
||||
<img src="https://img.shields.io/badge/frontend-react-blue" />
|
||||
<img src="https://img.shields.io/badge/frontend-react%2019-blue" />
|
||||
<img src="https://img.shields.io/badge/backend-fastapi-green" />
|
||||
<img src="https://img.shields.io/badge/GPT-openai%20%7C%20deepseek%20%7C%20qwen-ff69b4" />
|
||||
<img src="https://img.shields.io/badge/docker-compose-blue" />
|
||||
<img src="https://img.shields.io/badge/docker-ghcr.io-blue" />
|
||||
<img src="https://img.shields.io/badge/status-active-success" />
|
||||
<img src="https://img.shields.io/github/stars/jefferyhcool/BiliNote?style=social" />
|
||||
</p>
|
||||
@@ -22,36 +22,86 @@
|
||||
|
||||
## ✨ 项目简介
|
||||
|
||||
BiliNote 是一个开源的 AI 视频笔记助手,支持通过哔哩哔哩、YouTube 等视频链接,自动提取内容并生成结构清晰、重点明确的 Markdown 格式笔记。支持插入截图、原片跳转等功能。
|
||||
BiliNote 是一个开源的 AI 视频笔记助手,支持通过哔哩哔哩、YouTube、抖音等视频链接,自动提取内容并生成结构清晰、重点明确的 Markdown 格式笔记。支持插入截图、原片跳转、AI 问答等功能。
|
||||
|
||||
## 🚀 体验地址
|
||||
[https://www.bilinote.app](https://www.bilinote.app) 维护中 暂不用
|
||||
注意:由于 项目部署在 Cloudflare Pages,访问速度可能存在一些问题,请耐心等待。
|
||||
## 📝 使用文档
|
||||
详细文档可以查看[这里](https://docs.bilinote.app/)
|
||||
|
||||
## 📦 Windows 打包版
|
||||
本项目提供了 Windows 系统的 exe 文件,可在[release](https://github.com/JefferyHcool/BiliNote/releases/tag/v1.0.1) 进行下载。**注意一定要在没有中文路径的环境下运行。**
|
||||
## 体验地址
|
||||
可以通过访问 [这里](https://www.bilinote.app/) 进行体验,速度略慢,不支持长视频。
|
||||
|
||||
## 📦 桌面版下载
|
||||
本项目提供了 Windows 和 macOS 桌面客户端,可在 [Releases](https://github.com/JefferyHcool/BiliNote/releases) 页面下载最新版本。
|
||||
|
||||
> Windows 用户请注意:一定要在没有中文路径的环境下运行。
|
||||
|
||||
## 🔧 功能特性
|
||||
|
||||
- 支持多平台:Bilibili、YouTube(后续会加入更多平台)
|
||||
- 本地模型音频转写(支持 Fast-Whisper)
|
||||
- GPT 大模型总结视频内容(支持 OpenAI、DeepSeek、Qwen)
|
||||
- 支持多平台:Bilibili、YouTube、本地视频、抖音、快手
|
||||
- 支持返回笔记格式选择
|
||||
- 支持笔记风格选择
|
||||
- 支持多模态视频理解
|
||||
- 支持多版本记录保留
|
||||
- 支持自行配置 GPT 大模型(OpenAI、DeepSeek、Qwen 等)
|
||||
- 本地模型音频转写(支持 Fast-Whisper、MLX-Whisper、Groq、BCut)
|
||||
- GPT 大模型总结视频内容
|
||||
- 自动生成结构化 Markdown 笔记
|
||||
- 可选插入截图(自动截取)
|
||||
- 可选内容跳转链接(关联原视频)
|
||||
- 任务记录与历史回看
|
||||
- 基于 RAG 的笔记内容 AI 问答(支持 Function Calling)
|
||||
- 笔记顶部视频封面 Banner 展示
|
||||
- 工作区和生成历史面板支持折叠/展开
|
||||
|
||||
### v2.0.0 新增
|
||||
|
||||
- 基于 RAG 的笔记内容 AI 问答功能,支持半屏/全屏模式
|
||||
- AI 问答支持 Function Calling,模型可主动查询原文数据
|
||||
- RAG 索引支持视频元信息(标题、作者、简介、标签等)
|
||||
- AI 回复支持 Markdown 渲染
|
||||
- 笔记顶部新增视频封面 Banner
|
||||
- 工作区和生成历史面板支持折叠/展开
|
||||
- 笔记开头添加来源链接功能
|
||||
- YouTube 字幕优先获取,有字幕时跳过音频下载
|
||||
- 性能优化与转写器配置改进
|
||||
|
||||
## 📸 截图预览
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 克隆仓库
|
||||
### 方式一:Docker 部署(推荐)
|
||||
|
||||
确保已安装 Docker,直接拉取预构建镜像运行:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/jefferyhcool/bilinote:latest
|
||||
|
||||
docker run -d -p 80:80 \
|
||||
-v bilinote-data:/app/backend/data \
|
||||
--name bilinote \
|
||||
ghcr.io/jefferyhcool/bilinote:latest
|
||||
```
|
||||
|
||||
访问:`http://localhost`
|
||||
|
||||
也可以使用 docker-compose 本地构建:
|
||||
|
||||
```bash
|
||||
# 标准部署
|
||||
docker-compose up -d
|
||||
|
||||
# GPU 加速部署(需要 NVIDIA GPU)
|
||||
docker-compose -f docker-compose.gpu.yml up -d
|
||||
```
|
||||
|
||||
### 方式二:源码部署
|
||||
|
||||
#### 1. 克隆仓库
|
||||
|
||||
```bash
|
||||
git clone https://github.com/JefferyHcool/BiliNote.git
|
||||
@@ -59,7 +109,7 @@ cd BiliNote
|
||||
mv .env.example .env
|
||||
```
|
||||
|
||||
### 2. 启动后端(FastAPI)
|
||||
#### 2. 启动后端(FastAPI)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
@@ -67,19 +117,20 @@ pip install -r requirements.txt
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 3. 启动前端(Vite + React)
|
||||
#### 3. 启动前端(Vite + React)
|
||||
|
||||
```bash
|
||||
cd BiliNote_frontend
|
||||
cd BillNote_frontend
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
访问:`http://localhost:5173`
|
||||
访问:`http://localhost:3015`
|
||||
|
||||
## ⚙️ 依赖说明
|
||||
|
||||
### 🎬 FFmpeg
|
||||
本项目依赖 ffmpeg 用于音频处理与转码,必须安装:
|
||||
本项目依赖 ffmpeg 用于音频处理与转码,源码部署时必须安装:
|
||||
```bash
|
||||
# Mac (brew)
|
||||
brew install ffmpeg
|
||||
@@ -91,6 +142,8 @@ sudo apt install ffmpeg
|
||||
# 请从官网下载安装:https://ffmpeg.org/download.html
|
||||
```
|
||||
> ⚠️ 若系统无法识别 ffmpeg,请将其加入系统环境变量 PATH
|
||||
>
|
||||
> Docker 部署已内置 FFmpeg,无需额外安装。
|
||||
|
||||
### 🚀 CUDA 加速(可选)
|
||||
若你希望更快地执行音频转写任务,可使用具备 NVIDIA GPU 的机器,并启用 fast-whisper + CUDA 加速版本:
|
||||
@@ -99,70 +152,48 @@ sudo apt install ffmpeg
|
||||
|
||||
### 🐳 使用 Docker 一键部署
|
||||
|
||||
确保你已安装 Docker 和 Docker Compose:
|
||||
确保你已安装 Docker,然后直接拉取预构建镜像运行:
|
||||
|
||||
#### 1. 克隆本项目
|
||||
```bash
|
||||
git clone https://github.com/JefferyHcool/BiliNote.git
|
||||
cd BiliNote
|
||||
mv .env.example .env
|
||||
# 拉取最新镜像
|
||||
docker pull ghcr.io/jefferyhcool/bilinote:latest
|
||||
|
||||
# 运行容器
|
||||
docker run -d -p 80:80 \
|
||||
-v bilinote-data:/app/backend/data \
|
||||
--name bilinote \
|
||||
ghcr.io/jefferyhcool/bilinote:latest
|
||||
```
|
||||
#### 2. 启动 Docker Compose
|
||||
``` bash
|
||||
docker compose up --build
|
||||
|
||||
访问:`http://localhost`
|
||||
|
||||
也可以使用 docker-compose 本地构建:
|
||||
|
||||
```bash
|
||||
# 标准部署
|
||||
docker-compose up -d
|
||||
|
||||
# GPU 加速部署(需要 NVIDIA GPU)
|
||||
docker-compose -f docker-compose.gpu.yml up -d
|
||||
```
|
||||
默认端口:
|
||||
|
||||
前端:http://localhost:${FRONTEND_PORT}
|
||||
|
||||
后端:http://localhost:${BACKEND_PORT}
|
||||
|
||||
.env 文件中可自定义端口与环境配置。
|
||||
|
||||
|
||||
## ⚙️ 环境变量配置
|
||||
> ⚠️ v.1.1.0 以后无需通过环境变量配置 AI
|
||||
|
||||
后端 `.env` 示例:
|
||||
|
||||
```ini
|
||||
API_BASE_URL=http://localhost:8000
|
||||
OUT_DIR=note_results
|
||||
IMAGE_BASE_URL=/static/screenshots
|
||||
MODEl_PROVIDER=openai
|
||||
OPENAI_API_KEY=sk-xxxxxx
|
||||
DEEP_SEEK_API_KEY=xxx
|
||||
QWEN_API_KEY=xxx
|
||||
```
|
||||
## Changelog
|
||||
### v1.1.0
|
||||
- #### Added
|
||||
- 新增 AI 笔记风格选择
|
||||
- 新增 AI 笔记返回格式选择
|
||||
- 添加 AI 自定义笔记备注 Prompt
|
||||
- 添加任务失败重试
|
||||
- 添加全局设置页,可在设置页进行模型设置
|
||||
|
||||
- #### Optimize
|
||||
- 优化前端样式,优化用户体验
|
||||
- 增加生成中间产物,可用于失败后加快生成速度
|
||||
- #### Fix
|
||||
- 修复视频截图视频过早删除错误
|
||||
|
||||
## 🧠 TODO
|
||||
|
||||
- [ ] 支持抖音及快手等视频平台
|
||||
- [x] 支持抖音及快手等视频平台
|
||||
- [x] 支持前端设置切换 AI 模型切换、语音转文字模型
|
||||
- [x] AI 摘要风格自定义(学术风、口语风、重点提取等)
|
||||
- [ ] 笔记导出为 PDF / Word / Notion
|
||||
- [x] 加入更多模型支持
|
||||
- [x] 加入更多音频转文本模型支持
|
||||
- [x] 基于 RAG 的笔记内容 AI 问答
|
||||
- [ ] 笔记导出为 PDF / Word / Notion
|
||||
|
||||
### Contact and Join-联系和加入社区
|
||||
- BiliNote 交流QQ群:785367111
|
||||
- BiliNote 交流微信群:
|
||||
|
||||
<img src="https://common-1304618721.cos.ap-chengdu.myqcloud.com/20250424091751.png" alt="wechat" style="zoom:33%;" />
|
||||
年会恢复更新以后放出最新社区地址
|
||||
|
||||
|
||||
|
||||
## 🔎代码参考
|
||||
- 本项目中的 `抖音下载功能` 部分代码参考引用自:[Evil0ctal/Douyin_TikTok_Download_API](https://github.com/Evil0ctal/Douyin_TikTok_Download_API)
|
||||
|
||||
## 📜 License
|
||||
|
||||
@@ -171,4 +202,13 @@ MIT License
|
||||
---
|
||||
|
||||
💬 你的支持与反馈是我持续优化的动力!欢迎 PR、提 issue、Star ⭐️
|
||||
## Buy Me a Coffee / 捐赠
|
||||
如果你觉得项目对你有帮助,考虑支持我一下吧
|
||||
<div style='display:inline;'>
|
||||
<img width='30%' src='https://common-1304618721.cos.ap-chengdu.myqcloud.com/8986c9eb29c356a0cfa3d470c23d3b6.jpg'/>
|
||||
<img width='30%' src='https://common-1304618721.cos.ap-chengdu.myqcloud.com/2a049ea298b206bcd0d8b8da3219d6b.jpg'/>
|
||||
</div>
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
[](https://www.star-history.com/#JefferyHcool/BiliNote&Date)
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
|
||||
ARG PIP_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
RUN rm -f /etc/apt/sources.list && \
|
||||
rm -rf /etc/apt/sources.list.d/* && \
|
||||
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
|
||||
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
echo "deb https://${APT_MIRROR}/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
|
||||
echo "deb https://${APT_MIRROR}/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
echo "deb https://${APT_MIRROR}/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y ffmpeg && \
|
||||
apt-get install -y --no-install-recommends ffmpeg curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 确保 PATH 中包含 ffmpeg 路径(可选)
|
||||
ENV PATH="/usr/bin:${PATH}"
|
||||
ENV HF_ENDPOINT=https://hf-mirror.com
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 先复制 requirements.txt 利用层缓存
|
||||
COPY ./backend/requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt
|
||||
|
||||
# 再复制应用代码(频繁变动不影响 pip 缓存层)
|
||||
COPY ./backend /app
|
||||
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
27
backend/Dockerfile.gpu
Normal file
27
backend/Dockerfile.gpu
Normal file
@@ -0,0 +1,27 @@
|
||||
FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04
|
||||
|
||||
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
|
||||
ARG PIP_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
RUN rm -f /etc/apt/sources.list && \
|
||||
rm -rf /etc/apt/sources.list.d/* && \
|
||||
echo "deb https://${APT_MIRROR}/ubuntu jammy main restricted universe multiverse" > /etc/apt/sources.list && \
|
||||
echo "deb https://${APT_MIRROR}/ubuntu jammy-updates main restricted universe multiverse" >> /etc/apt/sources.list && \
|
||||
echo "deb https://${APT_MIRROR}/ubuntu jammy-security main restricted universe multiverse" >> /etc/apt/sources.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends ffmpeg python3-pip curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV HF_ENDPOINT=https://hf-mirror.com
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 先复制 requirements.txt 利用层缓存
|
||||
COPY ./backend/requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -i ${PIP_INDEX} -r requirements.txt && \
|
||||
pip install --no-cache-dir -i ${PIP_INDEX} 'transformers[torch]>=4.23'
|
||||
|
||||
# 再复制应用代码
|
||||
COPY ./backend /app
|
||||
|
||||
CMD ["python3", "main.py"]
|
||||
@@ -1,10 +1,15 @@
|
||||
from fastapi import FastAPI
|
||||
from .routers import note, provider,model
|
||||
|
||||
from .routers import note, provider, model, config, chat
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="BiliNote")
|
||||
|
||||
def create_app(lifespan) -> FastAPI:
|
||||
app = FastAPI(title="BiliNote",lifespan=lifespan)
|
||||
app.include_router(note.router, prefix="/api")
|
||||
app.include_router(provider.router, prefix="/api")
|
||||
app.include_router(model.router,prefix="/api")
|
||||
app.include_router(config.router, prefix="/api")
|
||||
app.include_router(chat.router, prefix="/api")
|
||||
|
||||
return app
|
||||
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
@@ -21,15 +21,7 @@
|
||||
"type": "built-in",
|
||||
"logo": "Qwen",
|
||||
"api_key": "",
|
||||
"base_url": "https://qwen.aliyun.com/api"
|
||||
},
|
||||
{
|
||||
"id": "doubao",
|
||||
"name": "豆包 (Doubao)",
|
||||
"type": "built-in",
|
||||
"logo": "Doubao",
|
||||
"api_key": "",
|
||||
"base_url": "https://open.doubao.com/api"
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
},
|
||||
{
|
||||
"id": "Claude",
|
||||
@@ -38,5 +30,29 @@
|
||||
"logo": "Claude",
|
||||
"api_key": "",
|
||||
"base_url": "https://"
|
||||
},
|
||||
{
|
||||
"id": "gemini",
|
||||
"name": "Gemini",
|
||||
"type": "built-in",
|
||||
"logo": "Gemini",
|
||||
"api_key": "",
|
||||
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai/"
|
||||
},
|
||||
{
|
||||
"id": "groq",
|
||||
"name": "Groq",
|
||||
"type": "built-in",
|
||||
"logo": "Groq",
|
||||
"api_key": "",
|
||||
"base_url": "https://api.groq.com/openai/v1"
|
||||
},
|
||||
{
|
||||
"id": "ollama",
|
||||
"name": "ollama",
|
||||
"type": "built-in",
|
||||
"logo": "Ollama",
|
||||
"api_key": "",
|
||||
"base_url": "http://127.0.0.1:11434/v1"
|
||||
}
|
||||
]
|
||||
|
||||
45
backend/app/db/engine.py
Normal file
45
backend/app/db/engine.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# 默认 SQLite,如果想换 PostgreSQL 或 MySQL,可以直接改 .env
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///bili_note.db")
|
||||
|
||||
# SQLite 需要特定连接参数,其他数据库不需要
|
||||
engine_args = {}
|
||||
if DATABASE_URL.startswith("sqlite"):
|
||||
engine_args["connect_args"] = {"check_same_thread": False}
|
||||
|
||||
_pool_args = {}
|
||||
if not DATABASE_URL.startswith("sqlite"):
|
||||
_pool_args = {
|
||||
"pool_size": int(os.getenv("DB_POOL_SIZE", "10")),
|
||||
"max_overflow": int(os.getenv("DB_MAX_OVERFLOW", "20")),
|
||||
"pool_pre_ping": True,
|
||||
}
|
||||
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
echo=os.getenv("SQLALCHEMY_ECHO", "false").lower() == "true",
|
||||
**engine_args,
|
||||
**_pool_args,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_engine():
|
||||
return engine
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
9
backend/app/db/init_db.py
Normal file
9
backend/app/db/init_db.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from app.db.models.models import Model
|
||||
from app.db.models.providers import Provider
|
||||
from app.db.models.video_tasks import VideoTask
|
||||
from app.db.engine import get_engine, Base
|
||||
|
||||
def init_db():
|
||||
engine = get_engine()
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -1,58 +1,69 @@
|
||||
from app.db.sqlite_client import get_connection
|
||||
from app.db.engine import get_db
|
||||
from app.db.models.models import Model
|
||||
from app.db.models.providers import Provider
|
||||
|
||||
|
||||
def get_model_by_provider_and_name(provider_id: int, model_name: str):
|
||||
db = next(get_db())
|
||||
try:
|
||||
model = db.query(Model).filter_by(provider_id=provider_id, model_name=model_name).first()
|
||||
if model:
|
||||
return {
|
||||
"id": model.id,
|
||||
"provider_id": model.provider_id,
|
||||
"model_name": model.model_name,
|
||||
"created_at": model.created_at,
|
||||
}
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def init_model_table():
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS models (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider_id INTEGER NOT NULL,
|
||||
model_name TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# 插入模型
|
||||
def insert_model(provider_id: int, model_name: str):
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO models (provider_id, model_name)
|
||||
VALUES (?, ?)
|
||||
""", (provider_id, model_name))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
db = next(get_db())
|
||||
try:
|
||||
model = Model(provider_id=provider_id, model_name=model_name)
|
||||
db.add(model)
|
||||
db.commit()
|
||||
db.refresh(model)
|
||||
return {
|
||||
"id": model.id,
|
||||
"provider_id": model.provider_id,
|
||||
"model_name": model.model_name,
|
||||
"created_at": model.created_at,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# 根据provider查模型
|
||||
def get_models_by_provider(provider_id: int):
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT id, model_name FROM models
|
||||
WHERE provider_id = ?
|
||||
""", (provider_id,))
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
return [{"id": row[0], "model_name": row[1]} for row in rows]
|
||||
db = next(get_db())
|
||||
try:
|
||||
models = db.query(Model).filter_by(provider_id=provider_id).all()
|
||||
return [{"id": m.id, "model_name": m.model_name} for m in models]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# 删除某个模型
|
||||
def delete_model(model_id: int):
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
DELETE FROM models WHERE id = ?
|
||||
""", (model_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
db = next(get_db())
|
||||
try:
|
||||
model = db.query(Model).filter_by(id=model_id).first()
|
||||
if model:
|
||||
db.delete(model)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_all_models():
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT id, provider_id, model_name FROM models
|
||||
""")
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
return [{"id": row[0], "provider_id": row[1], "model_name": row[2]} for row in rows]
|
||||
db = next(get_db())
|
||||
try:
|
||||
# 只查询启用状态供应商的模型
|
||||
models = db.query(Model).join(Provider, Model.provider_id == Provider.id).filter(Provider.enabled == 1).all()
|
||||
return [
|
||||
{"id": m.id, "provider_id": m.provider_id, "model_name": m.model_name}
|
||||
for m in models
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
0
backend/app/db/models/__init__.py
Normal file
0
backend/app/db/models/__init__.py
Normal file
12
backend/app/db/models/models.py
Normal file
12
backend/app/db/models/models.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, func, ForeignKey
|
||||
|
||||
from app.db.engine import Base
|
||||
|
||||
|
||||
class Model(Base):
|
||||
__tablename__ = "models"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
provider_id = Column(Integer, nullable=False)
|
||||
model_name = Column(String, nullable=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
17
backend/app/db/models/providers.py
Normal file
17
backend/app/db/models/providers.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from sqlalchemy import Column, String, Integer, DateTime, func
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
from app.db.engine import Base
|
||||
|
||||
|
||||
class Provider(Base):
|
||||
__tablename__ = "providers"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
name = Column(String, nullable=False)
|
||||
logo = Column(String, nullable=False)
|
||||
type = Column(String, nullable=False)
|
||||
api_key = Column(String, nullable=False)
|
||||
base_url = Column(String, nullable=False)
|
||||
enabled = Column(Integer, default=1)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
14
backend/app/db/models/video_tasks.py
Normal file
14
backend/app/db/models/video_tasks.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, func
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
from app.db.engine import Base
|
||||
|
||||
|
||||
class VideoTask(Base):
|
||||
__tablename__ = "video_tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
video_id = Column(String, nullable=False)
|
||||
platform = Column(String, nullable=False)
|
||||
task_id = Column(String, unique=True, nullable=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
@@ -1,220 +1,129 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
from app.db.sqlite_client import get_connection
|
||||
import sys
|
||||
from app.db.models.providers import Provider
|
||||
from app.utils.logger import get_logger
|
||||
from app.db.engine import get_engine, Base, get_db
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def get_builtin_providers_path():
|
||||
if getattr(sys, 'frozen', False):
|
||||
base_path = sys._MEIPASS
|
||||
else:
|
||||
base_path = os.path.dirname(__file__)
|
||||
return os.path.join(base_path, 'builtin_providers.json')
|
||||
|
||||
|
||||
def seed_default_providers():
|
||||
conn = get_connection()
|
||||
if conn is None:
|
||||
logger.error("Failed to connect to database.")
|
||||
return
|
||||
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 检查已有数据
|
||||
cursor.execute("SELECT COUNT(*) FROM providers")
|
||||
count = cursor.fetchone()[0]
|
||||
if count > 0:
|
||||
logger.info("Providers already exist, skipping seed.")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
json_path = os.path.join(os.path.dirname(__file__), 'builtin_providers.json')
|
||||
db = next(get_db())
|
||||
try:
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
providers = json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read builtin_providers.json: {e}")
|
||||
conn.close()
|
||||
return
|
||||
if db.query(Provider).count() > 0:
|
||||
logger.info("Providers already exist, skipping seed.")
|
||||
return
|
||||
|
||||
json_path = get_builtin_providers_path()
|
||||
try:
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
providers = json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read builtin_providers.json: {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
for p in providers:
|
||||
cursor.execute("""
|
||||
INSERT INTO providers (id, name, api_key, base_url, logo, type, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
p['id'],
|
||||
p['name'],
|
||||
p['api_key'],
|
||||
p['base_url'],
|
||||
p['logo'],
|
||||
p['type'],
|
||||
p.get('enabled', 1)
|
||||
db.add(Provider(
|
||||
id=p['id'],
|
||||
name=p['name'],
|
||||
api_key=p['api_key'],
|
||||
base_url=p['base_url'],
|
||||
logo=p['logo'],
|
||||
type=p['type'],
|
||||
enabled=p.get('enabled', 1)
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
db.commit()
|
||||
logger.info("Default providers seeded successfully.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to seed default providers: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
def init_provider_table():
|
||||
conn = get_connection()
|
||||
if conn is None:
|
||||
logger.error("Failed to connect to the database.")
|
||||
return
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
logo TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
api_key TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
db.close()
|
||||
|
||||
|
||||
def insert_provider(id: str, name: str, api_key: str, base_url: str, logo: str, type_: str, enabled: int = 1):
|
||||
db = next(get_db())
|
||||
try:
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info("provider table created successfully.")
|
||||
seed_default_providers()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create provider table: {e}")
|
||||
def insert_provider(id: str, name: str, api_key: str, base_url: str, logo: str, type_: str,enabled:int=1):
|
||||
conn = get_connection()
|
||||
if conn is None:
|
||||
logger.error("Failed to connect to the database.")
|
||||
return
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO providers (id, name, api_key, base_url, logo, type, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (id, name, api_key, base_url, logo, type_, enabled))
|
||||
try:
|
||||
conn.commit()
|
||||
conn.close()
|
||||
provider = Provider(id=id, name=name, api_key=api_key, base_url=base_url, logo=logo, type=type_, enabled=enabled)
|
||||
db.add(provider)
|
||||
db.commit()
|
||||
logger.info(f"Provider inserted successfully. id: {id}, name: {name}, type: {type_}")
|
||||
return id
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to insert provider: {e}")
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_enabled_providers():
|
||||
conn = get_connection()
|
||||
if conn is None:
|
||||
logger.error("Failed to connect to the database.")
|
||||
return
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM providers WHERE enabled = 1")
|
||||
db = next(get_db())
|
||||
try:
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
if rows is None:
|
||||
logger.info("No providers found")
|
||||
return None
|
||||
logger.info(f"Providers found: {rows}")
|
||||
return rows
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get enabled providers: {e}")
|
||||
return db.query(Provider).filter_by(enabled=1).all()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_provider_by_name(name: str):
|
||||
conn = get_connection()
|
||||
if conn is None:
|
||||
logger.error("Failed to connect to the database.")
|
||||
return
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM providers WHERE name = ?", (name,))
|
||||
db = next(get_db())
|
||||
try:
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
if row is None:
|
||||
logger.info(f"Provider not found: {name}")
|
||||
return None
|
||||
logger.info(f"Provider found: {row}")
|
||||
return row
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get provider by name: {e}")
|
||||
return db.query(Provider).filter_by(name=name).first()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def get_provider_by_id(id: int):
|
||||
conn = get_connection()
|
||||
if conn is None:
|
||||
logger.error("Failed to connect to the database.")
|
||||
return
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM providers WHERE id = ?", (id,))
|
||||
|
||||
def get_provider_by_id(id: str):
|
||||
db = next(get_db())
|
||||
try:
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
if row is None:
|
||||
logger.info(f"Provider not found: {id}")
|
||||
return None
|
||||
logger.info(f"Provider found: {row}")
|
||||
return row
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get provider by id: {e}")
|
||||
return db.query(Provider).filter_by(id=id).first()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_all_providers():
|
||||
conn = get_connection()
|
||||
if conn is None:
|
||||
logger.error("Failed to connect to the database.")
|
||||
return
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM providers")
|
||||
db = next(get_db())
|
||||
try:
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
if rows is None:
|
||||
logger.info("No providers found")
|
||||
return None
|
||||
logger.info(f"Providers found: {rows}")
|
||||
return rows
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get all providers: {e}")
|
||||
return db.query(Provider).all()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def update_provider(id: str, **kwargs):
|
||||
conn = get_connection()
|
||||
if conn is None:
|
||||
logger.error("Failed to connect to the database.")
|
||||
return
|
||||
|
||||
fields = []
|
||||
values = []
|
||||
|
||||
for key, value in kwargs.items():
|
||||
fields.append(f"{key} = ?")
|
||||
values.append(value)
|
||||
|
||||
if not fields:
|
||||
logger.warning("No fields provided for update.")
|
||||
return
|
||||
|
||||
sql = f"""
|
||||
UPDATE providers
|
||||
SET {', '.join(fields)}
|
||||
WHERE id = ?
|
||||
"""
|
||||
|
||||
values.append(id) # id 最后加
|
||||
cursor = conn.cursor()
|
||||
|
||||
db = next(get_db())
|
||||
try:
|
||||
cursor.execute(sql, values)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"Provider updated successfully. id: {id}, updated_fields: {fields}")
|
||||
provider = db.query(Provider).filter_by(id=id).first()
|
||||
if not provider:
|
||||
logger.warning(f"Provider {id} not found for update.")
|
||||
return
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(provider, key):
|
||||
setattr(provider, key, value)
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Provider updated successfully. id: {id}, updated_fields: {list(kwargs.keys())}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update provider: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def delete_provider(id: int):
|
||||
conn = get_connection()
|
||||
if conn is None:
|
||||
logger.error("Failed to connect to the database.")
|
||||
return
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM providers WHERE id = ?", (id,))
|
||||
|
||||
def delete_provider(id: str):
|
||||
db = next(get_db())
|
||||
try:
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"Provider deleted successfully. id: {id}")
|
||||
provider = db.query(Provider).filter_by(id=id).first()
|
||||
if provider:
|
||||
db.delete(provider)
|
||||
db.commit()
|
||||
logger.info(f"Provider deleted successfully. id: {id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete provider: {e}")
|
||||
logger.error(f"Failed to delete provider: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
@@ -1,78 +1,61 @@
|
||||
from .sqlite_client import get_connection
|
||||
from app.db.models.video_tasks import VideoTask
|
||||
from app.db.engine import get_db
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
def init_video_task_table():
|
||||
conn = get_connection()
|
||||
if conn is None:
|
||||
logger.error("Failed to connect to the database.")
|
||||
return
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS video_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
video_id TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
try:
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info("video_tasks table created successfully.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create video_tasks table: {e}")
|
||||
|
||||
# 插入任务
|
||||
def insert_video_task(video_id: str, platform: str, task_id: str):
|
||||
db = next(get_db())
|
||||
try:
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO video_tasks (video_id, platform, task_id)
|
||||
VALUES (?, ?, ?)
|
||||
""", (video_id, platform, task_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"Video task inserted successfully."
|
||||
f"video_id: {video_id}"
|
||||
f"platform: {platform}"
|
||||
f"task_id: {task_id}")
|
||||
task = VideoTask(video_id=video_id, platform=platform, task_id=task_id)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
logger.info(f"Video task inserted successfully. video_id: {video_id}, platform: {platform}, task_id: {task_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to insert video task: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# 查询任务(最新一条)
|
||||
def get_task_by_video(video_id: str, platform: str):
|
||||
db = next(get_db())
|
||||
try:
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT task_id FROM video_tasks
|
||||
WHERE video_id = ? AND platform = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""", (video_id, platform))
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
if result is None:
|
||||
task = (
|
||||
db.query(VideoTask)
|
||||
.filter_by(video_id=video_id, platform=platform)
|
||||
.order_by(VideoTask.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if task:
|
||||
logger.info(f"Task found for video_id: {video_id} and platform: {platform}")
|
||||
return task.task_id
|
||||
else:
|
||||
logger.info(f"No task found for video_id: {video_id} and platform: {platform}")
|
||||
logger.info(f"Task found for video_id: {video_id} and platform: {platform}")
|
||||
return result[0] if result else None
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get task by video: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# 删除任务
|
||||
def delete_task_by_video(video_id: str, platform: str):
|
||||
db = next(get_db())
|
||||
try:
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
DELETE FROM video_tasks
|
||||
WHERE video_id = ? AND platform = ?
|
||||
""", (video_id, platform))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"Task deleted for video_id: {video_id} and platform: {platform}")
|
||||
tasks = (
|
||||
db.query(VideoTask)
|
||||
.filter_by(video_id=video_id, platform=platform)
|
||||
.all()
|
||||
)
|
||||
for task in tasks:
|
||||
db.delete(task)
|
||||
db.commit()
|
||||
logger.info(f"Task(s) deleted for video_id: {video_id} and platform: {platform}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete task by video: {e}")
|
||||
logger.error(f"Failed to delete task by video: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
@@ -8,6 +8,6 @@ def timeit(func):
|
||||
result = func(*args, **kwargs)
|
||||
end = time.perf_counter()
|
||||
duration = end - start
|
||||
print(f"⏱️ {func.__name__} executed in {duration:.4f} seconds")
|
||||
print(f"{func.__name__} executed in {duration:.4f} seconds")
|
||||
return result
|
||||
return wrapper
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user