From 6831bd48315af96240c5c23a9208f17202ba90d3 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Thu, 1 Oct 2026 15:21:38 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=A3=E5=86=B3=E7=BC=96=E8=BE=91=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dsh-plugins/pet-dock/lib/client.js | 496 -- .dsh-plugins/pet-dock/lib/index.js | 3 - .dsh-plugins/pet-dock/package.json | 19 - .env.example | 15 +- .gitignore | 5 + CHANGELOG_DEPLOY.md | 158 - DATABASE.md | 341 - DEPLOY.md | 330 - DEPLOYEE.md | 17 - QUICKSTART.md | 230 - README.md | 330 +- README_DOCKER.md | 193 - backend/README.md | 218 +- backend/app/api/v1/dashboard.py | 3 +- backend/app/api/v1/files.py | 55 +- backend/app/api/v1/notifications.py | 12 +- backend/app/api/v1/projects.py | 38 +- backend/app/api/v1/shares.py | 3 +- backend/app/core/config.py | 28 +- backend/app/models/__init__.py | 4 + .../app/services/llm_provider_service.py.bak | 794 -- backend/main.py | 7 + backend/pytest.ini | 4 + backend/requirements-dev.txt | 6 + backend/scripts/README.md | 25 + backend/scripts/add_git_columns.sql | 5 - backend/scripts/add_git_repo_sync_path.sql | 3 - backend/scripts/add_role_permissions_menu.py | 103 - backend/scripts/add_role_permissions_menu.sql | 73 - backend/scripts/add_user_role_menus.py | 147 - backend/scripts/create_chat_tables.sql | 33 - .../scripts/create_document_vector_table.sql | 17 - backend/scripts/create_git_repos_table.sql | 14 - backend/scripts/create_mcp_bots_table.sql | 14 - backend/scripts/create_nex_design_project.py | 154 - ...reate_project_vectorization_task_table.sql | 23 - backend/scripts/create_share_links_table.sql | 18 - backend/scripts/fix_role_system_flag.py | 95 - backend/scripts/init_database.sql | 340 - backend/scripts/init_db.py | 6 +- backend/scripts/init_db_pymysql.py | 94 - .../migrate_document_vector_add_chunks.sql | 19 - ...ate_llm_model_config_embedding_options.sql | 8 - .../migrate_llm_model_config_type_config.sql | 46 - backend/scripts/update_system_menu_paths.py | 75 - backend/tests/test_project_permissions.py | 23 + backend/tests/test_security_selfcheck.py | 55 + docker-compose.yml | 5 +- docs/README.md | 41 + .../archive/IMPLEMENTATION_PLAN.md | 0 PROJECT.md => docs/archive/PROJECT.md | 0 docs/archive/README.md | 23 + docs/database.md | 415 ++ docs/deploy/README.md | 177 + docs/deploy/changelog.md | 133 + docs/manual/docus系统使用手册.md | 4 - docs/manual/user-guide.md | 322 + docs/quickstart.md | 162 + docs/sdd/README.md | 20 +- docs/sdd/architecture/constraints.md | 2 +- .../decisions/ADR-0008-compose-v2.md | 4 +- docs/sdd/product/roadmap.md | 2 +- docs/sdd/releases/README.md | 18 +- docs/sdd/releases/v0.9.9.md | 16 +- docs/sdd/releases/v1.0.0.md | 129 + docs/sdd/specs/DV-0001-auth/design.md | 2 +- .../DV-0002-project-filesystem/design.md | 2 +- docs/sdd/specs/DV-0004-rbac-admin/design.md | 2 +- docs/sdd/specs/_template/design.md | 2 +- fix_docker_deployment.sh | 29 - frontend/.eslintrc.cjs | 29 + frontend/package-lock.json | 2767 +++++-- frontend/package.json | 12 +- frontend/src/App.jsx | 122 +- frontend/src/api/chat.js | 2 +- .../ActionHelpPanel/ActionHelpPanel.css | 264 - .../ActionHelpPanel/ActionHelpPanel.jsx | 228 - .../BottomHintBar/BottomHintBar.css | 304 - .../BottomHintBar/BottomHintBar.jsx | 90 - .../ButtonWithGuide/ButtonWithGuide.css | 201 - .../ButtonWithGuide/ButtonWithGuide.jsx | 165 - .../ButtonWithGuideBadge.css | 248 - .../ButtonWithGuideBadge.jsx | 222 - .../ButtonWithHoverCard.css | 194 - .../ButtonWithHoverCard.jsx | 179 - .../ButtonWithTip/ButtonWithTip.css | 163 - .../ButtonWithTip/ButtonWithTip.jsx | 105 - .../src/components/ChartPanel/ChartPanel.css | 17 - .../src/components/ChartPanel/ChartPanel.jsx | 202 - .../ConfirmDialog/ConfirmDialog.jsx | 138 - .../components/DetailDrawer/DetailDrawer.css | 119 - .../components/DetailDrawer/DetailDrawer.jsx | 97 - .../DocFloatActions/DocFloatActions.jsx | 37 - .../ExtendInfoPanel/ExtendInfoPanel.css | 105 - .../ExtendInfoPanel/ExtendInfoPanel.jsx | 68 - .../components/Feedback/FeedbackBridge.jsx | 18 + .../src/components/Feedback/feedbackApi.js | 29 + .../src/components/InfoPanel/InfoPanel.css | 94 - .../src/components/InfoPanel/InfoPanel.jsx | 58 - .../LargeMarkdownEditor.jsx | 3 + .../src/components/MainLayout/AppHeader.css | 6 +- .../src/components/MainLayout/AppHeader.jsx | 28 +- .../src/components/MainLayout/AppSider.css | 96 - .../src/components/MainLayout/AppSider.jsx | 8 +- frontend/src/components/MainLayout/index.js | 4 - .../MarkdownViewer/MarkdownViewer.jsx | 48 - .../ModernSidebar/ModernSidebar.css | 2 +- .../ModernSidebar/ModernSidebar.jsx | 3 +- .../src/components/PDFViewer/PDFViewer.css | 62 - .../src/components/PDFViewer/PDFViewer.jsx | 138 - .../components/PDFViewer/VirtualPDFViewer.css | 4 +- .../src/components/PageHeader/PageHeader.css | 2 +- .../components/PageLoading/PageLoading.css | 20 + .../components/PageLoading/PageLoading.jsx | 21 + .../components/PageTitleBar/PageTitleBar.css | 187 - .../components/PageTitleBar/PageTitleBar.jsx | 53 - .../ProjectFileTree/ProjectFileTree.jsx | 27 +- .../SelectionAlert/SelectionAlert.css | 49 - .../SelectionAlert/SelectionAlert.jsx | 89 - .../SideInfoPanel/SideInfoPanel.css | 88 - .../SideInfoPanel/SideInfoPanel.jsx | 58 - .../components/SplitLayout/SplitLayout.css | 72 - .../components/SplitLayout/SplitLayout.jsx | 69 - frontend/src/components/StatCard/StatCard.css | 4 +- frontend/src/components/StatCard/StatCard.jsx | 8 +- frontend/src/components/Toast/Toast.jsx | 158 +- .../TreeFilterPanel/TreeFilterPanel.css | 58 - .../TreeFilterPanel/TreeFilterPanel.jsx | 119 - frontend/src/data/docsMenuData.json | 105 - frontend/src/data/headerMenuData.json | 8 - frontend/src/data/menuData.json | 17 - frontend/src/index.css | 144 +- frontend/src/pages/Chat/Chat.css | 62 +- frontend/src/pages/Chat/Chat.jsx | 8 +- frontend/src/pages/Constructing.jsx | 2 +- frontend/src/pages/Desktop.css | 4 +- frontend/src/pages/Desktop.jsx | 2 +- .../src/pages/Document/DocumentEditor.css | 108 +- .../src/pages/Document/DocumentEditor.jsx | 339 +- frontend/src/pages/Document/DocumentPage.css | 2 +- frontend/src/pages/Document/DocumentPage.jsx | 112 +- .../pages/Document/documentBrowserUtils.jsx | 145 - frontend/src/pages/Document/editorViewMode.js | 36 + .../src/pages/Document/useDocumentBrowser.js | 255 - .../Document/useDocumentEditorWorkspace.js | 168 - frontend/src/pages/Login/Login.css | 18 +- .../pages/Notifications/NotificationList.jsx | 6 +- frontend/src/pages/Preview/FileSharePage.jsx | 2 +- frontend/src/pages/Preview/PreviewPage.css | 2 +- .../src/pages/Preview/ProjectSharePage.jsx | 5 +- .../src/pages/Preview/previewBrowserUtils.js | 8 - .../src/pages/Preview/usePreviewBrowser.js | 290 - frontend/src/pages/Profile/ProfilePage.css | 6 +- frontend/src/pages/Profile/ProfilePage.jsx | 4 +- .../src/pages/ProjectList/ProjectList.css | 14 +- .../src/pages/ProjectList/ProjectList.jsx | 35 +- .../ProjectList/useProjectCollaboration.js | 127 - .../src/pages/ProjectList/useProjectExport.js | 117 - .../pages/ProjectList/useProjectGitRepos.js | 117 - .../src/pages/ProjectList/useProjectShare.js | 99 - frontend/src/pages/System/ModelConfigs.css | 8 +- frontend/src/pages/System/ModelConfigs.jsx | 2 +- frontend/src/pages/System/Users.jsx | 1 - frontend/src/pages/SystemLogs/SystemLogs.jsx | 2 +- frontend/src/styles/design-tokens.css | 180 + frontend/src/theme/antdTheme.js | 99 + frontend/src/utils/browserIO.js | 56 - frontend/src/utils/markdownToc.js | 91 + frontend/src/utils/request.js | 4 +- frontend/src/utils/treeUtils.js | 54 + frontend/tailwind.config.js | 66 - frontend/vite.config.js | 12 + frontend/yarn.lock | 6567 ----------------- scripts/README.md | 46 + deploy.sh => scripts/deploy.sh | 18 +- scripts/start.sh | 319 + scripts/stop.sh | 36 + 177 files changed, 5652 insertions(+), 18246 deletions(-) delete mode 100644 .dsh-plugins/pet-dock/lib/client.js delete mode 100644 .dsh-plugins/pet-dock/lib/index.js delete mode 100644 .dsh-plugins/pet-dock/package.json delete mode 100644 CHANGELOG_DEPLOY.md delete mode 100644 DATABASE.md delete mode 100644 DEPLOY.md delete mode 100644 DEPLOYEE.md delete mode 100644 QUICKSTART.md delete mode 100644 README_DOCKER.md delete mode 100644 backend/app/services/llm_provider_service.py.bak create mode 100644 backend/pytest.ini create mode 100644 backend/requirements-dev.txt create mode 100644 backend/scripts/README.md delete mode 100644 backend/scripts/add_git_columns.sql delete mode 100644 backend/scripts/add_git_repo_sync_path.sql delete mode 100644 backend/scripts/add_role_permissions_menu.py delete mode 100644 backend/scripts/add_role_permissions_menu.sql delete mode 100644 backend/scripts/add_user_role_menus.py delete mode 100644 backend/scripts/create_chat_tables.sql delete mode 100644 backend/scripts/create_document_vector_table.sql delete mode 100644 backend/scripts/create_git_repos_table.sql delete mode 100644 backend/scripts/create_mcp_bots_table.sql delete mode 100644 backend/scripts/create_nex_design_project.py delete mode 100644 backend/scripts/create_project_vectorization_task_table.sql delete mode 100644 backend/scripts/create_share_links_table.sql delete mode 100644 backend/scripts/fix_role_system_flag.py delete mode 100644 backend/scripts/init_database.sql delete mode 100644 backend/scripts/init_db_pymysql.py delete mode 100644 backend/scripts/migrate_document_vector_add_chunks.sql delete mode 100644 backend/scripts/migrate_llm_model_config_embedding_options.sql delete mode 100644 backend/scripts/migrate_llm_model_config_type_config.sql delete mode 100644 backend/scripts/update_system_menu_paths.py create mode 100644 backend/tests/test_security_selfcheck.py create mode 100644 docs/README.md rename IMPLEMENTATION_PLAN.md => docs/archive/IMPLEMENTATION_PLAN.md (100%) rename PROJECT.md => docs/archive/PROJECT.md (100%) create mode 100644 docs/archive/README.md create mode 100644 docs/database.md create mode 100644 docs/deploy/README.md create mode 100644 docs/deploy/changelog.md delete mode 100644 docs/manual/docus系统使用手册.md create mode 100644 docs/manual/user-guide.md create mode 100644 docs/quickstart.md create mode 100644 docs/sdd/releases/v1.0.0.md delete mode 100644 fix_docker_deployment.sh create mode 100644 frontend/.eslintrc.cjs delete mode 100644 frontend/src/components/ActionHelpPanel/ActionHelpPanel.css delete mode 100644 frontend/src/components/ActionHelpPanel/ActionHelpPanel.jsx delete mode 100644 frontend/src/components/BottomHintBar/BottomHintBar.css delete mode 100644 frontend/src/components/BottomHintBar/BottomHintBar.jsx delete mode 100644 frontend/src/components/ButtonWithGuide/ButtonWithGuide.css delete mode 100644 frontend/src/components/ButtonWithGuide/ButtonWithGuide.jsx delete mode 100644 frontend/src/components/ButtonWithGuideBadge/ButtonWithGuideBadge.css delete mode 100644 frontend/src/components/ButtonWithGuideBadge/ButtonWithGuideBadge.jsx delete mode 100644 frontend/src/components/ButtonWithHoverCard/ButtonWithHoverCard.css delete mode 100644 frontend/src/components/ButtonWithHoverCard/ButtonWithHoverCard.jsx delete mode 100644 frontend/src/components/ButtonWithTip/ButtonWithTip.css delete mode 100644 frontend/src/components/ButtonWithTip/ButtonWithTip.jsx delete mode 100644 frontend/src/components/ChartPanel/ChartPanel.css delete mode 100644 frontend/src/components/ChartPanel/ChartPanel.jsx delete mode 100644 frontend/src/components/ConfirmDialog/ConfirmDialog.jsx delete mode 100644 frontend/src/components/DetailDrawer/DetailDrawer.css delete mode 100644 frontend/src/components/DetailDrawer/DetailDrawer.jsx delete mode 100644 frontend/src/components/DocFloatActions/DocFloatActions.jsx delete mode 100644 frontend/src/components/ExtendInfoPanel/ExtendInfoPanel.css delete mode 100644 frontend/src/components/ExtendInfoPanel/ExtendInfoPanel.jsx create mode 100644 frontend/src/components/Feedback/FeedbackBridge.jsx create mode 100644 frontend/src/components/Feedback/feedbackApi.js delete mode 100644 frontend/src/components/InfoPanel/InfoPanel.css delete mode 100644 frontend/src/components/InfoPanel/InfoPanel.jsx delete mode 100644 frontend/src/components/MainLayout/AppSider.css delete mode 100644 frontend/src/components/MainLayout/index.js delete mode 100644 frontend/src/components/MarkdownViewer/MarkdownViewer.jsx delete mode 100644 frontend/src/components/PDFViewer/PDFViewer.css delete mode 100644 frontend/src/components/PDFViewer/PDFViewer.jsx create mode 100644 frontend/src/components/PageLoading/PageLoading.css create mode 100644 frontend/src/components/PageLoading/PageLoading.jsx delete mode 100644 frontend/src/components/PageTitleBar/PageTitleBar.css delete mode 100644 frontend/src/components/PageTitleBar/PageTitleBar.jsx delete mode 100644 frontend/src/components/SelectionAlert/SelectionAlert.css delete mode 100644 frontend/src/components/SelectionAlert/SelectionAlert.jsx delete mode 100644 frontend/src/components/SideInfoPanel/SideInfoPanel.css delete mode 100644 frontend/src/components/SideInfoPanel/SideInfoPanel.jsx delete mode 100644 frontend/src/components/SplitLayout/SplitLayout.css delete mode 100644 frontend/src/components/SplitLayout/SplitLayout.jsx delete mode 100644 frontend/src/components/TreeFilterPanel/TreeFilterPanel.css delete mode 100644 frontend/src/components/TreeFilterPanel/TreeFilterPanel.jsx delete mode 100644 frontend/src/data/docsMenuData.json delete mode 100644 frontend/src/data/headerMenuData.json delete mode 100644 frontend/src/data/menuData.json delete mode 100644 frontend/src/pages/Document/documentBrowserUtils.jsx create mode 100644 frontend/src/pages/Document/editorViewMode.js delete mode 100644 frontend/src/pages/Document/useDocumentBrowser.js delete mode 100644 frontend/src/pages/Document/useDocumentEditorWorkspace.js delete mode 100644 frontend/src/pages/Preview/previewBrowserUtils.js delete mode 100644 frontend/src/pages/Preview/usePreviewBrowser.js delete mode 100644 frontend/src/pages/ProjectList/useProjectCollaboration.js delete mode 100644 frontend/src/pages/ProjectList/useProjectExport.js delete mode 100644 frontend/src/pages/ProjectList/useProjectGitRepos.js delete mode 100644 frontend/src/pages/ProjectList/useProjectShare.js create mode 100644 frontend/src/styles/design-tokens.css create mode 100644 frontend/src/theme/antdTheme.js delete mode 100644 frontend/src/utils/browserIO.js create mode 100644 frontend/src/utils/markdownToc.js create mode 100644 frontend/src/utils/treeUtils.js delete mode 100644 frontend/tailwind.config.js delete mode 100644 frontend/yarn.lock create mode 100644 scripts/README.md rename deploy.sh => scripts/deploy.sh (93%) create mode 100755 scripts/start.sh create mode 100755 scripts/stop.sh diff --git a/.dsh-plugins/pet-dock/lib/client.js b/.dsh-plugins/pet-dock/lib/client.js deleted file mode 100644 index f1f0ed9..0000000 --- a/.dsh-plugins/pet-dock/lib/client.js +++ /dev/null @@ -1,496 +0,0 @@ -// Native client bundle for @nex/pet-dock -// Registered into shell.overlay + settings.section. -// Plain CJS factory consumed by the browser ModuleLoader; only `react` (a platform seed) is required. -window.__ModuleLoader__.load({ - id: "@nex/pet-dock", - factory: (require) => { - var module = { exports: {} }; - var exports = module.exports; - Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); - let react = require("react"); - - // ---- stylesheet ------------------------------------------------------- - var cssId = "@nex/pet-dock/pet.css"; - function claimStyles() { - if (typeof document === "undefined") return; - if (document.querySelector('style[data-plugin-css="' + cssId + '"]')) return; - var tag = document.createElement("style"); - tag.dataset.plugin = "@nex/pet-dock"; - tag.dataset.pluginCss = cssId; - tag.textContent = css; - document.head.appendChild(tag); - } - - var css = ` - .pet-float { - position: fixed; - top: 50%; - right: 14px; - transform: translateY(-50%); - z-index: 2147483000; - pointer-events: none; - font-family: var(--dsw-alias-font, system-ui, 'Segoe UI', sans-serif); - } - .pet-float * { box-sizing: border-box; } - .pet-outer { - pointer-events: auto; - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 10px; - } - .pet-avatar { - width: 64px; height: 64px; border-radius: 50%; border: none; cursor: pointer; - display: flex; align-items: center; justify-content: center; font-size: 32px; - background: var(--dsw-alias-bg-overlay, #fff); - box-shadow: 0 4px 18px rgba(0,0,0,.18), inset 0 0 0 1px var(--dsw-alias-border-l1, rgba(0,0,0,.08)); - animation: pet-bob 3s ease-in-out infinite; - } - @keyframes pet-bob { 0%,100%{transform:translateY(0);} 50%{transform:translateY(-6px);} } - .pet-window { - width: 220px; - background: var(--dsw-alias-bg-layer-1, #fff); - border-radius: 20px; - box-shadow: 0 18px 50px rgba(0,0,0,.26), inset 0 0 0 1px var(--dsw-alias-border-l1, rgba(0,0,0,.06)); - overflow: hidden; - transform-origin: bottom right; - animation: pet-grow .3s cubic-bezier(.34,1.45,.64,1) both; - } - @keyframes pet-grow { 0%{opacity:0;transform:scale(.7) translateX(12px);} 100%{opacity:1;transform:scale(1) translateX(0);} } - .pet-head { display:flex; align-items:center; justify-content:space-between; padding:10px 12px 4px; } - .pet-name { font-weight:700; font-size:14px; color:var(--dsw-alias-label-primary,#222); } - .pet-name small { font-weight:500; color:var(--dsw-alias-label-secondary,#888); margin-left:6px; } - .pet-stage { height: 176px; display:flex; align-items:center; justify-content:space-between; padding:0 6px; position:relative; } - .pet-stage .stage-ground { position:absolute; bottom:12px; left:24%; right:24%; height:9px; border-radius:50%; background:radial-gradient(ellipse at center, rgba(0,0,0,.10), transparent 70%); } - .pet-stage .figure-wrap { position:absolute; left:0; right:0; top:0; bottom:0; display:flex; align-items:flex-end; justify-content:center; padding-bottom:18px; cursor:pointer; } - .pet-stage .figure-wrap:hover .pet-figure { transform: translateY(-6px) scale(1.06); } - .pet-stage .figure-wrap .tap-tip { position:absolute; top:4px; left:0; right:0; text-align:center; font-size:10px; color:var(--dsw-alias-label-secondary,#888); opacity:.75; pointer-events:none; } - .pet-arrow { - position:relative; z-index:2; width:30px; height:30px; border-radius:50%; - border:1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08)); - background:var(--dsw-alias-bg-overlay,#fff); color:var(--dsw-alias-label-secondary,#888); - font-size:16px; line-height:1; cursor:pointer; display:flex; align-items:center; justify-content:center; - } - .pet-arrow:hover { background:var(--dsw-alias-bg-layer-2,#f3f4f8); color:var(--dsw-alias-brand-primary,#6c5ce7); transform:scale(1.1); } - .pet-status { text-align:center; font-size:11px; color:var(--dsw-alias-label-secondary,#888); padding:0 8px 10px; } - .pet-hint { text-align:center; font-size:10px; color:var(--dsw-alias-label-secondary,#888); padding:0 8px 8px; opacity:.7; } - .pet-figure { position:relative; transition:transform .25s ease; animation:body-bob 2.6s ease-in-out infinite; filter:drop-shadow(0 8px 10px rgba(0,0,0,.14)); } - @keyframes body-bob { 0%,100%{transform:translateY(0);} 50%{transform:translateY(-7px);} } - .p-eye { position:absolute; width:11px; height:11px; background:#222; border-radius:50%; animation:blink 4.2s steps(1) infinite; } - .p-eye::after { content:''; position:absolute; left:2px; top:1px; width:4px; height:4px; background:#fff; border-radius:50%; } - .p-eye.p-eyeB { background:#1e7a4d; } - @keyframes blink { 0%,88%,100%{transform:scaleY(1);} 92%,96%{transform:scaleY(.1);} } - @keyframes tail-wag { 0%,100%{transform:rotate(0);} 50%{transform:rotate(-16deg);} } - @keyframes dog-wag { 0%,100%{transform:rotate(-12deg);} 50%{transform:rotate(16deg);} } - @keyframes ear-flop { 0%,100%{transform:rotate(8deg);} 50%{transform:rotate(3deg);} } - @keyframes ear-flop-r { 0%,100%{transform:rotate(-8deg);} 50%{transform:rotate(-3deg);} } - .cow { position:relative; width:118px; height:122px; transform:scale(1.02); } - .cow-head { position:absolute; left:14px; top:14px; width:90px; height:82px; background:#fdfdfb; border-radius:46% 46% 40% 40%; box-shadow:inset 0 -10px 18px rgba(0,0,0,.05); } - .cow-patch { position:absolute; background:#3a3a42; border-radius:50%; } - .cow-patch.p1 { left:24px; top:5px; width:34px; height:22px; transform:rotate(12deg); } - .cow-patch.p2 { left:6px; top:34px; width:24px; height:17px; transform:rotate(-18deg); } - .cow-patch.p3 { right:12px; top:10px; width:17px; height:21px; transform:rotate(24deg); } - .cow-patch.p4 { right:2px; top:46px; width:20px; height:14px; background:#cfcfe0; transform:rotate(10deg); } - .cow-patch.p5 { left:9px; bottom:4px; width:17px; height:22px; background:#cfcfe0; transform:rotate(-22deg); } - .cow-ear { position:absolute; top:18px; width:24px; height:38px; background:#f0ede4; border-radius:50%; } - .cow-ear.l { left:1px; transform:rotate(-18deg); } - .cow-ear.r { right:1px; transform:rotate(18deg); } - .cow-ear::after { content:''; position:absolute; inset:6px 5px; background:#e8b7b0; border-radius:50%; } - .cow-horn { position:absolute; top:3px; width:0; height:0; border-left:9px solid transparent; border-right:9px solid transparent; border-bottom:19px solid #e4b983; } - .cow-horn.l { left:22px; transform:rotate(-24deg); } - .cow-horn.r { right:22px; transform:rotate(24deg); } - .cow-eye.l { left:20px; top:40px; z-index:2; } - .cow-eye.r { right:16px; top:42px; z-index:2; } - .cow-muzzle { position:absolute; left:25px; bottom:7px; width:40px; height:28px; background:#f2b6ad; border-radius:50%; } - .cow-muzzle::before, .cow-muzzle::after { content:''; position:absolute; top:8px; width:7px; height:9px; background:#d9837a; border-radius:50%; } - .cow-muzzle::before { left:8px; } .cow-muzzle::after { right:8px; } - .cow-body { position:absolute; left:14px; bottom:6px; width:90px; height:42px; background:#fdfdfb; border-radius:50% 50% 44% 44%; } - .cow-spot { position:absolute; background:#3a3a42; border-radius:50%; } - .cow-spot.s1 { left:2px; top:3px; width:21px; height:15px; transform:rotate(-14deg); } - .cow-spot.s2 { left:26px; top:2px; width:24px; height:15px; transform:rotate(8deg); } - .cow-spot.s3 { right:3px; top:8px; width:16px; height:15px; transform:rotate(-24deg); } - .cow-spot.s4 { left:11px; bottom:1px; width:15px; height:17px; background:#cfcfe0; transform:rotate(18deg); } - .cow-spot.s5 { right:13px; bottom:1px; width:17px; height:15px; background:#cfcfe0; transform:rotate(-8deg); } - .cow-leg { position:absolute; bottom:-8px; width:13px; height:21px; background:#efe7da; border-radius:4px; } - .cow-leg.l1 { left:16px; } .cow-leg.l2 { left:42px; } - .cow-leg.r1 { left:68px; } .cow-leg.r2 { left:92px; } - .cow-tail { position:absolute; right:2px; bottom:10px; width:34px; height:9px; border-radius:8px; background:#3a3a42; transform-origin:left center; animation:tail-wag 1.8s ease-in-out infinite; } - .cat { position:relative; width:112px; height:122px; transform:scale(1.04); } - .cat-head { position:absolute; left:16px; top:14px; width:80px; height:78px; background:linear-gradient(160deg,#ffb34d,#f79a35); border-radius:48% 48% 44% 44%; } - .cat-ear { position:absolute; top:0; width:0; height:0; border-left:15px solid transparent; border-right:15px solid transparent; border-bottom:28px solid #f79a35; } - .cat-ear.l { left:20px; transform:rotate(-14deg); } - .cat-ear.r { right:20px; transform:rotate(14deg); } - .cat-ear::after { content:''; position:absolute; left:-9px; top:11px; width:0; height:0; border-left:9px solid transparent; border-right:9px solid transparent; border-bottom:17px solid #ffc9d4; } - .cat-stripe { position:absolute; background:#e07f1f; border-radius:8px; } - .cat-stripe.s1 { left:24px; top:12px; width:7px; height:17px; transform:rotate(8deg); } - .cat-stripe.s2 { left:36px; top:9px; width:7px; height:19px; } - .cat-stripe.s3 { right:24px; top:12px; width:7px; height:17px; transform:rotate(-8deg); } - .cat-eye.l { left:22px; top:40px; background:#1e7a4d; } - .cat-eye.r { right:22px; top:40px; background:#1e7a4d; } - .cat-muzzle { position:absolute; left:29px; top:58px; width:22px; height:18px; background:#ffe9c9; border-radius:50%; } - .cat-nose { position:absolute; left:40px; top:52px; width:11px; height:7px; background:#f176a8; border-radius:50%; } - .cat-whisker { position:absolute; top:60px; width:20px; height:2px; background:rgba(0,0,0,.35); border-radius:2px; } - .cat-whisker.w1 { left:1px; transform:rotate(10deg); } - .cat-whisker.w2 { left:12px; top:68px; transform:rotate(-6deg); } - .cat-whisker.w3 { right:1px; transform:rotate(-10deg); } - .cat-whisker.w4 { right:12px; top:68px; transform:rotate(6deg); } - .cat-body { position:absolute; left:22px; bottom:7px; width:68px; height:42px; background:linear-gradient(160deg,#ffb34d,#f79a35); border-radius:48% 48% 42% 42%; } - .cat-belly { position:absolute; left:33px; bottom:7px; width:47px; height:36px; background:#fff3df; border-radius:48% 48% 42% 42%; } - .cat-paw { position:absolute; bottom:-5px; width:16px; height:13px; background:#f79a35; border-radius:50%; } - .cat-paw.p1 { left:27px; } .cat-paw.p2 { left:69px; } - .cat-tail { position:absolute; right:4px; bottom:8px; width:30px; height:11px; background:linear-gradient(180deg,#ffb34d 0%,#d9782b 100%); border-radius:8px 6px 6px 8px; transform-origin:left bottom; transform:rotate(-14deg); animation:tail-wag 2.2s ease-in-out infinite; } - .dog { position:relative; width:112px; height:124px; transform:scale(1.04); } - .dog-head { position:absolute; left:16px; top:16px; width:80px; height:76px; background:linear-gradient(160deg,#eebc83,#d99c5b); border-radius:48% 48% 44% 44%; } - .dog-ear { position:absolute; width:24px; height:36px; background:linear-gradient(180deg,#b9783d,#9c6230); border-radius:50% 50% 18px 18px; } - .dog-ear.l { left:6px; top:4px; transform-origin: top center; animation:ear-flop 2.6s ease-in-out infinite; } - .dog-ear.r { right:6px; top:4px; transform-origin: top center; animation:ear-flop-r 2.6s ease-in-out infinite; } - .dog-ear::after { content:''; position:absolute; left:7px; top:12px; width:10px; height:16px; background:#e8b6a0; border-radius:50%; } - .dog-snout { position:absolute; left:27px; top:56px; width:26px; height:18px; background:#f6e6cf; border-radius:50%; } - .dog-nose { position:absolute; left:39px; top:52px; width:13px; height:10px; background:#3a3028; border-radius:50%; } - .dog-mouth { position:absolute; left:37px; top:62px; width:16px; height:8px; border-bottom:2.5px solid #8a5a3a; border-radius:0 0 50% 50%; } - .dog-eye.l { left:30px; top:38px; } - .dog-eye.r { right:30px; top:38px; } - .dog-blush { position:absolute; top:50px; width:13px; height:8px; background:rgba(255,140,160,.5); border-radius:50%; } - .dog-blush.l { left:22px; } .dog-blush.r { right:22px; } - .dog-body { position:absolute; left:22px; bottom:7px; width:68px; height:44px; background:linear-gradient(160deg,#eebc83,#d99c5b); border-radius:48% 48% 42% 42%; } - .dog-belly { position:absolute; left:32px; bottom:7px; width:48px; height:36px; background:#f6e6cf; border-radius:48% 48% 42% 42%; } - .dog-paw { position:absolute; bottom:-5px; width:17px; height:14px; background:#d99c5b; border-radius:50%; } - .dog-paw.p1 { left:26px; } .dog-paw.p2 { left:69px; } - .dog-tail { position:absolute; right:3px; bottom:8px; width:30px; height:12px; background:linear-gradient(180deg,#eebc83 0%,#c08145 100%); border-radius:6px 7px 7px 6px; transform-origin:left bottom; transform:rotate(18deg); animation:dog-wag .8s ease-in-out infinite; } - .dog-tag { position:absolute; left:44px; bottom:16px; width:10px; height:10px; background:#ffd76a; border-radius:50%; box-shadow:0 0 0 2px #e6b84d; } - .pet-center { position:fixed; inset:0; z-index:2147483999; display:flex; align-items:center; justify-content:center; background:radial-gradient(120% 120% at 50% 40%, rgba(0,0,0,.10), transparent 70%); } - .pet-center .big-figure { cursor:pointer; filter:drop-shadow(0 16px 34px rgba(0,0,0,.28)); animation:center-in .55s cubic-bezier(.3,1.6,.5,1) both; } - .pet-center .big-figure.playing { animation:center-in .55s cubic-bezier(.3,1.6,.5,1) both, happy-dance 1.3s ease-in-out .4s infinite; } - .pet-center .big-figure.leaving { animation:center-out .55s cubic-bezier(.6,-0.1,.9,1) both; } - .pet-center .big-scale { transform:scale(calc(50vmin / 150px)); transform-origin:center; } - .pet-center .center-hint { position:absolute; bottom:14%; text-align:center; font-size:clamp(13px, 2.2vmin, 22px); font-weight:600; color:var(--dsw-alias-label-primary,#222); animation:hint-in .4s ease both; } - @keyframes center-in { 0%{opacity:0;transform:scale(.1);} 60%{transform:scale(1.15);} 100%{opacity:1;transform:scale(1);} } - @keyframes happy-dance { 0%,100%{transform:translateY(0) rotate(0) scale(1);} 25%{transform:translateY(-4%) rotate(-5deg) scale(1.02);} 50%{transform:translateY(0) rotate(0) scale(1);} 75%{transform:translateY(-4%) rotate(5deg) scale(1.02);} } - @keyframes center-out { 0%{opacity:1;transform:scale(1);} 100%{opacity:0;transform:scale(.06) translate(60vw, 0);} } - @keyframes hint-in { 0%{opacity:0;transform:translateY(8px);} 100%{opacity:1;transform:translateY(0);} } - /* ---- settings page ---- */ - .pet-settings { display:flex; flex-direction:column; gap:12px; padding:4px 2px; } - .pet-settings-title { margin:0; font-size:16px; font-weight:600; color:var(--dsw-alias-label-primary,#222); } - .pet-settings-desc { margin:0; font-size:12px; color:var(--dsw-alias-label-secondary,#888); } - .pet-settings-row { - display:flex; align-items:center; gap:12px; padding:10px 12px; - border:1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08)); - border-radius:12px; background:var(--dsw-alias-bg-layer-2,#f7f7fa); - } - .pet-settings-row.off { opacity:.55; } - .pet-settings-emoji { font-size:26px; flex:none; width:36px; text-align:center; } - .pet-settings-name { - flex:1; min-width:0; height:32px; border-radius:8px; - border:1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.14)); - background:var(--dsw-alias-bg-overlay,#fff); color:var(--dsw-alias-label-primary,#222); - padding:0 10px; font-size:13px; outline:none; - } - .pet-settings-name:focus { border-color:var(--dsw-alias-brand-primary,#6c5ce7); } - .pet-settings-name:disabled { background:var(--dsw-alias-bg-layer-2,#f0f0f4); color:var(--dsw-alias-label-secondary,#999); } - .pet-settings-switch { display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--dsw-alias-label-secondary,#888); white-space:nowrap; } - .pet-settings-switch input { width:15px; height:15px; accent-color:var(--dsw-alias-brand-primary,#6c5ce7); cursor:pointer; } - `; - - // ---- data --------------------------------------------------------------- - var STORAGE_KEY = 'pet-dock:config'; - var DEFAULT_PETS = [ - { kind: 'cow', name: '哞哞', emoji: '🐮', sub: '奶牛' }, - { kind: 'cat', name: '喵喵', emoji: '🐱', sub: '小猫' }, - { kind: 'dog', name: '汪汪', emoji: '🐶', sub: '小狗' }, - ]; - - function defaultConfig() { - var cfg = { enabled: {}, names: {} }; - DEFAULT_PETS.forEach(function (p) { cfg.enabled[p.kind] = true; cfg.names[p.kind] = p.name; }); - return cfg; - } - function loadConfig() { - try { - var raw = localStorage.getItem(STORAGE_KEY); - if (raw) { - var parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') return parsed; - } - } catch (e) {} - return defaultConfig(); - } - function saveConfig(cfg) { - try { localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg)); } catch (e) {} - } - function activePets() { - var cfg = loadConfig(); - return DEFAULT_PETS.filter(function (p) { return cfg.enabled && cfg.enabled[p.kind] !== false; }) - .map(function (p) { - var name = (cfg.names && cfg.names[p.kind]) || p.name; - return { kind: p.kind, name: name, emoji: p.emoji, sub: p.sub }; - }); - } - - // ---- shared timing helper (browser-native; avoids service timing issues) --- - function later(fn, ms) { - var id = setTimeout(fn, ms); - return function () { clearTimeout(id); }; - } - - function PetFigure(_a) { - var pet = _a.pet; - var el = react.createElement; - if (pet.kind === 'cow') { - return el('div', { className: 'cow' }, - el('div', { className: 'cow-tail' }), - el('div', { className: 'cow-body' }, - el('div', { className: 'cow-spot s1' }), - el('div', { className: 'cow-spot s2' }), - el('div', { className: 'cow-spot s3' }), - el('div', { className: 'cow-spot s4' }), - el('div', { className: 'cow-spot s5' })), - el('div', { className: 'cow-leg l1' }), - el('div', { className: 'cow-leg l2' }), - el('div', { className: 'cow-leg r1' }), - el('div', { className: 'cow-leg r2' }), - el('div', { className: 'cow-horn l' }), - el('div', { className: 'cow-horn r' }), - el('div', { className: 'cow-ear l' }), - el('div', { className: 'cow-ear r' }), - el('div', { className: 'cow-head' }, - el('div', { className: 'cow-patch p1' }), - el('div', { className: 'cow-patch p2' }), - el('div', { className: 'cow-patch p3' }), - el('div', { className: 'cow-patch p4' }), - el('div', { className: 'cow-patch p5' }), - el('div', { className: 'cow-muzzle' }), - el('div', { className: 'p-eye cow-eye l' }), - el('div', { className: 'p-eye cow-eye r' }))); - } - if (pet.kind === 'cat') { - return el('div', { className: 'cat' }, - el('div', { className: 'cat-tail' }), - el('div', { className: 'cat-body' }), - el('div', { className: 'cat-belly' }), - el('div', { className: 'cat-paw p1' }), - el('div', { className: 'cat-paw p2' }), - el('div', { className: 'cat-ear l' }), - el('div', { className: 'cat-ear r' }), - el('div', { className: 'cat-head' }, - el('div', { className: 'cat-stripe s1' }), - el('div', { className: 'cat-stripe s2' }), - el('div', { className: 'cat-stripe s3' }), - el('div', { className: 'p-eye cat-eye l' }), - el('div', { className: 'p-eye cat-eye r' }), - el('div', { className: 'cat-muzzle' }), - el('div', { className: 'cat-nose' }), - el('div', { className: 'cat-whisker w1' }), - el('div', { className: 'cat-whisker w2' }), - el('div', { className: 'cat-whisker w3' }), - el('div', { className: 'cat-whisker w4' }))); - } - return el('div', { className: 'dog' }, - el('div', { className: 'dog-tail' }), - el('div', { className: 'dog-body' }), - el('div', { className: 'dog-belly' }), - el('div', { className: 'dog-paw p1' }), - el('div', { className: 'dog-paw p2' }), - el('div', { className: 'dog-ear l' }), - el('div', { className: 'dog-ear r' }), - el('div', { className: 'dog-tag' }), - el('div', { className: 'dog-head' }, - el('div', { className: 'p-eye dog-eye l' }), - el('div', { className: 'p-eye dog-eye r' }), - el('div', { className: 'dog-blush l' }), - el('div', { className: 'dog-blush r' }), - el('div', { className: 'dog-snout' }), - el('div', { className: 'dog-nose' }), - el('div', { className: 'dog-mouth' }))); - } - - function PetWindow(props) { - var el = react.createElement; - var pet = props.pet, onPrev = props.onPrev, onNext = props.onNext, onPlay = props.onPlay, disabled = props.disabled; - return el('div', { className: 'pet-window' }, - el('div', { className: 'pet-head' }, - el('div', { className: 'pet-name' }, - pet.emoji + ' ' + pet.name, - el('small', null, pet.sub))), - el('div', { className: 'pet-stage' }, - el('div', { className: 'stage-ground' }), - el('div', { className: 'figure-wrap', onClick: onPlay, title: '点击互动' }, - el('div', { className: 'tap-tip' }, '点击我互动 ✨'), - el('div', { className: 'pet-figure' }, el(PetFigure, { pet: pet }))), - el('button', { className: 'pet-arrow', onClick: onPrev, title: '上一个', disabled: disabled }, '‹'), - el('button', { className: 'pet-arrow', onClick: onNext, title: '下一个', disabled: disabled }, '›')), - el('div', { className: 'pet-status' }, '正在努力陪着你工作…'), - el('div', { className: 'pet-hint' }, '点击宠物,放大到屏幕中央互动')); - } - - function CenterReveal(props) { - var el = react.createElement; - var pet = props.pet, leaving = props.leaving, onExit = props.onExit; - return el('div', { className: 'pet-center', onClick: onExit }, - el('div', { - className: 'big-figure' + (leaving ? ' leaving' : ' playing'), - onClick: onExit - }, - el('div', { className: 'big-scale' }, el(PetFigure, { pet: pet }))), - el('div', { className: 'center-hint' }, - leaving ? '回到窗口…' : pet.emoji + ' ' + pet.name + ' 来找你玩啦!点击返回')); - } - - // ---- settings page ------------------------------------------------------ - function SettingsView() { - var el = react.createElement; - var useState = react.useState; - var _a = useState(0), tick = _a[0], setTick = _a[1]; - var useEffect = react.useEffect; - useEffect(function () { - function onChange() { setTick(function (t) { return t + 1; }); } - if (typeof window !== 'undefined' && window.addEventListener) { - window.addEventListener('pet-dock:config', onChange); - return function () { window.removeEventListener('pet-dock:config', onChange); }; - } - }, []); - var cfg = loadConfig(); - return el('div', { className: 'pet-settings' }, - el('h3', { className: 'pet-settings-title' }, 'PetDock 宠物'), - el('p', { className: 'pet-settings-desc' }, '勾选要启用的形象,并为每只宠物自定义名字。'), - DEFAULT_PETS.map(function (p) { - var enabled = cfg.enabled && cfg.enabled[p.kind] !== false; - var name = (cfg.names && cfg.names[p.kind]) || p.name; - return el('div', { className: 'pet-settings-row' + (enabled ? '' : ' off'), key: p.kind }, - el('span', { className: 'pet-settings-emoji' }, p.emoji), - el('input', { - className: 'pet-settings-name', - value: name, - disabled: !enabled, - placeholder: p.name, - onChange: function (e) { - var next = loadConfig(); - next.names = next.names || {}; - next.names[p.kind] = e.target.value; - saveConfig(next); - notifyConfig(); - } - }), - el('label', { className: 'pet-settings-switch' }, - el('input', { - type: 'checkbox', - checked: enabled, - onChange: function (e) { - var next = loadConfig(); - next.enabled = next.enabled || {}; - next.enabled[p.kind] = !!e.target.checked; - saveConfig(next); - notifyConfig(); - } - }), - el('span', null, enabled ? '已启用' : '已停用'))); - })); - } - - function notifyConfig() { - if (typeof window !== 'undefined' && window.dispatchEvent) { - try { window.dispatchEvent(new Event('pet-dock:config')); } catch (e) {} - } - } - - function apply(ctx) { - claimStyles(); - var slots = ctx.slots; - - slots.inject('settings.section', function () { - return slots.register( - { name: 'settings.section', id: 'pet-dock', order: 50, label: 'PetDock' }, - SettingsView); - }); - - slots.inject('shell.overlay', function () { - return slots.register( - { name: 'shell.overlay', id: 'pet-dock' }, - function () { - var useState = react.useState; - var useEffect = react.useEffect; - var _a = useState(0), index = _a[0], setIndex = _a[1]; - var _b = useState(false), open = _b[0], setOpen = _b[1]; - var _c = useState(false), switching = _c[0], setSwitching = _c[1]; - var _d = useState(false), playing = _d[0], setPlaying = _d[1]; - var _e = useState(false), leaving = _e[0], setLeaving = _e[1]; - var _f = useState(0), tick = _f[0], setTick = _f[1]; - - useEffect(function () { - function onChange() { setTick(function (t) { return t + 1; }); } - if (typeof window !== 'undefined' && window.addEventListener) { - window.addEventListener('pet-dock:config', onChange); - return function () { window.removeEventListener('pet-dock:config', onChange); }; - } - }, []); - - var pets = activePets(); - var safeIndex = pets.length === 0 ? 0 : (index >= pets.length ? 0 : index); - var pet = pets.length === 0 ? null : pets[safeIndex]; - var single = pets.length <= 1; - - var cycle = function (dir) { - if (playing || pets.length === 0) return; - setSwitching(true); - later(function () { - setIndex(function (i) { - if (pets.length === 0) return 0; - var base = i >= pets.length ? 0 : i; - return (base + dir + pets.length) % pets.length; - }); - setSwitching(false); - setOpen(true); - }, 180); - }; - - var play = function () { - if (!pet || playing) return; - setPlaying(true); - setLeaving(false); - later(function () { - setLeaving(true); - later(function () { - setPlaying(false); - setLeaving(false); - setOpen(false); - }, 560); - }, 1900); - }; - - var exit = function () { - if (!playing) return; - setLeaving(true); - later(function () { - setPlaying(false); - setLeaving(false); - setOpen(false); - }, 560); - }; - - var el = react.createElement; - var dock = null; - if (pet) { - dock = el('div', { - className: 'pet-float', - onMouseEnter: function () { if (!playing) setOpen(true); }, - onMouseLeave: function () { if (!playing) setOpen(false); } - }, - el('div', { className: 'pet-outer' }, - open - ? el(PetWindow, { pet: pet, switching: switching, disabled: single, onPrev: function () { return cycle(-1); }, onNext: function () { return cycle(1); }, onPlay: play }) - : el('button', { className: 'pet-avatar', title: pet.name }, pet.emoji))); - } - - var center = playing && pet - ? el(CenterReveal, { pet: pet, leaving: leaving, onExit: exit }) - : null; - - return el(react.Fragment, null, dock, center); - }); - }); - } - - var inject = ["slots"]; - module.exports = { apply: apply, inject: inject }; - return module.exports; - } -}); diff --git a/.dsh-plugins/pet-dock/lib/index.js b/.dsh-plugins/pet-dock/lib/index.js deleted file mode 100644 index 3b39ba9..0000000 --- a/.dsh-plugins/pet-dock/lib/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export function apply(ctx) { - // Host half is intentionally empty: this plugin only contributes browser UI. -} diff --git a/.dsh-plugins/pet-dock/package.json b/.dsh-plugins/pet-dock/package.json deleted file mode 100644 index 4bcd341..0000000 --- a/.dsh-plugins/pet-dock/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@nex/pet-dock", - "version": "0.1.0", - "description": "Digital pets dock: cow, cat & dog pets at the right-middle edge with click-to-center interaction.", - "type": "module", - "main": "lib/index.js", - "exports": { - ".": "./lib/index.js", - "./client": "./lib/client.js", - "./package.json": "./package.json" - }, - "dsh": { - "client": { - "platform": "web", - "immediately": true - } - }, - "license": "MIT" -} diff --git a/.env.example b/.env.example index 6c5265b..148d4ca 100644 --- a/.env.example +++ b/.env.example @@ -33,11 +33,10 @@ BACKEND_PORT=8000 FRONTEND_PORT=8080 # ==================== 前端配置 ==================== -# API 基础 URL(根据实际部署方式选择) -# 方式1(推荐):使用 Nginx 反向代理,前后端同域名同端口 -VITE_API_BASE_URL=/api -# 方式2:直接访问后端端口(需开放后端端口,可能有跨域问题) -# VITE_API_BASE_URL=http://yourdomain.com:8001 +# 前端始终请求同源相对路径 /api/v1: +# - 开发环境由 vite 的 server.proxy 转发到后端 +# - Docker 部署由前端容器内的 Nginx 反代(见 frontend/nginx.conf,上游为 compose 服务 backend) +# 因此无需配置前端侧的 API 地址;如需跨域直连后端,请自行修改 frontend/nginx.conf 或部署自己的网关。 # ==================== 存储配置 ==================== # 文件存储路径(宿主机路径,用于存储项目文档和上传文件) @@ -47,8 +46,12 @@ STORAGE_PATH=./storage # 初始管理员账号信息 ADMIN_USERNAME=admin ADMIN_PASSWORD=Admin@123456 -ADMIN_EMAIL=admin@unisspace.com +# 首次初始化时创建的管理员邮箱(占位值,部署后请在「个人设置」中改为真实邮箱) +ADMIN_EMAIL=admin@example.com ADMIN_NICKNAME=系统管理员 + +# 管理员在系统里新建用户时的初始密码(务必改为随机值;部署后立即修改管理员密码) +DEFAULT_USER_PASSWORD=User@123456 # 仅当 LLM/Embedding 服务使用自签名证书时开启(生产环境保持 false) DISABLE_SSL_VERIFY=false diff --git a/.gitignore b/.gitignore index 7c8cf6a..441c3c6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ Thumbs.db # Project storage (user uploaded files) storage/ backup/ +backups/ # Documentation files (可能是临时的) *.md.backup @@ -32,8 +33,12 @@ logs/ *.tmp *.temp +# 本地运行产物(scripts/start.sh 生成的 pid / 日志) +.run/ + # Local models backend/models # AI .gemini-clipboard/ +.claude/ diff --git a/CHANGELOG_DEPLOY.md b/CHANGELOG_DEPLOY.md deleted file mode 100644 index 494c0c9..0000000 --- a/CHANGELOG_DEPLOY.md +++ /dev/null @@ -1,158 +0,0 @@ -# 部署配置更新日志 - -> ⚠️ 版本对齐:git 发布线当前为 v0.9.9(无 v1.0.x tag),下列 v1.0.1 为早期文案占位。详见 SDD `docs/sdd/releases/`。 - -## v1.0.1 (2024-12-23,遗留占位) - -### 🔧 配置变更 - -#### 1. 前端端口调整 -- **变更**: 前端默认端口从 `80` 改为 `8080` -- **原因**: 避免与常见服务冲突,提高兼容性 -- **影响**: - - 访问地址变更为: `http://localhost:8080` - - 可在 `.env` 中配置 `FRONTEND_PORT` 自定义端口 - -#### 2. 存储目录映射优化 -- **变更**: Storage 目录从 Docker Volume 改为宿主机目录映射 -- **配置项**: 新增环境变量 `STORAGE_PATH` - - 默认值: `./storage` - - 支持相对路径和绝对路径 - - 示例: - ```bash - STORAGE_PATH=./storage # 相对路径 - STORAGE_PATH=/data/nex-docus-data # 绝对路径 - ``` -- **优势**: - - ✅ 便于直接访问和管理文件 - - ✅ 方便备份和迁移 - - ✅ 支持挂载到独立磁盘或网络存储 - - ✅ 数据独立于容器生命周期 - -### 📝 配置文件更新 - -已更新以下文件: -- `.env.example` - 添加 `STORAGE_PATH` 配置,修改 `FRONTEND_PORT` 默认值 -- `docker-compose.yml` - 修改 storage 为宿主机目录映射 -- `deploy.sh` - 更新访问信息显示 -- `DEPLOY.md` - 更新部署文档 - -### 🔄 迁移指南 - -如果您已经部署了旧版本,需要进行以下操作: - -#### 方案 1: 保留现有数据(推荐) - -```bash -# 1. 停止服务 -./deploy.sh stop - -# 2. 备份现有数据 -docker run --rm -v nex-docus_storage_data:/from -v $(pwd)/storage:/to alpine sh -c "cd /from && cp -r . /to" - -# 3. 更新配置文件 -cp .env.example .env -vim .env # 配置 STORAGE_PATH=./storage - -# 4. 重新启动 -./deploy.sh start - -# 5. 删除旧的 volume(可选) -docker volume rm nex-docus_storage_data -``` - -#### 方案 2: 全新部署 - -```bash -# 1. 备份数据库 -./deploy.sh backup - -# 2. 完全卸载 -./deploy.sh uninstall - -# 3. 重新初始化 -./deploy.sh init - -# 4. 恢复数据库(如需要) -./deploy.sh restore -``` - -### 📊 配置示例 - -#### 开发环境配置 -```bash -FRONTEND_PORT=8080 -BACKEND_PORT=8000 -STORAGE_PATH=./storage -DEBUG=true -``` - -#### 生产环境配置 -```bash -FRONTEND_PORT=80 -BACKEND_PORT=8000 -STORAGE_PATH=/data/nex-docus-storage -DEBUG=false -``` - -#### 多实例部署配置 -```bash -# 实例 1 -FRONTEND_PORT=8081 -BACKEND_PORT=8001 -STORAGE_PATH=/data/instance1/storage - -# 实例 2 -FRONTEND_PORT=8082 -BACKEND_PORT=8002 -STORAGE_PATH=/data/instance2/storage -``` - -### ⚙️ 存储路径说明 - -`STORAGE_PATH` 目录结构: -``` -storage/ -├── projects/ # 项目文档存储 -│ ├── / # 项目 1 -│ │ ├── README.md -│ │ ├── docs/ -│ │ └── _assets/ # 项目资源 -│ └── / # 项目 2 -└── temp/ # 临时文件 -``` - -### 🔒 安全建议 - -1. **权限设置** - ```bash - # 设置适当的目录权限 - chmod 755 storage - chown -R 1000:1000 storage # Docker 容器内用户 - ``` - -2. **备份策略** - ```bash - # 定时备份 storage 目录 - tar -czf storage_backup_$(date +%Y%m%d).tar.gz storage/ - ``` - -3. **网络存储** - ```bash - # 挂载 NFS - mount -t nfs server:/share /data/nex-docus-storage - - # 配置 .env - STORAGE_PATH=/data/nex-docus-storage - ``` - -### 📞 支持 - -如有问题,请查看: -- [部署文档](DEPLOY.md) -- [项目文档](README_DOCKER.md) -- 或提交 Issue - ---- - -**更新时间**: 2024-12-23 diff --git a/DATABASE.md b/DATABASE.md deleted file mode 100644 index 2e23167..0000000 --- a/DATABASE.md +++ /dev/null @@ -1,341 +0,0 @@ -# NEX Docus 数据库设计文档 - -## 数据库连接信息 -- **数据库类型**: MySQL 5.7.5+ -- **连接地址**: 10.100.51.51:3306 -- **数据库名**: nex_docus -- **字符集**: utf8mb4 -- **排序规则**: utf8mb4_unicode_ci - -## Redis 缓存 -- **连接地址**: 10.100.51.51:6379 -- **用途**: Session 存储、Token 黑名单、文件上传临时缓存 - ---- - -## 1. 用户认证相关表 - -### 1.1 用户表 (`users`) - -存储系统用户基本信息。 - -```sql -CREATE TABLE `users` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '用户ID', - `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名(登录账号)', - `password_hash` VARCHAR(255) NOT NULL COMMENT '密码哈希(bcrypt)', - `nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称(显示名称)', - `email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱', - `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', - `avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像URL', - `status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用', - `is_superuser` TINYINT DEFAULT 0 COMMENT '是否超级管理员:0-否 1-是', - `last_login_at` DATETIME DEFAULT NULL COMMENT '最后登录时间', - `last_login_ip` VARCHAR(50) DEFAULT NULL COMMENT '最后登录IP', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - INDEX `idx_username` (`username`), - INDEX `idx_email` (`email`), - INDEX `idx_status` (`status`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; -``` - -### 1.2 角色表 (`roles`) - -定义系统角色。 - -```sql -CREATE TABLE `roles` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '角色ID', - `role_name` VARCHAR(50) NOT NULL UNIQUE COMMENT '角色名称', - `role_code` VARCHAR(50) NOT NULL UNIQUE COMMENT '角色编码(如:admin, editor, viewer)', - `description` VARCHAR(255) DEFAULT NULL COMMENT '角色描述', - `status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用', - `is_system` TINYINT DEFAULT 0 COMMENT '是否系统角色:0-否 1-是(系统角色不可删除)', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - INDEX `idx_role_code` (`role_code`), - INDEX `idx_status` (`status`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表'; -``` - -**预置角色数据**: -```sql -INSERT INTO `roles` (`role_name`, `role_code`, `description`, `is_system`) VALUES -('超级管理员', 'super_admin', '拥有系统所有权限', 1), -('项目管理员', 'project_admin', '可以创建和管理项目', 1), -('普通用户', 'user', '可以查看和编辑被授权的项目', 1), -('访客', 'guest', '只读权限', 1); -``` - -### 1.3 用户角色关联表 (`user_roles`) - -用户与角色多对多关系。 - -```sql -CREATE TABLE `user_roles` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关联ID', - `user_id` BIGINT NOT NULL COMMENT '用户ID', - `role_id` BIGINT NOT NULL COMMENT '角色ID', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - UNIQUE KEY `uk_user_role` (`user_id`, `role_id`), - INDEX `idx_user_id` (`user_id`), - INDEX `idx_role_id` (`role_id`), - FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表'; -``` - ---- - -## 2. 权限与菜单管理 - -### 2.1 系统菜单表 (`system_menus`) - -定义系统功能菜单和权限点。 - -```sql -CREATE TABLE `system_menus` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '菜单ID', - `parent_id` BIGINT DEFAULT 0 COMMENT '父菜单ID(0表示根菜单)', - `menu_name` VARCHAR(50) NOT NULL COMMENT '菜单名称', - `menu_code` VARCHAR(50) NOT NULL UNIQUE COMMENT '菜单编码(权限标识)', - `menu_type` TINYINT NOT NULL COMMENT '菜单类型:1-目录 2-菜单 3-按钮/权限点', - `path` VARCHAR(255) DEFAULT NULL COMMENT '路由路径', - `component` VARCHAR(255) DEFAULT NULL COMMENT '组件路径', - `icon` VARCHAR(100) DEFAULT NULL COMMENT '图标', - `sort_order` INT DEFAULT 0 COMMENT '排序号', - `visible` TINYINT DEFAULT 1 COMMENT '是否可见:0-隐藏 1-显示', - `status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用', - `permission` VARCHAR(100) DEFAULT NULL COMMENT '权限字符串(如:project:create)', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - INDEX `idx_parent_id` (`parent_id`), - INDEX `idx_menu_code` (`menu_code`), - INDEX `idx_status` (`status`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统菜单表'; -``` - -**预置菜单数据**: -```sql -INSERT INTO `system_menus` (`id`, `parent_id`, `menu_name`, `menu_code`, `menu_type`, `path`, `icon`, `sort_order`, `permission`) VALUES -(1, 0, '项目管理', 'project', 1, '/projects', 'FolderOutlined', 1, NULL), -(2, 1, '我的项目', 'my_projects', 2, '/projects/my', NULL, 1, 'project:view'), -(3, 1, '创建项目', 'create_project', 3, NULL, NULL, 2, 'project:create'), -(4, 1, '编辑项目', 'edit_project', 3, NULL, NULL, 3, 'project:edit'), -(5, 1, '删除项目', 'delete_project', 3, NULL, NULL, 4, 'project:delete'), -(10, 0, '文档管理', 'document', 1, '/documents', 'FileTextOutlined', 2, NULL), -(11, 10, '查看文档', 'view_document', 3, NULL, NULL, 1, 'document:view'), -(12, 10, '编辑文档', 'edit_document', 3, NULL, NULL, 2, 'document:edit'), -(13, 10, '删除文档', 'delete_document', 3, NULL, NULL, 3, 'document:delete'), -(20, 0, '系统管理', 'system', 1, '/system', 'SettingOutlined', 3, NULL), -(21, 20, '用户管理', 'user_manage', 2, '/system/users', NULL, 1, 'system:user:view'), -(22, 20, '角色管理', 'role_manage', 2, '/system/roles', NULL, 2, 'system:role:view'); -``` - -### 2.2 角色菜单授权表 (`role_menus`) - -角色与菜单权限的多对多关系。 - -```sql -CREATE TABLE `role_menus` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关联ID', - `role_id` BIGINT NOT NULL COMMENT '角色ID', - `menu_id` BIGINT NOT NULL COMMENT '菜单ID', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - UNIQUE KEY `uk_role_menu` (`role_id`, `menu_id`), - INDEX `idx_role_id` (`role_id`), - INDEX `idx_menu_id` (`menu_id`), - FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`menu_id`) REFERENCES `system_menus`(`id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色菜单授权表'; -``` - ---- - -## 3. 项目与文档管理 - -### 3.1 项目表 (`projects`) - -存储项目基本信息。 - -```sql -CREATE TABLE `projects` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '项目ID', - `name` VARCHAR(100) NOT NULL COMMENT '项目名称', - `description` VARCHAR(500) DEFAULT NULL COMMENT '项目描述', - `storage_key` CHAR(36) NOT NULL COMMENT '磁盘存储UUID(物理文件夹名)', - `owner_id` BIGINT NOT NULL COMMENT '项目所有者ID', - `is_public` TINYINT DEFAULT 0 COMMENT '是否公开:0-私有 1-公开', - `is_template` TINYINT DEFAULT 0 COMMENT '是否模板项目:0-否 1-是', - `status` TINYINT DEFAULT 1 COMMENT '状态:0-归档 1-活跃', - `cover_image` VARCHAR(255) DEFAULT NULL COMMENT '封面图', - `sort_order` INT DEFAULT 0 COMMENT '排序号', - `visit_count` INT DEFAULT 0 COMMENT '访问次数', - `git_repo_url` VARCHAR(255) DEFAULT NULL COMMENT 'Git仓库地址', - `git_branch` VARCHAR(50) DEFAULT 'main' COMMENT 'Git分支', - `git_username` VARCHAR(100) DEFAULT NULL COMMENT 'Git用户名', - `git_token` VARCHAR(255) DEFAULT NULL COMMENT 'Git访问令牌/密码', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - UNIQUE KEY `uk_storage_key` (`storage_key`), - INDEX `idx_owner_id` (`owner_id`), - INDEX `idx_name` (`name`), - INDEX `idx_status` (`status`), - INDEX `idx_created_at` (`created_at`), - FOREIGN KEY (`owner_id`) REFERENCES `users`(`id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目表'; -``` - -### 3.2 项目成员表 (`project_members`) - -项目协作成员管理。 - -```sql -CREATE TABLE `project_members` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '成员ID', - `project_id` BIGINT NOT NULL COMMENT '项目ID', - `user_id` BIGINT NOT NULL COMMENT '用户ID', - `role` ENUM('admin', 'editor', 'viewer') DEFAULT 'viewer' COMMENT '项目角色:admin-管理员 editor-编辑者 viewer-查看者', - `invited_by` BIGINT DEFAULT NULL COMMENT '邀请人ID', - `joined_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间', - UNIQUE KEY `uk_project_user` (`project_id`, `user_id`), - INDEX `idx_project_id` (`project_id`), - INDEX `idx_user_id` (`user_id`), - INDEX `idx_role` (`role`), - FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`invited_by`) REFERENCES `users`(`id`) ON DELETE SET NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目成员表'; -``` - -### 3.3 文档元数据表 (`document_meta`) - -可选表,用于存储文档的额外元数据(标签、评论数等)。 - -```sql -CREATE TABLE `document_meta` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '元数据ID', - `project_id` BIGINT NOT NULL COMMENT '项目ID', - `file_path` VARCHAR(500) NOT NULL COMMENT '文件相对路径', - `title` VARCHAR(200) DEFAULT NULL COMMENT '文档标题', - `tags` VARCHAR(500) DEFAULT NULL COMMENT '标签(JSON数组)', - `author_id` BIGINT DEFAULT NULL COMMENT '作者ID', - `word_count` INT DEFAULT 0 COMMENT '字数统计', - `view_count` INT DEFAULT 0 COMMENT '浏览次数', - `last_editor_id` BIGINT DEFAULT NULL COMMENT '最后编辑者ID', - `last_edited_at` DATETIME DEFAULT NULL COMMENT '最后编辑时间', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - UNIQUE KEY `uk_project_path` (`project_id`, `file_path`), - INDEX `idx_project_id` (`project_id`), - INDEX `idx_author_id` (`author_id`), - FULLTEXT KEY `ft_title_tags` (`title`, `tags`), - FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON DELETE SET NULL, - FOREIGN KEY (`last_editor_id`) REFERENCES `users`(`id`) ON DELETE SET NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文档元数据表'; -``` - ---- - -## 4. 操作日志与审计 - -### 4.1 操作日志表 (`operation_logs`) - -记录关键操作日志。 - -```sql -CREATE TABLE `operation_logs` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '日志ID', - `user_id` BIGINT DEFAULT NULL COMMENT '操作用户ID', - `username` VARCHAR(50) DEFAULT NULL COMMENT '用户名(冗余字段)', - `operation_type` VARCHAR(50) NOT NULL COMMENT '操作类型(create, update, delete等)', - `resource_type` VARCHAR(50) NOT NULL COMMENT '资源类型(project, document, user等)', - `resource_id` BIGINT DEFAULT NULL COMMENT '资源ID', - `detail` TEXT DEFAULT NULL COMMENT '操作详情(JSON)', - `ip_address` VARCHAR(50) DEFAULT NULL COMMENT 'IP地址', - `user_agent` VARCHAR(500) DEFAULT NULL COMMENT '用户代理', - `status` TINYINT DEFAULT 1 COMMENT '状态:0-失败 1-成功', - `error_message` TEXT DEFAULT NULL COMMENT '错误信息', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间', - INDEX `idx_user_id` (`user_id`), - INDEX `idx_resource` (`resource_type`, `resource_id`), - INDEX `idx_created_at` (`created_at`), - FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表'; -``` - ---- - -## 5. 文件存储说明 - -### 5.1 物理存储结构 - -**根目录**: `/data/nex_docus_store/` - -``` -/data/nex_docus_store/ -├── projects/ -│ ├── / # 项目文件夹(UUID命名) -│ │ ├── README.md # 项目首页 -│ │ ├── _assets/ # 资源文件夹(图片、附件) -│ │ │ ├── images/ # 图片 -│ │ │ └── files/ # 附件 -│ │ ├── / # 用户创建的文件夹 -│ │ │ └── *.md # Markdown文档 -│ │ └── ... -└── temp/ # 临时文件(上传缓存) -``` - -### 5.2 存储规则 - -1. **项目隔离**: 每个项目使用独立的 UUID 文件夹 -2. **路径映射**: `storage_key` 字段存储 UUID,数据库存储显示名称 -3. **资源管理**: 图片和附件存储在 `_assets` 目录 -4. **安全控制**: 所有文件访问必须经过权限验证 -5. **备份策略**: 可直接备份 `/data/nex_docus_store/` 目录 - ---- - -## 6. 索引优化建议 - -1. **复合索引**: - - `projects`: (`owner_id`, `status`) - - `project_members`: (`project_id`, `role`) - - `document_meta`: (`project_id`, `last_edited_at`) - -2. **覆盖索引**: 针对高频查询添加包含查询字段的复合索引 - -3. **分区表**: 当 `operation_logs` 数据量大时,可按月份分区 - ---- - -## 7. 数据库初始化脚本 - -创建完整的初始化 SQL 文件:`backend/scripts/init_database.sql` - -执行顺序: -1. 创建数据库 -2. 创建所有表 -3. 插入初始角色数据 -4. 插入初始菜单数据 -5. 创建默认管理员用户 - ---- - -## 8. 性能优化建议 - -1. **连接池配置**: SQLAlchemy 配置合理的连接池大小 -2. **查询优化**: 使用 `joinedload` 避免 N+1 查询 -3. **缓存策略**: - - 用户信息缓存(5分钟) - - 菜单权限缓存(10分钟) - - 项目列表缓存(1分钟) -4. **读写分离**: 后续可配置主从数据库 - ---- - -**文档版本**: v1.0(与代码版本 v0.9.9 的 git 基线无直接对应,见 SDD releases/) -**最后更新**: 2023-12-20 -**维护人**: Mula.liu diff --git a/DEPLOY.md b/DEPLOY.md deleted file mode 100644 index 8baa571..0000000 --- a/DEPLOY.md +++ /dev/null @@ -1,330 +0,0 @@ -# NEX Docus Docker 部署文档 - -完整的 Docker 容器化部署方案,支持一键部署、升级、备份等功能。 - -## 🚀 快速开始 - -### 1. 环境要求 - -- Docker 20.10+ -- Docker Compose 2.0+ -- 至少 2GB 可用内存 -- 至少 10GB 可用磁盘空间 - -### 2. 首次部署 - -```bash -# 1. 克隆项目(如果还没有) -git clone -cd "NEX Docus" - -# 2. 配置环境变量 -cp .env.example .env -vim .env # 编辑配置文件 - -# 3. 初始化并启动 -./deploy.sh init -``` - -### 3. 配置说明 - -编辑 `.env` 文件,修改以下关键配置: - -```bash -# 数据库配置 -DB_NAME=nex_docus -DB_USER=nexdocus -DB_PASSWORD=your_secure_password_here - -# Redis 配置 -REDIS_PASSWORD=your_redis_password_here - -# JWT 密钥(必须修改!) -SECRET_KEY=your-secret-key-change-me-in-production - -# 服务端口配置 -FRONTEND_PORT=8080 # 前端访问端口 -BACKEND_PORT=8000 # 后端 API 端口 - -# 存储路径配置(用于存储项目文档和上传文件) -STORAGE_PATH=./storage # 可修改为绝对路径,如 /data/nex-docus-storage - -# 管理员账号 -ADMIN_USERNAME=admin -ADMIN_PASSWORD=Your_Secure_Password_123 -ADMIN_EMAIL=admin@yourdomain.com - -# API 地址配置 -# 推荐:使用 Nginx 反向代理(前后端同域名同端口,无跨域问题) -VITE_API_BASE_URL=/api -# 或:直接访问后端端口(需开放后端端口到外网) -# VITE_API_BASE_URL=http://yourdomain.com:8001 -``` - -**⚠️ 重要提示:** -- `SECRET_KEY` 必须修改为随机字符串(可用 `openssl rand -hex 32` 生成) -- 生产环境必须修改所有默认密码 -- `DEBUG` 设置为 `false` -- `STORAGE_PATH` 可配置为绝对路径,便于数据管理和备份 -- **API 访问配置**: - - 使用 `VITE_API_BASE_URL=/api` 时,前端通过 Nginx 反向代理访问后端,无需开放后端端口 - - 使用 `VITE_API_BASE_URL=http://IP:8001` 时,直接访问后端,需要开放 8001 端口 - -## 📋 部署脚本使用 - -### 基本命令 - -```bash -# 查看帮助 -./deploy.sh help - -# 初始化部署(首次部署) -./deploy.sh init - -# 启动服务 -./deploy.sh start - -# 停止服务 -./deploy.sh stop - -# 重启服务 -./deploy.sh restart - -# 查看服务状态 -./deploy.sh status -``` - -### 日志管理 - -```bash -# 查看所有服务日志 -./deploy.sh logs - -# 查看后端日志 -./deploy.sh logs backend - -# 查看前端日志 -./deploy.sh logs frontend - -# 查看数据库日志 -./deploy.sh logs mysql - -# 查看 Redis 日志 -./deploy.sh logs redis -``` - -### 升级部署 - -```bash -# 升级到最新版本 -./deploy.sh upgrade -``` - -升级流程: -1. 拉取最新代码 -2. 停止服务 -3. 构建新镜像 -4. 更新数据库 -5. 启动服务 -6. 清理旧镜像 - -### 数据库管理 - -```bash -# 备份数据库 -./deploy.sh backup - -# 恢复数据库 -./deploy.sh restore ./backups/nex_docus_20240101_120000.sql -``` - -### 卸载 - -```bash -# 完全卸载(删除所有容器、镜像和数据) -./deploy.sh uninstall -``` - -## 🏗️ 架构说明 - -### 服务组成 - -| 服务 | 端口 | 说明 | -|------|------|------| -| frontend | 8080 | 前端 Nginx 服务 | -| backend | 8000 | 后端 FastAPI 服务 | -| mysql | 3306 | MySQL 8.0 数据库 | -| redis | 6379 | Redis 缓存 | - -### 目录结构 - -``` -NEX Docus/ -├── backend/ # 后端代码 -│ ├── app/ # 应用代码 -│ ├── scripts/ # 脚本文件 -│ │ └── init_db.py # 数据库初始化 -│ ├── Dockerfile # 后端镜像 -│ └── requirements.txt # Python 依赖 -├── frontend/ # 前端代码 -│ ├── src/ # 源代码 -│ ├── Dockerfile # 前端镜像 -│ └── nginx.conf # Nginx 配置 -├── docker-compose.yml # Docker 编排文件 -├── .env.example # 环境变量示例 -├── deploy.sh # 部署管理脚本 -└── DEPLOY.md # 部署文档 -``` - -### 数据持久化 - -所有重要数据都已持久化存储: - -- `mysql_data`: MySQL 数据(Docker Volume) -- `redis_data`: Redis 数据(Docker Volume) -- `${STORAGE_PATH}`: 用户上传的文件和项目文档(宿主机目录映射) - - 默认路径: `./storage` - - 可在 `.env` 中配置 `STORAGE_PATH` 修改为其他路径 - - 建议使用绝对路径,便于备份和迁移 - -**存储目录说明:** -- 该目录存储所有项目文档和上传的文件 -- 支持独立备份和迁移 -- 可配置到独立磁盘或网络存储 - -## 🔧 常见问题 - -### 1. 端口被占用 - -如果默认端口被占用,可以在 `.env` 中修改: - -```bash -FRONTEND_PORT=8080 -BACKEND_PORT=8001 -MYSQL_PORT=3307 -REDIS_PORT=6380 -``` - -### 2. 数据库连接失败 - -检查 MySQL 容器状态: -```bash -docker logs nex-docus-mysql -``` - -确保数据库完全启动(约需 10-15 秒) - -### 3. 前端无法访问后端 API - -检查 `.env` 中的 `VITE_API_BASE_URL` 配置,确保与实际部署地址匹配。 - -### 4. 内存不足 - -如果服务器内存小于 2GB,可能导致 MySQL 无法启动。建议: -- 增加服务器内存 -- 或使用外部 MySQL 服务 - -### 5. 镜像下载缓慢 - -已配置国内镜像加速: -- Docker 基础镜像使用华为云镜像 -- Debian APT 包使用阿里云镜像源 -- Python 使用清华源 -- Node 使用淘宝镜像 - -**Docker 镜像源配置**(如需更换): - -编辑 `/etc/docker/daemon.json`: -```json -{ - "registry-mirrors": [ - "https://mirror.ccs.tencentyun.com", - "https://docker.mirrors.ustc.edu.cn" - ] -} -``` - -**说明**: -- `backend/Dockerfile` 已配置阿里云 Debian APT 源,系统依赖安装速度大幅提升 -- 如构建仍然缓慢,可尝试清华源:`mirrors.tuna.tsinghua.edu.cn` - -## 🔐 安全建议 - -1. **修改默认密码** - - 管理员账号密码 - - 数据库密码 - - Redis 密码 - - JWT 密钥 - -2. **配置防火墙** - ```bash - # 只开放必要端口 - ufw allow 80/tcp - ufw allow 443/tcp - ufw enable - ``` - -3. **使用 HTTPS** - - 建议配置 Nginx 反向代理 - - 使用 Let's Encrypt 免费证书 - -4. **定期备份** - ```bash - # 添加定时任务 - crontab -e - # 每天凌晨 2 点备份 - 0 2 * * * cd /path/to/nex-docus && ./deploy.sh backup - ``` - -5. **关闭调试模式** - ```bash - DEBUG=false - ``` - -## 📊 监控与维护 - -### 查看资源使用情况 - -```bash -# Docker 容器资源使用 -docker stats - -# 磁盘使用情况 -df -h - -# 清理 Docker 缓存 -docker system prune -a -``` - -### 定期维护 - -```bash -# 每周检查日志大小 -du -sh backend/logs - -# 清理旧日志 -find backend/logs -name "*.log" -mtime +30 -delete -``` - -## 🆘 技术支持 - -如遇问题,请检查: - -1. 服务日志: `./deploy.sh logs <服务名>` -2. Docker 状态: `docker ps -a` -3. 系统资源: `top` 或 `htop` - -## 📝 更新日志 - -> ⚠️ 版本对齐:git 发布线当前为 v0.9.9(无 v1.0.0 tag),下列 v1.0.0 为早期文案占位。详见 SDD `docs/sdd/releases/`。 - -### v1.0.0 (2024-12-20,遗留占位) -- ✨ 完整的 Docker 部署方案 -- ✨ 一键初始化和升级 -- ✨ 数据库备份恢复 -- ✨ 国内镜像加速 -- ✨ 完善的文档和脚本 - ---- - -**祝部署顺利!** 🎉 diff --git a/DEPLOYEE.md b/DEPLOYEE.md deleted file mode 100644 index a47922e..0000000 --- a/DEPLOYEE.md +++ /dev/null @@ -1,17 +0,0 @@ - -# 开发环境说明 -## Mysql: - + 连接字符串:10.100.51.51:3306 - + 数据库名:nex_docus - + 字符集:utf8mb4 - + 排序规则:utf8mb4_unicode_ci - + 用户名、密码: root | Unis@123 - -## Redis: - + 连接字符串:10.100.51.51:6379 - + db:1 - + 密码: Unis@123 - -## 本地开发环境: - frontend: 前端 - backend: 后端(已经创建虚拟环境venv) \ No newline at end of file diff --git a/QUICKSTART.md b/QUICKSTART.md deleted file mode 100644 index 3726c5e..0000000 --- a/QUICKSTART.md +++ /dev/null @@ -1,230 +0,0 @@ -# NEX Docus 快速启动指南 - -欢迎使用 NEX Docus!这是一个完整的快速启动指南,帮助你在 5 分钟内运行整个项目。 - ---- - -## 📋 前置要求 - -确保已安装以下软件: - -- **Python**: 3.9.6+ -- **Node.js**: 16+ -- **MySQL**: 5.7.5+ -- **Redis**: 最新稳定版 -- **Git**: 最新版本 - ---- - -## 🚀 快速启动(3 步) - -### Step 1: 初始化数据库 - -```bash -# 1. 连接到 MySQL -mysql -h10.100.51.51 -uroot -pUnis@321 - -# 2. 执行初始化脚本 -source backend/scripts/init_database.sql - -# 或使用命令行直接执行 -mysql -h10.100.51.51 -uroot -pUnis@321 < backend/scripts/init_database.sql -``` - -### Step 2: 启动后端服务 - -```bash -# 1. 进入后端目录 -cd backend - -# 2. 激活虚拟环境 -source venv/bin/activate # macOS/Linux -# 或 -venv\Scripts\activate # Windows - -# 3. 安装依赖 -pip install -r requirements.txt - -# 4. 启动服务 -python main.py -``` - -后端服务将在 http://localhost:8000 启动 - -### Step 3: 启动前端服务 - -```bash -# 1. 打开新终端,进入前端目录 -cd frontend - -# 2. 安装依赖 -npm install -# 或 -pnpm install - -# 3. 启动开发服务器 -npm run dev -``` - -前端应用将在 http://localhost:5173 启动 - ---- - -## 🎉 开始使用 - -### 1. 登录系统 - -访问 http://localhost:5173,使用默认管理员账号登录: - -- **用户名**: `admin` -- **密码**: `admin123` - -### 2. 创建项目 - -- 点击「创建项目」按钮 -- 填写项目名称和描述 -- 提交创建 - -### 3. 编辑文档 - -- 点击项目卡片进入项目 -- 在左侧目录树中选择文件 -- 在右侧编辑器中编辑 Markdown -- 点击「保存」按钮保存更改 - ---- - -## 📚 目录结构 - -``` -NEX Docus/ -├── backend/ # 后端服务(FastAPI) -│ ├── app/ -│ │ ├── api/ # API 路由 -│ │ ├── core/ # 核心配置 -│ │ ├── models/ # 数据库模型 -│ │ ├── schemas/ # Pydantic Schemas -│ │ ├── services/ # 业务逻辑 -│ │ └── middleware/ # 中间件 -│ ├── scripts/ # 脚本文件 -│ ├── main.py # 应用入口 -│ └── requirements.txt # 依赖包 -│ -├── frontend/ # 前端应用(React + Vite) -│ ├── src/ -│ │ ├── api/ # API 请求 -│ │ ├── components/ # 通用组件 -│ │ ├── pages/ # 页面组件 -│ │ ├── stores/ # 状态管理 -│ │ └── utils/ # 工具函数 -│ ├── package.json -│ └── vite.config.js -│ -├── DATABASE.md # 数据库设计文档 -├── IMPLEMENTATION_PLAN.md # 实施计划 -├── PROJECT.md # 项目技术方案 -└── DEPLOYE.md # 部署配置 -``` - ---- - -## 🔧 常见问题 - -### 1. 后端启动失败 - -**问题**: `ModuleNotFoundError: No module named 'xxx'` - -**解决**: -```bash -cd backend -source venv/bin/activate -pip install -r requirements.txt -``` - -### 2. 数据库连接失败 - -**问题**: `Can't connect to MySQL server` - -**解决**: -- 检查 `backend/.env` 中的数据库配置 -- 确认 MySQL 服务已启动 -- 测试数据库连接: - ```bash - mysql -h10.100.51.51 -uroot -pUnis@321 -e "SELECT 1" - ``` - -### 3. 前端请求 404 - -**问题**: API 请求返回 404 - -**解决**: -- 确认后端服务已启动 -- 检查 `frontend/.env` 中的 API 地址配置 -- 检查浏览器控制台的网络请求 - -### 4. 文件上传/保存失败 - -**问题**: 文件操作失败 - -**解决**: -- 确保文件存储目录存在并有写权限: - ```bash - mkdir -p /data/nex_docus_store/{projects,temp} - chmod 755 /data/nex_docus_store - ``` -- 或修改 `backend/.env` 中的存储路径为当前用户有权限的目录 - ---- - -## 📖 API 文档 - -启动后端服务后,访问以下地址查看 API 文档: - -- **Swagger UI**: http://localhost:8000/docs -- **ReDoc**: http://localhost:8000/redoc - ---- - -## 🔐 安全提醒 - -⚠️ **生产环境部署前请务必修改:** - -1. 修改默认管理员密码 -2. 修改 `backend/.env` 中的 `SECRET_KEY` -3. 配置 HTTPS -4. 限制 CORS 允许的域名 -5. 配置防火墙规则 - ---- - -## 📞 技术支持 - -如果遇到问题,请查看: - -1. **PROJECT.md** - 完整技术方案 -2. **DATABASE.md** - 数据库设计文档 -3. **IMPLEMENTATION_PLAN.md** - 实施计划 - -或联系技术负责人:Mula.liu - ---- - -## ⚡️ 快速命令参考 - -```bash -# 后端 -cd backend && source venv/bin/activate && python main.py - -# 前端 -cd frontend && npm run dev - -# 数据库初始化 -mysql -h10.100.51.51 -uroot -pUnis@321 < backend/scripts/init_database.sql - -# 查看日志 -tail -f backend/logs/app.log -``` - ---- - -**祝你使用愉快!🎊** diff --git a/README.md b/README.md index 7383bb2..b317bdf 100644 --- a/README.md +++ b/README.md @@ -2,263 +2,177 @@
-一个轻量级、高性能的团队协作文档管理平台 +轻量、可自托管的团队文档中心:文件系统存储内容,数据库管理权限,内置 AI 知识库问答。 -[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![Python](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/) -[![React](https://img.shields.io/badge/react-18+-blue.svg)](https://reactjs.org/) -[![FastAPI](https://img.shields.io/badge/fastapi-0.109+-green.svg)](https://fastapi.tiangolo.com/) +v1.0.0 · FastAPI + React 18 + MySQL 8 + Redis
--- -## ✨ 特性 +## ✨ 核心特性 -- 🚀 **高性能**: FastAPI 异步后端 + React 18 前端 -- 📁 **文件存储**: 数据库管理权限 + 文件系统存储内容 -- 🔐 **权限控制**: 完整的 RBAC 权限体系 -- 📝 **Markdown 编辑**: 实时预览、图片上传 -- 👥 **团队协作**: 项目成员管理、角色分配 -- 🌲 **目录树**: 无限层级目录支持 -- 💾 **数据安全**: 文件即真理、易于备份和迁移 +- 📁 **文件即真理**:文档正文以 Markdown 文件形式存放在磁盘(`storage/projects//…`),数据库只保存权限与元数据,备份/迁移只需拷目录。 +- 📝 **Markdown 编辑**:基于 ByteMD 的编辑器,支持 **编辑 / 分栏 / 预览** 三态切换,右侧悬浮目录(TOC)在纯编辑模式下同样可用;支持 GFM、代码高亮、Frontmatter、emoji、图片上传。 +- 🌲 **无限层级目录树**:文件/文件夹创建、重命名、拖拽移动、排序。 +- 👥 **团队协作**:项目成员与项目内角色(admin / editor / viewer),公开项目与「参与项目」列表。 +- 🔎 **双引擎检索**:本地全文检索(Whoosh + jieba 中文分词)+ 向量检索(ZVec / 远程 Embedding)。 +- 🤖 **AI 知识库问答**:基于项目文档的 RAG 对话,返回引用文件与命中片段,可中断、可回看思考过程与耗时。 +- 🧩 **模型配置中心**:在系统管理里维护 Chat / Embedding 模型(OpenAI 兼容接口、本地 sentence-transformers),运行时热切换。 +- 🔗 **分享与预览**:项目/单文件分享链接(可带访问密码)、Markdown / PDF / 图片预览,PDF 支持服务端导出。 +- 🔐 **RBAC**:用户 / 角色 / 菜单与按钮级权限点,侧边栏按授权动态生成。 +- 📄 **通知与审计**:站内通知中心、全量操作日志检索。 +- 🔄 **Git 双向同步**:项目可绑定 Git 仓库,按目录同步文档。 +- 🛰️ **MCP 接入**:为 MCP Bot 提供凭证与文档读写能力。 +- 🌗 **明暗双主题**:全站统一设计令牌(`styles/design-tokens.css` + antd 主题),跟随系统或手动切换。 ---- +## 🏗️ 技术栈 -## 🏗️ 技术架构 +### 后端 +| 组件 | 选型 | +| --- | --- | +| Web 框架 | FastAPI 0.109 + Uvicorn(全异步) | +| ORM | SQLAlchemy 2.0(asyncio + aiomysql) | +| 数据库 | MySQL 8.0(utf8mb4) | +| 缓存/队列 | Redis 7 | +| 认证 | JWT(python-jose)+ bcrypt 密码哈希 | +| 检索 | Whoosh3 + jieba(全文)、ZVec + sentence-transformers / 远程 Embedding(向量) | +| 文档处理 | markdown、weasyprint(PDF)、python-magic | +| 集成 | OpenAI 兼容 LLM 接口、系统 `git` 命令(subprocess 调用)、MCP SDK | -### 后端技术栈 -- **框架**: FastAPI (Python 3.9+) -- **ORM**: SQLAlchemy 2.0 (异步) -- **数据库**: MySQL 5.7.5+ -- **缓存**: Redis -- **认证**: JWT (PyJWT) -- **文件**: aiofiles (异步 I/O) - -### 前端技术栈 -- **框架**: React 18 -- **构建工具**: Vite -- **UI 组件**: Ant Design 5 -- **路由**: React Router v6 -- **状态管理**: Zustand -- **Markdown**: @uiw/react-md-editor -- **样式**: Tailwind CSS - ---- +### 前端 +| 组件 | 选型 | +| --- | --- | +| 框架 | React 18 + React Router v6 | +| 构建 | Vite 5(`manualChunks` 分包、全部路由 lazy 加载) | +| UI | Ant Design 5 + 自研设计令牌(无 Tailwind / postcss) | +| 状态 | Zustand + Axios 统一封装 | +| Markdown | ByteMD(`@bytemd/react` + gfm/highlight/frontmatter/breaks/gemoji)+ react-markdown 渲染 | +| 其它 | react-pdf / pdfjs-dist、react-virtuoso、antd-img-crop | ## 📦 项目结构 ``` -NEX Docus/ -├── backend/ # FastAPI 后端服务 +NexDocus/ +├── backend/ # FastAPI 后端 │ ├── app/ -│ │ ├── api/v1/ # API 路由(v1) -│ │ ├── core/ # 核心配置 -│ │ ├── models/ # 数据库模型 -│ │ ├── schemas/ # Pydantic Schemas -│ │ ├── services/ # 业务逻辑服务 -│ │ └── middleware/ # 中间件 -│ ├── scripts/ # 初始化脚本 -│ └── main.py # 应用入口 +│ │ ├── api/v1/ # API 路由(认证/项目/文件/检索/对话/分享/系统…) +│ │ ├── core/ # 配置、数据库、安全、依赖注入、幂等迁移 +│ │ ├── models/ # SQLAlchemy 模型(18 张表) +│ │ ├── schemas/ # Pydantic Schema +│ │ ├── services/ # 业务逻辑(存储、检索、向量化、RAG、Git、导出…) +│ │ └── mcp/ # MCP Streamable HTTP 接入(凭证鉴权 + 工具注册) +│ ├── scripts/ # 数据库初始化脚本(随代码走,Docker 构建上下文需要) +│ ├── tests/ # pytest 用例 +│ └── main.py # 应用入口 │ -├── frontend/ # React 前端应用 -│ ├── src/ -│ │ ├── api/ # API 封装 -│ │ ├── components/ # 通用组件 -│ │ ├── pages/ # 页面组件 -│ │ ├── stores/ # 状态管理 -│ │ └── utils/ # 工具函数 -│ └── package.json +├── frontend/ # React 前端 +│ └── src/ +│ ├── api/ # 接口封装 +│ ├── components/ # 通用组件(Feedback 统一提示、MainLayout…) +│ ├── data/ # 静态配置数据 +│ ├── pages/ # 页面(全部 lazy 加载) +│ ├── stores/ # Zustand +│ ├── styles/ # design-tokens.css 等全局样式 +│ ├── theme/ # antd 主题(明/暗) +│ └── utils/ │ -├── DATABASE.md # 数据库设计文档 -├── PROJECT.md # 技术方案文档 -├── QUICKSTART.md # 快速启动指南 -└── IMPLEMENTATION_PLAN.md # 实施计划 +├── scripts/ # 运维/开发脚本(start / stop / deploy) +├── docs/ # 全部文档(见 docs/README.md) +├── storage/ # 运行期文件存储(已 gitignore) +├── backup/ # 数据库备份产物(已 gitignore) +├── .run/ # 本地 pid / 日志(已 gitignore) +├── docker-compose.yml # 容器编排 +└── .env.example # Docker 部署环境变量模板 ``` ---- - ## 🚀 快速开始 -### 前置要求 +### 环境要求 +- Python **3.10+**(推荐 3.12) +- Node.js **18+** +- MySQL **8.0**、Redis **7**(已有实例亦可,Docker 部署会自动拉起) +- 启用 Git 同步时需要本机存在 `git` 可执行文件(后端镜像已内置) -- Python 3.9.6+ -- Node.js 16+ -- MySQL 5.7.5+ -- Redis (最新版) - -### 1. 初始化数据库 +### 方式一:一键启动(本地开发,推荐) ```bash -mysql -h10.100.51.51 -uroot -pUnis@321 < backend/scripts/init_database.sql +./scripts/start.sh ``` -### 2. 启动后端 +首次运行会:创建 `backend/venv` → 生成 `backend/.env` 模板(**此时会停下**,请填好 MySQL/Redis 连接信息后重新执行)→ 安装前后端依赖 → 真实校验 MySQL/Redis 连通性 → 幂等初始化数据库 → 拉起后端与前端。 ```bash -cd backend -source venv/bin/activate -pip install -r requirements.txt -python main.py +./scripts/start.sh --backend # 只启动后端 +./scripts/start.sh --frontend # 只启动前端 +./scripts/start.sh --install # 只准备环境,不启动服务 +./scripts/start.sh --init-db # 强制重跑数据库初始化 +./scripts/start.sh --port 8001 # 换端口(环境变量优先,不改 .env) +./scripts/start.sh --daemon # 启动后立即返回(后台常驻) +./scripts/stop.sh # 停止 ``` -后端将运行在 http://localhost:8000 +启动完成后:前端 http://localhost:5173 · 后端 http://localhost:8000 · 接口文档 http://localhost:8000/docs -### 3. 启动前端 +### 方式二:Docker Compose(服务器部署) ```bash -cd frontend -npm install -npm run dev +cp .env.example .env # 按注释修改密码/端口/存储路径 +./scripts/deploy.sh init # 生成配置、构建镜像、初始化数据库 +./scripts/deploy.sh start +./scripts/deploy.sh status ``` -前端将运行在 http://localhost:5173 +细节见 [docs/deploy/README.md](docs/deploy/README.md)。 -### 4. 登录使用 +### 默认账号 -- **用户名**: `admin` -- **密码**: `admin123` +| 场景 | 用户名 | 密码 | +| --- | --- | --- | +| `scripts/start.sh` / `backend/scripts/init_db.py` | `admin` | `admin@123` | +| Docker 部署(`.env.example` 默认值) | `admin` | `Admin@123456` | ---- +可用 `ADMIN_USERNAME` / `ADMIN_PASSWORD` / `ADMIN_EMAIL` / `ADMIN_NICKNAME` 覆盖;**首次登录后请立即修改密码**。 -## 📖 核心功能 +## 📖 文档索引 -### 用户认证 -- ✅ 用户注册/登录 -- ✅ JWT Token 认证 -- ✅ 密码加密存储 -- ✅ 权限角色管理 +| 文档 | 内容 | +| --- | --- | +| [docs/README.md](docs/README.md) | 文档地图(从这里开始) | +| [docs/quickstart.md](docs/quickstart.md) | 开发环境快速上手、常见启动问题 | +| [docs/deploy/README.md](docs/deploy/README.md) | Docker 部署、升级、备份与恢复 | +| [docs/database.md](docs/database.md) | 数据库 18 张表结构与初始化链路 | +| [docs/manual/user-guide.md](docs/manual/user-guide.md) | 面向使用者的功能手册 | +| [docs/sdd/](docs/sdd/) | 规格驱动开发文档:愿景、架构、ADR、DV 规格、发布记录 | +| [scripts/README.md](scripts/README.md) | 脚本清单与约定 | -### 项目管理 -- ✅ 创建/编辑/删除项目 -- ✅ 项目成员管理 -- ✅ 访问权限控制 -- ✅ 项目归档 +## 🧪 开发与质量 -### 文档编辑 -- ✅ Markdown 实时预览 -- ✅ 无限层级目录 -- ✅ 文件/文件夹操作 -- ✅ 图片/附件上传 -- ✅ 自动保存 +```bash +# 前端:静态检查与构建 +cd frontend && npm run lint && npm run build ---- +# 后端:pytest(用例较少,见发布报告 OI 清单) +cd backend && ./venv/bin/pip install -r requirements-dev.txt +cd backend && ./venv/bin/python -m pytest +``` -## 🗂️ 数据库设计 +- 前端统一使用 `@/` 别名指向 `frontend/src`;新页面必须在 `App.jsx` 用 `lazy()` 注册。 +- 交互提示统一走 `components/Feedback` 的 `Toast`(`Toast.confirm` 替代 `Modal.confirm`)。 +- 颜色/圆角/间距一律取 `styles/design-tokens.css` 与 antd token,禁止硬编码色值。 +- 数据库结构变更:改 `app/models/`,并在 `app/core/migrations.py` 增加幂等补列逻辑;**不要**再往仓库里追加一次性 `.sql`。 -### 核心表结构 +## 🔒 安全要点 -- `users` - 用户表 -- `roles` - 角色表 -- `user_roles` - 用户角色关联 -- `system_menus` - 系统菜单 -- `role_menus` - 角色菜单授权 -- `projects` - 项目表 -- `project_members` - 项目成员 -- `document_meta` - 文档元数据(可选) -- `operation_logs` - 操作日志 - -详细设计请查看 [DATABASE.md](DATABASE.md) - ---- - -## 🔒 安全特性 - -- ✅ **密码加密**: bcrypt 加密存储 -- ✅ **JWT 认证**: 访问令牌机制 -- ✅ **路径安全**: 防止路径穿越攻击 -- ✅ **权限校验**: 基于角色的访问控制 -- ✅ **SQL 注入防护**: ORM 自动防护 -- ✅ **CORS 配置**: 跨域请求控制 - ---- - -## 📋 API 文档 - -启动后端后访问: - -- **Swagger UI**: http://localhost:8000/docs -- **ReDoc**: http://localhost:8000/redoc - -### 主要接口 - -**认证** -- `POST /api/v1/auth/register` - 注册 -- `POST /api/v1/auth/login` - 登录 -- `GET /api/v1/auth/me` - 获取当前用户 - -**项目** -- `GET /api/v1/projects/` - 项目列表 -- `POST /api/v1/projects/` - 创建项目 -- `GET /api/v1/projects/{id}` - 项目详情 -- `PUT /api/v1/projects/{id}` - 更新项目 -- `DELETE /api/v1/projects/{id}` - 删除项目 - -**文件** -- `GET /api/v1/files/{project_id}/tree` - 目录树 -- `GET /api/v1/files/{project_id}/file` - 文件内容 -- `POST /api/v1/files/{project_id}/file` - 保存文件 -- `POST /api/v1/files/{project_id}/upload` - 上传文件 - ---- - -## 🛠️ 开发指南 - -### 后端开发 - -1. 添加新的数据模型到 `backend/app/models/` -2. 定义 Pydantic Schema 到 `backend/app/schemas/` -3. 在 `backend/app/api/v1/` 创建路由 -4. 实现业务逻辑到 `backend/app/services/` - -### 前端开发 - -1. 在 `frontend/src/api/` 封装 API 请求 -2. 在 `frontend/src/pages/` 创建页面组件 -3. 在 `frontend/src/App.jsx` 添加路由 -4. 使用 Zustand 管理全局状态 - ---- - -## 📝 文档 - -- [技术方案文档](PROJECT.md) - 完整的技术设计方案 -- [数据库设计](DATABASE.md) - 数据库表结构设计 -- [快速启动指南](QUICKSTART.md) - 5分钟快速上手 -- [实施计划](IMPLEMENTATION_PLAN.md) - 分阶段实施计划 - ---- - -## 🔄 版本历史 - -> ⚠️ 版本对齐:git 发布线为 v0.9.1 → v0.9.2 → v0.9.6 → v0.9.7 → v0.9.8 → v0.9.9(当前基线,提交 `416ef48`),仓库未打 tag。下列 v1.0.0 为早期文案占位,与 git 不符;详见 SDD `docs/sdd/releases/`。 - -### v1.0.0 (2023-12-20,遗留占位,见上方对齐说明) - -- ✅ 完整的用户认证系统 -- ✅ 项目管理功能 -- ✅ Markdown 文档编辑 -- ✅ 文件系统管理 -- ✅ 权限控制体系 -- ✅ 团队协作功能 - ---- +- 密码 bcrypt 存储;JWT 过期时间由 `ACCESS_TOKEN_EXPIRE_MINUTES` 控制。 +- 文件路径全部经过规范化校验,拒绝路径穿越;上传大小与类型受限。 +- `DEBUG=True` 会打印全量 SQL,**生产必须关闭**。 +- `SECRET_KEY`、数据库/Redis 密码、Git Token 必须由环境注入,禁止提交到仓库;`backend/.env`、`.env` 已在 `.gitignore` 中。 +- 分享链接的访问密码当前为明文存储于 `share_links.access_pass`,见发布报告 P2 项。 ## 📄 许可证 -Copyright © 2023 Mula.liu - ---- - -## 🙏 致谢 - -感谢以下开源项目: - -- [FastAPI](https://fastapi.tiangolo.com/) -- [React](https://reactjs.org/) -- [Ant Design](https://ant.design/) -- [SQLAlchemy](https://www.sqlalchemy.org/) -- [Vite](https://vitejs.dev/) +Copyright © 2026 Mula.liu --- diff --git a/README_DOCKER.md b/README_DOCKER.md deleted file mode 100644 index 43a1e1e..0000000 --- a/README_DOCKER.md +++ /dev/null @@ -1,193 +0,0 @@ -# NEX Docus - -
- -**现代化的文档管理系统** - -[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![Docker](https://img.shields.io/badge/Docker-Ready-brightgreen.svg)](DEPLOY.md) -[![Python](https://img.shields.io/badge/Python-3.9+-blue.svg)](backend/) -[![React](https://img.shields.io/badge/React-18.2-61DAFB.svg)](frontend/) - -
- -## ✨ 特性 - -- 📝 **Markdown 编辑器** - 强大的 Markdown 实时预览和编辑 -- 📁 **项目管理** - 多项目支持,灵活的文件组织结构 -- 🔐 **权限控制** - 基于 RBAC 的完善权限管理 -- 🔗 **文档分享** - 支持密码保护的公开分享链接 -- 👥 **协作功能** - 项目成员管理和协作编辑 -- 📱 **响应式设计** - 完美支持桌面和移动端 -- 🐳 **Docker 部署** - 一键容器化部署 -- 🚀 **高性能** - 基于 FastAPI 和 React 构建 - -## 🏗️ 技术栈 - -### 后端 -- **框架**: FastAPI (Python 3.9+) -- **数据库**: MySQL 8.0 -- **缓存**: Redis 7 -- **ORM**: SQLAlchemy 2.0 -- **认证**: JWT (python-jose) -- **异步**: Asyncio - -### 前端 -- **框架**: React 18 + Vite -- **UI**: Ant Design 5 -- **路由**: React Router 6 -- **状态管理**: Zustand -- **Markdown**: react-markdown + rehype -- **HTTP**: Axios - -## 📦 快速开始 - -### 使用 Docker 部署(推荐) - -```bash -# 1. 克隆项目 -git clone -cd "NEX Docus" - -# 2. 配置环境变量 -cp .env.example .env -vim .env # 修改配置 - -# 3. 一键部署 -./deploy.sh init -``` - -详细部署文档请查看 [DEPLOY.md](DEPLOY.md) - -### 本地开发 - -#### 后端开发 - -```bash -cd backend - -# 创建虚拟环境 -python -m venv venv -source venv/bin/activate # Windows: venv\Scripts\activate - -# 安装依赖 -pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple - -# 配置环境变量 -cp .env.example .env -vim .env - -# 初始化数据库 -python scripts/init_db.py - -# 启动服务 -python main.py -``` - -后端服务将运行在 `http://localhost:8000` -API 文档: `http://localhost:8000/docs` - -#### 前端开发 - -```bash -cd frontend - -# 安装依赖 -npm install -# 或使用国内镜像 -npm install --registry=https://registry.npmmirror.com - -# 启动开发服务器 -npm run dev -``` - -前端服务将运行在 `http://localhost:5173` - -## 📖 文档 - -- [部署文档](DEPLOY.md) - Docker 容器化部署指南 -- [API 文档](http://localhost:8000/docs) - FastAPI 自动生成的 API 文档 -- [数据库设计](DATABASE.md) - 数据库表结构说明 - -## 🔑 默认账号 - -首次部署后,系统会自动创建管理员账号: - -- 用户名: `admin`(可在 .env 中配置) -- 密码: `Admin@123456`(可在 .env 中配置) - -**⚠️ 重要提示:请在首次登录后立即修改默认密码!** - -## 🛠️ 部署脚本 - -```bash -# 查看帮助 -./deploy.sh help - -# 初始化部署 -./deploy.sh init - -# 启动/停止/重启 -./deploy.sh start|stop|restart - -# 查看状态和日志 -./deploy.sh status -./deploy.sh logs [服务名] - -# 升级部署 -./deploy.sh upgrade - -# 数据库备份/恢复 -./deploy.sh backup -./deploy.sh restore -``` - -## 📁 项目结构 - -``` -NEX Docus/ -├── backend/ # 后端服务 -│ ├── app/ -│ │ ├── api/ # API 路由 -│ │ ├── core/ # 核心配置 -│ │ ├── models/ # 数据模型 -│ │ ├── schemas/ # Pydantic Schemas -│ │ └── services/ # 业务逻辑 -│ ├── scripts/ # 脚本文件 -│ ├── Dockerfile # 后端镜像 -│ └── requirements.txt # Python 依赖 -├── frontend/ # 前端应用 -│ ├── src/ -│ │ ├── api/ # API 请求 -│ │ ├── components/ # 组件 -│ │ ├── pages/ # 页面 -│ │ ├── stores/ # 状态管理 -│ │ └── utils/ # 工具函数 -│ ├── Dockerfile # 前端镜像 -│ └── package.json # npm 依赖 -├── docker-compose.yml # Docker 编排 -├── deploy.sh # 部署脚本 -├── .env.example # 环境变量示例 -└── README.md # 项目文档 -``` - -## 🤝 贡献 - -欢迎提交 Issue 和 Pull Request! - -## 📄 开源协议 - -本项目采用 [MIT](LICENSE) 协议 - -## 🙏 致谢 - -感谢以下开源项目: - -- [FastAPI](https://fastapi.tiangolo.com/) -- [React](https://react.dev/) -- [Ant Design](https://ant.design/) -- [SQLAlchemy](https://www.sqlalchemy.org/) - ---- - -**Made with ❤️ by NEX Docus Team** diff --git a/backend/README.md b/backend/README.md index 86f0af1..77eec98 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,158 +1,106 @@ -# NEX Docus Backend +# NEX Docus 后端 -NEX Docus 后端服务 - 基于 FastAPI 构建的高性能文档管理平台。 +FastAPI + SQLAlchemy 2.0(异步)+ MySQL + Redis 的文档管理后端,同时提供 RAG 知识库问答与 MCP 服务。 + +> **上手请先看 [`docs/quickstart.md`](../docs/quickstart.md)**(一键启动、环境变量、初始化、常见问题)。 +> 部署/运维见 [`docs/deploy/README.md`](../docs/deploy/README.md),表结构见 [`docs/database.md`](../docs/database.md)。 +> 本文件只描述**后端代码内部结构**与开发约定。 ## 技术栈 -- **框架**: FastAPI 0.109+ -- **数据库**: MySQL 5.7.5+ (通过 SQLAlchemy 2.0 异步 ORM) -- **缓存**: Redis -- **认证**: JWT (python-jose) -- **密码加密**: bcrypt (passlib) -- **文件处理**: aiofiles (异步文件 I/O) +| 用途 | 选型 | +| --- | --- | +| Web 框架 | FastAPI + Uvicorn | +| ORM | SQLAlchemy 2.0 异步(aiomysql),Alembic 迁移 | +| 数据库 / 缓存 | MySQL 8(utf8mb4)/ Redis | +| 认证 | JWT(python-jose)+ bcrypt(passlib) | +| 全文检索 | Whoosh 3 + jieba 分词 | +| 向量检索 | ZVec(远端 Embedding 或本地 Sentence-Transformers) | +| 文件与导出 | aiofiles、自研 Markdown/PDF 导出服务 | +| Git 同步 | 通过命令行调用 `git`(主机需自行安装) | +| 测试 | pytest + pytest-asyncio | -## 项目结构 +## 目录结构 ``` backend/ ├── app/ -│ ├── api/ -│ │ └── v1/ # API 路由(v1 版本) -│ │ ├── auth.py # 用户认证 -│ │ ├── projects.py # 项目管理 -│ │ └── files.py # 文件系统 -│ ├── core/ # 核心配置 -│ │ ├── config.py # 应用配置 -│ │ ├── database.py # 数据库连接 -│ │ ├── security.py # 安全工具 -│ │ └── deps.py # 依赖注入 -│ ├── models/ # 数据库模型 -│ ├── schemas/ # Pydantic Schemas -│ ├── services/ # 业务逻辑 -│ ├── middleware/ # 中间件 -│ └── utils/ # 工具函数 -├── scripts/ # 脚本文件 -│ ├── init_database.sql # 数据库初始化 SQL -│ └── init_db.py # 数据库初始化 Python 脚本 -├── tests/ # 测试文件 -├── main.py # 应用入口 -├── requirements.txt # 依赖包 -└── .env # 环境配置 - +│ ├── api/v1/ # HTTP 路由(前缀 /api/v1),__init__.py 汇总注册 +│ ├── core/ # config / database / deps / security / enums / migrations / redis_client +│ ├── mcp/ # MCP server 与请求上下文(挂载在 /mcp) +│ ├── models/ # SQLAlchemy 模型(__init__.py 必须导入全部模型,见下方陷阱) +│ ├── schemas/ # Pydantic Schema 与统一响应包装 +│ └── services/ # 业务逻辑:project/file/search/rag/zvec/git/vectorization/notification/log/storage/llm… +├── scripts/ # init_db.py(建表+种子数据)、generate_password.py,详见 scripts/README.md +├── tests/ # pytest 用例 +├── models/ # 本地向量模型目录(gitignore,需自行放置 HF 模型,如 m3e-small) +├── main.py # 应用入口:/api/v1 路由、/mcp 挂载、/ 与 /health +├── requirements.txt # 运行依赖 +├── requirements-dev.txt # 开发/测试依赖(含 pytest) +├── pytest.ini # testpaths=tests, pythonpath=. +└── .env # 本地配置(不入库;键名见下表) ``` -## 快速开始 +## 路由一览 -### 1. 安装依赖 +`main.py` 暴露:`GET /`(名称/版本/状态)、`GET /health`(`{"status":"healthy"}`)、`/docs`、`/openapi.json`、`/mcp`(MCP Streamable HTTP,需 `X-Bot-Id` / `X-Bot-Secret`)。 + +`app/api/v1/__init__.py` 注册的子路由前缀: + +| 前缀 | 模块 | 说明 | +| --- | --- | --- | +| `/auth` | auth | 登录、当前用户、资料/密码/头像、MCP 凭证签发与轮换 | +| `/projects` | projects | 项目 CRUD、成员、所有权转移、分享开关、Git pull/push 与目录选择 | +| (同 `/projects/{id}/git-repos`) | git_repos | 仓库配置的增删改查与连通性测试(路由内部自带前缀) | +| `/files` | files | 目录树、读写文件、文件操作、上传、导入导出、PDF 导出、文档与静态资源流式访问 | +| `/preview` | preview | 项目预览 | +| `/shares` | shares | 分享链接创建/校验/访问 | +| `/search` | search | 全文 + 向量双引擎检索 | +| `/chat` | chat | 会话管理、SSE 流式问答、引用与中断 | +| `/llm-model-configs` | llm_model_configs | chat / embedding 模型配置与连通性测试 | +| `/dashboard` | dashboard | 管理员统计 | +| `/users` `/roles` `/role-permissions` `/menu` | users/roles/role_permissions/menu | 用户、角色、角色权限、菜单树 | +| `/notifications` | notifications | 站内通知 | +| `/logs` | logs | 系统日志查询 | + +## 环境变量(`backend/.env`) + +完整键表与说明见 [`docs/quickstart.md`](../docs/quickstart.md);此处只列必填项与常见坑: + +- 必填:`DB_HOST` `DB_USER` `DB_PASSWORD` `DB_NAME`、`REDIS_HOST` `REDIS_PASSWORD`、`SECRET_KEY`。 +- 文件存储:`STORAGE_ROOT` / `PROJECTS_PATH` / `USERS_PATH` / `TEMP_PATH`(**本地开发用这组绝对路径**;Docker 部署用根 `.env` 的 `STORAGE_PATH`,两套体系不同,别混用)。 +- `DEBUG=True` 时 SQLAlchemy 会打印全量 SQL,**生产必须 `false`**。 +- `DB_PASSWORD` 含特殊字符无需手动转义(连接串构造时已 `quote_plus`)。 + +> ⚠️ **不要在代码、文档或提交里写真实环境的账号密码。** 需要示例时用占位值(如 `password_change_me`)。 + +## 开发命令 ```bash -# 激活虚拟环境 -source venv/bin/activate # macOS/Linux -# 或 -venv\Scripts\activate # Windows +cd backend +python3 -m venv venv +./venv/bin/pip install -r requirements-dev.txt # 含运行依赖 + pytest +cp .env.example .env # 若无,按 docs/quickstart.md 的键表创建 -# 安装依赖 -pip install -r requirements.txt +./venv/bin/python scripts/init_db.py # 建表 + 种子数据(幂等) +./venv/bin/python scripts/generate_password.py # 生成 bcrypt 哈希 +./venv/bin/python -m pytest # 单元测试 +./venv/bin/uvicorn main:app --reload --port 8000 # 单独起服务(推荐用仓库根 ./scripts/start.sh) ``` -### 2. 配置环境变量 +## 必须知道的实现约定 -编辑 `.env` 文件,配置数据库连接等信息。 +1. **新增模型要在 `app/models/__init__.py` 里导入。** 只建表逻辑而不导入,`Base.metadata` 就看不到该表,`init_db.py` 会**静默漏建表**,线上表现为 `Table ... doesn't exist`。 +2. **项目权限只走 `app/services/project_service.py`**(`require_project_read_access` / `require_project_write_access` / `normalize_project_role`)。不要在路由里手写角色判断:历史数据里 `project_members.role` 存在大写(`ADMIN`/`EDITOR`/`VIEWER`),直接 `== "admin"` 比较会静默失效。返回给前端的角色必须先 `normalize_project_role`。 +3. **异步 Session 配了 `expire_on_commit=False`**,但 `onupdate=func.now()` 的列(如 `updated_at`)在 UPDATE 提交后仍会被标记过期;提交后若还要序列化整个 ORM 对象,**必须 `await db.refresh(obj)`**,否则会在异步上下文触发同步 IO 抛 `MissingGreenlet`(HTTP 500)。 +4. **统一响应**用 `app/schemas/response.py` 的 `success_response()`;业务错误抛 `HTTPException` 并用 4xx 表达(不要用 500 表达"参数/状态不合法")。 +5. **SSE 接口**(`/chat`)要求网关关闭响应缓冲,见 `docs/deploy/README.md` 的 nginx 示例。 +6. **本地向量模型**放在 `backend/models/<模型名>/`(含 `config.json`、`1_Pooling/config.json`、`model.safetensors` 或 `pytorch_model.bin`),由 `LocalEmbeddingService` 扫描列出;该目录已 gitignore,不要提交。 -### 3. 初始化数据库 +## 测试 ```bash -# 方式一:使用 SQL 脚本(推荐) -mysql -h10.100.51.51 -uroot -pUnis@321 < scripts/init_database.sql - -# 方式二:使用 Python 脚本(仅创建表结构) -python scripts/init_db.py +cd backend && ./venv/bin/python -m pytest ``` -### 4. 启动服务 - -```bash -# 开发模式(自动重载) -python main.py - -# 或使用 uvicorn -uvicorn main:app --reload --host 0.0.0.0 --port 8000 -``` - -服务启动后,访问: -- API 文档: http://localhost:8000/docs -- 健康检查: http://localhost:8000/health - -## API 接口 - -### 认证相关 (`/api/v1/auth`) - -- `POST /register` - 用户注册 -- `POST /login` - 用户登录 -- `GET /me` - 获取当前用户信息 -- `POST /change-password` - 修改密码 - -### 项目管理 (`/api/v1/projects`) - -- `GET /` - 获取我的项目列表 -- `POST /` - 创建新项目 -- `GET /{project_id}` - 获取项目详情 -- `PUT /{project_id}` - 更新项目信息 -- `DELETE /{project_id}` - 删除项目(归档) -- `GET /{project_id}/members` - 获取项目成员 -- `POST /{project_id}/members` - 添加项目成员 - -### 文件系统 (`/api/v1/files`) - -- `GET /{project_id}/tree` - 获取项目目录树 -- `GET /{project_id}/file?path=xxx` - 获取文件内容 -- `POST /{project_id}/file` - 保存文件内容 -- `POST /{project_id}/file/operate` - 文件操作(重命名/删除/创建) -- `POST /{project_id}/upload` - 上传文件(图片/附件) -- `GET /{project_id}/assets/{subfolder}/{filename}` - 获取资源文件 - -## 默认账号 - -初始化数据库后,会创建默认管理员账号: - -- 用户名: `admin` -- 密码: `admin123` - -**⚠️ 生产环境请立即修改默认密码!** - -## 开发指南 - -### 代码风格 - -遵循 PEP 8 代码规范。 - -### 添加新的 API 端点 - -1. 在 `app/api/v1/` 下创建新的路由文件 -2. 在 `app/api/v1/__init__.py` 中注册路由 -3. 在 `app/schemas/` 中定义 Pydantic Schema -4. 在 `app/services/` 中实现业务逻辑 - -### 数据库迁移 - -使用 Alembic 进行数据库迁移: - -```bash -# 生成迁移脚本 -alembic revision --autogenerate -m "描述" - -# 执行迁移 -alembic upgrade head -``` - -## 安全说明 - -1. **路径安全**: 所有文件系统操作都经过路径安全检查,防止路径穿越攻击 -2. **认证鉴权**: 使用 JWT Token 认证,所有需要登录的接口都受保护 -3. **权限控制**: 实现了项目级别的权限控制(owner/admin/editor/viewer) -4. **密码加密**: 使用 bcrypt 加密存储密码 -5. **SQL 注入**: 使用 SQLAlchemy ORM,自动防止 SQL 注入 - -## 许可证 - -Copyright © 2023 Mula.liu +覆盖范围(v1.0.0):项目权限与角色归一化、Git 服务命令构造、搜索服务、模型与向量配置、RAG 引用解析。**前端目前没有自动化测试**,界面回归需手工验证,重点清单见 [`docs/sdd/releases/v1.0.0.md`](../docs/sdd/releases/v1.0.0.md)。 diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index cb6a91b..98d47ad 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -18,6 +18,7 @@ from app.models.project import Project, ProjectMember from app.models.log import OperationLog from app.core.enums import OperationType, ResourceType from app.schemas.response import success_response +from app.services.project_service import normalize_project_role router = APIRouter() @@ -167,7 +168,7 @@ async def get_personal_stats( "id": project.id, "name": project.name, "description": project.description, - "role": member.role, + "role": normalize_project_role(member.role), "joined_at": member.joined_at.isoformat() if member.joined_at else None, } for project, member in recent_shared_projects_rows diff --git a/backend/app/api/v1/files.py b/backend/app/api/v1/files.py index 0a4f423..71b6f34 100644 --- a/backend/app/api/v1/files.py +++ b/backend/app/api/v1/files.py @@ -19,7 +19,6 @@ from app.core.deps import get_current_user, get_user_from_token_or_query from app.models.user import User from app.models.log import OperationLog from app.models.share import ShareLink -from app.models.project import Project, ProjectMember from app.schemas.file import ( FileTreeNode, FileSaveRequest, @@ -40,45 +39,6 @@ from app.core.enums import OperationType router = APIRouter() -async def check_project_access( - project_id: int, - current_user: User, - db: AsyncSession, - require_write: bool = False -): - """检查项目访问权限""" - # 查询项目 - result = await db.execute(select(Project).where(Project.id == project_id)) - project = result.scalar_one_or_none() - - if not project: - raise HTTPException(status_code=404, detail="项目不存在") - - # 检查是否是项目所有者 - if project.owner_id == current_user.id: - return project - - # 检查是否是项目成员 - member_result = await db.execute( - select(ProjectMember).where( - ProjectMember.project_id == project_id, - ProjectMember.user_id == current_user.id - ) - ) - member = member_result.scalar_one_or_none() - - if not member: - if project.is_public == 1 and not require_write: - return project - raise HTTPException(status_code=403, detail="无权访问该项目") - - # 如果需要写权限,检查成员角色 - if require_write and member.role == "viewer": - raise HTTPException(status_code=403, detail="无写入权限") - - return project - - def annotate_shared_files(tree: List[FileTreeNode], shared_paths: set[str]) -> None: """为文件树节点补充分享状态""" for node in tree: @@ -114,19 +74,8 @@ async def get_project_tree( } annotate_shared_files(tree, shared_paths) - # 获取当前用户角色 - user_role = "owner" # 默认是所有者 - if project.owner_id != current_user.id: - # 查询成员角色 - member_result = await db.execute( - select(ProjectMember).where( - ProjectMember.project_id == project_id, - ProjectMember.user_id == current_user.id - ) - ) - member = member_result.scalar_one_or_none() - if member: - user_role = member.role + # user_role 直接来自 require_project_read_access(已做历史大小写归一化), + # 不要在这里再查一次成员表:重复查询曾导致前端拿到 VIEWER 这类历史值后判断失效。 return success_response(data={ "tree": tree, diff --git a/backend/app/api/v1/notifications.py b/backend/app/api/v1/notifications.py index 96499a6..772a773 100644 --- a/backend/app/api/v1/notifications.py +++ b/backend/app/api/v1/notifications.py @@ -1,6 +1,8 @@ """ 通知管理 API (Redis版) """ +import logging + from fastapi import APIRouter, Depends, HTTPException from typing import List, Union from datetime import datetime @@ -15,6 +17,8 @@ from app.schemas.notification import ( from app.schemas.response import success_response from app.services.notification_service import notification_service +logger = logging.getLogger(__name__) + router = APIRouter() @@ -58,7 +62,7 @@ async def get_notifications( } except Exception as e: # 降级处理,防止 500 - print(f"Error fetching notifications: {e}") + logger.warning("读取通知列表失败,已降级为空列表: %s", e, exc_info=True) return { "code": 200, "message": "success", @@ -78,7 +82,7 @@ async def get_unread_count( count = await notification_service.get_unread_count(current_user.id) return success_response(data={"unread_count": count}) except Exception as e: - print(f"Error fetching unread count: {e}") + logger.warning("读取未读通知数量失败,已降级为 0: %s", e, exc_info=True) return success_response(data={"unread_count": 0}) @@ -91,7 +95,7 @@ async def get_unread_by_project( counts = await notification_service.get_unread_count_by_project(current_user.id) return success_response(data={"unread_by_project": counts}) except Exception as e: - print(f"Error fetching unread by project: {e}") + logger.warning("按项目统计未读通知失败,已降级为空: %s", e, exc_info=True) return success_response(data={"unread_by_project": {}}) @@ -105,7 +109,7 @@ async def mark_project_read( count = await notification_service.mark_project_read(current_user.id, project_id) return success_response(data={"marked": count}, message="项目通知已标记为已读") except Exception as e: - print(f"Error marking project read: {e}") + logger.warning("标记项目通知已读失败: %s", e, exc_info=True) return success_response(message="操作完成") diff --git a/backend/app/api/v1/projects.py b/backend/app/api/v1/projects.py index c60d05a..aaa056f 100644 --- a/backend/app/api/v1/projects.py +++ b/backend/app/api/v1/projects.py @@ -30,7 +30,12 @@ from app.services.storage import storage_service from app.services.log_service import log_service from app.services.git_service import git_service from app.services.notification_service import notification_service -from app.services.project_service import normalize_project_role, require_project_roles +from app.services.project_service import ( + normalize_project_role, + require_project_read_access, + require_project_roles, + serialize_project, +) from app.core.enums import OperationType, ResourceType router = APIRouter() @@ -281,31 +286,20 @@ async def get_project( db: AsyncSession = Depends(get_db) ): """获取项目详情""" - # 查询项目 - result = await db.execute(select(Project).where(Project.id == project_id)) - project = result.scalar_one_or_none() - - if not project: - raise HTTPException(status_code=404, detail="项目不存在") - - # 检查权限(项目所有者或成员可访问) - if project.owner_id != current_user.id: - member_result = await db.execute( - select(ProjectMember).where( - ProjectMember.project_id == project_id, - ProjectMember.user_id == current_user.id - ) - ) - member = member_result.scalar_one_or_none() - if not member and project.is_public != 1: - raise HTTPException(status_code=403, detail="无权访问该项目") + # 权限与角色统一走 project_service,避免与其他接口出现两套判定 + project, user_role = await require_project_read_access( + db, project_id, current_user, allow_public=True + ) # 增加访问次数 (简单计数) project.visit_count += 1 await db.commit() + # updated_at 由数据库 onupdate 生成,commit 后该列处于过期状态, + # 必须用异步 refresh 取回,否则序列化时会在异步上下文里触发同步 IO(MissingGreenlet -> 500) + await db.refresh(project) - project_data = ProjectResponse.from_orm(project) - return success_response(data=project_data.dict()) + project_data = serialize_project(project, user_role=user_role) + return success_response(data=project_data) @router.put("/{project_id}", response_model=dict) @@ -690,7 +684,7 @@ async def update_project_member_role( if not target_member: raise HTTPException(status_code=404, detail="该用户不是项目成员") - old_role = target_member.role + old_role = normalize_project_role(target_member.role) target_member.role = member_in.role await db.commit() await db.refresh(target_member) diff --git a/backend/app/api/v1/shares.py b/backend/app/api/v1/shares.py index e303f65..063fa9d 100644 --- a/backend/app/api/v1/shares.py +++ b/backend/app/api/v1/shares.py @@ -23,6 +23,7 @@ from app.schemas.project import FileShareCreate from app.schemas.response import success_response from app.services.log_service import log_service from app.services.pdf_service import pdf_service +from app.services.project_service import normalize_project_role from app.services.search_service import search_service from app.services.storage import storage_service @@ -65,7 +66,7 @@ async def ensure_project_member(project: Project, current_user: User, db: AsyncS member = member_result.scalar_one_or_none() if not member: raise HTTPException(status_code=403, detail="无权访问该项目") - return member.role + return normalize_project_role(member.role) async def get_share_by_code_or_404(share_code: str, share_type: Optional[str], db: AsyncSession) -> ShareLink: diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 6d39437..24a4fef 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -12,7 +12,7 @@ class Settings(BaseSettings): # 应用信息 APP_NAME: str = "NEX Docus" - APP_VERSION: str = "0.9.9" + APP_VERSION: str = "1.0.0" DEBUG: bool = True # 服务器配置 @@ -84,6 +84,32 @@ class Settings(BaseSettings): env_file = ".env" case_sensitive = True + def security_warnings(self) -> List[str]: + """启动自检:返回仍在使用的占位/默认敏感配置说明(不阻断启动,仅告警)。""" + warnings: List[str] = [] + if self.SECRET_KEY in INSECURE_SECRET_KEYS or len(self.SECRET_KEY) < 16: + warnings.append( + "SECRET_KEY 仍是占位值或过短,任何人都可伪造 JWT。" + "请在 .env 中设置为随机值:openssl rand -hex 32" + ) + if self.DEFAULT_USER_PASSWORD in DEFAULT_INSECURE_PASSWORDS: + warnings.append( + "DEFAULT_USER_PASSWORD 仍是仓库内置默认值,管理员新建的账号可被猜到。" + "请在部署配置中改为随机初始密码" + ) + return warnings + + +# 仓库/模板里出现过的占位值:命中即视为未修改 +INSECURE_SECRET_KEYS = { + "your-secret-key-change-me-in-production", + "your-secret-key-change-me-in-production-use-openssl-rand-hex-32", + "change_me", + "change-me", + "secret", +} +DEFAULT_INSECURE_PASSWORDS = {"User@123", "User@123456"} + # 创建配置实例 settings = Settings() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 4f33205..7f784c5 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -8,6 +8,8 @@ from app.models.menu import SystemMenu, RoleMenu from app.models.project import Project, ProjectMember, ProjectMemberRole from app.models.document import DocumentMeta from app.models.document_vector import DocumentVector +from app.models.git_repo import ProjectGitRepo +from app.models.notification import Notification from app.models.share import ShareLink from app.models.log import OperationLog from app.models.mcp_bot import MCPBot @@ -27,6 +29,8 @@ __all__ = [ "ProjectMemberRole", "DocumentMeta", "DocumentVector", + "ProjectGitRepo", + "Notification", "ShareLink", "OperationLog", "MCPBot", diff --git a/backend/app/services/llm_provider_service.py.bak b/backend/app/services/llm_provider_service.py.bak deleted file mode 100644 index 8d842f4..0000000 --- a/backend/app/services/llm_provider_service.py.bak +++ /dev/null @@ -1,794 +0,0 @@ -""" -LLM 提供方测试服务 -""" -import asyncio -import json -import socket -import time -import urllib.error -import urllib.parse -import urllib.request -import ssl -from typing import Any, Dict, List, Optional - - -PROVIDER_CATALOG: List[Dict[str, str]] = [ - { - "value": "openai", - "label": "OpenAI", - "default_endpoint_url": "https://api.openai.com/v1", - "protocol": "openai_compatible", - }, - { - "value": "deepseek", - "label": "DeepSeek", - "default_endpoint_url": "https://api.deepseek.com/v1", - "protocol": "openai_compatible", - }, - { - "value": "anthropic", - "label": "Anthropic", - "default_endpoint_url": "https://api.anthropic.com", - "protocol": "anthropic", - }, - { - "value": "gemini", - "label": "Google Gemini", - "default_endpoint_url": "https://generativelanguage.googleapis.com/v1beta", - "protocol": "gemini", - }, - { - "value": "dashscope", - "label": "阿里百炼", - "default_endpoint_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", - "protocol": "openai_compatible", - }, - { - "value": "zhipu", - "label": "智谱 AI", - "default_endpoint_url": "https://open.bigmodel.cn/api/paas/v4", - "protocol": "openai_compatible", - }, - { - "value": "moonshot", - "label": "Moonshot AI", - "default_endpoint_url": "https://api.moonshot.cn/v1", - "protocol": "openai_compatible", - }, - { - "value": "groq", - "label": "Groq", - "default_endpoint_url": "https://api.groq.com/openai/v1", - "protocol": "openai_compatible", - }, - { - "value": "openrouter", - "label": "OpenRouter", - "default_endpoint_url": "https://openrouter.ai/api/v1", - "protocol": "openai_compatible", - }, - { - "value": "siliconflow", - "label": "SiliconFlow", - "default_endpoint_url": "https://api.siliconflow.cn/v1", - "protocol": "openai_compatible", - }, - { - "value": "ollama", - "label": "Ollama", - "default_endpoint_url": "http://localhost:11434/v1", - "protocol": "openai_compatible", - }, - { - "value": "ark", - "label": "火山方舟", - "default_endpoint_url": "https://ark.cn-beijing.volces.com/api/v3", - "protocol": "openai_compatible", - }, - { - "value": "custom", - "label": "自定义兼容接口", - "default_endpoint_url": "", - "protocol": "openai_compatible", - }, -] - -PROVIDER_MAP = {item["value"]: item for item in PROVIDER_CATALOG} - - -class LLMProviderService: - """LLM 提供方测试服务""" - - @staticmethod - def get_provider_catalog() -> List[Dict[str, str]]: - return PROVIDER_CATALOG - - @staticmethod - def get_provider_label(provider: Optional[str]) -> str: - if not provider: - return "自定义模型" - return PROVIDER_MAP.get(provider, {}).get("label", provider) - - @staticmethod - def get_default_endpoint_url(provider: Optional[str]) -> str: - if not provider: - return "" - return PROVIDER_MAP.get(provider, {}).get("default_endpoint_url", "") - - @classmethod - def build_model_name(cls, provider: Optional[str], llm_model_name: str) -> str: - model_name = (llm_model_name or "").strip() - label = cls.get_provider_label(provider) - if not model_name: - return label - return f"{label} {model_name}" - - @staticmethod - def build_model_code(provider: Optional[str], llm_model_name: str) -> str: - provider_part = (provider or "custom").strip().lower() - model_part = (llm_model_name or "").strip().lower() - sanitized = [] - previous_is_separator = False - for char in model_part: - if char.isalnum(): - sanitized.append(char) - previous_is_separator = False - else: - if not previous_is_separator: - sanitized.append("_") - previous_is_separator = True - - model_slug = "".join(sanitized).strip("_") or "model" - return f"llm_{provider_part}_{model_slug}" - - @classmethod - async def test_model_connection(cls, payload: Dict[str, Any]) -> Dict[str, Any]: - provider = payload.get("provider") - provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"]) - endpoint_url = (payload.get("endpoint_url") or provider_meta.get("default_endpoint_url") or "").strip() - llm_model_name = (payload.get("llm_model_name") or "").strip() - api_key = (payload.get("api_key") or "").strip() - - if not endpoint_url: - raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url") - if not llm_model_name: - raise ValueError("缺少模型名称,请填写 llm_model_name") - if provider not in {"ollama"} and not api_key: - raise ValueError("缺少 API Key,请填写后再测试") - - timeout = int(payload.get("llm_timeout") or 120) - temperature = float(payload.get("llm_temperature") or 0.7) - top_p = float(payload.get("llm_top_p") or 0.9) - max_tokens = int(payload.get("llm_max_tokens") or 2048) - system_prompt = (payload.get("llm_system_prompt") or "").strip() - - started_at = time.perf_counter() - preview = await asyncio.to_thread( - cls._send_test_request, - provider_meta.get("protocol", "openai_compatible"), - provider, - endpoint_url, - api_key, - llm_model_name, - timeout, - temperature, - top_p, - max_tokens, - system_prompt, - ) - latency_ms = int((time.perf_counter() - started_at) * 1000) - - return { - "provider": provider, - "endpoint_url": endpoint_url, - "llm_model_name": llm_model_name, - "latency_ms": latency_ms, - "preview": preview[:200], - } - - @classmethod - def _send_test_request( - cls, - protocol: str, - provider: Optional[str], - endpoint_url: str, - api_key: str, - llm_model_name: str, - timeout: int, - temperature: float, - top_p: float, - max_tokens: int, - system_prompt: str, - ) -> str: - if protocol == "anthropic": - return cls._test_anthropic( - endpoint_url, - api_key, - llm_model_name, - timeout, - temperature, - top_p, - max_tokens, - system_prompt, - ) - - if protocol == "gemini": - return cls._test_gemini( - endpoint_url, - api_key, - llm_model_name, - timeout, - temperature, - top_p, - max_tokens, - system_prompt, - ) - - return cls._test_openai_compatible( - provider, - endpoint_url, - api_key, - llm_model_name, - timeout, - temperature, - top_p, - max_tokens, - system_prompt, - ) - - @classmethod - def _test_openai_compatible( - cls, - provider: Optional[str], - endpoint_url: str, - api_key: str, - llm_model_name: str, - timeout: int, - temperature: float, - top_p: float, - max_tokens: int, - system_prompt: str, - ) -> str: - url = cls._join_endpoint(endpoint_url, "/chat/completions") - headers = { - "Content-Type": "application/json", - } - if api_key: - headers["Authorization"] = f"Bearer {api_key}" - - payload = { - "model": llm_model_name, - "messages": cls._build_openai_messages(system_prompt), - "temperature": temperature, - "top_p": top_p, - "max_tokens": min(max_tokens, 256), - "stream": False, - } - response = cls._request_json(url, headers, payload, timeout) - choices = response.get("choices") or [] - if not choices: - raise ValueError("测试请求已发送,但未收到模型返回内容") - - content = choices[0].get("message", {}).get("content", "") - if isinstance(content, list): - content = "".join( - item.get("text", "") if isinstance(item, dict) else str(item) - for item in content - ) - content = str(content).strip() - if not content: - raise ValueError("模型返回成功,但内容为空") - return content - - @classmethod - def _test_anthropic( - cls, - endpoint_url: str, - api_key: str, - llm_model_name: str, - timeout: int, - temperature: float, - top_p: float, - max_tokens: int, - system_prompt: str, - ) -> str: - url = cls._join_endpoint(endpoint_url, "/v1/messages") - headers = { - "Content-Type": "application/json", - "x-api-key": api_key, - "anthropic-version": "2023-06-01", - } - payload = { - "model": llm_model_name, - "max_tokens": min(max_tokens, 256), - "temperature": temperature, - "top_p": top_p, - "messages": [ - { - "role": "user", - "content": "请只回复“连接测试成功”。", - } - ], - } - if system_prompt: - payload["system"] = system_prompt - - response = cls._request_json(url, headers, payload, timeout) - content = response.get("content") or [] - texts = [] - for item in content: - if isinstance(item, dict) and item.get("type") == "text": - texts.append(item.get("text", "")) - preview = "".join(texts).strip() - if not preview: - raise ValueError("模型返回成功,但内容为空") - return preview - - @classmethod - def _test_gemini( - cls, - endpoint_url: str, - api_key: str, - llm_model_name: str, - timeout: int, - temperature: float, - top_p: float, - max_tokens: int, - system_prompt: str, - ) -> str: - model_path = llm_model_name if llm_model_name.startswith("models/") else f"models/{llm_model_name}" - encoded_model_path = "/".join(urllib.parse.quote(part) for part in model_path.split("/")) - url = f"{endpoint_url.rstrip('/')}/{encoded_model_path}:generateContent?key={urllib.parse.quote(api_key)}" - headers = { - "Content-Type": "application/json", - } - payload = { - "contents": [ - { - "role": "user", - "parts": [{"text": "请只回复“连接测试成功”。"}], - } - ], - "generationConfig": { - "temperature": temperature, - "topP": top_p, - "maxOutputTokens": min(max_tokens, 256), - }, - } - if system_prompt: - payload["systemInstruction"] = { - "parts": [{"text": system_prompt}], - } - - response = cls._request_json(url, headers, payload, timeout) - candidates = response.get("candidates") or [] - if not candidates: - raise ValueError("测试请求已发送,但未收到模型返回内容") - - parts = candidates[0].get("content", {}).get("parts", []) - preview = "".join( - part.get("text", "") for part in parts if isinstance(part, dict) - ).strip() - if not preview: - raise ValueError("模型返回成功,但内容为空") - return preview - - @staticmethod - def _build_openai_messages(system_prompt: str) -> List[Dict[str, str]]: - messages: List[Dict[str, str]] = [] - if system_prompt: - messages.append({"role": "system", "content": system_prompt}) - messages.append({"role": "user", "content": "请只回复“连接测试成功”。"}) - return messages - - @staticmethod - def _join_endpoint(base_url: str, suffix: str) -> str: - normalized = base_url.rstrip("/") - if normalized.endswith(suffix): - return normalized - return f"{normalized}{suffix}" - - @classmethod - def _request_json( - cls, - url: str, - headers: Dict[str, str], - payload: Dict[str, Any], - timeout: int, - ) -> Dict[str, Any]: - request = urllib.request.Request( - url, - data=json.dumps(payload).encode("utf-8"), - headers=headers, - method="POST", - ) - - ctx = None - if os.getenv("DISABLE_SSL_VERIFY", "").lower() in ("1", "true", "yes"): - ctx = ssl.create_default_context() - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE - - try: - with urllib.request.urlopen(request, timeout=timeout, context=ctx) as response: - body = response.read().decode("utf-8") - if not body: - return {} - return json.loads(body) - except urllib.error.HTTPError as exc: - error_body = exc.read().decode("utf-8", errors="ignore") - message = cls._extract_error_message(error_body) or error_body[:300] or str(exc) - raise ValueError(f"模型测试失败(HTTP {exc.code}):{message}") from exc - except urllib.error.URLError as exc: - reason = exc.reason - if isinstance(reason, socket.timeout): - raise ValueError("模型测试超时,请检查网络或调大超时时间") from exc - raise ValueError(f"模型测试失败:{reason}") from exc - except socket.timeout as exc: - raise ValueError("模型测试超时,请检查网络或调大超时时间") from exc - except json.JSONDecodeError as exc: - raise ValueError("模型服务返回了无法解析的响应,请检查接口地址是否正确") from exc - - @staticmethod - def _extract_error_message(error_body: str) -> Optional[str]: - if not error_body: - return None - - try: - payload = json.loads(error_body) - except json.JSONDecodeError: - return error_body.strip() - - if isinstance(payload, dict): - if isinstance(payload.get("error"), dict): - return payload["error"].get("message") or payload["error"].get("type") - return payload.get("message") or payload.get("detail") - - return None - - # ==================== 对话文本生成(RAG 调用) ==================== - - @classmethod - async def generate_text( - cls, - provider: Optional[str], - endpoint_url: str, - api_key: str, - llm_model_name: str, - timeout: int, - temperature: float, - top_p: float, - max_tokens: int, - system_prompt: str, - messages: List[Dict[str, Any]], - ) -> str: - """根据多轮对话消息生成回复文本(供知识库 RAG 使用)""" - provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"]) - endpoint_url = (endpoint_url or provider_meta.get("default_endpoint_url") or "").strip() - llm_model_name = (llm_model_name or "").strip() - api_key = (api_key or "").strip() - - if not endpoint_url: - raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url") - if not llm_model_name: - raise ValueError("缺少模型名称,请填写 llm_model_name") - if provider not in {"ollama"} and not api_key: - raise ValueError("缺少 API Key,请填写后再调用") - - normalized = cls._normalize_messages(messages) - if not normalized: - raise ValueError("缺少有效的对话消息") - - protocol = provider_meta.get("protocol", "openai_compatible") - return await asyncio.to_thread( - cls._chat_completion, - protocol, - endpoint_url, - api_key, - llm_model_name, - int(timeout or 120), - float(temperature or 0.7), - float(top_p or 0.9), - int(max_tokens or 2048), - (system_prompt or "").strip(), - normalized, - ) - - @staticmethod - def _normalize_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, str]]: - normalized: List[Dict[str, str]] = [] - for item in messages or []: - if not isinstance(item, dict): - continue - role = str(item.get("role") or "").strip().lower() - if role not in {"system", "user", "assistant"}: - continue - content = item.get("content", "") - if isinstance(content, list): - content = "".join( - part.get("text", "") if isinstance(part, dict) else str(part) - for part in content - ) - content = str(content).strip() - if not content: - continue - normalized.append({"role": role, "content": content}) - return normalized - - @classmethod - def _chat_completion( - cls, - protocol: str, - endpoint_url: str, - api_key: str, - llm_model_name: str, - timeout: int, - temperature: float, - top_p: float, - max_tokens: int, - system_prompt: str, - messages: List[Dict[str, str]], - ) -> str: - if protocol == "anthropic": - return cls._chat_anthropic( - endpoint_url, api_key, llm_model_name, timeout, - temperature, top_p, max_tokens, system_prompt, messages, - ) - if protocol == "gemini": - return cls._chat_gemini( - endpoint_url, api_key, llm_model_name, timeout, - temperature, top_p, max_tokens, system_prompt, messages, - ) - return cls._chat_openai_compatible( - endpoint_url, api_key, llm_model_name, timeout, - temperature, top_p, max_tokens, system_prompt, messages, - ) - - @classmethod - def _chat_openai_compatible( - cls, - endpoint_url: str, - api_key: str, - llm_model_name: str, - timeout: int, - temperature: float, - top_p: float, - max_tokens: int, - system_prompt: str, - messages: List[Dict[str, str]], - ) -> str: - url = cls._join_endpoint(endpoint_url, "/chat/completions") - headers = {"Content-Type": "application/json"} - if api_key: - headers["Authorization"] = f"Bearer {api_key}" - - payload_messages: List[Dict[str, str]] = [] - if system_prompt: - payload_messages.append({"role": "system", "content": system_prompt}) - payload_messages.extend(messages) - - payload = { - "model": llm_model_name, - "messages": payload_messages, - "temperature": temperature, - "top_p": top_p, - "max_tokens": max_tokens, - "stream": False, - } - response = cls._request_json(url, headers, payload, timeout) - choices = response.get("choices") or [] - if not choices: - raise ValueError("请求已发送,但未收到模型返回内容") - content = choices[0].get("message", {}).get("content", "") - if isinstance(content, list): - content = "".join( - item.get("text", "") if isinstance(item, dict) else str(item) - for item in content - ) - content = str(content).strip() - if not content: - raise ValueError("模型返回成功,但内容为空") - return content - - @classmethod - def _chat_anthropic( - cls, - endpoint_url: str, - api_key: str, - llm_model_name: str, - timeout: int, - temperature: float, - top_p: float, - max_tokens: int, - system_prompt: str, - messages: List[Dict[str, str]], - ) -> str: - url = cls._join_endpoint(endpoint_url, "/v1/messages") - headers = { - "Content-Type": "application/json", - "x-api-key": api_key, - "anthropic-version": "2023-06-01", - } - payload = { - "model": llm_model_name, - "max_tokens": max_tokens, - "temperature": temperature, - "top_p": top_p, - "messages": [ - {"role": m["role"], "content": [{"type": "text", "text": m["content"]}]} - for m in messages - if m["role"] in {"user", "assistant"} - ], - } - if system_prompt: - payload["system"] = system_prompt - - response = cls._request_json(url, headers, payload, timeout) - content = response.get("content") or [] - texts = [ - item.get("text", "") - for item in content - if isinstance(item, dict) and item.get("type") == "text" - ] - preview = "".join(texts).strip() - if not preview: - raise ValueError("模型返回成功,但内容为空") - return preview - - @classmethod - def _chat_gemini( - cls, - endpoint_url: str, - api_key: str, - llm_model_name: str, - timeout: int, - temperature: float, - top_p: float, - max_tokens: int, - system_prompt: str, - messages: List[Dict[str, str]], - ) -> str: - model_path = llm_model_name if llm_model_name.startswith("models/") else f"models/{llm_model_name}" - encoded_model_path = "/".join(urllib.parse.quote(part) for part in model_path.split("/")) - url = f"{endpoint_url.rstrip('/')}/{encoded_model_path}:generateContent?key={urllib.parse.quote(api_key)}" - headers = {"Content-Type": "application/json"} - - role_map = {"user": "user", "assistant": "model"} - contents = [ - {"role": role_map[m["role"]], "parts": [{"text": m["content"]}]} - for m in messages - if m["role"] in role_map - ] - payload = { - "contents": contents, - "generationConfig": { - "temperature": temperature, - "topP": top_p, - "maxOutputTokens": max_tokens, - }, - } - if system_prompt: - payload["systemInstruction"] = {"parts": [{"text": system_prompt}]} - - response = cls._request_json(url, headers, payload, timeout) - candidates = response.get("candidates") or [] - if not candidates: - raise ValueError("请求已发送,但未收到模型返回内容") - parts = candidates[0].get("content", {}).get("parts", []) - preview = "".join( - part.get("text", "") for part in parts if isinstance(part, dict) - ).strip() - if not preview: - raise ValueError("模型返回成功,但内容为空") - return preview - - # ==================== Embedding 向量生成与测试 ==================== - - @classmethod - async def generate_embedding( - cls, - provider: Optional[str], - endpoint_url: str, - api_key: str, - llm_model_name: str, - text: str, - timeout: int = 60, - dimension: Optional[int] = None, - ) -> List[float]: - """调用 OpenAI 兼容的 /embeddings 接口生成单条文本向量""" - provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"]) - endpoint_url = (endpoint_url or provider_meta.get("default_endpoint_url") or "").strip() - llm_model_name = (llm_model_name or "").strip() - api_key = (api_key or "").strip() - text = (text or "").strip() - - if not endpoint_url: - raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url") - if not llm_model_name: - raise ValueError("缺少模型名称,请填写 embedding 模型标识") - if provider not in {"ollama"} and not api_key: - raise ValueError("缺少 API Key,请填写后再调用") - if not text: - raise ValueError("待向量化文本为空") - - vectors = await asyncio.to_thread( - cls._embeddings_request, - endpoint_url, - api_key, - llm_model_name, - [text[:8000]], - int(timeout or 60), - dimension, - ) - if not vectors: - raise ValueError("Embedding 接口返回为空") - return vectors[0] - - @classmethod - def _embeddings_request( - cls, - endpoint_url: str, - api_key: str, - llm_model_name: str, - inputs: List[str], - timeout: int, - dimension: Optional[int], - ) -> List[List[float]]: - url = cls._join_endpoint(endpoint_url, "/embeddings") - headers = {"Content-Type": "application/json"} - if api_key: - headers["Authorization"] = f"Bearer {api_key}" - - payload: Dict[str, Any] = { - "model": llm_model_name, - "input": inputs, - } - if dimension: - payload["dimensions"] = dimension - - response = cls._request_json(url, headers, payload, timeout) - data = response.get("data") or [] - if not data: - raise ValueError("Embedding 请求已发送,但未收到向量数据") - - vectors: List[List[float]] = [] - for item in data: - embedding = item.get("embedding") if isinstance(item, dict) else None - if embedding: - vectors.append([float(x) for x in embedding]) - if not vectors: - raise ValueError("Embedding 返回结果中未解析到向量") - return vectors - - @classmethod - async def test_embedding_connection(cls, payload: Dict[str, Any]) -> Dict[str, Any]: - """测试 embedding 模型连通性,并返回实际向量维度""" - provider = payload.get("provider") - provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"]) - endpoint_url = (payload.get("endpoint_url") or provider_meta.get("default_endpoint_url") or "").strip() - llm_model_name = (payload.get("llm_model_name") or "").strip() - api_key = (payload.get("api_key") or "").strip() - timeout = int(payload.get("llm_timeout") or 60) - dimension = payload.get("embedding_dimension") - - started_at = time.perf_counter() - vector = await cls.generate_embedding( - provider, - endpoint_url, - api_key, - llm_model_name, - "连接测试", - timeout=timeout, - dimension=int(dimension) if dimension else None, - ) - latency_ms = int((time.perf_counter() - started_at) * 1000) - - return { - "provider": provider, - "endpoint_url": endpoint_url, - "llm_model_name": llm_model_name, - "latency_ms": latency_ms, - "dimension": len(vector), - "preview": f"成功生成 {len(vector)} 维向量", - } - diff --git a/backend/main.py b/backend/main.py index 93bd4fa..3531b28 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,6 +3,7 @@ FastAPI 主应用 """ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +import logging from contextlib import asynccontextmanager from app.core.config import settings from app.core.redis_client import init_redis, close_redis @@ -19,9 +20,15 @@ except RuntimeError: mcp_session_manager = None +logger = logging.getLogger(__name__) + + @asynccontextmanager async def lifespan(app: FastAPI): """应用生命周期管理""" + # 敏感配置自检:命中占位值时告警(不阻断启动,避免影响存量部署) + for warning in settings.security_warnings(): + logger.warning("[安全自检] %s", warning) # 启动时补齐数据库新增列(幂等),避免存量库缺少新字段 await migrate_schema() # 启动时初始化 Redis diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..0268209 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +testpaths = tests +pythonpath = . +addopts = -q diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..9cafadd --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,6 @@ +# 开发/测试依赖(在生产依赖之外额外安装) +# ./venv/bin/pip install -r requirements-dev.txt +# ./venv/bin/python -m pytest -q +-r requirements.txt +pytest==8.3.4 +pytest-asyncio==0.25.0 diff --git a/backend/scripts/README.md b/backend/scripts/README.md new file mode 100644 index 0000000..6830372 --- /dev/null +++ b/backend/scripts/README.md @@ -0,0 +1,25 @@ +# backend/scripts —— 随镜像发布的运行时脚本 + +这里的脚本会被 `backend/Dockerfile` 的 `COPY . .` 打进后端镜像,并在容器启动时执行: + +```dockerfile +CMD ["sh", "-c", "python scripts/init_db.py && exec uvicorn main:app --host 0.0.0.0 --port 8000"] +``` + +因此它们**必须留在 `backend/` 内**(Compose 的 build context 就是 `./backend`, +无法 COPY 仓库根的 `scripts/`)。仓库层面的运维脚本请放到根目录 [`scripts/`](../../scripts/)。 + +| 脚本 | 用途 | +| --- | --- | +| `init_db.py` | 幂等地建表(`Base.metadata.create_all`)、执行结构迁移(`app.core.migrations.migrate_schema`)、初始化角色/菜单/管理员 | +| `generate_password.py` | 生成管理员密码的 bcrypt 哈希,用于手工建号等运维场景 | + +## 数据库结构如何演进 + +1. 新增表:在 `backend/app/models/` 定义模型即可,`create_all` 会自动建表。 +2. 新增列:在 `backend/app/core/migrations.py` 追加一条 + `(表名, 列名, "ALTER TABLE ... ADD COLUMN ...")`,启动时幂等补齐。 +3. 新增种子数据(菜单/角色等):写进 `init_db.py`,保证全新部署与存量部署结果一致。 + +> 历史遗留的一次性 `*.sql` / `add_*.py` 补丁脚本已在 v0.9.9 → v1.0.0 整理中删除, +> 它们的语义已完全被上述幂等机制覆盖。 diff --git a/backend/scripts/add_git_columns.sql b/backend/scripts/add_git_columns.sql deleted file mode 100644 index da29561..0000000 --- a/backend/scripts/add_git_columns.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE projects -ADD COLUMN git_repo_url VARCHAR(255) COMMENT 'Git仓库地址', -ADD COLUMN git_branch VARCHAR(50) DEFAULT 'main' COMMENT 'Git分支', -ADD COLUMN git_username VARCHAR(100) COMMENT 'Git用户名', -ADD COLUMN git_token VARCHAR(255) COMMENT 'Git访问令牌/密码'; diff --git a/backend/scripts/add_git_repo_sync_path.sql b/backend/scripts/add_git_repo_sync_path.sql deleted file mode 100644 index 65201e6..0000000 --- a/backend/scripts/add_git_repo_sync_path.sql +++ /dev/null @@ -1,3 +0,0 @@ --- 为项目Git仓库表增加“同步目录”字段(空=整个仓库) -ALTER TABLE project_git_repos -ADD COLUMN sync_path VARCHAR(255) DEFAULT '' COMMENT '同步目录(空=整个仓库)' AFTER is_default; diff --git a/backend/scripts/add_role_permissions_menu.py b/backend/scripts/add_role_permissions_menu.py deleted file mode 100644 index 34755b9..0000000 --- a/backend/scripts/add_role_permissions_menu.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -添加角色权限管理菜单到数据库 -""" -import sys -import asyncio -from pathlib import Path - -# 添加项目根目录到 Python 路径 -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from sqlalchemy import text -from app.core.database import async_session - - -async def add_role_permissions_menu(): - """添加角色权限管理菜单""" - print("正在添加角色权限管理菜单...") - - async with async_session() as session: - # 1. 检查菜单是否已存在 - result = await session.execute( - text("SELECT COUNT(*) FROM system_menus WHERE id = 14") - ) - count = result.scalar() - - if count > 0: - print(" 菜单已存在,跳过添加") - return - - # 2. 插入菜单项 - await session.execute( - text(""" - INSERT INTO system_menus ( - id, parent_id, menu_name, menu_code, menu_type, - path, component, icon, sort_order, visible, status, - created_at, updated_at - ) VALUES ( - 14, 4, '角色权限管理', 'system:role_permissions', 1, - '/role-permissions', 'RolePermissions', 'SafetyOutlined', - 6, 1, 1, NOW(), NOW() - ) - """) - ) - print("✓ 菜单项添加成功") - - # 3. 为超级管理员角色分配菜单权限 - result = await session.execute( - text("SELECT id FROM roles WHERE role_code = 'super_admin'") - ) - super_admin_role_id = result.scalar() - - if super_admin_role_id: - await session.execute( - text(""" - INSERT INTO role_menus (role_id, menu_id, created_at) - VALUES (:role_id, 14, NOW()) - """), - {"role_id": super_admin_role_id} - ) - print(f"✓ 已为超级管理员角色分配权限") - - # 4. 为管理员角色分配菜单权限 - result = await session.execute( - text("SELECT id FROM roles WHERE role_code = 'admin'") - ) - admin_role_id = result.scalar() - - if admin_role_id: - await session.execute( - text(""" - INSERT INTO role_menus (role_id, menu_id, created_at) - VALUES (:role_id, 14, NOW()) - """), - {"role_id": admin_role_id} - ) - print(f"✓ 已为管理员角色分配权限") - - await session.commit() - print("\n✓ 角色权限管理菜单添加完成!") - - -async def main(): - """主函数""" - print("=" * 60) - print("添加角色权限管理菜单") - print("=" * 60) - print() - - try: - await add_role_permissions_menu() - print() - print("=" * 60) - print("✓ 操作完成!") - print("=" * 60) - except Exception as e: - print(f"\n✗ 操作失败: {str(e)}") - import traceback - traceback.print_exc() - sys.exit(1) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/backend/scripts/add_role_permissions_menu.sql b/backend/scripts/add_role_permissions_menu.sql deleted file mode 100644 index 39cac10..0000000 --- a/backend/scripts/add_role_permissions_menu.sql +++ /dev/null @@ -1,73 +0,0 @@ --- 添加角色权限管理菜单 --- 执行时间: 2024-12-23 - --- 1. 插入菜单项 -INSERT INTO system_menus ( - id, - parent_id, - menu_name, - menu_code, - menu_type, - path, - component, - icon, - sort_order, - visible, - status, - created_at, - updated_at -) VALUES ( - 14, - 4, - '角色权限管理', - 'system:role_permissions', - 1, - '/role-permissions', - 'RolePermissions', - 'SafetyOutlined', - 6, - 1, - 1, - NOW(), - NOW() -); - --- 2. 为超级管理员角色分配该菜单权限 -INSERT INTO role_menus (role_id, menu_id, created_at) -SELECT r.id, 14, NOW() -FROM roles r -WHERE r.role_code = 'super_admin' -AND NOT EXISTS ( - SELECT 1 FROM role_menus rm - WHERE rm.role_id = r.id AND rm.menu_id = 14 -); - --- 3. 为管理员角色分配该菜单权限 -INSERT INTO role_menus (role_id, menu_id, created_at) -SELECT r.id, 14, NOW() -FROM roles r -WHERE r.role_code = 'admin' -AND NOT EXISTS ( - SELECT 1 FROM role_menus rm - WHERE rm.role_id = r.id AND rm.menu_id = 14 -); - --- 验证结果 -SELECT - m.id, - m.menu_name, - m.menu_code, - m.path, - CASE WHEN m.parent_id = 0 THEN '根菜单' ELSE CONCAT('子菜单 (父ID: ', m.parent_id, ')') END as menu_level -FROM system_menus m -WHERE m.id = 14; - --- 查看哪些角色拥有此菜单权限 -SELECT - r.role_name, - r.role_code, - m.menu_name -FROM roles r -JOIN role_menus rm ON r.id = rm.role_id -JOIN system_menus m ON rm.menu_id = m.id -WHERE m.id = 14; diff --git a/backend/scripts/add_user_role_menus.py b/backend/scripts/add_user_role_menus.py deleted file mode 100644 index d8f75d8..0000000 --- a/backend/scripts/add_user_role_menus.py +++ /dev/null @@ -1,147 +0,0 @@ -""" -添加用户管理和角色管理菜单到数据库 -""" -import sys -import asyncio -from pathlib import Path - -# 添加项目根目录到 Python 路径 -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from sqlalchemy import text -from app.core.database import async_session - - -async def add_user_role_menus(): - """添加用户管理和角色管理菜单""" - print("正在添加用户管理和角色管理菜单...") - - async with async_session() as session: - # 1. 检查菜单是否已存在 - result = await session.execute( - text("SELECT COUNT(*) FROM system_menus WHERE id IN (15, 16)") - ) - count = result.scalar() - - if count > 0: - print(f" 菜单已存在({count}个),跳过添加") - return - - # 2. 插入用户管理菜单(ID: 15) - await session.execute( - text(""" - INSERT INTO system_menus ( - id, parent_id, menu_name, menu_code, menu_type, - path, component, icon, sort_order, visible, status, - created_at, updated_at - ) VALUES ( - 15, 4, '用户管理', 'system:users', 1, - '/users', 'UserManagement', 'UserOutlined', - 1, 1, 1, NOW(), NOW() - ) - """) - ) - print("✓ 用户管理菜单添加成功(ID: 15)") - - # 3. 插入角色管理菜单(ID: 16) - await session.execute( - text(""" - INSERT INTO system_menus ( - id, parent_id, menu_name, menu_code, menu_type, - path, component, icon, sort_order, visible, status, - created_at, updated_at - ) VALUES ( - 16, 4, '角色管理', 'system:roles', 1, - '/roles', 'RoleManagement', 'TeamOutlined', - 2, 1, 1, NOW(), NOW() - ) - """) - ) - print("✓ 角色管理菜单添加成功(ID: 16)") - - # 4. 更新已有菜单的sort_order,避免冲突 - await session.execute( - text(""" - UPDATE system_menus - SET sort_order = sort_order + 2 - WHERE parent_id = 4 AND id NOT IN (15, 16) AND sort_order >= 1 - """) - ) - print("✓ 已调整其他子菜单的排序") - - # 5. 为超级管理员角色分配这两个菜单权限 - result = await session.execute( - text("SELECT id FROM roles WHERE role_code = 'super_admin'") - ) - super_admin_role_id = result.scalar() - - if super_admin_role_id: - # 用户管理菜单 - await session.execute( - text(""" - INSERT INTO role_menus (role_id, menu_id, created_at) - VALUES (:role_id, 15, NOW()) - """), - {"role_id": super_admin_role_id} - ) - # 角色管理菜单 - await session.execute( - text(""" - INSERT INTO role_menus (role_id, menu_id, created_at) - VALUES (:role_id, 16, NOW()) - """), - {"role_id": super_admin_role_id} - ) - print(f"✓ 已为超级管理员角色分配权限") - - # 6. 为管理员角色分配这两个菜单权限(如果存在) - result = await session.execute( - text("SELECT id FROM roles WHERE role_code = 'admin'") - ) - admin_role_id = result.scalar() - - if admin_role_id: - # 用户管理菜单 - await session.execute( - text(""" - INSERT INTO role_menus (role_id, menu_id, created_at) - VALUES (:role_id, 15, NOW()) - """), - {"role_id": admin_role_id} - ) - # 角色管理菜单 - await session.execute( - text(""" - INSERT INTO role_menus (role_id, menu_id, created_at) - VALUES (:role_id, 16, NOW()) - """), - {"role_id": admin_role_id} - ) - print(f"✓ 已为管理员角色分配权限") - - await session.commit() - print("\n✓ 用户管理和角色管理菜单添加完成!") - - -async def main(): - """主函数""" - print("=" * 80) - print("添加用户管理和角色管理菜单") - print("=" * 80) - print() - - try: - await add_user_role_menus() - print() - print("=" * 80) - print("✓ 操作完成!现在可以在系统管理菜单中访问用户管理和角色管理了") - print("=" * 80) - except Exception as e: - print(f"\n✗ 操作失败: {str(e)}") - import traceback - traceback.print_exc() - sys.exit(1) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/backend/scripts/create_chat_tables.sql b/backend/scripts/create_chat_tables.sql deleted file mode 100644 index 1eba4bf..0000000 --- a/backend/scripts/create_chat_tables.sql +++ /dev/null @@ -1,33 +0,0 @@ --- 创建知识库对话会话表 -CREATE TABLE IF NOT EXISTS `chat_session` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '会话ID', - `project_id` BIGINT NOT NULL COMMENT '项目ID', - `user_id` BIGINT NOT NULL COMMENT '用户ID', - `llm_config_id` BIGINT NOT NULL COMMENT 'LLM配置ID', - `title` VARCHAR(255) NOT NULL COMMENT '会话标题', - `description` TEXT DEFAULT NULL COMMENT '会话描述', - `is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否激活', - `message_count` INT NOT NULL DEFAULT 0 COMMENT '消息数', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - INDEX `idx_project_id` (`project_id`), - INDEX `idx_user_id` (`user_id`), - INDEX `idx_project_user` (`project_id`, `user_id`), - FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`llm_config_id`) REFERENCES `llm_model_config`(`config_id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识库对话会话表'; - --- 创建知识库对话消息表 -CREATE TABLE IF NOT EXISTS `chat_message` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '消息ID', - `session_id` BIGINT NOT NULL COMMENT '会话ID', - `role` VARCHAR(32) NOT NULL COMMENT '角色(user/assistant)', - `content` TEXT NOT NULL COMMENT '消息内容', - `referenced_files` TEXT DEFAULT NULL COMMENT '参考文件(JSON数组)', - `tokens_used` INT DEFAULT NULL COMMENT '消耗的token数', - `is_deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已删除', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - INDEX `idx_session_id` (`session_id`), - FOREIGN KEY (`session_id`) REFERENCES `chat_session`(`id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识库对话消息表'; diff --git a/backend/scripts/create_document_vector_table.sql b/backend/scripts/create_document_vector_table.sql deleted file mode 100644 index ecaf81f..0000000 --- a/backend/scripts/create_document_vector_table.sql +++ /dev/null @@ -1,17 +0,0 @@ -CREATE TABLE IF NOT EXISTS `document_vector` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '向量ID', - `project_id` BIGINT NOT NULL COMMENT '项目ID', - `file_path` VARCHAR(500) NOT NULL COMMENT '文件相对路径', - `chunk_index` INT NOT NULL DEFAULT 0 COMMENT '分块序号(0起),同一文件可有多个分块', - `chunk_text` TEXT DEFAULT NULL COMMENT '分块首段文本,作为点击引用时的定位锚点', - `content_hash` VARCHAR(64) DEFAULT NULL COMMENT '整个文件内容哈希值,用于判断文件是否变更', - `zvec_id` VARCHAR(256) DEFAULT NULL COMMENT 'ZVec返回的向量ID(每个分块独立)', - `zvec_response` TEXT DEFAULT NULL COMMENT 'ZVec完整响应JSON', - `status` VARCHAR(32) NOT NULL DEFAULT 'success' COMMENT '向量化状态:success/failed/pending', - `error_message` VARCHAR(500) DEFAULT NULL COMMENT '错误信息', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - INDEX `idx_project_file` (`project_id`, `file_path`), - INDEX `idx_project_file_chunk` (`project_id`, `file_path`, `chunk_index`), - FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文档向量表'; diff --git a/backend/scripts/create_git_repos_table.sql b/backend/scripts/create_git_repos_table.sql deleted file mode 100644 index eba6bb0..0000000 --- a/backend/scripts/create_git_repos_table.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE TABLE `project_git_repos` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'ID', - `project_id` BIGINT NOT NULL COMMENT '项目ID', - `name` VARCHAR(50) NOT NULL COMMENT '仓库别名', - `repo_url` VARCHAR(255) NOT NULL COMMENT 'Git仓库地址', - `branch` VARCHAR(50) DEFAULT 'main' COMMENT 'Git分支', - `username` VARCHAR(100) DEFAULT NULL COMMENT 'Git用户名', - `token` VARCHAR(255) DEFAULT NULL COMMENT 'Git访问令牌/密码', - `is_default` TINYINT DEFAULT 0 COMMENT '是否默认仓库', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - INDEX `idx_project_id` (`project_id`), - FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目Git仓库表'; diff --git a/backend/scripts/create_mcp_bots_table.sql b/backend/scripts/create_mcp_bots_table.sql deleted file mode 100644 index d664c4a..0000000 --- a/backend/scripts/create_mcp_bots_table.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE TABLE IF NOT EXISTS `mcp_bots` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'Bot credential ID', - `user_id` BIGINT NOT NULL COMMENT 'Owner user ID', - `bot_id` VARCHAR(64) NOT NULL COMMENT 'External MCP bot id', - `bot_secret` VARCHAR(255) NOT NULL COMMENT 'External MCP bot secret', - `status` TINYINT DEFAULT 1 COMMENT 'Status: 0-disabled 1-enabled', - `last_used_at` DATETIME DEFAULT NULL COMMENT 'Last successful MCP access time', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'Created at', - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Updated at', - UNIQUE KEY `uk_mcp_bots_user_id` (`user_id`), - UNIQUE KEY `uk_mcp_bots_bot_id` (`bot_id`), - INDEX `idx_mcp_bots_status` (`status`), - CONSTRAINT `fk_mcp_bots_user_id` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MCP bot credentials'; diff --git a/backend/scripts/create_nex_design_project.py b/backend/scripts/create_nex_design_project.py deleted file mode 100644 index abef3c8..0000000 --- a/backend/scripts/create_nex_design_project.py +++ /dev/null @@ -1,154 +0,0 @@ -""" -创建 NEX Design 项目 -""" -import pymysql -import uuid -import os -from pathlib import Path -from datetime import datetime - -# 数据库配置 -DB_CONFIG = { - 'host': '10.100.51.51', - 'port': 3306, - 'user': 'root', - 'password': 'Unis@123', - 'database': 'nex_docus', - 'charset': 'utf8mb4', -} - -# 项目信息 -PROJECT_INFO = { - 'name': 'NEX Design', - 'description': 'NEX 设计文档库', - 'owner_id': 1, # admin 用户的 ID -} - -# 文件存储根目录 -STORAGE_ROOT = '/data/nex_docus_store/projects' - -def create_project(): - """创建项目""" - try: - # 生成 UUID - storage_key = str(uuid.uuid4()) - print(f"生成项目 UUID: {storage_key}") - - # 连接数据库 - print("正在连接数据库...") - connection = pymysql.connect(**DB_CONFIG) - - try: - with connection.cursor() as cursor: - # 1. 插入项目记录 - print("创建项目记录...") - insert_project_sql = """ - INSERT INTO `projects` - (`name`, `description`, `storage_key`, `owner_id`, `is_public`, `status`, `created_at`) - VALUES (%s, %s, %s, %s, %s, %s, %s) - """ - cursor.execute(insert_project_sql, ( - PROJECT_INFO['name'], - PROJECT_INFO['description'], - storage_key, - PROJECT_INFO['owner_id'], - 0, # 私有项目 - 1, # 活跃状态 - datetime.now() - )) - - project_id = cursor.lastrowid - print(f"✓ 项目ID: {project_id}") - - # 2. 添加项目成员(admin 作为管理员) - print("添加项目管理员...") - insert_member_sql = """ - INSERT INTO `project_members` - (`project_id`, `user_id`, `role`, `joined_at`) - VALUES (%s, %s, %s, %s) - """ - cursor.execute(insert_member_sql, ( - project_id, - PROJECT_INFO['owner_id'], - 'admin', - datetime.now() - )) - print("✓ 管理员已添加") - - # 提交事务 - connection.commit() - print("✓ 数据库记录创建成功") - - finally: - connection.close() - - # 3. 创建物理文件夹结构 - print("\n创建项目文件夹...") - project_path = Path(STORAGE_ROOT) / storage_key - - try: - # 创建项目根目录 - project_path.mkdir(parents=True, exist_ok=True) - print(f"✓ 创建目录: {project_path}") - - # 创建 _assets 目录 - assets_dir = project_path / "_assets" - assets_dir.mkdir(exist_ok=True) - (assets_dir / "images").mkdir(exist_ok=True) - (assets_dir / "files").mkdir(exist_ok=True) - print(f"✓ 创建资源目录") - - # 创建默认 README.md - readme_path = project_path / "README.md" - with open(readme_path, "w", encoding="utf-8") as f: - f.write(f"""# {PROJECT_INFO['name']} - -{PROJECT_INFO['description']} - -## 欢迎使用 NEX Docus! - -这是您的项目首页,您可以在这里编写项目介绍、使用说明等内容。 - -### 快速开始 - -1. 在左侧目录树中创建文件夹和文档 -2. 支持 Markdown 语法编写文档 -3. 支持图片和附件上传 - ---- - -创建时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} -""") - print(f"✓ 创建 README.md") - - except Exception as e: - print(f"⚠️ 文件夹创建警告: {e}") - print(f" 请确保目录 {STORAGE_ROOT} 存在并有写入权限") - print(f" 或者修改 backend/.env 中的 STORAGE_ROOT 配置") - - print("\n" + "="*60) - print("✅ 项目创建成功!") - print("="*60) - print(f"项目名称: {PROJECT_INFO['name']}") - print(f"项目ID: {project_id}") - print(f"存储路径: {project_path}") - print(f"UUID: {storage_key}") - print("="*60) - print("\n你现在可以:") - print("1. 启动后端服务: cd backend && python main.py") - print("2. 启动前端服务: cd frontend && npm run dev") - print("3. 登录系统 (admin / admin@123)") - print("4. 在项目中添加你的文档") - print() - - except pymysql.Error as e: - print(f"❌ 数据库错误: {e}") - except Exception as e: - print(f"❌ 未知错误: {e}") - -if __name__ == "__main__": - print("=" * 60) - print("创建 NEX Design 项目") - print("=" * 60) - print() - create_project() diff --git a/backend/scripts/create_project_vectorization_task_table.sql b/backend/scripts/create_project_vectorization_task_table.sql deleted file mode 100644 index 4ed1380..0000000 --- a/backend/scripts/create_project_vectorization_task_table.sql +++ /dev/null @@ -1,23 +0,0 @@ -CREATE TABLE IF NOT EXISTS `project_vectorization_task` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '任务ID', - `task_id` VARCHAR(64) NOT NULL COMMENT '任务唯一标识', - `project_id` BIGINT NOT NULL COMMENT '项目ID', - `user_id` BIGINT NOT NULL COMMENT '触发用户ID', - `task_type` VARCHAR(32) NOT NULL COMMENT '任务类型:incremental/full', - `status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '任务状态:pending/running/success/failed', - `total` INT NOT NULL DEFAULT 0 COMMENT '文件总数', - `processed` INT NOT NULL DEFAULT 0 COMMENT '处理成功数', - `skipped` INT NOT NULL DEFAULT 0 COMMENT '跳过数', - `failed` INT NOT NULL DEFAULT 0 COMMENT '失败数', - `error_message` TEXT DEFAULT NULL COMMENT '错误信息', - `started_at` DATETIME DEFAULT NULL COMMENT '开始时间', - `finished_at` DATETIME DEFAULT NULL COMMENT '完成时间', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - UNIQUE KEY `uk_vector_task_id` (`task_id`), - INDEX `idx_vector_task_project_status` (`project_id`, `status`), - INDEX `idx_vector_task_user_id` (`user_id`), - INDEX `idx_vector_task_created_at` (`created_at`), - FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='项目向量化任务表'; diff --git a/backend/scripts/create_share_links_table.sql b/backend/scripts/create_share_links_table.sql deleted file mode 100644 index a377fa3..0000000 --- a/backend/scripts/create_share_links_table.sql +++ /dev/null @@ -1,18 +0,0 @@ -CREATE TABLE IF NOT EXISTS `share_links` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '分享ID', - `project_id` BIGINT NOT NULL COMMENT '项目ID', - `share_type` VARCHAR(20) NOT NULL COMMENT '分享类型: project/file', - `share_code` VARCHAR(64) NOT NULL COMMENT '公开分享码', - `file_path` VARCHAR(500) DEFAULT NULL COMMENT '文件路径,仅文件分享使用', - `access_pass` VARCHAR(100) DEFAULT NULL COMMENT '访问密码', - `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', - `status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - UNIQUE KEY `uk_share_code` (`share_code`), - INDEX `idx_share_project` (`project_id`, `share_type`, `status`), - INDEX `idx_share_file` (`project_id`, `file_path`(255), `status`), - INDEX `idx_share_created_by` (`created_by`), - FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分享链接表'; diff --git a/backend/scripts/fix_role_system_flag.py b/backend/scripts/fix_role_system_flag.py deleted file mode 100644 index bea1834..0000000 --- a/backend/scripts/fix_role_system_flag.py +++ /dev/null @@ -1,95 +0,0 @@ -""" -检查和修复角色的 is_system 字段 -""" -import sys -import asyncio -from pathlib import Path - -# 添加项目根目录到 Python 路径 -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from sqlalchemy import text -from app.core.database import async_session - - -async def check_and_fix_roles(): - """检查和修复角色的 is_system 字段""" - print("正在检查角色数据...") - - async with async_session() as session: - # 查询所有角色 - result = await session.execute( - text("SELECT id, role_name, role_code, is_system FROM roles ORDER BY id") - ) - roles = result.fetchall() - - print("\n当前角色列表:") - print("-" * 80) - print(f"{'ID':<5} {'角色名称':<20} {'角色编码':<20} {'是否系统角色':<15}") - print("-" * 80) - for role in roles: - is_system_text = "是" if role[3] == 1 else "否" - print(f"{role[0]:<5} {role[1]:<20} {role[2]:<20} {is_system_text:<15}") - print("-" * 80) - - # 修复建议:只有 super_admin 应该是系统角色 - print("\n开始修复角色 is_system 字段...") - - # 将 super_admin 设置为系统角色 - await session.execute( - text("UPDATE roles SET is_system = 1 WHERE role_code = 'super_admin'") - ) - print("✓ 已将 super_admin 设置为系统角色") - - # 将其他角色设置为非系统角色 - await session.execute( - text("UPDATE roles SET is_system = 0 WHERE role_code != 'super_admin'") - ) - print("✓ 已将其他角色设置为非系统角色") - - await session.commit() - - # 再次查询验证 - result = await session.execute( - text("SELECT id, role_name, role_code, is_system FROM roles ORDER BY id") - ) - roles = result.fetchall() - - print("\n修复后的角色列表:") - print("-" * 80) - print(f"{'ID':<5} {'角色名称':<20} {'角色编码':<20} {'是否系统角色':<15}") - print("-" * 80) - for role in roles: - is_system_text = "是" if role[3] == 1 else "否" - print(f"{role[0]:<5} {role[1]:<20} {role[2]:<20} {is_system_text:<15}") - print("-" * 80) - - print("\n✓ 角色数据修复完成!") - print("\n说明:") - print(" - super_admin (超级管理员): 系统角色,不允许修改权限") - print(" - admin (管理员): 非系统角色,可以修改权限") - print(" - user (普通用户): 非系统角色,可以修改权限") - - -async def main(): - """主函数""" - print("=" * 80) - print("检查和修复角色 is_system 字段") - print("=" * 80) - print() - - try: - await check_and_fix_roles() - print() - print("=" * 80) - print("✓ 操作完成!现在可以在前端管理非系统角色的权限了") - print("=" * 80) - except Exception as e: - print(f"\n✗ 操作失败: {str(e)}") - import traceback - traceback.print_exc() - sys.exit(1) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/backend/scripts/init_database.sql b/backend/scripts/init_database.sql deleted file mode 100644 index 1557a77..0000000 --- a/backend/scripts/init_database.sql +++ /dev/null @@ -1,340 +0,0 @@ --- NEX Docus 数据库初始化脚本 --- 创建数据库 -CREATE DATABASE IF NOT EXISTS `nex_docus` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -USE `nex_docus`; - --- 1. 用户表 -CREATE TABLE IF NOT EXISTS `users` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '用户ID', - `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名(登录账号)', - `password_hash` VARCHAR(255) NOT NULL COMMENT '密码哈希(bcrypt)', - `nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称(显示名称)', - `email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱', - `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', - `avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像URL', - `status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用', - `is_superuser` TINYINT DEFAULT 0 COMMENT '是否超级管理员:0-否 1-是', - `last_login_at` DATETIME DEFAULT NULL COMMENT '最后登录时间', - `last_login_ip` VARCHAR(50) DEFAULT NULL COMMENT '最后登录IP', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - INDEX `idx_username` (`username`), - INDEX `idx_email` (`email`), - INDEX `idx_status` (`status`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; - --- 2. 角色表 -CREATE TABLE IF NOT EXISTS `roles` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '角色ID', - `role_name` VARCHAR(50) NOT NULL UNIQUE COMMENT '角色名称', - `role_code` VARCHAR(50) NOT NULL UNIQUE COMMENT '角色编码', - `description` VARCHAR(255) DEFAULT NULL COMMENT '角色描述', - `status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用', - `is_system` TINYINT DEFAULT 0 COMMENT '是否系统角色:0-否 1-是', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - INDEX `idx_role_code` (`role_code`), - INDEX `idx_status` (`status`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表'; - --- 3. 用户角色关联表 -CREATE TABLE IF NOT EXISTS `user_roles` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关联ID', - `user_id` BIGINT NOT NULL COMMENT '用户ID', - `role_id` BIGINT NOT NULL COMMENT '角色ID', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - UNIQUE KEY `uk_user_role` (`user_id`, `role_id`), - INDEX `idx_user_id` (`user_id`), - INDEX `idx_role_id` (`role_id`), - FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表'; - --- 4. 系统菜单表 -CREATE TABLE IF NOT EXISTS `system_menus` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '菜单ID', - `parent_id` BIGINT DEFAULT 0 COMMENT '父菜单ID', - `menu_name` VARCHAR(50) NOT NULL COMMENT '菜单名称', - `menu_code` VARCHAR(50) NOT NULL UNIQUE COMMENT '菜单编码', - `menu_type` TINYINT NOT NULL COMMENT '菜单类型:1-目录 2-菜单 3-按钮', - `path` VARCHAR(255) DEFAULT NULL COMMENT '路由路径', - `component` VARCHAR(255) DEFAULT NULL COMMENT '组件路径', - `icon` VARCHAR(100) DEFAULT NULL COMMENT '图标', - `sort_order` INT DEFAULT 0 COMMENT '排序号', - `visible` TINYINT DEFAULT 1 COMMENT '是否可见:0-隐藏 1-显示', - `status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用', - `permission` VARCHAR(100) DEFAULT NULL COMMENT '权限字符串', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - INDEX `idx_parent_id` (`parent_id`), - INDEX `idx_menu_code` (`menu_code`), - INDEX `idx_status` (`status`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统菜单表'; - --- 5. 角色菜单授权表 -CREATE TABLE IF NOT EXISTS `role_menus` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关联ID', - `role_id` BIGINT NOT NULL COMMENT '角色ID', - `menu_id` BIGINT NOT NULL COMMENT '菜单ID', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - UNIQUE KEY `uk_role_menu` (`role_id`, `menu_id`), - INDEX `idx_role_id` (`role_id`), - INDEX `idx_menu_id` (`menu_id`), - FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`menu_id`) REFERENCES `system_menus`(`id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色菜单授权表'; - --- 6. 项目表 -CREATE TABLE IF NOT EXISTS `projects` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '项目ID', - `name` VARCHAR(100) NOT NULL COMMENT '项目名称', - `description` VARCHAR(500) DEFAULT NULL COMMENT '项目描述', - `storage_key` CHAR(36) NOT NULL COMMENT '磁盘存储UUID', - `owner_id` BIGINT NOT NULL COMMENT '项目所有者ID', - `is_public` TINYINT DEFAULT 0 COMMENT '是否公开:0-私有 1-公开', - `is_template` TINYINT DEFAULT 0 COMMENT '是否模板项目:0-否 1-是', - `status` TINYINT DEFAULT 1 COMMENT '状态:0-归档 1-活跃', - `cover_image` VARCHAR(255) DEFAULT NULL COMMENT '封面图', - `sort_order` INT DEFAULT 0 COMMENT '排序号', - `visit_count` INT DEFAULT 0 COMMENT '访问次数', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - UNIQUE KEY `uk_storage_key` (`storage_key`), - INDEX `idx_owner_id` (`owner_id`), - INDEX `idx_name` (`name`), - INDEX `idx_status` (`status`), - INDEX `idx_created_at` (`created_at`), - FOREIGN KEY (`owner_id`) REFERENCES `users`(`id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目表'; - --- 7. 项目成员表 -CREATE TABLE IF NOT EXISTS `project_members` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '成员ID', - `project_id` BIGINT NOT NULL COMMENT '项目ID', - `user_id` BIGINT NOT NULL COMMENT '用户ID', - `role` ENUM('admin', 'editor', 'viewer') DEFAULT 'viewer' COMMENT '项目角色', - `invited_by` BIGINT DEFAULT NULL COMMENT '邀请人ID', - `joined_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间', - UNIQUE KEY `uk_project_user` (`project_id`, `user_id`), - INDEX `idx_project_id` (`project_id`), - INDEX `idx_user_id` (`user_id`), - INDEX `idx_role` (`role`), - FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`invited_by`) REFERENCES `users`(`id`) ON DELETE SET NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目成员表'; - --- 8. 文档元数据表 -CREATE TABLE IF NOT EXISTS `document_meta` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '元数据ID', - `project_id` BIGINT NOT NULL COMMENT '项目ID', - `file_path` VARCHAR(500) NOT NULL COMMENT '文件相对路径', - `title` VARCHAR(200) DEFAULT NULL COMMENT '文档标题', - `tags` VARCHAR(500) DEFAULT NULL COMMENT '标签(JSON数组)', - `author_id` BIGINT DEFAULT NULL COMMENT '作者ID', - `word_count` INT DEFAULT 0 COMMENT '字数统计', - `view_count` INT DEFAULT 0 COMMENT '浏览次数', - `last_editor_id` BIGINT DEFAULT NULL COMMENT '最后编辑者ID', - `last_edited_at` DATETIME DEFAULT NULL COMMENT '最后编辑时间', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - UNIQUE KEY `uk_project_path` (`project_id`, `file_path`(255)), - INDEX `idx_project_id` (`project_id`), - INDEX `idx_author_id` (`author_id`), - FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON DELETE SET NULL, - FOREIGN KEY (`last_editor_id`) REFERENCES `users`(`id`) ON DELETE SET NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文档元数据表'; - --- 9. 操作日志表 -CREATE TABLE IF NOT EXISTS `operation_logs` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '日志ID', - `user_id` BIGINT DEFAULT NULL COMMENT '操作用户ID', - `username` VARCHAR(50) DEFAULT NULL COMMENT '用户名', - `operation_type` VARCHAR(50) NOT NULL COMMENT '操作类型', - `resource_type` VARCHAR(50) NOT NULL COMMENT '资源类型', - `resource_id` BIGINT DEFAULT NULL COMMENT '资源ID', - `detail` TEXT DEFAULT NULL COMMENT '操作详情(JSON)', - `ip_address` VARCHAR(50) DEFAULT NULL COMMENT 'IP地址', - `user_agent` VARCHAR(500) DEFAULT NULL COMMENT '用户代理', - `status` TINYINT DEFAULT 1 COMMENT '状态:0-失败 1-成功', - `error_message` TEXT DEFAULT NULL COMMENT '错误信息', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间', - INDEX `idx_user_id` (`user_id`), - INDEX `idx_resource` (`resource_type`, `resource_id`), - INDEX `idx_created_at` (`created_at`), - FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表'; - --- 10. MCP Bot 凭证表 -CREATE TABLE IF NOT EXISTS `mcp_bots` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'Bot credential ID', - `user_id` BIGINT NOT NULL COMMENT 'Owner user ID', - `bot_id` VARCHAR(64) NOT NULL COMMENT 'External MCP bot id', - `bot_secret` VARCHAR(255) NOT NULL COMMENT 'External MCP bot secret', - `status` TINYINT DEFAULT 1 COMMENT 'Status: 0-disabled 1-enabled', - `last_used_at` DATETIME DEFAULT NULL COMMENT 'Last successful MCP access time', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'Created at', - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Updated at', - UNIQUE KEY `uk_mcp_bots_user_id` (`user_id`), - UNIQUE KEY `uk_mcp_bots_bot_id` (`bot_id`), - INDEX `idx_mcp_bots_status` (`status`), - FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MCP bot credentials'; - --- 11. 分享链接表 -CREATE TABLE IF NOT EXISTS `share_links` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '分享ID', - `project_id` BIGINT NOT NULL COMMENT '项目ID', - `share_type` VARCHAR(20) NOT NULL COMMENT '分享类型: project/file', - `share_code` VARCHAR(64) NOT NULL COMMENT '公开分享码', - `file_path` VARCHAR(500) DEFAULT NULL COMMENT '文件路径,仅文件分享使用', - `access_pass` VARCHAR(100) DEFAULT NULL COMMENT '访问密码', - `created_by` BIGINT DEFAULT NULL COMMENT '创建人ID', - `status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - UNIQUE KEY `uk_share_code` (`share_code`), - INDEX `idx_share_project` (`project_id`, `share_type`, `status`), - INDEX `idx_share_file` (`project_id`, `file_path`(255), `status`), - INDEX `idx_share_created_by` (`created_by`), - FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分享链接表'; - --- 12. LLM 模型配置表 -CREATE TABLE IF NOT EXISTS `llm_model_config` ( - `config_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '配置ID', - `model_code` VARCHAR(128) NOT NULL COMMENT '模型编码', - `model_name` VARCHAR(255) NOT NULL COMMENT '模型名称', - `model_type` VARCHAR(32) NOT NULL DEFAULT 'chat' COMMENT '模型类型: chat/embedding', - `provider` VARCHAR(64) DEFAULT NULL COMMENT '模型提供方', - `endpoint_url` VARCHAR(512) DEFAULT NULL COMMENT '接口地址', - `api_key` VARCHAR(512) DEFAULT NULL COMMENT 'API Key', - `llm_model_name` VARCHAR(128) NOT NULL COMMENT '模型名称/部署名', - `llm_timeout` INT NOT NULL DEFAULT 120 COMMENT '超时时间(秒)', - `type_config` JSON NOT NULL COMMENT '模型类型差异参数', - `description` VARCHAR(500) DEFAULT NULL COMMENT '描述', - `is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', - `is_default` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否默认', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - UNIQUE KEY `uk_model_code` (`model_code`), - INDEX `idx_model_code` (`model_code`), - INDEX `idx_model_type` (`model_type`), - INDEX `idx_is_active` (`is_active`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='LLM 模型配置表'; - --- 13. 文档向量表 -CREATE TABLE IF NOT EXISTS `document_vector` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '向量ID', - `project_id` BIGINT NOT NULL COMMENT '项目ID', - `file_path` VARCHAR(500) NOT NULL COMMENT '文件相对路径', - `content_hash` VARCHAR(64) DEFAULT NULL COMMENT '内容哈希值,用于判断文件是否变更', - `zvec_id` VARCHAR(256) DEFAULT NULL COMMENT 'ZVec返回的向量ID', - `zvec_response` TEXT DEFAULT NULL COMMENT 'ZVec完整响应JSON', - `status` VARCHAR(32) NOT NULL DEFAULT 'success' COMMENT '向量化状态:success/failed/pending', - `error_message` VARCHAR(500) DEFAULT NULL COMMENT '错误信息', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - INDEX `idx_project_file` (`project_id`, `file_path`), - FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文档向量表'; - --- 14. 知识库对话会话表 -CREATE TABLE IF NOT EXISTS `chat_session` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '会话ID', - `project_id` BIGINT NOT NULL COMMENT '项目ID', - `user_id` BIGINT NOT NULL COMMENT '用户ID', - `llm_config_id` BIGINT NOT NULL COMMENT 'LLM配置ID', - `title` VARCHAR(255) NOT NULL COMMENT '会话标题', - `description` TEXT DEFAULT NULL COMMENT '会话描述', - `is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否激活', - `message_count` INT NOT NULL DEFAULT 0 COMMENT '消息数', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - INDEX `idx_project_id` (`project_id`), - INDEX `idx_user_id` (`user_id`), - INDEX `idx_project_user` (`project_id`, `user_id`), - FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`llm_config_id`) REFERENCES `llm_model_config`(`config_id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库对话会话表'; - --- 15. 知识库对话消息表 -CREATE TABLE IF NOT EXISTS `chat_message` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '消息ID', - `session_id` BIGINT NOT NULL COMMENT '会话ID', - `role` VARCHAR(32) NOT NULL COMMENT '角色(user/assistant)', - `content` TEXT NOT NULL COMMENT '消息内容', - `status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '消息状态: pending/completed/interrupted/error', - `duration_ms` INT DEFAULT NULL COMMENT '生成耗时(毫秒)', - `thinking_log` TEXT DEFAULT NULL COMMENT '思考过程(JSON数组)', - `referenced_files` TEXT DEFAULT NULL COMMENT '参考文件(JSON数组)', - `tokens_used` INT DEFAULT NULL COMMENT '消耗的token数', - `is_deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已删除', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - INDEX `idx_session_id` (`session_id`), - FOREIGN KEY (`session_id`) REFERENCES `chat_session`(`id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库对话消息表'; - --- 16. 项目向量化任务表 -CREATE TABLE IF NOT EXISTS `project_vectorization_task` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '任务ID', - `task_id` VARCHAR(64) NOT NULL COMMENT '任务唯一标识', - `project_id` BIGINT NOT NULL COMMENT '项目ID', - `user_id` BIGINT NOT NULL COMMENT '触发用户ID', - `task_type` VARCHAR(32) NOT NULL COMMENT '任务类型:incremental/full', - `status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '任务状态:pending/running/success/failed', - `total` INT NOT NULL DEFAULT 0 COMMENT '文件总数', - `processed` INT NOT NULL DEFAULT 0 COMMENT '处理成功数', - `skipped` INT NOT NULL DEFAULT 0 COMMENT '跳过数', - `failed` INT NOT NULL DEFAULT 0 COMMENT '失败数', - `error_message` TEXT DEFAULT NULL COMMENT '错误信息', - `started_at` DATETIME DEFAULT NULL COMMENT '开始时间', - `finished_at` DATETIME DEFAULT NULL COMMENT '完成时间', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - UNIQUE KEY `uk_vector_task_id` (`task_id`), - INDEX `idx_vector_task_project_status` (`project_id`, `status`), - INDEX `idx_vector_task_user_id` (`user_id`), - INDEX `idx_vector_task_created_at` (`created_at`), - FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目向量化任务表'; - --- 插入初始角色数据 -INSERT INTO `roles` (`role_name`, `role_code`, `description`, `is_system`) VALUES -('超级管理员', 'super_admin', '拥有系统所有权限', 1), -('项目管理员', 'project_admin', '可以创建和管理项目', 1), -('普通用户', 'user', '可以查看和编辑被授权的项目', 1); - --- 插入初始菜单数据 -INSERT INTO `system_menus` (`id`, `parent_id`, `menu_name`, `menu_code`, `menu_type`, `path`, `icon`, `sort_order`, `permission`) VALUES -(1, 0, '项目管理', 'project', 1, NULL, 'FolderOutlined', 1, NULL), -(2, 1, '我的项目', 'projects:my', 2, '/projects', NULL, 1, 'project:view'), -(3, 1, '创建项目', 'project', 3, NULL, NULL, 2, 'project:create'), -(4, 1, '编辑项目', 'project:edit', 3, NULL, NULL, 3, 'project:edit'), -(5, 1, '删除项目', 'project:delete', 3, NULL, NULL, 4, 'project:delete'), -(10, 0, '知识库管理', 'knowledge', 1, NULL, 'FileTextOutlined', 2, NULL), -(11, 10, '我的知识库', 'knowledge:view', 2, '/chat', 'CommentOutlined', 1, 'knowledge:view'), -(12, 10, '编辑知识库', 'knowledge:edit', 3, NULL, NULL, 2, 'knowledge:edit'), -(13, 10, '删除知识库', 'knowledge:delete', 3, NULL, NULL, 3, 'knowledge:delete'), -(20, 0, '系统管理', 'system', 1, '/system', 'SettingOutlined', 3, NULL), -(21, 20, '用户管理', 'user_manage', 2, '/system/users', NULL, 1, 'system:user:view'), -(22, 20, '角色管理', 'role_manage', 2, '/system/roles', NULL, 2, 'system:role:view'); - --- 创建默认管理员用户(密码: admin@123) -INSERT INTO `users` (`username`, `password_hash`, `nickname`, `is_superuser`, `status`) VALUES -('admin', '$2b$12$gQlTcKxyQKRYQtTT0Db.mexAsAYTJre9G8OSlzl3H3gMNTfXijiZy', '系统管理员', 1, 1); - --- 将管理员用户分配超级管理员角色 -INSERT INTO `user_roles` (`user_id`, `role_id`) VALUES (1, 1); - --- 为超级管理员角色授权所有菜单 -INSERT INTO `role_menus` (`role_id`, `menu_id`) -SELECT 1, id FROM `system_menus`; - --- 完成 -SELECT '数据库初始化完成!' AS message; -SELECT CONCAT('默认管理员账号: admin, 密码: admin@123') AS credentials; diff --git a/backend/scripts/init_db.py b/backend/scripts/init_db.py index 2d8ed79..f2ad5f4 100644 --- a/backend/scripts/init_db.py +++ b/backend/scripts/init_db.py @@ -144,7 +144,7 @@ async def init_menus(): menu_name="知识库空间", menu_code="knowledge", menu_type=0, - path="/knowledge", + path="/chat", icon="BookOutlined", sort_order=4, visible=1, @@ -220,7 +220,7 @@ async def init_menus(): menu_name="我的知识库", menu_code="knowledge:my", menu_type=1, - path="/knowledge", + path="/chat", component="MyKnowledge", icon="CommentOutlined", sort_order=1, @@ -348,7 +348,7 @@ async def init_admin_user(): # 从环境变量获取管理员信息 admin_username = os.getenv("ADMIN_USERNAME", "admin") admin_password = os.getenv("ADMIN_PASSWORD", "admin@123") - admin_email = os.getenv("ADMIN_EMAIL", "admin@unisspace.com") + admin_email = os.getenv("ADMIN_EMAIL", "admin@example.com") admin_nickname = os.getenv("ADMIN_NICKNAME", "系统管理员") async with async_session() as session: diff --git a/backend/scripts/init_db_pymysql.py b/backend/scripts/init_db_pymysql.py deleted file mode 100644 index ba6e0d5..0000000 --- a/backend/scripts/init_db_pymysql.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -使用 Python 执行数据库初始化脚本 -""" -import pymysql -import sys -from pathlib import Path - -# 数据库配置 -DB_CONFIG = { - 'host': '10.100.51.51', - 'port': 3306, - 'user': 'root', - 'password': 'Unis@123', - 'charset': 'utf8mb4', -} - -def execute_sql_file(sql_file_path): - """执行 SQL 文件""" - try: - # 读取 SQL 文件 - with open(sql_file_path, 'r', encoding='utf-8') as f: - sql_content = f.read() - - # 连接数据库 - print("正在连接数据库...") - connection = pymysql.connect(**DB_CONFIG) - - try: - with connection.cursor() as cursor: - # 分割SQL语句(简单分割,按分号) - statements = [] - current_statement = [] - - for line in sql_content.split('\n'): - # 跳过注释 - line = line.strip() - if line.startswith('--') or not line: - continue - - current_statement.append(line) - - # 如果行以分号结尾,表示一条完整的语句 - if line.endswith(';'): - statement = ' '.join(current_statement) - if statement.strip(): - statements.append(statement) - current_statement = [] - - # 执行所有语句 - print(f"共 {len(statements)} 条SQL语句...") - for i, statement in enumerate(statements, 1): - try: - cursor.execute(statement) - # 如果是SELECT语句,获取结果 - if statement.strip().upper().startswith('SELECT'): - result = cursor.fetchall() - if result: - print(f" [{i}] {result}") - else: - print(f" [{i}] 执行成功") - except Exception as e: - print(f" [{i}] 执行失败: {e}") - print(f" SQL: {statement[:100]}...") - - # 提交事务 - connection.commit() - print("\n✅ 数据库初始化完成!") - - finally: - connection.close() - - except FileNotFoundError: - print(f"❌ SQL 文件不存在: {sql_file_path}") - sys.exit(1) - except pymysql.Error as e: - print(f"❌ 数据库错误: {e}") - sys.exit(1) - except Exception as e: - print(f"❌ 未知错误: {e}") - sys.exit(1) - -if __name__ == "__main__": - script_dir = Path(__file__).parent - sql_file = script_dir / "init_database.sql" - - print("=" * 60) - print("NEX Docus 数据库初始化") - print("=" * 60) - print(f"SQL 文件: {sql_file}") - print(f"数据库地址: {DB_CONFIG['host']}:{DB_CONFIG['port']}") - print("=" * 60) - print() - - execute_sql_file(sql_file) diff --git a/backend/scripts/migrate_document_vector_add_chunks.sql b/backend/scripts/migrate_document_vector_add_chunks.sql deleted file mode 100644 index 254b391..0000000 --- a/backend/scripts/migrate_document_vector_add_chunks.sql +++ /dev/null @@ -1,19 +0,0 @@ --- 迁移脚本:为 document_vector 表增加分块(chunk)支持 --- 背景:RAG 由「整篇文档单向量」升级为「按分块向量化」, --- 一个文件可对应多条 chunk 记录。 --- --- 用法(已有数据库升级): --- mysql -u -p < migrate_document_vector_add_chunks.sql --- --- 注意:升级后建议对所有项目执行一次「全量重建」向量化, --- 以将旧的整篇文档向量替换为分块向量(旧记录 chunk_index 默认为 0)。 - -ALTER TABLE `document_vector` - ADD COLUMN `chunk_index` INT NOT NULL DEFAULT 0 - COMMENT '分块序号(0起),同一文件可有多个分块' AFTER `file_path`, - ADD COLUMN `chunk_text` TEXT DEFAULT NULL - COMMENT '分块首段文本,作为点击引用时的定位锚点' AFTER `chunk_index`; - --- 新增按 (project_id, file_path, chunk_index) 的复合索引,加速按分块查询/删除 -ALTER TABLE `document_vector` - ADD INDEX `idx_project_file_chunk` (`project_id`, `file_path`, `chunk_index`); diff --git a/backend/scripts/migrate_llm_model_config_embedding_options.sql b/backend/scripts/migrate_llm_model_config_embedding_options.sql deleted file mode 100644 index 51b888d..0000000 --- a/backend/scripts/migrate_llm_model_config_embedding_options.sql +++ /dev/null @@ -1,8 +0,0 @@ --- 为已有数据库增加向量模型的分块参数。 --- 执行前请确认当前数据库已包含 llm_model_config 表。 - -ALTER TABLE `llm_model_config` - ADD COLUMN `chunk_size` INT NOT NULL DEFAULT 800 - COMMENT '文档分块字符数(仅 embedding 类型)' AFTER `embedding_dimension`, - ADD COLUMN `chunk_overlap` INT NOT NULL DEFAULT 150 - COMMENT '相邻分块重叠字符数(仅 embedding 类型)' AFTER `chunk_size`; diff --git a/backend/scripts/migrate_llm_model_config_type_config.sql b/backend/scripts/migrate_llm_model_config_type_config.sql deleted file mode 100644 index 400012c..0000000 --- a/backend/scripts/migrate_llm_model_config_type_config.sql +++ /dev/null @@ -1,46 +0,0 @@ --- 将对话模型与向量模型的差异字段归并到 type_config JSON。 --- 适用于 MySQL 8.0;执行前应已完成 embedding_options 迁移。 - -ALTER TABLE `llm_model_config` - ADD COLUMN `type_config` JSON NULL - COMMENT '模型类型差异参数' AFTER `llm_timeout`; - -UPDATE `llm_model_config` -SET `type_config` = CASE - WHEN `model_type` = 'embedding' THEN - CASE - WHEN `embedding_dimension` IS NULL THEN JSON_OBJECT( - 'chunk_size', `chunk_size`, - 'chunk_overlap', `chunk_overlap` - ) - ELSE JSON_OBJECT( - 'dimension', `embedding_dimension`, - 'chunk_size', `chunk_size`, - 'chunk_overlap', `chunk_overlap` - ) - END - ELSE - CASE - WHEN `llm_system_prompt` IS NULL OR `llm_system_prompt` = '' THEN JSON_OBJECT( - 'temperature', CAST(`llm_temperature` AS DOUBLE), - 'top_p', CAST(`llm_top_p` AS DOUBLE), - 'max_tokens', `llm_max_tokens` - ) - ELSE JSON_OBJECT( - 'temperature', CAST(`llm_temperature` AS DOUBLE), - 'top_p', CAST(`llm_top_p` AS DOUBLE), - 'max_tokens', `llm_max_tokens`, - 'system_prompt', `llm_system_prompt` - ) - END -END; - -ALTER TABLE `llm_model_config` - MODIFY COLUMN `type_config` JSON NOT NULL COMMENT '模型类型差异参数', - DROP COLUMN `llm_temperature`, - DROP COLUMN `llm_top_p`, - DROP COLUMN `llm_max_tokens`, - DROP COLUMN `llm_system_prompt`, - DROP COLUMN `embedding_dimension`, - DROP COLUMN `chunk_size`, - DROP COLUMN `chunk_overlap`; diff --git a/backend/scripts/update_system_menu_paths.py b/backend/scripts/update_system_menu_paths.py deleted file mode 100644 index 263fad7..0000000 --- a/backend/scripts/update_system_menu_paths.py +++ /dev/null @@ -1,75 +0,0 @@ -""" -更新系统管理菜单的路径 -""" -import sys -import asyncio -from pathlib import Path - -# 添加项目根目录到 Python 路径 -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from sqlalchemy import text -from app.core.database import async_session - - -async def update_menu_paths(): - """更新菜单路径""" - print("正在更新系统管理菜单路径...") - - async with async_session() as session: - # 1. 更新角色权限管理菜单路径 - result = await session.execute( - text(""" - UPDATE system_menus - SET path = '/system/permissions', component = 'System/Permissions' - WHERE id = 14 - """) - ) - print(f"✓ 角色权限管理菜单路径已更新: /system/permissions") - - # 2. 更新用户管理菜单路径 - result = await session.execute( - text(""" - UPDATE system_menus - SET path = '/system/users', component = 'System/Users' - WHERE id = 15 - """) - ) - print(f"✓ 用户管理菜单路径已更新: /system/users") - - # 3. 更新角色管理菜单路径 - result = await session.execute( - text(""" - UPDATE system_menus - SET path = '/system/roles', component = 'System/Roles' - WHERE id = 16 - """) - ) - print(f"✓ 角色管理菜单路径已更新: /system/roles") - - await session.commit() - print("\n✓ 所有菜单路径更新完成!") - - -async def main(): - """主函数""" - print("=" * 80) - print("更新系统管理菜单路径") - print("=" * 80) - print() - - try: - await update_menu_paths() - print() - print("=" * 80) - print("✓ 操作完成!现在可以通过新路径访问系统管理菜单了") - print("=" * 80) - except Exception as e: - print(f"\n✗ 操作失败: {str(e)}") - import traceback - traceback.print_exc() - sys.exit(1) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/backend/tests/test_project_permissions.py b/backend/tests/test_project_permissions.py index e5b4508..e4fa19e 100644 --- a/backend/tests/test_project_permissions.py +++ b/backend/tests/test_project_permissions.py @@ -5,6 +5,7 @@ from fastapi import HTTPException from app.services.project_service import ( normalize_project_role, + require_project_read_access, require_project_roles, require_project_write_access, ) @@ -69,6 +70,28 @@ class ProjectPermissionsTest(unittest.IsolatedAsyncioTestCase): self.assertEqual(context.exception.status_code, 403) + async def test_write_access_rejects_legacy_uppercase_viewer(self): + """历史数据里 role 可能是大写 VIEWER,写权限校验必须先归一化再判断。""" + db = _RecordingDB([ + self.project, + SimpleNamespace(role="VIEWER"), + ]) + + with self.assertRaises(HTTPException) as context: + await require_project_write_access(db, 25, self.current_user) + + self.assertEqual(context.exception.status_code, 403) + + async def test_read_access_returns_normalized_role(self): + db = _RecordingDB([ + self.project, + SimpleNamespace(role="VIEWER"), + ]) + + project, role = await require_project_read_access(db, 25, self.current_user) + + self.assertEqual(role, "viewer") + async def test_admin_only_permission_rejects_editor(self): db = _RecordingDB([ self.project, diff --git a/backend/tests/test_security_selfcheck.py b/backend/tests/test_security_selfcheck.py new file mode 100644 index 0000000..e8252c0 --- /dev/null +++ b/backend/tests/test_security_selfcheck.py @@ -0,0 +1,55 @@ +"""敏感配置启动自检回归用例。 + +背景:docker-compose 用 `${SECRET_KEY:-your-secret-key-change-me-in-production}` 之类的 +占位默认值,运维忘记改 .env 时会带着公开已知的密钥上线;这里锁定告警行为。 +""" +import os + +import pytest + +from app.core.config import Settings + +BASE_ENV = { + "DB_HOST": "localhost", + "DB_USER": "u", + "DB_PASSWORD": "p", + "DB_NAME": "d", + "REDIS_HOST": "localhost", + "REDIS_PASSWORD": "r", + "SECRET_KEY": "x" * 48, +} + + +def make_settings(**overrides): + env = {**BASE_ENV, **{k: str(v) for k, v in overrides.items()}} + saved = {k: os.environ.get(k) for k in env} + os.environ.update(env) + try: + return Settings(_env_file=None) + finally: + for k, v in saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +def test_placeholder_secret_key_is_flagged(): + s = make_settings(SECRET_KEY="your-secret-key-change-me-in-production") + assert any("SECRET_KEY" in w for w in s.security_warnings()) + + +def test_short_secret_key_is_flagged(): + s = make_settings(SECRET_KEY="abc") + assert any("SECRET_KEY" in w for w in s.security_warnings()) + + +@pytest.mark.parametrize("password", ["User@123", "User@123456"]) +def test_default_user_password_is_flagged(password): + s = make_settings(DEFAULT_USER_PASSWORD=password) + assert any("DEFAULT_USER_PASSWORD" in w for w in s.security_warnings()) + + +def test_hardened_configuration_produces_no_warnings(): + s = make_settings(SECRET_KEY="a" * 48, DEFAULT_USER_PASSWORD="R7#kQ!zs9dLp2Vt4") + assert s.security_warnings() == [] diff --git a/docker-compose.yml b/docker-compose.yml index 9122d47..46fd08c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: # MySQL 数据库 mysql: @@ -74,6 +72,7 @@ services: - ADMIN_PASSWORD=${ADMIN_PASSWORD:-Admin@123456} - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com} - ADMIN_NICKNAME=${ADMIN_NICKNAME:-系统管理员} + - DEFAULT_USER_PASSWORD=${DEFAULT_USER_PASSWORD:-User@123456} - TZ=Asia/Shanghai volumes: - ${STORAGE_PATH:-./storage}:/data/nex_docus_store @@ -96,8 +95,6 @@ services: build: context: ./frontend dockerfile: Dockerfile - args: - - VITE_API_BASE_URL=${VITE_API_BASE_URL:-http://localhost:8000} container_name: nex-docus-frontend restart: unless-stopped environment: diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..09239a1 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,41 @@ +# 文档地图 + +> 本目录是 NexDocus 文档的**唯一入口**。仓库根目录只保留 `README.md`(项目总览),其余文档全部在此。 + +## 按角色导航 + +| 你是谁 / 想做什么 | 从这里开始 | +| --- | --- | +| 第一次跑起来 | [quickstart.md](quickstart.md) | +| 部署到服务器(Docker) | [deploy/README.md](deploy/README.md) | +| 查表结构、写 SQL、排查数据 | [database.md](database.md) | +| 给使用者介绍功能 | [manual/user-guide.md](manual/user-guide.md) | +| 写代码前先对齐设计 | [sdd/README.md](sdd/README.md) | +| 查某个版本改了什么 | [sdd/releases/](sdd/releases/) · [deploy/changelog.md](deploy/changelog.md) | +| 找运维/开发脚本 | [../scripts/README.md](../scripts/README.md) | +| 翻历史方案(已与现状不符) | [archive/README.md](archive/README.md) | + +## 目录结构 + +``` +docs/ +├── README.md # 本文(文档地图) +├── quickstart.md # 开发环境快速上手 +├── database.md # 数据库设计:18 张表 + 初始化/迁移链路 +├── deploy/ +│ ├── README.md # Docker Compose 部署、升级、备份恢复 +│ └── changelog.md # 部署相关变更记录(端口、存储、脚本迁移等) +├── manual/ +│ └── user-guide.md # 面向最终使用者的功能手册 +├── sdd/ # 规格驱动开发(SDD)体系:愿景/架构/ADR/DV 规格/发布 +└── archive/ # 历史文档归档,只作背景,不作为实现依据 +``` + +## 文档维护约定 + +1. **新增文档只能落在 `docs/` 下**,根目录不再新增 `*.md`(`README.md` 除外)。 +2. 文档内互链一律用**相对路径**,跨目录引用要能点击跳转;不要写"参见根目录 XXX.md"这类口头引用。 +3. 涉及命令的段落必须与 `scripts/` 的实际行为一致(例如统一写 `./scripts/deploy.sh`,不是 `./deploy.sh`)。 +4. **严禁在文档中写入真实环境的地址、账号、口令**。示例一律使用 `change_me` 之类的占位值。 +5. 代码行为变更时,同一批改动里更新对应文档;做不到就在 `sdd/releases/` 的发布记录里登记待办。 +6. 数据库结构以 `backend/app/models/` 为准,`database.md` 是其可读镜像;两者冲突时以模型为准并回头修文档。 diff --git a/IMPLEMENTATION_PLAN.md b/docs/archive/IMPLEMENTATION_PLAN.md similarity index 100% rename from IMPLEMENTATION_PLAN.md rename to docs/archive/IMPLEMENTATION_PLAN.md diff --git a/PROJECT.md b/docs/archive/PROJECT.md similarity index 100% rename from PROJECT.md rename to docs/archive/PROJECT.md diff --git a/docs/archive/README.md b/docs/archive/README.md new file mode 100644 index 0000000..e33ceb5 --- /dev/null +++ b/docs/archive/README.md @@ -0,0 +1,23 @@ +# 历史归档(archive) + +这里的文档**只用于追溯决策来源,不代表当前实现**。请勿据此开发、部署或排查问题。 + +| 文件 | 内容 | 为什么不再生效 | +| --- | --- | --- | +| [PROJECT.md](PROJECT.md) | 立项阶段的产品与技术方案(V1.0,2023-12):混合架构选型、FastAPI 决策、早期数据库与权限设计 | 表结构、模块划分、前端技术栈均已演进;现行架构看 [SDD](../sdd/README.md),表结构看 [数据库说明](../database.md) | +| [IMPLEMENTATION_PLAN.md](IMPLEMENTATION_PLAN.md) | 「远端合并 + ZVec 集成 + 知识库对话」三阶段实现计划(已执行完毕) | 是一次性施工计划,任务已落地;后续版本规划看 [发布记录](../sdd/releases/README.md) 与 [路线图](../sdd/product/roadmap.md) | + +## 现行文档入口 + +- 使用者:[使用手册](../manual/user-guide.md) +- 本地开发:[快速开始](../quickstart.md) +- 部署运维:[部署指南](../deploy/README.md)、[配置变更日志](../deploy/changelog.md) +- 数据模型:[数据库说明](../database.md) +- 规格/架构/决策:[SDD 目录](../sdd/README.md) +- 全部文档索引:[docs/README.md](../README.md) + +## 归档规则 + +1. 文档一旦与代码不符且不再维护,就 `git mv` 到本目录,并在上表登记原因,**不要直接删除**(保留决策来源)。 +2. 归档文件**不再修改内容**;需要更新信息时,在现行文档中重写。 +3. 归档文档中的外链可能已失效,反向链接(现行文档指向归档)应尽量避免。 diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..3a44001 --- /dev/null +++ b/docs/database.md @@ -0,0 +1,415 @@ +# 数据库设计 + +> 权威来源是 `backend/app/models/`:本文是它的可读镜像,两者冲突时**以模型为准并回改本文**。 +> 结构变更流程见 [quickstart.md §9](quickstart.md#9-数据库结构与迁移)。 + +- 引擎:MySQL 8.0 / InnoDB / `utf8mb4` / `utf8mb4_unicode_ci` +- 表数量:**18** +- 时间列:`created_at` / `updated_at` 由各模型的 `default=now` / `onupdate=now` 维护(DATETIME,无时区,容器时区固定 `Asia/Shanghai`) +- 主键:除 `llm_model_config.config_id` 外,其余表主键均为自增 `id` +- ⚠️ **文档正文不在数据库里**:Markdown 正文存在文件系统(`STORAGE_ROOT/projects//…`),数据库只存权限、元数据与索引(见 [sdd ADR-0001](sdd/architecture/decisions/ADR-0001-hybrid-storage.md)、[ADR-0002](sdd/architecture/decisions/ADR-0002-uuid-storage-key.md)) + +## 表清单 + +| 分组 | 表 | 用途 | 主要写入方 | +| --- | --- | --- | --- | +| 身份权限 | `users` | 账号、状态、超管标记、登录痕迹 | 注册、用户管理、登录 | +| 身份权限 | `roles` | 角色(super_admin / admin / user + 自定义) | 角色管理、`init_db.py` 种子 | +| 身份权限 | `user_roles` | 用户 ↔ 角色 | 用户管理 | +| 身份权限 | `system_menus` | 菜单 **与** 按钮级权限点(`menu_type` 区分) | `init_db.py` 种子、权限管理 | +| 身份权限 | `role_menus` | 角色 ↔ 菜单/权限点 | 权限管理 | +| 项目文档 | `projects` | 项目主表,`storage_key` 是磁盘目录名 | 项目管理 | +| 项目文档 | `project_members` | 项目成员与项目内角色 | 成员管理 | +| 项目文档 | `document_meta` | 文档标题/标签/字数/浏览与编辑痕迹(可选表,正文仍在文件) | 保存文件、浏览统计 | +| 项目文档 | `project_git_repos` | 项目绑定的 Git 仓库(含令牌) | Git 同步设置 | +| 项目文档 | `share_links` | 项目/文件分享码与访问密码 | 分享管理 | +| AI 知识库 | `llm_model_config` | Chat / Embedding 模型配置 | 模型配置 | +| AI 知识库 | `chat_session` | 对话会话(按项目 + 用户 + 模型) | 对话 | +| AI 知识库 | `chat_message` | 消息、状态、耗时、思考过程、引用文件 | 对话 | +| AI 知识库 | `document_vector` | 文档分块 → 向量库映射(ZVec) | 向量化 | +| AI 知识库 | `project_vectorization_task` | 全量/增量向量化任务与进度 | 向量化 | +| 通知审计 | `notifications` | 站内通知 | 通知服务 | +| 通知审计 | `operation_logs` | 操作审计(谁、何时、对什么、结果) | `log_service`(各 API 埋点) | +| 通知审计 | `mcp_bots` | MCP Bot 的 `bot_id/secret` 凭证 | MCP 凭证管理 | + +## 关系(逻辑外键) + +数据库只在 `notifications.user_id` 与 `project_git_repos.project_id` 上声明了真正的 FOREIGN KEY,其余是**逻辑外键**(不建约束,便于分库与批量清理): + +``` +users 1─N user_roles N─1 roles 1─N role_menus N─1 system_menus +users 1─N projects(owner_id) 1─N project_members N─1 users +projects 1─N document_meta / share_links / project_git_repos + / document_vector / project_vectorization_task / chat_session +chat_session 1─N chat_message +projects 1─N chat_session(同时 chat_session.user_id → users) +``` + +删除项目时由服务层负责级联清理文件目录与相关索引行;不要指望数据库级联。 + +## 初始化与迁移 + +| 阶段 | 代码 | 行为 | +| --- | --- | --- | +| 建表 | `backend/scripts/init_db.py` → `Base.metadata.create_all` | 只创建缺失表;表结构来自 `app/models/`。**模型必须在 `app/models/__init__.py` 中注册**,否则该表不会出现在新库里 | +| 种子 | 同上 | 角色(super_admin/admin/user)、系统菜单与权限点、管理员账号;按 id 与 `(parent_id, menu_name)` 去重,幂等增量 | +| 补列 | `app/core/migrations.py::migrate_schema()` | 幂等 `ALTER TABLE ADD COLUMN`(先查 `information_schema`)。当前覆盖:`chat_message.status/duration_ms/thinking_log`、`project_git_repos.sync_path` | +| 未使用 | Alembic | 依赖里有 `alembic`,但项目未启用版本化迁移脚本 | + +Docker 部署时后端容器启动命令是 `python scripts/init_db.py && uvicorn main:app …`,因此**新环境无需手工执行 SQL**。 + +## 已知数据差异与运维建议 + +1. **`project_members.role` 大小写混杂**:历史数据(早期 `init_database.sql` 建的库)多为 `ADMIN/EDITOR/VIEWER`,新写入是 `admin/editor/viewer`。读取路径统一经过 `app/services/project_service.normalize_project_role()` 归一化,因此行为正确;如需彻底清洗,可执行: + + ```sql + UPDATE project_members SET role = LOWER(role) WHERE BINARY role <> LOWER(role); + ``` + +2. **`share_links.access_pass` / `project_git_repos.token` 为明文存储**:属已知技术债(见发布报告 P2)。上线前应限制库账号的远程访问并纳入备份加密范围。 +3. **`document_vector` 无数据库唯一约束**:靠 `idx_project_file_chunk(project_id, file_path, chunk_index)` 普通索引查询,重复向量化由服务层用 `content_hash` 判定,不在库里去重。 +4. **`llm_model_config.api_key` 明文**:同上,接口返回时会脱敏,但库里是原文。 +5. **备份**:文件系统(`STORAGE_PATH`)与数据库必须一起备份,缺一个都无法还原,命令见 [deploy/README.md](deploy/README.md)。 + +```sql +-- 常用排查 +SELECT id, name, storage_key, owner_id, is_public FROM projects WHERE id = ?; -- storage_key 即磁盘目录名 +SELECT status, COUNT(*) FROM document_vector WHERE project_id = ? GROUP BY status; +SELECT task_id, task_type, status, total, processed, failed FROM project_vectorization_task + WHERE project_id = ? ORDER BY created_at DESC LIMIT 5; +SELECT role, COUNT(*) FROM project_members WHERE project_id = ? GROUP BY role; +``` + +--- + +## 身份与权限 + +### `users` — 用户 + +| 字段 | 类型 | 约束/默认 | 说明 | +| --- | --- | --- | --- | +| `id` | BIGINT | PK | 用户ID | +| `username` | VARCHAR(50) | UNIQUE · NOT NULL | 用户名 | +| `password_hash` | VARCHAR(255) | NOT NULL | 密码哈希 | +| `nickname` | VARCHAR(50) | — | 昵称 | +| `email` | VARCHAR(100) | — | 邮箱 | +| `phone` | VARCHAR(20) | — | 手机号 | +| `avatar` | VARCHAR(255) | — | 头像URL | +| `status` | SMALLINT | 默认 `1` | 状态:0-禁用 1-启用 | +| `is_superuser` | SMALLINT | 默认 `0` | 是否超级管理员:0-否 1-是 | +| `last_login_at` | DATETIME | — | 最后登录时间 | +| `last_login_ip` | VARCHAR(50) | — | 最后登录IP | +| `created_at` | DATETIME | — | 创建时间 | +| `updated_at` | DATETIME | — | 更新时间 | + +索引:`ix_users_email`(email);`ix_users_status`(status);`ix_users_username`(username) UNIQUE + +### `roles` — 角色 + +| 字段 | 类型 | 约束/默认 | 说明 | +| --- | --- | --- | --- | +| `id` | BIGINT | PK | 角色ID | +| `role_name` | VARCHAR(50) | UNIQUE · NOT NULL | 角色名称 | +| `role_code` | VARCHAR(50) | UNIQUE · NOT NULL | 角色编码 | +| `description` | VARCHAR(255) | — | 角色描述 | +| `status` | SMALLINT | 默认 `1` | 状态:0-禁用 1-启用 | +| `is_system` | SMALLINT | 默认 `0` | 是否系统角色:0-否 1-是 | +| `created_at` | DATETIME | — | 创建时间 | +| `updated_at` | DATETIME | — | 更新时间 | + +索引:`ix_roles_role_code`(role_code) UNIQUE;`ix_roles_status`(status) + +### `user_roles` — 用户-角色关联 + +| 字段 | 类型 | 约束/默认 | 说明 | +| --- | --- | --- | --- | +| `id` | BIGINT | PK | 关联ID | +| `user_id` | BIGINT | NOT NULL | 用户ID | +| `role_id` | BIGINT | NOT NULL | 角色ID | +| `created_at` | DATETIME | — | 创建时间 | + +索引:`ix_user_roles_role_id`(role_id);`ix_user_roles_user_id`(user_id) + +### `system_menus` — 系统菜单/权限点 + +| 字段 | 类型 | 约束/默认 | 说明 | +| --- | --- | --- | --- | +| `id` | BIGINT | PK | 菜单ID | +| `parent_id` | BIGINT | 默认 `0` | 父菜单ID(0表示根菜单) | +| `menu_name` | VARCHAR(50) | NOT NULL | 菜单名称 | +| `menu_code` | VARCHAR(50) | UNIQUE · NOT NULL | 菜单编码 | +| `menu_type` | SMALLINT | NOT NULL | 菜单类型:1-目录 2-菜单 3-按钮/权限点 | +| `path` | VARCHAR(255) | — | 路由路径 | +| `component` | VARCHAR(255) | — | 组件路径 | +| `icon` | VARCHAR(100) | — | 图标 | +| `sort_order` | INTEGER | 默认 `0` | 排序号 | +| `visible` | SMALLINT | 默认 `1` | 是否可见:0-隐藏 1-显示 | +| `status` | SMALLINT | 默认 `1` | 状态:0-禁用 1-启用 | +| `permission` | VARCHAR(100) | — | 权限字符串 | +| `created_at` | DATETIME | — | 创建时间 | +| `updated_at` | DATETIME | — | 更新时间 | + +索引:`ix_system_menus_menu_code`(menu_code) UNIQUE;`ix_system_menus_status`(status) + +### `role_menus` — 角色-菜单授权 + +| 字段 | 类型 | 约束/默认 | 说明 | +| --- | --- | --- | --- | +| `id` | BIGINT | PK | 关联ID | +| `role_id` | BIGINT | NOT NULL | 角色ID | +| `menu_id` | BIGINT | NOT NULL | 菜单ID | +| `created_at` | DATETIME | — | 创建时间 | + +索引:`ix_role_menus_menu_id`(menu_id);`ix_role_menus_role_id`(role_id) + +## 项目与文档 + +### `projects` — 项目 + +| 字段 | 类型 | 约束/默认 | 说明 | +| --- | --- | --- | --- | +| `id` | BIGINT | PK | 项目ID | +| `name` | VARCHAR(100) | NOT NULL | 项目名称 | +| `description` | VARCHAR(500) | — | 项目描述 | +| `storage_key` | VARCHAR(36) | UNIQUE · NOT NULL | 磁盘存储UUID | +| `owner_id` | BIGINT | NOT NULL | 项目所有者ID | +| `is_public` | SMALLINT | 默认 `0` | 是否公开:0-私有 1-公开 | +| `is_template` | SMALLINT | 默认 `0` | 是否模板项目:0-否 1-是 | +| `status` | SMALLINT | 默认 `1` | 状态:0-归档 1-活跃 | +| `cover_image` | VARCHAR(255) | — | 封面图 | +| `sort_order` | INTEGER | 默认 `0` | 排序号 | +| `visit_count` | INTEGER | 默认 `0` | 访问次数 | +| `access_pass` | VARCHAR(100) | — | 访问密码(用于分享链接) | +| `created_at` | DATETIME | — | 创建时间 | +| `updated_at` | DATETIME | — | 更新时间 | + +索引:`ix_projects_created_at`(created_at);`ix_projects_name`(name);`ix_projects_owner_id`(owner_id);`ix_projects_status`(status) + +### `project_members` — 项目成员 + +| 字段 | 类型 | 约束/默认 | 说明 | +| --- | --- | --- | --- | +| `id` | BIGINT | PK | 成员ID | +| `project_id` | BIGINT | NOT NULL | 项目ID | +| `user_id` | BIGINT | NOT NULL | 用户ID | +| `role` | VARCHAR(20) | 默认 `viewer` | 项目角色: admin/editor/viewer | +| `invited_by` | BIGINT | — | 邀请人ID | +| `joined_at` | DATETIME | — | 加入时间 | + +索引:`ix_project_members_project_id`(project_id);`ix_project_members_role`(role);`ix_project_members_user_id`(user_id) + +### `document_meta` — 文档元数据 + +| 字段 | 类型 | 约束/默认 | 说明 | +| --- | --- | --- | --- | +| `id` | BIGINT | PK | 元数据ID | +| `project_id` | BIGINT | NOT NULL | 项目ID | +| `file_path` | VARCHAR(500) | NOT NULL | 文件相对路径 | +| `title` | VARCHAR(200) | — | 文档标题 | +| `tags` | VARCHAR(500) | — | 标签(JSON数组) | +| `author_id` | BIGINT | — | 作者ID | +| `word_count` | INTEGER | 默认 `0` | 字数统计 | +| `view_count` | INTEGER | 默认 `0` | 浏览次数 | +| `last_editor_id` | BIGINT | — | 最后编辑者ID | +| `last_edited_at` | DATETIME | — | 最后编辑时间 | +| `created_at` | DATETIME | — | 创建时间 | +| `updated_at` | DATETIME | — | 更新时间 | + +索引:`ix_document_meta_author_id`(author_id);`ix_document_meta_project_id`(project_id) + +### `project_git_repos` — 项目绑定的 Git 仓库 + +| 字段 | 类型 | 约束/默认 | 说明 | +| --- | --- | --- | --- | +| `id` | BIGINT | PK | ID | +| `project_id` | BIGINT | FK → projects.id · NOT NULL | 项目ID | +| `name` | VARCHAR(50) | NOT NULL | 仓库别名 | +| `repo_url` | VARCHAR(255) | NOT NULL | Git仓库地址 | +| `branch` | VARCHAR(50) | 默认 `main` | Git分支 | +| `username` | VARCHAR(100) | — | Git用户名 | +| `token` | VARCHAR(255) | — | Git访问令牌/密码 | +| `is_default` | SMALLINT | 默认 `0` | 是否默认仓库 | +| `sync_path` | VARCHAR(255) | — | 同步目录(空=整个仓库) | +| `created_at` | DATETIME | — | 创建时间 | +| `updated_at` | DATETIME | — | 更新时间 | + +索引:`ix_project_git_repos_project_id`(project_id) + +### `share_links` — 分享链接 + +| 字段 | 类型 | 约束/默认 | 说明 | +| --- | --- | --- | --- | +| `id` | BIGINT | PK | 分享ID | +| `project_id` | BIGINT | NOT NULL | 项目ID | +| `share_type` | VARCHAR(20) | NOT NULL | 分享类型: project/file | +| `share_code` | VARCHAR(64) | UNIQUE · NOT NULL | 公开分享码 | +| `file_path` | VARCHAR(500) | — | 文件路径,仅文件分享使用 | +| `access_pass` | VARCHAR(100) | — | 访问密码 | +| `created_by` | BIGINT | — | 创建人ID | +| `status` | SMALLINT | 默认 `1` | 状态:0-禁用 1-启用 | +| `created_at` | DATETIME | — | 创建时间 | +| `updated_at` | DATETIME | — | 更新时间 | + +索引:`ix_share_links_created_by`(created_by);`ix_share_links_project_id`(project_id);`ix_share_links_share_code`(share_code) UNIQUE;`ix_share_links_share_type`(share_type);`ix_share_links_status`(status) + +## AI 知识库 + +### `llm_model_config` — LLM 模型配置 + +| 字段 | 类型 | 约束/默认 | 说明 | +| --- | --- | --- | --- | +| `config_id` | BIGINT | PK | 配置ID | +| `model_code` | VARCHAR(128) | UNIQUE · NOT NULL | 模型编码 | +| `model_name` | VARCHAR(255) | NOT NULL | 模型名称 | +| `model_type` | VARCHAR(32) | NOT NULL · 默认 `chat` | 模型类型: chat/embedding | +| `provider` | VARCHAR(64) | — | 模型提供方 | +| `endpoint_url` | VARCHAR(512) | — | 接口地址 | +| `api_key` | VARCHAR(512) | — | API Key | +| `llm_model_name` | VARCHAR(128) | NOT NULL | 模型名称/部署名 | +| `llm_timeout` | INTEGER | NOT NULL · 默认 `120` | 超时时间(秒) | +| `type_config` | JSON | NOT NULL · 默认 `服务端函数` | 模型类型差异参数 | +| `description` | VARCHAR(500) | — | 描述 | +| `is_active` | BOOLEAN | NOT NULL · 默认 `True` | 是否启用 | +| `is_default` | BOOLEAN | NOT NULL · 默认 `False` | 是否默认 | +| `created_at` | DATETIME | — | 创建时间 | +| `updated_at` | DATETIME | — | 更新时间 | + +索引:`ix_llm_model_config_is_active`(is_active);`ix_llm_model_config_model_code`(model_code) UNIQUE;`ix_llm_model_config_model_type`(model_type) + +### `chat_session` — 对话会话 + +| 字段 | 类型 | 约束/默认 | 说明 | +| --- | --- | --- | --- | +| `id` | BIGINT | PK | 会话ID | +| `project_id` | BIGINT | NOT NULL | 项目ID | +| `user_id` | BIGINT | NOT NULL | 用户ID | +| `llm_config_id` | BIGINT | NOT NULL | LLM配置ID | +| `title` | VARCHAR(255) | NOT NULL | 会话标题 | +| `description` | TEXT | — | 会话描述 | +| `is_active` | BOOLEAN | NOT NULL · 默认 `True` | 是否激活 | +| `message_count` | INTEGER | NOT NULL · 默认 `0` | 消息数 | +| `created_at` | DATETIME | — | 创建时间 | +| `updated_at` | DATETIME | — | 更新时间 | + +索引:`ix_chat_session_project_id`(project_id);`ix_chat_session_user_id`(user_id) + +### `chat_message` — 对话消息 + +| 字段 | 类型 | 约束/默认 | 说明 | +| --- | --- | --- | --- | +| `id` | BIGINT | PK | 消息ID | +| `session_id` | BIGINT | NOT NULL | 会话ID | +| `role` | VARCHAR(32) | NOT NULL | 角色(user/assistant) | +| `content` | TEXT | NOT NULL | 消息内容 | +| `status` | VARCHAR(32) | NOT NULL · 默认 `pending` | 消息状态: pending/completed/interrupted/error | +| `duration_ms` | INTEGER | — | 生成耗时(毫秒) | +| `thinking_log` | TEXT | — | 思考过程(JSON数组) | +| `referenced_files` | TEXT | — | 参考文件(JSON数组) | +| `tokens_used` | INTEGER | — | 消耗的token数 | +| `is_deleted` | BOOLEAN | NOT NULL · 默认 `False` | 是否已删除 | +| `created_at` | DATETIME | — | 创建时间 | + +索引:`ix_chat_message_session_id`(session_id) + +### `document_vector` — 文档向量分块 + +| 字段 | 类型 | 约束/默认 | 说明 | +| --- | --- | --- | --- | +| `id` | BIGINT | PK | 向量ID | +| `project_id` | BIGINT | NOT NULL | 项目ID | +| `file_path` | VARCHAR(500) | NOT NULL | 文件相对路径 | +| `chunk_index` | INTEGER | NOT NULL · 默认 `0` | 分块序号(0起),同一文件可有多个分块 | +| `chunk_text` | TEXT | — | 分块首段文本,作为点击引用时的定位锚点 | +| `content_hash` | VARCHAR(64) | — | 整个文件内容哈希值,用于判断文件是否变更 | +| `zvec_id` | VARCHAR(256) | — | ZVec返回的向量ID(每个分块独立) | +| `zvec_response` | TEXT | — | ZVec完整响应JSON | +| `status` | VARCHAR(32) | NOT NULL · 默认 `success` | 向量化状态:success/failed/pending | +| `error_message` | VARCHAR(500) | — | 错误信息 | +| `created_at` | DATETIME | — | 创建时间 | +| `updated_at` | DATETIME | — | 更新时间 | + +索引:`idx_project_file_chunk`(project_id, file_path, chunk_index);`idx_project_file`(project_id, file_path);`ix_document_vector_project_id`(project_id) + +### `project_vectorization_task` — 项目向量化任务 + +| 字段 | 类型 | 约束/默认 | 说明 | +| --- | --- | --- | --- | +| `id` | BIGINT | PK | 任务ID | +| `task_id` | VARCHAR(64) | UNIQUE · NOT NULL | 任务唯一标识 | +| `project_id` | BIGINT | NOT NULL | 项目ID | +| `user_id` | BIGINT | NOT NULL | 触发用户ID | +| `task_type` | VARCHAR(32) | NOT NULL | 任务类型:incremental/full | +| `status` | VARCHAR(32) | NOT NULL · 默认 `pending` | 任务状态:pending/running/success/failed | +| `total` | INTEGER | NOT NULL · 默认 `0` | 文件总数 | +| `processed` | INTEGER | NOT NULL · 默认 `0` | 处理成功数 | +| `skipped` | INTEGER | NOT NULL · 默认 `0` | 跳过数 | +| `failed` | INTEGER | NOT NULL · 默认 `0` | 失败数 | +| `error_message` | TEXT | — | 错误信息 | +| `started_at` | DATETIME | — | 开始时间 | +| `finished_at` | DATETIME | — | 完成时间 | +| `created_at` | DATETIME | — | 创建时间 | +| `updated_at` | DATETIME | — | 更新时间 | + +索引:`idx_vector_task_created_at`(created_at);`idx_vector_task_project_status`(project_id, status);`ix_project_vectorization_task_project_id`(project_id);`ix_project_vectorization_task_status`(status);`ix_project_vectorization_task_task_id`(task_id) UNIQUE;`ix_project_vectorization_task_user_id`(user_id) + +## 通知与审计 + +### `notifications` — 站内通知 + +| 字段 | 类型 | 约束/默认 | 说明 | +| --- | --- | --- | --- | +| `id` | BIGINT | PK | 通知ID | +| `user_id` | BIGINT | FK → users.id · NOT NULL | 接收用户ID | +| `type` | VARCHAR(20) | 默认 `info` | 类型:info, success, warning, error | +| `category` | VARCHAR(50) | 默认 `system` | 分类:system, project, collaboration | +| `title` | VARCHAR(200) | NOT NULL | 标题 | +| `content` | TEXT | — | 内容 | +| `link` | VARCHAR(255) | — | 跳转链接 | +| `is_read` | SMALLINT | 默认 `0` | 是否已读:0-未读 1-已读 | +| `created_at` | DATETIME | — | 创建时间 | +| `read_at` | DATETIME | — | 阅读时间 | + +索引:`ix_notifications_user_id`(user_id) + +### `operation_logs` — 操作日志 + +| 字段 | 类型 | 约束/默认 | 说明 | +| --- | --- | --- | --- | +| `id` | BIGINT | PK | 日志ID | +| `user_id` | BIGINT | — | 操作用户ID | +| `username` | VARCHAR(50) | — | 用户名 | +| `operation_type` | VARCHAR(50) | NOT NULL | 操作类型 | +| `resource_type` | VARCHAR(50) | NOT NULL | 资源类型 | +| `resource_id` | BIGINT | — | 资源ID | +| `detail` | TEXT | — | 操作详情(JSON) | +| `ip_address` | VARCHAR(50) | — | IP地址 | +| `user_agent` | VARCHAR(500) | — | 用户代理 | +| `status` | SMALLINT | 默认 `1` | 状态:0-失败 1-成功 | +| `error_message` | TEXT | — | 错误信息 | +| `created_at` | DATETIME | — | 操作时间 | + +索引:`ix_operation_logs_created_at`(created_at);`ix_operation_logs_resource_id`(resource_id);`ix_operation_logs_resource_type`(resource_type);`ix_operation_logs_user_id`(user_id) + +### `mcp_bots` — MCP Bot 凭证 + +| 字段 | 类型 | 约束/默认 | 说明 | +| --- | --- | --- | --- | +| `id` | BIGINT | PK | Bot credential ID | +| `user_id` | BIGINT | UNIQUE · NOT NULL | Owner user ID | +| `bot_id` | VARCHAR(64) | UNIQUE · NOT NULL | External MCP bot id | +| `bot_secret` | VARCHAR(255) | NOT NULL | External MCP bot secret | +| `status` | SMALLINT | 默认 `1` | Status: 0-disabled 1-enabled | +| `last_used_at` | DATETIME | — | Last successful MCP access time | +| `created_at` | DATETIME | — | Created at | +| `updated_at` | DATETIME | — | Updated at | + +索引:`ix_mcp_bots_bot_id`(bot_id) UNIQUE;`ix_mcp_bots_status`(status);`ix_mcp_bots_user_id`(user_id) UNIQUE + + +--- + +## 变更记录 + +| 日期 | 变更 | +| --- | --- | +| 2026-09-30 | 按 v1.0.0 代码重写:补齐 `notifications`、`mcp_bots`、`project_git_repos`、`chat_*`、`document_vector`、`project_vectorization_task` 等 10 张此前未记录的表;删除已废弃的 `init_database.sql` 相关描述 | diff --git a/docs/deploy/README.md b/docs/deploy/README.md new file mode 100644 index 0000000..4a4ca05 --- /dev/null +++ b/docs/deploy/README.md @@ -0,0 +1,177 @@ +# 部署指南(Docker Compose) + +> 本地开发请用 [`./scripts/start.sh`](../quickstart.md),不要用本文流程。 +> 本文所有命令都在**仓库根目录**执行;部署脚本已迁到 `scripts/`,写法固定为 `./scripts/deploy.sh …`。 + +## 1. 运行拓扑 + +`docker-compose.yml` 定义 4 个服务,容器名前缀 `nex-docus-`,基础镜像走华为云 SWR 镜像站(国内直连可用): + +| 服务 | 容器 | 端口(宿主机→容器) | 说明 | +| --- | --- | --- | --- | +| `mysql` | `nex-docus-mysql` | `${MYSQL_PORT:-3306}` → 3306 | MySQL 8.0,`utf8mb4` / `utf8mb4_unicode_ci`,数据在 `${STORAGE_PATH}/mysql` | +| `redis` | `nex-docus-redis` | `${REDIS_PORT:-6379}` → 6379 | Redis 7,`requirepass` + AOF,数据在 `${STORAGE_PATH}/redis` | +| `backend` | `nex-docus-backend` | `${BACKEND_PORT:-8000}` → 8000 | 由 `backend/Dockerfile` 构建;启动命令 `python scripts/init_db.py && uvicorn main:app …`,即**建表/迁移/种子全自动** | +| `frontend` | `nex-docus-frontend` | `${FRONTEND_PORT:-8080}` → 80 | 由 `frontend/Dockerfile` 构建(node 构建 + nginx 托管),nginx 反代 `/api/`、`/mcp` 到后端 | + +- 网络:`nex-docus-network`;后端在容器内的上游主机名是 `backend`,前端 nginx 依赖该名字。 +- **文件存储**:宿主机 `${STORAGE_PATH}` 挂到后端 `/data/nex_docus_store`(后端配置里的 `STORAGE_ROOT`)。文档正文、向量索引、搜索索引、PDF 缓存都在这里。 +- ⚠️ compose 里的相对路径按**执行 compose 的当前目录**解析,建议 `.env` 中把 `STORAGE_PATH` 写成绝对路径(如 `/data/nexdocus/storage`),避免换目录执行时数据"凭空消失"。 + +## 2. 前置要求 + +- Docker 20.10+,Docker Compose v2(`docker compose`;脚本也兼容老 `docker-compose`) +- 磁盘 ≥ 15 GB 空闲(后端镜像要装 PyTorch / weasyprint / zvec,镜像本身 + 向量索引 + 文档存储增长都吃磁盘) +- 内存 ≥ 4 GB(若启用本地 Embedding `sentence-transformers`,建议 8 GB) +- 出站网络可达所选 LLM / Embedding 服务 + +## 3. 配置 `.env` + +```bash +cp .env.example .env +$EDITOR .env +``` + +| 键 | 默认值 | 说明 | +| --- | --- | --- | +| `MYSQL_ROOT_PASSWORD` | `root_password_change_me` | **必改** | +| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `nex_docus` / `nexdocus` / `password_change_me` | 应用库与账号;**必改密码** | +| `MYSQL_PORT` | `3306` | 宿主机端口;不需要外部访问时建议改成 `127.0.0.1:3306` 或干脆不映射 | +| `REDIS_PASSWORD` / `REDIS_PORT` / `REDIS_DB` | `redis_password_change_me` / `6379` / `8` | **必改密码** | +| `SECRET_KEY` | 占位串 | **必改**:`openssl rand -hex 32`。改了会让已登录用户全部失效 | +| `DEBUG` | `false` | 保持 false(true 会打印全量 SQL) | +| `BACKEND_PORT` / `FRONTEND_PORT` | `8000` / `8080` | 对外端口;用户访问的是 `FRONTEND_PORT` | +| `STORAGE_PATH` | `./storage` | 持久化根目录,**建议绝对路径**;MySQL/Redis 数据也放这里 | +| `CHUNK_SIZE` / `CHUNK_OVERLAP` | `800` / `150` | RAG 分块策略,改动后需重新向量化才生效 | +| `ADMIN_USERNAME` / `ADMIN_PASSWORD` / `ADMIN_EMAIL` / `ADMIN_NICKNAME` | `admin` / `Admin@123456` / `admin@example.com` / `系统管理员` | **只在第一次建库时生效**,账号已存在则不会改密码 | +| `DEFAULT_USER_PASSWORD` | `User@123456` | 管理员在「系统管理 → 用户管理」新建用户时的初始密码;**建议改随机值**,否则后端启动会打 `[安全自检]` 告警 | +| `DISABLE_SSL_VERIFY` | `false` | 仅自签名证书的内部模型服务才开 | +| `ZVEC_DATA_DIR` | 空 | 留空即 `${STORAGE_ROOT}/vector_index` | +| `ZVEC_EMBEDDING_DIM` | `1536` | 必须与选定的 embedding 模型维度一致,换模型要重建索引 | + +> 前端**没有** API 地址配置项:容器内 nginx 固定把 `/api/` 反代到 `backend:8000`。跨域直连后端需自行改 `frontend/nginx.conf`。 +> 这套 `.env`(部署用)与 `backend/.env`(开发用,键名不同)是两套配置,容器里只认前者。 + +## 4. 首次部署 + +```bash +./scripts/deploy.sh init +``` + +依次执行:`.env` 检查(没有就从 `.env.example` 生成并中止,等你填)→ Docker 检查 → `pull` → `build --no-cache` → 起 mysql/redis → 等 15s → `run --rm backend python scripts/init_db.py` → `up -d` → 打印访问信息。 + +等价的手工流程: + +```bash +docker compose pull +docker compose build +docker compose up -d mysql redis +until docker compose exec -T mysql mysqladmin ping -hlocalhost -uroot -p"$MYSQL_ROOT_PASSWORD" --silent; do sleep 2; done +docker compose run --rm backend python scripts/init_db.py +docker compose up -d +docker compose ps +``` + +验证: + +```bash +curl -fsS http://127.0.0.1:8000/health # {"status":"healthy"} +docker compose ps # 4 个服务 healthy/running +open http://<服务器IP>:8080 # 登录页;默认 admin / .env 里的 ADMIN_PASSWORD +``` + +## 5. 日常运维 + +| 命令 | 作用 | +| --- | --- | +| `./scripts/deploy.sh start` / `stop` / `restart` | 起停服务 | +| `./scripts/deploy.sh status` | 容器状态 | +| `./scripts/deploy.sh logs` | 跟踪全部日志 | +| `./scripts/deploy.sh logs backend` | 跟踪单个服务(`mysql` / `redis` / `backend` / `frontend`) | +| `./scripts/deploy.sh upgrade` | 升级:交互确认 → `git pull` → 停前后端 → `build --no-cache` → 跑 `init_db.py`(自动补列/种子) → `up -d` → `docker image prune -f` | +| `./scripts/deploy.sh backup` | `mysqldump` 应用库到 `./backups/nex_docus_<时间戳>.sql` | +| `./scripts/deploy.sh restore <文件>` | 覆盖式恢复数据库(需输入 `yes`) | +| `./scripts/deploy.sh uninstall` | 删除容器/网络/本地构建镜像;**不会**删除 `STORAGE_PATH` 数据 | + +> `upgrade` 会执行 `git pull`:如果服务器上有本地改动或未切换分支,请先自行处理。 + +## 6. 备份与恢复 + +文档正文在文件系统、权限在数据库,**两者必须一起备份**,单独还原任何一侧都不算恢复。 + +```bash +# 备份 +./scripts/deploy.sh backup # 数据库 +tar czf nexdocus_storage_$(date +%F).tar.gz -C "$STORAGE_PATH" . # 文件 + 索引 + MySQL/Redis 数据 + +# 恢复(先起 mysql/redis,再灌数据,最后起应用) +docker compose up -d mysql redis +./scripts/deploy.sh restore backups/nex_docus_20260930_120000.sql +docker compose up -d backend frontend +``` + +迁移到新机器:拷贝 `storage` 目录 + SQL 备份 + `.env`,新机 `STORAGE_PATH` 指到新目录,恢复 SQL 后直接 `up -d`。项目的磁盘目录名是 `projects.storage_key`(UUID),与项目名解耦,可以整目录搬迁。 + +定期备份建议交给系统 `cron` 或备份软件,注意 `backups/` 也在 `.gitignore` 内,别提交到仓库。 + +## 7. 反向代理与 HTTPS + +前端容器只监听 80。生产建议前置一层网关做 TLS 与域名: + +```nginx +server { + listen 443 ssl http2; + server_name docs.example.com; + + ssl_certificate /etc/ssl/fullchain.pem; + ssl_certificate_key /etc/ssl/privkey.pem; + + client_max_body_size 100M; # 附件上传 + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_kind; + proxy_set_header Connection "upgrade"; + } +} +``` + +要点: +- 前端容器内的 nginx 已对 `/api/v1/chat/send/stream`(SSE)与 `/mcp` 关闭缓冲并放大超时;**外层网关若另有 location 命中这两条路径,也要关闭 `proxy_buffering`**,否则 AI 对话会"卡住不出字"。 +- `map $http_upgrade $http_kind { default upgrade; '' close; }` 放在 `http {}` 中(示例已简化)。 +- 上传大小限制要逐层一致(示例 100M)。 + +## 8. 上线前安全清单 + +- [ ] `MYSQL_ROOT_PASSWORD` / `DB_PASSWORD` / `REDIS_PASSWORD` / `SECRET_KEY` 全部改掉默认值 +- [ ] `ADMIN_PASSWORD` / `DEFAULT_USER_PASSWORD` 改为随机值;启动后 `docker compose logs backend | grep 安全自检` **应无输出**(有输出说明仍在用仓库默认值) +- [ ] 首次登录后修改 `admin` 密码;删除或禁用演示账号 +- [ ] MySQL / Redis 端口不对公网开放(安全组或改绑 `127.0.0.1`) +- [ ] `DEBUG=false` +- [ ] TLS 前置,HTTP 跳转 HTTPS +- [ ] `STORAGE_PATH` 与数据库备份纳入例行备份(数据库里含 Git Token、模型 API Key、分享密码明文) +- [ ] 磁盘监控:Redis 落盘失败会让整站变只读(见下) + +## 9. 常见问题 + +| 现象 | 原因 / 处理 | +| --- | --- | +| 前端 502 / 一直转圈 | 后端还没通过 `/health`(首次 `init_db.py` 需要时间)。`./scripts/deploy.sh logs backend` 观察 | +| `backend` 反复重启,日志 `MISCONF ... RDB snapshot` | Redis 服务端落盘失败并拒绝写入 → **磁盘满或目录权限问题**。清磁盘后 `redis-cli CONFIG SET stop-writes-on-bgsave-error no` 只是临时解封,重启 Redis 会复原,必须根治磁盘 | +| `Access denied for user` | `.env` 与实际库账号不一致;改过 `DB_PASSWORD` 后已初始化的 MySQL 不会自动同步,需要进容器 `ALTER USER` 或删库重建 | +| 换机器后项目列表空了 | `STORAGE_PATH` 用了相对路径,数据实际写在别的目录 | +| 改了 `ADMIN_PASSWORD` 但登录仍是旧密码 | 管理员已存在,`init_db.py` 不会重置密码;在「系统管理 → 用户管理」里改 | +| AI 对话无引用 / 报维度不匹配 | Embedding 模型或 `ZVEC_EMBEDDING_DIM` 变过 → 重新向量化项目(项目页触发全量) | +| PDF 导出中文乱码 | 后端镜像已内置 `fonts-wqy-microhei`;若自定义基础镜像请自带中文字体 | +| `docker compose` 提示 `version` 已废弃 | 已从 `docker-compose.yml` 移除该字段;若用老 `docker-compose` v1,建议升级到 Compose v2 | + +## 10. 本版验证边界 + +- ✅ 已验证:`./scripts/deploy.sh` 的参数路由与脚本语法、Compose 文件解析、后端镜像构建步骤文本与启动命令。 +- ❌ **未在本轮做端到端 `docker compose up` 实测**(缺少可用 Docker 环境)。首个正式部署请把它当作演练:`init → status → /health → 登录 → 建项目 → 上传 → AI 问答 → 备份`,并把结果登记到 [sdd/releases/v1.0.0.md](../sdd/releases/v1.0.0.md) 的验证边界。 +- ⚠️ `frontend/Dockerfile` 目前 `rm -rf package-lock.json && npm install`(历史原因是 Alpine 下 rollup 可选依赖问题),构建**不保证可重现**;确认网络环境稳定后建议改回 `npm ci`。 diff --git a/docs/deploy/changelog.md b/docs/deploy/changelog.md new file mode 100644 index 0000000..a3e9c1d --- /dev/null +++ b/docs/deploy/changelog.md @@ -0,0 +1,133 @@ +# 部署与配置变更日志 + +> 记录**影响部署方式、配置项、运维命令**的变更。纯功能/界面变更记录见 `docs/sdd/releases/`。 +> 约定:新变更写在最上方;每条必须写清「变更 / 原因 / 影响 / 迁移动作」。 + +--- + +## v1.0.0(待发布:需 `git tag v1.0.0`) + +### 1. 仓库脚本统一迁移到 `scripts/` + +- **变更**:根目录的部署/启动脚本全部移入 `scripts/`,新增一键启动与停止脚本。 + + | 旧位置 | 新位置 | 说明 | + | --- | --- | --- | + | `./deploy.sh` | `./scripts/deploy.sh` | 部署脚本保留,路径变更 | + | — | `./scripts/start.sh` | **新增**:本地一键启动(后端 + 前端 + 探活) | + | — | `./scripts/stop.sh` | **新增**:一键停止本地服务 | + | `backend/scripts/{init_database.sql,migrate_*.sql,add_*.py,check_*.py,…}` | 删除 | 过期的一次性脚本/SQL,初始化与迁移链路见 `docs/database.md` | + | `fix_docker_deployment.sh` | 删除 | 针对已不存在的旧 compose 结构 | + +- **原因**:根目录脚本混杂、命名不一致,历史一次性脚本已与新 schema 脱节,容易误执行。 +- **影响**:所有引用 `./deploy.sh` 的命令、文档、CI 需改为 `./scripts/deploy.sh`。 +- **迁移动作**: + ```bash + ./scripts/deploy.sh --help # 查看可用子命令 + ./scripts/start.sh # 本地开发一键启动 + ``` + +### 2. Compose 与 `.env` 配置项收敛 + +- **变更**: + - `docker-compose.yml` 移除废弃的顶层 `version: '3.8'` 字段。 + - 移除前端镜像构建参数 `VITE_API_BASE_URL`(前端代码从未读取该变量)。 + - `.env.example` 移除 `VITE_API_BASE_URL`,改为注释说明前端固定请求同源 `/api/v1`。 +- **原因**:前端 API 地址固定为相对路径 `/api/v1`(开发由 vite `server.proxy` 转发,容器内由 `frontend/nginx.conf` 反代到 compose 服务 `backend`)。保留该变量会让人以为可以改前端 API 域名,实际compose默认值会直接产出坏产物。 +- **影响**:升级后重新 `build` 前端镜像即可;`.env` 中残留的 `VITE_API_BASE_URL` 不再生效(可直接删除)。 +- **迁移动作**:`grep -n VITE_API_BASE_URL .env` 确认并删除该行。 + +### 3. `backups/` 纳入 `.gitignore` + +- **变更**:`.gitignore` 新增 `backups/`。 +- **原因**:`./scripts/deploy.sh backup` 会在仓库根产出 `backups/`(SQL + storage 归档),此前未被忽略,易被误提交(内含数据库全量数据)。 +- **迁移动作**:无需操作;历史遗留的 `backup/`(旧目录)请自行确认是否已离线保存后删除。 + +### 4. `deploy.sh uninstall` 行为修正 + +- **变更**:`uninstall` 子命令改为 `docker compose down -v --rmi local --remove-orphans`,并修正提示文案。 +- **原因**:旧实现里 `docker rmi $(docker images | grep nex-docus)` 是空操作(镜像名不带该前缀),且文案暗示会删除数据;实际 storage 采用宿主机 bind mount,`down -v` 不会删除文档文件。 +- **影响**:卸载会删除命名卷(MySQL/Redis 数据)与本地构建镜像;`STORAGE_PATH` 指向的宿主机目录仍需手动删除。 +- **迁移动作**:卸载前先执行 `./scripts/deploy.sh backup`。 + +### 5. 两套默认密码并存(重要,易踩坑) + +| 场景 | 初始化管理员 | 新建普通用户默认密码 | 来源 | +| --- | --- | --- | --- | +| 本地开发(`scripts/start.sh` + `backend/scripts/init_db.py`) | `admin` / `admin@123` | `User@123` | `backend/scripts/init_db.py` 与 `config.py` 的默认值 | +| Docker 部署(`docker-compose.yml` + `backend/scripts/init_db.py`) | `admin` / `Admin@123456` | `User@123456` | `.env` 的 `ADMIN_PASSWORD` / `DEFAULT_USER_PASSWORD`(compose 注入,见本文第 8 条) | + +- **原因**:历史原因两套初始化路径分别写死了不同默认值。 +- **影响**:跨环境复现问题时会以为"密码错了"。 +- **迁移动作**:**任何环境部署后立即修改管理员密码**;生产环境必须在 `.env` 显式设置 `ADMIN_PASSWORD`。 +- 同时:`init_db.py` 的管理员默认邮箱由真实域名改为占位 `admin@example.com`(原为 `admin@unisspace.com`),部署后请在「个人设置」里改成实际邮箱。 + +### 6. 文档位置重组 + +- **变更**:根目录文档全部迁入 `docs/`。 + + | 旧位置 | 新位置 | + | --- | --- | + | `QUICKSTART.md` | `docs/quickstart.md` | + | `DATABASE.md` | `docs/database.md` | + | `DEPLOY.md` | `docs/deploy/README.md` | + | `CHANGELOG_DEPLOY.md` | `docs/deploy/changelog.md`(本文件) | + | `PROJECT.md` / `IMPLEMENTATION_PLAN.md` | `docs/archive/`(历史归档,与现状不符) | + | `docs/manual/docus系统使用手册.md` | `docs/manual/user-guide.md` | + | `DEPLOYEE.md` / `README_DOCKER.md` | 删除(内容与 `DEPLOY.md` 重复且过期) | + +- **原因**:根目录 8 份 Markdown 重复/矛盾,且部分文档含明文生产凭据。 +- **迁移动作**:外部书签按上表更新;文档总入口见 `docs/README.md`。 + +### 7. 后端版本号为 1.0.0 + +- **变更**:`backend/app/core/config.py` 的 `APP_VERSION` 从 `0.9.9` 改为 `1.0.0`,与前端 `package.json`、发布计划对齐。 +- **影响**:`/openapi.json` 的 `info.version` 会随之变为 1.0.0;`/health` 无变化(`/health` 仅返回状态);如运维脚本按版本号做灰度判断需重新对齐。 +- **上线动作**:需执行 `git tag -a v1.0.0 -m "NEX Docus v1.0.0"`(见 [`docs/sdd/releases/v1.0.0.md`](../sdd/releases/v1.0.0.md))。 + +### 8. `DEFAULT_USER_PASSWORD` 现在真的可以从 `.env` 配置 + +- **变更**:`docker-compose.yml` 的 backend 服务补上 `DEFAULT_USER_PASSWORD=${DEFAULT_USER_PASSWORD:-User@123456}`,`.env.example` 补上该键。 +- **原因**:`config.py` 一直支持该配置项,但 compose 从未透传,Docker 部署下**改 `.env` 无效**,新建用户永远是 `User@123456`。 +- **影响**:升级后在 `.env` 设置即可生效;未设置时行为不变。 +- **迁移动作**:`echo 'DEFAULT_USER_PASSWORD=<随机值>' >> .env && docker compose up -d backend`。 + +### 9. 后端启动增加「安全自检」告警 + +- **变更**:`Settings.security_warnings()` 在应用启动时检查 `SECRET_KEY` 与 `DEFAULT_USER_PASSWORD`,命中仓库模板里的占位/默认值时打 `WARNING`(**只告警,不阻断启动**,避免影响存量部署)。 +- **原因**:compose 用 `${SECRET_KEY:-your-secret-key-change-me-in-production}` 之类的兜底默认值,运维漏配 `.env` 时会带着**公开已知的 JWT 密钥**上线且毫无提示。 +- **影响**:日志里可能出现 `[安全自检]` 开头的告警行;看到这行必须改配置,不要忽略。 +- **迁移动作**:`SECRET_KEY=$(openssl rand -hex 32)` 写入 `.env`;`ADMIN_PASSWORD`、`DEFAULT_USER_PASSWORD` 改为随机值。 + +### 10. `.env.example` 去掉真实域名邮箱 + +- **变更**:`ADMIN_EMAIL` 从 `admin@unisspace.com` 改为占位 `admin@example.com`。 +- **原因**:模板不应携带真实域名;compose 与 `init_db.py` 的默认值本就是 `admin@example.com`,两处不一致会让运维以为默认邮箱是前者。 +- **迁移动作**:已有 `.env` 自行决定是否替换;部署后在「个人设置」里改成实际邮箱。 + +--- + +## v0.9.9 及更早(历史,仅作追溯) + +> 以下条目来自旧 `CHANGELOG_DEPLOY.md`,其中提到的 `./deploy.sh` 现位于 `./scripts/deploy.sh`;早期版本号 `v1.0.1` 为文案占位,git 发布线此前最高仅到 v0.9.9。 + +### 前端端口 80 → 8080 +- 避免与宿主机常见服务冲突;可用 `.env` 的 `FRONTEND_PORT` 覆盖。 + +### Storage 由 Docker Volume 改为宿主机 bind mount +- 新增 `STORAGE_PATH`(默认 `./storage`),便于直接访问、备份、挂载独立磁盘,数据独立于容器生命周期。 +- 旧 volume 数据一次性导出: + ```bash + docker run --rm -v nex-docus_storage_data:/from -v "$(pwd)/storage:/to" alpine \ + sh -c "cd /from && cp -a . /to" + ``` + 确认无误后再 `docker volume rm nex-docus_storage_data`。 + +### 后端容器时区固定为 Asia/Shanghai +- 容器内 `TZ=Asia/Shanghai`,避免日志与 `created_at` 与业务时间相差 8 小时。 + +### 新增 `/health` 健康检查 +- 供 compose `healthcheck` 与外层负载均衡探活使用。 + +### Nginx 增加 SSE 支持 +- 知识库对话为流式响应:`proxy_buffering off`、`proxy_read_timeout` 放大,避免答案被截断。 diff --git a/docs/manual/docus系统使用手册.md b/docs/manual/docus系统使用手册.md deleted file mode 100644 index 3855a4a..0000000 --- a/docs/manual/docus系统使用手册.md +++ /dev/null @@ -1,4 +0,0 @@ -# 新文件 - -这是使用手册,我进一步修改了内容。测试和git仓库的同步。 -这次是用chatgpt修改的。 diff --git a/docs/manual/user-guide.md b/docs/manual/user-guide.md new file mode 100644 index 0000000..f2b5df9 --- /dev/null +++ b/docs/manual/user-guide.md @@ -0,0 +1,322 @@ +# NEX Docus 使用手册 + +> 面向**使用者与项目管理员**的功能说明(v1.0.0)。 +> 部署/运维请看 `docs/deploy/README.md`,本地跑起来请看 `docs/quickstart.md`。 + +## 目录 + +- [1. 认识 NEX Docus](#1-认识-nex-docus) +- [2. 登录与账号](#2-登录与账号) +- [3. 界面总览](#3-界面总览) +- [4. 项目空间](#4-项目空间) +- [5. 文档浏览页](#5-文档浏览页) +- [6. 编辑模式(编辑 + 文档索引)](#6-编辑模式编辑--文档索引) +- [7. 成员、角色与分享](#7-成员角色与分享) +- [8. Git 仓库同步](#8-git-仓库同步) +- [9. 知识库对话(AI 问答)](#9-知识库对话ai-问答) +- [10. 个人桌面、通知与个人设置](#10-个人桌面通知与个人设置) +- [11. 系统管理(管理员)](#11-系统管理管理员) +- [12. 快捷键与交互约定](#12-快捷键与交互约定) +- [13. 常见问题](#13-常见问题) + +--- + +## 1. 认识 NEX Docus + +NEX Docus 是一个**团队文档管理平台**,把三件事放在一起: + +1. **文档管理**:以「项目」为单位组织 Markdown / PDF / 图片等文件,支持目录树、在线编辑、版本化的 Git 同步。 +2. **知识检索**:文档可被切块并向量化,配合自研的**全文检索 + 向量检索**双引擎。 +3. **AI 问答**:在知识库对话中基于你自己的文档回答,并给出**可点击的引用来源**。 + +三类典型角色: + +| 角色 | 主要动作 | 常用入口 | +| --- | --- | --- | +| 读者 | 浏览、搜索、问答 | 项目空间 → 文档浏览页 / 知识库对话 | +| 编辑者 | 新建、编辑、整理目录、分享 | 编辑模式 | +| 管理员 | 用户/角色/权限、模型配置、系统日志 | 系统管理 | + +--- + +## 2. 登录与账号 + +访问前端地址(默认开发 `http://localhost:5173`,Docker `http://<服务器>:8080`),未登录会自动跳转 `/login`。 + +- 输入用户名 + 密码登录,令牌默认有效期 **24 小时**,过期后需重新登录。 +- 首次部署的默认管理员账号取决于部署方式(**本地开发**与 **Docker** 默认密码不同,详见 `docs/deploy/changelog.md` §5)。 +- **登录后请立即修改密码**:右上角头像 → 个人设置 → 修改密码。 +- 被管理员重置密码后,新密码为系统默认密码(同样在个人设置中改为自己的密码)。 + +登录失败排查:提示「用户名或密码错误」时先确认部署方式对应的默认密码;提示网络错误则先确认后端 `/health` 是否可访问(`curl http://<后端地址>:8000/health`)。 + +--- + +## 3. 界面总览 + +登录后左侧为**功能导航**,内容由服务端下发的菜单权限决定,常见结构: + +| 菜单 | 路径 | 用途 | +| --- | --- | --- | +| 个人桌面 | `/desktop` | 个人工作台:常用项目、最近文档、我的动态 | +| 管理面板 | `/dashboard` | 管理员统计视图(用户、项目、文档、活动) | +| 项目空间 → 我的项目 | `/projects/my` | 我拥有/参与的项目 | +| 项目空间 → 分享给我的 | `/projects/share` | 别人分享给我的项目 | +| 知识库空间 | `/chat` | AI 问答会话列表与对话界面 | +| 系统管理 | `/system/*` | 权限管理 / 用户管理 / 角色管理 / 模型配置 / 系统日志 | + +顶部栏右侧提供:**主题切换(浅色/深色)**、**通知入口**、**用户菜单**(个人设置、退出登录)。 + +> 菜单可见性由「系统管理 → 权限管理」控制。看不到某个入口通常是没有该菜单权限,而不是功能坏了。 +> 旧版本的知识库路径 `/knowledge`、`/knowledge/my` 会自动重定向到 `/chat`。 + +--- + +## 4. 项目空间 + +**项目**是文档的容器,一个项目对应磁盘上的一份文件目录。 + +### 4.1 创建项目 + +「我的项目」→ 右上角「创建项目」→ 填写项目名称、描述。创建者自动成为**所有者**。 + +### 4.2 项目卡片操作 + +每个项目卡片右上角提供: + +| 图标 | 名称 | 说明 | +| --- | --- | --- | +| ✏️ / 卡片本身 | 打开 | 进入文档浏览页 | +| ⚙️ | 项目设置 | 修改名称、描述、是否公开项目、是否允许公开分享 | +| 🗄 | 知识库向量化 | 打开向量化弹窗,见 §9.1 | +| 👥 | 成员管理 | 添加/移除成员、调整角色、转移所有权 | +| 🗑 | 删除项目 | 仅所有者/管理员,操作不可逆 | + +卡片下方展示**文档数量**、**最后更新时间**、**参与人数量**。 + +### 4.3 项目可见性 + +- **私有项目**:仅所有者与成员可见。 +- **公开项目**:全站登录用户可见(列表只读,编辑仍需 editor/admin 角色)。 +- **项目公开分享**:开启后才能生成对外分享链接(见 §7.3)。 + +--- + +## 5. 文档浏览页 + +路径:`/projects/<项目ID>/docs`。这是**阅读与整理**的主界面,左侧目录树 + 右侧渲染区。 + +### 5.1 目录树 + +- **点击文件**:右侧渲染 Markdown / PDF / 图片 / 代码等。 +- **右键节点**(或节点右侧的更多按钮):新建文件、新建文件夹、重命名、移动、删除。 +- **拖拽**:把文件或文件夹拖到目标文件夹即可移动,拖到根目录即移到顶层。 +- 目录树支持折叠全部/展开全部,并展示非文档类文件(可通过筛选开关隐藏)。 + +### 5.2 页内操作 + +顶部面包屑右侧: + +| 入口 | 说明 | +| --- | --- | +| 搜索文档内容 | 在项目内按关键词搜索文档正文,结果点击后直接跳到目标文件并高亮关键词 | +| 编辑 | 进入编辑模式(§6),并带上当前选中的文件 | +| 分享 | 生成该文件或项目的分享链接,可设置访问密码(§7.3) | +| 刷新 | 重新拉取目录树(外部改动、Git 同步后使用) | +| Git Pull / Git Push | 与远端仓库同步,可选择**同步范围**(仅某个目录);见 §8 | + +--- + +## 6. 编辑模式(编辑 + 文档索引) + +路径:`/projects/<项目ID>/editor`。从文档浏览页点「编辑」进入,会保留当前选中的文件。 + +### 6.1 三种视图模式 + +编辑器工具栏**右侧**的分段控件在三种模式间切换,与「全屏」按钮同排,**默认只打开编辑区**: + +| 模式 | 图标 | 内容 | 适用场景 | +| --- | --- | --- | --- | +| **编辑**(默认) | 笔 | 只有 Markdown 源码编辑区,占满整个内容宽度 | 专注写作 | +| **分栏** | 双栏 | 左编辑 + 右实时预览 | 检查排版、表格、图片 | +| **预览** | 眼睛 | 只渲染结果 | 交付前自查 | + +- 三个按钮只显示图标,鼠标悬停显示「编辑 · 仅编辑区」等说明文字,与同行其他图标保持一致。 +- 选择会被记住(写入浏览器本地存储),下次进入编辑模式沿用;不再强制并排打开预览。 +- 只有在**分栏**模式下才会出现「同步滚动」开关;纯编辑 / 纯预览时没有左右对照,该开关自动隐藏。 + +### 6.2 文档索引(Table of Contents) + +编辑区右侧悬浮「文档索引」面板列出当前文档的标题层级: + +- 目录来自 **Markdown 源码解析**,因此**在纯编辑模式下同样可用**,不依赖预览区是否打开。 +- 点击条目:编辑器滚动到对应标题并把光标定位过去(编辑模式下不会跳走页面)。 +- 文档没有标题时面板显示为空;标题层级过深时按层级缩进。 + +### 6.3 保存、重置与未保存保护 + +- **保存**:把编辑区内容写回服务器文件。快捷键 `Ctrl/Cmd + S`(弹窗打开或正在保存时不会触发)。 +- **未保存标记**:内容与服务端版本不一致时,内容区右上角显示「未保存」标记。 +- **退出保护**:点「退出编辑」时,只有在**确有未保存改动**才弹确认框(「放弃修改并退出 / 继续编辑」);关闭浏览器标签或刷新页面时,浏览器也会给出挽留提示。 +- **重置**:放弃本地改动,重新加载服务器上最后一次保存的版本(同样需要确认)。 + +### 6.4 编辑区的其他能力 + +- **上传文件**:把本地文件(含图片)上传到当前目录,图片自动写入相对路径引用。 +- **插入链接**:支持**页内链接**(选择本文标题生成锚点)与**页间链接**(选择项目内其他文件)。 +- **新建/重命名/移动/删除**:与浏览页一致的目录树右键菜单。 +- **大文档模式**:超大 Markdown 自动切换为轻量编辑器(此时不提供分栏/预览),避免卡顿。 +- **全屏**:点工具栏右侧的全屏图标把编辑器铺满整个窗口,左侧项目树的操作按钮不会浮现在编辑器上;再点一次该图标或按 `Esc` 退出全屏。 + +--- + +## 7. 成员、角色与分享 + +### 7.1 项目角色 + +| 角色 | 能做什么 | +| --- | --- | +| 所有者 owner | 全部权限,含删除项目、转移所有权 | +| 管理员 admin | 管理成员与全部文档,可转让所有权以外的操作 | +| 编辑者 editor | 新建/编辑/删除文档、上传、Git 同步 | +| 查看者 viewer | 只读浏览、搜索、参与 AI 问答 | + +### 7.2 管理成员 + +项目卡片 → 成员管理 → 按用户名搜索添加,并为成员指定角色;也可以在此**转移项目所有权**。移除成员不会删除其创建的文档。 + +### 7.3 分享链接 + +- **项目分享**:项目设置里开启「项目公开分享」后生成链接,形如 `/share/project/<分享码>`。 +- **文件分享**:文档浏览页对单个文件点「分享」,形如 `/share/file/<分享码>`。 +- 可设置**访问密码**,访问时需先输入密码。 +- 分享页**只读**,不提供编辑入口;关闭「公开分享」会让既有链接失效。 + +> 安全提示:分享链接无需登录即可访问,请勿把含敏感信息的文档设为公开分享;管理员应定期清理不再使用的分享。 + +--- + +## 8. Git 仓库同步 + +用于把项目目录和一个 Git 仓库对接(适合团队用 IDE/命令行协作,同时保留 Web 编辑)。 + +**配置**:项目卡片 → 成员管理旁的 Git 仓库管理入口 → 填写仓库别名、Git 仓库地址、分支、用户名、Token/密码。 + +**使用**:文档浏览页顶部「Git Pull / Git Push」,两者都可**选择同步范围**(只同步某个子目录),避免大仓一次性同步。 + +前提与注意: + +- 服务端所在主机必须安装 `git` 命令(后端通过命令行调用 git,不是内置实现)。 +- 私有仓库请优先使用 **Access Token** 而不是账号密码。 +- 冲突时同步会失败并返回 git 的原始报错,请在本地仓库解决冲突后重试。 +- 同步完成后点「刷新」重载目录树。 + +--- + +## 9. 知识库对话(AI 问答) + +路径:`/chat`(新建会话 `/chat/new`)。左侧会话列表,右侧对话区。 + +### 9.1 先决条件:模型配置 + 向量化 + +1. 管理员在「系统管理 → 模型配置」中启用至少一个 **chat** 模型(回答用)和一个 **embedding** 模型(向量化用)。 +2. 项目卡片 → 「知识库向量化」→ 选择**增量向量化**(只处理新增/变更文档)或**全量向量化**(重建索引)。弹窗展示最近任务的状态、进度与已向量化数量。 + +> 若弹窗提示「尚未配置可用的向量模型」,说明 embedding 模型未启用,或向量维度与模型实际输出不一致(`ZVEC_EMBEDDING_DIM`)。 + +### 9.2 提问 + +- 新建对话时**选择要检索的项目/知识库**,可以选择多个。 +- 答案以流式返回,过程中可点**停止**中断生成。 +- 回答下方展示**引用来源**(命中的文档与片段),点击可跳转到原文档位置。 +- 界面给出**耗时**等运行信息,便于判断是检索慢还是模型慢。 + +### 9.3 检索行为 + +问答走**双引擎**:关键词(全文)检索 + 向量(语义)检索,合并后交给模型。因此: + +- 精确术语/代码标识符命中靠全文检索;换词、口语化提问靠向量检索。 +- **文档改了但答案还是旧的** → 通常是忘了做增量向量化。 +- 没向量化过的项目只能靠全文检索,质量会明显下降。 + +--- + +## 10. 个人桌面、通知与个人设置 + +- **个人桌面** `/desktop`:个人工作台,快速进入常用项目与最近编辑的文档。 +- **通知中心** `/notifications`:查看项目邀请、文档变更、向量化/同步任务结果等站内通知,支持标记已读。 + +### 10.1 个人设置(`/profile`) + +| 标签页 | 内容 | +| --- | --- | +| 个人资料 | 修改昵称、邮箱、头像(图片不超过 1MB) | +| 修改密码 | 校验原密码后设置新密码,修改后需重新登录 | +| MCP 接入 | 查看/复制本人的 `X-Bot-Id` 与 `X-Bot-Secret`,可**重新生成** Secret | + +> **重新生成 MCP Secret 会立即让旧的 Secret 失效**,用旧凭证配置的 MCP 客户端(如 IDE、机器人)需要同步更新。MCP 的接入方式与可用工具见 `docs/sdd/integrations/mcp.md`。 + +--- + +## 11. 系统管理(管理员) + +| 页面 | 路径 | 作用 | +| --- | --- | --- | +| 权限管理 | `/system/permissions` | 维护菜单与权限点,并给角色授权;决定用户能看到哪些入口 | +| 用户管理 | `/system/users` | 新增用户、启停用、重置密码、分配角色 | +| 角色管理 | `/system/roles` | 新增角色、改名与描述、绑定权限 | +| 模型配置 | `/system/model-configs` | 维护 LLM 服务:类型分 **chat / embedding**,配置名称、服务地址、API Key、模型名、维度,并设为默认/启用 | +| 系统日志 | `/system/logs` | 查看操作与系统日志,用于排查问题 | + +模型配置注意: + +- **embedding 模型一旦用于向量化,维度就不能随意改**;换模型需同时更新 `ZVEC_EMBEDDING_DIM` 并做**全量向量化**。 +- 使用自签名 HTTPS 的私有模型服务时,需要 `DISABLE_SSL_VERIFY=true`(仅该场景,生产保持 `false`)。 +- API Key 属于敏感信息,只有管理员可维护,界面上会做掩码处理。 + +--- + +## 12. 快捷键与交互约定 + +| 场景 | 操作 | +| --- | --- | +| 保存当前文档 | `Ctrl/Cmd + S`(编辑模式) | +| 打开文件/目录操作菜单 | 目录树节点**右键** | +| 移动文件/目录 | 目录树中**拖拽**到目标文件夹 | +| 返回上一级视图 | 面包屑点击,或浏览器后退 | +| 深浅色切换 | 顶部栏主题开关,选择会被记住 | + +统一交互约定: + +- **危险操作**(删除项目/文件/成员、Git 推送等)一律先弹确认框,确认后才执行。 +- **操作结果**用轻提示反馈(不打断操作);表单错误就近显示在字段下方。 +- 列表/树加载时显示骨架或加载态;空数据统一显示空状态占位与下一步引导。 +- 只读角色(viewer)不会看到编辑、删除类按钮。 + +--- + +## 13. 常见问题 + +**Q:打开项目只有阅读区,找不到编辑按钮?** +A:当前账号在该项目中是「查看者」。让所有者/管理员在成员管理里把角色提升为「编辑者」或「管理员」。 + +**Q:编辑模式右边没有预览,是不是坏了?** +A:不是。v1.0.0 起**默认只打开编辑区**,点编辑器工具栏右侧的分段控件切到「分栏」或「预览」即可;选择会被记住。 + +**Q:文档索引(TOC)里是空的?** +A:目录来自 Markdown 标题(`#`/`##`…)。文档没有标题,或超大文档走了大文档模式时,索引可能为空或受限。 + +**Q:AI 问答说「文档里没有相关内容」,但文档确实存在?** +A:按顺序检查:① 会话是否选中了正确的项目;② 该项目是否做过(增量)向量化;③ 模型配置里 chat / embedding 模型是否都已启用。 + +**Q:向量化一直不动或失败?** +A:先看弹窗里最近任务的报错,再看「系统日志」和后端日志。常见原因是 embedding 服务不可达、维度不匹配,或 Redis 拒绝写入(`MISCONF`,见 `docs/quickstart.md` FAQ)。 + +**Q:Git 同步报错 `git: command not found`?** +A:后端主机未安装 git。容器部署时需要在镜像内提供 git(`docs/deploy/README.md` 有说明)。 + +**Q:分享链接打不开?** +A:确认项目设置里「项目公开分享」仍为开启状态;文件分享需对应文件仍存在;有密码的要先输密码。 + +**Q:左侧菜单少了一块?** +A:菜单由权限控制,见「系统管理 → 权限管理」中该角色的菜单授权。 diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..057fa35 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,162 @@ +# 快速上手(开发环境) + +本文面向**本地开发**。服务器部署请看 [deploy/README.md](deploy/README.md)。 + +## 1. 前置要求 + +| 依赖 | 版本 | 说明 | +| --- | --- | --- | +| Python | 3.10+(推荐 3.12) | 后端运行时 | +| Node.js | 18+ | 前端构建(vite 5) | +| MySQL | 8.0(utf8mb4) | 元数据/权限;5.7 未纳入支持范围 | +| Redis | 5+ | 会话缓存、索引同步信号 | +| git | 任意新版 | 仅在使用「Git 仓库同步」功能时需要 | + +## 2. 一键启动(推荐) + +```bash +./scripts/start.sh +``` + +脚本按顺序执行:环境检查 → 创建 `backend/venv` → 生成 `backend/.env` → 安装前后端依赖 → **真实校验** MySQL/Redis → 幂等初始化数据库 → 启动后端与前端。 + +首次执行会因为需要填写连接信息而**主动中断**: + +```text +! backend/.env 不存在,正在从模板创建(请填写数据库/Redis 连接信息) +✗ 请编辑 backend/.env 填写数据库与 Redis 连接信息后重新运行 +``` + +填好 `DB_HOST/DB_USER/DB_PASSWORD/DB_NAME`、`REDIS_HOST/REDIS_PASSWORD/REDIS_DB` 后再次运行即可。 + +常用参数: + +| 命令 | 作用 | +| --- | --- | +| `./scripts/start.sh` | 启动后端 + 前端(前台运行,Ctrl+C 全停) | +| `./scripts/start.sh --backend` | 只启动后端 | +| `./scripts/start.sh --frontend` | 只启动前端 | +| `./scripts/start.sh --install` | 只准备环境(venv / npm 依赖 / 建表),不启动服务 | +| `./scripts/start.sh --init-db` | 强制重跑一次数据库初始化(幂等) | +| `./scripts/start.sh --port 8001` | 换后端端口(用环境变量覆盖,不改 `backend/.env`) | +| `./scripts/start.sh --docker` | 转交 `scripts/deploy.sh start` | +| `./scripts/start.sh --daemon` | 启动后立即返回(服务后台常驻,用 `stop.sh` 停止) | +| `./scripts/stop.sh` | 停止由 start.sh 启动的进程 | + +启动成功输出: + +```text +✓ MySQL 连接正常(MySQL 8.0.37) +✓ Redis 连接正常 +✓ 后端健康检查通过(/health) +✓ NEX Docus 已启动 + 后端 API http://localhost:8000 文档 http://localhost:8000/docs + 前端页面 http://localhost:5173 +``` + +进程 pid 与日志在 `.run/`(`backend.log`、`frontend.log`),该目录已被 gitignore。 + +## 3. 手动启动(不使用脚本时) + +```bash +# 后端 +cd backend +python3 -m venv venv && source venv/bin/activate +pip install -r requirements.txt # 国内网络可加 -i https://pypi.tuna.tsinghua.edu.cn/simple +cp -n .env.example .env 2>/dev/null || true # 没有模板时按下一节的键自行创建 .env +python scripts/init_db.py # 建表 + 种子数据(幂等) +uvicorn main:app --host 0.0.0.0 --port 8000 --reload + +# 前端(另一个终端) +cd frontend +npm install +npm run dev +``` + +> `requirements.txt` 含 `sentence-transformers`(会拉取 PyTorch,体积以 GB 计)与 `zvec`。若只用远程 OpenAI 兼容 Embedding 接口,可以只装它们之外的依赖;`sentence_transformers` 是延迟导入的,不装也能启动。 + +## 4. `backend/.env` 配置项 + +后端配置由 `app/core/config.py` 用 pydantic-settings 读取 `.env`(同名环境变量优先级更高)。 + +| 键 | 默认值 | 说明 | +| --- | --- | --- | +| `APP_NAME` | `NEX Docus` | 标题 | +| `APP_VERSION` | `1.0.0` | 出现在根路径 `GET /` 的 `version`、`/docs` 标题与 `/openapi.json` | +| `DEBUG` | `True` | **为 True 时 SQLAlchemy 打印全量 SQL,生产必须关闭** | +| `HOST` / `PORT` | `0.0.0.0` / `8000` | 监听地址(`uvicorn` 命令行参数优先) | +| `DB_HOST` `DB_PORT` `DB_USER` `DB_PASSWORD` `DB_NAME` `DB_CHARSET` | — / 3306 / — / — / — / utf8mb4 | MySQL 连接;密码会自动做 URL 编码 | +| `REDIS_HOST` `REDIS_PORT` `REDIS_PASSWORD` `REDIS_DB` | — / 6379 / — / 8 | Redis 连接 | +| `SECRET_KEY` | — | JWT 签名密钥,务必随机化;仍为模板占位值或长度 <16 时启动会打「安全自检」告警 | +| `ALGORITHM` / `ACCESS_TOKEN_EXPIRE_MINUTES` | `HS256` / `1440` | Token 配置 | +| `DEFAULT_USER_PASSWORD` | `User@123456` | 管理员新建用户时的初始密码;仍为默认值时启动会打「安全自检」告警 | +| `STORAGE_ROOT` `PROJECTS_PATH` `USERS_PATH` `TEMP_PATH` | `/data/nex_docus_store/...` | 文件存储根目录与各子目录;本地开发通常指向 `./storage/...` | +| `AVATAR_MAX_SIZE` | `1048576` | 头像上传上限(字节) | +| `CHUNK_SIZE` / `CHUNK_OVERLAP` | `800` / `150` | RAG 分块字符数与重叠 | +| `CORS_ORIGINS` | `["http://localhost:5173","http://localhost:3000"]` | JSON 数组字符串 | +| `LOG_LEVEL` / `LOG_PATH` | `INFO` / `logs` | 日志 | +| `ADMIN_USERNAME` `ADMIN_PASSWORD` `ADMIN_EMAIL` `ADMIN_NICKNAME` | `admin` / `admin@123` / `admin@example.com` / `系统管理员` | 仅 `init_db.py` 首次创建管理员时读取(Docker 部署由 compose 注入,默认口令见 [deploy/changelog.md](deploy/changelog.md#5-两套默认密码并存重要易踩坑)) | +| `ZVEC_DATA_DIR` / `ZVEC_EMBEDDING_DIM` | `STORAGE_ROOT/vector_index` / `1536` | 向量库目录与维度(维度必须与所选 embedding 模型一致) | +| `DISABLE_SSL_VERIFY` | `false` | 仅当 LLM/Embedding 服务使用自签名证书时开启 | + +> Docker 部署用的是仓库根目录 `.env`(键名不同,见 [deploy/README.md](deploy/README.md) 与 `.env.example`),与 `backend/.env` 是两套配置,别混用。 + +## 5. 默认账号 + +`backend/scripts/init_db.py` 首次创建管理员时使用: + +- 用户名 `admin` +- 密码 `admin@123` + +已存在同名用户时不会重置密码。生产环境请用 `ADMIN_PASSWORD` 覆盖并在首次登录后立即修改。 + +## 6. 前端约定 + +- 开发端口 `5173`,`vite.config.js` 已把 `/api` 代理到 `http://localhost:8000`,因此**前端不需要配置后端地址**。 +- 路径别名 `@` → `frontend/src`。 +- 页面一律在 `src/App.jsx` 中用 `lazy()` 注册,路由级懒加载。 +- 主题/颜色取自 `src/styles/design-tokens.css` 与 `src/theme/antdTheme.js`,不要写死色值。 +- 全局提示统一 `@/components/Toast`(含 `Toast.confirm`),不要散用 `Modal.confirm` / `message.*`。 + +## 7. 自检清单 + +```bash +curl -s http://127.0.0.1:8000/health # {"status":"healthy"} +curl -s http://127.0.0.1:8000/openapi.json | head -c 120 # version 应为 1.0.0 +open http://127.0.0.1:8000/docs # Swagger +open http://localhost:5173 # 登录页 +``` + +## 8. 常用开发命令 + +```bash +cd frontend && npm run lint # ESLint(当前基线:0 error / 25 warning) +cd frontend && npm run lint:strict # 警告也视为失败 +cd frontend && npm run build # 生产构建 + +cd backend && ./venv/bin/pip install -r requirements-dev.txt # 安装 pytest 等开发依赖 +cd backend && ./venv/bin/python -m pytest # 当前基线:40 passed +``` + +> 注意:`npx eslint src` 会**秒退且全通过**——ESLint 8 对目录默认只收 `.js`,必须写 `eslint "src/**/*.{js,jsx}"` 或用 `npm run lint`(已带 `--ext js,jsx`)。 + +## 9. 数据库结构与迁移 + +- 建表:`init_db.py` 调 `Base.metadata.create_all`,表结构来自 `app/models/`;**新增模型必须在 `app/models/__init__.py` 里导入**,否则该表不会被创建(历史事故:`notifications`、`project_git_repos` 曾因此在新库缺失)。 +- 种子数据:角色(super_admin / admin / user)、系统菜单、管理员账号,全部幂等增量。 +- 补列:`app/core/migrations.py::migrate_schema()` 在服务启动前/初始化时执行,只补已知缺失列。 +- 变更流程:改模型 → 本地重跑 `./scripts/start.sh --init-db` → 若是老库需要的列,补进 `migrations.py` → 更新 [database.md](database.md)。仓库不再接收散落的 `*.sql` 补丁。 + +## 10. 常见问题 + +| 现象 | 原因 / 处理 | +| --- | --- | +| `✗ Redis 连接失败:AUTH 失败` | `REDIS_PASSWORD` 与服务端不一致;服务端未设密码时留空 | +| `Redis 拒绝写入:MISCONF Redis is configured to save RDB snapshots` | 服务端 RDB/AOF 落盘失败(磁盘满或目录无权限)。**先修磁盘**;应急可 `redis-cli CONFIG SET stop-writes-on-bgsave-error no`,但该配置重启即失效,不是修复 | +| `✗ MySQL 连接失败:Access denied` | 用户/密码/授权主机不匹配;确认对 `DB_NAME` 有权限 | +| 后端起来了但某些表不存在 | 模型未在 `app/models/__init__.py` 注册;补 import 后 `--init-db` | +| 前端 401 后一直跳登录页 | Token 过期(`ACCESS_TOKEN_EXPIRE_MINUTES`),或 `SECRET_KEY` 改过导致旧 Token 失效 | +| 前端请求 404 | 后端未启动/端口不是 8000(改了 `--port` 就要同步改 `vite.config.js` 的 proxy target) | +| `pip install` 卡在 torch | 网络问题;用清华源,或临时跳过 `sentence-transformers`(仅本地 Embedding 需要) | +| 控制台刷屏 SQL | `DEBUG=True` 的正常行为,关掉即止 | +| 端口被占用 | `lsof -ti:8000 \| xargs kill`;后端也可 `./scripts/start.sh --port 8001` | diff --git a/docs/sdd/README.md b/docs/sdd/README.md index ba4053a..56730c1 100644 --- a/docs/sdd/README.md +++ b/docs/sdd/README.md @@ -28,8 +28,9 @@ docs/sdd/ │ └── git.md # 项目 Git 仓库集成 ├── releases/ │ ├── README.md # 公开版本与资产索引 -│ ├── v0.9.6.md # v0.9.6 历史升级记录(整合自 docs/) -│ └── v0.9.9.md # 当前基线发布(对齐 git) +│ ├── v1.0.0.md # 当前发布(首个正式版本,含验证边界与上线清单) +│ ├── v0.9.9.md # 历史发布记录 +│ └── v0.9.6.md # 历史升级记录(整合自 docs/) └── specs/ ├── README.md # 功能规格索引 ├── _template/ # 新规格模板 @@ -38,11 +39,12 @@ docs/sdd/ ## 当前状态(Status) -- **SDD 文档状态**:基线(Baseline)· 对齐 git 当前版本 v0.9.9 -- **适用代码基线**:当前仓库(backend + frontend + docker-compose 部署) +- **SDD 文档状态**:v1.0.0 发布评审(Release Candidate) +- **适用代码基线**:`ba80d28 fix project role permission`(main)+ 发布前整改(未提交) - **规格覆盖**:11 个功能单元(DV-0001 ~ DV-0011);价值主张 PO-1~5;架构决策 ADR-0001~0008 -- **已知整改项**:见 governance「开放问题」与各规格 tasks.md 中的“待整改/待决”标记(如敏感日志脱敏 TS-12、Compose v2 官方化 TS-13) -- **版本基线**:以 git 为准(当前 v0.9.9;见 releases/README.md 与 v0.9.9.md 版本对齐说明;代码字段 1.0.0 为遗留占位) +- **验证边界**:已复验项(pytest 40 passed / eslint 0 error / vite build / 编辑器与权限的浏览器实测)与**未验证项**(Docker 端到端、MCP 实连、Git 真实远端同步)统一登记在 [releases/v1.0.0.md](releases/v1.0.0.md) +- **已知整改项**:以 [releases/v1.0.0.md 的「已知问题」表](releases/v1.0.0.md#已知问题开放项)为总表(含 P0 运维阻塞:Redis RDB 失败);细节见 governance「开放问题」与各规格 tasks.md +- **版本基线**:**v1.0.0**(`APP_VERSION` 与 `package.json` 已对齐;**git tag `v1.0.0` 待创建**,属上线动作,见 releases/README.md) ## 如何使用本中心(工作流) @@ -71,9 +73,9 @@ docs/sdd/ ## 与外部文档的关系 SDD 中心**汇总并指向**仓库既有的详细材料,而非重复复制全部内容: -- 数据库细节 → 仓库根 `DATABASE.md`(SDD 只保留 ER 概览与规格化的链路表) -- 部署运维 → `DEPLOY.md`、`README_DOCKER.md`、`CHANGELOG_DEPLOY.md`(及已并入的 docs/DOCKER_DOCS_SETUP 历史,见 archive.md) -- 历史变更 → `docs/UPGRADE_v0.9.6.md`、`docs/MIGRATION.md`(已整合/弃置,见 archive.md) +- 数据库细节 → [`docs/database.md`](../database.md)(SDD 只保留 ER 概览与规格化的链路表) +- 部署运维 → [`docs/deploy/README.md`](../deploy/README.md)、[配置变更日志](../deploy/changelog.md)(原 `DEPLOY.md`/`README_DOCKER.md`/`CHANGELOG_DEPLOY.md` 已合并去重) +- 发布与已知问题 → [releases/v1.0.0.md](releases/v1.0.0.md);历史变更 → [releases/v0.9.6.md](releases/v0.9.6.md)(原 `docs/UPGRADE_v0.9.6.md`);`docs/MIGRATION.md` 已弃置,见 [archive.md](archive.md) - 结构规范 → `architecture/standards/`(原 docs/code-structure-* 已迁入) > 本中心是入口与追踪层;具体逐表 DDL、逐配置项说明等细节仍以被指向的源文档为准。 diff --git a/docs/sdd/architecture/constraints.md b/docs/sdd/architecture/constraints.md index 447ce7e..615d034 100644 --- a/docs/sdd/architecture/constraints.md +++ b/docs/sdd/architecture/constraints.md @@ -20,7 +20,7 @@ | --- | --- | --- | --- | | OP-1 | Alembic vs 幂等 ALTER | 采用幂等 ALTER(`migrations.py`) | 评估引入 Alembic 以支撑大规模结构变更 | | OP-2 | 敏感日志 | `security.py`/`deps.py` 记录 SECRET_KEY 前缀/JWT 载荷 | 脱敏,见 OI-1 | -| OP-3 | Compose v2 官方化 | deploy.sh 探测 v2 但根文档仍提及 v1 | 统一 v2 路径,见 OI-2 | +| OP-3 | Compose v2 官方化 | 文档已统一 v2 与 `./scripts/deploy.sh` 路径,待一次真实环境端到端复验 | 见 OI-2、[v1.0.0 验证边界](../releases/v1.0.0.md) | | OP-4 | 测试覆盖 | 仅 3 个后端单测 | 增加集成/E2E,见 OI-3 | | OP-5 | 实时协同编辑 | 明确不在此版本做(vision 边界) | 另立项评估 OT/CRDT | | OP-6 | 多实例/高可用 | 单机部署,无状态服务仅后端(Redis/DB 独立) | 若需要再评估 | diff --git a/docs/sdd/architecture/decisions/ADR-0008-compose-v2.md b/docs/sdd/architecture/decisions/ADR-0008-compose-v2.md index ea20b9a..fc4cb8e 100644 --- a/docs/sdd/architecture/decisions/ADR-0008-compose-v2.md +++ b/docs/sdd/architecture/decisions/ADR-0008-compose-v2.md @@ -7,9 +7,9 @@ 仅支持 **Docker Compose v2**(`docker compose` 子命令)部署;弃用 v1。`deploy.sh` 中 `get_compose_cmd` 优先探测 v2,回退 v1 仅作兼容提示。 ## 后果 -- 升级路径:安装 `docker-compose-plugin`(v2)并卸载 v1 后即可用 `./deploy.sh upgrade` 或 `docker compose up -d --build`。 +- 升级路径:安装 `docker-compose-plugin`(v2)并卸载 v1 后即可用 `./scripts/deploy.sh upgrade` 或 `docker compose up -d --build`。 - `docker-compose.yml` 顶部 `version:` 仅产生弃用警告,不影响功能。 ## 关联 -NFR-6, OI-2;部署细节见 `DEPLOY.md`、`README_DOCKER.md`。 +NFR-6, OI-2;部署细节见 [`docs/deploy/README.md`](../../../deploy/README.md)。 diff --git a/docs/sdd/product/roadmap.md b/docs/sdd/product/roadmap.md index 19853ed..3248b3c 100644 --- a/docs/sdd/product/roadmap.md +++ b/docs/sdd/product/roadmap.md @@ -1,6 +1,6 @@ # 阶段性路线图(Roadmap) -> 基于仓库现状(IMPLEMENTATION_PLAN.md 三阶段已基本完成)与 SDD 梳理结果,重新组织为阶段化路线图。状态为 SDD 核对后的推断。 +> 基于仓库现状(原 IMPLEMENTATION_PLAN.md 三阶段已基本完成,现已归档到 [archive/IMPLEMENTATION_PLAN.md](../../archive/IMPLEMENTATION_PLAN.md))与 SDD 梳理结果,重新组织为阶段化路线图。状态为 SDD 核对后的推断。 ## 阶段一:MVP 文档平台(已完成) - 认证与会话(DV-0001) diff --git a/docs/sdd/releases/README.md b/docs/sdd/releases/README.md index 2130d36..20ff3a8 100644 --- a/docs/sdd/releases/README.md +++ b/docs/sdd/releases/README.md @@ -6,20 +6,22 @@ - 版本号语义化(MAJOR.MINOR.PATCH)。 - 发布前必须:对应 DV verification.md 证据通过;ADR 无未决的重大分歧。 - 发布文件中须给出「验证边界」:哪些 DV 被覆盖、如何复验、已知限制。 -- 部署相关变更同步到 `DEPLOY.md` / `CHANGELOG_DEPLOY.md`。 -- **版本以 git 为准**:发布必须对应一个 git 版本标记(提交信息中的 vX.Y.Z 或 tag);代码内版本字段应与 git 标记一致。 +- 部署/配置相关变更同步到 [`docs/deploy/README.md`](../../deploy/README.md) 与 [配置变更日志](../../deploy/changelog.md)。 +- **版本以 git 为准**:发布必须对应一个 git 版本标记(优先使用 tag;历史提交以提交信息中的 vX.Y.Z 为准);代码内版本字段应与 git 标记一致。 ## 版本列表(对齐 git) | 版本 | 文件 | 对应 git 标记 | 主要规格覆盖 | 状态 | | --- | --- | --- | --- | --- | -| v0.9.9 | [v0.9.9.md](v0.9.9.md) | `416ef48 v0.9.9`(当前基线,HEAD `4ef3c92`) | 阶段一~三全部 DV | 当前基线 | +| **v1.0.0** | [v1.0.0.md](v1.0.0.md) | 基线 `ba80d28`,**待打 tag `v1.0.0`** | DV-0001 ~ DV-0011 全量(首个正式版本) | 待发布 | +| v0.9.9 | [v0.9.9.md](v0.9.9.md) | `416ef48 v0.9.9` / `f92fff6 基线版本` / `eea3b96 v0.9.9-SP1` | 阶段一~三全部 DV | 历史发布记录 | | v0.9.6 | [v0.9.6.md](v0.9.6.md) | `c005964 v0.9.6` | MCP 内集成/凭证管理/Python3.12/storage 持久化(历史) | 历史发布记录 | -> git 发布线:v0.9.1 → v0.9.2 → v0.9.6 → v0.9.7 → v0.9.8 → **v0.9.9**。仓库**未打 git tag**;上表以提交信息中的版本标记为准。 +> git 发布线:v0.9.1 → v0.9.2 → v0.9.6 → v0.9.7 → v0.9.8 → v0.9.9(含 `-SP1`)→ **v1.0.0(待打 tag)**。 +> 仓库历史**未使用 git tag**,上表在 v1.0.0 之前以提交信息中的版本标记为准;自 v1.0.0 起发布**必须打 tag**。 > -> v0.9.6 为**历史发布记录**(整合自原 `docs/UPGRADE_v0.9.6.md`);v0.9.9 为**当前基线**。 +> v1.0.0 是首个正式大版本:代码内版本字段(`APP_VERSION`、`package.json version`)已统一为 1.0.0,此前的「遗留占位」说明随本发布失效。 > -> ⚠️ 代码内版本字段(`APP_VERSION`、`package.json version`)与部分旧文档标注为 1.0.0,与 git 标记不符,属遗留占位,见 v0.9.9.md「版本对齐说明」。 +> v0.9.6 / v0.9.9 为**历史发布记录**(v0.9.6 整合自原 `docs/UPGRADE_v0.9.6.md`);其中的「版本对齐说明」「已知差异」保留原文,仅用于追溯。 -## 历史部署变更参考(非发布、仅供追踪) -- `CHANGELOG_DEPLOY.md`:v1.0.1 前端端口 80→8080、storage 目录映射(STORAGE_PATH)等(该记录与 git 发布线冲突,已标注意外,见 archive/版本对齐)。 +## 部署/配置类变更 +不占用发布号的运维与配置变更,记录在 [docs/deploy/changelog.md](../../deploy/changelog.md)(原根目录 `CHANGELOG_DEPLOY.md`,已迁入 `docs/`)。 diff --git a/docs/sdd/releases/v0.9.9.md b/docs/sdd/releases/v0.9.9.md index eda097b..7dd872c 100644 --- a/docs/sdd/releases/v0.9.9.md +++ b/docs/sdd/releases/v0.9.9.md @@ -1,6 +1,8 @@ -# Release v0.9.9(当前基线) +# Release v0.9.9(历史记录) -> 基于 `git log` 版本标记对应的最新发布提交(`416ef48 v0.9.9`)与当前 HEAD(`4ef3c92 优化了显示`,位于 v0.9.9 之后)。仓库未打 git tag,此记录以提交信息中的版本标记为准。 +> **本文为历史发布记录**:v0.9.9 线已由 [v1.0.0](v1.0.0.md) 接替,文中「当前基线」「HEAD」等措辞为撰写当时(`4ef3c92`)的状态,保留原文以供追溯。 +> +> 基于 `git log` 版本标记对应的最新发布提交(`416ef48 v0.9.9`)与当时的 HEAD(`4ef3c92 优化了显示`)。仓库未打 git tag,此记录以提交信息中的版本标记为准;v0.9.9 线后续还有 `f92fff6 v0.9.9 基线版本` 与 `d6ebd62`/`eea3b96 v0.9.9-SP1`。 ## 元数据 - **版本**:v0.9.9(当前基线) @@ -19,7 +21,7 @@ ## 验证边界 - 自动化测试:`backend/tests/test_chat_citations.py`、`test_model_and_vector_configuration.py`、`test_search_service.py`。 - 运行验证:`/health` 返回 healthy;Swagger `/docs` 可用。 -- 部署验证:Compose v2 `docker compose up -d --build`;`./deploy.sh upgrade`。 +- 部署验证:Compose v2 `docker compose up -d --build`;`./scripts/deploy.sh upgrade`(脚本自 v1.0.0 起位于 `scripts/`)。 - 已知限制:单机部署;无实时协同编辑;测试覆盖待增强(OI-3)。 ## 已知问题 @@ -27,9 +29,9 @@ | --- | --- | --- | | OI-1 | 敏感日志脱敏 | 待整改 | | OI-2 | Compose v2 官方化 | 待整改 | -| OI-4 | 版本字段 1.0.0 与 git 标记不符,需统一 | 待整改(见本文档「版本对齐」说明) | +| OI-4 | 版本字段 1.0.0 与 git 标记不符,需统一 | **已关闭**(v1.0.0 立项并显式建 tag,见 [v1.0.0.md](v1.0.0.md)) | ## 版本对齐说明 -- git 发布线:v0.9.1 → v0.9.2 → v0.9.6 → v0.9.7 → v0.9.8 → v0.9.9(`416ef48`)。 -- **无 v1.0.0** 的 git 版本;代码 `APP_VERSION=1.0.0`、`package.json version=1.0.0` 与文档中的 v1.0.0/v1.0.1 均为遗留占位,与 git 不符。 -- 建议:下次打 tag 时以 v0.9.9 为基线;若计划升级 v1.0.0 则应显式建 tag 并同步代码字段。 +- git 发布线:v0.9.1 → v0.9.2 → v0.9.6 → v0.9.7 → v0.9.8 → v0.9.9(`416ef48`,含 `-SP1`)。 +- 撰写本文时**尚无 v1.0.0** 的 git 版本,代码 `APP_VERSION=1.0.0`、`package.json version=1.0.0` 与文档中的 v1.0.0/v1.0.1 均为遗留占位。 +- **后续处置(已完成)**:v1.0.0 已作为首个正式版本立项,代码字段保留 1.0.0 并通过显式建 tag 对齐,见 [v1.0.0.md](v1.0.0.md)。OI-4 在本发布关闭。 diff --git a/docs/sdd/releases/v1.0.0.md b/docs/sdd/releases/v1.0.0.md new file mode 100644 index 0000000..6ac3b43 --- /dev/null +++ b/docs/sdd/releases/v1.0.0.md @@ -0,0 +1,129 @@ +# Release v1.0.0(首个正式版本) + +> 本文件是 **v1.0.0 的发布记录与验证边界**。发布内容 = git 基线 `ba80d28 fix project role permission`(v0.9.9-SP1 之后)+ 当前工作区未提交的发布前整改。 +> **上线动作**:本发布**尚未打 git tag**,需在整改提交合并后执行 `git tag -a v1.0.0 -m "NEX Docus v1.0.0"` 并推送,使「版本以 git 为准」的规则重新成立。 + +## 元数据 +| 项 | 值 | +| --- | --- | +| 版本 | v1.0.0(首个正式版本 / 第一个大版本) | +| git 基线提交 | `ba80d28 fix project role permission`(main) | +| 上一条版本标记 | `eea3b96 v0.9.9-SP1`(非连续,其间的 `5d41339`/`fef17e5`/`b3f5fb0`/`dfd4618` 无版本标记) | +| git tag | **待创建** `v1.0.0` | +| 代码内版本字段 | `backend/app/core/config.py` `APP_VERSION = "1.0.0"`;`frontend/package.json` `version = "1.0.0"` — **已对齐** | +| 适用组件 | backend(FastAPI + SQLAlchemy + MySQL + Redis)、frontend(React 18 + Vite + antd + ByteMD)、docker-compose 部署编排、`scripts/` 运维脚本 | +| 数据库 | 无 schema 变更(本发布不含迁移;表结构与 `docs/database.md` 一致,共 18 张表) | +| 破坏性变更 | 脚本路径 `./deploy.sh` → `./scripts/deploy.sh`;`.env` 移除 `VITE_API_BASE_URL`(详见[部署与配置变更日志](../../deploy/changelog.md#v100未发布)) | + +> 历史遗留说明:v0.9.9.md 与旧 `releases/README.md` 曾把代码里的 1.0.0 判定为「遗留占位」。本发布通过**显式建 tag** 把版本线正式推进到 v1.0.0,占位问题到此结束。 + +## 本发布范围 + +### 1. 文档编辑体验(DV-0003) +- 编辑器**默认只打开编辑区**(`edit` / `split` / `preview` 三态可切换,选择写入 `localStorage` 并按用户记忆)。旧行为是编辑区与预览区同时打开。 +- **视图模式切换并入编辑器工具栏**:三态分段控件由内容区标题栏移到 ByteMD 编辑器工具栏**右侧**,与内置「全屏」按钮同排(`createPortal` 注入 `.bytemd-toolbar-right` 末个子节点),只保留图标 + Tooltip(「编辑 · 仅编辑区」等);与重复的内置开关(文档索引 / 帮助 / 仅编辑 / 仅预览)统一隐藏,标题栏只留「未保存」标记与保存/重置。 + - ByteMD 由 Svelte 渲染且会在 StrictMode 双挂载、切换文档时重建 DOM,插槽采用 `MutationObserver` + rAF **持续校验**(缓存节点 `isConnected` 失效即重找),避免 Portal 渲染到游离节点。 + - 仅**分栏**模式显示「同步滚动」开关;纯编辑 / 纯预览下自动隐藏。 +- **修复编辑器全屏层级**:ByteMD 的 `.bytemd-fullscreen` 是 `position: fixed` 但没有 `z-index`,会被 `.mode-switch`(`z-index: 1`)等浮层压住,导致左侧项目树的操作按钮浮现在全屏编辑器上。现在显式抬到 `var(--z-float)`(900),文档索引浮标再抬一层仍可点;并补 **`Esc` 退出全屏**(自动补全等浮层打开时不抢占)。 +- **Table of Contents 不再依赖预览区**:新增 `frontend/src/utils/markdownToc.js`,大纲直接从 Markdown **源码**解析(标题层级 + 滚动定位),因此在纯编辑视图下 TOC 依然可用(`FloatingToc`)。 +- **未保存保护**:以 `contentBaseline` 计算 `isDirty` → 标题栏显示「未保存」标记;离开页面(`beforeunload`)与返回项目列表时拦截确认;新增全局 **Ctrl/Cmd + S** 保存并带 loading 并发保护。 + +### 2. UI / 交互 / 设计规范统一(跨 DV) +- 新增设计令牌层 `frontend/src/styles/design-tokens.css` 与 antd 主题映射 `frontend/src/theme/antdTheme.js`;22 个文件中的硬编码颜色/圆角/间距替换为令牌。 +- 新增统一反馈组件 `frontend/src/components/Feedback`(Toast / `Toast.confirm`),16 处 `Modal.confirm` 迁移;新增路由级 `PageLoading`,全部路由改为 `React.lazy` 代码分割。 +- 新增 `frontend/.eslintrc.cjs` 固化前端规范;删除 33 个从未被引用的死代码组件/模块,删除无效的「语言切换」假开关。 + +### 3. 后端正确性修复 +- `app/models/__init__.py` 补齐 `Notification`、`ProjectGitRepo` 注册 —— 此前单独导入这两个模型会缺表,影响 metadata 完整性与新库初始化。 +- `GET /api/v1/projects/{id}` 由 500(`MissingGreenlet`)修复为正常返回:改用 `require_project_read_access(..., allow_public=True)` + `await db.refresh(...)` + `serialize_project(...)`,响应补充 `doc_count`、`user_role`。 +- 项目角色判定统一走 `normalize_project_role`(历史数据角色名为大写),修复分享/仪表盘/项目接口对同一用户判定不一致的问题;`projects.py` 中重复的 `check_project_access` 死代码删除。 +- 新增回归用例覆盖角色归一化与项目读权限。 + +### 4. 仓库与运维整理 +- **脚本统一迁入 `scripts/`**:`deploy.sh`(路径变更)+ **新增** `start.sh` 一键启动(依赖检查 / 后端 venv / 前端 vite / `/health` 探活)+ 新增 `stop.sh`;`scripts/README.md` 说明用途。 +- 删除 19 个与新 schema 脱节的一次性 SQL/Python 脚本、删除针对旧 compose 结构的 `fix_docker_deployment.sh`。 +- `docker-compose.yml` 去除废弃 `version` 字段与无效的 `VITE_API_BASE_URL` 构建参数;`.gitignore` 补 `backups/`、`.run/`;`deploy.sh uninstall` 行为修正。 +- 新增 `backend/requirements-dev.txt` 与 `backend/pytest.ini`,使后端测试可一条命令运行。 +- 删除根目录里与本项目无关的历史残留 `.dsh-plugins/pet-dock/`(第三方桌面 shell 插件,仓库内无任何引用);`.gitignore` 补 `.claude/`。 + +### 4b. 配置与安全加固 +- `docker-compose.yml` 补 `DEFAULT_USER_PASSWORD` 透传,`.env.example` 补齐该键 —— 此前 Docker 部署下改 `.env` **完全无效**,新建用户恒为 `User@123456`。 +- 新增启动期「安全自检」`Settings.security_warnings()`:`SECRET_KEY` 命中模板占位值或长度 < 16、`DEFAULT_USER_PASSWORD` 仍为仓库默认值时打 `WARNING`(**只告警不阻断**,兼容存量部署)。此前漏配 `.env` 会带着**公开已知的 JWT 密钥**静默上线。 +- `.env.example` 的 `ADMIN_EMAIL` 由真实域名邮箱改为 `admin@example.com`,与 compose / `init_db.py` 默认值对齐。 +- `scripts/start.sh` 新增 `--daemon`:健康检查通过后立即返回(默认仍为前台驻留 + Ctrl+C 停止),使一键启动可被 CI / 上层脚本调用。 + +### 5. 文档体系重组(`docs/`) +- 根目录仅保留 `README.md` + `docker-compose.yml` + `.env.example` + `.gitignore`/`.dockerignore`,文档全部归入 `docs/`。 +- 重写:根 `README.md`、`docs/README.md`(总入口)、`docs/quickstart.md`、`docs/database.md`、`docs/deploy/README.md`、`docs/deploy/changelog.md`、`backend/README.md`、`docs/manual/user-guide.md`(13 章用户手册,替代原中文命名手册)。 +- 归档:`PROJECT.md`、`IMPLEMENTATION_PLAN.md` → `docs/archive/`(含登记表 `docs/archive/README.md`);删除内容重复且过期的 `DEPLOYEE.md`、`README_DOCKER.md`。 +- 清理文档中的明文生产凭据;修复失效链接与**与代码不符的描述**:健康检查统一为 `GET /health`(不存在 `/api/v1/health`)、OpenAPI 实际路径是 `/openapi.json` 而非 `/api/v1/openapi.json`、`APP_NAME`/`ADMIN_EMAIL` 默认值回填、`app/utils` 目录并不存在等。 + +## 能力覆盖(DV 矩阵) +| DV | 单元 | v1.0.0 影响 | +| --- | --- | --- | +| DV-0001 | 认证与会话 | 无功能变更;`/health` 探活口径在文档中统一 | +| DV-0002 | 项目与文件系统 | `get_project` 500 修复、角色归一化、响应补 `doc_count`/`user_role` | +| DV-0003 | 文档编辑与文件操作 | **重点变更**:默认编辑区 + 工具栏内三态切换 + 源码 TOC + 未保存保护 + Ctrl/Cmd+S + 全屏层级修复 | +| DV-0004 | RBAC 与系统管理 | 角色大小写归一化,读权限判定统一 | +| DV-0005 | 全文检索 | 无功能变更;UI 令牌化 | +| DV-0006 | ZVec 向量化 | 无功能变更;`backend/models/`(本地向量模型)明确 gitignore | +| DV-0007 | 知识库 RAG 对话 | UI 令牌化、Toast 反馈、SSE 约定写入 `backend/README.md` | +| DV-0008 | LLM 模型配置 | UI 令牌化;`api_key` 明文存储列为已知问题 | +| DV-0009 | 分享与公开预览 | 角色归一化影响分享权限判定 | +| DV-0010 | 通知/日志/Git/导出 | 模型注册修复;UI 令牌化 | +| DV-0011 | MCP 接入 | 无功能变更;接入说明见 `docs/sdd/integrations/mcp.md` | + +## 验证边界(本发布实际做过什么) + +### 已验证(可复验) +| 项 | 命令 / 方法 | 结果 | +| --- | --- | --- | +| 后端单测 | `cd backend && ./venv/bin/python -m pytest` | **40 passed**,10 warnings(均为 Pydantic `class Config` 弃用告警) | +| 前端静态检查 | `cd frontend && npm run lint` | **0 error / 25 warning**(全部为 `react-hooks/exhaustive-deps`) | +| 前端构建 | `cd frontend && npm run build` | 通过;产物见下方「已知问题」的体积表 | +| 编辑器三态 + TOC | 浏览器实测(headless Chrome + CDP,1680×980,临时项目,用后已清理) | 默认 `编辑` 且 `.bytemd-preview{display:none}`;分栏 641/641;预览模式下左栏 Markdown 工具栏隐藏;TOC 来自源码大纲,编辑模式下点条目光标跳到目标行;切换文件(编辑器重挂载)后分段控件仍在;超大文档(>250000 字符)与 PDF 不出现分段控件 | +| 工具栏三态切换与全屏同排 | 同上 | 工具栏右侧顺序:`编辑/分栏/预览`(88×24)→ 全屏图标(24×24),垂直中心差 0px,工具栏高 33px 无重叠;≥1100px 视口不换行;明/暗主题配色一致 | +| 全屏层级与 `Esc` 退出 | 同上 | `.bytemd-fullscreen` `z-index:900` + `fixed` 覆盖 1680×980;`.mode-switch`(`z-index:1`)被完全压住(`elementFromPoint` 落在编辑器内);`Esc` 两次均退出并恢复布局 | +| 未保存保护 | 同上 | 输入 → 「未保存」标记出现;`Cmd+S` → 保存成功且标记消失;不脏时返回不弹框;脏时弹框且「继续编辑」保持现场 | +| 项目详情接口 | `GET /api/v1/projects/{id}` | 有权 200(含 `doc_count`/`user_role`)、不存在 404、他人私有 403,与 `/files/{id}/tree` 口径一致 | +| 模型注册 | 遍历 `Base.metadata.tables` 与线上库双向 diff | 18 张表,差异为空 | +| 本地一键启动 | `./scripts/start.sh --daemon` → `curl /health` → `./scripts/stop.sh` | 脚本返回 0;后端 `GET /health` healthy、前端 `:5173` 200、`.run/*.pid` 保留 | +| 安全自检 | `tests/test_security_selfcheck.py` | 占位/短 `SECRET_KEY`、默认 `DEFAULT_USER_PASSWORD` 均被告警;加固后的配置零告警 | +| 文档链接完整性 | 全仓 Markdown 相对链接检查(排除 `storage/`、`venv`、`models/`) | 0 断链(归档内 `_assets/*.png` 为预期缺图) | +| 凭据泄露扫描 | 全仓明文口令扫描 | 仅剩 `.env.example` / compose / `deploy.sh` 的模板默认值(`.env.example` 内的真实域名邮箱已改占位) | +| 文档命令/路径可执行性 | 逐条比对脚本与端点:`GET /`、`/health`、`/docs`、`/openapi.json`、`deploy.sh --help` 的子命令表、`start.sh --help` | 一致(`GET /` 返回 `version=1.0.0`;`/api/v1/health` 与 `/api/v1/openapi.json` 均不存在,文档已修正) | + +### 未验证(发布风险,需在上线前补做) +- **Docker 端到端**:本机 Docker daemon 不可用,`docker compose up -d --build` + `./scripts/deploy.sh upgrade` 本发布**未实测**,只做静态审查(Dockerfile / nginx.conf / compose 已逐行核对,结论见 OI-5/OI-13/OI-14)。上线前必须演练一次:storage bind mount、SSE 流式对话、`/mcp` 反代、`init_db.py` 在空库上建表。 +- **MCP 真实客户端接入**:仅有接口级验证,未用真实 MCP 客户端跑通 DV-0011 全链路。 +- **Git 仓库同步真实远端**:`project_git_repos` 的 push/pull 未在真实远端验证(依赖外部环境凭据)。 +- **多浏览器 / 移动端适配**:仅在 Chromium 内核验证;Safari/Firefox 与窄屏未测。 +- **升级回归**:本发布无 DB 迁移,但未在「旧库 + 新代码」上做过完整冒烟(共享开发库含真实数据,禁止写测)。 + +## 已知问题(开放项) +| 编号 | 优先级 | 内容 | 建议处置 | +| --- | --- | --- | --- | +| OI-P0 | **P0(阻塞上线)** | 远端 Redis(`192.168.124.203`)`rdb_last_bgsave_status=err`,最近一次成功 RDB 已距今数小时,当前靠 `stop-writes-on-bgsave-error=no` 临时放开写入。该参数**非持久配置**,Redis 重启后恢复默认 → 全量写入返回 MISCONF | 上线前由运维排查磁盘/`fork` 内存并恢复 RDB;上线前把该参数写入正式配置或彻底修复 bgsave | +| OI-1 | P1 | 敏感信息明文入库:`share_links.access_pass`、`project_git_repos.token`、`llm_model_config.api_key`(详情接口回传原文) | 下一版本引入对称加密/KMS + 掩码回显;先限制详情接口权限 | +| OI-3 | P1 | 前端 **0 自动化测试**;后端仅 40 个用例,权限/文件系统覆盖薄 | 建 vitest + testing-library 基线,优先覆盖 DV-0002/0003/0004 | +| OI-5 | P1 | `frontend/Dockerfile` 中 `rm -rf package-lock.json && npm install` 导致前端构建不可重现。**根因已定位**:`package-lock.json` 在 macOS/arm64 上生成,`packages` 里只有 `@rollup/rollup-darwin-arm64`,**缺少 `@rollup/rollup-linux-x64-musl`**,直接 `npm ci` 会在 Alpine 镜像内因 rollup 缺原生二进制而失败(脚本注释提到的正是这个 bug) | 用 `npm install --cpu=x64 --os=linux --include=optional`(或 `npm install --package-lock-only --force`)重生成 lockfile 并验证含 linux-musl 条目,再切 `npm ci`;或把构建阶段换成 glibc 基础镜像。**未做**:本发布无法验证 Docker 构建,故保持现状不改 | +| OI-6 | P2 | 前端产物偏大:`antd` 1302 kB(gzip 408)、`index` 1242 kB(gzip 387)、`index` 919 kB(gzip 301)、`index` 522 kB(gzip 161) | 按需引入 antd 图标/组件、拆分 ByteMD 相关 chunk、提高 `manualChunks` 粒度 | +| OI-7 | P2 | 超大组件:`DocumentEditor` 1724 行、`DocumentPage` 1620 行、`Chat` 1638 行、`ProjectList` 1501 行 | 按 DV 切片拆分子组件与自定义 hook | +| OI-8 | P2 | eslint 25 warning(全部为 `react-hooks/exhaustive-deps`)。原 `ProjectList.jsx` 成员弹窗里 4 处输出成员/用户列表的 `console.log` 已删除,`notifications.py` 4 处 `print()` 降级日志已改 `logger`(`console.log`/`print()` 全仓归零) | 逐个消解 exhaustive-deps,CI 开启 `--max-warnings 0` | +| OI-9 | P2 | 重复实现:`shares.py` 内本地 `get_project_or_404`;`generate_share_code` 在 `projects.py` 与 `shares.py` 各一份;`api/knowledgeBase.js` 命名遗留 | 收敛到 `project_service` 与统一命名 | +| OI-10 | P2 | 配置键名两套体系:`backend/.env`(`STORAGE_ROOT/PROJECTS_PATH/USERS_PATH/TEMP_PATH`)与根 `.env`(`STORAGE_PATH`) | 下一版本统一并保留一个发布周期的兼容读取 | +| OI-11 | P2 | 超级管理员访问他人私有项目返回 403(全站口径已一致),但仪表盘提供全局统计 | 需产品明确超管边界(数据面 vs 管理面),再决定是否放开 | +| OI-12 | P3 | Pydantic `class Config` 弃用告警 10 处;磁盘遗留 `storage/projects_bak`(61M)、`backup/nex_docus_20260311.sql`(均已 gitignore) | 迁 `model_config`;离线归档后删除磁盘垃圾 | +| OI-13 | P1 | `docker-compose.yml` 默认把 **MySQL `3306` 与 Redis `6379` 发布到宿主机所有网卡**(`${MYSQL_PORT:-3306}:3306`)。配合默认口令/占位 `SECRET_KEY`,等同于把数据库直接暴露到局域网 | 默认改绑回环:`"127.0.0.1:${MYSQL_PORT:-3306}:3306"`;确需远程访问时用 `.env` 显式声明监听地址,并在主机防火墙拦截 | +| OI-14 | P2 | MySQL 数据目录挂在 `${STORAGE_PATH}/mysql`,而 `${STORAGE_PATH}` 又整体 bind mount 进后端容器当作文档存储根(`STORAGE_ROOT=/data/nex_docus_store`)→ 后端容器内可见数据库文件,且 `deploy.sh backup` 打包 storage 时会连正在写入的 datadir 一起归档 | 数据库与文件存储分目录(如 `${DATA_PATH}/mysql`),备份脚本显式排除 datadir | +| OI-15 | P2 | compose 里 `SECRET_KEY`/`ADMIN_PASSWORD`/`DB_PASSWORD` 等都有**公开已知的兜底默认值**,漏配 `.env` 时静默使用。本发布已加启动告警,但仍属"默认可用即不安全" | 下一版本移除敏感项兜底值,缺配即启动失败(fail-fast) | + +## 上线清单(发布执行顺序) +1. 按提交划分合并改动(编辑器 / UI 规范 / 后端修复 / 脚本 / 文档 / 配置 / 测试)。 +2. `git tag -a v1.0.0 -m "NEX Docus v1.0.0" && git push origin v1.0.0`。 +3. **先解决 OI-P0(Redis RDB)**,再执行部署。 +4. Docker 演练:`./scripts/deploy.sh upgrade` → 登录 → 打开文档编辑 → AI 问答 → 分享预览。 +5. **立即修改默认管理员密码**(两套初始口令见[部署变更日志](../../deploy/changelog.md#5-两套默认密码并存重要易踩坑)),并在 `.env` 显式设置: + `ADMIN_PASSWORD=<随机值>`、`DEFAULT_USER_PASSWORD=<随机值>`、`SECRET_KEY=$(openssl rand -hex 32)`。 +6. 确认 `GET /` 返回 `version=1.0.0`,并且 `docker compose logs backend | grep 安全自检` **无输出**(有输出说明第 5 步没做完)。 +7. 按 OI-13 把 MySQL/Redis 端口绑到 `127.0.0.1`(或确认主机防火墙已拦截)。 diff --git a/docs/sdd/specs/DV-0001-auth/design.md b/docs/sdd/specs/DV-0001-auth/design.md index 975b4dd..f88d0d7 100644 --- a/docs/sdd/specs/DV-0001-auth/design.md +++ b/docs/sdd/specs/DV-0001-auth/design.md @@ -4,7 +4,7 @@ 涉及 `auth.py`(API)、`security.py`(JWT/bcrypt)、`deps.py`(依赖注入)、`redis_client.py`(TokenCache)。 ## 数据模型 -- `users`(id/username/password_hash/…)——细节见 DATABASE.md。 +- `users`(id/username/password_hash/…)——细节见 [`docs/database.md`](../../../database.md)。 ## 接口设计 - POST /api/v1/auth/{register,login,logout} diff --git a/docs/sdd/specs/DV-0002-project-filesystem/design.md b/docs/sdd/specs/DV-0002-project-filesystem/design.md index 7157a9b..f978542 100644 --- a/docs/sdd/specs/DV-0002-project-filesystem/design.md +++ b/docs/sdd/specs/DV-0002-project-filesystem/design.md @@ -7,7 +7,7 @@ - `projects`(storage_key/owner_id/status/access_pass…) - `project_members`(role: admin/editor/viewer) - `document_meta`、`document_vector` -- 细节见 DATABASE.md。 +- 细节见 [`docs/database.md`](../../../database.md)。 ## 存储结构 `STORAGE_ROOT/projects//` + `_assets/images|files` + 默认 README.md。 diff --git a/docs/sdd/specs/DV-0004-rbac-admin/design.md b/docs/sdd/specs/DV-0004-rbac-admin/design.md index a53c82d..537a8b5 100644 --- a/docs/sdd/specs/DV-0004-rbac-admin/design.md +++ b/docs/sdd/specs/DV-0004-rbac-admin/design.md @@ -5,7 +5,7 @@ ## 数据模型 - `roles`、`user_roles`、`system_menus`、`role_menus`。 -- 细节见 DATABASE.md。 +- 细节见 [`docs/database.md`](../../../database.md)。 ## 接口设计 - /api/v1/roles/*、/api/v1/role-permissions/* diff --git a/docs/sdd/specs/_template/design.md b/docs/sdd/specs/_template/design.md index dabd53f..ece2f01 100644 --- a/docs/sdd/specs/_template/design.md +++ b/docs/sdd/specs/_template/design.md @@ -7,7 +7,7 @@ - 代码/模块组织。 ## 数据模型 -- 涉及表/字段(指向 DATABASE.md 细节)。 +- 涉及表/字段(指向 [`docs/database.md`](../../../database.md) 细节)。 ## 接口设计 - API 端点 / 交互。 diff --git a/fix_docker_deployment.sh b/fix_docker_deployment.sh deleted file mode 100644 index b2744ca..0000000 --- a/fix_docker_deployment.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/bash - -# Fix for "KeyError: 'ContainerConfig'" in legacy docker-compose - -echo "Cleaning up Docker resources to fix deployment error..." - -# 1. Stop containers and remove orphans -echo "Step 1: Stopping containers..." -if command -v docker-compose &> /dev/null; then - docker-compose down --remove-orphans -elif docker compose version &> /dev/null; then - docker compose down --remove-orphans -fi - -# 2. Remove the frontend image explicitly to force metadata refresh -echo "Step 2: Removing frontend image..." -docker rmi nex-docus-frontend 2>/dev/null || true -# Also remove the tagged image if it exists differently (based on docker-compose.yml naming) -docker images | grep nexdocus-frontend | awk '{print $3}' | xargs -r docker rmi - -# 3. Prune dangling images which might cause confusion -echo "Step 3: Pruning dangling images..." -docker image prune -f - -echo "Cleanup complete." -echo "You can now try deploying again with:" -echo " docker-compose up -d --build" -echo " OR" -echo " ./deploy.sh upgrade" diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 0000000..3f595cf --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -0,0 +1,29 @@ +module.exports = { + root: true, + env: { browser: true, es2021: true }, + extends: [ + 'eslint:recommended', + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + 'plugin:react-hooks/recommended', + ], + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { jsx: true }, + }, + settings: { react: { version: 'detect' } }, + ignorePatterns: ['dist', 'node_modules', 'public'], + overrides: [ + { + files: ['vite.config.js'], + env: { node: true, es2022: true }, + }, + ], + rules: { + 'react/prop-types': 'off', + 'react/no-unescaped-entities': 'off', + 'no-unused-vars': ['warn', { args: 'none', caughtErrors: 'none' }], + 'no-console': ['warn', { allow: ['warn', 'error'] }], + }, +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c42b635..4599fa3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "nex-docus-frontend", - "version": "0.9.9", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nex-docus-frontend", - "version": "0.9.9", + "version": "1.0.0", "dependencies": { "@ant-design/icons": "^5.2.6", "@bytemd/plugin-breaks": "^1.22.0", @@ -16,39 +16,40 @@ "@bytemd/plugin-highlight": "^1.22.0", "@bytemd/react": "^1.22.0", "antd": "^5.12.0", + "antd-img-crop": "^4.27.0", "axios": "^1.6.2", "bytemd": "^1.22.0", "dayjs": "^1.11.10", + "github-slugger": "^2.0.0", + "mark.js": "^8.11.1", + "pdfjs-dist": "5.4.296", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-highlight-words": "^0.21.0", + "react-markdown": "^9.0.1", + "react-pdf": "^10.2.0", "react-router-dom": "^6.20.1", + "react-virtuoso": "^4.18.1", + "react-window": "^2.2.3", + "rehype-highlight": "^7.0.2", + "rehype-raw": "^7.0.0", + "rehype-slug": "^6.0.0", + "remark-gfm": "^4.0.1", "zustand": "^4.4.7" }, "devDependencies": { "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@vitejs/plugin-react": "^4.2.1", - "autoprefixer": "^10.4.16", "eslint": "^8.55.0", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", - "postcss": "^8.4.32", - "tailwindcss": "^3.3.6", + "terser": "^5.46.0", "vite": "^5.0.8" - } - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=18" } }, "node_modules/@ant-design/colors": { @@ -1614,6 +1615,17 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmmirror.com/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1632,6 +1644,256 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.100", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-0.1.100.tgz", + "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.100", + "@napi-rs/canvas-darwin-arm64": "0.1.100", + "@napi-rs/canvas-darwin-x64": "0.1.100", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", + "@napi-rs/canvas-linux-arm64-musl": "0.1.100", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-musl": "0.1.100", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", + "@napi-rs/canvas-win32-x64-msvc": "0.1.100" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz", + "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz", + "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.100", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz", + "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.100", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz", + "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz", + "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz", + "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz", + "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz", + "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz", + "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz", + "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz", + "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1926,6 +2188,24 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/js-yaml": { "version": "4.0.9", "resolved": "https://registry.npmmirror.com/@types/js-yaml/-/js-yaml-4.0.9.tgz", @@ -1947,6 +2227,15 @@ "@types/lodash": "*" } }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", @@ -1963,14 +2252,12 @@ "version": "15.7.15", "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.27", "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1996,11 +2283,16 @@ "@types/estree": "*" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, "license": "ISC" }, "node_modules/@vitejs/plugin-react": { @@ -2155,34 +2447,21 @@ "react-dom": ">=16.9.0" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmmirror.com/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", + "node_modules/antd-img-crop": { + "version": "4.30.0", + "resolved": "https://registry.npmmirror.com/antd-img-crop/-/antd-img-crop-4.30.0.tgz", + "integrity": "sha512-fjpwyCNKtKr22AQcENBix5Y+P8ECM80Ivk/Yn7kF9D0XltYBGJHg+3yycxcGq7ATgnl7pgPYDvN2VzKleogCLQ==", + "license": "MIT", "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" + "react-easy-crop": "^5.5.6", + "tslib": "^2.8.1" }, - "engines": { - "node": ">= 8" + "peerDependencies": { + "antd": ">=4.0.0", + "react": ">=16.8.0", + "react-dom": ">=16.8.0" } }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmmirror.com/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, - "license": "MIT" - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", @@ -2343,43 +2622,6 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, - "node_modules/autoprefixer": { - "version": "10.4.23", - "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.23.tgz", - "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001760", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -2434,19 +2676,6 @@ "baseline-browser-mapping": "dist/cli.js" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2458,19 +2687,6 @@ "concat-map": "0.0.1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.1.tgz", @@ -2505,6 +2721,13 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/bytemd": { "version": "1.22.0", "resolved": "https://registry.npmmirror.com/bytemd/-/bytemd-1.22.0.tgz", @@ -3425,16 +3648,6 @@ "node": ">=6" } }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001761", "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", @@ -3513,29 +3726,14 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, "node_modules/classnames": { @@ -3544,6 +3742,15 @@ "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/codemirror-ssr": { "version": "0.65.0", "resolved": "https://registry.npmmirror.com/codemirror-ssr/-/codemirror-ssr-0.65.0.tgz", @@ -3595,16 +3802,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/compute-scroll-into-view": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", @@ -3649,19 +3846,6 @@ "node": ">= 8" } }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", @@ -3819,12 +4003,18 @@ "node": ">=6" } }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, - "license": "Apache-2.0" + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } }, "node_modules/diff": { "version": "5.2.0", @@ -3835,13 +4025,6 @@ "node": ">=0.3.1" } }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmmirror.com/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, - "license": "MIT" - }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/doctrine/-/doctrine-2.1.0.tgz", @@ -3876,6 +4059,18 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmmirror.com/es-abstract/-/es-abstract-1.24.1.tgz", @@ -4351,6 +4546,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", @@ -4374,23 +4579,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4441,19 +4629,6 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", @@ -4553,20 +4728,6 @@ "node": ">=0.4.x" } }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -4713,6 +4874,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", @@ -4734,19 +4901,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmmirror.com/globals/-/globals-13.24.0.tgz", @@ -4890,6 +5044,82 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmmirror.com/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5/node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5/node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-heading-rank": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz", + "integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-parse-selector": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/hast-util-parse-selector/-/hast-util-parse-selector-3.1.1.tgz", @@ -4918,6 +5148,31 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-sanitize": { "version": "4.1.0", "resolved": "https://registry.npmmirror.com/hast-util-sanitize/-/hast-util-sanitize-4.1.0.tgz", @@ -4946,6 +5201,94 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmmirror.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-string": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", + "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hastscript": { "version": "7.2.0", "resolved": "https://registry.npmmirror.com/hastscript/-/hastscript-7.2.0.tgz", @@ -4988,6 +5331,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/highlight-words-core": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/highlight-words-core/-/highlight-words-core-1.2.3.tgz", + "integrity": "sha512-m1O9HW3/GNHxzSIXWw1wCNXXsgLlxrP0OI6+ycGUhiUHkikqW3OrwVHz+lxeNBe5yqLESdIcj8PowHQ2zLvUvQ==", + "license": "MIT" + }, "node_modules/highlight.js": { "version": "11.11.1", "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz", @@ -4997,6 +5346,26 @@ "node": ">=12.0.0" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", @@ -5052,6 +5421,12 @@ "dev": true, "license": "ISC" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/internal-slot/-/internal-slot-1.1.0.tgz", @@ -5067,6 +5442,30 @@ "node": ">= 0.4" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmmirror.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5121,19 +5520,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/is-boolean-object": { "version": "1.2.2", "resolved": "https://registry.npmmirror.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz", @@ -5238,6 +5624,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5297,6 +5693,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmmirror.com/is-map/-/is-map-2.0.3.tgz", @@ -5323,16 +5729,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-number-object": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/is-number-object/-/is-number-object-1.1.1.tgz", @@ -5549,16 +5945,6 @@ "node": ">= 0.4" } }, - "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "bin/jiti.js" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5682,26 +6068,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", @@ -5753,6 +6119,21 @@ "loose-envify": "cli.js" } }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmmirror.com/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", @@ -5763,6 +6144,30 @@ "yallist": "^3.0.2" } }, + "node_modules/make-cancellable-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz", + "integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1" + } + }, + "node_modules/make-event-props": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/make-event-props/-/make-event-props-2.0.0.tgz", + "integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/make-event-props?sponsor=1" + } + }, + "node_modules/mark.js": { + "version": "8.11.1", + "resolved": "https://registry.npmmirror.com/mark.js/-/mark.js-8.11.1.tgz", + "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", + "license": "MIT" + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmmirror.com/markdown-table/-/markdown-table-3.0.4.tgz", @@ -5854,6 +6259,219 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.1.tgz", + "integrity": "sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-newline-to-break": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/mdast-util-newline-to-break/-/mdast-util-newline-to-break-1.0.0.tgz", @@ -5938,30 +6556,684 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" }, - "engines": { - "node": ">=8.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmmirror.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.3.tgz", + "integrity": "sha512-wgyJtgUkUcdU7zci7uuwc/tzoAhm0TswWhaXuvnWolqug+8jY7bgNkBBL0dYBnHgc/tHL7lCzRdPsdUxUKKBqw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/memoize-one": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-4.0.3.tgz", + "integrity": "sha512-QmpUu4KqDmX0plH4u+tf0riMc1KHE1+lw95cMrLlXQAFOx/xnBtwhZ52XJxd9X2O6kwKBqX32kmhbhlobD0cuw==", + "license": "MIT" + }, + "node_modules/merge-refs": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/merge-refs/-/merge-refs-2.0.0.tgz", + "integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/merge-refs?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/micromark": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/micromark/-/micromark-4.0.3.tgz", + "integrity": "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.4.tgz", + "integrity": "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.1.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.3" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz", + "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-factory-space/-/micromark-factory-space-2.1.0.tgz", + "integrity": "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-edit-map": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/micromark-util-edit-map/-/micromark-util-edit-map-1.0.0.tgz", + "integrity": "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/micromark-util-types/-/micromark-util-types-2.0.3.tgz", + "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", @@ -6011,18 +7283,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", @@ -6056,15 +7316,11 @@ "dev": true, "license": "MIT" }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } + "node_modules/normalize-wheel": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/normalize-wheel/-/normalize-wheel-1.0.1.tgz", + "integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==", + "license": "BSD-3-Clause" }, "node_modules/object-assign": { "version": "4.1.1", @@ -6076,16 +7332,6 @@ "node": ">=0.10.0" } }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", @@ -6275,6 +7521,43 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", @@ -6312,6 +7595,18 @@ "dev": true, "license": "MIT" }, + "node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -6319,39 +7614,6 @@ "dev": true, "license": "ISC" }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmmirror.com/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -6391,140 +7653,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmmirror.com/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/postcss-js/-/postcss-js-4.1.0.tgz", - "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmmirror.com/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmmirror.com/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6547,6 +7675,16 @@ "react-is": "^16.13.1" } }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -7221,6 +8359,33 @@ "react": "^18.3.1" } }, + "node_modules/react-easy-crop": { + "version": "5.5.7", + "resolved": "https://registry.npmmirror.com/react-easy-crop/-/react-easy-crop-5.5.7.tgz", + "integrity": "sha512-kYo4NtMeXFQB7h1U+h5yhUkE46WQbQdq7if54uDlbMdZHdRgNehfvaFrXnFw5NR1PNoUOJIfTwLnWmEx/MaZnA==", + "license": "MIT", + "dependencies": { + "normalize-wheel": "^1.0.1", + "tslib": "^2.0.1" + }, + "peerDependencies": { + "react": ">=16.4.0", + "react-dom": ">=16.4.0" + } + }, + "node_modules/react-highlight-words": { + "version": "0.21.0", + "resolved": "https://registry.npmmirror.com/react-highlight-words/-/react-highlight-words-0.21.0.tgz", + "integrity": "sha512-SdWEeU9fIINArEPO1rO5OxPyuhdEKZQhHzZZP1ie6UeXQf+CjycT1kWaB+9bwGcVbR0NowuHK3RqgqNg6bgBDQ==", + "license": "MIT", + "dependencies": { + "highlight-words-core": "^1.2.0", + "memoize-one": "^4.0.0" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.0 || ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", @@ -7228,6 +8393,62 @@ "dev": true, "license": "MIT" }, + "node_modules/react-markdown": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/react-markdown/-/react-markdown-9.1.0.tgz", + "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-pdf": { + "version": "10.5.0", + "resolved": "https://registry.npmmirror.com/react-pdf/-/react-pdf-10.5.0.tgz", + "integrity": "sha512-zGKb6Ph5AYn2OfO4TS8Gg0Go3eW5K/TKy0+0FTm8EwFNbpgbuaWBZoJdYkJt+cRBCkizE9dYvqtMtHRj522Q4w==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "dequal": "^2.0.3", + "make-cancellable-promise": "^2.0.0", + "make-event-props": "^2.0.0", + "merge-refs": "^2.0.0", + "pdfjs-dist": "5.4.296", + "tiny-invariant": "^1.0.0", + "warning": "^4.0.0" + }, + "funding": { + "url": "https://github.com/wojtekmaj/react-pdf?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", @@ -7270,27 +8491,24 @@ "react-dom": ">=16.8" } }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, + "node_modules/react-virtuoso": { + "version": "4.18.16", + "resolved": "https://registry.npmmirror.com/react-virtuoso/-/react-virtuoso-4.18.16.tgz", + "integrity": "sha512-BumVOhd0mNKlBkqeHojlLP8IRlJ2oYU7haU92GvBSlSzcdlWUAxIu1gphKm5gLQm07gMzmoQRXdVQeiG4TKSeQ==", "license": "MIT", - "dependencies": { - "pify": "^2.3.0" + "peerDependencies": { + "react": ">=16 || >=17 || >= 18 || >= 19", + "react-dom": ">=16 || >=17 || >= 18 || >=19" } }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, + "node_modules/react-window": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/react-window/-/react-window-2.3.3.tgz", + "integrity": "sha512-uu/dTwysXp+YL+i5GW/cC/3JrqyE7oE6DYXYVlTeMaDSDgCI+UGo+XCKwXfS8uoFPih8TJYTnikqjAnbDvn75Q==", "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" } }, "node_modules/reflect.getprototypeof": { @@ -7337,6 +8555,38 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/rehype-highlight": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/rehype-highlight/-/rehype-highlight-7.0.2.tgz", + "integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-text": "^4.0.0", + "lowlight": "^3.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rehype-sanitize": { "version": "5.0.1", "resolved": "https://registry.npmmirror.com/rehype-sanitize/-/rehype-sanitize-5.0.1.tgz", @@ -7429,6 +8679,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-slug": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/rehype-slug/-/rehype-slug-6.0.0.tgz", + "integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "github-slugger": "^2.0.0", + "hast-util-heading-rank": "^3.0.0", + "hast-util-to-string": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-breaks": { "version": "3.0.3", "resolved": "https://registry.npmmirror.com/remark-breaks/-/remark-breaks-3.0.3.tgz", @@ -7962,33 +9229,78 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmmirror.com/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmmirror.com/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmmirror.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", "license": "MIT" }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", @@ -8341,6 +9653,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", @@ -8351,6 +9673,17 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", @@ -8519,35 +9852,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmmirror.com/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmmirror.com/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/stylis": { "version": "4.3.6", "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.3.6.tgz", "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", "license": "MIT" }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", @@ -8574,56 +9902,31 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tailwindcss": { - "version": "3.4.19", - "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.4.19.tgz", - "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "node_modules/terser": { + "version": "5.51.2", + "resolved": "https://registry.npmmirror.com/terser/-/terser-5.51.2.tgz", + "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.7", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" }, "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" + "terser": "bin/terser" }, "engines": { - "node": ">=14.0.0" + "node": ">=10" } }, - "node_modules/tailwindcss/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } + "license": "MIT" }, "node_modules/text-table": { "version": "0.2.0", @@ -8632,29 +9935,6 @@ "dev": true, "license": "MIT" }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmmirror.com/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/throttle-debounce": { "version": "5.0.2", "resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz", @@ -8664,53 +9944,11 @@ "node": ">=12.22" } }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" }, "node_modules/tippy.js": { "version": "6.3.7", @@ -8721,19 +9959,6 @@ "@popperjs/core": "^2.9.0" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/toggle-selection": { "version": "1.0.6", "resolved": "https://registry.npmmirror.com/toggle-selection/-/toggle-selection-1.0.6.tgz", @@ -8760,12 +9985,11 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -8890,6 +10114,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmmirror.com/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-generated": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/unist-util-generated/-/unist-util-generated-2.0.1.tgz", @@ -8900,6 +10157,74 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -8950,13 +10275,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, "node_modules/uvu": { "version": "0.5.6", "resolved": "https://registry.npmmirror.com/uvu/-/uvu-0.5.6.tgz", @@ -8975,6 +10293,48 @@ "node": ">=8" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmmirror.com/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", @@ -9035,6 +10395,15 @@ } } }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/web-namespaces/-/web-namespaces-2.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 51c3d87..4e3911b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,13 +1,14 @@ { "name": "nex-docus-frontend", "private": true, - "version": "0.9.9", + "version": "1.0.0", "type": "module", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview", - "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0" + "lint": "eslint . --ext js,jsx", + "lint:strict": "eslint . --ext js,jsx --max-warnings 0" }, "dependencies": { "@ant-design/icons": "^5.2.6", @@ -42,16 +43,15 @@ "devDependencies": { "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", - "@vitejs/plugin-legacy": "^5.4.3", "@vitejs/plugin-react": "^4.2.1", - "autoprefixer": "^10.4.16", "eslint": "^8.55.0", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", - "postcss": "^8.4.32", - "tailwindcss": "^3.3.6", "terser": "^5.46.0", "vite": "^5.0.8" + }, + "engines": { + "node": ">=18" } } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ddae2b7..3f49c01 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,33 +1,38 @@ -import { useEffect } from 'react' +import { lazy, Suspense, useEffect } from 'react' import { BrowserRouter, Routes, Route, Navigate, useParams, Outlet } from 'react-router-dom' -import { ConfigProvider, theme } from 'antd' +import { App as AntdApp, ConfigProvider, theme } from 'antd' import zhCN from 'antd/locale/zh_CN' import dayjs from 'dayjs' import 'dayjs/locale/zh-cn' import useThemeStore from '@/stores/themeStore' +import FeedbackBridge from '@/components/Feedback/FeedbackBridge' +import PageLoading from '@/components/PageLoading/PageLoading' +import { getAntdTheme } from '@/theme/antdTheme' import Login from '@/pages/Login/Login' -import ProjectList from '@/pages/ProjectList/ProjectList' -import DocumentPage from '@/pages/Document/DocumentPage' -import DocumentEditor from '@/pages/Document/DocumentEditor' -import Dashboard from '@/pages/Dashboard' -import Desktop from '@/pages/Desktop' -import Constructing from '@/pages/Constructing' -import ProjectSharePage from '@/pages/Preview/ProjectSharePage' -import FileSharePage from '@/pages/Preview/FileSharePage' -import ProfilePage from '@/pages/Profile/ProfilePage' -import Permissions from '@/pages/System/Permissions' -import Users from '@/pages/System/Users' -import Roles from '@/pages/System/Roles' -import ModelConfigs from '@/pages/System/ModelConfigs' -import SystemLogs from '@/pages/SystemLogs/SystemLogs' -import Chat from '@/pages/Chat/Chat' -import NotificationList from '@/pages/Notifications/NotificationList' -import ProtectedRoute from '@/components/ProtectedRoute' import MainLayout from '@/components/MainLayout/MainLayout' +import ProtectedRoute from '@/components/ProtectedRoute' import '@/App.css' dayjs.locale('zh-cn') +// 路由级懒加载:首屏只加载登录页与布局,其余页面按需加载 +const ProjectList = lazy(() => import('@/pages/ProjectList/ProjectList')) +const DocumentPage = lazy(() => import('@/pages/Document/DocumentPage')) +const DocumentEditor = lazy(() => import('@/pages/Document/DocumentEditor')) +const Dashboard = lazy(() => import('@/pages/Dashboard')) +const Desktop = lazy(() => import('@/pages/Desktop')) +const Constructing = lazy(() => import('@/pages/Constructing')) +const ProjectSharePage = lazy(() => import('@/pages/Preview/ProjectSharePage')) +const FileSharePage = lazy(() => import('@/pages/Preview/FileSharePage')) +const ProfilePage = lazy(() => import('@/pages/Profile/ProfilePage')) +const Permissions = lazy(() => import('@/pages/System/Permissions')) +const Users = lazy(() => import('@/pages/System/Users')) +const Roles = lazy(() => import('@/pages/System/Roles')) +const ModelConfigs = lazy(() => import('@/pages/System/ModelConfigs')) +const SystemLogs = lazy(() => import('@/pages/SystemLogs/SystemLogs')) +const Chat = lazy(() => import('@/pages/Chat/Chat')) +const NotificationList = lazy(() => import('@/pages/Notifications/NotificationList')) + // 重定向到文档页面的组件 function RedirectToDocs() { const { projectId } = useParams() @@ -47,11 +52,7 @@ function App() { const { isDarkMode } = useThemeStore() useEffect(() => { - if (isDarkMode) { - document.body.classList.add('dark') - } else { - document.body.classList.remove('dark') - } + document.body.classList.toggle('dark', isDarkMode) }, [isDarkMode]) return ( @@ -59,41 +60,52 @@ function App() { locale={zhCN} theme={{ algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm, + ...getAntdTheme(isDarkMode), }} > - - - } /> - } /> - } /> + + + + }> + + } /> + } /> + } /> - {/* 使用共享布局的路由 */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + {/* 使用共享布局的路由 */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + - } /> - - + } /> + + + + + ) } diff --git a/frontend/src/api/chat.js b/frontend/src/api/chat.js index e65c5fe..0e8b219 100644 --- a/frontend/src/api/chat.js +++ b/frontend/src/api/chat.js @@ -83,7 +83,7 @@ export const sendChatMessageStream = async (sessionId, message, handlers = {}) = } } - while (true) { + for (;;) { const { value, done } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) diff --git a/frontend/src/components/ActionHelpPanel/ActionHelpPanel.css b/frontend/src/components/ActionHelpPanel/ActionHelpPanel.css deleted file mode 100644 index 7a6c645..0000000 --- a/frontend/src/components/ActionHelpPanel/ActionHelpPanel.css +++ /dev/null @@ -1,264 +0,0 @@ -/* 帮助面板样式 */ -.action-help-panel .ant-drawer-header { - border-bottom: 2px solid var(--border-color); -} - -.help-panel-title { - display: flex; - align-items: center; - gap: 12px; - font-size: 16px; - font-weight: 600; -} - -.help-panel-header { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; -} - -.help-panel-header-text { - font-weight: 500; - color: rgba(0, 0, 0, 0.88); -} - -/* 操作详情样式 */ -.help-action-detail { - display: flex; - flex-direction: column; - gap: 20px; -} - -.help-action-header { - display: flex; - align-items: flex-start; - gap: 12px; - padding: 16px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - border-radius: 12px; - color: white; -} - -.help-action-icon { - font-size: 28px; - line-height: 1; - opacity: 0.95; -} - -.help-action-info { - flex: 1; - display: flex; - flex-direction: column; - gap: 6px; -} - -.help-action-title { - margin: 0; - font-size: 18px; - font-weight: 600; - color: white; -} - -.help-action-badge { - align-self: flex-start; - margin: 0; - font-size: 11px; - padding: 2px 8px; - border-radius: 10px; -} - -/* 帮助区块样式 */ -.help-section { - padding: 16px; - background: var(--bg-color-secondary); - border-radius: 8px; - border-left: 3px solid #1677ff; -} - -.help-section-warning { - background: #fff7e6; - border-left-color: #faad14; -} - -.help-section-title { - display: flex; - align-items: center; - gap: 6px; - font-size: 14px; - font-weight: 600; - color: var(--text-color); - margin-bottom: 12px; -} - -.help-section-content { - font-size: 13px; - line-height: 1.8; - color: rgba(0, 0, 0, 0.65); -} - -.help-section-list { - margin: 0; - padding-left: 20px; - list-style-type: disc; -} - -.help-section-list li { - font-size: 13px; - line-height: 1.8; - color: rgba(0, 0, 0, 0.65); - margin-bottom: 8px; -} - -.help-section-list li:last-child { - margin-bottom: 0; -} - -.help-section-steps { - margin: 0; - padding-left: 20px; - counter-reset: step-counter; - list-style: none; -} - -.help-section-steps li { - font-size: 13px; - line-height: 1.8; - color: rgba(0, 0, 0, 0.65); - margin-bottom: 12px; - padding-left: 12px; - position: relative; - counter-increment: step-counter; -} - -.help-section-steps li:before { - content: counter(step-counter); - position: absolute; - left: -20px; - top: 0; - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - background: #1677ff; - color: white; - border-radius: 50%; - font-size: 11px; - font-weight: 600; -} - -.help-section-steps li:last-child { - margin-bottom: 0; -} - -.help-shortcut { - display: inline-block; -} - -.help-shortcut kbd { - display: inline-block; - padding: 6px 12px; - background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%); - border: 1px solid var(--border-color-strong); - border-radius: 6px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05); - font-size: 12px; - font-family: 'Monaco', 'Consolas', monospace; - color: rgba(0, 0, 0, 0.88); - font-weight: 500; -} - -/* 操作列表样式 */ -.help-actions-list { - display: flex; - flex-direction: column; - gap: 12px; -} - -.help-action-item { - padding: 12px; - background: white; - border: 1px solid var(--border-color); - border-radius: 8px; - transition: all 0.3s ease; - cursor: pointer; -} - -.help-action-item:hover { - border-color: #1677ff; - box-shadow: 0 2px 8px rgba(22, 119, 255, 0.1); - transform: translateY(-2px); -} - -.help-action-item-header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 6px; -} - -.help-action-item-icon { - font-size: 16px; - color: #1677ff; -} - -.help-action-item-title { - flex: 1; - font-size: 14px; - font-weight: 500; - color: var(--text-color); -} - -.help-action-item-shortcut { - padding: 2px 6px; - background: var(--bg-color-secondary); - border: 1px solid var(--border-color-strong); - border-radius: 4px; - font-size: 11px; - font-family: 'Monaco', 'Consolas', monospace; - color: var(--text-color-secondary); -} - -.help-action-item-desc { - font-size: 12px; - line-height: 1.6; - color: var(--text-color-secondary); - padding-left: 24px; -} - -/* 折叠面板自定义样式 */ -.action-help-panel .ant-collapse-ghost > .ant-collapse-item { - margin-bottom: 16px; -} - -.action-help-panel .ant-collapse-ghost > .ant-collapse-item > .ant-collapse-header { - padding: 12px 16px; - background: var(--bg-color-secondary); - border-radius: 8px; - font-weight: 500; -} - -body.dark .help-section-warning { - background: rgba(250, 173, 20, 0.12); - border-left-color: #faad14; -} - -.action-help-panel .ant-collapse-ghost > .ant-collapse-item > .ant-collapse-content { - padding-top: 12px; -} - -/* 响应式调整 */ -@media (max-width: 768px) { - .action-help-panel .ant-drawer-content-wrapper { - width: 100% !important; - } - - .help-action-header { - padding: 12px; - } - - .help-section { - padding: 12px; - } -} diff --git a/frontend/src/components/ActionHelpPanel/ActionHelpPanel.jsx b/frontend/src/components/ActionHelpPanel/ActionHelpPanel.jsx deleted file mode 100644 index a3cde11..0000000 --- a/frontend/src/components/ActionHelpPanel/ActionHelpPanel.jsx +++ /dev/null @@ -1,228 +0,0 @@ -import { useState, useEffect } from 'react' -import { Drawer, Collapse, Badge, Tag, Empty } from 'antd' -import { - QuestionCircleOutlined, - BulbOutlined, - WarningOutlined, - InfoCircleOutlined, - ThunderboltOutlined, -} from '@ant-design/icons' -import './ActionHelpPanel.css' - -const { Panel } = Collapse - -/** - * 操作帮助面板组件 - * 在页面侧边显示当前操作的详细说明和帮助信息 - * @param {Object} props - * @param {boolean} props.visible - 是否显示面板 - * @param {Function} props.onClose - 关闭回调 - * @param {Object} props.currentAction - 当前操作信息 - * @param {Array} props.allActions - 所有可用操作列表 - * @param {string} props.placement - 面板位置 - * @param {Function} props.onActionSelect - 选择操作的回调 - */ -function ActionHelpPanel({ - visible = false, - onClose, - currentAction = null, - allActions = [], - placement = 'right', - onActionSelect, -}) { - const [activeKey, setActiveKey] = useState(['current']) - - // 当 currentAction 变化时,自动展开"当前操作"面板 - useEffect(() => { - if (currentAction && visible) { - setActiveKey(['current']) - } - }, [currentAction, visible]) - - // 渲染当前操作详情 - const renderCurrentAction = () => { - if (!currentAction) { - return ( - - ) - } - - return ( -
- {/* 操作标题 */} -
-
{currentAction.icon}
-
-

{currentAction.title}

- {currentAction.badge && ( - - {currentAction.badge.text} - - )} -
-
- - {/* 操作描述 */} - {currentAction.description && ( -
-
- 功能说明 -
-
{currentAction.description}
-
- )} - - {/* 使用场景 */} - {currentAction.scenarios && currentAction.scenarios.length > 0 && ( -
-
- 使用场景 -
-
    - {currentAction.scenarios.map((scenario, index) => ( -
  • {scenario}
  • - ))} -
-
- )} - - {/* 操作步骤 */} - {currentAction.steps && currentAction.steps.length > 0 && ( -
-
- 操作步骤 -
-
    - {currentAction.steps.map((step, index) => ( -
  1. {step}
  2. - ))} -
-
- )} - - {/* 注意事项 */} - {currentAction.warnings && currentAction.warnings.length > 0 && ( -
-
- 注意事项 -
-
    - {currentAction.warnings.map((warning, index) => ( -
  • {warning}
  • - ))} -
-
- )} - - {/* 快捷键 */} - {currentAction.shortcut && ( -
-
⌨️ 快捷键
-
- {currentAction.shortcut} -
-
- )} - - {/* 权限要求 */} - {currentAction.permission && ( -
-
🔐 权限要求
-
- {currentAction.permission} -
-
- )} -
- ) - } - - // 渲染所有操作列表 - const renderAllActions = () => { - if (allActions.length === 0) { - return - } - - return ( -
- {allActions.map((action, index) => ( -
{ - if (onActionSelect) { - onActionSelect(action) - setActiveKey(['current']) - } - }} - > -
- {action.icon} - {action.title} - {action.shortcut && ( - {action.shortcut} - )} -
-
{action.description}
-
- ))} -
- ) - } - - return ( - - - 操作帮助 - {currentAction && } - - } - placement={placement} - width={420} - open={visible} - onClose={onClose} - className="action-help-panel" - > - - - 当前操作 - {currentAction && ( - - )} - - } - key="current" - > - {renderCurrentAction()} - - - - {renderAllActions()} - - - - ) -} - -export default ActionHelpPanel diff --git a/frontend/src/components/BottomHintBar/BottomHintBar.css b/frontend/src/components/BottomHintBar/BottomHintBar.css deleted file mode 100644 index a69ff8c..0000000 --- a/frontend/src/components/BottomHintBar/BottomHintBar.css +++ /dev/null @@ -1,304 +0,0 @@ -/* 底部提示栏基础样式 */ -.bottom-hint-bar { - position: fixed; - bottom: 0; - left: 0; - right: 0; - z-index: 9999; - padding: 12px 24px; - box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1); - animation: slideUp 0.3s ease; -} - -@keyframes slideUp { - from { - transform: translateY(100%); - opacity: 0; - } - to { - transform: translateY(0); - opacity: 1; - } -} - -/* 主题样式 */ -.bottom-hint-bar-light { - background: #ffffff; - border-top: 1px solid var(--border-color); -} - -.bottom-hint-bar-dark { - background: #001529; - color: #ffffff; -} - -.bottom-hint-bar-gradient { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: #ffffff; -} - -/* 容器布局 */ -.hint-bar-container { - display: flex; - align-items: center; - gap: 24px; - max-width: 1400px; - margin: 0 auto; -} - -/* 左侧区域 */ -.hint-bar-left { - display: flex; - align-items: center; - gap: 12px; - flex-shrink: 0; -} - -.hint-bar-icon { - font-size: 24px; - opacity: 0.9; -} - -.bottom-hint-bar-light .hint-bar-icon { - color: #1677ff; -} - -.hint-bar-title-section { - display: flex; - flex-direction: column; - gap: 4px; -} - -.hint-bar-title { - margin: 0; - font-size: 15px; - font-weight: 600; - line-height: 1.2; -} - -.bottom-hint-bar-light .hint-bar-title { - color: rgba(0, 0, 0, 0.88); -} - -.hint-bar-badge { - margin: 0; - font-size: 10px; - padding: 1px 6px; - align-self: flex-start; -} - -/* 中间区域 */ -.hint-bar-center { - flex: 1; - display: flex; - align-items: center; - gap: 24px; - flex-wrap: wrap; -} - -.hint-bar-description, -.hint-bar-quick-tip, -.hint-bar-warning { - display: flex; - align-items: center; - gap: 8px; - font-size: 13px; - line-height: 1.4; -} - -.bottom-hint-bar-light .hint-bar-description, -.bottom-hint-bar-light .hint-bar-quick-tip { - color: rgba(0, 0, 0, 0.65); -} - -.hint-info-icon { - font-size: 14px; - opacity: 0.8; -} - -.bottom-hint-bar-light .hint-info-icon { - color: #1677ff; -} - -.hint-tip-icon { - font-size: 14px; - color: #fadb14; -} - -.hint-warning-icon { - font-size: 14px; - color: #ff7a45; -} - -.bottom-hint-bar-light .hint-bar-warning { - color: #d46b08; -} - -/* 右侧区域 */ -.hint-bar-right { - display: flex; - align-items: center; - gap: 16px; - flex-shrink: 0; -} - -.hint-bar-shortcut { - display: flex; - align-items: center; - gap: 8px; -} - -.shortcut-label { - font-size: 11px; - opacity: 0.7; -} - -.shortcut-kbd { - display: inline-block; - padding: 4px 10px; - background: rgba(255, 255, 255, 0.2); - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 4px; - font-size: 11px; - font-family: 'Monaco', 'Consolas', monospace; - color: inherit; - font-weight: 500; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.bottom-hint-bar-light .shortcut-kbd { - background: #f0f0f0; - border-color: var(--border-color-strong); - color: rgba(0, 0, 0, 0.88); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05); -} - -.hint-bar-close { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 4px; - color: inherit; - cursor: pointer; - transition: all 0.3s ease; -} - -.hint-bar-close:hover { - background: rgba(255, 255, 255, 0.2); - transform: scale(1.05); -} - -.bottom-hint-bar-light .hint-bar-close { - background: #f0f0f0; - border-color: var(--border-color-strong); - color: rgba(0, 0, 0, 0.45); -} - -.bottom-hint-bar-light .hint-bar-close:hover { - background: #e0e0e0; - color: rgba(0, 0, 0, 0.88); -} - -/* 进度指示条 */ -.hint-bar-progress { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 2px; - background: rgba(255, 255, 255, 0.3); - overflow: hidden; -} - -.hint-bar-progress::after { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(255, 255, 255, 0.6); - animation: progressWave 3s ease-in-out infinite; -} - -.bottom-hint-bar-light .hint-bar-progress { - background: #f0f0f0; -} - -.bottom-hint-bar-light .hint-bar-progress::after { - background: #1677ff; -} - -@keyframes progressWave { - 0%, 100% { - transform: translateX(-100%); - } - 50% { - transform: translateX(0); - } -} - -/* 响应式调整 */ -@media (max-width: 1024px) { - .hint-bar-container { - flex-wrap: wrap; - gap: 12px; - } - - .hint-bar-center { - flex-basis: 100%; - order: 3; - gap: 12px; - } - - .hint-bar-description, - .hint-bar-quick-tip, - .hint-bar-warning { - font-size: 12px; - } -} - -@media (max-width: 768px) { - .bottom-hint-bar { - padding: 10px 16px; - } - - .hint-bar-left { - gap: 8px; - } - - .hint-bar-icon { - font-size: 20px; - } - - .hint-bar-title { - font-size: 14px; - } - - .hint-bar-right { - gap: 8px; - } - - .shortcut-label { - display: none; - } - - .hint-bar-close { - width: 24px; - height: 24px; - } -} - -@media (max-width: 480px) { - .hint-bar-quick-tip { - display: none; - } - - .hint-bar-warning { - flex-basis: 100%; - } -} diff --git a/frontend/src/components/BottomHintBar/BottomHintBar.jsx b/frontend/src/components/BottomHintBar/BottomHintBar.jsx deleted file mode 100644 index 9efe937..0000000 --- a/frontend/src/components/BottomHintBar/BottomHintBar.jsx +++ /dev/null @@ -1,90 +0,0 @@ -import { Tag } from 'antd' -import { - InfoCircleOutlined, - BulbOutlined, - WarningOutlined, - CloseOutlined, -} from '@ant-design/icons' -import './BottomHintBar.css' - -/** - * 底部固定提示栏组件 - * 在页面底部显示当前悬停按钮的实时说明 - * @param {Object} props - * @param {boolean} props.visible - 是否显示提示栏 - * @param {Object} props.hintInfo - 当前提示信息 - * @param {Function} props.onClose - 关闭回调 - * @param {string} props.theme - 主题:light, dark, gradient - */ -function BottomHintBar({ visible = false, hintInfo = null, onClose, theme = 'gradient' }) { - if (!visible || !hintInfo) return null - - return ( -
e.stopPropagation()} - > -
- {/* 左侧:图标和标题 */} -
-
{hintInfo.icon}
-
-

{hintInfo.title}

- {hintInfo.badge && ( - - {hintInfo.badge.text} - - )} -
-
- - {/* 中间:主要信息 */} -
- {/* 描述 */} - {hintInfo.description && ( -
- - {hintInfo.description} -
- )} - - {/* 快速提示 */} - {hintInfo.quickTip && ( -
- - {hintInfo.quickTip} -
- )} - - {/* 警告 */} - {hintInfo.warning && ( -
- - {hintInfo.warning} -
- )} -
- - {/* 右侧:快捷键和关闭 */} -
- {hintInfo.shortcut && ( -
- 快捷键 - {hintInfo.shortcut} -
- )} - {onClose && ( - - )} -
-
- - {/* 进度指示条 */} -
-
- ) -} - -export default BottomHintBar diff --git a/frontend/src/components/ButtonWithGuide/ButtonWithGuide.css b/frontend/src/components/ButtonWithGuide/ButtonWithGuide.css deleted file mode 100644 index 1d5ba9a..0000000 --- a/frontend/src/components/ButtonWithGuide/ButtonWithGuide.css +++ /dev/null @@ -1,201 +0,0 @@ -/* 按钮带引导 - 简洁现代设计 */ -.button-with-guide { - display: inline-flex; - align-items: center; - gap: 4px; -} - -/* 帮助图标按钮 - 简洁扁平设计 */ -.guide-icon-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - padding: 0; - background: transparent; - border: none; - border-radius: 4px; - color: rgba(0, 0, 0, 0.35); - font-size: 14px; - cursor: pointer; - transition: all 0.2s ease; -} - -.guide-icon-btn:hover { - background: rgba(22, 119, 255, 0.06); - color: #1677ff; -} - -.guide-icon-btn:active { - background: rgba(22, 119, 255, 0.12); -} - -/* 引导弹窗样式 */ -.button-guide-modal .ant-modal-header { - padding: 20px 24px; - border-bottom: 2px solid var(--border-color); -} - -.button-guide-modal .ant-modal-body { - padding: 24px; - max-height: 600px; - overflow-y: auto; -} - -.guide-modal-header { - display: flex; - align-items: center; - gap: 12px; -} - -.guide-modal-icon { - font-size: 24px; - color: #1677ff; -} - -.guide-modal-title { - font-size: 18px; - font-weight: 600; - color: rgba(0, 0, 0, 0.88); -} - -.guide-modal-badge { - margin: 0; - font-size: 11px; - padding: 2px 8px; -} - -/* 引导区块样式 */ -.guide-section { - margin-bottom: 20px; - padding: 16px; - background: var(--bg-color-secondary); - border-radius: 8px; - border-left: 3px solid #1677ff; -} - -.guide-section:last-child { - margin-bottom: 0; -} - -.guide-section-warning { - background: #fff7e6; - border-left-color: #faad14; -} - -.guide-section-title { - display: flex; - align-items: center; - gap: 8px; - font-size: 14px; - font-weight: 600; - color: var(--text-color); - margin-bottom: 12px; -} - -body.dark .guide-section-warning { - background: rgba(250, 173, 20, 0.12); - border-left-color: #faad14; -} - -.guide-section-icon { - font-size: 16px; - color: #1677ff; -} - -.guide-section-warning .guide-section-icon { - color: #faad14; -} - -.guide-section-content { - margin: 0; - font-size: 14px; - line-height: 1.8; - color: rgba(0, 0, 0, 0.65); -} - -.guide-list { - margin: 0; - padding-left: 20px; - list-style-type: disc; -} - -.guide-list li { - font-size: 13px; - line-height: 1.8; - color: rgba(0, 0, 0, 0.65); - margin-bottom: 8px; -} - -.guide-list li:last-child { - margin-bottom: 0; -} - -/* 步骤样式 */ -.guide-steps { - margin-top: 12px; -} - -.guide-steps .ant-steps-item-title { - font-size: 13px !important; - font-weight: 600 !important; -} - -.guide-steps .ant-steps-item-description { - font-size: 13px !important; - line-height: 1.6 !important; - color: rgba(0, 0, 0, 0.65) !important; -} - -/* 引导底部 */ -.guide-footer { - display: flex; - flex-wrap: wrap; - gap: 16px; - margin-top: 20px; - padding: 16px; - background: white; - border-radius: 8px; - border: 1px solid var(--border-color); -} - -.guide-footer-item { - display: flex; - align-items: center; - gap: 8px; -} - -.guide-footer-label { - font-size: 13px; - color: rgba(0, 0, 0, 0.65); -} - -.guide-footer-kbd { - display: inline-block; - padding: 4px 10px; - background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%); - border: 1px solid var(--border-color-strong); - border-radius: 6px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05); - font-size: 11px; - font-family: 'Monaco', 'Consolas', monospace; - color: rgba(0, 0, 0, 0.88); - font-weight: 500; -} - -/* 响应式调整 */ -@media (max-width: 768px) { - .button-guide-modal { - max-width: calc(100% - 32px); - } - - .button-guide-modal .ant-modal-body { - max-height: 500px; - } - - .guide-footer { - flex-direction: column; - gap: 12px; - } -} diff --git a/frontend/src/components/ButtonWithGuide/ButtonWithGuide.jsx b/frontend/src/components/ButtonWithGuide/ButtonWithGuide.jsx deleted file mode 100644 index 0f30cc3..0000000 --- a/frontend/src/components/ButtonWithGuide/ButtonWithGuide.jsx +++ /dev/null @@ -1,165 +0,0 @@ -import { useState } from 'react' -import { Button, Modal, Steps, Tag } from 'antd' -import { - QuestionCircleOutlined, - BulbOutlined, - WarningOutlined, - CheckCircleOutlined, - InfoCircleOutlined, -} from '@ant-design/icons' -import './ButtonWithGuide.css' - -/** - * 带引导的按钮组件 - 简洁现代设计 - * 在按钮旁边显示一个简洁的帮助图标,点击后显示详细引导 - */ -function ButtonWithGuide({ - label, - icon, - type = 'default', - danger = false, - disabled = false, - onClick, - guide, - size = 'middle', - ...restProps -}) { - const [showGuideModal, setShowGuideModal] = useState(false) - - const handleGuideClick = (e) => { - e.stopPropagation() - if (guide) { - setShowGuideModal(true) - } - } - - return ( - <> -
- - {guide && !disabled && ( - - )} -
- - {/* 引导弹窗 */} - {guide && ( - - {guide.icon || icon} - {guide.title} - {guide.badge && ( - - {guide.badge.text} - - )} -
- } - open={showGuideModal} - onCancel={() => setShowGuideModal(false)} - footer={[ - , - ]} - width={600} - className="button-guide-modal" - > - {/* 功能描述 */} - {guide.description && ( -
-
- - 功能说明 -
-

{guide.description}

-
- )} - - {/* 使用步骤 */} - {guide.steps && guide.steps.length > 0 && ( -
-
- - 操作步骤 -
- ({ - title: `步骤 ${index + 1}`, - description: step, - status: 'wait', - }))} - className="guide-steps" - /> -
- )} - - {/* 使用场景 */} - {guide.scenarios && guide.scenarios.length > 0 && ( -
-
- - 适用场景 -
-
    - {guide.scenarios.map((scenario, index) => ( -
  • {scenario}
  • - ))} -
-
- )} - - {/* 注意事项 */} - {guide.warnings && guide.warnings.length > 0 && ( -
-
- - 注意事项 -
-
    - {guide.warnings.map((warning, index) => ( -
  • {warning}
  • - ))} -
-
- )} - - {/* 快捷键和权限 */} - {(guide.shortcut || guide.permission) && ( -
- {guide.shortcut && ( -
- 快捷键: - {guide.shortcut} -
- )} - {guide.permission && ( -
- 权限要求: - {guide.permission} -
- )} -
- )} - - )} - - ) -} - -export default ButtonWithGuide diff --git a/frontend/src/components/ButtonWithGuideBadge/ButtonWithGuideBadge.css b/frontend/src/components/ButtonWithGuideBadge/ButtonWithGuideBadge.css deleted file mode 100644 index ca901bf..0000000 --- a/frontend/src/components/ButtonWithGuideBadge/ButtonWithGuideBadge.css +++ /dev/null @@ -1,248 +0,0 @@ -.button-guide-badge-wrapper { - display: inline-block; - position: relative; -} - -/* 引导徽章样式 - 改为放在右上角外部 */ -.button-guide-badge-wrapper .ant-badge { - display: block; -} - -.button-guide-badge-wrapper .ant-badge-count { - top: -8px; - right: -8px; - transform: none; -} - -/* 引导徽章样式 */ -.guide-badge { - display: flex; - align-items: center; - justify-content: center; - min-width: 20px; - height: 20px; - padding: 0 6px; - background: #1677ff; - border-radius: 10px; - color: white; - font-size: 12px; - font-weight: 600; - cursor: pointer; - transition: all 0.3s ease; - animation: pulseBadge 2s ease-in-out infinite; - box-shadow: 0 2px 8px rgba(22, 119, 255, 0.4); - border: 2px solid white; -} - -.guide-badge:hover { - animation: none; - transform: scale(1.2); - box-shadow: 0 4px 12px rgba(22, 119, 255, 0.6); -} - -.guide-badge-new { - background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%); - box-shadow: 0 2px 8px rgba(82, 196, 26, 0.4); -} - -.guide-badge-new:hover { - box-shadow: 0 4px 12px rgba(82, 196, 26, 0.6); -} - -.guide-badge-help { - background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%); - box-shadow: 0 2px 8px rgba(22, 119, 255, 0.4); -} - -.guide-badge-help:hover { - box-shadow: 0 4px 12px rgba(22, 119, 255, 0.6); -} - -.guide-badge-warn { - background: linear-gradient(135deg, #faad14 0%, #ffc53d 100%); - box-shadow: 0 2px 8px rgba(250, 173, 20, 0.4); -} - -.guide-badge-warn:hover { - box-shadow: 0 4px 12px rgba(250, 173, 20, 0.6); -} - -@keyframes pulseBadge { - 0%, 100% { - transform: scale(1); - opacity: 1; - } - 50% { - transform: scale(1.15); - opacity: 0.8; - } -} - -/* 引导弹窗样式 */ -.button-guide-modal .ant-modal-header { - padding: 20px 24px; - border-bottom: 2px solid var(--border-color); -} - -.button-guide-modal .ant-modal-body { - padding: 24px; - max-height: 600px; - overflow-y: auto; -} - -.guide-modal-header { - display: flex; - align-items: center; - gap: 12px; -} - -.guide-modal-icon { - font-size: 24px; - color: #1677ff; -} - -.guide-modal-title { - font-size: 18px; - font-weight: 600; - color: rgba(0, 0, 0, 0.88); -} - -.guide-modal-badge { - margin: 0; - font-size: 11px; - padding: 2px 8px; -} - -/* 引导区块样式 */ -.guide-section { - margin-bottom: 20px; - padding: 16px; - background: var(--bg-color-secondary); - border-radius: 8px; - border-left: 3px solid #1677ff; -} - -.guide-section:last-child { - margin-bottom: 0; -} - -.guide-section-warning { - background: #fff7e6; - border-left-color: #faad14; -} - -.guide-section-title { - display: flex; - align-items: center; - gap: 8px; - font-size: 14px; - font-weight: 600; - color: var(--text-color); - margin-bottom: 12px; -} - -.guide-section-icon { - font-size: 16px; - color: #1677ff; -} - -.guide-section-warning .guide-section-icon { - color: #faad14; -} - -body.dark .guide-section-warning { - background: rgba(250, 173, 20, 0.12); - border-left-color: #faad14; -} - -.guide-section-content { - margin: 0; - font-size: 14px; - line-height: 1.8; - color: rgba(0, 0, 0, 0.65); -} - -.guide-list { - margin: 0; - padding-left: 20px; - list-style-type: disc; -} - -.guide-list li { - font-size: 13px; - line-height: 1.8; - color: rgba(0, 0, 0, 0.65); - margin-bottom: 8px; -} - -.guide-list li:last-child { - margin-bottom: 0; -} - -/* 步骤样式 */ -.guide-steps { - margin-top: 12px; -} - -.guide-steps .ant-steps-item-title { - font-size: 13px !important; - font-weight: 600 !important; -} - -.guide-steps .ant-steps-item-description { - font-size: 13px !important; - line-height: 1.6 !important; - color: rgba(0, 0, 0, 0.65) !important; -} - -/* 引导底部 */ -.guide-footer { - display: flex; - flex-wrap: wrap; - gap: 16px; - margin-top: 20px; - padding: 16px; - background: white; - border-radius: 8px; - border: 1px solid var(--border-color); -} - -.guide-footer-item { - display: flex; - align-items: center; - gap: 8px; -} - -.guide-footer-label { - font-size: 13px; - color: rgba(0, 0, 0, 0.65); -} - -.guide-footer-kbd { - display: inline-block; - padding: 4px 10px; - background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%); - border: 1px solid var(--border-color-strong); - border-radius: 6px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05); - font-size: 11px; - font-family: 'Monaco', 'Consolas', monospace; - color: rgba(0, 0, 0, 0.88); - font-weight: 500; -} - -/* 响应式调整 */ -@media (max-width: 768px) { - .button-guide-modal { - max-width: calc(100% - 32px); - } - - .button-guide-modal .ant-modal-body { - max-height: 500px; - } - - .guide-footer { - flex-direction: column; - gap: 12px; - } -} diff --git a/frontend/src/components/ButtonWithGuideBadge/ButtonWithGuideBadge.jsx b/frontend/src/components/ButtonWithGuideBadge/ButtonWithGuideBadge.jsx deleted file mode 100644 index 35a8224..0000000 --- a/frontend/src/components/ButtonWithGuideBadge/ButtonWithGuideBadge.jsx +++ /dev/null @@ -1,222 +0,0 @@ -import { useState } from 'react' -import { Button, Badge, Modal, Steps, Tag, Divider } from 'antd' -import { - QuestionCircleOutlined, - BulbOutlined, - WarningOutlined, - CheckCircleOutlined, - InfoCircleOutlined, -} from '@ant-design/icons' -import './ButtonWithGuideBadge.css' - -/** - * 智能引导徽章按钮组件 - * 为新功能或复杂按钮添加脉冲动画的徽章,点击后显示详细引导 - * @param {Object} props - * @param {string} props.label - 按钮文本 - * @param {ReactNode} props.icon - 按钮图标 - * @param {string} props.type - 按钮类型 - * @param {boolean} props.danger - 危险按钮 - * @param {boolean} props.disabled - 禁用状态 - * @param {Function} props.onClick - 点击回调 - * @param {Object} props.guide - 引导配置 - * @param {boolean} props.showBadge - 是否显示徽章 - * @param {string} props.badgeType - 徽章类型:new, help, warn - * @param {string} props.size - 按钮大小 - */ -function ButtonWithGuideBadge({ - label, - icon, - type = 'default', - danger = false, - disabled = false, - onClick, - guide, - showBadge = true, - badgeType = 'help', - size = 'middle', - ...restProps -}) { - const [showGuideModal, setShowGuideModal] = useState(false) - - const handleBadgeClick = (e) => { - e.stopPropagation() - if (guide) { - setShowGuideModal(true) - } - } - - const getBadgeConfig = () => { - const configs = { - new: { - text: 'NEW', - color: '#52c41a', - icon: , - }, - help: { - text: '?', - color: '#1677ff', - icon: , - }, - warn: { - text: '!', - color: '#faad14', - icon: , - }, - } - return configs[badgeType] || configs.help - } - - const badgeConfig = getBadgeConfig() - - return ( - <> -
- {showBadge && guide && !disabled ? ( - - {badgeConfig.icon} -
- } - offset={[-5, 5]} - > - - - ) : ( - - )} - - - {/* 引导弹窗 */} - {guide && ( - - {guide.icon || icon} - {guide.title} - {guide.badge && ( - - {guide.badge.text} - - )} - - } - open={showGuideModal} - onCancel={() => setShowGuideModal(false)} - footer={[ - , - ]} - width={600} - className="button-guide-modal" - > - {/* 功能描述 */} - {guide.description && ( -
-
- - 功能说明 -
-

{guide.description}

-
- )} - - {/* 使用步骤 */} - {guide.steps && guide.steps.length > 0 && ( -
-
- - 操作步骤 -
- ({ - title: `步骤 ${index + 1}`, - description: step, - status: 'wait', - }))} - className="guide-steps" - /> -
- )} - - {/* 使用场景 */} - {guide.scenarios && guide.scenarios.length > 0 && ( -
-
- - 适用场景 -
-
    - {guide.scenarios.map((scenario, index) => ( -
  • {scenario}
  • - ))} -
-
- )} - - {/* 注意事项 */} - {guide.warnings && guide.warnings.length > 0 && ( -
-
- - 注意事项 -
-
    - {guide.warnings.map((warning, index) => ( -
  • {warning}
  • - ))} -
-
- )} - - {/* 快捷键和权限 */} - {(guide.shortcut || guide.permission) && ( -
- {guide.shortcut && ( -
- 快捷键: - {guide.shortcut} -
- )} - {guide.permission && ( -
- 权限要求: - {guide.permission} -
- )} -
- )} -
- )} - - ) -} - -export default ButtonWithGuideBadge diff --git a/frontend/src/components/ButtonWithHoverCard/ButtonWithHoverCard.css b/frontend/src/components/ButtonWithHoverCard/ButtonWithHoverCard.css deleted file mode 100644 index 390ccba..0000000 --- a/frontend/src/components/ButtonWithHoverCard/ButtonWithHoverCard.css +++ /dev/null @@ -1,194 +0,0 @@ -.button-hover-card-wrapper { - display: inline-block; - position: relative; -} - -/* 悬浮卡片 */ -.hover-info-card { - position: fixed; - z-index: 10000; - transform: translateY(-50%); - opacity: 0; - animation: slideInRight 0.3s ease forwards; - pointer-events: none; -} - -.hover-info-card-visible { - opacity: 1; -} - -@keyframes slideInRight { - from { - opacity: 0; - transform: translateY(-50%) translateX(-20px); - } - to { - opacity: 1; - transform: translateY(-50%) translateX(0); - } -} - -.hover-info-card-content { - width: 340px; - background: white; - border-radius: 12px; - box-shadow: - 0 12px 28px rgba(0, 0, 0, 0.12), - 0 6px 12px rgba(0, 0, 0, 0.08), - 0 0 2px rgba(0, 0, 0, 0.04); - overflow: hidden; -} - -.hover-info-card-content .ant-card-body { - padding: 16px; -} - -/* 卡片头部 */ -.hover-card-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - margin-bottom: 12px; - padding-bottom: 12px; - border-bottom: 1px solid var(--border-color); -} - -.hover-card-title-wrapper { - display: flex; - align-items: center; - gap: 8px; - flex: 1; -} - -.hover-card-icon { - font-size: 20px; - color: #1677ff; -} - -.hover-card-title { - margin: 0; - font-size: 16px; - font-weight: 600; - color: rgba(0, 0, 0, 0.88); -} - -.hover-card-badge { - margin: 0; - font-size: 11px; - padding: 2px 8px; - border-radius: 10px; -} - -/* 卡片描述 */ -.hover-card-description { - margin: 0; - font-size: 13px; - line-height: 1.6; - color: var(--text-color-secondary); -} - -/* 卡片区块 */ -.hover-card-section { - margin-top: 12px; - padding: 10px; - background: var(--bg-color-secondary); - border-radius: 8px; - border-left: 3px solid #1677ff; -} - -.hover-card-warning { - background: #fff7e6; - border-left-color: #faad14; -} - -.hover-card-section-title { - display: flex; - align-items: center; - gap: 6px; - font-size: 12px; - font-weight: 600; - color: var(--text-color); - margin-bottom: 8px; -} - -body.dark .hover-card-warning { - background: rgba(250, 173, 20, 0.12); - border-left-color: #faad14; -} - -.section-icon { - font-size: 12px; - color: #1677ff; -} - -.hover-card-warning .section-icon { - color: #faad14; -} - -.hover-card-list { - margin: 0; - padding-left: 16px; - list-style-type: disc; -} - -.hover-card-list li { - font-size: 12px; - line-height: 1.6; - color: rgba(0, 0, 0, 0.65); - margin-bottom: 4px; -} - -.hover-card-list li:last-child { - margin-bottom: 0; -} - -/* 卡片底部 */ -.hover-card-footer { - display: flex; - align-items: center; - justify-content: space-between; - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid var(--border-color); -} - -.footer-label { - font-size: 12px; - color: rgba(0, 0, 0, 0.45); -} - -.footer-kbd { - display: inline-block; - padding: 4px 10px; - background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%); - border: 1px solid var(--border-color-strong); - border-radius: 6px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05); - font-size: 11px; - font-family: 'Monaco', 'Consolas', monospace; - color: rgba(0, 0, 0, 0.88); - font-weight: 500; -} - -/* 响应式调整 */ -@media (max-width: 768px) { - .hover-info-card-content { - width: 280px; - } - - .hover-info-card { - left: 50% !important; - transform: translateX(-50%) translateY(-50%); - } - - @keyframes slideInRight { - from { - opacity: 0; - transform: translateX(-50%) translateY(-50%) scale(0.95); - } - to { - opacity: 1; - transform: translateX(-50%) translateY(-50%) scale(1); - } - } -} diff --git a/frontend/src/components/ButtonWithHoverCard/ButtonWithHoverCard.jsx b/frontend/src/components/ButtonWithHoverCard/ButtonWithHoverCard.jsx deleted file mode 100644 index 9800e64..0000000 --- a/frontend/src/components/ButtonWithHoverCard/ButtonWithHoverCard.jsx +++ /dev/null @@ -1,179 +0,0 @@ -import { useState, useRef } from 'react' -import { createPortal } from 'react-dom' -import { Button, Card, Tag } from 'antd' -import { - BulbOutlined, - WarningOutlined, - ThunderboltOutlined, -} from '@ant-design/icons' -import './ButtonWithHoverCard.css' - -/** - * 悬浮展开卡片按钮组件 - * 鼠标悬停时,在按钮旁边展开一个精美的信息卡片 - * @param {Object} props - * @param {string} props.label - 按钮文本 - * @param {ReactNode} props.icon - 按钮图标 - * @param {string} props.type - 按钮类型 - * @param {boolean} props.danger - 危险按钮 - * @param {boolean} props.disabled - 禁用状态 - * @param {Function} props.onClick - 点击回调 - * @param {Object} props.cardInfo - 卡片信息配置 - * @param {string} props.size - 按钮大小 - */ -function ButtonWithHoverCard({ - label, - icon, - type = 'default', - danger = false, - disabled = false, - onClick, - cardInfo, - size = 'middle', - ...restProps -}) { - const [showCard, setShowCard] = useState(false) - const [cardPosition, setCardPosition] = useState({ top: 0, left: 0 }) - const wrapperRef = useRef(null) - - const handleMouseEnter = () => { - if (!cardInfo || disabled) return - - if (wrapperRef.current) { - const rect = wrapperRef.current.getBoundingClientRect() - setCardPosition({ - top: rect.top + rect.height / 2, - left: rect.right + 12, - }) - } - setShowCard(true) - } - - const handleMouseLeave = () => { - setShowCard(false) - } - - // 渲染悬浮卡片 - const renderCard = () => { - if (!showCard || !cardInfo) return null - - return ( -
- - {/* 标题区 */} -
-
- {cardInfo.icon && ( - {cardInfo.icon} - )} -

{cardInfo.title}

-
- {cardInfo.badge && ( - - {cardInfo.badge.text} - - )} -
- - {/* 描述 */} - {cardInfo.description && ( -
-

{cardInfo.description}

-
- )} - - {/* 使用场景 */} - {cardInfo.scenarios && cardInfo.scenarios.length > 0 && ( -
-
- - 使用场景 -
-
    - {cardInfo.scenarios.slice(0, 2).map((scenario, index) => ( -
  • {scenario}
  • - ))} -
-
- )} - - {/* 快速提示 */} - {cardInfo.quickTips && cardInfo.quickTips.length > 0 && ( -
-
- - 快速提示 -
-
    - {cardInfo.quickTips.map((tip, index) => ( -
  • {tip}
  • - ))} -
-
- )} - - {/* 注意事项 */} - {cardInfo.warnings && cardInfo.warnings.length > 0 && ( -
-
- - 注意 -
-
    - {cardInfo.warnings.slice(0, 2).map((warning, index) => ( -
  • {warning}
  • - ))} -
-
- )} - - {/* 快捷键 */} - {cardInfo.shortcut && ( -
- 快捷键 - {cardInfo.shortcut} -
- )} -
-
- ) - } - - return ( - <> -
- -
- - {/* 使用 Portal 渲染悬浮卡片到 body */} - {typeof document !== 'undefined' && createPortal(renderCard(), document.body)} - - ) -} - -export default ButtonWithHoverCard diff --git a/frontend/src/components/ButtonWithTip/ButtonWithTip.css b/frontend/src/components/ButtonWithTip/ButtonWithTip.css deleted file mode 100644 index f1d665a..0000000 --- a/frontend/src/components/ButtonWithTip/ButtonWithTip.css +++ /dev/null @@ -1,163 +0,0 @@ -/* 按钮包裹容器 */ -.button-with-tip-wrapper { - position: relative; - display: inline-flex; - align-items: center; - gap: 4px; -} - -.button-with-tip { - transition: all 0.3s ease; -} - -/* 提示指示器 */ -.button-tip-indicator { - font-size: 12px; - color: rgba(0, 0, 0, 0.25); - cursor: help; - transition: all 0.3s ease; - animation: pulse 2s ease-in-out infinite; -} - -.button-with-tip-wrapper:hover .button-tip-indicator { - color: #1677ff; - animation: none; -} - -/* 脉冲动画 */ -@keyframes pulse { - 0%, 100% { - opacity: 1; - transform: scale(1); - } - 50% { - opacity: 0.6; - transform: scale(1.1); - } -} - -/* 提示框样式 */ -.button-tip-overlay { - max-width: 360px; -} - -.button-tip-overlay .ant-tooltip-inner { - padding: 12px 16px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - border-radius: 8px; - box-shadow: 0 8px 24px rgba(102, 126, 234, 0.3); -} - -.button-tip-overlay .ant-tooltip-arrow { - --antd-arrow-background-color: #667eea; -} - -.button-tip-overlay .ant-tooltip-arrow-content { - background: #667eea; -} - -/* 提示内容布局 */ -.button-tip-content { - display: flex; - flex-direction: column; - gap: 8px; - color: #ffffff; - font-size: 13px; - line-height: 1.6; -} - -.button-tip-title { - font-size: 14px; - font-weight: 600; - color: #ffffff; - border-bottom: 1px solid rgba(255, 255, 255, 0.2); - padding-bottom: 6px; -} - -.button-tip-description { - color: rgba(255, 255, 255, 0.95); - font-size: 13px; -} - -.button-tip-shortcut { - display: flex; - align-items: center; - gap: 6px; - margin-top: 4px; - padding-top: 8px; - border-top: 1px solid rgba(255, 255, 255, 0.15); -} - -.tip-label { - font-size: 12px; - color: rgba(255, 255, 255, 0.8); -} - -.tip-kbd { - display: inline-block; - padding: 2px 8px; - background: rgba(255, 255, 255, 0.2); - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 4px; - font-size: 11px; - font-family: 'Monaco', 'Consolas', monospace; - color: #ffffff; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.button-tip-notes { - margin-top: 4px; - padding-top: 8px; - border-top: 1px solid rgba(255, 255, 255, 0.15); -} - -.tip-notes-title { - font-size: 12px; - font-weight: 500; - color: rgba(255, 255, 255, 0.9); - margin-bottom: 6px; -} - -.tip-notes-list { - margin: 0; - padding-left: 16px; - list-style-type: disc; -} - -.tip-notes-list li { - font-size: 12px; - color: rgba(255, 255, 255, 0.85); - margin-bottom: 4px; -} - -.tip-notes-list li:last-child { - margin-bottom: 0; -} - -/* 不同主题的提示框 */ -.tip-theme-success.button-tip-overlay .ant-tooltip-inner { - background: linear-gradient(135deg, #56ab2f 0%, #a8e063 100%); -} - -.tip-theme-warning.button-tip-overlay .ant-tooltip-inner { - background: linear-gradient(135deg, #f7971e 0%, #ffd200 100%); -} - -.tip-theme-danger.button-tip-overlay .ant-tooltip-inner { - background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%); -} - -.tip-theme-info.button-tip-overlay .ant-tooltip-inner { - background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); -} - -/* 响应式调整 */ -@media (max-width: 768px) { - .button-tip-overlay { - max-width: 280px; - } - - .button-tip-indicator { - display: none; - } -} diff --git a/frontend/src/components/ButtonWithTip/ButtonWithTip.jsx b/frontend/src/components/ButtonWithTip/ButtonWithTip.jsx deleted file mode 100644 index f8d4130..0000000 --- a/frontend/src/components/ButtonWithTip/ButtonWithTip.jsx +++ /dev/null @@ -1,105 +0,0 @@ -import { Button, Tooltip } from 'antd' -import { QuestionCircleOutlined } from '@ant-design/icons' -import './ButtonWithTip.css' - -/** - * 带有增强提示的按钮组件 - * @param {Object} props - * @param {string} props.label - 按钮文本 - * @param {ReactNode} props.icon - 按钮图标 - * @param {string} props.type - 按钮类型 - * @param {boolean} props.danger - 危险按钮 - * @param {boolean} props.disabled - 禁用状态 - * @param {Function} props.onClick - 点击回调 - * @param {Object} props.tip - 提示配置 - * @param {string} props.tip.title - 提示标题 - * @param {string} props.tip.description - 详细描述 - * @param {string} props.tip.shortcut - 快捷键提示 - * @param {Array} props.tip.notes - 注意事项列表 - * @param {string} props.tip.placement - 提示位置 - * @param {boolean} props.showTipIcon - 是否显示提示图标 - * @param {string} props.size - 按钮大小 - */ -function ButtonWithTip({ - label, - icon, - type = 'default', - danger = false, - disabled = false, - onClick, - tip, - showTipIcon = true, - size = 'middle', - ...restProps -}) { - // 如果没有提示配置,直接返回普通按钮 - if (!tip) { - return ( - - ) - } - - // 构建提示内容 - const tooltipContent = ( -
- {tip.title &&
{tip.title}
} - {tip.description &&
{tip.description}
} - {tip.shortcut && ( -
- 快捷键: - {tip.shortcut} -
- )} - {tip.notes && tip.notes.length > 0 && ( -
-
注意事项:
-
    - {tip.notes.map((note, index) => ( -
  • {note}
  • - ))} -
-
- )} -
- ) - - return ( - -
- - {showTipIcon && !disabled && ( - - )} -
-
- ) -} - -export default ButtonWithTip diff --git a/frontend/src/components/ChartPanel/ChartPanel.css b/frontend/src/components/ChartPanel/ChartPanel.css deleted file mode 100644 index 69a0cc2..0000000 --- a/frontend/src/components/ChartPanel/ChartPanel.css +++ /dev/null @@ -1,17 +0,0 @@ -/* 图表面板 */ -.chart-panel { - margin-bottom: 16px; -} - -.chart-panel:last-child { - margin-bottom: 0; -} - -.chart-panel-title { - font-size: 13px; - font-weight: 600; - color: rgba(0, 0, 0, 0.88); - margin-bottom: 12px; - padding-left: 8px; - border-left: 3px solid #1677ff; -} diff --git a/frontend/src/components/ChartPanel/ChartPanel.jsx b/frontend/src/components/ChartPanel/ChartPanel.jsx deleted file mode 100644 index 5f26c8f..0000000 --- a/frontend/src/components/ChartPanel/ChartPanel.jsx +++ /dev/null @@ -1,202 +0,0 @@ -import { useEffect, useRef } from 'react' -import * as echarts from 'echarts' -import './ChartPanel.css' - -/** - * 图表面板组件 - * @param {Object} props - * @param {string} props.type - 图表类型: 'line' | 'bar' | 'pie' | 'ring' - * @param {string} props.title - 图表标题 - * @param {Object} props.data - 图表数据 - * @param {number} props.height - 图表高度,默认 200px - * @param {Object} props.option - 自定义 ECharts 配置 - * @param {string} props.className - 自定义类名 - */ -function ChartPanel({ type = 'line', title, data, height = 200, option = {}, className = '' }) { - const chartRef = useRef(null) - const chartInstance = useRef(null) - - useEffect(() => { - if (!chartRef.current || !data) return - - // 使用 setTimeout 确保 DOM 完全渲染 - const timer = setTimeout(() => { - // 初始化图表 - if (!chartInstance.current) { - chartInstance.current = echarts.init(chartRef.current) - } - - // 根据类型生成配置 - const chartOption = getChartOption(type, data, option) - chartInstance.current.setOption(chartOption, true) - }, 0) - - // 窗口大小改变时重绘(使用 passive 选项) - const handleResize = () => { - if (chartInstance.current) { - chartInstance.current.resize() - } - } - - // 添加被动事件监听器 - window.addEventListener('resize', handleResize, { passive: true }) - - return () => { - clearTimeout(timer) - window.removeEventListener('resize', handleResize) - } - }, [type, data, option]) - - // 组件卸载时销毁图表 - useEffect(() => { - return () => { - chartInstance.current?.dispose() - } - }, []) - - return ( -
- {title &&
{title}
} -
-
- ) -} - -/** - * 根据图表类型生成 ECharts 配置 - */ -function getChartOption(type, data, customOption) { - const baseOption = { - grid: { - left: '10%', - right: '5%', - top: '15%', - bottom: '15%', - }, - tooltip: { - trigger: type === 'pie' || type === 'ring' ? 'item' : 'axis', - backgroundColor: 'rgba(255, 255, 255, 0.95)', - borderColor: '#e8e8e8', - borderWidth: 1, - textStyle: { - color: '#333', - }, - }, - } - - switch (type) { - case 'line': - return { - ...baseOption, - xAxis: { - type: 'category', - data: data.xAxis || [], - boundaryGap: false, - axisLine: { lineStyle: { color: '#e8e8e8' } }, - axisLabel: { color: '#8c8c8c', fontSize: 11 }, - }, - yAxis: { - type: 'value', - axisLine: { lineStyle: { color: '#e8e8e8' } }, - axisLabel: { color: '#8c8c8c', fontSize: 11 }, - splitLine: { lineStyle: { color: '#f0f0f0' } }, - }, - series: [ - { - type: 'line', - data: data.series || [], - smooth: true, - lineStyle: { width: 2, color: '#1677ff' }, - areaStyle: { - color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ - { offset: 0, color: 'rgba(22, 119, 255, 0.3)' }, - { offset: 1, color: 'rgba(22, 119, 255, 0.05)' }, - ]), - }, - symbol: 'circle', - symbolSize: 6, - itemStyle: { color: '#1677ff' }, - }, - ], - ...customOption, - } - - case 'bar': - return { - ...baseOption, - xAxis: { - type: 'category', - data: data.xAxis || [], - axisLine: { lineStyle: { color: '#e8e8e8' } }, - axisLabel: { color: '#8c8c8c', fontSize: 11 }, - }, - yAxis: { - type: 'value', - axisLine: { lineStyle: { color: '#e8e8e8' } }, - axisLabel: { color: '#8c8c8c', fontSize: 11 }, - splitLine: { lineStyle: { color: '#f0f0f0' } }, - }, - series: [ - { - type: 'bar', - data: data.series || [], - itemStyle: { - color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ - { offset: 0, color: '#4096ff' }, - { offset: 1, color: '#1677ff' }, - ]), - borderRadius: [4, 4, 0, 0], - }, - barWidth: '50%', - }, - ], - ...customOption, - } - - case 'pie': - case 'ring': - return { - ...baseOption, - grid: undefined, - legend: { - orient: 'vertical', - right: '10%', - top: 'center', - textStyle: { color: '#8c8c8c', fontSize: 12 }, - }, - series: [ - { - type: 'pie', - radius: type === 'ring' ? ['40%', '65%'] : '65%', - center: ['40%', '50%'], - data: data.series || [], - label: { - fontSize: 11, - color: '#8c8c8c', - }, - labelLine: { - lineStyle: { color: '#d9d9d9' }, - }, - itemStyle: { - borderRadius: 4, - borderColor: '#fff', - borderWidth: 2, - }, - emphasis: { - itemStyle: { - shadowBlur: 10, - shadowOffsetX: 0, - shadowColor: 'rgba(0, 0, 0, 0.3)', - }, - }, - }, - ], - ...customOption, - } - - default: - return { ...baseOption, ...customOption } - } -} - -export default ChartPanel diff --git a/frontend/src/components/ConfirmDialog/ConfirmDialog.jsx b/frontend/src/components/ConfirmDialog/ConfirmDialog.jsx deleted file mode 100644 index 528ef63..0000000 --- a/frontend/src/components/ConfirmDialog/ConfirmDialog.jsx +++ /dev/null @@ -1,138 +0,0 @@ -import { Modal } from 'antd' -import { ExclamationCircleOutlined, DeleteOutlined } from '@ant-design/icons' - -/** - * 标准确认对话框组件 - * @param {Object} options - 对话框配置 - * @param {string} options.title - 标题 - * @param {string|ReactNode} options.content - 内容 - * @param {string} options.okText - 确认按钮文字 - * @param {string} options.cancelText - 取消按钮文字 - * @param {string} options.type - 类型: 'warning', 'danger', 'info' - * @param {Function} options.onOk - 确认回调 - * @param {Function} options.onCancel - 取消回调 - */ -const ConfirmDialog = { - /** - * 显示删除确认对话框(单个项目) - */ - delete: ({ title = '确认删除', itemName, itemInfo, onOk, onCancel }) => { - Modal.confirm({ - title, - content: ( -
-

您确定要删除以下项目吗?

-
-

{itemName}

- {itemInfo && ( -

{itemInfo}

- )} -
-

- 此操作不可恢复,请谨慎操作! -

-
- ), - okText: '确认删除', - cancelText: '取消', - okType: 'danger', - centered: true, - icon: , - onOk, - onCancel, - }) - }, - - /** - * 显示批量删除确认对话框 - */ - batchDelete: ({ count, items, onOk, onCancel }) => { - Modal.confirm({ - title: '批量删除确认', - content: ( -
-

您确定要删除选中的 {count} 个项目吗?

-
- {items.map((item, index) => ( -
- {item.name} - {item.info && ( - - ({item.info}) - - )} -
- ))} -
-

- 此操作不可恢复,请谨慎操作! -

-
- ), - okText: '确认删除', - cancelText: '取消', - okType: 'danger', - centered: true, - icon: , - onOk, - onCancel, - }) - }, - - /** - * 显示警告确认对话框 - */ - warning: ({ title, content, okText = '确定', cancelText = '取消', onOk, onCancel }) => { - Modal.confirm({ - title, - content, - okText, - cancelText, - centered: true, - icon: , - onOk, - onCancel, - }) - }, - - /** - * 显示通用确认对话框 - */ - confirm: ({ - title, - content, - okText = '确定', - cancelText = '取消', - okType = 'primary', - onOk, - onCancel, - }) => { - Modal.confirm({ - title, - content, - okText, - cancelText, - okType, - centered: true, - onOk, - onCancel, - }) - }, -} - -export default ConfirmDialog diff --git a/frontend/src/components/DetailDrawer/DetailDrawer.css b/frontend/src/components/DetailDrawer/DetailDrawer.css deleted file mode 100644 index 062d149..0000000 --- a/frontend/src/components/DetailDrawer/DetailDrawer.css +++ /dev/null @@ -1,119 +0,0 @@ -/* 详情抽屉容器 */ -.detail-drawer-content { - height: 100%; - display: flex; - flex-direction: column; -} - -/* 顶部信息区域 - 固定不滚动 */ -.detail-drawer-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px; - background: var(--bg-color-secondary); - border-bottom: 1px solid var(--border-color); - flex-shrink: 0; -} - -.detail-drawer-header-left { - display: flex; - align-items: center; - gap: 16px; -} - -.detail-drawer-close-button { - font-size: 18px; - color: var(--text-color-secondary); -} - -.detail-drawer-close-button:hover { - color: #1677ff; -} - -.detail-drawer-header-info { - display: flex; - align-items: center; - gap: 12px; -} - -.detail-drawer-title-icon { - font-size: 18px; - color: #1677ff; -} - -.detail-drawer-title { - margin: 0; - font-size: 18px; - font-weight: 600; - color: rgba(0, 0, 0, 0.88); -} - -.detail-drawer-badge { - display: flex; - align-items: center; -} - -.detail-drawer-header-right { - flex: 1; - display: flex; - justify-content: flex-end; -} - -/* 可滚动内容区域 */ -.detail-drawer-scrollable-content { - flex: 1; - overflow-y: auto; - overflow-x: hidden; - padding: 24px; -} - -/* 标签页区域 */ -.detail-drawer-tabs { - background: var(--card-bg); - padding: 0; - min-height: 400px; -} - -.detail-drawer-tabs :global(.ant-tabs) { - height: 100%; -} - -.detail-drawer-tabs :global(.ant-tabs-content-holder) { - overflow: visible; -} - -.detail-drawer-tabs :global(.ant-tabs-nav) { - padding: 0; - margin: 0 0 16px 0; - background: transparent; -} - -.detail-drawer-tabs :global(.ant-tabs-nav::before) { - border-bottom: 1px solid var(--border-color); -} - -.detail-drawer-tabs :global(.ant-tabs-tab) { - padding: 12px 0; - margin: 0 32px 0 0; - font-size: 14px; - font-weight: 500; -} - -.detail-drawer-tabs :global(.ant-tabs-tab:first-child) { - margin-left: 0; -} - -.detail-drawer-tabs :global(.ant-tabs-tab-active .ant-tabs-tab-btn) { - color: #d946ef; -} - -.detail-drawer-tabs :global(.ant-tabs-ink-bar) { - background: #d946ef; - height: 3px; -} - -.detail-drawer-tab-content { - padding: 0; - background: var(--card-bg); -} diff --git a/frontend/src/components/DetailDrawer/DetailDrawer.jsx b/frontend/src/components/DetailDrawer/DetailDrawer.jsx deleted file mode 100644 index 2ef0eab..0000000 --- a/frontend/src/components/DetailDrawer/DetailDrawer.jsx +++ /dev/null @@ -1,97 +0,0 @@ -import { Drawer, Button, Space, Tabs } from 'antd' -import { CloseOutlined } from '@ant-design/icons' -import './DetailDrawer.css' - -/** - * 详情抽屉组件 - * @param {Object} props - * @param {boolean} props.visible - 是否显示抽屉 - * @param {Function} props.onClose - 关闭回调 - * @param {Object} props.title - 标题配置 - * @param {string} props.title.text - 标题文本 - * @param {ReactNode} props.title.badge - 状态徽标(可选) - * @param {ReactNode} props.title.icon - 图标(可选) - * @param {Array} props.headerActions - 顶部操作按钮 - * @param {number} props.width - 抽屉宽度 - * @param {ReactNode} props.children - 主要内容 - * @param {Array} props.tabs - 标签页配置(可选) - */ -function DetailDrawer({ - visible, - onClose, - title, - headerActions = [], - width = 1080, - children, - tabs, -}) { - return ( - -
- {/* 顶部标题栏 - 固定不滚动 */} -
-
-
-
- - {headerActions.map((action) => ( - - ))} - -
-
- - {/* 可滚动内容区域 */} -
- {children} - - {/* 可选的标签页区域 */} - {tabs && tabs.length > 0 && ( -
- ({ - key: tab.key, - label: tab.label, - children:
{tab.content}
, - }))} - /> -
- )} -
-
-
- ) -} - -export default DetailDrawer diff --git a/frontend/src/components/DocFloatActions/DocFloatActions.jsx b/frontend/src/components/DocFloatActions/DocFloatActions.jsx deleted file mode 100644 index 9a85079..0000000 --- a/frontend/src/components/DocFloatActions/DocFloatActions.jsx +++ /dev/null @@ -1,37 +0,0 @@ -import { FloatButton, Tooltip } from 'antd' -import { MenuOutlined, FilePdfOutlined, VerticalAlignTopOutlined } from '@ant-design/icons' - -/** - * 文档浮动操作按钮组(导出 PDF + 回到顶部) - * @param {object} props - * @param {function} props.onExportPDF - 导出 PDF 回调 - * @param {React.RefObject} props.scrollRef - 滚动容器 ref - * @param {number} [props.right=24] - 距右侧距离 - */ -export default function DocFloatActions({ onExportPDF, scrollRef, right = 24 }) { - return ( - } - style={{ right }} - > - - } - onClick={onExportPDF} - /> - - - } - onClick={() => { - if (scrollRef?.current) { - scrollRef.current.scrollTo({ top: 0, behavior: 'smooth' }) - } - }} - /> - - - ) -} diff --git a/frontend/src/components/ExtendInfoPanel/ExtendInfoPanel.css b/frontend/src/components/ExtendInfoPanel/ExtendInfoPanel.css deleted file mode 100644 index 9a5dd69..0000000 --- a/frontend/src/components/ExtendInfoPanel/ExtendInfoPanel.css +++ /dev/null @@ -1,105 +0,0 @@ -/* 扩展信息面板容器 */ -.extend-info-panel { - display: flex; - gap: 16px; - width: 100%; -} - -/* 垂直布局(默认) */ -.extend-info-panel-vertical { - flex-direction: column; -} - -/* 水平布局 */ -.extend-info-panel-horizontal { - flex-direction: row; - flex-wrap: wrap; -} - -/* 信息区块 */ -.extend-info-section { - background: var(--card-bg); - border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); - overflow: hidden; - transition: all 0.3s ease; -} - -/* 水平布局时区块自适应宽度 */ -.extend-info-panel-horizontal .extend-info-section { - flex: 1; - min-width: 0; -} - -.extend-info-section:hover { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); -} - -/* 区块头部 */ -.extend-info-section-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px 20px; - background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%); - border-bottom: 1px solid var(--border-color); - cursor: pointer; - user-select: none; - transition: background 0.2s ease; -} - -.extend-info-section-header:hover { - background: linear-gradient(135deg, #f0f4ff 0%, #e8f0ff 100%); -} - -.extend-info-section-title { - display: flex; - align-items: center; - gap: 8px; - font-size: 14px; - font-weight: 600; - color: rgba(0, 0, 0, 0.88); -} - -.extend-info-section-icon { - display: flex; - align-items: center; - font-size: 16px; - color: #1677ff; -} - -.extend-info-section-toggle { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border: none; - background: transparent; - color: #8c8c8c; - cursor: pointer; - transition: all 0.2s ease; - border-radius: 4px; -} - -.extend-info-section-toggle:hover { - background: rgba(0, 0, 0, 0.06); - color: #1677ff; -} - -/* 区块内容 */ -.extend-info-section-content { - padding: 16px 20px; - animation: expandContent 0.3s ease-out; -} - -@keyframes expandContent { - from { - opacity: 0; - transform: translateY(-10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} diff --git a/frontend/src/components/ExtendInfoPanel/ExtendInfoPanel.jsx b/frontend/src/components/ExtendInfoPanel/ExtendInfoPanel.jsx deleted file mode 100644 index 03e5166..0000000 --- a/frontend/src/components/ExtendInfoPanel/ExtendInfoPanel.jsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useState } from 'react' -import { UpOutlined, DownOutlined } from '@ant-design/icons' -import './ExtendInfoPanel.css' - -/** - * 扩展信息面板组件 - * @param {Object} props - * @param {Array} props.sections - 信息区块配置数组 - * @param {string} props.sections[].key - 区块唯一键 - * @param {string} props.sections[].title - 区块标题 - * @param {ReactNode} props.sections[].icon - 标题图标 - * @param {ReactNode} props.sections[].content - 区块内容 - * @param {boolean} props.sections[].defaultCollapsed - 默认是否折叠 - * @param {boolean} props.sections[].hideTitleBar - 是否隐藏该区块的标题栏(默认 false) - * @param {string} props.layout - 布局方式:'vertical'(垂直堆叠)| 'horizontal'(水平排列) - * @param {string} props.className - 自定义类名 - */ -function ExtendInfoPanel({ sections = [], layout = 'vertical', className = '' }) { - const [collapsedSections, setCollapsedSections] = useState(() => { - const initial = {} - sections.forEach((section) => { - if (section.defaultCollapsed) { - initial[section.key] = true - } - }) - return initial - }) - - const toggleSection = (key) => { - setCollapsedSections((prev) => ({ - ...prev, - [key]: !prev[key], - })) - } - - return ( -
- {sections.map((section) => { - const isCollapsed = collapsedSections[section.key] - const hideTitleBar = section.hideTitleBar === true - - return ( -
- {/* 区块头部 - 可配置隐藏 */} - {!hideTitleBar && ( -
toggleSection(section.key)}> -
- {section.icon && {section.icon}} - {section.title} -
- -
- )} - - {/* 区块内容 - 如果隐藏标题栏则总是显示,否则根据折叠状态 */} - {(hideTitleBar || !isCollapsed) && ( -
{section.content}
- )} -
- ) - })} -
- ) -} - -export default ExtendInfoPanel diff --git a/frontend/src/components/Feedback/FeedbackBridge.jsx b/frontend/src/components/Feedback/FeedbackBridge.jsx new file mode 100644 index 0000000..01dd32d --- /dev/null +++ b/frontend/src/components/Feedback/FeedbackBridge.jsx @@ -0,0 +1,18 @@ +import { useEffect } from 'react' +import { App } from 'antd' +import { setFeedbackApi } from './feedbackApi' + +/** + * 把 AntD App 上下文里的 message / notification / modal 注册到全局反馈通道。 + * 必须渲染在 + 内部。 + */ +export default function FeedbackBridge({ children }) { + const api = App.useApp() + + useEffect(() => { + setFeedbackApi(api) + return () => setFeedbackApi(null) + }, [api]) + + return children +} diff --git a/frontend/src/components/Feedback/feedbackApi.js b/frontend/src/components/Feedback/feedbackApi.js new file mode 100644 index 0000000..2df51ed --- /dev/null +++ b/frontend/src/components/Feedback/feedbackApi.js @@ -0,0 +1,29 @@ +import { Modal, message as staticMessage, notification as staticNotification } from 'antd' + +/** + * 全局反馈通道(message / notification / modal) + * + * AntD 5 的静态方法(message.success、Modal.confirm 等)不会继承 ConfigProvider 的主题, + * 暗色模式下会弹出浅色气泡,交互风格也不统一。因此统一通过 FeedbackBridge 注入 + * App.useApp() 返回的上下文实例,业务代码继续调用 Toast.xxx 即可。 + * + * 静态实例仅作为 React 树之外(如 axios 拦截器)的兜底。 + */ +const fallbackApi = { + message: staticMessage, + // 静态 Modal.confirm 仅作为 React 树之外的兜底 + modal: { confirm: (config) => Modal.confirm(config) }, + notification: staticNotification, +} + +let currentApi = fallbackApi + +export function setFeedbackApi(api) { + currentApi = api ? { ...fallbackApi, ...api } : fallbackApi +} + +export function getFeedbackApi() { + return currentApi +} + +export default getFeedbackApi diff --git a/frontend/src/components/InfoPanel/InfoPanel.css b/frontend/src/components/InfoPanel/InfoPanel.css deleted file mode 100644 index 8d2a14f..0000000 --- a/frontend/src/components/InfoPanel/InfoPanel.css +++ /dev/null @@ -1,94 +0,0 @@ -/* 信息面板 */ -.info-panel { - padding: 0; - background: var(--card-bg); -} - -/* 信息区域容器 */ -.info-panel > :global(.ant-row) { - padding: 24px; - background: var(--card-bg); - border-bottom: 1px solid var(--border-color); -} - -.info-panel-item { - display: flex; - flex-direction: column; - gap: 5px; - padding: 10px 0; - border-bottom: 1px solid var(--border-color); - transition: all 0.2s ease; - position: relative; -} - -.info-panel-item:last-child { - border-bottom: none; -} - -/* 添加左侧装饰条 */ -.info-panel-item::before { - content: ''; - position: absolute; - left: 0; - top: 50%; - transform: translateY(-50%); - width: 0; - height: 0; - background: linear-gradient(180deg, #1677ff 0%, #4096ff 100%); - border-radius: 2px; - transition: all 0.3s ease; -} - -.info-panel-item:hover { - background: linear-gradient(90deg, #f0f7ff 0%, transparent 100%); - padding-left: 10px; - padding-right: 16px; - margin-left: -12px; - margin-right: -16px; - border-radius: 8px; - border-bottom-color: transparent; -} - -.info-panel-item:hover::before { - width: 3px; - height: 60%; -} - -.info-panel-label { - color: rgba(0, 0, 0, 0.45); - font-size: 13px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 1px; - margin-bottom: 4px; -} - -.info-panel-value { - color: rgba(0, 0, 0, 0.88); - font-size: 15px; - font-weight: 500; - word-break: break-all; - line-height: 1.6; -} - -/* 操作按钮区 */ -.info-panel-actions { - padding: 24px 32px; - background: linear-gradient(to bottom, #fafafa 0%, #f5f5f5 100%); - border-top: 2px solid var(--border-color); - position: relative; -} - -/* 操作区域顶部装饰线 */ -.info-panel-actions::before { - content: ''; - position: absolute; - top: -2px; - left: 0; - right: 0; - height: 2px; - background: linear-gradient(90deg, #1677ff 0%, transparent 50%, #1677ff 100%); - opacity: 0.3; -} - - diff --git a/frontend/src/components/InfoPanel/InfoPanel.jsx b/frontend/src/components/InfoPanel/InfoPanel.jsx deleted file mode 100644 index c60f2c9..0000000 --- a/frontend/src/components/InfoPanel/InfoPanel.jsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Row, Col, Space, Button } from 'antd' -import './InfoPanel.css' - -/** - * 信息展示面板组件 - * @param {Object} props - * @param {Object} props.data - 数据源 - * @param {Array} props.fields - 字段配置数组 - * @param {Array} props.actions - 操作按钮配置(可选) - * @param {Array} props.gutter - Grid间距配置 - */ -function InfoPanel({ data, fields = [], actions = [], gutter = [24, 16] }) { - if (!data) { - return null - } - - return ( -
- - {fields.map((field) => { - const value = data[field.key] - const displayValue = field.render ? field.render(value, data) : value - - return ( - -
-
{field.label}
-
{displayValue}
-
- - ) - })} -
- - {/* 可选的操作按钮区 */} - {actions && actions.length > 0 && ( -
- - {actions.map((action) => ( - - ))} - -
- )} -
- ) -} - -export default InfoPanel diff --git a/frontend/src/components/LargeMarkdownViewer/LargeMarkdownEditor.jsx b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownEditor.jsx index c7134b1..3aab535 100644 --- a/frontend/src/components/LargeMarkdownViewer/LargeMarkdownEditor.jsx +++ b/frontend/src/components/LargeMarkdownViewer/LargeMarkdownEditor.jsx @@ -13,6 +13,8 @@ import './LargeMarkdownEditor.css' // 复用 ByteMD 底层的 codemirror-ssr,为超大文档提供带 Markdown 源码着色的纯文本编辑器。 // 不引入新依赖,CodeMirror 5 的视口渲染让超大文档编辑保持流畅。 +// 注意:codemirror-ssr 导出的扩展注册函数恰好以 use 开头,它们不是 React Hook +/* eslint-disable react-hooks/rules-of-hooks */ function createCodeMirror() { const codemirror = factory() usePlaceholder(codemirror) @@ -21,6 +23,7 @@ function createCodeMirror() { useMarkdown(codemirror) useGfm(codemirror) useContinuelist(codemirror) +/* eslint-enable react-hooks/rules-of-hooks */ return codemirror } diff --git a/frontend/src/components/MainLayout/AppHeader.css b/frontend/src/components/MainLayout/AppHeader.css index 9651e70..3bb3942 100644 --- a/frontend/src/components/MainLayout/AppHeader.css +++ b/frontend/src/components/MainLayout/AppHeader.css @@ -39,7 +39,7 @@ } .trigger:hover { - color: #1677ff; + color: var(--color-primary); background: rgba(22, 119, 255, 0.08); } @@ -159,7 +159,7 @@ body.dark .workspace-brand-icon { } .notification-item.unread { - background: #e6f7ff; + background: var(--color-primary-bg); } /* Dark mode adjustment for unread */ @@ -168,7 +168,7 @@ body.dark .notification-item.unread { } .notification-item.unread:hover { - background: #bae7ff; + background: var(--color-primary-border); } body.dark .notification-item.unread:hover { diff --git a/frontend/src/components/MainLayout/AppHeader.jsx b/frontend/src/components/MainLayout/AppHeader.jsx index 814c68c..e9cd9e8 100644 --- a/frontend/src/components/MainLayout/AppHeader.jsx +++ b/frontend/src/components/MainLayout/AppHeader.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Layout, Badge, Avatar, Dropdown, Space, Popover, List, Tabs, Button, Empty, Typography, Segmented, Tooltip } from 'antd' +import { Layout, Badge, Avatar, Space, Popover, List, Button, Empty, Typography } from 'antd' import { useNavigate } from 'react-router-dom' import { MenuFoldOutlined, @@ -10,12 +10,12 @@ import { NotificationOutlined, MoonOutlined, SunOutlined, - AppstoreOutlined + AppstoreOutlined, } from '@ant-design/icons' import useUserStore from '@/stores/userStore' import useNotificationStore from '@/stores/notificationStore' import useThemeStore from '@/stores/themeStore' -import { getNotifications, getUnreadCount, markAsRead, markAllAsRead } from '@/api/notification' +import { getNotifications, markAsRead, markAllAsRead } from '@/api/notification' import Toast from '@/components/Toast/Toast' import './AppHeader.css' @@ -31,7 +31,6 @@ function AppHeader({ collapsed, onToggle, showLogo = true }) { const [notifications, setNotifications] = useState([]) const [loading, setLoading] = useState(false) const [popoverVisible, setPopoverVisible] = useState(false) - const [lang, setLang] = useState('zh') useEffect(() => { if (user) { @@ -86,9 +85,9 @@ function AppHeader({ collapsed, onToggle, showLogo = true }) { const getCategoryIcon = (category) => { switch (category) { - case 'project': return - case 'collaboration': return - default: return + case 'project': return + case 'collaboration': return + default: return } } @@ -142,7 +141,7 @@ function AppHeader({ collapsed, onToggle, showLogo = true }) { {/* Logo 区域 */}
logo -

NexDocus

+

NexDocus

{/* 折叠按钮 */} @@ -167,18 +166,7 @@ function AppHeader({ collapsed, onToggle, showLogo = true }) { {isDarkMode ? : }
- {/* 2. 语言切换 */} - - - {/* 3. 消息通知 */} + {/* 2. 消息通知 */} { } } - if (item.path === '/knowledge' || item.path === '/knowledge/my' || item.menu_name === '我的知识库') { + if ( + item.path === '/chat' || + item.path === '/knowledge' || + item.path === '/knowledge/my' || + item.menu_name === '我的知识库' + ) { return { path: '/chat', icon: 'CommentOutlined', diff --git a/frontend/src/components/MainLayout/index.js b/frontend/src/components/MainLayout/index.js deleted file mode 100644 index 12555fd..0000000 --- a/frontend/src/components/MainLayout/index.js +++ /dev/null @@ -1,4 +0,0 @@ -export { default } from './MainLayout' -export { default as MainLayout } from './MainLayout' -export { default as AppSider } from './AppSider' -export { default as AppHeader } from './AppHeader' diff --git a/frontend/src/components/MarkdownViewer/MarkdownViewer.jsx b/frontend/src/components/MarkdownViewer/MarkdownViewer.jsx deleted file mode 100644 index e9779d7..0000000 --- a/frontend/src/components/MarkdownViewer/MarkdownViewer.jsx +++ /dev/null @@ -1,48 +0,0 @@ -import { forwardRef, useMemo } from 'react' -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' -import rehypeRaw from 'rehype-raw' -import rehypeSlug from 'rehype-slug' -import rehypeHighlight from 'rehype-highlight' - -const MarkdownViewer = forwardRef(function MarkdownViewer( - { - content = '', - components, - onClick, - className = '', - allowRaw = false, - enableHighlight = true, - }, - ref -) { - const rehypePlugins = useMemo(() => { - const plugins = [] - if (allowRaw) { - plugins.push(rehypeRaw) - } - plugins.push(rehypeSlug) - if (enableHighlight) { - plugins.push(rehypeHighlight) - } - return plugins - }, [allowRaw, enableHighlight]) - - return ( -
- - {content} - -
- ) -}) - -export default MarkdownViewer diff --git a/frontend/src/components/ModernSidebar/ModernSidebar.css b/frontend/src/components/ModernSidebar/ModernSidebar.css index de1cd8d..4240fd6 100644 --- a/frontend/src/components/ModernSidebar/ModernSidebar.css +++ b/frontend/src/components/ModernSidebar/ModernSidebar.css @@ -51,7 +51,7 @@ } .sidebar-collapse-trigger:hover { - color: #1677ff; + color: var(--color-primary); background: rgba(22, 119, 255, 0.08); } diff --git a/frontend/src/components/ModernSidebar/ModernSidebar.jsx b/frontend/src/components/ModernSidebar/ModernSidebar.jsx index 93580fa..d8be0cf 100644 --- a/frontend/src/components/ModernSidebar/ModernSidebar.jsx +++ b/frontend/src/components/ModernSidebar/ModernSidebar.jsx @@ -1,4 +1,3 @@ -import React from 'react'; import { Layout, Avatar, Tooltip } from 'antd'; import { LogoutOutlined, @@ -139,7 +138,7 @@ const ModernSidebar = ({ {user?.name?.[0]?.toUpperCase() || 'U'} diff --git a/frontend/src/components/PDFViewer/PDFViewer.css b/frontend/src/components/PDFViewer/PDFViewer.css deleted file mode 100644 index 7344f71..0000000 --- a/frontend/src/components/PDFViewer/PDFViewer.css +++ /dev/null @@ -1,62 +0,0 @@ -.pdf-viewer-container { - display: flex; - flex-direction: column; - height: 100%; - width: 100%; - background: var(--bg-color-secondary); - flex: 1; - min-height: 0; -} - -.pdf-toolbar { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 16px; - background: var(--card-bg); - border-bottom: 1px solid var(--border-color); - flex-shrink: 0; -} - -.pdf-content { - flex: 1; - overflow: auto; - display: flex; - justify-content: center; - align-items: flex-start; - padding: 20px; -} - -.pdf-content .react-pdf__Document { - display: flex; - justify-content: center; -} - -.pdf-content .react-pdf__Page { - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); - margin-bottom: 20px; - background: #fff; -} - -.pdf-content .react-pdf__Page canvas { - max-width: 100%; - height: auto !important; -} - -.pdf-loading { - display: flex; - justify-content: center; - align-items: center; - min-height: 200px; - color: #999; - font-size: 14px; -} - -.pdf-error { - display: flex; - justify-content: center; - align-items: center; - min-height: 200px; - color: #f5222d; - font-size: 14px; -} diff --git a/frontend/src/components/PDFViewer/PDFViewer.jsx b/frontend/src/components/PDFViewer/PDFViewer.jsx deleted file mode 100644 index 8160fd7..0000000 --- a/frontend/src/components/PDFViewer/PDFViewer.jsx +++ /dev/null @@ -1,138 +0,0 @@ -import { useState, useMemo } from 'react' -import { Document, Page, pdfjs } from 'react-pdf' -import { Button, Space, InputNumber, Spin } from 'antd' -import Toast from '@/components/Toast/Toast' -import { - LeftOutlined, - RightOutlined, - ZoomInOutlined, - ZoomOutOutlined, -} from '@ant-design/icons' -import 'react-pdf/dist/Page/AnnotationLayer.css' -import 'react-pdf/dist/Page/TextLayer.css' -import './PDFViewer.css' - -// 配置 PDF.js worker - 使用本地文件 -pdfjs.GlobalWorkerOptions.workerSrc = '/pdf-worker/pdf.worker.min.mjs' - -function PDFViewer({ url, filename }) { - const [numPages, setNumPages] = useState(null) - const [pageNumber, setPageNumber] = useState(1) - const [scale, setScale] = useState(1.0) - - // 使用 useMemo 避免不必要的重新加载 - const fileConfig = useMemo(() => ({ url }), [url]) - - const onDocumentLoadSuccess = ({ numPages }) => { - setNumPages(numPages) - setPageNumber(1) - } - - const onDocumentLoadError = (error) => { - Toast.error('PDF文件加载失败') - } - - const goToPrevPage = () => { - setPageNumber((prev) => Math.max(prev - 1, 1)) - } - - const goToNextPage = () => { - setPageNumber((prev) => Math.min(prev + 1, numPages)) - } - - const zoomIn = () => { - setScale((prev) => Math.min(prev + 0.2, 3.0)) - } - - const zoomOut = () => { - setScale((prev) => Math.max(prev - 0.2, 0.5)) - } - - const handlePageChange = (value) => { - if (value >= 1 && value <= numPages) { - setPageNumber(value) - } - } - - return ( -
- {/* 工具栏 */} -
- - - - - - - - - - - - - {Math.round(scale * 100)}% - - - -
- - {/* PDF内容区 */} -
- - -
正在加载PDF...
-
- } - error={
PDF加载失败,请稍后重试
} - > - - -
正在渲染页面...
-
- } - /> - - - - ) -} - -export default PDFViewer diff --git a/frontend/src/components/PDFViewer/VirtualPDFViewer.css b/frontend/src/components/PDFViewer/VirtualPDFViewer.css index 61fec14..a4fd590 100644 --- a/frontend/src/components/PDFViewer/VirtualPDFViewer.css +++ b/frontend/src/components/PDFViewer/VirtualPDFViewer.css @@ -125,7 +125,7 @@ } .pdf-page-error p { - color: #ff4d4f; + color: var(--color-danger); font-size: 14px; } @@ -145,7 +145,7 @@ align-items: center; height: 100%; min-height: 400px; - color: #ff4d4f; + color: var(--color-danger); font-size: 16px; } diff --git a/frontend/src/components/PageHeader/PageHeader.css b/frontend/src/components/PageHeader/PageHeader.css index 6ba9202..939eef2 100644 --- a/frontend/src/components/PageHeader/PageHeader.css +++ b/frontend/src/components/PageHeader/PageHeader.css @@ -1,5 +1,5 @@ .page-header-standard { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: var(--color-primary-gradient); border-radius: 12px; padding: 24px 28px; margin-bottom: 24px; diff --git a/frontend/src/components/PageLoading/PageLoading.css b/frontend/src/components/PageLoading/PageLoading.css new file mode 100644 index 0000000..8ef4cea --- /dev/null +++ b/frontend/src/components/PageLoading/PageLoading.css @@ -0,0 +1,20 @@ +.page-loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 320px; + width: 100%; + padding: var(--space-xl) 0; +} + +.page-loading-inner { + width: min(520px, 100%); +} + +.page-loading-placeholder { + min-height: 96px; +} + +.page-loading-skeleton.ant-skeleton { + margin-top: var(--space-lg); +} diff --git a/frontend/src/components/PageLoading/PageLoading.jsx b/frontend/src/components/PageLoading/PageLoading.jsx new file mode 100644 index 0000000..a980186 --- /dev/null +++ b/frontend/src/components/PageLoading/PageLoading.jsx @@ -0,0 +1,21 @@ +import { Skeleton, Spin } from 'antd' +import './PageLoading.css' + +/** + * 页面级加载态:路由懒加载(Suspense fallback)与整页数据加载共用, + * 保证所有页面的"加载中"表现一致。 + */ +export default function PageLoading({ tip = '加载中...', spinning = true, children }) { + if (!spinning) return children ?? null + + return ( +
+
+ +
+ + +
+
+ ) +} diff --git a/frontend/src/components/PageTitleBar/PageTitleBar.css b/frontend/src/components/PageTitleBar/PageTitleBar.css deleted file mode 100644 index 619c864..0000000 --- a/frontend/src/components/PageTitleBar/PageTitleBar.css +++ /dev/null @@ -1,187 +0,0 @@ -.page-title-bar { - background: linear-gradient(135deg, #e0e7ff 0%, #f3e8ff 100%); - border-radius: 12px; - padding: 16px 24px; - margin-bottom: 16px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); - position: relative; - overflow: hidden; - border: 1px solid rgba(139, 92, 246, 0.1); -} - -.page-title-bar::before { - content: ''; - position: absolute; - top: -50%; - right: -5%; - width: 200px; - height: 200px; - background: rgba(139, 92, 246, 0.05); - border-radius: 50%; -} - -.title-bar-content { - position: relative; - z-index: 1; - display: flex; - justify-content: space-between; - align-items: center; -} - -.title-bar-left { - flex: 1; -} - -.title-row { - display: flex; - align-items: center; - gap: 16px; -} - -.title-group { - display: flex; - align-items: center; - gap: 12px; -} - -.page-title { - font-size: 20px; - font-weight: 600; - color: #1e293b; - margin: 0; - letter-spacing: 0.3px; -} - -.title-badge { - background: rgba(139, 92, 246, 0.15); - color: #7c3aed; - padding: 2px 10px; - border-radius: 10px; - font-size: 12px; - font-weight: 500; -} - -.page-description { - font-size: 13px; - color: #64748b; - margin: 0; - white-space: nowrap; -} - -.title-bar-right { - display: flex; - align-items: center; - gap: 12px; -} - -.title-actions { - display: flex; - gap: 10px; -} - -.title-actions button { - padding: 8px 16px; - border-radius: 6px; - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: all 0.3s; - border: none; - outline: none; -} - -.title-actions button.primary { - background: #7c3aed; - color: #ffffff; -} - -.title-actions button.primary:hover { - background: #6d28d9; - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(124, 58, 237, 0.25); -} - -.title-actions button.secondary { - background: rgba(139, 92, 246, 0.1); - color: #7c3aed; - border: 1px solid rgba(139, 92, 246, 0.2); -} - -.title-actions button.secondary:hover { - background: rgba(139, 92, 246, 0.15); - transform: translateY(-1px); -} - -.toggle-button { - width: 32px; - height: 32px; - border-radius: 6px; - background: rgba(139, 92, 246, 0.1); - border: 1px solid rgba(139, 92, 246, 0.2); - color: #7c3aed; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.3s; - font-size: 14px; -} - -.toggle-button:hover { - background: rgba(139, 92, 246, 0.2); - transform: translateY(-1px); -} - -/* 扩展内容区域 */ -.title-bar-expanded-content { - position: relative; - z-index: 1; - margin-top: 8px; - padding: 8px; - background: var(--card-bg); - border: 1px solid rgba(139, 92, 246, 0.1); - animation: expandContent 0.3s ease-out; -} - -@keyframes expandContent { - from { - opacity: 0; - transform: translateY(-10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -/* 响应式适配 */ -@media (max-width: 768px) { - .title-bar-content { - flex-direction: column; - align-items: flex-start; - gap: 12px; - } - - .title-row { - flex-direction: column; - align-items: flex-start; - gap: 4px; - } - - .page-description { - white-space: normal; - } - - .title-bar-right { - width: 100%; - justify-content: space-between; - } - - .title-actions { - flex: 1; - } - - .title-actions button { - flex: 1; - } -} diff --git a/frontend/src/components/PageTitleBar/PageTitleBar.jsx b/frontend/src/components/PageTitleBar/PageTitleBar.jsx deleted file mode 100644 index a023b8f..0000000 --- a/frontend/src/components/PageTitleBar/PageTitleBar.jsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useState } from 'react' -import { UpOutlined, DownOutlined } from '@ant-design/icons' -import './PageTitleBar.css' - -function PageTitleBar({ - title, - badge, - description, - actions, - showToggle = false, - onToggle, - defaultExpanded = false, -}) { - const [expanded, setExpanded] = useState(defaultExpanded) - - const handleToggle = () => { - const newExpanded = !expanded - setExpanded(newExpanded) - if (onToggle) { - onToggle(newExpanded) - } - } - - return ( -
-
-
-
-
-

{title}

- {badge && {badge}} -
- {description &&

{description}

} -
-
-
- {actions &&
{actions}
} - {showToggle && ( - - )} -
-
-
- ) -} - -export default PageTitleBar diff --git a/frontend/src/components/ProjectFileTree/ProjectFileTree.jsx b/frontend/src/components/ProjectFileTree/ProjectFileTree.jsx index c31adef..e097f08 100644 --- a/frontend/src/components/ProjectFileTree/ProjectFileTree.jsx +++ b/frontend/src/components/ProjectFileTree/ProjectFileTree.jsx @@ -8,6 +8,7 @@ import { FileOutlined, ShareAltOutlined, } from '@ant-design/icons' +import { collectParentKeys, findNodeByKey } from '@/utils/treeUtils' import './ProjectFileTree.css' /** @@ -37,30 +38,6 @@ import './ProjectFileTree.css' * - className: 追加到菜单根节点的 class */ -// 按 key 查找树节点 -const findNodeByKey = (nodes, key) => { - for (const node of nodes) { - if (node.key === key) return node - if (node.children && node.children.length) { - const found = findNodeByKey(node.children, key) - if (found) return found - } - } - return null -} - -// 计算路径的所有父级目录(不含自身),如 "a/b/c" -> ["a", "a/b"] -const getAncestorPaths = (path) => { - const parts = path.split('/') - const ancestors = [] - let current = '' - for (let i = 0; i < parts.length - 1; i++) { - current = current ? current + '/' + parts[i] : parts[i] - ancestors.push(current) - } - return ancestors -} - // 仅当文本被省略(显示不全)时才显示 Tooltip,避免冗余提示 function OverflowTooltip({ title, className, children }) { const textRef = useRef(null) @@ -173,7 +150,7 @@ export default function ProjectFileTree({ if (collapseOnSelect) { const node = findNodeByKey(treeData, selectedKey) if (node && !node.isLeaf) { - const ancestors = getAncestorPaths(selectedKey) + const ancestors = collectParentKeys(selectedKey) const next = [...ancestors] // 保留选中文件夹自身的展开(如从文件夹视图下钻时 expandSelf) if (openKeys.includes(selectedKey)) next.push(selectedKey) diff --git a/frontend/src/components/SelectionAlert/SelectionAlert.css b/frontend/src/components/SelectionAlert/SelectionAlert.css deleted file mode 100644 index bfd2069..0000000 --- a/frontend/src/components/SelectionAlert/SelectionAlert.css +++ /dev/null @@ -1,49 +0,0 @@ -.selection-alert-container { - margin-bottom: 16px; -} - -.selection-alert-content { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; -} - -.selection-alert-content span { - flex: 1; - color: rgba(0, 0, 0, 0.85); -} - -.selection-alert-content strong { - color: #1677ff; - font-weight: 600; - margin: 0 4px; -} - -.selection-alert-content a { - color: #1677ff; - cursor: pointer; - white-space: nowrap; - transition: all 0.2s; - text-decoration: none; - padding: 0 8px; - border-radius: 4px; -} - -.selection-alert-content a:hover { - background: rgba(22, 119, 255, 0.08); - text-decoration: underline; -} - -/* 响应式处理 */ -@media (max-width: 768px) { - .selection-alert-content { - flex-direction: column; - align-items: flex-start; - gap: 8px; - } - - .selection-alert-content a { - padding: 4px 8px; - } -} diff --git a/frontend/src/components/SelectionAlert/SelectionAlert.jsx b/frontend/src/components/SelectionAlert/SelectionAlert.jsx deleted file mode 100644 index 82502cd..0000000 --- a/frontend/src/components/SelectionAlert/SelectionAlert.jsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Alert } from 'antd' -import './SelectionAlert.css' - -/** - * 全选提示条组件 - * @param {Object} props - * @param {number} props.currentPageCount - 当前页选中数量 - * @param {number} props.totalCount - 总数据量 - * @param {boolean} props.isAllPagesSelected - 是否已选择所有页 - * @param {Function} props.onSelectAllPages - 选择所有页的回调 - * @param {Function} props.onClearSelection - 清除选择的回调 - */ -function SelectionAlert({ - currentPageCount, - totalCount, - isAllPagesSelected, - onSelectAllPages, - onClearSelection, -}) { - // 如果没有选中任何项,不显示 - if (currentPageCount === 0) { - return null - } - - // 如果已选择所有页 - if (isAllPagesSelected) { - return ( -
- - - 已选择全部 {totalCount} 条数据 - - 清除选择 -
- } - type="info" - showIcon - closable={false} - /> -
- ) - } - - // 如果只选择了当前页,且总数大于当前页 - if (currentPageCount > 0 && totalCount > currentPageCount) { - return ( -
- - - 已选择当前页 {currentPageCount} 条数据 - - - 选择全部 {totalCount} 条数据 - -
- } - type="warning" - showIcon - closable={false} - /> - - ) - } - - // 只选择了部分数据,且总数等于当前页(单页情况) - return ( -
- - - 已选择 {currentPageCount} 条数据 - - 清除选择 -
- } - type="info" - showIcon - closable={false} - /> - - ) -} - -export default SelectionAlert diff --git a/frontend/src/components/SideInfoPanel/SideInfoPanel.css b/frontend/src/components/SideInfoPanel/SideInfoPanel.css deleted file mode 100644 index aec0f7b..0000000 --- a/frontend/src/components/SideInfoPanel/SideInfoPanel.css +++ /dev/null @@ -1,88 +0,0 @@ -/* 侧边信息面板容器 */ -.side-info-panel { - display: flex; - flex-direction: column; - gap: 16px; -} - -/* 信息区块 */ -.side-info-section { - background: var(--card-bg); - border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); - overflow: hidden; - transition: all 0.3s ease; -} - -.side-info-section:hover { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); -} - -/* 区块头部 */ -.side-info-section-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px 20px; - background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%); - border-bottom: 1px solid var(--border-color); - cursor: pointer; - user-select: none; - transition: background 0.2s ease; -} - -.side-info-section-header:hover { - background: linear-gradient(135deg, #f0f4ff 0%, #e8f0ff 100%); -} - -.side-info-section-title { - display: flex; - align-items: center; - gap: 8px; - font-size: 14px; - font-weight: 600; - color: rgba(0, 0, 0, 0.88); -} - -.side-info-section-icon { - display: flex; - align-items: center; - font-size: 16px; - color: #1677ff; -} - -.side-info-section-toggle { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border: none; - background: transparent; - color: #8c8c8c; - cursor: pointer; - transition: all 0.2s ease; - border-radius: 4px; -} - -.side-info-section-toggle:hover { - background: rgba(0, 0, 0, 0.06); - color: #1677ff; -} - -/* 区块内容 */ -.side-info-section-content { - padding: 16px 20px; - animation: expandContent 0.3s ease-out; -} - -@keyframes expandContent { - from { - opacity: 0; - transform: translateY(-10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} diff --git a/frontend/src/components/SideInfoPanel/SideInfoPanel.jsx b/frontend/src/components/SideInfoPanel/SideInfoPanel.jsx deleted file mode 100644 index b08e4bc..0000000 --- a/frontend/src/components/SideInfoPanel/SideInfoPanel.jsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useState } from 'react' -import { UpOutlined, DownOutlined } from '@ant-design/icons' -import './SideInfoPanel.css' - -/** - * 侧边信息面板组件 - * @param {Object} props - * @param {Array} props.sections - 信息区块配置数组 - * @param {string} props.className - 自定义类名 - */ -function SideInfoPanel({ sections = [], className = '' }) { - const [collapsedSections, setCollapsedSections] = useState(() => { - const initial = {} - sections.forEach((section) => { - if (section.defaultCollapsed) { - initial[section.key] = true - } - }) - return initial - }) - - const toggleSection = (key) => { - setCollapsedSections((prev) => ({ - ...prev, - [key]: !prev[key], - })) - } - - return ( -
- {sections.map((section) => { - const isCollapsed = collapsedSections[section.key] - - return ( -
- {/* 区块头部 */} -
toggleSection(section.key)}> -
- {section.icon && {section.icon}} - {section.title} -
- -
- - {/* 区块内容 */} - {!isCollapsed && ( -
{section.content}
- )} -
- ) - })} -
- ) -} - -export default SideInfoPanel diff --git a/frontend/src/components/SplitLayout/SplitLayout.css b/frontend/src/components/SplitLayout/SplitLayout.css deleted file mode 100644 index 18e7fc2..0000000 --- a/frontend/src/components/SplitLayout/SplitLayout.css +++ /dev/null @@ -1,72 +0,0 @@ -/* 分栏布局容器 */ -.split-layout { - display: flex; - width: 100%; - align-items: flex-start; -} - -/* 横向布局(左右分栏) */ -.split-layout-horizontal { - flex-direction: row; -} - -/* 纵向布局(上下分栏) */ -.split-layout-vertical { - flex-direction: column; -} - -/* 主内容区 */ -.split-layout-main { - flex: 1; - min-width: 0; - width: 100%; - display: flex; - flex-direction: column; -} - -/* 扩展信息区 */ -.split-layout-extend { - flex-shrink: 0; - background: var(--card-bg); -} - -/* 右侧扩展区(横向布局) */ -.split-layout-extend-right { - height: 693px; - overflow-y: auto; - overflow-x: hidden; - position: sticky; - top: 16px; - padding-right: 4px; -} - -/* 顶部扩展区(纵向布局) */ -.split-layout-extend-top { - width: 100%; -} - -/* 滚动条样式(横向布局右侧扩展区) */ -.split-layout-extend-right::-webkit-scrollbar { - width: 6px; -} - -.split-layout-extend-right::-webkit-scrollbar-track { - background: var(--bg-color-secondary); - border-radius: 3px; -} - -.split-layout-extend-right::-webkit-scrollbar-thumb { - background: var(--border-color); - border-radius: 3px; -} - -.split-layout-extend-right::-webkit-scrollbar-thumb:hover { - background: var(--border-color-strong); -} - -/* 响应式:小屏幕时隐藏右侧扩展区 */ -@media (max-width: 1200px) { - .split-layout-extend-right { - display: none; - } -} diff --git a/frontend/src/components/SplitLayout/SplitLayout.jsx b/frontend/src/components/SplitLayout/SplitLayout.jsx deleted file mode 100644 index b54a2f7..0000000 --- a/frontend/src/components/SplitLayout/SplitLayout.jsx +++ /dev/null @@ -1,69 +0,0 @@ -import './SplitLayout.css' - -/** - * 主内容区布局组件 - * @param {Object} props - * @param {string} props.direction - 布局方向:'horizontal'(左右)| 'vertical'(上下) - * @param {ReactNode} props.mainContent - 主内容区 - * @param {ReactNode} props.extendContent - 扩展内容区 - * @param {number} props.extendSize - 扩展区尺寸(horizontal 模式下为宽度,px) - * @param {number} props.gap - 主内容与扩展区间距(px) - * @param {boolean} props.showExtend - 是否显示扩展区 - * @param {string} props.extendPosition - 扩展区位置(horizontal: 'right', vertical: 'top') - * @param {string} props.className - 自定义类名 - * - * @deprecated 旧参数(向后兼容):leftContent, rightContent, rightWidth, showRight - */ -function SplitLayout({ - // 新 API - direction = 'horizontal', - mainContent, - extendContent, - extendSize = 360, - gap = 16, - showExtend = true, - extendPosition, - className = '', - // 旧 API(向后兼容) - leftContent, - rightContent, - rightWidth, - showRight, -}) { - // 向后兼容:如果使用旧 API,转换为新 API - const actualMainContent = mainContent || leftContent - const actualExtendContent = extendContent || rightContent - const actualExtendSize = extendSize !== 360 ? extendSize : (rightWidth || 360) - const actualShowExtend = showExtend !== undefined ? showExtend : (showRight !== undefined ? showRight : true) - const actualDirection = direction - const actualExtendPosition = extendPosition || (actualDirection === 'horizontal' ? 'right' : 'top') - - return ( -
- {/* 纵向布局且扩展区在顶部时,先渲染扩展区 */} - {actualDirection === 'vertical' && actualExtendPosition === 'top' && actualShowExtend && actualExtendContent && ( -
- {actualExtendContent} -
- )} - - {/* 主内容区 */} -
{actualMainContent}
- - {/* 横向布局时,扩展区在右侧 */} - {actualDirection === 'horizontal' && actualShowExtend && actualExtendContent && ( -
- {actualExtendContent} -
- )} -
- ) -} - -export default SplitLayout diff --git a/frontend/src/components/StatCard/StatCard.css b/frontend/src/components/StatCard/StatCard.css index 9b62930..87c78c1 100644 --- a/frontend/src/components/StatCard/StatCard.css +++ b/frontend/src/components/StatCard/StatCard.css @@ -94,12 +94,12 @@ } .stat-card-trend.trend-up { - color: #52c41a; + color: var(--color-success); background: rgba(82, 196, 26, 0.12); } .stat-card-trend.trend-down { - color: #ff4d4f; + color: var(--color-danger); background: rgba(255, 77, 79, 0.12); } diff --git a/frontend/src/components/StatCard/StatCard.jsx b/frontend/src/components/StatCard/StatCard.jsx index e333c5c..af6c9e7 100644 --- a/frontend/src/components/StatCard/StatCard.jsx +++ b/frontend/src/components/StatCard/StatCard.jsx @@ -30,10 +30,10 @@ function StatCard({ style: customStyle = {}, }) { const colorMap = { - blue: '#1677ff', - green: '#52c41a', - orange: '#faad14', - red: '#ff4d4f', + blue: 'var(--color-primary)', + green: 'var(--color-success)', + orange: 'var(--color-warning)', + red: 'var(--color-danger)', purple: '#722ed1', gray: '#8c8c8c', } diff --git a/frontend/src/components/Toast/Toast.jsx b/frontend/src/components/Toast/Toast.jsx index d329d67..ca1252f 100644 --- a/frontend/src/components/Toast/Toast.jsx +++ b/frontend/src/components/Toast/Toast.jsx @@ -1,18 +1,18 @@ -import { notification } from 'antd' import { CheckCircleOutlined, CloseCircleOutlined, ExclamationCircleOutlined, InfoCircleOutlined, } from '@ant-design/icons' +import { getFeedbackApi } from '@/components/Feedback/feedbackApi' -// 配置全局通知位置和样式 -notification.config({ - placement: 'topRight', - top: 24, - duration: 3, - maxCount: 3, -}) +/** + * 标准反馈组件(全站唯一入口) + * + * - 通知统一从右上角滑出,默认 3 秒后消失(配置见 App.jsx 的 ) + * - 内部走 AntD App 上下文实例,因此自动继承明暗主题与圆角等设计规范 + * - 二次确认统一用 Toast.confirm(),不要再用 Modal.confirm 静态方法 + */ let muteErrorsUntil = 0 @@ -20,113 +20,75 @@ function shouldMuteError() { return Date.now() < muteErrorsUntil } -/** - * 标准通知反馈组件 - * 从右上角滑出,默认3秒后消失 - */ +const notificationStyle = { + borderRadius: 'var(--radius-md)', + boxShadow: 'var(--shadow-3)', +} + +const ICONS = { + success: , + error: , + warning: , + info: , +} + +const open = (type, message, description, duration) => { + getFeedbackApi().notification[type]({ + message, + description, + duration, + icon: ICONS[type], + style: notificationStyle, + }) +} + const Toast = { + /** 短时间内抑制错误/警告提示,用于批量操作失败刷屏的场景 */ muteErrors: (durationMs = 2000) => { muteErrorsUntil = Date.now() + durationMs }, - /** - * 成功通知 - * @param {string} message - 消息内容 - * @param {string} description - 详细描述(可选) - * @param {number} duration - 显示时长(秒),默认3秒 - */ - success: (message, description = '', duration = 3) => { - notification.success({ - message, - description, - duration, - icon: , - style: { - borderRadius: '8px', - boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', - }, - }) - }, + /** 成功通知 @param {string} message @param {string} [description] @param {number} [duration] 秒 */ + success: (message, description = '', duration = 3) => open('success', message, description, duration), - /** - * 错误通知 - * @param {string} message - 消息内容 - * @param {string} description - 详细描述(可选) - * @param {number} duration - 显示时长(秒),默认3秒 - */ + /** 错误通知(默认受 muteErrors 抑制) */ error: (message, description = '', duration = 3, options = {}) => { - if (!options.force && shouldMuteError()) { - return - } - - notification.error({ - message, - description, - duration, - icon: , - style: { - borderRadius: '8px', - boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', - }, - }) + if (!options.force && shouldMuteError()) return + open('error', message, description, duration) }, - /** - * 警告通知 - * @param {string} message - 消息内容 - * @param {string} description - 详细描述(可选) - * @param {number} duration - 显示时长(秒),默认3秒 - */ + /** 警告通知(默认受 muteErrors 抑制) */ warning: (message, description = '', duration = 3, options = {}) => { - if (!options.force && shouldMuteError()) { - return - } - - notification.warning({ - message, - description, - duration, - icon: , - style: { - borderRadius: '8px', - boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', - }, - }) + if (!options.force && shouldMuteError()) return + open('warning', message, description, duration) }, - /** - * 信息通知 - * @param {string} message - 消息内容 - * @param {string} description - 详细描述(可选) - * @param {number} duration - 显示时长(秒),默认3秒 - */ - info: (message, description = '', duration = 3) => { - notification.info({ - message, - description, - duration, - icon: , - style: { - borderRadius: '8px', - boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', - }, - }) - }, + /** 信息通知 */ + info: (message, description = '', duration = 3) => open('info', message, description, duration), - /** - * 自定义通知 - * @param {Object} config - 完整的通知配置对象 - */ + /** 自定义通知(透传 AntD notification 配置) */ custom: (config) => { - notification.open({ + getFeedbackApi().notification.open({ ...config, - style: { - borderRadius: '8px', - boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', - ...config.style, - }, + style: { ...notificationStyle, ...config.style }, }) }, + + /** 轻提示(顶部居中的 message),用于无需用户处理的即时反馈 */ + toast: (content, type = 'info', duration = 2) => { + getFeedbackApi().message[type](content, duration) + }, + + /** + * 二次确认对话框(危险操作请传 okButtonProps={{ danger: true }}) + * @returns AntD ModalConfirmHandler + */ + confirm: (config) => + getFeedbackApi().modal.confirm({ + okText: '确定', + cancelText: '取消', + ...config, + }), } export default Toast diff --git a/frontend/src/components/TreeFilterPanel/TreeFilterPanel.css b/frontend/src/components/TreeFilterPanel/TreeFilterPanel.css deleted file mode 100644 index 463dc92..0000000 --- a/frontend/src/components/TreeFilterPanel/TreeFilterPanel.css +++ /dev/null @@ -1,58 +0,0 @@ -/* 树形筛选面板 */ -.tree-filter-panel { - width: 320px; - max-height: 500px; - overflow-y: auto; -} - -/* 已选择的筛选条件 */ -.tree-filter-selected { - min-height: 40px; - padding: 12px; - background: var(--bg-color-secondary); - border-radius: 6px; - border: 1px dashed var(--border-color-strong); -} - -.tree-filter-tag { - display: flex; - align-items: center; - gap: 8px; -} - -.tree-filter-label { - font-size: 13px; - color: var(--text-color-secondary); - font-weight: 500; -} - -.tree-filter-placeholder { - display: flex; - align-items: center; - justify-content: center; - min-height: 24px; -} - -.tree-filter-placeholder span { - color: #8c8c8c; - font-size: 13px; -} - -/* 树形选择器容器 */ -.tree-filter-container { - max-height: 280px; - overflow-y: auto; -} - -.tree-filter-header { - font-size: 14px; - font-weight: 500; - margin-bottom: 12px; - color: rgba(0, 0, 0, 0.85); -} - -/* 操作按钮 */ -.tree-filter-actions { - display: flex; - justify-content: flex-end; -} diff --git a/frontend/src/components/TreeFilterPanel/TreeFilterPanel.jsx b/frontend/src/components/TreeFilterPanel/TreeFilterPanel.jsx deleted file mode 100644 index 2723c22..0000000 --- a/frontend/src/components/TreeFilterPanel/TreeFilterPanel.jsx +++ /dev/null @@ -1,119 +0,0 @@ -import { Tree, Tag, Divider, Button, Space } from 'antd' -import { useState, useEffect } from 'react' -import './TreeFilterPanel.css' - -/** - * 树形筛选面板组件 - * @param {Object} props - * @param {Array} props.treeData - 树形数据 - * @param {string} props.selectedKey - 当前选中的节点ID - * @param {string} props.tempSelectedKey - 临时选中的节点ID(确认前) - * @param {string} props.treeTitle - 树标题 - * @param {Function} props.onSelect - 选择变化回调 - * @param {Function} props.onConfirm - 确认筛选 - * @param {Function} props.onClear - 清除筛选 - * @param {string} props.placeholder - 占位提示文本 - */ -function TreeFilterPanel({ - treeData, - selectedKey, - tempSelectedKey, - treeTitle = '分组筛选', - onSelect, - onConfirm, - onClear, - placeholder = '请选择分组进行筛选', -}) { - // 获取所有节点的key用于默认展开 - const getAllKeys = (nodes) => { - let keys = [] - const traverse = (node) => { - keys.push(node.key) - if (node.children) { - node.children.forEach(traverse) - } - } - nodes.forEach(traverse) - return keys - } - - const [expandedKeys, setExpandedKeys] = useState([]) - - // 初始化时展开所有节点 - useEffect(() => { - if (treeData && treeData.length > 0) { - setExpandedKeys(getAllKeys(treeData)) - } - }, [treeData]) - - // 查找节点名称 - const findNodeName = (nodes, id) => { - for (const node of nodes) { - if (node.key === id) return node.title - if (node.children) { - const found = findNodeName(node.children, id) - if (found) return found - } - } - return '' - } - - const handleTreeSelect = (selectedKeys) => { - const key = selectedKeys[0] || null - onSelect?.(key) - } - - const handleExpand = (keys) => { - setExpandedKeys(keys) - } - - return ( -
- {/* 已选择的筛选条件 */} -
- {tempSelectedKey ? ( -
- 已选择分组: - onSelect?.(null)}> - {findNodeName(treeData, tempSelectedKey)} - -
- ) : ( -
- {placeholder} -
- )} -
- - - - {/* 树形选择器 */} -
-
{treeTitle}
- -
- - - - {/* 操作按钮 */} -
- - - - -
-
- ) -} - -export default TreeFilterPanel diff --git a/frontend/src/data/docsMenuData.json b/frontend/src/data/docsMenuData.json deleted file mode 100644 index 80a5329..0000000 --- a/frontend/src/data/docsMenuData.json +++ /dev/null @@ -1,105 +0,0 @@ -[ - { - "key": "design", - "label": "设计规范", - "children": [ - { - "key": "design-cookbook", - "label": "设计手册", - "path": "/docs/DESIGN_COOKBOOK.md" - } - ] - }, - { - "key": "layouts", - "label": "布局文档", - "children": [ - { - "key": "main-layout", - "label": "主布局", - "path": "/docs/layouts/main-layout.md" - }, - { - "key": "content-area-layout", - "label": "主内容区布局", - "path": "/docs/layouts/content-area-layout.md" - } - ] - }, - { - "key": "components", - "label": "组件文档", - "children": [ - { - "key": "components-overview", - "label": "组件概览", - "path": "/docs/components/README.md" - }, - { - "key": "page-title-bar", - "label": "PageTitleBar", - "path": "/docs/components/PageTitleBar.md" - }, - { - "key": "list-action-bar", - "label": "ListActionBar", - "path": "/docs/components/ListActionBar.md" - }, - { - "key": "tree-filter-panel", - "label": "TreeFilterPanel", - "path": "/docs/components/TreeFilterPanel.md" - }, - { - "key": "list-table", - "label": "ListTable", - "path": "/docs/components/ListTable.md" - }, - { - "key": "detail-drawer", - "label": "DetailDrawer", - "path": "/docs/components/DetailDrawer.md" - }, - { - "key": "info-panel", - "label": "InfoPanel", - "path": "/docs/components/InfoPanel.md" - }, - { - "key": "confirm-dialog", - "label": "ConfirmDialog", - "path": "/docs/components/ConfirmDialog.md" - }, - { - "key": "toast", - "label": "Toast", - "path": "/docs/components/Toast.md" - }, - { - "key": "split-layout", - "label": "SplitLayout", - "path": "/docs/components/SplitLayout.md" - }, - { - "key": "extend-info-panel", - "label": "ExtendInfoPanel", - "path": "/docs/components/ExtendInfoPanel.md" - }, - { - "key": "stat-card", - "label": "StatCard", - "path": "/docs/components/StatCard.md" - }, - { - "key": "chart-panel", - "label": "ChartPanel", - "path": "/docs/components/ChartPanel.md" - }, - { - "key": "button-extension", - "label": "ButtonExtension", - "path": "/docs/components/ButtonExtension.md" - } - ] - } -] diff --git a/frontend/src/data/headerMenuData.json b/frontend/src/data/headerMenuData.json deleted file mode 100644 index f1c6e25..0000000 --- a/frontend/src/data/headerMenuData.json +++ /dev/null @@ -1,8 +0,0 @@ -[ - { - "type": "link", - "label": "支持", - "key": "support", - "icon": "CustomerServiceOutlined" - } -] diff --git a/frontend/src/data/menuData.json b/frontend/src/data/menuData.json deleted file mode 100644 index cc33e5b..0000000 --- a/frontend/src/data/menuData.json +++ /dev/null @@ -1,17 +0,0 @@ -[ - { - "key": "docs", - "label": "文档", - "icon": "FileTextOutlined" - }, - { - "key": "help", - "label": "帮助", - "icon": "QuestionCircleOutlined" - }, - { - "key": "support", - "label": "支持", - "icon": "CustomerServiceOutlined" - } -] diff --git a/frontend/src/index.css b/frontend/src/index.css index 9cf4a4e..8b4f74e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,50 +1,10 @@ -:root { - --bg-color: #fff; - --bg-color-secondary: #fafafa; - --text-color: #000000e0; - --text-color-secondary: #00000073; - --border-color: #d9dde3; - --border-color-strong: #c4cad3; - --header-bg: #fff; - --sider-bg: #fff; - --item-hover-bg: #f5f5f5; - --card-bg: #fff; +@import './styles/design-tokens.css'; - /* Markdown & Editor Specific */ - --code-bg: #f6f8fa; - --table-border-color: #cfd6de; - --table-header-bg: #f6f8fa; - --blockquote-border-color: #cfd6de; - --blockquote-text-color: #6a737d; - --toolbar-bg: #fafafa; - --link-color: #1677ff; - --panel-shadow: 0 6px 20px rgba(15, 23, 42, 0.06); - --selected-bg: rgba(22, 119, 255, 0.08); -} - -body.dark { - --bg-color: #141414; - --bg-color-secondary: #1f1f1f; - --text-color: #ffffffd9; - --text-color-secondary: #ffffff73; - --border-color: #454545; - --border-color-strong: #5a5a5a; - --header-bg: #141414; - --sider-bg: #141414; - --item-hover-bg: #1f1f1f; - --card-bg: #1f1f1f; - - /* Markdown & Editor Specific Dark */ - --code-bg: #2d2d2d; - --table-border-color: #4d4d4d; - --table-header-bg: #1f1f1f; - --blockquote-border-color: #4d4d4d; - --blockquote-text-color: #8b949e; - --toolbar-bg: #1f1f1f; - --link-color: #177ddc; - --panel-shadow: 0 10px 24px rgba(0, 0, 0, 0.22); - --selected-bg: rgba(23, 125, 220, 0.18); -} +/* + * 历史页面里大量使用了 --bg-color / --border-color / --panel-shadow 等变量, + * 这些变量现在统一由 styles/design-tokens.css 提供(含 body.dark 覆盖), + * 此处不再重复定义;新增样式请直接使用 design-tokens.css 中的令牌。 + */ body { background-color: var(--bg-color); @@ -54,26 +14,26 @@ body { .document-workspace-frame { background: var(--card-bg); border: 1px solid var(--border-color); - border-radius: 18px; + border-radius: var(--radius-xl); box-shadow: var(--panel-shadow); overflow: hidden; } .search-highlight { - background-color: #ffd54f !important; - color: black !important; + background-color: var(--highlight-bg) !important; + color: var(--text-color) !important; font-weight: bold; padding: 0 2px; - border-radius: 2px; + border-radius: var(--radius-xs); } /* 知识库引用跳转定位(mark.js 生成),样式与搜索高亮保持一致 */ .citation-highlight { - background-color: #ffd54f !important; - color: black !important; + background-color: var(--highlight-bg) !important; + color: var(--text-color) !important; font-weight: bold; padding: 0 2px; - border-radius: 2px; + border-radius: var(--radius-xs); } /* 从知识库引用跳转定位到的段落,短暂强调后淡出 */ @@ -87,12 +47,12 @@ body { @keyframes cited-flash { 0%, 30% { - background-color: #ff9800 !important; - box-shadow: 0 0 0 4px rgba(255, 152, 0, 0.35); + background-color: var(--highlight-flash-color) !important; + box-shadow: 0 0 0 4px color-mix(in srgb, var(--highlight-flash-color) 35%, transparent); } 100% { - background-color: #ffd54f !important; - box-shadow: 0 0 0 0 rgba(255, 152, 0, 0); + background-color: var(--highlight-bg) !important; + box-shadow: 0 0 0 0 transparent; } } @@ -158,3 +118,73 @@ body.dark .hljs-deletion { color: #ffdcd7; background-color: #67060c; } + +/* --------------------------------------------------------------------------- + * 全站通用布局/交互工具类 + * 目标:让各页面的容器、面板、辅助文本保持一致的间距、圆角与颜色。 + * ------------------------------------------------------------------------ */ + +.page-container { + padding: var(--space-lg); + max-width: var(--page-max-width); + margin: 0 auto; +} + +/* + * 页面标题栏请使用组件 @/components/PageHeader/PageHeader, + * 这里只提供容器级别的通用类,避免与组件样式重名。 + */ + +.panel { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-1); +} + +.panel-padded { + padding: var(--space-lg); +} + +.text-secondary { + color: var(--text-color-secondary); +} + +.text-tertiary { + color: var(--text-color-tertiary); +} + +/* 统一的可点击元素焦点可见性,满足键盘操作与无障碍要求 */ +:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 1px; +} + +/* 统一滚动条样式(Webkit),避免出现多种粗细/颜色 */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-thumb { + background: var(--border-color-strong); + border-radius: var(--radius-pill); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-color-tertiary); +} + +::-webkit-scrollbar-track { + background: transparent; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} diff --git a/frontend/src/pages/Chat/Chat.css b/frontend/src/pages/Chat/Chat.css index 7765bcd..a9dfe4e 100644 --- a/frontend/src/pages/Chat/Chat.css +++ b/frontend/src/pages/Chat/Chat.css @@ -43,7 +43,7 @@ } .chat-action-entry:hover { - color: #1677ff; + color: var(--color-primary); } .chat-action-entry span { @@ -77,12 +77,12 @@ } .chat-session-item:hover { - border-color: #1677ff; + border-color: var(--color-primary); } .chat-session-item.active { - border-color: #1677ff; - background: color-mix(in srgb, #1677ff 10%, var(--bg-color-secondary)); + border-color: var(--color-primary); + background: color-mix(in srgb, var(--color-primary) 10%, var(--bg-color-secondary)); } .chat-session-item-top { @@ -312,7 +312,7 @@ } .chat-markdown a { - color: #1677ff; + color: var(--color-primary); text-decoration: none; } @@ -391,7 +391,7 @@ font-size: 0.6em; line-height: 0; vertical-align: super; - color: #1677ff; + color: var(--color-primary); font-weight: 600; margin: 0 2px; padding: 0; @@ -409,7 +409,7 @@ height: 1.05em; margin-left: 2px; vertical-align: text-bottom; - background: #1677ff; + background: var(--color-primary); border-radius: 1px; animation: chat-caret-blink 1s steps(2, start) infinite; } @@ -485,7 +485,7 @@ } .chat-thinking-toggle:hover { - color: #1677ff; + color: var(--color-primary); } .chat-thinking-chevron { @@ -552,8 +552,8 @@ } .chat-message-action.danger:hover { - background: color-mix(in srgb, #ff4d4f 14%, transparent); - color: #ff4d4f; + background: color-mix(in srgb, var(--color-danger) 14%, transparent); + color: var(--color-danger); } .chat-plain-text { @@ -607,12 +607,12 @@ } .chat-reference-chip:hover { - border-color: color-mix(in srgb, #1677ff 45%, var(--border-color)); - background: color-mix(in srgb, #1677ff 8%, transparent); + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--border-color)); + background: color-mix(in srgb, var(--color-primary) 8%, transparent); } .chat-reference-number { - color: #1677ff; + color: var(--color-primary); font-weight: 600; font-size: 11px; flex-shrink: 0; @@ -633,7 +633,7 @@ .chat-message-error-inline { margin-top: 10px; padding-top: 10px; - border-top: 1px dashed color-mix(in srgb, #ff4d4f 35%, transparent); + border-top: 1px dashed color-mix(in srgb, var(--color-danger) 35%, transparent); font-size: 13px; } @@ -682,7 +682,7 @@ font-size: 13px; line-height: 1.7; color: var(--text-color); - background: color-mix(in srgb, #1677ff 8%, transparent); + background: color-mix(in srgb, var(--color-primary) 8%, transparent); border-radius: 6px; padding: 6px 10px; } @@ -691,12 +691,12 @@ font-size: 13px; line-height: 1.7; color: var(--text-color); - border-left: 3px solid #52c41a; + border-left: 3px solid var(--color-success); padding-left: 10px; } .chat-citation-quote-open { - color: #1677ff; + color: var(--color-primary); font-size: 13px; cursor: pointer; margin-left: 6px; @@ -735,8 +735,8 @@ .chat-citation-preview-content .chat-chunk-band { border-radius: 3px; - background: color-mix(in srgb, #1677ff 14%, transparent); - box-shadow: 0 0 0 2px color-mix(in srgb, #1677ff 14%, transparent); + background: color-mix(in srgb, var(--color-primary) 14%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 14%, transparent); color: inherit; } @@ -915,14 +915,14 @@ } .chat-send-button:not(:disabled):hover { - background: #4096ff !important; - border-color: #4096ff !important; + background: var(--color-primary-hover) !important; + border-color: var(--color-primary-hover) !important; color: #fff !important; } .chat-send-button:not(:disabled) { - background: #1677ff !important; - border-color: #1677ff !important; + background: var(--color-primary) !important; + border-color: var(--color-primary) !important; color: #fff !important; } @@ -939,7 +939,7 @@ min-width: 32px !important; border: 0 !important; border-radius: 50% !important; - background: #ff4d4f !important; + background: var(--color-danger) !important; color: #fff !important; font-size: 14px !important; line-height: 1 !important; @@ -992,8 +992,8 @@ body.dark .chat-search-highlight { } body.dark .chat-citation-preview-content .chat-chunk-band { - background: color-mix(in srgb, #1677ff 26%, transparent); - box-shadow: 0 0 0 2px color-mix(in srgb, #1677ff 26%, transparent); + background: color-mix(in srgb, var(--color-primary) 26%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 26%, transparent); } body.dark .chat-composer-shell { @@ -1007,14 +1007,14 @@ body.dark .chat-send-button { } body.dark .chat-send-button:not(:disabled) { - background: #1668dc !important; - border-color: #1668dc !important; + background: var(--color-primary-active) !important; + border-color: var(--color-primary-active) !important; color: #fff !important; } body.dark .chat-send-button:not(:disabled):hover { - background: #4096ff !important; - border-color: #4096ff !important; + background: var(--color-primary-hover) !important; + border-color: var(--color-primary-hover) !important; } body.dark .chat-send-button:disabled { @@ -1028,7 +1028,7 @@ body.dark .chat-stop-button { } body.dark .chat-stop-button:hover { - background: #ff4d4f !important; + background: var(--color-danger) !important; } body.dark .chat-message-error { diff --git a/frontend/src/pages/Chat/Chat.jsx b/frontend/src/pages/Chat/Chat.jsx index 42db198..6b47b1c 100644 --- a/frontend/src/pages/Chat/Chat.jsx +++ b/frontend/src/pages/Chat/Chat.jsx @@ -219,7 +219,7 @@ function formatDateGroup(value) { function stripMarkdown(text) { return (text || '') .replace(/```[\s\S]*?```/g, ' ') - .replace(/[#>*_`~\-\[\]()]/g, ' ') + .replace(/[#>*_`~[\]()/]/g, ' ') .replace(/\s+/g, ' ') .trim() } @@ -918,7 +918,7 @@ function Chat() { markMessageInterrupted(Number(stoppedId)).catch(() => {}) } else { // ids 事件尚未到达(例如在检索阶段就点了停止):先从服务端解析真实 id - ;(async () => { + void (async () => { try { const res = await getChatMessages(session.session_id) const freshMessages = res.data || [] @@ -968,7 +968,7 @@ function Chat() { setMessages((prev) => prev.filter((m) => m.id !== item.id)) return } - Modal.confirm({ + Toast.confirm({ title: '删除消息', content: '确定删除这条消息吗?删除后无法恢复。', okText: '删除', @@ -1060,7 +1060,7 @@ function Chat() { } const handleDeleteSession = (session) => { - Modal.confirm({ + Toast.confirm({ title: '删除对话', content: `确定删除「${session.title}」吗?删除后无法恢复。`, okText: '删除', diff --git a/frontend/src/pages/Constructing.jsx b/frontend/src/pages/Constructing.jsx index 4be8a95..ed47cd2 100644 --- a/frontend/src/pages/Constructing.jsx +++ b/frontend/src/pages/Constructing.jsx @@ -15,7 +15,7 @@ function Constructing() { padding: '50px' }}> } + icon={} title="功能开发中" subTitle="该功能正在紧张开发中,敬请期待..." extra={ diff --git a/frontend/src/pages/Desktop.css b/frontend/src/pages/Desktop.css index 4614b4d..2657b8f 100644 --- a/frontend/src/pages/Desktop.css +++ b/frontend/src/pages/Desktop.css @@ -60,7 +60,7 @@ /* 可点击的活动项 */ .activity-item-clickable:hover { - background-color: #e6f7ff; + background-color: var(--color-primary-bg); padding-left: 12px; padding-right: 12px; border-radius: 4px; @@ -68,7 +68,7 @@ } .activity-item-clickable:active { - background-color: #bae7ff; + background-color: var(--color-primary-border); } /* 不可点击的活动项(已失效) */ diff --git a/frontend/src/pages/Desktop.jsx b/frontend/src/pages/Desktop.jsx index ae67deb..c7a8e4b 100644 --- a/frontend/src/pages/Desktop.jsx +++ b/frontend/src/pages/Desktop.jsx @@ -66,7 +66,7 @@ function Desktop() { { + try { + window.localStorage.setItem(EDITOR_VIEW_MODE_STORAGE_KEY, editorViewMode) + } catch (error) { + // localStorage 不可用时忽略,不影响编辑 + } + }, [editorViewMode]) + + // ByteMD 工具栏由 Svelte 渲染,页面拿不到插槽,因此在挂载后定位它的右侧容器, + // 再用 Portal 把「编辑 / 分栏 / 预览」塞进去,与内置的「全屏」按钮同排。 + // 注意:Svelte 会在 StrictMode 双挂载、切换文档等时机整体重建编辑器 DOM, + // 所以插槽只能「持续校验」而不能「找到就撒手」,否则 Portal 会渲染到已脱离文档的节点上。 + const [toolbarRightSlot, setToolbarRightSlot] = useState(null) + const toolbarRightRef = useRef(null) + + useEffect(() => { + const pane = editorPaneRef.current + // PDF 与超大文档不使用 ByteMD,没有工具栏插槽 + if (!pane || !selectedFile || isPdfSelected || isLargeMarkdown) { + toolbarRightRef.current = null + setToolbarRightSlot(null) + return undefined + } + + let frame = 0 + const sync = () => { + const cached = toolbarRightRef.current + // 容器仍在文档里就什么都不做:编辑时 DOM 变动频繁,这里必须足够便宜 + if (cached && cached.isConnected && pane.contains(cached)) return + const slot = pane.querySelector('.bytemd-toolbar-right') || null + if (slot === cached) return + toolbarRightRef.current = slot + setToolbarRightSlot(slot) + } + + sync() + const observer = new MutationObserver(() => { + // 合并到下一帧校验,避免逐条 mutation 都去查询 DOM + if (frame) return + frame = window.requestAnimationFrame(() => { + frame = 0 + sync() + }) + }) + observer.observe(pane, { childList: true, subtree: true }) + return () => { + observer.disconnect() + if (frame) window.cancelAnimationFrame(frame) + } + }, [selectedFile, isPdfSelected, isLargeMarkdown]) + + // 编辑器全屏时 ByteMD 会给根节点加 .bytemd-fullscreen(覆盖整个视口), + // 此时补一个 Esc 退出,避免用户被困在全屏里找不到出口。 + const [isEditorFullscreen, setIsEditorFullscreen] = useState(false) + // 全屏是 ByteMD 的内部状态,退出只能回放点击,因此记下进入全屏时被点的图标 + const fullscreenToggleRef = useRef(null) + + useEffect(() => { + const pane = editorPaneRef.current + if (!pane) return undefined + + const read = () => Boolean(pane.querySelector('.bytemd-fullscreen')) + setIsEditorFullscreen(read()) + const observer = new MutationObserver(() => setIsEditorFullscreen(read())) + observer.observe(pane, { attributes: true, attributeFilter: ['class'], subtree: true }) + // 记下进入全屏的图标,退出时回放点击 + const onClick = (event) => { + const icon = event.target?.closest?.('.bytemd-toolbar-icon') + if (!icon) return + window.requestAnimationFrame(() => { + if (pane.querySelector('.bytemd-fullscreen')) fullscreenToggleRef.current = icon + }) + } + pane.addEventListener('click', onClick, true) + return () => { + observer.disconnect() + pane.removeEventListener('click', onClick, true) + } + }, [selectedFile, isLargeMarkdown, isPdfSelected]) + + useEffect(() => { + fullscreenToggleRef.current = null + }, [selectedFile]) + + useEffect(() => { + if (!isEditorFullscreen) return undefined + const onKeyDown = (event) => { + if (event.key !== 'Escape') return + // 自动补全等浮层打开时,Esc 先交给浮层处理 + if (document.querySelector('.CodeMirror-hints, .bytemd-dropdown')) return + const toggle = + fullscreenToggleRef.current || + document.querySelector('.bytemd-fullscreen .bytemd-toolbar-right .bytemd-toolbar-icon:nth-child(5)') + toggle?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + } + window.addEventListener('keydown', onKeyDown, true) + return () => window.removeEventListener('keydown', onKeyDown, true) + }, [isEditorFullscreen]) const linkTreeData = useMemo(() => { const markSelectable = (nodes) => nodes.map((node) => { @@ -107,21 +224,41 @@ function DocumentEditor() { return markSelectable(treeData) }, [treeData]) - // 当前文档标题列表(页内链接用) - const pageHeadings = useMemo(() => { - const slugger = new GithubSlugger() - const headings = [] - if (fileContent) { - fileContent.split('\n').forEach((line) => { - const match = line.match(/^(#{1,6})\s+(.+)$/) - if (!match) return - const level = match[1].length - const title = match[2].trim() - headings.push({ title, level, slug: slugger.slug(title) }) - }) + // 当前文档标题列表:仅依赖 Markdown 源码,编辑区/预览区/页内链接共用同一份大纲 + const pageHeadings = useMemo(() => extractMarkdownHeadings(fileContent), [fileContent]) + + // 大纲跳转:编辑区(含分栏)定位到源码行;仅预览时定位到渲染出的标题 + const handleTocNavigate = (href) => { + const container = editorPaneRef.current + if (!container || !href) return + + if (editorViewMode !== 'preview') { + const heading = pageHeadings.find((item) => item.href === href) + if (!heading) return + const cmEl = container.querySelector('.CodeMirror') + const cm = cmEl?.CodeMirror + if (cm) { + cm.setCursor({ line: heading.line, ch: 0 }, { scroll: false }) + cm.focus() + // 让目标标题落在编辑区顶部,和预览区跳转的阅读体验保持一致 + cm.scrollIntoView({ line: Math.max(0, heading.line - 1), ch: 0 }, 'start') + } + return } - return headings - }, [fileContent]) + + const preview = container.querySelector('.bytemd-preview') || container + const target = preview.querySelector(`#${window.CSS?.escape ? window.CSS.escape(href.slice(1)) : href.slice(1)}`) + target?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + + // 视图模式切换会改变编辑区宽度,需要让 CodeMirror 重新测量,避免行高/换行错位 + useEffect(() => { + const frame = window.requestAnimationFrame(() => { + const cm = editorPaneRef.current?.querySelector('.CodeMirror')?.CodeMirror + cm?.refresh() + }) + return () => window.cancelAnimationFrame(frame) + }, [editorViewMode]) const navigateWithTransition = (to) => { if (document.startViewTransition) { @@ -421,10 +558,10 @@ function DocumentEditor() { Toast.warning('提示', '请选择页内标题') return } - const heading = pageHeadings.find((h) => h.slug === linkHeading) + const heading = pageHeadings.find((h) => h.key === linkHeading) if (!heading) return const linkTitle = editor.getSelection() || heading.title - linkText = `[${linkTitle}](#${heading.slug})` + linkText = `[${linkTitle}](${heading.href})` } else { if (!linkTarget) { Toast.warning('提示', '请选择文件') @@ -463,47 +600,29 @@ function DocumentEditor() { } } + // 是否存在未保存改动(PDF 为只读预览,不参与判定) + const isDirty = Boolean(selectedFile) && !isPdfSelected && fileContent !== contentBaseline + + const goBackToProjects = () => { + navigate(userRole === 'owner' ? '/projects/my' : '/projects/share') + } + const handleClose = () => { - Modal.confirm({ + if (!isDirty) { + goBackToProjects() + return + } + Toast.confirm({ title: '确认退出', - content: '确定要退出编辑页面吗?未保存的修改可能会丢失。', - okText: '退出', - cancelText: '取消', - onOk: () => { - if (userRole === 'owner') { - navigate('/projects/my') - } else { - navigate('/projects/share') - } - }, + content: '当前文档有未保存的修改,确定要退出编辑页面吗?', + okText: '放弃修改并退出', + cancelText: '继续编辑', + onOk: goBackToProjects, }) } - // 查找树节点的辅助函数 - const findNodeByKey = (nodes, key) => { - for (const node of nodes) { - if (node.key === key) { - return node - } - if (node.children) { - const found = findNodeByKey(node.children, key) - if (found) return found - } - } - return null - } - const expandParentFolders = (path) => { - const parts = path.split('/') - if (parts.length <= 1) return - - const parentKeys = [] - let currentPath = '' - for (let i = 0; i < parts.length - 1; i++) { - currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i] - parentKeys.push(currentPath) - } - setOpenKeys(prev => Array.from(new Set([...prev, ...parentKeys]))) + setOpenKeys((prev) => mergeParentKeys(prev, path)) } const openNodeByKey = async (key, node = null, syncUrl = false) => { @@ -536,6 +655,7 @@ function DocumentEditor() { setSelectedFile(key) setIsPdfSelected(true) setFileContent('') + setContentBaseline('') setPdfUrl(buildDocumentUrl(key)) setPdfFilename(key.split('/').pop()) setSelectedFolderKey(null) @@ -551,6 +671,7 @@ function DocumentEditor() { const res = await getFileContent(projectId, key) setSelectedFile(key) setFileContent(res.data.content) + setContentBaseline(res.data.content) } catch (error) { Toast.error('加载失败', '加载文件失败') } finally { @@ -598,7 +719,7 @@ function DocumentEditor() { const handleReset = () => { if (!selectedFile) return - Modal.confirm({ + Toast.confirm({ title: '确认重置', content: '确定要重置当前修改吗?所有未保存的更改都将丢失。', onOk: async () => { @@ -606,6 +727,7 @@ function DocumentEditor() { try { const res = await getFileContent(projectId, selectedFile) setFileContent(res.data.content) + setContentBaseline(res.data.content) Toast.success('重置成功', '已恢复至最后保存的版本') } catch (error) { Toast.error('重置失败', '无法重新加载文件内容') @@ -621,13 +743,19 @@ function DocumentEditor() { Toast.warning('提示', '请先选择文件') return } + if (saving) return + // 保存请求进行中的后续编辑不会被计入本次保存,成功后按快照重置基线 + const snapshot = fileContent setSaving(true) try { await saveFile(projectId, { path: selectedFile, - content: fileContent, + content: snapshot, }) + if (snapshot === fileContent) { + setContentBaseline(snapshot) + } Toast.success('成功', '保存成功') } catch (error) { // 错误已通过request interceptor处理 @@ -636,6 +764,35 @@ function DocumentEditor() { } } + // 保存动作的最新闭包:供全局快捷键使用,避免频繁重新订阅监听器 + const saveFileRef = useRef(null) + useEffect(() => { + saveFileRef.current = handleSaveFile + }) + + // 存在未保存改动时,关闭标签页/刷新前给出浏览器级提示 + useEffect(() => { + if (!isDirty) return undefined + const handleBeforeUnload = (event) => { + event.preventDefault() + event.returnValue = '' + } + window.addEventListener('beforeunload', handleBeforeUnload) + return () => window.removeEventListener('beforeunload', handleBeforeUnload) + }, [isDirty]) + + // Ctrl/Cmd + S 保存当前文档(弹窗打开、加载或保存中时不拦截) + useEffect(() => { + const handleKeyDown = (event) => { + if (!(event.metaKey || event.ctrlKey) || String(event.key).toLowerCase() !== 's') return + if (!selectedFile || isPdfSelected || saving || loading || modalVisible || moveModalVisible || linkModalVisible) return + event.preventDefault() + saveFileRef.current?.() + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [selectedFile, isPdfSelected, saving, loading, modalVisible, moveModalVisible, linkModalVisible]) + const getParentPath = (node) => { if (!node) return '' if (!node.isLeaf) return node.key @@ -670,7 +827,7 @@ function DocumentEditor() { return } - Modal.confirm({ + Toast.confirm({ title: '确认删除', content: `确定要删除 ${path} 吗?`, onOk: async () => { @@ -742,7 +899,7 @@ function DocumentEditor() { // 检查文件是否已存在 const fileExists = checkFileExists(path) if (fileExists) { - Modal.confirm({ + Toast.confirm({ title: '文件已存在', content: `文件 "${finalName}" 已存在,是否覆盖?`, okText: '覆盖', @@ -852,7 +1009,7 @@ function DocumentEditor() { // 如果有重名文件,询问是否覆盖 if (existingFiles.length > 0) { - Modal.confirm({ + Toast.confirm({ title: '文件已存在', content: (
@@ -1281,16 +1438,23 @@ function DocumentEditor() { // PDF 只读预览,不提供保存/重置(工具栏由 VirtualPDFViewer 注入)
) : selectedFile ? ( - - + + {isDirty && ( + + 未保存 + + )} + + +