mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-05-09 05:22:41 +08:00
Compare commits
222 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
89ceef60d0 | ||
|
|
84cd345b9f | ||
|
|
446e2a60c7 | ||
|
|
489fa78946 | ||
|
|
1291910961 | ||
|
|
c1237996e7 | ||
|
|
8ff074c0d9 | ||
|
|
f43c423a65 | ||
|
|
af18ba0250 | ||
|
|
6055118539 | ||
|
|
30da57ddab | ||
|
|
03300d86c3 | ||
|
|
171dea5e0d | ||
|
|
1323cfd1ec | ||
|
|
d519f284e9 | ||
|
|
7cfade6f78 | ||
|
|
d26655a0a2 | ||
|
|
eff6e7fe75 | ||
|
|
30e6a6ddd7 | ||
|
|
84da8ba7c2 | ||
|
|
4012a45c93 | ||
|
|
76ce0f58ef | ||
|
|
82e69734ee | ||
|
|
b17a6f39a5 | ||
|
|
842ae97883 | ||
|
|
bb974b0b89 | ||
|
|
7344d93053 | ||
|
|
a567788448 | ||
|
|
369de19572 | ||
|
|
0b7f6ca4ee | ||
|
|
2aad103a77 | ||
|
|
c21669d518 | ||
|
|
40450199f2 | ||
|
|
d7e800697b | ||
|
|
e845b3011b | ||
|
|
58f9a57886 |
321
.dockerignore
Normal file
321
.dockerignore
Normal file
@@ -0,0 +1,321 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
.DS_Store
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
BiliNote/pnpm-lock.yaml
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
.BiliNote-dev/*
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
!.env.example
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
.idea/
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
/backend/data/*
|
||||
/backend/static/*
|
||||
/backend/note_tasks.db
|
||||
/backend/bin/
|
||||
/backend/logs/
|
||||
/backend/note_results
|
||||
/backend/models
|
||||
/backend/.idea/*
|
||||
/backend/bili_note.db
|
||||
/backend/uploads/*
|
||||
/BiliNote_frontend/.idea/*
|
||||
34
.env.example
34
.env.example
@@ -1,30 +1,24 @@
|
||||
# 通用端口配置
|
||||
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:8483
|
||||
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
|
||||
|
||||
|
||||
# AI 相关配置
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_API_BASE_URL=
|
||||
OPENAI_MODEL=
|
||||
DEEP_SEEK_API_KEY=
|
||||
DEEP_SEEK_API_BASE_URL=
|
||||
DEEP_SEEK_MODEL=
|
||||
QWEN_API_KEY=
|
||||
QWEN_API_BASE_URL=
|
||||
QWEN_MODEL=
|
||||
|
||||
# FFMPEG 配置
|
||||
FFMPEG_BIN_PATH=
|
||||
FFMPEG_BIN_PATH=
|
||||
|
||||
# transcriber 相关配置
|
||||
TRANSCRIBER_TYPE=fast-whisper # fast-whisper/bcut/kuaishou/mlx-whisper(仅Apple平台)/groq
|
||||
WHISPER_MODEL_SIZE=base
|
||||
|
||||
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: ''
|
||||
|
||||
---
|
||||
|
||||
**这个功能请求是否与某个问题相关?请描述**
|
||||
清晰简要地描述问题是什么。例如:每次遇到 [...] 都让我感到很沮丧。
|
||||
|
||||
**描述你希望实现的解决方案**
|
||||
清晰简要地描述你希望发生的事情。
|
||||
|
||||
**描述你考虑过的备选方案**
|
||||
清晰简要地描述你考虑过的其他解决方案或功能。
|
||||
|
||||
**其他补充信息**
|
||||
请在此添加关于功能请求的其他上下文或截图。
|
||||
67
.github/workflows/main.yml
vendored
Normal file
67
.github/workflows/main.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
# .github/workflows/release.yml
|
||||
name: Build Desktop App (Python Backend + Tauri Frontend)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*' # 发布 tag 时触发
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [macos-latest, windows-latest]
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# 设置 Python 环境
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
# 安装 Python 依赖并执行你的 build.sh
|
||||
- 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
|
||||
|
||||
# 设置 Node 环境 + 安装前端依赖
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Enable Corepack + Install pnpm
|
||||
working-directory: BillNote_frontend
|
||||
run: |
|
||||
corepack enable
|
||||
pnpm install
|
||||
|
||||
# 设置 Rust 环境
|
||||
- name: Set up Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
# 打包 Tauri 应用
|
||||
- name: Build Tauri App
|
||||
working-directory: BillNote_frontend
|
||||
run: pnpm tauri build
|
||||
|
||||
# 可选:上传构建产物
|
||||
- name: Upload Desktop Bundle
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: app-${{ matrix.platform }}
|
||||
path: BillNote_frontend/src-tauri/target/release/bundle/
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -190,7 +190,7 @@ cover/
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
.idea/
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
@@ -315,4 +315,10 @@ cython_debug/
|
||||
/backend/logs/
|
||||
/backend/note_results
|
||||
/backend/models
|
||||
/backend/.idea
|
||||
/backend/.idea/*
|
||||
/backend/bili_note.db
|
||||
/backend/uploads/*
|
||||
/backend/.idea/*
|
||||
/backend/config/*
|
||||
/BiliNote_frontend/.idea/*
|
||||
/BiliNote_frontend/src-tauri/bin/
|
||||
@@ -1,3 +0,0 @@
|
||||
# 前端专用
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8000
|
||||
VITE_SCREENSHOT_BASE_URL=http://127.0.0.1:8000/static/screenshots
|
||||
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
|
||||
1
BillNote_frontend/.gitignore
vendored
1
BillNote_frontend/.gitignore
vendored
@@ -23,3 +23,4 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
/pnpm-lock.yaml
|
||||
/src-tauri/bin/
|
||||
|
||||
8
BillNote_frontend/.prettierignore
Normal file
8
BillNote_frontend/.prettierignore
Normal file
@@ -0,0 +1,8 @@
|
||||
dist
|
||||
build
|
||||
node_modules
|
||||
*.svg
|
||||
*.lock
|
||||
*.png
|
||||
public
|
||||
coverage
|
||||
11
BillNote_frontend/.prettierrc
Normal file
11
BillNote_frontend/.prettierrc
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf",
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
# === 前端构建阶段 ===
|
||||
FROM node:18-alpine AS build
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
# 安装 pnpm
|
||||
RUN npm install -g pnpm
|
||||
@@ -13,20 +13,13 @@ COPY ./BillNote_frontend /app
|
||||
# 安装依赖并构建
|
||||
RUN pnpm install && pnpm run build
|
||||
|
||||
# === nginx 运行阶段 ===
|
||||
FROM nginx:alpine
|
||||
# --- 阶段2:使用 nginx 作为静态服务器 ---
|
||||
FROM nginx:1.25-alpine
|
||||
|
||||
# 删除默认配置(可选)
|
||||
RUN rm -rf /etc/nginx/conf.d/default.conf
|
||||
COPY ./BillNote_frontend/deploy/default.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# 拷贝模板配置
|
||||
COPY ./BillNote_frontend/deploy/default.conf.template /etc/nginx/templates/default.conf.template
|
||||
|
||||
# 拷贝构建产物
|
||||
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
|
||||
@@ -18,4 +18,4 @@
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,7 @@ export default tseslint.config(
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -11,40 +11,62 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@lobehub/icons": "^1.97.1",
|
||||
"@lobehub/icons-static-svg": "^1.45.0",
|
||||
"@lottiefiles/dotlottie-react": "^0.13.3",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.7",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||
"@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.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",
|
||||
"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",
|
||||
@@ -54,6 +76,8 @@
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^15.15.0",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.24.1",
|
||||
"vite": "^6.2.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
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": "1.8.1",
|
||||
"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,16 +1,70 @@
|
||||
|
||||
import './App.css'
|
||||
import {HomePage} from "./pages/Home.tsx";
|
||||
import {useTaskPolling} from "@/hooks/useTaskPolling.ts";
|
||||
import { HomePage } from './pages/HomePage/Home.tsx'
|
||||
import { useTaskPolling } from '@/hooks/useTaskPolling.ts'
|
||||
import SettingPage from './pages/SettingPage/index.tsx'
|
||||
import { BrowserRouter, HashRouter, Navigate, Routes } from 'react-router-dom'
|
||||
import { Route } from 'react-router-dom'
|
||||
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 Prompt from '@/pages/SettingPage/Prompt.tsx'
|
||||
import AboutPage from '@/pages/SettingPage/about.tsx'
|
||||
import Downloader from '@/pages/SettingPage/Downloader.tsx'
|
||||
import DownloaderForm from '@/components/Form/DownloaderForm/Form.tsx'
|
||||
import { useEffect } from 'react'
|
||||
import { systemCheck } from '@/services/system.ts'
|
||||
import { useCheckBackend } from '@/hooks/useCheckBackend.ts'
|
||||
import BackendInitDialog from '@/components/BackendInitDialog'
|
||||
|
||||
function App() {
|
||||
useTaskPolling(3000) // 每 3 秒轮询一次
|
||||
useTaskPolling(3000) // 每 3 秒轮询一次
|
||||
const { loading, initialized } = useCheckBackend()
|
||||
|
||||
// 在后端初始化完成后执行系统检查
|
||||
useEffect(() => {
|
||||
if (initialized) {
|
||||
systemCheck()
|
||||
}
|
||||
}, [initialized])
|
||||
|
||||
// 如果后端还未初始化,显示初始化对话框
|
||||
if (!initialized) {
|
||||
return (
|
||||
<>
|
||||
<HomePage></HomePage>
|
||||
</>
|
||||
<>
|
||||
<BackendInitDialog open={loading} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 后端已初始化,渲染主应用
|
||||
return (
|
||||
<>
|
||||
<HashRouter>
|
||||
<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="about" element={<AboutPage />}></Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App
|
||||
3296
BillNote_frontend/src/assets/Lottie/404.json
Normal file
3296
BillNote_frontend/src/assets/Lottie/404.json
Normal file
File diff suppressed because it is too large
Load Diff
1
BillNote_frontend/src/assets/Lottie/Error.json
Normal file
1
BillNote_frontend/src/assets/Lottie/Error.json
Normal file
File diff suppressed because one or more lines are too long
1
BillNote_frontend/src/assets/Lottie/download.json
Normal file
1
BillNote_frontend/src/assets/Lottie/download.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
BillNote_frontend/src/assets/customAI.png
Normal file
BIN
BillNote_frontend/src/assets/customAI.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
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
|
||||
340
BillNote_frontend/src/components/Form/modelForm/Form.tsx
Normal file
340
BillNote_frontend/src/components/Form/modelForm/Form.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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, deleteModelById } from '@/services/model.ts'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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 { Tag } from 'antd'
|
||||
import { useModelStore } from '@/store/modelStore'
|
||||
|
||||
// ✅ Provider表单schema
|
||||
const ProviderSchema = z.object({
|
||||
name: z.string().min(2, '名称不能少于 2 个字符'),
|
||||
apiKey: z.string().optional(),
|
||||
baseUrl: z.string().url('必须是合法 URL'),
|
||||
type: z.string(),
|
||||
})
|
||||
|
||||
type ProviderFormValues = z.infer<typeof ProviderSchema>
|
||||
|
||||
// ✅ Model表单schema
|
||||
const ModelSchema = z.object({
|
||||
modelName: z.string().min(1, '请选择或填写模型名称'),
|
||||
})
|
||||
|
||||
type ModelFormValues = z.infer<typeof ModelSchema>
|
||||
interface IModel {
|
||||
id: string
|
||||
created: number
|
||||
object: string
|
||||
owned_by: string
|
||||
permission: string
|
||||
root: string
|
||||
}
|
||||
const ProviderForm = ({ isCreate = false }: { isCreate?: boolean }) => {
|
||||
let { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const isEditMode = !isCreate
|
||||
|
||||
const getProviderById = useProviderStore(state => state.getProviderById)
|
||||
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>({
|
||||
resolver: zodResolver(ProviderSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
type: 'custom',
|
||||
},
|
||||
})
|
||||
const filteredModelOptions = modelOptions.filter(model => {
|
||||
const keywords = search.trim().toLowerCase().split(/\s+/) // 支持多个关键词
|
||||
const target = model.id.toLowerCase()
|
||||
return keywords.every(kw => target.includes(kw))
|
||||
})
|
||||
|
||||
const modelForm = useForm<ModelFormValues>({
|
||||
resolver: zodResolver(ModelSchema),
|
||||
defaultValues: {
|
||||
modelName: '',
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const load = async () => {
|
||||
if (isEditMode) {
|
||||
|
||||
const data = await loadProviderById(id!)
|
||||
providerForm.reset(data)
|
||||
setIsBuiltIn(data.type === 'built-in')
|
||||
} else {
|
||||
providerForm.reset({
|
||||
name: '',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
type: 'custom',
|
||||
})
|
||||
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()
|
||||
if (!values.apiKey || !values.baseUrl) {
|
||||
toast.error('请填写 API Key 和 Base URL')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (!id){
|
||||
toast.error('请先保存供应商信息')
|
||||
return
|
||||
}
|
||||
setTesting(true)
|
||||
await testConnection({
|
||||
id
|
||||
})
|
||||
|
||||
toast.success('测试连通性成功 🎉')
|
||||
|
||||
} catch (error) {
|
||||
|
||||
toast.error(`连接失败: ${data.data.msg || '未知错误'}`)
|
||||
// toast.error('测试连通性异常')
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载模型列表
|
||||
const handleModelLoad = async () => {
|
||||
const values = providerForm.getValues()
|
||||
if (!values.apiKey || !values.baseUrl) {
|
||||
toast.error('请先填写 API Key 和 Base URL')
|
||||
return
|
||||
}
|
||||
try {
|
||||
setModelLoading(true) // ✅ 开始 loading
|
||||
const res = await fetchModels(id!, { noCache: true }) // 这里稍后解释
|
||||
if (res.data.code === 0 && res.data.data.models.data.length > 0) {
|
||||
setModelOptions(res.data.data.models.data)
|
||||
console.log('🔧 模型列表:', res.data.data)
|
||||
toast.success('模型列表加载成功 🎉')
|
||||
} else {
|
||||
toast.error('未获取到模型列表')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('加载模型列表失败')
|
||||
} finally {
|
||||
setModelLoading(false) // ✅ 结束 loading
|
||||
}
|
||||
}
|
||||
|
||||
// 保存Provider信息
|
||||
const onProviderSubmit = async (values: ProviderFormValues) => {
|
||||
if (isEditMode) {
|
||||
await updateProvider({ ...values, id: id! })
|
||||
toast.success('更新供应商成功')
|
||||
} else {
|
||||
id = await addNewProvider({ ...values })
|
||||
|
||||
toast.success('新增供应商成功')
|
||||
}
|
||||
// 刷新页面
|
||||
|
||||
}
|
||||
|
||||
// 保存Model信息
|
||||
const onModelSubmit = async (values: ModelFormValues) => {
|
||||
toast.success(`保存模型: ${values.modelName}`)
|
||||
await loadModelsById(id!)
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-4">加载中...</div>
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 p-4">
|
||||
{/* Provider信息表单 */}
|
||||
<Form {...providerForm}>
|
||||
<form
|
||||
onSubmit={providerForm.handleSubmit(onProviderSubmit)}
|
||||
className="flex max-w-xl flex-col gap-4"
|
||||
>
|
||||
<div className="text-lg font-bold">
|
||||
{isEditMode ? '编辑模型供应商' : '新增模型供应商'}
|
||||
</div>
|
||||
{!isBuiltIn && (
|
||||
<div className="text-sm text-red-500 italic">
|
||||
自定义模型供应商需要确保兼容 OpenAI SDK
|
||||
</div>
|
||||
)}
|
||||
<FormField
|
||||
control={providerForm.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-4">
|
||||
<FormLabel className="w-24 text-right">名称</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isBuiltIn} className="flex-1" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={providerForm.control}
|
||||
name="apiKey"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-4">
|
||||
<FormLabel className="w-24 text-right">API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} className="flex-1" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={providerForm.control}
|
||||
name="baseUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-4">
|
||||
<FormLabel className="w-24 text-right">API地址</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} className="flex-1" />
|
||||
</FormControl>
|
||||
<Button type="button" onClick={handleTest} variant="ghost" disabled={testing}>
|
||||
{testing ? '测试中...' : '测试连通性'}
|
||||
</Button>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={providerForm.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-4">
|
||||
<FormLabel className="w-24 text-right">类型</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled className="flex-1" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="pt-2">
|
||||
<Button type="submit" disabled={!providerForm.formState.isDirty}>
|
||||
{isEditMode ? '保存修改' : '保存创建'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{/* 模型信息表单 */}
|
||||
<div className="flex max-w-xl flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="font-bold">模型列表</span>
|
||||
<div className={'flex flex-col gap-2 rounded bg-[#FEF0F0] p-2.5'}>
|
||||
<h2 className={'font-bold'}>注意!</h2>
|
||||
<span>请确保已经保存供应商信息,以及通过测试连通性.</span>
|
||||
</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 (
|
||||
<>
|
||||
<Tag onClose={()=>{
|
||||
handelDelete(model.id)
|
||||
}} key={model.id} closable color={'blue'}>
|
||||
{model.model_name}
|
||||
</Tag></>
|
||||
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
</div>
|
||||
{/*<ModelSelector providerId={id!} />*/}
|
||||
|
||||
{/*<datalist id="model-options">*/}
|
||||
{/* {modelOptions.map(model => (*/}
|
||||
{/* <option key={model.id + '1'} value={model.id} />*/}
|
||||
{/* ))}*/}
|
||||
{/*</datalist>*/}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProviderForm
|
||||
@@ -0,0 +1,4 @@
|
||||
// iconMap.ts
|
||||
import * as Icons from '@lobehub/icons'
|
||||
|
||||
export const IconMap = Icons;
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as Icons from '@lobehub/icons'
|
||||
import CustomLogo from '@/assets/customAI.png'
|
||||
|
||||
interface AILogoProps {
|
||||
name: string // 图标名称(区分大小写!如 OpenAI、DeepSeek)
|
||||
style?: 'Color' | 'Text' | 'Outlined' | 'Glyph'
|
||||
size?: number
|
||||
}
|
||||
|
||||
const AILogo = ({ name, style = 'Color', size = 24 }: AILogoProps) => {
|
||||
const Icon = Icons[name as keyof typeof Icons]
|
||||
if (!Icon) {
|
||||
console.error(`❌ 图标组件不存在: ${name}`)
|
||||
return (
|
||||
<span style={{ fontSize: size }}>
|
||||
<img src={CustomLogo} alt="CustomLogo" style={{ width: size, height: size }} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const Variant = Icon[style as keyof typeof Icon]
|
||||
if (!Variant) {
|
||||
return <Icon size={size} />
|
||||
}
|
||||
|
||||
return <Variant size={size} />
|
||||
}
|
||||
|
||||
export default AILogo
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useModelStore } from '@/store/modelStore'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
interface ModelSelectorProps {
|
||||
providerId: string
|
||||
}
|
||||
|
||||
export function ModelSelector({ providerId }: ModelSelectorProps) {
|
||||
const { models, loading, selectedModel, loadModels, setSelectedModel, addNewModel } =
|
||||
useModelStore()
|
||||
const [search, setSearch] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const filteredModels = models.filter(model => {
|
||||
const keywords = search.trim().toLowerCase().split(/\s+/)
|
||||
const target = model.id.toLowerCase()
|
||||
return keywords.every(kw => target.includes(kw))
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (providerId) {
|
||||
loadModels(providerId)
|
||||
}
|
||||
}, [providerId])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedModel) {
|
||||
toast.error('请选择一个模型')
|
||||
return
|
||||
}
|
||||
try {
|
||||
setSubmitting(true)
|
||||
await addNewModel(providerId, selectedModel)
|
||||
toast.success('保存模型成功 🎉')
|
||||
} catch (error) {
|
||||
toast.error('保存失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2 font-bold">
|
||||
<span>选择模型</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => loadModels(providerId)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '加载中...' : '刷新模型'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className="w-[300px]">
|
||||
<SelectValue placeholder="请选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<div className="p-2">
|
||||
<Input
|
||||
placeholder="搜索模型..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
{filteredModels.map((model, index) => (
|
||||
<SelectItem key={`${model.id}-${index}`} value={model.id}>
|
||||
{model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button onClick={handleSubmit} disabled={submitting || !selectedModel}>
|
||||
{submitting ? '保存中...' : '保存模型'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
BillNote_frontend/src/components/Form/modelForm/Provider.tsx
Normal file
44
BillNote_frontend/src/components/Form/modelForm/Provider.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import ProviderCard from '@/components/Form/modelForm/components/providerCard.tsx'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import { useProviderStore } from '@/store/providerStore'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const Provider = () => {
|
||||
const providers = useProviderStore(state => state.provider)
|
||||
const navigate = useNavigate()
|
||||
const handleClick = () => {
|
||||
navigate(`/settings/model/new`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className={'search flex gap-1 py-1.5'}>
|
||||
<Button
|
||||
type={'button'}
|
||||
onClick={() => {
|
||||
handleClick()
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
添加模型供应商
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-sm font-light">模型供应商列表</div>
|
||||
<div>
|
||||
{providers &&
|
||||
providers.map((provider, index) => {
|
||||
return (
|
||||
<ProviderCard
|
||||
key={index}
|
||||
providerName={provider.name}
|
||||
Icon={provider.logo}
|
||||
id={provider.id}
|
||||
enable={provider.enabled}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</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,66 @@
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
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: 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)
|
||||
updateProvider({
|
||||
id,
|
||||
enabled: enable == 1 ? 0 : 1,
|
||||
})
|
||||
}
|
||||
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 h-9 w-9 items-center">
|
||||
<AILogo name={Icon} />
|
||||
</div>
|
||||
<div className="font-semibold">{providerName}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Switch
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
handleEnable()
|
||||
}}
|
||||
checked={enable == 1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default ProviderCard
|
||||
4
BillNote_frontend/src/components/Icons/iconMap.ts
Normal file
4
BillNote_frontend/src/components/Icons/iconMap.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// iconMap.ts
|
||||
import * as Icons from '@lobehub/icons'
|
||||
|
||||
export const IconMap = Icons;
|
||||
24
BillNote_frontend/src/components/Icons/index.tsx
Normal file
24
BillNote_frontend/src/components/Icons/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as Icons from '@lobehub/icons';
|
||||
|
||||
interface AILogoProps {
|
||||
name: string; // 图标名称(区分大小写!如 OpenAI、DeepSeek)
|
||||
style?: 'Color' | 'Text' | 'Outlined' | 'Glyph';
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const AILogo = ({ name, style = 'Color', size = 24 }: AILogoProps) => {
|
||||
const Icon = Icons[name as keyof typeof Icons];
|
||||
if (!Icon) {
|
||||
console.error(`❌ 图标组件不存在: ${name}`);
|
||||
return <span style={{ fontSize: size }}>🚫</span>;
|
||||
}
|
||||
|
||||
const Variant = Icon[style as keyof typeof Icon];
|
||||
if (!Variant) {
|
||||
return <Icon size={size} />;
|
||||
}
|
||||
|
||||
return <Variant size={size} />;
|
||||
};
|
||||
|
||||
export default AILogo;
|
||||
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
|
||||
13
BillNote_frontend/src/components/Lottie/404.tsx
Normal file
13
BillNote_frontend/src/components/Lottie/404.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { FC } from 'react'
|
||||
import Lottie from 'lottie-react'
|
||||
import Animation from '@/assets/Lottie/404.json'
|
||||
|
||||
const NotFound: FC = () => {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<Lottie animationData={Animation} loop autoplay />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotFound
|
||||
@@ -3,16 +3,11 @@ import Lottie from 'lottie-react'
|
||||
import loadingJson from '@/assets/Lottie/idle.json'
|
||||
|
||||
const Idle: FC = () => {
|
||||
return (
|
||||
<div className="flex justify-center items-center ">
|
||||
<Lottie
|
||||
animationData={loadingJson}
|
||||
loop
|
||||
autoplay
|
||||
style={{ width: 350, height: 350 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<Lottie animationData={loadingJson} loop autoplay style={{ width: 350, height: 350 }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Idle
|
||||
|
||||
@@ -3,16 +3,11 @@ import Lottie from 'lottie-react'
|
||||
import loadingJson from '@/assets/Lottie/loading.json'
|
||||
|
||||
const Loading: FC = () => {
|
||||
return (
|
||||
<div className="flex justify-center items-center ">
|
||||
<Lottie
|
||||
animationData={loadingJson}
|
||||
loop
|
||||
autoplay
|
||||
style={{ width: 150, height: 150 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<Lottie animationData={loadingJson} loop autoplay style={{ width: 150, height: 150 }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Loading
|
||||
|
||||
40
BillNote_frontend/src/components/Lottie/download.tsx
Normal file
40
BillNote_frontend/src/components/Lottie/download.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { FC, useRef, useEffect } from 'react'
|
||||
import Lottie, { LottieRefCurrentProps } from 'lottie-react'
|
||||
import download from '@/assets/Lottie/download.json'
|
||||
|
||||
interface LoadingProps {
|
||||
play?: boolean // 是否播放
|
||||
color?: string // 控制主色,比如 "#00BFFF"
|
||||
}
|
||||
|
||||
const Downloading: FC<LoadingProps> = ({ play = true, color = '#00BFFF' }) => {
|
||||
const lottieRef = useRef<LottieRefCurrentProps>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!lottieRef.current) return
|
||||
|
||||
if (play) {
|
||||
lottieRef.current.play()
|
||||
} else {
|
||||
lottieRef.current.pause()
|
||||
}
|
||||
}, [play])
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<Lottie
|
||||
lottieRef={lottieRef}
|
||||
animationData={download}
|
||||
loop
|
||||
autoplay={play}
|
||||
style={{
|
||||
width: 150,
|
||||
height: 150,
|
||||
filter: `drop-shadow(0 0 4px ${color}) saturate(2) brightness(1.2)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Downloading
|
||||
21
BillNote_frontend/src/components/Lottie/error.tsx
Normal file
21
BillNote_frontend/src/components/Lottie/error.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { FC } from 'react'
|
||||
import Lottie from 'lottie-react'
|
||||
import error from '@/assets/Lottie/Error.json'
|
||||
|
||||
const Error: FC = () => {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<Lottie
|
||||
animationData={error}
|
||||
loop
|
||||
autoplay
|
||||
style={{
|
||||
width: 450,
|
||||
height: 450,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Error
|
||||
64
BillNote_frontend/src/components/ui/alert.tsx
Normal file
64
BillNote_frontend/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-card text-card-foreground',
|
||||
destructive:
|
||||
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
|
||||
success:
|
||||
'text-success bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-success/90',
|
||||
warning:
|
||||
'text-[#303133] bg-[#FEF0F0] [&>svg]:text-current *:data-[slot=alert-description]:text-warning/90',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -1,26 +1,24 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -30,17 +28,10 @@ function Badge({
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'span'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
|
||||
@@ -1,36 +1,33 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -41,11 +38,11 @@ function Button({
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from "react"
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -15,12 +15,12 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -28,65 +28,48 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
className={cn('leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import * as React from 'react'
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||
import { CheckIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
@@ -9,10 +9,10 @@ import {
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
} from 'react-hook-form'
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
@@ -23,9 +23,7 @@ type FormFieldContextValue<
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
@@ -48,7 +46,7 @@ const useFormField = () => {
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
throw new Error('useFormField should be used within <FormField>')
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
@@ -67,35 +65,26 @@ type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
<div data-slot="form-item" className={cn('grid gap-2', className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
function FormLabel({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
className={cn('data-[error=true]:text-destructive', className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
@@ -109,33 +98,29 @@ function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
const body = error ? String(error?.message ?? '') : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
@@ -145,7 +130,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
className={cn('text-destructive text-sm', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import * as React from "react"
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
"use client"
|
||||
'use client'
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...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 }
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
import * as React from 'react'
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
@@ -11,7 +11,7 @@ function ScrollArea({
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
className={cn('relative', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
@@ -28,7 +28,7 @@ function ScrollArea({
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
@@ -36,11 +36,9 @@ function ScrollBar({
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
'flex touch-none p-px transition-colors select-none',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,34 +1,28 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import * as React from 'react'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
size = 'default',
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
size?: 'sm' | 'default'
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
@@ -51,7 +45,7 @@ function SelectTrigger({
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
position = 'popper',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
@@ -59,9 +53,9 @@ function SelectContent({
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
'bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
@@ -70,9 +64,9 @@ function SelectContent({
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -83,14 +77,11 @@ function SelectContent({
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -127,7 +118,7 @@ function SelectSeparator({
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -140,10 +131,7 @@ function SelectScrollUpButton({
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
@@ -158,10 +146,7 @@ function SelectScrollDownButton({
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||
import { useTheme } from 'next-themes'
|
||||
import { Toaster as Sonner, ToasterProps } from 'sonner'
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
const { theme = 'system' } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
theme={theme as ToasterProps['theme']}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
'--normal-bg': 'var(--popover)',
|
||||
'--normal-text': 'var(--popover-foreground)',
|
||||
'--normal-border': 'var(--border)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
|
||||
29
BillNote_frontend/src/components/ui/switch.tsx
Normal file
29
BillNote_frontend/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
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 }
|
||||
18
BillNote_frontend/src/components/ui/textarea.tsx
Normal file
18
BillNote_frontend/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
import * as React from 'react'
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
@@ -16,9 +16,7 @@ function TooltipProvider({
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
@@ -26,9 +24,7 @@ function Tooltip({
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
@@ -44,7 +40,7 @@ function TooltipContent({
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
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 }
|
||||
}
|
||||
@@ -1,46 +1,59 @@
|
||||
// hooks/useTaskPolling.ts
|
||||
import { useEffect } from "react"
|
||||
import { useTaskStore } from "@/store/taskStore"
|
||||
import {get_task_status} from "@/services/note.ts";
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTaskStore } from '@/store/taskStore'
|
||||
import { get_task_status } from '@/services/note.ts'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export const useTaskPolling = (interval = 3000) => {
|
||||
const tasks = useTaskStore(state => state.tasks)
|
||||
const updateTaskContent = useTaskStore(state => state.updateTaskContent)
|
||||
const removeTask=useTaskStore(state=>state.removeTask)
|
||||
useEffect(() => {
|
||||
const timer = setInterval(async () => {
|
||||
const pendingTasks = tasks.filter(
|
||||
(task) => task.status === "PENDING" || task.status === "running"
|
||||
)
|
||||
const tasks = useTaskStore(state => state.tasks)
|
||||
const updateTaskContent = useTaskStore(state => state.updateTaskContent)
|
||||
const updateTaskStatus = useTaskStore(state => state.updateTaskStatus)
|
||||
const removeTask = useTaskStore(state => state.removeTask)
|
||||
|
||||
for (const task of pendingTasks) {
|
||||
try {
|
||||
console.log(task)
|
||||
const res = await get_task_status(task.id)
|
||||
const {status}=res.data
|
||||
const tasksRef = useRef(tasks)
|
||||
|
||||
if (status && status !== task.status) {
|
||||
if (status === "SUCCESS") {
|
||||
const { markdown, transcript, audio_meta } = res.data.result
|
||||
// 每次 tasks 更新,把最新的 tasks 同步进去
|
||||
useEffect(() => {
|
||||
tasksRef.current = tasks
|
||||
}, [tasks])
|
||||
|
||||
updateTaskContent(task.id, {
|
||||
status,
|
||||
markdown,
|
||||
transcript,
|
||||
audioMeta: audio_meta,
|
||||
})
|
||||
} else {
|
||||
updateTaskStatus(task.id, status)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("❌ 任务轮询失败:", e)
|
||||
removeTask(task.id)
|
||||
useEffect(() => {
|
||||
const timer = setInterval(async () => {
|
||||
const pendingTasks = tasksRef.current.filter(
|
||||
task => task.status != 'SUCCESS' && task.status != 'FAILED'
|
||||
)
|
||||
|
||||
}
|
||||
for (const task of pendingTasks) {
|
||||
try {
|
||||
console.log('🔄 正在轮询任务:', task.id)
|
||||
const res = await get_task_status(task.id)
|
||||
const { status } = res
|
||||
|
||||
if (status && status !== task.status) {
|
||||
if (status === 'SUCCESS') {
|
||||
const { markdown, transcript, audio_meta } = res.result
|
||||
toast.success('笔记生成成功')
|
||||
updateTaskContent(task.id, {
|
||||
status,
|
||||
markdown,
|
||||
transcript,
|
||||
audioMeta: audio_meta,
|
||||
})
|
||||
} else if (status === 'FAILED') {
|
||||
updateTaskContent(task.id, { status })
|
||||
console.warn(`⚠️ 任务 ${task.id} 失败`)
|
||||
} else {
|
||||
updateTaskContent(task.id, { status })
|
||||
}
|
||||
}, interval)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('❌ 任务轮询失败:', e)
|
||||
// toast.error(`生成失败 ${e.message || e}`)
|
||||
updateTaskContent(task.id, { status: 'FAILED' })
|
||||
// removeTask(task.id)
|
||||
}
|
||||
}
|
||||
}, interval)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [interval, tasks])
|
||||
return () => clearInterval(timer)
|
||||
}, [interval])
|
||||
}
|
||||
|
||||
@@ -1,7 +1,32 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 修改滚动条轨道颜色 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px; /* 控制滚动条的宽度 */
|
||||
}
|
||||
|
||||
/* 修改滚动条的轨道颜色 */
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: #f1f1f1; /* 轨道的背景颜色 */
|
||||
}
|
||||
|
||||
/* 修改滚动条的滑块颜色 */
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #888; /* 滑块的颜色 */
|
||||
border-radius: 4px; /* 圆角 */
|
||||
}
|
||||
|
||||
/* 当鼠标悬停时,修改滑块颜色 */
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #555; /* 悬停时的颜色 */
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
@@ -11,7 +36,7 @@
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: #3C77FB;
|
||||
--primary: #3c77fb;
|
||||
--primary-light: #e0eeff;
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
@@ -21,7 +46,7 @@
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: #e6f7ff;
|
||||
--border: var( --color-neutral-200);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: #096dd9;
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
@@ -46,8 +71,8 @@
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: #3C77FB;
|
||||
--primary-light:#e0eeff;
|
||||
--primary: #3c77fb;
|
||||
--primary-light: #e0eeff;
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: #3C77FB;
|
||||
--primary: #3c77fb;
|
||||
--primary-light: #e0eeff;
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
@@ -47,8 +47,8 @@
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: #3C77FB;
|
||||
--primary-light:#e0eeff;
|
||||
--primary: #3c77fb;
|
||||
--primary-light: #e0eeff;
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
|
||||
@@ -1,43 +1,79 @@
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import { Button } from "@/components/ui/button.tsx"
|
||||
import React, { FC } from 'react'
|
||||
import { SlidersHorizontal } from 'lucide-react'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip.tsx'
|
||||
|
||||
interface HomeLayoutProps {
|
||||
form: ReactNode
|
||||
preview: ReactNode
|
||||
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 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 HomeLayout: FC<HomeLayoutProps> = ({ form, preview }) => {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-white">
|
||||
<div className="flex flex-1">
|
||||
{/* 左侧部分:Header + 表单 */}
|
||||
<aside className="w-[400px] bg-white border-r border-neutral-200 flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="h-16 flex items-center px-6 gap-2">
|
||||
<div className="w-10 h-10 rounded-2xl overflow-hidden flex justify-center items-center">
|
||||
<img src="/icon.svg" alt="logo" className="w-full h-full object-contain" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-800">BiliNote</div>
|
||||
</header>
|
||||
return (
|
||||
<div className="flex h-screen flex-col overflow-hidden">
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
|
||||
{/* 左边表单 */}
|
||||
<ResizablePanel defaultSize={18} minSize={10} maxSize={35}>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* 表单内容 */}
|
||||
<div className="flex-1 p-4 overflow-auto">
|
||||
{form}
|
||||
</div>
|
||||
</aside>
|
||||
<ResizableHandle />
|
||||
|
||||
{/* 右侧预览区域 */}
|
||||
<main className="flex-1 h-screen p-6 bg-white overflow-hidden">
|
||||
{preview}
|
||||
</main>
|
||||
</div>
|
||||
{/* 中间历史 */}
|
||||
<ResizablePanel defaultSize={16} minSize={10} maxSize={30}>
|
||||
<aside className="flex h-full flex-col overflow-hidden border-r border-neutral-200 bg-white">
|
||||
<ScrollArea className="flex-1 overflow-auto">
|
||||
<div className="">{History}</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
</ResizablePanel>
|
||||
|
||||
{/* 页脚 */}
|
||||
{/*<footer className="h-12 bg-white shadow-inner flex items-center justify-center text-sm text-neutral-600">*/}
|
||||
{/* © 2025 BiliNote. All rights reserved.*/}
|
||||
{/*</footer>*/}
|
||||
</div>
|
||||
)
|
||||
<ResizableHandle />
|
||||
|
||||
{/* 右边预览 */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
<main className="flex h-full flex-col overflow-hidden bg-white p-6">{Preview}</main>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HomeLayout
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import type { ReactNode, FC } from "react"
|
||||
import type { ReactNode, FC } from 'react'
|
||||
// import "@/global.css"
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
|
||||
interface RootLayoutProps {
|
||||
children: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "BiliNote - 视频笔记生成器",
|
||||
description: "通过视频链接结合大模型自动生成对应的笔记",
|
||||
title: 'BiliNote - 视频笔记生成器',
|
||||
description: '通过视频链接结合大模型自动生成对应的笔记',
|
||||
}
|
||||
|
||||
const RootLayout: FC<RootLayoutProps> = ({ children }) => {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-100 text-neutral-900 font-sans">
|
||||
<Toaster
|
||||
position="top-center" // 顶部居中显示
|
||||
toastOptions={{
|
||||
style: {
|
||||
borderRadius: '8px',
|
||||
background: '#333',
|
||||
color: '#fff',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-100 font-sans text-neutral-900">
|
||||
<Toaster
|
||||
position="top-center" // 顶部居中显示
|
||||
toastOptions={{
|
||||
style: {
|
||||
borderRadius: '8px',
|
||||
background: '#333',
|
||||
color: '#fff',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RootLayout
|
||||
|
||||
65
BillNote_frontend/src/layouts/SettingLayout.tsx
Normal file
65
BillNote_frontend/src/layouts/SettingLayout.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip.tsx'
|
||||
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
|
||||
}
|
||||
const SettingLayout = ({ Menu }: ISettingLayoutProps) => {
|
||||
return (
|
||||
<div
|
||||
className="h-full w-full"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-muted)',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-1">
|
||||
{/* 左侧部分:Header + 表单 */}
|
||||
<aside className="flex w-[300px] 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={logo} alt="logo" className="h-full w-full object-contain" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-800">BiliNote</div>
|
||||
</div>
|
||||
<div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Link to={'/'}>
|
||||
<SlidersHorizontal className="text-muted-foreground hover:text-primary cursor-pointer" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span>返回首页</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 表单内容 */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{/*<NoteForm />*/}
|
||||
{Menu}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 右侧预览区域 */}
|
||||
<main className="h-screen flex-1 overflow-hidden">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default SettingLayout
|
||||
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 })
|
||||
@@ -1,5 +1,5 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
|
||||
@@ -2,12 +2,11 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import RootLayout from "./layouts/RootLayout.tsx";
|
||||
|
||||
import RootLayout from './layouts/RootLayout.tsx'
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<RootLayout>
|
||||
<App />
|
||||
</RootLayout>
|
||||
</StrictMode>,
|
||||
<StrictMode>
|
||||
<RootLayout>
|
||||
<App />
|
||||
</RootLayout>
|
||||
</StrictMode>
|
||||
)
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import React,{FC,useEffect,useState} from "react";
|
||||
import HomeLayout from "@/layouts/HomeLayout.tsx";
|
||||
import NoteForm from '@/pages/components/NoteForm'
|
||||
import MarkdownViewer from '@/pages/components/MarkdownViewer'
|
||||
import NoteFormWrapper from "@/pages/components/NoteFormWrapper.tsx";
|
||||
import {get_task_status} from "@/services/note.ts";
|
||||
import {useTaskStore} from "@/store/taskStore";
|
||||
type ViewStatus = 'idle' | 'loading' | 'success'
|
||||
export const HomePage:FC =()=>{
|
||||
const tasks = useTaskStore((state) => state.tasks)
|
||||
const currentTaskId = useTaskStore((state) => state.currentTaskId)
|
||||
|
||||
const currentTask = tasks.find((t) => t.id === currentTaskId)
|
||||
|
||||
const [status, setStatus] = useState<ViewStatus>('idle')
|
||||
|
||||
const content = currentTask?.markdown || ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentTask) {
|
||||
setStatus('idle')
|
||||
} else if (currentTask.status === 'PENDING') {
|
||||
setStatus('loading')
|
||||
} else if (currentTask.status === 'SUCCESS') {
|
||||
setStatus('success')
|
||||
}
|
||||
}, [currentTask])
|
||||
|
||||
// useEffect( () => {
|
||||
// get_task_status('d4e87938-c066-48a0-bbd5-9bec40d53354').then(res=>{
|
||||
// console.log('res1',res)
|
||||
// setContent(res.data.result.markdown)
|
||||
// })
|
||||
// }, [tasks]);
|
||||
return (
|
||||
<HomeLayout
|
||||
form={<NoteForm/>}
|
||||
preview={<MarkdownViewer status={status} content={content} />}
|
||||
|
||||
/>
|
||||
)
|
||||
}
|
||||
43
BillNote_frontend/src/pages/HomePage/Home.tsx
Normal file
43
BillNote_frontend/src/pages/HomePage/Home.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import HomeLayout from '@/layouts/HomeLayout.tsx'
|
||||
import NoteForm from '@/pages/HomePage/components/NoteForm.tsx'
|
||||
import MarkdownViewer from '@/pages/HomePage/components/MarkdownViewer.tsx'
|
||||
import { useTaskStore } from '@/store/taskStore'
|
||||
import History from '@/pages/HomePage/components/History.tsx'
|
||||
type ViewStatus = 'idle' | 'loading' | 'success' | 'failed'
|
||||
export const HomePage: FC = () => {
|
||||
const tasks = useTaskStore(state => state.tasks)
|
||||
const currentTaskId = useTaskStore(state => state.currentTaskId)
|
||||
|
||||
const currentTask = tasks.find(t => t.id === currentTaskId)
|
||||
|
||||
const [status, setStatus] = useState<ViewStatus>('idle')
|
||||
|
||||
const content = currentTask?.markdown || ''
|
||||
|
||||
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')
|
||||
}
|
||||
}, [currentTask])
|
||||
|
||||
// useEffect( () => {
|
||||
// get_task_status('d4e87938-c066-48a0-bbd5-9bec40d53354').then(res=>{
|
||||
// console.log('res1',res)
|
||||
// setContent(res.data.result.markdown)
|
||||
// })
|
||||
// }, [tasks]);
|
||||
return (
|
||||
<HomeLayout
|
||||
NoteForm={<NoteForm />}
|
||||
Preview={<MarkdownViewer status={status} />}
|
||||
History={<History />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
26
BillNote_frontend/src/pages/HomePage/components/History.tsx
Normal file
26
BillNote_frontend/src/pages/HomePage/components/History.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import NoteHistory from '@/pages/HomePage/components/NoteHistory.tsx'
|
||||
import { useTaskStore } from '@/store/taskStore'
|
||||
import { Info, Clock, Loader2 } from 'lucide-react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
|
||||
const History = () => {
|
||||
const currentTaskId = useTaskStore(state => state.currentTaskId)
|
||||
const setCurrentTask = useTaskStore(state => state.setCurrentTask)
|
||||
return (
|
||||
<>
|
||||
<div className={'flex h-full w-full flex-col gap-4 px-2.5 py-1.5'}>
|
||||
{/*生成历史 */}
|
||||
<div className="my-4 flex h-[40px] items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-neutral-500" />
|
||||
<h2 className="text-base font-medium text-neutral-900">生成历史</h2>
|
||||
</div>
|
||||
<ScrollArea className="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>*/}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default History
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Copy, Download, BrainCircuit } 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
|
||||
}
|
||||
|
||||
export function MarkdownHeader({
|
||||
currentTask,
|
||||
isMultiVersion,
|
||||
currentVerId,
|
||||
setCurrentVerId,
|
||||
modelName,
|
||||
style,
|
||||
noteStyles,
|
||||
onCopy,
|
||||
onDownload,
|
||||
createAt,
|
||||
showTranscribe,
|
||||
setShowTranscribe,
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import { Copy, Download, ArrowRight, Play, ExternalLink } from 'lucide-react'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import Error from '@/components/Lottie/error.tsx'
|
||||
import Loading from '@/components/Lottie/Loading.tsx'
|
||||
import Idle from '@/components/Lottie/Idle.tsx'
|
||||
import StepBar from '@/pages/HomePage/components/StepBar.tsx'
|
||||
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 { FC } from 'react'
|
||||
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'
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{ label: '解析链接', key: 'PARSING' },
|
||||
{ label: '下载音频', key: 'DOWNLOADING' },
|
||||
{ label: '转写文字', key: 'TRANSCRIBING' },
|
||||
{ label: '总结内容', key: 'SUMMARIZING' },
|
||||
{ label: '保存完成', key: 'SUCCESS' },
|
||||
]
|
||||
|
||||
const MarkdownViewer: FC<MarkdownViewerProps> = ({ 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>('')
|
||||
const baseURL = String(import.meta.env.VITE_API_BASE_URL).replace('/api','') || ''
|
||||
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 [viewMode, setViewMode] = useState<'map' | 'preview'>('preview')
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
// 多版本内容处理
|
||||
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(selectedContent)
|
||||
setCopied(true)
|
||||
toast.success('已复制到剪贴板')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (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 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 = `${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">
|
||||
<StepBar steps={steps} currentStep={taskStatus} />
|
||||
<Loading className="h-5 w-5" />
|
||||
<div className="text-center text-sm">
|
||||
<p className="text-lg font-bold">正在生成笔记,请稍候…</p>
|
||||
<p className="mt-2 text-xs text-neutral-500">这可能需要几秒钟时间,取决于视频长度</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'idle') {
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col items-center justify-center space-y-3 text-neutral-500">
|
||||
<Idle />
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-bold">输入视频链接并点击“生成笔记”</p>
|
||||
<p className="mt-2 text-xs text-neutral-500">支持哔哩哔哩、YouTube 、抖音等视频平台</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'failed' && !isMultiVersion) {
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col items-center justify-center gap-4 space-y-3">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<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}
|
||||
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%" // 根据需求可以设定百分比或固定高度
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 overflow-hidden bg-white py-2">
|
||||
{selectedContent && selectedContent !== 'loading' && selectedContent !== 'empty' ? (
|
||||
<>
|
||||
<ScrollArea className="w-full">
|
||||
<div className={'markdown-body w-full px-2'}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[gfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
// Headings with improved styling and anchor links
|
||||
h1: ({ children, ...props }) => (
|
||||
<h1
|
||||
className="text-primary my-6 scroll-m-20 text-3xl font-extrabold tracking-tight lg:text-4xl"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children, ...props }) => (
|
||||
<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 }) => (
|
||||
<h3
|
||||
className="text-primary mt-8 mb-4 scroll-m-20 text-xl font-semibold tracking-tight"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children, ...props }) => (
|
||||
<h4
|
||||
className="text-primary mt-6 mb-2 scroll-m-20 text-lg font-semibold tracking-tight"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
|
||||
// Paragraphs with better line height
|
||||
p: ({ children, ...props }) => (
|
||||
<p className="leading-7 [&:not(:first-child)]:mt-6" {...props}>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
|
||||
// Enhanced links with special handling for "原片" links
|
||||
a: ({ href, children, ...props }) => {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// Default link styling with external indicator
|
||||
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>
|
||||
)
|
||||
},
|
||||
|
||||
// Enhanced image with zoom capability
|
||||
img: ({ node, ...props }) =>{
|
||||
|
||||
let src = baseURL +props.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>
|
||||
)},
|
||||
|
||||
// Better strong/bold text
|
||||
strong: ({ children, ...props }) => (
|
||||
<strong className="text-primary font-bold" {...props}>
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
|
||||
// Enhanced list items with support for "fake headings"
|
||||
li: ({ children, ...props }) => {
|
||||
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>
|
||||
)
|
||||
},
|
||||
|
||||
// Enhanced unordered lists
|
||||
ul: ({ children, ...props }) => (
|
||||
<ul className="my-6 ml-6 list-disc [&>li]:mt-2" {...props}>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
|
||||
// Enhanced ordered lists
|
||||
ol: ({ children, ...props }) => (
|
||||
<ol className="my-6 ml-6 list-decimal [&>li]:mt-2" {...props}>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
|
||||
// Enhanced blockquotes
|
||||
blockquote: ({ children, ...props }) => (
|
||||
<blockquote
|
||||
className="border-primary/20 text-muted-foreground mt-6 border-l-4 pl-4 italic"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
|
||||
// Enhanced code blocks with syntax highlighting and copy button
|
||||
code: ({ inline, className, children, ...props }) => {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// Inline code styling
|
||||
return (
|
||||
<code
|
||||
className="bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
|
||||
// Enhanced tables
|
||||
table: ({ children, ...props }) => (
|
||||
<div className="my-6 w-full overflow-y-auto">
|
||||
<table className="w-full border-collapse text-sm" {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
|
||||
// Table headers
|
||||
th: ({ children, ...props }) => (
|
||||
<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>
|
||||
),
|
||||
|
||||
// Table cells
|
||||
td: ({ children, ...props }) => (
|
||||
<td
|
||||
className="border-muted-foreground/20 border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
|
||||
// Horizontal rule
|
||||
hr: ({ ...props }) => (
|
||||
<hr className="border-muted-foreground/20 my-8" {...props} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{selectedContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{showTranscribe && (
|
||||
<div className={'ml-2 w-2/4'}>
|
||||
<TranscriptViewer />
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MarkdownViewer
|
||||
@@ -0,0 +1,118 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Markmap } from 'markmap-view'
|
||||
import { transformer } from '@/lib/markmap.ts'
|
||||
import { Toolbar, ToolbarButton } from 'markmap-toolbar'
|
||||
import 'markmap-toolbar/dist/style.css'
|
||||
|
||||
export interface MarkmapEditorProps {
|
||||
/** 要渲染的 Markdown 文本 */
|
||||
value: string
|
||||
/** 内容变化时的回调 */
|
||||
onChange: (value: string) => void
|
||||
/** Toolbar 上要展示的 item id 列表,默认使用 Toolbar.defaultItems */
|
||||
toolbarItems?: string[]
|
||||
/** 自定义按钮列表,会依次注册 */
|
||||
customButtons?: ToolbarButton[]
|
||||
/** 容器 SVG 的高度,默认为 600px */
|
||||
height?: string
|
||||
}
|
||||
|
||||
export default function MarkmapEditor({
|
||||
value,
|
||||
onChange,
|
||||
toolbarItems,
|
||||
customButtons = [],
|
||||
height = '600px',
|
||||
}: MarkmapEditorProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const mmRef = useRef<Markmap>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 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">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
559
BillNote_frontend/src/pages/HomePage/components/NoteForm.tsx
Normal file
559
BillNote_frontend/src/pages/HomePage/components/NoteForm.tsx
Normal file
@@ -0,0 +1,559 @@
|
||||
/* NoteForm.tsx ---------------------------------------------------- */
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form.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 { message, Alert } from 'antd'
|
||||
import { generateNote } from '@/services/note.ts'
|
||||
import { uploadFile } from '@/services/upload.ts'
|
||||
import { useTaskStore } from '@/store/taskStore'
|
||||
import { useModelStore } from '@/store/modelStore'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip.tsx'
|
||||
import { Checkbox } from '@/components/ui/checkbox.tsx'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select.tsx'
|
||||
import { Input } from '@/components/ui/input.tsx'
|
||||
import { Textarea } from '@/components/ui/textarea.tsx'
|
||||
import { noteStyles, noteFormats, videoPlatforms } from '@/constant/note.ts'
|
||||
import { fetchModels } from '@/services/model.ts'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
/* -------------------- 校验 Schema -------------------- */
|
||||
const formSchema = z
|
||||
.object({
|
||||
video_url: z.string(),
|
||||
platform: z.string().nonempty('请选择平台'),
|
||||
quality: z.enum(['fast', 'medium', 'slow']),
|
||||
screenshot: z.boolean().optional(),
|
||||
link: z.boolean().optional(),
|
||||
model_name: z.string().nonempty('请选择模型'),
|
||||
format: z.array(z.string()).default([]),
|
||||
style: z.string().nonempty('请选择笔记生成风格'),
|
||||
extras: z.string().optional(),
|
||||
video_understanding: z.boolean().optional(),
|
||||
video_interval: z.coerce.number().min(1).max(30).default(4).optional(),
|
||||
grid_size: z
|
||||
.tuple([z.coerce.number().min(1).max(10), z.coerce.number().min(1).max(10)])
|
||||
.default([3, 3])
|
||||
.optional(),
|
||||
})
|
||||
.superRefine(({ video_url, platform }, ctx) => {
|
||||
if (platform === 'local' || platform === 'douyin') {
|
||||
if (!video_url) {
|
||||
ctx.addIssue({ code: 'custom', message: '本地视频路径不能为空', path: ['video_url'] })
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const url = new URL(video_url)
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error()
|
||||
} catch {
|
||||
ctx.addIssue({ code: 'custom', message: '请输入正确的视频链接', path: ['video_url'] })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type NoteFormValues = z.infer<typeof formSchema>
|
||||
|
||||
/* -------------------- 可复用子组件 -------------------- */
|
||||
const SectionHeader = ({ title, tip }: { title: string; tip?: string }) => (
|
||||
<div className="my-3 flex items-center justify-between">
|
||||
<h2 className="block">{title}</h2>
|
||||
{tip && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="hover:text-primary h-4 w-4 cursor-pointer text-neutral-400" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-xs">{tip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const CheckboxGroup = ({
|
||||
value = [],
|
||||
onChange,
|
||||
disabledMap,
|
||||
}: {
|
||||
value?: string[]
|
||||
onChange: (v: string[]) => void
|
||||
disabledMap: Record<string, boolean>
|
||||
}) => (
|
||||
<div className="flex flex-wrap space-x-1.5">
|
||||
{noteFormats.map(({ label, value: v }) => (
|
||||
<label key={v} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={value.includes(v)}
|
||||
disabled={disabledMap[v]}
|
||||
onCheckedChange={checked =>
|
||||
onChange(checked ? [...value, v] : value.filter(x => x !== v))
|
||||
}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
/* -------------------- 主组件 -------------------- */
|
||||
const NoteForm = () => {
|
||||
const 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: {
|
||||
platform: 'bilibili',
|
||||
quality: 'medium',
|
||||
model_name: modelList[0]?.model_name || '',
|
||||
style: 'minimal',
|
||||
video_interval: 4,
|
||||
grid_size: [3, 3],
|
||||
format: [],
|
||||
},
|
||||
})
|
||||
const currentTask = getCurrentTask()
|
||||
|
||||
/* ---- 派生状态(只 watch 一次,提高性能) ---- */
|
||||
const platform = useWatch({ control: form.control, name: 'platform' }) as string
|
||||
const videoUnderstandingEnabled = useWatch({ control: form.control, name: 'video_understanding' })
|
||||
const editing = currentTask && currentTask.id
|
||||
|
||||
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 ?? 4,
|
||||
grid_size: formData.grid_size ?? [3, 3],
|
||||
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="h-full w-full">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit, onInvalid)} className="space-y-4">
|
||||
{/* 顶部按钮 */}
|
||||
<FormButton></FormButton>
|
||||
|
||||
{/* 视频链接 & 平台 */}
|
||||
<SectionHeader title="视频链接" tip="支持 B 站、YouTube 等平台" />
|
||||
<div className="flex gap-2">
|
||||
{/* 平台选择 */}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="platform"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Select
|
||||
disabled={!!editing}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{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>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* 链接输入 / 上传框 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="video_url"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
{platform === 'local' ? (
|
||||
<>
|
||||
<Input disabled={!!editing} placeholder="请输入本地视频路径" {...field} />
|
||||
</>
|
||||
) : (
|
||||
<Input disabled={!!editing} placeholder="请输入视频网站链接" {...field} />
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="video_url"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
{platform === 'local' && (
|
||||
<>
|
||||
<div
|
||||
className="hover:border-primary mt-2 flex h-40 cursor-pointer items-center justify-center rounded-md border-2 border-dashed border-gray-300 transition-colors"
|
||||
onDragOver={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) handleFileUpload(file, field.onChange)
|
||||
}}
|
||||
onClick={() => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'video/*'
|
||||
input.onchange = e => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (file) handleFileUpload(file, field.onChange)
|
||||
}
|
||||
input.click()
|
||||
}}
|
||||
>
|
||||
{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
|
||||
closable
|
||||
type="error"
|
||||
message={
|
||||
<div>
|
||||
<strong>提示:</strong>
|
||||
<p>视频理解功能必须使用多模态模型。</p>
|
||||
</div>
|
||||
}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 笔记格式 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="format"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<SectionHeader title="笔记格式" tip="选择要包含的笔记元素" />
|
||||
<CheckboxGroup
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
disabledMap={{
|
||||
link: platform === 'local',
|
||||
screenshot: !videoUnderstandingEnabled,
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 备注 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="extras"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<SectionHeader title="备注" tip="可在 Prompt 结尾附加自定义说明" />
|
||||
<Textarea placeholder="笔记需要罗列出 xxx 关键点…" {...field} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NoteForm
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Form } from '@/components/ui/form.tsx'
|
||||
import NoteForm from './NoteForm.tsx'
|
||||
|
||||
const NoteFormWrapper = () => {
|
||||
const form = useForm()
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<NoteForm />
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export default NoteFormWrapper
|
||||
186
BillNote_frontend/src/pages/HomePage/components/NoteHistory.tsx
Normal file
186
BillNote_frontend/src/pages/HomePage/components/NoteHistory.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useTaskStore } from '@/store/taskStore'
|
||||
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 } from 'react'
|
||||
|
||||
interface NoteHistoryProps {
|
||||
onSelect: (taskId: string) => void
|
||||
selectedId: string | null
|
||||
}
|
||||
|
||||
const NoteHistory: FC<NoteHistoryProps> = ({ onSelect, selectedId }) => {
|
||||
const tasks = useTaskStore(state => state.tasks)
|
||||
const removeTask = useTaskStore(state => state.removeTask)
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL || 'api/'
|
||||
const [rawSearch, setRawSearch] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const fuse = new Fuse(tasks, {
|
||||
keys: ['audioMeta.title'],
|
||||
threshold: 0.4 // 匹配精度(越低越严格)
|
||||
})
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (rawSearch === '') return
|
||||
setSearch(rawSearch)
|
||||
}, 300) // 300ms 防抖
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [rawSearch])
|
||||
const filteredTasks = search.trim()
|
||||
? fuse.search(search).map(result => result.item)
|
||||
: tasks
|
||||
if (filteredTasks.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<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="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
|
||||
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')}
|
||||
|
||||
>
|
||||
{/* 封面图 */}
|
||||
{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="封面"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 标题 + 状态 */}
|
||||
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="line-clamp-2 max-w-[180px] flex-1 overflow-hidden text-sm text-ellipsis">
|
||||
{task.audioMeta.title || '未命名笔记'}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{task.audioMeta.title || '未命名笔记'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'mt-2 flex items-center justify-between text-[10px]'}>
|
||||
<div className="shrink-0">
|
||||
{task.status === 'SUCCESS' && (
|
||||
<div className={'bg-primary w-10 rounded p-0.5 text-center text-white'}>
|
||||
已完成
|
||||
</div>
|
||||
)}
|
||||
{task.status !== 'SUCCESS' && task.status !== 'FAILED' ? (
|
||||
<div className={'w-10 rounded bg-green-500 p-0.5 text-center text-white'}>
|
||||
等待中
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{task.status === 'FAILED' && (
|
||||
<div className={'w-10 rounded bg-red-500 p-0.5 text-center text-white'}>失败</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
removeTask(task.id)
|
||||
}}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Trash className="text-muted-foreground h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>删除</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
{/*<div className="shrink-0">*/}
|
||||
{/* {task.status === 'SUCCESS' && <Badge variant="default">已完成</Badge>}*/}
|
||||
{/* {task.status !== 'SUCCESS' && task.status === 'FAILED' && (*/}
|
||||
{/* <Badge variant="outline">等待中</Badge>*/}
|
||||
{/* )}*/}
|
||||
{/* {task.status === 'FAILED' && <Badge variant="destructive">失败</Badge>}*/}
|
||||
{/*</div>*/}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NoteHistory
|
||||
53
BillNote_frontend/src/pages/HomePage/components/StepBar.tsx
Normal file
53
BillNote_frontend/src/pages/HomePage/components/StepBar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { FC } from 'react'
|
||||
|
||||
interface Step {
|
||||
label: string
|
||||
key: string
|
||||
Icon?: React.ReactNode // 加一个可选的 Lottie 动画
|
||||
}
|
||||
|
||||
interface StepBarProps {
|
||||
steps: Step[]
|
||||
currentStep: string
|
||||
}
|
||||
|
||||
const StepBar: FC<StepBarProps> = ({ steps, currentStep }) => {
|
||||
const currentIndex = steps.findIndex(step => step.key === currentStep)
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{steps.map((step, index) => {
|
||||
const isActive = index <= currentIndex
|
||||
const isCurrent = index === currentIndex
|
||||
const isLast = index === steps.length - 1
|
||||
return (
|
||||
<div key={step.key} className="relative flex flex-1 flex-col items-center">
|
||||
{/* 圆圈或者Lottie */}
|
||||
<div className="relative flex flex-col items-center justify-center">
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold ${
|
||||
isActive ? 'bg-primary text-white' : 'bg-gray-300 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
{/* 当前步骤显示动画 */}
|
||||
{isCurrent && step.Icon && (
|
||||
<div className="absolute top-10 h-16 w-16">{step.Icon}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 步骤名称 */}
|
||||
<div className="mt-4 text-center text-xs text-gray-700">{step.label}</div>
|
||||
|
||||
{/* 连接线 */}
|
||||
|
||||
<div className={`h-1 w-full ${isActive ? 'bg-primary' : 'bg-gray-300'}`}></div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StepBar
|
||||
@@ -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
|
||||
10
BillNote_frontend/src/pages/Index.tsx
Normal file
10
BillNote_frontend/src/pages/Index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
|
||||
const Index = () => {
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default Index
|
||||
25
BillNote_frontend/src/pages/NotFoundPage/index.tsx
Normal file
25
BillNote_frontend/src/pages/NotFoundPage/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
// src/pages/NotFoundPage.tsx
|
||||
import NotFound from '@/components/Lottie/404.tsx'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const NotFoundPage = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full flex-col items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
<h1 className="mb-4 text-4xl font-bold">你好像走丢了哦!~~</h1>
|
||||
<p className="mb-4 text-lg">请检查你的网址是否正确,或者点击下面的按钮返回首页。</p>
|
||||
<Button onClick={() => navigate('/')} className="hover:underline">
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<NotFound />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotFoundPage
|
||||
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
|
||||
68
BillNote_frontend/src/pages/SettingPage/Menu.tsx
Normal file
68
BillNote_frontend/src/pages/SettingPage/Menu.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
BotMessageSquare,
|
||||
SquareChevronRight,
|
||||
Captions,
|
||||
HardDriveDownload,
|
||||
Wrench,
|
||||
Info,
|
||||
} from 'lucide-react'
|
||||
import MenuBar, { IMenuProps } from '@/pages/SettingPage/components/menuBar.tsx'
|
||||
|
||||
const Menu = () => {
|
||||
const menuList: IMenuProps[] = [
|
||||
{
|
||||
id: 'model',
|
||||
name: 'AI 模型设置',
|
||||
icon: <BotMessageSquare />,
|
||||
path: '/settings/model',
|
||||
},
|
||||
// TODO :下一版本升级优化
|
||||
// {
|
||||
// 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: 'about',
|
||||
name: '关于',
|
||||
icon: <Info />,
|
||||
path: '/settings/about',
|
||||
},
|
||||
// {
|
||||
// id: 'other',
|
||||
// name: '其他配置',
|
||||
// icon: <Wrench />,
|
||||
// path: '/settings/other',
|
||||
// },
|
||||
]
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className={'flex w-full flex-col gap-2'}>
|
||||
<div className="text-2xl font-medium">设置</div>
|
||||
<div className="text-sm font-light text-gray-800">全局配置与模型设置</div>
|
||||
</div>
|
||||
<div className="mt-6 flex-1">
|
||||
{menuList &&
|
||||
menuList.map(item => {
|
||||
return <MenuBar key={item.id} menuItem={item} />
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default Menu
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user